知识专区
关于我们 产品中心 解决方案 新闻资讯 客户案例 知识专区 售后服务 联系我们
知识专区:反思: Google 为何把 SurfaceView 设计的这么难用?
2022-10-21    点击关注我们

版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。

如果你有过SurfaceView的使用经历,那么你一定和我一样,曾经被它所引发出 层出不穷的异状 折磨的 怀疑人生—— 毕竟,作为一个有理想的开发者,在深入了解SurfaceView之前,你很难想通这样一个问题:

为什么Google把SurfaceView设计的这么难用?

  • 不支持transform动画;
  • 不支持半透明混合;
  • 移动,大小改变,隐藏/显示操作引发的各种问题;

另一方面,即使你对SurfaceView使用不多,图形系统 的这朵乌云依然笼罩在每一位Android开发者的头顶,来看Google对其的 描述

在这里插入图片描述

最终我尝试走近这片迷雾,并一点点去思考下列问题的答案:

    1. SurfaceView的设计初衷是为了解决什么问题?
    1. 实际开发中,SurfaceView这么 难用 的根本原因是什么?
    1. 为了解决这些问题,Google的工程师进行了哪些 尝试

接下来,读者可带着这些问题,跟随笔者一起,再次回顾SurfaceView设计和实现的精彩历程。

一、世界观

在了解SurfaceView的设计初衷之前,读者首先需要对Android现有的图形架构有一个基本的了解。

Android系统采用一种称为Surface的图形架构,简而言之,每一个Activity都关联有至少一个Window(窗口),每一个Window都对应有一个Surface。

Surface这里直译过来叫做 绘图表面 ,顾名思义,其可在内存中生成一个图形缓冲区队列,用于描述UI,经与系统服务的WindowServiceManager通信后、通过SurfaceFlinger服务持续合成并送显到显示屏。

读者可通过下图,在印象上对整个流程建立一个简单的轮廓:

在这里插入图片描述

由此可见,通常情况下,一个Activity的UI渲染本质是 系统提供一块内存,并创建一个图形缓冲区进行维护;这块内存就是Surface,最终页面所有View的UI状态数据,都会被填充到同一个Surface中。

截至目前一切正常,但需要指出的是,现有图形系统的架构设计中还藏了一个线程相关的 隐患

二、设计起源

1.线程问题

问题点在于:我们还需保证Surface内部Buffer缓冲区的 线程安全

这样的描述,对于读者似乎太过飘渺,但从结论来说,最终,一条Android开发者 耳熟能详 的规则因此而诞生:

主线程不能执行耗时操作

我们知道,UI的所有操作,一定会涉及到视图(View树) 内部大量状态的维护,而Surface内部的缓冲区也会不断地被读写,并交给系统渲染。因此,如果UI相关的操作,放在不同的线程中执行,而多线程对这一块内存区域的读写,势必会引发内部状态的混乱。

为了避免这个问题,设计者就需要通过某种手段保证线程同步(比如加锁),而这种同步所带来的巨大开销,对于开发者而言,是不可接受的。

因此,最合理的方案就是保证所有UI相关操作都在同一个线程,而这个线程也被称作 主线程 或UI线程。

现在,我们将UI操作限制到主线程去执行,以解决了本小节开始时提到的线程问题,但开发者仍需小心—— 众所周知,主线程除了执行UI相关的操作之外,还负责接收各种各样的 输入事件(比如触摸、按键等),因此,为了保证用户的输入事件能够及时得到响应,我们就要保证UI操作的 稳定高效,尽可能避免耗时的UI操作。

2.动机

挑战随之而来。

当渲染的缓冲数据来自外部的其它系统服务或API时——比如系统媒体解码器的音视频数据,或者Camera API的相机数据等,这时UI渲染的效率要求会变得非常高。

开发者有了新的诉求:能否有这样一种特殊的视图,它拥有独立的Surface,这样就可以脱离现有Activity宿主的限制,在一个独立的线程中进行绘制。

由于该视图不会占用主线程资源,一方面可以实现复杂而高效的UI渲染,另一方面可以及时响应用户其它输入事件

因此,SurfaceView应运而生:与常规视图控件不同,SurfaceView拥有独立的Surface,如果我们将一个Surface理解为一个层级 (Layer),最终SurfaceFlinger会将前后两者的2个Layer进行 合成渲染

在这里插入图片描述

现在,我们引用官方文档的描述,再次重申适用SurfaceView的场景:

在需要渲染到单独的Surface(例如,使用Camera API或OpenGL ES上下文进行渲染)时,使用SurfaceView进行渲染很有帮助。使用SurfaceView进行渲染时,SurfaceFlinger会直接将缓冲区合成到屏幕上。

