iOS App冷啟動治理:來自美團外賣的實踐

美團技術團隊發表於2018-12-07

一、背景

冷啟動時長是App效能的重要指標,作為使用者體驗的第一道“門”,直接決定著使用者對App的第一印象。美團外賣iOS客戶端從2013年11月開始,歷經幾十個版本的迭代開發,產品形態不斷完善,業務功能日趨複雜;同時外賣App也已經由原來的獨立業務App演進成為一個平臺App,陸續接入了閃購、跑腿等其他新業務。因此,更多更復雜的工作需要在App冷啟動的時候被完成,這給App的冷啟動效能帶來了挑戰。對此,我們團隊基於業務形態的變化和外賣App的特點,對冷啟動進行了持續且有針對性的優化工作,目的就是為了呈現更加流暢的使用者體驗。

二、冷啟動定義

一般而言,大家把iOS冷啟動的過程定義為:從使用者點選App圖示開始到appDelegate didFinishLaunching方法執行完成為止。這個過程主要分為兩個階段:

  • T1:main()函式之前,即作業系統載入App可執行檔案到記憶體,然後執行一系列的載入&連結等工作,最後執行至App的main()函式。
  • T2:main()函式之後,即從main()開始,到appDelegate的didFinishLaunchingWithOptions方法執行完畢。

iOS App冷啟動治理:來自美團外賣的實踐

然而,當didFinishLaunchingWithOptions執行完成時,使用者還沒有看到App的主介面,也不能開始使用App。例如在外賣App中,App還需要做一些初始化工作,然後經歷定位、首頁請求、首頁渲染等過程後,使用者才能真正看到資料內容並開始使用,我們認為這個時候冷啟動才算完成。我們把這個過程定義為T3。

iOS App冷啟動治理:來自美團外賣的實踐

綜上,外賣App把冷啟動過程定義為:__從使用者點選App圖示開始到使用者能看到App主介面內容為止這個過程,即T1+T2+T3。__在App冷啟動過程當中,這三個階段中的每個階段都存在很多可以被優化的點。

三、問題現狀

效能存量問題

美團外賣iOS客戶端經過幾十個版本的迭代開發後,在冷啟動過程中已經積累了若干效能問題,解決這些效能瓶頸是冷啟動優化工作的首要目標,這些問題主要包括:

iOS App冷啟動治理:來自美團外賣的實踐

注:啟動項的定義,在App啟動過程中需要被完成的某項工作,我們稱之為一個啟動項。例如某個SDK的初始化、某個功能的預載入等。

效能增量問題

一般情況下,在App早期階段,冷啟動不會有明顯的效能問題。冷啟動效能問題也不是在某個版本突然出現的,而是隨著版本迭代,App功能越來越複雜,啟動任務越來越多,冷啟動時間也一點點延長。最後當我們注意到,並想要優化它的時候,這個問題已經變得很棘手了。外賣App的效能問題增量主要來自啟動項的增加,隨著版本迭代,啟動項任務簡單粗暴地堆積在啟動流程中。如果每個版本冷啟動時間增加0.1s,那麼幾個版本下來,冷啟動時長就會明顯增加很多。

四、治理思路

冷啟動效能問題的治理目標主要有三個:

  1. 解決存量問題:優化當前效能瓶頸點,優化啟動流程,縮短冷啟動時間。
  2. 管控增量問題:冷啟動流程規範化,通過程式碼正規化和文件指導後續冷啟動過程程式碼的維護,控制時間增量。
  3. 完善監控:完善冷啟動效能指標監控,收集更詳細的資料,及時發現效能問題。

iOS App冷啟動治理:來自美團外賣的實踐

五、規範啟動流程

截止至2017年底,美團外賣使用者數已達2.5億,而美團外賣App也已完成了從支撐單一業務的App到支援多業務的平臺型App的演進(美團外賣iOS多端複用的推動、支撐與思考),公司的一些新興業務也陸續整合到外賣App當中。下面是外賣App的架構圖,外賣的架構主要分為三層,底層是基礎元件層,中層是外賣平臺層,平臺層向下管理基礎元件,向上為業務元件提供統一的適配介面,上層是基礎元件層,包括外賣業務拆分的子業務元件(外賣App和美團App中的外賣頻道可以複用子業務元件)和接入的其他非外賣業務。

