Android性能優(yōu)化(五)--更快--穩(wěn)定--響應速度ANR

1 ANR簡介

  • ANR,是“Application Not Responding”的縮寫,即“應用程序無響應”。
  • 在Android中,ActivityManagerService(簡稱AMS)和WindowManagerService(簡稱WMS)會監(jiān)測應用程序的響應時間,如果應用程序主線程(即UI線程)在超時時間內(nèi)對輸入事件沒有處理完畢(或沒有處理),或者對特定操作沒有執(zhí)行完畢,就會出現(xiàn)ANR。
  • 對于輸入事件沒有處理完畢產(chǎn)生的ANR,Android會顯示一個對話框,提示用戶當前應用程序沒有響應,用戶可以選擇繼續(xù)等待或者關閉這個應用程序(也就是殺掉這個應用程序的進程)。

2 為什么會有ANR

ANR的產(chǎn)生需要同時滿足三個條件:

  • 主線程:只有應用程序進程的主線程響應超時才會產(chǎn)生ANR;
  • 超時時間:產(chǎn)生ANR的上下文不同,超時時間也會不同,但只要在這個時間上限內(nèi)沒有響應就會ANR;
  • 輸入事件/特定操作:輸入事件是指按鍵、觸屏等設備輸入事件,特定操作是指BroadcastReceiver和Service的生命周期中的各個函數(shù),產(chǎn)生ANR的上下文不同,導致ANR的原因也會不同;

3 三種情況會觸發(fā)ANR

3.1 主線程對輸入事件在5秒內(nèi)沒有處理完畢

  • Android的事件系統(tǒng)從2.3開始做了完全不同的實現(xiàn),原先2.2中是在Java層實現(xiàn)的,但在2.3中整體轉移到了C++層,本書基于2.3以后的版本進行說明。我們先簡單了解一下產(chǎn)生這種ANR的整個流程。
  • 當應用程序的Window處于Active狀態(tài)并且能夠接收輸入事件(例如按鍵事件、觸摸事件等)時,系統(tǒng)底層上報的事件就會被InputDispatcher分發(fā)給這個應用程序,應用程序的主線程通過InputChannel讀取輸入事件并交給界面視圖處理,界面視圖是一個樹狀結構,DecorView是視圖樹的根,事件從樹根開始一層一層向端點(例如一個Button)傳遞。
  • 我們通常會注冊一個監(jiān)聽器來接收并處理事件,或者創(chuàng)建自定義的視圖控件來處理事件。
  • InputDispatcher運行在系統(tǒng)進程(進程名為system_server)的一個單獨的線程中,應用程序的主線程在處理事件的過程中,InputDispatcher會不斷的檢測處理過程是否超時,一旦超時,會通過一系列的回調通知WMS的notifyANR函數(shù),最終會調用到AMS中mHandler對象里的SHOW_NOT_RESPONDING_MSG這個case,此時界面上就顯示系統(tǒng)提示對話框了,同時使用logcat命令查看log(日志信息)也可以看到關于ANR的信息。
  • Window:具體指的是PhoneWindow對象,表示一個能夠顯示的窗口,它能夠接收系統(tǒng)分發(fā)的各種輸入事件;
  • InputDispatcher:將系統(tǒng)上報的輸入事件分發(fā)給當前活動的窗口;
  • InputChannel:InputDispatcher和應用程序分別運行在兩個不同的進程中,InputDispatcher就是通過InputChannel將事件對象傳遞給應用進程的。
  • 注意:產(chǎn)生這種ANR的前提是要有輸入事件,如果用戶沒有觸發(fā)任何輸入事件,即便是主線程阻塞了,也不會產(chǎn)生ANR,因為InputDispatcher沒有分發(fā)事件給應用程序,當然也不會檢測處理超時和報告ANR了。

3.2 主線程在執(zhí)行BroadcastReceiver的onReceive函數(shù)時10秒內(nèi)沒有執(zhí)行完畢

  • BroadcastReceiver(簡稱BR)的onReceive函數(shù)運行在主線程中,當這個函數(shù)超過10秒鐘沒有返回就會觸發(fā)ANR。不過對這種情況的ANR系統(tǒng)不會顯示對話框提示,僅是輸出log而已。

