Jetpack Compose助我快速打造电影App

简介: Jetpack Compose助我快速打造电影App

1832b220aa754cd18c504acc7686a560.png

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

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

成品

话不多说,先看下效果。

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

1832b220aa754cd18c504acc7686a560.png

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

image.png

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

https://github.com/ellisonchan/ComposeMovie

实现方案

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

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

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

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

功能点 技术方案
整体架构 MVVM
UI Compose
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栏删除,直接提供输入框即可。

1832b220aa754cd18c504acc7686a560.png

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

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

https://github.com/ellisonchan/ComposeMovie

参考资料

 以官方为准

官方提供的文档专业且详尽,如下的主页可以引导到各个要点。

https://developer.android.google.cn/jetpack/compose?hl=zh-cn

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

● 高手在民间

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

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

https://juejin.cn/post/6935220677339267079

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

https://www.jianshu.com/p/7bff0964c767

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

https://www.raywenderlich.com/books/jetpack-compose-by-tutorials/v1.0/chapters/1-developing-ui-in-android

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

https://blog.csdn.net/haojiagou/article/details/114476160?spm=1001.2014.3001.5501

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

https://blog.csdn.net/vitaviva/article/details/114451891

https://blog.csdn.net/vitaviva/article/details/114764215

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

https://juejin.cn/post/6937700592340959269

推荐阅读

参加Google Compose挑战赛的趣事

除了SQLite一定要试试Room

写了个MVP架构的电影搜索App

相关文章
|
2月前
|
安全 Java Android开发
探索安卓应用开发的新趋势:Kotlin和Jetpack Compose
在安卓应用开发领域,随着技术的不断进步,新的编程语言和框架层出不穷。Kotlin作为一种现代的编程语言,因其简洁性和高效性正逐渐取代Java成为安卓开发的首选语言。同时,Jetpack Compose作为一个新的UI工具包,提供了一种声明式的UI设计方法,使得界面编写更加直观和灵活。本文将深入探讨Kotlin和Jetpack Compose的特点、优势以及如何结合使用它们来构建现代化的安卓应用。
58 4
|
3月前
|
Kubernetes Linux Docker
【Azure 应用服务】使用Docker Compose创建App Service遇见"Linux Version is too long. It cannot be more than 4000 characters"错误
【Azure 应用服务】使用Docker Compose创建App Service遇见"Linux Version is too long. It cannot be more than 4000 characters"错误
|
4月前
|
存储 移动开发 Android开发
使用kotlin Jetpack Compose框架开发安卓app, webview中h5如何访问手机存储上传文件
在Kotlin和Jetpack Compose中,集成WebView以支持HTML5页面访问手机存储及上传音频文件涉及关键步骤:1) 添加`READ_EXTERNAL_STORAGE`和`WRITE_EXTERNAL_STORAGE`权限,考虑Android 11的分区存储;2) 配置WebView允许JavaScript和文件访问,启用`javaScriptEnabled`、`allowFileAccess`等设置;3) HTML5页面使用`<input type="file">`让用户选择文件,利用File API;
|
4月前
|
JavaScript Java 测试技术
基于SpringBoot+Vue+uniapp的电影信息推荐APP的详细设计和实现(源码+lw+部署文档+讲解等)
基于SpringBoot+Vue+uniapp的电影信息推荐APP的详细设计和实现(源码+lw+部署文档+讲解等)
|
4月前
|
JavaScript Java 测试技术
基于springboot+vue.js+uniapp的电影信息推荐APP附带文章源码部署视频讲解等
基于springboot+vue.js+uniapp的电影信息推荐APP附带文章源码部署视频讲解等
41 1
|
5月前
|
JavaScript Java Android开发
kotlin安卓在Jetpack Compose 框架下跨组件通讯EventBus
**EventBus** 是一个Android事件总线库,简化组件间通信。要使用它,首先在Gradle中添加依赖`implementation &#39;org.greenrobot:eventbus:3.3.1&#39;`。然后,可选地定义事件类如`MessageEvent`。在活动或Fragment的`onCreate`中注册订阅者,在`onDestroy`中反注册。通过`@Subscribe`注解方法处理事件,如`onMessageEvent`。发送事件使用`EventBus.getDefault().post()`。
|
5月前
|
安全 JavaScript 前端开发
kotlin开发安卓app,JetPack Compose框架,给webview新增一个按钮,点击刷新网页
在Kotlin中开发Android应用,使用Jetpack Compose框架时,可以通过添加一个按钮到TopAppBar来实现WebView页面的刷新功能。按钮位于右上角,点击后调用`webViewState?.reload()`来刷新网页内容。以下是代码摘要:
|
5月前
|
JavaScript 前端开发 Android开发
kotlin安卓在Jetpack Compose 框架下使用webview , 网页中的JavaScript代码如何与native交互
在Jetpack Compose中使用Kotlin创建Webview组件,设置JavaScript交互:`@Composable`函数`ComposableWebView`加载网页并启用JavaScript。通过`addJavascriptInterface`添加`WebAppInterface`类,允许JavaScript调用Android方法如播放音频。当页面加载完成时,执行`onWebViewReady`回调。
|
5月前
深入了解 Jetpack Compose 中的 Modifier
深入了解 Jetpack Compose 中的 Modifier
|
5月前
|
Android开发
Jetpack Compose: Hello Android
Jetpack Compose: Hello Android