iOS App冷啟動治理:來自美團外賣的實踐

App的平臺化為業務方提供了高效、標準的統一平臺,但與此同時,平臺化和業務的快速迭代也給冷啟動帶來了問題:

  1. 現有的啟動項堆積嚴重,拖慢啟動速度。
  2. 新的啟動項缺乏新增正規化,雜亂無章,修改風險大,難以閱讀和維護。

面對這個問題,我們首先梳理了目前啟動流程中所有的啟動項,然後針對App平臺化設計了新的啟動項管理方式:分階段啟動和啟動項自注冊

iOS App冷啟動治理:來自美團外賣的實踐

分階段啟動

早期由於業務比較簡單,所有啟動項都是不加以區分,簡單地堆積到didFinishLaunchingWithOptions方法中,但隨著業務的增加,越來越多的啟動項程式碼堆積在一起,效能較差,程式碼臃腫而混亂。

iOS App冷啟動治理:來自美團外賣的實踐

通過對SDK的梳理和分析,我們發現啟動項也需要根據所完成的任務被分類,有些啟動項是需要剛啟動就執行的操作,如Crash監控、統計上報等,否則會導致資訊收集的缺失;有些啟動項需要在較早的時間節點完成,例如一些提供使用者資訊的SDK、定位功能的初始化、網路初始化等;有些啟動項則可以被延遲執行,如一些自定義配置,一些業務服務的呼叫、支付SDK、地圖SDK等。我們所做的分階段啟動,首先就是把啟動流程合理地劃分為若干個啟動階段,然後依據每個啟動項所做的事情的優先順序把它們分配到相應的啟動階段,優先順序高的放在靠前的階段,優先順序低的放在靠後的階段。

iOS App冷啟動治理:來自美團外賣的實踐

下面是我們對美團外賣App啟動階段進行的重新定義,對所有啟動項進行的梳理和重新分類,把它們對應到合理的啟動階段。這樣做一方面可以推遲執行那些不必過早執行的啟動項,縮短啟動時間;另一方面,把啟動項進行歸類,方便後續的閱讀和維護。然後把這些規則落地為啟動項的維護文件,指導後續啟動項的新增和維護。

iOS App冷啟動治理:來自美團外賣的實踐

通過上面的工作,我們梳理出了十幾個可以推遲執行的啟動項,佔所有啟動項的30%左右,有效地優化了啟動項所佔的這部分冷啟動時間。

啟動項自注冊

確定了啟動項分階段啟動的方案後,我們面對的問題就是如何執行這些啟動項。比較容易想到的方案是:在啟動時建立一個啟動管理器,然後讀取所有啟動項,然後當時間節點到來時由啟動器觸發啟動項執行。這種方式存在兩個問題:

  1. 所有啟動項都要預先寫到一個檔案中(在.m檔案import,或用.plist檔案組織),這種中心化的寫法會導致臃腫的程式碼,難以閱讀維護。
  2. 啟動項程式碼無法複用:啟動項無法收斂到子業務庫內部,在外賣App和美團App中要重複實現,和外賣App平臺化的方向不符。

iOS App冷啟動治理:來自美團外賣的實踐

而我們希望的方式是,啟動項維護方式可插拔,啟動項之間、業務模組之間不耦合,且一次實現可在兩端複用。下圖是我們採用的啟動項管理方式,我們稱之為啟動項的自注冊:一個啟動項定義在子業務模組內部,被封裝成一個方法,並且自宣告啟動階段(例如一個啟動項A,在獨立App中可以宣告為在willFinishLaunch階段被執行,在美團App中則宣告在resignActive階段被執行)。這種方式下,啟動項即實現了兩端複用,不相關的啟動項互相隔離,新增/刪除啟動項都更加方便。

iOS App冷啟動治理:來自美團外賣的實踐

那麼如何給一個啟動項宣告啟動階段?又如何在正確的時機觸發啟動項的執行呢?在程式碼上,一個啟動項最終都會對應到一個函式的執行,所以在執行時只要能獲取到函式的指標,就可以觸發啟動項。美團平臺開發的元件啟動治理基建Kylin正是這樣做的:Kylin的核心思想就是在編譯時把資料(如函式指標)寫入到可執行檔案的__DATA段中,執行時再從__DATA段取出資料進行相應的操作(呼叫函式)。

