Android 之如何优化 UI 渲染(下)

UI 优化系列专题,来聊一聊 Android 渲染相关知识,主要涉及 UI 渲染背景知识如何优化 UI 渲染两部分内容。


UI 优化系列专题
  1. UI 渲染背景知识
  1. 如何优化 UI 渲染

“学而时习之、温故而知新”,在介绍具体的 UI 优化方法之前,我们先来回顾一下在上期介绍的 UI 卡顿的排查方法。关于 UI 卡顿,我们介绍了 6 种本地排查工具,以及多种可以在线上监控 UI 卡顿、帧率的方法。

UI 渲染也会造成卡顿,大家是否会有这样的疑惑:UI 优化和卡顿优化两者之间有什么关系和区别吗?

在 Android 系统的 VSYNC 信号到达时,如果 UI 线程被某个耗时任务堵塞,长时间无法对 UI 进行渲染,这时就会出现卡顿,但它并不属于 UI 优化的范畴。UI 优化要解决的核心是由于渲染性能本身造成用户感知的卡顿,可以把它理解为卡顿优化的一个子集


每个开发者都希望自己的应用可以达到 60 fps “如丝般顺滑”。通过上一期的学习,我们已经清楚了问题所在,那有哪些方法可以帮助我们优化 UI 渲染,并保持在 60 fps?再来回顾下 UI 渲染阶段的流程图:


通用优化策略

我们的目标是保持 60 fps,这意味着所有操作都必须在 16 ms(=1000 ms / 60 fps) 内完成。优化的手段其实就是拆解渲染各阶段的耗时,找到瓶颈点再加以优化。下面我们就来聊一聊 UI 渲染的各个阶段都有哪些具体的优化手段和方法?

  1. 尽量使用硬件加速

在 Android 3.0 之前(或者没有启动硬件加速时),UI 绘制任务完全由 CPU 完成。然而 CPU 对于图形处理并不是那么高效,这个过程完全没有利用到 GPU 的图形高性能。所以 Android 从 3.0 开始加入硬件加速,但是直到 4.0 时才默认开启。说道这,如果你对硬件加速还不熟悉的话,可以参考《关于 UI 渲染,你需要了解什么?》。

通过对硬件加速的进一步了解,相信你也发自内心地认同硬件加速绘制的性能是远远高于软件绘制的。所以,UI 优化的第一个方法就是保证渲染尽量使用硬件加速

此时,大家可能会有这样的疑问:有哪些情况是我们不能使用硬件加速?之所以不能使用硬件加速,是因为硬件加速不能支持所有的 Canvas API,具体 API 兼容列表可以参考 drawing-support 文档。如果使用了不支持的 API,系统就需要通过 CPU 软件模拟绘制,这也是类似渐变、磨砂和圆角等效果渲染性能比较低的原因。

SVG 就是一个非常典型的例子,SVG 有很多指令都不支持硬件加速。不过凡事都有“利弊”,SVG 在加载时间、清晰度和体积方面相比 PNG 更具有优势。有关 SVG 在 Android 上的应用可以参考这里

其实我们还是可以用一个非常取巧的方法,提前将这些 SVG 转换成 Bitmap 缓存起来。这样,系统就可以更好地使用硬件加速绘制。同理对于其它圆角、渐变等场景我们也可以改为 Bitmap 实现。

public static Bitmap getBitmapFromvectorDrawable(@NonNull Context context, @DrawableRes int drawableId){
    Drawable drawable = ContextCompat.getDrawable(context, drawableId);
    if(Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP){
        drawable = (DrawableCompat.wrap(drawable)).mutate();
    }
    final Bitmap bitmap = Bitmap.createBitmap(drawable.getIntrinsicWidth(), drawable.getIntrinsicHeight(), Bitmap.Config.ARGB_8888);
    final Canvas canvas = new Canvas(bitmap);
    drawable.setBounds(0,0 , canvas.getWidth(), canvas.getHeight());
    drawable.draw(canvas);
    return bitmap;
}

  1. Create View 优化

