Android啟動(dòng)優(yōu)化 :學(xué)會(huì)這些讓應(yīng)用啟動(dòng)速度提高10倍臼寄!

作者:胡飛洋
鏈接:https://juejin.im/post/5f183026f265da230739b7db

一霸奕、概述

手機(jī)桌面點(diǎn)擊一個(gè)應(yīng)用,用戶希望應(yīng)用能及時(shí)響應(yīng)吉拳、快速加載质帅。啟動(dòng)時(shí)間過長(zhǎng)的應(yīng)用可能會(huì)令用戶失望。這種糟糕的體驗(yàn)可能會(huì)導(dǎo)致用戶在 Play 商店針對(duì)您的應(yīng)用給出很低的評(píng)分留攒,甚至完全棄用您的應(yīng)用煤惩。

本篇就來講解如何分析和優(yōu)化應(yīng)用的啟動(dòng)時(shí)間。首先介紹啟動(dòng)過程機(jī)制炼邀,然后討論如何檢測(cè)啟動(dòng)時(shí)間以及分析工具魄揉,最后給出通用啟動(dòng)優(yōu)化方案。

二拭宁、應(yīng)用啟動(dòng)流程介紹

根據(jù)官方文檔洛退,應(yīng)用有三種啟動(dòng)狀態(tài):冷啟動(dòng)溫啟動(dòng)杰标、熱啟動(dòng)兵怯。

  • 冷啟動(dòng) 冷啟動(dòng)是指應(yīng)用從頭開始啟動(dòng):系統(tǒng)進(jìn)程在冷啟動(dòng)后才創(chuàng)建應(yīng)用進(jìn)程。發(fā)生冷啟動(dòng)的情況包括應(yīng)用自設(shè)備啟動(dòng)后或系統(tǒng)終止應(yīng)用后首次啟動(dòng)腔剂。例如媒区,通過任務(wù)列表手動(dòng)殺掉應(yīng)用進(jìn)程后,又重新啟動(dòng)應(yīng)用桶蝎。

  • 熱啟動(dòng) 熱啟動(dòng)比冷啟動(dòng)簡(jiǎn)單得多驻仅,開銷也更低。在熱啟動(dòng)中登渣,系統(tǒng)的所有工作就是將您的 Activity 帶到前臺(tái)噪服。只要應(yīng)用的所有 Activity 仍駐留在內(nèi)存中,應(yīng)用就不必重復(fù)執(zhí)行進(jìn)程胜茧、應(yīng)用粘优、activity的創(chuàng)建仇味。例如,按home鍵到桌面雹顺,然后又點(diǎn)圖標(biāo)啟動(dòng)應(yīng)用丹墨。

  • 溫啟動(dòng) 溫啟動(dòng)包含了在冷啟動(dòng)期間發(fā)生的部分操作;同時(shí)嬉愧,它的開銷要比熱啟動(dòng)高贩挣。有許多潛在狀態(tài)可視為溫啟動(dòng)。例如:用戶按返回鍵退出應(yīng)用后又重新啟動(dòng)應(yīng)用没酣。這時(shí)進(jìn)程已在運(yùn)行王财,但應(yīng)用必須通過調(diào)用 onCreate() 從頭開始重新創(chuàng)建 Activity。

啟動(dòng)優(yōu)化是在 冷啟動(dòng) 的基礎(chǔ)上進(jìn)行優(yōu)化裕便。要優(yōu)化應(yīng)用以實(shí)現(xiàn)快速啟動(dòng)绒净,了解系統(tǒng)和應(yīng)用層面的情況以及它們?cè)诟鱾€(gè)狀態(tài)中的互動(dòng)方式很有幫助。

在冷啟動(dòng)開始時(shí)偿衰,系統(tǒng)有三個(gè)任務(wù)挂疆,它們是:

  • 加載并啟動(dòng)應(yīng)用。
  • 在啟動(dòng)后立即顯示應(yīng)用的空白啟動(dòng)窗口下翎。
  • 創(chuàng)建應(yīng)用進(jìn)程缤言。

系統(tǒng)一創(chuàng)建應(yīng)用進(jìn)程,應(yīng)用進(jìn)程就負(fù)責(zé)后續(xù)階段

  • 啟動(dòng)主線程视事。
  • 創(chuàng)建應(yīng)用對(duì)象墨闲。
  • 創(chuàng)建主 Activity。
  • 加載視圖郑口。
  • 執(zhí)行初始繪制。

一旦應(yīng)用進(jìn)程完成第一次繪制盾鳞,系統(tǒng)進(jìn)程就會(huì)換掉當(dāng)前顯示的后臺(tái)窗口(StartingWindow)犬性,替換為主 Activity。此時(shí)腾仅,用戶可以開始使用應(yīng)用乒裆。

下面是[官方文檔]中的啟動(dòng)過程流程圖,顯示系統(tǒng)進(jìn)程和應(yīng)用進(jìn)程之間如何交接工作推励。實(shí)際上對(duì)啟動(dòng)流程的簡(jiǎn)要概括鹤耍。

三、優(yōu)化核心思想

問題來了验辞,啟動(dòng)優(yōu)化是對(duì) 啟動(dòng)流程的那些步驟進(jìn)行優(yōu)化呢稿黄?

這是一個(gè)好問題。我們知道跌造,用戶關(guān)心的是:點(diǎn)擊桌面圖標(biāo)后 要盡快的顯示第一個(gè)頁面杆怕,并且能夠進(jìn)行交互族购。 根據(jù)啟動(dòng)流程的分析,顯示頁面能和用戶交互陵珍,這是主線程做的事情寝杖。那么就要求 我們不能再主線程做耗時(shí)的操作。啟動(dòng)中的系統(tǒng)任務(wù)我們無法干預(yù)互纯,能干預(yù)的就是在創(chuàng)建應(yīng)用和創(chuàng)建 Activity 的過程中可能會(huì)出現(xiàn)的性能問題瑟幕。這一過程具體就是:

  • Application的attachBaseContext
  • Application的onCreate
  • activity的onCreate
  • activity的onStart
  • activity的onResume

