肥大化したUITextViewをテスタブルで再利用可能な構造にリファクタする

複雑なテキスト処理ロジックを持つテキストエディタを実装しようとすると、UITextViewのカスタムクラスが肥大化することはありませんか?


例えば、ユーザがテキストを入力すると、その内容を検証したり、ハイライトしたり、その他様々な処理を行うという要件があるとします。
これを素直に実装するとこんな感じになると思います。

class CustomTextView: UITextView {
    override init(frame: CGRect, textContainer: NSTextContainer?) {
        super.init(frame: frame, textContainer: textContainer)
        commonInit()
    }

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

    private func commonInit() {
        delegate = self
    }

    private func validate() {
        // テキストの検証を行う複雑なロジック
    }

    private func decorate() {
        // テキストの装飾を行う複雑なロジック
    }

    // その他多数のテキスト処理ロジックが続く...
    private func ...
}

extension CustomTextView: UITextViewDelegate {
    func textViewDidChange(_ textView: UITextView) {
        validate()
        decorate()
        ...  // その他多数のテキスト処理ロジックが続く...
    }
}


上記実装の問題点は3つあると考えます。

  1. ロジックが増えるたびにクラスが肥大化していき、可読性が悪くなる
  2. ロジックの再利用ができない
  3. ロジックのテストができない

要件というのはどんどん増えていくものです。そのたびにこのクラスにロジックが積まれてコード行数が増えていき、可読性が悪くメンテナンスしづらいクラスになっていきます。

構造的にロジックの再利用ができないので、他の画面で似たようなテキストエディタを実装する必要が出てきたときに、同じようなロジックをそちらのクラスにも実装することになります。

さらに、このクラスはユニットテストをすることが非常に困難です。将来ロジックの一部が変更されたり追加されたりするたびに、デグレのリスクに晒されることになります。


では、どうすればこのクラスをテスタブルで再利用可能な構造にすることができるでしょうか?


UITextViewを受け取って設定変更するロジッククラスを作る

割とシンプルな話ですが、テキスト処理ロジックを行う専用のクラスを作り、UITextViewは処理をそのクラスに委譲してしまえば良いのです。

class CustomTextView: UITextView {
    private let validator = TextValidator()  // テキストの検証を行うクラス
    private let decorator = TextDecorator()  // テキストの装飾を行うクラス

    override init(frame: CGRect, textContainer: NSTextContainer?) {
        super.init(frame: frame, textContainer: textContainer)
        commonInit()
    }

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

    private func commonInit() {
        delegate = self
    }
}

extension CustomTextView: UITextViewDelegate {
    func textViewDidChange(_ textView: UITextView) {
        validator.validate(textView: textView)  // テキストの検証処理はTextValidatorに委譲
        decorator.decorate(textView: textView)  // テキストの装飾処理はTextDecoratorに委譲
    }
}

class TextValidator {
    func validate(textView: UITextView) {
        ...
    }
}

class TextDecorator {
    func decorate(textView: UITextView) {
        ...
    }
}


この構造により、上記3つの問題点が解決できます。

  1. ロジックが増えるたびにクラスが肥大化していき、可読性が悪くなる
    →UITextViewのカスタムクラスからロジックの実装が無くなりシンプルになる
  2. ロジックの再利用ができない
    →TextValidatorやTextDecoratorは特定のクラスに依存しないので、他のUITextViewのカスタムクラスで再利用できる
  3. ロジックのテストができない
    →ユニットテストが書けるようになる。具体例は後述します


余談ですが、自分自身(UITextView)を丸ごと渡して別のクラスに自身のプロパティを変更させるというアーキテクチャが個人的には発見でした。

class TextDecorator {
    func decorate(textView: UITextView) {
        // UITextViewを受け取り、textやattributedTextの値を変更する
    }
}


下記のように色々なプロパティを渡して戻り値を受け取るような構造を考えたりしてたのですが、これだとシグネチャが複雑で可読性が悪く、非常に使い勝手の悪いものになってしまいます(というかそもそも動くものが実装できなかった)。

class TextDecorator {
    /// カーソル位置の文字列を置換してハイライトする、などの処理
    func decorate(text: String, currentCursor: UITextRange, replacingText: String, ...) {
        ...
    }
}


構造を変えたことでユニットテストもしやすくなります。

例えばTextDecoratorのテストはこんな感じで書くことができます。

class TextDecorator {
    func decorate(textView: UITextView) {
        // めっちゃシンプルな例ですが...
        textView.text = "decorated \(textView.text!)"
    }
}

class TextDecoratorTests: XCTestCase {
    func testDecorator() throws {
        let decorator = TextDecorator()
        let textView = UITextView()
        textView.text = "text"
        decorator.decorate(textView: textView)
        XCTAssertEqual(textView.text, "decorated text")
    }
}



単純なテクニックですが、構造を変える前と変えた後では保守性が大きく異なります。

もし肥大化したUITextViewのカスタムクラスを見つけたら、上記の方法でリファクタしてみてください。