肇鑫的技术博客

肇鑫 / Owen Zhao

独立开发者,主要开发 iOS、watchOS、macOS 应用。

目前在维护 SleepTapRooster Time,以及 Markdown Writer 相关工具。

最新文章

iOS每次升级后,苹果表表盘通知额度归零问题的处理

watchOS

我是苹果表的老用户了。有多老?初代苹果表可以预购的第一天,我就预定了一只42mm的不锈钢苹果表。除了2018年苹果表电池把屏幕拱开,维修返厂的那几天,我天天都戴着它。

我写的应用,有一个叫Stand Minus的,提交到了苹果商店后,被审核的工作人员认为功能太少给拒绝上架了。我觉得它现有功能“增一分则肥,减一分则瘦”,拒绝修改。所以这个应用我就自己这一个用户。今天我要说的就是和这个Stand Minus相关的内容。

感谢git的存在,我们可以知道,Stand Minus是我在2017年1月31日最初创建的。

stand_minus_initiation

Stand Minus有两种工作方式,根据当前表盘是否有Stand Minus的小部件来判断。

发现问题

根据苹果的文档,当iOSwatchOS发送消息的时候,可以使用WCSession,其中最好用的方法是transferCurrentComplicationUserInfo(_:)。这个方法,同时还能唤醒手表端扩展的后台。因为能唤醒后台,会导致额外的耗电,所以苹果规定每天唤醒次数最多只能有50次,超过就自动变成不能唤醒后台的transferCurrentComplicationUserInfo(_:)调用。

一天有24小时,50次限制约等于每小时2次。我们可以使用remainingComplicationUserInfoTransfers属性来获得剩余的次数。

Stand Minus设计就是每小时发送两次,一天50次肯定是用不光的。但在实际使用中,有时候表盘小部件并没能及时更新。通过手表端查看我发现,提示剩余次数为0。

zero_count_remain

这个问题不常见,但是有时也会出。经过多次观察,我最终发现,这个问题和iOS升级有关,每次iOS升级完成之后,剩余次数都会归零。解决的办法很简单,额外再重启一次手机,然后就好了。

尝试解决问题

时光荏苒,到了2019年,因为升级到iOS 13 beta之后,我一直使用的推送OneSignal失效了。不得不重新折腾起一直用得好好的Stand Minus。同时,我尝试测试究竟是什么原因导致了次数归零。

我将代码

remainCountsLabel.text = String(session.remainingComplicationUserInfoTransfers)

改成了

remainCountsLabel.text = {
    guard session.activationState == .activated else {
        return "Session状态不是.activated。"
    }
    
    guard session.isWatchAppInstalled else {
        return String("对应手表应用正在安装中……")
    }
    
    guard session.isComplicationEnabled else {
        return "错误:手表当前表盘未安装Stand-的小部件。"
    }
    
    return String(session.remainingComplicationUserInfoTransfers)
}()

然后等待iOS的更新再测试。我最终发现了,导致错误的原因是,虽然手表的表盘上有Stand Minus的小部件,但是session.isComplicationEnabled返回的是否。而对于当前表盘没有对应小部件的情况下,session.remainingComplicationUserInfoTransfers返回0。

no_complication_erro

找到了更确切的原因,就可以解决问题了。我找了不用重启iPhone的办法。先切换手表的表盘到另外的表盘,然后再切回来,然后再点击Stand Minus的表盘小部件,打开手表应用。

经过上面的步骤,再重新打开手机上的应用查看,就会显示正确的剩余次数了。

最终解决方案

每次iOS升级之后,都做一遍切表盘,切表盘,开应用的操作。不算麻烦,但是也挺讨厌的。有没有有什么更好的办法呢?

我用的苹果表初代,在2018年的watchOS 5就不能更新了,我曾经猜测苹果应该早就修复了这个问题。我今年卖了苹果表5,手表到手之后,我发现苹果表5还是有这个问题。苹果怎么回事?这都watchOS 6了啊。

我尝试绕过这个问题。观察代码,手机向手表发送消息的核心代码如下:

if session.activationState == .activated && session.isPaired && session.isComplicationEnabled {
    session.transferCurrentComplicationUserInfo(userInfo)
}

即当一切正常,且当前表盘存在对应小部件的时候,发送能够唤醒后台的消息。已知出错的是session.isComplicationEnabled,那么可否移除它,直接发送唤醒后台的消息呢?

