Android启动性能优化及实践

725 阅读18分钟

一、前言

随着项目版本的迭代,App的性能问题会逐渐暴露出来。低性能的APP常见的表现有启动/界面切换慢、动画掉帧、卡顿、耗电,甚至出现应用无响应、程序崩溃的现象。

二、初识启动加速

来看一下Google官方文档[《Launch-Time Performance》] 对应用启动优化的概述;

应用的启动分为冷启动、热启动、温启动,而启动最慢、挑战最大的就是冷启动:系统和App本身都有更多的工作要从头开始! 应用在冷启动之前,要执行三个任务:

  • 加载并启动App;
  • App在启动后立即显示应用的空白启动窗口;
  • 创建App的进程;

而这三个任务执行完毕之后会马上执行以下任务:

  • 创建App对象;
  • 启动Main Thread;
  • 创建启动的Activity对象;
  • 加载View;
  • 布置屏幕;
  • 进行第一次绘制;

而一旦App进程完成了第一次绘制,系统进程就会用主Activity替换已经展示的Background Window,此时用户就可以使用App了。

下图显示系统进程和应用进程之间如何交接工作。 wPnhsx.jpg

作为普通应用,App进程的创建等环节我们是无法主动控制的,可以优化的也就是Application、Activity创建以及回调等过程。

同样,Google也给出了启动加速的方向:

  • 利用提前展示出来的Window,快速展示出来一个界面,给用户快速反馈的体验;
  • 避免在启动时做密集沉重的初始化(Heavy app initialization);
  • 定位问题:避免I/O操作、反序列化、网络操作、布局嵌套等。

备注:方向1属于治标不治本,只是表面上快;方向2、3可以真实的加快启动速度。 接下来我们就在项目中实际应用。

三、启动加速之主题切换

按照官方文档的说明:使用Activity的windowBackground主题属性来为启动的Activity提供一个简单的drawable。 Layout XML file:

# AndroidManifest.xml
<activity
    android:name=".SplashActivity"
    android:label="@string/app_name"
    android:launchMode="singleTask"
    android:screenOrientation="portrait"
    android:theme="@style/SplashTheme">
    <intent-filter>
        <action android:name="android.intent.action.MAIN" />
        <category  android:name="android.intent.category.LAUNCHER" />
    </intent-filter>
</activity>

# styles.xml
<style name="SplashTheme" parent="AppTheme">
    <item name="android:windowFullscreen">true</item>
    <item name="android:windowBackground">@drawable/bg_splash_layer_list</item>
    <item name="android:windowNoTitle">true</item>
    <item name="android:windowDrawsSystemBarBackgrounds">false</item>
</style>

# bg_splash_layer_list.xml
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
    <item>
        <shape>
            <solid android:color="#ffd500" />
        </shape>
    </item>
    <item
        android:top="200dp"
        android:left="100dp"
        android:right="100dp">
        <bitmap
            android:src="@drawable/splash_slogan"
            android:gravity="top|center_horizontal"/>
    </item>
</layer-list>

这样在启动的时候,会先展示一个界面,这个界面就是Manifest中设置的Style,等Activity加载完毕后,再去加载Activity的界面,而在Activity的界面中,我们将主题重新设置为正常的主题,从而产生一种快的感觉。不过如上文总结这种方式其实并没有真正的加速启动过程,而是通过交互体验来优化了展示的效果。

四、通过CPU Profiler找到关键问题并解决

概览


要打开 CPU Profiler,请按以下步骤操作:

    1. 依次选择 View > Tool Windows > Profiler 或点击工具栏中的 Profile 图标 。

如果 Select Deployment Target 对话框显示提示,请选择要将您的应用部署到哪个设备上以进行性能剖析。如果您已通过 USB 连接设备但系统未列出该设备,请确保您已启用 USB 调试

  • 2. 点击 CPU 时间轴上的任意位置以打开 CPU Profiler。

当您打开 CPU Profiler 时,它会立即开始显示应用的 CPU 使用率和线程活动。系统会显示类似于图 1 的界面。 d0fJjP.png 图 1. CPU Profiler 中的时间轴。

如图 1 所示,CPU Profiler 的默认视图包括以下时间轴:

记录跟踪数据