如果没有SurfaceView,您需要将缓冲区合成到屏幕外的Surface,然后该Surface会合成到屏幕上,而使用SurfaceView进行渲染可以省去额外的工作。

3.具体思路

根据当前的设想,我们针对 SurfaceView 设计思路进行细化。

首先,我们需对现有的视图树结构进行改造。为了便于使用,我们允许开发者将SurfaceView直接加入到现有的视图树中(即作为控件,它受限于宿主View Hierachy的结构关系),但在系统服务端中,对于SurfaceFlinger而言,SurfaceView又是完全与宿主完全分离开的:

在这里插入图片描述

在上图中,我们可以看到,在z轴上,SurfaceView默认是低于DecorView的,也就是说,SurfaceView通常总是处于当前页面的最下方。

这似乎有些违反直觉,但仔细考虑SurfaceView的应用场景,无论是Camera相机应用、音视频播放页,亦或者是渲染游戏画面等,SurfaceView承载的画面似乎总应该在页面的最下面。

实际设计中也是如此,用来描述SurfaceView的Layer或者LayerBuffer的z轴位置默认是低于宿主窗口的。与此同时,为了便于最底层的视图可见,SurfaceView在宿主Activity的窗口上设置了一块透明区域(挖了一个洞)。

最终,SurfaceFlinger把所有的Layer通过用统一流程来绘制和合成对应的UI。

在整个过程中,我们需更进一步深入研究几个细节:

  1. SurfaceView与宿主视图树结构的关系,以及 挖洞 过程的实现;
  2. SurfaceView与系统服务的通信创建Surface的实现;
  3. SurfaceView具体绘制流程的实现。

三、施工

1. 视图树与挖洞

一句话总结SurfaceView与视图树的关系: 在视图树内部,但又没完全在内部

首先,SurfaceView的设计依然遵循Android的View体系,继承了View,这意味着使用时,它可以声明在xml布局文件中:

// /frameworks/base/core/java/android/view/SurfaceView.java public class SurfaceView extends View { } 
			

			
  • 1
  • 2

出于安全性的考量,SurfaceView相关源码并未直接开放出来,开发者只能看到自动生成的一个接口类,源码可以借助梯子在 这里 查阅。

LayoutInflater布局填充阶段,按既有的布局填充流程,将SurfaceView构造并加入到视图树的某个结点;接下来,根布局会通过深度遍历依次执行onAttachedToWindow()处理视图挂载窗口的事件:

// /frameworks/base/core/java/android/view/SurfaceView.java @Override protected void onAttachedToWindow() { // ... mParent.requestTransparentRegion(SurfaceView.this); // 1. ViewTreeObserver observer = getViewTreeObserver(); observer.addOnPreDrawListener(mDrawListener); // 2. } @UnsupportedAppUsage private final ViewTreeObserver.OnPreDrawListener mDrawListener = new ViewTreeObserver.OnPreDrawListener() { @Override public boolean onPreDraw() { updateSurface(); // 3. return true; } }; protected void updateSurface() { // ... mSurfaceSession = new SurfaceSession(); mSurfaceControl = new SurfaceControl.Builder(mSurfaceSession); // 4 //... } 
			

			
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24

步骤1中,SurfaceView会向父视图依次向上请求创造一份透明区域,根视图统计到最终的信息后,通过Binder通知WindowManagerService将对应区域设置为透明。

步骤2、3、4是在同一个方法的调用栈中,由此可见,SurfaceView向系统请求透明区域后,会立即创建一个与绘图表面的连接SurfaceSession,并创建一个对应的控制器SurfaceControl,便于对这个独立的绘图表面进行直接通信。

由此可见,Android自有的视图树体系中,SurfaceView作为一个普通的View被挂载上去之后,通过Binder通信,WindowManagerService将其所在区域设置为透明(挖洞);并建立了与独立绘图表面的连接,后续便可与其直接通信。

2. 子图层类型

在阐述绘制流程之前,读者需简单了解 子图层类型 的概念。

上文说到,SurfaceView的绝大多数使用场景中,其z轴的位置通常是在页面的 最下方 。但在实际开发中,随着业务场景复杂度的上升,仍然有部分场景是无法被满足的,比如:在页面的最上方播放一条全屏的视频广告。

因此,SurfaceView的设计中引入了一个 子图层类型 的概念,用于定义这个独立的Surface相比较当前页面窗口 (即Activity) 的位置:

// /frameworks/base/core/java/android/view/SurfaceView.java public class SurfaceView extends View { // SurfaceView 的子图层类型 int mSubLayer = APPLICATION_MEDIA_SUBLAYER; // SurfaceView 是否展示在当前窗口的最上方 // 该方法在挖洞和绘制流程中都有使用,最终影响到用户的视觉效果 private boolean isAboveParent() { return mSubLayer >= 0; } } // /frameworks/base/core/java/android/view/WindowManagerPolicyConstants.java public interface WindowManagerPolicyConstants { // ... int APPLICATION_MEDIA_SUBLAYER = -2; int APPLICATION_MEDIA_OVERLAY_SUBLAYER = -1; int APPLICATION_PANEL_SUBLAYER = 1; int APPLICATION_SUB_PANEL_SUBLAYER = 2; int APPLICATION_ABOVE_SUB_PANEL_SUBLAYER = 3; // ... } 
			

			
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23

如代码所示,mSubLayer默认值为-2,这表示SurfaceView默认总是在Activity的下方,想要让SurfaceView展示在Activity上方,可以调用setZOrderOnTop(true)以修改mSubLayer的值:

// /frameworks/base/core/java/android/view/SurfaceView.java public class SurfaceView extends View { public void setZOrderOnTop(boolean onTop) { if (onTop) { mSubLayer = APPLICATION_PANEL_SUBLAYER; } else { mSubLayer = APPLICATION_MEDIA_SUBLAYER; } } public void setZOrderMediaOverlay(boolean isMediaOverlay) { mSubLayer = isMediaOverlay ? APPLICATION_MEDIA_OVERLAY_SUBLAYER : APPLICATION_MEDIA_SUBLAYER; } } 
			

			
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15

现在,无论是将SurfaceView放在页面的上方还是下方,都轻而易举。

但这仍然无法满足所有诉求,比如针对具有alpha通道的透明视频进行渲染时,产品希望其所在的图层位置能够更灵活(在两个View之间),但由于SurfaceView自身设计的原因,其并无法与视图树融合,这也正是SurfaceView饱受诟病的主要原因之一。

通过辩证的观点来看,SurfaceView的这种设计虽然满足不了严苛的业务诉求,但在绝大多数场景下,独立绘图表面 这种设计都能够保证足够的渲染性能,同时不影响主线程输入事件的处理,绝对是一个优秀的设计。

3.子图层类型-插曲

值得一提的是,在SurfaceView的设计中,设计者还考虑到了音视频渲染时,字幕相关业务的场景,因此额外提供了一个setZOrderMediaOverlay()方法:

// /frameworks/base/core/java/android/view/SurfaceView.java public class SurfaceView extends View { public void setZOrderMediaOverlay(boolean isMediaOverlay) { mSubLayer = isMediaOverlay ? APPLICATION_MEDIA_OVERLAY_SUBLAYER : APPLICATION_MEDIA_SUBLAYER; } } 
			

			
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

该方法的设计说明了2点:

首先,由于APPLICATION_MEDIA_SUBLAYER和APPLICATION_MEDIA_OVERLAY_SUBLAYER都小于0,因此,无论如何,字幕始终被渲染在页面的下方。又因为视频理应渲染在字幕的下方,所以 不推荐 开发者在使用SurfaceView渲染视频时调用setZOrderOnTop(true),将视频放在页面视图的顶层。

其次,同时具有setZOrderOnTop()和setZOrderMediaOverlay()方法,显然是提供给两个不同SurfaceView分别使用的,以定义不同的渲染层级,因此同一个页面存在多个SurfaceView是正常的,开发者完全可以根据业务场景,合理运用。

4. 令人头大的黑屏问题

在使用SurfaceView的过程中,笔者最终也遇到了 默认黑屏 的问题:

由于视频本身的加载和编解码的耗时,用户总是会先看到SurfaceView的黑色背景一闪而过,然后视频才开始播放的情况,对于产品而言,这种交互体验是 不可容忍 的。

通过上文读者知道,SurfaceView拥有独立的绘制表面,因此常规对付View的一些手段——比如setVisibility()、setAlpha()、setBackgroundColor()并不能解决上述问题;因此,想真正解决它,就必须先弄清楚SurfaceView底层的绘制流程。

SurfaceView虽然特殊,但其作为视图树的一个结点,其依然参与到了视图树常规绘制流程,这里我们直接看SurfaceView的draw()方法:

// /frameworks/base/core/java/android/view/SurfaceView.java public class SurfaceView extends View { //... @Override public void draw(Canvas canvas) { if (mDrawFinished && !isAboveParent()) { // 1. if ((mPrivateFlags & PFLAG_SKIP_DRAW) == 0) { clearSurfaceViewPort(canvas); } } super.draw(canvas); } private void clearSurfaceViewPort(Canvas canvas) { // ... canvas.drawColor(0, PorterDuff.Mode.CLEAR); // 2. } } 
			

			
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19

由此可见,当满足!isAboveParent()的条件——即SurfaceView的子图层类型位于宿主视图的下方时,SurfaceView默认会将绘图表面的颜色指定为黑色。

