Swift 在手淘商品評價的技術重構與實踐

語言: CN / TW / HK

作者:王浙劍(柘劍)

手淘新版商品評價列表在經歷一個半月的技術重構,幾個月的迭代和放量,最終在 2021 年的雙十一上,以 100% 的流量穩定的跑完了整個過程。我們不僅在業務上有了比較明確的提升,同時還沉澱了不少技術探索,比如沉澱基於 DinamicX + 事件鏈編排的輕模式研發框架、推動原生語言升級成 Swift/Kotlin,最終使得整體研發效率和穩定性有一個比較大的提升。(注:DinamicX 為內部自研動態化 UI 框架)

這篇文章,我會重點談論關於 Swift 的部分。如果你想了解關於 Swift 如何提升研發效率/質量、現有專案/模組是否需要 Swift 作為原生語言如何選型、在商品評價落地 Swift 過程中我們遇到了哪些問題以及最後有哪些收益和結論的一些問題,希望這篇文章可以給你帶來一些幫助。

首先是,我為什麼會選擇學習 Swift?

技術變革,未來已來

因為,我內心十分堅定,相比較於 OC,Swift 更能承載未來。

堅強後盾

最主要的原因就是它有一個堅強的後盾,Swift 作為 Apple 未來最重要的開發語言,光對外輸出的 WWDC 內容就已經高達 73 個,包括但不限於語法、設計、效能、開發工具鏈等,具體內容如圖所示:

回過頭來看 Swift 這幾年的發展,從 2014 年開始正式對外發布,到現在已經經歷了 7 個年頭了,在整個過程中,Apple 投入了大量精力建設 Swift,尤其是 Swift Only 框架的出現,也意味著 Apple 正在積極倡導各位投入到 Swift 開發中來。

三大優勢

其次,Swift 有三個比較明確的優勢: 更快、更安全且更具備表達性

更快 是指 Swift 在執行效率上做了很多優化。比如,Swift 系統庫本身就採用了很多不需要引用計數的基礎型別,無論是記憶體分配大小、引用計數損耗、方法派發靜態分析等方面的問題都得到了一個有效的提升。具體細節這裡就不展開分析,感興趣的可以移步 Understanding Swift Performance 瞭解細節。

所謂的 安全 不等於不發生 Crash,而是指任何的輸入都有一個比較明確的表現定義。Swift 設計初衷是希望開發者無需任何不安全的資料結構就能編寫程式碼,因此 Swift 擁有一個十分健壯的型別系統,開發者幾乎不需要考慮指標的問題,就能完成所有的開發工作。同時還提供了一系列字首為 Unsafe 的型別或函式,用於與不安全語言(例如 C 語言)的高效能互動、操作原始記憶體等相對不安全的操作,一方面以 Unsafe 警惕開發者使用這些 API ,另外一方面是區分型別以保證大部分開發場景使用的都是安全的型別。

這裡可以分享一個數據,我之前參與的一個 App 專案,是用 Pure Swift 編寫的(99%+),我們的線上 crash 率常年持續在十萬分之 8 左右,這對於一個小型團隊(單端 4 人)來說,是一個十分可觀的結果。我們幾乎不使用 Unsafe 的 API,使得我們的大部分問題都能在編譯期間避免,可選型別及可選繫結的設計強制開發者需要去思考如何處理值為空的場景,使得在軟體釋出之前就把開發者的錯誤扼殺在萌芽之中。

更具備表達性 簡單點說就是用更少的程式碼來表達一段完整的邏輯。在 Swift Evolution 專案中已經有 330 個提案來增強 Swift 的表達性,也得益於這些特性,使得 Swift 的程式碼量比 OC 少了大概 30% - 50% 左右。我們舉幾個實際例子

Builder Pattern

當我們定義了一個有很多屬性的複雜 model 時,我們不希望這個 model 的屬性在初始化完成後可以被變更。我們就需要通過 builder 模式來解決,程式碼如下:

// OCDemoModelBuilder.h
@interface OCDemoModelBuilder : NSObject

@property (nonatomic, copy, nonnull) NSString *a;
@property (nonatomic, copy, nonnull) NSString *b;
@property (nonatomic, copy, nonnull) NSString *c;
@property (nonatomic, copy, nonnull) NSString *d;
@property (nonatomic, copy, nonnull) NSString *e;
@property (nonatomic, copy, nonnull) NSString *f;
@property (nonatomic, copy, nonnull) NSString *g;
@property (nonatomic, copy, nonnull) NSString *h;

@end

// OCDemoModelBuilder.m

@implementation OCDemoModelBuilder

- (instancetype)init {
    if (self = [super init]) {
        _a = @"a";
        _b = @"b";
        _c = @"c";
        _d = @"d";
        _e = @"e";
        _f = @"f";
        _g = @"g";
        _h = @"h";
    }
    return self;
}