要开始记录跟踪数据,请从 CPU Profiler 顶部的下拉菜单中选择记录配置,然后点击 Recordd05qbV.png 图 2. CPU Profiler 显示了正在进行的记录的状态、持续时间和类型。

与您的应用交互,然后在完成时点击 Stop。分析器将自动选择记录的时间范围,并在跟踪数据窗格中显示其跟踪信息,如图 3 所示。如果要检查其他线程的跟踪数据,请从线程活动时间轴上选择相应线程。 d0Ie8H.png 图 3. 记录方法跟踪数据后的 CPU Profiler。

  • 1.选定范围 :确定要在跟踪数据窗格中检查所记录时间的哪一部分。当您首次记录跟踪数据时,CPU Profiler 会自动在 CPU 时间轴上选择记录的完整长度。 要仅检查已记录的时间范围中的一部分的跟踪数据,请拖动突出显示区域的边缘。
  • 2.时间戳:指示所记录跟踪数据的开始和结束时间(相对于分析器开始收集 CPU 使用率信息的时间)。要选择完整的记录,请点击时间戳。
  • 3.踪数据窗格:显示您选择的时间范围和线程的跟踪数据。此窗格要在您至少记录一条跟踪数据后才会显示。 在此窗格中,您可以选择如何查看每个堆栈轨迹(使用跟踪数据标签页),以及如何测量执行时间(使用时间参考下拉菜单)。
  • 4.跟踪数据窗格标签页:选择如何显示跟踪数据详细信息。如需详细了解各个选项,请参阅检查跟踪数据。
  • 5.时间参考菜单:选择以下选项之一,以确定如何测量每次调用的时间信息:
    • Wall clock time:该时间信息表示实际经过的时间。
    • Thread time:时间信息表示实际经过的时间减去线程没有占用 CPU 资源的那部分时间。对于任何给定的调用,其线程时间始终小于或等于其挂钟时间。使用线程时间可以让您更好地了解线程的实际 CPU 使用率中有多少是给定方法或函数占用的。
  • 6.过滤器:按函数、方法、类或软件包名称过滤跟踪数据。例如,如果您要快速识别与特定调用相关的跟踪数据,请点击 Filter 图标,然后在搜索字段中输入相应的名称。在 Call chartFlame chart 标签页中,会突出显示包含符合搜索查询条件的调用、软件包或类的调用堆栈。在 Top downBottom up 标签页中,这些调用堆栈优先于其他跟踪结果。您还可以通过勾选搜索字段旁边的相应方框来启用以下选项:
    • Regex:要在您的搜索中包含正则表达式,请使用此选项。
    • Match case:如果您的搜索区分大小写,请使用此选项。

创建、修改或查看记录配置

您可以在 CPU Recording Configurations 对话框中创建、修改和查看记录配置,从 CPU Profiler 顶部的记录配置下拉菜单中选择 Edit configurations 即可打开该对话框。

要查看某个现有记录配置的设置,请在 CPU Recording Configurations 对话框的左侧窗格中选择该配置。

要创建一个新的记录配置,请执行以下操作:

  • 1.点击对话框左上角的 Add 图标 +。这样会创建一个包含一些默认设置的新配置。
  • 2.为您的配置命名。
  • 3.选择一种 Trace Technology
  • 4.对于采样记录配置,以微秒 (μs) 为单位指定 Sampling interval。此值表示应用的每个调用堆栈样本的时间间隔。指定的时间间隔越短,达到记录数据的文件大小限制就越快。
    1. 对于写入连接设备的记录数据,以兆字节 (MB) 为单位指定 File size limit。当您停止记录时,Android Studio 会解析此数据并将其显示在分析器窗口中。因此,如果您提高此限制并记录大量的数据,Android Studio 解析文件所需的时间会大大增加,并且可能会变得无响应。

注意:如果您使用的连接设备搭载的是 Android 8.0(API 级别 26)或更高版本,那么对跟踪数据的文件大小没有限制,系统会忽略此值。不过,您仍需留意每次记录后设备收集了多少数据,Android Studio 可能会无法解析大型跟踪文件。例如,如果您记录的是采样时间间隔很短的采样跟踪数据,或是在应用于短时间内调用许多方法的情况下记录检测跟踪数据,那么很快就会生成大型跟踪文件。

    1. 要接受所做的更改并继续对其他配置进行更改,请点击 Apply。要接受进行的所有更改并关闭对话框,请点击 OK

