Android Compose——一个简单的Bilibili APP

简介: 此Demo采用Android Compose声明式UI编写而成,主体采用MVVM设计框架,Demo涉及到的主要技术包括:Flow、Coroutines、Retrofit、Okhttp、Hilt以及适配了深色模式等;主要数据来源于Bilibili API。

简介

此Demo采用Android Compose声明式UI编写而成,主体采用MVVM设计框架,Demo涉及到的主要技术包括:Flow、Coroutines、Retrofit、Okhttp、Hilt以及适配了深色模式等;主要数据来源于Bilibili API。

依赖

Demo中所使用的依赖如下表格所示

库名称 备注
Flow
Coroutines 协程
Retrofit 网络
Okhttp 网络
Hilt 依赖注入
room 数据存储
coil 异步加载图片
paging 分页加载
media3-exoplayer 视频

效果

登录

登录在Demo中分为WebView嵌入B站网页实现获取Cookie和自主实现登录,由于后者需要通过极验API验证,所以暂且采用前者获取Cookie,后者绘制了基本view和基本逻辑

效果

82595d021f614c8b889d3aac8fa280ba.png
8623c10b476541dcab2576851221289f.png

WebView

由于登录暂未实现,故而此处就介绍使用WebView获取Cookie。由于在Compose中并未直接提供WebView组件,故使用AndroidView进行引入。以下代码对WebView进行了一个简单的封装,我们只需要在onPageFinished方法中回调所获的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) {
            //回调网页内容加载进度
            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)
            //监听获取cookie
            val cookie = CookieManager.getInstance().getCookie(url)
            cookie?.let{ onCookie(cookie) }
        }
        override fun shouldOverrideUrlLoading(
            view: WebView?,
            request: WebResourceRequest?
        ): Boolean {
            if(null == request?.url) return false
            val 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? = null
    val coroutineScope = rememberCoroutineScope()
    AndroidView(modifier = modifier,factory = { ctx ->
        WebView(ctx).apply {
            this.webViewClient = webViewClient
            this.webChromeClient = webViewChromeClient
            initSettings(this.settings)
            webView = this
            loadUrl(url)
        }
    })
    BackHandler {
        coroutineScope.launch {
            onBack(webView)
        }
    }
}

自定义TobRow的Indicator大小

由于在compose中TobRow的指示器宽度被写死,如果需要更改指示器宽度,则需要自己进行重写,将源码拷贝一份,然后根据自己需求进行定制,具体代码如下

@ExperimentalPagerApi
fun Modifier.customIndicatorOffset(
    pagerState: PagerState,
    tabPositions: List<TabPosition>,
    width: Dp
): Modifier = composed {
    if (pagerState.pageCount == 0) return@composed this

    val targetIndicatorOffset: Dp
    val indicatorWidth: Dp

    val currentTab = tabPositions[minOf(tabPositions.lastIndex, pagerState.currentPage)]
    val targetPage = pagerState.targetPage
    val targetTab = tabPositions.getOrNull(targetPage)

    if (targetTab != null) {
        val targetDistance = (targetPage - pagerState.currentPage).absoluteValue
        val fraction = (pagerState.currentPageOffset / max(targetDistance, 1)).absoluteValue

        targetIndicatorOffset = lerp(currentTab.left, targetTab.left, fraction)
        indicatorWidth = lerp(currentTab.width, targetTab.width, fraction).value.absoluteValue.dp
    } else {
        targetIndicatorOffset = currentTab.left
        indicatorWidth = currentTab.width
    }

    fillMaxWidth()
        .wrapContentSize(Alignment.BottomStart)
        .padding(horizontal = (indicatorWidth - width) / 2)
        .offset(x = targetIndicatorOffset)
        .width(width)
}

使用就变得很简单了,因为是采用modifier的扩展函数进行编写,而modifier在每一个compose组件都拥有,所以只需要在tabrow的指示器调用即可,具体代码如下

TabRow(
            ...
            indicator = { pos ->
                TabRowDefaults.Indicator(
                    color = BilibiliTheme.colors.tabSelect,
                    modifier = Modifier.customIndicatorOffset(
                        pagerState = pageState,
                        tabPositions = pos,
                        32.dp
                    )
                )
            }
            ...
      )

首页

整个首页页面由BottomNavbar构成,包含四个子界面,其中第一个界面又由两个子界面组成,通过TabRow+HorizontalPager完成子页面滑动,子页面分为推荐热门两个页面

推荐

推荐页面由上面的Banner和下方的LazyGridView组成,由于Compose中不允许同向滑动,所以就将Banner作为LazyGridView的一个item,进而进行包裹

c91333f486f94acdb0eabc2f33014261.png
475b34b0664f493abeef99e348962c42.png

LazyGridView使用Paging3

