本文介绍了 iOS 10 的一个重要更新:Messages 应用支持第三方插件了。作者用一个小游戏作为例子,说明了插件开发从建工程开始,到绘制界面、收发消息的全过程。
《iOS 10 day by day》是 shinobicontrols 公司编写的系列博客,介绍开发者需要了解的 iOS 10 新特性,每周更新。本系列翻译(文集地址)已取得官方授权。目录点此。仓薯翻译,欢迎指正:)
Shinobicontrols 为 iOS 和 Android 开发者提供高性能、响应式的 UI 控件 SDK,尤其是图表方面的控件。 官网 : shinobicontrols.com twitter : @shinobicontrols
苹果官方的 Messages 在 iOS 10 推出了非常重大的更新,可能主要是想从其他 IM 巨头手里抢点市场份额回来,包括 Facebook Messenger, Wechat 和 Snapchat。
一个重要的新功能是,用户可以直接在 Messages 里使用第三方开发者开发的扩展插件了。这个功能是在 iOS 8 引入的 Extension 技术基础上实现的,可以参考我们往年系列里 Sam Davies 写的文章。Messages 插件的一大好处是,它是可以独立于 app 存在的,不用跟父 app 打包在一起。今年晚些时候 iOS 10 将会发布一个小巧的 Messages App Store,里面会有一堆插件供用户挑选。
为了演示一下这个令人兴奋的插件功能,我们看一个简单的例子吧,这个插件可以让两个用户玩一个简化版的流行游戏 Battleships。为了让约束布局方面简单一些,我们只考虑竖屏的情况。为方便大家下载这个 demo,我把它放到Github上了。
游戏规则是这样的:
- 玩家 A 发起游戏,在棋盘上布置两个『战舰』,然后隐藏起来
- 另一个玩家 B 要猜测战舰的位置
- 如果猜中了两艘隐藏战舰的位置,玩家 B 就赢了;但是如果猜错 3 次,玩家 B 就输了。
建工程
用 Xcode 新建一个插件工程非常简单。只需点击 File -> New Project,然后在窗口中选择 iMessage Application。
给工程起个名字,然后语言选择 Swift(本系列均使用 Swift 语言示例),这就完事了。因为有一个自动生成的MessagesExtension
target ,然后默认的Info.plist
里带有必需的配置(插件界面的 storyboard 以及插件的类型等),所以只要运行工程,Messages 就能自动识别出我们的插件了。
改 Display Name
如果在模拟器里运行MessagesExtension
这个 target,它会让你选择在哪个 app 里运行这个插件。我们选择Messages
。
Messages 打开的时候,应该能在输入框下方看到我们的插件。如果看不到,可能需要点击 "Applications" icon,然后再点 4 个椭圆的 icon,从里面选择我们的插件。
现在里面啥也没有,不过我们将很快改变这一点。眼下最迫切的是要把我们插件的 display name 改改:现在显示的是 "MessagesExtension"(实际上是 "MessagesEx..." 后面被截掉了)。下面我们点击 target,然后把Display Name
输入框里的名字改一改。
棋盘
我们需要展示的是 3x3 的棋盘。有很多实现方法,我用的是 UICollectionView。在本教程里,画界面这一块并不重要,因此实现细节不再详述了。
数据模型
为了记录一局游戏本身以及游戏的状态,我们定义以下两个结构体:
struct GameConstants {
/// 一共需要布置的战舰数
static let totalShipCount = 2
/// 允许玩家 B 失败的次数
static let incorrectAttemptsAllowed = 3
}
struct GameModel {
/// 战舰的位置
let shipLocations: [Int]
/// 游戏是否已经结束
var isComplete: Bool
}
MessagesViewController
MessagesViewController
是我们插件的入口点。它是MSMessagesAppViewController
的子类,相当于是 Messages 插件的 root View Controller。自动生成的模板里面包含了一些供我们重写的方法,比如插件启动状态下用户收到消息的回调函数。待会我们就要用到其中的一部分方法。
第一点要注意的是,我们的插件启动之后有两种可能的 presentation style:
- compact
- expanded
compact
是用户从应用托盘里打开插件的模式,插件显示在键盘区域里。expanded
则多给了一些喘息的空间,插件占据大部分的屏幕。
为了让代码整洁一些,我们会用不同的 view controller 来分别实现两种模式,并且把这些 view Controller 都加为MessagesViewController
的子 view controller。
几个子 View Controller
本文不会花太长篇幅来描述这些 controller 的实现细节,只会重点关注在收发信息的过程,游戏状态和数据是怎么变化的。关于具体实现,请自行阅读 Github 上的源码。
GameStartViewController
我们的插件刚启动的时候处于compact
状态。这点空间并不够展示游戏的棋盘,在 iPhone 上尤其不够。我们可以简单粗暴地立即切换成expanded
状态,但是苹果官方警告不要这么做,毕竟还是应该把控制权交给用户。
于是,我们来显示一个简单的欢迎界面,里面有一个 label 和一个 button。按下 button 的时候,再切换到游戏的主界面,用户就可以开始放置『战舰』了。
Ship Location View Controller
这个 view controller 是玩家 A 布置战舰的界面。
我们实现gameBoard
的onCellSelection
方法来控制 cell 的样式:上面有战舰的 cell 显示为绿色,空白的显示为蓝色。
shipsLeftToPosition
返回 0 时,结束按钮会变得可点。这个按钮的点击事件是一个叫completedShipLocationSelection:
的IBAction
方法,它会新建一个游戏 model,然后使用 UIImage 的 extension 来创建一张游戏棋盘的截图(我们会先reset()
棋盘,所以截图的时候战舰的位置是隐藏的——现在可不是揭晓谜底的时候!)。这张截图在待会发消息的时候会用到。
Ship Destroy View Controller
当玩家 B 点击对话中的消息时,我们希望他能看到一个略微不同的 view controller —— 一个能让他寻找隐藏战舰的界面。
我们还是实现棋盘的onCellSelection
方法。这一次我们把选择的 cell 位置与玩家 A 布置的位置匹配的(『击中战舰』)标为绿色,如果没有击中就标为红色。
游戏结束后,不管是因为 3 条命用完了,还是因为两条战舰都找出来了,我们都会相应地记录在数据模型中,然后调起游戏结束的回调。
添加子 Controller
回到我们的MessagesViewController
,我们现在可以把子 controller 们加进去了。
class MessagesViewController: MSMessagesAppViewController {
override func willBecomeActive(with conversation: MSConversation) {
configureChildViewController(for: presentationStyle, with: conversation)
}
override func willTransition(to presentationStyle: MSMessagesAppPresentationStyle) {
guard let conversation = self.activeConversation else { return }
configureChildViewController(for: presentationStyle, with: conversation)
}
}
这两个方法是继承自MSMessagesAppViewController
的,分别提醒我们插件启动了(比如被用户打开了)以及要变换到另一种 presentation style 了。我们利用这两个方法来配置子 view controller。
private func configureChildViewController(for presentationStyle: MSMessagesAppPresentationStyle,
with conversation: MSConversation) {
// 清空所有之前的子 view controller
for child in childViewControllers {
child.willMove(toParentViewController: nil)
child.view.removeFromSuperview()
child.removeFromParentViewController()
}
// 好,现在建一个新的吧
let childViewController: UIViewController
switch presentationStyle {
case .compact:
childViewController = createGameStartViewController()
case .expanded:
if let message = conversation.selectedMessage,
let url = message.url {
// 如果 conversation.selectedMessage 不为空,说明玩家 A 已经把战舰布置好了,当前是玩家 B
// 所以我们需要显示能让玩家 B 选择位置来击沉战舰的界面
let model = GameModel(from: url)
childViewController = createShipDestroyViewController(with: conversation, model: model)
}
else {
// 否则,我们就需要布置战舰了
childViewController = createShipLocationViewController(with: conversation)
}
}
// 添加子 view controller
addChildViewController(childViewController)
childViewController.view.frame = view.bounds
childViewController.view.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(childViewController.view)
childViewController.view.leftAnchor.constraint(equalTo: view.leftAnchor).isActive = true
childViewController.view.rightAnchor.constraint(equalTo: view.rightAnchor).isActive = true
childViewController.view.topAnchor.constraint(equalTo: view.topAnchor).isActive = true
childViewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true
childViewController.didMove(toParentViewController: self)
}
上面这个方法决定了我们该向当前的用户展示哪个子 view controller。如果处于compact
模式,那么应该显示 "start game" 界面。
如果处于expanded
模式,我们需要判断是 A 玩家还是 B 玩家。如果是 B 玩家在对话界面中点击消息,此时conversation.selectedMessage
就不会是 nil,这说明游戏已经开始了,所以我们要展示ShipDestroyViewController
。否则就展示ShipLocationViewController
。
切换界面模式
在GameStartViewController
点击 "start game" 按钮,我们希望插件能切换到expanded
模式,好让我们展示棋盘。
// 在 'createGameStartViewController' 里
controller.onButtonTap = {
[unowned self] in
self.requestPresentationStyle(.expanded)
}
创建『可以更新』的消息
之前在 Messages 里面,任何新的内容——不管是新的短信还是表情——都会以一条新消息的形式出现在对话的底部,跟之前的所有消息都不相干。
然而,这一点可能带来很多麻烦:比如,一个下国际象棋的游戏插件会造成每走一步棋都要发一条新消息。而我们理想中的情况应该是更新后的消息能代替之前的消息。
谢天谢地,苹果也想到了这一点,给我们提供了一个类MSSession——这个类没有属性也没有方法,只是用来更新消息的。
我们发一条消息的时候,就用这个 session 来告诉 Messages,要覆盖此前 session 相同的信息。前一条信息会被从聊天记录中移除,然后新的信息插入到底部。
使用联系人姓名
最近几年,苹果一直说要把保护用户隐私当做头等大事。对 Messages framework 来说确实如此:你并不能得到用户的身份,只能得到一个每个设备不同的UUID。也就是说,你不能在消息里加入发消息的用户的身份 ID,然后指望收消息的用户能通过这个 ID 识别出发消息的是谁。
另外,你只能访问到用户点击的那条消息的内容,不能访问到对话中任何其他消息的内容(而且点击的这条消息还必须是从你的插件发出来的)。
MSConversation 这个类有两个属性localParticipantIdentifier
和remoteParticipantIdentfiers
,可以用来显示对话双方的名字。要加一个前缀$
。
let player = "$\(conversation.localParticipantIdentifier)"
把它放在消息里发出去,Messages 会解析这个 UUID,然后显示出对应的联系人姓名。
收发应用数据
游戏状态的数据是以 URL 的形式传递的。你的插件装在任意一台手机上,都应该有能力解析这个 URL,展示相关的内容。
使用 URL 的另一个好处是,它还能为 MacOS 用户提供一个备用方案。不幸的是,MacOS 上的 Messages 应用并不支持插件功能。文档里是这样说的:
如果在 macOS 上点击这条信息,系统会转到 web 浏览器打开这个 URL。所以这个 URL 应该定向到你自己的 web service,基于 URL 里 encode 的数据为用户呈现合理的结果。
要构建这个 URL,我们可以使用URLComponents
,组合一个 base url 和一群URLQueryItems
(都是有效的键值对)。
extension GameModel {
func encode() -> URL {
let baseURL = "www.shinobicontrols.com/battleship"
guard var components = URLComponents(string: baseURL) else {
fatalError("Invalid base url")
}
var items = [URLQueryItem]()
// 战舰的位置
let locationItems = shipLocations.map {
location in
URLQueryItem(name: "Ship_Location", value: String(location))
}
items.append(contentsOf: locationItems)
// 游戏结束
let complete = isComplete ? "1" : "0"
let completeItem = URLQueryItem(name: "Is_Complete", value: complete)
items.append(completeItem)
components.queryItems = items
guard let url = components.url else {
fatalError("Invalid URL components")
}
return url
}
}
最后得出的 url 结果形如:www.shinobicontrols.com/battleship?Ship_Location=0&Ship_Location=1&Is_Complete=0
而解码基本与此过程相反:先得到 url,取出每个键值对,由每个对应的值来构建游戏的数据模型。
在聊天对话中插入信息
经过前面的艰苦努力,我们终于创建出了这条消息,准备好让玩家在对话中发给其他玩家了。
/// 构建一条消息,然后插入到对话中
func insertMessageWith(caption: String,
_ model: GameModel,
_ session: MSSession,
_ image: UIImage,
in conversation: MSConversation) {
let message = MSMessage(session: session)
let template = MSMessageTemplateLayout()
template.image = image
template.caption = caption
message.layout = template
message.url = model.encode()
// 我们构建好这条消息之后,把它插入对话中
conversation.insert(message)
}
就像前面说过的那样,这条消息是用一个 session 创建的,这样我们就可以覆盖对话中同一个 session 的信息了。
为了修改消息的外观,我们要用到MSMessageTemplateLayout。它能让我们修改消息的一系列属性,在这个例子里主要用到caption
(文字)和image
(图片)。
修改完消息的外观,配置好 session 和 URL 属性,我们终于可以把消息插进对话中了。最后这行代码会把消息放进 Messages 的输入框里。注意:我们没有权限直接把这条消息发出去——只能放进输入框里。
结束啦
插入完这条消息之后,我们的插件也没有必要再在这闲待着了。用户可以手动把它关掉,不过为了让他们体验好一点,所以我们调用这行代码,自己结束掉MessagesViewController
的生命:
self.dismiss()
扩展阅读
谢谢你看完这么长一篇文章,希望能让你对于 iOS 10 Message 应用的强大功能略窥一二。
目前的 beta 版肯定少不了一些小问题:iOS 模拟器启动 Messages 应用速度很慢,而且有时就是加载不出来插件——我经常需要从 Messages 的应用托盘里手动重启我的插件。而且 Messages framework 非常『絮叨』:打出来的 log 简直多到极点。当然,在 iOS 10 结束 beta 之后这些问题都会得到解决,不过目前这种状态下你还是需要一双火眼金睛,从大量 debug 信息里寻找跟你插件有关的内容,比如 AutoLayout constraint 冲突之类。
如果你还想继续往下探索,我推荐你看这场 WWDC 视频,也可以看看苹果官方的例子工程:里面可以学到很多有趣的小 tips,例如如何优雅地解析 URL。
如果有任何问题和评论,我们都很欢迎你的反馈。可以发我 tweet @sam_burnstone,也可以关注 @shinobicontrols 关注最新动态以及 iOS 10 Day by Day 系列的更新。感谢阅读!
原作者:Sam Burnstone @sam_burnstone
ShinobiControls 官网:ShinobiControls.com twitter : @shinobicontrols
译者:戴仓薯