本文介紹了 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 語言示例),這就完事了。因為有一個自動生成的MessagesExtensiontarget ,然後默認的Info.plist裡帶有必需的配置(插件界面的 storyboard 以及插件的類型等),所以只要運行工程,Messages 就能自動識別出我們的插件了。
如果在模擬器裡運行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 是我們插件的入口點。它是MSMessagesAppViewController的子類,相當於是 Messages 插件的 root View Controller。自動生成的模板裡面包含了一些供我們重寫的方法,比如插件啟動狀態下用戶收到消息的回調函數。待會我們就要用到其中的一部分方法。
第一點要注意的是,我們的插件啟動之後有兩種可能的 presentation style:
compact
expanded
compact是用戶從應用托盤裡打開插件的模式,插件顯示在鍵盤區域裡。expanded則多給了一些喘息的空間,插件占據大部分的屏幕。
為了讓代碼整潔一些,我們會用不同的 view controller 來分別實現兩種模式,並且把這些 view Controller 都加為MessagesViewController的子 view controller。
本文不會花太長篇幅來描述這些 controller 的實現細節,只會重點關注在收發信息的過程,游戲狀態和數據是怎麼變化的。關於具體實現,請自行閱讀 Github 上的源碼。
我們的插件剛啟動的時候處於compact狀態。這點空間並不夠展示游戲的棋盤,在 iPhone 上尤其不夠。我們可以簡單粗暴地立即切換成expanded狀態,但是蘋果官方警告不要這麼做,畢竟還是應該把控制權交給用戶。
於是,我們來顯示一個簡單的歡迎界面,裡面有一個 label 和一個 button。按下 button 的時候,再切換到游戲的主界面,用戶就可以開始放置『戰艦』了。
這個 view controller 是玩家 A 布置戰艦的界面。
我們實現gameBoard的onCellSelection方法來控制 cell 的樣式:上面有戰艦的 cell 顯示為綠色,空白的顯示為藍色。
shipsLeftToPosition返回 0 時,結束按鈕會變得可點。這個按鈕的點擊事件是一個叫completedShipLocationSelection:的IBAction方法,它會新建一個游戲 model,然後使用 UIImage 的 extension 來創建一張游戲棋盤的截圖(我們會先reset()棋盤,所以截圖的時候戰艦的位置是隱藏的——現在可不是揭曉謎底的時候!)。這張截圖在待會發消息的時候會用到。
當玩家 B 點擊對話中的消息時,我們希望他能看到一個略微不同的 view controller —— 一個能讓他尋找隱藏戰艦的界面。
我們還是實現棋盤的onCellSelection方法。這一次我們把選擇的 cell 位置與玩家 A 布置的位置匹配的(『擊中戰艦』)標為綠色,如果沒有擊中就標為紅色。
游戲結束後,不管是因為 3 條命用完了,還是因為兩條戰艦都找出來了,我們都會相應地記錄在數據模型中,然後調起游戲結束的回調。
回到我們的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 系列的更新。感謝閱讀!
原文地址: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
譯者:戴倉薯