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.
1. The standard screen, both platforms
Section titled “1. The standard screen, both platforms”One immutable UI state, owned by the ViewModel or model. The view renders it and emits events — nothing else.
// Androiddata class BranchesUiState( val branches: List<Branch> = emptyList(), val isLoading: Boolean = false, val error: String? = null,)
@HiltViewModelclass 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) } } }}
@Composablefun BranchesScreen(viewModel: BranchesViewModel = hiltViewModel()) { val state by viewModel.state.collectAsStateWithLifecycle() BranchesContent(state = state, onRefresh = viewModel::load)}// iOS — the same shape.@Observablefinal 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() } }}2. The repository seam
Section titled “2. The repository seam”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.
3. Signing without secrets in the repo
Section titled “3. Signing without secrets in the repo”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:
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.