iOS - 路由解耦

2017-03-25

路由解耦是啥?

路由解耦,故名思意就是使用URL,根据请求地址进行解析,映射到相关代码的逻辑操作,诸如:页面跳转、UI弹层展示、事件分发、IO存储等等,对于调用方来说,使用统一标准化格式的URL协议即可。
在iOS端的开发中,使用URL交互的地方无外乎:应用内Web、应用外Web、应用模块间解耦单向通信(双向通信使用模块暴露接口会更佳合理和优雅),本文将从Hybrid(JSSDK)、OpenURL(iOS9-)、Universal Links(iOS9+)三方面阐述路由解耦设计的一些经验和思考。

路由解耦能解决什么?

  1. 应用内Web,使用封装的Hybrid(JSSDK)进行双向通信。
  2. 应用外Web,会受到制约:有的APP比较强势,会拦截URL,不允许跳转到外部应用,例如:微信。iOS8目前仍无解(用户占比少),但iOS9+(用户占比极高)的设备可以使用Universal Links来解决该问题。
  3. 应用模块间解耦单向通信,相当于给模块暴露的单向通信接口起一个URL的别名,好处是方便业务部门的人来使用,例如:活动配置,下发URL可以跳转到相应的页面。

技术需求背景

  1. 在早期的iOS开发中,经常需要会遇到调用或跳转外部其他App,诸如:在关于联系我们页面中的电话点击事件触发后直接拨打电话、点击email时弹出发送邮件页(预设好接受人email、主题、内容等)、跳转到Apple的设置页、跳转到Sina微博分享等等,Apple在iOS2.0时提供了OpenURL解决方案来支持上述的需求场景。
  2. 项目中有很多活动相关的Web页,在Web页面中常常需要获取客户端的信息、调用客户端的原生功能,诸如:获取客户端版本号、打开客户端的原生页面、修改导航栏色值、调用客户端的本地逻辑操作等。
  3. 在微信这种强势APP中,OpenURL被它拦截,导致无法跳转自己的APP 的相关功能页。
  4. 由于OpenURL的过程没有任何安全性校验,导致APP内支持的OpenURL可能会被任何的App随时呼起,以及滥用其中的功能。Apple考虑到OpenURL安全性不足,于是在iOS9中推出了Universal Links(通用链接)和ATS 隐私控制,不在AASA、ATS白名单的域名统统被拒绝。我们工程最低版本支持iOS8+,Web页在iOS8的设备上能通过OpenURL唤起APP,但iOS9+后,由于未配置Universal Links,导致OpenURL失效,最终无法唤起APP。

整理技术实现思路

Hybrid(JSSDK)、OpenURL、Universal Links三者都是使用URL统一资源定位符来与客户端通信,这三者的通性,利用这一点,只要URL统一规则,客户端就可以无差别的解析处理了。
格式定义如下:

1
scheme://module:port/method?{query:{}}

  1. 关于scheme,Hybrid(JSSDK)、OpenURL、Universal Links可以使用一致的scheme,一般而言,scheme统一使用项目的scheme即可。Hybrid(JSSDK)使用了scheme为jsbridge来区分这是JSSDK的API。
  2. port,解析后,只有Hybrid(JSSDK)会使用该参数进行OC与JS通信,实现原理很简单,JS提供一个回调方法,port参数用于告知是哪一个事件回调了,就像设置了一个delegate。OpenURL、Universal Links只能URL->客户端的单向通信,无法回传数据给调用方,即无法使用、也不需要port参数。
  3. module,模块,映射到客户端的哪个模块。
  4. method,方法,映射到客户端的哪个模块的哪个方法。
  5. query,参数,映射到客户端的哪个模块的哪个方法中的参数。

不同的iOS版本、应用内、应用外,使用的安全处理机制不一样,解决方案如下:

  1. iOS8设备,Hybrid(JSSDK) 有自己单独的域名白名单,可以监管到安全问题;OpenURL可以随意调用,无法监管到安全问题。
  2. iOS9+设备,Hybrid(JSSDK) 有自己单独的域名白名单,可以监管到安全问题;应用内HTTP、Web由ATS监管安全问题;Universal Links使用配置的apple-app-site-association来监管安全问题。

    注意:如果开启了ATS功能,iOS9和iOS10还是有些区别的,iOS9中的ATS没有区分本地请求和Web请求,iOS10中考虑到Web无法全部使用ATS的场景,所以如果在项目中开启了ATS并设置了NSExceptionDomains白名单以及开启了NSAllowsArbitraryLoadsInWebContent字段为true,那在iOS9应用内Web会验证白名单,而iOS10应用内Web不会验证白名单,直接使用NSAllowsArbitraryLoadsInWebContent允许全部的web关闭ATS验证。当前iOS端主流最低支持版本为iOS8+,这就需要和客户端合作的Web前端同学注意资源域名要在白名单中才能兼容iOS9的设备Web正常访问资源。

