0

Background

I'm creating some SDK library, and I want to offer some liveData as a returned object for a function, that will allow to monitor data on the DB.

The problem

I don't want to reveal the real objects from the DB and their fields (like the ID), and so I wanted to use a transformation of them.

So, suppose I have this liveData from the DB:

val dbLiveData = Database.getInstance(context).getSomeDao().getAllAsLiveData()

What I did to get the liveData to provide outside, is:

val resultLiveData: LiveData<List<SomeClass>> = Transformations.map(
    dbLiveData) { data ->
    data.map { SomeClass(it) }
}

This works very well.

However, the problem is that the first line (to get dbLiveData) should work on a background thread, as the DB might need to initialize/update, and yet the Transformations.map part is supposed to be on the UI thread (including the mapping itself, sadly).

What I've tried

This lead me to this kind of ugly solution, of having a listener to a live data, to be run on the UI thread:

@UiThread
fun getAsLiveData(someContext: Context,listener: OnLiveDataReadyListener) {
    val context = someContext.applicationContext ?: someContext
    val handler = Handler(Looper.getMainLooper())
    Executors.storageExecutor.execute {
        val dbLiveData = Database.getInstance(context).getSomeDao().getAllAsLiveData()
        handler.post {
            val resultLiveData: LiveData<List<SomeClass>> = Transformations.map(
                dbLiveData) { data ->
                data.map { SomeClass(it) }
            }
            listener.onLiveDataReadyListener(resultLiveData)
        }
    }
}

Note: I use simple threading solution because it's an SDK, so I wanted to avoid importing libraries when possible. Plus it's quite a simple case anyway.

The question

Is there some way to offer the transformed live data on the UI thread even when it's all not prepared yet, without any listener ?

Meaning some kind of "lazy" initialization of the transformed live data. One that only when some observer is active, it will initialize/update the DB and start the real fetching&conversion (both in the background thread, of course).

android developer
  • 114,585
  • 152
  • 739
  • 1,270
  • Why not encapsulate the state in a MutableLiveData that contains different states, e.g.: NotReady, Ready(List), Etc...? The purpose of this being asyncronous is that someone has to wait... I'd have the database better encapsulated in a Repository that exposes either a MutableLD or a `Flow`, the ViewModel observes this, and the UI, observes the ViewModel. This gives you enough pieces to wrap the work and `suspend` it until it's ready. – Martin Marconcini Mar 09 '21 at 10:19
  • The whole "transformation" is irrelevant and either belongs on your VM or your Repository if you don't even want other viewmodels to have access to "the DB". (I'd probably transform on-demand in the VM). – Martin Marconcini Mar 09 '21 at 10:21
  • @MartinMarconcini As I wrote, this is an SDK. I don't have ViewModel and I don't think adding a ViewModel on the SDK is a recommended thing to do. In any case, please offer your solution in a new answer instead of a comment. – android developer Mar 09 '21 at 10:24

2 Answers2

1

The Problem

  • You are an SDK that has no UX/UI, or no context to derive Lifecycle.
  • You need to offer some data, but in an asynchronous way because it's data you need to fetch from the source.
  • You also need time to initialize your own internal dependencies.
  • You don't want to expose your Database objects/internal models to the outside world.

Your Solution

  • You have your data as LiveData directly from your Source (in this particular, albeit irrelevant case, from Room Database).

What you COULD do

  • Use Coroutines, it's the preferred documented way these days (and smaller than a beast like RxJava).
  • Don't offer a List<TransformedData>. Instead have a state:
sealed class SomeClassState {
   object NotReady : SomeClassState()
   data class DataFetchedSuccessfully(val data: List<TransformedData>): SomeClassState()
   // add other states if/as you see fit, e.g.: "Loading" "Error" Etc.
}

Then Expose your LiveData differently:

private val _state: MutableLiveData<SomeClassState> = MutableLiveData(SomeClassState.NotReady) // init with a default value
val observeState(): LiveData<SomeClassState) = _state

Now, whoever is consuming the data, can observe it with their own lifecycle.

Then, you can proceed to have your fetch public method:

Somewhere in your SomeClassRepository (where you have your DB), accept a Dispatcher (or a CoroutineScope):

suspend fun fetchSomeClassThingy(val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default) {
     return withContext(defaultDispatcher) {
          // Notify you're fetching...
          _state.postValue(SomeClassState.Loading)         
 
          // get your DB or initialize it (should probably be injected in an already working state, but doesn't matter)
          val db = ...
          
          //fetch the data and transform at will
          val result = db.dao().doesntmatter().what().you().do()

          // Finally, post it.
          _state.postValue(SomeClassState.DataFetchedSuccessfully(result))
     }   
}