显然,该问题最简单的解决方式就是对源码进行hook或者反射,遗憾的是,上文我们也提到了,出于安全性的考量,SurfaceView的源码是没有公开暴露的。

设计者其实也想到了这个问题,因此额外提供了一个SurfaceHolder的API接口,通过该接口,开发者可以直接拿到独立绘图表面的Canvas对象,以及对这个画布进行绘制操作:

// /frameworks/base/core/java/android/view/SurfaceHolder.java public interface SurfaceHolder { // ... public Canvas lockCanvas(); public void unlockCanvasAndPost(Canvas canvas); //... } 
			

			
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

遗憾的是,即使拿到Canvas,开发者仍然会受到限制:

// /frameworks/base/core/java/com/android/internal/view/BaseSurfaceHolder.java public abstract class BaseSurfaceHolder implements SurfaceHolder { private final Canvas internalLockCanvas(Rect dirty, boolean hardware) { if (mType == SURFACE_TYPE_PUSH_BUFFERS) { throw new BadSurfaceTypeException("Surface type is SURFACE_TYPE_PUSH_BUFFERS"); } // ... } } 
			

			
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

这里的代码,笔者引用 罗升阳这篇文章 中的一段来解释:

注意,只有在一个SurfaceView的绘图表面的类型不是SURFACE_TYPE_PUSH_BUFFERS的时候,我们才可以自由地在上面绘制UI。我们使用SurfaceView来显示摄像头预览或者播放视频时,一般就是会将它的绘图表面的类型设置为SURFACE_TYPE_PUSH_BUFFERS。在这种情况下,SurfaceView的绘图表面所使用的图形缓冲区是完全由摄像头服务或者视频播放服务来提供的,因此,我们就不可以随意地去访问该图形缓冲区,而是要由摄像头服务或者视频播放服务来访问,因为该图形缓冲区有可能是在专门的硬件里面分配的。

由此可见,SurfaceView黑屏问题的原因是综合且复杂的,无论是通过setZOrderOnTop()等方法设置为背景透明(但是会在页面层级的最上方),亦或者调整布局参数,都会有大大小小的一些问题。

小结

综合来看,SurfaceView这些饱受争议的问题,从设计的角度来看,都是有其自身考量的。

而为了解决这些问题,官方后续提供了TextureView以替换SurfaceView,TextureView的原理是和View一样绘制到当前Activity的窗口上,因此不存在SurfaceView的这些问题。

换个角度来看,由于TextureView渲染依赖于主线程,因此也会导致了新的问题出现。除了性能比较SurfaceView会有明显下降外,还会有经常掉帧的问题,有机会笔者会另起一篇进行分享。

阅读:599
OpenCV中的图像变换——傅里叶变换
OpenCV中的图像变换——傅里叶变换
静态路由简介及配置
静态路由简介及配置
PyTorch实现苹果M1芯片GPU加速:训练速度提升7倍,性能最高提升21倍
PyTorch实现苹果M1芯片GPU加速:训练速度提升7倍,性能最高提升21倍
Redis到底是什么?
Redis到底是什么?
win10 如何做到 C盘 的绝对干净,所有软件都安装到D盘,C盘只用来存操作系统。
win10 如何做到 C盘 的绝对干净,所有软件都安装到D盘,C盘只用来存操作系统。
相见恨晚的8个电脑端设计软件
相见恨晚的8个电脑端设计软件,没用过的都后悔了!
安卓APP全局黑白化实现方案
安卓APP全局黑白化实现方案
机器学习编译器的前世今生
机器学习编译器的前世今生
瑞芯微RV1126及RV1109 IPC方案优势解析
瑞芯微RV1126及RV1109 IPC方案优势解析
从苹果 M1 看芯片技术发展趋势
对于开发者来说,今年频频登上科技版头条的新闻莫过于苹果发布的 M1 芯片了,业界首款使用领先的 5nm 技术的 PC 芯片。在苹果 
上一篇:Flutter 3.3 之 SelectionArea 好不好用?用 “Bug” 带你全面了解它
下一篇:LLVM之父Chris Lattner:编译器的黄金时代
关于我们 产品中心 解决方案 新闻资讯 客户案例 知识专区 售后服务 联系我们
我们的联系方式
联系地址:云南省昆明市官渡区永平路188号鑫都韵城写字楼6栋1004号
联系电话:0871-64605728、传真号码:0871-64605728
电子邮箱:19701580@qq.com
点击拨打 0871-64605728 咨询我们
长按指纹即可关注我们
微网站由云港互联设计开发  点击进入
【版权声明】本站部分内容由互联网用户自行发布,著作权或版权归原作者所有。如果侵犯到您的权益请发邮件致info@ynjwz.com,我们会第一时间进行删除并表示歉意。