一、Hybrid(JSSDK)

关于JSSDK,核心技术有以下几点:

  1. URL的协议定义,上述讲了,为了避免上翻回去查找,这里再贴一次,格式为:

    scheme://module:port/method?{query:{}}

  2. 使用OC的动态性、以及使用block回调,动态查找类、方法,动态调用方法,动态传入参数,动态性+block的组合很完美。

  3. 利用port字段,在OC回调JS方法时传入,作为delegate使用,这样就支持了OC与JS的双向通信能力。
    实际编码过程中,JSSDK的核心代码量不超过100行,是不是特别简单容易理解。

Hybrid(JSSDK)的使用场景及URL举例

举例双向通信和单向通信,栗子来了:
使用场景如下:

  1. Web页调用JS获取客户端的信息,通过port端口号回调JS方法,好比:获取客户端的VersionCode,如下代码所示:

    1
    2
    //双向通信
    jsbridge://device:1/versionCode
  2. Web页调用JS设置导航栏右侧按钮,点击按钮后,通过port端口号回调JS方法,好比:设置Web页导航栏右侧按钮为分享,如下代码所示:

    1
    2
    //双向通信
    jsbridge://ui:2/setHeaderRight?{"query":{"icon":"share"}}
  3. 应用内Web页的JS -> OC 单向通信,好比:Web页中点击封面跳转客户端原生书籍详情页:

    1
    2
    //单向通信
    jsbridge://app/showBook?{"query":{"bookId": 1004976324}}

二、OpenURL

OpenURL设计的目的就是解耦模块,我们来看看它能做什么:

  1. Apple就提供了若干的功能,诸如:打电话、发邮件、打开AppStore等。
  2. 常见的第三方登录模块也用的OpenURL来进行APP间跳转式的双向通信。
  3. APNS推送中可以添加字段URL,用户点击推送后,可以执行相应逻辑,这里能很清楚地看得到模块间的解耦。
  4. 桌面APP使用3D Touch时,可以用OpenURL的思路解决模块解耦问题,在设置3D Touch项时,使用OpenURL作为item的描述,这样3D Touch回调事件里只需写一句话:执行OpenURL。

OpenURL遇到的坑

实际使用过程中,我发现Apple的OpenURL效率并不高,在某些场景下速度慢的惊人,譬如上述它能做什么中的第4点,如果在3D Touch回调中直接使用Apple的OpenURL方法,真机上看到的现象是进入APP奇慢无比,像是卡住了一样。3D Touch回调事件代码如下:

1
2
3
4
5
- (void)application:(UIApplication *)application performActionForShortcutItem:(UIApplicationShortcutItem *)shortcutItem completionHandler:(void(^)(BOOL succeeded))completionHandler {
NSString *urlString = shortcutItem.type;
if (urlString.length == 0) return;
[[UIApplication sharedApplication] openURL:[NSURL URLWithString:[urlString stringByAddingPercentEscapesUsingEncoding:NSUTF8StringEncoding]];
}

解决办法也很简单:
其实想想就知道OpenURL是用于APP外部调用的,在我们APP应用内直接走解析URL和动态执行方法就好了。改进后的代码:

1
2
3
4
5
- (void)application:(UIApplication *)application performActionForShortcutItem:(UIApplicationShortcutItem *)shortcutItem completionHandler:(void(^)(BOOL succeeded))completionHandler {
NSString *urlString = shortcutItem.type;
if (urlString.length == 0) return;
[self qd_handleActionUrl:[NSURL URLWithString:[urlString stringByAddingPercentEscapesUsingEncoding:NSUTF8StringEncoding]]];
}

OpenURL的使用场景及URL举例

使用场景如下:

  1. 应用内部调用URL路由作模块解耦,好比:书籍详情页调用加书架功能:

    1
    QD://app/addToBookshelf?{"query":{"bookId": 1004976324}}
  2. APNS推送点击后可以响应客户端本地事件,好比:用户收到了一条热门书籍推送,点击推送后跳转客户端原生书籍详情页:

    1
    QD://app/showBook?{"query":{"bookId": 1004976324}}

三、Universal Links

这里讲到它的目的,是以下2点:

  1. 在iOS9+时,Apple提出了ATS的要求,以前的OpenURL在Web前端可以随意被其他APP滥用,没有任何的安全机制保护,于是推出了Universal Links来增加OpenURL在Web前端的安全合法校验,只有域名验证通过的链接,才能使用OpenURL打开APP。
  2. 微信中拦截了URL,不让跳转到外部应用中。但Universal Links是系统级别的,可以绕过微信的拦截限制。

它和Hybrid(JSSDK)、OpenURL的URL格式有所区别,它的格式如下:
PS: 由于Universal Links只作单向通信,所以我们忽略port参数(port参数的目的就是作为双向通信delegate)。

1
scheme://host/route/module/method?{query:{}}

它的区别在于:它多出来了host/route,除了这一点,其他格式和Hybrid(JSSDK)、OpenURL的URL毫无差别,所以当客户端接收到URL时,过滤掉host/route就可以了。

Universal Links的使用场景及URL举例

使用场景如下:

  1. iOS9+在Web前端的链接,好比:书籍详情页调用加书架功能:

    1
    QD://qidian.com/reader/app/addToBookshelf?{"query":{"bookId": 1004976324}}
  2. 微信中点击链接打开客户端原生页面, 好比:微信公众号里分享了一本经典小说,点击后跳转打开客户端原生阅读页:

    1
    QD://qidian.com/reader/app/openBook?{"query":{"bookId": 1004976324}}

实现要点

解析URL的入口

Hybrid(JSSDK)、OpenURL、Universal Links 3者处理URL入口不一样,具体如下:

  1. Hybrid(JSSDK)在UIWebView的delegate回调中拦截URL进行特殊处理,如果Hybrid(JSSDK)拦截成功,则直接返回NO,打断UIWebView自身的处理逻辑;否则返回YES,继续UIWebView的处理逻辑,方法如下:

    1
    -(BOOL)webView:shouldStartLoadWithRequest:navigationType:
  2. OpenURL,在AppDelegate的回调中处理解析URL,iOS8与iOS9的有所区别,如下所示:

    1
    2
    3
    4
    5
    //iOS9代理回调方法
    -(BOOL)application:openURL:options:

    //iOS8代理回调方法
    -(BOOL)application:openURL:sourceApplication:annotation:
  3. Universal Links,在AppDelegate的回调中处理解析URL,方法如下:

    1
    2
    //需要注意一点的是,通用链接过来的URL,会带有host/route,在代理方法中需要过滤掉host/route,将格式统一为scheme://module/method?{query:{}}
    -(BOOL)application:continueUserActivity:restorationHandler:
  4. 暴露了一个C函数,方便跨模块调用,主要用于解耦目的。目前只支持单向通信(考虑到双向通信会增加复杂度,使用模块对外暴露接口更优雅,所以暂不考虑双向通信)。举例,调用方法如下:

    1
    QDRouteBridge(@"QDReader://app/addToBookshelf?{\"query\":{\"bookId\": 1004976324}}")

解析后的字段作路由转发

当成功解析了scheme、port、module、method、query部分后,iOS客户端会通过module映射到一个类名,method映射到其中的一个具体方法,query、port这2个作为传入参数,然后执行该方法。
总结关键执行步骤:

  1. 映射到具体的类。
  2. 拼接方法名。
  3. 利用OC语言的动态性,动态调用方法。
    关键的代码部分:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    -(BOOL)qd_handleJsRequest:(NSURL *)request module:(NSString *)module method:(NSString *)method port:(NSNumber *)port query:(NSDictionary *)query fromEnv:(NSDictionary *)env {
    if (!request ||
    !module ||
    !method) {
    return YES;
    }

    SEL selector = NSSelectorFromString([NSString stringWithFormat:@"qd_handleJsBridgeRequest_%@_%@:port:", module, method]);
    if ([self respondsToSelector:selector]) {
    id<QDJSWebViewProtocol> webViewController = [env objectForKey:kJSPluginEnvWebViewController];
    self.webViewController = webViewController;

    ((void (*)(id, SEL, NSDictionary*, NSNumber *))objc_msgSend)(self, selector, query, port);
    return YES;
    }

    return NO;
    }

未完待续