Kriptofolio 앱 시리즈 — 5부
오늘날 거의 모든 Android 앱은 데이터를 가져오거나 보내기 위해 인터넷에 연결합니다. RESTful 웹 서비스를 올바르게 구현하는 것이 최신 앱을 만드는 동안 핵심 지식이기 때문에 RESTful 웹 서비스를 처리하는 방법을 반드시 배워야 합니다.
이 부분이 복잡해집니다. 작업 결과를 얻기 위해 한 번에 여러 라이브러리를 결합할 것입니다. 실세계에서는 아무도 그것을 사용하지 않기 때문에 인터넷 요청을 처리하는 기본 Android 방식에 대해서는 이야기하지 않을 것입니다. 모든 좋은 앱은 바퀴를 재발명하려고 하지 않고 대신 가장 인기 있는 타사 라이브러리를 사용하여 일반적인 문제를 해결합니다. 이러한 잘 만들어진 라이브러리가 제공해야 하는 기능을 다시 만드는 것은 너무 복잡합니다.
시리즈 콘텐츠
- 소개:2018–2019년 최신 Android 앱을 구축하기 위한 로드맵
- 1부:SOLID 원칙 소개
- 2부:Android 앱 빌드 시작 방법:목업, UI 및 XML 레이아웃 만들기
- 3부:아키텍처에 관한 모든 것:다양한 아키텍처 패턴 탐색 및 앱에서 이를 사용하는 방법
- 4부:Dagger 2를 사용하여 앱에서 종속성 주입을 구현하는 방법
- 5부:Retrofit, OkHttp, Gson, Glide 및 Coroutine을 사용하여 RESTful 웹 서비스 처리(현재 위치)
Retrofit, OkHttp 및 Gson이란 무엇입니까?
Retrofit은 Java 및 Android용 REST 클라이언트입니다. 내 생각에 이 라이브러리는 주요 작업을 수행하므로 배워야 할 가장 중요한 라이브러리입니다. REST 기반 웹 서비스를 통해 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 라이브러리를 사용하여 변환합니다.
먼저 사용해보기 위한 Retrofit 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 종속성과 함께 이미 제공됩니다. 버전은 편의를 위한 별도의 gradle 파일일 뿐입니다.
def versions = [:]
versions.retrofit = "2.4.0"
ext.versions = versions
다음으로 Retrofit 인터페이스 설정
요청과 해당 유형을 선언하는 인터페이스입니다. 여기에서 클라이언트 측에서 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 및 운영 체제 상호 작용을 처리하는 논리만 포함해야 합니다.
사실 이 빠른 설정 후에 많은 작업을 하고 많은 변경을 했습니다. 글에 변경된 코드를 다 넣어도 소용이 없습니다. 대신 여기에서 최종 Part 5 코드 리포지토리를 찾아보는 것이 좋습니다. 나는 모든 것을 아주 잘 설명했고 내 코드는 당신이 이해할 수 있도록 명확해야 합니다. 하지만 저는 제가 한 가장 중요한 일과 그 일을 한 이유에 대해 이야기할 것입니다.
개선을 위한 첫 번째 단계는 의존성 주입을 사용하는 것이었습니다. 이전 부분에서 이미 Dagger 2가 프로젝트 내부에 올바르게 구현되었음을 기억하십시오. 그래서 Retrofit 설정에 사용했습니다.
/**
* 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 서비스 메서드의 반환 유형으로 인터페이스.
또 다른 중요한 단계는 리포지토리 패턴 사용을 시작하는 것입니다. 3부에서 이에 대해 이야기했습니다. 해당 게시물에서 MVVM 아키텍처 스키마를 확인하여 어디로 가는지 기억하세요.
그림에서 보듯이 Repository는 데이터를 위한 별도의 레이어입니다. 데이터를 가져오거나 보낼 수 있는 단일 연락처입니다. Repository를 사용할 때 우리는 관심사 분리 원칙을 따릅니다. 다른 데이터 소스(예:SQLite 데이터베이스의 영구 데이터 및 웹 서비스의 데이터)를 가질 수 있지만 Repository는 항상 모든 앱 데이터에 대한 단일 소스가 될 것입니다.
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
작지만 매우 중요한 도우미 클래스로 로컬 데이터베이스와 웹 서비스 간의 동기화를 유지 관리할 수 있습니다. 우리의 목표는 기기가 오프라인일 때도 원활하게 작동하는 최신 애플리케이션을 구축하는 것입니다. 또한 이 클래스의 도움으로 오류 또는 로드와 같은 다양한 네트워크 상태를 사용자에게 시각적으로 표시할 수 있습니다.
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 소스를 한 번에 관찰할 수 있는 기능을 사용하여 만들어집니다. 여기에는 데이터베이스와 네트워크 호출 응답이라는 두 가지 LiveData 소스가 있습니다. 이러한 LiveData는 모두 NetworkBoundResource
에 의해 노출되는 하나의 MediatorLiveData로 래핑됩니다. .
NetworkBoundResource
우리 앱에서 작동합니다. 사용자가 앱을 실행하고 오른쪽 하단 모서리에 있는 플로팅 작업 버튼을 클릭한다고 상상해 보세요. 앱이 암호화 코인 추가 화면을 시작합니다. 이제 NetworkBoundResource
을 분석할 수 있습니다. 의 사용법입니다.
앱을 새로 설치하고 처음 실행하는 경우 로컬 데이터베이스 내부에 데이터가 저장되지 않습니다. 표시할 데이터가 없으므로 로딩 진행률 표시줄 UI가 표시됩니다. 그 동안 앱은 모든 암호화폐 목록을 가져오기 위해 웹 서비스를 통해 서버에 요청 호출을 할 것입니다.
응답이 실패하면 버튼을 눌러 통화를 다시 시도할 수 있는 오류 메시지 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
로 개조 빌더 인스턴스를 생성할 때 이미 추가했습니다. Dagger AppModule
내부 함수 provideApiService
.
이미지 로딩을 위한 글라이드
글라이드란? 문서에서:
Glide는 미디어 디코딩, 메모리 및 디스크 캐싱, 리소스 풀링을 간단하고 사용하기 쉬운 인터페이스로 래핑하는 Android용 빠르고 효율적인 오픈 소스 미디어 관리 및 이미지 로딩 프레임워크입니다.
Glide의 주요 초점은 모든 종류의 이미지 목록을 가능한 한 부드럽고 빠르게 스크롤하는 것이지만 원격 이미지를 가져와서 크기를 조정하고 표시해야 하는 거의 모든 경우에도 효과적입니다.
혼자서 개발하고 싶지 않은 많은 유용한 기능을 제공하는 복잡한 라이브러리처럼 들립니다. My Crypto Coins 앱에는 여러 암호 화폐 로고(인터넷에서 한 번에 찍은 사진)를 표시해야 하는 여러 목록 화면이 있으며 여전히 사용자에게 부드러운 스크롤 경험을 보장합니다. 따라서 이 라이브러리는 우리의 요구에 완벽하게 맞습니다. 또한 이 라이브러리는 Android 개발자들 사이에서 매우 인기가 있습니다.
My Crypto Coins 앱 프로젝트에서 Glide를 설정하는 단계:
종속성 선언
최신 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의 구현은 하나만 있을 수 있으며 AppGlideModule
은 하나만 있을 수 있습니다. 신청당.
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 사용을 시작할 수 있으며 모든 힘든 작업을 수행할 수 있습니다. 매우 간단합니다.
코틀린 코루틴
이 앱을 빌드하는 동안 데이터베이스에 데이터를 쓰거나 데이터베이스에서 읽기, 네트워크에서 데이터 가져오기 등 시간이 많이 걸리는 작업을 실행해야 하는 상황에 직면하게 됩니다. 이러한 모든 일반적인 작업은 Android 프레임워크의 기본 스레드에서 허용하는 것보다 완료하는 데 시간이 더 오래 걸립니다.
메인 스레드는 UI에 대한 모든 업데이트를 처리하는 단일 스레드입니다. 개발자는 앱이 멈추거나 애플리케이션이 응답하지 않음 대화 상자와 함께 충돌하는 것을 방지하기 위해 앱을 차단하지 않아야 합니다. Kotlin 코루틴은 메인 스레드 안전성을 도입하여 이 문제를 해결할 것입니다. My Crypto Coins 앱에 추가하고 싶은 마지막 누락 부분입니다.
코루틴은 데이터베이스 또는 네트워크 액세스와 같은 장기 실행 작업에 대한 비동기 콜백을 순차 코드로 변환하는 Kotlin 기능입니다. 코루틴을 사용하면 동기식 스타일을 사용하여 콜백 패턴을 사용하여 전통적으로 작성되었던 비동기식 코드를 작성할 수 있습니다. 함수의 반환 값은 비동기 호출의 결과를 제공합니다. 순차적으로 작성된 코드는 일반적으로 읽기 쉽고 예외와 같은 언어 기능을 사용할 수도 있습니다.
따라서 우리는 장기 실행 작업에서 결과를 사용할 수 있을 때까지 기다려야 하고 실행을 계속해야 하는 이 앱의 모든 곳에서 코루틴을 사용할 것입니다. 메인 화면에 표시되는 암호화폐에 대한 최신 데이터를 서버에서 다시 가져오기 위해 다시 시도할 ViewModel의 정확한 구현을 살펴보겠습니다.
먼저 프로젝트에 코루틴을 추가합니다.
// 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
로 표시된 함수를 호출합니다. 코루틴용. 즉, 결과가 준비될 때까지 함수가 실행을 일시 중단한 다음 결과와 함께 중단된 곳에서 다시 시작합니다. 결과를 기다리는 동안 일시 중단되고 실행 중인 스레드의 차단을 해제합니다.
또한 하나의 suspend 함수에서 다른 suspend 함수를 호출할 수 있습니다. 보시다시피 withContext
로 표시된 새 일시 중단 함수를 호출하여 이를 수행합니다. 다른 스레드에서 실행됩니다.
이 모든 코드의 아이디어는 여러 호출을 결합하여 멋진 순차 코드를 형성할 수 있다는 것입니다. 먼저 로컬 데이터베이스에서 우리가 소유한 암호화폐의 ID를 가져오도록 요청하고 응답을 기다립니다. 그것을 얻은 후에야 응답 ID를 사용하여 Retrofit에 새로 호출하여 업데이트된 암호 화폐 값을 얻습니다. 이것이 재시도 기능입니다.
해냈습니다! 최종 생각, 저장소, 앱 및 프레젠테이션
축하합니다. 끝까지 도달했다면 기쁩니다. 이 앱을 만드는 데 가장 중요한 모든 사항을 다뤘습니다. 이 부분에서 수행된 새로운 작업이 많이 있고 이 문서에서 다루지 않는 부분이 많지만 코드를 여기저기에 아주 잘 주석 처리했으므로 코드에서 길을 잃지 않도록 해야 합니다. GitHub에서 이 5부의 최종 코드를 확인하세요.
GitHub에서 소스 보기
개인적으로 가장 큰 도전은 새로운 기술을 배우는 것이 아니라 앱을 개발하는 것이 아니라 이 모든 기사를 작성하는 것이었습니다. 사실 이 챌린지를 완료한 제 자신이 너무 행복합니다. 배우고 개발하는 것은 다른 사람을 가르치는 것에 비해 쉽지만, 주제를 더 잘 이해할 수 있는 곳입니다. 새로운 것을 배우는 가장 좋은 방법을 찾고 있다면 내 조언은 즉시 스스로 무언가를 만들기 시작하는 것입니다. 많은 것을 빠르게 배울 것을 약속합니다.
이 모든 기사는 여기에서 별도의 APK 파일로 다운로드할 수 있는 "Kriptofolio"(이전의 "My Crypto Coins") 앱 버전 1.0.0을 기반으로 합니다. 하지만 스토어에서 직접 최신 앱 버전을 설치하고 평가해 주시면 매우 기쁠 것입니다.
Google Play에서 다운로드
또한 제가 이 프로젝트를 위해 만든 간단한 프레젠테이션 웹사이트를 방문하시기 바랍니다.
Kriptofolio.app
아츄! 읽어 주셔서 감사합니다! 저는 원래 2019년 5월 11일 개인 블로그 www.baruckis.com에 이 게시물을 게시했습니다.