Screen Transition Traversal Pattern

とある開発環境で iPhone/Android アプリを書いてます。開発言語は js ですが Titanium ではありません。

今回の開発で実現したい事の1つに「可能なら UI を自動でテストしたい」というのがあります。
ぼーっとしていたら、以下のようなロジック(パターン)を ピコーン しました (恐らくボクが知らないだけで車輪の再発明なんだろうけどさ!)

  • 画面をディレクトリと見立てる。ディレクトリ=画面。ファイルに該当するものは無し
    • / はルートディレクトリ(ルート画面)。常に存在する
    • /A はルート直下のAディレクトリ(画面A)。親画面の上に表示される子画面達は /A/B/C のように表現する
  • 画面の状態をディレクトリパスにパラメタとして与えるだけで、ネストした画面を再現できる
    • /A(key1=value2;key2=value)/B/C といったパスは、画面Aを構築する際に key1=value2;key2=value といったパラメタが与えられる
    • テスト環境から cd /A(key1=value2;key2=value)/B/C と入力すると、画面Cが一番上に表示された状態が再現できる

一言でいうと、画面構造をREST風味にステートレス化するということです。途中の画面の状態を再現するパラメタをパスに埋め込む事ができ、再現する仕組みです。

このような構造を取ることにより、

  • 画面A が 画面B を知らず、画面B が 画面A を知らなくても成立する(かもしれない)
    • 開発終盤の仕様変更と再テストに強くなる。途中にどのような画面が新たに挟まっても手直しが少ない(かもしれない)
  • 画面A から 画面B に遷移する UI (ボタン等)には、最終的に /A/B といったパスが設定されるが、このパスは画面A が固定で持たず、UI 全体を統括するマネージャ的存在が 画面A に知らせる。

といった良いことがありそうです。

こんな感じ?

たたき台を考えてみました。mofmof.js ベースです。

閉じる/開く画面を決定するロジックのキモは、 mm.Class.Screen#resolve に実装する形になります(まだ空っぽです)

mm.Class.singleton("Screen", { // シングルトンクラスの定義。 mm("Screen").pwd() のように使用する
  _current: "/",               // カレントパス
  pwd: function() { // @return String: カレントパスを返す。pwd で通じるよね?
      return this._current;
  },
  cd: function(path) { // @param String: 移動先のパスを指定する
      var r = this.resolve(this._current, path); // 閉じる/開く画面の解決
      
      if (r.close.length) {
          // r.close の順に画面を破棄する
      }
      if (r.open.length) {
          // r.open の順に画面を破棄する
      }
      this._current = path; // カレントパスを更新
  },
  resolve: function(before,  // @param String:
                    after) { // @param String:
                             // @return Hash: { close, open, base }
                             //   close - StringArray:
                             //   open - StringArray:
                             //   base - String: base dir to open
    if (before === after) {
        return { close: [], open: [], base: "" };
    }

    var rv = { close: [], open: [], before: base: "" }, ...

    // closeする画面と、openする画面の配列 をここで作成する

    return rv;    
  }
});

予想では、resolve メソッドの結果は以下のようになるはずです。

mm("Screen").resolve("/A/B/C", "/A/B/C") -> { close: [], open: [], before: "/A/B/C", after: "/A/B/C" }
mm("Screen").resolve("/A/B/C", "/A/B") -> { close: ["C"], open: [], before: "/A/B/C", after: "/A/B" }
mm("Screen").resolve("/A/B/C", "/A/D") -> { close: ["C", "B"], open: ["D"], before: "/A/B/C", after: "/A/D" }
mm("Screen").resolve("/A/B/C", "/") -> { close: ["C", "B", "A"], open: [], before: "/A/B/C", after: "/" }
mm("Screen").resolve("/A/B/C", "/E/F") -> { close: ["C", "B", "A"], open: ["E", "F"], before: "/A/B/C", after: "/E/F" }

現在実装中なのですが、resolve が中々に手強いです。

実装できたらこのエントリを更新します。

追記

