Android
 Computer >> コンピューター >  >> システム >> Android

Retrofit、OkHttp、Gson、Glide、およびコルーチンを使用して RESTful Web サービスを処理する方法

Kriptofolio アプリ シリーズ — パート 5

最近では、ほぼすべての Android アプリがインターネットに接続してデータを取得/送信しています。最新のアプリを作成する際には、RESTful Web サービスを正しく実装することが重要な知識であるため、RESTful Web サービスの処理方法を確実に学ぶ必要があります。

この部分は複雑になります。一度に複数のライブラリを結合して、機能する結果を取得します。インターネット リクエストを処理するネイティブな Android の方法については説明しません。現実の世界では誰も使用していないからです。すべての優れたアプリは車輪の再発明を試みるのではなく、最も人気のあるサードパーティ ライブラリを使用して一般的な問題を解決します。これらのよくできたライブラリが提供しなければならない機能を再現するのは、あまりにも複雑です。

シリーズ コンテンツ

  • はじめに:2018 ~ 2019 年に最新の Android アプリを構築するためのロードマップ
  • パート 1:SOLID 原則の紹介
  • パート 2:Android アプリの作成方法:モックアップ、UI、XML レイアウトの作成
  • パート 3:アーキテクチャのすべて:さまざまなアーキテクチャ パターンとアプリでの使用方法を探る
  • パート 4:Dagger 2 を使用してアプリに依存性注入を実装する方法
  • パート 5:Retrofit、OkHttp、Gson、Glide、およびコルーチンを使用して RESTful Web サービスを処理する (ここにいます)

Retrofit、OkHttp、Gson とは?

Retrofit は、Java および Android 用の REST クライアントです。私の意見では、このライブラリは主な仕事をするので、学ぶべき最も重要なものです。 REST ベースの Web サービスを介して JSON (またはその他の構造化データ) を比較的簡単に取得およびアップロードできます。

Retrofit では、データのシリアル化に使用するコンバーターを構成します。通常、JSON との間でオブジェクトをシリアライズおよびデシリアライズするには、オープンソースの Java ライブラリである Gson を使用します。また、必要に応じてカスタム コンバーターを Retrofit に追加して、XML やその他のプロトコルを処理することもできます。

HTTP リクエストを作成するために、Retrofit は OkHttp ライブラリを使用します。 OkHttp は純粋な HTTP/SPDY クライアントであり、低レベルのネットワーク操作、キャッシュ、リクエスト、およびレスポンスの操作を担当します。対照的に、Retrofit は OkHttp の上に構築された高レベルの REST 抽象化です。 Retrofit は OkHttp と強く結びついており、それを集中的に利用しています。

すべてが密接に関連していることがわかったので、これら 3 つのライブラリをすべて一度に使用します。私たちの最初の目標は、Retrofit を使用してインターネットからすべての暗号通貨のリストを取得することです。サーバーを呼び出すときに、CoinMarketCap API 認証用の特別な OkHttp インターセプター クラスを使用します。 JSON データの結果を取得し、Gson ライブラリを使用して変換します。

最初に試すためのレトロフィット 2 のクイック セットアップ

新しいことを学ぶときは、できるだけ早く実際に試してみたいと思っています。より迅速に理解できるように、Retrofit 2 にも同様のアプローチを適用します。コードの品質やプログラミングの原則や最適化について今は心配する必要はありません。プロジェクトで Retrofit 2 を機能させるコードを書き、それが何をするかについて説明します。

My Crypto Coins アプリ プロジェクトで Retrofit 2 をセットアップするには、次の手順に従います。

まず、アプリにインターネット アクセス許可を付与します

インターネット経由でアクセスできるサーバーで HTTP リクエストを実行します。次の行をマニフェスト ファイルに追加して、この許可を与えます。

<manifest xmlns:android="https://schemas.android.com/apk/res/android"
    package="com.baruckis.mycryptocoins">

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

次に、ライブラリの依存関係を追加する必要があります

最新の Retrofit バージョンを見つけます。また、Retrofit には統合された JSON コンバーターが同梱されていないことも知っておく必要があります。 JSON 形式で応答を取得するため、依存関係にも手動でコンバーターを含める必要があります。最新の Google の JSON コンバーター Gson バージョンを使用します。これらの行を gradle ファイルに追加しましょう:

// 3rd party
// HTTP client - Retrofit with OkHttp
implementation "com.squareup.retrofit2:retrofit:$versions.retrofit"
// JSON converter Gson for JSON to Java object mapping
implementation "com.squareup.retrofit2:converter-gson:$versions.retrofit"

私のコメントから気づいたように、OkHttp 依存関係は Retrofit 2 依存関係に既に同梱されています。 Versions は便宜上、別の gradle ファイルにすぎません:

def versions = [:]

versions.retrofit = "2.4.0"

ext.versions = versions

次にレトロフィット インターフェースをセットアップします

これは、リクエストとそのタイプを宣言するインターフェースです。ここでは、クライアント側で API を定義します。

/**
 * REST API access points.
 */
interface ApiService {

    // The @GET annotation tells retrofit that this request is a get type request.
    // The string value tells retrofit that the path of this request is
    // baseUrl + v1/cryptocurrency/listings/latest + query parameter.
    @GET("v1/cryptocurrency/listings/latest")
    // Annotation @Query is used to define query parameter for request. Finally the request url will
    // look like that https://sandbox-api.coinmarketcap.com/v1/cryptocurrency/listings/latest?convert=EUR.
    fun getAllCryptocurrencies(@Query("convert") currency: String): Call<CryptocurrenciesLatest>
    // The return type for this function is Call with its type CryptocurrenciesLatest.
}

データ クラスを設定します

データ クラスは、これから行う API 呼び出しの応答を表す POJO (Plain Old Java Objects) です。

/**
 * Data class to handle the response from the server.
 */