阅读文档,苹果是这么说的:

Call this method when you have new data to send to your complication. Your WatchKit extension can use the data to replace or extend its current timeline entries.

This method can only be called while the session is active—that is, the activationState property is set to WCSessionActivationState.activated. Calling this method for an inactive or deactivated session is a programmer error.

原来,调用transferCurrentComplicationUserInfo(_:)方法,只要求WCSession为活跃就可以,其余的都是可以忽略的。

于是我删掉了session.isComplicationEnabled的检测。这相当于主动忽略了系统出错的部分。

no_complication_but_still_send

不过,因为不再检测当前表盘是否存在小部件,相对于Stand Minus的原本架构,在不使用表盘小部件的情况下,手表的额外耗电理论上会多一些。

其它

我这边能做的就是这些。API的错误,最终还是需要苹果来解决。

参考资料

iOS、watchOS应用用户设置相关问题的答案

watchOSiOS

最近在写咕唧2,对于iOS应用与配套watchOS应用之间的设置的相关问题有了一些心得。记下来,备查。

问题与答案

1. 创建的Settings.bundle文件放在哪里?

无论iOS还是watchOS的,都放在iOS应用下。

2. iOS应用的设置,为什么watchOS应用不能直接读了?

这是一个历史遗留问题。1代的手表系统,当时还不叫watchOS,上的应用只有一种,叫瞥一眼(glance)。生成机制是所有的一切都在手机上生成,然后传到手表上显示。因此,当然手机应用和手表应用实际上都在手机上运行。因此,直接通过分享组(share group),手表应用就能读取手机应用的设置。

watchOS 2开始,手表应用改在手表上运行了。而手表上的共享组和手机上的共享组之间不自动同步,所以手表和手机之间的设置不能直接读了。

3. iOS应用如何获得watchOS应用的设置?

不能直接读。因为苹果规定,手表应用可以唤醒手机应用,反之则不行。

4. watchOS应用如何获得iOS应用的设置?

通过session的sendMessage方法唤醒手机,然后手机发送设置给手表。

5. 如何设置watchOS应用的设置?

通过手机端的手表应用。这个应用虽然在手机上,但是可以直接读手表应用的设置。并且会写回去。

一张图

iOS与watchOS的设置同步

参考资料

为什么说只有远程通知才是最吼的!(续)

watchOS

这篇文章会根据我的体验和理解进行增补和删减

如果是新增,那么内容会直接添加。
如果是修改或删减。我会将原有的段落标记加上删除线。然后再写上新段落。
如果出现多次修改和删减。我会添加版本的提示。如果没有提示,那么相邻的删除线的段落,就是同一个版本的。

简析远程推送服务的解决方案

苹果不承诺远程通知的可靠性。所以远程通知在实际使用中,经常有延迟,甚至丢失的情况。这时,你有两个选择。PushKit或调教苹果的apns服务器。

苹果不承诺远程通知的可靠性。并且由于我们在使用远程通知时,往往会使用第三方的推送服务。因此,一旦通知出现延迟甚至丢失,那么既有可能是苹果的问题,也可能是第三方服务的问题。就目前我自己的感受来说,苹果的服务要比第三方服务更可靠一些。

如果你的程序对于实时性要求较高。那么可以使用收费的第三方服务,这样可以保证你的推送的质量。或者使用自己的推送服务器。更进一步,你还可以使用PushKit

PushKit

PushKit是苹果伴随iOS 8推出的特殊通知,它针对的是一般远程通知的缺点。最初的设计目标是提供给VoIP的app以实时的通知。iOS 9的时候,苹果又增加了对于手表表盘上小部件的推送。

一般远程通知的缺陷

  1. 苹果不保证远程通知一定能到达。苹果会根据它的apns服务器的情况,动态来处理你的推送请求。也就是说,你的请求可能被执行,也可能会延迟,甚至被忽略(忘记)。
  2. 苹果的apns服务器最多只保留最后一条的推送请求。比如用户设备离线的情况下,你发出了多条通知,那么最终用户至多只能收到最后一条。
  3. 如果用户通过呼出任务管理,并且上滑移除app。那么该app的远程推送,将不能唤醒该app到后台,直到用户手动打开该app,或重启手机并解锁进入。
  4. 远程通知注册的token,隔一段时间会自动变化。这时如果还按照旧的申请,就会收不到消息。

