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

fe7beb5e3ed6371d0afbacfe12960f54.jpeg

/   今日科技快讯   /

近日,根据中国载人航天官方透露,神舟十五号载人飞船与空间站组合体成功实现自主快速交会对接后,航天员乘组从飞船返回舱进入轨道舱。完成各项准备后,北京时间2022年11月30日7时33分,神舟十四号航天员乘组打开舱门,两个航天员乘组胜利会师,并在空间站进行了合影。

/   作者简介   /

本篇文章转自却把清梅嗅的博客,文章主要分享了他对SurfaceView设计思路和难点的相关理解,相信会对大家有所帮助!

原文地址:

https://juejin.cn/post/7140191497982312455

/   前言   /

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

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

  • 不支持 transform 动画;

  • 不支持半透明混合;

  • 移动,大小改变,隐藏/显示操作引发的各种问题;

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

77b61b065cc09496a0a1380492e2d422.jpeg

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

  • SurfaceView 的设计初衷是为了解决什么问题?

  • 实际开发中,SurfaceView 这么难用的根本原因是什么?

  • 为了解决这些问题,Google 的工程师进行了哪些尝试 ?

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

/   世界观   /

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

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

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

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

cad2a9ae374a106f8a698f8df01403a9.jpeg

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

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

/   设计起源   /

线程问题

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

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

主线程不能执行耗时操作。

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

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

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

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

动机

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

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

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

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

4a0131a8b4a4d1a7be32fbd10d8f0369.jpeg

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

在需要渲染到单独的 Surface(例如,使用 Camera API 或 OpenGL ES 上下文进行渲染)时,使用 SurfaceView 进行渲染很有帮助。使用 SurfaceView 进行渲染时,SurfaceFlinger 会直接将缓冲区合成到屏幕上。如果没有 SurfaceView,您需要将缓冲区合成到屏幕外的 Surface,然后该 Surface 会合成到屏幕上,而使用 SurfaceView 进行渲染可以省去额外的工作。


具体思路

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

11cab110f36ba6cef1415baf8abb14ea.jpeg

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

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

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

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

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

  1. SurfaceView 与宿主视图树结构的关系,以及挖洞过程的实现;

  2. SurfaceView 与系统服务的通信创建 Surface 的实现;

  3. SurfaceView 具体绘制流程的实现。

/   施工   /

视图树与挖洞

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

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

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

出于安全性的考量,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 中,SurfaceView 会向父视图依次向上请求创造一份透明区域,根视图统计到最终的信息后,通过 Binder 通知 WindowManagerService 将对应区域设置为透明。

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

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


子图层类型

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

上文说到,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; 
  // ...
}

如代码所示,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;
  }
}

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

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

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

子图层类型-插曲

值得一提的是,在 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;
  }
}

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

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

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

令人头大的黑屏问题

在使用 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.
  }
}

由此可见,当满足 !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);
  //...
}

遗憾的是,即使拿到 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");
    }
    // ...
  }
}

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

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

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

/   写在最后   /

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

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

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

推荐阅读:

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

Kotlin Flow响应式编程,操作符函数进阶

Android开发中的WMS详细解析

欢迎关注我的公众号

学习技术或投稿

2e071292bfa3b1800ff1ff1c02a2cfde.png

77b89fefe159bbe7d6bfa28c699c0f30.jpeg

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

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
SurfaceView是一个用于绘制复杂的2D图形、视频以及相机预览等的Android视图。与普通的View不同,SurfaceView在屏幕上绘制时,它的内容可以在一个独立的线程中进行绘制,这使得它可以在绘制内容时保持较高的性能。 以下是使用SurfaceView的基本步骤: 1. 在XML布局文件中添加SurfaceView视图,或者在代码中使用构造函数创建SurfaceView视图。 2. 在SurfaceView的生命周期方法中,获取SurfaceHolder对象并添加SurfaceHolder.Callback回调。 3. 在SurfaceHolder.Callback回调方法中,实现surfaceCreated()、surfaceChanged()和surfaceDestroyed()三个方法,以处理SurfaceView的生命周期。 4. 在surfaceCreated()方法中,创建并启动绘制线程,在线程中绘制SurfaceView的内容。 5. 在surfaceChanged()方法中,处理SurfaceView的大小变化。 下面是一个简单的示例代码: ``` public class MySurfaceView extends SurfaceView implements SurfaceHolder.Callback { private SurfaceHolder surfaceHolder; private MySurfaceViewThread myThread; public MySurfaceView(Context context, AttributeSet attrs) { super(context, attrs); surfaceHolder = getHolder(); surfaceHolder.addCallback(this); } @Override public void surfaceCreated(SurfaceHolder holder) { myThread = new MySurfaceViewThread(holder); myThread.setRunning(true); myThread.start(); } @Override public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) { // 处理SurfaceView大小变化 } @Override public void surfaceDestroyed(SurfaceHolder holder) { boolean retry = true; myThread.setRunning(false); while (retry) { try { myThread.join(); retry = false; } catch (InterruptedException e) { // 线程被中断,继续尝试关闭线程 } } } private class MySurfaceViewThread extends Thread { private SurfaceHolder surfaceHolder; private boolean isRunning; public MySurfaceViewThread(SurfaceHolder holder) { surfaceHolder = holder; isRunning = false; } public void setRunning(boolean run) { isRunning = run; } @Override public void run() { while (isRunning) { Canvas canvas = null; try { canvas = surfaceHolder.lockCanvas(null); synchronized (surfaceHolder) { // 在画布上绘制图形 } } finally { if (canvas != null) { surfaceHolder.unlockCanvasAndPost(canvas); } } } } } } ``` 在这个示例中,我们创建了一个名为`MySurfaceView`的SurfaceView,并在构造函数中获取了`SurfaceHolder`对象并添加了回调。在`surfaceCreated()`方法中,我们创建了一个绘制线程并启动它。在线程中,我们使用`SurfaceHolder`对象来获取`Canvas`并在上面绘制图形。在`surfaceDestroyed()`方法中,我们停止线程并等待线程结束。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值