Jetpack Compose助我快速打造电影App

2,616 阅读11分钟

去年开源了一个电影App,其采用的是成熟(过时)的MVP架构。现如今Jetpack框架愈发火热,便萌生了完全使用Jetpack框架重新开发的想法。加上Compose Beta版的正式公开,这个时机再适合不过了。

整体上采用Compose去实现UI。数据请求则依赖Coroutines调用Retrofit接口,最后通过LiveData反映结果。

成品

话不多说,先看下效果。

启动页面,搜索页面和电影详情页面。

在这里插入图片描述

店铺页面,收藏页面以及和个人资料页面。

在这里插入图片描述

Github地址如下,欢迎参考,不吝STAR⭐️。

github.com/ellisonchan…

实现方案

讲述本次的实现方案前先来回顾下之前的MVP版本是怎么做的。

功能点技术方案
整体架构MVP
UIViewPager + Fragment
View注入ButterKnife
异步处理RxJava
数据请求Retrofit
图片处理Glide

之前的做法可以说是比较成熟、比较传统的(轻喷😉)。

那如果采用Jetpack的Compose作为UI基盘,我会给出什么样的方案?

功能点技术方案
整体架构MVVM
UICompose
View注入不需要😎
异步处理Coroutines + LiveData
数据请求Retrofit
图片处理coil

实战

如同电影一样,脚本有了,接下来就让各个角色按部就班地动起来。

ACTION...

UI导航

整体UI采用BottomNavigation组件作为底部导航栏,将预设的几个TAB页面Compose进来。同时提供TopAppBar作为TITLE栏展示页面标题和返回导航。

// Navigation.kt
@Composable
fun Navigation() {
    ...
    Scaffold(
        topBar = {
            TopAppBar(
                ...
            )
        },
        bottomBar = {
            if (!isCurrentMovieDetail.value) {
                BottomNavigation {
                    ...
                }
            }
        }
    ) {
        NavHost(navController, startDestination = Screen.Find.route) {
            composable(Screen.Find.route) {
                FindScreen(navController, setTitle, movieModel)
            }
            composable(
                route = Constants.ROUTE_DETAIL,
                arguments = listOf(navArgument(Constants.ROUTE_DETAIL_KEY) {
                    type = NavType.StringType
                })
            ) { 
                backStackEntry ->
                DetailScreen(
                    backStackEntry.arguments?.getString(Constants.ROUTE_DETAIL_KEY)!!,
                    setTitle,
                    movieModel
                )
            }
            composable(Screen.Store.route) {
                StoreScreen(setTitle)
            }
            composable(Screen.Favourite.route) {
                FavouriteScreen(setTitle)
            }
            composable(Screen.Profile.route) {
                ProfileScreen(setTitle)
            }
        }
    }
}

这里有两点需要注意一下。

  • 电影详情页面是从搜索页面跳转过去的,展示底部导航栏比较奇怪。所以需要声明State控制这个页面不展示导航栏
  • 底部导航栏导航到店铺等其他页面的话会被记录在栈里,导致TITLE栏展示了返回按钮。对于独立的TAB页面来说没有必要提供返回操作。那同样声明State去确保这些页面不展示返回按钮

搜索页面

搜索页面首先确保网络能正常使用,并在网络不畅的情况下给出AlertDialog提醒。

UI上采用TextField提供输入区域,LaunchedEffect观察输入内容更新,自动执行搜索请求的协程。

在数据成功取得后通过LiveData反映到提供GRID列表的LazyVerticalGrid。LazyVerticalGrid组件仍然是实验性的API,随时可能删除,使用的话需要添加的@ExperimentalFoundationApi注解。

