
去年開源了一個電影App,其采用的是成熟(過時)的MVP架構。現如今Jetpack架構愈發火熱,便萌生了完全使用Jetpack架構重新開發的想法。加上Compose Beta版的正式公開,這個時機再适合不過了。
整體上采用
Compose
去實作UI。資料請求則依賴
Coroutines
調用
Retrofit
接口,最後通過
LiveData
反映結果。
成品
話不多說,先看下效果。
啟動頁面,搜尋頁面和電影詳情頁面。
店鋪頁面,收藏頁面以及和個人資料頁面。
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)
)
}
}
)
}
詳情頁面
電影詳情頁面的布局相對來說較為複雜,主要是想要展示的内容很多,簡單布局顯得臃腫,沒有層次感。
是以靈活采用了
Box
、
Card
、
Column
、
Row
及
IconToggleButton
這些元件實作了橫縱嵌套的多層次布局。
用作展示收藏按鈕的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,
[email protected],
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.收藏和資料資料需持久化
目前收藏的電影資料沒有持久化到本地,資料頁面也沒提供編輯入口。後面需要通過
Room
或
DataStore
架構提供資料的支撐。
當然,螢幕前的你覺得還有什麼不足可以不吝賜教,我必洗耳恭聽。
結語
文思如泉湧,一口氣碼了這麼多字,最後還想再分享些切實感受。
- Compose版本和MVP版本的對比?
- Compose版本的代碼精簡得多,聲明式UI的程式設計方式也饒有新意,其側重于聲明和狀态的程式設計思想無處不在。其與Jetpack架構、Material主題的無縫銜接讓習慣了XML布局方式的開發者亦能快速入門
- Compose工具包也并非完美,其在性能方面的表現也令我有些懷疑。而且各大公司、各個産品對于這個新生技術的态度眼下也無從保證
- MVP架構龐雜的接密碼人诟病,也并非一無是處。結合産品的定位和需求,辯證地看待這兩種方式
- Compose使用上有無痛點?
- 日志匮乏:看不到debug和error級别的任何日志,很難把控流程和定位問題
- 原理學習困難:UI和邏輯的包衆多、講解原理的文章極度匮乏(希望日後我能貢獻一份力💪)
- 面對Android新技術的層出不窮到底要采取什麼姿态?
- 把頭埋進土裡無視是肯定不行的,時刻保持關注并做一定的嘗試
- 不要把簡單便捷的編碼當成全部,需認識到背後的架構和編譯器默默地做了很多工作
- 不要執迷于架構、依賴于架構,了解并掌握其原理,在坑來臨的時候遊刃有餘
本文DEMO
上面隻闡述了些關鍵的細節,需要的話還得參考完整代碼。
https://github.com/ellisonchan/ComposeMovie
參考資料
● 以官方為準
官方提供的文檔專業且詳盡,如下的首頁可以引導到各個要點。
https://developer.android.google.cn/jetpack/compose?hl=zh-cn
其中需要特别推薦兩篇文章,可以幫助我們了解Compose的程式設計思想和核心的狀态管理。
- https://developer.android.google.cn/jetpack/compose/mental-model
- https://developer.android.google.cn/jetpack/compose/state?hl=zh-cn
● 高手在民間
民間開發者對于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