【Android】SQLiteへのデータ追加と検索(和歌山トイレマップで遊んでみる 5)
はじめに
今回はCSVから取得したデータをDB(SQLite)に保存して、画面上部に検索フォーム(SearchView)を追加してそこからデータの検索ができるようにしたいと思います。
ToolbarとSearchViewの追加
画面上部に表示して、メニューボタンなどを表示するバーは、これまでActionbarが使われてきましたが、Android 5.0からはToolbarが使われるようになるようです。
Toolbarを使うために、Actionbarを非表示にしてToolbarを追加します。
このアプリでは対応バージョンをAndroid4.4からとしているため、AppCompatを利用してToolbarを追加します。
ActionBarを非表示
Themeに「NoActionBar」とついたものを指定します。
styles.xml
< ?xml version="1.0" encoding="utf-8"? > < resources > < style name="AppTheme" parent="Theme.AppCompat.NoActionBar" > < /style > < /resources >
Toolbarの追加
activity_main.xml
< RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" tools:context=".MainActivity" > < fragment xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" android:id="@+id/map" tools:context=".MapsActivity" android:name="com.google.android.gms.maps.SupportMapFragment" android:layout_marginTop="50dp" android:layout_marginLeft="@dimen/activity_horizontal_margin" android:layout_marginRight="@dimen/activity_horizontal_margin" android:layout_marginBottom="@dimen/activity_vertical_margin" / > < include layout="@layout/layout_toolbar" / > < /RelativeLayout >
- include layoutで、 app > src > main > res > layout に追加したlayout_toolbar.xmlを読み込みます。
layout_toolbar.xml
< android.support.v7.widget.Toolbar xmlns:android="http://schemas.android.com/apk/res/android" android:id="@+id/toolbar" android:layout_height="wrap_content" android:layout_width="match_parent" android:minHeight="50dp" android:elevation="4dp" android:background="@color/primary_material_dark" > < /android.support.v7.widget.Toolbar >
SearchViewの追加
Toolbarにアイテムを追加するには、Actionbarと同じくmenu_main.xmlに記述します。
menu_main.xml
< menu xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" tools:context=".MainActivity" > < item android:id="@+id/searchview" android:title="@string/action_search" android:icon="@mipmap/ic_search_black_24dp" app:showAsAction="always" app:actionViewClass="android.support.v7.widget.SearchView" / > < /menu >
なお、検索ボタンの虫眼鏡アイコンは、以下からダウンロードしました。
Zipファイルで入手できるので、app > src > main > res > mipmap-○○○ の各フォルダに入れています。
これで画面の上部に黒いバーと検索ボタンが表示されるようになると思います。
Event追加
検索ボタンの表示や検索フォームが空の場合のデフォルト文言、検索ボタン押下時の処理を追加します。
MainActivity.java
~省略~ import android.support.v4.view.MenuItemCompat; import android.support.v7.widget.SearchView; import android.support.v7.widget.Toolbar; ~省略~ public class MainActivity extends FragmentActivity{ ~省略~ @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); ~省略~ Toolbar _toolbar = (Toolbar) findViewById(R.id.toolbar); _toolbar.inflateMenu(R.menu.menu_main); SearchView _searchView = (SearchView) MenuItemCompat.getActionView(_toolbar.getMenu().findItem(R.id.searchview)); // 検索フォーム上の検索ボタンは非表示にする. _searchView.setSubmitButtonEnabled(false); // 入力欄が空の場合に表示する文言の設定. _searchView.setQueryHint("名称や住所から検索"); _searchView.setOnQueryTextListener(new SearchView.OnQueryTextListener() { @Override public boolean onQueryTextSubmit(String searchWord) { // 検索ボタンの押下かEnterキーの押下で実行される. // 入力内容(searchWord)でDBを検索して、マーカーを再設置する. return false; } @Override public boolean onQueryTextChange(String newText) { // 入力される度に呼び出される. // 入力候補を検索して、検索フォームの下に表示する. return false; } }); ~省略~
なお、プロジェクト作成時にデフォルトで作成されている(かもしれない)「onCreateOptionsMenu(Menu menu)」と「onOptionsItemSelected(MenuItem item)」は実行されないため、注意が必要です
(上記コードを「onCreateOptionsMenu(Menu menu)」に記述していて、内容が反映されない原因をしばらく考えてしまいました…)。
課題
作成したSearchViewで、Google日本語入力を使って文字を入力すると、入力した文字の確定時に「SPAN_EXCLUSIVE_EXCLUSIVE spans cannot have a zero length」というエラーが出ます。
以下などを参考に、InputTypeの指定などを行いましたが、解決はできていません。
* java - Android - SPAN_EXCLUSIVE_EXCLUSIVE spans cannot have a zero length - Stack Overflow
ただGoogle中国語入力などではこの問題は発生せず、Google日本語入力を使うとGmailやHangoutsなど他のアプリでも再現していたため、一旦対応は保留とすることにしました。
今後解決ができれば追記または別途このブログに書こうかと思います。
DBへのデータ挿入
DBの作成
CSVから取得したデータをDB(SQLite)に挿入します。方法は以前と同じです。
DBが作成されていなければ新規で作成します。
DatabaseAccesser.java
~省略~ import android.content.Context; import android.content.ContentValues; import android.database.Cursor; import android.database.sqlite.SQLiteDatabase; import android.database.sqlite.SQLiteOpenHelper; public class DatabaseAccesser extends SQLiteOpenHelper{ ~省略~ public DatabaseAccesser(Context context){ super(context, "toiletinfo.db", null, 1); } ~省略~ @Override public void onCreate(SQLiteDatabase db) { db.execSQL("CREATE TABLE IF NOT EXISTS toiletinfo(" + "id INTEGER PRIMARY KEY, " + "toiletname TEXT NOT NULL, " + "district TEXT, " + "municipality TEXT, " + "address TEXT NOT NULL, " + "latitude REAL NOT NULL, " + "longitude REAL NOT NULL, " + "availabletime TEXT, " + "nearbysightseeing TEXT, " + "lastupdatedate INTEGER)" ); } ~省略~
データの挿入
データ格納用にToiletInfoModelというクラスを作り、1件ずつデータを挿入していきます。
DatabaseAccesser.java
~省略~ import android.content.ContentValues; ~省略~ public class DatabaseAccesser extends SQLiteOpenHelper{ // データ挿入用クラス. public class ToiletInfoModel{ public int id = 0; public String toiletName; public String district; public String municipality; public String address; public double latitude; public double longitude; public String availableTime; public String nearbySightseeing; } ~省略~ public void insertInfo(SQLiteDatabase db, ToiletInfoModel toiletInfo){ // トランザクション開始. db.beginTransaction(); // 挿入用のデータ準備. ContentValues contentValues = new ContentValues(); contentValues.put("toiletname", toiletInfo.toiletName); contentValues.put("district", toiletInfo.district); contentValues.put("municipality", toiletInfo.municipality); contentValues.put("address", toiletInfo.address); contentValues.put("latitude", toiletInfo.latitude); contentValues.put("longitude", toiletInfo.longitude); contentValues.put("availabletime", toiletInfo.availableTime); contentValues.put("nearbysightseeing", toiletInfo.nearbySightseeing); contentValues.put("lastupdatedate", java.lang.System.currentTimeMillis()); // データ挿入. db.insert("toiletinfo", null, contentValues); // トランザクション終了. db.setTransactionSuccessful(); db.endTransaction(); } ~省略~
LocationAccesser.java
~省略~ public class LocationAccesser implements GoogleApiClient.ConnectionCallbacks, GoogleApiClient.OnConnectionFailedListener{ ~省略~ private DatabaseAccesser mDbAccesser; private SQLiteDatabase mSqliteDb; private DatabaseAccesser.ToiletInfoModel mToiletInfoModel; public void initialize(final MainActivity mainActivity, final LocationManager locationManager){ ~省略~ mDbAccesser = new DatabaseAccesser(mainActivity); mSqliteDb = mDbAccesser.getWritableDatabase(); mToiletInfoModel = mDbAccesser.new ToiletInfoModel(); } ~省略~ public void loadCsvData(final MainActivity mainActivity){ mMap.clear(); // 別スレッドで実行. HandlerThread handlerThread = new HandlerThread("AddMarker"); handlerThread.start(); Handler handler = new Handler(handlerThread.getLooper()); handler.post(new Runnable() { @Override public void run() { ~省略~ if (mNetworkAccesser.checkIsNetworkConnected()) { Geocoder geocoder = new Geocoder(mainActivity, Locale.getDefault()); AssetManager asmAsset = mainActivity.getResources().getAssets(); try { // CSVの読み込み. InputStream ipsInput = asmAsset.open("toilet-map.csv"); InputStreamReader inputStreamReader = new InputStreamReader(ipsInput); BufferedReader bufferReader = new BufferedReader(inputStreamReader); String strLine = ""; String[] strSplited; Pattern p = Pattern.compile("^[0-9]+"); // 1行ずつ読み込む. while ((strLine = bufferReader.readLine()) != null) { // とりあえず数値から始まっている行のみ if (p.matcher(strLine).find()) { // とりあえずSplit後に4件以上データがある行のみ. strSplited = strLine.split(","); if (strSplited.length >= 4) { List addressList = geocoder.getFromLocationName(strSplited[3], 1); if (!addressList.isEmpty()) { Address address = (Address) addressList.get(0); // DBへの挿入用にデータを格納. mToiletInfoModel.toiletName = strSplited[1]; // 都道府県はひとまず和歌山固定. mToiletInfoModel.district = "和歌山"; mToiletInfoModel.municipality = strSplited[2]; mToiletInfoModel.address = strSplited[3]; mToiletInfoModel.latitude = address.getLatitude(); mToiletInfoModel.longitude = address.getLongitude(); mToiletInfoModel.availableTime = strSplited[4]; String strNearbySightseeing = (strSplited.length > 35) ? strSplited[35] : ""; mToiletInfoModel.nearbySightseeing = strNearbySightseeing; // データ挿入. mDbAccesser.insertInfo(mSqliteDb, mToiletInfoModel); ~省略~
- ToiletInfoModelクラスを初期化するときに、「new DatabaseAccesser(mainActivity).new ToiletInfoModel();」や「mDbAccesser.new ToiletInfoModel();」と書くのは最初びっくりしましたw
データを検索してマーカーを再設置する
データの検索
DBに挿入したデータを検索します。
データの検索自体は「SQLiteDatabase.query」を使用します。
この第3引数でSQLのWHERE句の内容(例: "id = ?")を記述し、第4引数で第3引数に渡す値(?と置き換える)を指定します。
全件検索ならどちらもnullを指定、画面上部のSearchViewに文字列を入力して検索ボタンを押した場合は入力内容でトイレの名称または住所を検索することとします。
DatabaseAccesser.java
~省略~ public class DatabaseAccesser extends SQLiteOpenHelper{ ~省略~ private String mStrSearchCriteria; private String[] mStrSearchParameters; ~省略~ public void setSearchCriteriaFromFreeWord(String strWord){ // SQLiteDatabase.queryの第3引数、第4引数を作成する. String[] splittedWords = strWord.split(" |\\s"); StringBuilder _newSearchCriteria = new StringBuilder(); // toiletname, addressが対象. mStrSearchParameters = new String[splittedWords.length * 2]; for(int i = 0, j = 0; i < splittedWords.length; i++){ if(i > 0){ _newSearchCriteria.append(" AND "); } _newSearchCriteria.append("(toiletname LIKE ? OR address LIKE ?)"); StringBuilder _newParameter = new StringBuilder("%"); _newParameter.append(splittedWords[i]); _newParameter.append("%"); // toiletname, addressが対象なので同じ単語を2つずつ格納する. mStrSearchParameters[j] = _newParameter.toString(); mStrSearchParameters[j + 1] = _newParameter.toString(); j += 2; } mStrSearchCriteria = _newSearchCriteria.toString(); } public ArrayListsearch(SQLiteDatabase db){ ArrayList aryToiletInfo = new ArrayList (); // DBを検索する. Cursor _cursor = db.query("toiletinfo", null, mStrSearchCriteria, mStrSearchParameters, null, null, "id ASC", null); _cursor.moveToFirst(); for(int i = 0; i < _cursor.getCount(); i++){ ToiletInfoModel toiletInfoModel = new ToiletInfoModel(); toiletInfoModel.toiletName = _cursor.getString(_cursor.getColumnIndex("toiletname")); toiletInfoModel.district = _cursor.getString(_cursor.getColumnIndex("district")); toiletInfoModel.municipality = _cursor.getString(_cursor.getColumnIndex("municipality")); toiletInfoModel.address = _cursor.getString(_cursor.getColumnIndex("address")); toiletInfoModel.latitude = _cursor.getDouble(_cursor.getColumnIndex("latitude")); toiletInfoModel.longitude = _cursor.getDouble(_cursor.getColumnIndex("longitude")); toiletInfoModel.availableTime = _cursor.getString(_cursor.getColumnIndex("availabletime")); toiletInfoModel.nearbySightseeing = _cursor.getString(_cursor.getColumnIndex("nearbysightseeing")); aryToiletInfo.add(toiletInfoModel); _cursor.moveToNext(); } // 検索後はNullに戻す. mStrSearchCriteria = null; mStrSearchParameters = null; return aryToiletInfo; } ~省略~
- setSearchCriteriaFromFreeWordにて、SearchViewにスペース区切りで文字列が入力された場合はAnd検索するための文字列を「mStrSearchCriteria」に追加します。
- あいまい検索をしたい場合、SQL文は「WHERE toiletname LIKE %トイレ%」のように値の前後に「%」を追加します(%は0字以上の任意の文字列、_は1字以上の任意の文字列)。
「SQLiteDatabase.query」の場合、第3引数に「toiletname LIKE %?%」とつけるのではなく、第4引数に「%トイレ%」のように付与します。
マーカーの再設置
~省略~ public void setMarkersByFreeWord(final String strWord){ // マーカーを一旦全て削除する. mMap.clear(); // 別スレッドの作成. HandlerThread handlerThread = new HandlerThread("AddMarker"); handlerThread.start(); Handler handler = new Handler(handlerThread.getLooper()); handler.post(new Runnable() { @Override public void run() { // 検索条件の指定. mDbAccesser.setSearchCriteriaFromFreeWord(strWord); // データの検索. ArrayListaryToiletInfo = mDbAccesser.search(mSqliteDb); if (aryToiletInfo != null) { for (DatabaseAccesser.ToiletInfoModel toiletInfo : aryToiletInfo) { // マーカー設置用のデータの準備. Message msgToiletInfo = new Message(); msgToiletInfo.what = R.string.handler_get_csv; msgToiletInfo.obj = toiletInfo; // UIスレッドでマーカーを設置. executeOnUiThreadHandler.sendMessage(msgToiletInfo); } } } }); } private Handler executeOnUiThreadHandler = new Handler() { public void handleMessage(Message msg) { switch (msg.what){ case R.string.handler_get_csv: if(msg.obj != null){ // Messageに格納されたデータを取得してマーカーを設置. DatabaseAccesser.ToiletInfoModel toiletInfo = (DatabaseAccesser.ToiletInfoModel)msg.obj; addMarker(toiletInfo.toiletName, toiletInfo.latitude, toiletInfo.longitude); } break; } } }; ~省略~
- 設置済みのマーカーを削除するには、設置したマーカー(変数)を一つ一つ指定して削除するか、「GoogleMap.clear」を実行します。
- 別スレッドにてDBの検索を行い、UIスレッドでマーカーを設置しています。
- 最初はDBから取得した値を変数に格納して、UIスレッドの実行を「sendEmptyMessage」で行っていましたが、forループ中に「sendEmptyMessage」を実行しても、 実際に「handleMessage」が実行されるのはforループ完了後であったことから、Messageを使ってマーカーの設置を行っています。
おわりに
これでDBに登録が完了していれば、トイレの名称や住所で検索を行うことができます。
ただし、現状CSVからのデータ取得、DBへのデータ保存が完了する前にアプリを止めたりすると、それ以降のデータが登録されないため、次回はこれを修正する予定です。
あと各コードももう少し綺麗にしたいところです。
参考
Toolbar
- AppCompat v21 — Lollipop 搭載前のデバイスにマテリアル デザインを! - Google Developers Japan
- Android - Toolbar に 13行で SearchView を実装する - Qiita
- ToolbarでSearchViewを利用する - SYSTEM_KDのブログ
- Toolbar - Android Developers
- AndroidのToolBar(新しいActionBar)メモ - Qiita