苹果的处理方式

  1. 针对问题1。苹果保证PushKit通知会即时到达。看其他人写的评测,基本上延迟只有1秒。因为VoIP是类似打电话的功能,这个延迟高了,服务就没法用了。
  2. 用户设备可以在收到远程通知后,向自己的服务器,苹果称之为Provider,进行反馈。由于通知是即时的,服务器在“向要求苹果apns的服务器发出通知”的命令发出后,如果在特定时间没有收到用户设备的反馈,就可以认定用户的设备是离线的。这之后如果还需要给用户设备发通知,就可以先缓存起来,而不要发送到苹果的apns服务器。服务器可以在确认用户设备启动之后,再一次性将缓存的通知发出,这样,用户就可以得到在设备离线期间缓存的所有信息。
  3. PushKit的通知仍可以唤醒程序。
  4. PushKit的token是不变的。

PushKit的缺点

  • 实现比远程通知更为复杂。除了远程通知的要实现之外,还要向Provider做出反馈。
  • 国内没有现成的第三方服务,需要使用国外的第三方服务。或者自己编写Provider

调教苹果的apns服务器

如果你不想实现PushKit,那么你可以采用下面的比较脏的方式来调教苹果的apns服务器。

我的程序每小时通知一次设备。但是我发现,这个通知有时候会丢失,每天大概会丢个两回左右。

更进一步研究,我发现,如果通知没有及时到达,那么我可以在一段时间之后,补发一个通知。由于我没有实现PushKitProvider服务器不知道上一个通知到达了没有,因此我必须每小时发送两次通知。iOS端如果收到了第一个通知,那么可以做下标记,第二个通知到来时,就直接忽略,不要向Apple Watch传送就可以了。

这个调教的方式的原理是这样的。当apns服务器繁忙时,我猜测苹果采用了一个典型的美国式公平的策略。即大家都丢一些,而不是规定一个资源上限,占用资源多的丢,占用少的不丢。这导致一个现象,就是爱哭的孩子有糖吃。比如我的程序,原本每小时只需要一次推送。但是由于苹果的apns服务器会偶发的抛弃其中的1-2个通知。那么我为了保证通知的必须到达,就需要每小时发两次。实际上这导致苹果apns服务器的负载增加了一倍。但是我没有办法,因为只有这样,才能保证我的程序每小时至少刷新一次。

这个方法相对于PushKit,优点是实现简单。缺点也很明显,不考虑苹果apns的服务器负载增加的损失。只考虑开发者自己这边,最主要的缺点是通知到来的时间不稳定。既可能在第一次的时间到来,也可能是第二次。如果是对于通知到来时间,有特定要求的程序,那就不能用这种方式,而必须使用PushKit

系列文章

为什么说只有远程通知才是最吼的!
为什么说只有远程通知才是最吼的!(续)
为什么说只有远程通知才是最吼的!(续+1)

为什么说只有远程通知才是最吼的!(续+1)

watchOS

昨天的为什么说只有远程通知才是最吼的!(续)更新了。增加了新的缺点。这篇文章就是为了解决这个新缺点的。

新缺点

远程通知用久了,你会发现,有时Provider服务器会在你要求发送远程通知的时候回复你错误,说token未注册之类的。如果你之前能成功发送远程通知,那么这个错误指的不是token未注册,而是过期了。是的,远程通知注册之后的token,可能过期。

哪些情形token会过期?

  1. 一种是token用了一段时间会过期。这个一段时间是多久,苹果并未说明,我这里测试平均至少是一天以上。
  2. 当你的程序被卸载并重新安装。这个卸载指的是从iOS上卸载。因为watchOS不能直接注册远程通知,因此从watchOS卸载,再重新安装无影响。

解决方案

知道问题的原理,就可以开始解决了。我的思路是,既然远程通知的token会过期,过期的表现在app看来就是通知不来。那么可以通过计算通知的间隔来决定何时重新注册通知。比如,我的通知设定在整点和整点20分通知。且整点的通知了,之后的20分钟的那么就不通知了。因此,不来的情形是这样:

上次通知时间 没来的通知 没来的通知 间隔时长
9:00 10:00 10:20 80分钟
9:20 10:00 10:20 60分钟


可以看出来,理论上只要连续80分钟没有通知过来,就可以认为通知出了问题。实际上,考虑远程通知的延迟,可以将间隔时长设定为90分钟。即连续时长90分钟没有通知过来,就通过session给iOS发消息,让它重新注册通知。