activity的onResume方法完成后才開始首幀的繪制。所以這些方法中的耗時(shí)操作我們是要極力避免的留潦。

并且只盹,通常情況下,一個(gè)應(yīng)用的主頁的數(shù)據(jù)是需要進(jìn)行網(wǎng)絡(luò)請(qǐng)求的愤兵,那么用戶啟動(dòng)應(yīng)用是希望快速進(jìn)入主頁以及看到主頁數(shù)據(jù)鹿霸,這也是我們計(jì)算啟動(dòng)結(jié)束時(shí)間的一個(gè)依據(jù)。

四秆乳、時(shí)間檢測(cè)

4.1 Displayed

在 Android 4.4(API 級(jí)別 19)及更高版本中懦鼠,logcat 包含一個(gè)輸出行,其中包含名為 “Displayed” 的值屹堰。此值代表從啟動(dòng)進(jìn)程到在屏幕上完成對(duì)應(yīng) Activity 的繪制所用的時(shí)間肛冶。經(jīng)過的時(shí)間包括以下事件序列:

  • 啟動(dòng)進(jìn)程。
  • 初始化對(duì)象扯键。
  • 創(chuàng)建并初始化 Activity睦袖。
  • 擴(kuò)充布局。
  • 首次繪制荣刑。

這是我的demo app 啟動(dòng)的日志打印馅笙,查看

2020-07-13 19:54:38.256 18137-18137/com.hfy.androidlearning I/hfy: onResume begin. 
2020-07-13 19:54:38.257 18137-18137/com.hfy.androidlearning I/hfy: onResume end. 
2020-07-13 19:54:38.269 1797-16782/? I/WindowManager: addWindow: Window{1402051 u0 com.hfy.androidlearning/com.hfy.demo01.MainActivity}
2020-07-13 19:54:38.391 1797-2017/? I/ActivityTaskManager: Displayed com.hfy.androidlearning/com.hfy.demo01.MainActivity: +2s251ms
復(fù)制代碼

可見“Displayed”的時(shí)間打印是在添加window之后,而添加window是在onResume方法之后厉亏。

4.2 adb shell

也可以使用adb命令運(yùn)行應(yīng)用來測(cè)量初步顯示所用時(shí)間:

adb shell am start -W [ApplicationId]/[根Activity的全路徑] 當(dāng)ApplicationId和package相同時(shí)董习,根Activity全路徑可以省略前面的packageName。

Displayed 指標(biāo)和前面一樣出現(xiàn)在 logcat 輸出中:

2020-07-14 14:53:05.294 1797-2017/? I/ActivityTaskManager: Displayed com.hfy.androidlearning/com.hfy.demo01.MainActivity: +2s98ms
復(fù)制代碼

您的終端窗口在adb命令執(zhí)行后還應(yīng)顯示以下內(nèi)容:

hufeiyangdeMacBook-Pro:~ hufeiyang$ adb shell am start -W com.hfy.androidlearning/com.hfy.demo01.MainActivity
Starting: Intent { act=android.intent.action.MAIN cat=[android.intent.category.LAUNCHER] cmp=com.hfy.androidlearning/com.hfy.demo01.MainActivity }
Status: ok
LaunchState: COLD
Activity: com.hfy.androidlearning/com.hfy.demo01.MainActivity
TotalTime: 2098
WaitTime: 2100
Complete
復(fù)制代碼

我們關(guān)注TotalTime即可爱只,即應(yīng)用的啟動(dòng)時(shí)間皿淋,包括 創(chuàng)建進(jìn)程 + Application初始化 + Activity初始化到界面顯示 的過程。

4.3 reportFullyDrawn()

可以使用 reportFullyDrawn() (API19及以上)方法測(cè)量從應(yīng)用啟動(dòng)到完全顯示所有資源和視圖層次結(jié)構(gòu)所用的時(shí)間恬试。什么意思呢窝趣?前面核心思想中提到,主頁數(shù)據(jù)請(qǐng)求后完全呈現(xiàn)界面的過程也是一個(gè)優(yōu)化點(diǎn)训柴,而前面的“Displayed”哑舒、:“TotalTime”的時(shí)間統(tǒng)計(jì)都是啟動(dòng)到首幀繪制,那么如何獲取 從 啟動(dòng) 到 獲取網(wǎng)絡(luò)請(qǐng)求后再次完成刷新 的時(shí)間呢畦粮?

要解決此問題散址,您可以手動(dòng)調(diào)用Activity的 reportFullyDrawn()方法乖阵,讓系統(tǒng)知道您的 Activity 已完成延遲加載。當(dāng)您使用此方法時(shí)预麸,logcat 顯示的值為從創(chuàng)建應(yīng)用對(duì)象到調(diào)用 reportFullyDrawn() 時(shí)所用的時(shí)間瞪浸。使用示例如下:

    @Override
    protected void onResume() {
        super.onResume();
        new Thread(new Runnable() {
            @Override
            public void run() {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                runOnUiThread(new Runnable() {
                    @Override
                    public void run() {
                        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
                            reportFullyDrawn();
                        }
                    }
                });

            }
        }).start();
    }
復(fù)制代碼

使用子線程睡1秒來模擬數(shù)據(jù)加載,然后調(diào)用reportFullyDrawn()吏祸,以下是 logcat 的輸出对蒲。

2020-07-14 15:26:00.979 1797-2017/? I/ActivityTaskManager: Displayed com.hfy.androidlearning/com.hfy.demo01.MainActivity: +2s133ms
2020-07-14 15:26:01.788 1797-2017/? I/ActivityTaskManager: Fully drawn com.hfy.androidlearning/com.hfy.demo01.MainActivity: +2s943ms
復(fù)制代碼

4.4 代碼打點(diǎn)

寫一個(gè)打點(diǎn)工具類,開始結(jié)束時(shí)分別記錄贡翘,把時(shí)間上報(bào)到服務(wù)器蹈矮。

此方法可帶到線上,但代碼有侵入性鸣驱。