使用 Debug API 记录 CPU 活动

使用 Debug API,可以让应用能够在 CPU Profiler 中开始和停止记录 CPU 活动。 要使用 Debug API 控制 CPU 活动的记录,请将检测的应用部署到搭载 Android 8.0(API 级别 26)或更高版本的设备上。

Debug.startMethodTracingSampling("Vision.trace", 8000000, 1000)

Debug.stopMethodTracing()

重要提示:Debug 应该与用于开始和停止 CPU 活动记录的其他方法(如 CPU Profiler 图形界面中的按钮,以及在应用启动时执行的自动记录的记录配置中的设置)分开使用。

导出跟踪数据

使用 CPU Profiler 记录 CPU 活动后,您可以将相应数据导出为 .trace 文件,以便与他人共享或日后进行检查。

要从 CPU 时间轴导出跟踪文件,请执行以下操作:

  1. 在 CPU 时间轴上,右键点击要导出的记录的方法跟踪数据或系统跟踪数据。
  2. 从菜单中选择 Export trace
  3. 浏览到要保存文件的目标位置,指定文件名,然后点击 OK

要从 Sessions 窗格导出跟踪文件,请执行以下操作:

  1. Sessions 窗格中,右键点击要导出的记录的跟踪数据。
  2. 点击会话条目右侧的 Export method traceExport system trace 按钮。
  3. 浏览到要保存文件的目标位置,指定文件名,然后点击 OK

导出使用 Debug API记录的跟踪文件:

adb pull sdcard/Android/data/{pacakgeName}/files/Vision.trace ~/Downloads

导入跟踪数据

可以导入使用 Debug API 或 CPU Profiler 创建的 .trace 文件。

要导入跟踪文件,请在分析器的 Sessions 窗格中点击 Start new profiler session 图标 + ,然后选择 Load from file

您可以检查导入到 CPU Profiler 中的跟踪数据,就像检查直接在 CPU Profiler 中捕获的跟踪数据一样,但有下面几点不同:

  • CPU 活动未显示在 CPU 时间轴上。
  • 线程活动时间轴仅指明了可在哪里获取各线程的跟踪数据,而未指明实际线程状态(如运行中、等待中或休眠中)。

检查跟踪数据


CPU Profiler 中的跟踪数据窗格提供多个标签页,供您选择如何查看所记录的跟踪数据中的信息。

要查看方法跟踪数据和函数跟踪数据,您可以从 Call ChartFlame ChartTop DownBottom Up 标签页中进行选择。要查看系统跟踪数据,您可以从 Trace EventsFlame ChartTop DownBottom Up 标签页中进行选择。

使用“Call Chart”标签页检查跟踪数据

Call Chart 标签页会以图形来呈现方法跟踪数据或函数跟踪数据,其中调用的时间段和时间在横轴上表示,而其被调用方则在纵轴上显示。对系统 API 的调用显示为橙色,对应用自有方法的调用显示为绿色,对第三方 API(包括 Java 语言 API)的调用显示为蓝色。图 4 显示了一个调用图表示例,说明了给定方法或函数的 Self 时间、Children 时间和 Total 时间的概念。 d75Tb9.png 图 4. 一个调用图表示例,说明了方法 D 的 Self 时间、Children 时间和 Total 时间。

使用“Flame Chart”标签页检查跟踪数据

Flame Chart 标签页提供一个倒置的调用图表,用来汇总完全相同的调用堆栈。也就是说,将具有相同调用方顺序的完全相同的方法或函数收集起来,并在火焰图中将它们表示为一个较长的横条(而不是将它们显示为多个较短的横条,如调用图表中所示)。这样更方便您查看哪些方法或函数消耗的时间最多。不过,这也意味着,横轴不代表时间轴,而是表示执行每个方法或函数所需的相对时间。

为帮助说明此概念,不妨考虑图 5 中的调用图表。请注意,方法 D 多次调用 B(B1、B2 和 B3),其中一些对 B 的调用也调用了 C(C1 和 C3)。 d7I7QS.png 图 5. 一个调用图表,其中的多个方法调用具有相同的调用方顺序。