由于在现在Compose版本中LazyGridView并不支持Paging3,所以如果有此类需求,则需要自己动手,具体代码如下

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])
    }
}

热门

热门页面代码与推荐页面代码类似,此处不在阐述

799d1ec432854e438b9965589246dcf2.png
bbeb0d54795c4b19963acebcd356e3c6.png

排行榜

排行界面与上述类似,Tab+HorizontalPager完成所有子页面滑动切换,此处也不在继续阐述

f71ca17d5aac4275a29accf4debf3757.png
270699fa05534454a07d8909039c55f2.png

搜索

搜索界面主要分为四个模块:搜索栏、热搜内容、搜索记录、搜索列表;搜索框内字符改变,搜索列表显示并以富文本显示,热搜内容展开与折叠、搜索记录内容展开与折叠、清空记录等操作都在ViewModel中完成,然后view通过监听VM中状态值进行重组

38b9926e55ed4d63bc7b56c83d720d09.png
e1cffdb0a7164fac8ace529460dad156.png

模糊搜索

在搜索框内键入字符,然后通过字符的改变,获取相应的网络请求数据,最后通过AnimatedVisibility显示与隐藏搜索建议列表

858581beb9ef4138839b18ddeaaad76b.png
1d847bb3c16a4d75960209ee79799a25.png

富文本

通过逐字匹配输入框内的字符与搜索建议item内容,然后输入框的字符存在搜索建议列表中的文字就加入高亮显示列表中,因为采用buildAnnotatedString,可以让文本显示多种不同风格,所以最后将字符内容区别为高亮颜色和普通文本两种文本,并让其进行显示

@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 -1
            if (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(),
    )
}

搜索结果

搜索结果也是由ScrollableTabRow+HorizontalPager完成子页面的滑动切换,但是与上述不同的是,所展现的Tab与内容并不是固定,而是根据后端返回的数据进行自动生成的。由于其他子页面的内容都是由LazyColumn进行展现,而综合界面有需要将其他界面的数据进行集中,所以就必须LazyColumn嵌套LazyColumn,然后这在Compose中是不被允许的,所以就将子Page的LazyColumn,使用modifier.heightIn(max = screenHeight.dp)进行高度限制,高度可以取屏幕高度,并且多个item之间都是取屏幕高度,之间不会存在间隙

2bfb480e13a6459fa0d624304687536b.png
5e951789e0524d859710aeacaa2fb86d.png

视频详情

视频播放功能暂未实现完成,因为获取的API返回的URL进行播放一直为403,被告知权限不足,在网上进行多番查询未果,所以暂且搁置。视频库采用的Google的ExoPlayer

a842aac22239445580b20add9abe871e.png
9949c50837b2466191daacbc970086fb.png

合集

每个视频返回的内容数据格式一致,但具体内容不一致,有的视频存在排行信息、合集等,就通过AnimatedVisibility进行显示和隐藏,将所有结果进行列出,然后在ViewModel通过解析数据,并改变相应的状态值,view即可进行重组

4d1498767fe14b2ba9b891bf56b6d916.png
b3e57737026d4d8ab03c6f1dbb1b30c5.png

信息

38c1a10cb0af4d408fb383ab16a92d7f.png
0140048fb06b4814b9fa7703a2bb2a88.png

Coroutines进行网络请求管理,避免回调地狱

在日常开发中网络请求必不可少,在传统View+java开发中使用Retrifit或者okhttp进行网络请求最为常见,但大多数场景中,后一个API需要前一个API数据内字段值,此时就需要callback进行操作,回调一次获取代码依旧看起来简洁,可读,但次数一旦增多,则会掉入回调地狱。Google后续推出的协程完美解决此类问题,协程的主要核心就是“通过非阻塞的代码实现阻塞功能”,具体代码如下

添加suspend

以下为示例代码,通过给接口添加suspend标志符,告知外界次方法需要挂起

@GET("xxxxx")
    suspend fun getVideoDetail(@Query("aid")aid:Int):BaseResponse<VideoDetail>

withContext

getVideoDetail挂起函数返回一个字段值,然后通过withContext包裹,使其进行阻塞,然后将返回值进行返回,后续的getVideoUrl挂起函数就可以使用前一个接口返回的数据;需要注意的是,函数都需为suspend修饰的方法,并且在统一协程域中,否则会出现异常

 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项目链接

Git项目链接

此Demo并未完全完善,尤其是播放界面,由于采用Bilibili API获取的视频URL,在播放时一直返回403错误,被告知没有权限,在根据文档进行使用以及网上查询未果之后,只能暂且搁置此功能。