開始記錄的位置放在 Application 的 attachBaseContext 方法中泛鸟,attachBaseContext 是我們應(yīng)用能接收到的最早的一個(gè)生命周期回調(diào)方法。

計(jì)算啟動(dòng)結(jié)束時(shí)間的兩種方式

  • 一種是在 onWindowFocusChanged 方法中計(jì)算啟動(dòng)耗時(shí)踊东。 onWindowFocusChanged 方法只是 Activity 的首幀時(shí)間北滥,是 Activity 首次進(jìn)行繪制的時(shí)間,首幀時(shí)間和界面完整展示出來還有一段時(shí)間差闸翅,不能真正代表界面已經(jīng)展現(xiàn)出來了再芋。

  • 按首幀時(shí)間計(jì)算啟動(dòng)耗時(shí)并不準(zhǔn)確,我們要的是用戶真正看到我們界面的時(shí)間坚冀。 正確的計(jì)算啟動(dòng)耗時(shí)的時(shí)機(jī)是要等真實(shí)的數(shù)據(jù)展示出來济赎,比如在列表第一項(xiàng)的展示時(shí)再計(jì)算啟動(dòng)耗時(shí)。 (在 Adapter 中記錄啟動(dòng)耗時(shí)要加一個(gè)布爾值變量進(jìn)行判斷记某,避免 onBindViewHolder 方法被多次調(diào)用導(dǎo)致不必要的計(jì)算司训。)

//第一個(gè)item 且沒有記錄過,就結(jié)束打點(diǎn)
  if (helper.getLayoutPosition() == 1 && !mHasRecorded) {
      mHasRecorded = true;
      helper.getView(R.id.xxx).getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() {
          @Override
          public boolean onPreDraw() {
              helper.getView(R.id.xxx).getViewTreeObserver().removeOnPreDrawListener(this);
              LogHelper.i("結(jié)束打點(diǎn)液南!");
              return true;
          }
      });
  }

復(fù)制代碼

4.5 AOP(Aspect Oriented Programming) 打點(diǎn)

面向切面編程豁遭,可以使用AspectJ。例如可以切Application的onCreate方法來計(jì)算其耗時(shí)贺拣。 特點(diǎn)是是對(duì)代碼無侵入性、可帶到線上捂蕴。

五譬涡、分析工具介紹

分析方法耗時(shí)的工具: Systrace 、 Traceview啥辨,兩個(gè)是相互補(bǔ)充的關(guān)系涡匀,我們要在不同的場(chǎng)景下使用不同的工具,這樣才能發(fā)揮工具的最大作用溉知。

5.1 Traceview

Traceview 能以圖形的形式展示代碼的執(zhí)行時(shí)間和調(diào)用棧信息陨瘩,而且 Traceview 提供的信息非常全面腕够,因?yàn)樗怂芯€程。

Traceview 的使用可以分為兩步:開始跟蹤舌劳、分析結(jié)果帚湘。我們來看看具體操作。

通過 Debug.startMethodTracing(tracepath) 開始跟蹤方法甚淡,記錄一段時(shí)間內(nèi)的 CPU 使用情況大诸。調(diào)用 Debug.stopMethodTracing() 停止跟蹤方法,然后系統(tǒng)就會(huì)為我們生成一個(gè).trace文件贯卦,我們可以通過 Traceview 查看這個(gè)文件記錄的內(nèi)容资柔。

文件生成的位置默認(rèn)在 Android/data/包名/files 下,下面來看一個(gè)例子撵割。

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        Log.i(TAG, "onCreate begin. ");
        super.onCreate(savedInstanceState);
        //默認(rèn)生成路徑:Android/data/包名/files/dmtrace.trace
        Debug.startMethodTracing();
        //也可以自定義路徑
       //Debug.startMethodTracing(getExternalFilesDir(null)+"test.trace");

        setContentView(R.layout.activity_main);
        Intent intent = getIntent();
        String name = intent.getStringExtra("name");
        Log.i(TAG, "onCreate: name = " + name);

        initConfig();
        initView();
        initData();
        ...

        Debug.stopMethodTracing();
    }
復(fù)制代碼

在MainActivity的onCreate前后方法中分別調(diào)用開始停止記錄方法贿堰,運(yùn)行打開應(yīng)用進(jìn)入首頁后,我們定位到 /sdcard/android/data/包名/files/ 目錄下查看文件管理器確實(shí)是有.trace文件:

然后雙擊打開:


以圖形來呈現(xiàn)方法跟蹤數(shù)據(jù)或函數(shù)跟蹤數(shù)據(jù)啡彬,其中調(diào)用的時(shí)間段和時(shí)間在橫軸上表示羹与,而其被調(diào)用方則在縱軸上顯示。 所以我們可以看到具體的方法及其耗時(shí)外遇。

詳細(xì)介紹參考官方文檔《使用 CPU Profiler 檢查 CPU 活動(dòng)》注簿。

可以看到在onCreate方法中,最耗時(shí)的是testHandler方法跳仿,它里面睡了一覺诡渴。

5.2 Systrace

Systrace 結(jié)合了 Android 內(nèi)核數(shù)據(jù),分析了線程活動(dòng)后會(huì)給我們生成一個(gè)非常精確 HTML 格式的報(bào)告菲语。

Systrace原理:在系統(tǒng)的一些關(guān)鍵鏈路(如SystemServcie妄辩、虛擬機(jī)、Binder驅(qū)動(dòng))插入一些信息(Label)山上。然后眼耀,通過Label的開始和結(jié)束來確定某個(gè)核心過程的執(zhí)行時(shí)間,并把這些Label信息收集起來得到系統(tǒng)關(guān)鍵路徑的運(yùn)行時(shí)間信息佩憾,最后得到整個(gè)系統(tǒng)的運(yùn)行性能信息哮伟。其中,Android Framework 里面一些重要的模塊都插入了label信息妄帘,用戶App中也可以添加自定義的Lable楞黄。

