Skip to content
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

Introduce Mavericks core module #635

Merged
merged 15 commits into from
Jul 25, 2022
1 change: 1 addition & 0 deletions docs/_sidebar.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
* [**Setting Up Mavericks**](setup.md)
* [**What's new in Mavericks 2.0**](new-2x.md)
* [**Upgrading from MvRx 1.x**](new-2x.md#upgrading)
* [**What's new in Mavericks 3.0**](new-3x.md)
---
* [**Async/Network/Db Operations**](async.md)
* [**Debug Checks**](debug-checks.md)
Expand Down
55 changes: 55 additions & 0 deletions docs/new-3x.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# What's new in Mavericks 3.0

### Experimental MavericksRepository

Mavericks 3.0 introduces an experimental module `mvrx-core` with new abstraction `MavericksRepository` designed to provide a base class for any statefull repository implementation that owns and manages its state. This module is pure Kotlin and has no Android dependencies. Primary goal of this module is to provide the same API and behaviour as `MavericksViewModel` in Android modules.


### API Changes

Mavericks 3.0 introduces one breaking change: `MavericksViewModelConfig.BlockExecutions` is extracted into `MavericksBlockExecutions`. In order to migrate, you need to update your code to use `MavericksBlockExecutions` instead of `MavericksViewModelConfig.BlockExecutions`.

### New Functionality

#### `MavericksRepository`

`MavericksRepository` behaves exactly like `MavericksViewModel` except it doesn't have any Android dependencies. Even more, under the hood `MavericksViewModel` uses `MavericksRepository` to manage its state. You can find that `MavericksRepository` and `MavericksViewModel` are very similar in terms of API and behaviour.
As this is experimental module you have to opt in to use it by `-Xopt-in=com.airbnb.mvrx.InternalMavericksApi` compilation argument.

```kotlin
data class Forecast(
val temperature: Int,
val precipitation: Int,
val wind: Int,
)

data class WeatherForecastState(
val forecasts: Async<List<Forecast>> = Uninitialized,
) : MavericksState

interface WeatherApi {
suspend fun getForecasts(): List<Forecast>
}

class WeatherForecastRepository(
scope: CoroutineScope,
private val api: WeatherApi,
) : MavericksRepository<WeatherForecastState>(
initialState = WeatherForecastState(),
configProvider = { initialState ->
MavericksRepositoryConfig(
debugMode = true,
stateStore = CoroutinesStateStore(initialState, scope),
coroutineScope = scope,
)
},
) {
init {
suspend { api.getForecasts() }.execute { copy(forecasts = it) }
}

fun refresh() {
suspend { api.getForecasts() }.execute { copy(forecasts = it) }
}
}
```
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
VERSION_NAME=2.6.1
VERSION_NAME=3.0.0
GROUP=com.airbnb.android
POM_DESCRIPTION=Mavericks is an Android application framework that makes product development fast and fun.
POM_URL=https://github.com/airbnb/mavericks
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ import android.os.Parcelable
import android.util.Log
import androidx.core.os.postDelayed
import androidx.fragment.app.Fragment
import com.airbnb.mvrx.MavericksBlockExecutions
import com.airbnb.mvrx.MavericksView
import com.airbnb.mvrx.MavericksViewModelConfig
import com.airbnb.mvrx.launcher.utils.toastLong
import com.airbnb.mvrx.mocking.MockBehavior
import com.airbnb.mvrx.mocking.MockedView
Expand Down Expand Up @@ -190,15 +190,15 @@ class MavericksLauncherMockActivity : MavericksBaseLauncherActivity() {
mock.forInitialization || mock.isForProcessRecreation -> MockBehavior(
initialStateMocking = MockBehavior.InitialStateMocking.Partial,
stateStoreBehavior = MockBehavior.StateStoreBehavior.Normal,
blockExecutions = MavericksViewModelConfig.BlockExecutions.No
blockExecutions = MavericksBlockExecutions.No
)
// The Fragment is fully mocked out and we prevent initialization code from overriding the mock.
// However, our ViewModelEnabler will later toggle executions to be allowed, once initialization is over,
// so that the fragment is functional from the state we set it up in.
else -> MockBehavior(
initialStateMocking = MockBehavior.InitialStateMocking.Full,
stateStoreBehavior = MockBehavior.StateStoreBehavior.Scriptable,
blockExecutions = MavericksViewModelConfig.BlockExecutions.Completely
blockExecutions = MavericksBlockExecutions.Completely
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ import android.os.Handler
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleObserver
import androidx.lifecycle.OnLifecycleEvent
import com.airbnb.mvrx.MavericksBlockExecutions
import com.airbnb.mvrx.MavericksView
import com.airbnb.mvrx.MavericksViewModel
import com.airbnb.mvrx.MavericksViewModelConfig
import com.airbnb.mvrx.mocking.MockBehavior
import com.airbnb.mvrx.mocking.MockableMavericksViewModelConfig
import com.airbnb.mvrx.mocking.MockedView
Expand Down Expand Up @@ -46,7 +46,7 @@ class ViewModelEnabler(
.forEach { viewModel ->
MockableMavericksViewModelConfig.access(viewModel).pushBehaviorOverride(
MockBehavior(
blockExecutions = MavericksViewModelConfig.BlockExecutions.No,
blockExecutions = MavericksBlockExecutions.No,
stateStoreBehavior = MockBehavior.StateStoreBehavior.Normal
)
)
Expand Down
29 changes: 29 additions & 0 deletions mvrx-core/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

plugins {
id 'java-library'
id 'org.jetbrains.kotlin.jvm'
}

tasks.withType(KotlinCompile).all {
kotlinOptions {
freeCompilerArgs += [
'-Xopt-in=kotlin.RequiresOptIn',
'-Xopt-in=kotlinx.coroutines.ExperimentalCoroutinesApi',
'-Xopt-in=com.airbnb.mvrx.InternalMavericksApi',
'-Xopt-in=com.airbnb.mvrx.ExperimentalMavericksApi',
]
}
}

java {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}

dependencies {
api Libraries.kotlinCoroutines

testImplementation TestLibraries.junit
testImplementation TestLibraries.kotlinCoroutinesTest
}
4 changes: 4 additions & 0 deletions mvrx-core/gradle.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
POM_NAME=Mavericks
POM_ARTIFACT_ID=mavericks-core
POM_PACKAGING=jar
GROUP=com.airbnb.android
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ data class Success<out T>(private val value: T) : Async<T>(complete = true, shou
* you could map a network request to just the data you need in the value, but your base layers could
* keep metadata about the request, like timing, for logging.
*
* @see MavericksViewModel.execute
* @see MavericksRepository.execute
* @see Async.setMetadata
* @see Async.getMetadata
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,6 @@ class CoroutinesStateStore<S : MavericksState>(
* The internally allocated buffer is replay + extraBufferCapacity but always allocates 2^n space.
* We use replay=1 so buffer = 64-1.
*/
internal const val SubscriberBufferSize = 63
@InternalMavericksApi const val SubscriberBufferSize = 63
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.airbnb.mvrx

/**
* Marks declarations that are still experimental in Mavericks API.
* Marked declarations are subject to change their semantics or behaviours, that not backward compatible.
*/
@Retention(value = AnnotationRetention.BINARY)
@RequiresOptIn(level = RequiresOptIn.Level.WARNING)
annotation class ExperimentalMavericksApi
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.airbnb.mvrx

/**
* Defines whether a [MavericksRepository.execute] invocation should not be run.
*/
enum class MavericksBlockExecutions {
/** Run the execute block normally. */
No,

/** Block the execute call from having an impact. */
Completely,

/**
* Block the execute call from having an impact from values returned by the object
* being executed, but perform one state callback to set the Async property to loading
* as if the call is actually happening.
*/
WithLoading
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,5 @@
package com.airbnb.mvrx

import android.os.Build
import android.util.SparseArray
import androidx.collection.ArrayMap
import androidx.collection.LongSparseArray
import androidx.collection.SparseArrayCompat
import java.lang.reflect.Field
import java.lang.reflect.Modifier
import java.lang.reflect.ParameterizedType
Expand All @@ -30,6 +25,16 @@ fun assertMavericksDataClassImmutability(
) {
require(kClass.java.isData) { "Mavericks state must be a data class! - ${kClass.simpleName}" }

val disallowedFieldCollectionTypes = listOfNotNull(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

seems like a good solution 👍

ArrayList::class.java,
HashMap::class.java,
runCatching { Class.forName("android.util.SparseArray") }.getOrNull(),
runCatching { Class.forName("androidx.collection.LongSparseArray") }.getOrNull(),
runCatching { Class.forName("androidx.collection.SparseArrayCompat") }.getOrNull(),
runCatching { Class.forName("androidx.collection.ArrayMap") }.getOrNull(),
runCatching { Class.forName("android.util.ArrayMap") }.getOrNull(),
)

fun Field.isSubtype(vararg classes: KClass<*>): Boolean {
return classes.any { klass ->
return when (val returnType = this.type) {
Expand All @@ -43,16 +48,12 @@ fun assertMavericksDataClassImmutability(
// During tests, jacoco can add a transient field called jacocoData.
.filterNot { Modifier.isTransient(it.modifiers) }
.forEach { prop ->
val disallowedFieldCollectionType = disallowedFieldCollectionTypes.firstOrNull { clazz -> prop.isSubtype(clazz.kotlin) }
when {
!Modifier.isFinal(prop.modifiers) -> "State property ${prop.name} must be a val, not a var."
prop.isSubtype(ArrayList::class) -> "You cannot use ArrayList for ${prop.name}.\n$IMMUTABLE_LIST_MESSAGE"
prop.isSubtype(SparseArray::class) -> "You cannot use SparseArray for ${prop.name}.\n$IMMUTABLE_LIST_MESSAGE"
prop.isSubtype(LongSparseArray::class) -> "You cannot use LongSparseArray for ${prop.name}.\n$IMMUTABLE_LIST_MESSAGE"
prop.isSubtype(SparseArrayCompat::class) -> "You cannot use SparseArrayCompat for ${prop.name}.\n$IMMUTABLE_LIST_MESSAGE"
prop.isSubtype(ArrayMap::class) -> "You cannot use ArrayMap for ${prop.name}.\n$IMMUTABLE_MAP_MESSAGE"
Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT &&
prop.isSubtype(android.util.ArrayMap::class) -> "You cannot use ArrayMap for ${prop.name}.\n$IMMUTABLE_MAP_MESSAGE"
prop.isSubtype(HashMap::class) -> "You cannot use HashMap for ${prop.name}.\n$IMMUTABLE_MAP_MESSAGE"
disallowedFieldCollectionType != null -> {
"You cannot use ${disallowedFieldCollectionType.simpleName} for ${prop.name}.\n$IMMUTABLE_LIST_MESSAGE"
}
!allowFunctions && prop.isSubtype(Function::class, KCallable::class) -> {
"You cannot use functions inside Mavericks state. Only pure data should be represented: ${prop.name}"
}
Expand Down
Loading