Android 上のユーザ・インタフェースの実現

2012.2.28 (付録追加: 2012.3.28) (鈴)

1. はじめに

前回 Semi-Arc を Android に移植したが,そのユーザ・インタフェースは最低限のものだった。 実用性を目指し,二つのアクティビティとして,ロードすべきファイルを SD カードから見つけるファインダと,それを編集するエディタを追加した。 ここでは,この新しい操作の概要と,Lisp 式の入力に役立つ括弧の自動的な対応表示など,主だった機能の実装について記述する。

Android エミュレータでの SD カードの使い方については 前回の付録「Android 開発手順」の「SD カード・イメージの利用」の節を参照してください。

2. 操作概要

主画面で MENU ボタンを押すと Files, Edit, Clear Screen のメニューが現れる。 Arc インタープリタの arc> プロンプトを待たずに,アプリケーションの起動後すぐに MENU ボタンを押してもよい。

この時点ではまだ「現在のファイル」はないから,Edit の項目は無効化されている。 Clear Screen は主画面の文字を消す (Arc インタープリタの動作には影響しない)。

Files を選択するとファインダが起動し,SD カード直下のファイルとディレクトリが列挙される。 ディレクトリは末尾の "/" によってファイルと区別される。

ファインダを終了するには「戻る」ボタンを押せばよい。

ファインダ上でディレクトリを選択すると,(Android OS で禁止されていない限り) そのディレクトリに移動する。 ファイルを選択すると Edit, Load, Remove のメニューが現れる。

Edit, Load, Remove のメニューで Edit を選択すると エディタが起動する。 ただし,ファイル内容が UTF-8 テキストとして解釈できないとき,エディタは起動しない。

エディタを終了するには「戻る」ボタンを押せばよい。

エディタで表示している内容を書き換えると,タイトルに書かれている現在のファイルのパス名 (この例では /mnt/sdcard/tak.ar) の先頭に星印 * が書かれる。 ファイル内容を保存するまでこの星印は消えない。 注意: この状態で「戻る」ボタンを押すと,どこにも編集内容が保存されないままエディタが終了する。

MENU ボタンを押すと Save, Save as, Exit & Load のメニューが現れる。 Save は現在のファイルに編集内容を保存する。 Save as では保存先を新しく指定することができる。

Exit & Load を選択すると,エディタを終了して主画面に戻り,その入力行に (load "ファイルのパス名") が書き込まれる。

編集内容を書き換えるまでは Save は無効化されている。内容を書き換えてから保存するまでは Exit & Load が無効化される (右図はこの状態を示している)。

なお,ファインダの画面で,ディレクトリでなくファイルを選択した時に現れる Edit, Load, Remove のメニューで Load を選んだ場合も同様に主画面に戻り,その入力行に (load "ファイルのパス名") が書き込まれる。

その状態でそのまま Enter キーを押せば,Arc の load 関数によって引数のファイルの内容が評価される。

この例では,まず tak 関数が def マクロで定義される。 ついで (tak 4 2 0) の値が計算されて結果の 1prn 関数で表示される。 それから (tak 18 12 6) の値がやや時間をかけて計算されて結果の 7prn 関数で表示される。

最後に load 関数自身の戻り値 nil が表示されて arc> プロンプトが現れる。

load 関数の実行のために最後に1回 Enter キーを押す必要がある点については操作性に議論の余地があります。 後述の onActivityResult メソッドで input.notifyOf(line) をするように直せば,ただちに Semi-Arc インタープリタに入力を伝えることができます。

3. アクティビティ間の遷移

主画面からファインダへ,ファインダからエディタへと遷移するとき,内部では SemiArcActivity から FinderActivity へ, FinderActivity から EditorActivity へと遷移している。 アクティビティ間の遷移を左図にまとめる。

ここで EditorActivity からの「戻る」の遷移先が二つあるが,もともと FinderActivity から遷移してきたならば FinderActivity に戻り, SemiArcActivity から遷移してきたならば SemiArcActivity に戻る。 アプリケーションを操作して確認されたい。

遷移方法は次に説明するとおり,Android アプリケーションで一般的な方法である。

