[iOS 10 day by day] Day 1:开发 iMessage 的第三方插件

本文介绍了 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上了。

demo 动图

游戏规则是这样的:

  • 玩家 A 发起游戏,在棋盘上布置两个『战舰』,然后隐藏起来
  • 另一个玩家 B 要猜测战舰的位置
  • 如果猜中了两艘隐藏战舰的位置,玩家 B 就赢了;但是如果猜错 3 次,玩家 B 就输了。

建工程

用 Xcode 新建一个插件工程非常简单。只需点击 File -> New Project,然后在窗口中选择 iMessage Application。

建工程

给工程起个名字,然后语言选择 Swift(本系列均使用 Swift 语言示例),这就完事了。因为有一个自动生成的MessagesExtensiontarget ,然后默认的Info.plist里带有必需的配置(插件界面的 storyboard 以及插件的类型等),所以只要运行工程,Messages 就能自动识别出我们的插件了。

改 Display Name

如果在模拟器里运行MessagesExtension这个 target,它会让你选择在哪个 app 里运行这个插件。我们选择Messages

在 Messages 里运行

Messages 打开的时候,应该能在输入框下方看到我们的插件。如果看不到,可能需要点击 "Applications" icon,然后再点 4 个椭圆的 icon,从里面选择我们的插件。

现在里面啥也没有,不过我们将很快改变这一点。眼下最迫切的是要把我们插件的 display name 改改:现在显示的是 "MessagesExtension"(实际上是 "MessagesEx..." 后面被截掉了)。下面我们点击 target,然后把Display Name输入框里的名字改一改。

改 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 布置战舰的界面。

我们实现gameBoardonCellSelection方法来控制 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)
}
切换到 expanded 模式

创建『可以更新』的消息

之前在 Messages 里面,任何新的内容——不管是新的短信还是表情——都会以一条新消息的形式出现在对话的底部,跟之前的所有消息都不相干。

然而,这一点可能带来很多麻烦:比如,一个下国际象棋的游戏插件会造成每走一步棋都要发一条新消息。而我们理想中的情况应该是更新后的消息能代替之前的消息。

谢天谢地,苹果也想到了这一点,给我们提供了一个类MSSession——这个类没有属性也没有方法,只是用来更新消息的。

我们发一条消息的时候,就用这个 session 来告诉 Messages,要覆盖此前 session 相同的信息。前一条信息会被从聊天记录中移除,然后新的信息插入到底部。

使用联系人姓名

最近几年,苹果一直说要把保护用户隐私当做头等大事。对 Messages framework 来说确实如此:你并不能得到用户的身份,只能得到一个每个设备不同的UUID。也就是说,你不能在消息里加入发消息的用户的身份 ID,然后指望收消息的用户能通过这个 ID 识别出发消息的是谁。

另外,你只能访问到用户点击的那条消息的内容,不能访问到对话中任何其他消息的内容(而且点击的这条消息还必须是从你的插件发出来的)。

MSConversation 这个类有两个属性localParticipantIdentifierremoteParticipantIdentfiers,可以用来显示对话双方的名字。要加一个前缀$

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 系列的更新。感谢阅读!

原文地址:iOS 10 Day by Day :: Day 1 :: Messages

原作者:Sam Burnstone @sam_burnstone

ShinobiControls 官网:ShinobiControls.com twitter : @shinobicontrols

文集地址:iOS 10 day by day 仓薯翻译

本文地址:http://www.jianshu.com/p/8728d405b310

译者:戴仓薯

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,456评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,370评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,337评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,583评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,596评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,572评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,936评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,595评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,850评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,601评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,685评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,371评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,951评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,934评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,167评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,636评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,411评论 2 342

推荐阅读更多精彩内容