系列文章

为什么说只有远程通知才是最吼的!
为什么说只有远程通知才是最吼的!(续)
为什么说只有远程通知才是最吼的!(续+1)

为什么说只有远程通知才是最吼的!

watchOS

你写了一个iOS应用,不满足,还要写一个watchOS应用。你写好了watchOS的应用,不满足,还要写个表盘小部件。终于全都写好了。实际运行时,你发现,小部件它竟然不按照你设计的更新。好的欢迎你入坑watchOS,下面咱们来讨论一下究竟发生了什么。

伪实时的表盘小部件

做为一个watchOS的用户是幸福的,看着表盘上那些显示最新数据的各个小部件,一种掌控感油然而生——"I am the King of the world!"

但是,作为一名watchOS开发者,你必须知道,表盘小部件是伪实时的。苹果限制你实时地更新它。苹果设想的小部件的数据是这样的,你提前就知道某个时间会发生什么,然后在小部件初始化时,一次告知watchOS,watchOS会在相应的时间到来时,更改表盘的内容,这样,在用户看来,表盘就是实时变化的了。

可是如果实际内容发生了变化怎么办?根据变化的类型,你可以选择重新加载时间片,或者扩展时间片。二者的区别是前者作废旧数据,而后者是在旧数据的基础上添加新数据。

这么做就可以了吗?

怎么会?苹果会让你那么舒服?上面提到的两个操作,你的程序在计算时,苹果会拿着小本本记录你消耗了多少时间。一旦消耗时间过多,超过了苹果允许的范围,那么苹果就会立即终止你的计算。并且你再申请,也会无视你。直到,直到第二天的到来。

为什么苹果要这么做?

一切为了电池!Apple Watch的电池小,一旦没电,大家全完蛋。

因此,苹果认为不好用的小部件,也比没有电强太多。而重新计算小部件的行为,被视作是耗电行为。需要加以限制。

如何应对?

在讨论如何应对之前,我们先看看watchOS面对的复杂情况。说复杂,是因为相比于iOS,watchOS经常会遇到资源竞争的情形。

我们先假设你的苹果手表开启了多个表盘,你制作的程序叫A,它支持小部件,并且在这些表盘中,有的表盘添加了A的小部件,有的表盘则没有。并且,每个表盘上A的小部件数量至多只有一个。那么……

手表启动篇

当苹果手表启动后,会自动运行dock中的程序,以及当前表盘上的小部件。此时,就有可能对于相同的资源同时进行访问,从而产生问题。要解决这个问题,可以采用的最优策略是,不在程序的主界面运行可能资源占用冲突的代码,而只让表盘小部件运行。

为什么说这个是最优的策略?因为表盘的小部件运行苹果会记录时间,如果你采用信号量的方式,那么主程序独占资源运行的时间,会被一同记录到表盘的小部件运行的时间(虽然这时小部件没有运行,而是在等待资源独占结束),这样就会产生超时错误。

当然,你也可以使用Timer来延迟主程序的运行,但是这个延迟运行的延迟时间不好掌握。太短了不起作用,会产生同样的问题。太长的话,如果用户此时打开了主程序,会发现界面一直不动,这样程序用起来也还是有问题。

表盘切换篇

当你通过滑动屏幕切换手表的表盘时,如果新表盘有相应的小部件。那么系统会根据情况刷新小部件。因此,你需要根据数据的新鲜程度来决定是否要刷新小部件,还是沿用旧的数据。

另外,如果该表盘没有小部件,那么就没有后台刷新小部件的必要。这样可以节省电量。

后台任务的种类(表盘小部件、Dock以及远程通知)