為什麼要用借用__DATA段呢?原因就是為了能夠覆蓋所有的啟動階段,例如main()之前的階段。

iOS App冷啟動治理:來自美團外賣的實踐

Kylin實現原理簡述:Clang 提供了很多的編譯器函式,它們可以完成不同的功能。其中一種就是 section() 函式,section()函式提供了二進位制段的讀寫能力,它可以將一些編譯期就可以確定的常量寫入資料段。 在具體的實現中,主要分為編譯期和執行時兩個部分。在編譯期,編譯器會將標記了 attribute((section())) 的資料寫到指定的資料段中,例如寫一個{key(key代表不同的啟動階段), *pointer}對到資料段。到執行時,在合適的時間節點,在根據key讀取出函式指標,完成函式的呼叫。

上述方式,可以封裝成一個巨集,來達到程式碼的簡化,以呼叫巨集 KLN_STRINGS_EXPORT("Key", "Value")為例,最終會被展開為:

__attribute__((used, section("__DATA" "," "__kylin__"))) static const KLN_DATA __kylin__0 = (KLN_DATA){(KLN_DATA_HEADER){"Key", KLN_STRING, KLN_IS_ARRAY}, "Value"};
複製程式碼

使用示例,編譯器把啟動項函式註冊到啟動階段A:

KLN_FUNCTIONS_EXPORT(STAGE_KEY_A)() { // 在a.m檔案中,通過註冊巨集,把啟動項A宣告為在STAGE_KEY_A階段執行
    // 啟動項程式碼A
}
複製程式碼
KLN_FUNCTIONS_EXPORT(STAGE_KEY_A)() { // 在b.m檔案中,把啟動項B宣告為在STAGE_KEY_A階段執行
    // 啟動項程式碼B
}
複製程式碼

在啟動流程中,在啟動階段STAGE_KEY_A觸發所有註冊到STAGE_KEY_A時間節點的啟動項,通過對這種方式,幾乎沒有任何額外的輔助程式碼,我們用一種很簡潔的方式完成了啟動項的自注冊。

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    // 其他邏輯
    [[KLNKylin sharedInstance] executeArrayForKey:STAGE_KEY_A];  // 在此觸發所有註冊到STAGE_KEY_A時間節點的啟動項
    // 其他邏輯
    return YES;
}
複製程式碼

完成對現有的啟動項的梳理和優化後,我們也輸出了後續啟動項的新增&維護規範,規範後續啟動項的分類原則,優先順序和啟動階段。目的是管控效能問題增量,保證優化成果。

六、優化main()之前

在呼叫main()函式之前,基本所有的工作都是由作業系統完成的,開發者能夠插手的地方不多,所以如果想要優化這段時間,就必須先了解一下,作業系統在main()之前做了什麼。main()之前作業系統所做的工作就是把可執行檔案(Mach-O格式)載入到記憶體空間,然後載入動態連結庫dyld,再執行一系列動態連結操作和初始化操作的過程(載入、繫結、及初始化方法)。這方面的資料網上比較多,但重複性較高,此處附上一篇WWDC的Topic:Optimizing App Startup Time

載入過程—從exec()到main()

真正的載入過程從exec()函式開始,exec()是一個系統呼叫。作業系統首先為程式分配一段記憶體空間,然後執行如下操作:

  1. 把App對應的可執行檔案載入到記憶體。
  2. 把Dyld載入到記憶體。
  3. Dyld進行動態連結。

iOS App冷啟動治理:來自美團外賣的實踐

下面我們簡要分析一下Dyld在各階段所做的事情:

階段 工作
載入動態庫 Dyld從主執行檔案的header獲取到需要載入的所依賴動態庫列表,然後它需要找到每個 dylib,而應用所依賴的 dylib 檔案可能會再依賴其他 dylib,所以所需要載入的是動態庫列表一個遞迴依賴的集合
Rebase和Bind - Rebase在Image內部調整指標的指向。在過去,會把動態庫載入到指定地址,所有指標和資料對於程式碼都是對的,而現在地址空間佈局是隨機化,所以需要在原來的地址根據隨機的偏移量做一下修正
- Bind是把指標正確地指向Image外部的內容。這些指向外部的指標被符號(symbol)名稱繫結,dyld需要去符號表裡查詢,找到symbol對應的實現
Objc setup - 註冊Objc類 (class registration)
- 把category的定義插入方法列表 (category registration)
- 保證每一個selector唯一 (selector uniquing)
Initializers - Objc的+load()函式
- C++的建構函式屬性函式
- 非基本型別的C++靜態全域性變數的建立(通常是類或結構體)