@end

// OCDemoModel.h

@interface OCDemoModel : NSObject

@property (nonatomic, readonly, nonnull) NSString *a;
@property (nonatomic, readonly, nonnull) NSString *b;
@property (nonatomic, readonly, nonnull) NSString *c;
@property (nonatomic, readonly, nonnull) NSString *d;
@property (nonatomic, readonly, nonnull) NSString *e;
@property (nonatomic, readonly, nonnull) NSString *f;
@property (nonatomic, readonly, nonnull) NSString *g;
@property (nonatomic, readonly, nonnull) NSString *h;

- (instancetype)initWithBuilder:(void(^)(OCDemoModelBuilder *builder))builderBlock;

@end

// OCDemoModel.m

@implementation OCDemoModel

- (instancetype)initWithBuilder:(void(^)(OCDemoModelBuilder *builder))builderBlock {
    if (self = [super init]) {
        OCDemoModelBuilder * builder = [[OCDemoModelBuilder alloc] init];
        if (builderBlock) {
            builderBlock(builder);
        }
        _a = builder.a;
        _b = builder.b;
        _c = builder.c;
        _d = builder.d;
        _e = builder.e;
        _f = builder.f;
        _g = builder.g;
        _h = builder.h;
    }
    return self;
}

@end

// Usage

OCDemoModel *ret = [[OCDemoModel alloc] initWithBuilder:^(OCDemoModelBuilder * _Nonnull builder) {
    builder.b = @"b1";
}];

// ret = a,b1,c,d,e,f,g

但是 Swift 的 Struct 支援屬性預設值和初始化構造器,使得 builder pattern 意義並不是很大,程式碼如下:

struct SwiftDemoModel {
    var a = "a"
    var b = "b"
    var c = "c"
    var d = "d"
    var e = "e"
    var f = "f"
    var g = "g"
    var h = "h"
}

// Usage

let ret = SwiftDemoModel(b: "b1")

// ret = a,b1,c,d,e,f,g

State Pattern

當一個函式的執行結果可能存在多種不同的狀態時,我們通常會採用狀態模式來解決問題。

例如我們定義一個函式執行結果可能存在 finish\failure\none 三種狀態,由於存在一些關聯值,我們不能使用列舉來解決。需要定義三個不同的具體型別,具體程式碼如下所示:

///  Executable.h
@protocol Executable <NSObject>

- (nullable NSDictionary *)toFormattedData;

@end

///  OCDemoExecutedResult.h
@interface OCDemoExecutedResult: NSObject<Executable>

/// 構造一個空返回值
+ (OCDemoNoneResult *)none;

/// 構造成功返回值
+ (OCDemoFinishedResult *)finishedWithData:(nullable NSDictionary *)data
                                      type:(nullable NSString *)type;
/// 構造一個錯誤返回值
+ (OCDemoFailureResult *)failureWithErrorCode:(nonnull NSString *)errorCode
                                     errorMsg:(nonnull NSString *)errorMsg
                                     userInfo:(nullable NSDictionary *)userInfo;

@end

///  OCDemoExecutedResult.m
@implementation OCDemoExecutedResult

/// 構造一個空返回值
+ (OCDemoNoneResult *)none {
    return [OCDemoNoneResult new];
}

+ (OCDemoFinishedResult *)finishedWithData:(nullable NSDictionary *)data type:(nullable NSString *)type {
    return [[OCDemoFinishedResult alloc] initWithData:data type:type];
}

+ (OCDemoFailureResult *)failureWithErrorCode:(nonnull NSString *)errorCode errorMsg:(nonnull NSString *)errorMsg userInfo:(nullable NSDictionary *)userInfo {
    return [[OCDemoFailureResult alloc] initWithErrorCode:errorCode errorMsg:errorMsg userInfo:userInfo];
}

- (nullable NSDictionary *)toFormattedData {
    return nil;
}

@end

///  OCDemoNoneResult.h
@interface OCDemoNoneResult : OCDemoExecutedResult

@end

///  OCDemoNoneResult.m
@implementation OCDemoNoneResult

@end

///  OCDemoFinishedResult.h
@interface OCDemoFinishedResult: OCDemoExecutedResult

/// 型別
@property (nonatomic, copy, nonnull) NSString *type;
/// 關聯值
@property (nonatomic, copy, nullable) NSDictionary *data;

/// 初始化方法
- (instancetype)initWithData:(nullable NSDictionary *)data type:(nullable NSString *)type;

@end

///  OCDemoFinishedResult.h
@implementation OCDemoFinishedResult

