Jetpack Compose 从入门到精通(七):高级特性与实战

138 阅读5分钟

深入探索 Compose 的高级特性,通过完整实战项目掌握企业级开发技能。


前言

经过前六篇的学习,我们已经掌握了 Compose 的核心开发技能。本篇文章将探索 Compose 的高级特性,并通过一个完整的实战项目,将所学知识融会贯通。


一、自定义绘制

1.1 Canvas 基础

Canvas 是 Compose 中自定义绘制的核心组件:

@Composable
fun CustomCanvas() {
    Canvas(
        modifier = Modifier
            .fillMaxWidth()
            .height(200.dp)
    ) {
        // 绘制矩形
        drawRect(
            color = Color.Blue,
            topLeft = Offset(0f, 0f),
            size = Size(100f, 100f)
        )
        
        // 绘制圆形
        drawCircle(
            color = Color.Red,
            radius = 50f,
            center = Offset(200f, 100f)
        )
        
        // 绘制线条
        drawLine(
            color = Color.Green,
            start = Offset(0f, 200f),
            end = Offset(size.width, 200f),
            strokeWidth = 5f
        )
    }
}

1.2 Path 绘制

@Composable
fun TriangleShape() {
    Canvas(modifier = Modifier.size(100.dp)) {
        val path = Path().apply {
            moveTo(size.width / 2, 0f)
            lineTo(size.width, size.height)
            lineTo(0f, size.height)
            close()
        }
        
        drawPath(
            path = path,
            color = Color.Blue
        )
    }
}

@Composable
fun CurvedLine() {
    Canvas(modifier = Modifier.fillMaxWidth().height(200.dp)) {
        val path = Path().apply {
            moveTo(0f, size.height / 2)
            
            // 二次贝塞尔曲线
            quadraticBezierTo(
                size.width / 2, 0f,
                size.width, size.height / 2
            )
        }
        
        drawPath(
            path = path,
            color = Color.Red,
            style = Stroke(width = 5f)
        )
    }
}

1.3 渐变绘制

@Composable
fun GradientBackground() {
    Canvas(modifier = Modifier.fillMaxSize()) {
        val gradient = Brush.linearGradient(
            colors = listOf(Color.Blue, Color.Purple, Color.Red),
            start = Offset(0f, 0f),
            end = Offset(size.width, size.height)
        )
        
        drawRect(brush = gradient)
    }
}

@Composable
fun RadialGradientCircle() {
    Canvas(modifier = Modifier.size(200.dp)) {
        val gradient = Brush.radialGradient(
            colors = listOf(Color.Yellow, Color.Orange, Color.Red),
            center = center,
            radius = size.minDimension / 2
        )
        
        drawCircle(brush = gradient)
    }
}

1.4 实战:饼图组件

@Composable
fun PieChart(
    data: List<PieData>,
    modifier: Modifier = Modifier
        .size(200.dp)
) {
    val total = data.sumOf { it.value.toDouble() }.toFloat()
    var startAngle = -90f
    
    Canvas(modifier = modifier) {
        data.forEach { item ->
            val sweepAngle = (item.value / total) * 360f
            
            drawArc(
                color = item.color,
                startAngle = startAngle,
                sweepAngle = sweepAngle,
                useCenter = true
            )
            
            startAngle += sweepAngle
        }
    }
}

data class PieData(
    val value: Float,
    val color: Color,
    val label: String
)

二、图形变换

2.1 GraphicsLayer

@Composable
fun TransformedBox() {
    var rotation by remember { mutableStateOf(0f) }
    var scale by remember { mutableStateOf(1f) }
    
    Box(
        modifier = Modifier
            .graphicsLayer {
                rotationZ = rotation
                scaleX = scale
                scaleY = scale
                // 阴影
                shadowElevation = 10f
                // 裁剪
                clip = true
                shape = RoundedCornerShape(16.dp)
            }
            .size(100.dp)
            .background(Color.Blue)
            .clickable {
                rotation += 45f
                scale = if (scale == 1f) 1.5f else 1f
            }
    )
}

2.2 BlendMode 混合模式

@Composable
fun BlendModeDemo() {
    Canvas(modifier = Modifier.fillMaxSize()) {
        // 绘制红色圆形
        drawCircle(
            color = Color.Red,
            radius = 100f,
            center = Offset(size.width / 2 - 50, size.height / 2)
        )
        
        // 使用混合模式绘制蓝色圆形
        drawCircle(
            color = Color.Blue,
            radius = 100f,
            center = Offset(size.width / 2 + 50, size.height / 2),
            blendMode = BlendMode.Multiply  // 正片叠底
        )
    }
}

