【Java】ISBNから書籍情報を取得するAndroidアプリを作成しました

この記事は、群馬高専Advent Calendaの7日目の記事です。
adventar.org


非同期通信処理を用いて、HTTP通信処理を実装していきます。また、今回ではHTTP通信をする際に、Google Books APIを使用して実際にインターネット接続を経由して、書籍の情報を入手してみたいと思います。

今回作るもの

インターネット経由で情報を取得するという、考えるだけでは簡単なアプリを作りますが、一応最終的な完成品を以下に示しておきたいと思います。

f:id:PsyTouSan:20211202134031j:plain:w250
作るもの(ISBNからタイトルと著者を表示する)
Google Books API とは

Google Books APIは、特定のURLに書籍の裏表紙に記されているISBNをクエリパラメータとしてアクセスすることで、そのISBNの情報から、書籍のタイトル、著者、出版社、説明、などの情報を含んだJSONデータを返してくれるAPIです。特別な申請などはしなくても問題なく、誰でも無料で扱うことができるので、今回はこのAPIを活用したいと思います。試しに、以下のURLを踏んでみてください。

https://www.googleapis.com/books/v1/volumes?q=9784065163870
JSONデータが返ってくると思います。

非同期通信処理の準備

そもそも非同期通信処理とは

まず非同期通信処理について簡単に説明します。理解しているという人は飛ばしてもらっても構いません。
HTTP接続や、ダウンロードなどの時間のかかる処理を、メインで処理を実行するスレッド(メインスレッド)に任せてしまうと、それ以降の処理が滞ってしまうので、これは避けるべきことです。そこで、時間のかかる処理を、メインスレッドではない新たなスレッド(サブスレッド)に実行させることで、時間のかかる処理をしている間に、別の処理を実行できるので、効率的かつ安全であるということです。

f:id:PsyTouSan:20211120210929p:plain
非同期通信処理の簡略図

処理の記述

ここからが本題です。Androidアプリ内で、非同期通信をしていきます。とはいっても、いきなりソースコードを示すと分かりにくくなりそうなので、手順だけ先に示しておきます。

  1. looperとhandlerを宣言する
  2. 時間のかかる処理をするのクラスを作成し、インスタンス化する
  3. サブスレッドを用意する
  4. サブスレッドで処理を実行する

と言った感じです。結構長いコードを書くことになります。

マニフェストの設定

このアプリケーションは、デバイスにインターネットの接続を必要とします。なので、インターネット接続をさせるために、AndroidManifestを少しいじります。
以下のコードを参考に、一行だけ追加してください。

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android=...省略

    //以下の一行を記述
    <uses-permission android:name="android.permission.INTERNET" />

    <application
        ...省略
</manifest>

レイアウトの作成

レイアウトはこんな感じです。

f:id:PsyTouSan:20211127184733p:plain:w450
レイアウトとブループリント

ソースコードは以下の通りです。今回は、簡単なアプリを作るだけですし、何かと面倒だったのでstrings.xmlファイルは使用しませんでした。

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 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"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <EditText
        android:id="@+id/editIsbn"
        android:layout_width="300dp"
        android:layout_height="wrap_content"
        android:layout_marginTop="120dp"
        android:inputType="number"
        android:hint="ISBNを入力してください"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <Button
        android:id="@+id/search_button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="50dp"
        android:hint="検索"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/editIsbn" />

    <TextView
        android:id="@+id/resultTitle"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="50dp"
        android:textSize="25sp"
        android:text="Hello World!"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/search_button" />

    <TextView
        android:id="@+id/resultAuthor"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:textSize="25sp"
        android:text="Hello Android!"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/resultTitle" />

</androidx.constraintlayout.widget.ConstraintLayout>

簡単に説明しますと、テキストボックスにISBNが入力されて「検索」ボタンが押下されると、その下にあるテキストビューにその書籍のタイトルと著者が表示されるといった仕組みです。
その他、EditText内のinputTypeで、表示されるキーボードと、そのテキストボックスに入力可能な文字が指定できます。今回の場合は、numberを指定しているので、テキストボックスをタップしたときに表示されるキーボードは、一般的な日本語配列キーボードではなく、数字や記号が打てるキーボードが立ち上がり、さらに数字以外の文字の入力が不可能になります。そのほかにも、ここのnumberのところを「textPassword」と指定すれば、入力時に文字が見えないように「・」で隠してくれたり、「phone」とすれば、電話番号を入力しやすいテンキーが立ち上がるようになったりします。

