百度360必应搜狗淘宝本站头条
当前位置:网站首页 > 编程字典 > 正文

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

toyiye 2024-09-08 09:56 3 浏览 0 评论

启程

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

为什么 GoogleSurfaceView 设计的这么难用?

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

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

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

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

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

一、世界观

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

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

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

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

由此可见,通常情况下,一个 ActivityUI 渲染本质是 系统提供一块内存,并创建一个图形缓冲区进行维护;这块内存就是 Surface,最终页面所有 ViewUI 状态数据,都会被填充到同一个 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 会将前后两者的2Layer 进行 合成渲染

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

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

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

3.具体思路

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

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

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

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

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

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

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

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

三、施工

1. 视图树与挖洞

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

首先,SurfaceView 的设计依然遵循 AndroidView 体系,继承了 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 将其所在区域设置为透明(挖洞);并建立了与独立绘图表面的连接,后续便可与其直接通信。

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; 
  // ...
}

如代码所示,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 的这种设计虽然满足不了严苛的业务诉求,但在绝大多数场景下,独立绘图表面 这种设计都能够保证足够的渲染性能,同时不影响主线程输入事件的处理,绝对是一个优秀的设计。

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;
  }
}

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

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

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

4. 令人头大的黑屏问题

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

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

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

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

// /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 的源码是没有公开暴露的。

设计者其实也想到了这个问题,因此额外提供了一个 SurfaceHolderAPI 接口,通过该接口,开发者可以直接拿到独立绘图表面的 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 以替换 SurfaceViewTextureView 的原理是和 View 一样绘制到当前 Activity 的窗口上,因此不存在 SurfaceView 的这些问题。

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

作者:却把清梅嗅
链接:https://juejin.cn/post/7140191497982312455
来源:稀土掘金

相关推荐

# Python 3 # Python 3字典Dictionary(1)

Python3字典字典是另一种可变容器模型,且可存储任意类型对象。字典的每个键值(key=>value)对用冒号(:)分割,每个对之间用逗号(,)分割,整个字典包括在花括号({})中,格式如...

Python第八课:数据类型中的字典及其函数与方法

Python3字典字典是另一种可变容器模型,且可存储任意类型对象。字典的每个键值...

Python中字典详解(python 中字典)

字典是Python中使用键进行索引的重要数据结构。它们是无序的项序列(键值对),这意味着顺序不被保留。键是不可变的。与列表一样,字典的值可以保存异构数据,即整数、浮点、字符串、NaN、布尔值、列表、数...

Python3.9又更新了:dict内置新功能,正式版十月见面

机器之心报道参与:一鸣、JaminPython3.8的热乎劲还没过去,Python就又双叒叕要更新了。近日,3.9版本的第四个alpha版已经开源。从文档中,我们可以看到官方透露的对dic...

Python3 基本数据类型详解(python三种基本数据类型)

文章来源:加米谷大数据Python中的变量不需要声明。每个变量在使用前都必须赋值,变量赋值以后该变量才会被创建。在Python中,变量就是变量,它没有类型,我们所说的"类型"是变...

一文掌握Python的字典(python字典用法大全)

字典是Python中最强大、最灵活的内置数据结构之一。它们允许存储键值对,从而实现高效的数据检索、操作和组织。本文深入探讨了字典,涵盖了它们的创建、操作和高级用法,以帮助中级Python开发...

超级完整|Python字典详解(python字典的方法或操作)

一、字典概述01字典的格式Python字典是一种可变容器模型,且可存储任意类型对象,如字符串、数字、元组等其他容器模型。字典的每个键值key=>value对用冒号:分割,每个对之间用逗号,...

Python3.9版本新特性:字典合并操作的详细解读

处于测试阶段的Python3.9版本中有一个新特性:我们在使用Python字典时,将能够编写出更可读、更紧凑的代码啦!Python版本你现在使用哪种版本的Python?3.7分?3.5分?还是2.7...

python 自学,字典3(一些例子)(python字典有哪些基本操作)

例子11;如何批量复制字典里的内容2;如何批量修改字典的内容3;如何批量修改字典里某些指定的内容...

Python3.9中的字典合并和更新,几乎影响了所有Python程序员

全文共2837字,预计学习时长9分钟Python3.9正在积极开发,并计划于今年10月发布。2月26日,开发团队发布了alpha4版本。该版本引入了新的合并(|)和更新(|=)运算符,这个新特性几乎...

Python3大字典:《Python3自学速查手册.pdf》限时下载中

最近有人会想了,2022了,想学Python晚不晚,学习python有前途吗?IT行业行业薪资高,发展前景好,是很多求职群里严重的香饽饽,而要进入这个高薪行业,也不是那么轻而易举的,拿信工专业的大学生...

python学习——字典(python字典基本操作)

字典Python的字典数据类型是基于hash散列算法实现的,采用键值对(key:value)的形式,根据key的值计算value的地址,具有非常快的查取和插入速度。但它是无序的,包含的元素个数不限,值...

324页清华教授撰写【Python 3 菜鸟查询手册】火了,小白入门字典

如何入门学习python...

Python3.9中的字典合并和更新,了解一下

全文共2837字,预计学习时长9分钟Python3.9正在积极开发,并计划于今年10月发布。2月26日,开发团队发布了alpha4版本。该版本引入了新的合并(|)和更新(|=)运算符,这个新特性几乎...

python3基础之字典(python中字典的基本操作)

字典和列表一样,也是python内置的一种数据结构。字典的结构如下图:列表用中括号[]把元素包起来,而字典是用大括号{}把元素包起来,只不过字典的每一个元素都包含键和值两部分。键和值是一一对应的...

取消回复欢迎 发表评论:

请填写验证码