30、实战项目(下):搜索功能、用户登录、主题切换、性能优化、总结与展望

好,咱们接着上一章继续聊。上一章我们把项目架子搭起来了,数据层也铺好了。这一章,咱们要把剩下的几个核心功能补全,再聊聊性能优化,最后做个总结。

说实话,一个项目做到这个份上,才算真正有了“灵魂”。搜索、登录、主题切换,这些都是用户天天打交道的功能。做得顺手,用户就舒服;做得卡顿,用户扭头就走。

一、搜索功能:别小看这个输入框

搜索功能看起来简单,不就是个输入框加个列表过滤吗?但我在项目中踩过不少坑。比如用户输入太快,频繁触发网络请求,直接把后端打挂了。再比如中文输入法下的拼音匹配问题。

咱们用 Compose 实现一个带防抖的本地搜索。

@Composable
fun SearchScreen(viewModel: NoteViewModel) {
    var query by remember { mutableStateOf("") }
    val debouncedQuery = remember { mutableStateOf("") }

    // 防抖:用户停止输入 300ms 后再搜索
    LaunchedEffect(query) {
        delay(300)
        debouncedQuery.value = query
    }

    val searchResults by viewModel.searchNotes(debouncedQuery.value).collectAsState(initial = emptyList())

    Column(modifier = Modifier.padding(16.dp)) {
        OutlinedTextField(
            value = query,
            onValueChange = { query = it },
            placeholder = { Text("搜索笔记...") },
            leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
            modifier = Modifier.fillMaxWidth()
        )

        Spacer(modifier = Modifier.height(12.dp))

        LazyColumn {
            items(searchResults) { note ->
                NoteItem(note = note)
            }
        }
    }
}
💡 我的习惯: 防抖延迟设在 300ms 左右比较合适。太短了防不住,太长了用户觉得卡。另外,如果搜索是本地数据,用 LaunchedEffect 就够了;如果是网络搜索,建议用 Flow.debounce() 配合 flatMapLatest

二、用户登录:Token 管理与状态持久化

登录功能几乎是每个 App 的标配。但很多新手容易忽略一个问题:登录状态怎么保持? 总不能每次打开 App 都让用户重新登录吧?

我的做法是:用 DataStore 存 Token,用拦截器自动附加 Token,用 sealed class 管理登录状态。

// 登录状态
sealed class AuthState {
    object Loading : AuthState()
    object Unauthenticated : AuthState()
    data class Authenticated(val token: String) : AuthState()
}

// 登录 ViewModel
class AuthViewModel(private val dataStore: DataStore<Preferences>) : ViewModel() {

    private val _authState = MutableStateFlow<AuthState>(AuthState.Loading)
    val authState: StateFlow<AuthState> = _authState.asStateFlow()

    init {
        // 启动时检查本地 Token
        viewModelScope.launch {
            val token = dataStore.data.first()[TOKEN_KEY] ?: ""
            _authState.value = if (token.isBlank()) {
                AuthState.Unauthenticated
            } else {
                AuthState.Authenticated(token)
            }
        }
    }

    fun login(username: String, password: String) {
        viewModelScope.launch {
            // 模拟网络请求
            delay(1000)
            val token = "mock_token_${System.currentTimeMillis()}"
            dataStore.edit { it[TOKEN_KEY] = token }
            _authState.value = AuthState.Authenticated(token)
        }
    }

    fun logout() {
        viewModelScope.launch {
            dataStore.edit { it.remove(TOKEN_KEY) }
            _authState.value = AuthState.Unauthenticated
        }
    }

    companion object {
        private val TOKEN_KEY = stringPreferencesKey("auth_token")
    }
}
⚠️ 我曾经踩过的坑: 把 Token 存在 SharedPreferences 里,结果被其他进程读走了。后来我改用 EncryptedSharedPreferences 或者 DataStore + 加密。另外,Token 过期后要自动跳转到登录页,这个逻辑别忘了。

三、主题切换:白天黑夜,一键搞定

主题切换现在几乎是 App 的标配了。Compose 里做这个特别方便,核心就是 MaterialTheme 配合 CompositionLocal

我习惯把主题定义成 sealed class,然后通过一个全局的 StateHolder 来控制。

// 主题枚举
enum class ThemeMode { LIGHT, DARK, SYSTEM }

// 主题管理
class ThemeManager(private val dataStore: DataStore<Preferences>) {

    val themeMode: Flow<ThemeMode> = dataStore.data.map { prefs ->
        val name = prefs[THEME_KEY] ?: ThemeMode.SYSTEM.name
        ThemeMode.valueOf(name)
    }

