アプリケーション開発ポータルサイト
ServerNote.NET
Amazon.co.jpでPC関連商品タイムセール開催中!
カテゴリー【AndroidJava
【Android】エディット入力に追従して検索候補(サジェスト)をバックグラウンドで取得し表示する
POSTED BY
2023-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クラスを記述。

JavaMainActivity.javaGitHub 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が呼ばれる言語仕様なので、後片付け忘れを防ぎます。

XMLAndroidManifest.xmlGitHub 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>

XMLlayout/activity_main.xmlGitHub 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>

XMLvalues/strings.xmlGitHub Source
<resources>
    <string name="app_name">AsyncSuggest</string>
    <string name="input_keyword">キーワード入力</string>
</resources>

XMLvalues/colors.xmlGitHub 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>

XMLdrawable/edittext_background.xmlGitHub Source
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
    <solid android:color="@color/colorWhite" />
</shape>

※本記事は当サイト管理人の個人的な備忘録です。本記事の参照又は付随ソースコード利用後にいかなる損害が発生しても当サイト及び管理人は一切責任を負いません。
※本記事内容の無断転載を禁じます。
【WEBMASTER/管理人】
自営業プログラマーです。お仕事ください!
ご連絡は以下アドレスまでお願いします★

☆ServerNote.NETショッピング↓
ShoppingNote / Amazon.co.jp
☆お仲間ブログ↓
一人社長の不動産業務日誌
【キーワード検索】