Compose架构实战密码:用MVI重构电商搜索模块的启示录

电商应用的搜索功能看似简单,实则暗藏玄机。当用户输入关键词时,我们需要实时展示联想词;当用户选择筛选项时,搜索结果需要动态更新;当用户滚动列表时,又要处理分页加载。这些看似独立的交互背后,隐藏着复杂的状态流转和业务逻辑。传统的MVVM架构在这种场景下往往力不从心,而MVI(Model-View-Intent)架构则展现出了独特的优势。

1. 电商搜索模块的架构挑战

电商搜索功能通常包含以下几个核心场景:

  • 关键词联想:用户输入时实时展示搜索建议
  • 筛选项联动:价格区间、品牌等多维度筛选条件相互影响
  • 分页加载:滚动到底部自动加载更多结果
  • 搜索历史:持久化存储用户搜索记录

这些场景带来的技术挑战包括:

  1. 状态爆炸:每个交互都可能影响多个UI组件的状态
  2. 事件竞争:快速输入可能导致多个搜索请求同时进行
  3. 状态同步:筛选项变化需要立即反映到搜索结果
// 传统MVVM中可能出现的状态定义
class SearchViewModel {
    val keyword = MutableStateFlow("")
    val suggestions = MutableStateFlow<List<String>>(emptyList())
    val filters = MutableStateFlow<Filters>(Filters())
    val results = MutableStateFlow<List<Product>>(emptyList())
    val isLoading = MutableStateFlow(false)
    val error = MutableStateFlow<Throwable?>(null)
    // ...更多状态
}

2. MVI架构的核心思想

MVI架构由三个核心概念组成:

  1. Model:代表应用的状态,通常是不可变的数据类
  2. View:反映当前状态的UI呈现
  3. Intent:用户意图的抽象表示

与MVVM相比,MVI的关键区别在于:

特性 MVVM MVI
状态管理 多个分散的StateFlow 单一聚合的状态对象
数据流方向 双向绑定 严格单向数据流
事件处理 直接调用ViewModel方法 通过Intent发送
可预测性 中等
// MVI中的状态定义
data class SearchState(
    val keyword: String = "",
    val suggestions: List<String> = emptyList(),
    val filters: Filters = Filters(),
    val results: List<Product> = emptyList(),
    val isLoading: Boolean = false,
    val error: Throwable? = null
)

sealed class SearchIntent {
    data class UpdateKeyword(val keyword: String) : SearchIntent()
    data class UpdateFilters(val filters: Filters) : SearchIntent()
    object LoadMore : SearchIntent()
}

3. 电商搜索的MVI实现

3.1 状态建模

电商搜索的状态应该包含所有必要的UI信息:

data class SearchState(
    // 用户输入
    val keyword: String = "",
    
    // 搜索建议
    val suggestions: List<String> = emptyList(),
    
    // 筛选条件
    val filters: Filters = Filters(),
    
    // 搜索结果
    val results: List<Product> = emptyList(),
    val hasMore: Boolean = true,
    
    // 加载状态
    val isLoading: Boolean = false,
    val isSuggesting: Boolean = false,
    
    // 错误处理
    val error: Throwable? = null
) {
    // 派生状态,避免重复计算
    val filteredResults: List<Product> by lazy {
        results.filter { product ->
            filters.brands?.contains(product.brand) ?: true &&
            filters.priceRange?.contains(product.price) ?: true
        }
    }
}

3.2 Intent处理

所有用户操作都转化为Intent发送给ViewModel:

class SearchViewModel : ViewModel() {
    private val _state = MutableStateFlow(SearchState())
    val state: StateFlow<SearchState> = _state.asStateFlow()
    
    private val intentChannel = Channel<SearchIntent>(capacity = Channel.UNLIMITED)
    
    init {
        viewModelScope.launch {
            intentChannel.consumeAsFlow().collect { intent ->
                when (intent) {
                    is SearchIntent.UpdateKeyword -> updateKeyword(intent.keyword)
                    is SearchIntent.UpdateFilters -> updateFilters(intent.filters)
                    SearchIntent.LoadMore -> loadMore()
                }
            }
        }
    }
    
