建設(shè)綜合信息網(wǎng)站需要多少錢(qián)如何廣告推廣
Bilibili移動(dòng)端APP
- 簡(jiǎn)介
- 依賴
- 效果
- 登錄
- 效果
- WebView
- 自定義TobRow的Indicator大小
- 首頁(yè)
- 推薦
- LazyGridView使用Paging3
- 熱門(mén)
- 排行榜
- 搜索
- 模糊搜索
- 富文本
- 搜索結(jié)果
- 視頻詳情
- 合集
- 信息
- Coroutines進(jìn)行網(wǎng)絡(luò)請(qǐng)求管理,避免回調(diào)地獄
- 添加suspend
- withContext
- Git項(xiàng)目鏈接
- 末
簡(jiǎn)介
此Demo采用Android Compose聲明式UI編寫(xiě)而成,主體采用MVVM設(shè)計(jì)框架,Demo涉及到的主要技術(shù)包括:Flow、Coroutines、Retrofit、Okhttp、Hilt以及適配了深色模式等;主要數(shù)據(jù)來(lái)源于Bilibili API。
依賴
Demo中所使用的依賴如下表格所示
庫(kù)名稱 | 備注 |
---|---|
Flow | 流 |
Coroutines | 協(xié)程 |
Retrofit | 網(wǎng)絡(luò) |
Okhttp | 網(wǎng)絡(luò) |
Hilt | 依賴注入 |
room | 數(shù)據(jù)存儲(chǔ) |
coil | 異步加載圖片 |
paging | 分頁(yè)加載 |
media3-exoplayer | 視頻 |
效果
登錄
登錄在Demo中分為WebView嵌入B站網(wǎng)頁(yè)實(shí)現(xiàn)獲取Cookie和自主實(shí)現(xiàn)登錄,由于后者需要通過(guò)極驗(yàn)API驗(yàn)證,所以暫且采用前者獲取Cookie,后者繪制了基本view和基本邏輯
效果