观察 Android 渲染流水线时,大家是否有注意到这里缺少了一个非常重要的环节,那就是 View 创建的耗时。不要忘了 View 的创建也是在 UI 线程里,对于一些非常复杂的界面,这部分的耗时是不容忽视的。View 创建过程可以参考《View 绘制流程之 setContentView() 到底做了什么 ?》。

在优化 View 创建耗时之前,我们先来简单拆解下这部分内容:包括各种 XML 随机读取的 I/O 时间、解析 XML 的时间、生成对象的时间。需要注意 View 的创建在 Framework 层使用了大量反射。具体可以参考《Android 之 LayoutInflater 全面解析》。

相应的,我们来看看这个阶段有哪些优化的方法。

使用代码创建

使用 XML 进行 UI 界面编写可以说是非常方便的,还可以在 Android Studio 的 Preview 中实时预览到界面。

但是,如果我们需要对某个界面进行极致的优化,就可以使用代码进行编写界面。不过这种方式对于开发效率来说简直是灾难,此时我们可能会考虑一些取巧的方法,例如开源的 X2C 工具能够将 XML 转换为 Java 代码。但它还是有一些情况是不支持直接转换的。

  • <Merge/> 标签,由于在编译期间无法确定 XML 的 parent,所以无法支持。
  • 系统 style,在编译期间只能查到应用的 style 列表,无法查询系统 style,所以只支持应用内 style。

使用 XML 或 Java 两者在编写 UI 方面的综合对比(性能 & 效率):

实际开发过程中,我们需要兼容性能与开发效率,建议只在对性能要求非常高,但修改又不非常频繁的场景下才考虑使用代码创建


异步创建

此时,聪明的你肯定会想到,能不能把 View 的创建工作放到其他线程,实现 UI 的预加载呢?这里需要特别注意, View 的绘制任务最终还是要回到 UI 线程,否则系统会抛出如下异常:

void checkThread() {
    if (mThread != Thread.currentThread()) {
        throw new CalledFromWrongThreadException(
                "Only the original thread that created a view hierarchy can touch its views.");
    }
}
  • View 的绘制流程不一定非要在主线程,但它要求一定是在原线程。绝大多数操作系统 UI 框架都是单线程的,这主要是因为多线程的 UI 框架在设计上会非常复杂,而且由于线程同步等问题并不一定比单线程性能更好。

可能还有朋友反映会出现如下报错异常:

