配置好之后,檢查一下 AudioRecord 當(dāng)前的狀態(tài)是否可以進(jìn)行錄制,可以通過 AudioRecord##getState 來獲取當(dāng)前的狀態(tài):
- STATE_UNINITIALIZED 還沒有初始化,或者初始化失敗了
- STATE_INITIALIZED 已經(jīng)初始化成功了。
2. 開啟采集
創(chuàng)建好 AudioRecord 之后,就可以開啟音頻數(shù)據(jù)的采集了,可以通過調(diào)用下面的函數(shù)進(jìn)行控制麥克風(fēng)的采集:
mAudioRecord.startRecording();
3. 提取數(shù)據(jù)
執(zhí)行完上一步之后,需要開啟一個子線程用于不斷的從 AudioRecord 緩沖區(qū)讀取 PCM 數(shù)據(jù),調(diào)用如下函數(shù)進(jìn)行讀取數(shù)據(jù):
int read(@NonNull byte[] audioData, int offsetInBytes, int sizeInBytes);
4. 停止采集
如果想要停止采集,那么只需要調(diào)用 AudioRecord 的 stop 方法來實(shí)現(xiàn),最后可以通過一個變量先控制子線程停止讀取數(shù)據(jù),然后在調(diào)用 stop 停止最后釋放 AudioRecord 實(shí)例。
public void stopEncode() {
//停止的變量標(biāo)記
mStopFlag = true;
if(mAudioEncoder != null) {
//停止采集
mAudioEncoder.stop();
//釋放內(nèi)存
mAudioEncoder = null;
}
}
視頻采集
視頻畫面的采集主要是使用各個平臺提供的攝像頭 API 來實(shí)現(xiàn)的,在為攝像頭設(shè)置了合適的參數(shù)之后,將攝像頭實(shí)時采集的視頻幀渲染到屏幕上提供給用戶預(yù)覽,然后將該視頻幀傳遞給編碼通道,進(jìn)行編碼。
1. 權(quán)限配置
2. 打開攝像頭
2.1 檢查攝像頭
public static void checkCameraService(Context context)
throws CameraDisabledException {
// Check if device policy has disabled the camera.
DevicePolicyManager dpm = (DevicePolicyManager) context.getSystemService(
Context.DEVICE_POLICY_SERVICE);
if (dpm.getCameraDisabled(null)) {
throw new CameraDisabledException();
}
}
2.2 檢查攝像頭的個數(shù)
檢查完攝像頭服務(wù)后,還需要檢查手機(jī)上攝像頭的個數(shù),如果個數(shù)為 0,則說明手機(jī)上沒有攝像頭,這樣的話也是不能進(jìn)行后續(xù)操作的。
public static List getAllCamerasData(boolean isBackFirst) {
ArrayList cameraDatas = new ArrayList<>();
Camera.CameraInfo cameraInfo = new Camera.CameraInfo();
int numberOfCameras = Camera.getNumberOfCameras();
for (int i = 0; i < numberOfCameras; i++) {
Camera.getCameraInfo(i, cameraInfo);
if (cameraInfo.facing == Camera.CameraInfo.CAMERA_FACING_FRONT) {
CameraData cameraData = new CameraData(i, CameraData.FACING_FRONT);
if(isBackFirst) {
cameraDatas.add(cameraData);
} else {
cameraDatas.add(0, cameraData);
}
} else if (cameraInfo.facing == Camera.CameraInfo.CAMERA_FACING_BACK) {
CameraData cameraData = new CameraData(i, CameraData.FACING_BACK);
if(isBackFirst) {
cameraDatas.add(0, cameraData);
} else {
cameraDatas.add(cameraData);
}
}
}
return cameraDatas;
}
在上面的方法中,需要傳入一個是否先開啟背面攝像頭的 boolean 變量,如果變量為 true,則把背面攝像頭放在列表第一個,之后打開攝像頭的時候,直接獲取列表中第一個攝像頭相關(guān)參數(shù),然后進(jìn)行打開。這樣的設(shè)計(jì)使得切換攝像頭也變得十分簡單,切換攝像頭時,先關(guān)閉當(dāng)前攝像頭,然后變化攝像頭列表中的順序,然后再打開攝像頭即可,也就是每次打開攝像頭都打開攝像頭列表中第一個攝像頭參數(shù)所指向的攝像頭。
2.3 打開攝像頭
打開攝像頭之前,先從攝像頭列表中獲取第一個攝像頭參數(shù),之后根據(jù)參數(shù)中的 CameraId 來打開攝像頭,打開成功后改變相關(guān)狀態(tài)。相關(guān)代碼如下:
public synchronized Camera openCamera()
throws CameraHardwareException, CameraNotSupportException {
CameraData cameraData = mCameraDatas.get(0);
if(mCameraDevice != null && mCameraData == cameraData) {
return mCameraDevice;
}
if (mCameraDevice != null) {
releaseCamera();
}
try {
Log.d(TAG, "open camera " + cameraData.cameraID);
mCameraDevice = Camera.open(cameraData.cameraID);
} catch (RuntimeException e) {
Log.e(TAG, “fail to connect Camera”);
throw new CameraHardwareException(e);
}
if(mCameraDevice == null) {
throw new CameraNotSupportException();
}
mCameraData = cameraData;
mState = State.OPENED;
return mCameraDevice;
}
上面需要注意的是,在 Android 提供的 Camera 源碼中,Camera.open(cameraData.cameraID) 拋出異常則說明Camera 不可用,否則說明 Camera 可用,但是在一些手機(jī)上 Camera.open(cameraData.cameraID) 不是拋出異常,而是返回 null。
3. 配置攝像頭參數(shù)
在給攝像頭設(shè)置參數(shù)后,需要記錄這些參數(shù),以方便其他地方使用。比如記錄當(dāng)前攝像頭是否有閃光點(diǎn),從而可以決定 UI 界面上是否顯示打開閃光燈按鈕。在直播項(xiàng)目中使用 CameraData 來記錄這些參數(shù),CameraData 類如下所示:
public class CameraData {
public static final int FACING_FRONT = 1;
public static final int FACING_BACK = 2;
public int cameraID; //camera的id
public int cameraFacing; //區(qū)分前后攝像頭
public int cameraWidth; //camera的采集寬度
public int cameraHeight; //camera的采集高度
public boolean hasLight; //camera是否有閃光燈
public int orientation; //camera旋轉(zhuǎn)角度
public boolean supportTouchFocus; //camera是否支持手動對焦
public boolean touchFocusMode; //camera是否處在自動對焦模式
public CameraData(int id, int facing, int width, int height){
cameraID = id;
cameraFacing = facing;
cameraWidth = width;
cameraHeight = height;
}
public CameraData(int id, int facing) {
cameraID = id;
cameraFacing = facing;
}
}
給攝像頭設(shè)置參數(shù)的時候,有一點(diǎn)需要注意:設(shè)置的參數(shù)不生效會拋出異常,因此需要每個參數(shù)單獨(dú)設(shè)置,這樣就避免一個參數(shù)不生效后拋出異常,導(dǎo)致之后所有的參數(shù)都沒有設(shè)置。
4. 攝像頭開啟預(yù)覽
設(shè)置預(yù)覽界面有兩種方式:1、通過 SurfaceView 顯示;2、通過 GLSurfaceView 顯示。當(dāng)為 SurfaceView 顯示時,需要傳給 Camera 這個 SurfaceView 的 SurfaceHolder。當(dāng)使用 GLSurfaceView 顯示時,需要使用Renderer 進(jìn)行渲染,先通過 OpenGL 生成紋理,通過生成紋理的紋理 id 生成 SurfaceTexture ,將SurfaceTexture 交給 Camera ,那么在 Render 中便可以使用這個紋理進(jìn)行相應(yīng)的渲染,最后通過GLSurfaceView 顯示。
4.1 設(shè)置預(yù)覽回調(diào)
public static void setPreviewFormat(Camera camera, Camera.Parameters parameters) {
//設(shè)置預(yù)覽回調(diào)的圖片格式
try {
parameters.setPreviewFormat(ImageFormat.NV21);
camera.setParameters(parameters);
} catch (Exception e) {
e.printStackTrace();
}
}
當(dāng)設(shè)置預(yù)覽好預(yù)覽回調(diào)的圖片格式后,需要設(shè)置預(yù)覽回調(diào)的 Callback。
Camera.PreviewCallback myCallback = new Camera.PreviewCallback() {
@Override
public void onPreviewFrame(byte[] data, Camera camera) {
//得到相應(yīng)的圖片數(shù)據(jù)
//Do something
}
};
public static void setPreviewCallback(Camera camera, Camera.PreviewCallback callback) {
camera.setPreviewCallback(callback);
}
Android 推薦的 PreViewFormat 是 NV21,在 PreviewCallback 中會返回 Preview 的 N21 圖片。如果是軟編的話,由于 H264 支持 I420 的圖片格式,因此需要將 N21格式轉(zhuǎn)為 I420 格式,然后交給 x264 編碼庫。如果是硬編的話,由于 Android 硬編編碼器支持 I420(COLOR_FormatYUV420Planar) 和NV12(COLOR_FormatYUV420SemiPlanar),因此可以將 N21 的圖片轉(zhuǎn)為 I420 或者 NV12 ,然后交給硬編編碼器。
4.2 設(shè)置預(yù)覽圖像大小
在攝像頭相關(guān)處理中,一個比較重要的是 屏幕顯示大小和攝像頭預(yù)覽大小比例不一致 的處理。在 Android 中,攝像頭有一系列的 PreviewSize,我們需要從中選出適合的 PreviewSize 。選擇合適的攝像頭 PreviewSize 的代碼如下所示:
public static Camera.Size getOptimalPreviewSize(Camera camera, int width, int height) {
Camera.Size optimalSize = null;
double minHeightDiff = Double.MAX_VALUE;
double minWidthDiff = Double.MAX_VALUE;
List<Camera.Size> sizes = camera.getParameters().getSupportedPreviewSizes();
if (sizes == null) return null;
//找到寬度差距最小的
for(Camera.Size size:sizes){
if (Math.abs(size.width - width) < minWidthDiff) {
minWidthDiff = Math.abs(size.width - width);
}
}
//在寬度差距最小的里面,找到高度差距最小的
for(Camera.Size size:sizes){
if(Math.abs(size.width - width) == minWidthDiff) {
if(Math.abs(size.height - height) < minHeightDiff) {
optimalSize = size;
minHeightDiff = Math.abs(size.height - height);
}
}
}
return optimalSize;
}
public static void setPreviewSize(Camera camera, Camera.Size size, Camera.Parameters parameters) {
try {
parameters.setPreviewSize(size.width, size.height);
camera.setParameters(parameters);
}
catch (Exception e) {
e.printStackTrace();
}
}
在設(shè)置好最適合的 PreviewSize 之后,將 size 信息存儲在 CameraData 中。當(dāng)選擇了 SurfaceView 顯示的方式,可以將 SurfaceView 放置在一個 LinearLayout 中,然后根據(jù)攝像頭 PreviewSize 的比例改變 SurfaceView 的大小,從而使得兩者比例一致,確保圖像正常。當(dāng)選擇了GLSurfaceView 顯示的時候,可以通過裁剪紋理,使得紋理的大小比例和 GLSurfaceView 的大小比例保持一致,從而確保圖像顯示正常。
4.3 圖像旋轉(zhuǎn)
在 Android 中攝像頭出來的圖像需要進(jìn)行一定的旋轉(zhuǎn),然后才能交給屏幕顯示,而且如果應(yīng)用支持屏幕旋轉(zhuǎn)的話,也需要根據(jù)旋轉(zhuǎn)的狀況實(shí)時調(diào)整攝像頭的角度。在 Android 中旋轉(zhuǎn)攝像頭圖像同樣有兩種方法,一是通過攝像頭的 setDisplayOrientation(result) 方法,一是通過 OpenGL 的矩陣進(jìn)行旋轉(zhuǎn)。下面是通過setDisplayOrientation(result) 方法進(jìn)行旋轉(zhuǎn)的代碼:
public static int getDisplayRotation(Activity activity) {
int rotation = activity.getWindowManager().getDefaultDisplay().getRotation();
switch (rotation) {
case Surface.ROTATION_0: return 0;
case Surface.ROTATION_90: return 90;
case Surface.ROTATION_180: return 180;
case Surface.ROTATION_270: return 270;
}
return 0;
}
public static void setCameraDisplayOrientation(Activity activity, int cameraId, Camera camera) {
// See android.hardware.Camera.setCameraDisplayOrientation for
// documentation.
Camera.CameraInfo info = new Camera.CameraInfo();
Camera.getCameraInfo(cameraId, info);
int degrees = getDisplayRotation(activity);
int result;
if (info.facing == Camera.CameraInfo.CAMERA_FACING_FRONT) {
result = (info.orientation + degrees) % 360;
result = (360 - result) % 360; // compensate the mirror
} else { // back-facing
result = (info.orientation - degrees + 360) % 360;
}
camera.setDisplayOrientation(result);
}
4.4 設(shè)置預(yù)覽幀率
通過 Camera.Parameters 中 getSupportedPreviewFpsRange() 可以獲得攝像頭支持的幀率變化范圍,從中選取合適的設(shè)置給攝像頭即可。相關(guān)的代碼如下:
public static void setCameraFps(Camera camera, int fps) {
Camera.Parameters params = camera.getParameters();
int[] range = adaptPreviewFps(fps, params.getSupportedPreviewFpsRange());
params.setPreviewFpsRange(range[0], range[1]);
camera.setParameters(params);
}
private static int[] adaptPreviewFps(int expectedFps, List<int[]> fpsRanges) {
expectedFps *= 1000;
int[] closestRange = fpsRanges.get(0);
int measure = Math.abs(closestRange[0] - expectedFps) + Math.abs(closestRange[1] - expectedFps);
for (int[] range : fpsRanges) {
if (range[0] <= expectedFps && range[1] >= expectedFps) {
int curMeasure = Math.abs(range[0] - expectedFps) + Math.abs(range[1] - expectedFps);
if (curMeasure < measure) {
closestRange = range;
measure = curMeasure;
}
}
}
return closestRange;
}
4.5 設(shè)置相機(jī)對焦
一般攝像頭對焦的方式有兩種:手動對焦和觸摸對焦。下面的代碼分別是設(shè)置自動對焦和觸摸對焦的模式:
public static void setAutoFocusMode(Camera camera) {
try {
Camera.Parameters parameters = camera.getParameters();
List focusModes = parameters.getSupportedFocusModes();
if (focusModes.size() > 0 && focusModes.contains(Camera.Parameters.FOCUS_MODE_CONTINUOUS_PICTURE)) {
parameters.setFocusMode(Camera.Parameters.FOCUS_MODE_CONTINUOUS_PICTURE);
camera.setParameters(parameters);
} else if (focusModes.size() > 0) {
parameters.setFocusMode(focusModes.get(0));
camera.setParameters(parameters);
}
} catch (Exception e) {
e.printStackTrace();
}
}
public static void setTouchFocusMode(Camera camera) {
try {
Camera.Parameters parameters = camera.getParameters();
List focusModes = parameters.getSupportedFocusModes();
if (focusModes.size() > 0 && focusModes.contains(Camera.Parameters.FOCUS_MODE_AUTO)) {
parameters.setFocusMode(Camera.Parameters.FOCUS_MODE_AUTO);
camera.setParameters(parameters);
} else if (focusModes.size() > 0) {
parameters.setFocusMode(focusModes.get(0));
camera.setParameters(parameters);
}
} catch (Exception e) {
e.printStackTrace();
}
}
對于自動對焦這樣設(shè)置后就完成了工作,但是對于觸摸對焦則需要設(shè)置對應(yīng)的對焦區(qū)域。要準(zhǔn)確地設(shè)置對焦區(qū)域,有三個步驟:一、得到當(dāng)前點(diǎn)擊的坐標(biāo)位置;二、通過點(diǎn)擊的坐標(biāo)位置轉(zhuǎn)換到攝像頭預(yù)覽界面坐標(biāo)系統(tǒng)上的坐標(biāo);三、根據(jù)坐標(biāo)生成對焦區(qū)域并且設(shè)置給攝像頭。整個攝像頭預(yù)覽界面定義了如下的坐標(biāo)系統(tǒng),對焦區(qū)域也需要對應(yīng)到這個坐標(biāo)系統(tǒng)中。
如果攝像機(jī)預(yù)覽界面是通過 SurfaceView 顯示的則比較簡單,由于要確保不變形,會將 SurfaceView 進(jìn)行拉伸,從而使得 SurfaceView 和預(yù)覽圖像大小比例一致,因此整個 SurfaceView 相當(dāng)于預(yù)覽界面,只需要得到當(dāng)前點(diǎn)擊點(diǎn)在整個 SurfaceView 上對應(yīng)的坐標(biāo),然后轉(zhuǎn)化為相應(yīng)的對焦區(qū)域即可。如果攝像機(jī)預(yù)覽界面是通過GLSurfaceView 顯示的則要復(fù)雜一些,由于紋理需要進(jìn)行裁剪,才能使得顯示不變形,這樣的話,我們要還原出整個預(yù)覽界面的大小,然后通過當(dāng)前點(diǎn)擊的位置換算成預(yù)覽界面坐標(biāo)系統(tǒng)上的坐標(biāo),然后得到相應(yīng)的對焦區(qū)域,然后設(shè)置給攝像機(jī)。當(dāng)設(shè)置好對焦區(qū)域后,通過調(diào)用 Camera 的 autoFocus() 方法即可完成觸摸對焦。 整個過程代碼量較多,請自行閱讀項(xiàng)目源碼。
4.6 設(shè)置縮放
當(dāng)檢測到手勢縮放的時候,我們往往希望攝像頭也能進(jìn)行相應(yīng)的縮放,其實(shí)這個實(shí)現(xiàn)還是比較簡單的。首先需要加入縮放的手勢識別,當(dāng)識別到縮放的手勢的時候,根據(jù)縮放的大小來對攝像頭進(jìn)行縮放。代碼如下所示:
/**
- Handles the pinch-to-zoom gesture
*/
private class ZoomGestureListener extends ScaleGestureDetector.SimpleOnScaleGestureListener {
@Override
public boolean onScale(ScaleGestureDetector detector) {
if (!mIsFocusing) {
float progress = 0;
if (detector.getScaleFactor() > 1.0f) {
progress = CameraHolder.instance().cameraZoom(true);
} else if (detector.getScaleFactor() < 1.0f) {
progress = CameraHolder.instance().cameraZoom(false);
} else {
return false;
}
if(mZoomListener != null) {
mZoomListener.onZoomProgress(progress);
}
}
return true;
}
}
public float cameraZoom(boolean isBig) {
if(mState != State.PREVIEW || mCameraDevice == null || mCameraData == null) {
return -1;
}
Camera.Parameters params = mCameraDevice.getParameters();
if(isBig) {
params.setZoom(Math.min(params.getZoom() + 1, params.getMaxZoom()));
} else {
params.setZoom(Math.max(params.getZoom() - 1, 0));
}
mCameraDevice.setParameters(params);
return (float) params.getZoom()/params.getMaxZoom();
}
4.7 閃光燈操作
一個攝像頭可能有相應(yīng)的閃光燈,也可能沒有,因此在使用閃光燈功能的時候先要確認(rèn)是否有相應(yīng)的閃光燈。檢測攝像頭是否有閃光燈的代碼如下:
public static boolean supportFlash(Camera camera){
Camera.Parameters params = camera.getParameters();
List flashModes = params.getSupportedFlashModes();
if(flashModes == null) {
return false;
}
for(String flashMode : flashModes) {
if(Camera.Parameters.FLASH_MODE_TORCH.equals(flashMode)) {
return true;
}
}
return false;
}
切換閃光燈的代碼如下:
public static void switchLight(Camera camera, Camera.Parameters cameraParameters) {
if (cameraParameters.getFlashMode().equals(Camera.Parameters.FLASH_MODE_OFF)) {
cameraParameters.setFlashMode(Camera.Parameters.FLASH_MODE_TORCH);
} else {
cameraParameters.setFlashMode(Camera.Parameters.FLASH_MODE_OFF);
}
try {
camera.setParameters(cameraParameters);
}catch (Exception e) {
e.printStackTrace();
}
}
4.8 開始預(yù)覽
當(dāng)打開了攝像頭,并且設(shè)置好了攝像頭相關(guān)的參數(shù)后,便可以通過調(diào)用 Camera 的 startPreview() 方法開始預(yù)覽。有一個需要說明,無論是 SurfaceView 還是 GLSurfaceView ,都可以設(shè)置 SurfaceHolder.Callback ,當(dāng)界面開始顯示的時候打開攝像頭并且開始預(yù)覽,當(dāng)界面銷毀的時候停止預(yù)覽并且關(guān)閉攝像頭,這樣的話當(dāng)程序退到后臺,其他應(yīng)用也能調(diào)用攝像頭。
private SurfaceHolder.Callback mSurfaceHolderCallback = new SurfaceHolder.Callback() {
@Override
public void surfaceDestroyed(SurfaceHolder holder) {
Log.d(SopCastConstant.TAG, “SurfaceView destroy”);
CameraHolder.instance().stopPreview();
CameraHolder.instance().releaseCamera();
}
@TargetApi(Build.VERSION_CODES.GINGERBREAD)
@Override
public void surfaceCreated(SurfaceHolder holder) {
Log.d(SopCastConstant.TAG, “SurfaceView created”);
}
@Override
public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
Log.d(SopCastConstant.TAG, “SurfaceView width:” + width + " height:" + height);
CameraHolder.instance().openCamera();
CameraHolder.instance().startPreview();
}
};
5. 停止預(yù)覽
停止預(yù)覽只需要釋放掉相機(jī)資源即可:
public synchronized void releaseCamera() {
if (mState == State.PREVIEW) {
stopPreview();
}
if (mState != State.OPENED) {
return;
}
if (mCameraDevice == null) {
return;
}
mCameraDevice.release();
mCameraDevice = null;
mCameraData = null;
mState = State.INIT;
}
音頻編碼
AudioRecord 采集完之后需要對 PCM 數(shù)據(jù)進(jìn)行實(shí)時的編碼 (軟編利用 libfaac 通過 NDK 交叉編譯靜態(tài)庫、硬編使用 Android SDK MediaCodec 進(jìn)行編碼)。
軟編
語音軟編這里們用主流的編碼庫 libfaac 進(jìn)行編碼 AAC 語音格式數(shù)據(jù)。
1. 編譯 libfaac
1.1 下載 libfaac
wget https://sourceforge.net/projects/faac/files/faac-src/faac-1.29/faac-1.29.9.2.tar.gz
1.2 編寫交叉編譯腳本
#!/bin/bash
#打包地址
PREFIX=pwd
/android/armeabi-v7a
#配置NDK 環(huán)境變量
NDK_ROOT=KaTeX parse error: Expected 'EOF', got '#' at position 10: NDK_HOME #?指定 CPU CPU=arm-…NDK_ROOT/toolchains/$CPU-4.9/prebuilt/linux-x86_64
FLAGS=“-isysroot $NDK_ROOT/sysroot -isystem KaTeX parse error: Expected group after '_' at position 54: …-androideabi -D_?_ANDROID_API__=ANDROID_API -U_FILE_OFFSET_BITS -DANDROID -ffunction-sections -funwind-tables -fstack-protector-strong -no-canonical-prefixes -march=armv7-a -mfloat-abi=softfp -mfpu=vfpv3-d16 -mthumb -Wa,–noexecstack -Wformat -Werror=format-security -O0 -fPIC”
CROSS_COMPILE= T O O L C H A I N / b i n / a r m ? l i n u x ? a n d r o i d e a b i e x p o r t C C = " TOOLCHAIN/bin/arm-linux-androideabi export CC=" TOOLCHAIN/bin/arm?linux?androideabiexportCC="CROSS_COMPILE-gcc --sysroot= N D K R O O T / p l a t f o r m s / a n d r o i d ? 17 / a r c h ? a r m " e x p o r t C F L A G S = " NDK_ROOT/platforms/android-17/arch-arm" export CFLAGS=" NDKR?OOT/platforms/android?17/arch?arm"exportCFLAGS="FLAGS"
./configure
–prefix=$PREFIX
–host=arm-linux
–with-pic
–enable-shared=no
make clean
make install
2. CMakeLists.txt 配置
cmake_minimum_required(VERSION 3.4.1)
#語音編碼器
set(faac KaTeX parse error: Expected 'EOF', got '#' at position 26: …RCE_DIR}/faac) #?加載 faac 頭文件目錄 i…{faac}/include)
#指定 faac 靜態(tài)庫文件目錄
set(CMAKE_CXX_FLAGS “
C
M
A
K
E
C
X
X
F
L
A
G
S
?
L
{CMAKE_CXX_FLAGS} -L
CMAKEC?XXF?LAGS?L{faac}/libs/${CMAKE_ANDROID_ARCH_ABI}”)
#批量添加自己編寫的 cpp 文件,不要把 .h 加入進(jìn)來了
file(GLOB Push_CPP ${ykpusher}/.cpp)
#添加自己編寫 cpp 源文件生成動態(tài)庫
add_library(ykpusher SHARED ${Push_CPP})
#找系統(tǒng)中 NDK log庫
find_library(log_lib
log)
#推流 so
target_link_libraries(
#播放 so
ykpusher
# 寫了此命令不用在乎添加 ffmpeg lib 順序問題導(dǎo)致應(yīng)用崩潰
-Wl,–start-group
avcodec avfilter avformat avutil swresample swscale
-Wl,–end-group
z
#推流庫
rtmp
#視頻編碼
x264
#語音編碼
faac
#本地庫
android
${log_lib}
)
3. 配置 faac 編碼參數(shù)
//設(shè)置語音軟編碼參數(shù)
void AudioEncoderChannel::setAudioEncoderInfo(int samplesHZ, int channel) {
//如果已經(jīng)初始化,需要釋放
release();
//通道 默認(rèn)單聲道
mChannels = channel;
//打開編碼器
//3、一次最大能輸入編碼器的樣本數(shù)量 也編碼的數(shù)據(jù)的個數(shù) (一個樣本是16位 2字節(jié))
//4、最大可能的輸出數(shù)據(jù) 編碼后的最大字節(jié)數(shù)
mAudioCodec = faacEncOpen(samplesHZ, channel, &mInputSamples, &mMaxOutputBytes);
if (!mAudioCodec) {
if (mIPushCallback) {
mIPushCallback->onError(THREAD_MAIN, FAAC_ENC_OPEN_ERROR);
}
return;
}
//設(shè)置編碼器參數(shù)
faacEncConfigurationPtr config = faacEncGetCurrentConfiguration(mAudioCodec);
//指定為 mpeg4 標(biāo)準(zhǔn)
config->mpegVersion = MPEG4;
//lc 標(biāo)準(zhǔn)
config->aacObjectType = LOW;
//16位
config->inputFormat = FAAC_INPUT_16BIT;
// 編碼出原始數(shù)據(jù) 既不是adts也不是adif
config->outputFormat = 0;
faacEncSetConfiguration(mAudioCodec, config);
//輸出緩沖區(qū) 編碼后的數(shù)據(jù) 用這個緩沖區(qū)來保存
mBuffer = new u_char[mMaxOutputBytes];
//設(shè)置一個標(biāo)志,用于開啟編碼
isStart = true;
}
4. 配置 AAC 包頭
在發(fā)送 rtmp 音視頻包的時候需要將語音包頭第一個發(fā)送
/**
- 音頻頭包數(shù)據(jù)
- @return
*/
RTMPPacket *AudioEncoderChannel::getAudioTag() {
if (!mAudioCodec) {
setAudioEncoderInfo(FAAC_DEFAUTE_SAMPLE_RATE, FAAC_DEFAUTE_SAMPLE_CHANNEL);
if (!mAudioCodec)return 0;
}
u_char *buf;
u_long len;
faacEncGetDecoderSpecificInfo(mAudioCodec, &buf, &len);
int bodySize = 2 + len;
RTMPPacket *packet = new RTMPPacket;
RTMPPacket_Alloc(packet, bodySize);
//雙聲道
packet->m_body[0] = 0xAF;
if (mChannels == 1) { //單身道
packet->m_body[0] = 0xAE;
}
packet->m_body[1] = 0x00;
//將包頭數(shù)據(jù) copy 到RTMPPacket 中
memcpy(&packet->m_body[2], buf, len);
//是否使用絕對時間戳
packet->m_hasAbsTimestamp = FALSE;
//包大小
packet->m_nBodySize = bodySize;
//包類型
packet->m_packetType = RTMP_PACKET_TYPE_AUDIO;
//語音通道
packet->m_nChannel = 0x11;
packet->m_headerType = RTMP_PACKET_SIZE_LARGE;
return packet;
}
5. 開始實(shí)時編碼
void AudioEncoderChannel::encodeData(int8_t *data) {
if (!mAudioCodec || !isStart)//不符合編碼要求,退出
return;
//返回編碼后的數(shù)據(jù)字節(jié)長度
int bytelen = faacEncEncode(mAudioCodec, reinterpret_cast<int32_t *>(data), mInputSamples,mBuffer, mMaxOutputBytes);
if (bytelen > 0) {
//開始打包 rtmp
int bodySize = 2 + bytelen;
RTMPPacket *packet = new RTMPPacket;
RTMPPacket_Alloc(packet, bodySize);
//雙聲道
packet->m_body[0] = 0xAF;
if (mChannels == 1) {
packet->m_body[0] = 0xAE;
}
//編碼出的音頻 都是 0x01
packet->m_body[1] = 0x01;
memcpy(&packet->m_body[2], mBuffer, bytelen);
packet->m_hasAbsTimestamp = FALSE;
packet->m_nBodySize = bodySize;
packet->m_packetType = RTMP_PACKET_TYPE_AUDIO;
packet->m_nChannel = 0x11;
packet->m_headerType = RTMP_PACKET_SIZE_LARGE;
//發(fā)送 rtmp packet,回調(diào)給 RTMP send 模塊
mAudioCallback(packet);
}
}
6. 釋放編碼器
在不需要編碼或者退出編碼的時候需要主動釋放編碼器,釋放 native 內(nèi)存,可以通過如下函數(shù)來實(shí)現(xiàn)釋放編碼器的操作:
void AudioEncoderChannel::release() {
//退出編碼的標(biāo)志
isStart = false;
//釋放編碼器
if (mAudioCodec) {
//關(guān)閉編碼器
faacEncClose(mAudioCodec);
//釋放緩沖區(qū)
DELETE(mBuffer);
mAudioCodec = 0;
}
}
硬編
軟編碼介紹完了下面利用 Android SDK 自帶的 MediaCodec 函數(shù)進(jìn)行對 PCM 編碼為 AAC 的格式音頻數(shù)據(jù)。使用 MediaCodec 編碼 AAC 對 Android 系統(tǒng)是有要求的,必須是 4.1系統(tǒng)以上,即要求 Android 的版本代號在 Build.VERSION_CODES.JELLY_BEAN (16) 以上。MediaCodec 是 Android 系統(tǒng)提供的硬件編碼器,它可以利用設(shè)備的硬件來完成編碼,從而大大提高編碼的效率,還可以降低電量的使用,但是其在兼容性方面不如軟編號,因?yàn)?Android 設(shè)備的鎖片化太嚴(yán)重,所以讀者可以自己衡量在應(yīng)用中是否使用 Android 平臺的硬件編碼特性。
1. 創(chuàng)建 "audio/mp4a-latm"
類型的硬編碼器
mediaCodec = MediaCodec.createEncoderByType(configuration.mime);
2. 配置音頻硬編碼器
public static MediaCodec getAudioMediaCodec(AudioConfiguration configuration){
MediaFormat format = MediaFormat.createAudioFormat(configuration.mime, configuration.frequency, configuration.channelCount);
if(configuration.mime.equals(AudioConfiguration.DEFAULT_MIME)) {
format.setInteger(MediaFormat.KEY_AAC_PROFILE, configuration.aacProfile);
}
//語音碼率
format.setInteger(MediaFormat.KEY_BIT_RATE, configuration.maxBps * 1024);
//語音采樣率 44100
format.setInteger(MediaFormat.KEY_SAMPLE_RATE, configuration.frequency);
int maxInputSize = AudioUtils.getRecordBufferSize(configuration);
format.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, maxInputSize);
format.setInteger(MediaFormat.KEY_CHANNEL_COUNT, configuration.channelCount);
MediaCodec mediaCodec = null;
try {
mediaCodec = MediaCodec.createEncoderByType(configuration.mime);
//MediaCodec.CONFIGURE_FLAG_ENCODE 代表編碼器,解碼傳 0 即可
mediaCodec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);
} catch (Exception e) {
e.printStackTrace();
if (mediaCodec != null) {
mediaCodec.stop();
mediaCodec.release();
mediaCodec = null;
}
}
return mediaCodec;
}
3. 開啟音頻硬編碼器
void prepareEncoder() {
mMediaCodec = AudioMediaCodec.getAudioMediaCodec(mAudioConfiguration);
mMediaCodec.start();
}
4. 拿到硬編碼輸入(PCM)輸出(AAC) ByteBufferer
到了這一步說明,音頻編碼器配置完成并且也成功開啟了,現(xiàn)在就可以從 MediaCodec 實(shí)例中獲取兩個 buffer ,一個是輸入 buffer 一個是輸出 buffer , 輸入 buffer 類似于 FFmpeg 中的 AVFrame 存放待編碼的 PCM 數(shù)據(jù),輸出 buffer 類似于 FFmpeg 的 AVPacket 編碼之后的 AAC 數(shù)據(jù), 其代碼如下:
//存放的是 PCM 數(shù)據(jù)
ByteBuffer[] inputBuffers = mMediaCodec.getInputBuffers();
//存放的是編碼之后的 AAC 數(shù)據(jù)
ByteBuffer[] outputBuffers = mMediaCodec.getOutputBuffers();
5. 開始 PCM 硬編碼為 AAC
到此,所有初始化方法已實(shí)現(xiàn)完畢,下面來看一下 MediaCodec 的工作原理如下圖所示,左邊 Client 元素代表要將 PCM 放到 inputBuffer 中的某個具體的 buffer 中去,右邊的 Client 元素代表將編碼之后的原始 AAC 數(shù)據(jù)從 outputBuffer 中的某個具體 buffer 中取出來,?? 左邊的小方塊代表各個 inputBuffer 元素,右邊的小方塊則代表各個 outputBuffer 元素。詳細(xì)介紹可以看 MediaCodec 類介紹。
代碼具體實(shí)現(xiàn)如下:
//input:PCM
synchronized void offerEncoder(byte[] input) {
if(mMediaCodec == null) {
return;
}
ByteBuffer[] inputBuffers = mMediaCodec.getInputBuffers();
ByteBuffer[] outputBuffers = mMediaCodec.getOutputBuffers();
int inputBufferIndex = mMediaCodec.dequeueInputBuffer(12000);
if (inputBufferIndex >= 0) {
ByteBuffer inputBuffer = inputBuffers[inputBufferIndex];
inputBuffer.clear();
inputBuffer.put(input);
mMediaCodec.queueInputBuffer(inputBufferIndex, 0, input.length, 0, 0);
}
int outputBufferIndex = mMediaCodec.dequeueOutputBuffer(mBufferInfo, 12000);
while (outputBufferIndex >= 0) {
ByteBuffer outputBuffer = outputBuffers[outputBufferIndex];
if(mListener != null) {
//將 AAC 數(shù)據(jù)回調(diào)出去
mListener.onAudioEncode(outputBuffer, mBufferInfo);
}
//釋放當(dāng)前內(nèi)部編碼內(nèi)存
mMediaCodec.releaseOutputBuffer(outputBufferIndex, false);
outputBufferIndex = mMediaCodec.dequeueOutputBuffer(mBufferInfo, 0);
}
}
6. AAC 打包為 flv
@Override
public void onAudioData(ByteBuffer bb, MediaCodec.BufferInfo bi) {
if (packetListener == null || !isHeaderWrite || !isKeyFrameWrite) {
return;
}
bb.position(bi.offset);
bb.limit(bi.offset + bi.size);
byte[] audio = new byte[bi.size];
bb.get(audio);
int size = AUDIO_HEADER_SIZE + audio.length;
ByteBuffer buffer = ByteBuffer.allocate(size);
FlvPackerHelper.writeAudioTag(buffer, audio, false, mAudioSampleSize);
packetListener.onPacket(buffer.array(), AUDIO);
}
public static void writeAudioTag(ByteBuffer buffer, byte[] audioInfo, boolean isFirst, int audioSize) {
//寫入音頻頭信息
writeAudioHeader(buffer, isFirst, audioSize);
//寫入音頻信息
buffer.put(audioInfo);
}
復(fù)制代碼
7. 釋放編碼器
在使用完 MediaCodec 編碼器之后,就需要停止運(yùn)行并釋放編碼器,代碼如下:
synchronized public void stop() {
if (mMediaCodec != null) {
mMediaCodec.stop();
mMediaCodec.release();
mMediaCodec = null;
}
}
視頻編碼
Camera 采集完之后需要對 YUV 數(shù)據(jù)進(jìn)行實(shí)時的編碼 (軟編利用 x264 通過 NDK 交叉編譯靜態(tài)庫、硬編使用 Android SDK MediaCodec 進(jìn)行編碼)。
軟編
視頻軟編這里們用主流的編碼庫 x264 進(jìn)行編碼 H264 視頻格式數(shù)據(jù)。
1. 交叉編譯 x264
1.1 下載 x264
//方式 一
git clone https://code.videolan.org/videolan/x264.git
//方式 二
wget ftp://ftp.videolan.org/pub/x264/snapshots/last_x264.tar.bz2
1.2 編寫編譯腳本
在編寫腳本之前需要在 configure 中添加一處代碼 -Werror=implicit-function-declaration
,如下所示:
交叉編譯腳本如下:
#!/bin/bash
#打包地址
PREFIX=./android/armeabi-v7a
#配置NDK 環(huán)境變量
NDK_ROOT=$NDK_HOME
#指定 CPU
CPU=arm-linux-androideabi
#指定 Android API
ANDROID_API=17
TOOLCHAIN= N D K R O O T / t o o l c h a i n s / NDK_ROOT/toolchains/ NDKR?OOT/toolchains/CPU-4.9/prebuilt/linux-x86_64
FLAGS=“-isysroot $NDK_ROOT/sysroot -isystem KaTeX parse error: Expected group after '_' at position 54: …-androideabi -D_?_ANDROID_API__=ANDROID_API -U_FILE_OFFSET_BITS -DANDROID -ffunction-sections -funwind-tables -fstack-protector-strong -no-canonical-prefixes -march=armv7-a -mfloat-abi=softfp -mfpu=vfpv3-d16 -mthumb -Wa,–noexecstack -Wformat -Werror=format-security -O0 -fPIC”
#–disable-cli 不需要命令行工具
#–enable-static 靜態(tài)庫
./configure
–prefix=
P
R
E
F
I
X
?
?
?
d
i
s
a
b
l
e
?
c
l
i
?
?
?
e
n
a
b
l
e
?
s
t
a
t
i
c
?
?
?
e
n
a
b
l
e
?
p
i
c
?
?
?
h
o
s
t
=
a
r
m
?
l
i
n
u
x
?
?
?
c
r
o
s
s
?
p
r
e
f
i
x
=
PREFIX \ --disable-cli \ --enable-static \ --enable-pic \ --host=arm-linux \ --cross-prefix=
PREFIX???disable?cli???enable?static???enable?pic???host=arm?linux???cross?prefix=TOOLCHAIN/bin/arm-linux-androideabi-
–sysroot=
N
D
K
R
O
O
T
/
p
l
a
t
f
o
r
m
s
/
a
n
d
r
o
i
d
?
17
/
a
r
c
h
?
a
r
m
?
?
?
e
x
t
r
a
?
c
f
l
a
g
s
=
"
NDK_ROOT/platforms/android-17/arch-arm \ --extra-cflags="
NDKR?OOT/platforms/android?17/arch?arm???extra?cflags="FLAGS"
make clean
make install
2. CMakeList.txt 配置
cmake_minimum_required(VERSION 3.4.1)
#視頻編碼器
set(x264 ${CMAKE_SOURCE_DIR}/x264)
#加載 x264 頭文件目錄
include_directories(${x264}/include)
#指定 x264 靜態(tài)庫文件目錄
set(CMAKE_CXX_FLAGS “
C
M
A
K
E
C
X
X
F
L
A
G
S
?
L
{CMAKE_CXX_FLAGS} -L
CMAKEC?XXF?LAGS?L{x264}/libs/${CMAKE_ANDROID_ARCH_ABI}”)
#批量添加自己編寫的 cpp 文件,不要把 .h 加入進(jìn)來了
file(GLOB Player_CPP ${ykplayer}/.cpp)
file(GLOB Push_CPP ${ykpusher}/*.cpp)
#添加自己編寫 cpp 源文件生成動態(tài)庫
add_library(ykpusher SHARED ${Push_CPP})
#找系統(tǒng)中 NDK log庫
find_library(log_lib
log)
#推流 so
target_link_libraries(
#播放 so
ykpusher
# 寫了此命令不用在乎添加 ffmpeg lib 順序問題導(dǎo)致應(yīng)用崩潰
-Wl,–start-group
avcodec avfilter avformat avutil swresample swscale
-Wl,–end-group
z
#推流庫
rtmp
#視頻編碼
x264
#語音編碼
faac
#本地庫
android
${log_lib}
)
3. 配置并打開 x264 編碼器
void VideoEncoderChannel::setVideoEncoderInfo(int width, int height, int fps, int bit) {
pthread_mutex_lock(&mMutex);
this->mWidth = width;
this->mHeight = height;
this->mFps = fps;
this->mBit = bit;
this->mY_Size = width * height;
this->mUV_Size = mY_Size / 4;
//如果編碼器已經(jīng)存在,需要釋放
if (mVideoCodec || pic_in) {
release();
}
//打開x264編碼器
//x264編碼器的屬性
x264_param_t param;
//2: 最快
//3: 無延遲編碼
x264_param_default_preset(¶m, x264_preset_names[0], x264_tune_names[7]);
//base_line 3.2 編碼規(guī)格
param.i_level_idc = 32;
//輸入數(shù)據(jù)格式
param.i_csp = X264_CSP_I420;
param.i_width = width;
param.i_height = height;
//無b幀
param.i_bframe = 0;
//參數(shù)i_rc_method表示碼率控制,CQP(恒定質(zhì)量),CRF(恒定碼率),ABR(平均碼率)
param.rc.i_rc_method = X264_RC_ABR;
//碼率(比特率,單位Kbps)
param.rc.i_bitrate = mBit;
//瞬時最大碼率
param.rc.i_vbv_max_bitrate = mBit * 1.2;
//設(shè)置了i_vbv_max_bitrate必須設(shè)置此參數(shù),碼率控制區(qū)大小,單位kbps
param.rc.i_vbv_buffer_size = mBit;
//幀率
param.i_fps_num = fps;
param.i_fps_den = 1;
param.i_timebase_den = param.i_fps_num;
param.i_timebase_num = param.i_fps_den;
// param.pf_log = x264_log_default2;
//用fps而不是時間戳來計(jì)算幀間距離
param.b_vfr_input = 0;
//幀距離(關(guān)鍵幀) 2s一個關(guān)鍵幀
param.i_keyint_max = fps * 2;
// 是否復(fù)制sps和pps放在每個關(guān)鍵幀的前面 該參數(shù)設(shè)置是讓每個關(guān)鍵幀(I幀)都附帶sps/pps。
param.b_repeat_headers = 1;
//多線程
param.i_threads = 1;
x264_param_apply_profile(¶m, “baseline”);
//打開編碼器
mVideoCodec = x264_encoder_open(¶m);
pic_in = new x264_picture_t;
x264_picture_alloc(pic_in, X264_CSP_I420, width, height);
//相當(dāng)于重啟編碼器
isStart = true;
pthread_mutex_unlock(&mMutex);
}
4. 開始編碼
void VideoEncoderChannel::onEncoder() {
while (isStart) {
if (!mVideoCodec) {
continue;
}
int8_t *data = 0;
mVideoPackets.pop(data);
if (!data) {
LOGE(“獲取 YUV 數(shù)據(jù)錯誤”);
continue;
}
//copy Y 數(shù)據(jù)
memcpy(this->pic_in->img.plane[0], data, mY_Size);
//拿到 UV 數(shù)據(jù)
for (int i = 0; i < mUV_Size; ++i) {
//拿到 u 數(shù)據(jù)
*(pic_in->img.plane[1] + i) = *(data + mY_Size + i * 2 + 1);
//拿到 v 數(shù)據(jù)
*(pic_in->img.plane[2] + i) = *(data + mY_Size + i * 2);
}
//編碼出來的數(shù)據(jù)
x264_nal_t *pp_nal;
//編碼出來的幀數(shù)量
int pi_nal = 0;
x264_picture_t pic_out;
//開始編碼
int ret = x264_encoder_encode(mVideoCodec, &pp_nal, &pi_nal, pic_in, &pic_out);
if (!ret) {
LOGE(“編碼失敗”);
continue;
}
//如果是關(guān)鍵幀
int sps_len = 0;
自我介紹一下,小編13年上海交大畢業(yè),曾經(jīng)在小公司待過,也去過華為、OPPO等大廠,18年進(jìn)入阿里一直到現(xiàn)在。
深知大多數(shù)Android工程師,想要提升技能,往往是自己摸索成長或者是報(bào)班學(xué)習(xí),但對于培訓(xùn)機(jī)構(gòu)動則幾千的學(xué)費(fèi),著實(shí)壓力不小。自己不成體系的自學(xué)效果低效又漫長,而且極易碰到天花板技術(shù)停滯不前!
因此收集整理了一份《2024年Android移動開發(fā)全套學(xué)習(xí)資料》,初衷也很簡單,就是希望能夠幫助到想自學(xué)提升又不知道該從何學(xué)起的朋友,同時減輕大家的負(fù)擔(dān)。
既有適合小白學(xué)習(xí)的零基礎(chǔ)資料,也有適合3年以上經(jīng)驗(yàn)的小伙伴深入學(xué)習(xí)提升的進(jìn)階課程,基本涵蓋了95%以上Android開發(fā)知識點(diǎn),真正體系化!
由于文件比較大,這里只是將部分目錄大綱截圖出來,每個節(jié)點(diǎn)里面都包含大廠面經(jīng)、學(xué)習(xí)筆記、源碼講義、實(shí)戰(zhàn)項(xiàng)目、講解視頻,并且后續(xù)會持續(xù)更新
如果你覺得這些內(nèi)容對你有幫助,可以添加V獲?。簐ip204888 (備注Android)
自學(xué)編程路線、面試題集合/面經(jīng)、及系列技術(shù)文章等,資源持續(xù)更新中…
ut;
//開始編碼
int ret = x264_encoder_encode(mVideoCodec, &pp_nal, &pi_nal, pic_in, &pic_out);
if (!ret) {
LOGE(“編碼失敗”);
continue;
}
//如果是關(guān)鍵幀
int sps_len = 0;
自我介紹一下,小編13年上海交大畢業(yè),曾經(jīng)在小公司待過,也去過華為、OPPO等大廠,18年進(jìn)入阿里一直到現(xiàn)在。
深知大多數(shù)Android工程師,想要提升技能,往往是自己摸索成長或者是報(bào)班學(xué)習(xí),但對于培訓(xùn)機(jī)構(gòu)動則幾千的學(xué)費(fèi),著實(shí)壓力不小。自己不成體系的自學(xué)效果低效又漫長,而且極易碰到天花板技術(shù)停滯不前!
因此收集整理了一份《2024年Android移動開發(fā)全套學(xué)習(xí)資料》,初衷也很簡單,就是希望能夠幫助到想自學(xué)提升又不知道該從何學(xué)起的朋友,同時減輕大家的負(fù)擔(dān)。
[外鏈圖片轉(zhuǎn)存中…(img-MnPucfT0-1711916466684)]
[外鏈圖片轉(zhuǎn)存中…(img-K5Cp8myo-1711916466685)]
[外鏈圖片轉(zhuǎn)存中…(img-5CJ2bp5N-1711916466686)]
[外鏈圖片轉(zhuǎn)存中…(img-JD4irCbo-1711916466687)]
[外鏈圖片轉(zhuǎn)存中…(img-DzLaX3OR-1711916466687)]
[外鏈圖片轉(zhuǎn)存中…(img-nw2aT6To-1711916466688)]
既有適合小白學(xué)習(xí)的零基礎(chǔ)資料,也有適合3年以上經(jīng)驗(yàn)的小伙伴深入學(xué)習(xí)提升的進(jìn)階課程,基本涵蓋了95%以上Android開發(fā)知識點(diǎn),真正體系化!
由于文件比較大,這里只是將部分目錄大綱截圖出來,每個節(jié)點(diǎn)里面都包含大廠面經(jīng)、學(xué)習(xí)筆記、源碼講義、實(shí)戰(zhàn)項(xiàng)目、講解視頻,并且后續(xù)會持續(xù)更新
如果你覺得這些內(nèi)容對你有幫助,可以添加V獲取:vip204888 (備注Android)
[外鏈圖片轉(zhuǎn)存中…(img-mniGFlP9-1711916466689)]
自學(xué)編程路線、面試題集合/面經(jīng)、及系列技術(shù)文章等,資源持續(xù)更新中…
[外鏈圖片轉(zhuǎn)存中…(img-oSEfASTC-1711916466689)]文章來源:http://www.zghlxwxcb.cn/news/detail-848334.html
本文已被CODING開源項(xiàng)目:《Android學(xué)習(xí)筆記總結(jié)+移動架構(gòu)視頻+大廠面試真題+項(xiàng)目實(shí)戰(zhàn)源碼》收錄文章來源地址http://www.zghlxwxcb.cn/news/detail-848334.html
到了這里,關(guān)于Android-音視頻學(xué)習(xí)系列-(九)Android-端實(shí)現(xiàn)-rtmp-推流(2)的文章就介紹完了。如果您還想了解更多內(nèi)容,請?jiān)谟疑辖撬阉鱐OY模板網(wǎng)以前的文章或繼續(xù)瀏覽下面的相關(guān)文章,希望大家以后多多支持TOY模板網(wǎng)!