Migrate from V2 to V3

Ballast v3.0.0 is a major release, which some breaking changes in its public API and significant improvements to its internals. Most of these changes were introduced in a way that is source backward-compatible, but no attempt was made to maintain strict binary backward-compatibility. Most projects that were compiled with Ballast v2.3 should be able to be updated to Ballast v3.0 and still compile, though a library that depends on Ballast v2.3 may need to be recompiled against Ballast v3 to work properly.

The breaking changes are intended to be easily adopted, with deprecations introduced from v2.3 and automatic fixes available. Features that were already marked as deprecated in v2.3 have been removed.

See below for the complete list of changes, and how to update your project to support these changes.

Dependency artifact changes:

  • ballast-firebase-analytics and ballast-firebase-crashlytics modules are reimplemented for all platforms as basic reporting
    • ballast-firebase-analytics implementation (without Firebase dependency) is now in ballast-analytics. ballast-firebase-analytics depends on ballast-analytics and adds FirebaseAnalyticsTracker. FirebaseAnalyticsInterceptor is now deprecated
    • ballast-firebase-crashlytics implementation (without Firebase dependency) is now in ballast-crash-reporting. ballast-firebase-crashlytics depends on ballast-crash-reporting and adds FirebaseCrashReporter. FirebaseCrashlyticsInterceptor is now deprecated
  • ballast-core has been broken out into several smaller artifacts: ballast-api, ballast-logging, ballast-viewmodel, and ballast-utils. ballast-core depends on all these new artifacts, so no changes are necessary unless you wish to pick which of these core modules you need.

Breaking Changes

  • Changes to BallastNotification
    • BallastNotification previously had a hard reference to the BallastViewModel which emitted the notification, which led to potential issues with memory leaks or incorrect usage within an Interceptor. The intended use-case for this property was to give access to the name of the VM for debugging purposes, so BallastNotification.vm has been replaced with BallastNotification.viewModelType and BallastNotification.viewModelName
  • name and type properties are no longer exposed through the BallastViewModel interface. These were intended to be used by Interceptors for debugging, but because of the changes to BallastNotification, are no longer necessary as part of the BallastViewModel public API.
  • A new subclass of Queued has been added: Queued.CloseGracefully.
  • fetchWithCache() can now be used from any ViewModel, rather than being restricted to BallastRepository. This was done by adding a new Events type parameter, so it now takes 3 type paramters rather than 2
  • Cached can now be used with nullable values, by removing the bounds of its type parameter
  • Drops support for deprecated KMPP targets: JS Legacy, iosArm32

New APIs

  • InputHandlerScope.cancelSideJob() allows you to cancel a running sideJob, rather than needing to create an empty one to cancel it.
  • SideJobScope.key gives you the key used to start the sideJob
  • SideJobScope.getInterceptor() allow you to find an Interceptor that is registered within the ViewModel, for using its public members (such as BallastUndoInterceptor.undo() or KillSwitch.requestGracefulShutdown())
  • KillSwitch interceptor, to request graceful shutdown
  • RestoreStateScope.postInput() and RestoreStateScope.postEvent() added as a replacement of SavedStateAdapter.onRestoreComplete, allowing more flexibility when restoring the state of a ViewModel.
  • AndroidViewModel and AndroidBallastRepository now accept an optional coroutineScope which will be passed into the ViewModel constructor. If this coroutineScope implements Closeable, it will be cancelled when the ViewModel is cleared. If it does not implement Closeable, it will be wrapped in a Closeable { } block to cancel it instead.
  • New platform-specific loggers are available:
    • NSLogLogger writes to NSLog on iOS. Credit goes to Kermit for the implementation, which was copied for this logger.
    • OSLogLogger writes to OSLog on iOS. Credit goes to Kermit for the implementation, which was copied for this logger.
  • You can now intelligently serialize the data sent to the Debugger UI, for example by serializing it to JSON. BallastDebuggerInterceptor now has 3 optional properties for serializeInput, serializeEvent, and serializeState which you could use to convert those values into JSON or any other serialized format for improved display in the Intellij Plugin's Debugger UI. By default, values are serialized by calling their .toString(), which was the previous the default behavior.

Deprecations

Several features have been deprecated with the release of v3.0.0, which will be removed in Ballast v4. Most of these changes are simple name changes, or related to the overhaul of ViewModel internals.

FirebaseAnalyticsInterceptor

FirebaseAnalyticsInterceptor is deprecated. AnalyticsInterceptor is the intended replacement, which is now available in all supported targets. In addition to supporting trackers other than Firebase, it also allows you more flexibility in selecting which Inputs to track, so that you can now track Inputs without needing the @FirebaseAnalyticsTrackInput annotation.

// Old usage
BallastViewModelConfiguration.Builder()
    .apply {
        this += FirebaseAnalyticsInterceptor(Firebase.analytics) // FirebaseAnalyticsInterceptor class
    }
    .build()

// New usage
BallastViewModelConfiguration.Builder()
    .apply {
        // customized interceptor
        this += AnalyticsInterceptor(
            tracker = FirebaseAnalyticsTracker(Firebase.analytics),
            shouldTrackInput = { it.isAnnotatedWith<FirebaseAnalyticsTrackInput>() },
        )
        // helper function for setting up tracking with Firebase
        this += FirebaseAnalyticsInterceptor() // FirebaseAnalyticsInterceptor factory function, which returns AnalyticsInterceptor
    }
    .build()

