前端篇

用Vue.js開發微信小程序:開源框架mpvue解析
Flutter原理與實踐 
Picasso 開啓大前端的將來
美團客戶端響應式框架 EasyReact 開源啦
Logan:美團點評的開源移動端基礎日誌庫
美團點評移動端基礎日誌庫——Logan
MCI:移動持續集成在大衆點評的實踐
美團外賣Android Crash治理之路
美團外賣Android平臺化的複用實踐
美團外賣Android平臺化架構演進實踐
美團外賣Android Lint代碼檢查實踐
Android動態日誌系統Holmes
Android消息總線的演進之路:用LiveDataBus替代RxBus、EventBus
Android組件化方案及組件消息總線modular-event實戰
Android自動化頁面測速在美團的實踐
Kotlin代碼檢查在美團的探索與實踐
WMRouter:美團外賣Android開源路由框架
美團外賣客戶端高可用建設體系
iOS 覆蓋率檢測原理與增量代碼測試覆蓋率工具實現
iOS系統中導航欄的轉場解決方案與最佳實踐
Category 特性在 iOS 組件化中的應用與管控
美團開源Graver框架:用「雕刻」詮釋iOS端UI界面的高效渲染
美團外賣iOS App冷啓動治理
美團外賣iOS多端複用的推進、支撐與思考
【基本功】深刻剖析Swift性能優化
前端安全系列(一):如何防止XSS攻擊? 
前端安全系列(二):如何防止CSRF攻擊?php

Hades:移動端靜態分析框架 
Jenkins的Pipeline腳本在美團餐飲SaaS中的實踐
MSON,讓JSON序列化更快
Toast與Snackbar的那點事
WWDC案例解讀:大衆點評相機直接掃描支付是怎麼實現的
beeshell —— 開源的 React Native 組件庫 
前端趕上Go: 靜態資源增量更新的新實踐 
深刻理解JSCore
深度學習及AR在移動端打車場景下的應用 
美團點評金融平臺Web前端技術體系
插件化、熱補丁中繞不開的Proguard的坑 
美團掃碼付小程序的優化實踐
用微前端的方式搭建類單頁應用 
構建時預渲染:網頁首幀優化實踐 
美團掃碼付的前端可用性保障實踐
ARKit:加強現實技術在美團到餐業務的實踐 css

 

用Vue.js開發微信小程序:開源框架mpvue解析

前言

mpvue 是一款使用 Vue.js 開發微信小程序的前端框架。使用此框架,開發者將獲得完整的 Vue.js 開發體驗,同時爲 H5 和小程序提供了代碼複用的能力。若是想將 H5 項目改造爲小程序,或開發小程序後但願將其轉換爲 H5,mpvue 將是十分契合的一種解決方案。html

目前, mpvue 已經在美團點評多個實際業務項目中獲得了驗證,所以咱們決定將其開源,但願更多技術同行一塊兒開發,應用到更普遍的場景裏去。github 項目地址請參見 mpvue 。使用文檔請參見 http://mpvue.com/前端

爲了幫助你們更好的理解 mpvue 的架構,接下來咱們來解析框架的設計和實現思路。文中主要內容已經發表在《程序員》雜誌2017年第9期小程序專題封面報道,內容略有修改。vue

小程序開發特色

微信小程序推薦簡潔的開發方式,經過多頁面聚合完成輕量的產品功能。小程序以離線包方式下載到本地,經過微信客戶端載入和啓動,開發規範簡潔,技術封裝完全,自成開發體系,有 Native 和 H5 的影子,但又毫不雷同。java

小程序自己定位爲一個簡單的邏輯視圖層框架,官方並不推薦用來開發複雜應用,但業務需求卻難以作到精簡。複雜的應用對開發方式有較高的要求,如組件和模塊化、自動構建和集成、代碼複用和開發效率等,但小程序開發規範較大的限制了這部分能力。爲了解決上述問題,提供更好的開發體驗,咱們創造了 mpvue,經過使用 Vue.js 來開發微信小程序。node

mpvue是什麼

mpvue 是一套定位於開發小程序的前端開發框架,其核心目標是提升開發效率,加強開發體驗。使用該框架,開發者只需初步瞭解小程序開發規範、熟悉 Vue.js 基本語法便可上手。框架提供了完整的 Vue.js 開發體驗,開發者編寫 Vue.js 代碼,mpvue 將其解析轉換爲小程序並確保其正確運行。此外,框架還經過 vue-cli 工具向開發者提供 quick start 示例代碼,開發者只需執行一條簡單命令,便可得到可運行的項目。python

爲何作mpvue

在小程序內測之初,咱們計劃快速迭代出一款對標 H5 的產品實現,核心訴求是:快速實現、代碼複用、低成本和高效率… 隨後經歷了多個小程序建設,結合業務場景、技術選型和小程序開發方式,咱們整理彙總出了開發階段面臨的主要問題:react

  • 組件化機制不夠完善
  • 代碼多端複用能力欠缺
  • 小程序框架和團隊技術棧沒法有機結合
  • 小程序學習成本不夠低

組件機制:小程序邏輯和視圖層代碼彼此分離,公共組件提取後沒法聚合爲單文件入口,組件需分別在視圖層和邏輯層引入,維護性差;組件無命名空間機制,事件回調必須設置爲全局函數,組件設計有命名衝突的風險,數據封裝不強。開發者須要友好的代碼組織方式,經過 ES 模塊一次性導入;組件數據有良好的封裝。成熟的組件機制,對工程化開發相當重要。android

多端複用:常見的業務場景有兩類,經過已有 H5 產品改造爲小程序應用或反之。從效率角度出發,開發者但願經過複用代碼完成開發,但小程序開發框架卻沒法作到。咱們嘗試過經過靜態代碼分析將 H5 代碼轉換爲小程序,但只作了視圖層轉換,沒法帶來更多收益。多端代碼複用須要更成熟的解決方案。

引入 Vue.js:小程序開發方式與 H5 近似,所以咱們考慮和 H5 作代碼複用。沿襲團隊技術棧選型,咱們將 Vue.js 肯定爲小程序開發規範。使用 Vue.js 開發小程序,將直接帶來以下開發效率提高:

  • H5 代碼能夠經過最小修改複用到小程序
  • 使用 Vue.js 組件機制開發小程序,可實現小程序和 H5 組件複用
  • 技術棧統一後小程序學習成本下降,開發者從 H5 轉換到小程序不須要更多學習
  • Vue.js 代碼能夠讓全部前端直接參與開發維護

爲何是 Vue.js?這取決於團隊技術棧選型,引入新的選型與統一技術棧和提升開發效率相悖,有違開發工具服務業務的初衷。

mpvue 的演進

mpvue的造成,來源於業務場景和需求,最終方案的肯定,經歷了三個階段。

第一階段:咱們實現了一個視圖層代碼轉換工具,旨在提升代碼首次開發效率。經過將H5視圖層代碼轉換爲小程序代碼,包括 HTML 標籤映射、Vue.js 模板和樣式轉換,在此目標代碼上進行二次開發。咱們作到了有限的代碼複用,但組件化開發和小程序學習成本並未獲得有效改善。

第二階段:咱們着眼於完善代碼組件化機制。參照 Vue.js 組件規範設計了代碼組織形式,經過代碼轉換工具將代碼解析爲小程序。轉換工具主要解決組件間數據同步、生命週期關聯和命名空間問題。最終咱們實現了一個 Vue.js 語法子集,但想要實現更多特性或跟隨 Vue.js 版本迭代,工做量變得難以估計,有永無止境之感。

第三階段:咱們的目標是實現對 Vue.js 語法全集的支持,達到使用 Vue.js 開發小程序的目的。並經過引入 Vue.js runtime 實現了對 Vue.js 語法的支持,從而避免了人肉語法適配。至此,咱們完成了使用 Vue.js 開發小程序的目的。較好地實現了技術棧統1、組件化開發、多端代碼複用、下降學習成本和提升開發效率的目標。

mpvue設計思路

Vue.js 和小程序都是典型的邏輯視圖層框架,邏輯層和視圖層之間的工做方式爲:數據變動驅動視圖更新;視圖交互觸發事件,事件響應函數修改數據再次觸發視圖更新,如圖1所示。

圖1: 小程序實現原理

圖1: 小程序實現原理

 

鑑於 Vue.js 和小程序一致的工做原理,咱們思考將小程序的功能託管給 Vue.js,在正確的時機將數據變動同步到小程序,從而達到開發小程序的目的。這樣,咱們能夠將精力聚焦在 Vue.js 上,參照 Vue.js 編寫與之對應的小程序代碼,小程序負責視圖層展現,全部業務邏輯收斂到 Vue.js 中,Vue.js 數據變動後同步到小程序,如圖2所示。如此一來,咱們就得到了以 Vue.js 的方式開發小程序的能力。爲此,咱們設計的方案以下:

圖2:mpvue 實現原理

圖2:mpvue 實現原理

 

Vue代碼

  • 將小程序頁面編寫爲 Vue.js 實現
  • 以 Vue.js 開發規範實現父子組件關聯

小程序代碼

  • 以小程序開發規範編寫視圖層模板
  • 配置生命週期函數,關聯數據更新調用
  • 將 Vue.js 數據映射爲小程序數據模型

並在此基礎上,附加以下機制

  • Vue.js 實例與小程序 Page 實例創建關聯
  • 小程序和 Vue.js 生命週期創建映射關係,能在小程序生命週期中觸發 Vue.js 生命週期
  • 小程序事件創建代理機制,在事件代理函數中觸發與之對應的 Vue.js 組件事件響應

這套機制總結起來很是簡單,但實現卻至關複雜。在揭祕具體實現以前,讀者可能會有這樣一些疑問:

  • 要同時維護 Vue.js 和小程序,是否須要寫兩個版本的代碼實現?
  • 小程序負責視圖層展示,Vue.js的視圖層是否還須要,若是不須要應該如何處理?
  • 生命週期如何打通,數據同步更新如何實現?

上述問題包含了 mpvue 框架的核心內容,下文將仔細爲你道來。首先,mpvue 爲提升效率而生,自己提供了自動生成小程序代碼的能力,小程序代碼根據 Vue.js 代碼構建獲得,並不須要同時開發兩套代碼。

Vue.js 視圖層渲染由 render 方法完成,同時在內存中維護着一份虛擬 DOM,mpvue 無需使用 Vue.js 完成視圖層渲染,所以咱們改造了 render 方法,禁止視圖層渲染。熟悉源代碼的讀者,都知道 Vue runtime 有多個平臺的實現,除了咱們常見的 Web 平臺,還有 Weex。從如今開始,咱們增長了新的平臺 mpvue。

生命週期關聯:生命週期和數據同步是 mpvue 框架的靈魂,Vue.js 和小程序的數據彼此隔離,各自有不一樣的更新機制。mpvue 從生命週期和事件回調函數切入,在 Vue.js 觸發數據更新時實現數據同步。小程序經過視圖層呈現給用戶、經過事件響應用戶交互,Vue.js 在後臺維護着數據變動和邏輯。能夠看到,數據更新發端於小程序,處理自 Vue.js,Vue.js 數據變動後再同步到小程序。爲實現數據同步,mpvue 修改了 Vue.js runtime 實現,在 Vue.js 的生命週期中增長了更新小程序數據的邏輯。

事件代理機制:用戶交互觸發的數據更新經過事件代理機制完成。在 Vue.js 代碼中,事件響應函數對應到組件的 method, Vue.js 自動維護了上下文環境。然而在小程序中並無相似的機制,又由於 Vue.js 執行環境中維護着一份實時的虛擬 DOM,這與小程序的視圖層徹底對應,咱們思考,在小程序組件節點上觸發事件後,只要找到虛擬 DOM 上對應的節點,觸發對應的事件不就完成了麼;另外一方面,Vue.js 事件響應若是觸發了數據更新,其生命週期函數更新將自動觸發,在此函數上同步更新小程序數據,數據同步也就實現了。

mpvue如何使用

mpvue框架自己由多個npm模塊構成,入口模塊已經處理好依賴關係,開發者只須要執行以下代碼便可完成本地項目建立。

# 安裝 vue-cli $ npm install --global vue-cli # 根據模板項目建立本地項目,目前爲內網地址 $ vue init mpvue/mpvue-quickstart my-project # 安裝依賴和啓動自動構建 $ cd my-project $ npm install $ npm run dev 

執行完上述命令,在當前項目的 dist 子目錄將構建出小程序目標代碼,使用小程序開發者工具載入 dist 目錄便可啓動本地調試和預覽。示例項目遵循 Vue.js 模板項目規範,經過Vue.js 命令行工具vue-cli建立。代碼組織形式與 Vue.js 官方實例保持一致,咱們爲小程序定製了 Vue.js runtime 和 webpack 加載器,此部分依賴也已經內置到項目中。

針對小程序開發中常見的兩類代碼複用場景,mpvue 框架爲開發者提供瞭解決思路和技術支持,開發者只須要在此指導下進行項目配置和改造。咱們內部實踐了一個將 H5 轉換爲小程序的項目,下圖爲使用 mpvue 框架的轉換效果:

圖3:H5和小程序轉換效果

圖3:H5和小程序轉換效果

 

將小程序轉換爲H5:直接使用 Vue.js 規範開發小程序,代碼自己與H5並沒有不一樣,具體代碼差別會集中在平臺 Api 部分。此外並不需明顯改動,改造主要分以下幾部分:

  • 將小程序平臺的 Vue.js 框架替換爲標準 Vue.js
  • 將小程序平臺的 vue-loader 加載器替換爲標準 vue-loader
  • 適配和改造小程序與 H5 的底層 Api 差別

將H5轉換爲小程序:已經使用 Vue.js 開發完 H5,咱們須要作的事情以下:

  • 將標準 Vue.js 替換爲小程序平臺的 Vue.js 框架
  • 將標準 vue-loader 加載器替換爲小程序平臺的 vue-loader
  • 適配和改造小程序與 H5 的底層 Api 差別

根據小程序開發平臺提供的能力,咱們最大程度的支持了 Vue.js 語法特性,但部分功能現階段暫時還沒有實現。

表1:mpvue暫不支持的語法特性

表1:mpvue暫不支持的語法特性

 

項目轉換注意事項:框架的目標是將小程序和 H5 的開發方式經過 Vue.js 創建關聯,達到最大程度的代碼複用。但因爲平臺差別的客觀存在(主要集中在實現機制、底層Api 能力差別),咱們沒法作到代碼 100% 複用,平臺差別部分的改形成本沒法避免。對於代碼複用的場景,開發者須要重點思考以下問題並作好準備:

  • 儘可能使用平臺無的語法特性,這部分特性無需轉換和適配成本
  • 避免使用不支持的語法特性,譬如 slot, filter 等,下降改形成本
  • 若是使用特定平臺 Api ,考慮抽象好適配層接口,經過切換底層實現完成平臺轉換

mpvue 最佳實踐

在表2中,咱們對微信小程序、mpvue、WePY 這三個開發框架的主要能力和特色作了橫向對比,幫助你們瞭解不一樣框架的側重點,結合業務場景和開發習慣,肯定技術方案。對於如何更好地使用 mpvue 進行小程序開發,咱們總結了一些最佳實踐。

  • 使用 vue-cli 命令行工具建立項目,使用Vue 2.x 的語法規範進行開發
  • 避免使用框架不支持的語法特性,部分 Vue.js語法在小程序中沒法使用,儘可能使用 mpvue 和 Vue.js 共有特性
  • 合理設計數據模型,對數據的更新和操做作到細粒度控制,避免性能問題
  • 合理使用組件化開發小程序,提升代碼複用率

表2:框架使用特色對比

表2:框架使用特色對比

 

結語

mpvue 框架已經在業務項目中獲得實踐和驗證,目前正在美團點評內部大範圍使用。mpvue 來源於開源社區,飲水思源,咱們也但願爲開源社區貢獻一份力量,爲廣大小程序開發者提供一套技術方案。mpvue 的初衷是讓 Vue.js 的開發者以低成本接入小程序開發,作到代碼的低成本遷移和複用,咱們將來會繼續擴展示有能力、解決開發者的訴求、優化使用體驗、完善周邊生態建設,幫助到更多的開發者。

最後,mpvue 基於 Vue.js 源碼進行二次開發,新增長了小程序平臺的實現,咱們保留了跟隨 Vue.js 版本升級的能力,由衷的感謝 Vue.js 框架和微信小程序給業界帶來的便利。

 

Flutter原理與實踐

Flutter是Google開發的一套全新的跨平臺、開源UI框架,支持iOS、Android系統開發,而且是將來新操做系統Fuchsia的默認開發套件。自從2017年5月發佈第一個版本以來,目前Flutter已經發布了近60個版本,而且在2018年5月發佈了第一個「Ready for Production Apps」的Beta 3版本,6月20日發佈了第一個「Release Preview」版本。

初識Flutter

Flutter的目標是使同一套代碼同時運行在Android和iOS系統上,而且擁有媲美原生應用的性能,Flutter甚至提供了兩套控件來適配Android和iOS(滾動效果、字體和控件圖標等等)爲了讓App在細節處看起來更像原生應用。

在Flutter誕生以前,已經有許多跨平臺UI框架的方案,好比基於WebView的Cordova、AppCan等,還有使用HTML+JavaScript渲染成原生控件的React Native、Weex等。

基於WebView的框架優勢很明顯,它們幾乎能夠徹底繼承現代Web開發的全部成果(豐富得多的控件庫、知足各類需求的頁面框架、徹底的動態化、自動化測試工具等等),固然也包括Web開發人員,不須要太多的學習和遷移成本就能夠開發一個App。同時WebView框架也有一個致命(在對體驗&性能有較高要求的狀況下)的缺點,那就是WebView的渲染效率和JavaScript執行性能太差。再加上Android各個系統版本和設備廠商的定製,很難保證所在全部設備上都能提供一致的體驗。

爲了解決WebView性能差的問題,以React Native爲表明的一類框架將最終渲染工做交還給了系統,雖然一樣使用類HTML+JS的UI構建邏輯,可是最終會生成對應的自定義原生控件,以充分利用原生控件相對於WebView的較高的繪製效率。與此同時這種策略也將框架自己和App開發者綁在了系統的控件系統上,不只框架自己須要處理大量平臺相關的邏輯,隨着系統版本變化和API的變化,開發者可能也須要處理不一樣平臺的差別,甚至有些特性只能在部分平臺上實現,這樣框架的跨平臺特性就會大打折扣。

Flutter則開闢了一種全新的思路,從頭至尾重寫一套跨平臺的UI框架,包括UI控件、渲染邏輯甚至開發語言。渲染引擎依靠跨平臺的Skia圖形庫來實現,依賴系統的只有圖形繪製相關的接口,能夠在最大程度上保證不一樣平臺、不一樣設備的體驗一致性,邏輯處理使用支持AOT的Dart語言,執行效率也比JavaScript高得多。

Flutter同時支持Windows、Linux和macOS操做系統做爲開發環境,而且在Android Studio和VS Code兩個IDE上都提供了全功能的支持。Flutter所使用的Dart語言同時支持AOT和JIT運行方式,JIT模式下還有一個備受歡迎的開發利器「熱刷新」(Hot Reload),即在Android Studio中編輯Dart代碼後,只須要點擊保存或者「Hot Reload」按鈕,就能夠當即更新到正在運行的設備上,不須要從新編譯App,甚至不須要重啓App,當即就能夠看到更新後的樣式。

在Flutter中,全部功能均可以經過組合多個Widget來實現,包括對齊方式、按行排列、按列排列、網格排列甚至事件處理等等。Flutter控件主要分爲兩大類,StatelessWidget和StatefulWidget,StatelessWidget用來展現靜態的文本或者圖片,若是控件須要根據外部數據或者用戶操做來改變的話,就須要使用StatefulWidget。State的概念也是來源於Facebook的流行Web框架React,React風格的框架中使用控件樹和各自的狀態來構建界面,當某個控件的狀態發生變化時由框架負責對比先後狀態差別而且採起最小代價來更新渲染結果。

Hot Reload

在Dart代碼文件中修改字符串「Hello, World」,添加一個驚歎號,點擊保存或者熱刷新按鈕就能夠當即更新到界面上,僅需幾百毫秒:

Flutter經過將新的代碼注入到正在運行的DartVM中,來實現Hot Reload這種神奇的效果,在DartVM將程序中的類結構更新完成後,Flutter會當即重建整個控件樹,從而更新界面。可是熱刷新也有一些限制,並非全部的代碼改動均可以經過熱刷新來更新:

  1. 編譯錯誤,若是修改後的Dart代碼沒法經過編譯,Flutter會在控制檯報錯,這時須要修改對應的代碼。
  2. 控件類型從StatelessWidgetStatefulWidget的轉換,由於Flutter在執行熱刷新時會保留程序原來的state,而某個控件從stageless→stateful後會致使Flutter從新建立控件時報錯「myWidget is not a subtype of StatelessWidget」,而從stateful→stateless會報錯「type ‘myWidget’ is not a subtype of type ‘StatefulWidget’ of ‘newWidget’」。
  3. 全局變量和靜態成員變量,這些變量不會在熱刷新時更新。
  4. 修改了main函數中建立的根控件節點,Flutter在熱刷新後只會根據原來的根節點從新建立控件樹,不會修改根節點。
  5. 某個類從普通類型轉換成枚舉類型,或者類型的泛型參數列表變化,都會使熱刷新失敗。

熱刷新沒法實現更新時,執行一次熱重啓(Hot Restart)就能夠全量更新全部代碼,一樣不須要重啓App,區別是restart會將全部Dart代碼打包同步到設備上,而且全部狀態都會重置。

Flutter插件

Flutter使用的Dart語言沒法直接調用Android系統提供的Java接口,這時就須要使用插件來實現中轉。Flutter官方提供了豐富的原生接口封裝:

在Flutter中,依賴包由Pub倉庫管理,項目依賴配置在pubspec.yaml文件中聲明便可(相似於NPM的版本聲明 Pub Versioning Philosophy),對於未發佈在Pub倉庫的插件能夠使用git倉庫地址或文件路徑:

dependencies: 
  url_launcher: ">=0.1.2 <0.2.0" collection: "^0.1.2" plugin1: git: url: "git://github.com/flutter/plugin1.git" plugin2: path: ../plugin2/ 

以shared_preferences爲例,在pubspec中添加代碼:

dependencies:
  flutter:
    sdk: flutter

  shared_preferences: "^0.4.1" 

脫字號「^」開頭的版本表示和當前版本接口保持兼容的最新版,^1.2.3 等效於 >=1.2.3 <2.0.0 而 ^0.1.2 等效於 >=0.1.2 <0.2.0,添加依賴後點擊「Packages get」按鈕便可下載插件到本地,在代碼中添加import語句就能夠使用插件提供的接口:

import 'package:shared_preferences/shared_preferences.Dart'; class _MyAppState extends State<MyAppCounter> { int _count = 0; static const String COUNTER_KEY = 'counter'; _MyAppState() { init(); } init() async { var pref = await SharedPreferences.getInstance(); _count = pref.getInt(COUNTER_KEY) ?? 0; setState(() {}); } increaseCounter() async { SharedPreferences pref = await SharedPreferences.getInstance(); pref.setInt(COUNTER_KEY, ++_count); setState(() {}); } ... 

Dart

Dart是一種強類型、跨平臺的客戶端開發語言。具備專門爲客戶端優化、高生產力、快速高效、可移植(兼容ARM/x86)、易學的OO編程風格和原生支持響應式編程(Stream & Future)等優秀特性。Dart主要由Google負責開發和維護,在2011年10啓動項目,2017年9月發佈第一個2.0-dev版本。

Dart自己提供了三種運行方式:

  1. 使用Dart2js編譯成JavaScript代碼,運行在常規瀏覽器中(Dart Web)。
  2. 使用DartVM直接在命令行中運行Dart代碼(DartVM)。
  3. AOT方式編譯成機器碼,例如Flutter App框架(Flutter)。

Flutter在篩選了20多種語言後,最終選擇Dart做爲開發語言主要有幾個緣由:

  1. 健全的類型系統,同時支持靜態類型檢查和運行時類型檢查。
  2. 代碼體積優化(Tree Shaking),編譯時只保留運行時須要調用的代碼(不容許反射這樣的隱式引用),因此龐大的Widgets庫不會形成發佈體積過大。
  3. 豐富的底層庫,Dart自身提供了很是多的庫。
  4. 多生代無鎖垃圾回收器,專門爲UI框架中常見的大量Widgets對象建立和銷燬優化。
  5. 跨平臺,iOS和Android共用一套代碼。
  6. JIT & AOT運行模式,支持開發時的快速迭代和正式發佈後最大程度發揮硬件性能。

在Dart中,有一些重要的基本概念須要瞭解:

  • 全部變量的值都是對象,也就是類的實例。甚至數字、函數和null也都是對象,都繼承自Object類。
  • 雖然Dart是強類型語言,可是顯式變量類型聲明是可選的,Dart支持類型推斷。若是不想使用類型推斷,能夠用dynamic類型。
  • Dart支持泛型,List<int>表示包含int類型的列表,List<dynamic>則表示包含任意類型的列表。
  • Dart支持頂層(top-level)函數和類成員函數,也支持嵌套函數和本地函數。
  • Dart支持頂層變量和類成員變量。
  • Dart沒有public、protected和private這些關鍵字,使用下劃線「_」開頭的變量或者函數,表示只在庫內可見。參考庫和可見性

DartVM的內存分配策略很是簡單,建立對象時只須要在現有堆上移動指針,內存增加始終是線形的,省去了查找可用內存段的過程:

Dart中相似線程的概念叫作Isolate,每一個Isolate之間是沒法共享內存的,因此這種分配策略能夠讓Dart實現無鎖的快速分配。

Dart的垃圾回收也採用了多生代算法,新生代在回收內存時採用了「半空間」算法,觸發垃圾回收時Dart會將當前半空間中的「活躍」對象拷貝到備用空間,而後總體釋放當前空間的全部內存:

整個過程當中Dart只須要操做少許的「活躍」對象,大量的沒有引用的「死亡」對象則被忽略,這種算法也很是適合Flutter框架中大量Widget重建的場景。

Flutter Framework

Flutter的框架部分徹底使用Dart語言實現,而且有着清晰的分層架構。分層架構使得咱們能夠在調用Flutter提供的便捷開發功能(預約義的一套高質量Material控件)以外,還能夠直接調用甚至修改每一層實現(由於整個框架都屬於「用戶空間」的代碼),這給咱們提供了最大程度的自定義能力。Framework底層是Flutter引擎,引擎主要負責圖形繪製(Skia)、文字排版(libtxt)和提供Dart運行時,引擎所有使用C++實現,Framework層使咱們能夠用Dart語言調用引擎的強大能力。

分層架構

Framework的最底層叫作Foundation,其中定義的大都是很是基礎的、提供給其餘全部層使用的工具類和方法。繪製庫(Painting)封裝了Flutter Engine提供的繪製接口,主要是爲了在繪製控件等固定樣式的圖形時提供更直觀、更方便的接口,好比繪製縮放後的位圖、繪製文本、插值生成陰影以及在盒子周圍繪製邊框等等。Animation是動畫相關的類,提供了相似Android系統的ValueAnimator的功能,而且提供了豐富的內置插值器。Gesture提供了手勢識別相關的功能,包括觸摸事件類定義和多種內置的手勢識別器。GestureBinding類是Flutter中處理手勢的抽象服務類,繼承自BindingBase類。Binding系列的類在Flutter中充當着相似於Android中的SystemService系列(ActivityManager、PackageManager)功能,每一個Binding類都提供一個服務的單例對象,App最頂層的Binding會包含全部相關的Bingding抽象類。若是使用Flutter提供的控件進行開發,則須要使用WidgetsFlutterBinding,若是不使用Flutter提供的任何控件,而直接調用Render層,則須要使用RenderingFlutterBinding。

Flutter自己支持Android和iOS兩個平臺,除了性能和開發語言上的「native」化以外,它還提供了兩套設計語言的控件實現Material & Cupertino,能夠幫助App更好地在不一樣平臺上提供原生的用戶體驗。

渲染庫(Rendering)

Flutter的控件樹在實際顯示時會轉換成對應的渲染對象(RenderObject)樹來實現佈局和繪製操做。通常狀況下,咱們只會在調試佈局,或者須要使用自定義控件來實現某些特殊效果的時候,才須要考慮渲染對象樹的細節。渲染庫主要提供的功能類有:

abstract class RendererBinding extends BindingBase with ServicesBinding, SchedulerBinding, HitTestable { ... } abstract class RenderObject extends AbstractNode with DiagnosticableTreeMixin implements HitTestTarget { abstract class RenderBox extends RenderObject { ... } class RenderParagraph extends RenderBox { ... } class RenderImage extends RenderBox { ... } class RenderFlex extends RenderBox with ContainerRenderObjectMixin<RenderBox, FlexParentData>, RenderBoxContainerDefaultsMixin<RenderBox, FlexParentData>, DebugOverflowIndicatorMixin { ... } 

RendererBinding是渲染樹和Flutter引擎的膠水層,負責管理幀重繪、窗口尺寸和渲染相關參數變化的監聽。RenderObject渲染樹中全部節點的基類,定義了佈局、繪製和合成相關的接口。RenderBox和其三個經常使用的子類RenderParagraphRenderImageRenderFlex則是具體佈局和繪製邏輯的實現類。

在Flutter界面渲染過程分爲三個階段:佈局、繪製、合成,佈局和繪製在Flutter框架中完成,合成則交由引擎負責。

控件樹中的每一個控件經過實現RenderObjectWidget#createRenderObject(BuildContext context) → RenderObject方法來建立對應的不一樣類型的RenderObject對象,組成渲染對象樹。由於Flutter極大地簡化了佈局的邏輯,因此整個佈局過程當中只須要深度遍歷一次:

渲染對象樹中的每一個對象都會在佈局過程當中接受父對象的Constraints參數,決定本身的大小,而後父對象就能夠按照本身的邏輯決定各個子對象的位置,完成佈局過程。子對象不存儲本身在容器中的位置,因此在它的位置發生改變時並不須要從新佈局或者繪製。子對象的位置信息存儲在它本身的parentData字段中,可是該字段由它的父對象負責維護,自身並不關心該字段的內容。同時也由於這種簡單的佈局邏輯,Flutter能夠在某些節點設置佈局邊界(Relayout boundary),即當邊界內的任何對象發生從新佈局時,不會影響邊界外的對象,反之亦然:

佈局完成後,渲染對象樹中的每一個節點都有了明確的尺寸和位置,Flutter會把全部對象繪製到不一樣的圖層上:

由於繪製節點時也是深度遍歷,能夠看到第二個節點在繪製它的背景和前景不得不繪製在不一樣的圖層上,由於第四個節點切換了圖層(由於「4」節點是一個須要獨佔一個圖層的內容,好比視頻),而第六個節點也一塊兒繪製到了紅色圖層。這樣會致使第二個節點的前景(也就是「5」)部分須要重繪時,和它在邏輯上絕不相干可是處於同一圖層的第六個節點也必須重繪。爲了不這種狀況,Flutter提供了另一個「重繪邊界」的概念:

在進入和走出重繪邊界時,Flutter會強制切換新的圖層,這樣就能夠避免邊界內外的互相影響。典型的應用場景就是ScrollView,當滾動內容重繪時,通常狀況下其餘內容是不須要重繪的。雖然重繪邊界能夠在任何節點手動設置,可是通常不須要咱們來實現,Flutter提供的控件默認會在須要設置的地方自動設置。

控件庫(Widgets)

Flutter的控件庫提供了很是豐富的控件,包括最基本的文本、圖片、容器、輸入框和動畫等等。在Flutter中「一切皆是控件」,經過組合、嵌套不一樣類型的控件,就能夠構建出任意功能、任意複雜度的界面。它包含的最主要的幾個類有:

class WidgetsFlutterBinding extends BindingBase with GestureBinding, ServicesBinding, SchedulerBinding, PaintingBinding, RendererBinding, WidgetsBinding { ... } abstract class Widget extends DiagnosticableTree { ... } abstract class StatelessWidget extends Widget { ... } abstract class StatefulWidget extends Widget { ... } abstract class RenderObjectWidget extends Widget { ... } abstract class Element extends DiagnosticableTree implements BuildContext { ... } class StatelessElement extends ComponentElement { ... } class StatefulElement extends ComponentElement { ... } abstract class RenderObjectElement extends Element { ... } ... 

基於Flutter控件系統開發的程序都須要使用WidgetsFlutterBinding,它是Flutter的控件框架和Flutter引擎的膠水層。Widget就是全部控件的基類,它自己全部的屬性都是隻讀的。RenderObjectWidget全部的實現類則負責提供配置信息並建立具體的RenderObjectElementElement是Flutter用來分離控件樹和真正的渲染對象的中間層,控件用來描述對應的element屬性,控件重建後可能會複用同一個element。RenderObjectElement持有真正負責佈局、繪製和碰撞測試(hit test)的RenderObject對象。

StatelessWidgetStatefulWidget並不會直接影響RenderObject的建立,它們只負責建立對應的RenderObjectWidgetStatelessElementStatefulElement也是相似的功能。

它們之間的關係以下圖:

若是控件的屬性發生了變化(由於控件的屬性是隻讀的,因此變化也就意味着從新建立了新的控件樹),可是其樹上每一個節點的類型沒有變化時,element樹和render樹能夠徹底重用原來的對象(由於element和render object的屬性都是可變的):

可是,若是控件樹種某個節點的類型發生了變化,則element樹和render樹中的對應節點也須要從新建立:

外賣全品類頁面實踐

在調研了Flutter的各項特性和實現原理以後,外賣計劃灰度上線Flutter版的全品類頁面。對於將Flutter頁面做爲App的一部分這種集成模式,官方並無提供完善的支持,因此咱們首先須要瞭解Flutter是如何編譯、打包而且運行起來的。

Flutter App構建過程

最簡單的Flutter工程至少包含兩個文件:

運行Flutter程序時須要對應平臺的宿主工程,在Android上Flutter經過自動建立一個Gradle項目來生成宿主,在項目目錄下執行flutter create .,Flutter會建立ios和android兩個目錄,分別構建對應平臺的宿主項目,android目錄內容以下:

此Gradle項目中只有一個app module,構建產物便是宿主APK。Flutter在本地運行時默認採用Debug模式,在項目目錄執行flutter run便可安裝到設備中並自動運行,Debug模式下Flutter使用JIT方式來執行Dart代碼,全部的Dart代碼都會打包到APK文件中assets目錄下,由libflutter.so中提供的DartVM讀取並執行:

kernel_blob.bin是Flutter引擎的底層接口和Dart語言基本功能部分代碼:

third_party/dart/runtime/bin/*.dart third_party/dart/runtime/lib/*.dart third_party/dart/sdk/lib/_http/*.dart third_party/dart/sdk/lib/async/*.dart third_party/dart/sdk/lib/collection/*.dart third_party/dart/sdk/lib/convert/*.dart third_party/dart/sdk/lib/core/*.dart third_party/dart/sdk/lib/developer/*.dart third_party/dart/sdk/lib/html/*.dart third_party/dart/sdk/lib/internal/*.dart third_party/dart/sdk/lib/io/*.dart third_party/dart/sdk/lib/isolate/*.dart third_party/dart/sdk/lib/math/*.dart third_party/dart/sdk/lib/mirrors/*.dart third_party/dart/sdk/lib/profiler/*.dart third_party/dart/sdk/lib/typed_data/*.dart third_party/dart/sdk/lib/vmservice/*.dart flutter/lib/ui/*.dart 

platform.dill則是實現了頁面邏輯的代碼,也包括Flutter Framework和其餘由pub依賴的庫代碼:

flutter_tutorial_2/lib/main.dart flutter/packages/flutter/lib/src/widgets/*.dart flutter/packages/flutter/lib/src/services/*.dart flutter/packages/flutter/lib/src/semantics/*.dart flutter/packages/flutter/lib/src/scheduler/*.dart flutter/packages/flutter/lib/src/rendering/*.dart flutter/packages/flutter/lib/src/physics/*.dart flutter/packages/flutter/lib/src/painting/*.dart flutter/packages/flutter/lib/src/gestures/*.dart flutter/packages/flutter/lib/src/foundation/*.dart flutter/packages/flutter/lib/src/animation/*.dart .pub-cache/hosted/pub.flutter-io.cn/collection-1.14.6/lib/*.dart .pub-cache/hosted/pub.flutter-io.cn/meta-1.1.5/lib/*.dart .pub-cache/hosted/pub.flutter-io.cn/shared_preferences-0.4.2/*.dart 

kernel_blob.bin和platform.dill都是由flutter_tools中的bundle.dart中調用KernelCompiler生成。

在Release模式(flutter run --release)下,Flutter會使用Dart的AOT運行模式,編譯時將Dart代碼轉換成ARM指令:

kernel_blob.bin和platform.dill都不在打包後的APK中,取代其功能的是(isolate/vm)_snapshot_(data/instr)四個文件。snapshot文件由Flutter SDK中的flutter/bin/cache/artifacts/engine/android-arm-release/darwin-x64/gen_snapshot命令生成,vm_snapshot_*是Dart虛擬機運行所須要的數據和代碼指令,isolate_snapshot_*則是每一個isolate運行所須要的數據和代碼指令。

Flutter App運行機制

Flutter構建出的APK在運行時會將全部assets目錄下的資源文件解壓到App私有文件目錄中的flutter目錄下,主要包括處理字符編碼的icudtl.dat,還有Debug模式的kernel_blob.bin、platform.dill和Release模式下的4個snapshot文件。默認狀況下Flutter在Application#onCreate時調用FlutterMain#startInitialization來啓動解壓任務,而後在FlutterActivityDelegate#onCreate中調用FlutterMain#ensureInitializationComplete來等待解壓任務結束。

Flutter在Debug模式下使用JIT執行方式,主要是爲了支持廣受歡迎的熱刷新功能:

觸發熱刷新時Flutter會檢測發生改變的Dart文件,將其同步到App私有緩存目錄下,DartVM加載而且修改對應的類或者方法,重建控件樹後當即能夠在設備上看到效果。

在Release模式下Flutter會直接將snapshot文件映射到內存中執行其中的指令:

在Release模式下,FlutterActivityDelegate#onCreate中調用FlutterMain#ensureInitializationComplete方法中會將AndroidManifest中設置的snapshot(沒有設置則使用上面提到的默認值)文件名等運行參數設置到對應的C++同名類對象中,構造FlutterNativeView實例時調用nativeAttach來初始化DartVM,運行編譯好的Dart代碼。

打包Android Library

瞭解Flutter項目的構建和運行機制後,咱們就能夠按照其需求打包成AAR而後集成到現有原生App中了。首先在andorid/app/build.gradle中修改:

  APK AAR
修改android插件類型 apply plugin: ‘com.android.application’ apply plugin: ‘com.android.library’
刪除applicationId字段 applicationId 「com.example.fluttertutorial」 applicationId 「com.example.fluttertutorial」
建議添加發布全部配置功能,方便調試 - defaultPublishConfig ‘release’
publishNonDefault true

簡單修改後咱們就能夠使用Android Studio或者Gradle命令行工具將Flutter代碼打包到aar中了。Flutter運行時所須要的資源都會包含在aar中,將其發佈到maven服務器或者本地maven倉庫後,就能夠在原生App項目中引用。

但這只是集成的第一步,爲了讓Flutter頁面無縫銜接到外賣App中,咱們須要作的還有不少。

圖片資源複用

Flutter默認將全部的圖片資源文件打包到assets目錄下,可是咱們並非用Flutter開發全新的頁面,圖片資源原來都會按照Android的規範放在各個drawable目錄,即便是全新的頁面也會有不少圖片資源複用的場景,因此在assets目錄下新增圖片資源並不合適。

Flutter官方並無提供直接調用drawable目錄下的圖片資源的途徑,畢竟drawable這類文件的處理會涉及大量的Android平臺相關的邏輯(屏幕密度、系統版本、語言等等),assets目錄文件的讀取操做也在引擎內部使用C++實現,在Dart層面實現讀取drawable文件的功能比較困難。Flutter在處理assets目錄中的文件時也支持添加多倍率的圖片資源,並可以在使用時自動選擇,可是Flutter要求每一個圖片必須提供1x圖,而後纔會識別到對應的其餘倍率目錄下的圖片:

flutter:  assets: - images/cat.png - images/2x/cat.png - images/3.5x/cat.png 
new Image.asset('images/cat.png'); 

這樣配置後,才能正確地在不一樣分辨率的設備上使用對應密度的圖片。可是爲了減少APK包體積咱們的位圖資源通常只提供經常使用的2x分辨率,其餘分辨率的設備會在運行時自動縮放到對應大小。針對這種特殊的狀況,咱們在不增長包體積的前提下,一樣提供了和原生App同樣的能力:

  1. 在調用Flutter頁面以前將指定的圖片資源按照設備屏幕密度縮放,並存儲在App私有目錄下。
  2. Flutter中使用時經過自定義的WMImage控件來加載,實際是經過轉換成FileImage並自動設置scale爲devicePixelRatio來加載。

這樣就能夠同時解決APK包大小和圖片資源缺失1x圖的問題。

Flutter和原生代碼的通訊

咱們只用Flutter實現了一個頁面,現有的大量邏輯都是用Java實現,在運行時會有許多場景必須使用原生應用中的邏輯和功能,例如網絡請求,咱們統一的網絡庫會在每一個網絡請求中添加許多通用參數,也會負責成功率等指標的監控,還有異常上報,咱們須要在捕獲到關鍵異常時將其堆棧和環境信息上報到服務器。這些功能不太可能當即使用Dart實現一套出來,因此咱們須要使用Dart提供的Platform Channel功能來實現Dart→Java之間的互相調用。

以網絡請求爲例,咱們在Dart中定義一個MethodChannel對象:

import 'dart:async'; import 'package:flutter/services.dart'; const MethodChannel _channel = const MethodChannel('com.sankuai.waimai/network'); Future<Map<String, dynamic>> post(String path, [Map<String, dynamic> form]) async { return _channel.invokeMethod("post", {'path': path, 'body': form}).then((result) { return new Map<String, dynamic>.from(result); }).catchError((_) => null); } 

而後在Java端實現相同名稱的MethodChannel:

public class FlutterNetworkPlugin implements MethodChannel.MethodCallHandler { private static final String CHANNEL_NAME = "com.sankuai.waimai/network"; @Override public void onMethodCall(MethodCall methodCall, final MethodChannel.Result result) { switch (methodCall.method) { case "post": RetrofitManager.performRequest(post((String) methodCall.argument("path"), (Map) methodCall.argument("body")), new DefaultSubscriber<Map>() { @Override public void onError(Throwable e) { result.error(e.getClass().getCanonicalName(), e.getMessage(), null); } @Override public void onNext(Map stringBaseResponse) { result.success(stringBaseResponse); } }, tag); break; default: result.notImplemented(); break; } } } 

在Flutter頁面中註冊後,調用post方法就能夠調用對應的Java實現:

loadData: (callback) async {
    Map<String, dynamic> data = await post("home/groups"); if (data == null) { callback(false); return; } _data = AllCategoryResponse.fromJson(data); if (_data == null || _data.code != 0) { callback(false); return; } callback(true); }), 

SO庫兼容性

Flutter官方只提供了四種CPU架構的SO庫:armeabi-v7a、arm64-v8a、x86和x86-64,其中x86系列只支持Debug模式,可是外賣使用的大量SDK都只提供了armeabi架構的庫。雖然咱們能夠經過修改引擎src根目錄和third_party/dart目錄下build/config/arm.gnithird_party/skia目錄下的BUILD.gn等配置文件來編譯出armeabi版本的Flutter引擎,可是實際上市面上絕大部分設備都已經支持armeabi-v7a,其提供的硬件加速浮點運算指令能夠大大提升Flutter的運行速度,在灰度階段咱們能夠主動屏蔽掉不支持armeabi-v7a的設備,直接使用armeabi-v7a版本的引擎。作到這點咱們首先須要修改Flutter提供的引擎,在Flutter安裝目錄下的bin/cache/artifacts/engine下有Flutter下載的全部平臺的引擎:

咱們只須要修改android-arm、android-arm-profile和android-arm-release下的flutter.jar,將其中的lib/armeabi-v7a/libflutter.so移動到lib/armeabi/libflutter.so便可:

cd $FLUTTER_ROOT/bin/cache/artifacts/engine for arch in android-arm android-arm-profile android-arm-release; do pushd $arch cp flutter.jar flutter-armeabi-v7a.jar # 備份 unzip flutter.jar lib/armeabi-v7a/libflutter.so mv lib/armeabi-v7a lib/armeabi zip -d flutter.jar lib/armeabi-v7a/libflutter.so zip flutter.jar lib/armeabi/libflutter.so popd done 

這樣在打包後Flutter的SO庫就會打到APK的lib/armeabi目錄中。在運行時若是設備不支持armeabi-v7a可能會崩潰,因此咱們須要主動識別並屏蔽掉這類設備,在Android上判斷設備是否支持armeabi-v7a也很簡單:

public static boolean isARMv7Compatible() { try { if (SDK_INT >= LOLLIPOP) { for (String abi : Build.SUPPORTED_32_BIT_ABIS) { if (abi.equals("armeabi-v7a")) { return true; } } } else { if (CPU_ABI.equals("armeabi-v7a") || CPU_ABI.equals("arm64-v8a")) { return true; } } } catch (Throwable e) { L.wtf(e); } return false; } 

灰度和自動降級策略

Horn是一個美團內部的跨平臺配置下發SDK,使用Horn能夠很方便地指定灰度開關:

在條件配置頁面定義一系列條件,而後在參數配置頁面添加新的字段flutter便可:

由於在客戶端作了ABI兜底策略,因此這裏定義的ABI規則並無啓用。

Flutter目前仍然處於Beta階段,灰度過程當中不免發生崩潰現象,觀察到崩潰後再針對機型或者設備ID來作降級雖然能夠儘可能下降影響,可是咱們能夠作到更迅速。外賣的Crash採集SDK同時也支持JNI Crash的收集,咱們專門爲Flutter註冊了崩潰監聽器,一旦採集到Flutter相關的JNI Crash就當即中止該設備的Flutter功能,啓動Flutter以前會先判斷FLUTTER_NATIVE_CRASH_FLAG文件是否存在,若是存在則表示該設備發生過Flutter相關的崩潰,頗有多是不兼容致使的問題,當前版本週期內在該設備上就再也不使用Flutter功能。

除了崩潰之外,Flutter頁面中的Dart代碼也可能發生異常,例如服務器下發數據格式錯誤致使解析失敗等等,Dart也提供了全局的異常捕獲功能:

import 'package:wm_app/plugins/wm_metrics.dart'; void main() { runZoned(() => runApp(WaimaiApp()), onError: (Object obj, StackTrace stack) { uploadException("$obj\n$stack"); }); } 

這樣咱們就能夠實現全方位的異常監控和完善的降級策略,最大程度減小灰度時可能對用戶帶來的影響。

分析崩潰堆棧和異常數據

Flutter的引擎部分所有使用C/C++實現,爲了減小包大小,全部的SO庫在發佈時都會去除符號表信息。和其餘的JNI崩潰堆棧同樣,咱們上報的堆棧信息中只能看到內存地址偏移量等信息:

*** *** *** *** *** *** *** *** *** *** *** *** *** *** *** ***
Build fingerprint: 'Rock/odin/odin:7.1.1/NMF26F/1527007828:user/dev-keys' Revision: '0' Author: collect by 'libunwind' ABI: 'arm64-v8a' pid: 28937, tid: 29314, name: 1.ui >>> com.sankuai.meituan.takeoutnew <<< signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x0  backtrace: r0 00000000 r1 ffffffff r2 c0e7cb2c r3 c15affcc r4 c15aff88 r5 c0e7cb2c r6 c15aff90 r7 bf567800 r8 c0e7cc58 r9 00000000 sl c15aff0c fp 00000001 ip 80000000 sp c0e7cb28 lr c11a03f9 pc c1254088 cpsr 200c0030 #00 pc 002d7088 /data/app/com.sankuai.meituan.takeoutnew-1/lib/arm/libflutter.so #01 pc 002d5a23 /data/app/com.sankuai.meituan.takeoutnew-1/lib/arm/libflutter.so #02 pc 002d95b5 /data/app/com.sankuai.meituan.takeoutnew-1/lib/arm/libflutter.so #03 pc 002d9f33 /data/app/com.sankuai.meituan.takeoutnew-1/lib/arm/libflutter.so #04 pc 00068e6d /data/app/com.sankuai.meituan.takeoutnew-1/lib/arm/libflutter.so #05 pc 00067da5 /data/app/com.sankuai.meituan.takeoutnew-1/lib/arm/libflutter.so #06 pc 00067d5f /data/app/com.sankuai.meituan.takeoutnew-1/lib/arm/libflutter.so #07 pc 003b1877 /data/app/com.sankuai.meituan.takeoutnew-1/lib/arm/libflutter.so #08 pc 003b1db5 /data/app/com.sankuai.meituan.takeoutnew-1/lib/arm/libflutter.so #09 pc 0000241c /data/data/com.sankuai.meituan.takeoutnew/app_flutter/vm_snapshot_instr 

單純這些信息很難定位問題,因此咱們須要使用NDK提供的ndk-stack來解析出具體的代碼位置:

ndk-stack -sym PATH [-dump PATH] Symbolizes the stack trace from an Android native crash. -sym PATH sets the root directory for symbols -dump PATH sets the file containing the crash dump (default stdin) 

若是使用了定製過的引擎,必須使用engine/src/out/android-release下編譯出的libflutter.so文件。通常狀況下咱們使用的是官方版本的引擎,能夠在flutter_infra頁面直接下載帶有符號表的SO文件,根據打包時使用的Flutter工具版本下載對應的文件便可。好比0.4.4 beta版本:

$ flutter --version # version命令能夠看到Engine對應的版本 06afdfe54e Flutter 0.4.4 • channel beta • https://github.com/flutter/flutter.git Framework • revision f9bb4289e9 (5 weeks ago) • 2018-05-11 21:44:54 -0700 Engine • revision 06afdfe54e Tools • Dart 2.0.0-dev.54.0.flutter-46ab040e58 $ cat flutter/bin/internal/engine.version # flutter安裝目錄下的engine.version文件也能夠看到完整的版本信息 06afdfe54ebef9168a90ca00a6721c2d36e6aafa 06afdfe54ebef9168a90ca00a6721c2d36e6aafa 

拿到引擎版本號後在 https://console.cloud.google.com/storage/browser/flutter_infra/flutter/06afdfe54ebef9168a90ca00a6721c2d36e6aafa/ 看到該版本對應的全部構建產物,下載android-arm-release、android-arm64-release和android-x86目錄下的symbols.zip,並存放到對應目錄:

執行ndk-stack便可看到實際發生崩潰的代碼和具體行數信息:

ndk-stack -sym flutter-production-syms/06afdfe54ebef9168a90ca00a6721c2d36e6aafa/armeabi-v7a -dump flutter_jni_crash.txt ********** Crash dump: ********** Build fingerprint: 'Rock/odin/odin:7.1.1/NMF26F/1527007828:user/dev-keys' pid: 28937, tid: 29314, name: 1.ui >>> com.sankuai.meituan.takeoutnew <<< signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x0 Stack frame #00 pc 002d7088 /data/app/com.sankuai.meituan.takeoutnew-1/lib/arm/libflutter.so: Routine minikin::WordBreaker::setText(unsigned short const*, unsigned int) at /b/build/slave/Linux_Engine/build/src/out/android_release/../../flutter/third_party/txt/src/minikin/WordBreaker.cpp:55 Stack frame #01 pc 002d5a23 /data/app/com.sankuai.meituan.takeoutnew-1/lib/arm/libflutter.so: Routine minikin::LineBreaker::setText() at /b/build/slave/Linux_Engine/build/src/out/android_release/../../flutter/third_party/txt/src/minikin/LineBreaker.cpp:74 Stack frame #02 pc 002d95b5 /data/app/com.sankuai.meituan.takeoutnew-1/lib/arm/libflutter.so: Routine txt::Paragraph::ComputeLineBreaks() at /b/build/slave/Linux_Engine/build/src/out/android_release/../../flutter/third_party/txt/src/txt/paragraph.cc:273 Stack frame #03 pc 002d9f33 /data/app/com.sankuai.meituan.takeoutnew-1/lib/arm/libflutter.so: Routine txt::Paragraph::Layout(double, bool) at /b/build/slave/Linux_Engine/build/src/out/android_release/../../flutter/third_party/txt/src/txt/paragraph.cc:428 Stack frame #04 pc 00068e6d /data/app/com.sankuai.meituan.takeoutnew-1/lib/arm/libflutter.so: Routine blink::ParagraphImplTxt::layout(double) at /b/build/slave/Linux_Engine/build/src/out/android_release/../../flutter/lib/ui/text/paragraph_impl_txt.cc:54 Stack frame #05 pc 00067da5 /data/app/com.sankuai.meituan.takeoutnew-1/lib/arm/libflutter.so: Routine tonic::DartDispatcher<tonic::IndicesHolder<0u>, void (blink::Paragraph::*)(double)>::Dispatch(void (blink::Paragraph::*)(double)) at /b/build/slave/Linux_Engine/build/src/out/android_release/../../topaz/lib/tonic/dart_args.h:150 Stack frame #06 pc 00067d5f /data/app/com.sankuai.meituan.takeoutnew-1/lib/arm/libflutter.so: Routine void tonic::DartCall<void (blink::Paragraph::*)(double)>(void (blink::Paragraph::*)(double), _Dart_NativeArguments*) at /b/build/slave/Linux_Engine/build/src/out/android_release/../../topaz/lib/tonic/dart_args.h:198 Stack frame #07 pc 003b1877 /data/app/com.sankuai.meituan.takeoutnew-1/lib/arm/libflutter.so: Routine dart::NativeEntry::AutoScopeNativeCallWrapperNoStackCheck(_Dart_NativeArguments*, void (*)(_Dart_NativeArguments*)) at /b/build/slave/Linux_Engine/build/src/out/android_release/../../third_party/dart/runtime/vm/native_entry.cc:198 Stack frame #08 pc 003b1db5 /data/app/com.sankuai.meituan.takeoutnew-1/lib/arm/libflutter.so: Routine dart::NativeEntry::LinkNativeCall(_Dart_NativeArguments*) at /b/build/slave/Linux_Engine/build/src/out/android_release/../../third_party/dart/runtime/vm/native_entry.cc:348 Stack frame #09 pc 0000241c /data/data/com.sankuai.meituan.takeoutnew/app_flutter/vm_snapshot_instr 

Dart異常則比較簡單,默認狀況下Dart代碼在編譯成機器碼時並無去除符號表信息,因此Dart的異常堆棧自己就能夠標識真實發生異常的代碼文件和行數信息:

FlutterException: type '_InternalLinkedHashMap<dynamic, dynamic>' is not a subtype of type 'num' in type cast #0 _$CategoryGroupFromJson (package:wm_app/lib/all_category/model/category_model.g.dart:29) #1 new CategoryGroup.fromJson (package:wm_app/all_category/model/category_model.dart:51) #2 _$CategoryListDataFromJson.<anonymous closure> (package:wm_app/lib/all_category/model/category_model.g.dart:5) #3 MappedListIterable.elementAt (dart:_internal/iterable.dart:414) #4 ListIterable.toList (dart:_internal/iterable.dart:219) #5 _$CategoryListDataFromJson (package:wm_app/lib/all_category/model/category_model.g.dart:6) #6 new CategoryListData.fromJson (package:wm_app/all_category/model/category_model.dart:19) #7 _$AllCategoryResponseFromJson (package:wm_app/lib/all_category/model/category_model.g.dart:19) #8 new AllCategoryResponse.fromJson (package:wm_app/all_category/model/category_model.dart:29) #9 AllCategoryPage.build.<anonymous closure> (package:wm_app/all_category/category_page.dart:46) <asynchronous suspension> #10 _WaimaiLoadingState.build (package:wm_app/all_category/widgets/progressive_loading_page.dart:51) #11 StatefulElement.build (package:flutter/src/widgets/framework.dart:3730) #12 ComponentElement.performRebuild (package:flutter/src/widgets/framework.dart:3642) #13 Element.rebuild (package:flutter/src/widgets/framework.dart:3495) #14 BuildOwner.buildScope (package:flutter/src/widgets/framework.dart:2242) #15 _WidgetsFlutterBinding&BindingBase&GestureBinding&ServicesBinding&SchedulerBinding&PaintingBinding&RendererBinding&WidgetsBinding.drawFrame (package:flutter/src/widgets/binding.dart:626) #16 _WidgetsFlutterBinding&BindingBase&GestureBinding&ServicesBinding&SchedulerBinding&PaintingBinding&RendererBinding._handlePersistentFrameCallback (package:flutter/src/rendering/binding.dart:208) #17 _WidgetsFlutterBinding&BindingBase&GestureBinding&ServicesBinding&SchedulerBinding._invokeFrameCallback (package:flutter/src/scheduler/binding.dart:990) #18 _WidgetsFlutterBinding&BindingBase&GestureBinding&ServicesBinding&SchedulerBinding.handleDrawFrame (package:flutter/src/scheduler/binding.dart:930) #19 _WidgetsFlutterBinding&BindingBase&GestureBinding&ServicesBinding&SchedulerBinding._handleDrawFrame (package:flutter/src/scheduler/binding.dart:842) #20 _rootRun (dart:async/zone.dart:1126) #21 _CustomZone.run (dart:async/zone.dart:1023) #22 _CustomZone.runGuarded (dart:async/zone.dart:925) #23 _invoke (dart:ui/hooks.dart:122) #24 _drawFrame (dart:ui/hooks.dart:109) 

Flutter和原生性能對比

雖然使用原生實現(左)和Flutter實現(右)的全品類頁面在實際使用過程當中幾乎分辨不出來:

可是咱們還須要在性能方面有一個比較明確的數據對比。

咱們最關心的兩個頁面性能指標就是頁面加載時間和頁面渲染速度。測試頁面加載速度能夠直接使用美團內部的Metrics性能測試工具,咱們將頁面Activity對象建立做爲頁面加載的開始時間,頁面API數據返回做爲頁面加載結束時間。從兩個實現的頁面分別啓動400屢次的數據中能夠看到,原生實現(AllCategoryActivity)的加載時間中位數爲210ms,Flutter實現(FlutterCategoryActivity)的加載時間中位數爲231ms。考慮到目前咱們尚未針對FlutterView作緩存和重用,FlutterView每次建立都須要初始化整個Flutter環境並加載相關代碼,多出的20ms還在預期範圍內:

由於Flutter的UI邏輯和繪製代碼都不在主線程執行,Metrics原有的FPS功能沒法統計到Flutter頁面的真實狀況,咱們須要用特殊方法來對比兩種實現的渲染效率。Android原生實現的界面渲染耗時使用系統提供的FrameMetrics接口進行監控:

public class AllCategoryActivity extends WmBaseActivity { @Override protected void onCreate(Bundle savedInstanceState) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { getWindow().addOnFrameMetricsAvailableListener(new Window.OnFrameMetricsAvailableListener() { List<Integer> frameDurations = new ArrayList<>(100); @Override public void onFrameMetricsAvailable(Window window, FrameMetrics frameMetrics, int dropCountSinceLastInvocation) { frameDurations.add((int) (frameMetrics.getMetric(TOTAL_DURATION) / 1000000)); if (frameDurations.size() == 100) { getWindow().removeOnFrameMetricsAvailableListener(this); L.w("AllCategory", Arrays.toString(frameDurations.toArray())); } } }, new Handler(Looper.getMainLooper())); } super.onCreate(savedInstanceState); // ... } } 

Flutter在Framework層只能取到每幀中UI操做的CPU耗時,GPU操做都在Flutter引擎內部實現,因此須要修改引擎來監控完整的渲染耗時,在Flutter引擎目錄下的src/flutter/shell/common/rasterizer.cc文件中添加:

void Rasterizer::DoDraw(std::unique_ptr<flow::LayerTree> layer_tree) { if (!layer_tree || !surface_) { return; } if (DrawToSurface(*layer_tree)) { last_layer_tree_ = std::move(layer_tree); #if defined(OS_ANDROID) if (compositor_context_->frame_count().count() == 101) { std::ostringstream os; os << "["; const std::vector<TimeDelta> &engine_laps = compositor_context_->engine_time().Laps(); const std::vector<TimeDelta> &frame_laps = compositor_context_->frame_time().Laps(); size_t i = 1; for (auto engine_iter = engine_laps.begin() + 1, frame_iter = frame_laps.begin() + 1; i < 101 && engine_iter != engine_laps.end(); i++, engine_iter++, frame_iter++) { os << (*engine_iter + *frame_iter).ToMilliseconds() << ","; } os << "]"; __android_log_write(ANDROID_LOG_WARN, "AllCategory", os.str().c_str()); } #endif } } 

便可獲得每幀繪製時真正消耗的時間。測試時咱們將兩種實現的頁面分別打開100次,每次打開後執行兩次滾動操做,使其繪製100幀,將這100幀的每幀耗時記錄下來:

for (( i = 0; i < 100; i++ )); do openWMPage allcategory sleep 1 adb shell input swipe 500 1000 500 300 900 adb shell input swipe 500 1000 500 300 900 adb shell input keyevent 4 done 

將測試結果的100次啓動中每幀耗時取平均値,獲得每幀平均耗時狀況(橫座標軸爲幀序列,縱座標軸爲每幀耗時,單位爲毫秒):

Android原生實現和Flutter版本都會在頁面打開的前5幀超過16ms,剛打開頁面時原生實現須要建立大量View,Flutter也須要建立大量Widget,後續幀中能夠重用大部分控件和渲染節點(原生的RenderNode和Flutter的RenderObject),因此啓動時的佈局和渲染操做都是最耗時的。

10000幀(100次×100幀每次)中Android原生總平均値爲10.21ms,Flutter總平均値爲12.28ms,Android原生實現總丟幀數851幀8.51%,Flutter總丟幀987幀9.87%。在原生實現的觸摸事件處理和過分繪製充分優化的前提下,Flutter徹底能夠媲美原生的性能。

總結

Flutter目前仍處於早期階段,也尚未發佈正式的Release版本,不過咱們看到Flutter團隊一直在爲這一目標而努力。雖然Flutter的開發生態不如Android和iOS原生應用那麼成熟,許多經常使用的複雜控件還須要本身實現,有的甚至會比較困難(好比官方還沒有提供的ListView.scrollTo(index)功能),可是在高性能和跨平臺方面Flutter在衆多UI框架中仍是有很大優點的。

開發Flutter應用只能使用Dart語言,Dart自己既有靜態語言的特性,也支持動態語言的部分特性,對於Java和JavaScript開發者來講門檻都不高,3-5天能夠快速上手,大約1-2周能夠熟練掌握。在開發全品類頁面的Flutter版本時咱們也深入體會到了Dart語言的魅力,Dart的語言特性使得Flutter的界面構建過程也比Android原生的XML+JAVA更直觀,代碼量也從原來的900多行減小到500多行(排除掉引用的公共組件)。Flutter頁面集成到App後APK體積至少會增長5.5MB,其中包括3.3MB的SO庫文件和2.2MB的ICU數據文件,此外業務代碼1300行編譯產物的大小有2MB左右。

Flutter自己的特性適合追求iOS和Android跨平臺的一致體驗,追求高性能的UI交互效果的場景,不適合追求動態化部署的場景。Flutter在Android上已經能夠實現動態化部署,可是因爲Apple的限制,在iOS上實現動態化部署很是困難,Flutter團隊也正在和Apple積極溝通。

 

Picasso 開啓大前端的將來

Picasso是大衆點評移動研發團隊自研的高性能跨平臺動態化框架,通過兩年多的孕育和發展,目前在美團多個事業羣已經實現了大規模的應用。

Picasso源自咱們對大前端實踐的從新思考,以簡潔高效的架構達成高性能的頁面渲染目標。在實踐中,甚至能夠把Native技術向Picasso技術的遷移當作一種性能優化手段;與此同時,Picasso在跨越小程序端和Web端方面的工做已經取得了突破性進展,有望在四端(Android、iOS、H五、微信小程序)統一大前端實踐的基礎之上,達成高性能大前端實踐,同時配合Picasso佈局DSL強表達能力和Picasso代碼生成技術,能夠進一步提高生產力。

客戶端動態化

2007年,蘋果公司第一代iPhone發佈,它的出現「從新定義了手機」,並開啓了移動互聯網蓬勃發展的序幕。Android、iOS等移動技術,打破了Web應用開發技術即將一統江湖的局面,以後海量的應用如雨後春筍般涌現出來。移動開發技術給用戶提供了更好的移動端使用和交互體驗,但其「靜態」的開發模式卻給須要快速迭代的互聯網團隊帶來了沉重的負擔。

客戶端「靜態」開發模式

客戶端開發靜態模式

客戶端開發靜態模式

 

客戶端開發技術與Web端開發技術相比,天生帶有「靜態」的特性,咱們能夠從空間和時間兩個維度來看。

從空間上看須要集成發佈,美團App承載業務衆多,是跨業務合流,橫向涉及開發人員最多的公司,雖然開發人員付出了巨大的心血完成了業務間的組件化解耦拆分,但依然無可避免的形成了如下問題:

  1. 編譯時間過長。 隨着代碼複雜度的增長,集成編譯的時間愈來愈長。研發力量被等待編譯大量消耗,集成檢查也變成了一個巨大的挑戰。
  2. App包體增加過快。 這與迅猛發展的互聯網勢頭相符,但與新用戶拓展和業務迭代進化造成了尖銳矛盾。
  3. 運行時耦合嚴重。 在集成發佈的包體內,任何一個功能組件產生的Crash、內存泄漏等異常行爲都會致使整個App可用性降低,帶來較大的損失。
  4. 集成難度大。 業務線間代碼複用耦合,業務層、框架層、基礎服務層錯綜複雜,須要拆分出至關多的兼容層代碼,影響總體開發效率。

從時間上看須要集中發佈,線上Bug修復鬚髮版或熱修復,成本高昂。新功能的添加也必須等待統一的發版週期,這對快速成長的業務來講是不可接受的。App開發還面臨嚴重的長尾問題,沒法爲使用老版本的用戶提供最新的功能,嚴重損害了用戶和業務方的利益。

這種「靜態」的開發模式,會對研發效率和運營質量產生負面影響。對於傳統的桌面應用軟件開發而言,靜態的研發模式也許是相對能夠接受的。但對於業務蓬勃發展的移動互聯網行業來講,靜態開發模式和敏捷迭代發佈需求的矛盾日益突出。

客戶端動態化的趨勢

如何解決客戶端「靜態」開發模式帶來的問題?

業界最先給出的答案是使用Web技術

但Web技術與Native平臺相比存在性能和交互體驗上的差距。在一些性能和交互體驗能夠妥協的場景,Web技術能夠在定製容器、離線化等技術的支持下,承載運營性質的須要快速迭代試錯的頁面。

另外一個業界給出的思路是優化Web實現

利用移動客戶端技術的靈活性與高性能,再造一個「標準Web瀏覽器」,使得「Web技術」同時具備高性能、良好的交互體驗以及Web技術的動態性。此次技術浪潮中Facebook再次成爲先驅,推出了React Native技術(簡稱RN)。不過RN的設計取向有些奇怪,RN不兼容標準Web,甚至不爲Android、iOS雙端行爲對齊作努力。產生的後果就是全部「吃螃蟹」的公司都須要作二次開發才能基本對齊雙端的訴求。同時還須要盡最大努力爲RN的兼容性問題、穩定性問題甚至是性能問題買單。

而咱們給出的答案是Picasso

客戶端開發靜態模式

客戶端開發靜態模式

 

Picasso另闢蹊徑,在實現高性能動態化能力的同時,還以較強的適應能力,以動態頁面、動態模塊甚至是動態視圖的形式融入到業務開發代碼體系中,贏得了許多移動研發團隊的認同。

Picasso框架跨Web端和小程序端的實踐也已經取得了突破性進展,除了達成四端統一的大前端融合目標,Picasso的佈局理念有望支持四端的高性能渲染,同時配合Picasso代碼生成技術以及Picasso的強表達能力,生產力在大前端統一的基礎之上獲得了進一步的提高。

Picasso動態化原理

Picasso應用程序開發者使用基於通用編程語言的佈局DSL代碼編寫佈局邏輯。佈局邏輯根據給定的屏幕寬高和業務數據,計算出精準適配屏幕和業務數據的佈局信息、視圖結構信息和文本、圖片URL等必要的業務渲染信息,咱們稱這些視圖渲染信息爲PModel。PModel做爲Picasso佈局渲染的中間結果,和最終渲染出的視圖結構一一對應;Picasso渲染引擎根據PModel的信息,遞歸構建出Native視圖樹,並完成業務渲染信息的填充,從而完成Picasso渲染過程。須要指出的是,渲染引擎不作適配計算,使用佈局DSL表達佈局需求的同時完成佈局計算,既所謂「表達即計算」。

從更大的圖景上看,Picasso開發人員用TypeScript在VSCode中編寫Picasso應用程序;提交代碼後能夠經過Picasso持續集成系統自動化的完成Lint檢查和打包,在Picasso分發系統進行灰度發佈,Picasso應用程序最終以JavaScript包的形式下發到客戶端,由Picasso SDK解釋執行,達成客戶端業務邏輯動態化的目的。

在應用程序開發過程當中,TypeScript的靜態類型系統,搭配VSCode以及Picasso Debug插件,能夠得到媲美傳統移動客戶端開發IDE的智能感知和斷點調試的開發體驗。Picasso CI系統配合TypeScript的類型系統,能夠避免低級錯誤,助力多端和多團隊的配合;同時能夠經過「兼容計算」有效的解決能力支持的長尾問題。

Picasso佈局DSL

Picasso針對移動端主流的佈局引擎和系統作了系統的對比分析,這些系統包括:

  1. Android開發經常使用的LinearLayout
  2. 前端及Picasso同類動態化框架使用的FlexBox
  3. 蘋果公司主推的AutoLayout

其中蘋果官方推出的AutoLayout缺少一個好用的DSL,因此咱們直接將移動開發者社區貢獻的AutoLayout DSL方案列入對比。

首先從性能上看,AutoLayout系統是表現最差的,隨着需求複雜度的增長「佈局計算」耗時成指數級的增加。FlexBox和LinearLayout相比較AutoLayout而言會在性能表現上有較大優點。可是LinearLayout和FlexBox會讓開發者爲了佈局方面須要的概念增長沒必要要的視圖層級,進而帶來渲染性能問題。

從靈活性上看,LinearLayout和FlexBox佈局有很強的概念約束。一個強調線性排布,一個強調盒子模式、伸縮等概念,這些模型在佈局需求和模型概念不匹配時,就不得不借助編程語言進行干預。而且因爲佈局系統的隔離,這樣的干預並不容易作,必定程度上影響了佈局的靈活性和表達能力。而配合基於通用編程語言設計的DSL加上AutoLayout的佈局邏輯,能夠得到理論上最強的靈活性。可是這三個佈局系統都在試圖解決「用聲明式的方式表達佈局邏輯的問題」,基於編程語言的DSL的引入讓佈局計算引擎變得多餘。

Picasso佈局DSL的核心在於:

  1. 基於通用編程語言設計。
  2. 支持錨點概念(如上圖)。

使用錨點概念能夠簡單清晰的設置非同一個座標軸方向的兩個錨點「錨定」好的視圖位置。同時錨點能夠提供描述「相對」位置關係語義支持。事實上,針對佈局的需求更符合人類思惟的描述是相似於「B位於A的右邊,間距10,頂對齊」,而不該該是「A和B在一個水平佈局容器中……」。錨點概念經過極簡的實現消除了需求描述和視圖系統底層實現之間的語義差距。

下面舉幾個典型的例子說明錨點的用法:

1 居中對齊:

view.centerX = bgView.width / 2 view.centerY = bgView.height /2 

2 右對齊:

view.right = bgView.width - 10 view.centerY = bgView.height / 2 

3 相對排列:

viewB.top = viewA.top
    viewB.left = viewA.right + 10 

4 「花式」佈局:

viewB.top = viewA.centerY
    viewB.left = viewA.centerX 

Picasso錨點佈局邏輯具備理論上最爲靈活的的表達能力,能夠作到「所想即所得」的表達佈局需求。可是有些時候咱們會發如今特定的場景下這樣的表達能力是「過剩的」。相似於下圖的佈局需求,須要水平排布4個視圖元素、間距十、頂對齊;可能會有以下的錨點佈局邏輯代碼:

v1.top = 10 v1.left = 10 v2.top = v1.top v3.top = v2.top v4.top = v3.top v2.left = v1.right + 10 v3.left = v2.right + 10 v4.left = v3.right + 10 

顯然這樣的代碼不是特別理想,其中有較多可抽象的重複的邏輯,針對這樣的需求場景,Picasso提供了hlayout佈局函數,完美的解決了水平排布的問題:

hlayout([v1, v2, v3, v4], { top: 10, left: 10, divideSpace: 10 }) 

有心人能夠發現,這和Android平臺經典的LinearLayout一模一樣。對應hlayout函數的還有vlayout,這一對幾乎完整實現Android LinearLayout語義的兄弟函數,實現邏輯不足300行,這裏強調的重點其實不在於兩個layout函數,而是Picasso佈局DSL無限制的抽象表達能力。若是業務場景中須要相似於Flexbox或其餘的概念模型,業務應用方均可以按需快速的作出實現。

在性能方面,Picasso錨點佈局系統避免了「聲明式到命令式」的計算過程,徹底無需佈局計算引擎的介入,達成了「需求表達即計算」的效果,具備理論上最佳性能表現。

因而可知,Picasso佈局DSL,不管在性能潛力和表達能力方面都優於以上佈局系統。Picasso佈局DSL的設計是Picasso得以構建高性能四端動態化框架的基石。

同時得益於Picasso佈局DSL的表達能力和擴展能力,Picasso在自動化生成佈局代碼方面也具備得天獨厚的優點,生成的代碼更具備可維護性和擴展性。伴隨着Picasso的普及,當前前端研發過程當中「視覺還原」的過程會成爲歷史,前端開發者的經歷也會從「複製」視覺稿的重複勞動中解脫出來。

Picasso高性能渲染

業界對於動態化方案的期待一直是「接近原生性能」,可是Picasso卻作到了等同於原生的渲染效率,在複雜業務場景能夠達成超越原生技術基本實踐的效果。就目前Picasso在美團移動團隊實踐來看,同一個頁面使用Picasso技術實現會得到更好的性能表現。

Picasso實現高性能的基礎是宿主端高效的原生渲染,但實現「青出於藍而勝於藍」的效果卻有些反直覺,在這背後是有理論上的必然性的:

  • Picasso的錨點佈局讓 佈局表達和佈局計算同時發生。避免了冗餘反覆的佈局計算過程。

  • Picasso的佈局理念使 視圖層級扁平。全部的視圖都各自獨立,沒有爲了佈局邏輯表達所產生的冗餘層級。

  • Picasso設計支持了 預計算的過程。本來須要在主線程進行計算的部分過程能夠在後臺線程進行。

在常規的原生業務編碼中,很難將這些優化作到最好,由於對比每一個小點所帶來的性能提高而言,應用邏輯複雜度的提高是不能接受的。而Picasso渲染引擎,將傳統原生業務邏輯開發所能作的性能優化作到了「統一複用」,實現了一次優化,全線受益的目標。

Picasso在美團內部的應用

Picasso跨平臺高性能動態化框架在集團內部發布後,獲得了普遍關注,集團內部對於客戶端動態化的方向也十分承認,積極的在急需敏捷發佈能力的業務場景展開Picasso應用實踐;通過大概兩年多的內部迭代使用,Picasso的可靠性、性能、業務適應能力受到的集團內部的確定,Picasso動態化技術獲得了普遍的應用。

經過Picasso的橋接能力,基於Picasso的上層應用程序仍然能夠利用集團內部移動技術團隊積累的高質量基礎建設,同時已經造成初步的公司內部大生態,多個部門已經向Picasso生態貢獻了動畫能力、動態模塊能力、複用Web容器橋接基建能力、大量業務組件和通用組件。

Picasso團隊除了持續維護Picasso SDK,Picasso持續集成系統、包括基於VSCode的斷點調試,Liveload等核心開發工具鏈,還爲集團提供了統一的分發系統,爲集團內部大前端團隊開展Picasso動態化實踐奠基了堅實的基礎。

到發稿時,集團內部Picasso應用領先的BG已經實現Picasso動態化技術覆蓋80%以上的業務開發,相信通過更長時間的孵化,Picasso會成爲美團移動開發技術的「神兵利器」,助力公司技術團隊實現高速發展。

列舉Picasso在美團的部分應用案例:

Picasso開啓大前端將來

Picasso在實踐客戶端動態化的方向取得了成功,解決了傳統客戶端「靜態」研發模式致使的種種痛點。總結下來:

  1. 若是想要 敏捷發佈,使用Picasso。
  2. 若是想要 高交付質量,使用Picasso。
  3. 若是想要 優秀用戶體驗,使用Picasso。
  4. 若是想要 高性能表現,使用Picasso。
  5. 若是想要 自動化生成佈局代碼,使用Picasso。
  6. 若是想要 高效生產力,使用Picasso。

至此Picasso並無中止持續創新的腳步,目前Picasso在Web端和微信小程序端的適配工做已經有了突破性進展,正如Picasso在移動端取得的成就同樣,Picasso會在完成四端統一(Android、iOS、Web、小程序)的同時,構建出更快、更強的大前端實踐。

業界對大前端融合的將來有不少想象和憧憬,Picasso動態化實踐已經開啓大前端將來的一種新的可能。

 

美團客戶端響應式框架 EasyReact 開源啦

EasyReact

EasyReact

 

前言

EasyReact 是一款基於響應式編程範式的客戶端開發框架,開發者能夠使用此框架輕鬆地解決客戶端的異步問題。

目前 EasyReact 已在美團和大衆點評客戶端的部分業務中實踐,而且持續迭代了一年多的時間。近日,咱們決定開源這個項目的 iOS Objective-C 語言部分,但願可以幫助更多的開發者不斷探索更普遍的業務場景,也歡迎更多的社區的開發者跟咱們一塊兒增強 EasyReact 的功能。Github 的項目地址,參見 https://github.com/meituan-dianping/EasyReact

背景

美團 iOS 客戶端團隊在業界比較早地使用響應式來解決項目問題,爲此咱們引入了 ReactiveCocoa 這個函數響應式框架(相關實踐,參考以前的 系列博客)。隨着業務的急速擴張和團隊拆分變動,ReactiveCocoa 在解決異步問題的同時也帶來了新的挑戰,總結起來有如下幾點:

  1. 高學習門檻
  2. 易出錯
  3. 調試困難
  4. 風格不統一

既然響應式編程帶來了這麼多的麻煩,是否咱們應該摒棄響應式編程,用更通俗易懂的面向對象編程來解決問題呢?這要從移動端開發的特色提及。

移動端開發特色

客戶端程序自己充滿異步的場景。客戶端的主要邏輯就是從視圖中處理控件事件,經過網絡獲取後端內容再展現到視圖上。這其中事件的處理和網絡的處理都是異步行爲。

通常客戶端程序發起網絡請求後程序會異步的繼續執行,等待網絡資源的獲取。一般咱們還會須要設置必定的標誌位和顯示一些加載指示器來讓視圖進行等待。可是當網絡進行獲取的時候,通知、UI 事件、定時器都對狀態產生改變就會致使狀態的錯亂。咱們是否也遇到過:忙碌指示器沒有正確隱藏掉,頁面的顯示的字段被錯誤的顯示成舊的值,甚至一個頁面幾個部分信息不一樣步的狀況?

單個的問題看似簡單,可是客戶端飛速發展的今年,不少公司包括美團在內的客戶端代碼行數早已突破百萬。業務邏輯愈發複雜,使得維護狀態自己就成了一個大問題。響應式編程正是解決這個問題的一種手段。

響應式編程的相關概念

響應式編程是基於數據流動編程的一種編程範式。作過 iOS 客戶端開發的同窗必定了解過 KVO 這一系列的 API。

KVO 幫助咱們將屬性的變動和變動後的處理分離開,大大簡化了咱們的更新邏輯。響應式編程將這一優點體現得更加淋漓盡致,能夠簡單的理解成一個對象的屬性改變後,另一連串對象的屬性都隨之發生改變。

響應式的最簡單例子莫過於電子表格,Excel 和 Numbers 中單元格公式就是一個響應的例子。咱們只須要關心單元格和單元格的關係,而不須要關心當一個單元格發生變化,另外的單元格須要進行怎樣的處理。「程序」的書寫被提早到事件發生以前,因此響應式編程是一種聲明式編程。它幫助咱們將更多的精力集中在描述數據流動的關係上,而不是關注數據變化時處理的動做。

單純的響應式編程,好比電子表格中的公式和 KVO 是比較容易理解的,可是爲了在 Objective-C 語言中支持響應式特性,ReactiveCocoa 使用了函數響應式編程的手段實現了響應式編程框架。而函數式編程正是形成你們學習路徑陡峭的主要緣由。在函數式編程的世界中, 一切又都複雜起來了。這些複雜的概念,如 Immutable、Side effect、High-order Function、Functor、Applicative、Monad 等等,讓不少開發者望而卻步。

防不勝防的錯誤

函數式編程主要使用高階函數來解決問題,映射到 Objective-C 語言中就是使用 Block 來進行主要的處理。因爲 Objective-C 使用自動引用計數(ARC)來管理內存,一旦出現循環引用,就須要程序員主動破除循環引用。而 Block 閉包捕獲變量最容易造成循環引用。無腦的 weakify-strongify 會引發提前釋放,而無腦的不使用 weakify-strongify 則會引發循環引用。即使是「老手」在使用的過程當中,也不免出錯。

另外,ReactiveCocoa 框架爲了方便開發者更快的使用響應式編程,Hook 了不少 Cocoa 框架中的功能,例如 KVO、Notification Center、Perform Selector。一旦其它框架在 Hook 的過程當中與之造成衝突,後續問題的排查就變得十分困難。

調試的困難性

函數響應式編程使用高階函數還帶來了另一個問題,那就是大量的嵌套閉包函數致使的調用棧深度問題。在 ReactiveCocoa 2.5 版本中,進行簡單的 5 次變換,其調用棧深度甚至達到了 50 層(見下圖)。

ReactiveCocoa 的調用棧

ReactiveCocoa 的調用棧

 

仔細觀察調用棧,咱們發現整個調用棧的內容極爲類似,難以從中發現問題所在。

另外異步場景更是給調試增長了新的難度。不少時候,數據的變化是由其餘隊列派發過來的,咱們甚至沒法在調用棧中追溯數據變化的來源。

風格差別化

業內不少人使用 FRP 框架來解決 MVVM 架構中的綁定問題。在業務實踐中不少操做是高度類似且可被泛化的,這也意味着,能夠被腳手架工具自動生成。

但目前業內知名的框架並無提供相應的工具,最佳實踐也沒法「模板化」地傳遞下去。這就致使了對於 MVVM 和響應式編程,你們有了各自不一樣的理解。

EasyReact的初心

EasyReact 的誕生,其初心是爲了解決 iOS 工程實現 MVVM 架構但沒有對應的框架支撐,而致使的風格不統1、可維護性差、開發效率低等多種問題。而 MVVM 中最重要的一個功能就是綁定,EasyReact 就是爲了讓綁定和響應式的代碼變得 Easy 起來。

它的目標就是讓開發者可以簡單的理解響應式編程,而且簡單的將響應式編程的優點利用起來。

EasyReact 依賴庫介紹

EasyReact 先是基於 Objective-C 開發。而 Objective-C 是一門古老的編程語言,在 2014 年蘋果公司推出 Swift 編程語言以後,Objective-C 已經基本再也不更新,而 Swift支持的 Tuple 類型和集合類型自帶的 mapfilter 等方法會讓代碼更清晰易讀。 在 EasyReact Objective-C 版本的開發中,咱們還衍生了一些周邊庫以支持這些新的代碼技巧和語法糖。這些周邊庫現已開源,而且能夠獨立於 EasyReact 使用。

EasyTuple

EasyTuple

 

EasyTuple 使用宏構造出相似 Swift 的 Tuple 語法。使用 Tuple 能夠讓你在須要傳遞一個簡單的數據架構的時,沒必要手動爲其建立對應的類,輕鬆的交給框架解決。

EasySequence

EasySequence

 

EasySequence 是一個給集合類型擴展的庫,能夠清晰的表達對一個集合類型的迭代操做,而且經過巧妙的手法能夠讓這些迭代操做使用鏈式語法拼接起來。同時 EasySequence 也提供了一系列的 線程安全 和 weak 內存管理的集合類型用以補充系統容器沒法提供的功能。

EasyFoundation

EasyFoundation

 

EasyFoundation 是上述 EasyTuple 和 EasySequence 以及將來底層依賴庫的一個統一封裝。

用 EasyReact 解決以前的問題

EasyReact 因業務的須要而誕生,首要的任務就是解決業務中出現的那幾點問題。咱們來看看建設至今,那幾個問題是否已經解決:

響應式編程的學習門檻

前面已經分析過,單純的響應式編程並非特別的難以理解,而函數式編程纔是形成高學習門檻的緣由。所以 EasyReact 採用你們都熟知的面向對象編程進行設計, 想要了解代碼,相對於函數式編程變得容易不少。

另外響應式編程基於數據流動,流動就會產生一個有向的流動網絡圖。在函數式編程中,網絡圖是使用閉包捕獲來創建的,這樣作很是不利於圖的查找和遍歷。而 EasyReact 選擇在框架中使用圖的數據結構,將數據流動的有向網絡圖抽象成有向有環圖的節點和邊。這樣使得框架在運行過程當中能夠隨時查詢到節點和邊的關係,詳細內容能夠參見 框架概述

另外對於已經熟悉了 ReactiveCocoa 的同窗來講,咱們也在數據的流動操做上基本實現了 ReactiveCocoa API。詳細內容能夠參見 基本操做。更多的功能能夠向咱們提功能的 ISSUE,也歡迎你們可以提 Pull Request 來共同建設 EasyReact。

避免不經意的錯誤

前面提到過 ReactiveCocoa 易形成循環引用或者提前釋放的問題,那 EasyReact 是怎樣解決這個問題的呢?由於 EasyReact 中的節點和邊以及監聽者都不是使用閉包來進行捕獲,因此刨除轉換和訂閱中存在的反作用(轉換 block 或者訂閱 block 中致使的閉包捕獲),EasyReact 是能夠自動管理內存的。詳細內容能夠參見 內存管理

除了內存問題,ReactiveCocoa 中的 Hook Cocoa 框架問題,在 EasyReact 上經過規避手段來進行處理。EasyReact 在整個計劃中只是用來完成最基本的數據流驅動的部分,因此自己與 Cocoa 和 CocoaTouch 框架無關,必定程度上避免了與系統 API 和其餘庫 Hook 形成衝突。這並非指 Easy 系列不去解決相應的部分,而是 Easy 系列但願以更規範和加以約束的方式來解決相同問題,後續 Easy 系列的其餘開源項目中會有更多這些特定需求的解決方案。

EasyReact 的調試

EasyReact 利用對象的持有關係和方法調用來實現響應式中的數據流動,因此可方便的在調用棧信息中找出數據的傳遞關係。在 EasyReact 中,進行與前面 ReactiveCocoa 一樣的 5 次簡單變換,其調用棧只有 15 層(見下圖)。

EasyReact 的調用棧

EasyReact 的調用棧

 

通過觀察不難發現,調用棧的順序剛好就是變換的行爲。這是由於咱們將每種操做定義成一個邊的類型,使得調用棧能夠經過類名進行簡單的分析。

爲了方便調試,咱們提供了一個 - [EZRNode graph] 方法。任意一個節點調用這個方法均可以獲得一段 GraphViz 程序的 DotDSL 描述字符串,開發者能夠經過 GraphViz 工具觀察節點的關係,更好的排查問題。

使用方式以下:

  1. macOS 安裝 GraphViz 工具 brew install graphviz

  2. 打印 -[EZRNode graph] 返回的字符串或者 Debug 期間在 lldb 調用 -[EZRNode graph] 獲取結果字符串,並輸出保存至文件如 test.dot

  3. 使用工具分析生成圖像 circo -Tpdf test.dot -o test.pdf && open test.pdf

結果示例:

節點靜態圖

節點靜態圖

 

另外咱們還開發了一個帶有錄屏而且能夠動態查看應用程序中全部節點和邊的調試工具,後期也會開源。開發中的工具是這樣的:

節點動態圖

節點動態圖

 

響應式編程風格上的統一

EasyReact 幫助咱們解決了很多難題,遺憾的是它也不是「銀彈」。在實際的項目實施中,咱們發現僅僅經過 EasyReact 仍然很難讓你們在開發的過程當中風格上統一塊兒來。固然它從寫法上要比 ReactiveCocoa 上統一了一些,可是構建數據流仍然有着多種多樣的方式。

因此咱們想到經過一個上層的業務框架來統一風格,這也就是後續衍生項目 EasyMVVM 誕生的緣由,不久後咱們也會將 EasyMVVM 進行開源。

EasyReact 和其餘框架的對比

EasyReact 從誕生之初,就不可避免要和已有的其餘響應式編程框架作對比。下表對幾大響應式框架進行了一個大概的對比:

項目 EasyReact ReactiveCocoa ReactiveX
核心概念 圖論和麪向對象編程 函數式編程 函數式編程和泛型編程
傳播可變性
基本變換
組合變換
高階變換
遍歷節點 / 信號
多語言支持 Objective-C (其餘語言開源計劃中) Objective-C、Swift 大量語言
性能 較快
中文文檔支持
調試工具 靜態拓撲圖展現和動態調試工具(開源計劃中) Instrument

性能方面,咱們也和一樣是 Objective-C 語言的 ReactiveCocoa 2.5 版本作了相應的 Benchmark。

測試環境

編譯平臺: macOS High Sierra 10.13.5

IDE: Xcode 9.4.1

真機設備: iPhone X 256G iOS 11.4(15F79)

測試對象

  1. listener、map、filter、flattenMap 等單階操做
  2. combine、zip、merge 等多點聚合操做
  3. 同步操做

其中測試的規模爲:

  • 節點或信號個數 10 個
  • 觸發操做次數 1000 次

例如 Listener 方法有 10 個監聽者,重複發送值 1000 次。

統計時間單位爲 ns。

測試數據

重複上面的實驗 10 次,獲得數據平均值以下:

name listener map filter flattenMap combine zip merge syncWith
EasyReact 1860665 30285707 7043007 7259761 6234540 63384482 19794457 12359669
ReactiveCocoa 4054261 74416369 45095903 44675757 209096028 143311669 13898969 53619799
RAC:EasyReact 217.89% 245.71% 640.29% 615.39% 3353.83% 226.10% 70.22% 433.83%

性能測試結果

性能測試結果

 

結果總結

ReactiveCocoa 平均耗時是 EasyReact 的 725.41%。

EasyReact 的 Swift 版本即將開源,屆時會和 RxSwift 進行 Benchmark 比較。

EasyReact的最佳實踐

一般咱們建立一個類,裏面會包含不少的屬性。在使用 EasyReact 時,咱們一般會把這些屬性包裝爲 EZRNode 並加上一個泛型。如:

// SearchService.h #import <Foundation/Foundation.h> #import <EasyReact/EasyReact.h> @interface SearchService : NSObject @property (nonatomic, readonly, strong) EZRMutableNode<NSString *> *param; @property (nonatomic, readonly, strong) EZRNode<NSDictionary *> *result; @property (nonatomic, readonly, strong) EZRNode<NSError *> *error; @end 

這段代碼展現瞭如何建立一個 WiKi 查詢服務,該服務接收一個 param 參數,查詢後會返回 result 或者 error。如下是實現部分:

// SearchService.m @implementation SearchService - (instancetype)init { if (self = [super init]) { _param = [EZRMutableNode new]; EZRNode *resultNode = [_param flattenMap:^EZRNode * _Nullable(NSString * _Nullable searchParam) { NSString *queryKeyWord = [searchParam stringByAddingPercentEncodingWithAllowedCharacters:[NSCharacterSet URLQueryAllowedCharacterSet]]; NSURL *url = [NSURL URLWithString:[NSString stringWithFormat:@"https://en.wikipedia.org/w/api.php?action=query&titles=%@&prop=revisions&rvprop=content&format=json&formatversion=2", queryKeyWord]]; EZRMutableNode *returnedNode = [EZRMutableNode new]; [[NSURLSession sharedSession] dataTaskWithURL:url completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) { if (error) { returnedNode.value = error; } else { NSError *serializationError; NSDictionary *resultDictionary = [NSJSONSerialization JSONObjectWithData:data options:0 error:&serializationError]; if (serializationError) { returnedNode.value = serializationError; } else if (!([resultDictionary[@"query"][@"pages"] count] && !resultDictionary[@"query"][@"pages"][0][@"missing"])) { NSError *notFoundError = [NSError errorWithDomain:@"com.example.service.wiki" code:100 userInfo:@{NSLocalizedDescriptionKey: [NSString stringWithFormat:@"keyword '%@' not found.", searchParam]}]; returnedNode.value = notFoundError; } else { returnedNode.value = resultDictionary; } } }]; return returnedNode; }]; EZRIFResult *resultAnalysedNode = [resultNode if:^BOOL(id _Nullable next) { return [next isKindOfClass:NSDictionary.class]; }]; _result = resultAnalysedNode.thenNode; _error = resultAnalysedNode.elseNode; } return self; } @end 

在調用時,咱們只須要經過 listenedBy 方法關注節點的變化:

self.service = [SearchService new]; [[self.service.result listenedBy:self] withBlock:^(NSDictionary * _Nullable next) { NSLog(@"Result: %@", next); }]; [[self.service.error listenedBy:self] withBlock:^(NSError * _Nullable next) { NSLog(@"Error: %@", next); }]; self.service.param.value = @"mipmap"; //should print search result self.service.param.value = @"420v"; // should print error, keyword not found. 

使用 EasyReact 後,網絡請求的參數、結果和錯誤能夠很好地被分離。不須要像命令式的寫法那樣在網絡請求返回的回調中寫一堆判斷來分離結果和錯誤。

由於節點的存在先於結果,咱們能對暫時尚未獲得的結果構建鏈接關係,完成整個響應鏈的構建。響應鏈構建以後,一旦有了數據,數據便會自動按照咱們預期的構建來傳遞。

在這個例子中,咱們不須要顯式地來調用網絡請求,只須要給響應鏈中的 param 節點賦值,框架就會主動觸發網絡請求,而且請求完成以後會根據網絡返回結果來分離出 result 和 error 供上層業務直接使用。

對於開源,咱們是認真的

EasyReact 項目自立項以來,就勵志打形成一個通用的框架,團隊也一直以開源的高標準要求本身。整個開發的過程當中咱們始終保證測試覆蓋率在一個高的標準上,對於接口的設計也力求完美。在開源的流程,咱們也學習借鑑了 Github 上大量優秀的開源項目,在流程、文檔、規範上力求標準化、國際化。

文檔

除了 中文 README 和 英文 README 之外,咱們還提供了中文的說明性質文檔:

和英文的說明性質文檔:

後續幫助理解的文章,也會陸續上傳到項目中供你們學習。

另外也爲開源的貢獻提供了標準的 中文貢獻流程 和 英文貢獻流程,其中對於 ISSUE 模板、Commit 模板、Pull Requests 模板和 Apache 協議頭均有說起。

若是你仍然對 EasyReact 有所不解或者流程代碼上有任何問題,能夠隨時經過提 ISSUE 的方式與咱們聯繫,咱們都會盡快答覆。

行爲驅動開發

爲了保證 EasyReact 的質量,咱們在開發的過程當中使用 行爲驅動開發。當每一個新功能的聲明部分肯定後,咱們會先編寫大量的測試用例,這些用例模擬使用者的行爲。經過模擬使用者的行爲,以更加接近使用者的想法,去設計這個新功能的 API。同時大量的測試用例也保證了新的功能完成之時,必定是穩定的。

測試覆蓋率

EasyReact 系列立項之時,就以高質量、高標準的開發原則來要求開發組成員執行。開源以後全部項目使用 codecov.io 服務生成對應的測試覆蓋率報告,Easy 系列的框架覆蓋率均保證在 95% 以上。

name listener
EasyReact
EasyTuple
EasySequence
EasyFoundation

持續集成

爲了保證項目質量,全部的 Easy 系列框架都配有持續集成工具 Travis CI。它確保了每一次提交,每一次 Pull Request 都是可靠的。

展望

目前開源的框架組件只是創建起響應式編程的基石,Easy 系列的初心是爲 MVVM 架構提供一個強有力的框架工具。下圖是 Easy 系列框架的架構簡圖:

Archticture

Archticture

 

將來開源計劃

將來咱們還有提供更多框架能力,開源給你們:

名稱 描述
EasyDebugToolBox 動態節點狀態調試工具
EasyOperation 基於行爲和操做抽象的響應式庫
EasyNetwork 響應式的網絡訪問庫
EasyMVVM MVVM 框架標準和相關工具
EasyMVVMCLI EasyMVVM 項目腳手架工具

跨平臺與多語言

EasyReact 的設計基於面向對象,因此很容易在各個語言中實現,咱們也正在積極的在 Swift、Java、JavaScript 等主力語言中實現 EasyReact。

另外動態化做爲目前行業的趨勢,Easy 系列天然不會忽視。在 EasyReact 基於圖的架構下,咱們能夠很輕鬆的讓一個 Objective-C 的上游節點經過一個特殊的橋接邊鏈接到一個 JavaScript 節點,這樣就能夠讓部分的邏輯動態下發過來。

結語

數據傳遞和異步處理,是大部分業務的核心。EasyReact 從架構上用響應式的方式來很好的解決了這個問題。它有效地組織了數據和數據之間的聯繫, 讓業務的處理流程從命令式編程方式,變成以數據流爲核心的響應式編程方式。用先構建數據流關係再響應觸發的方法,讓業務方更關心業務的本質。使廣大開發者從瑣碎的命令式編程的狀態處理中解放出來,提升了生產力。EasyReact 不只讓業務邏輯代碼更容易維護,也讓出錯的概率大大降低。

 

Logan:美團點評的開源移動端基礎日誌庫

前言

Logan是美團點評集團移動端基礎日誌組件,這個名稱是Log和An的組合,表明個體日誌服務。同時Logan也是「金剛狼」大叔的名號,固然咱們更但願這個產品能像金剛狼大叔同樣犀利。

Logan已經穩定迭代了一年多的時間。目前美團點評絕大多數App已經接入並使用Logan進行日誌收集、上傳、分析。近日,咱們決定開源Logan生態體系中的存儲SDK部分(Android/iOS),但願可以幫助更多開發者合理的解決移動端日誌存儲收集的相關痛點,也歡迎更多社區的開發者和咱們一塊兒共建Logan生態。Github的項目地址參見:https://github.com/Meituan-Dianping/Logan

背景

隨着業務的不斷擴張,移動端的日誌也會不斷增多。但業界對移動端日誌並無造成相對成體系的處理方式,在大多數狀況下,仍是針對不一樣的日誌進行單一化的處理,而後結合這些日誌處理的結果再來定位問題。然而,當用戶達到必定量級以後,不少「疑難雜症」卻沒法經過以前的定位問題的方式來進行解決。移動端開發者最頭疼的事情就是「爲何我使用和用戶如出一轍的手機,如出一轍的系統版本,仿照用戶的操做卻復現不出Bug」。特別是對於Android開發者來講,手機型號、系統版本、網絡環境等都很是複雜,即便拿到了如出一轍的手機也復現不出Bug,這並不奇怪,固然不少時候並不能徹底拿到真正徹底如出一轍的手機。相信不少同窗見到下面這一幕都似曾相識:

用(lao)戶(ban):我發現咱們App的XX頁面打不開了,UI展現不出來,你來跟進一下這個問題。

你:好的。

因而,咱們檢查了用戶反饋的機型和系統版本,而後找了一臺同型號同版本的手機,試着復現卻發現一切正常。咱們又給用戶打個電話,問問他究竟是怎麼操做的,再問問網絡環境,繼續嘗試復現依舊未果。最後,咱們查了一下Crash日誌,網絡日誌,再看看埋點日誌(發現還沒報上來)。

你心裏OS:奇怪了,也沒產生Crash,網絡也是通的,可是爲何UI展現不出來呢?

幾個小時後……

用(lao)戶(ban):這問題有結果了嗎?

你:我用了各類辦法復現不出來……暫時查不到是什麼緣由致使的這個問題。

用(lao)戶(ban):那怪我咯?

你:……

若是把一次Bug的產生看做是一次「兇案現場」,開發者就是破案的「偵探」。案發以後,偵探須要經過各類手段蒐集線索,推理出犯案過程。這就比如開發者須要經過查詢各類日誌,分析這段時間App在用戶手機裏都經歷了什麼。通常來講,傳統的日誌蒐集方法存在如下缺陷:

  • 日誌上報不及時。因爲日誌上報須要網絡請求,對於移動App來講頻繁網絡請求會比較耗電,因此日誌SDK通常會積累到必定程度或者必定時間後再上報一次。
  • 上報的信息有限。因爲日誌上報網絡請求的頻次相對較高,爲了節省用戶流量,日誌一般不會太大。尤爲是網絡日誌等這種實時性較高的日誌。
  • 日誌孤島。不一樣類型的日誌上報到不一樣的日誌系統中,相對孤立。
  • 日誌不全。日誌種類愈來愈多,有些日誌SDK會對上報日誌進行採樣。

面臨挑戰

美團點評集團內部,移動端日誌種類已經超過20種,並且隨着業務的不斷擴張,這一數字還在持續增長。特別是上文中提到的三個缺陷,也會被無限地進行放大。

查問題是個苦力活,不必定全部的日誌都上報在一個系統裏,對於開發者來講,可能須要在多個系統中查看不一樣種類的日誌,這大大增長了開發者定位問題的成本。若是咱們天天上班都看着疑難Bug掛着沒法解決,確實會很難受。這就像一個偵探遇到了疑難的案件,當他用盡各類手段收集線索,依然一無所得,那種心情可想而知。咱們收集日誌復現用戶Bug的思路和偵探破案的思路很是類似,經過蒐集的線索儘量拼湊出相對完整的犯案場景。若是按照這個思路想下去,目前咱們並無什麼更好的方法來處理這些問題。

不過,雖然偵探破案和開發者查日誌解決問題的思路很像,但實質並不同。咱們處理的是Bug,不是真實的案件。換句話說,由於咱們的「死者」是可見的,那麼就能夠從它身上獲取更多信息,甚至和它進行一次「靈魂的交流」。換個思路想,以往的操做都是經過各類各樣的日誌拼湊出用戶出現Bug的場景,那可不能夠先獲取到用戶在發生Bug的這段時間產生的全部日誌(不採樣,內容更詳細),而後聚合這些日誌分析出(篩除無關項)用戶出現Bug的場景呢?

個案分析

新的思路重心從「日誌」變爲「用戶」,咱們稱之爲「個案分析」。簡單來講,傳統的思路是經過蒐集散落在各系統的日誌,而後拼湊出問題出現的場景,而新的思路是從用戶產生的全部日誌中聚合分析,尋找出現問題的場景。爲此,咱們進行了技術層面的嘗試,而新的方案須要在功能上知足如下條件:

  • 支持多種日誌收集,統一底層日誌協議,抹平日誌種類帶來的差別。
  • 日誌本地記錄,在須要時上報,儘量保證日誌不丟失。
  • 日誌內容要儘量詳細,不採樣。
  • 日誌類型可擴展,可由上層自定義。

咱們還須要在技術上知足如下條件:

  • 輕量級,包體儘可能小
  • API易用
  • 沒有侵入性
  • 高性能

最佳實踐

在這種背景下,Logan橫空出世,其核心體系由四大模塊構成:

  • 日誌輸入
  • 日誌存儲
  • 後端系統
  • 前端系統

日誌輸入

常見的日誌類型有:代碼級日誌、網絡日誌、用戶行爲日誌、崩潰日誌、H5日誌等。這些都是Logan的輸入層,在不影響原日誌體系功能的狀況下,可將內容往Logan中存儲一份。Logan的優點在於:日誌內容能夠更加豐富,寫入時能夠攜帶更多信息,也沒有日誌採樣,只會等待合適的時機進行統一上報,可以節省用戶的流量和電量。

以網絡日誌爲例,正常狀況下網絡日誌只記錄端到端延時、發包大小、回包大小字段等等,同時存在採樣。而在Logan中網絡日誌不會被採樣,除了上述內容還能夠記錄請求Headers、回包Headers、原始Url等信息。

日誌存儲

Logan存儲SDK是這個開源項目的重點,它解決了業界內大多數移動端日誌庫存在的幾個缺陷:

  • 卡頓,影響性能
  • 日誌丟失
  • 安全性
  • 日誌分散

Logan自研的日誌協議解決了日誌本地聚合存儲的問題,採用「先壓縮再加密」的順序,使用流式的加密和壓縮,避免了CPU峯值,同時減小了CPU使用。跨平臺C庫提供了日誌協議數據的格式化處理,針對大日誌的分片處理,引入了MMAP機制解決了日誌丟失問題,使用AES進行日誌加密確保日誌安全性。Logan核心邏輯都在C層完成,提供了跨平臺支持的能力,在解決痛點問題的同時,也大大提高了性能。

爲了節約用戶手機空間大小,日誌文件只保留最近7天的日誌,過時會自動刪除。在Android設備上Logan將日誌保存在沙盒中,保證了日誌文件的安全性。

詳情請參考:美團點評移動端基礎日誌庫——Logan

後端系統

後端是接收和處理數據中心,至關於Logan的大腦。主要有四個功能:

  • 接收日誌
  • 日誌解析歸檔
  • 日誌分析
  • 數據平臺

接收日誌

客戶端有兩種日誌上報的形式:主動上報和回撈上報。主動上報能夠經過客服引導用戶上報,也能夠進行預埋,在特定行爲發生時進行上報(例如用戶投訴)。回撈上報是由後端向客戶端發起回撈指令,這裏再也不贅述。全部日誌上報都由Logan後端進行接收。

日誌解析歸檔

客戶端上報的日誌通過加密和壓縮處理,後端須要對數據解密、解壓還原,繼而對數據結構化歸檔存儲。

日誌分析

不一樣類型日誌由不一樣的字段組合而成,攜帶着各自特有信息。網絡日誌有請求接口名稱、端到端延時、發包大小、請求Headers等信息,用戶行爲日誌有打開頁面、點擊事件等信息。對全部的各種型日誌進行分析,把獲得的信息串連起來,最終聚集造成一個完整的我的日誌。

數據平臺

數據平臺是前端系統及第三方平臺的數據來源,由於我的日誌屬於機密數據,因此數據獲取有着嚴格的權限審覈流程。同時數據平臺會收集過往的Case,抽取其問題特徵記錄解決方案,爲新Case提供建議。

前端系統

一個優秀的前端分析系統能夠快速定位問題,提升效率。研發人員經過Logan前端系統搜索日誌,進入日誌詳情頁查看具體內容,從而定位問題,解決問題。

目前集團內部的Logan前端日誌詳情頁已經具有如下功能:

  • 日誌可視化。全部的日誌都通過結構化處理後,按照時間順序展現。
  • 時間軸。數據可視化,利用圖形方式進行語義分析。
  • 日誌搜索。快速定位到相關日誌內容。
  • 日誌篩選。支持多類型日誌,可選擇須要分析的日誌。
  • 日誌分享。分享單條日誌後,點開分享連接自動定位到分享的日誌位置。

Logan對日誌進行數據可視化時,嘗試利用圖形方式進行語義分析簡稱爲時間軸。

每行表明着一種日誌類型。同一日誌類型有着多種圖形、顏色,他們標識着不一樣的語義。

例如時間軸中對代碼級日誌進行了日誌類別的區分:

利用顏色差別,能夠輕鬆區分出錯誤的日誌,點擊紅點便可直接跳轉至錯誤日誌詳情。

個案分析流程

  • 用戶遇到問題聯繫客服反饋問題。
  • 客服收到用戶反饋。記錄Case,整理問題,同時引導用戶上報Logan日誌。
  • 研發同窗收到Case,查找Logan日誌,利用Logan系統完成日誌篩選、時間定位、時間軸等功能,分析日誌,進而還原Case「現場」。
  • 最後,結合代碼定位問題,修復問題,解決Case。

定位問題

結合用戶信息,經過Logan前端系統查找用戶的日誌。打開日誌詳情,首先使用時間定位功能,快速跳轉到出問題時的日誌,結合該日誌上下文,可獲得當時App運行狀況,大體推斷問題發生的緣由。接着利用日誌篩選功能,查找關鍵Log對可能出問題的地方逐一進行排查。最後結合代碼,定位問題。

固然,在實際上排查中問題比這複雜多,咱們要反覆查看日誌、查看代碼。這時還可能要藉助一下Logan高級功能,如時間軸,經過時間軸可快速找出現異常的日誌,點擊時間軸上的圖標可跳轉到日誌詳情。經過網絡日誌中的Trace信息,還能夠查看該請求在後臺服務詳細的響應棧狀況和後臺響應值。

將來規劃

  • 機器學習分析。首先收集過往的Case及解決方案,提取分析Case特徵,將Case結構化後入庫,而後經過機器學習快速分析上報的日誌,指出日誌中可能存在的問題,並給出解決方案建議;
  • 數據開放平臺。業務方能夠經過數據開放平臺獲取數據,再結合自身業務的特性研發出適合本身業務的工具、產品。

平臺支持

Platform iOS Android Web Mini Programs
Support

目前Logan SDK已經支持以上四個平臺,本次開源iOS和Android平臺,其餘平臺將來將會陸續進行開源,敬請期待。

測試覆蓋率

因爲Travis、Circle對Android NDK環境支持不夠友好,Logan爲了兼容較低版本的Android設備,目前對NDK的版本要求是16.1.4479499,因此咱們並無在Github倉庫中配置CI。開發者能夠本地運行測試用例,測試覆蓋率可達到80%或者更高。

開源計劃

在集團內部已經造成了以Logan爲中心的個案分析生態系統。本次開源的內容有iOS、Android客戶端模塊、數據解析簡易版,小程序版本、Web版本已經在開源的路上,後臺系統,前端系統也在咱們開源計劃之中。

將來咱們會提供基於Logan大數據的數據平臺,包含機器學習、疑難日誌解決方案、大數據特徵分析等高級功能。

最後,咱們但願提供更加完整的一體化個案分析生態系統,也歡迎你們給咱們提出建議,共建社區。

Module Open Source Processing Planning
iOS    
Android    
Web    
Mini Programs    
Back End    
Front End    

 

 

美團點評移動端基礎日誌庫——Logan

背景

對於移動應用來講,日誌庫是必不可少的基礎設施,美團點評集團旗下移動應用天天產生的衆多種類的日誌數據已經達到幾十億量級。爲了解決日誌模塊廣泛存在的效率、安全性、丟失日誌等問題,Logan基礎日誌庫應運而生。

現存問題

目前,業內移動端日誌庫大多都存在如下幾個問題:

  • 卡頓,影響性能
  • 日誌丟失
  • 安全性
  • 日誌分散

首先,日誌模塊做爲底層的基礎庫,對上層的性能影響必須儘可能小,可是日誌的寫操做是很是高頻的,頻繁在Java堆裏操做數據容易致使GC的發生,從而引發應用卡頓,而頻繁的I/O操做也很容易致使CPU佔用太高,甚至出現CPU峯值,從而影響應用性能。

其次,日誌丟失的場景也很常見,例如當用戶的App發生了崩潰,崩潰日誌還來不及寫入文件,程序就退出了,但本次崩潰產生的日誌就會丟失。對於開發者來講,這種狀況是很是致命的,由於這類日誌丟失,意味着沒法復現用戶的崩潰場景,不少問題依然得不到解決。

第三點,日誌的安全性也是相當重要的,絕對不能隨意被破解成明文,也要防止網絡被劫持致使的日誌泄漏。

最後一點,對於移動應用來講,日誌確定不止一種,通常會包含端到端日誌1、代碼日誌、崩潰日誌、埋點日誌這幾種,甚至會更多。不一樣種類的日誌都具備各自的特色,會致使日誌比較分散,查一個問題須要在各個不一樣的日誌平臺查不一樣的日誌,例如端到端日誌還存在日誌採樣,這無疑增長了開發者定位問題的成本。

面對美團點評幾十億量級的移動端日誌處理場景,這些問題會被無限放大,最終可能致使日誌模塊不穩定、不可用。然而,Logan應運而生,漂亮地解決了上述問題。

簡介

Logan,名稱是Log和An的組合,表明個體日誌服務的意思,同時也是金剛狼大叔的大名。通俗點說,Logan是美團點評移動端底層的基礎日誌庫,能夠在本地存儲各類類型的日誌,在須要時能夠對數據進行回撈和分析。

Logan具有兩個核心能力:本地存儲和日誌撈取。做爲基礎日誌庫,Logan已經接入了集團衆多日誌系統,例如端到端日誌、用戶行爲日誌、代碼級日誌、崩潰日誌等。做爲移動應用的幕後英雄,Logan天天都會處理幾十億量級的移動端日誌。

設計

做爲一款基礎日誌庫,在設計之初就必須考慮如何解決日誌系統現存的一些問題。

卡頓,影響性能

I/O是比較耗性能的操做,寫日誌須要大量的I/O操做,爲了提高性能,首先要減小I/O操做,最有效的措施就是加緩存。先把日誌緩存到內存中,達到必定大小的時候再寫入文件。爲了減小寫入本地的日誌大小,須要對數據進行壓縮,爲了加強日誌的安全性,須要對日誌進行加密。然而這樣作的弊端是:

  • 對Android來講,對日誌加密壓縮等操做所有在Java堆裏面。因爲日誌寫入是一個高頻的動做,頻繁地堆內存操做,容易引起Java的GC,致使應用卡頓;
  • 集中壓縮會致使CPU短期飆高,出現峯值;
  • 因爲日誌是內存緩存,在殺進程、Crash的時候,容易丟失內存數據,從而致使日誌丟失。

Logan的解決方案是經過Native方式來實現日誌底層的核心邏輯,也就是C編寫底層庫。這樣作不光能解決Java GC問題,還作到了一份代碼運行在Android和iOS兩個平臺上。同時在C層實現流式的壓縮和加密數據,能夠減小CPU峯值,使程序運行更加順滑。並且先壓縮再加密的方式壓縮率比較高,總體效率較高,因此這個順序不能變。

日誌丟失

加緩存以後,異常退出丟失日誌的問題就必須解決,Logan爲此引入了MMAP機制。MMAP是一種內存映射文件的方法,即將一個文件或者其它對象映射到進程的地址空間,實現文件磁盤地址和進程虛擬地址空間中一段虛擬地址的一一對應關係。MMAP機制的優點是:

  • MMAP使用邏輯內存對磁盤文件進行映射,操做內存就至關於操做文件;
  • 通過測試發現,操做MMAP的速度和操做內存的速度同樣快,能夠用MMAP來作數據緩存;
  • MMAP將日誌回寫時機交給操做系統控制。如內存不足,進程退出的時候操做系統會自動回寫文件;
  • MMAP對文件的讀寫操做不須要頁緩存,只須要從磁盤到用戶主存的一次數據拷貝過程,減小了數據的拷貝次數,提升了文件讀寫效率。

引入MMAP機制以後,日誌丟失問題獲得了有效解決,同時也提高了性能。不過這種方式也不能百分百解決日誌丟失的問題,MMAP存在初始化失敗的狀況,這時候Logan會初始化堆內存來作日誌緩存。根據咱們統計的數據來看,MMAP初始化失敗的狀況僅佔0.002%,已是一個小几率事件了。

安全性

日誌文件的安全性必須獲得保障,不能隨意被破解,更不能明文存儲。Logan採用了流式加密的方式,使用對稱密鑰加密日誌數據,存儲到本地。同時在日誌上傳時,使用非對稱密鑰對對稱密鑰Key作加密上傳,防止密鑰Key被破解,從而在網絡層保證日誌安全。

日誌分散

針對日誌分散的狀況,爲了保證日誌全面,須要作本地聚合存儲。Logan採用了自研的日誌協議,對於不一樣種類的日誌都會按照Logan日誌協議進行格式化處理,存儲到本地。當須要上報的時候進行集中上報,經過Logan日誌協議進行反解,還原出不一樣日誌的本來面貌。同時Logan後臺提供了聚合展現的能力,全面展現日誌內容,根據協議綜合各類日誌進行分析,使用時間軸等方式展現不一樣種日誌的重要信息,使得開發者只須要經過Logan平臺就能夠查詢到某一段時間App到底產生了哪些日誌,能夠快速復現問題場景,定位問題並處理。

關於Logan平臺是如何展現日誌的,下文會再進行說明。

架構

首先,看一下Logan的總體架構圖:

Logan的總體架構圖

Logan的總體架構圖

 

Logan自研的日誌協議解決了日誌本地聚合存儲的問題,採用先壓縮再加密的順序,使用流式的加密和壓縮,避免了CPU峯值,同時減小了CPU使用。跨平臺C庫提供了日誌協議數據的格式化處理,針對大日誌的分片處理,引入了MMAP機制解決了日誌丟失問題,使用AES進行日誌加密確保日誌安全性,而且提供了主動上報接口。Logan核心邏輯都在C層完成,提供了跨平臺支持的能力,在解決痛點問題的同時,也大大提高了性能。

日誌分片

Logan做爲日誌底層庫,須要考慮上層傳入日誌過大的狀況。針對這樣的場景,Logan會作日誌分片處理。以20k大小作分片,每一個切片按照Logan的協議進行存儲,上報到Logan後臺的時候再作反解合併,恢復日誌原本的面貌。

那麼Logan是如何進行日誌寫入的呢?下圖爲Logan寫日誌的流程:

Logan寫日誌的流程

Logan寫日誌的流程

 

性能

爲了檢測Logan的性能優化效果,咱們專門寫了測試程序進行對比,讀取16000行的日誌文本,間隔3ms,依次調用寫日誌函數。

首先對比Java實現和C實現的內存情況:

Java:

C:

能夠看出Java實現寫日誌,GC頻繁,而C實現並不會出現這種狀況,由於它不會佔用Java的堆內存。那麼再對比一下Java實現和C實現的CPU使用狀況:

C實現沒有頻繁的GC,同時採用流式的壓縮和加密避免了集中壓縮加密可能產生的CPU峯值,因此CPU平均使用率會下降,如上圖所示。

特點功能

日誌回撈

開發者可能都會遇到相似的場景:某個用戶手機上裝了App,出現了崩潰或者其它問題,日誌還沒上報或者上報過程當中被網絡劫持發生日誌丟失,致使有些問題一直查不清緣由,或者無法及時定位到問題,影響處理進程。依託集團PushSDK強大的推送能力,Logan能夠確保用戶的本地日誌在發出撈取指令後及時上傳。經過網絡類型和日誌大小上限選擇,能夠爲用戶最大可能的節省移動流量。

回饋機制能夠確保撈取日誌任務的進度獲得實時展示。

日誌回撈平臺有着嚴格的審覈機制,確保開發者不會侵犯用戶隱私,只關注問題場景。

主動上報

Logan日誌回撈,依賴於Push透傳。客戶端被喚醒接收Push消息,受到一些條件影響:

  • Android想要後臺喚醒App,須要確保Push進程在後臺存活;
  • iOS想要後臺喚醒APP,須要確保用戶開啓後臺刷新開關;
  • 網絡環境太差,Android上Push長連創建不成功。

若是沒法喚醒App,只有在用戶再次進入App時,Push通道創建後才能收到推送消息,以上是致使Logan日誌回撈會有延遲或收不到的根本緣由,從分析能夠看出,Logan系統回撈的最大瓶頸在於Push系統。那麼可否拋開Push系統獲得Logan日誌呢?先來看一下使用日誌回撈方式的典型場景:

其中最大的障礙在於Push觸達用戶。那麼主動上報的設計思路是怎樣的呢?

經過在App中主動調用上報接口,用戶直接上報日誌的方式,稱之爲Logan的主動上報。主動上報的優點很是明顯,跳過了Push系統,讓用戶在須要的時候主動上報Logan日誌,開發者不再用爲不能及時撈到日誌而煩惱,在用戶投訴以前就已經拿到日誌,便於更高效地分析解決問題。

線上效果

Logan基礎日誌庫自2017年9月上線以來,運行很是穩定,大大提升了集團移動開發工程師分析日誌、定位線上問題的效率。

Logan平臺時間軸日誌展現:

Logan日誌聚合詳情展現:

做爲基礎日誌庫,Logan目前已經接入了集團衆多日誌系統:

  • CAT端到端日誌
  • 埋點日誌
  • 用戶行爲日誌
  • 代碼級日誌
  • 網絡內部日誌
  • Push日誌
  • Crash崩潰日誌

如今,Logan已經接入美團、大衆點評、美團外賣、貓眼等衆多App,日誌種類也更加豐富。

展望將來

H5 SDK

目前,Logan只有移動端版本,支持Android/iOS系統,暫不支持H5的日誌上報。對於純JS開發的頁面來講,一樣有日誌分散、問題場景復現困難等痛點,也迫切須要相似的日誌底層庫。咱們計劃統一H5和Native的日誌底層庫,包括日誌協議、聚合等,Logan的H5 SDK也在籌備中。

日誌分析

Logan平臺的日誌展現方式,咱們還在探索中。將來計劃對日誌作初步的機器分析與處理,能針對某些關鍵路徑給出一些分析結果,讓開發者更專一於業務問題的定位與分析,同時但願分析出用戶的行爲是否存在風險、惡意請求等。

思考題

本文給你們講述了美團點評移動端底層基礎日誌庫Logan的設計、架構與特點,Logan在解決了許多問題的同時,也會帶來新的問題。日誌文件不能無限大,目前Logan日誌文件最大限制爲10M,遇到大於10M的狀況,應該如何處理最佳?是丟掉前面的日誌,仍是丟掉追加的日誌,仍是作分片處理呢?這是一個值得深思的問題。

 

MCI:移動持續集成在大衆點評的實踐

1、背景

美團是全球最大的互聯網+生活服務平臺,爲3.2億活躍用戶和500多萬的優質商戶提供一個鏈接線上與線下的電子商務服務。秉承「幫你們吃得更好,生活更好」的使命,咱們的業務覆蓋了超過200個品類和2800個城區縣網絡,在餐飲、外賣、酒店旅遊、麗人、家庭、休閒娛樂等領域具備領先的市場地位。

隨着各業務的蓬勃發展,大衆點評移動研發團隊從當初各自爲戰的「小做坊」已經發展成爲能夠協同做戰的、擁有千人規模的「正規軍」。咱們的移動項目架構爲了適應業務發展也發生了天翻地覆的變化,這對移動持續集成提出更高的要求,而整個移動研發團隊也迎來了新的機遇和挑戰。

2、問題與挑戰

當前移動客戶端的組件庫超過600個,多個移動項目的代碼量達到百萬行級別,天天有幾百次的發版集成需求。保證近千名移動研發人員順利進行開發和集成,這是咱們部門的重要使命。可是,前進的道路歷來都不是平坦的,在通向目標的大道上,咱們還面臨着不少問題與挑戰,主要包括如下幾個方面:

項目依賴複雜

上圖僅僅展現了咱們移動項目中一小部分組件間的依賴關係,能夠想象一下,這600多個組件之間的依賴關係,就如同一個城市複雜的道路交通網讓人眼花繚亂。這種組件間錯綜複雜的依賴關係也必然會致使兩個嚴重的問題,第一,若是某個業務須要修改代碼,極有可能會影響到其它業務,牽一髮而動全身,進而會讓不少研發同窗工做時戰戰兢兢,作項目更加畏首畏尾;第二,管理這些組件間繁瑣的依賴關係也是一件使人頭疼的事情,如今平均每一個組件的依賴數有70多個,最多的甚至達到了270多個,若是依靠人工來維護這些依賴關係,難如登天。

研發流程瑣碎

移動研發要完成一個完整功能需求,除了代碼開發之外,須要經歷組件發版、組件集成、打包、測試。若是測試發現Bug須要進行修復,而後再次經歷組件發版、組件集成、打包、測試,直到測試經過交付產品。研發同窗在整個過程當中須要手動提交MR、手動升級組件、手動觸發打包以及人工實時監控流程的狀態,如此研發會被頻繁打斷來跟蹤處理過程的銜接,勢必嚴重影響開發專一度,下降研發生產力。

構建速度慢

目前大衆點評的iOS項目構建時間,從兩年前的20分鐘已經增加到如今的60分鐘以上,Android項目也從5分鐘增加到11分鐘,移動項目構建時間的增加,已經嚴重影響了移動端開發集成的效率。並且隨着業務的快速擴張,項目代碼還在持續不斷的增加。爲了適應業務的高速發展,尋求行之有效的方法來加快移動項目的構建速度,已經變得刻不容緩。

App質量保證

評價App的性能質量指標有不少,例如:CPU使用率、內存佔用、流量消耗、響應時間、線上Crash率、包體等等。其中線上Crash直接影響着用戶體驗,當用戶使用App時若是發生閃退,他們頗有可能會給出「一星」差評;而包體大小是影響新用戶下載App的重要因素,包體過大用戶頗有可能會對你的App失去興趣。所以,下降App線上Crash率以及控制App包體大小是每一個移動研發都要追求的重要目標。

項目依賴複雜、研發流程瑣碎、構建速度慢、App質量保證是每一個移動項目在團隊、業務發展壯大過程當中都會遇到的問題,本文將根據大衆點評移動端多年來積累的實踐經驗,一步步闡述咱們是如何在實戰中解決這些問題的。

3、MCI架構

MCI(Mobile continuous integration)是大衆點評移動端團隊多年來實踐總結出來的一套行之有效的架構體系。它能實際解決移動項目中依賴複雜、研發流程瑣碎、構建速度慢的問題,同時接入MCI架構體系的移動項目能真正有效實現App質量的提高。

MCI完整架構體系以下圖所示:

MCI架構體系包含移動CI平臺、流程自動化建設、靜態檢查體系、日誌監控&分析、信息管理配置,另外MCI還採起二進制集成等措施來提高MCI的構建速度。

構建移動CI平臺

咱們經過構建移動CI平臺,來保證移動研發在項目依賴極其複雜的狀況下,也能互不影響完成業務研發集成;其次咱們設計了合理的CI策略,來幫助移動研發人員走出使人望而生畏的依賴關係管理的「泥潭」。

流程自動化建設

在構建移動CI平臺的基礎上,咱們對MCI流程進行自動化建設來解決研發流程瑣碎問題,從而解放移動研發生產力。

提高構建速度

在CI平臺保證集成正確性的狀況下,咱們經過依賴扁平化以及優化集成方式等措施來提高MCI的構建速度,進一步提高研發效率。

靜態檢查體系

咱們創建一套完整自研的靜態檢查體系,針對移動項目的特色,MCI上線全方位的靜態檢查來促進App質量的提高。

日誌監控&分析

咱們對MCI體系的完整流程進行日誌落地,方便問題的追溯與排查,同時經過數據分析來進一步優化MCI的流程以及監控移動App項目的健康情況。

信息管理配置

最後,爲了方便管理接入MCI的移動項目,咱們建設了統一的項目信息管理配置平臺。

接下來,咱們將依次詳細探討MCI架構體系是如何一步步創建,進而解決咱們面臨的各類問題。

4、構建移動CI平臺

4.1 搭建移動CI平臺

咱們對目前業內流行的CI系統,如:Travis CI、 CircleCI、Jenkins、Gitlab CI調研後,針對移動項目的特色,綜合考慮代碼安全性、可擴展性及頁面可操做性,最終選擇基於Gitlab CI搭建移動持續集成平臺,固然咱們也使用Jenkins作一些輔助性的工做。MCI體系的CI核心架構以下圖所示:

名詞解釋:

  • Gitlab CI:Gitlab CI是GitLab Continuous Integration(Gitlab持續集成)的簡稱。
  • Runner:Runner是Gitlab CI提供註冊CI服務器的接口。
  • Pipeline:能夠理解爲流水線,包含CI不一樣階段的不一樣任務。
  • Trigger:觸發器,Push代碼或者提交Merge Request等操做會觸發相應的觸發器以進入下一流程。

該架構的優點是可擴展性強、可定製、支持併發。首先CI服務器能夠任意擴展,除了專用的服務器能夠做爲CI服務器,普通我的PC機也能夠做爲CI服務器(缺點是性能比服務器差,任務執行時間較長);其次每一個集成任務的Pipeline是支持可定製的,託管在MCI的集成項目能夠根據自身需求定製與之匹配的Pipeline;最後,每一個集成項目的任務執行是可併發的,所以各業務線間能夠互不干擾的進行組件代碼集成。

4.2 CI流程設計

一次完整的組件集成流程包含兩個階段:組件庫發版和向目標App工程集成。以下圖所示:

第一階段,在平常功能開發完畢後,研發提PR到指定分支,在對代碼進行Review、組件庫編譯及靜態檢查無誤後,自動發版進入組件池中。全部進入組件池中的組件都可以在不一樣App項目中複用。

第二階段,研發根據須要將組件合入指定App工程。組件A自己的正確性已經在第一階段的組件庫發版中驗證,第二階段是檢查組件A的改變是否對目標App中原有依賴它的其它組件形成影響。因此首先須要分析組件A被目標App中哪些組件所依賴,目標App工程按照各自的准入標準,對合入的組件庫進行編譯和靜態分析,待檢查無誤後,最終合入發佈分支。

經過組件發版和集成兩階段的CI流程,組件將被正確集成到目標項目中。而對於存在問題的組件則會阻擋在項目以外,所以不會影響其它業務的正常開發和發版集成,各業務研發流程獨立可控。

4.3 設計合理的CI策略

組件的發版和集成可否經過CI檢查,取決於組件當前的依賴以及組件自己是否與目標項目兼容。移動研發須要對組件當前依賴有足夠的瞭解才能順利完成發版集成,爲了減少組件依賴管理的複雜度,咱們設計了合理的發版集成策略來幫助移動研發走出繁瑣的版本依賴管理的困境。

組件集成策略

每一個組件都有本身的依賴項,不一樣組件可能會依賴同一個組件,組件向目標項目集成過程當中會面臨以下一些問題:

  • 版本集成衝突:組件在集成過程當中某個依賴項與目標項目中現有依賴的版本號存在衝突。
  • App測試包不穩定:組件依賴項的版本發生變化致使在不一樣時刻打出不一樣依賴項的App測試包。

頻繁的版本集成衝突會致使業務協同開發集成效率低下,App測試包的不穩定性會給研發追蹤問題帶來極大的困擾。問題的根源在於目標項目使用每一個組件的依賴項來進行集成。所以咱們經過在集成項目中顯示指定組件版本號以及禁止動態依賴的方式,保證了App測試包的穩定性和可靠性,同時也解決了組件版本集成衝突問題。

組件發版策略

組件向組件池發版也同樣會涉及依賴項的管理,簡單粗暴的方法是指定全部依賴項的版本號,這樣作的好處是直觀明瞭,但研發須要對不一樣版本依賴項的功能有足夠的瞭解。正如組件集成策略中所述,集成項目中每一個組件的版本都是顯示指定而且惟一肯定的,組件中指定依賴項的版本號在集成項目中並不起做用。因此咱們在組件發版時採用自動依賴組件池中最新版本的方式。這樣設計的好處在於:

  • 避免移動研發對版本依賴關係的處理。
  • 給基礎組件的變動迭代提供了強有力的推進機制。

當基礎組件庫的接口和設計發生較大變化時,能夠強有力的推進業務層組件作相應適配,保證了在高度解耦的項目架構下保持高度的敏捷性。但這種能力不能濫用,須要根據業務迭代週期合理安排,並作好提早通知動員工做。

5、流程自動化建設

研發流程瑣碎的主要緣由是研發須要人工參與持續集成中每一步過程,一旦咱們把移動研發從持續集成過程當中解放出來,天然就能提升研發生產力。咱們經過項目集成發佈流程自動化以及優化測試包分發來優化MCI流程。

項目集成流程託管

研發流程中的組件發版、組件集成與App打包都是持續集成中的標準化流程,咱們經過流程託管工具來完成這幾個步驟的自動銜接,研發同窗只需關注代碼開發與Bug修復。

流程託管工具實現方案以下:

  • 自動化流程執行:經過託管隊列實現任務自動化順序執行,webhook實現流程狀態的監聽。
  • 關鍵節點通知:在關鍵性節點流程執行成功後發送通知,讓研發對流程狀態瞭然於胸。
  • 流程異常通知:一旦持續集成流程執行異常,例如項目編譯失敗、靜態檢查沒經過等,第一時間通知研發及時處理。

打包發佈流程託管

不管iOS仍是Android,在發佈App包到市場前都須要作一系列處理,例如iOS須要導出ipa包進行備份,保存符號表來解析線上Crash,以及上傳ipa包到iTC(iTunes Connect);而Android除了包備份,保存Mapping文件解析線上Crash外,還要發佈App包到不一樣的渠道,整個打包發佈流程更加複雜繁瑣。

在沒有MCI流程託管之前,每到App發佈日,研發同窗就如臨大敵守在打包機器前,披荊斬棘,過五關斬六將,直到全部App包被「運送」到指定地點,搞得十分疲憊。如同項目集成流程託管同樣,咱們把整個打包發佈流程作了全流程託管,無人值守的自動打包發佈方式解放了研發同窗,研發同窗不再用每次都披星戴月,早出晚歸,跪鍵盤了(捂臉)。

包分發流程建設

對於QA和研發而言,上面的場景是否似曾相識。Bug是QA與研發之間溝通的橋樑,但因爲缺少統一的包管理和分發,這種模糊的溝通致使難以快速定位和追溯發生問題的包。爲了減小QA和研發之間的無效溝通以及優化包分發流程,咱們亟需一個平臺來統一管理分發公司內部的App包,因而MCI App應運而生。

MCI App提供以下功能:

  • 查看下載安裝不一樣類型不一樣版本的App。
  • 查看App包的基礎信息(打包者、打包耗時、包版本、代碼提交commit點等)。
  • 查看App包當前版本集成的全部組件庫信息。
  • 查看App包體佔用狀況。
  • 查詢App發版時間計劃。
  • 分享安裝App包下載連接。

將來MCI App還會支持查詢項目集成狀態以及App發佈提醒、問題反饋,整合移動研發全流程。

6、提高構建速度

移動項目在構建過程當中最爲耗時的兩個步驟分別爲組件依賴計算和工程編譯。

組件依賴計算

組件依賴計算是根據項目中指定的集成組件計算出全部相關的依賴項以及依賴版本,當項目中集成組件較多的時候,遞歸計算依賴項以及依賴版本是一件很是耗時的操做,特別是還要處理相關的依賴衝突。

工程編譯

工程編譯時間是跟項目工程的代碼量成正比的,集團業務在快速發展,代碼量也在快速的膨脹。

爲了提高項目構建速度,咱們經過依賴扁平化的方法來完全去掉組件依賴計算耗時,以及經過優化項目集成方式的手段來減小工程編譯時間。

依賴扁平化

依賴扁平化的核心思想是事先把依賴項以及依賴版本號進行顯示指定,這樣經過固定依賴項以及依賴版本就完全去掉了組件依賴計算的耗時,極大的提升了項目構建速度。與此同時,依賴扁平化還額外帶來了下面的好處:

  • 減輕研發依賴關係維護的負擔。
  • App項目更加穩定,不會由於依賴項的自動升級出現問題。

優化集成方式

一般組件代碼都是以源碼方式集成到目標工程,這種集成方式的最大缺點是編譯速度慢,對於上百萬行代碼的App,若是採用源碼集成的方式,工程編譯時間將超過40分鐘甚至更長,這個時間,顯然會使人崩潰。

使用源碼集成

使用二進制集成

實際上組件代碼還能夠經過二進制的方式集成到目標工程:

相比源碼方式集成,組件的二進制包都是預先編譯好的,在集成過程當中只須要進行連接無需編譯,所以二進制集成的方式能夠大幅提高項目編譯速度。

二進制集成優化

爲了進一步提升二進制集成效率,咱們還作了幾件小事:

(1)多線程下載

儘管二進制集成的方式能減小工程編譯時間,但二進制包仍是得從遠端下載到CI服務器上。咱們修改了默認單線程下載的策略,經過多線程下載二進制包提高下載效率。

(2)二進制包緩存

研發在MCI上觸發不一樣的集成任務,這些集成任務間除了升級的組件,其它使用的組件二進制包大部分是相同的,所以咱們在CI服務器上對組件二進制包進行緩存以便不一樣任務間進行共享,進一步提高項目構建速度。

二進制集成成果

咱們在MCI中採用二進制集成而且通過一系列優化後,iOS項目工程的編譯時間比原來減小60%,Android項目也比原來減小接近50%,極大地提高了項目構建效率。

7、靜態檢查體系

除了完成平常需求開發,提升代碼質量是每一個研發的必修課。若是每一位移動研發在平時開發中能嚴格遵照移動編程規範與最佳實踐,那不少線上問題徹底能夠提早避免。事實上僅僅依靠研發自覺性,難以長期有效的執行,咱們須要把這些移動編程規範和最佳實踐切實落地成爲靜態檢查強制執行,纔能有效的將問題扼殺在搖籃之中。

靜態檢查基礎設施

靜態檢查最簡單的方式是文本匹配,這種方式檢查邏輯簡單,但存在侷限性。好比編寫的靜態檢查代碼維護困難,再者文本匹配能力有限對一些複雜邏輯的處理無能爲力。現有針對Objective-C和Java的靜態分析工具也有很多,常見的有:OCLint、FindBugs、CheckStyle等等,但這些工具定製門檻較高。爲了下降靜態檢查接入成本,咱們自主研發了一個適應MCI需求的靜態分析框架–Hades。

Hades的特色:

  • 徹底代碼語義理解
  • 具有全局分析能力
  • 支持增量分析
  • 接入成本低

Hades的核心思想是對源碼生成的AST(Abstract Syntax Tree)進行結構化數據的語義表達,在此基礎上咱們就能夠創建一系列靜態分析工具和服務。做爲一個靜態分析框架,Hades並不侷限於Lint工具的製做,咱們也但願經過這種結構化的語義表達來對代碼有更深層次的理解。所以,咱們能夠藉助文檔型數據庫(如:CouchDB、MongoDB等)創建項目代碼的語義模型數據庫,這樣咱們可以經過JS的Map-Reduce創建視圖從而快速檢索咱們須要查找的內容。關於Hades的技術實現原理咱們將在後續的技術Blog中進行詳細闡述,敬請期待。

MCI靜態檢查現狀

目前MCI已經上線了覆蓋代碼基本規範、非空特性、多線程最佳實踐、資源合法性、啓動流程管控、動態行爲管控等20多項靜態檢查,這些靜態檢查切實有效地促進了App代碼質量的提升。

8、日誌監控&分析

MCI做爲大衆點評移動端持續集成的重要平臺,穩定高效是要達成的第一目標,日誌監控是推進MCI走向穩定高效的重要手段。咱們對MCI全流程的日誌進行落地,方便問題追溯與排查,如下是部分線上監控項。

流程時間監控分析

經過監控分析MCI流程中每一步的執行時間,咱們能夠進行鍼對性的優化以提升集成速度。

異常流程監控分析

咱們會對異常流程進行監控而且通知流程發起者,同時咱們會對失敗次數較多的Job分析緣由。一部分CI環境或者網絡問題MCI能夠自動解決,而其它因爲代碼錯誤引發的異常MCI會引導移動研發進行問題的排查與解決。

包體監控分析

咱們對包體總大小、可執行文件以及圖片進行全方面的監控,包體變化的趨勢一目瞭然,對於包體的異常變化咱們能夠第一時間感知。

除此以外,咱們還對MCI集成成功率、二進制覆蓋率等方面作了監控,作到對MCI全流程瞭然於胸,讓MCI穩定高效的運行。

9、信息管理配置

目前MCI平臺已經接入公司多個移動項目,爲了接入MCI的項目進行統一方便的信息管理,咱們建設了MCI信息管理平臺——摩卡(Mocha)。Mocha平臺的功能包含項目信息管理、配置靜態檢查項以及組件發版集成查詢。

項目信息管理

Mocha平臺負責註冊接入MCI項目的基本信息,包含項目地址、項目負責人等,同時對各個項目的成員進行權限管理。

配置靜態檢查項

MCI支持不一樣項目自定義不一樣的靜態檢查項,在Mocha平臺上能夠完成項目所需靜態檢查項的定製,同時支持靜態檢查白名單的配置審覈。

組件發版集成查詢

Mocha平臺支持組件歷史發版集成的記錄查詢,方便問題的排查與追溯。

做爲移動集成項目的可視化配置系統,Mocha平臺是MCI的一個重要補充。它使得移動項目接入MCI變得簡單快捷,將來Mocha平臺還會加入更多的配置項。

10、總結與展望

本文從大衆點評移動項目業務複雜度出發,詳細介紹了構建穩定高效的移動持續集成系統的思路與最佳實踐方案,解決項目依賴複雜所帶來的問題,經過依賴扁平化以及二進制集成提高構建速度。在此基礎上,經過自研的靜態檢查基礎設施Hades下降靜態檢查准入的門檻,幫助提高App質量;最後MCI提供的全流程託管能力能顯著提升移動研發生產力。

目前MCI爲iOS、Android原生代碼的項目集成已經提供了至關完善的支持。此外,MCI還支持Picasso項目的持續集成,Picasso是大衆點評自研的高性能跨平臺動態化框架,專一於橫跨iOS、Android、Web、小程序四端的動態化UI構建。固然移動端原生項目的持續集成和動態化項目的持續集成有共通也有不少不一樣之處。將來MCI將在移動工程化領域進一步探索,爲移動端業務蓬勃發展保駕護航。

 

美團外賣Android Crash治理之路

Crash率是衡量一個App好壞的重要指標之一,若是你忽略了它的存在,它就會愈演愈烈,最後形成大量用戶的流失,進而給公司帶來沒法估量的損失。本文講述美團外賣Android客戶端團隊在將App的Crash率從千分之三作到萬分之二過程當中所作的大量實踐工做,拋磚引玉,但願可以爲其餘團隊提供一些經驗和啓發。

面臨的挑戰和成果

面對用戶使用頻率高,外賣業務增加快,Android碎片化嚴重這些問題,美團外賣Android App如何持續的下降Crash率,是一項極具挑戰的事情。經過團隊的全力全策,美團外賣Android App的平均Crash率從千分之三降到了萬分之二,最優值萬一左右(Crash率統計方式:Crash次數/DAU)。

美團外賣自2013年建立以來,業務就以指數級的速度發展。美團外賣承載的業務,從單一的餐飲業務,發展到餐飲、超市、生鮮、果蔬、藥品、鮮花、蛋糕、跑腿等十多個大品類業務。目前美團外賣日完成訂單量已突破2000萬,成爲美團點評最重要的業務之一。美團外賣客戶端所承載的業務模塊愈來愈多,產品複雜度愈來愈高,團隊開發人員日益增長,這些都給App下降Crash率帶來了巨大的挑戰。

Crash的治理實踐

對於Crash的治理,咱們儘可能遵照如下三點原則:

  • 由點到面。一個Crash發生了,咱們不能只針對這個Crash的去解決,而要去考慮這一類Crash怎麼去解決和預防。只有這樣才能使得這一類Crash真正被解決。
  • 異常不能隨便吃掉。隨意的使用try-catch,只會增長業務的分支和隱蔽真正的問題,要了解Crash的本質緣由,根據本質緣由去解決。catch的分支,更要根據業務場景去兜底,保證後續的流程正常。
  • 預防勝於治理。當Crash發生的時候,損失已經形成了,咱們再怎麼治理也只是減小損失。儘量的提早預防Crash的發生,能夠將Crash消滅在萌芽階段。

常規的Crash治理

常規Crash發生的緣由主要是因爲開發人員編寫代碼不當心致使的。解決這類Crash須要由點到面,根據Crash引起的緣由和業務自己,統一集中解決。常見的Crash類型包括:空節點、角標越界、類型轉換異常、實體對象沒有序列化、數字轉換異常、Activity或Service找不到等。這類Crash是App中最爲常見的Crash,也是最容易反覆出現的。在獲取Crash堆棧信息後,解決這類Crash通常比較簡單,更多考慮的應該是如何避免。下面介紹兩個咱們治理的量比較大的Crash。

NullPointerException

NullPointerException是咱們遇到最頻繁的,形成這種Crash通常有兩種狀況:

  • 對象自己沒有進行初始化就進行操做。
  • 對象已經初始化過,可是被回收或者手動置爲null,而後對其進行操做。

針對第一種狀況致使的緣由有不少,多是開發人員的失誤、API返回數據解析異常、進程被殺死後靜態變量沒初始化致使,咱們能夠作的有:

  • 對可能爲空的對象作判空處理。
  • 養成使用@NonNull和@Nullable註解的習慣。
  • 儘可能不使用靜態變量,萬不得已使用SharedPreferences來存儲。
  • 考慮使用Kotlin語言。

針對第二種狀況大部分是因爲Activity/Fragment銷燬或被移除後,在Message、Runnable、網絡等回調中執行了一些代碼致使的,咱們能夠作的有:

  • Message、Runnable回調時,判斷Activity/Fragment是否銷燬或被移除;加try-catch保護;Activity/Fragment銷燬時移除全部已發送的Runnable。
  • 封裝LifecycleMessage/Runnable基礎組件,並自定義Lint檢查,提示使用封裝好的基礎組件。
  • 在BaseActivity、BaseFragment的onDestory()裏把當前Activity所發的全部請求取消掉。

IndexOutOfBoundsException

這類Crash常見於對ListView的操做和多線程下對容器的操做。

針對ListView中形成的IndexOutOfBoundsException,常常是由於外部也持有了Adapter裏數據的引用(如在Adapter的構造函數裏直接賦值),這時若是外部引用對數據更改了,但沒有及時調用notifyDataSetChanged(),則有可能形成Crash,對此咱們封裝了一個BaseAdapter,數據統一由Adapter本身維護通知, 同時也極大的避免了The content of the adapter has changed but ListView did not receive a notification,這兩類Crash目前獲得了統一的解決。

另外,不少容器是線程不安全的,因此若是在多線程下對其操做就容易引起IndexOutOfBoundsException。經常使用的如JDK裏的ArrayList和Android裏的SparseArray、ArrayMap,同時也要注意有一些類的內部實現也是用的線程不安全的容器,如Bundle裏用的就是ArrayMap。

系統級Crash治理

衆所周知,Android的機型衆多,碎片化嚴重,各個硬件廠商可能會定製本身的ROM,更改系統方法,致使特定機型的崩潰。發現這類Crash,主要靠雲測平臺配合自動化測試,以及線上監控,這種狀況下的Crash堆棧信息很難直接定位問題。下面是常見的解決思路:

  1. 嘗試找到形成Crash的可疑代碼,看是否有特異的API或者調用方式不當致使的,嘗試修改代碼邏輯來進行規避。
  2. 經過Hook來解決,Hook分爲Java Hook和Native Hook。Java Hook主要靠反射或者動態代理來更改相應API的行爲,須要嘗試找到能夠Hook的點,通常Hook的點多爲靜態變量,同時須要注意Android不一樣版本的API,類名、方法名和成員變量名均可能不同,因此要作好兼容工做;Native Hook原理上是用更改後方法把舊方法在內存地址上進行替換,須要考慮到Dalvik和ART的差別;相對來講Native Hook的兼容性更差一點,因此用Native Hook的時候須要配合降級策略。
  3. 若是經過前兩種方式都沒法解決的話,咱們只能嘗試反編譯ROM,尋找解決的辦法。

咱們舉一個定製系統ROM致使Crash的例子,根據Crash平臺統計數據發現該Crash只發生在vivo V3Max這類機型上,Crash堆棧以下:

java.lang.RuntimeException: An error occured while executing doInBackground() at android.os.AsyncTask$3.done(AsyncTask.java:304) at java.util.concurrent.FutureTask.finishCompletion(FutureTask.java:355) at java.util.concurrent.FutureTask.setException(FutureTask.java:222) at java.util.concurrent.FutureTask.run(FutureTask.java:242) at android.os.AsyncTask$SerialExecutor$1.run(AsyncTask.java:231) at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1112) at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:587) at java.lang.Thread.run(Thread.java:818) Caused by: java.lang.NullPointerException: Attempt to invoke interface method 'int java.util.List.size()' on a null object reference at android.widget.AbsListView$UpdateBottomFlagTask.isSuperFloatViewServiceRunning(AbsListView.java:7689) at android.widget.AbsListView$UpdateBottomFlagTask.doInBackground(AbsListView.java:7665) at android.os.AsyncTask$2.call(AsyncTask.java:292) at java.util.concurrent.FutureTask.run(FutureTask.java:237) ... 4 more 

咱們發現原生系統上對應系統版本的AbsListView裏並無UpdateBottomFlagTask類,所以能夠判定是vivo該版本定製的ROM修改了系統的實現。咱們在定位這個Crash的可疑點無果後決定經過Hook的方式解決,經過源碼發現AsyncTask$SerialExecutor是靜態變量,是一個很好的Hook的點,經過反射添加try-catch解決。由於修改的是final對象因此須要先反射修改accessFlags,須要注意ART和Dalvik下對應的Class不一樣,代碼以下:

public static void setFinalStatic(Field field, Object newValue) throws Exception { field.setAccessible(true); Field artField = Field.class.getDeclaredField("artField"); artField.setAccessible(true); Object artFieldValue = artField.get(field); Field accessFlagsFiled = artFieldValue.getClass().getDeclaredField("accessFlags"); accessFlagsFiled.setAccessible(true); accessFlagsFiled.setInt(artFieldValue, field.getModifiers() & ~Modifier.FINAL); field.set(null, newValue); } 
private void initVivoV3MaxCrashHander() { if (!isVivoV3()) { return; } try { setFinalStatic(AsyncTask.class.getDeclaredField("SERIAL_EXECUTOR"), new SafeSerialExecutor()); Field defaultfield = AsyncTask.class.getDeclaredField("sDefaultExecutor"); defaultfield.setAccessible(true); defaultfield.set(null, AsyncTask.SERIAL_EXECUTOR); } catch (Exception e) { L.e(e); } } 

美團外賣App用上述方法解決了對應的Crash,可是美團App裏的外賣頻道由於平臺的限制沒法經過這種方式,因而咱們嘗試反編譯ROM。 Android ROM編譯時會將framework、app、bin等目錄打入system.img中,system.img是Android系統中用來存放系統文件的鏡像 (image),文件格式通常爲yaffs2或ext。但Android 5.0開始支持dm-verity後,system.img再也不提供,而是提供了三個文件system.new.dat,system.patch.dat,system.transfer.list,所以咱們首先須要經過上述的三個文件獲得system.img。但咱們將vivo ROM解壓後發現廠商將system.new.dat進行了分片,以下圖所示:

通過對system.transfer.list中的信息和system.new.dat 1 2 3 … 文件大小對比研究,發現一些共同點,system.transfer.list中的每個block數*4KB 與對應的分片文件的大小大體相同,故大膽猜想,vivo ROM對system.patch.dat分片也只是單純的按block前後順序進行了分片處理。因此咱們只須要在轉化img前將這些分片文件合成一個system.patch.dat文件就能夠了。最後根據system.img的文件系統格式進行解包,拿到framework目錄,其中有framework.jar和boot.oat等文件,由於Android4.4以後引入了ART虛擬機,會預先把system/framework中的一些jar包轉換爲oat格式,因此咱們還須要將對應的oat文件經過ota2dex將其解包得到dex文件,以後經過dex2jarjd-gui查看源碼。

OOM

OOM是OutOfMemoryError的簡稱,在常見的Crash疑難排行榜上,OOM絕對能夠名列前茅而且經久不衰。由於它發生時的Crash堆棧信息每每不是致使問題的根本緣由,而只是壓死駱駝的最後一根稻草。

致使OOM的緣由大部分以下:

  • 內存泄漏,大量無用對象沒有被及時回收致使後續申請內存失敗。
  • 大內存對象過多,最多見的大對象就是Bitmap,幾個大圖同時加載很容易觸發OOM。

內存泄漏

內存泄漏指系統未能及時釋放已經再也不使用的內存對象,通常是由錯誤的程序代碼邏輯引發的。在Android平臺上,最多見也是最嚴重的內存泄漏就是Activity對象泄漏。Activity承載了App的整個界面功能,Activity的泄漏同時也意味着它持有的大量資源對象都沒法被回收,極其容易形成OOM。

常見的可能會形成Activity泄漏的緣由有:

  • 匿名內部類實現Handler處理消息,可能致使隱式持有的Activity對象沒法回收。
  • Activity和Context對象被混淆和濫用,在許多隻須要Application Context而不須要使用Activity對象的地方使用了Activity對象,好比註冊各種Receiver、計算屏幕密度等等。
  • View對象處理不當,使用Activity的LayoutInflater建立的View自身持有的Context對象其實就是Activity,這點常常被忽略,在本身實現View重用等場景下也會致使Activity泄漏。

對於Activity泄漏,目前已經有了一個很是好用的檢測工具:LeakCanary,它能夠自動檢測到全部Activity的泄漏狀況,而且在發生泄漏時給出十分友好的界面提示,同時爲了防止開發人員的疏漏,咱們也會將其上報到服務器,統一檢查解決。另外咱們能夠在debug下使用StrictMode來檢查Activity的泄露、Closeable對象沒有被關閉等問題。

大對象

在Android平臺上,咱們分析任一應用的內存信息,幾乎均可以得出一樣的結論:佔用內存最多的對象大都是Bitmap對象。隨着手機屏幕尺寸愈來愈大,屏幕分辨率也愈來愈高,1080p和更高的2k屏已經佔了大半份額,爲了達到更好的視覺效果,咱們每每須要使用大量高清圖片,同時也爲OOM埋下了禍根。

對於圖片內存優化,咱們有幾個經常使用的思路:

  • 儘可能使用成熟的圖片庫,好比Glide,圖片庫會提供不少通用方面的保障,減小沒必要要的人爲失誤。
  • 根據實際須要,也就是View尺寸來加載圖片,能夠在分辨率較低的機型上儘量少地佔用內存。除了經常使用的BitmapFactory.Options#inSampleSize和Glide提供的BitmapRequestBuilder#override以外,咱們的圖片CDN服務器也支持圖片的實時縮放,能夠在服務端進行圖片縮放處理,從而減輕客戶端的內存壓力。 分析App內存的詳細狀況是解決問題的第一步,咱們須要對App運行時到底佔用了多少內存、哪些類型的對象有多少個有大體瞭解,並根據實際狀況作出預測,這樣才能在分析時作到有的放矢。Android Studio也提供了很是好用的Memory Profiler堆轉儲分配跟蹤器功能能夠幫咱們迅速定位問題。

AOP加強輔助

AOP是面向切面編程的簡稱,在Android的Gradle插件1.5.0中新增了Transform API以後,編譯時修改字節碼來實現AOP也由於有了官方支持而變得很是方便。

在一些特定狀況下,能夠經過AOP的方式自動處理未捕獲的異常:

  • 拋異常的方法很是明確,調用方式比較固定。
  • 異常處理方式比較統一。
  • 和業務邏輯無關,即自動處理異常後不會影響正常的業務邏輯。典型的例子有讀取Intent Extras參數、讀取SharedPreferences、解析顏色字符串值和顯示隱藏Window等等。

這類問題的解決原理大體相同,咱們以Intent Extras爲例詳細介紹一下。讀取Intent Extras的問題在於咱們很是經常使用的方法 Intent#getStringExtra 在代碼邏輯出錯或者惡意攻擊的狀況下可能會拋出ClassNotFoundException異常,而咱們平時在寫代碼時又不太可能給全部調用都加上try-catch語句,因而一個更安全的Intent工具類應運而生,理論上只要全部人都使用這個工具類來訪問Intent Extras參數就能夠防止此類型的Crash。可是面對龐大的舊代碼倉庫和諸多的業務部門,修改現有代碼須要極大成本,還有更多的外部依賴SDK基本不可能使用咱們本身的工具類,此時就須要AOP大展身手了。

咱們專門製做了一個Gradle插件,只須要配置一下參數就能夠將某個特定方法的調用替換成另外一個方法:

WaimaiBytecodeManipulator { replacements( "android/content/Intent.getIntExtra(Ljava/lang/String;I)I=com/waimai/IntentUtil.getInt(Landroid/content/Intent;Ljava/lang/String;I)I", "android/content/Intent.getStringExtra(Ljava/lang/String;)Ljava/lang/String;=com/waimai/IntentUtil.getString(Landroid/content/Intent;Ljava/lang/String;)Ljava/lang/String;", "android/content/Intent.getBooleanExtra(Ljava/lang/String;Z)Z=com/waimai/IntentUtil.getBoolean(Landroid/content/Intent;Ljava/lang/String;Z)Z", ...) } } 

上面的配置就能夠將App代碼(包括第三方庫)裏全部的Intent.getXXXExtra調用替換成IntentUtil類中的安全版實現。固然,並非全部的異常都只須要catch住就萬事大吉,若是真的有邏輯錯誤確定須要在開發和測試階段及時暴露出來,因此在IntentUtil中會對App的運行環境作判斷,Debug下會將異常直接拋出,開發同窗能夠根據Crash堆棧分析問題,Release環境下則在捕獲到異常時返回對應的默認值而後將異常上報到服務器。

依賴庫的問題

Android App常常會依賴不少AAR, 每一個AAR可能有多個版本,打包時Gradle會根據規則肯定使用的最終版本號(默認選擇最高版本或者強制指定的版本),而其餘版本的AAR將被丟棄。若是互相依賴的AAR中有不兼容的版本,存在的問題在打包時是不能發現的,只有在相關代碼執行時纔會出現,會形成NoClassDefFoundError、NoSuchFieldError、NoSuchMethodError等異常。如圖所示,order和store兩個業務庫都依賴了platform.aar,一個是1.0版本,一個是2.0版本,默認最終打進APK的只有platform 2.0版本,這時若是order庫裏用到的platform庫裏的某個類或者方法在2.0版本中被刪除了,運行時就可能發生異常,雖然SDK在升級時會盡可能作到向下兼容,但不少時候尤爲是第三方SDK是無法獲得保證的,在美團外賣Android App v6.0版本時由於這個緣由致使熱修復功能喪失,所以爲了提早發現問題,咱們接入了依賴檢查插件Defensor。

Defensor在編譯時經過DexTask獲取到全部的輸入文件(也就是被編譯過的class文件),而後檢查每一個文件裏引用的類、字段、方法等是否存在。

除此以外咱們寫了一個Gradle插件SVD(strict version dependencies)來對那些重要的SDK的版本進行統一管理。插件會在編譯時檢查Gradle最終使用的SDK版本是否和配置中的一致,若是不一致插件會終止編譯並報錯,並同時會打印出發生衝突的SDK的全部依賴關係。

Crash的預防實踐

單純的靠約定或規範去減小Crash的發生是不現實的。約定和規範受限於組織架構和具體執行的我的,很容易被忽略,只有靠工程架構和工具才能保證Crash的預防長久的執行下去。

工程架構對Crash率的影響

在治理Crash的實踐中,咱們每每忽略了工程架構對Crash率的影響。Crash的發生大部分緣由是源於程序員的不合理的代碼,而程序員工做中最直接的接觸的就是工程架構。對於一個邊界模糊,層級混亂的架構,程序員是更加容易寫出引發Crash的代碼。在這樣的架構裏面,即便程序員意識到致使某種寫法存在問題,想要去改善這樣不合理的代碼,也是很是困難的。相反,一個層級清晰,邊界明確的架構,是可以大大減小Crash發生的機率,治理和預防Crash也是相對更容易。這裏咱們能夠舉幾個咱們實踐過的例子闡述。

業務模塊的劃分

原來咱們的Crash基本上都是由個別同窗關注解決的,團隊裏的每一個同窗都會提交可能引發Crash的代碼,若是負責Crash的同窗由於某些事情,暫時沒有關注App的Crash率,那麼形成Crash的同窗也不會知道他的代碼引發了Crash。

對於這個問題,咱們的作法是App的業務模塊化。業務模塊化後,每一個業務都有都有惟一包名和對應的負責人。當某個模塊發生了Crash,能夠根據包名提交問題給這個模塊的負責人,讓他第一時間進行處理。業務模塊化自己也是工程架構優先須要考慮的事情之一。

頁面跳轉路由統一處理頁面跳轉

對外賣App而言,使用過程當中最多的就是頁面間的跳轉,而頁面間跳轉常常會形成ActivityNotFoundException,例如咱們配了一個scheme,但對方的scheme路徑已經發生了變化;又例如,咱們調用手機上相冊的功能,而相冊應用已被用戶本身禁用或移除了。解決這一類Crash,其實也很簡單,只須要在startActivity增長ActivityNotFoundException異常捕獲便可。但一個App裏,啓動Activity的地方,幾乎是隨處可見,沒法預測哪一處會形成ActivityNotFoundException。

咱們的作法是將頁面的跳轉,都經過咱們封裝的scheme路由去分發。這樣的好處是,經過scheme路由,在工程架構上全部業務都是解耦,模塊間不須要相互依賴就能夠實現頁面的跳轉和基本類型參數的傳遞;同時,因爲全部的頁面跳轉都會走scheme路由,咱們只須要在scheme路由裏一處加上ActivityNotFoundException異常捕獲便可解決這種類型的Crash。路由設計示意圖以下:

網絡層統一處理API髒數據

客戶端的很大一部分的Crash是由於API返回的髒數據。好比當API返回空值、空數組或返回不是約定類型的數據,App收到這些數據,就極有可能發生空指針、數組越界和類型轉換錯誤等Crash。並且這樣的髒數據,特別容易引發線上大面積的崩潰。

最先咱們的工程的網絡層用法是:頁面監聽網絡成功和失敗的回調,網絡成功後,將JSON數據傳遞給頁面,頁面解析Model,初始化View,如圖所示。這樣的問題就是,網絡雖然請求成功了,可是JSON解析Model這個過程可能存在問題,例如沒有返回數據或者返回了類型不對的數據,而這個髒數據致使問題會出如今UI層,直接反應給用戶。

根據上圖,咱們能夠看到因爲網絡層只承擔了請求網絡的職責,沒有承擔數據解析的職責,數據解析的職責交給了頁面去處理。這樣使得咱們一旦發現髒數據致使的Crash,就只能在網絡請求的回調裏面增長各類判斷去兼容髒數據。咱們有幾百個頁面,補漏徹底補不過來。經過幾個版本的重構,咱們從新劃分了網絡層的職責,如圖所示:

從圖上能夠看出,重構後的網絡層負責請求網絡和數據解析,若是存在髒數據的話,在網絡層就會發現問題,不會影響到UI層,返回給UI層的都是校驗成功的數據。這樣改造後,咱們發現這類的Crash率有了極大的改善。

大圖監控

上面講到大對象是致使OOM的主要緣由之一,而Bitmap是App裏最多見的大對象類型,所以對佔用內存過大的Bitmap對象的監控就頗有必要了。

咱們用AOP方式Hook了三種常見圖片庫的加載圖片回調方法,同時監控圖片庫加載圖片時的兩個維度:

  1. 加載圖片使用的URL。外賣App中除靜態資源外,全部圖片都要求發佈到專用的圖片CDN服務器上,加載圖片時使用正則表達式匹配URL,除了限定CDN域名以外還要求全部圖片加載時都要添加對應的動態縮放參數。
  2. 最終加載出的圖片結果(也就是Bitmap對象)。咱們知道Bitmap對象所佔內存和其分辨率大小成正比,而通常狀況下在ImageView上設置超過自身尺寸的圖片是沒有意義的,因此咱們要求顯示在ImageView中的Bitmap分辨率不容許超過View自身的尺寸(爲了下降誤報率也能夠設定一個報警閾值)。

開發過程當中,在App裏檢測到不合規的圖片時會當即高亮出錯的ImageView所在的位置並彈出對話框提示ImageView所在的Activity、XPath和加載圖片使用的URL等信息,以下圖,輔助開發同窗定位並解決問題。在Release環境下能夠將報警信息上報到服務器,實時觀察數據,有問題及時處理。

Lint檢查

咱們發現線上的不少Crash其實能夠在開發過程當中經過Lint檢查來避免。Lint是Google提供的Android靜態代碼檢查工具,能夠掃描並發現代碼中潛在的問題,提醒開發人員及早修正,提升代碼質量。

可是Android原生提供的Lint規則(如是否使用了高版本API)遠遠不夠,缺乏一些咱們認爲有必要的檢測,也不能檢查代碼規範。所以咱們開始開發自定義Lint,目前咱們經過自定義Lint規則已經實現了Crash預防、Bug預防、提高性能/安全和代碼規範檢查這些功能。如檢查實現了Serializable接口的類,其成員變量(包括從父類繼承的)所聲明的類型都要實現Serializable接口,能夠有效的避免NotSerializableException;強制使用封裝好的工具類如ColorUtil、WindowUtil等能夠有效的避免由於參數不正確產生的IllegalArgumentException和由於Activity已經finish致使的BadTokenException。

Lint檢查能夠在多個階段執行,包括在本地手動檢查、編碼實時檢查、編譯時檢查、commit時檢查,以及在CI系統中提Pull Request時檢查、打包時檢查等,以下圖所示。更詳細的內容可參考《美團外賣Android Lint代碼檢查實踐》

資源重複檢查

在以前的文章《美團外賣Android平臺化架構演進實踐》中講述了咱們的平臺化演進過程,在這個過程當中你們很大的一部分工做是下沉,可是下沉不徹底就會致使一些類和資源的重複,類由於有包名的限制不會出現問題。可是一些資源文件如layout、drawable等若是同名則下層會被上層覆蓋,這時layout裏view的id發生了變化就可能致使空指針的問題。爲了不這種問題,咱們寫了一個Gradle插件經過hook MergeResource這個Task,拿到全部library和主庫的資源文件,若是檢查到重複則會中斷編譯過程,輸出重複的資源名及對應的library name,同時避免有些資源由於樣式等緣由確實須要覆蓋,所以咱們設置了白名單。同時在這個過程當中咱們也拿到了全部的的圖片資源,能夠順手作圖片大小的本地監控,以下圖所示:

Crash的監控&止損的實踐

監控

在通過前面提到的各類檢查和測試以後,應用便開始發佈了。咱們創建了以下圖的監控流程,來保證異常發生時可以及時獲得反饋並處理。首先是灰度監控,灰度階段是增量Crash最容易暴露的階段,若是這個階段沒有很好的把握住,會使得增量變存量,從而致使Crash率上升。若是條件容許的話,能夠在灰度期間制定一些灰度策略去提升這個階段Crash的暴露。例如分渠道灰度、分城市灰度、分業務場景灰度、新裝用戶的灰度等等,儘可能覆蓋全部的分支。灰度結束以後便開始全量,在全量的過程當中咱們還須要一些平常Crash監控和Crash率的異常報警來防止突發狀況的發生,例如由於後臺上線或者運營配置錯誤致使的線上Crash。除此以外還須要一些其餘的監控,例如,以前提到的大圖監控,來避免由於大圖致使的OOM。具體的輸出形式主要有郵件通知、IM通知、報表。

止損

儘管咱們在前面作了那麼多,可是Crash仍是沒法避免的,例如,在灰度階段由於量級不夠,有些Crash沒有被暴露出來;又或者某些功能客戶端比後臺更早上線,而這些功能在灰度階段沒有被覆蓋到;這些狀況下,若是出現問題就須要考慮如何止損了。

問題發生時首先須要評估重要性,若是問題不是很嚴重並且修復成本較高能夠考慮在下個版本再修復,相反若是問題比較嚴重,對用戶體驗或下單有影響時就必需要修復。修復時首先考慮業務降級,主要看該部分異常的業務是否有兜底或者A/B策略,這樣是最穩妥也是最有效的方式。若是業務不能降級就須要考慮熱修復了,目前美團外賣Android App接入的熱修復框架是自研的Robust,能夠修復90%以上的場景,熱修成功率也達到了99%以上。若是問題發生在熱修復沒法覆蓋的場景,就只能強制用戶升級。強制升級由於覆蓋週期長,同時影響用戶的體驗,只在萬不得已的狀況下才會使用。

展望

Crash的自我修復

咱們在作新技術選型時除了要考慮是否能知足業務需求、是否比現有技術更優秀和團隊學習成本等因素以外,兼容性和穩定性也很是重要。但面對國內非富多彩的Android系統環境,在體量百萬級以上的的App中幾乎不可能實現毫無瑕疵的技術方案和組件,因此通常狀況下若是某個技術實現方案能夠達到0.01‰如下的崩潰率,而其餘方案也沒有更好的表現,咱們就認爲它是能夠接受的。可是哪怕僅僅十萬分之一的崩潰率,也表明還有用戶受到影響,而咱們認爲Crash對用戶來講是最糟糕的體驗,尤爲是涉及到交易的場景,因此咱們必須本着每一單都很重要的原則,盡最大努力保證用戶順利執行流程。

實際狀況中有一些技術方案在兼容性和穩定性上作了必定妥協的場景,每每是由於考慮到性能或擴展性等方面的優點。這種狀況下咱們其實能夠再多作一些,進一步提升App的可用性。就像不少操做系統都有「兼容模式」或者「安全模式」,不少自動化機械機器都配套有手動操做模式同樣,App裏也能夠實現備用的降級方案,而後設置特定條件的觸發策略,從而達到自動修復Crash的目的。

舉例來說,Android 3.0中引入了硬件加速機制,雖然能夠提升繪製幀率而且下降CPU佔用率,可是在某些機型上仍是會有繪製錯亂甚至Crash的狀況,這時咱們就能夠在App中記錄硬件加速相關的Crash問題或者使用檢測代碼主動檢測硬件加速功能是否正常工做,而後主動選擇是否開啓硬件加速,這樣既能夠讓絕大部分用戶享受硬件加速帶來的優點,也能夠保障硬件加速功能不完善的機型不受影響。

還有一些相似的能夠作自動降級的場景,好比:

  • 部分使用JNI實現的模塊,在SO加載失敗或者運行時發生異常則能夠降級爲Java版實現。
  • RenderScript實現的圖片模糊效果,也能夠在失敗後降級爲普通的Java版高斯模糊算法。
  • 在使用Retrofit網絡庫時發現OkHttp3或者HttpURLConnection網絡通道失敗率高,能夠主動切換到另外一種通道。

這類問題都須要根據具體狀況具體分析,若是能夠找到準確的斷定條件和穩定的修復方案,就能夠讓App穩定性再上一個臺階。

特定Crash類型日誌自動回撈

外賣業務發展迅速,即便咱們在開發時使用各類工具、措施來避免Crash的發生,但Crash仍是不可避免。線上某些怪異的Crash發生後,咱們除了分析Crash堆棧信息以外,還能夠使用離線日誌回撈、下發動態日誌等工具來還原Crash發生時的場景,幫助開發同窗定位問題,可是這兩種方式都有它們各自的問題。離線日誌顧名思義,它的內容都是預先記錄好的,有時候可能會漏掉一些關鍵信息,由於在代碼中加日誌通常只是在業務關鍵點,在大量的普通方法中不可能都加上日誌。動態日誌(Holmes)存在的問題是每次下發只能針對已知UUID的一個用戶的一臺設備,對於大量線上Crash的狀況這種操做並不合適,由於咱們並不能知道哪一個發生Crash的用戶還會再次復現此次操做,下發配置充滿了不肯定性。

咱們能夠改造Holmes使其支持批量甚至全量下發動態日誌,記錄的日誌等到發生特定類型的Crash時才上報,這樣一來能夠減小日誌服務器壓力,同時也能夠極大提升定位問題的效率,由於咱們能夠肯定上報日誌的設備最後都真正發生了該類型Crash,再來分析日誌就能夠作到事半功倍。

總結

業務的快速發展,每每不可能給團隊充足的時間去治理Crash,而Crash又是App最重要的指標之一。團隊須要由一個個Crash個例,去探究每個Crash發生的最本質緣由,找到最合理解決這類Crash的方案,創建解決這一類Crash的長效機制,而不能飲鴆止渴。只有這樣,隨着版本的不斷迭代,咱們才能在Crash治理之路上離目標愈來愈近。

參考資料

  1. Crash率從2.2%降至0.2%,這個團隊是怎麼作到的?
  2. Android運行時ART加載OAT文件的過程分析
  3. Android動態日誌系統Holmes
  4. Android Hook技術防範漫談
  5. 美團外賣Android Lint代碼檢查實踐

 

美團外賣Android平臺化的複用實踐

美團外賣平臺化複用主要是指多端代碼複用,正如美團外賣iOS多端複用的推進、支撐與思考文章所述,多端包含有兩層意思:其一是相同業務的多入口,指美團外賣業務須要在美團外賣App(下文簡稱外賣App)和美團App外賣頻道(下文簡稱外賣頻道)同時上線;其二是指平臺上各個業務線,美團外賣不一樣業務線都依賴外賣基礎服務,好比登錄、定位等。

多入口及多業務線給美團外賣平臺化複用帶來了巨大的挑戰,此前咱們的一篇博客《美團外賣Android平臺化架構演進實踐》(下文簡稱《架構演進實踐》)也提到了這個問題,本文將在「代碼複用」這一章節的基礎上,進一步介紹平臺化複用工做面臨的挑戰以及相應的解決方案。

美團外賣平臺化複用背景

美團外賣App和美團App外賣頻道業務基本同樣,但因爲歷史緣由,兩端代碼差別較大,形成一樣的子業務需求在一端上線後,另外一端幾乎須要從新實現,嚴重浪費開發資源。在《架構演進實踐》一文中,將美團外賣Android客戶端平臺化架構分爲平臺層、業務層和宿主層,咱們但願可以在平臺化架構中實現平臺層和業務層的多端複用,從而節省子業務需求開發資源,實現多端部署。

難點總結

兩端業務雖然基本一致,可是仍舊存在差別,UI、基礎服務、需求差別等。這些差別存在於美團外賣平臺化架構中的平臺層和業務層各個模塊中,給平臺化複用帶來了巨大的挑戰。咱們總結了兩端代碼的差別點,主要包括如下幾個方面:

  1. 基礎服務的差別:包括基礎Activity、網絡庫、圖片庫等底層庫的差別。
  2. 組件的實現差別:包括基礎數據Model、下拉刷新、頁面跳轉等基礎組件的差別。
  3. 頁面的差別:包括兩端的UI、交互、業務和版本發佈時間不一致等差別。

前期探索

前期,咱們嘗試經過一些設計方案來繞過上述差別,從而實現兩端的代碼複用。咱們選擇了二級頻道頁(下文統稱金剛頁)進行方案嘗試,設計以下:

其中,KingKongDelegate是Activity生命週期實現的代理類,包含onCreate、onResume等Activity生命週期回調方法。在外賣App和外賣頻道兩端分別基於各自的基礎Activity實現WMKingKongAcitivity和MTKingKongActivity,分別會經過調用KingKongDelegate的方法對Activity的生命週期進行分發。

KingKongInjector是兩端差別部分的接口集合,包括頁面跳轉(兩端頁面差別)、獲取頁面刷新間隔時間、默認資源等,在外賣App和外賣頻道分別有對應的接口實現WMKingKongInjector和MTKingKongInjector。

NetworkController則是用Retrofit實現統一的網絡請求封裝,PageListController是對列表分頁加載邏輯以及頁面空白、網絡加載失敗等異常邏輯處理。

在金剛頁設計方案中,咱們採用了「代理+繼承」的方式,實現了用統一的網絡庫實現網絡請求,定義了統一的基礎數據Model,統一了部分基礎服務以及基礎數據。經過KingKongDelegate屏蔽了兩端基礎Acitivity的差別,同時,經過KingKongInjector實現了兩端差別部分的處理。可是咱們發現這種設計方案存在如下問題:

  1. 雖然這樣能夠解決網絡庫和圖片的差別,可是不能屏蔽兩端基礎Activity的差別。
  2. KingKongInjector提供了一種解決兩端差別的處理方式,可是KingKongInjector會存在不少不相關的方法集合,不易控制其邊界。此外,多個子模塊須要調用KingKongInjector,會致使KingKongInjector不便管理。
  3. 因爲兩端Model不一樣,須要實現這個模塊使用的統一Model,可是並未和其餘頁面使用的相同含義的Model統一。

平臺化複用方案設計

經過代碼複用初步嘗試總結,咱們總結出平臺化複用,須要考慮四件事情:

  1. 差別化的統一管理。
  2. 基礎服務的複用。
  3. 基礎組件的複用。
  4. 頁面的複用。

總體設計

咱們在實現平臺化架構的基礎上,通過不斷的探索,最終造成適合外賣業務的平臺化複用設計:總體分爲基礎服務層-基礎組件層-業務層-宿主層。設計圖以下:

  1. 基礎服務層:包含多端統一的基礎服務和有差別的基礎服務,其中統一的基礎服務包括網絡庫、圖片庫、統計、監控等。對於登陸、分享、定位等外賣App和外賣頻道兩端有差別的部分,咱們經過抽象服務層來屏蔽兩端的差別。
  2. 基礎組件層:包括統一的兩端Model、埋點、下拉刷新、權限、Toast、A/B測試、Utils等兩端複用的基礎組件。
  3. 業務層:包括外賣的具體業務模塊,目前能夠分爲列表頁模塊(如首頁、金剛頁等)、商家模塊(如商家頁、商品詳情頁等)和訂單模塊(以下單頁、訂單狀態頁等)。這些業務模塊的特色是:模塊間複用可能性小,模塊內的複用可能性大。
  4. 宿主層:主要是初始化服務,例如Application的初始化、dex加載和其餘各類必要的組件的初始化。

分層架構可以實現各層功能的職責分離,同時,咱們要求上層不感知下層的多端差別。在各層中進行組件劃分,一樣,咱們也要求實現調用組件方不感知組件的多端差別。經過這樣的設計,可以使得總體架構更加清晰明朗,複用率提升的同時,不影響架構的複雜度和靈活度。

差別化管理

須要多端複用的業務相對於普通業務而言,最大的挑戰在於差別化管理。首先多端的先天條件就決定了多端複用業務會存在差別;其次,多端複用的業務有個性化的需求。在多端複用的差別化管理方案中,咱們總結了如下兩種方案:

  1. 差別分支管理方案。
  2. pins工程+Flavor管理的方案。
差別分支管理

分支管理經常使用於多個需求在一端上線後,須要在另外一端某一個時間節點跟進的場景,以下圖所示:

兩端開發1.0版本時,分別要在wm分支(外賣App對應分支)開發feature1和mt分支(外賣頻道對應分支)開發feature2。開發2.0版本時,feature1須要在外賣頻道上線,feature2須要在外賣App上線,則分別將feature1分支代碼合入mt分支,feature2代碼合入wm分支。這樣經過拉取新需求分支管理的方式,知足了需求的差別化管理。可是這種實現方式存在兩個問題:

  1. 兩端需求差別太多的話,就會存在不少分支,形成分支管理困難。
  2. 不支持細粒度的差別化管理,好比模塊內部的差別化管理。
pins工程+Flavor的差別化管理

在Android官網《配置構建變體》章節中介紹了Product Flavor(下文簡稱Flavor)能夠用於實現full版本以及demo版本的差別化管理,經過配置Gradle,能夠基於不一樣的Flavor生成不一樣的apk版本。所以,模塊內部的差別化管理是經過Flavor來實現,其原理以下圖所示:

其中Common是兩端複用的代碼,DiffHandler是兩端差別部分接口,WMDiffHandler是外賣App對應的Flavor下的DiffHandler實現,MTDiffHandler是外賣頻道對應Flavor下的DiffHandler實現。經過兩端分別依賴不一樣Flavor代碼實現模塊內差別化管理。

對於需求在兩端版本差別化管理,也能夠經過配置Flavor來實現,以下圖所示:

在1.0版本時,feature1只在外賣App上線,feature2只在外賣頻道上線。當2.0版本時,若是feature一、feature2須要同時在兩端上線,只須要將對應業務代碼移動到共用SourceSet便可實現feature一、feature2代碼複用。

綜合兩種差別代碼實現來看,咱們選擇使用Flavor方式來實現代碼差別化管理。其優點以下:

  1. 一個功能模塊只須要維護一套代碼。
  2. 差別代碼在業務庫不一樣Flavor中實現,方便追溯代碼實現歷史以及作差別實現對比。
  3. 對於上層來講,只會依賴下層代碼的不一樣Flavor版本;下層對上層暴露接口也基本同樣,上層不用關心下層差別實現。
  4. 需求版本差別,也只需先在上線一端對應的Flavor中實現,當須要複用時移動到共用的SourceSet下面,就能實現需求代碼複用。

從Android工程結構來看,使用Flavor只能在module內複用,可是以module爲粒度的複用對於差別化管理來講約束過重。這意味着同個module內不一樣模塊的差別代碼同時存在於對應Flavor目錄下,或者說須要將每一個子模塊都建立成不一樣的module,這樣管理代碼是很是不便的。《微信Android模塊化架構重構實踐》一文中提到了一個重要的概念pins工程,pins工程能在module以內再次構建完整的多子工程結構。咱們經過創造性的使用pins工程+Flavor的方案,將差別化的管理單元從module降到了pins工程。而pins工程能夠定義到最小的業務單元,例如一個Java文件。總體的設計實現以下:pins+flavor

pins+flavor

 

具體的配置過程,首先須要在Android Studio工程裏首先要定義兩個Flavor:wm、mt。

productFlavors { wm {} mt {} } 

而後使用pins工程結構,把每一個子業務做爲一個pins工程,實現以下Gradle配置:

最終的工程目錄結構以下:

以名爲base的pins工程爲例,src/base/main是該工程的兩端共用代碼,src/base/wm是該工程的外賣App使用的代碼,src/base/mt是外賣頻道使用的代碼。同時,咱們作了代碼檢查,除了base pins工程能夠依賴之外,其餘pins不存在直接依賴關係。經過這樣實現了module內部更細粒度的工程依賴,同時配合Gradle配置能夠實現只編譯部分pins工程,使總體代碼更加靈活。

經過pins工程+Flavor的差別化管理方式,咱們既實現了需求級別的差別化管理,也實現了模塊內的功能差別化管理。同時,pins工程更好的控制了代碼粒度以及代碼邊界,也將差別代碼控制在比module更小的粒度。

基礎服務的複用

對於一個App來講,基礎服務的重要性不言而喻,因此在平臺化複用中,每每基礎服務的差別最大。因爲基礎服務的使用範圍比較廣,若是基礎服務的差別得不到有效的處理,讓上層感知到差別,就會增長架構層與層之間的耦合,上層自己實現業務的難度也會加大。下文裏講解一個咱們在實踐過程當中遇到的例子,來闡述咱們的主要解決思路。

在前期探索章節中,咱們提到金剛頁因爲兩端基礎Activity差別,以至於要使用代理類來實現Activity生命週期分發。經過採用統一接口以及Flavor方式,咱們能夠統一兩端基礎Activity組件,以下圖所示:

分別將兩端WMBaseActivity和MTBaseActivity的差別接口統一成DialogController、ToastController以及ActionBarController等通用接口,而後在wm、mt兩個Flavor目錄下分別定義全限定名徹底相同的BaseActivity,分別繼承MTBaseActivity和MTBaseActivity並實現統一接口,接口實現儘可能保持一致。對於上層來講,若是繼承BaseActivity,其可調用的接口徹底一致,從而達到屏蔽兩端基礎Activity差別的目的。

對於一些通用基礎組件,因爲使用範圍比較廣,若是不統一或者差別較大,會形成業務層代碼實現差別較大,不利於代碼複用。因此咱們採用的策略是外賣App向外賣頻道看齊。代碼複用前,外賣App主要使用的網絡庫是Volley,統一切換爲外賣頻道使用的MTRetrofit;外賣使用的圖片庫是Fresco,統一切換爲外賣頻道使用的MTPicasso;其餘統一的組件還包括動態加載框架、WebView加載組件、網絡監控Cat、線上監控Holmes、日誌回撈Logan以及降級限流等。兩端代碼複用時,修復問題、監控數據能力方面保持統一。

對於登陸、定位等通用基礎服務,咱們的原則是能統一儘可能統一,這樣能夠有效的減小多端複用中來帶的多端維護成本,多份變成一份。而對於沒法統一的服務,抽象出統一的服務接口,讓上層不感知差別,從而減小上層的複用成本。

組件複用

組件化能夠大大的提升一個App的複用率。對於平臺化複用的業務而言,也是同樣。多個模塊之間也是會常用相同的功能,例以下拉刷新、分頁加載、埋點、樣式等功能。將這些經常使用的功能抽離成組件供上層業務層調用,將能夠大大提升複用效果。能夠說組件化是平臺化複用的必要條件之一。

面對外賣App包含複雜衆多的業務功能,一個功能能夠被拆分紅組件的基本原則是不一樣業務庫中不一樣業務的共用的業務功能或行爲功能。而後按照業務實現中相關性的遠近,自上而下的依賴性將抽離出來的組件劃分爲基礎通用組件、基礎業務組件、UI公共組件。

基礎通用組件指那些變化不大,與業務無關的組件,例如頁面加載下拉刷新組件(p_refresh),日誌記錄相關組件(p_log),異常兜底組件(p_exception)。基礎業務組件指以業務爲基礎的組件:評論通用組件(p_ugc),埋點組件(p_judas),搜索通用組件(p_search),紅包通用組件(p_coupon)等。UI公共組件指公用View或者UI樣式組件,與View 相關的通用組件(p_widget),與UI樣式相關的通用組件(p_theme)。

對於抽離出來的基礎組件,多端之間的差別怎麼處理呢? 例如兜底組件,外賣兜底樣式以黃色爲主調,而外賣頻道中以綠色小團爲主調,如圖所示:

咱們首先將這個組件劃分爲一個pins工程,對於多端的差別,在pins工程裏面利用Flavor管理多端之間的差別。這樣的方案,首先組件是一個獨立的模塊,其次多端的差別在組件內部被統一處理了,上層業務不用感知組件的實現差別。而因爲基礎服務層已經將差別化管理了,組件層也不用感知基礎服務的差別,減小了組件層的複用成本。

頁面複用

對兩端同一個頁面來講,絕大部分的功能模塊是可複用的,可是也存在不一致的功能模塊。之外賣App和美團外賣頻道首頁爲例,中部流量區等業務基本相同,可是頂部導航欄樣式功能和中部流量區佈局在兩端不同,以下圖所示:

針對上述問題,咱們頁面複用的實現思路是頁面模塊化:先將頁面功能按照業務類似性以及兩端差別拆分紅高內聚低耦合的功能單元Block,而後兩端頁面使用拆分的功能單元Block像搭積木似的搭建頁面,單個的單元Block能夠採用MVP模式實現。美團點評內部酒旅的Ripper和到店綜合Shield頁面模塊化開發框架也是採用這樣的思路。因爲咱們要實現兩端複用,還要考慮頁面之間的差別。對於兩端頁面差別,咱們統一使用上文中提到的Flavor機制在業務單元內對兩端差別化管理,業務單元所在頁面不感知業務單元的差別性。對於不一樣的差別,單元Block能夠在MVP不一樣層作差別化管理。

以首頁爲例,首頁Block化複用架構以下圖。兩端首頁頭部導航欄UI展現、數據、功能不同,導航欄整個功能就以一個Flavor在兩端分別實現;商家列表中部流量區部分雖然總體UI佈局不同,可是裏面單個功能Block業務邏輯、整個數據同樣,繼續將中部流量區裏面的業務Block化;下方的商家列表項兩端同樣的功能,用一個公有的Block實現。在各個單元Block已經實現的基礎上,兩端首頁搭建成首頁Fragment。

頁面模塊化後,將兩端不一樣的差別在各個單元Block以Flavor方式處理,業務單元Block所在頁面不用關心各個Block實現差別,不只實現了頁面的複用,各個模塊功能職責分離,還提升了可維護性。

總結

美團外賣業務須要在外賣平臺和美團平臺同時部署,所以,在美團外賣平臺化架構過程當中就產生了平臺化複用的問題。而怎麼去實現平臺化複用呢?筆者認爲須要從不一樣粒度去考慮:基礎服務、組件、頁面。對於基礎服務,咱們須要儘量的統一,不能統一的就抽象服務層。組件級別,須要分塊分層,將依賴梳理好。頁面的複用,最重要的是頁面模塊化和頁面內模塊作到職責分離。平臺化複用最大的難點在於:差別的管理和屏蔽。本文提出使用pins工程+Flavor的方案,能夠使得差別代碼的管理獲得有效的解決。同時利用分層策略,每層都本身處理好本身的差別,使得上層不用關心下層的差別。平臺化複用不能單純的追求複用率,同時要考慮到端的個性化。

到目前爲止,咱們實現了絕大部分外賣App和外賣頻道代碼複用,總體代碼複用率達到88.35%,人效提高70%以上。將來,咱們可能會在外賣平臺、美團平臺、大衆點評平臺三個平臺進行代碼複用,其場景將會更加複雜。固然,咱們在作平臺化複用的時候,要合理地進行評估,複用帶來的「成本節約」和爲了複用帶來的「成本增長」之間的比率。另外,平臺化複用視角不該該侷限於業務頁面的複用,對於監控、測試、研發工具、運維工具等也能夠進行復用,這也是平臺化複用理念的核心價值所在。

參考資料

  1. 美團外賣Android平臺化架構演進實踐
  2. 美團外賣iOS多端複用的推進、支撐與思考
  3. 微信Android模塊化架構重構實踐
  4. 配置構建變體
  5. Shield—開源的移動端頁面模塊化開發框架

 

美團外賣Android平臺化架構演進實踐

美團外賣自2013年建立以來,業務一直高速發展。目前美團外賣日完成訂單量已突破1800萬,成爲美團點評最重要的業務之一。美團外賣的用戶端入口,從單一的外賣獨立App,拓展爲外賣、美團、點評等多個App入口。美團外賣所承載的業務,也從單一的餐飲業務,發展到餐飲、超市、生鮮、果蔬、藥品、鮮花、蛋糕、跑腿等十多個大品類業務。業務的快速發展對客戶端架構不斷提出新的挑戰。

平臺化背景

很早以前,外賣做爲孵化中的項目只有美團外賣App(下文簡稱外賣App)一個入口,後來外賣做爲一個子頻道接入到美團App(下文簡稱外賣頻道),兩端業務並行迭代開發。早期爲了快速上線,開發同窗直接將外賣App的代碼拷貝出一份到外賣頻道,作了簡單的適配就很快接入到美團App了。

早期外賣App和外賣頻道由兩個團隊分別維護,而在隨後一段時間裏,兩端代碼體系差別愈來愈來大。最後演變成了從網絡、圖片等基礎庫到UI控件、類的命名等都不盡相同的兩套代碼。儘管後來兩個團隊合併到一塊兒,但歷史的差別已經造成,爲了優先知足業務需求,很長一段時間內,咱們只能在兩套代碼的基礎上不斷堆積更多的功能。維護兩套代碼的成本可想而知,而業務的迅猛發展又使得這一問題愈加不可忍受。

在咱們探索解決兩端代碼複用的同時,業務的發展又對咱們提出新的挑戰。隨着團隊成員擴充了數倍,商超生鮮等垂直品類的拆分,以及異地研發團隊的創建,外賣客戶端的平臺化被提上日程。而在此以前,外賣App和外賣頻道基本保持單工程開發,這樣的模式顯然是沒法支持多團隊協做開發的。所以,咱們須要快速將代碼重構爲支持平臺化的多工程模式,同時還要考慮業務模塊的解耦,使得新業務能夠拷貝現有的代碼快速上線。此外,在實施平臺化的過程當中,兩端代碼複用的問題尚未解決,若是兩端的代碼沒有統一而直接作平臺化業務拆庫,必然會致使問題的複雜化。

在這樣的背景下,能夠看出咱們面臨的問題相較於其餘平臺型App更爲特殊和複雜:既要解決外賣業務平臺化的問題,又要解決外賣App和外賣頻道兩端代碼複用的問題。

多次探索

在實施平臺化和兩端代碼複用的道路上並不是一路順風,不少方案只有在嘗試以後才知道問題所在。咱們屢次遇到這樣的狀況:設計方案完成後,團隊已經全身心投入到開發之中,可是因爲業務形態發生變化,原有的設計也被迫更改。在不斷的探索和實踐過程當中,咱們經歷了多箇中間階段。雖然有很多失敗的案例,可是也積累了不少架構設計上的寶貴經驗,整個團隊對業務和架構也有了更深的理解。

搜索庫拆分實踐

早期美團外賣App和美團外賣頻道兩個團隊的合併,帶來的最大痛點是代碼複用,而非平臺化,而在很長的一段時間內,咱們也沒有想過從平臺化的角度去解決兩端代碼複用的問題。然而代碼複用的一些失敗嘗試,給後續平臺化的架構帶來了很多寶貴的經驗。當時是怎麼解決代碼複用問題的呢?咱們經過和產品、設計同窗的溝通,約定了將來的需求,會從需求內容、交互、樣式上,兩端儘量的保持一致。通過屢次討論後,團隊發起了兩端代碼複用的技術方案嘗試,咱們決定將搜索模塊從主工程拆分出來,並實現兩端代碼複用。然而兩端的搜索模塊代碼底層差別很大,BaseActivity和BaseFragment不統一,UI樣式不統一,數據Model不統一,圖片、網絡、埋點不統一,而且兩端發版週期也不一致。針對這些問題的解決方案是:

  1. 經過代理屏蔽Activity和Fragment基類不統一的問題;
  2. 兩端主工程style覆蓋搜索庫的UI樣式;
  3. 搜索庫使用獨立的數據Model,上層去作數據適配;
  4. 其餘差別統統拋出接口讓上層實現;
  5. 和PM溝通儘可能使產品需求和發版週期一致。

架構大體如圖:

雖然搜索庫在短時間內拆分爲獨立的工程,並實現了絕大部分的兩端代碼複用,可是好景不長,僅僅更新過幾個版本後,因爲需求和版本發佈週期的差別,搜索庫開始變爲兩個分支,而且兩個分支的差別愈來愈大,最後代碼沒法合併而不得不永久維護兩個搜索庫。搜索庫事實上是一次失敗的拆分,其中的問題總結起來有三個:

  1. 在兩端底層差別巨大的狀況下自上而下的強行拆分,致使大量實現和適配留在了兩端主工程實現,這樣的設計層級混亂,邊界模糊,而且極大的增長了業務開發的複雜性;
  2. 寄但願於兩端需求和發版週期徹底一致這個想法不切實際,若是在架構上不爲兩端的差別性預留可伸縮的空間,複用最終是難以持續的;
  3. 約定或規範,受限於組織架構和具體執行的我的,不肯定性過高。

頁面組件化實踐

在經歷過搜索庫的失敗拆分後,你們認爲目前還不具有實現模塊總體拆分和複用的條件,所以咱們走向了另外一個方向,即實現頁面的組件化以達成部分組件複用的目標。頁面組件化的設計思路是:

  1. 將頁面拆分爲粒度更小的組件,組件內部除了包含UI實現,還包含數據層和邏輯層;
  2. 組件提供個性化配置知足兩端差別需求,若是沒法知足再經過代理拋到上層處理。

頁面組件化是一個良好的設計,但它主要適用於解決Activity巨大化的問題。因爲底層差別巨大的狀況,使得頁面組件化很難實現大規模的複用,複用效率低。另外一方面,頁面組件化也沒有爲2端差別性預留可伸縮的空間。

MVP分層複用實踐

咱們還嘗試過運用設計模式解決兩端代碼複用的問題。想法是將代碼分爲易變的和穩定的兩部分,易變部分在兩端上層實現差別化處理,穩定部分能夠在下層實現複用。方案的主要設計思路是:

  1. 借鑑Clean MVP架構,根據職責將代碼拆分爲Presenter,Data Repository,Use Case,View,Model等角色;
  2. UI、動畫、數據請求等邏輯在下層僅保留接口,在上層實現並注入到下層;
  3. 對於兩端不一致的數據Model,經過轉換器適配爲下層統一的模型。

架構大體如圖:

這是一種靈活、優雅的設計,可以實現部分代碼的複用,並能解決兩端基礎庫和UI等差別。這個方案在首頁和二級頻道頁的部分模塊使用了一段時間,可是由於學習成本較高等緣由推廣比較緩慢。另外,這個時期平臺化已被提上日程,業務痛點決定了咱們必須快速實施模塊總體的拆分和複用,而優雅的設計模式並不適合解決這一類問題。即便從複用性的角度來看,這樣的設計也會使得業務開發變得更爲複雜、調試困難,對於新人來講難以勝任,最終推廣落地困難。

中間層實踐

經過屢次實踐,咱們認識到要實現兩端代碼複用,基礎庫的統一是必然的工做,是其餘一切工做的基礎。不然必然致使複雜和難以維護的設計,最終致使兩端複用沒法快速推動下去。

計算機界有一句名言:「計算機科學領域的任何問題均可以經過增長一箇中間層來解決。」(原始版本出自計算機科學家David Wheeler)咱們固然有想過經過中間層設計屏蔽兩端的基礎庫差別。例如網絡庫,外賣App基於Volley實現,外賣頻道基於Retrofit實現。咱們曾經在Volley和Retrofit之上封裝了一層網絡框架,對外暴露統一的接口,上層能夠切換底層依賴Volley或是Retrofit。但這個中間層並無上線,最終咱們將兩端的網絡庫統一成了Retrofit。這裏面有多個緣由:首先Retrofit自己就是較高層次的封裝,而且擁有優雅的設計模式,理論上咱們很難封裝一套擴展性更強的接口;其次長期來看底層網絡框架變動的風險極低,而且適配網絡層的各類插件也是一件費時費力的事情,所以保持網絡中間層的性價比極低;此外將兩端的網絡請求都替換爲中間層接口,顯然工做量遠大於只保留一端的依賴。

經過實踐咱們認識到,中間層設計是一把雙刃劍。若是基礎框架自己的擴展性足夠強,中間層設計就顯得畫蛇添足,甚至喪失了原有框架的良好特性。

平臺化實踐

好的架構源於不停地衍變,而非設計。對於外賣Android客戶端的平臺化架構構建也是經歷了一樣的過程。咱們從考慮如何解決代碼複用的問題,逐漸的衍變成如何去解決代碼複用和平臺化的兩個問題。而實際上外賣平臺化正是解決兩端代碼複用的一劑良藥。咱們經過創建外賣平臺,將現有的外賣業務降級爲一個頻道,將外賣業務以aar的形式分別接入到外賣平臺和美團平臺,這樣在解決外賣平臺化的同時,代碼複用的問題也將獲得完美的解決。

平臺化架構

通過了整整一年的艱苦奮鬥,造成了如圖所示的美團外賣Android客戶端平臺化架構:

從底層到高層依次爲平臺層、業務層和宿主層。

  1. 平臺層的內容包括,承載上層的數據通訊和頁面跳轉;提供外賣核心服務,例如商品管理、訂單管理、購物車管理等;提供配置管理服務;提供統一的基礎設施能力,例如網絡、圖片、監控、報警、定位、分享、熱修、埋點、Crash上報等;提供其餘管理能力,例如生命週期管理、組件化等。
  2. 業務層的內容包括,外賣業務和垂直業務。
  3. 宿主層的內容包括,Waimai App殼和美團外賣頻道Waimai-channel殼,這一層用於Application的初始化、dex加載和其餘各類必要的組件或基礎庫的初始化。

在構建平臺化架構的過程當中,咱們遇到這樣一個問題,如何長久的維持咱們平臺化架構的層級邊界。試想,若是全部的代碼都在一個工程裏面開發,經過包名、約定去規範層級邊界,任何一個緊急的需求均可能破壞層級邊界。維持層級邊界的最好辦法是什麼?咱們的經驗是工程隔離。平臺化的每一層都去作工程隔離,業務層的每一個業務都創建本身的工程庫,實現工程隔離。同時,配套編譯腳本,檢查業務庫之間是否存在相互依賴關係。工程隔離的好處是顯而易見的:

  1. 每一個工程均可以獨立編譯、獨立打包;
  2. 每一個工程內部的修改,不會影響其餘工程;
  3. 業務庫工程能夠快速拆分出來,集成到其餘App中。

但工程隔離帶來的另外一個問題是,同層間的業務庫須要通訊怎麼辦?這時候就須要提供業務庫通訊框架來解決這個問題。

業務庫通訊框架

在拆分外賣商家業務庫的時候,咱們就發這樣一個案例:在商家頁有一個業務,當發現當前商家是打烊的,就會彈出一個浮層,推薦類似的商家列表,而在咱們以前劃分的外賣子業務庫裏面,類似商家列表應該是屬於頁面庫裏面的內容。那怎麼讓商家業務庫訪問到頁面庫裏面的代碼呢。若是咱們將商家庫去依賴頁面庫,那咱們的層級邊界就會被打破,咱們的依賴關係也會變得複雜。所以咱們須要在架構中提供同層間的通訊框架,它去解決不打破層級邊界的狀況下,完成同層間的通訊。

彙總同層間通訊的場景,大體上能夠劃分爲:頁面的跳轉、基本數據類型的傳遞(包括可序列化的共有類對象的傳遞)、模塊內部自定義方法和類的調用。針對上述狀況,在咱們的架構裏面提供了二種平級間的通訊方式:scheme路由和美團自建的ServiceLoaders sdk。scheme路由本質上是利用Android的scheme原理進行通訊,ServiceLoader本質上是利用的Java反射機制進行通訊。

scheme路由的調用如圖所示:

最終效果:全部業務頁面的跳轉,都須要經過平臺層的scheme路由去分發。經過scheme路由,全部業務都獲得解耦,再也不須要相互依賴而能夠實現頁面的跳轉和基本數據類型的傳遞。

serviceloader的調用如圖所示:

提供方和使用方經過平臺層的一個接口做爲雙方交互的約束。使用方經過平臺層的ServiceLoader完成提供方的實現對象獲取。這種方式能夠解決模塊內部自定義方法和類的調用,例如咱們以前提到了商家庫須要調用頁面庫代碼的問題就能夠經過ServiceLoader解決。

外賣內核模塊設計

在實踐的過程當中,咱們也遇到業務自己上就很差劃分層級邊界的業務。你們能夠從美團外賣三層架構圖上,看出外賣業務庫,像商家、訂單等,是和外賣的垂類業務庫是同級的。而實際上外賣業務的子業務是否應該和垂類業務保持同層是一個目前沒法肯定的事情。

目前,外賣接入的垂類業務商超業務,是隸屬於外賣業務的子頻道,它依然依賴着外賣的核心model、核心服務,包括商品管理、訂單管理、購物車管理等,所以目前它和外賣業務的商家、訂單這樣的子業務庫同層是沒有問題的。但隨着商超業務的發展,商超業務將來可能會建設本身的商品管理、訂單管理、購物車管理的服務,那麼到時商超業務就會上升到和外賣業務同樣同層的業務。這時候,外賣核心管理服務,處在平臺層,就會致使架構的層級邊界變得再也不清晰。

咱們的解決辦法是經過設計一個屬於外賣業務的內核模塊來適應將來的變化,內核模塊的設計如圖:

  1. 內圈爲基礎模型類,這些模型類構成了外賣核心業務(從門店→點菜→購物車→訂單)的基礎;
  2. 中間圈爲依賴基礎模型類構建的基礎服務(CRUD);
  3. 最外圈爲外賣的各維度業務,向內依賴基礎模型圈和外賣基礎服務圈。

若是將來肯定外賣平臺須要接入更多和外賣平級的業務,且最內圈都徹底不同,咱們將把外賣內核模塊上移,在外賣業務子庫下創建對內核模塊的依賴;若是將來只是有更多的外賣子業務的接入,那就繼續保留咱們如今的架構;若是將來接入的業務基礎模型類同樣,但本身的業務服務須要分化,那麼咱們將對保留內核模塊最核心的內圈,並抽象出服務層由外賣和商超上層本身實現真正的服務。

業務庫拆分

在拆分業務庫的時候,咱們面臨着這樣的問題:業務之間的關係是較爲複雜的,如何去拆分業務庫,纔是較爲合理的呢?一開始咱們準備根據外賣業務核心流程:頁面→商家→下單,去拆分外賣業務。可是隨着外賣子頻道業務的快速發展,子頻道業務也創建了本身的研發團隊,在頁面、商家、下單等環節,也開始創建本身的頁面。若是咱們仍然按照外賣下單的流程去拆分庫,那在同一個庫之間,就會有外賣團隊和外賣子頻道團隊共同開發的狀況,這樣職責邊界很不清晰,在實際的開發過程當中,確定會出現理不清的狀況。

咱們都知道軟件工程領域有所謂的康威定律

Organizations which design systems are constrained to produce designs which are copies of the communication structures of these organizations. - Melvin Conway(1967)

翻譯成中文的大概意思是:設計系統的組織,其產生的設計等同於組織以內、組織之間的溝通結構。

在康威定理的指導下:咱們認爲技術架構應該反映出團隊的組織結構,同時,組織結構的變遷,也應該致使技術架構的演進。美團外賣平臺下包含外賣業務和垂直品類業務,對於在咱們團隊中已經有了組織結構,優先組織結構,去拆出獨立的業務庫,方便子業務庫的同窗內部溝通協做,減小他們跨組織溝通的成本。同時,咱們將負責外賣業務的大團隊,再進一步細化成頁面小組、商家小組和訂單小組,由這些小組的同窗去在外賣業務下完成更細維度的外賣子業務庫拆分。根據組織結構劃分的業務庫,自然的存在業務邊界,每一個同窗都會按照本身業務的目標去繼續完善本身的業務庫。這樣的拆庫對內是高內聚,對外是低耦合的,有效的下降了內外溝通協做的成本。

工程內代碼隔離

在實現工程隔離以後,咱們發現工程內部的代碼仍是能夠相互引用的。工程內部若是也不能實現代碼的隔離,那麼工程內部的邊界就是模糊的。咱們但願工程內至少可以實現頁面級別的代碼隔離,由於Activity是組成一個App的頁面單元,圍繞這個Activity,一般會有大量的代碼及資源文件,咱們但願這些代碼和資源文件是被集中管理的。

一般咱們想到的作法是以module工程爲單位的相互隔離,但在module是相對比較重的一個約束,難道每一個Activity都要建一個module嗎?這樣代碼結構會變得很複雜,並且針對一些大的業務體,又會造成巨大化的module。

那咱們又想到規範代碼,用包名去人爲約定,但靠包名約束的代碼,邊界模糊,時不時的緊急需求,就把包名約定打破了,並且資源文件的擺放也是任意的,遷移成本高。

那怎麼去解決工程內部的邊界問題呢?《微信的模塊化架構重構實踐》一文中提到了一個重要的概念p(pins)工程,p工程可謂是工程內約束代碼邊界的重要法寶。經過在Gradle裏面配置sourceSets,就能夠改變工程內的代碼結構目錄,完成代碼的隔離,配置示例:

sourceSets { main { def dirs = ['p_widget', 'p_theme', 'p_shop', 'p_shopcart', 'p_submit_order','p_multperson','p_again_order', 'p_location', 'p_log','p_ugc','p_im','p_share'] dirs.each { dir -> java.srcDir("src/$dir/java") res.srcDir("src/$dir/res") } } } 

效果如圖所示:

從圖上能夠能夠看出,這個業務庫被以頁面爲單元拆分紅了多個p工程,每一個p工程的邊界都是清楚的,實現了工程內的代碼隔離。工程內代碼隔離帶來的好處顯而易見:

  1. p工程實現了最小粒度的代碼邊界約束;
  2. 工程內模塊職責清晰;
  3. 業務模塊能夠被快速的拆分出來。

代碼複用

p工程知足了工程內代碼隔離的需求,可是別忘了,咱們每一個模塊在外賣兩個終端上(外賣App&美團App)上可能存在差別,若是能在模塊內部實現兩端差別,咱們的目標纔算達成。基於上述考慮,咱們想到了使用Gradle提供的productFlavors來實現兩端的差別化。爲此,咱們須要定義兩個flavor:wm和mt。

productFlavors { wm {} mt {} } 

可是,這樣生成的p工程是並列的,也就是說,各個p工程中全部的差別化代碼都須要被存放在這兩個flavor對應的SourceSet下,這豈不是跟模塊間代碼隔離的理念相違背?理想的結構是在p工程內部進行flavor劃分,由p工程內部包容差別化,繼續改爲Gradle腳本以下:

productFlavors {
    wm {}
    mt {}
}
sourceSets {
    def dirs = ['p_restaurant', 'p_goods_detail', 'p_comment', 'p_compose_order', 'p_shopping_cart', 'p_base', 'p_product_set'] main { manifest.srcFile 'src/p_restaurant/main/AndroidManifest.xml' dirs.each { dir -> java.srcDir("src/${dir}/main/java") res.srcDir("src/${dir}/main/res") } } wm { dirs.each { dir -> java.srcDir("src/${dir}/wm/java") res.srcDir("src/${dir}/wm/res") } } mt { dirs.each { dir -> java.srcDir("src/${dir}/mt/java") res.srcDir("src/${dir}/mt/res") } } } 

最終工程結構變成以下:

經過p工程和flavor的靈活應用,咱們最終將業務庫配置成以p工程爲維度的模塊單元,並在p工程內部兼容兩端的共性及差別,代碼複用被很好的解決了。同時,兩端差別的問題是歸屬在p工程內部本身處理的,並無創建中間層,或將差別拋給上層殼工程去完成,這樣的設計遵照了邊界清晰,向下依賴的原則。

可是,工程內隔離也存在與工程隔離同樣的問題:同層級p工程須要通訊怎麼辦?咱們在拆分商家庫的時候,就面臨這這樣的問題,商品活動頁和商品詳情頁,能夠根據頁面維度,去拆分紅2個p工程,這兩個頁面都會用到同一個商品樣式的item。如何讓同層間商品活動頁p工程和商品詳情頁p工程訪問到商品樣式item呢?在實際拆庫的實踐中,咱們逐漸的探索出三級工程結構。三級工程結構不只能夠解決工程內p工程通訊的問題,並且能夠保持架構的靈活性。

三級工程結構

三級工程結構,指的是工程→module→p工程的三級結構。咱們能夠將任何一個很是複雜的業務工程內部劃分紅若干個獨立單元的module工程,同時獨立單元的module工程,咱們能夠繼續去劃分它內部的獨立p工程。由於module是具有編譯時的代碼隔離的,邊界是不容易被打破的,它能夠隨時升級爲一個工程。須要通訊的p工程依賴module的主目錄,base目錄,經過base目錄實現通訊。工程和module具備編譯上隔離代碼的能力,p工程具備最小約束代碼邊界的能力,這樣的設計能夠使得工程內邊界清晰,向下依賴。設計如圖所示:

三級工程結構的最大好處就是,每級均可按照須要靈活的升級或降級,這樣靈活的升降級,能夠隨時適應團隊組織結構的變化,保持架構拆分合並的靈活性,從而動態的知足了康威定理。

工程化建設

平臺化一個直觀的結果就是產生了不少子庫,如何對這些子庫進行有效的工程化管理將是一個影響團隊研發效率的問題。目前爲止,咱們從如下兩個方面作了改進。

一鍵切源碼

主工程集成業務庫時,有兩種依賴模式:aar依賴和源碼依賴。默認是aar依賴,可是在平時開發時,常常須要從aar依賴切換到源碼依賴,好比新需求開發、bugfix及排查問題等。正常狀況咱們須要在各個工程的build.

中將compile aar手動改成compile project,若是業務庫也須要依賴平臺庫源碼,也要作相似的操做。以下圖所示:

這樣手動操做會帶來兩個問題:

  1. build.gradle改動頻繁,若是開發人員不當心push上去了,將會形成各類衝突。
  2. 當業務庫愈來愈多時,這種改動的成本就愈來愈大了。

鑑於這種需求具有通用性,咱們開發了一個Gradle插件,經過主工程的一個配置文件(被git ignore),可一鍵切換至源碼依賴。例如須要源碼依賴商家庫,那麼只須要在主工程中將該庫的源碼依賴開關打開便可。商家庫還依賴平臺庫,默認也是aar依賴,若是想改爲源碼依賴,也只需把開關打開便可。

一鍵打包

業務庫增多之後,構建流程也變得複雜起來,咱們交付的產物有兩種:外賣App的apk和外賣頻道的aar。外賣App的狀況會簡單一些,在Jenkins上關聯各個業務庫指定分支的源碼,直接打包便可。而外賣頻道的狀況則比較複雜,由於受到美團平臺的一些限制,頻道打包不能直接關聯各個業務庫的源碼,只能依賴aar。按照傳統作法,須要逐個打業務庫的aar,而後統一在頻道工程中集成,最後再打頻道aar,這樣效率實在過低。爲此,咱們改進了頻道的打包流程。以下圖所示:

先打平臺庫aar,打完後自動提PR到各個業務庫去修改平臺庫的版本號,接着再逐個觸發業務庫去打aar,業務庫打完aar以後再自動提PR到頻道主庫去修改業務庫的版本號,等所有業務庫aar打完後最後再自動觸發打頻道主庫的aar,至此一鍵打包完畢。

平臺化總結

從搜索庫拆分的第一次嘗試算起,外賣Android客戶端在架構上的持續探索和實踐已經經歷了2年多的時間。起初爲了解決兩端代碼複用的問題,咱們嘗試過自上而下的強行拆分和複用,但很快就暴露出層次混亂、邊界模糊帶來的問題,而且認識到若是不能提供兩端差別化的解決方案,代碼複用是很難持續的。後來咱們又嘗試過運用設計模式約束邊界,先實現解耦再進行復用,但在推廣落地過程當中認識到複雜的設計很難快速推動下去。

在平臺化開始的時候,團隊已經造成了設計簡單、邊界清晰的架構理念。咱們將總體結構劃分爲宿主層、業務層、平臺層,並嚴格約束層次間的依賴關係。在業務模塊拆分的過程當中,咱們借鑑微信的工程結構方案,按照三級工程結構劃分業務邊界,實現靈活的代碼隔離,並下降了後續模塊遷出和遷入成本,使得架構動態知足康威定律。

在兩端代碼複用的問題上,咱們認識到要實現可持續的代碼複用,必須自下向上的逐步統一兩端底層的基礎依賴,同時又能容易的支持兩端上層業務的差別化處理。使用Flavor管理兩端的差別代碼,儘可能減小向上依賴,在具體實施時應用以前積累的解耦設計的經驗,從而知足了架構的可伸縮性。

沒有一個方案能得到每一個人的贊同。在平臺化的實施過程當中,團隊成員屢次對方案選型發生過針鋒相對的討論。這時咱們會拋開技術方案,回到問題自己,去從新審視業務的痛點,列出要解決的問題,再回過頭來看哪個方案可以解決問題。雖然咱們並不經常這麼作,但某些時刻也會強制決策和實施,遇到問題再覆盤和調整。

任何一種設計理念都有其適用場景。咱們在不斷關注業內一些優秀的架構和設計理念,以及公司內部美團App、點評App團隊的平臺化實踐經驗,學習和借鑑了許多優秀的設計思想,但也因爲盲目濫用踩過很多坑。咱們認識到架構的選擇正如其餘技術問題同樣,應該是面向問題的,而不是面向技術自己。架構的演進必須在理論和實踐中交替前行,脫離了其中一個談論架構,都將是個悲劇。

展望

平臺化以後,各業務團隊的協做關係和開發流程都發生了很大轉變。在如何提高平臺支持能力,如何保持架構的穩定性,如何使得各業務進一步解耦等問題上,咱們又將面對新的問題和挑戰。其中有三個問題是亟待咱們解決的:

  1. 要確保在長期的業務迭代中架構不被破壞,除了流程規範以外,還須要在本地編譯、遠程提交、代碼合併、打包提測等各個階段創建更健全的檢查工具來約束,而目前這些工具鏈還並不完善。
  2. 插件化架構是平臺型App集成的最好方式,不只使得子業務具有動態發佈的能力,還能夠解決使人頭疼的編譯速度問題。目前美團平臺已經在部分業務上較好的實現了插件化集成,外賣正在跟進。
  3. 統一頁面級開發的標準化框架,能夠解決代碼的可維護性、可測試性,和更細粒度的可複用性,而且有利於各類自動化方案的實施。目前咱們正在部分業務嘗試,後續會持續推動。

參考資料

  1. MVP + Clean Architecture
  2. 58同城沈劍:好的架構源於不停地衍變,而非設計
  3. 每一個架構師都應該研究下康威定理
  4. 微服務架構的理論基礎 - 康威定律
  5. 架構的本質是管理複雜性,微服務自己也是架構演化的結果
  6. 微信Android模塊化架構重構實踐
  7. 配置構建變體
  8. 美團App 插件化實踐

 

美團外賣Android Lint代碼檢查實踐

概述

Lint是Google提供的Android靜態代碼檢查工具,能夠掃描並發現代碼中潛在的問題,提醒開發人員及早修正,提升代碼質量。除了Android原生提供的幾百個Lint規則,還能夠開發自定義Lint規則以知足實際須要。

爲何要使用Lint

在美團外賣Android App的迭代過程當中,線上問題頻繁發生。開發時很容易寫出一些問題代碼,例如Serializable的使用:實現了Serializable接口的類,若是其成員變量引用的對象沒有實現Serializable接口,序列化時就會Crash。咱們對一些常見問題的緣由和解決方法作分析總結,並在開發人員組內或跟測試人員一塊兒分享交流,幫助相關人員主動避免這些問題。

爲了進一步減小問題發生,咱們逐步完善了一些規範,包括制定代碼規範,增強代碼Review,完善測試流程等。但這些措施仍然存在各類不足,包括代碼規範難以實施,溝通成本高,特別是開發人員變更頻繁致使反覆溝通等,所以其效果有限,類似問題仍然不時發生。另外一方面,愈來愈多的總結、規範文檔,對於組內新人也產生了不小的學習壓力。

有沒有辦法從技術角度減小或減輕上述問題呢?

咱們調研發現,靜態代碼檢查是一個很好的思路。靜態代碼檢查框架有不少種,例如FindBugs、PMD、Coverity,主要用於檢查Java源文件或class文件;再例如Checkstyle,主要關注代碼風格;但咱們最終選擇從Lint框架入手,由於它有諸多優點:

  1. 功能強大,Lint支持Java源文件、class文件、資源文件、Gradle等文件的檢查。
  2. 擴展性強,支持開發自定義Lint規則。
  3. 配套工具完善,Android Studio、Android Gradle插件原生支持Lint工具。
  4. Lint專爲Android設計,原生提供了幾百個實用的Android相關檢查規則。
  5. 有Google官方的支持,會和Android開發工具一塊兒升級完善。

在對Lint進行了充分的技術調研後,咱們根據實際遇到的問題,又作了一些更深刻的思考,包括應該用Lint解決哪些問題,怎麼樣更好的推廣實施等,逐步造成了一套較爲全面有效的方案。

Lint API簡介

爲了方便後文的理解,咱們先簡單看一下Lint提供的主要API。

主要API

Lint規則經過調用Lint API實現,其中最主要的幾個API以下:

  1. Issue:表示一個Lint規則。
  2. Detector:用於檢測並報告代碼中的Issue,每一個Issue都要指定Detector。
  3. Scope:聲明Detector要掃描的代碼範圍,例如JAVA_FILE_SCOPECLASS_FILE_SCOPERESOURCE_FILE_SCOPEGRADLE_SCOPE等,一個Issue可包含一到多個Scope。
  4. Scanner:用於掃描並發現代碼中的Issue,每一個Detector能夠實現一到多個Scanner。
  5. IssueRegistry:Lint規則加載的入口,提供要檢查的Issue列表。

舉例來講,原生的ShowToast就是一個Issue,該規則檢查調用Toast.makeText()方法後是否漏掉了Toast.show()的調用。其Detector爲ToastDetector,要檢查的Scope爲JAVA_FILE_SCOPE,ToastDetector實現了JavaPsiScanner,示意代碼以下:

public class ToastDetector extends Detector implements JavaPsiScanner { public static final Issue ISSUE = Issue.create( "ShowToast", "Toast created but not shown", "...", Category.CORRECTNESS, 6, Severity.WARNING, new Implementation( ToastDetector.class, Scope.JAVA_FILE_SCOPE)); // ... } 

IssueRegistry的示意代碼以下:

public class MyIssueRegistry extends IssueRegistry { @Override public List<Issue> getIssues() { return Arrays.asList( ToastDetector.ISSUE, LogDetector.ISSUE, // ... ); } } 

Scanner

Lint開發過程當中最主要的工做就是實現Scanner。Lint中包括多種類型的Scanner以下,其中最經常使用的是掃描Java源文件和XML文件的Scanner。

  • JavaScanner / JavaPsiScanner / UastScanner:掃描Java源文件
  • XmlScanner:掃描XML文件
  • ClassScanner:掃描class文件
  • BinaryResourceScanner:掃描二進制資源文件
  • ResourceFolderScanner:掃描資源文件夾
  • GradleScanner:掃描Gradle腳本
  • OtherFileScanner:掃描其餘類型文件

值得注意的是,掃描Java源文件的Scanner前後經歷了三個版本。

  1. 最開始使用的是JavaScanner,Lint經過Lombok庫將Java源碼解析成AST(抽象語法樹),而後由JavaScanner掃描。
  2. 在Android Studio 2.2和lint-api 25.2.0版本中,Lint工具將Lombok AST替換爲PSI,同時棄用JavaScanner,推薦使用JavaPsiScanner。 PSI是JetBrains在IDEA中解析Java源碼生成語法樹後提供的API。相比以前的Lombok AST,PSI能夠支持Java 1.八、類型解析等。使用JavaPsiScanner實現的自定義Lint規則,能夠被加載到Android Studio 2.2+版本中,在編寫Android代碼時實時執行。
  3. 在Android Studio 3.0和lint-api 25.4.0版本中,Lint工具將PSI替換爲UAST,同時推薦使用新的UastScanner。 UAST是JetBrains在IDEA新版本中用於替換PSI的API。UAST更加語言無關,除了支持Java,還能夠支持Kotlin。

本文目前仍然基於PsiJavaScanner作介紹。根據UastScanner源碼中的註釋,能夠很容易的從PsiJavaScanner遷移到UastScanner。

Lint規則

咱們須要用Lint檢查代碼中的哪些問題呢?

開發過程當中,咱們比較關注App的Crash、Bug率等指標。經過長期的整理總結髮現,有很多發生頻率很高的代碼問題,其原理和解決方案都很明確,可是在寫代碼時卻很容易遺漏且難以發現;而Lint剛好很容易檢查出這些問題。

Crash預防

Crash率是App最重要的指標之一,避免Crash也一直是開發過程當中比較頭疼的一個問題,Lint能夠很好的檢查出一些潛在的Crash。例如:

  • 原生的NewApi,用於檢查代碼中是否調用了Android高版本才提供的API。在低版本設備中調用高版本API會致使Crash。
  • 自定義的SerializableCheck。實現了Serializable接口的類,若是其成員變量引用的對象沒有實現Serializable接口,序列化時就會Crash。咱們制定了一條代碼規範,要求實現了Serializable接口的類,其成員變量(包括從父類繼承的)所聲明的類型都要實現Serializable接口。
  • 自定義的ParseColorCheck。調用Color.parseColor()方法解析後臺下發的顏色時,顏色字符串格式不正確會致使IllegalArgumentException,咱們要求調用這個方法時必須處理該異常。

Bug預防

有些Bug能夠經過Lint檢查來預防。例如:

  • SpUsage:要求全部SharedPrefrence讀寫操做使用基礎工具類,工具類中會作各類異常處理;同時定義SPConstants常量類,全部SP的Key都要在這個類定義,避免在代碼中分散定義的Key之間衝突。
  • ImageViewUsage:檢查ImageView有沒有設置ScaleType,加載時有沒有設置Placeholder。
  • TodoCheck:檢查代碼中是否還有TODO沒完成。例如開發時可能會在代碼中寫一些假數據,但最終上線時要確保刪除這些代碼。這種檢查項比較特殊,一般在開發完成後提測階段才檢查。

性能/安全問題

一些性能、安全相關問題能夠使用Lint分析。例如: - ThreadConstruction:禁止直接使用new Thread()建立線程(線程池除外),而須要使用統一的工具類在公用線程池執行後臺操做。 - LogUsage:禁止直接使用android.util.Log,必須使用統一工具類。工具類中能夠控制Release包不輸出Log,提升性能,也避免發生安全問題。

代碼規範

除了代碼風格方面的約束,代碼規範更多的是用於減小或防止發生Bug、Crash、性能、安全等問題。不少問題在技術上難以直接檢查,咱們經過封裝統一的基礎庫、制定代碼規範的方式間接解決,而Lint檢查則用於減小組內溝通成本、新人學習成本,並確保代碼規範的落實。例如:

  • 前面提到的SpUsage、ThreadConstruction、LogUsage等。
  • ResourceNaming:資源文件命名規範,防止不一樣模塊之間的資源文件名衝突。

代碼檢查的實施

當檢查出代碼問題時,如何提醒開發者及時修正呢?

早期咱們將靜態代碼檢查配置在Jenkins上,打包發佈AAR/APK時,檢查代碼中的問題並生成報告。後來發現雖然靜態代碼檢查能找出來很多問題,可是不多有人主動去看報告,特別是報告中還有過多可有可無的、優先級很低的問題(例如過於嚴格的代碼風格約束)。

所以,一方面要肯定檢查哪些問題,另外一方面,什麼時候、經過什麼樣的技術手段來執行代碼檢查也很重要。咱們結合技術實現,對此作了更多思考,肯定了靜態代碼檢查實施過程當中的主要目標:

  1. 重點關注高優先級問題,屏蔽低優先級問題。正如前面所說,若是代碼檢查報告中夾雜了大量可有可無的問題,反而影響了關鍵問題的發現。
  2. 高優問題的解決,要有必定的強制性。當檢查發現高優先級的代碼問題時,給開發者明確直接的報錯,並經過技術手段約束,強制要求開發者修復。
  3. 某些問題儘量作到在第一時間發現,從而減小風險或損失。有些問題發現的越早越好,例如業務功能開發中使用了Android高版本API,經過Lint原生的NewApi能夠檢查出來。若是在開發期間發現,當時就能夠考慮其餘技術方案,實現困難時能夠及時和產品、設計人員溝通;而若是到提代碼、提測,甚至發版、上線時才發現,可能爲時已晚。

優先級定義

每一個Lint規則均可以配置Sevirity(優先級),包括Fatal、Error、Warning、Information等,咱們主要使用Error和Warning,以下。

  • Error級別:明確須要解決的問題,包括Crash、明確的Bug、嚴重性能問題、不符合代碼規範等,必須修復。
  • Warning級別:包括代碼編寫建議、可能存在的Bug、一些性能優化等,適當放鬆要求。

執行時機

Lint檢查能夠在多個階段執行,包括在本地手動檢查、編碼實時檢查、編譯時檢查、commit檢查,以及在CI系統中提Pull Request時檢查、打包發版時檢查等,下面分別介紹。

手動執行

在Android Studio中,自定義Lint能夠經過Inspections功能(Analyze - Inspect Code)手動運行。

在Gradle命令行環境下,可直接用./gradlew lint執行Lint檢查。

手動執行簡單易用,但缺少強制性,容易被開發者遺漏。

編碼階段實時檢查

編碼時檢查即在Android Studio中寫代碼時在代碼窗口實時報錯。其好處很明顯,開發者能夠第一時間發現代碼問題。但受限於Android Studio對自定義Lint的支持不完善,開發人員IDE的配置不一樣,須要開發者主動關注報錯並修復,這種方式不能徹底保證效果。

IDEA提供了Inspections功能和相應的API來實現代碼檢查,Android原生Lint就是經過Inspections集成到了Android Studio中。對於自定義Lint規則,官方彷佛沒有給出明確說明,但實際研究發現,在Android Studio 2.2+版本和基於JavaPsiScanner開發的條件下(或Android Studio 3.0+和JavaPsiScanner/UastScanner),IDE會嘗試加載並實時執行自定義Lint規則。

技術細節:

  1. 在Android Studio 2.x版本中,菜單Preferences - Editor - Inspections - Android - Lint - Correctness - Error from Custom Lint Check(avaliable for Analyze|Inspect Code)中指出,自定義Lint只支持命令行或手動運行,不支持實時檢查。

    Error from Custom Rule When custom (third-party) lint rules are integrated in the IDE, they are not available as native IDE inspections, so the explanation text (which must be statically registered by a plugin) is not available. As a workaround, run the lint target in Gradle instead; the HTML report will include full explanations.

  2. 在Android Studio 3.x版本中,打開Android工程源碼後,IDE會加載工程中的自定義Lint規則,在設置菜單的Inspections列表裏能夠查看,和原生Lint效果相同(Android Studio會在打開源文件時觸發對該文件的代碼檢查)。

  3. 分析自定義Lint的IssueRegistry.getIssues()方法調用堆棧,能夠看到Android Studio環境下,是由org.jetbrains.android.inspections.lint.AndroidLintExternalAnnotator調用LintDriver加載執行自定義Lint規則。

    參考代碼: https://github.com/JetBrains/android/tree/master/android/src/org/jetbrains/android/inspections/lint

在Android Studio中的實際效果如圖:

本地編譯時自動檢查

配置Gradle腳本可實現編譯Android工程時執行Lint檢查。好處是既能夠儘早發現問題,又能夠有強制性;缺點是對編譯速度有必定的影響。

編譯Android工程執行的是assemble任務,讓assemble依賴lint任務,便可在編譯時執行Lint檢查;同時配置LintOptions,發現Error級別問題時中斷編譯。

在Android Application工程(APK)中配置以下,Android Library工程(AAR)把applicationVariants換成libraryVariants便可。

android.applicationVariants.all { variant ->
    variant.outputs.each { output ->
        def lintTask = tasks["lint${variant.name.capitalize()}"] output.assemble.dependsOn lintTask } } 

LintOptions的配置:

android.lintOptions {
	abortOnError true } 

本地commit時檢查

利用git pre-commit hook,能夠在本地commit代碼前執行Lint檢查,檢查不經過則沒法提交代碼。這種方式的優點在於不影響開發時的編譯速度,但發現問題相對滯後。

技術實現方面,能夠編寫Gradle腳本,在每次同步工程時自動將hook腳本從工程拷貝到.git/hooks/文件夾下。

提代碼時CI檢查

做爲代碼提交流程規範的一部分,發Pull Request提代碼時用CI系統檢查Lint問題是一個常見、可行、有效的思路。可配置CI檢查經過後代碼才能被合併。

CI系統經常使用Jenkins,若是使用Stash作代碼管理,能夠在Stash上配置Pull Request Notifier for Stash插件,或在Jenkins上配置Stash Pull Request Builder插件,實現發Pull Request時觸發Jenkins執行Lint檢查的Job。

在本地編譯和CI系統中作代碼檢查,均可以經過執行Gradle的Lint任務實現。能夠在CI環境下給Gradle傳遞一個StartParameter,Gradle腳本中若是讀取到這個參數,則配置LintOptions檢查全部Lint問題;不然在本地編譯環境下只檢查部分高優先級Lint問題,減小對本地編譯速度的影響。

Lint生成報告的效果如圖所示:

打包發佈時檢查

即便每次提代碼時用CI系統執行Lint檢查,仍然不能保證全部人的代碼合併後必定沒有問題;另外對於一些特殊的Lint規則,例如前面提到的TodoCheck,還但願在更晚的時候檢查。

因而在CI系統打包發佈APK/AAR用於測試或發版時,還須要對全部代碼再作一次Lint檢查。

最終肯定的檢查時機

綜合考慮多種檢查方式的優缺點以及咱們的目標,最終肯定結合如下幾種方式作代碼檢查:

  1. 編碼階段IDE實時檢查,第一時間發現問題。
  2. 本地編譯時,及時檢查高優先級問題,檢查經過才能編譯。
  3. 提代碼時,CI檢查全部問題,檢查經過才能合代碼。
  4. 打包階段,完整檢查工程,確保萬無一失。

配置文件支持

爲了方便代碼管理,咱們給自定義Lint建立了一個獨立的工程,該工程打包生成一個AAR發佈到Maven倉庫,而被檢查的Android工程依賴這個AAR(具體開發過程能夠參考文章末尾連接)。

自定義Lint雖然在獨立工程中,但和被檢查的Android工程中的代碼規範、基礎組件等存在較多耦合。

例如咱們使用正則表達式檢查Android工程的資源文件命名規範,每次業務邏輯變更要新增資源文件前綴時,都要修改Lint工程,發佈新的AAR,再更新到Android工程中,很是繁瑣。另外一方面,咱們的Lint工程除了在外賣C端Android工程中使用,也但願能直接用在其餘端的其餘Android工程中,而不一樣工程之間存在差別。

因而咱們嘗試使用配置文件來解決這一問題。以檢查Log使用的LogUsage爲例,不一樣工程封裝了不一樣的Log工具類,報錯時提示信息也應該不同。定義配置文件名爲custom-lint-config.json,放在被檢查Android工程的模塊目錄下。在Android工程A中的配置文件是:

{
	"log-usage-message": "請勿使用android.util.Log,建議使用LogUtils工具類" } 

而Android工程B的配置文件是:

{
	"log-usage-message": "請勿使用android.util.Log,建議使用Logger工具類" } 

從Lint的Context對象可獲取被檢查工程目錄從而讀取配置文件,關鍵代碼以下:

import com.android.tools.lint.detector.api.Context; public final class LintConfig { private LintConfig(Context context) { File projectDir = context.getProject().getDir(); File configFile = new File(projectDir, "custom-lint-config.json"); if (configFile.exists() && configFile.isFile()) { // 讀取配置文件... } } } 

配置文件的讀取,能夠在Detector的beforeCheckProject、beforeCheckLibraryProject回調方法中進行。LogUsage中檢查到錯誤時,根據配置文件定義的信息報錯。

public class LogUsageDetector extends Detector implements Detector.JavaPsiScanner { // ... private LintConfig mLintConfig; @Override public void beforeCheckProject(@NonNull Context context) { // 讀取配置 mLintConfig = new LintConfig(context); } @Override public void beforeCheckLibraryProject(@NonNull Context context) { // 讀取配置 mLintConfig = new LintConfig(context); } @Override public List<String> getApplicableMethodNames() { return Arrays.asList("v", "d", "i", "w", "e", "wtf"); } @Override public void visitMethod(JavaContext context, JavaElementVisitor visitor, PsiMethodCallExpression call, PsiMethod method) { if (context.getEvaluator().isMemberInClass(method, "android.util.Log")) { // 從配置文件獲取Message String msg = mLintConfig.getConfig("log-usage-message"); context.report(ISSUE, call, context.getLocation(call.getMethodExpression()), msg); } } } 

模板Lint規則

Lint規則開發過程當中,咱們發現了一系列類似的需求:封裝了基礎工具類,但願你們都用起來;某個方法很容易拋出RuntimeException,有必要作處理,但Java語法上RuntimeException並不強制要求處理從而常常遺漏……

這些類似的需求,每次在Lint工程中開發一樣會很繁瑣。咱們嘗試實現了幾個模板,能夠直接在Android工程中經過配置文件配置Lint規則。

以下爲一個配置文件示例:

{
  "lint-rules": { "deprecated-api": [{ "method-regex": "android\\.content\\.Intent\\.get(IntExtra|StringExtra|BooleanExtra|LongExtra|LongArrayExtra|StringArrayListExtra|SerializableExtra|ParcelableArrayListExtra).*", "message": "避免直接調用Intent.getXx()方法,特殊機型可能發生Crash,建議使用IntentUtils", "severity": "error" }, { "field": "java.lang.System.out", "message": "請勿直接使用System.out,應該使用LogUtils", "severity": "error" }, { "construction": "java.lang.Thread", "message": "避免單首創建Thread執行後臺任務,存在性能問題,建議使用AsyncTask", "severity": "warning" }, { "super-class": "android.widget.BaseAdapter", "message": "避免直接使用BaseAdapter,應該使用統一封裝的BaseListAdapter", "severity": "warning" }], "handle-exception": [{ "method": "android.graphics.Color.parseColor", "exception": "java.lang.IllegalArgumentException", "message": "Color.parseColor須要加try-catch處理IllegalArgumentException異常", "severity": "error" }] } } 

示例配置中定義了兩種類型的模板規則:

  • DeprecatedApi:禁止直接調用指定API
  • HandleException:調用指定API時,須要加try-catch處理指定類型的異常

問題API的匹配,包括方法調用(method)、成員變量引用(field)、構造函數(construction)、繼承(super-class)等類型;匹配字符串支持glob語法或正則表達式(和lint.xml中ignore的配置語法一致)。

實現方面,主要是遍歷Java語法樹中特定類型的節點並轉換成完整字符串(例如方法調用android.content.Intent.getIntExtra),而後檢查是否有模板規則與其匹配。匹配成功後,DeprecatedApi規則直接輸出message報錯;HandleException規則會檢查匹配到的節點是否處理了特定Exception(或Exception的父類),沒有處理則報錯。

按Git版本檢查新增文件

隨着Lint新規則的不斷開發,咱們又遇到了一個問題。Android工程中存在大量歷史代碼,不符合新增Lint規則的要求,但也沒有致使明顯問題,這時接入新增Lint規則要求修改全部歷史代碼,成本較高並且有必定風險。例如新增代碼規範,要求使用統一的線程工具類而不容許直接用Handler以免內存泄露等。

咱們嘗試了一個折中的方案:只檢查指定git commit以後新增的文件。在配置文件中添加配置項,給Lint規則配置git-base屬性,其值爲commit ID,只檢查這次commit以後新增的文件。

實現方面,執行git rev-parse --show-toplevel命令獲取git工程根目錄的路徑;執行git ls-tree --full-tree --full-name --name-only -r <commit-id>命令獲取指定commit時已有文件列表(相對git根目錄的路徑)。在Scanner回調方法中經過Context.getLocation(node).getFile()獲取節點所在文件,結合git文件列表判斷是否須要檢查這個節點。須要注意的是,代碼量較大時要考慮Lint檢查對電腦的性能消耗。

總結

通過一段時間的實踐發現,Lint靜態代碼檢查在解決特定問題時的效果很是好,例如發現一些語言或API層面比較明確的低級錯誤、幫助進行代碼規範的約束。使用Lint前,很多這類問題剛好對開發人員來講又很容易遺漏(例如原生的NewApi檢查、自定義的SerializableCheck);相同問題反覆出現;代碼規範的執行,特別是有新人蔘與開發時,須要很高的學習和溝通成本,還常常出現新人提交代碼時因爲沒有遵照代碼規範反覆被要求修改。而使用Lint後,這些問題都能在第一時間獲得解決,節省了大量的人力,提升了代碼質量和開發效率,也提升了App的使用體驗。

參考資料與擴展閱讀

Lint和Gradle相關技術細節還能夠閱讀我的博客:

 

Android動態日誌系統Holmes

背景

美團是全球領先的一站式生活服務平臺,爲6億多消費者和超過450萬優質商戶提供鏈接線上線下的電子商務網絡。美團的業務覆蓋了超過200個豐富品類和2800個城區縣網絡,在餐飲、外賣、酒店旅遊、麗人、家庭、休閒娛樂等領域具備領先的市場地位。平臺大,責任也大。在移動端,如何快速定位並解決線上問題提升用戶體驗給咱們帶來了極大挑戰。線上偶爾會發生某一個頁面打不開、新活動搶單按鈕點擊沒響應、登陸不了、不能下單等現象,因爲Android碎片化、網絡環境、機型ROM、操做系統版本、本地環境複雜多樣,這些個性化的使用場景很難在本地復現,加上問題反饋的時候描述的每每都比較模糊,快速定位並解決問題難度不小。爲此,咱們開發了動態日誌系統Holmes,但願它能像大偵探福爾摩斯那樣幫咱們順着線上bug的蛛絲馬跡,發現背後真相。

現有的解決辦法

  • 發臨時包用戶安裝
  • QA嘗試去復現問題
  • 在線debug調試工具
  • 預先手動埋點回撈

現有辦法的弊端

  • 臨時發包:用戶配合過程繁瑣,並且解決問題時間很長
  • QA復現:嘗試已有機型發現個性化場景很難復現
  • 在線debug:網絡環境不穩定,代碼混淆調試成本很高,佔用用戶過多時間用戶難以接受
  • 手動埋點:覆蓋範圍有限,沒法提早預知,並且因爲業務量大、多地區協做開發、業務類型多等形成很難統一埋點方案,而且在排查問題時大量的手動埋點會產生不少冗餘的上報數據,尋找一條有用的埋點信息猶如大海撈針

目標訴求

  • 快速拿到線上日誌
  • 不須要大量埋點甚至不埋點
  • 精準的問題現場日誌

實現

針對難定位的線上問題,動態日誌提供了一套快速定位問題的方案。預先在用戶手機自動產生方法執行的日誌信息,當須要排查用戶問題時,經過信令下發精準回撈用戶日誌,再現用戶操做路徑;動態日誌系統也支持動態下發代碼,從而實現動態分析運行時對象快照、動態增長埋點等功能,可以分析複雜使用場景下的用戶問題。

自動埋點

自動埋點是線上App自動產生日誌,怎麼樣自動產生日誌呢?咱們對方法進行了插樁來記錄方法執行路徑(調用堆棧),在方法的開頭插入一段樁代碼,當方法運行的時候就會記錄方法簽名、進程、線程、時間等造成一條完整的執行信息(這裏咱們叫TraceLog),將TraceLog存入DB等待信令下發回撈數據。

public void onCreate(Bundle bundle) { //插樁代碼 if (Holmes.isEnable(....)) { Holmes.invoke(....); return; } super.onCreate(bundle); setContentView(R.layout.main); } 

歷史數據

Tracelog造成的是代碼的歷史執行路徑,一旦線上出現問題就能夠回撈用戶歷史數據來排查問題,而且Tracelog有如下幾個優勢:

  1. Tracelog是自動產生的無需開發者手動埋點
  2. 插樁覆蓋了全部的業務代碼,並且這裏Tracelog不受Proguard內聯方法的限制,插樁在Proguard以前因此方法被內聯以後樁代碼也會被內聯,這樣就會記錄下來對照原始代碼的完整執行路徑信息
  3. 回撈日誌能夠基於一個方法爲中心點向前或者向後採集日誌(例如:點擊下單按鈕無響應只須要回撈點擊下單按鈕事件以後的代碼執行路徑來分析問題),這樣能夠避免上報一堆無用日誌,減小咱們排查問題的時間和下降複雜度

Tracelog工做的流程

方法運行產生方法調用日誌首先會通過checker進行檢測,checker包含線程檢測和方法檢測(減小信息干擾),線程檢測主要過濾相似於定時任務這種一直在不斷的產生日誌的線程,方法檢測會在必定時間內檢測方法調用的頻率,過濾掉頻繁調用的方法,方法若是不會被過濾就會進行異步處理,其次向對象池獲取一個Tracelog對象,Tracelog對象進入生產隊列組裝時間、線程、序列號等信息,完成後進入消費隊列,最後消費隊列到達固定數量以後批量處理存入DB。

Tracelog數據展現

日誌回撈到Trace平臺上按時間順序排列展現結果:

問題總結

咱們的平臺部署實施了幾個版本,總結了不少的案例。通過實戰的考驗發現多數的場景下用戶回撈Tracelog分析問題只能把問題的範圍不斷的縮小,可是不少的問題肯定了是某一個方法的異常,這個時候是須要知道方法的執行信息好比:入參、當前對象字段、返回值等信息來肯定代碼的執行邏輯,只有Tracelog在這裏的感受就比如只差臨門一腳了,怎麼才能獲取方法運行時產生的內存快照呢?這正是體現動態日誌的動態性能力。

動態下發

對目標用戶下發信令,動態執行一段代碼並將結果上報,咱們利用Lua腳本在方法運行的時候去獲取對象的快照信息。爲何選擇Lua?Lua運行時庫很是小而且能夠調用Java代碼並且語言精簡易懂。動態執行Lua有三個重要的時機:當即執行、方法前執行、方法後執行。

  • 當即執行:接受到信令以後就會立馬去執行並上報結果
  • 方法前執行:在某一個方法執行以前執行Lua腳本,動態獲取入參、對象字段等信息
  • 方法後執行:在某一個方法執行以後執行Lua腳本,動態獲取返回值、入參變化、對象字段變化等信息

在方法後執行Lua腳本遇到了一些問題,咱們只在方法前插樁,若是在方法後也插樁這樣能解決在方法後執行的問題,可是這樣增長代碼體積和影響proguard內聯方法數,如何解決這個問題以下:

咱們利用反射執行當前方法,當進入方法前面的插樁代碼不會直接執行本方法的方法體會在樁代碼裏經過反射調用本身,這樣就作到了一個動態AOP的功能就能夠在方法以後執行腳本,一樣這種方法也存在一個問題,就是會出現死循環,解決這個問題的辦法只須要在執行反射的時候標記是反射調用進來的就能夠避免死循環的問題。

咱們還能夠讓腳本作些什麼呢?除了能夠獲取對象的快照信息外,還增長了DB查詢、上報普通文本、ShardPreferences查詢、獲取Context對象、查詢權限、追加埋點到本地、上傳文件等綜合能力,並且Lua腳本的功能遠不只如此,能夠利用Lua腳本調用Java的方法來模擬代碼邏輯,從而實現更深層次的動態能力。

動態下發數據展現

對象數據

對象數據

 

權限信息

權限信息

 

DB數據

DB數據

 

技術挑戰

動態日誌在開發的過程中遇到了不少的技術難點,咱們在實施方案的時候遇到不少的問題,下面來回顧一下問題及解決方案。

數據量大的問題

  • 主線程卡頓

    • 1. 因爲同時會有多個線程產生日誌,因此要考慮到線程同步安全的問題。使用synchronized或者lock能夠保證同步安全問題,可是同時也帶來多線程之間鎖互斥的問題,形成主線程等待並卡頓,這裏使用CAS技術方案來實現自定義數據結構,保證線程同步安全的狀況下並解決了多線程之間鎖互斥的問題。
    • 2. 因爲數據產生太多,因此在存儲DB的時候就會產生大量的IO,致使CPU佔用時間過長從而影響其餘線程使用CPU的時間。針對這個問題,首先是採起線程過濾和方法過濾來減小產生無用的日誌,而且下降處理線程的級別不與主線程爭搶CPU時間,而後對數據進行批量處理來減小IO的頻率,並在數據庫操做上將原來的Delete+insert的操做改成update+insert。Tracelog固定存儲30萬條數據(大約美團App使用6次以上的記錄),若是滿30萬就刪除早期的一部分數據再寫入新的數據。操做越久,delete操做越多,CPU資源佔比越大。通過數據庫操做的實際對比發現,直接改成滿30萬以後使用update來更新數據效率會更高一些(這裏就不作太多的詳細說明)。咱們的優化成果從起初的CPU佔比40%多下降到了20%左右再降到10%之內,這是在中低端的機器上測試的結果。
  • 建立對象過多致使頻繁GC

    • 日誌產生就會生成一個Tracelog對象,大量的日誌會形成頻繁的GC,針對這個問題咱們使用了對象池來使對象複用,從而減小建立對象減低GC頻率,對象池是相似於android.os.Message.obtain()的工做原理。
  • 干擾日誌太多影響分析問題

    • 咱們已通過濾掉了大部分的干擾日誌,但仍是會有一些代碼執行比較頻繁的方法會產生干擾日誌。例如:自定義View庫、日誌類型的庫、監控類型的庫等,這些方法的日誌會影響咱們DB的存儲空間,形成保留不了太多的正常方法執行路徑,這種狀況下頗有可能會出現開發這關心的日誌其實已經被沖掉了。怎麼解決這個問題那?在插樁的時候可以讓開發者配置一些過濾或者識別的規則來認定是否要處理這個方法,在插樁的方法上增長一個二進制的參數,而後根據配置的規則會在相應的位上設置成0或者1,方法執行的時候只須要一個異或操做就能知道是否須要記錄這個方法,這樣增長的識別判斷幾乎對原來的方法執行耗時不會產生任何影響,使用這種方案產生的日誌就是開發者所指望的日誌,通過幾番測試以後咱們的日誌也能保留住用戶6次以上的完整行爲,並且CPU的佔用時間也下降到了5%之內。

性能影響

對每個方法進行插樁記錄日誌,會對代碼會形成方法耗時的影響嗎?最終咱們在中低端機型上分別測試了方法的耗時和CPU的使用佔比。

  • 方法耗時影響的測試,100萬次耗時平均值在55~65ms之間,方法執行一次的耗時就微乎其微了
  • CPU的耗時測試在5%之內,以下圖所示:

  • 內存的使用測試在56kB左右,以下圖:

對象快照

在方法運行時獲取對象快照保留現場日誌,提取對象快照就須要對一個對象進行深度clone(爲了防止在尚未完整記錄下來信息以前對象已經被改變,影響最終判斷代碼執行的結果),在Java中clone對象有如下幾種方法:

  • 實現一個clone接口
  • 實現一個序列化接口
  • 使用Gson序列化

clone接口和序列化接口都有一樣的一個問題,有可能這個對象沒有實現相應的接口,這樣是沒法進行深度clone的,並且實現clone接口也作不到深度clone,Java序列化有IO問題執行效率很低。最後可能只有Gson序列化這個方法還可行,可是Gson也有不少的坑,若是一個對象中有和父類同樣的字段,那麼Gson在作序列的時候把父類的字段覆蓋掉;若是兩個對象有相互引用的場景,那麼在Gson序列化的時候直接會死循環。

怎麼解決以上的這些問題呢?最後咱們參照一些開源庫的方案和Java系統的一些API,開發出了一個深度clone的庫,再加上本身定義數據對象和使用Gson來解決對象快照的問題。深度clone實現主要利用了Java系統API,先建立出來一個目標對象的空殼對象,而後利用反射將原對象上的全部字段都複製到這個空殼對象上,最後這個空殼對象會造成跟原有對象徹底同樣的東西,同時對Android增長的一些類型進行了特殊處理,在提升速度上對基本類型、集合、map等系統自帶類型作了快速處理,clone完成的對象直接進行快照處理。

總結

動態日誌對業務開發零成本,對用戶使用無打擾。在排查線上問題時,方法執行路徑可能直接就會反映出問題的緣由,至少也能縮小問題代碼的範圍,最終鎖定到某一個方法,這時再使用動態下發Lua腳本,最終肯定問題代碼的位置。動態日誌的動態下發功能也能夠作爲一種基礎的能力,提供給其餘須要動態執行代碼或動態獲取數據的基礎庫,例如:遇到一些難解決的崩潰場景,除了正常的棧信息外,同時也能夠根據不一樣的崩潰類型,動態採集一些其餘的輔助信息來幫助排查問題。

 

Android消息總線的演進之路:用LiveDataBus替代RxBus、EventBus

背景

對於Android系統來講,消息傳遞是最基本的組件,每個App內的不一樣頁面,不一樣組件都在進行消息傳遞。消息傳遞既能夠用於Android四大組件之間的通訊,也可用於異步線程和主線程之間的通訊。對於Android開發者來講,常用的消息傳遞方式有不少種,從最先使用的Handler、BroadcastReceiver、接口回調,到近幾年流行的通訊總線類框架EventBus、RxBus。Android消息傳遞框架,總在不斷的演進之中。

從EventBus提及

EventBus是一個Android事件發佈/訂閱框架,經過解耦發佈者和訂閱者簡化Android事件傳遞。EventBus能夠代替Android傳統的Intent、Handler、Broadcast或接口回調,在Fragment、Activity、Service線程之間傳遞數據,執行方法。

EventBus最大的特色就是:簡潔、解耦。在沒有EventBus以前咱們一般用廣播來實現監聽,或者自定義接口函數回調,有的場景咱們也能夠直接用Intent攜帶簡單數據,或者在線程之間經過Handler處理消息傳遞。但不管是廣播仍是Handler機制遠遠不能知足咱們高效的開發。EventBus簡化了應用程序內各組件間、組件與後臺線程間的通訊。EventBus一經推出,便受到廣大開發者的推崇。

如今看來,EventBus給Android開發者世界帶來了一種新的框架和思想,就是消息的發佈和訂閱。這種思想在其後不少框架中都獲得了應用。

圖片摘自EventBus GitHub主頁

圖片摘自EventBus GitHub主頁

 

發佈/訂閱模式

訂閱發佈模式定義了一種「一對多」的依賴關係,讓多個訂閱者對象同時監聽某一個主題對象。這個主題對象在自身狀態變化時,會通知全部訂閱者對象,使它們可以自動更新本身的狀態。

RxBus的出現

RxBus不是一個庫,而是一個文件,實現只有短短30行代碼。RxBus自己不須要過多分析,它的強大徹底來自於它基於的RxJava技術。響應式編程(Reactive Programming)技術這幾年特別火,RxJava是它在Java上的實做。RxJava天生就是發佈/訂閱模式,並且很容易處理線程切換。因此,RxBus憑藉區區30行代碼,就敢挑戰EventBus江湖老大的地位。

RxBus原理

在RxJava中有個Subject類,它繼承Observable類,同時實現了Observer接口,所以Subject能夠同時擔當訂閱者和被訂閱者的角色,咱們使用Subject的子類PublishSubject來建立一個Subject對象(PublishSubject只有被訂閱後纔會把接收到的事件馬上發送給訂閱者),在須要接收事件的地方,訂閱該Subject對象,以後若是Subject對象接收到事件,則會發射給該訂閱者,此時Subject對象充當被訂閱者的角色。

完成了訂閱,在須要發送事件的地方將事件發送給以前被訂閱的Subject對象,則此時Subject對象做爲訂閱者接收事件,而後會馬上將事件轉發給訂閱該Subject對象的訂閱者,以便訂閱者處理相應事件,到這裏就完成了事件的發送與處理。

最後就是取消訂閱的操做了,RxJava中,訂閱操做會返回一個Subscription對象,以便在合適的時機取消訂閱,防止內存泄漏,若是一個類產生多個Subscription對象,咱們能夠用一個CompositeSubscription存儲起來,以進行批量的取消訂閱。

RxBus有不少實現,如:

AndroidKnife/RxBus(https://github.com/AndroidKnife/RxBus) Blankj/RxBus(https://github.com/Blankj/RxBus)

其實正如前面所說的,RxBus的原理是如此簡單,咱們本身均可以寫出一個RxBus的實現:

基於RxJava1的RxBus實現:

public final class RxBus { private final Subject<Object, Object> bus; private RxBus() { bus = new SerializedSubject<>(PublishSubject.create()); } private static class SingletonHolder { private static final RxBus defaultRxBus = new RxBus(); } public static RxBus getInstance() { return SingletonHolder.defaultRxBus; } /* * 發送 */ public void post(Object o) { bus.onNext(o); } /* * 是否有Observable訂閱 */ public boolean hasObservable() { return bus.hasObservers(); } /* * 轉換爲特定類型的Obserbale */ public <T> Observable<T> toObservable(Class<T> type) { return bus.ofType(type); } } 

基於RxJava2的RxBus實現:

public final class RxBus2 { private final Subject<Object> bus; private RxBus2() { // toSerialized method made bus thread safe bus = PublishSubject.create().toSerialized(); } public static RxBus2 getInstance() { return Holder.BUS; } private static class Holder { private static final RxBus2 BUS = new RxBus2(); } public void post(Object obj) { bus.onNext(obj); } public <T> Observable<T> toObservable(Class<T> tClass) { return bus.ofType(tClass); } public Observable<Object> toObservable() { return bus; } public boolean hasObservers() { return bus.hasObservers(); } } 

引入LiveDataBus的想法

從LiveData談起

LiveData是Android Architecture Components提出的框架。LiveData是一個能夠被觀察的數據持有類,它能夠感知並遵循Activity、Fragment或Service等組件的生命週期。正是因爲LiveData對組件生命週期可感知特色,所以能夠作到僅在組件處於生命週期的激活狀態時才更新UI數據。

LiveData須要一個觀察者對象,通常是Observer類的具體實現。當觀察者的生命週期處於STARTED或RESUMED狀態時,LiveData會通知觀察者數據變化;在觀察者處於其餘狀態時,即便LiveData的數據變化了,也不會通知。

LiveData的優勢

  • UI和實時數據保持一致 由於LiveData採用的是觀察者模式,這樣一來就能夠在數據發生改變時得到通知,更新UI。
  • 避免內存泄漏 觀察者被綁定到組件的生命週期上,當被綁定的組件銷燬(destroy)時,觀察者會馬上自動清理自身的數據。
  • 不會再產生因爲Activity處於stop狀態而引發的崩潰

例如:當Activity處於後臺狀態時,是不會收到LiveData的任何事件的。

  • 不須要再解決生命週期帶來的問題 LiveData能夠感知被綁定的組件的生命週期,只有在活躍狀態纔會通知數據變化。
  • 實時數據刷新 當組件處於活躍狀態或者從不活躍狀態到活躍狀態時老是能收到最新的數據。
  • 解決Configuration Change問題 在屏幕發生旋轉或者被回收再次啓動,馬上就能收到最新的數據。

談一談Android Architecture Components

Android Architecture Components的核心是Lifecycle、LiveData、ViewModel 以及 Room,經過它能夠很是優雅的讓數據與界面進行交互,並作一些持久化的操做,高度解耦,自動管理生命週期,並且不用擔憂內存泄漏的問題。

  • Room 一個強大的SQLite對象映射庫。
  • ViewModel 一類對象,它用於爲UI組件提供數據,在設備配置發生變動時依舊能夠存活。
  • LiveData 一個可感知生命週期、可被觀察的數據容器,它能夠存儲數據,還會在數據發生改變時進行提醒。
  • Lifecycle 包含LifeCycleOwer和LifecycleObserver,分別是生命週期全部者和生命週期感知者。

Android Architecture Components的特色

  • 數據驅動型編程 變化的永遠是數據,界面無需更改。
  • 感知生命週期,防止內存泄漏。
  • 高度解耦 數據,界面高度分離。
  • 數據持久化 數據、ViewModel不與UI的生命週期掛鉤,不會由於界面的重建而銷燬。

重點:爲何使用LiveData構建數據通訊總線LiveDataBus

使用LiveData的理由

  • LiveData具備的這種可觀察性和生命週期感知的能力,使其很是適合做爲Android通訊總線的基礎構件。
  • 使用者不用顯示調用反註冊方法。

因爲LiveData具備生命週期感知能力,因此LiveDataBus只須要調用註冊回調方法,而不須要顯示的調用反註冊方法。這樣帶來的好處不只能夠編寫更少的代碼,並且能夠徹底杜絕其餘通訊總線類框架(如EventBus、RxBus)忘記調用反註冊所帶來的內存泄漏的風險。

爲何要用LiveDataBus替代EventBus和RxBus

  • LiveDataBus的實現及其簡單 相對EventBus複雜的實現,LiveDataBus只須要一個類就能夠實現。
  • LiveDataBus能夠減少APK包的大小 因爲LiveDataBus只依賴Android官方Android Architecture Components組件的LiveData,沒有其餘依賴,自己實現只有一個類。做爲比較,EventBus JAR包大小爲57kb,RxBus依賴RxJava和RxAndroid,其中RxJava2包大小2.2MB,RxJava1包大小1.1MB,RxAndroid包大小9kb。使用LiveDataBus能夠大大減少APK包的大小。
  • LiveDataBus依賴方支持更好 LiveDataBus只依賴Android官方Android Architecture Components組件的LiveData,相比RxBus依賴的RxJava和RxAndroid,依賴方支持更好。
  • LiveDataBus具備生命週期感知 LiveDataBus具備生命週期感知,在Android系統中使用調用者不須要調用反註冊,相比EventBus和RxBus使用更爲方便,而且沒有內存泄漏風險。

LiveDataBus的設計和架構

LiveDataBus的組成

  • 消息 消息能夠是任何的Object,能夠定義不一樣類型的消息,如Boolean、String。也能夠定義自定義類型的消息。
  • 消息通道 LiveData扮演了消息通道的角色,不一樣的消息通道用不一樣的名字區分,名字是String類型的,能夠經過名字獲取到一個LiveData消息通道。
  • 消息總線 消息總線經過單例實現,不一樣的消息通道存放在一個HashMap中。
  • 訂閱 訂閱者經過getChannel獲取消息通道,而後調用observe訂閱這個通道的消息。
  • 發佈 發佈者經過getChannel獲取消息通道,而後調用setValue或者postValue發佈消息。

LiveDataBus原理圖

LiveDataBus原理圖

LiveDataBus原理圖

 

LiveDataBus的實現

第一個實現:

public final class LiveDataBus { private final Map<String, MutableLiveData<Object>> bus; private LiveDataBus() { bus = new HashMap<>(); } private static class SingletonHolder { private static final LiveDataBus DATA_BUS = new LiveDataBus(); } public static LiveDataBus get() { return SingletonHolder.DATA_BUS; } public <T> MutableLiveData<T> getChannel(String target, Class<T> type) { if (!bus.containsKey(target)) { bus.put(target, new MutableLiveData<>()); } return (MutableLiveData<T>) bus.get(target); } public MutableLiveData<Object> getChannel(String target) { return getChannel(target, Object.class); } } 

短短二十行代碼,就實現了一個通訊總線的所有功能,而且還具備生命週期感知功能,而且使用起來也及其簡單:

註冊訂閱:

LiveDataBus.get().getChannel("key_test", Boolean.class) .observe(this, new Observer<Boolean>() { @Override public void onChanged(@Nullable Boolean aBoolean) { } }); 

發送消息:

LiveDataBus.get().getChannel("key_test").setValue(true); 

咱們發送了一個名爲」key_test」,值爲true的事件。

這個時候訂閱者就會收到消息,並做相應的處理,很是簡單。

問題出現

對於LiveDataBus的初版實現,咱們發現,在使用這個LiveDataBus的過程當中,訂閱者會收到訂閱以前發佈的消息。對於一個消息總線來講,這是不可接受的。不管EventBus或者RxBus,訂閱方都不會收到訂閱以前發出的消息。對於一個消息總線,LiveDataBus必需要解決這個問題。

問題分析

怎麼解決這個問題呢?先分析下緣由:

當LifeCircleOwner的狀態發生變化的時候,會調用LiveData.ObserverWrapper的activeStateChanged函數,若是這個時候ObserverWrapper的狀態是active,就會調用LiveData的dispatchingValue。

在LiveData的dispatchingValue中,又會調用LiveData的considerNotify方法。

在LiveData的considerNotify方法中,紅框中的邏輯是關鍵,若是ObserverWrapper的mLastVersion小於LiveData的mVersion,就會去回調mObserver的onChanged方法。而每一個新的訂閱者,其version都是-1,LiveData一旦設置過其version是大於-1的(每次LiveData設置值都會使其version加1),這樣就會致使LiveDataBus每註冊一個新的訂閱者,這個訂閱者馬上會收到一個回調,即便這個設置的動做發生在訂閱以前。

問題緣由總結

對於這個問題,總結一下發生的核心緣由。對於LiveData,其初始的version是-1,當咱們調用了其setValue或者postValue,其vesion會+1;對於每個觀察者的封裝ObserverWrapper,其初始version也爲-1,也就是說,每個新註冊的觀察者,其version爲-1;當LiveData設置這個ObserverWrapper的時候,若是LiveData的version大於ObserverWrapper的version,LiveData就會強制把當前value推送給Observer。

如何解決這個問題

明白了問題產生的緣由以後,咱們來看看怎麼才能解決這個問題。很顯然,根據以前的分析,只須要在註冊一個新的訂閱者的時候把Wrapper的version設置成跟LiveData的version一致便可。

那麼怎麼實現呢,看看LiveData的observe方法,他會在步驟1建立一個LifecycleBoundObserver,LifecycleBoundObserver是ObserverWrapper的派生類。而後會在步驟2把這個LifecycleBoundObserver放入一個私有Map容器mObservers中。不管ObserverWrapper仍是LifecycleBoundObserver都是私有的或者包可見的,因此沒法經過繼承的方式更改LifecycleBoundObserver的version。

那麼能不能從Map容器mObservers中取到LifecycleBoundObserver,而後再更改version呢?答案是確定的,經過查看SafeIterableMap的源碼咱們發現有一個protected的get方法。所以,在調用observe的時候,咱們能夠經過反射拿到LifecycleBoundObserver,再把LifecycleBoundObserver的version設置成和LiveData一致便可。

對於非生命週期感知的observeForever方法來講,實現的思路是一致的,可是具體的實現略有不一樣。observeForever的時候,生成的wrapper不是LifecycleBoundObserver,而是AlwaysActiveObserver(步驟1),並且咱們也沒有機會在observeForever調用完成以後再去更改AlwaysActiveObserver的version,由於在observeForever方法體內,步驟3的語句,回調就發生了。

那麼對於observeForever,如何解決這個問題呢?既然是在調用內回調的,那麼咱們能夠寫一個ObserverWrapper,把真正的回調給包裝起來。把ObserverWrapper傳給observeForever,那麼在回調的時候咱們去檢查調用棧,若是回調是observeForever方法引發的,那麼就不回調真正的訂閱者。

LiveDataBus最終實現

public final class LiveDataBus { private final Map<String, BusMutableLiveData<Object>> bus; private LiveDataBus() { bus = new HashMap<>(); } private static class SingletonHolder { private static final LiveDataBus DEFAULT_BUS = new LiveDataBus(); } public static LiveDataBus get() { return SingletonHolder.DEFAULT_BUS; } public <T> MutableLiveData<T> with(String key, Class<T> type) { if (!bus.containsKey(key)) { bus.put(key, new BusMutableLiveData<>()); } return (MutableLiveData<T>) bus.get(key); } public MutableLiveData<Object> with(String key) { return with(key, Object.class); } private static class ObserverWrapper<T> implements Observer<T> { private Observer<T> observer; public ObserverWrapper(Observer<T> observer) { this.observer = observer; } @Override public void onChanged(@Nullable T t) { if (observer != null) { if (isCallOnObserve()) { return; } observer.onChanged(t); } } private boolean isCallOnObserve() { StackTraceElement[] stackTrace = Thread.currentThread().getStackTrace(); if (stackTrace != null && stackTrace.length > 0) { for (StackTraceElement element : stackTrace) { if ("android.arch.lifecycle.LiveData".equals(element.getClassName()) && "observeForever".equals(element.getMethodName())) { return true; } } } return false; } } private static class BusMutableLiveData<T> extends MutableLiveData<T> { private Map<Observer, Observer> observerMap = new HashMap<>(); @Override public void observe(@NonNull LifecycleOwner owner, @NonNull Observer<T> observer) { super.observe(owner, observer); try { hook(observer); } catch (Exception e) { e.printStackTrace(); } } @Override public void observeForever(@NonNull Observer<T> observer) { if (!observerMap.containsKey(observer)) { observerMap.put(observer, new ObserverWrapper(observer)); } super.observeForever(observerMap.get(observer)); } @Override public void removeObserver(@NonNull Observer<T> observer) { Observer realObserver = null; if (observerMap.containsKey(observer)) { realObserver = observerMap.remove(observer); } else { realObserver = observer; } super.removeObserver(realObserver); } private void hook(@NonNull Observer<T> observer) throws Exception { //get wrapper's version Class<LiveData> classLiveData = LiveData.class; Field fieldObservers = classLiveData.getDeclaredField("mObservers"); fieldObservers.setAccessible(true); Object objectObservers = fieldObservers.get(this); Class<?> classObservers = objectObservers.getClass(); Method methodGet = classObservers.getDeclaredMethod("get", Object.class); methodGet.setAccessible(true); Object objectWrapperEntry = methodGet.invoke(objectObservers, observer); Object objectWrapper = null; if (objectWrapperEntry instanceof Map.Entry) { objectWrapper = ((Map.Entry) objectWrapperEntry).getValue(); } if (objectWrapper == null) { throw new NullPointerException("Wrapper can not be bull!"); } Class<?> classObserverWrapper = objectWrapper.getClass().getSuperclass(); Field fieldLastVersion = classObserverWrapper.getDeclaredField("mLastVersion"); fieldLastVersion.setAccessible(true); //get livedata's version Field fieldVersion = classLiveData.getDeclaredField("mVersion"); fieldVersion.setAccessible(true); Object objectVersion = fieldVersion.get(this); //set wrapper's version fieldLastVersion.set(objectWrapper, objectVersion); } } } 

註冊訂閱

LiveDataBus.get()
        .with("key_test", String.class) .observe(this, new Observer<String>() { @Override public void onChanged(@Nullable String s) { } }); 

發送消息:

LiveDataBus.get().with("key_test").setValue(s); 

源碼說明

LiveDataBus的源碼能夠直接拷貝使用,也能夠前往做者的GitHub倉庫查看下載: https://github.com/JeremyLiao/LiveDataBus 。

總結

本文提供了一個新的消息總線框架——LiveDataBus。訂閱者能夠訂閱某個消息通道的消息,發佈者能夠把消息發佈到消息通道上。利用LiveDataBus,不只能夠實現消息總線功能,並且對於訂閱者,他們不須要關心什麼時候取消訂閱,極大減小了由於忘記取消訂閱形成的內存泄漏風險。

 

 

Android組件化方案及組件消息總線modular-event實戰

背景

組件化做爲Android客戶端技術的一個重要分支,近年來一直是業界積極探索和實踐的方向。美團內部各個Android開發團隊也在嘗試和實踐不一樣的組件化方案,而且在組件化通訊框架上也有不少高質量的產出。最近,咱們團隊對美團零售收銀和美團輕收銀兩款Android App進行了組件化改造。本文主要介紹咱們的組件化方案,但願對從事Android組件化開發的同窗能有所啓發。

爲何要組件化

近年來,爲何這麼多團隊要進行組件化實踐呢?組件化究竟能給咱們的工程、代碼帶來什麼好處?咱們認爲組件化可以帶來兩個最大的好處。

提升組件複用性

可能有些人會以爲,提升複用性很簡單,直接把須要複用的代碼作成Android Module,打包AAR並上傳代碼倉庫,那麼這部分功能就能被方便地引入和使用。可是咱們以爲僅僅這樣是不夠的,上傳倉庫的AAR庫是否方便被複用,須要組件化的規則來約束,這樣才能提升複用的便捷性。

下降組件間的耦合

咱們須要經過組件化的規則把代碼拆分紅不一樣的模塊,模塊要作到高內聚、低耦合。模塊間也不能直接調用,這須要組件化通訊框架的支持。下降了組件間的耦合性能夠帶來兩點直接的好處:第一,代碼更便於維護;第二,下降了模塊的Bug率。

組件化以前的狀態

咱們的目標是要對團隊的兩款App(美團零售收銀、美團輕收銀)進行組件化重構,那麼這裏先簡單地介紹一下這兩款應用的架構。

總的來講,這兩款應用的構架比較類似,主工程Module依賴Business Module,Business Module是各類業務功能的集合,Business Module依賴Service Module,Service Module依賴Platform Module,Service Module和Platform Module都對上層提供服務。

有所不一樣的是Platform Module提供的服務更爲基礎,主要包括一些工具Utils和界面Widget,而Service Module提供各類功能服務,如KNB、位置服務、網絡接口調用等。這樣的話,Business Module就變得很是臃腫和繁雜,各類業務模塊相互調用,耦合性很強,改業務代碼時容易「牽一髮而動全身」,即便改一小塊業務代碼,可能要連帶修改不少相關的地方,不只在代碼層面不利於進行維護,並且對一個業務的修改很容易形成其餘業務產生Bug。

組件化以前的狀態

組件化以前的狀態

 

組件化方案調研

爲了獲得最適合咱們業態和構架的組件化方案,咱們調研了業界開源的一些組件化方案和公司內部其餘團隊的組件化方案,在此作個總結。

開源組件化方案調研

咱們調研了業界一些主流的開源組件化方案。

號稱業界首個支持漸進式組件化改造的Android組件化開源框架。不管頁面跳轉仍是組件間調用,都採用CC統一的組件調用方式完成。

獲得的方案採用路由 + 接口下沉的方式,全部接口下沉到base中,組件中實現接口並在IApplicationLike中添加代碼註冊到Router中。

組件間調用需指定同步實現仍是異步實現,調用組件時統一拿到RouterResponse做爲返回值,同步調用的時候用RouterResponse.getData()來獲取結果,異步調用獲取時須要本身維護線程。

阿里推出的路由引擎,是一個路由框架,並非完整的組件化方案,可做爲組件化架構的通訊引擎。

聚美的路由引擎,在此基礎上也有聚美的組件化實踐方案,基本思想是採用路由 + 接口下沉的方式實現組件化。

美團其餘團隊組件化方案調研

美團收銀ComponentCenter

美團收銀的組件化方案支持接口調用和消息總線兩種方式,接口調用的方式須要構建CCPData,而後調用ComponentCenter.call,最後在統一的Callback中進行處理。消息總線方式也須要構建CCPData,最後調用ComponentCenter.sendEvent發送。美團收銀的業務組件都打包成AAR上傳至倉庫,組件間存在相互依賴,這樣致使mainapp引用這些組件時須要當心地exclude一些重複依賴。在咱們的組件化方案中,咱們採用了一種巧妙的方法來解決這個問題。

美團App ServiceLoader

美團App的組件化方案採用ServiceLoader的形式,這是一種典型的接口調用組件通訊方式。用註解定義服務,獲取服務時取得一個接口的List,判斷這個List是否爲空,若是不爲空,則獲取其中一個接口調用。

WMRouter

美團外賣團隊開發的一款Android路由框架,基於組件化的設計思路。主要提供路由、ServiceLoader兩大功能。以前美團技術博客也發表過一篇WMRouter的介紹:《WMRouter:美團外賣Android開源路由框架》。WMRouter提供了實現組件化的兩大基礎設施框架:路由和組件間接口調用。支持和文檔也很充分,能夠考慮做爲咱們團隊實現組件化的基礎設施。

組件化方案

組件化基礎框架

在前期的調研工做中,咱們發現外賣團隊的WMRouter是一個不錯的選擇。首先,WMRouter提供了路由+ServiceLoader兩大組件間通訊功能,其次,WMRouter架構清晰,擴展性比較好,而且文檔和支持也比較完備。因此咱們決定了使用WMRouter做爲組件化基礎設施框架之一。然而,直接使用WMRouter有兩個問題:

  1. 咱們的項目已經在使用一個路由框架,若是使用WMRouter,須要把以前使用的路由框架改爲WMRouter路由框架。
  2. WMRouter沒有消息總線框架,咱們調研的其餘項目也沒有適合咱們項目的消息總線框架,所以咱們須要開發一個可以知足咱們需求的消息總線框架,這部分會在後面詳細描述。

組件化分層結構

在參考了不一樣的組件化方案以後,咱們採用了以下分層結構:

  1. App殼工程:負責管理各個業務組件和打包APK,沒有具體的業務功能。
  2. 業務組件層:根據不一樣的業務構成獨立的業務組件,其中每一個業務組件包含一個Export Module和Implement Module。
  3. 功能組件層:對上層提供基礎功能服務,如登陸服務、打印服務、日誌服務等。
  4. 組件基礎設施:包括WMRouter,提供頁面路由服務和ServiceLoader接口調用服務,以及後面會介紹的組件消息總線框架:modular-event。

總體架構以下圖所示:

分層結構

分層結構

 

業務組件拆分

咱們調研其餘組件化方案的時候,發現不少組件方案都是把一個業務模塊拆分紅一個獨立的業務組件,也就是拆分紅一個獨立的Module。而在咱們的方案中,每一個業務組件都拆分紅了一個Export Module和Implement Module,爲何要這樣作呢?

1. 避免循環依賴

若是採用一個業務組件一個Module的方式,若是Module A須要調用Module B提供的接口,那麼Module A就須要依賴Module。同時,若是Module B須要調用Module A的接口,那麼Module B就須要依賴Module A。此時就會造成一個循環依賴,這是不容許的。

循環依賴

循環依賴

 

也許有些讀者會說,這個好解決:能夠把Module A和Module B要依賴的接口放到另外一個Module中去,而後讓Module A和Module B都去依賴這個Module就能夠了。這確實是一個解決辦法,而且有些項目組在使用這種把接口下沉的方法。

可是咱們但願一個組件的接口,是由這個組件本身提供,而不是放在一個更加下沉的接口裏面,因此咱們採用了把每一個業務組件都拆分紅了一個Export Module和Implement Module。這樣的話,若是Module A須要調用Module B提供的接口,同時Module B須要調用Module A的接口,只須要Module A依賴Module B Export,Module B依賴Module A Export就能夠了。

組件結構

組件結構

 

2. 業務組件徹底平等

在使用單Module方案的組件化方案中,這些業務組件其實不是徹底平等,有些被依賴的組件在層級上要更下沉一些。可是採用Export Module+Implement Module的方案,全部業務組件在層級上徹底平等。

3. 功能劃分更加清晰

每一個業務組件都劃分紅了Export Module+Implement Module的模式,這個時候每一個Module的功能劃分也更加清晰。Export Module主要定義組件須要對外暴露的部分,主要包含:

  • 對外暴露的接口,這些接口用WMRouter的ServiceLoader進行調用。
  • 對外暴露的事件,這些事件利用消息總線框架modular-event進行訂閱和分發。
  • 組件的Router Path,組件化以前的工程雖然也使用了Router框架,可是全部Router Path都是定義在了一個下沉Module的公有Class中。這樣致使的問題是,不管哪一個模塊添加/刪除頁面,或是修改路由,都須要去修改這個公有的Class。設想若是組件化拆分以後,某個組件新增了頁面,還要去一個外部的Java文件中新增路由,這顯然難以接受,也不符合組件化內聚的目標。所以,咱們把每一個組件的Router Path放在組件的Export Module中,既能夠暴露給其餘組件,也能夠作到每一個組件管理本身的Router Path,不會出現全部組件去修改一個Java文件的窘境。

Implement Module是組件實現的部分,主要包含:

  • 頁面相關的Activity、Fragment,而且用WMRouter的註解定義路由。
  • Export Module中對外暴露的接口的實現。
  • 其餘的業務邏輯。

組件功能劃分

組件功能劃分

 

組件化消息總線框架modular-event

前文提到的實現組件化基礎設施框架中,咱們用外賣團隊的WMRouter實現頁面路由和組件間接口調用,可是卻沒有消息總線的基礎框架,所以,咱們本身開發了一個組件化消息總線框架modular-event。

爲何須要消息總線框架

以前,咱們開發過一個基於LiveData的消息總線框架:LiveDataBus,也在美團技術博客上發表過一篇文章來介紹這個框架:《Android消息總線的演進之路:用LiveDataBus替代RxBus、EventBus》。關於消息總線的使用,老是伴隨着不少爭論。有些人以爲消息總線很好用,有些人以爲消息總線容易被濫用。

既然已經有了ServiceLoader這種組件間接口調用的框架,爲何還須要消息總線這種方式呢?主要有兩個理由。

1. 更進一步的解耦

基於接口調用的ServiceLoader框架的確實現瞭解耦,可是消息總線可以實現更完全的解耦。接口調用的方式調用方須要依賴這個接口而且知道哪一個組件實現了這個接口。消息總線方式發送者只須要發送一個消息,根本不用關心是否有人訂閱這個消息,這樣發送者根本不須要了解其餘組件的狀況,和其餘組件的耦合也就越少。

2. 多對多的通訊

基於接口的方式只能進行一對一的調用,基於消息總線的方式可以提供多對多的通訊。

消息總線的優勢和缺點

總的來講,消息總線最大的優勢就是解耦,所以很適合組件化這種須要對組件間進行完全解耦的場景。然而,消息總線被不少人詬病的重要緣由,也確實是由於消息總線容易被濫用。消息總線容易被濫用通常體如今幾個場景:

1. 消息難以溯源

有時候咱們在閱讀代碼的過程當中,找到一個訂閱消息的地方,想要看看是誰發送了這個消息,這個時候每每只能經過查找消息的方式去「溯源」。致使咱們在閱讀代碼,梳理邏輯的過程不太連貫,有種被割裂的感受。

2. 消息發送比較隨意,沒有強制的約束

消息總線在發送消息的時候通常沒有強制的約束。不管是EventBus、RxBus或是LiveDataBus,在發送消息的時候既沒有對消息進行檢查,也沒有對發送調用進行約束。這種不規範性在特定的時刻,甚至會帶來災難性的後果。好比訂閱方訂閱了一個名爲login_success的消息,編寫發送消息的是一個比較隨意的程序員,沒有把這個消息定義成全局變量,而是定義了一個臨時變量String發送這個消息。不幸的是,他把消息名稱login_success拼寫成了login_seccess。這樣的話,訂閱方永遠接收不到登陸成功的消息,並且這個錯誤也很難被發現。

組件化消息總線的設計目標

1. 消息由組件本身定義

之前咱們在使用消息總線時,喜歡把全部的消息都定義到一個公共的Java文件裏面。可是組件化若是也採用這種方案的話,一旦某個組件的消息發生變更,都會去修改這個Java文件。因此咱們但願由組件本身來定義和維護消息定義文件。

2. 區分不一樣組件定義的同名消息

若是消息由組件定義和維護,那麼有可能不一樣組件定義了重名的消息,消息總線框架須要可以區分這種消息。

3. 解決前文提到的消息總線的缺點

解決消息總線消息難以溯源和消息發送沒有約束的問題。

基於LiveData的消息總線

以前的博文《Android消息總線的演進之路:用LiveDataBus替代RxBus、EventBus》詳細闡述瞭如何基於LiveData構建消息總線。組件化消息總線框架modular-event一樣會基於LiveData構建。使用LiveData構建消息總線有不少優勢:

  1. 使用LiveData構建消息總線具備生命週期感知能力,使用者不須要調用反註冊,相比EventBus和RxBus使用更爲方便,而且沒有內存泄漏風險。
  2. 使用普通消息總線,若是回調的時候Activity處於Stop狀態,這個時候進行彈Dialog一類的操做就會引發崩潰。使用LiveData構建消息總線徹底沒有這個風險。

組件消息總線modular-event的實現

解決不一樣組件定義了重名消息的問題

其實這個問題仍是比較好解決的,實現的方式就是採用兩級HashMap的方式解決。第一級HashMap的構建以ModuleName做爲Key,第二級HashMap做爲Value;第二級HashMap以消息名稱EventName做爲Key,LiveData做爲Value。查找的時候先用組件名稱ModuleName在第一級HashMap中查找,若是找到則用消息名EventName在第二級HashName中查找。整個結構以下圖所示:

消息總線結構

消息總線結構

 

對消息總線的約束

咱們但願消息總線框架有如下約束:

  1. 只能訂閱和發送在組件中預約義的消息。換句話說,使用者不能發送和訂閱臨時消息。
  2. 消息的類型須要在定義的時候指定。
  3. 定義消息的時候須要指定屬於哪一個組件。

如何實現這些約束

  1. 在消息定義文件上使用註解,定義消息的類型和消息所屬Module。
  2. 定義註解處理器,在編譯期間收集消息的相關信息。
  3. 在編譯器根據消息的信息生成調用時須要的interface,用接口約束消息發送和訂閱。
  4. 運行時構建基於兩級HashMap的LiveData存儲結構。
  5. 運行時採用interface+動態代理的方式實現真正的消息訂閱和發送。

整個流程以下圖所示:

實現流程

實現流程

 

消息總線modular-event的結構

  • modular-event-base:定義Anotation及其餘基本類型
  • modular-event-core:modular-event核心實現
  • modular-event-compiler:註解處理器
  • modular-event-plugin:Gradle Plugin

Anotation

  • @ModuleEvents:消息定義
@Retention(RetentionPolicy.SOURCE) @Target(ElementType.TYPE) public @interface ModuleEvents { String module() default ""; } 
  • @EventType:消息類型
@Retention(RetentionPolicy.SOURCE) @Target(ElementType.FIELD) public @interface EventType { Class value(); } 

消息定義

經過@ModuleEvents註解一個定義消息的Java類,若是@ModuleEvents指定了屬性module,那麼這個module的值就是這個消息所屬的Module,若是沒有指定屬性module,則會把定義消息的Java類所在的包的包名做爲消息所屬的Module。

在這個消息定義java類中定義的消息都是public static final String類型。能夠經過@EventType指定消息的類型,@EventType支持java原生類型或自定義類型,若是沒有用@EventType指定消息類型,那麼消息的類型默認爲Object,下面是一個消息定義的示例:

//能夠指定module,若不指定,則使用包名做爲module名 @ModuleEvents() public class DemoEvents { //不指定消息類型,那麼消息的類型默認爲Object public static final String EVENT1 = "event1"; //指定消息類型爲自定義Bean @EventType(TestEventBean.class) public static final String EVENT2 = "event2"; //指定消息類型爲java原生類型 @EventType(String.class) public static final String EVENT3 = "event3"; } 

interface自動生成

咱們會在modular-event-compiler中處理這些註解,一個定義消息的Java類會生成一個接口,這個接口的命名是EventsDefineOf+消息定義類名,例如消息定義類的類名爲DemoEvents,自動生成的接口就是EventsDefineOfDemoEvents。消息定義類中定義的每個消息,都會轉化成接口中的一個方法。使用者只能經過這些自動生成的接口使用消息總線。咱們用這種巧妙的方式實現了對消息總線的約束。前文提到的那個消息定義示例DemoEvents.java會生成一個以下的接口類:

package com.sankuai.erp.modularevent.generated.com.meituan.jeremy.module_b_export; public interface EventsDefineOfDemoEvents extends com.sankuai.erp.modularevent.base.IEventsDefine { com.sankuai.erp.modularevent.Observable<java.lang.Object> EVENT1(); com.sankuai.erp.modularevent.Observable<com.meituan.jeremy.module_b_export.TestEventBean> EVENT2( ); com.sankuai.erp.modularevent.Observable<java.lang.String> EVENT3(); } 

關於接口類的自動生成,咱們採用了square/javapoet來實現,網上介紹JavaPoet的文章不少,這裏就再也不累述。

使用動態代理實現運行時調用

有了自動生成的接口,就至關於有了一個殼,然而殼下面的全部邏輯,咱們經過動態代理來實現,簡單介紹一下代理模式和動態代理:

  • 代理模式: 給某個對象提供一個代理對象,並由代理對象控制對於原對象的訪問,即客戶不直接操控原對象,而是經過代理對象間接地操控原對象。
  • 動態代理: 代理類是在運行時生成的。也就是說Java編譯完以後並無實際的class文件,而是在運行時動態生成的類字節碼,並加載到JVM中。

在動態代理的InvocationHandler中實現查找邏輯:

  1. 根據interface的typename獲得ModuleName。
  2. 調用的方法的methodname即爲消息名。
  3. 根據ModuleName和消息名找到相應的LiveData。
  4. 完成後續訂閱消息或者發送消息的流程。

消息的訂閱和發送能夠用鏈式調用的方式編碼:

  • 訂閱消息
ModularEventBus
        .get()
        .of(EventsDefineOfModuleBEvents.class)
        .EVENT1()
        .observe(this, new Observer<TestEventBean>() { @Override public void onChanged(@Nullable TestEventBean testEventBean) { Toast.makeText(MainActivity.this, "MainActivity收到自定義消息: " + testEventBean.getMsg(), Toast.LENGTH_SHORT).show(); } }); 
  • 發送消息
ModularEventBus
        .get()
        .of(EventsDefineOfModuleBEvents.class)
        .EVENT1()
        .setValue(new TestEventBean("aa")); 

訂閱和發送的模式

  • 訂閱消息的模式

    1. observe:生命週期感知,onDestroy的時候自動取消訂閱。
    2. observeSticky:生命週期感知,onDestroy的時候自動取消訂閱,Sticky模式。
    3. observeForever:須要手動取消訂閱。
    4. observeStickyForever:須要手動取消訂閱,Sticky模式。
  • 發送消息的模式

    1. setValue:主線程調用。
    2. postValue:後臺線程調用。

總結

本文介紹了美團行業收銀研發組Android團隊的組件化實踐,以及強約束組件消息總線modular-event的原理和使用。咱們團隊很早以前就在探索組件化改造,前期有些方案在落地的時候遇到不少困難。咱們也研究了不少開源的組件化方案,以及公司內部其餘團隊(美團App、美團外賣、美團收銀等)的組件化方案,學習和借鑑了不少優秀的設計思想,固然也踩過很多的坑。咱們逐漸意識到:任何一種組件化方案都有其適用場景,咱們的組件化架構選擇,應該更加面向業務,而不只僅是面向技術自己。

後期工做展望

咱們的組件化改造工做遠遠沒有結束,將來可能會在如下幾個方向繼續進行深刻的研究:

  1. 組件管理:組件化改造以後,每一個組件是個獨立的工程,組件也會迭代開發,如何對這些組件進行版本化管理。
  2. 組件重用:如今看起來對這些組件的重用是很方便的,只須要引入組件的庫便可,可是若是一個新的項目到來,需求有些變化,咱們應該怎樣最大限度的重用這些組件。
  3. CI集成:如何更好的與CI集成。
  4. 集成到腳手架:集成到腳手架,讓新的項目從一開始就以組件化的模式進行開發。

參考資料

  1. Android消息總線的演進之路:用LiveDataBus替代RxBus、EventBus
  2. WMRouter:美團外賣Android開源路由框架
  3. 美團外賣Android平臺化架構演進實踐

 

Android自動化頁面測速在美團的實踐

背景

隨着移動互聯網的快速發展,移動應用愈來愈注重用戶體驗。美團技術團隊在開發過程當中也很是注重提高移動應用的總體質量,其中很重要的一項內容就是頁面的加載速度。若是發生冷啓動時間過長、頁面渲染時間過長、網絡請求過慢等現象,就會直接影響到用戶的體驗,因此,如何監控整個項目的加載速度就成爲咱們部門面臨的重要挑戰。

對於測速這個問題,不少同窗首先會想到在頁面中的不一樣節點加入計算時間的代碼,以此算出某段時間長度。然而,隨着美團業務的快速迭代,會有愈來愈多的新頁面、愈來愈多的業務邏輯、愈來愈多的代碼改動,這些不肯定性會使咱們測速部分的代碼耦合進業務邏輯,而且須要手動維護,進而增長了成本和風險。因而經過借鑑公司先前的方案Hertz(移動端性能監控方案Hertz),分析其存在的問題並結合自身特性,咱們實現了一套無需業務代碼侵入的自動化頁面測速插件,本文將對其原理作一些解讀和分析。

現有解決方案Hertz(移動端性能監控方案Hertz) * 手動在 Application.onCreate() 中進行SDK的初始化調用,同時計算冷啓動時間。

  • 手動在Activity生命週期方法中添加代碼,計算頁面不一樣階段的時間。
  • 手動爲 Activity.setContentView() 設置的View上,添加一層自定義父View,用於計算繪製完成的時間。
  • 手動在每一個網絡請求開始前和結束後添加代碼,計算網絡請求的時間。

  • 本地聲明JSON配置文件來肯定須要測速的頁面以及該頁面須要統計的初始網絡請求API, getClass().getSimpleName() 做爲頁面的key,來標識哪些頁面須要測速,指定一組API來標識哪些請求是須要被測速的。

現有方案問題:

  • 冷啓動時間不許:冷啓動起始時間從 Application.onCreate() 中開始算起,會使得計算出來的冷啓動時間偏小,由於在該方法執行前可能會有 MultiDex.install() 等耗時方法的執行。
  • 特殊狀況未考慮:忽略了ViewPager+Fragment延時加載這些常見而複雜的狀況,這些狀況會形成實際測速時間很是不許。
  • 手動注入代碼:全部的代碼都須要手動寫入,耦合進業務邏輯中,難以維護而且隨着新頁面的加入容易遺漏。
  • 寫死配置文件:如需添加或更改要測速的頁面,則須要修改本地配置文件,進行發版。

目標方案效果:

  • 自動注入代碼,無需手動寫入代碼與業務邏輯耦合。
  • 支持Activity和Fragment頁面測速,並解決ViewPager+Fragment延遲加載時測速不許的問題。
  • 在Application的構造函數中開始冷啓動時間計算。
  • 自動拉取和更新配置文件,能夠實時的進行配置文件的更新。

實現

咱們要實現一個自動化的測速插件,須要分爲五步進行:

  1. 測速定義:肯定須要測量的速度指標並定義其計算方式。
  2. 配置文件:經過配置文件肯定代碼中須要測量速度指標的位置。
  3. 測速實現:如何實現時間的計算和上報。
  4. 自動化實現:如何自動化實現頁面測速,不須要手動注入代碼。
  5. 疑難雜症:分析並解決特殊狀況。

測速定義

咱們把頁面加載流程抽象成一個通用的過程模型:頁面初始化 -> 初次渲染完成 -> 網絡請求發起 -> 請求完成並刷新頁面 -> 二次渲染完成。據此,要測量的內容包括如下方面:

  • 項目的冷啓動時間:從App被建立,一直到咱們首頁初次繪製出來所經歷的時間。
  • 頁面的初次渲染時間:從Activity或Fragment的 onCreate() 方法開始,一直到頁面View的初次渲染完成所經歷的時間。
  • 頁面的初始網絡請求時間:Activity或Fragment指定的一組初始請求,所有完成所用的時間。
  • 頁面的二次渲染時間:Activity或Fragment全部的初始請求完成後,到頁面View再次渲染完成所經歷的時間。

須要注意的是,網絡請求時間是指定的一組請求所有完成的時間,即從第一個請求發起開始,直到最後一個請求完成所用的時間。根據定義咱們的測速模型以下圖所示:

配置文件

接下來要知道哪些頁面須要測速,以及頁面的初始請求是哪些API,這須要一個配置文件來定義。

<page id="HomeActivity" tag="1"> <api id="/api/config"/> <api id="/api/list"/> </page> <page id="com.test.MerchantFragment" tag="0"> <api id="/api/test1"/> </page> 

咱們定義了一個XML配置文件,每一個 <page/> 標籤表明了一個頁面,其中 id 是頁面的類名或者全路徑類名,用以表示哪些Activity或者Fragment須要測速; tag 表明是否爲首頁,這個首頁指的是用以計算冷啓動結束時間的頁面,好比咱們想把冷啓動時間定義爲從App建立到HomeActivity展現所須要的時間,那麼HomeActivity的tag就爲1;每個 <api/> 表明這個頁面的一個初始請求,好比HomeActivity頁面是個列表頁,一進來會先請求config接口,而後請求list接口,當list接口回來後展現列表數據,那麼該頁面的初始請求就是config和list接口。更重要的一點是,咱們將該配置文件維護在服務端,能夠實時更新,而客戶端要作的只是在插件SDK初始化時拉取最新的配置文件便可。

測速實現

測速須要實現一個SDK,用於管理配置文件、頁面測速對象、計算時間、上報數據等,項目接入後,在頁面的不一樣節點調用SDK提供的方法完成測速。

冷啓動開始時間

冷啓動的開始時間,咱們以Application的構造函數被調用爲準,在構造函數中進行時間點記錄,並在SDK初始化時,將時間點傳入做爲冷啓動開始時間。

//Application public MyApplication(){ super(); coldStartTime = SystemClock.elapsedRealtime(); } //SDK初始化 public void onColdStart(long coldStartTime) { this.startTime = coldStartTime; } 

這裏說明幾點:

  • SDK中全部的時間獲取都使用 SystemClock.elapsedRealtime() 機器時間,保證了時間的一致性和準確性。
  • 冷啓動初始時間以構造函數爲準,能夠算入MultiDex注入的時間,比在 onCreate() 中計算更爲準確。
  • 在構造函數中直接調用Java的API來計算時間,以後傳入SDK中,而不是直接調用SDK的方法,是爲了防止MultiDex注入以前,調用到未注入的Dex中的類。

SDK初始化

SDK的初始化在 Application.onCreate() 中調用,初始化時會獲取服務端的配置文件,解析爲 Map<String,PageObject> ,對應配置中頁面的id和其配置項。另外還維護了一個當前頁面對象的 MAP<Integer, Object> ,key爲一個int值而不是其類名,由於同一個類可能有多個實例同時在運行,若是存爲一個key,可能會致使同一頁面不一樣實例的測速對象只有一個,因此在這裏咱們使用Activity或Fragment的 hashcode() 值做爲頁面的惟一標識。

頁面開始時間

頁面的開始時間,咱們以Activtiy或Fragment的 onCreate() 做爲時間節點進行計算,記錄頁面的開始時間。

public void onPageCreate(Object page) { int pageObjKey = Utils.getPageObjKey(page); PageObject pageObject = activePages.get(pageObjKey); ConfigModel configModel = getConfigModel(page);//獲取該頁面的配置 if (pageObject == null && configModel != null) {//有配置則須要測速 pageObject = new PageObject(pageObjKey, configModel, Utils.getDefaultReportKey(page), callback); pageObject.onCreate(); activePages.put(pageObjKey, pageObject); } } //PageObject.onCreate() void onCreate() { if (createTime > 0) { return; } createTime = Utils.getRealTime(); } 

這裏的 getConfigModel() 方法中,會使用頁面的類名或者全路徑類名,去初始化時解析的配置Map中進行id的匹配,若是匹配到說明頁面須要測速,就會建立測速對象 PageObject 進行測速。

網絡請求時間

一個頁面的初始請求由配置文件指定,咱們只需在第一個請求發起前記錄請求開始時間,在最後一個請求回來後記錄結束時間便可。

boolean onApiLoadStart(String url) {
    String relUrl = Utils.getRelativeUrl(url);
    if (!hasApiConfig() || !hasUrl(relUrl) || apiStatusMap.get(relUrl.hashCode()) != NONE) { return false; } //改變Url的狀態爲執行中 apiStatusMap.put(relUrl.hashCode(), LOADING); //第一個請求開始時記錄起始點 if (apiLoadStartTime <= 0) { apiLoadStartTime = Utils.getRealTime(); } return true; } boolean onApiLoadEnd(String url) { String relUrl = Utils.getRelativeUrl(url); if (!hasApiConfig() || !hasUrl(relUrl) || apiStatusMap.get(relUrl.hashCode()) != LOADING) { return false; } //改變Url的狀態爲執行結束 apiStatusMap.put(relUrl.hashCode(), LOADED); //所有請求結束後記錄時間 if (apiLoadEndTime <= 0 && allApiLoaded()) { apiLoadEndTime = Utils.getRealTime(); } return true; } private boolean allApiLoaded() { if (!hasApiConfig()) return true; int size = apiStatusMap.size(); for (int i = 0; i < size; ++i) { if (apiStatusMap.valueAt(i) != LOADED) { return false; } } return true; } 

每一個頁面的測速對象,維護了一個請求url和其狀態的映射關係 SparseIntArray ,key就爲請求url的hashcode,狀態初始爲 NONE 。每次請求發起時,將對應url的狀態置爲 LOADING ,結束時置爲 LOADED 。當第一個請求發起時記錄起始時間,當全部url狀態爲 LOADED 時說明全部請求完成,記錄結束時間。

渲染時間

按照咱們對測速的定義,如今冷啓動開始時間有了,還差結束時間,即指定的首頁初次渲染結束時的時間;頁面的開始時間有了,還差頁面初次渲染的結束時間;網絡請求的結束時間有了,還差頁面的二次渲染的結束時間。這一切都是和頁面的View渲染時間有關,那麼怎麼獲取頁面的渲染結束時間點呢?

由View的繪製流程可知,父View的 dispatchDraw() 方法會執行其全部子View的繪製過程,那麼把頁面的根View當作子View,是否是能夠在其外部增長一層父View,以其 dispatchDraw() 做爲頁面繪製完畢的時間點呢?答案是能夠的。

class AutoSpeedFrameLayout extends FrameLayout { public static View wrap(int pageObjectKey, @NonNull View child) { ... //將頁面根View做爲子View,其餘參數保持不變 ViewGroup vg = new AutoSpeedFrameLayout(child.getContext(), pageObjectKey); if (child.getLayoutParams() != null) { vg.setLayoutParams(child.getLayoutParams()); } vg.addView(child, new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); return vg; } private final int pageObjectKey;//關聯的頁面key private AutoSpeedFrameLayout(@NonNull Context context, int pageObjectKey) { super(context); this.pageObjectKey = pageObjectKey; } @Override protected void dispatchDraw(Canvas canvas) { super.dispatchDraw(canvas); AutoSpeed.getInstance().onPageDrawEnd(pageObjectKey); } } 

咱們自定義了一層 FrameLayout 做爲全部頁面根View的父View,其 dispatchDraw() 方法執行super後,記錄相關頁面繪製結束的時間點。

測速完成

如今全部時間點都有了,那麼何時算做測速過程結束呢?咱們來看看每次渲染結束後的處理就知道了。

//PageObject.onPageDrawEnd() void onPageDrawEnd() { if (initialDrawEndTime <= 0) {//初次渲染尚未完成 initialDrawEndTime = Utils.getRealTime(); if (!hasApiConfig() || allApiLoaded()) {//若是沒有請求配置或者請求已完成,則沒有二次渲染時間,即初次渲染時間即爲頁面總體時間,且能夠上報結束頁面了 finalDrawEndTime = -1; reportIfNeed(); } //頁面初次展現,回調,用於統計冷啓動結束 callback.onPageShow(this); return; } //若是二次渲染沒有完成,且全部請求已經完成,則記錄二次渲染時間並結束測速,上報數據 if (finalDrawEndTime <= 0 && (!hasApiConfig() || allApiLoaded())) { finalDrawEndTime = Utils.getRealTime(); reportIfNeed(); } } 

該方法用於處理渲染完畢的各類狀況,包括初次渲染時間、二次渲染時間、冷啓動時間以及相應的上報。這裏的冷啓動在 callback.onPageShow(this) 是如何處理的呢?

//初次渲染完成時的回調 void onMiddlePageShow(boolean isMainPage) { if (!isFinish && isMainPage && startTime > 0 && endTime <= 0) { endTime = Utils.getRealTime(); callback.onColdStartReport(this); finish(); } } 

還記得配置文件中 tag 麼,他的做用就是指明該頁面是否爲首頁,也就是代碼段裏的 isMainPage 參數。若是是首頁的話,說明首頁的初次渲染結束,就能夠計算冷啓動結束的時間並進行上報了。

上報數據

當測速完成後,頁面測速對象 PageObject 裏已經記錄了頁面(包括冷啓動)各個時間點,剩下的只須要進行測速階段的計算並進行網絡上報便可。

//計算網絡請求時間 long getApiLoadTime() { if (!hasApiConfig() || apiLoadEndTime <= 0 || apiLoadStartTime <= 0) { return -1; } return apiLoadEndTime - apiLoadStartTime; } 

自動化實現

有了SDK,就要在咱們的項目中接入,並在相應的位置調用SDK的API來實現測速功能,那麼如何自動化實現API的調用呢?答案就是採用AOP的方式,在App編譯時動態注入代碼,咱們實現一個Gradle插件,利用其Transform功能以及Javassist實現代碼的動態注入。動態注入代碼分爲如下幾步:

  • 初始化埋點:SDK的初始化。
  • 冷啓動埋點:Application的冷啓動開始時間點。
  • 頁面埋點:Activity和Fragment頁面的時間點。
  • 請求埋點:網絡請求的時間點。

初始化埋點

在 Transform 中遍歷全部生成的class文件,找到Application對應的子類,在其 onCreate() 方法中調用SDK初始化API便可。

CtMethod method = it.getDeclaredMethod("onCreate") method.insertBefore("${Constants.AUTO_SPEED_CLASSNAME}.getInstance().init(this);") 

最終生成的Application代碼以下:

public void onCreate() { ... AutoSpeed.getInstance().init(this); } 

冷啓動埋點

同上一步,找到Application對應的子類,在其構造方法中記錄冷啓動開始時間,在SDK初始化時候傳入SDK,緣由在上文已經解釋過。

//Application private long coldStartTime; public MobileCRMApplication() { coldStartTime = SystemClock.elapsedRealtime(); } public void onCreate(){ ... AutoSpeed.getInstance().init(this,coldStartTime); } 

頁面埋點

結合測速時間點的定義以及Activity和Fragment的生命週期,咱們可以肯定在何處調用相應的API。

Activity

對於Activity頁面,如今開發者已經不多直接使用 android.app.Activity 了,取而代之的是 android.support.v4.app.FragmentActivity 和 android.support.v7.app.AppCompatActivity ,因此咱們只需在這兩個基類中進行埋點便可,咱們先來看FragmentActivity。

protected void onCreate(@Nullable Bundle savedInstanceState) { AutoSpeed.getInstance().onPageCreate(this); ... } public void setContentView(View var1) { super.setContentView(AutoSpeed.getInstance().createPageView(this, var1)); } 

注入代碼後,在FragmentActivity的 onCreate 一開始調用了 onPageCreate() 方法進行了頁面開始時間點的計算;在 setContentView() 內部,直接調用super,並將頁面根View包裝在咱們自定義的 AutoSpeedFrameLayout 中傳入,用於渲染時間點的計算。 然而在AppCompatActivity中,重寫了setContentView()方法,且沒有調用super,調用的是 AppCompatDelegate 的相應方法。

public void setContentView(View view) { getDelegate().setContentView(view); } 

這個delegate類用於適配不一樣版本的Activity的一些行爲,對於setContentView,無非就是將根View傳入delegate相應的方法,因此咱們能夠直接包裝View,調用delegate相應方法並傳入便可。

public void setContentView(View view) { AppCompatDelegate var2 = this.getDelegate(); var2.setContentView(AutoSpeed.getInstance().createPageView(this, view)); } 

對於Activity的setContentView埋點須要注意的是,該方法是重載方法,咱們須要對每一個重載的方法作處理。

Fragment

Fragment的 onCreate() 埋點和Activity同樣,沒必要多說。這裏主要說下 onCreateView() ,這個方法是返回值表明根View,而不是直接傳入View,而Javassist沒法單獨修改方法的返回值,因此沒法像Activity的setContentView那樣注入代碼,而且這個方法不是 @CallSuper 的,意味着不能在基類裏實現。那麼怎麼辦呢?咱們決定在每一個Fragment的該方法上作一些事情。

//Fragment標誌位 protected static boolean AUTO_SPEED_FRAGMENT_CREATE_VIEW_FLAG = true; //利用遞歸包裝根View public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { if(AUTO_SPEED_FRAGMENT_CREATE_VIEW_FLAG) { AUTO_SPEED_FRAGMENT_CREATE_VIEW_FLAG = false; View var4 = AutoSpeed.getInstance().createPageView(this, this.onCreateView(inflater, container, savedInstanceState)); AUTO_SPEED_FRAGMENT_CREATE_VIEW_FLAG = true; return var4; } else { ... return rootView; } } 

咱們利用一個boolean類型的標誌位,進行遞歸調用 onCreateView() 方法:

  1. 最初調用時,會將標誌位置爲false,而後遞歸調用該方法。
  2. 遞歸調用時,因爲標誌位爲false因此會調用原有邏輯,即獲取根View。
  3. 獲取根View後,包裝爲 AutoSpeedFrameLayout 返回。

而且因爲標誌位爲false,因此在遞歸調用時,即便調用了 super.onCreateView() 方法,在父類的該方法中也不會走if分支,而是直接返回其根View。

請求埋點

關於請求埋點咱們針對不一樣的網絡框架進行不一樣的處理,插件中只須要配置使用了哪些網絡框架便可實現埋點,咱們拿如今用的最多的 Retrofit 框架來講。

開始時間點

在建立Retrofit對象時,須要 OkHttpClient 對象,能夠爲其添加 Interceptor 進行請求發起前 Request 的攔截,咱們能夠構建一個用於記錄請求開始時間點的Interceptor,在 OkHttpClient.Builder() 調用時,插入該對象。

public Builder() { this.addInterceptor(new AutoSpeedRetrofitInterceptor()); ... } 

而該Interceptor對象就是用於在請求發起前,進行請求開始時間點的記錄。

public class AutoSpeedRetrofitInterceptor implements Interceptor { public Response intercept(Chain var1) throws IOException { AutoSpeed.getInstance().onApiLoadStart(var1.request().url()); return var1.proceed(var1.request()); } } 

結束時間點

使用Retrofit發起請求時,咱們會調用其 enqueue() 方法進行異步請求,同時傳入一個 Callback 進行回調,咱們能夠自定義一個Callback,用於記錄請求回來後的時間點,而後在enqueue方法中將參數換爲自定義的Callback,而原Callback做爲其代理對象便可。

public void enqueue(Callback<T> callback) { final Callback<T> callback = new AutoSpeedRetrofitCallback(callback); ... } 

該Callback對象用於在請求成功或失敗回調時,記錄請求結束時間點,並調用代理對象的相應方法處理原有邏輯。

public class AutoSpeedRetrofitCallback implements Callback { private final Callback delegate; public AutoSpeedRetrofitMtCallback(Callback var1) { this.delegate = var1; } public void onResponse(Call var1, Response var2) { AutoSpeed.getInstance().onApiLoadEnd(var1.request().url()); this.delegate.onResponse(var1, var2); } public void onFailure(Call var1, Throwable var2) { AutoSpeed.getInstance().onApiLoadEnd(var1.request().url()); this.delegate.onFailure(var1, var2); } } 

使用Retrofit+RXJava時,發起請求時內部是調用的 execute() 方法進行同步請求,咱們只須要在其執行先後插入計算時間的代碼便可,此處再也不贅述。

疑難雜症

至此,咱們基本的測速框架已經完成,不過通過咱們的實踐發現,有一種狀況下測速數據會很是不許,那就是開頭提過的ViewPager+Fragment而且實現延遲加載的狀況。這也是一種很常見的狀況,一般是爲了節省開銷,在切換ViewPager的Tab時,才首次調用Fragment的初始加載方法進行數據請求。通過調試分析,咱們找到了問題的緣由。

等待切換時間

該圖紅色時間段反映出,直到ViewPager切換到Fragment前,Fragment不會發起請求,這段等待的時間就會延長整個頁面的加載時間,但其實這塊時間不該該算在內,由於這段時間是用戶無感知的,不能做爲頁面耗時過長的依據。

那麼如何解決呢?咱們都知道ViewPager的Tab切換是能夠經過一個 OnPageChangeListener 對象進行監聽的,因此咱們能夠爲ViewPager添加一個自定義的Listener對象,在切換時記錄一個時間,這樣能夠經過用這個時間減去頁面建立後的時間得出這個多餘的等待時間,上報時在總時間中減去便可。

public ViewPager(Context context) { ... this.addOnPageChangeListener(new AutoSpeedLazyLoadListener(this.mItems)); } 

mItems 是ViewPager中當前頁面對象的數組,在Listener中能夠經過他找到對應的頁面,進行切換時的埋點。

//AutoSpeedLazyLoadListener public void onPageSelected(int var1) { if(this.items != null) { int var2 = this.items.size(); for(int var3 = 0; var3 < var2; ++var3) { Object var4 = this.items.get(var3); if(var4 instanceof ItemInfo) { ItemInfo var5 = (ItemInfo)var4; if(var5.position == var1 && var5.object instanceof Fragment) { AutoSpeed.getInstance().onPageSelect(var5.object); break; } } } } } 

AutoSpeed的 onPageSelected() 方法記錄頁面的切換時間。這樣一來,在計算頁面加載速度總時間時,就要減去這一段時間。

long getTotalTime() {
    if (createTime <= 0) { return -1; } if (finalDrawEndTime > 0) {//有二次渲染時間 long totalTime = finalDrawEndTime - createTime; //若是有等待時間,則減掉這段多餘的時間 if (selectedTime > 0 && selectedTime > viewCreatedTime && selectedTime < finalDrawEndTime) { totalTime -= (selectedTime - viewCreatedTime); } return totalTime; } else {//以初次渲染時間爲總體時間 return getInitialDrawTime(); } } 

這裏減去的 viewCreatedTime 不是Fragment的 onCreate() 時間,而應該是 onViewCreated() 時間,由於從onCreate到onViewCreated之間的時間也是應該算在頁面加載時間內,不該該減去,因此爲了處理這種狀況,咱們還須要對Fragment的onViewCreated方法進行埋點,埋點方式同 onCreate() 的埋點。

渲染時機不固定

此外經實踐發現,因爲不一樣View在繪製子View時的繪製原理不同,有可能會致使如下狀況的發生:

  • 沒有切換至Fragment時,Fragment的View初次渲染已經完成,即View不可見的狀況下也調用了 dispatchDraw()
  • 沒有切換至Fragment時,Fragment的View初次渲染未完成,即直到View初次可見時 dispatchDraw() 纔會調用。
  • 沒有延遲加載時,當ViewPager沒有切換到Fragment,而是直接發送請求後,請求回來時更新View,會調用 dispatchDraw() 進行二次渲染。
  • 沒有延遲加載時,當ViewPager沒有切換到Fragment,而是直接發送請求後,請求回來時更新View,不會調用 dispatchDraw() ,即直到切換到Fragment時纔會進行二次渲染。

上面的問題總結來看,就是初次渲染時間和二次渲染時間中,可能會有個等待切換的時間,致使這兩個時間變長,而這個切換時間點並非 onPageSelected() 方法調用的時候,由於該方法是在Fragment徹底滑動出來以後纔會調用,而這個問題裏的切換時間點,應該是指View初次展現的時候,也就是剛一滑動,ViewPager露出目標View的時間點。因而類比延遲加載的切換時間,咱們利用Listener的 onPageScrolled() 方法,在ViewPager滑動時,找到目標頁面,爲其記錄一個滑動時間點 scrollToTime 。

public void onPageScrolled(int var1, float var2, int var3) { if(this.items != null) { int var4 = Math.round(var2); int var5 = var2 != (float)0 && var4 != 1?(var4 == 0?var1 + 1:-1):var1; int var6 = this.items.size(); for(int var7 = 0; var7 < var6; ++var7) { Object var8 = this.items.get(var7); if(var8 instanceof ItemInfo) { ItemInfo var9 = (ItemInfo)var8; if(var9.position == var5 && var9.object instanceof Fragment) { AutoSpeed.getInstance().onPageScroll(var9.object); break; } } } } } 

那麼這樣就能夠解決兩次渲染的偏差:

  • 初次渲染時間中, scrollToTime - viewCreatedTime 就是頁面建立後,到初次渲染結束之間,由於等待滾動而產生的多餘時間。
  • 二次渲染時間中, scrollToTime - apiLoadEndTime 就是請求完成後,到二次渲染結束之間,由於等待滾動而產生的多餘時間。

因而在計算初次和二次渲染時間時,能夠減去多餘時間獲得正確的值。

long getInitialDrawTime() {
    if (createTime <= 0 || initialDrawEndTime <= 0) { return -1; } if (scrollToTime > 0 && scrollToTime > viewCreatedTime && scrollToTime <= initialDrawEndTime) {//延遲初次渲染,須要減去等待的時間(viewCreated->changeToPage) return initialDrawEndTime - createTime - (scrollToTime - viewCreatedTime); } else {//正常初次渲染 return initialDrawEndTime - createTime; } } long getFinalDrawTime() { if (finalDrawEndTime <= 0 || apiLoadEndTime <= 0) { return -1; } //延遲二次渲染,須要減去等待時間(apiLoadEnd->scrollToTime) if (scrollToTime > 0 && scrollToTime > apiLoadEndTime && scrollToTime <= finalDrawEndTime) { return finalDrawEndTime - apiLoadEndTime - (scrollToTime - apiLoadEndTime); } else {//正常二次渲染 return finalDrawEndTime - apiLoadEndTime; } } 

總結

以上就是咱們對頁面測速及自動化實現上作的一些嘗試,目前已經在項目中使用,並在監控平臺上能夠獲取實時的數據。咱們能夠經過分析數據來了解頁面的性能進而作優化,不斷提高項目的總體質量。而且經過實踐發現了一些測速偏差的問題,也都逐一解決,使得測速數據更加可靠。自動化的實現也讓咱們在後續開發中的維護變得更容易,不用維護頁面測速相關的邏輯,就能夠作到實時監測全部頁面的加載速度。

參考文獻

 

 

Kotlin代碼檢查在美團的探索與實踐

背景

Kotlin有着諸多的特性,好比空指針安全、方法擴展、支持函數式編程、豐富的語法糖等。這些特性使得Kotlin的代碼比Java簡潔優雅許多,提升了代碼的可讀性和可維護性,節省了開發時間,提升了開發效率。這也是咱們團隊轉向Kotlin的緣由,可是在實際的使用過程當中,咱們發現看似寫法簡單的Kotlin代碼,可能隱藏着不容忽視的額外開銷。本文剖析了Kotlin的隱藏開銷,並就如何避免開銷進行了探索和實踐。

Kotlin的隱藏開銷

伴生對象

伴生對象經過在類中使用companion object來建立,用來替代靜態成員,相似於Java中的靜態內部類。因此在伴生對象中聲明常量是很常見的作法,但若是寫法不對,可能就會產生額外開銷。好比下面這段聲明Version常量的代碼:

class Demo { fun getVersion(): Int { return Version } companion object { private val Version = 1 } } 

表面上看還算簡潔,可是將這段Kotlin代碼轉化成等同的Java代碼後,卻顯得晦澀難懂:

public class Demo { private static final int Version = 1; public static final Demo.Companion Companion = new Demo.Companion(); public final int getVersion() { return Companion.access$getVersion$p(Companion); } public static int access$getVersion$cp() { return Version; } public static final class Companion { private static int access$getVersion$p(Companion companion) { return companion.getVersion(); } private int getVersion() { return Demo.access$getVersion$cp(); } } } 

與Java直接讀取一個常量不一樣,Kotlin訪問一個伴生對象的私有常量字段須要通過如下方法:

  • 調用伴生對象的靜態方法
  • 調用伴生對象的實例方法
  • 調用主類的靜態方法
  • 讀取主類中的靜態字段

爲了訪問一個常量,而多花費調用4個方法的開銷,這樣的Kotlin代碼無疑是低效的。

咱們能夠經過如下解決方法來減小生成的字節碼:

  1. 對於基本類型和字符串,能夠使用const關鍵字將常量聲明爲編譯時常量。
  2. 對於公共字段,能夠使用@JvmField註解。
  3. 對於其餘類型的常量,最好在它們本身的主類對象而不是伴生對象中來存儲公共的全局常量。

Lazy()委託屬性

lazy()委託屬性能夠用於只讀屬性的惰性加載,可是在使用lazy()時常常被忽視的地方就是有一個可選的model參數:

  • LazyThreadSafetyMode.SYNCHRONIZED:初始化屬性時會有雙重鎖檢查,保證該值只在一個線程中計算,而且全部線程會獲得相同的值。
  • LazyThreadSafetyMode.PUBLICATION:多個線程會同時執行,初始化屬性的函數會被屢次調用,可是隻有第一個返回的值被當作委託屬性的值。
  • LazyThreadSafetyMode.NONE:沒有雙重鎖檢查,不該該用在多線程下。

lazy()默認狀況下會指定LazyThreadSafetyMode.SYNCHRONIZED,這可能會形成沒必要要線程安全的開銷,應該根據實際狀況,指定合適的model來避免不須要的同步鎖。

基本類型數組

在Kotlin中有3種數組類型:

  • IntArrayFloatArray,其餘:基本類型數組,被編譯成int[]float[],其餘
  • Array<T>:非空對象數組
  • Array<T?>:可空對象數組

使用這三種類型來聲明數組,能夠發現它們之間的區別:

等同的Java代碼:

後面兩種方法都對基本類型作了裝箱處理,產生了額外的開銷。

因此當須要聲明非空的基本類型數組時,應該使用xxxArray,避免自動裝箱。

For循環

Kotlin提供了downTostepuntilreversed等函數來幫助開發者更簡單的使用For循環,若是單一的使用這些函數確實是方便簡潔又高效,但要是將其中兩個結合呢?好比下面這樣:

上面的For循環中結合使用了downTostep,那麼等同的Java代碼又是怎麼實現的呢?

重點看這行代碼:

IntProgression var10000 = RangesKt.step(RangesKt.downTo(10, 1), 2);

這行代碼就建立了兩個IntProgression臨時對象,增長了額外的開銷。

Kotlin檢查工具的探索

Kotlin的隱藏開銷不止上面列舉的幾個,爲了不開銷,咱們須要實現這樣一個工具,實現Kotlin語法的檢查,列出不規範的代碼並給出修改意見。同時爲了保證開發同窗的代碼都是通過工具檢查的,整個檢查流程應該自動化。

再進一步考慮,Kotlin代碼的檢查規則應該具備擴展性,方便其餘使用方定製本身的檢查規則。

基於此,整個工具主要包含下面三個方面的內容:

  1. 解析Kotlin代碼
  2. 編寫可擴展的自定義代碼檢查規則
  3. 檢查自動化

結合對工具的需求,在通過思考和查閱資料以後,肯定了三種可供選擇的方案:

ktlint

ktlint 是一款用來檢查Kotlin代碼風格的工具,和咱們的工具定位不一樣,須要通過大量的改造工做才行。

detekt

detekt 是一款用來靜態分析Kotlin代碼的工具,符合咱們的需求,可是不太適合Android工程,好比沒法指定variant(變種)檢查。另外,在整個檢查流程中,一份kt文件只能檢查一次,檢查結果(當時)只支持控制檯輸出,不便於閱讀。

改造Lint

改造Lint來增長Lint對Kotlin代碼檢查的支持,一方面Lint提供的功能徹底能夠知足咱們的需求,同時還能支持資源文件和class文件的檢查,另外一方面改造後的Lint和Lint很類似,學習上手的成本低。

相對於前兩種方案,方案3的成本收益比最高,因此咱們決定改造Lint成Kotlin Lint(KLint)插件。

先來大體瞭解下Lint的工做流程,以下圖:

很顯然,上圖中的紅框部分須要被改造以適配Kotlin,主要工做有如下3點:

  • 建立KotlinParser對象,用來解析Kotlin代碼
  • 從aar中獲取自定義KLint規則的jar包
  • Detector類須要定義一套新的接口方法來適配遍歷Kotlin節點回調時的調用

Kotlin代碼解析

和Java同樣,Kotlin也有本身的抽象語法樹。惋惜的是目前尚未解析Kotlin語法樹的單獨庫,只能經過Kotlin編譯器這個庫中的相關類來解析。KLint用的是kotlin-compiler-embeddable:1.1.2-5庫。

public KtFile parseKotlinToPsi(@NonNull File file) { try { org.jetbrains.kotlin.com.intellij.openapi.project.Project ktProject = KotlinCoreEnvironment.Companion.createForProduction(() -> { }, new CompilerConfiguration(), CollectionsKt.emptyList()).getProject(); this.psiFileFactory = PsiFileFactory.getInstance(ktProject); return (KtFile) psiFileFactory.createFileFromText(file.getName(), KotlinLanguage.INSTANCE, readFileToString(file, "UTF-8")); } catch (IOException e) { e.printStackTrace(); } return null; } //可忽視,只是將文件轉成字符流 public static String readFileToString(File file, String encoding) throws IOException { FileInputStream stream = new FileInputStream(file); String result = null; try { result = readInputStreamToString(stream, encoding); } finally { try { stream.close(); } catch (IOException e) { // ignore } } return result; } 

以上這段代碼能夠封裝成KotlinParser類,主要做用是將.Kt文件轉化成KtFile對象。

在檢查Kotlin文件時調用KtFile.acceptChildren(KtVisitorVoid)後,KtVisitorVoid便會屢次回調遍歷到的各個節點(Node)的方法:

KtVisitorVoid visitorVoid = new KtVisitorVoid(){ @Override public void visitClass(@NotNull KtClass klass) { super.visitClass(klass); } @Override public void visitPrimaryConstructor(@NotNull KtPrimaryConstructor constructor) { super.visitPrimaryConstructor(constructor); } @Override public void visitProperty(@NotNull KtProperty property) { super.visitProperty(property); } ... }; ktPsiFile.acceptChildren(visitorVoid); 

自定義KLint規則的實現

自定義KLint規則的實現參考了Android自定義Lint實踐這篇文章。

上圖展現了aar中容許包含的文件,aar中能夠包含lint.jar,這也是Android自定義Lint實踐這篇文章採用的實現方式。可是klint.jar不能直接放入aar中,固然更不該該將klint.jar重命名成lint.jar來實現目的。

最後採用的方案是:

  1. 經過建立klintrules這個空的aar,將klint.jar放入assets中;
  2. 修改KLint代碼實現從assets中讀取klint.jar
  3. 項目依賴klintrulesaar時使用debugCompile來避免把klint.jar帶到release包。

Detector類中接口方法的定義

既然是對Kotlin代碼的檢查,天然Detector類要定義一套新的接口方法。先來看一下Java代碼檢查規則提供的方法:

相信寫過Lint規則的同窗對上面的方法應該很是熟悉。爲了儘可能下降KLint檢查規則編寫的學習成本,咱們參照JavaPsiScanner接口,定義了一套很是類似的接口方法:

KLint的實現

經過對上述3個主要方面的改造,完成了KLint插件。

因爲KLint和Lint的類似,KLint插件簡單易上手:

  1. 和Lint類似的編寫規範(參考最後一節的代碼);
  2. 支持@SuppressWarnings("")等Lint支持的註解;
  3. 具備和Lint的Options相同功能的klintOptions,以下:
mtKlint { klintOptions { abortOnError false htmlReport true htmlOutput new File(project.getBuildDir(), "mtKLint.html") } } 

檢查自動化

  • 關於自動檢查有兩個方案:

    1. 在開發同窗commit/push代碼時,觸發pre-commit/push-hook進行檢查,檢查不經過不容許commit/push;
    2. 在建立pull request時,觸發CI構建進行檢查,檢查不經過不容許merge。

    這裏更偏向於方案2,由於pre-commit/push-hook能夠經過--no-verify命令繞過,咱們但願全部的Kotlin代碼都是經過檢查的。

KLint插件自己支持經過./gradlew mtKLint命令運行,可是考慮到幾乎全部的項目在CI構建上都會執行Lint檢查,把KLint和Lint綁定在一塊兒能夠省去CI構建腳本接入KLint插件的成本。

經過如下代碼,將lint task依賴klint task,實如今執行Lint以前先執行KLint檢查:

//建立KLint task,並設置被Lint task依賴 KLint klintTask = project.getTasks().create(String.format(TASK_NAME, ""), KLint.class, new KLint.GlobalConfigAction(globalScope, null, KLintOptions.create(project))) Set<Task> lintTasks = project.tasks.findAll { it.name.toLowerCase().equals("lint") } lintTasks.each { lint -> klintTask.dependsOn lint.taskDependencies.getDependencies(lint) lint.dependsOn klintTask } //建立Klint變種task,並設置被Lint變種task依賴 for (Variant variant : androidProject.variants) { klintTask = project.getTasks().create(String.format(TASK_NAME, variant.name.capitalize()), KLint.class, new KLint.GlobalConfigAction(globalScope, variant, KLintOptions.create(project))) lintTasks = project.tasks.findAll { it.name.startsWith("lint") && it.name.toLowerCase().endsWith(variant.name.toLowerCase()) } lintTasks.each { lint -> klintTask.dependsOn lint.taskDependencies.getDependencies(lint) lint.dependsOn klintTask } } 

檢查實時化

雖然實現了檢查的自動化,可是能夠發現執行自動檢查的時機相對滯後,每每是開發同窗準備合代碼的時候,這時再去修改代碼成本高而且存在風險。CI上的自動檢查應該是做爲是否有「漏網之魚」的最後一道關卡,而問題應該暴露在代碼編寫的過程當中。基於此,咱們開發了Kotlin代碼實時檢查的IDE插件。

經過這款工具,實如今Android Studio的窗口實時報錯,幫助開發同窗第一時間發現問題及時解決。

Kotlin代碼檢查實踐

KLint插件分爲Gradle插件和IDE插件兩部分,前者在build.gradle中引入,後者經過Android Studio安裝使用。

KLint規則的編寫

針對上面列舉的lazy()中未指定mode的case,KLint實現了對應的檢查規則:

public class LazyDetector extends Detector implements Detector.KtPsiScanner { public static final Issue ISSUE = Issue.create( "Lazy Warning", "Missing specify `lazy` mode ", "see detail: https://wiki.sankuai.com/pages/viewpage.action?pageId=1322215247", Category.CORRECTNESS, 6, Severity.ERROR, new Implementation( LazyDetector.class, EnumSet.of(Scope.KOTLIN_FILE))); @Override public List<Class<? extends PsiElement>> getApplicableKtPsiTypes() { return Arrays.asList(KtPropertyDelegate.class); } @Override public KtVisitorVoid createKtPsiVisitor(KotlinContext context) { return new KtVisitorVoid() { @Override public void visitPropertyDelegate(@NotNull KtPropertyDelegate delegate) { boolean isLazy = false; boolean isSpeifyMode = false; KtExpression expression = delegate.getExpression(); if (expression != null) { PsiElement[] psiElements = expression.getChildren(); for (PsiElement psiElement : psiElements) { if (psiElement instanceof KtNameReferenceExpression) { if ("lazy".equals(((KtNameReferenceExpression) psiElement).getReferencedName())) { isLazy = true; } } else if (psiElement instanceof KtValueArgumentList) { List<KtValueArgument> valueArguments = ((KtValueArgumentList) psiElement).getArguments(); for (KtValueArgument valueArgument : valueArguments) { KtExpression argumentValue = valueArgument.getArgumentExpression(); if (argumentValue != null) { if (argumentValue.getText().contains("SYNCHRONIZED") || argumentValue.getText().contains("PUBLICATION") || argumentValue.getText().contains("NONE")) { isSpeifyMode = true; } } } } } if (isLazy && !isSpeifyMode) { context.report(ISSUE, expression,context.getLocation(expression.getContext()), "Specify the appropriate thread safety mode to avoid locking when it’s not needed."); } } } }; } } 

檢查結果

Gradle插件和IDE插件共用一套規則,因此上面的規則編寫一次,就能夠同時在兩個插件中使用:

  • CI上自動檢查對應的檢測結果的HTML頁面:

  • Android Studio上對應的實時報錯信息:

總結

藉助KLint插件,編寫檢查規則來約束不規範的Kotlin代碼,一方面避免了隱藏開銷,提升了Kotlin代碼的性能,另外一方面也幫助開發同窗更好的理解Kotlin。

參考資料

 

WMRouter:美團外賣Android開源路由框架

WMRouter是一款Android路由框架,基於組件化的設計思路,功能靈活,使用也比較簡單。

WMRouter最初用於解決美團外賣C端App在業務演進過程當中的實際問題,以後逐步推廣到了美團其餘App,所以咱們決定將其開源,但願更多技術同行一塊兒開發,應用到更普遍的場景裏去。Github項目地址與使用文檔詳見 https://github.com/meituan/WMRouter

本文先簡單介紹WMRouter的功能和適用場景,而後詳細介紹WMRouter的發展背景和過程。

功能簡介

WMRouter主要提供URI分發、ServiceLoader兩大功能。

URI分發功能可用於多工程之間的頁面跳轉、動態下發URI連接的跳轉等場景,特色以下:

  1. 支持多scheme、host、path
  2. 支持URI正則匹配
  3. 頁面配置支持Java代碼動態註冊,或註解配置自動註冊
  4. 支持配置全局和局部攔截器,可在跳轉前執行同步/異步操做,例如定位、登陸等
  5. 支持單次跳轉特殊操做:Intent設置Extra/Flags、設置跳轉動畫、自定義StartActivity操做等
  6. 支持頁面Exported控制,特定頁面不容許外部跳轉
  7. 支持配置全局和局部降級策略
  8. 支持配置單次和全局跳轉監聽
  9. 徹底組件化設計,核心組件都可擴展、按需組合,實現靈活強大的功能

基於SPI (Service Provider Interfaces) 的設計思想,WMRouter提供了ServiceLoader模塊,相似Java中的java.util.ServiceLoader,但功能更加完善。經過ServiceLoader能夠在一個App的多個模塊之間經過接口調用代碼,實現模塊解耦,便於實現組件化、模塊間通訊,以及和依賴注入相似的功能等。其特色以下:

  1. 使用註解自動配置
  2. 支持獲取接口的全部實現,或根據Key獲取特定實現
  3. 支持獲取Class或獲取實例
  4. 支持無參構造、Context構造,或自定義Factory、Provider構造
  5. 支持單例管理
  6. 支持方法調用

其餘特性:

  1. 優化的Gradle插件,對編譯耗時影響很小
  2. 編譯期和運行時配置檢查,避免配置衝突和錯誤
  3. 編譯期自動添加Proguard混淆規則,免去手動配置的繁瑣
  4. 完善的調試功能,幫助及時發現問題

適用場景

WMRouter適用但不限於如下場景:

  1. Native+H5混合開發模式,須要進行頁面之間的互相跳轉,或進行靈活的運營跳轉連接下發。能夠利用WMRouter統一頁面跳轉邏輯,根據不一樣的協議(HTTP、HTTPS、用於Native頁面的自定義協議)跳轉對應頁面,且在跳轉過程當中能夠使用UriInterceptor對跳轉連接進行修改,例如跳轉H5頁面時在URL中加參數。

  2. 統一管理來自App外部的URI跳轉。來自App外部的URI跳轉,若是使用Android原生的Manifest配置,會直接啓動匹配的Activity,而不少時候但願先正常啓動App打開首頁,完成常規初始化流程(例如登陸、定位等)後再跳轉目標頁面。此時能夠使用統一的Activity接收全部外部URI跳轉,到首頁時再用WMRouter啓動目標頁面。

  3. 頁面跳轉有複雜判斷邏輯的場景。例如多個頁面都須要先登陸、先定位後才容許打開,若是使用常規方案,這些頁面都須要處理相同的業務邏輯;而利用WMRouter,只須要開發好UriInterceptor並配置到各個頁面便可。

  4. 多工程、組件化、平臺化開發。多工程開發要求各個工程之間能互相通訊,也可能遇到和外賣App相似的代碼複用、依賴注入、編譯等問題,這些問題均可以利用WMRouter的URI分發和ServiceLoader模塊解決。

  5. 對業務埋點需求較強的場景。頁面跳轉做爲最多見的業務邏輯之一,經常須要埋點。給每一個頁面配置好URI,使用WMRouter統一進行頁面跳轉,並在全局的OnCompleteListener中埋點便可。

  6. 對App可用性要求較高的場景。一方面,能夠對頁面跳轉失敗進行埋點監控上報,及時發現線上問題;另外一方面,頁面跳轉時能夠執行判斷邏輯,發現異常(例如服務端異常、客戶端崩潰等)則自動打開降級後的頁面,保證關鍵功能的正常工做,或給用戶友好的提示。

  7. 頁面A/B測試、動態配置等場景。在WMRouter提供的接口基礎上進行少許開發配置,就能夠實現:根據下發的A/B測試策略跳轉不一樣的頁面實現;根據不一樣的須要動態下發一組路由表,相同的URI跳轉到不一樣的一組頁面(實現方面能夠自定義UriInterceptor,對匹配的URI返回301的UriResult使跳轉重定向)。

基本概念解釋

下面開始介紹WMRouter的發展背景和過程。爲了方便後文的理解,咱們先簡單瞭解和回顧幾個基本概念。

路由

根據維基百科的解釋,路由(routing)能夠理解成在互聯的網絡經過特定的協議把信息從源地址傳輸到目的地址的過程。一個典型的例子就是在互聯網中,路由器能夠根據IP協議將數據發送到特定的計算機。

URI

URI(Uniform Resource Identifier,統一資源標識符)是一個用於標識某一互聯網資源名稱的字符串。URI的組成以下圖所示。

一些常見的URI舉例以下,包括平時常常用到的網址、IP地址、FTP地址、文件、打電話、發郵件的協議等。

在Android中也提供了android.net.Uri工具類用於處理URI,Android中URI經常使用的幾個部分主要是scheme、host、path和query。

Android中的Intent跳轉

在Android中的Intent跳轉,分爲顯式跳轉和隱式跳轉兩種。

顯式跳轉即指定ComponentName(類名)的Intent跳轉,通常經過Bundle傳參,示例代碼以下:

Intent intent = new Intent(context, TestActivity.class); intent.putExtra("param", "value") startActivity(intent); 

隱式跳轉即不指定ComponentName的Intent跳轉,經過IntentFilter找到匹配的組件,IntentFilter支持action、category和data的匹配,其中data就是URI。例以下面的代碼,會啓動系統默認的瀏覽器打開網頁:

Intent intent = new Intent(Intent.ACTION_VIEW); intent.setData(Uri.parse("http://www.meituan.com")) startActivity(intent); 

Activity經過Manifest配置IntentFilter,例以下面的配置能夠匹配全部形如demo_scheme://demo_host/***的URI。

<activity android:name=".app.UriProxyActivity" android:exported="true"> <intent-filter> <action android:name="android.intent.action.VIEW"/> <category android:name="android.intent.category.DEFAULT"/> <category android:name="android.intent.category.BROWSABLE"/> <data android:scheme="demo_scheme" android:host="demo_host"/> </intent-filter> </activity> 

URI跳轉

在美團外賣C端早期開發過程當中,產品但願經過後臺下發URI控制客戶端跳轉指定頁面,從而實現靈活的運營配置。外賣App採用了Native+H5的混合開發模式,Native頁面定義了專用的URI,而H5頁面則使用HTTP/HTTPS連接在專門的WebView容器中加載,兩種連接的跳轉邏輯不一樣,實現起來比較繁瑣。

Native頁面的URI跳轉最開始使用的是Android原生的IntentFilter,經過隱式跳轉啓動,可是這種方式存在靈活性差、功能缺失、Bug多等問題。例如:

  1. 從外部(瀏覽器、微信等)跳轉外賣的URI時,系統會直接打開相應的Activity,而沒有通過歡迎頁的正常啓動流程,一些代碼邏輯可能沒有執行,例如定位邏輯。

  2. 有不少頁面在打開前須要確保用戶先登陸或先定位,每一個頁面都寫一遍判斷登陸、定位的邏輯很是麻煩,提升了開發維護成本。

  3. 運營人員可能會配錯URI,頁面跳轉失敗,有些跳轉的地方沒有作try-catch處理,會產生Crash;有些地方雖然加了try-catch,但跳轉失敗後沒有任何響應,用戶體驗差;跳轉失敗沒有監控,不能及時發現和解決線上業務異常。

爲了解決上述問題,咱們但願有一個Android的URI分發組件,能夠根據URI中不一樣的scheme、host、path,進行不一樣的處理,同時可以在頁面跳轉過程當中進行更靈活的干預。調研發現,現有的一些Android路由組件主要都是在解決多工程之間解耦的問題,而URI每每只支持經過path分發,頁面跳轉的配置也不夠靈活,難以知足實際須要。因而咱們決定自行設計實現。

核心設計思路

下圖展現了WMRouter中URI分發機制的核心設計思路。借鑑網絡請求的機制,WMRouter中的每次URI跳轉視爲發起一個UriRequest;URI跳轉請求被WMRouter逐層分發給一系列的UriHandler進行處理;每一個UriHandler處理以前能夠被UriInterceptor攔截,並插入一些特殊操做。

頁面跳轉來源

常見的頁面跳轉來源以下:

  1. 來自App內部Native頁面的跳轉
  2. 來自App內Web容器的跳轉,即H5頁面發起的跳轉
  3. 從App外經過URI喚起App的跳轉,例如來自瀏覽器、微信等
  4. 從通知中心Push喚起App的跳轉

對於來自App內部和Web容器的跳轉,咱們把全部跳轉代碼統一改爲調用WMRouter處理,而來自外部和Push通知的跳轉則所有使用一個獨立的中轉Activity接收,再調用WMRouter處理。

UriRequest

UriRequest中包含Context、URI和Fields,其中Fields爲HashMap<string, object="">,能夠經過Key存聽任意數據。簡單起見,UriRequest類同時承擔了Response的功能,跳轉請求的結果,也會被保存到Fields中。Fields能夠根據須要自定義,其中一些常見字段舉例以下:

  • Intent的Extra參數,Bundle類型
  • 用於startActivityForResult的RequestCode,int類型
  • 用於overridePendingTransition方法的頁面切換動畫資源,int[]類型
  • 本次跳轉結果的監聽器,OnCompleteListener類型

每次URI跳轉請求會有一個ResultCode(相似HTTP請求的ResponseCode),表示跳轉結果,也存放在Fields中。常見Code以下,用戶也能夠自定義Code:

  • 200:跳轉成功
  • 301:重定向到其餘URI,會再次跳轉
  • 400:請求錯誤,一般是Context或URI爲空
  • 403:禁止跳轉,例如跳轉白名單之外的HTTP連接、Activity的exported爲false等
  • 404:找不到目標(Activity或UriHandler)
  • 500:發生錯誤

總結來講,UriRequest用於實現一次URI跳轉中全部組件之間的通訊功能。

UriHandler

UriHandler用於處理URI跳轉請求,能夠嵌套從而逐層分發和處理請求。UriHandler是異步結構,接收到UriRequest後處理(例如跳轉Activity等),若是處理完成,則調用callback.onComplete()並傳入ResultCode;若是沒有處理,則調用callback.onNext()繼續分發。下面的示例代碼展現了一個只處理HTTP連接的UriHandler的實現:

public interface UriCallback { /** * 處理完成,繼續後續流程。 */ void onNext(); /** * 處理完成,終止分發流程。 * * @param resultCode 結果 */ void onComplete(int resultCode); } public class DemoUriHandler extends UriHandler { public void handle(@NonNull final UriRequest request, @NonNull final UriCallback callback) { Uri uri = request.getUri(); // 處理HTTP連接 if ("http".equalsIgnoreCase(uri.getScheme())) { try { // 調用系統瀏覽器 Intent intent = new Intent(); intent.setAction(Intent.ACTION_VIEW); intent.setData(uri); request.getContext().startActivity(intent); // 跳轉成功 callback.onComplete(UriResult.CODE_SUCCESS); } catch (Exception e) { // 跳轉失敗 callback.onComplete(UriResult.CODE_ERROR); } } else { // 非HTTP連接不處理,繼續分發 callback.onNext(); } } // ... } 

UriInterceptor

UriInterceptor爲攔截器,不作最終的URI跳轉操做,但能夠在最終的跳轉前進行各類同步/異步操做,常見操做舉例以下:

  • URI跳轉攔截,禁止特定的URI跳轉,直接返回403(例如禁止跳轉非meituan域名的HTTP連接)
  • URI參數修改(例如在HTTP連接末尾添加query參數)
  • 各類中間處理(例如打開登陸頁登陸、獲取定位、髮網絡請求)
  • ……

每一個UriHandler均可以添加若干UriInterceptor。在UriHandler基類中,handle()方法先調用抽象方法shouldHandle()判斷是否要處理UriRequest,若是須要處理,則逐個執行Interceptor,最後再調用handleInternal()方法進行跳轉操做。

public abstract class UriHandler { // ChainedInterceptor將多個UriInterceptor合併成一個 protected ChainedInterceptor mInterceptor; public UriHandler addInterceptor(@NonNull UriInterceptor interceptor) { if (interceptor != null) { if (mInterceptor == null) { mInterceptor = new ChainedInterceptor(); } mInterceptor.addInterceptor(interceptor); } return this; } public void handle(@NonNull final UriRequest request, @NonNull final UriCallback callback) { if (shouldHandle(request)) { if (mInterceptor != null) { mInterceptor.intercept(request, new UriCallback() { @Override public void onNext() { handleInternal(request, callback); } @Override public void onComplete(int result) { callback.onComplete(result); } }); } else { handleInternal(request, callback); } } else { callback.onNext(); } } /** * 是否要處理給定的uri。在Interceptor以前調用。 */ protected abstract boolean shouldHandle(@NonNull UriRequest request); /** * 處理uri。在Interceptor以後調用。 */ protected abstract void handleInternal(@NonNull UriRequest request, @NonNull UriCallback callback); } 

URI的分發與降級

在外賣C端App中的URI分發示意以下圖。全部URI跳轉都會分發到RootUriHandler,而後根據不一樣的scheme分發到不一樣的子Handler。例如waimai協議分發到WmUriHandler,而後進一步根據不一樣的path分發到子Handler,從而啓動相應的Activity;HTTP/HTTPS協議分發到HttpHandler,啓動WebView容器;對於其餘類型URI(tel、mailto等),前面的幾個Handler都沒法處理,則會分發到StartUriHandler,嘗試使用Android原生的隱式跳轉啓動系統應用。

每一個UriHandler均可以根據實際須要實現降級策略,也能夠不做處理繼續分發給其餘UriHandler。RootUriHandler中提供了一個全局的分發完成事件監聽器,當UriHandler處理失敗返回異常ResultCode或全部子UriHandler都沒有處理時,執行全局降級策略。

平臺化與兩端複用

隨着外賣C端業務的演進,團隊成員擴充了數倍,商超生鮮等垂直品類的拆分,以及異地研發團隊的創建,客戶端的平臺化被提上日程。關於外賣平臺化更詳細的內容,可參考團隊以前已經發布的文章 美團外賣Android平臺化架構演進實踐

爲了知足實際開發須要,在長時間的探索後,逐步造成了如圖所示的三層工程結構。

原有的單個工程拆分紅多個工程,就不可避免的涉及到多工程之間的耦合問題,主要包括通訊問題、複用問題、依賴注入、編譯問題,下面詳細介紹。

通訊問題

當原先的一個工程拆分到各個業務庫後,業務庫之間的頁面須要進行通訊,最主要的場景就是頁面跳轉並經過Intent傳遞參數。

原先的頁面跳轉使用顯式跳轉,Activity之間存在強引用,當Activity被拆分到不一樣的業務庫,業務庫不能直接互相依賴,所以須要進行解耦。

利用WMRouter的URI分發機制,恰好能夠很容易的解決這個問題。將將全部業務庫的Activity註冊到WMRouter,各個業務庫之間就能夠進行頁面跳轉了。

此時WMRouter已經承載了兩項功能:

  1. 後臺下發的運營URI跳轉 (waimai://*)
  2. 內部頁面跳轉,用於代替原有的顯式跳轉,實現工程解耦 (wm_router://page/*)

因爲後臺下發的URI是和產品、運營、H五、iOS等各端統一制定的協議,支持的頁面、格式、參數等都不能隨意改動,而內部頁面跳轉使用的URI,則須要根據實際開發須要進行配置,兩套URI協議不能兼容,所以使用了不一樣的scheme。

複用問題與ServiceLoader模塊

業務庫之間常常須要複用代碼。一些通用代碼邏輯能夠下沉到平臺層從而複用,例如業務無關的通用View組件;而有些代碼不適合下沉到平臺層,例如業務庫A要使用業務庫B中的某個界面模塊,而這個界面模塊和業務庫B的耦合很緊密。具體到外賣實際業務場景中,商家頁在商家休息時會展現推薦商家列表,其樣式和首頁相同(如圖),而兩個頁面不在一個工程中,商家頁但願能直接從首頁業務庫中獲取商家列表的實現。

爲了解決上述問題,咱們調研瞭解到Java中SPI (Service Provider Interfaces) 的設計思想與java.util.ServiceLoader工具類,還學習到美團平臺爲了解決相似問題而開發的ServiceLoader組件。

考慮到實際需求差別,咱們從新開發了本身的ServiceLoader實現。相比Java中的實現,WMRouter的實現借鑑了美團平臺的設計思路,不只支持經過接口獲取全部實現類,還支持經過接口和惟一的Key獲取特定的實現類。另外WMRouter的實現還支持直接加載實現類的Class、用Factory和Provider建立對象、單例管理、方法調用等功能。在Gradle插件的實現思路上,借鑑了美團平臺的ServiceLoader並作了性能優化,給平臺提出了改進建議。

業務庫之間代碼複用的需求示意如圖,業務庫A須要複用業務庫B中的ServiceImpl但又不能直接引用,所以經過WMRouter加載:

  1. 抽取接口(或父類,後面再也不重複說明)下沉到平臺層,實現類ServiceImpl實現該接口,並聲明一個Key(字符串類型)。
  2. 調用方經過接口和Key,由ServiceLoader加載實現類,經過接口訪問實現類。

URI跳轉和ServiceLoader看起來彷佛沒有關聯,但通訊和複用需求的本質均可以理解成路由,頁面經過URI分發跳轉時的協議是Activity+URI,在這裏ServiceLoader的協議是Interface+Key。

依賴注入

爲了兼容外賣獨立App和美團App外賣頻道的兩端差別,平臺層的一些接口要在兩個主工程分別實現,並注入到底層。常規Java代碼注入的方式寫起來很繁瑣,而使用WMRouter的ServiceLoader功能能夠更簡單的實現和依賴注入相似的效果。

對於WMRouter來講,全部依賴它的工程(包括主工程、業務庫、平臺庫)都是同樣的,任何一個庫想要調用其餘庫中的代碼,均可以經過WMRouter路由轉發。前面的通訊和複用問題,是同級的業務庫之間經過WMRouter調用,而依賴注入則是底層庫經過WMRouter調用上層庫,其本質和實現都是相同的。

ServiceLoader模塊在加載實現類時提供了單例管理功能,可用於管理一些全局的Service/Manager,例如用戶帳戶管理類UserManager

編譯問題

因爲歷史緣由,主工程做爲一個沒有業務邏輯的殼工程,對業務庫卻有較多依賴,特別是對業務庫的初始化配置繁瑣,和各業務庫耦合緊密。另外一方面,WMRouter跳轉的頁面、加載的實現類,須要在Application初始化時註冊到WMRouter中,也會增長主工程和業務庫的耦合。開發過程當中各業務庫頻繁更新,常常出現沒法編譯的問題,嚴重影響開發。

爲了解決這個問題,一方面WMRouter增長了註解支持,在Activity類、ServiceLoader實現類上配置註解,就能夠在編譯期間自動生成代碼註冊到WMRouter中。

// 沒有註解時,須要在Application初始化時代碼註冊各個頁面,耦合嚴重 register("/home", HomeActivity.class); register("/order", OrderListActivity.class); register("/shop", ShopActivity.class) register("/account", MyAccountActivity.class); register("/address", MyAddressActivity.class); // ... 
// 增長註解後,只須要在各個Activity上經過註解配置便可 @RouterUri(path = "/shop") public class ShopActivity extends BaseActivity { } 

另外一方面,ServiceLoader還支持指定接口加載全部實現類,主工程能夠經過統一接口,加載各個業務庫中全部實現類並進行初始化,最終實現和業務庫的完全隔離。

開發過程當中,各個業務庫能夠像插件同樣按需集成到主工程,能大幅減小不能編譯的問題,同時因爲編譯時能夠跳過不須要的業務庫,編譯速度也能獲得提升。

WMRouter的推廣

在WMRouter解決了外賣C端App的各類問題後,發現公司內甚至公司外的其餘App也遇到了類似的問題和需求,因而決定對WMRouter進行推廣和開源。

因爲WMRouter是一個開放式組件化框架,UriRequest能夠存聽任意數據,UriHandler、UriInterceptor能夠徹底自定義,不一樣的UriHandler能夠任意組合,具備很大的靈活性。但過於靈活容易致使易用性的降低,即便對於最常規最簡單的應用,也須要複雜的配置才能完成功能。

爲了在靈活性與易用性之間平衡,一方面,WMRouter對包結構進行了合理的劃分,核心接口和實現類提供基礎通用能力,儘量保留最大的靈活性;另外一方面,封裝了一系列通用實現類,並組合成一套默認實現,從而知足絕大多數常規使用場景,儘量下降其餘App的接入成本,方便推廣。

總結

目前業界已有的一些Android路由框架,不能知足外賣C端App在開發過程當中的實際須要,所以咱們開發了WMRouter路由框架。借鑑網絡請求的思想,設計了基於UriRequest、UriHandler、UriInterceptor的URI分發機制,在保證功能靈活強大的同時,又儘量的下降了使用難度;另外一方面,借鑑SPI的設計思想、Java和美團平臺的ServiceLoader實現,開發了本身的ServiceLoader模塊,解決外賣平臺化過程當中的四個問題(通訊問題、複用問題、依賴注入、編譯問題)。在通過了近一年的不斷迭代完善後,WMRouter已經成爲美團多個App中的核心基礎組件之一。

參考資料

  1. Routing - Wikipedia
  2. 統一資源標誌符 - 維基百科
  3. RFC 3966 - The tel URI for Telephone Numbers
  4. RFC 6068 - The ‘mailto’ URI Scheme
  5. Intent 和 Intent 過濾器
  6. Introduction to the Service Provider Interfaces
  7. 美團外賣Android平臺化架構演進實踐

 

美團外賣客戶端高可用建設體系

背景

美團外賣從2013年11月開始起步,通過數年的高速發展,一直在不斷地刷新着記錄。2018年5月19日,日訂單量峯值突破2000萬單,已經成爲全球規模最大的外賣平臺。業務的快速發展對系統穩定性提出了更高的要求,如何爲線上用戶提供高穩定的服務體驗,保障全鏈路業務和系統高可用運行,不只須要後端服務支持,更須要在端上提供全面的技術保障。而相對服務端而言,客戶端運行環境千差萬別,不可控因素多,面對突發問題應急能力差。所以,構建客戶端的高可用建設體系,保障服務穩定高可用,不只是對工程師的技術挑戰,也是外賣平臺的核心競爭力之一。

高可用建設體系的思路

一個設計良好的大型客戶端系統每每是由一系列各自獨立的小組共同開發完成的,每個小組都應當具備明肯定義的的職責劃分。各業務模塊之間推行「鬆耦合」開發模式,讓業務模塊擁有隔離式變動的能力,是一種能夠同時提高開發靈活性和系統健壯性的有效手段。這是美團外賣總體的業務架構,總體上以商品交易鏈路(門店召回,商品展現,交易)爲核心方向進行建設,局部上依據業務特色和團隊分工分紅多個可獨立運維單元單獨維護。可獨立運維單元的簡單性是可靠性的前提條件,這使得咱們可以持續關注功能迭代,不斷完成相關的工程開發任務。

咱們將問題依照生命週期劃分爲三個階段:發現、定位、解決,圍繞這三個階段的持續建設,構成了美團外賣高可用建設體系的核心。

美團外賣質量保障體系全景圖

這是美團外賣客戶端總體質量體系全景圖。總體思路:監控報警,日誌體系,容災。

經過採集業務穩定性,基礎能力穩定性,性能穩定性三大類指標數據並上報,衡量客戶端系統質量的標準得以完善;經過設立基線,應用特定業務模型對這一系列指標進行監控報警,客戶端具有了分鐘級感知核心鏈路穩定性的能力;而經過搭建日誌體系,整個系統有了提取關鍵線索能力,多維度快速定位問題。當問題一旦定位,咱們就能經過美團外賣的線上運維規範進行容災操做:降級,切換通道或限流,從而保證總體的核心鏈路穩定性。

監控&報警

監控系統,處於整個服務可靠度層級模型的最底層,是運維一個可靠的穩定系統必不可少的重要組成部分。爲了保障全鏈路業務和系統高可用運行,須要在用戶感知問題以前發現系統中存在的異常,離開了監控系統,咱們就沒有能力分辨客戶端是否是在正常提供服務。

按照監控的領域方向,能夠分紅系統監控與業務監控。

系統監控,主要用於基礎能力如端到端成功率,服務響應時長,網絡流量,硬件性能等相關的監控。系統監控側重在無業務侵入和定製系統級別的監控,更多側重在業務應用的底層,多屬於單系統級別的監控。

業務監控,側重在某個時間區間,業務的運行狀況分析。業務監控系統構建於系統監控之上,能夠基於系統監控的數據指標計算,並基於特定的業務介入,實現多系統之間的數據聯合與分析,並根據相應的業務模型,提供實時的業務監控與告警。按照業務監控的時效性,能夠繼續將其細分紅實時業務監控與離線業務監控。

  • 實時業務監控,經過實時的數據採集分析,幫助快速發現及定位線上問題,提供告警機制及介入響應(人工或系統)途徑,幫助避免發生系統故障。
  • 離線的業務監控,對必定時間段收集的數據進行數據挖掘、聚合、分析,推斷出系統業務可能存在的問題,幫助進行業務上的從新優化或改進的監控。

美團外賣的業務監控,大部分屬於實時業務監控。藉助美團統一的系統監控建設基礎,美團外賣聯合公司其餘部門將部分監控基礎設施進行了改造、共建和整合複用,並打通造成閉環(監控,日誌,回撈),咱們構建了特定符合外賣業務流程的實時業務監控; 而離線的業務監控,主要經過用戶行爲的統計與業務數據的挖掘分析,來幫助產品設計,運營策略行爲等產生影響,目前這部分監控主要由美團外賣數據組提供服務。值得特別說明的是單純的信息彙總展現,無需或沒法當即作出介入動做的業務監控,能夠稱之爲業務分析,如特定區域的活動消費狀況、區域訂單數量、特定路徑轉換率、曝光點擊率等,除非這些數據用來決策系統實時狀態健康狀況,幫助產生系統維護行爲,不然這部分監控由離線來處理更合適。

咱們把客戶端穩定性指標分爲3類維度:業務穩定性指標,基礎能力穩定性指標,性能穩定性指標。對不一樣的指標,咱們採用不一樣的採集方案進行提取上報,彙總到不一樣系統;在設定完指標後,咱們就能夠制定基線,並依照特定的業務模型制定報警策略。美團外賣客戶端擁有超過40項度量質量指標,其中25項指標支持分鐘級別報警。報警通道依據緊急程度支持郵件,IM和短信三條通道。所以,咱們團隊具有及時發現影響核心鏈路穩定性的關鍵指標變化能力。

一個完善的監控報警系統是很是複雜的,所以在設計時必定要追求簡化。如下是《Site Reliability Engineering: How Google Runs Production Systems》一書中提到的告警設置原則:

最能反映真實故障的規則應該可預測性強,很是可靠,而且越簡單越好 不經常使用的數據採集,彙總以及告警配置應該定時清除(某些SRE團隊的標準是一季度未使用即刪除) 沒有暴露給任何監控後臺、告警規則的採集數據指標應該定時清除

經過監控&報警系統,2017年下半年美團外賣客戶端團隊共發現影響核心鏈路穩定性超過20起問題:包括爬蟲、流量、運營商403問題、性能問題等。目前,全部問題均已所有改造完畢。

日誌體系

監控系統的一個重要特徵是生產緊急告警。一旦出現故障,須要有人來調查這項告警,以決定目前是否存在真實故障,是否須要採起特定方法緩解故障,直至查出致使故障的問題根源。

簡單定位和深刻調試的過程必需要保持很是簡單,必須可以被團隊中任何一我的所理解。日誌體系,在簡化這一過程當中起到了決定性做用。

美團外賣的日誌體系整體分爲3大類:即全量日誌系統,個體日誌系統,異常日誌系統。全量日誌系統,主要負責採集總體性指標,如網絡可用性,埋點可用性,咱們能夠經過他了解到系統總體大盤,瞭解總體波動,肯定問題影響範圍;異常日誌系統,主要採集異常指標,如大圖問題,分享失敗,定位失敗等,咱們經過他能夠迅速獲取異常上下文信息,分析解決問題;而個體日誌系統,則用於提取個體用戶的關鍵信息,從而針對性的分析特定客訴問題。這三類日誌,構成了完整的客戶端日誌體系。

日誌的一個典型使用場景是處理單點客訴問題,解決系統潛在隱患。個體日誌系統,用於簡化工程師提取關鍵線索步驟,提高定位分析問題效率。在這一領域,美團外賣使用的是點評平臺開發的Logan服務。做爲美團移動端底層的基礎日誌庫,Logan接入了集團衆多日誌系統,例如端到端日誌、用戶行爲日誌、代碼級日誌、崩潰日誌等,而且這些日誌所有都是本地存儲,且有多重加密機制和嚴格的權限審覈機制,在處理用戶客訴時纔對數據進行回撈和分析,保證用戶隱私安全。

經過設計和實施美團外賣核心鏈路日誌方案,咱們打通了用戶交易流程中各系統如訂單,用戶中心,Crash平臺與Push後臺之間的底層數據同步;經過輸出標準問題分析手冊,針對常見個體問題的分析和處理得以標準化;經過制定日誌撈取SOP並按期演練,線上追溯能力大幅提高,平常客訴絕大部分可在30分鐘內定位緣由。在這一過程當中,經過個體暴露出影響核心鏈路穩定性的問題也均已所有改進/修復。

故障排查是運維大型系統的一項關鍵技能。採用系統化的工具和手段而不只僅依靠經驗甚至運氣,這項技能是能夠自我學習,也能夠內部進行傳授。

容災備份

針對不一樣級別的服務,應該採起不一樣的手段進行有效止損。非核心依賴,經過降級向用戶提供可伸縮的服務;而核心依賴,採用多通道方式進行依賴備份容災保證交易路徑鏈路的高可用;異常流量,經過多維度限流,最大限度保證業務可用性的同時,給予用戶良好的體驗。總結成三點,即:非核心依賴降級、核心依賴備份、過載保護限流。接下來咱們分別來闡述這三方面。

降級

在這裏選取美團外賣客戶端總體系統結構關係圖來介紹非核心依賴降級建設概覽。圖上中間紅色部分是核心關鍵節點,即外賣業務的核心鏈路:定位,商家召回,商品展現,下單;藍色部分,是核心鏈路依賴的關鍵服務;黃色部分,是可降級服務。咱們經過梳理依賴關係,改造先後端通信協議,實現了客戶端非核心依賴可降級;然後端服務,經過各級緩存,屏蔽隔離策略,實現了業務模塊內部可降級,業務之間可降級。這構成了美團外賣客戶端總體的降級體系。

右邊則是美團外賣客戶端業務/技術降級開關流程圖。經過推拉結合,緩存更新策略,咱們可以分鐘級別同步降級配置,快速止損。

目前,美團外賣客戶端有超過20項業務/能力支持降級。經過有效降級,咱們避開了1次S2級事故,屢次S三、S4級事故。此外,降級開關總體方案產出SDK horn,推廣至集團酒旅、金融等其餘核心業務應用。

備份

核心依賴備份建設上,在此重點介紹美團外賣多網絡通道。網絡通道,做爲客戶端的最核心依賴,倒是整個全鏈路體系最不可控的部分,常常出現問題:網絡劫持,運營商故障,甚至光纖被物理挖斷等大大小小的故障嚴重影響了核心鏈路的穩定性。所以,治理網絡問題,必需要建設可靠的多通道備份。

這是美團外賣多網絡通道備份示意圖。美團外賣客戶端擁有Shark、HTTP、HTTPS、HTTP DNS等四條網絡通道:總體網絡以Shark長連通道爲主通道,其他三條通道做爲備份通道。配合完備的開關切換流程,能夠在網絡指標發生驟降時,實現分鐘級別的分城市網絡通道切換。而經過制定故障應急SOP並不斷演練,提高了咱們解決問題的能力和速度,有效應對各種網絡異常。咱們的網絡通道開關思路也輸出至集團其餘部門,有效支持了業務發展。

限流

服務過載是另外一類典型的事故。究其緣由大部分狀況下都是因爲少數調用方調用的少數接口性能不好,致使對應服務的性能惡化。若調用端缺少有效降級容錯,在某些正常狀況下可以下降錯誤率的手段,如請求失敗後重試,反而會讓服務進一步性能惡化,甚至影響原本正常的服務調用。

美團外賣業務在高峯期訂單量已達到了至關高的規模量級,業務系統也及其複雜。根據以往經驗,在業務高峯期,一旦出現異常流量瘋狂增加從而致使服務器宕機,則損失不可估量。

所以,美團外賣先後端聯合開發了一套「流量控制系統」,對流量實施實時控制。既能平常保證業務系統穩定運轉,也能在業務系統出現問題的時候提供一套優雅的降級方案,最大限度保證業務的可用性,在將損失降到最低的前提下,給予用戶良好的體驗。

整套系統,後端服務負責識別打標分類,經過統一的協議告訴前端所標識類別;而前端,經過多級流控檢查,對不一樣流量進行區分處理:彈驗證碼,或排隊等待,或直接處理,或直接丟棄。

面對不一樣場景,系統支持多級流控方案,可有效攔截系統過載流量,防止系統雪崩。此外,整套系統擁有分接口流控監控能力,可對流控效果進行監控,及時發現系統異常。整套方案在數次異常流量增加的故障中,經受住了考驗。

發佈

隨着外賣業務的發展,美團外賣的用戶量和訂單量已經達到了至關的量級,在線直接全量發佈版本/功能影響範圍大,風險高。

版本灰度和功能灰度是一種可以平滑過渡的發佈方式:即在線上進行A/B實驗,讓一部分用戶繼續使用產品(特性)A,另外一部分用戶開始使用產品(特性)B。若是各項指標平穩正常,結果符合預期,則擴大範圍,將全部用戶都遷移到B上來,不然回滾。灰度發佈能夠保證系統的穩定,在初試階段就能夠發現問題,修復問題,調整策略,保證影響範圍不被擴散。

美團外賣客戶端在版本灰度及功能灰度已較爲完善。

版本灰度 iOS採用蘋果官方提供的分階段發佈方式,Android則採用美團自研的EVA包管理後臺進行發佈。這兩類發佈均支持逐步放量的分發方式。

功能灰度 功能發佈開關配置系統依據用戶特徵維度(如城市,用戶ID)發佈,而且整個配置系統有測試和線上兩套不一樣環境,配合固定的上線窗口,保證上線的規範性。

對應的,相應的監控基礎設施也支持分用戶特徵維度(如城市,用戶ID)監控,避免了那些沒法在總體大盤體現的灰度異常。此外,不管版本灰度或功能灰度,咱們均有相應最小灰度週期和回滾機制,保證整個灰度發佈過程可控,最小化問題影響。

線上運維

在故障來臨時如何應對,是整個質量保障體系中最關鍵的環節。沒有人天生就能完美的處理緊急狀況,面對問題,恰當的處理須要平時不斷的演練。

圍繞問題的生命週期,即發現、定位、解決(預防),美團外賣客戶端團隊組建了一套完備的處理流程和規範來應對影響鏈路穩定性的各種線上問題。總體思路:創建規範,提早建設,有效應對,過後總結。在不一樣階段用不一樣方式解決不一樣問題,事前肯定完整的事故流程管理策略,並確保平穩實施,常常演練,問題的平均恢復時間大大下降,美團外賣核心鏈路的高穩定性纔可以得以保障。

將來展望

當前美團外賣業務仍然處於快速增加期。伴隨着業務的發展,背後支持業務的技術系統也日趨複雜。在美團外賣客戶端高可用體系建設過程當中,咱們但願可以經過一套智能化運維繫統,幫助工程師快速、準確的識別核心鏈路各子系統異常,發現問題根源,並自動執行對應的異常解決預案,進一步縮短服務恢復時間,從而避免或減小線上事故影響。

誠然,業界關於自動化運維的探索有不少,但多數都集中在後臺服務領域,前端方向成果較少。咱們外賣技術團隊目前也在同步的探索中,正處於基礎性建設階段,歡迎更多業界同行跟咱們一塊兒討論、切磋。

參考資料

  1. Site Reliability Engineering: How Google Runs Production Systems
  2. 美團移動端基礎日誌庫——Logan
  3. 美團移動網絡優化實踐

 

iOS 覆蓋率檢測原理與增量代碼測試覆蓋率工具實現

背景

對蘋果開發者而言,因爲平臺審覈週期較長,客戶端代碼致使的線上問題影響時間每每比較久。若是在開發、測試階段可以提早暴露問題,就有助於避免線上事故的發生。代碼覆蓋率檢測正是幫助開發、測試同窗提早發現問題,保證代碼質量的好幫手。

對於開發者而言,代碼覆蓋率能夠反饋兩方面信息:

  1. 自測的充分程度。
  2. 代碼設計的冗餘程度。

儘管代碼覆蓋率對代碼質量有着上述好處,但在 iOS 開發中卻使用的很少。咱們調研了市場上經常使用的 iOS 覆蓋率檢測工具,這些工具主要存在如下四個問題:

  1. 第三方工具備時生成的檢測報告文件會出錯甚至會失敗,開發者對覆蓋率生成原理不瞭解,遇到這類問題容易棄用工具。
  2. 第三方工具每次展現全量的覆蓋率報告,會分散開發者的不少精力在未修改部分。而在絕大多數狀況下,開發者的關注重點在本次新增和修改的部分。
  3. Xcode 自帶的覆蓋率檢測只適用於單元測試場景,因爲需求變動頻繁,業務團隊開發單元測試的成本很高。
  4. 已有工具很難和現有開發流程結合起來,須要額外進行測試,運行覆蓋率腳本才能獲取報告文件。

爲了解決上述問題,咱們深刻調研了覆蓋率報告的生成邏輯,並結合團隊的開發流程,開發了一套嵌入在代碼提交流程中、基於單次代碼提交(git commit)生成報告、對開發者透明的增量代碼測試覆蓋率工具。開發者只須要正常開發,經過模擬器測試開發代碼,commit 本次代碼(commit 和測試順序可交換),推送(git push)到遠端,就能夠在本地看到此次提交代碼的詳細覆蓋率報告了。

本文分爲兩部分,先從介紹通用覆蓋率檢測的原理出發,讓讀者對覆蓋率的收集、解析有直觀的認識。以後介紹咱們增量代碼測試覆蓋率工具的實現。

覆蓋率檢測原理

生成覆蓋率報告,首先須要在 Xcode 中配置編譯選項,編譯後會爲每一個可執行文件生成對應的 .gcno 文件;以後在代碼中調用覆蓋率分發函數,會生成對應的 .gcda 文件。

其中,.gcno 包含了代碼計數器和源碼的映射關係, .gcda 記錄了每段代碼具體的執行次數。覆蓋率解析工具須要結合這兩個文件給出最後的檢測報表。接下來先看看 .gcno 的生成邏輯。

.gcno

利用 Clang 分別生成源文件的 AST 和 IR 文件,對比發現,AST 中不存在計數指令,而 IR 中存在用來記錄執行次數的代碼。搜索 LLVM 源碼能夠找到覆蓋率映射關係生成源碼。覆蓋率映射關係生成源碼是 LLVM 的一個 Pass,(下文簡稱 GCOVPass)用來向 IR 中插入計數代碼並生成 .gcno 文件(關聯計數指令和源文件)。

下面分別介紹IR插樁邏輯和 .gcno 文件結構。

IR 插樁邏輯

代碼行是否執行到,須要在運行中統計,這就須要對代碼自己作一些修改,LLVM 經過修改 IR 插入了計數代碼,所以咱們不須要改動任何源文件,僅需在編譯階段增長編譯器選項,就能實現覆蓋率檢測了。

從編譯器角度看,基本塊(Basic Block,下文簡稱 BB)是代碼執行的基本單元,LLVM 基於 BB 進行覆蓋率計數指令的插入,BB 的特色是:

  1. 只有一個入口。
  2. 只有一個出口。
  3. 只要基本塊中第一條指令被執行,那麼基本塊內全部指令都會順序執行一次。

覆蓋率計數指令的插入會進行兩次循環,外層循環遍歷編譯單元中的函數,內層循環遍歷函數的基本塊。函數遍歷僅用來向 .gcno 中寫入函數位置信息,這裏再也不贅述。

一個函數中基本塊的插樁方法以下:

  1. 統計全部 BB 的後繼數 n,建立和後繼數大小相同的數組 ctr[n]。
  2. 之後繼數編號爲序號將執行次數依次記錄在 ctr[i] 位置,對於多後繼狀況根據條件判斷插入。

舉個例子,下面是一段猜數字的遊戲代碼,當玩家猜中了咱們預設的數字10的時候會輸出Bingo,不然輸出You guessed wrong!。這段代碼的控制流程圖如圖1所示(猜數字遊戲 )。

- (void)guessNumberGame:(NSInteger)guessNumber { NSLog(@"Welcome to the game"); if (guessNumber == 10) { NSLog(@"Bingo!"); } else { NSLog(@"You guess is wrong!"); } } 

這段代碼若是開啓了覆蓋率檢測,會生成一個長度爲 6 的 64 位數組,對照插樁位置,方括號中標記了樁點序號,圖 1 中代碼前數字爲所在行數。

圖 1 樁點位置

圖 1 樁點位置

 

.gcno計數符號和文件位置關聯

.gcno 是用來保存計數插樁位置和源文件之間關係的文件。GCOVPass 在經過兩層循環插入計數指令的同時,會將文件及 BB 的信息寫入 .gcno 文件。寫入步驟以下:

  1. 建立 .gcno 文件,寫入 Magic number(oncg+version)。
  2. 隨着函數遍歷寫入文件地址、函數名和函數在源文件中的起止行數(標記文件名,函數在源文件對應行數)。
  3. 隨着 BB 遍歷,寫入 BB 編號、BB 起止範圍、BB 的後繼節點編號(標記基本塊跳轉關係)。
  4. 寫入函數中BB對應行號信息(標註基本塊與源碼行數關係)。

從上面的寫入步驟能夠看出,.gcno 文件結構由四部分組成:

  • 文件結構
  • 函數結構
  • BB 結構
  • BB 行結構

經過這四部分結構能夠徹底還原插樁代碼和源碼的關聯,咱們以 BB 結構 / BB 行結構爲例,給出結構圖 2 (a) BB 結構,(b) BB 行信息結構,在本章末尾覆蓋率解析部分,咱們利用這個結構圖還原代碼執行次數(每行等高格表明 64bit):

圖2 BB 結構和 BB 行信息結構

圖2 BB 結構和 BB 行信息結構

 

.gcda

入口函數

關於 .gcda 的生成邏輯,可參考覆蓋率數據分發源碼。這個文件中包含了 __gcov_flush() 函數,這個函數正是分發邏輯的入口。接下來看看 __gcov_flush() 如何生成 .gcda 文件。

經過閱讀代碼和調試,咱們發如今二進制代碼加載時,調用了 llvm_gcov_init(writeout_fn wfn, flush_fn ffn) 函數,傳入了 _llvm_gcov_writeout(寫 gcov 文件),_llvm_gcov_flush(gcov 節點分發)兩個函數,而且根據調用順序,分別創建了以文件爲節點的鏈表結構。(flush_fn_node * ,writeout_fn_node *

__gcov_flush() 代碼以下所示,當咱們手動調用 __gcov_flush()進行覆蓋率分發時,會遍歷flush_fn_node *這個鏈表(即遍歷全部文件節點),並調用分發函數_llvm_gcov_flush(curr->fn 正是__llvm_gcov_flush函數類型)。

void __gcov_flush() { struct flush_fn_node *curr = flush_fn_head; while (curr) { curr->fn(); curr = curr->next; } } 

具體的分發邏輯

觀察__llvm_gcov_flush的 IR 代碼,能夠看到:

圖3 __llvm_gcov_flush 代碼示例

圖3 __llvm_gcov_flush 代碼示例

 

  1. __llvm_gcov_flush先調用了__llvm_gcov_writeout,來向 .gcda 寫入覆蓋率信息。
  2. 最後將計數數組清零__llvm_gcov_ctr.xx

而 __llvm_gcov_writeout 邏輯爲:

  1. 生成對應源文件的 .gcda 文件,寫入 Magic number。
  2. 循環執行
    • llvm_gcda_emit_function: 向 .gcda 文件寫入函數信息。
    • llvm_gcda_emit_arcs: 向 .gcda 文件寫入BB執行信息,若是已經存在 .gcda 文件,會和以前的執行次數進行合併。
  3. 調用llvm_gcda_summary_info,寫入校驗信息。
  4. 調用llvm_gcda_end_file,寫結束符。

感興趣的同窗能夠本身生成 IR 文件查看更多細節,這裏再也不贅述。

.gcda 的文件/函數結構和 .gcno 基本一致,這裏再也不贅述,統計插樁信息結構如圖 4 所示。定製化的輸出也能夠經過修改上述函數完成。咱們的增量代碼測試覆蓋率工具解決代碼 BB 結構變更後合併到已有 .gcda 文件不兼容的問題,也是修改上述函數實現的。

圖4 計數樁輸出結構

圖4 計數樁輸出結構

 

覆蓋率解析

在瞭解瞭如上所述 .gcno ,.gcda 生成邏輯與文件結構以後,咱們以例 1 中的代碼爲例,來闡述解析算法的實現。

例 1 中基本塊 B0,B1 對應的 .gcno 文件結構以下圖所示,從圖中能夠看出,BB 的主結構徹底記錄了基本塊之間的跳轉關係。

圖5 B0,B1 對應跳轉信息

圖5 B0,B1 對應跳轉信息

 

B0,B1 的行信息在 .gcno 中表示以下圖所示,B0 塊由於是入口塊,只有一行,對應行號能夠從 B1 結構中獲取,而 B1 有兩行代碼,會依次把行號寫入 .gcno 文件。

圖6 B0,B1 對應行信息

圖6 B0,B1 對應行信息

 

在輸入數字 100 的狀況下,生成的 .gcda 文件以下:

圖7 輸入 100 獲得的 .gcda 文件

圖7 輸入 100 獲得的 .gcda 文件

 

經過控制流程圖中節點出邊的執行次數能夠計算出 BB 的執行次數,核心算法爲計算這個 BB 的全部出邊的執行次數,不存在出邊的狀況下計算全部入邊的執行次數(具體實現能夠參考 gcov 工具源碼),對於 B0 來講,即看 index=0 的執行次數。而 B1 的執行次數即 index=1,2 的執行次數的和,對照上圖中 .gcda 文件能夠推斷出,B0 的執行次數爲 ctr[0]=1,B1 的執行次數是 ctr[1]+ctr[2]=1, B2 的執行次數是 ctr[3]=0,B4 的執行次數爲 ctr[4]=1,B5 的執行次數爲 ctr[5]=1。

通過上述解析,最終生成的 HTML 以下圖所示(利用 lcov):

圖8 覆蓋率檢測報告

圖8 覆蓋率檢測報告

 

以上是 Clang 生成覆蓋率信息和解析的過程,下面介紹美團到店餐飲 iOS 團隊基於以上原理作的增量代碼測試覆蓋率工具。

增量代碼覆蓋率檢測原理

方案權衡

因爲 gcov 工具(和前面的 .gcov 文件區分,gcov 是覆蓋率報告生成工具)生成的覆蓋率檢測報告可讀性不佳,如圖 9 所示。咱們作的增量代碼測試覆蓋率工具是基於 lcov 的擴展,報告展現如上節末尾圖 8 所示。

圖9 gcov 輸出,行前數字表明執行次數,##### 表明沒執行

圖9 gcov 輸出,行前數字表明執行次數,##### 表明沒執行

 

比 gcov 直接生成報告多了一步,lcov 的處理流程是將 .gcno 和 .gcda 文件解析成一個以 .info 結尾的中間文件(這個文件已經包含所有覆蓋率信息了),以後經過覆蓋率報告生成工具生成可讀性比較好的 HTML 報告。

結合前兩章內容和覆蓋率報告生成步驟,覆蓋率生成流程以下圖所示。考慮到增量代碼覆蓋率檢測中代碼增量部分須要經過 Git 獲取,比較天然的想法是用 git diff 的信息去過濾覆蓋率的內容。根據過濾點的不一樣,存在如下兩套方案:

  1. 經過 GCOVPass 過濾,只對修改的代碼進行插樁,每次修改後需從新插樁。
  2. 經過 .info 過濾,一次性爲全部代碼插樁,獲取所有覆蓋率信息,過濾覆蓋率信息。

圖10 覆蓋率生成流程

圖10 覆蓋率生成流程

 

分析這兩個方案,第一個方案須要自定義 LLVM 的 Pass,進而會引入如下兩個問題:

  • 只能使用開源 Clang 進行編譯,不利於接入正常的開發流程。
  • 每次從新插樁會丟失以前的覆蓋率信息,屢次運行只能獲得最後一次的結果。

而第二個方案相對更加輕量,只須要過濾中間格式文件,不只能夠解決咱們在文章開頭提到的問題,也能夠避免上述問題:

  • 能夠很方便地加入到日常代碼的開發流程中,甚至對開發者透明。
  • 未修改文件的覆蓋率能夠疊加(有修改的那些控制流程圖結構可能變化,沒法疊加)。

所以咱們實際開發選定的過濾點是在 .info 。在選定了方案 2 以後,咱們對中間文件 .info 進行了一系列調研,肯定了文件基本格式(函數/代碼行覆蓋率對應的文件的表示),這裏再也不贅述,具體能夠參考 .info 生成文檔

增量代碼測試覆蓋率工具的實現

前一節是實現增量代碼覆蓋率檢測的基本方案選擇,爲了更好地接入現有開發流程,咱們作了如下幾方面的優化。

下降使用成本

在接入方面,接入增量代碼測試覆蓋率工具只需一次接入配置,同步到代碼倉庫後,團隊中成員無需配置便可使用,下降了接入成本。

在使用方面,考慮到插樁在編譯時進行,對所有代碼進行插樁會很大程度下降編譯速度,咱們經過解析 Podfile(iOS 開發中較爲經常使用的包管理工具 CocoaPods 的依賴描述文件),只對 Podfile 中使用本地代碼的倉庫進行插樁(可配置指定倉庫),下降了團隊的開發成本。

對開發者透明

接入增量代碼測試覆蓋率工具後,開發者無需特殊操做,也不須要對工程作任何其餘修改,正常的 git commit 代碼,git push 到遠端就會自動生成並上傳此次 commit 的覆蓋率信息了。

爲了作到這一點,咱們在接入 Pod 的過程當中,自動部署了 Git 的 pre-push 腳本。熟悉 Git 的同窗知道,Git 的 hooks 是開發者的本地腳本,不會被歸入版本控制,如何經過一次配置就讓這個倉庫的全部使用成員都能開啓,是作好這件事的一個難點。

咱們考慮到 Pod 自己會被歸入版本控制,所以利用了 CocoaPods 的一個屬性 script_phase,增長了 Pod 編譯後腳本,來幫助咱們把 pre-push 插入到本地倉庫。利用 script_phase 插入還帶來了另一個好處,咱們能夠直接獲取到工程的緩存文件,也避免了 .gcno / .gcda 文件獲取的不肯定性。整個流程以下:

圖11 pre-push 分發流程

圖11 pre-push 分發流程

 

覆蓋率累計

在實現了覆蓋率的過濾後,咱們在實際開發中遇到了另一個問題:修改分支/循環結構後生成的 .gcda 文件沒法和以前的合併。 在這種狀況下,__gcov_flush會直接返回,再也不寫入 .gcda 文件了致使覆蓋率檢測失敗,這也是市面上已有工具的通用問題。

而這個問題在開發過程當中很常見,好比咱們給例 1 中的遊戲增長一些提示,當輸入比預設數字大時,咱們就提示出來,反之亦然。

- (void)guessNumberGame:(NSInteger)guessNumber { NSInteger targetNumber = 10; NSLog(@"Welcome to the game"); if (guessNumber == targetNumber) { NSLog(@"Bingo!"); } else if (guessNumber > targetNumber) { NSLog(@"Input number is larger than the given target!"); } else { NSLog(@"Input number is smaller than the given target!"); } } 

這個問題困擾了咱們好久,也推進了對覆蓋率檢測原理的調研。結合前面覆蓋率檢測的原理能夠知道,不能合併的緣由是生成的控制流程圖比原來多了兩條邊( .gcno 和舊的 .gcda 也不能匹配了),反映在 .gcda 上就是數組多了兩個數據。考慮到代碼變更後,原有的覆蓋率信息已經沒有意義了,當發生邊數不一致的時候,咱們會刪除掉舊的 .gcda 文件,只保留最新 .gcda 文件(有變更狀況下 .gcno 會從新生成)。以下圖所示:

圖12 覆蓋率衝突解決算法

圖12 覆蓋率衝突解決算法

 

總體流程圖

結合上述流程,咱們的增量代碼測試覆蓋率工具的總體流程如圖 13 所示。

開發者只需進行接入配置,再次運行時,工程中那些做爲本地倉庫進行開發的代碼庫會被自動插樁,並在 .git 目錄插入 hooks 信息;當開發者使用模擬器進行需求自測時,插樁統計結果會被自動分發出去;在代碼被推到遠端前,會根據插樁統計結果,生成僅包含本次代碼修改的詳細增量代碼測試覆蓋率報告,以及向遠端推送覆蓋率信息;同時若是測試覆蓋率小於 80% 會強制拒絕提交(可配置關閉,百分比可自定義),保證只有通過充分自測的代碼才能提交到遠端。

圖13 增量代碼測試覆蓋率生成流程圖

圖13 增量代碼測試覆蓋率生成流程圖

 

總結

以上是咱們在代碼開發質量方面作的一些積累和探索。經過對覆蓋率生成、解析邏輯的探究,咱們揭開了覆蓋率檢測的神祕面紗。開發階段的增量代碼覆蓋率檢測,能夠幫助開發者聚焦變更代碼的邏輯缺陷,從而更好地避免線上問題。

 

 

iOS系統中導航欄的轉場解決方案與最佳實踐

背景

目前,開源社區和業界內已經存在一些 iOS 導航欄轉場的解決方案,但對於歷史包袱沉重的美團 App 而言,這些解決方案並不完美。有的方案不能知足複雜的頁面跳轉場景,有的方案遷移成本較大,爲此咱們提出了一套解決方案並開發了相應的轉場庫,目前該轉場庫已經成爲美團點評多個 App 的基礎組件之一。

在美團 App 開發的早期,涉及到導航欄樣式改變的需求時,常常會遇到轉場效果不佳或者與預期樣式不符的「小問題」。在業務體量較小的狀況下,爲了知足快速的業務迭代,一般會使用硬編碼的方式來解決這一類「小問題」。但隨着美團 App 業務的高速發展,這種硬編碼的方式遇到了如下的挑戰:

  1. 業務模塊的不斷增長,致使使用硬編碼方式編寫的代碼維護成本增長,代碼質量迅速降低。
  2. 大型 App 的路由系統使得頁面間的跳轉變得更加自由和靈活,也使得導航欄相關的問題激增,不但增長了問題的排查難度,還下降了總體的開發效率。
  3. App 中的導航欄屬於各個業務方的公用資源,因爲缺少相應的約束機制和最佳實踐,致使業務方之間的代碼耦合程度不斷增長。

從各個角度來看,硬編碼的方式已經不能很好的解決此類問題,美團 App 須要一個更加合理、更加持久、更加簡單易行的解決方案來處理導航欄轉場問題。

本文將從導航欄的概念入手,經過講解轉場過程當中的狀態管理、轉換時機和樣式變化等內容,引出了在大型應用中導航欄轉場的三種常看法決方案,並對美團點評的解決方案進行剖析。

從新認識導航欄

導航欄裏的 MVC

在 iOS 系統中, 蘋果公司不只建議開發者遵循 MVC 開發框架,在它們的代碼裏也能夠看到 MVC 的影子,導航欄組件的構成就是一個相似 MVC 的結構,讓咱們先看看下面這張圖:

在這張圖裏,咱們能夠將 UINavigationController 看作是 C,UINavigationBar 看作是 V,而 UIViewController 和 UINavigationItem 組成的 Stack 能夠看作是 M。這裏要說明的是,每一個 UIViewController 都有一個屬於本身的 UINavigationItem,也就是說它們是一一對應的。

UINavigationController 經過驅動 Stack 中的 UIViewController 的變化來實現 View 層級的變化,也就是 UINavigationBar 的改變。而 UINavigationBar 樣式的數據就存儲在 UIViewController 的 UINavigationItem 中。這也就是爲何咱們在代碼裏只要設置 self.navigationItem 的相關屬性就能夠改變 UINavigationBar 的樣式。

不少時候,國內的開發者會將 UINavigationBar 和 UINavigationController 混在一塊兒叫導航欄,這樣的作法不只增長了開發者之間的溝通成本,也容易致使誤解。畢竟它們是兩個徹底不同的東西。

因此本文爲了更好的闡明問題,會採用英文區分不一樣的概念,當須要描述籠統的導航欄概念時,會使用導航欄組件一詞。

經過這一節的回顧,咱們應該明確了 NavigationItem、ViewController、NavigationBar 和 NavigationController 在 MVC 框架下的角色。下面咱們會從新梳理一下導航欄的生命週期和各個相關方法的調用順序。

導航欄組件的生命週期

你們能夠經過下圖得到更爲直觀的感覺,進而瞭解到導航欄組件在 push 過程當中各個方法的調用順序。

值得注意的地方有兩點:

第一個是 UINavigationController 做爲 UINavigationBar 的代理,在沒有特殊需求的狀況下,不該該修改其代理方法,這裏是經過符號斷點獲取它們的調用順序。若是咱們建立了一個自定義的導航欄組件系統,它的調用順序可能會與此不一樣。

第二個是用虛線圈起來的方法,它們也有可能不被調用,這與 ViewController 裏的佈局代碼相關,假設跳轉到新頁面後,新舊頁面中的控件位置會發生變化,或者因爲數據改變驅動了控件之間的約束關係發生變化,這就會帶來新一輪的佈局,進而觸發 viewWillLayoutSubview 和 viewDidLayoutSubview 這兩個方法。固然,具體的調用順序會與業務代碼緊密相關,若是咱們發現順序有所不一樣,也沒必要驚慌。

下面這張圖展現了導航欄在 pop 過程當中各個方法的調用順序:

除了上面說到的兩點,pop 過程當中還須要注意一點,那就是從 B 返回到 A 的過程當中,A 視圖控制器的 viewDidLoad 方法並不會被調用。關於這個問題,只要提醒一下,大多數人都會反應過來是爲何。不過在實際開發過程當中,總會有人忘記這一點。

經過這兩個圖,咱們已經基本瞭解了導航欄組件的生命週期和相關方法的調用順序,這也是後面章節的理論基礎。

導航欄組件的改變與革新

導航欄組件在 iOS 11 發佈時,得到了重大更新,這個更新可不是增長了一個大標題樣式(Large Title Display Mode)那麼簡單,須要注意的地方大概有兩點:

  1. 導航欄全面支持 Auto Layout 且 NavigationBar 的層級發生了明顯的改變,關於這一點能夠閱讀 UIBarButtonItem 在 iOS 11 上的改變及應對方案 。
  2. 因爲引進了 Safe Area 等概念,topLayoutGuide 和 bottomLayoutGuide 等屬性會逐漸廢棄,雖然變化不大,但若是咱們的導航欄在轉場過程當中老是出現視圖上下移動的現象,不妨從這個方面思考一下,若是想深究能夠查看 WWDC 2017 Session 412

導航欄組件到底怎麼了?

常常有人說 iOS 的原生導航欄組件很差使用,抱怨主要集中在導航欄組件的狀態管理和控件的佈局問題上。

控件的佈局問題隨着 iOS 11 的到來已經變得相對容易處理了很多,但導航欄組件的狀態管理仍然讓開發者頭疼不已。

可能已經有朋友在思考導航欄組件的狀態管理究竟是什麼東西?不要着急,下面的章節就會作相關的介紹。

導航欄的狀態管理

雖然導航欄組件的 push 和 pop 動畫給人一種每次操做後都會建立一遍導航欄組件的錯覺,但實際上這些 ViewController 都是由一個 NavigationController 所管理,因此你看到的 NavigationBar 是惟一的。

在 NavigationController 的 Stack 存儲結構下,每當 Stack 中的 ViewController 修改了導航欄,勢必會影響其餘 ViewController 展現的效果。

例以下圖所示的場景,若是 NavigationBar 原先的顏色是綠色,但以後進入 Stack 裏的 ViewController 將 NavigationBar 顏色修改成紫色後,在此以後 push 的 ViewController 會從默認的綠色變爲紫色,直到有新的 ViewController 修改導航欄顏色纔會發生變化。

雖然在 push 過程當中,NavigationBar 的變化聽起來合情合理,但若是你在 NavigationBar 爲綠色的 ViewController 裏設置不當的話,那麼當你 pop 回這個 ViewController 時,NavigationBar 可就不必定是綠色了,它還會保持爲紫色的狀態。

經過這個例子,咱們大概會意識到在導航欄裏的 Stack 中,每一個 ViewController 均可以永久的影響導航欄樣式,這種全局性的變化要求咱們在實際開發中必須堅持「誰修改,誰復原」的原則,不然就會形成導航欄狀態的混亂。這不只僅是樣式上的混亂,在一些極端情況下,還有可能會引發 Stack 混亂,進而形成 Crash 的狀況。

導航欄樣式轉換的時機

咱們剛纔提到了「誰修改,誰復原」的原則,但什麼時候修改,什麼時候復原呢?

對於那些存儲在 Stack 中的 ViewController 而言,它其實就是在不斷的經歷 appear 和 disappear 的過程,結合 ViewController 的生命週期來看,viewWillAppear: 和 viewWillDisappear: 是兩個完美的時間節點,但不少人卻對這兩個方法的調用存在疑惑。

蘋果公司在它的 API 文檔中專門用了一段文字來解答你們的疑惑,這段文字的標題爲《Handling View-Related Notifications》,在這裏咱們直接引用原文:

When the visibility of its views changes, a view controller automatically calls its own methods so that subclasses can respond to the change. Use a method like viewWillAppear: to prepare your views to appear onscreen, and use the viewWillDisappear: to save changes or other state information. Use other methods to make appropriate changes.

Figure 1 shows the possible visible states for a view controller’s views and the state transitions that can occur. Not all ‘will’ callback methods are paired with only a ‘did’ callback method. You need to ensure that if you start a process in a ‘will’ callback method, you end the process in both the corresponding ‘did’ and the opposite ‘will’ callback method.

這裏很好的解釋了全部的 will 系列方法和 did 系列方法的對應關係,同時也給咱們吃了一個定心丸,那就是在 appearing 和 disappearing 狀態之間會由 will 系列方法進行銜接,避免了狀態中斷。這對於連續 push 或者連續 pop 的狀況是及其重要的,不然咱們沒法作到 「誰修改,誰復原」的原則。

一般來講,若是隻是一個簡單的導航欄樣式變化,咱們的代碼結構大致會以下所示:

- (void)viewWillAppear:(BOOL)animated{ [super viewWillAppear:animated]; // MARK: change the navigationbar style } - (void)viewWillDisappear:(BOOL)animated{ [super viewWillDisappear:animated]; // MARK: restore the navigationbar style } 

如今,咱們明確了修改時機,接下來要明確的就是導航欄的樣式會進行怎樣的變化。

導航欄的樣式變化

對於不一樣 ViewController 之間的導航欄樣式變化,大多能夠總結爲兩種狀況:

  1. 導航欄的顯示與否。
  2. 導航欄的顏色變化。

導航欄的顯示與否

對於顯示與否的問題,能夠在上一節提到的兩個方法裏調用 setNavigationBarHidden:animated: 方法,這裏須要提醒的有兩點:

  1. 在導航欄轉場的過程當中,不要天真的覺得 setNavigationBarHidden: 和 setNavigationBarHidden:animated: 的效果是同樣的,直接使用 setNavigationBarHidden: 會形成導航欄轉場過程當中的閃現、背景錯亂等問題,這一現象在使用手勢驅動轉場的場景中十分常見,因此正確的方式是使用帶有 animated 參數的 API。
  2. 在 push 和 pop 的方法裏也會帶有 animated 參數,儘可能保證與 setNavigationBarHidden:animated: 中的 animated 參數一致。

導航欄的顏色變化

顏色變化的問題就稍微複雜一些,在 iOS 7 後,導航欄增長了 translucent 效果,這使得導航欄背景色的變化出現了兩種狀況:

  1. translucent 屬性值爲 YES 的前提下,更改導航欄的背景色。
  2. translucent 屬性值爲 NO 的前提下,更改導航欄的背景色。

對於第一種狀況,咱們須要調用 UINavigationBar 的 setBackgroundColor: 方法。

對於第二種狀況咱們須要調用 UINavigationBar 的 setBackgroundImage:forBarMetrics: 方法。

對於第二種狀況,這裏有三點須要提示:

  1. 在設置透明效果時,咱們一般能夠直接設置一個 [UIImage new] 建立的對象,無須建立一個顏色爲透明色的圖片。
  2. 在使用 setBackgroundImage:forBarMetrics: 方法的過程當中,若是圖像裏存在 alpha 值小於 1.0 的像素點,則 translucent 的值爲 YES,反之爲 NO。也就是說,若是咱們真的想讓導航欄變成純色且沒有 translucent 效果,請保證全部像素點的 alpha 值等於 1。
  3. 若是設置了一個徹底不透明的圖片且強行將 NavigationBar 的 translucent 屬性設置爲 YES 的話,系統會自動修正這個圖片併爲它添加一個透明度,用於模擬 translucent 效果。
  4. 若是咱們使用了一個帶有透明效果的圖片且導航欄的 translucent 效果爲 NO 的話,那麼系統會在這個帶有透明效果的圖片背後,添加一個不透明的純色圖片用於總體效果的合成。這個純色圖片的顏色取決於 barStyle 屬性,當屬性爲 UIBarStyleBlack 時爲黑色,當屬性爲 UIBarStyleDefault 時爲白色,若是咱們設置了 barTintColor,則以設置的顏色爲基準。

分清楚 transparenttranslucentopaquealpha 和 opacity 也挺重要

在剛接觸導航欄 API 時,許多人常常會把文檔裏的這些英文詞搞混,也不太明白帶有這些詞的變量爲何有的是布爾型,有的是浮點型,總之一切都讓人很困惑。

在這裏將作了一個總結,這對於理解 Apple 的 API 設計原則十分有幫助。

transparent, translucent, opaque 三個詞常常會用在一塊兒,它用於描述物體的透光強度,爲了讓你們更好的理解這三個詞,這裏作了三個比喻:

  • transparent 是指透明,就比如咱們能夠透過一面乾淨的玻璃清楚的看到外面的風景。
  • translucent 是指半透明,就比如咱們能夠透過一面有點磨砂效果的塑料牆看外面的風景,不能說看不見,但咱們確定看不清。
  • opaque 是指不透明,就比如咱們透過一個堵石牆是看不見任何外面的東西,眼前看到的只有這面牆。

這三個詞更多的是用來表述一種狀態,不須要量化,因此這與這三個詞相關的屬性,通常都是 BOOL 類型。

alpha 和 opacity 常常會在一塊兒使用,它要表示的就是透明度,在 Web 端這兩個屬性有着明顯的區別。

在 Web 端裏,opacity 是設定整個元素的透明值,而 alpha 通常是放在顏色設置裏面,因此咱們能夠作到對特定對元素的某個屬性設定 alpha,好比背景、邊框、文字等。

div { width: 100px; height: 100px; background: rgba(0,0,0,0.5); border: 1px solid #000000; opacity: 0.5; } 

這一律念一樣適用於 iOS 裏的概念,好比咱們能夠經過 alpha 通道單獨的去設置 backgroudColorborderColor,它們互不影響,且有着獨立的 alpha 通道,咱們也能夠經過 opacity 統一設置整個 view 的透明度。

但與 Web 端不一致的是,iOS 裏面的 view 不光擁有獨立的 alpha 屬性,同時也是基於 CALayer,因此咱們能夠看到任意 UIView 對象下面都會有一個 layer 的屬性,用於代表 CALayer 對象。view 的 alpha 屬性與 layer 裏面的 opacity 屬性是一個相等的關係,須要注意的是 view 上的 alpha 屬性是 Web 端並不具有的一個能力,因此筆者認爲:在 iOS 中去說 alpha 時,要區分是在說 view 上的屬性,仍是在說顏色通道里的 alpha

因爲這兩個詞都是在描述程度,因此咱們看到它們都是 CGFloat 類型:

轉場過程當中須要注意的問題和細節

說完了導航欄的轉場時機和轉場方式,其實大致上你已經能處理好不一樣樣式間的轉換,但還有一些細節須要你去考慮,下面咱們來講說其中須要你關注的兩點。

translucent 屬性帶來的佈局改變

translucent 會影響導航欄組件裏 ViewController 的 View 佈局,這裏須要你們理清 5 個 API 的使用場景:

  1. edgesForExtendedLayout
  2. extendedLayoutIncluedsOpaqueBars
  3. automaticallyAdjustScrollViewInsets
  4. contentInsetAdjustmentBehavior
  5. additionalSafeAreaInsets

前三個 API 是 iOS 11 以前的 API,它們之間的區別和聯繫在 Stack Overflow 上有一個比較精彩的回答 - Explaining difference between automaticallyAdjustsScrollViewInsets, extendedLayoutIncludesOpaqueBars, edgesForExtendedLayout in iOS7,我在這裏就不作詳細闡述,總結一下它的觀點就是:

若是咱們先定義一個 UINavigationController,它裏面包含了多個 UIViewController,每一個 UIViewController 裏面包含一個 UIView 對象:

  • 那麼 edgesForExtendedLayout 是爲了解決 UIViewController 與 UINavigationController 的對齊問題,它會影響 UIViewController 的實際大小,例如 edgesForExtendedLayout 的值爲 UIRectEdgeAll 時,UIViewController 會佔據整個屏幕的大小。
  • 當 UIView 是一個 UIScrollView 類或者子類時,automaticallyAdjustsScrollViewInsets 是爲了調整這個 UIScrollView 與 UINavigationController 的對齊問題,這個屬性並不會調整 UIViewController 的大小。
  • 對於 UIView 是一個 UIScrollView 類或者子類且導航欄的背景色是不透明的狀態時,咱們會發現使用 edgesForExtendedLayout 來調整 UIViewController 的大小是無效的,這時候你必須使用 extendedLayoutIncludesOpaqueBars 來調整 UIViewController 的大小,能夠認爲 extendedLayoutIncludesOpaqueBars 是基於 automaticallyAdjustsScrollViewInsets 誕生的,這也是爲何常常會看到這兩個 API 會同時使用。

這些調整佈局的 API 背後是一套基於 topLayoutGuide 和 bottomLayoutGuide 的計算而已,在 iOS 11 後,Apple 提出了 Safe Area 的概念,將原先分裂開來的 topLayoutGuide 和 bottomLayoutGuide 整合到一個統一的 LayoutGuide 中,也就是所謂的 Safe Area,這個改變看起來彷佛不是很大,但它的出現確實方便了開發者。

若是想對 Safe Area 帶來的改變有更全面的認識,十分推薦閱讀 Rosberry 的工程師 Evgeny Mikhaylov 在 Medium 上的文章 iOS Safe Area,這篇文章基本涵蓋了 iOS 11 中全部與 Safe Area 相關的 API 並給出了真正合理的解釋。

這裏只說一下 contentInsetAdjustmentBehavior 和 additionalSafeAreaInsets 兩個 API。

對於 contentInsetAdjustmentBehavior 屬性而言,它的誕生也意味着 automaticallyAdjustsScrollViewInsets 屬性的失效,因此咱們在那些已經適配了 iOS 11 的工程裏能看到以下相似的代碼:

if (@available(iOS 11.0, *)) { self.tableView.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentNever; } else { self.automaticallyAdjustsScrollViewInsets = NO; } 

此處的代碼片斷只是一個示例,並不適用全部的業務場景,這裏須要着重說明幾個問題:

  1. 關於 contentInsetAdjustmentBehavior 中的 UIScrollViewContentInsetAdjustmentAutomatic 的說明一直很「模糊」,經過 Evgeny Mikhaylov 的文章,咱們能夠了解到他在大多數狀況下會與 UIScrollViewContentInsetAdjustmentScrollableAxes 一致,當且僅當知足如下全部條件時纔會與 UIScrollViewContentInsetAdjustmentAlways 類似:
    • UIScrollView 類型的視圖在水平軸方向是可滾動的,垂直軸是不可滾動的。
    • ViewController 視圖裏的第一個子控件是 UIScrollView 類型的視圖。
    • ViewController 是 navigation 或者 tab 類型控制器的子視圖控制器。
    • 啓用 automaticallyAdjustsScrollViewInsets
  2. iOS 11 後,經過 contentInset 屬性獲取的偏移量與 iOS 10 以前的表現形式並不一致,須要獲取 adjustedContentInset 屬性才能保證與以前的 contentInset 屬性一致,這樣的改變須要咱們在代碼裏對不一樣的版本進行適配。

對於 additionalSafeAreaInsets 而言,若是系統提供的這幾種行爲並不能知足咱們的佈局要求,開發者還能夠考慮使用 additionalSafeAreaInsets 屬性作調整,這樣的設定使得開發者能夠更加靈活,更加自由的調整視圖的佈局。

backIndicator 上的動畫

蘋果提供了許多修改導航欄組件樣式的 API,有關於佈局的,有關於樣式的,也有關於動畫的。backIndicatorImage 和 backIndicatorTransitionMaskImage 就是其中的兩個 API。

backIndicatorImage 和 backIndicatorTransitionMaskImage 操做的是 NavigationBar 裏返回按鈕的圖片,也就是下圖紅色圓圈所標註的區域。

想要成功的自定義返回按鈕的圖標樣式,咱們須要同時設置這兩個 API ,從字面上來看,它們一個是返回圖片自己,另外一個是返回圖片在轉場時用到的 mask 圖片,看起來不怎麼難,咱們寫一段代碼試試效果:

self.navigationController.navigationBar.backIndicatorImage = [UIImage imageNamed:@"backArrow"]; self.navigationController.navigationBar.backIndicatorTransitionMaskImage = [UIImage imageNamed:@"backArrowMask"]; 

代碼裏的圖片以下所示:

也許大多數人在這裏會都認爲,mask 圖片會遮擋住文字使其在遇到返回按鈕右邊緣的時候就消失。但實際的運行效果是怎麼樣子的呢?咱們來看一下:

在上面的圖片中,咱們能夠看到返回按鈕的文字從返回按鈕的圖片下面穿過而且文字被圖片所遮擋,這種動畫看起來十分奇怪,這是沒法接受的。咱們須要作點修改:

self.navigationController.navigationBar.backIndicatorImage = [UIImage imageNamed:@"backArrow"]; self.navigationController.navigationBar.backIndicatorTransitionMaskImage = [UIImage imageNamed:@"backArrow"]; 

這一次咱們將 backIndicatorTransitionMaskImage 改成 indicatorImage 所用的圖片。

到這裏,可能大多數人都會好奇,這代碼也能行?讓咱們看下它實際的效果:

在上面的圖中,咱們看到文字在到達圖片的右邊緣時就從下方穿過並被徹底遮蓋住了,這種動畫效果雖然比上面好一些,但仍然有改進的空間,不過這裏咱們先不繼續優化了,咱們先來討論一下它們背後的運做原理。

iOS 系統會將 indicatorImage 中不透明的顏色繪製成返回按鈕的圖標, indicatorTransitionMaskImage 與 indicatorImage 的做用不一樣。indicatorTransitionMaskImage 將自身不透明的區域像 mask 同樣做用在 indicatorImage 上,這樣就保證了返回按鈕中的文字像左移動時,文字只出如今被 mask 的區域,也就是 indicatorTransitionMaskImage 中不透明的區域。

掌握了原理,咱們來解釋下剛纔的兩種現象:

在第一種實現中,咱們提供的 indicatorTransitionMaskImage 覆蓋了整個返回按鈕的圖標,因此咱們在轉場過程當中能夠清晰的看到返回按鈕的文字。

在第二種實現中,咱們使用 indicatorImage 做爲 indicatorTransitionMaskImage,記住文字是隻能出如今 indicatorTransitionMaskImage 裏不透明的區域,因此顯然返回按鈕中的文字會在圖標的最右邊就已經被遮擋住了,由於那片區域是透明的。

那麼前面提到的進一步優化指的是什麼呢?

讓咱們來看一下下面這個示例圖,爲了更好的區分,咱們將 indicatorTransitionMaskImage 用紅色進行標註。黑色仍然是 indicatorImage。

按照剛纔介紹的原理,咱們應該能夠理解,如今文字只會出如今紅色區域,那麼它的實際效果是什麼樣子的呢,咱們能夠看下圖:

如今,一個完美的返回動畫,誕生啦!

此節所用的部分效果圖出自 Ray Wenderlich 的文章 UIAppearance Tutorial: Getting Started

導航欄的跳轉或許能夠這麼玩兒…

前兩章的鋪墊就是爲了這一章的內容,因此如今讓咱們開始今天的大餐吧。

這樣真的好麼?

剛纔咱們說了兩個頁面間 NavigationBar 的樣式變化須要在各自的 viewWillAppear: 和 viewWillDisappear: 中進行設置。那麼問題就來了:這樣的設置會帶來什麼問題呢?

試想一下,當咱們的頁面會跳到不一樣的地方時,咱們是否是要在 viewWillAppear: 和 viewWillDisappear: 方法裏面寫上一堆的判斷呢?若是應用裏還有 router 系統的話,那麼頁面間的跳轉將變得更加不可預知,這時候又該如何在 viewWillAppear: 和 viewWillDisappear: 裏作判斷呢?

如今咱們的問題就來了,如何讓導航欄的轉場更加靈活且相互獨立呢?

常見的解決方案以下所示:

  1. 從新實現一個相似 UINavigationController 的容器類視圖管理器,這個容器類視圖管理器作好不一樣 ViewController 間的導航欄樣式轉換工做,而每一個 ViewController 只須要關心自身的樣式便可。

  2. 將系統原有導航欄的背景設置爲透明色,同時在每一個 ViewController 上添加一個 View 或者 NavigationBar 來充當咱們實際看到的導航欄,每一個 ViewController 一樣只須要關心自身的樣式便可。

  3. 在轉場的過程當中隱藏原有的導航欄並添加假的 NavigationBar,當轉場結束後刪除假的 NavigationBar 並恢復原有的導航欄,這一過程能夠經過 Swizzle 的方式完成,而每一個 ViewController 只須要關心自身的樣式便可。

這三種方案各有優劣,咱們在網上也能夠看到不少關於它們的討論。

例如方案一,雖然看起來工做量大且難度高,可是這個工做一旦完成,咱們就會將處理導航欄轉場的主動權緊緊抓在手裏。但這個方案的一個弊端就是,若是蘋果修改了導航欄的總體風格,就比如 iOS 11 的大標題特效,那麼工做量就來了。

對於方案二而言,雖然看起來簡單易用,但這須要一個良好的繼承關係,若是整個工程裏的繼承關係混亂或者是歷史包袱比較重,後續的維護就像「打補丁」同樣,另外這個方案也須要良好的團隊代碼規範和完善的技術文檔來作輔助。

對於方案三而言,它不須要所謂的繼承關係,使用起來也相對簡單,這對於那些繼承關係和歷史包袱比較重的工程而言,這一個不錯的解決方案,但在解決 Bug 的時候,Swizzle 這種方式無疑會增長解決問題的時間成本和學習成本。

咱們的解決方案

在美團 App 的早期,各個業務方都想充分利用導航欄的能力,但對於導航欄的狀態維護缺少理解與關注,隨着業務方的增長和代碼量的上升,與導航欄相關的問題逐漸暴露出來,此時咱們才意識到這個問題的嚴重性。

大型 App 的導航欄問題就像一個典型的「公地悲劇」問題。在軟件行業,公用代碼的全部權能夠被視做「公地」,由於不注重長期需求而容易遭到消耗。若是開發人員傾向於交付「價值」,而以可維護性和可理解性爲代價,那麼這個問題就特別廣泛了。若是是這種狀況,每次代碼修改將大大減小其整體質量,最終致使軟件的不可維護。

因此解決這個問題的核心在於:明確公用代碼的全部權,並在開發期施加約束。

明確公用代碼的全部權,能夠理解爲將導航欄相關的組件抽離成一個單獨的組件,並交由特定的團隊維護。而在開發期施加約束,則意味着咱們要提供一套完整的解決方案讓各個業務方遵照。

這一節咱們會以美團內部的解決方案爲例,講解如何實現一個流暢的導航欄跳轉過程和相關使用方法。

設計理念

使用者只用關心當前 ViewController 的 NavigationBar 樣式,而不用在 push 或者 pop 的時候去處理 NavigationBar 樣式。

舉個例子來講,當從 A 頁面 push 到 B 頁面的時候,轉場庫會保存 A 頁面的導航欄樣式,當 pop 回去後就會還原成之前的樣式,所以咱們不用考慮 pop 後導航欄樣式會改變的狀況,同時咱們也沒必要考慮 push 後的狀況,由於這個是頁面 B 自己須要考慮的。

使用方法

轉場庫的使用十分簡單,咱們不須要 import 任何頭文件,由於它在底層經過 Method Swizzling 進行了處理,只須要在使用的時候遵循下面 4 點便可:

  • 當須要改變導航欄樣式的時候,在視圖控制器的 viewDidLoad 或者 viewWillAppear: 方法裏去設置導航欄樣式。
  • 用 setBackgroundImage:forBarMetrics: 方法和 shadowImage 屬性去修改導航欄的背景樣式。
  • 不要在 viewWillDisappear: 裏添加針對導航欄樣式修改的代碼。
  • 不要隨意修改 translucent 屬性,包括隱式的修改和顯示的修改。

隱式修改是指使用 setBackgroundImage:forBarMetrics: 方法時,若是 image 裏的像素點沒有 alpha 通道或者 alpha 所有等於 1 會使得 translucent 變爲 NO 或者 nil。

基本原理

以上,咱們講完了設計理念和使用方法,那麼咱們來看看美團的轉場庫到底作了什麼?

從大方向上來看,美團使用的是前面所說的第三種方案,不過它也有一些本身獨特的地方,爲了更好的讓你們理解整個過程,咱們設計這樣一個場景,從頁面 A push 到頁面 B,結合以前探討過的方法調用順序,咱們能夠知道幾個核心方法的調用順序大體以下:

  1. 頁面 A 的 pushViewController:animated:
  2. 頁面 B 的 viewDidLoad or viewWillAppear:
  3. 頁面 B 的 viewWillLayoutSubviews
  4. 頁面 B 的 viewDidAppear:

在 push 過程的開始,轉場庫會在頁面 A 自身的 view 上添加一個與導航欄如出一轍的 NavigationBar 並將真的導航欄隱藏。以後這個假的導航欄會一直存在頁面 A 上,用於保留 A 離開時的導航欄樣式。

等到頁面 B 調用 viewDidLoad 或者 viewWillAppear: 的時候,開發者在這裏自行設置真的導航欄樣式。轉場庫在這裏會對頁面佈局作一些修正和輔助操做,但不會影響導航欄的樣式。

等到頁面 B 調用 viewWillLayoutSubviews 的時候,轉場庫會在頁面 B 自身的 view 上添加一個與真的導航欄如出一轍的 NavigationBar,同時將真的導航欄隱藏。此時不論真的導航欄,仍是假的導航欄都已經與 viewDidLoad 或者 viewWillAppear: 裏設置的同樣的。

固然,這一步也能夠放在 viewWillAppear: 裏並在 dispatch main queue 的下一個 runloop 中處理。

等到頁面 B 調用 viewDidAppear: 的時候,轉場庫會將假的導航欄樣式設置到真的導航欄中,並將假的導航欄從視圖層級中移除,最終將真的導航欄顯示出來。

爲了讓你們更好地理解上面的內容,請參考下圖:

說完了 push 過程,咱們再來講一下從頁面 B pop 回頁面 A 的過程,幾個核心方法的調用順序以下:

  1. 頁面 B 的 popViewControllerAnimated:
  2. 頁面 A 的 viewWillAppear:
  3. 頁面 A 的 viewDidAppear:

在 pop 過程的開始,轉場庫會在頁面 B 自身的 view 上添加一個與導航欄如出一轍的 NavigationBar 並將真的導航欄隱藏,雖然這個假的導航欄會一直存在於頁面 B 上,但它自身會隨着頁面 B 的 dealloc 而消亡。

等到頁面 A 調用 viewWillAppear: 的時候,開發者在這裏自行設置真的導航欄樣式。固然咱們也能夠不設置,由於這時候頁面 A 還持有一個假的導航欄,這裏還保留着咱們以前在 viewDidLoad 裏寫的導航欄樣式。

等到頁面 A 調用 viewDidAppear: 的時候,轉場庫會將假的導航欄樣式設置到真的導航欄中,並將假的導航欄從視圖層級中移除,最終將真的導航欄顯示出來。

一樣,咱們能夠參考下面的圖來理解上面所說的內容:

如今,你們應該對咱們美團的解決方案有了必定的認識,但在實際開發過程當中,還須要考慮一些佈局和適配的問題。

最佳實踐

在維護這套轉場方案的時間裏,咱們總結了一些此類方案的最佳實踐。

判斷導航欄問題的基本準則

若是發現導航欄在轉場過程當中出現了樣式錯亂,能夠遵循如下幾點基本原則:

  • 檢查相應 ViewController 裏是否有修改其餘 ViewController 導航欄樣式的行爲,若是有,請作調整。
  • 保證全部對導航欄樣式變化的操做出如今 viewDidLoad 和 viewWillAppear: 中,若是在 viewWillDisappear: 等方法裏出現了對導航欄的樣式修改的操做,若是有,請作調整。
  • 檢查是否有改動 translucent 屬性,包括顯示修改和隱式修改,若是有,請作調整。

只關心當前頁面的樣式

永遠記住每一個 ViewController 只用關心本身的樣式,設置的時機點在 viewWillAppear: 或者 viewDidLoad 裏。

透明樣式導航欄的正確設置方法

若是須要一個透明效果的導航欄,能夠使用以下代碼實現:

[self.navigationController.navigationBar setBackgroundImage:[UIImage new] forBarMetrics:UIBarMetricsDefault]; self.navigationController.navigationBar.shadowImage = [UIImage new]; 

導航欄的顏色漸變效果

若是須要導航欄實現隨滾動改變總體 alpha 值的效果,能夠經過改變 setBackgroundImage:forBarMetrics: 方法裏 image 的 alpha 值來達到目標,這裏通常是使用監聽 scrollView.contentOffset 的手段來作。請避免直接修改 NavigationBar 的 alpha 值。

還有一點須要注意的是,在頁面轉場的過程當中,也會觸發 contentOffset 的變化,因此請儘可能在 disappear 的時候取消監聽。不然會容易出現導航欄透明度的變化。

導航欄背景圖片的規範

請避免背景圖裏的像素點沒有 alpha 通道或者 alpha 所有等於 1,容易觸發 translucent 的隱式改變。

若是真的要隱藏導航欄

若是咱們須要隱藏導航欄,請保證全部的 ViewController 能堅持以下原則:

  1. 每一個 ViewController 只須要關心當前頁面下的導航欄是否被隱藏。
  2. 在 viewWillAppear: 中,統一設置導航欄的隱藏狀態。
  3. 使用 setNavigationBarHidden:animated: 方法,而不是 setNavigationBarHidden:

轉場動畫與導航欄隱藏動畫的一致性

若是在轉場的過程當中還會顯示或者隱藏導航欄的話,請保證兩個方法的動畫參數一致。

- (void)viewWillAppear:(BOOL)animated{ [self.navigationController setNavigationBarHidden:YES animated:animated]; } 

viewWillAppear: 裏的 animated 參數是受 push 和 pop 方法裏 animated 參數影響。

導航欄固有的系統問題

目前已知的有兩個系統問題以下:

  1. 當先後兩個 ViewController 的導航欄都處於隱藏狀態,而後在後一個 ViewController 中使用返回手勢 pop 到一半時取消,再連續 push 多個頁面時會形成導航欄的 Stack 混亂或者 Crash。
  2. 當頁面的層級結構大致以下所示時,在紅色導航欄的 Stack 中,返回手勢會大機率的出現跨層級的跳轉,屢次後會致使整個導航欄的 Stack 錯亂或者 Crash。

導航欄內置組件的佈局規範

導航欄裏的組件佈局在 iOS 11 後發生了改變,原有的一些解決方案已經失效,這些內容不在本篇文章的討論範圍以內,推薦閱讀UIBarButtonItem 在 iOS 11 上的改變及應對方案,這篇文章詳細的解釋了 iOS 11 裏的變化和可行的應對方案。

總結

本文涉及內容較多,從 iOS 系統下的導航欄概念到大型應用裏的最佳實踐,這裏咱們總結一下整篇文章的核心內容:

  • 理解導航欄組件的結構和相關方法的生命週期。
    • 導航欄組件的結構留有 MVC 架構的影子,在解決問題時,要去相應的層級處理。
    • 轉場問題的關鍵點是方法的調用順序,因此瞭解生命週期是解決此類問題的基礎。
  • 狀態管理,轉換時機和樣式變化是導航欄裏常見問題的三種表現形式,遇到實際問題時須要區分清楚。
    • 狀態管理要堅持「誰修改,誰復原」的原則。
    • 轉換時機的設定要作到連續可執行。
    • 樣式變化的核心點是導航欄的顯示與否與顏色變化。
  • 爲了更好的配合大型應用裏的路由系統,導航欄轉場的常看法決方案有三種,各有利弊,須要根據自身的業務場景和歷史包袱作取捨。
    • 解決方案1:自定義導航欄組件。
    • 解決方案2:在原有導航欄組件裏添加 Fake Bar。
    • 解決方案3:在導航欄轉場過程當中添加 Fake Bar。
  • 美團在實際開發過程當中採用了第三種方案,並給出了適合美團 App 的最佳實踐。

特別感謝莫洲騏在此項目裏的貢獻與付出。

參考連接

 

 

Category 特性在 iOS 組件化中的應用與管控

背景

iOS Category功能簡介

Category 是 Objective-C 2.0以後添加的語言特性。

Category 就是對裝飾模式的一種具體實現。它的主要做用是在不改變原有類的前提下,動態地給這個類添加一些方法。在 Objective-C(iOS 的開發語言,下文用 OC 代替)中的具體體現爲:實例(類)方法、屬性和協議。

除了引用中提到的添加方法,Category 還有不少優點,好比將一個類的實現拆分開放在不一樣的文件內,以及能夠聲明私有方法,甚至能夠模擬多繼承等操做,具體可參考官方文檔Category

若 Category 添加的方法是基類已經存在的,則會覆蓋基類的同名方法。本文將要提到的組件間通訊都是基於這個特性實現的,在本文的最後則會提到對覆蓋風險的管控。

組件通訊的背景

隨着移動互聯網的快速發展,不斷迭代的移動端工程每每面臨着耦合嚴重、維護效率低、開發不夠敏捷等常見問題,所以愈來愈多的公司開始推行「組件化」,經過解耦重組組件來提升並行開發效率。

可是大多數團隊口中的「組件化」就是把代碼分庫,主工程使用 CocoaPods 工具把各個子庫的版本號聚合起來。但能合理的把組件分層,而且有一整套工具鏈支撐發版與集成的公司較少,致使開發效率很難有明顯地提高。

處理好各個組件之間的通訊與解耦一直都是組件化的難點。諸如組件之間的 Podfile 相互顯式依賴,以及各類聯合發版等問題,若處理不當可能會引起「災難」性的後果。

目前作到 ViewController (指iOS中的頁面,下文用VC代替)級別解耦的團隊較多,維護一套 mapping 關係並使用 scheme 進行跳轉,可是目前仍然沒法作到更細粒度的解耦通訊,依然知足不了部分業務的需求。

實際業務案例

例1:外賣的首頁的商家列表(WMPageKit),在進入一個商家(WMRestaurantKit)選擇5件商品返回到首頁的時候,對應的商家cell須要顯示已選商品「5」。

例2:搜索結果(WMSearchKit)跳轉到商超的容器頁(WMSupermarketKit),須要傳遞一個通用Domain(也有的說法叫模型、Model、Entity、Object等等,下文統一用Domain表示)。

例3:作一鍵下單需求(WMPageKit),須要調用下單功能的一個方法(WMOrderKit)入參是一個訂單相關 Domain 和一個 VC,不須要返回值。

這幾種場景基本涵蓋了組件通訊所需的的基本功能,那麼怎樣才能夠實現最優雅的解決方案?

組件通訊的探索

模型分析

對於上文的實際業務案例,很容易想到的應對方案有三種,第一是拷貝共同依賴代碼,第二是直接依賴,第三是下沉公共依賴。

對於方案一,會維護多份冗餘代碼,邏輯更新後代碼不一樣步,顯然是不可取的。對於方案二,對於調用方來講,會引入較多無用依賴,且可能形成組件間的循環依賴問題,致使組件沒法發佈。對於方案三,實際上是可行解,可是開發成本較大。對於下沉出來的組件來講,其實很難找到一個明確的定位,最終淪爲多個組件的「大雜燴」依賴,從而致使嚴重的維護性問題。

那如何解決這個問題呢?根據面向對象設計的五大原則之一的「依賴倒置原則」(Dependency Inversion Principle),高層次的模塊不該該依賴於低層次的模塊,二者(的實現)都應該依賴於抽象接口。推廣到組件間的關係處理,對於組件間的調用和被調用方,從本質上來講,咱們也須要儘可能避免它們的直接依賴,而但願它們依賴一個公共的抽象層,經過架構工具來管理和使用這個抽象層。這樣咱們就能夠在解除組件間在構建時沒必要要的依賴,從而優雅地實現組件間的通信。

圖1-1 模型設計

圖1-1 模型設計

 

業界現有方案的幾大方向

實踐依賴倒置原則的方案有不少,在 iOS 側,OC 語言和 Foundation 庫給咱們提供了數個可用於抽象的語言工具。在這一節咱們將對其中部分實踐進行分析。

1.使用依賴注入

表明做品有 Objection 和 Typhoon,二者都是 OC 中的依賴注入框架,前者輕量級,後者較重並支持 Swift。

比較具備通用性的方法是使用「協議」 <-> 「類」綁定的方式,對於要注入的對象會有對應的 Protocol 進行約束,會常常看到一些RegisterClass:ForProtocol:classFromProtocol的代碼。在須要使用注入對象時,用框架提供的接口以協議做爲入參從容器中得到初始化後的所需對象。也能夠在 Register 的時候直接註冊一段 Block-Code,這個代碼塊用來初始化本身,做爲id類型的返回值返回,能夠支持一些編譯檢查來確保對應代碼被編譯。

美團內推行將一些運行時加載的操做前移至編譯時,好比將各項註冊從 +load 改成在編譯期使用__attribute((used,section("__DATA,key"))) 寫入 mach-O 文件 Data 的 Segment 中來減小冷啓動的時間消耗。

所以,該方案的侷限性在於:代碼塊存取的性能消耗較大,而且協議與類的綁定關係的維護須要花費更多的時間成本。

2.基於SPI機制

全稱是 Service Provider Interfaces,表明做品是 ServiceLoader。

實現過程大體是:A庫與B庫之間無依賴,但都依賴於P平臺。把B庫內的一個接口I下沉到平臺層(「平臺層」也叫作「通用能力層」,下文統一用平臺層表示),入參和返回值的類型須要平臺層包含,接口I的實現放在B庫裏(由於實如今B庫,因此實現裏能夠正常引用B庫的元素)。而後A庫經過P平臺的這個接口I來實現功能。A能夠調用的到接口I,可是在B的庫中進行實現。

在A庫須要經過一個接口I實例化出一個對象,使用ServiceLoader.load(接口,key),經過註冊過的key使用反射找到這個接口imp的文件路徑而後獲得這個實例對象調用對應接口。

這個操做在安卓中使用較爲普遍,大體至關於用反射操做來替代一次了 import 這樣的耦合引用。但實際上iOS中若使用反射來實現功能則徹底沒必要這麼麻煩。

關於反射,Java能夠實現相似於ClassFromString的功能,可是沒法直接使用 MethodFromString的功能。而且ClassFromString也是經過字符串map到這個類的文件路徑,相似於 com.waimai.home.searchImp,從而能夠得到類型而後實例化,而OC的反射是經過消息機制實現。

3.基於通知中心

以前和一個作讀書類App的同窗交流,發現行業內有些公司的團隊在使用 NotificationCenter 進行一些解耦的通訊,由於通知中心自己支持傳遞對象,而且通知中心的功能也原生支持同步執行,因此也能夠達到目的。

通知中心在iOS 9以後有一次比較大的升級,將通知支持了 request 和 response 的處理邏輯,並支持獲取到通知的發送者。比以往的通知羣發但不感知發送者和是否收到,進步了不少。

字符串的約定也能夠理解爲一個簡化的協議,可設置成宏或常量放在平臺層進行統一的維護。

比較明顯的缺陷是開發的統一範式難以約束,風格迥異,且字符串相較於接口而言仍是難以管理。

4.使用objc_msgSend

這是iOS原生消息機制中最萬能的方法,編寫時會有一些硬編碼。核心代碼以下:

id s = ((id(*)(id, SEL))objc_msgSend)(ClassName,@selector(methodName)); 

這種方法的特色是即插即用,在開發者能100%肯定整條調用鏈沒問題的時候,能夠快速實現功能。

此方案的缺陷在於編寫十分隨意,檢查和校驗的邏輯還不夠,滿屏的強轉。對於 int、Integer、NSNumber 這樣的很容易發生類型轉換錯誤,結果雖然不報錯,但數字會有錯誤。

方案對比

接下來,咱們對這幾個大方向進行一些性能對比。

考慮到在公司內的實際用法與限制,可能比常規方法增長了若干步驟,結果也可能會與常規裸測存在必定的誤差。

例如依賴注入經常使用作法是存在單例(內存)裏,可是咱們爲了優化冷啓動時間都寫入 mach-O 文件 Data 的 Segment 裏了,因此在咱們的統計口徑下存取時間會相對較長。

// 爲了避免暴露類名將業務屬性用「some」代替,並隱藏初始化、循環100W次、差值計算等代碼,關鍵操做代碼以下 // 存取注入對象 xxConfig = [[WMSomeGlueCore sharedInstance] createObjectForProtocol:@protocol(WMSomeProtocol)]; // 通知發送 [[NSNotificationCenter defaultCenter]postNotificationName:@"nixx" object:nil]; // 原生接口調用 a = [WMSomeClass class]; // 反射調用 b = objc_getClass("WMSomeClass"); 

運行結果顯示

運行結果顯示

 

圖1-2 性能消耗檢測

圖1-2 性能消耗檢測

 

能夠看出原生的接口調用明顯是最高效的用法,反射的時長比原生要多一個數量級,不過100W次也就是多了幾十毫秒,還在能夠接受的範圍以內。通知發送相比之下性能就很低了,存取注入對象更低。

固然除了性能消耗外,還有不少很差量化的維度,包括規範約束、功能性、代碼量、可讀性等,筆者按照實際場景客觀評價給出對比的分值。

下面,咱們用五種維度的能力值圖來對比每一種方案優缺點:

  • 各維度的的評分考慮到了必定的實際場景,可能和常規結果稍有誤差。

  • 已經作了轉化,看圖面積越大越優。可讀性的維度越長表明可讀性越高,代碼量的維度越長表明代碼成本越少。

圖2-1 各方案優缺點對比

圖2-1 各方案優缺點對比

 

如圖2所示,能夠看出上圖的四種方式或多或少都存在一些缺點:

  1. 依賴注入是由於美團的實際場景問題,因此在性能消耗上存在明顯的短板,而且代碼量和可讀性都不突出,規範約束這裏是亮點。
  2. SPI機制的範圍圖很大,但使用了反射,而且代碼開發成本較高,實踐上來看,對協議管理有必定要求。
  3. 通知中心看上去挺方便,但發送與接收大多成對出現,還附帶綁定方法或者Block,代碼量並很多。
  4. 而msgsend功能強大,代碼量也少,可是在規範約束和可讀性上幾乎爲零。

綜合看來 SPI 和 objc_msgSend 二者的特色比較明顯,頗有潛力,若是針對這兩種方案分別進行必定程度的完善,應該能夠實現一個綜合評分更高的方案。

從現有方案中完善或衍生出的方案

5.使用Category+NSInvocation

此方案從 objc_msgSend 演化而來。NSInvocation 的調用方式的底層仍是會使用到 objc_msgSend,可是經過一些方法簽名和返回值類型校驗,能夠解決不少類型規範相關的問題,而且這種方式沒有繁瑣的註冊步驟,任何一次新接口的添加,均可以直接在低層的庫中進行完成。

爲了更進一步限制調用者可以調用的接口,建立一些 Category 來提供接口,內部包裝下層接口,把返回值和入參都限制實際的類型。業界比較接近的例子有 casatwy 的 CTMediator。

6.原生CategoryCoverOrigin方式

此方案從 SPI 方式演化而來。兩個的共同點是都在平臺層提供接口供業務方調用,不一樣點是此方式徹底規避了各類硬編碼。並且 CategoryCoverOrigin 是一個思想,沒有任何框架代碼,能夠說 OC 的 Runtime 就是這個方案的框架支撐。此方案的核心操做是在基類裏彙總全部業務接口,在上層的業務庫中建立基類的 Category 中對聲明的接口進行覆蓋。整個過程沒有任何硬編碼與反射。

演化出的這兩種方案能力評估以下(綠色部分),圖中也貼了和演化前方案(桔色部分)的對比:

圖2-2 兩種演化方案對比

圖2-2 兩種演化方案對比

 

上文對這兩種方案描述的很是歸納,可能有同窗會對能力評估存在質疑。接下來會分別進行詳解的介紹,並描述在實際操做值得注意的細節。這兩種方案組合成了外賣內部的組件通訊框架 WMScheduler。

WMScheduler組件通訊

外賣的 WMScheduler 主要是經過對 Category 特性的運用來實現組件間通訊,實際操做中有兩種的應用方案:Category+NSInvocation 和 Category CoverOrigin。

1.Category+NSInvocation方案

方案簡介:

這個方案將其對 NSInvocation 功能容錯封裝、參數判斷、類型轉換的代碼寫在下層,提供簡易萬能的接口。並在上層建立通訊調度器類提供經常使用接口,在調度器的的 Category 裏擴展特定業務的專用接口。全部的上層接口均有規範約束,這些規範接口的內部會調用下層的簡易萬能接口便可經過NSInvocation 相關的硬編碼操做調用任何方法。

UML圖:

圖3-1 Category+NSInvocation的UML圖

圖3-1 Category+NSInvocation的UML圖

 

如圖3-1所示,代碼的核心在 WMSchedulerCore 類,其包含了基於 NSInvocation 對 target 與 method 的操做、對參數的處理(包括對象,基本數據類型,NULL類型)、對異常的處理等等,最終開放了簡潔的萬能接口,接口參數有 target、method、parameters等等,而後內部幫咱們完成調用。但這個接口並非讓上層業務直接進行調用,而是須要建立一個 WMSchedule r的 Category,在這個 Category 中編寫規範的接口(前綴、入參類型、返回值類型都是肯定的)。

值得一提的是,提供業務專用接口的 Category 沒有以 WMSchedulerCore 爲基類,而是以 WMScheduler 爲基類。看似畫蛇添足,其實是爲了作權限的隔離。 上層業務只能訪問到 WMScheduler.h 及其 Category 的規範接口。並不能訪問到 WMSchedulerCore.h 提供的「萬能但不規範」接口。

例如:在UML圖中能夠看到 外界只能夠調用到wms_getOrderCountWithPoiid(規範接口),並不能使用wm_excuteInstance Method(萬能接口)。

爲了更好地理解實際使用,筆者貼一個組件調用週期的完整代碼:

圖3-2 Category+NSInvocation的示例圖

圖3-2 Category+NSInvocation的示例圖

 

如圖3-2,在這種方案下,「B庫調用A庫方法」的需求只須要改兩個倉庫的代碼,須要改動的文件標了下劃線,請仔細看下示例代碼。

示例代碼:

平臺(通用功能)庫三個文件:

// WMScheduler+AKit.h #import "WMScheduler.h" @interface WMScheduler(AKit) /** * 經過商家id查到當前購物車已選e的小紅點數量 * @param poiid 商家id * @return 實際的小紅點數量 */ + (NSUInteger)wms_getOrderedFoodCountWithPoiID:(NSNumber *)poiID; @end 

// WMScheduler+AKit.m #import "WMSchedulerCore.h" #import "WMScheduler+AKit.h" #import "NSObject+WMScheduler.h" @implementation WMScheduler (AKit) + (NSUInteger)wms_getOrderedFoodCountWithPoiID:(NSNumber *)poiID{ if (nil == poiid) { return 0; } #pragma clang diagnostic push #pragma clang diagnostic ignored "-Wundeclared-selector" id singleton = [wm_scheduler_getClass("WMXXXSingleton") wm_executeMethod:@selector(sharedInstance)]; NSNumber* orderFoodCount = [singleton wm_executeMethod:@selector(calculateOrderedFoodCountWithPoiID:) params:@[poiID]]; return orderFoodCount == nil ? 0 : [orderFoodCount integerValue]; #pragma clang diagnostic pop } @end 

// WMSchedulerInterfaceList.h #ifndef WMSchedulerInterfaceList_h #define WMSchedulerInterfaceList_h // 這個文件會被加到上層業務的pch裏,因此下文不用import本文件 #import "WMScheduler.h" #import "WMScheduler+AKit.h" #endif /* WMSchedulerInterfaceList_h */ 

BKit (調用方)一個文件:

// WMHomeVC.m @interface WMHomeVC () <UITableViewDataSource, UITableViewDelegate> @end @implementation WMHomeVC ... NSUInteger *foodCount = [WMScheduler wms_getOrderedFoodCountWithPoiID:currentPoi.poiID]; NSLog(@"%ld",foodCount); ... @end 

代碼分析:

上文四個文件完成了一次跨組件的調用,在 WMScheduler+AKit.m 中的第30、31行,調用的都是AKit(提供方)的現有方法,由於 WMSchedulerCore 提供了 NSInvocation 的調用方式,因此能夠直接向上調用。WMScheduler+AKit 中提供的接口就是上文說的「規範接口」,這個接口在WMHomeVC(調用方)調用時和調用本倉庫內的OC方法,並無區別。

延伸思考:

  • 上文的例子中入參和返回值都是基本數據類型,Domain 也是支持的,前提是這個 Domain 是放在平臺庫的。咱們能夠將工程中的 Domain 分爲BO(Business Object)、VO(View Object)與TO(Transfer Object),VO 常常出如今 view 和 cell,BO通常僅在各業務子庫內部使用,這個TO則是須要放在平臺庫是用於各個組件間的通訊的通用模型。例如:通用 PoiDomain,通用 OrderDomain,通用 AddressDomain 等等。這些稱爲 TO 的 Domain 能夠做爲規範接口的入參類型或返回值類型。

  • 在實際業務場景中,跳轉頁面時傳遞 Domain 的需求也是一個老生常談的問題,大多數頁面級跳轉框架僅支持傳遞基本數據類型(也有 trick 的方式傳 Domain 內存地址但很不優雅)。在有了上文支持的能力,咱們能夠在規範接口內經過萬能接口獲取目標頁面的VC,並調用其某個屬性的 set 方法將咱們想傳遞的Domain賦值過去,而後將這個 VC 對象做爲返回值返回。調用方得到這個 VC 後在當前的導航棧內push便可。

  • 上文代碼中咱們用 WMScheduler 調用了 Akit 的一個名爲calculateOrderedFoodCount WithPoiID:的方法。那麼有個爭議點:在組件通訊須要調用某方法時,是容許直接調用現有方法,仍是複製一份加上前綴標註此方法專門用於提供組件通訊? 前者的問題點在於現有方法可能會被修改,擴充參數會直接致使調用方找不到方法,Method 字符串的不會編譯報錯(上文平臺代碼 WMScheduler+AKit.m 中第31行)。後者的問題在於大大增長了開發成本。權衡後咱們仍是使用了前者,加了些特殊處理,若現有方法被修改了,則會在isReponseForSelector這裏檢查出來,並走到 else 的斷言及時發現。

階段總結:

Category+NSInvocation 方案的優勢是便捷,由於 Category 的專用接口放在平臺庫,之後有除了 BKit 之外的其餘調用方也能夠直接調用,還有更多強大的功能。

可是,不優雅的地方咱們也列舉一下:

  • 當這個跨組件方法內部的代碼行數比較多時,會寫不少硬編碼。

  • 硬編碼method字符串,在現有方法被修改時,編譯檢測不報錯(只能靠斷言約束)。

  • 下層庫向上調用的設計會被詬病。

接下來介紹的 CategoryCoverOrigin 的方案,能夠解決這三個問題。

2.CategoryCoverOrigin方案

方案簡介:

首先說明下這個方案和 NSInvocation 沒有任何關係,此方案與上一方案也是徹底不一樣的兩個概念,不要將上一個方案的思惟帶到這裏。

此方案的思路是在平臺層的 WMScheduler.h 提供接口方法,接口的實現只寫空實現或者兜底實現(兜底實現中可根據業務場景在 Debug 環境下增長 toast 提示或斷言),上層庫的提供方實現接口方法並經過 Category 的特性,在運行時進行對基類同名方法的替換。調用方則正常調用平臺層提供的接口。在 CategoryCoverOrigin 的方案中 WMScheduler 的 Category 在提供方倉庫內部,所以業務邏輯的依賴能夠在倉庫內部使用常規的OC調用。

UML圖:

圖4-1 CategoryCover 的 UML 圖

圖4-1 CategoryCover 的 UML 圖

 

從圖4-1能夠看出,WMScheduler 的 Category 被移到了業務倉庫,而且 WMScheduler 中有全部接口的全集。

爲了更好地理解 CategoryCover 實際應用,筆者再貼一個此方案下的完整完整代碼:

圖4-2 CategoryCover的示例圖

圖4-2 CategoryCover的示例圖

 

如圖4-2,在這種方案下,「B庫調用A庫方法」的需求須要修改三個倉庫的代碼,但除了這四個編輯的文件,沒有其餘任何的依賴了,請仔細看下代碼示例。

示例代碼:

平臺(通用功能庫)兩個文件

// WMScheduler.h @interface WMScheduler : NSObject // 這個文件是全部組件通訊方法的彙總 #pragma mark - AKit /** * 經過商家id查到當前購物車已選e的小紅點數量 * @param poiid 商家id * @return 實際的小紅點數量 */ + (NSUInteger)wms_getOrderedFoodCountWithPoiID:(NSNumber *)poiID; #pragma mark - CKit // ... #pragma mark - DKit // ... @end 

// WMScheduler.m #import "WMScheduler.h" @implementation WMScheduler #pragma mark - Akit + (NSUInteger)wms_getOrderedFoodCountWithPoiID:(NSNumber *)poiID { return 0; // 這個.m裏只要求一個空實現 做爲兜底方案。 } #pragma mark - Ckit // ... #pragma mark - Dkit // ... @end 

AKit(提供方)一個 Category 文件:

// WMScheduler+AKit.m #import "WMScheduler.h" #import "WMAKitBusinessManager.h" #import "WMXXXSingleton.h" // 直接導入了不少AKit相關的業務文件,由於自己就在AKit倉庫內 @implementation WMScheduler (AKit) // 這個宏能夠屏蔽分類覆蓋基類方法的警告 #pragma clang diagnostic push #pragma clang diagnostic ignored "-Wobjc-protocol-method-implementation" // 在平臺層寫過的方法,這邊是是自動補全的 + (NSUInteger)wms_getOrderedFoodCountWithPoiID:(NSNumber *)poiID { if (nil == poiid) { return 0; } // 全部AKIT相關的類都能直接接口調用,不須要任何硬編碼,能夠和以前的寫法對比下。 WMXXXSingleton *singleton = [WMXXXSingleton sharedInstance]; NSNumber *orderFoodCount = [singleton calculateOrderedFoodCountWithPoiID:poiID]; return orderFoodCount == nil ? 0 : [orderFoodCount integerValue]; } #pragma clang diagnostic pop @end 

BKit(調用方) 一個文件寫法不變:

// WMHomeVC.m @interface WMHomeVC () <UITableViewDataSource, UITableViewDelegate> @end @implementation WMHomeVC ... NSUInteger *foodCount = [WMScheduler wms_getOrderedFoodCountWithPoiID:currentPoi.poiID]; NSLog(@"%ld",foodCount); ... @end 

代碼分析:

CategoryCoverOrigin 的方式,平臺庫用 WMScheduler.h 文件存放全部的組件通訊接口的彙總,各個倉庫用註釋隔開,並在.m文件中編寫了空實現。功能代碼編寫在服務提供方倉庫的 WMScheduler+AKit.m,看這個文件的1七、18行業務邏輯是使用常規 OC 接口調用。在運行時此Category的方法會覆蓋 WMScheduler.h 基類中的同名方法,從而達到目的。CategoryCoverOrigin 方式不須要其餘功能類的支撐。

延伸思考:

若是業務庫不少,方法不少,會不會出現 WMScheduler.h 爆炸? 目前咱們的工程跨組件調用的實際場景不是不少,因此彙總在一個文件了,若是滿屏都是跨組件調用的工程,則須要思考業務架構與模塊劃分是否合理這一問題。固然,若是真出現 WMScheduler.h 爆炸的狀況,徹底能夠將各個業務的接口移至本身Category 的.h文件中,而後建立一個 WMSchedulerInterfaceList 文件統一 import 這些 Category。

兩種方案的選擇

剛纔咱們對於 Category+NSInvocation 和 CategoryCoverOrigin 兩種方式都作了詳細的介紹,咱們再整理一下二者的優缺點對比:

  Category+NSInvocation CategoryCover
優勢 只改兩個倉庫,流程上的時間成本更少
能夠實現url調用方法
(scheme://target/method:?para=x)
無任何硬編碼,常規OC接口調用
除了接口聲明、分類覆蓋、調用,沒有其餘多餘代碼
不存在下層調用上層的場景
缺點 功能複雜時硬編碼寫法成本較大
下層調上層,上層業務改變時會影響平臺接口
不能使用url調用方法
新增接口時需改動三個倉庫,稍有麻煩。
(當接口已存在時,兩種方式都只需修改一處)

筆者更建議使用 CategoryCoverOrigin 的無硬編碼的方案,固然具體也要看項目的實際場景,從而作出最優的選擇。

更多建議

  • 關於組件對外提供的接口,咱們更傾向於借鑑 SPI 的思想,做爲一個 Kit 哪些功能是須要對外公開的?提供哪些服務給其餘方解耦調用?建議主動開放核心方法,儘可能減小「用到才補」的場景。例如全局購物車就須要「提供獲取小紅點數量的方法」,商家中心就須要提供「根據字符串 id 獲得整個 Poi 的 Domain」的接口服務。
  • 須要考慮到抽象能力,提供更有泛用性的接口。好比「獲取到了最低滿減價格後拼接成一個文案返回字符串」 這個方法,就沒有「獲取到了最低滿減價格」 這個方法具有泛用性。

Category 風險管控

先舉兩個發生過的案例

1. 2017年10月 一個關於NSDate重複覆蓋的問題

當時美團平臺有 NSDate+MTAddition 類,在外賣側有 NSDate+WMAddition 類。前者 NSDate+MTAddition 以前就有方法 getCurrentTimestamp,返回的時間戳是秒。後者 NSDate+WMAddition 在一次需求中也增長了 getCurrentTimestamp 方法,可是爲了和其餘平臺統一口徑返回值使用了毫秒。在正常的加載順序中外賣類比平臺類要晚,所以在外賣的測試中沒有發現問題。但集成到 imeituan 主項目以後,原先其餘業務方調用這個返回「秒」的方法,就被外賣測的返回「毫秒」的同名方法給覆蓋了,出現接口錯誤和UI錯亂等問題。

2. 2018年3月 一個WMScheduler組件通訊遇到的問題

在外賣側有訂單組件和商家容器組件,這兩個組件的聯繫是十分緊密的,有的功能放在兩個倉庫任意一箇中都說的通。所以出現了了兩個倉庫寫了同名方法的場景。在 WMScheduler+Restaurant 和 WMScheduler+Order 兩個倉庫都添加了方法 -(void)wms_enterGlobalCartPageFromPage:,在運行中這兩處有一處被覆蓋。在有一次 Bug 解決中,給其中一處增長了異常處理的代碼,恰巧增長的這處先加載,就被後加載的同名方法覆蓋了,這就致使了異常處理代碼不生效的問題。

那麼使用 CategoryCover 的方式是否是很不安全? NO!只要弄清其中的規律,風險點都是徹底能夠管控的,接下來,咱們來分析 Category 的覆蓋原理。

Category 方法覆蓋原理

1) Category 的方法沒有「徹底替換掉」原來類已經有的方法,也就是說若是 Category 和原來類都有methodA,那麼 Category 附加完成以後,類的方法列表裏會有兩個 methodA。

2) Category 方法被放到了新方法列表的前面,而原來類的方法被放到了新方法列表的後面,這也就是咱們日常所說的 Category 的方法會「覆蓋」掉原來類的同名方法,這是由於運行過程當中,咱們在查找方法的時候會順着方法列表的順序去查找,它只要一找到對應名字的方法,就會罷休^_^,卻不知後面可能還有同樣名字的方法。

Category 在運行期進行決議,而基類的類是在編譯期進行決議,所以分類中,方法的加載順序必定在基類以後。

美團曾經有一篇技術博客深刻分析了 Category,而且從編譯器和源碼的角度對分類覆蓋操做進行詳細解析:深刻理解Objective-C:Category

根據方法覆蓋的原理,咱們能夠分析出哪些操做比較安全,哪些存在風險,並針對性地進行管理。接下來,咱們就介紹美團 Category 管理相關的一些工做。

Category 方法管理

因爲歷史緣由,無論是什麼樣的管理規則,都沒法直接「一刀切」。因此針對現狀,咱們將整個管理環節先拆分爲「數據」、「場景」、 「策略」三部分。

其中數據層負責發現異常數據,全部策略公用一個數據層。針對 Category 方法的數據獲取,咱們有以下幾種方式:

根據優缺點的分析,再考慮到美團已經完全實現了「組件化」的工程,因此對 Category 的管控最好放在集成階段之後進行。咱們最終選擇了使用 linkmap 進行數據獲取,具體方法咱們將在下文進行介紹。

策略部分則針對不一樣的場景異常進行控制,主要的開發工做位於咱們的組件化 CI 系統上,即以前介紹過的 Hyperloop 系統。

Hyperloop 自己即提供了包括白名單,發佈集成流程管理等一系列策略功能,咱們只須要將工具進行關聯開發便可。咱們開發的數據層做爲一個獨立組件,最終也是運行在 Hyperloop 上。

圖5-2 方法管理環節

圖5-2 方法管理環節

 

根據場景細分的策略以下表所示(須要注意的是,表中有的場景實際不存在,只是爲了思考的嚴謹列出):

咱們在前文描述的 CategoryCoverOrigin 的組件通訊方案的管控體如今第2點。風險管控中提到的兩個案例的管控主要體如今第4點。

Category 數據獲取原理

上一章節,咱們提到了採用 linkmap 分析的方式進行 Category 數據獲取。在這一章節內,咱們詳細介紹下作法。

啓用 linkmap

首先,linkmap 生成功能是默認關閉的,咱們須要在 build settings 內手動打開開關並配置存儲路徑。對於美團工程和美團外賣工程來講,每次正式構建後產生的 linkmap,咱們還會經過內部的美團雲存儲工具進行持久化的存儲,保證後續的可追溯。

圖6 啓用 linkmap 的設置

圖6 啓用 linkmap 的設置

 

linkmap 組成

若要解析 linkmap,首先須要瞭解 linkmap 的組成。

如名稱所示,linkmap 文件生成於代碼連接以後,主要由4個部分組成:基本信息、Object files 表、Sections 表和 Symbols 表。

前兩行是基本信息,包括連接完成的二進制路徑和架構。若是一個工程內有多個最終產物(如 Watch App 或 Extension),則通過配置後,每個產物的每一種架構都會生成一份 linkmap。

# Path: /var/folders/tk/xmlx38_x605127f0fhhp_n1r0000gn/T/d20180828-59923-v4pjhg/output-sandbox/DerivedData/Build/Intermediates.noindex/ArchiveIntermediates/imeituan/InstallationBuildProductsLocation/Applications/imeituan.app/imeituan # Arch: arm64 

第二部分的 Object files,列舉了連接所用到的全部的目標文件,包括代碼編譯出來的,靜態連接庫內的和動態連接庫(如系統庫),而且給每個目標文件分配了一個 file id。

# Object files: [ 0] linker synthesized [ 1] dtrace [ 2] /var/folders/tk/xmlx38_x605127f0fhhp_n1r0000gn/T/d20180828-59923-v4pjhg/output-sandbox/DerivedData/Build/Intermediates.noindex/ArchiveIntermediates/imeituan/IntermediateBuildFilesPath/imeituan.build/DailyBuild-iphoneos/imeituan.build/Objects-normal/arm64/main.o …… [ 26] /private/var/folders/tk/xmlx38_x605127f0fhhp_n1r0000gn/T/d20180828-59923-v4pjhg/repo-sandbox/imeituan/Pods/AFNetworking/bin/libAFNetworking.a(AFHTTPRequestOperation.o) …… [25919] /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS11.3.sdk/usr/lib/libobjc.tbd [25920] /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS11.3.sdk/usr/lib/libSystem.tbd 

第三部分的 Sections,記錄了全部的 Section,以及它們所屬的 Segment 和大小等信息。

# Sections: # Address Size Segment Section 0x100004450 0x07A8A8D0 __TEXT __text …… 0x109EA52C0 0x002580A0 __DATA __objc_data 0x10A0FD360 0x001D8570 __DATA __data 0x10A2D58D0 0x0000B960 __DATA __objc_k_kylin …… 0x10BFE4E5D 0x004CBE63 __RODATA __objc_methname 0x10C4B0CC0 0x000D560B __RODATA __objc_classname 

第四部分的 Symbols 是重頭戲,列舉了全部符號的信息,包括所屬的 object file、大小等。符號除了咱們關注的 OC 的方法、類名、協議名等,也包含 block、literal string 等,能夠供其餘需求分析進行使用。

# Symbols: # Address Size File Name 0x1000045B8 0x00000060 [ 2] ___llvm_gcov_writeout 0x100004618 0x00000028 [ 2] ___llvm_gcov_flush 0x100004640 0x00000014 [ 2] ___llvm_gcov_init 0x100004654 0x00000014 [ 2] ___llvm_gcov_init.4 0x100004668 0x00000014 [ 2] ___llvm_gcov_init.6 0x10000467C 0x0000015C [ 3] _main …… 0x10002F56C 0x00000028 [ 38] -[UIButton(_AFNetworking) af_imageRequestOperationForState:] 0x10002F594 0x0000002C [ 38] -[UIButton(_AFNetworking) af_setImageRequestOperation:forState:] 0x10002F5C0 0x00000028 [ 38] -[UIButton(_AFNetworking) af_backgroundImageRequestOperationForState:] 0x10002F5E8 0x0000002C [ 38] -[UIButton(_AFNetworking) af_setBackgroundImageRequestOperation:forState:] 0x10002F614 0x0000006C [ 38] +[UIButton(AFNetworking) sharedImageCache] 0x10002F680 0x00000010 [ 38] +[UIButton(AFNetworking) setSharedImageCache:] 0x10002F690 0x00000084 [ 38] -[UIButton(AFNetworking) imageResponseSerializer] …… 

linkmap 數據化

根據上文的分析,在理解了 linkmap 的格式後,經過簡單的文本分析便可提取數據。因爲美團內部 iOS 開發工具鏈統一採用 Ruby,因此 linkmap 分析也採用 Ruby 開發,整個解析器被封裝成一個 Ruby Gem。

具體實施上,處於通用性考慮,咱們的 linkmap 解析工具分爲解析、模型、解析器三層,每一層均可以單獨進行擴展。

圖7 linkmap解析工具

圖7 linkmap解析工具

 

對於 Category 分析器來講,link map parser 解析指定 linkmap,生成通用模型的實例。從實例中獲取 symbol 類,將名字中有「()」的符號過濾出來,即爲 Category 方法。

接下來只要按照方法名聚合,若是超過1個則確定有 Category 方法衝突的狀況。按照上一節中分析的場景,分析其具體衝突類型,提供結論輸出給 Hyperloop。

具體對外接口能夠直接參考咱們的工具測試用例。最後該 Gem 會直接被 Hyperloop 使用。

it 'should return a map with keys for method name and classify' do @parser = LinkmapParser::Parser.new @file_path = 'spec/fixtures/imeituan-LinkMap-normal-arm64.txt' @analyze_result_with_classification = @parser.parse @file_path expect(@analyze_result_with_classification.class).to eq(Hash) # Category 方法互相沖突 symbol = @analyze_result_with_classification["-[NSDate isEqualToDateDay:]"] expect(symbol.class).to eq(Hash) expect(symbol[:type]).to eq([LinkmapParser::CategoryConflictType::CONFLICT]) expect(symbol[:detail].class).to eq(Array) expect(symbol[:detail].count).to eq(3) # Category 方法覆蓋原方法 symbol = @analyze_result_with_classification["-[UGCReviewManager setCommonConfig:]"] expect(symbol.class).to eq(Hash) expect(symbol[:type]).to eq([LinkmapParser::CategoryConflictType::REPLACE]) expect(symbol[:detail].class).to eq(Array) expect(symbol[:detail].count).to eq(2) end 

Category 方法管理總結

1. 風險管理

對於任何語法工具,都是有利有弊的。因此除了發掘它們在實際場景中的應用,也要時刻對它們可能帶來的風險保持警戒,並選擇合適的工具和時機來管理風險。

而 Xcode 自己提供了很多的工具和時機,能夠供咱們分析構建過程和產物。如果在平常工做中遇到一些坑,不妨從構建期工具的角度去考慮管理。好比本文內提到的 linkmap,不只能夠用於 Category 分析,還能夠用於二進制大小分析、組件信息管理等。投入必定資源在相關工具開發上,每每能夠得到事半功倍的效果。

2. 代碼規範

回到 Category 的使用,除了工具上的管控,咱們也有相應的代碼規範,從源頭管理風險。如咱們在規範中要求全部的 Category 方法都使用前綴,下降無心衝突的可能。而且咱們也計劃把「使用前綴」作成管控之一。

3. 後續規劃

1.覆蓋系統方法檢查

因爲目前在管控體系內暫時沒有引入系統符號表,因此沒法對覆蓋系統方法的行爲進行分析和攔截。咱們計劃後續和 Crash 分析系統打通符號表體系,提前發現對系統庫的不當覆蓋。

2.工具複用

當前的管控系統僅針對美團外賣和美團 App,將來計劃推廣到其餘 App。因爲有 Hyperloop,事情在技術上並無太大的難度。

從工具自己的角度看,咱們有計劃在合適的時機對數據層代碼進行開源,但願能對更多的開發有所幫助。

總結

在這篇文章中,咱們從具體的業務場景入手,總結了組件間調用的通用模型,並對經常使用的解耦方案進行了分析對比,最終選擇了目前最適合咱們業務場景的方案。即經過 Category 覆蓋的方式實現了依賴倒置,將構建時依賴延後到了運行時,達到咱們預期的解耦目標。同時針對該方案潛在的問題,經過 linkmap 工具管控的方式進行規避。

另外,咱們在模型設計時也提到,組件間解耦其實在 iOS 側有多種方案選擇。對於其餘的方案實踐,咱們也會陸續和你們分享。但願咱們的工做能對你們的 iOS 開發組件間解耦工做有所啓發。

 

 

美團開源Graver框架:用「雕刻」詮釋iOS端UI界面的高效渲染

Graver 是一款高效的 UI 渲染框架,它以更低的資源消耗來構建十分流暢的 UI 界面。Graver 首創性的採用了基於繪製的視覺元素分解方式來構建界面,得益於此,該框架能讓 UI 渲染過程變得更加簡單、靈活。目前,該框架已經在美團 App 的外賣頻道、獨立外賣 App 核心業務場景的大多數業務中進行了應用,同時也獲得美團外賣內部技術團隊的承認和確定。

App 渲染性能優化是一個廣泛存在的問題,爲了惠及更多的前端開發同窗,美團外賣 iOS 開發團隊將其進行開源,Github 項目地址與使用文檔詳見:https://github.com/Meituan-Dianping/Graver 。咱們但願該框架可以應用到更廣闊的業務場景。固然,咱們也知道該框架尚有待完善之處,也但願能與更多技術同行一塊兒交流、探討、共建。

前言

咱們爲何須要關注界面的渲染性能?App 使用體驗主要包含產品功能、交互視覺、前端性能,而使用體驗的好與壞,直接影響用戶持續使用仍是轉而使用其餘 App,因此咱們很是關注 App 的渲染性能。並且在互聯網產品流量競爭愈發激烈的大背景下,優質的使用體驗能夠爲現有用戶提供更好的服務,進而提升用戶轉化和留存,這也意味着創收、盈利。

圖1 使用體驗與轉化、留存

圖1 使用體驗與轉化、留存

 

背景

美團外賣 App 從2013年成立至今,已經走過了五個春秋,在技術層面前後經歷了快速驗證、模塊化、精細化和平臺化四個階段,產品形態上也日趨成熟。在此期間,咱們構建並完善了監控、報警、容災、備份等各項基礎設施,Metrics 便是其中的性能監控系統。

曾經一段時間,咱們之外賣 App 首頁商家卡片列表爲例,經過 Metrics 性能監控系統發現其在 FPS、CPU、Memory 等方面的各項指標並不理想。因而,經過 Xcode 自帶的 TimeProfile 等性能檢測工具,而後結合代碼分析等手段找到了現存性能瓶頸。與此同時,咱們梳理其近半年的迭代版本需求發現,UI 每每須要根據不一樣場景甚至不一樣用戶展現不一樣的內容。爲了避免斷迎合用戶的需求,快速應對市場變化,這種特徵還會持續存在。然而,它會帶來如下問題:

  • 視圖層級越發複雜、視圖數量越發衆多,從版本長期迭代來看是潛在的性能瓶頸點。
  • 如何快速、高效支撐 UI 變化,同時保證不會二次引入性能瓶頸。

圖2 影響渲染性能、研發效率的瓶頸點

圖2 影響渲染性能、研發效率的瓶頸點

 

Graver 介紹

爲了解決現存的性能瓶頸以及後續潛在的性能瓶頸,咱們指望構建一套解決方案,該方案能在充分知足外賣業務特徵的前提下,以標準化、一站式的方式解決 iOS 端 App 的渲染性能問題,而且對研發效率有必定提高, Graver(雕工)框架應運而生。

由於 Graver 首創性地採用了全新的視覺元素分解思路,因此該框架使用起來十分靈活、簡單。咱們先來看一下 Graver 的主要特色:

性能表現優異

之外賣 App 首頁商家列表爲例,應用 Graver 以後5分位滾動幀率從滿幀的84%提高至96%,50分位幾乎滿幀;CPU 佔用率降低了近6個百分點,有效提高了空閒 CPU 的資源利用率,下降了峯值 CPU 的佔用率。如圖3所示:

圖3 優化先後技術指標對比

圖3 優化先後技術指標對比

 

「一站式」異步化

Graver 從文本計算、樣式排版渲染、圖片解碼,再到繪製,實現了全程異步化,而且是線程安全的。使用 Graver 能夠一站式得到所有性能優化點,能夠讓咱們:

  • 再也不擔憂散點式的「碰見一處改一處」的麻煩。
  • 再也不擔憂離屏渲染等各類可能致使性能瓶頸的問題,以及使人頭痛的解決辦法。
  • 再也不擔憂優化會有遺漏、優化不到位。
  • 再也不擔憂將來變化可能帶來的任何性能瓶頸。

性能消耗的「邊際成本」幾乎爲零

Graver 渲染整個過程除畫板視圖外徹底沒有使用 UIKit 控件,最終產出的結果是一張位圖(Bitmap),視圖層級、數量大幅下降。之外賣 App 首頁鉑金展位視圖爲例,原有方案由58個控件、12層級拼接而成;而應用 Graver 後僅需1個視圖、1級層級繪製而成。 伴隨着需求迭代、視覺元素變化,性能消耗恆屬常數級。如圖4所示:

圖4 外賣 App 鉑金展位應用 Graver 先後對比

圖4 外賣 App 鉑金展位應用 Graver 先後對比

 

渲染速度快

Graver 併發進行多個畫板視圖的渲染、顯示工做。得益於圖文混排技術的應用,達到了內存佔用低,渲染速度快的效果。因爲排版數據是不變的,因此內部會進行緩存、複用,這又進一步促進了總體渲染效率。Graver 既作到了高效渲染,又保證了低時延頁面加載。

圖5 渲染效率說明

圖5 渲染效率說明

 

以「少」勝「繁」

Graver 從新抽象封裝 CoreText、CoreGraphic 等系統基礎能力,經過少許系統標準圖形繪製接口便可實現複雜界面展現。

基於位圖(Bitmap)的輕量事件交互系統

如上述所說,界面展現從傳統的視圖樹轉變爲一張位圖,而位圖不能響應、區份內部具體位置的點擊事件。Graver 提供了基於位圖的輕量事件交互系統,能夠準確識別點擊位置發生在位圖的哪一塊「繪製單元」內。該「繪製單元」能夠理解爲與咱們一向使用的某個具體 UI 控件相對應的視覺展現。使用 Graver 爲某一視覺展現添加事件如同使用系統 UIButton 添加事件同樣簡單。

全新的視覺元素分解思路

Graver 一改界面編程思路,與傳統的經過控件「拼接」、「添加」,視圖排列組合方式構建界面不一樣,它提供了靈活、便捷的接口讓咱們以「視覺所見」的方式構建界面。這一特色在下文Graver使用中詳細闡述,正是由於該特色實現了研發效率的提高。

Graver 使用

Graver 引入了全新的視覺元素分解的思路。藉助該思路能夠實現經過一種對象來表達任一視覺元素、甚至是任一視覺元素的組合,從而消除界面佈局的複雜性。

咱們先來回顧下傳統界面的構建方式,之外賣 App 商家卡片其中一種樣式爲例,如圖6所示:

圖6 外賣 App 商家卡片

圖6 外賣 App 商家卡片

 

在實現商家卡片的界面樣式時,一般會根據視覺上的識別、交互要求來創建界面展現與系統提供的 UI 控件間的映射關係。以標號②位置的樣式爲例,在考慮複用的狀況下一般這部分會使用三個系統控件來完成,分別是左側藍底的「預訂」使用 UILabel 控件、右側的藍色邊框「2.26.21:30起送」使用 UILabel 控件、把左右兩側 UILabel 控件裝起來的 UIView 控件;在肯定好採用的 UI 控件以後,須要針對展現樣式分門別類的設置各個控件的渲染屬性來實現圖示 UI 效果,渲染屬性一般一部分預設,一部分根據業務數據的不一樣再進行二次設置;其次,設置各個控件的內容屬性實現業務數據內容的展現,展現的內容通常是網絡業務數據經邏輯處理、加工後的數據。若是涉及到點擊事件,還須要添加手勢或者更換成 UIButton 控件。接下來,須要根據視覺要求實現排版邏輯,以標號⑧、⑨爲例,當標號⑧位置的數據沒有的狀況下,須要上提標號⑨位置的「美團專送」到圖示標號⑧位置。諸如相似的排版邏輯隨處可見。對於圖示任一位置的展現內容都存在上述的循環思考、編寫工做。隨着界面元素的增長、變化,問題會變得更加複雜。

傳統的界面構建方式實際上是在 UI控件的維度去分解視覺元素,具體是作如下四方面的編寫工做:

  • 控件選擇:根據展現內容、樣式、交互要求肯定採用哪一種系統控件。
  • 佈局信息:UI 控件的大小、位置,即 Frame。
  • 內容信息:UI 控件展現出來的業務數據,如標號①位置的「星巴克咖啡店」。
  • 渲染信息:UI 控件展現出來的效果,如字體、字號、透明度、邊框、顏色等。

最後,將各個控件以排列組合方式合成爲一棵視圖樹。

Graver 框架提供了以畫板視圖爲基礎,經過對更底層的 CoreText、CoreGraphic 框架封裝,以更貼近「視覺所見」的角度定義了全新視覺元素分解、界面展現構建的過程。

一般「視覺所見」可劃分爲兩部分:靜態展現、動態展現。靜態展現包含圖片、文本;動態展現包含視頻、動畫等。在視覺展現所有爲靜態內容的時候,一個 Cell 便是一個畫布,除此之外沒有任何 UI 控件;不然,能夠按需靈活的進行畫布拆分來知足動畫、視頻等須要。

圖7 畫板和傳統視圖樹

圖7 畫板和傳統視圖樹

 

以圖6商家卡片中標號②、⑧爲例,新實現方式的僞代碼是這樣的:

WMMutableAttributedItem *item = [[WMMutableAttributedItem alloc] init];
[[[[item appendImage:[[UIImage wmg_imageWithColor:"blue"] wmg_drawText:"預訂"]] appendImage:[[UIImage wmg_imageWithColor:"clear" borderWidth:1 borderColor:"blue"] wmg_drawText:"2.26.21:30起送"] appendWhiteSpaceWithWidth:"width"]//整體寬度減去②和⑧的寬度總和剩餘部分 apendText:"50分鐘|2.5km"]; 

上述實現方式便是把標號②、⑧部分做爲一個總體來實現,任何單一系統控件都沒法作到這一點。

Graver 渲染原理

圖8 Graver 工做時序

圖8 Graver 工做時序

 

如圖8所示,Graver 涉及多個隊列間的交互,之外賣 App 商家列表爲例,總體流程以下:

  • 主線程構建請求參數,建立請求任務並放入網絡線程隊列中,發起網絡請求。
  • 網絡線程向後端服務發起請求,得到對應的業務模型數據(如包含了店鋪名稱,商家頭圖,評分,配送時長,客單價,優惠活動等店鋪屬性的商家卡片列表)。
  • 網絡線程建立包含業務模型數據(如商家卡片列表)的排版任務,提交到預排版線程處理,進入預排版流程。預排版隊列取出排版任務,交由佈局引擎計算 UI 佈局,將業務模型解析成可被渲染引擎直接處理的,包含佈局、層級、渲染信息的排版模型。解析結束後,通知主線程排版完成。
  • 主線程獲取排版模型後,隨即觸發內容顯示。根據相對屏幕位置及出現的前後順序,建立包含將須要顯示區域信息的繪製任務,放入異步繪製線程隊列中,發起繪製流程。
  • 異步繪製線程隊列取出繪製任務,進行圖文繪製,最終輸出一張包含了圖文內容(如商家卡片)的圖片。繪製任務結束後,通知主線程隊繪製完成,主線程隨後展現繪製區域。

總體按照隊列間串行、隊列內並行的方式執行。

業務應用

Graver 在外賣內部發布以後,咱們也將其推廣到更多的業務線,並但願 Graver 可以成爲對業務開展有重要保障的一項基礎服務。通過半年多的內部試用,Graver 的可靠性、渲染性能、業務適應能力也受到外賣內部的確定和承認。截止發稿時,Graver 已經基本覆蓋了美團 App 的外賣頻道、獨立外賣 App 核心業務場景的大多數業務。下面列舉 Graver 在外賣業務的部分應用案例:

09業務應用

09業務應用

 

經驗總結

總結一下,對於界面渲染性能優化而言,要站在一個更高角度來思考問題的解決方案。橫向上,從普適性角度解決性能瓶頸點,避免其餘人遇到相似問題的重複工做;縱向上,從長遠考慮問題作到防微杜漸,一次優化,長期受益。基於此,咱們提出一站式、標準化的渲染性能解決方案。誠然,這會遇到不少難點。面對界面樣式構建的問題,系統 UIKit 框架着實爲咱們提供了便利,然而有時候咱們須要跳出固有思惟,嘗試創建一套全新界面構建、視覺元素分解的思路。

參考資料

 

美團外賣iOS App冷啓動治理

1、背景

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

2、冷啓動定義

通常而言,你們把iOS冷啓動的過程定義爲:從用戶點擊App圖標開始到appDelegate didFinishLaunching方法執行完成爲止。這個過程主要分爲兩個階段:

  • T1:main()函數以前,即操做系統加載App可執行文件到內存,而後執行一系列的加載&連接等工做,最後執行至App的main()函數。
  • T2:main()函數以後,即從main()開始,到appDelegate的didFinishLaunchingWithOptions方法執行完畢。

然而,當didFinishLaunchingWithOptions執行完成時,用戶尚未看到App的主界面,也不能開始使用App。例如在外賣App中,App還須要作一些初始化工做,而後經歷定位、首頁請求、首頁渲染等過程後,用戶才能真正看到數據內容並開始使用,咱們認爲這個時候冷啓動纔算完成。咱們把這個過程定義爲T3。

綜上,外賣App把冷啓動過程定義爲:從用戶點擊App圖標開始到用戶能看到App主界面內容爲止這個過程,即T1+T2+T3。在App冷啓動過程中,這三個階段中的每一個階段都存在不少能夠被優化的點。

3、問題現狀

性能存量問題

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

注:啓動項的定義,在App啓動過程當中須要被完成的某項工做,咱們稱之爲一個啓動項。例如某個SDK的初始化、某個功能的預加載等。

性能增量問題

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

4、治理思路

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

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

5、規範啓動流程

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

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

  1. 現有的啓動項堆積嚴重,拖慢啓動速度。
  2. 新的啓動項缺少添加範式,雜亂無章,修改風險大,難以閱讀和維護。

面對這個問題,咱們首先梳理了目前啓動流程中全部的啓動項,而後針對App平臺化設計了新的啓動項管理方式:分階段啓動和啓動項自注冊。

分階段啓動

早期因爲業務比較簡單,全部啓動項都是不加以區分,簡單地堆積到didFinishLaunchingWithOptions方法中,但隨着業務的增長,愈來愈多的啓動項代碼堆積在一塊兒,性能較差,代碼臃腫而混亂。

經過對SDK的梳理和分析,咱們發現啓動項也須要根據所完成的任務被分類,有些啓動項是須要剛啓動就執行的操做,如Crash監控、統計上報等,不然會致使信息收集的缺失;有些啓動項須要在較早的時間節點完成,例如一些提供用戶信息的SDK、定位功能的初始化、網絡初始化等;有些啓動項則能夠被延遲執行,如一些自定義配置,一些業務服務的調用、支付SDK、地圖SDK等。咱們所作的分階段啓動,首先就是把啓動流程合理地劃分爲若干個啓動階段,而後依據每一個啓動項所作的事情的優先級把它們分配到相應的啓動階段,優先級高的放在靠前的階段,優先級低的放在靠後的階段。

下面是咱們對美團外賣App啓動階段進行的從新定義,對全部啓動項進行的梳理和從新分類,把它們對應到合理的啓動階段。這樣作一方面能夠推遲執行那些沒必要過早執行的啓動項,縮短啓動時間;另外一方面,把啓動項進行歸類,方便後續的閱讀和維護。而後把這些規則落地爲啓動項的維護文檔,指導後續啓動項的新增和維護。

經過上面的工做,咱們梳理出了十幾個能夠推遲執行的啓動項,佔全部啓動項的30%左右,有效地優化了啓動項所佔的這部分冷啓動時間。

啓動項自注冊

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

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

而咱們但願的方式是,啓動項維護方式可插拔,啓動項之間、業務模塊之間不耦合,且一次實現可在兩端複用。下圖是咱們採用的啓動項管理方式,咱們稱之爲啓動項的自注冊:一個啓動項定義在子業務模塊內部,被封裝成一個方法,而且自聲明啓動階段(例如一個啓動項A,在獨立App中能夠聲明爲在willFinishLaunch階段被執行,在美團App中則聲明在resignActive階段被執行)。這種方式下,啓動項即實現了兩端複用,不相關的啓動項互相隔離,添加/刪除啓動項都更加方便。

那麼如何給一個啓動項聲明啓動階段?又如何在正確的時機觸發啓動項的執行呢?在代碼上,一個啓動項最終都會對應到一個函數的執行,因此在運行時只要能獲取到函數的指針,就能夠觸發啓動項。美團平臺開發的組件啓動治理基建Kylin正是這樣作的:Kylin的核心思想就是在編譯時把數據(如函數指針)寫入到可執行文件的__DATA段中,運行時再從__DATA段取出數據進行相應的操做(調用函數)。

爲何要用借用__DATA段呢?緣由就是爲了可以覆蓋全部的啓動階段,例如main()以前的階段。

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

完成對現有的啓動項的梳理和優化後,咱們也輸出了後續啓動項的添加&維護規範,規範後續啓動項的分類原則,優先級和啓動階段。目的是管控性能問題增量,保證優化成果。

6、優化main()以前

在調用main()函數以前,基本全部的工做都是由操做系統完成的,開發者可以插手的地方很少,因此若是想要優化這段時間,就必須先了解一下,操做系統在main()以前作了什麼。main()以前操做系統所作的工做就是把可執行文件(Mach-O格式)加載到內存空間,而後加載動態連接庫dyld,再執行一系列動態連接操做和初始化操做的過程(加載、綁定、及初始化方法)。這方面的資料網上比較多,但重複性較高,此處附上一篇WWDC的Topic:Optimizing App Startup Time 。

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

真正的加載過程從exec()函數開始,exec()是一個系統調用。操做系統首先爲進程分配一段內存空間,而後執行以下操做:

  1. 把App對應的可執行文件加載到內存。
  2. 把Dyld加載到內存。
  3. Dyld進行動態連接。

下面咱們簡要分析一下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] } 

7、優化耗時操做

在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的分析效果圖:

經過對火焰圖的分析,咱們發現了冷啓動過程當中存在着很多問題,併成功優化了0.3S+的時間。優化內容總結以下:

優化點 舉例
發現隱晦的耗時操做 發如今冷啓動過程當中archive了一張圖片,很是耗時
推遲&減小I/O操做 減小動畫圖片組的數量,替換大圖資源等。由於相比於內存操做,硬盤I/O是很是耗時的操做
推遲執行的一些任務 如一些資源的I/O,一些佈局邏輯,對象的建立時機等

8、優化串行操做

在冷啓動過程當中,有不少操做是串行執行的,若干個任務串行執行,時間必然比較長。若是能變串行爲並行,那麼冷啓動時間就可以大大縮短。

閃屏頁的使用

如今許多App在啓動時並不直接進入首頁,而是會向用戶展現一個持續一小段時間的閃屏頁,若是使用恰當,這個閃屏頁就能幫咱們節省一些啓動時間。由於當一個App比較複雜的時候,啓動時首次構建App的UI就是一個比較耗時的過程,假定這個時間是0.2秒,若是咱們是先構建首頁UI,而後再在Window上加上這個閃屏頁,那麼冷啓動時,App就會實實在在地卡住0.2秒,可是若是咱們是先把閃屏頁做爲App的RootViewController,那麼這個構建過程就會很快。由於閃屏頁只有一個簡單的ImageView,而這個ImageView則會向用戶展現一小段時間,這時咱們就能夠利用這一段時間來構建首頁UI了,一箭雙鵰。

緩存定位&首頁預請求

美團外賣App冷啓動過程當中一個重要的串行流程就是:首頁定位–>首頁請求–>首頁渲染過程,這三個操做佔了整個首頁加載時間的77%左右,因此想要縮短冷啓動時間,就必定要從這三點出發進行優化。

以前串行操做流程以下:

優化後的設計,在發起定位的同時,使用客戶端緩存定位,進行首頁數據的預請求,使定位和請求並行進行。而後當用戶真實定位成功後,判斷真實定位是否命中緩存定位,若是命中,則剛纔的預請求數據有效,這樣能夠節省大概40%的時間首頁加載時間,效果很是明顯;若是未命中,則棄用預請求數據,從新請求。

9、數據監控

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版本的部分過程節點監控數據截圖:

Metrics還會由後臺對數據作聚合計算,獲得冷啓動總時長和各個測速點時長的50分位數、90分位數和95分位數的統計數據,這樣咱們就能從宏觀上對冷啓動時長分佈狀況有所瞭解。下圖中橫軸爲時長,縱軸爲上報的樣本數。

10、總結

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

 

 

美團外賣iOS多端複用的推進、支撐與思考

前言

美團外賣2013年11月開始起步,隨後高速發展,不斷刷新多項行業記錄。截止至2018年5月19日,日訂單量峯值已超過2000萬,是全球規模最大的外賣平臺。業務的快速發展對技術支撐提出了更高的要求。爲線上用戶提供高穩定的服務體驗,保障全鏈路業務和系統高可用運行的同時,要提高多入口業務的研發速度,推動App系統架構的合理演化,進一步提高跨部門跨地域團隊之間的協做效率。而另外一方面隨着用戶數與訂單數的高速增加,美團外賣逐漸有了流量平臺的特徵,兄弟業務紛紛嘗試接入美團外賣進行推廣和發佈,指望提供統一標準化服務平臺。所以,基礎能力標準化,推動多端複用,同時輸出成熟穩定的技術服務平臺,一直是咱們技術團隊追求的核心目標。

多端複用的端

這裏的「端」有兩層意思:

  • 其一是相同業務的多入口

    美團外賣在iOS下的業務入口有三個,『美團外賣』App、『美團』App的外賣頻道、『大衆點評』App的外賣頻道。

    值得一提的是:因爲用戶畫像與產品策略差別,『大衆點評』外賣頻道與『美團』外賣頻道和『美團外賣』雖經歷技術棧融合,但業務形態區別較大,暫不考慮上層業務的複用,故這篇文章主要介紹美團系兩大入口的複用。

    在2015年外賣C端合併以前,美團系的兩大入口由兩個不一樣的團隊研發,雖然用戶感知的交互界面幾乎相同,但功能實現層面的代碼風格和技術棧都存在較大差別,同一需求須要在兩端重複開發顯然不合理。因此,咱們的目標是相同功能,只須要寫一次代碼,作一次估時,其餘端只需作少許的適配工做。

  • 其二是指平臺上各個業務線

    外賣不一樣兄弟業務線都依賴外賣基礎業務,包括但不限於:地圖定位、登陸綁定、網絡通道、異常處理、工具UI等。考慮到標準化的範疇,這些基礎能力也是須要多端複用的。

    圖1 美團外賣的多端複用的目標

    圖1 美團外賣的多端複用的目標

     

關於組件化

提到多端複用,難免與組件化產生聯繫,能夠說組件化是多端複用的必要條件之一。大多數公司口中的「組件化」僅僅作到代碼分庫,使用Cocoapods的Podfile來管理,再在主工程把各個子庫的版本號聚合起來。可是能設計一套合理的分層架構,理清依賴關係,並有一整套工具鏈支撐組件發版與集成的相對較少。不然組件化只會致使包體積增大,開發效率變慢,依賴關係複雜等反作用。

總體思路

A. 多端複用概念圖

圖2 多端複用概念圖

圖2 多端複用概念圖

 

多端複用的目標形態其實很好理解,就是將原有主工程中的代碼抽出獨立組件(Pods),而後各自工程使用Podfile依賴所需的獨立組件,獨立組件再經過podspec間接依賴其餘獨立組件。

B. 準備工做

確認多端所依賴的基層庫是一致的,這裏的基層庫包括開源庫與公司內的技術棧。

iOS中經常使用開源庫(網絡、圖片、佈局)每一個功能基本都有一個庫業界壟斷,這一點是iOS相對於Android的優點。公司內也存在一些對開源庫二次開發或自行研發的基礎庫,即技術棧。不一樣的大組之間技術棧可能存在必定差別。如須要複用的端之間存在差別,則須要重構使得技術棧統一。(這裏建議重構,不建議適配,由於若是作的不夠完全,後續很大可能須要填坑。)

就美團而言,美團平臺與點評平臺做爲公司兩大App,歷史積澱厚重。自2015年末合併以來,爲了共建和沉澱公共服務,減小重複造輪子,提高研發效率,對上層業務方提供統一標準的高穩定基礎能力,兩大平臺的底層技術棧也在不斷融合。而美團外賣做爲較早實踐獨立App,同時也是依託於兩大平臺App的大業務方,在外賣C端合併後的1年內,咱們也作了大量底層技術棧統一的必要工做。

C. 方案選型

在演進式設計與計劃式設計中的抉擇。

演進式設計指隨着系統的開發而作設計變動,而計劃式設計是指在開發以前徹底指定系統架構的設計。演進的設計,一樣須要遵循架構設計的基本準則,它與計劃的設計惟一的區別是設計的目標。演進的設計提倡知足客戶現有的需求;而計劃的設計則須要考慮將來的功能擴展。演進的設計推崇儘快地實現,追求快速肯定解決方案,快速編碼以及快速實現;而計劃的設計則須要考慮計劃的周密性,架構的完整性並保證開發過程的有條不紊。

美團外賣iOS客戶端,在多端複用的立項初期面臨着多個關鍵點:頻道入口與獨立應用的複用,外賣平臺的搭建,兄弟業務的接入,點評外賣的協做,以及架構遷移不影響現有業務的開發等等,所以權衡後咱們使用「演進式架構爲主,計劃式架構爲輔」的設計方案。不強求歷史代碼一下達到終極完美架構,而是按部就班一步一個腳印,知足現有需求的同時並保留必定的擴展性。

演進式架構推進複用

術語解釋

  • Waimai:特指『美團外賣』App,泛指那些獨立App形式的業務入口,通常爲project。
  • Channel:特指『美團』App中的外賣頻道,泛指那些以頻道或者Tab形式集成在主App內的業務入口,通常爲Pods。
  • Special:指將Waimai中的業務代碼與原有工程分離出來,讓業務代碼成爲一個Pods的形態。
  • 下沉:即下沉到下層,這裏的「下層」指架構的基層,通常爲平臺層或通用層。「下沉」指將不一樣上層庫中的代碼統一併移動到下層的基層庫中。

在這裏先貼出動態的架構演進過程,讓你們有一個宏觀的概念,後續再對不一樣節點的經歷作進一步描述。

圖3 演進式架構動態圖

圖3 演進式架構動態圖

 

原始複用架構

如圖4所示,在過去一兩年,由於技術棧等緣由咱們只能採用比較保守的代碼複用方案。將獨立業務或工具類代碼沉澱爲一個個「Kit」,也就是粒度較小的組件。此時分層的概念還比較模糊,而且以往的工程因歷史包袱致使耦合嚴重、邏輯複雜,在將UGC業務剝離後發現其餘的業務代碼沒法輕易的抽出。(此時的代碼複用率只有2.4%。)

鑑於以前的準備工做已經完成,多端基礎庫已經一致,因而咱們再也不採起保守策略,豐富了一些組件化通訊、解耦與過渡的手段,在分層架構上開始發力。

圖4 原始複用架構

圖4 原始複用架構

 

業務複用探索

在技術棧已統一,基礎層已對齊的背景下,咱們挑選外賣核心業務之一的Store(即商家容器)開始了在業務複用上的探索。如圖5所示,大體能夠理解爲「二合一,一分三」的思路,咱們從代碼風格和開發思路上對兩邊的Store業務進行對齊,在此過程當中順勢將業務類與技術(功能)類的代碼分離,一些通用Domain也隨之分離。隨着一個個組件的拆分,咱們的總體複用度有明顯提高,但開發效率卻意外的受到了影響。多庫開發在版本的發佈與集成中增長了不少人工操做:依賴衝突、lock文件衝突等問題都阻礙了咱們的開發效率進一步提高,而這就是以前「關於組件化」中提到的反作用。

因而咱們將自動發版與自動集成提上了日程。自動集成是將「組件開發完畢到功能合入工程主體打出測試包」之間的一系列操做自動化完成。在這以前必須完成一些前期鋪墊工做——殼工程分離。

圖5 商家容器下沉時期

圖5 商家容器下沉時期

 

殼工程分離

如圖6所示,殼工程顧名思義就是將原來的project中的代碼所有拆出去,獲得一個空殼,僅僅保留一些工程配置選項和依賴庫管理文件。

爲何說殼工程是自動集成的必要條件之一?

由於自動集成涉及版本號自增,須要機器修改工程配置類文件。若是在建立二進制的過程當中有新業務PR合入,會形成commit樹分叉大機率產生衝突致使集成失敗。抽出殼工程以後,咱們的殼只關心配置選項修改(不多),與依賴版本號的變化。業務代碼的正常PR流程轉移到了各自的業務組件git中,以此來杜絕人工與機器的衝突。

圖6 殼工程分離

圖6 殼工程分離

 

殼工程分離的意義主要有以下幾點:

  • 讓職能更加明確,以前的綜合層身兼數職過於繁重。
  • 爲自動集成鋪路,避免業務PR與機器衝突。
  • 提高效率,後續Pods往Pods移動代碼比proj往Pods移動代碼更快。
  • 『美團外賣』向『美團』開發環境靠齊,下降適配成本。

圖7 殼工程分離階段圖

圖7 殼工程分離階段圖

 

圖7的第一張圖到第二張圖就是上文提到的殼工程分離,將「Waimai」全部的業務代碼打包抽出,移動到過渡倉庫Special,讓原先的「Waimai」成爲殼。

第二張圖到第三張圖是Pods庫的內部消化。

前一階段至關於簡單粗暴的物理代碼移動,後一階段是對Pods內整塊代碼的梳理與分庫。

內部消化對齊

在前文「多端複用概念圖」的部分咱們提到過,所謂的複用是讓多端的project以Pods的方式接入統一的代碼。咱們兼容考慮保留一端代碼完整性,下降回接成本,決定分Subpods使用階段性合入達到平滑遷移。

圖8 代碼下沉方案

圖8 代碼下沉方案

 

圖8描述了多端相同模塊內的代碼具體是如何統一的。此時由於已經完成了殼工程分離,因此業務代碼都在「Special」這樣的過渡倉庫中。

「Special」和「Channel」兩端的模塊統一大體可分爲三步:平移 → 下沉 → 回接。(前提是此模塊的業務上已經肯定是徹底一致。)

平移階段是保留其中一端「Special」代碼的完整性,以自上而下的平移方式將代碼文件拷貝到另外一端「Channel」中。此時前者不受任何影響,後者的代碼由於新文件拷貝和原有代碼存在重複。此時將舊文件重命名,並深度優先遍歷新文件的依賴關係補齊文件,最終使得編譯經過。而後將舊文件中的部分差別代碼加到新文件中作好必定的差別化管理,最後刪除舊文件。

下沉階段是將「Channel」處理後的代碼解耦並獨立出來,移動到下層的Pods或下層的SubPods。此時這裏的代碼是既支持「Special」也支持「Channel」的。

回接階段是讓「Special」以Pods依賴的形式引用以前下沉的模塊,引用後刪除平移前的代碼文件。(若是是在版本的間隙完成當然最好,不然須要考慮平移前的代碼文件在這段時間的diff。)

實際操做中很難在有限時間內處理完一個完整的模塊(例如訂單模塊)下沉到Pods再回接。因而選擇將大模塊分紅一個個子模塊,這些子模塊平滑的下沉到SubPods,而後「Special」也只引用這個統一後的SubPods,待一個模塊徹底下沉完畢再拆出獨立的Pods。

再總結下大量代碼下沉時如何保證風險可控:

  • 聯合PM,先進行業務梳理,特殊差別要標註出來。
  • 使用OClint的提早掃描依賴,作到心中有數,精準估時。
  • 以「Special」的代碼風格爲基準,「Channel」在對齊時僅作加法不作減法。
  • 「Channel」對齊工做不影響「Special」,而且回接時工做量很小。
  • 分迭代包,QA資源提早協調。

中間件層級壓平

通過前面的「內部消化」,Channel和Special中的過渡代碼逐漸被分發到合適的組件,如圖9所示,Special只剩下AppOnly,Channel也只剩下ChannelOnly。因而Special消亡,Channel變成打包工程。

AppOnly和ChannelOnly 與其餘業務組件層級壓平。上層只留下兩個打包工程。

圖9 中間件層級壓平

圖9 中間件層級壓平

 

平臺層建設

如圖10所示,下層是外賣基礎庫,WaimaiKit包含衆多細分後的平臺能力,Domain爲通用模型,XunfeiKit爲對智能語音二次開發,CTKit爲對CoreText渲染框架的二次開發。

針對平臺適配層而言,在差別化收斂與依賴關係梳理方面發揮重要角色,這兩點在下問的「衍生問題解決中」會有詳細解釋。

外賣基礎庫加上平臺適配層,總體構成了咱們的外賣平臺層(這是邏輯結構不是物理結構),提供了60餘項通用能力,支持無差別調用。

圖10 外賣平臺層的建設

圖10 外賣平臺層的建設

 

多端通用架構

此時咱們把基層組件與開源組件梳理並補充上,達到多端通用架構,到這裏能夠說真正達到了多端複用的目標。

圖11 多端通用架構完成

圖11 多端通用架構完成

 

由上層不一樣的打包工程來控制實際須要的組件。除去兩個打包工程和兩個Only組件,下面的組件都已達到多端複用。對比下「Waimai」與「Channel」的業務架構圖中兩個黑色圓圈的部分。

圖12 「Waimai」的業務架構

圖12 「Waimai」的業務架構

 

圖13 「Channel」的業務架構

圖13 「Channel」的業務架構

 

衍生問題解決

差別問題

A.需求自己的差別

三種解決策略:

  • 對於文案、數值、等一兩行代碼的差別咱們使用 運行時宏(動態獲取proj-identifier)或預編譯宏(custome define)直接在方法中進行if else判斷。
  • 對於方法實現的不一樣 使用Glue(膠水層),protocol提供相同的方法聲明,用來給外部調用,在不一樣的載體中寫不一樣的方法實現。
  • 對於較大差別例如兩邊WebView容器不同,咱們建多個文件採用文件級預編譯,可預編譯常規.m文件或者Category。(例如WMWebViewManeger_wm.m&WMWebViewManeger_mt.m、UITableView+WMEstimated.m&UITableView+MTEstimated.m)

進一步優化策略:

用上述三種策略雖然完成差別化管理,但差別代碼散落在不一樣組件內難以收斂,不便於管理。有了平臺適配層以後,咱們將差別化判斷收斂到適配層內部,對上層提供無差別調用。組件開發者在開發中不用考慮宿主差別,直接調用用通用接口。差別的判斷或者後續優化在接口內部處理外部不感知。

圖14給出了一個平臺適配層提供通用接口修改後的例子。

圖14 平臺適配層接口示例

圖14 平臺適配層接口示例

 

B.多端節奏差別

實際場景中除了需求的差別還有可能出現多端進版節奏的差別,這類差別問題咱們使用分支管理模型解決。

前提條件既然要多端複用了,那需求的大方向仍是會但願多端統一。通常較多的場景是:多端中A端功能最少,B端功能基本算是是A端的超集。(沒有絕對的超集,A端也會有較少的差別點。)在外賣的業務中,「Channel」就是這個功能較少的一端,「Waimai」基本是「Channel」的超集。

兩端的差別大體分爲了這5大類9小類:

  1. 需求兩端相同(1.一、提測上線時間基本相同;1.二、「Waimai」比「Channel」早3天提測 ;1.三、「Waimai」比「Channel」晚3天提測)。
  2. 需求「Waimai」先進版,「Channel」下一版進 (2.一、頻道下一版就上;2.二、頻道下兩版本後再上)。
  3. 需求「Waimai」先進版,「Channel」不須要。
  4. 需求「Channel」先進版,「Waimai」下一版進(4.一、須要改動通用部分;4.二、只改動「ChannelOnly」的部分)。
  5. 需求「Channel」先進版,「Waimai」不須要(只改動「ChannelOnly」的部分)。

圖15 最複雜場景下的分支模型

圖15 最複雜場景下的分支模型

 

也不用過多糾結,圖15是最複雜的場景,實際場合中很難遇到,目前的咱們的業務只遇到1和2兩個大類,最多2條線。

編譯問題

以往的開發方式初次全量編譯5分鐘左右,以後就是差量編譯很快。可是抽成組件後,隨着部分子庫版本的切換間接的增長了pod install的次數,此時高頻率的3分鐘、5分鐘會讓人難以接受。

因而在這個節點咱們採用了全二進制依賴的方式,目標是在平常開發中直接引用編譯後的產物減小編譯時間。

圖16 使用二進制的依賴方式

圖16 使用二進制的依賴方式

 

如圖所示三個.a就是三個subPods,分了三種Configuration:

  1. debug/ 下是 deubg 設置編譯的 x64 armv7 arm64。
  2. release/ 下是 release 設置編譯的 armv7 arm64。
  3. dailybuild/ 下是 release + TEST=1編譯的 armv7 arm64。
  4. 默認(在文件夾外的.a)是 debug x64 + release armv7 + release arm64。

這裏有一個問題須要解決,即引用二進制帶來的弊端,顯而易見的就是將編譯期的問題帶到了運行期。某個宏修改了,可是編譯完的二進制代碼不感知這種改動,而且依賴版本不匹配的話,本來的方法缺失編譯錯誤,就會帶到運行期發生崩潰。解決此類問題的方法也很簡單,就是在全部的打包工程中都配置了打包自動切換源碼。二進制僅僅用來在開發中得到更高的效率,一旦打提測包或者發佈包都會使用全源碼從新編譯一遍。關於切源碼與切二進制是由環境變量控制拉取不一樣的podspec源。

而且在開發中咱們支持源碼與二進制的混合開發模式,咱們給某個binary_pod修飾的依賴庫加上標籤,或者使用.patch文件,控制特定的庫拉源碼。通常狀況下,開發者將與本身當前需求相關聯的庫拉源碼便於Debug,不關聯的庫拉二進制跳過編譯。

依賴問題

如圖17所示,外賣有多個業務組件,公司也有不少基礎Kit,不一樣業務組件或多或少會依賴幾個Kit,因此極易造成網狀依賴的局面。並且依賴的版本號可能不一致,易出現依賴衝突,一旦遇到依賴衝突須要對某一組件進行修改再從新發版來解決,很影響效率。解決方式是使用平臺適配層來統一維護一套依賴庫版本號,上層業務組件僅僅關心平臺適配層的版本。

圖17 平臺適配層統一維護依賴

圖17 平臺適配層統一維護依賴

 

固然爲了不引入平臺適配層而增長過多無用依賴的問題,咱們將一些依賴較多且使用頻度不高的Kit抽出subPods,支持可選的方式引入,例如IM組件。

再者就是pod install 時依賴分析慢的問題。對於殼工程而言,這是全部依賴庫匯聚的地方,依賴關係寫法若不科學極易在analyzing dependency中耗費大量時間。Cocoapods的依賴分析用的是Molinillo算法,連接中介紹了這個算法的實現方式,是一個具備前向檢察的回溯算法。這個算法自己是沒有問題的,依賴層級深只要依賴寫的合理也能夠達到秒開。可是若是對依賴樹葉子節點的版本號控制不夠嚴密,或中間出現了循環依賴的狀況,會致使回溯算法重複執行了不少壓棧和出棧操做耗費時間。美團針對此類問題的作法是維護一套「去依賴的podspec源」,這個源中的dependency節點被清空了(下圖中間)。實際的所需依賴的全集在殼工程Podfile裏平鋪,統一維護。這麼作的好處是將以前的樹狀依賴(下圖左)壓平成一層(下圖右)。

圖18 依賴數的壓平

圖18 依賴數的壓平

 

效率問題

前面咱們提到了自動集成,這裏展現下具體的使用方式。美團發佈工程組自行研發了一套HyperLoop發版集成平臺。當某個組件在建立二進制以前可自行選擇集成的目標,若是多端複用了,那隻須要在發版建立二進制的同時勾選多個集成的目標。發版後會自行進行一系列檢查與測試,最終將代碼合入主工程(修改對應殼工程的依賴版本號)。

圖19 HyperLoop自動發版自動集成

圖19 HyperLoop自動發版自動集成

 

圖20 主工程commit message的變化

圖20 主工程commit message的變化

 

以上是「Waimai」的commit對比圖。第一張圖是以往的開發方式,能看出工程配置的commit與業務的commit交錯堆砌。第二張圖是進行殼工程分離後的commit,能看出每條message都是改了某個依賴庫的版本號。第三張圖是使用自動集成後的commit,能看出每條message都是畫風統一且機器串行提交的。

這裏又衍生出另外一個問題,當咱們用殼工程引Pods的方式替代了project集中式開發以後,咱們的代碼修改散落到了不一樣的組件庫內。想看下主工程6.5.0版本和6.4.0版本的diff時只能看到全部依賴庫版本號的diff,想看commit和code diff時必須挨個去組件庫查看,在三輪提測期間這樣相似的操做天天都會重複屢次,很不效率。

因而咱們開發了atomic diff的工具,主要原理是調git stash的接口獲得版本號diff,再經過版本號和對應的倉庫地址深度遍歷commit,再深度遍歷commit對應的文件,最後彙總,獲得總體的代碼diff。

圖21 atomic diff彙總後的commit message

圖21 atomic diff彙總後的commit message

 

整套工具鏈對多端複用的支撐

上文中已經提到了一些自動化工具,這裏整理下咱們工具鏈的全景圖。

圖22 整套工具鏈

圖22 整套工具鏈

 

  1. 在準備階段,咱們會用OClint工具對compile_command.json文件進行處理,對將要修改的組件提早掃描依賴。
  2. 在依賴庫拉取時,咱們有binary_pod.rb腳本里經過對源的控制達到二進制與去依賴的效果,美團發佈工程組維護了一套ios-re-sankuai.com的源用於存儲remove dependency的podspec.json文件。
  3. 在依賴同步時,會經過sync_podfile定時同步主工程最新Podfile文件,來對依賴庫全集的版本號進行維護。
  4. 在開發階段,咱們使用Podfile.patch工具一鍵對二進制/源碼、遠端/本地代碼進行切換。
  5. 在引用本地代碼開發時,子庫的版本號咱們不太關心,只關心主工程的版本號,咱們使用beforePod和AfterPod腳本進行依賴過濾以防止依賴衝突。
  6. 在代碼提交時,咱們使用git squash對多條相同message的commit進行擠壓。
  7. 在建立PR時,以往須要一些網頁端手動操做,填寫大量Reviewers,如今咱們使用MTPR工具一鍵完成,或者根據我的喜愛使用Chrome插件。
  8. 在功能合入master以前,會有一些jenkins的job進行檢測。
  9. 在發版階段,使用Hyperloop系統,一鍵發版操做簡便。
  10. 在發版以後,可選擇自動集成和聯合集成的方式來打包,打包產物會自動上傳到美團的「搶鮮」內測平臺。
  11. 在問題跟蹤時,若是須要查看主工程各個版本號間的commit message和code diff,咱們有atomic diff工具深度遍歷各個倉庫並彙總結果。

總結

  • 多端複用以後對PM-RD-QA都有較大的變化,咱們代碼複用率由最初的2.4%達到了84.1%,讓更多的PM投入到了新需求的吞吐中,但研發效率提高增大了QA的工做量。一個大的嘗試須要RD不斷與PM和QA保持溝通,選擇三方都能接受的最優方案。
  • 分清主次關係,技術架構等最終是爲了支撐業務,若是一個架構設計的美如畫完美無缺,可是落實到本身的業務中確不能發揮理想效果,或引來抱怨一片,那這就是個失敗的設計。而且在實際開發中技術類代碼修改儘可能選擇版本間隙合入,若是與業務開發的同窗產生衝突時,都要給業務同窗讓路,不能影響本來的版本迭代速度。
  • 時刻對 「不合理」 和 「重複勞動」保持敏感。新增一個埋點常量要去改一下平臺再發個版是否成本太大?一處訂單狀態的需求爲何要修改首頁的Kit?實際開發中遇到彆扭的地方多增長一些思考而不是硬着頭皮過去,而且手動重複兩次以上的操做就要思考有沒有自動化的替代方案。
  • 一旦決定要作,在一些關鍵節點決不能手軟。例如某個節點爲了避免Block別人,加班不可避免。在大量代碼改動時也不用過於緊張,有提早預估,有Case自測,還有QA的三輪迴歸來保障,保持專一,放手去作就好。
相關文章
相關標籤/搜索