散々悩んで書いた汚いロジックがこちら。
「画面遷移どうすべ〜」→ ピコーン待ち → ピコーンきたー → ブログカキカキ → コードカキカキ 含めて8時間ぐらい。

    resolve: function(before,  // @param String:
                      after) { // @param String:
                               // @return Hash: { close, open, base }
                               //   close - StringArray:
                               //   open - StringArray:
                               //   base - String:
        if (before === after) { // match all -> nop
            return { close: [], open: [], base: "" };
        }

        var close = [], open = [], base = "/", bary, aary, last, i, iz;

        bary = before.split("/"); // "/A/B/C" -> ["", A, B, C]
        aary = after.split("/");  // "/A/B"   -> ["", A, B]

        // close
        while ((last = bary.pop())) {
            close.push(last);
            aary.length = bary.length;
            if (bary.join("/") === aary.join("/")) { // match
                break;
            }
        }
        bary = before.split("/"); // "/A/B/C" -> ["", A, B, C]
        aary = after.split("/");  // "/A/B"   -> ["", A, B]

        if (after === "/") { // case: resolve("/A/B/C", "/")
            ;
        } else if (bary[1] !== aary[1]) { // case: resolve("/A/B/C", "/E/F")
            // ルート直下から違う場合は全てopen対象となる
            base = "/";
            open = aary;
            open.shift();
        } else {
            // open ,, close とは逆方向に走査を行い不一致要素を open に追加する
            //      ,, 画面を開く際の起点となる要素をopenParentに設定する
            for (i = 1, iz = Math.max(bary.length, aary.length); i < iz; ++i) {
                if (aary[i] === void 0) { // outof index
                    break;
                }
                if (base) {
                    // 親画面が異なるため、親画面以下の全ての画面を追加する
                    if (bary[i] !== aary[i]) {
                        for (iz = aary.length; i < iz; ++i) {
                            open.push(aary[i]);
                            console.log("add @@".f(aary[i]));
                        }
                        break;
                    }
                } else {
                    base = bary[i];
                }
            }
        }
        return { close: close, open: open, base };
    }

(↑)のロジックは見て分かるように、最悪のコードです。日本語コメントが必要なくらいに最悪です。自分で書いてて「これはメンテできないわー」ってなりました。さらに幾つかのテストケースをパスできず「あかん、これはあかん…」とクソ悩んでました。
困ってしまって、2つ隣に座ってる同僚(セト神様)をティディベアに見立て、一人ティディベアデバッグ(夜中の4:00に一方的にブツブツ話かけたった)をした所、再度ピコーンが!!

セト神の啓示をうけ、5分でリライトしたロジックがこちら

    resolve: function(before,  // @param String:
                      after) { // @param String:
                               // @return Hash: { close, open, base }
                               //   close - StringArray:
                               //   open - StringArray:
                               //   base - String: base dir to open
        var close = [], open = [], ary1, ary2, base = "", i = 0, j = 0;

        if (before !== after) {
            ary1 = before.split("/"); // "/A/B/C" -> ["", A, B, C]
            ary2 = after.split("/");  // "/A/B"   -> ["", A, B]

            while (ary1[i] === ary2[i]) {
                base = ary1[i++] || "/";
            }
            j = i;
            while (ary1[i]) {
                close.push(ary1[i++]);
            }
            while (ary2[j]) {
                open.push(ary2[j++]);
            }
        }
        return { close: close.reverse(), open: open, base: base };
    }

テストコード

(function() {
'mm("Screen").resolve("/", "/")'.test('{ close: [], open: [], base: "" }');
'mm("Screen").resolve("/A", "/Z")'.test('{ close: ["A"], open: ["Z"], base: "/" }');
'mm("Screen").resolve("/", "/A/B/C")'.test('{ close: [], open: ["A","B","C"], base: "/" }');
'mm("Screen").resolve("/A", "/A/B/C")'.test('{ close: [], open: ["B","C"], base: "A" }');
'mm("Screen").resolve("/A/B/C", "/A/B/C")'.test('{ close: [], open: [], base: "" }');
'mm("Screen").resolve("/A/B/C", "/A/B")'.test(  '{ close: ["C"], open: [], base: "B" }');
'mm("Screen").resolve("/A/B/C", "/A/D")'.test(  '{ close: ["C", "B"], open: ["D"], base: "A" }');
'mm("Screen").resolve("/A/B/C", "/")'.test(     '{ close: ["C", "B", "A"], open: [], base: "/" }');
'mm("Screen").resolve("/A/B/C", "/E/F")'.test(  '{ close: ["C", "B", "A"], open: ["E", "F"], base: "/" }');
'mm("Screen").resolve("/A/B/C/D/E", "/A/Z/C/D/E")'.test('{ close: ["E", "D", "C", "B"], open: ["Z", "C", "D", "E"], base: "A" }');

"run".test("core.js - mm.Class.Screen");
})();

何が言いたいかというと、ティディベアデバッグ☆オススメ☆デス!!!!