Android性能优化(一)

2021-01-22 18:08:29 浏览数 (1)

  • 启动优化
    • 视觉优化
      • 启动主题优化
    • 代码优化
      • 冷启动耗时统计
      • Application 优化
      • 闪屏页业务优化
      • 广告页优化
    • 优化效果
    • 启动窗口
  • UI渲染优化
    • CPU、GPU的职责
    • 查找Overdraw
    • clipRect解决自定义View的OverDraw
    • Hierarchy Viewer的使用
    • 内存抖动现象
  • 崩溃优化
    • 崩溃
      • 崩溃的收集
      • ANR
      • 应用退出
    • 崩溃处理
      • 崩溃现场
      • 崩溃分析
      • 系统崩溃
  • 内存优化
    • 优化工具
      • Memory Profiler
      • Memory Analyzer(MAT)
      • LeakCannary
    • 内存管理
      • 内存区域
      • 对象存活判断
      • 垃圾回收算法
    • 内存抖动
      • 模拟内存抖动
      • 分析并定位
    • 内存泄露
      • 模拟内存泄露
      • 分析并定位
    • MAT分析工具
      • Overview
      • Histogram
      • Dominator_tree
      • SQL
      • Thread_overview
      • Top Consumers
      • Leak Suspects
    • 通过ARTHook检测不合理图片
      • 获取Bitmap占用内存
      • 检测大图
    • 线上内存监控
      • 常规方案
      • LeakCannary定制改造
      • 完整方案

启动优化

一个应用App的启动速度能够影响用户的首次体验,启动速度较慢(感官上)的应用可能导致用户再次开启App的意图下降,或者卸载放弃该应用程序。

视觉优化

应用程序启动有三种状态,每种状态都会影响应用程序对用户可见所需的时间:冷启动,热启动和温启动。

在冷启动时,应用程序从头开始。在其他状态下,系统需要将正在运行的应用程序从后台运行到前台。我们建议您始终根据冷启动的假设进行优化。这样做也可以改善热启动和温启动的性能。

在冷启动开始时,系统有三个任务。这些任务是:

  1. 加载并启动应用程序。
  2. 启动后立即显示应用程序空白的启动窗口。
  3. 创建应用程序进程。

一旦系统创建应用程序进程,应用程序进程就会负责下一阶段。这些阶段是: 1. 创建app对象. 2. 启动主线程(main thread). 3. 创建应用入口的Activity对象. 4. 填充加载布局Views 5. 在屏幕上执行View的绘制过程.measure -> layout -> draw

应用程序进程完成第一次绘制后,系统进程会交换当前显示的背景窗口,将其替换为主活动。此时,用户可以开始使用该应用程序。

因为App应用进程的创建过程是由手机的软硬件决定的,所以我们只能在这个创建过程中视觉优化。

启动主题优化

冷启动阶段 :

  1. 加载并启动应用程序。
  2. 启动后立即显示应用程序空白的启动窗口。
  3. 创建应用程序进程。 所谓的主题优化,就是应用程序在冷启动的时候(1~2阶段),设置启动窗口的主题。

因为现在 App 应用启动都会先进入一个闪屏页(LaunchActivity) 来展示应用信息。

  • 默认情况

如果我们对App没有做处理(设置了默认主题),并且在 Application 初始化了其它第三方的服务(假设需要加载2000ms),那么冷启动过程就会如下图 :

系统默认会在启动应用程序的时候启动空白窗口,直到 App 应用程序的入口 Activity 创建成功,视图绘制完毕。( 大概是onWindowFocusChanged方法回调的时候 )

  • 透明主题优化

为了解决启动窗口白屏问题,许多开发者使用透明主题来解决这个问题,但是治标不治本。

虽然解决了上面这个问题,但是仍然有些不足。

代码语言:txt复制
    <!-- Base application theme. -->
    <style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
        <item name="android:windowFullscreen">true</item>
        <item name="android:windowIsTranslucent">true</item>
    </style>

(无白屏,不过从点击到App仍然存在视觉延迟~)

  • 设置闪屏图片主题

为了更顺滑无缝衔接我们的闪屏页,可以在启动 Activity 的 Theme中设置闪屏页图片,这样启动窗口的图片就会是闪屏页图片,而不是白屏。

代码语言:txt复制
    <style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar">
        <item name="android:windowBackground">@drawable/lunch</item>  //闪屏页图片
        <item name="android:windowFullscreen">true</item>
        <item name="android:windowDrawsSystemBarBackgrounds">false</item><!--显示虚拟按键,并腾出空间-->
    </style>

这样设置的话,就会在冷启动的时候,展示闪屏页的图片,等App进程初始化加载入口 Activity (也是闪屏页) 就可以无缝衔接。

其实这种方式并没有真正的加速应用进程的启动速度,而只是通过用户视觉效果带来的优化体验。

代码优化

当然上面使用设置主题的方式优化用户体验效果治标不治本,关键还在于对代码的优化。

首先统计一下应用冷启动的时间。

冷启动耗时统计

  • adb 命令统计

adb命令 :adb shell am start -S -W 包名/启动类的全限定名, -S 表示重启当前应用

代码语言:txt复制
C:AndroidDemo>adb shell am start -S -W com.example.moneyqian.demo/com.example.moneyqian.demo.MainActivity
Stopping: com.example.moneyqian.demo
Starting: Intent { act=android.intent.action.MAIN cat=[android.intent.category.LAUNCHER] cmp=com.example.moneyqian.demo/.MainActivity }
Status: ok
Activity: com.example.moneyqian.demo/.MainActivity
ThisTime: 2247
TotalTime: 2247
WaitTime: 2278
Complete
  • ThisTime : 最后一个 Activity 的启动耗时(例如从 LaunchActivity - >MainActivity「adb命令输入的Activity」 , 只统计 MainActivity 的启动耗时)
  • TotalTime : 启动一连串的 Activity 总耗时.(有几个Activity 就统计几个)
  • WaitTime : 应用进程的创建过程 TotalTime .
  • 在第①个时间段内,AMS 创建 ActivityRecord 记录块和选择合理的 Task、将当前Resume 的 Activity 进行 pause.
  • 在第②个时间段内,启动进程、调用无界面 Activity 的 onCreate() 等、 pause/finish 无界面的 Activity.
  • 在第③个时间段内,调用有界面 Activity 的 onCreate、onResume.
代码语言:txt复制
//ActivityRecord

    private void reportLaunchTimeLocked(final long curTime) {
		``````
        final long thisTime = curTime - displayStartTime;
        final long totalTime = stack.mLaunchStartTime != 0 ? (curTime - stack.mLaunchStartTime) : thisTime;
    }

如果需要统计从点击桌面图标到 Activity 启动完毕,可以用WaitTime作为标准,但是系统的启动时间优化不了,所以优化冷启动只要在意ThisTime即可。

  • 系统日志统计

也可以根据系统日志来统计启动耗时,在Android Studio中查找已用时间,必须在logcat视图中禁用过滤器(No Filters)。因为这个是系统的日志输出,而不是应用程序的。你也可以查看其它应用程序的启动耗时。

过滤displayed输出的启动日志.

根据上面启动时间的输出统计,就可以先记录优化前的冷启动耗时,然后再对比优化之后的启动时间。

Application 优化

Application 作为 应用程序的整个初始化配置入口,时常担负着它不应该有的负担

有很多第三方组件(包括App应用本身)都在 Application 中抢占先机,完成初始化操作。

但是在 Application 中完成繁重的初始化操作和复杂的逻辑就会影响到应用的启动性能

通常,有机会优化这些工作以实现性能改进,这些常见问题包括:

  1. 复杂繁琐的布局初始化
  2. 阻塞主线程 UI 绘制的操作,如 I/O 读写或者是网络访问.
  3. Bitmap 大图片或者 VectorDrawable加载
  4. 其它占用主线程的操作

我们可以根据这些组件的轻重缓急之分,对初始化做一下分类 :

  1. 必要的组件一定要在主线程中立即初始化(入口 Activity 可能立即会用到)
  2. 组件一定要在主线程中初始化,但是可以延迟初始化。
  3. 组件可以在子线程中初始化。

放在子线程的组件初始化建议延迟初始化,这样就可以了解是否会对项目造成影响!

所以对于上面的分析,可以在项目中 Application 的加载组件进行如下优化 :

  • 将Bugly,x5内核初始化,SP的读写,友盟等组件放到子线程中初始化。(子线程初始化不能影响到组件的使用)