相关文章
|
7天前
|
Java 数据库 Android开发
一个Android App最少有几个线程?实现多线程的方式有哪些?
本文介绍了Android多线程编程的重要性及其实现方法,涵盖了基本概念、常见线程类型(如主线程、工作线程)以及多种多线程实现方式(如`Thread`、`HandlerThread`、`Executors`、Kotlin协程等)。通过合理的多线程管理,可大幅提升应用性能和用户体验。
25 15
一个Android App最少有几个线程?实现多线程的方式有哪些?
|
9天前
|
Java 数据库 Android开发
一个Android App最少有几个线程?实现多线程的方式有哪些?
本文介绍了Android应用开发中的多线程编程,涵盖基本概念、常见实现方式及最佳实践。主要内容包括主线程与工作线程的作用、多线程的多种实现方法(如 `Thread`、`HandlerThread`、`Executors` 和 Kotlin 协程),以及如何避免内存泄漏和合理使用线程池。通过有效的多线程管理,可以显著提升应用性能和用户体验。
29 10
|
11天前
|
安全 Java Android开发
探索安卓应用开发的新趋势:Kotlin和Jetpack Compose
在安卓应用开发领域,随着技术的不断进步,新的编程语言和框架层出不穷。Kotlin作为一种现代的编程语言,因其简洁性和高效性正逐渐取代Java成为安卓开发的首选语言。同时,Jetpack Compose作为一个新的UI工具包,提供了一种声明式的UI设计方法,使得界面编写更加直观和灵活。本文将深入探讨Kotlin和Jetpack Compose的特点、优势以及如何结合使用它们来构建现代化的安卓应用。
26 4
|
23天前
|
API Android开发
Android P 性能优化:创建APP进程白名单,杀死白名单之外的进程
本文介绍了在Android P系统中通过创建应用进程白名单并杀死白名单之外的进程来优化性能的方法,包括设置权限、获取运行中的APP列表、配置白名单以及在应用启动时杀死非白名单进程的代码实现。
42 1
|
27天前
|
IDE Java 开发工具
探索安卓开发之旅:打造你的第一款App
【8月更文挑战第24天】在这篇文章中,我们将一起踏上激动人心的安卓开发之旅。不论你是编程新手还是希望扩展技能的老手,本文将为你提供一份详尽指南,帮助你理解安卓开发的基础知识并实现你的第一个应用程序。从搭建开发环境到编写“Hello World”,每一步都将用浅显易懂的语言进行解释。那么,让我们开始吧!
|
1月前
|
开发工具 Android开发
|
1月前
|
Android开发
将AAB(Android App Bundle)转换为APK
将AAB(Android App Bundle)转换为APK
40 1
|
20天前
|
Android开发 iOS开发 C#
Xamarin:用C#打造跨平台移动应用的终极利器——从零开始构建你的第一个iOS与Android通用App,体验前所未有的高效与便捷开发之旅
【8月更文挑战第31天】Xamarin 是一个强大的框架,允许开发者使用单一的 C# 代码库构建高性能的原生移动应用,支持 iOS、Android 和 Windows 平台。作为微软的一部分,Xamarin 充分利用了 .NET 框架的强大功能,提供了丰富的 API 和工具集,简化了跨平台移动应用开发。本文通过一个简单的示例应用介绍了如何使用 Xamarin.Forms 快速创建跨平台应用,包括设置开发环境、定义用户界面和实现按钮点击事件处理逻辑。这个示例展示了 Xamarin.Forms 的基本功能,帮助开发者提高开发效率并实现一致的用户体验。
42 0
|
23天前
|
存储 XML Linux
深入理解操作系统:进程管理与调度策略探索安卓应用开发:从零开始构建你的第一个App
【8月更文挑战第28天】在数字世界里航行,操作系统是掌控一切的舵手。本文将带你领略操作系统的精妙设计,特别是进程管理和调度策略这两大核心领域。我们将从基础概念出发,逐步深入到复杂的实现机制,最后通过实际代码示例,揭示操作系统如何高效协调资源,确保多任务顺畅运行的秘密。准备好了吗?让我们启航,探索那些隐藏在日常电脑使用背后的奥秘。 【8月更文挑战第28天】在这个数字时代,拥有一款自己的移动应用程序不仅是技术的展示,也是实现创意和解决问题的一种方式。本文将引导初学者了解安卓开发的基础知识,通过一个简单的待办事项列表App项目,逐步介绍如何利用安卓开发工具和语言来创建、测试并发布一个基本的安卓应用
|
24天前
|
Java 程序员 Android开发
探索安卓开发:构建你的第一个App
【8月更文挑战第27天】在数字化时代的浪潮中,移动应用成为人们生活不可或缺的一部分。对于渴望进入软件开发领域的新手而言,掌握如何构建一款简单的安卓App是开启技术之旅的关键一步。本文旨在通过浅显易懂的语言和步骤分解,引导初学者了解安卓开发的基础知识,并跟随示例代码,一步步实现自己的第一个安卓App。从环境搭建到界面设计,再到功能实现,我们将一同揭开编程的神秘面纱,让每个人都能体会到创造软件的乐趣。