Android性能优化之UI卡顿优化

Android应用性能优化

性能优化分类

  1. 卡顿优化
  2. 内存优化
  3. 电量优化
  4. 网络优化
  5. 启动优化、安装包体积优化

官网性能优化指导(https://developer.android.com/topic/performance/index.html

卡顿优化

卡顿:从用户角度说,App操作起来缓慢,响应不及时,列表滑动一顿一顿的,动画刷新不流畅等等一些直观感受。从系统角度来说,屏幕刷新的帧率不稳定,无法保证每秒绘制60帧,也就是说有掉帧的情况发生。

掉帧检测方案

Looper

Android使用消息机制进行UI更新,UI线程有个Looper,在其loop方法中会不断取出message,调用其绑定的Handler在UI线程执行。如果在handler的dispatchMesaage方法里有耗时操作,就会发生卡顿。
下面看Looper.loop()方法源码:


    public static void loop() {
        final Looper me = myLooper();
        if (me == null) {
            throw new RuntimeException("No Looper; Looper.prepare() wasn't called on this thread.");
        }
        final MessageQueue queue = me.mQueue;

        // Make sure the identity of this thread is that of the local process,
        // and keep track of what that identity token actually is.
        Binder.clearCallingIdentity();
        final long ident = Binder.clearCallingIdentity();

        for (;;) {
            Message msg = queue.next(); // might block
            if (msg == null) {
                // No message indicates that the message queue is quitting.
                return;
            }

            // This must be in a local variable, in case a UI event sets the logger
            //处理消息前,打印开始日志
            Printer logging = me.mLogging;
            if (logging != null) {
                logging.println(">>>>> Dispatching to " + msg.target + " " +
                        msg.callback + ": " + msg.what);
            }

            msg.target.dispatchMessage(msg);

            //处理完消息后,打印结束日志
            if (logging != null) {
                logging.println("<<<<< Finished to " + msg.target + " " + msg.callback);
            }

            // Make sure that during the course of dispatching the
            // identity of the thread wasn't corrupted.
            final long newIdent = Binder.clearCallingIdentity();
            if (ident != newIdent) {
                Log.wtf(TAG, "Thread identity changed from 0x"
                        + Long.toHexString(ident) + " to 0x"
                        + Long.toHexString(newIdent) + " while dispatching to "
                        + msg.target.getClass().getName() + " "
                        + msg.callback + " what=" + msg.what);
            }

            msg.recycleUnchecked();
        }
    }

我们可以根据消息处理前后的日志输出作为检测点,计算出消息处理的耗时,如果超出16ms,说明发生了卡顿,此时就可以把UI线程的堆栈日志打印出来。


Looper.getMainLooper().setMessageLogging(new Printer() {
            private static final String START = ">>>>> Dispatching";
            private static final String END = "<<<<< Finished";

            @Override
            public void println(String x) {
                if (x.startsWith(START)) {
                    UiBlockLogMonitor.getInstance().startMonitor();
                }
                if (x.startsWith(END)) {
                    UiBlockLogMonitor.getInstance().stopMonitor();
                }
            }
        });

不过,由于系统定制的原因,打印出来的日志标识不一定标准,所以可以改为判断第一次日志输出和第二次日志输出。

Choreographer.FrameCallback

Choreographer官方说明(https://developer.android.com/reference/android/view/Choreographer.html

Choreographer 编舞者,协调动画、输入和绘图的时间(api >= 16)。

Choreographer从显示子系统接收定时脉冲(如垂直同步),然后安排下一帧的渲染工作。
在开发中,我们并不直接使用Choreographer,当我们想要检测是否有丢帧发生时,可以利用Choreographer.FrameCallback回调的方式,获取每一帧开始绘制的时间,通过计算两帧之间的时间差,如果大于16ms,说明发生了丢帧。

//为Choreographer设置一个回调,当一帧开始渲染时触发。
Choreographer.getInstance().postFrameCallback(new Choreographer.FrameCallback() {
            long lastFrameTimeNanos = 0;
            long currentFrameTimeNanos = 0;

            @Override
            public void doFrame(long frameTimeNanos) {
                if (lastFrameTimeNanos == 0) {
                    lastFrameTimeNanos = frameTimeNanos;
                }
                currentFrameTimeNanos = frameTimeNanos;
                long diffMs = TimeUnit.MILLISECONDS.convert(currentFrameTimeNanos - lastFrameTimeNanos, TimeUnit.NANOSECONDS);
                lastFrameTimeNanos = currentFrameTimeNanos;
                if (diffMs == 0) {
                    diffMs = (long) 16.7;
                }

                if (isShowFPS) {
                    long current = System.currentTimeMillis();
                    if (current - mLastFPSRefreshTs > refreshInterval) {
                        int fps = (int) (1000 / diffMs);
                        refreshFPS(fps);
                        mLastFPSRefreshTs = current;
                    }
                }

                if (diffMs > 16.7f) {
                    long droppedCount = (long) (diffMs / 16.7f);
                    if (droppedCount > 1) {
                        System.out.println("掉帧数 : " + droppedCount);
                    }
                }

                if (UiBlockLogMonitor.getInstance().isMonitor()) {
                    UiBlockLogMonitor.getInstance().stopMonitor();
                }

                if (isDetectContinue) {
                    UiBlockLogMonitor.getInstance().startMonitor();
                    Choreographer.getInstance().postFrameCallback(this);
                }
            }
        });

问题检测工具

当发生掉帧时,需要判断是什么原因导致了UI线程耗时过程或阻塞。这时需要借助一些开发工具来帮助定位。

systrace

systrace 官方说明:https://developer.android.com/studio/command-line/systrace.html
systrace.py 是一个命令行工具,位于 ../sdk/platform-tools/systrace目录下。在应用运行时,它可以帮助我们收集和分析所有进程的计时信息,包含了CPU调度、应用线程、磁盘活动等Android内核数据,然后生成一份HTML报告。

systace对检测应用UI表现非常有效,因为它可以分析你的代码和帧率来识别出问题区域,然后提出可能的解决方案。示例

如果其中的Expensive measure/layout 或 Long View#draw() 警告特别多,可能是因为页面层级比较深,导致测量、布局和渲染时间过长,从而引起掉帧。

检测布局层级是否太深最有效的工具就是开发者选项中的GPU过度绘制模式了,这个稍后会讲到。

systrace对每一种警告类型都做出了解释:

Scheduling delay
渲染一帧的工作被推迟了几个毫秒,从而导致了不合格。确保UI线程上的代码不会被其他线程上完成的工作阻塞,并且后台线程(例如,网络或位图加载)在android.os.Process#THREAD_PRIORITY_BACKGROUND中运行或更低,因此它们不太可能中断UI线程。

Expensive measure/layout pass
测量/布局花费了很长时间,导致掉帧,要避免在动画过程中触发重新布局。

Long View#draw()
记录无效的绘图命令花费了很长时间,在View或Drawable自定义视图时,要避免做耗时操作,尤其是Bitmap的分配和绘制。

Expensive Bitmap uploads
修改或新创建Bitmap视图要传送给GPU,如果像素总数很大,这个操作会很耗时。因此在每一帧中要尽量减少Bitmap变更的次数。

Inefficient View alpha usage
将alpha设置为半透明值(0

traceview

TraceView 是 Android SDK 中内置的一个工具,它可以加载 trace 文件,用图形的形式展示代码的执行时间、次数及调用栈,便于我们分析。我们可以在Android Profiler或DDMS中启动它。

使用这个工具最关键的地方就是要理解各个统计维度的含义:

方法执行时间
Incl Cpu Time: 执行方法X及子方法占用Cpu的时间
Excl Cpu Time: 执行方法X占用Cpu时间,不包含子方法

Incl Real Time: 执行方法X及子方法总时间
Excl Real Time: 执行方法x总时间

Cpu Time/Call: 每次执行方法X占用Cpu时间
Real Time/Call: 每次执行方法X总时间

占用CPU比例

Incl Cpu Time%
Excl Cpu Time%
Incl Real Time%
Excl Real Time%

以上各个时间占Cpu执行耗时的百分比

调用次数

Calls + Recur Calls/Total: 方法X调用次数和递归调用次数

使用时只需要关注 Incl Real Time、Real Time/Call、Calls + Recur Calls/Total这三个指标即可,找出应用包名下的耗时方法调用后加以优化。

GPU过度绘制调试模式

开发者选项 -> 调试GPU过度绘制

  • 原色:没有过度绘制
  • 蓝色:过度绘制1次
  • 绿色:过度绘制2次
  • 粉色:过度绘制3次
  • 红色:过度绘制4次或更多

请注意,这些颜色是半透明的,因此,您在屏幕上看到的确切颜色取决于您的界面内容。

可以通过此功能查看哪些页面的布局层级过深。

常见卡顿原因及解决方案

过度绘制

去除不必要的背景色
1. 设置窗口背景色为通用背景色,去除根布局背景色。
2. 若页面背景色与通用背景色不一致,在页面渲染完成后移除窗口背景色
3. 去除和列表背景色相同的Item背景色

布局视图树扁平化
1. 移除嵌套布局
2. 使用merge、include标签
3. 使用性能消耗更小布局(TableLayout、ConstraintLayout)

减少透明色,即alpha属性的使用
1. 通过使用半透明颜色值(#77000000)代替

其他
1. 使用ViewStub标签,延迟加载不必要的视图
2. 使用AsyncLayoutInflater异步解析视图

主线程耗时操作

  1. Json数据解析耗时(Cache类)
  2. 文件操作(获取所属渠道名称)
  3. Binder通信(获取系统属性(mac地址))
  4. 正则匹配(Hybird 通信)
  5. 相机操作:初始化、预览、停止预览、释放(反扫)
  6. 组件初始化(推送)
  7. 循环删除、创建View(更多页面)
  8. WebView首次初始化

处理方案评估:
异步 > 缓存 > 替代方案 > 保持原状

异步:
1. 登录、退出登录后的数据处理
2. 相机操作
3. 组件初始化

示例:

    //异步启动消息推送服务
    private void startPushAsync(Context context) {
        Subscription startPushSub = Observable.unsafeCreate(subscriber -> {
            MyPushManager.getInstance().startPush(context);
            MyPushManager.getInstance().connectHwPushAgent(mInteraction.getActivity());
        }).compose(executorTransformer.transformer())
                .subscribe(new DefaultSubscriber<Object>(context) {
                    @Override
                    protected void onFinally() {
                        super.onFinally();
                    }
                });
        addSubscription(startPushSub);
    }

缓存:
1. Cache类
2. 系统属性

示例:

//获取应用渠道标识
public static String getChannel(Context context) {
        if (!TextUtils.isEmpty(APP_CHANNEL)) {
            return APP_CHANNEL;
        }

        ...        

         zipfile = new ZipFile(sourceDir);
            Enumeration<?> entries = zipfile.entries();
            while (entries.hasMoreElements()) {
                ZipEntry entry = ((ZipEntry) entries.nextElement());
                String entryName = entry.getName();
                if (entryName.contains(start_flag)) {
                    channel = entryName.replace(start_flag, "");
                    break;
                }
            }
}

替代方案:
1. Hybird通信中的一处正则匹配
2. 更多页面采用RecycleView嵌套

示例:


    private void dispatchMessage(WVJBMessage message) {
        String messageJSON = message2JSONObject(message).toString();
        //使用JSONObject的quote方法,代替正则替换,效率更高
        messageJSON = JSONObject.quote(messageJSON);
        messageJSON = messageJSON.substring(1, messageJSON.length() - 1);

        log("SEND", messageJSON);
        executeJavascript("WebViewJavascriptBridge._handleMessageFromObjC('"
                + messageJSON + "');");
    }

    旧版本
    String messageJSON = message2JSONObject(message).toString()
                .replaceAll("\\\\", "\\\\\\\\").replaceAll("\"", "\\\\\"")
                .replaceAll("\'", "\\\\\'").replaceAll("\n", "\\\\\n")
                .replaceAll("\r", "\\\\\r").replaceAll("\f", "\\\\\f");

保持原状:
1. WebView首次初始化耗时

提前加载一个WebView窗口 (没必要)
异步初始化WebView (不支持)

主线程挂起

  1. 异步线程与主线程竞争CPU资源

设置异步线程优先级为Process.THREAD_PRIORITY_BACKGROUND,减少与主线程的竞争。
有两种设置优先级的方式:Thread.currentThread().setPriority() 和 Process.setThreadPriority(),两种设置方式相互独立,应该使用后者。


Process.setThreadPriority(Process.myTid(), Process.THREAD_PRIORITY_BACKGROUND);

同时可以提高主线程的优先级

Process.setThreadPriority(Process.THREAD_PRIORITY_DISPLAY);
  1. 频繁GC使主线程挂起

后续内存优化

冷启动白屏

设置欢迎页窗口背景为应用Logo

优化效果

测试设备:手机低配版(512M Rom)未安装第三方App
测试标准:最大、平均掉帧数
测试App:我厂App release版

商米低配版 优化前 优化后
最大掉帧数 65帧 48帧
平均掉帧数 15帧 7帧

猜你喜欢

转载自blog.csdn.net/joye123/article/details/79425398