代码语言:txt复制
        new Thread(new Runnable() {
            @Override
            public void run() {
                //设置线程的优先级,不与主线程抢资源
                Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND);
				//子线程初始化第三方组件
				Thread.sleep(5000);//建议延迟初始化,可以发现是否影响其它功能,或者是崩溃!
            }
        }).start();
  • 将需要在主线程中初始化但是可以不用立即完成的动作延迟加载(原本是想在入口 Activity 中进行此项操作,不过组件的初始化放在 Application 中统一管理为妙.)
代码语言:txt复制
        handler.postDelayed(new Runnable() {
            @Override
            public void run() {
				//延迟初始化组件
            }
        }, 3000);

闪屏页业务优化

最后还剩下那些为数不多的组件在主线程初始化动作,例如埋点,点击流,数据库初始化等,不过这些消耗的时间可以在其它地方相抵

需求背景: 应用App通常会设置一个固定的闪屏页展示时间,例如2000ms,所以我们可以根据用户手机的运行速度,对展示时间做出调整,但是总时间仍然为 2000ms。

闪屏页政展示总时间 = 组件初始化时间 剩余展示时间

也就是2000ms的总时间,组件初始化了800ms,那么就再展示1200ms即可。

先了解一下 Application的启动过程

虽然这个以下图片的源码并不是最新源码(5.0源码),不过不影响整体流程。(7.0,8.0方法名会有所改变)。

冷启动的过程中系统会初始化应用程序进程,创建Application等任务,这时候会展示一个启动窗口 Starting Window,如果没有优化主题的话,那么就是白屏。

分析源码后,我们可以知道 Application 初始化后会调用attachBaseContext()方法,再调用 Application 的onCreate(),再到入口 Activity的创建和执行onCreate()方法。所以我们就可以在 Application 中记录启动时间。

代码语言:txt复制
//Application

    @Override
    protected void attachBaseContext(Context base) {
        super.attachBaseContext(base);
		SPUtil.putLong("application_attach_time", System.currentTimeMillis());//记录Application初始化时间
    }

有了启动时间,我们得知道入口的 Acitivty 显示给用户的时间(View绘制完毕),在onWindowFocusChanged()的回调时机中表示可以获取用户的触摸时间和View的流程绘制完毕,所以可以在这个方法里记录显示时间。

代码语言:txt复制
//入口Activity

    @Override
    public void onWindowFocusChanged(boolean hasFocus) {
        super.onWindowFocusChanged(hasFocus);
  
          long appAttachTime = SPUtil.getLong("application_attach_time");
          long diffTime = System.currentTimeMillis() - appAttachTime;//从application到入口Acitity的时间
 
		 //所以闪屏页展示的时间为 2000ms - diffTime.
    }

所以就可以动态的设置应用闪屏的显示时间,尽量让每一部手机展示的时间一致,这样就不会让手机配置较低的用户感觉漫长难熬的闪屏页时间(例如初始化了2000ms,又要展示2000ms的闪屏页时间.),优化用户体验。

广告页优化

闪屏页过后就要展示金主爸爸们的广告页了。

因为项目中广告页图片有可能是大图,APng动态图片,所以需要将这些图片下载到本地文件,下载完成后再显示,这个过程往往会遇到以下两个问题 :

  • 广告页的下载,由于这个是一个异步过程,所以往往不知道加载到页面的合适时机。
  • 广告页的保存,因为保存是 I/O 流操作,很有可能被用户中断,下次拿到破损的图片。

因为不清楚用户的网络环境,有些用户下载广告页可能需要一段时间,这时候又不可能无限的等候。所以针对这个问题可以开启IntentService用来下载广告页图片。

  • 在入口 Acitivity 中开启IntentService来下载广告页。 或者是其它异步下载操作。
  • 在广告页图片文件流完全写入后记录图片大小,或者记录一个标识。

在下次的广告页加载中可以判断是否已经下载好了广告页图片以及图片是否完整,否则删除并且再次下载图片。

另外因为在闪屏页中仍然有剩余展示时间,所以在这个时间段里如果用户已经下载好了图片并且图片完整,就可以显示广告页。否则进入主 Activity , 因为IntentService仍然在后台继续默默的下载并保存图片~

优化效果

优化前 : (小米6)

Displayed

LaunchActivity

MainActivity

2s526ms

1s583ms

2s603ms

1s533ms

2s372ms

1s556ms

优化后 : (小米6)

Displayed

LaunchActivity

MainActivity

995ms

1s191ms

911ms

1s101ms

903ms

1s187ms

通过手上 小米6,小米 mix2s,还有小米 2s的启动测试,发现优化后App冷启动的启动速度均提升了 60% !!! ,并且可以再看一下手机冷启动时候的内存情况 :

优化前 : 伴随着大量对象的创建回收,15s内系统GC 5次。内存使用波澜荡漾。

优化后 : 趋于平稳上升状态创建对象,15s内系统GC 2次。(后期业务拓展加入新功能,所以代码量增加。)之后总内存使用平缓下降。

  • Other:应用使用的系统不确定如何分类的内存。
  • Code:应用用于处理代码和资源(如 dex 字节码、已优化或已编译的 dex 码、.so 库和字体)的内存。
  • Stack: 应用中的原生堆栈和 Java 堆栈使用的内存。 这通常与您的应用运行多少线程有关。
  • Graphics:图形缓冲区队列向屏幕显示像素(包括 GL 表面、GL 纹理等等)所使用的内存。 (请注意,这是与 CPU 共享的内存,不是 GPU 专用内存。)
  • Native:从 C 或 C 代码分配的对象内存。即使应用中不使用 C ,也可能会看到此处使用的一些原生内存,因为 Android 框架使用原生内存代表处理各种任务,如处理图像资源和其他图形时,即使编写的代码采用 Java 或 Kotlin 语言。
  • Java:从 Java 或 Kotlin 代码分配的对象内存。
  • Allocated:应用分配的 Java/Kotlin 对象数。 它没有计入 C 或 C 中分配的对象。

启动窗口

优化完代码后,分析一下启动窗口的源码。基于 android-25 (7.1.1)

启动窗口是由 WindowManagerService 统一管理的 Window 窗口,一般作为冷启动页入口 Activity 的预览窗口,启动窗口由 ActivityManagerService 来决定是否显示的,并不是每一个 Activity 的启动和跳转都会显示这个窗口。

WindowManagerService 通过窗口管理策略类 PhoneWindowManager 来创建启动窗口。

AMS启动Activity流程

ActivityStarterstartActivityUnchecked() 方法中,调用了 ActivityStack (Activity 状态管理)的 startActivityLocked() 方法。此时Activity 还在启动过程中,窗口并未显示。

启动窗口的显示过程

首先,由 Activity 状态管理者 ActivityStack 开始执行显示启动窗口的流程。

代码语言:txt复制
//ActivityStack


 final void startActivityLocked(ActivityRecord r, boolean newTask, boolean keepCurTransition,
            ActivityOptions options) {

		``````
        if (!isHomeStack() || numActivities() > 0) {//HOME_STACK表示Launcher桌面所在的Stack
	        // 1.首先当前启动栈不在Launcher的桌面栈里,并且当前系统已经有激活过Activity
	        
            // We want to show the starting preview window if we are
            // switching to a new task, or the next activity's process is
            // not currently running.

            boolean doShow = true;
            if (newTask) {
	            // 2.要将该Activity组件放在一个新的任务栈中启动
	            
                // Even though this activity is starting fresh, we still need
                // to reset it to make sure we apply affinities to move any
                // existing activities from other tasks in to it.
                if ((r.intent.getFlags() & Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED) != 0) {
                    resetTaskIfNeededLocked(r, r);
                    doShow = topRunningNonDelayedActivityLocked(null) == r;
                }
            } else if (options != null && options.getAnimationType()
                    == ActivityOptions.ANIM_SCENE_TRANSITION) {
                doShow = false;
            }
            if (r.mLaunchTaskBehind) {
	            //3. 热启动,不需要启动窗口
	            
                // Don't do a starting window for mLaunchTaskBehind. More importantly make sure we
                // tell WindowManager that r is visible even though it is at the back of the stack.
                mWindowManager.setAppVisibility(r.appToken, true);
                ensureActivitiesVisibleLocked(null, 0, !PRESERVE_WINDOWS);
            } else if (SHOW_APP_STARTING_PREVIEW && doShow) {

				``````
				//4. 显示启动窗口
                r.showStartingWindow(prev, showStartingIcon);
            }
        } else {
	        // 当前启动的是桌面Launcher (开机启动)
            // If this is the first activity, don't do any fancy animations,
            // because there is nothing for it to animate on top of.
			``````
        }

    }
  1. 首先判断当前要启动的 Activity 不在Launcher栈里
  2. 要启动的 Activity 是否处于新的 Task 里,并且没有转场动画
  3. 如果是热/温启动则不需要启动窗口,直接设置App的Visibility