3.3 主線程在執(zhí)行Service的各個生命周期函數(shù)時20秒內(nèi)沒有執(zhí)行完畢

  • Service的各個生命周期函數(shù)也運行在主線程中,當這些函數(shù)超過20秒鐘沒有返回就會觸發(fā)ANR。同樣對對這種情況的ANR系統(tǒng)不會顯示對話框提示,僅是輸出log而已。

3.4 小結

  • 1.三種ANR中只有第1種會顯示系統(tǒng)提示對話框,因為用戶正在做界面交互操作,如果長時間沒有任何響應,會讓用戶懷疑設備死機了,大多數(shù)人此時會開始亂按,甚至拔出電池重啟,給用戶的體驗肯定是非常糟糕的。
  • 2.三種ANR發(fā)生時都會在log中輸出錯誤信息,你會發(fā)現(xiàn)各個應用進程和系統(tǒng)進程的函數(shù)堆棧信息都輸出到了一個/data/anr/traces.txt的文件中,這個文件是分析ANR原因的關鍵文件,同時在日志中還會看到當時的CPU使用率,這也是重要信息,可以利用它們分析ANR問題。
  • 3.這三種ANR不是孤立的,有可能會相互影響。例如一個應用程序進程中同時有一個正在顯示的Activity和一個正在處理消息的BroadcastReceiver,它們都運行在這個進程的主線程中。如果BR的onReceive函數(shù)沒有返回,此時用戶點擊屏幕,而onReceive超過5秒仍然沒有返回,主線程無法處理用戶輸入事件,就會引起第1種ANR。如果繼續(xù)超過10秒沒有返回,又會引起第2種ANR。

4 traces.txt--ANR分析

4.1 獲取ANR產(chǎn)生的trace文件

ANR產(chǎn)生時, 系統(tǒng)會生成一個traces.txt的文件放在/data/anr/下. 可以通過adb命令將其導出到本地:

$adb pull data/anr/traces.txt .

4.2 分析traces.txt

4.2.1 普通阻塞導致的ANR

強行sleep thread產(chǎn)生的一個ANR
獲取到的tracs.txt文件一般如下:

----- pid 2976 at 2016-09-08 23:02:47 -----
Cmd line: com.anly.githubapp  // 最新的ANR發(fā)生的進程(包名)

...

DALVIK THREADS (41):
"main" prio=5 tid=1 Sleeping
  | group="main" sCount=1 dsCount=0 obj=0x73467fa8 self=0x7fbf66c95000
  | sysTid=2976 nice=0 cgrp=default sched=0/0 handle=0x7fbf6a8953e0
  | state=S schedstat=( 0 0 0 ) utm=60 stm=37 core=1 HZ=100
  | stack=0x7ffff4ffd000-0x7ffff4fff000 stackSize=8MB
  | held mutexes=
  at java.lang.Thread.sleep!(Native method)
  - sleeping on <0x35fc9e33> (a java.lang.Object)
  at java.lang.Thread.sleep(Thread.java:1031)
  - locked <0x35fc9e33> (a java.lang.Object)
  at java.lang.Thread.sleep(Thread.java:985) // 主線程中sleep過長時間, 阻塞導致無響應.
  at com.tencent.bugly.crashreport.crash.c.l(BUGLY:258)
  - locked <@addr=0x12dadc70> (a com.tencent.bugly.crashreport.crash.c)
  at com.tencent.bugly.crashreport.CrashReport.testANRCrash(BUGLY:166)  // 產(chǎn)生ANR的那個函數(shù)調用
  - locked <@addr=0x12d1e840> (a java.lang.Class<com.tencent.bugly.crashreport.CrashReport>)
  at com.anly.githubapp.common.wrapper.CrashHelper.testAnr(CrashHelper.java:23)
  at com.anly.githubapp.ui.module.main.MineFragment.onClick(MineFragment.java:80) // ANR的起點
  at com.anly.githubapp.ui.module.main.MineFragment_ViewBinding$2.doClick(MineFragment_ViewBinding.java:47)
  at butterknife.internal.DebouncingOnClickListener.onClick(DebouncingOnClickListener.java:22)
  at android.view.View.performClick(View.java:4780)
  at android.view.View$PerformClick.run(View.java:19866)
  at android.os.Handler.handleCallback(Handler.java:739)
  at android.os.Handler.dispatchMessage(Handler.java:95)
  at android.os.Looper.loop(Looper.java:135)
  at android.app.ActivityThread.main(ActivityThread.java:5254)
  at java.lang.reflect.Method.invoke!(Native method)
  at java.lang.reflect.Method.invoke(Method.java:372)
  at com.android.internal.os.ZygoteInit$MethodAndArgsCaller.run(ZygoteInit.java:903)
  at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:698)