// Find.kt
@ExperimentalFoundationApi
@Composable
fun Find(movieModel: MovieModel, onClick: (Movie) -> Unit) {
    ...
    if (!Utils.ensureNetworkAvailable(context, false))
        ShowDialog(R.string.search_dialog_tip, R.string.search_failure)

    Column {
        Row() {
            TextField(
                value = textFieldValue,
                ...
                trailingIcon = {
                    IconButton(
                        onClick = {
                            if (textFieldValue.text.length > 1) {
                                searchQuery = textFieldValue.text
                            } else Toast.makeText(
                                context,
                                warningTip,
                                Toast.LENGTH_SHORT
                            ).show()
                        }
                    ) {
                        Icon(Icons.Outlined.Search, "search", tint = Color.White)
                    }
                },
                ...
            )
        }

        LaunchedEffect(searchQuery) {
            if (searchQuery.length > 0) {
                movieModel.searchMoviesComposeCoroutines(searchQuery)
            }
        }
        val moviesData: State<List<Movie>> = movieModel.movies.observeAsState(emptyList())
        val movies = moviesData.value
        val scrollState = rememberLazyListState()

        LazyVerticalGrid(
            ...
        ) {
            items(movies) { movie ->
                MovieThumbnail(movie, onClick = { onClick(movie) })
            }
        }

    }
}

另外Compose里的UI展示与否都依赖State的更新,网络不畅的AlertDialog亦是如此。在点击取消后仍需要依赖State触发Dialog的消失,不然它永远会在那的😅。

// Dialog.kt
@Composable
fun ShowDialog(
    title: Int,
    message: Int
) {
    val openDialog = remember { mutableStateOf(true) }

    if (openDialog.value)
        AlertDialog(
            onDismissRequest = { openDialog.value = false },
            title = {
                ...
            },
            text = {
                ...
            },
            confirmButton = {
                TextButton(onClick = { openDialog.value = false }) {
                    ...
                }
            },
            shape = shapes.large,
        )
}

电影海报的加载则依赖Compose的coil加载函数。

// LoadImage.kt
@Composable
fun LoadImage(
    url: String,
    contentDescription: String?,
    modifier: Modifier = Modifier,
    contentScale: ContentScale = ContentScale.Crop,
    placeholderColor: Color? = MaterialTheme.colors.compositedOnSurface(0.2f)
) {
    CoilImage(
        data = url,
        modifier = modifier,
        contentDescription = contentDescription,
        contentScale = contentScale,
        fadeIn = true,
        onRequestCompleted = {
            when (it) {
                is ImageLoadState.Success -> ...
                is ImageLoadState.Error -> ...
                ImageLoadState.Loading -> Utils.logDebug(Utils.TAG_NETWORK, "Image loading")
                ImageLoadState.Empty -> Utils.logDebug(Utils.TAG_NETWORK, "Image empty")
            }
        },
        loading = {
            if (placeholderColor != null) {
                Spacer(
                    modifier = Modifier
                        .fillMaxSize()
                        .background(placeholderColor)
                )
            }
        }
    )
}

详情页面

电影详情页面的布局相对来说较为复杂,主要是想要展示的内容很多,简单布局显得臃肿,没有层次感。

所以灵活采用了BoxCardColumnRowIconToggleButton这些组件实现了横纵嵌套的多层次布局。

用作展示收藏按钮的IconToggleButton和之前的AlertDialog一样,依赖State更新Toggle状态。在Compose工具包里State的概念可谓是无处不在啊👍。

// Detail.kt
@Composable
fun Detail(moviePro: MoviePro) {
    Box(
        modifier = Modifier
            .fillMaxHeight(),
    ) {
        Column(
            ...
        ) {
            Box(
                modifier = Modifier
                    .fillMaxHeight(),
                contentAlignment = Alignment.TopEnd
            ) {
                LoadImage(
                    url = moviePro.Poster,
                    modifier = Modifier
                        .fillMaxWidth()
                        .height(380.dp),
                    contentScale = ContentScale.FillBounds,
                    contentDescription = moviePro.Title
                )

                val checkedState = remember { mutableStateOf(false) }
                Card(
                    modifier = Modifier.padding(6.dp),
                    shape = RoundedCornerShape(50),
                    backgroundColor = likeColorBg
                ) {
                    IconToggleButton(
                        modifier = Modifier
                            .padding(6.dp)
                            .size(32.dp),
                        checked = checkedState.value,
                        onCheckedChange = {
                            checkedState.value = it
                        }
                    ) {
                        ...
                    }
                }
            }

            Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
                Row(
                    modifier = Modifier.fillMaxWidth(),
                    verticalAlignment = Alignment.CenterVertically
                ) {
                    Text(
                        modifier = Modifier
                            .weight(0.9f)
                            .align(Alignment.CenterVertically),
                        text = moviePro.Title,
                        style = MaterialTheme.typography.h6,
                        color = nameColor,
                        overflow = TextOverflow.Ellipsis,
                        maxLines = 1
                    )

                    ...
                }
                ...
            }
        }
    }
}