三、窗口 Insets 处理

3.1 什么是 Insets?

Insets 表示系统 UI 占据的区域:

  • 状态栏(Status Bar)
  • 导航栏(Navigation Bar)
  • 键盘(IME)
  • 刘海屏/挖孔屏(Display Cutout)

3.2 WindowInsets API

@Composable
fun InsetsDemo() {
    // 获取各种 Insets
    val statusBars = WindowInsets.statusBars
    val navigationBars = WindowInsets.navigationBars
    val ime = WindowInsets.ime
    val safeDrawing = WindowInsets.safeDrawing
    
    // 应用到布局
    Box(
        modifier = Modifier
            .fillMaxSize()
            .windowInsetsPadding(statusBars)
            .windowInsetsPadding(navigationBars)
    ) {
        // 内容不会被系统栏遮挡
    }
}

3.3 边缘到边缘适配

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        
        // 启用边缘到边缘
        enableEdgeToEdge()
        
        setContent {
            MyApp()
        }
    }
}

@Composable
fun EdgeToEdgeScreen() {
    Scaffold(
        modifier = Modifier.fillMaxSize(),
        topBar = {
            TopAppBar(
                title = { Text("标题") },
                modifier = Modifier.windowInsetsPadding(WindowInsets.statusBars)
            )
        },
        bottomBar = {
            NavigationBar(
                modifier = Modifier.windowInsetsPadding(WindowInsets.navigationBars)
            ) {
                // 导航项
            }
        }
    ) { innerPadding ->
        // 内容区域自动避开系统栏
        LazyColumn(
            modifier = Modifier.padding(innerPadding)
        ) {
            // 列表内容
        }
    }
}

3.4 键盘处理

@Composable
fun KeyboardAwareScreen() {
    val imeInsets = WindowInsets.ime
    val isKeyboardVisible = imeInsets.getBottom(LocalDensity.current) > 0
    
    Column(
        modifier = Modifier
            .fillMaxSize()
            .windowInsetsPadding(WindowInsets.statusBars)
    ) {
        // 内容区域
        LazyColumn(
            modifier = Modifier.weight(1f)
        ) {
            // 列表项
        }
        
        // 输入框
        OutlinedTextField(
            value = text,
            onValueChange = { text = it },
            modifier = Modifier
                .fillMaxWidth()
                .windowInsetsPadding(WindowInsets.ime)
        )
    }
}

四、实战项目:新闻阅读 App

4.1 项目架构

app/
├── data/
│   ├── remote/
│   │   ├── NewsApi.kt
│   │   └── dto/
│   ├── local/
│   │   ├── NewsDao.kt
│   │   └── AppDatabase.kt
│   ├── repository/
│   │   └── NewsRepository.kt
│   └── model/
│       └── Article.kt
├── ui/
│   ├── theme/
│   │   ├── Color.kt
│   │   ├── Theme.kt
│   │   └── Type.kt
│   ├── component/
│   │   ├── ArticleCard.kt
│   │   ├── LoadingIndicator.kt
│   │   └── ErrorMessage.kt
│   ├── screen/
│   │   ├── home/
│   │   │   ├── HomeScreen.kt
│   │   │   ├── HomeViewModel.kt
│   │   │   └── HomeUiState.kt
│   │   ├── detail/
│   │   │   ├── DetailScreen.kt
│   │   │   └── DetailViewModel.kt
│   │   └── bookmark/
│   │       └── BookmarkScreen.kt
│   └── navigation/
│       └── AppNavigation.kt
└── di/
    └── AppModule.kt

4.2 数据层实现

// 数据模型
data class Article(
    val id: String,
    val title: String,
    val summary: String,
    val imageUrl: String,
    val publishedAt: String,
    val isBookmarked: Boolean = false
)

// API 接口
interface NewsApi {
    @GET("top-headlines")
    suspend fun getTopHeadlines(
        @Query("country") country: String = "us",
        @Query("apiKey") apiKey: String = BuildConfig.API_KEY
    ): NewsResponse
    
    @GET("everything")
    suspend fun searchNews(
        @Query("q") query: String,
        @Query("apiKey") apiKey: String = BuildConfig.API_KEY
    ): NewsResponse
}

