トップ地図アプリMap4 > フリーワード検索

フリーワード検索

はじめに

Windowsパソコン/タブレット版の地図アプリではフリーワード検索機能をサポートしており、 それなりに利用していた。Androidスマホ版では文字入力が面倒なため、 これまでフリーワード検索はサポートしてこなかった。

実機デバッグはAndroidタブレットが中心となった。自作地図アプリと同じ場所を 標準OSM地図や Google Map で表示することは簡単であるが、その逆はできない。 検索機能があれば、同じ場所を素早く見つけることができる。

タブレットの場合、スマホよりは文字入力が楽なため、 フリーワード検索機能をサポートしたい。

仕様はとりあえず、Windows版と同じとする。Windowsに比べて Android はアプリが使える メモリ容量が小さいため、OSMデータファイルはメモリに常駐させず、検索の都度、 ファイルから読み込むこととする。

または、検索開始でファイルを読み込み、検索終了でメモリを開放する。 ファイルサイズが 50MB程度であれば問題ない。

OSMDataファイル

OSMDataは下に示すようなタブ(ここでは|で表す)区切りデータとする。 カラムは順に、type, way_area, lon, lat, name である。

0|0|1397686834|356349354|台場
0|0|1397577436|356417244|芝浦ふ頭
0|0|1397815555|356335737|有明ジャンクション
...

カラムを増やす場合には、OSMバイナリレコード作成の全プロセスを見直した方がよい。徐々に機能拡張したため全体が分かりにくくなってきている。

レコード数は 192万、ファイルサイズは 83.4MB となった。現在、lineレコードは含んでいない。

フリーワード検索自体では type、wayarea を省いてもよい。更に、レコードをバイナリ化する。 OSMバイナリレコードには name:en も含むため、UTF-8コードを用いているが、nameタグは日本語が中心のため、 UTF-16の方がファイルサイズは小さくなり、また、全ての文字が2バイトで表されるため、検索が単純になるメリットもある。

この時のファイルサイズは 46.8MB となった。

最初のプログラム

読み込みテスト

とりあえず、ファイルのスキャンにどれくらいかかるか調べた。

レコード形式は レコードサイズ、lon、lat、name である。 読み込みを各項目を順に4回に分けて length=dis.readShort()、lon=dis.readInt()、lat=dis.readInt()、 dis.read(buf, 0, length-10) として読み込んだ場合は2秒以上かかる。

I/O処理の負荷は高いため、下のプログラムは、レコード当たり1回の読み込みで済ませている。 最初に、先頭レコードの長さだけを読み込み、ループ処理では lon、lat、name、次のレコードの長さ を1回の読み込みで実行している。

この結果、全レコードの読み込み時間は1秒未満となった。

    static void test0() {
        byte[] buf = new byte[1024];      // サイズはもっと小さくてもよい
        long start = System.currentTimeMillis();
        String path = Map.DIR + "Common/" + "osmdata_japan.dat";
        try (DataInputStream dis = new DataInputStream(
                new BufferedInputStream(new FileInputStream(path))) ) {
            int length = dis.readShort(); // 最初のレコードのバイト長
            while (true) {
                // 次のレコードの長さまで読込む. 文字列は 8 ~ length-8
                int len = dis.read(buf, 0, length);
                int lon = getInt(buf, 0);
                int lat = getInt(buf, 4);
                //String name = new String(buf, 8, length-10, StandardCharsets.UTF_16);
                //System.out.printf("%d,%d,%s\n", lonlat>>32, lonlat&0xffffffffL, name);
                if (len < length) break;
                length = getShort(buf, len - 2);    // 次のレコードの長さ
            }
        } catch (EOFException e) {  // ファイル入力完了
        } catch (Exception e) {
            e.printStackTrace();
        }
        System.out.println((System.currentTimeMillis() - start) + "mS");
    }                // 検索はバイト配列の比較による

    static int getShort(byte[] ba, int off) {
        return (ba[off]<<8) | (ba[off+1]&0xff);
    }

    static int getInt(byte[] ba, int off) {
        return (ba[off]<<24) | (ba[off+1]&0xff)<<16 | (ba[off+2]&0xff)<<8 | (ba[off+3]&0xff);
    }

さらに読み込み時間を短縮するには、例えば4KB毎に読み込み、byte配列からレコードを取り出してゆく。 レコード長は2の倍数となっている。最後のレコードは途中までとなっているかも知れない。 この場合には、残りのデータを前にシフトして、次の読み込みは 4KB よりその分少なくなる。

レコードを読み込んだ後の検索処理は byte配列の比較による。 String name = new String(buf, 8, length-10, StandardCharsets.UTF_16) によって、約200万回、 String型に変換すると、長大な時間がかかる。

検索テスト

いずれAND検索をサポートするが、まずは単語検索だけをテストした。検索語を「東京都」とした場合、 該当は 48レコードで検索時間は 0.87秒となった。殆どがファイル読み込み時間であり、 検索そのものにかかる時間は微々たるものであることが分かった。

