转载自:Penguin
Android Camera Develop: process preview frames in real time efficiently
概述
本篇我们暂时不介绍像相机APP增加新功能,而是介绍如何处理相机预览帧数据。想必大多数人都对处理预览帧没有需求,因为相机只需要拿来拍照和录像就好了,实际上本篇和一般的相机开发也没有太大联系,但因为仍然是在操作Camera类,所以还是归为相机开发。处理预览帧简单来说就是对相机预览时的每一帧的数据进行处理,一般来说如果相机的采样速率是30fps的话,一秒钟就会有30个帧数据需要处理。帧数据具体是什么?如果你就是奔着处理帧数据来的话,想必你早已知道答案,其实就是一个byte类型的数组,包含的是YUV格式的帧数据。本篇仅介绍几种高效地处理预览帧数据的方法,而不介绍具体的用处,因为拿来进行人脸识别、图像美化等又是长篇大论了。
本篇在Android相机开发(二): 给相机加上偏好设置的基础上介绍。预览帧数据的处理通常会包含大量的计算,从而导致因为帧数据太多而处理效率低下,以及衍生出的预览画面卡顿等问题。本篇主要介绍分离线程优化画面显示,以及通过利用HandlerThread、Queue、ThreadPool和AsyncTask来提升帧数据处理效率的方法。
准备
为了简单起见,我们在相机开始预览的时候就开始获取预览帧并进行处理,为了能更清晰地分析这个过程,我们在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()
将相机预览移除,此时会触发CameraPreview
类中的相关结束方法,关闭相机预览。
现在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();
}
});
}
运行试试
现在运行APP不会立即开始相机预览了,点击“开始”按钮屏幕上才会出现相机预览画面,点击“停止”则画面消失,预览停止。
基本的帧数据获取和处理
这里我们首先实现最基础,也是最常用的帧数据获取和处理的方法;然后看看改进提升性能的方法。
基础
获取帧数据的接口是Camera.PreviewCallback
,实现此接口下的onPreviewFrame(byte[] data, Camera camera)
方法即可获取到每个帧数据data
。所以现在要做的就是给CameraPreview
类增加Camera.PreviewCallback
接口声明,再在CameraPreview
中实现onPreviewFrame()
方法,最后给Camera
绑定此接口。这样相机预览时每产生一个预览帧,就会调用onPreviewFrame()
方法,处理预览帧数据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()
的实现
Java
public void onPreviewFrame(byte[] data, Camera camera) {
Log.i(TAG, "processing frame");
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
这里并没有处理帧数据data
,而是暂停0.5秒模拟处理帧数据。
在surfaceCreated()
中getCameraInstance()
这句的下面加入
Java
mCamera.setPreviewCallback(this);
将此接口绑定到mCamera
,使得每当有预览帧生成,就会调用onPreviewFrame()
。
运行试试
现在运行APP,点击“开始”,一般在屏幕上观察不到明显区别,但这里其实有两个潜在的问题。其一,如果你这时点击“设置”,会发现设置界面并不是马上出现,而是会延迟几秒出现;而再点击返回键,设置界面也会过几秒才消失。其二,在logcat中可以看到输出的"processing frame"
,大约0.5秒输出一条,因为线程睡眠设置的是0.5秒,所以一秒钟的30个帧数据只处理了2帧,剩下的28帧都被丢弃了(这里没有非常直观的方法显示剩下的28帧被丢弃了,但事实就是这样,不严格的来说,当新的帧数据到达时,如果onPreviewFrame()
正在执行还没有返回,这个帧数据就会被丢弃)。
与UI线程分离
问题分析
现在我们来解决第一个问题。第一个问题的原因很简单,也是Android开发中经常碰到的:UI线程被占用,导致UI操作卡顿。在这里就是onPreviewFrame()
会阻塞线程,而阻塞的线程就是UI线程。
onPreviewFrame()
在哪个线程执行?官方文档里有相关描述:
Called as preview frames are displayed. This callback is invoked on the event thread open(int) was called from.
意思就是onPreviewFrame()
在执行Camera.open()
时所在的线程运行。而目前Camera.open()
就是在UI线程中执行的(因为没有创建新进程),对应的解决方法也很简单了:让Camera.open()
在非UI线程执行。
解决方法
这里使用HandlerThread来实现。HandlerThread会创建一个新的线程,并且有自己的loop,这样通过Handler.post()
就可以确保在这个新的线程执行指定的语句。虽然说起来容易,但还是有些细节问题要处理。
先从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,在构造函数中就tart()
启动这个Thread,并创建一个handler。openCamera()
要达到的效果是在此线程中执行mCamera = Camera.open();
,因此通过handler.post()
在Runnable()
中执行,我们将要执行的语句封装在openCameraOriginal()
中。使用notify-wait是为安全起见,因为post()
执行会立即返回,而Runnable()
会异步执行,可能在执行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
来处理。
运行试试
现在运行APP,会发现第一个问题已经解决了。
处理帧数据
接下来解决第二个问题,如何确保不会有帧数据被丢弃,即保证每个帧数据都被处理。解决方法的中心思想很明确:让onPreviewFrame()
尽可能快地返回,不至于丢弃帧数据。
下面介绍4种比较常用的处理方法:HandlerThread、Queue、AsyncTask和ThreadPool,针对每一种方法简单分析其优缺点。
HandlerThread
简介
采用HandlerThread就是利用Android的Message Queue来异步处理帧数据。流程简单来说就是onPreviewFrame()
调用时将帧数据封装为Message,发送给HandlerThread,HandlerThread在新的线程获取Message,对帧数据进行处理。因为发送Message所需时间很短,所以不会造成帧数据丢失。
实现
新建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
接口,此接口实现handleMessage()
方法,用来处理获得的Message。帧数据被封装到Message的obj
属性中,用what
进行标记。processFrame()
即处理帧数据,这里仅作示例。
下面要在CameraPreview
中实例化ProcessWithHandlerThread
,绑定接口,封装帧数据,以及发送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;
在构造函数末尾增加
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;
}
}
这里将帧数据data
封装为Message,并发送出去。
运行试试
现在运行APP,在logcat中会出现大量的"test",你也可以自己修改processFrame()
进行测试。
分析
这种方法就是灵活套用了Android的Handler机制,借助其消息队列模型Message Queue解决问题。存在的问题就是帧数据都封装为Message一股脑丢给Message Queue会不会超出限度,不过目前还没遇到。另一问题就是Handler机制可能过于庞大,相对于拿来处理这个问题不太“轻量级”。
Queue
简介
Queue方法就是利用Queue建立帧数据队列,onPreviewFrame()
负责向队尾添加帧数据,而由处理方法在队头取出帧数据并进行处理,Queue就是缓冲和提供接口的角色。
实现
新建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。为能够独立处理帧数据以及随时处理帧数据,ProcessWithQueue
继承Thread
,并重载了run()
方法。run()
方法中的死循环用来随时处理Queue中的帧数据,mQueue.take()
在队列空时阻塞,因此不会造成循环导致的CPU占用。processFrame()
即处理帧数据,这里仅作示例。
下面要在CameraPreview
中创建队列并实例化ProcessWithQueue
,将帧数据加入到队列中。
在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;
在构造函数的switch中加入
Java
case PROCESS_WITH_QUEUE:
frameQueue = new LinkedBlockingQueue<>();
processFrameQueue = new ProcessWithQueue(frameQueue);
break;
这里使用LinkedBlockingQueue
满足并发性要求,由于只操作队头和队尾,采用链表结构。
在onPreviewFrame()
的switch中加入
Java
case PROCESS_WITH_QUEUE:
try {
frameQueue.put(data);
} catch (InterruptedException e) {
e.printStackTrace();
}
break;
将帧数据加入到队尾。
运行试试
现在运行APP,在logcat中会出现大量的"test",你也可以自己修改processFrame()
进行测试。
分析
这种方法可以简单理解为对之前的HandlerThread方法的简化,仅用LinkedBlockingQueue
来实现缓冲,并且自己写出队列处理方法。这种方法同样也没有避开之前说的缺点,如果队列中的帧数据不能及时处理,就会造成队列过长,占用大量内存。但优点就是实现简单方便。
AsyncTask
简介
AsyncTask方法就是用到了Android的AsyncTask类,这里就不详细介绍了。简单来说每次调用AsyncTask都会创建一个异步处理事件来异步执行指定的方法,在这里就是将普通的帧数据处理方法交给AsyncTask去执行。
实现
新建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()
内的代码就是在异步执行,这里就是processFrame()
,处理帧数据,这里仅作示例。
下面要在CameraPreview
中实例化ProcessWithAsyncTask
,将帧数据交给AsyncTask。与之前介绍的方法不一样,每次处理新的帧数据都要实例化一个新的ProcessWithAsyncTask
并执行。
在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
,向其传递帧数据data
并执行。
运行试试
现在运行APP,在logcat中会出现大量的"test",你也可以自己修改processFrame()
进行测试。
分析
这种方法代码简单,但理解其底层实现有难度。AsyncTask实际是利用到了线程池技术,可以实现异步和并发。其相对之前的方法的优点就在于并发性高,但也不能无穷并发下去,还是会受到帧处理时间的制约。另外根据官方文档中的介绍,AsyncTask的出现主要是为解决UI线程通信的问题,所以在这里算旁门左道了。AsyncTask相比前面的方法少了“主控”的部分,可能满足不了某些要求。
ThreadPool
简介
ThreadPool方法主要用到的是Java的ThreadPoolExecutor类,想必之前的AsyncTask就显得更底层一些。通过手动建立线程池,来实现帧数据的并发处理。
实现
新建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
构造函数建立线程池,corePoolSize
为并发度,这里就是处理器核心个数,线程池大小maximumPoolSize
则被设置为并发度的两倍。post()
则用来通过线程池执行帧数据处理方法。processFrame()
即处理帧数据,这里仅作示例。
下面要在CameraPreview
中实例化ProcessWithThreadPool
,将帧数据交给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;
在构造函数的switch中加入
Java
case PROCESS_WITH_THREAD_POOL:
processFrameThreadPool = new ProcessWithThreadPool();
break;
在onPreviewFrame()
的switch中加入
Java
case PROCESS_WITH_THREAD_POOL:
processFrameThreadPool.post(data);
break;
将帧数据交给ThreadPool。
运行试试
现在运行APP,在logcat中会出现大量的"test",你也可以自己修改processFrame()
进行测试。
分析
ThreadPool方法相比AsyncTask代码更清晰,显得不太“玄乎”,但两者的思想是一致的。ThreadPool方法在建立线程池时有了更多定制化的空间,但同样没能避免AsyncTask方法的缺点。
一点唠叨
上面介绍的诸多方法都只是大概描述了处理的思想,在实际使用时还要根据需求去修改,但大体是这样的流程。因为实时处理缺乏完善的测试方法,所以bug也会经常存在,还需要非常小心地去排查;比如处理的帧中丢失了两三帧就很难发现,即使发现了也不太容易找出出错的方法,还需要大量的测试。
上面介绍的这些方法都是根据我踩的无数坑总结出来的,因为一直没找到高质量的介绍实时预览帧处理的文章,所以把自己知道的一些知识贡献出来,能够帮到有需要的人就算达到目的了。
关于帧数据和YUV格式等的实际处理问题,可以参考我之前写的一些Android视频解码和YUV格式解析的文章,也希望能够帮到你。
DEMO
本文实现的相机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 )
网友评论