如上trace信息中的添加的中文注釋已基本說明了trace文件該怎么分析:

  • 1.文件最上的即為最新產(chǎn)生的ANR的trace信息.
  • 2.前面兩行表明ANR發(fā)生的進程pid, 時間, 以及進程名字(包名).
  • 3.尋找我們的代碼點, 然后往前推, 看方法調用棧, 追溯到問題產(chǎn)生的根源.

4.2.2 CPU滿負荷

race信息可能會包含這樣的信息:

Process:com.anly.githubapp
...
CPU usage from 3330ms to 814ms ago:
6% 178/system_server: 3.5% user + 1.4% kernel / faults: 86 minor 20 major
4.6% 2976/com.anly.githubapp: 0.7% user + 3.7% kernel /faults: 52 minor 19 major
0.9% 252/com.android.systemui: 0.9% user + 0% kernel
...

100%TOTAL: 5.9% user + 4.1% kernel + 89% iowait
  • 最后一句表明了:
  • 1.當是CPU占用100%, 滿負荷了.
  • 2.其中絕大數(shù)是被iowait即I/O操作占用了.

4.2.3 內(nèi)存原因

  • 其實內(nèi)存原因有可能會導致ANR,
    例如如果由于內(nèi)存泄露, App可使用內(nèi)存所剩無幾, 我們點擊按鈕啟動一個大圖片作為背景的activity, 就可能會產(chǎn)生ANR, 這時trace信息可能是這樣的:
// 以下trace信息來自網(wǎng)絡, 用來做個示例
Cmdline: android.process.acore

DALVIK THREADS:
"main"prio=5 tid=3 VMWAIT
|group="main" sCount=1 dsCount=0 s=N obj=0x40026240self=0xbda8
| sysTid=1815 nice=0 sched=0/0 cgrp=unknownhandle=-1344001376
atdalvik.system.VMRuntime.trackExternalAllocation(NativeMethod)
atandroid.graphics.Bitmap.nativeCreate(Native Method)
atandroid.graphics.Bitmap.createBitmap(Bitmap.java:468)
atandroid.view.View.buildDrawingCache(View.java:6324)
atandroid.view.View.getDrawingCache(View.java:6178)

...

MEMINFO in pid 1360 [android.process.acore] **
native dalvik other total
size: 17036 23111 N/A 40147
allocated: 16484 20675 N/A 37159
free: 296 2436 N/A 2732

可以看到free的內(nèi)存已所剩無幾
當然這種情況可能更多的是會產(chǎn)生OOM的異常...

5 ANR的避免和檢測

5.1 切記不要在主線程中作耗時操作

針對三種不同的情況, 一般的處理情況如下

  • 1.主線程阻塞的
    開辟單獨的子線程來處理耗時阻塞事務.
  • 2.CPU滿負荷, I/O阻塞的
    I/O阻塞一般來說就是文件讀寫或數(shù)據(jù)庫操作執(zhí)行在主線程了, 也可以通過開辟子線程的方式異步執(zhí)行.
  • 3.內(nèi)存不夠用的
    增大VM內(nèi)存, 使用largeHeap屬性, 排查內(nèi)存泄露。

5.2 借助于一些工具來進行檢測,從而更有效的避免ANR的引入。

5.2.1 StrictMode