店铺页面

这个页面目前是展示了推荐的电影列表和以演员分类的电影列表,称之为Store似乎不妥,暂且这样吧。

UI上采用垂直布局的Column和横向滚动的LazyRow展示嵌套的布局。需要推荐的一点是如果需要展示圆形图片,使用RoundedCornerShape可以做到。

// Store.kt
@Composable
fun Store() {
    Column(Modifier.verticalScroll(rememberScrollState())) {
        Spacer(Modifier.sizeIn(16.dp))
        Text(
            modifier = Modifier.padding(6.dp),
            style = MaterialTheme.typography.h6,
            text = stringResource(id = R.string.tab_store_recommend)
        )

        Spacer(Modifier.sizeIn(16.dp))
        MovieGallery(recommendedMovies, width = 220.dp, height = 190.dp)

        CastGroup(cast = testCast1)
        CastGroup(cast = testCast2)
    }
}

@Composable
fun CastGroup(cast: Cast) {
    Column {
        Spacer(Modifier.sizeIn(32.dp))
        CastCategory(cast)
        Spacer(Modifier.sizeIn(6.dp))
        MovieGallery(cast.movies)
    }
}

@Composable
fun CastCategory(cast: Cast) {
    Row(
        modifier = Modifier
            .height(40.dp)
            .padding(16.dp, 2.dp, 2.dp, 16.dp)
    ) {
        Card(
            modifier = Modifier.wrapContentSize(),
            shape = RoundedCornerShape(50),
            elevation = 8.dp
        ) {
            ...
        }
        ..
    }
}

@Composable
fun MovieGallery(movies: List<Movie>, width: Dp = 130.dp, height: Dp = 136.dp) {
    LazyRow(modifier = Modifier.padding(top = 2.dp)) {
        items(movies.size) {
            RowItem(
                ...
            )
        }
    }
}

@Composable
fun RowItem(modifier: Modifier, width: Dp = 130.dp, height: Dp = 1306.dp, movie: Movie) {
    Card(
        ...
    ) {

        Box {
            LoadImage(
                url = movie.Poster,
                modifier = Modifier
                    .width(width)
                    .height(height),
                contentScale = ContentScale.FillBounds,
                contentDescription = movie.Title
            )
            Text(
                ...
            )
        }
    }
}

这个页面使用Column嵌套了三个横向滚动视图,屏幕高度不够的情况下会存在显示不全的问题。自然想到了类似ScrollView的组件,一开始查到了ScrollableColumn,可是AS反复提示不存在该组件。

去官网一查,发现出于性能方面的考虑,这个组件和ScrollableRow在之前的版本被移除了😓。还好,官方提示可以使用Modifier.verticalScroll或LazyColumn可以达到滚动的目的。

收藏页面

收藏页面只展示了收藏的电影列表,最为简单。使用LazyColumn即可cover。

// Favourite.kt
@Composable
fun Favourite(moviePros: List<MoviePro>, onClick: () -> Unit) {
    LazyColumn(modifier = Modifier.padding(top = 2.dp)) {
        items(moviePros.size) {
            LikeItem(
                moviePro = moviePros[it],
                onClick
            )
        }
    }

}