最後 dyld 會呼叫 main() 函式,main() 會呼叫 UIApplicationMain(),before main()的過程也就此完成。

瞭解完main()之前的載入過程後,我們可以分析出一些影響T1時間的因素:

  1. 動態庫載入越多,啟動越慢。
  2. ObjC類,方法越多,啟動越慢。
  3. ObjC的+load越多,啟動越慢。
  4. C的constructor函式越多,啟動越慢。
  5. C++靜態物件越多,啟動越慢。

針對以上幾點,我們做了如下一些優化工作:

程式碼瘦身

隨著業務的迭代,不斷有新的程式碼加入,同時也會廢棄掉無用的程式碼和資原始檔,但是工程中經常有無用的程式碼和檔案被遺棄在角落裡,沒有及時被清理掉。這些無用的部分一方面增大了App的包體積,另一方便也拖慢了App的冷啟動速度,所以及時清理掉這些無用的程式碼和資源十分有必要。

通過對Mach-O檔案的瞭解,可以知道__TEXT:__objc_methname:中包含了程式碼中的所有方法,而__DATA__objc_selrefs中則包含了所有被使用的方法的引用,通過取兩個集合的差集就可以得到所有未被使用的程式碼。核心方法如下,具體可以參考:objc_cover:

def referenced_selectors(path):
    re_sel = re.compile("__TEXT:__objc_methname:(.+)") //獲取所有方法
    refs = set()
    lines = os.popen("/usr/bin/otool -v -s __DATA __objc_selrefs %s" % path).readlines() # ios & mac //真正被使用的方法
    for line in lines:
        results = re_sel.findall(line)
        if results:
            refs.add(results[0])
    return refs
}
複製程式碼

通過這種方法,我們排查了十幾個無用類和250+無用的方法。

+load優化

目前iOS App中或多或少的都會寫一些+load方法,用於在App啟動執行一些操作,+load方法在Initializers階段被執行,但過多+load方法則會拖慢啟動速度,對於大中型的App更是如此。通過對App中+load的方法分析,發現很多程式碼雖然需要在App啟動時較早的時機進行初始化,但並不需要在+load這樣非常靠前的位置,完全是可以延遲到App冷啟動後的某個時間節點,例如一些路由操作。其實+load也可以被當做一種啟動項來處理,所以在替換+load方法的具體實現上,我們仍然採用了上面的Kylin方式。

使用示例:

// 用WMAPP_BUSINESS_INIT_AFTER_HOMELOADING宣告替換+load宣告即可,不需其他改動
WMAPP_BUSINESS_INIT_AFTER_HOMELOADING() { 
    // 原+load方法中的程式碼
}
複製程式碼
// 在某個合適的時機觸發註冊到該階段的所有方法,如冷啟動結束後
[[KLNKylin sharedInstance] executeArrayForKey:@kWMAPP_BUSINESS_INITIALIZATION_AFTER_HOMELOADING_KEY] 
}
複製程式碼

七、優化耗時操作

在main()之後主要工作是各種啟動項的執行(上面已經敘述),主介面的構建,例如TabBarVC,HomeVC等等。資源的載入,如圖片I/O、圖片解碼、archive文件等。這些操作中可能會隱含著一些耗時操作,靠單純閱讀非常難以發現,如何發現這些耗時點呢?找到合適的工具就會事半功倍。

Time Profiler

Time Profiler是Xcode自帶的時間效能分析工具,它按照固定的時間間隔來跟蹤每一個執行緒的堆疊資訊,通過統計比較時間間隔之間的堆疊狀態,來推算某個方法執行了多久,並獲得一個近似值。Time Profiler的使用方法網上有很多使用教程,這裡我們也不過多介紹,附上一篇使用文件:Instruments Tutorial with Swift: Getting Started

火焰圖