由于 B1、B2 和 B3 具有相同的调用方顺序 (A → D → B),因此系统将它们汇总在一起,如图 6 所示。同样,也将 C1 和 C3 汇总在一起,因为它们也具有相同的调用方顺序 (A → D → B → C)。请注意,C2 不包括在内,因为它具有不同的调用方顺序 (A → D → C)。 d7o1wd.png 图 6. 汇总具有相同调用堆栈的完全相同的方法。

汇总的调用用于创建火焰图,如图 7 所示。 请注意,对于火焰图中的任何给定调用,占用最多 CPU 时间的被调用方最先显示。 d7o6f0.png

使用“Top Down”和“Bottom Up”检查跟踪数据

Top Down 标签显示一个调用列表,在该列表中展开方法或函数节点会显示它的被调用方。图 8 显示了图 4 中调用图表的自上而下图。图中的每个箭头都是从调用方指向被调用方。

如图 8 所示,在 Top Down 标签页中展开方法 A 的节点会显示它的被调用方,即方法 B 和 D。在此之后,展开方法 D 的节点会显示它的被调用方,即方法 B 和 C,依此类推。与 Flame chart 标签页类似,“Top Down”树也汇总了具有相同调用堆栈的完全相同的方法的跟踪信息。也就是说,Flame chart 标签页提供了 Top down 标签页的图形表示方式。

Top Down 标签提供以下信息来帮助说明在每个调用上所花的 CPU 时间(时间也可表示为在选定范围内占线程总时间的百分比):

  • Self:方法或函数调用在执行自己的代码(而非被调用方的代码)上所花的时间,如图 4 中的方法 D 所示。
  • Children:方法或函数调用在执行它的被调用方(而非自己的代码)上所花的时间,如图 4 中的方法 D 所示。
  • Total:方法的 Self 时间和 Children 时间的总和。这表示应用在执行调用时所用的总时间,如图 4 中的方法 D 所示。

wFFxXT.jpg

Bottom Up 标签页显示一个调用列表,在该列表中展开函数或方法的节点会显示它的调用方。沿用图 8 中所示的跟踪数据示例,图 9 提供了方法 C 的“Bottom Up”树。在该“Bottom Up”树中打开方法 C 的节点会显示它独有的各个调用方,即方法 B 和 D。请注意,尽管 B 调用 C 两次,但在“Bottom Up”树中展开方法 C 的节点时,B 仅显示一次。在此之后,展开 B 的节点会显示它的调用方,即方法 A 和 D。

Bottom Up 标签页用于按照占用的 CPU 时间由多到少(或由少到多)的顺序对方法或函数排序。您可以检查每个节点以确定哪些调用方在调用这些方法或函数上所花的 CPU 时间最多。 与“Top Down”树相比,“Bottom Up”树中每个方法或函数的时间信息参照的是每个树顶部的方法(顶部节点)。 CPU 时间也可表示为在该记录期间占线程总时间的百分比。下表说明了如何解读顶部节点及其调用方(子节点)的时间信息。

SelfChildrenTotal
“Bottom Up”树顶部的方法或函数(顶部节点)表示方法或函数在执行自己的代码(而非被调用方的代码)上所花的总时间。与“Top Down”树相比,此时间信息表示在记录的持续时间内对此方法或函数的所有调用时间的总和。表示方法或函数在执行它的被调用方(而非自己的代码)上所花的总时间。与“Top Down”树相比,此时间信息表示在记录的持续时间内对此方法或函数的被调用方的所有调用时间的总和。Self 时间和 Children 时间的总和。
调用方(子节点)表示被调用方在由调用方调用时的总 Self 时间。以图 9 中的“Bottom Up”树为例,方法 B 的 Self 时间将等于每次执行由方法 B 调用的方法 C 所用的 Self 时间的总和。表示被调用方在由调用方调用时的总 Children 时间。以图 9 中的“Bottom Up”树为例,方法 B 的 Children 时间将等于每次执行由方法 B 调用的方法 C 所用的 Children 时间的总和。Self 时间和 Children 时间的总和。

五、实践

下面主要介绍通过 Frame Chart 找到导致性能问题的代码,然后根据实际情况进行性能优化的实践,如下图10所示: 图10. 追踪数据Frame chart 图片

  • 红色:代表对系统 API 的调用。
  • 黄色:代表对应用自有方法和第三方API的调用。
  • 橙色:代表对Java 语言 API的调用。

