普通视图

发现新文章,点击刷新页面。
昨天 — 2025年4月11日掘金 iOS

iOS开发:关于路由

作者 season_zhu
2025年4月11日 16:22

在iOS开发中引入路由框架一直是一个有争议的话题。

因为即使不使用路由框架,似乎也不会有太大的影响。那么我们先来回顾一下几个典型的跳转场景:

从外部跳转到App

  1. Safari浏览器网页点击事件跳转到App的页面

  2. App在挂起或者杀死状态,收到推送跳转到App的页面

  3. 收到短信,短信里面有短链接跳转到App的页面

  4. 从邮件中的链接跳转到App的页面

  5. 从社交媒体应用(如微信、微博)中的链接跳转到App的页面

  6. 从App的小组件跳转到App的页面

  7. Siri、ShotCut进行跳转

App内部跳转

  1. 单一主工程,无业务模块依赖,页面间进行跳转

  2. 多模块工程,多个模块之间可以随意跳转

需要注意的是,对于多模块工程,模块可能是第三方开发的,并不遵守内部开发标准,这种情况不在考虑范围内。

路由中心

  1. 跳转到微信小程序:其实跳转到其他App的小程序也可以认为是这种业务场景。目前已经无法从微信小程序跳转到App了。点击查看
  2. 跳转到其他App:这种情况跳转API相对固定,传值也有规则,无需路由框架,不在我们的讨论之中。
  3. 跳转到Safari浏览器:这种情况跳转API也相对固定,传值也有规则,无需路由框架,不在我们的讨论之中。

image.png

上图展示了非常复杂的跳转场景。在日常开发过程中,比如推送跳转到不同页面,实际上是通过推送信息中的字符串创建一个枚举映射判断,不同的字段跳转到不同的页面。浏览器和短信消息以及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都由路由管理,然后再从路由中心发出去: image.png

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 关闭

image.png

github.com/HuolalaTech…

同时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)

TheRouter

如果项目是多模块组成,传统的push与pop可能需要模块对外暴露Controller,以保证可以构建控制器与页面跳转。使用路由框架可以抹掉这些细节与传参、构造器方法,对外暴露跳转路径即可。

同时我也在思考,如果一个Flutter项目也是多模块的情况下,主工程无法知道子模块的暴露的Page,是如何维护路由表的呢?

截止我发文的时候,掘金的货拉拉又发了一篇文章,《iOS货运用户App组件路由器设计与实践》,不过目前被删除了,也不知道是个啥情况,反正就是说他们还有一套与TheRouter的不同的路由框架,嗯,好吧~

结论

  1. 路由框架并不是iOS开发的必备工具。如果外部跳转到App场景少,App内部跳转简单,单一工程,或者多模块但模块间跳转场景少、不复杂,可以不用。

  2. 路由带来方便的同时,可能会导致页面切换转场动画的固定化,因为路由的目的是打开页面,而页面相关的动画等,如果放在路由框架中,显然又不太合适,所以当存在路由框架时,在需要使用转场动画时,可能无法尽善尽美。

  3. 如果外部跳转和App内部跳转复杂,可以考虑使用路由框架,以减轻维护逻辑的编写。同时如果考虑双端一致性,甚至可以一次配置,双端可行。

❌
❌