Systrace 提供的 Trace 工具類默認(rèn)只能 API 18 以上的項(xiàng)目中才能使用,如果我們的兼容版本低于 API 18抡驼,我們可以使用 TraceCompat鬼廓。 Systrace 的使用步驟和 Traceview 差不多,分為下面兩步致盟。

  • 調(diào)用跟蹤方法
  • 查看跟蹤結(jié)果

來看示例碎税,在onCreate前后分別使用TraceCompat.beginSection尤慰、TraceCompat.endSection方法:

    @Override
    protected void onCreate(Bundle savedInstanceState) {

        Log.i(TAG, "onCreate begin. ");

        super.onCreate(savedInstanceState);

        TraceCompat.beginSection("MainActivity onCreate");

        Debug.startMethodTracing();//dmtrace.trace
//        Debug.startMethodTracing(getExternalFilesDir(null)+"test.trace");

        setContentView(R.layout.activity_main);

        initConfig();
        initView();
        initData();

        Debug.stopMethodTracing();

        TraceCompat.endSection();
    }
復(fù)制代碼

運(yùn)行app后,手動(dòng)殺掉雷蹂。然后cd 到SDK 目錄下的 platform-tools/systrace 下伟端,使用命令:

python systrace.py -t 10 -o /Users/hufeiyang/trace.html -a com.hfy.androidlearning

其中:-t 10是指跟蹤10秒,-o 表示把文件輸出到指定目錄下萎河,-a 是指定應(yīng)用包名荔泳。

輸入完這行命令后,可以看到開始跟蹤的提示虐杯。看到 “Starting tracing ”后玛歌,手動(dòng)打開我們的應(yīng)用。

示例如下:

hufeiyangdeMacBook-Pro:~ hufeiyang$ cd  /Users/hufeiyang/Library/Android/sdk/platform-tools/systrace

hufeiyangdeMacBook-Pro:systrace hufeiyang$ python systrace.py -t 10 -o /Users/hufeiyang/trace.html  -a com.hfy.androidlearning

Starting tracing (10 seconds)
Tracing completed. Collecting output...
Outputting Systrace results...
Tracing complete, writing results

Wrote trace HTML file: file:///Users/hufeiyang/trace.html
復(fù)制代碼

跟蹤10秒擎椰,然后就在指定目錄生成了html文件支子,我們打開看看:

這里我們同樣可以看到具體的耗時(shí),以及每一幀渲染耗費(fèi)的時(shí)間达舒。具體參考官方文檔《Systrace 概覽》

小結(jié) Traceview 的兩個(gè)特點(diǎn)

  • 可埋點(diǎn) Traceview 的好處之一是可以在代碼中埋點(diǎn)值朋,埋點(diǎn)后可以用 CPU Profiler 進(jìn)行分析。 因?yàn)槲覀儸F(xiàn)在優(yōu)化的是啟動(dòng)階段的代碼巩搏,如果我們打開 App 后直接通過 CPU Profiler 進(jìn)行記錄的話昨登,就要求你有單身三十年的手速,點(diǎn)擊開始記錄的時(shí)間要和應(yīng)用的啟動(dòng)時(shí)間完全一致贯底。 有了 Traceview丰辣,哪怕你是老年人手速也可以記錄啟動(dòng)過程涉及的調(diào)用棧信息。
  • 開銷大 Traceview 的運(yùn)行時(shí)開銷非常大禽捆,它會(huì)導(dǎo)致我們程序的運(yùn)行變慢笙什。 之所以會(huì)變慢,是因?yàn)樗鼤?huì)通過虛擬機(jī)的 Profiler 抓取我們當(dāng)前所有線程的所有調(diào)用堆棧胚想。 因?yàn)檫@個(gè)問題琐凭,Traceview 也可能會(huì)帶偏我們的優(yōu)化方向。 比如我們有一個(gè)方法浊服,這個(gè)方法在正常情況下的耗時(shí)不大统屈,但是加上了 Traceview 之后可能會(huì)發(fā)現(xiàn)它的耗時(shí)變成了原來的十倍甚至更多。

Systrace 的兩個(gè)特點(diǎn)

  • 開銷小 Systrace 開銷非常小牙躺,不像 Traceview鸿吆,因?yàn)樗粫?huì)在我們埋點(diǎn)區(qū)間進(jìn)行記錄。 而 Traceview 是會(huì)把所有的線程的堆棧調(diào)用情況都記錄下來述呐。
  • 直觀 在 Systrace 中我們可以很直觀地看到 CPU 利用率的情況。 當(dāng)我們發(fā)現(xiàn) CPU 利用率低的時(shí)候蕉毯,我們可以考慮讓更多代碼以異步的方式執(zhí)行乓搬,以提高 CPU 利用率思犁。

Traceview 與 Systrace 的兩個(gè)區(qū)別

  • 查看工具 Traceview 分析結(jié)果要使用 Profiler 查看。 Systrace 分析結(jié)果是在瀏覽器查看 HTML 文件进肯。
  • 埋點(diǎn)工具類 Traceview 使用的是 Debug.startMethodTracing()激蹲。 Systrace 用的是 Trace.beginSection() 和 TraceCompat.beginSection()。

六江掩、啟動(dòng)優(yōu)化方案

優(yōu)化方案有兩個(gè)方向:

  • 視覺優(yōu)化学辱,啟動(dòng)耗時(shí)沒有變少,但是啟動(dòng)過程中給用戶更好的體驗(yàn)环形。
  • 速度優(yōu)化策泣,減少主線程的耗時(shí),真實(shí)做到快速啟動(dòng)抬吟。

6.1 視覺優(yōu)化

《Activity的啟動(dòng)》中提到萨咕,在Activity啟動(dòng)前會(huì)展示一個(gè)名字叫StartingWindow的window,這個(gè)window的背景是取要啟動(dòng)Activity的Theme中配置的WindowBackground火本。

因?yàn)閱?dòng)根activity前是需要?jiǎng)?chuàng)建進(jìn)程等一系列操作危队,需要一定時(shí)間,而展示StartingWindow的目的是 告訴用戶你點(diǎn)擊是有反應(yīng)的钙畔,只是在處理中茫陆,然后Activity啟動(dòng)后,Activity的window就替換掉這個(gè)StartingWindow了擎析。如果沒有這個(gè)StartingWindow簿盅,那么點(diǎn)擊后就會(huì)一段時(shí)間沒有反應(yīng),給用戶誤解叔锐。

