Mobile Development 19 min read

How Tiny UI Changes Can Cripple Android Apps—and How Clean Architecture Fixes It

Even a minor UI adjustment, like reshaping a product list or tweaking a cart button, can trigger crashes, state loss, and hard‑to‑track side effects in Android apps; this article explains why solid architecture—separating concerns with Clean Architecture, SOLID principles, and layered design—prevents such disasters and eases future migrations.

AndroidPub
AndroidPub
AndroidPub
How Tiny UI Changes Can Cripple Android Apps—and How Clean Architecture Fixes It

1. Architecture Chaos: Small Requirements, Big Disasters

You receive a tiny request—adjust the product list layout and tweak the "Add to Cart" logic. What seems like a 30‑minute task can turn into days of crashes, state loss, and mysterious side effects.

This is the consequence of treating software architecture as an after‑the‑fact concern. Patterns such as MVVM, Clean Architecture, and SOLID are not decorative concepts; they are the "anchor" for building extensible, change‑resistant, and maintainable applications.

2. Core Principle: Separation of Concerns

All good architectures revolve around a single idea: Separation of Concerns .

The principle states that an application should be divided into independent parts, each responsible for a distinct core duty. For example, a Fragment that displays a product list should only handle UI rendering and user interactions, while data fetching and local storage belong to other layers.

Think of a professional kitchen: chefs focus on cooking, not on the oven’s circuitry or the refrigerator’s cooling system. Each role has its own tools, enabling parallel work and easy replacement of equipment without disrupting the whole operation.

In Android development this separation shines: it lets you handle screen rotation, device variations, and code readability with ease.

3. Clean Architecture Layers on Android

Separation of concerns answers "why"; Clean Architecture’s layered approach answers "how". A typical Android codebase is split into three layers:

3.1 Presentation Layer (UI)

This is the part the user directly sees and interacts with, composed of Activity, Fragment, Compose components, etc. Its sole responsibility is to display data prepared by a ViewModel and forward user actions. No business logic should reside here.

Example: a product‑detail Activity only shows the name, price, and image received from the ViewModel. Clicking "Add to Cart" is passed to the ViewModel, not handled inside the Activity.

3.2 Domain Layer (Business Core)

This is the "heart" of the app, usually a pure Kotlin module. It contains business rules, validation logic, and orchestrates data flow. It is often called "Use Cases" or "Interactors" and knows nothing about Android framework classes such as Context, Room, or Retrofit.

For instance, a use case that determines whether a user is a premium member evaluates registration time and purchase history, without caring where that data originates.

3.3 Data Layer (Infrastructure)

The sole job of this layer is to interact with data sources—REST APIs via Retrofit, local storage via Room, etc. It hides the concrete source behind a Repository interface.

Example: ProductRepository offers methods to fetch product lists from the network or retrieve cached favorites from the database, exposing a unified API to the rest of the app.

The SOLID principles (single responsibility, open‑closed, Liskov substitution, interface segregation, dependency inversion) act as microscopic rules that keep each layer clean and maintainable.

4. Anti‑Pattern: The Monolithic Activity

Consider an order page implemented as a single massive OrderActivity. The code below illustrates typical problems:

class OrderActivity : AppCompatActivity() {
    private lateinit var orderListRecyclerView: RecyclerView
    private lateinit var progressBar: ProgressBar
    private lateinit var emptyStateTextView: TextView
    private val apiService = ApiClient.retrofit.create(ApiService::class.java)
    private val db = AppDatabase.getInstance(this)

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_order)
        // init views …
        val userId = intent.getIntExtra("USER_ID", -1)
        if (userId != -1) fetchAndDisplayOrders(userId)
    }

    private fun fetchAndDisplayOrders(userId: Int) {
        progressBar.visibility = View.VISIBLE
        emptyStateTextView.visibility = View.GONE
        apiService.getOrders(userId).enqueue(object : Callback<List<OrderApiResponse>> {
            override fun onResponse(call: Call<List<OrderApiResponse>>, response: Response<List<OrderApiResponse>>) {
                progressBar.visibility = View.GONE
                val orderResponses = response.body()
                if (response.isSuccessful && orderResponses != null && orderResponses.isNotEmpty()) {
                    // business logic mixed with UI logic
                    val filteredOrders = orderResponses.filter { it.status != "已取消" }
                    val isVip = db.userDao().getUser(userId)?.isVip ?: false
                    val displayOrders = if (isVip) filteredOrders else filteredOrders.take(5)
                    // UI update
                    val adapter = OrderAdapter(displayOrders)
                    orderListRecyclerView.adapter = adapter
                } else {
                    emptyStateTextView.visibility = View.VISIBLE
                    emptyStateTextView.text = "暂无订单"
                }
            }
            override fun onFailure(call: Call<List<OrderApiResponse>>, t: Throwable) {
                progressBar.visibility = View.GONE
                emptyStateTextView.visibility = View.VISIBLE
                emptyStateTextView.text = "网络错误,加载失败"
            }
        })
    }
}

Issues include:

Fragility: Changing the OrderApiResponse model can crash the activity.

Non‑reusability: The logic cannot be reused for a Wear OS Tile because it is tightly coupled to the Activity lifecycle.

Testing difficulty: Unit‑testing requires mocking Retrofit, the Android framework, and the database.

Configuration changes: Rotating the screen re‑triggers network calls and loses data.

5. Migration Pain Points

Management now demands two new features: replace the REST API with GraphQL and create a Wear OS Tile to show order summaries. The monolithic OrderActivity becomes a nightmare—both the data‑fetching method and the UI logic must be duplicated and heavily modified.

6. Refactoring with Clean Architecture

We rebuild the order display using a layered architecture based on ViewModel, use cases, and Repository.

6.1 Step 1: Data Layer – Define Contract and Implementation

// —— Domain layer ——
interface OrderRepository {
    suspend fun getOrdersByUserId(userId: Int): Result<List<Order>>
}

data class Order(
    val id: String,
    val productName: String,
    val price: Double,
    val status: String,
    val createTime: LocalDateTime
)

// —— Data layer ——
class RestOrderRepositoryImpl(private val apiService: ApiService) : OrderRepository {
    override suspend fun getOrdersByUserId(userId: Int): Result<List<Order>> {
        return try {
            val response = apiService.getOrders(userId)
            val orderResponses = response.body()
            if (response.isSuccessful && orderResponses != null) {
                val domainOrders = orderResponses.map { apiOrder ->
                    Order(
                        id = apiOrder.id,
                        productName = apiOrder.productName,
                        price = apiOrder.price,
                        status = apiOrder.status,
                        createTime = apiOrder.createTime
                    )
                }
                Result.success(domainOrders)
            } else {
                Result.failure(Exception("API error, code: ${response.code()}"))
            }
        } catch (e: Exception) {
            Result.failure(e)
        }
    }
}

class GraphqlOrderRepositoryImpl(private val graphqlClient: GraphqlClient) : OrderRepository {
    override suspend fun getOrdersByUserId(userId: Int): Result<List<Order>> {
        return try {
            val query = """
                query GetOrders(${'$'}userId: Int!) {
                    orders(userId: ${'$'}userId) {
                        id
                        productName
                        price
                        status
                        createTime
                    }
                }
            """.trimIndent()
            val variables = mapOf("userId" to userId)
            val response = graphqlClient.execute(query, variables)
            val graphqlOrders = response.getDataOrNull()?.getList("orders") ?: emptyList()
            val domainOrders = graphqlOrders.map { graphqlOrder ->
                Order(
                    id = graphqlOrder.getString("id"),
                    productName = graphqlOrder.getString("productName"),
                    price = graphqlOrder.getDouble("price"),
                    status = graphqlOrder.getString("status"),
                    createTime = LocalDateTime.parse(graphqlOrder.getString("createTime"))
                )
            }
            Result.success(domainOrders)
        } catch (e: Exception) {
            Result.failure(e)
        }
    }
}

6.2 Step 2: Domain Layer – Order Use Case