throw new RuntimeException( "Can't create handler inside thread 
                                                that has not called Looper.prepare()")

事实上,该异常的报错点发生在 Handler,在 Handler 的构造方法内会尝试获取当前线程的 Looper 对象,我们知道 UI 主线程的 Looper 对象是在 ActivityThread 的 main 方法中创建的,所以我们在 UI 主线程的任意位置进行 new Handler() 都没有问题,但是子线程默认是不存在 Looper 对象的,简单看下这一过程。

public Handler(Callback callback, boolean async) {
    // 获取当前线程的Looper对象,通过ThreadLocal
    mLooper = Looper.myLooper();
    if (mLooper == null) {
         // 非UI线程默认不存在Looper对象,此时便会抛出异常
        throw new RuntimeException(
            "Can't create handler inside thread that has not called Looper.prepare()");
    }
    // Looper中的MessageQueue,消息对垒
    mQueue = mLooper.mQueue;
    mCallback = callback;
    mAsynchronous = async;
}
  • Handler 必须在具有 Looper 对象的线程中工作,此时我们需要手动为当前线程开启消息队列。
@Override
public void run() {
    // 为当前线程创建Looper对象,Looper是线程单例(原理 ThreadLocal)
    Looper.prepare();
    final Handler handler = new Handler() {
        @Override
        public void handleMessage(Message msg) {
           // do somethings
        }
     }
     // 开启出队,不断的从MessageQueue中next
     Looper.loop();
}

View 重用

正常来说,View 会随着 Activity 或 Fragment 的销毁而同时销毁。系统的 ListView、RecycleView 通过 View 的缓存与重用大大提升了渲染性能。因此我们可以参考它们的思想,实现一套可以在不同 Activity 或 Fragment 使用的 View 缓存机制。

但是这里需要注意,所有进入缓存池的 View 都已经“净身出户”,不会保留之前的状态。


3. measure/layout 优化

渲染流程中 measure 和 layout 也是需要 CPU 在主线程执行的,大家网上查询关于 UI 优化的内容,绝大多数都是关于这块儿的优化建议,这里也简单做下总结:

  • 减少 UI 布局层次。例如尽量扁平化布局,使用<ViewStub/> <Merge/>等优化。

  • 优化 layout 的开销。尽量不再使用 RelativeLayout 或者基于 weight LinearLayout,它们 layout 的开销非常巨大。现在更加推荐使用 ConstraintLayout 替代 RelativeLayout 或者 weight LinearLayout。

  • 背景优化。尽量不要重复去设置背景,这里需要注意的是主题背景(theme),theme 默认会是一个纯色背景,如果我们自定义了界面的背景,那么主题的背景对我们来说是无用的,但是由于主题的背景设置在 DecorView 中,所以这里会带来 Overdraw,也就是无谓的绘制性能损耗。如果需要去掉 theme 背景色可以通过如下方式:

// 必须在 setContentView() 之前
getWindow().setBackgroundDrawable(null);

利用系统新特性

Android 渲染框架非常庞大,并且演进的非常快,接下来我们就一起看下系统又提供了哪些新的特性可以更好的帮助我们优化 UI 渲染。

1. RenderThread

Android 在 4.0 默认开启硬件加速,但是真正较大改善 UI 渲染性能的却是在 Android 4.1 的 Project Butter 黄油计划之后。虽然从此开始利用 GPU 的图形高性能运算,但是从计算 DisplayList,到通过 GPU 完成 Graphic Buffer 绘制,再到 Frame Buffer 整个计算和绘制都在 UI 主线程中完成。

UI 主线程 “既当爹又当妈”,任务过于繁重。所以在 Android 5.0 又引入了两个比较大的变化,一个是 RenderNode,它对 DisplayList 及一些 View 显示属性做了进一步封装;另一个是引入了 RenderThread,所有的 GL 命令执行都放到这个线程上,怎么理解它呢?

在 Android 5.0 之前,Android 的主线程同时也是 Open GL 线程,在此之后,Android 应用的 Open GL 线程就独立出来了,称之为 RenderThread。渲染线程在 RenderNode 中存有渲染帧的所有信息,对于 ViewPropertyAnimator 和 CircularReveal 动画,我们可以使用 RenderThread 实现动画的异步渲染,这样即便主线程发生阻塞的时候也可以保证动画的流畅。

下面我以 ViewPropertyAnimator 动画与普通属性动画为例:

  • 普通属性动画
private void startAnim(View v) {
    final ObjectAnimator objectAnimator = ObjectAnimator.ofFloat(v, "scaleY", 1, 2);
    objectAnimator.setDuration(2000);
    objectAnimator.start();
    // 模拟主线程发生卡顿
    delaySleep(1000, 2000);
}
  • 利用 RenderThread 异步动画渲染
private void startPropertyAnim(View v) {
    v.setScaleY(1);
    final ViewPropertyAnimator animator = v.animate().scaleY(2).setDuration(2000);
    // 必须在start之前调用
    AsyncAnimHelper.onStartBefore(animator, v);
    animator.start();
    // 模拟主线程发生卡顿
    delaySleep(1000, 2000);
}

设置两种动画分别在执行 1s 后,让主线程休眠 2s(模拟主线程卡顿)。可以很明显看到普通属性动画,在主线程阻塞的时候,会出现丢帧卡顿现象。而使用 RenderThread 渲染的动画即使阻塞了主线程仍然不受影响,如下图所示(上面控件为普通属性动画):

需要注意,目前支持 RenderThread 完全渲染的动画仅有两种:ViewPropertyAnimator 和 CircularReveal(揭露动画),不过它们在支持异步渲染的前提下还存在一些限制,具体可以参考这里


2. RenderScript

现在越来越多的应用会使用一些高级图片或者视频剪辑功能,例如图片的高斯模糊、放大、锐角等。拿日常我们使用最多的“扫一扫”这个场景,这里涉及大量的图片变化操作,例如缩放、剪裁、二值化以及降噪等。

经过上一期的介绍,图片的变换涉及大量的计算任务,这个时候使用 GPU 是更好的选择。此时我们便可以通过 RenderScirpt,它是 Android 系统上的一套 API,专门用于高性能地运行计算密集型任务,它允许开发者以较少的代码实现功能复杂且性能优越的应用程序。

以 RenderScript 提供的 ScriptIntrinsicBlur 实现图片的模糊效果为例,原图如下:

模糊后效果如下:

相比较基于 Java 实现的 fastblur ,RenderScript 在处理时间和内存占用都更具有优势。如何将 RenderScript 应用到我们的项目中?可以参考下面的一些实践方案:


UI 优化的进阶

在通用优化策略中,我们介绍了 View 的创建耗时也是不能忽视的,对于复杂的 UI 可以采用异步预加载的方式。在 Android 5.0 之后,系统更是将 View 的绘制任务抽离到 RenderThread 线程,那能不能更进一步,将 View 的 measure 和 layout 的计算任务也像 Create View 一样实现线程的预加载呢?这样可以极大地提升首次显示的性能。

TextView 是 Android 系统控件中非常强大也非常重要的一个控件,强大的背后就意味着需要做很多计算工作。在 2018 年的 Google I/O 大会,发布了 PrecomputedText 并已经集成在 Jetpack 中,它为我们提供了进行异步 measure 和 layout 的接口,进一步减轻 UI 线程的计算量。

那对于其他控件我们有没有可能采用类似的方式?接下来一起看下最近两年业界都有哪些优秀的方案。

1. Litho:异步布局

Litho 是 Facebook 开源的声明式 Android UI 渲染框架,它是基于另外一个 Facebook 开源的布局引擎 Yoga 开发的。Litho 本身非常强大,内部做了很多非常不错的优化,下面就来简单介绍一下它是如何优化 UI 的。

异步布局

一般来说,Android 所有的控件绘制都要遵守 measure -> layout -> draw 的流水线,并且这些都发生在主线程中。

但 Litho 如前面提到 PrecomputedText 一样,把 measure 和 layout 都放到了后台线程,只留下必须要在主线程完成的 draw,这有效降低了 UI 线程的负载。它的渲染流水变化如下:

极致扁平化

前面也有提到,降低 UI 的层级是一个非常通用的优化方法。Litho 给了我们一种在不用改变代码的情况下直接降低 UI 的层级。由于 Litho 使用了自有的布局引擎(Yoga),在布局阶段就可以检测不必要的层级,减少 ViewGroups 来实现 UI 扁平化。

如下图,使用 Litho 布局,我们可以得到一个极致扁平的视图效果,这对减少渲染时递归调用,加快渲染速度起到极大的帮助。上半部分是我们一般编写界面的方法,下半部分是 Litho 编写的界面,可以看到只有一层层级。

优化 RecycleView

Litho 还优化了 RecylerView 中 UI 组件的缓存和回收方法。原生的 RecycleView 或者 ListView 是按照 viewType 来进行缓存和回收,但如果一个 RecyclerView / ListView 中出现 viewType 过多,会使缓存形同虚设。

而 Litho 中的所有组件都可以被回收,它是按照 text、image 和 video 独立回收的,并在任意位置进行复用。这种细粒度的复用方式尤其适合复杂的滑动列表,可以极大地提高内存命中率,提高滚动帧率,内存优化非常明显。

Litho 还对类似 RecycleView 等可以提前知道下一个视图“长什么样子”的场景,利用 CPU 的闲置时间提前在异步线程中完成 Measure / Layout 过程。

Litho 虽然强大,但也有自己的缺点,它为了实现 measure/layout 异步化,使用了类似 react 单向数据流设计,这一定程度上加大了 UI 开发的复杂性。Litho 还抛弃了原生的布局方式,通过组件方式使用 Java/Kotlin 来编写 UI,无法做到在 AS 中预览。

public class MainActivity {
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        ComponentContext context = new ComponentContext(this);
        final Text.Builder builder = Text.create(context);
        final Component = builder.text("Hello World")
                .textSizeDip(40)
                .textColor(Color.parseColor("#666666"))
                .textAlignment(Layout.Alignment.ALIGN_CENTER)
                .build();
        final LithoView view = LithoView.create(context, component);
        setContentView(view);
    }
}

