前言
说起Surface截图,很多人一个惯性思维是使用MediaProjection框架,但是有点杀鸡使用宰牛刀的问题,实际上,MediaProjection往往需要申请权限,其录制范围包括第三方app,但是如果是自身app,实际上完全没有必要,仅仅使用DisplayManager创建虚拟屏即可,连权限都不需要申请。
实际上,Android Surface截图实际上不算什么难事,在Android N版本开始,系统就已经提供了PixelCopy类来截取Surface,同时还支持缩放,也就是会根据传入的Bitmap大小进行缩放,这种方式可以辅助我们实现画面调整。
关于截屏
在之前一篇文章中我们提到过,截取自身屏幕实际上也有最简单的方法,技术使用DecorView#draw方法将UI绘制到Bitmap上,然后再将Bitmap绘制到android.media.MediaCodec#createInputSurface创建的Surface上即可。
SurfaceView截图问题
但是,有一种情况比较特殊,那就是UI中包含SurfaceView和GLSurfaceView,那么其View是无法截取到的,SurfaceView绘制完之后会直接将GraphicBuffer提交给SurfaceFlinger,因此是无法绘制到的。
TextureView截图问题
SurfaceView存在问题,那么TextureView是不是可以使用DecorView#draw,实际上并不是,这个时候我们要使用TexureView#gitBitmap + DecorView#Bitmap 合成,或者使用PixelCopy的新方法
java
public static void request(@NonNull Window source, @NonNull Bitmap dest,
@NonNull OnPixelCopyFinishedListener listener, @NonNull Handler listenerThread) {
request(source, null, dest, listener, listenerThread);
}
上面的方法仅仅支持Android 8.0 版本,实际上我们在Android 7上也可以参考Android 8的实现,也能获得截图,理论上是合成后的画面。
PixelCopy的缺陷
PixelCopy最大的缺陷是不支持Android 7之前的版本。
PixelCopy性能如何?
一般来说,我们可能认为PixelCopy性能较差,但实际上他的代码却是【相反的表达】,主要是下面的逻辑中,线程涉及是
主线程截取图片,在子线程中发送图片
用中国思维来理解,其表达的意思是:【拿走你的东西,别挡着老子干活】。道理很简单,此方法截图很快,不要低估他的性能。
下面是代码
java
public static void request(@NonNull Surface source, @Nullable Rect srcRect,
@NonNull Bitmap dest, @NonNull OnPixelCopyFinishedListener listener,
@NonNull Handler listenerThread) {
validateBitmapDest(dest);
if (!source.isValid()) {
throw new IllegalArgumentException("Surface isn't valid, source.isValid() == false");
}
if (srcRect != null && srcRect.isEmpty()) {
throw new IllegalArgumentException("sourceRect is empty");
}
// TODO: Make this actually async and fast and cool and stuff
int result = ThreadedRenderer.copySurfaceInto(source, srcRect, dest); //在主线程截图
listenerThread.post(new Runnable() {
@Override
public void run() {
listener.onPixelCopyFinished(result); //发送到其他线程
}
});
}
总之,这个方法性能还是可以的,然而,如果我们要实现定时截图,那么需要对Bitmap进行池化,使得Bitmap能够被复用。
Surface截取
实际上,截屏中最困难的就是SurfaceView和GLSurfaceView的截屏,不过后者可以使用glReadPixels来读取argb缓存。
java
private Bitmap captureBitmap() {
int width = getWidth();
int height = getHeight();
int size = width * height * 4;
if(size <= 0) return null;
try {
if(mPBufferPixels == null || mPBufferPixels.capacity() != size) {
mPBufferPixels = ByteBuffer.allocateDirect(size)
.order(ByteOrder.nativeOrder());
}
GLES20.glReadPixels(/*x*/ 0, /*y*/ 0, width, height,
GLES20.GL_RGBA, GLES20.GL_UNSIGNED_BYTE, mPBufferPixels);
checkGlError("glReadPixels");
Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
bitmap.copyPixelsFromBuffer(mPBufferPixels);
return bitmap;
}catch (Throwable e){
e.printStackTrace();
}
return null;
}
那么SurfaceView怎么办呢,前面说过,Android 7之后可以使用PixelCopy工具,但是Android 7之前的版本呢?
虚拟屏方案
Android 7之前对于Surface可以说几乎无能为力,不过Android 4.4开始提供DisplayManager#createVirtualDisplay可以解决此问题,我们可以创建一个虚拟屏
java
public VirtualDisplay createVirtualDisplay(@NonNull String name,
int width, int height, int densityDpi, @Nullable Surface surface, int flags,
@Nullable VirtualDisplay.Callback callback, @Nullable Handler handler) {
final VirtualDisplayConfig.Builder builder = new VirtualDisplayConfig.Builder(name, width,
height, densityDpi);
builder.setFlags(flags);
if (surface != null) {
builder.setSurface(surface);
}
return createVirtualDisplay(null /* projection */, builder.build(), callback, handler);
}
我们知道Android系统中的虚拟屏默认是会投影DefaultDisplay上的画面,上面方法中的Surface我们可以使用ImageReader来创建,从而实现截屏。
java
imageReader = ImageReader.newInstance(1280, 720, ImageFormat.YUV_420_888, 3);
imageReader.setOnImageAvailableListener(new ImageReader.OnImageAvailableListener() {
@Override
public void onImageAvailable(ImageReader reader) {
Image image = reader.acquireLatestImage();
if(image == null) return;
Surface surface = videoSurface;
if(surface == null) {
image.close();
return;
}
synchronized (surface){
if(surface.isValid()){
byte[] yuv420SP = ImageBitmapUtil.getBytesFromImageAsType(image, ImageBitmapUtil.NV21);
int[] rgb = ImageBitmapUtil.decodeYUV420SP(yuv420SP, image.getWidth(), image.getHeight());
Bitmap bitmap = Bitmap.createBitmap(rgb, 0, image.getWidth(), image.getWidth(),
image.getHeight(),
Bitmap.Config.ARGB_8888);
Canvas canvas = surface.lockCanvas(null);
canvas.drawBitmap(bitmap,0,0,null);
surface.unlockCanvasAndPost(canvas);
bitmap.recycle();
}
}
image.close();
}
},new Handler(thread.getLooper()));
将imageReader#getSurface传入下面方法接口
java
virtualDisplay = displayManager.createVirtualDisplay("AutoProjection", displayMetrics.widthPixels, displayMetrics.heightPixels, displayMetrics.densityDpi,surface,DisplayManager.VIRTUAL_DISPLAY_FLAG_PRESENTATION);
综上,Android 7.0我们使用PixelCopy ,而低版本可以使用虚拟屏方案
引申: 如何实现副屏录屏
我们知道,Android 系统只能录制主屏幕,那如果想录制指定的副屏如何实现?
scrcpy方案
看过scrcpy 源码的应该知道,scrcpy利用SurfaceControl和日志嗅探出副屏的displayId,通过通过SurfaceControl方法SurfaceFlinger从而实现了副屏录制
但是缺陷是只支持ADB方法,需要屏幕录制权限,且访问SurfaceFlinger的权限属于系统权限,普通app无法获取。
虚拟屏中转方案
所谓中砖方案是先中转到虚拟屏,然后虚拟屏输出内容到副屏的同时截取画面,从而实现副屏边录边播。当然如果是简单的录制Surface,EGL离屏渲染即可。
Android 11 虚拟屏
Android 11 中的DisplayManager提供了一些方法createVirtualDisplay可以指定displayId,具体暂时没有试过,后续有时间我们补充一下。
Android 11 mirrorSurface
Android 11 提供了另一种方案,但是被@hide注解,暂时没有试过,后续试一下
系统代码:com.android.systemui.accessibility.WindowMagnificationController#createMirror
java
private void createMirror() {
mMirrorSurface = WindowManagerWrapper.getInstance().mirrorDisplay(mDisplayId);
if (!mMirrorSurface.isValid()) {
return;
}
mTransaction.show(mMirrorSurface)
.reparent(mMirrorSurface, mMirrorSurfaceView.getSurfaceControl());
modifyWindowMagnification(mTransaction);
mTransaction.apply();
}
总结
本篇,我们主要针对Surface截屏方法进行了总结,当然,如果要放到生产环境,如果仅仅是简单的截图就已经够了,但是要是实现视频录制,我们还需要做更多的内存优化,比如前文提到的Bitmap池化(享元模式)。另外我们还可能涉及Bitmap转ByteBuffer(Direct ByteBuffer 不会因为GC而整理内存碎片,引发内存地址变化)的处理,意味着ByteBuffer池化,这部分就不赘述了。
本篇就到这里,希望对你有所帮助。