作者:閑魚技術-君愛
背景
近年來,閑魚舊業務在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代碼覆寫率資料,有效地推動廢棄業務下線,助力包體瘦身,對工程健康做長效監控與改善。