轉載自:Penguin
Android Camera Develop: process preview frames in real time efficiently
概述
本篇我們暫時不介紹像相機APP增加新功能,而是介紹如何處理相機預覽幀數(shù)據(jù)。想必大多數(shù)人都對處理預覽幀沒有需求,因為相機只需要拿來拍照和錄像就好了,實際上本篇和一般的相機開發(fā)也沒有太大聯(lián)系,但因為仍然是在操作Camera類,所以還是歸為相機開發(fā)。處理預覽幀簡單來說就是對相機預覽時的每一幀的數(shù)據(jù)進行處理,一般來說如果相機的采樣速率是30fps的話,一秒鐘就會有30個幀數(shù)據(jù)需要處理。幀數(shù)據(jù)具體是什么?如果你就是奔著處理幀數(shù)據(jù)來的話,想必你早已知道答案,其實就是一個byte類型的數(shù)組,包含的是YUV格式的幀數(shù)據(jù)。本篇僅介紹幾種高效地處理預覽幀數(shù)據(jù)的方法,而不介紹具體的用處,因為拿來進行人臉識別、圖像美化等又是長篇大論了。
本篇在Android相機開發(fā)(二): 給相機加上偏好設置的基礎上介紹。預覽幀數(shù)據(jù)的處理通常會包含大量的計算,從而導致因為幀數(shù)據(jù)太多而處理效率低下,以及衍生出的預覽畫面卡頓等問題。本篇主要介紹分離線程優(yōu)化畫面顯示,以及通過利用HandlerThread、Queue、ThreadPool和AsyncTask來提升幀數(shù)據(jù)處理效率的方法。
準備
為了簡單起見,我們在相機開始預覽的時候就開始獲取預覽幀并進行處理,為了能更清晰地分析這個過程,我們在UI中“設置”按鈕之下增加“開始”和“停止”按鈕以控制相機預覽的開始與停止。
修改UI
修改activity_main.xml,將
Java
<Button
android:id="@+id/button_settings"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="設置" />
替換為
Java
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="right"
android:orientation="vertical">
<Button
android:id="@+id/button_settings"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="設置" />
<Button
android:id="@+id/button_start_preview"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="開始" />
<Button
android:id="@+id/button_stop_preview"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="停止" />
</LinearLayout>
這樣增加了“開始”和“停止”兩個按鈕。
綁定事件
修改mainActivity,將原onCreate()中初始化相機預覽的代碼轉移到新建的方法startPreview()中
Java
public void startPreview() {
final CameraPreview mPreview = new CameraPreview(this);
FrameLayout preview = (FrameLayout) findViewById(R.id.camera_preview);
preview.addView(mPreview);
SettingsFragment.passCamera(mPreview.getCameraInstance());
PreferenceManager.setDefaultValues(this, R.xml.preferences, false);
SettingsFragment.setDefault(PreferenceManager.getDefaultSharedPreferences(this));
SettingsFragment.init(PreferenceManager.getDefaultSharedPreferences(this));
Button buttonSettings = (Button) findViewById(R.id.button_settings);
buttonSettings.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
getFragmentManager().beginTransaction().replace(R.id.camera_preview, new SettingsFragment()).addToBackStack(null).commit();
}
});
}
同時再增加一個stopPreview()方法,用來停止相機預覽
Java
public void stopPreview() {
FrameLayout preview = (FrameLayout) findViewById(R.id.camera_preview);
preview.removeAllViews();
}
stopPreview()獲取相機預覽所在的FrameLayout,然后通過removeAllViews()將相機預覽移除,此時會觸發(fā)CameraPreview類中的相關結束方法,關閉相機預覽。
現(xiàn)在onCreate()的工作就很簡單了,只需要將兩個按鈕綁定上對應的方法就好了
Java
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
Button buttonStartPreview = (Button) findViewById(R.id.button_start_preview);
buttonStartPreview.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
startPreview();
}
});
Button buttonStopPreview = (Button) findViewById(R.id.button_stop_preview);
buttonStopPreview.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
stopPreview();
}
});
}
運行試試
現(xiàn)在運行APP不會立即開始相機預覽了,點擊“開始”按鈕屏幕上才會出現(xiàn)相機預覽畫面,點擊“停止”則畫面消失,預覽停止。
基本的幀數(shù)據(jù)獲取和處理
這里我們首先實現(xiàn)最基礎,也是最常用的幀數(shù)據(jù)獲取和處理的方法;然后看看改進提升性能的方法。
基礎
獲取幀數(shù)據(jù)的接口是Camera.PreviewCallback,實現(xiàn)此接口下的onPreviewFrame(byte[] data, Camera camera)方法即可獲取到每個幀數(shù)據(jù)data。所以現(xiàn)在要做的就是給CameraPreview類增加Camera.PreviewCallback接口聲明,再在CameraPreview中實現(xiàn)onPreviewFrame()方法,最后給Camera綁定此接口。這樣相機預覽時每產生一個預覽幀,就會調用onPreviewFrame()方法,處理預覽幀數(shù)據(jù)data。
在CameraPreview中,修改
Java
public class CameraPreview extends SurfaceView implements SurfaceHolder.Callback
為
Java
public class CameraPreview extends SurfaceView implements SurfaceHolder.Callback, Camera.PreviewCallback
加入Camera.PreviewCallback接口聲明。
加入onPreviewFrame()的實現(xiàn)
Java
public void onPreviewFrame(byte[] data, Camera camera) {
Log.i(TAG, "processing frame");
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
這里并沒有處理幀數(shù)據(jù)data,而是暫停0.5秒模擬處理幀數(shù)據(jù)。
在surfaceCreated()中getCameraInstance()這句的下面加入
Java
mCamera.setPreviewCallback(this);
將此接口綁定到mCamera,使得每當有預覽幀生成,就會調用onPreviewFrame()。
運行試試
現(xiàn)在運行APP,點擊“開始”,一般在屏幕上觀察不到明顯區(qū)別,但這里其實有兩個潛在的問題。其一,如果你這時點擊“設置”,會發(fā)現(xiàn)設置界面并不是馬上出現(xiàn),而是會延遲幾秒出現(xiàn);而再點擊返回鍵,設置界面也會過幾秒才消失。其二,在logcat中可以看到輸出的"processing frame",大約0.5秒輸出一條,因為線程睡眠設置的是0.5秒,所以一秒鐘的30個幀數(shù)據(jù)只處理了2幀,剩下的28幀都被丟棄了(這里沒有非常直觀的方法顯示剩下的28幀被丟棄了,但事實就是這樣,不嚴格的來說,當新的幀數(shù)據(jù)到達時,如果onPreviewFrame()正在執(zhí)行還沒有返回,這個幀數(shù)據(jù)就會被丟棄)。
與UI線程分離
問題分析
現(xiàn)在我們來解決第一個問題。第一個問題的原因很簡單,也是Android開發(fā)中經(jīng)常碰到的:UI線程被占用,導致UI操作卡頓。在這里就是onPreviewFrame()會阻塞線程,而阻塞的線程就是UI線程。
onPreviewFrame()在哪個線程執(zhí)行?官方文檔里有相關描述:
Called as preview frames are displayed. This callback is invoked on the event thread open(int) was called from.
意思就是onPreviewFrame()在執(zhí)行Camera.open()時所在的線程運行。而目前Camera.open()就是在UI線程中執(zhí)行的(因為沒有創(chuàng)建新進程),對應的解決方法也很簡單了:讓Camera.open()在非UI線程執(zhí)行。
解決方法
這里使用HandlerThread來實現(xiàn)。HandlerThread會創(chuàng)建一個新的線程,并且有自己的loop,這樣通過Handler.post()就可以確保在這個新的線程執(zhí)行指定的語句。雖然說起來容易,但還是有些細節(jié)問題要處理。
先從HandlerThread下手,在CameraPreview中加入
Java
private class CameraHandlerThread extends HandlerThread {
Handler mHandler;
public CameraHandlerThread(String name) {
super(name);
start();
mHandler = new Handler(getLooper());
}
synchronized void notifyCameraOpened() {
notify();
}
void openCamera() {
mHandler.post(new Runnable() {
@Override
public void run() {
openCameraOriginal();
notifyCameraOpened();
}
});
try {
wait();
} catch (InterruptedException e) {
Log.w(TAG, "wait was interrupted");
}
}
}
CameraHandlerThread繼承自HandlerThread,在構造函數(shù)中就tart()啟動這個Thread,并創(chuàng)建一個handler。openCamera()要達到的效果是在此線程中執(zhí)行mCamera = Camera.open();,因此通過handler.post()在Runnable()中執(zhí)行,我們將要執(zhí)行的語句封裝在openCameraOriginal()中。使用notify-wait是為安全起見,因為post()執(zhí)行會立即返回,而Runnable()會異步執(zhí)行,可能在執(zhí)行post()后立即使用mCamera時仍為null;因此在這里加上notify-wait控制,確認打開相機后,openCamera()才返回。
接下來是openCameraOriginal(),在CameraPreview中加入
Java
private void openCameraOriginal() {
try {
mCamera = Camera.open();
} catch (Exception e) {
Log.d(TAG, "camera is not available");
}
}
這個不用解釋,就是封裝成了方法。
最后將getCameraInstance()修改為
Java
public Camera getCameraInstance() {
if (mCamera == null) {
CameraHandlerThread mThread = new CameraHandlerThread("camera thread");
synchronized (mThread) {
mThread.openCamera();
}
}
return mCamera;
}
這個也很容易理解,就是交給CameraHandlerThread來處理。
運行試試
現(xiàn)在運行APP,會發(fā)現(xiàn)第一個問題已經(jīng)解決了。
處理幀數(shù)據(jù)
接下來解決第二個問題,如何確保不會有幀數(shù)據(jù)被丟棄,即保證每個幀數(shù)據(jù)都被處理。解決方法的中心思想很明確:讓onPreviewFrame()盡可能快地返回,不至于丟棄幀數(shù)據(jù)。
下面介紹4種比較常用的處理方法:HandlerThread、Queue、AsyncTask和ThreadPool,針對每一種方法簡單分析其優(yōu)缺點。
HandlerThread
簡介
采用HandlerThread就是利用Android的Message Queue來異步處理幀數(shù)據(jù)。流程簡單來說就是onPreviewFrame()調用時將幀數(shù)據(jù)封裝為Message,發(fā)送給HandlerThread,HandlerThread在新的線程獲取Message,對幀數(shù)據(jù)進行處理。因為發(fā)送Message所需時間很短,所以不會造成幀數(shù)據(jù)丟失。
實現(xiàn)
新建ProcessWithHandlerThread類,內容為
Java
public class ProcessWithHandlerThread extends HandlerThread implements Handler.Callback {
private static final String TAG = "HandlerThread";
public static final int WHAT_PROCESS_FRAME = 1;
public ProcessWithHandlerThread(String name) {
super(name);
start();
}
@Override
public boolean handleMessage(Message msg) {
switch (msg.what) {
case WHAT_PROCESS_FRAME:
byte[] frameData = (byte[]) msg.obj;
processFrame(frameData);
return true;
default:
return false;
}
}
private void processFrame(byte[] frameData) {
Log.i(TAG, "test");
}
}
ProcessWithHandlerThread繼承HandlerThread和Handler.Callback接口,此接口實現(xiàn)handleMessage()方法,用來處理獲得的Message。幀數(shù)據(jù)被封裝到Message的obj屬性中,用what進行標記。processFrame()即處理幀數(shù)據(jù),這里僅作示例。
下面要在CameraPreview中實例化ProcessWithHandlerThread,綁定接口,封裝幀數(shù)據(jù),以及發(fā)送Message。
在CameraPreview中添加新的成員變量
Java
private static final int PROCESS_WITH_HANDLER_THREAD = 1;
private int processType = PROCESS_WITH_HANDLER_THREAD;
private ProcessWithHandlerThread processFrameHandlerThread;
private Handler processFrameHandler;
在構造函數(shù)末尾增加
Java
switch (processType) {
case PROCESS_WITH_HANDLER_THREAD:
processFrameHandlerThread = new ProcessWithHandlerThread("process frame");
processFrameHandler = new Handler(processFrameHandlerThread.getLooper(), processFrameHandlerThread);
break;
}
注意這里的new Handler()同時也在綁定接口,讓ProcessWithHandlerThread處理接收到的Message。
修改onPreviewFrame()為
Java
public void onPreviewFrame(byte[] data, Camera camera) {
switch (processType) {
case PROCESS_WITH_HANDLER_THREAD:
processFrameHandler.obtainMessage(ProcessWithHandlerThread.WHAT_PROCESS_FRAME, data).sendToTarget();
break;
}
}
這里將幀數(shù)據(jù)data封裝為Message,并發(fā)送出去。
運行試試
現(xiàn)在運行APP,在logcat中會出現(xiàn)大量的"test",你也可以自己修改processFrame()進行測試。
分析
這種方法就是靈活套用了Android的Handler機制,借助其消息隊列模型Message Queue解決問題。存在的問題就是幀數(shù)據(jù)都封裝為Message一股腦丟給Message Queue會不會超出限度,不過目前還沒遇到。另一問題就是Handler機制可能過于龐大,相對于拿來處理這個問題不太“輕量級”。
Queue
簡介
Queue方法就是利用Queue建立幀數(shù)據(jù)隊列,onPreviewFrame()負責向隊尾添加幀數(shù)據(jù),而由處理方法在隊頭取出幀數(shù)據(jù)并進行處理,Queue就是緩沖和提供接口的角色。
實現(xiàn)
新建ProcessWithQueue類,內容為
Java
public class ProcessWithQueue extends Thread {
private static final String TAG = "Queue";
private LinkedBlockingQueue<byte[]> mQueue;
public ProcessWithQueue(LinkedBlockingQueue<byte[]> frameQueue) {
mQueue = frameQueue;
start();
}
@Override
public void run() {
while (true) {
byte[] frameData = null;
try {
frameData = mQueue.take();
} catch (InterruptedException e) {
e.printStackTrace();
}
processFrame(frameData);
}
}
private void processFrame(byte[] frameData) {
Log.i(TAG, "test");
}
}
ProcessWithQueue實例化時由外部提供Queue。為能夠獨立處理幀數(shù)據(jù)以及隨時處理幀數(shù)據(jù),ProcessWithQueue繼承Thread,并重載了run()方法。run()方法中的死循環(huán)用來隨時處理Queue中的幀數(shù)據(jù),mQueue.take()在隊列空時阻塞,因此不會造成循環(huán)導致的CPU占用。processFrame()即處理幀數(shù)據(jù),這里僅作示例。
下面要在CameraPreview中創(chuàng)建隊列并實例化ProcessWithQueue,將幀數(shù)據(jù)加入到隊列中。
在CameraPreview中添加新的成員變量
Java
private static final int PROCESS_WITH_QUEUE = 2;
private ProcessWithQueue processFrameQueue;
private LinkedBlockingQueue<byte[]> frameQueue;
將
Java
private int processType = PROCESS_WITH_THREAD_POOL;
修改為
Java
private int processType = PROCESS_WITH_QUEUE;
在構造函數(shù)的switch中加入
Java
case PROCESS_WITH_QUEUE:
frameQueue = new LinkedBlockingQueue<>();
processFrameQueue = new ProcessWithQueue(frameQueue);
break;
這里使用LinkedBlockingQueue滿足并發(fā)性要求,由于只操作隊頭和隊尾,采用鏈表結構。
在onPreviewFrame()的switch中加入
Java
case PROCESS_WITH_QUEUE:
try {
frameQueue.put(data);
} catch (InterruptedException e) {
e.printStackTrace();
}
break;
將幀數(shù)據(jù)加入到隊尾。
運行試試
現(xiàn)在運行APP,在logcat中會出現(xiàn)大量的"test",你也可以自己修改processFrame()進行測試。
分析
這種方法可以簡單理解為對之前的HandlerThread方法的簡化,僅用LinkedBlockingQueue來實現(xiàn)緩沖,并且自己寫出隊列處理方法。這種方法同樣也沒有避開之前說的缺點,如果隊列中的幀數(shù)據(jù)不能及時處理,就會造成隊列過長,占用大量內存。但優(yōu)點就是實現(xiàn)簡單方便。
AsyncTask
簡介
AsyncTask方法就是用到了Android的AsyncTask類,這里就不詳細介紹了。簡單來說每次調用AsyncTask都會創(chuàng)建一個異步處理事件來異步執(zhí)行指定的方法,在這里就是將普通的幀數(shù)據(jù)處理方法交給AsyncTask去執(zhí)行。
實現(xiàn)
新建ProcessWithAsyncTask類,內容為
Java
public class ProcessWithAsyncTask extends AsyncTask<byte[], Void, String> {
private static final String TAG = "AsyncTask";
@Override
protected String doInBackground(byte[]... params) {
processFrame(params[0]);
return "test";
}
private void processFrame(byte[] frameData) {
Log.i(TAG, "test");
}
}
ProcessWithAsyncTask繼承AsyncTask,重載doInBackground()方法,輸入為byte[],返回String。doInBackground()內的代碼就是在異步執(zhí)行,這里就是processFrame(),處理幀數(shù)據(jù),這里僅作示例。
下面要在CameraPreview中實例化ProcessWithAsyncTask,將幀數(shù)據(jù)交給AsyncTask。與之前介紹的方法不一樣,每次處理新的幀數(shù)據(jù)都要實例化一個新的ProcessWithAsyncTask并執(zhí)行。
在CameraPreview中添加新的成員變量
Java
private static final int PROCESS_WITH_ASYNC_TASK = 3;
將
Java
private int processType = PROCESS_WITH_QUEUE;
修改為
Java
private int processType = PROCESS_WITH_ASYNC_TASK;
在onPreviewFrame()的switch中加入
Java
case PROCESS_WITH_ASYNC_TASK:
new ProcessWithAsyncTask().execute(data);
break;
實例化一個新的ProcessWithAsyncTask,向其傳遞幀數(shù)據(jù)data并執(zhí)行。
運行試試
現(xiàn)在運行APP,在logcat中會出現(xiàn)大量的"test",你也可以自己修改processFrame()進行測試。
分析
這種方法代碼簡單,但理解其底層實現(xiàn)有難度。AsyncTask實際是利用到了線程池技術,可以實現(xiàn)異步和并發(fā)。其相對之前的方法的優(yōu)點就在于并發(fā)性高,但也不能無窮并發(fā)下去,還是會受到幀處理時間的制約。另外根據(jù)官方文檔中的介紹,AsyncTask的出現(xiàn)主要是為解決UI線程通信的問題,所以在這里算旁門左道了。AsyncTask相比前面的方法少了“主控”的部分,可能滿足不了某些要求。
ThreadPool
簡介
ThreadPool方法主要用到的是Java的ThreadPoolExecutor類,想必之前的AsyncTask就顯得更底層一些。通過手動建立線程池,來實現(xiàn)幀數(shù)據(jù)的并發(fā)處理。
實現(xiàn)
新建ProcessWithThreadPool類,內容為
Java
public class ProcessWithThreadPool {
private static final String TAG = "ThreadPool";
private static final int KEEP_ALIVE_TIME = 10;
private static final TimeUnit TIME_UNIT = TimeUnit.SECONDS;
private BlockingQueue<Runnable> workQueue;
private ThreadPoolExecutor mThreadPool;
public ProcessWithThreadPool() {
int corePoolSize = Runtime.getRuntime().availableProcessors();
int maximumPoolSize = corePoolSize * 2;
workQueue = new LinkedBlockingQueue<>();
mThreadPool = new ThreadPoolExecutor(corePoolSize, maximumPoolSize, KEEP_ALIVE_TIME, TIME_UNIT, workQueue);
}
public synchronized void post(final byte[] frameData) {
mThreadPool.execute(new Runnable() {
@Override
public void run() {
processFrame(frameData);
}
});
}
private void processFrame(byte[] frameData) {
Log.i(TAG, "test");
}
}
ProcessWithThreadPool構造函數(shù)建立線程池,corePoolSize為并發(fā)度,這里就是處理器核心個數(shù),線程池大小maximumPoolSize則被設置為并發(fā)度的兩倍。post()則用來通過線程池執(zhí)行幀數(shù)據(jù)處理方法。processFrame()即處理幀數(shù)據(jù),這里僅作示例。
下面要在CameraPreview中實例化ProcessWithThreadPool,將幀數(shù)據(jù)交給ThreadPool。
在CameraPreview中添加新的成員變量
Java
private static final int PROCESS_WITH_THREAD_POOL = 4;
private ProcessWithThreadPool processFrameThreadPool;
將
Java
private int processType = PROCESS_WITH_ASYNC_TASK;
修改為
Java
private int processType = PROCESS_WITH_THREAD_POOL;
在構造函數(shù)的switch中加入
Java
case PROCESS_WITH_THREAD_POOL:
processFrameThreadPool = new ProcessWithThreadPool();
break;
在onPreviewFrame()的switch中加入
Java
case PROCESS_WITH_THREAD_POOL:
processFrameThreadPool.post(data);
break;
將幀數(shù)據(jù)交給ThreadPool。
運行試試
現(xiàn)在運行APP,在logcat中會出現(xiàn)大量的"test",你也可以自己修改processFrame()進行測試。
分析
ThreadPool方法相比AsyncTask代碼更清晰,顯得不太“玄乎”,但兩者的思想是一致的。ThreadPool方法在建立線程池時有了更多定制化的空間,但同樣沒能避免AsyncTask方法的缺點。
一點嘮叨
上面介紹的諸多方法都只是大概描述了處理的思想,在實際使用時還要根據(jù)需求去修改,但大體是這樣的流程。因為實時處理缺乏完善的測試方法,所以bug也會經(jīng)常存在,還需要非常小心地去排查;比如處理的幀中丟失了兩三幀就很難發(fā)現(xiàn),即使發(fā)現(xiàn)了也不太容易找出出錯的方法,還需要大量的測試。
上面介紹的這些方法都是根據(jù)我踩的無數(shù)坑總結出來的,因為一直沒找到高質量的介紹實時預覽幀處理的文章,所以把自己知道的一些知識貢獻出來,能夠幫到有需要的人就算達到目的了。
關于幀數(shù)據(jù)和YUV格式等的實際處理問題,可以參考我之前寫的一些Android視頻解碼和YUV格式解析的文章,也希望能夠幫到你。
DEMO
本文實現(xiàn)的相機APP源碼都放在GitHub上,如果需要請點擊zhantong/AndroidCamera-ProcessFrames。
參考
- Camera | Android Developers
- Camera.PreviewCallback | Android Developers
- android - Best use of HandlerThread over other similar classes - Stack Overflow
- Using concurrency to improve speed and performance in Android – Medium
- HandlerThread | Android Developers
- LinkedBlockingQueue | Android Developers
- AsyncTask | Android Developers
- ThreadPoolExecutor (Java Platform SE 7 )