// 数据库
@Dao
interface NewsDao {
    @Query("SELECT * FROM articles ORDER BY publishedAt DESC")
    fun getAllArticles(): Flow<List<ArticleEntity>>
    
    @Query("SELECT * FROM articles WHERE isBookmarked = 1")
    fun getBookmarkedArticles(): Flow<List<ArticleEntity>>
    
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertArticles(articles: List<ArticleEntity>)
    
    @Query("UPDATE articles SET isBookmarked = :isBookmarked WHERE id = :articleId")
    suspend fun updateBookmark(articleId: String, isBookmarked: Boolean)
}

// Repository
class NewsRepository @Inject constructor(
    private val api: NewsApi,
    private val dao: NewsDao
) {
    fun getArticles(): Flow<List<Article>> = flow {
        try {
            val response = api.getTopHeadlines()
            val articles = response.articles.map { it.toDomain() }
            dao.insertArticles(articles.map { it.toEntity() })
        } catch (e: Exception) {
            // 网络失败时使用本地缓存
        }
        
        emitAll(dao.getAllArticles().map { list ->
            list.map { it.toDomain() }
        })
    }
    
    suspend fun toggleBookmark(articleId: String, isBookmarked: Boolean) {
        dao.updateBookmark(articleId, isBookmarked)
    }
}

4.3 ViewModel 实现

sealed interface HomeUiState {
    data object Loading : HomeUiState
    data class Success(val articles: List<Article>) : HomeUiState
    data class Error(val message: String) : HomeUiState
}

@HiltViewModel
class HomeViewModel @Inject constructor(
    private val repository: NewsRepository
) : ViewModel() {
    
    private val _uiState = MutableStateFlow<HomeUiState>(HomeUiState.Loading)
    val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow()
    
    init {
        loadArticles()
    }
    
    private fun loadArticles() {
        viewModelScope.launch {
            repository.getArticles()
                .onStart { _uiState.value = HomeUiState.Loading }
                .catch { e ->
                    _uiState.value = HomeUiState.Error(e.message ?: "Unknown error")
                }
                .collect { articles ->
                    _uiState.value = HomeUiState.Success(articles)
                }
        }
    }
    
    fun toggleBookmark(articleId: String, isBookmarked: Boolean) {
        viewModelScope.launch {
            repository.toggleBookmark(articleId, isBookmarked)
        }
    }
    
    fun refresh() {
        loadArticles()
    }
}

4.4 UI 实现

@Composable
fun HomeScreen(
    viewModel: HomeViewModel = hiltViewModel(),
    onArticleClick: (String) -> Unit
) {
    val uiState by viewModel.uiState.collectAsState()
    
    Scaffold(
        topBar = {
            TopAppBar(
                title = { Text("新闻") },
                actions = {
                    IconButton(onClick = { viewModel.refresh() }) {
                        Icon(Icons.Default.Refresh, contentDescription = "刷新")
                    }
                }
            )
        }
    ) { innerPadding ->
        when (val state = uiState) {
            is HomeUiState.Loading -> LoadingIndicator()
            is HomeUiState.Error -> ErrorMessage(
                message = state.message,
                onRetry = { viewModel.refresh() }
            )
            is HomeUiState.Success -> {
                ArticleList(
                    articles = state.articles,
                    onArticleClick = onArticleClick,
                    onBookmarkClick = { id, bookmarked ->
                        viewModel.toggleBookmark(id, bookmarked)
                    },
                    modifier = Modifier.padding(innerPadding)
                )
            }
        }
    }
}

@Composable
fun ArticleList(
    articles: List<Article>,
    onArticleClick: (String) -> Unit,
    onBookmarkClick: (String, Boolean) -> Unit,
    modifier: Modifier = Modifier
) {
    LazyColumn(
        modifier = modifier.fillMaxSize(),
        contentPadding = PaddingValues(16.dp),
        verticalArrangement = Arrangement.spacedBy(16.dp)
    ) {
        items(articles, key = { it.id }) { article ->
            ArticleCard(
                article = article,
                onClick = { onArticleClick(article.id) },
                onBookmarkClick = { onBookmarkClick(article.id, !article.isBookmarked) }
            )
        }
    }
}