如果你想要定期后台更新程序的内容,那么有三种情况可以做到。

  1. 程序在当前的表盘有相应的小部件。这样可以保证你的程序每小时至少可以后台运行一次。至多运行的次数,在于当前表盘有多少个小部件。如果其它小部件有没使用自己的次数的,那么你可以占用它们节省的次数。
  2. 如果你的程序没有小部件,又需要后台刷新,那么你的程序需要在Dock中存在。在Dock中保留的程序每小时至少可以运行一次。至多可以运行10次,这取决于Dock中其它程序是否使用了它的次数,因为是大家共用这10次。
    1. watchOS 3.x比较简单,直接选择固定该程序在Dock就可以。
    2. 但是到了watchOS 4.0,苹果画蛇添足的增加了两种模式的Dock,并且将新模式作为了默认。新模式被称为近期(recent),即你最近运行的程序被固定在Dock上,新运行的会逐渐顶掉旧的,但是这个没有鸟用。因为你近期运行的程序很可能不需要后台刷新,而需要的则可能被挤到外面去了。
    3. 这时,就要引导用户开启Dock的另外一个模式,最爱模式(favorite)。这个其实就是watchOS 3.x中原本的模式。用户可以选择哪些程序常驻Dock。
  3. 远程通知。远程通知是通过苹果推送服务器,将远程消息推送到用户的iPhone,iPhone在30秒内处理完成后,将结果发到苹果手表上。苹果手表接到结果,再进一步处理,之后刷新表盘小部件或向用户发出通知。

各种后台任务运行时的区别

  • 小部件和Dock中运行后台刷新的方式,属于本地刷新,可以通过WKExtension.scheduleBackgroundRefresh(withPreferredDate:userInfo:scheduledCompletion:)的方法来进行安排。时间到了,系统会调用WKExtensionDelegate.handle(_ backgroundTasks: Set<WKRefreshBackgroundTask>)方法,执行后台任务。
    • 优点:
      • 本地操作。实现简单。
      • 可以直接运行异步的调用。异步调用结束时,调用task.setTaskCompletedWithSnapshot(Bool)即可
    • 缺点:
      • 时间不稳定。根据苹果的文档,虽然你可以指定后台任务运行的时间。但是这个时间watchOS只是用来参考,系统会在它认为适当的时机进行调用。可能比设定的时间早,也可能晚。实际运行中,我发现只可能晚,还没发现有早于设定时间的情形。
      • 计算量存在限额。苹果很奇葩的规定了计算量的限制,计算量是一个很抽象的概念。不同于计算时间,计算量是一个确定的量,但是苹果并未公开这个量具体是多少。只是说当后台任务运行时,它会记录,如果超出了,就会终止。直到重新分得配额。这个不是以天为单位的,而是动态的。如果你的计算量较大。就很可能出现表盘不更新的问题。
  • 远程通知的方式,属于远程刷新,需要配置调用苹果的推送服务器。远程通知到来时,必须先经过iOS,调用UIApplicationDelegate.application(_:didReceiveRemoteNotification:fetchCompletionHandler:),然后调用WCSession.transferCurrentComplicationUserInfo(_:)给苹果手表发送信息。最终手表端会调用WCSessionDelegate.session(_:didReceiveUserInfo:)函数,执行最终的任务。
    • 优点:
      • 准时。苹果的远程推送,只要不是批量推送,而是推动到指定设备或指定账户的设备。还是很准时的。延迟在几秒到几分钟。
      • 计算量充裕。远程通知在iOS端最多可以后台运行30秒。因为iPhone速度远快于Apple Watch,因此有充裕的计算量来进行计算。此外,在传递到watchOS后,相应地还可以继续进行后台计算。这个也没有文档说有计算量的限制。
    • 缺点:
      • 次数限制。虽然远程通知没有计算量的限制,但是由于规定远程通知必须先经过iOS,然后由iOS唤醒watchOS,即WCSession.transferCurrentComplicationUserInfo(_:)这个函数,这个函数每个程序每天有50次的调用限制。超出限制后,调用会自动降级到WCSession.transferUserInfo(_:)相同的待遇,即必须手动打开Apple Watch上的对应程序,才会接收相应的数据。
      • 连接性问题。不同于本地任务,是在手表本地运行的。远程通知必须有网络才能收到。同时,由于通知先传到iOS,处理后再传到Apple Watch。如果此时手表与手机距离较远,会导致相应的数据在iOS排队。而这个队列在手表与手机重新连接后不会主动发送。而是需要手工开启iOS上相应的程序才会发送。我认为这个行为属于bug,已经向苹果进行了提交。
      • WCSessionDelegate.session(_:didReceiveUserInfo:)不能直接处理异步程序。如果你的代码里包含异步程序,那么你就需要增加额外的代码。比如使用信号量来限制函数不要一下跑完。如果函数执行完毕了,很有可能异步程序还没处理完,手表程序就被系统睡眠了。
      • 实现复杂。远程通知不仅需要申请通知权限,还可能需要使用第三方的推送服务。以及申请http的特定域名的解禁。因为苹果目前默认只能运行https的域名,还必须是特定加密等级之上的。
      • watchOS 3.x存在bug。早期的watchOS 3.x会在iOS端调用WCSession.transferCurrentComplicationUserInfo(_:)之后,自动刷新表盘小部件,然后再调用WCSessionDelegate.session(_:didReceiveUserInfo:)。这个是有很大的问题的。因为表盘小部件在数据到来之前就刷新了。而且是每次都刷新,而不是先判断是否有必要进行刷新。大概半年前,我向苹果提交了这个问题的的错误报告。目前4.0解决了这个问题,不会直接刷新表盘,而是由开发者来判断是否需要刷新表盘。我不清楚3.x的后续版本是否也解决了这个问题。有需要的可以自己测试一下。

