美团 app 之 3D Touch 初探

3D Touch

在现在的 Multi-Touch 技术下,我们见的最多的就是点,滑和缩放操作,这些操作都是在二维的维度下。而 3D Touch 则是可以给操作提供了一个新的维度——可以感受用户按压的力度。

适用场景

目前官方提供了三个可以适用 3D Touch 的场景:

Pressure Sensitivity

可以用于绘画的 App,通过不同的力度来画出不同的线条。

Pressure Sensitivity

Peek and Pop

一个快速预览的功能。

Peek and Pop

Quick Action

主屏幕上的 App 快捷菜单,提供一个快接入口,简化一些操作。

Quick Action

目前来看,后两者更适合于我们的App。

关于更多 3D Touch 的介绍,详见:https://developer.apple.com/ios/3d-touch/ 或者 http://www.apple.com/cn/iphone-6s/3d-touch/

静态的 Quick Action

考虑到适配工作和当前业务的使用情况,我们的 PM 首先选择了在主屏幕上添加一个 Quick Action 这样一个快捷菜单,嗯……如下图所示

美团 Quick Action

这是三个静态的菜单选项,何为静态的?就是从 App 安装后一直是长这个样子的,它是声明在 Info.plist 里面的 UIApplicationShortcutItems 数组中的。

Info.plist

我这里只用到了三个key:

  • UIApplicationShortcutItemType,有点像是这个 item 的标识符,它会通过 launchOption 传到 appDelegate 里面然后供开发者判断 app 是通过哪个 item 启动的,以便处理自己的逻辑
  • UIApplicationShortcutItemTitle,顾名思义了,这个就是用于设置快捷菜单标题的
  • UIApplicationShortcutItemIconFile,这个是用于设置快捷菜单的 icon 的,不过是使用工程中的图像文件来设置的,这是一个 35 * 35 点,单色的图片。

其中前两个 key 是必须要声明的。这里还有其他的一堆 key 用于 item 的定制:

  • UIApplicationShortcutItemSubtitle,这是用来设置副标题的,不是必要的,如果设置了这个将会在标题下方第二行出现一行副标题。另外,如果没有副标题的话,标题过长会自动折行到第二行。
  • UIApplicationShortcutItemIconType,这个是用来设置图标的,不过这个是使用系统提供的一堆图标,这个key其实是声明在 UIApplicationShortcutItemIcon 中的一个枚举,想用的话可以从这里面找,不是很多。另外,如果你又通过 UIApplicationShortcutItemIconFile 来定制了自己的 icon,那么系统将会自动忽略这个 key 的设置而使用来自 file 的 icon 设置。
  • UIApplicationShortcutItemUserInfo,苹果说这个可以利用这个 key 让开发者提供一些类似 app 版本信息的东西,用于 app 升级后但是还没有被用户启动,但是用户又通过 Quick Action 来启动了 app,可能会存在的问题。这个时候,这个 key 所提供的类似版本信息的东西就可能会派上用场。但是我还没有发现它的使用场景。

当你在 Info.plist 里面设置好这些东西后,你就能在支持 3D Touch 的设备上通过用力按压 app 的 icon 呼出这个快捷菜单了,并且你还可以通过这个菜单来启动 app,只是没有做任何的处理,现在从这个菜单启动 app 和从 icon 启动没啥两样。接下来我们还要对从菜单启动 app 这个逻辑进行单独的处理。同样我们还是先说静态的菜单选项:

剩下的工作我们就要在 appDelegate 这里面来做了。首先我们要先认识下新增的一个回调:-application:performActionForShortcutItem:completionHandler:

这个回调会在 app 通过快捷菜单启动或者从后台唤醒的时候被调用,但是,如果你在 -application:didFinishLaunchingWithOptions: 或者 -application:willFinishLaunchingWithOptions: 返回的是 false,这个回调就不会被调用。苹果官方给的 Demo 里面是这样写的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func application(application: UIApplication, didFinishLaunchingWithOptions launchOptions: [NSObject: AnyObject]?) -> Bool {
// Override point for customization after application launch.
var shouldPerformAdditionalDelegateHandling = true
// If a shortcut was launched, display its information and take the appropriate action
if let shortcutItem = launchOptions?[UIApplicationLaunchOptionsShortcutItemKey] as? UIApplicationShortcutItem {
launchedShortcutItem = shortcutItem
// This will block "performActionForShortcutItem:completionHandler" from being called.
shouldPerformAdditionalDelegateHandling = false
}
// Launch Code
......
return shouldPerformAdditionalDelegateHandling
}

