Template repository with various commonly used components, to reduce "project setup" time.
Fragment based template can be found here.
After setup change package name everywhere.
- Always attempt to have a single line
if/elsestatement. In that case braces aren't needed. If theif/elseexpression exceeds single line - please use braces. If only anifis used and expression is a single line - braces aren't needed. Also consider usingwhenfor value assignments, most of the times it's a better fit thanif/elseflow.
private fun provideCardColor(): Int = if (x > y) Color.CYAN else Color.BLUEif (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() + 123use:
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,
)- Use typealiases if type name is too long or we have a lot of recurring lambda types.
typealiasshould 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 createCommonTypeAliasesfile 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
)- We are extensively using
tryOrNullextension when we don't need to check the exception reasons fromtry/catchblocks, which allows us to write concise statements like in example below, by using kotlins nullability with elvis operator
suspend fun checkIfEmailExists(email: String): EmailExistsResult {
val result = tryOrNull { authCalls.checkIfEmailExists(email) }
result ?: return EmailExistsNetworkError
return if (result.isUserRegisteredByEmail) EmailExists else EmailNotFound
}- Handle process death. 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-liveDatafields.LiveDatavalue 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
instead of:
val timeRange: Pair<Int, Int>? = nulluse:
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.dateanymore. 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 changed.
-
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
shareInorstateInto create a new flow that’s returned when calling a function. Explanation -
Use shrinkResources
-
Use firebase dynamic links for deep links
-
Be very carefull with stateFlow, since it's not exact replacement for liveData. Change proposal, motivation
- 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.
- 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=truetag 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.
- Always attempt to use function references
- Be carefull when using
InputValue.ktin cases where the error might be observed from a different Composable. Then it makes more sense to separatevalue&error - use @Immutable annotations for immutable classes
- give keys to lazy columns and everywhere where use iterate over items like items.foreach, since keys prevent recomposing unchanged items
- Be very careful when choosing between
liveData&stateFlows. We still can't dropliveDatanot only because we need it in thesavedStateHandle.getLiveData<Key>scenarios, but becausestateFlowcan'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
liveDatafor cases when we are performingobservable.switchMap/flatMapLatesttype of operations. In code above it's thequery, it has to be declared asliveData. You can always observe it using.asFlowBehaviour difference is explained here - Be carefull how you update the
stateFlowvalue, since usingstateFlow.value = stateFlow.value.copy()can create unexpected results. If between the time copy function completes and thestateFlowsnew 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.
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.```