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

本文介紹了 iOS 10 的一個重要更新:Messages 應用支持第三方插件了。做者用一個小遊戲做爲例子,說明了插件開發從建工程開始,到繪製界面、收發消息的全過程。ios

《iOS 10 day by day》是 shinobicontrols 公司編寫的系列博客,介紹開發者須要瞭解的 iOS 10 新特性,每週更新。本系列翻譯(文集地址)已取得官方受權。倉薯翻譯,歡迎指正:)git

Shinobicontrols 爲 iOS 和 Android 開發者提供高性能、響應式的 UI 控件 SDK,尤爲是圖表方面的控件。 官網 : shinobicontrols.com twitter : @shinobicontrolsgithub

蘋果官方的 Messages 在 iOS 10 推出了很是重大的更新,可能主要是想從其餘 IM 巨頭手裏搶點市場份額回來,包括 Facebook Messenger, Wechat 和 Snapchat。web

一個重要的新功能是,用戶能夠直接在 Messages 裏使用第三方開發者開發的擴展插件了。這個功能是在 iOS 8 引入的 Extension 技術基礎上實現的,能夠參考咱們往年系列裏 Sam Davies 寫的文章。Messages 插件的一大好處是,它是能夠獨立於 app 存在的,不用跟父 app 打包在一塊兒。今年晚些時候 iOS 10 將會發佈一個小巧的 Messages App Store,裏面會有一堆插件供用戶挑選。瀏覽器

爲了演示一下這個使人興奮的插件功能,咱們看一個簡單的例子吧,這個插件可讓兩個用戶玩一個簡化版的流行遊戲 Battleships。爲了讓約束佈局方面簡單一些,咱們只考慮豎屏的狀況。爲方便你們下載這個 demo,我把它放到Github上了。session

demo 動圖

遊戲規則是這樣的:app

  • 玩家 A 發起遊戲,在棋盤上佈置兩個『戰艦』,而後隱藏起來ide

  • 另外一個玩家 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 倉薯翻譯

譯者:戴倉薯

相關文章
相關標籤/搜索