iOS Router

前几天我们 SwiftGG 激烈的讨论了 iOS 中 Router 。

于是今天用文字总结一下我个人的一些观点,并放出了 Demo (opens new window) 。注意,虽然有 Demo ,但仍然只是个思想,具体怎么实现,都可以。

# 什么是 Router

我认为就是通过打开指定的 URL(字符串),完成对应的操作,通常该操作都是展示一个新的 ViewController 。

# 为什么需要 URL Router

# 和其他 APP 通信/交互

原则上和其他 APP 交互的方式只能是通过 URL Scheme ,事实上像微信分享、支付等 APP 交互都是通过 URL Scheme 的方式,具体的交互逻辑可以参考 MonkeyKing (opens new window)

此处微信分享等交互式通过 URL 和 UIPasteboard 进行的共享数据,当然大多数的 APP 都是采用这样的方式。

有一个良好的 URL Scheme 可以为使用者提供更多方便的交互,做的比较好的比如 OmniFocus (opens new window)

业界存在一个公认的 x-callback-url 标准,可以参见 http://x-callback-url.com/specifications/ (opens new window)

当产品提出这样的需求时,添加一个还好,添加多个就显得麻烦了,我们需要一种类似于后端的 router 方案,对应不同的 url ,匹配到不同的逻辑。

# 从 Web 直接打开 APP

在目前的 App 中,这个场景还是很常见,比如打开了高德地图的 web ,我们想进行更复杂的交互,或者是获得更好的体验,就要打开 app 完成这件事情,于是这里总不能再去复制地址,打开搜索,粘贴吧。我们需要一个合理的方案可以直接从 web 中跳转到 app 。

# 解耦

移除 ViewController 中的依赖关系。

# GitHub 上的 Router 资源

我表示,对于那些 Router 实现,我很不满意,基本上都不能满足我的需求,在此就不一一列举了。

# Router 原理

事实上,Router 的原理很简单,只有两步。

  1. 解析 URL
  2. 根据解析结果做不同的处理

所以在处理 Router 时,我们只需要 GET 一个比较合理的 URL 解析方案,这里我选择 ♂ 了 RouterX (opens new window)

本文重点讨论** 根据解析结果做不同的处理**。

# TopViewController

不可避免的是,我们仍然需要有个 ViewController 去调用以下几种方法展示新界面。

func pushViewController(viewController: UIViewController, animated: Bool)
func presentViewController(viewControllerToPresent: UIViewController, animated flag: Bool, completion: (() -> Void)?)
func showViewController(vc: UIViewController, sender: AnyObject?)
func showDetailViewController(vc: UIViewController, sender: AnyObject?)

这里就可能存在依赖关系,传入的参数是 ViewController ,我们需要实例化要展示的 ViewController 。比如我们有个 AViewController ,在 A 中展示 BViewController ,总是容易在 AViewController 写一几行代码去实例化 B ,并传入一些参数,再调用上面的转场方法。

那么,能不能把这些逻辑代码尽可能移到 B 中呢?可以的。

首先我们需要一个 topViewController 。所谓 topViewController 就是最上层的 ViewController ,即你在屏幕上看到的界面对应的 ViewController 。

实现起来并不麻烦。

func topViewController(base: UIViewController? = UIApplication.sharedApplication().keyWindow?.rootViewController) -> UIViewController? {
	  if let nav = base as? UINavigationController {
		    return topViewController(nav.visibleViewController)
	  }
	  if let tab = base as? UITabBarController {
	      if let selected = tab.selectedViewController {
	          return topViewController(selected)
	      }
	  }
	  if let presented = base?.presentedViewController {
	      return topViewController(presented)
	  }
	  return base
}

这里不在赘述相应的逻辑了。

进入本文重点。

# 抽象的 protocol

protocol Routerable {
	  /// path , 用于判断是否为相同类型界面
	  var routingPattern: String { get }
	  /// unique path , 用于判断是否为完全相同界面
	  var routingIdentifier: String? { get }
	  /// GET 方法,一般用来展示新界面
	  func get(url: NSURL, sender: JSON?)
	  /// POST 方法,一般用来更新数据
	  func post(url: NSURL, sender: JSON?)
}