Javaプログラムの作成

とりあえずコメント付きのソースコードを張り付けておきます。ソースコードの後に、いろいろな説明を書いてありますが、onCreateメソッドの役割だったり、クリック処理の記述方法など、非同期に関する以外の処理の説明は省かせていただきます。

package com.example.multithread_sample;

import androidx.annotation.UiThread;
import androidx.annotation.WorkerThread;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.os.HandlerCompat;

import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;
import android.view.View;
import android.widget.Button;
import android.widget.EditText;
import android.widget.TextView;
import android.widget.Toast;

import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.MalformedURLException;
import java.net.SocketTimeoutException;
import java.net.URL;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class MainActivity extends AppCompatActivity {
    private static final String GoogleBooksAPI = "https://www.googleapis.com/books/v1/volumes?q=";

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        EditText isbn = findViewById(R.id.editIsbn);
        Button search = findViewById(R.id.search_button);
        search.setOnClickListener(v -> {
            try {
                if (isbn.getText().toString().length() != 13){
                    Toast.makeText(MainActivity.this, "適切な値を入れてください", Toast.LENGTH_LONG).show();
                } else{
                    BookSearch(isbn.getText().toString());
                }
            } catch (NullPointerException e){
                Log.d("Null Exception","EditText is null");
                Toast.makeText(MainActivity.this, "適切な値を入れてください", Toast.LENGTH_LONG).show();
            }

        });
    }

    //メインスレッドでの動作を保証するアノテーション
    @UiThread
    private void BookSearch(final String Isbn) {
        String urlFull = GoogleBooksAPI + Isbn;
        Looper mainLooper = Looper.getMainLooper();
        Handler handler = HandlerCompat.createAsync(mainLooper);
        BookDataReceiver receiver = new BookDataReceiver(handler, urlFull);
        ExecutorService executorService = Executors.newSingleThreadExecutor();
        executorService.submit(receiver);
        executorService.shutdown();

    }

    private class BookDataReceiver implements Runnable {
        private final Handler _handler;
        private final String _urlFull;

        private BookDataReceiver(Handler handler, String urlFull) {
            _handler = handler;
            _urlFull = urlFull;
        }

        //サブスレッドでの動作することを保証するアノテーション
        @WorkerThread
        @Override
        public void run() {
            //ここに非同期処理を記述している
            //HTTP通信するオブジェクトの作成
            HttpURLConnection connection = null;
            //連続的なデータを格納するオブジェクト
            InputStream stream = null;
            //取得したJSONデータを格納する
            String result = "";
            try {
                URL url = new URL(_urlFull);
                //接続用のオブジェクトを生成する
                connection = (HttpURLConnection) url.openConnection();
                //接続のタイムアウトを指定する
                connection.setConnectTimeout(1000);
                //値を取得するタイムアウトを時間を指定
                connection.setReadTimeout(1000);
                //値を送信する場合はPOST、取得する場合はGETを指定する
                connection.setRequestMethod("GET");
                //接続を行う
                connection.connect();
                //streamに取得した値を格納する
                stream = connection.getInputStream();
                //129行目に示したinputStream関数を用いてString型のデータに変換
                result = inputStream(stream);
            } catch (MalformedURLException e) {
                Toast.makeText(MainActivity.this,"URLの変換に失敗", Toast.LENGTH_LONG).show();
            } catch (SocketTimeoutException e) {
                Toast.makeText(MainActivity.this, "接続タイムアウト", Toast.LENGTH_LONG).show();
            } catch (IOException e) {
                Toast.makeText(MainActivity.this, "通信できませんでした",Toast.LENGTH_LONG).show();
            } finally {
                if (connection != null){
                    //切断する
                    connection.disconnect();
                }
                if (stream != null){
                    try {
                        stream.close();
                    } catch (IOException e){
                        Toast.makeText(MainActivity.this, "解放失敗", Toast.LENGTH_LONG).show();
                    }
                }
            }
            JsonDecoder decoder = new JsonDecoder(result);
            _handler.post(decoder);
        }

        private String inputStream(InputStream stream) throws IOException {
            BufferedReader reader = new BufferedReader(new InputStreamReader(stream, "UTF-8"));
            StringBuffer result = new StringBuffer();
            char[] buffer = new char[1024];
            int line;
            while (0 <= (line = reader.read(buffer))) {
                result.append(buffer, 0, line);
            }
            return result.toString();
        }
    }

    private class JsonDecoder implements Runnable {
        private final String _result;

        private JsonDecoder(String result) {
            _result = result;
        }

        @UiThread
        @Override
        public void run() {
            String title = "";
            String author = "";
            try {
                //JSONオブジェクトを生成
                JSONObject jsonObject = new JSONObject(_result);
                //取得したJSONデータから「items」と名づけられた配列を取得する
                JSONArray items = jsonObject.getJSONArray("items");
                //取得した「items」配列のゼロ番目の要素を取得
                JSONObject itemValue = items.getJSONObject(0);
                //さらに、itemValue内の「volumeInfo」と名付けられた配列を取得
                JSONObject info = itemValue.getJSONObject("volumeInfo");
                //タイトルを取得
                title = info.getString("title");
                //「authors」と名付けられた配列を取得
                JSONArray authors = info.getJSONArray("authors");
                //著者を取得
                author = authors.getString(0);
            } catch (JSONException e) {
                e.printStackTrace();
            }
            TextView resultTitle = findViewById(R.id.resultTitle);
            TextView resultAuthor = findViewById(R.id.resultAuthor);
            resultTitle.setText("タイトル:" + title);
            resultAuthor.setText("著者:" + author);
        }
    }
}