虽然有以上的缺点,但是如果你的计算量太大,本地后台任务经常超时,那么远程通知是唯一的选择。

多个小部件

如果你的表盘有你的程序的多个小部件,那么小部件之间也会产生资源冲突的问题。但是这个情况一般很少见。因为除非用于测试,用户一般不会在屏幕上放两个一样的小部件。因此,这个问题可以暂时忽略。

系列文章

为什么说只有远程通知才是最吼的!
为什么说只有远程通知才是最吼的!(续)
为什么说只有远程通知才是最吼的!(续+1)

深入探讨NotificationCenter的addObserver(forName:object:queue:using:)中的queque

iOSwatchOSmacOS

先看看下面的代码,这两段notiObserver有区别吗?

override func viewDidLoad() {
    super.viewDidLoad()
    
    let noti = Notification(name: MyNotificationName)
    var v = 0
    let center = NotificationCenter.default
    var notiObserver:NSObjectProtocol! = nil
    
    notiObserver = center.addObserver(forName: MyNotificationName, object: nil, queue: .main) {_ in 
        if v == 10 { center.removeObserver(notiObserver) }
        else {
            v += 1
            center.post(noti)
        }
        print(v)
    }
    
    notiObserver = center.addObserver(forName: MyNotificationName, object: nil, queue: nil) { _ in
        DispatchQueue.main.async {
            if v == 10 { center.removeObserver(notiObserver) }
            else {
                v += 1
                center.post(noti)
            }
            print(v)
        }
    }
    
    center.post(noti)
}

无论你觉得有区别,还是没区别,你可以分别注释掉其中一个然后运行。运行结果是这样的。

上面的那段会输出

10
10
10
10
10
10
10
10
10
10
10

下面那段会输出

1
2
3
4
5
6
7
8
9
10
10

这是为什么呢?我们来看文档。苹果的文档在提到queue是这么说的。

queue
The operation queue to which block should be added.
If you pass nil, the block is run synchronously on the posting thread.

也就是说,它默认是在队列中同步执行。即便我们换成了.main,它也不是我们以为的是异步执行的。实际上,我们可以测试出来,这段代码,在运行时,是堆栈式的。新到的Notification,会进行压栈,优先执行。然后才是旧的Notification没执行完的代码。

如图所示,从左到右是时间轴,依次运行的3段代码,绿色的center.post(noti)导致黄色被执行,黄色的center.post(noti)导致蓝色被执行……,一直到v==10,最后的一段代码print(v),才会从上到下执行下来。

stack
结论:如果我们想要代码符合我们的预期的顺序,应该使用第二种方式,通过GCD,将代码加入到顺序执行的queue中去。

WKWatchConnectivityRefreshBackgroundTask is never triggered in background, but WKSnapshotRefreshBackgroundTask

watchOS

Conclusion first

Currently WKWatchConnectivityRefreshBackgroundTask is only called for sure on a watchOS Simulator when the watchOS extension's WCSession is in notActivated state and the extension is not running in foreground (in background or terminated).

In real devices, it won't be called in my tests. But Apple docs says it may. So you shouldn't rely on it won't be called until Apple changes its docs.

WCSession Cores

WCSession Core

For WCSession, when it is activated, you can transfer userInfo, and when the counterpart is active, it can get the userInfo. The counterpart won't need to be in foreground to be activated, it can be in a high priority background.

Testing Results

Here are my testing results.

In Simulators and in devices