嚴格模式StrictMode是Android SDK提供的一個用來檢測代碼中是否存在違規(guī)操作的工具類,StrictMode主要檢測兩大類問題。

  • 線程策略 ThreadPolicy
    1.detectCustomSlowCalls:檢測自定義耗時操作
    2.detectDiskReads:檢測是否存在磁盤讀取操作
    3.detectDiskWrites:檢測是否存在磁盤寫入操作
    4.detectNetWork:檢測是否存在網(wǎng)絡操作
  • 虛擬機策略VmPolicy
    1.detectActivityLeaks:檢測是否存在Activity泄露
    2.detectLeakedClosableObjects:檢測是否存在未關閉的Closeable對象泄露
    3.detectLeakedSqlLiteObjects:檢測是否存在Sqlite對象泄露
    4.setClassInstanceLimit:檢測類實例個數(shù)是否超過限制
    Debug版本中使用
    ThreadPolicy可以用來檢測可能催在的主線程耗時操作,需要注意的是我們只能在Debug版本中使用它,發(fā)布到市場上的版本要關閉掉。StrictMode的使用很簡單,我們只需要在應用初始化的地方例如Application或者MainActivity類的onCreate方法中執(zhí)行如下代碼:
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_login);

        // 開啟線程模式
        StrictMode.setThreadPolicy(new StrictMode.ThreadPolicy.Builder()
                .detectAll()//調用detectAll方法表示啟動所有的檢測策略
                .penaltyLog()
                .penaltyDialog() ////打印logcat,當然也可以定位到dropbox,通過文件保存相應的log
                .build());
        // 開啟虛擬機模式
        StrictMode.setVmPolicy(new StrictMode.VmPolicy.Builder()
                .detectAll()//調用detectAll方法表示啟動所有的檢測策略
                .penaltyLog()//調用penaltyLog表示在Logcat中打印日志
                .build());
    }

也可以根據(jù)應用的具體要求只開啟某些策略

         StrictMode.setThreadPolicy(new StrictMode.ThreadPolicy.Builder()
                 .detectDiskReads()
                 .detectDiskWrites()
                 .detectNetwork()  
                 .penaltyLog()
                 .build());

         StrictMode.setVmPolicy(new StrictMode.VmPolicy.Builder()
                 .detectLeakedSqlLiteObjects()
                 .detectLeakedClosableObjects()
                 .detectActivityLeaks()
                 .penaltyLog()
                 .build());

5.2.2 BlockCanary監(jiān)控應用主線程的卡頓

  • BlockCanary是一個非侵入式式的性能監(jiān)控函數(shù)庫,它的用法和leakCanary類似,只不過后者監(jiān)控應用的內(nèi)存泄露,而BlockCanary主要用來監(jiān)控應用主線程的卡頓。
  • 它的基本原理是利用主線程的消息隊列處理機制,通過對比消息分發(fā)開始和結束的時間點來判斷是否超過設定的時間,如果是,則判斷為主線程卡頓。
  • 它的集成很簡單,首先在build.gradle中添加依賴一般選取以下其中一個 case 引入即可
dependencies {
    compile 'com.github.markzhai:blockcanary-android:1.5.0'

    // 僅在debug包啟用BlockCanary進行卡頓監(jiān)控和提示的話,可以這么用
    debugCompile 'com.github.markzhai:blockcanary-android:1.5.0'
    releaseCompile 'com.github.markzhai:blockcanary-no-op:1.5.0'
}

然后在Application類中進行配置和初始化即可

public class AnrDemoApplication extends Application {

    @Override
    public void onCreate() {
        super.onCreate();
        // 在主進程初始化調用哈
        BlockCanary.install(this, new AppBlockCanaryContext()).start();

    }
}

實現(xiàn)自己監(jiān)控的上下文

public class AppBlockCanaryContext extends BlockCanaryContext {
    // 實現(xiàn)各種上下文,包括應用標示符,用戶uid,網(wǎng)絡類型,卡慢判斷闕值,Log保存位置等

    /**
     * Implement in your project.
     *
     * @return Qualifier which can specify this installation, like version + flavor.
     */
    public String provideQualifier() {
        return "unknown";
    }


    /**
     * Implement in your project.
     *
     * @return user id
     */
    public String provideUid() {
        return "uid";
    }

    /**
     * Network type
     *網(wǎng)絡類型
     * @return {@link String} like 2G, 3G, 4G, wifi, etc.
     */
    public String provideNetworkType() {
        return "unknown";
    }

    /**
     * Config monitor duration, after this time BlockCanary will stop, use
     * with {@code BlockCanary}'s isMonitorDurationEnd
     * 配置監(jiān)視器持續(xù)時間,此時BlockCanary將停止,并使用{@Code BlockCanary}的isMonitorDurationEnd
     *
     * @return monitor last duration (in hour)
     */
    public int provideMonitorDuration() {
        return -1;
    }