data class CryptocurrenciesLatest(
        val status: Status,
        val data: List<Data>
) {

    data class Data(
            val id: Int,
            val name: String,
            val symbol: String,
            val slug: String,
            // The annotation to a model property lets you pass the serialized and deserialized
            // name as a string. This is useful if you don't want your model class and the JSON
            // to have identical naming.
            @SerializedName("circulating_supply")
            val circulatingSupply: Double,
            @SerializedName("total_supply")
            val totalSupply: Double,
            @SerializedName("max_supply")
            val maxSupply: Double,
            @SerializedName("date_added")
            val dateAdded: String,
            @SerializedName("num_market_pairs")
            val numMarketPairs: Int,
            @SerializedName("cmc_rank")
            val cmcRank: Int,
            @SerializedName("last_updated")
            val lastUpdated: String,
            val quote: Quote
    ) {

        data class Quote(
                // For additional option during deserialization you can specify value or alternative
                // values. Gson will check the JSON for all names we specify and try to find one to
                // map it to the annotated property.
                @SerializedName(value = "USD", alternate = ["AUD", "BRL", "CAD", "CHF", "CLP",
                    "CNY", "CZK", "DKK", "EUR", "GBP", "HKD", "HUF", "IDR", "ILS", "INR", "JPY",
                    "KRW", "MXN", "MYR", "NOK", "NZD", "PHP", "PKR", "PLN", "RUB", "SEK", "SGD",
                    "THB", "TRY", "TWD", "ZAR"])
                val currency: Currency
        ) {

            data class Currency(
                    val price: Double,
                    @SerializedName("volume_24h")
                    val volume24h: Double,
                    @SerializedName("percent_change_1h")
                    val percentChange1h: Double,
                    @SerializedName("percent_change_24h")
                    val percentChange24h: Double,
                    @SerializedName("percent_change_7d")
                    val percentChange7d: Double,
                    @SerializedName("market_cap")
                    val marketCap: Double,
                    @SerializedName("last_updated")
                    val lastUpdated: String
            )
        }
    }

    data class Status(
            val timestamp: String,
            @SerializedName("error_code")
            val errorCode: Int,
            @SerializedName("error_message")
            val errorMessage: String,
            val elapsed: Int,
            @SerializedName("credit_count")
            val creditCount: Int
    )
}

サーバーへの呼び出し時に認証用の特別なインターセプター クラスを作成します。サーバー

これは、正常な応答を得るために認証を必要とする API に特有のケースです。インターセプターは、リクエストをカスタマイズするための強力な方法です。実際のリクエストをインターセプトし、個々のリクエスト ヘッダーを追加します。これにより、CoinMarketCap Professional API 開発者ポータルが提供する API キーで呼び出しが検証されます。取得するには、そこで登録する必要があります。

/**
 * Interceptor used to intercept the actual request and
 * to supply your API Key in REST API calls via a custom header.
 */
class AuthenticationInterceptor : Interceptor {

    override fun intercept(chain: Interceptor.Chain): Response {

        val newRequest = chain.request().newBuilder()
                // TODO: Use your API Key provided by CoinMarketCap Professional API Developer Portal.
                .addHeader("X-CMC_PRO_API_KEY", "CMC_PRO_API_KEY")
                .build()

        return chain.proceed(newRequest)
    }
}

最後に、このコードをアクティビティに追加して Retrofit の動作を確認します

一刻も早く手を汚したかったので、一箇所にまとめました。これは正しい方法ではありませんが、視覚的な結果をすばやく確認するのが最も速い方法です。

class AddSearchActivity : AppCompatActivity(), Injectable {

    private lateinit var listView: ListView
    private lateinit var listAdapter: AddSearchListAdapter

    ...

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        ...

        // Later we will setup Retrofit correctly, but for now we do all in one place just for quick start.
        setupRetrofitTemporarily()
    }

    ...

    private fun setupRetrofitTemporarily() {

        // We need to prepare a custom OkHttp client because need to use our custom call interceptor.
        // to be able to authenticate our requests.
        val builder = OkHttpClient.Builder()
        // We add the interceptor to OkHttpClient.
        // It will add authentication headers to every call we make.
        builder.interceptors().add(AuthenticationInterceptor())
        val client = builder.build()


        val api = Retrofit.Builder() // Create retrofit builder.
                .baseUrl("https://sandbox-api.coinmarketcap.com/") // Base url for the api has to end with a slash.
                .addConverterFactory(GsonConverterFactory.create()) // Use GSON converter for JSON to POJO object mapping.
                .client(client) // Here we set the custom OkHttp client we just created.
                .build().create(ApiService::class.java) // We create an API using the interface we defined.


        val adapterData: MutableList<Cryptocurrency> = ArrayList<Cryptocurrency>()

        val currentFiatCurrencyCode = "EUR"

        // Let's make asynchronous network request to get all latest cryptocurrencies from the server.
        // For query parameter we pass "EUR" as we want to get prices in euros.
        val call = api.getAllCryptocurrencies("EUR")
        val result = call.enqueue(object : Callback<CryptocurrenciesLatest> {

            // You will always get a response even if something wrong went from the server.
            override fun onFailure(call: Call<CryptocurrenciesLatest>, t: Throwable) {

                Snackbar.make(findViewById(android.R.id.content),
                        // Throwable will let us find the error if the call failed.
                        "Call failed! " + t.localizedMessage,
                        Snackbar.LENGTH_INDEFINITE).show()
            }

            override fun onResponse(call: Call<CryptocurrenciesLatest>, response: Response<CryptocurrenciesLatest>) {

                // Check if the response is successful, which means the request was successfully
                // received, understood, accepted and returned code in range [200..300).
                if (response.isSuccessful) {

                    // If everything is OK, let the user know that.
                    Toast.makeText(this@AddSearchActivity, "Call OK.", Toast.LENGTH_LONG).show();

                    // Than quickly map server response data to the ListView adapter.
                    val cryptocurrenciesLatest: CryptocurrenciesLatest? = response.body()
                    cryptocurrenciesLatest!!.data.forEach {
                        val cryptocurrency = Cryptocurrency(it.name, it.cmcRank.toShort(),
                                0.0, it.symbol, currentFiatCurrencyCode, it.quote.currency.price,
                                0.0, it.quote.currency.percentChange1h,
                                it.quote.currency.percentChange7d, it.quote.currency.percentChange24h,
                                0.0)
                        adapterData.add(cryptocurrency)
                    }

                    listView.visibility = View.VISIBLE
                    listAdapter.setData(adapterData)

                }
                // Else if the response is unsuccessful it will be defined by some special HTTP
                // error code, which we can show for the user.
                else Snackbar.make(findViewById(android.R.id.content),
                        "Call error with HTTP status code " + response.code() + "!",
                        Snackbar.LENGTH_INDEFINITE).show()

            }

        })

    }

   ...
}

