いものやま。

雑多な知識の寄せ集め

変種オセロのスタート画面を作ってみた。(その4)

昨日は設定の描画を作った。

今日は設定のモデルオブジェクトの実装と、設定の描画のリファクタリングを行う。

設定の保存について

iOSでは、データを保存するための手段がいくつか提供されている。
その中でも特に、設定を保存するためのクラスとして用意されているのが、NSUserDefaults。

NSUserDefaultsでは、辞書と同様に、キーと値をペアにして設定データを保存できる。
また、設定の初期値をセットしておくことも出来るので、設定を読み出す前に値がセットされているかどうかをチェックするというのも不要になっている。

設定のモデルオブジェクト

さて、NSUserDefaultsを直接そのまま使ってもいいのだけど、それだとどんなキーが用意されているのかといったコードがいろんな場所に散らばってしまう可能性が高く、あまりよろしくない。
なので、設定のモデルオブジェクトを作って、NSUserDefaultsに触る処理はそこにまとめてしまった方がいい。
設定に触るクラスから見た場合も、NSUserDefaultsを使っていることが隠蔽されるので、使い勝手がよくなるし。

ということで用意したクラスが、以下のConfigクラス。

//==============================
// YWF
//------------------------------
// Config.swift
//==============================

import Foundation

public class Config {
  public enum ComputerLevel: Int {
    case Easy = 0
    case Normal
    case Hard
    
    private static let key = "ComputerLevel"
  }
  
  public enum FirstMove: Int {
    case Random = 0
    case Human
    case Computer
    
    private static let key = "FirstMove"
  }
  
  private static let defaults = [
    ComputerLevel.key: ComputerLevel.Easy.rawValue,
    FirstMove.key: FirstMove.Random.rawValue,
  ]
  
  private static var instance: Config! = nil
  
  public class func getInstance() -> Config {
    if instance == nil {
      Config.instance = Config()
    }
    return Config.instance
  }
  
  private var userDefaults: NSUserDefaults
  
  public var computerLevel: ComputerLevel {
    get {
      let computerLevelValue = self.userDefaults.integerForKey(ComputerLevel.key)
      return ComputerLevel(rawValue: computerLevelValue)!
    }
    
    set {
      self.userDefaults.setInteger(newValue.rawValue, forKey: ComputerLevel.key)
      self.userDefaults.synchronize()
    }
  }
  
  public var firstMove: FirstMove {
    get {
      let firstMoveValue = self.userDefaults.integerForKey(FirstMove.key)
      return FirstMove(rawValue: firstMoveValue)!
    }
    
    set {
      self.userDefaults.setInteger(newValue.rawValue, forKey: FirstMove.key)
      self.userDefaults.synchronize()
    }
  }
  
  private init() {
    self.userDefaults = NSUserDefaults.standardUserDefaults()
    self.userDefaults.registerDefaults(Config.defaults)
  }
}

設定のモデルオブジェクトはアプリの中で常にただ一つでないと困るので、シングルトンになるようにしている。
(厳密に言うと、スレッドセーフになっていないのだけど・・・)

ポイントを何点か。

まず、設定の初期値をセットしているのが、イニシャライザで呼んでいるNSUserDefaults#registerDefaults(_: [String: AnyObject])。
このメソッドに辞書を渡すことで、各キーに対する初期値が設定される。

気をつけないといけないのは、このメソッドはアプリ開始時に毎回呼び出す必要があるということ。
なので、ネットを検索すると、AppDelegateでこのメソッドを呼び出しているケースがけっこう見つかる。
ただ、上のコードのように、設定オブジェクトをシングルトンにして、イニシャライザ内でこのメソッドを呼び出すようにしておけば、インスタンスを最初に取得したときに(そしてそのときに限り1回だけ)必ず呼び出されるようになるので、コードを散らばせないという意味で、上のような実装の方がいいと思う。

次に、各プロパティのセッタで呼び出している、NSUserDefaults#synchronize()。
このメソッドは、NSUserDefaultsのインスタンスの状態をファイルに書き戻すためのメソッド。
ただ、NSUserDefaultsのインスタンスの状態は定期的に自動保存されるみたいなので、アプリが終了するタイミング以外には、本当は呼び出す必要はないはず・・・
まぁ、速度とかに問題がないのであれば、呼び出して悪いことはないので、とりあえず毎回呼び出すようにしてある。

設定の描画の修正