How to make WCSession notActivated?

  1. Using Xcode terminate your watchOS Extension. Xcode will send a kill signal to your WKExtension.
  2. Or don't run WCSession.activate() in your code on watchOS Extension side. As WCSession is notActivated by default.

WatchConnectivity的一些坑

watchOS

func updateApplicationContext([String : Any])的传输

这个方法的传输会对数据进行优化,连续发送相同的数据,后发的不会被传输。

正如这个函数的名称那样,这个函数用于发送程序状态的信息,如果有多个状态,那么连续发送相同的,只有第一个会被发送。

与WKWatchConnectivityRefreshBackgroundTask的关系


  1. When transferring data between iOS and watchOS, there must be an activated WCSession in the sending app. That is the first condition to check before sending data.
  2. For sendMessage(_:replyHandler:errorHandler:) and sendMessageData(_:replyHandler:errorHandler:), it is needed to check isReachable to be true as well.
  3. For a watchOS app sending data to iOS, you need to check iOSDeviceNeedsUnlockAfterRebootForReachability for if the iOS device needs to unlock after rebooting.
  4. For an iOS app sending data to watchOS, you may also check isPaired and isWatchAppInstalled before sending any data.
// iOS app
class AppDelegate: UIResponder, UIApplicationDelegate, WCSessionDelegate {

    var window: UIWindow?

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
        // Override point for customization after application launch.
        if WCSession.isSupported() {
            let session = WCSession.default()
            session.delegate = self
            session.activate()
        }
        
        return true
    }
    
    extension AppDelegate:WCSessionDelegate {
    public func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
        guard error == nil else { fatalError(error!.localizedDescription) } 
    }
}
// view controller
class ViewController: UIViewController {
    @IBAction func sendButtonClicked(_ sender: Any) {
        if WCSession.isSupported() {
            let session = WCSession.default()
            if session.activationState == .activated {
                if session.isWatchAppInstalled && session.isPaired {
                    session.transferUserInfo(["send test":nil])
                }
            }
        }
    }
}

// watchOS app
class ExtensionDelegate: NSObject, WKExtensionDelegate {

    func applicationDidFinishLaunching() {
        // Perform any final initialization of your application.
        let session = WCSession.default()
        session.delegate = self
        session.activate()
    }

    func applicationDidBecomeActive() {
        // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface.
    }

    func applicationWillResignActive() {
        // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state.
        // Use this method to pause ongoing tasks, disable timers, etc.
    }
    
    var total = 0

    func handle(_ backgroundTasks: Set<WKRefreshBackgroundTask>) {
        // Sent when the system needs to launch the application in the background to process tasks. Tasks arrive in a set, so loop through and process each one.
        for task in backgroundTasks {
            // Use a switch statement to check the task type
            switch task {
            case let backgroundTask as WKApplicationRefreshBackgroundTask:
                // Be sure to complete the background task once you’re done.
                backgroundTask.setTaskCompleted()
            case let snapshotTask as WKSnapshotRefreshBackgroundTask:
                // Snapshot tasks have a unique completion call, make sure to set your expiration date
                snapshotTask.setTaskCompleted(restoredDefaultState: true, estimatedSnapshotExpiration: Date.distantFuture, userInfo: nil)
            case let connectivityTask as WKWatchConnectivityRefreshBackgroundTask:
                // Be sure to complete the connectivity task once you’re done.
                total += 1
                DispatchQueue.main.async {
                    if let viewController = WKExtension.shared().rootInterfaceController as? InterfaceController {
                        viewController.activeBurnedEnergyLabel.setText(String(self.total))
                    }
                }
                
                connectivityTask.setTaskCompleted()
            case let urlSessionTask as WKURLSessionRefreshBackgroundTask:
                // Be sure to complete the URL session task once you’re done.
                urlSessionTask.setTaskCompleted()
            default:
                // make sure to complete unhandled task types
                task.setTaskCompleted()
            }
        }
    }

}

extension ExtensionDelegate: WCSessionDelegate {
    public func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
        guard error == nil else { fatalError(error!.localizedDescription) }
    }
    
    /** -------------------------- Background Transfers ------------------------- */
    
    public func session(_ session: WCSession, didReceiveUserInfo userInfo: [String : Any] = [:]) {
        total += 4
        DispatchQueue.main.async {
            if let viewController = WKExtension.shared().rootInterfaceController as? InterfaceController {
                viewController.activeBurnedEnergyLabel.setText(String(self.total))
            }
        }
    }
}