ここでコードを調べることができます。これは、アイデアをよりよく理解するための最初の単純化された実装バージョンにすぎないことを忘れないでください。

OkHttp 3 と Gson を使用した Retrofit 2 の最終的な正しいセットアップ

簡単な実験の後、この Retrofit の実装を次のレベルに引き上げる時が来ました。すでにデータは正常に取得されていますが、正しくありません。読み込み中、エラー、成功などの状態がありません。私たちのコードは、懸念事項を分離することなく混合されています。すべてのコードをアクティビティまたはフラグメントに記述するのはよくある間違いです。アクティビティ クラスは UI ベースであり、UI とオペレーティング システムの対話を処理するロジックのみを含める必要があります。

実際、この簡単なセットアップの後、私は多くの作業を行い、多くの変更を加えました。変更したすべてのコードを記事に載せても意味がありません。代わりに、ここでパート 5 の最終的なコード リポジトリを参照することをお勧めします。私はすべてに非常によくコメントしました。私のコードはあなたが理解できるように明確でなければなりません。しかし、私が行った最も重要なことと、それらを行った理由についてお話しします。

改善するための最初のステップは、依存性注入の使用を開始することでした。前の部分から、プロジェクト内にすでに Dagger 2 が正しく実装されていることを思い出してください。それで、レトロフィットのセットアップに使用しました。

/**
 * AppModule will provide app-wide dependencies for a part of the application.
 * It should initialize objects used across our application, such as Room database, Retrofit, Shared Preference, etc.
 */
@Module(includes = [ViewModelsModule::class])
class AppModule() {
    ...

    @Provides
    @Singleton
    fun provideHttpClient(): OkHttpClient {
        // We need to prepare a custom OkHttp client because need to use our custom call interceptor.
        // to be able to authenticate our requests.
        val builder = OkHttpClient.Builder()
        // We add the interceptor to OkHttpClient.
        // It will add authentication headers to every call we make.
        builder.interceptors().add(AuthenticationInterceptor())

        // Configure this client not to retry when a connectivity problem is encountered.
        builder.retryOnConnectionFailure(false)

        // Log requests and responses.
        // Add logging as the last interceptor, because this will also log the information which
        // you added or manipulated with previous interceptors to your request.
        builder.interceptors().add(HttpLoggingInterceptor().apply {
            // For production environment to enhance apps performance we will be skipping any
            // logging operation. We will show logs just for debug builds.
            level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BODY else HttpLoggingInterceptor.Level.NONE
        })
        return builder.build()
    }

    @Provides
    @Singleton
    fun provideApiService(httpClient: OkHttpClient): ApiService {
        return Retrofit.Builder() // Create retrofit builder.
                .baseUrl(API_SERVICE_BASE_URL) // Base url for the api has to end with a slash.
                .addConverterFactory(GsonConverterFactory.create()) // Use GSON converter for JSON to POJO object mapping.
                .addCallAdapterFactory(LiveDataCallAdapterFactory())
                .client(httpClient) // Here we set the custom OkHttp client we just created.
                .build().create(ApiService::class.java) // We create an API using the interface we defined.
    }

    ...
}

ご覧のとおり、Retrofit は本来あるべきアクティビティ クラスから分離されています。一度だけ初期化され、アプリ全体で使用されます。

Retrofit ビルダー インスタンスの作成中にお気づきかもしれませんが、addCallAdapterFactory を使用して特別な Retrofit 呼び出しアダプターを追加しました。 .デフォルトでは、Retrofit は Call<T> を返します ですが、私たちのプロジェクトでは LiveData<T> を返す必要があります タイプ。そのためには LiveDataCallAdapter を追加する必要があります LiveDataCallAdapterFactory を使用して .

/**
 * A Retrofit adapter that converts the Call into a LiveData of ApiResponse.
 * @param <R>
</R> */
class LiveDataCallAdapter<R>(private val responseType: Type) :
        CallAdapter<R, LiveData<ApiResponse<R>>> {

    override fun responseType() = responseType

    override fun adapt(call: Call<R>): LiveData<ApiResponse<R>> {
        return object : LiveData<ApiResponse<R>>() {
            private var started = AtomicBoolean(false)
            override fun onActive() {
                super.onActive()
                if (started.compareAndSet(false, true)) {
                    call.enqueue(object : Callback<R> {
                        override fun onResponse(call: Call<R>, response: Response<R>) {
                            postValue(ApiResponse.create(response))
                        }

                        override fun onFailure(call: Call<R>, throwable: Throwable) {
                            postValue(ApiResponse.create(throwable))
                        }
                    })
                }
            }
        }
    }
}
class LiveDataCallAdapterFactory : CallAdapter.Factory() {
    override fun get(
            returnType: Type,
            annotations: Array<Annotation>,
            retrofit: Retrofit
    ): CallAdapter<*, *>? {
        if (CallAdapter.Factory.getRawType(returnType) != LiveData::class.java) {
            return null
        }
        val observableType = CallAdapter.Factory.getParameterUpperBound(0, returnType as ParameterizedType)
        val rawObservableType = CallAdapter.Factory.getRawType(observableType)
        if (rawObservableType != ApiResponse::class.java) {
            throw IllegalArgumentException("type must be a resource")
        }
        if (observableType !is ParameterizedType) {
            throw IllegalArgumentException("resource must be parameterized")
        }
        val bodyType = CallAdapter.Factory.getParameterUpperBound(0, observableType)
        return LiveDataCallAdapter<Any>(bodyType)
    }
}

これで LiveData<T> が得られます Call<T> の代わりに ApiService で定義された Retrofit サービス メソッドからの戻り値の型として

