注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

环信FAQ

环信FAQ

集成常见问题及答案
RTE开发者社区

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

十六进制常量还有这种玩法

前言 上一篇文章中 juejin.cn/post/715437… ,在源码解析阶段,那些判断16进制的地方,很有意思,加上我以前也写过一篇关于这个的文章 http://www.jianshu.com/p/bff2b84ca… ,所以想在这里做个分享。 状态变量...
继续阅读 »

前言


上一篇文章中 juejin.cn/post/715437… ,在源码解析阶段,那些判断16进制的地方,很有意思,加上我以前也写过一篇关于这个的文章 http://www.jianshu.com/p/bff2b84ca… ,所以想在这里做个分享。


状态变量的一般写法


可能有些朋友平时在开发时定义常量状态是这样定义的:


public static final int SEX_BOY = 0; // 男生
public static final int SEX_GIRL = 1; // 女生

然后看了某篇文章之后,某个经验丰富的说,定义常量时最好使用16进制,再去看了看Android某些类的源码,嗯,好像里面定义常量确实是使用了16进制,于是之后写代码就开始


public static final int SEX_BOY = 0x00; // 男生
public static final int SEX_GIRL = 0x01; // 女生
public static final int SEX_OTHER = 0x02; // 其它

比如说有很多个状态,就从0x01、0x02......0xff 这样列举下去。


这样写对吗?我随便找个源码来举例下,随便从View.java扣出一段代码


e60a26626531e0f7231f1858adc1518.png


为什么是这样定义呢,为什么不是像我们那种写法?


叠加状态的定义方式


其实这个直接说不好解释,跟着我去操作,就理解为什么要这么定义了。


假设我们定义状态,定义成这样


public static final int TYPE1 = 0x01;
public static final int TYPE2 = 0x02;
public static final int TYPE3 = 0x04;
public static final int TYPE4 = 0x08;
public static final int TYPE5 = 0x10;
public static final int TYPE6 = 0x20;
public static final int TYPE7 = 0x40;
public static final int TYPE8 = 0x80;

为什么这么写呢?

我们将16进制转成2进行,上面就对应成


0d39ccd1271903a3cc6f461d2b03217.png


有意思的就在这里,我先说的我想的过程中错误的一个思路 (我觉得挺有意思的,所以可以说下,因为是一个错误的思路,如果不想看可以直接跳看下面的这样定义的原因)


二进制从右往左来说

(1)我用第一位表示性别 000:女 001:男

(2)我用第二位表示角色 000:学生 010:老师

(3)我用第三位表示班级 000:A班 100:B班

那么 “A班的女老师” 我可以表示成 010 = 2

“A班的男老师” 可以表示成 011=3

“B班的女学生”可以表示成 100 .....

这样可以组成8个状态而不会冲突,但是这样的做法是只能用3个状态组合进行比较,而且单个状态下有000表示了3种,而且这种做法同一位上只能表示两种状态,假如我加个C班,那就没辙了。


然后换了一种思考的方法,假如我这样表示状态


public static final int TYPE1 = 0x01;  // 女
public static final int TYPE2 = 0x02; // 男
public static final int TYPE3 = 0x04; // 学生
public static final int TYPE4 = 0x08; // 老师
public static final int TYPE5 = 0x10; // 主任
public static final int TYPE6 = 0x20; // A班
public static final int TYPE7 = 0x40; // B班
public static final int TYPE8 = 0x80; // C班

那么 使用二进制的或运算:

“A班的女老师” 我可以表示成 TYPE6|TYPE1|TYPE4 = 0010 1001 = 41

“A班的男老师” 可以表示成 TYPE6|TYPE2|TYPE4 = 0010 1010 = 42

“B班的女主任”可以表示成 TYPE7|TYPE1|TYPE5 = 0101 0001 = 81

这样也能把多个状态组成一个状态,而且组合状态也能和单个状态进行同等级判断,并且这种做法不会产生重复的状态。


举个例子就是说你平时写


if(性别==女 && 角色 == 老师 && 班级 == A班){
......
}else if(版本 == C班){
......
}

如果用我这种方法定义状态的话,你只用写


if(type == 0x29){
......
}else if(type == 0x80){
......
}

可能有些人说就仅仅为了这样?那我写&&还好过,写成16进制转换转的我脑壳疼。我还不如多写几个&&,而且这样也更容易让别人看懂。
但这个写法不仅仅有这种好处,再举个例子,假如在很多个组合的状态中你需要去判断这个状态是“男”还是“女”等等,多状态下判断单状态多了,也不是说乱,但会写很多代码,但是现在可以直接这样写


public void switchSex(type){
if(type & 0x03 == 0x01){
// 是女生
}else{
// 是男生
}
}

就可以直接这样用二进制的与运算来实现判断。

我也仅仅是举了两个例子,我的意思是这样去定义十六进制常量,方便二进制做运算,二进制还有其他的运算呢,我仅仅举了“或”和“与”,还有什么异或啊,移位啊之类的,而且就算作用不大,按装逼来说,我直接做二进制的运算肯定比你那些乱七八糟的运算来得快吧。


总结


当然这只是我领悟的一种思路,而且我想很多人也知道这种做法,或者用16进制来定义常量不仅仅有这个好处,只是我觉得很有意思,所以想分享一下。


作者:流浪汉kylin
来源:juejin.cn/post/7155474762053992485
收起阅读 »

android自定义View: 九宫格解锁

本系列自定义View全部采用kt 系统:mac android studio: 4.1.3 kotlin version1.5.0 gradle: gradle-6.5-bin.zip 废话不多说,先来看今天要完成的效果: 3X3 (样式1)4*4(样式2)...
继续阅读 »

本系列自定义View全部采用kt



系统:mac


android studio: 4.1.3


kotlin version1.5.0


gradle: gradle-6.5-bin.zip


废话不多说,先来看今天要完成的效果:


3X3 (样式1)4*4(样式2)5*5(样式3)
68003856905943AF9D5C44066EC4E13128A4F0086BD411F14D83F0B04E14893C6826652AEDDA18C295974B9A54BC55C6

Tips:不止3X3 或者 5X5 ,如果你想,甚至可以设置10*10


画圆


先以3*3的九宫格来介绍!


image-20220914105128040


我们要画成这样的效果, 画的是有一点丑,但是没关系.


首先来分析一下怎么花,这9个点的位置如何确定:



  • 我们为了平均分, 单个圆的外层矩形 宽 = view.width / 3

  • 高 = 宽

  • 1号圆的圆心位置 = 0个矩形的宽度 = view.width / (3 * 2) + ( view.width / 3 ) * 0

  • 2号圆的圆心位置 = 1号圆的圆心位置 + 1个矩形的宽度 = view.width / (3 * 2) + (view.width / 3) * 1

  • 3号圆的圆心位置 = 1号圆的圆心位置 + 2个矩形的宽度 = view.width / (3 * 2) + (view.width / 3) * 2


高坐标的计算也是如此


来看看目前的代码:


class BlogUnLockView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
//
strokeJoin = Paint.Join.BEVEL
}

// 大圆半径
private val bigRadius by lazy { width / (NUMBER * 2) * 0.7f }

// 小圆半径
private val smallRadius by lazy { bigRadius * 0.2f }

companion object {
const val NUMBER = 3
}

private val unLockPoints = arrayListOf<ArrayList<UnLockBean>>()

override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
// 矩形直径
val diameter = width / NUMBER

//
val ratio = (NUMBER * 2f)
var index = 1

// 循环每一行行
for (i in 0 until NUMBER) {
val list = arrayListOf<UnLockBean>()

// 循环每一列
for (j in 0 until NUMBER) {
list.add(
UnLockBean(
width / ratio + diameter * j,
height / ratio + diameter * i,
index++
)
)
}
unLockPoints.add(list)
}
}

override fun onDraw(canvas: Canvas) {
canvas.drawColor(Color.YELLOW)

unLockPoints.forEach {
it.forEach { data ->
// 绘制大圆
paint.alpha = (255 * 0.6).toInt()
canvas.drawCircle(data.x, data.y, bigRadius, paint)

// 绘制小圆
paint.alpha = 255
canvas.drawCircle(data.x, data.y, smallRadius, paint)
}
}
}
}

当前效果:


image-20220914110551658


目前问题:



  • 整个view占满了屏幕,需要测量


测量代码比较简单,就是让宽和高一样即可


image-20220914111142598


此时改变number变量,就可以设置几行几列:


例如这样:


5*510*10
image-20220914111416881image-20220914111450264

接下来我们就处理手势事件,按下滑动,抬起等,来改变选中


onTouchEvent事件处理


在事件处理之前先来分析一下需要几种事件,对于解锁功能来说:



  • ORIGIN 刚开始,还没有触摸

  • DOWN 正在触摸中(输入密码)

  • UP 触摸结束 (输入密码正确)

  • ERROR 触摸结束 (输入密码错误)


那么就先定义4种颜色,来表示这4种状态:


companion object {

// 原始颜色
private var ORIGIN_COLOR = Color.parseColor("#D8D9D8")

// 按下颜色
private var DOWN_COLOR = Color.parseColor("#3AD94E")

// 抬起颜色
private var UP_COLOR = Color.parseColor("#57D900")

// 错误颜色
private var ERROR_COLOR = Color.parseColor("#D9251E")
}

接下来挨个处理事件


DOWN(按下)


首先需要思考,在按下的时候要做什么事情:



  • 判断是否选中


/*
* TODO 判断是否选中某个圆
* @param x,y: 点击坐标位置
*/

private fun isContains(x: Float, y: Float) = let {
unLockPoints.forEach {
it.forEach { data ->
// 循环所有坐标 判断两个位置是否相同
if (PointF(x, y).contains(PointF(data.x, data.y), bigRadius)) {
return@let data
}
}
}
return@let null
}

// 判断一个点是否在另一个点范围内
fun PointF.contains(b: PointF, bPadding: Float = 0f): Boolean {
val isX = this.x <= b.x + bPadding && this.x >= b.x - bPadding

val isY = this.y <= b.y + bPadding && this.y >= b.y - bPadding
return isX && isY
}

思路: 通过比较 按下位置和所有位置,判断是否有相同的



  • 如果有相同的,那么就返回对应坐标

  • 如果没有相同的,那么就返回null



@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.action) {
MotionEvent.ACTION_DOWN -> {
// 判断是否选中
val pointF = isContains(event.x, event.y)
pointF?.let {
// 将当前类型变为按下类型
it.type = JiuGonGeUnLockView.Type.DOWN
}
}
...
}
invalidate()
return true
}

override fun onDraw(canvas: Canvas) {
// canvas.drawColor(Color.YELLOW)

unLockPoints.forEach {
it.forEach { data ->
// 根据类型设置颜色
paint.color = getTypeColor(data.type)

// 绘制大圆
paint.alpha = (255 * 0.6).toInt()
canvas.drawCircle(data.x, data.y, bigRadius, paint)

// 绘制小圆
paint.alpha = 255
canvas.drawCircle(data.x, data.y, smallRadius, paint)
}
}
}

/// TODO 获取类型对应颜色
private fun getTypeColor(type: JiuGonGeUnLockView.Type): Int {
return when (type) {
JiuGonGeUnLockView.Type.ORIGIN -> ORIGIN_COLOR
JiuGonGeUnLockView.Type.DOWN -> DOWN_COLOR
JiuGonGeUnLockView.Type.UP -> UP_COLOR
JiuGonGeUnLockView.Type.ERROR -> ERROR_COLOR
}
}

当前效果:


B6B94BC2B7487B5894E6840C1F783F7A

MOVE(移动)


move事件和down事件的逻辑是一样的,滑动的过程中判断点是否选中,然后绘制点


@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.action) {
MotionEvent.ACTION_DOWN -> {
val pointF = isContains(event.x, event.y)
pointF?.let {
// 将当前类型改变为按下类型
it.type = JiuGonGeUnLockView.Type.DOWN
}
}
MotionEvent.ACTION_MOVE -> {
val pointF = isContains(event.x, event.y)
pointF?.let {
// 将当前类型改变为按下类型
it.type = JiuGonGeUnLockView.Type.DOWN
}
}

....
}

invalidate()
return true
}

当前效果:


1800F1D0441C219F4F2735B706DFFB9B

可以看出,效果是基本完成了,但是还有一个小错误


通常我们在九宫格的时候,一般都是先按下一个点才能滑动, 否则是不能滑动的,


现在的问题是,直接就可以滑动,所以还需要调整一下


那么我们就需要在down事件中标记一下是否按下,然后在move事件中判断一下


// 是否按下
private var isDOWN = false

@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.action) {
MotionEvent.ACTION_DOWN -> {
val pointF = isContains(event.x, event.y)
pointF?.let {
// 将当前类型改变为按下类型
it.type = JiuGonGeUnLockView.Type.DOWN
isDOWN = true // 表示按下
}
}
MotionEvent.ACTION_MOVE -> {
if (!isDOWN) {
return super.onTouchEvent(event)
}
val pointF = isContains(event.x, event.y)
pointF?.let {
// 将当前类型改变为按下类型
it.type = JiuGonGeUnLockView.Type.DOWN
}
}

MotionEvent.ACTION_CANCEL,
MotionEvent.ACTION_UP -> {
isDOWN = false // 标记没有按下
}
}

invalidate()
return true
}

此时效果:


980BE4943A8EBF10516BAA27E023151B

UP(抬起)


思路分析:


抬起的时候要做很多事情




  • 判断输入密码是否正确



    • 密码输入正确,那么就改变为深绿色

    • 密码输入错误,就改变为红色




  • 完成之后,还需要吧所有的状态清空




在这里的时候,先不判断密码是否成功, 默认都是成功的,



  • 先吧输入的密码toast出来

  • 并且吧状态清空


等结尾的时候再来判断密码.


那么此时肯定是需要将所有选中的都记录下来, 然后在up事件中操作即可


// 记录选中的坐标
private val recordList = arrayListOf<UnLockBean>()

@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.action) {
MotionEvent.ACTION_DOWN -> {
val pointF = isContains(event.x, event.y)
pointF?.let {
// 将当前类型改变为按下类型
it.type = JiuGonGeUnLockView.Type.DOWN
isDOWN = true

recordList.add(it)
}
}
MotionEvent.ACTION_MOVE -> {
if (!isDOWN) {
return super.onTouchEvent(event)
}
val pointF = isContains(event.x, event.y)
pointF?.let {
// 将当前类型改变为按下类型
it.type = JiuGonGeUnLockView.Type.DOWN

// 这里会重复调用,所以需要判断是否包含,如果不包含才添加
if (!recordList.contains(it)) {
recordList.add(it)
}
}
}

MotionEvent.ACTION_CANCEL,
MotionEvent.ACTION_UP -> {
// 将结果打印
recordList.map {
it.index
}.toList() toast context

clear()
}
}

invalidate()
return true
}

/// 清空所有状态
private fun clear() {
recordList.forEach {
// 将所有选中状态还原
it.type = JiuGonGeUnLockView.Type.ORIGIN
}
recordList.clear()
isDOWN = false // 标记没有按下

invalidate()
}

当前效果:


C1A1C9AA5362879D8EB870BC953FFAD9

画连接线


还是以这张图来说:


image-20220914105128040


假设现在需要连接 1,5,6,9


那么可以通过Path()来画线


在DOWN事件中,通过moveTo()移动到1的位置


在MOVE事件中,通过lineTo()画5,6,9的位置 即可


private val path = Path()

@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.action) {
MotionEvent.ACTION_DOWN -> {
val pointF = isContains(event.x, event.y)
pointF?.let {
/// 隐藏部分代码

path.moveTo(it.x, it.y)
}
}
MotionEvent.ACTION_MOVE -> {
val pointF = isContains(event.x, event.y)
pointF?.let {
/// 隐藏部分代码

// 这里会重复调用,所以需要判断是否包含,如果不包含才添加
if (!recordList.contains(it)) {
recordList.add(it)
path.lineTo(it.x, it.y) // 连接到移动的位置
}
}
}

MotionEvent.ACTION_CANCEL,
MotionEvent.ACTION_UP -> {
// 将结果打印
recordList.map {
it.index
}.toList() toast context


clear()
}
}

invalidate()
return true
}

/*
* 作者:史大拿
* 创建时间: 9/14/22 1:38 PM
* TODO 用来清空标记
*/

private fun clear() {
path.reset() // 重置

recordList.forEach {
// 将所有选中状态还原
it.type = JiuGonGeUnLockView.Type.ORIGIN
}
recordList.clear()
isDOWN = false // 标记没有按下
}

override fun onDraw(canvas: Canvas) {
paint.style = Paint.Style.FILL
unLockPoints.forEach {
/// 隐藏部分代码
}

paint.style = Paint.Style.STROKE
paint.strokeWidth = 4.dp
paint.color = DOWN_COLOR // 默认按下颜色
canvas.drawPath(path, paint)
}

当前效果:


93DE90804F77B93312D8547F84F4609B

可以看出,已经完成了画连接线,但是还缺少一条指示当前手指位置的线,


我叫他移动线,,, (好土的名字)


移动线就2个坐标



  • 开始位置 (最后一个选中的位置)

  • 结束位置 (当前手指按下的位置)


private val line = Pair(PointF(), PointF())

@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.action) {
MotionEvent.ACTION_DOWN -> {
val pointF = isContains(event.x, event.y)
pointF?.let {
/// 隐藏代码

line.first.x = it.x
line.first.y = it.y
}
}
MotionEvent.ACTION_MOVE -> {
val pointF = isContains(event.x, event.y)
pointF?.let {
if (!recordList.contains(it)) {
//// 隐藏代码

// 最后一个选中的位置
line.first.x = it.x
line.first.y = it.y
}
}

// 手指的位置
line.second.x = event.x
line.second.y = event.y
}
....
}

invalidate()
return true
}

override fun onDraw(canvas: Canvas) {

paint.style = Paint.Style.FILL
unLockPoints.forEach {
/// 隐藏代码
}

// 绘制连接线
paint.style = Paint.Style.STROKE
paint.strokeWidth = 4.dp
paint.color = DOWN_COLOR // 默认按下颜色
canvas.drawPath(path, paint)

// 绘制移动线
if (line.first.x != 0f && line.second.x != 0f
) {
canvas.drawLine(
line.first.x,
line.first.y,
line.second.x,
line.second.y,
paint
)
}
}

当前效果:


2C05F7D7EB4E102778B87581AA183E79

此时效果就差不多了,画笔默认是实心圆, 来看看空心效果


空心效果


空心效果很简单,只需要调整画笔的style即可


 override fun onDraw(canvas: Canvas) {
// 实心效果
// paint.style = Paint.Style.FILL

// 空心效果
paint.style = Paint.Style.STROKE
paint.strokeWidth = 4.dp

// canvas.drawXXX()
}

当前效果


2F8ECA7B3AE2F9DCAE2FD46F846B66C9

可以看出,此时的效果和我们想的一样,但是画线的时候从小圆圆心穿过了,不太好看


有没有一种办法,让线不从圆心穿过


那么就先来分析一下:


image-20220914144550029


假设现在是从7移动到2


那么就需要连接C点和F点,只需要计算出C点和F点的坐标即可


先来分析现在的已知条件:



  • dx = end.x - start.x

  • dy = end.y - start.y

  • d = (dx平方 + dy平方) 开根号

  • 小圆半径 = smallRadius


那么就可以算出当前的偏移量:



  • offsetX = dx * (smallRadius / d)

  • offsetY = dy * (smallRadius / d)


知道偏移量,就可以算出C和F的坐标:


那么C的坐标为:



  • C.x = start.x + offsetX

  • C.y = start.y + offsetY


那么F的坐标为:



  • F.x = end.x + offsetX

  • F.y = end.y + offsetY


只要C和F的坐标之后


只需要通过path.moveTo() 移动到C的位置


通过path.lineTo() 移动到F的位置即可


@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.action) {
MotionEvent.ACTION_DOWN -> {
/// ...
}
MotionEvent.ACTION_MOVE -> {
val pointF = isContains(event.x, event.y)
pointF?.let {
// 将当前类型改变为按下类型
it.type = JiuGonGeUnLockView.Type.DOWN

// 这里会重复调用,所以需要判断是否包含,如果不包含才添加
if (!recordList.contains(it)) {
recordList.add(it)
if (recordList.size >= 2) {
// TODO 不穿过圆心
val start = recordList[recordList.size - 2]
val end = recordList[recordList.size - 1]

val d = PointF(start.x, start.y).distance(PointF(end.x, end.y))
val dx = (end.x - start.x)
val dy = (end.y - start.y)
val offsetX = dx * smallRadius / d
val offsetY = dy * smallRadius / d

val cX = start.x + offsetX
val cY = start.y + offsetY
path.moveTo(cX, cY)

val fX = end.x - offsetX
val fY = end.y - offsetY
path.lineTo(fX, fY)

// line
line.first.x = it.x + offsetX
line.first.y = it.y + offsetY
}
}
}

// 手指的位置
line.second.x = event.x
line.second.y = event.y
}

/// 隐藏UP代码
}

invalidate()
return true
}


// 计算两点之间的距离
fun PointF.distance(b: PointF): Float = let {
val a = this

// 这里 * 1.0 是为了转Double
val dx = b.x - a.x * 1.0
val dy = b.y - a.y * 1.0
return@let sqrt(dx.pow(2) + dy.pow(2)).toFloat()
}

当前的效果:


18478E736B00DAB45797EC8BC2164F9F

所有的效果基本就差不多了,接下来来比较密码


密码比较


思路分析:



  • 先将正确密码集合传过来,然后和输入的密码做比较

  • 首先先判断两个集合的长度

    • 如果长度不一样,那么密码肯定是不同的,直接标记为错误即可

    • 如果长度一样,只需要比较每一个值是否相同

      • 相同则输入成功,将正确结果回调回去

      • 有一个不相同,则输入失败,标记为错误即可






// 密码
open var password = listOf<Int>()

MotionEvent.ACTION_UP -> {
// 清空移动线
line.first.x = 0f
line.first.y = 0f
line.second.x = 0f
line.second.y = 0f


// 标记是否成功
val isSuccess =
// 先比较长度是否相同
if (recordList.size == password.size) {
val list = recordList.zip(password).filter {
// 通过判断每一个值
it.first.index == it.second
}.toList()

// 如果每一个值都相同,那么就成功
list.size == password.size
} else {
false
}

// 密码错误,将标记改变成成错误
if (!isSuccess) {
recordList.forEach {
it.type = JiuGonGeUnLockView.Type.ERROR
}
"输入失败" toast context
} else {
"输入成功" toast context
}

// 延迟1秒清空
postDelayed({
clear()
}, 1000)
}

23B8401108604115F972F00855280E1C

现在已经可以完成输入密码了,


但是状态还不对,我们希望连接线的颜色和圆的颜色一致,


当然我们可以这样:


override fun onDraw(canvas: Canvas) 
// paint.style = Paint.Style.FILL
paint.style = Paint.Style.STROKE
paint.strokeWidth = 4.dp

unLockPoints.forEach {
it.forEach { data ->

// 根据类型设置颜色
paint.color = getTypeColor(data.type)

// 绘制大圆
paint.alpha = (255 * 0.6).toInt()
canvas.drawCircle(data.x, data.y, bigRadius, paint)

// 绘制小圆
paint.alpha = 255
canvas.drawCircle(data.x, data.y, smallRadius, paint)

// 绘制连接线
canvas.drawPath(path, paint)

// 绘制移动线
if (line.first.x != 0f && line.second.x != 0f
) {
canvas.drawLine(
line.first.x,
line.first.y,
line.second.x,
line.second.y,
paint
)
}
}
}
}

但是我还是选择了通过一个全局变量,来记录当前的状态,然后给连接线和移动线设置颜色


代码很简单,就不展示了,直接看效果:


9D20C8CE4024A396D9AE75D7607F739E

到此时,效果就基本完成了,


但是,写完发现,代码真的太乱了,而且有很多设置的东西,


比如说:



  • 默认颜色

  • 移动颜色

  • 输入成功颜色

  • 输入失败颜色

  • 解锁的大小

    • 例如3,就是3 X 3 5就是5 X 5



  • 样式

    • 空心 or 实心




一般遇到这种情况我认为有2种方式



  • 自定义属性

  • 设计模式


自定义属性用的很多,这里我就通过Adapter模式来优化一下


先来定义规范


abstract class UnLockBaseAdapter {
// 设置宫格个数
// 例如输入3: 表示3*3
abstract fun getNumber(): Int

// 设置样式
abstract fun getStyle(): JiuGonGeUnLockView.Style

/*
* 作者:史大拿
* 创建时间: 9/14/22 10:24 AM
* TODO 画连接线时,是否穿过圆心
*/

open fun lineCenterCircle() = false

// 设置原始颜色
open fun getOriginColor(): Int = let {
return Color.parseColor("#D8D9D8")
}

// 设置按下颜色
open fun getDownColor(): Int = let {
return Color.parseColor("#3AD94E")
}

// 设置抬起颜色
open fun getUpColor(): Int = let {
return Color.parseColor("#57D900")
}

// 设置错误颜色
open fun getErrorColor(): Int = let {
return Color.parseColor("#D9251E")
}
}

实现:


class UnLockAdapter : UnLockBaseAdapter() {
override fun getNumber(): Int = 5

override fun getStyle(): JiuGonGeUnLockView.Style = JiuGonGeUnLockView.Style.STROKE

override fun getOriginColor(): Int {
return Color.YELLOW
}
}

读取数据:


open var adapter: UnLockBaseAdapter? = null

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)

if (adapter == null) {
throw AndroidRuntimeException("请设置Adapter")
}
adapter?.also {
NUMBER = it.getNumber()
ORIGIN_COLOR = it.getOriginColor()
DOWN_COLOR = it.getDownColor()
UP_COLOR = it.getUpColor()
ERROR_COLOR = it.getErrorColor()
}
}

来看看最终效果:


71F2901C6F6BE9DB19BBF98B95CE3FA0

思路参考自


完整代码


原创不易,您的点赞就是对我最大的帮助


其他自定义文章:



作者:史大拿
来源:juejin.cn/post/7143137578080796686
收起阅读 »

Android开发仿掘金Web端登录界面(Kotlin)

Android开发仿掘金Web端登录界面(Kotlin) 前言 各位大佬好,给大家分享一下用Android原生实现掘金Web端的登录界面效果,有哪些可以优化希望大佬们可以指正,那我们开始吧 最终效果图 前期准备 我们需要先把需要的资源给download下来,...
继续阅读 »

Android开发仿掘金Web端登录界面(Kotlin)


前言


各位大佬好,给大家分享一下用Android原生实现掘金Web端的登录界面效果,有哪些可以优化希望大佬们可以指正,那我们开始吧


最终效果图


LPDS_GIF_20220905_182520.gif


前期准备


我们需要先把需要的资源给download下来,我用Chrome来进行这一步



  • 开启Chrmoe的调试模式: 按F12开启或者在设置->更多工具->开发工具


1662367049960.jpg



  • 开是网络抓包:网络->图片


1662367135318.jpg


这样我们就看到了所需要的图片资料了,我们另存一下放入我们的项目


代码


配置Gradle



  • 我们来配置ViewBinding。在build.gradle中的android添加如下代码:


viewBinding {
enabled = true
}


  • 我们需要添加一些依赖


//glide库
implementation 'com.github.bumptech.glide:glide:4.13.0'
annotationProcessor 'com.github.bumptech.glide:compiler:4.13.0'

LoginDialog


我们创建一个LoginDialog.kt文件,并且继承与DialogFragment用于展示登录的UI,具体操作如下



  • dailog_login.xml


layout目录下创建dailog_login.xml文件,用于显示登录的布局,具体代码如下:


<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
//设置这个布局的高度是自适应的
android:layout_height="wrap_content">

//CardView来优化布局(可以快速设置圆角、阴影等操作)
<androidx.cardview.widget.CardView
android:layout_width="0dp"
android:layout_height="wrap_content"
//这里设置88dp是因为最上面的图片高度是96dp,我们这是88dp就可以实现完成重叠效果
android:layout_marginTop="88dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@+id/dialog_top_img">

//为了方便布局在CardView里面添加一个约束布局
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="16dp">

<ImageView
android:id="@+id/imageView3"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="@+id/textView2"
app:layout_constraintEnd_toEndOf="@+id/edit_user"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/ic_baseline_close_24" />

<TextView
android:id="@+id/textView2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="手机登录"
android:textColor="@color/black"
android:textSize="20sp"
android:textStyle="bold"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />

<EditText
android:id="@+id/edit_user"
android:layout_width="0dp"
android:layout_height="50dp"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp"
android:background="@drawable/bg_edit"
android:ems="11"
android:hint="请输入手机号码"
android:inputType="number"
android:maxLength="11"
android:paddingStart="100dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/textView2" />

<EditText
android:id="@+id/edit_pwd"
android:layout_width="0dp"
android:layout_height="50dp"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp"
android:background="@drawable/bg_edit"
android:ems="11"
android:hint="请输入密码"
android:inputType="number"
android:maxLength="4"
android:paddingStart="10dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/edit_user" />

<TextView
android:id="@+id/tv_code"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
android:text="获取验证码"
android:textColor="#007fff"
android:textSize="16sp"
app:layout_constraintBottom_toBottomOf="@+id/edit_pwd"
app:layout_constraintEnd_toEndOf="@+id/edit_pwd"
app:layout_constraintTop_toTopOf="@+id/edit_pwd" />

<LinearLayout
android:id="@+id/linearLayout"
android:layout_width="80dp"
android:layout_height="0dp"
android:orientation="horizontal"
app:layout_constraintBottom_toBottomOf="@+id/edit_user"
app:layout_constraintStart_toStartOf="@+id/edit_user"
app:layout_constraintTop_toTopOf="@+id/edit_user">

<TextView
android:id="@+id/textView5"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center"
android:text="+86"
android:textColor="#000000" />

<ImageView
android:id="@+id/imageView2"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_weight="1"
android:scaleType="center"
app:srcCompat="@drawable/ic_down" />
</LinearLayout>

<TextView
android:id="@+id/btn_login"
android:layout_width="0dp"
android:layout_height="50dp"
android:layout_marginTop="16dp"
android:background="@drawable/bg_btn"
android:gravity="center"
android:text="登录"
android:textColor="@color/white"
android:textSize="16sp"
app:layout_constraintEnd_toEndOf="@+id/edit_pwd"
app:layout_constraintStart_toStartOf="@+id/edit_pwd"
app:layout_constraintTop_toBottomOf="@+id/edit_pwd" />

<TextView
android:id="@+id/textView6"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="其他登录方式"
android:textColor="#007fff"
android:textSize="16sp"
app:layout_constraintStart_toStartOf="@+id/btn_login"
app:layout_constraintTop_toBottomOf="@+id/btn_login" />

<TextView
android:id="@+id/textView7"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="登录即表示同意"
android:textSize="16sp"
app:layout_constraintStart_toStartOf="@+id/btn_login"
app:layout_constraintTop_toBottomOf="@+id/textView6" />

<TextView
android:id="@+id/textView8"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:text="用户协议"
android:textColor="#007fff"
android:textSize="16sp"
app:layout_constraintStart_toEndOf="@+id/textView7"
app:layout_constraintTop_toTopOf="@+id/textView7" />

<TextView
android:id="@+id/textView10"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="2dp"
android:text="、"
android:textSize="16sp"
app:layout_constraintStart_toEndOf="@+id/textView8"
app:layout_constraintTop_toTopOf="@+id/textView8" />

<TextView
android:id="@+id/textView9"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="隐私政策"
android:textColor="#007fff"
android:textSize="16sp"
app:layout_constraintStart_toEndOf="@+id/textView10"
app:layout_constraintTop_toTopOf="@+id/textView7" />

</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.cardview.widget.CardView>

<ImageView
android:id="@+id/dialog_top_img"
android:layout_width="142dp"
android:layout_height="96dp"
android:elevation="2dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/ic_login_2" />

</androidx.constraintlayout.widget.ConstraintLayout>


  • bg_edit.xml


drawable目录下创建bg_edit.xml的资源文件,设置EditText的样式,代码如下:


<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
//不对焦的样式
<item android:state_window_focused="false"
android:drawable="@drawable/bg_edit_nofocused"/>

//对焦的样式
<item android:state_focused="true"
android:drawable="@drawable/bg_edit_focused" />

</selector>


  • bg_edit_nofocused


<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<corners android:radius="2px"/>
<solid android:color="@color/white"/>
<stroke android:color="#e4e6eb" android:width="1dp"/>
</shape>


  • bg_edit_focused


<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="@color/white"/>
<stroke android:color="#007fff" android:width="1dp"/>
</shape>


  • bg_btn.xml


drawable目录下创建bg_btn.xml的资源文件,设置TextView的样式,不用Button是因为设置background较为麻烦,代码如下


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

<solid android:color="#007fff"/>
<corners android:radius="2px"/>
</shape>


  • LoginDialo


LoginDialog.kt中代码具体如下:


class LoginDialog : DialogFragment() {
//使用viewBinding
lateinit var mBinding: DialogLoginBinding

override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
)
: View {
//创建布局
mBinding = DialogLoginBinding.inflate(layoutInflater)
return mBinding.root
}

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
//初始化Dialog的相关配置
initDialog()
}

/**
* 初始化dialog相关配置
*
*/

private fun initDialog() {
//设置Dialog的显示大小
setDialogSize()

//设置window的背景为透明色
dialog?.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))

//设置点击空白和返回键不消失
dialog?.setCanceledOnTouchOutside(false)

//设置dialog的动画
dialog?.window?.setWindowAnimations(R.style.dialog_base_anim)
}

/**
* 设置dialog的大小
*
*/

private fun setDialogSize(){
val window = dialog?.window
window?.let {

//获取屏幕信息
val wm = requireContext().getSystemService(Context.WINDOW_SERVICE) as? WindowManager
val display = wm?.defaultDisplay
val point = Point();
display?.getSize(point);


val layoutParams = it.attributes;

//设置宽度为屏幕的百分之90
layoutParams.width = (point.x * 0.9).toInt()
//设置高度为自适应
layoutParams.height = WindowManager.LayoutParams.WRAP_CONTENT
it.attributes = layoutParams
}
}
}

MainActivity



  1. 我们修改一下MainActivity,实现展示一个登录Button,点击后弹出登录界面,具体代码如下:


class MainActivity : AppCompatActivity() {

lateinit var mBinding: ActivityMainBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
mBinding = ActivityMainBinding.inflate(layoutInflater)
setContentView(mBinding.root)

mBinding.button.setOnClickListener {
LoginDialog().show(supportFragmentManager, "")
}
}
}

运行一下


这个时候我们的UI大致就完成了,我们运行看一下,是不是我们所有期望的那样


Screenshot_20220905_171401_com.juejin.login.jpg


登录逻辑


我们完成了UI相关的功能,接下来我们需要开始写,登录相关的逻辑了



  • 点击不同输入框显示不同UI


在web端中,当我们点击输入手机号和请输入密码时,最上面的UI是显示不同,我们先把这个一部分功能实现以下:


我们添加一个initView()方法,专门初始化View相关操作,具体代码如下:


private fun initView() {
//设置焦点变化监听
mBinding.editUser.onFocusChangeListener =
View.OnFocusChangeListener { v, hasFocus ->
//该控件获取了焦点
if(hasFocus){
//设置获取焦点后的UI
Glide.with(this).load(R.drawable.ic_login_2).into(mBinding.dialogTopImg)
}
}

mBinding.editPwd.onFocusChangeListener =
View.OnFocusChangeListener { v, hasFocus ->
//该控件获取了焦点
if(hasFocus){
//设置获取焦点后的UI
Glide.with(this).load(R.drawable.ic_login_1).into(mBinding.dialogTopImg)
}
}

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
//让输入框获取焦点
mBinding.editUser.requestFocus()
}
}

获取验证码


我们知道点击获取验证码会出现一个验证是否为人为操作,当操作完成后发送验证码,并且会有一个60s间隔,并且需要显示出实际秒数,我们使用Captcha库来完成验证,使用CountDownTimer来实现倒计时的效果


添加验证拼图



  • 添加倒计时


val timeDown = object : CountDownTimer(60 * 1000, 1000) {
override fun onTick(millisUntilFinished: Long) {
mBinding.tvCode.text = "${millisUntilFinished / 1000}s"
}

override fun onFinish() {
//设置验证码可点击
mBinding.tvCode.isEnabled = true
//恢复text
mBinding.tvCode.text = "获取验证码"
}

}


  • 添加依赖


implementation 'com.luozm.captcha:captcha:1.1.2'


  • 添加布局


<com.luozm.captcha.Captcha
android:id="@+id/capt_cha"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:elevation="2dp"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="@+id/cardView"
app:layout_constraintStart_toStartOf="@+id/cardView"
app:layout_constraintTop_toTopOf="@+id/cardView"
//随便找一个图片就行了
app:src="@drawable/ic_captcha" />


  • 添加事件监听


mBinding.captCha.setCaptchaListener(object : Captcha.CaptchaListener {
/**
* 验证通过回调
*
* @param time
* @return
*/

override fun onAccess(time: Long): String {
//设置验证码不可点击
mBinding.tvCode.isEnabled = false
//开始倒计时
timeDown.start()
//关闭图片验证
mBinding.captCha.visibility = View.GONE
return "验证通过,耗时" + time + "毫秒";
}

/**
* 验证失败回调
*
* @param failCount
* @return
*/

override fun onFailed(failCount: Int): String {
return "验证失败,已失败" + failCount + "次";
}

override fun onMaxFailed(): String {
Toast.makeText(
this@LoginDialog.requireContext(),
"验证超过次数,你的帐号被封锁",
Toast.LENGTH_SHORT
).show();
return "验证失败,帐号已封锁";
}

})


  • 点击发送验证码


mBinding.tvCode.setOnClickListener {
//显示图片验证
mBinding.captCha.visibility = View.VISIBLE
}

登录



  • 添加登录判断逻辑


我们还是在initView方法中添加代码:


mBinding.btnLogin.setOnClickListener {
//登录按钮不可交互
mBinding.btnLogin.isEnabled =false

//修改UI
mBinding.btnLogin.text = "登录中..."

//开始验证
//判断手机号格式是否正确,这里只做了长度的按断,其实可以用正则来判断,我这里知识简单判断
if(mBinding.editUser.text.toString().length < 11){
Toast.makeText(
this@LoginDialog.requireContext(),
"账号格式错误",
Toast.LENGTH_SHORT
).show();
//登录按钮可交互
mBinding.btnLogin.isEnabled =true
//修改UI
mBinding.btnLogin.text = "登录"
return@setOnClickListener
}
if(mBinding.editPwd.text.toString().length < 4){
Toast.makeText(
this@LoginDialog.requireContext(),
"验证码错误",
Toast.LENGTH_SHORT
).show();
//登录按钮可交互
mBinding.btnLogin.isEnabled =true
//修改UI
mBinding.btnLogin.text = "登录"
return@setOnClickListener
}

Toast.makeText(
this@LoginDialog.requireContext(),
"登录成功",
Toast.LENGTH_SHORT
).show();

dismiss()

}

总结


到这里我们模仿掘金Web端登录就成功了,如果想看源码在这里传送门


作者:zuoz
来源:juejin.cn/post/7139841541350588447
收起阅读 »

线程池也会导致OOM的原因

1. 前言 我这边从一个问题引出这次的话题,我们可能会在开中碰到一种OOM问题,java.lang.OutOfMemoryError: pthread_create (1040KB stack) failed: Try again 相信很多人碰到过这个错误,很...
继续阅读 »

1. 前言


我这边从一个问题引出这次的话题,我们可能会在开中碰到一种OOM问题,java.lang.OutOfMemoryError: pthread_create (1040KB stack) failed: Try again


相信很多人碰到过这个错误,很容易从网上搜索到出现这个问题的原因是线程过多,那线程过多为什么会导致OOM?线程什么情况下会释放资源?你又能如何做到让它不释放资源?


有的人可能会想到,那既然创建线程过多会导致OOM,那我用线程池不就行了。但是有没有想过,线程池,也可能会造成OOM。其实这里有个很经典的场景,你使用OkHttp的时候不注意,每次请求都创建OkHttpClient,导致线程池过多出现OOM


2. 简单了解线程池


如何去了解线程池,看源码,直接去看是很难看得懂的,要先了解线程池的原理,对它的设计思想有个大概的掌握之后,再去看源码,就会轻松很多,当然这里只了解基础的原理还不够,还需要有一些多线程相关的基础知识。


本篇文章只从部分源码的角度去分析,线程池如何导致OOM的,而不会全部去看所有线程池的源码细节,因为太多了


首先,要了解线程池,首先需要从它的参数入手:



  • corePoolSize:核心线程数量

  • maximumPoolSize:最大线程数量

  • keepAliveTime,unit:非核心线程的存活时间和单位

  • workQueue:阻塞队列

  • ThreadFactory:线程工厂

  • RejectedExecutionHandler:饱和策略


然后你从网上任何一个地方搜都能知道它大致的工作流程是,当一个任务开始执行时,先判断当前线程池数量是否达到核心线程数,没达到则创建一个核心线程来执行任务,如果超过,放到阻塞队列中等待,如果阻塞队列满了,未达到最大线程数,创建一条非核心线程执行任务,如果达到最大线程数,执行饱和策略。在这个过程中,核心线程不会回收,非核心线程会根据keepAliveTime和unit进行回收。


**这里可以多提一嘴,这个过程用了工厂模式ThreadFactory和策略模式RejectedExecutionHandler,关于策略模式可以看我这篇文章 ** juejin.cn/post/719502…


其实从这里就可以看出为什么线程池也会导致OOM了:核心线程不会回收,非核心线程使用完之后会根据keepAliveTime和unit进行回收 ,那核心线程就会一直存活(我这不考虑shutdown()和shutdownNow()这些情况),一直存活就会占用内存,那你如果创建很多线程池,就会OOM。


所以我这篇文章要分析:核心线程不会释放资源的过程,它内部怎么做到的。 只从这部分的源码去进行分析,不会全部都详细讲。


先别急,为了照顾一些基础不太好的朋友,涉及一些基础知识感觉还是要多讲一下。上面提到的线程回收和shutdown方法这些是什么意思?线程执行完它内部的代码后会主动释放资源吗?


我们都知道开发中有个概念叫生命周期,当然线程池和线程也有生命周期(这很重要),在开发中,我们称之为lifecycle。


生命周期当然是设计这个东西的开发者所定义的,我们先看线程池的生命周期,在ThreadPoolExecutor的注释中有写:


*
* The runState provides the main lifecycle control, taking on values:
*
* RUNNING: Accept new tasks and process queued tasks
* SHUTDOWN: Don't accept new tasks, but process queued tasks
* STOP: Don't accept new tasks, don't process queued tasks,
* and interrupt in-progress tasks
* TIDYING: All tasks have terminated, workerCount is zero,
* the thread transitioning to state TIDYING
* will run the terminated() hook method
* TERMINATED: terminated() has completed
*

看得出它的生命周期有RUNNING,SHUTDOWN,STOP,TIDYING和TERMINATED。而shutdown()和shutdownNow()方法会改变生命周期,这里不是对线程池做全面解析,所以先有个大概了解就行,可以暂时理解成这篇文章的所有分析都是针对RUNNING状态下的。


看完线程池的,再看看线程的生命周期。线程的生命周期有:



  • NEW:创建,简单来说就是new出来没start

  • RUNNABLE:运行,简单来说就是start后执行run方法

  • TERMINATED:中止,简单来说就是执行完run方法或者进行中断操作之后会变成这个状态

  • BLOCKED:阻塞,就是加锁之后竞争锁会进入到这个状态

  • WAITING、TIMED_WAITING:休眠,比如sleep方法


这个很重要,需要了解,你要学会线程这块相关的知识点的话,这些生命周期要深刻理解 。比如BLOCKED和WAITING有什么不同?然后学这块又会涉及到锁那一块的知识。以后有时间可以单独写几篇这类的文章,这里先大概有个概念,只需要能先看懂后面的源码就行。


从生命周期的概念你就能知道线程执行完它内部的代码后会主动释放资源,因为它run执行完之后生命周期会到TERMINATED,那这又涉及到了一个知识点,为什么主线程(ActivityThread),执行完run的代码后不会生命周期变成TERMINATED,这又涉及到Looper,就得了解Handler机制,可以看我这篇文章 juejin.cn/post/715882…


扯远了,现在进入正题,先想想,如果是你,你怎么做让核心线程执行完run之后不释放资源,很明显,只要让它不执行到TERMINATED生命周期就行,如何让它不变成TERMINATED状态,只需要让它进入BLOCKED或者WAITING状态就行。所以我的想法是这样的,当这个核心线程执行完这个任务之后,我让它WAITING,等到有新的任务进来的时候我再唤醒它进入RUNNABLE状态。 这是我从理论这个角度去分析的做法,那看看实际ThreadPoolExecutor是怎么做的


3. 线程池部分源码分析


前面说了,不会全部都讲,这里涉及到文章相关内容的流程就是核心线程的任务执行过程,所以这里主要分析核心线程。


当我们使用线程池执行一个任务时,会调用ThreadPoolExecutor的execute方法


public void execute(Runnable command) {
......

int c = ctl.get();
if (workerCountOf(c) < corePoolSize) {
if (addWorker(command, true))
return;
c = ctl.get();
}

// 我们只看核心线程的流程,所以后面的代码不用管
......
}

这个ctl是一个状态相关的代码,可以先不用管,我后面会简单统一做个解释,这里不去管它会比较容易理解,我们现在主要是为了看核心线程的流程。从这里可以看出,当前线程的数量小于核心线程的话执行addWorker方法


private boolean addWorker(Runnable firstTask, boolean core) {
retry:
for (;;) {
int c = ctl.get();
int rs = runStateOf(c);

// Check if queue empty only if necessary.
if (rs >= SHUTDOWN &&
! (rs == SHUTDOWN &&
firstTask == null &&
! workQueue.isEmpty()))
return false;

for (;;) {
int wc = workerCountOf(c);
if (wc >= CAPACITY ||
wc >= (core ? corePoolSize : maximumPoolSize))
return false;
if (compareAndIncrementWorkerCount(c))
break retry;
c = ctl.get(); // Re-read ctl
if (runStateOf(c) != rs)
continue retry;
// else CAS failed due to workerCount change; retry inner loop
}
}

boolean workerStarted = false;
boolean workerAdded = false;
Worker w = null;
try {
w = new Worker(firstTask);
final Thread t = w.thread;
if (t != null) {
final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
// Recheck while holding lock.
// Back out on ThreadFactory failure or if
// shut down before lock acquired.
int rs = runStateOf(ctl.get());

if (rs < SHUTDOWN ||
(rs == SHUTDOWN && firstTask == null)) {
if (t.isAlive()) // precheck that t is startable
throw new IllegalThreadStateException();
workers.add(w);
int s = workers.size();
if (s > largestPoolSize)
largestPoolSize = s;
workerAdded = true;
}
} finally {
mainLock.unlock();
}
if (workerAdded) {
t.start();
workerStarted = true;
}
}
} finally {
if (! workerStarted)
addWorkerFailed(w);
}
return workerStarted;
}

这个addWorker分为上下两部分,我们分别来做解析


private boolean addWorker(Runnable firstTask, boolean core) {
retry:
for (;;) {
int c = ctl.get();
int rs = runStateOf(c);

// Check if queue empty only if necessary.
if (rs >= SHUTDOWN &&
! (rs == SHUTDOWN &&
firstTask == null &&
! workQueue.isEmpty()))
return false;

for (;;) {
int wc = workerCountOf(c);
if (wc >= CAPACITY ||
wc >= (core ? corePoolSize : maximumPoolSize))
return false;
if (compareAndIncrementWorkerCount(c))
break retry;
c = ctl.get(); // Re-read ctl
if (runStateOf(c) != rs)
continue retry;
// else CAS failed due to workerCount change; retry inner loop
}
}

// 下半部分
......
}

这里主要是做了状态判断的一些操作,我说过状态相关的我们可以先不管,但是这里的写法我觉得要单独讲一下为什么会这么写。不然它内部很多代码是这样的,我怕劝退很多人。


首先retry: ...... break retry; 这个语法糖,平常我们开发很少用到,可以去了解一下,这里就是为了跳出循环。 其次,这里的compareAndIncrementWorkerCount内部的代码是AtomicInteger ctl.compareAndSet(expect, expect + 1) ,Atomic的compareAndSet操作搭配死循环,这叫自旋,所以说要看懂这个需要一定的java多线程相关的基础。自旋的目的是为了什么?这就又涉及到了锁的分类中有乐观锁,有悲观锁。不清楚的可以去学一下这些知识,你就知道为什么它要这么做了,这里就不一一解释。包括你看它的源码,能看到,它会很多地方用自旋,很多地方用ReentrantLock,但它就是不用synchronized ,这些都是多线程这块基础的知识,这里不多说了。


看看下半部分


private boolean addWorker(Runnable firstTask, boolean core) {

// 上半部分
......



boolean workerStarted = false;
boolean workerAdded = false;
Worker w = null;
try {
w = new Worker(firstTask);
final Thread t = w.thread;
if (t != null) {
......
if (workerAdded) {
t.start();
workerStarted = true;
}
}
} finally {
......
}
return workerStarted;
}

看到它先创建一个Worker对象,再调用Worker对象内部的线程的start方法,我们看看Worker


private final class Worker
extends AbstractQueuedSynchronizer
implements Runnable
{

private static final long serialVersionUID = 6138294804551838833L;

final Thread thread;
Runnable firstTask;

Worker(Runnable firstTask) {
setState(-1); // inhibit interrupts until runWorker
this.firstTask = firstTask;
this.thread = getThreadFactory().newThread(this);
}

public void run() {
runWorker(this);
}

// 其它方法
......
}

看到它内部主要有两个对象firstTask就是任务,thread就是执行这个任务的线程,而这个线程是通过getThreadFactory().newThread(this)创建出来的,这个就是我们创建ThreadPoolExecutor时传的“线程工厂”

外部调t.start();之后就会执行这里的run方法,因为newThread传了this进去,你可以先简单理解调这个线程start会执行到这个run,然后run中调用runWorker(this);


注意,你想想runWorker(this)方法,包括之后的流程,都是执行在哪个线程中?都是执行在子线程中,因为这个run方法中的代码,都是执行在这个线程中。你一定要理解这一步,不然你自己看源码会可能看懵。 因为有些人长期不接触多线程环境的情况下,你会习惯单线程的思维去看问题,那就很容易出现理解上的错误。


我们继续看看runWorker,时刻提醒你自己,之后的流程都是在子线程中进行,这条子线程的生命周期变为RUNNABLE


final void runWorker(Worker w) {
Thread wt = Thread.currentThread();
Runnable task = w.firstTask;
w.firstTask = null;
w.unlock(); // allow interrupts
boolean completedAbruptly = true;
try {
while (task != null || (task = getTask()) != null) {s
w.lock();

// 中断相关的操作
......

try {
beforeExecute(wt, task);
Throwable thrown = null;
try {
task.run();
} catch (RuntimeException x) {
......
} finally {
afterExecute(task, thrown);
}
} finally {
......
}
}
completedAbruptly = false;
} finally {
processWorkerExit(w, completedAbruptly);
}
}

先讲讲这里的一个开发技巧,task.run()就是执行任务,它前面的beforeExecute和afterExecute就是模板方法设计模式,方便扩展用。

执行完任务后,最后执行processWorkerExit方法


private void processWorkerExit(Worker w, boolean completedAbruptly) {
if (completedAbruptly) // If abrupt, then workerCount wasn't adjusted
decrementWorkerCount();

final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
completedTaskCount += w.completedTasks;
workers.remove(w);
} finally {
mainLock.unlock();
}

tryTerminate();

......
}

workers.remove(w)后执行tryTerminate方法尝试将线程池的生命周期变为TERMINATED


final void tryTerminate() {
for (;;) {
int c = ctl.get();
if (isRunning(c) ||
runStateAtLeast(c, TIDYING) ||
(runStateOf(c) == SHUTDOWN && ! workQueue.isEmpty()))
return;
if (workerCountOf(c) != 0) { // Eligible to terminate
interruptIdleWorkers(ONLY_ONE);
return;
}

final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
if (ctl.compareAndSet(c, ctlOf(TIDYING, 0))) {
try {
terminated();
} finally {
ctl.set(ctlOf(TERMINATED, 0));
termination.signalAll();
}
return;
}
} finally {
mainLock.unlock();
}
// else retry on failed CAS
}
}

先不用管状态的变化,一般一眼都能看得出这里是结束的操作了,我们追踪的核心线程正常在RUNNING状态下是不会执行到这里的。 那我们期望的没任务情况下让线程休眠的操作在哪里?

看回runWorker方法


final void runWorker(Worker w) {
......
boolean completedAbruptly = true;
try {
while (task != null || (task = getTask()) != null) {s
......
}
completedAbruptly = false;
} finally {
processWorkerExit(w, completedAbruptly);
}
}

看到它的while中有个getTask()方法,认真看runWorker方法其实能看出,核心线程执行完一个任务之后会getTask()拿下一个任务去执行,这就是当核心线程满的时候任务会放到阻塞队列中,核心线程执行完任务之后会从阻塞队列中拿下一个任务执行。 getTask()从抽象上来看,就是从队列中拿任务。


private Runnable getTask() {
boolean timedOut = false; // Did the last poll() time out?

for (;;) {
......

try {
Runnable r = timed ?
workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS) :
workQueue.take();
if (r != null)
return r;
timedOut = true;
} catch (InterruptedException retry) {
timedOut = false;
}
}
}

先把timed当成正常情况下为false,然后会执行workQueue.take(),这个workQueue是阻塞队列BlockingQueue, 注意,这里又需要有点基础了。正常有点基础的人看到这里,已经知道这里就是当没有任务会让核心线程休眠的操作,看不懂的,可以先了解下什么是AQS,可以看看我这篇文章 juejin.cn/post/716801…


如果你说你懒得看,行吧,我随便拿个ArrayBlockingQueue给你举例


public E take() throws InterruptedException {
final ReentrantLock lock = this.lock;
lock.lockInterruptibly();
try {
while (count == 0)
notEmpty.await();
return dequeue();
} finally {
lock.unlock();
}
}

notEmpty是Condition,这里调用了Condition的await()方法,然后想想执行这步操作的是在哪条线程上?线程进入WAITING状态了吧,不会进入TERMINATED了吧。


然后当有任务添加之后会唤醒它,它继续在循环中去执行任务。


这就验证了我们的猜想,通过让核心线程进入WAITING状态以此来达到执行完run方法中的任务也不会主动TERMINATED而释放线程。所以核心线程一直占用资源,这里说的资源指的是空间,而cpu的时间片是会让出的。


4. 部分线程池的操作解读


为什么线程池也会导致OOM,上面已经通过源码告诉你,核心线程不会释放内存空间,导致线程池多的情况下也会导致OOM。这里为了方便新手阅读ThreadPoolExecutor相关的代码,还是觉得写一些它内部的设计思想,不然没点基础的话确实很难看懂。


首先就是状态,上面源码中都有关线程池的生命中周期状态(ctl字段),可以看看它怎么设计的


private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0));
private static final int COUNT_BITS = Integer.SIZE - 3; // Integer.SIZE是32
private static final int CAPACITY = (1 << COUNT_BITS) - 1;

// runState is stored in the high-order bits
private static final int RUNNING = -1 << COUNT_BITS;
private static final int SHUTDOWN = 0 << COUNT_BITS;
private static final int STOP = 1 << COUNT_BITS;
private static final int TIDYING = 2 << COUNT_BITS;
private static final int TERMINATED = 3 << COUNT_BITS;

它这里用了两个设计思想,第一个就是用位来表示状态,关于这类型的设计,可以看我这2篇文章 juejin.cn/post/715547…juejin.cn/post/720550…


另外一个设计思想是:用一个变量的高位置表示状态,低位表示数量。 这里就是用高3位来表示生命周期,剩下的低位表示线程的数量。和这个类似的操作有view中的MeasureSpec,也是一个变量表示两个状态。


然后关于设计模式,可以看到它这里最经典的就是用了策略模式,如果你看饱和策略那块的源码,可以好好看看它是怎么设计的。其它的还有工厂、模板之类的,这些也不难,就是策略还是建议学下它怎么去设计的。


然后多线程相关的基础,这个还是比较重要的,这块的基础不好,看ThreadPoolExecutor的源码会相对吃力。比如我上面提过的,线程的生命周期,锁相关的知识,还有AQS等等。如果你熟悉这些,再看这个源码就会轻松很多。


对于总体的设计,你第一看会觉得它的源码很绕,为什么会这样?因为有中断操作+自旋锁+状态的设计 ,它的这种设计就基本可以说是优化代码到极致,比如说状态的设计,就比普通的能省内存,能更方便通过CAS操作。用自旋就是乐观锁,能节省资源等。有中断操作,能让整个系统更灵活。相对的缺点就是不安全,什么意思呢?已是就是这样写代码很容易出BUG,所以这里的让人觉得很绕的代码,就是很多的状态的判断,这些都是为了保证这个流程的安全。


5. 总结


从部分源码的角度去分析,得到的结论是线程池也可能会导致OOM


那再思考一个问题:不断的创建线程池,“一定”会导致OOM吗? 如果你对线程池已经有一定的了解,相信你也知

作者:流浪汉kylin
来源:juejin.cn/post/7210691957790572601
道这个问题的答案。


收起阅读 »

从Flutter到Compose,为什么都在推崇声明式UI?

Compose推出之初,就曾引发广泛的讨论,其中一个比较普遍的声音就是——“🤨这跟Flutter也长得太像了吧?!” 这里说的长得像,实际更多指的是UI编码的风格相似,而关于这种风格有一个专门的术语,叫做声明式UI。 对于那些已经习惯了命令式UI的Androi...
继续阅读 »

Compose推出之初,就曾引发广泛的讨论,其中一个比较普遍的声音就是——“🤨这跟Flutter也长得太像了吧?!”


这里说的长得像,实际更多指的是UI编码的风格相似,而关于这种风格有一个专门的术语,叫做声明式UI


对于那些已经习惯了命令式UI的Android或iOS开发人员来说,刚开始确实很难理解什么是声明式UI。就像当初刚踏入编程领域的我们,同样也很难理解面向过程编程面向对象编程的区别一样。


为了帮助这部分原生开发人员完成从命令式UI到声明式UI的思维转变,本文将结合示例代码编写、动画演示以及生活例子类比等形式,详细介绍声明式UI的概念、优点及其应用。


照例,先奉上思维导图一张,方便复习:





命令式UI的特点


既然命令式UI与声明式UI是相对的,那就让我们先来回顾一下,在一个常规的视图更新流程中,如果采用的是命令式UI,会是怎样的一个操作方式。


以Android为例,首先我们都知道,Android所采用的界面布局,是基于View与ViewGroup对象、以树状结构来进行构建的视图层级。



当我们需要对某个节点的视图进行更新时,通常需要执行以下两个操作步骤:



  1. 使用findViewById()等方法遍历树节点以找到对应的视图。

  2. 通过调用视图对象公开的setter方法更新视图的UI状态


我们以一个最简单的计数器应用为例:



这个应用唯一的逻辑就是“当用户点击"+"号按钮时数字加1”。在传统的Android实现方式下,代码应该是这样子的:


class CounterActivity : AppCompatActivity() {

var count: Int = 0

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_counter)

val countTv = findViewById<TextView>(R.id.count_tv)
countTv.text = count.toString()

val plusBtn = findViewById<Button>(R.id.plus_btn)
plusBtn.setOnClickListener {
count += 1
countTv.text = count.toString()
}

}
}

这段代码看起来没有任何难度,也没有明显的问题。但是,假设我们在下一个版本中添加了更多的需求:




  • 当用户点击"+"号按钮,数字加1的同时在下方容器中添加一个方块。

  • 当用户点击"-"号按钮,数字减1的同时在下方容器中移除一个方块。

  • 当数字为0时,下方容器的背景色变为透明。


现在,我们的代码变成了这样:


class CounterActivity : AppCompatActivity() {

var count: Int = 0

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_counter)

// 数字
val countTv = findViewById<TextView>(R.id.count_tv)
countTv.text = count.toString()

// 方块容器
val blockContainer = findViewById<LinearLayout>(R.id.block_container)

// "+"号按钮
val plusBtn = findViewById<Button>(R.id.plus_btn)
plusBtn.setOnClickListener {
count += 1
countTv.text = count.toString()
// 方块
val block = View(this).apply {
setBackgroundColor(Color.WHITE)
layoutParams = LinearLayout.LayoutParams(40.dp, 40.dp).apply {
bottomMargin = 20.dp
}
}
blockContainer.addView(block)
when {
count > 0 -> {
blockContainer.setBackgroundColor(Color.parseColor("#FF6200EE"))
}
count == 0 -> {
blockContainer.setBackgroundColor(Color.TRANSPARENT)
}
}
}

// "-"号按钮
val minusBtn = findViewById<Button>(R.id.minus_btn)
minusBtn.setOnClickListener {
if(count <= 0) return@setOnClickListener
count -= 1
countTv.text = count.toString()
blockContainer.removeViewAt(0)
when {
count > 0 -> {
blockContainer.setBackgroundColor(Color.parseColor("#FF6200EE"))
}
count == 0 -> {
blockContainer.setBackgroundColor(Color.TRANSPARENT)
}
}
}

}

}

已经开始看得有点难受了吧?这正是命令式UI的特点,侧重于描述怎么做,我们需要像下达命令一样,手动处理每一项UI的更新,如果UI的复杂度足够高的话,就会引发一系列问题,诸如:



  • 可维护性差:需要编写大量的代码逻辑来处理UI变化,这会使代码变得臃肿、复杂、难以维护。

  • 可复用性差:UI的设计与更新逻辑耦合在一起,导致只能在当前程序使用,难以复用。

  • 健壮性差:UI元素之间的关联度高,每个细微的改动都可能一系列未知的连锁反应。


声明式UI的特点


而同样的功能,假如采用的是声明式UI,则代码应该是这样子的:


class _CounterPageState extends State<CounterPage> {
int _count = 0;

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Column(
children: [
// 数字
Text(
_count.toString(),
style: const TextStyle(fontSize: 48),
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
// +"号按钮
ElevatedButton(
onPressed: () {
setState(() {
_count++;
});
},
child: const Text("+")),
// "-"号按钮
ElevatedButton(
onPressed: () {
setState(() {
if (_count == 0) return;
_count--;
});
},
child: const Text("-"))
],
),
Expanded(
// 方块容器
child: Container(
width: 60,
padding: const EdgeInsets.all(10),
color: _count > 0 ? const Color(0xFF6200EE) : Colors.transparent,

child: ListView.separated(
itemCount: _count,
itemBuilder: (BuildContext context, int index) {
// 方块
return Container(width: 40, height: 40, color: Colors.white);
},
separatorBuilder: (BuildContext context, int index) {
return const Divider(color: Colors.transparent, height: 10);
},
),
))
],
),
);
}
}


在这样的代码中,我们几乎看不到任何操作UI更新的代码,而这正是声明式UI的特点,它侧重于描述做什么,而不是怎么做,开发者只需要关注UI应该如何呈现,而不需要关心UI的具体实现过程。


开发者要做的,就只是提供不同UI与不同状态之间的映射关系,而无需编写如何在不同UI之间进行切换的代码。


所谓状态,指的是构建用户界面时所需要的数据,例如一个文本框要显示的内容,一个进度条要显示的进度等。Flutter框架允许我们仅描述当前状态,而转换的工作则由框架完成,当我们改变状态时,用户界面将自动重新构建


下面我们将按照通常情况下,用声明式UI实现一个Flutter应用所需要经历的几个步骤,来详细解析前面计数器应用的代码:



  1. 分析应用可能存在的各种状态


根据我们前面对于“状态”的定义,我们可以很容易地得出,在本例中,数字(_count值)本身即为计数器应用的状态,其中还包括数字为0时的一个特殊状态。



  1. 提供每个不同状态所对应要展示的UI


build方法是将状态转换为UI的方法,它可以在任何需要的时候被框架调用。我们通过重写该方法来声明UI的构造:


对于顶部的文本,只需声明每次都使用最新返回的状态(数字)即可:


Text(
_count.toString(),
...
),

对于方块容器,只需声明当_count的值为0时,容器的背景颜色为透明色,否则为特定颜色:


Container(
color: _count > 0 ? const Color(0xFF6200EE) : Colors.transparent,
...
)

对于方块,只需声明返回的方块个数由_count的值决定:


ListView.separated(
itemCount: _count,
itemBuilder: (BuildContext context, int index) {
// 方块
return Container(width: 40, height: 40, color: Colors.white);
},
...
),


  1. 根据用户交互或数据查询结果更改状态


当由于用户的点击数字发生变化,而我们需要刷新页面时,就可以调用setState方法。setState方法将会驱动build方法生成新的UI:


// "+"号按钮
ElevatedButton(
onPressed: () {
setState(() {
_count++;
});
},
child: const Text("+")),
// "-"号按钮
ElevatedButton(
onPressed: () {
setState(() {
if (_count == 0) return;
_count--;
});
},
child: const Text("-"))
],

可以结合动画演示来回顾这整个过程:



最后,用一个公式来总结一下UI、状态与build方法三者的关系,那就是:



以命令式和声明式分别点一杯奶茶


现在,你能了解命令式UI与声明式UI的区别了吗?如果还是有些抽象,我们可以用一个点奶茶的例子来做个比喻:


当我们用命令式UI的思维方式去点一杯奶茶,相当于我们需要告诉制作者,冲一杯奶茶必须按照煮水、冲茶、加牛奶、加糖这几个步骤,一步步来完成,也即我们需要明确每一个步骤,从而使得我们的想法具体而可操作。


而当我们用声明式UI的思维方式去点一杯奶茶,则相当于我们只需要告诉制作者,我需要一杯“温度适中、口感浓郁、有一点点甜味”的奶茶,而不必关心具体的制作步骤和操作细节。


声明式编程的优点


综合以上内容,我们可以得出声明式UI有以下几个优点:




  • 简化开发:开发者只需要维护状态->UI的映射关系,而不需要关注具体的实现细节,大量的UI实现逻辑被转移到了框架中。




  • 可维护性强:通过函数式编程的方式构建和组合UI组件,使代码更加简洁、清晰、易懂,便于维护。




  • 可复用性强:将UI的设计和实现分离开来,使得同样的UI组件可以在不同的应用程序中使用,提高了代码的可复用性。




总结与展望


总而言之,声明式UI是一种更加高层次、更加抽象的编程方式,其最大的优点在于能极大地简化现有的开发模式,因此在现代应用程序中得到广泛的应用,随着更多框架的采用与更多开发者的加入,声明式UI必将继续发展壮大,成为以后构建用户界面的首选方式。


作者:星际码仔
来源:juejin.cn/post/7212622837063811109
收起阅读 »

Android 带你重新认知属性动画

我报名参加金石计划1期挑战——瓜分10万奖池,这是我的第5篇文章,点击查看活动详情 前言 之前写过一篇关于属性动画简单使用的文章juejin.cn/post/714417… 虽然官方直接提供的属性动画只有4个效果:透明度、位移、旋转、缩放,然后用Set实现组合...
继续阅读 »

我报名参加金石计划1期挑战——瓜分10万奖池,这是我的第5篇文章,点击查看活动详情


前言


之前写过一篇关于属性动画简单使用的文章juejin.cn/post/714417…


虽然官方直接提供的属性动画只有4个效果:透明度、位移、旋转、缩放,然后用Set实现组合,用插值器加一些效果。但其实属性动画能做的超越你的想象,他能做到anything。你可以实现各种你所想象的效果,改图片形状、路径的动画、颜色的变化等(当然这得是矢量图)。而插值器,除了系统提供的那些插值器之外,你还能进行自定义实现你想要的运动效果。


实现的效果


我这里拿个形变的效果来举例。可以先看看实现的效果:


sp.gif


实现要点


要点主要有两点:(1)要去想象,到了这种程度包括更复杂的效果,没有人能教你的,只能靠自己凭借经验和想象力去规划怎么实现。 (2)要计算,一般做这种自定义的往往会涉及计算的成分,所以你要实现的效果越高端,需要计算的操作就越复杂。


思路


我做这个播放矢量图和暂停矢量图之间的形变,这个思路是这样的: 其实那个三角形是由两部分组成,左边是一个矩形(转90度的梯形),右边是一个三角形。然后把两个图形再分别变成长方形。具体计算方式是我把width分成4份,然后配合一个偏移量offset去进行调整(计算的部分没必要太纠结,都是要调整的)


步骤:



  1. 绘制圆底和两个图形

  2. 属性动画

  3. 页面退出后移除动画


1. 绘制圆底和两个图形


一共三个Paint


init {
paint = Paint()
paint2 = Paint()
paint3 = Paint()

paint?.color = context.resources.getColor(R.color.kylin_main_color)
paint?.isAntiAlias = true
paint2?.color = context.resources.getColor(R.color.kylin_white)
paint2?.style = Paint.Style.FILL
paint2?.isAntiAlias = true
paint3?.color = context.resources.getColor(R.color.kylin_white)
paint3?.isAntiAlias = true
}

绘制圆底就比较简单


paint?.let {
canvas?.drawCircle((width/2).toFloat(), (height/2).toFloat(), (width/2).toFloat(),
it
)
}

然后先看看我的一个参考距离的计算(有这个参考距离,才能让图形大小跟着宽高而定,而不是写死)


override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
super.onLayout(changed, left, top, right, bottom)
if (baseDim == 0f){
baseDim = (0.25 * width).toFloat()
}
}

另外两个图用路径实现


if (path1 == null || path2 == null){
path1 = Path()
path2 = Path()
// 设置初始状态
startToStopAnim(0f)
}
paint2?.let { canvas?.drawPath(path1!!, it) }
paint3?.let { canvas?.drawPath(path2!!, it) }

看具体的绘制实现


private fun startToStopAnim(currentValue : Float){
val offset : Int = (baseDim * 0.25 * (1-currentValue)).toInt()

path1?.reset()
path1?.fillType = Path.FillType.WINDING
path1?.moveTo(baseDim + offset, baseDim) // 点1不变
path1?.lineTo(2 * baseDim+ offset - baseDim/3*currentValue,
baseDim + (0.5 * baseDim).toInt() * (1-currentValue))
path1?.lineTo(2 * baseDim+ offset - baseDim/3*currentValue,
2 * baseDim +(0.5 * baseDim).toInt() + (0.5 * baseDim).toInt() * currentValue)
path1?.lineTo(baseDim+ offset, 3 * baseDim) // 点4不变
path1?.close()


path2?.reset()
path2?.fillType = Path.FillType.WINDING
if (currentValue <= 0f) {
path2?.moveTo(2 * baseDim + offset, baseDim + (0.5 * baseDim).toInt())
path2?.lineTo(3 * baseDim + offset, 2 * baseDim)
path2?.lineTo(2 * baseDim + offset, 2 * baseDim + (0.5 * baseDim).toInt())
}else {
path2?.moveTo(2 * baseDim+ offset + baseDim/3*currentValue,
baseDim + (0.5 * baseDim).toInt() * (1-currentValue))
path2?.lineTo(3 * baseDim + offset, baseDim + baseDim * (1-currentValue))
path2?.lineTo(3 * baseDim + offset, 2 * baseDim + baseDim * currentValue)
path2?.lineTo(2 * baseDim+ offset + baseDim/3*currentValue,
2 * baseDim +(0.5 * baseDim).toInt() + (0.5 * baseDim).toInt() * currentValue)
}
path2?.close()
}

这个计算的过程不好解释,加偏移量就是一个调整的过程,可以去掉偏移量offset看看效果就知道为什么要加了。path1代表左边的路径,左边的路径是4个点,path2是右边的路径,右边的路径会根据情况去决定是3个点还是4个点,默认情况是3个。


2、属性动画


fun startToStopChange(){
isRecordingStart = true
if (mValueAnimator1 == null) {
mValueAnimator1 = ValueAnimator.ofFloat(0f, 1f)
mValueAnimator1?.addUpdateListener {
val currentValue: Float = it.animatedValue as Float
startToStopAnim(currentValue)
postInvalidate()
}
mValueAnimator1?.interpolator = AccelerateInterpolator()
}
mValueAnimator1?.setDuration(500)?.start()
}

float类型0到1其实就是实现一个百分比的效果。变过去能实现后,变回来就也就很方便


fun stopToStartChange(){
isRecordingStart = false
if (mValueAnimator2 == null) {
mValueAnimator2 = ValueAnimator.ofFloat(1f, 0f)
mValueAnimator2?.addUpdateListener {
val currentValue: Float = it.animatedValue as Float
startToStopAnim(currentValue)
postInvalidate()
}
mValueAnimator2?.interpolator = AccelerateInterpolator()
}
mValueAnimator2?.setDuration(500)?.start()
}

3.移除动画


view移除后要移除动画


fun close(){
try {
if (mValueAnimator1?.isStarted == true){
mValueAnimator1?.cancel()
}
if (mValueAnimator2?.isStarted == true){
mValueAnimator2?.cancel()
}
}catch (e : Exception){
e.printStackTrace()
}finally {
mValueAnimator1 = null
mValueAnimator2 = null
}
}

然后还要注意,这个动画是耗时操作,所以要做防快速点击。


总结


从代码可以看出,其实并实现起来并不难,难的在于自己要有想象力,要能想出这样的一个过程,比较花费时间的可能就是一个调整的过程,其它也基本没什么技术难度。


我这个也只是简单做了个Demo来演示,你要问能不能实现其它效果,of course,你甚至可以先把三角形变成一个正方形,再变成两个长方形等等,你甚至可以用上贝塞尔来实现带曲线的效果。属性动画就是那么的强大,对于矢量图,它能实现几乎所有的你想要的效果,只有你想不到,没有它做不到。


作者:流浪汉kylin
来源:juejin.cn/post/7144190782205853732
收起阅读 »

我的 Android 应用安全方案梳理

作为独立开发者,应用被破解是一件非常让人烦恼的事情。之前有同学在我的一篇博文下面问,有没有一些 Android 防破解的方法。在多次加固、破解、再加固、再破解的过程中,我也积累了一些思路和方法。这里分享一下,如果需要用到,可以作一个参考。 先说一个结论,也是我...
继续阅读 »

作为独立开发者,应用被破解是一件非常让人烦恼的事情。之前有同学在我的一篇博文下面问,有没有一些 Android 防破解的方法。在多次加固、破解、再加固、再破解的过程中,我也积累了一些思路和方法。这里分享一下,如果需要用到,可以作一个参考。


先说一个结论,也是我在 Stackoverflow 上面的一个国外程序员的答案,


anti_debug.png


就是说,APK 包已经在别人手上了,我们能做的不过是提升被破解的难度,如果真的遇到非常“执着”的,要破解一样被破解。如果逻辑非常值钱,那么最好还是把逻辑放到服务器上面。此外,加固也是一个可选的方案。不过目前市面上专业的加固价格并不美丽,各大平台年费从 3 万至 8 万不等,并且对个人开发者并不友好。


下面是我开发过程中为了防止应用被破解采取的一些策略。


1、一些必要的基础知识


首先,别人要破解你的软件。如果只是在自己的手机上面使用,那么他可以修改系统的一些方法进行破解。这种不在我的考虑范围内,因为他们的修改只在自己的手机上生效,构不成传播。我关注的是 APK 文件被破解的情况。


我们在加密的时候会用到一些加密或者编码方法。常见的有,非对称加密算法 RSA 等;对称加密算法 DES、3DES 和 AES 等;不可逆的加密 MD5、SHA256 等。


另外,我们会把重要的加密逻辑放到 Native 层来实现,所以一些 JNI 编程的方法也是需要的。不过,如果仅仅是用来作加密的话,对 C/C++ 的要求是没那么高的。对在 Android 中使用 JNI,可以参考我之前的文章《在 Android 中使用 JNI 的总结》


2、签名校验


2.1 基础签名校验


在应用和 so 中作签名校验可以说是最基本的安全策略。在应用中作签名校验可以防止应用被二次打包。因为如果别人修改你的代码,肯定要重新打包,此时签名必然会改变。对 so 作签名校验是很有必要的,除了防止应用被打包,也可以防止你的 so 被别人盗用。


可以使用如下的代码在 java 中进行签名校验,


private static String getAppSignatureHash(final String packageName, final String algorithm) {
if (StringUtils.isSpace(packageName)) return "";
Signature[] signature = getAppSignature(packageName);
if (signature == null || signature.length <= 0) return "";
return StringUtils.bytes2HexString(EncryptUtils.hashTemplate(signature[0].toByteArray(), algorithm))
.replaceAll("(?<=[0-9A-F]{2})[0-9A-F]{2}", ":$0");
}

对于在 Native 层作签名校验,将上述方法翻译成对应的 JNI 调用即可,这里就不赘述了。


上面是签名校验的逻辑,看似美好,实际上稍微碰到有点破解的经验的就顶不住了。我之前遇到的一种破解上述签名校验的方法是,在自定义 Application 的 onCreate() 方法中读取 APK 的签名并存储到全局变量中,然后 Hook 获取应用签名的方法,并把上述读取到的真实的签名信息返回,以此绕过签名校验逻辑。


2.2 Application 类型校验


针对上述这种破解方式,我想到的第一个方法是对当前应用的 Application 类型作校验。因为他们加载 Hook 的逻辑是在自定义的 Application 中完成的,如果他们的 Application 和我们自己的 Application 类路径不一致,那么可以认定应用为破解版。


不过,这种方式作用也有限。我当时采用这种策略是考虑到有的破解者可能就是用一个脚本破解所有应用,所以改动一下可以防止这类破解者。但是,后来我也遇到一些“狠人”。因为我的软件用了 360 加固,所以如果加固壳工程的 Application 也认为是合法的。于是,我就看到了有的破解者在我的加固包之上又做了一层加固...


2.3 另一种签名校验方法


上述签名校验容易被 Hook 绕过,我们还可以采用另一种签名校验方法。


记得之前在《使用 APT 开发组件化框架的若干细节问题》 这篇文章中提到过,ARouter 在加载 APT 生成的路由信息的时候,一种方式是获取软件的 APK,然后从 APK 的 dex 中获取指定包名下的类文件。那么,我们是不是也可以借鉴这种方式来直接对 APK 进行签名校验呢?


首先,你可以采用下面的方法获取软件的 APK,


ApplicationInfo applicationInfo = context.getPackageManager().getApplicationInfo(context.getPackageName(), 0);
File sourceApk = new File(applicationInfo.sourceDir);

获取 APK 签名信息的方法比较多,这里我提供的是 Android 源码中的打包文件的签名代码,代码位置是:android.googlesource.com/platform/to…


这样,当我们拿到 APK 之后,使用上述方法直接对 APK 的签名信息进行校验即可。


3、对重要信息的加密


上述我们提到了一些常用的加密方法,这里介绍下我在设计软件和系统的时候是如何对用户的重要信息作加密处理的。


3.1 使用签名字段防止伪造信息


首先,我的应用在做用户鉴权的时候是通过服务器下发的字段来验证的。为了防止服务器返回的信息被篡改以及在本地被用户篡改,我为返回的鉴权信息增加了签名字段。逻辑是这样的,



  • 服务器查询用户信息之后根据预定义的规则拼接一个字符串,然后使用 SHA256 算法对拼接后的字符串做不可逆向的加密

  • 从服务器拿到用户信息之后会直接丢到 SharedPreference 中(最好加密之后再存储)

  • 当需要做用户鉴权的时候,首先根据之前预定义的规则,对签名字段做校验以判断鉴权信息是否给篡改

  • 如果鉴权信息被篡改,则默认为普通用户权限


除了上述方法之外,为服务器配置 SSL 证书也是比不可少的。现在很多云平台都会提供一年免费的 Trust Asia 的证书(到期可再续费),免费使用即可。


3.2 对写入到本地的键值对做处理


为了防止应用的逻辑被破解,当某些重要的信息(比如上面的鉴权信息)写入到本地的时候,除了做上述处理,我对存储到 SharedPreference 中的键也做了一层处理。主要是使用设备 ID 和键名称拼接,做 SHA256 加密之后作为键值对的键。这里的设备 ID 就是 ANDROID_ID. 虽然 ANDROID_ID 用作设备 ID 并不可靠,但是在这个场景中它可以保证大部分用户存储到本地的键值对中的键是不同的,也就增加了破解者针对某个键值对进行破解的难度。


3.3 重要信息不要直接使用字符串


在代码中直接使用字符串很容易被别人搜索到,一般对于重要的字符串信息,我们可以将其先转换为整数数组。然后再在代码中通过数组得到最终的字符串。比如下面的代码用来将字符串转换为 short 类型的数组,


static short[] getShortsFromBytes(String from) {
byte[] bytesFrom = from.getBytes();
int size = bytes.length%2==0 ? bytes.length/2 : bytes.length/2+1;
short[] shorts = new short[size];
int i = 0;
short s = 0;
for (byte b : bytes) {
if (i % 2 == 0) {
s = (short) (b << 8);
} else {
s = (short) (s | b);
}
shorts[i/2] = s;
i++;
}
return shorts;
}

3.4 Jetpack 中的数据安全


除了上面的一些方法之外,Android 的 Jetpack 对数据安全开发了 Security 库,适用于运行 Android 6.0 和更高版本的设备。Security 库针对的是 Android 应用中读写文件的安全性。详情可以阅读官方文档相关的内容:



更安全地处理数据:developer.android.com/topic/secur…



4、增强混淆字典


混淆之后可以让别人反编译我们的代码之后阅读起来更加困难。这在一定程度上可以增强应用的安全性。默认的混淆字典是 abc 等英文字母组成,还是具有一定的可读性的。我们可以通过配置混淆字典进一步增加阅读的难度:使用特殊符号、0oO 这种相近的字符甚至 java 的关键字来增加阅读的难度。配置的方式是,


# 方法名等混淆指定配置
-obfuscationdictionary dict.txt
# 类名混淆指定配置
-classobfuscationdictionary dict.txt
# 包名混淆指定配置
-packageobfuscationdictionary dict.txt

一般来说,当我们自定义混淆字典的时候需要从下面两个方面呢考虑,



  1. 混淆字典增加反编译识别难度使代码可读性变差

  2. 减小方法和字段名长度从而减小包体积


对于 o0O 这种虽然可读性变差了,但是代码长度相比于默认混淆字典要长一些,这会增加我们应用的包体积。我在选择混淆字典的时候使用的是比较难以记忆的字符。我把混淆字典放到了 Github 上面,需要的可以自取,



混淆字典:github.com/Shouheng88/…



下面是混淆之后的效果,


QQ截图20220216230706.png


这既可以保证包体积不会增大,又增加了阅读的难度。不过当我们反混淆的时候可能会遇到反混淆乱码的问题,比如 SDK 默认的反混淆工具就有这个问题(工具本身的问题)。


5、so 安全性


对 so 的破解,我现在也没有特别好的方法。之前我已经把一些需要高级权限的逻辑搬到了 native 层,但是最终一样被破解。如果是专业的加固,会对 so 同时做加固。我个人目前对 so 也不是特别熟,之前被破解也是因为 so 的内容被修改。后面会对 so 相关的内容做进一步学习和补充。上面提到的 so 的签名校验可以作为安全性检查之一,下面还有一些开发过程中的其他建议可以做参考。


5.1 不要使用布尔类型作为重要 native 方法的返回类型


使用布尔类型作为 native 方法的返回值的一个不好的地方是,别人破解起来会非常容易。因为对于布尔类型,它只有 true 和 false 两种情况。所以,破解者可以很容易地通过将类地方法修改为直接返回 true 或者 false 来绕开校验的逻辑。相对来收更好的方式是返回一个整数或者字符串。


5.2 校验方法的 native 特性


如果一个方法是 native 方法,我们可以通过判断方法的属性信息来判断这个方法是否被修改。上面提到了有些 native 方法如果直接返回布尔类型,可能直接会被篡改为直接返回 true/false 的形式。此时,破解者就把 native 方法修改为普通的方法。所以,我们可以通过判断方法的 native 特性,来判断这个方法是否被别人做了手脚。下面是一个示例方法,


val method = cls.getMethod("method", Int::class.java)
Modifier.isNative(method.modifiers)

6、不要把校验逻辑封装到一个方法里


把一套逻辑封装成一个方法对于常规业务的开发是一个好的习惯。但是把权限校验的逻辑封装到一个方法中就不一定了。因为别人只要把注意力方法在你的这一个方法上面就足够了。这样,只要破解了这一个方法就可以破解你的应用中所有的安全校验逻辑。


但是如果把同一个权限校验的逻辑在所有需要做权限校验的地方都拷贝一份,后续代码维护起来也会非常困难。那么有没有比较折衷的手段,既可以实现逻辑集中维护,又可以把权限校验的逻辑分散到各个需要做权限校验的地方呢?答案是有,只不过要求应用中使用的是 kotlin 语言。


使用 inline 实现权限校验集中管理和分散调用:inline 是 kotlin 的一个关键字,效果类似于 C 语言中的内联。编译的时候会将 inline 方法中的逻辑内联到调用的地方。我们只需要将我们的权限校验的逻辑写到 inline 方法中,然后在需要鉴权的地方调用这个 inline 方法,就可以实现权限校验集中管理和分散调用。这样如果需要破解我们的校验逻辑,需要到每个地方依次进行破解。


此外,


1、权限校验的逻辑最好和业务代码交织在一起而不是分开写。原因如上,分开写别人只要破解这一个方法就够了。
2、C/C++ 层也可以尝试使用 inline 方法


7、使用服务器做安全校验


上面也说了最好的安全措施还是把重要的逻辑放到后端。不过,对于我开发的应用,因为它本身基本是离线使用的,所以,无法在操作过程中使用服务器做鉴权。对此,我使用了两个方案来让服务器参与到防破解中。


其一是,启用版本配置,在应用配置中下发强制升级信息。最初为应用设计服务器的时候我就设计了应用从后端拉取配置信息的接口。这个接口也会同时下发应用的版本信息以及升级的类型。如果是强制升级,那么会弹出一个无法取消的对话框。这样这个版本基本就无法继续使用了。通过这个配置,我们可以通过服务器配置直接禁用被破解的应用版本。


其二,在执行需要高级权限的操作的时候上报服务器。服务器通过后端存储的用户信息判断该用户是否具备该权限。如果不具备权限,那么增加一条违规记录,并记录违规用户的用户信息。后台通过可以配置的形式对单一用户进行禁用。至于这里为什么不直接对用户进行禁用的问题。正如《七武士》中的一个桥段一样,好的防守总是会留一个入口。直接禁用很容易被破解者发现并做相应处理。


另外,最好不要直接抛出异常,弹出的 toast 不要使用明文字符串。因为,上述两种方式都很容易让别人直接定位到我们校验的逻辑的位置。如果不得不抛异常,建议触发 OOM!


总结


写了那么多东西,我也无奈,破解比反破解要容易得多,以上是我在实践过程中总结的一些基本的技巧。对于 Android 应用安全,我还有很多东西需要学习和了解。毕竟,对于应用层开发来说,安全是另一个专业领域的事情。我也只能“防君子不防小人”。后续我学习了更多的内容,做了更多的攻防战,总结更多经验之后再补充。唉,“本是同根生,相煎何太急”!


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

初探 Kotlin Multiplatform Mobile 跨平台原理

一、背景 本文会尝试通过 KMM 编译产物理解一套 kt 代码是如何在多个平台复用的。 KMM 发流程简介 我以开发一个 KMM 日志库为例,简单介绍开发流程是什么: 在 CommonMain 定义接口,用 expect 关键字修饰,表示此接口在不同平台的...
继续阅读 »



一、背景



本文会尝试通过 KMM 编译产物理解一套 kt 代码是如何在多个平台复用的。


KMM 发流程简介


我以开发一个 KMM 日志库为例,简单介绍开发流程是什么:



  1. 在 CommonMain 定义接口,用 expect 关键字修饰,表示此接口在不同平台的实现不一样。

  2. 在具体平台实现接口,并用 actual 关键字修饰


// ----- commonMain -----

expect fun log(tag: String, msg: String)

// ----- androidMain -----

actual fun log(tag: String, msg: String) {
Log.i(tag, msg)
}

// ----- iosMain -----

actual fun log(tag: String, msg: String) {
NSLog("$tag:: %s", msg)
}


  1. 编译、打包、发布


publish_artifacts.png



  1. 依赖具体平台仓库

    1. 如果宿主为 Android App,则依赖对应的 kmm-infra-android

    2. 如果宿主为 iOS App,需要现将 kmm-infra-iosarm64 打包成 Framework,然后 iOS 依赖 Framework

    3. 如果宿主为 KMM 库,则依赖 kmm-infra




二、Common 和具体平台的联系



了解 KMM 基本的开发流程和发布产物后,我们需要继续深入了解发布产物的结构,再来理解 Common 层代码和具体平台代码是如何建立联系的。



Common 层编译产物


├── kmm-infra
   ├── 1.0.0-SNAPSHOT
      ├── kmm-infra-1.0.0-SNAPSHOT-kotlin-tooling-metadata.json
      ├── kmm-infra-1.0.0-SNAPSHOT-sources.jar
      ├── kmm-infra-1.0.0-SNAPSHOT.jar
      ├── kmm-infra-1.0.0-SNAPSHOT.module
      ├── kmm-infra-1.0.0-SNAPSHOT.pom
      └── maven-metadata-local.xml


  • kotlin-tooling-metadata.json,存放了编译工具的相关信息,比如 gradle 版本、KMM 插件版本以及具体平台编译工具的信息,比如 jvm 平台会有 jdk 版本,native 平台会有 konan 版本信息

  • source.jar,Kotlin 源码

  • .jar,存放 .knm (knm是什么,后文会具体介绍) ,其中描述了 expect 的接口

  • .module,见下文


.module 是什么?


用 json 描述编译产物文件结构的清单文件,以及关联 common 和具体平台产物的信息。里面描述的字段较多,我只放一些关键信息,剩余内容感兴趣的读者可以自己研究


{
"variants": [
{
"name": "",
"attributes": {
"org.gradle.category": "",
"org.gradle.usage": "",
"org.jetbrains.kotlin.platform.type": ""
}
"available-at": {
"url": "",
},
"dependencies": [
{
"group": "org.jetbrains.kotlin",
"module": "kotlin-stdlib-common",
"version": {
"requires": "1.8.0"
}
}
]
}
]
}



  • name,当前产物的名称,比如 common 层为 metadataApiElements,具体平台为 {target}{Api/Metadata}Elements-published




  • available-at,具体平台特有的字段,其中 url 指的是具体平台 .module 的文件路径,作为关联 common 和具体平台的桥梁




  • dependencies,描述有哪些依赖




具体平台的 .module


为方便大家更好的理解,这里还是贴出一份完整的 iOS 平台的 .module 文件


{
"formatVersion": "1.1",
"component": {
"url": "../../kmm-infra/1.0.0-SNAPSHOT/kmm-infra-1.0.0-SNAPSHOT.module",
"group": "com.gpt.jarvis.kmm",
"module": "kmm-infra",
"version": "1.0.0-SNAPSHOT",
"attributes": {
"org.gradle.status": "integration"
}
},
"createdBy": {
"gradle": {
"version": "7.4.2"
}
},
"variants": [
{
"name": "iosArm64ApiElements-published",
"attributes": {
"artifactType": "org.jetbrains.kotlin.klib",
"org.gradle.category": "library",
"org.gradle.usage": "kotlin-api",
"org.jetbrains.kotlin.native.target": "ios_arm64",
"org.jetbrains.kotlin.platform.type": "native"
},
"dependencies": [
{
"group": "org.jetbrains.kotlin",
"module": "kotlin-stdlib-common",
"version": {
"requires": "1.8.0"
}
}
],
"files": [
{
"name": "kmm-infra.klib",
"url": "kmm-infra-iosarm64-1.0.0-SNAPSHOT.klib",
"size": 6396,
"sha512": "2ebdb65f7409b86188648c1c9341115ab714ad5579564ce4ec0ee7fb6e0286351f01d43094bc7810d59ab1c4d4fa7887c21ce53bc087c34d129309396ceb85a5",
"sha256": "056914503154535806165c132df52819aedcc93a7b1e731667a3776f4e92ff79",
"sha1": "c43ed6cb8b5bf3f40935230ce3a54b2f27ec1d6a",
"md5": "d79166eda9f4bf67f5907b368f9e9477"
}
]
},
{
"name": "iosArm64MetadataElements-published",
"attributes": {
"artifactType": "org.jetbrains.kotlin.klib",
"org.gradle.category": "library",
"org.gradle.usage": "kotlin-metadata",
"org.jetbrains.kotlin.native.target": "ios_arm64",
"org.jetbrains.kotlin.platform.type": "native"
},
"dependencies": [
{
"group": "org.jetbrains.kotlin",
"module": "kotlin-stdlib-common",
"version": {
"requires": "1.8.0"
}
}
],
"files": [
{
"name": "kmm-infra-iosarm64-1.0.0-SNAPSHOT-metadata.jar",
"url": "kmm-infra-iosarm64-1.0.0-SNAPSHOT-metadata.jar",
"size": 5176,
"sha512": "fa828f456c3214d556942105952cb901900a7495f6ce6030e4e65375926a6989cd1e7b456f772e862d3675742ce2678925a0a12a1aa37f4795e660172d31bbff",
"sha256": "c4de0db2b60846e3b0dbbd25893f3bd35973ae790696e8d39bd3d97d443a7d4c",
"sha1": "e59036a081663f5c5c9f96c72c9c87788233c8bc",
"md5": "9293e982f84b623a5f0daf67c6e7bb33"
}
]
}
]
}


iOS 平台编译产物


我们其实可以通过上面 iOS 平台 .module 文件看到一些描述,有 metadata.jar.klib


├── kmm-infra-iosarm64
│   ├── 1.0.0-SNAPSHOT
│   │   ├── kmm-infra-iosarm64-1.0.0-SNAPSHOT-metadata.jar
│   │   ├── kmm-infra-iosarm64-1.0.0-SNAPSHOT-sources.jar
│   │   ├── kmm-infra-iosarm64-1.0.0-SNAPSHOT.klib
│   │   ├── kmm-infra-iosarm64-1.0.0-SNAPSHOT.module
│   │   ├── kmm-infra-iosarm64-1.0.0-SNAPSHOT.pom
│   │   └── maven-metadata-local.xml
│   └── maven-metadata-local.xml
└── kmm-infra-iosx64
├── 1.0.0-SNAPSHOT
│   ├── kmm-infra-iosx64-1.0.0-SNAPSHOT-metadata.jar
│   ├── kmm-infra-iosx64-1.0.0-SNAPSHOT-sources.jar
│   ├── kmm-infra-iosx64-1.0.0-SNAPSHOT.klib
│   ├── kmm-infra-iosx64-1.0.0-SNAPSHOT.module
│   ├── kmm-infra-iosx64-1.0.0-SNAPSHOT.pom
│   └── maven-metadata-local.xml
└── maven-metadata-local.xml


  • metadata.jar,主要存放了 .knm

  • .klib,也存放了 metadata.jar 中相同的内容,除此以外还有 ir,方便编译器后端继续编程机器码

  • 如果不了解 ir 是什么,可以参考我之前写的 Kotlin Compiler】IR 介绍


.knm 和 .klib 是什么?后文会具体介绍


三、.klib 和 .knm 文件




  1. klib 的文件结构是怎样的?

  2. .knm 是什么文件?为什么只能用 IDEA 浏览?



klib 文件结构


klib 指 Kotlin Library


klib
├── ir
│   ├── bodies.knb
│   ├── debugInfo.knd
│   ├── files.knf
│   ├── irDeclarations.knd
│   ├── signatures.knt
│   ├── strings.knt
│   └── types.knt
├── linkdata
│   ├── module
│   ├── package_com
│   │   └── 0_com.knm
│   ├── package_com.jarvis
│   │   └── 0_jarvis.knm
│   ├── package_com.jarvis.kmm
│   │   └── 0_kmm.knm
│   ├── package_com.jarvis.kmm.infra
│   │   └── 0_infra.knm
│   └── root_package
│   └── 0_.knm
├── manifest
├── resources
└── targets
└── ios_arm64
├── included
├── kotlin
└── native


.knm 的生成过程


knm 指 kotlin native metadata


kt2knm.svg



  1. .kt 经过编译器 frontend, 生成 kotlinIr

  2. 经过 protobuf 序列化后,生成 .knm 文件,这也解释了 vim 打开是乱码的原因

  3. .knm 通过反序列化可以得到 KotlinIr

  4. KotlinIr 通过反编译可以得到代码的细节,这正是在 IDEA 里能看到 .knm 是什么的原因


使用安装 Kotlin Plugin 的 IDEA 查看 knm 文件


idea_knm.png


使用 vim 查看 knm 文件


vim_knm.png


四、iOS 和 KMM 库的关系



iOS 中的依赖库是一组 .h 和二进制文件,所以 KMM 库最终一定要转成 .h 和二进制文件。
KMM 中,iOS 平台的编译产物是 klib


问题:



  1. Kotlin 是怎样依赖并调用 iOS Objective-C 库的?

  2. iOS 是如何使用 KMM 库的?


为了解释上面的两个问题,需要了解 KMM 和 OC 互操作的机制(互相调用),以及 klib 是如何打包



OC 互操作流程


interop_ios.png



  1. Copy iOS 工程中需要用到的 .h 文件(此处也可以直接在 KMM 工程中通过 Cocoapods 插件直接依赖 pod 库)

  2. .h 文件通过 cinterop 工具生成 klib,由于 kotlin 不认识 oc 的 .h,所以需要通过 klib 将 .h 转成 kotlin 认识的形式后才能调用

  3. 将开发完成的 kotlin 代码编译打包,通过 fatFramework 工具输出最终 .h 和二进制文件

  4. iOS 依赖 Umbrella.h 和二进制文件,此流程已经走到 iOS 原生端,和 KMM 无关了


FatFrameWork 流程


assemble_ios.png



  1. KMM 工程打包 klib 并上传

  2. KMM_Umbrella (依赖了很多 KMM 库的全家桶工程) 工程拉取 klib 依赖

  3. 执行 iosFatFramework 任务,输出最终 framework.h 和二进制文件

    • klib 中的 ir 通过 kotlin 编译器后端,编译成对应平台的二进制文件

    • 链接

    • 合并不同架构的二进制文件,比如 iosArm64 iosX64,具体可参考【mac】lipo命令详解

    • 合并头文件

    • 创建 .modulemap 文件,具体细节可以参考 理解 iOS 中的 Modules

    • 生成 info.plist ,此文件是对 framework 的描述清单文件

    • 合成 DSYM( Debugger Symbols) 文件




最终输出结构如下


fat-framework
└── debug
└── KMMUmbrellaFramework.framework
├── Headers
│   └── KMMUmbrellaFramework.h
├── Info.plist
├── KMMUmbrellaFramework
└── Modules
└── module.modulemap


总结


conclusion.png



  1. 通过在 Common 层定义 expect 接口,生成 .knm,以及关联具体平台信息的 .module

  2. 在具体平台通过 actual 实现接口,生成 .klib/.aar/.jar

  3. Android 平台比较特殊,因为 Kotlin 以前只能编译成 JVM 字节码,不存在 ir 概念,K2 Compiler 出现后,统一抽象了编译流程,使得 JVM 也有了自己的编译器后端,也可以通过 IR 编译为 JVM 字节码

  4. iOS 平台通过 .klib 存放 ir,然后经过编译器后端打成 iOS 可以使用的 .framework

  5. 将对应产物接入到对应平台工程


通过对 KMM 编译产物的探索,能让我们更好地理解 KMM 是如何实现跨平台的。


参考



作者:ZzT
来源:juejin.cn/post/7214412608400212028
收起阅读 »

使用 Kotlin 委托,拆分比较复杂的 ViewModel

需求背景 在实际的开发场景中,一个页面的数据,可能是由多个业务的数据来组成的。 使用 MVVM 架构进行实现,在 ViewModel 中存放和处理多个业务的数据,通知 View 层刷新 UI。 传统实现 比如上面的例子,页面由3 个模块数据构成。 我们可...
继续阅读 »

需求背景




  1. 在实际的开发场景中,一个页面的数据,可能是由多个业务的数据来组成的。

  2. 使用 MVVM 架构进行实现,在 ViewModel 中存放和处理多个业务的数据,通知 View 层刷新 UI。


传统实现


比如上面的例子,页面由3 个模块数据构成。


我们可以创建一个 ViewModel ,以及 3个 LiveData 来驱动刷新对应的 UI 。


    class HomeViewModel() : ViewModel() {

private val _newsViewState = MutableLiveData<String>()
val newsViewState: LiveData<String>
get() = _newsViewState

private val _weatherState = MutableLiveData<String>()
val weatherState: LiveData<String>
get() = _weatherState

private val _imageOfTheDayState = MutableLiveData<String>()
val imageOfTheDayState: LiveData<String>
get() = _imageOfTheDayState

fun getNews(){}
fun getWeather(){}
fun getImage(){}

}

这样的实现会有个缺点,就是随着业务的迭代,页面的逻辑变得复杂,这里的 ViewModel 类代码会变复杂,变得臃肿。


这个时候,就可能需要考虑进行拆分 ViewModel


一种实现方法,就是直接简单地拆分为3个 ViewModel,每个 ViewModel 处理对应的业务。但是这样会带来其他问题,就是在 View 层使用的时候,要判断当前是什么业务,然后再去获取对应的ViewModel,使用起来会比较麻烦。


优化实现


目标:



  • 将 ViewModel 拆分成多个子 ViewModel,每个子 ViewModel 只关注处理自身的业务逻辑

  • 尽量考虑代码的可维护性、可扩展性


Kotlin 委托



  • 委托(Delegate)是 Kotlin 的一种语言特性,用于更加优雅地实现代理模式

  • 本质上就是使用了 by 语法后,编译器会帮忙生成相关代码。

  • 类委托: 一个类的方法不在该类中定义,而是直接委托给另一个对象来处理。

  • 基础类和被委托类都实现同一个接口,编译时生成的字节码中,继承自 Base 接口的方法都会委托给BaseImpl 处理。


// 基础接口
interface Base {
fun print()
}

// 基础对象
class BaseImpl(val x: Int) : Base {
override fun print() { print(x) }
}

// 被委托类
class Derived(b: Base) : Base by b

fun main(args: Array<String>) {
val b = BaseImpl(10)
Derived(b).print() // 最终调用了 Base#print()
}

具体实现


定义子 ViewModel 的接口,以及对应的实现类


    interface NewsViewModel {
companion object {
fun create(): NewsViewModel = NewsViewModelImpl()
}

val newsViewState: LiveData<String>

fun getNews()
}

interface WeatherViewModel {
companion object {
fun create(): WeatherViewModel = WeatherViewModelImpl()
}

val weatherState: LiveData<String>

fun getWeather()
}

interface ImageOfTheDayStateViewModel {
companion object {
fun create(): ImageOfTheDayStateViewModel = ImageOfTheDayStateImpl()
}

val imageState: LiveData<String>

fun getImage()
}

class NewsViewModelImpl : NewsViewModel, ViewModel() {
override val newsViewState = MutableLiveData<String>()

override fun getNews() {
newsViewState.postValue("测试")
}
}

class WeatherViewModelImpl : WeatherViewModel, ViewModel() {
override val weatherState = MutableLiveData<String>()

override fun getWeather() {
weatherState.postValue("测试")
}
}

class ImageOfTheDayStateImpl : ImageOfTheDayStateViewModel, ViewModel() {
override val imageState = MutableLiveData<String>()

override fun getImage() {
imageState.postValue("测试")
}
}


  • 把一个大模块,划分成若干个小的业务模块,由对应的 ViewModel 来进行处理,彼此之间尽量保持独立。

  • 定义接口类,提供需要对外暴漏的字段和方法

  • 定义接口实现类,内部负责实现 ViewModel 的业务细节,修改对应字段值,实现相应方法。

  • 这种实现方式,就不需要像上面的例子一样,每次都要多声明一个带划线的私有变量。并且可以对外隐藏更多 ViewModel 的实现细节,封装性更好


组合 ViewModel


image.png


    interface HomeViewModel : NewsViewModel, WeatherViewModel, ImageOfTheDayStateViewModel {
companion object {
fun create(activity: FragmentActivity): HomeViewModel {
return ViewModelProviders.of(activity, object : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
return if (modelClass == HomeViewModelImpl::class.java) {
@Suppress("UNCHECKED_CAST")

val newsViewModel = NewsViewModel.create()
val weatherViewModel = WeatherViewModel.create()
val imageOfTheDayStateImpl = ImageOfTheDayStateViewModel.create()

HomeViewModelImpl(
newsViewModel,
weatherViewModel,
imageOfTheDayStateImpl
) as T
} else {
modelClass.newInstance()
}

}
}).get(HomeViewModelImpl::class.java)
}
}
}

class HomeViewModelImpl(
private val newsViewModel: NewsViewModel,
private val weatherViewModel: WeatherViewModel,
private val imageOfTheDayState: ImageOfTheDayStateViewModel
) : ViewModel(),
HomeViewModel,
NewsViewModel by newsViewModel,
WeatherViewModel by weatherViewModel,
ImageOfTheDayStateViewModel by imageOfTheDayState {

val subViewModels = listOf(newsViewModel, weatherViewModel, imageOfTheDayState)

override fun onCleared() {
subViewModels.filterIsInstance(BaseViewModel::class.java)
.forEach { it.onCleared() }
super.onCleared()
}
}


  • 定义接口类 HomeViewModel,继承了多个子 ViewModel 的接口

  • 定义实现类 HomeViewModelImpl,组合多个子 ViewModel,并通过 Kotlin 类委托的形式,把对应的接口交给相应的实现类来处理

  • 通过这种方式,可以把对应模块的业务逻辑,拆分到对应的子 ViewModel 中进行处理

  • 如果后续需要新增一个新业务数据,只需新增相应的子模块对应的 ViewModel,而无需修改其他子模块对应的 ViewModel。

  • 自定义 ViewModelFactory,提供 create 的静态方法,用于外部获取和创建 HomeViewModel。


使用方式



  • 对于 View 层来说,只需要获取 HomeViewModel 就行了。

  • 调用暴露的方法,最后会委托给对应子 ViewModel 实现类进行处理。


        val viewModel = HomeViewModel.create(this)

viewModel.getNews()
viewModel.getWeather()
viewModel.getImage()

viewModel.newsViewState.observe(this) {

}
viewModel.weatherState.observe(this) {

}
viewModel.imageState.observe(this) {

}

扩展



  • 上面的例子,HomeViewModel 下面,可以由若干个子 ViewMdeol 构成。

  • 随着业务拓展,NewsViewModel、WeatherViewModel、ImageOfTheDayStateViewModel,也可能是分别由若干个子 ViewModel 构成。那也可以参照上面的方式,进行实现,最后会形成一棵”ViewModel 树“,各个节点的 ViewModel 负责处理对应的业务逻辑。


image.png


总结


这里只是提供一种拆分 ViewModel 的思路,在项目中进行应用的话,可以根据需要进行改造。


参考文章


slicing-your-viewmodel-with-delegates


Kotlin | 委托机制 & 原理 & 应用 - 掘金


作者:入魔的冬瓜
来源:juejin.cn/post/7213257917254860861
收起阅读 »

写给Android工程师的协程指南

这是一份写给 Android工程师 的协程指南,希望在平静的2023,给大家带来一些本质或者别样的理解。 引言 在 Android 的开发世界中,关于 异步任务 的处理一直不是件简单事。 面对复杂的业务逻辑,比如多次的异步操作,我们常常会经历回调嵌套的情况,对...
继续阅读 »



这是一份写给 Android工程师 的协程指南,希望在平静的2023,给大家带来一些本质或者别样的理解。


引言


Android 的开发世界中,关于 异步任务 的处理一直不是件简单事。


面对复杂的业务逻辑,比如多次的异步操作,我们常常会经历回调嵌套的情况,对于开发者而言,无疑苦不堪言。😟


Kotlin协程 出现之后,上述问题可以说真正意义上得到了好的解法。其良好的可读性及api设计,使得无论是新手还是老手,都能快速享受到协程带来的舒适体验。


但越是使用顺手的组件,背后也往往隐藏着更复杂的设计。


故此,在本篇,我们将由浅入深,系统且全面的聊聊 Kotlin协程 的思想及相关问题,从而帮助大家更好的理解。



本篇没有难度定位、更多的是作为一个 Kotlin 使用者的基本技术铺垫。



ps: 在B站也有视频版本,结合观看,体验更佳,Android Kotlin 协程分享


写在开始


大概在三年前,那时的我实习期间刚学会 Kotlin ,意气风发,协程Api 调用的也是炉火纯青,对外自称api调用渣渣工程师。


那时候的客户端还没这么饱和,也不像现在这样稳定。


那个时期,曾探寻过几次 Kotlin协程 的设计思想,比如看霍老师、扔物线视频、相关博客等。


但看完后处于一种,懂了,又似乎不是很懂的状态,就一直迷迷糊糊着。


记得后来去面试,有人问我,协程到底是什么?



我回答: 一个在 Kotlin 上以 同步方式写异步代码 的线程框架,底层是使用了 线程池+状态机 的概念,诸如此类,巴拉巴拉。


面试官: 那它到底和线程池有啥区别,我为啥不直接用线程池呢?


我心想:上面不是已经回答了吗,同步方式,爽啊!… 但奈何遭到了一顿白眼。


事后回想,他可能想问的是更深层,多角度的解释,但显然我只停留在使用层次,以及借着别人的几句碎片经验,冠冕堂皇、看似Easy。



直到现在为止,我仍然没有认真去看过协程的底层实现,真是何其的尴尬,再次想起,仍觉不安。


随着近几年对协程的使用以及一些cv经验,相关的api理解也逐渐像那么回事,也有些对Kt代码背后实现进行同步转换的经验。


故此,这篇文章也是对自己三年来的一份答卷。


当然网上对于协程的解析也有很多,无论是从原理或是顶层抽象概括,其中更是不乏优秀的文章与作者。


本文会尽量在这两者中间找到一个合适的折中点,并增加一些特别思考,即不缺深度,又能使初学者对于协程能够有较清晰明了的认知。


好了,让我们开始吧! 🏃🏻


基础铺垫


在开始之前,我们先对基础做一些铺垫,从而便于更好的理解 Kotlin协程


线程


我们知道,线程是 cpu调度 的最小单元,每个cpu所能启动的线程数量往往也是有限的。


在常见的业务开发中,尽管大多数时候我们都是基于单线程,或者最多开启子线程去请求网络,与多线程的 [多] 似乎关系不大。但其实这也属于多线程的一种,不过是少任务的情况。但就算这样,线程在执行时的切换,也是存在这一些小成本,比如从主线程切到子线程去执行异步计算,完成后再从子线程切到主线程去执行UI操作,而这个切换的过程在学术上又被称之为 [上下文切换]


协程


在维基百科中,是这样解释的:



协程是计算机程序的一类组件,推广了协作式多任务子例程,允许执行被挂起与被恢复。相对子例程而言,协程更为一般和灵活,但在实践中使用没有子例程那样广泛。协程更适合于用来实现彼此熟悉的程序组件,如协作式多任务异常处理事件循环迭代器无限列表管道



上面这些词似乎拆开都懂,但连在一起就不懂了。


说的通俗一点就是,协程指的是一种特殊的函数,它可以在执行到某个位置时 暂停 ,并 保存 当前的执行状态,然后 让出 CPU控制权,使得其他代码可以继续执行。当CPU再次调用这个函数时,它会从上次暂停的位置继续执行,而不是从头开始执行。从而使得程序在执行 长时间任务 时更加高效和灵活。


协作式与抢占式


这两个概念通常用于描述操作系统中多任务的处理方式。



  • 协作式指的是 多个任务共享CPU时间 ,并且在没有主动释放CPU的情况下,任务不会被强制中断。相应的,在协作式多任务处理中,任务需要自己决定何时放弃CPU,否则将影响其他任务的执行。

  • 抢占式指的是操作系统可以在没有任务主动放弃CPU的情况下,强制中断 当前任务,以便其他任务可以获得执行。这也就意味着,抢占式多任务通常是需要硬件支持,以便操作系统可以在必要时强制中断任务。


如果将上述概念带入到协程与线程中,当一个线程执行时,它会一直运行,直到被操作系统强制中断或者自己放弃CPU;而协程的协作式则需要协程之间互相配合协作,以便让其他协程也可以获得执行机会,通常情况下,这种协作关系是由应用层(开发者)自行控制。也就意味着相比线程,协程的切换与创建开销比较小,因为其并不需要多次的上下文切换,或者说,线程是真实的操作系统内核线程的隐射,而协程只是在应用层调度,故协程的切换与创建开销比较小。


协程与线程的区别



  • 线程是操作系统调度的基本单位,一个进程可以拥有多个线程,每个线程独立运行,但它们共享进程的资源。线程切换的开销较大,且线程间的通信需要通过共享内存或消息传递等方式实现,容易出现资源竞争、死锁等问题。

  • 协程是用户空间下的轻量级线程,也称为“微线程”。它不依赖操作系统的调度,而是由用户自己控制协程的执行。协程之间的切换只需要保存和恢复少量的状态,开销较小。协程通信和数据共享的方式比线程更加灵活,通常使用消息传递或共享状态的方式实现。

  • 简单来说,协程是一种更加高效、灵活的并发处理方式,但需要用户 自己控制执行流程和协程间的通信 ,而线程则由操作系统负责调度,具有更高的并发度和更强的隔离性,但开销较大。在不同的场景下,可以根据需要选择使用不同的并发处理方式。


那Kotlin协程呢?


在上面,我们说了 线程协程 ,但这个协程指的是 广义协程 这个概念,而不是 Kotlin协程 ,那如果回到 Kotlin协程 呢?


相信不少同学在学习 Kotlin协程 的时候,常常会看到很多人(包括官网)会将线程与协程拉在一起比较,或者经常也能看见一些实验,比如同时启动10w个线程与10w个协程,然后从结果上看两者差距巨大,线程看起来性能巨差,协程又无比的优秀。



此时就会有同学喊,你上个线程池与协程试试啊!用线程试谈什么公平(很有道理)😂。


ps: 如果你真的使用了线程池并且使用了schedule代替Thread.sleep(),会发现,线程比协程显然要更快。当然,这也并不难理解。



那协程到底是什么呢?它和线程池的区别呢?或者说协程的职责呢?


这里我们用 Android官方 的一句话来概括:



协程是一种并发设计模式,您可以在 Android 平台上使用它来 简化 异步执行的代码。协程是我们在 Android 上进行异步编程的推荐解决方案。



简单明了,协程就是用于 Android 上进行 异步编程 的推荐解决方案,或者说其就是一个 异步框架 ,仅此而已,别无其他🙅🏻‍♂️。


那有些同学可能要问了,异步框架多了,为什么要使用协程呢?


因为协程的设计更加先进,比如我们可以同步代码写出类似异步回调的逻辑。这一点,也是Kotlin协程在Android平台最大的特点,即 简化异步代码


相应的,Kotlin协程 具有以下特点:



  • 轻量:您可以在单个线程上运行多个协程,因为协程支持挂起,不会使正在运行协程的线程阻塞。挂起比阻塞节省内存,且支持多个并行操作。

  • 内存泄漏更少:使用结构化并发机制在一个作用域内执行多项操作。

  • 内置取消支持取消操作会自动在运行中的整个协程层次结构内传播。

  • Jetpack 集成:许多 Jetpack 库都包含提供全面协程支持的扩展。某些库还提供自己的协程作用域,可供您用于结构化并发。



上述特点来自Android官网-Android上的Kotlin协程



协程进展



:如非特别标注,本文接下来的协程皆指Kotlin协程。



本小节,我们将看一下Kotlin协程的发展史,从而为大家解释kotlin协程的背景。


image-20230220152147904


Kotlin1.6 之前,协程的版本通常与 kotlin 版本作为对应,但是 1.6 之后,协程的大版本就没有怎么更新了(目前最新是1.7.0-beta),反而是 Kotlin 版本目前最新已经 1.8.10


基本示例


在开始之前,我们还是用一个最基本的示例看一下协程与往常回调写法的区别,在哪里。



比如,我们现在有这样一个场景,需要请求网络,获取数据,然后显示到UI中。



回调写法


fun main() {
// 示例,一般为线程池
thread(name="t1") {
val message = getMessage()
// 或者其他切线程方式,底层都是这样,handler复用
val handler = Handler(Looper.getMainLooper())
handler.post {
showMessage(message)
}
}
}

fun getMessage(): String {
Thread.sleep(1000)
return "123"
}

如上所示,创建了一个线程t1,并在其中调用了 getMessage() 方法,该方法我们使用 Thread.sleep() 模拟网络请求,然后返回一个String数据, 最后使用 handler 将当前要执行的任务发送到主线程去执行从而实现线程切换。


协程写法


fun main() {
val coroutineScope = CoroutineScope(Dispatchers.Main)
coroutineScope.launch {
val message = getMessages()
showMessage(message)
}
}

suspend fun getMessages(): String {
return withContext(Dispatchers.IO) {
delay(1000)
"123"
}
}

如上所示,创建了一个协程作用域,并启动了一个新的子协程c1,该协程内部调用了 getMessages() 方法,用于获得一个 String类型 的消息。然后调用 showMessage() 方法,显示刚才获取的消息。在相应的 getMessages() 方法上,我们增加了 suspend 标记,并在内部使用withContext(Dispatcher.IO) 将当前上下文环境切换到IO协程中,用于延迟等待(假设网络请求),最终返回该结果。



在不谈性能的背景下,上述这两种方式,无疑是协程的代码更加直观简洁,毕竟同步的写法去写异步,这没什么可比性,当然我们也允许部分的性能损失。



挂起与恢复


站在初学者的视角,当聊到挂起与恢复,开发者到底想了解什么?


什么是挂起恢复?挂起是挂起什么?挂起线程吗?还是挂起一个函数?恢复又是具体指什么?又是如何做到恢复的呢?


基础概念


在标准的解释中,如下所示:



在协程中,当我们的代码执行到某个位置时,可以使用特定的关键字来暂停函数的执行,同时保存函数的执行状态,这个过程叫做 [挂起],挂起操作会将控制器交还给调用方,调用方可以继续执行其他任务。


当再次调用被挂起的函数时,它会从上一次暂停的位置开始继续执行,这个过程称为 [恢复]。在恢复操作之后,被挂起的函数会继续执行之前保存的状态,从而可以在不重新计算的情况下继续执行之前的逻辑。



如果切换到 Kotlin 的世界中中,这个特定的关键字就是 suspend 。但并不是说加了这个关键字就一定会挂起,suspend 只是作为一个标记,用于告诉编译器,该函数可能会挂起并暂停执行(即该函数可能会执行耗时操作,并且好事期间会暂停执行并等待耗时操作完成,同时需要将控制权返回给调用方),但至于要不要挂起及保存函数当前的执行状态,最终还是要取决于函数内部是否满足条件。


如下所示,我们用一个示例Gif(出处已找不到)来表示:


img


那用程序员的语言该怎么理解呢?我们用一段代码举例:


coroutineScope.launch(Dispatchers.Main) {
val message = getNetMessages()
showMessage(message)
}

suspend fun getNetMessages(): String {
return withContext(Dispatchers.IO) {
delay(1000)
"123"
}
}


  • 当我们的程序运行到 coroutineScope.launch(Dispatchers.Main) 时,此时会创建一个新协程,并将这个协程放入默认的协程调度器(即Main调度器),同时当前新创建的协程也会成为 coroutineScope 的子协程。

  • 当执行到 getNetMssage() 方法时,此时遇到了 withContext(Dispatchers.IO) ,此时会切换当前协程的上下文到IO调度器(可以理解将当前协程放入IO线程池中执行),此时协程将被挂起,然后我们当前 withContext() 被挂起的状态会通知给外部的调用者,并将当前的状态保存到协程的上下文中,直到IO操作完成。

    • 当遇到 delay(1000) 时,此时再次挂起(这里不是切换线程,而是使用了协程的调度算法),并保存当前的函数状态;

    • delay(1000) 结束后,再次恢复到先前所在的IO调度器,并开始返回 “123”;

    • 当上述逻辑执行完成后,此时 withContext() 会将协程的调度器再次切换到之前开始时的调度器(这里是Main),并恢复之前的函数状态;



  • 此时我们获得了 getNetMssage() 的返回值,继续执行 showMessage()


挂起函数


在上面我们聊到了 Kotlin 的挂起函数,与相关的 挂起恢复 。那 suspend 标志到底做了什么呢?


本小节,我们将就这个问题,从字节码层,展开分析。


我们先看一下 suspend 方法是如何被编译器识别的?如下代码所示:


image-20230304225541849


不难发现,我们带有suspend的函数最终会被转变为一个带 Continutaion 参数,并且返回值为Object(可null)的函数。



上述示例中,原函数没带返回值,你也可以使用带返回值的原函数,结果也是与上述一致。



1. Continucation 是什么?为什么要携带它呢?


在前文中,我们已经提及,suspend 只是一个标志,它的目的是告诉编译器可能会挂起,类似与我们开发中常使用的注解一样,但又比注解更加强大,suspend 标志是编译器级别,而注解是应用级别。从原理上来看,那最终的代码运行时应该怎么记住这些状态呢,或者怎么知道这个方法和其他方法不一样?故此,kotlin编译器 会对带有 suspend 的方法在最终的字节码生成上进行额外更改,这个过程又被称作 CPS转换 (下面会再解释),如下所示:


suspend fun xx()
->
Object xx(Continucation c)

在字节码中,我们原有的函数方法参数中会再增加一个 Continucation ,而 Continuation 就相当于一个参数传递的纽带(或者你也可以理解其就是一个 CallBack ),负责保存函数的执行状态、执行 挂起与恢复 操作,具体如下:


public interface Continuation<in T> {
public val context: CoroutineContext

public fun resumeWith(result: Result<T>)
}

context 参数类似于 Android 开发中的 context 一样,其代表了当前的配置,对使用协程的同学而言,context就相当于当前协程所运行的环境与参数 ,而 resumeWith() 则是负责对我们函数方法进行挂起与恢复(这块我们先这样理解即可)。




1 什么是CPS转换?



CPS(Continuation Passing Style)转换是一种将函数转换为回调函数的编程技术。在 CPS 转换中,一个函数不会像通常那样直接返回结果,而是接受一个额外的回调函数作为参数,用于接收函数的结果。这个回调函数本身也可能接受一个回调函数,形成一个连续的回调链。这种方式可以避免阻塞线程,提高代码的并发性能。



比如,协程通过 CPS 转换来实现异步编程。具体来说,协程在被挂起时,会将当前的执行状态保存到一个回调函数(即挂起函数的 Continuation)中,然后将控制权交回给调用方。当协程准备好恢复时,它会从回调函数中取回执行状态,继续执行。这种方式可以使得异步代码的逻辑更加清晰和易于维护。




2. 为什么还要增一个 Object 类型返回值呢?


这块的直接解释比较麻烦,但是我们可以先思考一下,代码运行时,该怎么知道该方法真的被挂起呢?难道是增加了suspend就要被挂起吗?


故此,还是需要一个返回值,用于确定,该挂起函数是否真的被挂起。


在IDE中,对于使用了suspend的方法而言,如果内部没有其他挂起函数,那么编译器就会提示我们移除suspend标记,如下所示:


image-20230304225126237




3. 为什么返回值类型是Object?


对于挂起函数而言,在协程,是否真的被挂起,通过函数返回值来确定,但相应的,如果我们有挂起函数需要具备返回类型呢?那如果该函数没有挂起呢?如下示例所示:


image-20230304224957432


对于挂起函数而言,返回值有可能是 COROUTINE_SUSPENDEDUnit.INSTANCE 或者最终返回我们方法需要的返回类型结果,所以采用 Object 作为返回值以适应所有结果。


深入探索


在上面,我们看到了 suspend 在底层的转换细节,那回到挂起函数本质上,它到底是怎么做到 **挂起 ** 与 恢复 的呢?


故此,本小节,我们将就着这个问题,从字节码层次,展开分析,力求流程完整明了,不过相对而言可能有点繁琐。


如下代码所示:


fun main() = runBlocking {
val isSuccess = copyFileTo(File("old.mp4"), File("new.mp4"))
println("---copy:$isSuccess")
}


suspend fun copyFileTo(oldFile: File, newFile: File): Boolean {
val isCopySuccess = withContext(Dispatchers.IO) {
try {
oldFile.copyTo(newFile)
// 示例代码,通常这里需要验证字节流或者MD5
true
} catch (e: Exception) {
false
}
}
return isCopySuccess
}

这是一段用于将文件复制到指定文件的示例代码,具体伪字节码如下:


image-20230306214535952



上述的步骤实在是难读,思路整理起来比较绕圈,不过还是建议开发者多理解几遍。



上述的步骤如下:


当左侧 main() 方法开始执行时,因为示例中使用的 runBlocking(),其需要传递一个函数式接口对象,通常我们会以 lambda表达式 的形式去实例化这个函数对象,然后在其中写入我们的业务代码。


所以根据最终的字节码对比,我们的lambda会被转化为如下的形式:


suspend CoroutineScope.() -> Unit
⚡️ ->
(Function2) (new Function2((Continuation) null){}
// 具体伪代码如下所示,为什么会是这样的,下面会解释
class xxx(Continucation) : Function2<CoroutineScope,Continucation,Any> {
fun invoke(Any,Continucation) : Any {}
}

接着当我们的函数被调用时,会触发 invoke() 方法,即我们的函数体开始执行,开始进入我们的业务代码中。因为 invoke() 需要返回一个Object(因为我们的函数体本身也是suspend),这时候,会先创建一个 Continuation 对象,用于执行协程体逻辑,然后去调用 invokeSuspend() 方法从而获得本次的执行结果。



这里为什么要再去创建一个 Continuation?不是在runBlocking()里已经利用lambda表达式实例化了函数对象了吗?


不知道是否会有同学有这个疑问,所以这里依然需要解释一遍。


我们知道,在 kotlin 中,lambda 是匿名内部类的一种实例化方式(简化),所以这里只是给 runBlocking() 函数传递了所需要的方法参数。但是这个 lambda 内部的 invoke() 依然是挂起函数(因为增加过suspend),所以这里的匿名内部类实际上也是实现了 Continuation(默认的只有Funcation1,2,3等等),为了便于底层调用 invoke() 时传递 Continuation ,否则后续挂起恢复流程就断了🔺。相应的,为了延续 invoke() 里的挂起函数流程,编译器在当前匿名类内部又创建了一个 anonymous constructor(无类型) 的内部类(实际上是继承自SuspendLambda),从而在其 ivokeSuspend() 里执行当前挂起函数的状态机。


所以来说,大家可以理解我们传递的 lambda 相当于一个入口,但是其内部(即invoke)的触发方法,又是一个 挂起函数 ,这也就是为什么 invoke() 里需要创建 Continuation ,以及为什么 invoke() 方法参数里需要有 continuation 的原因,以及为什么字节码中会出现 new Function2((Continuation) null) ,Continuation 为null 的情况🤔,因为它压根没有 continuation 啊(不在挂起函数内部😂)。


这里的解释稍许有些啰嗦,但这对于理解全流程将非常有用,如果不是很理解,建议多读几遍。



invokeSuspend() 方法里,即正式进入了函数的状态机,这里的状态标记使用了一个 int 类型的 label 表示。



  • 默认执行 case 0,因为我们接下来要进入 copyFileTo() 方法,而该方法也是一个挂起函数,所以执行该方法后会获得一个返回状态,用于判断该函数是否真的已经挂起。如果返回值是 COROUTINE_SUSPENDED,则证明该函数已经挂起,然后直接 return 当前函数的挂起状态(相当于告诉父callback,当前我内部已经在忙了,你可以先执行自己的事了,等我执行完再通知你),否则继续执行当前剩余逻辑。

  • copyFileTo() 执行结束后,会再次触发当前 invokeSuspend(),因为我们在 case0 里已经更新了label为1,然后正常执行接下来的流程。


我们再去看一下 copyFileTo() 方法,我们在字节码中可以看到,其默认先创建了当前的 ContinuationImpl() ,并在初始化时将父 Continuation 也保存在其中,接着进入状态机开始执行逻辑,因为我们在该方法里有使用 withContext() 切换到IO调度器,所以这里也需要获取 withContext() 的挂起状态,如果成功挂起,则直接 return 当前状态,类似上述 invokeSuspend() 里的流程。


需要注意的,我们 withContext() 范围内,虽然经历了CPS转换,但因为不存在其他挂起函数,所以并不会再返回是否挂起,而是直到我们的逻辑执行结束 ,从而触发 withContext() 内部去调用 resumeWith(),从而恢复外部 copyFileTo() 的执行,重复此流程,从而恢复 runBlocking() 内部的继续执行,然后拿到我们的最终结果。


总结


关于Kotlin协程的挂起与恢复,从字节码去看,核心的 continuation 似乎有点像 callback 的嵌套,但相比 callback ,协程做的更加完善。比如当触发挂起函数调用时,会进入其内部对应的状态机,从而触发状态流转。并且为了避免了 callback 的 重复创建,而每一个挂起函数内部都会复用当前已创建好的 continuation


比如说,对于挂起函数,编译器会对其进行 CPS转换 ,从而使其从:


supend fun test()
->
fun test:(Continuation):Any?

当我们在挂起函数中调用该函数时,编译器就会将当前的 continuation 也一并传入并获得当前函数的结果。在具体调用时,如果挂起函数内部真的挂起(函数返回值为 COROUTINE_SUSPENDED ),则将调用权交还给调用方,然后当前的状态+1。而当该挂起函数内部执行结束时,因为其持有着外部的 continuation ,所以会调用 continuation.resume() 恢复挂起的协程,即调用了 invokeSuspend() ,从而恢复执行先前的逻辑。


而我们常说的状态机,从根本上,其实就是构造了一个 switch 结构的label流转,每个 case 内部都可能又会再对应着一个类似的结构(如果存在挂起函数)。如果我们称其为分层,那每一层也都持有上层的对象,而当我们最底层的函数执行结束时,即开始触发恢复上层逻辑,此时状态回传,从而将子函数的结果返回出去。


协程的矛与盾


当我们在讨论协程时,首先要明确,我们是在说 Kotlin协程 ,下述论点也都是基于这个背景下开始。



相应的,我们也需要一个参照物,如果直接对比线程,未免有些太过于不公平,所以我们选用 线程池协程 进行对比分析。



协程是线程框架吗?


Jvm 平台,因为 协程 底层离不开 Java线程模型 ,故最终的任务也是需要 线程池 最终去承载。所以从底层而言,我们可以通俗且大胆的认为协程就是一个线程框架,这没问题。


[但],这显然不是很合适,或者说,这有点过于糙了!


在文章开始,我们已经提过了,Android官方对其的描述:



协程是一种并发设计模式,您可以在 Android 平台上使用它来简化异步执行的代码。



所以,如果我们从协程本质与设计思想去看待,显然其相比线程池具有更高层次的编程模型,故此时称其为 异步编程框架 也许更为合适。具体原因与分析有如下几点:




  • 从编程模型而言


    协程与线程池两者都是用于处理异步任务或者耗时任务的工具,但两者的编程模型完全不同。线程池或者其他线程框架,往往使用回调函数来处理任务,这种方式常常比较繁琐,业务复杂时,代码可读性较差;而协程则是异步任务同步写法,基于挂起恢复的理念,由程序员自己控制执行顺序,可读性高;




  • 从异常的处理角度而言


    在线程池中,处理异常时,我们可以通过 tryCach 业务代码,或者可以在创建线程池时,自定义 ThreadFactory , 然后使用 Thread.setDefaultUncaughtExceptionHandler() 设置一个默认异常处理方式。相应的,协程通过 异常处理机制 来捕获和处理异常,相对于线程池而言,更加先进。




  • 从调度方式而言


    线程池通过创建一个固定数量的线程池来执行并发任务。每个任务将在一个可用的线程上运行,任务执行结束后,线程将返回线程池以供以后使用,并且通过在队列中等待任务来保持活动状态。如果使用协程,它并不创建新的线程,在jvm平台,其是利用少量的线程来实现并发执行,支持在单线程中执行,并使用 挂起与恢复 机制来允许并发执行。




协程性能很高?



先给结论,通常情况,协程的性能与线程池相差不大,甚至大多数常见场景,协程性能其实是不如直接使用线程池。



同时启动10w线程和协程


在协程官网,我们大概都能看到这样一句话,同时启动10w和线程和协程等等。


我们举个例子来看看,如下所示:


同时启动10w线程同时启动10w协程
image-20230319122629722image-20230319122642399

协程果然比线程快多了,那此时肯定就有同学说了,你拿协程欺负线程,咋不用线程池呢?


使用线程池替代线程


我们继续测试,这次改为线程池:


image-20230319122953566



线程池就是快啊!⚡️



如果你这样想,证明你可能理解错了🙅🏻‍♂️,我们这里只是往线程池里添加了10w个任务,因为我们用例里核心线程数是10,所以,同一时刻,只有10个任务在被处理,所以剩下的任务都在队列中等待。即这里打印的耗时仅仅只是上述代码的耗时,而不是线程池执行任务的总耗时,相比之下协程可是真真实实把10w个都跑完了,所以这两者根本没法比较。


所以我们对上面的逻辑进行更改,如下所示:


image-20230319123353502


总耗时…,没工夫等待了,不过我们可以大概算一下,总耗时16分钟多(10w/10*0.1/60)🤔。


为什么呢?明明底层都是线程池?


如果注意观察的话,线程的等待我们使用的是 sleep() ,而协程是 delay() ,两者的区别在于,前者是真真实实让我们的线程阻塞了指定时间,而后者则是语言级别,故差距很大。所以如果要做到相对公平,我们应该选用支持定时任务的线程池。


使用线程池模拟delay


为了保证相对公平,我们使用 ScheduledExecutorService ,并且将这个线程池转为协程的调度器。


结果如下:


添加10w个任务启动10w个协程
image-20230319131156258image-20230319131213967

???为什么线程池更快呢?😟


因为协程底层,最终任务还是需要我们的线程池来承载,但协程还需要维护自己的微型线程,而这个模型又是语言级别的控制,所以当协程代码转为字节码之后,即需要更多的代码才能实现。相比之下,线程池就简单直接很多,故这也是为什么线程池会快一点的原因。


场景推荐


通常情况下,我们真正耗时的任务都是IO网络 或者其他操作,所以此时协程的应用层的额外操作几乎并不影响大局。或者说面对复杂的异步场景是,此时性能也许并不是我们首先考虑,而如何更清晰的编码与封装实现,才是我们所更关心的。相应的,相比线程池,协程就很擅长这个处理异步任务。比如协程可以通过简化异步操作,也能在很大程度上,能避免我们不当的操作行为导致阻塞UI线程行为,从而提高应用性能。故在某个角度而言,协程的性能相比不恰当的使用线程池,是会更高。


所以如果我们的场景对性能有这极致要求,比如应用启动框架等,那么此时使用协程往往并不是最佳选择。但如果我们的场景是日常的业务开发,那么协程绝对是你的最佳选择。


协程的使用技巧


将协程设置为可取消


在协程中,取消属于协作操作,也就是说,当我们cancel掉某个job之后,相应的协程在挂起与恢复之前并不会立即取消(原因是协程的check时机是在我们状态机的每个步骤里),即也就是说,如果你有某个阻塞操作,协程此时并不会被取消。


如下所示:


image-20230319110607834


如上所示,我们会发现,当我们 cancel() 子协程后,我们的 readFile() 依然会正常执行。


要解释原理也非常简单:


因为 readFile() 并不是挂起函数,并且该方法内部也没有做协程 状态判断


在协程中,我们常用的函数 delay()withContext()ensureActive()yield() 等都提供了检查功能。


我们改动一下上述示例,如下所示:


image-20230319183838882image-20230319183911944

如上所示,我们在 readFile() 中增加了 yield() 方法,而当我们 cancel() 掉子协程时,当 Thread.sleep() 执行结束后,遇到 yield()时,该方法就会判断当前协程作用域是否已经不在活跃,如果满足条件,则直接抛出 CancellationException 异常。


协程的同步问题?


因为 Kotlin协程 是运行在 Java线程模型 基础之上,所以相应的,也存在 同步 问题。


在多线程的情况下,操作执行的顺序是不可预测的。与编译器优化操作的顺序不同,线程无法保证以特定的顺序运行,而上下文切换的操作随时有可能发生。所以如果在访问一个未经处理的状态时,线程很有可能就会访问到过时的数据,丢失必要的更新,或者遇到 资源竞争 等情况。


所以,使用了协程并且涉及可变状态的类必须采取措施使其可控,比如保证协程中的代码所访问的数据是最新的。这样一来,不同的线程之间就不会互相干扰。


如下示例:


image-20230314225515905


上述代码很简单,需要注意的是,为了防止 println() 先于我们的 repeat() 执行结束,我们使用measureTimeMillis()+coroutineScope() 进行嵌套,从而等待 coroutineScope() 内部所有子协程全部执行结束,才退出 measureTimeMillis()


不过从结果来看,不出意外的也存在同步问题,那该怎么解决?



按照Java开发中的习惯,我们可以使用 synchronized ,或者使用 AtomicInteger 管理sum。



常规方式解决


如下所示,我们选用 synchronized 来解决:


image-20230319111855237


如上所示,我们使用了 synchronized 对象锁来解决同步问题。



注意:这里我们锁的是 this@coroutineScope ,而不是 this ,前者代表着我们循环外的作用域对象,而直接使用this则代表了当前协程的作用域对象,并不存在竞争关系。



使用Mutex解决


除去传统的解决方式之外,Kotlin 中还增加了额外的辅助类去解决协程同步问题,其使用起来也更加简单,即 Mutex(互斥锁) ,这也是协程中解决同步问题的推荐方式。


如下示例:


image-20230314230330867


我们创建了一个 Mutex 对象,并使用其 加锁方法 withLock() ,从而避免多协程下的同步问题。相应的,Mutex 也提供了 lock()unLock() 从而控制对共享资源的访问(withLock()是这两者的封装)。


从原理上而言,Mutex 是通过 一个 AtomicInteger 类型的状态记录锁的状态(是否被占用),并使用一个 ConcurrentLinkedQueue 类型的队列来持有 等待持有锁 的协程,从而解决多个协程并发下的同步问题。


相比传统的 synchronized 阻塞线程,Mutex 内部使用了 CAS机制,并且支持协程的挂起恢复,其可扩展性,其都更具有优势;并且在协程的挂起函数中使用 synchronized,也可能会影响协程的正常调度和执行。故无论是上手难度及可读性,Mutex 无疑是更适合协程开发者的。


Mutex是性能的最佳选择吗?


在过往,我们提到 synchronized 都会觉得,它会直接阻塞线程,大家都会不约而同的推荐CAS作为更好的替代。但其实 synchronizedjdk1.6 之后,已经增加了各种优化,比如增加了各种锁去减缓直接加锁所导致的上下文切换耗时。


所以,我们对比一下上述的耗时:


image-20230319185125581image-20230319185132651

为什么 Mutex 的性能其实不如 synchronized 呢?


原因如下



  • Mutex 在处理并发访问时会产生额外的开销,由于 Mutex 是一个互斥锁,它需要操作系统层面的支持来实现,包括支持挂起和恢复、上下文切换和内核态和用户态之间的切换等操作,这些操作都需要较大的系统开销和时间,导致 Mutex 的性能较差。

  • synchronized 采用了一种更加灵活的方式来实现锁的机制,它会检查锁状态,如果没有被持有,则可以立即获取锁。如果锁被持有,则选择等待,或者继续执行其他的任务。从具体的实现上来说,synchronized 底层由jvm保证,在运行过程中,可能会出现偏向锁、轻量级锁、重量级锁等。关于 synchronized 相关的问题,大家也可以去看看我这篇文章 浅析 synchronized 底层实现与锁相关


最后,我们再看一下 KotlinFlow 中关于同步问题的解决方法:


image-20230319120056743


嗯,所以Mutex还要不要用了?🤨


如果我们把视线向上提一级,就会理解,当我们在选用 Kotlin 协程的时候,就已经选择了为了使用方便去容忍牺牲一部分性能。再者说,如果你的业务真的对性能要求极致,那么协程本身其实并不是首选推荐的,此时你应该选用线程池去处理,从而得到性能的最大化,因为协程本身的微型机制就需要做更多的额外操作。


再将视角切回到同步问题的处理上,Mutex 是协程中的推荐解决同步问题的方式,而且支持挂起与恢复,这点是其他同步解决方式无法具备的;再者说,Mutex 的上手难度相比 synchronized 低了不少。而至于性能上的差距,对于我们的业务开发而言,几乎是不会感知到,所以在协程中,Kotlin团队建议我们使用Mutex。


协程的异常处理方式


关于协程的异常处理,其实一直都不是一个简单事,或者说,优雅的处理异常并没那么简单。


在传统的原生的异常处理中,我们处理异常无在乎是这两种:



  • tryCatch();

  • Thread.setDefaultUncaughtExceptionHandler();


后者常用于非主线程的保底,前者用于几乎任何位置。


因为协程底层也是使用的java线程模型,所以上述的方式,在协程的异常处理中,同样有效,如下所示:


image-20230319163635334



上述的 runCatching() 是kotlin中对 tryCatch() 的一种封装。



使用CoroutineExceptionHandler


在协程中,官方建议我们使用 CoroutineExceptionHandler 去处理协程中异常,或者作为协程异常的保底手段,如下所示:


image-20230319164039472


我们定义了一个 CoroutineExceptionHandler,并在初始化 CoroutineScope 时将其传入,从而我们这个协程作用域下的所有子协程发生异常时都将被这个 handler 所拦截。



这里使用了 SupervisorJob() 的原因是,协程的异常是会传递的,比如当一个子协程发生异常时,它会影响它的兄弟协程与它的父协程。而使用了 SupervisorJob() 则意味着,其子协程的异常都将由其自己处理,而不会向外扩散,影响其他协程。



还有一点需要注意的是, CoroutineExceptionHandler 只能用于初始化 CoroutineScope 本身的初始化或者其直接子协程(即scope.launch),否则就算创建子协程时携带了 CoroutineExceptionHandler,也不会生效。


关于协程的异常处理,具体可以看我的这篇文章,里面有详细讲解:Kotlin | 关于协程异常处理,你想知道的都在这里


常见高阶函数


在开发中,有一些高阶函数,对我们特别有用,这里就将其列出来,以便大家开发中进行使用:


image-20230319190852334


如果你对上述的方法都非常了解,那不妨为自己鼓鼓掌。👏


总结


在本篇,我们着力于从全盘看起,理清 Kotlin协程 的方方面面。从 协程背景 到 挂起函数字节码实现,一瞥挂起与恢复的底层实现,从而体会其相应的设计魅力,并针对一些常见问题进行分析与解析,从而建立起协程彻底理解。文章中挂起函数部分的源码部分可能稍显繁琐,但依然建议大家多看几遍流程,从而更好理解。相应的细节问题,也都有详细注释。


最后,让我们再回到这个问题,协程到底是什么呢?



在JVM平台,Kotlin协程就是一个异步编程框架,它可以帮助我们简化异步代码,提升可读性,从而极大减少异步回调所带来的复杂逻辑。



从底层实现来看:



  • kotlin协程基于 java线程模型 ,故底层依然是使用了 线程池 作为任务承载,但相比传统的线程模型,协程在其基础上搭建了一套基于语言级别的 ”微型“ 线程模型。并定义了挂起函数作为相应的子任务,其内部采用了状态机的思想,用于实现协程中的挂起与恢复。

  • 在挂起与恢复的实现上,使用了 suspend 关键字标记的函数被称为挂起函数。其在字节码中,会经过 CPS转换 为一个带有 Continuation 参数,返回值为 Object 的方法。而 Continuation 正是用于保存我们的函数状态、步骤,从而实现挂起恢复,其内部也都包含着上一个 Continuation,正如 callback 的嵌套一样。

  • 当我们的函数被挂起时,我们当前的函数内部会实例化一个 ContinuationImpl() ,其内部 invokeSuspend() 又维护着当前的函数逻辑,并使用一个 label 作为状态进行流转,如果我们的函数内部依然有其他挂起函数,此时也会将当前的 Continuation 对象传入子挂起函数内部,从而实现 Continuation 的传递,并更改当前的函数状态。而当我们最底层的方法执行结束后,此时就会再次触发父 ContinuationImpl 内部的 invokeSuspend() 方法,从而回到调用方的逻辑内部,从而完成挂起函数的恢复。以此类推,直到我们最开始的调用方法内;


从性能上去看:



  • 协程的性能并不优于线程池或者其他异步框架,主要是其做了更多语言级别步骤,但通常情况下,与其他框架的性能几乎一致,因为相比IO的耗时,语言级别的损耗可以几乎忽略不计;


从设计模式去看:



  • 协程使得开发者可以自行管理异步任务,而不同于线程的抢占式任务,并且写成还支持子协程的嵌套关闭、更简便的异常处理机制等,故相比其他异步框架,协程的理念更加先进;


参照



关于我


我是 Petterp ,一个 Android工程师 ,如果本文对你有所帮助,欢迎 点赞、评论、收藏,你的支持是我持续创作的最大鼓励!



欢迎关注我的 公众号(Petterp) ,期待与你一同前进 :)



作者:Petterp
来源:juejin.cn/post/7212311942613385253
收起阅读 »

Kotlin委托的原理与使用,以及在Android开发中常用的几个场景

Kotlin委托的常见使用场景 前言 在设计模式中,委托模式(Delegate Pattern)与代理模式都是我们常用的设计模式(Proxy Pattern),两者非常的相似,又有细小的区分。 委托模式中,委托对象和被委托对象都是同一类型的对象,委托对象将任务...
继续阅读 »

Kotlin委托的常见使用场景


前言


在设计模式中,委托模式(Delegate Pattern)与代理模式都是我们常用的设计模式(Proxy Pattern),两者非常的相似,又有细小的区分。


委托模式中,委托对象和被委托对象都是同一类型的对象,委托对象将任务委托给被委托对象来完成。委托模式可以用于实现事件监听器、回调函数等功能。


代理模式中,代理对象与被代理对象是两种不同的对象,代理对象代表被代理对象的功能,代理对象可以控制客户对被代理对象的访问。代理模式可以用于实现远程代理、虚拟代理、安全代理等功能。


以类的委托与代理来举例,委托对象和被委托对象都实现了同一个接口或继承了同一个类,委托对象将任务委托给被委托对象来完成。代理模式中,代理对象与被代理对象实现了同一个接口或继承了同一个类,代理对象代表被代理对象,客户端通过代理对象来访问被代理对象。


两者的区别:


他们虽然都有同一个接口,主要区别在于委托模式中委托对象和被委托对象是同一类型的对象,而代理模式中代理对象与被代理对象是两种不同的对象。总的来说,委托模式是为了将方法的实现交给其他类去完成,而代理模式则是为了控制对象的访问,并在访问前后进行额外的操作。


而我们常用的委托模式怎么使用?在 Java 语言中需要我们手动的实现,而在 Kotlin 语言中直接通过关键字 by 就可以实现委托,其实现更加优雅、简洁了。


我们在开发一个 Android 应用中,常用到的委托分为:



  1. 接口/类的委托

  2. 属性的委托

  3. 结合lazy的延迟委托

  4. 观察者的委托

  5. Map数据的委托


下面我们就一起看看不同种类的委托使用以及在 Android 常见的一些场景中的使用。


一、接口/类委托


我们可以选择使用接口来实现类似的效果,也可以直接传参,当然接口的方式更加的灵活,比如我们这里就以接口比如我定义一个攻击与防御的行为接口:


interface IUserAction {

fun attack()

fun defense()
}

定义了用户的行为,有攻击和防御两种操作!接下来我们就定义一个默认的实现类:


class UserActionImpl : IUserAction {

override fun attack() {
YYLogUtils.w("默认操作-开始执行攻击")
}

override fun defense() {
YYLogUtils.w("默认操作-开始执行防御")
}
}

都是很简单的代码,我们定义一些默认的操作,如果任意类想拥有攻击和防御的能力就直接实现这个接口,如果想自定义攻击和防御则重写对应的方法即可。


如果使用 Java 的方式实现委托,大致代码如下:


class UserDelegate1(private val action: IUserAction) : IUserAction {
override fun attack() {
YYLogUtils.w("UserDelegate1-需要自己实现攻击")
}

override fun defense() {
YYLogUtils.w("UserDelegate1-需要自己实现防御")
}
}

如果使用 Kotlin 的方式实现则是:


class UserDelegate2(private val action: IUserAction) : IUserAction by action

如果 Kotlin 的实现不想默认的实现也可以重写部分的操作:


class UserDelegate3(private val action: IUserAction) : IUserAction by action {

override fun attack() {
YYLogUtils.w("UserDelegate3 - 只重写了攻击")
}
}

那么使用起来就是这样的:


    val actionImpl = UserActionImpl()

UserDelegate1(actionImpl).run {
attack()
defense()
}

UserDelegate2(actionImpl).run {
attack()
defense()
}

UserDelegate3(actionImpl).run {
attack()
defense()
}

打印日志如下:


image.png


其实在 Android 源码中也有不少委托的使用,例如生命周期的 Lifecycle 委托:


Lifecycle 通过委托机制实现其功能。具体来说,组件可以将自己的生命周期状态委托给 LifecycleOwner 对象,LifecycleOwner 对象则负责管理这些组件的生命周期。


例如,在一个 Activity 中,我们可以通过将 Activity 对象作为 LifecycleOwner 对象,并将该对象传递给需要注册生命周期的组件,从而实现组件的生命周期管理。 页面可以使用 getLifecycle() 方法来获取它所依赖的 LifecycleOwner 对象的 Lifecycle 实例,并在需要时将自身的生命周期状态委托给该 Lifecycle 实例。


通过这种委托机制,Lifecycle 实现了一种方便的方式来管理组件的生命周期,避免了手动管理生命周期带来的麻烦和错误。



class AnimUtil private constructor() : DefaultLifecycleObserver {

...

private fun addLoopLifecycleObserver() {
mOwner?.lifecycle?.addObserver(this)
}

// 退出页面的时候释放资源
override fun onDestroy(owner: LifecycleOwner) {
mAnim?.cancel()
destory()
}

}


除此之外委托还特别适用于一些可配置的功能,比如 Resutl-Api 的封装,如果当前页面需要开启 startActivityForResult 的功能,就实现这个接口,不需要这个功能就不实现接口,达到可配置的效果。


/**
* 定义是否需要SAFLauncher
*/

interface ISAFLauncher {

fun <T : ActivityResultCaller> T.initLauncher()

fun getLauncher(): GetSAFLauncher?

}

由于代码是固定的实现,目标Activity也不需要重新实现,我们只需要实现默认的实现即可:


class SAFLauncher : ISAFLauncher {

private var safLauncher: GetSAFLauncher? = null

override fun <T : ActivityResultCaller> T.initLauncher() {
safLauncher = GetSAFLauncher(this)
}

override fun getLauncher(): GetSAFLauncher? = safLauncher

}

使用起来我们直接用默认的实现即可:


class DemoActivity : BaseActivity, ISAFLauncher by SAFLauncher() {

override fun init() {
initLauncher() // 实现了接口还需要初始化Launcher
}

fun gotoOtherPage() {
//使用 Result Launcher 的方式启动,并获取到返回值
getLauncher()?.launch<DemoCircleActivity> { result ->
val result = result.data?.getStringExtra("text")
toast("收到返回的数据:$result")
}

}

}

这样是不是就非常简单了呢?具体如何使用封装 Result Launcher 可以看看我去年的文章 【传送门】


二、属性委托


除了类与接口对象的委托,我们还常用于属性的委托。


我知道了!这么弄就行了。


    private val textStr by "123"

哎?怎么报错了?其实不是这么用的。


属性委托和类委托一样,属性的委托其实是对属性的 set/get 方法的委托。


需要我们把 set/get 方法委托给 setValue/getValue 方法,因此被委托类(真实类)需要提供 setValue/getValue 方法,val属性只需要提供 getValue 方法。


我们修改代码如下:


    private val textStr by TextDelegate()

class TextDelegate {

operator fun getValue(thisRef: Any?, property: KProperty<*>): String {
return "我是赋值给与的文本"
}

}

打印的结果:


image.png


而我们定义一个可读写的属性则可以


  private var textStr by TextDelegate()

class TextDelegate {

operator fun getValue(thisRef: Any?, property: KProperty<*>): String {
return "我是赋值给与的文本"
}

operator fun setValue(thisRef: Any?, property: KProperty<*>, value: String) {
YYLogUtils.w("设置的值为:$value")
}

}

YYLogUtils.w("textStr:$textStr")
textStr = "abc123"

打印则如下:


image.png


为了怕大家写错,我们其实可以用接口来限制,只读的和读写的属性,我们分别可以用 ReadOnlyProperty 与 ReadWriteProperty 来限制:



class TextDelegate : ReadOnlyProperty<Any, String> {
override fun getValue(thisRef: Any, property: KProperty<*>): String {
return "我是赋值给与的文本"
}
}

class TextDelegate : ReadWriteProperty<Any, String> {
override fun getValue(thisRef: Any, property: KProperty<*>): String {
return "我是赋值给与的文本"
}

override fun setValue(thisRef: Any, property: KProperty<*>, value: String) {
YYLogUtils.w("设置的值为:$value")
}
}


那么实现的方式和上面自己实现的效果是一样的。如果要使用属性委托可以选用这种接口限制的方式实现。


我们的属性除了委托给类去实现,同时也能委托给其他属性(Kotlin 1.4+)来实现,例如:


    private var textStr by TextDelegate2()
private var textStr2 by this::textStr

其实是内部委托了对象的 get 和 set 函数。相对委托对象而言性能更好一些。而委托对象去实现,不仅增加了一个委托类,而且还还在初始化时就创建了委托类的实例对象,算起来其实性能并不好。


所以属性的委托不要滥用,如果要用,可以选择委托现成的其他属性来完成,或者使用延迟委托Lazy实现,或者使用更简单的方式实现:


    private val industryName: String
get() {
return "abc123"
}

对于只读的属性,这种方式也是我们常见的使用方式。


三、延迟委托


如果说使用类来实现委托不那么好的话,其实我们可以使用延迟委托。延迟关键字 lazy 接收一个 lambda 表达式,最后一行代表返回值给被推脱的属性。


默认的 Lazy 实现:


    val name: String by lazy {
YYLogUtils.w("第一次调用初始化")
"abc123"
}

YYLogUtils.w(name)
YYLogUtils.w(name)
YYLogUtils.w(name)

只有在第一次使用此属性的时候才会初始化,一旦初始化之后就可以直接获取到值。


日志打印:


image.png


它的内部其实也是使用的是类的委托实现。


public actual fun <T> lazy(initializer: () -> T): Lazy<T> = SynchronizedLazyImpl(initializer)


最终的实现是由 SynchronizedLazyImpl 类生成并实现的:


private class SynchronizedLazyImpl<out T>(initializer: () -> T, lock: Any? = null) : Lazy<T>, Serializable {
private var initializer: (() -> T)? = initializer
@Volatile private var _value: Any? = UNINITIALIZED_VALUE
// final field is required to enable safe publication of constructed instance
private val lock = lock ?: this

override val value: T
get() {
val _v1 = _value
if (_v1 !== UNINITIALIZED_VALUE) {
@Suppress("UNCHECKED_CAST")
return _v1 as T
}

return synchronized(lock) {
val _v2 = _value
if (_v2 !== UNINITIALIZED_VALUE) {
@Suppress("UNCHECKED_CAST") (_v2 as T)
} else {
val typedValue = initializer!!()
_value = typedValue
initializer = null
typedValue
}
}
}

override fun isInitialized(): Boolean = _value !== UNINITIALIZED_VALUE

override fun toString(): String = if (isInitialized()) value.toString() else "Lazy value not initialized yet."

private fun writeReplace(): Any = InitializedLazyImpl(value)
}


我们可以直接看 value 的 get 方法,如果_v1 !== UNINITIALIZED_VALUE 则表明已经初始化过了,就直接返回 value ,否则表明没有初始化过,调用initializer方法,也就是 lazy 的 lambda 表达式返回属性的赋值。


跟我们自己实现类的委托类似,也是实现了getValue方法。只是多了判断是否初始化的一些相关逻辑。


lazy的参数分为三种类型:



  1. SYNCHRONIZED:添加同步锁,使lazy延迟初始化线程安全

  2. PUBLICATION:初始化的lambda表达式,可以在同一时间多次调用,但是只有第一次的返回值作为初始化值

  3. NONE:没有同步锁,非线程安全


默认情况下,对于 lazy 属性的求值是同步锁的(synchronized),是可以保证线程安全的,但是如果不需要线程安全和减少性能花销可以可以使用 lazy(LazyThreadSafetyMode.NONE){} 即可。


四、观察者委托


除了对属性的值进行委托,我们甚至还能对观察到这个变化过程:


使用 observable 委托监听值的变化:


    var values: String by Delegates.observable("默认值") { property, oldValue, newValue ->

YYLogUtils.w("打印值: $oldValue -> $newValue ")
}

values = "第一次修改"
values = "第二次修改"
values = "第三次修改"

打印:


image.png


我们还能使用 vetoable 委托,和 observable 一样可以观察属性的变化,不同的是 vetoable 可以决定是否使用新值。


    var age: Int by Delegates.vetoable(18) { property, oldValue, newValue ->
newValue > oldValue
}

YYLogUtils.w("age:$age")
age = 14
YYLogUtils.w("age:$age")
age = 20
YYLogUtils.w("age:$age")
age = 22
YYLogUtils.w("age:$age")
age = 20
YYLogUtils.w("age:$age")

我们需要返回 booble 值觉得是否使用新值,比如上述的例子就是当新值大于老值的时候才赋值。那么打印的日志就是如下:


image.png


虽然这种方式我们并不常用,一般我们都是使用类似 Flow 之类的工具在源头就处理了逻辑,使用这种方式我们就可以在属性的赋值过程中进行拦截了。在一些特定的场景下还是有用的。


五、Map委托


我们的属性不止可以使用类的委托,延迟的委托,观察的委托,还能委托Map来进行赋值。


当属性的值与 Map 中 key 相同的时候,我们可以把对应 key 的 value 取出来并赋值给属性:


class Member(private val map: Map<String, Any>) {

val name: String by map
val age: Int by map
val dob: Long by map

override fun toString(): String {
return "Member(name='$name', age=$age, dob=$dob)"
}

}

使用:


        val member = Member(mapOf("name" to "guanyu", "age" to 36, Pair("dob", 1234567890L)))
YYLogUtils.w("member:$member")

打印的日志:


image.png


但是需要注意的是,map 中的 key 名字必须要和属性的名字一致才行,否则委托后运行解析时会抛出 NoSuchElementException 异常提示。


例如我们在 Member 对象中加入一个并不存在的 address 属性,再次运行就会报错。


image.png


而我们把 Int 的 age 属性赋值给为字符串也会报类型转换异常:


image.png


所以一定要一一对应才行哦,我怎么感觉有一点 TypeScript 结构赋值的那味道 - - !


image.png


总结


委托虽好不要滥用。委托毕竟还是中间多了一个委托类,如果没必要可以直接赋值实现,而不需要多一个中间类占用内存。


我们可以通过接口委托来实现一些可选的配置。通过委托类实现属性的监听与赋值。可以减少一些模板代码,达到低耦合高内聚的效果,可以提高程序的可维护性、可扩展性和可重用性。


对于属性的类委托,我们可以将属性的读取和写入操作委托给另一个对象,或者另一个属性,或者使用延迟委托来推迟对象的创建直到第一次访问。


对于 map 的委托,我们需要仔细对应属性与 key 的一致性。以免出现错误,这是运行时的错误,有可能出现在生产环境上的。


那么大家都是怎么使用的呢?有没有更好的方式呢?或者你有遇到的坑也都可以在评论区交流一下,大家可以互相学习进步。如有本文有一些错漏的地方,希望同学们可以指出。


如果感觉本文对你有一点点的帮助,还望你能点赞支持一下,你的支持是我最大的动力。


本文的部分代码可以在我的 Kotlin 测试项目中看到,【传送门】。你也可以关注我的这个Kotlin项目,我有时间都会持续更新。


Ok,这一期就此完结。




作者:newki
来源:juejin.cn/post/7213267574770090039
收起阅读 »

Dart 与 Java & Kotlin 差异一览

前言 最近学习Flutter,发现其使用的Dart语言,有些方面很像Java,有些方面又很像Kotlin,所以整理下目前发现的区别点,一方面方便自己记忆,另一方面也希望可以给尚未接触过Flutter小伙伴们提供一些帮助。(本文仅从Dart语言使用角度对比Jav...
继续阅读 »

前言


最近学习Flutter,发现其使用的Dart语言,有些方面很像Java,有些方面又很像Kotlin,所以整理下目前发现的区别点,一方面方便自己记忆,另一方面也希望可以给尚未接触过Flutter小伙伴们提供一些帮助。(本文仅从Dart语言使用角度对比Java & Kotlin。)


1. 基本数据类型


Dart中,只有三种基本数据类型,数字型(num),布尔型(bool),字符串类型(String)。容器类型如List ,Map,是否属于基本数据类型,这里暂不讨论,毕竟使用也很简单。


类型DartJavaKotlin
布尔boolbooleanBoolean
数字num (int / double)int / double / long / char /byte ...Int / Double /Long / Char / Byte...
字符串StringStringString

1.1 数字类型




  • num 在Dart 中为抽象类,具有intdouble两个实现类,使用num 为类型定义变量时,会进行变量类型推断,推断为对应的实现类(int/ double)。


        ///其中需要注意,Dart中num 同样可以作为数据类型使用,如:
    num a = 10; (整数型)
    num b = 10.0; (浮点型)

    int c = 10; (整数型)
    double d = 10.00; (浮点型)



  • int 类型不仅可以表示整形数字,还代表byte 及 char类型数据,具体使用方式如下:


        ///byte 类型
    int x = 65;
    print(x.toRadixString(2));// 输出 1000001

    ///char 类型
    List<int> codes = [65, 66];
    for(var element in codes) {
    print(String.fromCharCode(element); //输出 AB
    }



1.2 字符串类型




  • 先看下Dart中字符串的定义,大概与Java 和 Kotlin相同:


      字符串定义:
    ///单引号定义字符串
    String e = 'hello world';
    String g = '''hello world''';

    ///双引号定义字符串
    String f = "hello world";
    String h = """hello world""";



  • 其中使用三引号'''"""时,会跟随文本换行,而双引号与单引号""''不会,单引号主动换行需要借助\n换行符。单引号定义的字符串中可以包含双引号,双引号定义的字符串中可以包含单引号,
    同类引号中无法包含同类引号,如:


        错误使用:
    String a = "----"hello world"----";
    String b = '----'hello world'----';

    正确使用:
    String a = '----"hello world"----';
    String b = "----'hello world'----";



  • Dart 支持 Kotlin 字符串拼接方式:


        String name = "Child";
    String s = "$name, hello world, ${name}"



2.语法区别


Dart的语法与Java基本是相同的,只不过在细节上有些差异,Dart在Java基础上,进行了优化。Dart在Java基础上,进行了优化,使其更加简洁,方便。


2.1 构造函数




  • Dart中类构造函数写法有很多种,既可以使用与Java完全一样的写法,也可以使用Dart特有写法,具体写入如下:


        Class TestA {
    int a = 0;
    int b = 0;
    ///与Java相同的基本写法
    TestA(int a, int b) {
    this.a = a;
    this.b = b;
    }

    ///Dart 特有构造写法,
    ///方式1:
    TestA(this.a, this.b);

    ///方式2:
    TestA(int x, int y)
    : this.a = x,
    this.b = y;

    ///方式3:命名构造,与Kotlin中的扩展函数类似,但功能完全不同。
    TestA.instance(this.a, this.b);
    }

    个人感觉,为了方便与Java区分,不建议使用与Java相同的构造写法,而且Dart特有的构造写法,更加简洁。




2.2 对象操作




  • 对象创建:


        ///Dart中,可以和Java一样相同,使用new关键字创新对象
    TestA a = new TestA();

    ///同样也可以使用Kotlin方式一样,创建对象
    TestA b = TestA();
    var c = TestA();



  • 对象属性赋值:


        ///通用赋值方式:
    TestA object = TestA();
    object.x = 10;
    object.y = 20;

    ///Dart特有赋值方式:
    TestA object = TestA()
    ..x = 10
    ..y = 20;

    Dart特有的赋值方式看起来有些奇怪,但是多看看也就习惯了,注意分号()在赋值结束后添加,赋值过程中不需要加。




2.3 空安全




  • Dart 中拥有与Kotlin 相同的变量空安全机制。在定义可为空的变量时,需要在变量后加 ?,示例如下:


        class Test {
    String? x = null;

    void method() {
    ///当变量可能为null时,添加问号,检查对象是否为null,不为null时,才会实行
    print(x?.length);
    ///类似Java 三元表达式,Kotlin变量判断是否为null,若为null,则赋予对应值。
    String y = x ?? 'hello word';
    ///当非常非常非常确定,变量不为null时,可以使用!,强制声明变量肯定不为null
    print(x!.length);
    }
    }



2.4 可变参数




  • Dart具有与Kotlin相同的可变参数的功能,只是实现方式有些许不同,示例如下:


        class Test() {
    ///命名参数,required修饰的参数都为必填
    void method1({required int a, required int b}) {
    print('add = ${a + b}');
    }

    ///默认参数,可以为参数赋予默认值,使用时,可以不传入该参数
    void method2({required int a, int b = 0}) {
    print('add = ${a + b}');
    }

    /// 位置参数,其特点是必须按顺序依次进行指定若干入参
    void method3(int a, [int b = 1, int c = 0]) {
    print('param: a = ${a}, b = ${b}, c = ${c}');
    }

    void test() {
    method1(a: 10, b 10);
    method2(a: 10);
    method3(10);
    method3(10, 20);
    method3(10, 20, 30);
    }

    }



3.关键字区别


这里将从Dart与Java不同的关键字,讲述不同的关键字对功能及编码方面的影响。


3.1 可见范围关键字




  • Dart的类,方法,变量只有两种访问类型,可访问/不可访问:




  • 在类名,方法名,变量名前添加 _ (下划线),即为外部类不可访问;没有 _ (下划线)为可访问。




  • 没有访问范围控制关键字,public, private, protect。


        class TestB {
    ///公共变量
    int a = 10;
    ///私有变量,仅能在本类中调用
    int _b = 20;
    ///常量定义, 与Kotlin中定义相同
    const c = 30;
    ///相当于Kotlin的 lateinit,延迟初始化变量
    late String d;

    ///公共方法,可以供内部/外部类调用
    void method1() {

    }

    ///私有方法,只能在本类调用
    void _method2() {
    var object = _TestC();
    }

    //静态方法,与Java使用方式一致,TestB.method3() 调用
    static void method3() {
    }
    }

    ///私有类,访问范围在本.dart文件中(在TestB类中可以访问),其他文件中无法访问
    class _TestC {

    }



3.2 interface & implement 使用区别:




  • Dart中, 没有interface 接口关键字的定义,但是有implement




  • implement 关键字使用,可以实现所有类:抽象类及普通类,需要实现类中所有定义的变量及方法,如下图所示:


       implement 实现普通类:

    class BaseA {
    int x = 10;

    void method1() {
    }
    }

    class ImplementA implements BaseA {
    @override
    int x = 0;

    @override
    void method1() {
    //TODO
    }
    }

        implement 实现抽象类:

    abstract class BaseB {
    final int a = 0;

    void method1();

    void method2() {
    }
    }

    class ImplementB implement BaseB {
    @override
    //TODO
    int get a => 0;

    @overide
    void method1() {
    //TODO
    }

    @override
    void method2() {
    //TODO
    }
    }



  • 与Java & Kotlin相同,一个类可以实现多个(接口)类。




  • 接口二义性问题解决:当 C 类实现 A 、B 接口,会强制重写所有方法,成员变量提供 get 方法;即在当前类,方法只具有一种实现,变量值需重新赋值,这样就解决了二义性问题。示例如下:


        class C implement A, B {
    @override
    String str = 'hello world';

    @override
    void go() {
    //TODO
    }
    }



3.3 with & mixin 混入




  • 含义:with & mixin 为Dart实现混入(mixins)的关键字,混入是指将一个类的代码插入到另一个类中,以增强该类的功能,而不需要创建一个新的子类。




  • 作用:实现类功能扩展(可以同时混入多个)。比如Java & Kotlin 可以通过内部类的形式,来扩展类功能。




  • 与普通类区别:混入类,没有构造方法,无法实例化。




  • 与接口区别:接口只定义一类功能接口,没有完整功能实现;混入类需具备完整功能实现。


        ///混入类定义
    mixin Write {
    final String word = 'hello world';

    void write() {
    print('person can write: $word');
    }
    }

    ///一般类接入混入类,引入混入类实现功能
    class Person with Write {
    @override
    String get word => 'hello word! ++';
    }

    class Test {
    void method() {
    Person p = Person();
    p.write();
    }
    }



混入类功能与接口类似,所以同样存在二义性问题,那么混入类是如何解决二义性问题的呢?



  • 如C 以先A ,后B顺序混入两个类,A, B 中都含有一个变量名name的字符串,混入C后,打印字符串name,显示的为后混入B类中name的值。
    即混入多个类时,若定义的相同类型&相同名称的变量,值为最后混入的类的值。

  • 若变量名相同,但变量类型不同,同时混入会报错。


3.4 extension 拓展/扩展方法




  • 这个功能与Kotlin的扩展方法是类似的,都可以在不修改类文件的前提下,扩展类方法。




  • Kotlin不仅可以添加扩展方法,同时可以添加扩展变量。Dart只可以添加拓展方法。


        extension StringUtil on String {
    bool isNullorEmpty(String? str) {
    return str == null || str.isEmpty;
    }
    }



3.5 on 关键字。




  • on 关键字用于混入类间,实现类似 extends 的关系。即混入类可以通过 on 关键字引入其他类的功能。需要注意的是,混入类不仅可以引入混入类,也可以引入普通类与抽象类。例如:


        mixin D {
    String d = 'hello word! D';

    void run() {
    print('on keyword --- ${d}')
    }
    }

    mixin E on D {
    @override
    set(String value) {
    d = value;
    }

    @override
    void run() {
    super.run();
    }
    }



  • onextension 配合使用,表示对哪个类进行扩展。




3.6 switch 关键字




  • switch 关键字与Java中的功能相同,即判断执行分支。其中有一个需要注意的细节,Dart中,对象类型也可以作为分支判断条件。


        class Test {
    void method1() {
    Person p1 = Person();
    Person p2 = Person();
    Person p3 = Person();

    Person p = p1;
    switch(p) {
    case p1:
    //TODO
    break;
    case p2:
    //TODO
    break;
    case p3:
    //TODO
    break;
    }
    }
    }



  • Java中,判断对象只能为基本数据类型,如下图所示。




20230316164610.jpg


3.7 set & get 关键字




  • 与Kotlin类似,Dart提供了 set & get关键字,实现变量的 setter & getter 功能。示例如下:


        class Test {
    void method() {
    A a = A();
    print(' get value : ${a.getValue}');

    a.setValue(10);
    print(' get value : ${a.getValue}');
    }
    }

    class A {
    int _value = 0;

    int get getValue => _value;
    /// => 是Dart中的省略写法,完整方法如下:
    int get getValue {
    return _value;
    }

    set setValue(int value) {
    _value = value;
    }
    }



3.8 Function 函数对象




  • 定义:函数对象与Kotlin的高阶函数类似,可以理解为函数对象类型的关键字;指定传入参数,执行对应代码块后,返回指定类型的返回值。这也是Dart 比 Java 更靠近万物皆对象的体现。




  • 作用:与Kotlin的高阶函数功能一致,定义一类功能的实现规则。示例如下:


        typedef Operate = int Function(int, int);

    class Test {
    void method() {
    Operate add = (a, b) {
    return a + b;
    }

    add.call(10, 20)
    }
    }



总结


总的来说,Dart语言与Java&Kotlin很多相似的地方,在最开始学习时,记住不同点,编码方面就不会有太多的阻碍。但是从语言设计层面看,Dart与Java&Kotlin还是有很大的区别,等我悟道之后,再和大家细说。
如果有需要完善,或者不认同的地方,欢迎大家留言评论。



作者:Child
来源:juejin.cn/post/7213232948794884155
收起阅读 »

Android App封装 ——架构(MVI + kotlin + Flow)

项目搭建经历记录 Android App封装 ——架构(MVI + kotlin + Flow) Android App封装 —— ViewBinding Android App封装 —— DI框架 Hilt?Koin? Android App封装 —— 实...
继续阅读 »

项目搭建经历记录



  1. Android App封装 ——架构(MVI + kotlin + Flow)

  2. Android App封装 —— ViewBinding

  3. Android App封装 —— DI框架 Hilt?Koin?

  4. Android App封装 —— 实现自己的EventBus


一、背景


最近看了好多MVI的文章,原理大多都是参照google发布的 应用架构指南,但是实现方式有很多种,就想自己封装一套自己喜欢用的MVI架构,以供以后开发App使用。


说干就干,准备对标“玩Android”,利用提供的数据接口,搭建一个自己习惯使用的一套App项目,项目地址:Github wanandroid


二、MVI


先简单说一下MVI,从MVC到MVP到MVVM再到现在的MVI,google是为了一直解决痛点所以不断推出新的框架,具体的发展流程就不多做赘诉了,网上有好多,我们可以选择性适合自己的。


应用架构指南中主要的就是两个架构图:


2.1 总体架构


image.png


Google推荐的是每个应用至少有两层:



  • UI Layer 界面层: 在屏幕上显示应用数据

  • Data Layer 数据层: 提供所需要的应用数据(通过网络、文件等)

  • Domain Layer(optional)领域层/网域层 (可选):主要用于封装数据层的逻辑,方便与界面层的交互,可以根据User Case


图中主要的点在于各层之间的依赖关系是单向的,所以方便了各层之间的单元测试


2.2 UI层架构


UI简单来说就是拿到数据并展示,而数据是以state表示UI不同的状态传送给界面的,所以UI架构分为



  • UI elements层:UI元素,由activity、fragment以及包含的控件组成

  • State holders层: state状态的持有者,这里一般是由viewModel承担


image.png


2.3 MVI的特点


MVI相比与MVVM的核心区别是它的两大特性:


1. 唯一可信数据源


唯一可信数据源,是为了解决MVVM中View层使用大量LiveData,导致各种LiveData数据并行更新或者互相交互时会偶尔出现不可控逻辑,导致偶现一些的奇奇怪怪的Bug。


MVI使用唯一可信的数据源UI State来避免这种问题。


2. 数据单向流动。


image.png


从图中可以看到,



  1. 数据从Data Layer -> ViewModel -> UI,数据是单向流动的。ViewModel将数据封装成UI State传输到UI elements中,而UI elements是不会传输数据到ViewModel的。

  2. UI elements上的一些点击或者用户事件,都会封装成events事件,发送给ViewModel。



PS:这里有同学问,为啥不直接调用ViewModel的方法,还要弄个events事件流这么麻烦?


的确,如果直接调用是很方便,但是这样UI和ViewModel就耦合了,这时就要像MVP架构那样定义很多接口才能解耦。而定义events事件流就是另外一种方便解耦的方法,避免接口膨胀。其次,这个也是为了保证数据的单向流动,如果UI和ViewModel能直接调用方法的话,如果方法还有返回值,就破坏了数据的单向流动。



2.4 搭建MVI要注意的点


了解了MVI的原理和特点后,我们就要开始着手搭建了,其中需要解决的有以下几点



  1. 定义UI Stateevents

  2. 构建UI State单向数据流UDF

  3. 构建事件流events

  4. UI State的订阅和发送


三、搭建项目


3.1 定义UI Stateevents


我们可以用interface先定义一个抽象的UI Stateeventseventintent是一个意思,都可以用来表示一次事件。


@Keep
interface IUiState

@Keep
interface IUiIntent

然后根据具体逻辑定义页面的UIState和UiIntent。


data class MainState(val bannerUiState: BannerUiState, val detailUiState: DetailUiState) : IUiState

sealed class BannerUiState {
object INIT : BannerUiState()
data class SUCCESS(val models: List<BannerModel>) : BannerUiState()
}

sealed class DetailUiState {
object INIT : DetailUiState()
data class SUCCESS(val articles: ArticleModel) : DetailUiState()
}

通过MainState将页面的不同状态封装起来,从而实现唯一可信数据源


3.2 构建单向数据流UDF


在ViewModel中使用StateFlow构建UI State流。



  • _uiStateFlow用来更新数据

  • uiStateFlow用来暴露给UI elements订阅


abstract class BaseViewModel<UiState : IUiState, UiIntent : IUiIntent> : ViewModel() {

private val _uiStateFlow = MutableStateFlow(initUiState())
val uiStateFlow: StateFlow<UiState> = _uiStateFlow

protected abstract fun initUiState(): UiState

protected fun sendUiState(copy: UiState.() -> UiState) {
_uiStateFlow.update { copy(_uiStateFlow.value) }
}
}

class MainViewModel : BaseViewModel<MainState, MainIntent>() {

override fun initUiState(): MainState {
return MainState(BannerUiState.INIT, DetailUiState.INIT)
}
}

3.3 构建事件流


在ViewModel中使用 Channel构建事件流



有人好奇这里为啥用Channel,而不用SharedFlow或者StateFlow?


Channel就像一个队列一样,适合实现单个生产者和单个消费者之间的通信,而 SharedFlow 更适合实现多个观察者订阅同一数据源。而这里的Intent事件更像前者,各个协程生产出不同的Intent事件通过Channel发送给ViewModel,然后在ViewModel中集中处理消费。




  1. _uiIntentFlow用来传输Intent

  2. 在viewModelScope中开启协程监听uiIntentFlow,在子ViewModel中只用重写handlerIntent方法就可以处理Intent事件了

  3. 通过sendUiIntent就可以发送Intent事件了


abstract class BaseViewModel<UiState : IUiState, UiIntent : IUiIntent> : ViewModel() {

private val _uiIntentFlow: Channel<UiIntent> = Channel()
val uiIntentFlow: Flow<UiIntent> = _uiIntentFlow.receiveAsFlow()

fun sendUiIntent(uiIntent: UiIntent) {
viewModelScope.launch {
_uiIntentFlow.send(uiIntent)
}
}

init {
viewModelScope.launch {
uiIntentFlow.collect {
handleIntent(it)
}
}
}

protected abstract fun handleIntent(intent: IUiIntent)

class MainViewModel : BaseViewModel<MainState, MainIntent>() {

override fun handleIntent(intent: IUiIntent) {
when (intent) {
MainIntent.GetBanner -> {
requestDataWithFlow()
}
is MainIntent.GetDetail -> {
requestDataWithFlow()
}
}
}
}

3.4 UI State的订阅和发送


3.4.1 订阅UI State


在Activity中订阅UI state的变化



  1. lifecycleScope中开启协程,collect uiStateFlow

  2. 使用map 来做局部变量的更新

  3. 使用distinctUntilChanged来做数据防抖


class MainActivity : BaseMVIActivity() {

private fun registerEvent() {
lifecycleScope.launchWhenStarted {
mViewModel.uiStateFlow.map { it.bannerUiState }.distinctUntilChanged().collect { bannerUiState ->
when (bannerUiState) {
is BannerUiState.INIT -> {}
is BannerUiState.SUCCESS -> {
bannerAdapter.setList(bannerUiState.models)
}
}
}
}
lifecycleScope.launchWhenStarted {
mViewModel.uiStateFlow.map { it.detailUiState }.distinctUntilChanged().collect { detailUiState ->
when (detailUiState) {
is DetailUiState.INIT -> {}
is DetailUiState.SUCCESS -> {
articleAdapter.setList(detailUiState.articles.datas)
}
}

}
}
}
}

3.4.2 发送Intent


直接调用sendUiIntent就可以发送Intent事件


button.setOnClickListener {
mViewModel.sendUiIntent(MainIntent.GetBanner)
mViewModel.sendUiIntent(MainIntent.GetDetail(0))
}

3.4.3 更新Ui State


调用sendUiState发送Ui State更新


需要注意的是: 在UiState改变时,使用的是copy复制一份原来的UiState,然后修改变动的值。这是为了做到 “可信数据源”,在定义MainState的时候,设置的就是val,是为了避免多线程并发读写,导致线程安全的问题。


class MainViewModel : BaseViewModel<MainState, MainIntent>() {
private val mWanRepo = WanRepository()

override fun initUiState(): MainState {
return MainState(BannerUiState.INIT, DetailUiState.INIT)
}

override fun handleIntent(intent: IUiIntent) {
when (intent) {
MainIntent.GetBanner -> {
requestDataWithFlow(showLoading = true,
request = { mWanRepo.requestWanData() },
successCallback = { data -> sendUiState { copy(bannerUiState = BannerUiState.SUCCESS(data)) } },
failCallback = {})
}
is MainIntent.GetDetail -> {
requestDataWithFlow(showLoading = false,
request = { mWanRepo.requestRankData(intent.page) },
successCallback = { data -> sendUiState { copy(detailUiState = DetailUiState.SUCCESS(data)) } })
}
}
}
}

其中 requestDataWithFlow 是封装的一个网络请求的方法


protected fun <T : Any> requestDataWithFlow(
showLoading: Boolean = true,
request: suspend () -> BaseData<T>,
successCallback: (T) -> Unit,
failCallback: suspend (String) -> Unit = { errMsg ->
//默认异常处理
},
)
{
viewModelScope.launch {
val baseData: BaseData<T>
try {
baseData = request()
when (baseData.state) {
ReqState.Success -> {
sendLoadUiState(LoadUiState.ShowMainView)
baseData.data?.let { successCallback(it) }
}
ReqState.Error -> baseData.msg?.let { error(it) }
}
} catch (e: Exception) {
e.message?.let { failCallback(it) }
}
}
}

至此一个MVI的框架基本就搭建完毕了


3.5运行效果


www.alltoall.net_device-2022-12-15-161207_I_ahtLP5Kj.gif

四、 总结


不管是MVC、MVP、MVVM还是MVI,主要就是View和Model之间的交互关系不同



  • MVI的核心是 数据的单向流动

  • MVI使用kotlin flow可以很方便的实现 响应式编程

  • MV整个View只依赖一个State刷新,这个State就是 唯一可信数据源


目前搭建了基础框架,后续还会在此项目的基础上继续封装jetpack等更加完善这个项目。


项目源码地址:Github wanandroid


作者:剑冲
来源:juejin.cn/post/7177619630050000954
收起阅读 »

Rust在Android端的入门开发

前言 IOS上应用还在半路上,遇到了一些集成问题。在了解、学习过程中发现,IOS的Swifit UI动画真的是比Flutter做的好几倍,后面有时间可以记录记录。本次先记录Android集成吧,对比性能的话,可以在rust中for循环个10万次,对比C的时间消...
继续阅读 »

前言


IOS上应用还在半路上,遇到了一些集成问题。在了解、学习过程中发现,IOSSwifit UI动画真的是比Flutter做的好几倍,后面有时间可以记录记录。本次先记录Android集成吧,对比性能的话,可以在rustfor循环个10万次,对比C的时间消耗。

参考资料

Building and Deploying a Rust library on Android

JNI Create

Create JNI


目录


Rust在Android端的入门开发.png


一、环境准备


rustup配置


这个配置,在装rust的时候就配置了,可以忽略。如果没有配置,想了解的可以看二、Rust入门之Hello World


配置NDK


第一步

先确定自己的NDK目录

默认目录一般都在 /Users/你的用户名/Library/Android/sdk/ndk-bundle 这个位置,用户目录可以用 ${HOME} 代替。


第二步

创建库crate


cargo new android_demo --lib

第三步

切换到 android_demo 项目下,创建 NDK 文件

找到 make_standalone_toolchain.py 文件,执行以下语句


python D:/Android/SDK/ndk-bundle/build/tools/make_standalone_toolchain.py --api 26 --arch arm64 --install-dir NDK/arm64
python D:/Android/SDK/ndk-bundle/build/tools/make_standalone_toolchain.py --api 26 --arch arm --install-dir NDK/arm
python D:/Android/SDK/ndk-bundle/build/tools/make_standalone_toolchain.py --api 26 --arch x86 --install-dir NDK/x86

对应的NDK目录如下


rust_ndk_1.PNG


第四步

找到 cargo的配置文件,~/.cargo/config


[target.aarch64-linux-android]
ar = "E:/VSCodeWorkspace/rust/android_demo/NDK/arm64/bin/aarch64-linux-android-ar"
linker = "E:/VSCodeWorkspace/rust/android_demo/NDK/arm64/bin/aarch64-linux-android-clang"

[target.armv7-linux-androideabi]
ar = "E:/VSCodeWorkspace/rust/android_demo/NDK/arm/bin/arm-linux-androideabi-ar"
linker = "E:/VSCodeWorkspace/rust/android_demo/NDK/arm/bin/arm-linux-androideabi-clang"

[target.i686-linux-android]
ar = "E:/VSCodeWorkspace/rust/android_demo/NDK/x86/bin/i686-linux-android-ar"
linker = "E:/VSCodeWorkspace/rust/android_demo/NDK/x86/bin/i686-linux-android-clang"

其中 E:/VSCodeWorkspace/rust/android_demo 是本次项目目录。


第五步

添加工具链


rustup target add aarch64-linux-android armv7-linux-androideabi i686-linux-android

第六步

在当前 android_demo 目录下,执行以下语句

编译Rust项目,按需要的架构编译即可。


cargo build --target aarch64-linux-android --release
cargo build --target armv7-linux-androideabi --release
cargo build --target i686-linux-android --release


  • 出现问题

    • note: %1 不是有效的 Win32 应用程序。 (os error 193) ,第三步和第六步编译不一致。解决方法:将第四步,换成Android SDK 目录下的ndk,看下面代码示例。

    • error: linker cc not found,解决方案也是按照下面,一定要使用 .cmd




解决方案


[target.aarch64-linux-android]
ar = "D:\\Android\\SDK\\ndk\\21.4.7075529\\toolchains\\llvm\\prebuilt\\windows-x86_64\\bin\\aarch64-linux-android-ar"
linker = "D:\\Android\\SDK\\ndk\\21.4.7075529\\toolchains\\llvm\\prebuilt\\windows-x86_64\\bin\\aarch64-linux-android26-clang.cmd"

[target.armv7-linux-androideabi]
ar = "D:\\Android\\SDK\\ndk\\21.4.7075529\\toolchains\\llvm\\prebuilt\\windows-x86_64\\bin\\arm-linux-androideabi-ar"
linker = "D:\\Android\\SDK\\ndk\\21.4.7075529\\toolchains\\llvm\\prebuilt\\windows-x86_64\\bin\\armv7a-linux-androideabi26-clang++.cmd"
xxx

产物


rust_target_2.PNG


二、Rust实现


Cargo.toml


[package]
name = "android_demo"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
jni-sys = "0.3.0"

[target.'cfg(target_os="android")'.dependencies]
jni = { version = "0.5", default-features = false }

[lib]
crate-type = ["dylib"]

lib.rs


/*
* @Author: axiong
*/

use std::os::raw::{c_char};
use std::ffi::{CString, CStr};

#[no_mangle]
pub extern fn rust_greeting(to: *const c_char) -> *mut c_char {
let c_str = unsafe { CStr::from_ptr(to) };
let recipient = match c_str.to_str() {
Err(_) => "there",
Ok(string) => string,
};

CString::new("Hello ".to_owned() + recipient).unwrap().into_raw()
}

/// Expose the JNI interface for android below
/// 只有在目标平台是Android的时候才开启 [cfg(target_os="android")
/// 由于JNI要求驼峰命名,所以要开启 allow(non_snake_case)
#[cfg(target_os="android")]
#[allow(non_snake_case)]
pub mod android {
extern crate jni;

use super::*;
use self::jni::JNIEnv;
use self::jni::objects::{JClass, JString};
use self::jni::sys::{jstring};

#[no_mangle]
pub unsafe extern fn Java_com_rjx_rustdemo_RustGreeting_greeting(env: JNIEnv, _: JClass, java_pattern: JString) -> jstring {
// Our Java companion code might pass-in "world" as a string, hence the name.
let world = rust_greeting(env.get_string(java_pattern).expect("invalid pattern string").as_ptr());
// Retake pointer so that we can use it below and allow memory to be freed when it goes out of scope.
let world_ptr = CString::from_raw(world);
let output = env.new_string(world_ptr.to_str().unwrap()).expect("Couldn't create java string!");

output.into_inner()
}
}

三、Android集成


SO集成


rust_android_001.PNG


RustGreeting.java


public class RustGreeting {
static {
System.loadLibrary("android_demo");
}

private static native String greeting(final String pattern);

public static String sayHello(String to) {
return greeting(to);
}

}

MainActivity.java


public class MainActivity extends AppCompatActivity {

// Used to load the 'native-lib' library on application startup.
static {
//System.loadLibrary("native-lib");
}

private ActivityMainBinding binding;

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);

binding = ActivityMainBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());

// Example of a call to a native method
TextView tv = binding.sampleText;
tv.setText(RustGreeting.sayHello("Rust!!"));
}

}

效果


Rust_Hello.PNG


作者:CodeOver
来源:juejin.cn/post/7170696817682694152
收起阅读 »

在 Flutter 多人视频中实现虚拟背景、美颜与空间音效

在之前的「基于声网 Flutter SDK 实现多人视频通话」里,我们通过 Flutter + 声网 SDK 完美实现了跨平台和多人视频通话的效果,那么本篇我们将在之前例子的基础上进阶介绍一些常用的特效功能。 本篇主要带你了解 SDK 里几个实用的 API ...
继续阅读 »

在之前的「基于声网 Flutter SDK 实现多人视频通话」里,我们通过 Flutter + 声网 SDK 完美实现了跨平台和多人视频通话的效果,那么本篇我们将在之前例子的基础上进阶介绍一些常用的特效功能。



本篇主要带你了解 SDK 里几个实用的 API 实现,相对简单



虚拟背景


虚拟背景是视频会议里最常见的特效之一,在声网 SDK 里可以通过 enableVirtualBackground 方法启动虚拟背景支持。


首先,因为我们是在 Flutter 里使用,所以我们可以在 Flutter 里放一张 assets/bg.jpg 图片作为背景,这里有两个需要注意的点:




  • assets/bg.jpg 图片需要在 pubspec.yaml 文件下的 assets 添加引用


      assets:
      - assets/bg.jpg



  • 需要在 pubspec.yaml 文件下添加 path_provider: ^2.0.8path: ^1.8.2 依赖,因为我们需要把图片保存在 App 本地路径下




如下代码所示,首先我们通过 Flutter 内的 rootBundle 读取到 bg.jpg ,然后将其转化为 bytes, 之后调用 getApplicationDocumentsDirectory 获取路径,保存在的应用的 /data" 目录下,然后就可以把图片路径配置给 enableVirtualBackground 方法的 source ,从而加载虚拟背景。


Future<void> _enableVirtualBackground() async {
 ByteData data = await rootBundle.load("assets/bg.jpg");
 List<int> bytes =
     data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes);
 Directory appDocDir = await getApplicationDocumentsDirectory();
 String p = path.join(appDocDir.path, 'bg.jpg');
 final file = File(p);
 if (!(await file.exists())) {
   await file.create();
   await file.writeAsBytes(bytes);
}

 await _engine.enableVirtualBackground(
     enabled: true,
     backgroundSource: VirtualBackgroundSource(
         backgroundSourceType: BackgroundSourceType.backgroundImg,
         source: p),
     segproperty:
         const SegmentationProperty(modelType: SegModelType.segModelAi));
 setState(() {});
}

如下图所示是都开启虚拟背景图片之后的运行效果,当然,这里还有两个需要注意的参数:



  • BackgroundSourceType :可以配置 backgroundColor(虚拟背景颜色)、backgroundImg(虚拟背景图片)、backgroundBlur (虚拟背景模糊) 这三种情况,基本可以覆盖视频会议里的所有场景

  • SegModelType :可以配置为 segModelAi (智能算法)或 segModelGreen(绿幕算法)两种不同场景下的抠图算法。




这里需要注意的是,在官方的提示里,建议只在搭载如下芯片的设备上使用该功能(应该是对于 GPU 有要求):



  • 骁龙 700 系列 750G 及以上

  • 骁龙 800 系列 835 及以上

  • 天玑 700 系列 720 及以上

  • 麒麟 800 系列 810 及以上

  • 麒麟 900 系列 980 及以上



另外需要注意的是,为了将自定义背景图的分辨率与 SDK 的视频采集分辨率适配,声网 SDK 会在保证自定义背景图不变形的前提下,对自定义背景图进行缩放和裁剪


美颜


美颜作为视频会议里另外一个最常用的功能,声网也提供了 setBeautyEffectOptions 方法支持一些基础美颜效果调整。


如下代码所示, setBeautyEffectOptions 方法里主要是通过 BeautyOptions 来调整画面的美颜风格,参数的具体作用如下表格所示。



这里的 .5 只是做了一个 Demo 效果,具体可以根据你的产品需求,配置出几种固定模版让用户选择。



_engine.setBeautyEffectOptions(
 enabled: true,
 options: const BeautyOptions(
   lighteningContrastLevel:
       LighteningContrastLevel.lighteningContrastHigh,
   lighteningLevel: .5,
   smoothnessLevel: .5,
   rednessLevel: .5,
   sharpnessLevel: .5,
),
);

属性作用
lighteningContrastLevel对比度,常与 lighteningLevel 搭配使用。取值越大,明暗对比程度越大
lighteningLevel美白程度,取值范围为 [0.0,1.0],其中 0.0 表示原始亮度,默认值为 0.0。取值越大,美白程度越大
smoothnessLevel磨皮程度,取值范围为 [0.0,1.0],其中 0.0 表示原始磨皮程度,默认值为 0.0。取值越大,磨皮程度越大
rednessLevel红润度,取值范围为 [0.0,1.0],其中 0.0 表示原始红润度,默认值为 0.0。取值越大,红润程度越大
sharpnessLevel锐化程度,取值范围为 [0.0,1.0],其中 0.0 表示原始锐度,默认值为 0.0。取值越大,锐化程度越大

运行后效果如下图所示,开了 0.5 参数后的美颜整体画面更加白皙,同时唇色也更加明显。


没开美颜开了美颜

色彩增强


接下来要介绍的一个 API 是色彩增强: setColorEnhanceOptions ,如果是美颜还无法满足你的需求,那么色彩增强 API 可以提供更多参数来调整你的需要的画面风格。


如下代码所示,色彩增强 API 很简单,主要是调整 ColorEnhanceOptionsstrengthLevelskinProtectLevel 参数,也就是调整色彩强度和肤色保护的效果


  _engine.setColorEnhanceOptions(
     enabled: true,
     options: const ColorEnhanceOptions(
         strengthLevel: 6.0, skinProtectLevel: 0.7));

如下图所示,因为摄像头采集到的视频画面可能存在色彩失真的情况,而色彩增强功能可以通过智能调节饱和度和对比度等视频特性,提升视频色彩丰富度和色彩还原度,最终使视频画面更生动。



开启增强之后画面更抢眼了。



没开增加开了美颜+增强

属性参数
strengthLevel色彩增强程度。取值范围为 [0.0,1.0]。0.0 表示不对视频进行色彩增强。取值越大,色彩增强的程度越大。默认值为 0.5。
skinProtectLevel肤色保护程度。取值范围为 [0.0,1.0]。0.0 表示不对肤色进行保护。取值越大,肤色保护的程度越大。默认值为 1.0。 当色彩增强程度较大时,人像肤色会明显失真,你需要设置肤色保护程度; 肤色保护程度较大时,色彩增强效果会略微降低。 因此,为获取最佳的色彩增强效果,建议动态调节 strengthLevel 和 skinProtectLevel 以实现最合适的效果。

空间音效


其实声音调教才是重头戏,声网既然叫声网,在音频处理上肯定不能落后,在声网 SDK 里就可以通过 enableSpatialAudio 打开空间音效的效果。


_engine.enableSpatialAudio(true);

什么是空间音效?简单说就是特殊的 3D 音效,它可以将音源虚拟成从三维空间特定位置发出,包括听者水平面的前后左右,以及垂直方向的上方或下方。



本质上空间音效就是通过一些声学相关算法计算,模拟实现类似空间 3D 效果的音效实现



同时你还可以通过 setRemoteUserSpatialAudioParams 来配置空间音效的相关参数,如下表格所示,可以看到声网提供了非常丰富的参数来让我们可以自主调整空间音效,例如这里面的 enable_blurenable_air_absorb 效果就很有意思,十分推荐大家去试试。


属性作用
speaker_azimuth远端用户或媒体播放器相对于本地用户的水平角。 取值范围为 [0,360],单位为度,例如 (默认)0 度,表示水平面的正前方;90 度,表示水平面的正左方;180 度,表示水平面的正后方;270 度,表示水平面的正右方;360 度,表示水平面的正前方;
speaker_elevation远端用户或媒体播放器相对于本地用户的俯仰角。 取值范围为 [-90,90],单位为度。(默认)0 度,表示水平面无旋转;-90 度,表示水平面向下旋转 90 度;90 度,表示水平面向上旋转 90 度
speaker_distance远端用户或媒体播放器相对于本地用户的距离,取值范围为 [1,50],单位为米,默认值为 1 米。
speaker_orientation远端用户或媒体播放器相对于本地用户的朝向。 取值范围为 [0,180],单位为度。默认)0 度,表示声源和听者朝向同一方向;180: 180 度,表示声源和听者面对面
enable_blur是否开启声音模糊处理
enable_air_absorb是否开启空气衰减,即模拟声音在空气中传播的音色衰减效果:在一定的传输距离下,高频声音衰减速度快、低频声音衰减速度慢。
speaker_attenuation远端用户或媒体播放器的声音衰减系数,取值范围为[0,1]。 0:广播模式,即音量和音色均不随距离衰减;(0,0.5):弱衰减模式,即音量和音色在传播过程中仅发生微弱衰减;0.5:(默认)模拟音量在真实环境下的衰减,效果等同于不设置 speaker_attenuation 参数;(0.5,1]:强衰减模式,即音量和音色在传播过程中发生迅速衰减
enable_doppler是否开启多普勒音效:当声源与接收声源者之间产生相对位移时,接收方听到的音调会发生变化


音频类的效果这里就无法展示了,强烈推荐大家自己动手去试试。



人声音效


另外一个推荐的 API 就是人声音效:setAudioEffectPreset, 调用该方法可以通过 SDK 预设的人声音效,在不会改变原声的性别特征的前提下,修改用户的人声效果,例如:


_engine.setAudioEffectPreset(AudioEffectPreset.roomAcousticsKtv);

声网 SDK 里预设了非常丰富的 AudioEffectPreset ,如下表格所示,从场景效果如 KTV、录音棚,到男女变声,再到恶搞的音效猪八戒等,可以说是相当惊艳。


参数作用
audioEffectOff原声
roomAcousticsKtvKTV
roomAcousticsVocalConcert演唱会
roomAcousticsStudio录音棚
roomAcousticsPhonograph留声机
roomAcousticsVirtualStereo虚拟立体声
roomAcousticsSpacial空旷
roomAcousticsEthereal空灵
roomAcousticsVirtualSurroundSound虚拟环绕声
roomAcoustics3dVoice3D 人声
voiceChangerEffectUncle大叔
voiceChangerEffectOldman老年男性
voiceChangerEffectBoy男孩
voiceChangerEffectSister少女
voiceChangerEffectGirl女孩
voiceChangerEffectPigking猪八戒
voiceChangerEffectHulk绿巨人
styleTransformationRnbR&B
styleTransformationPopular流行
pitchCorrection电音


PS:为获取更好的人声效果,需要在调用该方法前将 setAudioProfile 的 scenario 设为 audioScenarioGameStreaming(3):


_engine.setAudioProfile(
 profile: AudioProfileType.audioProfileDefault,
 scenario: AudioScenarioType.audioScenarioGameStreaming);


当然,这里需要注意的是,这个方法只推荐用在对人声的处理上,不建议用于处理含音乐的音频数据


最后,完整代码如下所示:


class VideoChatPage extends StatefulWidget {
 const VideoChatPage({Key? key}) : super(key: key);

 @override
 State<VideoChatPage> createState() => _VideoChatPageState();
}

class _VideoChatPageState extends State<VideoChatPage> {
 late final RtcEngine _engine;

 ///初始化状态
 late final Future<bool?> initStatus;

 ///当前 controller
 late VideoViewController currentController;

 ///是否加入聊天
 bool isJoined = false;

 /// 记录加入的用户id
 Map<int, VideoViewController> remoteControllers = {};

 @override
 void initState() {
   super.initState();
   initStatus = _requestPermissionIfNeed().then((value) async {
     await _initEngine();

     ///构建当前用户 currentController
     currentController = VideoViewController(
       rtcEngine: _engine,
       canvas: const VideoCanvas(uid: 0),
    );
     return true;
  }).whenComplete(() => setState(() {}));
}

 Future<void> _requestPermissionIfNeed() async {
   if (Platform.isMacOS) {
     return;
  }
   await [Permission.microphone, Permission.camera].request();
}

 Future<void> _initEngine() async {
   //创建 RtcEngine
   _engine = createAgoraRtcEngine();
   // 初始化 RtcEngine
   await _engine.initialize(const RtcEngineContext(
     appId: appId,
  ));

   _engine.registerEventHandler(RtcEngineEventHandler(
     // 遇到错误
     onError: (ErrorCodeType err, String msg) {
       if (kDebugMode) {
         print('[onError] err: $err, msg: $msg');
      }
    },
     onJoinChannelSuccess: (RtcConnection connection, int elapsed) {
       // 加入频道成功
       setState(() {
         isJoined = true;
      });
    },
     onUserJoined: (RtcConnection connection, int rUid, int elapsed) {
       // 有用户加入
       setState(() {
         remoteControllers[rUid] = VideoViewController.remote(
           rtcEngine: _engine,
           canvas: VideoCanvas(uid: rUid),
           connection: const RtcConnection(channelId: cid),
        );
      });
    },
     onUserOffline:
        (RtcConnection connection, int rUid, UserOfflineReasonType reason) {
       // 有用户离线
       setState(() {
         remoteControllers.remove(rUid);
      });
    },
     onLeaveChannel: (RtcConnection connection, RtcStats stats) {
       // 离开频道
       setState(() {
         isJoined = false;
         remoteControllers.clear();
      });
    },
  ));

   // 打开视频模块支持
   await _engine.enableVideo();
   // 配置视频编码器,编码视频的尺寸(像素),帧率
   await _engine.setVideoEncoderConfiguration(
     const VideoEncoderConfiguration(
       dimensions: VideoDimensions(width: 640, height: 360),
       frameRate: 15,
    ),
  );

   await _engine.startPreview();
}

 @override
 void dispose() {
   _engine.leaveChannel();
   super.dispose();
}

 @override
 Widget build(BuildContext context) {
   return Scaffold(
       appBar: AppBar(),
       body: Stack(
         children: [
           FutureBuilder<bool?>(
               future: initStatus,
               builder: (context, snap) {
                 if (snap.data != true) {
                   return const Center(
                     child: Text(
                       "初始化ing",
                       style: TextStyle(fontSize: 30),
                    ),
                  );
                }
                 return AgoraVideoView(
                   controller: currentController,
                );
              }),
           Align(
             alignment: Alignment.topLeft,
             child: SingleChildScrollView(
               scrollDirection: Axis.horizontal,
               child: Row(
                 ///增加点击切换
                 children: List.of(remoteControllers.entries.map(
                  (e) => InkWell(
                     onTap: () {
                       setState(() {
                         remoteControllers[e.key] = currentController;
                         currentController = e.value;
                      });
                    },
                     child: SizedBox(
                       width: 120,
                       height: 120,
                       child: AgoraVideoView(
                         controller: e.value,
                      ),
                    ),
                  ),
                )),
              ),
            ),
          )
        ],
      ),
       floatingActionButton: FloatingActionButton(
         onPressed: () async {
           // 加入频道
           _engine.joinChannel(
             token: token,
             channelId: cid,
             uid: 0,
             options: const ChannelMediaOptions(
               channelProfile:
                   ChannelProfileType.channelProfileLiveBroadcasting,
               clientRoleType: ClientRoleType.clientRoleBroadcaster,
            ),
          );
        },
      ),
       persistentFooterButtons: [
         ElevatedButton.icon(
             onPressed: () {
               _enableVirtualBackground();
            },
             icon: const Icon(Icons.accessibility_rounded),
             label: const Text("虚拟背景")),
         ElevatedButton.icon(
             onPressed: () {
               _engine.setBeautyEffectOptions(
                 enabled: true,
                 options: const BeautyOptions(
                   lighteningContrastLevel:
                       LighteningContrastLevel.lighteningContrastHigh,
                   lighteningLevel: .5,
                   smoothnessLevel: .5,
                   rednessLevel: .5,
                   sharpnessLevel: .5,
                ),
              );
               //_engine.setRemoteUserSpatialAudioParams();
            },
             icon: const Icon(Icons.face),
             label: const Text("美颜")),
         ElevatedButton.icon(
             onPressed: () {
               _engine.setColorEnhanceOptions(
                   enabled: true,
                   options: const ColorEnhanceOptions(
                       strengthLevel: 6.0, skinProtectLevel: 0.7));
            },
             icon: const Icon(Icons.color_lens),
             label: const Text("增强色彩")),
         ElevatedButton.icon(
             onPressed: () {
               _engine.enableSpatialAudio(true);
            },
             icon: const Icon(Icons.surround_sound),
             label: const Text("空间音效")),
         ElevatedButton.icon(
             onPressed: () {                
               _engine.setAudioProfile(
                   profile: AudioProfileType.audioProfileDefault,
                   scenario: AudioScenarioType.audioScenarioGameStreaming);
               _engine
                  .setAudioEffectPreset(AudioEffectPreset.roomAcousticsKtv);
            },
             icon: const Icon(Icons.surround_sound),
             label: const Text("人声音效")),
      ]);
}

 Future<void> _enableVirtualBackground() async {
   ByteData data = await rootBundle.load("assets/bg.jpg");
   List<int> bytes =
       data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes);
   Directory appDocDir = await getApplicationDocumentsDirectory();
   String p = path.join(appDocDir.path, 'bg.jpg');
   final file = File(p);
   if (!(await file.exists())) {
     await file.create();
     await file.writeAsBytes(bytes);
  }

   await _engine.enableVirtualBackground(
       enabled: true,
       backgroundSource: VirtualBackgroundSource(
           backgroundSourceType: BackgroundSourceType.backgroundImg,
           source: p),
       segproperty:
           const SegmentationProperty(modelType: SegModelType.segModelAi));
   setState(() {});
}
}

最后


本篇的内容作为上一篇的补充,相对来说内容还是比较简单,不过可以看到不管是在画面处理还是在声音处理上,声网 SDK 都提供了非常便捷的 API 实现,特别在声音处理上,因为文章限制这里只展示了简单的 API 介绍,所以强烈建议大家自己尝试下这些音频 API ,真的非常有趣。


作者:无知小猿
来源:juejin.cn/post/7211388928242352184
收起阅读 »

Android悬浮窗自己踩的2个小坑

最近在做一个全局悬浮窗基于ChatGPT应用快Ai,需要悬浮于在其他应用上面,方便从悬浮窗中,和ChatGPT对话后,对ChatGPT返回的内容拖拽到其他应用内部。快Ai应用本身透明,通过WindowManger添加悬浮窗。类似现在很多应用跳转到其他应用,会悬...
继续阅读 »

最近在做一个全局悬浮窗基于ChatGPT应用快Ai,需要悬浮于在其他应用上面,方便从悬浮窗中,和ChatGPT对话后,对ChatGPT返回的内容拖拽到其他应用内部。快Ai应用本身透明,通过WindowManger添加悬浮窗。类似现在很多应用跳转到其他应用,会悬浮一个小按钮,方便用户点击调回自身一样。只不过快Ai窗口比较大,但不全屏。


碰到以下几个问题:


1、悬浮窗中EditText无法获得弹出键盘


主要是没有明白下面两个属性的作用,在网上搜索之后直接设置了。



  • WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE


设置FLAG_NOT_FOCUSABLE,悬浮窗外的点击才有效,会把事件分发给悬浮窗底层的其他应用Activity。但设置了FLAG_NOT_FOCUSABLE,屏幕上除悬浮窗之外的地方也可以点击、但是悬浮窗上的EditText会掉不起键盘。


此时悬浮窗外的事件是不会触发悬浮窗内ViewonToucheEvent函数,可以通过添加WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH标志位,但无法拦截事件。




  • WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL


    屏幕上除了悬浮窗外能够点击、弹窗上的EditText也可以输入、键盘能够弹出来。




所以根据业务需要,我只需要添加WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL标志位即可。


2、悬浮窗无法录音


通过Activity调起Service,然后在Service通过WindowManager添加悬浮窗。在没有进行任何操作,正常情况下,可以调起科大讯飞进行录音转成文字发给ChatGPT。


问题点一:同事为了解决我还没来得及修复的windowManger.removeView改成exitProcess问题,强行进行各种修改,最终还调用了activityfinish函数,把activity干掉。最终导致无法调起科大讯飞的语音识别。总是报录音权限问题,找不到任何的问题点,网上资料都说没有给录音权限,其实是有的。最后通过代码回退,定位到是Activity被干掉了,同事也承认他的愚蠢行为。


问题点二:在进行一些操作,例如授权跳转到设置之后,退出设置回到原先界面,科大讯飞调不起录音,还是报权限问题。在有了问题点一的经验后,在Activity的各个生命周期打印日志,发现但onResume函数没有被回调到,也就是应用在后台运行时,该问题必现。


所以就一顿顿顿搜索后,找到官方文档:
Android 9 对后台运行的应用增加了权限限制。


image.png


解决方法:



  1. 声明为系统应用,没问题。但我们想做通用软件。

  2. 增加前台服务。实测没效果。

  3. 在2的基础上,再添加一个属性:android:foregroundServiceType="microphone"。完美。


<service android:name=".ui.service.AiService"
android:foregroundServiceType="microphone"
/>

image.png


希望本文对君有用!


作者:新小梦
来源:juejin.cn/post/7211116982513811516
收起阅读 »

Android应用被抓包?防护手段需知道

为了提高网络数据传输的安全性,业内采用HTTPS的方式取代原来的HTTP,Android的应用开发也不例外,我们似乎只需要修改一下域名就能完成http到https的切换,无需做其他额外的操作,那么这个HTTPS是如何实现的?是否真的就安全了?在不同的Andro...
继续阅读 »

为了提高网络数据传输的安全性,业内采用HTTPS的方式取代原来的HTTP,Android的应用开发也不例外,我们似乎只需要修改一下域名就能完成http到https的切换,无需做其他额外的操作,那么这个HTTPS是如何实现的?是否真的就安全了?在不同的Android版本上是否有差异?今天我们就来详细研究一下以上的问题。


Tips:本篇旨在讨论HTTPS传输的安全性,应用本地安全不在讨论范畴。


HTTPS原理



诞生背景



首先就是老生常谈的问题,什么是HTTPS,相信大家有有所了解,这里简单提一下:


由于HTTP协议(HyperText Transfer Protocol,超文本传输协议)中,传输的内容是明文的,请求一旦被劫持,内容就会完全暴露,劫持者可以对其进行窃取或篡改,因此这种数据的传输方式存在极大的安全隐患。


因此,在基于HTTP协议的基础上对传输内容进行加密的HTTPS协议(HyperText Transfer Protocol over Secure Socket Layer)便诞生了,这样即使传输的内容被劫持,由于数据是加密的,劫持者没有对应的密钥也很难对内容进行破解,从而提高的传输的安全性。



密钥协商



既然要对传输的内容进行加密,那就要约定好加密的方式与密钥管理。首先在加密方式的选择上,有对称加密非对称加密两种,这两种方式各有有缺。


对称加密:


加密和解密使用相同的密钥,这种效率比较高,但是存在密钥维护的问题。如果密钥通过请求动态下发,会有泄漏的风险。如果密钥存放到Client端,那么密钥变更时就要重新发版更新密钥,而且如果要请求多个服务器就要维护多个密钥,对于服务器端也是同理,这种密钥的维护成本极高。


非对称加密:


加密和解密使用不同的密钥,即公钥与私钥,私钥存放在Server端,不对外公开,公钥是对外公开的,这样无论是公钥打包进Client端还是由Server端动态下发,也无需担心泄漏的问题。但是这种加密方式效率较低。


HTTPS协议中,结合了对称加密和非对称加密两种方式,取其精华,弃其糟粕,发挥了两者各自的优势。


假设目前Server端有一对密钥,公钥A和私钥A,在Client端发起请求时,Server端下发公钥A给Client端,Client端生成一个会话密钥B,并使用公钥A对会话密钥B进行加密传到Server端,Server端使用私钥A进行解密得到会话密钥B,这时Client端和Server端完成了密钥协商工作,之后Client和和Server端交互的数据都使用会话密钥B进行对称加解密。在密钥协商过程中,就算被劫持,由于劫持者没有私钥A,也无法获取协商的会话密钥B,因此保证了数据传输的安全性。


密钥协商过程简图如下:


密钥协商简图.png



CA证书



上面的过程貌似解决了数据传输的安全问题,但依然有一个漏洞,就是如果劫持者篡改了Server端下发给Client端的公钥的情况。


中间人攻击(MITM攻击)简图如下:


中间人攻击简图.png


为了解决Client端对Server端下发公钥的信任问题,出现了一个被称作CA(Certificate Authority)的机构。


CA机构同样拥有采用非对称加密的公钥和私钥,公钥加上一些其他的信息(组织单位、颁发时间、过期时间等)信息被制作成一个cer/pem/crt等格式的文件,被称作证书,这些CA机构用来给其他组织单位签发证书的证书叫做根证书,根证书一般都会被预装在我们的设备中,被无条件信任


以Android设备为例,我们可以在设置 -> 安全 -> 更多安全设置 -> 加密与凭据 -> 信任的凭据中查看当前设备所有的预装的证书。


设备预装的证书.jpeg


如果Server端部署的证书是正规CA机构签发的证书(CA机构一般不会直接用根证书为企业签发域名证书,而是使用根证书生成的中间证书,一般情况下证书链是三级,根证书-中间证书-企业证书),那么我们在进行HTTPS请求的时候,不需要做其他额外操作,Client端获取到Server端下发的证书会自动与系统预装的证书进行校验,以确认证书是否被篡改。


如果Server端的证书是自签的,则需要在Client端自行处理证书校验规则,否则无法正常完成HTTPS请求。


这也是为什么,我们在Android开发网络请求时,无需做额外操作便能丝滑切换到HTTPS,但是这样真的就能保证网络请求的安全性了吗?


真的安全了吗?


经过上面的介绍我们可以了解到,如果Client端(手机、浏览器)中预装了大量正规CA机构的根证书,Server端如果是正规CA签发的证书,理论上是解决了HTTPS通信中双端的信任问题,但是还存在一个问题,就是这些Client端一般都会支持用户自行安装证书,这将会给Android端的网络安全带来哪些风险?接下来我们就继续来聊聊。


由于Android版本更新迭代较快,且不同版本之前差异较大,因此分析这个问题的时候一定要基于一个特定的系统版本,区别分析。Android 5.0(21)之前的版本太过古老,这里就不再进行分析,直接分析5.0之后的版本。


在一个只采用默认配置的的测试项目中进行HTTPS请求的抓包测试,发现在5.0(包括)到7.0(不包括)之间的版本,可以通过中间人或VPN的方式进行抓包,而7.0及以上版本则无法正常抓包,抓包情况如下



7.0以下手机代理抓包情况:



Android7.0以下.jpeg



7.0及以上手机代理抓包情况:



之所以7.0是个分水岭,是因为在Android7.0之前,系统除了对系统内置CA证书无条件信任外,对用户手动安装的CA证书也无条件信任了。


虽然说7.0及以上的设备不再信用用户自行添加的CA证书,安全性比之前的高很多,但是无门却无法阻止那些抓包的人使用7.0之下的手机,除非提高应用的最小支持版本,但这样就意味着要放弃一些用户,显然也不适用于所有情况。


那么如何在保证低版本兼容性的同时兼顾安全性呢,我们接下来继续探讨。


如何更安全


除了系统默认的安全校验之外,我们也可以通过如下手段来提高请求的安全性,让抓包变得更加困难。



禁用代理



该方式适用于所有Android版本。


在网络请求时,通过调用系统API获取当前网络是否设置了代理,如果设置了就终止请求,达到保护数据安全的目的。因为通过中间人的方式进行抓包,需要把网络请求转发到中间人的代理服务器,如果禁止了代理相当于从源头解决了问题。


优势:设置简单,系统API简单调用即可获取代理状态。


劣势:




  1. 会错杀一些因为其他场景而使用代理的用户,导致这样的用户无法正常使用




  2. 通过开启VPN在VPN上设置代理转发到中间人服务器的方式绕过




由于设置禁用代理的方式很容易被绕过且有可能影响正常开启VPN用户的使用,因此不推荐使用该方式。



数据加密



该方式适用于所有Android版本。


对请求传输的数据进行加密,然后再通过HTTPS协议传输。HTTPS本身在传输过程中会生成一个会话密钥,但是这个密钥可以被抓包获取,如果对传输的数据进行一次加密后再传输,即使被抓包也没法解析出真实的数据。


优势:安全性较高,只要密钥没有泄漏,数据被破获的风险较低


劣势:




  1. 修改同时修改Client端和Server端代码,增加加解密逻辑




  2. 加解密操作影响效率且有密钥维护的成本




在对数据安全性要求比较高的接口上,可以采用这种方式对传输内容进行增强保护。



证书单向认证



该方式适用于所有Android版本。


在默认情况下,HTTPS在握手时,Server端下证书等信息到Client端,Client端校验该证书是否为正规CA机构签发,如果是则通过校验。这里我们可以自定义校验规则,可以下载Server端的证书到打包到APK中,在请求时进行证书校验。


https单向认证.png


优势:安全性高。


劣势:证书容易过期,当前企业证书有效期只有1年,需要每年进行续签,Client需要维护证书更新的问题。



证书双向认证



该方式适用于所有Android版本。


在单向认证中,Client端会验证Server端是否安全,但是Server端并没有对Client进行校验,这里可以让Server端对Client也进行一次认证。这种认证需要在单向认证的基础上再额外创建一套证书B,存放在Client端,并在Client端完成对Server端的校验后,把Client端的公钥证书发送到Server端,由Server端进行校验,校验通过后开始密钥协商等后续步骤。


https双向认证.png


优势:安全性非常高!


劣势:




  1. Server端要存放Client端公钥证书,如果一个Server对应多个Client则需要维护多套




  2. 增加了校验成本,会降低相应速度





网络安全配置文件



该方案为google官方推荐的方案,也是一种证书的单向校验,不过在Android7.0及更高版本上,配置简单,只需要再清单文件的application节点下增加一个networkSecurityConfig项,并指向一个按固定的格式创建一个xml文件,即可完成网络安全的校验,体验相当丝滑,唯一美中不足的是该配置不支持7.0以下的版本。


在7.0及以上版本中,在xml文件夹下创建名为network_security_config_wanandroid的网络安全配置文件:


网络安全文件配置.jpeg


该文件只需要在清单文件application节点的networkSecurityConfig中引用该文件即可,如此就完成了对wanandroid.com域名及其所有次级域名的证书单向认证。


在7.0以下版本中:


由于networkSecurityConfig是7.0版本新增的,因此在所有7.0以下的设备上无法生效,所以针对7.0以下的设备只能通过代码进行认证。推荐使用OkHttp:


okHttp进行证书校验.png


需要注意的是,在通过代码配置指定域名的证书校验时,根域名和次级域名需要分别进行配置。


优势:安全性较高,代码改动少。


劣势:本质还是证书的单向认证。



选择要校验的证书



如果说采取了google推荐的方式进行安全校验,那校验证书链中的哪个证书比较合适呢?


理论上来说,当然是校验企业自己的证书最好,即证书链的第三层企业证书


image.png


但是该层证书的有效期比较短,一般每年都要进行重签,重签之后证书的Sha256就会发生变化,这时候就要及时更新Client端中信息,否则就无法正常完成校验。


为了规避证书频繁过期的问题,我们可以直接对根证书进行校验,一般来说,根证书的有效期是比较长的:


image.png


这样就不用担心证书频繁过期的问题了,但是如果再企业证书续签的时候更换了CA机构,那就必须要更新Client端中的根证书信息了,不过这就是另外的一个问题了。



只校验根证书会不会存在风险?



几乎不会,因为正规的CA机构在给一个企业颁发证书的时候,会有审核机制的,一般不会出现错误办法的状况,但在历史上确实出现过CA机构被骗,将证书颁发给了相应域名之外的人。下面截图来自Google官网:


列入黑名单.png


不过这是非常小概率的事件了,因此校验域名+根证书摘要算是即安全又避免了证书频繁过期的问题,再加上google官方的推荐,算的上是最佳解决方案了。


这篇文章就介绍到这里,感谢观看~~


上号.jpg


作者:王远道
来源:juejin.cn/post/7210688688921821221
收起阅读 »

扒一扒抖音是如何做线程优化的

背景 最近在对一些大厂App进行研究学习,在对某音App进行研究时,发现其在线程方面做了一些优化工作,并且其解决的问题也是之前我在做线上卡顿优化时遇到的,因此对其具体实现方案做了深入分析。本文是对其相关源码的研究加上个人理解的一个小结。 问题 创建线程卡顿 在...
继续阅读 »

背景


最近在对一些大厂App进行研究学习,在对某音App进行研究时,发现其在线程方面做了一些优化工作,并且其解决的问题也是之前我在做线上卡顿优化时遇到的,因此对其具体实现方案做了深入分析。本文是对其相关源码的研究加上个人理解的一个小结。


问题


创建线程卡顿


在Java中,真正的内核线程被创建是在执行 start函数的时候, nativeCreate的具体流程可以参考我之前的一篇分析文章 Android虚拟机线程启动过程解析 。这里假设你已经了解了,我们可以可以知道 start()函数底层涉及到一系列的操作,包括 栈内存空间分配、内核线程创建 等操作,这些操作在某些情况下可能出现长耗时现象,比如由于linux系统中,所有系统线程的创建在内核层是由一个专门的线程排队实现,那么是否可能由于队列较长同时内核调度出现问题而出现长耗时问题? 具体的原因因为没有在线下复现过此类问题,因此只能大胆猜测,不过在线上确实收集到一些case, 以下是线上收集到一个阻塞现场样本:



那么是不是不要直接在主线程创建其他线程,而是直接使用线程池调度任务就没有问题? 让我们看下 ThreadPoolExecutor.execute(Runnable command)的源码实现



从文档中可以知道,execute函数的执行在很多情况下会创建(JavaThread)线程,并且跟踪其内部实现后可以发现创建Java线程对象后,也会立即在当前线程执行start函数。



来看一下线上收集到的一个在主线程使用线程池调度任务依旧发生卡顿的现场。



线程数过多的问题


在ART虚拟机中,每创建一个线程都需要为其分配独立的Java栈空间,当Java层未显示设置栈空间大小时,native层会在FixStackSize函数会分配默认的栈空间大小.



从这个实现中,可以看出每个线程至少会占用1M的虚拟内存大小,而在32位系统上,由于每个进程可分配的用户用户空间虚拟内存大小只有3G,如果一个应用的线程数过多,而当进程虚拟内存空间不足时,创建线程的动作就可能导致OOM问题.



另一个问题是某些厂商的应用所能创建的线程数相比原生Android系统有更严格的限制,比如某些华为的机型限制了每个进程所能创建的线程数为500, 因此即使是64位机型,线程数不做控制也可能出现因为线程数过多导致的OOM问题。


优化思路


线程收敛


首先在一个Android App中存在以下几种情况会使用到线程



  • 通过 Thread类 直接创建使用线程

  • 通过 ThreadPoolExecutor 使用线程

  • 通过 ThreadTimer 使用线程

  • 通过 AsyncTask 使用线程

  • 通过 HandlerThread 使用线程


线程收敛的大致思路是, 我们会预先创建上述几个类的实现类,并在自己的实现类中做修改, 之后通过编译期的字节码修改,将App中上述使用线程的地方都替换为我们的实现类。


使用以上线程相关类一般有几种方式:



  1. 直接通过 new 原生类 创建相关实例

  2. 继承原生类,之后在代码中 使用 new 指令创建自己的继承类实例


因此这里的替换包括:



  • 修改类的继承关系,比如 将所有 继承 Thread类的地方,替换为 我们实现 的 PThread

  • 修改上述几种类直接创建实例的地方,比如将代码中存在 new ThreadPoolExecutor(..) 调用的地方替换为 我们实现的 PThreadPoolExecutor


通过字码码修改,将代码中所有使用线程的地方替换为我们的实现类后,就可以在我们的实现类做一些线程收敛的操作。


Thread类 线程收敛


在Java虚拟机中,每个Java Thread 都对应一个内核线程,并且线程的创建实际上是在调用 start()函数才开始创建的,那么我们其实可以修改start()函数的实现,将其任务调度到指定的一个线程池做执行, 示例代码如下


class ThreadProxy : Thread() {
override fun start() {
SuperThreadPoolExecutor.execute({
this@ThreadProxy.run()
}, priority = priority)
}
}

线程池 线程收敛


由于每个ThreadPoolExecutor实例内部都有独立的线程缓存池,不同ThreadPoolExecutor实例之间的缓存互不干扰,在一个大型App中可能存在非常多的线程池,所有的线程池加起来导致应用的最低线程数不容小视。


另外也因为线程池是独立的,线程的创建和回收也都是独立的,不能从整个App的任务角度来调度。举个例子: 比如A线程池因为空闲正在释放某个线程,同时B线程池确可能正因为可工作线程数不足正在创建线程,如果可以把所有的线程池合并成 一个统一的大线程池,就可以避免类似的场景。


核心的实现思路为:



  1. 首先将所有直接继承 ThreadPoolExecutor的类替换为 继承 ThreadPoolExecutorProxy,以及代码中所有new ThreadPoolExecutor(..)类 替换为 new ThreadPoolExecutorProxy(...)

  2. ThreadPoolExecutorProxy 持有一个 大线程池实例 BigThreadPool ,该线程池实例为应用中所有线程池共用,因此其核心线程数可以根据应用当前实际情况做调整,比如如果你的应用当前线程数平均是200,你可以将BigThreadPool 核心线程设置为150后,再观察其调度情况。

  3. 在 ThreadPoolExecutorProxy 的 addWorker 函数中,将任务调度到 BigThreadPool中执行



AsyncTask 线程收敛


对于AsyncTask也可以用同样的方式实现,在execute1函数中调度到一个统一的线程池执行



public abstract class AsyncTaskProxy<Params,Progress,Result> extends AsyncTask<Params,Progress,Result>{

private static final Executor THREAD_POOL_EXECUTOR = new PThreadPoolExecutor(0,20,
3, TimeUnit.MILLISECONDS,
new SynchronousQueue<>(),new DefaultThreadFactory("PThreadAsyncTask"));


public static void execute(Runnable runnable){
THREAD_POOL_EXECUTOR.execute(runnable);
}

/**
* TODO 使用插桩 将所有 execute 函数调用替换为 execute1
* @param params The parameters of the task.
* @return This instance of AsyncTask.
*/

public AsyncTask<Params, Progress, Result> execute1(Params... params) {
return executeOnExecutor(THREAD_POOL_EXECUTOR,params);
}


}

Timer类


Timer类一般项目中使用的地方并不多,并且由于Timer一般对任务间隔准确性有比较高的要求,如果收敛到线程池执行,如果某些Timer类执行的task比较耗时,可能会影响原业务,因此暂不做收敛。


卡顿优化


针对在主线程执行线程创建可能会出现的阻塞问题,可以判断下当前线程,如果是主线程则调度到一个专门负责创建线程的线程进行工作。


    private val asyncExecuteHandler  by lazy {
val worker = HandlerThread("asyncExecuteWorker")
worker.start()
return@lazy Handler(worker.looper)
}


fun execute(runnable: Runnable, priority: Int) {
if (Looper.getMainLooper().thread == Thread.currentThread() && asyncExecute
){
//异步执行
asyncExecuteHandler.post {
mExecutor.execute(runnable,priority)
}
}else{
mExecutor.execute(runnable, priority)
}

}

32位系统线程栈空间优化


在问题分析中的环节中,我们已经知道 每个线程至少需要占用 1M的虚拟内存,而32位应用的虚拟内存空间又有限,如果希望在线程这里挤出一点虚拟内存空间来,可以参考微信的一个方案, 其利用PLT hook需改了创建线程时的栈空间大小。


而在另一篇 juejin.cn/post/720930… 技术文章中,也介绍了另一个取巧的方案 :在Java层直接配置一个 负值,从而起到一样的效果



OOM了? 我还能再抢救下!


针对在创建线程时由于内存空间不足或线程数限制抛出的OOM问题,可以做一些兜底处理, 比如将任务调度到一个预先创建的线程池进行排队处理, 而这个线程池核心线程和最大线程是一致的 因此不会出现创建线程的动作,也就不会出现OOM异常了。



另外由于一个应用可能会存在非常多的线程池,每个线程池都会设置一些核心线程数,要知道默认情况下核心线程是不会被回收的,即使一直处于空闲状态,该特性是由线程池的 allowCoreThreadTimeOut控制。



该参数值可通过 allowCoreThreadTimeOut(value) 函数修改



从具体实现中可以看出,当value值和当前值不同 且 value 为true时 会触发 interruptIdleWorkers()函数, 在该函数中,会对空闲Worker 调用 interrupt来中断对应线程



因此当创建线程出现OOM时,可以尝试通过调用线程池的 allowCoreThreadTimeOut 来触发 interruptIdleWorkers 实现空闲线程的回收。 具体实现代码如下:



因此我们可以在每个线程池创建后,将这些线程池用弱引用队列保存起来,当线程start 或者某个线程池execute 出现OOM异常时,通过这种方式来实现线程回收。


线程定位


线程定位 主要是指在进行问题分析时,希望直接从线程名中定位到创建该线程的业务,关于此类优化的文章网上已经介绍的比较多了,基本实现是通过ASM 修改调用函数,将当前类的类名或类名+函数名作为兜底线程名设置。这里就不详细介绍了,感兴趣的可以看 booster 中的实现



字节码修改工具


前文讲了一些优化方式,其中涉及到一个必要的操作是进行字节码修改,这些需求可以概括为如下



  • 替换类的继承关系,比如将 所有继承于 java.lang.Thread的类,替换为我们自己实现的 ProxyThread

  • 替换 new 指令的实例类型,比如将代码中 所有 new Thread(..) 的调用替换为 new ProxyThread(...)


针对这些通用的修改,没必要每次遇到类似需求时都 进行插件的单独开发,因此我将这种修改能力集成到 LanceX插件中,我们可以通过以下 注解方便实现上述功能。


替换 new 指令


@Weaver
@Group("threadOptimize")
public class ThreadOptimize {

@ReplaceNewInvoke(beforeType = "java.lang.Thread",
afterType = "com.knightboost.lancetx.ProxyThread")
public static void replaceNewThread(){
}

}

这里的 beforeType表示原类型,afterType 表示替换后的类型,使用该插件在项目编译后,项目中的如下源码



会被自动替换为



替换类的继承关系


@Weaver
@Group("threadOptimize")
public class ThreadOptimize {

@ChangeClassExtends(
beforeExtends = "java.lang.Thread",
afterExtends = "com.knightboost.lancetx.ProxyThread"
)
public void changeExtendThread(){};



}

这里的beforeExtends表示 原继承父类,afterExtends表示修改后的继承父类,在项目编译后,如下源码



会被自动替换为



总结


本文主要介绍了有关线程的几个方面的优化



  • 主线程创建线程耗时优化

  • 线程数收敛优化

  • 线程默认虚拟空间优化

  • OOM优化


这些不同的优化手段需要根据项目的实际情况进行选择,比如主线程创建线程优化的实现方面比较简单、影响面也比较低,可以优先实施。 而线程数收敛需要涉及到字节码插桩、各种对象代理 复杂度会高一些,可以根据当前项目的实际线程数情况再考虑是否需要优化。


线程OOM问题主要出现在低端设备 或一些特定厂商的机型上,可能对于某些大厂的用户基数来说有一定的收益,如果你的App日活并没有那么大,这个优化的优先级也是较低的。


性能优化专栏历史文章:


文章地址
监控Android Looper Message调度的另一种姿势juejin.cn/post/713974…
Android 高版本采集系统CPU使用率的方式juejin.cn/post/713503…
Android 平台下的 Method Trace 实现及应用juejin.cn/post/710713…
Android 如何解决使用SharedPreferences 造成的卡顿、ANR问题juejin.cn/post/705476…
基于JVMTI 实现性能监控juejin.cn/post/694278…

参考资料


1.某音App


2.内核线程创建流程


3.juejin.cn/post/720930… 虚拟内存优化: 线程 + 多进程优化


4.github.com/didi/booste…


作者:卓修武K
来源:juejin.cn/post/7212446354920407096
收起阅读 »

Android无需权限调起系统相机拍照

在进行一些小型APP的开发,或者是对拍照界面没有自定义要求时,我们可以用调起系统相机的方式快速完成拍照需求 和不需读写权限进行读写操作的方案一样,都是通过Intent启动系统的activity让用户进行操作,系统再将用户操作的结果告诉我们,因为过程对APP是完...
继续阅读 »

在进行一些小型APP的开发,或者是对拍照界面没有自定义要求时,我们可以用调起系统相机的方式快速完成拍照需求


和不需读写权限进行读写操作的方案一样,都是通过Intent启动系统的activity让用户进行操作,系统再将用户操作的结果告诉我们,因为过程对APP是完全透明的,所以不会侵犯用户隐私。


有两种方法可以调起系统相机拍照获取图片,我们先讲比较简单的一种


1、直接获取用户拍照结果

val launcher = registerForActivityResult(ActivityResultContracts.TakePicturePreview()) {bitmap->
bitmap ?: return@registerForActivityResult
vm.process(bitmap)
}

launcher.launch("image/*")

这个在旧版本的API中就等于


startActivityForResult(Intent(MediaStore.ACTION_IMAGE_CAPTURE),CODE)

等到用户完成拍照,返回我们的activity时,我们就可以得到一张经过压缩的bitmap。这个方法很简单,它的缺点就是获得的bitmap像素太低了,如果对图片像素有要求的话需要使用第二种方法


2、用户拍照之后指定相机将未压缩的图片存放到我们指定的目录

var uri: Uri? = null

val launcher =
registerForActivityResult(ActivityResultContracts.TakePicture()) {
if(it){
uri?.let { it1 -> vm.process(it1) }
}
}

val picture = File(externalCacheDir?.path, "picture")
picture.mkdirs()
uri = FileProvider.getUriForFile(
this,
"${BuildConfig.APPLICATION_ID}.fileprovider",
File(picture, "cache")
)
launcher.launch(uri)

这里我逐行进行解释:



  1. 首先,我们需要指定拍摄的照片要存到哪,所以我们先指定图片的存放路径为externalCacheDir.path/picture/cache 注意这张图片在文件系统中的名字就叫做cache了(没有文件后缀)。

  2. 然后我们通过FileProvider构建一个有授权的Uri给系统相机,相机程序拿到我们的临时授权,才有权限将文件存放到APP的私有目录。

  3. 系统相机拍照完成之后就会走到回调,如果resultCode为RESULT_OK才说明用户成功拍照并保存图片了。这样我们就能得到一张系统相机拍出来的原图的Uri,这样我们就可以用这张图片去处理业务了。


注意:使用方法二需要用到FileProvider,所以我们还要在AndroidManifest里声明


<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/provider_paths" />
</provider>

@xml/provider_paths是我们授权访问的文件路径,这里我写的是


<paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-path name="external_files" path="."/>
</paths>

关于这个"path.xml",其实还有一些可以补充说明的,后面有空会补上,这里我简单说明一下:


因为我们创建临时文件的时候,文件指定的目录是externalCacheDir?.path,对应的path就是external-cache-path,表示我们要临时授权的目录是externalCacheDir,如果文件目录指定的是其他路径,那path节点也需要改成代表对应文件夹的节点,这样其他应用才能访问到

作者:用户5944254635000
来源:juejin.cn/post/7211400484104388663
我们APP的私有目录

收起阅读 »

从framework角度看app保活问题

问题背景 最近在群里看到群友在讨论app保活的问题,回想之前做应用(运动类)开发时也遇到过类似的需求,于是便又来了兴趣,果断加入其中,和群友展开了激烈的讨论 不少群友的想法和我当初的想法一样,这特么保活不是看系统的心情么,系统想让谁活谁才能活,作为app开发...
继续阅读 »

问题背景


最近在群里看到群友在讨论app保活的问题,回想之前做应用(运动类)开发时也遇到过类似的需求,于是便又来了兴趣,果断加入其中,和群友展开了激烈的讨论


保活


不少群友的想法和我当初的想法一样,这特么保活不是看系统的心情么,系统想让谁活谁才能活,作为app开发者,根本无能为力,可真的是这样的吗?


保活方案


首先,我整理了从古到今,app开发者所使用过的以及当前还在使用的保活方式,主要思路有两个:保活和复活


保活的方案有:



  • 1像素惨案




  • 后台无声音乐




  • 前台service




  • 心跳机制




  • socket长连接




  • 无障碍服务




  • ......




复活的方案有:


  • 双进程守护(java层和native层)

  • JobScheduler定时任务

  • 推送/相互唤醒

  • ......


不难看出,app开发者为了能让自己的应用多存活一会儿,可谓是绞尽脑汁,但即使这样,随着Android系统升级,尤其是进入8.0之后,系统对应用的限制越来越高,传统的保活方式已经不生效,这让Android开发者手足无措,于是乎,出现了一种比较和谐的保活方式:



  • 引导用户开启手机白名单


这也是目前绝大多数应用所采用的的方式,相对于传统黑科技而言,此方式显得不那么流氓,比较容易被用户所接受。


但跟微信这样的国民级应用比起来,保活效果还是差了一大截,那么微信是怎么实现保活的呢?或者回到我们开头的问题,应用的生死真的只能靠系统调度吗?开发者能否干预控制呢?


进程调度原则


解开这个疑问之前,我们需要了解一下Android系统进程调度原则,主要介绍framework中承载四大组件的进程是如何根据组件状态而动态调节自身状态的。进程有两个比较重要的状态值:




  • oom_adj,定义在frameworks/base/services/core/java/com/android/server/am/ProcessList.java当中




  • procState,定义在frameworks/base/core/java/android/app/ActivityManager.java当中




OOM_ADJ

以Android10的源码为例,oom_adj划分为20级,取值范围[-10000,1001],Android6.0以前的取值范围是[-17,16]




  • oom_adj值越大,优先级越低




  • oom_adj<0的进程都是系统进程。




public final class ProcessList {
static final String TAG = TAG_WITH_CLASS_NAME ? "ProcessList" : TAG_AM;

// The minimum time we allow between crashes, for us to consider this
// application to be bad and stop and its services and reject broadcasts.
static final int MIN_CRASH_INTERVAL = 60 * 1000;

// OOM adjustments for processes in various states:

// Uninitialized value for any major or minor adj fields
static final int INVALID_ADJ = -10000;

// Adjustment used in certain places where we don't know it yet.
// (Generally this is something that is going to be cached, but we
// don't know the exact value in the cached range to assign yet.)
static final int UNKNOWN_ADJ = 1001;

// This is a process only hosting activities that are not visible,
// so it can be killed without any disruption.
static final int CACHED_APP_MAX_ADJ = 999;
static final int CACHED_APP_MIN_ADJ = 900;

// This is the oom_adj level that we allow to die first. This cannot be equal to
// CACHED_APP_MAX_ADJ unless processes are actively being assigned an oom_score_adj of
// CACHED_APP_MAX_ADJ.
static final int CACHED_APP_LMK_FIRST_ADJ = 950;

// Number of levels we have available for different service connection group importance
// levels.
static final int CACHED_APP_IMPORTANCE_LEVELS = 5;

// The B list of SERVICE_ADJ -- these are the old and decrepit
// services that aren't as shiny and interesting as the ones in the A list.
static final int SERVICE_B_ADJ = 800;

// This is the process of the previous application that the user was in.
// This process is kept above other things, because it is very common to
// switch back to the previous app. This is important both for recent
// task switch (toggling between the two top recent apps) as well as normal
// UI flow such as clicking on a URI in the e-mail app to view in the browser,
// and then pressing back to return to e-mail.
static final int PREVIOUS_APP_ADJ = 700;

// This is a process holding the home application -- we want to try
// avoiding killing it, even if it would normally be in the background,
// because the user interacts with it so much.
static final int HOME_APP_ADJ = 600;

// This is a process holding an application service -- killing it will not
// have much of an impact as far as the user is concerned.
static final int SERVICE_ADJ = 500;

// This is a process with a heavy-weight application. It is in the
// background, but we want to try to avoid killing it. Value set in
// system/rootdir/init.rc on startup.
static final int HEAVY_WEIGHT_APP_ADJ = 400;

// This is a process currently hosting a backup operation. Killing it
// is not entirely fatal but is generally a bad idea.
static final int BACKUP_APP_ADJ = 300;

// This is a process bound by the system (or other app) that's more important than services but
// not so perceptible that it affects the user immediately if killed.
static final int PERCEPTIBLE_LOW_APP_ADJ = 250;

// This is a process only hosting components that are perceptible to the
// user, and we really want to avoid killing them, but they are not
// immediately visible. An example is background music playback.
static final int PERCEPTIBLE_APP_ADJ = 200;

// This is a process only hosting activities that are visible to the
// user, so we'd prefer they don't disappear.
static final int VISIBLE_APP_ADJ = 100;
static final int VISIBLE_APP_LAYER_MAX = PERCEPTIBLE_APP_ADJ - VISIBLE_APP_ADJ - 1;

// This is a process that was recently TOP and moved to FGS. Continue to treat it almost
// like a foreground app for a while.
// @see TOP_TO_FGS_GRACE_PERIOD
static final int PERCEPTIBLE_RECENT_FOREGROUND_APP_ADJ = 50;

// This is the process running the current foreground app. We'd really
// rather not kill it!
static final int FOREGROUND_APP_ADJ = 0;

// This is a process that the system or a persistent process has bound to,
// and indicated it is important.
static final int PERSISTENT_SERVICE_ADJ = -700;

// This is a system persistent process, such as telephony. Definitely
// don't want to kill it, but doing so is not completely fatal.
static final int PERSISTENT_PROC_ADJ = -800;

// The system process runs at the default adjustment.
static final int SYSTEM_ADJ = -900;

// Special code for native processes that are not being managed by the system (so
// don't have an oom adj assigned by the system).
static final int NATIVE_ADJ = -1000;

// Memory pages are 4K.
static final int PAGE_SIZE = 4 * 1024;

//省略部分代码
}

ADJ级别取值说明(可参考源码注释)
INVALID_ADJ-10000未初始化adj字段时的默认值
UNKNOWN_ADJ1001缓存进程,无法获取具体值
CACHED_APP_MAX_ADJ999不可见activity进程的最大值
CACHED_APP_MIN_ADJ900不可见activity进程的最小值
CACHED_APP_LMK_FIRST_ADJ950lowmemorykiller优先杀死的级别值
SERVICE_B_ADJ800旧的service的
PREVIOUS_APP_ADJ700上一个应用,常见于应用切换场景
HOME_APP_ADJ600home进程
SERVICE_ADJ500创建了service的进程
HEAVY_WEIGHT_APP_ADJ400后台的重量级进程,system/rootdir/init.rc文件中设置
BACKUP_APP_ADJ300备份进程
PERCEPTIBLE_LOW_APP_ADJ250受其他进程约束的进程
PERCEPTIBLE_APP_ADJ200可感知组件的进程,比如背景音乐播放
VISIBLE_APP_ADJ100可见进程
PERCEPTIBLE_RECENT_FOREGROUND_APP_ADJ50最近运行的后台进程
FOREGROUND_APP_ADJ0前台进程,正在与用户交互
PERSISTENT_SERVICE_ADJ-700系统持久化进程已绑定的进程
PERSISTENT_PROC_ADJ-800系统持久化进程,比如telephony
SYSTEM_ADJ-900系统进程
NATIVE_ADJ-1000native进程,不受系统管理

可以通过cat /proc/进程id/oom_score_adj查看目标进程的oom_adj值,例如我们查看电话的adj


dialer_oom_adj


值为935,处于不可见进程的范围内,当我启动电话app,再次查看


dialer_oom_adj_open


此时adj值为0,也就是正在与用户交互的进程


ProcessState

process_state划分为23类,取值范围为[-1,21]


@SystemService(Context.ACTIVITY_SERVICE)
public class ActivityManager {
//省略部分代码
/** @hide Not a real process state. */
public static final int PROCESS_STATE_UNKNOWN = -1;

/** @hide Process is a persistent system process. */
public static final int PROCESS_STATE_PERSISTENT = 0;

/** @hide Process is a persistent system process and is doing UI. */
public static final int PROCESS_STATE_PERSISTENT_UI = 1;

/** @hide Process is hosting the current top activities. Note that this covers
* all activities that are visible to the user. */

@UnsupportedAppUsage
public static final int PROCESS_STATE_TOP = 2;

/** @hide Process is hosting a foreground service with location type. */
public static final int PROCESS_STATE_FOREGROUND_SERVICE_LOCATION = 3;

/** @hide Process is bound to a TOP app. This is ranked below SERVICE_LOCATION so that
* it doesn't get the capability of location access while-in-use. */

public static final int PROCESS_STATE_BOUND_TOP = 4;

/** @hide Process is hosting a foreground service. */
@UnsupportedAppUsage
public static final int PROCESS_STATE_FOREGROUND_SERVICE = 5;

/** @hide Process is hosting a foreground service due to a system binding. */
@UnsupportedAppUsage
public static final int PROCESS_STATE_BOUND_FOREGROUND_SERVICE = 6;

/** @hide Process is important to the user, and something they are aware of. */
public static final int PROCESS_STATE_IMPORTANT_FOREGROUND = 7;

/** @hide Process is important to the user, but not something they are aware of. */
@UnsupportedAppUsage
public static final int PROCESS_STATE_IMPORTANT_BACKGROUND = 8;

/** @hide Process is in the background transient so we will try to keep running. */
public static final int PROCESS_STATE_TRANSIENT_BACKGROUND = 9;

/** @hide Process is in the background running a backup/restore operation. */
public static final int PROCESS_STATE_BACKUP = 10;

/** @hide Process is in the background running a service. Unlike oom_adj, this level
* is used for both the normal running in background state and the executing
* operations state. */

@UnsupportedAppUsage
public static final int PROCESS_STATE_SERVICE = 11;

/** @hide Process is in the background running a receiver. Note that from the
* perspective of oom_adj, receivers run at a higher foreground level, but for our
* prioritization here that is not necessary and putting them below services means
* many fewer changes in some process states as they receive broadcasts. */

@UnsupportedAppUsage
public static final int PROCESS_STATE_RECEIVER = 12;

/** @hide Same as {@link #PROCESS_STATE_TOP} but while device is sleeping. */
public static final int PROCESS_STATE_TOP_SLEEPING = 13;

/** @hide Process is in the background, but it can't restore its state so we want
* to try to avoid killing it. */

public static final int PROCESS_STATE_HEAVY_WEIGHT = 14;

/** @hide Process is in the background but hosts the home activity. */
@UnsupportedAppUsage
public static final int PROCESS_STATE_HOME = 15;

/** @hide Process is in the background but hosts the last shown activity. */
public static final int PROCESS_STATE_LAST_ACTIVITY = 16;

/** @hide Process is being cached for later use and contains activities. */
@UnsupportedAppUsage
public static final int PROCESS_STATE_CACHED_ACTIVITY = 17;

/** @hide Process is being cached for later use and is a client of another cached
* process that contains activities. */

public static final int PROCESS_STATE_CACHED_ACTIVITY_CLIENT = 18;

/** @hide Process is being cached for later use and has an activity that corresponds
* to an existing recent task. */

public static final int PROCESS_STATE_CACHED_RECENT = 19;

/** @hide Process is being cached for later use and is empty. */
public static final int PROCESS_STATE_CACHED_EMPTY = 20;

/** @hide Process does not exist. */
public static final int PROCESS_STATE_NONEXISTENT = 21;
//省略部分代码
}

state级别取值说明(可参考源码注释)
PROCESS_STATE_UNKNOWN-1不是真正的进程状态
PROCESS_STATE_PERSISTENT0持久化的系统进程
PROCESS_STATE_PERSISTENT_UI1持久化的系统进程,并且正在操作UI
PROCESS_STATE_TOP2处于栈顶Activity的进程
PROCESS_STATE_FOREGROUND_SERVICE_LOCATION3运行前台位置服务的进程
PROCESS_STATE_BOUND_TOP4绑定到top应用的进程
PROCESS_STATE_FOREGROUND_SERVICE5运行前台服务的进程
PROCESS_STATE_BOUND_FOREGROUND_SERVICE6绑定前台服务的进程
PROCESS_STATE_IMPORTANT_FOREGROUND7对用户很重要的前台进程
PROCESS_STATE_IMPORTANT_BACKGROUND8对用户很重要的后台进程
PROCESS_STATE_TRANSIENT_BACKGROUND9临时处于后台运行的进程
PROCESS_STATE_BACKUP10备份进程
PROCESS_STATE_SERVICE11运行后台服务的进程
PROCESS_STATE_RECEIVER12运动广播的后台进程
PROCESS_STATE_TOP_SLEEPING13处于休眠状态的进程
PROCESS_STATE_HEAVY_WEIGHT14后台进程,但不能恢复自身状态
PROCESS_STATE_HOME15后台进程,在运行home activity
PROCESS_STATE_LAST_ACTIVITY16后台进程,在运行最后一次显示的activity
PROCESS_STATE_CACHED_ACTIVITY17缓存进程,包含activity
PROCESS_STATE_CACHED_ACTIVITY_CLIENT18缓存进程,且该进程是另一个包含activity进程的客户端
PROCESS_STATE_CACHED_RECENT19缓存进程,且有一个activity是最近任务里的activity
PROCESS_STATE_CACHED_EMPTY20空的缓存进程,备用
PROCESS_STATE_NONEXISTENT21不存在的进程

进程调度算法

frameworks/base/services/core/java/com/android/server/am/OomAdjuster.java中,有三个核心方法用于计算和更新进程的oom_adj值



  • updateOomAdjLocked():更新adj,当目标进程为空,或者被杀则返回false,否则返回true。

  • computeOomAdjLocked():计算adj,计算成功返回true,否则返回false。

  • applyOomAdjLocked():应用adj,当需要杀掉目标进程则返回false,否则返回true。


adj更新时机

也就是updateOomAdjLocked()被调用的时机。通俗的说,只要四大组件被创建或者状态发生变化,或者当前进程绑定了其他进程,都会触发adj更新,具体可在源码中查看此方法被调用的地方,比较多,这里就不列举了


adj的计算过程

computeOomAdjLocked()计算过程相当复杂,将近1000行代码,这里就不贴了,有兴趣可自行查看,总体思路就是根据当前进程的状态,设置对应的adj值,因为状态值很多,所以会有很多个if来判断每个状态是否符合,最终计算出当前进程属于哪种状态。


adj的应用

计算得出的adj值将发送给lowmemorykiller(简称lmk),由lmk来决定进程的生死,不同的厂商,lmk的算法略有不同,下面是源码中对lmk的介绍


/* drivers/misc/lowmemorykiller.c
*
* The lowmemorykiller driver lets user-space specify a set of memory thresholds
* where processes with a range of oom_score_adj values will get killed. Specify
* the minimum oom_score_adj values in
* /sys/module/lowmemorykiller/parameters/adj and the number of free pages in
* /sys/module/lowmemorykiller/parameters/minfree. Both files take a comma
* separated list of numbers in ascending order.
*
* For example, write "0,8" to /sys/module/lowmemorykiller/parameters/adj and
* "1024,4096" to /sys/module/lowmemorykiller/parameters/minfree to kill
* processes with a oom_score_adj value of 8 or higher when the free memory
* drops below 4096 pages and kill processes with a oom_score_adj value of 0 or
* higher when the free memory drops below 1024 pages.
*
* The driver considers memory used for caches to be free, but if a large
* percentage of the cached memory is locked this can be very inaccurate
* and processes may not get killed until the normal oom killer is triggered.
*
* Copyright (C) 2007-2008 Google, Inc.
*
* This software is licensed under the terms of the GNU General Public
* License version 2, as published by the Free Software Foundation, and
* may be copied, distributed, and modified under those terms.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
*/


保活核心思路


根据上面的Android进程调度原则得知,我们需要尽可能降低app进程的adj值,从而减少被lmk杀掉的可能性,而我们传统的保活方式最终目的也是降低adj值。而根据adj等级分类可以看出,通过应用层的方式最多能将adj降到100~200之间,我分别测试了微信、支付宝、酷狗音乐,启动后返回桌面并息屏,测试结果如下


微信测试结果:


weixin_oom_adj


微信创建了两个进程,查看这两个进程的adj值均为100,对应为adj等级表中的VISIBLE_APP_ADJ,此结果为测试机上微信未登录状态测试结果,当换成我的小米8测试后发现,登录状态下的微信有三个进程在运行


weixin_login_oom_adj


后查阅资料得知,进程名为com.tencent.soter.soterserver的进程是微信指纹支付,此进程的adj值居然为-800,上面我们说过,adj小于0的进程为系统进程,那么微信是如何做到创建一个系统进程的,我和我的小伙伴都惊呆了o.o,为此,我对比了一下支付宝的测试结果


支付宝测试结果:


alipay_oom_adj


支付宝创建了六个进程,查看这六个进程的adj值,除了一个为915,其余均为0,怎么肥事,0就意味着正在与用户交互的前台进程啊,我的世界要崩塌了,只有一种可能,支付宝通过未知的黑科技降低了adj值。


酷狗测试结果:


kugou_oom_adj.png


酷狗创建了两个进程,查看这两个进程的adj值分别为700、200,对应为adj等级表中的PREVIOUS_APP_ADJPERCEPTIBLE_APP_ADJ,还好,这个在意料之中。


测试思考


通过上面三个app的测试结果可以看出,微信和支付宝一定是使用了某种保活手段,让自身的adj降到最低,尤其是微信,居然可以创建系统进程,简直太逆天了,这是应用层绝对做不到的,一定是在native层完成的,但具体什么黑科技就不得而知了,毕竟反编译技术不是我的强项。


正当我郁郁寡欢之时,我想起了前两天看过的一篇文章《当 App 有了系统权限,真的可以为所欲为?》,文章讲述了第三方App如何利用CVE漏洞获取到系统权限,然后神不知鬼不觉的干一些匪夷所思的事儿,这让我茅塞顿开,或许这些大厂的app就是利用了系统漏洞来保活的,不然真的就说不通了,既然都能获取到系统权限了,那创建个系统进程不是分分钟的事儿吗,还需要啥厂商白名单。


总结


进程保活是一把双刃剑,增加app存活时间的同时牺牲的是用户手机的电量,内存,cpu等资源,甚至还有用户的忍耐度,作为开发者一定要合理取舍,不要为了保活而保活,即使需要保活,也尽量采用白色保活手段,别让用户手机变板砖,然后再来哭爹骂娘。


参考资料:


探讨Android6.0及以上系统APP常驻内存(保活)实现-争宠篇


探讨Android6.0及以上系统APP常驻内存(保活)实现-复活篇


探讨一种新型的双进程守护应用保活


史上最强Android保活思路:深入剖析腾讯TIM的进程永生技术


当 App 有了系统权限,真的可以为所欲为?


「 深蓝洞察 」2022 年度最“不可赦”漏洞


作者:小迪vs同学
来源:juejin.cn/post/7210375037114138680
收起阅读 »

Android记一次JNI内存泄漏

记一次JNI内存泄漏 前景 在视频项目播放界面来回退出时,会触发内存LeakCanary内存泄漏警告。 分析 查看leakCanary的日志没有看到明确的泄漏点,所以直接取出leakCanary保存的hprof文件,保存目录在日志中有提醒,需要注意的是如果是a...
继续阅读 »

记一次JNI内存泄漏


前景


在视频项目播放界面来回退出时,会触发内存LeakCanary内存泄漏警告。


分析


查看leakCanary的日志没有看到明确的泄漏点,所以直接取出leakCanary保存的hprof文件,保存目录在日志中有提醒,需要注意的是如果是android11系统及以上的保存目录和android11以下不同,android11保存的目录在:


   /data/media/10/Download/leakcanary-包名/2023-03-14_17-19-45_115.hprof 

使用Memory Analyzer Tool(简称MAT) 工具进行分析,需要讲上面的hrof文件转换成mat需要的格式:


   hprof-conv -z 转换的文件 转换后的文件

hprof-conv -z 2023-03-14_17-19-45_115.hprof mat115.hprof

打开MAT,导入mat115文件,等待一段时间。


在预览界面打开Histogram,搜索需要检测的类,如:VideoActivity


screenshot-20230314-204413.png


搜索结果查看默认第一栏,如果没有泄漏,关闭VideoActivity之后,Objects数量一般是零,如果不为零,则可能存在泄漏。


右键Merge Shortest Paths to GC Roots/exclude all phantom/weak/soft etc,references/ 筛选出强引用的对象。


image.png


筛选出结果后,出现com.voyah.cockpit.video.ui.VideoActivity$1 @0x3232332 JIN Global 信息,且无法继续跟踪下去。


screenshot-20230314-205257.png


筛选出结果之后显示有六个VideoActivity对象没有释放,点击该对象也无法看到GC对象路径。(正常的java层内存泄漏能够看到泄漏的对象具体是哪一个)


正常的内存泄漏能够看到具体对象,如图:


image.png
这个MegaDataStorageConfig就是存在内存泄漏。


而我们现在的泄漏确实只知道VideoActivity$1 对象泄漏了,没有具体的对象,这样就没有办法跟踪下去了。


解决办法:


虽然无法继续跟踪,但泄漏的位置说明就是这个VideoActivity1,我们可以解压apk,在包内的class.dex中找到VideoActivity1 ,我们可以解压apk,在包内的class.dex中找到VideoActivity1这个Class类(class.dex可能有很多,一个个找),打开这个class,查看字节码(可以android studio中快捷打开build中的apk),根据【 .line 406 】等信息定位代码的位置,找到泄漏点。


screenshot-20230314-205442.png


screenshot-20230314-205600.png
screenshot-20230314-205523.png


根据方法名、代码行数、类名,直接定位到了存在泄漏的代码:


screenshot-20230314-205730.png


红框区内就是内存泄漏的代码,这个回调是一个三方sdk工具,我使用时进行了注册,在onDestory中反注册,但还是存在内存泄漏。(该对象未使用是我代码修改之后的)


修改方法


将这个回调移动到Application中去,然后进行事件或者回调的方式通知VideoActivity,在VideoActivity的onDestory中进行销毁回调。


修改完之后,多次进入VideoAcitivity然后在退出,导出hprof文件到mat中筛选查看,如图:


image.png


VideoActiviyty的对象已经变成了零,说明开始存在的内存泄漏已经修改好了,使用android proflier工具也能看到在退出videoactivity界面之后主动进行几次gc回收,内存使用量会回归到进入该界面之前。


总结:



  1. LeakCanary工具为辅助,MAT工具进行具体分析。因为LeakCanary工具的监听并不准确,如触发leakcanary泄漏警告时代码已经泄漏了很多次。

  2. 如果能够直接查看泄漏的对象,那是最好修改的,如果不能直接定位泄漏的对象,可以通过泄漏的Class对象在apk解压中找到改class,查看字节码定位具体的代码泄漏位置。

  3. 使用第三方的sdk时,最好使用Application Context,统一分发统一管理,减少内存泄漏。


作者:懵逼树上懵逼果
来源:juejin.cn/post/7210574525665771557
收起阅读 »

Android 指纹识别(给应用添加指纹解锁)

使用指纹 说明 : 指纹解锁在23 的时候,官方就已经给出了api ,但是由于Android市场复杂,无法形成统一,硬件由不同的厂商开发,导致相同版本的软件系统,搭载的硬件千变万化,导致由的机型不支持指纹识别,但是,这也挡不住指纹识别在接下来的时间中进入An...
继续阅读 »

使用指纹



说明 : 指纹解锁在23 的时候,官方就已经给出了api ,但是由于Android市场复杂,无法形成统一,硬件由不同的厂商开发,导致相同版本的软件系统,搭载的硬件千变万化,导致由的机型不支持指纹识别,但是,这也挡不住指纹识别在接下来的时间中进入Android市场的趋势,因为它相比较输入密码或图案,它更加简单,相比较密码或者图案,它更炫酷 ,本文Demo 使用最新的28 支持的androidx 库中的API及最近火热的kotlin语言完成的



需要知道的



  • FingerprintManager : 指纹管理工具类

  • FingerprintManager.AuthenticationCallback :使用验证的时候传入该接口,通过该接口进行验证结果回调

  • FingerprintManager.CryptoObject: FingerprintManager 支持的分装加密对象的类



以上是28以下API 中使用的类 在Android 28版本中google 宣布使用Androidx 库代替Android库,所以在28版本中Android 推荐使用androidx库中的类 所以在本文中我 使用的是推荐是用的FingerprintManagerCompat 二者的使用的方式基本相似



如何使用指纹



  • 开始验证 ,系统默认的每段时间验证指纹次数为5次 次数用完之后自动关闭验证,并且30秒之内不允行在使用验证


验证的方法是authenticate()


/**
*
*@param crypto object associated with the call or null if none required.
* @param flags optional flags; should be 0
* @param cancel an object that can be used to cancel authentication
* @param callback an object to receive authentication events
* @param handler an optional handler for events
**/

@RequiresPermission(android.Manifest.permission.USE_FINGERPRINT)
public void authenticate(@Nullable CryptoObject crypto, int flags,
@Nullable CancellationSignal cancel, @NonNull AuthenticationCallback callback,
@Nullable Handler handler)
{
if (Build.VERSION.SDK_INT >= 23) {
final FingerprintManager fp = getFingerprintManagerOrNull(mContext);
if (fp != null) {
android.os.CancellationSignal cancellationSignal = cancel != null
? (android.os.CancellationSignal) cancel.getCancellationSignalObject()
: null;
fp.authenticate(
wrapCryptoObject(crypto),
cancellationSignal,
flags,
wrapCallback(callback),
handler);
}
}
}



arg1: 用于通过指纹验证取出AndroidKeyStore中key的值
arg2: 系统建议为0




arg3: 取消指纹验证 手动关闭验证 可以调用该参数的cancel方法




arg4:返回验证结果




arg5: Handler fingerprint 中的
消息都是通过handler来传递的 如果不需要则传null 会自动默认创建一个主线程的handler来传递消息



使用指纹识别的条件



  • 添加权限(这个权限不需要在6.0中做处理)

  • 判断硬件是否支持

  • 是否已经设置了锁屏 并且已经有一个被录入的指纹

  • 判断是否至少存在一条指纹信息




通过零碎的知识完成一个Demo


这里写图片描述


指纹识别通过之后跳转到 指定页面


进入之后首先弹出对话框,进行指纹验证


<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">


<ImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:src="@drawable/fingerprint" />


<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginTop="20dp"
android:text="验证指纹" />


<TextView
android:id="@+id/fingerprint_error_tv"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginTop="10dp"
android:maxLines="1" />


<View
android:layout_width="match_parent"
android:layout_height="0.5dp"
android:layout_marginLeft="5dp"
android:layout_marginTop="10dp"
android:layout_marginRight="5dp"
android:background="#696969" />


<TextView
android:id="@+id/fingerprint_cancel_tv"
android:layout_width="wrap_content"
android:layout_height="50dp"
android:layout_gravity="center"
android:gravity="center"
android:text="取消"
android:textSize="16sp" />


</LinearLayout>


使用DialogFragment 完成对话框 新建一个DialogFragment 并且初始化相关的api


 override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
//获取fingerprintManagerCompat对象
fingerprintManagerCompat = FingerprintManagerCompat.from(context!!)
setStyle(DialogFragment.STYLE_NORMAL, android.R.style.Theme_Material_Light_Dialog)
}


在界面显示在前台的时候开始扫描


override fun onResume() {
super.onResume()
startListening()
}
@SuppressLint("MissingPermission")
private fun startListening() {
isSelfCancelled = false
mCancellationSignal = CancellationSignal()
fingerprintManagerCompat.authenticate(FingerprintManagerCompat.CryptoObject(mCipher), 0, mCancellationSignal, object : FingerprintManagerCompat.AuthenticationCallback() {
//验证错误
override fun onAuthenticationError(errMsgId: Int, errString: CharSequence?) {
if (!isSelfCancelled) {
errorMsg.text = errString
if (errMsgId == FingerprintManager.FINGERPRINT_ERROR_LOCKOUT) {
Toast.makeText(mActivity, errString, Toast.LENGTH_SHORT).show()
dismiss()
mActivity.finish()
}
}
}
//成功
override fun onAuthenticationSucceeded(result: FingerprintManagerCompat.AuthenticationResult?) {
MainActivity.startActivity(mActivity, true)
}
//错误时提示帮助,比如说指纹错误,我们将显示在界面上 让用户知道情况
override fun onAuthenticationHelp(helpMsgId: Int, helpString: CharSequence?) {
errorMsg.text = helpString
}
//验证失败
override fun onAuthenticationFailed() {
errorMsg.text = "指纹验证失败,请重试"
}
}, null)
}

在不可见的时候停止验证


if (null != mCancellationSignal) {
mCancellationSignal.cancel()
isSelfCancelled = true
}

在MainActivity 中首先判断是否验证成功 是 跳转到目标页 否则的话需要进行验证
在这个过程中我们需要做的就是判断是否支持,判断是否满足指纹验证的条件(条件在上面)


if (intent.getBooleanExtra("isSuccess", false)) {
WelcomeActivity.startActivity(this)
finish()
} else {
//判断是否支持该功能
if (supportFingerprint()) {
initKey() //生成一个对称加密的key
initCipher() //生成一个Cipher对象
}
}


验证条件


 if (Build.VERSION.SDK_INT < 23) {
Toast.makeText(this, "系统不支持指纹功能", Toast.LENGTH_SHORT).show()
return false
} else {
val keyguardManager = getSystemService(KeyguardManager::class.java)
val managerCompat = FingerprintManagerCompat.from(this)
if (!managerCompat.isHardwareDetected) {
Toast.makeText(this, "系统不支持指纹功能", Toast.LENGTH_SHORT).show()
return false
} else if (!keyguardManager.isKeyguardSecure) {
Toast.makeText(this, "屏幕未设置锁屏 请先设置锁屏并添加一个指纹", Toast.LENGTH_SHORT).show()
return false
} else if (!managerCompat.hasEnrolledFingerprints()) {
Toast.makeText(this, "至少在系统中添加一个指纹", Toast.LENGTH_SHORT).show()
return false
}
}

必须生成一个加密的key 和一个Cipher对象


//生成Cipher
private fun initCipher() {
val key = keyStore.getKey(DEFAULT_KEY_NAME, null) as SecretKey
val cipher = Cipher.getInstance(KeyProperties.KEY_ALGORITHM_AES + "/"
+ KeyProperties.BLOCK_MODE_CBC + "/"
+ KeyProperties.ENCRYPTION_PADDING_PKCS7)
cipher.init(Cipher.ENCRYPT_MODE, key)
showFingerPrintDialog(cipher)
}
//生成一个key
private fun initKey() {
keyStore = KeyStore.getInstance("AndroidKeyStore")
keyStore.load(null)
val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
val builder = KeyGenParameterSpec.Builder(DEFAULT_KEY_NAME,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
.setBlockModes(KeyProperties.BLOCK_MODE_CBC)
.setUserAuthenticationRequired(true)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_PKCS7)
keyGenerator.init(builder.build())
keyGenerator.generateKey()
}

Demo 是kotlin 写的
Demo地址


作者:狼窝山下的青年
来源:juejin.cn/post/7210220134601572410
收起阅读 »

Android 可视化预览及编辑Json

Android 可视化编辑json JsonPreviewer 项目中涉及到广告开发, 广告的配置是从API动态下发, 广告配置中,有很多业务相关参数,例如关闭或开启、展示间隔、展示时间、重试次数、每日最大显示次数等。 开发时单个广告可能需要多次修改配置来测试...
继续阅读 »


Android 可视化编辑json JsonPreviewer


项目中涉及到广告开发, 广告的配置是从API动态下发, 广告配置中,有很多业务相关参数,例如关闭或开启、展示间隔、展示时间、重试次数、每日最大显示次数等。


开发时单个广告可能需要多次修改配置来测试,为了方便测试,广告配置的json文件,有两种途径修改并生效





    1. 每次抓包修改配置





    1. 本地导入配置,从磁盘读取




但两种方式都有一定弊端



  • 首先测试时依赖电脑修改配置

  • 无法直观预览广告配置


考虑到开发时经常使用的Json格式化工具,既可以直观的预览Json, 还可以在线编辑


那么就考虑将Json格式化工具移植到项目测试模块中


web网页可以处理Json格式化,同理在Android webView 中同样可行, 只需要引入处理格式化的JS代码即可。


查找资料,发现一个很实用的文章可视化编辑json数据——json editor


开始处理


首先准备好WebView的壳子


    //初始化
@SuppressLint("SetJavaScriptEnabled")
private fun initWebView() {
binding.webView.settings.apply {
javaScriptEnabled = true
javaScriptCanOpenWindowsAutomatically = true
setSupportZoom(true)
useWideViewPort = true
builtInZoomControls = true
}
binding.webView.addJavascriptInterface(JsInterface(this@MainActivity), "json_parse")
}

//webView 与 Android 交互
inner class JsInterface(context: Context) {
private val mContext: Context

init {
mContext = context
}

@JavascriptInterface
fun configContentChanged() {
runOnUiThread {
contentChanged = true
}
}

@JavascriptInterface
fun toastJson(msg: String?) {
runOnUiThread { Toast.makeText(mContext, msg, Toast.LENGTH_SHORT).show() }
}

@JavascriptInterface
fun saveConfig(jsonString: String?) {
runOnUiThread {
contentChanged = false
Toast.makeText(mContext, "verification succeed", Toast.LENGTH_SHORT).show()
}
}

@JavascriptInterface
fun parseJsonException(e: String?) {
runOnUiThread {
e?.takeIf { it.isNotBlank() }?.let { alert(it) }
}
}
}


加载json并在WebView中展示



viewModel.jsonData.observe(this) { str ->
if (str?.isNotBlank() == true) {
binding.webView.loadUrl("javascript:showJson($str)")
}
}


WebView 加载预览页面


        binding.webView.webViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) {
super.onPageFinished(view, url)
viewModel.loadAdConfig(this@MainActivity)
}
}

binding.webView.loadUrl("file:///android_asset/preview_json.html")



Json 预览页, preview_json.html实现



<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="jquery.json-viewer.css"
rel="stylesheet" type="text/css">

</head>
<style type="text/css">
#json-display {
margin: 2em 0;
padding: 8px 15px;
min-height: 300px;
background: #ffffff;
color: #ff0000;
font-size: 16px;
width: 100%;
border-color: #00000000;
border:none;
line-height: 1.8;
}
#json-btn {
display: flex;
align-items: center;
font-size: 18px;
width:100%;
padding: 10;

}
#format_btn {
width: 50%;
height: 36px;
}
#save_btn {
width: 50%;
height: 36px;
margin-left: 4em;
}

</style>
<body>
<div style="padding: 2px 2px 2px 2px;">
<div id="json-btn" class="json-btn">
<button type="button" id="format_btn" onclick="format_btn();">Format</button>
<button type="button" id="save_btn" onclick="save_btn();">Verification</button>

</div>
<div>
<pre id="json-display" contenteditable="true"></pre>
</div>
<br>
</div>

<script type="text/javascript" src="jquery.min.js"></script>
<script type="text/javascript" src="jquery.json-viewer.js"></script>
<script>

document.getElementById("json-display").addEventListener("input", function(){
console.log("json-display input");
json_parse.configContentChanged();
}, false);
function showJson(jsonObj){
$("#json-display").jsonViewer(jsonObj,{withQuotes: true});//format json and display
}
function format_btn() {
var my_json_val = $("#json-display").clone(false);
my_json_val.find("a.json-placeholder").remove();
var jsonval = my_json_val.text();
var jsonObj = JSON.parse(jsonval); //parse string to json
$("#json-display").jsonViewer(jsonObj,{withQuotes: true});//format json and display
}


function save_btn() {
var my_json_val = $("#json-display").clone(false);
my_json_val.find("a.json-placeholder").remove();
var jsonval = my_json_val.text();
var saveFailed = false;
try {
var jsonObj = JSON.parse(jsonval); //parse
} catch (e) {
console.error(e.message);
saveFailed = true;
json_parse.parseJsonException(e.message); // throw exception
}
if(!saveFailed) {
json_parse.saveConfig(jsonval);
}
}

</script>
</body>
</html>


这其中有两个问题需注意





    1. 如果value的值是url, 格式化后缺少引号
      从json-viewer.js源码可以发现,源码中会判断value是否是url,如果是则直接输出




处理方式:在json 左右添加上双引号


    if (options.withLinks && isUrl(json)) {
html += '<a href="' + json + '" class="json-string" target="_blank">' + '"' +json + '"' + '</a>';
} else {
// Escape double quotes in the rendered non-URL string.
json = json.replace(/&quot;/g, '
\\&quot;');
html += '<span class="json-string">"' + json + '"</span>';
}




    1. 如果折叠后json-viewer会增加<a>标签,即使使用text()方法获取到纯文本数据,这里面也包含了“n items”的字符串,那么该如何去除掉这些字符串呢?




 var my_json_val = $("#json-display").clone(false);
my_json_val.find("a.json-placeholder").remove();

总结


使用时只需将json文件读取,传入preview_json.html的showJson方法


编辑结束后, 点击Save 即可保存


示例代码 Android 可视化编辑json JsonPreviewer




(可视化编辑json数据——json editor)[blog.51cto.com/u_56500

11/5…]

收起阅读 »

Android 插件化:插件内部跳转

在Android 插件化(加载插件)中,简单的用一个demo 讲了如何加载一个插件,并使用插件里的资源。 那如果我们的插件中有多个页面呢,要怎么办? 其实,也是很简单,还是通过外部 PluginActivity 的 startActivity来实现 一、Lif...
继续阅读 »

在Android 插件化(加载插件)中,简单的用一个demo 讲了如何加载一个插件,并使用插件里的资源。


那如果我们的插件中有多个页面呢,要怎么办?

其实,也是很简单,还是通过外部 PluginActivitystartActivity来实现


一、LifeActivitystartActivity


LifeActivity 这个插件类中定义一个 startActivity 方法,用宿主的 context 调用 startActivity 方法


public void startActivity(Intent intent) {
if (context != null) {
if (intent==null||intent.getComponent()==null)return;
Intent newIntent=new Intent();
String className=intent.getComponent().getClassName();
if (TextUtils.isEmpty(className))return;
Log.e("startActivity","className="+className);
newIntent.putExtra("className",intent.getComponent().getClassName());
context.startActivity(newIntent);
}
}

而对于第一个插件中的页面 TestActivity


image.png


可以看到,插件中的第一个页面 TestActivity 点击打开 插件页面 Test2Activity 时。写法跟我们在Android中的风格是一模一样的。其中的 findViewById 等,只要是用到上下文的,全部替换成宿主的,这里不多赘述了。


image.png


二、重写 PluginActivitystartActivity


注意:由于 Test2Activity 不是一个真正 Activity ,PluginActivitystartActivity 中,就不能打开这个页面,只能再重新打开一个PluginActivity,并将Test2Activity 类的信息再重新加载实例化一次,跟我们第一个加载TestActivity 是一样的。

override fun startActivity(intent: Intent?) {
val className = intent?.getStringExtra("className")
if (className.isNullOrBlank()) return
val newIntent = Intent(this, PluginActivity::class.java)
newIntent.putExtra("className", className)
super.startActivity(newIntent)
}

传入进去的 className 就是 Test2Activity ,在PluginActivity 走生命周期onCreate 时,loadClassActivity ,至此就完成了插件内部的跳转,是不是非常简单。


9b2ee0aed19ba5e77698cb1f9582b93d.gif


三、同理,有 Activity 就会有其他组件


我们可以在插件中自己实现 serviceContentProviderBroadcastReceiver
等等组件,并重写生命周期等方法。原理都非常简单,难的是思想,这些都是插件化中的冰山一脚,我自己的项目中的更加复杂。


由于这样的方式,需要手动创建生命周期管理,和后续Activity启动模式,入栈出栈的管理等等。其实可以使用ASM等字节码来转换,将四大组件转成普通类,这样,开发过程中容易调试,插件生成也相对简单。


四、动态加载


由于插件apk 是可以从外部sdk 等地方加载的,给我们带来很多便利。而且插件部分的资源都是动态的,可以做到热更新的效果,只要我修改了再重新打包下发就行了。后续可以自己实现一套插件管理,用于加载外部apk ,做到热插拔的作用。


作者:大强Dev
来源:juejin.cn/post/7209971268483825722
收起阅读 »

[崩溃] Android应用自动重启

背景 在App开发过程中,我们经常需要自动重启的功能。比如: 登录或登出的时候,为了清除缓存的一些变量,比较简单的方法就是重新启动app。 crash的时候,可以捕获到异常,直接自动重启应用。 在一些debug的场景中,比如设置了一些测试的标记位,需要重启才...
继续阅读 »

背景


在App开发过程中,我们经常需要自动重启的功能。比如:



  • 登录或登出的时候,为了清除缓存的一些变量,比较简单的方法就是重新启动app。

  • crash的时候,可以捕获到异常,直接自动重启应用。

  • 在一些debug的场景中,比如设置了一些测试的标记位,需要重启才能生效,此时可以用自动重启,方便测试。


那我们如何实现自动重启的功能呢?我们都知道如何杀掉进程,但是当我们的进程被杀掉之后,如何唤醒呢?


这篇文章就来和大家介绍一下,实现应用自动重启的几种方法。


方法1 AlarmManager


    private void setAlarmManager(){
Intent intent = new Intent();
intent.setClass(this, MainActivity.class);
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
PendingIntent pendingIntent = PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_ONE_SHOT);
AlarmManager alarmManager = (AlarmManager) getSystemService(Context.ALARM_SERVICE);
alarmManager.set(AlarmManager.RTC, System.currentTimeMillis()+100, pendingIntent);
Process.killProcess(Process.myPid());
System.exit(0);
}

使用AlarmManager实现自动重启的核心思想:创建一个100ms之后的Alarm任务,等Alarm任务到执行时间了,会自动唤醒App。


缺点:



  • 在App被杀和拉起之间,会显示系统Launcher桌面,体验不好。

  • 在高版本不适用


方法2 直接启动Activity


private void restartApp(){
Intent intent = new Intent(this, MainActivity.class);
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
startActivity(intent);
Process.killProcess(Process.myPid());
System.exit(0);
}

缺点:



  • MainActivity必须是Standard模式


方法3 ProcessPhoenix


JakeWharton大神开源了一个叫ProcessPhoenix的库,这个库可以实现无缝重启app。


实现原理其实很简单,我们先讲怎么使用,然后再来分析源码。


使用方法


首先引入ProcessPhoenix库,这个库不需要初始化,可以直接使用。


implementation 'com.jakewharton:process-phoenix:2.1.2'

使用1:如果想重启app后进入首页:


ProcessPhoenix.triggerRebirth(context);

使用2:如果想重启app后进入特定的页面,则需要构造具体页面的intent,当做参数传入:


Intent nextIntent = //...
ProcessPhoenix.triggerRebirth(context, nextIntent);

有一点需要特别注意。



  • 我们通常会在ApplicationonCreate方法中做一系列初始化的操作。

  • 如果使用Phoenix库,需要在onCreate方法中判断,如果当前进程是Phoenix进程,则直接return,跳过初始化的操作。


if (ProcessPhoenix.isPhoenixProcess(this)) {
return;
}

源码


ProcessPhoenix的原理:



  • 当调用triggerRebirth方法的时候,会启动一个透明的Activity,这个Activity运行在:phoenix进程

  • Activity启动后,杀掉主进程,然后用:phoenix进程拉起主进程的Activity

  • 关闭当前Activity,杀掉:phoenix进程


先来看看ManifestActivity的注册代码:


 <activity
android:name=".ProcessPhoenix"
android:theme="@android:style/Theme.Translucent.NoTitleBar"
android:process=":phoenix"
android:exported="false"
/>


可以看到这个Activity确实是在:phoenix进程启动的,且是Translucent透明的。


整个ProcessPhoenix的代码只有不到120行,非常简单。我们来看下triggerRebirth做了什么。


  public static void triggerRebirth(Context context) {
triggerRebirth(context, getRestartIntent(context));
}

不带intenttriggerRebirth,最后也会调用到带intenttriggerRebirth方法。


getRestartIntent会获取主进程的Launch Activity


  private static Intent getRestartIntent(Context context) {
String packageName = context.getPackageName();
Intent defaultIntent = context.getPackageManager().getLaunchIntentForPackage(packageName);
if (defaultIntent != null) {
return defaultIntent;
}
}

所以要调用不带intenttriggerRebirth,必须在当前Appmanifest里,指定Launch Activity,否则会抛出异常。


接着来看看真正的triggerRebirth方法:


  public static void triggerRebirth(Context context, Intent... nextIntents) {
if (nextIntents.length < 1) {
throw new IllegalArgumentException("intents cannot be empty");
}
// 第一个activity添加new_task标记,重新开启一个新的stack
nextIntents[0].addFlags(FLAG_ACTIVITY_NEW_TASK | FLAG_ACTIVITY_CLEAR_TASK);

Intent intent = new Intent(context, ProcessPhoenix.class);
// 这里是为了防止传入的context非Activity
intent.addFlags(FLAG_ACTIVITY_NEW_TASK); // In case we are called with non-Activity context.
// 将待启动的intent作为参数,intent是parcelable的
intent.putParcelableArrayListExtra(KEY_RESTART_INTENTS, new ArrayList<>(Arrays.asList(nextIntents)));
// 将主进程的pid作为参数
intent.putExtra(KEY_MAIN_PROCESS_PID, Process.myPid());
// 启动ProcessPhoenix Activity
context.startActivity(intent);
}

triggerRebirth方法,主要的功能是启动ProcessPhoenix Activity,相当于启动了:phoenix进程。同时,会将nextIntents和主进程的pid作为参数,传给新启动的ProcessPhoenix Activity


下面我们再来看看,ProcessPhoenix ActivityonCreate方法,看看新进程启动后做了什么。


  @Override protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// 首先杀死主进程
Process.killProcess(getIntent().getIntExtra(KEY_MAIN_PROCESS_PID, -1)); // Kill original main process

ArrayList<Intent> intents = getIntent().getParcelableArrayListExtra(KEY_RESTART_INTENTS);
// 再启动主进程的intents
startActivities(intents.toArray(new Intent[intents.size()]));
// 关闭当前Activity,杀掉当前进程
finish();
Runtime.getRuntime().exit(0); // Kill kill kill!
}

:phoenix进程主要做了以下事情:



  • 杀死主进程

  • 用传入的Intent启动主进程的Activity(也可以是Service)

  • 关闭phoenix Activity,杀掉phoenix进程


总结


如果App有自动重启的需求,比较推荐使用ProcessPhoenix的方法。


原理其实非常简单:



  • 启动一个新的进程

  • 杀掉主进程

  • 用新的进程,重新拉起主进程

  • 杀掉新的进程


我们可以直接在工程里引入ProcessPhoenix开源库,也可以自己用代码实现这样的机

作者:尹学姐
来源:juejin.cn/post/7207743145999024165
制,总之都比较简单。

收起阅读 »

一个app到底会创建多少个Application对象

问题背景 最近跟群友讨论一个技术问题: 一个应用开启了多进程,最终到底会创建几个application对象,执行几次onCreate()方法? 有的群友根据自己的想法给出了猜想 甚至有的群友直接咨询起了ChatGPT 但至始至终都没有一个最终的结论。于是...
继续阅读 »

问题背景


最近跟群友讨论一个技术问题:


交流1


一个应用开启了多进程,最终到底会创建几个application对象,执行几次onCreate()方法?


有的群友根据自己的想法给出了猜想


交流2


甚至有的群友直接咨询起了ChatGPT


chatgpt1.jpg


但至始至终都没有一个最终的结论。于是乎,为了弄清这个问题,我决定先写个demo测试得出结论,然后从源码着手分析原因


Demo验证


首先创建了一个app项目,开启多进程


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


<application
android:name=".DemoApplication"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Demo0307"
tools:targetApi="31">

<!--android:process 开启多进程并设置进程名-->
<activity
android:name=".MainActivity"
android:exported="true"
android:process=":remote">

<intent-filter>
<action android:name="android.intent.action.MAIN" />

<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>

</activity>
</application>

</manifest>

然后在DemoApplication的onCreate()方法打印application对象的地址,当前进程名称


public class DemoApplication extends Application {
private static final String TAG = "jasonwan";

@Override
public void onCreate() {
super.onCreate();
Log.d(TAG, "Demo application onCreate: " + this + ", processName=" + getProcessName(this));
}

private String getProcessName(Application app) {
int myPid = Process.myPid();
ActivityManager am = (ActivityManager) app.getApplicationContext().getSystemService(Context.ACTIVITY_SERVICE);
List<ActivityManager.RunningAppProcessInfo> runningAppProcesses = am.getRunningAppProcesses();
for (ActivityManager.RunningAppProcessInfo runningAppProcess : runningAppProcesses) {
if (runningAppProcess.pid == myPid) {
return runningAppProcess.processName;
}
}
return "null";
}
}

运行,得到的日志如下


2023-03-07 11:15:27.785 19563-19563/com.jason.demo0307 D/jasonwan: Demo application onCreate: com.jason.demo0307.DemoApplication@fb06c2d, processName=com.jason.demo0307:remote

查看当前应用所有进程


查看进程1


说明此时app只有一个进程,且只有一个application对象,对象地址为@fb06c2d


现在我们将进程增加到多个,看看情况如何


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


<application
android:name=".DemoApplication"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Demo0307"
tools:targetApi="31">

<!--android:process 开启多进程并设置进程名-->
<activity
android:name=".MainActivity"
android:exported="true"
android:process=":remote">

<intent-filter>
<action android:name="android.intent.action.MAIN" />

<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>

</activity>
<activity
android:name=".TwoActivity"
android:process=":remote2" />

<activity
android:name=".ThreeActivity"
android:process=":remote3" />

<activity
android:name=".FourActivity"
android:process=":remote4" />

<activity
android:name=".FiveActivity"
android:process=":remote5" />

</application>

</manifest>

逻辑是点击MainActivity启动TwoActivity,点击TwoActivity启动ThreeActivity,以此类推。最后我们运行,启动所有Activity得到的日志如下


2023-03-07 11:25:35.433 19955-19955/com.jason.demo0307 D/jasonwan: Demo application onCreate: com.jason.demo0307.DemoApplication@fb06c2d, processName=com.jason.demo0307:remote
2023-03-07 11:25:43.795 20001-20001/com.jason.demo0307 D/jasonwan: Demo application onCreate: com.jason.demo0307.DemoApplication@fb06c2d, processName=com.jason.demo0307:remote2
2023-03-07 11:25:45.136 20046-20046/com.jason.demo0307 D/jasonwan: Demo application onCreate: com.jason.demo0307.DemoApplication@fb06c2d, processName=com.jason.demo0307:remote3
2023-03-07 11:25:45.993 20107-20107/com.jason.demo0307 D/jasonwan: Demo application onCreate: com.jason.demo0307.DemoApplication@fb06c2d, processName=com.jason.demo0307:remote4
2023-03-07 11:25:46.541 20148-20148/com.jason.demo0307 D/jasonwan: Demo application onCreate: com.jason.demo0307.DemoApplication@fb06c2d, processName=com.jason.demo0307:remote5

查看当前应用所有进程


查看进程2


此时app有5个进程,但application对象地址均为@fb06c2d,地址相同意味着它们是同一个对象。


那是不是就可以得出结论,无论启动多少个进程都只会创建一个application对象呢?并不能妄下此定论,我们将MainActivityprocess属性去掉再运行,得到的日志如下


2023-03-07 11:32:10.156 20318-20318/com.jason.demo0307 D/jasonwan: Demo application onCreate: com.jason.demo0307.DemoApplication@5d49e29, processName=com.jason.demo0307
2023-03-07 11:32:15.143 20375-20375/com.jason.demo0307 D/jasonwan: Demo application onCreate: com.jason.demo0307.DemoApplication@fb06c2d, processName=com.jason.demo0307:remote2
2023-03-07 11:32:16.477 20417-20417/com.jason.demo0307 D/jasonwan: Demo application onCreate: com.jason.demo0307.DemoApplication@fb06c2d, processName=com.jason.demo0307:remote3
2023-03-07 11:32:17.582 20463-20463/com.jason.demo0307 D/jasonwan: Demo application onCreate: com.jason.demo0307.DemoApplication@fb06c2d, processName=com.jason.demo0307:remote4
2023-03-07 11:32:18.882 20506-20506/com.jason.demo0307 D/jasonwan: Demo application onCreate: com.jason.demo0307.DemoApplication@fb06c2d, processName=com.jason.demo0307:remote5

查看当前应用所有进程


查看进程3


此时app有5个进程,但有2个application对象,对象地址为@5d49e29和@fb06c2d,且子进程的application对象都相同。


上述所有进程的父进程ID为678,而此进程正是zygote进程


zygote进程


根据上面的测试结果我们目前能得出的结论:



  • 结论1:单进程只创建一个Application对象,执行一次onCreate()方法;

  • 结论2:多进程至少创建2个Application对象,执行多次onCreate()方法,几个进程就执行几次;


结论2为什么说至少创建2个,因为我在集成了JPush的商业项目中测试发现,JPush创建的进程跟我自己创建的进程,Application地址是不同的。


jpush进程


这里三个进程,分别创建了三个Application对象,对象地址分别是@f31ba9d,@2c586f3,@fb06c2d


源码分析


这里需要先了解App的启动流程,具体可以参考《App启动流程》


Application的创建位于frameworks/base/core/java/android/app/ActivityThread.javahandleBindApplication()方法中


	@UnsupportedAppUsage
private void handleBindApplication(AppBindData data) {
long st_bindApp = SystemClock.uptimeMillis();
//省略部分代码

// Note when this process has started.
//设置进程启动时间
Process.setStartTimes(SystemClock.elapsedRealtime(), SystemClock.uptimeMillis());

//省略部分代码

// send up app name; do this *before* waiting for debugger
//设置进程名称
Process.setArgV0(data.processName);
//省略部分代码

// Allow disk access during application and provider setup. This could
// block processing ordered broadcasts, but later processing would
// probably end up doing the same disk access.
Application app;
final StrictMode.ThreadPolicy savedPolicy = StrictMode.allowThreadDiskWrites();
final StrictMode.ThreadPolicy writesAllowedPolicy = StrictMode.getThreadPolicy();
try {
// If the app is being launched for full backup or restore, bring it up in
// a restricted environment with the base application class.
//此处开始创建application对象,注意参数2为null
app = data.info.makeApplication(data.restrictedBackupMode, null);

//省略部分代码
try {
if ("com.jason.demo0307".equals(app.getPackageName())){
Log.d("jasonwan", "execute app onCreate(), app=:"+app+", processName="+getProcessName(app)+", pid="+Process.myPid());
}
//执行application的onCreate方法()
mInstrumentation.callApplicationOnCreate(app);
} catch (Exception e) {
if (!mInstrumentation.onException(app, e)) {
throw new RuntimeException(
"Unable to create application " + app.getClass().getName()
+ ": " + e.toString(), e);
}
}
} finally {
// If the app targets < O-MR1, or doesn't change the thread policy
// during startup, clobber the policy to maintain behavior of b/36951662
if (data.appInfo.targetSdkVersion < Build.VERSION_CODES.O_MR1
|| StrictMode.getThreadPolicy().equals(writesAllowedPolicy)) {
StrictMode.setThreadPolicy(savedPolicy);
}
}
//省略部分代码
}

实际创建过程在frameworks/base/core/java/android/app/LoadedApk.java中的makeApplication()方法中,LoadedApk顾名思义就是加载好的Apk文件,里面包含Apk所有信息,像包名、Application对象,app所在的目录等,这里直接看application的创建过程


	@UnsupportedAppUsage
public Application makeApplication(boolean forceDefaultAppClass,
Instrumentation instrumentation)
{
if ("com.jason.demo0307".equals(mApplicationInfo.packageName)) {
Log.d("jasonwan", "makeApplication: mApplication="+mApplication+", pid="+Process.myPid());
}
//如果已经创建过了就不再创建
if (mApplication != null) {
return mApplication;
}

Trace.traceBegin(Trace.TRACE_TAG_ACTIVITY_MANAGER, "makeApplication");

Application app = null;

String appClass = mApplicationInfo.className;
if (forceDefaultAppClass || (appClass == null)) {
appClass = "android.app.Application";
}

try {
java.lang.ClassLoader cl = getClassLoader();
if (!mPackageName.equals("android")) {
Trace.traceBegin(Trace.TRACE_TAG_ACTIVITY_MANAGER,
"initializeJavaContextClassLoader");
initializeJavaContextClassLoader();
Trace.traceEnd(Trace.TRACE_TAG_ACTIVITY_MANAGER);
}
ContextImpl appContext = ContextImpl.createAppContext(mActivityThread, this);
//反射创建application对象
app = mActivityThread.mInstrumentation.newApplication(
cl, appClass, appContext);
if ("com.jason.demo0307.DemoApplication".equals(appClass)){
Log.d("jasonwan", "create application, app="+app+", processName="+mActivityThread.getProcessName()+", pid="+Process.myPid());
}
appContext.setOuterContext(app);
} catch (Exception e) {
Log.d("jasonwan", "fail to create application, "+e.getMessage());
if (!mActivityThread.mInstrumentation.onException(app, e)) {
Trace.traceEnd(Trace.TRACE_TAG_ACTIVITY_MANAGER);
throw new RuntimeException(
"Unable to instantiate application " + appClass
+ ": " + e.toString(), e);
}
}
mActivityThread.mAllApplications.add(app);
mApplication = app;

if (instrumentation != null) {
try {
//第一次启动创建时,instrumentation为null,不会执行onCreate()方法
instrumentation.callApplicationOnCreate(app);
} catch (Exception e) {
if (!instrumentation.onException(app, e)) {
Trace.traceEnd(Trace.TRACE_TAG_ACTIVITY_MANAGER);
throw new RuntimeException(
"Unable to create application " + app.getClass().getName()
+ ": " + e.toString(), e);
}
}
}

// 省略部分代码
return app;
}

为了看清application到底被创建了几次,我在关键地方埋下了log,TAG为jasonwan的log是我自己加的,编译验证,得到如下log


启动app,进入MainActivity
03-08 17:20:29.965 4069 4069 D jasonwan: makeApplication: mApplication=null, pid=4069
//创建application对象,地址为@c2f8311,当前进程id为4069
03-08 17:20:29.967 4069 4069 D jasonwan: create application, app=com.jason.demo0307.DemoApplication@c2f8311, processName=com.jason.demo0307, pid=4069
03-08 17:20:29.988 4069 4069 D jasonwan: execute app onCreate(), app=:com.jason.demo0307.DemoApplication@c2f8311, processName=com.jason.demo0307, pid=4069
03-08 17:20:29.989 4069 4069 D jasonwan: DemoApplication=com.jason.demo0307.DemoApplication@c2f8311, processName=com.jason.demo0307, pid=4069
03-08 17:20:36.614 4069 4069 D jasonwan: makeApplication: mApplication=com.jason.demo0307.DemoApplication@c2f8311, pid=4069

点击MainActivity,跳转到TwoActivity
03-08 17:20:39.686 4116 4116 D jasonwan: makeApplication: mApplication=null, pid=4116
//创建application对象,地址为@c2f8311,当前进程id为4116
03-08 17:20:39.687 4116 4116 D jasonwan: create application, app=com.jason.demo0307.DemoApplication@c2f8311, processName=com.jason.demo0307:remote2, pid=4116
03-08 17:20:39.688 4116 4116 D jasonwan: execute app onCreate(), app=:com.jason.demo0307.DemoApplication@c2f8311, processName=com.jason.demo0307:remote2, pid=4116
03-08 17:20:39.688 4116 4116 D jasonwan: DemoApplication=com.jason.demo0307.DemoApplication@c2f8311, processName=com.jason.demo0307:remote2, pid=4116
03-08 17:20:39.733 4116 4116 D jasonwan: makeApplication: mApplication=com.jason.demo0307.DemoApplication@c2f8311, pid=4116

点击TwoActivity,跳转到ThreeActivity
03-08 17:20:41.473 4147 4147 D jasonwan: makeApplication: mApplication=null, pid=4147
//创建application对象,地址为@c2f8311,当前进程id为4147
03-08 17:20:41.475 4147 4147 D jasonwan: create application, app=com.jason.demo0307.DemoApplication@c2f8311, processName=com.jason.demo0307:remote3, pid=4147
03-08 17:20:41.475 4147 4147 D jasonwan: execute app onCreate(), app=:com.jason.demo0307.DemoApplication@c2f8311, processName=com.jason.demo0307:remote3, pid=4147
03-08 17:20:41.476 4147 4147 D jasonwan: DemoApplication=com.jason.demo0307.DemoApplication@c2f8311, processName=com.jason.demo0307:remote3, pid=4147
03-08 17:20:41.519 4147 4147 D jasonwan: makeApplication: mApplication=com.jason.demo0307.DemoApplication@c2f8311, pid=4147

点击ThreeActivity,跳转到FourActivity
03-08 17:20:42.966 4174 4174 D jasonwan: makeApplication: mApplication=null, pid=4174
//创建application对象,地址为@c2f8311,当前进程id为4174
03-08 17:20:42.968 4174 4174 D jasonwan: create application, app=com.jason.demo0307.DemoApplication@c2f8311, processName=com.jason.demo0307:remote4, pid=4174
03-08 17:20:42.969 4174 4174 D jasonwan: execute app onCreate(), app=:com.jason.demo0307.DemoApplication@c2f8311, processName=com.jason.demo0307:remote4, pid=4174
03-08 17:20:42.969 4174 4174 D jasonwan: DemoApplication=com.jason.demo0307.DemoApplication@c2f8311, processName=com.jason.demo0307:remote4, pid=4174
03-08 17:20:43.015 4174 4174 D jasonwan: makeApplication: mApplication=com.jason.demo0307.DemoApplication@c2f8311, pid=4174

点击FourActivity,跳转到FiveActivity
03-08 17:20:44.426 4202 4202 D jasonwan: makeApplication: mApplication=null, pid=4202
//创建application对象,地址为@c2f8311,当前进程id为4202
03-08 17:20:44.428 4202 4202 D jasonwan: create application, app=com.jason.demo0307.DemoApplication@c2f8311, processName=com.jason.demo0307:remote5, pid=4202
03-08 17:20:44.429 4202 4202 D jasonwan: execute app onCreate(), app=:com.jason.demo0307.DemoApplication@c2f8311, processName=com.jason.demo0307:remote5, pid=4202
03-08 17:20:44.430 4202 4202 D jasonwan: DemoApplication=com.jason.demo0307.DemoApplication@c2f8311, processName=com.jason.demo0307:remote5, pid=4202
03-08 17:20:44.473 4202 4202 D jasonwan: makeApplication: mApplication=com.jason.demo0307.DemoApplication@c2f8311, pid=4202

结果很震惊,我们在5个进程中创建的application对象,地址均为@c2f8311,也就是至始至终创建的都是同一个Application对象,那么上面的结论2显然并不成立,只是测试的偶然性导致的。


可真的是这样子的吗,这也太颠覆我的三观了,为此我跟群友讨论了这个问题:


不同进程中的多个对象,内存地址相同,是否代表这些对象都是同一个对象?


群友的想法是,java中获取的都是虚拟内存地址,虚拟内存地址相同,不代表是同一个对象,必须物理内存地址相同,才表示是同一块内存空间,也就意味着是同一个对象,物理内存地址和虚拟内存地址存在一个映射关系,同时给出了java中获取物理内存地址的方法Android获取对象地址,主要是利用Unsafe这个类来操作,这个类有一个作用就是直接访问系统内存资源,具体描述见Java中的魔法类-Unsafe,因为这种操作是不安全的,所以被标为了私有,但我们可以通过反射去调用此API, 然后我又去请教了部门搞寄存器的大佬,大佬肯定了群友的想法,于是我添加代码,尝试获取对象的物理内存地址,看看是否相同


public class DemoApplication extends Application {
public static final String TAG = "jasonwan";

@Override
public void onCreate() {
super.onCreate();
Log.d(TAG, "DemoApplication=" + this + ", address=" + addressOf(this) + ", pid=" + Process.myPid());
}

//获取对象的真实物理地址
public static long addressOf(Object o) {
Object[] array = new Object[]{o};
long objectAddress = -1;
try {
Class cls = Class.forName("sun.misc.Unsafe");
Field field = cls.getDeclaredField("theUnsafe");
field.setAccessible(true);
Object unsafe = field.get(null);
Class unsafeCls = unsafe.getClass();
Method arrayBaseOffset = unsafeCls.getMethod("arrayBaseOffset", Object.class.getClass());
int baseOffset = (int) arrayBaseOffset.invoke(unsafe, Object[].class);
Method size = unsafeCls.getMethod("addressSize");
int addressSize = (int) size.invoke(unsafe);
switch (addressSize) {
case 4:
Method getInt = unsafeCls.getMethod("getInt", Object.class, long.class);
objectAddress = (int) getInt.invoke(unsafe, array, baseOffset);
break;
case 8:
Method getLong = unsafeCls.getMethod("getLong", Object.class, long.class);
objectAddress = (long) getLong.invoke(unsafe, array, baseOffset);
break;
default:
throw new Error("unsupported address size: " + addressSize);
}
} catch (Exception e) {
e.printStackTrace();
}
return objectAddress;
}
}

运行后得到如下日志


2023-03-10 11:01:54.043 6535-6535/com.jason.demo0307 D/jasonwan: DemoApplication=com.jason.demo0307.DemoApplication@930d275, address=8050489105119022792, pid=6535
2023-03-10 11:02:22.610 6579-6579/com.jason.demo0307 D/jasonwan: DemoApplication=com.jason.demo0307.DemoApplication@331b3b9, address=8050489105119027136, pid=6579
2023-03-10 11:02:36.369 6617-6617/com.jason.demo0307 D/jasonwan: DemoApplication=com.jason.demo0307.DemoApplication@331b3b9, address=8050489105119029912, pid=6617
2023-03-10 11:02:39.244 6654-6654/com.jason.demo0307 D/jasonwan: DemoApplication=com.jason.demo0307.DemoApplication@331b3b9, address=8050489105119032760, pid=6654
2023-03-10 11:02:40.841 6692-6692/com.jason.demo0307 D/jasonwan: DemoApplication=com.jason.demo0307.DemoApplication@331b3b9, address=8050489105119036016, pid=6692
2023-03-10 11:02:52.429 6729-6729/com.jason.demo0307 D/jasonwan: DemoApplication=com.jason.demo0307.DemoApplication@331b3b9, address=8050489105119038720, pid=6729

可以看到,虽然Application的虚拟内存地址相同,都是331b3b9,但它们的真实物理地址却不同,至此,我们可以得出最终结论



  • 单进程,创建1个application对象,执行一次onCreate()方法

  • 多进程(N),创建N个application对象,执行N次onCreate()方法


作者:小迪vs同学
来源:juejin.cn/post/7208345469658415159
收起阅读 »

Android 获取IP和UA

最近接入了一个新的SDK,初始化接口需要传入当前设备的IP和UA作为参数。本文介绍如何获取设备的IP和UA。 获取IP 使用WIFI联网与不使用WIFI,获取到的IP地址不同。因此,需要先判断当前设备通过哪种方式联网,然后再获取对应的IP地址。 判断网络连接...
继续阅读 »

最近接入了一个新的SDK,初始化接口需要传入当前设备的IP和UA作为参数。本文介绍如何获取设备的IP和UA。


获取IP


使用WIFI联网与不使用WIFI,获取到的IP地址不同。因此,需要先判断当前设备通过哪种方式联网,然后再获取对应的IP地址。



  • 判断网络连接类型


通过ConnectivityManager判断网络连接类型,代码如下:


private fun checkCurrentNetworkType() {
val connectivityManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
connectivityManager.run {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
when (activeNetworkInfo?.type) {
ConnectivityManager.TYPE_MOBILE -> {
// 通过手机流量
}
ConnectivityManager.TYPE_WIFI -> {
// 通过WIFI
}
else -> {}
}
} else {
// Android M 以上建议使用getNetworkCapabilities API
activeNetwork?.let { network ->
getNetworkCapabilities(network)?.let { networkCapabilities ->
if (networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)) {
when {
networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> {
// 通过手机流量
}
networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> {
// 通过WIFI
}
}
}
}
}
}
}
}


  • 获取手机卡联网 IP


通过NetworkInterface获取IPV4地址,代码如下:


NetworkInterface.getNetworkInterfaces().let {
loo@ for (networkInterface in Collections.list(it)) {
for (inetAddresses in Collections.list(networkInterface.inetAddresses)) {
if (!inetAddresses.isLoopbackAddress && !inetAddresses.isLinkLocalAddress) {
// IP地址
val mobileIp = inetAddresses.hostAddress
break@loo
}
}
}
}


  • 获取WIFI联网 IP


通过ConnectivityManagerWifiManager来获取IP地址,代码如下:


private fun getWIFIIp() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
val wifiManager = applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
// IP 地址
val wifiIp = Formatter.formatIpAddress(wifiManager.connectionInfo.ipAddress)
} else {
// Android Q 以上建议使用getNetworkCapabilities API
val connectivityManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
connectivityManager.run {
activeNetwork?.let { network ->
(getNetworkCapabilities(network)?.transportInfo as? WifiInfo)?.let { wifiInfo ->
// IP 地址
val wifiIp = Formatter.formatIpAddress(wifiInfo.ipAddress)
}
}
}
}
}

获取UA


获取设备的UserAgent比较简单,代码如下:


// 系统 UA
System.getProperty("http.agent")

// WebView UA
WebSettings.getDefaultUserAgent(context)

示例


在示例Demo中添加了相关的演示代码。


ExampleDemo github


ExampleDemo gitee


效果如图:


device-2023-03-12-09 -original-original.gif
作者:ChenYhong
来源:juejin.cn/post/7209272192852148282
收起阅读 »

虚拟内存优化:线程+多进程优化

在介绍内存的基础知识的时候,我们讲过在 32 位系统上虚拟内存只有 4G,因为有 1G 是给内核使用的,所以留给应用的只有 3G 了。3G 虽然看起来挺多,但依然会因为不够用而导致应用崩溃。为什么会这样呢? 我们在学习 Java 堆的组成时就知道 MainSp...
继续阅读 »

在介绍内存的基础知识的时候,我们讲过在 32 位系统上虚拟内存只有 4G,因为有 1G 是给内核使用的,所以留给应用的只有 3G 了。3G 虽然看起来挺多,但依然会因为不够用而导致应用崩溃。为什么会这样呢?


我们在学习 Java 堆的组成时就知道 MainSpace 会申请 512M 的虚拟内存,LargeObjectSpace 也会申请 512M 的虚拟内存,这就用掉了 1G 的虚拟内存,再加上其他 Space 和段映射申请的虚拟内存,如 bss 段、text 段以及各种 so 库文件的映射等,这样算下来,3G 的虚拟内存就没剩下多少了。


所以,虚拟内存的优化,在提升程序的稳定性上,是一种很重要的方案。虚拟内存的优化手段也有很多,这一章我们主要介绍 3 种优化方案:




  1. 通过线程治理来优化虚拟内存;




  2. 通过多进程架构来优化虚拟内存;




  3. 通过一些“黑科技”手段来优化虚内存。




方案 1 和 2 相对简单但效果更佳,投入产出比最高,也是我们最常用的。而方案 3 是通过多个“黑科技”的手段来完成虚拟内存的优化,这些手段虽然属于“黑科技”,但还是会用到我们学过的 Native Hook 等技术,所以你理解、吸收起来并不会很难。


那今天我们先介绍 方案 1 和 方案 2 ,方案 3 会在下一章节单独介绍,下面就开始这一章的学习吧。


线程治理


首先,为什么治理线程能优化虚拟内存呢?实际上,即使是一个空线程也会申请 1M 的虚拟空间来作为栈空间大小,我们可以分析 Thread 创建的源码来验证这一点。同时,对线程创建的分析,也能让你能更好的理解后面的优化方案。


线程创建流程


当我们使用线程执行任务时,通常会先调用 new Thread(Runnable runnable) 来创建一个 Thread.java 对象的实例,Thread 的构造函数中会将 stackSize 这个变量设置为 0,这个 stackSize 变量决定了线程栈大小,接着我们便会执行 Thread 实例提供的 start 方法运行这个线程,start 方法中会调用 nativeCreate 这个 Native 函数在系统层创建一个线程并运行。


Thread(ThreadGroup group, String name, int priority, boolean daemon) {
……
this.stackSize = 0;
}

public synchronized void start() {
if (started)
throw new IllegalThreadStateException();
group.add(this);
started = false;
try {
nativeCreate(this, stackSize, daemon);
started = true;
} finally {
try {
if (!started) {
group.threadStartFailed(this);
}
} catch (Throwable ignore) {

}
}
}

通过上面 Start 函数的源码可以看到,nativeCreate 会传入 stackSize。你可能想问,这个 stackSize 不是决定了线程栈空间的大小吗?但是它现在的值为 0,那前面为什么说线程有 1M 大小的栈空间呢?我们接着往下看就能知道答案了。


我们接着看 nativeCreate 的源码实现(),它的实现类是 java_lang_Thread.cc


static void Thread_nativeCreate(JNIEnv* env, jclass, jobject java_thread, jlong stack_size, jboolean daemon) {
Runtime* runtime = Runtime::Current();
if (runtime->IsZygote() && runtime->IsZygoteNoThreadSection()) {
jclass internal_error = env->FindClass("java/lang/InternalError");
CHECK(internal_error != nullptr);
env->ThrowNew(internal_error, "Cannot create threads in zygote");
return;
}

Thread::CreateNativeThread(env, java_thread, stack_size, daemon == JNI_TRUE);
}

nativeCreate 会执行 Thread::CreateNativeThread 函数,这个函数才是最终创建线程的地方,它的实现在 Thread.cc 这个对象中,并且在这个函数中会调用 FixStackSize 方法将 stack_size 调整为 1M,所以前面那个疑问在这里就解决了,即使我们将 stack_size 设置为 0,这里依然会被调整。我们继续往下分析,看看一个线程究竟是怎样被创建出来的?


void Thread::CreateNativeThread(JNIEnv* env, jobject java_peer, size_t stack_size, bool is_daemon) {
……
// 调整 stack_size,默认值为 1 M
stack_size = FixStackSize(stack_size);
……

if (child_jni_env_ext.get() != nullptr) {
pthread_t new_pthread;
pthread_attr_t attr;
child_thread->tlsPtr_.tmp_jni_env = child_jni_env_ext.get();
CHECK_PTHREAD_CALL(pthread_attr_init, (&attr), "new thread");
CHECK_PTHREAD_CALL(pthread_attr_setdetachstate, (&attr, PTHREAD_CREATE_DETACHED),
"PTHREAD_CREATE_DETACHED");
CHECK_PTHREAD_CALL(pthread_attr_setstacksize, (&attr, stack_size), stack_size);
// 创建线程
pthread_create_result = pthread_create(&new_pthread,
&attr,
Thread::CreateCallback,
child_thread);
CHECK_PTHREAD_CALL(pthread_attr_destroy, (&attr), "new thread");

if (pthread_create_result == 0) {
child_jni_env_ext.release(); // NOLINT pthreads API.
return;
}
}

……
}

在上面简化后的代码中我们可以看到,CreateNativeThread 的源码实现最终调用的是 pthread_create 函数,它是一个 Linux 函数,而 pthread_create 函数最终会调用 clone 这个内核函数。clone 函数会根据传入的 stack 大小,通过 mmap 函数申请一块对应大小的虚拟内存,并且创建一个进程。


int clone(int (*fn)(void * arg), void *stack, int flags, void *arg);

所以,对于 Linux 系统来说,一个线程实际是一个精简的进程。我们创建线程时,最终会执行 clone 这个内核函数去创建一个进程,通过查看官方文档也能看到,Clone 函数实际上会创建一个新的进程(These system calls create a new ("child") process, in a manner similar to fork)。


image.png


这里我就不继续深入介绍 Linux 中线程的原理了,如果你有兴趣可以参考这篇文章 《掌握 Android 和 Java 线程原理》。


除了通过线程的创建流程可以证明一个线程需要占用 1M 大小的虚拟内存,我们还能在 maps 文件中证明这一点,还是拿前面篇章提到的“设置”这个系统应用的 maps 文件为例,也能发现 anno:stack_and_tls 也就是线程的虚拟内存,大小为 1M 左右。


image.png


理解了一个线程会占用 1M 大小的虚拟内存,我们自然而然也能想到通过减少线程的数量和减少每个线程所占用的虚拟内存大小来进行优化。接下来,我们就详细了解一下如何实现这两种方案。


减少线程数量


首先是减少线程的数量,我们主要有 2 种手段:




  1. 在应用中使用统一的线程池;




  2. 将应用中的野线程及野线程池进行收敛。




Java 开发者应该都知道线程池,但有的人认知可能不深。实际上,线程池是非常重要的知识点,需要我们熟悉并能熟练使用的。线程池对应用的性能提升有很大的帮助,它可以帮助我们更高效和更合理地使用线程,提升应用的性能。但这里就不详细介绍线程池的使用了,在后面的章节中我们会深入来讲线程池的使用。如果你不熟悉线程池,那我建议你尽快熟悉起来,这里主要针对如何减少线程数这个方向,介绍一下线程池中线程数量的最优设置。


对于线程池,我们需要手动设置核心线程数和最大线程数。核心线程是不会退出的线程,被线程池创建之后会一直存在。最大线程数是该线程池最大能达到的线程数量,当达到最大线程数后,线程池处理新的任务便当做异常,放在兜底逻辑中处理。那么,这两个线程数设置成多少比较合适呢?这个问题也经常作为面试题,需要引起注意。


线程池可以分为 CPU 线程池和 IO 线程池,CPU 线程池用来处理 CPU 类型的任务,如计算,逻辑等操作,需要能够迅速响应,但任务耗时又不能太久。那些耗时较久的任务,如读写文件、网络请求等 IO 操作便用 IO 线程池来处理,IO 线程池专门处理耗时久,响应又不需要很迅速的任务。因此,对于 CPU 的线程池,我们会将核心线程数设置为该手机的 CPU 核数,理想状态下每一个核可以运行一个线程,这样能减少 CPU 线程池的调度损耗又能充分发挥 CPU 性能。


至于 CPU 线程池的最大线程数,和核心线程数保持一致即可。 因为当最大线程数超过了核心线程数时,反倒会降低 CPU 的利用率,因为此时会把更多的 CPU 资源用于线程调度上,如果 CPU 核数的线程数量无法满足我们的业务使用,很大可能就是我们对 CPU 线程池的使用上出了问题,比如在 CPU 线程中执行了 IO 阻塞的任务。


对于 IO 线程池,我们通常会将核心线程数设置为 0 个,而且 IO 线程池并不需要响应的及时性,所以将常驻线程设置为 0 可以减少该应用的线程数量。但并不是说这里一定要设置为 0 个,如果我们的业务 IO 任务比较多,这里也可以设置为不大于 3 个数量。对于 IO 线程池的最大线程数,则可以根据应用的复杂度来设置,如果是中小型应用且业务较简单设置 64 个即可,如果是大型应用,业务多且复杂,可以设置成 128 个


可以看到,如果业务中所有的线程都使用公共线程池,那即使我们将线程的数量设置得非常宽裕,所有线程加起来所占用的虚拟内存也不会超过 200 M。但现实情况下是,应用中总会有大量地方不遵守规范,独自创建线程或者线程池,我们称之为野线程或者野线程池。那如何才能收敛野线程和野线程池呢?


对于简单的应用,我们一个个排查即可,通过全局搜索 new Thread() 线程创建代码,以及全局搜索 newFixedThreadPool 线程池创建代码,然后将不合规范的代码,进行修改收敛进公共线程池即可。


但如果是一个中大型应用,还大量使用了二方库、三方库和 aar 包等,那全局搜索也不管用了,这个时候就需要我们使用字节码操作的方式了,技术方案还是前面文章介绍过的 Lancet,通过 hook 住 newFixedThreadPool 创建线程池的函数,并在函数中将线程池的创建替换成我们公共的线程池,就能完成对线程池的收敛。


public class ThreadPoolLancet {

@TargetClass("java.util.concurrent.Executors")
@Proxy(value = "newFixedThreadPool")
public static ExecutorService newFixedThreadPool(int nThreads, ThreadFactory threadFactory) {
// 替换并返回我们的公共线程池
……
}

@TargetClass("java.util.concurrent.Executors")
@Proxy(value = "newFixedThreadPool")
public static ExecutorService newFixedThreadPool(int nThreads) {
// 替换并返回我们的公共线程池
……
}
}

收敛完了野线程池,那直接使用 new Thread() 创建的野线程又该怎么收敛呢? 对于三方库中的野线程,我们没有太好的收敛手段,因为即使 Thread 的构造函数被 hook 住了,也不能将其收敛到公共线程池中。好在我们使用的三方库大都已经很成熟并经过大量用户验证过,直接使用野线程的地方会很少。我们可以采用 hook 住 Thread 的构造函数并打印堆栈的方式,来确定这个线程是不是通过线程池创建出来的,如果三方库中确实有大量的野线程,那么我们只能将源码下载下来之后手动修改了。


减少线程占用的虚拟内存


在刚才讲解 CreateNativeThread 源码的时候我们讲过,该函数会执行 FixStackSize 方法将 stack_size 调整为 1M。那结合前面各种 hook 的案例,我们很容易就能想到,通过 hook FixStackSize 这个函数,是不是可以将 stack_size 的从 1M 减少到 512 KB 了呢? 当时是可以的,但是这个时候我们没法通过 PLT Hook 的方案来实现了,而是要通过 Inline Hook 方案实现,因为 FixStackSize 是 so 库内部函数的调用,所以只有 FixStackSize 才能实现。


那如果我们想用 PLT Hook 方案来实现可以做到么?其实也可以。CreateNativeThread 是位于 libart.so 中的函数,但是 CreateNativeThread 实际是调用 pthread_create 来创建线程的,而 pthread_create 是位于 libc.so 库中的函数,如果在 CreateNativeThread 中调用 pthread_create ,同样需要通过走 plt 表和 got 表查询地址的方式,所以我们通过 bhook 工具 hook 住 libc.so 库中的 pthread_create 函数,将入参 &attr 中的 stack_size 直接设置成 512KB 即可,实现起来也非常简单,一行代码即可。


static int AdjustStackSize(pthread_attr_t const* attr) {
pthread_attr_setstacksize(attr, 512 * 1024);
}

至于如何 hook 住 pthread_create 这个函数的方法也非常简单,通过 bhook 也是一行代码就能实现,前面的篇章已经讲过怎么使用了,所以这个方案剩下的部分就留给你自己去实践啦。


除了 Native Hook 方案,我们还能在 Java 层通过字节码操作的方式来实现该方案。stack_size 不就是通过 Java 层传递到 Native 层嘛,那我们直接在 Java 层调整 stack_size 的大小就可以了,但在这之前之前,要先看看在 FixStackSize 函数中是如何调整 stack_size 大小的。


static size_t FixStackSize(size_t stack_size) {

if (stack_size == 0) {
stack_size = Runtime::Current()->GetDefaultStackSize();
}

stack_size += 1 * MB;

……

return stack_size;
}

FixStackSize 函数的源码实现很简单,就是通过 stack_size += 1 * MB 来设置 stack_size 的:如果我们传入的 stack_size 为 0 时,默认大小就是 1 M ;如果我们传入的 stack_size 为 -512KB 时,stack_size 就会变成 512KB(1M - 512KB)。那我们是不是只用带有 stackSize 入参的构造函数去创建线程,并且设置 stackSize 为 -512KB 就行了呢?


public Thread(ThreadGroup group, Runnable target, String name,
long stackSize)
{
this(group, target, name, stackSize, null, true);
}

是的,但是因为应用中创建线程的地方太多很难一一修改,而且我们实际不需要这样去修改。前面我们已经将应用中的线程全部收敛到公共线程池中去创建了,所以只需要修改公共线程池中创建的线程方式就可以了,并且线程池刚好也可以让我们自己创建线程,那只需要传入自定义的 ThreadFactory 就能实现需求。


image.pngimage.png

在我们自定义的 ThreadFactory 中,创建 stack_size 为 - 512 kb 的线程,这么一个简单的操作就能减少线程所占用的虚拟内存。


image.png


当我们将应用中线程栈的大小全改成 512 kb 后,可能会导致一些任务比较重的线程出现栈溢出,此时我们可以通过埋点收集会栈溢出的线程,不修改这部分线程的大小即可。总的来说,这是一个容易落地且投入产出比高的方案。


通过上面的方案介绍,我们也可以看到,减少一个线程所占用的虚拟内存的方案很多,可以通过 Native Hook,也可以通过 Java 代码直接修改。我们在做业务或者性能相关的工作时,往往都有多个实现方案,但是我们在敲定最终方案时,始终要选择最简单、最稳定且投入产出比最高的方案。


多进程架构优化


在 Java 堆内存优化中,我们已经讲到了可以通过多进程优化,那对于虚拟内存,我们依然可以通过多进程的架构来优化。比如说,下面这些业务我都建议你放在独立的进程中:




  1. WebView 相关的业务




  2. 小程序相关的业务




  3. Flutter 相关的业务




  4. RN 相关的业务




这些业务都是虚拟内存占用的大户,用独立的进程来承载,会减少很多虚拟内存的占用,也会减少相应的异常情况。并且,将这些业务放在子进程中也很简单,只需要在承载这些业务的 activity 的 mainfest 配置文件中添加 android:process = "子进程名" 即可。需要注意的是,如果我们把业务放在子进程,就没法直接和主进程通信了,需要借助 Binder 跨进程通信的方式来完成。


当然,你还可能会担心把这些业务放在独立进程后,会影响这些业务的启动速度,其实这都可以通过各种优化方案来解决,比如预启动子进程等。在后面速度提升优化的章节中,我们会进行详细讲解。


小结


这一节课我们介绍了两种虚拟内存优化方案,如下图:


image.png


这两种优化方案相对简单,容易落地,投入产出比高。对于一个中小型应用来说,这两个方案几乎能保证 32 位手机上有足够可用的虚拟内存了。如果这两个方案落地后,还是会有因虚拟内存不足导致的应用崩溃问题,我们就需要接着用“黑科技”手段来进行优化了,所以在下一篇文章中,会接着带大家看看有哪些“黑科技”可以用在虚拟内存优化上,它们又能带来什么样的效果!


作者:helson赵子健
来源:juejin.cn/post/7209306358582853688
收起阅读 »

ChatGPT3微调-评论文本情感分析

前言 如果阅读过openai的文档,便能看到对于模型提供了fine-turning功能,即微调。GPT-3已经在互联网中进行了大量文本的预训练,当我们给出少量示例的提示时,它通常可以直观地了解正在尝试执行的任务并生成一个合理的完成。这通常被称为“小样本学习”。...
继续阅读 »

前言


如果阅读过openai的文档,便能看到对于模型提供了fine-turning功能,即微调。GPT-3已经在互联网中进行了大量文本的预训练,当我们给出少量示例的提示时,它通常可以直观地了解正在尝试执行的任务并生成一个合理的完成。这通常被称为“小样本学习”。但我们需要的是一些特定的需求,比如GPT之前未预训练过的数据或是一些私有数据,便可以用微调通过训练来改进小样本学习。


那么微调都可以解决什么问题呢?结合官网的指南,常见的场景有:



  • 文本生成:可以通过提供相关数据集和指导性的文本,生成更加准确和有针对性的文本

  • 文本分类:将一段文本分成多个类别,例如电子邮件分类

  • 情感分析:分析一段文本的情感倾向,是否积极或消极


本文将对情感分析进行一次尝试。


准备数据


先从网上获取了一份关于酒店评论的数据,总共就两列。


第一列是评论内容,第二列1代表积极, 0 代表消极。


image.png


有需要的可以从这里下载,总共是1w条
评论文本情感分析


不过目前的数据是不能直接使用的,我们需要转换成GPT能接受的格式


{"prompt": "", "completion": ""}
{"prompt": "", "completion": ""}
...

数据预处理


openai很贴心的准备一个工具来验证、格式化数据。


安装CLI


pip install --upgrade openai


验证、格式化


openai tools fine_tunes.prepare_data -f


image.png


执行命令后我们看到他返回的提示中告诉了数据一共有300条,并猜测我们是要进行分类模型,同时建议我们用ada模型,拆分出训练集和测试集,加入分隔符(加入分隔符可以帮助模型更好地理解输入的数据),分别会在接下来让我们选择


为所有提示添加后缀分隔符 `->`
- [Recommended] Add a suffix separator ` ->` to all prompts [Y/n]: Y
在完成的开头添加空格字符
- [Recommended] Add a whitespace character to the beginning of the completion [Y/n]: Y
是否要将其拆分为训练和验证集
- [Recommended] Would you like to split into training and validation set? [Y/n]: Y

无特殊情况全部选Y即可。


image.png


之后会生成两个jsonl文件,同时返回一段参考命令、训练预计的时间。


训练模型


选择模型


首先,我们需要对模型进行一个选择,目前只支持davincicuriebabbageada


模型名称描述训练/1K tokens使用/1K tokens
Davinci最强大的GPT-3模型,可以完成其他模型可以完成的任何任务,通常具有更高的质量$0.0300 $0.1200
Curie非常有能力,但速度更快,成本更低,比Davinci更适合$0.0030$0.0120
Babbage适用于简单任务,非常快速,成本更低$0.0006$0.0024
Ada适用于非常简单的任务,通常是GPT-3系列中速度最快,成本最低的模型$0.0004$0.0016

模型的训练和使用都是需要费用的。出于各种原因我们直接选择Ada。


开始训练


在此之前,我们先将key添加到环境变量中


export OPENAI_API_KEY=""


然后再来看一下之前openai给我们参考的代码


openai api fine_tunes.create 
-t ".\train_data_prepared_train.jsonl"
-v ".\train_data_prepared_valid.jsonl"
--compute_classification_metrics
--classification_positive_class " 1"

-t、-v分别是训练集和测试集


--compute_classification_metrics可以计算模型在分类任务中的性能指标,在验证集上计算模型的准确率(accuracy)、精确率(precision)、召回率(recall)和F1分数。这有助于评估模型的性能,并调整微调过程中的超参数和训练策略。


--classification_positive_class是指分类的积极类别或正例


这里还需要一个 -m,来设置选择的模型。我也是手快直接回车了,本来以为会报错,可它正常运行了,但是默认的模型可能不是我们期望的ada,所以我们需要取消这次训练。


3VGP%(3UDXQ@4`7`}0`IG%V.gif


openai api fine_tunes.cancel -i


不过我也是用list查了一下,发现默认的模型是curie


openai api fine_tunes.list


image.png


接下来我们加上模型等待训练完成即可。
如果过程中不小心关掉窗口或者中断了可以用以下命令恢复。


openai api fine_tunes.follow -i


结束训练


耗时25分钟,花费了0.06刀(比预计的少很多)。


image.png


最后我们看一下分析结果

openai api fine_tunes.results -i


image.png


详细的解析大家可以阅读官方文档,这里我们主要看一下准确度

image.png


使用模型


模型的性能指标给出了0.85的准确率,这里用Playground调用测试一下。


除此之外还可以使用CLI命令


openai api completions.create -m -p


或者使用API


const openai = new OpenAIApi(configuration);
const response = await openai.createCompletion({
model: "训练完后模型的id",
prompt: "Say this is a test",
});

输入的prompt末尾需要加上之前CLI自动给我们补齐的分隔符。


从大数据集中随机拿了几个例子,结果是对的,但是输出有问题
image.png


image.png


image.png


D9}6@O_VYQ@W5R)BI)J%Q_W.gif


应该是completion结尾没有分隔符的原因,明天再试试,顺便扩大一下样本。

梅开二度


第二次训练在completion的末尾全都加上了" ###"作为分隔符。


在playgroud、API、CLI中记得设置Stop


image.png


image.png


image.png


R`F1(}96)`OO(YWJD9`{U]D.jpg


作者:Defineee
来源:juejin.cn/post/7208108117837217848
收起阅读 »

Android将so库封装到jar包中并加载其中的so库

说明 因为一些原因,我们提供给客户的sdk,只能是jar包形式的,一些情况下,sdk里面有native库的时候,就不太方便操作了,此篇文章主要解决如何把so库放入jar包里面,如何打包成jar,以及如何加载。 1.如何把so库放入jar包 so库放入jar参考...
继续阅读 »

说明


因为一些原因,我们提供给客户的sdk,只能是jar包形式的,一些情况下,sdk里面有native库的时候,就不太方便操作了,此篇文章主要解决如何把so库放入jar包里面,如何打包成jar,以及如何加载。


1.如何把so库放入jar包


so库放入jar参考此文章ANDROID将SO库封装到JAR包中并加载其中的SO库
放置路径
将so库改成.jet后缀,放置和加载so库的SoLoader类同一个目录下面。


2.如何使用groovy打包jar


打包jar
先把需要打包的class放置到同一个文件夹下面,然后打包即可,利用groovy的copy task完成这项工作非常简单。


3.如何加载jar包里面的so


3.1.首先判断当前jar里面是否存在so

InputStream inputStream = SoLoader.class.getResourceAsStream("/com/dianping/logan/arm64-v8a/liblogan.jet");

如果inputStream不为空就表示存在。


3.2.拷贝

判断是否已经把so库拷贝到手机里面了,如果没有拷贝过就进行拷贝,这个代码逻辑很简单。


public class SoLoader {
private static final String TAG = "SoLoader";

/**
* so库释放位置
*/

public static String getPath() {
String path = GlobalCtx.getApp().getFilesDir().getAbsolutePath();
//String path = GlobalCtx.getApp().getExternalFilesDir(null).getAbsolutePath();
return path;
}

public static String get64SoFilePath() {
String path = SoLoader.getPath();
String v8a = path + File.separator + "jniLibs" + File.separator +
"arm64-v8a" + File.separator + "liblogan.so";
return v8a;
}

public static String get32SoFilePath() {
String path = SoLoader.getPath();
String v7a = path + File.separator + "jniLibs" + File.separator +
"armeabi-v7a" + File.separator + "liblogan.so";
return v7a;
}

/**
* 支持两种模式,如果InputStream inputStream = SoLoader.class.getResourceAsStream("/com/dianping/logan/arm64-v8a/liblogan.jet");
* 返回了空,表示可能此库是aar接入的,普通加载so库就行,不为空,需要拷贝so库,动态加载
*/

public static boolean jarMode() {
boolean jarMode = false;
InputStream inputStream = SoLoader.class.getResourceAsStream("/com/dianping/logan/arm64-v8a/liblogan.jet");
if (inputStream != null) {
jarMode = true;
try {
inputStream.close();
} catch (IOException e) {
e.printStackTrace();
}
}
return jarMode;
}

/**
* 是否已经拷贝过so了
*/

public static boolean alreadyCopySo() {
String v8a = SoLoader.get64SoFilePath();
File file = new File(v8a);
if (file.exists()) {
String v7a = SoLoader.get32SoFilePath();
file = new File(v7a);
return file.exists();
}
return false;
}

/**
* 拷贝logan的so库
*/

public static boolean copyLoganJni() {
boolean load;
File dir = new File(getPath(), "jniLibs");
if (!dir.exists()) {
load = dir.mkdirs();
if (!load) {
return false;
}
}
File subdir = new File(dir, "arm64-v8a");
if (!subdir.exists()) {
load = subdir.mkdirs();
if (!load) {
return false;
}
}
File dest = new File(subdir, "liblogan.so");
//load = copySo("/lib/arm64-v8a/liblogan.so", dest);
load = copySo("/com/dianping/logan/arm64-v8a/liblogan.jet", dest);
if (load) {
subdir = new File(dir, "armeabi-v7a");
if (!subdir.exists()) {
load = subdir.mkdirs();
if (!load) {
return false;
}
}
dest = new File(subdir, "liblogan.so");
//load = copySo("/lib/armeabi-v7a/liblogan.so", dest);
load = copySo("/com/dianping/logan/armeabi-v7a/liblogan.jet", dest);
}
return load;
}

public static boolean copySo(String name, File dest) {
InputStream inputStream = SoLoader.class.getResourceAsStream(name);
if (inputStream == null) {
Log.e(TAG, "inputStream == null");
return false;
}
boolean result = false;
FileOutputStream outputStream = null;
try {
outputStream = new FileOutputStream(dest);
int i;
byte[] buf = new byte[1024 * 4];
while ((i = inputStream.read(buf)) != -1) {
outputStream.write(buf, 0, i);
}
result = true;
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
inputStream.close();
} catch (IOException e) {
e.printStackTrace();
}
if (outputStream != null) {
try {
outputStream.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
return result;
}

}

3.3.加载

首先判断当前应用是32位还是64位Process.is64Bit();。然后加载对应的32或者64位的so。


static {
try {
if (SoLoader.jarMode()) {
if (SoLoader.alreadyCopySo()) {
sIsCloganOk = loadLocalSo();
} else {
boolean copyLoganJni = SoLoader.copyLoganJni();
if (copyLoganJni) {
sIsCloganOk = loadLocalSo();
}
}
} else {
System.loadLibrary(LIBRARY_NAME);
sIsCloganOk = true;
}
} catch (Throwable e) {
e.printStackTrace();
sIsCloganOk = false;
}
}

static boolean loadLocalSo() {
boolean bit = Process.is64Bit();
if (bit) {
String v8a = SoLoader.get64SoFilePath();
try {
System.load(v8a);
return true;
} catch (Throwable e) {
e.printStackTrace();
return false;
}
} else {
String v7a = SoLoader.get32SoFilePath();
try {
System.load(v7a);
return true;
} catch (Throwable e) {
e.printStackTrace();
return false;
}
}
}

作者:CCtomorrow
来源:juejin.cn/post/7206627150621851707
收起阅读 »

Android 完整的apk打包流程

在Android Studio中,我们需要打一个apk包,可以在Gradle task 任务中选一个 assembleDebug/assembleRelease 任务, 控制台上就可以看到所有的构建相关task: 可以看到,这么多个task任务,执行是有...
继续阅读 »

在Android Studio中,我们需要打一个apk包,可以在Gradle task 任务中选一个
assembleDebug/assembleRelease 任务,


企业微信截图_fa2194a8-735e-4720-91be-81fd2524d20f.png


控制台上就可以看到所有的构建相关task:


截屏2023-03-05 20.48.57.png
截屏2023-03-05 20.49.28.png
可以看到,这么多个task任务,执行是有先后顺序的,其实主要就是以下步骤:


//aidl 转换aidl文件为java文件
> Task :app:compileDebugAidl

//生成BuildConfig文件
> Task :app:generateDebugBuildConfig

//获取gradle中配置的资源文件
> Task :app:generateDebugResValues

// merge资源文件
> Task :app:mergeDebugResources

// merge assets文件
> Task :app:mergeDebugAssets
> Task :app:compressDebugAssets

// merge所有的manifest文件
> Task :app:processDebugManifest

//AAPT 生成R文件
> Task :app:processDebugResources

//编译kotlin文件
> Task :app:compileDebugKotlin

//javac 编译java文件
> Task :app:compileDebugJavaWithJavac

//转换class文件为dex文件
> Task :app:dexBuilderDebug

//打包成apk并签名
> Task :app:packageDebug

依靠这些关键步骤最后就能打包出一个apk。


首先看


第一步:aidl(编译aidl文件)


将项目中的aidl文件编译为java文件,AIDL用于进程间通信


第二步:生成BuildConfig文件


在项目中配置了
buildConfigField等信息,会在BuildConfig class类里以静态属性的方式展示:


截屏2023-03-05 21.09.18.png


第三步:合并Resources、assets、manifest、so等资源文件


在我们的项目中会依赖不同的库、组件,也会有多渠道的需求,所以merge这一步操作就是将不同地方的资源文件进行整合。
多个manifest文件也需要整理成一个完整的文件,所以如果有属性冲突这一步就会报错。资源文件也会整理分类到不同的分辨率目录中。


资源处理用的工具是aapt/aapt2


注意AGP3.0.0之后默认通过AAPT2来编译资源,AAPT2支持了增量更新,大大提升了效率。


AAPT 工具负责编译项目中的这些资源文件,所有资源文件会被编译处理,XML 文件(drawable 图片除外)会被编译成二进制文件,所以解压 apk 之后无法直接打开 XML 文件。但是 assets 和 raw 目录下的资源并不会被编译,会被原封不动的打包到 apk 压缩包中。
资源文件编译之后的产物包括两部分:resources.arsc 文件和一个 R.java。前者保存的是一个资源索引表,后者定义了各个资源 ID 常量。这两者结合就可以在代码中找到对应的资源引用。比如如下的 R.java 文件:


截屏2023-03-05 21.19.59.png
实际上被打包到 apk 中的还有一些其他资源,比如 AndroidManifest.xml 清单文件和三方库中使用的动态库 .so 文件。


第四步:编译java文件(用到的工具 javac )


1、java文件包含之前提到的AIDL 生成的java文件


2、java代码部份:通过Java Compiler 编译项目中所有的Java代码,包括R.java.aidl文件生成的.java文件、Java源文件,生成.class文件。在对应的build目录下可以找到相关的代码


3、kotlin代码部份:通过Kotlin Compiler编译项目中的所有Kotlin代码,生成.class文件


注:注解处理器(APT,KAPT)生成代码也是在这个阶段生成的。当注解的生命周期被设置为CLASS的时候,就代表该注解会在编译class文件的时候生效,并且生成java源文件和Class字节码文件。

第五步: Class文件打包成DEX(dx/r8/d8等工具编译class文件)


image.png



  • 在原来 dx是最早的转换工具,用于转换class文件为dex文件。

  • Android Studio 3.1之后,引入了D8编译器和 R8 工具。

  • Android Studio 3.4之后,默认开启 R8
    具体的区别可以点击看看


注意:JVM 和 Dalvik(ART) 的区别:JVM执行的是.class文件、Dalvik和ART执行的.dex文件。具体的区别可以点击看看


而在编译class文件过程也常用于编译插桩,比如ASM,通过直接操作字节码文件完成代码修改或生成。


第六步:apkbuilder/zipflinger(生成APK包)


这一步就是生成APK文件,将manifest文件、resources文件、dex文件、assets文件等等打包成一个压缩包,也就是apk文件。
在老版本使用的工具是apkbuilder,新版本用的是 zipflinger
而在AGP3.6.0之后,使用zipflinger作为默认打包工具来构建APK,以提高构建速度。


第七步: zipalign(对齐处理)


对齐是Android apk 很重要的优化,它会使 APK 中的所有未压缩数据(例如图片或原始文件)在 4 字节边界上对齐。这使得CPU读写就会更高效。


也就是使用工具 zipalign 对 apk 中的未压缩资源(图片、视频等)进行对齐操作,让资源按照 4 字节的边界进行对齐。这种思想同 Java 对象内存布局中的对齐空间非常类似,主要是为了加快资源的访问速度。如果每个资源的开始位置都是上一个资源之后的 4n 字节,那么访问下一个资源就不用遍历,直接跳到 4n 字节处判断是不是一个新的资源即可。


第八步: apk 签名


没有签名的apk 无法安装,也无法发布到应用市场。


大家比较熟知的签名工具是JDK提供的jarsigner,而apksignerGoogle专门为Android提供的签名和签证工具。


其区别就在于jarsigner只能进行v1签名,而apksigner可以进行v2v3v4签名。



  • v1签名


v1签名方式主要是利用META-INFO文件夹中的三个文件。


首先,将apk中除了META-INFO文件夹中的所有文件进行进行摘要写到 META-INFO/MANIFEST.MF;然后计算MANIFEST.MF文件的摘要写到CERT.SF;最后计算CERT.SF的摘要,使用私钥计算签名,将签名和开发者证书写到CERT.RSA。


所以META-INFO文件夹中这三个文件就能保证apk不会被修改。



  • v2签名


Android7.0之后,推出了v2签名,为了解决v1签名速度慢以及签名不完整的问题。


apk本质上是一个压缩包,而压缩包文件格式一般分为三块:


文件数据区,中央目录结果,中央目录结束节。


而v2要做的就是,在文件中插入一个APK签名分块,位于中央目录部分之前,如下图:


图片


这样处理之后,文件就完成无法修改了,这也是为什么 zipalign(对齐处理) 要在签名之前完成。



  • v3签名


Android 9 推出了v3签名方案,和v2签名方式基本相同,不同的是在v3签名分块中添加了有关受支持的sdk版本和新旧签名信息,可以用作签名替换升级。



  • v4签名


Android 11 推出了v4签名方案。


最后,apk得以完成打包


PMS 在安装过程中会检查 apk 中的签名证书的合法性,具体安装apk内容稍后介绍。


apk内容包含如下:


截屏2023-03-05 22.01.14.png


总体的打包流程图如下:


截屏2023-03-05 22.02.33.png


,,


作者:大强Dev
来源:juejin.cn/post/7206998548343668796
收起阅读 »

字节跳动音视频面试一面挂,转拿腾讯音视频 offer

一、面试官: 视频为什么需要压缩 心理分析:视频压缩在音视频领域是一个恒久不变的话题,有压缩也就意味有解压操作,我们把压 缩称为编码 解压成为解码。它们是成对出现的,做音视频最难的就在音视频编解码。如何提高音 视频播放效率,在不牺牲视频质量下 做高度压缩就显...
继续阅读 »

一、面试官: 视频为什么需要压缩



心理分析:视频压缩在音视频领域是一个恒久不变的话题,有压缩也就意味有解压操作,我们把压
缩称为编码 解压成为解码。它们是成对出现的,做音视频最难的就在音视频编解码。如何提高音
视频播放效率,在不牺牲视频质量下 做高度压缩就显得格外重要了。面试官想问的问题并不是压
缩了什么,而是编码中对视频帧做了什么



求职者:需要求职者对视频编码有所了解,接下来我们从帧内压缩,与帧间压缩讲起



  • 未经压缩的数字视频的数据量巨大 下图一分钟的视频量 差不多需要68G

  • 存储困难:一张32G的U盘只能存储几秒钟的未压缩数字视频。

  • 传输困难 : 1兆的带宽传输一秒的视频需要大约10分钟。



二、面试官: 封装格式是什么



心理分析:很多人对音视频的概念停留在 苍老师的小电影上,只能理解他是一个视频文件。面试官考
的对视频文件下的封装格式,封装格式里面的内容有没有了解



求职者:首先需要从封装格式概念讲起,慢慢深入到封装格式基础下,然后散发解封装与封装过程


(1)封装格式(也叫容器)就是将已经编码压缩好的视频轨和音频轨按照一定的格式放到一个文件中,也就
是说仅仅是一个外壳,可以把它当成一个放视频轨和音频轨的文件夹也可以。
(2)通俗点说视频轨相当于饭,而音频轨相当于菜,封装格式就是一个碗,或者一个锅,用来盛放饭菜的容
器。
(3)封装格式和专利是有关系的,关系到推出封装格式的公司的盈利。
(4)有了封装格式,才能把字幕,配音,音频和视频组合起来。
(5)常见的AVI、RMVB、MKV、ASF、WMV、MP4、3GP、FLV等文件都指的是一种封装格式。


举例MKV格式的封装



三、面试官: 一个视频文件是否可以完成倒放(或者你们的倒放如何实现)



心理分析:面试官考的是 是否有经历过音视频剪辑相关的经验,需要从求职者中得到的答案,不是
“能”或者“不能” 而是分析为什么不能倒放,



不能倒放的本质原因,对I B P有有没有了解



求职者:倒放在视频剪辑中 是必备功能,按常理来看,倒放肯定是能够实现的,但是问题就出现在
这里,求职者如果对视频编码原理不理解的话,对视频倒放肯定打不上来的,求职者需要首先答对
“一个视频不能实现倒放,两个文件可以" 这个入手, 再从编解码入手 讲解为什么不能实现倒放



答案


第一种方式:



  1. 从第一个gop然后顺序解码

  2. 将一个解码的gop的yuv写入文件中

  3. 将第二个gop顺序解码yuv写入文件

  4. 第三个gop序列,以此类推....


然后倒序读入内存中,进行编码即可.



缺点:如果文件过大,不能使用此方法,因为yuv文件较大,一分钟yuv就有1-2G左右,有可能撑爆sdcard.



第二种方式



  1. 全部遍历视频一遍,获取一共有多少gop序列

  2. 跳到(seek)到最后一个gop的I帧,然后把这个gop解码的yuv存放在sdcard

  3. 再逆序读出这个解码的gop的yuv,进行编码,这样最后一个gop就变成了第一帧的gop;

  4. 接下来seek到倒数第一个gop的I帧,依次类推,把每个gop解码、然后编码


其实在音视频岗位面试中,问到得远远不止上面的相关问题,上述知识举例,还有更多内容可以面试题可以参考:



1.什么是I帧 P帧 B帧?
2.简述H264视频编码流程?
3.视频能倒放吗,倒放如何实现?
4.硬编码与软编码有什么区别?
5.你对sps 和pps的理解?
6.如何从一段残缺H264数据 解析出画面?
7.讲讲MediaCodec硬编码底层解码机制?
8.音频播放过快,视频慢,如何选择丢帧
9.码率和分辨率都会影响视频的清晰度
10.生产者和消费者的关系
11.sps和pps的区别
12.……



对一些没有学习过、了解过音视频这块知识点的朋友,仿佛是在看天书一般,在这里请大家不要着急,在这为大家准备了《Android 音视频开发入门到精通》的学习笔记:https://qr18.cn/Ei3VPD,帮助大家快速提升。


作者:冬日毛毛雨
来源:juejin.cn/post/7208092574162157626
收起阅读 »

简单教你Intent如何传大数据

前言 最近想不出什么比较好的内容,但是碰到一个没毕业的小老弟问的问题,那就借机说说这个事。Intent如何传大数据?为什么是简单的说,因为这背后深入的话,有很多底层的细节包括设计思想,我也不敢说完全懂,但我知道当你用Intent传大数据报错的时候应该怎么解决,...
继续阅读 »

前言


最近想不出什么比较好的内容,但是碰到一个没毕业的小老弟问的问题,那就借机说说这个事。Intent如何传大数据?为什么是简单的说,因为这背后深入的话,有很多底层的细节包括设计思想,我也不敢说完全懂,但我知道当你用Intent传大数据报错的时候应该怎么解决,并且简单聊聊这背后所涉及到的东西。


Intent传大数据


平时可能不会发生这种问题,但比如我之前是做终端设备的,我的设备每秒都会生成一些数据,而长时间的话数据量自然大,这时当我跳到另外一个页面使用intent把数据传过去的时候,就会报错


我们调用


intent.putExtra("key", value) // value超过1M

会报错


android.os.TransactionTooLargeException: data parcel size xxx bytes

这里的xxx就是1M左右,告诉你传输的数据大小不能超过1M,有些话咱也不敢乱说,有点怕误人子弟。我这里是凭印象说的,如果有大佬看到我说错,请狠狠的纠正我。


这个错误描述是这么描述,但真的是限死1M吗,说到这个,就不得不提一样东西,Binder机制,先不要跑,这里不会详细讲Binder,只是提一嘴。


说到Binder那就会联系到mmap内存映射,你可以先简单理解成内存映射是分配一块空间给内核空间和用户空间共用,如果还是不好理解,就简单想成分配一块空间通信用,那在android中mmap分配的空间是多少呢?1M-4K。


那是不是说Intent传输的数据超过1M-4K就会报错,理论上是这样,但实际没到这个值,比如0.8M也可能会报错。所以你不能去走极限操作,比如你的数据到了1M,你觉得只要减少点数据,减到8K,应该就能过了,也许你自己测试是正常的,但是这很危险。


所以能不传大数据就不要传大数据,它的设计初衷也不是为了传大数据用的。如果真要传大数据,也不要走极限操作。


那怎么办,切莫着急,请听我慢慢讲。就这个Binder它是什么玩意,它是Android中独特的进程通信的方式,而Linux中进程通信的方式,在Android中同样也适用。进程间通信有很多方式,Binder、管道、共享内存等。为什么会有这么多种通信方式,因为每种通信方式都有自己的特点,要在不同的场合使用不同的通信方式。


为什么要提这个?因为要看懂这个问题,你需要知道Binder这种通信方式它有什么特点,它适合大量的数据传输吗?那你Binder又与我Intent何干,你抓周树人找我鲁迅干嘛~~所以这时候你就要知道Android四大组件之间是用什么方式通信的。


有点扯远了,现在可以来说说结论了,Binder没办法传大数据,我就1M不到你想怎样?当然它不止1M,只是Android在使用时限制了它只能最多用1M,内核的最大限制是4M。又有点扯远了,你不要想着怎么把限制扩大到4M,不要往这方面想。前面说了,不同的进程通信方式,有自己的特点,适用于某些特定的场景。那Binder不适用于传输大数据,我共享内存行不行?


所以就有了解决办法


bundle.putBinder()

有人可能一看觉得,这有什么不同,这在表面上看差别不大,实则内部大大的不同,bundle.putBinder()用了共享内存,所以能传大数据,那为什么这里会用共享内存,而putExtra不是呢?想搞清楚这个问题,就要看源码了。 这里就不深入去分析了,我怕劝退,不是劝退你们,是劝退我自己。有些东西是这样的,你要自己去看懂,看个大概就差不多,但是你要讲出来,那就要看得细致,而有些细节确实会劝退人。所以想了解为什么的,可以自己去看源码,不想看的,就知道这是怎么一回事就行。


那还有没有其它方式呢?当然有,你不懂共享内存,你写到本地缓存中,再从本地缓存中读取行不行?


办法有很多,如果你不知道这个问题怎么解决,你找不到你觉得可行的解决方案,甚至可以通过逻辑通过流程的方式去绕开这个问题。但是你要知道为什么会出现这样的问题,如果你没接触过进程通信,没接触过Binder,让你看一篇文章就能看懂我觉得不切实际,但是至少得知道是怎么一回事。


比如我只说bundle.putBinder()能解决这个问题,你一试,确实能解决,但是不知道为什么,你又怕会不会有其它问题。虽然这篇文章我一直在打擦边球,没有提任何的原理,但我觉得还是能大概让人知道为什么bundle.putBinder()能解决Intent传大数据,

作者:流浪汉kylin
来源:juejin.cn/post/7205138514870829116
你也就能放心去用了。

收起阅读 »

Android必知必会-Stetho调试工具

一、背景 Stetho是 Facebook 出品的一个强大的 Android 调试工具,使用该工具你可以在 Chrome Developer Tools查看APP的布局, 网络请求(仅限使用Volle, okhttp的网络请求库), Sqlite, Pref...
继续阅读 »

一、背景



Stetho是 Facebook 出品的一个强大的 Android 调试工具,使用该工具你可以在 Chrome Developer Tools查看APP的布局, 网络请求(仅限使用Volle, okhttp的网络请求库), Sqlite, Preference, 一切都是可视化的操作,无须自己在去使用adb, 也不需要root你的设备



本人使用自己的Nubia Z9 Mini作为调试机,由于牵涉到Sqlite数据库,所以尝试了很多办法把它Root了,然而Root之后就无法正常升级系统。
今天得知一调试神器Stetho,无需Root就能查看数据库以及APP的布局(这一点没有Android Device Monitor使用方便,但是Android Device Monitor在Mac上总是莫名其妙出问题),使用起来很方便,大家可以尝试一下。


二、配置流程


1.引入主库


使用Gradle方式:


// Gradle dependency on Stetho 
dependencies {
compile 'com.facebook.stetho:stetho:1.3.1'
}

此外还支持Maven方式,这里不做介绍。


2.引入网络请求库


如果需要调试网络且你使用的网络请求库是Volle或者Okhttp,那么你才需要配置,否则跳过此步。
以下根据自己使用的网络请求库情况来导入相应的库:
1.使用okhttp 2.X


 dependencies { 
compile 'com.facebook.stetho:stetho-okhttp:1.3.1'
}

2.使用okhttp 3.X


dependencies { 
compile 'com.facebook.stetho:stetho-okhttp3:1.3.1'
}

3.使用HttpURLConnection


dependencies { 
compile 'com.facebook.stetho:stetho-urlconnection:1.3.1'
}

3.配置代码


配置Application


public class XXX extends Application {
public void onCreate() {
super.onCreate();
Stetho.initializeWithDefaults(this);
}
}

配置网络请求库:
OkHttp 2.2.x+ 或 3.x


//方案一
OkHttpClient client = new OkHttpClient();
client.networkInterceptors().add(new StethoInterceptor());

//方案二
new OkHttpClient.Builder()
.addNetworkInterceptor(new StethoInterceptor())
.build();

如果使用的是HttpURLConnection,请查阅相关文档。


4.使用


运行重新编译后的APP程序,保持手机与电脑的连接,然后打开Chrome浏览器,在地址栏里输入:chrome://inspect然后选择自己的设备下运行的APP进程名下的Inspect链接 即可进行调试。


三、遇到的问题


1.okhttp版本问题:


可能你还在使用okhttp 2.x的版本,在引入网络库的时候,你需要去查看一下Stetho当前版本使用的okhttp版本,避免在项目中使用多个不同版本的okhttp


PSokhttp2.x和3.x的引入方式略有不同,不可以直接修改版本号来导入:


//2.x
compile 'com.squareup.okhttp:okhttp:2.x.x'
//3.x
compile 'com.squareup.okhttp3:okhttp:3.x.x'

2.配置okhttp代码方案一报错:


//方案一
OkHttpClient client = new OkHttpClient();
client.networkInterceptors().add(new StethoInterceptor());

//方案二
OkHttpClient client = new OkHttpClient.Builder()
.addNetworkInterceptor(new StethoInterceptor())
.build();

我在使用方案一进行配置okhttp的时候,会报错:


 Caused by: java.lang.UnsupportedOperationException

不知道是不是兼容的问题,大家在使用的时候请注意。



Stetho官网






转载请注明出处,如果有什么建议或者问题可以随时联系我,共同探讨学习:



作者:cafeting
来源:juejin.cn/post/7202164243612860472

收起阅读 »

Android:我是如何优化APP体积的

前言 在日常开发中,随着APP功能迭代发现打出的安装包体积越来越大,这里说的大是猛增的那种大,而并非一点一点增大。从最开始的几兆到后面的几十兆,虽然市面上的很多APP甚至达到上百兆,但毕竟别人功能强大,用到的一些底层库就特别占面积,流量也多所以也可理解。但自...
继续阅读 »

前言



在日常开发中,随着APP功能迭代发现打出的安装包体积越来越大,这里说的大是猛增的那种大,而并非一点一点增大。从最开始的几兆到后面的几十兆,虽然市面上的很多APP甚至达到上百兆,但毕竟别人功能强大,用到的一些底层库就特别占面积,流量也多所以也可理解。但自研的一些APP可经不住这些考验,所以能压缩就压缩,能优化就尽量优化,以得到用户最好的体验,下面就来说说我在项目中是如何优化APP体积的。



1. 本地资源优化


这里主要是压缩一些图片和视频。项目中本地资源用到最多的应该就是图片,几乎每个页面都离不开图标,甚至一些页面采用大图片的形式。你可知道,正常不经压缩的图片大的可以上大几十兆,小则也是一兆起步。这里做了个实验,同一个文件分别采用svg、png、使用tiny压缩后的png、webp四种类型图片进行展示(顺序是从左到右,从上到下):


image.png


可以看到,加载出来的效果几乎没有什么区别,但体积却有很大的差别(其中webp是采取的默认75%转换):


image.png


所以,别再使用png格式图片,太浪费资源了,就算经过压缩还是不及svg和webp,这里的webp其实还可以加大转换力度,但个人还是比较喜欢svg。


至于音视频文件也是可以通过其他工具进行压缩再放入本地,如非必要,尽量还是使用网络资源。


2. lib优化


一些三方库会使用底层so文件,一般在配置的时候我们尽量选择一种cpu类型,这里选择armeabi-v7a,其实几乎都兼容


ndk {
//设置支持的SO库架构 armeabi、armeabi-v7a、arm64-v8a、x86、x86_64、mips、mips64
abiFilters 'armeabi-v7a'
}

可以看看APK体积分析,每种cpu占用体积都比较大,少配置一种就能省下不少空间。
image.png


3. 代码混淆、无用资源的删除


在bulid.gradle中配置minifyEnabled true开启代码混淆,还需要配置混淆规则,否则无法找到目标类。shrinkResources true则是打包时不会将无用资源打入包内,这里有个小坑。之前使用腾讯地图时,某些第三方的静态资源会因为这个操作不被打入包内,导致无法找到资源,所以根据具体情况使用。


 release {
buildConfigField "boolean", "LOG_DEBUG", "false"
minifyEnabled true
// shrinkResources true 慎用,可能会导致第三方资源文件找不到
zipAlignEnabled true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}

4. 代码复用,剔除无用代码


项目中由于多人协同开发会出现各写各的情况,需要抽出一些公共库之类的工具,方便代码复用。一些注释掉的代码该删除就删除。其实这一部分优化的体积相当少,但也得做,也是对代码质量的一种提升。


总结


其实只要做到了以上四步,APP体积优化已经得到了很大程度的提升了,其他再怎么优化效果也不是很明显了,最主要的就是本地资源和第三方so包体积占用较多。图片的使用我们尽量做到:小图标用svg,全屏类的大图可以考虑webp,最好不要使用png。ndk配置最好只配置一款cpu,几乎都可兼容,万不得已再加一个。


以上便是全部内容,希望对大家有所帮助。



作者:似曾相识2022
来源:juejin.cn/post/7206292770277261368
收起阅读 »

面试题:Android 中 Intent 采用了什么设计模式?

答案是采用了原型模式。 原型模式的好处在于方便地拷贝某个实例的属性进行使用、又不会对原实例造成影响,其逻辑在于对 Cloneable 接口的实现。 话不多说看下 Intent 的关键源码:  // frameworks/base/core/java/andro...
继续阅读 »

答案是采用了原型模式


原型模式的好处在于方便地拷贝某个实例的属性进行使用、又不会对原实例造成影响,其逻辑在于对 Cloneable 接口的实现。


话不多说看下 Intent 的关键源码:


 // frameworks/base/core/java/android/content/Intent.java
 public class Intent implements Parcelable, Cloneable {
    ...
     private static final int COPY_MODE_ALL = 0;
     private static final int COPY_MODE_FILTER = 1;
     private static final int COPY_MODE_HISTORY = 2;
 ​
     @Override
     public Object clone() {
         return new Intent(this);
    }
 ​
     public Intent(Intent o) {
         this(o, COPY_MODE_ALL);
    }
 ​
     private Intent(Intent o, @CopyMode int copyMode) {
         this.mAction = o.mAction;
         this.mData = o.mData;
         this.mType = o.mType;
         this.mIdentifier = o.mIdentifier;
         this.mPackage = o.mPackage;
         this.mComponent = o.mComponent;
         this.mOriginalIntent = o.mOriginalIntent;
        ...
 ​
         if (copyMode != COPY_MODE_FILTER) {
            ...
             if (copyMode != COPY_MODE_HISTORY) {
                ...
            }
        }
    }
    ...
 }

可以看到 Intent 实现的 clone() 逻辑是直接调用了 new 并传入了自身实例,而非调用 super.clone() 进行拷贝。


默认的拷贝策略是 COPY_MODE_ALL,顾名思义,将完整拷贝源实例的所有属性进行构造。其他的拷贝策略是 COPY_MODE_FILTER 指的是只拷贝跟 Intent-filter 相关的属性,即用来判断启动目标组件的 actiondatatypecomponentcategory 等必备信息。无视启动 flagbundle 等数据。


 // frameworks/base/core/java/android/content/Intent.java
 public class Intent implements Parcelable, Cloneable {
    ...
     public @NonNull Intent cloneFilter() {
         return new Intent(this, COPY_MODE_FILTER);
    }
 ​
     private Intent(Intent o, @CopyMode int copyMode) {
         this.mAction = o.mAction;
        ...
 ​
         if (copyMode != COPY_MODE_FILTER) {
             this.mFlags = o.mFlags;
             this.mContentUserHint = o.mContentUserHint;
             this.mLaunchToken = o.mLaunchToken;
            ...
        }
    }
 }

还有中拷贝策略是 COPY_MODE_HISTORY,不需要 bundle 等历史数据,保留 action 等基本信息和启动 flag 等数据。


 // frameworks/base/core/java/android/content/Intent.java
 public class Intent implements Parcelable, Cloneable {
    ...
     public Intent maybeStripForHistory() {
         if (!canStripForHistory()) {
             return this;
        }
         return new Intent(this, COPY_MODE_HISTORY);
    }
 ​
     private Intent(Intent o, @CopyMode int copyMode) {
         this.mAction = o.mAction;
        ...
 ​
         if (copyMode != COPY_MODE_FILTER) {
            ...
             if (copyMode != COPY_MODE_HISTORY) {
                 if (o.mExtras != null) {
                     this.mExtras = new Bundle(o.mExtras);
                }
                 if (o.mClipData != null) {
                     this.mClipData = new ClipData(o.mClipData);
                }
            } else {
                 if (o.mExtras != null && !o.mExtras.isDefinitelyEmpty()) {
                     this.mExtras = Bundle.STRIPPED;
                }
            }
        }
    }
 }

总结起来:


Copy Modeaction 等数据flags 等数据bundle 等历史
COPY_MODE_ALLYESYESYES
COPY_MODE_FILTERYESNONO
COPY_MODE_HISTORYYESYESNO

除了 Intent,Android 源码中还有很多地方采用了原型模式。




  • Bundle 也实现了 clone(),提供了 new Bundle(this) 的处理:


     public final class Bundle extends BaseBundle implements Cloneable, Parcelable {
        ...
         @Override
         public Object clone() {
             return new Bundle(this);
        }
     }



  • 组件信息类 ComponentName 也在 clone() 中提供了类似的实现:


     public final class ComponentName implements Parcelable, Cloneable, Comparable<ComponentName> {
        ...
         public ComponentName clone() {
             return new ComponentName(mPackage, mClass);
        }
     }



  • 工具类 IntArray 亦是如此:


     public class IntArray implements Cloneable {
        ...
         @Override
         public IntArray clone() {
             return new IntArray(mValues.clone(), mSize);
        }
     }



原型模式也不一定非得实现 Cloneable,提供了类似的实现即可。比如:




  • Bitmap 没有实现该接口但提供了 copy(),内部将传递原始 Bitmap 在 native 中的对象指针并伴随目标配置进行新实例的创建:


     public final class ComponentName implements Parcelable, Cloneable, Comparable<ComponentName> {
        ...
         public Bitmap copy(Config config, boolean isMutable) {
            ...
             noteHardwareBitmapSlowCall();
             Bitmap b = nativeCopy(mNativePtr, config.nativeInt, isMutable);
             if (b != null) {
                 b.setPremultiplied(mRequestPremultiplied);
                 b.mDensity = mDensity;
            }
             return b;
        }
     }


  • <
    作者:TechMerger
    来源:juejin.cn/post/7204013918958649405
    li>
收起阅读 »

Android斩首行动——接口预请求

前言 开发同学应该都很熟悉我们页面的渲染过程一般是从Activity#onCreate开始,再发起网络请求,等请求回调回来后,再基于网络数据渲染页面。可以用下面这幅图来粗略描述这个过程: 可以看到,目标页面渲染完成前必须得等待网络请求,导致渲染速度并没有那么...
继续阅读 »

前言


开发同学应该都很熟悉我们页面的渲染过程一般是从Activity#onCreate开始,再发起网络请求,等请求回调回来后,再基于网络数据渲染页面。可以用下面这幅图来粗略描述这个过程:


image.png


可以看到,目标页面渲染完成前必须得等待网络请求,导致渲染速度并没有那么快。尤其是当网络并不好的时候感受会更加明显。并且,当目标页面是H5页面或者是Flutter页面的时候,因为涉及到H5容器与Flutter容器的创建,白屏时间会更长。


那么有没有可能提前发起请求,来缩短网络请求这一部分的等待时间呢?这就是我们今天要讲的部分,接口预请求。


目标


我们要达到的目标很简单,就是提前异步发起目标页面的网络请求,从而加快目标页面的渲染速度。改善后的过程可以用下图表示:


image.png


并且,我们的预请求能力需要尽量少地侵入业务,与业务解耦,并保证能力的通用性,适用于工程内的任意页面(Android页面、H5页面、Flutter页面)。


方案


整体链路


首先给大家看一下整体链路,具体的细节可以先不用去抠,下面会一一讲到。


image.png


预请求时机


预请求时机一般有三种选择:



  1. 由业务层自行选择时机进行异步预请求

  2. 点击控件时进行异步预请求

  3. 路由最终跳转前进行异步预请求


第1种选择,由业务层自行选择时机进行预请求,需要涉及到业务层的改造,以及对时机合理性的把握。一方面是存在改造成本,另一方面是无法保证业务侧调用时机的合理性。


第2种选择,点击控件时进行预请求。若点击时进行预请求,点击事件监听并不是业务域统一的,无法形成有效封装。并且,若后续路由拦截器修改了参数,或是终止了跳转,这次预请求就失去了意义。


因此这里我们选择第3种,基于统一路由框架,在路由最终跳转前进行预请求。既保证了良好的封装性,也实现了对业务的零侵入,同时也做到了懒请求,即用户必然要发起该请求时才会去预请求。这里需要注意的是必须是在最终跳转前进行预请求,可以理解为是路由的最后一个前置异步拦截器。


预请求规则配置


我们通过本地的json文件(当然,有需要也可以上云通过配置后台下发),对预请求的规则进行配置,并将这份配置在App启动阶段异步读入到内存。后续在路由过程中,只有命中了预请求规则,才能发起预请求。配置demo如下:


{
"routeConfig":{
"scheme://domain/path?param1=true&itemId=123":["prefetchKey"],
"
route2":["prefetchKey2"],
"
route3":["prefetchKey3","prefetchKey4"]
},
"
prefetcher":{
"
prefetchKey":{
"
prefetchType":"network",
"
prefetchInfo":{
"
api":"network.api.name",
"
apiVersion":"1.0",
"
method":"post",
"
needLogin":"false",
"
showLoginUI":"false",
"
params": {
"
itemId":"$route.itemId",
"
firstTime":"true"
},
"
headers": {

},
"
prefetchImgInResponse": [
{
"
imgUrl":"$data.imgData.img",
"
imgWidth":"$data.imgData.imgWidth",
"
imgHeight":150
}
]
}
},
"
prefetchKey2":{
"
prefetchType":"network",
"
prefetchInfo":{
"
api":"network.api.name2",
"
apiVersion":"1.0",
"
method":"post",
"
needLogin":"false",
"
showLoginUI":"false",
"
params": {
"
itemId":"$route.productId",
"
firstTime":"false"
},
"
headers": {

}
},
"
prefetchKey3":{
"
prefetchType":"image",
"
prefetchInfo":{
"
imgUrl":"$route.imgUrl",
"
imgWidth":"$route.imgWidth",
"
imgHeight": 150
}
},
"
prefetchKey4":{
"
prefetchInfo":{}
}
}
}

规则解读


参数名描述备注
routeConfig路由配置配置路由到预请求的映射
prefetcher预请求配置记录所有的预请求
prefetchKey预请求的key
prefetchType预请求类型分为network类型与image类型,两种类型所需要的参数不同
prefetchInfo预请求所需要的信息其中value若为route.param格式,那么该值从路由中获取;若为route.param格式,那么该值从路由中获取;若为data.param格式,则从响应数据中获取。
paramsnetwork请求所需要的请求params
headersnetwork请求所需要的请求headers
prefetchImgFromResponse预请求的响应返回后,需要预加载的图片用于需要预加载图片时,无法确定图片url,图片url只能从预请求响应中获取的场景。

举例说明


网络预请求


例如跳转目标页面,它的路由是scheme://domain/path?param1=true&itemId=123


首先我们在跳转路由时,若跳转的路由是这个目标页面,我们就会尝试去发起预请求。根据上面的demo配置文件,它将匹配到prefetchKey这个预请求。


那么我们详细看prefetchKey这个预请求,预请求类型prefetchTypenetwork,是一个网络预请求,prefetchInfo中具备了请求的基本参数(如apiName、apiVersion、method、请求params与请求headers,不同工程不一样,大家可以根据自己的工程项目进行修改)。具体看params中,有一个参数为itemId:$route.itemId。以$route.开头的意思,就是这个value值要从路由中获取,即itemId=123,那么这个值就是123。


图片预请求


在做网络预请求的过程中,我忽然想到图片做预请求也是可以大大提升用户体验的,尤其是当大图片首次下载到内存中渲染需要的时间会比较长。图片预请求分为url已知url未知两种场景,下面各举两个例子。


图片url已知

什么是图片url已知呢?比如我们在首页跳转首页的二级页面时,如果二级页面需要预加载的图片跟首页的某张图是一样的(尺寸可能不同),那么首页跳转路由时我们是能够提前知道这个图片的url的,所以我们看到prefetchKey3中配置了prefetchTypeimage的预请求。image的信息来自于路由参数,需要在跳转时将图片url和宽高作为路由参数之一。


比如scheme://domain/path?imgUrl=${encodeUrl}&imgWidth=200,那么根据配置项,我们将提前将encodeUrl这个图片以宽200,高150的尺寸,加载到内存中去。当目标页面用到这个图片时,将能很快渲染出来。


图片url未知

相反,当跳转目标页面时,目标页面所要加载的图片url没法取到,就对应了图片url未知的场景。


例如闪屏页跳转首页时,如果需要预加载首页顶部的图片,此时闪屏页是无法获取到图片的url的,因为这个图片url是首页接口返回的。这种情况下,我们只能依赖首页的预请求进行。


在demo配置文件中,我们可以看到prefetchImgFromResponse字段。这个字段代表着,当这个预请求响应回来之后,我需要去预请求某张图片。其中,imgUrl$data.param格式,以$data.开头,代表着这份数据是来自于响应数据的。响应数据就是一串json串,可以凭此,索引到预请求响应中图片url的位置,就能实现图片的提前加载了。


至于图片怎么提前加载到内存中,以及真实图片的加载怎么匹配到内存中的图片,这一部分是通过glide已有的preload机制实现的,感兴趣的同学可以去看一下源码了解一下,这里就不展开了。后面讲的预请求的方案细节,都只限于网络请求。


预请求匹配


预请求匹配指的是实际的业务请求怎样与已经执行的预请求匹配上,从而节省请求的空中时间,直接返回预请求的结果。


首先网络预请求执行前先在内存中生成一份PrefetchRecord,代表着已经执行的预请求,其中的字段跟配置文件中差不多,主要就是记录预请求相关的信息:


class PrefetchRecord {
// 请求信息
String api;
String apiVersion;
String method;
String needLogin;
String showLoginUI;
JSONObject params;
JSONObject headers;

// 预请求状态
int status;
// 预请求结果
ResponseModel response;
// 生成的请求id
String requestId;

boolean isMatch(RealRequest realRequest) {
requestId.equals(realRequest.requestId)
}
}

每一个PrefetchRecord生成时,都会生成一个requestId,用于跟实际业务请求进行匹配。requestId的生成规则可以自行制定,比如将所有请求信息包一起做一下md5处理之类。


在实际业务请求发起之前,也会根据同样的规则生成requestId。若内存中存在相同requestId对应的PrefetchRecord,那么就相当于匹配成功了。匹配成功后,再根据预请求的状态进行进一步的处理。


预请求状态


预请求状态分为START、FINISH、ABORT,对应“正在发起预请求”、“已经获得预请求结果”、“预请求被抛弃”。ABORT状态下一节再讲。


为什么要记录这个状态呢?因为我们无法保证,预请求的响应一定在实际请求之前。用图来表示:


image.png


因为预请求是一个并发行为。当预请求的空中时间特别长,长到目标页面已经发出实际请求了,预请求的响应还没回来,即预请求状态为START,而非FINISH。那么此时该怎么办?我们就需要让实际请求在一旁等着(记录到内存中,RealRequestRecord),等预请求接收到响应了,再根据requestId去进行匹配,匹配到RealRequestRecord了,就触发RealRequestRecord中的回调,返回数据。


另外,在匹配过程中需要注意一点,因为每次路由跳转,如果发起预请求了,总会生成一个Record在内存中等待匹配。因此在匹配结束后,不管是匹配成功还是匹配失败,都要及时释放将Record从内存中释放掉。


超时重试机制


基于实际请求等待预请求响应的场景,我们再延伸一下。若预请求请求超时,迟迟拿不到响应,该怎么办?用图表示:


image.png


假设目前的网络请求,端上默认的超时时间是30s。那么在超时场景下,实际的业务请求在30s内若拿不到预请求的结果,就需要重新发起业务请求,抛弃预请求,并将预请求的状态置为ABORT,这样即使后面预请求响应回来了也不做任何处理。


image.png


忽然想到一个很贴切的场景来比喻这个预请求方案。


我们把跳转页面理解为去柜台取餐。


预请求代表着我们人还没到柜台,就先远程下单让柜员去准备食物。


如果柜员准备得比较快,那么我们到柜台后就能直接把食物拿走了,就能快点吃上了(代表着页面渲染速度变快)。


如果柜员准备得比较慢,那么我们到柜台后还是得等一会儿才能取餐,但总体上吃上食物的速度还是要比到柜台后再点餐来得快。


但如果这个柜员消极怠工准备得太慢了,我们到柜台等了很久都没拿到食物,那么我们就只能换个柜员重新点了(超时后发起实际的业务请求),同时还不忘投诉一把(预请求空中时间太慢了)。


总结


通过这篇文章,我们知道了什么是接口预请求,怎么实现接口预请求。我们通过配置文件+统一路由处理+预请求发起、匹配、回调,实现了与业务解耦的,可适用于任意页面的轻量级预请求方案,从而提升页面的渲染速度。


作者:孝之请回答
来源:juejin.cn/post/7203615594390732855
收起阅读 »

我发现了 Android 指纹认证 Api 内存泄漏

我发现了 Android 指纹认证 Api 内存泄漏 目前很多市面上的手机基本都有指纹登陆功能。Google 也提供了调用相关功能 API,安全类的App 也基本都在使用。接下来就一起捋一捋今天的主角 BiometricPrompt 先说问题,使用Biome...
继续阅读 »

我发现了 Android 指纹认证 Api 内存泄漏


目前很多市面上的手机基本都有指纹登陆功能。Google 也提供了调用相关功能 API,安全类的App 也基本都在使用。接下来就一起捋一捋今天的主角 BiometricPrompt


先说问题,使用BiometricPrompt 会造成内存泄漏,目前该问题试了 Android 11 到 13 都发生,而且没有什么好的办法。目前想到的最好的方法是漏的少一点。当然谁有好的办法欢迎留言。


问题再现


先看动画


在这里插入图片描述


动画中操作如下



  1. MainAcitivity 跳转到 SecondActivity

  2. SecondActivity 调用 BiometricPrompt 三次

  3. 从SecondActivity 返回到 MainAcitivity


以下是使用 BiometricPrompt 的代码


public fun showBiometricPromptDialog() {
val keyguardManager = getSystemService(
Context.KEYGUARD_SERVICE
) as KeyguardManager;

if (keyguardManager.isKeyguardSecure) {
var biometricPromptBuild = BiometricPrompt.Builder(this).apply {// this is SecondActivity
setTitle("verify")
setAllowedAuthenticators(BiometricManager.Authenticators.DEVICE_CREDENTIAL or BiometricManager.Authenticators.BIOMETRIC_WEAK)
}
val biometricPromp = biometricPromptBuild.build()
biometricPromp.authenticate(CancellationSignal(), mExecutor, object :
BiometricPrompt.AuthenticationCallback() {

})
}
else {
Log.d("TAG", "showLockScreen: isKeyguardSecure is false");
}
}

以上逻辑 biometricPromp 是局部变量,应该没有问题才对。


内存泄漏如下


在这里插入图片描述
可以看到每启动一次生物认证,创建的 BiometricPrompt 都不会被回收。


规避方案:


修改方案也简单


方案一:



  1. biometricPromp 改为全局变量。

  2. this 改为 applicationContext


方案一存在的问题,SecondActivity 可能频繁创建,所以 biometricPromp 还会存在多个实例。


方案二(目前想到的最优方案):



  1. biometricPromp 改为单例

  2. this 改为 applicationContext


修改后,App memory 中只存在一个 biometricPromp ,且没有 Activity 被泄漏。


想到这里,应该会觉得奇怪,biometricPromp 为什么不会被回收?提供的 API 都看过了,没有发现什么方法可以解决这个问题。直觉告诉我这个可能是系统问题,下来分析下BiometricPrompt 吧。


BiometricPrompt 源码分析


在这里插入图片描述


App 相关信息通过 BiometricPrompt 传递到 System 进程,System 进程再通知 SystemUI 显示认证界面。


App 信息传递到 System 进程,应该会使用 Binder。这个查找 BiometricPrompt 使用哪些 Binder。


private final IBiometricServiceReceiver mBiometricServiceReceiver =
new IBiometricServiceReceiver.Stub() {

......
}

源码中发现 IBiometricServiceReceiver 比较可疑,IBiometricServiceReceiver 是匿名内部类,内部是持有 BiometricPrompt 对象的引用。


接下来看下 System Server 进程信息(注:系统是 UserDebug 的手机,才可以查看,买的手机版本是不支持的)


在这里插入图片描述



😂 App 使用优化后(方案二)App 只存在一个 IBiometricServiceReceiver ,而 system 进程中存在三个 IBiometricServiceReceiver 的 binder proxy。 每次启动 BiometricPrompt 都会创建一个。这个就不解释为什么会出现三个binder proxy,感兴趣可以看下面推荐的文章。GC root 是 AuthSession。

再看下 AuthSession 的实例数


在这里插入图片描述


果然 AuthSession 也存在三个。


在这里插入图片描述


这里有个知识点,binder 也是有生命周期的,三个 Proxy 这篇文章也是解释了的。有兴趣的可以了看下。


Binder | 对象的生命周期


一开始,我以为 AuthSession 没有被置空,看下代码,发现 AOSP 的代码,还是比较严谨的,有置空的操作。


细心的同学发现,上图中 AuthSession 没有被任何对象引用,AuthSession 就是 GC Root,哈哈哈。


问题解密


一个实例什么情况可以作为GC Root,有兴趣的同学,可以自行百度,这里就不卖关子了,直接说问题吧。


Binder.linkToDeath()


public void linkToDeath(@NonNull DeathRecipient recipient, int flags) {
}

需要传递 IBinder.DeathRecipient ,这个 DeathRecipient 会被作为 GC root。当调用 unlinkToDeath(@NonNull DeathRecipient recipient, int flags),GC root 才被收回。


AuthSession 初始化的时候,会调用 IBiometricServiceReceiver .linkToDeath。


public final class AuthSession implements IBinder.DeathRecipient {
AuthSession(@NonNull Context context,
......
@NonNull IBiometricServiceReceiver clientReceiver,
......
) {
Slog.d(TAG, "Creating AuthSession with: " + preAuthInfo);
......
try {
mClientReceiver.asBinder().linkToDeath(this, 0 /* flags */);//this 变成 GC root
} catch (RemoteException e) {
Slog.w(TAG, "Unable to link to death");
}

setSensorsToStateUnknown();
}
}

Jni 中 通过 env->NewGlobalRef(object),告诉虚拟机 AuthSession 是 GC Root。


core/jni/android_util_Binder.cpp

static void android_os_BinderProxy_linkToDeath(JNIEnv* env, jobject obj,
jobject recipient, jint flags)
// throws RemoteException
{
if (recipient == NULL) {
jniThrowNullPointerException(env, NULL);
return;
}

BinderProxyNativeData *nd = getBPNativeData(env, obj);
IBinder* target = nd->mObject.get();

LOGDEATH("linkToDeath: binder=%p recipient=%p\n", target, recipient);

if (!target->localBinder()) {
DeathRecipientList* list = nd->mOrgue.get();
sp<JavaDeathRecipient> jdr = new JavaDeathRecipient(env, recipient, list);//java 中 DeathRecipient 会被封装为 JavaDeathRecipient
status_t err = target->linkToDeath(jdr, NULL, flags);
if (err != NO_ERROR) {
// Failure adding the death recipient, so clear its reference
// now.
jdr->clearReference();
signalExceptionForError(env, obj, err, true /*canThrowRemoteException*/);
}
}
}

JavaDeathRecipient(JNIEnv* env, jobject object, const sp<DeathRecipientList>& list)
: mVM(jnienv_to_javavm(env)), mObject(env->NewGlobalRef(object)),// object -> DeathRecipient 变为 GC root
mObjectWeak(NULL), mList(list)
{
// These objects manage their own lifetimes so are responsible for final bookkeeping.
// The list holds a strong reference to this object.
LOGDEATH("Adding JDR %p to DRL %p", this, list.get());
list->add(this);

gNumDeathRefsCreated.fetch_add(1, std::memory_order_relaxed);
gcIfManyNewRefs(env);
}

unlinkToDeath 最终会在 Jni 中 通过 env->DeleteGlobalRef(mObject),告诉虚拟机 AuthSession 不是GC root。


virtual ~JavaDeathRecipient()
{
//ALOGI("Removing death ref: recipient=%p\n", mObject);
gNumDeathRefsDeleted.fetch_add(1, std::memory_order_relaxed);
JNIEnv* env = javavm_to_jnienv(mVM);
if (mObject != NULL) {
env->DeleteGlobalRef(mObject);// object -> DeathRecipient GC root 被撤销
} else {
env->DeleteWeakGlobalRef(mObjectWeak);
}
}

解决方式


AuthSession 置空的时候调用 IBiometricServiceReceiver 的 unlinkToDeath 方法。


总结


以上梳理的其实就是 Binder 的造成的内存泄漏。


问题严重性来看,也不算什么大问题,因为调用 BiometricPrompt 的进程被杀,system 进程相关实例也就回收释放了。一般 app 也不太可能出现,常驻进程,而且还频繁调用手机认证的。


这里主要介绍了一种容易被忽略的内存泄漏,Binder.linktoDeath()。
Google issuetracker


参考资料


Binder | 对象的生命周期


作者:Jingle_zhang
来源:juejin.cn/post/7202066794299129914
收起阅读 »

Android 手写热修复dex

.markdown-body{word-break:break-word;line-height:1.75;font-weight:400;font-size:16px;overflow-x:hidden;color:#252933}.markdown-bod...
继续阅读 »

现有的热修复框架很多,尤以AndFix 和Tinker比较多



具体的实现方式和项目引用可以参考网络上的文章,今天就不谈,也不是主要目的



今天就来探讨,如何手写一个热修复的功能



对于简单的项目,不想集成其他修复框架的SDK,也不想用第三方平台,只是紧急修复一些bug
还是挺方便的



言归正传,如果一个或多个类出现bug,导致了崩溃或者数据显示异常,如果修复呢,如果熟悉jvm dalvik 类的加载机制,就会清楚的了解 ClassLoader的 双亲委托机制 就可以通过这个


什么是双亲委托机制



  1. 当前ClassLoader首先从自己已经加载的类中查询是否此类已经加载,如果已经加载则直接返回原来已经加载的类。
    每个类加载器都有自己的加载缓存,当一个类被加载了以后就会放入缓存,等下次加载的时候就可以直接返回了。

  2.  当前classLoader的缓存中没有找到被加载的类的时候,委托父类加载器去加载,父类加载器采用同样的策略,首先查看自己的缓存,然后委托父类的父类去加载,一直到bootstrp ClassLoader.

  3. 当所有的父类加载器都没有加载的时候,再由当前的类加载器加载,并将其放入它自己的缓存中,以便下次有加载请求的时候直接返回。



突破口来了,看1(如果已经加载则直接返回原来已经加载的类)
对于同一个类,如果先加载修复的类,当后续在加载未修复的类的时候,直接返回修复的类,这样bug不就解决了吗?



Nice ,多看源码和jvm 许多问题可以从framework和底层去解决


话不多说,提出了解决方法,下面着手去实现


public class InitActivity extends FragmentActivity {

@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
//这里默认在SD卡根目录,实际开发过程中可以把dex文件放在服务器,在启动页下载后加载进来
//第二次进入的时候可以根据目录下是否已经下载过,处理,避免重新下载
//最后根据当前app版本下载不同的修复dex包 等等一系列处理
String dexFilePath = Environment.getExternalStorageDirectory().getAbsolutePath() + "/fix.dex";
DexFile dexFile = null;
try {
dexFile = DexFile.loadDex(dexFilePath, null, Context.MODE_PRIVATE);
} catch (IOException e) {
e.printStackTrace();
}

patchDex(dexFile);

startActivity(new Intent(this, MainActivity.class));
}

/**
* 修复过程,可以放在启动页,这样在等待的过程中,网络下载修复dex文件
*
* @param dexFile
*/

public void patchDex(DexFile dexFile) {
if (dexFile == null) return;
Enumeration<String> enumeration = dexFile.entries();
String className;
//遍历dexFile中的类
while (enumeration.hasMoreElements()) {
className = enumeration.nextElement();
//加载修复后的类,只能修复当前Activity后加载类(可以放入Application中执行)
dexFile.loadClass(className, getClassLoader());
}
}
}
复制代码

方法很简单在启动页,或者Application中提前加载有bug的类



这里写的很简单,只是展示核心代码,实际开发过程中,dex包下载的网络请求,据当前app版本下载不同的修复dex,文件存在的时候可以在Application中先加载一次,启动页就不用加载,等等,一系列优化和判断处理,这里就不过多说明,具体一些处理看github上的代码



###ok 代码都了解了,这个 fix.dex 文件哪里来的呢
熟悉Android apk生成的小伙伴都知道了,跳过这个步骤,不懂的小伙伴继续往下看


上面的InitActivitystartActivity(new Intent(this, MainActivity.class)); 启动了一个MainActivity
看看我的MainActivity


public class MainActivity extends FragmentActivity {

@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
//0不能做被除数,这里会报ArithmeticException异常
Toast.makeText(this, "结果" + 10 / 0, Toast.LENGTH_LONG).show();
}
}
复制代码

哎呀不小心,写了一个bug 0 咋能做除数呢,app已经上线了,这里必崩啊,咋办
不要急,按照以下步骤:



  1. 我们要修复这个类MainActivity,先把bug解决


 Toast.makeText(this, "结果" + 10 / 2, Toast.LENGTH_LONG).show();
复制代码


  1. 把修复类生成.class文件(可以先run一次,之后在 build/intermediates/javac/debug/classes/com开的的文件夹,找到生成的class文件,也可以通过javac 命令行生成,也可以通过右边的gradle Task生成)
    class 路径图

  2. 把修复类.class文件 打包成dex (其他.class删除,只保留修复类) 打开cmd命令行,输入下面命令


D:\Android\sdk\build-tools\28.0.3\dx.bat --dex --output C:\Users\pei\Desktop\dx\fix.dex C:\Users\pei\Desktop\dx\
复制代码

D:\Android\sdk 为自己sdk目录 28.0.3build-tools版本,可以根据自己已经下载的版本更换
后面两个目录分别是生成.dex文件目录,和.class文件目录



切记 .class文件的目录必须是包名一样的,我的目录是 C:\Users\pei\Desktop\dx\com\pei\test\MainActivity.class,不然会报 class name does not match path




  1. 这样dx文件夹下就会生成fix.dex文件了,把fix.dex放进手机根目录试试吧


再次打开App,完美Toast 结果5,完美解决


总结



  1. 修复方法要在bug类之前执行

  2. 适合少量bug,太多bug影响性能

  3. 目前只能修复类,不能修复资源文件

  4. 目前只能适配单dex的项目,多dex的项目由于当前类和所有的引用类在同一个dex会 当前类被打上CLASS_ISPREVERIFIED标记,被打上这个标记的类不能引用其他dex中的类,否则就会报错
    解决办法是在构造方法里引用一个单独的dex中的类,这样不符合规则就不会被标记了
作者:one裴s
来源:https://juejin.cn/post/7203989318271483960
收起阅读 »

介绍一个令强迫症讨厌的小红点组件

@charset "UTF-8";.markdown-body{word-break:break-word;line-height:1.75;font-weight:400;font-size:15px;overflow-x:hidden;color:#333...
继续阅读 »

前言


在 App 的运营中,活跃度是一个重要的指标,日活/月活……为了提高活跃度,就发明了小红点,然后让强迫症用户“没法活”。


image.png


小红点虽然很讨厌,但是为了 KPI,程序员也不得不屈从运营同学的逼迫(讨好),得想办法实现。这一篇,来介绍一个徽标(Badge)组件,能够快速搞定应用内的小红点。


Badge 组件


Badge 组件被 Flutter 官方推荐,利用它让小红点的实现非常轻松,只需要2个步骤就能搞定。



  1. 引入依赖


pubspec.yaml文件种引入相应版本的依赖,如下所示。


badges: ^2.0.3
复制代码


  1. 将需要使用小红点的组件使用 Badge 作为上级组件,设置小红点的位置、显示内容、颜色(没错,也可以改成小蓝点)等参数,示例代码如下所示。


Badge(
badgeContent: Text('3'),
position: BadgePosition.topEnd(top: -10, end: -10),
badgeColor: Colors.blue,
child: Icon(Icons.settings),
)
复制代码

position可以设置徽标在组件的相对位置,包括右上角(topEnd)、右下角(bottomEnd)、左上角(topStart)、左下角(bottomStart)和居中(center)等位置。并可以通过调整垂直方向和水平方向的相对位置来进行位置的细微调整。当然,Badge 组件考虑了很多应用场景,因此还有其他的一些参数:



  • elevation:阴影偏移量,默认为2,可以设置为0消除阴影;

  • gradient:渐变色填充背景;

  • toAnimate:徽标内容改变后是否启用动效哦,默认有动效。

  • shape:徽标的形状,默认是原型,也可以设置为方形,设置为方形的时候可以使用 borderRadius 属性设置圆角弧度。

  • borderRadius:圆角的半径。

  • animationType:内容改变后的动画类型,有渐现(fade)、滑动(slide)和缩放(scale)三种效果。

  • showBadge:是否显示徽标,我们可以利用这个控制小红点的显示与否,比如没有提醒的时候该值设置为 false 即可隐藏掉小红点。


总的来说,这些参数能够满足所有需要使用徽标的场景了。


实例


我们来看一个实例,我们分别在导航栏右上角、内容区和底部导航栏使用了三种类型的徽标,实现效果如下。


badge.gif


其中导航栏的代码如下,这是 Badge 最简单的实现方式了。


AppBar(
title: const Text('Badge Demo'),
actions: [
Badge(
showBadge: _badgeNumber > 0,
padding: const EdgeInsets.all(4.0),
badgeContent: Text(
_badgeNumber < 99 ? _badgeNumber.toString() : '99+',
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white,
fontSize: 11.0,
),
),
position: BadgePosition.topEnd(top: 4, end: 4),
child: IconButton(
onPressed: () {},
icon: const Icon(
Icons.message_outlined,
color: Colors.white,
),
),
),
],
),
复制代码

内容区的徽标代码如下,这里使用了渐变色填充,动画形式为缩放,并且将徽标放到了左上角,注意如果使用了渐变色那么会覆盖 badgeColor 指定的背景色。


Badge(
showBadge: _badgeNumber > 0,
padding: const EdgeInsets.all(6.0),
badgeContent: Text(
_badgeNumber < 99 ? _badgeNumber.toString() : '99+',
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white,
fontSize: 10.0,
),
),
position: BadgePosition.topStart(top: -10, start: -10),
badgeColor: Colors.blue,
animationType: BadgeAnimationType.scale,
elevation: 0.0,
gradient: const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.red,
Colors.orange,
Colors.green,
],
),
child: Image.asset(
'images/girl.jpeg',
width: 200,
height: 200,
),
),
复制代码

底部导航栏的代码如下所示,这里需要注意,Badge 组件会根据内容区的尺寸自动调节大小,底部导航栏的显示控件有限,推荐使用小红点(不用数字标识)即可。


BottomNavigationBar(items: [
BottomNavigationBarItem(
icon: Badge(
showBadge: _badgeNumber > 0,
padding: const EdgeInsets.all(2.0),
badgeContent: Text(
_badgeNumber < 99 ? _badgeNumber.toString() : '99+',
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white,
fontSize: 11.0,
),
),
position: BadgePosition.topEnd(top: -4, end: -6),
animationType: BadgeAnimationType.fade,
child: const Icon(Icons.home_outlined)),
label: '首页',
),
const BottomNavigationBarItem(
icon: Icon(
Icons.star_border,
),
label: '推荐',
),
const BottomNavigationBarItem(
icon: Icon(
Icons.account_circle_outlined,
),
label: '我的',
),
]),
复制代码

总结


本篇介绍了使用 Badge 组件实现小红点徽标组件。可以看到,Badge 组件的使用非常简单,相比我们自己从零写一个 Badge 组件来说,使用它可以让我们省时省力、快速地完成运营同学要的小红点。本篇源码已上传至:实用组件相关代码

作者:岛上码农
来源:https://juejin.cn/post/7188124857958137911

收起阅读 »

运动APP视频轨迹回放分享实现

.markdown-body{word-break:break-word;line-height:1.75;font-weight:400;font-size:16px;overflow-x:hidden;color:#252933}.markdown-bod...
继续阅读 »

喜欢户外运动的朋友一般都应该使用过运动APP(keep, 咕咚,悦跑圈,国外的Strava等)的一项功能,就是运动轨迹视频分享,分享到朋友圈或是运动群的圈子里。笔者本身平常也是喜欢户外跑、骑行、爬山等户外运动,也跑过半马、全马,疫情原因之前报的杭州的全马也延期了好几次了。回归正题,本文笔者基于自己的思想实现运动轨迹回放的一套算法策略,实现本身是基于Mapbox地图的,但是其实可以套用在任何地图都可以实现,基本可以脱离地图SDK的API。Mapbox 10 版本之后的官方给出的Demo里已经有类似轨迹回放的Case了,但是深度地依赖地图SDK本身的API,倘若在高德上实现很难可以迁移的。


这里先看下gif动图的效果,这是我在奥森跑的10KM的一个轨迹:


轨迹视频回放_AdobeExpress .gif


整个的实现包含了轨迹的回放,视频的录制,然后视频的录制这块不再笔者这篇文章的介绍的范畴内。所以这里主要介绍轨迹的回放,这个回放过程其实也是包含了大概10多种动画在里面的,辅助信息距离的文字跳转动画;距离下面配速、运动时间等的flap in 及 out的动画;播放button,底部button的渐变Visibility; 地图的缩放以及视觉角度的变化等;以上的这些也不做讨论。主要介绍轨迹回放、整公里点的显示(起始、结束), 回放过程中窗口控制等,作为主要的讲解范畴。


首先介绍笔者最开始的一种实现,假如以上轨迹List 有一百个点,每相邻的两个点做Animation之后,在AnimationEnd的Listener里开起距离下一个点的Animation,直到所有点结束,这里有个问题每次的运动轨迹的点的数量不一样,所以开起Animation的次数也不一样,整个轨迹回放的时间等于所有的Animation执行的时间和,每次动画启动需要损耗20~30ms。倘若要分享到微信朋友圈,视频的时间是限制的,但之前的那种方式时间上显然不可控,每次动画启动的损耗累加导致视频播放不完。


紧接着换成AnimationSet, 将各个线段Animation的动画放入Set里,然后playSequentially执行,同样存在上面的问题。假如只执行一次动画,那么这次动画start的损耗在整个视频播放上时长上的占比就可以忽略不计了,那如何才能将整个List的回放在一个Animation下执行完呢?假如轨迹只是一个普通的 Path,那么我们就可以基于Path的 length一个属性动画了,当转化到地图运动轨迹上去时,又如何去实现呢?


基于Path Length的属性动画



  1. 计算List对应的Path

  2. 通过PathMeasure获取 Path 的 Length

  3. 对Path做 Length的属性动画


这里有两套Point体系,一个是View的Path对应的Points, 然后就是Map上的List对应的Points,运动轨迹原始数据是Map上的List 点,上面的第一步就是将Map上的Points 转成屏幕Pixel对应的点并生成Path; 第二部通过PathMeasure 计算Path的Length; 最后在Path Length上做属性动画,然而这里并非将属性动画中每次渐变的值(这里对应的是View的Point点)绘制成View对应的Path,而是将渐变中的点又通过Map的SDK转成地图Location点,绘制地图轨迹。这里一共做了两道转换,中间只是借助View的Path做了一个依仗Length属性做的一个动画。因为基本上每种地图SDK都有Pixel 跟Location Point点互相transform的API,所以这个可以直接迁移到其它地图上,例如高德地图等。


下面具体看下代码,先将Location 转成View的Point体系,这里保存了总的一个Path,以及List 中两两相邻点对应的分段Path的一个list.



  • 生成Path:


1.1 生成Path2.png


其中用到 Mapbox地图API Location 点转View的PointF 接口API toScreenLocation(LatLng latlng), 这里生成List, 然后计算得到Path.




  • 基于Length做属性动画:


1.3 Path length 属性动画.png


首先创建属性动画的 Instance:


ValueAnimator.ofObject(new DstPathEvaluator(), 0, mPathMeasure.getLength());
复制代码

将每次渐变的值经过 calculateAnimPathData(value) 计算后存入到 以下的四个变量中,这里除了Length的渐变值,还附带有角度的一个二元组值。


dstPathEndPoint[0] = 0;//x坐标
dstPathEndPoint[1] = 0;//y坐标
dstPathTan[0] = 0;//角度值
dstPathTan[1] = 0;//角度值
复制代码

然后将dstPathEndPoint 的值转成Mapbox的 Location的 Latlng 经纬度点,


PointF lastPoint = new PointF(dstPathEndPoint[0], dstPathEndPoint[1]);
LatLng lastLatLng = mapboxMap.getProjection().fromScreenLocation(lastPoint);
Point point = Point.fromLngLat(lastLatLng.getLongitude(), lastLatLng.getLatitude());
复制代码

过滤掉一些动画过程中可能产生的异常点,最后加入到Mapbox的轨迹绘制的Layer中形成轨迹的一个渐变:


Location curLocation = mLocationList.get(animIndex);
float degrees = MapBoxPathUtil.getRotate(curLocation, point);
if (animIndex < 5 || Math.abs(degrees - curRotate) < 5) {//排除异常点
setMarkerRecord(point);
}
复制代码

setMarkerRecord(point) 方法调用加入到 Map 轨迹的绘制Layer中


1.4 加入到Map轨迹绘制.png


动画过程中,当加入到Path中的点超过一定占比时,做了一个窗口显示的动画,窗口List跟整个List的一个计算:


//这里可以取后半段的数据,滑动窗口,保持 moveCamera 的窗口值不变。
int moveSize = passedPointList.size();
List windowPassList = passedPointList.subList(moveSize - windowLength, moveSize);
复制代码

接下来看整公里点的绘制,看之前先看下上面的calculateAnimPathData()方法的逻辑


1.5 Path渐变的计算.png


如上,length为当前Path走过的距离,假设轨迹一共100点,当前走到 49 ~ 50 点之间,那么calculateLength就是0到50这个点的Path的长度,它是大于length的,offsetLength = calculateLength - length; 记录的是 当前点到50号点的一个长度offsetLength,animIndex值当前值对应50,recordPathList为一开始提到的跟计算总Path时一个分段Path的List, 获取到49 ~ 50 这个Path对应的一个model.


RecordPathBean recordPathBean = recordPathList.get(animIndex);
复制代码

获得Path(49 ~ 50) 的长度减去 当前点到 50的Path(cur ~ 50)的到 Path(49 ~ cur) 的长度


float stopD = (float) (pathMeasure.getLength() - offsetLengthCur);
复制代码

然后最终通过PathMeasure的 getPosTan 获得dstPathEndPoint以及dstPathTan数据。


pathMeasure.getSegment(0, stopD, dstPath, false);
mDstPathMeasure = new PathMeasure(dstPath, false);
//这里有个参数 tan
mDstPathMeasure.getPosTan(mDstPathMeasure.getLength(), dstPathEndPoint, dstPathTan);
复制代码


  • 整公里点的绘制


原始数据中的List的Location中存储了一个字段kilometer, 当某个Location是整公里点时该字段就有对应的值,每次Path属性渐变时,上面的逻辑里记录了lastAnimIndex, animIndex。当 animIndex > lastAnimIndex时, 上面的calculateAnimPathData() 方法里分析animIndex有可能还没走到,所以在animIndex > lastAnimIndex时lastAnimIndex肯定走到了。


1.6 整公里点动画.png


当lastAnimIndex对应的点是 整公里时,做一个响应的属性动画。


至此,运动轨迹回放的一个动画执行逻辑分析完了,如文章开始所说,整个过程中其实还包含了好多种其它的动画,处理它们播放的一个时序问题,如何编排实现等等也是一个难点。另外还就是轨迹播放时的一个Camera的一个视觉跟踪的效果没有实现,这个用地图本身的Camera 的API是一种实现,但是如何跟上面的这些结合到一块;然后就是自行通过计算角度偏移,累计到一定的旋转角度时,转移地图的指南针;以上是笔者想到的方案,以上有计算角度的,但需要找准那个累计的角度值,然后大量实际数据适配。


最后,有需要了解轨迹回放功能其它实现的,可留言或私信笔者进行一起探讨。

作者:cxy107750
来源:https://juejin.cn/post/7183602475591548986

收起阅读 »

Android再探全面屏适配

.markdown-body{color:#383838;font-size:15px;line-height:30px;letter-spacing:2px;word-break:break-word;font-family:-apple-system,Bl...
继续阅读 »

前言


简单来说,以前是做app的,然后转去做了终端几年,现在又做回了app,然后就涉及到了全面屏的适配,但是很多年前做的适配也不记得了,所以来重新再探究一遍。


以前做终端的时候,适配?我不知道什么叫适配,就一个机型,想怎么玩就怎么玩,自己就是爹。现在做应用,不好意思,手机厂商才是大爹,我们都是孙子。


我简单的回顾了一下,其实全面屏的适配一开始是因为刘海屏才开始这条路线,然后就出现一大堆奇奇怪怪的东西。幸好谷歌也是做人,在28之后就提出一套规范。


Android P前后


对于Android P,其实也就android 8.0和android 9.0两个版本,因为是从android 8.0开始流行的,各做各的,然后在9.0的时候google给出了一套规范。


对于Android 9.0也就是28,google推出了DisplayCutout,它统一了android凹凸屏的处理,使用起来也很方便。


WindowManager.LayoutParams wlp = getWindow().getAttributes();
wlp.layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES;
getWindow().setAttributes(wlp);
复制代码

给WindowManager.LayoutParams设置layoutInDisplayCutoutMode就行,是不是很简单。

它有几个参数可供选择


(1)LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT:默认值,一般效果和LAYOUT_IN_DISPLAY_CUTOUT_MODE_NEVER相同。

(2)LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES:内容显示到凹凸屏区域。

(3)LAYOUT_IN_DISPLAY_CUTOUT_MODE_NEVER:内容不会显示到凹凸屏区域。


对于Android 28以下的适配


这个比较麻烦,因为在28以下是没有layoutInDisplayCutoutMode的,所以要单独去调,网上也有很多说如何去对不同的厂商去做适配,但其实这东西还是要调的。哪怕你是相同的机型,不同的系统版本都可能会产生不同的效果,没错,就是这么恐怖。基本都是只能做if-else单独对不同的机型做适配。要么就是让28以下的统一不做全面屏的效果,比如说把内容显示到凹凸屏区域,你就判断在28的时候不做这种操作,但一般不是你说的算,多多少少还是需要做适配,只能具体情况具体调试。


对不同的场景做适配


你觉得你说你就对28做适配,28以下就不管了,我就设置layoutInDisplayCutoutMode一行代码就行。可事情哪有这么简单。


系统的Bar主要分为3种,一种是在屏幕上方的状态栏,一种是在屏幕底端的导航栏,还是一直是仿IOS的底部横条代替导航栏,这在和导航栏一起分析但会有些许不同。


而这个过程中又会区分为横屏和竖屏的情况,多少也会又些许差异,当然我也没办法把全部特殊的常见列举出来。不同的手机厂商之间也会存在有不同的情况,还有上面说的android28前后,这里主要是对android28之后进行分析。


状态栏


假如要实现全屏显示的效果,我们要如何去对状态栏做适配。


为了方便调试,我把window的颜色设置为橙色,把布局的颜色设置成绿色

作者:流浪汉kylin

来源:juejin.cn/post/7201332537338806328

收起阅读 »

Android自定义View绘制进阶-水波浪温度刻度表

.markdown-body{color:#595959;font-size:15px;font-family:-apple-system,system-ui,BlinkMacSystemFont,Helvetica Neue,PingFang SC,Hira...
继续阅读 »

波浪形温度刻度表实现


前言


之前的绘制圆环,我们了解了如何绘制想要的形状和进度的一些特点,那么此篇文章我们更近一步,绘制一个稍微复杂一点的刻度与波浪。来一起复习一下Android的绘制。


相对应的这种类型的自定义View网上并不少见,但是如果我们要做一些个性化的效果,最好还是自己绘制一份,也相对的比较容易控制效果,如果想实现上面的效果,我们一般来说分为以下几个步骤:



  1. 重写测量方法,确保它是一个正方形

  2. 绘制刻度

  3. 绘制中心的圆与文字

  4. 水波纹的动画

  5. 设置进度与动画,一起动起来


思路我们已经有了,下面一步一步的来实现吧。


话不多说,Let's go


300.png


1、onMeasure重新测量


之前的圆环进度,我们并没有重写 onMeasure 方法,而是在布局中指定为固定的宽高,其实兼容性和健壮性并不好,万一写错了就会变形导致显示异常。


最好的办法是不管xml中设置为什么值,这里都能保证为一个正方形,要么是取宽度为准,让高度和宽度一致,要么就是宽度高度取最大值,让他们保持一致。由于我们是竖屏的应用,所以我就取宽度为准,让高度和宽度一致。


前面我们只是讲了 onDraw 并没有讲到 onMeasure , 这里简单的说一下。


我们为什么要重写 onMeasure ?



  1. 为了自定义View尺寸的规则,如果你的自定义View的尺寸是根据父控件行为一致,就不需要重写onMeasure()方法。

  2. 如果不重写onMeasure方法,那么自定义view的尺寸默认就和父控件一样大小,当然也可以在布局文件里面写死宽高,而重写该方法可以根据自己的需求设置自定义view大小。


一般来说我们重写的 onMeasure 长这样:


 override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec,heightMeasureSpec)
}
复制代码

widthMeasureSpec ,heightMeasureSpec 并不是真正的宽高,看名字就知道,它只是宽高测量的规格,我们通过 MeasureSpec 的一些静态方法,通过它们拿到一些信息。


static int getMode(int measureSpec):根据提供的测量值(规格)提取模式(上述三个模式之一)


测量的 Model 一共有三种



  1. UNSPECIFIED(未指定),父元素部队自元素施加任何束缚,子元素可以得到任意想要的大小;

  2. EXACTLY(完全),父元素决定自元素的确切大小,子元素将被限定在给定的边界里而忽略它本身大小;

  3. AT_MOST(至多),子元素至多达到指定大小的值。


我们常用的就是 EXACTLY 和 AT_MOST ,EXACTLY 对应的就是我们设置的match_parent或者300这样的精确值,而 AT_MOST 对应的就是wrap_content。


static int getSize(int measureSpec):根据提供的测量值(规格)提取大小值(这个大小也就是我们通常所说的大小)


通过此方法就能获取控件的宽度和高度值。


static int makeMeasureSpec(int size,int mode):根据提供的大小值和模式创建一个测量值(规格)


通过具体的宽高和model,创建对应的宽高测量规格,用于确定View的测量


onMeasure 的最终设置确定宽度的测量有两种方式,



  1. setMeasuredDimension(width, height)

  2. super.onMeasure(widthMeasureSpec,heightMeasureSpec)


实战:


比如我们的自定义温度刻度View,我们整个View要确保一个正方形,那么就拿到宽度,设置同样的高度,然后确定测量,流程如下:


    //重新测量
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {

//获取控件的宽度,高度
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int newWidthMeasureSpec = widthMeasureSpec;

//如果没有指定宽度,默认给200宽度
if (widthMode != MeasureSpec.EXACTLY) {
newWidthMeasureSpec = MeasureSpec.makeMeasureSpec(200, MeasureSpec.EXACTLY);
}

//获取到最新的宽度
int width = MeasureSpec.getSize(newWidthMeasureSpec) - getPaddingLeft() - getPaddingRight();

//我们要的是矩形,不管高度是多高,让它总是和宽度一致
int height = width;

centerPosition.x = width / 2;
centerPosition.y = height / 2;
radius = width / 2f;
mRectF.set(0f, 0f, width, height);


//最后设置生效-下面两种方式都可以
// setMeasuredDimension(width, height);

super.onMeasure(
MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)
);

}
复制代码

这里有详细的注释,大致实现的效果如下:


image.png


2、绘制刻度


由于原本的 Canvas 内部没有绘制刻度这么一说,所以我们只能用绘制线条的方式,就是 drawLine 方法。


为了了解到坐标系和方便实现,我们可以先绘制一个圆环,定位我们刻度需要绘制的位置。


    @Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);

//画圆环
canvas.drawArc(
mRectF.left + 2f, mRectF.top + 2f, mRectF.right - 2f, mRectF.bottom - 2f,
mStartAngle, mSweepAngle, false, mDegreeCirPaint
);
}
复制代码

这个圆环是之前讲到过了,就不过多赘述了,实现效果如下:


image.png


由于开始绘制的地方在左上角位置,我们要移动到圆的中心点开始绘制,也就是红色点移动到蓝色点。


我们就需要x轴和y轴做一下偏移 canvas.translate(radius, radius);


默认的 drawLine 都是横向绘制,我们想要实现效果图的效果,就需要旋转一下画笔,也就是用到 canvas.rotate(rotateAngle);


那么旋转多少了,如果说最底部是90度,我们的起始角度是120度开始的,我们就起始旋转30度。后面每一次旋转就按照百分比来,比如我们100度的温度,那么就相当于要画100个刻度,我们就用需要绘制的角度除以100,就是每一个刻度的角度。


具体的刻度实现代码:



private float mStartAngle = 120f; // 圆弧的起始角度
private float mSweepAngle = 300f; //绘制的起始角度和滑过角度(绘制300度)
private float mTargetAngle = 300f; //刻度的角度(根据此计算需要绘制有色的进度)

private void drawDegreeLine(Canvas canvas) {
//先保存
canvas.save();

// 移动画布
canvas.translate(radius, radius);
// 旋转坐标系,需要确定旋转角度
canvas.rotate(30);

// 每次旋转的角度
float rotateAngle = mSweepAngle / 100;
// 累计叠加的角度
float currentAngle = 0;
for (int i = 0; i <= 100; i++) {

if (currentAngle <= mTargetAngle && mTargetAngle != 0) {
// 计算累计划过的刻度百分比
float percent = currentAngle / mSweepAngle;

//动态的设置颜色
mDegreelinePaint.setColor(evaluateColor(percent, Color.GREEN, Color.RED));

canvas.drawLine(0, radius, 0, radius - 20, mDegreelinePaint);

// 画过的角度进行叠加
currentAngle += rotateAngle;

} else {
mDegreelinePaint.setColor(Color.WHITE);
canvas.drawLine(0, radius, 0, radius - 20, mDegreelinePaint);
}

//画完一个刻度就要旋转移动位置
canvas.rotate(rotateAngle);
}

//再恢复
canvas.restore();

}
复制代码

加上圆环与刻度的效果图:
image.png


3. 设置刻度动画


前面的一篇我们使用的是属性动画不停的绘制从而实现进度的效果,那么这一次我们使用定时任务的方式也是可以实现动画的效果。


由于我们之前的 drawDegreeLine 方法内部控制绘制进度的变量就是 targetAngle 来控制的,所以我们通过入口方法设置温度的时候通过定时任务的方式来控制。


代码如下:



//动画状态
private boolean isAnimRunning;
// 手动实现越来越慢的效果
private int[] slow = {10, 10, 10, 8, 8, 8, 6, 6, 6, 6, 4, 4, 4, 4, 2};
// 动画的下标
private int goIndex = 0;

//设置温度,入口的开始
public void setupTemperature(float temperature) {
mCurPercent = 0f;
totalAngle = (temperature / 100) * mSweepAngle;
targetAngle = 0f;
mCurPercent = 0f;
mCurTemperature = "0.0";
mWaveUpValue = 0;

startTimerAnim();
}

//使用定时任务做动画
private void startTimerAnim() {

if (isAnimRunning) {
return;
}

mAnimTimer = new Timer();
mAnimTimer.schedule(new TimerTask() {

@Override
public void run() {

isAnimRunning = true;
targetAngle += slow[goIndex];
goIndex++;
if (goIndex == slow.length) {
goIndex--;
}
if (targetAngle >= totalAngle) {
targetAngle = totalAngle;
isAnimRunning = false;
mAnimTimer.cancel();
}

// 计算的温度
mCurPercent = targetAngle / mSweepAngle;
mCurTemperature = mDecimalFormat.format(mCurPercent * 100);

// 水波纹的高度
mWaveUpValue = (int) (mCurPercent * (mSmallRadius * 2));

postInvalidate();
}
}, 250, 30);

}
复制代码

那么刻度动画的效果如下:


rote-02.gif


4. 绘制中心的圆与文字


我们再动画中记录动画的百分比进度,和动画当前的温度。


    ...    
// 计算的温度
mCurPercent = targetAngle / mSweepAngle;
mCurTemperature = mDecimalFormat.format(mCurPercent * 100);

postInvalidate();

...
复制代码

我们记录一下小圆的半径和文本的画笔资源


   private float mSmallRadius = 0f;
private Paint mTextPaint;
private Paint mSmallCirclePaint;
private float mCurPercent = 0f; //进度
private String mCurTemperature = "0.0";
private DecimalFormat mDecimalFormat;

private void init() {
...

mTextPaint = new Paint();
mTextPaint.setAntiAlias(true);
mTextPaint.setTextAlign(Paint.Align.CENTER);
mTextPaint.setColor(Color.WHITE);

mSmallCirclePaint = new Paint();
}

@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);

...

//画小圆
drawSmallCircle(canvas, evaluateColor(mCurPercent, Color.GREEN, Color.RED));

//画中心的圆与文本
drawTemperatureText(canvas);

}
复制代码

具体的文本与小圆的绘制


    private void drawSmallCircle(Canvas canvas, int evaluateColor) {
mSmallCirclePaint.setColor(evaluateColor);
mSmallCirclePaint.setAlpha(65);
canvas.drawCircle(centerPosition.x, centerPosition.y, mSmallRadius, mSmallCirclePaint);
}

private void drawTemperatureText(Canvas canvas) {

//提示文字
mTextPaint.setTextSize(mSmallRadius / 6f);
canvas.drawText("当前温度", centerPosition.x, centerPosition.y - mSmallRadius / 2f, mTextPaint);

//温度文字
mTextPaint.setTextSize(mSmallRadius / 2f);
canvas.drawText(mCurTemperature, centerPosition.x, centerPosition.y + mSmallRadius / 4f, mTextPaint);

//绘制单位
mTextPaint.setTextSize(mSmallRadius / 6f);
canvas.drawText("°C", centerPosition.x + (mSmallRadius / 1.5f), centerPosition.y, mTextPaint);

}
复制代码

由于进度和温度都是动画在 invalidate 之前赋值的,所以我们的文本和小圆天然就支持动画的效果了。


效果如下:


rote-03.gif


5. 水波纹动画


水波纹的效果,我们不能直接用 Canvas 来绘制,我们可以用刻度的方法用 drawLine的方式来绘制,如何绘制呢?相信大家也有了解,就是正弦函数了。


由于我们的效果是两个水波纹相互叠加起起伏伏的效果,所以我们定义两个函数。


总体的思路是:我们定义两个数组来管理我们的Y轴的值,通过正弦函数给Y轴赋值,然后在drawLine的时候取出对应的x轴的y值就可以绘制出来。


x轴其实就是我们的控件宽度,我们先用一个数组保存起来


    private float[] mFirstWaterLine;
private float[] mSecondWaterLine;

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {

//获取控件的宽度,高度
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int newWidthMeasureSpec = widthMeasureSpec;

//如果没有指定宽度,默认给200宽度
if (widthMode != MeasureSpec.EXACTLY) {
newWidthMeasureSpec = MeasureSpec.makeMeasureSpec(200, MeasureSpec.EXACTLY);
}

//获取到最新的宽度
int width = MeasureSpec.getSize(newWidthMeasureSpec) - getPaddingLeft() - getPaddingRight();

//我们要的是矩形,不管高度是多高,让它总是和宽度一致
int height = width;


mFirstWaterLine = new float[width];
mSecondWaterLine = new float[width];


super.onMeasure(
MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)
);

}

复制代码

然后我们再绘制之前就先对x轴对应的y值赋值,然后绘制的时候就取出对应的y值来 drawLine,具体的代码如下:


动画的时候先对横向运动和垂直运动的变量做一个赋值:


    private int mWaveUpValue = 0;
private float mWaveMoveValue = 0f;


//使用定时任务做动画
private void startTimerAnim() {

if (isAnimRunning) {
return;
}
mAnimTimer = new Timer();
mAnimTimer.schedule(new TimerTask() {

@Override
public void run() {

...

// 计算的温度
mCurPercent = targetAngle / mSweepAngle;
mCurTemperature = mDecimalFormat.format(mCurPercent * 100);

// 水波纹的高度
mWaveUpValue = (int) (mCurPercent * (mSmallRadius * 2));

postInvalidate();
}
}, 250, 30);

}

public void moveWaterLine() {
mWaveTimer = new Timer();
mWaveTimer.schedule(new TimerTask() {

@Override
public void run() {
mWaveMoveValue += 1;
if (mWaveMoveValue == 100) {
mWaveMoveValue = 1;
}
postInvalidate();
}
}, 500, 200);
}
复制代码

拿到了对应的变量值之后,然后开始绘制:


 /**
* 绘制水波
*/

private void drawWaterWave(Canvas canvas, int color) {

int len = (int) mRectF.right;

// 将周期定为view总宽度
float mCycleFactorW = (float) (2 * Math.PI / len);

// 得到第一条波的峰值
for (int i = 0; i < len; i++) {
mFirstWaterLine[i] = (float) (10 * Math.sin(mCycleFactorW * i + mWaveMoveValue) - mWaveUpValue);
}
// 得到第一条波的峰值
for (int i = 0; i < len; i++) {
mSecondWaterLine[i] = (float) (15 * Math.sin(mCycleFactorW * i + mWaveMoveValue + 10) - mWaveUpValue);
}

canvas.save();

// 裁剪成圆形区域
Path path = new Path();
path.addCircle(len / 2f, len / 2f, mSmallRadius, Path.Direction.CCW);
canvas.clipPath(path);
path.reset();

// 将坐标系移到底部
canvas.translate(0, centerPosition.y + mSmallRadius);

mSmallCirclePaint.setColor(color);

for (int i = 0; i < len; i++) {
canvas.drawLine(i, mFirstWaterLine[i], i, len, mSmallCirclePaint);
}
for (int i = 0; i < len; i++) {
canvas.drawLine(i, mSecondWaterLine[i], i, len, mSmallCirclePaint);
}

canvas.restore();

}
复制代码

一个是对Y轴赋值,一个是取出x轴对应的y轴进行绘制,这里需要注意的是我们裁剪出了一个小圆的图形,并且覆盖在小圆上面实现出效果图的样子。


运行的效果如下:


rote-04.gif


要记得对定时器进行资源你的关闭哦。


    @Override
protected void onDetachedFromWindow() {
super.onDetachedFromWindow();
if (mWaveTimer != null) {
mWaveTimer.cancel();
}
if (mAnimTimer != null && isAnimRunning) {
mAnimTimer.cancel();
}
}
复制代码

使用的时候我们只需要设置温度即可开始动画。


       findViewById(R.id.set_progress).click {

val temperatureView = findViewById(R.id.temperature_view)
temperatureView .setupTemperature(70f)
}
复制代码

后记


由于是自用定制的,本人也比较懒,所以并没有对一些配置的属性做自定义属性的抽取,比如圆环的间距,大小,颜色,波纹的间距,动画的快慢等等。


内部加了一点点测量的用法,但是主要还是绘制的流程,基本上把常用的几种绘制方式都用到了。以后有类似的效果大家也可以按需修改即可。


由于是自用的一个View,相对圆环进度没有那么多场景使用,就没有抽取出来上传到Maven,如果大家有兴趣可以查看源码点击【传送门】


同时,你也可以关注我的这个Kotlin项目,我有时间都会持续更新。


惯例,我如有讲解不到位或错漏的地方,希望同学们可以指出交流。


如果感觉本文对你有一点点的启发,还望你能点赞支持一下,你的支持是我最大的动力。


Ok,这一期就此完结。


作者:newki
来源:https://juejin.cn/post/7166151382154608670

收起阅读 »

Android:面向单Activity开发

记得前一两年很多人都跟风面向单Activity开发,顾名思义,就是整个项目只有一个Activity。一个Activity里面装着N多个Fragment,再给Fragment加上转场动画,效果和多Activity跳转无异。其实想想还比较酷,以前还需要关注多个Ac...
继续阅读 »

记得前一两年很多人都跟风面向单Activity开发,顾名思义,就是整个项目只有一个Activity。一个Activity里面装着N多个Fragment,再给Fragment加上转场动画,效果和多Activity跳转无异。其实想想还比较酷,以前还需要关注多个Acitivity之间的生命周期,现在只需关注一个,但还是需要对Fragment的生命周期进行关注。



其实早在六七年前GitHub上就有单Activity的开源库Fragmentation,后来谷歌也出了一个库Navigation。本来以为官方出品必为经典,当时跟着官方文档一步一步踩坑,最后还是放弃了该方案。理由大概如下:



  1. 需要创建XML文件,配置导航关系和跳转参数等

  2. 页面回退是重新创建,需要配合livedata使用

  3. 貌似还会存在卡顿,一些栈内跳转处理等问题


而Github上Fragmentation库已经停止维护,所幸的是再lssuse中发现了一个基于它继续维护的SFragmentation,于是正是开启了面向单Activity的开发。


提供了可滑动返回的版本


dependencies {
//请使用最新版本
implementation 'com.github.weikaiyun.SFragmentation:fragmentation:latest'
//滑动返回,可选
implementation 'com.github.weikaiyun.SFragmentation:fragmentation_swipeback:latest'
}
复制代码

由于是Fragment之间的跳转,我们需要将原有的Activity跳转动画在框架初始化时设置到该框架中


Fragmentation.builder() 
//设置 栈视图 模式为 (默认)悬浮球模式 SHAKE: 摇一摇唤出 NONE:隐藏, 仅在Debug环境生效
.stackViewMode(Fragmentation.BUBBLE)
.debug(BuildConfig.DEBUG)
.animation(
R.anim.public_translate_right_to_center, //进入动画
R.anim.public_translate_center_to_left, //隐藏动画
R.anim.public_translate_left_to_center, //重新出现时的动画
R.anim.public_translate_center_to_right //退出动画
)
.install()
复制代码

因为只有一个Activity,所以需要在这个Activity中装载根Fragment


loadRootFragment(int containerId, SupportFragment toFragment)
复制代码

但现在的APP几乎都是一个页面多个Tab组成的怎么办呢?


loadMultipleRootFragment(int containerId, int showPosition, SupportFragment... toFragments);
复制代码

有了多个Fragment的显示,我们需要切换Tab实际也很简单


showHideFragment(ISupportFragment showFragment);
复制代码

是不是使用起来很简单,首页我们解决了,关于跳转和返回、参数的接受和传递呢?


//启动目标fragment
start(SupportFragment fragment)
//带返回的启动方式
startForResult(SupportFragment fragment,int requestCode)
//接收返回参数
override fun onFragmentResult(requestCode: Int, resultCode: Int, data: Bundle?) {
super.onFragmentResult(requestCode, resultCode, data)
}
//返回到上个页面,和activity的back()类似
pop()
复制代码

对于单Activity而言,我们其实也可以注册一个全局的Fragment监听,这样就能掌控当前的Fragmnet


supportFragmentManager.registerFragmentLifecycleCallbacks(
object : FragmentManager.FragmentLifecycleCallbacks() {
override fun onFragmentAttached(fm: FragmentManager, f: Fragment, context: Context) {
super.onFragmentAttached(fm, f, context)
}
override fun onFragmentCreated(
fm: FragmentManager,
f: Fragment,
savedInstanceState: Bundle?
) {
super.onFragmentCreated(fm, f, savedInstanceState)
}
override fun onFragmentStarted(fm: FragmentManager, f: Fragment) {
super.onFragmentStarted(fm, f)
}
override fun onFragmentResumed(fm: FragmentManager, f: Fragment) {
super.onFragmentResumed(fm, f)
}
override fun onFragmentDestroyed(fm: FragmentManager, f: Fragment) {
super.onFragmentDestroyed(fm, f)
}
},
true
)
复制代码

接下来我们看看Pad应用。对于手机应用来说,一般不会存在局部页面跳转的情况,但是Pad上是常规操作。


image.png


如图,点击左边列表的单个item,右边需要显示详情,这时候再点左边的其他item,此时的左边页面是保持不动的,但右边的详情页需要跳转对应的页面。使用过Pad的应该经常见到这种页面,比如Pad的系统设置等页面。这时只使用Activty应该是不能实现的,必须配合Fragment,左右分为两个Fragment。


但问题又出现了,这时候点击back怎么区分局部返回和整个页面返回呢?


//整个页面回退,主要是用于当前装载了Fragment的页面回退
_mActivity.pop()
//局部回退,被装载的Fragment之间回退
pop()
复制代码

如下图,这样的页面我们又应该怎么装载呢?
image.png


可以分析,页面最外面是一个Activty,要实现单Activity其内部必装载了一个根Fragment。接着这个根Fragment中使用ViewPage和tablayout完成主页框架。当前tab页要满足右边详情页的单独跳转,还得将右边页面作为主页面,以此装载子Fragment才能实现。


image.png


总结


单Activity开发在手机和平板上使用都一样,但在平板上注意的地方更多,尤其是平板一个页面可能是多个页面组成,其局部还能单独跳转的功能,其中涉及到参数回传和栈的回退问题。使用下来,我还是觉得某些页面对硬件要求很高的使用单Activity会出现体验不好的情况,有可能是优化不到位。手机应用我还是使用多Activity方式,平板应用则使用该框架实现单Activity方式。


作者:似曾相识2022
链接:https://juejin.cn/post/7204100079430123557
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

教你解决字符对齐问题

前言其实很多人都会碰到文本不对齐,文字不对齐的情况,但是只要不明显被提出,一般都会置之不理。我关注这个问题是因为有个老哥问我倒计时的时候,10以上和10以下会出现宽度变化,因为2位数变1位数确实会变化很大,有的人会说1位数的时候前面补零,这也是一个方法,还有人...
继续阅读 »

前言

其实很多人都会碰到文本不对齐,文字不对齐的情况,但是只要不明显被提出,一般都会置之不理。我关注这个问题是因为有个老哥问我倒计时的时候,10以上和10以下会出现宽度变化,因为2位数变1位数确实会变化很大,有的人会说1位数的时候前面补零,这也是一个方法,还有人说,你设置控件的宽度固定不就行了吗?其实还真不好,即便你宽度固定,你的文字内容也是会变的。

所以我就去想这个问题,虽然不是一个什么大问题,但当你去探究,确实能收获一些不一样的东西。

基础概念

首先回顾一些基础的东西。

1字节是8位,所以1字节能有256种组合,说到这个,就能联系出ASCII码,ASCII码都熟吧,就是数字和字母啊这些。然后ASCII码的定义的符号,是没有到256的,这个也很容易理解,去看看ASCII码的表就知道了。所以,ASCII码中的符号,都能用1个字节表示。

但是你的汉字是没办法用256表示的,我们中华文化博大精深,不是区区256能容纳得下的。所以汉字得用2个字节表示,甚至3个字节表示。然后emoji好像是要占3个字节还是4个字节得,这个我记得不太清了。而且不同的编码占的也不同。

回顾一下这些内容主要是为了找找感觉。

半角和全角

这个相信大家也有点了解,我们平时用输入法的时候就能进行半角全角的切换。

简单来说,全角em是指一个字符占用两个标准字符位置,半角en是指一个字符占用一个标准字符的位置。注意这里说的是占多少的位置,和上面提的字节没关系,不是说你2个字节就占2个位置,1个字节只占一个位置。

但是一般半角和圆角都是针对ASCII码里面的符号的(这个我没找到相应的概念,我是根据现象推导的)

所以先来看看直接设置半角和全角的效果


上面是半角,下面是全角,能明显看出来,中文的半角和全角都是占了两个标准字符的位置,而ASCII码中的符号,在半角的情况下是占一个,在全角的情况下是占两个。

汉字是这样,但是我在找资料的时候看到一个挺有意思的场景。就是日文,因为编码方式,会出现部分日文的半角效果和全角效果是不同的。可以参考这个老哥写的juejin.cn/post/716953… ,用的是JIS C 6220这种编码方式。

那说到这里,其实你就已经有一个概念了,数字中,每个数字在半角情况下都是占一个字符(我这里说占一个坑位可能会更好理解),默认变量输出都是半角,那两位数,就占两个坑位。所以要让1位数的显示和两位数的相同,让1位数占两个坑位不就行了吗,把1位数转成全角就行了。


看我这的效果,蓝色的区域就是全角的效果,看得出是比之前好过一些,但也没办法完全等于两个半角数字的宽度,还是差了点意思。

空格

除了用半角全角的思路去处理,还有办法吗?当然有了,发挥想象力想想,要实现1位数和2位数对齐,我可以给1位数的两边加上空格,不就行了吗,所以这空格也是有讲究滴。

我们可以来看看Unicode中有哪些空格(只列举部分):

  • U+0020:ASCII空格

  • U+00A0:不间断空格

  • U+2002:EN空格

  • U+2003:EM空格

  • U+2004:⅓EM空格

  • U+2005:¼EM空格

  • U+2006:⅙EM空格

  • U+2007:数字空格

  • U+2009:窄空格

  • U+3000:文字空格

如果先了解了半角你就知道什么是en,什么是em,看这些的时候也会更有感觉。那这么多空格,我怎么知道哪个合适?那合不合适,试试不就知道了吗,这不就和谈女朋友一样,去试试嘛


首先看到ASCII空格是合适的,会不会有人看到这里有答案就跑了 ,然后还有几个看着也相近,我们可以单独拿出来比一下。U+2004、U+2005和U+2009


发现都不合适,那这个代码具体要怎么加呢,其实也很简单,直接写\u0020就行,比如我这里的布局就是这样

<TextView
  android:layout_width="wrap_content"
  android:layout_height="wrap_content"
  android:background="@color/blue"
  android:textColor="#000000"
  android:text="\u00206\u0020"
  android:textSize="26sp"
  />

其它

上面都是通过编码的方向去解决这个问题,那还有其它方式吗?当然又有,其实一开始就有人想说了,用几个textview去拼接,然后设置数字的textview固定宽度并且内容居中。

这当然可以。比如“倒计时30秒”这段文字,拆成3个textview,让第二个textview固定宽度并且内容居中,也能实现这个效果,但是这实现方式也太......,所以需要去探索不同的方式去处理。

那绘制可以吗,我不用textview,我自定义一个view然后画上去,我自己画的话能很好把控各种细节的处理。我倒是觉得这是一个好的主意。这是通过绘制的方式去解决这个问题。

所以从这里可以看出,其实解决这个问题的方式有很多,可以从不同的角度去处理。

作者:流浪汉kylin
来源:juejin.cn/post/7202501888616431672

收起阅读 »

如何自动打开你的 App?

相信大家在刷 某博 / 某书 / 某音 的时候,最能体会什么叫做 条条大路通 tao bao。经常是你打开一个 App,不小心点了下屏幕,就又打开了另一个 App 了。那么这种自动打开一个 App 到底是怎么实现的呢?URL Scheme首先是最原始的方式 U...
继续阅读 »


相信大家在刷 某博 / 某书 / 某音 的时候,最能体会什么叫做 条条大路通 tao bao。经常是你打开一个 App,不小心点了下屏幕,就又打开了另一个 App 了。

那么这种自动打开一个 App 到底是怎么实现的呢?

URL Scheme

首先是最原始的方式 URL Scheme。

URL Scheme 是一种特殊的 URL,用于定位到某个应用以及应用的某个功能。

它的格式一般是: [scheme:][//authority][path][?query]

scheme 代表要打开的应用,每个上架应用商店的 App 所注册的 scheme 都是唯一的;后面的参数代表应用下的某个功能及其参数。

在 IOS 上配置 URL Scheme

在 XCode 里可以轻松配置


在 Android 上配置 URL Scheme

Android 的配置也很简单,在 AndroidManifest.xml 文件下添加以下配置即可


通过访问链接自动打开 App

配置完成后,只要访问 URL Scheme 链接,系统便会自动打开对应 scheme 的 App。

因此,我们可以实现一个简单的 H5 页面来承载这个跳转逻辑,然后在页面中通过调用 location.href=schemeUrl 或者 <a href='schemeUrl' /> 等方式来触发访问链接,从而自动打开 App

优缺点分析

优点: 这个是最原始的方案,因此最大的优点就是兼容性好

缺点:

  1. 通过 scheme url 这种方式唤起 App,对于 H5 中间页面是无法感知的,并不知道是否已经成功打开 App

  2. 部分浏览器有安全限制,自动跳转会被拦截,必须用户手动触发跳转(即 location.href 行不通,必须 a 标签)

  3. 一些 App 会限制可访问的 scheme,你必须要在白名单内,否则也会被拦截跳转

  4. 通过 scheme url 唤起 App 时,浏览器会提示你是否确定要打开该 App,会影响用户体验

DeepLink

通过上述缺点我们可以看出,传统的 URL Scheme 在用户体验上是存在一定缺陷的。

因此,DeepLink 诞生了。

DeepLink 的宗旨就是通过传统的 HTT P链接就可以唤醒app,而如果用户没有安装APP,则会跳转到该链接对应的页面。

IOS Universal Link

在 IOS 上一般称之为 Universal Link。

【配置你的 Universal Link 域名】

首先要去 Apple 的开发者平台上配置你的 domains,假设是: mysite.com


【配置 apple-app-site-association 文件】

在该域名根目录下创建一个 .well-known 路径,并在该路径下放置 apple-app-site-association 文件。

文件内容包含 appID 以及 path,path如果配置 /app 则表示访问该域名下的 /app 路径均能唤起App

该文件内容大致如下:

{
   "applinks": {
       "apps": [],
       "details": [
          {
               "appID": "xxx", // 你的应用的 appID
               "paths": [ "/app/*"]
          }
      ]
  }
}
复制代码

【系统获取配置文件】

上面两步配置成功后,当用户 首次安装App 或者后续每次 覆盖安装App 时,系统都会主动去拉取域名下的配置文件。

即系统会主动去拉取 https://mysite.com/.well-known/apple-app-site-association 这个文件

然后根据返回的 appID 以及 path 判断访问哪些路径是需要唤起哪个App

【自动唤起 App】

当系统成功获取配置文件后,只要用户访问 mysite.com/app/xxx 链接,系统便会自动唤起你的 App。

同时,客户端还可以进行一些自定义逻辑处理:

客户端会接收到 NSUserActivity 对象,其 actionType 为 NSUserActivityTypeBrowsingWeb,因此客户端可以在接收到该对象后做一些跳转逻辑处理。


Android DeepLink

与 IOS Universal Link 原理相似,Android系统也能够直接通过网站地址打开应用程序对应的内容页面,而不需要用户选择使用哪个应用来处理网站地址

【配置 AndroidManifest.xml】 在 AndroidManifest 配置文件中添加对应域名的 intent-filter:

scheme 为 https / http;

host 则是你的域名,假设是: mysite.com


【生成 assetlinks.json 文件】

首先要去 Google developers.google.com/digital-ass… 生成你的 assetlinks json 文件。


【配置 assetlinks.json 文件】

生成文件后,同样的需要在该域名根目录下创建一个 .well-known 路径,并在该路径下放置 assetlinks.json 配置文件,文件内容包含应用的package name 和对应签名的sha哈希

【系统获取配置文件】

配置成功后,当用户 首次安装App 或者后续每次 覆盖安装App 时,系统会进行以下校验:

  1. 如果 intent-filter 的 autoVerify 设置为 true,那么系统会验证其

  • Action 是否为 android.intent.action.VIEW

  • Category 是否为android.intent.category.BROWSABLE 和 android.intent.category.DEFAULT

  • Data scheme 是否为 http 或 https

  1. 如果上述条件都满足,那么系统将会拉取该域名下的 json 配置文件,同时将 App 设置为该域名链接的默认处理App

【自动唤起 App】

当系统成功获取配置文件后,只要用户访问 mysite.com/app/xxx 链接,系统便会自动唤起你的 App。

优缺点分析

【优点】

  1. 用户体验好:可以直接打开 App,没有弹窗提示

  2. 唤起App失败则会跳转链接对应的页面

【缺点】

  1. iOS 9 以后才支持 Universal Link,

  2. Android 6.0 以后才支持 DeepLink

  3. DeepLink 需要依赖远程配置文件,无法保证每次都能成功拉取到配置文件

推荐方案: DeepLink + H5 兜底

基于前面两种方案的优缺点,我推荐的解决方案是配置 DeepLink,同时再加上一个 H5 页面作为兜底。

首先按照前面 DeepLink 的教程先配置好 DeepLink,其中访问路径配置为 https://mysite.com/app

接着,我们就可以在 https://mysite.com/app 路径下做文章了。在该路径下放置一个 H5 页面,内容可以是引导用户打开你的 App。

当用户访问 DeepLink 没有自动打开你的 App 时,此时用户会进入浏览器,并访问 https://mysite.com/app 这个 H5 页面。

在 H5 页面中,你可以通过浏览器 ua 获取当前的系统以及版本:

  1. 如果是 Android 6.0 以下,那么可以尝试用 URL Scheme 去唤起 App

  2. 如果是 IOS / Android 6.0 及以上,那么此时可以判断用户未安装 App。这种情况下可以做些额外的逻辑,比如重定向到应用商店引导用户去下载之类的

作者:龙飞_longfe
来源:juejin.cn/post/7201521440612974649

收起阅读 »

七道Android面试题,先来简单热个身

马上就要到招(tiao)聘(cao)旺季金三银四了,一批一批的社会精英在寻找自己的下一家的同时,也开始着手为面试做准备,回想起自己这些年,也大大小小经历过不少面试,有被面试过,也有当过面试官,其中也总结出了两个观点,一个就是不花一定的时间背些八股文还真的不行,...
继续阅读 »

马上就要到招(tiao)聘(cao)旺季金三银四了,一批一批的社会精英在寻找自己的下一家的同时,也开始着手为面试做准备,回想起自己这些年,也大大小小经历过不少面试,有被面试过,也有当过面试官,其中也总结出了两个观点,一个就是不花一定的时间背些八股文还真的不行,一些扯皮的话别去听,都是在害人,另一个就是面试造火箭,入职拧螺丝毕竟都是少数,真正一场合格的面试问的东西,都是实际开发过程中会遇到的,下面我就说几个我遇到过的面试题吧

为什么ArrayMap比HashMap更适合Android开发

我们一般习惯在项目当中使用HashMap去存储键值队这样的数据,所以往往在android面试当中HashMap是必问环节,但有次面试我记得被问到了有没有有过ArrayMap,我只能说有印象,毕竟用的最多的还是HashMap,然后那个面试官又问我,觉得Android里面更适合用ArrayMap还是HashMap,我就说不上来了,因为也没看过ArrayMap的源码,后来回去看了下才给弄明白了,现在就简单对比下ArrayMap与HashMap的特点

HashMap

  • HashMap的数据结构为数组加链表的结构,jdk1.8之后改为数组加链表加红黑树的结构

  • put的时候,会先计算key的hashcode,然后去数组中寻找这个hashcode的下标,如果数据为空就先resize,然后检查对应下标值(下标值=(数组长度-1)&hashcode)里面是否为空,空则生成一个entry插入,否就判断hascode与key值是否分别都相等,如果相等则覆盖,如果不等就发生哈希冲突,生成一个新的entry插入到链表后面,如果此时链表长度已经大于8且数组长度大于64,则先转成树,将entry添加到树里面

  • get的时候,也是先去查找数组对应下标值里面是否为空,如果不为空且key与hascode都相等,直接返回value,否就判断该节点是否为一个树节点,是就在树里面返回对应entry,否就去遍历整个链表,找出key值相等的entry并返回

ArrayMap

  • 内部维护两个数组,一个是int类型的数组(mHashes)保存key的hashcode,另一个是Object的数组(mArray),用来保存与mHashes对应的key-value

  • put数据的时候,首先用二分查找法找出mHashes里面的下标index来存放hashcode,在mArray对应下标index<<1与(index<<1)+1的位置存放key与value

  • get数据的时候,同样也是用二分查找法找出与key值对应的下标index,接着再从mArray的(index<<1)+1位置将value取出

对比

  • HashMap在存放数据的时候,无论存放的量是多少,首先是会生成一个Entry对象,这个就比较浪费内存空间,而ArrayMap只是把数据插入到数组中,不用生成新的对象

  • 存放大量数据的时候,ArrayMap性能上就不如HashMap,因为ArrayMap使用的是二分查找法找的下标,当数据多了下标值找起来时间就花的久,此外还需要将所有数据往后移再插入数据,而HashMap只要插入到链表或者树后面即可

所以这就是为什么,在没有那么大的数据量需求下,Android在性能角度上比较适合用ArrayMap

为什么Arrays.asList后往里add数据会报错

这个问题我当初问过不少人,不缺乏一些资历比较深的大佬,但是他们基本都表示不清楚,这说明平时我们研究Glide,OkHttp这样的三方库源码比较多,而像一些比较基础的往往会被人忽略,而有些问题如果被忽略了,往往会产生一些捉摸不透的问题,比如有的人喜欢用Arrays.asList去生成一个List

val dataList = Arrays.asList(1,2,3)
dataList.add(4)

但是当我们往这个List里面add数据的时候,我们会发现,crash了,看到的日志是


不被支持的操作,这让首次遇到这样问题的人肯定是一脸懵,List不让添加数据了吗?之前明明可以的啊,但是之前我们创建一个List是这样创建的


它所在的包是java.util.ArrayList里面,我们看下里面的代码

public boolean add(E e) {
  ensureCapacityInternal(size + 1); // Increments modCount!!
  elementData[size++] = e;
  return true;
}
public void add(int index, E element) {
  if (index > size || index < 0)
      throw new IndexOutOfBoundsException(outOfBoundsMsg(index));

  ensureCapacityInternal(size + 1); // Increments modCount!!
  System.arraycopy(elementData, index, elementData, index + 1,
                    size - index);
  elementData[index] = element;
  size++;
}

是存在add方法的,我们再回头再去看看asList生成的List


是在java.util.Arrays包里面的,而这里面的ArrayList我们看到了,并没有去实现List接口,所以也就没有add,get等方法,另外在kotlin里面,我们会看到一个细节,当你敲完Arrays.asList的时候,编译器会提示你,可以转换成listof函数,而这个还是我们知道生成的list都是只能读取,不能往里写数据

Thread.sleep(0)到底“睡没睡”

记得在上上家公司,接手的第一个需求就是做一个动画,这个动画需要一个延迟启动的功能,我那个时候想都没想加了个Thread.sleep(3000),后来被领导批了,不可以用Thread.sleep实现延迟功能,那会还不太明白,后来知道了,Thread.sleep(3000)不一定真的暂停三秒,我们来举个例子

println("start:${System.currentTimeMillis()}")
Thread(Runnable {
   Thread.sleep(3000)
   println("end:${System.currentTimeMillis()}")
}).start()

我们在主线程先打印一条数据展示时间,然后开启一个子线程,在里面sleep三秒以后在打印一下时间,我们看下结果如何

start:1675665421590
end:1675665424591

好像对了又好像没对,为什么是过了3001毫秒才打印出来呢?有的人会说,1毫秒而已,忽略嘛,那我们把上面的代码改下再试试

println("start:${System.currentTimeMillis()}")
Thread(Runnable {
   Thread.sleep(0)
   println("end:${System.currentTimeMillis()}")
}).start()

现在sleep了0毫秒,那是不是两条打印日志应该是一样的呢,我们看看结果

start:1675666764475
end:1675666764477

这下子给整不会了,明明sleep0毫秒,那么多出来的2毫秒是怎么回事呢?其实在Android操作系统中,每个线程使用cpu资源都是有优先级的,优先级高的才有资格使用,而操作系统则是在一个线程释放cpu资源以后,重新计算所有线程的优先级来重新分配cpu资源,所以sleep真正的意义不是暂停,而是在接下去的时间内不参与cpu的竞争,等到cpu重新分配完资源以后,如果优先级没变,那么继续执行,所以sleep(0)秒的真正含义是触发cpu资源重新分配

View.post为什么可以获取控件的宽高

我们都知道在onCreate里面想要获取一个控件的宽高,如果直接获取是拿不到的

val mWith = bindingView.mainButton.width
val mHeight = bindingView.mainButton.height
println("按钮宽:$mWith,高:$mHeight")
......
按钮宽:0,高:0

而如果想要获取宽高,则必须调用View.post的方法

bindingView.mainButton.post {
   val mWith = bindingView.mainButton.width
   val mHeight = bindingView.mainButton.height
   println("按钮宽:$mWith,高:$mHeight")
}
......
按钮宽:979,高:187

很神奇,加个post就可以在同样的地方获取控件宽高了,至于为什么呢?我们来分析一下

简单的来说

Activity生命周期,onCreate方法里面视图还在绘制过程中,所以没法直接获取宽高,而在post方法中执行,就是在线程里面获取宽高,这个线程会在视图没有绘制完成的时候放在一个等待队列里面,等到视图绘制执行完毕以后再去执行队列里面的线程,所以在post里面也可以获取宽高

复杂的来说

我们首先从View.post方法里面开始看


这个代码里面的两个框子,说明了post方法做了两件事情,当mAttachInfo不为空的时候,直接让mHandler去执行线程action,当mAttachInfo为空的时候,将线程放在了一个队列里面,从注释里面的第一个单词Postpone就可以知道,这个action是要推迟进行,什么时候进行呢,我们在慢慢看,既然是判断当mAttachInfo不为空才去执行线程,那我们找找什么时候对mAttachInfo赋值,整个View的源码里面只有一处是对mAttachInfo赋值的,那就是在dispatchAttachedToWindow 这个方法里面,我们看下

void dispatchAttachedToWindow(AttachInfo info, int visibility) {
mAttachInfo = info;
...省略部分源码...

// Transfer all pending runnables.
if (mRunQueue != null) {
mRunQueue.executeActions(info.mHandler);
mRunQueue = null;
}

}

当走到dispatchAttachedToWindow这个方法的时候,mAttachInfo才不为空,也就是从这里开始,我们就可以获取控件的宽高等信息了,另外我们顺着这个方法往下看,可以发现,之前的那个队列在这里开始执行了,现在就关键在于,什么时候执行dispatchAttachedToWindow这个方法,这个时候就要去ViewRootIml类里面查看,发现只有一处调用了这个方法,那就是在performTraversals这个方法里面

private void performTraversals() {
...省略部分源码...
host.dispatchAttachedToWindow(mAttachInfo, 0);
...省略部分源码...
// Ask host how big it wants to be
performMeasure(childWidthMeasureSpec, childHeightMeasureSpec);
...省略部分源码...
performLayout(lp, mWidth, mHeight);
...省略部分源码...
performDraw();
}

performTraversals这个方法我们就很熟悉了,整个View的绘制流程都在里面,所以只有当mAttachInfo在这个环节赋值了,才可以得到视图的信息

IdleHandler到底有啥用

Handler是面试的时候必问的环节,除了问一下那四大组件之外,有的面试官还会问一下IdleHandler,那IdleHandler到底是什么呢,它是干什么用的呢,我们来看看

Message next() {
...省略部分代码...
synchronized (this) {
// If first time idle, then get the number of idlers to run.
// Idle handles only run if the queue is empty or if the first message
// in the queue (possibly a barrier) is due to be handled in the future.
if (pendingIdleHandlerCount < 0
&& (mMessages == null || now < mMessages.when)) {
pendingIdleHandlerCount = mIdleHandlers.size();
}
if (pendingIdleHandlerCount <= 0) {
// No idle handlers to run. Loop and wait some more.
mBlocked = true;
continue;
}

if (mPendingIdleHandlers == null) {
mPendingIdleHandlers = new IdleHandler[Math.max(pendingIdleHandlerCount, 4)];
}
mPendingIdleHandlers = mIdleHandlers.toArray(mPendingIdleHandlers);
}

// Run the idle handlers.
// We only ever reach this code block during the first iteration.
for (int i = 0; i < pendingIdleHandlerCount; i++) {
final IdleHandler idler = mPendingIdleHandlers[i];
mPendingIdleHandlers[i] = null; // release the reference to the handler

boolean keep = false;
try {
keep = idler.queueIdle();
} catch (Throwable t) {
Log.wtf(TAG, "IdleHandler threw exception", t);
}

if (!keep) {
synchronized (this) {
mIdleHandlers.remove(idler);
}
}
}

}

只有在MessageQueue中的next方法里面出现了IdleHandler,作用也很明显,当消息队列在遍历队列中的消息的时候,当消息已经处理完了,或者只存在延迟消息的时候,就会去处理mPendingIdleHandlers里面每一个idleHandler的事件,而这些事件都是通过方法addIdleHandler注册进去的

Looper.myQueue().addIdleHandler {
false
}

addIdlehandler接受的参数是一个返回值为布尔类型的函数类型参数,至于这个返回值是true还是false,我们从next()方法中就能了解到,当为false的时候,事件处理完以后,这个IdleHandler就会从数组中删除,下次再去遍历执行这个idleHandler数组的时候,该事件就没有了,如果为true的话,该事件不会被删除,下次依然会被执行,所以我们按需设置。现在我们可以利用idlehandler去解决上面讲到的在onCreate里面获取控件宽高的问题

Looper.myQueue().addIdleHandler {
val mWith = bindingView.mainButton.width
val mHeight = bindingView.mainButton.height
println("按钮宽:$mWith,高:$mHeight")
false
}

当MessageQueue中的消息处理完的时候,我们的视图绘制也完成了,所以这个时候肯定也能获取控件的宽高,我们在IdleHandler里面执行了同样的代码之后,运行后的结果如下

按钮宽:979,高:187

除此之外,我们还可以做点别的事情,比如我们常说的不要在主线程里面做一些耗时的工作,这样会降低页面启动速度,严重的还会出现ANR,这样的场景除了开辟子线程去处理耗时操作之外,我们现在还可以用IdleHandler,这里举个例子,我们在主线程中给sp塞入一些数据,然后在把这些数据读取出来,看看耗时多久

println(System.currentTimeMillis())
val testData = "aabbbbakjsdhjkahsjkasdjasdhjakshdjkahsdjkhasjdkhjaskhdjkashdjkhasjkhas" +
"jkhdaabbbbakjsdhjkahsjkasdjasdhjakshdjkahsdjkhasjdkhjaskhdjkashdjkhasjkhasjkhd" +
"aabbbbakjsdhjkahsjkasdjasdhjakshdjkahsdjkhasjdkhjaskhdjkashdjkhasjkhasjkhd" +
"aabbbbakjsdhjkahsjkasdjasdhjakshdjkahsdjkhasjdkhjaskhdjkashdjkhasjkhasjkhd" +
"aabbbbakjsdhjkahsjkasdjasdhjakshdjkahsdjkhasjdkhjaskhdjkashdjkhasjkhasjkhd" +
"aabbbbakjsdhjkahsjkasdjasdhjakshdjkahsdjkhasjdkhjaskhdjkashdjkhasjkhasjkhd"
sharePreference = getSharedPreferences(packageName, MODE_PRIVATE)
for (i in 1..5000) {
sharePreference.edit().putString("test$i", testData).commit()
}
for (i in 1..5000){
sharePreference.getString("test$i","")
}
println(System.currentTimeMillis())

......运行结果
1676260921617
1676260942770

我们看到在塞入5000次数据,再读取5000次数据之后,一共耗时大概20秒,同时也阻塞了主线程,导致的现象是页面一片空白,只有等读写操作结束了,页面才展示出来,我们接着把读写操作的代码用IdleHandler执行一下看看

Looper.myQueue().addIdleHandler {
sharePreference = getSharedPreferences(packageName, MODE_PRIVATE)
val editor = sharePreference.edit()
for (i in 1..5000) {
editor.putString("test$i", testData).commit()
}
for (i in 1..5000){
sharePreference.getString("test$i","")
}
println(System.currentTimeMillis())
false
}
......运行结果
1676264286760
1676264308294

运行结果依然耗时二十秒左右,但区别在于这个时候页面不会受到读写操作的阻塞,很快就展示出来了,说明读写操作的确是等到页面渲染完才开始工作,上面过程没有放效果图主要是因为时间太长了,会影响gif的体验,有兴趣的可以自己试一下

如何让指定视图不被软键盘遮挡

我们通常使用android:windowSoftInputMode属性来控制软键盘弹出之后移动界面,让输入框不被遮挡,但是有些场景下,键盘永远都会挡住一些我们使用频次比较高的控件,比如现在我们有个登录页面,大概的样子长这样


它的布局文件是这样

<RelativeLayout
android:id="@+id/mainroot"
android:layout_width="match_parent"
android:layout_height="match_parent">

<ImageView
android:layout_width="200dp"
android:layout_height="200dp"
android:layout_centerHorizontal="true"
android:layout_marginTop="100dp"
android:src="@mipmap/ic_launcher_round" />

<androidx.appcompat.widget.LinearLayoutCompat
android:id="@+id/ll_view1"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_alignParentBottom="true"
android:layout_marginBottom="120dp"
android:gravity="center"
android:orientation="vertical">

<EditText
android:id="@+id/main_edit"
android:layout_width="match_parent"
android:layout_height="40dp"
android:hint="请输入用户名"
android:textColor="@color/black"
android:textSize="15sp" />

<EditText
android:id="@+id/main_edit2"
android:layout_width="match_parent"
android:layout_height="40dp"
android:layout_marginTop="30dp"
android:hint="请输入密码"
android:textColor="@color/black"
android:textSize="15sp" />

<Button
android:layout_width="match_parent"
android:layout_height="50dp"
android:layout_marginHorizontal="10dp"
android:layout_marginTop="20dp"
android:text="登录" />

</androidx.appcompat.widget.LinearLayoutCompat>

</RelativeLayout>

在这样一个页面里面,由于输入框与登录按钮都比较靠页面下方,导致当输入完内容想要点击登录按钮时候,必须再一次关闭键盘才行,这样的操作在体验上就比较大打折扣了


现在希望可以键盘弹出之后,按钮也展示在键盘上面,这样就不用收起弹框以后才能点击按钮了,这样一来,windowSoftInputMode这一个属性已经不够用了,我们要想一下其他方案

  • 首先,需要让按钮也展示在键盘上方,那只能让布局整体上移把按钮露出来,在这里我们可以改变LayoutParam的bottomMargin参数来实现

  • 其次,需要知道键盘什么时候弹出,我们都知道android里面并没有提供任何监听事件来告诉我们键盘什么时候弹出,我们只能从其他角度入手,那就是监听根布局可视区域大小的变化

ViewTreeObserver

我们先获取视图树的观察者,使用addOnGlobalLayoutListener去监听全局视图的变化

bindingView.mainroot.viewTreeObserver.addOnGlobalLayoutListener {

}

接下去就是要获取根视图的可视化区域了,如何来获取呢?View里面有这么一个方法,那就是getWindowVisibleDisplayFrame,我们看下源码注释就知道它是干什么的了


一大堆英文没必要都去看,只需要看最后一句就好了,大概意思就是获取能够展示给用户的可用区域,所以我们在监听器里面加上这个方法

bindingView.mainroot.viewTreeObserver.addOnGlobalLayoutListener {
val rect = Rect()
bindingView.mainroot.getWindowVisibleDisplayFrame(rect)
}

当键盘弹出或者收起的时候,rect的高度就会跟着变化,我们就可以用这个作为条件来改变bottomMargin的值,现在我们增加一个变量oldDelta来保存前一个rect变化的高度值,用来做比较,完整的代码如下

var oldDelta = 0
val params:RelativeLayout.LayoutParams = bindingView.llView1.layoutParams as RelativeLayout.LayoutParams
val originBottom = params.bottomMargin
bindingView.mainroot.viewTreeObserver.addOnGlobalLayoutListener {
val rect = Rect()
bindingView.mainroot.getWindowVisibleDisplayFrame(rect)
val deltaHeight = r.height()
if (oldDelta != deltaHeight) {
if (oldDelta != 0) {
if (oldDelta > deltaHeight) {
params.bottomMargin = oldDelta - deltaHeight
} else if (oldDelta < deltaHeight) {
params.bottomMargin = originBottom
}
bindingView.llView1.layoutParams = params
}
oldDelta = deltaHeight
}
}

最终效果如下


弹出后页面有个抖动是因为本身有个页面平移的效果,然后再去计算layoutparam,如果不想抖动可以在布局外层套个scrollView,用smoothScrollTo把页面滑上去就可以了,有兴趣的可以业余时间试一下

为什么LiveData的postValue会丢失数据

LiveData已经问世好多年了,大家都很喜欢用,因为它上手方便,一般知道塞数据用setValue和postValue,监听数据使用observer就可以了,然而实际开发中我遇到过好多人,一会这里用setValue一会那里用postValue,或者交替着用,这种做法也不能严格意义上说错,毕竟运行起来的确没问题,但是这种做法确实是存在风险隐患,那就是连续postValue会丢数据,我们来做个实验,连续setValue十个数据和连续postValue十个数据,收到的结果都分别是什么

var testData = MutableLiveData<Int>()
fun play(){
for (i in 1..10) {
testData.value = i
}
}

mainViewModel.testData.observe(this) {
println("收到:$it")
}

//执行结果
收到:1
收到:2
收到:3
收到:4
收到:5
收到:6
收到:7
收到:8
收到:9
收到:10

setValue十次数据都可以收到,现在把setValue改成postValue再来试试

var testData = MutableLiveData<Int>()
fun play(){
for (i in 1..10) {
testData.postValue(i)
}
}

得到的结果是

收到:10

只收到了最后一条数据10,这是为什么呢?我们进入postValue里面看看里面的源码就知道了


主要看红框里面,有一个synchronized同步锁锁住了一个代码块,我们称为代码块1,锁的对象是mDataLock,代码块1做的事情先是给postTask这个布尔值赋值,接着把传进来的值赋给mPendingData,那我们知道了,postTask除了第一个被执行的时候,值是true,结下去等mPendingData有值了以后就都为false,前提是mPendingData没有被重置为NOT_SET,然后我们顺着代码往下看,会看到代码接下来就要到一个mPostValueRunnable的线程里面去了,我们看下这个线程


发现同样的锁,锁住了另一块代码块,我们称为代码块2,这个代码块里面恰好是把mPendingData的值赋给newValue以后,重置为NOT_SET,这样一来,postValue又可以接受新的值了,所以这也是正常情况下每次postValue都可以接受到值的原因,但是我们想想连续postValue的场景,我们知道如果synchronized如果修饰一段代码块,那么当这段代码块获取到锁的时候,就具有优先级,只有当全部执行完以后才会释放锁,所以当代码块1连续被访问时候,代码块2是不会被执行的,只有等到代码块1执行完,释放了锁,代码块2才会被执行,而这个时候,mPendingData已经是最新的值了,之前的值已经全部被覆盖了,所以我们说的postValue会丢数据,其实说错了,应该是postValue只会发送最新数据

总结

这篇文章讲到的面试题还仅仅只是过去几年遇到的,现在面试估计除了一些常规问题之外,比重会更倾向于Kotlin,Compose,Flutter的知识点,所以只有不断的日积月累,让自己的知识点更加的全面,才能在目前竞争激烈的行情趋势下逆流而上,不会被拍打在沙滩上

作者:Coffeeee
来源:juejin.cn/post/7199537072302374969

收起阅读 »

在安卓项目中使用 FFmpeg 实现 GIF 拼接(可扩展为实现视频会议多人同屏效果)

前言在我的项目 隐云图解制作 中,有一个功能是按照一定规则将多张 gif 拼接成一张 gif。当然,这里说的拼接是类似于拼图一样的拼接,而不是简单粗暴的把多个 gif 合成一个 gif 并按顺序播放。大致效果如下:注意:上面的动图只展示了预览效果,没有展示实际...
继续阅读 »

前言

在我的项目 隐云图解制作 中,有一个功能是按照一定规则将多张 gif 拼接成一张 gif。

当然,这里说的拼接是类似于拼图一样的拼接,而不是简单粗暴的把多个 gif 合成一个 gif 并按顺序播放。

大致效果如下:


注意:上面的动图只展示了预览效果,没有展示实际合成效果,但是合成效果和预览效果是一摸一样的,有兴趣的话,我可以再开一篇文章讲解怎么实现这个预览效果

实现方法

FFmpeg 简介

在开始之前先简单介绍一下什么是 FFmpeg,不过我相信只要是稍微接触过一点音视频的开发者都知道 FFmpeg。

FFmpeg 是一个开放源代码的自由软件,可以执行音频和视频多种格式的录影、转换、串流功能,包含了 libavcodec ——这是一个用于多个项目中音频和视频的解码器库,以及 libavformat ——一个音频与视频格式转换库。

简单来说,只要是和音视频相关的操作,几乎都可以使用 FFmpeg 来实现。

当然,FFmpeg 是一个纯命令行工具,所以我在这里简单介绍几个本文需要用到的参数:

  1. -y 若指定的输出文件已存在则强制覆盖

  2. -i 设置输入文件,可以设置多个

  3. -filter_complex 设置复杂滤镜,我们这次要实现的拼接 gif 就是依靠这个参数完成

在安卓中使用 FFmpeg

我现在使用的库是 ffmpeg-kit 使用这个库可以直接集成 FFmpeg 到项目中,并且能够方便的执行 FFmpeg 命令。

该库执行 FFmpeg 很简单,只需要:

val session = FFmpegKit.executeWithArguments("your cmd text")
if (ReturnCode.isSuccess(session.returnCode)) {
   Log.i(TAG, "Command execution completed successfully.")
} else if (ReturnCode.isCancel(session.returnCode)) {
   Log.i(TAG, "Command execution cancelled by user.")
} else {
   Log.e(TAG, String.format("Command execution fail with state %s and rc %s.%s", session.state, session.returnCode, session.failStackTrace))
}

因为我需要自己管理线程,所以使用的是同步执行

另外,我几乎试过当前 GitHub 上最近还在维护所有的 FFmpeg for Android 库,甚至还自己写过一个,但是都或多或少的有点问题,最终只有这个库能够适配我的需求。

在此弱弱的吐槽一下某些“开源”库,只提供二进制包,不提供编译脚本,也不提供源代码,提供的二进制包缺少了某些依赖,我想自己动手编译都没法编译,一看 README ,好嘛,定制编译请联系作者付费获取,合着这开源开了个寂寞啊。

拼接命令

我们先来看一段完整的拼接命令,我会详细讲解各个参数的作用,最后再讲解如何动态生成需要的命令。

完整命令:

# 覆盖输出文件
-y

# 输入文件
-i jointBg.png
-i 1.gif
-i 2.gif
-i 3.gif
-i 4.gif

# 开始进行滤镜转换
-filter_complex
[0:v]pad=1280:2161[bg];
[1:v]scale=640:1137[gif0];
[2:v]scale=640:368[gif1];
[3:v]scale=640:1024[gif2];
[4:v]scale=640:368[gif3];

[bg][gif0] overlay=0:0[over0];
[over0][gif1] overlay=640:0[over1];
[over1][gif2] overlay=0:1137[over2];
[over2][gif3] overlay=640:368

# 输出路径
out.gif

为了方便查看,我使用换行分割了命令,使用时可不能加换行哦

在这段代码中,我们使用 -y 参数指定如果输出文件已存在则覆盖。

接下来使用 -i 参数输入了 5 个文件,其中 jointBg.png 是我生成的一个 1x1 像素的图片,用于后面扩展成背景画布,其他的 gif 文件就是要拼接的源文件。

然后使用 -filter_complex 表示要做一个复杂滤镜,后面跟着的都是这个复杂滤镜的参数:

[0:v]pad=1280:2161[bg]; 表示将输入的第一个文件作为视频打开,并将其当成画板,同时缩放分辨率为 1280x2161 (后面会讲这些分辨率是怎么来的),最后取名为 bg

[1:v]scale=640:1137[gif0]; 表示将输入的第二个文件作为视频打开,并缩放分辨率至 640x1137 , 最后取别名为 gif0

下面的三行语句作用相同。

然后就是开始拼接:

[bg][gif0] overlay=0:0[over0]; 表示将 gif0 覆盖到 bg 上,并且覆盖的起点坐标为 0x0 ,最后将该其取名为 over0

下面的三行代码作用相同。

简单理解一下这个过程:

  1. 创建一个图片,并缩放尺寸至事先计算出来的最终拼接成品的尺寸作为背景

  2. 依次将输入的文件缩放至事先计算好的尺寸

  3. 依次将缩放后的输入文件覆盖(叠加)到背景上

动画演示:


仅作演示便于理解,实际拼接时一般都是放大 bg , 缩小 gif,并且 gif 将完全覆盖住 bg

计算尺寸

上一节中的命令涉及到很多缩放过程,那么这个缩放的尺寸是如何得到的呢?

这一节我们将讲解如何计算尺寸。

首先,我们需要知道的是,当前这个功能,一共有三种拼接模式:

  1. 横向拼接

  2. 纵向拼接

  3. 宫格拼接


本文主要讲解的是宫格拼接,宫格拼接的样式即文章开头的预览效果那种。

既然是宫格拼接,那么绕不开的就是如果拼接的动图尺寸不一致,怎么确保拼接出来的动图美观?

这里我们有两种策略,由用户自行选择:

  1. 完全以最小尺寸的图片为基准,将所有图片强制缩放到最小尺寸,这样可能会造成部分动图被拉伸失真。

  2. 以所有图片中的最小宽度为基准,等比例缩放其他图片,这样可以确保所有图片都不会失真,但是拼接出来的成品将不是一个完美的矩形,而是一个留有黑色背景的异形图片。

确定了我们使用的两种缩放策略,下面就是开始计算成品的总尺寸和每张输入图片的需要缩放尺寸。

不过在此之前,我们需要遍历所有输入图片,拿到所有图片的原始尺寸和所有图片中的最小尺寸:

val jointGifResolution: MutableList<MutableList<Int>> = ArrayList() // 所有动图的原始尺寸 list
var minValue = Int.MAX_VALUE  // 最小宽度(别问我为什么不命名成 minWidth ,问就是兼容性)
var minValue2 = Int.MAX_VALUE  // 最小高度

for (uri in gifUris) {
   val gifDrawable = GifDrawable(context.contentResolver, uri)
   val height = gifDrawable.intrinsicHeight  // 当前 gif 的原始高度
   val width = gifDrawable.intrinsicWidth  // 当前 gif 的原始宽度
   jointGifResolution.add(mutableListOf(width, height))  // 将尺寸加入 list
   
   // 计算最小宽高
   if (minValue > width) {
       minValue = width
  }
   if (minValue2 > height) {
       minValue2 = height
  }
}

其中,gifUris 即事先获取到的所有输入动图的 uri 列表。

这里我们使用到了 GifDrawable 获取动图的尺寸,因为这不是本文的重点,所以不多加解释,读者只需知道这样可以拿到 gif 的原始尺寸即可。

拿到所有动图的原始宽高和最小宽高后,下一步是计算需要的缩放值:

var totalHeight = 0
var totalWidth = 0

var squareIndex = 0
val squareTotalHeight: MutableList<Int> = arrayListOf()

jointGifResolution.forEachIndexed { index, resolution ->
   val jointWidth = minValue // 无论使用缩放策略 1 还是 2,缩放宽度都是最小宽度
   val jointHeight = when (scaleMode) {
       // 如果使用缩放策略 2 则需要按比例计算出缩放高度
       GifTools.JointScaleModeWithRatio -> resolution[1] * minValue / resolution[0]
       // 如果使用缩放策略 1 则直接强制缩放到最小高度
       else -> minValue2
  }
   // 因为宫格拼接只能使用 2 的 n 次幂张图片,所以每行图片数量可以根据图片总数算出,不过太麻烦,所以这里我打了个表,直接从表里面拿
   // val JointGifSquareLineLength = hashMapOf(4 to 2, 9 to 3, 16 to 4, 25 to 5, 36 to 6, 49 to 7, 64 to 8, 81 to 9, 100 to 10)
   var lineLength = GifTools.JointGifSquareLineLength[jointGifResolution.size]
   if (lineLength == null) {
       lineLength = sqrt(jointGifResolution.size.toDouble()).toInt()
  }
   
   if (scaleMode == GifTools.JointScaleModeWithRatio) { // 使用等比缩放策略
       
       if (index < lineLength) {  // 所有图片宽度都是一样的,所以直接加一行的宽度得到的就是最大宽度
           totalWidth += jointWidth
      }
       try {
           // 这里是获取每一列的当前行高,并将其加起来,最终遍历完会得到当前列的高度
           val tempIndex = squareIndex % lineLength
           Log.e(TAG, "getJointGifResolution: temp index = $tempIndex")
           if (squareTotalHeight.size <= tempIndex) {
               squareTotalHeight.add(tempIndex, 0)
          }
           squareTotalHeight[tempIndex] = squareTotalHeight[tempIndex] + jointHeight
      } catch (e: java.lang.Exception) {
           Log.e(TAG, "getJointGifResolution: ", e)
      }
       
       // 将缩放尺寸更新至尺寸列表
       jointGifResolution[index] = mutableListOf(jointWidth, jointHeight)
  } else {
       // 如果不是按比例缩放,则直接将最小宽高存入总宽高
       if (index < lineLength) {
           totalHeight += min(jointHeight, jointWidth)
           totalWidth += min(jointHeight, jointWidth)
      }
       
       // 将缩放尺寸更新至尺寸列表
       jointGifResolution[index] = mutableListOf(min(jointHeight, jointWidth), min(jointHeight, jointWidth))
  }
   squareIndex++
}

上面的代码我已经加了详细的注释,至此所有图片的缩放尺寸已计算出来。

即,总尺寸为:

if (scaleMode != GifTools.JointScaleModeWithRatio) {
   jointGifResolution.add(mutableListOf(totalWidth, totalHeight))
}
else {
   Log.e(TAG, "getJointGifResolution: $squareTotalHeight")
   jointGifResolution.add(mutableListOf(totalWidth, Collections.max(squareTotalHeight)))
}

最小宽高为:

jointGifResolution.add(mutableListOf(minValue, minValue2))

对了,你可能会奇怪,为什么我要把总尺寸和最小宽高存入缩放尺寸 list,哈哈,这是因为我懒,所以我对这个 list 的定义是:

/**
*
* 遍历获取所有 gifUris 中的动图分辨率
*
* 并将经过处理后的所有长、宽之和存入 [size-2] ;
*
* 将最小的长宽存入 [size-1]
* */

动态生成命令

完成了尺寸的计算,下一步是按照输入文件和计算出来的尺寸动态的生成 FFmpeg 命令。

不过在这之前,我们需要先创建一个 1x1 的图片,用来扩展成背景:

private suspend fun createJointBgPic(context: Context): File? {
   val drawable = ColorDrawable(Color.parseColor("#FFFFFFFF"))
   val bitmap = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888)
   val canvas = Canvas(bitmap)
   drawable.draw(canvas)
   return try {
       Tools.saveBitmap2File(bitmap, "jointBg", context.externalCacheDir)
  } catch (e: Exception) {
       log2text("Create cache bg fail!", "e", e)
       null
  }
}

然后从尺寸列表中取出并删除追加在末尾的总尺寸和最小尺寸:

// 别看了,没写错,就是两个 size-1 ,为啥?你猜
val minResolution = gifResolution.removeAt(gifResolution.size - 1)
val totalResolution = gifResolution.removeAt(gifResolution.size - 1)

然后,就是开始拼接命令,这里我为了方便使用,自己写了一个 FFmpeg 命令的 Builder:

/**
* @author equationl
* */
public class FFMpegArgumentsBuilder {
private final String[] cmd;

public static class Builder {
private final ArrayList<String> cmd = new ArrayList<>();

/**
* Such as add [arg, value] to cmd[]
* */
public Builder setArgWithValue(String arg, String value) {
this.cmd.add(arg);
this.cmd.add(value);
return this;
}

/**
* Such as add arg to cmd[]
* */
public Builder setArg(String arg) {
this.cmd.add(arg);
return this;
}

/**
* Such as "-ss time"
* */
public Builder setStartTime(String time) {
this.cmd.add("-ss");
this.cmd.add(time);
return this;
}

/**
* Such as "-to time"
* */
public Builder setEndTime(String time) {
this.cmd.add("-to");
this.cmd.add(time);
return this;
}

/**
* Such as "-i input"
* */
public Builder setInput(String input) {
this.cmd.add("-i");
this.cmd.add(input);
return this;
}

/**
* <p>Such as "-t time"</p>
* <p>Note: call this before addInput() will limit input duration time; call before addOutput() will limit output duration time.</p>
* */
public Builder setDurationTime(String time) {
this.cmd.add("-t");
this.cmd.add(time);
return this;
}

/**
* <p>if isOverride is true, add "-y"; else add "-n"</p>
* <p>if do not set this arg, FFMpeg may ask for if override existed output file</p>
* */
public Builder setOverride(Boolean isOverride) {
if (isOverride) {
this.cmd.add("-y");
}
else {
this.cmd.add("-n");
}
return this;
}

/**
* Add output file to cmd[].<b>You must call this at end.</b>
* */
public Builder setOutput(String output) {
this.cmd.add(output);
return this;
}

/**
* <p>Set input/output file format</p>
* <p>Such as "-f format"</p>
* */
public Builder setFormat(String format) {
this.cmd.add("-f");
this.cmd.add(format);
return this;
}

/**
* Set video filter
* Such as "-vf filter"
* */
public Builder setVideoFilter(String filter) {
this.cmd.add("-vf");
this.cmd.add(filter);
return this;
}

/**
* Set frame rate, Such as "-r frameRate"
* */
public Builder setFrameRate(String frameRate) {
this.cmd.add("-r");
this.cmd.add(frameRate);
return this;
}

/**
* Set frame size, Such as "-s frameSize"
* */
public Builder setFrameSize(String frameSize) {
this.cmd.add("-s");
this.cmd.add(frameSize);
return this;
}

public FFMpegArgumentsBuilder build() {
return new FFMpegArgumentsBuilder(this, false);
}

/**
* Build cmd
*
* @param isAddFFmpeg true: Add a ffmpeg flag in first
* */
public FFMpegArgumentsBuilder build(Boolean isAddFFmpeg) {
return new FFMpegArgumentsBuilder(this, isAddFFmpeg);
}
}

public String[] getCmd() {
return this.cmd;
}

private FFMpegArgumentsBuilder(Builder b, Boolean isAddFFmpeg) {
if (isAddFFmpeg) {
b.cmd.add(0, "ffmpeg");
}
this.cmd = b.cmd.toArray(new String[0]);
}

}

开始生成命令文本:

首先是输入文件等,

val cmdBuilder = FFMpegArgumentsBuilder.Builder()
cmdBuilder.setOverride(true) // -y
.setInput(jointBg.absolutePath) // -i 输入背景

for (uri in gifUris) { //输入GIF
cmdBuilder.setInput(FileUtils.getMediaAbsolutePath(context, uri)) // -i
}

cmdBuilder.setArg("-filter_complex") //添加过滤器

然后是添加过滤器参数,

//过滤器参数
var cmdFilter = ""

//设置背景并扩展分辨率到 total
cmdFilter += "[0:v]pad=${totalResolution[0]}:${totalResolution[1]}[bg];"

//将输入文件缩放并取别名为 gifX (X为索引)
gifResolution.forEachIndexed { index, mutableList ->
cmdFilter += "[${index+1}:v]scale=${mutableList[0]}:${mutableList[1]}[gif$index];"
}

cmdFilter += "[bg][gif0] overlay=0:0[over0];" //将第一个GIF叠加 bg 的 0:0 (即画面左下角)

//开始叠加剩余动图
cmdFilter += getCmdFilterOverlaySquare(gifUris, gifResolution)

其中,getCmdFilterOverlaySquare 用于计算 gif 的摆放坐标,并合成参数命令,实现如下:

private fun getCmdFilterOverlaySquare(gifUris: ArrayList<Uri>, gifResolution: MutableList<MutableList<Int>>): String {
// "[bg][gif0] overlay=0:0[over0];"
var cmdFilter = ""
var h: Int
var w: Int
var index = 0
var lineLength = GifTools.JointGifSquareLineLength[gifUris.size]
if (lineLength == null) {
lineLength = sqrt(gifUris.size.toDouble()).toInt()
}

for (i in 0 until lineLength) {
for (j in 0 until lineLength) {
if ((i==lineLength-1 && j==lineLength-1) || (i==0 && j==0)) { //最后一张单独处理,第一张已处理
continue
}
if (j==0) { //竖排第一个,w当然等于 0
w = 0
} else {
w = 0
for (k in 0 until j) {
w += gifResolution[i*lineLength+k][0]
}
}
if (i==0) { //横排第一个,h等于0
h = 0
} else {
h = 0
for (k in j until index step lineLength) {
h += gifResolution[k][1]
}
}

cmdFilter += "[over${index}][gif${index+1}] overlay=$w:$h[over${index + 1}];"
index++
}
}

w = 0
for (i in 0 until lineLength-1) {
w += gifResolution[i+lineLength*(lineLength-1)][0]
}

h = 0
for (i in lineLength-1 until lineLength*lineLength-1 step lineLength) {
h += gifResolution[i][1]
}

cmdFilter += "[over${index}][gif${index+1}] overlay=$w:$h"

return cmdFilter
}

上述代码不难理解,总之就是根据遍历到的 gif 索引,判断它应该所处的坐标,然后加入过滤器参数。

最后,将过滤参数加入命令,加入输出文件路径,即可拿到最终命令文本 cmd

cmdBuilder.setArg(cmdFilter)
cmdBuilder.setOutput(resultPath)

val cmd = cmdBuilder.build(false).cmd

最后,只要将这个命令文本仍给 FFmpeg 执行即可!

总结

虽然本文仅仅说的是如何拼接 Gif , 但是 FFmpeg 是十分强大的,我这个属于是抛砖引玉。

相信各位有过这样一种需求,那就是做一个多人同屏的实时会议功能,如果在看本文之前你可能不知所措,但是看完本文你一定会觉得这是小菜一碟。

因为 FFmpeg 原生支持串流,支持视频处理,你只要把我这里的输入文件改成串流,输出文件改成串流,再按照你的需求改一下坐标,那不就完成了吗?

作者:equationl
来源:juejin.cn/post/7136325945937362952

收起阅读 »

为什么要选择VersionCatalog来做依赖管理?

虾扯淡很多人都介绍过Gradle 7.+提供新依赖管理工具VersionCatalog,我就不过多介绍这个了。我们最近也算是成功接入了VersionCatalog,过程也还是有点曲折的,总体来说我觉得确实比我们当前的ext,或者说是用buildSrc的形式进行...
继续阅读 »

虾扯淡

很多人都介绍过Gradle 7.+提供新依赖管理工具VersionCatalog,我就不过多介绍这个了。我们最近也算是成功接入了VersionCatalog,过程也还是有点曲折的,总体来说我觉得确实比我们当前的ext,或者说是用buildSrc的形式进行依赖管理是个更成熟的方案吧。下面是几个介绍的文章,尤其可以看看三七哥哥的。

之前大部分文章只介绍了技术方案,很少会去横向对比几个技术方案之间的优劣。从我们最近一个月的使用结果上来看吧,接下来我给大家分析下实际的优劣,仅仅只代表个人看法, 上表格了。

因为VersionCatalog使用的文件格式是toml,所以后续可能会用toml进行简称。

extbuildSrctoml
声明域*.gradle*.java *.kt*.toml
可修改可修改不可修改不可修改
写法花里胡哨静态变量固定写法 xxx.xxx.xxx
校验随便写编译时校验同步时校验

声明域: 指的是我们在哪里声明这些依赖管理。其中ext可以在绝大部分的.gradle中去进行声明,所以就会导致依赖声明的过于零散。而这部分问题就不存在于buildSrc和toml中,他们只能被声明在固定的位置上。

可修改性: 特制声明的依赖能否被修改,ext声明是在内存空间内,而ext的本质其实就是一个Any他可以存放任意的东西,如果出现同名的则会是后面声明的把前面声明的覆盖掉,这就是一个非常不稳定的属性,而buildSrc则是由class来声明的,我们没有办法在gradle中去修改这部分,所以相对来说是稳定的。而toml也类似,基于固定格式反序列化成代码。不具备修改的能力。

写法: ext这方面是真的拉胯,比如支持libs.abc或者libs."abc"或者libs.["abc"]还可以单引号,就非常的随意,而且极为不统一。这也是我们本次改动中碰到问题最多的时候。其他两种写法都相对比较固定,类似java/kt 中的静态常量。

校验: ext就是爱咋写咋写吧,反正也没有很好的校验啥的。而buildSrc则是基于java的代码编译来的,toml因为是一个新的文件格式,所以内置了一套相对比较强的语法校验,如果不合规则会报错,并显示错误行数。

据说buildSrc对于增量编译的适配等其实不太良好,而且我们是一个复杂的巨型复合构建的工程,所以个人并不太推荐buildSrc。

可以参考这篇文章第二章 Stop using Gradle buildSrc. Use composite builds instead

由此可证哦,VersionCatalog雀食是一个非常好的选择,尤其如果你们当前还是在使用的是ext的情况下。

巨型工程最麻烦的事情其实另外一点就是技术栈的切换,因为要改起来的地方可真的就是太多了,首先就是要先解决复合构建的情况下全局只有一份注册的逻辑,其二就是把当前工程的ext全部转移到toml中,然后要最好和之前的方式接近,尽量保证最小改动。最后则是所有工程都改一下!!!!!!!!(要我狗命)

共享配置

GradleSample demo 工程如下,其中plugin-version就是

我们也采取了之前Gradle 奇淫技巧之initscript pluginManagement一样的方式,通过initscript做到复合构建内共享插件的能力。

另外我们把VersionCatalog作为一个extension抛出来在外部完成注册。

catalogs {
  script = new File(rootProjectDir, "depencies.gradle")

  versionCatalogs {
      create("libs") { from(files("${rootProjectDir.path}/toml/dependencies.versions.toml")) }
      create("module") { from(files("${rootProjectDir.path}/toml/module.versions.toml")) }
  }
  dependencyResolutionManagement {
      repositories {
          maven { setUrl("https://maven.aliyun.com/repository/central/") }
          maven {
              setUrl("https://storage.googleapis.com/r8-releases/raw")
          }
          gradlePluginPortal()
          google()
          mavenLocal()
          maven {
              url "https://dl.bintray.com/kotlin/kotlin-eap"
          }
      }
  }

}

通过这部分配置就可以把共享的部分注入进工程内。然后就是很沙雕的改改改了,把所有的ext全部迁移到我们新的toml上去,然后注册出多个。

命令行工具

TheNext 虾开发的撒币cli工具 专门解决虾的撒币问题

以前也说过了我们工程的模块数量巨大,然后又因为ext的写法风骚,所以我们基本所有的写依赖的地方都要改,就是真的工作量巨大。

一个优秀的摸鱼工程师最重要的天赋就是要学会转化生产力,把这种简单又繁琐的工作交给命令行来解决。所以这就有了TheNext的一个新能力,基于当前的文件目录修改所有的.gradle文件,然后把非标准的ext的写法全部进行一次替换。


效果如图所示。

代码逻辑如下,我们首先会遍历整个工程的文件目录,然后发现.gradle后缀的文件,之后通过正则匹配出dependencies,然后进行把一些"" '' []等等都删掉,然后把- _更换成.,这样就能完成简单的自动替换了。

package com.kronos.mebium.android

import com.beust.jcommander.JCommander
import com.kronos.mebium.action.Handler
import com.kronos.mebium.entity.CommandEntity
import com.kronos.mebium.file.getRootProjectDir
import com.kronos.mebium.utils.green
import com.kronos.mebium.utils.red
import com.kronos.mebium.utils.yellow
import java.io.File
import java.util.Scanner

/**
*
* @Author LiABao
* @Since 2022/12/8
*
*/
class DependenciesHandler : Handler {

   val scanner = Scanner(System.`in`)
   var isSkip = false

   override fun handle(args: Array<String>) {
       isSkip = args.contains(skip)
       val realArgs = if (isSkip) {
           arrayListOf<String>().apply {
               args.forEach {
                   if (it != skip) {
                       add(it)
                  }
              }
          }.toTypedArray()
      } else {
           args
      }
       val commandEntity = CommandEntity()
       JCommander.newBuilder().addObject(commandEntity).build().parse(*realArgs)
       val first = commandEntity.file
       val name = commandEntity.name
       val root = first
       val files = root.walkTopDown().filter {
           it.isFile && it.name.contains(".gradle")
      }
       val overrideList = mutableListOf<Pair<File, File>>()
       files.forEach {
           onGradleCheck(it)?.apply {
               overrideList.add(it to this)
          }
      }
       confirm(overrideList)
  }

   private fun confirm(overrideList: MutableList<Pair<File, File>>) {
       if (overrideList.isEmpty()) {
           return
      }
       println("if you want overwrite all this file ? input y to confirm \r\n".red())
       val input = scanner.next()
       if (input == "y") {
           overrideList.forEach {
               it.first.delete()
               it.second.renameTo(it.first)
          }
           print("replace success \r\n ".green())
      } else {
           print("skip\r\n ".yellow())
      }
  }

   private val pattern =
       "(\\D\\S*)(implementation|Implementation|compileOnly|CompileOnly|test|Test|api|Api|kapt|Kapt|Processor)([ (])(\\D\\S*)".toPattern()

   private fun onGradleCheck(file: File): File? {
       var override = false
       val lines = file.readLines()
       val newLines = mutableListOf<String>()
       lines.forEach { line ->
           val matcher = pattern.matcher(line)
           if (matcher.find()) {
               val libs = matcher.group(4)
               if (!libs.contains(":") && !libs.contains("files(")) {
                   val newLibs =
                       libs.replace("\'", "").replace("\"", "").replace("-", ".").replace("_", ".")
                          .replace("kotlin.libs", "kotlinlibs").replace("[", ".").replace("]", "")
                   if (newLibs == libs) {
                       newLines.add(line)
                       return@forEach
                  }
                   print("fileName: ${file.name} dependencies : $line \r\n")
                   if (isSkip) {
                       override = true
                       newLines.add(line.replace(libs, newLibs))
                       print("$libs do you want replace to $newLibs   \r\n ".green())
                       return@forEach
                  }
                   print("$libs do you want replace to $newLibs ? input y to replace \r\n ".red())
                   while (true) {
                       val input = scanner.next()
                       if (input == "y") {
                           print("replace success\r\n".green())
                           override = true
                           newLines.add(line.replace(libs, newLibs))
                           return@forEach
                      } else {
                           print("skip\r\n ".yellow())
                           break
                      }
                  }
              }
          }
           newLines.add(line)
      }
       if (override) {
           val newFile = File(file.parent, file.name.removeSuffix(".gradle") + ".temp")
           newLines.forEach {
               newFile.appendText(it + "\r\n")
          }
           return newFile
      }
       return null
  }
}

const val skip = "--skip"

代码就基本是这样,如果有正则带佬可以帮忙优化下正则的。

然后这个工具也可以多次复用,因为我这个需求没有办法很快的被合入,需要频繁的rebase master的代码,每次rebase完之后都要进行二次修改,真的吐了。

验收

每个新功能开发最后都是要进行验收的,尤其是技改需求,你到时候把功能搞坏了到时候可是要背黑锅的啊。而且这种需求也没有办法要求测试进行特别系统性的测试,所以还是要开发自己想办法了。

我们拉取了apk包的依赖,然后用HashSet进行了拉平,去除重复依赖,然后通过diff对比前后差异,在基本符合预期的情况下我们就可以进行快速的合入。

结尾

其实本文的核心是给大家分析下几种依赖管理方式的优劣,然后对于还在使用gradle ext的大佬,其实可以逐渐考虑进行替换了。

作者:究极逮虾户
来源:juejin.cn/post/7190277951614058555

收起阅读 »

安卓与串口通信-实践篇

前言在上一篇文章中我们讲解了关于串口的基础知识,没有看过的同学推荐先看一下,否则你可能会不太理解这篇文章所述的某些内容。这篇文章我们将讲解安卓端的串口通信实践,即如何使用串口通信实现安卓设备与其他设备例如PLC主板之间数据交互。需要注意的是正如上一篇文章所说的...
继续阅读 »

前言

在上一篇文章中我们讲解了关于串口的基础知识,没有看过的同学推荐先看一下,否则你可能会不太理解这篇文章所述的某些内容。

这篇文章我们将讲解安卓端的串口通信实践,即如何使用串口通信实现安卓设备与其他设备例如PLC主板之间数据交互。

需要注意的是正如上一篇文章所说的,我目前的条件只允许我使用 ESP32 开发版烧录 Arduino 程序与安卓真机(小米10U)进行串口通信演示。

准备工作

由于我们需要使用 ESP32 烧录 Arduino 程序演示安卓端的串口通信,所以在开始之前我们应该先把程序烧录好。

那么烧录一个怎样的程序呢?

很简单,我这里直接烧了一个 ESP32 使用 9600 的波特率进行串口通信,程序内容就是 ESP32 不断的向串口发送数据 “e” ,并且监听串口数据,如果接收到数据 “o” 则打开开发版上自带的 LED 灯,如果接收到数据 “c” 则关闭这个 LED 灯。

代码如下:

#define LED 12

void setup() {
Serial.begin(9600);
pinMode(LED, OUTPUT);
}

void loop() {
if (Serial.available()) {
  char c = Serial.read();
  if (c == 'o') {
    digitalWrite(LED, HIGH);
  }
  if (c == 'c') {
    digitalWrite(LED, LOW);
  }
}

Serial.write('e');

delay(100);
}

上面的 12 号 Pin 是这块开发版的 LED。

使用 Arduino自带串口监视器测试结果:

1.gif

可以看到,确实如我们设想的通过串口不断的发送字符 “e”,并且在接收到字符 “o” 后点亮了 LED。

安卓实现串口通信

原理概述

众所周知,安卓其实是基于 Linux 的操作系统,所以在安卓中对于串口的处理与 Linux 一致。

在 Linux 中串口会被视为一个“设备”,并体现为 /dev/ttys 文件。

/dev/ttys 又被称为字符终端,例如 ttys0 对应的是 DOS/Windows 系统中的 COM1 串口文件。

通常,我们可以简单理解,如果我们插入了某个串口设备,则这个设备与 Linux 的通信会由 /dev/ttys 文件进行 “中转”。

即,如果 Linux 想要发送数据给串口设备,则可以通过往 /dev/ttys 文件中直接写入要发送的数据来实现,如:

echo test > /dev/ttyS1 这个命令会将 “test” 这串字符发送给串口设备。

如果想读取串口发送的数据也是一样的,可以通过读取 /dev/ttys 文件内容实现。

所以,如果我们在安卓中想要实现串口通信,大概率也会想到直接读取/写入这个特殊文件。

android-serialport-api

在上文中我们说到,在安卓中也可以通过与 Linux 一样的方式--直接读写 /dev/ttys 实现串口通信。

但是其实并不需要我们自己去处理读写和数据的解析,因为谷歌官方给出了一个解决方案:android-serialport-api

为了便于理解,我们会大致说一下这个解决方案的源码,但是就不上示例了,至于为什么,同学们往下看就知道了。另外,虽然这个方案历史比较悠久,也很长时间没有人维护了,但是并不意味着不能使用了,只是使用条件比较苛刻,当然,我司目前使用的还是这套方案(哈哈哈哈)。

不过这里我们不直接看 android-serialport-api 的源码,而是通过其他大佬二次封装的库来看: Android-SerialPort-API

在这个库中,通过

// 默认直接初始化,使用8N1(8数据位、无校验位、1停止位),path为串口路径(如 /dev/ttys1),baudrate 为波特率
SerialPort serialPort = new SerialPort(path, baudrate);

// 使用可选参数配置初始化,可配置数据位、校验位、停止位 - 7E2(7数据位、偶校验、2停止位)
SerialPort serialPort = SerialPort
  .newBuilder(path, baudrate)
// 校验位;0:无校验位(NONE,默认);1:奇校验位(ODD);2:偶校验位(EVEN)
//   .parity(2)
// 数据位,默认8;可选值为5~8
//   .dataBits(7)
// 停止位,默认1;1:1位停止位;2:2位停止位
//   .stopBits(2)
  .build();

初始化串口,然后通过:

InputStream in = serialPort.getInputStream();
OutputStream out = serialPort.getOutputStream();

获取到输入/输出流,通过读取/写入这两个流来实现与串口设备的数据通信。

我们首先来看看初始化串口是怎么做的。

2.png

首先检查了当前是否具有串口文件的读写权限,如果没有则通过 shell 命令更改权限为 666 ,更改后再次检查是否有权限,如果还是没有就抛出异常。

注意这里的执行 shell 时使用的 runtime 是 Runtime.getRuntime().exec(sSuPath); 也就是说,它是通过 root 权限来执行这段命令的!

换句话说,如果想要通过这种方式实现串口通信,必须要有 ROOT 权限!这就是我说我不会给出示例的原因,因为我手头的设备无法 ROOT 啊。至于为啥我司还能继续使用这种方案的原因也很简单,因为我们工控机的安卓设备都是定制版的啊,拥有 ROOT 权限不是基本操作?

确定权限可用后通过 open 方法拿到一个类型为 FileDescriptor 的变量 mFd ,最后通过这个 mFd 拿到输入输出流。

所以核心在于 open 方法,而 open 方法是一个 native 方法,即 C 代码:

private native FileDescriptor open(String absolutePath, int baudrate, int dataBits, int parity,
   int stopBits, int flags);

C 的源码这里就不放了,只需要知道它做的工作就是打开了 /dev/ttys 文件(准确的说是“终端”),然后通过传递进去的这些参数去按串口规则解析数据,最后返回一个 java 的 FileDescriptor 对象。

在 java 中我们再通过这个 FileDescriptor 对象可以拿到输入/输出流。

原理说起来是十分的简单。

看完通信部分的原理后,我们再来看看我们如何查找可用的串口呢?

其实和 Linux 上也一样:

public Vector<File> getDevices() {
   if (mDevices == null) {
       mDevices = new Vector<File>();
       File dev = new File("/dev");
       
       File[] files = dev.listFiles();

       if (files != null) {
           int i;
           for (i = 0; i < files.length; i++) {
               if (files[i].getAbsolutePath().startsWith(mDeviceRoot)) {
                   Log.d(TAG, "Found new device: " + files[i]);
                   mDevices.add(files[i]);
              }
          }
      }
  }
   return mDevices;
}

也是通过直接遍历 /dev 下的文件,只不过这里做了一些额外的过滤。

或者也可以通过读取 /proc/tty/drivers 配置文件后过滤:

Vector<Driver> getDrivers() throws IOException {
   if (mDrivers == null) {
       mDrivers = new Vector<Driver>();
       LineNumberReader r = new LineNumberReader(new FileReader("/proc/tty/drivers"));
       String l;
       while ((l = r.readLine()) != null) {
           // Issue 3:
           // Since driver name may contain spaces, we do not extract driver name with split()
           String drivername = l.substring(0, 0x15).trim();
           String[] w = l.split(" +");
           if ((w.length >= 5) && (w[w.length - 1].equals("serial"))) {
               Log.d(TAG, "Found new driver " + drivername + " on " + w[w.length - 4]);
               mDrivers.add(new Driver(drivername, w[w.length - 4]));
          }
      }
       r.close();
  }
   return mDrivers;
}

关于读取可用串口设备,其实从这里的路径也可以看出,都是系统路径,也就是说,如果没有权限,大概率也是读取不到东西的。

这就是使用与 Linux 一样的方式去读取串口数据的基本原理,那么问题来了,既然我说这个方法使用条件比较苛刻,那么更易用的替代方案是什么呢?

我们下面就会介绍,那就是使用安卓的 USB host (USB主机)的功能。

USB host

Android 3.1(API 级别 12)或更高版本的平台直接支持 USB 配件和主机模式。USB 配件模式还作为插件库向后移植到 Android 2.3.4(API 级别 10)中,以支持更广泛的设备。设备制造商可以选择是否在设备的系统映像中添加该插件库。

在安卓 3.1 版本开始,支持将USB作为主机模式(USB host)使用,而我们如果想要通过 USB 读取串口数据则需要依赖于这个主机模式。

在正式开始介绍USB主机模式前,我们先简要介绍一下安卓上支持的USB模式。

安卓上的USB支持三种模式:设备模式、主机模式、配件模式。

设备模式即我们常用的直接将安卓设备连接至电脑上,此时电脑上显示为 USB 外设,即可以当成 “U盘” 使用拷贝数据,不过现在安卓普遍还支持 MTP模式(作为摄像头)、文件传输模式(即当U盘用)、网卡模式等。

主机模式即将我们的安卓设备作为主机,连接其他外设,此时安卓设备就相当于上面设备模式中的电脑。此时安卓设备可以连接键盘、鼠标、U盘以及嵌入式应用USB转串口、转I2C等设备。但是如果想要将安卓设备作为主机模式可能需要一条支持 OTG 的数据线或转接头。(Micro-USB 或 USB type-c 转 USB-A 口)

而在 USB 配件模式下,外部 USB 硬件充当 USB 主机。配件示例可能包括机器人控制器、扩展坞、诊断和音乐设备、自助服务终端、读卡器等等。这样,不具备主机功能的 Android 设备就能够与 USB 硬件互动。Android USB 配件必须设计为与 Android 设备兼容,并且必须遵守 Android 配件通信协议。

设备模式与配件模式的区别在于在配件模式下,除了 adb 之外,主机还可以看到其他 USB 功能。

usb-host-accessory.png

使用USB主机模式与外设交互数据

在介绍完安卓中的三种USB模式后,下面我们开始介绍如何使用USB主机模式。当然,这里只是大概介绍原生APi的使用方法,我们在实际使用中一般都都是直接使用大佬编写的第三方库。

准备工作

在开始正式使用USB主机模式时我们需要先做一些准备工作。

首先我们需要在清单文件(AndroidManifest.xml)中添加:

<!-- 声明需要USB主机模式支持,避免不支持的设备安装了该应用 -->
<uses-feature android:name="android.hardware.usb.host" />

<!-- …… -->

<!-- 声明需要接收USB连接事件 -->
<action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" />

一个完整的清单文件示例如下:

<manifest ...>
   <uses-feature android:name="android.hardware.usb.host" />
   <uses-sdk android:minSdkVersion="12" />
  ...
   <application>
       <activity ...>
          ...
           <intent-filter>
               <action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" />
           </intent-filter>
       </activity>
   </application>
</manifest>

声明好清单文件后,我们就可以查找当前可用的设备信息了:

private fun scanDevice(context: Context) {
val manager = context.getSystemService(Context.USB_SERVICE) as UsbManager
val deviceList: HashMap<String, UsbDevice> = manager.deviceList
Log.i(TAG, "scanDevice: $deviceList")
}

将 ESP32 开发版插上手机,运行程序,输出如下:

3.png

可以看到,正确的查找到了我们的 ESP32 开发版。

这里提一下,因为我们的手机只有一个 USB 口,此时已经插上了 ESP32 开发版,所以无法再通过数据线直接连接电脑的 ADB 了,此时我们需要使用无线 ADB,具体怎么使用无线 ADB,请自行搜索。

另外,如果我们想要通过查找到设备后请求连接的方式连接到串口设备的话,还需要额外申请权限。(同理,如果我们直接在清单文件中提前声明需要连接的设备则不需要额外申请权限,具体可以看看参考资料5,这里不再赘述)

首先声明一个广播接收器,用于接收授权结果:

private lateinit var permissionIntent: PendingIntent

private const val ACTION_USB_PERMISSION = "com.android.example.USB_PERMISSION"

private val usbReceiver = object : BroadcastReceiver() {

override fun onReceive(context: Context, intent: Intent) {
if (ACTION_USB_PERMISSION == intent.action) {
synchronized(this) {
val device: UsbDevice? = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE)

if (intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)) {
device?.apply {
// 已授权,可以在这里开始请求连接
connectDevice(context, device)
}
} else {
Log.d(TAG, "permission denied for device $device")
}
}
}
}
}

声明好之后在 Acticity 的 OnCreate 中注册这个广播接收器:

permissionIntent = PendingIntent.getBroadcast(this, 0, Intent(ACTION_USB_PERMISSION), FLAG_MUTABLE)
val filter = IntentFilter(ACTION_USB_PERMISSION)
registerReceiver(usbReceiver, filter)

最后,在查找到设备后,调用 manager.requestPermission(deviceList.values.first(), permissionIntent) 弹出对话框申请权限。

连接到设备并收发数据

完成上述的准备工作后,我们终于可以连接搜索到的设备并进行数据交互了:

private fun connectDevice(context: Context, device: UsbDevice) {
val usbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager

CoroutineScope(Dispatchers.IO).launch {
device.getInterface(0).also { intf ->
intf.getEndpoint(0).also { endpoint ->
usbManager.openDevice(device)?.apply {
claimInterface(intf, forceClaim)
while (true) {
val validLength = bulkTransfer(endpoint, bytes, bytes.size, TIMEOUT)
if (validLength > 0) {
val result = bytes.copyOfRange(0, validLength)
Log.i(TAG, "connectDevice: length = $validLength")
Log.i(TAG, "connectDevice: byte = ${result.contentToString()}")
}
else {
Log.i(TAG, "connectDevice: Not recv data!")
}
}
}
}
}
}
}

在上面的代码中,我们使用 usbManager.openDevice 打开了指定的设备,即连接到设备。

然后通过 bulkTransfer 接收数据,它会将接收到的数据写入缓冲数组 bytes 中,并返回成功接收到的数据长度。

运行程序,连接设备,日志打印如下:

4.png

可以看到,输出的数据并不是我们预料中的数据。

这是因为这是非常原始的数据,如果我们想要读取数据,还需要针对不同的串口转USB芯片或协议编写驱动程序才能获取到正确的数据。

顺道一提,如果想要将数据写入串口数据的话可以使用 controlTransfer()

所以,我们在实际生产环境中使用的都是基于此封装好的第三方库。

这里推荐使用 usb-serial-for-android

usb-serial-for-android

使用这个库的第一步当然是导入依赖:

// 添加仓库
allprojects {
repositories {
...
maven { url 'https://jitpack.io' }
}
}
// 添加依赖
dependencies {
implementation 'com.github.mik3y:usb-serial-for-android:3.4.6'
}

添加完依赖同样需要在清单文件中添加相应字段以及处理权限,因为和上述使用原生API一致,所以这里不再赘述。

和原生 API 不同的是,因为我们此时已经知道了我们的 ESP32 主板的设备信息,以及使用的驱动(CDC),所以我们就不使用原生的查找可用设备的方法了,我们这里直接指定我们已知的这个设备(当然,你也可以继续使用原生API的查找和连接方法):

private fun scanDevice(context: Context) {
val manager = context.getSystemService(Context.USB_SERVICE) as UsbManager

val customTable = ProbeTable()
// 添加我们的设备信息,三个参数分别为 vendroId、productId、驱动程序
customTable.addProduct(0x1a86, 0x55d3, CdcAcmSerialDriver::class.java)

val prober = UsbSerialProber(customTable)
// 查找指定的设备是否存在
val drivers: List<UsbSerialDriver> = prober.findAllDrivers(manager)

if (drivers.isNotEmpty()) {
val driver = drivers[0]
// 这个设备存在,连接到这个设备
val connection = manager.openDevice(driver.device)
}
else {
Log.i(TAG, "scanDevice: 无设备!")
}
}

连接到设备后,下一步就是和数据交互,这里封装的十分方便,只需要获取到 UsbSerialPort 后,直接调用它的 read()write() 即可读写数据:

port = driver.ports[0] // 大多数设备都只有一个 port,所以大多数情况下直接取第一个就行

port.open(connection)
// 设置连接参数,波特率9600,以及 “8N1”
port.setParameters(9600, 8, UsbSerialPort.STOPBITS_1, UsbSerialPort.PARITY_NONE)

// 读取数据
val responseBuffer = ByteArray(1024)
port.read(responseBuffer, 0)

// 写入数据
val sendData = byteArrayOf(0x6F)
port.write(sendData, 0)

此时,一个完整的,用于测试我们上述 ESP32 程序的代码如下:

@Composable
fun SerialScreen() {
val context = LocalContext.current


Column(
Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Button(onClick = { scanDevice(context) }) {
Text(text = "查找并连接设备")
}

Button(onClick = { switchLight(true) }) {
Text(text = "开灯")
}
Button(onClick = { switchLight(false) }) {
Text(text = "关灯")
}

}
}

private fun scanDevice(context: Context) {
val manager = context.getSystemService(Context.USB_SERVICE) as UsbManager

val customTable = ProbeTable()
customTable.addProduct(0x1a86, 0x55d3, CdcAcmSerialDriver::class.java)

val prober = UsbSerialProber(customTable)
val drivers: List<UsbSerialDriver> = prober.findAllDrivers(manager)

if (drivers.isNotEmpty()) {
val driver = drivers[0]

val connection = manager.openDevice(driver.device)
if (connection == null) {
Log.i(TAG, "scanDevice: 连接失败")
return
}

port = driver.ports[0]

port.open(connection)
port.setParameters(9600, 8, UsbSerialPort.STOPBITS_1, UsbSerialPort.PARITY_NONE)

Log.i(TAG, "scanDevice: Connect success!")

CoroutineScope(Dispatchers.IO).launch {
while (true) {
val responseBuffer = ByteArray(1024)

val len = port.read(responseBuffer, 0)

Log.i(TAG, "scanDevice: recv data = ${responseBuffer.copyOfRange(0, len).contentToString()}")
}
}
}
else {
Log.i(TAG, "scanDevice: 无设备!")
}
}

private fun switchLight(isON: Boolean) {
val sendData = if (isON) byteArrayOf(0x6F) else byteArrayOf(0x63)

port.write(sendData, 0)
}

运行这个程序,并且连接设备,输出如下:

5.png

可以看到输出的是 byte 的 101,转换为 ASCII 即为 “e”。

然后我们点击 “开灯”、“关灯” 效果如下:

6.gif

对了,这里发送的数据 “0x6F” 即 ASCII “o” 的十六进制,同理,“0x63” 即 “c”。

可以看到,可以完美的和我们的 ESP32 开发版进行通信。

实例

无论使用什么方式与串口通信,我们在安卓APP的代码层面能够拿到的数据已经是处理好了的数据。

即,在上一篇文章中我们说过串口通信的一帧数据包括起始位、数据位、校验位、停止位。但是我们在安卓中使用时一般拿到的都只有 数据位 的数据,其他数据已经在底层被解析好了,无需我们去关心怎么解析,或者使用。

我们可以直接拿到的就是可用数据。

这里举一个我之前用过的某型号驱动版的例子。

这块驱动版关于通信的信息如图:

7.png

可以看到,它采用了 RS485 的通信方式,波特率支持 9600 或 38400,8位数据位,无校验,1位停止位。

并且,它还规定了一个数据协议。

在它定义的协议中,第一位为地址;第二位为指令;第三位到第N位为数据内容;最后两位为CRC校验。

需要注意的是,这里定义的协议是基于串口通信的,不要把这个协议和串口通信搞混了,简单来说就是在串口通信协议的数据位中又定义了一个自己的协议。

而且可以看到,虽然定义串口参数时没有指定校验,但是在它自己的协议中指定了使用 CRC 校验。

另外,弱弱的吐槽一句,这个驱动版的协议真的不好使。

在实际使用过程中,主机与驱动版的通信数据无法保证一定会在同一个数据帧中发送完成,所以可能会造成“粘包”、“分包”现象,也就是说,数据可能会分几次发过来,而且你不好判断这数据是上次没发送完的数据还是新的数据。

我使用过的另外一款驱动版就方便的多,因为它会在帧头加上开始符号和数据长度,帧尾加上结束符号。

这样一来,即使出现“粘包”、“分包”我们也能很好的给它解析出来。

当然,它这样设计协议肯定是有它的道理的,无非就是减少通信代价之类的。

我还遇到过一款十分简洁的驱动版,直接发送一个整数过去表示执行对应的指令。

驱动版回传的数据同样非常简单,就是一个数字,然后事先约定各个数字表示什么意思……

说归说,我们还是继续来看这款驱动版的通信协议:

8.png

这是它的其中一个指令内容,我们发送指令 “1” 过去后,它会返回当前驱动版的型号和版本信息给我们。

因为我们的主板是定制工控主板,所以使用的通信方式是直接用 android-serialport-api。

最终发送与接收回复也很简单:

/**
* 将十六进制字符串转成 ByteArray
* */
private fun hexStrToBytes(hexString: String): ByteArray {
   check(hexString.length % 2 == 0) { return ByteArray(0) }

   return hexString.chunked(2)
      .map { it.toInt(16).toByte() }
      .toByteArray()
}

private fun isReceivedLegalData(receiveBuffer: ByteArray): Boolean {

   val rcvData = receiveBuffer.copyOf()  //重新拷贝一个使用,避免原数据被清零

   if (cmd.cmdId.checkDataFormat(rcvData)) {  //检查回复数据格式
       isPkgLost = false
       if (cmd.cmdId.isResponseBelong(rcvData)) {  //检查回复命令来源
           if (!AdhShareData.instance.getIsUsingCrc()) {  //如果不开启CRC检验则直接返回 true
               resolveRcvData(cmdRcvDataCallback, rcvData, cmd.cmdId)
               coroutineScope.launch(Dispatchers.Main) {
                   cmdResponseCallback?.onCmdResponse(ResponseStatus.Success, rcvData, 0, rcvData.size, cmd.cmdId)
              }
               return true
          }

           if (cmd.cmdId.checkCrc(rcvData)) {  //检验CRC
                resolveRcvData(cmdRcvDataCallback, rcvData, cmd.cmdId)
               coroutineScope.launch(Dispatchers.Main) {
                   cmdResponseCallback?.onCmdResponse(ResponseStatus.Success, rcvData, 0, rcvData.size, cmd.cmdId)
              }

               return true
          }
           else {
               coroutineScope.launch(Dispatchers.Main) {
                   cmdResponseCallback?.onCmdResponse(ResponseStatus.FailCauseCrcError, ByteArray(0), -1, -1, cmd.cmdId)
              }

               return false
          }
      }
       else {
           coroutineScope.launch(Dispatchers.Main) {
               cmdResponseCallback?.onCmdResponse(ResponseStatus.FailCauseNotFromThisCmd, ByteArray(0), -1, -1, cmd.cmdId)
          }

           return false
      }
  }
   else {  //数据不符合,可能是遇到了分包,继续等待下一个数据,然后合并
       isPkgLost = true
       return isReceivedLegalData(cmd)
       /*coroutineScope.launch(Dispatchers.Main) {
           cmdResponseCallback?.onCmdResponse(ResponseStatus.FailCauseWrongFormat, ByteArray(0), -1, -1, cmd.cmdId)
       }

       return false */
  }
}

// ……省略初始化和连接代码

// 发送数据
val bytes = hexStrToBytes("0201C110")
outputStream.write(bytes, 0, bytes.size)

// 解析数据
val recvBuffer = ByteArray(0)
inputStream.read(recvBuffer)

while (receiveBuffer.isEmpty()) {
  delay(10)
}

isReceivedLegalData()

本来打算直接发我封装好的这个驱动版的协议库的,想了想,好像不太合适,所以就大概抽出了这些不完整的代码,懂这个意思就行了,哈哈。

总结

从上面介绍的两种方式可以看出,两种方式使用各有优缺点。

使用 android-serialport-api 可以直接读取串口数据内容,不需要转USB接口,不需要驱动支持,但是需要 ROOT,适合于定制安卓主板上已经预留了 RS232 或 RS485 接口且设备已 ROOT 的情况下使用。

而使用 USB host ,可以直接读取USB接口转接的串口数据,不需要ROOT,但是只支持有驱动的串口转USB芯片,且只支持使用USB接口,不支持直接连接串口设备。

各位可以根据自己的实际情况灵活选择使用什么方式来实现串口通信。

当然,除了现在介绍的这些串口通信,其实还有一个通信协议在实际使用中用的非常多,那就是 MODBUS 协议。

下一篇文章,我们将介绍 MODBUS。

参考资料

  1. android-serialport-api

  2. What is tty?

  3. Text-Terminal-HOWTO

  4. Terminal Special Files

  5. USB host

  6. Android开启OTG功能/USB Host API功能

作者:equationl
来源:https://juejin.cn/post/7171347086032502792

收起阅读 »