產品經理:「點這裡,我要跳到任何我想跳的頁面」—— 解耦提效神器「統跳路由」

百瓶技術發表於2022-03-01

公眾號名片
作者名片

1. 背景

我們知道前端領域以路由來定位頁面,要跳轉到對應頁面只需訪問對應路由即可,十分方便。可一直以來 iOS 領域沒有路由這個概念,跳轉一個頁面需要建立出目標頁面的例項,然後通過導航控制器進行跳轉,十分繁瑣。有這麼個需求:當點選推送訊息(或點選某個區域)時需要跳轉到任意一個可能的頁面。乍一聽我是抗拒的,但這個需求似乎又合情合理。作為一群有追求的開發者我們正式啟動了百瓶統跳路由專案。

統跳:即通過統跳路由 SDK 的 open() 方法,達到跳轉至任意頁面的目的(目標頁面實現方式支援但不限於 Native、Flutter、HTML5、微信小程式、系統應用和其他第三方應用)。

2. 解決哪些問題

按照慣例,啟動一個專案我們先梳理痛點,整理出要解決的問題,進而確定需求。

2.1 頁面跳轉

頁面跳轉只需提供目的頁面路由給統跳路由 SDK,統跳路由 SDK 就能準確無誤地跳轉到目的頁面,而不是像傳統方式那樣建立出頁面例項後通過導航控制器跳轉。

2.2 入參攜帶

頁面跳轉時需要攜帶一些引數給目標頁面例項,以供目標頁面正確處理邏輯。

2.3 返回值回傳

有時我們需要在目標頁面關閉時回傳一些我們感興趣的值。如:選擇收貨地址頁面,在選擇完地址後需要把剛選中的地址資訊回傳給上一個頁面。

2.4 回退到指定路由

有時我們需要回退到指定的頁面。以釋出短視訊為例:首先從首頁(M)進入視訊拍攝頁面(A),拍攝完成進入視訊編輯頁面(B),編輯完成進入釋出頁面(C),釋出完視訊應回退到進入拍攝頁面(A)之前的頁面(M)而不是視訊編輯頁面(B)。

2.5 統一的路由規則

基本思路

  • 一個路由對應一個頁面
  • 路由應是一個有意義的字串
  • 路由規則應適用於各端
  • 各端應把路由與頁面做繫結

方案

由以上思路很容易想到我們的路由規範沿用前端路由規範即可,既符合 RestfulURI

