专栏名称: 郭霖
Android技术分享平台,每天都有优质技术文章推送。你还可以向公众号投稿,将自己总结的技术心得分享给大家。
目录
相关文章推荐
郭霖  ·  Android外接设备开发使用一网打尽 ·  昨天  
郭霖  ·  使用Hilt来协助封装网络请求 ·  1 周前  
郭霖  ·  StateFlow 和 ... ·  1 周前  
郭霖  ·  Android 跨进程+解耦的数据持久化方案 ·  1 周前  
51好读  ›  专栏  ›  郭霖

Android Surface截图方法总结

郭霖  · 公众号  · android  · 2024-11-19 08:00

正文



/   今日科技快讯   /


近日,华为海报官宣华为 Mate品牌盛典定档2024年11月26日。


余承东曾称华为Mate70“史上最强大的 Mate”。据网上曝光信息显示,Mate70系列将延续 Mate60系列标志性的三挖孔屏设计,提供直屏和微曲屏两种选择;有望搭载麒麟9100处理器,手机信号处将可现实5.5G的标识;搭载原生鸿蒙系统等等。


据悉,除了华为Mate70系列,鸿蒙智行智界新S7将在发布会上正式发布,尊界首款车型将会在当天亮相。


/   作者简介   /


本篇文章转自时光少年的博客,文章主要分享了Android 开发中 Surface 截图的相关内容,相信会对大家有所帮助!


原文地址:

https://juejin.cn/post/7398748051878084648


/   前言   /


说起Surface截图,很多人一个惯性思维是使用MediaProjection框架,但是有点杀鸡使用宰牛刀的问题,实际上,MediaProjection往往需要申请权限,其录制范围包括第三方app,但是如果是自身app,实际上完全没有必要,仅仅使用DisplayManager创建虚拟屏即可,连权限都不需要申请,就能实现主屏录制。


当然,我们本篇主要谈论的Surface截图,一些情况下,我们可能需要录制部分内容。


实际上,Android Surface截图实际上不算什么难事,在Android N版本开始,系统就已经提供了PixelCopy类来截取Surface,支持指定录制区域,同时也支持Bitmap缩放,也就是会根据传入的Bitmap大小进行缩放,这种方式可以辅助我们实现画面调整。


/   关于录屏   /


在之前一篇文章中我们提到过,录制自身屏幕实际上也有最简单的方法,技术上就是使用DecorView#draw方法将UI绘制到Bitmap上,然后再将Bitmap绘制到android.media.MediaCodec#createInputSurface创建的Surface上即可,定时绘制即可。


Canvas canvas = surface.lockCanvas(null);
//调用lockHardwareCanvas会后台再回来会出现画面卡主,这里我们使用lockCanvas比较保险 canvas.drawBitmap(bitmap,0,0,null); 
surface.unlockCanvasAndPost(canvas); 
bitmap.recycle();

SurfaceView截图问题


但是,有一种情况比较特殊,那就是UI中包含SurfaceView和GLSurfaceView,那么其View是无法截取到的,原因是SurfaceView绘制完之后会直接将GraphicBuffer提交给SurfaceFlinger,因此是无法绘制到的。因此需要特殊的方式来实现,比如GrapicBuffer Client缓存读取、屏幕录制等。


当然,TextureView就不会有这种问题,本质上和他的工作机制有关。


TextureView截图问题


SurfaceView存在问题,那么TextureView是不是可以使用DecorView#draw,实际上并不是,这个时候我们要使用TexureView#gitBitmap + DecorView#Bitmap 合成,或者使用PixelCopy的新方法。


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性能较差,但实际上他的代码却是【相反的表达】,主要是核心逻辑中,似乎并不认为截图耗时,反而认为调用者处理结果才耗时。线程设计是思想如下。


主线程截取图片,在子线程中发送图片结果


用中国思维来理解,其表达的意思是:【拿走你的东西,别挡着老子干活】。道理很简单,此方法截图很快,不要低估他的性能。


