Android Compose——一个简单的Bilibili APP

news2024/12/23 0:37:35

Bilibili移动端APP

  • 简介
  • 依赖
  • 效果
  • 登录
    • 效果
    • WebView
  • 自定义TobRow的Indicator大小
  • 首页
    • 推荐
    • LazyGridView使用Paging3
    • 热门
  • 排行榜
  • 搜索
    • 模糊搜索
      • 富文本
  • 搜索结果
  • 视频详情
    • 合集
  • 信息
  • Coroutines进行网络请求管理,避免回调地狱
    • 添加suspend
    • withContext
  • Git项目链接

简介

此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和基本逻辑

效果

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,进而进行包裹

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

热门

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

排行榜

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

搜索

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

模糊搜索

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

富文本

通过逐字匹配输入框内的字符与搜索建议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之间都是取屏幕高度,之间不会存在间隙

视频详情

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

合集

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

信息

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错误,被告知没有权限,在根据文档进行使用以及网上查询未果之后,只能暂且搁置此功能。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/389641.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

Motor-DK (MM32SPIN05PF, MM32SPIN06PF, MM32SPIN07PF)

输入电压范围&#xff1a;12V - 30V 使用 60V / 40A N-MOS 管 使用内建&#xff08;MM32SPIN2x&#xff09;/外挂&#xff08;MM32SPIN05 / MM32SPIN06 / MM32SPIN07&#xff09;GBW 6MHz 高速运放 x 4 MCU 使用 5V 供电 支持 48 / 64 Pin MM32SPIN 系列 MCU 支持无霍尔&#x…

LearnDash测验报告如何帮助改进您的课程

某一个场景。Pennywell 大学有一门课程“Introduction to Linear Algebra”。上学期进行了两次测验。20% 的学生在第一次测验中不及格&#xff0c;而 80% 在第二次测验中不及格。在进一步评估中&#xff0c;观察到第一次测验不及格的学生在第二次测验中也不及格。在第二次测验中…

基于Linux系统-搭建Java Web开发环境

目录 1. 安装JDK 2.安装MySQL数据库 3.安装Tomcat 4.访问Tomcat 1. 安装JDK 1.执行以下命令&#xff0c;查看yum源中JDK版本。 yum list java* 2.执行以下命令&#xff0c;使用yum安装JDK1.8。 yum -y install java-1.8.0-openjdk* 3.执行以下命令&#xff0c;查看是否安…

【软件使用】MarkText下载安装与汉化设置 (markdown快捷键收藏)

一、安装与汉化 对版本没要求的可以直接选择 3、免安装的汉化包 1、下载安装MarkText MaxText win64 https://github.com/marktext/marktext/releases/download/v0.17.1/marktext-setup.exe 使用迅雷可以快速下载 2. 配置中文语言包 中文包下载地址&#xff1a;GitHub - chi…

TPU编程竞赛系列|算能赛道冠军SO-FAST团队获第十届CCF BDCI总决赛特等奖!

近日&#xff0c;第十届中国计算机学会&#xff08;CCF&#xff09;大数据与计算智能大赛总决赛暨颁奖典礼在苏州顺利落幕&#xff0c;算能赛道的冠军队伍SO-FAST从2万余支队伍中脱颖而出&#xff0c;获得了所有赛道综合评比特等奖&#xff01; 本届CCF大赛吸引了来自全国的2万…

【MySQL】查询访问方法

查询语句经过查询优化器生成 SQL 执行计划&#xff0c;在引入索引的情况下&#xff0c;MySQL 不可能让我们什么查询都是走全表扫描&#xff0c;那样效率太低了&#xff0c;所有需要有各种各样的执行计划 &#xff0c; MySQL 会根据经验为我们的查询语句生成它认为最优的执行计划…

mac安装nvm

1、nvm介绍 &#xff08;1&#xff09;什么是nvm&#xff1f;简单来说&#xff0c;nvm是一款可以用命令行快速切换node版本的工具&#xff01; &#xff08;2&#xff09;为什么要切换node版本&#xff1f;打个比方&#xff0c;你目前正在用node 14版本&#xff0c;现在出了nod…

Greenplum-主备同步机制

我们在学习Greenplum的架构时知道&#xff0c;Greenplum中主要有Master管理层和Segment计算层。在高可用方面&#xff0c;Master通过配置一个Standby来实现主备&#xff0c;Segment则通过对实例设置镜像的方式也实现主备高可用&#xff08;其中主实例称为Primary&#xff0c;备…

网络基础(二)