    /**
     * Config block threshold (in millis), dispatch over this duration is regarded as a BLOCK. You may set it
     * from performance of device.
     * 配置塊閾值(以毫為單位),在此持續(xù)時間內(nèi)的調度被視為BLOCK。 您可以根據(jù)設備的性能進行設置。
     * @return threshold in mills
     */
    public int provideBlockThreshold() {
        return 1000;
    }

    /**
     * Thread stack dump interval, use when block happens, BlockCanary will dump on main thread
     * stack according to current sample cycle.
     * <p>
     * Because the implementation mechanism of Looper, real dump interval would be longer than
     * the period specified here (especially when cpu is busier).
     * </p>
     *
     * @return dump interval (in millis)
     */
    public int provideDumpInterval() {
        return provideBlockThreshold();
    }

    /**
     * Path to save log, like "/blockcanary/", will save to sdcard if can.
     *
     * @return path of log files
     * 日志文件的路徑
     */
    public String providePath() {
        return "/blockcanary/";
    }

    /**
     * If need notification to notice block.
     * 是否需要通知
     * @return true if need, else if not need.
     */
    public boolean displayNotification() {
        return true;
    }

    /**
     * Implement in your project, bundle files into a zip file.
     * 將文件捆綁到一個zip文件中。
     * @param src  files before compress
     * @param dest files compressed
     * @return true if compression is successful
     */
    public boolean zip(File[] src, File dest) {
        return false;
    }

    /**
     * Implement in your project, bundled log files.
     *
     * @param zippedFile zipped file
     */
    public void upload(File zippedFile) {
        throw new UnsupportedOperationException();
    }


    /**
     * Packages that developer concern, by default it uses process name,
     * put high priority one in pre-order.
     * 開發(fā)人員關注的軟件包,默認情況下它使用進程名稱,并在預訂中放置高優(yōu)先級的軟件包。
     * @return null if simply concern only package with process name.
     * 如果僅僅關注包含進程名稱的包,則返回null。
     */
    public List<String> concernPackages() {
        return null;
    }

    /**
     * Filter stack without any in concern package, used with @{code concernPackages}.
     * 無需關注包的過濾器堆棧,與@ {code concernPackages}一起使用。
     * @return true if filter, false it not.
     */
    public boolean filterNonConcernStack() {
        return false;
    }

    /**
     * Provide white list, entry in white list will not be shown in ui list.
     * 提供白名單,白名單中的條目不會顯示在用戶名單中。
     * @return return null if you don't need white-list filter.
     */
    public List<String> provideWhiteList() {
        LinkedList<String> whiteList = new LinkedList<>();
        whiteList.add("org.chromium");
        return whiteList;
    }

    /**
     * Whether to delete files whose stack is in white list, used with white-list.
     * 是否刪除堆棧位于白名單中的文件,與白名單一起使用。
     * @return true if delete, false it not.
     */
    public boolean deleteFilesInWhiteList() {
        return true;
    }

    /**
     * Block interceptor, developer may provide their own actions.
     * Block攔截器,開發(fā)者可以提供自己的操作。
     */
    public void onBlock(Context context, BlockInfo blockInfo) {

    }
}

6 使用子線程的方式

6.1 啟Thread方式

這個其實也是Java實現(xiàn)多線程的方式. 有兩種實現(xiàn)方法, 繼承Thread 或 實現(xiàn)Runnable接口:

繼承Thread

class PrimeThread extends Thread {
    long minPrime;
    PrimeThread(long minPrime) {
        this.minPrime = minPrime;
    }

    public void run() {
        // compute primes larger than minPrime
         . . .
    }
}

PrimeThread p = new PrimeThread(143);
p.start();

實現(xiàn)Runnable接口

class PrimeRun implements Runnable {
    long minPrime;
    PrimeRun(long minPrime) {
        this.minPrime = minPrime;
    }

    public void run() {
        // compute primes larger than minPrime
         . . .
    }
}

PrimeRun p = new PrimeRun(143);
new Thread(p).start();

6.2 使用AsyncTask

這個是Android特有的方式, AsyncTask顧名思義, 就是異步任務的意思.