- (instancetype)initWithData:(nullable NSDictionary *)data type:(nullable NSString *)type {
    if (self = [super init]) {
        _data = [data copy];
        _type = [(type ?:@"result") copy];
    }
    return self;
}

- (NSDictionary *)toFormattedData {
    return @{
        @"type": self.type,
        @"data": self.data ?: [NSNull null]
    };
}

@end

///  OCDemoFailureResult.h
@interface OCDemoFailureResult: OCDemoExecutedResult

/// 錯誤碼
@property (nonatomic, copy, readonly, nonnull) NSString *errorCode;
/// 錯誤資訊
@property (nonatomic, copy, readonly, nonnull) NSString *errorMsg;
/// 關聯值
@property (nonatomic, copy, readonly, nullable) NSDictionary *userInfo;

/// 初始化方法
- (instancetype)initWithErrorCode:(NSString *)errorCode errorMsg:(NSString *)errorMsg userInfo:(nullable NSDictionary *)userInfo;

@end

///  OCDemoFailureResult.m
@implementation OCDemoFailureResult

- (OCDemoFailureResult *)initWithErrorCode:(NSString *)errorCode errorMsg:(NSString *)errorMsg userInfo:(nullable NSDictionary *)userInfo {
    if (self = [super init]) {
        _errorCode = [errorCode copy];
        _errorMsg = [errorMsg copy];
        _userInfo = [userInfo copy];
    }
    return self;
}

- (NSDictionary *)toFormattedData {
    return @{
        @"code": self.errorCode,
        @"msg": self.errorMsg,
        @"data": self.userInfo ?: [NSNull null]
    };
}

@end

但是如果我們使用 Swift 的 enum 特性,程式碼就會變的簡潔很多很多:

public enum SwiftDemoExecutedResult {
    /// 正確返回值
    case finished(type: String, result: [String: Any]?)
    /// 錯誤返回值
    case failure(errorCode: String, errorMsg: String, userInfo: [String: Any]?)
    /// 空返回值
    case none
    /// 格式化
    func toFormattedData() -> [String: Any]? {
        switch self {
        case .finished(type: let type, result: let result):
            var ret: [String: Any] = [:]
            ret["type"] = type
            ret["data"] = result
            return ret
        case .failure(errorCode: let errorCode, errorMsg: let errorMsg, userInfo: let userInfo):
            var ret: [String: Any] = [:]
            ret["code"] = errorCode
            ret["msg"] = errorMsg
            ret["data"] = userInfo
            return ret
        case .none:
            return nil
        }
    }
}

Facade Pattern

當我們定義一個入參需要符合多個協議型別時,我們通常會使用 Facade Pattern 來解決問題。

例如我們有四個協議 JSONDecodable、JSONEncodable、XMLDecodable、XMLEncodable 以及一帶有兩個入參的方法,入參 1 為 json 要求同時滿足 JSONDecodable、JSONEncodable 兩個協議,入參 2 為 xml 同時滿足 XMLDecodable、XMLEncodable。當我們使用 OC 來解決問題時通常會這麼寫:

@protocol JSONDecodable <NSObject>
@end

@protocol JSONEncodable <NSObject>
@end

@protocol XMLDecodable <NSObject>
@end

@protocol XMLEncodable <NSObject>
@end

@protocol JSONCodable <JSONDecodable, JSONEncodable>
@end

@protocol XMLCodable <XMLDecodable, XMLEncodable>
@end

- (void)decodeJSON:(id<JSONCodable>)json xml:(id<XMLCodable>)xml {

}

額外定義了兩個協議 JSONCodable、XMLCodable 來解決這個問題。但是在 Swift 中我們可以使用 & 來解決這個問題,不再需要定義額外的型別,程式碼如下:

protocol JSONDecodable {}
protocol JSONEncodable {}
protocol XMLDecodable {}
protocol XMLEncodable {}

func decode(json: JSONDecodable & JSONEncodable, xml: XMLDecodable & XMLEncodable) {
}

以上是 Swift 在 更具備表達性 方面的一些內容,當然優勢也遠不止這些,但是篇幅有限這裡不再展開。

總而言之,得益於 Swift 的高表達性,使得開發者可以通過更少的程式碼可以表達一段完整的邏輯,在一定程度上減少了開發成本,同時也降低了問題的產生。

勢不可擋

Swift 除了有一個堅強的後盾以及三大優勢以外,這幾年的發展趨勢也比較好。

首先根據 Githut 顯示,Swift 語言在 Github 的活躍度(Pull request) 已經超越了 OC 了,如下圖所示:(資料截止至 2021/10/25)

同時,國內 Top 100 的 Swift 混編應用也有明顯增加,從 19 年的 22% 已經上升到了 59%:(資料截止至 2021/04/22)