下面是代码。


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缓存。


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());
 }else{
       mPBufferPixels.reset(); //重置position
}
        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可以解决此问题,我们可以创建一个虚拟屏。


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来创建,从而实现截屏。


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 = null;
        try{
            canvas = surface.lockCanvas(null);
            canvas.drawBitmap(bitmap,0,0,null);
            bitmap.recycle();
        }catch(Throwable ignore){
          ignore.printStackTrace();
        }finally{
           if(canvas != null){ //防止绘制时异常,导致无法释放的问题
             surface.unlockCanvasAndPost(canvas);
         }
      }
    }
    image.close();
  }
},new Handler(thread.getLooper()));

ImageReader 风险点


对于ImageReader而言,其本身当然也存在一些风险,比如ImageReader创建的Surface“切剧”或者后传递给下一个实例后,可能出现无法收到数据的问题,目前还没定位到原因;ImageReader 在Genymotion模拟器上不支持YUV_420_888,在官方模拟器上只支持RGBA_8888,因此避免在模拟器上使用。将imageReader#getSurface传入下面方法接口。


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方式下使用:


在App中录制一般需要录屏权限(排除仅录制当前app的),假设这个权限你有了,但是你使用scrpcy到项目中就会发现,也需要访问SurfaceFlinger的权限,访问SurfaceFlinger的权限属于系统权限,普通app根本无法获取到,因此只能在adb模式运行。


虚拟屏中转方案


所谓中砖方案是先中转到虚拟屏,然后虚拟屏输出内容到副屏的同时截取画面,从而实现副屏边录边播。当然如果是简单的录制Surface,EGL离屏渲染即可。


Android 11 虚拟屏


Android 11 中的DisplayManager提供了一些方法createVirtualDisplay可以指定displayId,具体暂时没有试过,后续有时间我们补充一下。


总体上,设置DisplayId的操作理论上应该,但仍然缺少成熟的案例。


Android 11 mirrorSurface


Android 11 提供了另一种方案,但是被@hide注解,暂时没有试过,后续试一下。


不过我们看下的调用方式。


系统代码:com.android.systemui.accessibility.WindowMagnificationController#createMirror。


private void createMirror() {
    mMirrorSurface = WindowManagerWrapper.getInstance().mirrorDisplay(mDisplayId);
    if (!mMirrorSurface.isValid()) {
        return;
    }
    mTransaction.show(mMirrorSurface)
            .reparent(mMirrorSurface, mMirrorSurfaceView.getSurfaceControl());

    modifyWindowMagnification(mTransaction);
    mTransaction.apply();
}

这种调用方式显然只能在系统权限的条件下使用,因为DisplayContent在app  中无法获取到,另外Surface绑定SurfaceControl的方法目前还没看到。


那我们换一种思路,在Android 10开始,Google为了优化MediaCodec#setOutputSurface引发解码器异常问题,提供了一种新的离屏渲染是,以此来避免对对setOutputSurface的调用,一些应用也在尝试使用这种方式做离屏渲染和无缝切换,具体可以参考ExoPlayer中的demo-surface。


这个方案的最大改动点是将Surface和SurfaceControl实现了绑定。


SurfaceControl surfaceControl = new SurfaceControl.Builder()
        .setName(SURFACE_CONTROL_NAME)
        .setBufferSize(/* width= */ 0, /* height= */ 0)
        .build();
videoSurface = new Surface(surfaceControl);
player.setVideoSurface(videoSurface);

因此,我们理论上可以利用此方案和mirrorSurface实现分屏渲染和录制。


/   总结   /


本篇,我们主要针对Surface截屏方法进行了总结,当然,如果要放到生产环境,如果仅仅是简单的截图就已经够了,但是要是实现视频录制,我们还需要做更多的内存优化,比如前文提到的Bitmap池化(享元模式)。另外我们还可能涉及Bitmap转ByteBuffer(Direct ByteBuffer 不会因为GC而整理内存碎片,引发内存地址变化)的处理,意味着ByteBuffer池化,这部分就不赘述了。


本篇就到这里,希望对你有所帮助。


推荐阅读:

我的新书,《第一行代码 第3版》已出版!

Android Native内存越用越多,会不会触发GC?

Kotlin异步Web框架,Ktor 3.0 来啦!


欢迎关注我的公众号

学习技术或投稿



长按上图,识别图中二维码即可关注