現在のアクティビティが新しいアクティビティに遷移するために Activity#startActivityForResult(Intent intent, int requestCode) メソッドを呼び出す。 このとき intent 引数で,遷移先および付随的な情報 (開くべきファイルのパス名など) を指定する。 呼び出しを識別するための requestCode 引数には 0 以上の任意の整数を与える。

新しく起動したアクティビティは,渡された intentActivity#getIntent() メソッドで取得できる。

処理を終えたアクティビティが結果の情報 (選択したファイルのパス名など) を返すには,その情報を載せた intent を作成し, Activity#setResult(int resultCode, Intent intent)Activity#finish() をこの順で呼び出す。 resultCode 引数は処理の成否を表す。 処理の成否以外に返すべき情報がないときは,intent 引数をとらない Activity#setResult(int resultCode) を使う。

遷移先から結果が返されたとき,Android システムによって遷移元の Activity#onActivityResult(int requestCode, int resultCode, Intent intent) メソッドが呼び出される。このメソッドをオーバーライドして結果を受け取る。 このメソッドに渡されてくる requestCode はもともと startActivityForResult(Intent, int requestCode) に渡しておいた第2引数そのものである。 resultCodeintent は,結果を返すために遷移先が setResult(resultCode, intent) で与えた値と同じである。

また,アプリケーション・コードによらず「戻る」ボタンが押されると遷移元に強制的に戻される。

一つのアクティビティ・クラスの中で複数の箇所,複数の時点で startActivityForResult メソッドを呼び出せますが,結果を受け取るのはたった一箇所 onActivityResult メソッドだけです。 同じアクティビティ内のどのリクエストの結果か識別するために requestCode を使います。 requestCode は遷移先では使われず,あくまで呼び出し元のクラス (とその派生クラス) で任意に値を決めるコードです。
これに対して 0, 1, 2 のような平凡な値をわざと避けるように値を決めておくと, 引数の取り違えなどのミスをしたときログ出力などによってその出どころを追跡しやすくなります。 数値そのものに意味がなく,そのユニークさに意味があることを含めて Pascal のラベルや Fortran の文番号と似た性格の定数になります。 あるいは定数を与えるのではなくカウンタを使って呼び出しごとに値を増やして行くと,毎回のリクエストを個別に識別できます。

SemiArcActivity から FinderActivity または EditorActivity に遷移するコード片を下記に示す。 Intent のコンストラクタ引数として this と遷移先のクラスを与えていること,さらに EditorActivity に遷移する intent に対してエクストラな情報 (ここでは開くべきファイルのパス名の文字列) を載せていることに注意されたい。 エクストラな情報は,任意に指定したキーと値のペアとして保持される。 ここでは Intent#putExtra メソッドにキーとして Common.FILE_PATH (= "file path") を,値として currentPath を渡すことによって情報を載せている。 また,後で結果を照合するためにユニークな値 (ここでは 444) をリクエスト・コードとして渡している。

    @Override public boolean onOptionsItemSelected(MenuItem item) {
        if (item == filesItem) {
            Intent intent = new Intent (this, FinderActivity.class);
            startActivityForResult(intent, 444);
            return true;
        } else if (item == editItem) {
            Intent intent = new Intent (this, EditorActivity.class);
            intent.putExtra(Common.FILE_PATH, currentPath);
            startActivityForResult(intent, 444);
            return true;
        } else if (item == clsItem) {
            ……
        }
        return false;
    }

EditorActivity の処理開始時のコード片を下記に示す。 intent に載せられているエクストラな情報 (ここでは開くべきファイルのパス名) を得るために,渡された intentgetIntent で取得し,そこから getStringExtra(Common.FILE_PATH) を使ってキーに対応する文字列値を得ている。 ファイルの読み取り時に UTF-8 テキストでない等の理由で例外が発生したときは (6 節参照),例外メッセージ ex.getMessage()Toast を使って画面に表示した後,キャンセルを伝える Activity#RESULT_CANCELEDsetResult に渡して finish する。

    @Override protected void onCreate(Bundle savedInstanceState)
    {
        super.onCreate(savedInstanceState);
        ……
        // Intent で指定されたファイルを editText に読み込む
        path = getIntent().getStringExtra(Common.FILE_PATH);
        setTitle(path);
        int n;
        try {
            n = Common.loadEditText(editText, path);
        } catch (IOException ex) {
            Toast.makeText(this, ex.getMessage(), Toast.LENGTH_LONG).show();
            setResult(RESULT_CANCELED);
            finish();
            return;
        }
        ……
    }

