Skip to content

Mobile Examples

Illustrative only. The Native Mobile Standards page states the rules; this page shows the three shapes that decide whether an app ages well.

One immutable UI state, owned by the ViewModel or model. The view renders it and emits events — nothing else.

// Android
data class BranchesUiState(
val branches: List<Branch> = emptyList(),
val isLoading: Boolean = false,
val error: String? = null,
)
@HiltViewModel
class BranchesViewModel @Inject constructor(
private val repository: BranchRepository, // ← never a Retrofit service
) : ViewModel() {
private val _state = MutableStateFlow(BranchesUiState())
val state: StateFlow<BranchesUiState> = _state.asStateFlow()
fun load() = viewModelScope.launch {
_state.update { it.copy(isLoading = true) }
_state.update {
when (val result = repository.getBranches()) {
is Result.Ok -> it.copy(branches = result.value, isLoading = false)
is Result.Failed -> it.copy(error = result.message, isLoading = false)
}
}
}
}
@Composable
fun BranchesScreen(viewModel: BranchesViewModel = hiltViewModel()) {
val state by viewModel.state.collectAsStateWithLifecycle()
BranchesContent(state = state, onRefresh = viewModel::load)
}
// iOS — the same shape.
@Observable
final class BranchesModel {
private(set) var branches: [Branch] = []
private(set) var isLoading = false
private(set) var error: String?
private let repository: BranchRepository // ← never URLSession directly
init(repository: BranchRepository) { self.repository = repository }
func load() async {
isLoading = true
defer { isLoading = false }
do { branches = try await repository.getBranches() }
catch { self.error = error.localizedDescription }
}
}
struct BranchesScreen: View {
@State private var model: BranchesModel
var body: some View {
List(model.branches) { BranchRow(branch: $0) }
.task { await model.load() }
}
}

This is the layer both of our existing apps skipped, and it is why their networking is impossible to test.

// Android — the interface lives in the domain layer; Retrofit stays in data.
interface BranchRepository {
suspend fun getBranches(): Result<List<Branch>>
}
internal class DefaultBranchRepository(
private val api: BranchApi,
) : BranchRepository {
override suspend fun getBranches(): Result<List<Branch>> = runCatching {
api.getBranches().map { it.toDomain() }
}.fold(Result::Ok, { Result.Failed(it.message.orEmpty()) })
}
internal interface BranchApi {
@GET("branches") suspend fun getBranches(): List<BranchDto>
}
// The Hilt module that binds it. DI is a composition concern, not a screen concern.
@Module
@InstallIn(SingletonComponent::class)
abstract class DataModule {
@Binds abstract fun bindBranchRepository(impl: DefaultBranchRepository): BranchRepository
}

On iOS the equivalent is a plain struct built once in the app entry point:

struct AppDependencies {
let branchRepository: BranchRepository
static func live() -> AppDependencies {
.init(branchRepository: DefaultBranchRepository(client: .live))
}
}

No runtime DI container. A missing dependency should be a compile error, not a crash the first time someone opens a screen — and a container fights Swift 6 concurrency checking.

Never do this:

// A committed keystore, its password in plain text, and the SAME key
// signing debug and release. Anyone who has ever cloned the repo can
// publish an update to your production app.
signingConfigs {
default {
storeFile file('./signing.jks')
storePassword 'hunter2'
keyAlias 'release'
}
}
buildTypes {
release { minifyEnabled false }
}

Do this — the keystore arrives from an Azure DevOps Secure File, the passwords from a Key Vault–backed variable group, and neither exists on a developer machine:

build.gradle.kts
signingConfigs {
create("release") {
storeFile = System.getenv("KEYSTORE_PATH")?.let(::file)
storePassword = System.getenv("KEYSTORE_PASSWORD")
keyAlias = System.getenv("KEY_ALIAS")
keyPassword = System.getenv("KEY_PASSWORD")
}
}
buildTypes {
release {
signingConfig = signingConfigs.getByName("release")
isMinifyEnabled = true // R8 on
isShrinkResources = true
}
debug {
signingConfig = signingConfigs.getByName("debug") // local debug key only
}
}
# The pipeline supplies them. Nothing sensitive is in the repo or in this file.
- task: DownloadSecureFile@1
name: keystore
inputs: { secureFile: 'release.jks' }
- task: Gradle@3
env:
KEYSTORE_PATH: $(keystore.secureFilePath)
KEYSTORE_PASSWORD: $(KeystorePassword) # Key Vault-backed variable group
KEY_ALIAS: $(KeyAlias)
KEY_PASSWORD: $(KeyPassword)
inputs:
tasks: 'bundleRelease'

Same rule on iOS: the .p12 and the provisioning profile are Secure Files, their passwords are variable-group secrets.

Back to Native Mobile Standards.

👤 Owner: Fadel Sabbagh🗓 Last reviewed: 2026-07-25

اقرأ هذه الصفحة بالعربية