iOS开发:关于路由
在iOS开发中引入路由框架一直是一个有争议的话题。
因为即使不使用路由框架,似乎也不会有太大的影响。那么我们先来回顾一下几个典型的跳转场景:
从外部跳转到App
-
Safari浏览器网页点击事件跳转到App的页面
-
App在挂起或者杀死状态,收到推送跳转到App的页面
-
收到短信,短信里面有短链接跳转到App的页面
-
从邮件中的链接跳转到App的页面
-
从社交媒体应用(如微信、微博)中的链接跳转到App的页面
-
从App的小组件跳转到App的页面
-
Siri、ShotCut进行跳转
App内部跳转
-
单一主工程,无业务模块依赖,页面间进行跳转
-
多模块工程,多个模块之间可以随意跳转
需要注意的是,对于多模块工程,模块可能是第三方开发的,并不遵守内部开发标准,这种情况不在考虑范围内。
路由中心
- 跳转到微信小程序:其实跳转到其他App的小程序也可以认为是这种业务场景。目前已经无法从微信小程序跳转到App了。点击查看
- 跳转到其他App:这种情况跳转API相对固定,传值也有规则,无需路由框架,不在我们的讨论之中。
- 跳转到Safari浏览器:这种情况跳转API也相对固定,传值也有规则,无需路由框架,不在我们的讨论之中。
上图展示了非常复杂的跳转场景。在日常开发过程中,比如推送跳转到不同页面,实际上是通过推送信息中的字符串创建一个枚举映射判断,不同的字段跳转到不同的页面。浏览器和短信消息以及App开屏广告跳转也是使用枚举映射的逻辑。
维护一个枚举映射表可以完成这个功能,但随着业务量的增大,这种方式虽然可行,但不够友好,下面是伪代码示例:
func pushToAppPage(model: LaunchAdModel,
tabbarController: UITabBarController,
navigationController: UINavigationController) {
switch model.appPage {
case "app_home_page":
break
case "app_message_center":/// 消息中心
navigationController.pushViewController(MessageCenterController(), animated: true)
case "app_message_center_detail":/// 公告详情
let vc = AppMessageDetailController()
vc.messageId = model.appItemId
navigationController.pushViewController(vc, animated: true)
case "community_topics":// 资讯
break
case "activity":
break
/// 业务增加会case也逐渐增加,如果入参规则不同,还需要不同的构建器,初始化方法和赋值
}
}
如果使用路由框架将页面和路由表提前绑定,此时外部跳转进来,只需一行代码即可搞定。可以认为是将集中的枚举映射分散到了路由框架中。
其实我完全可以把上面的伪代码封装成一个路由中心,然后制定一系列入参传参规则来保证一致性,但是我也可以直接使用现有的框架来避免我重复造轮子,我只用了解框架的使用与传参规则就可以。
另外,良好的跳转逻辑,不仅需要移动端制定规则,还需要后端配合,完成数据下发的格式的对应。
可以想象一下再App中增加了一个路由中心,所有的跳转情况逻辑与跳转Action都由路由管理,然后再从路由中心发出去:
TheRouter
我最近研究了一下相关框架,目前觉得TheRouter的功能和业务场景符合要求,因为它同时兼容OC,所以在某些语法上看起来很怪异。
其实所有的路由这种从前端借鉴过来的舶来物,总需要这样个过程:
-
注册路由
-
保证注册之后再使用路由
-
异常路由侧进去了定义好的错误页面
比如Flutter中使用GetX的路由,我们会这样:
abstract class Routes {
Routes._();
static const coinRink = '/coinRink';
static const unknown = "/unknown";
///页面合集
static final routePage = [
GetPage(
name: coinRink,
page: () => const CoinRankPage(),
binding: CoinRankBinding(),
middlewares: [LoginMiddleware()],
),
GetPage(
name: unknown,
page: () => const UnknownPage(),
),
];
static final unknownPage = GetPage(
name: Routes.unknown,
page: () => const UnknownPage(),
);
}
TheRouter对比这种思路,手动注册之外,有一个我觉得很有特色功能就是通过runtime遍历进行路由的自动注册,减少了手动注册的不舒适度。
let beginRegisterTime = CFAbsoluteTimeGetCurrent()
var resultXLClass = [AnyClass]()
let bundles = CFBundleGetAllBundles() as? [CFBundle]
for bundle in bundles ?? [] {
let identifier = CFBundleGetIdentifier(bundle);
if let id = identifier as? String {
if excludeCocoapods {
if id.hasPrefix(kSAppleSuffix) || id.hasPrefix(kSCocoaPodsSuffix) {
continue
}
} else {
if id.hasPrefix(kSAppleSuffix) {
continue
}
}
}
guard let execURL = CFBundleCopyExecutableURL(bundle) as NSURL? else { continue }
let imageURL = execURL.fileSystemRepresentation
let classCount = UnsafeMutablePointer<UInt32>.allocate(capacity: MemoryLayout<UInt32>.stride)
guard let classNames = objc_copyClassNamesForImage(imageURL, classCount) else {
continue
}
for idx in 0..<classCount.pointee {
let currentClassName = String(cString: classNames[Int(idx)])
guard let currentClass = NSClassFromString(currentClassName) else {
continue
}
if class_getInstanceMethod(currentClass, NSSelectorFromString("methodSignatureForSelector:")) != nil,
class_getInstanceMethod(currentClass, NSSelectorFromString("doesNotRecognizeSelector:")) != nil {
if let cls = currentClass as? UIViewController.Type {
resultXLClass.append(cls)
}
}
#if DEBUG
if let clss = currentClass as? CustomRouterInfo.Type {
apiArray.append(clss.patternString)
classMapArray.append(clss.routerClass)
}
#endif
}
}
for i in 0 ..< resultXLClass.count {
let currentClass: AnyClass = resultXLClass[i]
if let cls = currentClass as? TheRouterable.Type {
let fullName: String = NSStringFromClass(currentClass.self)
if fullName.contains(kSADelegateClassSensorsSuffix) {
break
}
for s in 0 ..< cls.patternString.count {
if fullName.contains(NSKVONotifyingPrefix) {
let range = fullName.index(fullName.startIndex, offsetBy: NSKVONotifyingPrefix.count)..<fullName.endIndex
let subString = fullName[range]
registerRouterList.append([TheRouterPath: cls.patternString[s], TheRouterClassName: "\(subString)", TheRouterPriority: "\(cls.priority)"])
} else {
registerRouterList.append([TheRouterPath: cls.patternString[s], TheRouterClassName: fullName, TheRouterPriority: "\(cls.priority)"])
}
}
} else if currentClass.self.conforms(to: TheRouterableProxy.self) {
let fullName: String = NSStringFromClass(currentClass.self)
if fullName.contains(kSADelegateClassSensorsSuffix) {
break
}
for s in 0 ..< currentClass.patternString().count {
if fullName.contains(NSKVONotifyingPrefix) {
let range = fullName.index(fullName.startIndex, offsetBy: NSKVONotifyingPrefix.count)..<fullName.endIndex
let subString = fullName[range]
registerRouterList.append([TheRouterPath: currentClass.patternString()[s], TheRouterClassName: "\(subString)", TheRouterPriority: "\(String(describing: currentClass.priority()))"])
} else {
registerRouterList.append([TheRouterPath: currentClass.patternString()[s], TheRouterClassName: fullName, TheRouterPriority: "\(String(describing: currentClass.priority()))"])
}
}
}
}
let endRegisterTime = CFAbsoluteTimeGetCurrent()
另外需要注意,在最新的Xcode16下面,Debug模式下面自动注册runtime不起作用,需要修改一下工程配置:
Xcode16 下 Debug 模式
ENABLE_DEBUG_DYLIB
选项默认开启,开启之后objc_copyClassNamesForImage
主工程 image 调用失败,Debug 模式下会使用**.debug.dylib
文件,所以会有点问题。可以先将ENABLE_DEBUG_DYLIB
关闭
同时TheRouter会有一个强制校验过程,也就是必须在工程中手动维护一张路由表,来保证自动注册的路由表和手动注册的路由表一致,这种措施是为了保证在Debug环境下的一致性,当然如果工程不那么复杂,这个功能不用也罢:
/// - Parameters:
/// - excludeCocoapods: 排除一些非业务注册类,这里一般会将 "com.apple", "org.cocoapods" 进行过滤,但是如果组件化形式的,创建的BundleIdentifier也是
/// org.cocoapods,这里需要手动改下,否则组件内的类将不会被获取。
/// - urlPath: 将要打开的路由path
/// - userInfo: 路由传递的参数
/// - forceCheckEnable: 是否支持强制校验,强制校验要求Api声明与对应的类必须实现TheRouterAble协议
/// - forceCheckEnable 强制打开TheRouterApi定义的便捷类与实现TheRouterAble协议类是否相同,打开的话,debug环境会自动检测,避免线上出问题,建议打开
return TheRouterManager.addGloableRouter(true, url, userInfo, forceCheckEnable: false)
如果项目是多模块组成,传统的push与pop可能需要模块对外暴露Controller,以保证可以构建控制器与页面跳转。使用路由框架可以抹掉这些细节与传参、构造器方法,对外暴露跳转路径即可。
同时我也在思考,如果一个Flutter项目也是多模块的情况下,主工程无法知道子模块的暴露的Page,是如何维护路由表的呢?
截止我发文的时候,掘金的货拉拉又发了一篇文章,《iOS货运用户App组件路由器设计与实践》,不过目前被删除了,也不知道是个啥情况,反正就是说他们还有一套与TheRouter的不同的路由框架,嗯,好吧~
结论
-
路由框架并不是iOS开发的必备工具。如果外部跳转到App场景少,App内部跳转简单,单一工程,或者多模块但模块间跳转场景少、不复杂,可以不用。
-
路由带来方便的同时,可能会导致页面切换转场动画的固定化,因为路由的目的是打开页面,而页面相关的动画等,如果放在路由框架中,显然又不太合适,所以当存在路由框架时,在需要使用转场动画时,可能无法尽善尽美。
-
如果外部跳转和App内部跳转复杂,可以考虑使用路由框架,以减轻维护逻辑的编写。同时如果考虑双端一致性,甚至可以一次配置,双端可行。