class GetUserOrdersUseCase(
    private val orderRepository: OrderRepository,
    private val userRepository: UserRepository // for VIP check
) {
    suspend fun execute(userId: Int): Result<List<DisplayOrder>> {
        val ordersResult = orderRepository.getOrdersByUserId(userId)
        if (ordersResult.isFailure) return Result.failure(ordersResult.exceptionOrNull() ?: Exception("未知错误"))
        val orders = ordersResult.getOrNull() ?: return Result.failure(Exception("未获取到订单"))

        val userResult = userRepository.getUserById(userId)
        val isVip = if (userResult.isSuccess) userResult.getOrNull()?.isVip ?: false else false

        val filteredOrders = orders.filter { it.status != "已取消" }
        val displayOrders = if (isVip) filteredOrders else filteredOrders.take(5)

        val uiOrders = displayOrders.map { order ->
            DisplayOrder(
                id = order.id,
                productName = order.productName,
                price = "¥${order.price}",
                statusText = when (order.status) {
                    "已支付" -> "支付完成"
                    "已发货" -> "物流运输中"
                    "已签收" -> "订单完成"
                    else -> order.status
                },
                createTimeText = "下单时间:${order.createTime.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"))}"
            )
        }
        return Result.success(uiOrders)
    }
}

data class DisplayOrder(
    val id: String,
    val productName: String,
    val price: String,
    val statusText: String,
    val createTimeText: String
)

6.3 Step 3: Presentation Layer – ViewModel & Activity

class OrderViewModel(private val getUserOrdersUseCase: GetUserOrdersUseCase) : ViewModel() {
    private val _orderState = MutableStateFlow<OrderState>(OrderState.Loading)
    val orderState: StateFlow<OrderState> = _orderState.asStateFlow()

    fun loadUserOrders(userId: Int) {
        viewModelScope.launch {
            _orderState.value = OrderState.Loading
            val result = getUserOrdersUseCase.execute(userId)
            _orderState.value = result.fold(
                onSuccess = { OrderState.Success(it) },
                onFailure = { OrderState.Error(it.message ?: "未知错误") }
            )
        }
    }
}

sealed class OrderState {
    object Loading : OrderState()
    data class Success(val orders: List<DisplayOrder>) : OrderState()
    data class Error(val message: String) : OrderState()
}

class OrderActivity : AppCompatActivity() {
    private val viewModel: OrderViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_order)
        // find views …
        lifecycleScope.launchWhenStarted {
            viewModel.orderState.collect { state ->
                when (state) {
                    is OrderState.Loading -> { /* show loading */ }
                    is OrderState.Success -> { /* show list or empty state */ }
                    is OrderState.Error -> { /* show error message */ }
                }
            }
        }
        val userId = intent.getIntExtra("USER_ID", -1)
        if (userId != -1) viewModel.loadUserOrders(userId)
    }
}

7. Architectural Benefits

7.1 Smooth Backend Migration

Switching from REST to GraphQL only requires providing a new GraphqlOrderRepositoryImpl in the DI configuration; the use case, ViewModel, and Activity remain untouched.

7.2 Multi‑Platform Logic Reuse

The same GetUserOrdersUseCase can be injected into a Wear OS Tile service, delivering identical business logic without any UI coupling.

7.3 Precise Problem Isolation

New order‑filter rules that don’t take effect are likely still hard‑coded in the use case.

Incorrect UI display while the use case returns correct data points to the Activity or its layout.

Data‑fetch failures should be inspected in the concrete OrderRepository implementation.

8. The Essence of Architecture

Good architecture doesn’t force complex applications into a rigid template; it manages complexity by placing the right code in the right place. By cleanly separating frequently changing parts (UI, data sources) from stable core logic, you gain flexibility, testability, and resilience against rapid market changes.

AndroidKotlinClean ArchitectureMVVMSOLIDUse Cases
AndroidPub
Written by

AndroidPub

Senior Android Developer & Interviewer, regularly sharing original tech articles, learning resources, and practical interview guides. Welcome to follow and contribute!

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.