もう 1 つの重要なステップは、Repository パターンの使用を開始することです。これについてはパート 3 で説明しました。その記事の MVVM アーキテクチャ スキーマをチェックして、どこに行くのかを覚えておいてください。

Retrofit、OkHttp、Gson、Glide、およびコルーチンを使用して RESTful Web サービスを処理する方法

画像でわかるように、リポジトリはデータ用の別のレイヤーです。これは、データを取得または送信するための唯一の連絡先です。リポジトリを使用する場合、関心の分離の原則に従います。さまざまなデータ ソースを使用できます (この場合、SQLite データベースからの永続データと Web サービスからのデータのように) が、リポジトリは常にすべてのアプリ データの信頼できる唯一のソースになります。

Retrofit 実装と直接通信する代わりに、Repository を使用します。エンティティの種類ごとに、個別のリポジトリを作成します。

/**
 * The class for managing multiple data sources.
 */
@Singleton
class CryptocurrencyRepository @Inject constructor(
        private val context: Context,
        private val appExecutors: AppExecutors,
        private val myCryptocurrencyDao: MyCryptocurrencyDao,
        private val cryptocurrencyDao: CryptocurrencyDao,
        private val api: ApiService,
        private val sharedPreferences: SharedPreferences
) {

    // Just a simple helper variable to store selected fiat currency code during app lifecycle.
    // It is needed for main screen currency spinner. We set it to be same as in shared preferences.
    var selectedFiatCurrencyCode: String = getCurrentFiatCurrencyCode()


    ...
  

    // The Resource wrapping of LiveData is useful to update the UI based upon the state.
    fun getAllCryptocurrencyLiveDataResourceList(fiatCurrencyCode: String, shouldFetch: Boolean = false, callDelay: Long = 0): LiveData<Resource<List<Cryptocurrency>>> {
        return object : NetworkBoundResource<List<Cryptocurrency>, CoinMarketCap<List<CryptocurrencyLatest>>>(appExecutors) {

            // Here we save the data fetched from web-service.
            override fun saveCallResult(item: CoinMarketCap<List<CryptocurrencyLatest>>) {

                val list = getCryptocurrencyListFromResponse(fiatCurrencyCode, item.data, item.status?.timestamp)

                cryptocurrencyDao.reloadCryptocurrencyList(list)
                myCryptocurrencyDao.reloadMyCryptocurrencyList(list)
            }

            // Returns boolean indicating if to fetch data from web or not, true means fetch the data from web.
            override fun shouldFetch(data: List<Cryptocurrency>?): Boolean {
                return data == null || shouldFetch
            }

            override fun fetchDelayMillis(): Long {
                return callDelay
            }

            // Contains the logic to get data from the Room database.
            override fun loadFromDb(): LiveData<List<Cryptocurrency>> {

                return Transformations.switchMap(cryptocurrencyDao.getAllCryptocurrencyLiveDataList()) { data ->
                    if (data.isEmpty()) {
                        AbsentLiveData.create()
                    } else {
                        cryptocurrencyDao.getAllCryptocurrencyLiveDataList()
                    }
                }
            }

            // Contains the logic to get data from web-service using Retrofit.
            override fun createCall(): LiveData<ApiResponse<CoinMarketCap<List<CryptocurrencyLatest>>>> = api.getAllCryptocurrencies(fiatCurrencyCode)

        }.asLiveData()
    }


    ...


    fun getCurrentFiatCurrencyCode(): String {
        return sharedPreferences.getString(context.resources.getString(R.string.pref_fiat_currency_key), context.resources.getString(R.string.pref_default_fiat_currency_value))
                ?: context.resources.getString(R.string.pref_default_fiat_currency_value)
    }


    ...


    private fun getCryptocurrencyListFromResponse(fiatCurrencyCode: String, responseList: List<CryptocurrencyLatest>?, timestamp: Date?): ArrayList<Cryptocurrency> {

        val cryptocurrencyList: MutableList<Cryptocurrency> = ArrayList()

        responseList?.forEach {
            val cryptocurrency = Cryptocurrency(it.id, it.name, it.cmcRank.toShort(),
                    it.symbol, fiatCurrencyCode, it.quote.currency.price,
                    it.quote.currency.percentChange1h,
                    it.quote.currency.percentChange7d, it.quote.currency.percentChange24h, timestamp)
            cryptocurrencyList.add(cryptocurrency)
        }

        return cryptocurrencyList as ArrayList<Cryptocurrency>
    }

}

CryptocurrencyRepository でお気づきのように クラスコード、私は NetworkBoundResource を使用しています 抽象クラス。それは何ですか?なぜそれが必要なのですか?

NetworkBoundResource ローカル データベースと Web サービス間の同期を維持できる、小さいながらも非常に重要なヘルパー クラスです。私たちの目標は、デバイスがオフラインの場合でもスムーズに動作する最新のアプリケーションを構築することです。また、このクラスの助けを借りて、エラーやロードなどのさまざまなネットワーク状態をユーザーに視覚的に提示することができます。

NetworkBoundResource リソースのデータベースを観察することから始めます。エントリがデータベースから初めてロードされるとき、結果がディスパッチするのに十分であるかどうか、またはネットワークから再取得する必要があるかどうかがチェックされます。ネットワークからの更新中にキャッシュされたデータを表示したい場合、これらの状況は両方とも同時に発生する可能性があることに注意してください。

ネットワーク呼び出しが正常に完了すると、応答がデータベースに保存され、ストリームが再初期化されます。ネットワーク リクエストが失敗した場合、NetworkBoundResource 失敗を直接ディスパッチします。

/**
 * A generic class that can provide a resource backed by both the sqlite database and the network.
 *
 *
 * You can read more about it in the [Architecture
 * Guide](https://developer.android.com/arch).
 * @param <ResultType> - Type for the Resource data.
 * @param <RequestType> - Type for the API response.
</RequestType></ResultType> */