@Composable
fun ArticleCard(
    article: Article,
    onClick: () -> Unit,
    onBookmarkClick: () -> Unit
) {
    Card(
        modifier = Modifier.fillMaxWidth(),
        onClick = onClick
    ) {
        Column {
            AsyncImage(
                model = article.imageUrl,
                contentDescription = null,
                modifier = Modifier
                    .fillMaxWidth()
                    .height(200.dp),
                contentScale = ContentScale.Crop
            )
            
            Column(modifier = Modifier.padding(16.dp)) {
                Text(
                    text = article.title,
                    style = MaterialTheme.typography.titleLarge
                )
                
                Spacer(modifier = Modifier.height(8.dp))
                
                Text(
                    text = article.summary,
                    style = MaterialTheme.typography.bodyMedium,
                    maxLines = 2,
                    overflow = TextOverflow.Ellipsis
                )
                
                Spacer(modifier = Modifier.height(8.dp))
                
                Row(
                    modifier = Modifier.fillMaxWidth(),
                    horizontalArrangement = Arrangement.SpaceBetween,
                    verticalAlignment = Alignment.CenterVertically
                ) {
                    Text(
                        text = article.publishedAt,
                        style = MaterialTheme.typography.bodySmall,
                        color = MaterialTheme.colorScheme.onSurfaceVariant
                    )
                    
                    IconButton(onClick = onBookmarkClick) {
                        Icon(
                            imageVector = if (article.isBookmarked) {
                                Icons.Default.Bookmark
                            } else {
                                Icons.Default.BookmarkBorder
                            },
                            contentDescription = "收藏"
                        )
                    }
                }
            }
        }
    }
}

4.5 导航配置

@Composable
fun AppNavigation() {
    val navController = rememberNavController()
    
    NavHost(
        navController = navController,
        startDestination = "home"
    ) {
        composable("home") {
            HomeScreen(
                onArticleClick = { articleId ->
                    navController.navigate("detail/$articleId")
                }
            )
        }
        
        composable(
            route = "detail/{articleId}",
            arguments = listOf(
                navArgument("articleId") { type = NavType.StringType }
            )
        ) { backStackEntry ->
            val articleId = backStackEntry.arguments?.getString("articleId") ?: ""
            DetailScreen(
                articleId = articleId,
                onBack = { navController.popBackStack() }
            )
        }
        
        composable("bookmarks") {
            BookmarkScreen()
        }
    }
}

五、性能优化实战

5.1 列表优化

@Composable
fun OptimizedArticleList(articles: List<Article>) {
    LazyColumn {
        items(
            items = articles,
            key = { it.id },                    // 使用 key 优化重组
            contentType = { it::class }         // 使用 contentType 优化复用
        ) { article ->
            ArticleCard(
                article = article,
                // 缓存 lambda 避免每次重组创建
                onClick = remember(article.id) {
                    { viewModel.onArticleClick(article.id) }
                }
            )
        }
    }
}

5.2 图片优化

@Composable
fun OptimizedImage(imageUrl: String) {
    AsyncImage(
        model = ImageRequest.Builder(LocalContext.current)
            .data(imageUrl)
            .crossfade(true)
            .placeholder(R.drawable.placeholder)
            .error(R.drawable.error)
            // 内存缓存
            .memoryCachePolicy(CachePolicy.ENABLED)
            // 磁盘缓存
            .diskCachePolicy(CachePolicy.ENABLED)
            // 图片尺寸
            .size(800, 600)
            .build(),
        contentDescription = null,
        contentScale = ContentScale.Crop,
        modifier = Modifier.fillMaxWidth()
    )
}

六、本篇小结

通过本篇文章,我们:

  1. 掌握了自定义绘制:Canvas、Path、渐变绘制
  2. 理解了图形变换:GraphicsLayer、BlendMode
  3. 学会了 Insets 处理:边缘到边缘适配、键盘处理
  4. 完成了实战项目:新闻阅读 App 的完整实现
  5. 实践了性能优化:列表优化、图片优化

系列总结

经过七篇的学习,我们系统地掌握了 Jetpack Compose:

篇章核心内容
第一篇环境搭建、Composable 基础、Material 组件入门
第二篇Modifier 原理、布局系统、LazyColumn、ConstraintLayout
第三篇Slot Table、状态管理、ViewModel、副作用 API
第四篇Material Design 3、主题定制、深色模式、动态取色
第五篇动画系统、手势处理、自定义动画
第六篇项目架构、Navigation、Hilt、性能优化
第七篇自定义绘制、Insets 处理、实战项目

希望这个系列能帮助你真正精通 Compose!


如果本系列对你有帮助,欢迎 点赞收藏关注!有任何问题可以在评论区留言。