EditorActivity でメニューの Exit & Load 項目が選択されたときは,エクストラな情報としてファイルのパス名を載せた intent を作成し,処理の成功を伝える Activity#RESULT_OK とともに setResult に渡して finish する。

    @Override public boolean onOptionsItemSelected(MenuItem item) {
        File file = new File (path);
        if (item == saveItem) {
            ……
        } else if (item == saveAsItem) {
            ……
        } else if (item == exitItem) {
            Intent intent = new Intent().putExtra(Common.FILE_PATH, path);
            setResult(RESULT_OK, intent);
            finish();
            return true;
        }
        return false;
    }

画面の切り替えは非同期的に行われる。 startActivityForResult や finish を呼び出した時,ただちに切り替わるわけではないことに注意されたい。 startActivityForResult や finish の呼び出し自体はすぐに終わる。 このような非同期呼び出しのもとで処理の結果を受け取るために,下記のように onActivityResult メソッドをオーバーライドする。

    @Override protected void onActivityResult(int request, int result,
                                              Intent intent) {
        if (request == 444 && result == RESULT_OK) {
            if (currentPath == null && editItem != null)
                editItem.setEnabled(true);
            currentPath = intent.getStringExtra(Common.FILE_PATH);
            String line = "(load \"" + currentPath + "\")";
            editText.setText(line);
            editText.setSelection(line.length()); // カーソルを末尾に進める
        }
    }

ここでは,確かにはじめに説明したメソッドでリクエストした遷移の結果であることを request コードが同じ 444 であることで識別している。 また,そのリクエストが成功裏に終わっていることを result コードが RESULT_OK であることで確認している。 この二つの条件を確かめたあと,intent にエクストラな情報として返されているはずのファイルのパス名を getStgringExtra で取り出し,右図のように主画面の入力行に展開する。

request コードにカウンタではなくユニークな定数を使う場合,とりわけその定数が 0, 1 などの平凡な値であるか,またはリクエスト箇所がアクティビティ内に複数ある場合 (あるいは将来そうなりそうな場合) は整数リテラルではなく (非 public な) static final int フィールドによる記号名を使うのが妥当です。

4. テキスト改変の監視による括弧の対応表示

主画面の入力行とエディタの編集画面を android.widget.EditText クラスで実現しているが,そのテキストへの改変操作は android.text.TextWatcher インタフェースを使って監視できる。 これを使って,閉じ括弧が入力されたとき,対応する開き括弧を自動的に示す処理を実現できる。 この処理は Lisp 式,および一般に複雑な式の入力の便宜として有用である。

class EditorWatcher implements TextWatcher
{
    private final EditText editText;
    private final int maxDistance;

    /**
     * @param editText  監視対象
     * @param maxDistance  閉じ括弧に対応する開き括弧を探す最大文字数
     */
    EditorWatcher (EditText editText, int maxDistance) {
        this.editText = editText;
        this.maxDistance = maxDistance;
    }

TextWatcher インタフェースは三つのメソッドを宣言しているが,二つのメソッドはさしあたり使わない。

    /** ここでは何もしない。*/
    @Override public void afterTextChanged(Editable s) {}

    /** ここでは何もしない。*/
    @Override public void beforeTextChanged
        (CharSequence s, int start, int count, int after) {}

使うのは onTextChanged メソッドである。 これは監視対象のテキストが改変された時,改変後のテキスト全体である s,改変箇所の開始位置 start, 改変箇所の改変前文字数 before, 改変箇所の改変後文字数 count を引数として呼び出される。 下記のメソッド実装では,もしも改変箇所の先頭文字が閉じ括弧 ')' ならば,括弧の入れ子の数 (nest) を数えながら,テキストをさかのぼって対応する開き括弧 '(' を探している。

