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;
});
}