歡迎光臨
每天分享高質量文章

Android 性能優化—— 啟動優化提升60%

本文出自凶殘的程式員的博客

鏈接:https://blog.csdn.net/qian520ao/article/details/81908505

目錄:

1、應用啟動速度

2、視覺優化

    2.1啟動主題優化

        預設情況

        透明主題優化

        設置閃屏圖片主題

3、代碼優化

    3.1冷啟動耗時統計

        adb 命令統計

        系統日誌統計

    3.2代碼優化

        Application 優化

        閃屏頁業務優化

        廣告頁優化

4、優化效果

5、啟動視窗

6、總結

1、應用啟動速度

一個應用App的啟動速度能夠影響用戶的首次體驗,啟動速度較慢(感官上)的應用可能導致用戶再次開啟App的意圖下降,或者卸載放棄該應用程式。

本文將從兩個方向優化應用的啟動速度 :

  • 視覺體驗優化

  • 代碼邏輯優化

2、視覺優化

谷歌開發文件:

https://developer.android.com/topic/performance/vitals/launch-time

應用程式啟動有三種狀態,每種狀態都會影響應用程式對用戶可見所需的時間:冷啟動,熱啟動和溫啟動。

在冷啟動時,應用程式從頭開始。在其他狀態下,系統需要將正在運行的應用程式從後臺運行到前臺。我們建議您始終根據冷啟動的假設進行優化。這樣做也可以改善熱啟動和溫啟動的性能。

在冷啟動開始時,系統有三個任務。這些任務是:

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) 來展示應用信息。


1、預設情況

如果我們對App沒有做處理(設置了預設主題),並且在 Application 初始化了其它第三方的服務(假設需要加載2000ms),那麼冷啟動過程就會如下圖 :系統預設會在啟動應用程式的時候 啟動空白視窗 ,直到 App 應用程式的入口 Activity 創建成功,視圖繪製完畢。( 大概是onWindowFocusChanged方法回呼的時候 )



2、透明主題優化


為瞭解決啟動視窗白屏問題,許多開發者使用透明主題來解決這個問題,但是治標不治本。

雖然解決了上面這個問題,但是仍然有些不足。


    <style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
        <item name="android:windowFullscreen">trueitem>

        <item name="android:windowIsTranslucent">trueitem>

    style>


(無白屏,不過從點擊到App仍然存在視覺延遲~)


3、設置閃屏圖片主題


為了更順滑無縫銜接我們的閃屏頁,可以在啟動 Activity 的 Theme中設置閃屏頁圖片,這樣啟動視窗的圖片就會是閃屏頁圖片,而不是白屏。



<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
    <item name="android:windowBackground">@mipmap/launchitem>
   //閃屏頁圖片
    <item name="android:windowFullscreen">trueitem>

    <item name=“android:windowContentOverlay”>@nullitem>
style>

這樣設置的話,就會在冷啟動的時候,展示閃屏頁的圖片,等App行程初始化加載入口 Activity (也是閃屏頁) 就可以無縫銜接


其實這種方式並沒有真正的加速應用行程的啟動速度,而只是通過用戶視覺效果帶來的優化體驗。

3、代碼優化

當然上面使用設置主題的方式優化用戶體驗效果治標不治本,關鍵還在於對代碼的優化。

首先我們可以統計一下應用冷啟動的時間。


冷啟動耗時統計


adb 命令統計

參考如何計算 App 的啟動時間 

http://www.androidperformance.com/2015/12/31/How-to-calculation-android-app-lunch-time/

adb命令 : adb shell am start -S -W 包名/啟動類的全限定名 , -S 表示重啟當前應用

 
更多adb命令

https://github.com/mzlogin/awesome-adb


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

1、ThisTime最後一個 Activity 的啟動耗時(例如從 LaunchActivity – >MainActivity「adb命令輸入的Activity」 , 只統計 MainActivity 的啟動耗時)

2、TotalTime:啟動一連串的 Activity 總耗時.(有幾個Activity 就統計幾個)

3、WaitTime:應用行程的創建過程 + TotalTime .


在第①個時間段內,AMS 創建 ActivityRecord 記錄塊和選擇合理的 Task、將當前Resume 的 Activity 進行 pause.

在第②個時間段內,啟動行程、呼叫無界面 Activity 的 onCreate() 等、 pause/finish 無界面的 Activity.

在第③個時間段內,呼叫有界面 Activity 的 onCreate、onResume.


//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 中完成繁重的初始化操作和複雜的邏輯就會影響到應用的啟動性能通常,有機會優化這些工作以實現性能改進,這些常見問題包括:

  • 複雜繁瑣的佈局初始化

  • 阻塞主執行緒 UI 繪製的操作,如 I/O 讀寫或者是網絡訪問.

  • Bitmap 大圖片或者 VectorDrawable加載

  • 其它占用主執行緒的操作

我們可以根據這些組件的輕重緩急之分,對初始化做一下分類 :

1、必要的組件一定要在主執行緒中立即初始化(入口 Activity 可能立即會用到)

