作者:閒魚技術-君愛
背景
近年來,閒魚舊業務在Flutter架構升級下,大量頁面通過Flutter開發實現。業務不斷迭代,包體積也隨之增大,閒魚Android、iOS安裝包大小較去年有較大增加,其中,Flutter在閒魚包體積中佔比20%,閒魚開發逐步需要考慮進行Flutter側工程治理。Flutter官方也在為包大小不斷努力,致力於降低打包產物的大小,但仍未有成熟方案。因此現階段,我們可以考慮如何將無效代碼下線。
通過人工梳理的方式,依賴於開發人員的業務熟悉程度,難免疏漏。我們需要有準確的的線上代碼覆蓋率,作為數據依據,推動業務進行行之有效的代碼下線。
本文為您介紹,Flutter的線上代碼覆蓋率解決方案——FlutterCodeX。針對類級別編譯時代碼插粧,運行時後臺數據聚合,進行數據採集上報,獲得最終代碼覆蓋率數據,推動廢棄業務下線,達到包體瘦身,對工程健康做長效監控與改善。
插樁方案探索
在線上代碼覆蓋率的統計中,問題的難點主要在於,如何準確判斷類,是否被調用過?一般人會馬上可以想到,只需要在每個類初始化時,加入一段代碼,標記該類已經被調用,最快的就是構建函數中添加,但成本極高,有沒有自動化、無侵入的插樁方案呢?以下從iOS、Android、Flutter不同的插樁方案進行簡單的對比。
iOS
iOS中,ObjC首次調用類初始化時,+initialize被執行,系統會自動標記已被調用,在 metaClass的 data的flags字段中的 1<<29 位的這個bit RW_INITIALIZED,就記錄著類是否initialize。可以通過判斷類是否被初始化,因此在運行時,找到合適的時機,遍歷所有類,進行數據的聚合上傳。
static BOOL MOCClassIsInitilatized(Class cls) {
void *metaClass = (__bridge void *)object_getClass(cls);
class_rw_t *rw = *(class_rw_t **)((uintptr_t)metaClass + 4 * sizeof(uintptr_t));
if(((class_rw_t *)((uintptr_t)rw & FAST_DATA_MASK))->flags & RW_INITIALIZED) {
return YES;
}
return NO;
}
Android
Android中,Java語言可以不需要侵入原有代碼,以添加靜態代碼塊的形式添加插樁代碼,buildscript增加編譯插件,在編譯時遍歷所有類文件進行代碼插入即可。
public class A {
static {
// todo report class A initialize
}
}
Flutter
Flutter與Android、IOS的方案均有一定差異,Dart沒有Java的靜態代碼塊,也沒有類似ObjC的系統標記。在什麼地方插樁,可以不侵入原有代碼呢?
理論上,Dart Class初始化執行順序為:
- class variables initialize on declaration (no static)
- initializer list
- superclass’s constructor
- main class’s constructor
改寫構造函數會直接侵入原有代碼,Dart構造函數的多樣寫法也增加了自動化插件的難度。因此改寫構造器不是第一選擇。根據初始化執行順序,很快可以想到,是否可以增加新的類成員,初始化時調用插樁代碼,以達到類初始化插粧的效果。例如
class A {
bool isCodeX = ReportUtil.addCallTime('A');
// ...biz
}
但在Dart中,針對擁有常量構建器的類,要求所有的成員均為final,成員初始化必須在第1第2階段,或構造函數入參進行初始化,即使是extends、with也強制要求子類及Mixin所有的變量均為final。而Flutter中,Widget等常用組件,均使用常量構建函數,無法通過這種形式插樁。
class A {
final num x, y;
const A(this.x, this.y);
}
注入代碼的形式不可用!
還有其他辦法嗎?可不可以通過AOP的方式,hook住所有的類構建器呢?而閒魚技術團隊剛剛開源的AspectD,恰好可以解決這個問題。
AspectD是針對Dart的AOP編程框架,通過Transform實現dill變換以實現AOP,可以便捷地實現無侵入代碼自由注入。
在Flutter v1.12.13下驗證,針對常量構建器、無構建函數、命名為ClassName.identifier形式構建函數,均測試通過!AspectD代碼如下:
@Aspect()
@pragma("vm:entry-point")
class CodeXExecute {
@pragma("vm:entry-point")
CodeXExecute();
@Call("package:flutter_codex_demo/test.dart", "A", "+A")
@pragma("vm:entry-point")
void _incrementA(PointCut pointcut) {
pointcut.proceed();
// todo report class A initialize
}
}
AspectD原理不在此詳細說明,有興趣請移步https://github.com/alibaba-flutter/aspectd。
整體設計方案
FlutterCodeX線上代碼覆蓋率SDK,由編譯時代碼插樁插件、運行時數據採集模塊組成。
- 代碼插樁插件
編譯時,通過build_runner,CodeXGenerator與CodeAstVisitor進行工程內所有類ast解析,遍歷所有類構造函數,自動生成AspectD的PointCut Execute類文件,hook類構建函數,在構造函數執行完畢後,插樁標記類調用信息,同時還生成項目的完整類列表至構建產物。關鍵代碼如下:
CodeAstVisitor:
// visit all class
void visitClassDeclaration(ClassDeclaration node) {
SourceNode sourceNode = SourceNode(source_path, node.name?.name);
node.members.forEach((ClassMember member) {
// find all constructor
if (member is ConstructorDeclaration) {
String constructorName = member.name?.name;
if (constructorName == null || constructorName.isEmpty) {
// ClassName Constructor
constructorName = sourceNode.name;
} else {
// ClassName.identifier Constructor
constructorName = (sourceNode.name ?? '') + "\\." + constructorName;
}
sourceNode.constructor.add(constructorName);
return;
}});
CodeXGenerator.collector.codeList[sourceNode.key()] = sourceNode;
}
AspectD Execute如下圖所示,類A擁有兩個構造函數,生成兩個AspectD AOP函數。
- 運行時數據採集模塊
運行時,工程中每個類初始化後將會自動調用addCallTime方法,將類調用信息緩存,選擇用戶退出後臺的時機,進行數據文件進行壓縮上傳,目前我們採用阿里雲OSS文件上傳。根據應用活躍用戶數,設置採樣率,命中至少5萬用戶UV。
- 數據彙總與產出
最後,線上運行一段時間後,我們將數據彙總,與打包構建產物中的完整類列表進行比對,即可獲得線上代碼覆蓋率數據,推動業務進行行之有效的瘦身。
以簡單Demo工程為例:
最後
目前,FlutterCodeX在閒魚App即將上線,結合客戶端Android、iOS代碼覆蓋率數據,有效地推動廢棄業務下線,助力包體瘦身,對工程健康做長效監控與改善。