除了Time Profiler,火焰圖也是一個分析CPU耗時的利器,相比於Time Profiler,火焰圖更加清晰。火焰圖分析的產物是一張呼叫棧耗時圖片,之所以稱為火焰圖,是因為整個圖形看起來就像一團跳動的火焰,火焰尖部是呼叫棧的棧頂,底部是棧底,縱向表示呼叫棧的深度,橫向表示消耗的時間。一個格子的寬度越大,越說明其可能是瓶頸。分析火焰圖主要就是看那些比較寬大的火苗,特別留意那些類似“平頂山”的火苗。下面是美團平臺開發的效能分析工具-Caesium的分析效果圖:

iOS App冷啟動治理:來自美團外賣的實踐

通過對火焰圖的分析,我們發現了冷啟動過程中存在著不少問題,併成功優化了0.3S+的時間。優化內容總結如下:

優化點 舉例
發現隱晦的耗時操作 發現在冷啟動過程中archive了一張圖片,非常耗時
推遲&減少I/O操作 減少動畫圖片組的數量,替換大圖資源等。因為相比於記憶體操作,硬碟I/O是非常耗時的操作
推遲執行的一些任務 如一些資源的I/O,一些佈局邏輯,物件的建立時機等

八、優化序列操作

在冷啟動過程中,有很多操作是序列執行的,若干個任務序列執行,時間必然比較長。如果能變序列為並行,那麼冷啟動時間就能夠大大縮短。

閃屏頁的使用

現在許多App在啟動時並不直接進入首頁,而是會向使用者展示一個持續一小段時間的閃屏頁,如果使用恰當,這個閃屏頁就能幫我們節省一些啟動時間。因為當一個App比較複雜的時候,啟動時首次構建App的UI就是一個比較耗時的過程,假定這個時間是0.2秒,如果我們是先構建首頁UI,然後再在Window上加上這個閃屏頁,那麼冷啟動時,App就會實實在在地卡住0.2秒,但是如果我們是先把閃屏頁作為App的RootViewController,那麼這個構建過程就會很快。因為閃屏頁只有一個簡單的ImageView,而這個ImageView則會向使用者展示一小段時間,這時我們就可以利用這一段時間來構建首頁UI了,一舉兩得。

iOS App冷啟動治理:來自美團外賣的實踐

快取定位&首頁預請求

美團外賣App冷啟動過程中一個重要的序列流程就是:首頁定位-->首頁請求-->首頁渲染過程,這三個操作佔了整個首頁載入時間的77%左右,所以想要縮短冷啟動時間,就一定要從這三點出發進行優化。

之前序列操作流程如下:

iOS App冷啟動治理:來自美團外賣的實踐

優化後的設計,在發起定位的同時,使用客戶端快取定位,進行首頁資料的預請求,使定位和請求並行進行。然後當使用者真實定位成功後,判斷真實定位是否命中快取定位,如果命中,則剛才的預請求資料有效,這樣可以節省大概40%的時間首頁載入時間,效果非常明顯;如果未命中,則棄用預請求資料,重新請求。

iOS App冷啟動治理:來自美團外賣的實踐

九、資料監控

Time Profiler和Caesium火焰圖都只能線上下分析App在單臺裝置中的耗時操作,侷限性比較大,無法線上上監控App在使用者裝置上的表現。外賣App使用公司內部自研的Metrics效能監控系統,長期監控App的效能指標,幫助我們掌握App線上上各種環境下的真實表現,併為技術優化專案提供可靠的資料支援。Metrics監控的核心指標之一,就是冷啟動時間。

冷啟動開始&結束時間節點

  1. 結束時間點:結束時間比較好確定,我們可以將首頁某些檢視元素的展示作為首頁載入完成的標誌。
  2. 開始時間點:一般情況下,我們都是在main()之後才開始接管App,但以main()函式作為冷啟動起始點顯然不合適,因為這樣無法統計到T1時間段。那麼,起始時間如何確定呢?目前業界常見的有兩種方法,一是以可執行檔案中任意一個類的+load方法的執行時間作為起始點;二是分析dylib的依賴關係,找到葉子節點的dylib,然後以其中某個類的+load方法的執行時間作為起始點。根據Dyld對dylib的載入順序,後者的時機更早。但是這兩種方法獲取的起始點都只在Initializers階段,而Initializers之前的時長都沒有被計入。Metrics則另闢蹊徑,以App的程式建立時間(即exec函式執行時間)作為冷啟動的起始時間。因為系統允許我們通過sysctl函式獲得程式的有關資訊,其中就包括程式建立的時間戳。