String#getBytes(StandardCharsets.UTF_16)で返されるバイト配列には 先頭に -2、-1 という2バイトが付加されることが分かった。 OSMデータファイルにもこの2バイトが付加されている。

この2バイトは取り去ることも可能であるが、少なくとも、当面はこのままとする。 検索でもこの余分な2バイトを取り除く配慮が必要である。

    static void test() {
        Executors.newSingleThreadExecutor().execute(() -> {
            try {
                search("東京駅");
            } catch (Exception ex) {
                ex.printStackTrace();
            }
            // 別スレッド内での処理を管理し実行する
            HandlerCompat.createAsync(getMainLooper()).post(() -> {
                // Mainスレッドに渡す
                }
            );
        });
    }

    static void search(String strWord) throws Exception {
        byte[] buf = new byte[1024];
        byte[] word = strWord.getBytes(StandardCharsets.UTF_16);
        // 先頭に -2, -1 が付加されている
        System.out.println(Arrays.toString(word));
        System.out.println(Arrays.toString(strWord.toCharArray()));
        long start = System.currentTimeMillis();
        String path = Map.DIR + "Common/" + "osmdata_japan.dat";
        int cnt = 0;
        try (DataInputStream dis = new DataInputStream(
                new BufferedInputStream(new FileInputStream(path))) ) {
            int length = dis.readShort(); // 最初のレコードのバイト長
            while (true) {
                // 次のレコードの長さまで読込む. 文字列は 8 ~ length-8
                int len = dis.read(buf, 0, length);
                boolean found = false;
                for (int n = 10; n < length-8; n++) {
                    boolean match = true;
                    for (int k = 0; k < word.length-2; k++) {
                        if (buf[n+k] != word[k+2]) {
                            match = false;
                            break;
                        }
                    }
                    if (match) {
                        found = true;
                        break;
                    }
                }
                if (found) {
                    int lon = getInt(buf, 0); 
                    int lat = getInt(buf, 4); 
                    String name = new String(buf, 8, length-10, StandardCharsets.UTF_16);
                    System.out.printf("%d: %d,%d,%s\n", ++cnt, lon, lat, name);
                }
                if (len < length) break;
                length = getShort(buf, len - 2);    // 次のレコードの長さ
            }
        } catch (EOFException e) {  // ファイル入力完了
        } catch (Exception e) {
            e.printStackTrace();
        }
        System.out.println((System.currentTimeMillis() - start) + "mS");
    }

AND検索は2語目以降は検索結果に対してであるから、検索時間の増加は微々たるものである。

パフォーマンス上は全く問題がないことが判明したので、ユーザインタフェースに移る。 バス時刻表のように、画面の上部に PopupWindow をポップアップさせることにする。

検索実行直前の地図の中心位置は保存して置く。検索結果はこの中心位置から近い順に リスト表示する。 選択したレコードの位置にジャンプする。いずれ、元の位置に戻すこともできるようにしたい。

Android のキー入力は初めて、最初のプログラムはできるだけシンプルなものにしたい。

activity_main.xml に TextEdit を追加するのが一番簡単である。 結果は PopupWindow に横一列にボタンを並べるのが簡単である。

検索結果は1000レコード程度で打ち切り、地図の中心から近い順に並び替え、 ボタンに表示するのは数十レコード程度とする。

結果レコードが多すぎるときはAND検索で絞り込むこととする。

完成したプログラム

class OSMData {
    final static double F7 = 0.0000001;
    static double clon, clat;   // 地図の中心座標
    double lon, lat;            // この地物(レコード)の座標
    String name;                // この地物(レコード)のnameタグの値

    double distance() {
        return  Math.sqrt((lon - clon) * (lon - clon) + (lat - clat) * (lat - clat));
    }    // sortで使う

    public OSMData(int ilon, int ilat, String name) {
        this.lon = ilon * F7;
        this.lat = ilat * F7;
        this.name = name;
    }
}

public class Search extends PopupWindow {
    final static int MAX_RECORD = 1024;
    final int wContent = ViewGroup.LayoutParams.WRAP_CONTENT;
    Map map;
    Context context;
    Button[] btns;
    OSMData[] data;
    LinearLayout button_layout;

    public Search(Map map, int w, int h) {
        super(w, h);
        this.map = map;
        context = map.context;
        // 全体のレイアウト
        LinearLayout layout = new LinearLayout(context);
        layout.setGravity(Gravity.START);
        layout.setOrientation(LinearLayout.VERTICAL);
        ScrollView scrollView = new ScrollView(context);    // 垂直スクロール

        layout.setLayoutParams(new LinearLayout.LayoutParams(wContent, wContent));
        layout.addView(scrollView);
        setContentView(layout);

        // ボタン列のレイアウト
        button_layout = new LinearLayout(context);
        button_layout.setOrientation(LinearLayout.VERTICAL);
        button_layout.setLayoutParams(new LinearLayout.LayoutParams(wContent, wContent));
        scrollView.addView(button_layout);
    }

