いものやま。

雑多な知識の寄せ集め

「BirdHead」のUIを作ってみた。(その7)

前回は手札の表示の実装を行った。

次はマイナス点情報の表示の予定だったんだけど、その前にちょっとリファクタリングを行う。

気になったコード

さて、リファクタリングを行うということは、何か気になったコードがあったということ。
それは何かというと、アクションを確実に1つずつ実行するための、以下のようなコード。

self.actionQueue.addOperationWithBlock {
  self.actionQueue.suspended = true

  NSOperationQueue.mainQueue().addOperationWithBlock {
    // 何か処理
    someNode.runAction(someAction) {
      self.actionQueue.suspended = false
    }
  }
}

このコードの意味はSpriteKitでアクションを確実に1つずつ実行する方法について。 - いものやま。を参照。

このコード自体は必要なんだけど、こう何度も繰り返し書いてると、さすがに気になる。
つまり、このコードを何度も書かなくて済むように、部品化出来ないか、と。

さらに、手札の表示では、次のようなコードもあった。

  func playCards(cards: [Int], to playAreaNode: PlayAreaNode,
                 completion: (() -> Void)! = nil)
  {
    self.actionQueue.addOperationWithBlock {
      self.actionQueue.suspended = true
      
      NSOperationQueue.mainQueue().addOperationWithBlock {
        // 省略
        
        var lockCount = 0
      
        lockCount += 1
        playAreaNode.addCardNodes(actionCardNodes) {
          lockCount -= 1
          if lockCount == 0 {
            self.actionQueue.suspended = false
          }
        }
      
        var angle = HandNode.angle * CGFloat(self.cardNodes.count - 1) / 2.0
        for cardNode in self.cardNodes {
          // 省略
          
          lockCount += 1
          cardNode.runAction(arrangeAction) {
            lockCount -= 1
            if lockCount == 0 {
              self.actionQueue.suspended = false
            }
          }

          // 省略
        }
      }
    }
    
    // 省略
  }

このコードではlockCountという変数を用意して、待つ必要のある処理の数を管理している。
けど、待つ必要のある処理の数が複数あるというのは普通にありえるわけで、そのたびに同じようにlockCountを増減させてチェックしてというコードを書くのは、なんとも野暮ったい。
このあたりも、もうちょいスマートにしたいところ。

ということで、これらを解決するために、ActionQueueというクラスを用意した。

ActionQueue

ActionQueueでは、上に書いたようなコードをもっと簡単に書けるようにする。

//==============================
// BirdHead
//------------------------------
// ActionQueue.swift
//==============================

import Foundation
import SpriteKit

class ActionQueue {
  // 続く

ActionQueue.Executor

最初に、ActionQueue.Executorという内部クラスを用意しておく。

この内部クラスは、lockCountを管理して、ノードにアクションを実行させたときに、コンプリーションで必要ならキューを再開させる処理を行うというクラス。

  // 続き

  class Executor {
    private let actionQueue: ActionQueue
    private var lockCount: Int
    
    init(actionQueue: ActionQueue) {
      self.actionQueue = actionQueue
      self.lockCount = 0
    }
    
    func executeAction(action: SKAction, forNode node: SKNode,
                       completion: (() -> Void)! = nil)
    {
      self.startAction()
      node.runAction(action) {
        if completion != nil {
          completion()
        }
        self.endAction()
      }
    }
    
    func startAction() {
      self.lockCount += 1
    }
    
    func endAction() {
      self.lockCount -= 1
      if lockCount <= 0 {
        self.actionQueue.resume()
      }
    }
  }

  // 続く

ノードにアクションを実行させるときに、ActionQueue.Executor#executeAction(_: SKAction, forNode: SKNode, completion: (() -> Void)! = nil)を使えば、アクションを実行する前にlockCountを増やし、コンプリーションの最後でlockCountを減らして、もしlockCountが0ならキューを再開させることが出来る。

また、ActionQueue.Executor#startAction()、ActionQueue.Executor#endAction()を使って、直接コントロールを行うことも出来る。
この場合、何か待つ必要がある処理を行う前にstartAction()を呼んでおいて、その処理が終わったらendAction()を呼べばいい。
(※ただし、必ず対にして使わないとダメ)

プロパティとイニシャライザ

次はActionQueue自体のプロパティとイニシャライザ。

