天天看點

Jetpack Compose助我快速打造電影App

Jetpack Compose助我快速打造電影App
去年開源了一個電影App,其采用的是成熟(過時)的MVP架構。現如今Jetpack架構愈發火熱,便萌生了完全使用Jetpack架構重新開發的想法。加上Compose Beta版的正式公開,這個時機再适合不過了。

整體上采用

Compose

去實作UI。資料請求則依賴

Coroutines

調用

Retrofit

接口,最後通過

LiveData

反映結果。

成品

話不多說,先看下效果。

啟動頁面,搜尋頁面和電影詳情頁面。

Jetpack Compose助我快速打造電影App

店鋪頁面,收藏頁面以及和個人資料頁面。

Jetpack Compose助我快速打造電影App

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欄删除,直接提供輸入框即可。

Jetpack Compose助我快速打造電影App

4.搜尋之後IME可以自動隐藏

點選搜尋按鈕之後IME面闆不會自動隐藏,體驗不是太好。點選或搜尋完畢之後自動将IME隐藏可能體驗更佳。

簡單查了下資料,似乎是利用TextInputService去實作,搗鼓了半小時還沒實作,暫時擱置了。知道的朋友可以回複下,比心❤️。

5.店鋪頁面需要強化推薦

首先啊,這個頁面名稱可能需要更改,改為Home首頁是不是更好些。"家"才比較懂你,給你一些精準的建議。

OMDB沒有提供推薦電影的接口,是以目前的推薦清單的資料是模拟的。後面可能需要記錄并分析使用者搜尋的關鍵字、點選的電影類型、關注的電影導演及演員等資料,得出一套智能的推薦結果。最終按照類型、導演、演員等次元呈現出來。

到時候使用

Room

架構配合一套算法開幹。

6.收藏和資料資料需持久化

目前收藏的電影資料沒有持久化到本地,資料頁面也沒提供編輯入口。後面需要通過

Room

DataStore

架構提供資料的支撐。

當然,螢幕前的你覺得還有什麼不足可以不吝賜教,我必洗耳恭聽。

結語

文思如泉湧,一口氣碼了這麼多字,最後還想再分享些切實感受。

Jetpack Compose助我快速打造電影App
  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的程式設計思想和核心的狀态管理。

  • 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