前言:
1.现在网上有很多讲如何进行界面性能优化的文章,但是往往就讲到了如何解决内存波动问题。两者虽然有关联性,但是并不能完全划等号的,内存波动确实一定程度上会影响界面的绘制,但是也只是其中一种原因,并不能混为一谈。以及还有很多介绍过度绘制等等的,感觉都是大同小异。
2.本文的写作目的,是为了帮助初级,中级,包括部分高级安卓开发,快速的定位/解决实际应用场景所遇到的那些界面卡顿的问题,或者说引导一个合理的解决问题的方向。具体问题的解决,还是需要具体的方案去解决的。
3.当然,完全了解了本篇文章的原理后,对于安卓方面的面试,也会有一定的帮助作用。
一、如何衡量界面是否卡顿
衡量页面是否卡顿主要有三代方案,虽然中间也会有一些perfdog一类的监控框架,但是基本上都属于这三代方法原理之中的。
1.1 第一代方案:Looper注册回调方法(代表框架:BlockCanary)
1.1.1原理简介:
核心原理就是利用Looper中的回调,去判断每一个Message中任务的执行时间。如果我们注册的是回调对象是主线程Looper,那么我们就可以知道每个任务在主线程中的执行时间。而如果某个任务执行时间过长,那么就会造成卡顿问题。
所以从严格意义上讲,这种方案应该是属于检测主线程是否卡顿,而不是界面是否绘制流畅的。
具体原理这里就不详细讲了,如果想知道原理的,可以参见我另外一篇文章的第7章:
android源码学习-Handler机制及其六个核心点_分享+记录-CSDN博客
1.1.2如何使用:
BlockCanary中有比较详细的方法方式,链接如下:
我这里提供一种简单版的使用方式,效果基本差不多,代码如下:
public class ANRMonitor extends BaseMonitor {
final static String TAG = "anr";
public static void init(Context context) {
if (true) {
return;
}
ANRMonitor anrMonitor = new ANRMonitor();
anrMonitor.start(context);
Log.i(TAG, "ANRMonitor init");
}
private void start(Context context) {
Looper mainLooper = Looper.getMainLooper();
mainLooper.setMessageLogging(printer);
HandlerThread handlerThread = new HandlerThread(ANRMonitor.class.getSimpleName());
handlerThread.start();
//时间较长,则记录堆栈
threadHandler = new Handler(handlerThread.getLooper());
}
private long lastFrameTime = 0L;
private Handler threadHandler;
private long mSampleInterval = 40;
private Printer printer = new Printer() {
@Override
public void println(String it) {
long currentTimeMillis = System.currentTimeMillis();
//其实这里应该是一一对应判断的,但是由于是运行主线程中,所以Dispatching之后一定是Finished,依次执行
if (it.contains("Dispatching")) {
lastFrameTime = currentTimeMillis;
//开始进行记录
return;
}
if (it.contains("Finished")) {
long useTime = currentTimeMillis - lastFrameTime;
//记录时间
if (useTime > 20) {
//todo 这里超过20毫秒卡顿了
Log.i(TAG, "ANR:" + it + ", useTime:" + useTime);
}
threadHandler.removeCallbacks(mRunnable);
}
}
};
private Runnable mRunnable = new Runnable() {
@Override
public void run() {
threadHandler.postDelayed(mRunnable, mSampleInterval);
}
};
}
1.1.3 结论:
通过这种方式,我们可以得到每个主线程任务的执行时间,按照标准16ms刷新一次的话,那么每个主线程任务不应该超过16ms,否则就意味着造成了界面的卡顿。
1.2 第二代方案:Choreographer注册渲染回调(代表框架:腾讯GT)
第一代方案的缺陷就是识别的是主线程卡顿,这个主线程卡顿并不一定会造成用户感知上的卡顿。比如用户停在某个页面没有操作,这时候主线程就算阻塞住了,用户也不会有所感觉。我们想要的界面绘制卡顿,更应该偏向整个绘制的流程。
1.2.1原理简介:
View绘制的整个流程,首先是从子向父级层层通知,最上层是ViewRootImpl。通知到ViewRootImpl后,它会创建一个界面绘制消息,然后向Choreographer进行注册。Choreographer会通过native机制来尽量保证16ms回调一次,回调之后就会执行界面绘制的流程。
核心点就在回调这里,回调通知的是doFrame方法进行界面绘制,doFrame中又有四个回调,其中CALLBACK_TRAVERSAL就是真正通知去执行整个绘制流程的。
try {
Trace.traceBegin(Trace.TRACE_TAG_VIEW, "Choreographer#doFrame");
AnimationUtils.lockAnimationClock(frameTimeNanos / TimeUtils.NANOS_PER_MS);
mFrameInfo.markInputHandlingStart();
doCallbacks(Choreographer.CALLBACK_INPUT, frameTimeNanos);
mFrameInfo.markAnimationsStart();
doCallbacks(Choreographer.CALLBACK_ANIMATION, frameTimeNanos);
mFrameInfo.markPerformTraversalsStart();
doCallbacks(Choreographer.CALLBACK_TRAVERSAL, frameTimeNanos);//这里是核心
doCallbacks(Choreographer.CALLBACK_COMMIT, frameTimeNanos);
} finally {
AnimationUtils.unlockAnimationClock();
Trace.traceEnd(Trace.TRACE_TAG_VIEW);
}
所以我们可以针对其他三个做一点手脚,比如我们可以注册CALLBACK_ANIMATION的回调。这样如果CALLBACK_ANIMATION回调是以16ms定时回调通知的话,就可以证明CALLBACK_ANIMATION同样是按照16ms的刷新速度收到了的回调通知。
我的另一篇文章里面有较详细的介绍,有兴趣的可以看下:
android源码学习-View绘制流程_分享+记录-CSDN博客
1.2.2 如何使用:我们可以实现一个FPSFrameCallBacl的类,然后向
public class FPSFrameCallback implements Choreographer.FrameCallback {
private static final String TAG = "FPS_TEST";
private long mLastFrameTimeNanos = 0;
private long mFrameIntervalNanos;
public FPSFrameCallback(long lastFrameTimeNanos) {
mLastFrameTimeNanos = lastFrameTimeNanos;
mFrameIntervalNanos = (long)(1000000000 / 60.0);
}
@Override
public void doFrame(long frameTimeNanos) {
//初始化时间
if (mLastFrameTimeNanos == 0) {
mLastFrameTimeNanos = frameTimeNanos;
}
final long jitterNanos = frameTimeNanos - mLastFrameTimeNanos;
if (jitterNanos >= mFrameIntervalNanos) {
final long skippedFrames = jitterNanos / mFrameIntervalNanos;
if(skippedFrames>30){
Log.i(TAG, "Skipped " + skippedFrames + " frames! "
+ "The application may be doing too much work on its main thread.");
}
}
mLastFrameTimeNanos=frameTimeNanos;
//注册下一帧回调
Choreographer.getInstance().postFrameCallback(this);
}
}
注册代码:
Choreographer.getInstance().postFrameCallback(new FPSFrameCallback(System.nanoTime()));
1.2.3 结论:
第二代方案,已经很好的满足了我们只针对界面绘制是否流畅来做评估的需求。但是也存一些问题,比如数据不直观,使用了动画的回调有可能会影响动画的绘制流程等等。
1.3第三代方案:Window注册渲染回调
前面两代都是开发者自己研究出来的,第三代则是谷歌官方产品,所以也是最权威,数据最直观最全的一种方案。
1.3.1 原理简介:
我们最终的渲染都会由ViewRootImpl转换为一个个的渲染任务Renderer,向native层注册回调,获取丢帧数。
具体详细原理以后单独写一章来讲解。
1.3.2 如何使用:
getWindow().addOnFrameMetricsAvailableListener(new Window.OnFrameMetricsAvailableListener() {
@Override
public void onFrameMetricsAvailable(Window window, FrameMetrics frameMetrics, int dropCountSinceLastInvocation) {
}
});
1.3.3 结论:
第三代的方案,已经很完美的帮助我们判断是否卡顿,但是并不能帮助我们直接找到出问题的原因,所以这需要我们用各种手段来排查了。
二、排查界面卡顿的手段
2.1卡顿原因:
卡顿以我的观点主要分为三类,
第一类是CPU问题,比如运算量过大导致CPU过载,从而无法进行正常的计算。
第二类是GC问题,虚拟机频繁GC时,会暂停掉主线程的任务。
第三类是主线程阻塞的问题。比如我们主线程进行耗时操作,或者加了不恰当的锁,甚至有可能就是布局复杂或者嵌套层级太多。
2.2 方案一:
针对问题一,我们可以使用perfdog等工具,看一下CPU负载率。
2.3 方案二:
针对问题二,现有网上此类的文章太多了,主流都是这一种,所以我这里就不展开了。
读者可以自行百度
2.4 方案三:
针对问题三,往往才是真正导致界面绘制卡顿的原因。所以这才是我们要解决的重点。我这里采用的方案是上面1.1介绍的Looper回调的方案。
既然我们可以通过回调,知道每个主线程的任务执行时间。那么我们在这段时间里面开启一个新的线程,不断的去dump主线程的堆栈状态,就可以知道主线程被阻塞到了哪里。
举个例子,我在主线程读一个文件,需要100毫秒。那么我每隔20毫秒捕获一下主线程的堆栈,这个主线程堆栈读取文件的代码堆栈,就至少会被捕获5次,那么我们就可以知道,这里段代码是有问题的。
再举一个例子,一个RecyclerView在一个界面加载几百个itemView。刚进入界面的时候大概率会卡顿,这时候我们会发现,大多数的代码堆栈,打印的都是onCreateViewHolder()这个方法,虽然每次执行onCreateViewHolder方法的时间不会超过20毫秒,但是调用的频繁,则出现的概率就会高。我们就会知道是由于频繁创建ViewHolder导致的。
下面我写的简单排查卡顿问题的工具类代码:
public class ANRMonitor extends BaseMonitor {
final static String TAG = "anr";
public static void init(Context context) {
//开关
if (true){
return;
}
ANRMonitor anrMonitor = new ANRMonitor();
anrMonitor.start(context);
Log.i(TAG, "ANRMonitor init");
}
private void start(Context context) {
Looper mainLooper = Looper.getMainLooper();
mainLooper.setMessageLogging(printer);
HandlerThread handlerThread = new HandlerThread(ANRMonitor.class.getSimpleName());
handlerThread.start();
//时间较长,则记录堆栈
threadHandler = new Handler(handlerThread.getLooper());
mCurrentThread = Thread.currentThread();
}
private long lastFrameTime = 0L;
private Handler threadHandler;
private long mSampleInterval = 40;
private Thread mCurrentThread;//主线程
private final Map<String, String> mStackMap = new HashMap<>();
private Printer printer = new Printer() {
@Override
public void println(String it) {
long currentTimeMillis = System.currentTimeMillis();
//其实这里应该是一一对应判断的,但是由于是运行主线程中,所以Dispatching之后一定是Finished,依次执行
if (it.contains("Dispatching")) {
lastFrameTime = currentTimeMillis;
//开始进行记录
threadHandler.postDelayed(mRunnable, mSampleInterval);
synchronized (mStackMap) {
mStackMap.clear();
}
return;
}
if (it.contains("Finished")) {
long useTime = currentTimeMillis - lastFrameTime;
//记录时间
if (useTime > 20) {
//todo 要判断哪里耗时操作导致的
Log.i(TAG, "ANR:" + it + ", useTime:" + useTime);
//大于100毫秒,则打印出来卡顿日志
if (useTime > 100) {
synchronized (mStackMap) {
Log.i(TAG, "mStackMap.size:" + mStackMap.size());
for (String key : mStackMap.keySet()) {
Log.i(TAG, "key:" + key + ",state:" + mStackMap.get(key));
}
mStackMap.clear();
}
}
}
threadHandler.removeCallbacks(mRunnable);
}
}
};
private Runnable mRunnable = new Runnable() {
@Override
public void run() {
doSample();
threadHandler
.postDelayed(mRunnable, mSampleInterval);
}
};
protected void doSample() {
StringBuilder stringBuilder = new StringBuilder();
for (StackTraceElement stackTraceElement : mCurrentThread.getStackTrace()) {
stringBuilder
.append(stackTraceElement.toString())
.append("\n");
}
synchronized (mStackMap) {
mStackMap.put(mStackMap.size() + "", stringBuilder.toString());
}
}
}
三、解决界面卡顿实战案例
3.1 案例1.通过算法优化解决CPU高频计算
需求和问题场景:
页面上的数据不断通过蓝牙接收,结合本地数据库中查询到的解释,最终拼接为最终的Model进行渲染。发现刷新频率并未达到想象中的速度,并且不跟手。
排查过程:
通过2.4中的工具,发现主线程并没有阻塞。但是滑动操作就是感觉有些许卡顿,不顺手。通过perdog,发现CPU使用率很高。所以初步怀疑是进行复杂运算导致,于是排查代码,发现有双层for循环并且集合数据量大的地方。
解决方案:
1.方案一,优化算法
如下为老的代码结构,输入1000个值,从10W条数据中匹配合适的value,然后取前100个。
private List<String> show() {
List<Model> list = new ArrayList<>();//长度10W
List<String> input = new ArrayList<>();//长度1000
List<String> showList = new ArrayList<>();
for (String key : input) {
for (Model model : list) {
if (model.name.equals(key)) {
showList.add(model.value);
}
}
}
return showList.subList(0, 100);
}
则我们首先要把较长的list转换为map,然后取100个之后就跳出循环。则计算效率大幅提升。
优化后代码如下:
//优化后代码
private List<String> show2() {
List<Model> list = new ArrayList<>();//长度10W
List<String> input = new ArrayList<>();//长度1000
Map<String, Model> cache = new HashMap<>();
for (Model model : list) {
cache.put(model.name, model);
}
List<String> showList = new ArrayList<>();
for (int i = 0; i < Math.min(input.size(), 100); i++) {
Model model = cache.get(input.get(i));
showList.add(model.value);
}
return showList.subList(0, 100);
}
2.方案二,转JNI实现或尽量位运算
某些如果代码算法本身已经没有优化空间,并且业务运算较多,最终输出值不的多,可以考虑转JNI实现或者转换为位运算,提高效率,这里就不举例了。
3.2 案例2.主线程阻塞问题
需求和问题场景:
进入到一个页面,发现每次进入时都会有明显的卡顿感。
排查过程:
通过2.4中的工具,发现日志中有一个代码堆栈大量出现,则所以怀疑是这里出的问题。最终定位下来,就是主线程IO操作导致。
解决方案:
1.方案一,异步加载
IO是耗时操作,则使用线程去读取,读取完成后通知主线成刷新UI:
//3.2案例 优化代码
new Thread(() -> {
String s = "";
try {
InputStream is = getAssets().open("content.txt");
List<String> strings = IOHelper.readListStrByCode(is, "utf-8");
s = strings.get(0);
} catch (IOException e) {
Log.i("lxltest", e.getMessage());
e.printStackTrace();
}
String show = s;
handler.post(() -> title.setText(show));
}).start();
PS:为了代码简洁直观,就不用使用线程池了,后续场景也是。
完整示例代码链接
android_all_demo/PerformanceCaseActivity.java at master · aa5279aa/android_all_demo · GitHub
3.3 案例3.解决RecyclerView高频刷新问题
需求和问题场景:
需求很简单,类似于看股票价格一样,每隔100毫秒请求一次服务,然后取返回的数据展示给用户。
简单代码如下:
RecyclerView recyclerView;
ModelAdapter adapter;
boolean flag = true;
Handler handler = new Handler();
@RequiresApi(api = Build.VERSION_CODES.N)
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_recycler);
recyclerView = findViewById(R.id.recycler_view);
new Thread(() -> {
while (flag) {
List<Map<String, String>> data = getResponse();
notifyData(data);
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}).start();
}
private void notifyData(List<Map<String, String>> data) {
handler.post(() -> {
adapter.data = data;
adapter.notifyDataSetChanged();
});
}
但是我们在实际运行中发现,这种场景下上下滑动滑动是有卡顿的。
排查过程:
首先,我们使用文章2.4中提供的工具去扫描,发现有出现了大量的如下图所示的堆栈:
所以我们知道了问题的原因,就是频繁创建ViewHolder导致的卡顿。那为什么会频繁创建ViewHolder呢?带着这个疑问,我们深入RcyclerView的源码,终于知道了原因。每次调用notifyDataSetChanged()方法的时候,都会触发一个回收操作。由于RecyclerBin中默认缓存的数量是5,而我们一页却展示了15条数据,所以,这15个ItemView有10个是被释放掉了。
解决方案:
1.方案一:改大缓存
如下操作,改大缓存数量,问题解决。
recyclerView.getRecycledViewPool().setMaxRecycledViews(0,15);
2.方案二:优化算法
当然,我们也可以通过数据层面去解决,服务返回的数据,结合现有的数据计算,计算哪些数据发生了变化,只更新那些发生了变化的数据。
完整示例代码链接
android_all_demo/PerformanceCaseActivity.java at master · aa5279aa/android_all_demo · GitHub
3.4 案例4.复杂页面首次进入卡顿
需求和问题场景:
一个页面元素特别多,并且界面复杂的界面,我们首次点击进入的时候,发现点击之后会过1-2秒之后再会进入,给人明显不跟手的感觉。
排查过程:
还是通过ANRMonitor的工具,我们通过log进行分析,看看到底是什么原因导致的。
最终我们发现,日志里面,耗时最多的堆栈是打印到了setContentView方法。那么就说明,是创建布局耗时了。
解决方案:
1.方案一,预加载
一般来说,复杂页面不会是闪屏页面。所以我们可以在进入到复杂页面之前,预先使用预加载把xml转换成View。复杂页面onCreate的时候,判断缓存中是否存在对应的View,如果存在则使用缓存中的,不存在则创建。
添加缓存:
private var cacheMap = HashMap<String, View>()
fun addCachePageView(pageClassName: String, layoutId: Int) {
if (cacheMap[pageClassName] != null) {
return
}
val context = DemoApplication.getInstance()
val inflate = View.inflate(context, layoutId, null)
inflate.measure(1, 1)
cacheMap[pageClassName] = inflate
}
使用缓存:
View cachePageView = PageViewCache.Companion.getInstance().getCachePageView(PrepareMiddleActivity.class.getName());
if (cachePageView != null) {
setContentView(cachePageView);
} else {
setContentView(R.layout.prepare_middle_page);
}
完整示例代码链接https://github.com/aa5279aa/android_all_demo/blob/master/DemoClient/app/src/main/java/com/xt/client/activitys/PrepareActivity.kt
3.5 案例5.解决高频率波形图的刷新
需求和问题场景:
效果图如下图所示,每个波形图的刷新频率要达到1秒10次以上。
这时候我们发现虽然我们按照1秒10次的方式去通知刷新,但是实际上,一秒钟只能刷新2到3次。
排查过程:
使用2.4提供的工具,发现之所以卡顿,主要有两块时间消耗比较大:
1.每个图形的数据坐标计算,
2.measurce,layout等流程。
解决方案:
1.方案一,数据和渲染脱钩
我们可以开启一个线程,专门进行坐标计算,把计算好的数据放到缓存中。
主线程中,定时每隔100毫秒从缓存中获取计算好的数据直接渲染。这样数据就和渲染脱钩了。
2.转surfaceView实现
由于本身就是自定义View,所以也可以转SurfaceView实现,充分利用GPU性能。
完整示例代码链接
这一块代码因未脱密,暂未能开源,提供部分实例代码供参考:
public class DataSurfaceView extends SurfaceView implements SurfaceHolder.Callback {
...省略代码
/**
* 绘制方波图和波形图
*/
protected void drawArc(Canvas canvas) {
//长度不对等或者没有初始化,则不绘制
if (mShowPointList.length != mSettings.length || !isInit) {
return;
}
// canvas.save();
float startX = mMargin;
float startY = mMargin - mOffset;
RectF rectF;
for (int index = 0; index < mShowPointList.length; index++) {
List<Integer> integers = mShowPointList[index];
ShowPointSetting setting = mSettings[index];
int count = integers.size();
if (setting.showType == ShowConstant.ShowTypeWave) {
count--;
}
float itemWidth = itemCoordWidth / count;//每一个的宽度
//绘制背景 mBgPaint
rectF = new RectF(startX, startY, startX + itemViewWidth - mMargin * 2, startY + itemViewHeight - mMargin * 2);
if (mIndex == index) {
canvas.drawRoundRect(rectF, mItemRound, mItemRound, mBgClickPaint);
} else {
canvas.drawRoundRect(rectF, mItemRound, mItemRound, mBgPaint);
}
float itemX = startX + mItemPadding;
float itemY = startY + mItemPadding;
float nextY = 0;
float[] pts = new float[integers.size() * 8 - 4];
for (int innerIndex = 0; innerIndex < count; innerIndex++) {
Integer value = integers.get(innerIndex);
if (value != null) {
value = value > setting.showMaxValue ? setting.showMaxValue : value;
itemY = startY + mItemPadding + mItemTopHeight + itemCoordHeight - itemCoordHeight * value / setting.showMaxValue;
if (setting.showType == ShowConstant.ShowTypeSquare) {
pts[innerIndex * 8 + 0] = itemX;
pts[innerIndex * 8 + 1] = itemY;
pts[innerIndex * 8 + 2] = itemX + itemWidth;
pts[innerIndex * 8 + 3] = itemY;
}
}
itemX = itemX + itemWidth;
//方形图逻辑
if (setting.showType == ShowConstant.ShowTypeSquare) {
if (innerIndex != count - 1) {
Integer nextValue = integers.get(innerIndex + 1);
if (value != null && nextValue != null) {
nextValue = nextValue > setting.showMaxValue ? setting.showMaxValue : nextValue;
nextY = startY + mItemPadding + mItemTopHeight + itemCoordHeight - itemCoordHeight * nextValue / setting.showMaxValue;
pts[innerIndex * 8 + 4] = itemX;
pts[innerIndex * 8 + 5] = itemY;
pts[innerIndex * 8 + 6] = itemX;
pts[innerIndex * 8 + 7] = nextY;
}
} else {
//绘制坐标
canvas.drawText(String.valueOf(innerIndex + 2), itemX - 5, startY + mItemPadding + mItemTopHeight + itemCoordHeight + 20, mFontPaint);
}
} else if ((setting.showType == ShowConstant.ShowTypeWave)) {
if (value != null && integers.get(innerIndex + 1) != null) {
nextY = startY + mItemPadding + mItemTopHeight + itemCoordHeight - itemCoordHeight * integers.get(innerIndex + 1) / setting.showMaxValue;
pts[innerIndex * 8 + 4] = itemX - itemWidth;
pts[innerIndex * 8 + 5] = itemY;
pts[innerIndex * 8 + 6] = itemX;
pts[innerIndex * 8 + 7] = nextY;
}
if (innerIndex == count - 1) {
//绘制坐标
canvas.drawText(String.valueOf(innerIndex + 2), itemX - 5, startY + mItemPadding + mItemTopHeight + itemCoordHeight + 20, mFontPaint);
}
}
//绘制坐标
canvas.drawText(String.valueOf(innerIndex + 1), itemX - itemWidth - 5, startY + mItemPadding + mItemTopHeight + itemCoordHeight + 20, mFontPaint);
// mWaveShadowPaint.set
//渐变色
// canvas.drawRect(itemX - itemWidth, itemY, itemX, startY + mItemPadding + mItemTopHeight + itemCoordHeight, mWaveShadowPaint);
//绘制虚线
canvas.drawLine(itemX, startY + mItemPadding + mItemTopHeight, itemX, startY + mItemPadding + mItemTopHeight + itemCoordHeight, mEffectPaint);
}
//绘制最大值
if (!StringUtil.emptyOrNull(setting.showMaxValueShow)) {
canvas.drawText(setting.showMaxValueShow, startX + mItemPadding + 5, startY + mItemPadding + mItemTopHeight + 30, mFontPaint);//ok
}
//todo 绘制描述
canvas.drawText(setting.showDesc, startX + mItemPadding, startY + mItemPadding + 30, mDescPaint);
//todo 描述当前值
String currentStr = String.valueOf(mCurrents[index]);
float v = mCurrentPaint.measureText(currentStr);
canvas.drawText(currentStr, startX + mItemPadding + itemCoordWidth - v, startY + mItemPadding + 30, mCurrentPaint);
//绘制方形图的线
canvas.drawLines(pts, mWavePaint);
if (index % mAttr.widthCount == (mAttr.widthCount - 1)) {
startX = mMargin;
startY += itemViewHeight;
} else {
startX += itemViewWidth;
}
}
// canvas.restore();
}
}
3.6 案例6.一屏加载几百条数据的页面优化
需求和问题场景:
有的场景下,我们一个页面就是要加载大量的数据给用户看。如下图所示,一屏显示了大量的数据。原来是使用RecyclerView实现的,我们发现,每次进入到这个页面的都是,都要卡顿2-3S的时间才会进入。如下图所示:
排查过程:
同样是用检测工具来检查,我们发现大多数的耗时堆栈,都显示是onCreateViewHolder这个方法。一平米几百个itemView,就需要创建几百个ItemView,自然是耗时操作。当然,并不仅仅只是创建itemView耗时,几百个itemView都需要各自执行measure,layout,draw等方法,耗时也是相当巨大的。
解决方案:
方案一,自定义View
创建几百个View,渲染几百个肯定是耗时的。能否我们只创建一个,渲染一个,就显示所有数据呢。当然可以,方案就自定义View。
在自定义View中,我们可以分别计算每个Item数据的位置,是用canvas直接进行绘制。这样就只需要创建一个布局,measure/layout/draw各自只执行一遍。实践下来,果然效率大幅提升。
完整示例代码链接:
具体的自定义View的代码就不贴了,可以参考我的一个开源项目,里面会有更为详细的功能实现。
GitHub - September26/ExcelView: android项目,仿照WPS中excel的功能进行的实现,并进行进一步的功能扩展。
3.7 案例7.复杂长屏页面的优化
需求和问题场景:
一个内容复杂的页面,首先外层是RecyclerView,包含若干个模块。每个模块中又有RecyclerView包含若干个最终的控件。
首先,使用的是瀑布流式的布局方式,所以最终控件的变化会影响到整体模块的布局。
最终我们发现,数据频繁变化进行频繁刷新的时候,页面不流畅,有明显的卡顿感。
排查过程:
同样的,我们使用检测工具检测的时候,发现大多数的堆栈,都是打印到了notify的流程当中。所以我们可以简单推断,由于调用了太多的notifyChanged导致的卡顿。
解决方案:
那么如何减少notifyChanged的次数呢?技术上好像没有什么可优化的点,除非全部使用自定义view实现,但是那样的话开发成本太高了。
方案一,数据变化才通知
我们可以通过新旧数据做对比,知道那些发生变化的数据,并且只对这些数据进行通知刷新。
方案二,分级别刷新
两级RecyclerView,如果可以的话,自然要刷新实现最小颗粒度。我们可以把数据的变化分为两种。一种是会导致模块高度发生变化的,一种是影响范围只是自身这一行的。
对于第一种,我可以调用使用外层RecyclerView所对应的adapter通知刷新。
mRecyclerViewAdapt.data[positionMajor].items.clear()
mRecyclerViewAdapt.data[positionMajor].items.addAll(arrayList)
mRecyclerViewAdapt.notifyItemChanged(positionMajor)
第二种,我们只需要获取内层RecyclerView所对应的adapter通知刷新即可。
val recyclerView: RecyclerView = mRecyclerViewAdapt.getViewByPosition(positionMajor, R.id.recycler_view) as RecyclerView
val adapter = recyclerView.getTag(R.id.tag_adapter) //as GasWidgetDashboard.MultipleItemQuickAdapter
when (adapter) {
is GasWidgetDashboard.MultipleItemQuickAdapter -> adapter.notifyItemChanged(positionMinor)
is GasWidgetForm.QuickAdapter -> adapter.notifyItemChanged(positionMinor)
}
四、总结
4.1.先找原因在去解决
界面性能优化多种多样,但最重要的还是找到问题的原因。然后根据原因再去讨论如何解决性能上的问题。一味的照搬各种优化模式,是行不通的。比如明明是一个主线程IO操作导致卡顿,却一味的进行内存方面的优化,那自然是无法解决问题的。
4.2.界面性能优化必背知识点
想要比较完美的解决界面性能问题,还是要有一定的知识储备的,有了这些知识储备,可以快速的帮我们排查到问题点,并且相处合理的解决方案。
4.2.1 了解Handler机制
这样可以帮助我们进行主线程卡顿的排查。
4.2.2 View的整个绘制流程也需要清楚
从改变数据发出通知,到每个子View进行measure等操作。
4.3.3 常用的ViewGroup级别的容器实现原理要掌握
这一类的容器比如RecyclerView,RelativeLayout,ConstraintLayout等等。
4.3.4 最后也需要一定的抽象和逻辑思维能力。
上面举例的那些自定义View,都需要一定的抽象能力和逻辑思维能力才能知道如何去实现。
以上是我的个人建议,有兴趣的可以针对性的准备一下。
3.8 案例8.ViewPager优化
我们最经常使用的布局是像微信这样的,一般都是ViewPager+Fragment的方式。
ViewPager默认加载的时候会预加载相邻的一屏,所以虽然我们只打开了第一个标签页,但是使用Viewpager的话会导致加载第一个标签页和第二个标签页。如果打开第二个标签页的话,就会加载1-3三个标签页了。因为都是运行在主线程,所以无疑会导致拖慢首屏加载速度。
通过工具检测,我们可以发现好多时间是耗费在了第二屏的代码执行中。
所以彻底杀死微信后打开,快速滚动到第二屏,我们可以看到下面这样的占位图。这就是典型的使用懒加载代替预加载的方式。
简单的懒加载框架如下,我们可以在Fragment从不显示变为显示的时候去请求加载数据,同样的在从显示变为不显示的时候停止加载数据。
/**
* 懒加载
*/
abstract public class LazyFragment extends Fragment {
View rootView;
boolean lastVibible = false;
@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
if (rootView == null) {
rootView = inflater.inflate(getLayoutRes(), container);
}
initView(rootView);
if (getUserVisibleHint()) {
dispatchUserVisibleHint(true);
}
return rootView;
}
@Override
public void setUserVisibleHint(boolean isVisibleToUser) {
if (isVisibleToUser && !lastVibible) {
dispatchUserVisibleHint(true);
} else if (!isVisibleToUser && lastVibible) {
dispatchUserVisibleHint(false);
}
lastVibible = isVisibleToUser;
}
@Override
public void onResume() {
super.onResume();
if (!getUserVisibleHint() && !lastVibible) {
dispatchUserVisibleHint(false);
}
}
@Override
public void onPause() {
super.onPause();
if (getUserVisibleHint() && lastVibible) {
dispatchUserVisibleHint(false);
}
}
private void dispatchUserVisibleHint(boolean isVisble) {
if (rootView == null) {
return;
}
if (isVisble) {
//加载数据
} else {
//停止加载数据
}
}
protected abstract void initView(View rootView);
protected abstract int getLayoutRes();
}
五、备注
5.1 声明
由于本文当中优化的方案是基于公司现有项目进行的,为避免敏感内容外泄,所以相关代码和图例均使用demo进行演示,导致某些界面有些丑陋,尽请谅解。
5.2 本文涉及到项目地址
https://github.com/sollyu/3a5fcd5eeeb90696
https://github.com/aa5279aa/android_all_demo
https://github.com/aa5279aa/CommonLibs
5.3 本文引用的参考资料链接:
android源码学习-Handler机制及其六个核心点_分享+记录-CSDN博客
5.4 鸣谢
感谢
对本文创作的支持。
作者GitHub:@ https://github.com/aa5279aa/