有关 Litho 的更多实践内容可以参照下面资料:


2. Flutter:自己的布局 + 渲染引擎

Litho 虽然通过使用自家的的布局引擎 Yoga,一定程度上突破了系统的某些限制,但是在 draw 之后依然走的系统的渲染机制。如下图所示:

那能不能把系统的渲染也同时接管过来?Flutter 正式这样的框架,它也是最近十分火爆的一个新框架。

Flutter 是 Google 推出并开源的移动应用开发框架,开发者可以通过 Dart 语言开发 App,一套代码同时运行在 iOS 和 Android 平台。并且它也是 Google 未来的新操作系统 Fuchsia 的默认开发套件。

Flutter 在 Android 上完全没有基于系统的渲染引擎,而是把 Skia 引擎直接集成进了 App 中,并且直接使用了 Dart 虚拟机,可以说是一套跳出 Android 的方案,所以 Flutter 可以很容易实现跨平台。下图为 Flutter 的整体框架:

总的来说 Flutter 吸取了各优秀前端框架的精髓,加上强大的 Dart 虚拟机和 Skia 渲染引擎,特别适合在追求 iOS 和 Android 跨平台的一致性,高性能的 UI 交互体验场景。可以说是一个非常优秀的框架,美团、今日头条等很多应用部分功能已经使用 Flutter 开发。