    /** 閉じ括弧が書かれたとき,対応する開き括弧に 400 ミリ秒だけ
        カーソルを移動させる。ただし " で挟まれた中は除く。
        対応する開き括弧が maxDistance だけ離れた場所にも見つからない
        ときは何もしない。
    */
    @Override public void onTextChanged
        (CharSequence s, int start, int before, int count) {
        if (start < s.length() &&
            s.charAt(start) == ')') {
            boolean withinDoubleQuotes = false;
            int nest = 1;
            int distance = 0;
            for (int i = start - 1; i >= 0; i--) {
                if (++distance > maxDistance)
                    break;
                char ch = s.charAt(i);
                if (ch == '"') {
                    withinDoubleQuotes = ! withinDoubleQuotes;
                } else if (! withinDoubleQuotes) {
                    if (ch == ')') {
                        nest++;
                    } else if (ch == '(') {
                        nest--;
                        if (nest == 0) {
                            roundTrip(i, start + count);
                            break;
                        }
                    }
                }
            }
        }
    }
上記の実装は文字入力のための当座の便宜には間に合いますが,正確な解析ではありません。 二重引用符 '"' で囲まれた部分を読み飛ばすようにしているものの,括弧や二重引用符自身がエスケープされた場合の対処をしていない点に甘さがあります。

対応する開き括弧が見つかったとき,開き括弧の位置 (kakko) と閉じ括弧の位置 (kokka) を引数として roundTrip メソッドを呼び出す。 このメソッドは kakko にいったんカーソルを移動させた後,400 ミリ秒後に再び kokka にカーソルが戻るように仕掛ける。

    private void roundTrip(int kakko, final int kokka) {
        editText.setSelection(kakko);
        editText.post(new Runnable () {
                @Override public void run() {
                    try {
                        Thread.sleep(400);
                    } catch (InterruptedException ex) {
                        return;
                    }
                    editText.setSelection(kokka);
                }
            });
    }
}
Android エミュレータで確認した限りでは,主画面では特に問題ありませんが,エディタ画面でソフトウェアキーボードを使ったときは,この 0.4 秒間だけのカーソル移動が視覚的な効果を持たないようです。別の視覚的な効果を工夫する必要がありそうです。

主画面 SemiArcActivity ではこの監視クラスを次のように組み込んでいる。

    @Override protected void onCreate(Bundle savedInstanceState)
    {
        ……
        editText = (EditText) findViewById(R.id.et1);
        ……
        // 閉じ括弧入力時,100 文字以内での括弧の対応を表示
        editText.addTextChangedListener(new EditorWatcher (editText, 100));
        ……
    }

エディタ EditorActivity では次のように組み込んでいる。 ここではさらにテキスト改変後に呼び出される TextWatcher#afterTextChanged メソッドをオーバーライドし,テキストが最初に改変されたタイミングでタイトルに '*' を付け,メニュー項目の有効/無効を切り替えている。

    @Override protected void onCreate(Bundle savedInstanceState)
    {
        ……
        editText = (EditText) findViewById(R.id.et2);
        ……
        // 閉じ括弧入力時,1000 文字以内での括弧の対応を表示
        TextWatcher watcher = new EditorWatcher (editText, 1000) {
                // テキストが改変されたときはタイトルに * を表示
                @Override public void afterTextChanged(Editable s) {
                    if (! modified) {
                        modified = true;
                        setTitle("* " + path);
                        if (saveItem != null)
                            saveItem.setEnabled(true);
                        if (exitItem != null)
                            exitItem.setEnabled(false);
                    }
                }
            };
        editText.addTextChangedListener(watcher); // テキスト改変の監視を開始
        ……
    }

5. デバイスの回転による再起動の防止

