Windowsパソコン/タブレット版の地図アプリではフリーワード検索機能をサポートしており、 それなりに利用していた。Androidスマホ版では文字入力が面倒なため、 これまでフリーワード検索はサポートしてこなかった。
実機デバッグはAndroidタブレットが中心となった。自作地図アプリと同じ場所を 標準OSM地図や Google Map で表示することは簡単であるが、その逆はできない。 検索機能があれば、同じ場所を素早く見つけることができる。
タブレットの場合、スマホよりは文字入力が楽なため、 フリーワード検索機能をサポートしたい。
仕様はとりあえず、Windows版と同じとする。Windowsに比べて Android はアプリが使える メモリ容量が小さいため、OSMデータファイルはメモリに常駐させず、検索の都度、 ファイルから読み込むこととする。
または、検索開始でファイルを読み込み、検索終了でメモリを開放する。 ファイルサイズが 50MB程度であれば問題ない。
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; }); }