Szhangbiao's blog

记录一些让自己可以回忆的东西

0%

Android 性能优化之卡顿优化

目前所开发的应用是在开发板上的一款应用市场类应用,设备主要涉及到拉杆音响电视盒子两类,其中拉杆音响的设备性能要略好于电视盒子类的设备,年初应用在添加了一些高级功能比如GIF轮播这类对性能比较有挑战的功能后,在上述两类系统和性能排名较低的设备上暴力测试和Monkey测试时有较大的概率发生ANR,加上平时打开一些比较复杂页面的响应速度也比较堪忧,于是就开启了一系列优化的过程。

问题分析

从设备的表现上来看,产生UI卡顿甚至ANR的原因有两个层面,一个是系统层面,出现ANR的设备主要集中在Android 4.4这一系统上,再加上硬件参数相对较低,就会出现即使不运行任何应用仅停留在桌面,通过logcat也可以看到一些系统的进程在不断地进行GC,另一个是应用层面,面对这类设备情况,常规的开发手段显然不能有很好的表现,再加上一些API在使用上不严谨就更加加剧了卡顿的情况。

面对这些情况,系统层面我们肯定没法干预,虽然我司在系统固件定制方面有一些业务和经验,但显然不会单独为了这一两类设备去单独做优化,那我们只能从应用层面着手,采用一些非常规的开发手段,比如统一图片库、统一线程池、限制线程数、设备分级处理、使用代码代替xml创建布局、优化列表数据加载、View缓存复用和异步加载等等,其中一些手段我们在日常开发时就已经考虑到了,今天我们就来详细说说部分优化手段的实现以及优化后的效果。

要想知道优化的手段有没有起到效果就需要有优化前后的数据对比,首先想到的是统计Activity/Fragment的生命周期方法的执行时间,本身应用的页面架构采用的是单Activity + Fragments,路由则是使用Jetpack中的Navigation,所以我们实现一个继承FragmentLifecycleCallbacks的类,在其中的回调中统计Fragment的生命周期的方法执行时间即可。

XML 转 Code

以下是在Fragment的生命周期中结合业务的伪代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// Fragment lifecycle methods
@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
// 初始化 ViewModel
}

@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
View inflatedView = inflater.inflate(R.layout.fragment_xxxx, container, false);
return inflatedView;
}

@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
// 1. ViewBinding bind view
// 2. 初始化views的页面数据跟事件
// 3. 对ViewModel中对应数据的LiveData设置观察者
}

@Override
public void onResume() {
// 1. 调用ViewMode中的方法请求页面数据
}

这是封装的通用的页面逻辑处理过程,观察在FragmentLifecycleCallbacks的回调方法中打印生命周期方法的执行时间,在性能比较差的设备上且页面布局稍微复杂一点的时候,可以发现onCreateView的执行时间普遍在200ms左右,这还是我们在布局结构上只使用ConstraintLayout防止布局嵌套和优化过度绘制之后的数据表现,为了进一步提升性能,因此有了把XML布局转Java代码实现的尝试。

XML布局转换成Java代码实现是一种常见的优化手段,我们都知道xml布局在inflate的过程中会涉及到大量的IO和反射,这也就成为了低端机上的性能瓶颈之一,在最开始的阶段有想过寻找一些成熟的框架来解决这一问题,其中比较有影响力的像X2C框架,但是这个框架已经很久没有更新了,最新的Android Stuido已经不能引入改库了,我也有想过阅读X2C源码并仿写一个适用最新的Android Stuido的版本,直到后续读到Booster作者的博客才打消了这一念头,这个大佬最初也有过这一想法,因为官方的Compose出来之后就放弃了,View体系后面也会成为历史产物,继续在上面折腾是没有太大的意义的。

后续功能的实现也只剩手搓这一选项了,手搓的伪代码大致如下:

首先是创建类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class XXXLayoutCreator {

// 成员对象是XML中的View

public static XXXLayoutCreator create(Context context){
1. 创建ConstraintLayout父布局
2. 根据XML里View的顺序创建View并添加到ConstraintLayout中
3. 使用`ConstraintSet`设置各个View的约束
4. 返回XXXLayoutCreator对象
}

public View getRoot(){
return mConstraintLayout;
}
}