デバイスを回転させて縦横をかえると (エミュレータでは Control + F11 キーを押す) と,既定ではアクティビティがその都度再起動される。 これを防ぐには AndroidManifest.xml に下記のように android:configChanges の記述を追加する。

    <application android:label="@string/app_name" >
        <activity android:name="SemiArcActivity"
                  android:label="@string/app_name"
                  android:configChanges="orientation|keyboardHidden"
                  >

ここではデバイスに対する orientationkeyboardHidden の変化に対してシステムが Activity クラスのメソッドに処理を任せるようにしている。 具体的には,このとき Activity#onConfigurationChanged(Configuration) メソッドが呼び出される。

ただし,本アプリケーションではさしあたり,このような変化に対して特に何も行うことはないから,メソッドのオーバーライドはしていない。 AndroidManifest.xml の設定だけで再起動を防止している。

6. 厳密なテキスト読み込み

本節の内容は Android に限らず,一般の Java プログラミングに応用できます。

操作概要

ただし,ファイル内容が UTF-8 テキストとして解釈できないとき,エディタは起動しない。

と書いた。 これは下記の Common#loadEditText(EditText editText, String path) メソッドが非 UTF-8 テキストの読み込みに対して IOException を送出することによって実現している。

    /** ファイル path の UTF-8 テキストを editText に読み込む。
     * テキストの文字数を返す。
     */
    static int loadEditText(EditText editText, String path)
        throws IOException
    {
        // UTF-8 として解釈できない場合は例外を発生させる
        CharsetDecoder decoder = Charset.forName("UTF-8").newDecoder();
        decoder.onUnmappableCharacter(CodingErrorAction.REPORT);

        int chars = 0;
        Reader reader =
            new InputStreamReader (new FileInputStream (path), decoder);
        try {
            char[] buf = new char[1000];
            for (;;) {
                int n = reader.read(buf);
                if (n < 0)
                    break;
                chars += n;
                String s = new String (buf, 0, n);
                editText.append(s);
            }
        } catch (MalformedInputException ex) {
            throw new IOException ("Not in UTF-8: " + path);
        } finally {
            reader.close();
        }
        return chars;
    }

要点は次の三つである。

  1. Charset.forName("UTF-8").newDecoder() として陽に新しいデコーダを作る。
  2. decoder.onUnmappableCharacter(CodingErrorAction.REPORT) としてデコーダにその失敗時には (置換処理をさせずに) 報告させる。
  3. new InputStreamReader (new FileInputStream (path), decoder) のようにデコーダを使ってリーダを構築する。

こうしたとき,UTF-8 と解釈できないバイト列に遭遇してデコードに失敗した時点でリーダの read メソッドが MalformedInputException 例外を送出する。 言い換えれば,もしも最後まで例外が送出されなかったならば,そのときオリジナルのテキストの内容がすべて UTF-8 として解釈できたことが保証される。UTF-8 は最上位ビットが立ったバイト・データの並びに対して強い制約を課しているから, 非 ASCII 文字を含むテキストについては実際に UTF-8 テキストである可能性が高い。

IOException ex が発生したとき,このメソッドの呼び出し元では,ex.getMessage() で得られるメッセージをそのまま利用者に Toast として見せています (3 節参照)。 java.nio.charset.MalformedInputExceptionIOException の派生クラスですが, 残念ながら,そのメッセージは利用者に分かりやすいものではないため,直接送出せずにいったん catch して,"Not in UTF-8: パス名" のように分かりやすいメッセージの例外に仕立てています。

7. おわりに

今回の追加実装によって,さしあたり Lisp (Arc) プログラミングが Android 上で完結して行える環境が実現した。 本稿が読者の Android プログラミングのためのなにがしかの参考になれば幸いである。

例えば,手ごろな練習問題として,ファインダとエディタだけからなるテキスト閲覧アプリケーションに組み直してみてください。 これは Android システムをあれこれ調べる目的にも (あくまでパーミッションの範囲で,ですが) 便利に使えるものになるはずです。

もちろん,今のままでも主画面を無視すればそういった目的に使えます。 右図はファインダで / ディレクトリにさかのぼった後,/proc に下りて /proc/cpuinfo をエディタで開いた様子です。


総目次へ


Copyright (c) 2012 OKI Software Co., Ltd.