    fun processIntent(intent: SearchIntent) {
        viewModelScope.launch {
            intentChannel.send(intent)
        }
    }
    
    private suspend fun updateKeyword(keyword: String) {
        _state.update { it.copy(keyword = keyword, isSuggesting = true) }
        
        // 防抖处理
        delay(300)
        if (keyword.isNotEmpty()) {
            val suggestions = repository.getSuggestions(keyword)
            _state.update { it.copy(suggestions = suggestions, isSuggesting = false) }
        }
    }
    
    // 其他处理函数...
}

3.3 Compose UI集成

在Compose中,我们可以轻松订阅状态变化:

@Composable
fun SearchScreen(viewModel: SearchViewModel) {
    val state by viewModel.state.collectAsState()
    
    Column(modifier = Modifier.fillMaxSize()) {
        SearchBar(
            keyword = state.keyword,
            onKeywordChange = { viewModel.processIntent(SearchIntent.UpdateKeyword(it)) }
        )
        
        when {
            state.isLoading -> LoadingIndicator()
            state.error != null -> ErrorMessage(state.error)
            else -> ProductGrid(
                products = state.filteredResults,
                onLoadMore = { viewModel.processIntent(SearchIntent.LoadMore) }
            )
        }
    }
}

4. MVI在电商场景中的优势

4.1 状态管理的统一性

MVI将所有UI状态集中在一个不可变对象中,带来以下好处:

  • 单一数据源:不再需要担心状态分散导致的同步问题
  • 时间旅行调试:可以记录状态历史,方便复现问题
  • 线程安全:不可变状态天然适合多线程环境

4.2 业务逻辑的可测试性

由于所有状态变更都集中在ViewModel中,测试变得非常简单:

@Test
fun `update keyword should trigger suggestions`() = runTest {
    val viewModel = SearchViewModel(mockRepository)
    
    viewModel.processIntent(SearchIntent.UpdateKeyword("手机"))
    advanceUntilIdle()
    
    val state = viewModel.state.value
    assertEquals("手机", state.keyword)
    assertTrue(state.suggestions.isNotEmpty())
}

4.3 性能优化空间

MVI架构为性能优化提供了良好基础:

  1. 防抖处理:在Intent处理层可以轻松实现输入防抖
  2. 请求取消:当新Intent到达时,可以取消未完成的请求
  3. 缓存策略:可以在状态管理层实现智能缓存
private var searchJob: Job? = null

private suspend fun updateKeyword(keyword: String) {
    // 取消之前的搜索
    searchJob?.cancel()
    
    _state.update { it.copy(keyword = keyword) }
    
    searchJob = viewModelScope.launch {
        delay(300) // 防抖
        val suggestions = repository.getSuggestions(keyword)
        _state.update { it.copy(suggestions = suggestions) }
    }
}

5. 实战建议与陷阱规避

5.1 状态设计原则

  • 最小化原则:只存储必要的状态,派生状态通过计算获得
  • 扁平化原则:避免嵌套过深的状态结构
  • 不可变原则:所有状态修改都通过copy创建新实例

5.2 常见陷阱

  1. 过度聚合状态:将完全不相关的状态强行合并
  2. 忽视中间状态:忘记处理加载中等过渡状态
  3. Intent设计不当:将多个操作合并到一个Intent中

5.3 性能优化技巧

// 使用派生状态减少重组
val filteredProducts by derivedStateOf {
    state.results.filter { it.matches(state.filters) }
}

// 使用rememberSaveable保存UI状态
var expandedFilters by rememberSaveable { mutableStateOf(false) }

在电商搜索这种复杂交互场景下,MVI架构展现出了比传统MVVM更强大的优势。它通过单向数据流和集中状态管理,让代码更易于理解、测试和维护。虽然学习曲线略陡峭,但一旦掌握,将大幅提升复杂界面的开发效率和质量。

Logo

电商企业物流数字化转型必备!快递鸟 API 接口,72 小时快速完成物流系统集成。全流程实战1V1指导,营造开放的API技术生态圈。

更多推荐