这个代码的意思是,我在程序启动的时候看下是不是通过快捷菜单启动的,如果是的话,就不让 performActionForShortcutItem:completionHandler 这个回调被调用。具体的原因没有说,但是通过这个回调来看,会有影响程序启动的可能性,所以为了保证程序能够顺畅的启动,先把这里面传过来的 UIApplicationShortcutItem 存下来,在下面程序启动完后另外找个机会再处理接下来的逻辑,什么时候呢?

1
2
3
4
5
6
7
func applicationDidBecomeActive(application: UIApplication) {
guard let shortcut = launchedShortcutItem else { return }
handleShortCutItem(shortcut)
launchedShortcutItem = nil
}

-applicationDidBecomeActive 这个回调中,通过判断是否有 launchedShortcutItem,手动调起用于处理逻辑的方法。如果是从后台唤醒的话,就不需要这么麻烦了,直接通过系统正常调用 performActionForShortcutItem:completionHandler 这个回调就好啦:

1
2
3
4
5
func application(application: UIApplication, performActionForShortcutItem shortcutItem: UIApplicationShortcutItem, completionHandler: Bool -> Void) {
let handledShortCutItem = handleShortCutItem(shortcutItem)
completionHandler(handledShortCutItem)
}

所以以防重复调用,这里在首次启动完后,需要在 -applicationDidBecomeActive 将这个手动存储的 property 置为 nil

大体上一个快捷菜单的启动流程就是这样了。可是真正在我们的工程中可能还有一些问题:

  1. 我们的启动很复杂,可能有各种启动画面
  2. 需要跳转好多界面啊,还需要各种参数,前置界面,bulabula…
  3. 需要登录怎么办?我们从哪个界面发起新页面的跳转?用户取消后返回到哪里?
  4. ……

首先,通过快捷菜单启动 app 的话,我们自定义的启动画面会让这整个过程变得很复杂,因为系统的整个启动流程很快,但是可能真正到跳转方法被执行的时候,我们冗长的启动画面还没有结束,首页还没有创建,结果…… Push Failed!

为了保证整个跳转过程能够在启动画面完成,并且首页创建(或者说我们自己写的 tabbarController 创建好)后执行,我们就需要观察这个至关重要的 tabbarController

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
- (void)applicationDidBecomeActive:(UIApplication *)application
{
......
if ([[[UIDevice currentDevice] systemVersion] compare:@"9.0" options:NSNumericSearch] != NSOrderedAscending) {
// 用于处理来自 ShortcutItem 的唤醒动作,我们的 App 一开始有一大堆的界面广告之类的,调用早的话,tabbarController 还没创建,就没办法定位发动跳转的界面了
// 所以还是稍等下,等 tabbarController 创建好了我们再继续处理跳转逻辑
// 另外,这里仅仅处理首次启动时来自 ShortcutItem 的跳转逻辑,如果是从后台唤醒的话,请使用 -application:performActionForShortcutItem: completionHandler:这个回调
@weakify(self);
[[RACObserve(self, tabbarController) deliverOn:[RACScheduler mainThreadScheduler]] subscribeNext:^(MTGroupTabbarController *tabbarController) {
@strongify(self);
if (self.launchedShortcutItem && tabbarController) {
[self handleShortCutItem:self.launchedShortcutItem];
// 这里在首次启动完后,将这个 property 置 nil ,以防从后台唤醒的时候重复调用上面的方法
self.launchedShortcutItem = nil;
}
}];
}
}

至于 -application:performActionForShortcutItem:completionHandler: 这个回调,就比较简单了,教科书般的写法就好了:

1
2
3
4
5
6
7
8
9
10
11
// 这个回调会在程序通过ShortcutItem启动的被调用
// 但是如果 -application:didFinishLaunchingWithOptions:
// 或者 -application:willFinishLaunchingWithOptions: 返回的是NO,这个回调就不会被调用。
// 如果程序通过 ShortcutItem 启动的话,为了不影响 app 的正常启动
// 在上面说的那两个回调中存一下 ShortcutItem 后返回 NO,然后在 -applicationDidBecomeActive: 中处理跳转逻辑
// 正常情况下建议这个回调是用于程序通过 ShortcutItem 从后台被唤醒的时候调用
- (void)application:(UIApplication *)application performActionForShortcutItem:(UIApplicationShortcutItem *)shortcutItem completionHandler:(void (^)(BOOL))completionHandler
{
BOOL handledShortcutItem = [self handleShortCutItem:shortcutItem];
completionHandler(handledShortcutItem);
}

