雲計算

Flutter線上代碼覆蓋率FlutterCodeX

作者:閒魚技術-君愛

背景

近年來,閒魚舊業務在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初始化執行順序為:

  1. class variables initialize on declaration (no static)
  2. initializer list
  3. superclass’s constructor
  4. 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代碼覆蓋率數據,有效地推動廢棄業務下線,助力包體瘦身,對工程健康做長效監控與改善。

Leave a Reply

Your email address will not be published. Required fields are marked *