private class DownloadFilesTask extends AsyncTask<URL, Integer, Long> {
    // Do the long-running work in here
    // 執(zhí)行在子線程
    protected Long doInBackground(URL... urls) {
        int count = urls.length;
        long totalSize = 0;
        for (int i = 0; i < count; i++) {
            totalSize += Downloader.downloadFile(urls[i]);
            publishProgress((int) ((i / (float) count) * 100));
            // Escape early if cancel() is called
            if (isCancelled()) break;
        }
        return totalSize;
    }

    // This is called each time you call publishProgress()
    // 執(zhí)行在主線程
    protected void onProgressUpdate(Integer... progress) {
        setProgressPercent(progress[0]);
    }

    // This is called when doInBackground() is finished
    // 執(zhí)行在主線程
    protected void onPostExecute(Long result) {
        showNotification("Downloaded " + result + " bytes");
    }
}

// 啟動方式
new DownloadFilesTask().execute(url1, url2, url3);

6.3 HandlerThread

Android中結合Handler和Thread的一種方式. 前面有云, 默認情況下Handler的handleMessage是執(zhí)行在主線程的, 但是如果我給這個Handler傳入了子線程的looper, handleMessage就會執(zhí)行在這個子線程中的. HandlerThread正是這樣的一個結合體:

// 啟動一個名為new_thread的子線程
HandlerThread thread = new HandlerThread("new_thread");
thread.start();

// 取new_thread賦值給ServiceHandler
private ServiceHandler mServiceHandler;
mServiceLooper = thread.getLooper();
mServiceHandler = new ServiceHandler(mServiceLooper);

private final class ServiceHandler extends Handler {
    public ServiceHandler(Looper looper) {
      super(looper);
    }
    
    @Override
    public void handleMessage(Message msg) {
      // 此時handleMessage是運行在new_thread這個子線程中了.
    }
}

6.4 IntentService

Service是運行在主線程的, 然而IntentService是運行在子線程的.
實際上IntentService就是實現(xiàn)了一個HandlerThread + ServiceHandler的模式.

6.5 Loader

Android 3.0引入的數(shù)據(jù)加載器, 可以在Activity/Fragment中使用. 支持異步加載數(shù)據(jù), 并可監(jiān)控數(shù)據(jù)源在數(shù)據(jù)發(fā)生變化時傳遞新結果. 常用的有CursorLoader, 用來加載數(shù)據(jù)庫數(shù)據(jù).

// 創(chuàng)建一個Loader
public Loader<Cursor> onCreateLoader(int id, Bundle args) {
    // This is called when a new Loader needs to be created.  This
    // sample only has one Loader, so we don't care about the ID.
    // First, pick the base URI to use depending on whether we are
    // currently filtering.
    Uri baseUri;
    if (mCurFilter != null) {
        baseUri = Uri.withAppendedPath(Contacts.CONTENT_FILTER_URI,
                  Uri.encode(mCurFilter));
    } else {
        baseUri = Contacts.CONTENT_URI;
    }

    // Now create and return a CursorLoader that will take care of
    // creating a Cursor for the data being displayed.
    String select = "((" + Contacts.DISPLAY_NAME + " NOTNULL) AND ("
            + Contacts.HAS_PHONE_NUMBER + "=1) AND ("
            + Contacts.DISPLAY_NAME + " != '' ))";
    return new CursorLoader(getActivity(), baseUri,
            CONTACTS_SUMMARY_PROJECTION, select, null,
            Contacts.DISPLAY_NAME + " COLLATE LOCALIZED ASC");
}

// 加載完成
public void onLoadFinished(Loader<Cursor> loader, Cursor data) {
    // Swap the new cursor in.  (The framework will take care of closing the
    // old cursor once we return.)
    mAdapter.swapCursor(data);
}

6.6 特別注意

使用Thread和HandlerThread時, 為了使效果更好, 建議設置Thread的優(yōu)先級偏低一點:

Process.setThreadPriority(THREAD_PRIORITY_BACKGROUND);

因為如果沒有做任何優(yōu)先級設置的話, 你創(chuàng)建的Thread默認和UI Thread是具有同樣的優(yōu)先級的, 你懂的. 同樣的優(yōu)先級的Thread, CPU調度上還是可能會阻塞掉你的UI Thread, 導致ANR的.

參考

http://www.itdecent.cn/p/c8c15608c68e

http://www.itdecent.cn/p/6d855e984b99

https://blog.csdn.net/sinat_36668731/article/details/74924432

最后編輯于
?著作權歸作者所有,轉載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容