Looper

Looperは、自身の属したスレッドのMessageQueueに格納された処理を、先入先出方式で実行し、これを無限にループします。MessageQueueとは、処理を順番に実行する箱のようなものです。メインスレッドは、このMessageQueueというものを最初から保持しています。

Handler

Handlerを用いることで、別スレッドで実行させたい処理をMessageQueueに追加できます。この別スレッドというのは、Looperを持っているスレッドのことです。これにより、HandlerからLooperを通じて、スレッド間の通信を可能にします。

CreateAsync

これだけの説明では、HandlerとLooperに関する説明としては不十分です。しかし、62行目に示したCreateAsyncメソッドを用いることで、この複雑な仕組みを簡略化して実装することができます。62行目では、HandlerをCreateAsyncメソッドにメインメソッドのLooperを渡すことで生成しています。こうすることで、「メインメソッドのLooperの持つQueueに、処理を追加できるHandler」が完成するわけです。これにより、非同期で実行させたい処理をさせることもできれば、後述する「post」を用いた、メインスレッドに戻って処理を実行させる(厳密には、getMainLooperを実行したスレッドに戻って実行させる)ということも可能になります。
LooperとHandlerに関する詳しい解説は、以下のリンクを参考にさせていただきましたので、掲載させていただきます。

LooperとHandlerに関して参考になったサイト
academy.realm.io
developer.android.com

サブスレッドに実行させる処理

さて、次はGoogle Books APIを用いた書籍情報の取得をを行うクラスの作成をしていきたいと思います。ここで扱う内容は以下の通りです。

  1. HTTP接続を行う
  2. 返されたJSONデータを文字列(バッファ)として取得する
  3. バッファからお望みのデータを抽出する
  4. 画面に表示する

上に示した番号の、2と3の部分で書籍を取得しています。まず2の部分で、帰ってきた文字列データをバッファとして格納します。そして、3の部分でそのバッファ内にある文字列から必要な情報を抽出するといった感じです。

HTTP接続の開始

HTTP接続はサブスレッドで実行するため、Runnableインターフェースの中にある、runメソッドの中に記述しています。Runnableインターフェースは、runメソッドを必ず表記する必要があります。ちなみに、runメソッドは、引数も戻り値もありません。このrunメソッドは、Runnableインターフェースを実装したスレッドが開始されたときに、自動的に呼び出されます。したがって、ボタンを押下したときにサブスレッドを開始することで、runメソッド内の処理が実行されることになります。HTTP接続を行う処理の記述は、例外処理の中に入れています。

JSONデータをバッファとして取得

HTTP経由でJSONデータを取得します。129行目のメソッドでJSONデータをString型に変換しています。以下は、そのメソッドの抜粋です。

 private String inputStream(InputStream stream) throws IOException {
            BufferedReader reader = new BufferedReader(new InputStreamReader(stream, "UTF-8"));
            StringBuffer result = new StringBuffer();
            char[] buffer = new char[1024];
            int line;
            while (0 <= (line = reader.read(buffer))) {
                result.append(buffer, 0, line);
            }
            return result.toString();
        }

望みのデータを取得する