鼠标移动到相应方法就会显示详细的类名和方法名以及总的耗时时间。双击方法就会跳转到对应的方法调用。

1. 延迟加载 ViewPager 的子页面

从上图可以看出HomeFragment.showColumn 方法比较耗时,然后双击进入这个方法进一步分析这个方法都做了哪些工作。 HomeFragment底部导航的 首页 这个页面包含顶部的一个搜索框,接下来一个PagerIndicator,再下面就是ViewPager。所以HomeFragment包含很多子tab页面,当HomeFragment 进入onViewCreated 时会向服务端请求配置各个tab名称和对应页面的模板。响应成功后初始化各个tab和所有页面(因为ViewPager.offscreenPageLimit = tabs.size),即响应成功之后就会调用 showCloumn 方法。

所以我们可以将各个子页面用ViewStub替换,页面创建的时候只加载ViewStub,当页面第一次可见的时候才真正渲染页面(即inflate ViewStub),页面渲染之后接着再去向服务器请求需要的数据。

注意:但是这里有个坑,假如你的FragmentA内包含一个子FragmentB,当你的Activity放在后台,由于内存不足而被系统回收的时候(开发开发者模式,并设置不保留活动就可以模拟),然后再次切换到这个页面,这是你的FragmentB会显示空白,这是因为FragmentA重建的时候系统会找到之前缓存的FragmentB实例,然后试图通过onSaveInstanceState 保存的 FragmentState.mContainerIdFragmentB的父布局去查找对应的布局,并添加进去。但是因为我们是在FragmentA第一次可见的时候才会去渲染根节点,所以FragmentBView就添加不进FragmentA的布局。解决方法如下:

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <ViewStub
        android:id="@+id/vs_article_topic_container"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:focusable="true"
        android:layout="@layout/in_fragment_article_topic"
        android:focusableInTouchMode="true" />

</FrameLayout>
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    super.onViewCreated(view, savedInstanceState)
    savedInstanceState?.let { inflateContent() }
    ...
}

// 这个方法是我们自己实现的,当这个Fragment第一次真正显示的时候才会调用
override fun onFirstUserVisible(visibleType: VisibleType) {
    super.onFirstUserVisible(visibleType)
    initView()
    getData(true)
}

private fun initView() {
    inflateContent()

    ...
}

private fun inflateContent() {
    if (this.refreshLayout != null) {
        return
    }
    // vs_article_topic_container 就是根布局
    refreshLayout = vs_article_topic_container.inflate() as SmartRefreshLayout
}

override fun onDestroyView() {
    super.onDestroyView()
    ...
    refreshLayout = null
    ...
}

如上代码所示需要在onViewCreated方法里面判断savedInstanceState不为空时,inflateContent

注意:用ViewStub的时候,因为不能重复inflate,所以我们一般定义一个属性持有inflate 后的布局,如上代码所示的refreshLayout,但是当这个fragment被系统销毁重建时,有可能这个实例并没与被销毁,所以虽然走了onCreateView,但是refreshLayout的属性不为空,所以需要在onDestroyView的时候将refreshLaoyut置为null,以便重新渲染。

2. 不要在打印日志的时候序列化大对象,尤其是频繁序列化大对象。

因为我们的首页包含行情模块,以及信息流也会显示行情信息,所以会订阅很多个行情品种,行情报价推送不但频繁,而且行情报价对象挺大的(包括静态码表、动态码表、报价等),比如:

YtxLog.d(TAG, "===onNewQuote name: ${stock.name}, type: ${event.type} stock: ${Gson().toJson(stock)}")

如上面的代码所示,虽然YtxLog在生产环境不会输出日志,但是执行这行代码的时候就已经执行序列化了,所以会影响性能。

如果你一定要在测试环境输入序列化日志,那么可以在Log Util 类封装一个debug的方法,参数是一个callback函数,在debug环境下才会执行callback,然后在callback方法里面输出序列化日志。

3. 使用ViewStub按需渲染自定义View,以及减少布局嵌套

  • 自定义ProgressView

我们首页的某个tab的子页面的信息流模块用的是Fragment,信息流的Fragment里面第一次加载数据的时候会用ProgressView显示一个loading动画,tab子页面第一次加载自身的数据时也会用ProgressView显示一个loading动画。因为Progress动画用的是帧动画,帧动画少则十几、二十几张,多则四五十张。