// It defines two type parameters, ResultType and RequestType,
// because the data type returned from the API might not match the data type used locally.
abstract class NetworkBoundResource<ResultType, RequestType>
@MainThread constructor(private val appExecutors: AppExecutors) {

    // The final result LiveData.
    private val result = MediatorLiveData<Resource<ResultType>>()

    init {
        // Send loading state to UI.
        result.value = Resource.loading(null)
        @Suppress("LeakingThis")
        val dbSource = loadFromDb()
        result.addSource(dbSource) { data ->
            result.removeSource(dbSource)
            if (shouldFetch(data)) {
                fetchFromNetwork(dbSource)
            } else {
                result.addSource(dbSource) { newData ->
                    setValue(Resource.successDb(newData))
                }
            }
        }
    }

    @MainThread
    private fun setValue(newValue: Resource<ResultType>) {
        if (result.value != newValue) {
            result.value = newValue
        }
    }

    // Fetch the data from network and persist into DB and then send it back to UI.
    private fun fetchFromNetwork(dbSource: LiveData<ResultType>) {
        val apiResponse = createCall()
        // We re-attach dbSource as a new source, it will dispatch its latest value quickly.
        result.addSource(dbSource) { newData ->
            setValue(Resource.loading(newData))
        }

        // Create inner function as we want to delay it.
        fun fetch() {
            result.addSource(apiResponse) { response ->
                result.removeSource(apiResponse)
                result.removeSource(dbSource)
                when (response) {
                    is ApiSuccessResponse -> {
                        appExecutors.diskIO().execute {
                            saveCallResult(processResponse(response))
                            appExecutors.mainThread().execute {
                                // We specially request a new live data,
                                // otherwise we will get immediately last cached value,
                                // which may not be updated with latest results received from network.
                                result.addSource(loadFromDb()) { newData ->
                                    setValue(Resource.successNetwork(newData))
                                }
                            }
                        }
                    }
                    is ApiEmptyResponse -> {
                        appExecutors.mainThread().execute {
                            // reload from disk whatever we had
                            result.addSource(loadFromDb()) { newData ->
                                setValue(Resource.successDb(newData))
                            }
                        }
                    }
                    is ApiErrorResponse -> {
                        onFetchFailed()
                        result.addSource(dbSource) { newData ->
                            setValue(Resource.error(response.errorMessage, newData))
                        }
                    }
                }
            }
        }

        // Add delay before call if needed.
        val delay = fetchDelayMillis()
        if (delay > 0) {
            Handler().postDelayed({ fetch() }, delay)
        } else fetch()

    }

    // Called when the fetch fails. The child class may want to reset components
    // like rate limiter.
    protected open fun onFetchFailed() {}

    // Returns a LiveData object that represents the resource that's implemented
    // in the base class.
    fun asLiveData() = result as LiveData<Resource<ResultType>>

    @WorkerThread
    protected open fun processResponse(response: ApiSuccessResponse<RequestType>) = response.body

    // Called to save the result of the API response into the database.
    @WorkerThread
    protected abstract fun saveCallResult(item: RequestType)

    // Called with the data in the database to decide whether to fetch
    // potentially updated data from the network.
    @MainThread
    protected abstract fun shouldFetch(data: ResultType?): Boolean

    // Make a call to the server after some delay for better user experience.
    protected open fun fetchDelayMillis(): Long = 0

    // Called to get the cached data from the database.
    @MainThread
    protected abstract fun loadFromDb(): LiveData<ResultType>

    // Called to create the API call.
    @MainThread
    protected abstract fun createCall(): LiveData<ApiResponse<RequestType>>
}

内部では、NetworkBoundResource クラスは、MediatorLiveData と、複数の LiveData ソースを一度に観察するその機能を使用して作成されます。ここには、データベースとネットワーク呼び出し応答の 2 つの LiveData ソースがあります。これらの LiveData は両方とも、NetworkBoundResource によって公開される 1 つの MediatorLiveData にラップされます。 .

Retrofit、OkHttp、Gson、Glide、およびコルーチンを使用して RESTful Web サービスを処理する方法
NetworkBoundResource

NetworkBoundResource の仕組みを詳しく見てみましょう 私たちのアプリで動作します。ユーザーがアプリを起動し、右下隅にあるフローティング アクション ボタンをクリックするとします。アプリは暗号コインの追加画面を起動します。これで NetworkBoundResource を分析できます

アプリが新しくインストールされ、最初の起動である場合、ローカル データベース内に保存されているデータはありません。表示するデータがないため、読み込み進行状況バーの UI が表示されます。一方、アプリは Web サービスを介してサーバーに要求呼び出しを行い、すべての暗号通貨のリストを取得します。

応答が失敗した場合は、エラー メッセージ UI が表示され、ボタンを押して呼び出しを再試行できます。リクエスト呼び出しが最後に成功すると、レスポンス データがローカルの SQLite データベースに保存されます。

次回同じ画面に戻った場合、アプリはインターネットを再度呼び出す代わりに、データベースからデータを読み込みます。ただし、ユーザーは、pull-to-refresh 機能を実装することで、新しいデータの更新を要求できます。ネットワークコールが発生している間、古いデータ情報が表示されます。これはすべて NetworkBoundResource の助けを借りて行われます .

リポジトリと LiveDataCallAdapter で使用される別のクラス すべての「魔法」が起こる場所は ApiResponse です .実際には ApiResponse Retrofit2.Response の単純な共通ラッパーです。 各レスポンスを LiveData のインスタンスに変換するクラス

/**
 * Common class used by API responses. ApiResponse is a simple wrapper around the Retrofit2.Call
 * class that convert responses to instances of LiveData.
 * @param <CoinMarketCapType> the type of the response object
</T> */
@Suppress("unused") // T is used in extending classes
sealed class ApiResponse<CoinMarketCapType> {
    companion object {
        fun <CoinMarketCapType> create(error: Throwable): ApiErrorResponse<CoinMarketCapType> {
            return ApiErrorResponse(error.message ?: "Unknown error.")
        }

        fun <CoinMarketCapType> create(response: Response<CoinMarketCapType>): ApiResponse<CoinMarketCapType> {
            return if (response.isSuccessful) {
                val body = response.body()
                if (body == null || response.code() == 204) {
                    ApiEmptyResponse()
                } else {
                    ApiSuccessResponse(body = body)
                }
            } else {

                // Convert error response to JSON object.
                val gson = Gson()
                val type = object : TypeToken<CoinMarketCap<CoinMarketCapType>>() {}.type
                val errorResponse: CoinMarketCap<CoinMarketCapType> = gson.fromJson(response.errorBody()!!.charStream(), type)

                val msg = errorResponse.status?.errorMessage ?: errorResponse.message
                val errorMsg = if (msg.isNullOrEmpty()) {
                    response.message()
                } else {
                    msg
                }
                ApiErrorResponse(errorMsg ?: "Unknown error.")
            }
        }
    }
}