Fragment中改动:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
public class XXXFragment extends BaseFragment {

private final boolean isUseCreateView = true;

private FragmentXXXBinding mBinding;
private XXXLayoutCreator mCreator;

public XXXFragment() {
super(R.layout.fragment_xxx);
}

@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
if (isUseCreateView) {
mCreator = XXXLayoutCreator.create(requireContext());
return mCreator.getRoot();
} else {
return super.onCreateView(inflater, container, savedInstanceState);
}
}

@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
if(!isUseCreateView){
mBinding = FragmentXXXBinding.bind(view);
}
}

// 为各种View定义get方法

public TextView getTitleView() {
return isUseCreateView ? mCreator.getTitleView() : mBinding.tvTitle;
}

...
}

之所以使用一个开关保留XML布局主要有如下考虑:

  • XML布局适配了三套设计图,是经过测试验证过的,使用代码实现某些细节有可能会有所遗漏
  • 更加容易定位问题,后续遇到相关Bug可以方便切换XML布局和Java布局,做一定的问题排查
  • 方便维护,不管后续代码谁来维护,直接面对一堆代码肯定会略感无力,保留XML布局可以多一分参考

面对项目里众多的XML布局,一一转换的过程枯燥且费时,好在我们在创建各类XXXLayoutCreator的过程中使用Trae这个AI IDE工具来帮助我们提升转换的效率,主要思路是让它参考已经转换好的XML文件和Java文件,然后根据XML文件自动创建对应的Java文件,生成的Java文件略加修改即可直接使用,整个过程中大概节约了**30%**的时间。

在实现XMLCode这一优化后,同一页面onCreateView的执行时间优化到100ms以内,虽然没有达到有些博客里介绍的20 倍的优化效果,但也很不错了。

RecyclerView数据加载

项目里使用RecyclerView的页面比较多,虽然我把列表里Item的布局也转换为Java代码实现,但是在页面创建和销毁比较高频的场景下还是偶尔会发生ANR,使用内存分析工具也没有发现内存泄漏的地方,进一步的优化场景就只有mAdapter.notifyDataSetChanged()的使用了。

首先把出现问题的场景交代一下,这个页面是应用的主页,页面采用TabLayout+ViewPager+Fragments的结构,是一种比较典型的TV类布局结构,原型图大致如下:

image

使用遥控器TabLayout中快速切换Tab时,会造成Fragment下的View频繁的创建与销毁,而且页面在数据加载中这一状态时我们构建了一个无内容的数据列表去填充RecyclerView以达到提前预览的效果,这就导致了在数据显示到屏幕上时已经调用了两次notifyDataSetChanged(),对象的频繁创建与销毁会导致内存抖动,这是导致该场景下ANR发生的根本原因。

解决思路主要有以下尝试:

  • TabLayout切换时加防抖功能,避免过快切换Fragment
  • 移除notifyDataSetChanged()方法的使用,使用notifyItemRangeXXX或者DiffUtil去替代
  • 跨页面使用RecyclerViewPool来复用同类型的RecyclerView下的ViewHolder
  • 使用对象池来缓存和复用一些创建时损耗或占用资源比较大的对象

对于第二点我后续使用DiffUtil来替代notifyDataSetChanged()的使用,在使用细节上首先有根据AsyncListDiffer来封装自己的BaseAdapter,只不过在构造方法里初始化AsyncListDiffer对象时使用一个共享的ThreadPool静态变量来做DiffUtil内部异步数据对比,其次是页面加载中状态下构造无实际内容的数据时根据单个数据在列表中的位置赋予该数据一个唯一id,在真实数据到来时也进行这一过程,这样在DiffUtil.ItemCallback下的areItemsTheSame中使用该id来做比较可以保证页面状态变化后,Adapter不会执行两次onCreateViewHolder,当然这一细节只有该场景下才适用。

第三点其实很早之前就用了,只不过在使用Glide加载的场景下一直报错,使用Fresco加载的时候不会出现这一问题,后续找到原因是我在ViewHolder里持有了Fragment的对象,其主要用于图片加载时传入,这样该图片的资源就跟Fragment的生命周期绑定,但是在ViewPager+Fragments的场景下,复用同类型的ViewHolder就会出现ImageView使用已经回收的Bitmap的异常,后续是在onBindViewHolderFragment作为方法参数传入ViewHolder中使用才解决这一问题。

总结

以上优化方法的应用,总体上使得ANR的情况得到控制,如果不是以上很多因素叠加到一起可能就不会出现ANR的问题,卡顿优化也是要具体问题具体分析,必要时使用一些工具,最近听说字节的btrace最新版的工具快要发布了,后续肯定要在项目里尝试一波。