文系プログラマの勉強ノート

スマホアプリ開発やデザインなどについて勉強したことをまとめています

【Android】非同期通信で文字列をダウンロードする

Kotlinの勉強がてら、Androidの公式を参考にサーバーから文字列を取得する処理を作ったのですが、 一部内容が古く修正が必要だったので、修正後のソースコードを紹介します。

基本的には以下の公式ガイドの内容をAPIレベル29向けに少し修正したものになります。

developer.android.com

1. パーミッションを追加

ネットワーク接続とネットワーク状況の取得を許可するため、 AndroidManifest に以下のパーミッションを追加します。

AndroidManifest.xml

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />

2. ヘッドレス フラグメントを実装してネットワークを介したデータ操作をカプセル化する

ヘッドレスフラグメント=UI要素を持たないFragment。このサンプルで初めて使いました。

このヘッドレスフラグメントである NetworkFragment 上で通信処理を行います。

元ページでは最後に行うデバイス構成変更対応もここでまとめて実装しています。また、URLは後から指定したいこともあるのでイニシャライザから切り離しました。

NetworkFragment.kt

private const val TAG = "NetworkFragment"
private const val URL_KEY = "UrlKey"

class NetworkFragment : Fragment() {
    private var callback: DownloadCallback<String>? = null
    private var downloadTask: DownloadTask? = null
    private var urlString: String? = null

    companion object {
        /**
         * Static initializer for NetworkFragment that sets the URL of the host it will be
         * downloading from.
         */
        fun getInstance(fragmentManager: FragmentManager): NetworkFragment {
            var networkFragment = fragmentManager.findFragmentByTag(TAG) as? NetworkFragment
            if (networkFragment == null) {
                networkFragment = NetworkFragment()
                fragmentManager.beginTransaction().add(networkFragment, TAG).commit()
            }
            return networkFragment
        }
    }

    fun setUrl(url: String) {
        val arg = arguments ?: Bundle()
        arg.putString(URL_KEY, url)
        arguments = arg
        urlString = url
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        retainInstance = true
        urlString = arguments?.getString(URL_KEY)
    }

    override fun onAttach(context: Context) {
        super.onAttach(context)
        // Host Activity will handle callbacks from task.
        callback = context as? DownloadCallback<String>
    }

    override fun onDetach() {
        super.onDetach()
        // Clear reference to host Activity to avoid memory leak.
        callback = null
    }

    override fun onDestroy() {
        // Cancel task when Fragment is destroyed.
        cancelDownload()
        super.onDestroy()
    }

    /**
     * Start non-blocking execution of DownloadTask.
     */
    fun startDownload() {
        cancelDownload()
        callback?.also {
            downloadTask = DownloadTask(it).apply {
                execute(urlString)
            }
        }
    }

    /**
     * Cancel (and interrupt if necessary) any ongoing DownloadTask execution.
     */
    fun cancelDownload() {
        downloadTask?.cancel(true)
    }
}

DownloadCallback と DownloadTask が赤くなっていますので、これからクラスを作ります。

3. コールバックを作成

非同期の通信結果を受け取るため、コールバックを作成します。

DownloadCallback.kt

const val ERROR = -1
const val CONNECT_SUCCESS = 0
const val GET_INPUT_STREAM_SUCCESS = 1
const val PROCESS_INPUT_STREAM_IN_PROGRESS = 2
const val PROCESS_INPUT_STREAM_SUCCESS = 3

interface DownloadCallback<T> {

    /**
     * Indicates that the callback handler needs to update its appearance or information based on
     * the result of the task. Expected to be called from the main thread.
     */
    fun updateFromDownload(result: T?)

    /**
     * Get the connection manager object.
     */
    fun getConnectivityManager(): ConnectivityManager

    /**
     * Indicate to callback handler any progress update.
     * @param progressCode must be one of the constants defined in DownloadCallback.Progress.
     * @param percentComplete must be 0-100.
     */
    fun onProgressUpdate(progressCode: Int, percentComplete: Int)

    /**
     * Indicates that the download operation has finished. This method is called even if the
     * download hasn't completed successfully.
     */
    fun finishDownloading()
}

元々は上から2つ目は NetworkInfo を取得する処理でしたが、NetworkInfoクラスはAPIレベル29でDeprecatedとなりました。 代わりに ConnectivityManager を取得します。

    // 変更前
    fun getActiveNetworkInfo(): NetworkInfo

    // ↓

    // 変更後
    fun getConnectivityManager(): ConnectivityManager