這裡的提升,一方面是國內許多一線網際網路公司都開始佈局,另外一方面是 WidgetKit 等 Swift Only 的框架出現也在促使大家開始建設 Swift 基礎設施。

當然,國外資料更加亮眼,已經達到了 91%,幾乎可以說是全部都已經用上了,為什麼這麼說呢?因為美版前 100 中 Google 繫有 8 個應用都沒有使用上 Swift。

這裡再和大家分享一個數據,在業餘時間組織《WWDC內參》作者招募的時候,我們收集了作者的技術棧和興趣點,最終發現有超過一半的作者有比較豐富的 Swift 開發經驗,還有 2/3 的人對 Swift 這個專題的內容比較感興趣(總共 180 人樣本)。可以看得出社群對於 Swift 的熱情還是十分高的,長遠角度看,是否使用 Swift 進行開發也會成為大家選擇工作的原因之一。

為什麼選擇商品評價列表?

也許很多人在看到第一部分之後,會有一種我得馬上在我們專案中用上 Swift 的衝動。為了避免你為你的“衝動”買單,下面我分享一下「手淘商品評價列表」選擇 Swift 的心路歷程。

先簡單講下自己來手淘的經歷,起初我加入的是手淘基礎架構組,主要工作職責之一就是建設 Swift 基礎設施,但是後來因為組織需要,我加入到了一個新的業務架構組,工作重心也由原來的從 Swift 基礎升級驅動業務,轉變成業務試點驅動基礎技術升級。在這個過程中,我們主要經歷了三次技術決策:

  • 一. 團隊最開始接手的專案:手淘訂單協議升級為新奧創
  • 二. 基於對業務研發的領域理解,團隊提出新的事件鏈編排能力,並與DX共建
  • 三. 商品評價重構,包括評價列表、互動等

每個階段我都有思考過我是否要使用 Swift,但最終前兩次我都放棄了使用我自己比較擅長的 Swift,主要出於下面幾點考慮:

需要具備使用 Swift 的前提

訂單新奧創專案之所以沒有采用 Swift 為主要開發語言,最大的問題就是當時的基本基礎設施還不夠完備。依賴的大部分模組幾乎都不支援 Module,如果要硬上 Swift 幾乎是不可能的事情,會增加很多的工作量,對於一個工期較趕的專案來說,不是一個明智之舉,權衡之下,暫時放棄了使用 Swift 的念頭。

什麼樣的業務更適合使用 Swift 重構

在基本條件都很完備的情況下,對於一個業務重構專案來說,Swift 會是一個更好的選擇。無論是大環境的趨勢,還是 Swift 獨有的優勢來說,已經不太適合繼續使用 OC 去重構一個業務模組了。

對於想嘗試 Swift 的大型專案來說,建議可以優先考慮包袱小、牽連小的業務做試點。當時我們在訂單新奧創專案放棄使用 Swift 的另外一個重要原因就是因為奧創整體架構較為複雜,搭建和資料混合在一起、區域性改動成本過高會導致牽一髮而動全身的問題,整體對端側新技術互動的開放包容有限。但是手淘商品評價就沒有這類問題,可以選擇的空間比較多,因此我們就比較堅定的選擇了 Swift 作為端側主要開發語言。

既要因地制宜、又要獲取支援

當專案具備使用 Swift 的條件之後,一定要結合自身團隊現狀進行綜合考慮。

首先,團隊需要提前培養或者配備一位有 Swift 開發經驗的人,來保證複雜問題的攻堅以及程式碼質量的把控。尤其是程式碼質量,大部分最初從 OC 接觸 Swift 的人,都會經歷一段“不適”期,在這段時期,很容易寫出「OC 味」的 Swift 程式碼,所以特別需要一位有熱情、有相關經驗和技術能力的人來實踐並表率。

同時,我們還需要獲得主管的支援,這點很關鍵,光有技術熱愛很難把一件事件持續做下去。需要結合專案情況持續與主管保持溝通,並且在交流過程中不斷升級自己對一個技術的思考,讓主管從最初的質疑到最後的支援,也是一個十分有趣的過程。

需要有一定技術基礎支撐

首先,在基礎設施完備性上,我們做了一次大範圍的 Module 適配工作,解決了混編的核心問題。同時升級了 DevOps,將包管理工具 tpod 升級到了 1.9.1 支援了原始碼級別的靜態庫版本 framework 工程,同時還提供了 tpodedit 模式解決標頭檔案依賴問題,以及在釋出鏈路新增了一些核心卡口檢查防止工程劣化。

其次,我們基於手淘已有的技術方案,權衡效能與效率之類的問題之後,最終我們結合對業務研發的痛點理解,開展基於事件鏈編排的研發模式升級探索,並從成本上考慮初期在 DX 內部共建、並輸出到新奧創,整體架構如下所示:

在 UI 層,我們使用 XML 作為 DSL 保證雙端一致性的同時降低了雙端的開發成本。

在邏輯編排上,我們設計了事件鏈技術方案儘可能的原子化每一個端側基礎能力,從而保證端側能力開發者可以聚焦在能力的開發上。

基於上述框架支援下,開發者可以自行決定單個基礎能力所使用的開發語言, 對於新手使用 Swift 的上手成本,可以下降一個檔次,不再需要和複雜的環境做鬥爭。

遇到了哪些問題?

坦率說,雖然我們在技術決策的時候做了深度思考,但當真的執行起來的時候,依舊遇到了不少問題。

基礎庫 API 並未適配 Swift

雖然 Xcode 提供了 “自動” 生成橋接檔案的能力,但由於 OC 和 Swift 語法差異過大,大部分自動生成的 Swift API 並不遵循 “API Design Guidelines”,這會導致目前接入的 Swift 業務庫寫出很多可讀性差且不好維護的程式碼。

同時,由於 Swift 的可選值設計,使得 OC SDK 提供給 Swift 使用時需要梳理清楚每一個對外的 API 入參和出參的可選設定。商品評價重度依賴的一個基礎 SDK 就沒有很好的做到這一點,以至於我們遇到了不少問題。

錯誤推導導致的不必要相容

我們先看下,下面這段程式碼:

// DemoConfig.h

@interface DemoConfig : NSObject

/* 此處已省略無用程式碼 */

- (instancetype)initWithBizType:(NSString *)bizType;

@end

// DemoConfig.m

@implementation DemoConfig

- (instancetype)initWithBizType:(NSString *)bizType {
    if (self = [super init]) {
        _bizType = bizType;
    }
    return self;
}

/* 此處已省略無用程式碼 */

@end

由於 DemoConfig 這個類並沒有註明初始化方法返回值是否可選,以至於 Xcode 預設推導的 API 變成了。

// 自動生成的 Swift API
open class DemoConfig : NSObject {
    /* 此處已省略無用程式碼 */
    public init!(bizType: String!)
}

開發者就不得不去思考如何解決初始化為空的場景,這顯然是多餘的。

除了 SDK 做可選語義適配以外,我們也可以新增一個分類,提供一個返回值不為空的 OC 方法,程式碼如下:

/// DemoConfig+SwiftyRateKit.h

NS_ASSUME_NONNULL_BEGIN

@interface DemoConfig (SwiftyRateKit)

- (instancetype)initWithType:(NSString *)bizType;

@end

NS_ASSUME_NONNULL_END

/// DemoConfig+SwiftyRateKit.m
#import <SwiftyRateKit/DemoConfig+SwiftyRateKit.h>

@implementation DemoConfig (SwiftyRateKit)

- (instancetype)initWithType:(NSString *)bizType {
    return [self initWithBizType:bizType];
}

@end

不安全 API

沒有寫清楚可選設定的 OC API 被橋接到 Swift 本質上都是不安全的。為什麼這麼說呢?

我們拿一個線上 Crash 真實案例來舉例,堆疊如下:

Thread 0 Crashed:
0x0000000000000012 Swift runtime failure: Unexpectedly found nil while implicitly unwrapping an Optional value DemoEventHandler.swift
0x0000000000000011 handle DemoEventHandler.swift
0x0000000000000010 handle <compiler-generated>
0x0000000000000009 -[XXXXXXXXXX XXXXXXXXXX:XXXXXXXXXX:XXXXXXXXXX:] XXXXXXXXXX.m
0x0000000000000008 -[XXXXXXXX XXXXXXXX:XXXXXXXX:XXXXXXXX:] XXXXXXXX.m
0x0000000000000007 +[XXXXXXX XXXXXXX:XXXXXXX:XXXXXXX:] XXXXXXX.m
0x0000000000000006 -[XXXXXX XXXXXX:] XXXXXX.m
0x0000000000000005 -[XXXXX XXXXX:] XXXXX.m
0x0000000000000004 -[XXXX XXXX:] XXXX.m
0x0000000000000003 -[XXX XXX:XXX:] XXX.m
0x0000000000000002 -[XX XX:]
0x0000000000000001 -[X X:]

客戶端的實現程式碼如下:

class DemoEventHandler: SwiftyEventHandler {

    override func handle(event: DemoEvent?, args: [Any], context: DemoContext?) {

        guard let ret = context?.demoCtx.engine.value else {
            return
        }
        /// 此處省略無用程式碼
    }
}

導致 Crash 的原因是 context?.demoCtx.engine.value 這段程式碼。