这就是本文最重要的几行代码了。

  • routingPattern 用于判断是否为相同类型界面,比如两个搜索界面,虽然一个搜索内容是 foo ,另一个是 bar ,但同样都是搜索,可以认为是相同的。一般认为一个 ViewController 是对应一个 routingPattern
  • routingIdentifier 用于判断是否为完全相同界面,仍然是搜索界面,一个搜索内容是 foo ,一个是 bar ,虽然同样是搜索,但搜索内容不同,故认为是不完全相同界面。这很有用。
  • get 一般用于展示界面,具体使用姿势见下文。
  • post 一般用于更新界面,具体使用姿势见下文。

当然定义的协议 Routerable 中协议约定只是一个参考,正因为是一个参考,我们可以做很多事情,自由性会很高,

以一个搜索界面为例。

定义 Pattern 为 /search ,表示为搜索界面。

var routingPattern: String {
	  return "/search/:text"
}

根据搜索内容判断是否为相同界面。

var routingIdentifier: String? {
	  return searchBar?.text
}

get 的实现就比较有意思了。一步一步来。

func get(url: NSURL, sender: JSON?) {
	  _searchText = sender?["text"].string
	  Router.topViewControler?.showDetailViewController(self, sender: nil)
}

赋值,通过查找 topViewController ,调用 showDetailViewController 展示自己。这里我们将赋值的逻辑和展示的逻辑全部交给了 SearchViewController

可能需求还不够,我们不希望当前界面是搜索界面,然后还去打开一个搜索界面,这个就非常尴尬了,也是没有必要的。

加一个验证就可以了。

if let topRouter = Router.topRouter where topRouter.routingIdentifier == routingIdentifier {
	  print("打开了完全一样的搜索界面")
	  return
}

判断当前的 topRouter(topViewController) 的 routingIdentifier 是不是相同的。如果是,直接返回就行了。

还不够,既然打开的已经是搜索界面了,那为什么不能更新一下搜索内容呢?

let searchText = sender?["text"].string
if let topRouter = Router.topRouter where topRouter.routingPattern == searchText {
	  print("仍然打开了搜索界面,这里不展示新界面,更新搜索内容")
	  topRouter.post(NSURL(string: "")!, sender: JSON(["text": searchText]))
	  return
}

topRouter 发送一个更新的信息。

对应 post 方法为。

func post(url: NSURL, sender: JSON?) {
	  searchBar.text = sender?["text"].string
}

这样一来就完成了内容的更新。

topRouter.post(NSURL(string: "")!, sender: JSON(["text": searchText])) 这段代码写起来还是比较尴尬的,有更优雅一些的用法,我写了一个 findRouterable 的方法供参考。(毕竟本文只是提供一些思路/思想

上面的代码需要注意的地方是。在 get 方法中拿不到 searchBar ,毕竟是用 Storyboard 创建的,此时 searchBar 还是个 nil ,所以我添加了一个 _searchText 的属性解决该问题。当然,还有其他的方法,比如代码布局(这并不麻烦)。

对于 demo ,你可以尝试不同场景下,在 Safari 中打开链接 router://qing.com/search/Foo ,体验上述逻辑效果。

# 登录验证

比较好玩的是,这里我们可以很轻松的完成对于登录的处理,比如 Timeline ,需要登录才能进入该界面,在 get 中加入如下方法即可。

if (未登录) {
	// 跳转到登录
} else {
	// 展示 Timeline
}

这很方便,我们将打开 timeline 的权限交给了它自己,这样就不需要再每次其他 ViewController 进行跳转时再去写判断逻辑什么的了。

# 补充

需要补充的是,Demo 中的代码可以写的很优雅,当然这不是本文的重点,如何将代码写的更优雅可以参考写更优雅的 Swift 框架 — rx_tap -> rx.tap 以及 写更优雅的 Swift 框架 - 续

# 复杂数据

其实这才是 Router 中比较痛苦的事情,传递一个非常大的 JSON 数据。这里就不推荐使用 url 传值了。比较简单的办法就是通过全局变量 var json = JSON.null 传递值。当然你也可以选择自己喜欢的方式。

# 限定字段

使用 Router 很难避免的就是 String ,到处都是 String 。比较好的方案就是将 String 换成强类型,用 func enum 都可以,Demo 中选择了 enum 。你可以将需要的值全部写到参数中,这样可以一定程度上减少传值时字段写错的问题。

# 域名

这个就非常有意思了,你甚至可以在 app 中定义各种域名,根据不同域名走不同逻辑等等。比如 qing.com xiaoqing.com ,会进行不同的匹配逻辑。

# 总结

总之,思想核心就是对于一个 ViewController ,所有的事情都尽可能的交给这个 ViewController 去做。