Screen Transition Traversal Pattern
とある開発環境で iPhone/Android アプリを書いてます。開発言語は js ですが Titanium ではありません。
今回の開発で実現したい事の1つに「可能なら UI を自動でテストしたい」というのがあります。
ぼーっとしていたら、以下のようなロジック(パターン)を ピコーン しました (恐らくボクが知らないだけで車輪の再発明なんだろうけどさ!)
一言でいうと、画面構造を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"); })();
何が言いたいかというと、ティディベアデバッグ☆オススメ☆デス!!!!