/**
 * Separate class for HTTP 204 resposes so that we can make ApiSuccessResponse's body non-null.
 */
class ApiEmptyResponse<CoinMarketCapType> : ApiResponse<CoinMarketCapType>()

data class ApiSuccessResponse<CoinMarketCapType>(val body: CoinMarketCapType) : ApiResponse<CoinMarketCapType>()

data class ApiErrorResponse<CoinMarketCapType>(val errorMessage: String) : ApiResponse<CoinMarketCapType>()

このラッパー クラス内で、応答にエラーがある場合、Gson ライブラリを使用してエラーを JSON オブジェクトに変換します。ただし、応答が成功した場合は、JSON から POJO オブジェクトへのマッピングに Gson コンバーターが使用されます。 GsonConverterFactory でレトロフィット ビルダー インスタンスを作成するときに、既に追加しています。 ダガーの中 AppModule 関数 provideApiService .

画像読み込みのためのグライド

グライドとは?ドキュメントから:

Glide は、Android 用の高速で効率的なオープンソースのメディア管理および画像読み込みフレームワークであり、メディアのデコード、メモリとディスクのキャッシュ、およびリソースのプーリングをシンプルで使いやすいインターフェイスにラップします。
Glide の主な焦点は、あらゆる種類の画像リストのスクロールを可能な限りスムーズかつ高速にすることですが、リモート画像をフェッチ、サイズ変更、および表示する必要があるほとんどすべての場合にも効果的です.

多くの便利な機能を提供する複雑なライブラリのように聞こえますが、すべてを自分で開発したくはありません。 My Crypto Coins アプリには、複数の暗号通貨のロゴ (インターネットから一度に取得した写真) を表示する必要があるいくつかのリスト画面があり、ユーザーがスムーズにスクロールできるようにします。したがって、このライブラリは私たちのニーズに完全に適合します。また、このライブラリは Android 開発者の間で非常に人気があります。

Glide on My Crypto Coins アプリ プロジェクトをセットアップする手順:

依存関係を宣言する

最新の Glide バージョンを入手してください。ここでもバージョンは別のファイルです versions.gradle

// Glide
implementation "com.github.bumptech.glide:glide:$versions.glide"
kapt "com.github.bumptech.glide:compiler:$versions.glide"
// Glide's OkHttp3 integration.
implementation "com.github.bumptech.glide:okhttp3-integration:$versions.glide"+"@aar"

プロジェクトですべてのネットワーク操作にネットワーク ライブラリ OkHttp を使用する必要があるため、デフォルトの統合ではなく、特定の Glide 統合を含める必要があります。また、Glide はインターネット経由で画像をロードするためにネットワーク リクエストを実行するため、許可 INTERNET を含める必要があります。 AndroidManifest.xml で ファイル — ただし、Retrofit セットアップで既に実行しています。

AppGlideModule を作成

これから使用する Glide v4 は、アプリケーション用に生成された API を提供します。注釈プロセッサを使用して、アプリケーションが Glide の API を拡張し、統合ライブラリによって提供されるコンポーネントを含めることを可能にする API を生成します。生成された Glide API にアクセスするアプリには、適切に注釈を付けた AppGlideModule を含める必要があります。 実装。生成された API の実装は 1 つだけで、AppGlideModule は 1 つのみです。

AppGlideModule を拡張するクラスを作成しましょう アプリ プロジェクトのどこかに:

/**
 * Glide v4 uses an annotation processor to generate an API that allows applications to access all
 * options in RequestBuilder, RequestOptions and any included integration libraries in a single
 * fluent API.
 *
 * The generated API serves two purposes:
 * Integration libraries can extend Glide’s API with custom options.
 * Applications can extend Glide’s API by adding methods that bundle commonly used options.
 *
 * Although both of these tasks can be accomplished by hand by writing custom subclasses of
 * RequestOptions, doing so is challenging and produces a less fluent API.
 */
@GlideModule
class AppGlideModule : AppGlideModule()

アプリケーションが追加の設定を変更したり、AppGlideModule のメソッドを実装したりしていなくても 、Glideを使用するにはまだ実装が必要です。 AppGlideModule のメソッドを実装する必要はありません API が生成されるようにします。 AppGlideModule を拡張する限り、クラスを空白のままにすることができます @GlideModule の注釈が付けられています .

Glide で生成された API を使用する

AppGlideModule を使用する場合 、アプリケーションは GlideApp.with() ですべてのロードを開始することで API を使用できます .これは、Glide を使用して暗号通貨のロゴを読み込み、暗号通貨の追加画面のすべての暗号通貨リストに表示する方法を示すコードです。

class AddSearchListAdapter(val context: Context, private val cryptocurrencyClickCallback: ((Cryptocurrency) -> Unit)?) : BaseAdapter() {

    ...

    override fun getView(position: Int, convertView: View?, parent: ViewGroup?): View {
        ...

        val itemBinding: ActivityAddSearchListItemBinding

        ...

        // We make an Uri of image that we need to load. Every image unique name is its id.
        val imageUri = Uri.parse(CRYPTOCURRENCY_IMAGE_URL).buildUpon()
                .appendPath(CRYPTOCURRENCY_IMAGE_SIZE_PX)
                .appendPath(cryptocurrency.id.toString() + CRYPTOCURRENCY_IMAGE_FILE)
                .build()

        // Glide generated API from AppGlideModule.
        GlideApp
                // We need to provide context to make a call.
                .with(itemBinding.root)
                // Here you specify which image should be loaded by providing Uri.
                .load(imageUri)
                // The way you combine and execute multiple transformations.
                // WhiteBackground is our own implemented custom transformation.
                // CircleCrop is default transformation that Glide ships with.
                .transform(MultiTransformation(WhiteBackground(), CircleCrop()))
                // The target ImageView your image is supposed to get displayed in.
                .into(itemBinding.itemImageIcon.imageview_front)

        ...

        return itemBinding.root
    }