  // 続き
  
  private let queue: NSOperationQueue
  
  init() {
    self.queue = NSOperationQueue()
    self.queue.maxConcurrentOperationCount = 1
  }
  
  // 続く

プロパティはオペレーションキューだけ。
イニシャライザでは、このキューで並列して実行される処理の上限を1にしている。

処理の追加

あとは処理の追加。

  // 続き
  
  func addBlock(block: () -> Void) {
    self.queue.addOperationWithBlock {
      NSOperationQueue.mainQueue().addOperationWithBlock(block)
    }
  }

  func addActionBlock(block: (ActionQueue.Executor) -> Void) {
    self.queue.addOperationWithBlock {
      self.suspend()
      
      NSOperationQueue.mainQueue().addOperationWithBlock {
        let executor = Executor(actionQueue: self)
        
        // HACK: to resume this queue if block does not use executor.
        executor.startAction()
        
        block(executor)
        
        // HACK: to resume this queue if block does not use executor.
        executor.endAction()
      }
    }
  }
  
  private func suspend() {
    self.queue.suspended = true
  }
  
  private func resume() {
    self.queue.suspended = false
  }
}

ActionQueue#addBlock(_: () -> Void)では、キューを一時停止せずに単にメインキューに処理を投げる。

ActionQueue#addActionBlock(_: (ActionQueue.Executor) -> Void)では、キューを一時停止して、メインキューには「ActionQueue.Executorを生成し、引数として渡されたブロックにそのExecutorを渡して実行する」という処理を投げる。
こうすることで、ブロックではExecutorを使ってアクションを実行すれば、待つ必要のある処理の数はExecutorが管理するので、その数が0になり次第、キューが再開されるようになる。

ここで一つ、ちょっとしたトリックを使っていて、それは、引数で渡されたブロックを呼び出す前後で、startAction()とendAction()を呼んでいるということ。
これは何のためかというと、もしブロックの中でExecutorが全く使われなかった場合、誰もキューを再開させないので、キューが止まってしまう、という問題を回避するため。
ブロックの呼び出しをstartAction()とendAction()で挟んでおけば、

  • ブロックの中でExecutorを使っていなかった場合、ブロック呼び出し後のendAction()でlockCountは0になり、キューが再開される。
  • ブロックの中でExecutorを使っていた場合、ブロック呼び出し前のstartAction()とブロック呼び出し後のendAction()でlockCountの増減は±0なので、lockCountはブロック内の待つ必要のある処理の数に一致し、それらが終わり次第、キューが再開される。

というふうに、Executorを使った場合にも使わなかった場合にも、キューは問題なく再開されるようになる。

ActionQueueを使った書き直し例

このActionQueueを使うと、先程の手札の表示のコードは、次のように書き直せる。

  func playCards(cards: [Int], to playAreaNode: PlayAreaNode,
                 completion: (() -> Void)! = nil)
  {
    self.actionQueue.addActionBlock { executor in
      // 省略
      
      executor.startAction()
      playAreaNode.addCardNodes(actionCardNodes) {
        executor.endAction()
      }
      
      var angle = HandNode.angle * CGFloat(self.cardNodes.count - 1) / 2.0
      for cardNode in self.cardNodes {
        // 省略

        executor.executeAction(arrangeAction, forNode: cardNode)
        
        // 省略
      }
    }
    
    // 省略
  }

オペレーションキューを一時停止、再開させる処理を書いたり、メインキューにブロックを投げる処理を書いたりする必要がなくなってることが分かると思う。
また、lockCountはExecutorが勝手に管理してくれるので、自前で管理する必要がなくなっている。

実際には、このActionQueueを使うようにDeckNode、PlayAreaNode、HandNodeを修正したのだけど、全部載せるとそれなりの量になるので、修正後のコードは省略。
ActionQueueを実際に使ったコードは、マイナス点情報の表示の方で。

今日はここまで!