Джуниор написал код и уехал в отпуск. Его код пришел на ревью, нужно сделать его чище и исправить явные баги, если таковые есть. Исправления в коде и улучшения нужно аргументировать, чтобы Джуниор чему-то научился.
Требуется прочитать код, понять какую логику он выполняет и что задумывал разработчик, отрефакторить и сделать чище код. Если есть явные баги - исправить. Если есть идеи по тому как сделать код лучше - реализовать их.
class MainActivity: FragmentActivity() {
@Inject
public lateinit var getContent: GetContentRepository
public lateinit var service: RequestService
public lateinit var black: CheckBlacklistService
public lateinit var analytics: AnalyticsService
private lateinit var binding: LayoutMainBinding
private val vm by lazy {
MainViewModel(intent.getStringExtra("userId"), this, getContent, service, analytics, black, lifecycleScope)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
MainActivityInjector.inject()
binding = LayoutMainBinding.inflate(layoutInflater)
setContentView(binding.root)
supportFragmentManager.beginTransaction()
.add(R.id.fragmentContainer, AdsFragment())
.commit()
lifecycleScope.launch {
vm.sharedFlow.collect {
if (it == null) {
Toast.makeText(applicationContext, "Что-то пошло не так", Toast.LENGHT_LONG).show()
} else {
binding.name.text = it.name!!
binding.lastName.animate()
binding.postDelayed({
binding.lastName.text = it.lastName!!
}, 2_000L)
}
}
}
binding.btn.setOnClickListener {
if(vm.age < 18) {
Toast.makeText(applicationContext, "Вам должно быть 18 лет", Toast.LENGHT_LONG).show()
return@setOnClickListener
}
lifecycleScope.launch {
vm.request().collect {
if(it == null) {
Toast.makeText(applicationContext, "Что-то пошло не так", Toast.LENGHT_LONG).show()
} else {
supportFragmentManager
.beginTransaction()
.add(R.id.fragmentContainer, ScooterFragment().apply {userId = vm.userId})
.commit()
}
}
}
}
}
}
class MainViewModel(
val userId: String?,
val context: Context,
val getContentProvider: GetContentRepository,
val service: RequestService,
val analytics: AnalyticsService,
val black: CheckBlacklistService,
val coroutineScope: CoroutineScope,
) {
var age = 0
val sharedFlow = MutableSharedFlow<ContentModel?>(extraBufferCapacity = 1)
init {
coroutineScope.launch {
sharedFlow.emit(
try {
val c = getContentProvider.getContent(userId ?: error("userId must set"))
age = c.age ?: 0
c.copy(
c.name ?: context.getString(R.string.name),
c.lastName ?: context.getString(R.string.last_name)
)
} catch (e: Exception) {
null
}
)
}
}
@Synchronized
fun request(): Flow<Unit>? {
return flow {
try {
analytics.requestClicked()
emit(Unit)
} catch (e: Exception) {
emit(null)
}
}.flowOn(Dispatchers.IO).flatMapConcat {
flow {
if (it != null) {
try {
service.requestScooter()
} catch (e: Exception) {
emit(null)
}
}
emit(it)
}
}.flowOn(Dispatchers.IO).flatMapConcat {
flow {
if (it != null) {
try {
black.check(userId!!)
} catch (e: Exception) {
emit(null)
}
}
emit(it)
}
}.flowOn(Dispatchers.IO)
}
}
data class ContentModel(
var name: String?,
var lastName: String?,
var age: Int?
)
interface GetContentRepository {
suspend fun getContent(id: String): ContentModel
}
interface RequestService {
@POST("scooter/request")
suspend fun requestScooter(): ScooterResponse
}
/**
* Methods can throw an Exception
*/
interface CheckBlackListService {
@POST("users/checkBlackList")
suspend fun check(userId: String)
}
interface AnalyticsService {
@GET("users/analytics/requestClicked")
suspend fun requestClicked()
}
class ScooterResponse {
val resultCode: Int
}-
Присутствуют утечки памяти. Передача
Context,ActivityиlifecycleScopeвоMainViewModelможет быть опасно, за счет того, что мы можем потенциально обратиться к ActivityContext в время уничтожения Activity (смена конфигурации). Ко всему прочему передавать весь Context со всем функционалом может быть излишне:- Получение системных сервисов по типу
VibrationManager,LocationManager,ConnectivityManagerи т.д. - Доступ ко всем ресурсам (
Strings,Values,Drawables). - Доступ к файловой системе.
- Доступ к текущей среде (
Window)
Вьюмодели в данном случае нужен только доступ к стрингам. И такой функционал можно передать используя
Interface Segregation. Создать отдельный интерфейсResourcesManagerc методомgetString(@StringRes id: Int): Stringи тогда вьюмодель будет знать только о том функционале, который нужен ему в данный момент времени. Ко всему прочему реализацию всегда можно будет подменить. - Получение системных сервисов по типу
-
Все переменные я бы называл более понятным образом.
vm->viewModel,black->blackListServiceи т.д. -
Вместо использования
FragmentActivity, я бы лучше использовалAppCompatActivityдля поддержки обратной совместимости со старыми версиями Android +AppCompatActivityнаследуется отFragmentActivity, поэтому мы ничего не теряем. -
Инъекция бизнес логики прямо в Activity:
@Inject
public lateinit var getContent: GetContentRepository
public lateinit var service: RequestService
public lateinit var black: CheckBlacklistService
public lateinit var analytics: AnalyticsServiceТак как здесь присутствует явная попытка в паттерн MVVM, то следует соблюдать все его гайдлайны, а именно: View ничего не должна знать о Model (бизнес логика) и о ViewModel. Максимум что она может, это подписаться на изменения данных, предоставляемых ViewModel.
С бизнес логикой напрямую может взаимодействовать только ViewModel, поэтому лучше все эти зависимости перенести во ViewModel. Ко всему прочему можно будет убрать MainActivityInjector.inject(), т.к. ничего инжектиться в активити больше не будет. Также эти поля публичные и изменяемые, что противоречит принципу инкапсуляции.
-
bindingлучше разделить на мутабельный и на иммутабельный, чтобы избежать случайного стирания ссылки наbinding. -
ViewModelвMainActivityинициализируется черезlazy, что не очень хорошо. Несколько причин:- когда активити уничтожиться (например смена конфигурации), объект
ViewModelостанется жить некоторое время в памяти и продолжит работу, до следующего garbage collection, что является утечкой памяти + он принимает в конструкторActivityContext, что усиливает опасность, так как потенциальноViewModelможет обратиться к уничтоженнойActivity ViewModelу нас все же должна переживать такие моменты как смена конфигурации, потому что это сулит потерей предыдущих полученный данных, следовательно придется лишний раз идти за ними. Поэтому лучше иcпользовать либоViewModelProvider, либо инжектить.
- когда активити уничтожиться (например смена конфигурации), объект
-
Собирать Flow в активити следует с помощью
repeatOnLifecycle(Lifecycle.State.STARTED). Причина: ненужная работа - без этого метода значения будут собираться вплоть доonDestroy, потенциально может случиться такая ситуация, что при эмите следующего значения, коллектор будет пытаться обновить вью которой нет или не видна. Это пустая трата ресурсов. -
Проверка на возраст в
Activity. Это больше похоже на бизнес логику, ее лучше поместить воModel, потому что неActivityдолжна принимать такие решения. -
Проверка на
nullв активити, и в зависимости от этого показывается тост. Не активити должна принимать такое решение, аViewModel. По MVVM вью должна только подписываться на изменение данные вью. Проще говоря, она не должна ничего делать кроме того, чтобы показывать то, что ей говорят и отправлять инпуты юзера во вьюмодель на обработку. Под инветы по типу "показать тост" или навигацию можно завести отдельныеSharedFlowвоViewModel, тем самымViewModelбудет принимать решение что показывать, а активити это молча принимает и показывает. -
animate+postDelayed. Я так понимаю что тут происходит анимация, в течение 2 секунд она должна закончится и должно поставиться новое название. Вроде какanimate()внутренняя доп функция у класса, расширяющегоTextView. В таком случае, бы сделал внутри реализации animate слушателя, который по окончанию анимации сам сетит новый текст по окончанию анимации. Текст можно в принципе передать во входной параметрanimate(). Если это так, то ее бы по-хорошему и переименовать, аляsetTextWithAnimation(text: String) -
Получение аргумента из
intentможно перенести воViewModel, с помощью механизмаSavedStateHandle. + В зависимости от требований интервьюера, можно сделать, чтобы приложение не падало, еслиuserId = null, например можно сделать навигацию назад в случае если был переда некорректный айди. -
Так как мы запускаем метод
request()через корутину, аннотация@Synchronizedне будет работать правильно, т.к. она ипользуется для синхронизации потоков, а не корутин. (Если требуется синхронизация корутин, нужно исрользоватьMutex) -
Для всяческих запросов лучше использовать
viewModelScopeи в принципе запускать такие корутины воViewModel. Потому чтоlifecycleScopeживет столько, сколько живетActivityи при смене конфигурации, все корутины будут принудительно отменены.ViewModelже вместе сviewModelScopeпереживает такие ситуации. -
переменная
sharedFlowвоViewModel. В ней несколько проблем:- она публичная и мутабельная, любой кто имеет объект этой вьюмодели, в любой момент сможет что-нибудь заэмитить, если захочет. ViewModel не должна предоставлять такой возможности (инкапсуляция). Следует разделить на приватный мутабельный флоу и публичный немутабельный.
- Это
SharedFlow. Активити, которая пересоздаться, потеряет предыдущее значение и переподписке (в случае смены конфигурации).extraBufferCapacityне спасает ситуацию, т.к. она буферизуется значения именно для текущих подписчиков, а не для новый. Для новый подписчиков сохраненные значение хранит именноreplayCache. Можно сделать либоreplay = 1, либо заменитьSharedFlowнаStateFlow, у которого можно удобно получить последнее сохраненное значение. Соответственно не понадобятся всякие доп переменные какage.
-
Пока будут идти всяческие запросы и инициализация данных, по-хорошему юзеру показать какой-то лоадер и ограничить нажатие на кнопку, чтобы нельзя было послать 100 запросов разом и что-нибудь нечайно задудосить. У интервьюера можно спросить, что показывать (или не показывать) во время работы в фоне. Можно использовать
UiState, с разделением наLoading,Success,Error, а можно завести под это дело отдельный flowisLoading(выбрал этот вариант, просто потому что, он быстрее) -
Непонятый порядок запросов в методе
request(). Тут очень много нюансов:flowOn(Dispatchers.IO)после каждого запроса. Толку от этого ноль, одного методаflowOn(Dispatchers.IO)в самом низу цепочки уже достаточно. Все методы, которые выше него, будут выполнять на IO.flatMapConcatкоторые делают запросы и ничего не делают с результатомservice.requestScooter()какой бы код не вернулся, ошибочный или нет, на UI это отобразиться как успешный результат.- Сначала посылаем аналитику, затем запрашиваем скутер, а затем проверяем, нет ли юзера в черном списке. Логичнее будет наверное сначала проверить не в черном списке ли юзер и исходя из результата принять решение, заказывать ли скутер или нет.
- Непонятно почему это сделано в виде цепочки flow, так еще и код написан так, что при нажатии на кнопку каждый раз запускается новый флоу, не отменяя предыдущий, что потенциально приводит к не консистентным данным. Это все дело можно организовать и без flow, используя простые
suspendфункции. - Вообще 3 этих запрос представляют из себя цельную бизнес логику. Их всех можно совместить в один
UseCase. Ко всему прочему ему можно передатьageкак параметр и теперь бизнес логика принимает решение на основе возраста.
-
В
getContentRepositoryмы получаем нашContentModelcnullableмутабельными полями. Во-первых я бы из сделал иммутабельными (инкапсуляция). Во-вторых оперироватьnullableполями не очень удобно + ко всему, в init у вьюмодели ставятся дефолтные значения если ониnull. В таком случае можно завести отдельную UI модельContentModelUI. Затем смапитьContentModelвContentModelUI. Presentation слой не должен оперировать моделями из бизнес логики. Если бизнес логика поменяется - придется полностью переписывать Presentation. -
Передавать аргументы во фрагмент лучше через
argumentsс передачейBundle.
class MainActivity : AppCompatActivity() {
private var _binding: LayoutMainBinding? = null
private val binding get() = _binding!!
private val viewModel: MainViewModel by viewModels<MainViewModel>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
_binding = LayoutMainBinding.inflate(layoutInflater)
setContentView(binding.root)
supportFragmentManager.beginTransaction()
.add(R.id.fragmentContainer, AdsFragment())
.commit()
collectWithLifecycle(viewModel.contentModelFlow) {
binding.name.text = it.name
binding.lastName.setTextWithAnimation(it.lastName)
}
collectWithLifecycle(viewModel.isLoading) { isLoading ->
binding.btn.isVisible = !isLoading
bindgin.loader.isVisible = isLoading
}
collectWithLifecycle(viewModel.events) { event ->
when (event) {
is MainViewModel.Event.ErrorToast ->
Toast.makeText(applicationContext, event.message, Toast.LENGHT_LONG).show()
is MainViewModel.Event.NavigateToScooterFragment ->
supportFragmentManager
.beginTransaction()
.add(
R.id.fragmentContainer,
ScooterFragment().apply {
arguments = bundleOf(ScooterFragment.USER_ID_KEY to event.userId)
}
)
.commit()
}
}
}
}
fun <T> AppCompatActivity.collectWithLifecycle(flow: Flow<T>, block: (T) -> Unit) {
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
flow.collect(block)
}
}}
class MainViewModel(
private val getContentRepository: GetContentRepository,
private val bookScooterUseCase: BookScooterUseCase,
private val resourcesManager: ResourcesManager,
private val savedStateHandle: SavedStateHandle,
) {
sealed class Event {
data class ErrorToast(val message: String) : Event()
data class NavigateToScooterFragment(val userId: String) : Event()
}
private val userId: String = savedStateHandle["userId"] ?: error("userId must be set")
private val _events = MutableSharedFlow<Event>()
val events = _events.asSharedFlow()
private val _contentModelFlow = MutableStateFlow<ContentModelUI>(ContentModelUI("", "", -1))
val contentModelFlow = _contentModelFlow.asStateFlow()
private val _isLoading = MutableStateFlow(true)
val isLoading = _isLoading.asStateFlow()
init {
viewModelScope.launch(Dispatchers.IO) {
runCatching { getContentRepository.getContent(userId) }
.onSuccess { content ->
_contentModelFlow.value = ContentModelUI(
name = content.name ?: resourcesManager.getString(R.string.name),
lastName = content.lastName ?: resourcesManager.getString(R.string.last_name),
age = content.age ?: 0
)
}
.onFailure {
_events.emit(Event.ErrorToast(resourcesManager.getString(R.string.something_went_wrong)))
}
_isLoading.update { false }
}
}
fun request() {
viewModelScope.launch(Dispatchers.IO) {
_isLoading.update { true }
val age = _contentModelFlow.value.age
bookScooterUseCase(userId, age)
.onSuccess { _events.emit(Event.NavigateToScooterFragment) }
.onFailure {
val errorMessage = when (it) {
LowAgeException() -> resourcesManager.getString(R.string.low_age_error_text)
else -> resourcesManager.getString(R.string.something_went_wrong)
}
_events.emit(Event.ErrorToast(errorMessage))
}
_isLoading.update { false }
}
}
}
interface ResourcesManager {
fun getString(@StringRes id: Int): String
class Base(
private val context: Context
) : ResourcesManager {
override fun getString(id: Int): String =
context.getString(id)
}
}
data class ContentModel(
val name: String?,
val lastName: String?,
val age: Int?
)
data class ContentModelUI(
val name: String,
val lastName: String,
val age: Int,
)
class LowAgeException() : IllegalArgumentException()
class BookScooterUseCase @Inject constructor(
private val requestService: RequestService,
private val checkBlackListService: CheckBlackListService,
private val analyticsService: AnalyticsService,
) {
suspend operator fun invoke(userId: String, age: Int): Result<Unit> =
runCatching {
if (age < MIN_AGE) throw LowAgeException()
analyticsService.requestClicked()
checkBlackListService.check(userId)
if (requestService.requestScooter().resultCode != SUCCESS_CODE)
throw IllegalStateException()
}
companion object {
private const val SUCCESS_CODE = 200
private const val MIN_AGE = 18
}
}
interface GetContentRepository {
suspend fun getContent(id: String): ContentModel
}
interface RequestService {
@POST("scooter/request")
suspend fun requestScooter(): ScooterResponse
}
/**
* Methods can throw an Exception
*/
interface CheckBlackListService {
@POST("users/checkBlackList")
suspend fun check(userId: String)
}
interface AnalyticsService {
@GET("users/analytics/requestClicked")
suspend fun requestClicked()
}
class ScooterResponse(
val resultCode: Int
)