Edited at

UIView などでインスタンス単位で一度だけ実行したい初期化コードの考察

More than 3 years have passed since last update.


問題点の整理

UIView のサブクラスを書いている時などで初期化の為に一回だけ処理したい場合があったとします。例えば、UIViewController を使うときは、 viewDidLoad() に色々な初期化の為のコードをかけば、view が生成されてから一度しか呼ばれません。よって、UIViewController を使っている場合には、ここで、Gesture Recognizer を生成登録したり、複雑なサブビューをコードで生成したりする場合は非常に便利です。

class MyViewController: UIViewController {

func viewDidLoad() {
super.viewDidLoad()
// さまざまな初期化のコード
}
}

ところが、さまざまな大人の事情により、これらと同等なコードを UIView のサブクラスに持ってくる場合には、どうすればよいでしょうか。


イニシャライザに初期化のコードを書く

真っ先に思い出すのが、init で初期化する方法です。ところが、init は複数のイニシャライザが存在する可能性が発生するので、その場合重複したコードを二度書くのは、なんかいただけないです。

class MyView: UIView {

override init(frame: CGRect) {
super.init(frame: frame)
// さまざまな初期化のコード
}

required init?(coder: NSCoder) {
super.init(coder: coder)
// さまざまな初期化のコード
}
}

では、setup() という関数でも用意して、それぞれのイニシャライザから呼び出せばいいと思うと以下のようなコードになります。

class MyView: UIView {

override init(frame: CGRect) {
super.init(frame: frame)
setup()
}

required init?(coder: NSCoder) {
super.init(coder: coder)
setup()
}

func setup() {
// さまざまな初期化のコード
}
}

これで妥協するのもありなのですが、MyView のサブクラスの存在を考えると、状況は変化します。

class MySubview: MyView {

init(frame: CGRect, state: Bool) {
super.init(frame: frame)
// setup()
}

required init?(coder: NSCoder) {
super.init(coder: coder)
// setup()
}

override func setup() {
super.setup()
// MySubview固有の初期化のコード
}

}

サブクラスも当然同様な初期化が必要と仮定すると、スーパークラス、サブクラスそしてクラス構造全体で初期化のルールが必要となります。この場合は、サブクラスの init では setup() を呼び出してはいけません。スーパークラスが呼び出すので、複数呼ばれてしまう事になります。


layoutSubviews() 内に初期化コードを書く

やはり、あるクラスの初期化は、そのスーパークラスやサブクラスの事を心配しないで、自分のクラスだけに注力してコードが書ける方が、良いような気がします。それに、init の時って 実際の window 下の view の階層の中にまだいなかったり、初期化の順番によってはまだ、必要なコンポーネントが準備できていなかったりする場合があり、late initializer の方が都合が良い場合もありますね。そこで、よく使われるのが、layoutSubviews() 内に初期化のコードを書く方法です。

class MyView: UIView {

override func layoutSubviews() {
super.layoutSubviews()
// さまざまな初期化のコード
}
}

layoutSubviews() 内に初期化のコードを記述すれば、イニシャライザがいくつあっても気にする必要がなく、表示前に必ず呼ばれるので重宝します。backgroundColor プロパティの設定など、何度呼ばれても気にならないコードはここにダラダラとかけば良いのですが、何度も呼ばれると不都合な場合には一工夫必要となります。dispatch_once を使う方法も考えられますが…

class MyView: UIView {

private var token: dispatch_once_t = 0

override func layoutSubviews() {
super.layoutSubviews()

dispatch_once(&token) {
// さまざまな初期化のコード
}
}
}

ドキュメントには以下のような記述があります。token は global または static のスコープにおきなさいとあるのでやはり不安です。


Declaration

void dispatch_once( dispatch_once_t *predicate, dispatch_block_t block);

(snip)

The predicate must point to a variable stored in global or static scope. The result of using a predicate with automatic or dynamic storage (including Objective-C instance variables) is undefined.


もちろん、ここまできたら、dispatch_once_tBool に置き換えて一回だけ初期化するコードを書いてもいいかもしれませんが、もう少し考えてみる事にします。


遅延評価を使って考えてみる

なんか lazy キーワードを使えば、うまくインスタンスに一回だけ初期化できるコードが書けそうな気がします。

class MyView: UIView {

override func layoutSubviews() {
super.layoutSubviews()
if setup {}
}

private lazy var setup: Bool = {
// さまざまな初期化のコード
return true
}()
}