    ...

}

ご覧のように、ほんの数行のコードで Glide を使い始めることができ、面倒な作業はすべて Glide に任せることができます。とても簡単です。

Kotlin コルーチン

このアプリを構築している間、データベースへのデータの書き込みやデータベースからの読み取り、ネットワークからのデータのフェッチなど、時間のかかるタスクを実行する状況に直面することになります。これらの一般的なタスクはすべて、Android フレームワークのメイン スレッドで許可されているよりも完了するのに時間がかかります。

メイン スレッドは、UI のすべての更新を処理する単一のスレッドです。開発者は、アプリがフリーズしたり、[アプリケーションが応答していません] ダイアログが表示されてクラッシュしたりするのを防ぐために、ブロックしないようにする必要があります。 Kotlin コルーチンは、メイン スレッド セーフを導入することで、この問題を解決します。これは、My Crypto Coins アプリに追加したい最後の欠落部分です。

コルーチンは、データベースやネットワーク アクセスなどの長時間実行されるタスクの非同期コールバックをシーケンシャル コードに変換する Kotlin 機能です。コルーチンを使用すると、従来 Callback パターンを使用して記述されていた非同期コードを、同期スタイルを使用して記述できます。関数の戻り値は、非同期呼び出しの結果を提供します。通常、順番に書かれたコードは読みやすく、例外などの言語機能を使用することもできます。

そのため、長時間実行されるタスクから結果が得られるまで待機し、実行を継続する必要があるこのアプリのあらゆる場所でコルーチンを使用します。メイン画面に表示されている暗号通貨の最新データをサーバーから取得し直す ViewModel の 1 つの正確な実装を見てみましょう。

最初にコルーチンをプロジェクトに追加します:

// Coroutines support libraries for Kotlin.

// Dependencies for coroutines.
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$versions.coroutines"

// Dependency is for the special UI context that can be passed to coroutine builders that use
// the main thread dispatcher to dispatch events on the main thread.
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$versions.coroutines"

次に、この場合のコルーチンのような共通の機能を持つ必要がある ViewModel に使用される基本クラスになる抽象クラスを作成します。

abstract class BaseViewModel : ViewModel() {

    // In Kotlin, all coroutines run inside a CoroutineScope.
    // A scope controls the lifetime of coroutines through its job.
    private val viewModelJob = Job()
    // Since uiScope has a default dispatcher of Dispatchers.Main, this coroutine will be launched
    // in the main thread.
    val uiScope = CoroutineScope(Dispatchers.Main + viewModelJob)


    // onCleared is called when the ViewModel is no longer used and will be destroyed.
    // This typically happens when the user navigates away from the Activity or Fragment that was
    // using the ViewModel.
    override fun onCleared() {
        super.onCleared()
        // When you cancel the job of a scope, it cancels all coroutines started in that scope.
        // It's important to cancel any coroutines that are no longer required to avoid unnecessary
        // work and memory leaks.
        viewModelJob.cancel()
    }
}

ここでは、ジョブを通じてコルーチンの寿命を制御する特定のコルーチン スコープを作成します。ご覧のとおり、スコープを使用すると、コルーチンを実行するスレッドを制御するデフォルトのディスパッチャを指定できます。 ViewModel が使用されなくなったら、viewModelJob をキャンセルします これにより、すべてのコルーチンが uiScope で開始されました もキャンセルされます。

最後に、再試行機能を実装します:

/**
 * The ViewModel class is designed to store and manage UI-related data in a lifecycle conscious way.
 * The ViewModel class allows data to survive configuration changes such as screen rotations.
 */

// ViewModel will require a CryptocurrencyRepository so we add @Inject code into ViewModel constructor.
class MainViewModel @Inject constructor(val context: Context, val cryptocurrencyRepository: CryptocurrencyRepository) : BaseViewModel() {

    ...

    val mediatorLiveDataMyCryptocurrencyResourceList = MediatorLiveData<Resource<List<MyCryptocurrency>>>()
    private var liveDataMyCryptocurrencyResourceList: LiveData<Resource<List<MyCryptocurrency>>>
    private val liveDataMyCryptocurrencyList: LiveData<List<MyCryptocurrency>>

    ...

    // This is additional helper variable to deal correctly with currency spinner and preference.
    // It is kept inside viewmodel not to be lost because of fragment/activity recreation.
    var newSelectedFiatCurrencyCode: String? = null

    // Helper variable to store state of swipe refresh layout.
    var isSwipeRefreshing: Boolean = false


    init {
        ...

        // Set a resource value for a list of cryptocurrencies that user owns.
        liveDataMyCryptocurrencyResourceList = cryptocurrencyRepository.getMyCryptocurrencyLiveDataResourceList(cryptocurrencyRepository.getCurrentFiatCurrencyCode())


        // Declare additional variable to be able to reload data on demand.
        mediatorLiveDataMyCryptocurrencyResourceList.addSource(liveDataMyCryptocurrencyResourceList) {
            mediatorLiveDataMyCryptocurrencyResourceList.value = it
        }

        ...
    }

   ...

    /**
     * On retry we need to run sequential code. First we need to get owned crypto coins ids from
     * local database, wait for response and only after it use these ids to make a call with
     * retrofit to get updated owned crypto values. This can be done using Kotlin Coroutines.
     */
    fun retry(newFiatCurrencyCode: String? = null) {

        // Here we store new selected currency as additional variable or reset it.
        // Later if call to server is unsuccessful we will reuse it for retry functionality.
        newSelectedFiatCurrencyCode = newFiatCurrencyCode

        // Launch a coroutine in uiScope.
        uiScope.launch {
            // Make a call to the server after some delay for better user experience.
            updateMyCryptocurrencyList(newFiatCurrencyCode, SERVER_CALL_DELAY_MILLISECONDS)
        }
    }