接下来调用 ActivityRecordshowStartingWindow() 方法来设置启动窗口并且改变当前窗口的状态。

如果 App 的应用进程创建完成,并且入口 Activity 准备就绪,就可以根据 mStartingWindowState 来判断是否需要关闭启动窗口。

代码语言:txt复制
//ActivityRecord


    void showStartingWindow(ActivityRecord prev, boolean createIfNeeded) {
        final CompatibilityInfo compatInfo =
                service.compatibilityInfoForPackageLocked(info.applicationInfo);
        final boolean shown = service.mWindowManager.setAppStartingWindow(
                appToken, packageName, theme, compatInfo, nonLocalizedLabel, labelRes, icon,
                logo, windowFlags, prev != null ? prev.appToken : null, createIfNeeded);
        if (shown) {
            mStartingWindowState = STARTING_WINDOW_SHOWN;
        }
    }

WindowManagerService 会对当前 Activity 的token和主题进行判断。

代码语言:txt复制
//WindowManagerService

 @Override
    public boolean setAppStartingWindow(IBinder token, String pkg,
            int theme, CompatibilityInfo compatInfo,
            CharSequence nonLocalizedLabel, int labelRes, int icon, int logo,
            int windowFlags, IBinder transferFrom, boolean createIfNeeded) {

        synchronized(mWindowMap) {

			//1. 启动窗口也是需要token的
            AppWindowToken wtoken = findAppWindowToken(token);
            
			//2. 如果已经设置过启动窗口了,不继续处理
            if (wtoken.startingData != null) {
                return false;
            }

            // If this is a translucent window, then don't
            // show a starting window -- the current effect (a full-screen
            // opaque starting window that fades away to the real contents
            // when it is ready) does not work for this.
            if (theme != 0) {
                AttributeCache.Entry ent = AttributeCache.instance().get(pkg, theme,
                        com.android.internal.R.styleable.Window, mCurrentUserId);
                        
               //3. 一堆代码对主题判断,不符合要求则不显示启动窗口(如透明主题)
                if (windowIsTranslucent) {
                    return false;
                }
                if (windowIsFloating || windowDisableStarting) {
                    return false;
                }
				``````
            }

			//4. 创建StartingData,并且通过Handler发送消息

            wtoken.startingData = new StartingData(pkg, theme, compatInfo, nonLocalizedLabel,
                    labelRes, icon, logo, windowFlags);
            Message m = mH.obtainMessage(H.ADD_STARTING, wtoken);
            // Note: we really want to do sendMessageAtFrontOfQueue() because we
            // want to process the message ASAP, before any other queued
            // messages.

            mH.sendMessageAtFrontOfQueue(m);
        }
        return true;
    }
  1. 启动窗口也需要和 Activity 拥有同样令牌 token ,虽然启动窗口可能是白屏,或者一张图片,但是仍然需要走绘制流程已经通过WMS显示窗口。
  2. StartingData对象用来表示启动窗口的相关数据,描述了启动窗口的视图信息。
  3. 如果当前 Activity 是透明主题或者是浮动窗口等,那么就不需要启动窗口来过渡启动过程,所以在上面视觉优化中的设置透明主题就没有显示白色的启动窗口。
  4. 显示启动窗口也是一件心急火燎的事情,WMS的内部类H (handler) 处于主线程处理消息,所以需要将当前Message放置队列头部。

为什么需要通过 Handler 发送消息 ?

你可以在各大服务Service中见到 Handler 的身影,并且它们可能都有一个很吊的命名 H ,因为可能调用这个服务的某个执行方法处于子线程中,所以 Handler 的职责就是将它们切换到主线程中,并且也可以统一管理调度。