設定のモデルオブジェクトを用意したので、設定の描画(ConfigNode)にも修正を加える。
ついでに、リファクタリングも実施。

//==============================
// YWF
//------------------------------
// ConfigNode.swift
//==============================

import SpriteKit

public class ConfigNode: SKSpriteNode, LabelButtonNodeObserver {
  /* workaround for build error */
  typealias ConfigComputerLevel = Config.ComputerLevel
  typealias ConfigFirstMove = Config.FirstMove
  
  private struct LabelButtonNodeSetting {
    private let label: String
    private let fontSize: CGFloat
    private let x: CGFloat
    private let y: CGFloat
    private let align: SKLabelHorizontalAlignmentMode
    private let computerLevel: ConfigComputerLevel!
    private let firstMove: ConfigFirstMove!
  }
  
  private static let labelFontSize: CGFloat = 56.0
  private static let selectTextFontSize: CGFloat = 48.0
  
  private static let labelPositionX: [CGFloat] = [-163.0, 163.0]
  private static let labelPositionY: [CGFloat] = [210.0, 150,0]
  private static let selectTextPositionX: [CGFloat] = [-240.0, 90.0]
  private static let selectTextPositionY: [CGFloat] = [30.0, -65.0, -160.0]
  
  private static let labelButtonNodeSettings: [LabelButtonNodeSetting] = [
    /* Computer Level Label */
    LabelButtonNodeSetting(
      label: "Computer", fontSize: ConfigNode.labelFontSize,
      x: ConfigNode.labelPositionX[0], y: ConfigNode.labelPositionY[0],
      align: .Center,
      computerLevel: nil, firstMove: nil),
    LabelButtonNodeSetting(
      label: "Level", fontSize: ConfigNode.labelFontSize,
      x: ConfigNode.labelPositionX[0], y: ConfigNode.labelPositionY[1],
      align: .Center,
      computerLevel: nil, firstMove: nil),
    
    /* Computer Level Select List */
    LabelButtonNodeSetting(
      label: "Easy", fontSize: ConfigNode.selectTextFontSize,
      x: ConfigNode.selectTextPositionX[0], y: ConfigNode.selectTextPositionY[0],
      align: .Left,
      computerLevel: .Easy, firstMove: nil),
    LabelButtonNodeSetting(
      label: "Normal", fontSize: ConfigNode.selectTextFontSize,
      x: ConfigNode.selectTextPositionX[0], y: ConfigNode.selectTextPositionY[1],
      align: .Left,
      computerLevel: .Normal, firstMove: nil),
    LabelButtonNodeSetting(
      label: "Hard", fontSize: ConfigNode.selectTextFontSize,
      x: ConfigNode.selectTextPositionX[0], y: ConfigNode.selectTextPositionY[2],
      align: .Left,
      computerLevel: .Hard, firstMove: nil),
    
    /* First Move Label */
    LabelButtonNodeSetting(
      label: "First", fontSize: ConfigNode.labelFontSize,
      x: ConfigNode.labelPositionX[1], y: ConfigNode.labelPositionY[0],
      align: .Center,
      computerLevel: nil, firstMove: nil),
    LabelButtonNodeSetting(
      label: "Move", fontSize: ConfigNode.labelFontSize,
      x: ConfigNode.labelPositionX[1], y: ConfigNode.labelPositionY[1],
      align: .Center,
      computerLevel: nil, firstMove: nil),
    
    /* First Move Select List */
    LabelButtonNodeSetting(
      label: "Random", fontSize: ConfigNode.selectTextFontSize,
      x: ConfigNode.selectTextPositionX[1], y: ConfigNode.selectTextPositionY[0],
      align: .Left,
      computerLevel: nil, firstMove: .Random),
    LabelButtonNodeSetting(
      label: "Human", fontSize: ConfigNode.selectTextFontSize,
      x: ConfigNode.selectTextPositionX[1], y: ConfigNode.selectTextPositionY[1],
      align: .Left,
      computerLevel: nil, firstMove: .Human),
    LabelButtonNodeSetting(
      label: "Computer", fontSize: ConfigNode.selectTextFontSize,
      x: ConfigNode.selectTextPositionX[1], y: ConfigNode.selectTextPositionY[2],
      align: .Left,
      computerLevel: nil, firstMove: .Computer),
  ]
  
  private static let selectTokenPositionX: [CGFloat] = [-285.0, 45.0]
  private static let selectTokenPositionY: [CGFloat] = [50.0, -45.0, -140.0]
  