FirebaseCrashlyticsInterceptor

FirebaseCrashlyticsInterceptor is deprecated. CrashReportingInterceptor is the intended replacement, which is now available in all supported targets. In addition to supporting crash reporters other than Firebase, it also allows you more flexibility in selecting which Inputs to ignore, so that you can now ignore Inputs without needing the @FirebaseCrashlyticsIgnore annotation.

// Old usage
BallastViewModelConfiguration.Builder()
    .apply {
        this += FirebaseCrashlyticsInterceptor(Firebase.crashlytics) // FirebaseCrashlyticsInterceptor class
    }
    .build()

// New usage
BallastViewModelConfiguration.Builder()
    .apply {
        // customized interceptor
        this += CrashReportingInterceptor(
            tracker = FirebaseCrashReporter(Firebase.crashlytics),
            shouldTrackInput = { !it.isAnnotatedWith<FirebaseCrashlyticsIgnore>() },
        )
        // helper function for setting up crash reporting with Firebase
        this += FirebaseCrashlyticsInterceptor() // FirebaseCrashlyticsInterceptor factory function, which returns CrashReportingInterceptor
    }
    .build()

SavedStateAdapter

SavedStateAdapter.onRestoreComplete() was previously used to send an Input after a ViewModel's state had been restored. This function is now deprecated, and new methods added to RestoreStateScope (the receiver of SavedStateAdapter.restore()) which accomplish the same functionality.

// Old usage
class ExampleSavedStateAdapter(
    private val database: ExampleDatabase,
) : SavedStateAdapter<
        ExampleContract.Inputs,
        ExampleContract.Events,
        ExampleContract.State> {

    override suspend fun SaveStateScope<
            ExampleContract.Inputs,
            ExampleContract.Events,
            ExampleContract.State>.save() {

        saveDiff({ values }) { values ->
            database.saveValues(values)
        }
    }

    override suspend fun RestoreStateScope<
            ExampleContract.Inputs,
            ExampleContract.Events,
            ExampleContract.State>.restore(): ExampleContract.State {
        return ExampleContract.State(
            values = database.selectValues(values)
        )
    }

    override suspend fun onRestoreComplete(restoredState: State): Inputs? {
        return ExampleContract.Inputs.Initialize
    }
}

// New usage
class ExampleSavedStateAdapter(
    private val database: ExampleDatabase,
) : SavedStateAdapter<
        ExampleContract.Inputs,
        ExampleContract.Events,
        ExampleContract.State> {

    override suspend fun SaveStateScope<
            ExampleContract.Inputs,
            ExampleContract.Events,
            ExampleContract.State>.save() {

        saveDiff({ values }) { values ->
            database.saveValues(values)
        }
    }

    override suspend fun RestoreStateScope<
            ExampleContract.Inputs,
            ExampleContract.Events,
            ExampleContract.State>.restore(): ExampleContract.State {
        val restoredState = ExampleContract.State(
            values = database.selectValues(values)
        )
        
        postInput(ExampleContract.Inputs.Initialize)
        
        return restoredState
    }
}

SideJobScope

SideJobScope.currentStateWhenStarted is now deprecated, with no direct replacement. This property could lead to race conditions if the state was changed between the time the sideJob was dispatched and when it was actually started, which reduces the ability to know exactly what's running within the sideJob block.

Instead, capture a snapshot of the state yourself from the InputHandlerScope and pass that object into the sideJob.

// Old usage
sideJob("key") {
    doSomethingWithState(currentStateWhenStarted)
}

// New usage
val currentState = getCurrentState()
sideJob("key") {
    doSomethingWithState(currentState)
}

BallastInterceptor

BallastInterceptor has had 2 callbacks for its usage: BallastInterceptor.onNotify() to process Notifications one at a time for simple reactive usage, and BallastInterceptor.start() for full access to the Flow<BallastNotification> for more advanced usage. BallastInterceptor.onNotify() has been deprecated, so there should only be the single entry-point for making new Interceptors.

// Old Usage
class CustomInterceptor<Inputs : Any, Events : Any, State : Any> : BallastInterceptor<Inputs, Events, State> {
    override suspend fun onNotify(logger: BallastLogger, notification: BallastNotification<Inputs, Events, State>) {
        // do something
    }
}

// New Usage
class CustomInterceptor<Inputs : Any, Events : Any, State : Any> : BallastInterceptor<Inputs, Events, State> {
    override fun BallastInterceptorScope<Inputs, Events, State>.start(
        notifications: Flow<BallastNotification<Inputs, Events, State>>,
    ) {
        launch(start = CoroutineStart.UNDISPATCHED) {
            notifications.awaitViewModelStart()
            notifications
                .onEach { 
                    // do something
                }
                .collect()
        }
    }
}

JsConsoleBallastLogger

JsConsoleBallastLogger is deprecated, to be replaced with JsConsoleLogger. This is just a name change, functionality is identical.

AndroidBallastLogger

AndroidBallastLogger is deprecated, to be replaced with AndroidLogger. This is just a name change, functionality is identical.

DefaultUndoController

DefaultUndoController is deprecated, to be replaced with StateBasedUndoController. This is in preparation to maintain name-parity with a future feature to add an InputBasedUndoController. The StateBasedUndoController is also itself built as a BallastViewModel, so you can add Interceptors to this UndoController if you need more advanced logging or other features to better manage your undo/redo functionality.