    public void dismiss() {
        super.dismiss();
        map.search = null;
        map.editText.setText("");
    }

    void select(int id) {
        OSMData data = this.data[id];
        map.jump(data.lon, data.lat);
    }

    void set(LinearLayout layout, OSMData[] data) {
        btns = new Button[Math.min(data.length,32) + 1];
        for (int n = 0; n < btns.length; n++) {
            btns[n] = new Button(context);
            btns[n].setGravity(Gravity.START);
            btns[n].setId(n);
            if (n < btns.length - 1) {
                btns[n].setText(data[n].name);
                btns[n].setOnClickListener(v -> select(((Button) v).getId()));
            } else {
                btns[n].setText("閉じる");
                btns[n].setOnClickListener(v -> dismiss());
            }
            layout.addView(btns[n]);
        }
    }

    static void searchThread(String word, Map map) {
        Executors.newSingleThreadExecutor().execute(() -> {
            // 別スレッドで実行する
            OSMData[] data = search(word, map);
            map.search = new Search(map, 200, 420);
            map.search.data = data;
            HandlerCompat.createAsync(getMainLooper()).post(() -> {
                // Mainスレッドに渡す
                map.search.set(map.search.button_layout, data);
                map.search.showAtLocation(map.textViewBottom, Gravity.TOP,-300, 143);    // 145
            });
        });
    }

    static OSMData[] search(String strWord, Map map) {
        long start = System.currentTimeMillis();
        OSMData.clon = map.lon();   // 地図の中心座標
        OSMData.clat = map.lat();   // をセットする
        List<OSMData> list = new ArrayList<>();
        byte[] buf = new byte[1024];
        strWord = strWord.replace(" ", " ");    // 全角スペースは半角に変える
        String[] strWords = strWord.split(" ");
        byte[][] words = new byte[strWords.length][];
        for (int i = 0; i < words.length; i++) {
            words[i] = strWords[i].getBytes(StandardCharsets.UTF_16);
            System.out.println(Arrays.toString(words[i]));
        }   // 先頭に -2, -1 が付加されている
        String path = Map.DIR + "Common/" + "osmdata_japan.dat";
        int cnt = 0;
        try (DataInputStream dis = new DataInputStream(
                new BufferedInputStream(new FileInputStream(path))) ) {
            int length = dis.readShort(); // 最初のレコードのバイト長
            while (true) {
                // 次のレコードの長さまで読込む. 文字列は 8 ~ length-2
                int len = dis.read(buf, 0, length);
                boolean foundAll = true;                   // 全単語について
                for (byte[] word : words) {
                    boolean found = false;
                    for (int n = 10; n < length - 2; n++) {
                        boolean match = true;
                        for (int k = 0; k < word.length-2; k++) {
                            if (buf[n+k] != word[k+2]) { match = false; break; }
                        }
                        if (match) { found = true; break; }
                    }
                    if (!found) { foundAll = false; break; }
                }
                if (foundAll) {
                    int lon = getInt(buf, 0);
                    int lat = getInt(buf, 4);
                    String name = new String(buf, 8, length-10, StandardCharsets.UTF_16);
                    list.add(new OSMData(lon, lat, name));
                    System.out.printf("%d: %d,%d,%s\n", ++cnt, lon, lat, name);
                    if (list.size() >= MAX_RECORD) {
                        break;
                    }
                }
                if (len < length) break;
                length = getShort(buf, len - 2);    // 次のレコードの長さ
            }
        } catch (EOFException e) {  // ファイル入力完了
        } catch (Exception e) {
            e.printStackTrace();
        }
        OSMData[] osmDatas = list.toArray(new OSMData[0]);
        Arrays.sort(osmDatas, 0, osmDatas.length, Comparator.comparing(OSMData::distance));
        System.out.println((System.currentTimeMillis() - start) + "mS");
        return osmDatas;
    }

    static int getShort(byte[] ba, int off) {
        return (ba[off] << 8) | (ba[off + 1] & 0xff);
    }

    static int getInt(byte[] ba, int off) {
        return (ba[off] << 24) | (ba[off + 1] & 0xff) << 16 | (ba[off + 2] & 0xff) << 8 | (ba[off + 3] & 0xff);
    }
}
    // MainActivity.java
    @Override
    protected void onCreate(Bundle savedInstanceState) {

        map.editText = findViewById(R.id.edittext);

        map.editText.setOnEditorActionListener((v, actionId, event) -> {
            if (actionId == EditorInfo.IME_ACTION_SEARCH) {
                System.out.println(v.getText());
                if (map.search != null) {
                    map.search.dismiss();
                    map.search = null;
                }
                Search.searchThread(v.getText().toString(), map);
                //return true;
            }
            return false;
        });

    }

リファレンス