天天看點

如何縮減接近 50% 的 Flutter 包體積

以下是位元組跳動移動平台部 Flutter 資深工程師李夢雲的分享主題沉澱,《如何縮減接近 50% 的 Flutter 包體積》。

演講内容大綱:
  1. 包體積問題現狀
  2. Dart 編譯産物優化
  3. Flutter 引擎編譯産物優化
  4. 機器碼指令優化
  5. 總結與展望

個人介紹

我叫李夢雲,任職于位元組跳動移動平台部,負責移動端部分基礎設施平台的建設與落地,前兩年落地插件化平台和熱修複平台,這兩個平台現在基本位元組跳動所有的 APP 都在使用,也已經比較成熟了,現在我的主要精力在 Flutter 這邊,負責 Flutter Engine 和 Dart Runtime 這兩個底層方向上的一些工作。

今天在座的各位一定正在用 Flutter 或者想用 Flutter,發現 Flutter 包體積有點偏大、有點控制不住。可能還有一部分同學并沒有注意到這個問題,但是随着使用 Flutter 的深入程度增多,大家最終都會發現這個痛點的,那我們今天就來解決這個痛點。

這是我今天分享的 5 個組成部分:

第一部分,針對 Flutter 包體積給大家講講 Flutter 包體積現狀,以及它由哪幾個部分組成。

接下來三個部分會針對這幾個組成部分做針對性的優化。

最後一部分,總結優化手段,展望 Flutter 包體積的未來。

一、包體積問題現狀

我們先來統一認識,包體積到底重要不重要?結論是很重要。

右圖是 Google 2016 年公布的研究報告,核心思想是包體積每上升 6MB 就會帶來下載下傳轉化率降低 1%,當包體積增大到 100MB 時就會有斷崖式的下跌。這是 2016 年的資料,現在流量雖然變得更廉價一點,但是使用者的心理是不會變的。可能 6MB 這個資料現在變成 10MB 或者 20MB,但是當你 APP 出現在應用市場的相同位置時,包體積越大,使用者下載下傳意願就越低,這是毫無疑問的。是以我們的結論是:包體積很重要,需要優化。

那現狀是什麼?結合今日頭條的資料:Android 可以動态下發,我們現在使用的插件化架構,包體積增量約等于 0,即便是大家沒有插件化,也可以用各種方式使包體積增量約等于 0。至于為什麼安卓可以做我們後續會講到。但 iOS 上是什麼情況呢?今日頭條 APP 優化前包體積是 167M,Flutter 産物占 18MB,占比超過了 10%。那看到這個資料的結論就是:現階段需要重點關注并優化 Flutter 在 iOS 平台上的包體積問題。

那我們引用 Flutter 之後會對現有的包體積産生多大影響呢?結論很出乎意料,iOS 平台上,如果用 OC 寫,它大概是一個線性增長的關系,随着代碼量增加,包體積也會這樣增加;但是 Flutter 不是,它不是一個線性的關系,它是這樣的一個曲線:初始增長速度極快,随着代碼增多,增長速度逐漸減緩,最終趨近線性增長。原因是 Flutter 有一個 Tree Shaking 機制,從 Main 方法開始,逐級引用,最終沒有被引用的代碼,諸如類和函數都會被裁剪掉。

這個機制在 iOS 裡沒有,但是在 Android 裡挺常見的,類似 ProGuard,安卓開發工程師應該很熟悉這個概念。一開始引入 Flutter 之後随便寫一個業務,你就會大量用到 Flutter/Dart SDK 代碼,這樣初期 Flutter 包體積極速增加,但是過了一個臨界點,使用者包體積的增加就基本取決于你 Flutter 業務代碼增量,不會增長得那麼快。

是以我們分析 Flutter Release 産物的時候是不能用太簡單的 Demo 的,如果你隻是在螢幕上繪制一個 Hello World Text,包體積就會非常小,脫離實際的小,因為大部分 Flutter SDK 就都會被 Tree Shaken 掉了。但是實際的項目不是這樣的,我們需要寫個稍微複雜一點的項目讓包體積超過臨界點,但是又不能超過太多,否則編譯時間就會非常長,優化包體積時需要反複的編譯,這樣開發效率和優化效率就會降低。我們就寫了這麼一個簡單的 Demo,這個 Demo 有一個按鈕、用到了 Material Design 庫的一些控件,螢幕背後還用一些類做了一些别的事情,最終編譯出來之後長成這樣子。