ついでに呼び出し元のActivityに DownloadCallback インターフェース メソッド実装を追加します。

MainActivity.kt

class MainActivity : AppCompatActivity(), DownloadCallback<String> {

    private var networkFragment: NetworkFragment? = null
    private var downloading = false

    ...

    override fun updateFromDownload(result: String?) {
        // Update your UI here based on result of download.
    }

    override fun getConnectivityManager(): ConnectivityManager {
        return getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
    }

    override fun onProgressUpdate(progressCode: Int, percentComplete: Int) {
        when (progressCode) {
            // You can add UI behavior for progress updates here.
            ERROR -> {
            }
            CONNECT_SUCCESS -> {
            }
            GET_INPUT_STREAM_SUCCESS -> {
            }
            PROCESS_INPUT_STREAM_IN_PROGRESS -> {
            }
            PROCESS_INPUT_STREAM_SUCCESS -> {
            }
        }
    }

    override fun finishDownloading() {
        downloading = false
        networkFragment?.cancelDownload()
    }
}

4. 通信用のAsynkTaskのサブクラスを作成

NetworkFragment のプライベート内部クラスとして実装します。

NetworkFragment.kt

class NetworkFragment : Fragment() {

    ...
    
    /**
     * Implementation of AsyncTask designed to fetch data from the network.
     */
    private class DownloadTask(callback: DownloadCallback<String>)
        : AsyncTask<String, Int, DownloadTask.Result>() {

        private var callback: DownloadCallback<String>? = null

        init {
            setCallback(callback)
        }

        internal fun setCallback(callback: DownloadCallback<String>) {
            this.callback = callback
        }

        /**
         * Wrapper class that serves as a union of a result value and an exception. When the download
         * task has completed, either the result value or exception can be a non-null value.
         * This allows you to pass exceptions to the UI thread that were thrown during doInBackground().
         */
        internal class Result {
            var resultValue: String? = null
            var exception: Exception? = null

            constructor(resultValue: String) {
                this.resultValue = resultValue
            }

            constructor(exception: Exception) {
                this.exception = exception
            }
        }

        /**
         * Cancel background network operation if we do not have network connectivity.
         */
        override fun onPreExecute() {
            if (callback != null) {
                callback?.getConnectivityManager()?.apply {
                    getNetworkCapabilities(activeNetwork)?.apply {
                        if (!isConnected()
                            || !hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
                            && !hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) {
                            // If no connectivity, cancel task and update Callback with null data.
                            callback?.updateFromDownload(null)
                            cancel(true)
                        }
                    }
                }
            }
        }

        /**
         * Defines work to perform on the background thread.
         */
        override fun doInBackground(vararg urls: String): DownloadTask.Result? {
            var result: Result? = null
            if (!isCancelled && urls.isNotEmpty()) {
                val urlString = urls[0]
                result = try {
                    val url = URL(urlString)
                    val resultString = downloadUrl(url)
                    if (resultString != null) {
                        Result(resultString)
                    } else {
                        throw IOException("No response received.")
                    }
                } catch (e: Exception) {
                    Result(e)
                }

            }
            return result
        }

        /**
         * Updates the DownloadCallback with the result.
         */
        override fun onPostExecute(result: Result?) {
            callback?.apply {
                result?.exception?.also { exception ->
                    updateFromDownload(exception.message)
                    return
                }
                result?.resultValue?.also { resultValue ->
                    updateFromDownload(resultValue)
                    return
                }
                finishDownloading()
            }
        }

        /**
         * Override to add special behavior for cancelled AsyncTask.
         */
        override fun onCancelled(result: Result) {}

        private fun isConnected(): Boolean {
            callback?.getConnectivityManager()?.apply {
                val activeNetworks = allNetworks.mapNotNull { getNetworkCapabilities(it) }.filter {
                    it.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
                            it.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
                }

                return activeNetworks.isNotEmpty()
            }

            return false
        }
    }
}