What else I would do.

  • The fact that the data is coming from a Database is or should be absolutely irrelevant.
  • I would not return LiveData from Room directly (I find that a very bad decision on Google that goes against their own architecture that if anything, gives you the ability to shoot your own feet).
  • I would look at exposing a flow which allows you to emit values N times.

Last but not least, I do recommend you spend 15 minutes reading the recently (2021) published by Google Coroutines Best Practices, as it will give you an insight you may not have (I certainly didn't do some of those).

Notice I have not involved a single ViewModel, this is all for a lower layer of the architecture onion. By injecting (via param or DI) the Dispatcher, you facilitate testing this (by later in the test using a Testdispatcher), also doesn't make any assumption on the Threading, nor imposes any restriction; it's also a suspend function, so you have that covered there.

Hope this gives you a new perspective. Good luck!

Martin Marconcini
  • 26,875
  • 19
  • 106
  • 144
  • I don't want to force the SDK-user to use Coroutines, which are available only for Kotlin anyway (right?). I want to offer the liveData right away, as a returned value from a function that runs on the UI thread. I don't understand your function. It doesn't seem to prepare a live data to be used outside. I want to return something like `resultLiveData` that I've shown. – android developer Mar 09 '21 at 11:33
  • If you don't want your SDK user to use Coroutines, then it's up 2 YOU to abstract the problem. *YOU* are the one exposing the value, and you're also obtaining the value. But if the value takes time, you have to have the liveData caller WAIT (observe it). I don't understand what part are you missing here. You don't have to use coroutines, use a Thread Executor for all I care (and lose the benefits of coroutines). Or use coroutines internally and manage your scope/context inside your library, the caller will need to observe the live data anyway. – Martin Marconcini Mar 11 '21 at 11:20
  • In other words, You want the user to call a method synchronously and get a response synchronously, but you cannot because the work to do needs to be async. So I ask you, who is going to manage the state while you wait, certainly not the main-thread, so who's going to create/manage a thread? What's the main thread going to do while it waits for this asynchronous operation(s) to happen? – Martin Marconcini Mar 11 '21 at 11:21
  • I mean, look at what you're asking: "a way to offer the transformed live data on the UI thread even when it's all not prepared yet, without any listener". You want the cake and eat it too. If the data is *not prepared yet* then who's gonna prepare it and what will the Main Thread do while this happens? – Martin Marconcini Mar 11 '21 at 11:24
  • The topic here is not about coroutines. It's about UI-thread vs background-thread. The first part might be on the background-thread. You ask me of what should happen, but that's the issue I've presented. Anyway, I've added my own solution now, and it lets you "eat the cake" just fine. :) The only possible problem here now is the possibility of migration from another DB – android developer Mar 11 '21 at 13:03
0

OK I got it as such:

    @UiThread
    fun getSavedReportsLiveData(someContext: Context): LiveData<List<SomeClass>> {
        val context = someContext.applicationContext ?: someContext
        val dbLiveData =
            LibraryDatabase.getInstance(context).getSomeDao().getAllAsLiveData()
        val result = MediatorLiveData<List<SomeClass>>()
        result.addSource(dbLiveData) { list ->
            Executors.storageExecutor.execute {
                result.postValue(list.map { SomeClass(it) })
            }
        }
        return result
    }
internal object Executors {
    /**used only for things that are related to storage on the device, including DB */
    val storageExecutor: ExecutorService = ForkJoinPool(1)
}

The way I've found this solution is actually via a very similar question (here), which I think it's based on the code of Transformations.map() :

    @MainThread
    public static <X, Y> LiveData<Y> map(
            @NonNull LiveData<X> source,
            @NonNull final Function<X, Y> mapFunction) {
        final MediatorLiveData<Y> result = new MediatorLiveData<>();
        result.addSource(source, new Observer<X>() {
            @Override
            public void onChanged(@Nullable X x) {
                result.setValue(mapFunction.apply(x));
            }
        });
        return result;
    }

Do note though, that if you have migration code (from other DBs) on Room, it might be a problem as this should be on a background thread.

For this I have no idea how to solve, other than trying to do the migrations as soon as possible, or use the callback of "onCreate" (docs here) of the DB somehow, but sadly you won't have a reference to your class though. Instead you will get a reference to SupportSQLiteDatabase, so you might need to do a lot of manual migrations...

android developer
  • 114,585
  • 152
  • 739
  • 1,270