iOS 10 by Tutorials 笔记(五)

继续没事翻翻书,做做笔记,因为整本书都还在 Early Access 的状态,出来哪章写哪章,稍后再调整顺序吧。 Chapter 5: Intermediate

继续没事翻翻书,做做笔记,因为整本书都还在 Early Access 的状态,出来哪章写哪章,稍后再调整顺序吧。

Chapter 5: Intermediate Message Apps

本节我们要实现一个稍微复杂点 Message Apps 小游戏,其实就是你画我猜啦~

还是先来快速熟悉下工程:

  • 和第四章一样,其实这并不是一个完整的 App,所以的代码都在 MessagesExtension group 中
  • Models 分类中包含了 game 的模型对象 WenderPicGame
  • Controllers 分类下包含三个 VC 用来实现我们的游戏操作
    • SummaryViewController 用来展示一些游戏的基本信息,标题、按钮什么的
    • DrawingViewController 用来处理绘画操作
    • GuessesViewController 处理猜词的逻辑
  • MainInterface.storyboard 相关 VC 和 UI 已经添加完毕 iOS 10 by Tutorials 笔记(五)

The Messages app view controller

前面一章我们已经提到 MSMessagesAppViewController 是 UIViewController 的一个子类,也有类似于 UIViewController 的生命周期:

  • willBecomeActive(with:):
  • didResignActive(with:):
  • didReceive(_: conversation:): extension 生成的消息被接收了调用
  • didStartSending(_: conversation:)
  • didCancelSending(_: conversation:)

这些都比较简单,最后还有两个 presentation-style 样式:

  • Compact:当键盘出来基本就是这种
  • Expanded:全屏是这种

上面这两种样式切换会调用 willTransition(to:)didTransition(to:) 。我们将 MSMessagesAppViewController 作为根 VC (最高指挥官)处理以上所有的事务。

Adding the first child view controller

前面我们观察到在 MainInterface.storyboard 中的 VC 都是各自孤立的个体,作为 rootVC,我们需要手动往 MessagesViewController 中添加子 VC,因为需要在 rootVC 的基础上切换不同的子 VC,可以先搞一个 utility 方法出来,方便我们切换。