当然了,为了不影响我们正常的启动(尽管已经很慢了),我们还需要在 -application:didFinishLaunchingWithOptions: 里面做一下处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
BOOL shouldPerformAdditionalDelegateHandling = YES;
......
if ([[[UIDevice currentDevice] systemVersion] compare:@"9.0" options:NSNumericSearch] != NSOrderedAscending) {
// 判断是否是从 ShortcutItem 启动的 app
if ([launchOptions[UIApplicationLaunchOptionsShortcutItemKey] isKindOfClass:[UIApplicationShortcutItem class]]) {
// 将用于启动的 ShortcutItem 存下来后面用
self.launchedShortcutItem = launchOptions[UIApplicationLaunchOptionsShortcutItemKey];
// 为了不再调用 -application:performActionForShortcutItem:completionHandler:
shouldPerformAdditionalDelegateHandling = NO;
}
}
return shouldPerformAdditionalDelegateHandling;
}

最重要的还是处理来自快捷菜单的启动(或唤醒)逻辑,说白了这个快捷菜单就是个快捷入口,处理起来也很简单,就是个跳转。例如用户选择了『美团券』,app 启动完后就需要进入到美团券列表页。有了上面的 tabbarController 的保证,我们就能够利用这个东西做一些事情:

1
2
3
4
5
6
7
8
9
10
11
12
......
// 切换到首页
MTGroupTabbarController *tabBarController = self.tabbarController;
tabBarController.selectedIndex = 0;
MTNavigationController *destinationViewController = (MTNavigationController *)tabBarController.selectedViewController;
// 做一个 popToRootViewController 的操作
UIViewController *homePageViewController = [destinationViewController.viewControllers firstObject];
homePageViewController.navigationController.viewControllers = @[homePageViewController];
......

发起跳转前,先做个 tab 的切换,保证是通过首页跳转的。

然后我们就要判断传入的快捷菜单选项是什么了,这里就用到了上面提到的 UIApplicationShortcutItemType 这个字段来判断了:

1
2
3
4
5
6
......
if ([shortcutItem.type isEqualToString:@"Search"]) {
// 处理来自搜索的跳转
......
}

跳转这一块我们有现成的基于 URL 跳转的机制,能够实现通过 URL 跳转到指定的 viewController,我们就利用自己的库来处理。在这里就不过多叙述。

如果处理完后,还要告诉外面通过快捷菜单启动的逻辑完成了,就需要个 boolean,所以整个方法大致这个样子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// 以下 string 声明在 Info.plist 中 UIApplicationShortcutItems 内
static NSString *const METShortcutSearchItemType = @"Search";
static NSString *const METShortcutCouponItemType = @"Coupon";
static NSString *const METShortcutScanItemType = @"Scan";
......
// 处理 Quick Action 的跳转操作的方法
- (BOOL)handleShortCutItem:(UIApplicationShortcutItem *)shortcutItem
{
BOOL handled = NO;
if (!shortcutItem) {
return NO;
}
// 切换到首页
MTGroupTabbarController *tabBarController = self.tabbarController;
tabBarController.selectedIndex = 0;
MTNavigationController *destinationViewController = (MTNavigationController *)tabBarController.selectedViewController;
UIViewController *homePageViewController = destinationViewController.topViewController;
if ([shortcutItem.type isEqualToString:METShortcutSearchItemType]) {
handled = YES;
// 跳转到搜索页面
......
} else if ([shortcutItem.type isEqualToString:METShortcutCouponItemType]) {
handled = YES;
// 先判断是否有登录,没有的话先登录再跳转美团券列表页
......
} else if ([shortcutItem.type isEqualToString:METShortcutScanItemType]) {
handled = YES;
// 跳转到扫一扫
......
} else {
// do nothing
}
return handled;
}

一个静态的快捷菜单启动的流程差不多就是这个样子了。

动态的 Quick Action

静态的快捷菜单在 app 被安装后就会创建,哪怕你一次都没有启动过。

看起来是没问题的,但是我们的 app 在用户首次启动的时候会有漫长的引导界面还有一个城市选择界面,如果用户通过快捷菜单首次启动 app 的话,就会和引导界面还有城市选择界面冲突,并且实际上用户还没有启动 app 就展现这三个功能性的快捷菜单逻辑上也有点欠妥。

参考 EverNote 等一些 app 的做法之后,决定不用静态的快捷菜单,而是等到用户真正启动过一次 app 之后再动态生成,那就需要用到动态生成的快捷菜单。