2、組件一定要在主執行緒中初始化,但是可以延遲初始化。

3、組件可以在子執行緒中初始化。

放在子執行緒的組件初始化建議延遲初始化 ,這樣就可以瞭解是否會對專案造成影響!

所以對於上面的分析,我們可以在專案中 Application 的加載組件進行如下優化 :

將Bugly,x5內核初始化,SP的讀寫,友盟等組件放到子執行緒中初始化。(子執行緒初始化不能影響到組件的使用)


new Thread(new Runnable() {
    @Override
    public void run() {
        //設置執行緒的優先級,不與主執行緒搶資源
        Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND);
        //子執行緒初始化第三方組件
        Thread.sleep(5000);//建議延遲初始化,可以發現是否影響其它功能,或者是崩潰!
    }
}).start();

將需要在主執行緒中初始化但是可以不用立即完成的動作延遲加載(原本是想在入口 Activity 中進行此項操作,不過組件的初始化放在 Application 中統一管理為妙.)


handler.postDelayed(new Runnable() {
    @Override
    public void run() {
        //延遲初始化組件
    }
}, 3000);

閃屏頁業務優化


最後還剩下那些為數不多的組件在主執行緒初始化動作,例如埋點,點擊流,資料庫初始化等,不過這些消耗的時間可以在其它地方相抵。

需求背景 : 應用App通常會設置一個固定的閃屏頁展示時間,例如2000ms,所以我們可以根據用戶手機的運行速度,對展示時間做出調整,但是總時間仍然為 2000ms。

閃屏頁政展示總時間 = 組件初始化時間 + 剩餘展示時間。

也就是2000ms的總時間,組件初始化了800ms,那麼就再展示1200ms即可。

我們先瞭解一下 Application的啟動過程,圖片摘自 : 

如何統計Android App啟動時間 

https://www.jianshu.com/p/59a2ca7df681


雖然這個以下圖片的原始碼並不是最新原始碼(5.0原始碼),不過不影響整體流程。(7.0,8.0方法名會有所改變)。

冷啟動的過程中系統會初始化應用程式行程,創建Application等任務,這時候會展示一個 啟動視窗 Starting Window,上面分析了過,如果沒有優化主題的話,那麼就是白屏。 

如果要瞭解更多啟動過程原始碼,可以看我的博客 : 

Launcher 啟動 Activity 的工作過程

https://blog.csdn.net/qian520ao/article/details/78156214

分析原始碼後,我們可以知道 Application 初始化後會呼叫 attachBaseContext() 方法,再呼叫 Application 的 onCreate(),再到入口 Activity的創建和執行 onCreate() 方法。所以我們就可以在 Application 中記錄啟動時間。


//Application