extension MessagesViewController { func switchTo(viewController controller: UIViewController) { // Remove any existing child view controller for child in childViewControllers { child.willMove(toParentViewController: .none) child.view.removeFromSuperview() child.removeFromParentViewController() } // Add the new child view controller addChildViewController(controller) controller.view.translatesAutoresizingMaskIntoConstraints = false view.addSubview(controller.view) NSLayoutConstraint.activate([ controller.view.leftAnchor.constraint(equalTo: view.leftAnchor), controller.view.rightAnchor.constraint(equalTo: view.rightAnchor), controller.view.topAnchor.constraint(equalTo: topLayoutGuide.bottomAnchor), controller.view.bottomAnchor.constraint(equalTo: view.bottomAnchor) ]) controller.didMove(toParentViewController: self) }}

我们第一个要展现的子 VC 是 SummaryViewController ,它包含游戏的名称(title)和一个 "new game" 的按钮,因为要从 storyboard 中初始化,同样先创建一个 utility 方法

func instantiateSummaryViewController(game: WenderPicGame?) -> UIViewController { guard let controller = storyboard?.instantiateViewController( withIdentifier: "summaryVC") as? SummaryViewController else { fatalError("Unable to instantiate a summary view controller") } controller.game = game return controller}

至于在 rootVC 上到底显示哪个子 VC,可以根据当前对话(current conversation)和外观样式(current presentation style)来共同决定。目前先展示 SummaryViewController 好了

func presentViewController( forConversation conversation: MSConversation, withPresentationStyle style: MSMessagesAppPresentationStyle) { let controller: UIViewController // TODO: Create the right view controller here controller = instantiateSummaryViewController(game: nil) switchTo(viewController: controller)}

接着在 willBecomeActive 中调用上面这个展示方法,这里我们使用了当前环境下默认的 presentationStyle

override func willBecomeActive(with conversation: MSConversation) { // Called when the extension is about to move from the inactive to active state. // This will happen when the extension is about to present UI. // Use this method to configure the extension and restore previously stored state. presentViewController(forConversation: conversation, withPresentationStyle: presentationStyle)}

运行,选择我们的 Messages app,看上去是这样的,此时的样式是 Compact iOS 10 by Tutorials 笔记(五)

点击 New Game 按钮,好像并没有反应,我们来修复一下,其实在工程中我们的 SummaryViewController 并没有自己去实现这个点击操作,而是定义了一个 Protocol 交给 MessagesViewController 去实现,我们这里只是做了个样式变换(由 compact -> expanded)

extension MessagesViewController: SummaryViewControllerDelegate { func handleSummaryTap(forGame game: WenderPicGame?) { requestPresentationStyle(.expanded) }}

别忘了初始化 SummaryViewController 的时候 instantiateSummaryViewController(game:) 设置 delegate 为 MessagesViewController

controller.delegate = self

运行,现在 New Game 的按钮可以点击了,一点它就全屏了,此时的样式是 expanded iOS 10 by Tutorials 笔记(五)

Switching view controllers

现在我们需要点击 New Game 按钮开始一个新游戏,背后的逻辑是创建一个新 WenderPicGame 然后显示在 drawing view controller 上。还是先来搞一波 utility 方法

override func willTransition(to presentationStyle: MSMessagesAppPresentationStyle) { if let conversation = activeConversation { presentViewController( forConversation: conversation, withPresentationStyle: presentationStyle) }}

上面点击 New Game 按钮,将样式由 compact -> expanded,因此顺带着在样式变换 willTransition 的生命周期里搞点事情,调用 presentViewController 来决定该展示什么子 VC

和 SummaryViewController 一样,这次来搞一个 DrawingViewController 的初始化

func instantiateDrawingViewController(game: WenderPicGame?) -> UIViewController { guard let controller = storyboard?.instantiateViewController( withIdentifier: "drawingVC") as? DrawingViewController else { fatalError("Unable to instantiate a drawing view controller") } controller.game = game return controller}

然后回到 presentViewController(forConversation: withPresentationStyle:) 方法中,替换掉之前添加的 TODO: 注释,这次根据样式来选择展示什么子 VC

switch style { case .compact: controller = instantiateSummaryViewController(game: nil)case .expanded: let newGame = WenderPicGame.newGame(drawerId: conversation.localParticipantIdentifier) controller = instantiateDrawingViewController(game: newGame)}

我们使用了 MSConversationlocalParticipantIdentifier 属性来创建 WenderPicGame 。这个属性本质是一个 UUID,在整个应用的生命周期内都是唯一的。我们可以用来在两个玩家之间追踪游戏进程。

运行,随便画两笔,现在 Done 按钮还点击不了,马上修复 iOS 10 by Tutorials 笔记(五)

Creating a message

Messages framework 有三个 model 类: Conversation , Message , Session ,分别来学习下

Conversation

MSConversation 其实就是用来表示那一串串屏幕上的聊天对话,每个单独的聊天气泡其实就算一个 Conversation;通过这个类你并不能得到聊天内容,但还是能获取一些有用的属性。

  • localParticipantIdentifier 一串 UUID 用来表示用户设备
  • remoteParticipantIdentifiers 一组 UUIDs 表示这段消息的多个接受者
  • selectedMessage 这是可选属性,如果你的 extension 生成了可被选中的消息。

conversation 当然也可以添加附件、文本、表情什么的。

Message

MSMessage 表示一条可交互的消息,这条消息是独一无二的,只能被你的 Messages app 访问。如果其他人收到这种消息,但是并没有安装同样的消息应用,它会弹出提示让用户安装。

关于 message 最重要两个属性就是 layoutURL 了,要在发送消息前设置好他们。

  • layout 用来控制消息在对话中的展现形式,苹果并没有提供太多自定义的空间,你只需要设置合适的属性(image, caption 等)即可。
  • URL 你可以用来通过 message 发送自定义数据,通常使用 NSURLQueryItem 来以键值对的形式进行封装。

Session

MSSession 是用来保持对话进程状态的,对于发送消息,如果选择了和上一条消息相同的 session,则发送的新消息会附带上一条旧消息的内容回复过去(相当于在上一个 conversation 中更新了一条消息)

如果没有 Session,聊天小游戏就没办法追踪双方的游戏进程了。

Sending a message

理论知识就到这里,回到项目中来,我们已经画完了,现在发送给朋友。在 MessagesViewController.swift 中添加一个新的 extension

extension MessagesViewController { func composeMessage(with game: WenderPicGame, caption: String, session: MSSession? = .none) -> MSMessage { //1 let layout = MSMessageTemplateLayout() //2 layout.image = game.currentDrawing //3 layout.caption = caption //4 let message = MSMessage(session: session ?? MSSession()) message.layout = layout return message }}

前面理论准备阶段我们提到了 MSSession 最重要的属性之一 layout,我们用当前绘图的一些信息来配置它。

关于点击按钮的操作 DrawingViewController 同样是用 Protocol 的形式交给 MessagesViewController 来完成

extension MessagesViewController: DrawingViewControllerDelegate { func handleDrawingComplete(game: WenderPicGame?) { defer { dismiss() } guard let conversation = activeConversation, let game = game else { return } let message = composeMessage(with: game, caption: "Guess myWenderPic!", session: conversation.selectedMessage?.session!) conversation.insert(message) { (error) in if let error = error { print(error) } } }}

得到当前 conversation 然后插入自定义的消息,最后退出 extension( dismiss() )显示键盘,同样别忘了在初始化 DrawingViewController 时设置 controller.delegate = self

运行一下,现在可以把我们的画发送给朋友了 iOS 10 by Tutorials 笔记(五)

发送完成后,我们切到朋友的帐号,打开消息,点击一下,发现尼玛又进入了一个新的 DrawingViewController,这是因为每次 presentation style 变化,其实我们都通过 presentViewController 创建了一个新的子 VC,接下来我们会往消息里添加点游戏状态,这样双方就能保证玩的是同一个游戏了。

Custom message content

前面的理论准备阶段,我们讨论了 MSMessage 的 URL 属性是可以给消息中添加自定义数据的,这样我们把 WenderPicGame 想办法添加上去,然后在接收端根据规则重建数据模型。

想要在 URL 中携带数据信息,你需要 URLQueryItem,他可以以键值对的形式保持数据在 URL's query 部分,类似于 ?key=value&otherKey=otherValue 这种格式

打开 WenderPicGame.swift 添加计算属性 queryItems

extension WenderPicGame { var queryItems: [URLQueryItem] { var items = [URLQueryItem]() items.append(URLQueryItem(name: "word", value: word)) items.append(URLQueryItem(name: "guesses", value: guesses.joined(separator: "::-::"))) items.append(URLQueryItem(name: "drawerId", value: drawerId.uuidString)) items.append(URLQueryItem(name: "gameState", value: gameState.rawValue)) items.append(URLQueryItem(name: "gameId", value: gameId.uuidString)) return items } }

在 queryItems 数组内部,我们创建了很多 URLQueryItem,将 WenderPicGame 很多必要属性一一进行了编码。

切回到 MessagesViewController.swift ,找到 composeMessage(with: caption: session:) ,补上 message 的最重要属性之二: URL

var components = URLComponents() components.queryItems = game.queryItems message.url = components.url

其实你也直接使用 query components 创建一个有效的 URL 也是可以的

?word=dog&drawerId=D5E356A9-0B6A-4441-AB6C-08D24DB255B2

注意,图像太大了不能通过 URL 发送,我们可以考虑使用 web storage 技术使双方共享图片。本书为了方便,使用了 user defaults 在双方之间共享图片,因为模拟器上,对话双方在同一台机器中,user defaults 也是共享的,可以采取这种方式,但换到真机,双方都是不同机器,user defaults 的方式就行不通了。

找到 handleDrawingComplete(game:) ,把当前 image 存储到 user defaults 中(DrawingStore 是封装的一个将图片存储到 user defaults 中的对象)

if let drawing = game.currentDrawing { DrawingStore.store(image: drawing, forUUID: game.gameId)}

因为每一条消息是无状态的,我们需要在接收端根据消息携带的信息重建游戏,为了达到这一点,我们需要通过 [URLQueryItem] 数组对象来创建游戏模型,在 WenderPicGame.swift 中添加一个初始化方法

init?(queryItems: [URLQueryItem]) { var word: String? var guesses = [String]() var drawerId: UUID? var gameId: UUID? for item in queryItems { guard let value = item.value else { continue } switch item.name { case "word": word = value case "guesses": guesses = value.components(separatedBy: "::-::") case "drawerId": drawerId = UUID(uuidString: value) case "gameState": self.gameState = GameState(rawValue: value)! case "gameId": gameId = UUID(uuidString: value) default: continue } } guard let decodedWord = word, let decodedDrawerId = drawerId, let decodedGameId = gameId else { return nil } self.word = decodedWord self.guesses = guesses self.currentDrawing = DrawingStore.image(forUUID: decodedGameId) self.drawerId = decodedDrawerId self.gameId = decodedGameId}

我们接收到的是 MSMessage 消息,就再进一步封装下:

init?(message: MSMessage?) { guard let messageURL = message?.url, let urlComponents = URLComponents(url: messageURL, resolvingAgainstBaseURL: false), let queryItems = urlComponents.queryItems else { return nil } self.init(queryItems: queryItems)}

现在接收端点击消息,就有能力重建游戏状态了,我们希望对方点击消息后进入猜谜的 VC 页面(GuessViewController)

还是回到 MessagesViewController.swift 添加一个 GuessViewController 的初始化便利方法

func instantiateGuessViewController(game: WenderPicGame?) -> UIViewController { guard let controller = storyboard?.instantiateViewController( withIdentifier: "guessVC") as? GuessViewController else { fatalError("Unable to instantiate a guess view controller") } controller.game = game return controller}

找到 presentViewController(forConversation withPresentationStyle:) 在全屏 case .expanded 下替换之前的代码:

if let game = WenderPicGame(message: conversation.selectedMessage) { controller = instantiateGuessViewController(game: game)} else { let newGame = WenderPicGame.newGame( drawerId: conversation.localParticipantIdentifier) controller = instantiateDrawingViewController(game: newGame)}

这次是先拿当前消息 message 初始化一波游戏模型,成功了进入猜图模式,失败了再回到绘画模式。运行一下: iOS 10 by Tutorials 笔记(五)

输入文字,点击 Guess 按钮没反应?GuessViewController 还是老样子,代理给 MessagesViewController 去处理啦

extension MessagesViewController: GuessViewControllerDelegate { func handleGuessSubmission(forGame game: WenderPicGame, guess: String) { defer {dismiss()} guard let conversation = activeConversation else { return } let prefix = game.check(guess: guess) ? ":+1:" : ":-1:" let guesser = "$/(conversation.localParticipantIdentifier)" let caption = "/(prefix)/(guesser) guessed /(guess)" let message = composeMessage(game: game, caption: caption, session: conversation.selectedMessage?.session) conversation.insert(message) { (error) in print(error) } }}

我们其实已经预先设置了一组单词作为答案,每次获取一个随机字符串作为答案保存在 WenderPicGame 实例中,用户输入作为参数 guess,接着检查用户输入和答案是否相等,将结果封装到 message 中,最后插入到会话 conversation 中(别忘了设 controller.delegate = self )。

运行,发现猜谜结果已经封装到 message 中并插入到绘画的 conversation 里了(红框部分)

iOS 10 by Tutorials 笔记(五)

Getting a second chance

玩一会这个游戏,就发现提供的笔墨实在有限,我们希望给玩家提供第二次机会,如果对方第一次没猜对,我们就再原来的基础上再画一次,接着让用户猜,依次反复直到猜对为止。

打开 MessagesViewController.swift 找到 presentViewController(forConversation withPresentationStyle:) 方法,添加对一个游戏状态的判断:

if let game = WenderPicGame(message: conversation.selectedMessage) { switch game.gameState { case .guess: controller = instantiateDrawingViewController(game: game) case .challenge: controller = instantiateGuessViewController(game: game) }} else {

现在我们就能反复补充绘画,直到对方猜对为止了。 iOS 10 by Tutorials 笔记(五)

-EOF-
未登录用户
全部评论0
到底啦