缘起
最近有个想法,想开发一个iOS应用,在后台周期性的获取锻炼记录并上传到服务器。这个应用主要有三个关键点:获取锻炼记录、上传到服务器、周期性执行。前两个功能都一一实现了,本文主要涉及到的事最后一个功能,也就是周期性执行。
如果想让iOS应用在后台执行,据我了解,除了VoIP、音乐播放、后台位置更新等几种非常规手段外,大概有下面几种通用方案:
- 使用APNS后台推送;
- 使用Background Tasks框架;
- 使用AppIntent配合Shortcuts调用;
第一种方案需要服务器配合。关键是按照苹果文档上提醒的,限制每小时不超过2~3次。所以感觉使用起来局限性很大,特别是调试期间很麻烦。所以排除掉了。
第二种方案只需要客户端即可。但是iOS并不保证任务被执行的时间。比如我创建创建了一个后台任务,并且声明十分钟之后运行。实际上有可能真的十分钟左右就运行了,但是也完全可能几个小时才会被调度运行,甚至永远不被运行。
这一点苹果也毫不避讳,明确说明iOS会按照应用的使用频率,使用机器学习的算法自行评估何时运行后台任务。对于我这个应用,本身设计上就是几乎不会打开,只需要后台默默执行就行了。因此大概率会被iOS判定为不活跃应用,导致后台任务调度的优先级非常非常低。
而且这个方案还有一个更致命的问题。因为我的应用不活跃,导致我的后台任务优先级很低。很低优先级的任务往往会在设备空闲的时候,比如锁屏后、充电时才会被执行。但是由于iOS隐私政策的原因,导致锁屏后任何应用都是无法通过HealthKit读取到锻炼记录的。所以导致这个方案完全不可行。
因此,实际上我需要的周期性执行实际上隐含了如下要求:
- 必须在非锁屏期间;
- 可以周期性执行,或者在运动记录发生变化后执行;
- 时间可以不精确,但是不能太离谱;
综合这三个因素,我突然想到,可不可以使用iOS的快捷指令(Shortcuts)机制呢?
快捷指令做到定期或者当运动记录发生变化时执行。并且执行的时候如果锁屏了,会停留在通知栏,我们可以点击后解锁执行。
那么问题就变成了,如何在APP中支持快捷指令调用呢?答案就是AppIntent
AppIntent
App Intents是iOS中,Siri和Shortcuts和别的应用之间的一种交互方式。
从设计上,每个App Intent就是一个APP Action的抽象,APP可以按需提供任意数量的App Intent。每个App Intent都有相互独立且独立于应用的执行环境,但是他们之间可以共享代码。每次App Intent的执行,都是由系统(Siri或者Shortcuts)唤起并执行,获取到结果后结束,整个过程和APP并没有什么关系。
那么,如何让我们的APP支持AppIntent呢?主要有几个步骤:
- 创建一个遵循
AppIntent
协议的struct
; - 实现他的
perform
方法; - 可选的,实现AppIntent的参数支持,以及返回值。
以使用Swift开发SwiftUI类型的应用为例,具体过程如下:
创建AppIntent
由于SwiftUI是一种声明式的语言,因此创建AppIntent很简单,只需要创建一个(或多个)遵循AppIntent
协议的struct
即可。你甚至不需要给这个struct
添加类似@main
这样的注解,也不需要在应用的init
中去注册他。只需要在项目中任何的.swift
文件中写下下面的代码即可:
struct MyIntent: AppIntent{
@static var title: LocalizedStringResource = "Intent名称"
@static var description: IntentDescription("Exports your transaction history as CSV data.")
}
这样就创建了一个MyIntent
的AppIntent
,在Siri或者Shortcuts中看到的操作名就是这里title
属性中定义的名字。description
属性是可选的,如果你需要给他设置一个描述性的文本,可以在这里设置。
实现AppIntent的功能
AppIntent运行的时候,实际上执行的是struct的perform
方法。因此,只需要把相关的代码放到这个方法里即可。完整的例子如下:
struct MyIntent: AppIntent{
@static var title: LocalizedStringResource = "Intent名称"
func perform() async throws -> some IntentResult {
//在这里添加你要执行的代码
return .result()
}
}
注意这个方法的返回值,需要提供遵循IntentResult协议的返回值。从这个协议的文档上看,我们不需要真的去实现一个遵循该协议,而只需要根据实际需要,使用不同的参数调用该协议的.result
方法即可。
例如,如果我不需要任何返回值,我可以像上面的例子一样,直接return IntentResult.result()
。得益于Swift强大的类型推导功能,这里的IntentResult
都可以省略,直接用return .result()
,Swift会自动理解这里的.result()
实际上是调用的IntentResult
的类方法。
如果需要返回一个简单的值,可以通过.result(value: yourValue)
来实现,不过要修改一下perform
的返回值类型,从IntentResult
改为IntentResult & ReturnsValue<Type>
,这里的Type
为value
的类型,比如,以返回一个数字(Int
)为例,可以像下面这样实现perform
:
func perform() async throws -> some IntentResult & ReturnsValue<Int> {
let returnValue: Int = 1
//在这里添加你要执行的代码
return .result(value: returnValue)
}
给AppIntent添加参数
有时候我们希望AppIntent被调用时,可以设置一些参数。比如我们这个例子里,可以让调用者设置要上传的服务器地址、查询的最大锻炼记录数量。
要实现参数也很简单,只需要给struct
定义一些属性,然后给他加上@Parameter(title: "xxx")
注解即可。比如:
struct MyIntent: AppIntent{
@static var title: LocalizedStringResource = "Intent名称"
@Parameter(title: "服务器地址")
var title: String?
}
如果这里的属性类型是可为空(optional)的,那么AppIntent
被调用的时可以不设置参数,否则就必须设置。
属性的类型必须是AppIntent所支持的,可以在Shortcuts中查看。具体可以参考这个文档。
如何运行AppIntent
完成AppIntent的代码开发,构建并安装到目标设备后,运行一下APP。然后我们就可以在系统中调用了。以在Shortcuts(快捷指令)中调用为例,大致步骤如下:
- 打开iOS中的Shortcuts(快捷指令)应用;
- 在快捷指令标签下,点击右上角的加号;
- 在弹出的新快捷指令对话框中,点击*+添加操作*按钮;
- 在弹出的对话框中,切换到App标签页,找到我们的应用,并点击;
- 在打开的对话框中,就可以看到我们应用所支持的所有
AppIntent
了,这个列表里显示的是所有AppIntent
的title
属性; - 点击以添加具体的
AppIntent
,页面会回到新快捷指令对话框; - 如果需要设置参数,可以点击刚才添加的指令,在下拉框中输入对应的参数;
- 可以点右上角的完成按钮以保存,或者点右下角的▶️按钮运行,看看测试效果;