NetworkInfoを使っていた部分(onPreExecute() )を以下のように修正しています。

        // 変更前
        override fun onPreExecute() {
            if (callback != null) {
                val networkInfo = callback?.getActiveNetworkInfo()
                if (networkInfo?.isConnected == false
                    || networkInfo?.type != ConnectivityManager.TYPE_WIFI
                    && networkInfo?.type != ConnectivityManager.TYPE_MOBILE) {
                    // If no connectivity, cancel task and update Callback with null data.
                    callback?.updateFromDownload(null)
                    cancel(true)
                }
            }
        }

        // ↓

        // 変更後
        override fun onPreExecute() {
            if (callback != null) {
                callback?.getConnectivityManager()?.apply {
                    getNetworkCapabilities(activeNetwork)?.apply {
                        if (!isConnected()
                            || !hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
                            && !hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) {
                            // If no connectivity, cancel task and update Callback with null data.
                            callback?.updateFromDownload(null)
                            cancel(true)
                        }
                    }
                }
            }
        }

        private fun isConnected(): Boolean {
            callback?.getConnectivityManager()?.apply {
                val activeNetworks = allNetworks.mapNotNull { getNetworkCapabilities(it) }.filter {
                    it.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
                            it.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
                }

                return activeNetworks.isNotEmpty()
            }

            return false
        }

5. HttpsUrlConnection を使用したダウンロード処理を作成

DownloadTask のプライベート内部クラスとして実装します。

NetworkFragment.kt

class NetworkFragment : Fragment() {

    ...

    private class DownloadTask(callback: DownloadCallback<String>)
        : AsyncTask<String, Int, DownloadTask.Result>() {

        ...

        /**
         * Given a URL, sets up a connection and gets the HTTP response body from the server.
         * If the network request is successful, it returns the response body in String form. Otherwise,
         * it will throw an IOException.
         */
        @Throws(IOException::class)
        private fun downloadUrl(url: URL): String? {
            var connection: HttpsURLConnection? = null
            return try {
                connection = (url.openConnection() as? HttpsURLConnection)
                connection?.run {
                    // Timeout for reading InputStream arbitrarily set to 3000ms.
                    readTimeout = 3000
                    // Timeout for connection.connect() arbitrarily set to 3000ms.
                    connectTimeout = 3000
                    // For this use case, set HTTP method to GET.
                    requestMethod = "GET"
                    // Already true by default but setting just in case; needs to be true since this request
                    // is carrying an input (response) body.
                    doInput = true
                    // Open communications link (network traffic occurs here).
                    connect()
                    publishProgress(CONNECT_SUCCESS)
                    if (responseCode != HttpsURLConnection.HTTP_OK) {
                        throw IOException("HTTP error code: $responseCode")
                    }
                    // Retrieve the response body as an InputStream.
                    publishProgress(GET_INPUT_STREAM_SUCCESS, 0)
                    inputStream?.let { stream ->
                        // Converts Stream to String with max length of 500.
                        readStream(stream, 500)
                    }
                }
            } finally {
                // Close Stream and disconnect HTTPS connection.
                connection?.inputStream?.close()
                connection?.disconnect()
            }
        }

        /**
         * Converts the contents of an InputStream to a String.
         */
        @Throws(IOException::class, UnsupportedEncodingException::class)
        fun readStream(stream: InputStream, maxReadSize: Int): String? {
            val reader: Reader? = InputStreamReader(stream, "UTF-8")
            val rawBuffer = CharArray(maxReadSize)
            val buffer = StringBuffer()
            var readSize: Int = reader?.read(rawBuffer) ?: -1
            var maxReadBytes = maxReadSize
            while (readSize != -1 && maxReadBytes > 0) {
                if (readSize > maxReadBytes) {
                    readSize = maxReadBytes
                }
                buffer.append(rawBuffer, 0, readSize)
                maxReadBytes -= readSize
                readSize = reader?.read(rawBuffer) ?: -1
            }
            return buffer.toString()
        }
    }
}

6. NetworkFragment の呼び出しを実装する

Activityに NetworkFragment の呼び出しを実装します。

注意点としては、NetworkFragment.getInstance は OnCreate で行う必要があるようです。(通信する直前にgetInstanceしてもOnAttachが呼ばれません)

class MainActivity : AppCompatActivity(), DownloadCallback<String> {

    ...

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        networkFragment = NetworkFragment.getInstance(supportFragmentManager)
    }

    private fun startDownload(url: String) {
        if (!downloading) {
            // Execute the async download.
            networkFragment?.apply {
                setUrl(url)
                startDownload()
                downloading = true
            }
        }
    }
}

あとはActivityの通信したい箇所で startDownload() を呼べば Callback に結果が返ってきます。