既:URI = scheme:[//authority]path[?query][#fragment]

參見:RFC3986

我們給不同領域的路由定義了對應的 scheme,即 Native: native,Flutter:flutter,http/https:http/https,小程式:wxmp,三方應用:tp(third party)等。

2.6 跨模組 API 呼叫

有時我們需要像向 WebServer 發起 GET 請求的方式一樣訪問其他模組提供的方法。傳統模式下,跨模組方法互調需要引用目標模組,很繁瑣,有時還會發生迴圈引用。

2.7 行為路由

有時我們需要為頁面某個區域配置點選行為(非頁面跳轉行為)。如:有個 HTML5 頁面需要在點選頁面上某個區域時調起 Native 的分享行為。

2.8 路由攔截器

有時我們需要對路由進行鑑權、引數重整、打斷點、重定向等需求。因此為統跳路由 SDK 新增攔截器功能是一個很好的解決方案。

2.9 分組路由攔截器

有時我們需要對某組路由進行鑑權、引數重整、打斷點、防沉迷等需求。因此我們為統跳路由 SDK 新增分組攔截器功能。

2.10 路由重定向

隨著不斷迭代和技術的更新,很多頁面會被其他更合適的技術重寫,此時歷史程式碼還在用老路由,如果要把老的程式碼都改為新路由則要考慮版本控制和改動成本以及可能引入的風險。有了重定向功能則可以無成本切入新路由。

2.11 不同技術棧之間互不干擾(保持優雅)

當把一個路由傳入統跳路由 SDK 時,統跳路由 SDK 應能區分把當前路由分發至哪個路由排程器進行排程。如:Native、Flutter、HTML5 各自實現的頁面應交給各自的路由排程器。

3. 提供哪些 API

下面是 BBRouter 關鍵 API 的設計


NS_ASSUME_NONNULL_BEGIN

@interface BBRouter : NSObject <BBNativeRouter, BBBlockRouter>

@property (nonatomic, strong, class, readonly) BBRouterConfig *routerConfig;

/// 設定跳轉未定義路由時的統一回撥
/// @param undefinedRouteHandle 未定義路由時的統一回撥
+ (void)setUndefinedRouteHandle:(void (^)(BBRouterParameter *))undefinedRouteHandle;

/// 設定將要開啟指定頁面的回撥
+ (void)setWillOpenBlock:(void (^)(BBRouterParameter *))willOpenBlock;

/// 設定已經開啟指定頁面的回撥
+ (void)setDidOpenBlock:(void (^)(BBRouterParameter *))didOpenBlock;

#pragma mark - 註冊路由排程器 Dispatcher

/// 註冊路由排程器
/// @param dispatcher 排程器
/// @param scheme scheme
+ (BOOL)registerRouterDispatcher:(id<BBRouterDispatcher>)dispatcher scheme:(NSString *)scheme;

#pragma mark - BBBlockRouter
/// 註冊路由的實現 block
/// @param path 路由
/// @param action 實現
+ (BOOL)registerTask:(NSString *)path action:(BBBlockDispatcherAction)action;

/// 移除已註冊的 block
/// @param path 對應的路由
+ (BOOL)removeTask:(NSString *)path;

#pragma mark - 路由跳轉 API
/// 路由到指定頁面(該方法為底層方法,不建議直接使用,請使用下面的?便捷方法)
/// @param parameter 引數
+ (void)routeWithRouterParameter:(BBRouterParameter *)parameter;

#pragma mark - 已經存在 URL 的情況 頁面跳轉

/// 判斷能否開啟指定 URI
/// @param url 頁面 URI
+ (BOOL)canOpen:(NSString *)url;

/// 開啟頁面
/// @param url 頁面 URI
+ (void)open:(NSString *)url;

/// 開啟頁面並攜帶引數
/// @param url 頁面 URI
/// @param urlParams 攜帶的引數 json 可序列化的資料型別(當 scheme 為 native 時 可以傳遞複雜資料結構)
+ (void)open:(NSString *)url urlParams:(NSDictionary * __nullable)urlParams;

/// 開啟頁面並攜帶引數且支援資料回傳
/// @param url url
/// @param urlParams 攜帶的引數 json 可序列化的資料型別(當 scheme 為 native 時 可以傳遞複雜資料結構)
/// @param resultCallback 當需要回撥結果時 通過該回撥block實現
+ (void)open:(NSString *)url urlParams:(NSDictionary * __nullable)urlParams onPageFinished:(void (^ __nullable)(NSDictionary *result))resultCallback;

/// 開啟頁面並攜帶引數且支援資料回傳
/// @param url url
/// @param urlParams 攜帶的引數 json 可序列化的資料型別(當 scheme 為 native 時 可以傳遞複雜資料結構)
/// @param exts 額外引數
/// @param resultCallback 當需要回撥結果時通過該回撥 block 實現
+ (void)open:(NSString *)url urlParams:(NSDictionary * __nullable)urlParams exts:(NSDictionary * __nullable)exts onPageFinished:(void (^ __nullable)(NSDictionary *result))resultCallback;

/// 開啟頁面並攜帶引數且支援資料回傳
/// @param url url
/// @param urlParams 攜帶的引數 json 可序列化的資料型別(當 scheme 為 native 時 可以傳遞複雜資料結構)
/// @param exts 額外引數 animated:是否有過渡動畫
/// @param routerStyle 過渡動畫
/// @param resultCallback 當需要回撥結果時通過該回撥 block 實現
+ (void)open:(NSString *)url urlParams:(NSDictionary * __nullable)urlParams exts:(NSDictionary * __nullable)exts routerStyle:(KBBRouterStyle)routerStyle  onPageFinished:(void (^ __nullable)(NSDictionary *result))resultCallback;

#pragma - mark BBNativeRouter

///通過類註冊檢視控制器,path 為標識
+ (BOOL)registerClass:(Class)cls withPath:(NSString *)path;

///通過類名註冊檢視控制器 SDK,path 為標識
+ (BOOL)registerWithClassName:(NSString *)className andPath:(NSString *)path;

///通過類名註冊檢視控制器,path 為標識,確認引數是否匹配該路由
+ (BOOL)registerWithClassName:(NSString *)className andPath:(NSString *)path verifyBlock:(BOOL(^ __nullable)(NSString *path ,BBRouterParameter *routerParameter))verifyBlock;

///刪除註冊的檢視控制器
+ (BOOL)removeRegisteredPath:(NSString *)path;

#pragma mark - BlockDispatcher 相關實現

/// 執行已註冊的 block,同步返回
/// @param url url
/// @param urlParams 入參
+ (id _Nullable)invokeTaskWithUrl:(NSString *)url urlParams:(NSDictionary *)urlParams;

/// 執行已註冊的 block,同步返回
/// @param url url
/// @param urlParams 入參
/// @param error 出錯資訊
+ (id _Nullable)invokeTaskWithUrl:(NSString *)url urlParams:(NSDictionary *)urlParams error:(NSError **)error;

#pragma mark - 分組

/// 新增 path 到指定分組
/// @param path 路由路徑
/// @param group 分組名稱
+ (BOOL)addPath:(NSString *)path toGroup:(NSString *)group;

/// 新增一組路徑到指定分組
/// @param paths 路徑分組
/// @param group 分組名稱
+ (void)addPaths:(NSArray<NSString *> *)paths toGroup:(NSString *)group;

/// 指定分組下所有路由
/// @param group 分組名
+ (NSArray<NSString *> *)pathsInGroup:(NSString *)group;

/// 配置分組的回撥函式 用以處理分組邏輯
/// @param group 分組名稱
/// @param verifyBlock 回撥閉包
+ (void)configGroup:(NSString *)group verifyBlock:(BOOL(^ __nullable)(NSString *path ,BBRouterParameter *routerParameter))verifyBlock;

///回退
+ (UIViewController * _Nullable)backwardCompletion:(void (^ __nullable)(void))completion;

/// 回退
/// @param animated 是否動畫
+ (UIViewController * _Nullable)backwardAnimated:(BOOL)animated completion: (void (^ __nullable)(void))completion;

/// 回退,無動畫
/// @param count 回退級數
+ (void)backwardCount:(NSInteger)count completion: (void (^ __nullable)(void))completion;

/// 開啟指定新檢視控制器
/// @param vc 新的檢視控制器
+ (void)openVC:(UIViewController *)vc routerParameter:(BBRouterParameter *)parameter;

/// 當前是否存在路由指定的檢視控制器例項
/// @param path 路由路徑
+ (UIViewController * _Nullable)containsRouteObjectByPath:(NSString *)path;

/// 返回到指定頁面的上一級
/// @param vc 指定的檢視控制器例項
/// @param animatedBlock 是否需要動畫
/// @param completion 完成
+ (UIViewController * _Nullable)backwardVC:(UIViewController *)vc animatedBlock:(BOOL(^)(NSString *toppath))animatedBlock completion: (void (^__nullable)(void))completion;
@end

NS_ASSUME_NONNULL_END

3.1 註冊/移除路由排程器

路由排程器用來隔離不同領域的路由,便於解除耦合。開發者使用統跳路由 SDK 時可以非常方便的定義自己的路由排程器,實現自己的路由邏輯。

Native 頁面、Flutter 頁面和 HTML5 頁面跳轉邏輯肯定存在差異,此時應有對應的路由排程器實現跳轉行為。當然「行為路由」也有對應的路由排程器。

總之你可以發揮想象,盡情發揮統跳路由 SDK 的能力,你只需定義一個適合你的路由排程器。

3.2 註冊/移除路由與頁面的繫結關係

我們需要把路由與頁面的繫結關係註冊給統跳路由 SDK 以允許統跳路由 SDK 對路由進行動態解析,動態生成頁面例項並實現自動跳轉。

應該注意:這個註冊應該允許更新,以實現路由表動態更新。

註冊的不一定非要是一個頁面,也可以是某個服務(Service)。如:目標頁面是某個第三方提供的,只能通過呼叫對應 SDK 的某個方法開啟,想直接註冊頁面是做不到的。此時我們可以註冊一個服務做中轉,在該服務被呼叫時,我們再呼叫 SDK 的對應方法即可輕鬆實現以上需求。

3.3 開啟某個路由並獲取回傳值

統跳路由 SDK 應提供一個方法開啟某個頁面(或呼叫某個方法),並提供獲取返回值的回撥。

入參應有以下幾個

  • uri:路由
  • parameters: 要攜帶的入參
  • exts:其他引數(非業務引數,如:指定轉場動畫方式)
  • callback:回撥函式指標

3.4 回退到上個頁面

統跳路由 SDK 應提供返回上個頁面的方法。

3.5 回退到指定頁面

統跳路由 SDK 應提供回退到回退棧內指定路由的方法並返回指定路由的例項。
統跳路由 SDK 應提供回退 N 層路由的方法。

3.6 路由未找到的處理

當訊息推送了新版本特有的頁面時,老版本應進入路由未找到的統一出口,可以在此處做一個重定向或提示使用者升級到最新版本的操作。

4. 關鍵思路和規範

需求我們已經理順了,緊接著就是設計統跳路由 SDK 的架構。

4.1 如何實現高可擴充套件

合理抽象和功能拆分是實現高可擴充套件的基礎。

我們設計了路由排程器這個抽象的概念,路由排程器用來隔離不同領域的路由。

Native 路由、Flutter 路由、HTML5 路由、小程式路由等,分別有對應的路由排程器實現排程。行為路由也由對應的路由排程器實現排程。

4.2 如何避免侵入和耦合

統跳路由 SDK 立項時我們的專案已經有了一定規模,如果接入統跳路由 SDK 需要修改已有業務程式碼則無疑是個災難。因此我們必須完美相容傳統開發方式,避免引入額外工作量和成員學習成本。

如你所想,我們近乎完美地相容了傳統的開發方式,詳見 統跳路由 SDK(iOS 端實現)

4.3 科學管理路由表

  • 路由表集中管理
  • 版本管理(應用於動態下發路由表)
  • 路由表應標註路由名稱、用途描述、入參、出參、其他額外限制(如要進入該頁面需要的許可權)

4.3.1 使用體驗優化

通過指令碼生成各端程式碼

避免硬編碼:路由表對映為一個結構體,每個路由是一個屬性,通過這種方式避免硬編碼。

入參構造器:入參是一個字典,我們可以根據路由定義時的入參生成字典對應的構造器。

出參:出參是一個字典,我們可以根據路由表自動生成字典的關聯屬性。

版本管理:路由表倉庫打 tag 後自動執行指令碼生成各端程式碼(本文不展開)。

4.4 路由表動態下發

配置中心提供更新路由表能力,各端按約定的策略更新路由表。

5. 統跳路由 SDK(iOS 端實現)

5.1 相容原生開發方式

以 iOS 傳統開發方式為例,跳轉一個新頁面需要以下步驟

  1. 建立目標 ViewController 例項
  2. 入參以 ViewController 例項屬性賦值方式傳遞
  3. 獲取合適的 NavigationController 例項(若轉場方式為模態,則需獲取合適的 ViewController 例項)
  4. NavigationController 例項以 push 方式跳轉新頁面(或 ViewController 以模態方式跳轉新頁面)
  5. 以 block 或 delegate 方式回傳值

以上方式已經能滿足絕大部分場景,下面我們思考下如何以優雅的方式實現以上步驟

  1. 以鍵值對的方式實現 URI 與 ViewController 類的繫結,藉助 Objective-C runtime 動態生成 ViewController 例項。
  2. URI 以 Query 方式攜帶入參(統跳路由 SDK 內部會把入參解析為 Dictionary),key 為 ViewController 屬性(或例項變數)名,藉助 Objective-C runtime 判斷該 ViewController 類是否包含該屬性或例項變數,並判斷資料型別是否符合,如果符合則通過 Objective-C KVC 方式為該屬性或例項變數賦值,從而實現入參傳遞。
  3. 通過遍歷主 Window(未必是 keyWindow 要看實際情況)上的路由回退棧可以獲取合適的 NavigationController 例項(present 時是棧頂 ViewController 例項)。
  4. 以上條件都具備了,此時能很容易實現頁面跳轉。
  5. 關於資料回傳,我們可以通過 ViewController 被移除時回傳(一定不能是 dealloc 時,因為 dealloc 在記憶體洩露時不會呼叫,而記憶體洩露又偶爾會發生)。

以上思路清晰可執行,可如果想更靈活易用還需巧妙的使 ViewController 例項與路由相關引數建立聯絡。

我們把路由相關引數封裝為類 RouterParameter,結構如下

@interface RouterParameter : NSObject

/// 路由所屬領域(由哪個路由排程器排程)
@property (nonatomic, copy) NSString *scheme;
/// 路由路徑(不包含 query 和 fragment 部分)
@property (nonatomic, copy) NSString *fullPath;
/// URI query 部分
@property (nonatomic, copy) NSString *query;
/// URI fragment 部分
@property (nonatomic, copy) NSString *fragment;
/// 頁面跳轉方式(push/present)
@property (nonatomic, assign) KBBRouterStyle routerStyle;
/// 完整 URI(會把 addition 拼接入 query)
@property (nonatomic, copy) NSString *url;
/// 路由入參
@property (nonatomic, strong, readonly) NSMutableDictionary *addition;
/// 額外引數(路由行為引數,如:是否開啟轉場動畫)
@property (nonatomic, strong, readonly) NSMutableDictionary *exts;
/// 回撥值(code、message、data)
@property (nonatomic, strong) NSDictionary *response;
/// 回傳值使用的回撥函式
@property (nonatomic, copy) void (^__nullable callBackBlock)(NSDictionary *result);

把統跳 + (void)open:(NSString *)url urlParams:(NSDictionary * __nullable)urlParams exts:(NSDictionary * __nullable)exts routerStyle:(KBBRouterStyle)routerStyle onPageFinished:(void (^ __nullable)(NSDictionary *result))resultCallback 方法攜帶的相關引數轉換為 RouterParameter 例項在統跳路由 SDK 內部傳遞,通過 UIViewController 分類(Category)和 Objective-C 「關聯物件」的方式為 UIViewController 新增屬性 routerParameter

此刻我們會發現上面的「思路」已經被落實了,思路清晰易懂,並且完美相容原生開發模式。從而可以使傳統模式無痛漸進地切換到「路由模式」

5.2 架構

統跳路由架構圖

6. 如何使用

快速瀏覽 Demo 能更直觀地瞭解一個框架,我們一起來看下常規用法和用途。

6.1 整體流程

初始化階段

  • 載入路由表
  • 註冊路由攔截器
  • 原生路由註冊
  • 非頁面路由註冊
  • 分組攔截器註冊

就緒階段:此時統跳路由 SDK 已準備就緒。

6.2 頁面類路由呼叫

自有 Native 頁面

路由註冊


// 註冊 Objective-C 實現的 ViewController
BBRouter.register(withClassName: "MomentsViewController", andPath: BBRouterPaths.moments)

// 註冊 Swift 實現的 ViewController(注意名稱空間)
BBRouter.register(withClassName: swiftClassFullName("MomentsViewController", "Community"), andPath: BBRouterPaths.moments)
Flutter/HTML5 實現的頁面不在此處註冊,由 Flutter/HTML 5 專案自己管理

路由跳轉


// 無返還值路由跳轉
[BBRouter open:BBRouterPaths.moments urlParams:@{@"momentId":@"11223344"}];

// 有返回值路由跳轉(BBRouterPaths.selectAlcohol 這個頁面可能是任意一種技術實現的如:Native[Swift\Objective-C]、Flutter、HTML5 等)
[BBRouter open:BBRouterPaths.selectAlcohol urlParams:@{@"alcoholId":@"112233"} onPageFinished:^(NSDictionary * _Nonnull result) {
    // r_data 是通過 Objective-C 的 Category 和關聯物件方式為 NSDictionary 新增的屬性,從而幹掉硬編碼。
    DEBUGLog(@"%@", [NSString stringWithFormat:@"%@", result.r_data]);
}];
BBRouterPaths.selectAlcohol:使用這種方式把路由硬編碼幹掉。直接硬編碼無法使用編譯器檢查,維護成本高。統跳路由 SDK 的設計目標之一就是消滅硬編碼

6.2 方法/行為類路由呼叫


// 註冊行為
[BBRouter registerTask:@"action://xxx.com/yyy/zzz" action:^id _Nullable(BBRouterParameter * _Nonnull routerParameter) {
    return routerParameter.addition;
}];

// 方法非同步呼叫(統跳統一方法進行路由,不區分路由所屬領域)
[BBRouter open:@"action://xxx.com/yyy/zzz" urlParams:@{@"name":@"xiaoming"} onPageFinished:^(NSDictionary * _Nonnull result) {
    DEBUGLog(@"%@", [NSString stringWithFormat:@"%@", result]);
}];

// 方法同步呼叫(事件專用方法進行路由)
NSError *error = nil;
id result = [BBRouter invokeTaskWithUrl:@"action://xxx.com/yyy/zzz" urlParams:@{@"name":@"xiaoming"} error:&error];
DEBUGLog(@"%@", [NSString stringWithFormat:@"%@", result]);

三方應用指定頁面

解包淘寶和天貓的 .ipa 檔案,分析了他們的路由表和呼叫規則,抱著試一試的態度發現我們的統跳路由 SDK 也完美支援。


// 淘寶商品詳情頁
[BBRouter open:BBRouterPaths.threeSides urlParams:@{@"i":@"taobao://item.taobao.com/item.htm?id=554418184878"}];

// 天貓商品詳情頁
[BBRouter open:BBRouterPaths.threeSides urlParams:@{@"i":@"tmall://page.tm/itemDetail?itemID=551101867384"}];

6.3 路由攔截器簡單演示

引數重整:Objective-C 裡 id 是關鍵字,但其他語言可以正常使用,為了相容這種場景可以在攔截器裡做一個入參重新整理的操作。

BBRouter.register(withClassName: "XXXViewController", andPath: BBRouterPaths.xxx, verifyBlock: { path, routerParameter in
    routerParameter.addition["ID"] = routerParameter.addition["id"]
    return true
})

重定向:頁面使用新的技術重構,新版本應跳轉新頁面,藉助重定向能力,我們就不用修改已有程式碼了。即使老程式碼跳的還是老的路由,執行時也會被重定向到新頁面。

BBRouter.register(withClassName: "XXXViewController", andPath: BBRouterPaths.xxx, verifyBlock: { path, routerParameter in
    let newParameter = BBRouterParameter(byURI: BBRouterPaths.yyy, addition: routerParameter.addition.copy() as! [String : Any])
    newParameter.actionBlock = routerParameter.actionBlock
    newParameter.routerStyle = routerParameter.routerStyle
    newParameter.exts.addEntries(from: routerParameter.exts as! [String : Any])
    BBRouter.route(with: newParameter)

    return false
})

6.4 路由分組攔截器功能簡單演示

這裡使用分組攔截器實現一組頁面需要先成功登入才能訪問的需求,且實現了使用者操作的連貫性。


let isAuthed = "isAuthed"
BBRouter.addPaths(needAuthedPaths, toGroup: isAuthed);
BBRouter.configGroup(isAuthed) { (path, routerParameter) -> Bool in
    if (memberId.isEmpty) {
        BBRouter.open(BBRouterPaths.login, urlParams: Dictionary(), exts: Dictionary()) { (result) in
            if (!memberId.isEmpty) {// 如果已登入 則繼續之前的操作
                BBRouter.route(with: routerParameter)
            }
        }
        return false;
    }
    return true
}

6.5 路由未註冊處理

// 可以在這裡把未註冊的路由資訊交給 HTML5 落地頁,此時就很靈活了,可以做重定向也可以提示使用者升級。


BBRouter.setUndefinedRouteHandle { (parameter) in
    let url = parameter.url
    BBRouter.open(BBRouterPaths.routerNotFound, urlParams: ["url":url])
}

小結

百瓶統跳路由 SDK 使統跳成為現實,也為頁面視覺化搭建奠定了基礎。到目前為止已交付使用一年左右,對元件化/模組化程式有重要的推動作用,很好的完成了立項時「解耦提效」的目標。更可喜的是 iOS 端能無痛漸進地從傳統模式切換到「路由模式」,接入過程近乎零成本。

由於篇幅有限,很多重要地實現細節沒有提到,許多應用場景也沒有提到。另一方面也不希望把細節說的太透,免得先入為主,影響大家思考。

最後,真誠希望各位能指出方案的不足,並提出新的優化建議。大家如有疑問可在文章下方留言,我們會盡快回復。如果本文能對你有一點點啟發也請順手點個喜歡,如果能分享給你的朋友那就更感謝了。

更多精彩請關注我們的公眾號「百瓶技術」,有不定期福利呦!

相關文章