代码语言:txt复制
//WindowManagerService --> H 

        public void handleMessage(Message msg) {
            switch (msg.what) {

                case ADD_STARTING: {
                    final AppWindowToken wtoken = (AppWindowToken)msg.obj;
                    final StartingData sd = wtoken.startingData;

                    View view = null;
                    try {
                        final Configuration overrideConfig = wtoken != null && wtoken.mTask != null
                                ? wtoken.mTask.mOverrideConfig : null;
                        view = mPolicy.addStartingWindow(wtoken.token, sd.pkg, sd.theme,
                            sd.compatInfo, sd.nonLocalizedLabel, sd.labelRes, sd.icon, sd.logo,
                            sd.windowFlags, overrideConfig);
                    } catch (Exception e) {
                        Slog.w(TAG_WM, "Exception when adding starting window", e);
                    }

                    ``````
      
                } break;
   }     

在当前的 handleMessage 方法中,会处于主线程处理消息,拿到token和StartingData启动数据后,便通过 mPolicy.addStartingWindow() 方法将启动窗口添加到WIndow上。

mPolicyPhoneWindowManager ,控制着启动窗口的添加删除和修改。

在PhoneWindowManager对启动窗口进行配置,获取当前Activity设置的主题和资源信息,设置到启动窗口中。

代码语言:txt复制
//PhoneWindowManager


@Override
    public View addStartingWindow(IBinder appToken, String packageName, int theme,
            CompatibilityInfo compatInfo, CharSequence nonLocalizedLabel, int labelRes,
            int icon, int logo, int windowFlags, Configuration overrideConfig) {
            
         //可以通过SHOW_STARTING_ANIMATIONS设置不显示启动窗口
        if (!SHOW_STARTING_ANIMATIONS) {
            return null;
        }
        WindowManager wm = null;
        View view = null;

        try {
	        //1. 获取上下文Context和主题theme以及标题
            Context context = mContext;
            if (theme != context.getThemeResId() || labelRes != 0) {
                try {
                    context = context.createPackageContext(packageName, 0);
                    context.setTheme(theme);
                } catch (PackageManager.NameNotFoundException e) {
                    // Ignore
                }
            }

			//2. 创建PhoneWindow 用来显示
            final PhoneWindow win = new PhoneWindow(context);
            win.setIsStartingWindow(true);

			//3. 设置当前窗口type和flag,源码注释中描述的很清晰...
            win.setType(
                WindowManager.LayoutParams.TYPE_APPLICATION_STARTING);

            // Force the window flags: this is a fake window, so it is not really
            // touchable or focusable by the user.  We also add in the ALT_FOCUSABLE_IM
            // flag because we do know that the next window will take input
            // focus, so we want to get the IME window up on top of us right away.
            win.setFlags(
                windowFlags|
                WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE|
                WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE|
                WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM,
                windowFlags|
                WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE|
                WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE|
                WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM);

            win.setLayout(WindowManager.LayoutParams.MATCH_PARENT,
                    WindowManager.LayoutParams.MATCH_PARENT);

			``````
			
            view = win.getDecorView();

			//4. WindowManager的绘制流程
            wm.addView(view, params);

            return view.getParent() != null ? view : null;
        } catch (WindowManager.BadTokenException e) {
            // ignore
        } catch (RuntimeException e) {
            // don't crash if something else bad happens, for example a
            // failure loading resources because we are loading from an app
            // on external storage that has been unmounted.
            Log.w(TAG, appToken   " failed creating starting window", e);
        }
        return null;
    }
  1. 如果theme和labelRes的值不为0,那么说明开发者指定了启动窗口的主题和标题,那么就需要从当前要启动的Activity中获取这些信息,并设置到启动窗口中。
  2. 和其它窗口一样,启动窗口也需要通过PhoneWindow来设置布局信息DecorView。所以在上面视觉优化中的设置闪屏图片主题的启动窗口显示的就是图片内容。
  3. 启动窗口和普通窗口的不同之处在于它是 fake window ,不需要触摸事件
  4. 最后通过WindowManger走View的绘制流程(measure-layout-draw)将启动窗口显示出来,最后会请求WindowManagerService为启动窗口添加一个WindowState对象,真正的将启动窗口显示给用户,并且可以对启动窗口进行管理。

UI渲染优化

理解工作中常用的UI渲染性能优化及调试方法对于我们编写高质量代码也是很有帮助的

CPU、GPU的职责

对于大多数手机的屏幕刷新频率是60hz,也就是如果在1000/60=16.67ms内没有把这一帧的任务执行完毕,就会发生丢帧的现象,丢帧是造成界面卡顿的直接原因,渲染操作通常依赖于两个核心组件:CPU与GPU。CPU负责包括Measure,Layout等计算操作,GPU负责Rasterization(栅格化)操作(所谓栅格化就是将矢量图形转换为位图的过程,手机上显示是按照一个个像素来显示的,栅格化再普通一些的说法就是将一个Button,TextView等组件拆分到一个个像素上去显示)。

UI渲染优化的目的就是减轻CPU,GPU的压力,除去不必要的操作,保证每帧16ms以内处理完所有的CPU与GPU的计算,绘制,渲染等等操作,使UI顺滑,流畅的展示出来。

查找Overdraw

Overdraw(过度绘制)描述的是屏幕上的某个像素在同一帧的时间内被绘制了多次。在重叠的UI布局中,如果不可见的UI也在做绘制的操作或者后一个控件将前一个控件遮挡,会导致某些像素区域被绘制了多次,从而增加了CPU,GPU的压力。

那么如何找出布局中Overdraw的地方呢?很简单,一般手机里面开发者选项都有调试GPU过度绘制的开关,打开即可。

以小米4手机为例,依次找到设置->更多设置->开发者选项->调试GPU过度绘制开关,打开就可以了。

打开调试GPU过度绘制开关之后,再次回到自己开发的应用发现界面怎么多了一些花花绿绿的玩意,没错,不同的颜色代表过度绘制的程度,具体如下:

蓝色,淡绿,淡红,深红代表了4种不同程度的Overdraw情况,1x,2x,3x,4x分别表示同一像素上同一帧的时间内被绘制了多次,1x就表示一次(最理想情况),4x表示4次(最差的情况),我们要做的就是尽量减少3x,4x的情况出现。

下面以一个简单demo来进一步说明一下,比如我们开发好一个界面,如下:

很简单的功能,功能做完了,能不能做下优化呢?打开OverDraw功能,再次查看界面,如下:

咦?怎么大部分都是浅绿色呢?也就是说同一像素上同一帧的时间内被绘制了2次,这是怎么回事?这时我们需要看下UI布局了,看哪些地方可以优化一下。

主界面布局如下:

代码语言:txt复制
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">

<ListView
android:id="@ id/list_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:divider="#F1F1F1"
android:dividerHeight="1dp"
android:background="@android:color/white"
android:scrollbars="vertical">
</ListView>

</RelativeLayout>

ListView每个条目布局如下:

代码语言:txt复制
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="52dp"
android:background="@drawable/ts_account_list_selector">

<TextView
android:id="@ id/ts_item_has_login_account"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginLeft="10dp"
android:layout_marginTop="4dp"
android:gravity="center"
android:text="12345678999"
android:textColor="@android:color/black"
android:textSize="16sp" />

<LinearLayout
android:layout_width="wrap_content"
android:layout_height="20dp"
android:layout_alignParentBottom="true"
android:layout_marginBottom="3dp"
android:layout_marginLeft="10dp"
android:gravity="center_vertical" >

<ImageView
android:id="@ id/ts_item_time_clock_image"
android:layout_width="12dp"
android:layout_height="12dp"
android:src="@mipmap/ts_login_clock" />

<TextView
android:id="@ id/ts_item_last_login_time"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginLeft="5dp"
android:layout_toRightOf="@id/ts_item_time_clock_image"
android:text="上次登录"
android:textColor="@android:color/darker_gray"
android:textSize="11sp" />

<TextView
android:id="@ id/ts_item_login_time"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginLeft="5dp"
android:layout_toRightOf="@id/ts_item_last_login_time"
android:text="59分钟前"
android:textColor="@android:color/darker_gray"
android:textSize="11sp" />
</LinearLayout>

<TextView
android:id="@ id/ts_item_always_account_image_tips"
android:layout_width="wrap_content"
android:layout_height="13dp"
android:layout_alignParentRight="true"
android:layout_marginTop="2dp"
android:background="@mipmap/ts_always_account_bg"
android:gravity="center"
android:text="常用"
android:textColor="@android:color/white"
android:textSize="9sp" />

<ImageView
android:id="@ id/ts_item_delete_account_image"
android:layout_width="12dp"
android:layout_height="12dp"
android:layout_alignParentRight="true"
android:layout_marginTop="2dp"
android:layout_marginRight="13dp"
android:layout_centerVertical="true"
android:src="@mipmap/ts_close" />

</RelativeLayout>

发现哪里有问题了吗?问题在于ListView多余设置了背景:android:background="@android:color/white",设置此背景对于我们这个需求根本就没有用,显示不出来并且增加GPU额外压力,去掉ListView背景之后再次观察如下:

渲染性能提升了一个档次,在实际工作中情况会复杂很多,为了实现一个效果会不得不牺牲性能,这就需要自己团队权衡了。

clipRect解决自定义View的OverDraw

平时写自定义View的时候有时会重写onDraw方法,但是Android系统是无法检测onDraw里面具体会执行什么操作,从而系统无法为我们做一些优化。这样对编程人员要求就高了,如果我们自己写的View有大量重叠的地方就造成了CPU,GPU资源的浪费,但是我们可以通过canvas.clipRect()来帮助系统识别那些可见的区域。这个方法可以指定一块矩形区域,只有在这个区域内才会被绘制,其他的区域会被忽视,下面我们通过谷歌提供的一个小demo进一步说明。实现效果如下:

主要就是卡片重叠效果,优化前代码实现如下:

DroidCard类封装要绘制的一个个卡片的信息:

代码语言:txt复制
public class DroidCard {

public int x;//左侧绘制起点
public int width;
public int height;
public Bitmap bitmap;

public DroidCard(Resources res,int resId,int x){
this.bitmap = BitmapFactory.decodeResource(res,resId);
this.x = x;
this.width = this.bitmap.getWidth();
this.height = this.bitmap.getHeight();
}
}

DroidCardsView为真正的自定义View:

代码语言:txt复制
public class DroidCardsView extends View {
//图片与图片之间的间距
private int mCardSpacing = 150;
//图片与左侧距离的记录
private int mCardLeft = 10;

private List<DroidCard> mDroidCards = new ArrayList<DroidCard>();

private Paint paint = new Paint();

public DroidCardsView(Context context) {
super(context);
initCards();
}

public DroidCardsView(Context context, AttributeSet attrs) {
super(context, attrs);
initCards();
}
/**
* 初始化卡片集合
*/
protected void initCards(){
Resources res = getResources();
mDroidCards.add(new DroidCard(res,R.drawable.alex,mCardLeft));

mCardLeft =mCardSpacing;
mDroidCards.add(new DroidCard(res,R.drawable.claire,mCardLeft));

mCardLeft =mCardSpacing;
mDroidCards.add(new DroidCard(res,R.drawable.kathryn,mCardLeft));
}

@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
for (DroidCard c : mDroidCards){
drawDroidCard(canvas, c);
}
invalidate();
}

/**
* 绘制DroidCard
*/
private void drawDroidCard(Canvas canvas, DroidCard c) {
canvas.drawBitmap(c.bitmap,c.x,0f,paint);
}
}

代码不是重点,不过也不难,自行查看就可以了。我们打开overdraw开关,效果如下:

淡红色区域明显被绘制了三次(三张图片重合的地方),其实下面的图片完全没必要完全绘制,只需要绘制三分之一即可,接下来我们就需要对其优化,保证最下面两张图片只需要回执其三分之一最上面图片完全绘制出来就可。

DroidCardsView代码优化为:

代码语言:txt复制
public class DroidCardsView extends View {

//图片与图片之间的间距
private int mCardSpacing = 150;
//图片与左侧距离的记录
private int mCardLeft = 10;

private List<DroidCard> mDroidCards = new ArrayList<DroidCard>();

private Paint paint = new Paint();

public DroidCardsView(Context context) {
super(context);
initCards();
}

public DroidCardsView(Context context, AttributeSet attrs) {
super(context, attrs);
initCards();
}
/**
* 初始化卡片集合
*/
protected void initCards(){
Resources res = getResources();
mDroidCards.add(new DroidCard(res, R.drawable.alex,mCardLeft));

mCardLeft =mCardSpacing;
mDroidCards.add(new DroidCard(res, R.drawable.claire,mCardLeft));

mCardLeft =mCardSpacing;
mDroidCards.add(new DroidCard(res, R.drawable.kathryn,mCardLeft));
}

@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
for (int i = 0; i < mDroidCards.size() - 1; i  ){
drawDroidCard(canvas, mDroidCards,i);
}
drawLastDroidCard(canvas,mDroidCards.get(mDroidCards.size()-1));
invalidate();
}

/**
* 绘制最后一个DroidCard
* @param canvas
* @param c
*/
private void drawLastDroidCard(Canvas canvas,DroidCard c) {
canvas.drawBitmap(c.bitmap,c.x,0f,paint);
}

/**
* 绘制DroidCard
* @param canvas
* @param mDroidCards
* @param i
*/
private void drawDroidCard(Canvas canvas,List<DroidCard> mDroidCards,int i) {
DroidCard c = mDroidCards.get(i);
canvas.save();
canvas.clipRect((float)c.x,0f,(float)(mDroidCards.get(i 1).x),(float)c.height);
canvas.drawBitmap(c.bitmap,c.x,0f,paint);
canvas.restore();
}
}

主要就是使用Canvas的clipRect方法,绘制之前裁剪出一个区域,这样绘制的时候只在这区域内绘制,超出部分不会绘制出来。

重新执行程序,效果如下:

处理后性能就提升了一丝丝,此外我们还可以使用canvas.quickReject方法来判断是否没和某个矩形相交,从而跳过那些非矩形区域内的绘制操作。

Hierarchy Viewer的使用

Hierarchy Viewer可以很直观的呈现布局的层次关系。我们可以通过红,黄,绿三种不同的颜色来区分布局的Measure,Layout,Executive的相对性能表现如何

提升布局性能的关键点是尽量保持布局层级的扁平化,避免出现重复的嵌套布局。如果我们写的布局层级比较深会严重增加CPU的负担,造成性能的严重卡顿,关于Hierarchy Viewer的使用举例这里就不列举了。

内存抖动现象

在我们优化过view的树形结构和overdraw之后,可能还是感觉自己的app有卡顿和丢帧,或者滑动慢:卡顿还是存在。这时我们就要查看一下是否存在内存抖动情况了

Android有自动管理内存的机制,但是对内存的不恰当使用仍然容易引起严重的性能问题。在同一帧里面创建过多的对象是件需要特别引起注意的事情,在同一帧里创建大量对象可能引起GC的不停操作,执行GC操作的时候,所有线程的任何操作都会需要暂停,直到GC操作完成。大量不停的GC操作则会显著占用帧间隔时间。

如果在帧间隔时间里面做了过多的GC操作,那么自然其他类似计算,渲染等操作的可用时间就变得少了,严重时可能引起卡顿:

导致GC频繁操作有两个主要原因:

  1. 内存抖动,所谓内存抖动就是短时间产生大量对象又在短时间内马上释放。
  2. 短时间产生大量对象超出阈值,内存不够,同样会触发GC操作。

观察内存抖动我们可以借助android studio中的工具,3.0以前可以使用android monitor,3.0以后被替换为android Profiler。

如果工具里面查看到短时间发生了多次内存的涨跌,这意味着很有可能发生了内存抖动,如图:

为了避免发生内存抖动,我们需要避免在for循环里面分配对象占用内存,需要尝试把对象的创建移到循环体之外,自定义View中的onDraw方法也需要引起注意,每次屏幕发生绘制以及动画执行过程中,onDraw方法都会被调用到,避免在onDraw方法里面执行复杂的操作,避免创建对象。对于那些无法避免需要创建对象的情况,我们可以考虑对象池模型,通过对象池来解决频繁创建与销毁的问题,但是这里需要注意结束使用之后,需要手动释放对象池中的对象。

崩溃优化

崩溃

崩溃率是衡量一个应用质量高低的基本指标,那么,该怎样客观地衡量崩溃这个指标,以及又该如何看待和崩溃相关的稳定性。

Android 的两种崩溃:

  1. Java 崩溃
  2. Native 崩溃

简单来说,Java 崩溃就是在 Java 代码中,出现了未捕获异常,导致程序异常退出。那 Native 崩溃一般都是因为在 Native 代码中访问非法地址,也可能是地址对齐出现了问题,或者发生了程序主动 Abort,这些都会产生相应的 Signal 信号,导致程序异常退出。

崩溃的收集

“崩溃”就是程序出现异常,而一个产品的崩溃率,跟我们如何捕获、处理这些异常有比较大的关系。对于很多中小型公司来说,可以选择一些第三方的服务。目前各种平台也是百花齐放,包括阿里的友盟、腾讯的Bugly、网易云捕、Google 的 Firebase 等等。要懂得借力!

ANR

崩溃率是不是就能完全等价于应用的稳定性呢?答案是肯定不行。处理了崩溃,我们还会经常遇到 ANR(Application Not Responding,程序没有响应)这个问题。

出现 ANR 的时候,系统还会弹出对话框打断用户的操作,这是用户非常不能忍受的。

ANR处理方法:

使用 FileObserver 监听 /data/anr/traces.txt 的变化。非常不幸的是,很多高版本的 ROM,已经没有读取这个文件的权限了。这个时候你可能只能思考其他路径,海外可以使用 Google Play 服务,而国内微信利用Hardcoder框架(HC 框架是一套独立于安卓系统实现的通信框架,它让 App 和厂商 ROM 能够实时“对话”了,目标就是充分调度系统资源来提升 App 的运行速度和画质,切实提高大家的手机使用体验)向厂商获取了更大的权限。也可以将手机 ROOT 掉,然后取得 traces.txt 文件。

应用退出

除了常见的崩溃,还有一些会导致应用异常退出的情况,例如:

  1. 主动自杀。Process.killProcess()、exit() 等
  2. 崩溃。出现了 Java 或 Native 崩溃
  3. 系统重启。系统出现异常、断电、用户主动重启等,我们可以通过比较应用开机运行时间是否比之前记录的值更小
  4. 被系统杀死。被 low memory killer 杀掉、从系统的任务管理器中划掉等
  5. ANR

我们可以在应用启动的时候设定一个标志,在主动自杀或崩溃后更新标志,这样下次启动时通过检测这个标志就能确认运行期间是否发生过异常退出。对应上面的五种退出场景,我们排除掉主动自杀和崩溃(崩溃会单独的统计)这两种场景,希望可以监控到剩下三种的异常退出,理论上这个异常捕获机制是可以达到 100% 覆盖的。

通过这个异常退出的检测,可以反映如 ANR、low memory killer、系统强杀、死机、断电等其他无法正常捕获到的问题。当然异常率会存在一些误报,比如用户从系统的任务管理器中划掉应用。对于线上的大数据来说,还是可以帮助我们发现代码中的一些隐藏问题。

根据应用的前后台状态,我们可以把异常退出分为前台异常退出和后台异常退出。“被系统杀死” 是后台异常退出的主要原因,当然我们会更关注前台的异常退出的情况,这会跟 ANR、OOM 等异常情况有更大的关联。

崩溃处理

我们每天工作也会遇到各种各样的疑难问题,“崩溃”就是其中比较常见的一种问题。解决问题跟破案一样需要经验,我们分析的问题越多越熟练,定位问题就会越快越准。

当然这里也有很多套路,比如:

对于 “案发现场” 我们应该留意哪些信息?

怎样找到更多的 “证人” 和 “线索” ?

“侦查案件” 的一般流程是什么?

对不同类型的 “案件” 分别应该使用什么样的调查方式?

要相信 “真相永远只有一个”,崩溃也并不可怕。

崩溃现场

崩溃现场是我们的“第一案发现场”,它保留着很多有价值的线索。现在可以挖掘到的信息越多,下一步分析的方向就越清晰,而不是去靠盲目猜测。

崩溃信息

从崩溃的基本信息,我们可以对崩溃有初步的判断。进程名、线程名。崩溃的进程是前台进程还是后台进程,崩溃是不是发生在 UI 线程。