    suspend fun setThemeMode(mode: ThemeMode) {
        dataStore.edit { it[THEME_KEY] = mode.name }
    }

    companion object {
        private val THEME_KEY = stringPreferencesKey("theme_mode")
    }
}

// 在 Compose 中使用
@Composable
fun AppTheme(
    themeManager: ThemeManager,
    content: @Composable () -> Unit
) {
    val themeMode by themeManager.themeMode.collectAsState(initial = ThemeMode.SYSTEM)

    val isDarkTheme = when (themeMode) {
        ThemeMode.LIGHT -> false
        ThemeMode.DARK -> true
        ThemeMode.SYSTEM -> isSystemInDarkTheme()
    }

    MaterialTheme(
        colorScheme = if (isDarkTheme) darkColorScheme() else lightColorScheme(),
        content = content
    )
}
💡 我个人建议: 主题切换最好也持久化到 DataStore 里,这样用户切换一次后,下次打开 App 还是那个主题。另外,别忘了适配系统主题(SYSTEM 模式),很多用户喜欢跟着系统走。

四、性能优化:别让 App 卡成 PPT

性能优化是个大话题,但咱们这个项目里,有几个点特别值得注意。

4.1 列表性能:LazyColumn 的正确用法

很多新手用 LazyColumn 时,喜欢在 item 里写复杂计算。其实 key 参数特别重要,它能让 Compose 知道哪些 item 需要重组。

LazyColumn {
    items(
        items = notes,
        key = { it.id }  // 给每个 item 一个稳定的 key
    ) { note ->
        NoteItem(note = note)
    }
}

4.2 重组优化:remember 和 derivedStateOf

Compose 的重组机制很智能,但如果你不小心,它也会做很多无用功。比如下面这个例子:

// ❌ 错误写法:每次重组都重新计算
val filteredList = notes.filter { it.title.contains(query) }

// ✅ 正确写法:用 derivedStateOf 缓存计算结果
val filteredList by remember(notes, query) {
    derivedStateOf { notes.filter { it.title.contains(query) } }
}

4.3 图片加载:用 Coil 别用 Glide

在 Compose 里,我强烈推荐用 Coil。它原生支持 Compose,而且内存管理做得很好。

AsyncImage(
    model = ImageRequest.Builder(LocalContext.current)
        .data("https://example.com/image.jpg")
        .crossfade(true)
        .build(),
    contentDescription = null,
    modifier = Modifier.size(64.dp)
)
⚠️ 我曾经踩过的坑: 列表里加载大图,结果 OOM 了。后来我强制限制了图片尺寸,并且用了 MemoryCache。Coil 默认就有缓存,但别忘了在 Application 里配置 ImageLoader

五、总结与展望:这条路才刚刚开始

好了,咱们这个实战项目到这里就结束了。从项目搭建、数据层、UI 层,到搜索、登录、主题切换、性能优化,咱们走了一遍完整的开发流程。

说实话,30 章的内容,不可能把每个细节都讲透。但我希望你能掌握一个核心思路:用 Compose 做 Android 开发,核心是状态管理和重组机制。只要把状态管好了,UI 自然就流畅了。

最后,我想说几句心里话。

做技术这件事,没有捷径。我见过太多人,看了一堆教程,但自己不动手写,结果面试时一问三不知。我的建议是:把咱们这个项目从头到尾自己敲一遍,遇到问题就去查文档、看源码。等你把这个项目跑通了,Compose 的基本功就算扎实了。

未来 Compose 会越来越普及。Google 已经在推 Compose 作为 Android 开发的首选方案。你现在学的这些,两三年后依然有用。

嗯,就聊这么多吧。希望你能从这门课里真正学到东西。咱们江湖再见。

📌 核心要点回顾:
  • 搜索功能:防抖 + LaunchedEffect
  • 用户登录:Token 管理 + DataStore 持久化
  • 主题切换:CompositionLocal + 枚举模式
  • 性能优化:key、derivedStateOf、Coil
  • 核心思想:状态驱动 UI,重组最小化
实战项目(下)核心知识体系 实战项目(下) 🔍 搜索功能 防抖处理 (300ms) LaunchedEffect + delay 本地/网络搜索 👤 用户登录 Token 管理 DataStore 持久化 Sealed Class 状态管理 🎨 主题切换 LIGHT / DARK / SYSTEM CompositionLocal DataStore 持久化 ⚡ 性能优化 LazyColumn + key derivedStateOf 缓存 Coil 图片加载 核心思想:状态驱动 UI,重组最小化

公众号:蓝海资料掘金营,微信deep3321