String型に変換したJSON生データから、タイトルと著者を取得します。以下は、JSON生データから、必要な値を取得するメソッドの抜粋です。

 private class JsonDecoder implements Runnable {
        private final String _result;

        private JsonDecoder(String result) {
            _result = result;
        }

        @UiThread
        @Override
        public void run() {
            String title = "";
            String author = "";
            try {
                //JSONオブジェクトを生成
                JSONObject jsonObject = new JSONObject(_result);
                //取得したJSONデータから「items」と名づけられた配列を取得する
                JSONArray items = jsonObject.getJSONArray("items");
                //取得した「items」配列のゼロ番目の要素を取得
                JSONObject itemValue = items.getJSONObject(0);
                //さらに、itemValue内の「volumeInfo」と名付けられた配列を取得
                JSONObject info = itemValue.getJSONObject("volumeInfo");
                //タイトルを取得
                title = info.getString("title");
                //「authors」と名付けられた配列を取得
                JSONArray authors = info.getJSONArray("authors");
                //著者を取得
                author = authors.getString(0);
            } catch (JSONException e) {
                e.printStackTrace();
            }
            TextView resultTitle = findViewById(R.id.resultTitle);
            TextView resultAuthor = findViewById(R.id.resultAuthor);
            resultTitle.setText("タイトル:" + title);
            resultAuthor.setText("著者:" + author);
        }

このメソッドは、メインスレッドで実行しています。なので、アノテーションでも@UIThreadを指定しています。非同期通信を行うときと同様、Runnableインターフェースを実装して、その中のrunメソッド内に処理を記述しています。サブスレッドからメインスレッドに処理を戻す場合でも、非同期処理を行うときと同じように、runメソッド内に処理を記述します。

JSON生データからデータを抽出する

このrunメソッド内の処理を簡単に説明します。まず、前段階において、テキストボックスに入力されたISBNから、Google Books API を用いて、HTTP接続によりJSONの生データをString型で受け取ることを行いました。今度は、そのString型データから、書籍のタイトルと著者の情報を引き出します。とりあえず、実際のJSONデータを見てみます。以下のリンクにアクセスすると、JSONデータを取得できるのがわかると思います。

https://www.googleapis.com/books/v1/volumes?q=9784065163870

このデータに基づいて、JSONオブジェクトを取得したり、JSON配列を取得したり、JSON配列内の要素を抽出したりしています。先ほどの非同期処理では、このデータを取得していたということになります。
(余談ですが、この書籍はFlutter開発に関する書籍です。AndroidStudioで開発でき、Androidだけでなく、iOSアプリやWebアプリも作れるので、近年注目が集まっています。そのうち、これに関する記事も出していきたいと思っています…)
基本的には、ソースコードのコメント通りではありますが、簡潔にまとめると

  • JSONObjectクラスをnewし、そこにStringを渡して、JSONオブジェクトを生成
  • JSONArrayメソッドに、Stringを渡して、その名前の配列を取得
  • JSONObjectクラスののgetStringメソッドへStringを渡して、その名前の要素を取得

という感じになり、基本的にこれらでデータを抽出できます。

画面に表示する

レイアウト画面のタイトル、著者欄に文字を表示させます。上に示したソースコードの31から34行目に記述された内容が、文字の表示を担っています。
非同期処理の記述に比べたら遥かに簡単ですね。

動作確認

さて、ここまで記述が終えたので動作させてみます。

f:id:PsyTouSan:20211202133759j:plain:w250
起動直後の画面の様子

このテキストボックスに適当にISBNを入力してみてください。入力が終えたら、検索ボタンをタップしてください。HelloWorldとHelloAndroidと書かれている文章がタイトルと著者に代わるはずです。

f:id:PsyTouSan:20211202134031j:plain:w250
検索結果

まとめ

今回の記事の内容をまとめると

  • HTTP接続は基本的に非同期処理で行う
  • Androidの非同期処理には、LooperとHandlerが必要
  • スレッドの開始時にRunnable内のrunメソッドが実行される
  • runメソッド内に、非同期処理、もしくはサブからメインスレッドに戻ってきたときの処理を記述する
  • AndroidでもJSONデータを扱うことができる

といった感じになります。

非同期処理を用いて、ISBNから書籍データを取得することに成功しました。今回は、Google Books API を利用した方法でしたが、ほかにも、天気の情報を取得するとか、ニュースを取得するなどの他社のAPIサービスを利用すれば、応用の幅が一気に広がるので、ぜひ活用していきたいですね。

以上です。