剑客
关注科技互联网

iOS 10 by Tutorials 笔记(五)

继续没事翻翻书,做做笔记,因为整本书都还在 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-

分享到:更多 ()

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址