本質原因是 demoCtx 未註明可選語義,導致 OC 橋接到 Swift 的時候預設使用了隱式解包。在讀取過程中,如果值並沒有值,會由於強制解包而直接產生 Unexpectedly found nil while implicitly unwrapping an Optional value 的 Crash。

要解決這個問題,除了 SDK 做可選語義適配以外,我們還可以可以把呼叫程式碼都改成可選呼叫避免強制解包的問題:

破壞性繼承

在使用上面這個基礎 SDK 遇到最大的問題就是 DemoArray 的破壞性繼承。

DemoArray 繼承自 NSArray 並且重寫了不少方法,其中就有 objectAtIndex: 這個方法。

在 NSArray 標頭檔案中清楚的定義了

objectAtIndex: 這個方法的返回值一定不為空,但是 SDK 在 DemoArray 這個子類實現 objectAtIndex: 這個方法時居然返回了 nil,程式碼如下所示:

這使得使用 Swift 開發 SDK 自定義 EventHandler 壓根無法進行。

核心原因是實現一個 SDK 自定義 EventHandler 首先要符合 DemoEventHandler 協議,符合協議必須實現 - (void)handleEvent:(DemoEvent *)event args:(NSArray *)args context:(DemoContext *)context; 這個方法,由於協議上約定的是 NSArray 型別,因此轉換成 Swift API args 就變成了 [Any] 型別,如下圖所示:

但是 SDK 傳給 DemoEventHandler 的型別本質上是一個 DemoArray 型別:

倘若 DemoArray 裡面存在 [Null null] 物件,就會導致attempt to insert nil object from objects[0] 的 Crash,如下圖所示:

具體原因是在呼叫 handleEvent(_:args:context:) 時候,Swift 內部會呼叫 static Array.unconditionallyBridgeFromObjectiveC(:) 把 args 入參由 NSArray 轉變成 Swift 的 Array,而在呼叫 bridge 函式的時候,會先對原陣列進行一次 copy 操作,而在 NSArray Copy 的時候會呼叫 -[__NSPlaceholderArray initWithObjects:count:] ,由於 DemoArray 的 NSNull 被轉變成了 nil,初始化會失敗,直接 Crash。

要避免這個問題,讓 SDK 修改 DemoArray 顯然是不現實的,由於呼叫方實在是過多,無論是影響面還是迴歸測試成本短期內都無法評估。所以只能增加一箇中間層來解決這個問題。我們首先設計了一個 OC 的類叫 DemoEventHandlerBox 用於包裝和橋接,程式碼如下:

/// DemoEventHandlerBox.h

@class SwiftyEventHandler;

NS_ASSUME_NONNULL_BEGIN

@interface DemoEventHandlerBox : NSObject<DemoEventHandler>

-(instancetype)initWithHandler:(SwiftyEventHandler *)eventHandler;

@end

NS_ASSUME_NONNULL_END

/// DemoEventHandlerBox.m
#import <SwiftyRateKit/DemoEventHandlerBox.h>
#import <SwiftyRateKit/SwiftyRateKit-Swift.h>

@interface DXEventHandlerBox ()

/// 處理事件物件
@property (nonatomic, strong) SwiftyEventHandler *eventHandler;

@end

@implementation DemoEventHandlerBox


-(instancetype)initWithHandler:(SwiftyEventHandler *)eventHandler {

    self = [super init];

    if (self) {
        _eventHandler = eventHandler;
    }

    return self;
}

- (void)handleEvent:(DemoEvent *)event args:(NSArray *)args context:(DemoContext *)context {
    [self.eventHandler handle:event args:args context:context];
    return;
}

@end

DemoEventHandlerBox 中有個型別為 SwiftyEventHandler 類用於邏輯處理,程式碼如下:

@objcMembers
public class SwiftyEventHandler: NSObject {

    @objc
    public final func handle(_ event: DemoEvent?, args: NSArray?, context: DemoContext?) {
        var ret: [Any] = []
        if let value = args as? DemoArray {
            ret = value.origin
        } else {
            ret = args as? [Any] ?? []
        }
        return handle(event: event, args: ret, context: context)

    }

    func handle(event: DemoEvent?, args: [Any], context: DemoContext?) {
        return
    }

}

SwiftyEventHandler 暴露給 OC 的方法設定為 final 同時做好將 DemoArray 轉回 NSArray 的邏輯相容。最後 Swift 這邊的所有 EventHandler 實現類都繼承自 SwiftyEventHandler 並重寫 handle(event:args:context) 方法。這樣就可以完美避免由於破壞性繼承導致的問題了。

Clang Module 構建錯誤

第二大類問題主要和依賴有關,雖然前文有提到,目前的基本基礎設施已經完備,但依舊存在一些問題。

依賴更新不及時