有关 Flutter 更多原理和实践资料可以参考:


最后

回顾一下 UI 渲染优化整个过程,从问题排查到定位,再到具体的优化手段,我们不难发现它们存在这样一个特征:

1. 在系统的框架内优化 。使用代码创建、布局优化、 View 缓存等都是这个思路,我们希望尽可能提高甚至省下渲染流水线里某个阶段的耗时。

2. 利用系统新特性。使用硬件加速、RenderThread、RenderScript 都是这个思路,通过系统一些新的特性,最大限度的提高应用性能。

3. 突破系统的限制。例如 Litho 突破了布局,Flutter 则更近一步,把渲染也接管过来了。这正是由于 Android 系统碎片化严重,很多好的特性可能低版本系统并不支持。而且系统需要支持所有的场景,在一些特定场景下无法实现最优解。这个时候就希望突破系统的条条框框。


大家平时在工作中,都做过哪些关于 UI 优化的工作,相信你肯定也有属于自己的优化杀手锏。欢迎大家留言分享或指正。

文章如果对你有帮助,请留个赞吧。


扩展阅读

其他系列专题

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,117评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,328评论 1 293
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,839评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,007评论 0 206
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,384评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,629评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,880评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,593评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,313评论 1 243
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,575评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,066评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,392评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,052评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,082评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,844评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,662评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,575评论 2 270