Template repo with various common components, to reduce "project setup" time
Fragmentless template can be found here
- Single navigation graph
- Single navigation graph & Room
- Single navigation graph & Proto Store
- Bottom navigation view
- Bottom navigation view & Room
After setup:
- Change package name everywhere ( including proto files ). Link to painless solution
- Update readme.md
- Always attempt to have a single line
if/else
statement. In that case braces aren't needed. If theif/else
expression exceeds single line - please use braces. If only anif
is used and expression is a single line - braces aren't needed. Also consider usingwhen
for value assignments, most of the times it's a better fit thanif/else
flow.
private fun provideCardColor(): Int = if (x > y) Color.CYAN else Color.BLUE
if (condition == true) doSmth()
..
instead of:
private fun validateSmth() {
if (condition == true) state.postValue(stateX)
else events.offer(PasswordNotValid)
}
use:
private fun validateSmth() {
if (condition == true) {
state.postValue(stateX)
} else {
events.offer(PasswordNotValid)
}
}
- Preffering if/else to let/run avoids cryptic bugs:
var x: String? = "0"
x?.let {
//let block will be executed
executeSmth()
} ?: run {
//run block will be also executed since executeSmth() returned null
}
fun executeSmth(): String? = null
- Rule of a thumb: if we access the same object 3 or more times - please use
apply
,with
. If we want to hide big, non-priority (glance wise) code chunks - there is nothing wrong to useapply
, with even a 1 line under it. - Explicitly specify function return types if function output type isn't obvious from function name, or functions code block at first glance.
instead of:
private fun provideSomeValue() = (12f * 23).toInt() + 123
use:
private fun provideSomeValue(): Int = (12f * 23).toInt() + 123
- It's ok to use trailing commas. They are there to make our life easier.
data class SignUpRequest(
val email: String,
val firstName: String,
)
- Inside viewModel, refer to the following placement of functions & variables
class ViewModel(..){
// variables
// init{}
// ui callbacks, eg. onBackPressed(), onNextButtonClick(), onEmailEntered()
// db/network related functions
// helper functions, transformations etc.
// navigation functions
}
- Use typealiases if type name is too long or we have a lot of recurring lambda types.
typealias
should be declared in an appropriate scope. If used in a single place - it can be placed on top of the same class. If it's a common lambda, which can be reused across different feature packages - please createCommonTypeAliases
file under common package.
instead of:
class ShopProductItem(
private val onClick: (position: Int) -> Unit
)
use
typealias OnClick = (position: Int) -> Unit
class ShopProductItem(
private val onClick: OnClick
)
Always attempt to use references instead of lambdas.
QuestionContent(
question = question,
onIdAnswerGiven = viewModel::onIdAnswerGiven,
..
)
-
Unless constant is used across different classes, declare it as
const val NAME = VALUE
on top of the calling class. -
Be pragmatic when writing
composables
. As a rule of a thumb - just allow passingModifier
to the composable, unless it's very specific and is a part of a single not reusable composable, eg. if you have anAppBar
on a certain screen which is very different fromAppBar
that is used in 99% of the app - it makes no sense to pass other arguments to the latter, just to use single composable everywhere. -
Handle process death, at minimum in places with user input and other critical to lose temporary state. We are using SavedStateHandle inside viewModel 99% of the time. There is difference between return types. We need to manually save the values when using
non-liveData
fields.LiveData
value reassignments will be automatically reflected insidesavedStateHandle
. For testing purposes please use venom.
val state = handle.getLiveData("viewState", 0)
state.postValue(1) // doing so will automatically give us value of 1 upon PD
var scanCompleted = handle.get<Boolean>("scanCompleted") ?: false
set(value) {
field = value
handle.set("scanCompleted", value) // set the key/value pair to the bundle upon each value reassignment
}
scanCompleted = true // will be "false" after PD, unless we set the key/value pair to the bundle like above
Also remember about rememberSaveable
. Sometimes it's preffered to persist view state inside view itself. eg. when we want to remember last active Tab, and don't do anything with it on the viewModel layer.
instead of:
val timeRange: Pair<Int, Int>? = null
use:
class TimeRange(val from: Int, val to: Int)
val timeRange: TimeRange? = null
- When composing objects, consider the following approach:
instead of:
when (val response = authRepo.verifyCode(
VerifyCodeRequest(
email, pin,
"someValue", "someValue",
"someValue", "someValue"
)
)) {
is VerifyCodeSuccess -> signIn()
..
}
use:
val requestBody = VerifyCodeRequest(email, pin)
when (val response = authRepo.verifyCode(requestBody)) {
is VerifyCodeSuccess -> signIn()
..
}
//if object constructor has many arguments,or has some additional logic - move it into provideX() function, like this:
val requestBody = provideVerifyCodeRequestBody()
when (val response = authRepo.verifyCode(requestBody)) {
is VerifyCodeSuccess -> signIn()
..
}
- Be pragmatic with Kotlin Named Arguments. Use them to make parts that are not self documenting easier to read:
class SignUpRequestBody(val email: String, val password: String)
val requestBody = SignUpRequestBody(email, password)
class ItemDecorator(val paddingTop: Int, val paddingLeft: Int, val paddingBottom: Int)
val itemDecorator = ItemDecorator(
paddingTop = 16,
paddingLeft = 8,
paddingBottom = 2
)
-
Working with dates/times is done via java.time. No need for ThreeTenABP or
java.util.date
anymore. For API < 26 versions - just enable desugaring. Also don't be fast with creating extensions, first make yourself familiar with already available methods. There are plenty examples out there, like this one. We should rely on ISO-8601. All examples are inside this sheet. Template already contains basic usages insideDateTimeExtensions.kt
-
Document complex code blocks, custom views, values that represent "types" in network responses, logical flows, etc.
-
Take responsibility for keeping libraries updated to the latest versions available. Be very carefull, read all release notes & be prepared that there might be subtle, destructive changes.
-
Optimize internet traffic using HEAD requests where makes sense.
-
Ensure that you're handling system insets on all screens, so app falls under edge-to-edge category.
-
Never use
shareIn
orstateIn
to create a new flow that’s returned when calling a function. Explanation -
Use shrinkResources
-
Use firebase dynamic links for deep links
-
Be very careful when choosing between
liveData
&stateFlows
becausestateFlow
can't reproduce a certain behaviour in "search-like" scenarios:
class ViewModel(repository: Repository) : ViewModel() {
private val query = MutableStateFlow("")
val results: Flow<Result> = query.flatMapLatest { query ->
repository.search(query)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000L),
initialValue = Result()
)
fun onQueryChanged(query: String) { query.value = query }
}
- Always use
liveData
for cases when we are performingobservable.switchMap/flatMapLatest
type of operations. In code above it's thequery
, it has to be declared asliveData
. You can always observe it using.asFlow
Behaviour difference is explained here - Be carefull how you update the
stateFlow
value, since usingstateFlow.value = stateFlow.value.copy()
can create unexpected results. If between the time copy function completes and thestateFlows
new value is emitted another thread tries to update thestateFlow
— by using copy and updating one of the properties that the current copy isn’t modifying — we could end up with results we were not expecting. So please use update in such cases.
- I'd recommend reading this article about performance related things in Compose.
- Always use remember for anything that can allocate memory but can be created only once, is taking time to get calculated and unstable lambdas. Unless it's marked as
stable/immutable
. - Use collectAsStateWithLifecycleImmutable as a workaround when passing Collections & 3d party lirbary objects which are not inferred stable by compose compiler to ensure that Composables which take List for exampe won't recompose for no reason. Alternative would be kotlinx.collections.immutable but seems perfromance wise it's a bigger evil than a wrapper, also this issue is making it even worse.
- Be pragmatic with creating composables. If some element is specific to the screen, it's not necessary to provide constructor with parameters to it. If composable is going to be used in different places - then providing modifier and other params makes sense.
- Always use fastForEach, fastMap, fastFirstOrNull and other API's for optimised iteration through collection, since it doesn't allocate an iterator like
forEach
. Beware: never use it withLinkedList
, 3d party libraries or other non-common collections without ensuring it works faster and isn't bugged out. - When observing events and the
when
becomes big enough, please use the following:
LaunchedEffect(Unit) {
observeEvents(events)
}
- Template already has a few GitHub Actions workflows included. Please ensure you're passing the checks locally, before opening pull request. To do that, either run commands in the IDE terminal, or setup a github hook. Commands are:
./gradlew ktlintFormat
,./gradlew detektDebug
. Request a review only after the CI checks have passed successfully. - If pull request contains code that should close the issue, please write:
close #1, close #2
(number == issue number) somewhere in the PR description. This allows for automatic issue closing upon successfull PR merge. - Commit code as many times as you want while working on a feature. When the feature is ready - do a careful rebase over origin/master and squash all this stuff into one or two meaningful commits that clearly represent the feature, before opening a pull request.
- Features should be splitted into logical chunks if they require a lot of code changes.
- Attempt to keep PR size in range of 250 - 300 lines of code changed.
- Always preffer fakes over mocks whenever possible. It has a noticable effect on time test needs to run.
- Be as descriptive as possible when naming the test
- Also don't use
Update email when onEmailEntered() is invoked() = runTest {
, always use braces so that function name is always visible when function bodies are folded
@Test
fun `Update email when onEmailEntered() is invoked`() {
runTest {
// given
viewModel.email.value = InputWrapper(value = "example.email@")
// when
viewModel.onEmailEntered("example.email@g")
// then
viewModel.email.test {
Assert.assertEquals(InputWrapper(value = "example.email@g"), awaitItem())
cancelAndConsumeRemainingEvents()
}
}
}
- Check the app for overdrawing regions, and optimize wherever possible.
- Run IDE's
remove unused resources
. Be carefull to check the changes before commiting, so you don't accidentaly remove classes, which are just temporarily unused. - Run IDE's
convert png's to webp's
. - Check the r8 rules to prevent release .apk/.aab issues as much as possible.
- It won't hurt to use canary leak to check whether you don't have serious issues with memory leaks.
- Strict mode might be helpfull to do a few optimizations.
- If we decouple app language from the system language, please use SplitInstallManager or disable ubundling language files using android.bundle.language.enableSplit = false
- Check if cold startup time is good. If it's not - try to use app startup library in case when there is plenty of ContentProviders. Also take a look if smth can be lazy initialized if it's not used immediately upon app start. Here is a library which allows to monitor the amount of ms needed for content providers to be initialized. One of the approach of measuring the startup time is nicely described here (using a bash script).
- Invest some time into getting used to IDE shortcuts. Doing so will save you a lot of time.
- Guide on how to offload code execution to the background thread
- Use in-app updates to enhance UX. Sometimes we even want to block certain outdated versions. We always prefer in-app updates, but it's ok to create custom solutions according to project specifications.
- Carefully use in-app reviews to ensure that users leave high ratings on Google Play.
- Always use crashlytics to track the crashes.
- Use auto-fill where possible.
- Use scroll indicators for screens which are might not appear scrollable otherwise.
- Attemp to use min/max data models: shrinked User model returned from DB for list of users, and complete User model for details screen.
- The
android:allowBackup=true
tag can lead to a broken app state that can cause constant app crashes. Benefits of using this feature are almost non-existing, so we keep it off by default. Explanation - Remember that to keep everyone (including yourself) happy, we can always just increase the database schema during developtement, and rely on fallbackToDestructiveMigration when using Room. This will prevent people from getting crashes of they don't clear app data, and us from writing migrations during develpment process. Just ensure that you revert the version to 1 for the release.
- Leave code in a better shape than it was before even you've changed few lines in a file.
MIT License
Copyright (c) 2021 Denis Rudenko
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.```