Jetpack Compose助我快速打造電影App

去年開源了一個電影App,其採用的是成熟(過期)的MVP架構。現現在Jetpack框架愈發火熱,便萌生了徹底使用Jetpack框架從新開發的想法。加上Compose Beta版的正式公開,這個時機再適合不過了。android

總體上採用Compose去實現UI。數據請求則依賴Coroutines調用Retrofit接口,最後經過LiveData反映結果。git

成品

話很少說,先看下效果。github

啓動頁面,搜索頁面和電影詳情頁面。算法

在這裏插入圖片描述

店鋪頁面,收藏頁面以及和我的資料頁面。編程

在這裏插入圖片描述

Github地址以下,歡迎參考,不吝STAR⭐️。markdown

github.com/ellisonchan…網絡

實現方案

講述本次的實現方案前先來回顧下以前的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欄刪除,直接提供輸入框便可。

在這裏插入圖片描述

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…

相關文章
相關標籤/搜索