@Composable
fun LikeItem(moviePro: MoviePro, onClick: () -> Unit) {
    Box(
        modifier = Modifier
            .wrapContentSize()
            .padding(8.dp)
    ) {
        Card(
            modifier = Modifier
                .border(1.dp, Color.Gray, shape = MaterialTheme.shapes.small)
                .shadow(4.dp),
            shape = shapes.small,
            elevation = 8.dp,
            backgroundColor = itemCardColor
        ) {
            Row(
                modifier = Modifier
                    .clickable(onClick = onClick)
                    .fillMaxWidth()
                    .height(100.dp),
                verticalAlignment = Alignment.CenterVertically
            ) {
                LoadImage(
                    url = moviePro.Poster,
                    modifier = Modifier
                        .width(80.dp)
                        .height(100.dp),
                    contentScale = ContentScale.FillBounds,
                    contentDescription = moviePro.Title
                )
                ...
            }
        }
    }
}

个人资料页面

个人资料页面需要提供封面图、名称、简介、昵称以及社交账号等信息,稍微花些功夫。

鄙人设计天赋匮乏,参考了Compose示例项目Jetchat的资料页面。

需要推荐的是BoxWithConstraints组件,其可以提供类似ConstraintsLayout的效果,在指定约束规则或方向后可以动态更改其尺寸大小。

// Profile.kt
@Composable
fun Profile(account: Account) {
    val scrollState = rememberScrollState()

    Column(modifier = Modifier.fillMaxSize()) {
        BoxWithConstraints(modifier = Modifier.weight(1f)) {
            Surface {
                Column(
                    modifier = Modifier
                        .fillMaxSize()
                        .verticalScroll(scrollState),
                ) {
                    ProfileHeader(
                        scrollState,
                        this@BoxWithConstraints.maxHeight,
                        account.Post
                    )

                    NameAndPosition(
                        stringResource(id = account.FullName),
                        stringResource(id = account.About)
                    )

                    ProfileProperty(
                        stringResource(R.string.display_name),
                        stringResource(id = account.NickName)
                    )
                    ...
                    EditProfile()
                }
            }
        }
    }
}

@Composable
fun ProfileProperty(label: String, value: String, isLink: Boolean = false) {
    Column(modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp)) {
        Divider()
        CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) {
            Text(
                text = label,
                modifier = Modifier.paddingFromBaseline(24.dp),
                style = MaterialTheme.typography.caption
            )
        }
        val style = if (isLink) {
            MaterialTheme.typography.body1.copy(color = Color.Blue)
        } else {
            MaterialTheme.typography.body1
        }
        ...
    }
}

App大部分的实现细节都讲完了,代码量很小。除了本身功能相对简单以外,Compose工具包的简洁易用绝对功不可没。

不足

我们再来谈谈这个App还存在什么不足,包括UI交互上的、功能上的等等。

1.不支持中文关键字搜索

App采用的数据来源是国外的OMDB,它的电影库还是健全的,提供的电影相关内容也足够丰富。可其出生地也决定了它只擅长英文关键字的查询,但使用其他语言比如中文、日文,几乎查不到任何电影。

为了完善中文方面的功能,亟需导入华语电影的接口。奈何没有找到,之前使用良好的豆瓣API已经废弃了。

了解的朋友可以教育一下我,感谢🙏。

2.UI设计风格需要强化

目前整体UI的设计采用米色做背景,蓝色做高亮,辅助以浅灰色、白色以及紫色作其他内容的展示。给人感觉还是有点东西的,但总有种说不出的乱,无法沉浸进去。不知道屏幕面前的你有没有一样的感受😂?

后面计划针对Material设计语言做个深度地学习和理解,并能将其设计理念完美地融入到Compose中来。(好的,说人话。过段日子我将观摩几个不错的电影App,比如Netflix、Disney+啥的,好好地模仿一番成熟友好的视觉效果。)

3.搜索页面TITLE栏有点多余

搜索页面为了和其他页面的提供一致的TITLE栏效果,展示了搜索图标。对于用户来说,这和下面输入框的功能有些重叠,而且会占用电影列表的显示区域。

所以完全可以将这个页面的TITLE栏删除,直接提供输入框即可。

在这里插入图片描述

4.搜索之后IME可以自动隐藏

点击搜索按钮之后IME面板不会自动隐藏,体验不是太好。点击或搜索完毕之后自动将IME隐藏可能体验更佳。

