本文介紹了 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 語言示例),這就完事了。因為有一個自動生成的MessagesExtension
target ,然後默認的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) }切換到 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
譯者:戴倉薯