崩溃堆栈和类型。崩溃是属于 Java 崩溃、Native 崩溃,还是 ANR,对于不同类型的崩溃关注的点也不太一样。特别需要看崩溃堆栈的栈顶,看具体崩溃在系统的代码,还是 APP 代码里面。

关键字:FATAL

代码语言:txt复制
FATAL EXCEPTION: main
Process: com.cchip.csmart, PID: 27456
java.lang.NullPointerException: Attempt to invoke virtual method 'void android.widget.TextView.setText(int)' on a null object reference
at com.cchip.alicsmart.activity.SplashActivity$1.handleMessage(SplashActivity.java:67)
at android.os.Handler.dispatchMessage(Handler.java:102)
at android.os.Looper.loop(Looper.java:179)
at android.app.ActivityThread.main(ActivityThread.java:5672)
at java.lang.reflect.Method.invoke(Native Method)
at com.android.internal.os.ZygoteInit$MethodAndArgsCaller.run(ZygoteInit.java:784)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:674)

系统信息

系统的信息有时候会带有一些关键的线索,对我们解决问题有非常大的帮助。

Logcat。这里包括应用、系统的运行日志。由于系统权限问题,获取到的 Logcat 可能只包含与当前 APP 相关的。其中系统的 event logcat 会记录 APP 运行的一些基本情况,记录在文件 /system/etc/event-log-tags 中。

代码语言:txt复制
//system logcat:
10-25 17:13:47.788 21430 21430 D dalvikvm: Trying to load lib ...

//event logcat:
10-25 17:13:47.788 21430 21430 I am_on_resume_called: 生命周期
10-25 17:13:47.788 21430 21430 I am_low_memory: 系统内存不足
10-25 17:13:47.788 21430 21430 I am_destroy_activity: 销毁 Activty
10-25 17:13:47.888 21430 21430 I am_anr: ANR 以及原因
10-25 17:13:47.888 21430 21430 I am_kill: APP 被杀以及原因

机型、系统、厂商、CPU、ABI、Linux 版本等。通过采集多达几十个维度,这对寻找共性问题会很有帮助。

内存信息

OOM、ANR、虚拟内存耗尽等,很多崩溃都跟内存有直接关系。如果把用户的手机内存分为“2GB 以下”和“2GB 以上”两个区,就会发现“2GB 以下”用户的崩溃率是“2GB 以上”用户的几倍。

系统剩余内存。关于系统内存状态,可以直接读取文件 /proc/meminfo。当系统可用内存很小(低于 MemTotal 的 10%)时,OOM、大量 GC、系统频繁自杀拉起等问题都非常容易出现。

应用使用内存。包括 Java 内存、RSS(Resident Set Size)、PSS(Proportional Set Size),我们可以得出应用本身内存的占用大小和分布。PSS 和 RSS 通过 /proc/self/smap 计算,可以进一步得到例如 apk、dex、so 等更加详细的分类统计。

虚拟内存。虚拟内存可以通过 /proc/self/status 得到,通过 /proc/self/maps 文件可以得到具体的分布情况。有时候我们一般不太重视虚拟内存,但是很多类似 OOM、tgkill 等问题都是虚拟内存不足导致的。

代码语言:txt复制
Name: com.xmamiga.name // 进程名
FDSize: 800 // 当前进程申请的文件句柄个数
VmPeak: 3004628 kB // 当前进程的虚拟内存峰值大小
VmSize: 2997032 kB // 当前进程的虚拟内存大小
Threads: 600 // 当前进程包含的线程个数

一般来说,对于 32 位进程,如果是 32 位的 CPU,虚拟内存达到 3GB 就可能会引起内存申请失败的问题。如果是 64 位的 CPU,虚拟内存一般在 3~4GB 之间。当然如果我们支持 64 位进程,虚拟内存就不会成为问题。Google Play 要求 2019 年 8 月一定要支持 64 位,在国内虽然支持 64 位的设备已经在 90% 以上了,但是商店都不支持区分 CPU 架构类型发布,普及起来需要更长的时间。

资源信息

有的时候会发现应用堆内存和设备内存都非常充足,还是会出现内存分配失败的情况,这跟资源泄漏可能有比较大的关系。

文件句柄 fd。文件句柄的限制可以通过 /proc/self/limits 获得,一般单个进程允许打开的最大文件句柄个数为 1024。但是如果文件句柄超过 800 个就比较危险,需要将所有的 fd 以及对应的文件名输出到日志中,进一步排查是否出现了有文件或者线程的泄漏。

代码语言:txt复制
opened files count 812:
0 -> /dev/null
1 -> /dev/log/main4
2 -> /dev/binder
3 -> /data/data/com.xmamiga.sample/files/test.conf
...

线程数。当前线程数大小可以通过上面的 status 文件得到,一个线程可能就占 2MB 的虚拟内存,过多的线程会对虚拟内存和文件句柄带来压力。根据我的经验来说,如果线程数超过 400 个就比较危险。需要将所有的线程 id 以及对应的线程名输出到日志中,进一步排查是否出现了线程相关的问题。

代码语言:txt复制
threads count 412:
1820 com.xmamiga.crashsdk
1844 ReferenceQueueD
1869 FinalizerDaemon
...

JNI。使用 JNI 时,如果不注意很容易出现引用失效、引用爆表等一些崩溃。

应用信息

除了系统,其实我们的应用更懂自己,可以留下很多相关的信息。崩溃场景。崩溃发生在哪个 Activity 或 Fragment,发生在哪个业务中; 关键操作路径,不同于开发过程详细的打点日志,我们可以记录关键的用户操作路径,这对我们复现崩溃会有比较大的帮助。其他自定义信息。不同的应用关心的重点可能不太一样。

崩溃分析

有了这么多现场信息之后,就可以开始真正的“破案”之旅了。绝大部分的 “案件” 只要肯花功夫,最后都能真相大白。不要畏惧问题,经过耐心和细心地分析,总能敏锐地发现一些异常或关键点,并且还要敢于怀疑和验证。

第一步:确定重点

确认和分析重点,关键在于终过日志中找到重要的信息,对问题有一个大致判断。一般来说,我建议在确定重点这一步可以关注以下几点。

确认严重程度。解决崩溃也要看性价比,我们优先解决 Top 崩溃或者对业务有重大影响,例如主要功能的崩溃。不要花几天去解决了一个边角的崩溃,有可能下个版本就把功能删除了。

崩溃基本信息。确定崩溃的类型以及异常描述,对崩溃有大致的判断。

一般来说,大部分的简单崩溃经过这一步已经可以得到结论。

Java 崩溃。Java 崩溃类型比较明显,比如 NullPointerException 是空指针,OutOfMemoryError 是资源不足,这个时候需要去进一步查看日志中的 “内存信息”和“资源信息”。

Native 崩溃。需要观察 signal、code、fault addr 等内容,以及崩溃时 Java 的堆栈。关于各 signal 含义的介绍,你可以查看崩溃信号介绍。比较常见的是有 SIGSEGV 和 SIGABRT,前者一般是由于空指针、非法指针造成,后者主要因为 ANR 和调用 abort() 退出所导致。

ANR。先看看主线程的堆栈,是否是因为锁等待导致。接着看看 ANR 日志中 iowait、CPU、GC、system server 等信息,进一步确定是 I/O 问题,或是 CPU 竞争问题,还是由于大量 GC 导致卡死。

第二步:查找共性

如果使用了上面的方法还是不能有效定位问题,我们可以尝试查找这类崩溃有没有什么共性。找到了共性,也就可以进一步找到差异,离解决问题也就更进一步。

机型、系统、ROM、厂商、ABI,这些采集到的系统信息都可以作为维度聚合,共性问题例如是不是只出现在 x86 的手机,是不是只有三星这款机型,是不是只在 Android 8.0 的系统上。应用信息也可以作为维度来聚合,比如正在打开的链接、正在播放的视频、国家、地区等。

找到了共性,可以对你下一步复现问题有更明确的指引。

第三步:尝试复现

如果我们已经大概知道了崩溃的原因,为了进一步确认更多信息,就需要尝试复现崩溃。如果我们对崩溃完全没有头绪,也希望通过用户操作路径来尝试重现,然后再去分析崩溃原因。

“只要能本地复现,我就能解”,相信这是很多开发跟测试说过的话。有这样的底气主要是因为在稳定的复现路径上面,我们可以采用增加日志或使用 Debugger、GDB 等各种各样的手段或工具做进一步分析。

