( ...work in progress... )
This app allows me to prototype, test language, framework or CI features, explore new ideas, open discussions and self-reflection about patterns.
This readme is the documentation for it
It's a pet-store shop app inspired by the Swagger example https://petstore.swagger.io/
The UI is mostly a plan material design, I would be happy to implement a nice custom design system in the sample (like I've done before times in prod), but I'm not a designer, I won´t pretend to be one here.
It all starts with a pretty standard unidirectional data flow (UDF) architecture.
- Data sources area separated into services
- Repositories manage different data sources and provide domain data to the UI
- ViewModels implements the logic by managing the UI state and evens
- View draw the state to the screen without question its origin
In no specific order, here I discuss a few decisions quirks, ideas implemented around the code, pros/cons of some pattern applied and even further improvements (as I wanted to have a first commit and publish it and not keep refactoring and making everything absolutely perfect).
Some of this further improvement are added to Github issues.
Never had used before, just trying something new. Seems interesting, after initial "everything in red" seems to work fine. It's a shame it doesn't seem to handle version catalogs.
As we can see from this post from Sep/2024 and this other post from Aug/2019, the discussion on when to start loading data in a ViewModel is almost as old as the ViewModel itself.
In this repo I took the canonically accepted solution, but would like to discuss about the "load on
an init{}
block" option.
The major downside cited about this option is testing, but IMHO that's only a problem when the
ViewModel is not structured with a testability in mind. Also simple techniques such as setting the
right test dispatcher, constructor injection of CoroutineScope
, usage of awaitingCancellation()
makes the "load on an init{}
block" approach perfectly testable while keeping the implementation
uncomplicated.
It's interesting how the ideas morph when the reality of implementation happens. In my current project I work mostly with fresh data directly from backend services and I stand by the approach first proposed for the ViewModel data loading. In this project, when adding the SQL caching layer, things changed and more analise is needed.
I'll start by re-implementing the SQL as per issue #3, and afterward come back to this.
Every project should start from the first commit fully supporting internationalization using the appropriate platform methods (XML files on case of Android). But said, for a sample project used as exploration and showcase I didn't do it. I'll add an issue to extract the strings.
Each gradle module exposes a function to create a Koin
module and they all get called during app
start. The modules are extremely lightweight and all the object construction are done lazily by the
Koin framework.
This project is too small to worry about scoping. So just added services and repositories as
singletons
and view models as viewModels
I never used Hilt before, maybe I should create a parallel implementation with it just to try out and compare.
The service is feature complete for what it must do, but without a real REST API to access, I created a barebones API designed mostly to simplify the the logic from mock engine (e.g. pagination based on index). It works okay for a simple sample.
There's an upcoming FOSS generator from swagger yml to ktor client and server and it would be nice to use it here. The pattern applied is similar to the service, it should be easy to replace later.
Any real product like a shop would have items ordering and filtering, something like "order by best
match" and "show only dogs". I was missing some ordering from the backend so I added the priority
field to the network responses and feed them into the database entities to mock that
behavior, but those mock data was created using Random.nextFloat()
.
Outdated discussion and test on SQL vs Flows for query filtering
On a real/bigger project a discussion would be nice to weight some pros/cons related to
re-ordering/filtering the items via SQL statements or via operations on the Flow<List>
in the
repository layer. At the first commit we have a less than ideal scenario with a mixed approach.
An interesting idea also to explore would be
to add @RawQuery
into the mix,
this of course would require to pass and map data back and forward through the layers of UDF to keep
the clean separation, and also add id to the performance comparison tests proposed above, keeping in
mind this extract from the docs:
If you know the query at compile time, you should always prefer Query since it validates the query at compile time and also generates more efficient code
Update:
I did the comparison and the results are in. We had a very clear winner!
It were inserted 100.000 records in the DB and for each repo implementation a query for repeated R number of types for F amount of filters. I would imagine it takes more memory due to keep all the table in memory and filtering via Flow but the speed difference is massive.
# of filters | # of repeats | SQL Raw Query | Kotlin FLow |
---|---|---|---|
5 | 5 | 2.4s | 86ms |
10 | 10 | 6.s | 61ms |
25 | 25 | 27.4s | 426ms |
50 | 50 | 1m26s | 942ms |
1 | 1000 | 32.8s | 4.0s |
1000 | 3 | 1m54s | 592ms |
Tests were run on a real device (Google Pixel 6) and the code for it can be found on the
branch repo_performance_test
. It's a somehow messy code (e.g. PetStoreDatabase had to be made
public and not all tests were written) but it's there saved in the branch.
As it was said by smart people again and again
There are 2 hard problems in computer science: cache invalidation, naming things, and off-by-1 errors
Hence, it's out of scope for this sample app.
Lately I realized that in current Android project, the view model object doesn't need to extend
from androidx.lifecycle.ViewModel
, it can be any object that receives a CoroutineScope
(and a
simple wrapper with set/get when SavedStateHandle
is needed).
The original ViewModel
required to extend that class to use the onCleared()
method to unregister
listeners from database, location or similar. But with Kotlin Flows and Coroutines, that requirement
is basically void.
On this sample, I still coded them as the usual way, but already use constructor injection for the scope to facilitate test and distance it a bit from the framework.
When mapping domain to UI objects, some details of it might get overly complex or extend to
functionalities that would be shared to other pieces of UI. A good example of it is the conversion
of a kotlin Instant
to a friendly UI representation.
In such a case, a good approach clean approach is to extract the special parts to its own mapper and
pass it to the constructor. That can be seen here between RelativeAgeMapper
and StoreListMapper
.
By using constructor to pass the relative age mapper, testing of the store list mapper stays
unbothered by its complexity. Easier testing and improved re-usability.