货拉拉iOS模块化路由框架:TheRouter
背景
随着社区对支持Swift的需求日益增多,Swift5.0二进制库也具有更好的稳定性和兼容性表现,货拉拉技术团队根据社区反馈及内部讨论,决定开源内部业务使用的Swift版本路由组件,与2023年8月份已发布的Objective-C版本路由组件组成一个完整解决方案。
TheRouter开源团队将把重心放在维护和升级Swift版本的TheRouter上,同时也会持续支持Objective-C版本的TheRouter,并欢迎社区贡献。
对于使用Objective-C版本TheRouter的用户,建议将版本固定为1.0.0版以确保稳定性。
Features
TheRouterSwift是货拉拉TheRouter系列开源框架的Swift版本,为日益增多的Swift开发者提供一高可用路由框架。TheRouterSwift用于模块间解耦和通信,基于Swift协议进行动态懒加载注册路由与打开路由的工具。同时支持通过Service-Protocol寻找对应的模块,并用 protocol进行依赖注入和模块通信。Github传送门>>
主要功能如下:
页面导航跳转能力:支持常规vc或Storyboard的push/present/popToTaget/windowNavRoot/modalDismissBeforePush跳转能力;
路由自动注册能力:懒加载方式动态注册路由,仅当第一次调用OpenURL时进行动态注册;
路由映射文件导出:支持将工程中的路由映射关系导出为文档,支持JSON、Plist格式,方便开发者进行双端的汇总比对、记录等;
服务自动注册能力:动态注册服务,使用runtime方式自动注入;
动态化能力:支持添加重定向,移除重定向、动态添加路由、动态移除路由、拦截器、错误path修复等;
链式编程:支持链式编程方式拼接URL与参数;
适配Objective-C:OC类可以在Swift中使用继承的方式遵循协议来进行动态注册;
服务调用:支持本地服务调用与远端服务调用;
增加异步获取符合条件注册类:遍历工程实现路由协议的类,并提前存储;
增加路由本地缓存能力:每次重启应用,需要重新走注册流程,增加根据版本号进行本地缓存能力,避免初次注册;
详细功能如下:
| 功能描述 | 事例代码及注释 |
|---|---|
| 懒加载路由 | lazyRegisterRouterHandle 仅当第一次调用OpenURL时进行动态注册 |
| 发挥Swift特性,面向协议编程 | TheRouterServiceProtocol TheRouterableProtocol |
| 动态注册,无需手动注册 | TheRouterManager.addGloableRouter([".LA"], url, userInfo) |
| 支持依赖注入与服务的自动注册 | TheRouter.registerServices() TheRouterServiceManager.registerService(serviceName) |
| 服务的动态注册与协议方式调用 | TheRouter.fetchService(AppConfigServiceProtocol.self) |
| 支持路由映射文件导出 | TheRouter.writeRouterMapToFile |
| 支持重定向,移除重定向、动态添加路由、动态移除路由,错误path修复 | TheRouter.addRelocationHandle |
| 支持拦截器 | TheRouter.addRouterInterceptor |
| 支持Swift项目中调用OC路由 | OC类可以在Swift中使用继承的方式遵循协议来进行动态注册 |
| 支持全局失败监控 | TheRouter.addGlobalMatchFailedHandel |
| 支持路由与服务日志回调 | TheRouter.routerLogHandle(_ url: String, _ logType: TheRouterLogType, _ errorMsg: String) |
| 支持路由注册期的安全检查 | TheRouterManager.routerForceRecheck() 客户端强制校验,是否匹配,不匹配触发断言 |
| 支持后端对客户端服务的调用 | TheRouter.openURL() 服务接口下发,MQTT,JSBridge |
| 支持链式调用 | TheRouterBuilder.build("scheme://router/demo").withInt(key: "intValue", value: 2).navigation() |
| 支持链式调用打开路由回调闭包 | TheRouterBuilder.build("scheme://router/demo").withInt(key: "intValue", value: 2).navigation(_ handler: complateHandler = nil) |
| 支持非链式调用打开路由回调闭包 | TheRouter.openURL("https://therouter.cn/" ) { param, instance in } |
| 增加异步获取符合条件注册类 | TheRouterManager.fetchRouterRegisterClass() |
| 增加路由本地缓存能力 | TheRouterManager.fetchRouterRegisterClass([.The], userCache: true) |
背景
随着项目需求的日益增加,开发人员的不断增加,带来了很多问题:
模块划分不清晰,任何开发人员随意调用并修改其他模块的代码实现以满足自己的业务需求。
维护困难,同一组件的不同服务,散落在工程各个地方,不利于统一维护修改替换。
模块负责人无法清晰,导致同一功能多人维护,造成冲突。
另外件拆分完之后都上升到远端,那么它们之间本地的代码是没办法相互依赖的,所以就需要通过一种工具,然后去实现透传服务的能力。我们需要一个中间件去处理这些问题。路由即是将耦合进行转移,通过增加中间层映射关系,解决业务之间的依赖关系。
一个成熟的 路由 该是什么样子
业务组件化之后,组件化需要将整个项目的各个模块进行解耦,升级远端之后,界面之间的跳转怎么解决?路由Api
动态注册路由,无需手动注册。服务的动态注册,无需手动注册。
端上跳转统一问题怎么解决?使用统一 URL 映射方式处理
业务跳转中出现问题,如何修改跳转逻辑?服务如何降级? 远端下发配置,修改跳转 URL
业务服务异常,界面改为 h5 界面。重定向
App 跳转出现问题如何跳转到同一个本地的 error 界面?统一失败处理
如何在跳转前增加强制的业务逻辑处理,比如业务调整,必须先执行某些操作,才能进入。重定向
业务中有很多需要前置跳转,比如先登录才能去订单列表,如何实现。拦截器
如何测试各个跳转业务是否正常。 路由 Path 校验
如何把最频繁的业务跳转前置,减少查询次数?增加优先级 priority
本地服务通过路由调用,远端服务通过路由调用 支持服务调用
整体设计思路
为了和Android端保持一致,使用了URL,class注册的方式实现。通过URL匹配方式查询数组中保存的模版信息,找到执行获取对应实例,执行跳转操作。
如何集成使用
CocoaPods
Add the following entry in your Podfile:
ruby
pod 'TheRouter','1.1.3'
Swift限制版本
ruby
Swift5.0or above
TheRouterSwift 使用方式
1. 组件初始化
// 日志回调,可以监控线上路由运行情况
TheRouter.logcat { url, logType, errorMsg in
debugPrint("TheRouter: logMsg- \(url) \(logType.rawValue) \(errorMsg)")
}
// 提前获取声明了TheRouterable的VC,保存在内存与本地文件中
TheRouterManager.loadRouterClass([".The"], useCache:true)
// 首次打开调用TheRouter.openUrl()时会走此回调,进行路由信息的注册并打开路由
TheRouter.lazyRegisterRouterHandle { url, userInfo in
// 增加H5跳转与远端服务调用配置,为什么放在这里,因为这里你可以传入自定义的webRouterUrl,serivceHost
TheRouterManager.injectRouterServiceConfig(webRouterUrl, serivceHost)
returnTheRouterManager.addGloableRouter([".The"], url, userInfo)
}
// 动态注册服务
TheRouterManager.registerServices(excludeCocoapods:true)
### 2. 路由注册
常规注册
鉴于已经实现了自动注册能力,开发者无需自己添加路由,只需要对需要进行跳转的界面实现TheRouterable协议即可
/// 实现TheRouterable协议
extension TheRouterController:TheRouterable{
staticvar patternString:[String]{
["scheme://router/demo"]
}
static func registerAction(info:[String:Any])->Any{
debugPrint(info)
let vc =TheRouterController()
vc.qrResultCallBack = info["clouse"]as?QrScanResultCallBack
vc.resultLabel.text = info.description
return vc
}
staticvar priority:UInt{
TheRouterDefaultPriority
}
}
手动单个注册
// 模型模式
TheRouter.addRouterItem(RouteItem(path:"scheme://router/demo?&desc=简单注册,直接调用TheRouter.addRouterItem()注册即可", className:"TheRouter_Example.TheRouterController", desc:"简单注册,直接调用TheRouter",params:["key1":1]))
// 字典模式
TheRouter.addRouterItem(["scheme://router/demo?&desc=简单注册,直接调用TheRouter.addRouterItem()注册即可":"TheRouter_Example.TheRouterController"])
// 常量参数模式
TheRouter.addRouterItem("scheme://router/demo?&desc=简单注册", classString:"TheRouter_Example.TheRouterController")
// 协议模式, TheRouterApi实现了 CustomRouterInfo协议
TheRouter.addRouterItem(TheRouterApi.patternString, classString:TheRouterApi.routerClass)
手动批量注册
TheRouter.addRouterItem(["scheme://router/demo":"TheRouter_Example.TheRouterController",
"scheme://router/demo1":"TheRouter_Example.TheRouterControllerA"])
3. 路由打开
首推打开方式
TheRouterCApi构建方式
TheRouter.openURL(TheRouterCApi.init().requiredURL)
scheme+ path+ param构建方式
TheRouter.openURL("scheme://router/demo1?id=2&value=3&name=AKyS&desc=安全检查是指为了垮模块进行调用,我们统一使用了实现CustomRouterInfo协议的抽象类来管理路由的path与class映射关系,在注册之后,runtime动态注册与抽象类的数据结构映射是否正确,具体实现:TheRouterManager.routerForceRecheck方法中")
h5直接跳转方式
TheRouter.openWebURL("https://xxxxxxxx")
便利构造器链式打开路由
let model =TheRouterModel.init(name:"AKyS", age:18)
TheRouterBuilder.build("scheme://router/demo")
.withInt(key:"intValue", value:2)
.withString(key:"stringValue", value:"2222")
.withFloat(key:"floatValue", value:3.1415)
.withBool(key:"boolValue", value:false)
.withDouble(key:"doubleValue", value:2.0)
.withAny(key:"any", value: model)
.navigation()
TheRouterBuilder.build("scheme://router/demo")
.withInt(key:"intValue", value:2)
.withString(key:"stringValue", value:"sdsd")
.withFloat(key:"floatValue", value:3.1415)
.withBool(key:"boolValue", value:false)
.withDouble(key:"doubleValue", value:2.0)
.withAny(key:"any", value: model)
.navigation {params, instance in
}
路由打开时参数传递
组件化Api方式传递方式, params即为可配置的参数
publicclassTheRouterCApi:NSObject,CustomRouterInfo{
publicstaticvar patternString ="scheme://router/demo33"
publicstaticvar routerClass ="TheRouter_Example.TheRouterControllerC"
publicvarparams:[String:Any]{return["desc":"如果直接调用TheRouterCApi进行路由跳转,此时路由地址demo33是错误的,无法进行跳转,触发断言,仅当动态下发修复之后才能跳转,测试可以注释TheRouterManager.addRelocationHandle这行代码"]}
publicvar jumpType:LAJumpType=.push
publicoverride init(){}
}
元祖形式传入路由与追加参数
TheRouter.openURL(("scheme://router/demo1?id=2&value=3&name=AKyS&desc=直接调用TheRouter.addRouterItem()注册即可,支持单个注册,批量注册,动态注册,懒加载动态注册",["descs":"追加参数"]))
其他类型参数传递方式
let clouse ={(qrResult:String, qrStatus:Bool)in
print("\(qrResult) \(qrStatus)")
self.view.makeToast("\(qrResult) \(qrStatus)")
}
let model =TheRouterModel.init(name:"AKyS", age:18)
TheRouter.openURL(("scheme://router/demo?id=2&value=3&name=AKyS",["model": model,"clouse": clouse]))
4. 路由移除
TheRouter.removeRouter(TheRouterViewCApi.patternString)
5. 路由全局失败映射
TheRouter.globalOpenFailedHandler { info in
debugPrint(info)
}
6. 路由拦截
比如在未登录情况下统一拦截:跳转消息列表之前先去登录,登录成功之后跳转到消息列表等。
let login =TheRouterLoginApi.templateString
TheRouter.addRouterInterceptor([login], priority:0){(info)->Boolin
ifLALoginManger.shared.isLogin {
returntrue
}else{
TheRouter.openURL(TheRouterLoginApi().build)
returnfalse
}
}
登录成功之后删除拦截器即可。
7.如何让 OC 类也享受到 Swift 路由
这是一个 OC 类的界面,实现路由的跳转需要继承 OC 类,并实现 TheRouterAble 协议即可
@interfaceTheRouterBController:UIViewController
@property(nonatomic, strong)UILabel*desLabel;
@end
@interfaceTheRouterBController()
@end
@implementationTheRouterBController
-(void)viewDidLoad {
[super viewDidLoad];
self.view.backgroundColor =[UIColor yellowColor];
[self.view addSubview:self.desLabel];
// Do any additional setup after loading the view.
}
@end
publicclassTheRouterControllerB:TheRouterBController,TheRouterable{
publicstaticvar patternString:[String]{
["scheme://router/demo2",
"scheme://router/demo2-Android"]
}
publicstaticvar descriptions:String{
"TheRouterControllerDemo"
}
publicstatic func registerAction(info:[String:Any])->Any{
let vc =TheRouterBController()
vc.desLabel.text = info.description
return vc
}
}
8. 服务的注册
如何声明服务及实现服务
@objc
public protocol AppConfigServiceProtocol:TheRouterServiceProtocol{
// 打开小程序
func openMiniProgram(info:[String:Any])
}
finalclassConfigModuleService:NSObject,AppConfigServiceProtocol{
staticvar seriverName:String{
String(describing:AppConfigServiceProtocol.self)
}
func openMiniProgram(info:[String:Any]){
iflet window =UIApplication.shared.delegate?.window {
window?.makeToast("打开微信小程序", duration:1, position: window?.center)
}
}
}
如何使用服务
/// 使用方式
iflet appConfigService =TheRouter.fetchService(AppConfigServiceProtocol.self){
appConfigService.openMiniProgram(info:[:])
}
服务使用了runtime动态注册,所以你不用担心服务没有注册的问题。只需像上述案例一样使用即可。
TheRouterSwift 动态注册是如何实现的
OC 注解的形式
这里列举了OC使用注解的方式,Swift因为其缺乏动态性,是不支持注解的。
//使用注解
@page(@"home/main")
-(UIViewController*)homePage{
// Do stuff...
}
Swift 注册形式
Swift 中,我们都知道 Swift 是不支持注解的,那么 Swift 动态注册路由该怎么解决呢,我们使用 runtime 遍历工程自建类的方式找到遵循了路由协议的类进行自动注册。自动排除系统类进行遍历,提高效率。
为了避免无效遍历,我们通过传入 registerClassPrifxArray 指定我们遍历包含这些前缀的类即可。一旦是 UIViewController.Type 类型就进行存储,然后再进行校验是否遵循 TheRouterable 协议,遵循则自动注册。无需手动注册。
使用 objc_copyClassNamesForImage 方法查找对应的类,比 objc_getClassList 遍历效率更高。
新增了 org.cocoapods 过滤,考虑到组件化场景下,将会改为外部配置的方式传入。需要开发人员将自建的私有库bundleId修改为不是 org.cocoapods即可。
具体查看网址如下:
路由性能优化讨论
路由根据版本号缓存能力
增加缓存能力,同一版本再次打开无需走初次加载流程,直接读缓存注册,提升效率。
考虑到开发中同一个版本下会有新增路由情况,那么从缓存读取就是不正确的,导致无法跳转。我们做了逻辑优化,如果当前正在链接Xcode跑起来的应用,会默认不走缓存,仅当打出包情况下走缓存逻辑。
路由注册的懒加载
采用动态注册有一个不好的情况就是在启动时就去动态注册,在 TheRouter 中注册的时机被延后了,放在了 App 第一次通过 TheRouter.openUrl()时进行注册,会判断路由是否加载完毕,未加载完毕进行加载,然后打开路由。
@discardableResult
publicclass func openURL(_ urlString:String, userInfo:[String:Any]=[String:Any](), handler: complateHandler =nil)->Any?{
if urlString.isEmpty {
returnnil
}
if!shareInstance.isLoaded {
return shareInstance.lazyRegisterHandleBlock?(urlString, userInfo)
}else{
return openCacheRouter((urlString, userInfo))
}
}
// MARK: - Public method
@discardableResult
publicclass func openURL(_ uriTuple:(String,[String:Any]), handler: complateHandler =nil)->Any?{
if!shareInstance.isLoaded {
return shareInstance.lazyRegisterHandleBlock?(uriTuple.0, uriTuple.1)
}else{
return openCacheRouter(uriTuple)
}
}
publicclass func openCacheRouter(_ uriTuple:(String,[String:Any]), handler: complateHandler =nil)->Any?{
if uriTuple.0.isEmpty{
returnnil
}
if uriTuple.0.contains(shareInstance.serviceHost){
return routerService(uriTuple)
}else{
return routerJump(uriTuple)
}
}
路由 Path 与类正确安全校验
这里有一个硬性匹配,实现CustomRouterInfo 协议的类数量需要和实现TheRouterable协议的类数量一致,且类名,path必须一样,否则直接触发断言,避免线上事故。
// MARK: - 客户端强制校验,是否匹配
publicstatic func routerForceRecheck(){
let patternArray =Set(pagePathMap.keys)
let apiPathArray =Set(apiArray)
let diffArray = patternArray.symmetricDifference(apiPathArray)
debugPrint("URL差集:(diffArray)")
debugPrint("pagePathMap:(pagePathMap)")
assert(diffArray.count ==0,"URL 拼写错误,请确认差集中的url是否匹配")
let patternValueArray =Set(pagePathMap.values)
let classPathArray =Set(classMapArray)
let diffClassesArray = patternValueArray.symmetricDifference(classPathArray)
debugPrint("classes差集:(diffClassesArray)")
assert(diffClassesArray.count ==0,"classes 拼写错误,请确认差集中的class是否匹配")
}
踩坑 路由 注册-KVO
在进行 classes 本地校验时遇到了类名不匹配问题。
排查原因: 是因为为了避免路由在启动时就注册,影响启动速度,采用了懒加载的方式即第一次打开路由界面的时候才先进行注册然后跳转。但是在我们动态注册之前,某个类因为添加了 KVO (Key-Value Observing 键值监听),这个类在遍历时 className 修改为了 NSKVONotifying_xxx。需要我们进行特殊处理,如下
/// 对于KVO监听,动态创建子类,需要特殊处理
publicletNSKVONotifyingPrefix="NSKVONotifying_"
if fullName.hasPrefix(NSKVONotifyingPrefix){
let range = fullName.index(fullName.startIndex, offsetBy:NSKVONotifyingPrefix.count)..<fullName.endIndex
let subString = fullName[range]
pagePathMap[cls.patternString[s]]="(subString)"
TheRouter.addRouterItem(cls.patternString[s], classString:"(subString)")
}
动态调用 路由
在之上的路由能力下,我们希望 App 能够支持动态增加路由,删除路由,重定向路由、通过路由调起本地服务、远端通过路由调起 App 服务能力,随即进行了动态化的扩展。
重定向功能
定义路由下发模型数据结构
publicstructTheRouterInfo:Decodable{
public init(){}
publicvar targetPath:String?
publicvar orginPath:String?
publicvar routerType:Int=0// 1: 表示替换或者修复客户端代码path错误 2: 新增路由path 3:删除路由 4: 重置路由
publicvar path:String?// 新的路由地址
publicvar className:String?// 路由地址对应的界面
publicvarparams:[String:Any]?
enumCodingKeys:String,CodingKey{
case targetPath
case orginPath
case path
case className
case routerType
caseparams
}
public init(from decoder:Decoder)throws{
let container =try decoder.container(keyedBy:CodingKeys.self)
targetPath =try container.decodeIfPresent(String.self, forKey:CodingKeys.targetPath)
orginPath =try container.decodeIfPresent(String.self, forKey:CodingKeys.orginPath)
routerType =try container.decode(Int.self, forKey:CodingKeys.routerType)
path =try container.decodeIfPresent(String.self, forKey:CodingKeys.path)
className =try container.decodeIfPresent(String.self, forKey:CodingKeys.className)
params=try container.decodeIfPresent(Dictionary<String,Any>.self, forKey:CodingKeys.params)
}
}
通过远端下发重定向数据,原本跳转到白色界面的业务逻辑改为跳转到黄色界面
let relocationMap =["routerType":1,"targetPath":"scheme://router/demo1","orginPath":"scheme://router/demo"]asNSDictionary
TheRouterManager.addRelocationHandle(routerMapList:[relocationMap])
TheRouter.openURL("scheme://router/demo?desc=跳转白色界面被重定向到了黄色界面")
重定向恢复
在业务中,通常会进行业务调整,那么重定向之后需要恢复的话,就需要移除重定向
let relocationMap =["routerType":4,"targetPath":"scheme://router/demo","orginPath":"scheme://router/demo"]asNSDictionary
TheRouterManager.addRelocationHandle(routerMapList:[relocationMap])
TheRouter.openURL("scheme://router/demo?desc=跳转白色界面被重定向到了黄色界面之后,根据下发数据又恢复到跳转白色界面")
路由 Path 动态修复
在实际开发中,开发人员因为马虎写错了路由 Path,上线之后无法进行正常的业务跳转,此时就需要通过远端下发路由进行匹配跳转了。scheme://router/demo3 是正确 path,但是本地写错的路由 path 为 scheme://router/demo33,那么需要新增一个 path 进行映射。
let relocationMap =["routerType":2,"className":"TheRouterSwift_Example.TheRouterControllerC","path":"scheme://router/demo33"]asNSDictionary
TheRouterManager.addRelocationHandle(routerMapList:[relocationMap])
let value =TheRouterCApi.init().requiredURL
TheRouter.openURL(value)
路由 适配不同的 Android-Path
在实际开发中,一旦使用 URI 这种方式,牵扯到双端,就可以存在双端不一致的问题,那么如何解决呢,可以通过本地新增多路由 path 解决,也可以通过远端下发新路由解决。
publicclassTheRouterControllerB:TheRouterBController,TheRouterable{
publicstaticvar patternString:[String]{
["scheme://router/demo2",
"scheme://router/demo2Android"]
}
publicstatic func registerAction(info:[String:Any])->Any{
let vc =TheRouterBController()
vc.desLabel.text = info.description
return vc
}
}
let relocationMap =["routerType":2,"className":"TheRouterSwift_Example.TheRouterControllerD","path":"scheme://router/demo5"]asNSDictionary
TheRouterManager.addRelocationHandle(routerMapList:[relocationMap])
TheRouter.openURL("scheme://router/demo2Android?desc=demo5是Android一个界面的path,为了双端统一,我们动态增加一个path,这样远端下发时demo5也就能跳转了")
服务是如何注册的
服务使用了runtime动态注册,所以你不用担心服务没有注册的问题。只需像上述案例一样使用即可。
路由 远端调用本地服务:服务接口下发,MQTT,JSBridge
let dict =["ivar1":["key":"value"]]
let url ="scheme://services?protocol=AppConfigLAServiceProtocol&method=openMiniProgramWithInfo:&resultType=0"
TheRouter.openURL((url, dict))
是否考虑Swift5.9 Macros?
从目前的实现方式来看,懒加载加上动态注册,已经解决了注册时的性能问题。即使需要遍历全工程的类,然后处理相关逻辑,也不会超过0.2s。之所以能够通过Class取得path,因为给类声明了静态变量。
/// 实现TheRouterable协议
extension TheRouterController:TheRouterable{
staticvar patternString:[String]{
["scheme://router/demo"]
}
static func registerAction(info:[String:Any])->Any{
let vc =TheRouterController()
vc.qrResultCallBack = info["clouse"]as?QrScanResultCallBack
vc.resultLabel.text = info.description
return vc
}
}
关于作者
货拉拉移动端技术团队
开源协议
TheRouterSwift 采用Apache2.0协议,详情参考LICENSE