  private static let computerLevelSelectTokenPosition: [Config.ComputerLevel: CGPoint] = [
    .Easy: CGPoint(x: ConfigNode.selectTokenPositionX[0],
                   y: ConfigNode.selectTokenPositionY[0]),
    .Normal: CGPoint(x: ConfigNode.selectTokenPositionX[0],
                     y: ConfigNode.selectTokenPositionY[1]),
    .Hard: CGPoint(x: ConfigNode.selectTokenPositionX[0],
                   y: ConfigNode.selectTokenPositionY[2]),
  ]
  private static let firstMoveSelectTokenPosition: [Config.FirstMove: CGPoint] = [
    .Random: CGPoint(x: ConfigNode.selectTokenPositionX[1],
                     y: ConfigNode.selectTokenPositionY[0]),
    .Human: CGPoint(x: ConfigNode.selectTokenPositionX[1],
                    y: ConfigNode.selectTokenPositionY[1]),
    .Computer: CGPoint(x: ConfigNode.selectTokenPositionX[1],
                       y: ConfigNode.selectTokenPositionY[2]),
  ]
  
  private static let fadeDuration = NSTimeInterval(0.2)
  
  private static var textures = [String: SKTexture]()
  
  public class func loadAssets() {
    let atlas = SKTextureAtlas(named: "Config")
    let names = ["ConfigBack", "BadSelectToken", "GoodSelectToken"]
    for name in names {
      ConfigNode.textures[name] = atlas.textureNamed(name)
    }
  }
  
  private var config: Config
  
  private var computerLevel: [LabelButtonNode: ConfigComputerLevel]
  private var firstMove: [LabelButtonNode: ConfigFirstMove]
  
  private var computerLevelSelectToken: SKSpriteNode!
  private var firstMoveSelectToken: SKSpriteNode!
  
  public init() {
    self.config = Config.getInstance()
    
    self.computerLevel = [LabelButtonNode: ConfigComputerLevel]()
    self.firstMove = [LabelButtonNode: ConfigFirstMove]()
    
    self.computerLevelSelectToken = nil
    self.firstMoveSelectToken = nil
    
    let texture = ConfigNode.textures["ConfigBack"]!
    super.init(texture: texture, color: SKColor.whiteColor(), size: texture.size())
    
    /* Labels */
    for setting in ConfigNode.labelButtonNodeSettings {
      let label = LabelButtonNode(name: setting.label, fontSize: setting.fontSize)
      label.position.x = setting.x
      label.position.y = setting.y
      label.horizontalAlignmentMode = setting.align
      if setting.computerLevel != nil {
        label.addObserver(self)
        self.computerLevel[label] = setting.computerLevel!
      } else if setting.firstMove != nil {
        label.addObserver(self)
        self.firstMove[label] = setting.firstMove!
      } else {
        label.userInteractionEnabled = false
      }
      addChild(label)
    }
    
    /* Computer Level Select Token */
    self.computerLevelSelectToken = SKSpriteNode(texture: ConfigNode.textures["BadSelectToken"])
    self.computerLevelSelectToken.position = ConfigNode.computerLevelSelectTokenPosition[self.config.computerLevel]!
    addChild(self.computerLevelSelectToken)
    
    /* First Move Select Token */
    self.firstMoveSelectToken = SKSpriteNode(texture: ConfigNode.textures["GoodSelectToken"])
    self.firstMoveSelectToken.position = ConfigNode.firstMoveSelectTokenPosition[self.config.firstMove]!
    addChild(self.firstMoveSelectToken)
  }

  public required init?(coder aDecoder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
  }
  
  public func labelButtonIsSelected(button: LabelButtonNode) {
    if let computerLevel = self.computerLevel[button] {
      if self.config.computerLevel != computerLevel {
        self.config.computerLevel = computerLevel
        let fadeOut = SKAction.fadeOutWithDuration(ConfigNode.fadeDuration)
        self.computerLevelSelectToken.runAction(fadeOut) {
          self.computerLevelSelectToken.position = ConfigNode.computerLevelSelectTokenPosition[self.config.computerLevel]!
          let fadeIn = SKAction.fadeInWithDuration(ConfigNode.fadeDuration)
          self.computerLevelSelectToken.runAction(fadeIn)
        }
      }
    }
    
    if let firstMove = self.firstMove[button] {
      if self.config.firstMove != firstMove {
        self.config.firstMove = firstMove
        let fadeOut = SKAction.fadeOutWithDuration(ConfigNode.fadeDuration)
        self.firstMoveSelectToken.runAction(fadeOut) {
          self.firstMoveSelectToken.position = ConfigNode.firstMoveSelectTokenPosition[self.config.firstMove]!
          let fadeIn = SKAction.fadeInWithDuration(ConfigNode.fadeDuration)
          self.firstMoveSelectToken.runAction(fadeIn)
        }
      }
    }
  }
}