我们可以让LoadingView真正显示的时候才加载帧动画,而不是创建布局的时候就加载帧动画,如下所示:

# FrameAnimationView.kt
class FrameAnimationView : AppCompatImageView {
    var animViewRes = -1
        set(value) {
            if (field != value) {
                field = value
                stop()
                setImageResource(0)
            }
        }

    constructor(context: Context) : this(context, null, 0)
    constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0)
    constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) {
        initView(context, attrs, defStyleAttr)
    }

    private fun initView(context: Context, attrs: AttributeSet?, defStyleAttr: Int) {
        val a = context.obtainStyledAttributes(attrs, R.styleable.AnimView, defStyleAttr, 0)
        animViewRes = a.getResourceId(R.styleable.AnimView_anim_view_res, R.drawable.anim_drop_loading)
        a.recycle()
        hide(false)
    }

    override fun onFinishInflate() {
        super.onFinishInflate()
        if (isAttachedToWindow && visibility == View.VISIBLE) {
            start()
        }
    }

    override fun onAttachedToWindow() {
        super.onAttachedToWindow()
        if (visibility == View.VISIBLE) {
            start()
        }
    }

    override fun onDetachedFromWindow() {
        super.onDetachedFromWindow()
        stop()
    }

    override fun onVisibilityChanged(changedView: View, visibility: Int) {
        super.onVisibilityChanged(changedView, visibility)
        if (visibility != View.VISIBLE) {
            stop()
        } else {
            if (isAttachedToWindow) {
                start()
            }
        }
    }

    fun start() {
        if (drawable == null) {
            setImageResource(animViewRes)
        }
        val animationDrawable = drawable
        if (animationDrawable is AnimationDrawable && !animationDrawable.isRunning) {
            animationDrawable.start()
        }
    }

    fun stop() {
        (drawable as? AnimationDrawable)?.stop()
    }
}

# VisionProgress.kt
class VisionProgress : FrameLayout {
    private var loadingView: FrameAnimationView? = null

    constructor(context: Context): this(context, null, 0)
    constructor(context: Context, attrs: AttributeSet?): this(context, attrs, 0)
    constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int): super(context, attrs, defStyleAttr) {
        initView(context, attrs, defStyleAttr)
    }

    private fun initView(context: Context, attrs: AttributeSet?, defStyleAttr: Int) {
        val wrapper = FrameLayout(context)
        var layoutParams = LayoutParams(context.dp2px(120F), context.dp2px(84F), Gravity.CENTER)
        wrapper.setBackgroundResource(R.drawable.bg_vision_loading)
        this.addView(wrapper, layoutParams)

        loadingView = FrameAnimationView(context)
        loadingView!!.animViewRes = R.drawable.anim_drop_loading
        val size = context.dp2px(48F)
        layoutParams = LayoutParams(size, size, Gravity.CENTER)
        wrapper.addView(loadingView, layoutParams)
    }

    override fun onVisibilityChanged(changedView: View, visibility: Int) {
        super.onVisibilityChanged(changedView, visibility)
        loadingView?.visibility = visibility
    }
}

注意在ConstraintLayout布局内,ViewStub的android:idandroid:inflatedId 最好相同。假如ViewStub BViewStub A 为锚点,在 下面,那么 ViewStub A渲染和不渲染时,ViewStub B 的效果可能会不一样。

  • 自定义楼层标题

因为app有各种楼层标题样式如下图: 所以这个楼层标题也可以用ViewStub按需加载,对于走遍的imagedrawable、和 查看更多,都是非必须的。

  • 自定义View根布局使用merge标签可以减少布局嵌套

布局嵌套越深对性能的影响越大,尤其是列表页面的布局,所以我们在自定义View的时候根布局尽量使用merge标签,如下代码所示:

# R.layout.widget_level_title
<merge xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    app:layout_constraintLeft_toLeftOf="parent"
    app:layout_constraintTop_toTopOf="parent">

    <View
        android:id="@+id/top_margin"
        android:layout_width="0dp"
        android:layout_height="9dp"
        android:background="@color/window_background"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <ViewStub
        android:id="@+id/left_icon_container"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:inflatedId="@+id/left_icon_container"
        android:layout="@layout/in_level_title_icon_view"
        app:layout_constraintBottom_toBottomOf="@+id/level_title"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintTop_toTopOf="@+id/level_title" />

    <TextView
        android:id="@+id/level_title"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="15dp"
        android:layout_marginBottom="15dp"
        android:fontFamily="@font/alibaba_puhuiti_medium"
        android:includeFontPadding="false"
        android:textColor="#1a1a1a"
        android:textSize="17dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintLeft_toRightOf="@+id/left_icon_container"
        app:layout_constraintTop_toBottomOf="@+id/top_margin"
        app:layout_goneMarginLeft="15dp"
        tools:text="今日要闻" />

    <ViewStub
        android:id="@+id/more_container"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginRight="8dp"
        android:inflatedId="@+id/more_container"
        android:layout="@layout/in_level_title_more_view"
        app:layout_constraintBottom_toBottomOf="@+id/level_title"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="@+id/level_title" />
</merge>

# LevelTitleView
class LevelTitleView : ConstraintLayout {
		...

    constructor(context: Context) : this(context, null, 0)
    constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0)
    constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) {
        initView(context, attrs, defStyleAttr)
    }

    private fun initView(context: Context, attrs: AttributeSet?, defStyleAttr: Int) {
    	context.inflateLayout(R.layout.widget_level_title, this, true)
        ...
    }

}
  • 删除布局里面遗留的无用的节点,这些节点一般都是visible一直都设置为gone。
  • 自定义播主头像 BigVAvatar,用ViewStub实现按需渲染布局

自定义播主头像包括Vip标识、直播中文字及扩散动画,有是vip的博主才显示vip标识,正在直播的才显示直播中文字及动画,而且有的页面只显示一个头像。

  • 自定义播主头像 BigVAvatar 扩散动画,使用属性动画替代帧动画

因为帧动画是有很多一帧一帧的图片图片循环播放实现的效果,所以帧动画使用setImageResource(@DrawableRes int resId)设置帧动画资源集的时候会加载所有的图片,这块是比较耗时且比较耗内存,如果用属性动画一般用一两张图片就能达到同样的效果。当然如果你能通过自定义View,然后重写onDraw方法实现同样的效果那就最好,只不过代码写的更复杂可读性会差一点。

  • 布局尽量使用ConstraintLayout,并且开发的时候尽量能减少布局嵌套就减少布局嵌套,能使用一层嵌套实现的,不要写两三层嵌套。
  • 使用ViewStub优化视频播放器控件 我们的包含多个tab子页面,每个子页面都包含一个视频列表页的信息流,点击某个视频就会在当前页面播放。视频播放器是基于腾讯播放器进行封装成一个自定义控件方便app使用,自定义播放器做了如下分层:
  • topOverView // 最顶层的view, 用于其他操作
  • tipsView // 提示层,用于各种提示的View,包含如下层级:
    • 错误提示页
    • 播放结束页
    • loading页
    • 网络变化提示页
  • titleView // 标题,用于显示标题
  • coverView // 封面,用于显示封面
  • controlView // 控制层,用于控制视频及手势处理
  • danmuView // 弹幕层,用户显示弹幕
  • tXCloudVideoView // 显示层,用于显示图像画面

在一个视频列表页,初始状态下只需要展示标题和封面,对于其他view层都可以不需要渲染,当点击播放开始播放的时候才会开始渲染显示层和控制层,tipsView只是一个FrameLayout空布局,当需要显示某提示信息的时候才会动态创建对应的页面,danmuViewtopoOverView也是按需加载,不需要的页面就不会渲染这两个页面。

4.初始化任务异步化

经测试发现org.joda.time.DateTime 的第一次调用很耗时,所以可以在异步初始化线程调用一下 DateTime.now()

通过查看Glide的源码可以发现Glide的初始化会用到反射,而且会初始化很多资源,比较耗时,所以应该在application初始化的使用在异步初始化线程提前初始化。

对于友盟埋点、移动智能终端补充设备标识等第三方的初始化可以在Application.onCreate的生命周期方法里做异步初始化,如下代码所示:

fun initSDKOfApplication(context: Context) {
	...
	runAsync {
      // 在异步线程提前初始化glide,提升首页性能
      GlideApp.get(context.applicationContext)
      initUmeng(context)
      initMiit(context)
      // 初始化DateTime比较耗时,所以放在异步线程的最后
      DateTime.now()
    }
    ...
}

  • 在应用启动时延迟初始化