很多人剛開始寫 Swift 的時候,經常會遇到一個問題 Could not build Objective-C module,一般情況下的原因是因為你所依賴的模組並沒有適配 Module,但由於手淘基礎設施基本已經完備,大部分庫都已經完成 Module 化適配,所以你可能只需要更新一下模組依賴就可以很好的解決這類問題。

例如 STD 這個庫,手淘目前依賴的版本是 1.6.3.2,但當你的 Swift 模組需要依賴 STD 的時候,使用 1.6.3.2 會導致無法編譯通過。這時候你的 Swift 模組可能需要升級到 1.6.3.3 才能解決這個問題。本質上 1.6.3.3 和 1.6.3.2 的區別就是模組化適配,因此你也不用擔心會產生什麼副作用。

混編導致的依賴問題

前文提到的 Module 適配雖然解決了大部分問題,但是還是存在一些異常 case,這裡展開說下。

我們在商品評價重構的過程中,為了保證專案可以循序漸進的放量,我們做了程式碼的物理隔離,新建立了一個模組叫 SwiftyRateKit 是一個 Swift 模組。但是評價列表的入口類都在一個叫 TBRatedisplay 的 OC 模組。因此為了做切流,TBRatedisplay 需要依賴 SwiftyRateKit 的一些實現。但當我們將 TBRatedisplay 依賴了 SwiftyRateKit 開始編譯之後,就遇到了下面這麼一個問題:

Xcode 將暴露給 OC 的 Swift 類 ExpandableFastTextViewWidgetNode 的標頭檔案宣告寫到了 SwiftyRateKit-Swift.h 中,ExpandableFastTextViewWidgetNode 是繼承自 TBDinamic 的類 DXFastTextWidgetNode 的。

因為當時 TBRatedisplay 並沒有開啟 Clang Module 開關(CLANG_ENABLE_MODULES),導致 SwiftyRateKit-Swift.h 的下面這麼一段巨集定義並沒有生效,因此就不知道 ExpandableFastTextViewWidgetNode 這個類是在哪裡定義的了:

但當我們開啟 TBRatedisplay 的 Clang Module 開關之後,更恐怖的事情發生了。由於 TBDinamic 沒有開啟 Clang Module 開關,導致 @import TBDinamic 無法編譯通過,進入了一個“死迴圈”,最後不得不臨時移除了所有沒有支援 Clang Module 開關的 OC 模組匯出。

這裡概念比較抽象,我用一張圖來表示一下依賴關係:

首先,對於一個 Swift 模組來說,只要模組開啟了 DEFINES_MODULE = YES 且提供了 Umbrella Header 就可以通過 import TBDinamic 的方式匯入依賴。因此 SwiftyRateKit 可以在 TBDinamic 沒有開啟 Clang Module 開關的時候就顯示依賴,並可以編譯通過。

但對於一個 OC 模組來說,匯入另外一個模組分兩種情況。

  • 第一種是開啟了 DEFINES_MODULE = YES 的模組,我們可以通過 #import <TBDinamic/TBDinamic_Umbrella.h> 匯入。
  • 第二種是開啟了 Clang Module 開關的時候,我們可以通過 @import TBDinamic 匯入

由於 TBRatedisplay 依賴了 SwiftyRateKit,Xcode 自動生成的 SwiftyRateKit-Swift.h 標頭檔案採用的是 @import TBDinamic 的方式來匯入模組的,因此就造成了上面的問題。

所以我個人建議現階段要儘量避免或者減少將一個 Swift 模組的 API 提供給 OC 使用,不然就會導致這個 Swift 對外 API 需要依賴的 OC 模組都需要開啟 Clang Module,同時依賴了這個 Swift 模組的 OC 模組也需要開啟 Clang Module。而且,由於 Swift 和 OC 語法不對等,會讓 Swift 開發出來的介面層能力非常受限,從而導致 Swift 對外的 API 變得相當不協調。

類名與 Module 同名

理論上 Swift 模組之間互相呼叫是不會存在問題的。但由於手淘模組眾多,歷史包袱過重,我們在做商品評價改造的時候遇到了一個「類名與 Module 同名」的苦逼問題。

我們個 SDK 叫 STDPop,這個 SDK 的 Module 名也叫 STDPop,同時還有一個工具類也叫 STDPop。這會導致什麼問題呢?所有依賴 STDPop 的 Swift 模組,都無法被另外一個 Swift 模組所使用的,會報一個神奇的錯誤:'XXX' is not a member type of class 'STDPop.STDPop' 主要原因是因為依賴 STDPop 的 Swift 模組生成的 .swiftinterface 檔案時會給每個 STDPop 的類加一個 STDPop 的字首。例如 PopManager 會變成 STDPop.PopManager 但由於 STDPop 本身就一個類叫 STDPop 會就導致編譯器無法理解 STDPop 到底是 Module 名還是類名。

