2024-07-13
ブラウザでGoogleとかYahooで検索文字入力中に候補が表示されるが、これを自分のAndroidアプリで実装する典型的な方法を紹介します。
EditTextの入力をリッスン→AsyncTaskでバックグラウンドでBing Suggest APIを呼んで結果作成→TextViewに表示 という流れになります。
Bing Suggest APIは、以下のような感じで呼びます。
https://api.bing.com/qsonhs.aspx?mkt=ja-JP&q=Amazon
検索候補文字列がJSONの配列で帰る(AS->Results->Suggests->Txt)ので、JSONObjectで展開してTextViewに表示するだけです。
注意すべきは、エディットボックスはユーザーがキー押しっぱなしなどの連続入力が起こるので、次の入力が来たら、現在のAsyncTask処理をキャンセルして、新しく呼び直さないといけません。
この処理が適切に行われていないと、連続入力中に例外が出て落ちるか、正常な表示はなされないと思われます。
プロジェクト一式はこちら。
https://github.com/servernote/AndroidSample/tree/master/AsyncSuggest
単純なのでJavaソースはMainActivityだけで足りてます。内部にMyAsyncTaskクラスを記述。
Java | MainActivity.java | GitHub Source |
package net.servernote.asyncsuggest; import androidx.appcompat.app.AppCompatActivity; import android.content.Context; import android.os.AsyncTask; import android.os.Build; import android.os.Bundle; import android.text.Editable; import android.text.TextWatcher; import android.util.Log; import android.view.KeyEvent; import android.view.View; import android.view.inputmethod.InputMethodManager; import android.widget.EditText; import android.widget.TextView; import org.json.JSONArray; import org.json.JSONObject; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.net.URL; import java.net.URLEncoder; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Map; import java.util.zip.GZIPInputStream; import javax.net.ssl.HttpsURLConnection; public class MainActivity extends AppCompatActivity implements TextWatcher, View.OnKeyListener { private EditText mEditText; private TextView mTextView; private String mLastInput; // 直前の入力保存用 private MyAsyncTask mTask; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); mEditText = (EditText)findViewById(R.id.input_text); mEditText.addTextChangedListener(this); mEditText.setOnKeyListener(this); mTextView = (TextView)findViewById(R.id.output_text); mLastInput = ""; mTask = null; } // implements TextWatcher @Override public void beforeTextChanged(CharSequence s, int start, int count, int after) { } // implements TextWatcher @Override public void onTextChanged(CharSequence s, int start, int before, int count) { } // implements TextWatcher @Override public void afterTextChanged(Editable s) { String inputStr= s.toString(); Log.d("MainActivity", "input="+inputStr); if(!inputStr.equals(mLastInput)){ // 直前入力と異なっていたら処理 mLastInput = inputStr; // 直前入力保存 if(mTask != null){ // サジェスト通信処理中ならキャンセル指令を出す mTask.cancel(true); mTask = null; } if(inputStr.length() > 0) { // 入力ありならサジェスト通信処理開始 mTask = new MyAsyncTask(mTextView); mTask.execute(mLastInput); } else{ // 空なら結果画面をクリアする mTextView.setText(""); } } } // implements View.OnKeyListener // ENTERキー入力で、キーボードを閉じる。 @Override public boolean onKey(View v, int keyCode, KeyEvent event) { if (event.getAction() == KeyEvent.ACTION_DOWN && keyCode == KeyEvent.KEYCODE_ENTER) { InputMethodManager inputMethodManager = (InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE); inputMethodManager.hideSoftInputFromWindow(v.getWindowToken(), 0); return true; } return false; } // サジェスト通信処理を行うクラス private class MyAsyncTask extends AsyncTask<String, Integer, Long> { private TextView mTextView; private String mResponse; private String mDispText; public MyAsyncTask(TextView textView) { super(); mTextView = textView; //ここに結果を表示する mResponse = ""; mDispText = ""; } // doInBackground開始前に呼ばれる(UI操作可能) @Override protected void onPreExecute() { } // バックグラウンド処理 (UI操作禁止) // return code: 0:正常,1:キャンセル,2:エラー @Override protected Long doInBackground(String... params) { Log.d("MyAsyncTask", "doInBackground "+params[0]); // finallyで後始末するものはここで宣言する HttpsURLConnection connection = null; Map<String, List<String>> headers = null; InputStream inputStream = null; BufferedReader reader = null; try { // 入力文字列を Bing API に渡してサジェスト候補検索 String uri = "https://api.bing.com/qsonhs.aspx?mkt=ja-JP&q=" + URLEncoder.encode(params[0], "UTF-8"); Log.d("MyAsyncTask","URI="+uri); if (isCancelled()) { Log.d("MyAsyncTask", "cancel return 1"); return 1L; } URL url = new URL(uri); connection = (HttpsURLConnection)url.openConnection(); if (isCancelled()) { Log.d("MyAsyncTask", "cancel return 2"); return 1L; } connection.setRequestProperty("User-Agent","Android " + Build.MODEL); connection.setRequestProperty("Accept-Encoding", "gzip, deflate"); connection.setConnectTimeout(30000); // Timeout 30秒 connection.setReadTimeout(30000); // Timeout 30秒 connection.setDoInput(true); connection.setRequestMethod("GET"); if (isCancelled()) { Log.d("MyAsyncTask", "cancel return 3"); return 1L; } connection.connect(); // 接続・検索 if (isCancelled()) { Log.d("MyAsyncTask", "cancel return 4"); return 1L; } int responseCode = connection.getResponseCode(); // HTTPステータス取得 Log.d("MyAsyncTask", "got response "+responseCode); if(responseCode != HttpsURLConnection.HTTP_OK) { // 200 OK以外はエラー throw new IOException("HTTP responseCode: " + responseCode); } if (isCancelled()) { Log.d("MyAsyncTask", "cancel return 5"); return 1L; } //headers = connection.getHeaderFields(); // サジェスト候補JSONデータストリームOpen gzip圧縮に対応 String contentEncoding = connection.getContentEncoding(); if(contentEncoding!=null && contentEncoding.contains("gzip")){ inputStream = new GZIPInputStream(connection.getInputStream()); }else{ inputStream = connection.getInputStream(); } if (isCancelled()) { Log.d("MyAsyncTask", "cancel return 6"); return 1L; } // データ読み込み StringBuilder sb = new StringBuilder(); reader = new BufferedReader(new InputStreamReader(inputStream, Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT ? StandardCharsets.UTF_8 : Charset.forName("UTF-8"))); String line; while ((line = reader.readLine()) != null) { if (isCancelled()) { Log.d("MyAsyncTask", "cancel return 7"); return 1L; } sb.append(line); } mResponse = sb.toString(); // 結果JSONの生データ if(mResponse == null || mResponse.length() <= 0){ return 3L; } Log.d("MyAsyncTask", "finished stream read"); // JSON解析開始 JSONObject rootObject = new JSONObject(mResponse); JSONObject as = null; JSONArray results = null; if (isCancelled()) { Log.d("MyAsyncTask", "cancel return 8"); return 1L; } if(rootObject != null){ as = rootObject.optJSONObject("AS"); if(as != null){ results = as.optJSONArray("Results"); } } if (isCancelled()) { Log.d("MyAsyncTask", "cancel return 9"); return 1L; } if (results != null) { // Suggests要素配列を分解して出力 expandJSONArray(results.optJSONObject(0).optJSONArray("Suggests")); if (isCancelled()) { Log.d("MyAsyncTask", "cancel return 10"); return 1L; } Log.d("MyAsyncTask", "finished expand JSON"); } } catch (Exception e) { Log.e("AsyncTask", e.toString()); return 2L; // エラー終了 } finally { // 後片付け if(reader != null) { try { reader.close(); } catch (IOException e) { Log.e("AsyncTask", e.toString()); } } if(inputStream != null) { try { inputStream.close(); } catch (IOException e) { Log.e("AsyncTask", e.toString()); } } if(connection != null) { // A connection to https://api.bing.com/ was leaked. Did you forget to close a response body? // と言われるのを防ぐクローズ処理 if (connection.getErrorStream() != null) { try { connection.getErrorStream().close(); } catch (IOException e) { Log.e("AsyncTask", e.toString()); } } connection.disconnect(); } } return 0L; // 正常終了(検索候補無しも含む) } // doInBackgroundでpublishProgressを呼ぶと呼ばれる @Override protected void onProgressUpdate(Integer... values) { } // doInBackground完了後に呼ばれる(UI操作可能) @Override protected void onPostExecute(Long result) { Log.d("MyAsyncTask", "onPostExecute result="+result); if(result == 0L){ //正常終了なら、サジェスト結果文字列を表示する mTextView.setText(mDispText); Log.d("MyAsyncTask", "finished display text"); } } // doInBackground中にcancelされたら呼ばれる(UI操作可能) @Override protected void onCancelled() { Log.d("MyAsyncTask", "onCancelled"); } // BingサジェストAPIの結果配列を文字列に分解する // https://api.bing.com/qsonhs.aspx?mkt=ja-JP&q=Amazon void expandJSONArray(JSONArray array){ if(array == null){ return; } int i, n = array.length(); for (i = 0; i < n; i++) { //Txt要素が候補文字列なのでDispTextへ追加していく if (isCancelled()) { Log.d("MyAsyncTask", "cancel return 11"); return; } JSONObject object = array.optJSONObject(i); if(object == null){ break; } mDispText += object.optString("Txt") + "\n"; } } } }
・エディットボックスにテキスト変更を受け取るリスナーを登録し、afterTextChangedで受け取りBing API呼び出すAsyncTaskを生成して結果を受け取り表示します。すでに前回のAsyncTaskが存在する場合、cancelを呼びます。
・エディットボックスにはキー入力リスナーも登録し、Enterキーの押下でキーボードを閉じる処理を入れています。
・doInBackgroundではポイントごとに自身がキャンセルされたかをチェックする処理を頻繁に入れて、すぐに抜けれるようにします。こうしておけば、並列でタスクが乱立してしまうことを防げます。cancelが呼ばれていても、このように自分でチェックして抜けなければ処理は終わりません。キャンセルが呼ばれようとfinallyで後片付けは必要なので、これは当然の仕様と言えます。
・tryブロック中のどこでreturnで抜けてもfinallyが呼ばれる言語仕様なので、後片付け忘れを防ぎます。
XML | AndroidManifest.xml | GitHub Source |
<?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="net.servernote.asyncsuggest"> <uses-permission android:name="android.permission.INTERNET" /> <application android:allowBackup="true" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/AppTheme"> <activity android:name=".MainActivity"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity> </application> </manifest>
XML | layout/activity_main.xml | GitHub Source |
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="fill_parent" android:layout_height="fill_parent" android:background="@color/colorPrimaryDark" android:padding="10dp" android:orientation="vertical"> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="center_horizontal" android:text="@string/input_keyword" android:textColor="@color/colorAccent" android:textSize="14dp" /> <EditText android:id="@+id/input_text" android:layout_width="fill_parent" android:layout_height="36dp" android:background="@drawable/edittext_background" android:imeOptions="actionSearch" android:singleLine="true" android:textSize="14dp" /> <ScrollView android:layout_marginTop="5dp" android:layout_width="fill_parent" android:layout_height="wrap_content" android:layout_weight="1"> <TextView android:id="@+id/output_text" android:layout_width="fill_parent" android:layout_height="wrap_content" android:text="" android:textColor="@color/colorAccent" android:textSize="14dp" /> </ScrollView> </LinearLayout>
XML | values/strings.xml | GitHub Source |
<resources> <string name="app_name">AsyncSuggest</string> <string name="input_keyword">キーワード入力</string> </resources>
XML | values/colors.xml | GitHub Source |
<?xml version="1.0" encoding="utf-8"?> <resources> <color name="colorWhite">#FFFFFF</color> <color name="colorPrimary">#6200EE</color> <color name="colorPrimaryDark">#3700B3</color> <color name="colorAccent">#03DAC5</color> </resources>
XML | drawable/edittext_background.xml | GitHub Source |
<?xml version="1.0" encoding="utf-8"?> <shape xmlns:android="http://schemas.android.com/apk/res/android"> <solid android:color="@color/colorWhite" /> </shape>
※本記事内容の無断転載を禁じます。
ご連絡は以下アドレスまでお願いします★
Intel Macbook2020にBootCampで入れたWindows11 Pro 23H2のBluetoothを復活させる
Windowsのデスクトップ画面をそのまま配信するための下準備
WindowsでGPUの状態を確認するには(ASUS系監視ソフトの自動起動を停止する)
CORESERVER v1プランからさくらインターネットスタンダートプランへ引っ越しメモ
さくらインターネットでPython MecabをCGIから使う
さくらインターネットのPHPでAnalytics-G4 APIを使う
インクルードパスの調べ方
【Git】特定ファイルを除外する.gitignore
【Ubuntu/Debian】NVIDIA関係のドライバを自動アップデートさせない
【Apache】サーバーに同時接続可能なクライアント数を調整する
Windows版Google Driveが使用中と言われアンインストールできない場合
【Windows10】リモートデスクトップ間のコピー&ペーストができなくなった場合の対処法
Windows11+WSL2でUbuntuを使う【2】ブリッジ接続+固定IPの設定
【Linux】iconv/libiconvをソースコードからインストール
Googleスプレッドシートを編集したら自動で更新日時を入れる
【C/C++】小数点以下の切り捨て・切り上げ・四捨五入
【ひかり電話+VoIPアダプタ】LANしか通ってない環境でアナログ電話とFAXを使う
Windows11でMacのキーボードを使うには