-
-
Notifications
You must be signed in to change notification settings - Fork 13
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
[feature|build] Change the homepage style; optimize the use experienc…
…e; change the MVI implementation; update the dependencies version
- Loading branch information
Showing
31 changed files
with
1,017 additions
and
463 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
159 changes: 159 additions & 0 deletions
159
app/src/main/java/com/skyd/rays/base/mvi/AbstractMviViewModel.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,159 @@ | ||
package com.skyd.rays.base.mvi | ||
|
||
import android.os.Build | ||
import android.os.Looper | ||
import android.util.Log | ||
import androidx.annotation.CallSuper | ||
import androidx.annotation.MainThread | ||
import androidx.lifecycle.ViewModel | ||
import androidx.lifecycle.viewModelScope | ||
import com.skyd.rays.BuildConfig | ||
import kotlinx.coroutines.Dispatchers | ||
import kotlinx.coroutines.channels.Channel | ||
import kotlinx.coroutines.channels.onFailure | ||
import kotlinx.coroutines.channels.onSuccess | ||
import kotlinx.coroutines.currentCoroutineContext | ||
import kotlinx.coroutines.flow.Flow | ||
import kotlinx.coroutines.flow.FlowCollector | ||
import kotlinx.coroutines.flow.MutableSharedFlow | ||
import kotlinx.coroutines.flow.SharedFlow | ||
import kotlinx.coroutines.flow.SharingStarted | ||
import kotlinx.coroutines.flow.StateFlow | ||
import kotlinx.coroutines.flow.onEach | ||
import kotlinx.coroutines.flow.receiveAsFlow | ||
import kotlinx.coroutines.flow.shareIn | ||
import kotlinx.coroutines.flow.stateIn | ||
import java.util.concurrent.atomic.AtomicInteger | ||
import kotlin.LazyThreadSafetyMode.PUBLICATION | ||
import kotlin.coroutines.ContinuationInterceptor | ||
|
||
private fun debugCheckMainThread() { | ||
if (BuildConfig.DEBUG) { | ||
check(Looper.getMainLooper() === Looper.myLooper()) { | ||
"Expected to be called on the main thread but was " + Thread.currentThread().name | ||
} | ||
} | ||
} | ||
|
||
suspend fun debugCheckImmediateMainDispatcher() { | ||
if (BuildConfig.DEBUG) { | ||
val interceptor = currentCoroutineContext()[ContinuationInterceptor] | ||
Log.d( | ||
"###", | ||
"debugCheckImmediateMainDispatcher: $interceptor, ${Dispatchers.Main.immediate}, ${Dispatchers.Main}" | ||
) | ||
|
||
check(interceptor === Dispatchers.Main.immediate) { | ||
"Expected ContinuationInterceptor to be Dispatchers.Main.immediate but was $interceptor" | ||
} | ||
} | ||
} | ||
|
||
abstract class AbstractMviViewModel<I : MviIntent, S : MviViewState, E : MviSingleEvent> : | ||
MviViewModel<I, S, E>, ViewModel() { | ||
protected open val rawLogTag: String? = null | ||
|
||
protected val logTag by lazy(PUBLICATION) { | ||
(rawLogTag ?: this::class.java.simpleName).let { tag: String -> | ||
// Tag length limit was removed in API 26. | ||
if (tag.length <= MAX_TAG_LENGTH || Build.VERSION.SDK_INT >= 26) { | ||
tag | ||
} else { | ||
tag.take(MAX_TAG_LENGTH) | ||
} | ||
} | ||
} | ||
|
||
private val eventChannel = Channel<E>(Channel.UNLIMITED) | ||
private val intentMutableFlow = MutableSharedFlow<I>(extraBufferCapacity = 2) | ||
|
||
final override val singleEvent: Flow<E> = eventChannel.receiveAsFlow() | ||
|
||
@MainThread | ||
final override suspend fun processIntent(intent: I) { | ||
debugCheckMainThread() | ||
debugCheckImmediateMainDispatcher() | ||
|
||
check(intentMutableFlow.tryEmit(intent)) { "Failed to emit intent: $intent" } | ||
} | ||
|
||
@CallSuper | ||
override fun onCleared() { | ||
super.onCleared() | ||
eventChannel.close() | ||
} | ||
|
||
// Send event and access intent flow. | ||
|
||
/** | ||
* Must be called in [kotlinx.coroutines.Dispatchers.Main.immediate], | ||
* otherwise it will throw an exception. | ||
* | ||
* If you want to send an event from other [kotlinx.coroutines.CoroutineDispatcher], | ||
* use `withContext(Dispatchers.Main.immediate) { sendEvent(event) }`. | ||
*/ | ||
protected suspend fun sendEvent(event: E) { | ||
debugCheckMainThread() | ||
debugCheckImmediateMainDispatcher() | ||
|
||
eventChannel.trySend(event) | ||
.onSuccess { Log.e(logTag, "sendEvent: event=$event") } | ||
.onFailure { | ||
Log.e(logTag, "$it. Failed to send event: $event") | ||
} | ||
.getOrThrow() | ||
} | ||
|
||
protected val intentSharedFlow: SharedFlow<I> get() = intentMutableFlow | ||
|
||
// Extensions on Flow using viewModelScope. | ||
|
||
protected fun <T> Flow<T>.debugLog(subject: String): Flow<T> = | ||
if (BuildConfig.DEBUG) { | ||
onEach { Log.e(logTag, ">>> $subject: $it") } | ||
} else { | ||
this | ||
} | ||
|
||
protected fun <T> SharedFlow<T>.debugLog(subject: String): SharedFlow<T> = | ||
if (BuildConfig.DEBUG) { | ||
val self = this | ||
|
||
object : SharedFlow<T> by self { | ||
val subscriberCount = AtomicInteger(0) | ||
|
||
override suspend fun collect(collector: FlowCollector<T>): Nothing { | ||
val count = subscriberCount.getAndIncrement() | ||
|
||
self.collect { | ||
Log.e(logTag, ">>> $subject ~ $count: $it") | ||
collector.emit(it) | ||
} | ||
} | ||
} | ||
} else { | ||
this | ||
} | ||
|
||
/** | ||
* Share the flow in [viewModelScope], | ||
* start when the first subscriber arrives, | ||
* and stop when the last subscriber leaves. | ||
*/ | ||
protected fun <T> Flow<T>.shareWhileSubscribed(): SharedFlow<T> = | ||
shareIn(viewModelScope, SharingStarted.WhileSubscribed()) | ||
|
||
protected fun <T> Flow<T>.stateWithInitialNullWhileSubscribed(): StateFlow<T?> = | ||
stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null) | ||
|
||
@Deprecated( | ||
message = "This Flow is already shared in viewModelScope, so you don't need to share it again.", | ||
replaceWith = ReplaceWith("this"), | ||
level = DeprecationLevel.ERROR | ||
) | ||
protected fun <T> SharedFlow<T>.shareWhileSubscribed(): SharedFlow<T> = this | ||
|
||
private companion object { | ||
private const val MAX_TAG_LENGTH = 23 | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
package com.skyd.rays.base.mvi | ||
|
||
/** | ||
* Immutable object which represent an view's intent. | ||
*/ | ||
interface MviIntent |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
package com.skyd.rays.base.mvi | ||
/** | ||
* Immutable object which represents a single event | ||
* like snack bar message, navigation event, a dialog trigger, etc... | ||
*/ | ||
interface MviSingleEvent |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,29 @@ | ||
package com.skyd.rays.base.mvi | ||
|
||
import androidx.annotation.MainThread | ||
import kotlinx.coroutines.flow.Flow | ||
import kotlinx.coroutines.flow.StateFlow | ||
|
||
/** | ||
* Object that will subscribes to a MviView's [MviIntent]s, | ||
* process it and emit a [MviViewState] back. | ||
* | ||
* @param I Top class of the [MviIntent] that the [MviViewModel] will be subscribing to. | ||
* @param S Top class of the [MviViewState] the [MviViewModel] will be emitting. | ||
* @param E Top class of the [MviSingleEvent] that the [MviViewModel] will be emitting. | ||
*/ | ||
interface MviViewModel<I : MviIntent, S : MviViewState, E : MviSingleEvent> { | ||
val viewState: StateFlow<S> | ||
|
||
val singleEvent: Flow<E> | ||
|
||
/** | ||
* Must be called in [kotlinx.coroutines.Dispatchers.Main.immediate], | ||
* otherwise it will throw an exception. | ||
* | ||
* If you want to process an intent from other [kotlinx.coroutines.CoroutineDispatcher], | ||
* use `withContext(Dispatchers.Main.immediate) { processIntent(intent) }`. | ||
*/ | ||
@MainThread | ||
suspend fun processIntent(intent: I) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,16 @@ | ||
package com.skyd.rays.base.mvi | ||
|
||
import android.os.Bundle | ||
|
||
/** | ||
* Immutable object which contains all the required information to render a [MviView]. | ||
*/ | ||
interface MviViewState | ||
|
||
/** | ||
* An interface that converts a [MviViewState] to a [Bundle] and vice versa. | ||
*/ | ||
interface MviViewStateSaver<S : MviViewState> { | ||
fun S.toBundle(): Bundle | ||
fun restore(bundle: Bundle?): S | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,60 @@ | ||
package com.skyd.rays.ext | ||
|
||
import kotlinx.coroutines.CancellationException | ||
import kotlinx.coroutines.CoroutineStart | ||
import kotlinx.coroutines.flow.Flow | ||
import kotlinx.coroutines.flow.buffer | ||
import kotlinx.coroutines.flow.channelFlow | ||
import kotlinx.coroutines.flow.emitAll | ||
import kotlinx.coroutines.flow.flow | ||
import kotlinx.coroutines.flow.flowOf | ||
import kotlinx.coroutines.flow.flowOn | ||
import kotlinx.coroutines.flow.map | ||
import kotlinx.coroutines.flow.produceIn | ||
import kotlinx.coroutines.launch | ||
import java.util.concurrent.atomic.AtomicBoolean | ||
|
||
fun <T> concat(flow1: Flow<T>, flow2: Flow<T>): Flow<T> = flow { | ||
emitAll(flow1) | ||
emitAll(flow2) | ||
} | ||
|
||
fun <T> Flow<T>.startWith(item: T): Flow<T> = concat(flowOf(item), this) | ||
|
||
|
||
/** | ||
* Projects each source value to a [Flow] which is merged in the output [Flow] only if the previous projected [Flow] has completed. | ||
* If value is received while there is some projected [Flow] sequence being merged, it will simply be ignored. | ||
* | ||
* This method is a shortcut for `map(transform).flattenFirst()`. See [flattenFirst]. | ||
* | ||
* ### Operator fusion | ||
* | ||
* Applications of [flowOn], [buffer], and [produceIn] _after_ this operator are fused with | ||
* its concurrent merging so that only one properly configured channel is used for execution of merging logic. | ||
* | ||
* @param transform A transform function to apply to value that was observed while no Flow is executing in parallel. | ||
*/ | ||
fun <T, R> Flow<T>.flatMapFirst(transform: suspend (value: T) -> Flow<R>): Flow<R> = | ||
map(transform).flattenFirst() | ||
|
||
/** | ||
* Converts a higher-order [Flow] into a first-order [Flow] by dropping inner [Flow] while the previous inner [Flow] has not yet completed. | ||
*/ | ||
fun <T> Flow<Flow<T>>.flattenFirst(): Flow<T> = channelFlow { | ||
val busy = AtomicBoolean(false) | ||
|
||
collect { inner -> | ||
if (busy.compareAndSet(false, true)) { | ||
// Do not pay for dispatch here, it's never necessary | ||
launch(start = CoroutineStart.UNDISPATCHED) { | ||
try { | ||
inner.collect { send(it) } | ||
busy.set(false) | ||
} catch (e: CancellationException) { | ||
busy.set(false) | ||
} | ||
} | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.