WebView
由于登錄暫未實(shí)現(xiàn),故而此處就介紹使用WebView獲取Cookie。由于在Compose中并未直接提供WebView組件,故使用AndroidView進(jìn)行引入。以下代碼對(duì)WebView進(jìn)行了一個(gè)簡(jiǎn)單的封裝,我們只需要在onPageFinished
方法中回調(diào)所獲的cookie即可,然后保存到緩存文件即可
@Composable
fun CustomWebView(modifier: Modifier = Modifier,url:String,onBack: (webView: WebView?) -> Unit,onProgressChange: (progress:Int)->Unit = {},initSettings: (webSettings: WebSettings?) -> Unit = {},onReceivedError: (error: WebResourceError?) -> Unit = {},onCookie:(String)->Unit = {}
){val webViewChromeClient = object: WebChromeClient(){override fun onProgressChanged(view: WebView?, newProgress: Int) {//回調(diào)網(wǎng)頁(yè)內(nèi)容加載進(jìn)度onProgressChange(newProgress)super.onProgressChanged(view, newProgress)}}val webViewClient = object: WebViewClient(){override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {super.onPageStarted(view, url, favicon)onProgressChange(-1)}override fun onPageFinished(view: WebView?, url: String?) {super.onPageFinished(view, url)onProgressChange(100)//監(jiān)聽(tīng)獲取cookieval cookie = CookieManager.getInstance().getCookie(url)cookie?.let{ onCookie(cookie) }}override fun shouldOverrideUrlLoading(view: WebView?,request: WebResourceRequest?): Boolean {if(null == request?.url) return falseval showOverrideUrl = request.url.toString()try {if (!showOverrideUrl.startsWith("http://")&& !showOverrideUrl.startsWith("https://")) {Intent(Intent.ACTION_VIEW, Uri.parse(showOverrideUrl)).apply {addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)view?.context?.applicationContext?.startActivity(this)}return true}}catch (e:Exception){return true}return super.shouldOverrideUrlLoading(view, request)}override fun onReceivedError(view: WebView?,request: WebResourceRequest?,error: WebResourceError?) {super.onReceivedError(view, request, error)onReceivedError(error)}}var webView:WebView? = nullval coroutineScope = rememberCoroutineScope()AndroidView(modifier = modifier,factory = { ctx ->WebView(ctx).apply {this.webViewClient = webViewClientthis.webChromeClient = webViewChromeClientinitSettings(this.settings)webView = thisloadUrl(url)}})BackHandler {coroutineScope.launch {onBack(webView)}}
}
自定義TobRow的Indicator大小
由于在compose中TobRow的指示器寬度被寫(xiě)死,如果需要更改指示器寬度,則需要自己進(jìn)行重寫(xiě),將源碼拷貝一份,然后根據(jù)自己需求進(jìn)行定制,具體代碼如下
@ExperimentalPagerApi
fun Modifier.customIndicatorOffset(pagerState: PagerState,tabPositions: List<TabPosition>,width: Dp
): Modifier = composed {if (pagerState.pageCount == 0) return@composed thisval targetIndicatorOffset: Dpval indicatorWidth: Dpval currentTab = tabPositions[minOf(tabPositions.lastIndex, pagerState.currentPage)]val targetPage = pagerState.targetPageval targetTab = tabPositions.getOrNull(targetPage)if (targetTab != null) {val targetDistance = (targetPage - pagerState.currentPage).absoluteValueval fraction = (pagerState.currentPageOffset / max(targetDistance, 1)).absoluteValuetargetIndicatorOffset = lerp(currentTab.left, targetTab.left, fraction)indicatorWidth = lerp(currentTab.width, targetTab.width, fraction).value.absoluteValue.dp} else {targetIndicatorOffset = currentTab.leftindicatorWidth = currentTab.width}fillMaxWidth().wrapContentSize(Alignment.BottomStart).padding(horizontal = (indicatorWidth - width) / 2).offset(x = targetIndicatorOffset).width(width)
}
使用就變得很簡(jiǎn)單了,因?yàn)槭遣捎胢odifier的擴(kuò)展函數(shù)進(jìn)行編寫(xiě),而modifier在每一個(gè)compose組件都擁有,所以只需要在tabrow的指示器調(diào)用即可,具體代碼如下
TabRow(...indicator = { pos ->TabRowDefaults.Indicator(color = BilibiliTheme.colors.tabSelect,modifier = Modifier.customIndicatorOffset(pagerState = pageState,tabPositions = pos,32.dp))}...)
首頁(yè)
整個(gè)首頁(yè)頁(yè)面由BottomNavbar
構(gòu)成,包含四個(gè)子界面,其中第一個(gè)界面又由兩個(gè)子界面組成,通過(guò)TabRow
+HorizontalPager
完成子頁(yè)面滑動(dòng),子頁(yè)面分為推薦
和熱門(mén)
兩個(gè)頁(yè)面
推薦
推薦頁(yè)面由上面的Banner和下方的LazyGridView組成,由于Compose中不允許同向滑動(dòng),所以就將Banner作為L(zhǎng)azyGridView的一個(gè)item,進(jìn)而進(jìn)行包裹


LazyGridView使用Paging3
由于在現(xiàn)在Compose版本中LazyGridView并不支持Paging3,所以如果有此類需求,則需要自己動(dòng)手,具體代碼如下
fun <T : Any> LazyGridScope.items(items: LazyPagingItems<T>,key: ((item: T) -> Any)? = null,span: ((item: T) -> GridItemSpan)? = null,contentType: ((item: T) -> Any)? = null,itemContent: @Composable LazyGridItemScope.(value: T?) -> Unit
) {items(count = items.itemCount,key = if (key == null) null else { index ->val item = items.peek(index)if (item == null) {//PagingPlaceholderKey(index)} else {key(item)}},span = if (span == null) null else { index ->val item = items.peek(index)if (item == null) {GridItemSpan(1)} else {span(item)}},contentType = if (contentType == null) {{ null }} else { index ->val item = items.peek(index)if (item == null) {null} else {contentType(item)}}) { index ->itemContent(items[index])}
}
熱門(mén)
熱門(mén)頁(yè)面代碼與推薦頁(yè)面代碼類似,此處不在闡述


排行榜
排行界面與上述類似,Tab
+HorizontalPager
完成所有子頁(yè)面滑動(dòng)切換,此處也不在繼續(xù)闡述


搜索
搜索界面主要分為四個(gè)模塊:搜索欄、熱搜內(nèi)容、搜索記錄、搜索列表;搜索框內(nèi)字符改變,搜索列表顯示并以富文本顯示,熱搜內(nèi)容展開(kāi)與折疊、搜索記錄內(nèi)容展開(kāi)與折疊、清空記錄等操作都在ViewModel中完成,然后view通過(guò)監(jiān)聽(tīng)VM中狀態(tài)值進(jìn)行重組


模糊搜索
在搜索框內(nèi)鍵入字符,然后通過(guò)字符的改變,獲取相應(yīng)的網(wǎng)絡(luò)請(qǐng)求數(shù)據(jù),最后通過(guò)AnimatedVisibility
顯示與隱藏搜索建議列表


富文本
通過(guò)逐字匹配輸入框內(nèi)的字符與搜索建議item內(nèi)容,然后輸入框的字符存在搜索建議列表中的文字就加入高亮顯示列表中,因?yàn)椴捎?code>buildAnnotatedString,可以讓文本顯示多種不同風(fēng)格,所以最后將字符內(nèi)容區(qū)別為高亮顏色和普通文本兩種文本,并讓其進(jìn)行顯示
@Composable
fun RichText(selectColor: Color,unselectColor: Color,fontSize:TextUnit = TextUnit.Unspecified,searchValue: String,matchValue: String
){val richText = buildAnnotatedString {repeat(matchValue.length){val index = if (it < searchValue.length) matchValue.indexOf(searchValue[it]) else -1if (index == -1){withStyle(style = SpanStyle(fontSize = fontSize,color = unselectColor,)){append(matchValue[it])}}else{withStyle(style = SpanStyle(fontSize = fontSize,color = selectColor,)){append(matchValue[index])}}}}Text(text = richText,maxLines = 1,overflow = TextOverflow.Ellipsis,modifier = Modifier.fillMaxWidth(),)
}
搜索結(jié)果
搜索結(jié)果也是由ScrollableTabRow
+HorizontalPager
完成子頁(yè)面的滑動(dòng)切換,但是與上述不同的是,所展現(xiàn)的Tab與內(nèi)容并不是固定,而是根據(jù)后端返回的數(shù)據(jù)進(jìn)行自動(dòng)生成的。由于其他子頁(yè)面的內(nèi)容都是由LazyColumn
進(jìn)行展現(xiàn),而綜合界面有需要將其他界面的數(shù)據(jù)進(jìn)行集中,所以就必須LazyColumn
嵌套LazyColumn
,然后這在Compose中是不被允許的,所以就將子Page的LazyColumn
,使用modifier.heightIn(max = screenHeight.dp)
進(jìn)行高度限制,高度可以取屏幕高度,并且多個(gè)item之間都是取屏幕高度,之間不會(huì)存在間隙


視頻詳情
視頻播放功能暫未實(shí)現(xiàn)完成,因?yàn)楂@取的API返回的URL進(jìn)行播放一直為403,被告知權(quán)限不足,在網(wǎng)上進(jìn)行多番查詢未果,所以暫且擱置。視頻庫(kù)采用的Google的ExoPlayer


合集
每個(gè)視頻返回的內(nèi)容數(shù)據(jù)格式一致,但具體內(nèi)容不一致,有的視頻存在排行信息、合集等,就通過(guò)AnimatedVisibility
進(jìn)行顯示和隱藏,將所有結(jié)果進(jìn)行列出,然后在ViewModel通過(guò)解析數(shù)據(jù),并改變相應(yīng)的狀態(tài)值,view即可進(jìn)行重組


信息


Coroutines進(jìn)行網(wǎng)絡(luò)請(qǐng)求管理,避免回調(diào)地獄
在日常開(kāi)發(fā)中網(wǎng)絡(luò)請(qǐng)求必不可少,在傳統(tǒng)View+java開(kāi)發(fā)中使用Retrifit或者okhttp進(jìn)行網(wǎng)絡(luò)請(qǐng)求最為常見(jiàn),但大多數(shù)場(chǎng)景中,后一個(gè)API需要前一個(gè)API數(shù)據(jù)內(nèi)字段值,此時(shí)就需要callback
進(jìn)行操作,回調(diào)一次獲取代碼依舊看起來(lái)簡(jiǎn)潔,可讀,但次數(shù)一旦增多,則會(huì)掉入回調(diào)地獄。Google后續(xù)推出的協(xié)程完美解決此類問(wèn)題,協(xié)程的主要核心就是“通過(guò)非阻塞的代碼實(shí)現(xiàn)阻塞功能”
,具體代碼如下
添加suspend
以下為示例代碼,通過(guò)給接口添加suspend
標(biāo)志符,告知外界次方法需要掛起
@GET("xxxxx")suspend fun getVideoDetail(@Query("aid")aid:Int):BaseResponse<VideoDetail>
withContext
getVideoDetail
掛起函數(shù)返回一個(gè)字段值,然后通過(guò)withContext包裹,使其進(jìn)行阻塞,然后將返回值進(jìn)行返回,后續(xù)的getVideoUrl
掛起函數(shù)就可以使用前一個(gè)接口返回的數(shù)據(jù);需要注意的是,函數(shù)都需為suspend
修飾的方法,并且在統(tǒng)一協(xié)程域中,否則會(huì)出現(xiàn)異常
viewModelScope.launch(Dispatchers.Main) {try {withContext(Dispatchers.Main){val cid = withContext(Dispatchers.IO){getVideoDetail(_videoState.value.aid)}val url = withContext(Dispatchers.IO){getVideoUrl(avid = _videoState.value.aid, cid = cid)}if (url.isNotEmpty()){play(url)}getRelatedVideos(_videoState.value.aid)}}catch (e:Exception){Log.d("VDetailViewModel",e.message.toString())}}
Git項(xiàng)目鏈接
Git項(xiàng)目鏈接
末
此Demo并未完全完善,尤其是播放界面,由于采用Bilibili API獲取的視頻URL,在播放時(shí)一直返回403錯(cuò)誤,被告知沒(méi)有權(quán)限,在根據(jù)文檔進(jìn)行使用以及網(wǎng)上查詢未果之后,只能暫且擱置此功能。