目录 应用层 再谈 "协议" 协议是一种 "约定". socket api的接口, 在读写数据时, 都是按 "字符串" 的方式来发送接收的. 如果我们要传输一些"结构化的数据" 怎么办呢? 为什么要转换呢&#xff1f; 如果我们将struct message里面…

传输线的物理基础(二):信号在传输线中的速度

铜中电子的速度信号在传输线上传输的速度有多快&#xff1f;如果人们经常错误地认为信号在传输线上的速度取决于导线中电子的速度。凭着这种错误的直觉&#xff0c;我们可能会想象降低互连的电阻会提高信号的速度。事实上&#xff0c;典型铜线中电子的速度实际上比信号速度慢约…

OpenWrt 软路由 IPV6 配置 DDNS

一、申请 dynv6 账号 1、去官网注册一个账号&#xff0c;不过人机验证那块需要 "梯子" 才能注册成功 Free dynamic DNS for IPv6 2、注册成功后&#xff0c;创建一个 Domain 3、这是我创建好的 4、获取 密码&#xff0c;后面需要用到 二、配置 DDNS 1、点击服务菜…

C++之单例模式

目录 1. 请设计一个类&#xff0c;只能在堆上创建对象 2. 请设计一个类&#xff0c;只能在栈上创建对象 3.请设计一个类&#xff0c;不能被拷贝 C98 C11 4. 请设计一个类&#xff0c;不能被继承 C98 C11 5. 请设计一个类&#xff0c;只能创建一个对象(单例模式) 设计…

在GoLand中编译cgo程序

GoLand的C使用问题为什么会出现fyne之类的包无法在GoLand中运行安装gcc安装make安装Choco安装指令使用Choco安装make将make加入Path将GoLand的make路径进行替换测试&#xff0c;编译fyne程序问题 本人go萌新&#xff0c;正在学习golang的界面包fyen&#xff0c;但是在使用GoLa…

Android面试题——JVM

Android平台的虚拟机是基于栈的吗&#xff1f; JVM运行时数据区 运行时栈 基于栈的虚拟机 第0行表示将一个Int型的1推送至操作数栈栈顶&#xff0c;程序计数器指向第0行。第一行字节码表示将栈顶的int型数值存入第一个本地变量&#xff0c;这两行代码就进行了给局部变量赋值的操…

【开源硬件】STM32F030R8T6系统板

【开源硬件】STM32F030R8T6系统板✅STM32F030R8T6系统板兼容极海APM32F030R8T6 &#x1f530;支持stm32cubemx工程配置成STM32F030R8T6生成的MDK工程&#xff0c;经过Keil编译后可以直接使用ST-Link v2烧录器上传到极海APM32F030R8T6芯片当中&#xff0c;完全做到平替使用&…

[Java·算法·中等]LeetCode22. 括号生成

每天一题&#xff0c;防止痴呆题目示例分析思路1题解1分析思路2题解2分析思路3题解3&#x1f449;️ 力扣原文 题目 数字 n 代表生成括号的对数&#xff0c;请你设计一个函数&#xff0c;用于能够生成所有可能的并且 有效的 括号组合。 示例 输入&#xff1a;n 3 输出&…

Unity Lighting -- 配置阴影

本笔记使用URP管线进行说明 配置渲染管线阴影参数 在项目窗口中&#xff0c;主菜单中选择Edit -> ProjectSettings,然后选择Quality,查看当前项目所使用的URP资源&#xff0c;目前例子所使用的资源为UniversalRP_HighQuality资源 在工程窗口中&#xff0c;会有对应的渲染管…

1060:均值

一。题目 1060&#xff1a;均值 时间限制: 1000 ms 内存限制: 65536 KB 提交数: 101855 通过数: 62518 【题目描述】 给出一组样本数据&#xff0c;包含n个浮点数&#xff0c;计算其均值&#xff0c;精确到小数点后4位。 【输入】 输入有两行&#xff0c;第一行包含…

【Echarts】在Vue中使用Echarts

在Vue中使用Echarts引入第三方库vue-echartsvue-echarts安装与使用vue2中使用Echartsvue-echarts安装vue2中使用Echartsvue3中使用Echartsvue-echarts安装vue3中使用Echarts引入第三方库vue-echarts Vue是数据驱动dom&#xff0c;改变数据让图表自动根据数据变化更新&#xff…

Ubuntu 18.04 出现GLIBC_2.28 not found的解决方法(亲测有效)

关于/lib/x86_64-linux-gnu/libc.so.6: version GLIBC_2.28’ not found出现报错&#xff0c;建议不要使用源码包去编译并升级。在下文有分享一个使用官方的Debian软件包去升级使用的方法。仅供参考&#xff01; 环境 # uname -a Linux Ubuntu 5.4.0-144-generic #161~18.04.…