昨日の同じような処理がダラダラと続いていたコードが、かなりスッキリしたコードになっていることが分かると思う。

ポイントは2つあって、まず1つは、「処理」を「設定」にまとめたことと、もう1つは、オブジェクトに情報を持たせるようにしたということ。

まず、「処理」を「設定」にまとめた、とは、どういうことか。

昨日のコードだと、LabelButtonNodeを作って配置していく処理が、ダラダラと書かれていた。

    /* Computer Level Label */
    let labelComputer = LabelButtonNode(name: "Computer", fontSize: ConfigNode.labelFontSize)
    labelComputer.userInteractionEnabled = false
    labelComputer.position.x = ConfigNode.labelPositionX[0]
    labelComputer.position.y = ConfigNode.labelPositionY[0]
    addChild(labelComputer)
    // 省略
    
    /* Computer Level Select List */
    let selectTextEasy = LabelButtonNode(name: "Easy", fontSize: ConfigNode.selectTextFontSize)
    selectTextEasy.horizontalAlignmentMode = .Left
    selectTextEasy.position.x = ConfigNode.selectTextPositionX[0]
    selectTextEasy.position.y = ConfigNode.selectTextPositionY[0]
    selectTextEasy.addObserver(self)
    addChild(selectTextEasy)
    // 省略

これを、次のように設定にまとめる。

  private static let labelButtonNodeSettings: [LabelButtonNodeSetting] = [
    /* Computer Level Label */
    LabelButtonNodeSetting(
      label: "Computer", fontSize: ConfigNode.labelFontSize,
      x: ConfigNode.labelPositionX[0], y: ConfigNode.labelPositionY[0],
      align: .Center,
      computerLevel: nil, firstMove: nil),
    // 省略
    /* Computer Level Select List */
    LabelButtonNodeSetting(
      label: "Easy", fontSize: ConfigNode.selectTextFontSize,
      x: ConfigNode.selectTextPositionX[0], y: ConfigNode.selectTextPositionY[0],
      align: .Left,
      computerLevel: .Easy, firstMove: nil),
    // 省略
  ]

そうすると、処理自体は次のようにシンプルに書ける。

    /* Labels */
    for setting in ConfigNode.labelButtonNodeSettings {
      let label = LabelButtonNode(name: setting.label, fontSize: setting.fontSize)
      label.position.x = setting.x
      label.position.y = setting.y
      label.horizontalAlignmentMode = setting.align
      if setting.computerLevel != nil {
        label.addObserver(self)
        self.computerLevel[label] = setting.computerLevel!
      } else if setting.firstMove != nil {
        label.addObserver(self)
        self.firstMove[label] = setting.firstMove!
      } else {
        label.userInteractionEnabled = false
      }
      addChild(label)
    }

こうした場合も、今度は設定が多くなるので、これで何が嬉しいんだ?と思うかもしれないけれど、

  • 各要素の設定が分かりやすくなる
  • 要素の追加/削除が簡単になる
  • 処理の意味が抽象化されて分かりやすくなる
  • 処理に変更が入った場合に、何箇所もコードを修正する必要がなくなる

というメリットがある。

これは、変種オセロを考えてみた。(その3) - いものやま。の走査メソッドをシンプルに書くときに使った工夫と同質のもので、同じようなコードがダラダラと続く場合に有効なので、覚えておきたい手法。

次に、オブジェクトに情報を持たせるようにしたとは、どういうことか。