而這挪鹏,就是應(yīng)用啟動(dòng)開始時(shí) 會(huì)展示白屏的原因了。

那么視覺優(yōu)化的方案 也就有了:替換第一個(gè)activity(通常是閃屏頁)的Theme愉烙,把白色背景換成Logot圖讨盒,然后再Activity的onCreate中換回來。 這樣啟動(dòng)時(shí)看到的就是你配置的logo圖了步责。

具體操作一下:

        <activity android:name=".MainActivity" android:theme="@style/SplashTheme">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
復(fù)制代碼

這里我的而第一個(gè)activity是MainActivity返顺,配置了theme是R.style.SplashTheme,來看下:

    <style name="SplashTheme" parent="AppNoActionBarAlphaAnimTheme">
        <item name="android:windowBackground">@drawable/splash_background</item>
    </style>
復(fù)制代碼

看到 android:windowBackground已經(jīng)配置成了自定義的drawable蔓肯,這個(gè)就是關(guān)鍵點(diǎn)了遂鹊,而默認(rèn)是windowBackground是白色≌岚看看自定義的drawable:

<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android"
    android:opacity="opaque">

<!--兩層-->
    <item android:drawable="@android:color/white"/>
    <item>
        <bitmap
            android:src="@drawable/dog"
            android:gravity="center"/>
    </item>

</layer-list>
復(fù)制代碼

drawable的根節(jié)點(diǎn)是<layer-list>秉扑,然后一層是白色底,一層就是我們的logo圖片了。

最后舟陆,在activity的onCreate中把Theme換回R.style.AppTheme即可(要在super.onCreate之前)误澳。

    protected void onCreate(Bundle savedInstanceState) {
        setTheme(R.style.AppTheme);
        super.onCreate(savedInstanceState);
    }
復(fù)制代碼

效果如下:

可以看到,確實(shí)視覺上體驗(yàn)比白屏好很多秦躯。

但實(shí)際上啟動(dòng)速度并沒有變快忆谓,下面就來看看可以真實(shí)提高啟動(dòng)速度的方案有哪些。

6.2 異步初始化

前面提到 提高啟動(dòng)速度踱承,核心思想就是 減少主線程的耗時(shí)操作倡缠。啟動(dòng)過程中 可控住耗時(shí)的主線程 主要是Application的onCreate方法、Activity的onCreate茎活、onStart昙沦、onResume方法。

通常我們會(huì)在Application的onCreate方法中進(jìn)行較多的初始化操作妙色,例如第三方庫(kù)初始化桅滋,那么這一過程是就需要重點(diǎn)關(guān)注。

減少主線程耗時(shí)的方法身辨,又可細(xì)分為異步初始化丐谋、延遲初始化,即把 主線程任務(wù) 放到子線程執(zhí)行 或 延后執(zhí)行煌珊。 下面就先來看看異步初始化是如何實(shí)現(xiàn)的号俐。

執(zhí)行異步請(qǐng)求,一般是使用線程池定庵,例如:

        Runnable initTask = new Runnable() {
            @Override
            public void run() {
                //init task
            }
        };

        ExecutorService fixedThreadPool = Executors.newFixedThreadPool(threadCount);
        fixedThreadPool.execute(initTask);
復(fù)制代碼

但是通過線程池處理初始化任務(wù)的方式存在三個(gè)問題:

  • 代碼不夠優(yōu)雅 假如我們有 100 個(gè)初始化任務(wù)吏饿,那像上面這樣的代碼就要寫 100 遍,提交 100 次任務(wù)蔬浙。
  • 無法限制在 onCreate 中完成 有的第三方庫(kù)的初始化任務(wù)需要在 Application 的 onCreate 方法中執(zhí)行完成猪落,雖然可以用 CountDownLatch 實(shí)現(xiàn)等待,但是還是有點(diǎn)繁瑣畴博。
  • 無法實(shí)現(xiàn)存在依賴關(guān)系 有的初始化任務(wù)之間存在依賴關(guān)系笨忌,比如極光推送需要設(shè)備 ID,而 initDeviceId() 這個(gè)方法也是一個(gè)初始化任務(wù)俱病。

那么解決方案是啥官疲?啟動(dòng)器

LauncherStarter亮隙,即啟動(dòng)器途凫,是針對(duì)這三個(gè)問題的解決方案,結(jié)合CountDownLatch對(duì)線程池的再封裝溢吻,充分利用CPU多核维费,自動(dòng)梳理任務(wù)順序

使用方式:

  • 引入依賴
  • 劃分任務(wù),確認(rèn)依賴和限制關(guān)系
  • 添加任務(wù)犀盟,執(zhí)行啟動(dòng)

首先依賴引入:

implementation 'com.github.zeshaoaaa:LaunchStarter:0.0.1'
復(fù)制代碼

然后把初始化任務(wù)劃分成一個(gè)個(gè)任務(wù)噪漾;厘清依賴關(guān)系,例如任務(wù)2要依賴任務(wù)1完成后才能開始且蓬;還有例如3任務(wù)需要在onCreate方法結(jié)束前完成;任務(wù)4要在主線程執(zhí)行题翰。

然后添加這些任務(wù)恶阴,開始任務(wù),設(shè)置等待豹障。

具體使用也比較簡(jiǎn)單冯事,代碼如下:

public class MyApplication extends Application {

    private static final String TAG = "MyApplication";