    // Refresh the data from local database.
    fun refreshMyCryptocurrencyResourceList() {
        refreshMyCryptocurrencyResourceList(cryptocurrencyRepository.getMyCryptocurrencyLiveDataResourceList(cryptocurrencyRepository.getCurrentFiatCurrencyCode()))
    }

    // To implement a manual refresh without modifying your existing LiveData logic.
    private fun refreshMyCryptocurrencyResourceList(liveData: LiveData<Resource<List<MyCryptocurrency>>>) {
        mediatorLiveDataMyCryptocurrencyResourceList.removeSource(liveDataMyCryptocurrencyResourceList)
        liveDataMyCryptocurrencyResourceList = liveData
        mediatorLiveDataMyCryptocurrencyResourceList.addSource(liveDataMyCryptocurrencyResourceList)
        { mediatorLiveDataMyCryptocurrencyResourceList.value = it }
    }

    private suspend fun updateMyCryptocurrencyList(newFiatCurrencyCode: String? = null, callDelay: Long = 0) {

        val fiatCurrencyCode: String = newFiatCurrencyCode
                ?: cryptocurrencyRepository.getCurrentFiatCurrencyCode()

        isSwipeRefreshing = true

        // The function withContext is a suspend function. The withContext immediately shifts
        // execution of the block into different thread inside the block, and back when it
        // completes. IO dispatcher is suitable for execution the network requests in IO thread.
        val myCryptocurrencyIds = withContext(Dispatchers.IO) {
            // Suspend until getMyCryptocurrencyIds() returns a result.
            cryptocurrencyRepository.getMyCryptocurrencyIds()
        }

        // Here we come back to main worker thread. As soon as myCryptocurrencyIds has a result
        // and main looper is available, coroutine resumes on main thread, and
        // [getMyCryptocurrencyLiveDataResourceList] is called.
        // We wait for background operations to complete, without blocking the original thread.
        refreshMyCryptocurrencyResourceList(
                cryptocurrencyRepository.getMyCryptocurrencyLiveDataResourceList
                (fiatCurrencyCode, true, myCryptocurrencyIds, callDelay))
    }

    ...
}

ここでは、特別な Kotlin キーワード suspend でマークされた関数を呼び出します コルーチン用。これは、関数が結果の準備が整うまで実行を中断し、その後、中断したところから結果を再開することを意味します。結果を待って一時停止している間、実行中のスレッドのブロックを解除します。

また、あるサスペンド関数で別のサスペンド関数を呼び出すことができます。ご覧のとおり、withContext とマークされた新しいサスペンド関数を呼び出すことでそれを行います 別のスレッドで実行されます。

このすべてのコードのアイデアは、複数の呼び出しを組み合わせて見栄えの良い順次コードを形成できるということです。まず、所有している暗号通貨の ID をローカル データベースから取得するように要求し、応答を待ちます。それを取得した後でのみ、応答 ID を使用して Retrofit で新しい呼び出しを行い、更新された暗号通貨の値を取得します。これが再試行機能です。

完成しました!最終的な考え、リポジトリ、アプリ、プレゼンテーション

おめでとうございます、最後までたどり着けたなら幸いです。このアプリを作成するための最も重要なポイントはすべてカバーされています。このパートでは多くの新しいことが行われましたが、その多くはこの記事ではカバーされていませんが、私のコードをいたるところに非常によくコメントしているので、迷子になることはありません。このパート 5 の最終的なコードは、こちらの GitHub で確認してください:

GitHub でソースを表示。

私にとって個人的に最大の課題は、新しいテクノロジーを学ぶことでも、アプリを開発することでもなく、これらすべての記事を書くことでした。実際、私はこのチャレンジを達成できたことにとても満足しています。学習と開発は、他の人に教えるよりも簡単ですが、トピックをよりよく理解できるのはそこです。新しいことを学ぶ最善の方法を探しているなら、すぐに自分で何かを作り始めることをお勧めします。私はあなたが多くのことを素早く学ぶことを約束します.

これらの記事はすべて、「Kriptofolio」(以前の「My Crypto Coins」)アプリのバージョン 1.0.0 に基づいており、こちらから個別の APK ファイルとしてダウンロードできます。ただし、ストアから最新のアプリ バージョンを直接インストールして評価していただければ幸いです。

Google Play で入手

また、このプロジェクトのために私が作成したこの簡単なプレゼンテーション Web サイトにアクセスしてください:

Kriptofolio.app

Retrofit、OkHttp、Gson、Glide、およびコルーチンを使用して RESTful Web サービスを処理する方法

あちゅ!読んでくれてありがとう!この投稿は、2019 年 5 月 11 日に個人ブログ www.baruckis.com で最初に公開したものです。


  1. Outlook Web アプリを使用して不在時の自動返信を設定する方法

    仕事を終えて休暇に向けて家に帰る準備をしている時期です - 物理的には、最近はまったく同じかもしれませんが.お祝いの休暇を開始するための最後の障害は、不在リマインダーを設定することです。Web 上の Outlook 内で記録的な速さでそれを行う方法は次のとおりです。 まず、右上の設定アイコンをクリックします。設定フライアウトが画面の右側から表示されます。ウィンドウの下部にある [すべての Outlook 設定を表示] をクリックし、表示される [設定] ダイアログ内の [自動返信] をクリックします。 [自動応答をオンにする] トグル ボタンをクリックして、不在メッセージを有効にします

  2. ウェブとアプリの Gmail サイドバーから Google Meet を非表示にする方法

    Google は、すべてのモバイル オペレーティング システム向けの Gmail モバイル アプリの新しいアップデートを展開しています。これにより、新しくデザインされた下部ツールバーの Gmail 受信トレイに Google Meet アイコンが追加されます。ツールバーは メール と 会いましょう Gmail アカウント所有者向けのオプション。 ただし、アップデートがリリースされると、 Meet で画面のスペースが少なくなります。 アイコンは受信トレイ ページをやや乱雑にし、Gmail モバイル アプリでメールにアクセスする際に少し迷惑をかけることがあります。同様に、アプリのウェブ バ