@Override
protected void attachBaseContext(Context base
{
    super.attachBaseContext(base);
    SPUtil.putLong("application_attach_time"
        System.currentTimeMillis());//記錄Application初始化時間
}

有了啟動時間,我們得知道入口的 Acitivty 顯示給用戶的時間(View繪製完畢),在博客( View的工作流程)中瞭解到,在onWindowFocusChanged()的回呼時機中表示可以獲取用戶的觸摸時間和View的流程繪製完畢,所以我們可以在這個方法里記錄顯示時間。

https://blog.csdn.net/qian520ao/article/details/78657084


//入口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動態圖片,所以需要將這些圖片下載到本地檔案,下載完成後再顯示,這個過程往往會遇到以下兩個問題 :

1、廣告頁的下載,由於這個是一個異步過程,所以往往不知道加載到頁面的合適時機。

2、廣告頁的儲存,因為儲存是 I/O 流操作,很有可能被用戶中斷,下次拿到破損的圖片。

因為不清楚用戶的網絡環境,有些用戶下載廣告頁可能需要一段時間,這時候又不可能無限的等候。所以針對這個問題我們可以開啟 IntentService 用來下載廣告頁圖片。

1、在入口 Acitivity 中開啟 IntentService 來下載廣告頁。 或者是其它異步下載操作。

2、在廣告頁圖片 檔案流完全寫入後 記錄圖片大小,或者記錄一個標識。

在下次的廣告頁加載中可以判斷是否已經下載好了廣告頁圖片以及圖片是否完整,否則刪除並且再次下載圖片。

另外因為在閃屏頁中仍然有 剩餘展示時間,所以在這個時間段里如果用戶已經下載好了圖片並且圖片完整,就可以顯示廣告頁。否則進入主 Activity , 因為 IntentService 仍然在後臺繼續默默的下載並儲存圖片~

4、優化效果

優化前 : (小米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++ 中分配的物件。

更多查看 : 

https://developer.android.google.cn/studio/profile/memory-profiler?hl=zh-cn

5、啟動視窗

優化完我們的代碼後,分析一下啟動視窗的原始碼。基於 android-25 (7.1.1)

啟動視窗是由 WindowManagerService 統一管理的 Window視窗,一般作為冷啟動頁入口 Activity 的預覽視窗,啟動視窗由 ActivityManagerService 來決定是否顯示的,並不是每一個 Activity 的啟動和跳轉都會顯示這個視窗。

WindowManagerService 通過視窗管理策略類 PhoneWindowManager 來創建啟動視窗。

 

圖片摘自 老羅的原始碼分析

拿我之前原始碼分析的文章中的啟動流程圖來看看大致 :

 Launcher 啟動 Activity 的工作過程

https://blog.csdn.net/qian520ao/article/details/78156214


直奔主題,在 ActivityStarter的startActivityUnchecked()方法中,呼叫了ActivityStack(Activity 狀態管理)的startActivityLocked()方法。此時Activity 還在啟動過程中,視窗並未顯示。


先上一張流程圖,展示了啟動視窗的顯示過程。

首先,由 Activity 狀態管理者ActivityStack開始執行顯示啟動視窗的流程。


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

    ``````
    if (!isHomeStack() || numActivities() > 0) {//HOME_STACK表示Launcher桌面所在的Stack
        // 1.首先當前啟動棧不在Launcher的桌面棧里,並且當前系統已經有激活過Activity

        boolean doShow = true;
        if (newTask) {
            // 2.要將該Activity組件放在一個新的任務棧中啟動
            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. 熱啟動,不需要啟動視窗
          mWindowManager.setAppVisibility(r.appToken, true);
            ensureActivitiesVisibleLocked(null0, !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.
        ``````
    }

}

首先判斷當前要啟動的 Activity 不在Launcher棧里

要啟動的 Activity 是否處於新的 Task 里,並且沒有轉場動畫

如果是熱/溫啟動則不需要啟動視窗,直接設置App的Visibility


接下來呼叫ActivityRecord的showStartingWindow()方法來設置啟動視窗並且改變當前視窗的狀態。

如果 App 的應用行程創建完成,並且入口 Activity 準備就緒,就可以根據 mStartingWindowState 來判斷是否需要關閉啟動視窗。


//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和主題進行判斷。


//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 (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);

        mH.sendMessageAtFrontOfQueue(m);
    }
    return true;
}


啟動視窗也需要和 Activity 擁有同樣令牌 token ,雖然啟動視窗可能是白屏,或者一張圖片,但是仍然需要走繪製流程已經通過WMS顯示視窗。

StartingData物件用來表示啟動視窗的相關資料,描述了啟動視窗的視圖信息。

如果當前 Activity 是透明主題或者是浮動視窗等,那麼就不需要啟動視窗來過渡啟動過程,所以在上面視覺優化中的設置透明主題就沒有顯示白色的啟動視窗。

顯示啟動視窗也是一件心急火燎的事情,WMS的內部類H (handler) 處於主執行緒處理訊息,所以需要將當前Message放置佇列頭部。

PS : 為什麼需要通過 Handler 發送訊息 ? 


你可以在各大服務Service中見到 Handler 的身影,並且它們可能都有一個很弔的命名 H ,因為可能呼叫這個服務的某個執行方法處於子執行緒中,所以 Handler 的職責就是將它們切換到主執行緒中,並且也可以統一管理調度。

更多 Handler 瞭解可以查閱文章 : 

你真的瞭解Handler?

https://blog.csdn.net/qian520ao/article/details/78262289

//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上。

mPolicy為PhoneWindowManager,控制著啟動視窗的添加刪除和修改。 

在PhoneWindowManager對啟動視窗進行配置,獲取當前Activity設置的主題和資源信息,設置到啟動視窗中。


//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;

    //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);

    win.setFlags(...);

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

    //4. WindowManager的繪製流程
    wm.addView(view, params);
    return view.getParent() != null ? view : null;
}

如果theme和labelRes的值不為0,那麼說明開發者指定了啟動視窗的主題和標題,那麼就需要從當前要啟動的Activity中獲取這些信息,並設置到啟動視窗中。

和其它視窗一樣,啟動視窗也需要通過PhoneWindow來設置佈局信息DecorView。所以在上面視覺優化中的設置閃屏圖片主題的啟動視窗顯示的就是圖片內容。

啟動視窗和普通視窗的不同之處在於它是 fake window ,不需要觸摸事件

最後通過WindowManger走View的繪製流程(measure-layout-draw)將啟動視窗顯示出來,最後會請求WindowManagerService為啟動視窗添加一個WindowState物件,真正的將啟動視窗顯示給用戶,並且可以對啟動視窗進行管理。

更多WindowManager的addView流程可以查閱 : 

View的工作流程

https://blog.csdn.net/qian520ao/article/details/7865708

6、總結

至此應用程式的啟動優化和啟動視窗的原始碼分析已經總結完畢,在專案的開發中要知其然而之所以然 ,並且對原始碼的分析有助於我們瞭解原理和解決問題的根源。


●編號350,輸入編號直達本文

●輸入m獲取到文章目錄

推薦↓↓↓

Java編程

更多推薦18個技術類公眾微信

涵蓋:程式人生、演算法與資料結構、黑客技術與網絡安全、大資料技術、前端開發、Java、Python、Web開發、安卓開發、iOS開發、C/C++、.NET、Linux、資料庫、運維等。

赞(0)

分享創造快樂