    @Override
    public void onCreate() {
        super.onCreate();

        TaskDispatcher.init(getBaseContext());
        TaskDispatcher taskDispatcher = TaskDispatcher.createInstance();

        // task2依賴task1;
        // task3未完成時(shí)taskDispatcher.await()處需要等待血公;
        // test4在主線程執(zhí)行
        //每個(gè)任務(wù)都耗時(shí)一秒
        Task1 task1 = new Task1();
        Task2 task2 = new Task2();
        Task3 task3 = new Task3();
        Task4 task4Main = new Task4();

        taskDispatcher.addTask(task1);
        taskDispatcher.addTask(task2);
        taskDispatcher.addTask(task3);
        taskDispatcher.addTask(task4Main);

        Log.i(TAG, "onCreate: taskDispatcher.start()");
        taskDispatcher.start();//開始

        taskDispatcher.await();//等task3完成后才會(huì)往下走
        Log.i(TAG, "onCreate: end.");
    }

    private static class Task1 extends Task {
        @Override
        public void run() {
            Log.i(TAG, Thread.currentThread().getName()+" run start: task1");
            doTask();
            Log.i(TAG, Thread.currentThread().getName()+" run end: task1");
        }
    }

    private static class Task2 extends Task {
        @Override
        public void run() {
            Log.i(TAG, Thread.currentThread().getName()+" run start: task2");
            doTask();
            Log.i(TAG, Thread.currentThread().getName()+" run end: task2");
        }

        @Override
        public List<Class<? extends Task>> dependsOn() {
            //依賴task1,等task1執(zhí)行完再執(zhí)行
            ArrayList<Class<? extends Task>> classes = new ArrayList<>();
            classes.add(Task1.class);
            return classes;
        }
    }

    private static class Task3 extends Task {
        @Override
        public void run() {
            Log.i(TAG, Thread.currentThread().getName()+" run start: task3");
            doTask();
            Log.i(TAG, Thread.currentThread().getName()+" run end: task3");
        }

        @Override
        public boolean needWait() {
            //task3未完成時(shí)昵仅,在taskDispatcher.await()處需要等待。這里就是保證在onCreate結(jié)束前完成累魔。
            return true;
        }
    }

    private static class Task4 extends MainTask {
    //繼承自MainTask摔笤,即保證在主線程執(zhí)行
        @Override
        public void run() {
            Log.i(TAG, Thread.currentThread().getName()+" run start: task4");
            doTask();
            Log.i(TAG, Thread.currentThread().getName()+" run end: task4");
        }
    }

    private static void doTask() {
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}
復(fù)制代碼

有4個(gè)初始化任務(wù),都耗時(shí)1秒垦写,若都在主線程執(zhí)行吕世,那么會(huì)耗時(shí)4秒。這里使用啟動(dòng)器執(zhí)行梯投,并且保證了上面描述的任務(wù)要求限制命辖。執(zhí)行完成后日志如下:

2020-07-17 12:06:20.648 26324-26324/com.hfy.androidlearning I/MyApplication: onCreate: taskDispatcher.start()
2020-07-17 12:06:20.650 26324-26324/com.hfy.androidlearning I/MyApplication: main run start: task4
2020-07-17 12:06:20.651 26324-26427/com.hfy.androidlearning I/MyApplication: TaskDispatcherPool-1-Thread-1 run start: task1
2020-07-17 12:06:20.657 26324-26428/com.hfy.androidlearning I/MyApplication: TaskDispatcherPool-1-Thread-2 run start: task3

2020-07-17 12:06:21.689 26324-26324/com.hfy.androidlearning I/MyApplication: main run end: task4
2020-07-17 12:06:21.689 26324-26427/com.hfy.androidlearning I/MyApplication: TaskDispatcherPool-1-Thread-1 run end: task1
2020-07-17 12:06:21.690 26324-26429/com.hfy.androidlearning I/MyApplication: TaskDispatcherPool-1-Thread-3 run start: task2
2020-07-17 12:06:21.697 26324-26428/com.hfy.androidlearning I/MyApplication: TaskDispatcherPool-1-Thread-2 run end: task3
2020-07-17 12:06:21.697 26324-26324/com.hfy.androidlearning I/MyApplication: onCreate: end.

2020-07-17 12:06:22.729 26324-26429/com.hfy.androidlearning I/MyApplication: TaskDispatcherPool-1-Thread-3 run end: task2
復(fù)制代碼

可見主線程耗時(shí)只有1秒。 另外分蓖,要注意的是尔艇,task3、task4一定是在onCreate內(nèi)完成了么鹤,task1终娃、task2都可能是在onCreate結(jié)束后一段時(shí)間才完成,所以在Activity中就不能使用task1午磁、task2相關(guān)的庫(kù)了尝抖。那么 在劃分任務(wù),確認(rèn)依賴和限制關(guān)系時(shí)就要注意了迅皇。

異步初始化就說這么多昧辽,原理部分可直接閱讀源碼,很容易理解登颓。接著看延遲初始化搅荞。

6.3 延遲初始化

在 Application 和 Activity 中可能存在優(yōu)先級(jí)不高的初始化任務(wù),可以考慮把這些任務(wù)進(jìn)行 延遲初始化。延遲初始化并不是減少了主線程耗時(shí)咕痛,而是讓耗時(shí)操作讓位痢甘、讓資源給UI繪制,將耗時(shí)的操作延遲到UI加載完畢后茉贡。

那么問題來了塞栅,如何延遲呢?

  • 使用new Handler().postDelay()方法腔丧、或者view.postDelay()——但是延遲時(shí)間不好把握放椰,不知道啥時(shí)候UI加載完畢。
  • 使用View.getViewTreeObserver().addOnPreDrawListener()監(jiān)聽——可以保證view繪制完成愉粤,但是此時(shí)發(fā)生交互呢砾医,例如用戶在滑動(dòng)列表,那么就會(huì)造成卡頓了衣厘。

那么解決方案是啥如蚜?延遲啟動(dòng)器

延遲啟動(dòng)器影暴,利用IdleHandler特性错邦,在CPU空閑時(shí)執(zhí)行,對(duì)延遲任務(wù)進(jìn)行分批初始化坤检, 這樣 執(zhí)行時(shí)機(jī)明確兴猩、也緩解界面UI卡頓。 延遲啟動(dòng)器就是上面的LauncherStarter中的一個(gè)類早歇。

public class DelayInitDispatcher {