而能解決這個問題的唯一辦法就是需要 STDPop 這個模組移除或者修改 STDPop 這個類名。

具體有哪些方面的收益?

我們在一次深思熟慮之後,踏上了披荊斬棘的 Swift 落地之路,雖然在整個過程中遇到了很多前所未有的挑戰,但現在回過來看,我們當初的技術選型還是比較正確的。主要體現在下面幾個方面:

程式碼量減少,Coding 效率提高

得益於 Swift 的強表達性,我們可以用更少的程式碼去實現一個原本用 OC 實現的邏輯,如下圖所示,我們不再需要寫過多的防禦性程式設計的程式碼,就可以清晰的表達出我們要實現的邏輯。

同時,我們對原有 13 個用 OC 實現的表示式,用 Swift 重新寫了一遍,整體程式碼量的變化如下:

程式碼量的變少意味著需要投入開發的時間變少了,同時產生 bug 的機會也就變少了。

大幅降低交叉 Review 的成本

OC 奇特的語法使得大部分其他開發壓根無法看懂具體的邏輯,從而導致 iOS 和 Android 雙端交叉 Review 的成本相當之高,也會使得很多庫經常存在雙端邏輯不一致性。

當初在做訂單遷移新奧創時,面對較多雙端API不一致,且部分程式碼邏輯的味道較複雜,專案上發生過多起臨時問題排查影響節奏的事情。

因此,我們另闢蹊徑,採用 Swift & Kotlin 的模式進行開發,由於 Swift 和 Kotlin 的語法極度相似,使得我們交叉 Review 毫無壓力。

同時,得益於商品評價使用的腳手架,後續需求迭代也大幅下降。我們以「評價 Item 新增分享按鈕」為例:

如果採用 OC & Java 模式,因為雙端程式碼都看不懂。所以需求評審都雙端需要各派 1 名,加上討論等各種事宜大概需要 0.5 人日。然後雙端討論方案後一個人進行模板開發,需要 1 人日左右。最後雙端各自實現原生分享原子能力,需要各 2 人日左右(其中有 1 人日需要調研如何接入分享 SDK),總計 2 * 0.5 + 1 + 2 * 2 = 6 人日。

但是如果採用 Swift & Kotlin 的模式,我們只需要有 1 人取參加需求 Review,0.5 人日。單人完成技術調研、模板開發 3 人日左右。最後再把寫好的程式碼給另外一端看,另外一端可以直接 copy 程式碼並根據自己端的特點進行適配 1 人日左右。總計 0.5 + 3 + 1 = 4.5 人日左右。大約節省 25% 的時間。

專案穩定性有所提高

因為沒有比較好的量化指標,只能談談感受。

首先,由於編碼問題導致的提測問題明顯下降,基本上的異常分支流得益於 Swift 的可選值設計,都已經在開發階段考慮清楚了,總體提測問題明顯比使用 OC 時少了很多。

其次,線上問題也明顯下降,除了上文提到的 Crash 問題。商品評價重構專案基本上沒有發生線上問題。

優先享受技術紅利

無論是 WidgetKit 還是 DocC,可以很明顯的看得出來,蘋果內部對於新特性以及開發工具鏈的升級一定是 Swift 優先於 OC,因此所有使用 Swift 的同學都能很快速的使用上所有蘋果新開發的特性和工具。

同時,也得益於 Swift 的開源,我們不僅可以通過原始碼去學習一些不錯的設計模式,還可以定位一些疑難雜症,不再需要和生澀難懂的彙編程式碼作鬥爭。

總結與展望

以上算是我們在手淘探索 Swift 業務落地的一個總結,希望可以給大家在技術選型或者探索避坑的時候給到一點幫助,當然,這只是一個開始,還有很多事情值得去做。首先,我們需要一起去完善和規範 Swift 的編碼規範,甚至沉澱一系列最佳實踐去引導大家更低成本的從 OC 轉型到 Swift;其次,我們也需要針對前文提到的混編問題,推動基礎 SDK 做 Swift Layer 建設以及繼續優化現有 Swift 工具鏈;最後,我們還需要引入一些優秀的開源庫避免重複造輪子,以及利用好 Apple 提供的能力(例如 DocC),並最終找到一個 Swift 在手淘的最佳實踐。

最後,如果你對我們做的事情也比較感興趣,歡迎加入我們一起共建 Swift/Kotlin 生態,我的聯絡方式是:[email protected],期待你的加入。

【文獻參考】

關注【阿里巴巴移動技術】微信公眾號,每週 3 篇移動技術實踐&乾貨給你思考!