我们可能会遇到了各种各样的奇葩问题。比如某个厂商改了底层实现、新的 Android 系统实现有所更改,都需要去 Google、翻源码,有时候还需要去抠厂商的 ROM 或手动刷 ROM。很多疑难问题需要我们耐得住寂寞,反复猜测、反复发灰度、反复验证。–但这种问题还是要看问题的严重程序,不可捡了芝麻丢了西瓜。

系统崩溃

系统崩溃常常令我们感到非常无助,它可能是某个 Android 版本的 Bug,也可能是某个厂商修改 ROM 导致。这种情况下的崩溃堆栈可能完全没有我们自己的代码,很难直接定位问题。能做的有:

查找可能的原因。通过上面的共性归类,我们先看看是某个系统版本的问题,还是某个厂商特定 ROM 的问题。虽然崩溃日志可能没有我们自己的代码,但通过操作路径和日志,可以找到一些怀疑的点。

尝试规避。查看可疑的代码调用,是否使用了不恰当的 API,是否可以更换其他的实现方式规避。

Hook 解决。这里分为 Java Hook 和 Native Hook。它可能只出现在 Android 7.0 的系统中,参考 Android 8.0 的做法,直接 catch 住这个异常。

如果做到了上面说的这些,以上大部分的崩溃应该都能解决或者规避,大部分的系统崩溃也是如此。当然总有一些疑难问题需要依赖到用户的真实环境,这些需要具备类似动态跟踪和调试的能力。

崩溃攻防是一个长期的过程,我们尽可能地提前预防崩溃的发生,将它消灭在萌芽阶段。作为技术人员,我们不应该盲目追求崩溃率这一个数字,应该以用户体验为先,如果强行去掩盖一些问题往往更加适得其反。我们不应该随意使用 try catch 去隐藏真正的问题,要从源头入手,了解崩溃的本质原因,保证后面的运行流程。在解决崩溃的过程,也要做到由点到面,不能只针对这个崩溃去解决,而应该要考虑这一类崩溃怎么解决和预防。

内存优化

在内存管理上,JVM拥有垃圾内存回收的机制,自身会在虚拟机层面自动分配和释放内存,因此不需要像使用C/C 一样在代码中分配和释放某一块内存。Android系统的内存管理类似于JVM,通过new关键字来为对象分配内存,内存的释放由GC来回收。并且Android系统在内存管理上有一个Generational Heap Memory模型,当内存达到某一个阈值时,系统会根据不同的规则自动释放可以释放的内存。即便有了内存管理机制,但是,如果不合理地使用内存,也会造成一系列的性能问题,比如内存泄漏、内存抖动、短时间内分配大量的内存对象等等。

优化工具

Memory Profiler

Memory profiler是Android Studio自带的一个内存检测工具,通过实时图表的方式展示内存信息,具有可以识别内存泄露,内存抖动等现象,并可以将捕获到的内存信息进行堆转储、强制GC以及跟踪内存分配的能力。

Android Studio打开Profiler工具

观察Memory曲线,比较平缓即为内存分配正常,如果出现大的波动有可能发生了内存泄露。

GC:可手动触发GC

Dump:Dump出当前Java Heap信息

Record:记录一段时间内的内存信息

点击Dump后

可查看当前内存分配对象

Allocations:分配对象个数

Native Size:Native内存大小

Shallow Size:对象本身占用内存的大小,不包含其引用的对象

Retained Size: 对象的Retained Size = 对象本身的Shallow Size 对象能直接或间接访问到的对象的Shallow Size,也就是说 Retained Size 就是该对象被 Gc 之后所能回收内存的总和

点击Bitmap Preview可以进行预览图片,对查看图片占用内存情况比较有帮助

点击Record后

可以记录一段时间内内存分配情况,可查看各对象分配大小及调用栈、对象生成位置

Memory Analyzer(MAT)

比Memory Profiler更强大的Java Heap分析工具,可以准确查找内存泄露以及内存占用情况,还可以生成整体报告,用来分析问题等。

MAT一般用来线下结合Memory Profiler分析问题使用,Memory Profiler可以直观看出内存抖动,然后生成的hdprof文件,通过MAT深入分析及定位内存泄露问题。

LeakCannary

Leak Cannary是一个能自动监测内存泄露的线下监测工具。

内存管理

内存区域

Java内存划分为方法区、堆、程序计数器、本地方法栈、虚拟机栈五个区域;

线程维度分为线程共享区和线程隔离区,方法区和堆是线程共享的,程序计数器、本地方法栈、虚拟机栈是线程隔离的,如下图

方法区

  • 线程共享区域,用于存储类信息、静态变量、常量、即时编译器编译出来的代码数据
  • 无法满足内存分配需求时会发生OOM

  • 线程共享区域,是JAVA虚拟机管理的内存中最大的一块,在虚拟机启动时创建
  • 存放对象实例,几乎所有的对象实例都在堆上分配,GC管理的主要区域

虚拟机栈

  • 线程私有区域,每个java方法在执行的时候会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息。方法从执行开始到结束过程就是栈帧在虚拟机栈中入栈出栈过程
  • 局部变量表存放编译期可知的基本数据类型、对象引用、returnAddress类型。所需的内存空间会在编译期间完成分配,进入一个方法时在帧中局部变量表的空间是完全确定的,不需要运行时改变
  • 若线程申请的栈深度大于虚拟机允许的最大深度,会抛出SatckOverFlowError错误
  • 虚拟机动态扩展时,若无法申请到足够内存,会抛出OutOfMemoryError错误

本地方法栈

  • 为虚拟机中Native方法服务,对本地方法栈中使用的语言、数据结构、使用方式没有强制规定,虚拟机可自有实现
  • 占用的内存区大小是不固定的,可根据需要动态扩展

程序计数器

  • 一块较小的内存空间,线程私有,存储当前线程执行的字节码行号指示器
  • 字节码解释器通过改变这个计数器的值来选取下一条需要执行的字节码指令:分支、循环、跳转等
  • 每个线程都有一个独立的程序计数器
  • 唯一一个在java虚拟机中不会OOM的区域

对象存活判断

引用计数法

  • 给对象添加引用计数器,每当一个地方引用时,计数器加1,引用失效时计数器减1;当引用计数器为0时即为对象不可用
  • 实现简单,效率高,但是无法解决相互引用问题,主流虚拟机一般不使用此方法判断对象是否存活

可达性分析法

  • 从一些称为”GC Roots”的对象作为起点,向下搜索,搜索走过的路径称为引用链,当一个对象到GC Roots没有任何引用链时即为对象不可用,可被回收的
  • 可被称为GC Roots的对象:虚拟机栈中引用的对象、方法区中类静态属性引用的对象、方法区中常量引用的对象、本地方法栈中引用的对象

GC Root有以下几种:

  1. Class-由系统ClassLoader加载的对象
  2. Thread-活着的线程
  3. Stack Local-Java方法的local变量或参数
  4. JNI Local – JNI方法的local变量或参数
  5. JNI Global – 全局JNI引用
  6. Monitor Used – 用于同步的监控对象

垃圾回收算法

标记清除算法

标记清除算法有两个阶段,首先标记出需要回收的对象,在标记完成后统一回收所有标记的对象;

缺点:

  • 效率问题:标记和清除两个过程效率都不高
  • 空间问题:标记清除之后会导致很多不连续的内存碎片,会导致需要分配大对象时无法找到足够的连续空间而不得不触发GC的问题

复制算法

将可用内存按空间分为大小相同的两小块,每次只使用其中的一块,等这块内存使用完了将还存活的对象复制到另一块内存上,然后将这块内存区域对象整体清除掉。每次对整个半区进行内存回收,不会导致碎片问题,实现简单高效。

缺点:

  • 需要将内存缩小为原来的一半,空间代价太高

标记整理算法

标记整理算法标记过程和标记清除算法一样,但清除过程并不是对可回收对象直接清理,而是将所有存活对象像一端移动,然后集中清理到端边界以外的内存。

分代收集算法

当代虚拟机垃圾回收算法都采用分代收集算法来收集,根据对象存活周期不同将内存划分为新生代和老年代,再根据每个年代的特点采用最合适的算法。

  • 新生代存活对象较少,每次垃圾回收都有大量对象死去,一般采用复制算法,只需要付出复制少量存活对象的成本就可以实现垃圾回收;
  • 老年代存活对象较多,没有额外空间进行分配担保,就必须采用标记清除算法和标记整理算法进行回收;

