騰小云導讀
今年是 QQ 空間誕生的第十八年,空間客户端團隊也在它十八歲生日前夕完成了架構升級。因為以前不規範的多團隊協同開發,導致代碼逐漸劣化,有着巨大的風險。於是 QQ 空間面對龐大的歷史債務,選擇了重構升級,不破不立。這裏和大家分享一下在重構過程中遇到的問題和解題思路,歡迎閲讀。
目錄
1 空間重構項目的背景
2 為什麼要重構
3 空間的架構是如何崩壞的
4 架構的生命力
5 漸進式重構如何實現
6 如何保證架構的擴展性與複用性7 如何降低複雜度並長期可控
8 如何防止劣化
9 性能優化
10 項目重構成果總結
11 展望
18年前,QQ 空間上線,迅速風靡全網,成為了很多人的青春回憶。18年後的今天,QQ 空間的生命力依然強勁,是很多年輕用户的首選社交平台。
而作為最老牌的互聯網產品之一,QQ 空間的代碼也比較陳舊,代碼運行環境複雜,維護成本高,整體架構亟需一場升級。
01、空間重構項目的背景
作為一個平台型的入口,空間承擔了為很多兄弟業務引流的責任,許多團隊在空間的代碼裏協作開發。加上自身多年累積的功能迭代,空間的業務變得非常複雜。業務的複雜帶來了架構的複雜,架構的複雜意味着維護成本的升高。多年來空間的業務交接頻繁,多個團隊接手。交到我們團隊手上時,空間的代碼已經一言難盡。
這裏先簡單介紹一下空間的業務形態:空間目前主要的入口是在手 Q 裏,我們叫做結合版。同時獨立版的空間 App 還在維護(沒錯,空間獨立 App 仍然還有一批忠實觀眾)。可以看到,空間有一套獨立於手 Q 之外的架構,結合版與獨立版會共用大量技術組件和業務組件。
02、為什麼要重構?
空間是一個祖上很闊的業務,代碼量非常龐大,單統計結合版的代碼,就超過了150w 行。同時空間的代碼運行環境也極為複雜,涉及5個進程和2個插件。隨着頻繁的交接和多團隊的協同開發,空間的代碼逐漸劣化,各項代碼質量的指標幾乎都在手 Q 裏墊底。
空間的代碼成了著名的原始森林 - 進得去出不來。代碼的劣化導致歷史 bug 難以收斂,即使一行代碼不改,每個版本也會新增歷史 bug30+。
面對如此龐大的歷史債務,空間已經到了寸步難行,不破不立的地步,重構勢在必行。所以,藉着空間 UI 升級的契機,空間團隊開始空間歷史上最大規模的一次重構。
03、空間的架構是如何逐步劣化的?
跳出棋局,站在今天的角度回頭看,可以發現空間的代碼是個典型案例,很好地展示了一個乾淨的架構是如何逐步劣化的。
3.1 擴展性低,異化代碼無處安放
結合版與獨立版涉及大量的代碼複用,包括組件、頁面和跨 App 的複用等。但由於前期架構擴展性不高,導致異化的業務代碼無處安放,開始侵入底層技術組件。底層組件代碼開始受到污染。
3.2 代碼未隔離且缺乏編程範式
空間是個平台型的業務,廣告、會員、遊戲、直播、小世界等團隊都會在空間的代碼裏開發。由於沒有做好代碼隔離,各團隊的代碼耦合在一起,各寫各的。同時由於缺乏編程範式,同一個類中的代碼風格迥異。破窗效應發生,污染開始擴散。
3.3 維護成本暴增,惡性循環
空間的業務邏輯本身就很複雜,代碼的劣化使其複雜度暴增,後續接手團隊已有心無力,只能縫縫補補又三年,惡性循環。
最後陷入怪圈: 代碼很亂但是穩定,開發道理都懂但確實不敢動。
3.4 Feeds 流的崩壞
以空間的 Feeds 流為例,最開始的架構思路是很清楚的,核心功能在基類實現,上層業務可以低成本地開發一個新的 Feeds 流頁面。同時做了很多動態化和容器化的設計,來滿足迭代效率。
但後續的需求迅速膨脹,異化出18種 Feeds 流場景,單 Feeds 流可能出現60多種卡片。這導致基類代碼與 Feed View 中的代碼迅速膨脹。同時 N 個團隊在同一批代碼中開發,代碼行數和圈複雜度逐漸劣化。
04、架構的生命力
痛定思痛,在進行空間重構前的首件事情就是總結經驗,避免重蹈覆轍。如何保證這次重構平穩落地並且避免後續每三年一重構?
我們總結了四點:
| 漸進式重構:高速公路換輪胎,如何平穩落地? 提高擴展性和複用性:是否能低成本遷移到其他業務,甚至是其他 App? 複雜度長期可控:n 個團隊跑來做兩年需求,複雜度會不會變高? 做好防劣化:劣化代碼被引入,能否快速發現? |
|---|
空間的重構都圍繞着這四個問題來進行。
05、漸進式重構如何實現?
作為一個億級日活的業務,空間出現線上問題很容易引起大量投訴。高速公路換輪胎,小步快跑是最合適的方式。因此,平穩落地的關鍵是漸進式重構,避免步子邁得太大導致工作量擴散。
要做到漸進式重構,核心是保證兩點:
| 一個複雜的大問題能被分解為許多個小問題,可針對小問題重構和回滾; 系統隨時都是可用狀態。每解決一個小問題,都可以針對性的測試和上線。 |
|---|
為了實現以上兩點,我們基於以下幾點來進行改造:
5.1 先拆解,後治理
我們並沒有立即開始對舊代碼進行重寫,而是先基於團隊的 RFW-Part 框架對老代碼進行拆解。Part 自帶生命週期,可以保證老代碼平移前後的運行邏輯一致。
儘管代碼邏輯沒有翻新,但大問題被拆解為一個個小問題,我們再根據優先級對單個 Part 進行重構。保證無論重構了多少,空間都是可用狀態,能立即上線驗證。
RFW-Part 框架後文會有介紹,此處不做展開。
5.2 架構融合
我們徹底拋棄了空間老的技術組件,與團隊內部沉澱的 RFWComponent 進行架構融合,同時也積極接入手 Q 統一的 UI 體系。保證開發能專注於業務中間層開發。
5.3 提效前置,簡化運行環境
在進行業務重構前,我們先還了一部分技術債。包括去插件化、進程統一、工程結構優化和編譯優化等。這些工作都在業務重構前完成並上線驗證,簡化了空間代碼的運行環境,提升開發效率,保證了重構工作的敏捷性,達到了針對單點問題快速重構快速驗證的目的。
06、如何保證架構的擴展性與複用性?
擴展性和複用性是軟件工程永恆的話題。空間歷史架構並沒有很好處理這兩點,其他業務接入時難以處理異化邏輯,使異化邏輯侵入底層代碼。同時為了強行實現結合版和獨立版的代碼複用,使不同的場景耦合在一起,互相干擾。
為了提高架構的擴展性和複用性,我們重新設計了空間的架構層級。
6.1 業務層打薄,專注中間層
為了避免代碼跨層級污染,我們對架構的分層比以往更細,隔離做得更嚴格。
底層技術組件基於 RFW 框架。RFW 中的組件更乾淨,沒有任何業務侵入,能在其他 App 開箱即用。
中間層負責對 RFW 組件和手 Q 運行環境做橋接,並對底層組件進行擴展,實現一些空間相關但與具體場景無關的功能。中間層的代碼能在一週之內遷移到其他 App。
6.2 業務層打薄,專注中間層
RFWComponent 是一線開發在實際業務中沉澱出的一套組件庫,目前由空間和小世界團隊共同維護。所有組件都經過了線上業務的驗證,保證了易用性和擴展性。組件也很完整,開箱即用。
最重要的是,RFW 的核心組件都可由上層注入代理實現,這使其並不依賴於手 Q 的運行環境,也避免了業務側邏輯入侵底層代碼。
目前整套架構已在空間、小世界、頻道、基礎等團隊深度使用。空間也是第一個使用這套架構重構老代碼的業務,整個過程非常省心。
07、如何降低複雜度並長期可控?
7.1 組合代替繼承,Part + Section,拆!
什麼是 RFW-Part?RFW-Part 是團隊內部沉澱的一套頁面級的 UI 容器架構,Part 可感知頁面的生命的週期,功能在內部閉環。不同 Part 無法感知對方存在,代碼是嚴格隔離的。
但是 Part 是頁面級框架,無法解決 Feeds 流列表複雜的問題,Section 架構作為 Part 的補充,主要解決列表以及 ItemView 的拆解問題。其設計思路與 Part 框架一致。
基於 Part 和 Section 架構,我們將空間的代碼拆分為了一個個標準的集裝箱。代碼複雜度和上手難度大大降低。新人內包入職一週便可獨立開發,三天就完成了新功能此刻的消息頁。
7.2 使用 Part 架構重塑超級頁面
空間80%的流量和功能都集中在好友動態頁和個人主頁兩個 Feeds 流頁面,儘管內部已基於 mvvm 分層,但單層內的複雜度仍然過高:
以空間的好友動態頁為例,我們將頁面不同功能的代碼都拆分到一個個 Part 裏,Fragment 僅作為一個容器,負責組裝自己需要的 Part。
最終頁面被拆分為27個 Part,頁面代碼由6000多行減少到320行。很多 Part 可以直接拿去被其他 Feeds 流頁面複用。
7.3 使用 Section 框架重塑 Feeds 流
經過 Part 的改造,頁面級的功能都被拆分為子模塊。但 Feeds 流整體作為一個 Part,複雜度仍然過高,我們需要設計一套新的框架,對 Feeds 流中的卡片進一步拆解。
7.3.1 空間老的 Feeds 流框架
這裏先介紹一下空間老的 Feeds 流框架 - Ditto。
Ditto 框架魔改了 Android 原生的佈局體系。其將一個卡片按位置分為不同 Area,每個 Area 作為一個容器。不同類型的卡片根據服務端下發的數據在 Area 內部做異化。
而每個 Area 的佈局由 json 文件下發,Ditto 框架解析後使用 canvas 自繪,完成顯示。
這套架構的優勢是動態化能力強,服務端可定義任意樣式,但缺點同樣明顯:
| 代碼複雜度持續膨脹; 各業務代碼耦合; 功能代碼分散,AB 測試不友好; 難以擴展。 |
|---|
7.3.2 優化方向
為了降低複雜度,我們決定按以下方向優化:
| 中心化 -\> 去中心化; 代碼物理隔離; 內部閉環,動態開關; 組裝者模式,方便擴展。 |
|---|
7.3.3 Section 框架架構設計
和 Part 一樣,我們將一個卡片按照功能邏輯拆分為一個個 Section,形成一個 Section 池。不同卡片根據需要組裝自己需要的 Section 即可。
Section 的 UI、數據、業務都是內部閉環的。不同 Section 互不感知,保證了代碼物理隔離。
每個 Section 會與 ViewStub 綁定,佈局可以按需加載。ViewStub 與 Section 是一對多的關係,Section 在查找 ViewStub 前會先去緩存池找,這樣實現了多個 Section 修改同一個 View,保證 Section 拆得儘可能細。
上圖中各模塊的具體職責如下:
| Section:某一切片的完整 UI+邏輯; ViewStub:與 Section 一對多,按需加載; Assembler:負責組裝 Section,可根據頁面異化; SectionManager:綁定數據、分發生命週期; DataCenter:Feeds 相關數據在各頁面間的同步; IOC 框架:控制反轉,用於 Section 與頁面交互。 |
|---|
Section 整體的結構圖如下:
7.3.4 落地效果
基於這套 Feeds 流框架,我們完成了歷史卡片的梳理和重構:
| 接入36種 Feed,拆分52個 Section,下線28種 Feed; 重構4個核心頁面,單類代碼不超過500行; 單條 Feed 開發時間縮短一半; 廣告/增值團隊一個版本即完成歷史功能遷移。 |
|---|
7.4 完善通信設計,保證代碼隔離不被打破
Part 和 Section 之間會有許多通信的需求,比如數據同步,不同模塊交互等。為了保證代碼隔離不被打破,我們設計了比較完善的通信機制:
| 頁面與 Part:ViewModel + LiveData; Part 與 Part:頁面級事件,事件只在 PartHost 內部生效,無需註冊與反註冊; 頁面與頁面:DataCenter 數據同步。 |
|---|
7.5 異化邏輯抽離,複雜度持續可控
除此之外,另一種容易打穿架構的元素是異化邏輯。比如同一張卡片在不同的頁面需要顯示不同效果,比如數據埋點的參數需要從頁面最外層傳遞到 Section。針對這種跨層級通信的場景,我們設計了一套 IOC 框架來完成依賴注入,將異化邏輯拆分到了一個個 IOC 實現類中。
IOC 機制的核心是:View 樹回溯 + ViewTag 存儲 + 接口中心管理。我們註冊時將 IOC 實現類與 View 綁定,查找時基於 View 樹來回溯,保證了 O(N) 的複雜度,且可以跨越任意層級。
過去,即使傳遞一個 pageId 參數,也要一層層傳遞:
現在,層級再深我們也可以很方便拿到需要的 IOC 實現。
08、升級方案
8.1 容災設計
站在用户的角度,其實對重構與否並沒有太大感知,用户只關心穩定性是否有下降。如此大規模的重構,一行代碼引起的崩潰便能使幾個月的努力功虧一簣。我們上線前的首要目標便是保證用户使用不受影響,不求有功,但求無過。
因此,我們在上線前做了很多容災設計,保證空間的核心功能可用性。
8.1.1 動態開關
我們在空間的中間層埋了配置,能通過配置下架任意的 Part 或 Section。業務層編寫代碼時不用再單獨為每個小模塊添加開關,只要基於框架做好細粒度的拆分即可。
8.1.2 崩潰保護
同時,我們做了崩潰保護的設計,保證非核心功能崩潰不會影響核心功能的使用:
| 崩潰時進行關鍵詞匹配,達到指定頻率時禁用/降級相關功能; 自動對 Part/Section/頁面/Feed 做關鍵詞匹配,無需註冊; 非必要功能可手動註冊關鍵詞,添加保護。 |
|---|
8.2 性能監控
同時,為了防止性能劣化,我們做了很多性能監控。
針對線上:
| 利用手 Q RMonitor 框架的監控和我們自己上報的滑動流暢度指標,來監控頁面整體的流暢度; 通過在框架層打點,來監控每一個 Part、Section 或 Feed 的耗時。有劣化的模塊引入時能快速發現; 實現 RFWTracer 框架,自動在頁面啓動流程中打點,統計頁面啓動各階段的耗時。 |
|---|
針對線下:
我們基於 ARTMethodHook 框架,實現對具體 View 耗時的監控,能快速定位到出問題的控件,節約開發定位性能問題的時間。
整體監控體系如圖:
實際效果如圖:
09、性能優化
第一次灰度後,我們尷尬地發現啓動速度並沒有大幅提升,流暢率甚至發生了降低。因此我們做了首屏啓動和流暢度的專項優化:
9.1 首屏啓動優化
我們重新梳理了啓動流程中的數據處理,在啓動前和啓動後做了一定優化:
- 佈局異步渲染
我們將首屏啓動前,會根據緩存提前計算需要的佈局,實現佈局異步預加載。同時,為了保證 Context 的正確性,我們 Hook 了 Activity 的啓動流程,提前準備好空的 Activity 對象用於異步 inflate,並在啓動後綁定真實的 Context。
- 精準預加載
在首屏啓動前讀取緩存,提前計算首屏 Feed 對應的 Section 佈局並異步加載。
- 生命週期擴展
擴展 Part 生命週期,各個 Part 的次要功能在首屏展示後初始化。
- 優化後的效果
空間好友動態頁的冷啓動速度提升56%,熱啓動速度提升53%。
9.2 列表性能優化
經過分析,我們發現列表卡頓的原因集中在兩點:
| Item 複用率低,導致頻繁創建新 View; 佈局嵌套多,測量較慢。 |
|---|
解決思路:
| 邊滑邊異步 inflate:為了解決頻繁創建新 View 的問題,我們在滑動時,會提前計算後面卡片所需的 ViewStub,並提前異步加載好。 自定義組件,降低層級,提前計算高度:列表中部分組件測量性能較差,比如部分嵌套 RecyclerView 的組件,會頻繁觸發子 RecyclerView 的測量,拉高整體測量耗時。對於這些組件,我們使用自定義組件的方式進行了替換。降低佈局層級,並且提前計算高度,設置佈局的高度為固定值,防止頻繁測量。 |
|---|
優化後的效果:完成優化後,空間首頁 FPS 完成了反超,相比老版本提升了 4.9%。
10、項目重構成果總結
從我們 AB 測試的實驗數據來看,重構的整體結果是比較正向的,代碼質量提升與性能提升帶來了業務指標的提升,業務指標的提升也帶來廣告指標的提升。
11、展望
空間的代碼歷史悠久,錯綜複雜,使得空間業務在很長一段時間都處於維護狀態,難以快速開發新的需求。最大的三個模塊是壓在空間業務上的三座大山:Feeds 流、相冊和發表。通過這次架構升級,我們完成空間底層架構的煥新,完全重寫了最複雜的 Feeds 流場景,同時相冊模塊也已經重構了一半。等剩餘模塊重構完成,空間的祖傳代碼就被全部重寫了。面向未來,我們也能夠更迅速地支撐新需求的落地,讓十八歲的 QQ 空間煥然新生,重新上路。歡迎轉發分享\~
-End-
原創作者|尹述迪
QQ 空間已18年,你有什麼關於QQ空間的回憶殺?歡迎在騰訊雲開發者公眾號評論,我們將選取1則最有意義的評論(請使用加密對話),送出騰訊雲開發者-棒球帽1個(見下圖)。7月27日中午12點開獎。