ちょっと if のくだりが、カッコ悪いですが、もう一工夫すれば、カッコ良い方法があるかもしれません。ちなみに、BoolVoid にしてみると、if なんて表現書かなくて良くなるかなと思うと、呼び出す方法が見つかりません。そもそも、Void を遅延評価するというのは、想像しがたいものです。

    private lazy var setup: Void = {

// さまざまな初期化のコード
}()

override func layoutSubviews() {
super.layoutSubviews()
setup // error
}

さらに ()->() で試してみると、毎度呼ばれてしまいます。

    lazy var setup: ()->() = {

// さまざまな初期化のコード
}

override func layoutSubviews() {
super.layoutSubviews()
setup()
}


まとめ

惜しいですが、現時点では良しとしましょう。誰か改善案があれば、よろしくお願いいたします。という事で、Gesture Recognizer を初期化コードとして仕込んでみましょう。(EDIT: 追記があります)

class MyView: UIView {

override func layoutSubviews() {
super.layoutSubviews()
if setup {}
}

private lazy var setup: ()->() = {
let panGestureRecognizer = UIPanGestureRecognizer(target: self, action: "panGesture:")
self.addGestureRecognizer(panGestureRecognizer)
}

func panGesture(gesture: UIPanGestureRecognizer) {
// ジェスチャーの処理
}
}

ちなみに、このやり方で MyView のサブクラス化してみましょう。MyView のサブクラス MySubview を同様に宣言します。

class MyView: UIView {

override func layoutSubviews() {
print("MyView: \(__FUNCTION__)")
super.layoutSubviews()
if setup {}
}

private lazy var setup: Bool = {
print("MyView: \(__FUNCTION__)")
return true
}()
}

class MySubview: MyView {

override func layoutSubviews() {
print("MySubview: \(__FUNCTION__)")
super.layoutSubviews()
if setup {}
}

private lazy var setup: Bool = {
print("MySubview: \(__FUNCTION__)")
return true
}()
}

このコードで、MySubview を回転し続けますが、 setup はそれぞれ一度しか呼ばれませんでした。

MySubview: layoutSubviews()

MyView: layoutSubviews()
MyView: setup
MySubview: setup
MySubview: layoutSubviews()
MyView: layoutSubviews()
MySubview: layoutSubviews()
MyView: layoutSubviews()
MySubview: layoutSubviews()
MyView: layoutSubviews()


EDIT: 追記

rizumitaさん さんのコメントにより

インスタンス単位で一度だけ実行したい初期化コードの考察

class MyView: UIView {

lazy var setup: (()->())? = {
// さまざまな初期化のコード
self.setup = nil
}

override func layoutSubviews() {
super.layoutSubviews()
setup?()
}
}

ポイントは、setup の返す値が、(()->())? と closure の optional を返す点にあります。なるほど、これで、self.setup = nil が少々気になるにしても、 Boolを返して、意味不明の if 文を入れる必要は無くなりました。そっかぁ、optional かぁ。

って考えると、さらに想像力が湧いてきました。self.setup = nil のところを、return {}() でかけないかなぁと…

    private lazy var setup: (()->())? = {

// さまざまな初期化のコード
print("\(__FUNCTION__)")
return {}()
}

ダメです。結果は、毎回呼ばれてしまいます。そこで気がつきます。あれ? setup{} の後の () がないなと…そこで、setup{} の後に () をつけると、return{} の後の () が不要になります。これでは、体調が悪いと簡単に騙されそうですね。気を取り戻して、実行してみるとちゃんと一度しか呼ばれません。


EDIT: 追記その2

takasek さんより改善案をいただきました。A案的なやり方は研究したつもりでしたが、return {} ができるのは想定外でした。テストしてみるとちゃんと一度しか呼ばれません。素晴らしい。B案は takasek さん一押しだそうです。私はどちらもいいように思います。


現時点でのまとめ(追記2反映済)

(A案)

class MyView: UIView {

override func layoutSubviews() {
print("MyView: \(__FUNCTION__)")
super.layoutSubviews()
setup()
}

private lazy var setup: (()->()) = {
print("MyView: \(__FUNCTION__)")
// さまざまな初期化のコード
return {}
}()
}

(B案)

class MyView: UIView {

override func layoutSubviews() {
print("MyView: \(__FUNCTION__)")
super.layoutSubviews()
setup?()
}

private lazy var setup: (()->())? = {
print("MyView: \(__FUNCTION__)")
// さまざまな初期化のコード
return nil
}()
}

MyView: layoutSubviews()

MyView: setup
MyView: layoutSubviews()
MyView: layoutSubviews()
MyView: layoutSubviews()

rizumitaさんの closure を optional にするアイディアがなければ、ここにたどり着きませんでした。ありがとうございました。

takasekさんの 改善案も納得で採用させていただきます。ありがとうございました。