内存抖动

内存频繁分配和回收导致内存不稳定

  • 频繁GC,内存曲线呈现锯齿状,会导致卡顿
  • 频繁的创建对象会导致内存不足及碎片
  • 不连续的内存碎片无法被释放,导致OOM

模拟内存抖动

执行此段代码

代码语言:txt复制
private static Handler mShakeHandler = new Handler() {
    @Override public void handleMessage(Message msg) {
        super.handleMessage(msg);
        // 频繁创建对象,模拟内存抖动
        for(int index = 0;index <= 100;index   ) {
            String strArray[] = new String[100000];
        }
        mShakeHandler.sendEmptyMessageDelayed(0,30);
    }
};

分析并定位

利用Memory Profiler工具查看内存信息

发现内存曲线由原来的平稳曲线变成锯齿状

点击record记录内存信息,查找发生内存抖动位置,发现String对象ShallowSize非常异常,可直接通过Jump to Source定位到代码位置

内存泄露

定义:内存中存在已经没有用确无法回收的对象

现象:会导致内存抖动,可用内存减少,进而导致GC频繁、卡顿、OOM

模拟内存泄露

模拟内存泄露代码,反复进入退出该Activity

代码语言:txt复制
/**
 * 模拟内存泄露的Activity
 */
public class MemoryLeakActivity extends AppCompatActivity implements CallBack{
    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_memoryleak);
        ImageView imageView = findViewById(R.id.iv_memoryleak);
        Bitmap bitmap = BitmapFactory.decodeResource(getResources(), R.mipmap.splash);
        imageView.setImageBitmap(bitmap);
        
        // 添加静态类引用
        CallBackManager.addCallBack(this);
    }
    @Override
    protected void onDestroy() {
        super.onDestroy();
//        CallBackManager.removeCallBack(this);
    }
    @Override
    public void dpOperate() {
        // do sth
    }

分析并定位

通过Memory Profiler工具查看内存曲线,发现内存在不断的上升

如果想分析定位具体发生内存泄露位置需要借助MAT工具

首先生成hprof文件

点击dump将当前内存信息转成hprof文件,需要对生成的文件转换成MAT可读取文件

执行一下转换命令(Android/sdk/platorm-tools路径下)

代码语言:txt复制
hprof-conv 刚刚生成的hprof文件 memory-mat.hprof

使用mat打开刚刚转换的hprof文件

点击Historygram,搜索MemoryLeakActivity

可以看到有8个MemoryLeakActivity未释放

查看所有引用对象

查看到GC Roots的引用链

可以看到GC Roots是CallBackManager

解决问题,当Activity销毁时将当前引用移除

代码语言:txt复制
@Override
protected void onDestroy() {
    super.onDestroy();
    CallBackManager.removeCallBack(this);
}

MAT分析工具

Overview

当前内存整体信息

Histogram

列举对象所有的实例及实例所占大小,可按package排序

可以查看应用包名下Activity存在实例个数,可以查看是否存在内存泄露,这里发现内存中有8个Activity实例未释放

查看未被释放的Activity的引用链

Dominator_tree

当前所有实例的支配树,和Histogram区别时Histogram是类维度,dominator_tree是实例维度,可以查看所有实例的所占百分比和引用链

SQL

通过sql语句查询相关类信息

Thread_overview

查看当前所有线程信息

Top Consumers

通过图形方式展示占用内存较高的对象,对降低内存栈优化可用内存比较有帮助

Leak Suspects

内存泄露分析页面

直接定位到内存泄露位置

通过ARTHook检测不合理图片

获取Bitmap占用内存

  • 通过getByteCount方法,但是需要在运行时获取
  • width height 一个像素所占内存 * 图片所在资源目录压缩比

检测大图

当图片控件load图片大小超过控件自身大小时会造成内存浪费,所以检测出不合理图片对内存优化是很重要的。

ARTHook方式检测不合理图片

通过ARTHook方法可以优雅的获取不合理图片,侵入性低,但是因为兼容性问题一般在线下使用。

引入epic开源库

代码语言:txt复制
implementation 'me.weishu:epic:0.3.6'

实现Hook方法

代码语言:txt复制
public class CheckBitmapHook extends XC_MethodHook {
    @Override protected void afterHookedMethod(MethodHookParam param) throws Throwable {
        super.afterHookedMethod(param);
        ImageView imageView = (ImageView)param.thisObject;
        checkBitmap(imageView,imageView.getDrawable());
    }
    private static void checkBitmap(Object o,Drawable drawable) {
        if(drawable instanceof BitmapDrawable && o instanceof View) {
            final Bitmap bitmap = ((BitmapDrawable) drawable).getBitmap();
            if(bitmap != null) {
                final View view = (View)o;
                int width = view.getWidth();
                int height = view.getHeight();
                if(width > 0 && height > 0) {
                    if(bitmap.getWidth() > (width <<1) && bitmap.getHeight() > (height << 1)) {
                        warn(bitmap.getWidth(),bitmap.getHeight(),width,height,
                                new RuntimeException("Bitmap size is too large"));
                    }
                } else {
                    final Throwable stacktrace = new RuntimeException();
                    view.getViewTreeObserver().addOnPreDrawListener(
                            new ViewTreeObserver.OnPreDrawListener() {
                                @Override public boolean onPreDraw() {
                                    int w = view.getWidth();
                                    int h = view.getHeight();
                                    if(w > 0 && h > 0) {
                                        if (bitmap.getWidth() >= (w << 1)
                                                && bitmap.getHeight() >= (h << 1)) {
                                            warn(bitmap.getWidth(), bitmap.getHeight(), w, h, stacktrace);
                                        }
                                        view.getViewTreeObserver().removeOnPreDrawListener(this);
                                    }
                                    return true;
                                }
                            });
                }
            }
        }
    }
    private static void warn(int bitmapWidth, int bitmapHeight, int viewWidth, int viewHeight, Throwable t) {
        String warnInfo = new StringBuilder("Bitmap size too large: ")
                .append("n real size: (").append(bitmapWidth).append(',').append(bitmapHeight).append(')')
                .append("n desired size: (").append(viewWidth).append(',').append(viewHeight).append(')')
                .append("n call stack trace: n").append(Log.getStackTraceString(t)).append('n')
                .toString();
        LogUtils.i(warnInfo);Application初始化时注入Hook

DexposedBridge.hookAllConstructors(ImageView.class, new XC_MethodHook() {

代码语言:txt复制
@Override protected void afterHookedMethod(MethodHookParam param) throws Throwable {
代码语言:txt复制
    super.afterHookedMethod(param);
代码语言:txt复制
    DexposedBridge.findAndHookMethod(ImageView.class,"setImageBitmap", Bitmap.class,
代码语言:txt复制
            new CheckBitmapHook());
代码语言:txt复制
}

});

代码语言:txt复制

线上内存监控

常规方案

常规方案一

在特定场景中获取当前占用内存大小,如果当前内存大小超过系统最大内存80%,对当前内存进行一次Dump(Debug.dumpHprofData()),选择合适时间将hprof文件进行上传,然后通过MAT工具手动分析该文件。

缺点:

  • Dump文件比较大,和用户使用时间、对象树正相关
  • 文件较大导致上传失败率较高,分析困难

常规方案二

将LeakCannary带到线上,添加预设怀疑点,对怀疑点进行内存泄露监控,发现内存泄露回传到server。

缺点:

  • 通用性较低,需要预设怀疑点,对没有预设怀疑点的地方监控不到
  • LeakCanary分析比较耗时、耗内存,有可能会发生OOM

LeakCannary定制改造

  1. 将需要预设怀疑点改为自动寻找怀疑点,自动将前内存中所占内存较大的对象类中设置怀疑点。
  2. LeakCanary分析泄露链路比较慢,改造为只分析Retain size大的对象。
  3. 分析过程会OOM,是因为LeakCannary分析时会将分析对象全部加载到内存当中,我们可以记录下分析对象的个数和占用大小,对分析对象进行裁剪,不全部加载到内存当中。

完整方案

  1. 监控常规指标:待机内存、重点模块占用内存、OOM率
  2. 监控APP一个生命周期内和重点模块界面的生命周期内的GC次数、GC时间等
  3. 将定制的LeakCanary带到线上,自动化分析线上的内存泄露

0 人点赞