注册
环信即时通讯云

环信即时通讯云

单聊、群聊、聊天室...
环信开发文档

环信开发文档

Demo体验

Demo体验

场景Demo,开箱即用
RTE开发者社区

RTE开发者社区

汇聚音视频领域技术干货,分享行业资讯
技术讨论区

技术讨论区

技术交流、答疑
资源下载

资源下载

收集了海量宝藏开发资源
iOS Library

iOS Library

不需要辛辛苦苦的去找轮子, 这里都有
Android Library

Android Library

不需要辛辛苦苦的去找轮子, 这里都有

什么?Flutter 又要凉了? Flock 是什么东西?

今天突然看到这个消息,突然又有一种熟悉的味道,看来这个月 Flutter “又要凉一次了”: 起因 flutter foundation 决定 fork Flutter 并推出 Flock 分支用于自建维护,理由是: foundation 推测 Flutt...
继续阅读 »

今天突然看到这个消息,突然又有一种熟悉的味道,看来这个月 Flutter “又要凉一次了”



起因 flutter foundation 决定 fork Flutter 并推出 Flock 分支用于自建维护,理由是:



foundation 推测 Flutter 团队的劳动力短缺,因为 Flutter 需要维护 Android、iOS、Mac、Window、Linux、Web 等平台,但是 Flutter团队的规模仅略有增加。



在 foundation 看来,保守估计全球至少有 100 万 Flutter 相关开发者,而 Flutter 团队的规模大概就只有 50+ 人,这个比例并不健康。



问题在于这个数据推测就很迷,没有数据来源的推测貌似全靠“我认为”。。。。



另外 foundation 做这个决定,还因为 Flutter 官方团队对其 6 个支持的平台中,有 3 个处于维护模式(Window、Mac、Linux),所以他们无法接受桌面端的现场,因为他们认为桌面端很可能是 Flutter 最大的未开发价值。



关于这点目前 PC 端支持确实缓慢,但也并没有完全停止,如果关注 PC issue 的应该看到, Mac 的 PlatformView 和 WebView 支持近期才初步落地。



而让 foundation 最无法忍受的是,issue 的处理还有 pr 的 merge 有时候甚至可能会积累数年之久。



事实上这点确实成立,因为 Flutter 在很多功能上都十分保守,同时 issue 量大且各平台需求多等原因,很多能力的支持时间跨度多比较长,例如 「Row/Column 即将支持 Flex.spacing」「宏编程支持」「支持 P3 色域」 等这些都是持续了很久才被 merge 的 feature 。



所以 Flutter 的另外一个支持途径是来自社区 PR,但是 foundation 表示 Flutter 的代码 Review 和审计工作缓慢,并且沟通困难,想法很难被认可等,让 foundation 无法和 Flutter 官方有效沟通。


总结起来,在 foundation 的角度是,Flutter 官方团队维护 Flutter 不够尽心尽力



所以他们决定,创建 Flutter 分支,并称为 Flock:意寓位 “Flutter+”。



不过 foundation 表示,他们其实并不想也不打算分叉 Flutter 社区,Flock 将始终与 Flutter 保持同步


Flock 的重点是添加重要的错误修复和全新的社区功能支持,例如 Flutter 团队不做的,或者短期不会实现:



并且 Flock 的目的是招募一个比 Flutter 团队大得多的 PR 审查团队,从而加快 PR 的审计和推进。


所以看起来貌似这是一件好事,那么为什么解读会是“崩盘”和“内斗”?大概还是 Flutter 到时间凉了,毕竟刚刚过完 Flutter 是十周年生日 ,凉一凉也挺好的。



更多可见:flutterfoundation.dev/blog/posts/…


作者:恋猫de小郭
来源:juejin.cn/post/7431032490284236839
收起阅读 »

自研一套带双向认证的Android通用网络库

当前,许多网络库基于Retrofit或OkHttp开发,但实际项目中常需要定制化,并且需要添加类似双向认证等安全功能。这意味着每个项目都可能需要二次开发。那么,有没有一种通用的封装方式,可以满足大多数项目需求?本文将介绍一种通用化的封装方法,帮助你用最少的代码...
继续阅读 »

当前,许多网络库基于Retrofit或OkHttp开发,但实际项目中常需要定制化,并且需要添加类似双向认证等安全功能。这意味着每个项目都可能需要二次开发。那么,有没有一种通用的封装方式,可以满足大多数项目需求?本文将介绍一种通用化的封装方法,帮助你用最少的代码量开发出自己的网络库

源码及涉及思路参考:如何开发一款安全高效的Android网络库(详细教程)

框架简介

FlexNet 网络库是基于 Square 公司开源的 Retrofit 网络框架进行封装的。Retrofit 底层采用 OkHttp 实现,但相比于 OkHttp,Retrofit 更加便捷易用,尤其适合用于 RESTful API 格式的请求。

在网络库内部,我们实现了双向认证功能。在初始化时,您可以选择是否开启双向认证,框架会自动切换相应的 URL,而业务方无需关注与服务端认证的具体细节。

接入方式

1. 本地aar依赖

下载aar到本地(下载地址见文末),copy到app的libs目录下,如图:

image.png

implementation(files("libs/flex-net.aar"))

然后sync只会即可

2. 通过Maven远程依赖

FlexNet目前已上传Maven,可通过Maven的方式引入,在app的build.gradle中加入以下依赖:

implementation("com.max.android:flex-net:3.0.0")

sync之后即可拉到Flex-Net

快速上手

网络库中默认打开了双向认证,并根据双向认证开关配置了相应的 baseUrl,大多数场景下只需要控制双向认证开关,其余配置走默认即可。

  1. 初始化

在发起网络请求之前(建议在ApplicationonCreate()中),调用:

fun initialize(
app: Application,
logEnable: Boolean = BuildConfig.LOG_DEBUG,
sslParams: SSLParams? = null,
)

  • application: Application类型,传入当前App的Application实例;
  • logEnable: Boolean类型,网络日志开关,会发打印Http的Request和Resonpse信息,可能涉及敏感数据,release包慎用;(仅限网络请求日志,和双向认证的日志不同)
  • sslParams: 双向认证相关参数,可选,为空则关闭双向认证。具体描述见下文。

当App需要双向认证功能时,需要在initialize()方法中传递sslParams参数,所有双向认证相关的参数都放在sslParams当中,传此参数默认打开双向认证。

SSLParams的定义如下:

data class SSLParams(
/** App 是否在白名单之中。默认不在 */
val inWhiteList: Boolean = false,
/** 双向认证日志开关,可能涉及隐私,release版本慎开。默认关 */
val logSwitch: Boolean = true,
/** 是否开启双向认证。默认开 */
val enable: Boolean = true,
/** 双向认证回调。默认null */
val callback: MutualAuthCallback = null,
)
  • inWhiteList: App是否在白名单中,默认不在
  • logSwitch: 双向认证日志开关,可能涉及隐私,release版本慎开。默认关,注意这里仅针对双向认证日志,与initialize()方法中的logEnable不同
  • callback  监听初始化结果回调,true表示成功,反之失败。可选参数,默认为null,仅enableMutualAuth为true时有效

在调用了initialize之后就完成了初始化工作,内部包含了双向认证、网络状态、本地网络缓存等等功能,所有的网络请求都需要在初始化之后发起。

初始化示例代码:

FlexNetManger.initialize(this,
logEnable = true,
SSLParams {
Timber.i("Mutual auth result : $it")
})

PS * *部分App在启动的时候获取不到证书,所以这里会失败。如果失败了后续可以在合适的时机通过MutualAuthenticate.isSSLReady()来检查是否认证成功,然后通过MutualAuthenticate.suspendBuildSSL()来主动触发双向认证,成功之后方可开始网络请求。具体可参见文档“配置项”的内容。

双向认证失败及其相关问题,可参考双向认证文档  [双向认证])

  1. 定义数据 Model

在请求之前需要根据接口协议的字段定义对应的数据Model,用来做Request或者Response的body。

比如我们需要通过UserId获取对应用户的UserName

  1. 定义 Request 数据 Model

后端请求接口参数如下:

{
"userId" : "123456"
}

那么根据参数定义一个UserNameReq类:

data class UserNameReq(
/** 用户id */
var userId: String
)

  1. 定义 Response 数据 Model

后端返回数据如下:

{
"userName" : "MC"
}

对应定义一个UserNameRsp:

data class UserNameRsp(
/** 用户id */
var userId: String
)
  1. 编写 Http 接口

接口类必须继承自IServerAPI:

interface UserApi: IServerApi

然后在IServerApi的实现类中,每个接口需要用注解的形式标注 Http 方法,通过参数传入 http 请求的 url:

interface UserApi: IServerApi {

/** 获取用户ID */
@POST("api/cloudxcar/atmos/v1/getName")
suspend fun getUserName(@Body request: UserNameReq): ResponseEntity
}

这里需要注意的是,我们的UserNameRsp需要用ResponseEntity封装一层,看一下ResponseEntity的内容:

sealed class ResponseEntity<T>(val body: T?, val code: Int, val msg: String)

有3个参数:

  • body: 消息体,即UserNameReq。仅成功时有效
  • code  返回码,这里要分多种情况描述。

    • Http错误:此时code为Http错误码
    • 其他异常:code对应错误原因,后面会附上映射表
    • 请求成功:区分网络数据和缓存数据
  • msg  错误信息

可调用ResponseEntity.isSuccessful()来判断是否请求成功,然后通过ResponseEntity.body获取数据,返回的是一个根据服务端返回的 Json 解析而来的UserNameRsp实体类。

如果请求失败,则从ResponseEntity.msgResponseEntity.code中获取失败ma失败码和失败提示

  1. 创建网络请求Repo

继承自BaseRepo,泛型参数为步骤3中创建的IserverApi实现类:

class VersionRepo : BaseRepo<VersionAPI>
  1. 其中需要有1个必覆写的变量:

    1. baseUrl: 网络接口的baseUrl
  2. 两个可选项:

    1. mutualAuthSwitch: 双向认证开关,此开关仅针对当前 baseUrl 生效。默认开
    2. interceptorList: 需要设置的拦截器列表
  3. 一个必覆写的方法:

    1. createRepository(): 创建当前网络仓库

完整的Repo类内容如下:

class UserRepo: BaseRepo<UserApi>() {
// 必填
override val baseUrl = "https://juejin.cn/editor/drafts/7379502040140218422"
// 必填
override fun createRepository(): VersionAPI =
MutualAuthenticate.getServerApi(baseUrl, mutualAuthSwitch, interceptorList)
// 可选:双向认证开关,仅针对当前repo生效
override val mutualAuthSwitch = true
// 可选:Http拦截器
override val interceptorList: List? = listOf(HeaderInterceptor())

// 请求接口
suspend fun getUserName(): ResponseEntity{
return mRepo.upgradeVersion(UserNameReq("123456"))
}
}

注: 其中拦截器的设置interceptorList,如果声明的时候提示错误,可以尝试加上完整的类型声明:

interceptorList: List?

5 发起网络请求

最后就可以在业务代码中通过Repo类完成网络请求的调用了:

lifecycleScope.launch {
val entity= UserRepo().getUserName()
Timber.i("Get responseEntity: $entity")

if (entity.isSuccessful()) {
val result = entity.body
Timber.i("Get user name result: $result")
} else {
val code = entity.code
val msg = entity.msg
Timber.i("Get user name failed: code->$code; msg->$msg")
}
}

到这里,就可以发起一次基础的网络请求接口了。

依赖项

  1. 双向认证

目前引入的双向认证版本为1.6.0,如果需要切换版本,或者编译出现依赖冲突,可以尝试使用exclude的方式自行依赖。当然也请自行确保功能正常。

  1. 日志库

implementation("com.jakewharton.timber:timber:4.7.0")

组件库中的日志库。FlexNet推荐宿主使用Timber进行日志输出,但是需要宿主App在初始化FlexNet之前对Timber做plant操作。

  1. 网络请求内核

// Net
implementation ("com.squareup.retrofit2:retrofit:2.9.0")
implementation ("com.squareup.retrofit2:converter-gson:2.9.0")

底层网络请求目前依赖OkHttp完成。

  1. 本地持久化

implementation("com.tencent:mmkv:1.2.14")

网络库中的本地存储,主要用于保存网络缓存,目前采用MMKV-1.2.14版本,同样如果有冲突,或者需要另换版本,可通过exclude实现。

  1. Gson

api(core.network.retrofit.gson) {
exclude(module = "okio")
exclude(module = "okhttp")
}

依赖Gson,用于做数据结构和Json的相互转化

错误码对照表

CODE_SUCCESS10000请求成功,数据来源网络
CODE_SUCCESS_CACHE10001返回成功,数据来源于本地缓存
CODE_SUCCESS_BODY_NULL10002请求成功,但消息体为空
CODE_ERROR_UNKNOWN-200未知错误
CODE_ERROR_UNKNOWN_HOST-201host解析失败,无网络也属于其中
CODE_ERROR_NO_NETWORK-202无网络

日志管理

从FlexNet 2.0.5开始,对接入方使用的日志库不再限制(2.0.5以下必须用Timber,否则无日志输出)。可以通过以下接口来设置日志监视器:

setLogMonitor(log: ILog)

设置之后所有的网络日志都会回调给ILog,即可由接入方自行决定如何处理日志数据。

如果没有设置LogMonitor,则会使用Timber或者Android原生Log来进行日志输出。当宿主App的Timber挂载优先于FlexNet的初始化,则会采用Timber做日志输出,反之使用Android Log。

文件下载

网络库内置了下载功能,可配置下载链接和下载目录。注意外部存储地址需要自行申请系统权限。

1 构建下载器

使用Downloader.builder()来构建你的下载器,Builder需要传入以下参数:

  • url:待下载文件的url
  • filePath:下载文件路径
  • listener:下载状态回调。可选参数,空则无回调

示例代码如下:

Downloader.Builder("https://juejin.cn/editor/drafts/7379502040140218422.zip",
File(requireContext().filesDir, "MC").absolutePath)

2 回调监听

builder()最后一个参数,可传入下载监听器接口DownloadListener,内部有3个方法需要实现:

  • onFinish(file: File): 下载完成,返回下载完成的文件对象
  • onProgress( progress : Int, downloadedLengthKb: Long, totalLengthKb: Long): 下载进度回调,回传进度百分比、已下载的大小、总大小
  • onFailed(errMsg: String?): 下载失败,回调失败信息

示例代码如下:

val downloader = Downloader.Builder("https://juejin.cn/editor/drafts/7379502040140218422.zip",
File(Environment.getExternalStorageDirectory(), "MC").absolutePath,
object : DownloadListener {
override fun onFinish(file: File) {
Timber.e("下载的文件地址为:${file.absolutePath}".trimIndent())
}

override fun onProgress(
progress: Int,
downloadedLengthKb: Long,
totalLengthKb: Long,
)
{
runOnUiThread {
textView.text =
"文件文件下载进度:${progress}% \n\n已下载:%${downloadedLengthKb}KB | 总长:${totalLengthKb}KB"
}
}

override fun onFailed(errMsg: String?) {
Timber.e("Download Failed: $errMsg")
}
}).build()

PS  这里要注意,FlexNet会在业务方调用下载的线程返回下载回调,所以绝大部分时候回调是发生在子线程,此时如果有线程敏感的功能(比如刷新UI),需要自行处理线程切换。

3 触发下载

通过Builder.build()创建 Downloader 下载器,最后调用Downloader.download()方法即可开始下载。

和Http Request一样,download()是一个suspend方法,需要在协程中使用:

lifecycleScope.launch(Dispatchers.IO) {
downloader.download()
}

整体架构

设置配置项

1. 设置双向认证开关

在初始化的时候控制双向认证开关:

fun init(context: Application, needMutualAuth: Boolean = true)

方法内部会根据开关值来切换不同的后端服务器,但是有些App不能过早的获取证书,这样会有双向认证失败的风险,FlexNet同时支持懒汉式的主动双向认证

2. 主动双向认证接口

在确定拿到证书,或者确定可以双向认证的时机,可随时发起双向认证请求:

MutualAuthenticate.suspendBuildSSL()

可通过

MutualAuthenticate.isSSLReady()

接口来检查当前双向认证是否成功。

主动触发示例代码如下:

MutualAuthenticate.suspendBuildSSL {
if (it) {
Toast.makeText(context, "双向认证成功,可以开始访问加密资源", Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(context, "双向认证失败", Toast.LENGTH_SHORT).show()
}
}

3. 数据缓存

在前面发起请求调用httpRequest顶层函数的时候,可以传入一个可选参数cacheKey,这个key不为空则网络库会在本地保存当前请求的返回数据。Key作为缓存的唯一标识,在无网络或请求失败的时候,会通知调用方错误,并返回缓存的数据。

缓存部分流程如下:

4. 错误及异常处理

在发起请求的顶层函数 httpRequest 中,有两个参数用来提供给调用方处理错误和异常。

首先区分一下错误和异常:

错误通常是发起了网络请求,且网络请求有响应,只是由于接口地址或者参数等等原因导致服务端解析失败,最终返回错误码及错误信息。

而异常是指在发起网络请求的过程中出现了 Exception,导致整个网络请求流程被中断,所以当异常发生的时候,网络库是不会返回错误码和错误信息的,只能返回异常信息供调用方定位问题。

回调的使用方式很简单,只需要在httpRequest中传入两个回调:failerror,下面分别看看二者的处理方式:

1. 错误处理

fai的定义如下:

fail: (response: ResponseEntity) -> Unit = {
onFail(it)
}

传入的回调有一个 ResponseEntity 参数,这是网络请求返回的响应实体,内部包含errorCodeerrorMessage,不传则默认打印这两个字段,可以在 Logcat 中通过Tag:Http Request **过滤出来。

2. 异常处理

error的定义如下:

error: (e: Exception) -> Unit = {
onError(it)
} ,

回调函数只有一个 Exeption 对象,和前面的定义相符,在异常的时候将异常返回供调用方定位问题。不传网络库默认打印异常,可以在 Logcat 中通过Tag:Http Request **过滤出来。

扩展接口:发起请求并处理返回结果

网络库定义了一个顶层函数用来发起请求并接收返回结果或处理异常:

fun httpRequest(block, fail, error, cacheKey): T?

  • block: 实际请求体,必填。可以传入步骤 4 中实现的接口
  • fail: 请求错误回调,非必填。用来处理服务端返回的请求错误,会携带错误码及错误信息
  • error: 请求异常回调,非必填。用来处理请求中发生的异常,此时没有response返回
  • cacheKey: 数据缓存唯一标识,非必填

httpRequest 中的泛型 T 就是接入步骤2定义的 Response 实体,正常返回会在方法内部自动解析出 UserNameRsp,到此就完成了一次网络请求。

以上是基本的使用方式,涵盖了安全、数据请求、缓存、异常处理等功能,可以适应于多种项目场景。应大家的建议,后续会完善几篇文章拆解具体的原理及开发思路,从源码的角度教你如何从0开发一套完善的网络库

大家如果想了解设计思路及框架原理,可以参考:源码及涉及思路参考:如何开发一款安全高效的Android网络库(详细教程)

需要体验的同学可以在评论区留下联系方式,我给你发送aar以及源码。有问题欢迎随时探讨


作者:超低空
来源:juejin.cn/post/7379521155286941708
收起阅读 »

Android串口开发入门

最近的开发项目有涉及到Android串口开发,所以比较好奇安卓项目中是如何读取串口数据的,官方给了一个代码示例,但是发现官方的示例比较久了,没有使用CMake,下载后运行老是报错,踩坑,最后使用CMake的方式终于运行成功,在此记录一下,源码:gitee.co...
继续阅读 »

最近的开发项目有涉及到Android串口开发,所以比较好奇安卓项目中是如何读取串口数据的,官方给了一个代码示例,但是发现官方的示例比较久了,没有使用CMake,下载后运行老是报错,踩坑,最后使用CMake的方式终于运行成功,在此记录一下,源码:gitee.com/hluck/hello…


目录结构


QQ截图20240617143025.png


1.创建一个HelloWord项目


QQ截图20240617143924.png


2.引入jni和so库


将jni文件夹和jniLibs文件夹复制到main目录下:


QQ截图20240617144227.png


3.修改gradle


由于此时Android studio编译时,不会去编译加载CMakeLists.txt,所以要告诉他在哪加载:


android {
...
externalNativeBuild {
cmake {
path "src/main/jni/CMakeLists.txt" // 指定 CMakeLists.txt 文件路径
// 其他 CMake 选项
}
}
}

4.加载动态库,编译native方法


官方示例中有两个类是关于打开和关闭串口api的:


QQ截图20240617145352.png


1.SeriaPort

其中加载动态库,打开和关闭串口的native方法在SerialPort类中:


QQ截图20240617145618.png


这两个native方法对应的是jni文件下的SerialPort.c文件中,如果你的SerialPort类所在包名和我的不一样,记得修改一下这个文件,值得一提的是,open方法中的第一个参数是串口地址,第二个参数是波特率,第三个参数是打开串口时的操作模式,0表示默认,当调用读写操作时,如果串口没有准备好数据,程序会阻塞等待,直到有数据可以读取或写入。


QQ截图20240617145945.png


2.FileDescriptor

上面的open方法会返回一个FileDescriptor实例,通过这个实例获取写入和读取串口数据的流。


QQ截图20240617152233.png


5.读取或写入串口数据


在Application类中保存一个SerialPort实例,这样就能通过获取SerialPort实例来读写串口数据了。


QQ截图20240617154236.png


QQ截图20240617154322.png


参考文章


安卓与串口通信-基础篇


安卓与串口通信-实践篇


Android移植谷歌官方串口库


作者:等你等了那么久
来源:juejin.cn/post/7381347654743326746
收起阅读 »

Android ConstraintLayout使用进阶

前言 曾经Android有五大布局,LinearLayout、FrameLayout、TableLayout、RelativeLayout和AbsoluteLayout,那会我们比较常用的布局就两三个,写xml的时候根据界面灵活选择布局,但是往往会面临布局嵌套...
继续阅读 »

前言


曾经Android有五大布局,LinearLayout、FrameLayout、TableLayout、RelativeLayout和AbsoluteLayout,那会我们比较常用的布局就两三个,写xml的时候根据界面灵活选择布局,但是往往会面临布局嵌套过深的问题,阅读也不方便。随着Android生态的发展,Google后来推出了新的布局——ConstraintLayout(约束布局)。


我很快去学习并将其用在项目中,刚开始的时候觉得比较抽象难懂,各种不适应;一段时间过后,这玩意儿真香!


本文不讲ConstraintLayout基本使用(网上资料很多),而是关于使用ConstraintLayout的进阶。


导入依赖:(2.x版本)


implementation 'androidx.constraintlayout:constraintlayout:2.0.2'

进阶1


在开发中可能需要实现如下效果:
在这里插入图片描述
长文本
文本外层有背景,短文本的时候宽度自适应,长文本超过屏幕的时候,背景贴右边,文字显示...,这样的UI需求很常见,我们来一步步拆解。


1、文本背景需要占满屏幕,并且文本显示...


<TextView
android:layout_width="0dp"
android:ellipsize="end"
android:maxLines="1"
android:singleLine="true"
android:background="@drawable/xxx"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
/>


2、这时候TextView会水平居中,我们需要添加


app:layout_constraintHorizontal_bias="0"

layout_constraintHorizontal_bias表示水平偏移,即“当组件左侧和右侧 ( 或者 开始 和 结束 ) 两边被约束后, 两个联系之间的比例”,取值为0-1,具体看ConstraintLayout 偏移 ( Bias ) 计算方式详解,我们只需要将水平偏移量设置为0,控件就会被约束在左侧了。


3、最后一步,短文本的时候宽度自适应,长文本的时候占满屏幕,需要添加


app:layout_constraintWidth_max="wrap"

layout_constraintWidth_max表示指定视图的最大宽度,取值为“wrap”,它和“wrap_content”不同,虽然都是适应内容,但仍然允许视图比约束要求的视图更小。
最终代码:


<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:background="@drawable/xxx"
android:ellipsize="end"
android:maxLines="1"
android:singleLine="true"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintWidth_max="wrap"
tools:text="这是一个测试文案"
/>


进阶2


再来看个效果图:
在这里插入图片描述
在这里插入图片描述
还是文本适配的问题,短昵称的时候自适应,长昵称的时候,性别图标跟随文本长度移动,但是图标必须在“聊天”按钮左侧,文本显示...


我们再来一步步拆解(仅针对昵称Textview):


一、重复上面的步骤1和步骤2,代码如下(注意layout_width="wrap_content",上面的是0dp)


<TextView
android:id="@+id/tv_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="我是昵称"
android:singleLine="true"
android:ellipsize="end"
android:maxLines="1"
app:layout_constraintHorizontal_bias="0"
app:layout_constraintStart_toEndOf="@id/iv_head"
app:layout_constraintEnd_toStartOf="@id/iv_gender"
/>


二、这时候我们会发现布局是居中的,而且昵称TextView都需要收尾元素相连,我们可以使用layout_constraintHorizontal_chainStyle改变整条链的约束状态,它有三个值,分别是spread、spread_inside和packed,其中packed表示将所有 Views 打包到一起不分配多余的间隙(当然不包括通过 margin 设置多个 Views 之间的间隙),然后将整个组件组在可用的剩余位置居中(可以查看Chains链布局),同时由于layout_constraintHorizontal_bias="0"的作用,布局将会向左侧偏移。


app:layout_constraintHorizontal_chainStyle="packed"

三、最后,当我们输入文本时,发现文本并没有约束到“聊天”按钮左侧,因为layout_width="wrap_content",添加的约束是不起作用的,所以需要强制约束


 app:layout_constrainedWidth="true"

代码动态改变约束


初始约束:
在这里插入图片描述
修改后的约束:
在这里插入图片描述
如上图,初始状态,中间按钮约束在按钮1右侧,某个条件下需要将中间按钮约束在按钮2左侧,这种时候,我们就需要在代码动态设置约束了。
具体代码:


constraintLayout?.let {
//初始化一个ConstraintSet
val set = ConstraintSet()
//将原布局复制一份
set.clone(it)
//分别将“中间按钮”START方向和BOTTOM方向的约束清除
set.clear(“中间按钮”, ConstraintSet.START)
set.clear(“中间按钮”, ConstraintSet.BOTTOM)
//重新建立新的约束
//“中间按钮”的END约束“按钮2”控件的START
//相当于 app:layout_constraintEnd_toStartOf="@id/按钮2"
set.connect(
“中间按钮”,
ConstraintSet.END,
“按钮2”,
ConstraintSet.START,
resources.getDimensionPixelSize(R.dimen.dp_9)
)
//以及底部方向的约束
...
//最后将更新的约束应用到布局
set.applyTo(it)
}

MotionLayout


接下来是今天重头戏——MotionLayout。


MotionLayout继承自ConstraintLayout,能够通过约束关系构建丰富的view动画,动画状态分为start与end两个状态,它还能作为支持库,兼容到api 14。


来看下效果图,这是我司App某个页面的动画效果,就是用MotionLayout实现。


在这里插入图片描述


我们可以写个简单的demo实现上面一部分动画效果,如下图


在这里插入图片描述


首先我们需要在资源文件夹 res 下新建一个名为 xml 的资源文件夹,然后在 文件夹内新建一个根节点是 MotionScene 的 xml 文件,文件名为 test_motion_scene.xml,如下:


<?xml version="1.0" encoding="utf-8"?>
<MotionScene xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:motion="http://schemas.android.com/apk/res-auto">


</MotionScene>

activity的xml根布局改为MotionLayout,使用app:layoutDescription与之关联


在这里插入图片描述
再编写视图,定义视图具体的view和对应id


<androidx.constraintlayout.motion.widget.MotionLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
app:layoutDescription="@xml/test_motion_scene"
...
>


<ImageView
android:id="@+id/iv_head"
...
/>

<TextView
android:id="@+id/tv1"
...
/>


然后切换到test_motion_scene.xml,我们需要明确动画布局的两个状态,start和end。
在MotionScene标签下定义Transition标签,指定动画的start和end状态


<Transition
motion:constraintSetEnd="@+id/end"
motion:constraintSetStart="@id/start"
motion:duration="500">

</Transition>

之后,在Transition同级下再定义ConstrainSet标签,它表示用于指定所有视图在动画序列中某一点上的位置和属性,你可以把它理解成一个集合,集合了所有参与动画的view相关位置和属性,如下:


<?xml version="1.0" encoding="utf-8"?>
<MotionScene
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:motion="http://schemas.android.com/apk/res-auto">

<Transition>
...
</Transition>

<ConstraintSet android:id="@+id/start">
</ConstraintSet>

<ConstraintSet android:id="@+id/end">
</ConstraintSet>
</MotionScene>

大体的框架搭建好了,最后就是填充约束view状态的代码了。这时候我们需要明确动画的start状态和end状态,即


(start状态)↓
在这里插入图片描述


(end状态)↓
在这里插入图片描述


前面提到,ConstraintSet是存放一些view 约束和属性的的集合,而具体描述View约束和属性是通过Constraint 标签。我们声明Constraint标签,它支持一组标准 ConstraintLayout 属性,用于添加每个view start状态的约束。


<ConstraintSet android:id="@+id/start">
<Constraint
<!-- "android:id"表示activity的xml对应的view id
android:id="@id/iv_head"
android:layout_width="90dp"
android:layout_height="90dp"
motion:layout_constraintTop_toTopOf="parent"
motion:layout_constraintStart_toStartOf="parent"
motion:layout_constraintEnd_toEndOf="parent"/>

<Constraint
android:id="@id/iv1"
.../>

<Constraint
android:id="@id/iv2"
.../>

...
</ConstraintSet>

接下来以同样的方式添加end状态的view约束


<ConstraintSet android:id="@+id/end">
...
</ConstraintSet>

最后,我们需要让它动起来,在Transition标签写添加一个OnClick标签,run,就能让动画动起来


<Transition
motion:constraintSetEnd="@+id/end"
motion:constraintSetStart="@id/start"
motion:duration="1000">

<!-- 点击-->
<OnClick
motion:clickAction="toggle"
motion:targetId="@id/search_go_btn"/>

</Transition>

OnClick:表示由用户点击触发


属性:


motion:targetId="@id/target_view" (目标View的id)
如果不指定次属性,就是点击整个屏幕触发如果写了这个属性,就是点击对应id的View 触发转场动画


motion:clickAction=“action” 点击后要进行的行为 ,此属性可以设置以下几个值:


transitionToStart
过渡到 元素 motion::constraintSetStart 属性指定的状态,有过度动画效果。


transitionToEnd
过渡到 元素motion:constraintSetEnd 属性指定的状态,有过度动画效果。


jumpToStart
直接跳转到 元素 motion::constraintSetStart 属性指定的状态,没有动画效果。


jumpToEnd
直接跳转到 元素 motion:constraintSetEnd 属性指定的状态。


toggle
默认值就是这个,在 元素motion:constraintSetStart和 motion:constraintSetEnd 指定的布局之间切换,如果处于start状态就过度到end状态,如果处于end状态就过度到start状态,有过度动画。


除了OnClick之外,还有OnSwipe,它是根据用户滑动行为调整动画的进度,具体可查看文末资料。


改变动画运动过程(关键帧KeyFrameSet)


上面讲解了动画的start与end状态,但是如果我们想在动画运动过程去改变一些属性,比如设置view的透明度、旋转,又或者是改变动画运动过程的轨迹等,这时候可以用到关键帧。


KeyFrameSet是Transition的子元素,与OnClick、OnSwipe同级。KeyFrameSet中可以包含KeyPositionKeyAttributeKeyCycleKeyTimeCycleKeyTrigger,它们都可以用来改变动画过程。


此外还有与KeyFrameSet同级的KeyPositionKeyAttribute,具体大家根据需要自行了解即可。


最后再提一下MotionLayout一些常用的java api:


loadLayoutDescription() ——对应xml"app:layoutDescription",通过代码加载MotionScene;


transitionToStart() ——表示切换到动画start状态;


transitionToEnd() ——表示切换到动画end状态;


它们都默认有过渡效果,如果不需要过渡效果,可以通过**setProgress(float pos)**处理过渡进度,取值0-1;


transitionToState(int id) ——表示切换到动画某个状态,可以是start也可以是end,参数id指的是ConstraintSet标签定义的id;


setTransitionListener(MotionLayout.TransitionListener listener) ——监听MotionLayout动画执行过程,接口有四个方法,onTransitionStartedonTransitionChangeonTransitionCompletedonTransitionTrigger


OK,最最后,ConstraintLayout能有效提升日常的开发效率,通过这篇文章的介绍,此刻你学废了嘛~


参考


MotionLayout官网文档


ConstraintLayout / MotionLayout GitHub示例


MotionLayout 使用说明书(入门级详解)


ConstraintLayout使用小技巧


作者:哆Laker梦
来源:juejin.cn/post/6886337167279259661
收起阅读 »

大公司如何做 APP:背后的开发流程和技术

我记得五六年前,当我在 Android 开发领域尚处初出茅庐阶段之时,我曾有一个执念——想看下大公司在研发一款产品的流程和技术上跟小公司有什么区别。公司之大,对开发来说,不在于员工规模,而在于产品的用户量级。只有用户量级够大,研发过程中的小问题才会被放大。当用...
继续阅读 »

我记得五六年前,当我在 Android 开发领域尚处初出茅庐阶段之时,我曾有一个执念——想看下大公司在研发一款产品的流程和技术上跟小公司有什么区别。公司之大,对开发来说,不在于员工规模,而在于产品的用户量级。只有用户量级够大,研发过程中的小问题才会被放大。当用户量级够大,公司才愿意在技术上投入更多的人力资源。因此,在大公司里做技术,对个人的眼界、技术细节和深度的提升都有帮助。


我记得之前我曾跟同事调侃说,有一天我离职了,我可以说我毕业了,因为我这几年学到了很多。现在我想借这个机会总结下这些年在公司里经历的让我印象深刻的技术。


1、研发流程


首先在产品的研发流程上,我把过去公司的研发模式分成两种。


第一种是按需求排期的。在评审阶段一次性评审很多需求,和开发沟通后可能删掉优先级较低的需求,剩下的需求先开发,再测试,最后上线。上线的时间根据开发和测试最终完成的时间确定。


第二种是双周迭代模式,属于敏捷开发的一种。这种开发机制里,两周一个版本,时间是固定的。开发、测试和产品不断往时间周期里插入需求。如下图,第一周和第三周的时间是存在重叠的。具体每个阶段留多少时间,可以根据自身的情况决定。如果需求比较大,则可以跨迭代,但发布的时间窗口基本是固定的。


截屏2023-12-30 13.00.33.png


有意思的是,第二种开发机制一直是我之前的一家公司里负责人羡慕的“跑火车”模式。深度参与过两种开发模式之后,我说下我的看法。


首先,第一种开发模式适合排期时间比较长的需求。但是这种方式时间利用率相对较低。比如,在测试阶段,开发一般是没什么事情做的(有的会在这个时间阶段布置支线需求)。这种开发流程也有其好处,即沟通和协调成本相对较低。


注意!在这里,我们比较时间利用率的时候是默认两种模式的每日工作时间是相等的且在法律允许范围内。毕竟,不论哪一种研发流程,强制加班之后,时间利用率都“高”(至少老板这么觉得)。


第二种开发方式的好处:



  1. 响应速度快。可以快速发现问题并修复,适合快速试错。

  2. 时间利用率高。相比于按需求排期的方式,不存在开发和测试的间隙期。


但这种开发方式也有缺点:



  1. 员工压力大,容易造成人员流失。开发和测试时间穿插,开发需要保证开发的质量,否则容易影响整个迭代内开发的进度。

  2. 沟通成本高。排期阶段出现人力冲突需要协调。开发过程中出现问题也需要及时、有效的沟通。因此,在这种开发模式里还有一个角色叫项目经理,负责在中间协调,而第一种开发模式里项目经理的存在感很低。

  3. 这种开发模式中,产品要不断想需求,很容易导致开发的需求本身价值并不大。


做了这么多年开发,让人很难拒绝一个事实是,绝大多数互联网公司的壁垒既不是技术,也不是产品,而是“快速迭代,快速试错”。从这个角度讲,双周迭代开发机制更适应互联网公司的要求。就像我们调侃公司是给电脑配个人,这种开发模式里就是给“研发流水线”配个人,从产品、到开发、到测试,所有人都像是流水线上的一员。


2、一个需求的闭环


以上是需求的研发流程。如果把一个需求从产品提出、到上线、到线上数据回收……整个生命周期列出来,将如下图所示,


需求闭环.drawio.png


这里我整合了几个公司的研发过程。我用颜色分成了几个大的流程。相信每个公司的研发流程里或多或少都会包含其中的几个。在这个闭环里,我说一下我印象比较深刻的几个。


2.1 产品流程


大公司做产品一个显著的特点是数据驱动,一切都拿数据说话。一个需求的提出只是一个假设,开发上线之后效果评估依赖于数据。数据来源主要有埋点上报和舆情监控。


1. 数据埋点


埋点数据不仅用于产品需求的验证,也用于推荐算法的训练。因此,大公司对数据埋点的重视可以说是深入骨髓的。埋点数据也经常被纳入到绩效考核里。


开发埋点大致要经过如下流程,



  • 1). 产品提出需要埋的点。埋点的类型主要包括曝光和点击等,此外还附带一些上报的参数,统计的维度包括用户 uv 和次数 pv.

  • 2). 数据设计埋点。数据拿到产品要埋的点之后,设计埋点,并在埋点平台录入。

  • 3). 端上开发埋点。端上包括移动客户端和 Web,当然埋点框架也要支持 RN 和 H5.

  • 4). 端上验证埋点。端上埋点完成之后需要测试,上报埋点,然后再在平台做埋点校验。

  • 5). 产品提取埋点数据。

  • 6). 异常埋点数据修复。


由此可见,埋点及其校验对开发来说也是需要花费精力的一环。它不仅需要多个角色参与,还需要一个大数据平台,一个录入、校验和数据提取平台,以及端上的上报框架,可以说成本并不低。


2. 舆情监控


老实说,初次接触舆情监控的时候,它还是给了我一点小震撼的。没想到大公司已经把舆情监控做到了软件身上。


舆情监控就是对网络上关于该 APP 的舆情的监控,数据来源不仅包括应用内、外用户提交的反馈,还包括主流社交平台上关于该软件的消息。所有数据在整合到舆情平台之后会经过大数据分析和分类,然后进行监控。舆情监控工具可以做到对产品的负面信息预警,帮助产品经理优化产品,是产品研发流程中重要的一环。


3. AB 实验


很多同学可能对 AB 实验都不陌生。AB 实验就相当于同时提出多套方案,然后左右手博弈,从中择优录用。AB 实验的一个槽点是,它使得你代码中同时存在多份作用相同的代码,像狗皮膏药一样,也不能删除,非常别扭,最后导致的结果是代码堆积如山。


4. 路由体系建设


路由即组件化开发中的页面路由。但是在有些应用里,会通过动态下发路由协议支持运营场景。这在偏运营的应用里比较常见,比如页面的推荐流。一个推荐流里下发的模块可能打开不同的页面,此时,只需要为每个页面配置一个路由路径,然后推荐流里根据需要下发即可。所以,路由体系也需要 Android 和 iOS 双端统一,同时还要兼容 H5 和 RN.


mdn-url-all.png


在路由协议的定义上,我们可以参考 URL 的格式,定义自己的协议、域名、路径以及参数。以 Android 端为例,可以在一个方法里根据路由的协议、域名对原生、RN 和 H5 等进行统一分发。


2.2 开发流程


在开发侧的流程里,我印象深的有以下几个。


1. 重视技术方案和文档


我记得之前在一家公司里只文档平台就换了几个,足见对文档的重视。产品侧当然更重文档,而对研发侧,文档主要有如下几类:1). 周会文档;2).流程和规范;3).技术方案;4).复盘资料等。


对技术方案,现在即便我自己做技术也保留了写大需求技术方案先行的习惯。提前写技术方案有几个好处:



  • 1). 便于事后回忆:当我们对代码模糊的时候,可以通过技术方案快速回忆。

  • 2). 便于风险预知:技术方案也有助于提前预知开发过程中的风险点。前面我们说敏捷开发提前发现风险很重要,而做技术方案就可以做到这点。

  • 3). 便于全面思考:技术方案能帮助我们更全面地思考技术问题。一上来就写代码很容易陷入“只见树木,不见森林”的困境。


2. Mock 开发


Mock 开发也就是基于 Mock 的数据进行开发和测试。在这里它不局限于个人层面(很多人可能有自己 Mock 数据开发的习惯),而是在公司层面将其作为一种开发模式,以实现前后端分离。典型的场景是客户端先上线预埋,而后端开发可能滞后一段时间。为了支持 Mock 开发模式,公司需要专门的平台,提供以接口为维度的 Mock 工具。当客户端切换到 Mock 模式之后,上传到网络请求在后端的网关直接走 Mock 服务器,拉取 Mock 数据而不是真实数据。


这种开发模式显然也是为了适应敏捷开发模式而提出的。它可以避免前后端依赖,减轻人力资源协调的压力。这种开发方式也有其缺点:



  • 1). 数据结构定义之后无法修改。客户端上线之后后端就无法再修改数据结构。因此,即便后端不开发,也需要先投入人力进行方案设计,定义数据结构,并拉客户端进行评审。

  • 2). 缺少真实数据的验证。在传统的开发模式中,测试要经过测试和 UAT 两个环境,而 UAT 本身已经比较接近线上环境,而使用 Mock 开发就完全做不到这么严谨。当我们使用 Mock 数据测试时,如果我们自己的 Mock 的数据本身失真比较严重,那么在意识上你也不会在意数据的合理性,因此容易忽视一些潜在的问题。


3. 灰度和热修复


灰度的机制是,在用户群体中选择部分用户进行应用更新提示的推送。这要求应用本身支持自动更新,同时需要对推送的达到率、用户的更新率进行统计。需要前后端一套机制配合。灰度有助于提前发现应用中存在的问题,这对超大型应用非常有帮助,毕竟,现在上架之后发现问题再修复的成本非常高。


但如果上架之后确实出现了问题就需要走热修复流程。热修复的难点在于热修复包的下发,同时还需要审核流程,因此需要搭建一个平台。这里涉及的细节比较多,后面有时间再梳理吧。


4. 配置下发


配置下发就是通过平台录入配置,推送,然后在客户端读取配置信息。这也是应用非常灵活的一个功能,可以用来下发比如固定的图片、文案等。我之前做个人开发的时候也在服务器上做了配置下发的功能,主要用来绕过某些应用商店的审核,但是在数据结构的抽象上做得比较随意。这里梳理下配置下发的细节。



  • 首先,下发的配置是区分平台特征的。这包括,应用的目标版本(一个范围)、目标平台(Android、iOS、Web、H5 或者 RN)。

  • 其次,为了适应组件化开发,也为了更好地分组管理,下发的配置命名时采用 模块#配置名称 的形式。

  • 最后,下发的数据结构支持,整型、布尔类型、浮点数、字符串和 Json.


我自己在做配置下发的时候还遇到一个比较棘手的问题——多语言适配。国内公司的产品一般只支持中文,这方面就省事得多。


5. 复盘文化


对于敏捷开发,复盘是不可或缺的一环。有助于及时发现问题,纠正和解决问题。复盘的时间可以是定期的,在一个大需求上线之后,或者出现线上问题之后。


3、技术特点


3.1 组件化开发的痛点


在大型应用开发过程中,组件化开发的意义不仅局限于代码结构层面。组件化的作用体现在以下几个层面:



  • 1). 团队配合的利器。想想几十个人往同一份代码仓库里提交代码的场景。组件化可以避免无意义的代码冲突。

  • 2). 提高编译效率。对于大型应用,全源码编译一次的时间可能要几十分钟。将组件打包成 aar 之后可以减少需要编译的代码的数量,提升编译效率。

  • 3). 适应组织架构。将代码细分为各个组件,每个小团队只维护自己的组件,更方便代码权限划分。


那么,在实际开发过程中组件化开发会存在哪些问题呢?


1. 组件拆分不合理


这在从单体开发过渡到组件化开发的应用比较常见,即组件化拆分之后仍然存在某些模块彼此共用,导致提交代码的时候仍然会出现冲突问题。冲突包含两个层面的含义,一是代码文件的 Git 冲突,二是在打包合入过程中发布的 aar 版本冲突。比较常见的是,a 同学合入了代码到主干之后,b 同学没有合并主干到自己的分支就打包,导致发布的 aar 没有包含最新的代码。这涉及打包的问题,是另一个痛点问题,后面再总结。


单就拆分问题来看,避免上述冲突的一个解决办法是在拆分组件过程中尽可能解耦。根据我之前的观察,存在冲突的组件主要是数据结构和 SPI 接口。这是我之前公司没做好的地方——数据结构仓库和 SPI 接口是共用的。对于它们的组件化拆分,我待过的另一家公司做得更好。他们是如下拆分的,这里以 A 和 B 来命名两个业务模块。那么,在拆分的时候做如下处理,


模块:A-api
模块:A
模块:B-api
模块:B

即每个业务模块拆分成 api 和实现两部分。api 模块里包含需要共享的数据结构和 SPI 接口,实现模块里是接口的具体实现。当模块 A 需要和模块 B 进行交互的时候,只需要依赖 B 的 api 模块。可以参考开源项目:arch-android.


2. 打包合入的痛点


上面我们提到了一种冲突的情况。在我之前的公司里,每个组件有明确的负责人,在每个迭代开发的时候,组件负责人负责拉最新 release 分支。其他同学在该分支的开发需要经过负责人同意再合入到该分支。那么在最终打包的过程中,只需要保证这个分支的 aar 包含了全部最新的代码即可。也就是说,这种打包方式只关心每个 aar 的版本,而不关心实际的代码。因为它最终打包是基于 aar 而不是全源码编译。


这种打包方式存在最新的分支代码没有被打包的风险。一种可行的规避方法是,在平台通过 Git tag 和 commit 判断该分支是否已经包含最新代码。此外,还可能存在某个模块修改了 SPI 接口,而另一个模块没有更新,导致运行时异常的风险。


另一个公司是基于全源码编译的。不过,全源码编译只在最终打包阶段或者某个固定的时间点进行,而不是每次合入都全源码编译(一次耗时太久)。同时,虽然每个模块有明确的负责人,但是打包的 aar 不是基于当前 release 分支,而是自己的开发分支。这是为了保障当前 release 分支始终是可用的。合并代码到 release 分支的同时需要更新 aar 的版本。但它也存在问题,如果合并到 release 而没有打包 aar,那么可能导致 release 分支无法使用。如果打包了 aar 但是此时其他同学也打包了 aar,则可能导致本次打包的 aar 落后,需要重新打包。因此,这种合入方式也是苦不堪言。


有一种方法可以避免上述问题,即将打包和合入事件设计成一个消息队列。每次合入之前自动化执行上述操作,那么自然就可以保证每次操作的原子性(因为本身就是单线程的)。


对比两种打包和合入流程,显然第二种方式更靠谱。不过,它需要设计一个流程。这需要花费一点功夫。


3. 自动化切源码


我在之前的一家公司开发时,在开发过程中需要引用另一个模块的修改时,需要对另一个模块打 SNAPSHOT 包。这可行,但有些麻烦。之前我也尝试过手动修改 settings.gradle 文件进行源码依赖开发。不过,太麻烦了。


后来在另一个公司里看到一个方案,即动态切换到源码开发。可以将某个依赖替换为源码而只需要修改脚本即可。这个实践很棒,我已经把它应用到独立开发中。之前已经梳理过《组件化开发必备:Gradle 依赖切换源码的实践》.


3.2 大前端化开发


1. React Native


如今的就业环境,哪个 Android 开发不是同时会五六门手艺。跨平台开发几乎是不可避免的。


之前的公司为什么选择 React Native 而不是 Flutter 等新锐跨平台技术呢?我当时还刻意问了这个问题。主要原因:



  • 1). 首先是 React Native 相对更加成熟,毕竟我看了下 Github 第一个版本发布已经是 9 年前的事情了,并且至今依旧非常活跃。

  • 2). React Native 最近更新了 JavaScript 引擎,页面启动时间、包大小和内存占用性能都有显著提升。参考这篇文章《干货 | 加载速度提升15%,携程对RN新一代JS引擎Hermes的调研》.

  • 3). 从团队人才配置上,对 React Native 熟悉的更多。


React Native 开发是另一个领域的东西,不在本文讨论范围内。每个公司选择 React Native 可能有它的目的。比如,我之前的一家公司存粹是为了提效,即一次开发双端运行。而另一家公司,则是为了兼顾提效和动态化。如果只为提效,那么本地编译和打包 js bundle 就可以满足需求。若要追求动态化,就需要搭建一个 RN 包下发平台。实际上,在这个公司开发 RN 的整个流程,除了编码环节,从代码 clone 到最终发布都是在平台上执行的。平台搭建涉及的细节比较多,以后用到再总结。对于端侧,RN 的动态化依赖本地路由以及 RN 容器。


2. BFF + DSL


DSL 是一种 UI 动态下发的方案。相比于 React Native,DSL 下发的维度更细,是控件级别的(而 RN 是页面级别的)。简单的理解是,客户端和后端约定 UI 格式,然后按照预定的格式下发的数据。客户端获取到数据之后渲染。DSL 不适合需要复杂动画的场景。若确实要复杂动画,则需要自定义控件。


工作流程如下图中左侧部分所示,右侧部分是每个角色的责任。


DSL workflow.drawio.png


客户端将当前页面和位置信息传给 DSL 服务器。服务器根据上传的信息和位置信息找到业务接口,调用业务接口拉取数据。获取到数据后根据开发过程中配置的脚本对数据进行处理。数据处理完成之后再交给 DSL 服务器渲染。渲染完成之后将数据下发给客户端。客户端再根据下发的 UI 信息进行渲染。其中接口数据的处理是通过 BFF 实现的,由客户端通过编写 Groovy 脚本实现数据结构的转换。


这种工作流程中,大部分逻辑在客户端这边,需要预埋点位信息。预埋之后可以根据需求进行下发。这种开发的一个痛点在于调试成本高。因为 DSL 服务器是一个黑盒调用。中间需要配置的信息过多,搭建 UI 和编写脚本的平台分散,出现问题不易排查。


总结


所谓他山之石,可以攻玉。在这篇文章中,我只是选取了几个自己印象深刻的技术点,零零碎碎地写了很多,比较散。对于有这方面需求的人,会有借鉴意义。


作者:开发者如是说
来源:juejin.cn/post/7326268908984434697
收起阅读 »

车机系统与Android的关系

前言:搞懂 Android 系统和汽车到底有什么关系。 一、基本概念 1、Android Auto 1)是什么 Android Atuo 是一个 Android 端的 app,专门为驾驶环境设计的; 运行环境:需要在 Android 5.0 或者更高版本的...
继续阅读 »

前言:搞懂 Android 系统和汽车到底有什么关系。



一、基本概念


1、Android Auto


1)是什么



  • Android Atuo 是一个 Android 端的 app,专门为驾驶环境设计的;

  • 运行环境:需要在 Android 5.0 或者更高版本的系统,并且需要 Google 地图和 Google Play 音乐应用;


2)功能



  • Android Atuo 可以用来将 Android 设备上的部分功能映射到汽车屏幕上;

  • 满足了很多人在开车时会使用手机的需求;


2、Google Assistant



  • Google 将 GoofleAssistant 集成到 AndroidAuto 中;

  • 交互方式有键盘、触摸、语音等;

  • 对于汽车来说,语音无疑是比触摸更好的交互方式;

  • 在驾驶环境中,语音交换存在的优势

    • 用户不改变自身的物理姿势,这种交互方式不影响驾驶员对驾驶的操作;

    • 有需要多次触摸的交互时,可能只需要一条语音就可以完成;

    • 语音交互不存在入口的层次嵌套,数据更加扁平;

    • 优秀的语音系统可以利用对话的上下文完成任务,避免用户重复输入;




3、Android Automotive


1、Android Auto 和 Android Automotive 的区别



  • Android Auto 是以手机为中心的

    • 好处:数据和应用始终是一致的,不存在需要数据同步的问题,手机上装的软件和已有数据,接到汽车上就直接有了;

    • 坏处:每次都需要拿出手机,汽车只是作为手机的一个外设;这种模式不便于对于汽车本身的控制和相关数据的获取;



  • Android Automotive

    • 如果将系统直接内置于汽车中,会大大提升用户体验;

    • Android Automotive 就是面向这个方向进行设计的;

    • 一旦将系统内置于汽车,可以完成的功能就会大大增加;例如,直接在中控触摸屏上调整座椅和空调;同时,系统也能获取更多关于汽车的信息,例如:油耗水平、刹车使用等;




加两张中控和仪表的图片


4、App


1)App 的开发



  • Android Auto 目前仅支持两类第三方应用

    • 音频应用:允许用户浏览和播放汽车中的音乐和语音内容;

    • 消息应用:通过 text-to-speech 朗读消息并通过语音输入回复消息;




2)App 的设计



  • Google 专门为 Android Auto 上的 UI 设计做了一个指导网站:Auto UI guidelines;

  • 基本指导原则(车机交互系统的借鉴)

    • Android Auto 上的互动步调必须由驾驶员控制;

    • 汽车界面上的触摸目标必须足够大,以便可以轻松地浏览和点击;

    • 适当的私彩对比可以帮助驾驶员快速解读信息并做出决定;

    • 应用必须支持夜间模式,因为过高的强度可能会干扰注意力;

    • Roboto 字体在整个系统中用于保持一致性并帮助提高可读性;

    • 通过触摸来进行分页应用用来作为滑动翻页的补充;

    • 有节制地使用动画来描述两个状态间的变化;






二、源码和架构


1、Android Automative的整体架构




  • Android Automative 的源码包含在 AOSP 中;

  • Android Automative 是在原先 Android的 系统架构上增加了一些与车相关的(图中虚线框中绿色背景的)模块;

    • Car App:包括 OEM 和第三方开发的 App;

      • OEM:就是汽车厂商利用自身掌握的核心技术负责设计和开发新产品,而具体的生产制造任务则通过合同订购的方式委托给同类产品的其他厂家进行,最终产品会贴上汽车厂商自己的品牌商标。这种生产方式被称为定牌生产合作,俗称“贴牌”。承接这种加工任务的制造商就被称为OEM厂商,其生产的产品就是OEM产品;



    • Car API:提供给汽车 App 特有的接口;

    • Car Service:系统中与车相关的服务;

    • Vehicle Network Service:汽车的网络服务;

    • Vehicle HAL:汽车的硬件抽象层描述;




1)Car App



  • /car_product/build/car.mk 这个文件中列出了汽车系统中专有的模块;

  • 列表中,首字母大写的模块基本上都是汽车系统中专有的 App;

  • App的源码都位于 /platform/packages/services/Car/ 目录下


    # Automotive specific packages
    PRODUCT_PACKAGES += \
    vehicle_monitor_service \
    CarService \
    CarTrustAgentService \
    CarDialerApp \
    CarRadioApp \
    OverviewApp \
    CarLensPickerApp \
    LocalMediaPlayer \
    CarMediaApp \
    CarMessengerApp \
    CarHvacApp \
    CarMapsPlaceholder \
    CarLatinIME \
    CarUsbHandler \
    android.car \
    libvehiclemonitor-native \



2)Car API



  • 开发汽车专有的App自然需要专有的API;

  • 这些API对于其他平台(例如手机和平板)通常是没有意义的;

  • 所以这些API没有包含在Android Framework SDK中;

  • 下图列出了所有的 Car API;




  • android.car:包含了与车相关的基本API。例如:车辆后视镜,门,座位,窗口等。

    • cabin:座舱相关API。

    • hvac:通风空调相关API。(hvac是Heating, ventilation and air conditioning的缩写)

    • property:属性相关API。

    • radio:收音机相关API。

    • pm:应用包相关API。

    • render:渲染相关API。

    • menu:车辆应用菜单相关API。

    • annotation:包含了两个注解。

    • app

    • cluster:仪表盘相关API。

    • content

    • diagnostic:包含与汽车诊断相关的API。

    • hardware:车辆硬件相关API。

    • input:输入相关API。

    • media:多媒体相关API。

    • navigation:导航相关API。

    • settings:设置相关API。

    • vms:汽车监测相关API。




3)Car Service



  • Car Service并非一个服务,而是一系列的服务。这些服务都在ICarImpl.java构造函数中列了出来;


public ICarImpl(Context serviceContext, IVehicle vehicle, SystemInterface systemInterface,
CanBusErrorNotifier errorNotifier)
{
mContext = serviceContext;
mHal = new VehicleHal(vehicle);
mSystemActivityMonitoringService = new SystemActivityMonitoringService(serviceContext);
mCarPowerManagementService = new CarPowerManagementService(
mHal.getPowerHal(), systemInterface);
mCarSensorService = new CarSensorService(serviceContext, mHal.getSensorHal());
mCarPackageManagerService = new CarPackageManagerService(serviceContext, mCarSensorService,
mSystemActivityMonitoringService);
mCarInputService = new CarInputService(serviceContext, mHal.getInputHal());
mCarProjectionService = new CarProjectionService(serviceContext, mCarInputService);
mGarageModeService = new GarageModeService(mContext, mCarPowerManagementService);
mCarInfoService = new CarInfoService(serviceContext, mHal.getInfoHal());
mAppFocusService = new AppFocusService(serviceContext, mSystemActivityMonitoringService);
mCarAudioService = new CarAudioService(serviceContext, mHal.getAudioHal(),
mCarInputService, errorNotifier);
mCarCabinService = new CarCabinService(serviceContext, mHal.getCabinHal());
mCarHvacService = new CarHvacService(serviceContext, mHal.getHvacHal());
mCarRadioService = new CarRadioService(serviceContext, mHal.getRadioHal());
mCarNightService = new CarNightService(serviceContext, mCarSensorService);
mInstrumentClusterService = new InstrumentClusterService(serviceContext,
mAppFocusService, mCarInputService);
mSystemStateControllerService = new SystemStateControllerService(serviceContext,
mCarPowerManagementService, mCarAudioService, this);
mCarVendorExtensionService = new CarVendorExtensionService(serviceContext,
mHal.getVendorExtensionHal());
mPerUserCarServiceHelper = new PerUserCarServiceHelper(serviceContext);
mCarBluetoothService = new CarBluetoothService(serviceContext, mCarCabinService,
mCarSensorService, mPerUserCarServiceHelper);
if (FeatureConfiguration.ENABLE_VEHICLE_MAP_SERVICE) {
mVmsSubscriberService = new VmsSubscriberService(serviceContext, mHal.getVmsHal());
mVmsPublisherService = new VmsPublisherService(serviceContext, mHal.getVmsHal());
}
mCarDiagnosticService = new CarDiagnosticService(serviceContext, mHal.getDiagnosticHal());

4)Car Tool


a、VMS



  • VMS全称是Vehicle Monitor Service。正如其名称所示,这个服务用来监测其他进程;

  • 在运行时,这个服务是一个独立的进程,在init.car.rc中有关于它的配置


service vms /system/bin/vehicle_monitor_service
class core
user root
group root
critical

on boot
start vms


  • 这是一个Binder服务,并提供了C++和Java的Binder接口用来供其他模块使用;


作者:一个写代码的修车工
来源:juejin.cn/post/7356981730765291558
收起阅读 »

安卓开发转做鸿蒙后-开篇

一、为什么转做鸿蒙 本人从事安卓开发已近十年,大部分时间还是在不停的需求迭代,或者一遍遍优化各种轮子,自己的职业生涯已经进入了瓶颈期,同时现有工作也很难让自己产生成就感。正好年初有机会转入鸿蒙开发团队,虽然清楚肯定少不了加班,最终也不一定会有预期中的产出,还是...
继续阅读 »

一、为什么转做鸿蒙


本人从事安卓开发已近十年,大部分时间还是在不停的需求迭代,或者一遍遍优化各种轮子,自己的职业生涯已经进入了瓶颈期,同时现有工作也很难让自己产生成就感。正好年初有机会转入鸿蒙开发团队,虽然清楚肯定少不了加班,最终也不一定会有预期中的产出,还是希望自己能有一些新东西的刺激和积累。


二、App鸿蒙化的回顾


本人所在公司差不多算是中厂,C端App日活大概有个几百万,各部门团队大概有30人+,历时半年多的时间,差不多完成了全部功能70%左右。前期主要是个人自学及各种培训、前期调研、App基础库的排期、业务排期、开发上架等几个环节。


1、基础库



  • 网络库

  • 图片库

  • 埋点库

  • 路由库

  • 公共组件

  • 崩溃监控

  • 打包构建


2、业务排期



  • 业务拆分优先级

  • 分期迭代开发测试


三、跟安卓相比的差异性


1、ArkUI和Android布局



  • Android控件习惯于宽高自适应,ArkUI中部分子组件会超过容器组件区域,所以部分组件需要控制宽度

  • Android是命令式UI比较简单直接,ArkUI是声明式,需要重点关注状态管理的合理使用

  • Android列表重复相对简单,ArkUI中List懒加载和组件复用使用比较繁琐

  • Android基于Java可以通过继承抽取一些公共能力,ArkUI组件无法进行继承


2、鸿蒙开发便捷的一面


1、问题的反馈和响应比较及时,华为技术支持比较到位。


2、应用市场对性能要求和各类适配要求比较高,倒逼开发提高自己的开发能力。


3、跟安卓比提供了各种相对完善的组件,避免了开发者需要进行各种封装



  • 路由库

  • 网络库

  • 图片库

  • 扫码

  • 人脸识别

  • picker

  • 统一拖拽

  • 预加载服务

  • 应用接续

  • 智能填充

  • 意图框架

  • AI语音识别


3、鸿蒙开发不便的一面



  • ArkTS文档不够完善,没有从0到1的完整学习流程

  • ArkUI部分组件使用繁琐

  • DevEco-Studio的稳定性需要提升

  • 组件渲染性能需要提升,


四、跨平台方案



  • RN

  • Flutter

  • ArkUI-X


ArkUI-X作为鸿蒙主推的跨平台框架,主要问题是生态的建立和稳定性。所以还是要基于公司基建的完善程度和技术生态进行选择。同时由于鸿蒙的加入,适配3个OS系统的成本提高,公司为降本提效会加快跨平台技术的接入和推进。后续还是需要熟悉跨平台开发的技术。


五、知识体系(待完善)


1、ArkTS应用


1、应用程序包结构(hap、har、hsp)


2、整体架构


3、开发模型


2、ArkTs


1、基本语法


2、方舟字节码


3、容器类库


4、并发


3、ArkUI


1、基本语法


2、声明式UI描述


3、自定义组件


4、装饰器


5、状态管理


6、渲染控制


4、Stage模型


1、应用配置文件


2、应用组件


3、后台任务


4、进程模块


5、线程模型


5、性能优化


1、冷启动


2、响应时延


3、完成时延


4、滑动帧率


5、包大小


作者:村口老王
来源:juejin.cn/post/7409877909999026217
收起阅读 »

拼多多冷启真的秒开

背景 最近在使用拼多多购物,除了价格比较香之外,每次冷启打开的体验非常好,作为一个Android开发不免好奇 简单分析记录一下 冷启数据 体验好,让我想到了郭德纲的那句话"全靠同行的衬托",那找几个同行过来对比下,这里使用淘宝、京东、闲鱼,从点击开始图标开始录...
继续阅读 »



背景


最近在使用拼多多购物,除了价格比较香之外,每次冷启打开的体验非常好,作为一个Android开发不免好奇 简单分析记录一下


冷启数据


体验好,让我想到了郭德纲的那句话"全靠同行的衬托",那找几个同行过来对比下,这里使用淘宝、京东、闲鱼,从点击开始图标开始录个屏直接数秒,



测试手机是 华为 Mate 60



我这个样本比较少,机器性能也比较好,仅仅是个人对比,不代表大众使用的真实情况


可以粗略的看几个常见app的冷启对比下,这里录屏使用的剪映来分析
帧率为 每秒30帧,后面会涉及一些时间换算
image.png


拼多多


无广告冷启动 从点击图标到到首页完整展示 大概花了 29帧,
1000ms * 29/30 约为 0.96s
,太惊人了,基本冷启秒开



拼多多可能真的没有开屏广告,我印象中没有见过拼多多的开屏广告



image.pngimage.png


淘宝


无广告冷启东 从点击图标到到首页完整展示
image.png
image.png
大概花了 1s+21帧,
1000ms+ 21/30*1000ms = 1.7s
还可以



淘宝可能没有开屏广告,或者非常克制,我刷了十几次都没有见到开屏广告



京东


无广告冷启京东
从点击图标到到首页完整展示
image.png
image.png


大概花了 1s+28帧,
1000ms + 28/30*1000ms 约为 1.93s
也是不错的



不过京东的开屏有开屏广告,但是做了用户频控,刷了几次就没了,这里仅对比无广告冷启开屏



闲鱼


毕竟是国内最大的二手平台(虽然现在小商家也特别多),而且是flutter深度使用者,看看它的表现
image.png
image.png
大概花了** 2s+10帧**
2000ms+ 10/30*1000ms 约为 2.3s


image.png
从上面数据来看,怪不得 我使用拼多多之后,打开app 确实比较舒服,因为我就是奔着买东西去的,越快到购物页面越舒服的。或许这就是极致的用户体验吧


首屏细节


拼多多的首页数据咋这么快就准备好了,网络耗时应该也有呢,应该是它提前准备好了数据
image.png
我们来实操验证下



  • 切后台的截图


我们记住 手枪、去虾线、行李箱、停电 这几个卡片
image.png


冷启打开之后首先展示的是 还是切后台之前的数据
image.png
紧接着网络数据到了做了一次屏幕刷新
image.png


到这里大概就明白了,冷启使用上次feeds流的数据,先让用户看到数据,然后等新数据请求到之后再刷新页面就好


为了严谨点,把缓存数据清除的话,那么肯定首次冷启白屏,ok最后再验证一下
image.png


此时冷启白茫茫的一片,看来拼多多的策略还是让用户尽快进应用优先,或者这里并没有刻意设计🤔,都是先进首页有缓存就使用 没有的话就等网络数据,毕竟这种情况也只是新用户或者缓存数据过期才会这样
image.png


因此这里我可以得出把这种缓存优先的技术方案也可以学习学习,看看我们自己的app是不是可以复用一下,绩效这不就来了吗🤔
首页 = 数据 + UI
数据是使用缓存,UI也能吧一些UI组件提前预加载,不过这里也无法判断 是否预加载了首页UI🤔


开屏无广告


我目前在字节就是搞广告的,所以对广告稍微敏感些,开屏广告是一个很棒收入来源,特别是合约广告这种,之前应用冷启时间长,有时候其实是故意抽出一些时间来等待冷启的开屏广告,
但是我试了很多次,确实没看过拼多多的开屏广告,不过从这个结果来看 肯定是 经过严密的ab实验,不过拼多多在开屏广告上确实比较克制,



关于现在互联网的计算广告业务还是蛮有意思的比如 广告类型有 开屏、原生、激励、插屏、横幅,sdk类型有单个adn或者聚合广告sdk,有时间再单独分享几篇。



image.png


冷启优化一些常见手段


冷启动往往是大型应用的必争之地



  1. 实打实的提升用户体验

  2. 可能会带来一些GMV的转化


拼多多技术是应该是有些东西的,但是非常低调,属于人狠话不多那种,也没找到他们的方案。这里结合自身经验聊聊这块,主要是以下4个阶段结合技术手段做优化
image.png


Application attachBaseContext


这个阶段由于 Applicaiton Context 赋值等问题,一般不会有太多的业务代码,可能的耗时会在低版本机器4.x机器比较多,首次由于MultiDex.install耗时



dex 的指令格式设计并不完善,单个 dex 文件中引用的 Java 方法总数不能超过 65536 个,在方法数超过 65536 的情况下,将拆分成多个 dex。一般情况下 Dalvik 虚拟机只能执行经过优化后的 odex 文件,在 4.x 设备上为了提升应用安装速度,其在安装阶段仅会对应用的首个 dex 进行优化。对于非首个 dex 其会在首次运行调用 MultiDex.install 时进行优化,而这个优化是非常耗时的,这就造成了 4.x 设备上首次启动慢的问题。



可以使用一些开源方案,比如 github.com/bytedance/B…
不过 这里优化难度比较大,roi的话 看看app低版本的机型占比再做决定


ContentProvider


这里要注意检查 ContentProvider,特别是一些sdk在 AndroidManifest 里面注册了自己的 xxSDkProvider,然后在 xxSDkProvider 的 onCreate 方面里面进行初始化,确实调用者不需要自己初始化了,可却增加了启动耗时,
我们可以打开 Apk,看一下最终merge的 AndroidManiest 里面有多少 provider,看一下是否有这样的骚操作,往往这里容易忽视,这种情况可以使用谷歌App Startup来收敛ContentProvider


Application 优化



  1. 精简Application 中的启动任务

  2. 基于进程进行任务排布,比如常见的push进程、webview进程


西瓜视频 在冷启优化就将 push、小程序、sandboxed这几个进程做了优化拿到一些不错的收益mp.weixin.qq.com/s/v23jEhF9k…



搞进程难度大风险高




  1. 启动链路任务编排


这里需要先梳理启动链路,做成1任务编排,



  1. 比如之前串2.2行的,搞成并行初始化

  2. 核心任务做有向无环图(DGA)编排,非核心的延迟初始化


idlehandler是个好东西。
image.png
image.png



关于初始化DGA框架有不少框架,谷歌官方也有个 App Startup,感兴趣可以研究下



首页优化


首页是用户感知到的第一个页面,也是冷启优化的关键,前面也提过 首页 = 数据 + UI



  1. 数据 可以使用缓存

  2. UI的话 通常是xml解析优化,或者预加载


在性能较差的手机上,xml inflate 的时间可能在 200 到 500 毫秒之间。自定义控件和较深的 UI 层级会加重这个解析耗时。
一些框架比如x2c,或者AsyncLayoutInflater 可以帮助我们在UI这里做做文章



  1. 插件化


把非核心模块做成插件,使用时候下载使用,一劳永逸,不过插件化也有各种弊端


后台任务优化


主线程相关耗时的优化,事实上除了主线程直接的耗时,后台任务的耗时也是会影响到我们的启动速度的,因为它们会抢占我们前台任务的 cpu、io 等资源,导致前台任务的执行时间变长,因此我们在优化前台耗时的同时也需要优化我们的后台任务



  1. 减少后台线程不必要的任务的执行,特别是一些重 CPU、IO 的任务;

  2. 对启动阶段线程数进行收敛,防止过多的并发任务抢占主线程资源,同时也可以避免频繁的线程间调度降低并发效率

  3. GC 抑制


触发 GC 后可能会抢占我们的 cpu 资源甚至导致我们的线程被挂起,如果启动过程中存在大量的 GC,那么我们的启动速度将会受到比较大的影响。通过hook手段在启动阶段去抑制部分类型的 GC,以达到减少 GC 的目的。这个就比较高端了,也是只在一些大厂文章里面见过。


OK 本期就到这里了


作者:程序员龙湫
来源:juejin.cn/post/7331607384932876326
收起阅读 »

【实现环信 SDK登陆跳转至Harmony 底部导航栏】

1.在 Index.ets 的 aboutToApper 方法中 实现环信SDK 初始化代码块(可直接 copy):let optionss = new ChatOptions("输入管理后台注册的环信 APPkey");//管理后台网址:https://co...
继续阅读 »

1.在 Index.ets 的 aboutToApper 方法中 实现环信SDK 初始化


代码块(可直接 copy):

let optionss = new ChatOptions("输入管理后台注册的环信 APPkey");

//管理后台网址:https://console.easemob.com/user/login
//环信初始化
ChatClient.getInstance().init(getContext(),optionss)

2.登录环信SDK并跳转至少导航栏页面


代码块://userID 自定义 String类型参数 =环信id

  //userPasswrod 自定义 String类型参数  登录的密码
ChatClient.getInstance().login(this.userID,this.userPassword).then(()=>{
//登录成功后跳转到导航栏的指定类中
router.replaceUrl({url:'pages/Pages'})


}).catch((e:ChatError)=>{
//登录失败则提示错误信息
console.log("ccc== "+e.errorCode,"")

})

3.在 Harmony 平台下自定义容器


代码块:

@State currentIndex: number = 0
//定义TabsController控件
private Controller: TabsController = new TabsController()
//自定义布局 该布局定义时 可以卸载 页面的 build 方法外面
@Builder
TabBuilder(title: string, index: number, selectedImage: Resource, normalImage: Resource) {
//定义一个容器
Column() {
//容器的图片
Image(this.currentIndex === index ? selectedImage : normalImage)
.height(30)
.width(30)
//容器的文本
Text(title)
.margin({ top: 5 })
.fontSize(10)
.fontColor(this.currentIndex === index ? $r('app.color.start_window_background') :
$r('app.color.start_window_background'))
}
//居中
.justifyContent(FlexAlign.Center)
//容器布局的宽占满
.width('100%')
//容器布局的高尺寸 25
.height(25)
//点击事件改变数字
.onClick(() => {
this.currentIndex = index
this.Controller.changeIndex(this.currentIndex)
})
}


4.build 中通过 Tabs 组件实现底部导航栏


代码块:

build() {
//必写
Tabs({
barPosition: BarPosition.End,
controller: this.Controller
}) {
TabContent() {
//首页
}
.padding({ left: 12, right: 12 })
//背景颜色
.backgroundColor($r('app.color.start_window_background'))
//自定义布局的 名字 index位置,点击后和点击其他需要展示的图片
.tabBar(this.TabBuilder('首页', 0, $r('首次展示的图片'), $r('切到其他页面后展示的图片')))

TabContent() {
//会话列表页
}
//内边距
.padding({ left: 12, right: 12 })
//背景颜色
.backgroundColor($r('app.color.mainPage_backgroundColor'))
//自定义布局的 名字 index位置,点击后和点击其他需要展示的图片
.tabBar(this.TabBuilder('会话', 1, $r('被点击后展示的图片'), $r('被动展示图片')))
TabContent() {
//我的详情页
}.padding({ left: 12, right: 12 })
//背景颜色
.backgroundColor($r('定义背景颜色'))
// //自定义布局的 名字 index位置,点击后和点击其他需要展示的图片
.tabBar(this.TabBuilder('我的详情', 1, $r('被点击后展示的图片'), $r('被动展示图片')))
}
.width('100%')
.backgroundColor(Color.White)
.barHeight(56)
.barMode(BarMode.Fixed)
.onChange((index: number) => {
this.currentIndex = index
})

}


收起阅读 »

拿去吧你!Flutter 仿抖音个人主页下拉拖拽效果

引言 最近产品经理看到抖音的个人主页下拉效果很不错,让我也实现一个,如果是native还好办,开源成熟的库一大堆,可我是Flutter呐🤣,业内成熟可用的库非常有限,最终跟产品经理batte失败后,没办法只能参考native代码硬肝出来。 效果图 整体构思 ...
继续阅读 »

引言


最近产品经理看到抖音的个人主页下拉效果很不错,让我也实现一个,如果是native还好办,开源成熟的库一大堆,可我是Flutter呐🤣,业内成熟可用的库非常有限,最终跟产品经理batte失败后,没办法只能参考native代码硬肝出来。


效果图


掘金素材.gif


整体构思


实现拖拽滑动功能,关键在于对手势事件的识别。在 Flutter 中,可使用Listener来监听触摸事件,如下所示:


Listener(
onPointerDown: (result) {

},
onPointerMove: (result) {

},
onPointerUp: (_) {

}

在手指滑动的过程中不断的刷新背景图高度是不是就可以实现图片的拉伸效果呢?我们这里图片加载库使用CachedNetworkImage,高度在156的基础上动态识别手指的滑动距离extraPicHeight


CachedNetworkImage(
width: double.infinity,
height: 156 + extraPicHeight,
imageUrl: backgroundUrl,
fit: fitType,
)

识别到手指滑动就不断的刷新拉伸高度extraPicHeight,flutter setState 内部已经做了优化,不用担心性能问题,实际效果体验很不错。


setState(() {
extraPicHeight;
});

经过实验思路是没有问题,那么监听哪些事件,extraPicHeight到底怎么计算,有什么边界值还考虑到呢?我们从手势的顺序开始梳理一下。


首先按压屏幕会识别到触碰屏幕起点,也就是initialDx initialDy,对于下拉拖拽我们关心更多的是纵向坐标result.position.dy


onPointerDown: (result) {
initialDy = result.position.dy;
initialDx = result.position.dx;
},

当手指在屏幕滑动会触发onPointerMovew,result.position.dy代表的就是手势滑动的位置


onPointerMove: (result) {
//手指的移动时
// updatePicHeight(result.position.dy); //自定义方法,图片的放大由它完成。
},

这边处理逻辑比较复杂,我们先抽成函数updatePicHeight


updatePicHeight(changed) {
//。。。已省略不重要细节代码
extraPicHeight += changed - prev_dy; //新的一个y值减去前一次的y值然后累加,作为加载到图片上的高度。
debugPrint('extraPicHeight updatePicHeight : $extraPicHeight');
//这里是为了限制我们的最大拉伸效果
if (extraPicHeight > 300) {
extraPicHeight = 300;
}
if (extraPicHeight > 0) {
setState(() {
prev_dy = changed;
});
}
}

这里简化了很多细节逻辑,核心目的就是要不断的累加我们的拖动距离来计算extraPicHeight高度,这里的changed是我们手指的y坐标,滑动的距离需要减去上次滑动的回调y,所以我们必须声明一个过去y坐标的变量也就是prev_dy,通过通过 changed - prev_dy就可以得出真正滑动的距离,然后我们不断累加 extraPicHeight += changed - prev_dy就是图片的拉伸距离。


手指下拉以后图片确实拉伸了,但是松开手后发现回不去了🤣因为我们还需要处理图回去的问题,既然可以通过setState把图片高度拉高,我们也可以通过setState把图片高度刷回去,核心要思考的是如何平滑的让图片自己缩回去呢?有经验的你一定想到动画了。


flutter这里的动画库是TweenTween可以通过addListener监听距离的回调,当距离变化不断刷新图片高度


anim = Tween(begin: extraPicHeight, end: 0.0).animate(animationController)
..addListener(() {
setState(() {
extraPicHeight = anim.value;
fitType = BoxFit.cover;
});
});
prev_dy = 0; //同样归零

动画的效果最终由控制器animationController来决定,这里给了一个300ms的时间还不错,可以根据自己业务扩展


animationController = AnimationController(
vsync: this, duration: const Duration(milliseconds: 300));

所有在手抬起的时候执行我们的动画runAnimate函数即可


onPointerUp: (_) {
//当手指抬起离开屏幕时
if (isVerticalMove) {
if (extraPicHeight < 0) {
extraPicHeight = 0;
prev_dy = 0;
return;
}
debugPrint('extraPicHeight onPointerUp : $extraPicHeight');
runAnimate(); //动画执行
animationController.forward(from: 0); //重置动画
}
},

整体的技术方案履完了,之后就是细节问题了


问题1:横行稍微有倾角的滑动也会导致页面拖拽,比如侧滑返回上一页面


这是由于手指滑动的角度没有限制, 这里我们计算一下滑动倾角,超过45度无效,角度计算通过x,y坐标计算tan函数即可


onPointerMove: (result) {
double deltaY = result.position.dy - initialDy;
double deltaX = result.position.dx - initialDx;
double angle =
(deltaY == 0) ? 90 : atan(deltaX.abs() / deltaY.abs()) * 180 / pi;
debugPrint('onPointerMove angle : $angle');
if (angle < 45) {
isVerticalMove = true; // It's a valid vertical movement
updatePicHeight(result
.position.dy); // Custom method to handle vertical movement
} else {
isVerticalMove =
false; // It's not a valid vertical movement, ignore it
}
}

问题2:图片高度变了,为啥没有拉伸啊!


图片拉伸取决于你图片库的加载配置,以flutter举例,我们的图片库是CachedNetworkImage


 CachedNetworkImage(
width: double.infinity,
height: 156 + extraPicHeight,
imageUrl: backgroundUrl,
fit: fitType,
)

加载效果取决于fit,默认不变形我们使用cover,拉伸时使用fitHeight或者fill


updatePicHeight(changed) {
if (prev_dy == 0) {
//如果是手指第一次点下时,我们不希望图片大小就直接发生变化,所以进行一个判定。
prev_dy = changed;
}
if (extraPicHeight > 0) {
//当我们加载到图片上的高度大于某个值的时候,改变图片的填充方式,让它由以宽度填充变为以高度填充,从而实现了图片视角上的放大。
fitType = BoxFit.fitHeight;
} else {
fitType = BoxFit.cover;
}
extraPicHeight += changed - prev_dy; //新的一个y值减去前一次的y值然后累加,作为加载到图片上的高度。
debugPrint('extraPicHeight updatePicHeight : $extraPicHeight');
if (extraPicHeight > 300) {
extraPicHeight = 300;
}
if (extraPicHeight > 0) {
setState(() {
prev_dy = changed;
fitType = fitType;
});
}
}

最后看下组件如何布局


 CustomScrollView(
physics: const NeverScrollableScrollPhysics(),
slivers: <Widget>[
SliverToBoxAdapter(
child: buildTopWidget(),
),
SliverToBoxAdapter(
child: Column(
children: contents,
),
)
]
),
)

整个列表使用CustomScrollView,因为在flutter上用他才能实现这种变化效果,未来还可以扩展顶部导航栏的变化需求。buildTopWidget就是我们头部组件,包括内部的背景图,但是整个组件和背景图的高度都是依赖extraPicHeight变化的,contents是我们的内容,当头部组件挤压,会正常跟随滑动到底部。


全局变量依赖以下参数就够了,核心要注意的就是边界值问题,什么时候把状态值重置问题。


//初始坐标
double initialDy = 0;
double initialDx = 0;
double extraPicHeight = 0; //初始化要加载到图片上的高度
late double prev_dy; //前一次滑动y
//是否是垂直滑动
bool isVerticalMove = false;
//动画器
late AnimationController animationController;
late Animation<double> anim;


技术语言不是我分享的核心,解决这个需求的技术思维路线是我们大家可以借鉴学习的。



如果你有任何疑问可以通过掘金联系我,如果文章对你有所启发,希望能得到你的点赞、关注和收藏,这是我持续写作的最大动力。Thanks~


作者:小虎牙007
来源:juejin.cn/post/7419248277382021135
收起阅读 »

一次接手远古Android项目终于运行起来了

我也没做过安卓开发,2020年外包开发的app在客户新手机上安装不上,搞呗。apk安装报错此应用与最新版Android不兼容,试了同事的Android 14 确实同样报错 网上查到解决方案。 http://www.duidaima.com/Gr0up/Top...
继续阅读 »

我也没做过安卓开发,2020年外包开发的app在客户新手机上安装不上,搞呗。apk安装报错此应用与最新版Android不兼容,试了同事的Android 14 确实同样报错


image.png


网上查到解决方案。


http://www.duidaima.com/Gr0up/Topic…


按照第一点增加64位指令集后,重新打包apk解决问题了


1、【成功并上线】在build.gradel文件的ndk部分添加arm64-v8a的指令集


2、【未实验】targetSdkVersion最少为29就能在安卓14上避免异常弹框


安装Android开发环境过程很曲折,重点是要安装项目需要的开发环境版本,不然各种错误失败


第一步确认项目开发环境版本


最开始下载Android Studio 2024最新版,2021版等等,JDK21最新版,JDK17都失败。


得出结论:



  • 确认Android Studio 版本要看根目录build.gradle中gradle版本,再去官网下载对应版本号

  • 确认JDK版本要看另一个build.gradle中targetCompatibility的版本号


image.png


JDK 版本


http://www.oracle.com/java/techno…


根据build.gradle中看出要JDK8,而且jdk8安装后默认有jre目录,不像jdk21要手动生成jre目录


注意上面网站用Chrome打开登录Oracle后报错Cookie太长,改为360极速版正常下载


image.png


登录或注册oracle账号才能下载


image.png


配置环境变量


新建JAVA_HOME    C:\Program Files\Java\jdk-1.8


修改PATH    %JAVA_HOME%\bin       ;%JAVA_HOME%\jre\bin


网上说前面第二个前面一定要带分号


image.png


image.png


测试正常


image.png


额外补充 JDK17 和 JDK21 生成 jre目录


上面用的JDK8在安装好后默认是生成jre目录的,但是如果JDK17和JDK21没有默认生成jre目录,需要手动生成


必须管理员权限打开CMD


image.png


进入到jdk-21目录执行命令就可以生成jre文件夹了


bin\jlink.exe --module-path jmods --add-modules java.desktop --output jre

image.png


image.png


Android Studio 版本


developer.android.google.cn/studio/arch…


根据build.gradle中gradle:4.1.1看出要下载Android Studio 4.1.1 , 其他新版本项目有各种报错


image.png


再去官网下载对应版本


image.png


第二步 Android Studio 安装过程中问题解决


正常安装Android Studio


image.png


初始化设置sdk代理


启动后报错 Unable to access Android SDK add-on list,点 Setup Proxy


修改Automatic proxy configuration URL设置为:mirrors.neusoft.edu.cn

因为后面都是google的域名,不设置sdk代理多半是下载不了的


image.png


image.png


可能设置Proxy再报错同样Unable这个错,就点 Cancel 跳过,后面都点 Next 直达 Finiash


image.png


安装sdk版本


第一次进入启动页面,在Configure选择SDK Manager,我把API Level的28,29,30都勾选上,因为我看老项目代码里面targetSdkVersion 28,而我找到的解决方法说最少29,干脆我就勾上这三个


image.png


image.png


后面点Accept,就直接下载到Finish呗


image.png


后面遇到报错 Installed Build Tools revision 35.0.0 is corrupted. Remove and install again using the SDK Manager.


那打开工具条 File -> Settings 找到 Android SDK 项,在 Android SDK Location 点 Edit 重新点Next安装后报错消失


再把 build.gradle 中35都改成28


image.png


具体看Build Tools有哪些版本,可以查看SDK安装目录build-tools有哪些,改成有的版本即可


image.png


安装 avd 模拟器


Android项目要运行是要模拟器的,avd就是官方调试模拟器,也可以用第三方的逍遥模拟器,夜游模拟器等


image.png


进去后随便选个 Pixel 4 XL,再进去我老项目是API Level 28的,就需要点 Download 下载


image.png


image.png


安装 HAXM


运行项目要求安装 HAXM,默认安装即可


image.png


image.png


第三步运行老项目解决问题


打开项目


SDK目录与原项目不匹配,点OK自动更新,估计原项目是苹果电脑开发,我这是windows环境


image.png


设置Gradle阿里云代理


找到 gradle-wrapper.properties 文件修改 distributionUrl 为国内代理,国外域名下载gradle超时失败了


替换域名后点 Sync Now


distributionUrl=https\://mirrors.aliyun.com/macports/distfiles/gradle/gradle-6.5-all.zip

image.png


image.png


image.png


image.png


如果 sync now点了出现proxy settings弹窗,那在第一个HOST name填写 mirrors.neusoft.edu.cn


image.png


设置 maven 阿里云代理


在根目录build.gradle的allproject下面增加


maven { url 'https://maven.aliyun.com/repository/jcenter' }
maven { url 'https://maven.aliyun.com/repository/google' }
maven { url 'https://maven.aliyun.com/repository/gradle-plugin' }
maven { url 'https://maven.aliyun.com/repository/public' }

image.png


设置 64位指令集


在 app/build.gradle的ndk下增加 arm64-v8a


image.png


运行项目,如果遇到问题可以点工具栏 build -> clean project 再 rebuild project


image.png


第四步签名打包apk


工具栏 build -> Generate Signed Bundle / Apk ...


image.png


选 APK


image.png


选择签名文件输入 password这三个输入框,如果没有就create new新建


image.png


选择打包apk存放目录,Finish就完成了


image.png


右下角显示成功


image.png


作者:一个不会重复的id
来源:juejin.cn/post/7410711559964229682
收起阅读 »

Flutter GPU 是什么?为什么它对 Flutter 有跨时代的意义?

Flutter 3.24 版本引入了 Flutter GPU 概念的新底层图形 API flutter_gpu ,还有 flutter_scene 的 3D 渲染支持库,它们目前都是预览阶段,只能在 main channel 上体验,并且依赖 Impel...
继续阅读 »

Flutter 3.24 版本引入了 Flutter GPU 概念的新底层图形 API flutter_gpu ,还有 flutter_scene 的 3D 渲染支持库,它们目前都是预览阶段,只能在 main channel 上体验,并且依赖 Impeller 的实现。



Flutter GPU 是 Flutter 内置的底层图形 API,它可以通过编写 Dart 代码和 GLSL 着色器在 Flutter 中构建和集成自定义渲染器,而无需 Native 平台代码。


目前 Flutter GPU 处于早期预览阶段并只提供基本的光栅化 API,但随着 API 接近稳定,会继续添加和完善更多功能。



详细说,Flutter GPU 是 Impeller 对于 HAL 的一层很轻的包装,并搭配了关于着色器和管道编排的自动化能力,也通过 Flutter GPU 就可以使用 Dart 直接构建自定义渲染器。


Flutter GPU 和 Impeller 一样,它的着色器也是使用 impellerc 提前编译,所以 Flutter GPU 也只支持 Impeller 的平台上可用。



Impeller 的 HAL 和 Flutter GPU 都没打算成为类似 WebGPU 这样的正式标准,相反,Flutter GPU 主要是由 Flutter 社区开发和发展,专职为了 Flutter 服务,所以不需要考虑「公有化」的兼容问题。



在 Flutter GPU 上,可直接从 Dart 与 Impeller 的 HAL 对话,甚至 Impeller Scene API(3D)也将作为重写的一部分出现。



说人话就是,可以用 Dart 通过 Flutter GPU 直接构建自定义渲染效果,未来直接支持 3D



可能有的人对于 Impeller 的整体结构和 HAL 还很模式无法理解,那么这里我们简单过一下:



  • 在 Framework 上层,我们知道 Widget -> Element -> RenderObject -> Layer 这样的过程,而后其实流程就来到了 Flutter 自定义抽象的 DisplayList

  • DisplayList 帮助 Flutter 在 Engine 做了接耦,从而让 Flutter 可以在 skia 和 Impeller 之间进行的切换

  • 之后 Impeller 架构的顶层是 Aiks,这一层主要作为绘图操作的高级接口,它接受来自 Flutter 框架的命令,例如绘制路径或图像,并将这些命令转换为一组更精细的 “Entities”,然后转给下一层。

  • Entities Framework,它是 Impeller 架构的核心组件,当 Aiks 处理完命令时生成 Entities 后,每一个 Entity 其实就是渲染指令的独立单元,其中包含绘制特定元素的所有必要信息(编码位置、旋转、缩放、content object),此时还不能直接作用于 GPU

  • HAL(Hardware Abstraction Layer) 则为底层图形硬件提供了统一的接口,抽象了不同图形 API 的细节,该层确保了 Impeller 的跨平台能力,它将高级渲染命令转换为低级 GPU 指令,充当 Impeller 渲染逻辑和设备图形硬件之间的桥梁。


所以 HAL 它包装了各种图形 API,以提供通用的设备作业调度接口、一致的资源管理语义和统一的着色器创作体验,而对于 Impeller , Entities (2D renderer) 和 Scene (3D renderer) 都是直接通过 HAL 对接,甚至可以认为,Impeller 的 HAL 抽象并统一了 Metal 和 Vulkan 的常见用法和相似结构。



Unity 现在也有在 C# 直接向用户公开其 HAL 版本,称为 "Scriptable Render Pipeline" ,并提供了两个基于该 API 构建的默认渲染器 "Universal RP" / "High Definition RP" 用于服务不同的场景,所以 Unity 开发可以从使用这些渲染器去进行修改或扩展一些特定渲染需求。





而在 Flutter 的设计上,Flutter GPU 会作为 Flutter SDK 的一部分,并以 flutter_gpu 的 Dart 包的形式提供使用。


当然,Flutter GPU 由 Impeller 支持,但重要的是要记住它不是 Impeller ,Impeller 的 HAL 是私有内部代码与 Flutter GPU 的要求非常不同, Impeller 的私有 HAL 和 Flutter GPU 的公共 API 设计之间是存在一定差异化实现,而前面的流程,如 Scene (3D renderer) ,也可以被调整为基于 Flutter GPU 的全新模式实现。


而通过 Flutter GPU,如曾经的 Scene (3D renderer) 支持,也可以被调整为基于 Flutter GPU 的全新模式实现,因为 Flutter GPU 的 API 允许完全控制渲染通道附件、顶点阶段和数据上传到 GPU。这种灵活性对于创建复杂的渲染解决方案(从 2D 角色动画到复杂的 3D 场景)至关重要。



Flutter GPU 支持的自定义 2D 渲染器的一个很好的用例:依赖于骨骼网格变形的 2D 角色动画格式。


Spine 2D 就是一个很好的例子,骨骼网格解决方案通常具有动画剪辑,可以按层次结构操纵骨骼的平移、旋转和缩放属性,并且每个顶点都有几个相关的“bone weights”,这些权重决定了哪些骨骼应该影响顶点以及影响程度如何。



使用像 drawVertices 这样的 Canvas 解决方案,需要在 CPU 上对每个顶点应用骨骼权重变换,而 使用 Flutter GPU,骨骼变换可以用统一数组或纹理采样器的形式发送到顶点着色器,从而允许根据骨架状态和每个顶点的 “bone weights” 在 GPU 上并行计算每个顶点的最终位置。


使用 Flutter GPU


首先你需要在最新的 main channel 分支,然后通过 flutter pub add flutter_gpu --sdk=flutter 将 flutter_gpu SDK 包添加到你的 pubspec。


为了使用 Flutter GPU 渲染内容,你会需要编写一些 GLSL 着色器,Flutter GPU 的着色器与 Flutter 的 fragment shader 功能所使用的着色器具有不同的语义,特别是在统一绑定方面,还需要定义一个顶点(vertex)着色器来与 fragment shader 一起使用,然后配合 gpu.ShaderLibrary 等 API 就可以直接实现 Flutter GPU 渲染。


当然,本篇不会介绍详细的 API 使用 ,这里只是单纯做一个简单的介绍,目前 Flutter GPU 进行光栅化的简单流程如下:



  • 获取 GPUContext。

  • GpuContext.createCommandBuffer 创建一个 CommandBuffer

  • CommandBuffer.createRenderPass 创建一个 RenderPass

  • 使用各种方法设置状态/管道并绑定资源 RenderPass

  • 附加绘图命令 RenderPass.draw

  • CommandBuffer 使用 CommandBuffer.submit (异步)提交绘制,所有 RenderPass 会按照其创建顺序进行编码


·····
///导入 flutter_gpu
import 'package:flutter_gpu/gpu.dart' as gpu;

ByteData float32(List<double> values) {
return Float32List.fromList(values).buffer.asByteData();
}

ByteData float32Mat(Matrix4 matrix) {
return Float32List.fromList(matrix.storage).buffer.asByteData();
}

class TrianglePainter extends CustomPainter {
TrianglePainter(this.time, this.seedX, this.seedY);

double time;
double seedX;
double seedY;

@override
void paint(Canvas canvas, Size size) {
/// Allocate a new renderable texture.
final gpu.Texture? renderTexture = gpu.gpuContext.createTexture(
gpu.StorageMode.devicePrivate, 300, 300,
enableRenderTargetUsage: true,
enableShaderReadUsage: true,
coordinateSystem: gpu.TextureCoordinateSystem.renderToTexture);
if (renderTexture == null) {
return;
}

final gpu.Texture? depthTexture = gpu.gpuContext.createTexture(
gpu.StorageMode.deviceTransient, 300, 300,
format: gpu.gpuContext.defaultDepthStencilFormat,
enableRenderTargetUsage: true,
coordinateSystem: gpu.TextureCoordinateSystem.renderToTexture);
if (depthTexture == null) {
return;
}

/// Create the command buffer. This will be used to submit all encoded
/// commands at the end.
final commandBuffer = gpu.gpuContext.createCommandBuffer();

/// Define a render target. This is just a collection of attachments that a
/// RenderPass will write to.
final renderTarget = gpu.RenderTarget.singleColor(
gpu.ColorAttachment(texture: renderTexture),
depthStencilAttachment: gpu.DepthStencilAttachment(texture: depthTexture),
);

/// Add a render pass encoder to the command buffer so that we can start
/// encoding commands.
final encoder = commandBuffer.createRenderPass(renderTarget);

/// Load a shader bundle asset.
final library = gpu.ShaderLibrary.fromAsset('assets/TestLibrary.shaderbundle')!;

/// Create a RenderPipeline using shaders from the asset.
final vertex = library['UnlitVertex']!;
final fragment = library['UnlitFragment']!;
final pipeline = gpu.gpuContext.createRenderPipeline(vertex, fragment);

encoder.bindPipeline(pipeline);

/// (Optional) Configure blending for the first color attachment.
encoder.setColorBlendEnable(true);
encoder.setColorBlendEquation(gpu.ColorBlendEquation(
colorBlendOperation: gpu.BlendOperation.add,
sourceColorBlendFactor: gpu.BlendFactor.one,
destinationColorBlendFactor: gpu.BlendFactor.oneMinusSourceAlpha,
alphaBlendOperation: gpu.BlendOperation.add,
sourceAlphaBlendFactor: gpu.BlendFactor.one,
destinationAlphaBlendFactor: gpu.BlendFactor.oneMinusSourceAlpha));

/// Append quick geometry and uniforms to a host buffer that will be
/// automatically uploaded to the GPU later on.
final transients = gpu.HostBuffer();
final vertices = transients.emplace(float32(<double>[
-0.5, -0.5, //
0, 0.5, //
0.5, -0.5, //
]));
final color = transients.emplace(float32(<double>[0, 1, 0, 1])); // rgba
final mvp = transients.emplace(float32Mat(Matrix4(
1, 0, 0, 0, //
0, 1, 0, 0, //
0, 0, 1, 0, //
0, 0, 0.5, 1, //
) *
Matrix4.rotationX(time) *
Matrix4.rotationY(time * seedX) *
Matrix4.rotationZ(time * seedY)));

/// Bind the vertex data. In this case, we won't bother binding an index
/// buffer.
encoder.bindVertexBuffer(vertices, 3);

/// Bind the host buffer data we just created to the vertex shader's uniform
/// slots. Although the locations are specified in the shader and are
/// predictable, we can optionally fetch the uniform slots by name for
/// convenience.
final mvpSlot = pipeline.vertexShader.getUniformSlot('mvp')!;
final colorSlot = pipeline.vertexShader.getUniformSlot('color')!;
encoder.bindUniform(mvpSlot, mvp);
encoder.bindUniform(colorSlot, color);

/// And finally, we append a draw call.
encoder.draw();

/// Submit all of the previously encoded passes. Passes are encoded in the
/// same order they were created in.
commandBuffer.submit();

/// Wrap the Flutter GPU texture as a ui.Image and draw it like normal!
final image = renderTexture.asImage();

canvas.drawImage(image, Offset(-renderTexture.width / 2, 0), Paint());
}

@override
bool shouldRepaint(covariant CustomPainter oldDelegate) {
return true;
}
}

class TrianglePage extends StatefulWidget {
const TrianglePage({super.key});

@override
State<TrianglePage> createState() => _TrianglePageState();
}

class _TrianglePageState extends State<TrianglePage> {
Ticker? tick;
double time = 0;
double deltaSeconds = 0;
double seedX = -0.512511498387847167;
double seedY = 0.521295573094847167;

@override
void initState() {
tick = Ticker(
(elapsed) {
setState(() {
double previousTime = time;
time = elapsed.inMilliseconds / 1000.0;
deltaSeconds = previousTime > 0 ? time - previousTime : 0;
});
},
);
tick!.start();
super.initState();
}

@override
Widget build(BuildContext context) {
return Column(
children: <Widget>[
Slider(
value: seedX,
max: 1,
min: -1,
onChanged: (value) => {setState(() => seedX = value)}),
Slider(
value: seedY,
max: 1,
min: -1,
onChanged: (value) => {setState(() => seedY = value)}),
CustomPaint(
painter: TrianglePainter(time, seedX, seedY),
),
],
);
}
}

GpuContext 是分配所有 GPU 资源并调度 GPU 的存在,而 GpuContext 仅有启用 Impeller 时才能访问。


DeviceBuffer 和 Texture 就是 GPU 拥有的资源,可以通过 GPUContext 创建获取,如 createDeviceBuffercreateTexture



  • DeviceBuffer 简单理解就是在 GPU 上分配的简单字节串,主要用于存储几何数据(索引和顶点属性)以及统一数据

  • Texture 是一个特殊的设备缓冲区


CommandBuffer 用于对 GPU 上的异步执行进行排队和调度工作。


RenderPass 是 GPU 上渲染工作的顶层单元。


RenderPipeline 提供增量更改绘制所有状态以及附加绘制调用的方法如 RenderPass.draw()


可以想象,通过 Flutter GPU,Flutter 开发者可以更简单地对 GPU 进行更精细的控制,通过与 HAL 直接通信,创建 GPU 资源并记录 GPU 命令,从而最大限度的发挥 Flutter 的渲染能力。


另外,对于 3D 支持的 Flutter Scene , 可以通过使用 native-assets 来设置 Flutter Scene 的 3D 模型自动导入,通过导入编译模型 .model 之后,就可以通过 Dart 实现一些 3D 的渲染。


import 'dart:math';

import 'package:flutter/material.dart';
import 'package:flutter_scene/camera.dart';
import 'package:flutter_scene/node.dart';
import 'package:flutter_scene/scene.dart';
import 'package:vector_math/vector_math.dart';

void main() {
runApp(const MyApp());
}

class MyApp extends StatefulWidget {
const MyApp({super.key});

@override
MyAppState createState() => MyAppState();
}

class MyAppState extends State<MyApp> with SingleTickerProviderStateMixin {
double elapsedSeconds = 0;
Scene scene = Scene();

@override
void initState() {
createTicker((elapsed) {
setState(() {
elapsedSeconds = elapsed.inMilliseconds.toDouble() / 1000;
});
}).start();

Node.fromAsset('build/models/DamagedHelmet.model').then((model) {
model.name = 'Helmet';
scene.add(model);
});

super.initState();
}

@override
Widget build(BuildContext context) {
final painter = ScenePainter(
scene: scene,
camera: PerspectiveCamera(
position: Vector3(sin(elapsedSeconds) * 3, 2, cos(elapsedSeconds) * 3),
target: Vector3(0, 0, 0),
),
);

return MaterialApp(
title: 'My 3D app',
home: CustomPaint(painter: painter),
);
}
}

class ScenePainter extends CustomPainter {
ScenePainter({required this.scene, required this.camera});
Scene scene;
Camera camera;

@override
void paint(Canvas canvas, Size size) {
scene.render(camera, canvas, viewport: Offset.zero & size);
}

@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}



目前 Flutter GPU 和 Flutter Scene 的支持还十分有限,但是借助 Impeller ,Flutter 开启了新的可能,可以说是,Flutter 团队完全掌控了渲染堆栈,在除了自定义更丰富的 2D 场景之外,也为 Flutter 开启了 3D 游戏的可能,2023 年 Flutter Forward 大会的承诺,目前正在被落地实现




详细 API 使用例子可以参看 :medium.com/flutter/get…



如果你对 Flutter Impeller 和其着色器感兴趣,也可以看:



作者:恋猫de小郭
来源:juejin.cn/post/7399985723673821193
收起阅读 »

uni-app初体验,如何实现一个外呼APP

起因 2024年3月31日,我被公司裁员了。 2024年4月1日,果断踏上了回家的路,决定先休息一个星期。晚上回到了郑州,先跟一起被裁的同事在郑州小聚一下,聊聊后面的打算。第二天下午回家。 2024年4月8日,知道现在的大环境不好,不敢错过“金三银四”,赶忙回...
继续阅读 »

起因


2024年3月31日,我被公司裁员了。


2024年4月1日,果断踏上了回家的路,决定先休息一个星期。晚上回到了郑州,先跟一起被裁的同事在郑州小聚一下,聊聊后面的打算。第二天下午回家。


2024年4月8日,知道现在的大环境不好,不敢错过“金三银四”,赶忙回上海开始找工作。结果环境比预想的还要差啊,以前简历放开就有人找,现在每天投个几十封都是石沉大海。。。


2024年4月15日,有个好朋友找我,想让我给他们公司开发一个“拨号APP”(主要原因其实是这个好哥们想让我多个赚钱门路😌),主要的功能就是在他们的系统上点击一个“拨号”按钮,然后员工的工作手机上就自动拨打这个号码。


可行性分析


涉及到的修改:



  • 系统前后端

  • 拨号功能的APP


拿到这个需求之后,我并没有直接拒绝或者同意,而是先让他把他公司那边的的源代码发我了一份,大致看了一下使用的框架,然后找一些后端的朋友看有没人有人一起接这个单子;而我自己则是要先看下能否实现APP的功能(因为我以前从来没有做过APP!!!)。


我们各自看过自己的东西,然后又沟通了一番简单的实现过程后达成了一致,搞!


因为我这边之前的技术栈一直是VUE,所以决定使用uni-app实现,主要还是因为它的上手难度会低很多。


第一版


需求分析


虽说主体的功能是拨号,但其实是隐含很多辅助性需求的,比如拨号日志、通时通次统计、通话录音、录音上传、后台运行,另外除了这些外还有额外的例如权限校验、权限引导、获取手机号、获取拨号状态等功能需要实现。


但是第一次预算给的并不高,要把这些全部实现显然不可能。因此只能简化实现功能实现。



  • 拨号APP

    • 权限校验

      • 实现部分(拨号、录音、文件读写)



    • ❌权限引导

    • 查询当前手机号

      • 直接使用input表单,由用户输入



    • 查询当前手机号的拨号任务

      • 因为后端没有socket,使用setTimeout模拟轮询实现。



    • 拨号、录音、监测拨号状态

      • 根据官网API和一些安卓原生实现



    • 更新任务状态

      • 告诉后端拨号完成



    • ❌通话录音上传

    • ❌通话日志上传

    • ❌本地通时通次统计

    • 程序运行日志

    • 其他

      • 增加开始工作、开启录音的状态切换

      • 兼容性,只兼容安卓手机即可






基础设计


一个input框来输入用户手机号,一个开始工作的switch,一个开启录音的切换。用户输入手机号,点击开始工作后开启轮询,轮询到拨号任务后就拨号同时录音,同时监听拨号状态,当挂断后结束录音、更新任务状态,并开启新一轮的轮询。


开干


虽然本人从未开发过APP,但本着撸起袖子就是干的原则,直接打开了uni-app的官网就准备开怼。


1、下载 HbuilderX。


2、新建项目,直接选择了默认模板。


3、清空 Hello页面,修改文件名,配置路由。


4、在vue文件里写主要的功能实现,并增加 Http.jsRecord.jsPhoneCall.jsPower.js来实现对应的模块功能。


⚠️关于测试和打包


运行测试


在 HbuilderX 中点击“运行-运行到手机或模拟器-运行到Android APP基座”会打开一个界面,让你选择运行到那个设备。这是你有两种选择:



  • 把你手机通过USB与电脑连接,然后刷新列表就可以直接运行了。

    • 很遗憾,可能是苹果电脑与安卓手机的原因,插上后检测不出设备😭。。。



  • 安装Android Studio,然后通过运行内置的模拟器来供代码运行测试。

    • 这种很麻烦,要下载很久,且感觉测试效果并不好,最好还是用windows电脑连接手机的方法测试。




关于自定义基座和标准基座的差别,如果你没有买插件的话,直接使用基准插座就好。如果你要使用自定义基座,就首先要点击上图中的制作自定义基座,然后再切换到自定义基座执行。


但是不知道为什么,我这里一直显示安装自定义基座失败。。。


打包测试


除了以上运行测试的方法外,你还有一种更粗暴的测试方法,那就是打包成APP直接在手机上安装测试。


点击“发行-原生APP 云打包”,会生成一个APK文件,然后就可以发送到手机上安装测试。不过每天打包的次数有限,超过次数需要购买额外的打包服务或者等第二天打包。


我最终就是这样搞得,真的我哭死,我可能就是盲调的命,好多项目都是盲调的。


另外,在打包之前我们首先要配置manifest.json,里面包含了APP的很多信息。比较重要的一个是AppId,一个是App权限配置。参考uni-app 权限配置Android官方权限常量文档。以下是拨号所需的一些权限:



// 录制音频
<uses-permission android:name="android.permission.RECORD_AUDIO" />
// 修改音频设置
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />

// 照相机
<uses-permission android:name="android.permission.CAMERA" />
// 写入外部存储
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
// 读取外部存储
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />

// 读取电话号码
<uses-permission android:name="android.permission.READ_PHONE_NUMBERS" />
// 拨打电话
<uses-permission android:name="android.permission.CALL_PHONE" />
// 呼叫特权
<uses-permission android:name="android.permission.CALL_PRIVILEGED" />
// 通话状态
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
// 读取拨号日志
<uses-permission android:name="android.permission.READ_CALL_LOG" />
// 写入拨号日志
<uses-permission android:name="android.permission.WRITE_CALL_LOG" />
// 读取联系人
<uses-permission android:name="android.permission.READ_CONTACTS" />
// 写入联系人
<uses-permission android:name="android.permission.WRITE_CONTACTS" />
// 读取SMS?
<uses-permission android:name="android.permission.READ_SMS" />

// 写入设置
<uses-permission android:name="android.permission.WRITE_SETTINGS" />
// 唤醒锁定?
<uses-permission android:name="android.permission.WAKE_LOCK" />
// 系统告警窗口?
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
// 接受完整的引导?
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />

⚠️权限配置这个搞了很长时间,即便现在有些权限还是不太清楚,也不知道是不是有哪些权限没有配置上。。。


⚠️权限校验


1、安卓 1


好像除了这样的写法还可以写"scope.record"或者permission.CALL_PHONE


permision.requestAndroidPermission("android.permission.CALL_PHONE").then(res => {
// 1 获得权限 2 本次拒绝 -1 永久拒绝
});

2、安卓 2


plus.android.requestPermissions(["android.permission.CALL_PHONE"], e => {
// e.granted 获得权限
// e.deniedPresent 本次拒绝
// e.deniedAlways 永久拒绝
});

3、uni-app


这个我没测试,AI给的,我没有用这种方法。有一说一,百度AI做的不咋地。


// 检查权限
uni.hasPermission({
permission: 'makePhoneCall',
success() {
console.log('已经获得拨号权限');
},
fail() {
// 示例:请求权限
uni.authorize({
scope: 'scope.makePhoneCall',
success() {
console.log('已经获得授权');
},
fail() {
console.log('用户拒绝授权');
// 引导用户到设置中开启权限
uni.showModal({
title: '提示',
content: '请在系统设置中打开拨号权限',
success: function(res) {
if (res.confirm) {
// 引导用户到设置页
uni.openSetting();
}
}
});
}
});
}
});

✅拨号


三种方法都可以实现拨号功能,只要有权限,之所以找了三种是为了实现APP在后台的情况下拨号的目的,做了N多测试,甚至到后面搞了一份原生插件的代码不过插件加载当时没搞懂就放弃了,不过到后面才发现原来后台拨号出现问题的原因不在这里,,具体原因看后面。


另外获取当前设备平台可以使用let platform = uni.getSystemInfoSync().platform;,我这里只需要兼容固定机型。


1、uni-app API


uni.makePhoneCall({
phoneNumber: phone,
success: () => {
log(`成功拨打电话${phone}`);
},
fail: (err) => {
log(`拨打电话失败! ${err}`);
}
});

2、Android


plus.device.dial(phone, false);

3、Android 原生


写这个的时候有个小插曲,当时已经凌晨了,再加上我没有复制,是一个个单词敲的,结果竟然敲错了一个单词,测了好几遍都没有成功。。。还在想到底哪里错了,后来核对一遍才发现😭,control cv才是王道啊。


// Android
function PhoneCallAndroid(phone) {
if (!plus || !plus.android) return;
// 导入Activity、Intent类
var Intent = plus.android.importClass("android.content.Intent");
var Uri = plus.android.importClass("android.net.Uri");
// 获取主Activity对象的实例
var main = plus.android.runtimeMainActivity();
// 创建Intent
var uri = Uri.parse("tel:" + phone); // 这里可修改电话号码
var call = new Intent("android.intent.action.CALL", uri);
// 调用startActivity方法拨打电话
main.startActivity(call);
}

✅拨号状态查询


第一版用的就是这个获取状态的代码,有三种状态。第二版的时候又换了一种,因为要增加呼入、呼出、挂断、未接等状态的判断。


export function getCallStatus(callback) {
if (!plus || !plus.android) return;
let maintest = plus.android.runtimeMainActivity();
let Contexttest = plus.android.importClass("android.content.Context");
let telephonyManager = plus.android.importClass("android.telephony.TelephonyManager");
let telManager = plus.android.runtimeMainActivity().getSystemService(Contexttest.TELEPHONY_SERVICE);
let receiver = plus.android.implements('io.dcloud.android.content.BroadcastReceiver', {
onReceive: (Contexttest, intent) => {
plus.android.importClass(intent);
let phoneStatus = telManager.getCallState();
callback && callback(phoneStatus);
//电话状态 0->空闲状态 1->振铃状态 2->通话存在
}
});
let IntentFilter = plus.android.importClass("android.content.IntentFilter");
let filter = new IntentFilter();
filter.addAction(telephonyManager.ACTION_PHONE_STATE_CHANGED);
maintest.registerReceiver(receiver, filter);
}

⚠️录音


录音功能这个其实没啥,都是官网的API,无非是简单的处理一些东西。但是这里有一个大坑!


一坑


就是像通话录音这种涉及到的隐私权限很高,正常的这种录音是在通话过程中是不被允许的。


二坑


后来一次偶然的想法,在接通之后再开启录音,发现就可以录音了。


但随之而来的是第二个坑,那就是虽然录音了,但是当播放的时候发现没有任何的声音,还是因为保护隐私的原因,我当时还脱离代码专门试了试手机自带的录音器来在通话时录音,发现也不行。由此也发现uni的录音本身也是用的手机录音器的功能。


三坑


虽然没有声音,但是我还是试了下保存,然后就发现了第三个坑,那就是虽然获取了文件权限,但是现在手机给的读写权限都是在限定内的,录音所在的文件夹是无权访问的。。。


另辟蹊径


其实除了自己手动录音外还可以通过手机自带的通话录音来实现,然后只要手机去读取录音文件并找到对应的那个就可以了。思路是没啥问题,不过因为设置通话录音指引、获取录音文件都有问题,这一版本就没实现。


// 录音

var log = console.log,
recorder = null,
// innerAudioContext = null,
isRecording = false;

export function startRecording(logFun = console.log) {
if (!uni.getRecorderManager || !uni.getRecorderManager()) return logFun('不支持录音!');
log = logFun;
recorder = uni.getRecorderManager();
// innerAudioContext = uni.createInnerAudioContext();
// innerAudioContext.autoplay = true;
recorder.onStart(() => {
isRecording = true;
log(`录音已开始 ${new Date()}`);
});
recorder.onError((err) => {
log(`录音出错:${err}`);
console.log("录音出错:", err);
});
recorder.onInterruptionBegin(() => {
log(`检测到录音被来电中断...`);
});
recorder.onPause(() => {
log(`检测到录音被来电中断后尝试启动录音..`);
recorder.start({
duration: 10 * 60 * 1000,
});
});
recorder.start({
duration: 10 * 60 * 1000,
});
}

export function stopRecording() {
if (!recorder) return
recorder.onStop((res) => {
isRecording = false;
log(`录音已停止! ${new Date()}`); // :${res.tempFilePath}
// 处理录制的音频文件(例如,保存或上传)
// powerCheckSaveRecord(res.tempFilePath);
saveRecording(res.tempFilePath);
});
recorder.stop();
}

export function saveRecording(filePath) {
// 使用uni.saveFile API保存录音文件
log('开始保存录音文件');
uni.saveFile({
tempFilePath: filePath,
success(res) {
// 保存成功后,res.savedFilePath 为保存后的文件路径
log(`录音保存成功:${res.savedFilePath}`);
// 可以将res.savedFilePath保存到你的数据中,或者执行其他保存相关的操作
},
fail(err) {
log(`录音保存失败! ${err}`);
console.error("录音保存失败:", err);
},
});
}

运行日志


为了更好的测试,也为了能实时的看到执行的过程,需要一个日志,我这里就直接渲染了一个倒序的数组,数组中的每一项就是各个函数push的字符串输出。简单处理。。。。嘛。


联调、测试、交工


搞到最后,大概就交了个这么玩意,不过也没有办法,一是自己确实不熟悉APP开发,二是满共就给了两天的工时,中间做了大量的测试代码的工作,时间确实有点紧了。所幸最起码的功能没啥问题,也算是交付了。


image.png


第二版


2024年05月7日,老哥又找上我了,想让我们把他们的这套东西再给友商部署一套,顺便把这个APP再改一改,增加上通时通次的统计功能。同时也是谈合作,如果后面有其他的友商想用这套系统,他来谈,我们来实施,达成一个长期合作关系。


我仔细想了想,觉得这是个机会,这块东西的市场需求也一直有,且自己现在失业在家也有时间,就想着把这个简单的功能打磨成一个像样的产品。也算是做一次尝试。


需求分析



  • ✅拨号APP

    • 登录

      • uni-id实现



    • 权限校验

      • 拨号权限、文件权限、自带通话录音配置



    • 权限引导

      • 文件权限引导

      • 通话录音配置引导

      • 获取手机号权限配置引导

      • 后台运行权限配置引导

      • 当前兼容机型说明



    • 拨号

      • 获取手机号

        • 是否双卡校验

        • 直接读取手机卡槽中的手机号码

        • 如果用户不会设置权限兼容直接input框输入



      • 拨号

      • 全局拨号状态监控注册、取消

        • 支持呼入、呼出、通话中、来电未接或挂断、去电未接或挂断





    • 录音

      • 读取录音文件列表

        • 支持全部或按时间查询



      • 播放录音

      • ❌上传录音文件到云端



    • 通时通次统计

      • 云端数据根据上面状态监控获取并上传

        • 云端另写一套页面



      • 本地数据读取本机的通话日志并整理统计

        • 支持按时间查询

        • 支持呼入、呼出、总计的通话次数、通话时间、接通率、有效率等





    • 其他

      • 优化日志显示形式

        • 封装了一个类似聊天框的组件,支持字符串、Html、插槽三种显示模式

        • 在上个组件的基础上实现权限校验和权限引导

        • 在上两个组件的基础上实现主页面逻辑功能



      • 增加了拨号测试、远端连接测试

      • 修改了APP名称和图标

      • 打包时增加了自有证书






中间遇到并解决的一些问题


关于框架模板


这次重构我使用了uni中uni-starter + uni-admin 项目模板。整体倒还没啥,这俩配合还挺好的,就只是刚开始不知道还要配置东西一直没有启动起来。


建立完项目之后还要进uniCloud/cloudfunctions/common/uni-config-center/uni-id配置一个JSON文件来约定用户系统的一些配置。


打包的时候也要在manifest.json将部分APP模块配置进去。


还搞了挺久的,半天才查出来。。


类聊天组件实现



  • 设计

    • 每个对话为一个无状态组件

    • 一个图标、一个名称、一个白底的展示区域、一个白色三角

    • 内容区域通过类型判断如何渲染

    • 根据前后两条数据时间差判断是否显示灰色时间



  • 参数

    • ID、名称、图标、时间、内容、内容类型等



  • 样式

    • 根据左边右边区分发送接收方,给与不同的类名

    • flex布局实现




样式实现这里,我才知道原来APP和H5的展示效果是完全不同的,个别地方需要写两套样式。


关于后台运行


这个是除了录音最让我头疼的问题了,我想了很多实现方案,也查询了很多相关的知识,但依旧没效果。总体来说有以下几种思路。



  • 通过寻找某个权限和引导(试图寻找到底是哪个权限控制的)

  • 通过不停的访问位置信息

  • 通过查找相应的插件、询问GPT、百度查询

  • 通过程序切入后台之后,在屏幕上留个悬浮框(参考游戏脚本的做法)

  • 通过切入后台后,发送消息实现(没测试)


测试了不知道多少遍,最终在一次无意中,终于发现了如何实现后台拨号,并且在之后看到后台情况下拨号状态异常,然后又查询了应用权限申请记录,也终于知道,归根到底能否后台运行还是权限的问题。


关于通话状态、通话记录中的类型


这个倒还好,就是测试的问题,知道了上面为啥异常的情况下,多做几次测试,就能知道对应的都是什么状态了。


通话状态:呼入振铃、通话中(呼入呼出)、通话挂断(呼入呼出)、来电未接或拒绝、去电未接或拒接。


通话日志:呼入、呼出、未接、语音邮件、拒接


交付


总体上来说还过得去,相比于上次简陋的东西,最起码有了一点APP的样子,基本上该有的功能也基本都已经实现了,美中不足的一点是下面的图标没有找到合适的替换,然后录音上传的功能暂未实现,不过这个也好实现了。


image.png


后面的计划



  • 把图标改好

  • 把录音文件是否已上传、录音上传功能做好

  • 把APP的关于页面加上,对接方法、使用方法和视频、问题咨询等等

  • 原本通话任务、通时通次这些是放在一个PHP后端的,对接较麻烦。要用云函数再实现一遍,然后对外暴露几个接口,这样任何一个系统都可以对接这个APP,而我也可以通过控制云空间的跨域配置来开放权限

  • 把数据留在这边之后,就可以再把uni-admin上加几个页面,并且绑定到阿里云的云函数前端网页托管上去

  • 如果有可能的话,上架应用商店,增加上一些广告或者换量联盟之类的东西

  • 后台运行时,屏幕上加个悬浮图标,来电时能显示个振铃啥的

  • 增加拨号前的校验,对接平台,对于经常拉黑电销的客户号码进行过滤


大致的想法就这些了,如果这个产品能继续卖下去,我就会不断的完善它。


最后


现在的行情真的是不好啊,不知道有没有大哥给个内推的机会,本人大专计算专业、6.5年Vue经验(专精后台管理、监控大屏方向,其他新方向愿意尝试),多个0-1-2项目经验,跨多个领域如人员管理、项目管理、产品设计、软件测试、数据爬虫、NodeJS、流程规范等等方面均有了解,工作稳定不经常跳,求路过的大哥给个内推机会把!



😂被举报标题党了,换个名字。


作者:前端湫
来源:juejin.cn/post/7368421971384860684
收起阅读 »

uniapp-实现安卓app水印相机

写在前面的话:最近要配合项目输出带水印的图片,之前的实现的方式是调uniapp封装好的相机,然后在图片输出的时候用canvas,把水印绘制上去,但是老感觉没有水印相机看着舒服.改成了现在的这种方式。 1.相机实现 水印相机实现有两种方式,在小程序端可以用cam...
继续阅读 »

写在前面的话:最近要配合项目输出带水印的图片,之前的实现的方式是调uniapp封装好的相机,然后在图片输出的时候用canvas,把水印绘制上去,但是老感觉没有水印相机看着舒服.改成了现在的这种方式。


1.相机实现


水印相机实现有两种方式,在小程序端可以用camera来实现,但在安卓端不支持camera,使用uniapp的live-pusher来实现相机。


而live-pusher推荐使用nvue来做,好处是



  1. nvue也可一套代码编译多端。

  2. nvue的cover-view比vue的cover-view更强大,在视频上绘制元素更容易。如果只考虑App端的话,不用cover-view,任意组件都可以覆盖组件,因为nvue没有层级问题

  3. 若需要视频内嵌在swiper里上下滑动(类抖音、映客首页模式),App端只有nvue才能实现 当然nvue相比vue的坏处是css写法受限,如果只开发微信小程序,不考虑App,那么使用vue页面也是一样的。



  • App平台:使用 <live-pusher/> 组件,打包 App 时必须勾选 manifest.json->App 模块权限配置->LivePusher(直播推流) 模块。




上代码!


<template>
<view class="live-camera" :style="{ width: windowWidth, height: windowHeight }">
<view class="preview" :style="{ width: windowWidth, height: windowHeight }">
<live-pusher
id="livePusher"
ref="livePusher"
class="livePusher"
mode="FHD"
beauty="0"
whiteness="0"
:aspect="aspect"
min-bitrate="1000"
audio-quality="16KHz"
device-position="back"
:auto-focus="true"
:muted="true"
:enable-camera="true"
:enable-mic="false"
:zoom="false"
@statechange="statechange"
:style="{ width: windowWidth, height: windowHeight }"
>
</live-pusher>
<!--这里修改水印的样式-->
<cover-view class="remind">
<text class="remind-text" style="">{{ message }}</text>
<text class="remind-text" style="">经度:1002.32</text>
<text class="remind-text" style="">纬度:1002.32</text>
</cover-view>
</view>
<view class="menu">
<!--底部菜单区域背景-->
<cover-image class="menu-mask" src="/static/live-camera/bar.png"></cover-image>

<!--返回键-->
<cover-image class="menu-back" @tap="back" src="/static/live-camera/back.png"></cover-image>

<!--快门键-->
<cover-image class="menu-snapshot" @tap="snapshot" src="/static/live-camera/shutter.png"></cover-image>

<!--反转键-->
<cover-image class="menu-flip" @tap="flip" src="/static/live-camera/flip.png"></cover-image>
</view>
</view>
</template>

<script>
let _this = null;
export default {
data() {
return {
dotype: 'watermark',
message: '水印相机', //水印内容
poenCarmeInterval: null, //打开相机的轮询
aspect: '2:3', //比例
windowWidth: '', //屏幕可用宽度
windowHeight: '', //屏幕可用高度
camerastate: false, //相机准备好了
livePusher: null, //流视频对象
snapshotsrc: null //快照
};
},
onLoad(e) {
_this = this;
if (e.dotype != undefined) this.dotype = e.dotype;
this.initCamera();
},
onReady() {
this.livePusher = uni.createLivePusherContext('livePusher', this);
this.startPreview(); //开启预览并设置摄像头
this.poenCarme();
},
methods: {
//轮询打开
poenCarme() {
//#ifdef APP-PLUS
if (plus.os.name == 'Android') {
this.poenCarmeInterval = setInterval(function () {
console.log(_this.camerastate);
if (!_this.camerastate) _this.startPreview();
}, 2500);
}
//#endif
},
//初始化相机
initCamera() {
uni.getSystemInfo({
success: function (res) {
_this.windowWidth = res.windowWidth;
_this.windowHeight = res.windowHeight;
let zcs = _this.aliquot(_this.windowWidth, _this.windowHeight);
_this.aspect = _this.windowWidth / zcs + ':' + _this.windowHeight / zcs;
console.log('画面比例:' + _this.aspect);
}
});
},

//整除数计算
aliquot(x, y) {
if (x % y == 0) return y;
return this.aliquot(y, x % y);
},

//开始预览
startPreview() {
this.livePusher.startPreview({
success: (a) => {
console.log(a);
}
});
},

//停止预览
stopPreview() {
this.livePusher.stopPreview({
success: (a) => {
_this.camerastate = false; //标记相机未启动
}
});
},

//状态
statechange(e) {
//状态改变
console.log(e);
if (e.detail.code == 1007) {
_this.camerastate = true;
} else if (e.detail.code == -1301) {
_this.camerastate = false;
}
},

//返回
back() {
uni.navigateBack();
},

//抓拍
snapshot() {
this.livePusher.snapshot({
success: (e) => {
_this.snapshotsrc = e.message.tempImagePath;
_this.stopPreview();
_this.setImage();
uni.navigateBack();
}
});
},

//反转
flip() {
this.livePusher.switchCamera();
},

//设置
setImage() {
let pages = getCurrentPages();
let prevPage = pages[pages.length - 2]; //上一个页面

//直接调用上一个页面的setImage()方法,把数据存到上一个页面中去
prevPage.$vm.setImage({ path: _this.snapshotsrc, dotype: this.dotype });
}
}
};
</script>

<style lang="scss">
.live-camera {
justify-content: center;
align-items: center;
.preview {
justify-content: center;
align-items: center;
.remind {
position: absolute;
bottom: 180rpx;
left: 20rpx;
width: 130px;
z-index: 100;
.remind-text {
color: #dddddd;
font-size: 40rpx;
text-shadow: #fff 1px 0 0, #fff 0 1px 0, #fff -1px 0 0, #fff 0 -1px 0;
}
}
}
.menu {
position: absolute;
left: 0;
bottom: 0;
width: 750rpx;
height: 180rpx;
z-index: 98;
align-items: center;
justify-content: center;
.menu-mask {
position: absolute;
left: 0;
bottom: 0;
width: 750rpx;
height: 180rpx;
z-index: 98;
}
.menu-back {
position: absolute;
left: 30rpx;
bottom: 50rpx;
width: 80rpx;
height: 80rpx;
z-index: 99;
align-items: center;
justify-content: center;
}
.menu-snapshot {
width: 130rpx;
height: 130rpx;
z-index: 99;
}
.menu-flip {
position: absolute;
right: 30rpx;
bottom: 50rpx;
width: 80rpx;
height: 80rpx;
z-index: 99;
align-items: center;
justify-content: center;
}
}
}
</style>

2.水印图片绘制


图片水印返回上一页用<canvas>添加水印

<template>
<view class="page">
<view style="height: 80rpx;"></view>
<navigator class="buttons" url="../camera/watermark/watermark"><button type="primary">打开定制水印相机</button></navigator>
<view style="height: 80rpx;"></view>

<view>拍摄结果预览图,见下方</view>
<image class="preview" :src="imagesrc" mode="aspectFit" style="width:710rpx:height:710rpx;margin: 20rpx;"></image>

<canvas id="canvas-clipper" canvas-id="canvas-clipper" type="2d" :style="{width: canvasSiz.width+'px',height: canvasSiz.height+'px',position: 'absolute',left:'-500000px',top: '-500000px'}" />
</view>
</template>

<script>
var _this;
export default {
data() {
return {
windowWidth:'',
windowHeight:'',
imagesrc: null,
canvasSiz:{
width:188,
height:273
}
};
},
onLoad() {
_this= this;
this.init();
},
methods: {

//设置图片
setImage(e) {
console.log(e);
//显示在页面
//this.imagesrc = e.path;
if(e.dotype =='idphoto'){
_this.zjzClipper(e.path);
}else if(e.dotype =='watermark'){
_this.watermark(e.path);
}else{
_this.savePhoto(e.path);
}
},


//添加照片水印
watermark(path){
uni.getImageInfo({
src: path,
success: function(image) {
console.log(image);

_this.canvasSiz.width =image.width;
_this.canvasSiz.height =image.height;

//担心尺寸重置后还没生效,故做延迟
setTimeout(()=>{
let ctx = uni.createCanvasContext('canvas-clipper', _this);

ctx.drawImage(
path,
0,
0,
image.width,
image.height
);

//具体位置如需和相机页面上一致还需另外做计算,此处仅做大致演示
ctx.setFillStyle('white');
ctx.setFontSize(40);
ctx.fillText('live-camera', 20, 100);


//再来加个时间水印
var now = new Date();
var time= now.getFullYear()+'-'+now.getMonth()+'-'+now.getDate()+' '+now.getHours()+':'+now.getMinutes()+':'+now.getMinutes();
ctx.setFontSize(30);
ctx.fillText(time, 20, 140);

ctx.draw(false, () => {
uni.canvasToTempFilePath(
{
destWidth: image.width,
destHeight: image.height,
canvasId: 'canvas-clipper',
fileType: 'jpg',
success: function(res) {
_this.savePhoto(res.tempFilePath);
}
},
_this
);
});
},500)


}
});
},

//保存图片到相册,方便核查
savePhoto(path){
this.imagesrc = path;
//保存到相册
uni.saveImageToPhotosAlbum({
filePath: path,
success: () => {
uni.showToast({
title: '已保存至相册',
duration: 2000
});
}
});
},

//初始化
init(){
let _this = this;
uni.getSystemInfo({
success: function(res) {
_this.windowWidth = res.windowWidth;
_this.windowHeight = res.windowHeight;
}
});
}

}
};
</script>

<style lang="scss">
.page {
width: 750rpx;
justify-content: center;
align-items: center;
flex-direction:column;
display: flex;
.buttons {
width: 600rpx;
}
}


</style>

8dbee86b262efefc549933df666fbc7.jpg


作者:山沫微云
来源:juejin.cn/post/7399983106750447627
收起阅读 »

鸿蒙next高仿微信来了 我不允许你不会

前言导读 各位同学大家,有段时间没有跟大家见面了,因为最近一直在更新鸿蒙的那个实战课程所以就没有去更新文章实在是不好意思, 所以今天就给大家更新一期实战案例 高仿微信案例 希望帮助到各位同学工作和学习 效果图 特点 高仿程度80 目前不支持即时通...
继续阅读 »

前言导读


各位同学大家,有段时间没有跟大家见面了,因为最近一直在更新鸿蒙的那个实战课程所以就没有去更新文章实在是不好意思, 所以今天就给大家更新一期实战案例 高仿微信案例 希望帮助到各位同学工作和学习


效果图


image-20240809140521780


image-20240809140834969


image-20240809140849586


image-20240809140529987


image-20240809140538888


image-20240809140719388


特点



  1. 高仿程度80

  2. 目前不支持即时通讯功能

  3. 支持最新的api 12

  4. 目前做了账号注册和登录自动登录功能入口


具体实现




  • 启动页面




/**
* 创建人:xuqing
* 创建时间:2024年7月14日22:56:15
* 类说明:欢迎页面
*
*/


import router from '@ohos.router';
import { preferences } from '@kit.ArkData';
import CommonConstant from '../common/CommonConstants';
import Logger from '../utils/Logger';
import { httpRequestGet } from '../utils/OkhttpUtils';
import { LoginModel } from '../bean/LoginModel';

let dataPreferences: preferences.Preferences | null = null;


@Entry
@Component
struct Welcome {

  async aboutToAppear(){
    let options: preferences.Options = { name: 'myStore' };
    dataPreferences = preferences.getPreferencesSync(getContext(), options);
    let getusername=dataPreferences.getSync('username','');
    let getpassword=dataPreferences.getSync('password','');
    if(getusername===''||getpassword===''){
      router.pushUrl({
        url:'pages/LoginPage'
      })
    }else {
      let username:string='username=';
      let password:string='&password=';
      let netloginurl=CommonConstant.LOGIN+username+getusername+password+getpassword;
      httpRequestGet(netloginurl).then((data)=>{
        Logger.error("请求数据--->"+ data.toString());
        let loginmodel:LoginModel=JSON.parse(data.toString());
        if(loginmodel.code===200){
          router.pushUrl({
            url:'pages/Index'
          })
        }else{
          router.pushUrl({
            url:'pages/LoginPage'
          })
        }
      })
    }

}

build() {
  RelativeContainer(){
    Image($r('app.media.weixinbg'))
      .width('100%')
      .height('100%')

  }.height('100%')
  .width('100%')
  .backgroundColor(Color.Green)

}
}

登录页面


import CommonConstant, * as commonConst from '../common/CommonConstants';
import Logger from '../utils/Logger';
import { httpRequestGet } from '../utils/OkhttpUtils';
import { LoginData, LoginModel} from '../bean/LoginModel';
import prompt from '@ohos.promptAction';
import router from '@ohos.router';
import { preferences } from '@kit.ArkData';
let dataPreferences: preferences.Preferences | null = null;



/**
* 创建人:xuqing
* 创建时间:2024年7月14日17:00:03
* 类说明:登录页面
*
*/

//输入框样式
@Extend(TextInput) function inputStyle(){
.placeholderColor($r('app.color.placeholder_color'))
.height(45)
.fontSize(18)
.backgroundColor($r('app.color.background'))
.width('100%')
.padding({left:0})
.margin({top:12})
}
//线条样式
@Extend(Line) function lineStyle(){
.width('100%')
.height(1)
.backgroundColor($r('app.color.line_color'))
}
//黑色字体样式
@Extend(Text) function blackTextStyle(size?:number ,height?:number){
.fontColor($r('app.color.black_text_color'))
.fontSize(18)
.fontWeight(FontWeight.Medium)
}

@Entry
@Component
struct LoginPage {

@State accout:string='';
@State password:string='';
async login(){
  let username:string='username=';
  let password:string='&password=';
  let netloginurl=CommonConstant.LOGIN+username+this.accout+password+this.password;
    Logger.error("请求url"+netloginurl);
    await httpRequestGet(netloginurl).then((data)=>{
      Logger.error("请求结果"+data.toString());
      let loginModel:LoginModel=JSON.parse(data.toString());
      let msg=loginModel.msg;
      let logindata:LoginData=loginModel.user;
      let token=loginModel.token;
      let userid=logindata.id;
      let options: preferences.Options = { name: 'myStore' };
      dataPreferences = preferences.getPreferencesSync(getContext(), options);

      if(loginModel.code===200){
        Logger.error("登录成功");
        dataPreferences.putSync('token',token);
        dataPreferences.putSync('id',userid);
        dataPreferences.putSync('username',this.accout);
        dataPreferences.putSync('password',this.password);
        dataPreferences!!.flush()
        router.pushUrl({
          url:'pages/Index'
        })
      }else {
        prompt.showToast({
          message:msg
        })
      }
    })
}


build() {
    Column(){
      Image($r('app.media.weixinicon'))
        .width(48)
        .height(48)
        .margin({top:100,bottom:8})
        .borderRadius(8)
        Text('登录界面')
          .fontSize(24)
          .fontWeight(FontWeight.Medium)
          .fontColor($r('app.color.title_text_color'))
      Text('登录账号以使用更多服务')
        .fontSize(16)
        .fontColor($r('app.color.login_more_text_color'))
        .margin({bottom:30,top:8})

      Row(){
        Text('账号').blackTextStyle()
        TextInput({placeholder:'请输入账号'})
          .maxLength(12)
          .type(InputType.Number)
          .inputStyle()
          .onChange((value:string)=>{
            this.accout=value;
          }).margin({left:20})

      }.justifyContent(FlexAlign.SpaceBetween)
      .width('100%')
      .margin({top:8})
      Line().lineStyle().margin({left:80})



      Row(){
        Text('密码').blackTextStyle()
        TextInput({placeholder:'请输入密码'})
          .maxLength(12)
          .type(InputType.Password)
          .inputStyle()
          .onChange((value:string)=>{
            this.password=value;
          }).margin({left:20})
      }.justifyContent(FlexAlign.SpaceBetween)
      .width('100%')
      .margin({top:8})
      Line().lineStyle().margin({left:80})

      Button('登录',{type:ButtonType.Capsule})
        .width('90%')
        .height(40)
        .fontSize(16)
        .fontWeight(FontWeight.Medium)
        .backgroundColor($r('app.color.login_button_color'))
        .margin({top:47,bottom:12})
        .onClick(()=>{
          this.login()
        })
      Text('注册账号').onClick(()=>{
        router.pushUrl({
          url:'pages/RegisterPage'
        })
      }).fontColor($r('app.color.login_blue_text_color'))
        .fontSize(12)
        .fontWeight(FontWeight.Medium)
    }.backgroundColor($r('app.color.background'))
  .height('100%')
  .width('100%')
  .padding({
    left:12,
    right:12,
    bottom:24
  })

}
}



  • 主页index


    import home from './Home/Home';
    import contacts from './Contact/Contacts';
    import Discover from './Discover/Discover';
    import My from './My/My';
    import common from '@ohos.app.ability.common';
    import prompt from '@ohos.promptAction';


    @Entry
    @Component
    struct Index {
    @State message: string = 'Hello World';

    private backTime :number=0;
    @State fontColor: string = '#182451'
    @State selectedFontColor: string = 'rgb(0,196,104)'
    private controller:TabsController=new TabsController();
    showtoast(msg:string){
      prompt.showToast({
        message:msg
      })
    }



    @State SelectPos:number=0;
    private positionClick(){
      this.SelectPos=0;
      this.controller.changeIndex(0);

    }

    private companyClick(){
      this.SelectPos=1;
      this.controller.changeIndex(1);
    }
    private messageClick(){
      this.SelectPos=2;
      this.controller.changeIndex(2);

    }

    private myClick(){
      this.SelectPos=3;
      this.controller.changeIndex(3);

    }

    onBackPress(): boolean | void {
      let nowtime=Date.now();
      if(nowtime-this.backTime<1000){
        const mContext=getContext(this) as common.UIAbilityContext;
        mContext.terminateSelf()
      }else{
        this.backTime=nowtime;
        this.showtoast("再按一次将退出当前应用")
      }
      return true;
    }


    // ['微信','通讯录','发现','我']
    build() {
      Flex({direction:FlexDirection.Column,alignItems:ItemAlign.Center,justifyContent:FlexAlign.Center}){
        Tabs({controller:this.controller}){
          TabContent(){
            home();
          }
          TabContent(){
            contacts();
          }
          TabContent(){
            Discover();
          }
          TabContent(){
            My()
          }
        }.scrollable(false)
        .barHeight(0)
        .animationDuration(0)

        Row(){
          Column(){
            Image((this.SelectPos==0?$r('app.media.wetab01'):$r('app.media.wetab00')))
              .width(20).height(20)
              .margin({top:5})
            Text('微信')
              .size({width:'100%',height:30}).textAlign(TextAlign.Center)
              .fontSize(15)
              .fontColor((this.SelectPos==0?this.selectedFontColor:this.fontColor))
          }.layoutWeight(1)
          .backgroundColor($r('app.color.gray2'))
          .height('100%')
          .onClick(this.positionClick.bind(this))



          Column(){
            Image((this.SelectPos==1?$r('app.media.wetab11'):$r('app.media.wetab10')))
              .width(20).height(20)
              .margin({top:5})
            Text('通讯录')
              .size({width:'100%',height:30}).textAlign(TextAlign.Center)
              .fontSize(15)
              .fontColor((this.SelectPos==1?this.selectedFontColor:this.fontColor))
          }.layoutWeight(1)
          .backgroundColor($r('app.color.gray2'))
          .height('100%')
          .onClick(this.companyClick.bind(this))


          Column(){
            Image((this.SelectPos==2?$r('app.media.wetab21'):$r('app.media.wetab20')))
              .width(20).height(20)
              .margin({top:5})
            Text('发现')
              .size({width:'100%',height:30}).textAlign(TextAlign.Center)
              .fontSize(15)
              .fontColor((this.SelectPos==2?this.selectedFontColor:this.fontColor))
          }.layoutWeight(1)
          .backgroundColor($r('app.color.gray2'))
          .height('100%')
          .onClick(this.messageClick.bind(this))


          Column(){
            Image((this.SelectPos==5?$r('app.media.wetab31'):$r('app.media.wetab30')))
              .width(20).height(20)
              .margin({top:5})
            Text('我')
              .size({width:'100%',height:30}).textAlign(TextAlign.Center)
              .fontSize(15)
              .fontColor((this.SelectPos==5?this.selectedFontColor:this.fontColor))
          }.layoutWeight(1)
          .backgroundColor($r('app.color.gray2'))
          .height('100%')
          .onClick(this.myClick.bind(this))
        }.alignItems(VerticalAlign.Bottom).width('100%').height(60).margin({top:0,right:0,bottom:0,left:0})

      }.width('100%')
      .height('100%')


    }
    }

    后续目标



    1. 微信朋友圈

    2. 聊天菜单(相册,拍摄...)组件栏

    3. 语音|视频页面

    4. 支持群聊头像

    5. 支持图片,红包等聊天内容类型(现已支持图片类型)

    6. 二维码扫描




最后总结:


因为篇幅有限我也不能整个项目都展开讲,有兴趣的同学能可以关注我B站课程。 后续能我会把这个项目更新到项目里面 供大家学习


B站课程地址:http://www.bilibili.com/cheese/play…


团队介绍


团队介绍:坚果派由坚果等人创建,团队由12位华为HDE以及若干热爱鸿蒙的开发者和其他领域的三十余位万粉博主运营。专注于分享 HarmonyOS/OpenHarmony,ArkUI-X,元服务,仓颉,团队成员聚集在北京,上海,南京,深圳,广州,宁夏等地,目前已开发鸿蒙 原生应用,三方库60+,欢迎进行课程,项目等合作。


作者:坚果派_xq9527
来源:juejin.cn/post/7400741845508522019
收起阅读 »

UNIAPP开发电视app教程

目前开发安卓TV的方法相对开说是十分的少的,使用uniapp开发相对来说几乎是没有的,因此,写下这篇文章供大家参考。 开发难点 如何方便的开发调试 如何使需要被聚焦的元素获取聚焦状态 如何使被聚焦的元素滚动到视图中心位置 如何在切换路由时,缓存聚焦的状态 如...
继续阅读 »

目前开发安卓TV的方法相对开说是十分的少的,使用uniapp开发相对来说几乎是没有的,因此,写下这篇文章供大家参考。


开发难点



  1. 如何方便的开发调试

  2. 如何使需要被聚焦的元素获取聚焦状态

  3. 如何使被聚焦的元素滚动到视图中心位置

  4. 如何在切换路由时,缓存聚焦的状态

  5. 如何启用wgt和apk两种方式的升级


一、如何方便的开发调试


之前我在论坛看到人家说,没办法呀,电脑搬到电视,然后调试。


其实大可不必,安装android studio里边创建一个模拟器就可以了。


注意:最好安装和电视系统相同的版本号,我这里是长虹电视,安卓9所以使用安卓9的sdk


二、如何使需要被聚焦的元素获取聚焦状态


uniapp的本质上是webview, 因此我们可以在它的元素上添加tabIndex, 就可以获取焦点了。


  <view class="card" tabindex="0">
<image :src="`${VITE_URL}${props.image}`" fade-show lazy-load mode="aspectFill"></image>
<view class="bottom">
<text class="name">{{ props.name }}</text> <text class="remark">{{ props.remark }}</text>
<div class="footer">
<view class="tags">
<text class="tag" v-for="tag in tags" :key="tag">{{ tag }}</text>
</view>
<text class="price">&yen; {{ props.price }}</text>
</div>
</view>
</view>


.card {
border-radius: 1.25vw;
overflow: hidden;
}
.card:focus {
box-shadow: 0 0 0 0.3vw #fff, 0 0 1vw 0.3vw #333;
outline: none;
transform: scale(1.03);
transition: box-shadow 0.3s ease, transform 0.3s ease;
}


三、如何使被聚焦的元素滚动到视图中心位置


使用renderjs进行实现如下


<script  module="homePage" lang="renderjs">
export default {
mounted() {
let isScrolling = false; // 添加一个标志位,表示是否正在滚动
document.body.addEventListener('focusin', e => {
if (!isScrolling) {
// 检查是否正在滚动
isScrolling = true; // 设置滚动标志为true
requestAnimationFrame(() => {
// @ts-ignore
e.target.scrollIntoView({
behavior: 'smooth', // @ts-ignore
block: e.target.dataset.index ? 'end' : 'center'
});
isScrolling = false; // 在滚动完成后设置滚动标志为false
});
}
});
}
};
</script>

就可以使被聚焦元素滚动到视图中心,requestAnimationFrame的作用是缓存


四、如何在切换路由时,缓存聚焦的状态


通过设置tabindex属性为0和1,会有不同的效果:



  1. tabindex="0":将元素设为可聚焦,并按照其在文档中的位置来确定焦点顺序。当使用Tab键进行键盘导航时,tabindex="0"的元素会按照它们在源代码中的顺序获取焦点。这可以用于将某些非交互性元素(如
    等)设为可聚焦元素,使其能够被键盘导航。

  2. tabindex="1":将元素设为可聚焦,并将其置于默认的焦点顺序之前。当使用Tab键进行键盘导航时,tabindex="1"的元素会在默认的焦点顺序之前获取焦点。这通常用于重置焦点顺序,或者将某些特定的元素(如重要的输入字段或操作按钮)置于首位。


需要注意的是,如果给多个元素都设置了tabindex属性,那么它们的焦点顺序将取决于它们的tabindex值,数值越小的元素将优先获取焦点。如果多个元素具有相同的tabindex值,则它们将按照它们在文档中的位置来确定焦点顺序。同时,负数的tabindex值也是有效的,它们将优先于零和正数值获取焦点。


我们要安装缓存插件,如pinia或vuex,需要缓存的页面单独配置


import { defineStore } from 'pinia';
export const useGlobalStore = defineStore('global', {
state: () => ({ home_active_tag: 'active0', hot_active_tag: 'hot0', dish_active_tag: 'dish0' })
});


更新一下业务代码


组件区域
<view class="card" :tabindex="home_active_tag === 'packagecard' + props.id ? 1 : 0">
<image :src="`${VITE_URL}${props.image}`" fade-show lazy-load mode="aspectFill"></image>
<view class="bottom">
<text class="name">{{ props.name }}</text> <text class="remark">{{ props.remark }}</text>
<div class="footer">
<view class="tags">
<text class="tag" v-for="tag in tags" :key="tag">{{ tag }}</text>
</view>
<text class="price">&yen; {{ props.price }}</text>
</div>
</view>

</view>

const { home_active_tag } = storeToRefs(useGlobalStore());

页面区域

<view class="content">
<FoodCard
v-for="_package in list.dishes"
@click="goShopByFood(_package)"
:id="_package.id"
:name="_package.name"
:image="_package.image"
:tags="_package.tags"
:price="_package.price"
:shop_name="_package.shop_name"
:shop_id="_package.shop_id"
:key="_package.id"
></FoodCard>
<image
class="card"
@click="goMore"
:tabindex="home_active_tag === 'more' ? 1 : 0"
style="width: 29.375vw; height: 25.9375vw"
src="/static/home/more.png"
mode="aspectFill"
/>
</view>

const goShopByFood = async (row: Record<string, any>) => {
useGlobalStore().home_active_tag = 'foodcard' + row.id;
uni.navigateTo({
url: `/pages/shop/index?shop_id=${row.shop_id}`,
animationDuration: 500,
animationType: 'zoom-fade-out'
});
};


如果,要设置启动默认焦点 id和index可默认设置,推荐启动第一个焦点组用index,它可以确定


  <view class="active">
<image
v-for="(active, i) in list.active"
:key="active.id"
@click="goActive(active, i)"
:tabindex="home_active_tag === 'active' + i ? 1 : 0"
:src="`${VITE_URL}${active.image}`"
data-index="0"
fade-show
lazy-load
mode="aspectFill"
class="card"
></image>
</view>

import { defineStore } from 'pinia';
export const useGlobalStore = defineStore('global', {
state: () => ({
home_active_tag: 'active0', //默认选择
hot_active_tag: 'hot0',
dish_active_tag: 'dish0'
})
});


对于多层级的,要注意销毁,在前往之前设置默认焦点


const goHot = (index: number) => {
useGlobalStore().home_active_tag = 'hotcard' + index;
useGlobalStore().hot_active_tag = 'hot0';
uni.navigateTo({ url: `/pages/hot/index?index=${index}`, animationDuration: 500, animationType: 'zoom-fade-out' });
};


五、如何启用wgt和apk两种方式的升级


pages.json


{
"path": "components/update/index",
"style": {
"disableScroll": true,
"backgroundColor": "#0068d0",
"app-plus": {
"backgroundColorTop": "transparent",
"background": "transparent",
"titleNView": false,
"scrollIndicator": false,
"popGesture": "none",
"animationType": "fade-in",
"animationDuration": 200
}
}
}


组件


<template>
<view class="update">
<view class="content">
<view class="content-top">
<text class="content-top-text">发现版本</text>
<image class="content-top" style="top: 0" width="100%" height="100%" src="@/static/bg_top.png"> </image>
</view>
<text class="message"> {{ message }} </text>
<view class="progress-box">
<progress
class="progress"
border-radius="35"
:percent="progress.progress"
activeColor="#3DA7FF"
show-info
stroke-width="10"
/>

<view class="progress-text">
<text>安装包正在下载,请稍后,系统会自动重启</text>
<text>{{ progress.totalBytesWritten }}MB/{{ progress.totalBytesExpectedToWrite }}MB</text>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { onLoad } from '@dcloudio/uni-app';
import { reactive, ref } from 'vue';
const message = ref('');
const progress = reactive({ progress: 0, totalBytesExpectedToWrite: '0', totalBytesWritten: '0' });
onLoad((query: any) => {
message.value = query.content;
const downloadTask = uni.downloadFile({
url: `${import.meta.env.VITE_URL}/${query.url}`,
success(downloadResult) {
plus.runtime.install(
downloadResult.tempFilePath,
{ force: false },
() => {
plus.runtime.restart();
},
e => {}
);
}
});
downloadTask.onProgressUpdate(res => {
progress.progress = res.progress;
progress.totalBytesExpectedToWrite = (res.totalBytesExpectedToWrite / Math.pow(1024, 2)).toFixed(2);
progress.totalBytesWritten = (res.totalBytesWritten / Math.pow(1024, 2)).toFixed(2);
});
});
</script>
<style lang="less">
page {
background: transparent;
.update {
/* #ifndef APP-NVUE */
display: flex; /* #endif */
justify-content: center;
align-items: center;
position: fixed;
left: 0;
top: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.65);
.content {
position: relative;
top: 0;
width: 50vw;
height: 50vh;
background-color: #fff;
box-sizing: border-box;
padding: 0 50rpx;
font-family: Source Han Sans CN;
border-radius: 2vw;
.content-top {
position: absolute;
top: -5vw;
left: 0;
image {
width: 50vw;
height: 30vh;
}
.content-top-text {
width: 50vw;
top: 6.6vw;
left: 3vw;
font-size: 3.8vw;
font-weight: bold;
color: #f8f8fa;
position: absolute;
z-index: 1;
}
}
}
.message {
position: absolute;
top: 15vw;
font-size: 2.5vw;
}
.progress-box {
position: absolute;
width: 45vw;
top: 20vw;
.progress {
width: 90%;
border-radius: 35px;
}
.progress-text {
margin-top: 1vw;
font-size: 1.5vw;
}
}
}
}
</style>


App.vue


import { onLaunch } from '@dcloudio/uni-app';
import { useRequest } from './hooks/useRequest';
import dayjs from 'dayjs'; onLaunch(() => {
// #ifdef APP-PLUS
plus.runtime.getProperty('', async app => { const res: any = await useRequest('GET', '/api/tv/app'); if (res.code === 2000 && res.row.version > (app.version as string)) { uni.navigateTo({ url: `/components/update/index?url=${res.row.url}&type=${res.row.type}&content=${res.row.content}`, fail: err => { console.error('更新弹框跳转失败', err); } }); } });
// #endif
});

如果要获取启动参数


plus.android.importClass('android.content.Intent');
const MainActivity = plus.android.runtimeMainActivity();
const Intent = MainActivity.getIntent();
const roomCode = Intent.getStringExtra('roomCode');
if (roomCode) {
uni.setStorageSync('roomCode', roomCode);
} else if (!uni.getStorageSync('roomCode') && !roomCode) {
uni.setStorageSync('roomCode', '8888');
}

作者:前端纸飞机官方
来源:juejin.cn/post/7272348543625445437
收起阅读 »

小小扫码枪bug引发的思考

最近新公司发生了一件bug引发思考的事 产品需求 大致如上图,一个输入框,我们制作了自定义数字键盘,input框可以回显键盘的输入,并且,可以支持扫码枪输入回显 bug描述 在win 系统没有问题,但在安卓系统: 每次自定义键盘输入时,还会吊起系统软键盘,...
继续阅读 »

最近新公司发生了一件bug引发思考的事


产品需求


image.png


大致如上图,一个输入框,我们制作了自定义数字键盘,input框可以回显键盘的输入,并且,可以支持扫码枪输入回显


bug描述


在win 系统没有问题,但在安卓系统



  1. 每次自定义键盘输入时,还会吊起系统软键盘,且通过系统软键盘输入,input是无法回显的!键盘.jpg

  2. 不支持 扫码枪输入了


最讨厌研究 系统兼容性问题了,但问题出了,就得研究


我们先看一下,自定义数字键盘是怎么实现的?


在了解自定义键盘之前,我先问问大家,键盘输入会触发哪些事件?


对,就是这三个 keydown,keypress, keyup


如何控制Input框只回显数字呢?答案就是在keyDown事件里,通过捕获 event.key来获取用户按下的物理按键的值,非数字的值直接return就能做到了


那么言归正传,自定义键盘怎么实现呢?


其实到这边我们不难想到一个解决方案的思路就是,当按下自定义键盘时,我们模拟一个 keydown事件,并向获得焦点的input 派发这个keydown事件,那么就能模拟键盘输入了


上代码:


      const input = document.activeElement

      const event = document.createEvent('UIEvents')

      event.initUIEvent('keydown', true, true, window, 0)

      event.key = key

      event.keyCode = -1

      input.dispatchEvent(event)

扫码枪又是个啥?


就是这个东东:


image.png


去过超市的都看过吧


用扫码枪或者其他设备扫描图形码(条形码或其他码)后将其为文本输入,
input需要识别到扫码枪输入结束,并回显input区,


其实扫码枪输入和用户键盘输入一样都可以触发keydown事件,派发给聚焦的input


那么问题来了?


怎样识别 扫码枪输入结束呢?


答案是onEnter事件


我们再来看看 安卓端出现的bug


1,为啥每次我们在自定义键盘上输入,会同时弹出系统软键盘呢??


问了下安卓侧RD,原来只要input获得焦点,系统键盘就会弹出


但是不聚焦,自定义键盘/扫码枪也没办法回显了呀?


难道真的无解了吗?这时候第n个知识点来了!用readOnly!
readonly,对,就是它,


什么?readonly不是只读吗?有了它,相当于 用户无法输入,因此无法触发系统键盘,这个可以理解,但是,加上它之后,还有焦点吗?


这里有个问题要问大家,你知道readonly和disabled的区别吗?


答案就是在交互上,readonly 仍是可以聚焦的!disabled 就不能了


并且readOnly 是禁止用户输入,所以在允许聚焦的同时,又阻止了软键盘的弹出,这时我不禁感叹: 完美!


2,安卓为啥不支持扫码枪扫码了?


我们通过调试发现,在安卓上,keyDown事件 捕获到的event.key 是 Unidentified, 被我们判定为非数字,直接return了


那解法呢?我们神奇的发现,当我们解了bug1,加上readonly后,bug2也好了!


至于为啥它也好了,具体原因我还不清楚,以下是我的猜测:


前文我们提到,只要input聚焦,软键盘就会弹出,而扫码枪其实也可以看成一个特殊的键盘,可能两个键盘冲突导致 event.key 无法识别,加上readonly禁掉 软键盘后,冲突解除,自然event.key 也可以正常识别了


清楚原因的同学可以留言给我哈!我好想知道!!


反思来了


这件问题的最终解决方案只有一行代码,一个单词: readOnly


简单到令人发指,而且这个问题是一个刚来两天的新同学搞定的


我在想这一连串的故事,太神奇了


为啥这个困扰前辈同学包括我很久的问题,一个萌新一下子就解决了呢?虽然我也是萌新
image.png


readOnly可以 解决禁止软键盘弹出,网上的答案是有的,但是我pass了这��方案,


为什么呢?



  1. input相关基础差,我错误的认为readOnly是只读嘛,肯定会不带焦点啊,虽然禁用了软键盘,但是 扫码枪输入也不能回显了啊

  2. 当我看到 event.key 是 Unidentified 时,研究重点跑偏了

  3. 我觉得这可能某种程度上是一种 beginer’s luck, 因为当时新同学的任务是研究如何禁用软键盘,并没有提到其他扫码枪问题,可能这种心无旁骛反而成了事

  4. 工作中,尤其遇到一些诡异的兼容性问题,真的需要多尝试,不要被自己的想当然绑手绑脚

  5. 对于兼容性问题,因为要不断尝试,最好找到一种简单方便的调试方法,会大大加快调研进度


最后还是感谢一切的发生,收获了知识,也让我有冲动分享给大家我的一点小思考,感恩感恩!


作者:sophie旭
来源:juejin.cn/post/7388459061758017571
收起阅读 »

安卓开发转鸿蒙开发到底有多简单?

前言 相信各位搞安卓的同学多多少少都了解过鸿蒙了,有些一知半解而有些已经开始学习起来。那这个鸿蒙到底好不好搞?要不要搞? 安卓反正目前工作感觉不好找,即便是上海这样的大城市也难搞,人员挺饱和的。最近临近年关裁员的也很多。想想还是搞鸿蒙吧现在刚刚要起步说不定有机...
继续阅读 »

前言


相信各位搞安卓的同学多多少少都了解过鸿蒙了,有些一知半解而有些已经开始学习起来。那这个鸿蒙到底好不好搞?要不要搞?


安卓反正目前工作感觉不好找,即便是上海这样的大城市也难搞,人员挺饱和的。最近临近年关裁员的也很多。想想还是搞鸿蒙吧现在刚刚要起步说不定有机会!


首先可以肯定的一点,对于做安卓的来说鸿蒙很好搞,究竟有多好搞我来给大家说说。最近开始学鸿蒙,对其开发过程有了一定了解。刚好可以进行一些对比。


好不好搞?


开发环境


要我说,好搞的很。首先开发环境一样,不是说长得像,而是就一模一样。


截屏2023-12-04 09.25.27 (2).png


你看这个DevEco-Studio和Android Studio什么关系,就是双胞胎。同样基于Intellj IDEA开发, 刚装上的时候我都惊呆了,熟悉的感觉油然而生。


再来仔细看看:



  • 项目文件管理栏,同样可以切换Project和Packages视图


截屏2023-12-04 09.29.40.png



  • 底部工具栏,文件管理,日志输出,终端,Profiler等


截屏2023-12-04 09.31.05.png



  • SDK Manager, 和安卓一样也内建了SDK管理器,可以下载管理不同版本的SDK


截屏2023-12-04 09.32.55.png



  • 模拟器管理器


截屏2023-12-04 09.35.07.png


可以看出鸿蒙开发的IDE是功能完备并且安卓开发人员可以无学习成本进行转换。


开发工具


安卓开发中需要安装Java语言支持,由于开发过程需要进行调试,adb也是必不可少的。
在鸿蒙中,安装EcoDev-Studio后,可以在IDE中选择安装Node.js即可。由于鸿蒙开发使用的语言是基于TS改进增强而来,也就是熟悉JS语言就可以上手。而会JAVA的话很容易可以上手JS



  • 语言支持


截屏2023-12-04 09.44.25.png



  • 鸿蒙上的类似adb的工具名叫hdc



hdc(HarmonyOS Device Connector)是HarmonyOS为开发人员提供的用于调试的命令行工具,通过该工具可以在windows/linux/mac系统上与真实设备或者模拟器进行交互。




  1. hdc list targets

  2. hdc file send local remote

  3. hdc install package File


这里列举的几个命令是不是很熟悉?一看名字就知道和安卓中的adb是对应关系。不需要去记忆,在需要使用到的时候去官网查一下就行: hdc使用指导


配置文件


安卓中最主要的配置文件是AndroidManifest.xml。 其中定义了版本号,申明了页面路径,注册了广播和服务。并且申明了App使用的权限。


而鸿蒙中也对应有配置文件,但与安卓稍有不同的是鸿蒙分为多个文件。



  • build-profile.json5


Sdk Version配置在这里, 代码的模块区分也在这里


{
"app": {
"signingConfigs": [],
"compileSdkVersion": 9,
"compatibleSdkVersion": 9,
"products": [
{
"name": "default",
"signingConfig": "default",
}
],
"buildModeSet": [
{
"name": "debug",
},
{
"name": "release"
}
]
},
"modules": [
{
"name": "entry",
"srcPath": "./entry",
"targets": [
{
"name": "default",
"applyToProducts": [
"default"
]
}
]
}
]
}


  • app.json5


包名,VersionCode,VersionName等信息


{
"app": {
"bundleName": "com.example.firstDemo",
"vendor": "example",
"versionCode": 1000000,
"versionName": "1.0.0",
"icon": "$media:app_icon",
"label": "$string:app_name"
}
}


  • module.json5


模块的详细配置,页面名和模块使用到的权限在这里申明


{
"module": {
"name": "entry",
"type": "entry",
"description": "$string:module_desc",
"mainElement": "EntryAbility",
"deviceTypes": [
"phone",
"tablet"
],
"deliveryWithInstall": true,
"installationFree": false,
"pages": "$profile:main_pages",
"abilities": [
{
"name": "EntryAbility",
"srcEntry": "./ets/entryability/EntryAbility.ts",
"description": "$string:EntryAbility_desc",
"icon": "$media:icon",
"label": "$string:EntryAbility_label",
"startWindowIcon": "$media:startIcon",
"startWindowBackground": "$color:start_window_background",
"exported": true,
"skills": [
{
"entities": [
"entity.system.home"
],
"actions": [
"action.system.home"
]
}
]
}
],
"requestPermissions":[
{
"name" : "ohos.permission.APPROXIMATELY_LOCATION",
"reason": "$string:reason",
"usedScene": {
"abilities": [
"FormAbility"
],
"when":"inuse"
}
}
]
}
}

官方指导


安卓开发的各种技术文档在网上可以很方便的搜索到,各种demo也有基数庞大的安卓开发者在技术网站上分享。虽然鸿蒙目前处于刚起步的阶段,但是官方的技术文档目前也已经非常完善,并且可以感受到鸿蒙的官方维护团队肯定在高强度加班中,他们的文档更新的太快了。经常能看到文档的编辑日期在迅速迭代。


截屏2023-12-04 10.18.47.png


截屏2023-12-04 10.19.16.png


从日期可以看到非常新。而且文档都是中文的,学习和查找起来都特别方便。


并且不仅仅是api文档,鸿蒙官方还提供了各种用以学习的demo, 甚至还有官方的视频教程和开发论坛。


截屏2023-12-04 10.21.55.png


截屏2023-12-04 10.22.46.png


截屏2023-12-04 10.23.09.png


截屏2023-12-04 10.23.36.png


遇到问题有各种方法可以解决,查文档,看视频课程,抄官方demo, 论坛发帖提问,简直是保姆级的官方支持!


其他



  • 鸿蒙的UI开发模式是一种响应式开发,与安卓的compose UI很像。组件的名字可能不同,但是概念上是一致的,并且鸿蒙的原生组件种类丰富也比较全。熟悉以后使用起来很方便。


build() {
Column() {
Text(this.accessText)
.fontSize(20)
.fontWeight(FontWeight.Bold)

if (!this.hasAccess) {
Button('点击申请').margin({top: 12})
.onClick(() => {
this.reqPermissionsFromUser(this.permissions);
})
} else {
Text('设备模糊位置信息:' + '\n' + this.locationText)
.fontSize(20)
.margin({top: 12})
.width('100%')
}
}
.height('100%')
.width('100%')
.padding(12)
}


  • 对应安卓的权限管理


鸿蒙有ATM,ATM (AccessTokenManager) 是HarmonyOS上基于AccessToken构建的统一的应用权限管理能力。



  • 对应安卓的SharedPreferences能力,鸿蒙有首选项能力。


截屏2023-12-04 10.27.27.png


这里就不一一列举了


我们只需要知道在安卓上有的概念,就可以在鸿蒙官方文档中去找一下对应的文档。


原理都是相通的。所以有过安卓开发经验的同学相对于前端FE来说有对客户端开发理解的优势。


要不要搞?


先看看目前的情况, 各家大厂正在积极布局鸿蒙客户端开发。


截屏2023-12-04 10.35.36.png


截屏2023-12-04 10.36.15.png


截屏2023-12-04 10.37.25.png


虽说移动端操作系统领域对安卓和iOS进行挑战的先例也有且还没有成功的先例。但是当前从国内互联网厂商的支持态度,从国际形势的情况,从华为对鸿蒙生态的投入来看。 我觉得很有搞头!
明年鸿蒙即将剔除对安卓的支持,届时头部互联网公司的大流量App也将完成鸿蒙原生纯血版的开发。


更有消息称鸿蒙PC版本也在路上了,了解信创的朋友应该能感受到这将意味着国产移动端和PC端操作系统会占有更大比例的市场。不仅仅是企业的市场行为,也是国产操作系统快速提升市占率的大好时机。


话说回来,作为安卓开发者,学习鸿蒙的成本并不高!


而对我们来说这是个机遇,毕竟技多不压身,企业在选取人才的时候往往也会偏好掌握更多技术的候选人。


如果鸿蒙起飞,你要不要考虑乘上这股东风呢?


我是张保罗,一个老安卓。最近在学鸿蒙




作者:张保罗
来源:juejin.cn/post/7308001278420320275
收起阅读 »

关于我在HarmonyOS中越陷越深这件事...

前言 上次发文已是2023年,在上一篇 前端的春天!拥抱HarmonyOS4.0🤗 - 掘金 (juejin.cn)一文中我介绍了一些鸿蒙OS知识,此文一出大家的看法也层出不穷,笔者持开放的态度对待大家对于新生态的看法。在2024年的今天,我想来说说这几个月我...
继续阅读 »

前言


上次发文已是2023年,在上一篇 前端的春天!拥抱HarmonyOS4.0🤗 - 掘金 (juejin.cn)一文中我介绍了一些鸿蒙OS知识,此文一出大家的看法也层出不穷,笔者持开放的态度对待大家对于新生态的看法。在2024年的今天,我想来说说这几个月我有哪些思考和行动。


在短短几个月的时间里,HarmonyOS已经来到了Next版本,迎来属于鸿蒙的春天。俗话说光说不练假把式,实践是试金石,我深知在做开发的这一行只有不断试错,反复的验证,才能创造新的轮子,创造力一个人无法被替代的根本。


我写这篇文章的目的不在于极力推荐大家去学习这项技术,更多的是以一个求学者的角度去阐述自己对新技术学习的心路历程。


为什么学习鸿蒙?


迷茫


笔者是25届的学生,对于学生来说最多的是时间和学习热情,自己也曾经经历过一段时间的专业方向选择困惑期,或许当人越迷茫的时候越容易听信别人的话吧,好与坏是相对的,分人也分时间,在合适的时间选择做了合适的事情这就没有什么问题了,至少学习鸿蒙这件事情对我来说,无论将来何时都会让我记忆犹新。


渴望


在学校里老师会告诉你成熟的解决方案,会告诉你应该这样做,不应该那样做,你仿佛一个机器人,进行一些机械系的学习,时间太急,急到我们只能应付相对的课程考试与学习,内容太多,多到我们最后仅靠老师给出的精简知识点去实际开发项目。这显然不是我想要的学习方式和结果...


动力源泉



有人说:这不就是Vue、React、Flutter、就是个缝合怪....



面对互联网高速发展的今天,各家博采众长,相互吸收优秀的开发思想已不是一件新鲜的事情了。


我自己学习的方向是大前端,加上之前开发的项目都是web的与小程序相关的,自己一直想尝试结合之前开发的项目开发一个基于HarmonyOS的App,听到“一次开发多端部署”这句话让我眼前一亮(很可惜这里的多端部署在4.0的开放版本是不支持的)。在接触鸿蒙的第一天,犹如我第一次接触前端开发,那种所见即所得的开发体验让我从内心里竟有了一丝“自信”,但也恰恰是这种“自信”也逐渐将我推入了深渊


坎坷与前行


在我真正尝试开发一个鸿蒙App的时候是在2023年底,我希望通过我所学的东西去做一个完整的东西并参加 2024年的计算机设计大赛


在十二月份的那几周,我不断的使用Figma进行原型的绘制,与指导老师探讨功能、确定交互逻辑,期间我也参考了大量的App类设计准则,最后发现鸿蒙的ArkUI是具有工业审美的(至少是符合我的想法),这使得我不必耗费太多的精力在从0到1的去做一些组件,仅需适配设计规范上所涉及到的即可,将更多的精力放在逻辑的完整性。


在开发过程中遇到了各种形形色色的问题,例如:http请求封装upload组件无法拿到回调、地图功能无法使用的解决方案、websocket连接不上、创建时间、地理位置编码......


所幸所有问题都有解决办法,只是过程真的很痛苦,反复尝试、不断验证,我很喜欢在夜晚写代码,天空越黑星星越亮,当空气都变得安静时,我的内心反而会激发一种向上的力量来支撑我,可能是因为自己太想进步了吧(hhhh),在开发的App的日子里,每天都很崩溃,但是我的老师、朋友也都在鼓励我,我又不太想都付出这么多了又轻易放弃......


在2024年的4月,我去看了武汉的樱花,距离比截止还有七天不到,因为我实在撑不住了,在这个时间点,与其逼自己一把,不如放自己一马,于是和朋友相约武汉一起赏樱......


69166f616e7b27afdad013bd61aabe2.jpg


在五月,我得知自己的鸿蒙原生应用拿到了省一等奖,内心是非常激动的,但同时有一些失落的是,我无缘继续参与今年七月的国赛,因为赛制名额原因,我无法被上推。


比赛结束后,我开始准备投递简历实习,但最终都石沉大海,行业现状让我十分焦虑,我时常觉得自己能力不足......


星河璀璨,紧接着HarmonyOS Next 正式面世,我内心不断在问自己,难道我就所有的努力都要止步于此了吗?


....


学习现状


六月我的一位学习伙伴邀请我和他们一起开发研究院的一款基于ArkUI-X的软件,这将对我来说是一个非常宝贵的机会,几个月的时间,兜兜转转回到了我梦开始的地方......


240624-3.jpg


Harmony Next 正式beta发布已过去半个多月了,这期间我了解到了很多之前没有学习到的新东西,鸿蒙提供了3w+的api,这些api有什么用?我打个比方,你要做满汉全席首先得要食材,其次需要烹饪技巧。而鸿蒙他会为你提供所需的所有食材,但是,你要做松鼠桂鱼还是佛跳墙,完全取决于你自己!至于烹饪技巧,鸿蒙开设了相关的做菜视频,你可以从中学习。


笔者也看到了许多鸿蒙原生开发者,一起交流关于鸿蒙的技术问题,在这里我附上一个宝藏鸿蒙优秀案例仓库
HarmonyOS NEXT应用开发案例集


感悟



真正的强大不是对抗,而是允许和接受,接纳挫折,接纳无常,接纳情绪,接纳不同,当你允许一切发生之后,就会不再那么尖锐,会渐渐变得柔和。



Per aspera ad astra. 没有人能熄灭满天星光,鸿蒙让我见证了从星光微微到星河璀璨,它教会我的不是一项技术,更多的是教会我如何去解决问题,去思考问题,当问题没有解决方案的时候,是否自己能够结合现有资源去提出自己的想法,并不断进行验证与总结。


路上会有风


会有浪漫


会有悲伤


会有孤独


也会有无尽的星辰与希望


作者:彼日花
来源:juejin.cn/post/7390956576180109312
收起阅读 »

关于鸿蒙开发,我暂时放弃了

起因 在最近鸿蒙各种新闻资讯说要鸿蒙不再兼容android之后,我看完了鸿蒙视频,并简单的撸了一个demo。 # 鸿蒙HarmonyOS从零实现类微信app效果第一篇,基础界面搭建 # 鸿蒙HarmonyOS从零实现类微信app效果第二篇,我的+发现页面实...
继续阅读 »

image.png


image.png


起因


在最近鸿蒙各种新闻资讯说要鸿蒙不再兼容android之后,我看完了鸿蒙视频,并简单的撸了一个demo。


企业微信截图_6f8acb94-bd68-4f56-9460-4a59d2370a4a.png



鸿蒙的arkui,使用typescript作为基调,然后响应式开发,对于我这个old android来说,确实挺惊艳的。而且在模拟器中运行起来也很快,写demo的过程鸡血满满,着实很愉快。


后面自己写的文章,也在掘金站点上获得了不错的评价。


企业微信截图_fa34f233-af43-4567-8dac-57ef5666f1bd.png


image.png


打击


今天下午,刚好同事有一个遥遥领先(meta 40 pro),鸿蒙4.0版本


怀着秀操作的想法,在同事手机上运行了起来。very nice。 一切出奇的顺利。


but ...


尼玛,点击的时候,直接卡住不对,黑屏。让人瞬间崩溃。


本着优先怀疑自己的原则,我找了一个官方的demo。 运行起来。


额...


尼玛。还是点击之后卡住了,大概30s之后,才跳转到新的页面。


image.png


这一切,让我熬夜掉的头发瞬间崩溃。


放弃了...


放弃了...


后续


和其他学习鸿蒙的伙伴沟通,也遇到了同样的问题,真机不能运行,会卡线程。但是按下home键,再次回到界面,页面会刷新过来


我个人暂时决定搁置对于鸿蒙开发的学习了,后续如果慢慢变得比较成熟之后,再次接触学习吧。



后续个人计划:




  • 1、还是会持续关注后续版本是否真机能运行,传言api 10对黑屏和真机无法运行的修复了。奈何官方所有渠道的编译器都没有api 10 的模拟器,真机4.0按道理是支持api10,但是还是黑屏,再持续观察吧。插个眼

  • 2、为了贯彻执行持续学习。后续可能会持续更新jetpack compose相关内容,包含且不局限于 compose desktop以及multi platform


最新情报:有网友告知我,在meta60上是运行没问题的,可能是最新版4.0是ok的,那么结论就是目前真机适配不完善


作者:王先生技术栈
来源:juejin.cn/post/7304538094736343052
收起阅读 »

使用uniapp制作安卓app容器

1. 背景项目需要做一个安卓app,而且不需要上架应用市场,部门也没有安卓开发,想着就套个webview就行了吧。没有选择react native之类的是因为这些工具需要安装很多环境工具,我只是开发一个壳子没必要这么复杂。用webview也方便快速修复页面问题...
继续阅读 »

1. 背景

项目需要做一个安卓app,而且不需要上架应用市场,部门也没有安卓开发,想着就套个webview就行了吧。没有选择react native之类的是因为这些工具需要安装很多环境工具,我只是开发一个壳子没必要这么复杂。

webview也方便快速修复页面问题。

所以最后选择了uniapp,但是uniapp本身就是套在一个大的webview下的, 所以再套一个webview难免会有一些意想不到的问题,下面就是一些踩过的坑记录。

2. 项目初始化

新建项目就默认模板就行,我只需要壳子。

image.png 启动了之后可以看到有两个调试工具

image.png

第一个就是网页上常用的vue调试工具,可以看到vue组件属性啥的,第二个就是类似chrome的控制台,但是无法查看元素,还有就是必须让设备和电脑在同一个网段下才行,不然连接不上。

hbuilder的控制台本身也有一些输出,比如页面的console

image.png

但是这里输出对象的时候不是很方便查看,如果你需要的话就打开上面说的第二个调试工具。

3. webview使用

整个项目很简单,大概就这样一个页面

<template>
<web-view :src='PROJECT_PATH' @message="onMessage">web-view>
template>
<script>
// ...
script>

3.1 网页与app通信

这是最重要的一个功能,可以参考官方文档

网页和app交互总结起来就是这两点:

  • 网页 -> APPwindow.uni.postMessage();
  • APP -> 网页webview.evalJS()

3.1.1. 网页 -> APP

首先要在项目中引入uni.webview.js,这个就相当于jsbridge,可以让网页操作uniapp

初始化完成后会在window上挂载一个uni对象,通过uni.postMessage就能往app发送消息,app中监听onMessage就行。

这里有几个小坑:

  1. 发送的格式window.uni.postMessage({ data: 数据 }),必须要有个字段data,这样app才能收到数据。源码

image.png 2. 发送的数据不需要序列化成字符串,uniapp会转换json。 3. appmessage事件中接收到事件参数应该这样解构

function onMessage(e) {
const {
type,
data
} = e.detail.data[0]
}

3.1.2. APP -> 网页

app向网页传输消息就直接调用网页的js就行了。这里我统一封装了一个函数:

// app向网页发送消息
const deliverMessage = (msg) => {
// 调用webview中的deliverMessage函数
// 这个函数是我在网页挂载的一个全局函数,调用deliverMessage后会触发页面中的一些事件
currentWebview.evalJS(`deliverMessage(${JSON.stringify(msg)})`)
}

上面的代码例子中出现的currentWebview需要我们自己去获取。

// vue2中
const rootWebview = this.$scope.$getAppWebview()
this.currentWebview = rootWebview.children()[0]

// vue3中
import {
getCurrentInstance,
ref,
} from "vue";
const currentWebview = ref(null)
const vueInstance = getCurrentInstance()
const rootWebview = vueInstance.proxy.$scope.$getAppWebview()
currentWebview.value = rootWebview.children()[0]

这里也有一个坑,rootWebview.children()如果你一渲染就获取是无法获取到webview实例的,具体原因没有深入研究,估计是异步的原因

这里提供两个思路:

  1. 加一个定时器,延迟获取webview,这个方法虽然听起来不保险,但是实际测试还是挺稳当的。关键是简单
setTimeout(() => {
currentWebview.value = rootWebview.children()[0]
}, 1000)
  1. 你要是觉得定时器不保险,那就使用plusapi手动创建webview。但是消息处理这块比较麻烦。官网参考
<template>

template>
// 我这里vue3为例
onMounted(() => {
plus.globalEvent.addEventListener('plusMessage', ({data: {type, args}}) => {
// 是网页调用uni的api
if(type === 'WEB_INVOKE_APPSERVICE') {
const {data: {name, arg}} = args
// 是发送消息事件
if(name === 'postMessage') {
// arg就是传过来的数据
}
}
})
const wv = plus.webview.create("", "webview", {
'uni-app': 'none',
})
wv.loadURL(网页地址)
rootWebview.append(wv);
})

plus.globalEvent.addEventListener这个是翻源码找到的,主要是我不想改uni.webview.js的源码,所以只有找到正确的监听事件。

WEB_INVOKE_APPSERVICEuniapp内部定义的一个名字,反正就是用来交互操作的命名空间。

这样基础的互操作就有了。

3.1.3. 整个流程

  1. 网页调用window.uni.postMessage({ data }) => app监听(用组件的onMessage或者自定义的globalEvent
  2. app调用网页定义的函数deliverMessage并传递参数,网页中的deliverMessage内部处理监听
// 网页中的deliverMessage
window.deliverMessage = (msg) => {
// 触发网页注册的监听器
eventListeners.forEach((listener) => {

});
};

3.2. 返回拦截

默认情况下,手机按下返回键,app会响应提示是否退出,但是实际我需要网页进入二级路由的时候,按下手机返回键是返回上一级路由而不是退出。当路由是一级路由时才提示是否退出app

import {
onBackPress,
onShow,
} from '@dcloudio/uni-app'
// 页面当前的路由信息
const pageRoute = shallowRef()
onBackPress(() => {
// tab页正常app返回逻辑
if (pageRoute.value?.isTab) {
return false
} else {
// 二级路由拦截app返回
return true
}
})

pageRoute是页面当前路由信息,页面通过监听路由变化触发routeChange事件,将路由信息传给app。当按下返回键的时候,判断当前路由配置是不是tab页,如果是就正常退出,不是就拦截返回。

4. 总结

有了通信功能,很多操作就可以实现了,比如获取设备safeArea,获取设备联网状态等等。


作者:头上有煎饺
来源:juejin.cn/post/7313740940773097482

收起阅读 »

35岁,是终点?还是拐点?

35岁,是终点还是拐点,取决于我们对生活和事业的态度、目标以及行动。这个年龄可以看作是一个重要的转折点,具有多重意义和可能性。 很多人在35岁时,已经在自己的职业生涯中建立了一定的基础,可能达到了管理层或专家级别。如果你还是一个基层员工,那你要反思一下,你的...
继续阅读 »

35岁,是终点还是拐点,取决于我们对生活和事业的态度、目标以及行动。这个年龄可以看作是一个重要的转折点,具有多重意义和可能性。



很多人在35岁时,已经在自己的职业生涯中建立了一定的基础,可能达到了管理层或专家级别。如果你还是一个基层员工,那你要反思一下,你的职业生涯规划可能出了问题,工作能力与人情世故为什么都没有突破?是否在某个领域深耕多年?



有些人可能会选择在这个年龄段重新评估自己的职业,考虑转型或创业,寻找新的挑战和机遇。这是个不错的想法,基于过往积累的经验和能力,现在自媒体发达,个人的创业成本低到0也可以创业,就是你能坚持多久,给自己多长时间的规划,又想达成怎样的目标。



35岁通常是家庭责任较重的时期,可能要照顾孩子和父母,家庭生活会影响个人的时间和精力分配。这是35岁中年油腻大叔最难的地方,上有老,下有小,自己还是很渺小。这是最痛苦的,家里顶梁柱,连倒下的资格都没有。



在自我认知层面,35岁的人通常对自己的优缺点、兴趣爱好有更清晰的认识,知道自己想要什么,不想要什么。所以这个年龄段的人可能会更加关注健康和自我提升,进行一些以前没有时间或精力去做的事情,如学习新技能、锻炼身体等。


举个例子,现在的 AI 和鸿蒙,热得烫手,是程序员学习的新方向,不同于区块链、AR/VR这些事件,AI 落地各行各业产品,未来是 XXX+AI 的时代,不管你正在做 JAVA 后端开发,还是即将学习 JAVA 开发,你都逃脱不了 AI。



鸿蒙就不用说了,国产操作系统,多少年了,国人终于可以用上自己的操作系统,这不是事件,不是概念,是必然趋势,一个新的技术领域将要开启,我是很坚信的,拒绝反驳。



经过多年的生活和工作经历,相信大多数人已经积累了较为丰富的经验和智慧,对待问题更加冷静和理性。


35岁既不是终点,也不是绝对的拐点,而是人生旅途中一个重要的里程碑。它为未来的发展提供了丰富的经验和更明确的方向。关键在于如何利用过去的积累,进行自我调整和规划,为未来的生活和事业开辟新的道路。以上是 V哥的浅见,突出想到这些问题,就记录了下来,你有什么见解,咱们评论区讨论,拍砖请下手轻点,欢迎关注威哥爱编程,程序员路上愿与你成为一起前行的基友。


作者:威哥爱编程
来源:juejin.cn/post/7383342927509471283
收起阅读 »

Android 复杂项目崩溃率收敛至0.01%实践

一、崩溃收敛机制 1、创建修BUG分支 在我们的项目中,每个版本发布之后,我们会创建一个opt分支,用于修复线上崩溃以及业务逻辑BUG。 开发过程中,一个APP可能同时并行开发多个需求,每个需求上线的预期时间可能会有不同。但是这个opt分支我们会保证在下个版本...
继续阅读 »

一、崩溃收敛机制


1、创建修BUG分支


在我们的项目中,每个版本发布之后,我们会创建一个opt分支,用于修复线上崩溃以及业务逻辑BUG。


开发过程中,一个APP可能同时并行开发多个需求,每个需求上线的预期时间可能会有不同。但是这个opt分支我们会保证在下个版本一定上线,QA同学也会在每个版本发布前预留测试opt分支的时间。


2、每天早晨查看Dump后台


每天上班第一件事就是查看DUMP后台,收集昨天线上发生的DUMP崩溃,具体的堆栈分配给对应的业务负责人。


业务负责人收到崩溃之后,会优先跟进排查。排查下来如果相对好修复,会第一时间直接修复掉,并提交到opt分支。如果排查下来发现,较难定位或者耗时较久,则需要给出修复预期。也可以将Bug转为技术优化,作为专项推进。因为确实有一些Bug需要通盘考虑,所有业务配合。


二、崩溃容灾机制


1、背景


我们为什么要开发一套崩溃容灾逻辑?


在对线上崩溃进行收敛时,我们发现线上有几类崩溃是我们在应用无法修复的。


例如:案例一


java.lang.NullPointerException: Attempt to invoke virtual method 'boolean android.content.ClipDescription.hasMimeType(java.lang.String)' on a null object reference
at android.widget.TextView.canPasteAsPlainText(TextView.java:15065)
at android.widget.Editor$TextActionModeCallback.populateMenuWithItems(Editor.java:4692)
at android.widget.Editor$TextActionModeCallback.onCreateActionMode(Editor.java:4627)

案例二:小米手机上出现


java.lang.NullPointerException:Attempt to invoke virtual method 'int android.text.Layout.getLineForOffset(int)' on a null object reference

案例三:集成华为推送SDK后,偶现


ava.lang.RuntimeException:Unable to start activity ComponentInfo{com.netease.popo/com.huawei.hms.activity.BridgeActivity}:
android.util.AndroidRuntimeException: requestFeature() must be called before adding content"

案例四:BadTokenException


android.view.WindowManager$BadTokenException:Unable to add window -- token android.os.BinderProxy

我们大致将以上问题划分为四类:



  • 我们认为是系统异常,应用层仅能在使用的位置try cache,有些崩溃甚至无处try cache;

  • 排查下来发现仅在某个厂商的手机上出现;

  • 集成的一些第三方SDK所引入,依赖对方修复,时间上不好掌控;

  • 由于Android系统的一些机制引发的崩溃,如弹出弹框时,恰好依赖的Actity正在销毁。业务层希望弹框可以不弹出但不要崩溃,可是系统最终是抛出来一个BadTokenException。我们可以在使用DiaLog时做判断,但是总会用有同学忘记。


基于以上我们思考是否可以开发一个框架,将这些崩溃统计进行拦截,使其不影响用户的使用。


2、技术方案


作为Android开发应该都比较清楚Handler机制。我们的崩溃容灾主要是利用了Handler机制。
具体的逻辑图如下:


popo_2022-05-15  16-16-01.jpg



  • 应用启动后,初始化崩溃白名单(应用内置,也支持服务端动态下发)

  • 通过Handler#post()方法,向主线程中发送一条消息。

  • 在Runnable#run()方法中,执行一个死循环逻辑

  • 死循环中逻辑中使用try cache将Looper.loop()防护

  • 这样只要应用的进程不结束,相当于任务一直执行在我们前面post的消息中

  • 只是我们在这个消息中,再次执行了Looper.loop()方法,执行后续消息队列中所有的消息

  • 一旦后续所有消息遇到崩溃,会先被try cache捕获。

  • 然后判断崩溃信息是否在我们的白名单中,一旦在白名单中直接捕获掉,不向外抛异常,逻辑会回到外部的死循环中,继续执行Looper.loop()方法获取后续的消息。这样就保证了逻辑的连贯,后续的事件可以继续处理。

  • 不再我们的白名单中则继续将这个异常throw出去。


3、现状


崩溃拦截框架上线至今几年的时间,积累的崩溃种类目前已经达到81种。


比较典型的除了上面介绍的几类崩溃以外,还有如我们在适配Android 12的SplasScree时,遇到的TransferSplashScreenViewStateItem 相关的错误


java.lang.IllegalArgumentException: Activity client record must not be null to execute transaction item: android.app.servertransaction.TransferSplashScreenViewStateItem@de845fa
at android.app.servertransaction.ActivityTransactionItem.getActivityClientRecord(ActivityTransactionItem.java:85)
at android.app.servertransaction.ActivityTransactionItem.getActivityClientRecord(ActivityTransactionItem.java:58)
at android.app.servertransaction.ActivityTransactionItem.execute(ActivityTransactionItem.java:43)
at android.app.servertransaction.TransactionExecutor.executeCallbacks(TransactionExecutor.java:149)
at android.app.servertransaction.TransactionExecutor.execute(TransactionExecutor.java:103)
at android.app.ActivityThread$H.handleMessage(ActivityThread.java:2708)
at android.os.Handler.dispatchMessage(Handler.java:114)
at android.os.Looper.loopOnce(Looper.java:206)
at android.os.Looper.loop(Looper.java:296)

按照过往的手段,我们仅能等待Google官方来修复这个错误,或者先下线掉这个错误。本框架可以直接将其进行拦截,同时用户无感知。


三、其他崩溃收敛


我们针对自身业务特点,大致梳理以下几种业务中非常常见的崩溃场景,提醒每一位同学注意。同时内部维护了一个研发质量表,每个需求提测时我们会过一遍研发质量表格,提醒同学注意相关代码质量与性能。


1、空指针问题


NPE应该是最常见的问题了。针对NPE的问题,我们的解决方式有:



  • 推荐组内所有同学习惯使用注解@NonNull和@Nullable

  • 推荐大家使用Kotlin;并且在Java调用kotlin方法时,一定要注意Kotlin方法是否要求入参不为空

  • 从List、Map中取到的对象,使用前必须判空

  • 业务代码需要将Context传给第三方工具类,传入之前必须判空

  • 对象建议声明为final或者val,防止后续其他位置置空引起NPE

  • 外接传入的对象使用前必须判空

  • 基于AS插件进行检测


2、IndexOutBoundsException


角标越界异常在平时开发中也特别常见,在我们的业务中常见于集合以及Span操作



  • 集合传入index时需要判断是否在[0, size]内

  • 操作Spannable接口setSpan方法时,需要start与end的数值不会超过长度,同时不能够为负数


3、ConcurrentModificationException 并发修改异常


并发修改异常在复杂的业务中,是非常容易遇到的。通常有两个场景容易触发,分别是foreach循环中直接调用remove方法移除元素,以及线程不安全环境下使用线程不安全集合。


针对并发修改异常:



  • 我们推荐在遍历集合时,可以new一个新的List集合,将要遍历的List集合作为参数传入,然后遍历新的集合。这样原集合在遍历时改变也不会抛异常

  • 使用线程安全的集合,如CopyOnWriteArraylist、ConcurrentHashMap等


4、系统服务(FrameWork API)



  • 调用系统服务通常需要跨进程通信,其内部很可能会抛异常,所有调用系统服务的地方都必须使用try cache。cache异常必须写入日志文件,根据业务重要性判断是否需要上报埋点数据;

  • 系统服务频繁调用时可能会引发ANR,这点也需要特别注意;


5、数据库类问题


由于我们的业务重度依赖数据库,所以数据库相关的问题占比也比较高。
主要有以下几类问题:


CursorWindowAllocationException 2048问题:


com.tencent.wcdb.CursorWindowAllocationException: 
Cursor window allocation of 2048 kb failed. total:8159,active:49
at com.tencent.wcdb.CursorWindow.<init>(SourceFile:127)

针对CursorWindowAllocationException,在我们的工程中主要是短时间内大量的内存申请。 解决方案是基于SQL监控,统计工程中SQL执行的数量,基于SQL语句针对性的优化相关逻辑,将SQL语句执行数量降低了90%以上,这个问题线上不再复现。


存储空间不足


Caused by: 
com.tencent.wcdb.database.SQLiteFullException:database or disk is full (code 13,errno 0):
at com.tencent.wcdb.database.SQLiteConnection.nativeExecute(Native Method)
at com.tencent.wcdb.database.SQLiteConnection.execute(SourceFile:728)
at com.tencent.wcdb.database.SQLiteSession.endTransactionUnchecked(SourceFile:436)
at com.tencent.wcdb.database.SQLiteSession.endTransaction(SourceFile:400)
at com.tencent.wcdb.database.SQLiteDatabase.endTransaction(SourceFile:533)
at com.tencent.wcdb.room.db.WCDBDatabase.endTransaction(SourceFile:100)

针对存储空间不足,在我们的APP中主要是添加手机存储空间检测,当空间不足时引导用户清理。


数据库损坏


com tencent wcdb database.SQLiteDatabaseCorruptException: database disk image is malformed (code 11, errno 0): 
at com.tencent.wcdb.database.SQLiteConnection.nativePrepareStatement(Native Method)
at com.tencent.wcdb.database.SQLiteConnection.acquirePreparedStatement(SQLiteConnection.java:1004)
at com,tencent.wcdb.database.SQLiteConnection.executeForString(SQLiteConnection.java:807)
at com.tencent.wcdb.database.SQLiteConnection.setJournalMode(SQLiteConnection.java:424)
at com.tencent.wcdb.database.SQLiteConnection.setWalModeFromConfiguration(SQLiteConnection.java:414)
at com.tencent.wcdb.database.SQLiteConnection.open(SQLiteConnection.java:289)
at com.tencent.wcdb.database.SQLiteConnection.open(SQLiteConnection.java:254)
at com,tencent.wcdb.database.SQLiteConnectionPool.openConnectionLocked(SQLiteConnectionPool.java:603)
at com.tencent.wcdb.database.SQLiteConnectionPool.open(SQLiteConnectionPool.java:225)
at com.tencent.wcdb.database.SQLiteConnectionPool.open(SQLiteConnectionPool.java:217)
at com.tencent.wcdb.database.SQLiteDatabase.openInner(SQLiteDatabase.java:1002)

数据库损坏我们是引入了修复工具进行修复,同时对数据库损坏崩溃进行拦截。修复完成后退出到登录页面引导用户重新登录。


数据库的崩溃问题一度在我们的工程中占比超过50%,所以我们有启动数据库优化专项投入大量时间。针对数据库优化的具体介绍可以查看:Android 数据库系列三:复杂项目SQL治理与数据库的优化总结
,内部有更加详细的介绍。


四、OOM问题收敛


1、OOM介绍


OOM的问题在Android中也是非常常见了,所以这里单独拎出来说说。
OOM产生的条件:待申请的内存大于系统分配给应用的剩余内存。
OOM原因大致可以归为以下几类



  • 堆内存分配失败

    • 堆内存溢出

    • 没有足够的连续内存空间



  • 创建线程失败(pthread_create (1040KB stack) failed: Try again)

  • FD数量超出限制

  • Native虚拟内存OOM


2、内存泄露监控


线上内存泄露的监控我们是使用的快手的KOOM。
KOOM原理这里笔者就不详解了,社区内也有专门分析的文章,大家可以找找看,不过还是建议去读读源码,写的挺不错的。



指路地址:github.com/KwaiAppTeam…



将KOOM分析的报告上报到我们的后台中,有专门的同学每周会排时间跟进。


3、全局浮窗实时显示APP当前总体内存


除了线上的监控,我们也有一个自研的开发者工具。工具有一个浮窗功能,我们会在浮窗上实时显示当前应用的内存信息(每秒采集一次)。数据主要是通过获取Debug.MemoryInfo#getTotalPss()。与Android Studio Profile中Memory数据基本一致。同时在UI层面我们还会设置一个阈值,超过阈值就会将浮窗中内存的数值颜色改为红色,旨在提醒开发同学关注内存变化。


通过实时显示内存我们在开发过程中,就可以发现一些问题。如我们再进入某一个业务时,发现内存会固定涨50M+,基于此开发同学去排查,发现了很多优化点。线下发现这个问题的意义就是可以质量左移,避免到线上影响到用户。


4、线程数量监控与收敛


获取线程数量,我们可以读取文件/proc/[pid]/status 中的线程数量,代码大致如下:


public static String readThreadStatus(String pid){
RandomAccessFile reader2= null;
try {
reader2 = new RandomAccessFile("/proc/" + pid + "/status", "r");
String str;
while ((str = reader2.readLine()) != null) {
if (str.contains("Threads")) {
return str;
}
}
reader2.close();
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
if (reader2 != null) {
reader2.close();
}
} catch (Exception e) {
e.printStackTrace();
}
}
return "";
}


在前面提到的开发者工具的浮窗中,我们也有一行用来实时显示线程的数量。不过在我们的工具中,我们没有使用上面的方法,而是使用:Thread.getAllStackTraces();


使用Thread.getAllStackTraces();获取的数量比 /proc/[pid]/status 获取的少,但是在我们的工程中,我们主要关注Java线程而且通过Java线程的数量的波动也能观察到App当中线程的变化。而且Thread.getAllStackTraces()会返回Thread对象,以及堆栈数据这个对我们更加有用。


在开发者工具我们有一个单独的页面可以实时查看线程的ID、名称以及对应堆栈。在线上我们会间隔一段收集一次线程数据上报到我们的后台中。


在笔者过往的开发经历中,遇到过一次由于线程数量较多直接导致应用崩溃的情况,即某个独立业务使用OkHttp没有创建OkHttpClient单例对象,而是每次接口回调都创建一个新的client...


定位过程比较简单,在特定的场景下,可以看到浮窗中的线程数量基本处于线性增长,通过开发者工具查看线程列表可以直接看到非常多的OkHttp相关的线程。


5、FD 数量监控


获取FD数量大致可以通过以下代码


public static int getCurrentFdSize() {
int size = 0;
File dir = new File("/proc/self/fd");
try {
File[] fds = dir.listFiles();
if (fds != null) {
size = fds.length;
for (File fd : fds) {
if (Build.VERSION.SDK_INT >= 21) {
MLog.d("message", Os.readlink(fd.getAbsolutePath()));
}
}
}

} catch (Exception e) {
e.printStackTrace();
}
return size;
}


同时在KOOM中每次分析的结果中会携带所有FD句柄信息,所以我们没有单独做额外的监控了,直接查看KOOM的解析数据。


笔者仅遇到过一次由于FD句柄超限导致的异常。异常信息如下


java.lang.RuntimeException: Could not read input channel file descriptors from parcel.
at android.view.InputChannel.nativeReadFromParcel(Native Method)
at android.view.InputChannel.readFromParcel(InputChannel.java:148)
at android.view.InputChannel$1.createFromParcel(InputChannel.java:39)at android.view.InputChannel$1.createFromParcel(InputChannel.java:37)
at com.android.internal.view.InputBindResult.<init>(InputBindResult.java:68)
at com.android.internal.view.InputBindResult$1.createFromParcel(InputBindResult.java:112)at com.android.internal.view.InputBindResult$1.createFromParcel(InputBindResult.java:110)at com.android.internal.view.IInputMethodManager$Stub$Proxy.startInputOrWindowGainedFocus(IInp
at android.view.inputmethod.InputMethodManager.startInputInner(InputMethodManager.java:1361)
at android.view.inputmethod.InputMethodManager.onPostWindowFocus (InputMethodManager.java:1631)
at android.view.ViewRootImpl$ViewRootHandler.handleMessage(ViewRootImpl.java:4259)
at android.os.Handler.dispatchMessage(Handler.java:109)
at android.os.Looper.loop(Looper.java:166)

后面经过排查进入内置浏览器查看某网页,FD句柄的数量会瞬间飙升。通过遍历 /proc/pid/fd 文件发现大多是都是Socket。后面排查下来是应用内某个前端页面存在Bug,疯狂new Socket...


五、总结


以上简单介绍了一下我们在工程中如何针对各类崩溃信息进行收敛,值得欣喜的是经过几年的努力基本可以将崩溃控制在万一。回过头来看很多问题还是遇到问题-解决问题的思路,这就依赖我们的开发同学本身所写的代码质量要高,否则就会陷入到写Bug-改Bug这样的循环中。


所以我们也在积极探索如何通过前期的review,工具扫描的方式尽量降低线上问题的发生概率。尽可能将问题提前暴露。不过这方面目前还没有建设的特别好,人工review实验了一段时间发现在一个业务较多的团队的实施起来很难,没有时间不说盯着代码review也不见得就能发现一些逻辑上的异常。好在公司内其他团队再研究基于AI的代码扫描,后续计划接入到当项目中看看。


作者:半山居士
来源:juejin.cn/post/7377200392059617295
收起阅读 »

【干货分享】uniapp做的安卓App如何加固

2023年了,uniapp还有人用吗? 对于这个问题,只能说,2023年了,使用uniapp去开发APP的人越来越多了。 一方面在于全平台兼容确实很香,对于一些小项目或者时间要求比较高的项目来说,可以节省大量的时间与精力,也为公司节约了成本;另一方面,开发速度...
继续阅读 »

2023年了,uniapp还有人用吗?


对于这个问题,只能说,2023年了,使用uniapp去开发APP的人越来越多了。


一方面在于全平台兼容确实很香,对于一些小项目或者时间要求比较高的项目来说,可以节省大量的时间与精力,也为公司节约了成本;另一方面,开发速度非常快。就像前面说的,对于一些小项目来说,几天就可以搞定,而对于一些大项目来说,性能和原生大差不差,而且全平台兼容的特性也可以弥补这点;最后就是社区,里面有很多优质的框架和插件,节约了大量的时间(时间就是发量!!!),更重要的是,社区出人才,总能找到人和你一起吐槽(bushi)睿智的官方......


总而言之,虽然uniapp文档一般好,bug一般多,更新像拆炸弹,但是,对于很多人来说,还是很有意义的。所以用的人还是很多。


但是目前随着各种商城上架政策的严格审查,对于加固等需求也慢慢起来了,所以今天我们来讲讲uniapp开发的安卓APP要如何加固。


加固原理


先来看看一般加固会从哪几个方向进行加固


image.png


而我们如果把uniapp制作的安卓APP在加固上其实大同小异--只要是apk或者aab格式都可以,所以我们就基于这个原理来进行加固。


加固流程


01 代码混淆


按照一般的思路,先给他混淆一下子。使用代码混淆工具来混淆 JavaScript 代码,以使其难以被逆向工程和破解。常用的混淆工具包括 ProGuard 和 DexGuard。在 UniApp 中,你可以在打包安卓应用时配置 ProGuard 来进行代码混淆。示例代码如下所示,在项目根目录下的 uniapp.pro 文件中添加以下配置:


-keep class com.dcloud.** { *; }
-keep public class * extends io.dcloud.* {
*;
}

02 加固资源文件 & 防止调试和反调试


加固资源文件: 将敏感资源文件(如证书、配置文件等)进行加密或混淆,以防止被攻击者获取。可以使用第三方工具对资源文件进行加密,或者自定义加密算法来保护资源文件的安全


防止调试和反调试: 这一步可以使用第三方库或自定义代码来实现这些保护措施。比如说,可以检测应用程序是否在调试模式下运行,并在调试模式下采取相应的措施,例如关闭应用程序或隐藏敏感信息。


import android.os.Debug;

public class DebugUtils {
public static boolean isDebugMode() {
return Debug.isDebuggerConnected();
}
}

就是说,在应用程序中调用 DebugUtils.isDebugMode() 方法,可以根据返回值来判断应用程序是否在调试模式下运行,并采取相应的措施。


03 加密敏感数据


我们直接使用PBEWithMD5AndDES 算法对数据进行加密和解密。使用的时候,你可以调用 EncryptionUtils.encrypt(data) 方法来加密敏感数据,并调用 EncryptionUtils.decrypt(encryptedData) 方法来解密数据。记得将 PASSWORDSALT 替换为你自己的密码和盐值(重要!!!)。


import javax.crypto.Cipher;
import javax.crypto.SecretKey;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.PBEKeySpec;
import javax.crypto.spec.PBEParameterSpec;
import java.security.spec.KeySpec;
import java.util.Base64;

public class EncryptionUtils {
private static final String ALGORITHM = "PBEWithMD5AndDES";
private static final String PASSWORD = "your_secret_password"; // 自定义密码,请更换为自己的密码
private static final byte[] SALT = {
(byte) 0x4b, (byte) 0x6d, (byte) 0x7d, (byte) 0x15,
(byte) 0x78, (byte) 0x56, (byte) 0x34, (byte) 0x22
}; // 自定义盐值,请更换为自己的盐值

public static String encrypt(String data) {
try {
KeySpec keySpec = new PBEKeySpec(PASSWORD.toCharArray(), SALT, 65536);
SecretKey secretKey = SecretKeyFactory.getInstance(ALGORITHM).generateSecret(keySpec);
Cipher cipher = Cipher.getInstance(ALGORITHM);
PBEParameterSpec parameterSpec = new PBEParameterSpec(SALT, 100);
cipher.init(Cipher.ENCRYPT_MODE, secretKey, parameterSpec);
byte[] encryptedBytes = cipher.doFinal(data.getBytes("UTF-8"));
return Base64.getEncoder().encodeToString(encryptedBytes);
} catch (Exception e) {
e.printStackTrace();
}
return null;
}

public static String decrypt(String encryptedData) {
try {
KeySpec keySpec = new PBEKeySpec(PASSWORD.toCharArray(), SALT, 65536);
SecretKey secretKey = SecretKeyFactory.getInstance(ALGORITHM).generateSecret(keySpec);
Cipher cipher = Cipher.getInstance(ALGORITHM);
PBEParameterSpec parameterSpec = new PBEParameterSpec(SALT, 100);
cipher.init(Cipher.DECRYPT_MODE, secretKey, parameterSpec);
byte[] decodedBytes = Base64.getDecoder().decode(encryptedData);
byte[] decryptedBytes = cipher.doFinal(decodedBytes);
return new String(decryptedBytes, "UTF-8");
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
}

04 防止篡改


我们使用 SHA-256 哈希算法计算数据的哈希值。使用的时候,可以调用 IntegrityUtils.calculateHash(data) 方法来计算数据的哈希值,并将其与原始的哈希值进行比较,以验证数据的完整性。例如:


String data = "Hello, world!";
String originalHash = "2ef7bde608ce5404e97d5f042f95f89f1c232871";
String calculatedHash = IntegrityUtils.calculateHash(data);

boolean isIntegrityVerified = IntegrityUtils.verifyIntegrity(data, originalHash);
if (isIntegrityVerified) {
System.out.println("Data integrity verified.");
} else {
System.out.println("Data has been tampered with!");
}

05 签名功能


补充一个Android签名。


1)简介


本工具用于对android加固后的apk进行重新签名。


版本文件备注
Windows版apk签名工具压缩包.exe该版本包含Java运行环境,不需要额外安装。
通用版dx-signer-v1.9r.jar该版本需要Java 8+的运行环境,请依照操作系统进行安装:Adoptium

本工具依照Apache 2.0 协议开源,可以在这里查看源码github.com/dingxiangte…



使用说明



  1. 下载签名工具dx-signer.jar,双击运行。

  2. 选择输入apk、aab文件。

  3. 选择签名的key文件,并输入key密码。

  4. 选择重签后apk、aab的路径,以apk结束。如:D:\sign.apk

  5. 点击“签名”按钮,等待即可签名完成。


ps:如果有alias(证书别名)密钥的或者有多个证书的,请在高级tab中选择alias并输入alias密码


2)多渠道功能简介


多渠道工具兼容友盟和美团walle风格的多渠道包,方便客户把APP发布到不同的应用平台,进行渠道统计。



使用说明



  1. 在app中预留读取渠道信息的入口,具体见5.2.2读取渠道信息

  2. 在5.1.1的签名使用基础上,点击选择渠道清单

  3. 选择清单文件channel.txt。具体文件格式见5.2.3

  4. 点击签名,等待生成多个带签名的渠道app


读取渠道信息


顶象多渠道工具兼容友盟和美团walle风格的多渠道包,下面是两种不同风格的渠道信息读取方法。选其中之一即可


读取渠道信息:UMENG_CHANNEL

输出的Apk中将会包含UMENG_CHANNELmata-data



name="UMENG_CHANNEL"
android:value="XXX" />


可以读取这个字段。


public static String getChannel(Context ctx) {
String channel = "";
try {
ApplicationInfo appInfo = ctx.getPackageManager().getApplicationInfo(ctx.getPackageName(),
PackageManager.GET_META_DATA);
channel = appInfo.metaData.getString("UMENG_CHANNEL");
} catch (PackageManager.NameNotFoundException ignore) {
}
return channel;
}

读取渠道信息:Walle

输出的Apk也包含Walle风格的渠道信息


可以在使用Walle的方式进行读取。


implementation 'com.meituan.android.walle:library:1.1.7'


String channel = WalleChannelReader.getChannel(this.getApplicationContext());

渠道文件格式说明


请准备渠道清单文件channel.txt, 格式为每一行一个渠道, 例如:


0001_my
0003_baidu
0004_huawei
0005_oppo
0006_vivo

结语


以上就是基于uniapp制作的Android APP的加固方式,仅供参考~ 欢迎一起交流学习~




作者:昀和
来源:juejin.cn/post/7256615625882615866
收起阅读 »

安卓高版本HTTPS抓包:终极解决方案

虽然市面上有好多抓包工具,但是 Android 高版本都需要安装抓包工具的证书到系统目录,才能抓 https 协议的包。本文就以 Charles这个抓包工具来介绍,如何安装证书到 Android 的系统目录,实现 https 抓包。 修改证书名称 启动 Cha...
继续阅读 »

虽然市面上有好多抓包工具,但是 Android 高版本都需要安装抓包工具的证书到系统目录,才能抓 https 协议的包。本文就以 Charles这个抓包工具来介绍,如何安装证书到 Android 的系统目录,实现 https 抓包。


修改证书名称


启动 Charles,通过菜单栏中的 Help → SSL Proxying → Save Charles Root Certificate… 将 Charles 的证书导出。
使用 OpenSSL 查看证书在 Android 系统中对应的文件名,并重命名证书文件


openssl x509 -subject_hash_old -in charles-ssl-proxying-certificate.pem | head -n 1  #cdfb61bc
mv charles-ssl-proxying-certificate.pem cdfb61bc.0

将证书安装到系统证书目录下


使用 adb push 命令将我们的证书文件放到 SD 卡中


adb push cdfb61bc.0 /sdcard/Download

使用 adb 连接手机并切换到 root 用户


adb shell
su

将证书文件移动到 /system/etc/security/cacerts 目录下,由于 /system 默认是只读的,所以要先重新挂载为其添加写入权限


cat /proc/mounts  #查看挂载信息,这里我的 /system 是直接挂载到 / 的

mount -o rw,remount /
mv /sdcard/Download/cdfb61bc.0 /system/etc/security/cacerts
chmod 644 /system/etc/security/cacerts/cdfb61bc.0 #设置文件权限

如果👆的步骤你都能成功,就不用继续往下看了。


终极解决方案


我用我手上的手机都试了一下,用上面的方式安装正式,发现不能成功,一直提示 Read-only file system,但是HttpToolkit这个软件确可以通过 Android Device Via ADB来抓 https 的包。
它是怎么实现的呢?
这下又开始了漫长的谷歌之旅,最后在他们官网找到一篇文章,详细讲述了 通过有root权限的adb 来写入系统证书的神奇方案。



  1. 通过 ADB 将 HTTP Toolkit CA 证书推送到设备上。

  2. 从 /system/etc/security/cacerts/ 中复制所有系统证书到临时目录。

  3. 在 /system/etc/security/cacerts/ 上面挂载一个 tmpfs 内存文件系统。这实际上将一个可写的全新空文件系统放在了 /system 的一小部分上面。 将复制的系统证书移回到该挂载点。

  4. 将 HTTP Toolkit CA 证书也移动到该挂载点。

  5. 更新临时挂载点中所有文件的权限为 644,并将系统文件的 SELinux 标签设置为 system_file,以使其看起来像是合法的 Android 系统文件。


关键点就是挂载一个 内存文件系统, 太有才了。
具体命令如下


# 创建一个独立的临时目录,用于存储当前的证书
# 如果不这样做,在我们添加挂载后将无法再读取到当前的证书。
mkdir -m 700 /data/local/tmp/htk-ca-copy
# 复制现有的证书到临时目录
cp /system/etc/security/cacerts/* /data/local/tmp/htk-ca-copy/
# 在系统证书文件夹之上创建内存挂载点
mount -t tmpfs tmpfs /system/etc/security/cacerts
# 将之前复制的证书移回内存挂载点中,确保继续信任这些证书
mv /data/local/tmp/htk-ca-copy/* /system/etc/security/cacerts/
# 将新的证书复制进去,以便我们也信任该证书
cp /data
/local/tmp/c88f7ed0.0 /system/etc/security/cacerts/
# 更新权限和SELinux上下文标签,确保一切都和之前一样可读
chown root:root /system/etc/security/cacerts/*
chmod 644 /system/etc/security/cacerts/*
chcon u:object_r:system_file:s0 /system/etc/security/cacerts/*
# 删除临时证书目录
rm -r /data/local/tmp/htk-ca-copy

注意:由于是内存文件系统,所以重启手机后就失效了。可以将以上命令写成 shell 脚本,需要抓包的时候执行下就可以了


作者:平行绳
来源:juejin.cn/post/7360242772303577125
收起阅读 »

为什么 Android 要采用 Binder 作为 IPC 机制?

Hi 大家好,我是 DHL,大厂程序员,公众号:ByteCode ,在美团、快手、小米工作过。搞过逆向,做过性能优化,研究过系统,擅长鸿蒙、Android、Kotlin、性能优化、职场分享。 微信小程序「猿面试」每日分享一道大厂面试题,涉及 Java、And...
继续阅读 »

网站.jpg



Hi 大家好,我是 DHL,大厂程序员,公众号:ByteCode ,在美团、快手、小米工作过。搞过逆向,做过性能优化,研究过系统,擅长鸿蒙、Android、Kotlin、性能优化、职场分享。



微信小程序「猿面试」每日分享一道大厂面试题,涉及 JavaAndroid鸿蒙和ArkTS设计模式算法和数据结构 等内容。




本篇文章主要以面试为主,因此只要记住这些即可。Android 采用 Binder 作为 IPC (进程间通信) 机制的原因主要包括以下几点,


高效性


Binder 机制通过减少数据拷贝次数来提高 IPC 的效率。在 Binder 机制中,发送方只需要将数据从用户空间拷贝到内核空间一次,接收方可以直接访问内核空间中的数据,避免了额外的数据拷贝。


与其他 IPC 机制相比,Binder 更高效。Binder 数据拷贝只需要一次,而管道、消息队列、Socket 都需要 2 次,但共享内存方式一次内存拷贝都不需要;从性能角度看,Binder 性能仅次于共享内存。


对象级别的通信


与基于消息的通信方式不同,Binder 机制提供了一种面向对象的 IPC 方法。Binder 允许在进程间传递对象引用,开发者可以像调用本地对象一样调用远程对象的方法,而无需关心对象实际所在的进程。这种面向对象的 IPC 方式让编程模型更自然,易于理解和使用。


支持异步通信


除了同步调用外,Binder 还支持异步通信,这对于构建响应式应用尤其重要。通过异步通信,应用可以在等待 Binder 事务完成时继续执行其他任务,提高了应用的响应性和性能。


安全性


Binder 通过使用 UID(用户 ID)和 PID(进程 ID)来验证请求的来源,提供了进程间通信的安全性保障。这意味着每个 Binder 事务都可以精确到发起者,系统可以据此实施安全策略,例如权限检查,从而防止未授权的数据访问或通信。


每个 Binder 通信都有明确的权限控制,可以限制哪些进程可以访问 Binder 服务,从而增强了系统的安全性。


稳定性


与其他 IPC 机制相比,Binder 是基于 C/S 架构的,是指客户端 (Client) 和服务端 (Server) 组成的架构,Client 端有什么需求,直接发送给 Server 端去完成,架构清晰明朗,Server 端与 Client 端相对独立。


而共享内存实现方式复杂,没有客户与服务端之别,需要充分考虑到访问临界资源的并发同步问题,否则可能会出现死锁等问题;


从这稳定性角度看,Binder 架构优越于共享内存


简便性


Binder 为开发者提供了一套易于使用的 API 来进行进程间通信,隐藏了复杂的内部实现。它使得不同应用之间或应用与系统服务之间的数据传递和方法调用变得简单直观。


总之,由于其高效、安全、简便、面向对象等特性,Binder 成为了 Android 系统中进行 IPC 通信的首选机制。这些特性使得 Binder 非常适合移动设备这种资源受限的环境。



作者:程序员DHL
来源:juejin.cn/post/7378321582399602707
收起阅读 »

一个Android App最少有多少个线程?

守护线程 Signal Catcher线程 Signal Catcher是一个守护线程,用于捕获 SIGQUIT, SIGUSR1 信号,并采取相应的行为。 Android系统中,由Zygote孵化而来的子进程,包含system_server进程和各种APP进...
继续阅读 »

守护线程


Signal Catcher线程


Signal Catcher是一个守护线程,用于捕获 SIGQUIT, SIGUSR1 信号,并采取相应的行为。


Android系统中,由Zygote孵化而来的子进程,包含system_server进程和各种APP进程都存在一个Signal Catcher线程,但是Zygote进程本身没有这个线程。


在Process类中有SIGQUIT,SIGUSR1 的定义:


    public static final int SIGNAL_QUIT = 3;
public static final int SIGNAL_KILL = 9;
public static final int SIGNAL_USR1 = 10;

当前进程的Signal Catcher线程接收到信号SIGNAL_QUIT,则挂起进程中的所有线程并DUMP所有线程的状态。


当前进程的Signal Catcher线程接收到信号SIGNAL_USR1,则触发进程强制执行GC操作。


发送信号的方式:


Process.sendSignal(android.os.Process.myPid(), Process.SIGNAL_QUIT);
Process.sendSignal(android.os.Process.myPid(), Process.SIGNAL_USR1);

当我们直接在代码中调用上诉代码会发生什么?


Process.SIGNAL_USR1:


I/Process: Sending signal. PID: 12363 SIG: 10
I/etease.popo.ap: Thread[3,tid=12373,WaitingInMainSignalCatcherLoop,Thread*=0x793d816400,peer=0x13700020,"Signal Catcher"]: reacting to signal 10
I/etease.popo.ap: SIGUSR1 forcing GC (no HPROF) and profile save
I/etease.popo.ap: Explicit concurrent copying GC freed 18773(4MB) AllocSpace objects, 7(140KB) LOS objects, 68% free, 2MB/8MB, paused 130us total 13.633ms

Process.SIGNAL_QUIT


I/Process: Sending signal. PID: 12812 SIG: 3
I/etease.popo.ap: Thread[3,tid=12823,WaitingInMainSignalCatcherLoop,Thread*=0x793d816400,peer=0x148051b0,"Signal Catcher"]: reacting to signal 3
I/etease.popo.ap: Wrote stack traces to '[tombstoned]'

RenderThread 渲染线程


Android 5.0之后新增的一个线程,用来协助UI线程进行图形绘制。所有的GL命令执行都放在这个线程上。渲染线程在RenderNode中存有渲染帧的所有信息,并监听VSync信号,因此可以独立做一些属性动画。


在清单文件APP中添加:android:hardwareAccelerated="false"
启动APP将会不存在渲染线程;硬件加速在Android中试默认开启的。


Render Thread在运行时主要是做以下两件事情:



  • Task Queue的任务,这些Task一般就是由MainThread发送过来的,例如,MainThread通过发送一个DrawFrameTask给RenderThread的TaskQueue中,请求RenderThread渲染窗口的下一帧。

  • PendingRegistrationFrameCallbacks列表的IFrameCallback回调接口。每一个IFrameCallback回调接口代表的是一个动画帧,这些动画帧被同步到Vsync信号到来由RenderThread自动执行。具体来说,就是每当Vsync信号到来时,就将一个类型为DispatchFrameCallbacks的Task添加到RenderThread的TaskQueue去等待调度。一旦该Task被调度,就可以在RenderThread中执行注册在PendingRegistrationFrameCallbacks列表中的IFrameCallback回调接口了。


FinalizerDaemon 析构守护线程


对于重写成员函数finailze的对象,他们被GC决定回收时,并没有马上被回收,而是被放入到一个队列中,等待FinalizerDaemon守护线程去调用他们的成员函数finalize,然后再被回收。


FinalizerWatchdogDaemon 析构监护守护线程。


用来监控FinalizerDaemon线程的执行。一旦检测哪些重写了成员函数finalize的对象在执行成员函数finalize时超出一定的时候,那么就会退出VM。


ReferenceQueueDaemon 引用队列守护线程


我们知道在创建引用对象的时候,可以关联一个队列。当被引用对象引用的对象被GC回收的时候呀,被引用对象就会呗加入到其创建时关联的队列中去,这个加入队列的操作就是由ReferenceQueueDaemon守护线程来完成的。这样应用程序就可以知道哪些被引用对象引用的对象已经被回收了。


HeapTrimmerDaemon 堆裁剪守护线程


用于执行裁剪堆的操作,也就是用来将哪些空闲的堆内存归还给系统。


HeapTaskDaemon 线程


Android每个进程都有一个HeapTaskDaemon线程,在该线程内进行GC操作。
HeapTaskDaemon 继承自 Daemon 对象。Daemon 对象实际是一个Runnale,并且内部会创建一个线程,用于执行当前这个 Daemon unnable,这个内部线程的线程名就叫 HeapTaskDaemon。


private static class HeapTaskDaemon extends Daemon {
private static final HeapTaskDaemon INSTANCE = new HeapTaskDaemon();

HeapTaskDaemon() {
super("HeapTaskDaemon");
}

// Overrides the Daemon.interupt method which is called from Daemons.stop.
public synchronized void interrupt(Thread thread) {
VMRuntime.getRuntime().stopHeapTaskProcessor();
}

@Override public void runInternal() {
synchronized (this) {
if (isRunning()) {
// Needs to be synchronized or else we there is a race condition where we start
// the thread, call stopHeapTaskProcessor before we start the heap task
// processor, resulting in a deadlock since startHeapTaskProcessor restarts it
// while the other thread is waiting in Daemons.stop().
VMRuntime.getRuntime().startHeapTaskProcessor();
}
}
// This runs tasks until we are stopped and there is no more pending task.
VMRuntime.getRuntime().runHeapTasks();
}
}

Binder 线程


每个APP进程在启动之后会创建一个binder线程池,用于相应IPC客户端的请求。


例如APP与AMS等服务之间可以通过IPC双向通信,当APP作为服务端的时候,就需要通过Binder线程相应来自AMS的请求。一个Server进程中有一个最大的Binder线程数限制,默认为16个binder线程。


与系统服务通信或者自行实现多进程Binder通信大致需要注意一下几点:



  • 与系统通信的方法,建议使用try cache进行防护,防止App出现崩溃。

  • 需要注意调用频率,部分API调用频率过快响应会比较慢,从而导致主线程卡顿甚至ANR。


主线程


主线程也较UI线程。


这个线程作为Android 开发都比较熟悉。


三方线程


OkHttp相关


如果你使用OkHttp作为网络请求库,那么工程中会有以下几类线程


OkHttp Dispatcher


用于实际执行HTTP请求


  @get:Synchronized
@get:JvmName("executorService") val executorService: ExecutorService
get() {
if (executorServiceOrNull == null) {
executorServiceOrNull = ThreadPoolExecutor(0, Int.MAX_VALUE, 60, TimeUnit.SECONDS,
SynchronousQueue(), threadFactory("$okHttpName Dispatcher", false))
}
return executorServiceOrNull!!
}

OkHttp TaskRunner


4.x版本引入,用于管理和调度内部任务


  companion object {
@JvmField
val INSTANCE = TaskRunner(RealBackend(threadFactory("$okHttpName TaskRunner", daemon = true)))

val logger: Logger = Logger.getLogger(TaskRunner::class.java.name)
}

OkHttp ConnectionPool


负责清理和回收无用的连接池


  private val cleanupTask = object : Task("$okHttpName ConnectionPool") {
override fun runOnce() = cleanup(System.nanoTime())
}

Okio Watchdog


OkHttp中使用Watchdog来处理超时逻辑


  private class Watchdog internal constructor() : Thread("Okio Watchdog") {
init {
isDaemon = true
}

override fun run() {
while (true) {
try {
var timedOut: AsyncTimeout? = null
synchronized(AsyncTimeout::class.java) {
timedOut = awaitTimeout()

// The queue is completely empty. Let this thread exit and let another watchdog thread
// get created on the next call to scheduleTimeout().
if (timedOut === head) {
head = null
return
}
}

// Close the timed out node, if one was found.
timedOut?.timedOut()
} catch (ignored: InterruptedException) {
}
}
}
}

Glide相关


如果你使用Glide加载图片,那么工程中会有以下几类线程


"glide-source-thread-x"


用于从网络、文件系统或其他数据源中加载原始图像数据


  public static GlideExecutor.Builder newSourceBuilder() {
return new GlideExecutor.Builder(/* preventNetworkOperations= */ false)
.setThreadCount(calculateBestThreadCount())
.setName(DEFAULT_SOURCE_EXECUTOR_NAME);
}

"glide-disk-cache-thread-x"


用于从缓存中读取数据


  public static GlideExecutor.Builder newDiskCacheBuilder() {
return new GlideExecutor.Builder(/* preventNetworkOperations= */ true)
.setThreadCount(DEFAULT_DISK_CACHE_EXECUTOR_THREADS)
.setName(DEFAULT_DISK_CACHE_EXECUTOR_NAME);
}

source-unlimited


  public static GlideExecutor newUnlimitedSourceExecutor() {
return new GlideExecutor(
new ThreadPoolExecutor(
0,
Integer.MAX_VALUE,
KEEP_ALIVE_TIME_MS,
TimeUnit.MILLISECONDS,
new SynchronousQueue<Runnable>(),
new DefaultThreadFactory(
new DefaultPriorityThreadFactory(),
DEFAULT_SOURCE_UNLIMITED_EXECUTOR_NAME,
UncaughtThrowableStrategy.DEFAULT,
false)));
}

animation


用于执行动画


  public static GlideExecutor.Builder newAnimationBuilder() {
int maximumPoolSize = calculateAnimationExecutorThreadCount();
return new GlideExecutor.Builder(/* preventNetworkOperations= */ true)
.setThreadCount(maximumPoolSize)
.setName(DEFAULT_ANIMATION_EXECUTOR_NAME);
}

ARouter相关


如果你使用ARouter作为路由


ARouter task pool No.x , thread No.X


创建位置


    public static DefaultPoolExecutor getInstance() {
if (null == instance) {
synchronized (DefaultPoolExecutor.class) {
if (null == instance) {
instance = new DefaultPoolExecutor(
INIT_THREAD_COUNT,
MAX_THREAD_COUNT,
SURPLUS_THREAD_LIFE,
TimeUnit.SECONDS,
new ArrayBlockingQueue<Runnable>(64),
new DefaultThreadFactory());
}
}
}
return instance;
}

另外ARouter也为开发者提供了使用自己线程池的接口:


    static synchronized void setExecutor(ThreadPoolExecutor tpe) {
executor = tpe;
}

三方库总结


关于三方库中的线程池这里仅列举了几个库。如果你的工程中含有大量的三方库,我详细也会存在大量的工作线程。


一些三方库会提供接口允许开发者将其替换成工程内部统一维护的线程池,这样可以做到工程中线程的收敛。笔者遇到最离谱的应该是腾讯X5浏览器,由另外一个小组的同事引入到项目中,集成后发现其引入了大量的线程(大概几十个)。


其他


我个人不太喜欢发这些纯概念的东西,更喜欢总结一些在工程中遇到的问题、对应的解决方案以及思考。后面考虑尽量少发这类笔记,多输出一些思考。


作者:半山居士
来源:juejin.cn/post/7372445124753883155
收起阅读 »

两台Android 设备同一个局域网下如何自由通信?

一、背景 笔者前段时间开发了一款非常有意思的项目,已知在同一个局域网下有两款App分别运行在不同的设备上,业务上两款App分别具有不同的功能,同时两款App需要支持互相通信。后面我们称两款设备上的两款App一个为Server,一个为Client。 基于此我们需...
继续阅读 »

一、背景


笔者前段时间开发了一款非常有意思的项目,已知在同一个局域网下有两款App分别运行在不同的设备上,业务上两款App分别具有不同的功能,同时两款App需要支持互相通信。后面我们称两款设备上的两款App一个为Server,一个为Client。


基于此我们需要考虑以下几点



  • 如何发现对方

  • 两款App如何通信

  • 通信协议如何选择


二、发现对方


在局域网中查找识别我们可以基于DNS-SD协议。


1、DNS-SD协议介绍


DNS-SD(Domain Name System - Service Discovery)是一种用于在局域网(Local Area Network,LAN)中发现服务的协议。它使用DNS协议扩展了域名系统(Domain Name System,DNS)的功能,使得客户端能够在局域网中查找特定类型的服务,并获取有关该服务的信息。


在Android 中也有对应的Api可以使用:NsdManager。
NsdManager主要实现三个功能:



  • 注册

  • 发现

  • 解析


其中一台设备作为服务端(即上面的Server App)通过NsdManager注册服务,提供自己的IP地址与端口号,同时可以提供用于标识自己的信息,例如设备ID。当一个局域网中有多台服务时,客户端在发现服务后,可以基于标识信息确认是否为自己需要找到的服务端。


另一台设备作为客户端(即上面的Client App),通过NsdManager的发现接口去发现服务。发现服务之后,再调用解析接口,获取到对应的信息,如服务端的IP地址、端口号、设备标识信息等。



详细的官方介绍地址:developer.android.com/reference/a…



2、注册


作为服务端的Server App,通过NsdManager注册。


NsdManager nsdManager = (NsdManager) context.getSystemService(Context.NSD_SERVICE);
NsdServiceInfo serviceInfo = new NsdServiceInfo();
serviceInfo.setServiceName(ServiceName);
serviceInfo.setServiceType(ServiceType);
int localPort = getLocalPort();
serviceInfo.setPort(localPort);
serviceInfo.setAttribute(Attribute_UUID, UuidManager.INSTANCE.getUUID());
String ipAddress = LocalIpUtils.getIPAddress();
serviceInfo.setAttribute(Attribute_IP, ipAddress);
nsdManager.registerService(serviceInfo, NsdManager.PROTOCOL_DNS_SD, new NsdManager.RegistrationListener() {
@Override
public void onRegistrationFailed(NsdServiceInfo nsdServiceInfo, int i) {
//异常上报
}

@Override
public void onUnregistrationFailed(NsdServiceInfo nsdServiceInfo, int i) {}

@Override
public void onServiceRegistered(NsdServiceInfo nsdServiceInfo) {
//注册成功
}

@Override
public void onServiceUnregistered(NsdServiceInfo nsdServiceInfo) {}
});


3、发现


Client App,调用NsdManager#discoverServices()接口去发现服务。


NsdManager nsdManager = (NsdManager) context.getSystemService(Context.NSD_SERVICE);
discoveryListener = new NsdManager.DiscoveryListener() {
@Override
public void onStartDiscoveryFailed(String s, int i) {}

@Override
public void onStopDiscoveryFailed(String s, int i) {}

@Override
public void onDiscoveryStarted(String s) {}

@Override
public void onDiscoveryStopped(String s) {}

@Override
public void onServiceFound(NsdServiceInfo nsdServiceInfo) {}

@Override
public void onServiceLost(NsdServiceInfo nsdServiceInfo) {}
};
nsdManager.discoverServices(NsdServer.ServiceType, NsdManager.PROTOCOL_DNS_SD, discoveryListener);


4、解析


Client App 收到onServcieFound回调之后,就可以调用解析接口解析数据了


nsdManager.resolveService(nsdServiceInfo, new NsdManager.ResolveListener() {
@Override
public void onServiceResolved(NsdServiceInfo nsdServiceInfo) {
Map<String, byte[]> attributes = nsdServiceInfo.getAttributes();
}

@Override
public void onResolveFailed(NsdServiceInfo nsdServiceInfo, int i) {}
});


二、TCP通信


发现设备之后,我们要考虑两台设备要如何通信。此时我们可以有两套方案,分别是HTTP以及TCP。


HTTP


使用HTTP的话,就是在Server App上启动一个HTTP服务,我们可以预定义一些接口用于和Client来通信。
优点是比较简单,缺点是Server App没有办法主动通知Client App。


TCP


直接使用TCP协议的话,我们可以考虑选择一个支持TCP通信的框架,自定义一个简易的通信协议。优点是客户端与服务端可以互相通信,满足我们的需求。缺点是相对复杂一些。


在我们的项目中,我们有两台设备交互通信的需求,所以选择了TCP协议。


1、TCP通信库的选择


在我们的项目中使用了Netty作为TCP通信框架。关于Netty其大名鼎鼎,网络上的分析文章一大把,这里就不详细介绍了。Netty在国内有一位步道的大神名为李林峰(在华为工作),有兴趣的同学可以去看看大神的书。


2、简易的TCP协议设计


由于业务比较简单,所以这里我们将TCP通信协议进行简化。整体协议大致如下:[Int][String]。


其中Int用于指定后面String字符串长度,我们可以基于这个Int来处理拆包、粘包的问题,这个逻辑后面详细介绍。String可以是JSON结构,可以依据业务来定义JSON中字段。


3、TCP服务端


使用Netty实现TCP服务端大致代码


bossGr0up = new NioEventLoopGr0up();
workerGr0up = new NioEventLoopGr0up();
//构建引导程序
mBootstrap = new ServerBootstrap();
//设置EventGr0up
mBootstrap.group(bossGr0up, workerGr0up);
//设置Channel
mBootstrap.channel(NioServerSocketChannel.class);
mBootstrap.option(ChannelOption.SO_BACKLOG, 128);
//设置的好处是禁用Nagle算法。表示不延迟立即发送
//这个算法试图减少TCP包的数量和结构性开销,将多个较小的包组合较大的包进行发送。
//这个算法收TCP延迟确认影响,会导致相继两次向链接发送请求包。
mBootstrap.option(ChannelOption.TCP_NODELAY, false);
mBootstrap.option(ChannelOption.SO_KEEPALIVE, true);
mBootstrap.childHandler(new CustomChannelInitializer());
channelFuture = mBootstrap.bind(inetPort).sync();

以上TCP服务就启动了。


4、TCP客户端


使用Netty实现TCP客户端大致代码:


初始化


//构建线程池
mGr0up = new NioEventLoopGr0up();
//构建引导程序
mBootstrap = new Bootstrap();
//设置EventGr0up
mBootstrap.group(mGr0up);
//设置Channel
mBootstrap.channel(NioSocketChannel.class);
//设置的好处是禁用Nagle算法。表示不延迟立即发送
//这个算法试图减少TCP包的数量和结构性开销,将多个较小的包组合较大的包进行发送。
//这个算法收TCP延迟确认影响,会导致相继两次向链接发送请求包。
mBootstrap.option(ChannelOption.TCP_NODELAY, false);
mBootstrap.option(ChannelOption.SO_KEEPALIVE, true);
mBootstrap.remoteAddress(ip.getIp(), ip.getPort());
mBootstrap.handler(new CustomChannelInitializer());

建立连接


ChannelFuture channelFuture = mBootstrap.connect();
channelFuture.addListener(new FutureListener() {
@Override
public void operationComplete(Future future) {
final boolean isSuccess = future.isSuccess();
writeLog("operation complete future.isSuccess: " + isSuccess);
}
});


断开连接


public void disConnect(boolean onPurpose) {
try {
if (mGr0up != null) {
mGr0up.shutdownGracefully();
}
} catch (Exception e) {
e.printStackTrace();
}
try {
if (mChannel != null) {
mChannel.close();
}
} catch (Exception e) {
e.printStackTrace();
}
try {
if (mChannelHandlerContext != null) {
mChannelHandlerContext.close();
}
} catch (Exception e) {
e.printStackTrace();
}
}

我们可以在operationComplete()方法中,确认建立连接成功。


ChannelInitializer


为了让更多协议和其他各种方式处理数据,Netty有了Handler组件。Handler就是为了处理Netty里面的置顶事件或一组事件。 ChannelInitializer 的作用就是将Handler 添加到ChannelPipeline中。当你发送或收到消息的时候,这些Handler就决定怎么处理你的数据。


public class CustomChannelInitializer extends ChannelInitializer<SocketChannel> {

@Override
protected void initChannel(SocketChannel socketChannel) {
ChannelPipeline pipeline = socketChannel.pipeline();
mCustomDecoder = new CustomDecoder();
mCustomEncoder = new CustomEncoder();
mCustomHandler = new CustomHandler();

pipeline.addLast(mCustomDecoder);
pipeline.addLast(mCustomEncoder);
pipeline.addLast(mCustomHandler);
}

@Override
public void handlerAdded(ChannelHandlerContext ctx) throws Exception {
super.handlerAdded(ctx);
}

@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
super.exceptionCaught(ctx, cause);
}
}


ChannelPipeline 是一个管道,Handler就是里面一层一层要对数据进行处理的事件。所有的Handler都一个顶层接口ChannelHandler。ChannelHandler有两个子接口:



  • ChannelInboundHandler

  • ChannelOutboundHandler


Netty中数据流有2个方向:



  • 数据进(应用收到消息)的时候与ChannelInboundHandler有关。

  • 数据出(应用发出数据)的时候与ChannelOutboundHandler有关。


为了将数据从一端发送到另一端,一般都会有一个或多个ChannelHandler用各种方式对数据进行操作。 决定这些Handler以一种特定的顺序处理数据的是ChannelPipeline。


ChannelInboundHandler与ChannelOutboundHandler可以混在同一个ChannelPipeline里面。当应用收到数据时,首先从ChannelPipeline的头部进入到一个ChannelInboundHandler 。第一个ChannelInboundHandler处理后传给下一个ChannelInboundHandler。 然后ChannelPipeline中没有其他的ChannelInboundHandler 了数据就会到达ChannelPipeline的尾部,也就是应用对数据的处理已经完成了。


数据流出的过程是返过来的,首先从ChannelPipeline的尾部开始进入到最后一个ChannelOutboundHandler,最后一个ChannelOutboundHandler处理后,传给前面一个ChannelOutboundHandler。 和进不同的,进是从前到后,而出是从后到前。没有多余的ChannelOutboundHandler的时候,数据进入实际网络中传输,触发一些IO操作。


一旦一个ChannelHandler被添加到ChannelPipeline中,它就会获得一个ChannelHandlerContext。一般情况下,获得这个对象并持有它是安全的, 不过在数据包协议的时候不一样安全,例如UDP协议。 在Netty中有两种发送数据的方式。你可以写到Channel中或者使用ChannelhandlerContext对象。他们的主要区别是,直接写到Channel,则数据会从ChannelPipeline头部传到尾部。 每一个ChannelHandler都会处理数据,而使用ChannelHandlerContext则是将数据传送到下一个ChannelHandler。


一个 Channel 包含了一个 ChannelPipeline, 而 ChannelPipeline 中又维护了一个由 ChannelHandlerContext 组成的双向链表, 并且每个 ChannelHandlerContext 中又关联着一个 ChannelHandler。 入站事件和出站事件在一个双向链表中,入站事件会从链表head往后传递到最后一个入站的handler,出站事件会从链表tail往前传递到最前一个出站的handler,两种类型的handler互不干扰。


我们在 CustomChannelInitializer#initChannel()方法中添加编码、解码器。其中 CustomHandler 继承自SimpleChannelInboundHandler用来接收消息。


三、编解码


当你用Netty接收或发送消息,必须将其从一种格式转成另一种格式。比如收消息,你需要从字节转为Java对象。发消息就是将Java对象转成字节发出去。


Netty中有各种各样的编码器和解码器基类。



  • ByteToMessageDecoder

  • MessageToByteEncoder

  • ProtobufEncoder

  • ProtobufDecoder

  • StringDecoder

  • StringEncode


这里,编码器都是继承自ChannelInboundHandlerAdapter或者实现了ChannelInboundHandler。当读到数据时,会调用ChannelRead方法。重写此方法,然后就会调用decode 方法进行解码。并且会执行ChannelHandlerContext,fireChannelRead方法,将解码后的消息传给下一个Channelhandler。 当发送消息的时候,也执行类似的过程,编码器将消息转为字节,然后传给下一个ChannelOutboundHandler。


在我们的项目中,由于自定义了协议所以使用了ByteToMessageDecoder、MessageToByteEncoder。


1、编码


编码器比较简单,我们可以自定义类继承 MessageToByteEncoder 来实现解码。
需要注意的是,按照我们定义的协议顺序向 ByteBuf 中写入数据就好了。示例代码:


public class CustomEncoder extends MessageToByteEncoder<CustomMessage> {

@Override
protected void encode(ChannelHandlerContext channelHandlerContext, CustomMessage message, ByteBuf byteBuf) {
//byteBuf.writeInt();
//byteBuf.writeByte();
}

}


2、解码


解码我们要解决TCP拆包、粘包的问题。


一般TCP中应对拆包、粘包基本有以下几种方案:



  • 消息定长,这但比较好理解,由于定长了,我们可以直接判断ByteBuffer中数组长度。不过在实践过程中,一般我们不会使用这个方案,因为扩展性太差了。

  • 使用特殊字符作为结尾。

  • 自定义协议。


前面我们有提到我们的通信协议为[Int][String]实际上就是一个非常简易的自定义协议(由于业务简单,所以这里没有设计的非常复杂)。我们使用Int来标记后面的String长度,这样两端收到消息后,按照这个格式解析实际上就知道消息的长度了。


在Netty中,我们可以自定义类继承自 ByteToMessageDecoder,来处理解码。Netty中 ByteBuf 来处理数据。具体的步骤是:



  • 先标记已读位置:byteBuf.markReaderIndex();

  • 判断 ByteBuf 可读是否达到4个字节长度,如果不足直接返回,重置已读位置。

  • 读取前4个字节,转为Int。

  • 如果字符长度 > 0,那么接下来继续读 length 长度的字符,转为String。

  • 这样整个协议就解码完成了。


这里需要注意的是:客户端要与服务端约定好是大端字节序还是小端字节序。


以上在局域网中,两款App进行TCP通信就已经搭建好了,我们有了协议上层业务就可以基于此来封装业务需要的逻辑了。实际上很多IM 通信SDK,基本上都是自定义通信协议,只是协议会比本篇中举例的协议要复杂的多。


四、优化点


项目上线一段时间后,用户反馈偶现存在两台设备无法通信的问题。后面经过调研发现是服务端进程一直存在(即Server App 并没有挂),只是TCP服务挂了。


我们在客户端有处理断线重连逻辑,但是在服务端没有做任何监控重启逻辑。


如何优化


经过调研,我们在服务端Server App中,另外启动一个客户端来与TCP服务端建立连接(相当于是我监控我自己了),如果建立失败,就重启TCP服务。



  • 建立一个TCP客户端,启动轮询逻辑

  • 如果该客户端没有与服务端建立连接,那么尝试建立连接。

  • 如果连续三次都无法建立成功连接,那么认为此时TCP服务存在异常,重启TCP服务。

  • 如果可以正常与TCP服务建立连接,那么开始向TCP服务发送心跳包

  • 如果连续三次TCP服务没有回复心跳回包,那么也认为TCP服务存在异常,重启TCP服务。
    以上,就是我们针对TCP服务端的优化,上面逻辑上线后,无法建立连接的反馈就没有了。


五、总结


本篇主要是介绍如何在局域网中,两个APP使用Dns-SD协议来发现对方。自定义协议进行TCP通信,使用Netty作为TCP通信框架。


作者:半山居士
来源:juejin.cn/post/7375275474006802443
收起阅读 »

Dialog 可不可以传Application

自定义Dialog继承Dialogclass SourceDialog(context: Context, themeResId: Int) : Dialog(context, themeResId) { constructor(contex...
继续阅读 »

自定义Dialog

  1. 继承Dialog
class SourceDialog(context: Context, themeResId: Int) : Dialog(context, themeResId) {  

constructor(context: Context) : this(context, R.style.CustomDialogTheme)

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.source_layout)
}
}
  1. 创建自己的主题样式
<style name="CustomDialogTheme" parent="@android:style/Theme.Dialog">  
<item name="android:windowBackground">@android:color/transparentitem> //透明背景
<item name="android:windowNoTitle">trueitem> //没有标题
<item name="android:windowFullscreen">trueitem> //是否全屏
<item name="android:backgroundDimEnabled">trueitem> //背景黑暗
<item name="android:backgroundDimAmount">0.5item> //背景黑暗透明度
style>

可以传入自己创建的主题,也可以不传,Android 会有默认的主题

Dialog(@UiContext @NonNull Context context, @StyleRes int themeResId,  
boolean createContextThemeWrapper) {
if (createContextThemeWrapper) {
if (themeResId == Resources.ID_NULL) {
final TypedValue outValue = new TypedValue();
//这里会指定默认的主题,如果不传主题
context.getTheme().resolveAttribute(R.attr.dialogTheme, outValue, true);
themeResId = outValue.resourceId;
}
mContext = new ContextThemeWrapper(context, themeResId);
} else {
mContext = context;
}

mWindowManager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);

final Window w = new PhoneWindow(mContext);
mWindow = w;
w.setCallback(this);
w.setOnWindowDismissedCallback(this);
w.setOnWindowSwipeDismissedCallback(() -> {
if (mCancelable) {
cancel();
}
});
w.setWindowManager(mWindowManager, null, null);
w.setGravity(Gravity.CENTER);
mListenersHandler = new ListenersHandler(this);
}
  1. 使用
val dialog=SourceDialog(context) 
dialog.show()

遇到的问题

  1. Dialog 可不可以传Application ?

背景:这几天接到一个需求,收到动作需要在任何界面上弹出信号源选择器页面(铺满整个屏幕),我一开始是选择了Service+WindowManager 添加View显示的。之前也看了一下公司的CommonUI (展示一下亮度条,音量条之类的全局UI) 用到的是Dialog 弹出界面的。我也跟着写一个,才发现一个一个坑接着来。

答案是可以的,是要window 传一个 type

这是我的Dialog

class SourceDialog(context: Context, themeResId: Int) : Dialog(context, themeResId) {  

constructor(context: Context) : this(context, R.style.CustomDialogTheme)

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.source_layout)
}
}

//传入Application
val dialog=SourceDialog(MyApplication.CONTEXT)
dialog.show()

我就很疑惑,提示Activity需要运行

屏幕截图 2024-05-24 151913.png

我后来换成了,正常运行

//传入activity
val dialog=SourceDialog(this@MainActivity)
dialog.show()

很疑惑,对比同事负责的项目发现我需要给window设置了一些东西

//Dialog 

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.source_layout)
window?.setType(WindowManager.LayoutParams.TYPE_SYSTEM_ALERT)
}

//这样就可以正常展示
val dialog=SourceDialog(MyApplication.CONTEXT)
dialog.show()

  1. 设置Dialog 全屏宽高不成功

我的布局文件 最外层是线性布局

<LinearLayout android:id="@+id/group_source"  
android:layout_width="match_parent"
android:layout_height="match_parent"
android:focusable="false"
android:gravity="center"
android:orientation="horizontal"
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">


LinearLayout>

但是我发现 设置主题样式true 不起作用。 我在这里参考了 三句代码创建全屏Dialog或者DialogFragment

  • 粗暴一点直接设置window 大小 ==需要在setContentView 之后设置window的大小才会生效,如果在setContentView 之前设置,此时window的dectorView为空不会更新布局
class SourceDialog(context: Context, themeResId: Int) : Dialog(context, themeResId) {  

constructor(context: Context) : this(context, R.style.CustomDialogTheme)

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.source_layout)
window?.setType(WindowManager.LayoutParams.TYPE_SYSTEM_ALERT)
window?.setLayout(WindowManager.LayoutParams.MATCH_PARENT, WindowManager.LayoutParams.MATCH_PARENT) //也可以换成具体数值宽高
}
}
  • 主题样式增加一样 false 因为很多默认的Dialog 主题这个属性一般为true。
<style name="CustomDialogTheme" parent="@android:style/Theme.Dialog">  
<item name="android:windowBackground">@android:color/transparentitem>
<item name="android:windowNoTitle">trueitem>
<item name="android:windowFullscreen">trueitem>
<item name="android:windowIsFloating">falseitem>
<item name="android:backgroundDimEnabled">trueitem>
<item name="android:backgroundDimAmount">0.5item>
style>

Dialog setContentView 会走到PhoneWindow 的这个方法 走到installDecor -> generateLayout

屏幕截图 2024-05-24 160441.png

屏幕截图 2024-05-24 160620.png

屏幕截图 2024-05-24 160736.png 会发现这个属性为true的话,会根据内容展示。我们给这个属性设置为false这样就不用给Window设置大小了。


作者:很好881
来源:juejin.cn/post/7372396174249738278
收起阅读 »

我们如何让Android客户端暴瘦了100M

一、 引言随着Android应用功能的日益丰富,客户端的体积也逐渐膨胀,过大的安装包体积不仅给用户带来了下载和存储的压力,还会影响应用的启动速度和整体性能;传统的图片压缩、冗余资源移除、代码混淆等优化手段可以在一定程度上降低安装包大小,但是在面对大型复杂应用的...
继续阅读 »

一、 引言

随着Android应用功能的日益丰富,客户端的体积也逐渐膨胀,过大的安装包体积不仅给用户带来了下载和存储的压力,还会影响应用的启动速度和整体性能;传统的图片压缩、冗余资源移除、代码混淆等优化手段可以在一定程度上降低安装包大小,但是在面对大型复杂应用的时候,效果往往很有限,本文将详细介绍我们在包大小优化方面的实践经验,并通过一系列技术手段实现了显著的包体积缩减。

二、  安装包大小分析

Android的apk通常有以下几部分组成:

  1. 代码:包含应用中Java/Kotlin代码,在包中以dex的形式存在
  2. 资源:包含图片、布局文件等
  3. lib库: 包含了应用的Native代码库,以.so文件的形式存在
  4. assets:包含了应用运行时所需的非代码资源,如音频、视频、字体、配置文件等
  5. 其他:签名文件、资源索引文件等

通过分析安装包大小的组成,我们发现项目中lib库和assets占比达到70%,代码占比20%,资源和其他占比10%。

三、基础优化方案

  1. 代码优化:开启代码混淆,混淆可以帮助缩减代码尺寸、移除无用代码,通过分析反编译后的代码,我们发现很多本该混淆的类没有混淆,最终定位到工程中引入的一些三方库的混淆规则keep范围过大,导致混淆效果不理想;通过混淆规则的优化,包大小缩减了6M左右;
  2. 资源优化:解压apk文件,把res目录下的图片按照大小进行排序,我们发现项目中有一些尺寸较大的图片,把图片格式转为webp格式后,尺寸大大降低;同时通过对比资源的md5值,发现一些资源名字虽然不一样,但是内容是一样的,这些重复资源可以移除;通过资源的优化,包大小缩减了15M左右;
  3. assets资源优化:通过分析apk assets目录下的文件,我们发现里面有很多不用的文件,比如arm64位包中存在x86、armeabi-v7a等其他架构的so库,这些assets目录下的so库是三方库引入的,运行时动态加载,由于设置abiFilters无法过滤掉这些so库,导致打包进apk中;我们通过自定义构建流程,在mergeAssetsTask执行结束后移除assets中不用的so库;通过assets资源的优化,包大小缩减了4M左右.
project.afterEvaluate {
android.applicationVariants.all { variant ->
def mergeAssetsTask = project.getTasksByName("merge${variant.name.capitalize()}Assets", false)
mergeAssetsTask.doLast {
def assetsDir = mergeAssetsTask.outputDir.get().toString()
// 移除无用的assets资源
removeUnusedAssets(assetsDir)
}
}
}

通过基础的代码和资源等的优化,包大小缩减了25M左右,但是对于一个180M的apk来说,效果非常有限,需要探索其他方案进一步降低包大小。

三、  进阶优化方案

上面我们分析过apk中的lib库和assets文件占比达到70%,因此我们重点针对lib库和assets文件尺寸大的问题进行优化,我们可以把这些文件放到云端,在应用启动的时候下载到本地,但是这样做有以下问题:

  1. 一些lib库和assets文件在应用启动的时候就会用到,如果放到云端,会导致应用启动时间变长甚至崩溃;
  2. 应用中加载assets资源是通过系统API AssetManager.open来加载的,但是把assets文件从apk中移除后,需要修改使用AssetManager.open的地方,改为从本地私有目录加载,这样会导致改动的地方很多,而且容易漏改和错改;一些三方库由于没有开源,修改起来会更加困难;
  3. 应用中加载lib库是通过系统API System.loadLibrary来加载的,如果把so库从apk中移除后,需要修改为使用System.load加载私有目录下的so库,同样存在改动地方多,不开源的三方库修改困难的问题;
  4. 把移除的so库和assets文件打包成一个文件下发会存在由于文件尺寸大导致下载时间长,容易下载失败问题,同时会导致当用户使用到相关功能的时候需要长时间的等待,体验差。

针对以上问题,我们采用了以下优化策略:

  1. 选择性移除:只把一些尺寸大,用户使用频次较低的功能中使用的assets和so库从apk包中移除,在不影响用户体验的同时,降低安装包大小。
  2. 分包下载:需要移除的so库和assets文件按功能模块进行分包,首次使用时再去下载对应的资源包,这样能确保功能模块依赖的云端资源尽可能的小,大幅降低下载时间,提升下载成功率,减少用户等待时间。
  3. 自动化构建:通过编写gradle脚本,自定义构建过程,在构建阶段自动把assets和so库从apk包中移除并打包。
project.afterEvaluate {
android.applicationVariants.all { variant ->
def mergeAssetsTask = project.getTasksByName("merge${variant.name.capitalize()}Assets", false)
mergeAssetsTask.doLast {
def assetsDir = mergeAssetsTask.outputDir.get().toString()
// 把assetsDir中需要移除的assets文件移除,放到模块指定的目录下
}

def mergeNativeLibsTask = project.getTasksByName("merge${variant.name.capitalize()}NativeLibs", false)
mergeNativeLibsTask.doLast {
def libDir = mergeNativeLibsTask.outputDir.get().toString()
// 把 libDir中需要移除的so库移除,放到模块指定的目录下
// 打包压缩模块目录
}
}
}
  1. 字节码插桩:开发gradle插件,使用字节码插桩技术,在编译阶段自动把调用AssetManager.openSystem.loadLibrary的地方替换为我们的自定义加载器,工程中的代码和三方闭源库无需做任何改动。
public class MyMethodVisitor extends MethodVisitor {
@Override
public void visitMethodInsn(int opcode, String owner, String name, string desc, boolean isInterface) {
// 替换System.loadLibrary为DynamicLoader.loadLibrary
if ("java/lang/System".equals(owner) && "loadLibrary".equals(name)) {
return super.visitMethodInsn(opcode, "com/xxx/loader/DynamicLoader", name, desc, isInterface);
}

// 替换AssetManager.open为DynamicLoader.openAsset
if ("android/content/res/AssetManager".equals(owner) && "open".equals(name) && "(Ljava/lang/String;)Ljava/io/InputStream".equals(desc)) {
return super.visitMethodInsn(Opcodes.INVOKESTATIC, "com/xxx/loader/DynamicLoader", "openAsset", "(Landroid/content/res/AssetManager;Ljava/lang/String;)Ljava/io/InputStream");
}
return super.visitMethodInsn(opcode, owner, name, desc, isInterface);
}
}
  1. 双重加载机制:在自定义加载器中先尝试加载apk内置的so库和assets文件,如果出现异常,则从动态下发的文件中查找并加载,这样可以保证无论so库是否移除都可以正常加载。
// 自定义加载器

public class DynamicLoader {
public static void loadLibrary(string libname) throw Throwable {
try {
// 先加载apk包中的so库
System.loadLibrary(libname);
return
} catch(Throwable e) {
}

String soPath = findLibrary(libName);
// apk包中的so库加载失败时加载动态下发的so库
return System.load(soPath);
}

public static InputStream openAsset(AssetManager am, String fileName) throw IOException {
try {
// 先加载apk包中的asset文件
return am.open(fileName);
} catch(IOException e) {
}

// apk包中的asset文件加载失败时加载动态下发的asset文件
String assetPath = findAsset(fileName);
return new FileInputStream(assetPath);
}
}

四、  实施效果

采用上述包优化方案后,我们的Android客户端安装包大小从180M缩减到78M,实现了显著的包体积缩减。同时,通过监控优化后版本的崩溃率和用户反馈,未出现明显的崩溃率升高和用户体验下降的情况。

五、  未来展望

应用的安装包大小优化是一个长期的过程,需要建立一套包大小的监控、预警、原因分析、自动优化等机制,确保安装包大小在合理范围,我们将从以下几个方面进行探索:

  1. 设定安装包大小基准,持续监控安装包大小的变化,当安装包大小偏移基准值过大的时候,触发预警,并自动分析包大小增加原因,找出导致包大小增大的文件;
  2. 优化构建流程,构建阶段自动压缩大图片为webp格式,自动合并重复资源;
  3. 持续优化应用的性能表现和用户体验,并根据实际情况进行进一步的优化调整。


作者:jack5288
来源:juejin.cn/post/7379168502455222311
收起阅读 »

鸿蒙实现动态增删 View,看这一篇就够了!!

在 Android 开发的过程中,我们经常使用 addView ,removeView等实现在 java 代码中动态添加和删除 View 的能力 但是在鸿蒙中,组件是相对于静态的结构,而且鸿蒙也没有提供类似于 addView ,removeView的方法,那我...
继续阅读 »

在 Android 开发的过程中,我们经常使用 addViewremoveView等实现在 java 代码中动态添加和删除 View 的能力


但是在鸿蒙中,组件是相对于静态的结构,而且鸿蒙也没有提供类似于 addViewremoveView的方法,那我们怎么来实现动态化增删组件的能力呢


1. 使用 ForEach 实现动态化增删组件


1.1. ForEach 的入参


翻阅了鸿蒙的官方文档,终于看到了一种方法来解决这个问题,那就是使用 ForEach这个组件


这个组件的官方定义如下


ForEach(
arr: Array,
itemGenerator: (item: any, index: number) => void,
keyGenerator?: (item: any, index: number) => string
)


  • arr


    arr 有多少个元素,ForEach 就会渲染多少个组件


  • itemGenerator


    将 arr 中的元素转换为对应的组件样式


    决定了组件长什么样子


  • keyGenerator


    生成唯一的 key



1.2. ForEach 的渲染


在ForEach循环渲染过程中,系统会为每个数组元素生成一个唯一且持久的键值,用于标识对应的组件。当这个键值变化时,ArkUI框架将视为该数组元素已被替换或修改,并会基于新的键值创建一个新的组件。


ForEach 的渲染分为两种:



  • 首次渲染


在ForEach首次渲染时,会根据前述键值生成规则为数据源的每个数组项生成唯一键值,并创建相应的组件。



  • 非首次渲染:


在ForEach组件进行非首次渲染时,它会检查新生成的键值是否在上次渲染中已经存在。如果键值不存在,则会创建一个新的组件;如果键值存在,则不会创建新的组件,而是直接渲染该键值所对应的组件。


2. ForEach 的简单 demo


@Entry
@Component
export struct MyPage {
@State items: Item[] = [
{ text: "1" },
{ text: "2" },
{ text: "3" },
]

build() {
Column() {
ForEach(
this.items,
(item: Item, index: number) => {
Text(item.text)
.width(40)
.height(40)
}
)

Button("add item ")
.onClick(
()=>{
this.items.push(
{ text: "11" },
)
}
)
}
.height('100%')
.width('100%')
}
}


interface Item {
text: string,
}

运行后的状态为这样:每次点击按钮,都会新增一个



3. ForEach 的注意事项


3.1. 数组中元素子属性发生变化时的处理


数组中元素子属性发生变化时,鸿蒙默认是不会触发渲染的


解决办法是:使用@Observed@ObjectLink


3.2. 最好自定义 key,key 中不含index


鸿蒙的默认 key


鸿蒙 Foreach 的默认key 为


(item: T, index: number) => {
//👇 这里带着 index
return index + '__' + JSON.stringify(item);
}

默认的 key 里面带着 index,如果我们在对数组进行操作的过程中,将元素的 index改变了,就会导致 index 改变的元素对应的组件被重绘


鸿蒙 ForEach 的渲染


鸿蒙通过 key 来判断组件是新组件还是现有组件


index 改变,key 就改变了,鸿蒙就会执行两个操作



  1. 删除原来的 key 对应的组件

  2. 重新创建组件


就会导致原来的组件中的所有状态全部没有了比如说我们正在执行动画,但是 key 被改变了,动画就会中断,重新创建的组件没有动画执行的状态,如果我们想继续执行动画,那么必须保存动画的状态,这样成本太大了,


而我们只需要自定义 key,让 index 不在 key 里面,就解决这个问题了


为什么 index 会改变?


比如说中间的元素被删除了,后面的元素 index 就会改变


而且我们不可能在执行时,一定保证移除的是最后一个元素,添加的一定在最后面


而如果我们自定义key 里面没有 index,那么我们可以随意的增删数组中的元素


自定义 key 的优点



  • 可以让我们任意对数组进行操作,

  • 优化性能


比如说我们只是删除了数组中间的一个元素,后面的元素没有任何的改变


如果我们使用鸿蒙默认的 key,后面的元素 index 改变,key 改变,全部会重绘


如果我们自定义 key,后面的元素就不会重绘了,节省性能


自定义 key 的注意事项



  1. 一定不要包含 index

  2. key 的组成部分尽量全部为常量,不要变量


如果是变量,保证组件生命周期内不会变化


或者如果生命周期发生变化,是自己预期内的



作者:wddin
来源:juejin.cn/post/7374984900372709412
收起阅读 »

协程Job的取消,你真的用对了吗?

前言我们知道,调用协程的lifecycleScope的launch方法后会生成一个Job对象,Job可以调用cancel()方法来取消,也可以由lifecycle宿主在生命周期结束时自行取消。但job取消后,并不代表其后面的代码都不执行了,在老油条同事的代码里...
继续阅读 »

前言

我们知道,调用协程的lifecycleScope的launch方法后会生成一个Job对象,Job可以调用cancel()方法来取消,也可以由lifecycle宿主在生命周期结束时自行取消。但job取消后,并不代表其后面的代码都不执行了,在老油条同事的代码里也发现了同样的问题,cancel后并没有真正停掉后台的任务

结论

先说结论,协程Job的cancel()方法并不会立即中断后续代码的执行,只是将任务状态isActive改为false。只有当执行下一个可取消的suspend方法时,才会抛出一个CancellationException,停掉后面的代码。 这意味着,如果一个Job在任务过程中不存在一个可取消suspend方法的调用,那么直到任务结束都不会停止,即使是调用了cancel()方法。

fun jobTest() {
runBlocking {
val job1 = launch(Dispatchers.IO) {
Log.d(TAG, "job1 start")
Thread.sleep(2_000)
Log.d(TAG, "job1 finish")
}
val job2 = launch {
Log.d(TAG, "job2 start")
delay(2_000)
Log.d(TAG, "job2 finish")
}
delay(1000)
job1.cancel()
job2.cancel()
}
}
2024-06-10 23:05:37.407 21238-21272 JobTest    D  job1 start
2024-06-10 23:05:37.407 21238-21327 JobTest D job2 start
2024-06-10 23:05:39.407 21238-21272 JobTest D job1 finish

如上述示例中,job1跟job2都调用了cancel()方法取消,但由于job1任务内没有suspend方法,job1在cancel后依然执行完了代码;而job2在第二个delay方法前取消了,后面的代码也不再执行。

虽然说协程任务的错误取消,通常情况下也不会导致逻辑出错或者业务异常,但还是会造成一些后台资源的浪费或者内存泄漏问题。而且也由于没有太大影响,很多时候也难以被发现,像是代码刺客一样的东西在危害着项目。

如何取消协程

  1. 既然job取消后会改变任务状态,可以在代码语句中根据isActive状态决定是否继续执行
lifecycleScope.launch(Dispatchers.IO) {
val job = launch {
Log.d(TAG, "job start")
while (isActive) {
//..
}
Log.d(TAG, "job finish")
}
delay(1000)
job.cancel()
Log.d(TAG, "job cancel")
}
2024-06-10 23:54:46.430  4094-4353  JobTest        D  job start
2024-06-10 23:54:47.434 4094-4330 JobTest D job cancel
2024-06-10 23:54:47.434 4094-4353 JobTest D job finish

  1. 在代码执行语句中有suspend修饰的挂起方法,在协程取消后执行到suspend方法会抛出异常,从而停止协程job
lifecycleScope.launch(Dispatchers.IO) {
val job = launch {
Log.d(TAG, "job start")
while (true) {
delay(1)
}
Log.d(TAG, "job finish")
}
job.invokeOnCompletion {
Log.d(TAG, "invokeOnCompletion:$it")
}
delay(1000)
job.cancel()
Log.d(TAG, "job cancel")
}
2024-06-10 23:59:22.531 10172-10371 JobTest        D  job start
2024-06-10 23:59:23.536 10172-10270 JobTest D job cancel
2024-06-10 23:59:23.539 10172-10380 JobTest D invokeOnCompletion:kotlinx.coroutines.JobCancellationException: StandaloneCoroutine was cancelled; job=StandaloneCoroutine{Cancelled}@3df8870

可以看到任务抛出了JobCancellationException,并且不会执行到job finish语句。

两种任务停止方式的区别在于,第二种方式因为delay()这个suspend方法抛出了异常而终止执行,第一种由于没有遇到suspend方法并不会抛出异常,可以执行到结束。

那么只要是suspend方法就一定能停止协程吗?

lifecycleScope.launch(Dispatchers.IO) {
val job = launch {
Log.d(TAG, "job start")
while (true) {
emptySuspend()
}
Log.d(TAG, "job finish")
}
job.invokeOnCompletion {
Log.d(TAG, "invokeOnCompletion:$it")
}
delay(1000)
job.cancel()
Log.d(TAG, "job cancel")
}

private suspend fun emptySuspend() {
return suspendCoroutine {
it.resume(Unit)
}
}

2024-06-11 00:04:45.144 14010-14234 JobTest        D  job start
2024-06-11 00:04:46.151 14010-14241 JobTest D job cancel

运行后等待数秒,发现并不会抛出异常。明明一直在调用suspend方法,任务取消后却不会响应。

事实上,普通suspend方法并不会处理cancel标志,只有suspendCancelable类型方法会在执行前判断cancel状态并抛出异常。而常见的delay、emit方法都是suspendCancelable类型。

将emptySuspend()方法做一个修改如下

private suspend fun emptySuspend() {
return suspendCancellableCoroutine {
it.resume(Unit)
}
}

运行后发现任务可以被cancel()掉而停止

2024-06-11 00:09:11.169 17728-17872 JobTest        D  job start
2024-06-11 00:09:12.174 17728-17865 JobTest D job cancel
2024-06-11 00:09:12.177 17728-17872 JobTest D invokeOnCompletion:kotlinx.coroutines.JobCancellationException: StandaloneCoroutine was cancelled; job=StandaloneCoroutine{Cancelled}@7cc1e91

协程取消原理

再简单从协程的实现原理解释一下为什么协程Job要在执行suspend方法时才能中断。

挂起方法

用suspend修饰的方法称为挂起方法,需要在协程作用域才能调用。

suspend fun delaySuspend() {
Log.d(TAG, "start delay: ")
delay(100)
Log.d(TAG, "delay end")
}

挂起方法会编译成Switch状态机模式,每个挂起方法都是其中一个case,每个case执行都依赖前面的case,这就是协程切换与挂起停止的原理。协程本质上是产生了一个 switch 语句,每个挂起点之间的逻辑都是一个 case 分支的逻辑。 参考 协程是如何实现的 中的例子:

Function1 lambda = (Function1)(new Function1((Continuation)null) {
    int label;

    @Nullable
    public final Object invokeSuspend(@NotNull Object $result) {
        byte text;
        @BlockTag1: {
            Object result;
            @BlockTag2: {
                result = IntrinsicsKt.getCOROUTINE_SUSPENDED();
                switch(this.label) {
                    case 0:
                        ResultKt.throwOnFailure($result);
                        this.label = 1;
                        if (SuspendTestKt.dummy(this) == result) {
                            return result;
                        }
                        break;
                    case 1:
                        ResultKt.throwOnFailure($result);
                        break;
                    case 2:
                        ResultKt.throwOnFailure($result);
                        break @BlockTag2;
                    case 3:
                        ResultKt.throwOnFailure($result);
                        break @BlockTag1;
                    default:
                        throw new IllegalStateException("call to 'resume' before 'invoke' with coroutine");
            }

            text = 1;
            System.out.println(text);
            this.label = 2;
            if (SuspendTestKt.dummy(this) == result) {
                return result;
            }
        }

        text = 2;
        System.out.println(text);
        this.label = 3;
        if (SuspendTestKt.dummy(this) == result) {
            return result;
        }
    }
    text = 3;
    System.out.println(text);
    return Unit.INSTANCE;
}

@NotNull
public final Continuation create(@NotNull Continuation completion) {
    Intrinsics.checkNotNullParameter(completion, "completion");
    Function1 funcation = new constructor>(completion);
    return funcation;
}

public final Object invoke(Object object) {
    return (()this.create((Continuation)object)).invokeSuspend(Unit.INSTANCE);
        }
});

任务取消

任务取消后,对于suspendCancelable方法的分支,会因为取消的状态而抛出JobCancellationException,停止后续代码的执行。如果在job中对于异常进行捕获,将可能导致任务取消失败。

lifecycleScope.launch(Dispatchers.IO) {
val job = launch {
Log.d(TAG, "job start")
kotlin.runCatching {
while (true) {
emptySuspend()
}
}.onFailure {
Log.e(TAG, "catch: $it")
}
Log.d(TAG, "job finish")
}
delay(1000)
job.cancel()
Log.d(TAG, "job cancel")
}
2024-06-11 00:22:22.686 25890-26199 JobTest        D  job start
2024-06-11 00:22:23.690 25890-26217 JobTest D job cancel
2024-06-11 00:22:23.696 25890-26199 JobTest E catch: kotlinx.coroutines.JobCancellationException: StandaloneCoroutine was cancelled; job=StandaloneCoroutine{Cancelling}@e557022
2024-06-11 00:22:23.696 25890-26199 JobTest D job finish

由于捕获了JobCancellationException,导致job finish语句正常执行了。在一些情况下,可能会由于JobCancellationException被捕获导致任务没有及时取消。因此在job内捕获异常时,选择性的过滤掉JobCancellationException,将异常再度抛出

kotlin.runCatching {
// ...
}.onFailure {
if (it is CancellationException) {
throw it
}
}

协程异常处理

协程遇到无法处理的异常后,会按照停止自身子任务-停止自身任务-停止父任务的顺序依次停掉任务,并将异常抛给父作用域。当所有作用域都无法处理异常,会抛给unCautchExceptionHandler。如果异常一直没被处理,则可能引起崩溃。

值得一提的是,由Job.cancel()方法引起的CancellationException并不会传给父Job,在cancelParent之前会被过滤掉,也就是cancel()方法只能取消自身和子协程,不会影响父协程,也不会引起程序崩溃。


作者:护城河编程大师
来源:juejin.cn/post/7378363694939635722
收起阅读 »

鸿蒙,ArkTs 一段诡异的代码

分享一段我之前在学习 ArkTs 的时候,看到的一段诡异的代码。我们来看看下面的代码,按照你多年的经验,分析一下下面的代码是否可以正常执行,如果可以执行的话,能说出运行结果吗。for (let i = 0; i < 3; i++) { let i =...
继续阅读 »

分享一段我之前在学习 ArkTs 的时候,看到的一段诡异的代码。我们来看看下面的代码,按照你多年的经验,分析一下下面的代码是否可以正常执行,如果可以执行的话,能说出运行结果吗。

for (let i = 0; i < 3; i++) {
let i = 'abc';
console.log(i);
}

以上代码是可以正常运行的,这段代码的执行结果,将会输出了 3 个 'abc' :

abc
abc
abc

这段代码中的 for 循环尝试执行三次循环,每次循环中都声明了一个新的局部变量 i,并将其赋值为字符串 'abc'。然后,它打印出这个新的局部变量 i

在每次迭代中,尽管外部循环的控制变量也叫 i,但内部的 let i = 'abc'; 实际上创建了一个新的、同名的局部变量 i,这个变量的作用域仅限于 for 循环的块内部。因此,每次迭代打印的都是这个块作用域内的字符串 'abc',而不是外部循环控制变量的数值。

从执行结果也能间接说明,for 循环内部声明的变量 i 和循环变量 i 不在同一个作用域,它们有各自单独的作用域,设置循环变量的那部分是一个父作用域,而循环体内部是一个单独的子作用域,这是 for 循环的特别之处。

如果在同一个作用域,是不可使用 let 或者 const 重复声明相同名字的变量。比如下面的代码会报错。

if(true){
let a = 1;
let a = 2; // 报错

const b = 3;
const b = 4; // 报错
}

这就引发出了另外一问题 块作用域

块作用域是指变量在定义它的代码块或者说是大括号 {} 内有效的作用域。使用 let 或者 const 关键字声明的变量具有块级作用域(block scope),这意味着变量在包含它的块(在这个例子中是 for 循环的大括号内)以及任何嵌套的子块中都是可见的。

块作用域示例:

let blockScopedVariable = 'I am dhl';
if (true) {
let blockScopedVariable = 'I am block scoped';
console.log(blockScopedVariable); // 输出: I am block scoped
}
console.log(blockScopedVariable); // 输出: I am dhl

从执行结果可以看出,在 if 语句中定义的变量 blockScopedVariable,仅在代码块内有效,外层变量不会被内层同名变量的声明和赋值影响。

但是需要注意,在 ArkTS 中不能使用 for .. in,否则会有一个编译警告。

之所以不能使用 for .. in 是因为在 ArkTS 中,对象的布局在编译时是确定的,且不能在程序执行期间更改对象的布局,换句话说,ArkTS 禁止以下行为:

  • 向对象中添加新的属性或方法
  • 从对象中删除已有的属性或方法
  • 将任意类型的值赋值给对象属性

如果修改对象布局会影响代码的可读性以及运行时性能。

从开发的角度来说,在某处定义类,然后又在其它地方修改了实际对象布局,这很容易引入错误,另外如果修改对象布局,需要在运行时支持,这样会增加执行开销降低性能。


作者:程序员DHL
来源:juejin.cn/post/7376158566598705167
收起阅读 »

什么?这个 App 抓不到包

有个朋友说,某个车联网的 app 竟然抓不到包,让我帮忙看下,行吧,那就看下。 样本:byd海洋(v1.4.0) 小试牛刀 先用 Charles软件试试,使用这个软件抓 App 的包,有几个前提: 手机端要配置好Charles的证书 电脑端安装好 Cha...
继续阅读 »

有个朋友说,某个车联网的 app 竟然抓不到包,让我帮忙看下,行吧,那就看下。



  • 样本:byd海洋(v1.4.0)


小试牛刀


先用 Charles软件试试,使用这个软件抓 App 的包,有几个前提:



  • 手机端要配置好Charles的证书

  • 电脑端安装好 Charles 的根证书,并信任

  • 手机端证书要放到系统证书目录 (Android 系统 7.0 一下,这一步可以省略)


没配置好的话是抓不到 https 请求的,环境配置好后先抓个 https 的包测试一下
image.png
可以正常获取到数据,说明抓包环境配置成功。
现在对目标 app 抓包,看下是否成功,结果如下图
image.png
可以发现,并没有成功,为什么会这样呢🤔


为什么抓不到App包


目前App用到的网络请求库都是 OKHttp3,这个库有一个 api:CertificatePinner这个 API 的作用是



用于自定义证书验证策略,以增强网络安全性。在进行TLS/SSL连接时,服务器会提供一个SSL证书,客户端需要验证该证书的有效性以确保连接的安全性。CertificatePinner允许你指定哪些SSL证书是可接受的,从而有效地限制了哪些服务器可以与你的应用程序通信。


具体来说,CertificatePinner允许你指定一组预期的证书公钥或证书固定哈希值(SHA-256),以便与服务器提供的证书进行比较。如果服务器提供的证书与你指定的公钥或哈希值不匹配,则连接会被拒绝,从而防止中间人攻击和其他安全风险。



我们大胆的猜测一下,目标 App 就是用到了这个 API,添加了自定义的证书验证策略,我们既要大胆猜测,又要小心验证,怎么验证呢?这就要用到 Frida 了,用Frida Hook 这个 API,使其失效。Frida 代码如下


try {

var CertificatePinner = Java.use('okhttp3.CertificatePinner');

quiet_send('OkHTTP 3.x Found');

CertificatePinner.check.overload('java.lang.String', 'java.util.List').implementation = function () {

quiet_send('OkHTTP 3.x check() called. Not throwing an exception.');
}

} catch (err) {

// If we dont have a ClassNotFoundException exception, raise the
// problem encountered.
if (err.message.indexOf('ClassNotFoundException') === 0) {

throw new Error(err);
}
}

验证了一下,发现还是抓不到包。
除了这个 API 可以用来防止中间人攻击,OKHttp3还有其他防止中间人攻击的方法,如X509TrustManager



X509TrustManager 是Java安全架构中的一个接口,位于 javax.net.ssl 包中,主要用于处理 SSL(Secure Sockets Layer)和后续的 TLS(Transport Layer Security)协议中的证书信任管理功能。在基于SSL/TLS的网络通信中,特别是HTTPS连接,X509TrustManager扮演着至关重要的角色,它的主要职责是验证远程主机提供的X.509证书链的有效性。



同样,再用 Frida 验证一下,是不是X509TrustManager导致的抓不到包,代码如下


  var TrustManager;
try {
TrustManager = Java.registerClass({
name: 'org.wooyun.TrustManager',
implements: [X509TrustManager],
methods: {
checkClientTrusted: function (chain, authType) {
},
checkServerTrusted: function (chain, authType) {
},
getAcceptedIssuers: function () {
return [];
}
}
});
} catch (e) {
quiet_send("registerClass from X509TrustManager >>>>>>>> " + e.message);
}


// Prepare the TrustManagers array to pass to SSLContext.init()
var TrustManagers = [TrustManager.$new()];

try {
// Prepare a Empty SSLFactory
var TLS_SSLContext = SSLContext.getInstance("TLS");
TLS_SSLContext.init(null, TrustManagers, null);
} catch (e) {
quiet_send(e.message);
}

send('Custom, Empty TrustManager ready');

// Get a handle on the init() on the SSLContext class
var SSLContext_init = SSLContext.init.overload(
'[Ljavax.net.ssl.KeyManager;', '[Ljavax.net.ssl.TrustManager;', 'java.security.SecureRandom');

// Override the init method, specifying our new TrustManager
SSLContext_init.implementation = function (keyManager, trustManager, secureRandom) {

quiet_send('Overriding SSLContext.init() with the custom TrustManager');

SSLContext_init.call(this, null, TrustManagers, null);
};

Frida 执行上面的代码,再看下是否抓到包
image.png
这次就可以顺利的抓到数据了。


抓包工具推荐


虽然利用上面 Frida 的脚本可以成功抓包,但是上面的操作还是略显复杂,况且有的 App 还会有 Frida 检测,操作起来就难度骤升。
这里推荐一个抓包工具 httptoolkit,官网界面如下
image.png
使用起来也很简单



  • 下载安装这个软件

  • 连接手机

  • 选择 Android Device Via ADB选项卡

  • 打开目标应用,开始抓包


看下这个软件的抓包效果
image.png
可以看到,数据都出来了并且不用额外的设置。


Xposed 方案


如果你手机刷了 Xposed,那就很简单了,只需要安装 JustTrustMe++模块就可以了。安装之后,也可以通过 Charles软件直接抓包了。



本文的目的只有一个就是学习更多的逆向技巧和思路,如果有人利用本文技术去进行非法商业获取利益带来的法律责任都是操作者自己承担,和本文以及作者没关系.


本文涉及到的代码项目可以去 爱码者说 知识星球自取,欢迎加入知识星球一起学习探讨技术。
关注公众号 爱码者说 及时获取最新推送文章。



作者:平行绳
来源:juejin.cn/post/7374665776537567286
收起阅读 »

如何让不同Activity之间共享同一个ViewModel

问题背景 存在一个场景,在Acitivity1可以跳转到Activity2,但是两个Activty之间希望能共享数据 提出假设的手段 可以定义一个ViewModel,让这两个Activity去共享这个ViewModel 存在的问题 根据不同的Lifecycle...
继续阅读 »

问题背景


存在一个场景,在Acitivity1可以跳转到Activity2,但是两个Activty之间希望能共享数据


提出假设的手段


可以定义一个ViewModel,让这两个Activity去共享这个ViewModel


存在的问题


根据不同的LifecycleOwner创建出来的ViewModel是不同的实例,所以在两个不同的Activity之间无法创建同一个ViewModel对象


问题分析


先来梳理一下一个正常的ViewModel是怎么被构造出来的:



  1. ViewModel是由ViewModelFactoty负责构造出来

  2. 构造出来之后,存储在ViewModelStore里面
    但是问题是ViewModelStore是 和 (宿主Activity或者Fragment)是一一对应的关系
    具体代码如下


@MainThread  
public inline fun <reified VM : ViewModel> Fragment.viewModels(
noinline ownerProducer: () -> ViewModelStoreOwner = { this },
noinline factoryProducer: (() -> Factory)? = null
): Lazy<VM> {
val owner by lazy(LazyThreadSafetyMode.NONE) { ownerProducer() }
return createViewModelLazy(
VM::class,
{ owner.viewModelStore },
{
(owner as? HasDefaultViewModelProviderFactory)?.defaultViewModelCreationExtras
?: CreationExtras.Empty
},
factoryProducer ?: {
(owner as? HasDefaultViewModelProviderFactory)?.defaultViewModelProviderFactory
?: defaultViewModelProviderFactory
})
}

看到上面的代码第9行,viewModelStore和owner是对应关系,所以原则上根据不同LifecycleOwner无法构造出同一个ViewModel对象


解决思路



  1. 无法在不同的LifecycleOwner之间共享ViewMode对象的原因是:ViewModel的存储方ViewModelStore是和LifecycleOwner绑定,那如果可以解开这一层绑定关系,理论上就可以实现共享;

  2. 另外我们需要定义ViewModel的销毁时机:


    我们来模拟一个场景:由Activty1跳转到Activity2,然后两个Activity共享同一个ViewModel,两个activity都要拿到同一个ViewModel的实例,那这个时候ViewModel的销毁时机应该是和Acitivity1的生命周期走,也就是退出Activity1(等同于Activity1走onDestroy)的时候,去销毁这个ViewModel。



所以按照这个思路走,ViewModel需要在activity1中被创建出来,并且保存在一个特定的ViewModelStore里面,要保证这个ViewModelStore可以被这两个Activity共享;


然后等到Activity2取的时候,就直接可以从这个ViewModelStore把这个ViewModel取出来;


最后在Activity1进到destroy的时候,销毁这个ViewModel


具体实现


重写一个ViewModelProvider实现如下功能点:



  1. 把里面的ViewModelStore定义成一个单例供所有的LifecycleOwner共享

  2. 定义ViewModel的销毁时机: LifecycleOwner走到onDestroy的时机


// 需要放到lifecycle这个包,否则访问不到ViewModelStore
package androidx.lifecycle

class GlobalViewModelProvider(factory: Factory = NewInstanceFactory()) :
ViewModelProvider(globalStore, factory) {
companion object {
private val globalStore = ViewModelStore()
private val globalLifecycleMap = HashMap<String, MutableSet<Lifecycle>>()
private const val DEFAULT_KEY = "androidx.lifecycle.ViewModelProvider.DefaultKey"
}

@MainThread
fun <T: ViewModel> get(lifecycle: Lifecycle, modelClass: Class<T>): T {
val canonicalName = modelClass.canonicalName ?: throw IllegalArgumentException("Local and anonymous classes can not be ViewModels")
return get(lifecycle, "$DEFAULT_KEY:$canonicalName", modelClass)
}

@MainThread
fun <T: ViewModel> get(lifecycle: Lifecycle, key: String, modelClass: Class<T>): T {
if (lifecycle.currentState == Lifecycle.State.DESTROYED) {
throw IllegalStateException("Could not get viewmodel when lifecycle was destroyed")
}
val viewModel = super.get(key, modelClass)
val lifecycleList = globalLifecycleMap.getOrElse(key) { mutableSetOf() }
globalLifecycleMap[key] = lifecycleList
if (!lifecycleList.contains(lifecycle)) {
lifecycleList.add(lifecycle)
lifecycle.addObserver(ClearNegativeVMObserver(lifecycle, key, globalStore, globalLifecycleMap))
}
return viewModel
}

private class ClearNegativeVMObserver(
private val lifecycle: Lifecycle,
private val key: String,
private val store: ViewModelStore,
private val map: HashMap<String, MutableSet<Lifecycle>>,
): LifecycleEventObserver {
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
if (event == Lifecycle.Event.ON_DESTROY) {
val lifecycleList = map.getOrElse(key) { mutableSetOf() }
lifecycleList.remove(lifecycle)
if (lifecycleList.isEmpty()) {
store.put(key, null)
map.remove(key)
}
}
}
}
}

具体使用


@MainThread  
inline fun <reified VM: ViewModel> LifecycleOwner.sharedViewModel(
viewModelClass: Class<VM> = VM::class.java,
noinline keyFactory: (() -> String)? = null,
noinline factoryProducer: (() -> ViewModelProvider.Factory)? = null,
)
: Lazy<VM> {
return SharedViewModelLazy(
viewModelClass,
keyFactory,
{ this },
factoryProducer ?: { ViewModelProvider.NewInstanceFactory() }
)
}

@PublishedApi
internal class SharedViewModelLazy<VM: ViewModel>(
private val viewModelClass: Class<VM>,
private val keyFactory: (() -> String)?,
private val lifecycleProducer: () -> LifecycleOwner,
private val factoryProducer: () -> ViewModelProvider.Factory,
): Lazy<VM> {
private var cached: VM? = null
override val value: VM
get() {
return cached ?: kotlin.run {
val factory = factoryProducer()
if (keyFactory != null) {
GlobalViewModelProvider(factory).get(
lifecycleProducer().lifecycle,
keyFactory.invoke(),
viewModelClass
)
} else {
GlobalViewModelProvider(factory).get(
lifecycleProducer().lifecycle,
viewModelClass
)
}.also {
cached = it
}
}
}

override fun isInitialized() = cached != null
}

场景使用


val vm : MainViewModel by sharedViewModel()

作者:红鲤驴
来源:juejin.cn/post/7366913974624059427
收起阅读 »

Android:解放自己的双手,无需手动创建shape文件

大家好,我是似曾相识2022。不喜欢唱跳篮球,但对杰伦的Rap却情有独钟。 现在的移动应用中为了美化界面,会给各类视图增加一些圆角、描边、渐变等等效果。当然系统也提供了对应的功能,那就是创建shape标签的XML文件,例如下图就是创建一个圆角为10dp,填充...
继续阅读 »

大家好,我是似曾相识2022。不喜欢唱跳篮球,但对杰伦的Rap却情有独钟。



现在的移动应用中为了美化界面,会给各类视图增加一些圆角、描边、渐变等等效果。当然系统也提供了对应的功能,那就是创建shape标签的XML文件,例如下图就是创建一个圆角为10dp,填充是白色的shape文件。再把这个文件设置给目标视图作为背景,就达到了我们想要的圆角效果。


<shape xmlns:android="http://schemas.android.com/apk/res/android">
<corners android:radius="8dp" />
<solid android:color="#FFFFFF" />
</shape>

//圆角效果
android:background="@drawable/shape_white_r10"

但不是所有的圆角和颜色都一样,甚至还有四个角单独一个有圆角的情况,当然还有描边、虚线描边、渐变填充色等等各类情况。随着页面效果的多样和复杂性,我们添加的shape文件也是成倍增加。


这时候不少的技术大佬出现了,大佬们各显神通打造了许多自定义View。这样我们就可以使用三方库通过在目标视图外嵌套一层视图来达到原本的圆角等效果。不得不说,这确实能够大大减少我们手动创建各类shape的情况,使用起来也是得心应手,方便了不少。


问题:


简单的布局,嵌套层级较少的页面使用起来还好。但往往随着页面的复杂程度越高,嵌套层级也越来多,这个时候再使用三方库外层嵌套视图会越来越臃肿和复杂。那么有没有一种方式可以直接在XML中当前视图中增减圆角等效果呢?


还真有,使用DataBinding可以办到!


这里就不单独介绍DataBinding的基础配置,网上一搜到处都是。咱们直接进入正题,使用**@BindingAdapter** 注解,这是用来扩展布局XML属性行为的注解。


使用DataBinding实现圆角


//自定义shape_radius、shape_solidColor字段  即圆角和填充颜色
@BindingAdapter(value = ["shape_radius""shape_solidColor"])
fun View.setViewBackground(radius: Int = 0,solidColor: Int = Color.TRANSPARENT){
val drawable = GradientDrawable()
drawable.cornerRadius = context.dp2px(radius.toFloat()).toFloat()
drawable.setColor(solidColor)
background = drawable
}

//xml文件中
shape_radius="@{10}"
shape_solidColor="@{@color/white}"

其实就是对当前视图的一个扩展,有点和kotlin的扩展函数类似。既然这样我们可以通过代码配置更多自定义的属性:


各方向圆角的实现:


//自定义shape_radius、shape_solidColor字段  即圆角和填充颜色
@BindingAdapter(value = ["
"shape_solidColor",//填充颜色
"shape_tl_radius",//上左圆角
"shape_tr_radius",//上右圆角
"shape_bl_radius",//下左圆角
"shape_br_radius"//下右圆角
])
fun View.setViewBackground(radius: Int = 0,solidColor: Int = Color.TRANSPARENT){
val drawable = GradientDrawable()
drawable.setColor(solidColor)
drawable.cornerRadii = floatArrayOf(
context.dp2px(shape_tl_radius.toFloat()).toFloat(),
context.dp2px(shape_tl_radius.toFloat()).toFloat(),
context.dp2px(shape_tr_radius.toFloat()).toFloat(),
context.dp2px(shape_tr_radius.toFloat()).toFloat(),
context.dp2px(shape_br_radius.toFloat()).toFloat(),
context.dp2px(shape_br_radius.toFloat()).toFloat(),
context.dp2px(shape_bl_radius.toFloat()).toFloat(),
context.dp2px(shape_bl_radius.toFloat()).toFloat(),
)
background = drawable
}

//xml文件中
shape_radius="@{10}"
shape_tl_radius="@{@color/white}"//左上角
shape_tr_radius="@{@color/white}"//右上角
shape_bl_radius="@{@color/white}"//左下角
shape_br_radius="@{@color/white}"//右下角

虚线描边:


//自定义shape_radius、shape_solidColor字段  即圆角和填充颜色
@BindingAdapter(value = [
"shape_radius"
"shape_solidColor"
"shape_strokeWitdh",//描边宽度
"shape_dashWith",//描边虚线单个宽度
"shape_dashGap",//描边间隔宽度
])
fun View.setViewBackground(
radius: Int = 0,
solidColor: Int = Color.TRANSPARENT,
strokeWidth: Int = 0,
shape_dashWith: Int = 0,
shape_dashGap: Int = 0
){
val drawable = GradientDrawable()
drawable.setStroke(
context.dp2px(strokeWidth.toFloat()),
strokeColor,
shape_dashWith.toFloat(),
shape_dashGap.toFloat()
)
drawable.setColor(solidColor)
background = drawable
}

//xml文件中
shape_radius="@{10}"
shape_solidColor="@{@color/white}"
strokeWidth="@{1}"
shape_dashWith="@{2}"
shape_dashGap="@{3}"

渐变色的使用:


//自定义shape_radius、shape_solidColor字段  即圆角和填充颜色
@BindingAdapter(value = [
"shape_startColor",//渐变开始颜色
"shape_centerColor",//渐变中间颜色
"shape_endColor",//渐变结束颜色
"shape_gradualOrientation",//渐变角度
])
fun View.setViewBackground(
shape_startColor: Int = Color.TRANSPARENT,
shape_centerColor: Int = Color.TRANSPARENT,
shape_endColor: Int = Color.TRANSPARENT,
shape_gradualOrientation: Int = 1,//TOP_BOTTOM = 1 ,TR_BL = 2,RIGHT_LEFT = 3,BR_TL = 4,BOTTOM_TOP = 5,BL_TR = 6,LEFT_RIGHT = 7,TL_BR = 8
){
val drawable = GradientDrawable()
when (shape_gradualOrientation) {
1 -> drawable.orientation = GradientDrawable.Orientation.TOP_BOTTOM
2 -> drawable.orientation = GradientDrawable.Orientation.TR_BL
3 -> drawable.orientation = GradientDrawable.Orientation.RIGHT_LEFT
4 -> drawable.orientation = GradientDrawable.Orientation.BR_TL
5 -> drawable.orientation = GradientDrawable.Orientation.BOTTOM_TOP
6 -> drawable.orientation = GradientDrawable.Orientation.BL_TR
7 -> drawable.orientation = GradientDrawable.Orientation.LEFT_RIGHT
8 -> drawable.orientation = GradientDrawable.Orientation.TL_BR
}
drawable.gradientType = GradientDrawable.LINEAR_GRADIENT//线性
drawable.shape = GradientDrawable.RECTANGLE//矩形方正
drawable.colors = if (shape_centerColor != Color.TRANSPARENT) {//有中间色
intArrayOf(
shape_startColor,
shape_centerColor,
shape_endColor
)
} else {
intArrayOf(shape_startColor, shape_endColor)
}//渐变色
background = drawable
}

//xml文件中
shape_startColor="@{@color/cl_F1E6A0}"
shape_centerColor="@{@color/cl_F8F8F8}"
shape_endColor=@{@color/cl_3CB9FF}

不止设置shape功能,只要可以通过代码设置的功能一样可以在BindingAdapter注解中自定义,使用起来是不是更加方便了。


总结:



  • 注解BindingAdapter中value数组的自定义属性一样要和方法内的参数一一对应,否则会报错。

  • 布局中使用该自定义属性时需要将布局文件最外层修改为layout标签

  • XML中使用自定义属性时一定要添加@{}


好了,以上便是解放自己的双手,无需手动创建shape文件的全部内容,希望能给大家带来帮助!


作者:似曾相识2022
来源:juejin.cn/post/7278858311596359739
收起阅读 »

Android — 实现同意条款功能

在开发App时,让用户知晓并同意隐私政策、服务协议等条款是必不可少的功能,恰逢Facebook最近对隐私政策的审核愈发严格,本文介绍如何通过TextView和ClickableSpan简单快速的实现同意条款功能。 下面是掘金(小米应用商店下载)和Github(...
继续阅读 »

在开发App时,让用户知晓并同意隐私政策、服务协议等条款是必不可少的功能,恰逢Facebook最近对隐私政策的审核愈发严格,本文介绍如何通过TextViewClickableSpan简单快速的实现同意条款功能。


下面是掘金(小米应用商店下载)和Github(Google Play下载)两个App的同意条款功能示例图:


掘金Github
image.pngimage.png

可以看见二者有所区别,这是由于国内政策不允许App自动勾选同意,必须要用户手动勾选,如果不按照要求处理甚至无法上架。


实现同意条款功能


先梳理一下实现同意条款功能的核心需求:



  1. 可以根据上架的区域不同(是否海外)决定是否显示勾选框,显示勾选框时需要提供可以获取选中状态的方法。

  2. 同意条款的提示中可能仅包含单个条款或同时包含多个条款。

  3. 条款名的颜色需要与其他文案不同,可以根据需求决定是否显示下划线,点击条款名时可以查看条款的具体内容。


上述三项需求最关键,但可以调整的细节有很多,本文仅通过较为简单的方式来实现(不使用自定义View),各位读者可以根据实际项目需求进行调整。


自定义配置类


上面的三点需求中都包含了一些配置项,可以通过配置类来管理这些参数,根据外部设定的配置进行相应处理。示例代码如下:


class ConfirmTermsConfiguration private constructor() {

// 同意提示文案
var confirmTipsContent: String = ""
private set

// 可点击的条款文案,键为条款文案,值为条款内容(链接)
var clickableTerms = ArrayMap<String, String>()
private set

// 同意条款控件距离底部的距离,默认为32dp
// 左右两侧的边距可以根据实际需求决定是否需要提供配置方法
var viewBottomMargin = DensityUtil.dp2Px(36)
private set

// 文字大小,默认14sp
var textSize = 14f
private set

// 文字颜色,默认黑色
var textColor = android.R.color.black
private set

// 可点击文字的颜色,默认为蓝色
var clickableTextColor = R.color.color_blue_229CE9
private set

// 是否显示下滑线,默认不显示
var showUnderline = false
private set

// 是否显示勾选框,默认为false
// 示例中勾选框直接使用可点击文案的颜色
// 可以根据实际需求决定是否提供相应的配置方法
var showCheckbox = false
private set

class Builder() {
private var confirmTipsContent: String = ""
private val clickableTerms = ArrayMap<String, String>()
private var viewBottomMargin = DensityUtil.dp2Px(36)
private var textSize = 14f
private var textColor = android.R.color.black
private var clickableTextColor = R.color.color_blue_229CE9
private var showUnderline = false
private var showCheckbox = false

fun setConfirmTipContent(confirmTipsContent: String): Builder {
this.confirmTipsContent = confirmTipsContent
return this
}

fun setClickableTerm(clickableTerm: String, termsLink: String): Builder {
clickableTerms.clear()
clickableTerms[clickableTerm] = termsLink
return this
}

fun addClickableTerms(clickableTerms: Map<String, String>): Builder {
this.clickableTerms.clear()
this.clickableTerms.putAll(clickableTerms)
return this
}

fun setViewBottomMargin(viewBottomMargin: Int): Builder {
this.viewBottomMargin = viewBottomMargin
return this
}

fun setTextSize(textSize: Float): Builder {
this.textSize = textSize
return this
}

fun setTextColor(textColor: Int): Builder {
this.textColor = textColor
return this
}

fun setClickableTextColor(clickableTextColor: Int): Builder {
this.clickableTextColor = clickableTextColor
return this
}

fun setShowUnderline(showUnderline: Boolean): Builder {
this.showUnderline = showUnderline
return this
}

fun setShowCheckbox(showCheckbox: Boolean): Builder {
this.showCheckbox = showCheckbox
return this
}

fun build(): ConfirmTermsConfiguration {
return ConfirmTermsConfiguration().also {
it.confirmTipsContent = confirmTipsContent
it.clickableTerms = clickableTerms
it.viewBottomMargin = viewBottomMargin
it.textSize = textSize
it.textColor = textColor
it.clickableTextColor = clickableTextColor
it.showUnderline = showUnderline
it.showCheckbox = showCheckbox
}
}
}
}

自定义ClickSpan


ClickSpan是Android中专门处理可点击文本的类,继承ClickSpan类可以实现定制可点击文本的样式以及响应事件。可以使用自定义ClickSpan来实现第三点需求,示例代码如下:


class ClickSpan(
// 默认颜色为白色
private var colorRes: Int = -1,
// 默认不显示下划线
private var isShoeUnderLine: Boolean = false,
// 点击事件监听,必须传入
private var clickListener: () -> Unit
) : ClickableSpan() {

override fun onClick(widget: View) {
// 回调点击事件监听
clickListener.invoke()
}

override fun updateDrawState(ds: TextPaint) {
super.updateDrawState(ds)
//设置文本颜色
ds.color = colorRes
//设置是否显示下划线
ds.isUnderlineText = isShoeUnderLine
}
}


显示、隐藏同意条款控件


有了配置类和自定义ClickSpan类之后,就可以实现显示、隐藏同意条款控件了,示例代码如下:



  • 辅助类


class ConfirmTermsHelper {

private var confirmTermsView: View? = null

var confirmStatus = false
private set

fun showConfirmTermsView(activity: Activity, confirmTermsConfiguration: ConfirmTermsConfiguration) {
val confirmTipsContent = confirmTermsConfiguration.confirmTipsContent
val clickableTerms = confirmTermsConfiguration.clickableTerms
val showCheckBox = confirmTermsConfiguration.showCheckbox
// 同意条款的提示文案为空直接结束方法执行
if (confirmTipsContent.isEmpty()) {
return
}
// 先把当前的控件移除
hideConfirmTermsView()
activity.runOnUiThread {
if (showCheckBox) {
ConstraintLayout(activity).apply {
// 代码中创建CheckBox存在Padding,暂时未解决
addView(AppCompatCheckBox(activity).apply {
id = R.id.cb_confirm_terms
val checkboxSize = DensityUtil.dp2Px(30)
layoutParams = ConstraintLayout.LayoutParams(checkboxSize, checkboxSize).apply {
topToTop = ConstraintLayout.LayoutParams.PARENT_ID
startToStart = ConstraintLayout.LayoutParams.PARENT_ID
}
setButtonDrawable(R.drawable.selector_confirm_terms_chekcbox)
buttonTintList = ColorStateList.valueOf(ContextCompat.getColor(activity, confirmTermsConfiguration.clickableTextColor))
setOnCheckedChangeListener { _, isChecked ->
confirmStatus = isChecked
}
})
addView(AppCompatTextView(activity).apply {
id = R.id.tv_confirm_terms
layoutParams = ConstraintLayout.LayoutParams(0, ConstraintLayout.LayoutParams.WRAP_CONTENT).apply {
topToTop = ConstraintLayout.LayoutParams.PARENT_ID
startToEnd = R.id.cb_confirm_terms
endToEnd = ConstraintLayout.LayoutParams.PARENT_ID
marginStart = DensityUtil.dp2Px(10)
}
textSize = confirmTermsConfiguration.textSize
setTextColor(ContextCompat.getColor(activity, confirmTermsConfiguration.textColor))
movementMethod = LinkMovementMethodCompat.getInstance()
text = SpannableStringBuilder(confirmTipsContent).apply {
clickableTerms.entries.forEach { clickableTermEntry ->
val startHighlightIndex = confirmTipsContent.indexOf(clickableTermEntry.key)
if (startHighlightIndex > 0) {
setSpan(
ClickSpan(ContextCompat.getColor(activity, confirmTermsConfiguration.clickableTextColor), confirmTermsConfiguration.showUnderline) {
// 通过CustomTab打开链接
CustomTabHelper.openSimpleCustomTab(activity, clickableTermEntry.value)
},
startHighlightIndex, startHighlightIndex + clickableTermEntry.key.length,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
}
}
})
layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.WRAP_CONTENT, Gravity.BOTTOM).apply {
val defaultLeftRightSpace = DensityUtil.dp2Px(20)
marginStart = defaultLeftRightSpace
marginEnd = defaultLeftRightSpace
bottomMargin = confirmTermsConfiguration.viewBottomMargin
}
}
} else {
AppCompatTextView(activity).apply {
layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.WRAP_CONTENT, Gravity.BOTTOM).apply {
val defaultLeftRightSpace = DensityUtil.dp2Px(20)
marginStart = defaultLeftRightSpace
marginEnd = defaultLeftRightSpace
bottomMargin = confirmTermsConfiguration.viewBottomMargin
}
textSize = confirmTermsConfiguration.textSize
setTextColor(ContextCompat.getColor(activity, confirmTermsConfiguration.textColor))
movementMethod = LinkMovementMethodCompat.getInstance()
text = SpannableStringBuilder(confirmTipsContent).apply {
clickableTerms.entries.forEach { clickableTermEntry ->
val startHighlightIndex = confirmTipsContent.indexOf(clickableTermEntry.key)
if (startHighlightIndex > 0) {
setSpan(
ClickSpan(ContextCompat.getColor(activity, confirmTermsConfiguration.clickableTextColor), confirmTermsConfiguration.showUnderline) {
// 通过CustomTab打开链接
CustomTabHelper.openSimpleCustomTab(activity, clickableTermEntry.value)
},
startHighlightIndex, startHighlightIndex + clickableTermEntry.key.length,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
}
}
}
}.run {
confirmTermsView = this
removeViewInParent(this)
getRootView(activity).addView(this)
}
}
}

fun hideConfirmTermsView() {
confirmStatus = false
confirmTermsView?.run { post { removeViewInParent(this) } }
confirmTermsView = null
}

private fun getRootView(activity: Activity): FrameLayout {
return activity.findViewById(android.R.id.content)
}

private fun removeViewInParent(targetView: View) {
try {
(targetView.parent as? ViewGr0up)?.removeView(targetView)
} catch (e: Exception) {
e.printStackTrace()
}
}
}


  • 示例页面


class ConfirmTermsExampleActivity : AppCompatActivity() {

private lateinit var binding: LayoutConfirmTermsExampleActivityBinding

private val confirmTermsHelper = ConfirmTermsHelper()

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = LayoutConfirmTermsExampleActivityBinding.inflate(layoutInflater).apply {
setContentView(root)
}

binding.btnWithCheckBox.setOnClickListener {
confirmTermsHelper.showConfirmTermsView(this, ConfirmTermsConfiguration.Builder()
.setConfirmTipContent("已阅读并同意\"隐私政策\"")
.setClickableTerm("隐私政策", "https://lf3-cdn-tos.draftstatic.com/obj/ies-hotsoon-draft/juejin/7b28b328-1ae4-4781-8d46-430fef1b872e.html")
.setShowCheckbox(true)
.setTextColor(R.color.color_gray_999)
.setClickableTextColor(R.color.color_black_3B3946)
.build())
binding.btnGetConfirmStatus.visibility = View.VISIBLE
}
binding.btnWithoutCheckBox.setOnClickListener {
confirmTermsHelper.showConfirmTermsView(this, ConfirmTermsConfiguration.Builder()
.setConfirmTipContent("By signing in you accept out Terms of use and Privacy policy")
.addClickableTerms(
mapOf(
Pair("Terms of use", "https://docs.github.com/en/site-policy/github-terms/github-terms-of-service"),
Pair("Privacy policy", "https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement")
)
)
.setShowUnderline(true)
.setTextColor(R.color.color_gray_999)
.build())
binding.btnGetConfirmStatus.visibility = View.GONE
}
binding.btnGetConfirmStatus.setOnClickListener {
showSnackbar("Current confirm status:${confirmTermsHelper.confirmStatus}")
}
}

private fun showSnackbar(message: String) {
runOnUiThread {
Snackbar.make(binding.root, message, Snackbar.LENGTH_SHORT).show()
}
}

override fun onDestroy() {
super.onDestroy()
confirmTermsHelper.hideConfirmTermsView()
}
}

效果演示与完整示例代码


最终演示效果如下:


Screen_recording_202 -original-original.gif

所有演示代码已在示例Demo中添加。


ExampleDemo github


ExampleDemo gitee


作者:ChenYhong
来源:juejin.cn/post/7372577541112872972
收起阅读 »

我的又一个神奇的框架——Skins换肤框架

为什么会有换肤的需求 app的换肤,可以降低app用户的审美疲劳。再好的UI设计,一直不变的话,也会对用户体验大打折扣,即使表面上不说,但心里或多或少会有些难受。所以app的界面要适当的改版啊,要不然可难受死用户了,特别是UI设计还相对较丑的。 换肤是什么 换...
继续阅读 »

为什么会有换肤的需求


app的换肤,可以降低app用户的审美疲劳。再好的UI设计,一直不变的话,也会对用户体验大打折扣,即使表面上不说,但心里或多或少会有些难受。所以app的界面要适当的改版啊,要不然可难受死用户了,特别是UI设计还相对较丑的。


换肤是什么


换肤是将app的背景色、文字颜色以及资源图片,一键进行全部切换的过程。这里就包括了图片资源和颜色资源。


Skins怎么使用


Skins就是一个解决这样一种换肤需求的框架。


// 添加以下代码到项目根目录下的build.gradle
allprojects {
repositories {
maven { url "https://jitpack.io" }
}
}
// 添加以下代码到app模块的build.gradle
dependencies {
// skins依赖了dora框架,所以你也要implementation dora
implementation("com.github.dora4:dora:1.1.12")
implementation 'com.github.dora4:dview-skins:1.4'
}

我以更换皮肤颜色为例,打开res/colors.xml。


<!-- 需要换肤的颜色 -->
<color name="skin_theme_color">@color/cyan</color>
<color name="skin_theme_color_red">#d23c3e</color>
<color name="skin_theme_color_orange">#ff8400</color>
<color name="skin_theme_color_black">#161616</color>
<color name="skin_theme_color_green">#009944</color>
<color name="skin_theme_color_blue">#0284e9</color>
<color name="skin_theme_color_cyan">@color/cyan</color>
<color name="skin_theme_color_purple">#8c00d6</color>

将所有需要换肤的颜色,添加skin_前缀和_skinname后缀,不加后缀的就是默认皮肤。
然后在启动页应用预设的皮肤类型。在布局layout文件中使用默认皮肤的资源名称,像这里就是R.color.skin_theme_color,框架会自动帮你替换。要想让框架自动帮你替换,你需要让所有要换肤的Activity继承BaseSkinActivity。


private fun applySkin() {
val manager = PreferencesManager(this)
when (manager.getSkinType()) {
0 -> {
}
1 -> {
SkinManager.changeSkin("cyan")
}
2 -> {
SkinManager.changeSkin("orange")
}
3 -> {
SkinManager.changeSkin("black")
}
4 -> {
SkinManager.changeSkin("green")
}
5 -> {
SkinManager.changeSkin("red")
}
6 -> {
SkinManager.changeSkin("blue")
}
7 -> {
SkinManager.changeSkin("purple")
}
}
}

另外还有一个情况是在代码中使用换肤,那么跟布局文件中定义是有一些区别的。


val skinThemeColor = SkinManager.getLoader().getColor("skin_theme_color")

这个skinThemeColor拿到的就是当前皮肤下的真正的skin_theme_color颜色,比如R.color.skin_theme_color_orange的颜色值“#ff8400”或R.id.skin_theme_color_blue的颜色值“#0284e9”。
SkinLoader还提供了更简洁设置View颜色的方法。


override fun setImageDrawable(imageView: ImageView, resName: String) {
val drawable = getDrawable(resName) ?: return
imageView.setImageDrawable(drawable)
}

override fun setBackgroundDrawable(view: View, resName: String) {
val drawable = getDrawable(resName) ?: return
view.background = drawable
}

override fun setBackgroundColor(view: View, resName: String) {
val color = getColor(resName)
view.setBackgroundColor(color)
}

框架原理解析


先看BaseSkinActivity的源码。


package dora.skin.base

import android.content.Context
import android.os.Bundle
import android.util.AttributeSet
import android.view.InflateException
import android.view.LayoutInflater
import android.view.View
import androidx.collection.ArrayMap
import androidx.core.view.LayoutInflaterCompat
import androidx.core.view.LayoutInflaterFactory
import androidx.databinding.ViewDataBinding
import dora.BaseActivity
import dora.skin.SkinManager
import dora.skin.attr.SkinAttr
import dora.skin.attr.SkinAttrSupport
import dora.skin.attr.SkinView
import dora.skin.listener.ISkinChangeListener
import dora.util.LogUtils
import dora.util.ReflectionUtils
import java.lang.reflect.Constructor
import java.lang.reflect.Method
import java.util.*

abstract class BaseSkinActivity<T : ViewDataBinding> : BaseActivity<T>(),
ISkinChangeListener, LayoutInflaterFactory {

private val constructorArgs = arrayOfNulls<Any>(2)

override fun onCreateView(parent: View?, name: String, context: Context, attrs: AttributeSet): View? {
if (createViewMethod == null) {
val methodOnCreateView = ReflectionUtils.findMethod(delegate.javaClass, false,
"createView", *createViewSignature)
createViewMethod = methodOnCreateView
}
var view: View? = ReflectionUtils.invokeMethod(delegate, createViewMethod, parent, name,
context, attrs) as View?
if (view == null) {
view = createViewFromTag(context, name, attrs)
}
val skinAttrList = SkinAttrSupport.getSkinAttrs(attrs, context)
if (skinAttrList.isEmpty()) {
return view
}
injectSkin(view, skinAttrList)
return view
}

private fun injectSkin(view: View?, skinAttrList: MutableList<SkinAttr>) {
if (skinAttrList.isNotEmpty()) {
var skinViews = SkinManager.getSkinViews(this)
if (skinViews == null) {
skinViews = arrayListOf()
}
skinViews.add(SkinView(view, skinAttrList))
SkinManager.addSkinView(this, skinViews)
if (SkinManager.needChangeSkin()) {
SkinManager.apply(this)
}
}
}

private fun createViewFromTag(context: Context, viewName: String, attrs: AttributeSet): View? {
var name = viewName
if (name == "view") {
name = attrs.getAttributeValue(null, "class")
}
return try {
constructorArgs[0] = context
constructorArgs[1] = attrs
if (-1 == name.indexOf('.')) {
// try the android.widget prefix first...
createView(context, name, "android.widget.")
} else {
createView(context, name, null)
}
} catch (e: Exception) {
// We do not want to catch these, lets return null and let the actual LayoutInflater
null
} finally {
// Don't retain references on context.
constructorArgs[0] = null
constructorArgs[1] = null
}
}

@Throws(InflateException::class)
private fun createView(context: Context, name: String, prefix: String?): View? {
var constructor = constructorMap[name]
return try {
if (constructor == null) {
// Class not found in the cache, see if it's real, and try to add it
val clazz = context.classLoader.loadClass(
if (prefix != null) prefix + name else name).asSubclass(View::class.java)
constructor = clazz.getConstructor(*constructorSignature)
constructorMap[name] = constructor
}
constructor!!.isAccessible = true
constructor.newInstance(*constructorArgs)
} catch (e: Exception) {
// We do not want to catch these, lets return null and let the actual LayoutInflater
null
}
}

override fun onCreate(savedInstanceState: Bundle?) {
val layoutInflater = LayoutInflater.from(this)
LayoutInflaterCompat.setFactory(layoutInflater, this)
super.onCreate(savedInstanceState)
SkinManager.addListener(this)
}

override fun onDestroy() {
super.onDestroy()
SkinManager.removeListener(this)
}

override fun onSkinChanged(suffix: String) {
SkinManager.apply(this)
}

companion object {
val constructorSignature = arrayOf(Context::class.java, AttributeSet::class.java)
private val constructorMap: MutableMap<String, Constructor<out View>> = ArrayMap()
private var createViewMethod: Method? = null
val createViewSignature = arrayOf(View::class.java, String::class.java,
Context::class.java, AttributeSet::class.java)
}
}

我们可以看到BaseSkinActivity继承自dora.BaseActivity,所以dora框架是必须要依赖的。有人说,那我不用dora框架的功能,可不可以不依赖dora框架?我的回答是,不建议。Skins对Dora生命周期注入特性采用的是,依赖即配置。


package dora.lifecycle.application

import android.app.Application
import android.content.Context
import dora.skin.SkinManager

class SkinsAppLifecycle : ApplicationLifecycleCallbacks {

override fun attachBaseContext(base: Context) {
}

override fun onCreate(application: Application) {
SkinManager.init(application)
}

override fun onTerminate(application: Application) {
}
}

所以你无需手动配置<meta-data android:name="dora.lifecycle.config.SkinsGlobalConfig" android:value="GlobalConfig"/>,Skins已经自动帮你配置好了。那么我顺便问个问题,BaseSkinActivity中最关键的一行代码是哪行?LayoutInflaterCompat.setFactory(layoutInflater, this)这行代码是整个换肤流程最关键的一行代码。我们来干预一下所有Activity onCreateView时的布局加载过程。我们在SkinAttrSupport.getSkinAttrs中自己解析了AttributeSet。


    /**
* 从xml的属性集合中获取皮肤相关的属性。
*/

fun getSkinAttrs(attrs: AttributeSet, context: Context): MutableList<SkinAttr> {
val skinAttrs: MutableList<SkinAttr> = ArrayList()
var skinAttr: SkinAttr
for (i in 0 until attrs.attributeCount) {
val attrName = attrs.getAttributeName(i)
val attrValue = attrs.getAttributeValue(i)
val attrType = getSupportAttrType(attrName) ?: continue
if (attrValue.startsWith("@")) {
val ref = attrValue.substring(1)
if (TextUtils.isEqualTo(ref, "null")) {
// 跳过@null
continue
}
val id = ref.toInt()
// 获取资源id的实体名称
val entryName = context.resources.getResourceEntryName(id)
if (entryName.startsWith(SkinConfig.ATTR_PREFIX)) {
skinAttr = SkinAttr(attrType, entryName)
skinAttrs.add(skinAttr)
}
}
}
return skinAttrs
}

我们只干预skin_开头的资源的加载过程,所以解析得到我们需要的属性,最后得到SkinAttr的列表返回。


package dora.skin.attr

import android.view.View
import android.widget.ImageView
import android.widget.TextView
import dora.skin.SkinLoader
import dora.skin.SkinManager

enum class SkinAttrType(var attrType: String) {

/**
* 背景属性。
*/

BACKGROUND("background") {
override fun apply(view: View, resName: String) {
val drawable = loader.getDrawable(resName)
if (drawable != null) {
view.setBackgroundDrawable(drawable)
} else {
val color = loader.getColor(resName)
view.setBackgroundColor(color)
}
}
},

/**
* 字体颜色。
*/

TEXT_COLOR("textColor") {
override fun apply(view: View, resName: String) {
val colorStateList = loader.getColorStateList(resName) ?: return
(view as TextView).setTextColor(colorStateList)
}
},

/**
* 图片资源。
*/

SRC("src") {
override fun apply(view: View, resName: String) {
if (view is ImageView) {
val drawable = loader.getDrawable(resName) ?: return
view.setImageDrawable(drawable)
}
}
};

abstract fun apply(view: View, resName: String)

/**
* 获取资源管理器。
*/

val loader: SkinLoader
get() = SkinManager.getLoader()
}

当前skins框架只定义了几种主要的换肤属性,你理解原理后,也可以自己进行扩展,比如RadioButton的button属性等。


开源项目传送门


如果你要深入理解完整的换肤流程,请阅读skins的源代码,[github.com/dora4/dview…] 。


作者:dora
来源:juejin.cn/post/7258483700815609916
收起阅读 »

如何选择 Android 唯一标识符

前言 大家好,我是未央歌,一个默默无闻的移动开发搬砖者~ 本文针对 Android 各种标识符做了统一收集,方便大家比对,以供选择适合大家的唯一标识符。 标识符 IMEI 从 Android 6.0 开始获取 IMEI 需要权限,并且从 Android 10...
继续阅读 »

前言


大家好,我是未央歌,一个默默无闻的移动开发搬砖者~


本文针对 Android 各种标识符做了统一收集,方便大家比对,以供选择适合大家的唯一标识符。


标识符


IMEI



  • 从 Android 6.0 开始获取 IMEI 需要权限,并且从 Android 10+ 开始官方取消了获取 IMEI 的 API,无法获取到 IMEI 了


fun getIMEI(context: Context): String {
val telephonyManager = context
.getSystemService(TELEPHONY_SERVICE) as TelephonyManager
return telephonyManager.deviceId
}

Android ID(SSAID)



  • 无需任何权限

  • 卸载安装不会改变,除非刷机或重置系统

  • Android 8.0 之后签名不同的 APP 获取的 Android ID 是不一样的

  • 部分设备由于制造商错误实现,导致多台设备会返回相同的 Android ID

  • 可能为空


fun getAndroidID(context: Context): String {
return Settings.System.getString(context.contentResolver,Settings.Secure.ANDROID_ID)
}

MAC 地址



  • 需要申请权限,Android 12 之后 BluetoothAdapter.getDefaultAdapter().getAddress()需要动态申请 android.permission.BLUETOOTH_CONNECT 权限

  • MAC 地址具有全局唯一性,无法由用户重置,在恢复出厂设置后也不会变化

  • 搭载 Android 10+ 的设备会报告不是设备所有者应用的所有应用的随机化 MAC 地址

  • 在 Android 6.0 到 Android 9 中,本地设备 MAC 地址(如 WLAN 和蓝牙)无法通过第三方 API 使用 会返回 02:00:00:00:00:00,且需要 ACCESS_FINE_LOCATION 或 ACCESS_COARSE_LOCATION 权限


Widevine ID



  • DRM 数字版权管理 ID ,访问此 ID 无需任何权限

  • 对于搭载 Android 8.0 的设备,Widevine 客户端 ID 将为每个应用软件包名称和网络源(对于网络浏览器)返回一个不同的值

  • 可能为空


fun getWidevineID(): String {
try {
val WIDEVINE_UUID = UUID(-0x121074568629b532L, -0x5c37d8232ae2de13L)
val mediaDrm = MediaDrm(WIDEVINE_UUID)
val widevineId = mediaDrm.getPropertyByteArray(MediaDrm.PROPERTY_DEVICE_UNIQUE_ID);
val sb = StringBuilder();
for (byte in widevineId) {
sb.append(String.format("x", byte))
}
return sb.toString();
} catch (e: Exception) {
} catch (e: Error) {
}
return ""
}

AAID



  • 无需任何权限

  • Google 推出的广告 ID ,可由用户重置的标识符,适用于广告用例

  • 系统需要自带 Google Play Services 才支持,且用户可以在系统设置中重置



重置后,在未获得用户明确许可的情况下,新的广告标识符不得与先前的广告标识符或由先前的广告标识符所衍生的数据相关联。




还要注意,Google Play 开发者内容政策要求广告 ID“不得与个人身份信息或任何永久性设备标识符(例如:SSAID、MAC 地址、IMEI 等)相关联。”




在支持多个用户(包括访客用户在内)的 Android 设备上,您的应用可能会在同一设备上获得不同的广告 ID。这些不同的 ID 对应于登录该设备的不同用户。



OAID



  • 无需任何权限

  • 国内移动安全联盟出台的“拯救”国内移动广告的广告跟踪标识符

  • 基本上是国内知名厂商 Android 10+ 才支持,且用户可以在系统设置中重置


UUID



  • 生成之后本地持久化保存

  • 卸载后重新安装、清除应用缓存 会改变


如何选择


同个开发商需要追踪对比旗下应用各用户的行为



  • 可以采用 Android ID(SSAID),并且不同应用需使用同一签名

  • 如果获得的 Android ID(SSAID)为空,可以用 UUID 代替【 OAID / AAID 代替也可,但需要引入第三方库】

  • 在 Android 8.0+ 中, Android ID(SSAID)提供了一个在由同一开发者签名密钥签名的应用之间通用的标识符


希望限制应用内的免费内容(如文章)



  • 可以采用 UUID ,作用域是应用范围,用户要想规避内容限制就必须重新安装应用


用户群体主要是大陆



  • 可以采用 OAID ,低版本配合采用 Android ID(SSAID)/ UUID

  • 可以采用 Android ID(SSAID),空的时候配合采用 UUID 等


用户群体在海外



  • 可以采用 AAID

  • 可以采用 Android ID(SSAID),空的时候配合采用 UUID 等




作者:未央歌
来源:juejin.cn/post/7262558218169008188
收起阅读 »

Android 沉浸式状态栏,透明状态栏 采用系统api,超简单近乎完美的实现

前言 沉浸式的适配有多麻烦,相信大家既然来搜索这个,就说明都在为此苦恼,那么看看这篇文章吧,也许对你有所帮助(最下面有源码链接) 有写的不对的地方,欢迎指出 从adnroid 6.0开始,官方逐渐完善了这方面的api,直到android 11... ... 让...
继续阅读 »

前言


沉浸式的适配有多麻烦,相信大家既然来搜索这个,就说明都在为此苦恼,那么看看这篇文章吧,也许对你有所帮助(最下面有源码链接)


有写的不对的地方,欢迎指出


从adnroid 6.0开始,官方逐渐完善了这方面的api,直到android 11...


... 让我们直接开始吧


导入核心包


老项目非androidx的请自行研究下,这里使用的是androidx,并且用的kotlin语言
本次实现方式跟windowInsets息息相关,这可真是个好东西
首先是需要导入核心包
androidx.core:core

kotlin可选择导入这个:
androidx.core:core-ktx
我用的版本是
androidx.core:core-ktx:1.12.0

开启 “沉浸式” 支持


沉浸式原本的意思似乎是指全屏吧。。。算了,不管那么多,喊习惯了 沉浸式状态栏,就这么称呼吧。

在activity 的oncreate里调用
//将decorView的fitSystemWindows属性设置为false
WindowCompat.setDecorFitsSystemWindows(window, false)
//设置状态栏颜色为透明
window.statusBarColor = Color.TRANSPARENT
//是否需要改变状态栏上的 图标、字体 的颜色
//获取InsetsController
val insetsController = WindowCompat.getInsetsController(window, window.decorView)
//mask:遮罩 默认是false
//mask = true 状态栏字体颜色为黑色,一般在状态栏下面的背景色为浅色时使用
//mask = false 状态栏字体颜色为白色,一般在状态栏下面的背景色为深色时使用
var mask = true
insetsController.isAppearanceLightStatusBars = mask
//底部导航栏是否需要修改
//android Q+ 去掉虚拟导航键 的灰色半透明遮罩
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
window.isNavigationBarContrastEnforced = false
}
//设置虚拟导航键的 背景色为透明
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
//8.0+ 虚拟导航键图标颜色可以修改,所以背景可以用透明
window.navigationBarColor = Color.TRANSPARENT
} else {
//低版本因为导航键图标颜色无法修改,建议用黑色,不要透明
window.navigationBarColor = Color.BLACK
}
//是否需要修改导航键的颜色,mask 同上面状态栏的一样
insetsController.isAppearanceLightNavigationBars = mask

修改 状态栏、虚拟导航键 的图标颜色,可以在任意需要的时候设置,防止图标和字体颜色和背景色一致导致看不清

补充一下:
状态栏和虚拟导航栏的背景色要注意以下问题:
1.在低于6.0的手机上,状态栏上的图标、字体颜色是白色且不支持修改的,MIUI,Flyme这些除外,因为它们有自己的api能实现修改颜色
2.在低于8.0的手机上,虚拟导航栏的图标、字体颜色是白色且不支持修改的,MIUI,Flyme这些除外,因为他们有自己的api能实现修改颜色
解决方案:
低于指定版本的系统上,对应的颜色就不要用透明,除非你的APP页面是深色背景,否则,建议采用半透明的灰色

在带有刘海或者挖孔屏上,横屏时刘海或者挖孔的那条边会有黑边,解决方法是:
给APP的主题v27加上
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
参考图:

image.png


监听OnApplyWindowInsetsListener


//准备一个boolean变量 作为是否在跑动画的标记
var flagProgress = false

//这里可以使用decorView或者是任意view
val view = window.decorView

//监听windowInsets变化
ViewCompat.setOnApplyWindowInsetsListener(view) { view: View, insetsCompat: WindowInsetsCompat ->
//如果要配合下面的setWindowInsetsAnimationCallback一起用的话,一定要记得,onProgress的时候,这里做个拦截,直接返回 insets
if (flagProgress) return@setOnApplyWindowInsetsListener insetsCompat
//在这里开始给需要的控件分发windowInsets

//最后,选择不消费这个insets,也可以选择消费掉,不在往子控件分发
insetsCompat
}
//带平滑过渡的windowInsets变化,ViewCompat中的这个,官方提供了 api 21-api 29的支持,本来这个只支持 api 30+的,相当不错!
//启用setWindowInsetsAnimationCallback的同时,也必须要启用上面的setOnApplyWindowInsetsListener,否则在某些情况下,windowInsets改变了,但是因为不会触发setWindowInsetsAnimationCallback导致padding没有更新到UI上
//DISPATCH_MODE_CONTINUE_ON_SUBTREE这个代表动画事件继续分发下去给子View
ViewCompat.setWindowInsetsAnimationCallback(view, object : WindowInsetsAnimationCompat.Callback(DISPATCH_MODE_CONTINUE_ON_SUBTREE) {
override fun onProgress(insetsCompat: WindowInsetsCompat, runningAnimations: List<WindowInsetsAnimationCompat>): WindowInsetsCompat {
//每一帧的windowInsets
//可以在这里分发给需要的View。例如给一个聊天窗口包含editText的布局设置这个padding,可以实现键盘弹起时,在底部的editText跟着键盘一起滑上去,参考微信聊天界面,这个比微信还丝滑(android 11+最完美)。
//最后,直接原样return,不消费
return insetsCompat
}

override fun onEnd(animation: WindowInsetsAnimationCompat) {
super.onEnd(animation)
//动画结束,将标记置否
flagProgress = false
}

override fun onPrepare(animation: WindowInsetsAnimationCompat) {
super.onPrepare(animation)
//动画准备开始,在这里可以记录一些UI状态信息,这里将标记设置为true
flagProgress = true
}
})

读取高度值


通过上面的监听,我们能拿到WindowInsetsCompat对象,现在,我们从这里面取到我们需要的高度值


先定义几个变量,我们需要拿的包含:
1. 刘海,挖空区域所占据的宽度或者是高度
2. 被系统栏遮挡的区域
3. 被输入法遮挡的区域

//cutoutPadding 刘海,挖孔区域的padding
var cutoutPaddingLeft = 0
var cutoutPaddingTop = 0
var cutoutPaddingRight = 0
var cutoutPaddingBottom = 0

//获取刘海,挖孔的高度,因为这个不是所有手机都有,所以,需要判空
insetsCompat.displayCutout?.let { displayCutout ->
cutoutPaddingTop = displayCutout.safeInsetTop
cutoutPaddingLeft = displayCutout.safeInsetLeft
cutoutPaddingRight = displayCutout.safeInsetRight
cutoutPaddingBottom = displayCutout.safeInsetBottom
}


//systemBarPadding 系统栏区域的padding
var systemBarPaddingLeft = 0
var systemBarPaddingTop = 0
var systemBarPaddingRight = 0
var systemBarPaddingBottom = 0

//获取系统栏区域的padding
//系统栏 + 输入法
val systemBars = insetsCompat.getInsets(WindowInsetsCompat.Type.ime() or WindowInsetsCompat.Type.systemBars())
//左右两侧的padding通常直接赋值即可,如果横屏状态下,虚拟导航栏在侧边,那么systemBars.left或者systemBars.right的值就是它的宽度,竖屏情况下,一般都是0
systemWindowInsetLeft = systemBars.left
systemWindowInsetRight = systemBars.right
//这里判断下输入法 和 虚拟导航栏是否存在,如果存在才设置paddingBottom
if (insetsCompat.isVisible(WindowInsetsCompat.Type.ime()) || insetsCompat.isVisible(WindowInsetsCompat.Type.navigationBars())) {
systemWindowInsetBottom = systemBars.bottom
}
//同样判断下状态栏
if (insetsCompat.isVisible(WindowInsetsCompat.Type.statusBars())) {
systemWindowInsetTop = systemBars.top
}

到这里,我们需要的信息已经全部获取到了,接下来就是根据需求,设置padding属性了

补充一下:
我发现在低于android 11的手机上,insets.isVisible(Type)返回始终为true
并且,即使系统栏被隐藏,systemBars.top, systemBars.bottom也始终会有高度
所以这里


保留原本的Padding属性


上述获取的值,直接去设置padding的话,会导致原本的padding属性失效,所以我们需要在首次设置监听,先保存一份原本的padding属性,在最后设置padding的时候,把这份原本的padding值加上即可,就不贴代码了。


第一次写文章,写的粗糙了点

可能我写的不太好,没看懂也没关系,直接去看完整代码吧


我专门写了个小工具,可以去看看:
沉浸式系统栏 小工具


如果有更好的优化方案,欢迎在github上提出,我们一起互相学习!


作者:Matchasxiaobin
来源:juejin.cn/post/7275943802938130472
收起阅读 »

我是如何使用Flow+Retrofit封装网络请求的

各位好,本人是练习kt时长一年的准练习生,接下来我将用一种我认为还行的方式封装网络请求,如有雷点,请各位佬轻喷 首先,定义一个请求结果类 sealed class RequestResult<out T> { data object INI...
继续阅读 »

各位好,本人是练习kt时长一年的准练习生,接下来我将用一种我认为还行的方式封装网络请求,如有雷点,请各位佬轻喷
首先,定义一个请求结果类


sealed class RequestResult<out T> {
data object INIT : RequestResult<Nothing>()
data object LOADING : RequestResult<Nothing>()
data class Success<out T>(val data: T) : RequestResult<T>()
data class Error(val errorCode: Int = -1, val errorMsg: String? = "") : RequestResult<Nothing>()
}

接下来,定义Retrofit的service,由于我个人的极简主义,特别讨厌复制粘贴,所以我做了一个非常大胆的决定


interface SimpleService {
//目前我们只关注这两方法
@GET
suspend fun commonGet(@Url url: String, @QueryMap param: HashMap<String, Any>): ApiResponse<Any>
//目前我们只关注这两方法
@POST
suspend fun commonPost(@Url url: String, @Body param: HashMap<String, Any>): ApiResponse<Any>

@GET
suspend fun commonGetList(@Url url: String, @QueryMap param: HashMap<String, Any>): ApiListData<Any>

@POST
suspend fun commonPostList(@Url url: String, @Body param: HashMap<String, Any>): ApiListData<Any>

@GET
suspend fun commonGetPageList(@Url url: String, @QueryMap param: HashMap<String, Any>): ApiPageData<Any>

@POST
suspend fun commonPostPageList(@Url url: String, @Body param: HashMap<String, Any>): ApiPageData<Any>
}

and在apiManager中生成这个service


object BaseApiManager {
val simpleService by lazy<SimpleService> {
getService()
}

接下来我定义了一个RequestParam类来帮助收敛请求需要的参数


@Keep
data class RequestParam<T>(
val clazz: Class<T>? = null,
val url: String,
val isGet: Boolean = true,
val paramBuilder: (HashMap<String, Any>.() -> Unit)? = null
){
val param: HashMap<String, Any>
get() {
val value = hashMapOf<String, Any>()
paramBuilder?.invoke(value)
return value
}
}

再然后便是请求真正发出的地方


internal fun <T> commonRequest(
param: RequestParam<T>,
builder: ((T) -> Unit)? = null
)
= flow {
emit(RequestResult.LOADING)
Timber.d(param.param.toString())
runCatching {
if (param.isGet) {
BaseApiManager.simpleService.commonGet(param.url, param.param)
} else {
BaseApiManager.simpleService.commonPost(param.url, param.param)
}
}.onSuccess {
if (it.code != StatusCode.REQUEST_SUCCESS) {
emit(RequestResult.Error(it.code, it.message))
} else {
val gson = Gson()
val data = gson.fromJson(gson.toJson(it.data), param.clazz)
builder?.invoke(data)
emit(RequestResult.Success(data))
}
}.onFailure {
emit(RequestResult.Error(StatusCode.REQUEST_FAILED, it.message))
}
}.flowOn(Dispatchers.IO)

在经过上述封装后,此时我在vm中发出一个网络请求就变成


viewModelScope.launch {
commonRequest(
RequestParam(
XXXBean::class.java, //数据类class
"/xxx/xxx/xxx", //地址
false //是否get
) {
put("xxx", 11)
put("xxxx", "25")
}
).collect {
when(it) {
RequestResult.INIT -> {
假设这边是Init弹窗
}
RequestResult.LOADING -> {
关闭Init弹窗
假设这边是Loading弹窗
}
is RequestResult.Error -> {
关闭Loading弹窗
toast(it.errorMsg)
}

is RequestResult.Success -> {
关闭Loading弹窗
发送成功事件或者改变UI状态
}
}
}

那么这边会遇到一个有点烦人的事情,实际上


RequestResult.INIT -> {
假设这边是Init弹窗
}
RequestResult.LOADING -> {
关闭Init弹窗
假设这边是Loading弹窗
}
is RequestResult.Error -> {
关闭Loading弹窗
toast(it.errorMsg)
}

这三兄弟中,我们经常会做一些重复的操作,于是我略施小计,将这几个行为定义成CommonEffect


sealed class MVICommonEffect {
data object ShowLoading: MVICommonEffect()
data object DismissLoading: MVICommonEffect()
data class ShowToast(val msg: String?): MVICommonEffect()
}

同时将Flow<RequestResult>的订阅步骤拆开,由于kt中两个隐式this对象写起来很繁琐,所以我是把这一串代码放到baseiewModel中的


fun <T> Flow<RequestResult<T>>.onInit(initBlock: suspend () -> Unit): Flow<RequestResult<T>> {
return onEach {
if (it == RequestResult.INIT) {
initBlock.invoke()
}
}
}

fun <T> Flow<RequestResult<T>>.onLoading(
showLoading: Boolean = true,
loadingBlock: suspend () -> Unit
)
: Flow<RequestResult<T>> {
return onEach {
if (it == RequestResult.LOADING) {
if (showLoading) {
emitLoadingEffect()
}
loadingBlock.invoke()
}
}
}

fun <T> Flow<RequestResult<T>>.onSuccess(
dismissLoading: Boolean = true,
successBlock: suspend ((data: T) -> Unit)
)
: Flow<RequestResult<T>> {
return onEach {
if (it is RequestResult.Success) {
if (dismissLoading) {
emitDismissLoadingEffect()
}
successBlock.invoke(it.data)
}
}
}

fun <T> Flow<RequestResult<T>>.onError(
dismissLoading: Boolean = true,
showToast: Boolean = true,
errorBlock: suspend (code: Int, msg: String?) -> Unit
)
: Flow<RequestResult<T>> {
return onEach {
if (it is RequestResult.Error) {
if (dismissLoading) {
emitDismissLoadingEffect()
}
if (showToast) {
emitToastEffect(it.errorMsg)
}
errorBlock.invoke(it.errorCode, it.errorMsg)
}
}
}

fun <T> Flow<RequestResult<T>>.onCommonSuccess(
loadingInvoke: Boolean,
showToast: Boolean,
successBlock: suspend ((data: T) -> Unit)
)
= this.onInit().onLoading(loadingInvoke)
.onError(
dismissLoading = loadingInvoke,
showToast = showToast
).onSuccess(
dismissLoading = loadingInvoke
) {
successBlock.invoke(it)
}

private val _commonEffect = MutableSharedFlow<MVICommonEffect>()
override val commonEffect: SharedFlow<MVICommonEffect> by lazy {
_commonEffect.asSharedFlow()
}

override suspend fun emitLoadingEffect() {
_commonEffect.emit(MVICommonEffect.ShowLoading)
}

override suspend fun emitDismissLoadingEffect() {
_commonEffect.emit(MVICommonEffect.DismissLoading)
}

override suspend fun emitToastEffect(msg: String?) {
_commonEffect.emit(MVICommonEffect.ShowToast(msg))
}

那么接下来,vm中网络请求就可以用一种很赏心悦目的方式出现了


private fun requestTestData(): Flow<RequestResult<XXXBean>> {
return commonRequest(
RequestParam(
XXXBean::class.java,
"xxx"
)
)
}

private fun updateTestData() {
requestData().onInit().onLoading().onError().onSuccess {
Timber.d(it.toString)
}.launchIn(viewModelScope)
}

接下来,只需要在基类View中订阅上述的MVICommonEffect,就可以handle大部分情况下的loading,toast.


由于本人能力有限,不足之处还望大佬指正.


作者:伟大的小炮队长
来源:juejin.cn/post/7368758932154843188
收起阅读 »

关于我裁员在家没事接了个私单这件事...

起因 2024年3月31日,我被公司裁员了。 2024年4月1日,果断踏上了回家的路,决定先休息一个星期。晚上回到了郑州,先跟一起被裁的同事在郑州小聚一下,聊聊后面的打算。第二天下午回家。 2024年4月8日,知道现在的大环境不好,不敢错过“金三银四”,赶忙回...
继续阅读 »

起因


2024年3月31日,我被公司裁员了。


2024年4月1日,果断踏上了回家的路,决定先休息一个星期。晚上回到了郑州,先跟一起被裁的同事在郑州小聚一下,聊聊后面的打算。第二天下午回家。


2024年4月8日,知道现在的大环境不好,不敢错过“金三银四”,赶忙回上海开始找工作。结果环境比预想的还要差啊,以前简历放开就有人找,现在每天投个几十封都是石沉大海。。。


2024年4月15日,有个好朋友找我,想让我给他们公司开发一个“拨号APP”(主要原因其实是这个好哥们想让我多个赚钱门路😌),主要的功能就是在他们的系统上点击一个“拨号”按钮,然后员工的工作手机上就自动拨打这个号码。


可行性分析


涉及到的修改:



  • 系统前后端

  • 拨号功能的APP


拿到这个需求之后,我并没有直接拒绝或者同意,而是先让他把他公司那边的的源代码发我了一份,大致看了一下使用的框架,然后找一些后端的朋友看有没人有人一起接这个单子;而我自己则是要先看下能否实现APP的功能(因为我以前从来没有做过APP!!!)。


我们各自看过自己的东西,然后又沟通了一番简单的实现过程后达成了一致,搞!


因为我这边之前的技术栈一直是VUE,所以决定使用uni-app实现,主要还是因为它的上手难度会低很多。


第一版


需求分析


虽说主体的功能是拨号,但其实是隐含很多辅助性需求的,比如拨号日志、通时通次统计、通话录音、录音上传、后台运行,另外除了这些外还有额外的例如权限校验、权限引导、获取手机号、获取拨号状态等功能需要实现。


但是第一次预算给的并不高,要把这些全部实现显然不可能。因此只能简化实现功能实现。



  • 拨号APP

    • 权限校验

      • 实现部分(拨号、录音、文件读写)



    • ❌权限引导

    • 查询当前手机号

      • 直接使用input表单,由用户输入



    • 查询当前手机号的拨号任务

      • 因为后端没有socket,使用setTimeout模拟轮询实现。



    • 拨号、录音、监测拨号状态

      • 根据官网API和一些安卓原生实现



    • 更新任务状态

      • 告诉后端拨号完成



    • ❌通话录音上传

    • ❌通话日志上传

    • ❌本地通时通次统计

    • 程序运行日志

    • 其他

      • 增加开始工作、开启录音的状态切换

      • 兼容性,只兼容安卓手机即可






基础设计


一个input框来输入用户手机号,一个开始工作的switch,一个开启录音的切换。用户输入手机号,点击开始工作后开启轮询,轮询到拨号任务后就拨号同时录音,同时监听拨号状态,当挂断后结束录音、更新任务状态,并开启新一轮的轮询。


开干


虽然本人从未开发过APP,但本着撸起袖子就是干的原则,直接打开了uni-app的官网就准备开怼。


1、下载 HbuilderX。


2、新建项目,直接选择了默认模板。


3、清空 Hello页面,修改文件名,配置路由。


4、在vue文件里写主要的功能实现,并增加 Http.jsRecord.jsPhoneCall.jsPower.js来实现对应的模块功能。


⚠️关于测试和打包


运行测试


在 HbuilderX 中点击“运行-运行到手机或模拟器-运行到Android APP基座”会打开一个界面,让你选择运行到那个设备。这是你有两种选择:



  • 把你手机通过USB与电脑连接,然后刷新列表就可以直接运行了。

    • 很遗憾,可能是苹果电脑与安卓手机的原因,插上后检测不出设备😭。。。



  • 安装Android Studio,然后通过运行内置的模拟器来供代码运行测试。

    • 这种很麻烦,要下载很久,且感觉测试效果并不好,最好还是用windows电脑连接手机的方法测试。




关于自定义基座和标准基座的差别,如果你没有买插件的话,直接使用基准插座就好。如果你要使用自定义基座,就首先要点击上图中的制作自定义基座,然后再切换到自定义基座执行。


但是不知道为什么,我这里一直显示安装自定义基座失败。。。


打包测试


除了以上运行测试的方法外,你还有一种更粗暴的测试方法,那就是打包成APP直接在手机上安装测试。


点击“发行-原生APP 云打包”,会生成一个APK文件,然后就可以发送到手机上安装测试。不过每天打包的次数有限,超过次数需要购买额外的打包服务或者等第二天打包。


我最终就是这样搞得,真的我哭死,我可能就是盲调的命,好多项目都是盲调的。


另外,在打包之前我们首先要配置manifest.json,里面包含了APP的很多信息。比较重要的一个是AppId,一个是App权限配置。参考uni-app 权限配置Android官方权限常量文档。以下是拨号所需的一些权限:



// 录制音频
<uses-permission android:name="android.permission.RECORD_AUDIO" />
// 修改音频设置
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />

// 照相机
<uses-permission android:name="android.permission.CAMERA" />
// 写入外部存储
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
// 读取外部存储
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />

// 读取电话号码
<uses-permission android:name="android.permission.READ_PHONE_NUMBERS" />
// 拨打电话
<uses-permission android:name="android.permission.CALL_PHONE" />
// 呼叫特权
<uses-permission android:name="android.permission.CALL_PRIVILEGED" />
// 通话状态
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
// 读取拨号日志
<uses-permission android:name="android.permission.READ_CALL_LOG" />
// 写入拨号日志
<uses-permission android:name="android.permission.WRITE_CALL_LOG" />
// 读取联系人
<uses-permission android:name="android.permission.READ_CONTACTS" />
// 写入联系人
<uses-permission android:name="android.permission.WRITE_CONTACTS" />
// 读取SMS?
<uses-permission android:name="android.permission.READ_SMS" />

// 写入设置
<uses-permission android:name="android.permission.WRITE_SETTINGS" />
// 唤醒锁定?
<uses-permission android:name="android.permission.WAKE_LOCK" />
// 系统告警窗口?
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
// 接受完整的引导?
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />

⚠️权限配置这个搞了很长时间,即便现在有些权限还是不太清楚,也不知道是不是有哪些权限没有配置上。。。


⚠️权限校验


1、安卓 1


好像除了这样的写法还可以写"scope.record"或者permission.CALL_PHONE


permision.requestAndroidPermission("android.permission.CALL_PHONE").then(res => {
// 1 获得权限 2 本次拒绝 -1 永久拒绝
});

2、安卓 2


plus.android.requestPermissions(["android.permission.CALL_PHONE"], e => {
// e.granted 获得权限
// e.deniedPresent 本次拒绝
// e.deniedAlways 永久拒绝
});

3、uni-app


这个我没测试,AI给的,我没有用这种方法。有一说一,百度AI做的不咋地。


// 检查权限
uni.hasPermission({
permission: 'makePhoneCall',
success() {
console.log('已经获得拨号权限');
},
fail() {
// 示例:请求权限
uni.authorize({
scope: 'scope.makePhoneCall',
success() {
console.log('已经获得授权');
},
fail() {
console.log('用户拒绝授权');
// 引导用户到设置中开启权限
uni.showModal({
title: '提示',
content: '请在系统设置中打开拨号权限',
success: function(res) {
if (res.confirm) {
// 引导用户到设置页
uni.openSetting();
}
}
});
}
});
}
});

✅拨号


三种方法都可以实现拨号功能,只要有权限,之所以找了三种是为了实现APP在后台的情况下拨号的目的,做了N多测试,甚至到后面搞了一份原生插件的代码不过插件加载当时没搞懂就放弃了,不过到后面才发现原来后台拨号出现问题的原因不在这里,,具体原因看后面。


另外获取当前设备平台可以使用let platform = uni.getSystemInfoSync().platform;,我这里只需要兼容固定机型。


1、uni-app API


uni.makePhoneCall({
phoneNumber: phone,
success: () => {
log(`成功拨打电话${phone}`);
},
fail: (err) => {
log(`拨打电话失败! ${err}`);
}
});

2、Android


plus.device.dial(phone, false);

3、Android 原生


写这个的时候有个小插曲,当时已经凌晨了,再加上我没有复制,是一个个单词敲的,结果竟然敲错了一个单词,测了好几遍都没有成功。。。还在想到底哪里错了,后来核对一遍才发现😭,control cv才是王道啊。


// Android
function PhoneCallAndroid(phone) {
if (!plus || !plus.android) return;
// 导入Activity、Intent类
var Intent = plus.android.importClass("android.content.Intent");
var Uri = plus.android.importClass("android.net.Uri");
// 获取主Activity对象的实例
var main = plus.android.runtimeMainActivity();
// 创建Intent
var uri = Uri.parse("tel:" + phone); // 这里可修改电话号码
var call = new Intent("android.intent.action.CALL", uri);
// 调用startActivity方法拨打电话
main.startActivity(call);
}

✅拨号状态查询


第一版用的就是这个获取状态的代码,有三种状态。第二版的时候又换了一种,因为要增加呼入、呼出、挂断、未接等状态的判断。


export function getCallStatus(callback) {
if (!plus || !plus.android) return;
let maintest = plus.android.runtimeMainActivity();
let Contexttest = plus.android.importClass("android.content.Context");
let telephonyManager = plus.android.importClass("android.telephony.TelephonyManager");
let telManager = plus.android.runtimeMainActivity().getSystemService(Contexttest.TELEPHONY_SERVICE);
let receiver = plus.android.implements('io.dcloud.android.content.BroadcastReceiver', {
onReceive: (Contexttest, intent) => {
plus.android.importClass(intent);
let phoneStatus = telManager.getCallState();
callback && callback(phoneStatus);
//电话状态 0->空闲状态 1->振铃状态 2->通话存在
}
});
let IntentFilter = plus.android.importClass("android.content.IntentFilter");
let filter = new IntentFilter();
filter.addAction(telephonyManager.ACTION_PHONE_STATE_CHANGED);
maintest.registerReceiver(receiver, filter);
}

⚠️录音


录音功能这个其实没啥,都是官网的API,无非是简单的处理一些东西。但是这里有一个大坑!


一坑


就是像通话录音这种涉及到的隐私权限很高,正常的这种录音是在通话过程中是不被允许的。


二坑


后来一次偶然的想法,在接通之后再开启录音,发现就可以录音了。


但随之而来的是第二个坑,那就是虽然录音了,但是当播放的时候发现没有任何的声音,还是因为保护隐私的原因,我当时还脱离代码专门试了试手机自带的录音器来在通话时录音,发现也不行。由此也发现uni的录音本身也是用的手机录音器的功能。


三坑


虽然没有声音,但是我还是试了下保存,然后就发现了第三个坑,那就是虽然获取了文件权限,但是现在手机给的读写权限都是在限定内的,录音所在的文件夹是无权访问的。。。


另辟蹊径


其实除了自己手动录音外还可以通过手机自带的通话录音来实现,然后只要手机去读取录音文件并找到对应的那个就可以了。思路是没啥问题,不过因为设置通话录音指引、获取录音文件都有问题,这一版本就没实现。


// 录音

var log = console.log,
recorder = null,
// innerAudioContext = null,
isRecording = false;

export function startRecording(logFun = console.log) {
if (!uni.getRecorderManager || !uni.getRecorderManager()) return logFun('不支持录音!');
log = logFun;
recorder = uni.getRecorderManager();
// innerAudioContext = uni.createInnerAudioContext();
// innerAudioContext.autoplay = true;
recorder.onStart(() => {
isRecording = true;
log(`录音已开始 ${new Date()}`);
});
recorder.onError((err) => {
log(`录音出错:${err}`);
console.log("录音出错:", err);
});
recorder.onInterruptionBegin(() => {
log(`检测到录音被来电中断...`);
});
recorder.onPause(() => {
log(`检测到录音被来电中断后尝试启动录音..`);
recorder.start({
duration: 10 * 60 * 1000,
});
});
recorder.start({
duration: 10 * 60 * 1000,
});
}

export function stopRecording() {
if (!recorder) return
recorder.onStop((res) => {
isRecording = false;
log(`录音已停止! ${new Date()}`); // :${res.tempFilePath}
// 处理录制的音频文件(例如,保存或上传)
// powerCheckSaveRecord(res.tempFilePath);
saveRecording(res.tempFilePath);
});
recorder.stop();
}

export function saveRecording(filePath) {
// 使用uni.saveFile API保存录音文件
log('开始保存录音文件');
uni.saveFile({
tempFilePath: filePath,
success(res) {
// 保存成功后,res.savedFilePath 为保存后的文件路径
log(`录音保存成功:${res.savedFilePath}`);
// 可以将res.savedFilePath保存到你的数据中,或者执行其他保存相关的操作
},
fail(err) {
log(`录音保存失败! ${err}`);
console.error("录音保存失败:", err);
},
});
}

运行日志


为了更好的测试,也为了能实时的看到执行的过程,需要一个日志,我这里就直接渲染了一个倒序的数组,数组中的每一项就是各个函数push的字符串输出。简单处理。。。。嘛。


联调、测试、交工


搞到最后,大概就交了个这么玩意,不过也没有办法,一是自己确实不熟悉APP开发,二是满共就给了两天的工时,中间做了大量的测试代码的工作,时间确实有点紧了。所幸最起码的功能没啥问题,也算是交付了。


image.png


第二版


2024年05月7日,老哥又找上我了,想让我们把他们的这套东西再给友商部署一套,顺便把这个APP再改一改,增加上通时通次的统计功能。同时也是谈合作,如果后面有其他的友商想用这套系统,他来谈,我们来实施,达成一个长期合作关系。


我仔细想了想,觉得这是个机会,这块东西的市场需求也一直有,且自己现在失业在家也有时间,就想着把这个简单的功能打磨成一个像样的产品。也算是做一次尝试。


需求分析



  • ✅拨号APP

    • 登录

      • uni-id实现



    • 权限校验

      • 拨号权限、文件权限、自带通话录音配置



    • 权限引导

      • 文件权限引导

      • 通话录音配置引导

      • 获取手机号权限配置引导

      • 后台运行权限配置引导

      • 当前兼容机型说明



    • 拨号

      • 获取手机号

        • 是否双卡校验

        • 直接读取手机卡槽中的手机号码

        • 如果用户不会设置权限兼容直接input框输入



      • 拨号

      • 全局拨号状态监控注册、取消

        • 支持呼入、呼出、通话中、来电未接或挂断、去电未接或挂断





    • 录音

      • 读取录音文件列表

        • 支持全部或按时间查询



      • 播放录音

      • ❌上传录音文件到云端



    • 通时通次统计

      • 云端数据根据上面状态监控获取并上传

        • 云端另写一套页面



      • 本地数据读取本机的通话日志并整理统计

        • 支持按时间查询

        • 支持呼入、呼出、总计的通话次数、通话时间、接通率、有效率等





    • 其他

      • 优化日志显示形式

        • 封装了一个类似聊天框的组件,支持字符串、Html、插槽三种显示模式

        • 在上个组件的基础上实现权限校验和权限引导

        • 在上两个组件的基础上实现主页面逻辑功能



      • 增加了拨号测试、远端连接测试

      • 修改了APP名称和图标

      • 打包时增加了自有证书






中间遇到并解决的一些问题


关于框架模板


这次重构我使用了uni中uni-starter + uni-admin 项目模板。整体倒还没啥,这俩配合还挺好的,就只是刚开始不知道还要配置东西一直没有启动起来。


建立完项目之后还要进uniCloud/cloudfunctions/common/uni-config-center/uni-id配置一个JSON文件来约定用户系统的一些配置。


打包的时候也要在manifest.json将部分APP模块配置进去。


还搞了挺久的,半天才查出来。。


类聊天组件实现



  • 设计

    • 每个对话为一个无状态组件

    • 一个图标、一个名称、一个白底的展示区域、一个白色三角

    • 内容区域通过类型判断如何渲染

    • 根据前后两条数据时间差判断是否显示灰色时间



  • 参数

    • ID、名称、图标、时间、内容、内容类型等



  • 样式

    • 根据左边右边区分发送接收方,给与不同的类名

    • flex布局实现




样式实现这里,我才知道原来APP和H5的展示效果是完全不同的,个别地方需要写两套样式。


关于后台运行


这个是除了录音最让我头疼的问题了,我想了很多实现方案,也查询了很多相关的知识,但依旧没效果。总体来说有以下几种思路。



  • 通过寻找某个权限和引导(试图寻找到底是哪个权限控制的)

  • 通过不停的访问位置信息

  • 通过查找相应的插件、询问GPT、百度查询

  • 通过程序切入后台之后,在屏幕上留个悬浮框(参考游戏脚本的做法)

  • 通过切入后台后,发送消息实现(没测试)


测试了不知道多少遍,最终在一次无意中,终于发现了如何实现后台拨号,并且在之后看到后台情况下拨号状态异常,然后又查询了应用权限申请记录,也终于知道,归根到底能否后台运行还是权限的问题。


关于通话状态、通话记录中的类型


这个倒还好,就是测试的问题,知道了上面为啥异常的情况下,多做几次测试,就能知道对应的都是什么状态了。


通话状态:呼入振铃、通话中(呼入呼出)、通话挂断(呼入呼出)、来电未接或拒绝、去电未接或拒接。


通话日志:呼入、呼出、未接、语音邮件、拒接


交付


总体上来说还过得去,相比于上次简陋的东西,最起码有了一点APP的样子,基本上该有的功能也基本都已经实现了,美中不足的一点是下面的图标没有找到合适的替换,然后录音上传的功能暂未实现,不过这个也好实现了。


image.png


后面的计划



  • 把图标改好

  • 把录音文件是否已上传、录音上传功能做好

  • 把APP的关于页面加上,对接方法、使用方法和视频、问题咨询等等

  • 原本通话任务、通时通次这些是放在一个PHP后端的,对接较麻烦。要用云函数再实现一遍,然后对外暴露几个接口,这样任何一个系统都可以对接这个APP,而我也可以通过控制云空间的跨域配置来开放权限

  • 把数据留在这边之后,就可以再把uni-admin上加几个页面,并且绑定到阿里云的云函数前端网页托管上去

  • 如果有可能的话,上架应用商店,增加上一些广告或者换量联盟之类的东西

  • 后台运行时,屏幕上加个悬浮图标,来电时能显示个振铃啥的


大致的想法就这些了,如果这个产品能继续卖下去,我就会不断的完善它。


最后


现在的行情真的是不好啊,不知道有没有大哥给个内推的机会,本人大专计算专业、6.5年Vue经验(专精后台管理、监控大屏方向,其他新方向愿意尝试),多个0-1-2项目经验,跨多个领域如人员管理、项目管理、产品设计、软件测试、数据爬虫、NodeJS、流程规范等等方面均有了解,工作稳定不经常跳,求路过的大哥给个内推机会把!



作者:前端湫
来源:juejin.cn/post/7368421971384860684
收起阅读 »

当遇到需要在Activity间传递大量的数据怎么办?

在Activity间传递的数据一般比较简单,但是有时候实际开发中也会传一些比较复杂的数据,尤其是面试问道当遇到需要在Activity间传递大量的数据怎么办? Intent 传递数据的大小是有限制的,它大概能传的数据是1M-8K,原因是Binder锁映射的内存大...
继续阅读 »

在Activity间传递的数据一般比较简单,但是有时候实际开发中也会传一些比较复杂的数据,尤其是面试问道当遇到需要在Activity间传递大量的数据怎么办?


Intent 传递数据的大小是有限制的,它大概能传的数据是1M-8K,原因是Binder锁映射的内存大小就是1M-8K.一般activity间传递数据会要使用到binder,因此这个就成为了数据传递的大小的限制。那么当activity间要传递大数据采用什么方式呢?其实方式很多,我们就举几个例子给大家说明一下,但是无非就是使用数据持久化,或者内存共享方案。一般大数据的存储不适宜使用SP, MMKV,DataStore。


Activity之间传递大量数据主要有如下几种方式实现:


  • LruCache

  • 持久化(sqlite、file等)

  • 匿名共享内存


使用LruCache

LruCache是一种缓存策略,可以帮助我们管理缓存,想具体了解的同学可以去Glide章节中具体先了解下。在当前的问题下,我们可以利用LruCache存储我们数据作为一个中转,好比我们需要Activity A向Activity B传递大量数据,我们可以Activity A先向LruCache先写入数据,之后Activity B从LruCache读取。


首先我们定义好写入读出规则:


public interface IOHandler {
   //保存数据
   void put(String key, String value);
   void put(String key, int value);
   void put(String key, double value);
   void put(String key, float value);
   void put(String key, boolean value);
   void put(String key, Object value);

   //读取数据
   String getString(String key);
   double getDouble(String key);
   boolean getBoolean(String key);
   float getFloat(String key);
   int getInt(String key);
   Object getObject(String key);
}

我们可以根据规则也就是接口,写出具体的实现类。实现类中我们保存数据使用到LruCache,这里面我们一定要设置一个大小,因为内存中数据的最大值是确定,我们保存数据的大小最好不要超过最大值的1/8.


LruCache mCache = new LruCache<>( 10 * 1024*1024);

写入数据我们使用比较简单:


@Override
public void put(String key, String value) {
   mCache.put(key, value);
}

好比上面写入String类型的数据,只需要接收到的数据全部put到mCache中去。


读取数据也是比较简单方便:


@Override
public String getString(String key) {
   return String.valueOf(mCache.get(key));
}

持久化数据

那就是sqlite、file等方式。将需要传递的数据写在临时文件或者数据库中,再跳转到另外一个组件的时候再去读取这些数据信息,这种处理方式会由于读写文件较为耗时导致程序运行效率较低。这种方式特点如下:


优势:


(1)应用中全部地方均可以访问


(2)即便应用被强杀也不是问题了


缺点:


(1)操做麻烦


(2)效率低下


匿名共享内存

在跨进程传递大数据的时候,我们一般会采用binder传递数据,但是Binder只能传递1M一下的数据,所以我们需要采用其他方式完成数据的传递,这个方式就是匿名共享内存。


Anonymous Shared Memory 匿名共享内存」是 Android 特有的内存共享机制,它可以将指定的物理内存分别映射到各个进程自己的虚拟地址空间中,从而便捷的实现进程间内存共享。


Android 上层提供了一些内存共享工具类,就是基于 Ashmem 来实现的,比如 MemoryFile、 SharedMemory。


作者:派大星不吃蟹
来源:juejin.cn/post/7264503091116965940
收起阅读 »

Activity界面路由的一种简单实现

1. 引言 平时Android开发中,启动Activity是非常常见的操作,而打开一个新Activity可以直接使用Intent,也可以每个Activity提供一个静态的启动方法。但是有些时候使用这些方法并不那么方便,比如:一个应用内的网页需要打开一个原生Ac...
继续阅读 »

1. 引言


平时Android开发中,启动Activity是非常常见的操作,而打开一个新Activity可以直接使用Intent,也可以每个Activity提供一个静态的启动方法。但是有些时候使用这些方法并不那么方便,比如:一个应用内的网页需要打开一个原生Activity页面时。这种情况下,网页的调用代码可能是app.openPage("/testPage")这样,或者是用app.openPage("local://myapp.com/loginPage")这样的方式,我们需要用一种方式把路径和页面关联起来。Android可以允许我们在Manifest文件中配置<data>标签来达到类似效果,也可以使用ARouter框架来实现这样的功能。本文就用200行左右的代码实现一个类似ARouter的简易界面路由。


2. 示例


2.1 初始化


这个操作建议放在Application的onCreate方法中,在第一次调用Router来打开页面之前。


public class AppContext extends Application {
@Override
public void onCreate() {
super.onCreate();
Router.init(this);
}
}

2.2 启动无参数Activity


这是最简单的情况,只需要提供一个路径,适合“关于我们”、“隐私协议”这种简单无参数页面。


Activity配置:


@Router.Path("/testPage")
public class TestActivity extends Activity {
//......
}

启动代码:


Router.from(mActivity).toPath("/testPage").start();
//或
Router.from(mActivity).to("local://my.app/testPage").start();

2.3 启动带参数Activity


这是比较常见的情况,需要在注解中声明需要的参数名称,这些参数都是必要参数,如果启动的时候没有提供对应参数,则发出异常。


Activity配置:


@Router.Path(value = "/testPage",args = {"id", "type"})
public class TestActivity extends Activity {
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
//加载布局...

String id = getIntent().getStringExtra("id"); //获取参数
int type = getIntent().getIntExtra("type", 0);//获取参数
}
}

启动代码:


Router.from(mActivity).toPath("/testPage").with("id", "t_123").with("type", 1).start();

2.4 启动带有静态启动方法的Activity


有一些Activity需要通过它提供的静态方法启动,就可以使用Path中的method属性和Entry注解来声明入口,可以提供参数。在提供了method属性时,需要用Entryargs来声明参数。


Activity配置:


@Router.Path(value = "/testPage", method = "open")
public class TestActivity extends Activity {

@Router.Entry(args = {"id", "type"})
public static void open(Activity activity, Bundle args) {
Intent intent = new Intent(activity, NestWebActivity.class);
intent.putExtras(args);
activity.startActivity(intent);
}

@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
//加载布局...

String id = getIntent().getStringExtra("id"); //获取参数
int type = getIntent().getIntExtra("type", 0);//获取参数
}
}

启动代码:


Router.from(mActivity).toPath("/testPage").with("id", "t_123").with("type", 1).start();

3. API介绍


3.1 Path注解


这个注解只能用于Activity的子类,表示这个Activity需要页面路由的功能。这个类有三个属性:



  • value:表示这个Activity的相对路径。

  • args:表示这个Activity需要的参数,都是必要参数,如果打开页面时缺少指定参数,就会发出异常。

  • method:如果这个Activity需要静态方法做为入口,就将这个属性指定为方法名,并给对应方法添加Entry注解。(注意:这个属性值不为空时,忽略这个注解中的args属性内容)


3.1 Entry注解


这个注解只能用于Activity的静态方法,表示这个方法作为打开Activity的入口。仅包含一个属性:



  • args:表示这个方法需要的参数。


3.2 Router.init方法



  • 方法签名:public static void init(Context context)

  • 方法说明:这个方法用于初始化页面路由表,必须在第一次用Router打开页面之前完成初始化。建议在Application的onCreate方法中完成初始化。


3.3 Rouater.from方法



  • 方法签名:public static Router from(Activity activity)

  • 方法说明:这个方法用于创建Router实例,传入的参数通常为当前Activity。例如,要从AActivity打开BActivity,那么传入参数为AActivity的实例。


3.4 Rouater.to和Rouater.toPath方法



  • 方法签名:




  1. public RouterBuilder to(String urlString)

  2. public RouterBuilder toPath(String path)




  • 方法说明:这个方法用于指定目标的路径,to需要执行绝对路径,而toPath需要指定相对路径。返回的RouterBuilder用于接收打开页面需要的参数。


3.4 RouterBuilder.with方法



  • 方法签名:




  1. public RouterBuilder with(String key, String value)

  2. public RouterBuilder with(String key, int value)




  • 方法说明:这个方法用于添加参数,对应Bundle的各个put方法。目前只有常用的Stringint两个类型。如有需要可自行在RouterBuilder中添加对应的方法。


3.4 RouterBuilder.start方法



  • 方法签名:public void start()

  • 方法说明:这个方法用于打开页面。如果存在路径错误、参数错误等异常情况,会发出对应运行时异常。


4. 实现


import android.app.Activity;
import android.content.Context;
import android.content.Intent;
import android.content.pm.ActivityInfo;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import android.net.Uri;
import android.os.Bundle;
import android.text.TextUtils;

import androidx.annotation.Keep;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.lang.reflect.Method;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;

@Keep
public class Router {

public static final String SCHEME = "local";
public static final String HOST = "my.app";
public static final String URL_PREFIX = SCHEME + "://" + HOST;

private static final Map<String, ActivityStarter> activityPathMap = new ConcurrentHashMap<>();

public static void init(Context context) {
try {
PackageManager packageManager = context.getPackageManager();
PackageInfo packageInfo = packageManager.getPackageInfo(
context.getPackageName(), PackageManager.GET_ACTIVITIES);

for (ActivityInfo activityInfo : packageInfo.activities) {
Class<?> aClass = Class.forName(activityInfo.name);
Path annotation = aClass.getAnnotation(Path.class);
if (annotation != null && !TextUtils.isEmpty(annotation.value())) {
activityPathMap.put(annotation.value(), (Activity activity, Bundle bundle) -> {
if (TextUtils.isEmpty(annotation.method())) {
for (String arg : annotation.args()) {
if (!bundle.containsKey(arg)) {
throw new IllegalArgumentException(String.format("Bundle does not contains argument[%s]", arg));
}
}
Intent intent = new Intent(activity, aClass);
intent.putExtras(bundle);
activity.startActivity(intent);
} else {
try {
Method method = aClass.getMethod(annotation.method(), Activity.class, Bundle.class);
Entry entry = method.getAnnotation(Entry.class);
if (entry != null) {
for (String arg : entry.args()) {
if (!bundle.containsKey(arg)) {
throw new IllegalArgumentException(String.format("Bundle does not contains argument[%s]", arg));
}
}
method.invoke(null, activity, bundle);
} else {
throw new IllegalStateException("can not find a method with [Entry] annotation!");
}
} catch (Exception e) {
throw new RuntimeException(e);
}
}
});
}
}
} catch (Exception e) {
throw new RuntimeException(e);
}
}

public static Router from(Activity activity) {
return new Router(activity);
}

private final Activity activity;

private Router(Activity activity) {
this.activity = activity;
}

public RouterBuilder to(String urlString) {
if (TextUtils.isEmpty(urlString)) {
return new ErrorRouter(new IllegalArgumentException("argument [urlString] must not be null"));
} else {
return to(Uri.parse(urlString));
}
}

public RouterBuilder toPath(String path) {
return to(Uri.parse(URL_PREFIX + path));
}

public RouterBuilder to(Uri uri) {
try {
if (SCHEME.equals(uri.getScheme())) {
if (HOST.equals(uri.getHost())) {
String path = uri.getPath();//note: 二级路径暂不考虑
ActivityStarter starter = activityPathMap.get(path);
if (starter == null) {
throw new IllegalStateException(String.format("path [%s] is not support", path));
} else {
NormalRouter router = new NormalRouter(activity, starter);
for (String key : uri.getQueryParameterNames()) {
if (!TextUtils.isEmpty(key)) {
router.with(key, uri.getQueryParameter(key));
}
}
return router;
}
} else {
throw new IllegalArgumentException(String.format("invalid host : %s", uri.getHost()));
}
} else {
throw new IllegalArgumentException(String.format("invalid scheme : %s", uri.getScheme()));
}
} catch (RuntimeException e) {
return new ErrorRouter(e);
}
}

public static abstract class RouterBuilder {
public abstract RouterBuilder with(String key, String value);

public abstract RouterBuilder with(String key, int value);

public abstract void start();
}


private static class ErrorRouter extends RouterBuilder {
private final RuntimeException exception;

private ErrorRouter(RuntimeException exception) {
this.exception = exception;
}

@Override
public RouterBuilder with(String key, String value) {
return this;
}

@Override
public RouterBuilder with(String key, int value) {
return this;
}

@Override
public void start() {
throw exception;
}
}

private static class NormalRouter extends RouterBuilder {
final Activity activity;
final Bundle bundle = new Bundle();
final ActivityStarter starter;

private NormalRouter(Activity activity, ActivityStarter starter) {
this.activity = Objects.requireNonNull(activity);
this.starter = Objects.requireNonNull(starter);
}

@Override
public RouterBuilder with(String key, String value) {
bundle.putString(key, value);
return this;
}

@Override
public RouterBuilder with(String key, int value) {
bundle.putInt(key, value);
return this;
}

@Override
public void start() {
starter.start(activity, bundle);
}
}

@FunctionalInterface
private interface ActivityStarter {
void start(Activity activity, Bundle bundle);
}

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface Path {
String value();

String method() default "";

String[] args() default {};
}

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Entry {
String[] args() default {};
}
}

5. 注意



  1. 这个工具的一些功能与ARouter类似,实际项目中建议使用ARouter。如果有特殊需求,例如,页面参数的检查或定制具体打开行为,可以考虑基于这个工具进行修改。

  2. 使用了Pathmethod属性时注意添加对应的混淆设置,避免因混淆而导致找不到对应方法。


作者:乐征skyline
来源:juejin.cn/post/7235639979882463292
收起阅读 »

RecyclerView还能这样滚动对齐?

前言 RecyclerView要想滚动到指定position,一般有scrollToPosition()和smoothScrollToPosition()两种方式。滚动到指定position后,通常还会要求itemView对齐RecyclerView起始点、中...
继续阅读 »

前言


RecyclerView要想滚动到指定position,一般有scrollToPosition()smoothScrollToPosition()两种方式。滚动到指定position后,通常还会要求itemView对齐RecyclerView起始点、中心点或结束点


熟悉RecyclerView的人应该知道,使用自定义SmoothScroller可以实现平滑滚动到指定position的同时,让itemView和RecyclerView的对齐;而scrollToPosition()方法只能滚动到指定position。那有办法让scrollToPosition()也做到对齐吗?


拆解行为


分析对齐的行为后,可以分为几步



  1. 让目标itemView可见

  2. 计算itemView和目的位置的偏移量

  3. 将itemView移动到目的位置


第一步scrollToPosition()就已经可以实现了,最后一步就是调用scrollBy(),那其实只需要实现第二步计算偏移量,而这可以参考SmoothScroller的实现


平滑滚动


来看下SmoothScroller是怎么做的。通常做法都是自定义LinearSmoothScroller


RecyclerView.LayoutManager layoutManager = recyclerView.getLayoutManager();
int preference = LinearSmoothScroller.SNAP_TO_START;// 对齐方式
LinearSmoothScroller smoothScroller = new LinearSmoothScroller(context){
@Override
protected int getHorizontalSnapPreference() {
return preference;
}

@Override
protected int getVerticalSnapPreference() {
return preference;
}
};
smoothScroller.setTargetPosition(targetPosition);
layoutManager.startSmoothScroll(smoothScroller);

简单介绍下几种对齐方式



  • SNAP_TO_START:对齐RecyclerView起始位置

  • SNAP_TO_END:对齐RecyclerView结束位置

  • SNAP_TO_ANY:对齐RecyclerView任意位置,确保itemView在RecyclerView内


接下来看下getVerticalSnapPreference()或者getHorizontalSnapPreference()的返回值是怎么影响到itemView的对齐的。查看LinearSmoothScroller源码发现这两个方法会在onTargetFound()里调用


protected void onTargetFound(View targetView, RecyclerView.State state, Action action) {
final int dx = calculateDxToMakeVisible(targetView, getHorizontalSnapPreference());
final int dy = calculateDyToMakeVisible(targetView, getVerticalSnapPreference());
final int distance = (int) Math.sqrt(dx * dx + dy * dy);
final int time = calculateTimeForDeceleration(distance);
if (time > 0) {
action.update(-dx, -dy, time, mDecelerateInterpolator);
}
}

不难看出,该方法是计算targetView当前要滚动的偏移量和时长,并设置给action。而calculateDxToMakeVisible()calculateDyToMakeVisible()正是我们要找的计算偏移量的方法


由于这两个方法只依赖LayoutManager,所以我们可以将这些代码逻辑复制出来,创建一个Rangefinder类,用于计算偏移量


public class Rangefinder {
private final RecyclerView.LayoutManager mLayoutManager;

public Rangefinder(RecyclerView.LayoutManager layoutManager) {
mLayoutManager = layoutManager;
}

@Nullable
public RecyclerView.LayoutManager getLayoutManager() {
return mLayoutManager;
}

// 计算view在RecyclerView中完全可见所需的垂直偏移量
public int calculateDyToMakeVisible(View view, int snapPreference) {
final RecyclerView.LayoutManager layoutManager = getLayoutManager();
if (layoutManager == null || !layoutManager.canScrollVertically()) {
return 0;
}
final RecyclerView.LayoutParams params = (RecyclerView.LayoutParams)
view.getLayoutParams();
final int top = layoutManager.getDecoratedTop(view) - params.topMargin;
final int bottom = layoutManager.getDecoratedBottom(view) + params.bottomMargin;
final int start = layoutManager.getPaddingTop();
final int end = layoutManager.getHeight() - layoutManager.getPaddingBottom();
return calculateDtToFit(top, bottom, start, end, snapPreference);
}

// 计算view在RecyclerView中完全可见所需的水平偏移量
public int calculateDxToMakeVisible(View view, int snapPreference) {
final RecyclerView.LayoutManager layoutManager = getLayoutManager();
if (layoutManager == null || !layoutManager.canScrollHorizontally()) {
return 0;
}
final RecyclerView.LayoutParams params = (RecyclerView.LayoutParams)
view.getLayoutParams();
final int left = layoutManager.getDecoratedLeft(view) - params.leftMargin;
final int right = layoutManager.getDecoratedRight(view) + params.rightMargin;
final int start = layoutManager.getPaddingLeft();
final int end = layoutManager.getWidth() - layoutManager.getPaddingRight();
return calculateDtToFit(left, right, start, end, snapPreference);
}

public int calculateDtToFit(int viewStart, int viewEnd, int boxStart, int boxEnd,
@SnapPreference int snapPreference)
{
switch (snapPreference) {
case LinearSmoothScroller.SNAP_TO_START:
return boxStart - viewStart;
case LinearSmoothScroller.SNAP_TO_END:
return boxEnd - viewEnd;
case LinearSmoothScroller.SNAP_TO_ANY:
final int dtStart = boxStart - viewStart;
if (dtStart > 0) {
return dtStart;
}
final int dtEnd = boxEnd - viewEnd;
if (dtEnd < 0) {
return dtEnd;
}
break;
}
return 0;
}
}

有了计算偏移量的方法,接下来就是实现itemView的对齐了


即时滚动


根据上面的拆解步骤,再分析下每一步要做的事情



  1. 调用scrollToPosition()使目标itemView可见。因为该方法最终会requestLayout(),所以要在layout后,才能通过获取到itemView。那么可以post()后调用LayoutManagerfindViewByPosition()方法获取itemView

  2. 参考LinearSmoothScrolleronTargetFound()方法,使用上面的Rangefinder计算itemView和目的位置的偏移量

  3. 调用scrollBy()将itemView移动到目的位置


RecyclerView.LayoutManager layoutManager = recyclerView.getLayoutManager();
recyclerView.scrollToPosition(targetPosition);
recyclerView.post(new Runnable() {
@Override
public void run() {
View targetView = layoutManager.findViewByPosition(targetPosition);
if (targetView != null) {
Rangefinder rangefinder = new Rangefinder(layoutManager);
final int dx = rangefinder.calculateDxToMakeVisible(targetView, preference);
final int dy = rangefinder.calculateDyToMakeVisible(targetView, preference);
if (dx != 0 || dy != 0) {
recyclerView.scrollBy(-dx, -dy);
}
}
}
});

至此,我们就实现了即时滚动到position的同时,让itemView和RecyclerView对齐的功能。当然,这也只是测试代码,实际使用还会对上面的逻辑进行封装


测试代码 recyclerView-scroll-demo


参考


作者:benio
来源:juejin.cn/post/7364740313284444186
收起阅读 »