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,将

<Button
    android:id="@+id/button_settings"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:text="设置" />

替换为

<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()

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()方法,用来停止相机预览

public void stopPreview() {
    FrameLayout preview = (FrameLayout) findViewById(R.id.camera_preview);
    preview.removeAllViews();
}

stopPreview()获取相机预览所在的FrameLayout,然后通过removeAllViews()将相机预览移除,此时会触发CameraPreview类中的相关结束方法,关闭相机预览。

现在onCreate()的工作就很简单了,只需要将两个按钮绑定上对应的方法就好了

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中,修改

public class CameraPreview extends SurfaceView implements SurfaceHolder.Callback

public class CameraPreview extends SurfaceView implements SurfaceHolder.Callback, Camera.PreviewCallback

加入Camera.PreviewCallback接口声明。

加入onPreviewFrame()的实现

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()这句的下面加入

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中加入

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中加入

private void openCameraOriginal() {
    try {
        mCamera = Camera.open();
    } catch (Exception e) {
        Log.d(TAG, "camera is not available");
    }
}

这个不用解释,就是封装成了方法。

最后将getCameraInstance()修改为

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类,内容为

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继承HandlerThreadHandler.Callback接口,此接口实现handleMessage()方法,用来处理获得的Message。帧数据被封装到Message的obj属性中,用what进行标记。processFrame()即处理帧数据,这里仅作示例。

下面要在CameraPreview中实例化ProcessWithHandlerThread,绑定接口,封装帧数据,以及发送Message。

CameraPreview中添加新的成员变量

private static final int PROCESS_WITH_HANDLER_THREAD = 1;

private int processType = PROCESS_WITH_HANDLER_THREAD;

private ProcessWithHandlerThread processFrameHandlerThread;
private Handler processFrameHandler;

在构造函数末尾增加

switch (processType) {
    case PROCESS_WITH_HANDLER_THREAD:
        processFrameHandlerThread = new ProcessWithHandlerThread("process frame");
        processFrameHandler = new Handler(processFrameHandlerThread.getLooper(), processFrameHandlerThread);
        break;
}

注意这里的new Handler()同时也在绑定接口,让ProcessWithHandlerThread处理接收到的Message。

修改onPreviewFrame()

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类,内容为

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中添加新的成员变量

private static final int PROCESS_WITH_QUEUE = 2;

private ProcessWithQueue processFrameQueue;
private LinkedBlockingQueue<byte[]> frameQueue;

private int processType = PROCESS_WITH_THREAD_POOL;

修改为

private int processType = PROCESS_WITH_QUEUE;

在构造函数的switch中加入

case PROCESS_WITH_QUEUE:
    frameQueue = new LinkedBlockingQueue<>();
    processFrameQueue = new ProcessWithQueue(frameQueue);
    break;

这里使用LinkedBlockingQueue满足并发性要求,由于只操作队头和队尾,采用链表结构。

onPreviewFrame()的switch中加入

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类,内容为

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[],返回StringdoInBackground()内的代码就是在异步执行,这里就是processFrame(),处理帧数据,这里仅作示例。

下面要在CameraPreview中实例化ProcessWithAsyncTask,将帧数据交给AsyncTask。与之前介绍的方法不一样,每次处理新的帧数据都要实例化一个新的ProcessWithAsyncTask并执行。

CameraPreview中添加新的成员变量

private static final int PROCESS_WITH_ASYNC_TASK = 3;

private int processType = PROCESS_WITH_QUEUE;

修改为

private int processType = PROCESS_WITH_ASYNC_TASK;

onPreviewFrame()的switch中加入

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类,内容为

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中添加新的成员变量

private static final int PROCESS_WITH_THREAD_POOL = 4;

private ProcessWithThreadPool processFrameThreadPool;

private int processType = PROCESS_WITH_ASYNC_TASK;

修改为

private int processType = PROCESS_WITH_THREAD_POOL;

在构造函数的switch中加入

case PROCESS_WITH_THREAD_POOL:
    processFrameThreadPool = new ProcessWithThreadPool();
    break;

onPreviewFrame()的switch中加入

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

参考