#import <sys/sysctl.h>
#import <mach/mach.h>

+ (BOOL)processInfoForPID:(int)pid procInfo:(struct kinfo_proc*)procInfo
{
    int cmd[4] = {CTL_KERN, KERN_PROC, KERN_PROC_PID, pid};
    size_t size = sizeof(*procInfo);
    return sysctl(cmd, sizeof(cmd)/sizeof(*cmd), procInfo, &size, NULL, 0) == 0;
}

+ (NSTimeInterval)processStartTime
{
    struct kinfo_proc kProcInfo;
    if ([self processInfoForPID:[[NSProcessInfo processInfo] processIdentifier] procInfo:&kProcInfo]) {
        return kProcInfo.kp_proc.p_un.__p_starttime.tv_sec * 1000.0 + kProcInfo.kp_proc.p_un.__p_starttime.tv_usec / 1000.0;
    } else {
        NSAssert(NO, @"無法取得程式的資訊");
        return 0;
    }
}
複製程式碼

程式建立的時機非常早。經過實驗,在一個新建的空白App中,程式建立時間比葉子節點dylib中的+load方法執行時間早12ms,比main函式的執行時間早13ms(實驗裝置:iPhone 7 Plus (iOS 12.0)、Xcode 10.0、Release 模式)。外賣App線上的資料則更加明顯,同樣的機型(iPhone 7 Plus)和系統版本(iOS 12.0),程式建立時間比葉子節點dylib中的+load方法執行時間早688ms。而在全部機型和系統版本中,這一資料則是878ms。

冷啟動過程時間節點

我們也在App冷啟動過程中的所有關鍵節點打上一連串測速點,Metrics會記錄下測速點的名稱,及其距離程式建立時間的時長。我們沒有采用自動打點的方式,是因為外賣App的冷啟動過程十分複雜,而自動打點無法做到如此細緻,並不實用。另外,Metrics記錄的是時間軸上以程式建立時間為原點的一組順序的時間點,而不是一組時間段,是因為順序的時間點可以計算任意兩個時間點之間的距離,即可以將時間點處理成時間段。但是,一組時間段可能無法還原為順序的時間點,因為時間段之間可能並不是首尾相接的,特別是對於非同步執行或者多執行緒的情況。

在測速完畢後,Metrics會統一將所有測速點上報到後臺。下圖是美團外賣App 6.10版本的部分過程節點監控資料截圖:

iOS App冷啟動治理:來自美團外賣的實踐

Metrics還會由後臺對資料做聚合計算,得到冷啟動總時長和各個測速點時長的50分位數、90分位數和95分位數的統計資料,這樣我們就能從巨集觀上對冷啟動時長分佈情況有所瞭解。下圖中橫軸為時長,縱軸為上報的樣本數。

iOS App冷啟動治理:來自美團外賣的實踐

十、總結

對於快速迭代的App,隨著業務複雜度的增加,冷啟動時長會不可避免的增加。冷啟動流程也是一個比較複雜的過程,當遇到冷啟動效能瓶頸時,我們可以根據App自身的特點,配合工具的使用,從多方面、多角度進行優化。同時,優化冷啟動存量問題只是冷啟動治理的第一步,因為冷啟動效能問題並不是一日造成的,也不能簡單的通過一次優化工作就能解決,我們需要通過合理的設計、規範的約束,來有效地管控效能問題的增量,並通過持續的線上監控來及時發現並修正效能問題,這樣才能夠長期保證良好的App冷啟動體驗。

作者簡介

郭賽,美團點評資深工程師。2015年加入美團,目前作為外賣iOS團隊主力開發,負責移動端業務開發,業務類基礎設施的建設與維護。

徐巨集,美團點評資深工程師。2016年加入美團,目前作為外賣iOS團隊主力開發,負責移動端APM效能監控,高可用基礎設施支撐相關推進工作。

招聘

美團外賣長期招聘Android、iOS、FE高階/資深工程師和技術專家,Base北京、上海、成都,歡迎有興趣的同學投遞簡歷到chenhang03@meituan.com。

iOS App冷啟動治理:來自美團外賣的實踐

相關文章