具体判断逻辑跳转方法都不需要调整,唯一需要做的就是把声明在 Info.plist 的菜单项删掉,改成在 -application:didFinishLaunchingWithOptions: 里面动态生成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
// Override point for customization after application launch.
BOOL shouldPerformAdditionalDelegateHandling = YES;
// 判断是否是从 ShortcutItem 启动的 app
if ([launchOptions[UIApplicationLaunchOptionsShortcutItemKey] isKindOfClass:[UIApplicationShortcutItem class]]) {
// 将用于启动的 ShortcutItem 存下来后面用
self.launchedShortcutItem = launchOptions[UIApplicationLaunchOptionsShortcutItemKey];
// 为了不再调用 -application:performActionForShortcutItem:completionHandler:
shouldPerformAdditionalDelegateHandling = NO;
}
if (application.shortcutItems.count == 0) {
UIMutableApplicationShortcutItem *searchItem = [[UIMutableApplicationShortcutItem alloc] initWithType:@"Search" localizedTitle:@"搜索" localizedSubtitle:nil icon:[UIApplicationShortcutIcon iconWithTemplateImageName:@"icon_shortcut_search"] userInfo:nil];
UIMutableApplicationShortcutItem *couponItem = [[UIMutableApplicationShortcutItem alloc] initWithType:@"Coupon" localizedTitle:@"美团券" localizedSubtitle:nil icon:[UIApplicationShortcutIcon iconWithTemplateImageName:@"icon_shortcut_coupon"] userInfo:nil];
UIMutableApplicationShortcutItem *scanItem = [[UIMutableApplicationShortcutItem alloc] initWithType:@"Scan" localizedTitle:@"扫一扫" localizedSubtitle:nil icon:[UIApplicationShortcutIcon iconWithTemplateImageName:@"icon_shortcut_scan"] userInfo:nil];
application.shortcutItems = @[searchItem, couponItem, scanItem];
}
return shouldPerformAdditionalDelegateHandling;
}

这里面利用到了 UIApplicationShortcutItem 这个类,生成 UIMutableApplicationShortcutItem 并添加到 UIApplication 里面的 shortcutItems 这个属性中。可以看一下这个指定初始化器:

1
2
3
4
5
- (instancetype)initWithType:(NSString *)type
localizedTitle:(NSString *)localizedTitle
localizedSubtitle:(nullable NSString *)localizedSubtitle
icon:(nullable UIApplicationShortcutIcon *)icon
userInfo:(nullable NSDictionary *)userInfo NS_DESIGNATED_INITIALIZER;

从上面的参数来看,和 Info.plist 里面设置的一样, typetitle 是必须的,剩下的都是可选择的。

这些设置好后,直接赋值到 shortcutItems 这个属性中就好了。

小坑:为了防止有些用户没有完成首次启动 app 的城市选择设置,就把 app 杀掉,下次进来还会有可能冲突,索性我们就等用户完成所有的首次启动设置后,再创建这个菜单。

由于在我们的 app 中,如果没有选择城市,userDefaults 里面就不会存储城市信息,那就监听用于存储城市的 userDefaults 里面的 Dictionary 好了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
......
[[[[NSUserDefaults standardUserDefaults] rac_channelTerminalForKey:kMTUserDefaultCityKey] deliverOn:[RACScheduler scheduler]] subscribeNext:^(NSDictionary *cityInfo) {
if (cityInfo) {
UIMutableApplicationShortcutItem *searchItem = [[UIMutableApplicationShortcutItem alloc] initWithType:METShortcutSearchItemType localizedTitle:@"搜索" localizedSubtitle:nil icon:[UIApplicationShortcutIcon iconWithTemplateImageName:@"icon_shortcut_search"] userInfo:nil];
UIMutableApplicationShortcutItem *couponItem = [[UIMutableApplicationShortcutItem alloc] initWithType:METShortcutCouponItemType localizedTitle:@"美团券" localizedSubtitle:nil icon:[UIApplicationShortcutIcon iconWithTemplateImageName:@"icon_shortcut_coupon"] userInfo:nil];
UIMutableApplicationShortcutItem *scanItem = [[UIMutableApplicationShortcutItem alloc] initWithType:METShortcutScanItemType localizedTitle:@"扫一扫" localizedSubtitle:nil icon:[UIApplicationShortcutIcon iconWithTemplateImageName:@"icon_shortcut_scan"] userInfo:nil];
application.shortcutItems = @[searchItem, couponItem, scanItem];
}
}];
......

这样,一个动态生成的 Quick Action 菜单就生成了。

我们上面提到了,每一个 itemtype 就像是一个唯一标示符,我们还可以利用这个 type 动态改变这个 item 的内容。然后再设置给这个 shortcutItems 这个属性就好了。
动态的 item 差不多就是这个样子了。

小结

其实如果我们的 app 中并没有这么多的启动逻辑,用静态的 Quick Action 应该是最佳选择,而如果说非要用静态实现,也到不难,在处理接受到 Quick Action 事件的回调中,对 userDefaults 进行监听,如果首次启动完成了我们再调跳转方法。

动态的 Quick Action 则给我们提供了很多可能,例如动态配置一个活动入口啥的。

随着支持 3D Touch 的设备越来越多,苹果也应该会给 3D Touch 加入更多的可操作性,到那时候,我们可能还会有更多针对 3D Touch 的探索和适配,这只是个开始。