用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
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 是一套定位於開發小程序的前端開發框架,其核心目標是提升開發效率,加強開發體驗。使用該框架,開發者只需初步瞭解小程序開發規範、熟悉 Vue.js 基本語法便可上手。框架提供了完整的 Vue.js 開發體驗,開發者編寫 Vue.js 代碼,mpvue 將其解析轉換爲小程序並確保其正確運行。此外,框架還經過 vue-cli 工具向開發者提供 quick start 示例代碼,開發者只需執行一條簡單命令,便可得到可運行的項目。python
在小程序內測之初,咱們計劃快速迭代出一款對標 H5 的產品實現,核心訴求是:快速實現、代碼複用、低成本和高效率… 隨後經歷了多個小程序建設,結合業務場景、技術選型和小程序開發方式,咱們整理彙總出了開發階段面臨的主要問題:react
組件機制:小程序邏輯和視圖層代碼彼此分離,公共組件提取後沒法聚合爲單文件入口,組件需分別在視圖層和邏輯層引入,維護性差;組件無命名空間機制,事件回調必須設置爲全局函數,組件設計有命名衝突的風險,數據封裝不強。開發者須要友好的代碼組織方式,經過 ES 模塊一次性導入;組件數據有良好的封裝。成熟的組件機制,對工程化開發相當重要。android
多端複用:常見的業務場景有兩類,經過已有 H5 產品改造爲小程序應用或反之。從效率角度出發,開發者但願經過複用代碼完成開發,但小程序開發框架卻沒法作到。咱們嘗試過經過靜態代碼分析將 H5 代碼轉換爲小程序,但只作了視圖層轉換,沒法帶來更多收益。多端代碼複用須要更成熟的解決方案。
引入 Vue.js:小程序開發方式與 H5 近似,所以咱們考慮和 H5 作代碼複用。沿襲團隊技術棧選型,咱們將 Vue.js 肯定爲小程序開發規範。使用 Vue.js 開發小程序,將直接帶來以下開發效率提高:
爲何是 Vue.js?這取決於團隊技術棧選型,引入新的選型與統一技術棧和提升開發效率相悖,有違開發工具服務業務的初衷。
mpvue的造成,來源於業務場景和需求,最終方案的肯定,經歷了三個階段。
第一階段:咱們實現了一個視圖層代碼轉換工具,旨在提升代碼首次開發效率。經過將H5視圖層代碼轉換爲小程序代碼,包括 HTML 標籤映射、Vue.js 模板和樣式轉換,在此目標代碼上進行二次開發。咱們作到了有限的代碼複用,但組件化開發和小程序學習成本並未獲得有效改善。
第二階段:咱們着眼於完善代碼組件化機制。參照 Vue.js 組件規範設計了代碼組織形式,經過代碼轉換工具將代碼解析爲小程序。轉換工具主要解決組件間數據同步、生命週期關聯和命名空間問題。最終咱們實現了一個 Vue.js 語法子集,但想要實現更多特性或跟隨 Vue.js 版本迭代,工做量變得難以估計,有永無止境之感。
第三階段:咱們的目標是實現對 Vue.js 語法全集的支持,達到使用 Vue.js 開發小程序的目的。並經過引入 Vue.js runtime 實現了對 Vue.js 語法的支持,從而避免了人肉語法適配。至此,咱們完成了使用 Vue.js 開發小程序的目的。較好地實現了技術棧統1、組件化開發、多端代碼複用、下降學習成本和提升開發效率的目標。
Vue.js 和小程序都是典型的邏輯視圖層框架,邏輯層和視圖層之間的工做方式爲:數據變動驅動視圖更新;視圖交互觸發事件,事件響應函數修改數據再次觸發視圖更新,如圖1所示。
鑑於 Vue.js 和小程序一致的工做原理,咱們思考將小程序的功能託管給 Vue.js,在正確的時機將數據變動同步到小程序,從而達到開發小程序的目的。這樣,咱們能夠將精力聚焦在 Vue.js 上,參照 Vue.js 編寫與之對應的小程序代碼,小程序負責視圖層展現,全部業務邏輯收斂到 Vue.js 中,Vue.js 數據變動後同步到小程序,如圖2所示。如此一來,咱們就得到了以 Vue.js 的方式開發小程序的能力。爲此,咱們設計的方案以下:
Vue代碼
小程序代碼
並在此基礎上,附加以下機制
這套機制總結起來很是簡單,但實現卻至關複雜。在揭祕具體實現以前,讀者可能會有這樣一些疑問:
上述問題包含了 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框架自己由多個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 框架的轉換效果:
將小程序轉換爲H5:直接使用 Vue.js 規範開發小程序,代碼自己與H5並沒有不一樣,具體代碼差別會集中在平臺 Api 部分。此外並不需明顯改動,改造主要分以下幾部分:
將H5轉換爲小程序:已經使用 Vue.js 開發完 H5,咱們須要作的事情以下:
根據小程序開發平臺提供的能力,咱們最大程度的支持了 Vue.js 語法特性,但部分功能現階段暫時還沒有實現。
項目轉換注意事項:框架的目標是將小程序和 H5 的開發方式經過 Vue.js 創建關聯,達到最大程度的代碼複用。但因爲平臺差別的客觀存在(主要集中在實現機制、底層Api 能力差別),咱們沒法作到代碼 100% 複用,平臺差別部分的改形成本沒法避免。對於代碼複用的場景,開發者須要重點思考以下問題並作好準備:
在表2中,咱們對微信小程序、mpvue、WePY 這三個開發框架的主要能力和特色作了橫向對比,幫助你們瞭解不一樣框架的側重點,結合業務場景和開發習慣,肯定技術方案。對於如何更好地使用 mpvue 進行小程序開發,咱們總結了一些最佳實踐。
mpvue 框架已經在業務項目中獲得實踐和驗證,目前正在美團點評內部大範圍使用。mpvue 來源於開源社區,飲水思源,咱們也但願爲開源社區貢獻一份力量,爲廣大小程序開發者提供一套技術方案。mpvue 的初衷是讓 Vue.js 的開發者以低成本接入小程序開發,作到代碼的低成本遷移和複用,咱們將來會繼續擴展示有能力、解決開發者的訴求、優化使用體驗、完善周邊生態建設,幫助到更多的開發者。
最後,mpvue 基於 Vue.js 源碼進行二次開發,新增長了小程序平臺的實現,咱們保留了跟隨 Vue.js 版本升級的能力,由衷的感謝 Vue.js 框架和微信小程序給業界帶來的便利。
Flutter是Google開發的一套全新的跨平臺、開源UI框架,支持iOS、Android系統開發,而且是將來新操做系統Fuchsia的默認開發套件。自從2017年5月發佈第一個版本以來,目前Flutter已經發布了近60個版本,而且在2018年5月發佈了第一個「Ready for Production Apps」的Beta 3版本,6月20日發佈了第一個「Release Preview」版本。
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風格的框架中使用控件樹和各自的狀態來構建界面,當某個控件的狀態發生變化時由框架負責對比先後狀態差別而且採起最小代價來更新渲染結果。
在Dart代碼文件中修改字符串「Hello, World」,添加一個驚歎號,點擊保存或者熱刷新按鈕就能夠當即更新到界面上,僅需幾百毫秒:
Flutter經過將新的代碼注入到正在運行的DartVM中,來實現Hot Reload這種神奇的效果,在DartVM將程序中的類結構更新完成後,Flutter會當即重建整個控件樹,從而更新界面。可是熱刷新也有一些限制,並非全部的代碼改動均可以經過熱刷新來更新:
StatelessWidget
到StatefulWidget
的轉換,由於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’」。熱刷新沒法實現更新時,執行一次熱重啓(Hot Restart)就能夠全量更新全部代碼,一樣不須要重啓App,區別是restart會將全部Dart代碼打包同步到設備上,而且全部狀態都會重置。
Flutter使用的Dart語言沒法直接調用Android系統提供的Java接口,這時就須要使用插件來實現中轉。Flutter官方提供了豐富的原生接口封裝:
AlertManager
。在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是一種強類型、跨平臺的客戶端開發語言。具備專門爲客戶端優化、高生產力、快速高效、可移植(兼容ARM/x86)、易學的OO編程風格和原生支持響應式編程(Stream & Future)等優秀特性。Dart主要由Google負責開發和維護,在2011年10啓動項目,2017年9月發佈第一個2.0-dev版本。
Dart自己提供了三種運行方式:
Flutter在篩選了20多種語言後,最終選擇Dart做爲開發語言主要有幾個緣由:
在Dart中,有一些重要的基本概念須要瞭解:
null
也都是對象,都繼承自Object類。List<int>
表示包含int類型的列表,List<dynamic>
則表示包含任意類型的列表。DartVM的內存分配策略很是簡單,建立對象時只須要在現有堆上移動指針,內存增加始終是線形的,省去了查找可用內存段的過程:
Dart中相似線程的概念叫作Isolate,每一個Isolate之間是沒法共享內存的,因此這種分配策略能夠讓Dart實現無鎖的快速分配。
Dart的垃圾回收也採用了多生代算法,新生代在回收內存時採用了「半空間」算法,觸發垃圾回收時Dart會將當前半空間中的「活躍」對象拷貝到備用空間,而後總體釋放當前空間的全部內存:
整個過程當中Dart只須要操做少許的「活躍」對象,大量的沒有引用的「死亡」對象則被忽略,這種算法也很是適合Flutter框架中大量Widget重建的場景。
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更好地在不一樣平臺上提供原生的用戶體驗。
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
和其三個經常使用的子類RenderParagraph
、RenderImage
、RenderFlex
則是具體佈局和繪製邏輯的實現類。
在Flutter界面渲染過程分爲三個階段:佈局、繪製、合成,佈局和繪製在Flutter框架中完成,合成則交由引擎負責。
控件樹中的每一個控件經過實現RenderObjectWidget#createRenderObject(BuildContext context) → RenderObject
方法來建立對應的不一樣類型的RenderObject
對象,組成渲染對象樹。由於Flutter極大地簡化了佈局的邏輯,因此整個佈局過程當中只須要深度遍歷一次:
渲染對象樹中的每一個對象都會在佈局過程當中接受父對象的Constraints
參數,決定本身的大小,而後父對象就能夠按照本身的邏輯決定各個子對象的位置,完成佈局過程。子對象不存儲本身在容器中的位置,因此在它的位置發生改變時並不須要從新佈局或者繪製。子對象的位置信息存儲在它本身的parentData
字段中,可是該字段由它的父對象負責維護,自身並不關心該字段的內容。同時也由於這種簡單的佈局邏輯,Flutter能夠在某些節點設置佈局邊界(Relayout boundary),即當邊界內的任何對象發生從新佈局時,不會影響邊界外的對象,反之亦然:
佈局完成後,渲染對象樹中的每一個節點都有了明確的尺寸和位置,Flutter會把全部對象繪製到不一樣的圖層上:
由於繪製節點時也是深度遍歷,能夠看到第二個節點在繪製它的背景和前景不得不繪製在不一樣的圖層上,由於第四個節點切換了圖層(由於「4」節點是一個須要獨佔一個圖層的內容,好比視頻),而第六個節點也一塊兒繪製到了紅色圖層。這樣會致使第二個節點的前景(也就是「5」)部分須要重繪時,和它在邏輯上絕不相干可是處於同一圖層的第六個節點也必須重繪。爲了不這種狀況,Flutter提供了另一個「重繪邊界」的概念:
在進入和走出重繪邊界時,Flutter會強制切換新的圖層,這樣就能夠避免邊界內外的互相影響。典型的應用場景就是ScrollView,當滾動內容重繪時,通常狀況下其餘內容是不須要重繪的。雖然重繪邊界能夠在任何節點手動設置,可是通常不須要咱們來實現,Flutter提供的控件默認會在須要設置的地方自動設置。
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
全部的實現類則負責提供配置信息並建立具體的RenderObjectElement
。Element
是Flutter用來分離控件樹和真正的渲染對象的中間層,控件用來描述對應的element屬性,控件重建後可能會複用同一個element。RenderObjectElement
持有真正負責佈局、繪製和碰撞測試(hit test)的RenderObject
對象。
StatelessWidget
和StatefulWidget
並不會直接影響RenderObject
的建立,它們只負責建立對應的RenderObjectWidget
,StatelessElement
和StatefulElement
也是相似的功能。
它們之間的關係以下圖:
若是控件的屬性發生了變化(由於控件的屬性是隻讀的,因此變化也就意味着從新建立了新的控件樹),可是其樹上每一個節點的類型沒有變化時,element樹和render樹能夠徹底重用原來的對象(由於element和render object的屬性都是可變的):
可是,若是控件樹種某個節點的類型發生了變化,則element樹和render樹中的對應節點也須要從新建立:
在調研了Flutter的各項特性和實現原理以後,外賣計劃灰度上線Flutter版的全品類頁面。對於將Flutter頁面做爲App的一部分這種集成模式,官方並無提供完善的支持,因此咱們首先須要瞭解Flutter是如何編譯、打包而且運行起來的。
最簡單的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構建出的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代碼。
瞭解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」 | |
建議添加發布全部配置功能,方便調試 | - | 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同樣的能力:
WMImage
控件來加載,實際是經過轉換成FileImage並自動設置scale爲devicePixelRatio來加載。這樣就能夠同時解決APK包大小和圖片資源缺失1x圖的問題。
咱們只用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); }),
Flutter官方只提供了四種CPU架構的SO庫:armeabi-v7a、arm64-v8a、x86和x86-64,其中x86系列只支持Debug模式,可是外賣使用的大量SDK都只提供了armeabi架構的庫。雖然咱們能夠經過修改引擎src
根目錄和third_party/dart
目錄下build/config/arm.gni
,third_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實現(右)的全品類頁面在實際使用過程當中幾乎分辨不出來:
可是咱們還須要在性能方面有一個比較明確的數據對比。
咱們最關心的兩個頁面性能指標就是頁面加載時間和頁面渲染速度。測試頁面加載速度能夠直接使用美團內部的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源自咱們對大前端實踐的從新思考,以簡潔高效的架構達成高性能的頁面渲染目標。在實踐中,甚至能夠把Native技術向Picasso技術的遷移當作一種性能優化手段;與此同時,Picasso在跨越小程序端和Web端方面的工做已經取得了突破性進展,有望在四端(Android、iOS、H五、微信小程序)統一大前端實踐的基礎之上,達成高性能大前端實踐,同時配合Picasso佈局DSL強表達能力和Picasso代碼生成技術,能夠進一步提高生產力。
2007年,蘋果公司第一代iPhone發佈,它的出現「從新定義了手機」,並開啓了移動互聯網蓬勃發展的序幕。Android、iOS等移動技術,打破了Web應用開發技術即將一統江湖的局面,以後海量的應用如雨後春筍般涌現出來。移動開發技術給用戶提供了更好的移動端使用和交互體驗,但其「靜態」的開發模式卻給須要快速迭代的互聯網團隊帶來了沉重的負擔。
客戶端開發技術與Web端開發技術相比,天生帶有「靜態」的特性,咱們能夠從空間和時間兩個維度來看。
從空間上看須要集成發佈,美團App承載業務衆多,是跨業務合流,橫向涉及開發人員最多的公司,雖然開發人員付出了巨大的心血完成了業務間的組件化解耦拆分,但依然無可避免的形成了如下問題:
從時間上看須要集中發佈,線上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應用程序開發者使用基於通用編程語言的佈局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針對移動端主流的佈局引擎和系統作了系統的對比分析,這些系統包括:
其中蘋果官方推出的AutoLayout缺少一個好用的DSL,因此咱們直接將移動開發者社區貢獻的AutoLayout DSL方案列入對比。
首先從性能上看,AutoLayout系統是表現最差的,隨着需求複雜度的增長「佈局計算」耗時成指數級的增加。FlexBox和LinearLayout相比較AutoLayout而言會在性能表現上有較大優點。可是LinearLayout和FlexBox會讓開發者爲了佈局方面須要的概念增長沒必要要的視圖層級,進而帶來渲染性能問題。
從靈活性上看,LinearLayout和FlexBox佈局有很強的概念約束。一個強調線性排布,一個強調盒子模式、伸縮等概念,這些模型在佈局需求和模型概念不匹配時,就不得不借助編程語言進行干預。而且因爲佈局系統的隔離,這樣的干預並不容易作,必定程度上影響了佈局的靈活性和表達能力。而配合基於通用編程語言設計的DSL加上AutoLayout的佈局邏輯,能夠得到理論上最強的靈活性。可是這三個佈局系統都在試圖解決「用聲明式的方式表達佈局邏輯的問題」,基於編程語言的DSL的引入讓佈局計算引擎變得多餘。
Picasso佈局DSL的核心在於:
使用錨點概念能夠簡單清晰的設置非同一個座標軸方向的兩個錨點「錨定」好的視圖位置。同時錨點能夠提供描述「相對」位置關係語義支持。事實上,針對佈局的需求更符合人類思惟的描述是相似於「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生態貢獻了動畫能力、動態模塊能力、複用Web容器橋接基建能力、大量業務組件和通用組件。
Picasso團隊除了持續維護Picasso SDK,Picasso持續集成系統、包括基於VSCode的斷點調試,Liveload等核心開發工具鏈,還爲集團提供了統一的分發系統,爲集團內部大前端團隊開展Picasso動態化實踐奠基了堅實的基礎。
到發稿時,集團內部Picasso應用領先的BG已經實現Picasso動態化技術覆蓋80%以上的業務開發,相信通過更長時間的孵化,Picasso會成爲美團移動開發技術的「神兵利器」,助力公司技術團隊實現高速發展。
列舉Picasso在美團的部分應用案例:
Picasso在實踐客戶端動態化的方向取得了成功,解決了傳統客戶端「靜態」研發模式致使的種種痛點。總結下來:
至此Picasso並無中止持續創新的腳步,目前Picasso在Web端和微信小程序端的適配工做已經有了突破性進展,正如Picasso在移動端取得的成就同樣,Picasso會在完成四端統一(Android、iOS、Web、小程序)的同時,構建出更快、更強的大前端實踐。
業界對大前端融合的將來有不少想象和憧憬,Picasso動態化實踐已經開啓大前端將來的一種新的可能。
EasyReact 是一款基於響應式編程範式的客戶端開發框架,開發者能夠使用此框架輕鬆地解決客戶端的異步問題。
目前 EasyReact 已在美團和大衆點評客戶端的部分業務中實踐,而且持續迭代了一年多的時間。近日,咱們決定開源這個項目的 iOS Objective-C 語言部分,但願可以幫助更多的開發者不斷探索更普遍的業務場景,也歡迎更多的社區的開發者跟咱們一塊兒增強 EasyReact 的功能。Github 的項目地址,參見 https://github.com/meituan-dianping/EasyReact。
美團 iOS 客戶端團隊在業界比較早地使用響應式來解決項目問題,爲此咱們引入了 ReactiveCocoa 這個函數響應式框架(相關實踐,參考以前的 系列博客)。隨着業務的急速擴張和團隊拆分變動,ReactiveCocoa 在解決異步問題的同時也帶來了新的挑戰,總結起來有如下幾點:
既然響應式編程帶來了這麼多的麻煩,是否咱們應該摒棄響應式編程,用更通俗易懂的面向對象編程來解決問題呢?這要從移動端開發的特色提及。
客戶端程序自己充滿異步的場景。客戶端的主要邏輯就是從視圖中處理控件事件,經過網絡獲取後端內容再展現到視圖上。這其中事件的處理和網絡的處理都是異步行爲。
通常客戶端程序發起網絡請求後程序會異步的繼續執行,等待網絡資源的獲取。一般咱們還會須要設置必定的標誌位和顯示一些加載指示器來讓視圖進行等待。可是當網絡進行獲取的時候,通知、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 層(見下圖)。
仔細觀察調用棧,咱們發現整個調用棧的內容極爲類似,難以從中發現問題所在。
另外異步場景更是給調試增長了新的難度。不少時候,數據的變化是由其餘隊列派發過來的,咱們甚至沒法在調用棧中追溯數據變化的來源。
業內不少人使用 FRP 框架來解決 MVVM 架構中的綁定問題。在業務實踐中不少操做是高度類似且可被泛化的,這也意味着,能夠被腳手架工具自動生成。
但目前業內知名的框架並無提供相應的工具,最佳實踐也沒法「模板化」地傳遞下去。這就致使了對於 MVVM 和響應式編程,你們有了各自不一樣的理解。
EasyReact 的誕生,其初心是爲了解決 iOS 工程實現 MVVM 架構但沒有對應的框架支撐,而致使的風格不統1、可維護性差、開發效率低等多種問題。而 MVVM 中最重要的一個功能就是綁定,EasyReact 就是爲了讓綁定和響應式的代碼變得 Easy 起來。
它的目標就是讓開發者可以簡單的理解響應式編程,而且簡單的將響應式編程的優點利用起來。
EasyReact 先是基於 Objective-C 開發。而 Objective-C 是一門古老的編程語言,在 2014 年蘋果公司推出 Swift 編程語言以後,Objective-C 已經基本再也不更新,而 Swift支持的 Tuple 類型和集合類型自帶的 map
、filter
等方法會讓代碼更清晰易讀。 在 EasyReact Objective-C 版本的開發中,咱們還衍生了一些周邊庫以支持這些新的代碼技巧和語法糖。這些周邊庫現已開源,而且能夠獨立於 EasyReact 使用。
EasyTuple 使用宏構造出相似 Swift 的 Tuple 語法。使用 Tuple 能夠讓你在須要傳遞一個簡單的數據架構的時,沒必要手動爲其建立對應的類,輕鬆的交給框架解決。
EasySequence 是一個給集合類型擴展的庫,能夠清晰的表達對一個集合類型的迭代操做,而且經過巧妙的手法能夠讓這些迭代操做使用鏈式語法拼接起來。同時 EasySequence 也提供了一系列的 線程安全
和 weak
內存管理的集合類型用以補充系統容器沒法提供的功能。
EasyFoundation 是上述 EasyTuple 和 EasySequence 以及將來底層依賴庫的一個統一封裝。
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 中,進行與前面 ReactiveCocoa 一樣的 5 次簡單變換,其調用棧只有 15 層(見下圖)。
通過觀察不難發現,調用棧的順序剛好就是變換的行爲。這是由於咱們將每種操做定義成一個邊的類型,使得調用棧能夠經過類名進行簡單的分析。
爲了方便調試,咱們提供了一個 - [EZRNode graph]
方法。任意一個節點調用這個方法均可以獲得一段 GraphViz 程序的 DotDSL 描述字符串,開發者能夠經過 GraphViz 工具觀察節點的關係,更好的排查問題。
使用方式以下:
macOS 安裝 GraphViz 工具 brew install graphviz
打印 -[EZRNode graph]
返回的字符串或者 Debug 期間在 lldb 調用 -[EZRNode graph]
獲取結果字符串,並輸出保存至文件如 test.dot
使用工具分析生成圖像 circo -Tpdf test.dot -o test.pdf && open test.pdf
結果示例:
另外咱們還開發了一個帶有錄屏而且能夠動態查看應用程序中全部節點和邊的調試工具,後期也會開源。開發中的工具是這樣的:
EasyReact 幫助咱們解決了很多難題,遺憾的是它也不是「銀彈」。在實際的項目實施中,咱們發現僅僅經過 EasyReact 仍然很難讓你們在開發的過程當中風格上統一塊兒來。固然它從寫法上要比 ReactiveCocoa 上統一了一些,可是構建數據流仍然有着多種多樣的方式。
因此咱們想到經過一個上層的業務框架來統一風格,這也就是後續衍生項目 EasyMVVM 誕生的緣由,不久後咱們也會將 EasyMVVM 進行開源。
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)
其中測試的規模爲:
例如 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 時,咱們一般會把這些屬性包裝爲 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 系列框架的架構簡圖:
將來咱們還有提供更多框架能力,開源給你們:
名稱 | 描述 |
---|---|
EasyDebugToolBox | 動態節點狀態調試工具 |
EasyOperation | 基於行爲和操做抽象的響應式庫 |
EasyNetwork | 響應式的網絡訪問庫 |
EasyMVVM | MVVM 框架標準和相關工具 |
EasyMVVMCLI | EasyMVVM 項目腳手架工具 |
EasyReact 的設計基於面向對象,因此很容易在各個語言中實現,咱們也正在積極的在 Swift、Java、JavaScript 等主力語言中實現 EasyReact。
另外動態化做爲目前行業的趨勢,Easy 系列天然不會忽視。在 EasyReact 基於圖的架構下,咱們能夠很輕鬆的讓一個 Objective-C 的上游節點經過一個特殊的橋接邊鏈接到一個 JavaScript 節點,這樣就能夠讓部分的邏輯動態下發過來。
數據傳遞和異步處理,是大部分業務的核心。EasyReact 從架構上用響應式的方式來很好的解決了這個問題。它有效地組織了數據和數據之間的聯繫, 讓業務的處理流程從命令式編程方式,變成以數據流爲核心的響應式編程方式。用先構建數據流關係再響應觸發的方法,讓業務方更關心業務的本質。使廣大開發者從瑣碎的命令式編程的狀態處理中解放出來,提升了生產力。EasyReact 不只讓業務邏輯代碼更容易維護,也讓出錯的概率大大降低。
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在用戶手機裏都經歷了什麼。通常來講,傳統的日誌蒐集方法存在如下缺陷:
美團點評集團內部,移動端日誌種類已經超過20種,並且隨着業務的不斷擴張,這一數字還在持續增長。特別是上文中提到的三個缺陷,也會被無限地進行放大。
查問題是個苦力活,不必定全部的日誌都上報在一個系統裏,對於開發者來講,可能須要在多個系統中查看不一樣種類的日誌,這大大增長了開發者定位問題的成本。若是咱們天天上班都看着疑難Bug掛着沒法解決,確實會很難受。這就像一個偵探遇到了疑難的案件,當他用盡各類手段收集線索,依然一無所得,那種心情可想而知。咱們收集日誌復現用戶Bug的思路和偵探破案的思路很是類似,經過蒐集的線索儘量拼湊出相對完整的犯案場景。若是按照這個思路想下去,目前咱們並無什麼更好的方法來處理這些問題。
不過,雖然偵探破案和開發者查日誌解決問題的思路很像,但實質並不同。咱們處理的是Bug,不是真實的案件。換句話說,由於咱們的「死者」是可見的,那麼就能夠從它身上獲取更多信息,甚至和它進行一次「靈魂的交流」。換個思路想,以往的操做都是經過各類各樣的日誌拼湊出用戶出現Bug的場景,那可不能夠先獲取到用戶在發生Bug的這段時間產生的全部日誌(不採樣,內容更詳細),而後聚合這些日誌分析出(篩除無關項)用戶出現Bug的場景呢?
新的思路重心從「日誌」變爲「用戶」,咱們稱之爲「個案分析」。簡單來講,傳統的思路是經過蒐集散落在各系統的日誌,而後拼湊出問題出現的場景,而新的思路是從用戶產生的全部日誌中聚合分析,尋找出現問題的場景。爲此,咱們進行了技術層面的嘗試,而新的方案須要在功能上知足如下條件:
咱們還須要在技術上知足如下條件:
在這種背景下,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對日誌進行數據可視化時,嘗試利用圖形方式進行語義分析簡稱爲時間軸。
每行表明着一種日誌類型。同一日誌類型有着多種圖形、顏色,他們標識着不一樣的語義。
例如時間軸中對代碼級日誌進行了日誌類別的區分:
利用顏色差別,能夠輕鬆區分出錯誤的日誌,點擊紅點便可直接跳轉至錯誤日誌詳情。
結合用戶信息,經過Logan前端系統查找用戶的日誌。打開日誌詳情,首先使用時間定位功能,快速跳轉到出問題時的日誌,結合該日誌上下文,可獲得當時App運行狀況,大體推斷問題發生的緣由。接着利用日誌篩選功能,查找關鍵Log對可能出問題的地方逐一進行排查。最後結合代碼,定位問題。
固然,在實際上排查中問題比這複雜多,咱們要反覆查看日誌、查看代碼。這時還可能要藉助一下Logan高級功能,如時間軸,經過時間軸可快速找出現異常的日誌,點擊時間軸上的圖標可跳轉到日誌詳情。經過網絡日誌中的Trace信息,還能夠查看該請求在後臺服務詳細的響應棧狀況和後臺響應值。
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基礎日誌庫應運而生。
目前,業內移動端日誌庫大多都存在如下幾個問題:
首先,日誌模塊做爲底層的基礎庫,對上層的性能影響必須儘可能小,可是日誌的寫操做是很是高頻的,頻繁在Java堆裏操做數據容易致使GC的發生,從而引發應用卡頓,而頻繁的I/O操做也很容易致使CPU佔用太高,甚至出現CPU峯值,從而影響應用性能。
其次,日誌丟失的場景也很常見,例如當用戶的App發生了崩潰,崩潰日誌還來不及寫入文件,程序就退出了,但本次崩潰產生的日誌就會丟失。對於開發者來講,這種狀況是很是致命的,由於這類日誌丟失,意味着沒法復現用戶的崩潰場景,不少問題依然得不到解決。
第三點,日誌的安全性也是相當重要的,絕對不能隨意被破解成明文,也要防止網絡被劫持致使的日誌泄漏。
最後一點,對於移動應用來講,日誌確定不止一種,通常會包含端到端日誌1、代碼日誌、崩潰日誌、埋點日誌這幾種,甚至會更多。不一樣種類的日誌都具備各自的特色,會致使日誌比較分散,查一個問題須要在各個不一樣的日誌平臺查不一樣的日誌,例如端到端日誌還存在日誌採樣,這無疑增長了開發者定位問題的成本。
面對美團點評幾十億量級的移動端日誌處理場景,這些問題會被無限放大,最終可能致使日誌模塊不穩定、不可用。然而,Logan應運而生,漂亮地解決了上述問題。
Logan,名稱是Log和An的組合,表明個體日誌服務的意思,同時也是金剛狼大叔的大名。通俗點說,Logan是美團點評移動端底層的基礎日誌庫,能夠在本地存儲各類類型的日誌,在須要時能夠對數據進行回撈和分析。
Logan具有兩個核心能力:本地存儲和日誌撈取。做爲基礎日誌庫,Logan已經接入了集團衆多日誌系統,例如端到端日誌、用戶行爲日誌、代碼級日誌、崩潰日誌等。做爲移動應用的幕後英雄,Logan天天都會處理幾十億量級的移動端日誌。
做爲一款基礎日誌庫,在設計之初就必須考慮如何解決日誌系統現存的一些問題。
I/O是比較耗性能的操做,寫日誌須要大量的I/O操做,爲了提高性能,首先要減小I/O操做,最有效的措施就是加緩存。先把日誌緩存到內存中,達到必定大小的時候再寫入文件。爲了減小寫入本地的日誌大小,須要對數據進行壓縮,爲了加強日誌的安全性,須要對日誌進行加密。然而這樣作的弊端是:
Logan的解決方案是經過Native方式來實現日誌底層的核心邏輯,也就是C編寫底層庫。這樣作不光能解決Java GC問題,還作到了一份代碼運行在Android和iOS兩個平臺上。同時在C層實現流式的壓縮和加密數據,能夠減小CPU峯值,使程序運行更加順滑。並且先壓縮再加密的方式壓縮率比較高,總體效率較高,因此這個順序不能變。
加緩存以後,異常退出丟失日誌的問題就必須解決,Logan爲此引入了MMAP機制。MMAP是一種內存映射文件的方法,即將一個文件或者其它對象映射到進程的地址空間,實現文件磁盤地址和進程虛擬地址空間中一段虛擬地址的一一對應關係。MMAP機制的優點是:
引入MMAP機制以後,日誌丟失問題獲得了有效解決,同時也提高了性能。不過這種方式也不能百分百解決日誌丟失的問題,MMAP存在初始化失敗的狀況,這時候Logan會初始化堆內存來作日誌緩存。根據咱們統計的數據來看,MMAP初始化失敗的狀況僅佔0.002%,已是一個小几率事件了。
日誌文件的安全性必須獲得保障,不能隨意被破解,更不能明文存儲。Logan採用了流式加密的方式,使用對稱密鑰加密日誌數據,存儲到本地。同時在日誌上傳時,使用非對稱密鑰對對稱密鑰Key作加密上傳,防止密鑰Key被破解,從而在網絡層保證日誌安全。
針對日誌分散的狀況,爲了保證日誌全面,須要作本地聚合存儲。Logan採用了自研的日誌協議,對於不一樣種類的日誌都會按照Logan日誌協議進行格式化處理,存儲到本地。當須要上報的時候進行集中上報,經過Logan日誌協議進行反解,還原出不一樣日誌的本來面貌。同時Logan後臺提供了聚合展現的能力,全面展現日誌內容,根據協議綜合各類日誌進行分析,使用時間軸等方式展現不一樣種日誌的重要信息,使得開發者只須要經過Logan平臺就能夠查詢到某一段時間App到底產生了哪些日誌,能夠快速復現問題場景,定位問題並處理。
關於Logan平臺是如何展現日誌的,下文會再進行說明。
首先,看一下Logan的總體架構圖:
Logan自研的日誌協議解決了日誌本地聚合存儲的問題,採用先壓縮再加密的順序,使用流式的加密和壓縮,避免了CPU峯值,同時減小了CPU使用。跨平臺C庫提供了日誌協議數據的格式化處理,針對大日誌的分片處理,引入了MMAP機制解決了日誌丟失問題,使用AES進行日誌加密確保日誌安全性,而且提供了主動上報接口。Logan核心邏輯都在C層完成,提供了跨平臺支持的能力,在解決痛點問題的同時,也大大提高了性能。
Logan做爲日誌底層庫,須要考慮上層傳入日誌過大的狀況。針對這樣的場景,Logan會作日誌分片處理。以20k大小作分片,每一個切片按照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消息,受到一些條件影響:
若是沒法喚醒App,只有在用戶再次進入App時,Push通道創建後才能收到推送消息,以上是致使Logan日誌回撈會有延遲或收不到的根本緣由,從分析能夠看出,Logan系統回撈的最大瓶頸在於Push系統。那麼可否拋開Push系統獲得Logan日誌呢?先來看一下使用日誌回撈方式的典型場景:
其中最大的障礙在於Push觸達用戶。那麼主動上報的設計思路是怎樣的呢?
經過在App中主動調用上報接口,用戶直接上報日誌的方式,稱之爲Logan的主動上報。主動上報的優點很是明顯,跳過了Push系統,讓用戶在須要的時候主動上報Logan日誌,開發者不再用爲不能及時撈到日誌而煩惱,在用戶投訴以前就已經拿到日誌,便於更高效地分析解決問題。
Logan基礎日誌庫自2017年9月上線以來,運行很是穩定,大大提升了集團移動開發工程師分析日誌、定位線上問題的效率。
Logan平臺時間軸日誌展現:
Logan日誌聚合詳情展現:
做爲基礎日誌庫,Logan目前已經接入了集團衆多日誌系統:
如今,Logan已經接入美團、大衆點評、美團外賣、貓眼等衆多App,日誌種類也更加豐富。
目前,Logan只有移動端版本,支持Android/iOS系統,暫不支持H5的日誌上報。對於純JS開發的頁面來講,一樣有日誌分散、問題場景復現困難等痛點,也迫切須要相似的日誌底層庫。咱們計劃統一H5和Native的日誌底層庫,包括日誌協議、聚合等,Logan的H5 SDK也在籌備中。
Logan平臺的日誌展現方式,咱們還在探索中。將來計劃對日誌作初步的機器分析與處理,能針對某些關鍵路徑給出一些分析結果,讓開發者更專一於業務問題的定位與分析,同時但願分析出用戶的行爲是否存在風險、惡意請求等。
本文給你們講述了美團點評移動端底層基礎日誌庫Logan的設計、架構與特點,Logan在解決了許多問題的同時,也會帶來新的問題。日誌文件不能無限大,目前Logan日誌文件最大限制爲10M,遇到大於10M的狀況,應該如何處理最佳?是丟掉前面的日誌,仍是丟掉追加的日誌,仍是作分片處理呢?這是一個值得深思的問題。
美團是全球最大的互聯網+生活服務平臺,爲3.2億活躍用戶和500多萬的優質商戶提供一個鏈接線上與線下的電子商務服務。秉承「幫你們吃得更好,生活更好」的使命,咱們的業務覆蓋了超過200個品類和2800個城區縣網絡,在餐飲、外賣、酒店旅遊、麗人、家庭、休閒娛樂等領域具備領先的市場地位。
隨着各業務的蓬勃發展,大衆點評移動研發團隊從當初各自爲戰的「小做坊」已經發展成爲能夠協同做戰的、擁有千人規模的「正規軍」。咱們的移動項目架構爲了適應業務發展也發生了天翻地覆的變化,這對移動持續集成提出更高的要求,而整個移動研發團隊也迎來了新的機遇和挑戰。
當前移動客戶端的組件庫超過600個,多個移動項目的代碼量達到百萬行級別,天天有幾百次的發版集成需求。保證近千名移動研發人員順利進行開發和集成,這是咱們部門的重要使命。可是,前進的道路歷來都不是平坦的,在通向目標的大道上,咱們還面臨着不少問題與挑戰,主要包括如下幾個方面:
上圖僅僅展現了咱們移動項目中一小部分組件間的依賴關係,能夠想象一下,這600多個組件之間的依賴關係,就如同一個城市複雜的道路交通網讓人眼花繚亂。這種組件間錯綜複雜的依賴關係也必然會致使兩個嚴重的問題,第一,若是某個業務須要修改代碼,極有可能會影響到其它業務,牽一髮而動全身,進而會讓不少研發同窗工做時戰戰兢兢,作項目更加畏首畏尾;第二,管理這些組件間繁瑣的依賴關係也是一件使人頭疼的事情,如今平均每一個組件的依賴數有70多個,最多的甚至達到了270多個,若是依靠人工來維護這些依賴關係,難如登天。
移動研發要完成一個完整功能需求,除了代碼開發之外,須要經歷組件發版、組件集成、打包、測試。若是測試發現Bug須要進行修復,而後再次經歷組件發版、組件集成、打包、測試,直到測試經過交付產品。研發同窗在整個過程當中須要手動提交MR、手動升級組件、手動觸發打包以及人工實時監控流程的狀態,如此研發會被頻繁打斷來跟蹤處理過程的銜接,勢必嚴重影響開發專一度,下降研發生產力。
目前大衆點評的iOS項目構建時間,從兩年前的20分鐘已經增加到如今的60分鐘以上,Android項目也從5分鐘增加到11分鐘,移動項目構建時間的增加,已經嚴重影響了移動端開發集成的效率。並且隨着業務的快速擴張,項目代碼還在持續不斷的增加。爲了適應業務的高速發展,尋求行之有效的方法來加快移動項目的構建速度,已經變得刻不容緩。
評價App的性能質量指標有不少,例如:CPU使用率、內存佔用、流量消耗、響應時間、線上Crash率、包體等等。其中線上Crash直接影響着用戶體驗,當用戶使用App時若是發生閃退,他們頗有可能會給出「一星」差評;而包體大小是影響新用戶下載App的重要因素,包體過大用戶頗有可能會對你的App失去興趣。所以,下降App線上Crash率以及控制App包體大小是每一個移動研發都要追求的重要目標。
項目依賴複雜、研發流程瑣碎、構建速度慢、App質量保證是每一個移動項目在團隊、業務發展壯大過程當中都會遇到的問題,本文將根據大衆點評移動端多年來積累的實踐經驗,一步步闡述咱們是如何在實戰中解決這些問題的。
MCI(Mobile continuous integration)是大衆點評移動端團隊多年來實踐總結出來的一套行之有效的架構體系。它能實際解決移動項目中依賴複雜、研發流程瑣碎、構建速度慢的問題,同時接入MCI架構體系的移動項目能真正有效實現App質量的提高。
MCI完整架構體系以下圖所示:
MCI架構體系包含移動CI平臺、流程自動化建設、靜態檢查體系、日誌監控&分析、信息管理配置,另外MCI還採起二進制集成等措施來提高MCI的構建速度。
咱們經過構建移動CI平臺,來保證移動研發在項目依賴極其複雜的狀況下,也能互不影響完成業務研發集成;其次咱們設計了合理的CI策略,來幫助移動研發人員走出使人望而生畏的依賴關係管理的「泥潭」。
在構建移動CI平臺的基礎上,咱們對MCI流程進行自動化建設來解決研發流程瑣碎問題,從而解放移動研發生產力。
在CI平臺保證集成正確性的狀況下,咱們經過依賴扁平化以及優化集成方式等措施來提高MCI的構建速度,進一步提高研發效率。
咱們創建一套完整自研的靜態檢查體系,針對移動項目的特色,MCI上線全方位的靜態檢查來促進App質量的提高。
咱們對MCI體系的完整流程進行日誌落地,方便問題的追溯與排查,同時經過數據分析來進一步優化MCI的流程以及監控移動App項目的健康情況。
最後,爲了方便管理接入MCI的移動項目,咱們建設了統一的項目信息管理配置平臺。
接下來,咱們將依次詳細探討MCI架構體系是如何一步步創建,進而解決咱們面臨的各類問題。
咱們對目前業內流行的CI系統,如:Travis CI、 CircleCI、Jenkins、Gitlab CI調研後,針對移動項目的特色,綜合考慮代碼安全性、可擴展性及頁面可操做性,最終選擇基於Gitlab CI搭建移動持續集成平臺,固然咱們也使用Jenkins作一些輔助性的工做。MCI體系的CI核心架構以下圖所示:
名詞解釋:
該架構的優點是可擴展性強、可定製、支持併發。首先CI服務器能夠任意擴展,除了專用的服務器能夠做爲CI服務器,普通我的PC機也能夠做爲CI服務器(缺點是性能比服務器差,任務執行時間較長);其次每一個集成任務的Pipeline是支持可定製的,託管在MCI的集成項目能夠根據自身需求定製與之匹配的Pipeline;最後,每一個集成項目的任務執行是可併發的,所以各業務線間能夠互不干擾的進行組件代碼集成。
一次完整的組件集成流程包含兩個階段:組件庫發版和向目標App工程集成。以下圖所示:
第一階段,在平常功能開發完畢後,研發提PR到指定分支,在對代碼進行Review、組件庫編譯及靜態檢查無誤後,自動發版進入組件池中。全部進入組件池中的組件都可以在不一樣App項目中複用。
第二階段,研發根據須要將組件合入指定App工程。組件A自己的正確性已經在第一階段的組件庫發版中驗證,第二階段是檢查組件A的改變是否對目標App中原有依賴它的其它組件形成影響。因此首先須要分析組件A被目標App中哪些組件所依賴,目標App工程按照各自的准入標準,對合入的組件庫進行編譯和靜態分析,待檢查無誤後,最終合入發佈分支。
經過組件發版和集成兩階段的CI流程,組件將被正確集成到目標項目中。而對於存在問題的組件則會阻擋在項目以外,所以不會影響其它業務的正常開發和發版集成,各業務研發流程獨立可控。
組件的發版和集成可否經過CI檢查,取決於組件當前的依賴以及組件自己是否與目標項目兼容。移動研發須要對組件當前依賴有足夠的瞭解才能順利完成發版集成,爲了減少組件依賴管理的複雜度,咱們設計了合理的發版集成策略來幫助移動研發走出繁瑣的版本依賴管理的困境。
每一個組件都有本身的依賴項,不一樣組件可能會依賴同一個組件,組件向目標項目集成過程當中會面臨以下一些問題:
頻繁的版本集成衝突會致使業務協同開發集成效率低下,App測試包的不穩定性會給研發追蹤問題帶來極大的困擾。問題的根源在於目標項目使用每一個組件的依賴項來進行集成。所以咱們經過在集成項目中顯示指定組件版本號以及禁止動態依賴的方式,保證了App測試包的穩定性和可靠性,同時也解決了組件版本集成衝突問題。
組件向組件池發版也同樣會涉及依賴項的管理,簡單粗暴的方法是指定全部依賴項的版本號,這樣作的好處是直觀明瞭,但研發須要對不一樣版本依賴項的功能有足夠的瞭解。正如組件集成策略中所述,集成項目中每一個組件的版本都是顯示指定而且惟一肯定的,組件中指定依賴項的版本號在集成項目中並不起做用。因此咱們在組件發版時採用自動依賴組件池中最新版本的方式。這樣設計的好處在於:
當基礎組件庫的接口和設計發生較大變化時,能夠強有力的推進業務層組件作相應適配,保證了在高度解耦的項目架構下保持高度的敏捷性。但這種能力不能濫用,須要根據業務迭代週期合理安排,並作好提早通知動員工做。
研發流程瑣碎的主要緣由是研發須要人工參與持續集成中每一步過程,一旦咱們把移動研發從持續集成過程當中解放出來,天然就能提升研發生產力。咱們經過項目集成發佈流程自動化以及優化測試包分發來優化MCI流程。
研發流程中的組件發版、組件集成與App打包都是持續集成中的標準化流程,咱們經過流程託管工具來完成這幾個步驟的自動銜接,研發同窗只需關注代碼開發與Bug修復。
流程託管工具實現方案以下:
不管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提供以下功能:
將來MCI App還會支持查詢項目集成狀態以及App發佈提醒、問題反饋,整合移動研發全流程。
移動項目在構建過程當中最爲耗時的兩個步驟分別爲組件依賴計算和工程編譯。
組件依賴計算
組件依賴計算是根據項目中指定的集成組件計算出全部相關的依賴項以及依賴版本,當項目中集成組件較多的時候,遞歸計算依賴項以及依賴版本是一件很是耗時的操做,特別是還要處理相關的依賴衝突。
工程編譯
工程編譯時間是跟項目工程的代碼量成正比的,集團業務在快速發展,代碼量也在快速的膨脹。
爲了提高項目構建速度,咱們經過依賴扁平化的方法來完全去掉組件依賴計算耗時,以及經過優化項目集成方式的手段來減小工程編譯時間。
依賴扁平化的核心思想是事先把依賴項以及依賴版本號進行顯示指定,這樣經過固定依賴項以及依賴版本就完全去掉了組件依賴計算的耗時,極大的提升了項目構建速度。與此同時,依賴扁平化還額外帶來了下面的好處:
一般組件代碼都是以源碼方式集成到目標工程,這種集成方式的最大缺點是編譯速度慢,對於上百萬行代碼的App,若是採用源碼集成的方式,工程編譯時間將超過40分鐘甚至更長,這個時間,顯然會使人崩潰。
實際上組件代碼還能夠經過二進制的方式集成到目標工程:
相比源碼方式集成,組件的二進制包都是預先編譯好的,在集成過程當中只須要進行連接無需編譯,所以二進制集成的方式能夠大幅提高項目編譯速度。
爲了進一步提升二進制集成效率,咱們還作了幾件小事:
(1)多線程下載
儘管二進制集成的方式能減小工程編譯時間,但二進制包仍是得從遠端下載到CI服務器上。咱們修改了默認單線程下載的策略,經過多線程下載二進制包提高下載效率。
(2)二進制包緩存
研發在MCI上觸發不一樣的集成任務,這些集成任務間除了升級的組件,其它使用的組件二進制包大部分是相同的,所以咱們在CI服務器上對組件二進制包進行緩存以便不一樣任務間進行共享,進一步提高項目構建速度。
咱們在MCI中採用二進制集成而且通過一系列優化後,iOS項目工程的編譯時間比原來減小60%,Android項目也比原來減小接近50%,極大地提高了項目構建效率。
除了完成平常需求開發,提升代碼質量是每一個研發的必修課。若是每一位移動研發在平時開發中能嚴格遵照移動編程規範與最佳實踐,那不少線上問題徹底能夠提早避免。事實上僅僅依靠研發自覺性,難以長期有效的執行,咱們須要把這些移動編程規範和最佳實踐切實落地成爲靜態檢查強制執行,纔能有效的將問題扼殺在搖籃之中。
靜態檢查最簡單的方式是文本匹配,這種方式檢查邏輯簡單,但存在侷限性。好比編寫的靜態檢查代碼維護困難,再者文本匹配能力有限對一些複雜邏輯的處理無能爲力。現有針對Objective-C和Java的靜態分析工具也有很多,常見的有:OCLint、FindBugs、CheckStyle等等,但這些工具定製門檻較高。爲了下降靜態檢查接入成本,咱們自主研發了一個適應MCI需求的靜態分析框架–Hades。
Hades的特色:
Hades的核心思想是對源碼生成的AST(Abstract Syntax Tree)進行結構化數據的語義表達,在此基礎上咱們就能夠創建一系列靜態分析工具和服務。做爲一個靜態分析框架,Hades並不侷限於Lint工具的製做,咱們也但願經過這種結構化的語義表達來對代碼有更深層次的理解。所以,咱們能夠藉助文檔型數據庫(如:CouchDB、MongoDB等)創建項目代碼的語義模型數據庫,這樣咱們可以經過JS的Map-Reduce創建視圖從而快速檢索咱們須要查找的內容。關於Hades的技術實現原理咱們將在後續的技術Blog中進行詳細闡述,敬請期待。
目前MCI已經上線了覆蓋代碼基本規範、非空特性、多線程最佳實踐、資源合法性、啓動流程管控、動態行爲管控等20多項靜態檢查,這些靜態檢查切實有效地促進了App代碼質量的提升。
MCI做爲大衆點評移動端持續集成的重要平臺,穩定高效是要達成的第一目標,日誌監控是推進MCI走向穩定高效的重要手段。咱們對MCI全流程的日誌進行落地,方便問題追溯與排查,如下是部分線上監控項。
經過監控分析MCI流程中每一步的執行時間,咱們能夠進行鍼對性的優化以提升集成速度。
咱們會對異常流程進行監控而且通知流程發起者,同時咱們會對失敗次數較多的Job分析緣由。一部分CI環境或者網絡問題MCI能夠自動解決,而其它因爲代碼錯誤引發的異常MCI會引導移動研發進行問題的排查與解決。
咱們對包體總大小、可執行文件以及圖片進行全方面的監控,包體變化的趨勢一目瞭然,對於包體的異常變化咱們能夠第一時間感知。
除此以外,咱們還對MCI集成成功率、二進制覆蓋率等方面作了監控,作到對MCI全流程瞭然於胸,讓MCI穩定高效的運行。
目前MCI平臺已經接入公司多個移動項目,爲了接入MCI的項目進行統一方便的信息管理,咱們建設了MCI信息管理平臺——摩卡(Mocha)。Mocha平臺的功能包含項目信息管理、配置靜態檢查項以及組件發版集成查詢。
Mocha平臺負責註冊接入MCI項目的基本信息,包含項目地址、項目負責人等,同時對各個項目的成員進行權限管理。
MCI支持不一樣項目自定義不一樣的靜態檢查項,在Mocha平臺上能夠完成項目所需靜態檢查項的定製,同時支持靜態檢查白名單的配置審覈。
Mocha平臺支持組件歷史發版集成的記錄查詢,方便問題的排查與追溯。
做爲移動集成項目的可視化配置系統,Mocha平臺是MCI的一個重要補充。它使得移動項目接入MCI變得簡單快捷,將來Mocha平臺還會加入更多的配置項。
本文從大衆點評移動項目業務複雜度出發,詳細介紹了構建穩定高效的移動持續集成系統的思路與最佳實踐方案,解決項目依賴複雜所帶來的問題,經過依賴扁平化以及二進制集成提高構建速度。在此基礎上,經過自研的靜態檢查基礎設施Hades下降靜態檢查准入的門檻,幫助提高App質量;最後MCI提供的全流程託管能力能顯著提升移動研發生產力。
目前MCI爲iOS、Android原生代碼的項目集成已經提供了至關完善的支持。此外,MCI還支持Picasso項目的持續集成,Picasso是大衆點評自研的高性能跨平臺動態化框架,專一於橫跨iOS、Android、Web、小程序四端的動態化UI構建。固然移動端原生項目的持續集成和動態化項目的持續集成有共通也有不少不一樣之處。將來MCI將在移動工程化領域進一步探索,爲移動端業務蓬勃發展保駕護航。
Crash率是衡量一個App好壞的重要指標之一,若是你忽略了它的存在,它就會愈演愈烈,最後形成大量用戶的流失,進而給公司帶來沒法估量的損失。本文講述美團外賣Android客戶端團隊在將App的Crash率從千分之三作到萬分之二過程當中所作的大量實踐工做,拋磚引玉,但願可以爲其餘團隊提供一些經驗和啓發。
面對用戶使用頻率高,外賣業務增加快,Android碎片化嚴重這些問題,美團外賣Android App如何持續的下降Crash率,是一項極具挑戰的事情。經過團隊的全力全策,美團外賣Android App的平均Crash率從千分之三降到了萬分之二,最優值萬一左右(Crash率統計方式:Crash次數/DAU)。
美團外賣自2013年建立以來,業務就以指數級的速度發展。美團外賣承載的業務,從單一的餐飲業務,發展到餐飲、超市、生鮮、果蔬、藥品、鮮花、蛋糕、跑腿等十多個大品類業務。目前美團外賣日完成訂單量已突破2000萬,成爲美團點評最重要的業務之一。美團外賣客戶端所承載的業務模塊愈來愈多,產品複雜度愈來愈高,團隊開發人員日益增長,這些都給App下降Crash率帶來了巨大的挑戰。
對於Crash的治理,咱們儘可能遵照如下三點原則:
常規Crash發生的緣由主要是因爲開發人員編寫代碼不當心致使的。解決這類Crash須要由點到面,根據Crash引起的緣由和業務自己,統一集中解決。常見的Crash類型包括:空節點、角標越界、類型轉換異常、實體對象沒有序列化、數字轉換異常、Activity或Service找不到等。這類Crash是App中最爲常見的Crash,也是最容易反覆出現的。在獲取Crash堆棧信息後,解決這類Crash通常比較簡單,更多考慮的應該是如何避免。下面介紹兩個咱們治理的量比較大的Crash。
NullPointerException是咱們遇到最頻繁的,形成這種Crash通常有兩種狀況:
針對第一種狀況致使的緣由有不少,多是開發人員的失誤、API返回數據解析異常、進程被殺死後靜態變量沒初始化致使,咱們能夠作的有:
針對第二種狀況大部分是因爲Activity/Fragment銷燬或被移除後,在Message、Runnable、網絡等回調中執行了一些代碼致使的,咱們能夠作的有:
這類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。
衆所周知,Android的機型衆多,碎片化嚴重,各個硬件廠商可能會定製本身的ROM,更改系統方法,致使特定機型的崩潰。發現這類Crash,主要靠雲測平臺配合自動化測試,以及線上監控,這種狀況下的Crash堆棧信息很難直接定位問題。下面是常見的解決思路:
咱們舉一個定製系統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文件,以後經過dex2jar和jd-gui查看源碼。
OOM是OutOfMemoryError的簡稱,在常見的Crash疑難排行榜上,OOM絕對能夠名列前茅而且經久不衰。由於它發生時的Crash堆棧信息每每不是致使問題的根本緣由,而只是壓死駱駝的最後一根稻草。
致使OOM的緣由大部分以下:
內存泄漏
內存泄漏指系統未能及時釋放已經再也不使用的內存對象,通常是由錯誤的程序代碼邏輯引發的。在Android平臺上,最多見也是最嚴重的內存泄漏就是Activity對象泄漏。Activity承載了App的整個界面功能,Activity的泄漏同時也意味着它持有的大量資源對象都沒法被回收,極其容易形成OOM。
常見的可能會形成Activity泄漏的緣由有:
對於Activity泄漏,目前已經有了一個很是好用的檢測工具:LeakCanary,它能夠自動檢測到全部Activity的泄漏狀況,而且在發生泄漏時給出十分友好的界面提示,同時爲了防止開發人員的疏漏,咱們也會將其上報到服務器,統一檢查解決。另外咱們能夠在debug下使用StrictMode來檢查Activity的泄露、Closeable對象沒有被關閉等問題。
大對象
在Android平臺上,咱們分析任一應用的內存信息,幾乎均可以得出一樣的結論:佔用內存最多的對象大都是Bitmap對象。隨着手機屏幕尺寸愈來愈大,屏幕分辨率也愈來愈高,1080p和更高的2k屏已經佔了大半份額,爲了達到更好的視覺效果,咱們每每須要使用大量高清圖片,同時也爲OOM埋下了禍根。
對於圖片內存優化,咱們有幾個經常使用的思路:
AOP是面向切面編程的簡稱,在Android的Gradle插件1.5.0中新增了Transform API以後,編譯時修改字節碼來實現AOP也由於有了官方支持而變得很是方便。
在一些特定狀況下,能夠經過AOP的方式自動處理未捕獲的異常:
這類問題的解決原理大體相同,咱們以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的同窗由於某些事情,暫時沒有關注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了三種常見圖片庫的加載圖片回調方法,同時監控圖片庫加載圖片時的兩個維度:
開發過程當中,在App裏檢測到不合規的圖片時會當即高亮出錯的ImageView所在的位置並彈出對話框提示ImageView所在的Activity、XPath和加載圖片使用的URL等信息,以下圖,輔助開發同窗定位並解決問題。在Release環境下能夠將報警信息上報到服務器,實時觀察數據,有問題及時處理。
咱們發現線上的不少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。除此以外還須要一些其餘的監控,例如,以前提到的大圖監控,來避免由於大圖致使的OOM。具體的輸出形式主要有郵件通知、IM通知、報表。
儘管咱們在前面作了那麼多,可是Crash仍是沒法避免的,例如,在灰度階段由於量級不夠,有些Crash沒有被暴露出來;又或者某些功能客戶端比後臺更早上線,而這些功能在灰度階段沒有被覆蓋到;這些狀況下,若是出現問題就須要考慮如何止損了。
問題發生時首先須要評估重要性,若是問題不是很嚴重並且修復成本較高能夠考慮在下個版本再修復,相反若是問題比較嚴重,對用戶體驗或下單有影響時就必需要修復。修復時首先考慮業務降級,主要看該部分異常的業務是否有兜底或者A/B策略,這樣是最穩妥也是最有效的方式。若是業務不能降級就須要考慮熱修復了,目前美團外賣Android App接入的熱修復框架是自研的Robust,能夠修復90%以上的場景,熱修成功率也達到了99%以上。若是問題發生在熱修復沒法覆蓋的場景,就只能強制用戶升級。強制升級由於覆蓋週期長,同時影響用戶的體驗,只在萬不得已的狀況下才會使用。
咱們在作新技術選型時除了要考慮是否能知足業務需求、是否比現有技術更優秀和團隊學習成本等因素以外,兼容性和穩定性也很是重要。但面對國內非富多彩的Android系統環境,在體量百萬級以上的的App中幾乎不可能實現毫無瑕疵的技術方案和組件,因此通常狀況下若是某個技術實現方案能夠達到0.01‰如下的崩潰率,而其餘方案也沒有更好的表現,咱們就認爲它是能夠接受的。可是哪怕僅僅十萬分之一的崩潰率,也表明還有用戶受到影響,而咱們認爲Crash對用戶來講是最糟糕的體驗,尤爲是涉及到交易的場景,因此咱們必須本着每一單都很重要的原則,盡最大努力保證用戶順利執行流程。
實際狀況中有一些技術方案在兼容性和穩定性上作了必定妥協的場景,每每是由於考慮到性能或擴展性等方面的優點。這種狀況下咱們其實能夠再多作一些,進一步提升App的可用性。就像不少操做系統都有「兼容模式」或者「安全模式」,不少自動化機械機器都配套有手動操做模式同樣,App裏也能夠實現備用的降級方案,而後設置特定條件的觸發策略,從而達到自動修復Crash的目的。
舉例來說,Android 3.0中引入了硬件加速機制,雖然能夠提升繪製幀率而且下降CPU佔用率,可是在某些機型上仍是會有繪製錯亂甚至Crash的狀況,這時咱們就能夠在App中記錄硬件加速相關的Crash問題或者使用檢測代碼主動檢測硬件加速功能是否正常工做,而後主動選擇是否開啓硬件加速,這樣既能夠讓絕大部分用戶享受硬件加速帶來的優點,也能夠保障硬件加速功能不完善的機型不受影響。
還有一些相似的能夠作自動降級的場景,好比:
這類問題都須要根據具體狀況具體分析,若是能夠找到準確的斷定條件和穩定的修復方案,就能夠讓App穩定性再上一個臺階。
外賣業務發展迅速,即便咱們在開發時使用各類工具、措施來避免Crash的發生,但Crash仍是不可避免。線上某些怪異的Crash發生後,咱們除了分析Crash堆棧信息以外,還能夠使用離線日誌回撈、下發動態日誌等工具來還原Crash發生時的場景,幫助開發同窗定位問題,可是這兩種方式都有它們各自的問題。離線日誌顧名思義,它的內容都是預先記錄好的,有時候可能會漏掉一些關鍵信息,由於在代碼中加日誌通常只是在業務關鍵點,在大量的普通方法中不可能都加上日誌。動態日誌(Holmes)存在的問題是每次下發只能針對已知UUID的一個用戶的一臺設備,對於大量線上Crash的狀況這種操做並不合適,由於咱們並不能知道哪一個發生Crash的用戶還會再次復現此次操做,下發配置充滿了不肯定性。
咱們能夠改造Holmes使其支持批量甚至全量下發動態日誌,記錄的日誌等到發生特定類型的Crash時才上報,這樣一來能夠減小日誌服務器壓力,同時也能夠極大提升定位問題的效率,由於咱們能夠肯定上報日誌的設備最後都真正發生了該類型Crash,再來分析日誌就能夠作到事半功倍。
業務的快速發展,每每不可能給團隊充足的時間去治理Crash,而Crash又是App最重要的指標之一。團隊須要由一個個Crash個例,去探究每個Crash發生的最本質緣由,找到最合理解決這類Crash的方案,創建解決這一類Crash的長效機制,而不能飲鴆止渴。只有這樣,隨着版本的不斷迭代,咱們才能在Crash治理之路上離目標愈來愈近。
美團外賣平臺化複用主要是指多端代碼複用,正如美團外賣iOS多端複用的推進、支撐與思考文章所述,多端包含有兩層意思:其一是相同業務的多入口,指美團外賣業務須要在美團外賣App(下文簡稱外賣App)和美團App外賣頻道(下文簡稱外賣頻道)同時上線;其二是指平臺上各個業務線,美團外賣不一樣業務線都依賴外賣基礎服務,好比登錄、定位等。
多入口及多業務線給美團外賣平臺化複用帶來了巨大的挑戰,此前咱們的一篇博客《美團外賣Android平臺化架構演進實踐》(下文簡稱《架構演進實踐》)也提到了這個問題,本文將在「代碼複用」這一章節的基礎上,進一步介紹平臺化複用工做面臨的挑戰以及相應的解決方案。
美團外賣App和美團App外賣頻道業務基本同樣,但因爲歷史緣由,兩端代碼差別較大,形成一樣的子業務需求在一端上線後,另外一端幾乎須要從新實現,嚴重浪費開發資源。在《架構演進實踐》一文中,將美團外賣Android客戶端平臺化架構分爲平臺層、業務層和宿主層,咱們但願可以在平臺化架構中實現平臺層和業務層的多端複用,從而節省子業務需求開發資源,實現多端部署。
兩端業務雖然基本一致,可是仍舊存在差別,UI、基礎服務、需求差別等。這些差別存在於美團外賣平臺化架構中的平臺層和業務層各個模塊中,給平臺化複用帶來了巨大的挑戰。咱們總結了兩端代碼的差別點,主要包括如下幾個方面:
前期,咱們嘗試經過一些設計方案來繞過上述差別,從而實現兩端的代碼複用。咱們選擇了二級頻道頁(下文統稱金剛頁)進行方案嘗試,設計以下:
其中,KingKongDelegate是Activity生命週期實現的代理類,包含onCreate、onResume等Activity生命週期回調方法。在外賣App和外賣頻道兩端分別基於各自的基礎Activity實現WMKingKongAcitivity和MTKingKongActivity,分別會經過調用KingKongDelegate的方法對Activity的生命週期進行分發。
KingKongInjector是兩端差別部分的接口集合,包括頁面跳轉(兩端頁面差別)、獲取頁面刷新間隔時間、默認資源等,在外賣App和外賣頻道分別有對應的接口實現WMKingKongInjector和MTKingKongInjector。
NetworkController則是用Retrofit實現統一的網絡請求封裝,PageListController是對列表分頁加載邏輯以及頁面空白、網絡加載失敗等異常邏輯處理。
在金剛頁設計方案中,咱們採用了「代理+繼承」的方式,實現了用統一的網絡庫實現網絡請求,定義了統一的基礎數據Model,統一了部分基礎服務以及基礎數據。經過KingKongDelegate屏蔽了兩端基礎Acitivity的差別,同時,經過KingKongInjector實現了兩端差別部分的處理。可是咱們發現這種設計方案存在如下問題:
經過代碼複用初步嘗試總結,咱們總結出平臺化複用,須要考慮四件事情:
咱們在實現平臺化架構的基礎上,通過不斷的探索,最終造成適合外賣業務的平臺化複用設計:總體分爲基礎服務層-基礎組件層-業務層-宿主層。設計圖以下:
分層架構可以實現各層功能的職責分離,同時,咱們要求上層不感知下層的多端差別。在各層中進行組件劃分,一樣,咱們也要求實現調用組件方不感知組件的多端差別。經過這樣的設計,可以使得總體架構更加清晰明朗,複用率提升的同時,不影響架構的複雜度和靈活度。
須要多端複用的業務相對於普通業務而言,最大的挑戰在於差別化管理。首先多端的先天條件就決定了多端複用業務會存在差別;其次,多端複用的業務有個性化的需求。在多端複用的差別化管理方案中,咱們總結了如下兩種方案:
分支管理經常使用於多個需求在一端上線後,須要在另外一端某一個時間節點跟進的場景,以下圖所示:
兩端開發1.0版本時,分別要在wm分支(外賣App對應分支)開發feature1和mt分支(外賣頻道對應分支)開發feature2。開發2.0版本時,feature1須要在外賣頻道上線,feature2須要在外賣App上線,則分別將feature1分支代碼合入mt分支,feature2代碼合入wm分支。這樣經過拉取新需求分支管理的方式,知足了需求的差別化管理。可是這種實現方式存在兩個問題:
在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方式來實現代碼差別化管理。其優點以下:
從Android工程結構來看,使用Flavor只能在module內複用,可是以module爲粒度的複用對於差別化管理來講約束過重。這意味着同個module內不一樣模塊的差別代碼同時存在於對應Flavor目錄下,或者說須要將每一個子模塊都建立成不一樣的module,這樣管理代碼是很是不便的。《微信Android模塊化架構重構實踐》一文中提到了一個重要的概念pins工程,pins工程能在module以內再次構建完整的多子工程結構。咱們經過創造性的使用pins工程+Flavor的方案,將差別化的管理單元從module降到了pins工程。而pins工程能夠定義到最小的業務單元,例如一個Java文件。總體的設計實現以下:
具體的配置過程,首先須要在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%以上。將來,咱們可能會在外賣平臺、美團平臺、大衆點評平臺三個平臺進行代碼複用,其場景將會更加複雜。固然,咱們在作平臺化複用的時候,要合理地進行評估,複用帶來的「成本節約」和爲了複用帶來的「成本增長」之間的比率。另外,平臺化複用視角不該該侷限於業務頁面的複用,對於監控、測試、研發工具、運維工具等也能夠進行復用,這也是平臺化複用理念的核心價值所在。
美團外賣自2013年建立以來,業務一直高速發展。目前美團外賣日完成訂單量已突破1800萬,成爲美團點評最重要的業務之一。美團外賣的用戶端入口,從單一的外賣獨立App,拓展爲外賣、美團、點評等多個App入口。美團外賣所承載的業務,也從單一的餐飲業務,發展到餐飲、超市、生鮮、果蔬、藥品、鮮花、蛋糕、跑腿等十多個大品類業務。業務的快速發展對客戶端架構不斷提出新的挑戰。
很早以前,外賣做爲孵化中的項目只有美團外賣App(下文簡稱外賣App)一個入口,後來外賣做爲一個子頻道接入到美團App(下文簡稱外賣頻道),兩端業務並行迭代開發。早期爲了快速上線,開發同窗直接將外賣App的代碼拷貝出一份到外賣頻道,作了簡單的適配就很快接入到美團App了。
早期外賣App和外賣頻道由兩個團隊分別維護,而在隨後一段時間裏,兩端代碼體系差別愈來愈來大。最後演變成了從網絡、圖片等基礎庫到UI控件、類的命名等都不盡相同的兩套代碼。儘管後來兩個團隊合併到一塊兒,但歷史的差別已經造成,爲了優先知足業務需求,很長一段時間內,咱們只能在兩套代碼的基礎上不斷堆積更多的功能。維護兩套代碼的成本可想而知,而業務的迅猛發展又使得這一問題愈加不可忍受。
在咱們探索解決兩端代碼複用的同時,業務的發展又對咱們提出新的挑戰。隨着團隊成員擴充了數倍,商超生鮮等垂直品類的拆分,以及異地研發團隊的創建,外賣客戶端的平臺化被提上日程。而在此以前,外賣App和外賣頻道基本保持單工程開發,這樣的模式顯然是沒法支持多團隊協做開發的。所以,咱們須要快速將代碼重構爲支持平臺化的多工程模式,同時還要考慮業務模塊的解耦,使得新業務能夠拷貝現有的代碼快速上線。此外,在實施平臺化的過程當中,兩端代碼複用的問題尚未解決,若是兩端的代碼沒有統一而直接作平臺化業務拆庫,必然會致使問題的複雜化。
在這樣的背景下,能夠看出咱們面臨的問題相較於其餘平臺型App更爲特殊和複雜:既要解決外賣業務平臺化的問題,又要解決外賣App和外賣頻道兩端代碼複用的問題。
在實施平臺化和兩端代碼複用的道路上並不是一路順風,不少方案只有在嘗試以後才知道問題所在。咱們屢次遇到這樣的狀況:設計方案完成後,團隊已經全身心投入到開發之中,可是因爲業務形態發生變化,原有的設計也被迫更改。在不斷的探索和實踐過程當中,咱們經歷了多箇中間階段。雖然有很多失敗的案例,可是也積累了不少架構設計上的寶貴經驗,整個團隊對業務和架構也有了更深的理解。
早期美團外賣App和美團外賣頻道兩個團隊的合併,帶來的最大痛點是代碼複用,而非平臺化,而在很長的一段時間內,咱們也沒有想過從平臺化的角度去解決兩端代碼複用的問題。然而代碼複用的一些失敗嘗試,給後續平臺化的架構帶來了很多寶貴的經驗。當時是怎麼解決代碼複用問題的呢?咱們經過和產品、設計同窗的溝通,約定了將來的需求,會從需求內容、交互、樣式上,兩端儘量的保持一致。通過屢次討論後,團隊發起了兩端代碼複用的技術方案嘗試,咱們決定將搜索模塊從主工程拆分出來,並實現兩端代碼複用。然而兩端的搜索模塊代碼底層差別很大,BaseActivity和BaseFragment不統一,UI樣式不統一,數據Model不統一,圖片、網絡、埋點不統一,而且兩端發版週期也不一致。針對這些問題的解決方案是:
架構大體如圖:
雖然搜索庫在短時間內拆分爲獨立的工程,並實現了絕大部分的兩端代碼複用,可是好景不長,僅僅更新過幾個版本後,因爲需求和版本發佈週期的差別,搜索庫開始變爲兩個分支,而且兩個分支的差別愈來愈大,最後代碼沒法合併而不得不永久維護兩個搜索庫。搜索庫事實上是一次失敗的拆分,其中的問題總結起來有三個:
在經歷過搜索庫的失敗拆分後,你們認爲目前還不具有實現模塊總體拆分和複用的條件,所以咱們走向了另外一個方向,即實現頁面的組件化以達成部分組件複用的目標。頁面組件化的設計思路是:
頁面組件化是一個良好的設計,但它主要適用於解決Activity巨大化的問題。因爲底層差別巨大的狀況,使得頁面組件化很難實現大規模的複用,複用效率低。另外一方面,頁面組件化也沒有爲2端差別性預留可伸縮的空間。
咱們還嘗試過運用設計模式解決兩端代碼複用的問題。想法是將代碼分爲易變的和穩定的兩部分,易變部分在兩端上層實現差別化處理,穩定部分能夠在下層實現複用。方案的主要設計思路是:
架構大體如圖:
這是一種靈活、優雅的設計,可以實現部分代碼的複用,並能解決兩端基礎庫和UI等差別。這個方案在首頁和二級頻道頁的部分模塊使用了一段時間,可是由於學習成本較高等緣由推廣比較緩慢。另外,這個時期平臺化已被提上日程,業務痛點決定了咱們必須快速實施模塊總體的拆分和複用,而優雅的設計模式並不適合解決這一類問題。即便從複用性的角度來看,這樣的設計也會使得業務開發變得更爲複雜、調試困難,對於新人來講難以勝任,最終推廣落地困難。
經過屢次實踐,咱們認識到要實現兩端代碼複用,基礎庫的統一是必然的工做,是其餘一切工做的基礎。不然必然致使複雜和難以維護的設計,最終致使兩端複用沒法快速推動下去。
計算機界有一句名言:「計算機科學領域的任何問題均可以經過增長一箇中間層來解決。」(原始版本出自計算機科學家David Wheeler)咱們固然有想過經過中間層設計屏蔽兩端的基礎庫差別。例如網絡庫,外賣App基於Volley實現,外賣頻道基於Retrofit實現。咱們曾經在Volley和Retrofit之上封裝了一層網絡框架,對外暴露統一的接口,上層能夠切換底層依賴Volley或是Retrofit。但這個中間層並無上線,最終咱們將兩端的網絡庫統一成了Retrofit。這裏面有多個緣由:首先Retrofit自己就是較高層次的封裝,而且擁有優雅的設計模式,理論上咱們很難封裝一套擴展性更強的接口;其次長期來看底層網絡框架變動的風險極低,而且適配網絡層的各類插件也是一件費時費力的事情,所以保持網絡中間層的性價比極低;此外將兩端的網絡請求都替換爲中間層接口,顯然工做量遠大於只保留一端的依賴。
經過實踐咱們認識到,中間層設計是一把雙刃劍。若是基礎框架自己的擴展性足夠強,中間層設計就顯得畫蛇添足,甚至喪失了原有框架的良好特性。
好的架構源於不停地衍變,而非設計。對於外賣Android客戶端的平臺化架構構建也是經歷了一樣的過程。咱們從考慮如何解決代碼複用的問題,逐漸的衍變成如何去解決代碼複用和平臺化的兩個問題。而實際上外賣平臺化正是解決兩端代碼複用的一劑良藥。咱們經過創建外賣平臺,將現有的外賣業務降級爲一個頻道,將外賣業務以aar的形式分別接入到外賣平臺和美團平臺,這樣在解決外賣平臺化的同時,代碼複用的問題也將獲得完美的解決。
通過了整整一年的艱苦奮鬥,造成了如圖所示的美團外賣Android客戶端平臺化架構:
從底層到高層依次爲平臺層、業務層和宿主層。
在構建平臺化架構的過程當中,咱們遇到這樣一個問題,如何長久的維持咱們平臺化架構的層級邊界。試想,若是全部的代碼都在一個工程裏面開發,經過包名、約定去規範層級邊界,任何一個緊急的需求均可能破壞層級邊界。維持層級邊界的最好辦法是什麼?咱們的經驗是工程隔離。平臺化的每一層都去作工程隔離,業務層的每一個業務都創建本身的工程庫,實現工程隔離。同時,配套編譯腳本,檢查業務庫之間是否存在相互依賴關係。工程隔離的好處是顯而易見的:
但工程隔離帶來的另外一個問題是,同層間的業務庫須要通訊怎麼辦?這時候就須要提供業務庫通訊框架來解決這個問題。
在拆分外賣商家業務庫的時候,咱們就發這樣一個案例:在商家頁有一個業務,當發現當前商家是打烊的,就會彈出一個浮層,推薦類似的商家列表,而在咱們以前劃分的外賣子業務庫裏面,類似商家列表應該是屬於頁面庫裏面的內容。那怎麼讓商家業務庫訪問到頁面庫裏面的代碼呢。若是咱們將商家庫去依賴頁面庫,那咱們的層級邊界就會被打破,咱們的依賴關係也會變得複雜。所以咱們須要在架構中提供同層間的通訊框架,它去解決不打破層級邊界的狀況下,完成同層間的通訊。
彙總同層間通訊的場景,大體上能夠劃分爲:頁面的跳轉、基本數據類型的傳遞(包括可序列化的共有類對象的傳遞)、模塊內部自定義方法和類的調用。針對上述狀況,在咱們的架構裏面提供了二種平級間的通訊方式:scheme路由和美團自建的ServiceLoaders sdk。scheme路由本質上是利用Android的scheme原理進行通訊,ServiceLoader本質上是利用的Java反射機制進行通訊。
scheme路由的調用如圖所示:
最終效果:全部業務頁面的跳轉,都須要經過平臺層的scheme路由去分發。經過scheme路由,全部業務都獲得解耦,再也不須要相互依賴而能夠實現頁面的跳轉和基本數據類型的傳遞。
serviceloader的調用如圖所示:
提供方和使用方經過平臺層的一個接口做爲雙方交互的約束。使用方經過平臺層的ServiceLoader完成提供方的實現對象獲取。這種方式能夠解決模塊內部自定義方法和類的調用,例如咱們以前提到了商家庫須要調用頁面庫代碼的問題就能夠經過ServiceLoader解決。
在實踐的過程當中,咱們也遇到業務自己上就很差劃分層級邊界的業務。你們能夠從美團外賣三層架構圖上,看出外賣業務庫,像商家、訂單等,是和外賣的垂類業務庫是同級的。而實際上外賣業務的子業務是否應該和垂類業務保持同層是一個目前沒法肯定的事情。
目前,外賣接入的垂類業務商超業務,是隸屬於外賣業務的子頻道,它依然依賴着外賣的核心model、核心服務,包括商品管理、訂單管理、購物車管理等,所以目前它和外賣業務的商家、訂單這樣的子業務庫同層是沒有問題的。但隨着商超業務的發展,商超業務將來可能會建設本身的商品管理、訂單管理、購物車管理的服務,那麼到時商超業務就會上升到和外賣業務同樣同層的業務。這時候,外賣核心管理服務,處在平臺層,就會致使架構的層級邊界變得再也不清晰。
咱們的解決辦法是經過設計一個屬於外賣業務的內核模塊來適應將來的變化,內核模塊的設計如圖:
若是將來肯定外賣平臺須要接入更多和外賣平級的業務,且最內圈都徹底不同,咱們將把外賣內核模塊上移,在外賣業務子庫下創建對內核模塊的依賴;若是將來只是有更多的外賣子業務的接入,那就繼續保留咱們如今的架構;若是將來接入的業務基礎模型類同樣,但本身的業務服務須要分化,那麼咱們將對保留內核模塊最核心的內圈,並抽象出服務層由外賣和商超上層本身實現真正的服務。
在拆分業務庫的時候,咱們面臨着這樣的問題:業務之間的關係是較爲複雜的,如何去拆分業務庫,纔是較爲合理的呢?一開始咱們準備根據外賣業務核心流程:頁面→商家→下單,去拆分外賣業務。可是隨着外賣子頻道業務的快速發展,子頻道業務也創建了本身的研發團隊,在頁面、商家、下單等環節,也開始創建本身的頁面。若是咱們仍然按照外賣下單的流程去拆分庫,那在同一個庫之間,就會有外賣團隊和外賣子頻道團隊共同開發的狀況,這樣職責邊界很不清晰,在實際的開發過程當中,確定會出現理不清的狀況。
咱們都知道軟件工程領域有所謂的康威定律:
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工程的邊界都是清楚的,實現了工程內的代碼隔離。工程內代碼隔離帶來的好處顯而易見:
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,若是業務庫也須要依賴平臺庫源碼,也要作相似的操做。以下圖所示:
這樣手動操做會帶來兩個問題:
鑑於這種需求具有通用性,咱們開發了一個Gradle插件,經過主工程的一個配置文件(被git ignore),可一鍵切換至源碼依賴。例如須要源碼依賴商家庫,那麼只須要在主工程中將該庫的源碼依賴開關打開便可。商家庫還依賴平臺庫,默認也是aar依賴,若是想改爲源碼依賴,也只需把開關打開便可。
業務庫增多之後,構建流程也變得複雜起來,咱們交付的產物有兩種:外賣App的apk和外賣頻道的aar。外賣App的狀況會簡單一些,在Jenkins上關聯各個業務庫指定分支的源碼,直接打包便可。而外賣頻道的狀況則比較複雜,由於受到美團平臺的一些限制,頻道打包不能直接關聯各個業務庫的源碼,只能依賴aar。按照傳統作法,須要逐個打業務庫的aar,而後統一在頻道工程中集成,最後再打頻道aar,這樣效率實在過低。爲此,咱們改進了頻道的打包流程。以下圖所示:
先打平臺庫aar,打完後自動提PR到各個業務庫去修改平臺庫的版本號,接着再逐個觸發業務庫去打aar,業務庫打完aar以後再自動提PR到頻道主庫去修改業務庫的版本號,等所有業務庫aar打完後最後再自動觸發打頻道主庫的aar,至此一鍵打包完畢。
從搜索庫拆分的第一次嘗試算起,外賣Android客戶端在架構上的持續探索和實踐已經經歷了2年多的時間。起初爲了解決兩端代碼複用的問題,咱們嘗試過自上而下的強行拆分和複用,但很快就暴露出層次混亂、邊界模糊帶來的問題,而且認識到若是不能提供兩端差別化的解決方案,代碼複用是很難持續的。後來咱們又嘗試過運用設計模式約束邊界,先實現解耦再進行復用,但在推廣落地過程當中認識到複雜的設計很難快速推動下去。
在平臺化開始的時候,團隊已經造成了設計簡單、邊界清晰的架構理念。咱們將總體結構劃分爲宿主層、業務層、平臺層,並嚴格約束層次間的依賴關係。在業務模塊拆分的過程當中,咱們借鑑微信的工程結構方案,按照三級工程結構劃分業務邊界,實現靈活的代碼隔離,並下降了後續模塊遷出和遷入成本,使得架構動態知足康威定律。
在兩端代碼複用的問題上,咱們認識到要實現可持續的代碼複用,必須自下向上的逐步統一兩端底層的基礎依賴,同時又能容易的支持兩端上層業務的差別化處理。使用Flavor管理兩端的差別代碼,儘可能減小向上依賴,在具體實施時應用以前積累的解耦設計的經驗,從而知足了架構的可伸縮性。
沒有一個方案能得到每一個人的贊同。在平臺化的實施過程當中,團隊成員屢次對方案選型發生過針鋒相對的討論。這時咱們會拋開技術方案,回到問題自己,去從新審視業務的痛點,列出要解決的問題,再回過頭來看哪個方案可以解決問題。雖然咱們並不經常這麼作,但某些時刻也會強制決策和實施,遇到問題再覆盤和調整。
任何一種設計理念都有其適用場景。咱們在不斷關注業內一些優秀的架構和設計理念,以及公司內部美團App、點評App團隊的平臺化實踐經驗,學習和借鑑了許多優秀的設計思想,但也因爲盲目濫用踩過很多坑。咱們認識到架構的選擇正如其餘技術問題同樣,應該是面向問題的,而不是面向技術自己。架構的演進必須在理論和實踐中交替前行,脫離了其中一個談論架構,都將是個悲劇。
平臺化以後,各業務團隊的協做關係和開發流程都發生了很大轉變。在如何提高平臺支持能力,如何保持架構的穩定性,如何使得各業務進一步解耦等問題上,咱們又將面對新的問題和挑戰。其中有三個問題是亟待咱們解決的:
Lint是Google提供的Android靜態代碼檢查工具,能夠掃描並發現代碼中潛在的問題,提醒開發人員及早修正,提升代碼質量。除了Android原生提供的幾百個Lint規則,還能夠開發自定義Lint規則以知足實際須要。
在美團外賣Android App的迭代過程當中,線上問題頻繁發生。開發時很容易寫出一些問題代碼,例如Serializable的使用:實現了Serializable接口的類,若是其成員變量引用的對象沒有實現Serializable接口,序列化時就會Crash。咱們對一些常見問題的緣由和解決方法作分析總結,並在開發人員組內或跟測試人員一塊兒分享交流,幫助相關人員主動避免這些問題。
爲了進一步減小問題發生,咱們逐步完善了一些規範,包括制定代碼規範,增強代碼Review,完善測試流程等。但這些措施仍然存在各類不足,包括代碼規範難以實施,溝通成本高,特別是開發人員變更頻繁致使反覆溝通等,所以其效果有限,類似問題仍然不時發生。另外一方面,愈來愈多的總結、規範文檔,對於組內新人也產生了不小的學習壓力。
有沒有辦法從技術角度減小或減輕上述問題呢?
咱們調研發現,靜態代碼檢查是一個很好的思路。靜態代碼檢查框架有不少種,例如FindBugs、PMD、Coverity,主要用於檢查Java源文件或class文件;再例如Checkstyle,主要關注代碼風格;但咱們最終選擇從Lint框架入手,由於它有諸多優點:
在對Lint進行了充分的技術調研後,咱們根據實際遇到的問題,又作了一些更深刻的思考,包括應該用Lint解決哪些問題,怎麼樣更好的推廣實施等,逐步造成了一套較爲全面有效的方案。
爲了方便後文的理解,咱們先簡單看一下Lint提供的主要API。
Lint規則經過調用Lint API實現,其中最主要的幾個API以下:
JAVA_FILE_SCOPE
、CLASS_FILE_SCOPE
、RESOURCE_FILE_SCOPE
、GRADLE_SCOPE
等,一個Issue可包含一到多個Scope。舉例來講,原生的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, // ... ); } }
Lint開發過程當中最主要的工做就是實現Scanner。Lint中包括多種類型的Scanner以下,其中最經常使用的是掃描Java源文件和XML文件的Scanner。
值得注意的是,掃描Java源文件的Scanner前後經歷了三個版本。
本文目前仍然基於PsiJavaScanner作介紹。根據UastScanner源碼中的註釋,能夠很容易的從PsiJavaScanner遷移到UastScanner。
咱們須要用Lint檢查代碼中的哪些問題呢?
開發過程當中,咱們比較關注App的Crash、Bug率等指標。經過長期的整理總結髮現,有很多發生頻率很高的代碼問題,其原理和解決方案都很明確,可是在寫代碼時卻很容易遺漏且難以發現;而Lint剛好很容易檢查出這些問題。
Crash率是App最重要的指標之一,避免Crash也一直是開發過程當中比較頭疼的一個問題,Lint能夠很好的檢查出一些潛在的Crash。例如:
Color.parseColor()
方法解析後臺下發的顏色時,顏色字符串格式不正確會致使IllegalArgumentException,咱們要求調用這個方法時必須處理該異常。有些Bug能夠經過Lint檢查來預防。例如:
一些性能、安全相關問題能夠使用Lint分析。例如: - ThreadConstruction:禁止直接使用new Thread()
建立線程(線程池除外),而須要使用統一的工具類在公用線程池執行後臺操做。 - LogUsage:禁止直接使用android.util.Log
,必須使用統一工具類。工具類中能夠控制Release包不輸出Log,提升性能,也避免發生安全問題。
除了代碼風格方面的約束,代碼規範更多的是用於減小或防止發生Bug、Crash、性能、安全等問題。不少問題在技術上難以直接檢查,咱們經過封裝統一的基礎庫、制定代碼規範的方式間接解決,而Lint檢查則用於減小組內溝通成本、新人學習成本,並確保代碼規範的落實。例如:
當檢查出代碼問題時,如何提醒開發者及時修正呢?
早期咱們將靜態代碼檢查配置在Jenkins上,打包發佈AAR/APK時,檢查代碼中的問題並生成報告。後來發現雖然靜態代碼檢查能找出來很多問題,可是不多有人主動去看報告,特別是報告中還有過多可有可無的、優先級很低的問題(例如過於嚴格的代碼風格約束)。
所以,一方面要肯定檢查哪些問題,另外一方面,什麼時候、經過什麼樣的技術手段來執行代碼檢查也很重要。咱們結合技術實現,對此作了更多思考,肯定了靜態代碼檢查實施過程當中的主要目標:
每一個Lint規則均可以配置Sevirity(優先級),包括Fatal、Error、Warning、Information等,咱們主要使用Error和Warning,以下。
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規則。
技術細節:
在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.
在Android Studio 3.x版本中,打開Android工程源碼後,IDE會加載工程中的自定義Lint規則,在設置菜單的Inspections列表裏能夠查看,和原生Lint效果相同(Android Studio會在打開源文件時觸發對該文件的代碼檢查)。
分析自定義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 }
利用git pre-commit hook,能夠在本地commit代碼前執行Lint檢查,檢查不經過則沒法提交代碼。這種方式的優點在於不影響開發時的編譯速度,但發現問題相對滯後。
技術實現方面,能夠編寫Gradle腳本,在每次同步工程時自動將hook腳本從工程拷貝到.git/hooks/
文件夾下。
做爲代碼提交流程規範的一部分,發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檢查。
綜合考慮多種檢查方式的優缺點以及咱們的目標,最終肯定結合如下幾種方式作代碼檢查:
爲了方便代碼管理,咱們給自定義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規則開發過程當中,咱們發現了一系列類似的需求:封裝了基礎工具類,但願你們都用起來;某個方法很容易拋出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" }] } }
示例配置中定義了兩種類型的模板規則:
問題API的匹配,包括方法調用(method)、成員變量引用(field)、構造函數(construction)、繼承(super-class)等類型;匹配字符串支持glob語法或正則表達式(和lint.xml中ignore的配置語法一致)。
實現方面,主要是遍歷Java語法樹中特定類型的節點並轉換成完整字符串(例如方法調用android.content.Intent.getIntExtra
),而後檢查是否有模板規則與其匹配。匹配成功後,DeprecatedApi規則直接輸出message報錯;HandleException規則會檢查匹配到的節點是否處理了特定Exception(或Exception的父類),沒有處理則報錯。
隨着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相關技術細節還能夠閱讀我的博客:
美團是全球領先的一站式生活服務平臺,爲6億多消費者和超過450萬優質商戶提供鏈接線上線下的電子商務網絡。美團的業務覆蓋了超過200個豐富品類和2800個城區縣網絡,在餐飲、外賣、酒店旅遊、麗人、家庭、休閒娛樂等領域具備領先的市場地位。平臺大,責任也大。在移動端,如何快速定位並解決線上問題提升用戶體驗給咱們帶來了極大挑戰。線上偶爾會發生某一個頁面打不開、新活動搶單按鈕點擊沒響應、登陸不了、不能下單等現象,因爲Android碎片化、網絡環境、機型ROM、操做系統版本、本地環境複雜多樣,這些個性化的使用場景很難在本地復現,加上問題反饋的時候描述的每每都比較模糊,快速定位並解決問題難度不小。爲此,咱們開發了動態日誌系統Holmes,但願它能像大偵探福爾摩斯那樣幫咱們順着線上bug的蛛絲馬跡,發現背後真相。
現有的解決辦法
現有辦法的弊端
目標訴求
針對難定位的線上問題,動態日誌提供了一套快速定位問題的方案。預先在用戶手機自動產生方法執行的日誌信息,當須要排查用戶問題時,經過信令下發精準回撈用戶日誌,再現用戶操做路徑;動態日誌系統也支持動態下發代碼,從而實現動態分析運行時對象快照、動態增長埋點等功能,可以分析複雜使用場景下的用戶問題。
自動埋點是線上App自動產生日誌,怎麼樣自動產生日誌呢?咱們對方法進行了插樁來記錄方法執行路徑(調用堆棧),在方法的開頭插入一段樁代碼,當方法運行的時候就會記錄方法簽名、進程、線程、時間等造成一條完整的執行信息(這裏咱們叫TraceLog),將TraceLog存入DB等待信令下發回撈數據。
public void onCreate(Bundle bundle) { //插樁代碼 if (Holmes.isEnable(....)) { Holmes.invoke(....); return; } super.onCreate(bundle); setContentView(R.layout.main); }
歷史數據
Tracelog造成的是代碼的歷史執行路徑,一旦線上出現問題就能夠回撈用戶歷史數據來排查問題,而且Tracelog有如下幾個優勢:
Tracelog工做的流程
方法運行產生方法調用日誌首先會通過checker進行檢測,checker包含線程檢測和方法檢測(減小信息干擾),線程檢測主要過濾相似於定時任務這種一直在不斷的產生日誌的線程,方法檢測會在必定時間內檢測方法調用的頻率,過濾掉頻繁調用的方法,方法若是不會被過濾就會進行異步處理,其次向對象池獲取一個Tracelog對象,Tracelog對象進入生產隊列組裝時間、線程、序列號等信息,完成後進入消費隊列,最後消費隊列到達固定數量以後批量處理存入DB。
Tracelog數據展現
日誌回撈到Trace平臺上按時間順序排列展現結果:
問題總結
咱們的平臺部署實施了幾個版本,總結了不少的案例。通過實戰的考驗發現多數的場景下用戶回撈Tracelog分析問題只能把問題的範圍不斷的縮小,可是不少的問題肯定了是某一個方法的異常,這個時候是須要知道方法的執行信息好比:入參、當前對象字段、返回值等信息來肯定代碼的執行邏輯,只有Tracelog在這裏的感受就比如只差臨門一腳了,怎麼才能獲取方法運行時產生的內存快照呢?這正是體現動態日誌的動態性能力。
對目標用戶下發信令,動態執行一段代碼並將結果上報,咱們利用Lua腳本在方法運行的時候去獲取對象的快照信息。爲何選擇Lua?Lua運行時庫很是小而且能夠調用Java代碼並且語言精簡易懂。動態執行Lua有三個重要的時機:當即執行、方法前執行、方法後執行。
在方法後執行Lua腳本遇到了一些問題,咱們只在方法前插樁,若是在方法後也插樁這樣能解決在方法後執行的問題,可是這樣增長代碼體積和影響proguard內聯方法數,如何解決這個問題以下:
咱們利用反射執行當前方法,當進入方法前面的插樁代碼不會直接執行本方法的方法體會在樁代碼裏經過反射調用本身,這樣就作到了一個動態AOP的功能就能夠在方法以後執行腳本,一樣這種方法也存在一個問題,就是會出現死循環,解決這個問題的辦法只須要在執行反射的時候標記是反射調用進來的就能夠避免死循環的問題。
咱們還能夠讓腳本作些什麼呢?除了能夠獲取對象的快照信息外,還增長了DB查詢、上報普通文本、ShardPreferences查詢、獲取Context對象、查詢權限、追加埋點到本地、上傳文件等綜合能力,並且Lua腳本的功能遠不只如此,能夠利用Lua腳本調用Java的方法來模擬代碼邏輯,從而實現更深層次的動態能力。
動態下發數據展現
動態日誌在開發的過程中遇到了不少的技術難點,咱們在實施方案的時候遇到不少的問題,下面來回顧一下問題及解決方案。
數據量大的問題
主線程卡頓
建立對象過多致使頻繁GC
干擾日誌太多影響分析問題
性能影響
對每個方法進行插樁記錄日誌,會對代碼會形成方法耗時的影響嗎?最終咱們在中低端機型上分別測試了方法的耗時和CPU的使用佔比。
CPU的耗時測試在5%之內,以下圖所示:
內存的使用測試在56kB左右,以下圖:
對象快照
在方法運行時獲取對象快照保留現場日誌,提取對象快照就須要對一個對象進行深度clone(爲了防止在尚未完整記錄下來信息以前對象已經被改變,影響最終判斷代碼執行的結果),在Java中clone對象有如下幾種方法:
clone接口和序列化接口都有一樣的一個問題,有可能這個對象沒有實現相應的接口,這樣是沒法進行深度clone的,並且實現clone接口也作不到深度clone,Java序列化有IO問題執行效率很低。最後可能只有Gson序列化這個方法還可行,可是Gson也有不少的坑,若是一個對象中有和父類同樣的字段,那麼Gson在作序列的時候把父類的字段覆蓋掉;若是兩個對象有相互引用的場景,那麼在Gson序列化的時候直接會死循環。
怎麼解決以上的這些問題呢?最後咱們參照一些開源庫的方案和Java系統的一些API,開發出了一個深度clone的庫,再加上本身定義數據對象和使用Gson來解決對象快照的問題。深度clone實現主要利用了Java系統API,先建立出來一個目標對象的空殼對象,而後利用反射將原對象上的全部字段都複製到這個空殼對象上,最後這個空殼對象會造成跟原有對象徹底同樣的東西,同時對Android增長的一些類型進行了特殊處理,在提升速度上對基本類型、集合、map等系統自帶類型作了快速處理,clone完成的對象直接進行快照處理。
動態日誌對業務開發零成本,對用戶使用無打擾。在排查線上問題時,方法執行路徑可能直接就會反映出問題的緣由,至少也能縮小問題代碼的範圍,最終鎖定到某一個方法,這時再使用動態下發Lua腳本,最終肯定問題代碼的位置。動態日誌的動態下發功能也能夠作爲一種基礎的能力,提供給其餘須要動態執行代碼或動態獲取數據的基礎庫,例如:遇到一些難解決的崩潰場景,除了正常的棧信息外,同時也能夠根據不一樣的崩潰類型,動態採集一些其餘的輔助信息來幫助排查問題。
對於Android系統來講,消息傳遞是最基本的組件,每個App內的不一樣頁面,不一樣組件都在進行消息傳遞。消息傳遞既能夠用於Android四大組件之間的通訊,也可用於異步線程和主線程之間的通訊。對於Android開發者來講,常用的消息傳遞方式有不少種,從最先使用的Handler、BroadcastReceiver、接口回調,到近幾年流行的通訊總線類框架EventBus、RxBus。Android消息傳遞框架,總在不斷的演進之中。
EventBus是一個Android事件發佈/訂閱框架,經過解耦發佈者和訂閱者簡化Android事件傳遞。EventBus能夠代替Android傳統的Intent、Handler、Broadcast或接口回調,在Fragment、Activity、Service線程之間傳遞數據,執行方法。
EventBus最大的特色就是:簡潔、解耦。在沒有EventBus以前咱們一般用廣播來實現監聽,或者自定義接口函數回調,有的場景咱們也能夠直接用Intent攜帶簡單數據,或者在線程之間經過Handler處理消息傳遞。但不管是廣播仍是Handler機制遠遠不能知足咱們高效的開發。EventBus簡化了應用程序內各組件間、組件與後臺線程間的通訊。EventBus一經推出,便受到廣大開發者的推崇。
如今看來,EventBus給Android開發者世界帶來了一種新的框架和思想,就是消息的發佈和訂閱。這種思想在其後不少框架中都獲得了應用。
訂閱發佈模式定義了一種「一對多」的依賴關係,讓多個訂閱者對象同時監聽某一個主題對象。這個主題對象在自身狀態變化時,會通知全部訂閱者對象,使它們可以自動更新本身的狀態。
RxBus不是一個庫,而是一個文件,實現只有短短30行代碼。RxBus自己不須要過多分析,它的強大徹底來自於它基於的RxJava技術。響應式編程(Reactive Programming)技術這幾年特別火,RxJava是它在Java上的實做。RxJava天生就是發佈/訂閱模式,並且很容易處理線程切換。因此,RxBus憑藉區區30行代碼,就敢挑戰EventBus江湖老大的地位。
在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的實現:
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); } }
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(); } }
LiveData是Android Architecture Components提出的框架。LiveData是一個能夠被觀察的數據持有類,它能夠感知並遵循Activity、Fragment或Service等組件的生命週期。正是因爲LiveData對組件生命週期可感知特色,所以能夠作到僅在組件處於生命週期的激活狀態時才更新UI數據。
LiveData須要一個觀察者對象,通常是Observer類的具體實現。當觀察者的生命週期處於STARTED或RESUMED狀態時,LiveData會通知觀察者數據變化;在觀察者處於其餘狀態時,即便LiveData的數據變化了,也不會通知。
例如:當Activity處於後臺狀態時,是不會收到LiveData的任何事件的。
Android Architecture Components的核心是Lifecycle、LiveData、ViewModel 以及 Room,經過它能夠很是優雅的讓數據與界面進行交互,並作一些持久化的操做,高度解耦,自動管理生命週期,並且不用擔憂內存泄漏的問題。
因爲LiveData具備生命週期感知能力,因此LiveDataBus只須要調用註冊回調方法,而不須要顯示的調用反註冊方法。這樣帶來的好處不只能夠編寫更少的代碼,並且能夠徹底杜絕其餘通訊總線類框架(如EventBus、RxBus)忘記調用反註冊所帶來的內存泄漏的風險。
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方法引發的,那麼就不回調真正的訂閱者。
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