昨日のコードだと、ラベルボタンを押されたときの処理をベタにswitch文で切り分けてた。

  public func labelButtonIsSelected(button: LabelButtonNode) {
    switch button.text {
    case "Easy":
      if self.computerLevel != "Easy" {
        self.computerLevel = button.text
        let fadeOut = SKAction.fadeOutWithDuration(NSTimeInterval(0.2))
        self.computerLevelSelectToken.runAction(fadeOut) {
          self.computerLevelSelectToken.position.y = ConfigNode.selectTokenPositionY[0]
          let fadeIn = SKAction.fadeInWithDuration(NSTimeInterval(0.2))
          self.computerLevelSelectToken.runAction(fadeIn)
        }
      }
    case "Normal":
      if self.computerLevel != "Normal" {
        self.computerLevel = button.text
        let fadeOut = SKAction.fadeOutWithDuration(NSTimeInterval(0.2))
        self.computerLevelSelectToken.runAction(fadeOut) {
          self.computerLevelSelectToken.position.y = ConfigNode.selectTokenPositionY[1]
          let fadeIn = SKAction.fadeInWithDuration(NSTimeInterval(0.2))
          self.computerLevelSelectToken.runAction(fadeIn)
        }
      }
    // 以下、略
    }
  }

けど、ボタンを押されたときに設定をどうすればいいかや、トークンの位置をどうすればいいのかということは、ボタン自身が知っていればいいこと。
switch文で切り分けて各ボタンごとの処理を書いてやらなければならないというのは、かなりダサい。
ボタン自身が適切な処理をしてくれることが望ましい。

あるいは、ボタン自身がそのようなことを出来なかったとしても、ボタンから必要な情報を取り出せれば、その情報で処理を書くことが出来るようになるので、switch文を使う必要がなくなる。

ここでは、ボタンから必要な情報を取り出せるようにするために、以下のプロパティを追加してある。

  private var computerLevel: [LabelButtonNode: ConfigComputerLevel]
  private var firstMove: [LabelButtonNode: ConfigFirstMove]

ここで各ボタンに対応する設定内容を保持しておけば、switch文で分岐することなく、設定をセットすることが出来る。

さらに、各設定に対するトークンの位置も、

  private static let computerLevelSelectTokenPosition: [Config.ComputerLevel: CGPoint] = [
    .Easy: CGPoint(x: ConfigNode.selectTokenPositionX[0],
                   y: ConfigNode.selectTokenPositionY[0]),
    // 省略
  ]
  private static let firstMoveSelectTokenPosition: [Config.FirstMove: CGPoint] = [
    .Random: CGPoint(x: ConfigNode.selectTokenPositionX[1],
                     y: ConfigNode.selectTokenPositionY[0]),
    // 省略
  ]

と、設定内容から引き出せるようにしておけば、コードは次のように、おそろしくシンプルに。

  public func labelButtonIsSelected(button: LabelButtonNode) {
    if let computerLevel = self.computerLevel[button] {
      if self.config.computerLevel != computerLevel {
        self.config.computerLevel = computerLevel
        let fadeOut = SKAction.fadeOutWithDuration(ConfigNode.fadeDuration)
        self.computerLevelSelectToken.runAction(fadeOut) {
          self.computerLevelSelectToken.position = ConfigNode.computerLevelSelectTokenPosition[self.config.computerLevel]!
          let fadeIn = SKAction.fadeInWithDuration(ConfigNode.fadeDuration)
          self.computerLevelSelectToken.runAction(fadeIn)
        }
      }
    }
    
    if let firstMove = self.firstMove[button] {
      // ほぼ上と同じ
    }
  }

このような工夫は、オブジェクト指向でプログラミングしているときには、山のように出てくる。
例えば、ポリモーフィズムなんていうのもまさにこれで、処理の仕方はオブジェクト自身が知っているので、呼び出す側はswitch文で処理を切り分ける必要がなく、ただ指示を出す(=メソッドを呼び出す)だけでよくなる。

関連する話だと、SpriteKitでゲームを作るときに、タッチ処理の実装をSKSceneを継承したクラスだけでやっているケースをよく見かける。
SKSceneを継承したクラスで、タッチの位置からタッチされたノードを特定して、それに応じた処理をする、という感じ。

ただ、これまでの自分のコードを見てもらうと分かると思うのだけど、自分はそのような実装はしていない。
なぜって、タッチされたノードは自身がタッチされたことを知っているのだから、そこから処理を始めれば済む話だからだ。

オブジェクト指向な言語を使うなら、こういった考え方(オブジェクトをただのデータの入れ物と考えず、オブジェクトに責務を与えるという考え方)はマスターしておきたいところ。

動作確認

さて、これを動作させてみると、最初起動したときには初期値が使われていて、二度目以降は前回の設定が保存されていることが分かると思う。

今日はここまで!