Skip to content

Aleksei Vinogradov - Coroutines #215

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 8 commits into
base: development
Choose a base branch
from
Open
2 changes: 2 additions & 0 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,6 @@ dependencies {
implementation 'com.google.android.material:material:1.11.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'com.squareup.picasso:picasso:2.71828'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.3'
implementation 'androidx.fragment:fragment-ktx:1.8.1'
}
6 changes: 6 additions & 0 deletions app/src/main/java/otus/homework/coroutines/Cat.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package otus.homework.coroutines

data class Cat(
val fact: String,
val imageUrl: String
)
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
package otus.homework.coroutines

import retrofit2.Call
import retrofit2.http.GET

interface CatsService {
interface CatFactService {

@GET("fact")
fun getCatFact() : Call<Fact>
suspend fun getCatFact(): FactResponse?
}
9 changes: 9 additions & 0 deletions app/src/main/java/otus/homework/coroutines/CatImageService.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package otus.homework.coroutines

import retrofit2.http.GET

interface CatImageService {

@GET("images/search")
suspend fun getCatImage(): List<ImageResponse?>?
}
16 changes: 16 additions & 0 deletions app/src/main/java/otus/homework/coroutines/CatsCoroutineContext.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package otus.homework.coroutines

import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineName

/**
* Вынесено отдельно, так как используется, как в презентере, так и во ViewModel
*/

fun getCatsExceptionHandler() =
CoroutineExceptionHandler { _, e ->
CrashMonitor.trackWarning(e)
}

fun getCatsCoroutineName() =
CoroutineName("CatsCoroutine")
98 changes: 86 additions & 12 deletions app/src/main/java/otus/homework/coroutines/CatsPresenter.kt
Original file line number Diff line number Diff line change
@@ -1,28 +1,101 @@
package otus.homework.coroutines

import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.cancelChildren
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
import java.net.SocketTimeoutException

private const val PRESENTER_CAT_JOB_KEY = "CatJob"
private const val PRESENTER_AWAIT_TIMEOUT = 20000L

class CatsPresenter(
private val catsService: CatsService
private val catFactService: CatFactService,
private val catImageService: CatImageService,
private val scope: CoroutineScope = PresenterScope(),
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
private val jobs: MutableMap<JobKey, Job> = mutableMapOf(),
/**
* Колбэк для установки флага через корутину, что данные загружены.
* Если флаг == true, то данные больше не будут загружаться в onStart().
* Флаг нужен, чтобы, если приложение ушло в onStop() до загрузки данных,
* (флаг == false), а корутины отменились,
* то приложение в onStart() попыталось загрузить данные еще раз,
* а пользователь не остался с пустым экраном.
* Должно быть только параметром, не переменной.
* Локальная переменная создается дальше, чтобы ее можно было занулить
*/
isDataLoadedCallbaсk: (Boolean) -> Unit
) {

private var _catsView: ICatsView? = null

/**
* Локальная копия коллбэка для того,
* чтобы занулить ее при необходимости
* и минимизировать шанс утечки
* */
private var isDataLoadedCallback: ((Boolean) -> Unit)? = isDataLoadedCallbaсk

fun onInitComplete() {
catsService.getCatFact().enqueue(object : Callback<Fact> {
/** Если корутина уже запущена, то ничего не делаем */
if (jobs.getOrDefault(PRESENTER_CAT_JOB_KEY, null)?.isActive == true) return

override fun onResponse(call: Call<Fact>, response: Response<Fact>) {
if (response.isSuccessful && response.body() != null) {
_catsView?.populate(response.body()!!)
}
/** Добавляем Job в мапу по ключу */
jobs[PRESENTER_CAT_JOB_KEY] = scope.launch {
val factResponseDeffered = scope.async(ioDispatcher) {
catFactService.getCatFact()
}
val imageResponseDeffered = scope.async(ioDispatcher) {
catImageService.getCatImage()
}

/** Timeout, чтобы не ждать ответа бесконечно */
withTimeout(PRESENTER_AWAIT_TIMEOUT) {
val result = try {
/** Тестовое пробрасывание SocketTimeoutException для проверки */
// throw SocketTimeoutException()

val factResponse = factResponseDeffered.await()
?: return@withTimeout
val imageResponse = imageResponseDeffered.await()
?: return@withTimeout
val imageResponseFirstElement = imageResponse.firstOrNull()
?: return@withTimeout

override fun onFailure(call: Call<Fact>, t: Throwable) {
CrashMonitor.trackWarning()
val cat = mapServerResponseToCat(
factResponse = factResponse,
imageResponse = imageResponseFirstElement
)

Result.Success<Cat>(cat)
} catch (e: CancellationException) {
throw e
} catch (e: SocketTimeoutException) {
Result.Error.SocketError
} catch (e: Throwable) {
CrashMonitor.trackWarning(e)
Result.Error.OtherError(e)
}

/**
* Логика показа Тоста находится внутри View,
* работает в зависимости от результата
*/
_catsView?.populate(result)
isDataLoadedCallback?.invoke(true)
}
})
}
}

fun cancelAllCoroutines() {
scope.coroutineContext.cancelChildren()
jobs.clear()
}

fun attachView(catsView: ICatsView) {
Expand All @@ -31,5 +104,6 @@ class CatsPresenter(

fun detachView() {
_catsView = null
isDataLoadedCallback = null
}
}
61 changes: 57 additions & 4 deletions app/src/main/java/otus/homework/coroutines/CatsView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,31 +2,84 @@ package otus.homework.coroutines

import android.content.Context
import android.util.AttributeSet
import android.util.TypedValue
import android.widget.Button
import android.widget.ImageView
import android.widget.TextView
import android.widget.Toast
import androidx.constraintlayout.widget.ConstraintLayout
import com.squareup.picasso.Picasso

/**
* Считаю, что методы во вью не нужно делать suspend.
* Если ответ от сервера получен, то нет смысла создавать точку приостановки в данных функциях,
* это лишняя генерация кода под капотом в suspend функциях.
*/
class CatsView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : ConstraintLayout(context, attrs, defStyleAttr), ICatsView {

var presenter :CatsPresenter? = null
var presenter: CatsPresenter? = null

var viewModel: CatsViewModel? = null

override fun onFinishInflate() {
super.onFinishInflate()
findViewById<Button>(R.id.button).setOnClickListener {
presenter?.onInitComplete()
viewModel?.onInitComplete()
}
}

override fun populate(result: Result) {
when (result) {
is Result.Success<*> -> {
val cat = result.body as Cat
setImage(
imageUrl = cat.imageUrl,
imageView = findViewById<ImageView>(R.id.image)
)
findViewById<TextView>(R.id.fact_textView).text = cat.fact
}

Result.Error.SocketError -> {
val errorMessage = context.getString(R.string.no_internet_connection)
showToast(errorMessage)
}

is Result.Error.OtherError -> {
val errorMessage = result.e.message
?: context.getString(R.string.unknown_error)
showToast(errorMessage)
}
}
}

override fun populate(fact: Fact) {
findViewById<TextView>(R.id.fact_textView).text = fact.fact
private fun setImage(imageUrl: String, imageView: ImageView) {
val requiredSizeInPixels = dpToPixels(400F)
Picasso.get()
.load(imageUrl)
.resize(requiredSizeInPixels, requiredSizeInPixels)
.centerInside()
.into(imageView)
}

private fun dpToPixels(dp: Float): Int {
return TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP,
dp,
context.resources.displayMetrics
).toInt()
}

private fun showToast(message: String) {
Toast.makeText(context, message, Toast.LENGTH_LONG).show()
}
}

interface ICatsView {

fun populate(fact: Fact)
fun populate(result: Result)
}
Loading