    private Queue<Task> mDelayTasks = new LinkedList<>();

    private MessageQueue.IdleHandler mIdleHandler = new MessageQueue.IdleHandler() {
        @Override
        public boolean queueIdle() {
            if(mDelayTasks.size()>0){
                Task task = mDelayTasks.poll();
                new DispatchRunnable(task).run();
            }
            return !mDelayTasks.isEmpty();
        }
    };

    public DelayInitDispatcher addTask(Task task){
        mDelayTasks.add(task);
        return this;
    }

    public void start(){
        Looper.myQueue().addIdleHandler(mIdleHandler);
    }

}
復(fù)制代碼

使用也很簡(jiǎn)單倾芝,例如在閃屏頁中添加任務(wù)開始即可:

//SpalshActivity

DelayInitDispatcher delayInitDispatcher = new DelayInitDispatcher();

protected void onCreate(Bundle savedInstanceState) {
        delayInitDispatcher.addTask(new Task() {
            @Override
            public void run() {
                Log.i(TAG, "run: delay task begin");
                try {
                    Thread.sleep(3000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                Log.i(TAG, "run: delay task end");
            }
        });
        delayInitDispatcher.start();
}
復(fù)制代碼

經(jīng)測(cè)試,確實(shí)是在是在布局展示后開始任務(wù)箭跳。但是如果耗時(shí)較長(zhǎng)(例子中是3秒)晨另,過程中滑動(dòng)屏幕,是不能及時(shí)響應(yīng)的谱姓,會(huì)感覺到明顯的卡頓借尿。

所以,能異步的task優(yōu)先使用異步啟動(dòng)器在Application的onCreate方法中加載屉来,對(duì)于不能異步且耗時(shí)較少的task路翻,我們可以利用延遲啟動(dòng)器進(jìn)行加載。如果任務(wù)可以到用時(shí)再加載茄靠,可以使用懶加載的方式茂契。

IdleHandler原理分析:

//MessageQueue.java
    Message next() {
        // Return here if the message loop has already quit and been disposed.
        // This can happen if the application tries to restart a looper after quit
        // which is not supported.
        final long ptr = mPtr;
        if (ptr == 0) {
            return null;
        }

        int pendingIdleHandlerCount = -1; // -1 only during first iteration
        int nextPollTimeoutMillis = 0;
        for (;;) {
            if (nextPollTimeoutMillis != 0) {
                Binder.flushPendingCommands();
            }

            nativePollOnce(ptr, nextPollTimeoutMillis);

            synchronized (this) {
                // Try to retrieve the next message.  Return if found.
                final long now = SystemClock.uptimeMillis();
                Message prevMsg = null;
                Message msg = mMessages;
                if (msg != null && msg.target == null) {
                    // Stalled by a barrier.  Find the next asynchronous message in the queue.
                    do {
                        prevMsg = msg;
                        msg = msg.next;
                    } while (msg != null && !msg.isAsynchronous());
                }
                if (msg != null) {
                    if (now < msg.when) {
                        // Next message is not ready.  Set a timeout to wake up when it is ready.
                        nextPollTimeoutMillis = (int) Math.min(msg.when - now, Integer.MAX_VALUE);
                    } else {
                        // Got a message.
                        mBlocked = false;
                        if (prevMsg != null) {
                            prevMsg.next = msg.next;
                        } else {
                            mMessages = msg.next;
                        }
                        msg.next = null;
                        if (DEBUG) Log.v(TAG, "Returning message: " + msg);
                        msg.markInUse();
                        return msg;
                    }
                } else {
                    // No more messages.
                    nextPollTimeoutMillis = -1;
                }

                // Process the quit message now that all pending messages have been handled.
                if (mQuitting) {
                    dispose();
                    return null;
                }

                // If first time idle, then get the number of idlers to run.
                // Idle handles only run if the queue is empty or if the first message
                // in the queue (possibly a barrier) is due to be handled in the future.
                if (pendingIdleHandlerCount < 0
                        && (mMessages == null || now < mMessages.when)) {
                    pendingIdleHandlerCount = mIdleHandlers.size();
                }
                if (pendingIdleHandlerCount <= 0) {
                    // No idle handlers to run.  Loop and wait some more.
                    mBlocked = true;
                    continue;
                }

                if (mPendingIdleHandlers == null) {
                    mPendingIdleHandlers = new IdleHandler[Math.max(pendingIdleHandlerCount, 4)];
                }
                mPendingIdleHandlers = mIdleHandlers.toArray(mPendingIdleHandlers);
            }

            // Run the idle handlers.
            // We only ever reach this code block during the first iteration.
            for (int i = 0; i < pendingIdleHandlerCount; i++) {
                final IdleHandler idler = mPendingIdleHandlers[i];
                mPendingIdleHandlers[i] = null; // release the reference to the handler

                boolean keep = false;
                try {
                    keep = idler.queueIdle();
                } catch (Throwable t) {
                    Log.wtf(TAG, "IdleHandler threw exception", t);
                }

                if (!keep) {
                    synchronized (this) {
                        mIdleHandlers.remove(idler);
                    }
                }
            }

            // Reset the idle handler count to 0 so we do not run them again.
            pendingIdleHandlerCount = 0;

            // While calling an idle handler, a new message could have been delivered
            // so go back and look again for a pending message without waiting.
            nextPollTimeoutMillis = 0;
        }
    }
復(fù)制代碼

從消息隊(duì)列取消息時(shí),如果沒有取到消息慨绳,就執(zhí)行 空閑IdleHandler掉冶,執(zhí)行完就remove真竖。

6.4 Multidex預(yù)加載優(yōu)化

安裝或者升級(jí)后 首次 MultiDex 花費(fèi)的時(shí)間過于漫長(zhǎng)耻瑟,我們需要進(jìn)行Multidex的預(yù)加載優(yōu)化咧最。

5.0以上默認(rèn)使用ART散劫,在安裝時(shí)已將Class.dex轉(zhuǎn)換為oat文件了骇窍,無需優(yōu)化,所以應(yīng)判斷只有在主進(jìn)程及SDK 5.0以下才進(jìn)行Multidex的預(yù)加載

抖音BoostMultiDex優(yōu)化實(shí)踐:

抖音BoostMultiDex優(yōu)化實(shí)踐:Android低版本上APP首次啟動(dòng)時(shí)間減少80%(一)

Github地址:BoostMultiDex

快速接入:

  • build.gradle的dependencies中添加依賴:
dependencies {
    // For specific version number, please refer to app demo
    implementation 'com.bytedance.boost_multidex:boost_multidex:1.0.1'
}
復(fù)制代碼
  • 與官方MultiDex類似楔绞,在Application.attachBaseContext的最前面進(jìn)行初始化即可:
public class YourApplication extends Application {

    @Override
    protected void attachBaseContext(Context base) {
        super.attachBaseContext(base);
        BoostMultiDex.install(base);
    }
復(fù)制代碼

今日頭條5.0以下能扒,BoostMultiDex迟郎、MultiDex啟動(dòng)速度對(duì)比

6.5 頁面數(shù)據(jù)預(yù)加載

閃屏頁癣蟋、首頁的數(shù)據(jù)預(yù)加載:閃屏廣告拐袜、首頁數(shù)據(jù) 加載后緩存到本地,下次進(jìn)入時(shí)直接讀取緩存梢薪。 首頁讀取緩存到內(nèi)存的操作還可以提前到閃屏頁。

6.6 頁面繪制優(yōu)化

閃屏頁與主頁的繪制優(yōu)化尝哆,這里涉及到繪制優(yōu)化相關(guān)知識(shí)了秉撇,例如減少布局層級(jí)等。

七秋泄、總結(jié)

我們先介紹了啟動(dòng)流程琐馆、優(yōu)化思想、耗時(shí)檢測(cè)恒序、分析工具瘦麸,然后給出了常用優(yōu)化方案:異步初始化、延遲初始化歧胁。涉及了很多新知識(shí)和工具滋饲,一些地方文章中沒有展開,可以參考給出的連接詳細(xì)學(xué)習(xí)喊巍。畢竟性能優(yōu)化是多樣技術(shù)知識(shí)的綜合使用屠缭,需要系統(tǒng)掌握對(duì)應(yīng)工作流程被、分析工具崭参、解決方案呵曹,才能對(duì)性能進(jìn)行深層次的優(yōu)化。

好了何暮,今天就到這里奄喂,歡迎留言討論~

點(diǎn)關(guān)注,更多Android開發(fā)技能~~

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末海洼,一起剝皮案震驚了整個(gè)濱河市跨新,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌贰军,老刑警劉巖玻蝌,帶你破解...
    沈念sama閱讀 206,968評(píng)論 6 482
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件蟹肘,死亡現(xiàn)場(chǎng)離奇詭異,居然都是意外死亡俯树,警方通過查閱死者的電腦和手機(jī)帘腹,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,601評(píng)論 2 382
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來许饿,“玉大人阳欲,你說我怎么就攤上這事÷剩” “怎么了球化?”我有些...
    開封第一講書人閱讀 153,220評(píng)論 0 344
  • 文/不壞的土叔 我叫張陵,是天一觀的道長(zhǎng)瓦糟。 經(jīng)常有香客問我筒愚,道長(zhǎng),這世上最難降的妖魔是什么菩浙? 我笑而不...
    開封第一講書人閱讀 55,416評(píng)論 1 279
  • 正文 為了忘掉前任巢掺,我火速辦了婚禮,結(jié)果婚禮上劲蜻,老公的妹妹穿的比我還像新娘陆淀。我一直安慰自己,他們只是感情好先嬉,可當(dāng)我...
    茶點(diǎn)故事閱讀 64,425評(píng)論 5 374
  • 文/花漫 我一把揭開白布轧苫。 她就那樣靜靜地躺著,像睡著了一般疫蔓。 火紅的嫁衣襯著肌膚如雪含懊。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 49,144評(píng)論 1 285
  • 那天衅胀,我揣著相機(jī)與錄音绢要,去河邊找鬼。 笑死拗小,一個(gè)胖子當(dāng)著我的面吹牛重罪,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播哀九,決...
    沈念sama閱讀 38,432評(píng)論 3 401
  • 文/蒼蘭香墨 我猛地睜開眼剿配,長(zhǎng)吁一口氣:“原來是場(chǎng)噩夢(mèng)啊……” “哼!你這毒婦竟也來了阅束?” 一聲冷哼從身側(cè)響起呼胚,我...
    開封第一講書人閱讀 37,088評(píng)論 0 261
  • 序言:老撾萬榮一對(duì)情侶失蹤,失蹤者是張志新(化名)和其女友劉穎息裸,沒想到半個(gè)月后蝇更,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體沪编,經(jīng)...
    沈念sama閱讀 43,586評(píng)論 1 300
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 36,028評(píng)論 2 325
  • 正文 我和宋清朗相戀三年年扩,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了蚁廓。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 38,137評(píng)論 1 334
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡厨幻,死狀恐怖相嵌,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情况脆,我是刑警寧澤饭宾,帶...
    沈念sama閱讀 33,783評(píng)論 4 324
  • 正文 年R本政府宣布,位于F島的核電站格了,受9級(jí)特大地震影響看铆,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜盛末,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,343評(píng)論 3 307
  • 文/蒙蒙 一性湿、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧满败,春花似錦、人聲如沸叹括。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,333評(píng)論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽汁雷。三九已至净嘀,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間侠讯,已是汗流浹背挖藏。 一陣腳步聲響...
    開封第一講書人閱讀 31,559評(píng)論 1 262
  • 我被黑心中介騙來泰國(guó)打工, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留厢漩,地道東北人膜眠。 一個(gè)月前我還...
    沈念sama閱讀 45,595評(píng)論 2 355
  • 正文 我出身青樓,卻偏偏與公主長(zhǎng)得像溜嗜,于是被迫代替她去往敵國(guó)和親宵膨。 傳聞我的和親對(duì)象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 42,901評(píng)論 2 345