简单查了下资料,似乎是利用TextInputService去实现,捣鼓了半小时还没实现,暂时搁置了。知道的朋友可以回复下,比心❤️。

5.店铺页面需要强化推荐

首先啊,这个页面名称可能需要更改,改为Home主页是不是更好些。"家"才比较懂你,给你一些精准的建议。

OMDB没有提供推荐电影的接口,所以目前的推荐列表的数据是模拟的。后面可能需要记录并分析用户搜索的关键字、点击的电影类型、关注的电影导演及演员等数据,得出一套智能的推荐结果。最终按照类型、导演、演员等维度呈现出来。

到时候使用Room框架配合一套算法开干。

6.收藏和资料数据需持久化

目前收藏的电影数据没有持久化到本地,资料页面也没提供编辑入口。后面需要通过RoomDataStore框架提供数据的支撑。

当然,屏幕前的你觉得还有什么不足可以不吝赐教,我必洗耳恭听。

结语

文思如泉涌,一口气码了这么多字,最后还想再分享些切实感受。

在这里插入图片描述

  1. Compose版本和MVP版本的对比?
  • Compose版本的代码精简得多,声明式UI的编程方式也饶有新意,其侧重于声明和状态的编程思想无处不在。其与Jetpack框架、Material主题的无缝衔接让习惯了XML布局方式的开发者亦能快速入门

  • Compose工具包也并非完美,其在性能方面的表现也令我有些怀疑。而且各大公司、各个产品对于这个新生技术的态度眼下也无从保证

  • MVP架构庞杂的接口令人诟病,也并非一无是处。结合产品的定位和需求,辩证地看待这两种方式

  1. Compose使用上有无痛点?
  • 日志匮乏:看不到debug和error级别的任何日志,很难把控流程和定位问题

  • 原理学习困难:UI和逻辑的包众多、讲解原理的文章极度匮乏(希望日后我能贡献一份力💪)

  1. 面对Android新技术的层出不穷到底要采取什么姿态?
  • 把头埋进土里无视是肯定不行的,时刻保持关注并做一定的尝试

  • 不要把简单便捷的编码当成全部,需认识到背后的框架和编译器默默地做了很多工作

  • 不要执迷于框架、依赖于框架,了解并掌握其原理,在坑来临的时候游刃有余

本文DEMO

上面只阐述了些关键的细节,需要的话还得参考完整代码。 github.com/ellisonchan…

参考资料

以官方为准

官方提供的文档专业且详尽,如下的主页可以引导到各个要点。 developer.android.google.cn/jetpack/com…

其中需要特别推荐两篇文章,可以帮助我们理解Compose的编程思想和核心的状态管理。

高手在民间

民间开发者对于Compose的回应也很热烈,出炉的文章数量并不算多,但不乏高质量的。在此将我所知道的优质文章分享给大家。

扔物线大佬结合简单的示例,通俗易懂地讲解了XML布局方式和Compose声明方式的区别,非常值得准备入坑的朋友先行阅读。

juejin.cn/post/693522…

znjw大佬站在原理的角度详尽地解读了Compose与React、Vue及Swift的异同优劣,值得反复咀嚼。

www.jianshu.com/p/7bff0964c…

Tino Balint & Denis Buketa两位大佬事无巨细地分享了Compose上如何使用各类UI组件,专业度简直恐怖。需搭配翻译软件食用。

www.raywenderlich.com/books/jetpa…

ZhuJiangs大佬的这篇分享讲解了Compose上如何实现画面导航、如何和Android传统View互调及和其他框架配合等实际问题,不可多得。

blog.csdn.net/haojiagou/a…

fundroid_方卓大佬用其流畅的文笔精彩地还原了使用Compose打造动画和主题的畅快体验。

blog.csdn.net/vitaviva/ar… blog.csdn.net/vitaviva/ar…

路很长o0大佬凭借其丰富的描画经验生动地演示了使用Compose亦能自定义绘制各类花式效果,值得收藏学习。

juejin.cn/post/693770…