因为我们的app启动时调用了好几个初始化的访问服务器的接口,所以应用起来就密集访问服务器,可能会导致服务器瞬时并发量大增,所以对于及时性要求没那么高,比如进入首页是需要用到的初始化可以延迟初始化,对于一些首页需要用到的初始化工作也可以在这里做延迟初始化,延迟初始化可以让cpu空闲下来的时候,比如启动页已经可见到进入首页的这段时间,来执行你的初始化任务,提高cup的利用率

fun initSDKOfApplication(context: Context) {
	...
	runDelayed(500) {
        // 启动接口性能优化-延迟加载
        IntegralButton.getDailyTaskInfo()
    }
    ...
}
  • 对于常用的信息可以从SharedPreferences里只读取一次,然后缓存在内存里,之后从缓存中取。

比如说应用包对应的渠道信息、用户信息、token等

  • mqtt推送使用MqttAsyncClient,而不要用MqttAndroidClient

因为当收到服务端推送的消息时MqttAndroidClient是通过本地广播传递消息,会根据Qos的不同有写如文件的操作,而且还是在主线程,一旦消息的并发量高的话,卡顿非常明显。而 MqttAsyncClient 收到消息时,通过CommsReceiver(异步线程)将收到消息放到消息队列,CommsCallback(异步线程)会loop 消息队列,取出消息,并根据不同的Qos做持久化,并回调MqttCallback将消息传递出去。

  • 首页异步化初始任务

有些初始化任务是需要在进入首页的时候进行初始化,这些任务往往需要用户进行隐私授权,登录拿到token之后进行的初始化,可以放在异步线程初始化的任务包括:

  • 推送通知服务
  • 检查版本更新
fun initSDKOfMainActivity(applicationContext: Context) {
	...
	Completable.complete()
      .observeOn(Schedulers.io())
      .subscribe {
          PushManager.getInstance().initialize(applicationContext, CorePushService::class.java)
          PushManager.getInstance().registerPushIntentService(applicationContext, PushIntentService::class.java)
          UserHelper.bindClientId()
          CheckUpdateAppHelper.checkUpdate(applicationContext, false)
          CommonTracker.trackNotificationOpened(applicationContext)
          VisionApplication.openCameraFlag = OnlineConfigUtil.isOpenCameraFlag(VisionApplication.from())
          QuotationHelper.openCameraFlag = VisionApplication.openCameraFlag
      }
    ...
}
  • 首页延迟初始化任务

对于及时性要求不是很高的初始化任务可以延迟初始化,如闪验的一键登录sdk的初始化:

fun initSDKOfMainActivity(applicationContext: Context) {
	...
    runDelayed(500) {
        getPhoneInfo()
    }
    ...
}
private fun getPhoneInfo() {
    if (!OnlineConfigUtil.isShanyanEnable()) {
        return
    }
    //闪验SDK预取号(可选,能加速拉起授权页)
    OneKeyLoginManager.getInstance().getPhoneInfo { code, result ->
        YtxLog.d("InitHelper", "===预取号, code: $code, result: $result")
    }
}

5. 使用zipAlignEnabled 对包数据结构进行对齐,使得访问应用的资源是更加高效,能提高应用的性能:

release {
    ...
    minifyEnabled true
    proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
    //Zipalign优化
    zipAlignEnabled true
    signingConfig signingConfigs.config
    ndk {
        // You can customize the NDK configurations for each
        // productFlavors and buildTypes.
//                abiFilters 'armeabi'
        abiFilters 'armeabi-v7a'
    }
    ...
}

6. 其他

  • 对图片进行无损压缩,可以减小图片大小,减少内存使用和decode时间,以及减小包大小。
  • 去掉老的无用的代码和资源

其实过度绘制也会导致掉帧卡顿等问题,我怕写的太啰嗦,就不在这里赘述了。

总之性能优化是一个繁琐但很重要的工作,平时写代码的时候注意养成好的习惯,尤其是布局的嵌套问题,当需求变更的时候,你可能要重构代码来达到性能和方便维护的平衡,而不是为了快速实现功能而仓促的copy来copy去