組成部分是兩個 Framework,一個是 APP Framework,還有一個是 Flutter Framework,後面會講這兩個 Framework 主要由什麼組成。

畫了一張圖給大家詳細解釋一下:

第一部分是 App Framework,裡面的 App 在我這個 Demo 工程下是 9.2M,主要來源是 Dart 代碼 AOT 編譯産物,它是一個動态連結庫;還有一部分是 Flutter 靜态資源,内含圖檔,字型等,注意這一部分是一個變值,它是随着你的業務變化而變化的,有可能增加,有可能減少。在我的這個工程裡,flutter_assets 基本沒有東西,但是不等于你的項目 flutter_assets 沒有東西,同樣這個 9.2M 的 App 在你的工程裡可能就不是 9.2MB 了。

而 Flutter.Framework 裡則是一個定值,一個固定的值。第一部分是 Flutter 這個動态編譯庫,也就是我們的 Flutter Engine,他是由用 Flutter 底層和 Dart 語言的的 C++代碼編譯而成的。這個部分的大小主要是看用哪個分支或者哪個版本打出來的,基本上編譯 100 次,無數次都是這麼大,我們現在是 7.3MB。還有一個 icudtl.dat,國際化支援相關資料檔案,883KB,基本可以忽略不計。

二、Dart 編譯産物生成與優化

在我們講包體積優化前,先講一下包體積優化的方法論。啟動速度有方法論,包體積也有方法論。包體積的優化無非是三個方式:删、縮、挪。

删就是移除無用代碼和無用資源,删有可能是你人肉手動删,有可能是機器自動删,或者編譯的時候删除,比如剛才的 Tree Shaking 機制就是編譯時自動删除。

當你删不動時可以想一下壓縮,壓縮典型的有壓縮圖檔資源等。

當删和縮都沒有辦法解決問題時,最有效的辦法就是挪,從包裡直接挪出去,挪到遠端,典型是遠端下發插件或者安卓裡拆 App Bundle。這個挪,難度是三個中最大的,因為功能是有損的,需要特殊處理,而且一個功能挪出去之後,需要再動态下發才能跑起來。雖然功能是有損的,但是它的收益往往是最大的,随随便便挪一個插件或者挪一個 App Bundle 出去就可以帶來幾 MB 或者十幾 MB 收益,隻是它的技術難度大而已,并不是做不了。

結合 Flutter Tree Shaking 做,能删的代碼删掉,能壓的代碼也壓縮,還有其他的什麼手段嗎?能不能在 Flutter 中挪?事實是可以的。如下圖動畫,讓大家感受一下 Flutter 是怎麼“挪”的:

  1. 第一就是将 Dart 的編譯産物分成兩部分,Part1 和 Part2,把 Part2 挪出去;
  2. 第二是把 flutter_assets 這個檔案夾挪出去,也是動态下發;
  3. 第三是把 icudtl.dat 挪出去,這樣包體積就隻剩下了最後這兩部分。

核心思想是:移除非必要産物,動态下發。

那為什麼可以挪?我先結合這張 Dart 編譯流程圖詳細解釋一下 Dart 的編譯流程:

這是 Dart 的源碼,灰色是編譯工具,藍色是編譯産物或者編譯中間檔案,黃色表示編譯内層。當 Dart 代碼經過 front server 以後,編譯成 Dart Kernel,安卓上叫 app.dill,這部分屬于 Debug 編譯,編譯完成之後 Dart 代碼的 Debug 編譯就結束了,front server 主要做了詞法分析和文法分析,注意這是編譯原理的 front。經過在 Debug 編譯之後,在 Release 就多了 precompile 的流程,把抽象文法樹給編譯成中間代碼,這個時候就相當于是編譯原理的中層,底下是生成機器代碼,這相當于編譯原理的後端,編譯流程也符合現代的編譯思想分三層。

今天畢竟不是講編譯原理,我們主要關注編譯完成之後編譯産物的生成,編譯産物為編譯期生成機器碼記憶體資料的檔案形态,最終我們需要把記憶體打包到成檔案。

有兩種模式:

第一種是 Blob Mode,僅在安卓平台上支援,Flutter 1.7.8 版本之前 Android 平台上的預設模式,分四個部分:兩個指令段,兩個資料段。第二種是 Library Mode,安卓和 iOS 都支援,需要把機器碼導出成彙編然後使用平台提供的工具編譯成動态庫。iOS 是 xcrun,Android 是 ndkCompiler,注意這三種形态,内容是一樣的,用 nm App 檢視動态庫可以發現它裡面有隻有 4 個符号,跟 Blob Mode 的這 4 個 snapshot 是完全對應的。我們隻需要知道 AOT 的編譯産物編譯出來相當于四塊機器碼記憶體。

那編譯完之後我們需要把它拼起來,拼起來的話首先需要把打到包裡面的東西讓它加載起來,這是 Flutter 加載 Isolate 的代碼,Android 是從第二段裡面讀的,最終從預設 Native Library 裡讀,iOS 就是在最後讀的。

[外鍊圖檔轉存失敗,源站可能有防盜鍊機制,建議将圖檔儲存下來直接上傳(img-Cy4CgelC-1576734770831)(

https://upload-images.jianshu.io/upload_images/18452536-17dce6359baf72cd.jpg?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)]

那是以答案出來了,為什麼可以挪?我們隻需要把動态包下載下傳完成,解壓之後設定 Settings 各項路徑,原來的時候是預設設定成包裡的路徑,現在你下載下傳完成之後你強行改成自己的下載下傳路徑,再開始啟動就可以了。那剛才我們提到,安卓為什麼在沒有插件化的情況下也可以把包體積縮小非常小?因為安卓的 so 檔案本來就可以動态下發,那這樣的 snapshot 檔案也可以動态下發,資源檔案,icudlt.dat 什麼都可以動态下發的,包裡基本就什麼都沒有了,插件化唯一比它好的是 flutter.jar 也可以動态下發,具體到 iOS 基本可以把大部分挪出去。

為什麼不全挪?安卓可以全挪,iOS 為什麼不能全挪?Part1 和 Part2 又是什麼?

問題出在加載後的運作時階段。我們看一下這段運作代碼:

加載到記憶體以後,所謂指令段是需要可執行權限,大家可以看到這裡設定了 ImagePage 的 Executable 等于 True,兩個指令段需要可執行權限。iOS 不像安卓,沒有辦法随意标記執行,指令段那必須在動态庫裡下發,才能獲得可執行權限,這就是為什麼不能把 iOS 包裡面的内容全部給挪出去,就是因為這是平台限制,雖然 Flutter 提供了完整的 Settings 擴充支援,但仍然必須保證它可執行權限的那部分記憶體一定放在動态庫裡的。

那我們就完整回顧動态下發方案的原貌,原來相當于 APP 裡分成四個組成部分,我們挪了兩個 Data 過來,挪了資源過來,挪了 icudtl 過來,如下圖所示:

那這個收益是多少?收益就是剛才的 APP 從 9.2M 變成了 3.8M,Data 段一般情況來說它的體積是要大于指令段的,大家可以自己随便拿一個 Flutter 工程編譯一下安卓的包,安卓會編譯成 4 個 snapshot,Data 段體積一般是要比指令段大的。是以如果你采用動态下發方案,對于 App 動态庫檔案優化收益一定是大于 50%的,但我不确定結合大家具體實際工程的話會不會有這樣的收益,隻是在我們 Demo 裡确定可以使 App 的體積縮減一半以上。那我們還可以看到 flutter_assets 已經沒了,整體移出來了,雖然在 Demo 上收益很小,但是實際中收益應該很大,因為實際項目中不太可能沒有資源。最後 icudtl 整體移除,優化 883KB,在用 nm App 檢視一下動态庫就會發現動态庫就隻有兩個指令段,沒有了兩個 Data 段。

動态下發模式示例,引擎下發動态包示範,大家可以看我們把引擎挪出來的部分壓縮成了一個 Zip 包。這裡有一個需要注意的地方,就是你打出來機器碼是分架構的,32 位和 64 位的 Data 段是不一樣的。那你就需要生成兩個 Zip 檔案,根據自己 iOS 裝置做針對性下發。

那就有一個問題了,我是一個純 Flutter 應用,或者我一啟動就立刻要用這個功能,接受不了 Flutter 需要動态下發,這時候怎麼辦?我們可以變通一下,把這個引擎 Zip 包直接從遠端放到包裡,這樣首次使用需要解壓,會犧牲首次使用的啟動速度,那收益會比動态下發模式要小,就達不到标題所說的接近 50%了,但是仍然不失為一個有很大收益的方法。

動态下發模式包體積減少 6.3MB,這個減少部分壓縮之後體積是 2.5MB,我們内置壓縮需要把 2.5MB 再放回到到包裡去,這樣優化收益就少了 2.5MB,方案收益就變成了 3.8MB,當然 3.8M 這也是一個不小的包優化收益了。

同樣的問題,如果你想在 iOS 上支援 32 和 64 雙架構的話,Data 段檔案不通用,最終還是有兩份 Zip 檔案,不可能内置兩份 Zip 包,然後根據裝置針對性的解壓,那包體積可能不減反增。解決思路是将引擎 Zip 包置于 APP 動态庫内來規避這個問題。然後 App Store 可以針對動态庫自動實作分架構下發,就是你上傳的雙架構,但實際使用者下載下傳的還是單架構,我們可以巧妙利用這點讓 App Store 替我們完成這個事情。參考方案挺多,典型的 Dart 有一個 Observatory Server 的 Web 靜态資源,是整個直接打到 Dart 的運作時裡的。

風險應對。無論你采用哪種方案一定有風險的,比如下載下傳失敗、解壓失敗。應對政策也是,我們肯定需要提供引擎是否 ready 的 API,但是很難解釋清楚,功能雖然打進包裡,但仍然可能用不了。研發需要轉換思維,這兩種模式下不要假設 Flutter 一定可用,因為動态下發或者内置壓縮就絕對達不到百分之百的成功率,因為總有使用者的磁盤是滿的,總有網絡不可達的情況。這時候 PM 就會說接受不了這部分損失,但實際上你的功能沒那麼重要了。最終實際損失是用 Flutter 覆寫率乘以 Flutter 功能的滲透率。Flutter 覆寫率目前應該可以達到三個 9,因為我們用了内部壓縮方案。Flutter 功能的滲透率,有使用者雖然沒有用 Flutter,但是他如果不用 Flutter 這個功能那等于沒有損失,這一塊需要辯證來看,包體積優化之後是所有使用者都收益,而損失的隻是少部分使用者,你需要平衡一下,看哪部分損失大,這個情況是不是可以接受,如果可以接受,你想要求穩就用内置壓縮,如果你想更激進一點,那就用動态下發。

三、優化 Engine 編譯産物

接下來包變成這個樣子,是不是就沒有優化空間了?并不是。還沒有動心思優化的都是有優化空間的,隻不過多和少而已。

在 Flutter 引擎編譯時,安卓和 iOS 的編譯參數不同,安卓是-OZ,iOS 是-OS。如果想追求極緻包體積是需要用 OZ 的,不能用 OS,OZ 隻是性能稍微差一點,但是基本可以忍受。為什麼 iOS 性能普遍都比安卓好一點,但是為什麼它反而在這個性能好的平台上反而用 -OS 呢?它其實是之前的 build-tools 不統一,考慮到連結時優化的順序問題,OZ 反而增加了包大小。隻需要更新最新的 build-tools,改 OS 為 OZ,收益為 723.17KB,這是頭條自己的資料,大家的情況可能不一樣,但是這個收益是肯定有的。

除了統一編譯參數之後,第二部分是定制化編譯,這塊結合各個廠商、各個 APP 可能不一樣。但是有兩點大家都可以借鑒的:

第一部分,移除 boringSSL,可用 Method Channel 調用源生網絡庫來替代 Dart Http 功能,就跟在 Android 上我們基本上從來不會裸用 OKHttp 一樣,我們總得做點動态選路、失敗重連這種,還有各種對國内網絡做針對性的優化,Dart 的原生網絡庫性能一定是比不過 Native 針對國内環境做過專門優化的網絡庫的,這時候我們就可以用 Method Channel 調用源生網絡庫替代 Dart Http 功能,這樣性能絕對有提升,不會反而下降,同時還能帶來包體積的收益。具體到 Flutter Engine 收益是 0.5MB。官方也發現這個問題,他們也已經計劃把 Dart 的網絡功能交給上層來代理實作。

第二部分是 Skia,它的參數很多,其中有 3 個我們試過了,去掉之後在 Benchmark 上看不會對性能産生影響,把它禁掉的話最終得到收益不到 200KB。大家可以根據自己的情況做針對定制優化。官方有更高端的概念叫子產品化編譯,核心思想是把 Engine 拆成不同的 Modular,根據自己的情況選擇哪些打進去、哪些不能打進去,這樣就能保證 Flutter Engine 裡的所有東西都是必要的、必須的,但這隻停留在計劃階段,未來 Google 的方向是這樣,如果大家等不及可以先采用定制化編譯思路。

四、機器碼指令優化

現在 Flutter.Framework 裡的 Flutter 動态庫也得到了優化,還有最後一部分是這兩個指令段。

這兩個指令段能不能優化呢?其實是可以的,要深入 Dart 的編譯原理、機器碼生成等一大堆。我們一開始并不是特别在意這個,都是機器碼,那 OC 出來的機器碼就比 Dart 厲害嗎?結論還真是,目前 OC 寫出來的機器碼就是厲害一點。

我們做了包體積增量對比實驗,為什麼做這個實驗?是因為将來如果有一天 Flutter 鋪開以後,所有的業務代碼都用 Flutter 寫,那就涉及一個問題,之前用 OC 開發一個業務可能包體積是 200KB,現在用 Flutter 開發同樣一個業務發現包體積變成 400KB,翻倍怎麼辦?會不會有這個風險?其實是有的。

做個簡單的實驗,這樣一個函數傳回自定義的 View,不停的複制,一直複制到 1000,這時候沒有引用任何新增代碼,包體積增量完全取決于你自己 Copy 的新增代碼,這個時候你的增長就是完全線性的。但是這個線性的斜率是不一樣的,Dart 的斜率遠高于 OC。

這一塊是怎麼優化的?我們先分析了一下這個背後的原因,寫了一個更簡單的函數,傳回一個自定義的 View 太複雜,我們就直接列印了一個 Int:

OC 的版本用 Hopper 反編譯,得到 11 條指令,因為 a=3+4 在編譯期直接被優化,0×3 是我當時編譯時用 1+2 得出來的,第 4 個指令 orr 應該是改成 0×7,結論是:函數生成 11 條彙編指令。

Dart 呢?我們寫了這個相同的函數,如果你想得到機器碼指令需要修改 Dart 源碼,在編譯時把指令打出來,我們看它不是 11 條,而是 32 條,這時候包體積斜率不一樣的原因就找到了。Dart 彙編指令多了很多。

我們發了一個 Issue,這是标題(The ipa size grows too fast due to Flutter's incompact machine code instructions #40345,

https://github.com/flutter/flutter/issues/40345

)。Google 很快給我們回複了,這是負責 Dart 團隊的 Mraleph 分析的結果。

核心的結論說所有函數前 8 個都有指令對齊頭,後 6 位都有對齊指令,有的是基于曆史的原因,有的可能是基于性能的原因,但是一頭一尾都是可以移除。如果是一個特别小的函數,中間制定機器碼指令反而沒有這前後加起來的 14 條指令多。如果你的包裡面全部是小函數的話,那 Dart 和 OC 差出來就比較多,如果正常寫的話肯定不是這樣。我這個實驗其實對 Dart 非常不友好,但變相放大暴露這個問題其實挺好的,讓我們知道指令裡頭有這麼大的優化空間。中間還有 18 條指令,其中 5 條是為了做棧溢出檢查,OC 沒有這個指令,還剩 13 條必要指令,基本與 OC 11 條持平,也存在優化空間。

最終大家可以跟進一下這個 Issue,Google 的大神 Mraleph 給建立了 5 個 Task,有一些已經落地了,有一些還在推進中,這個問題他們非常重視,會持續性跟進。他們給出的結論是最終認為 OC 的機器碼應該跟 Dart 基本持平,不存在誰更厲害的問題。

五、總結與展望

總結

第一部分,我們分析了 Dart 的編譯産物,對 Dart 編譯産物做了針對性優化。

有兩個優化思路:

  1. 動态下發:剝離 Data 段及一切非必要産物,打包後動态下發。
  2. 内置壓縮:以二進制形态内置動态下發包。

第二部分是 Flutter 引擎編譯産物優化,主要優化思路有更新 Bulild Tools 統一雙編譯參數,定制化編譯裁剪引擎内部部分特定無用功能。

第三部分是機器碼指令優化,精簡機器碼指令,Google 也回複稱未來 Dart 與 OC 基本持平。

展望

剛才我們看到 Dart 是這樣的,Dart 未來這個斜率低于 iOS,如果 OC 跟 Dart 基本持平,我們可以把占包體積一半的 Dart 指令的 Data 段挪出去,即便是 OC 的機器碼隻有它的一半,我們仍然可以保證最終的包體積 Dart 和 OC 基本持平。如果 Google 能夠優化得更好,能到跟 OC 持平,那 Flutter 未來包體積增量一定比 iOS 小,這個問題在未來就可能不是一個問題了。當然,這個前提是發生在動态下發。内置壓縮這個斜率稍微高一點,但是至于比 OC 高還是低,我沒辦法準确預測,但是我覺得應該是一個可以接受的程度。

今天的分享就到這裡。

Q&A

提問:谷歌的引擎是一直在疊代的,如果我從現代的版本開始修改引擎,以後谷歌的引擎更新了,我要不要馬上跟進?

回答:這個問題很多人都問過我們,在 Flutter 團隊引擎不停疊代時,你的自定義引擎如何跟上節奏?這個問題是我們不需要緊跟潮流,我們挑一個穩定版本,154 做有針對性的優化,過兩個月再判斷一下,比如現在 191 适合不适合做适配、做遷移,如果适合,那我們就做,如果不适合,或者業務方沒有緊急需求,那就不升。為什麼有些團隊升到 178?因為海外要支援雙架構,原來的不夠,隻能支援單架構,那麼 178 預設模式就改成 Library Mode,支援 32 位和 64 位,是為了這個事情。如果你沒有這個強烈的需求,反而用自己公司内部的引擎更穩定一點。

提問:我是一名移動端研發。我們通過“挪”的方式使包體積變小了,但是使用者在使用實際子產品當中又要挪回來,我想問的是使用者在使用某個子產品時,把我們挪出去的這部分挪回來的時候,這個轉場我們應該怎麼去處理?或者有什麼更好的方案讓使用者無感覺的加載我們挪出去的這部分東西?

回答:還是跟剛才的問題一樣。這個問題對于位元組跳動的 Android 研發來講還挺司空見慣的,一個功能挪出去以後,構成插件以後,也會面臨你這個相同的問題。這很簡單,就判斷一下插件是否存在,iOS 也要判斷引擎是否存在,要麼是否展示接口、是否展示功能入口。你如果一定要在啟動階段首頁展示這個功能的話,那你就隻能阻塞一下了。

提問:您提到有一個位元組碼優化的問題,剛才講到 Dart 語言應該是運作在虛拟機上的,這個位元組碼優化是優化編譯的中間語言?還是由 Dart 虛拟機最終生成?

回答:不,它是機器,在 Release 模式下運作的是機器碼。在編譯器由 Dart 虛拟機生成的,但是實際運作的時候它是一個完完全全的機器碼。

提問:關于剛才提到位元組碼指令的問題,Dart 針對你舉的例子裡,同一條 OC 是 11 條,Dart 是 32 條,這種情況對于我們來說,我們自己沒辦法做這個優化,但是實際過程中要不要盡量減少小函數,這樣是不是也是一種方式?

回答:實際使用中應該不會寫到像我今天展示這麼小的函數,應該不會直接打出一個 Int,實際使用的函數遠比這個要複雜,在實際使用過程中 Dart 的代碼和 OC 的差別沒有今天展示的那麼大,我隻是為了示範備援指令,專門挑了一個特别對 Dart 不友好的 demo,但實際上沒有那麼大的差別。如果已經用上動态下發模式的話,留在包裡的指令段真的是非常少的一部分,我們隻是追求極緻把事情做到盡善盡美,但是這部分就算不優化也應該是可以接受的,我們線上已經在跑着、已經在廣泛用 Flutter 了,這應該不是阻礙你釋出或者采用它這個技術棧的原因。

原文作者:李夢雲

原文連結:

Flutter 沙龍回顧 | 如何縮減接近 50% 的 Flutter 包體積

來源:微信公衆号位元組跳動技術團隊

推薦閱讀資料:

Flutter 完全手冊 2019Flutter面試題最新整理大全(含答案) 作為一名初級移動開發人員,我能拿到年薪 25.2 萬的 Offer,隻因我做對了這四件事!

繼續閱讀