Apple Watch 即将于 4 月下旬发售,而 Watch App 的开发已成为 iOS 开发的热点。本文作者通过 Watch App 的实际开发经验,将其中的一些注意事项总结分享给大家。以下为正文:
接触 Apple Watch 相关的开发工作已经差不多快三个月时间了,每天都会去逛逛 WatchKit 苹果的开发者论坛,看看最近都有哪些其他开发者 po 出来的问题。我自己也遇到不少问题,其中很多都是我自己摸索着解决掉的。
苹果公布的关于 Apple Watch 的信息很多,用于开发已经足够,但一切感觉都是在抹黑前行,因为无法进行真机测试,包括 Handoff, 也包括语音输入,以及发布会上的那个类似 Emoji 的表情都是些什么。
自己来现在的公司实习到今,主要做的工作几乎都和 iOS8 新特性有关,毕竟现在公司这个项目实在是太成熟了,摸熟悉也需要一个过程。包括之前的 Today Widget,到后来的 Handoff,包括因为要适配 iPhone6 做的适配方面的调研等等,都是从去年 WWDC 之后的新事物,转眼就到 2015 年的 WWDC 了,不知道今年会有哪些革新的新事物。
闲话说到这里吧,是时候总结一下这两个月的收获和掉坑了。
目前开发者网站上的这几部分我觉得是开发 Watch 必须学习几遍的东西,还有苹果开发者论坛也是一个不错的交流地方。
- WatchKit Framework Reference
- WatchKit Development Tips : Optimize your WatchKit apps with these tips and best practices.
- Apple Watch Programming Guide
- Developer Forum
1. Watch Main App
在 iPhone 上,主程序是大哥,其他的小扩展必须让路,但是在 Watch 上,是不是大哥还要看这个 APP 主要的功能。如果是一个阅读性质的 APP,主程序在手表上作用还真不大,例如阅读新闻等等。如果是这类的应用,想在 Watch 上出彩,或者让用户使用的次数多一些,就要靠良好的 Notification 体验,以及极其方便用户生活的 Glance 了。
(1)以 Page-Based 方式启动 Watch App
如上图,现在手上要做的一个交互是,App 启动的时候是六个页面,用户可以左右滑动来切换,这里就需要在 MainInterfaceController 中使用下边这个方法了。
[WKInterfaceController reloadRootControllersWithNames: _controllersArrays contexts:_contextsArray];
在 Watch 上页面之间转换传值,很重要的一个纽带就是这个 context,传递有用的信息和标识,这个方法中,我传递进入六个 controller 的 interface builder identifier, 以及事前拼好的六个 context。
因为 Watch App 的打开可以是几种不同方式的,可以写一个统一的方法 [self showController],在这个方法中去选择启动哪一个具体的 Controller。我在.h 文件中定义了一个枚举来定义不同的启动方式:
typedef enum { WKOpenForNormal, // 普通打开 WKOpenForComment, // 打开评论页 WKOpenForFavorite, // 打开收藏页 WKOpenForGlance // 打开来自 glance 的内容 } WKOpenType; {1}
因为用户如果选择了点击 Glance 来查看具体的内容的话,Glance 和 MainApp 是通过 Handoff 来实现通信的,我们可以在入口的控制器中的:
- (void)handleUserActivity:(NSDictionary *)userInfo;
这个方法中去将 WKOpenType 赋值成 WKOpenForGlance。
当然了,如果是从 Notification 来的,我们完全可以通过:
- (void)handleActionWithIdentifier:(NSString *) identifier forRemoteNotification:(NSDictionary *)remoteNotification;
这个方法来根据具体的用户点击的动作来区分不同的打开方式。
这里比较难处理的是,如果用户是从 Glance 进来的,退出这个控制器,还是要显示那六个页面的,这里我的解决方法是注册通知。在出来的控制器中的 - (void)didDeactivate; 方法中 post 出来通知,来让主控制器重新打开六个 Page 页面。Notification 同 Glance。
(2)Watch App 与 Host App 联合调试
因为程序中多处用到了下边这个方法,因此主程序和 Watch App 联合调试就显得非常必要了,在 Xcode 的一个新 beta 的 release note 中苹果介绍了一种方法。
+ (BOOL)openParentApplication:(NSDictionary *)userInfo reply: (void(^)(NSDictionary *replyInfo, NSError *error)) reply;
- 首先 run 起来 Apple Watch App 在模拟器中。
- 在 iphone 模拟器中启动 demo App。
- Xcode - Debug - Attach to Process 里找到 host app 线程,Attach 上。
完成以上三个步骤,主程序和手表程序上的端点都可以进行调试。
(3)申请数据方面
在开发初期,我是在 extension 中进行数据的申请,这样尝试了一段时间之后发现性能上优化的空间不大,而且写出了很多重复的代码。复用项目中已有的代码是我最好的选择,尤其是一些第三方用 pod 管理的库,但是考虑到公司的项目已经是非常成熟的了,一些管理的第三方库无法正常的使用,进而又去考虑写一个共用的框架,由于时间问题,项目有点大,抽筋抽骨的不是很合适,所以决定充分发挥 openParent 这个方法,将申请数据这块放在主程序中,顺便将所有需要“问”主程序的东西全部整理到一个类中,这样就可以充分发挥老代码的作用。
数据策略大致如下:首先为了优化 Watch App 的启动速度,采用后台申请数据存起来,Watch 每次去使用就可以了,最后处理一下冷启动的问题,这种情况是当安装了我们的软件,没有在 iPhone 上打开过,直接打开 Watch 上的程序的时候已然有数据,这么做的话除了第一次会启动的稍微慢一点点之外,剩下的启动速度就会快很多。
具体用到的方法是:
- (void)application:(UIApplication *)application performFetchWithCompletionHandler: (void (^)(UIBackgroundFetchResult result))completionHandler NS_AVAILABLE_IOS(7_0);
我和同事做到这里的时候,就感觉是一个 iPhone 当做了服务器,而 Watch 则是一个终端,有什么需要的数据,我们两个人设计好协议,通过 openparent 这个方法沟通。比如说,软件运行当中如果想要知道一个用户是否登录了,因为没有登录是没有某些功能的,那么这个时候通过 openparent 咨询一下 isLogin 就好,判断一下是否登录。
Demo 中 watch 端代码实现如下:
[WKInterfaceController openParentApplication:@{@"type":@"isLogin"} reply:^(NSDictionary *replyInfo, NSError *error) {}
Demo 中 iphone 服务端代码实现如下:
#pragma mark - WatchKit Data -(void)application:(UIApplication *)application handleWatchKitExtensionRequest:(NSDictionary *)userInfo reply:(void (^)(NSDictionary *))reply { NSString *type = userInfo[@"type"]; NSDictionary *para = userInfo[@"para"]; ... ... NSDictionary *replyInfo; if ([type isEqualToString:@"isLogin"]) { int random = arc4random()%10 + 1; NSString *whetherLogin = @""; if (random == 1) { whetherLogin = @"YES"; }else { whetherLogin =@"NO"; } replyInfo = @{@"whetherLogin":whetherLogin}; } else if ([type isEqualToString:@"isFavorite"]) { ... ... ... reply(replyInfo); }
Demo 中有三种协议,分别是是否登录,回复信息,是否收藏,当然都是假的,根据项目需求来进行改变,务必注意的是每一种情况都要回调 reply(replyInfo);,否则这个方法实际上会响应失败。
而实际上,项目当中需要在 Watch 上显示很多图片的,这个就需要异步的申请一下,首要想到的还是 SDWebImage 这个经典框架,这里就可以在 openParent 里使用将 data 请求到,然后返回给 Watch。
PS:最后的最后,我们发现使用 App Group 来通信数据更加的有效率,因此一部分数据的请求采用了 App Group 来实现。
(4)TableView 在 Watch 上的使用
在 SDK 发布的初期,我以为新控件之一 WKInterfaceGroup 可以点击,因为目前来看 watch 上是没有图层的概念的,复杂的 UI 布局是相当困难的,布局方式和之前有很大的区别,包括在故事板中的布局方法。当初为了实现产品给过来的 UI 布局也是脑洞大开啊,比如各种嵌套 Group, 为了要实现 demo 中主页的这种感觉,我很自然的想到了,放一个 group,背景放图片,其他控件放在 group 上就好了,解决了无法实现控件在控件之上的问题。但是这就需要 group 可以点击,盼星星盼月亮之后,Xcode6.2 正式版出来之后彻底断了我这个念头,没办法,只能通过另一个控件 WKInterfaceTable 来实现了,每一页只有一行不就可以了么,只能这么干了。
WKInterfaceTable 和 UITableView 使用上还是有一些不同的,也比 UITableView 的使用方便了很多。
首先你需要去定义一个 Row 类,这个 Row 类相当于一个 cell,在这个 Row 上去布局,如果你的表格中呈现数据的方式不一样,那就要定义不同的 Row 类。
定义好之后,调用的时候需要使用如下方法:
#pragma mark - UI - (void)setUpUI { [self.newsRowTabel setNumberOfRows:1 withRowType:@"RowForOneNews"]; for (int i = 0; i < self.newsRowTabel.numberOfRows; i++) { JRWKNewsRow *newsRow = [self.newsRowTabel rowControllerAtIndex:i]; [newsRow.newsCategory setText:[NSString stringWithFormat:@" 第 %ld 张 ",_index+1]]; ... ... } }
RowType 唯一标识了一个 Row 类,这里我设置了只有一行,期间设置 Row 类中每一个属性的 UI 数据。
响应点击事件需要去实现:
#pragma mark - Table Row Select -(void)table:(WKInterfaceTable *)table didSelectRowAtIndex:(NSInteger)rowIndex { NSDictionary *contextDic = @{@"PicName":_picName,@"index":[NSNumber numberWithInteger:_index]}; [self presentControllerWithName:WKNEWSDETAILCONTROLLERIDENTIFIER context:contextDic]; }
这里去指定具体要呈现出来的是哪一个 Controller。
如果表格中的一行不能点击的话,在故事板中设定的时候把 selectable 勾选掉就可以了。
(5)数据在 Controller 间的传递
API 中的几个关于 Controller 切换的方法当中几乎都有 context 参数,也就是说传递数据由我们决定了。在十二月份刚开始写程序的时候,我传递的是一个很大的字典,发现在程序启动的时候非常的慢,后来决定写一个模型管理类,controller 之间只需要传递一个 index 就可以了。在 demo 中保留了完整的类。
(6)关于 HandOff
HandOff 在 iOS8 之后出现,着实是为了 Apple Watch 量身打造的好么,实在是太应景了,因此在 Watch 上合理的运用 handoff 是一个顺理成章的事情,而 WKInterfaceController 也带上了相关的一些方法,实际上是要比 iphone 上的简单易用一些的。
另一方面,在 Glance 界面,进入到主 App 上的时候,handoff 也起了决定性的作用,通过 handoff 将具体的信息交给主 App 去处理。
主要有两个 Api,这个是 update 了全局的 Activity,将我们需要传递的信息打包成一个 userinfo 即可。
- (void)updateUserActivity:(NSString *)type userInfo: (NSDictionary *)userInfo webpageURL:(NSURL *)webpageURL;
下面这个我还记得是开发者 watchkit 论坛里有一位开发者问过这个问题,在 watchkit 里怎么没有干掉 Activity 这一个方法。后来苹果的工程师估计是采纳了。但实际的效果来看,这个方法作用不大,例如在公司的项目中,几乎每一个页面都是需要 handoff 的,给它 invalidate 之后,iphone 左下角出现 logo 就会出现异常甚至是不出现的情况。因此如果不是已经很明确的话,轻易的不要用这个方法。
- (void)invalidateUserActivity;
总之,Handoff 是 Watch 和 iPhone 沟通的绝佳方式之一,苹果也一直很鼓励使用 SDK 新出的一些东西来补充自己的 App 的。不要再幻想(至少是现在)通过 Watch 上的一个按钮能够使得 iPhone 上的 Host App 能够打开并且显示在前台了。
(7)其他一些 Tips
(1).dynamic notification 中苹果是希望用户在通知中就把所有的信息都看完的,而不希望用户点击内容本身(实际上也是不能点击的)再进入到 Watch app 内查看这个通知的内容的,恰恰相反的是,glance 的交互理念是相反的,也就是苹果估计用户点击 glance 页面本身(实际上是可以点击的)进入到 Watch app 中进行继续深度阅读的。
(2). 关于 WKTextInputMode,一开始选择的是 WKTextInputModeAllowAnimatedEmoji,后来发现这个是动态的大表情,返回的是这个大表情的 data,不太适合我们一一对应到 iphone 上的 emoji 表情,于是后来切换到了 WKTextInputModeAllowEmoji。而 WKTextInputModePlain 只是显示了我们所“推荐的”那些回复文本选项。
typedef NS_ENUM(NSInteger, WKTextInputMode) { WKTextInputModePlain, // text (no emoji) from dictation + suggestions WKTextInputModeAllowEmoji, // text plus non-animated emoji from dictation + suggestions WKTextInputModeAllowAnimatedEmoji, // all text, animated emoji (GIF data) };
(3).- (void)becomeCurrentPage; 这个方法主要是在 page based 页面当中,如果第三页在启动的时候你想让它先出来,就要标识好,在 awake 里边获取到之后,调用这个方法,注意的是,这个第三页不是立马就出现在手表的表盘之上的,而是从第一页蹦到第二页,然后再第三页这样转的。
(4). 推荐一个很好用的工具,叫做 Bezel, 它能够将模拟器中运行的 watch app 映射到真实的手表里,表带的样式也分 38mm 以及 42mm,有很多种,可以更好的查看自己的 App 在真实手表上的样子。更换表带也很方便,直接拖着下边的某一个样式到 Bezel 上就自动换了。举个例子,在开发的时候曾想左右留边,但是放在 Bezel 上就会发现手表自带黑边,于是留下的左右边就是很多余了。
2.Notification
从目前来看,手表上出现 push 应该是随着手机一起来的,也就是同时去显示在这两个设备上,除非一切外力因素,比如手表关闭了抬手查看通知等。在之前的 blog 中提到过定义 category 来区分推送通知,如果没有定义 category 的故事板的话,就会在手表上显示一个系统默认的简短的通知。上边说道,苹果还是鼓励在 notification 中将该阅读的内容都阅读完,即使增加按钮也要是一些比较简单的操作,比如说一个日程安排的软件,来了一个 push,一个 done,一个 delete,加上系统的 cancel,就可以了。
我尝试了在 Dynamic notification 中申请了一个图片资源,发现系统就选择去显示 Static notification, 因此在 notification controller 内进行的任务的能力有限,这个在开发的时候要慎重。
开发的时候,Xcode 自动生成的 Payload 很重要,可以定义多个 payload 来进行相应的模拟,搭配不同的 category,不同的 category 故事板。
3.Glance
我依然认为 Glance 的地位在 Watch 上是最重要的,至少在第三方独立 app 登上 Watch 前,Glance 应该是用户使用最频繁的一个功能。因此 Glance 上要呈现的东西不能太少,也不能太多,一定要简明扼要,要呈现出最重要的一些东西。例如说如果自己的 App 不是以天气为主的,放一个天气温度什么的就不是很合适,系统的天气和地图软件还是非常出色的,因此还是在 Glance 只体现自己 App 里边独特的东西最好。
另外,Glance 的 UI 布局是很讲究的,如果可以尽量要按照 Xcode 给的 Upper 和 Lower 的模板进行 UI 布局。不能使用任何可以操作的空间,例如按钮这样的,因为 Glance 就一页(可以滚动也是禁止的),有点像是渲染出来的一张图片似的,因此加个按钮是没有意义的。
同 Notification,Glance controller 中进行任务的能力也比较有限,因为众多的 Glance 会一同呈现出来,用户翻腾着每一个 app 的 Glance,这就要求用户一扫之后就要呈现出来,一个比较好的解决方法就是 Glance 要呈现的数据提前的申请好,用的时候拿出来,具体实现的方法也有很多。比如上边提到的 App Group。
Glance 以及主 App 的通信是依靠 Handoff 来实现的,也就是说用户点击了 Glance 这个页面之后,进入到主 App,要做的事情需要根据传过来的 userinfo 来决定的,主要就是下边这个方法。
[self updateUserActivity:XXXXX userInfo:userInfo webpageURL:nil];
在入口 controller 中实现方法,决定启动什么页面,呈现什么内容,可以放在 willActivate 里边。记住的是请求数据这块一定要放在 awake 里边,不要放在 willActivate 里边。
-(void)handleUserActivity:(NSDictionary *)userInfo { wkOpenType = JRWKOpenForGlancedemo; if (userInfo) { NSString *sourceString = [userInfo objectForKey:@"Source"]; NSString *picName = [userInfo objectForKey:@"PicName"]; if ([sourceString isEqualToString:@"Glance"]) { _glancePicName = picName; } } }
根据 wkopentype 决定启动页面。
switch (wkOpenType) { case JRWKOpenForGlancedemo: //glance page break; case JRWKOpenForNotificationdemo: //notification page break; default: [self showPageBaseddemoController]; // 默认启动 break; }
Glance 在 demo 中的表现形式,demo 已经整理好,放在了自己的 Github 上。
4. 总结
其实 WatchKit 的东西真不多,更多的是在一个新的平台遇到的各种问题和 bug 是最让人头疼的。随着真机的即将到来,开发工作也不再是抹黑前行,这些都是利好的消息。不知道什么时候可以有独立的第三方应用的支持,也不知道 WatchKit 会丰满到什么程度,总之我个人还是很看好 Watch 的未来的,毕竟苹果引领的穿戴设备的头。
编后语
为了更好地向读者输出更优质的内容,InfoQ 将精选来自国内外的优秀文章,经过整理审校后,发布到网站。本篇文章作者为刘瑞,原文链接。本文已由原作者授权InfoQ 中文站转载。
作者信息
刘瑞,中国科学技术大学苏州研究院在读硕士,喜欢科技产品,也喜欢制作开箱、体验视频。大三起开始自学iOS 开发。
感谢徐川对本文的审校。
给InfoQ 中文站投稿或者参与内容翻译工作,请邮件至 editors@cn.infoq.com 。也欢迎大家通过新浪微博( @InfoQ , @丁晓昀),微信(微信号: InfoQChina )关注我们,并与我们的编辑和其他读者朋友交流。
评论