注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

在安卓项目中使用 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

收起阅读 »

使用 koin 作为 Android 注入工具,真香

koin 为 Android 提供了简单易用的 API 接口,让你简单轻松地接入 koin 框架。[koin 在 Android 中的 gradle 配置]mp.weixin.qq.com/s/bscC7mO4O…1.Application 类中 startK...
继续阅读 »

koin 为 Android 提供了简单易用的 API 接口,让你简单轻松地接入 koin 框架。


[koin 在 Android 中的 gradle 配置]

mp.weixin.qq.com/s/bscC7mO4O…

1.Application 类中 startKoin

从您的类中,您可以使用该函数并注入 Android 上下文,如下所示:

Application startKoin androidContext
class MainApplication : Application() {

   override fun onCreate() {
       super.onCreate()

       startKoin {
           // Log Koin into Android logger
           androidLogger()
           // Reference Android context
           androidContext(this@MainApplication)
           // Load modules
           modules(myAppModules)
      }

  }
}

如果您需要从另一个 Android 类启动 Koin,您可以使用该函数为您的 Android 实例提供如下:startKoin Context

startKoin {
   //inject Android context
   androidContext(/* your android context */)
   // ...
}

2. 额外配置

从您的 Koin 配置(在块代码中),您还可以配置 Koin 的多个部分。startKoin { }

2.1 Koin Logging for Android

koin 提供了 log 的 Android 实现。

startKoin {
   // use Android logger - Level.INFO by default
   androidLogger()
   // ...
}

2.2 加载属性

您可以在文件中使用 Koin 属性来存储键/值:assets/koin.properties

startKoin {
   // ...
   // use properties from assets/koin.properties
   androidFileProperties()

}

3. Android 中注入对象实例

3.1 为 Android 类做准备

koin 提供了KoinComponents 扩展,Android 组件都具有这种扩展,这些组件包括 Activity Fragment Service ComponentCallbacks

您可以通过如下方式访问 Kotlin 扩展:

by inject()- 来自 Koin 容器的延迟计算实例

get() - 从 Koin 容器中获取实例

我们可以将一个属性声明为惰性注入:

module {
   // definition of Presenter
   factory { Presenter() }
}
class DetailActivity : AppCompatActivity() {

   // Lazy inject Presenter
   override val presenter : Presenter by inject()

   override fun onCreate(savedInstanceState: Bundle?) {
       //...
  }
}

或者我们可以直接得到一个实例:

override fun onCreate(savedInstanceState: Bundle?) {
   super.onCreate(savedInstanceState)

   // Retrieve a Presenter instance
   val presenter : Presenter = get()
}

注意:如果你的类没有扩展,只需添加 KoinComponent 接口,如果你需要或来自另一个类的实例。inject() get()

3.2 Android Context 使用

class MainApplication : Application() {

   override fun onCreate() {
       super.onCreate()

       startKoin {
           //inject Android context
           androidContext(this@MainApplication)
           // ...
      }

  }
}

在你的定义中,下面的函数允许你在 Koin 模块中获取实例,以帮助你简单地编写需要实例的表达式。androidContext() androidApplication() Context Application

val appModule = module {

  // create a Presenter instance with injection of R.string.mystring resources from Android
  factory {
      MyPresenter(androidContext().resources.getString(R.string.mystring))
  }
}

4. 用于 Android 的 DSL 构造函数

4.1 DSL 构造函数

Koin 现在提供了一种新的 DSL 关键字,允许您直接面向类构造函数,并避免在 lambda 表达式中键入您的定义。

对于 Android,这意味着以下新的构造函数 DSL 关键字:

viewModelOf()`- 相当于`viewModel { }
fragmentOf()`- 相当于`fragment { }
workerOf()`- 相当于`worker { }

注意:请务必在类名之前使用,以定位类构造函数::

4.2 Android DSL 函数示例

给定一个具有以下组件的 Android 应用程序:

// A simple service
class SimpleServiceImpl() : SimpleService

// a Presenter, using SimpleService and can receive "id" injected param
class FactoryPresenter(val id: String, val service: SimpleService)

// a ViewModel that can receive "id" injected param, use SimpleService and get SavedStateHandle
class SimpleViewModel(val id: String, val service: SimpleService, val handle: SavedStateHandle) : ViewModel()

// a scoped Session, that can received link to the MyActivity (from scope)
class Session(val activity: MyActivity)

// a Worker, using SimpleService and getting Context & WorkerParameters
class SimpleWorker(
private val simpleService: SimpleService,
appContext: Context,
private val params: WorkerParameters
) : CoroutineWorker(appContext, params)

我们可以这样声明它们:

module {
singleOf(::SimpleServiceImpl){ bind<SimpleService>() }

factoryOf(::FactoryPresenter)

viewModelOf(::SimpleViewModel)

scope<MyActivity>(){
scopedOf(::Session)
}

workerOf(::SimpleWorker)
}

5. Android 中的 koin 多模块使用

通过使用 Koin,您可以描述模块中的定义。在本节中,我们将了解如何声明,组织和链接模块。

5.1 koin 多模块

组件不必位于同一模块中。模块是帮助您组织定义的逻辑空间,并且可以依赖于其他定义 模块。定义是惰性的,然后仅在组件请求它时才解析。

让我们举个例子,链接的组件位于单独的模块中:

// ComponentB <- ComponentA
class ComponentA()
class ComponentB(val componentA : ComponentA)

val moduleA = module {
// Singleton ComponentA
single { ComponentA() }
}

val moduleB = module {
// Singleton ComponentB with linked instance ComponentA
single { ComponentB(get()) }
}

我们只需要在启动 Koin 容器时声明已使用模块的列表:

class MainApplication : Application() {

override fun onCreate() {
super.onCreate()

startKoin {
// ...

// Load modules
modules(moduleA, moduleB)
}

}
}

5.2 模块包含

类中提供了一个新函数,它允许您通过以有组织和结构化的方式包含其他模块来组合模块includes() Module

新模块有 2 个突出特点:

将大型模块拆分为更小、更集中的模块。

在模块化项目中,它允许您更精细地控制模块可见性(请参阅下面的示例)。

它是如何工作的?让我们采用一些模块,我们将模块包含在:parentModule

// `:feature` module
val childModule1 = module {
/* Other definitions here. */
}
val childModule2 = module {
/* Other definitions here. */
}
val parentModule = module {
includes(childModule1, childModule2)
}

// `:app` module
startKoin { modules(parentModule) }

请注意,我们不需要显式设置所有模块:通过包含,声明的所有模块将自动加载。

parentModule includes childModule1 childModule2 parentModule childModule1 childModule2

信息:模块加载现在经过优化,可以展平所有模块图,并避免重复的模块定义。

最后,您可以包含多个嵌套或重复的模块,Koin 将扁平化所有包含的模块,删除重复项:

// :feature module
val dataModule = module {
/* Other definitions here. */
}
val domainModule = module {
/* Other definitions here. */
}
val featureModule1 = module {
includes(domainModule, dataModule)
}
val featureModule2 = module {
includes(domainModule, dataModule)
}
// :app module
class MainApplication : Application() {

override fun onCreate() {
super.onCreate()

startKoin {
// ...

// Load modules
modules(featureModule1, featureModule2)
}

}
}

请注意,所有模块将只包含一次:dataModule domainModule featureModule1 featureModule2

5.3 Android ViewModel 和 Navigation

Gradle 模块引入了一个新的 DSL 关键字,该关键字作为补充,以帮助声明 ViewModel 组件并将其绑定到 Android 组件生命周期。关键字也可用允许您使用其构造函数声明 ViewModel。koin-android viewModel singlefactory viewModelOf

val appModule = module {

// ViewModel for Detail View
viewModel { DetailViewModel(get(), get()) }

// or directly with constructor
viewModelOf(::DetailViewModel)
}

声明的组件必须至少扩展类。您可以指定如何注入类的构造函数 并使用该函数注入依赖项。android.arch.lifecycle.ViewModel get()

注意:关键字有助于声明 ViewModel 的工厂实例。此实例将由内部 ViewModelFactory 处理,并在需要时重新附加 ViewModel 实例。它还将允许注入参数。viewModel viewModelOf

5.4 注入 ViewModel

在 Android 组件中使用 viewModel ,Activity Fragment Service

by viewModel()- 惰性委托属性,用于将视图模型注入到属性中

getViewModel()- 直接获取视图模型实例

class DetailActivity : AppCompatActivity() {

// Lazy inject ViewModel
val detailViewModel: DetailViewModel by viewModel()
}

5.5 Activity 共享 ViewModel

一个 ViewModel 实例可以在 Fragment 及其主 Activity 之间共享。

要在使用中注入共享视图模型,请执行以下操作:Fragment

by activityViewModel()- 惰性委托属性,用于将共享 viewModel 实例注入到属性中

get ActivityViewModel()- 直接获取共享 viewModel 实例

只需声明一次视图模型:

val weatherAppModule = module {

// WeatherViewModel declaration for Weather View components
viewModel { WeatherViewModel(get(), get()) }
}

注意:viewModel 的限定符将作为 viewModel 的标记处理

并在 Activity 和 Fragment 中重复使用它:

class WeatherActivity : AppCompatActivity() {

/*
* Declare WeatherViewModel with Koin and allow constructor dependency injection
*/
private val weatherViewModel by viewModel<WeatherViewModel>()
}

class WeatherHeaderFragment : Fragment() {

/*
* Declare shared WeatherViewModel with WeatherActivity
*/
private val weatherViewModel by activityViewModel<WeatherViewModel>()
}

class WeatherListFragment : Fragment() {

/*
* Declare shared WeatherViewModel with WeatherActivity
*/
private val weatherViewModel by activityViewModel<WeatherViewModel>()
}

5.6 将参数传递给构造函数

向 viewModel 传入参数,示例代码如下:

模块中

val appModule = module {

// ViewModel for Detail View with id as parameter injection
viewModel { parameters -> DetailViewModel(id = parameters.get(), get(), get()) }
// ViewModel for Detail View with id as parameter injection, resolved from graph
viewModel { DetailViewModel(get(), get(), get()) }
// or Constructor DSL
viewModelOf(::DetailViewModel)
}

依赖注入点传入参数

class DetailActivity : AppCompatActivity() {

val id : String // id of the view

// Lazy inject ViewModel with id parameter
val detailViewModel: DetailViewModel by viewModel{ parametersOf(id)}
}

5.7 SavedStateHandle 注入

添加键入到构造函数的新属性以处理 ViewModel 状态:SavedStateHandle

class MyStateVM(val handle: SavedStateHandle, val myService : MyService) : ViewModel() 在 Koin 模块中,只需使用或参数解析它:get()

viewModel { MyStateVM(get(), get()) } 或使用构造函数 DSL:

viewModelOf(::MyStateVM) 在 Activity Fragment

by viewModel()- 惰性委托属性,用于将状态视图模型实例注入属性

getViewModel()- 直接获取状态视图模型实例

class DetailActivity : AppCompatActivity() {

// MyStateVM viewModel injected with SavedStateHandle
val myStateVM: MyStateVM by viewModel()
}

5.8 Navigation 导航图中的 viewModel

您可以将 ViewModel 实例的范围限定为导航图。只需要传入 ID 给by koinNavGraphViewModel()

class NavFragment : Fragment() {

val mainViewModel: NavViewModel by koinNavGraphViewModel(R.id.my_graph)

}

5.9 viewModel 通用 API

Koin 提供了一些“底层”API 来直接调整您的 ViewModel 实例。viewModelForClass ComponentActivity Fragment

ComponentActivity.viewModelForClass(
clazz: KClass<T>,
qualifier: Qualifier? = null,
owner: ViewModelStoreOwner = this,
state: BundleDefinition? = null,
key: String? = null,
parameters: ParametersDefinition? = null,
): Lazy<T>

还提供了顶级函数:

fun <T : ViewModel> getLazyViewModelForClass(
clazz: KClass<T>,
owner: ViewModelStoreOwner,
scope: Scope = GlobalContext.get().scopeRegistry.rootScope,
qualifier: Qualifier? = null,
state: BundleDefinition? = null,
key: String? = null,
parameters: ParametersDefinition? = null,
): Lazy<T>

5.10 ViewModel API - Java Compat

必须将 Java 兼容性添加到依赖项中:

// Java Compatibility
implementation "io.insert-koin:koin-android-compat:$koin_version"
您可以使用以下函数或静态函数将 ViewModel 实例注入到 Java 代码库中:viewModel() getViewModel() ViewModelCompat

@JvmOverloads
@JvmStatic
@MainThread
fun <T : ViewModel> getViewModel(
owner: ViewModelStoreOwner,
clazz: Class<T>,
qualifier: Qualifier? = null,
parameters: ParametersDefinition? = null
)

6. 在 Jetpack Compose 中注入

请先了解 Jetpack Compose 相关内容:

developer.android.com/jetpack/com…

6.1 注入@Composable

在编写可组合函数时,您可以访问以下 Koin API:

get()- 从 Koin 容器中获取实例

getKoin()- 获取当前 Koin 实例

对于声明“MyService”组件的模块:

val androidModule = module {

single { MyService() }
}

我们可以像这样获取您的实例:

@Composable
fun App() {
val myService = get<MyService>()
}

注意:为了在 Jetpack Compose 的功能方面保持一致,最好的编写方法是将实例直接注入到函数属性中。这种方式允许使用 Koin 进行默认实现,但保持开放状态以根据需要注入实例。

@Composable
fun App(myService: MyService = get()) {
}

6.2 viewModel @Composable

与访问经典单/工厂实例的方式相同,您可以访问以下 Koin ViewModel API:

getViewModel()`或 - 获取实例`koinViewModel()

对于声明“MyViewModel”组件的模块:

module {
viewModel { MyViewModel() }
// or constructor DSL
viewModelOf(::MyViewModel)
}

我们可以像这样获取您的实例:

@Composable
fun App() {
val vm = koinViewModel<MyViewModel>()
}

我们可以在函数参数中获取您的实例:

@Composable
fun App(vm : MyViewModel = koinViewModel()) {

}

7. 管理 Android 作用域

Android 组件,如Activity、Fragment、Service都有生命周期,这些组件都是由 System 实例化,组件中有相应的生命周期回调。

正因为 Android 组件具有生命周期属性,所以不能在 koin 中传入组件实例。按照生命周期长短,组件可分为三类:

  • • 长周期组件(Service、database)——由多个屏幕使用,永不丢弃

  • • 中等周期组件(User session)——由多个屏幕使用,必须在一段时间后删除

  • • 短周期组件(ViewModel) ——仅由一个 Screen 使用,必须在 Screen 末尾删除

对于长周期组件,我们通常在应用全局使用 single 创建单实例

在 MVP 架构模式下,Presenter 是短周期组件

在 Activity 中创建方式如下

class DetailActivity : AppCompatActivity() {

// injected Presenter
override val presenter : Presenter by inject()

我们也可以在 module 中创建

我们使用 factory 作用域创建 Presenter 实例

val androidModule = module {

// Factory instance of Presenter
factory { Presenter() }
}

生成绑定到作用域的实例 scope

val androidModule = module {

scope<DetailActivity> {
scoped { Presenter() }
}
}

大多数 Android 内存泄漏来自从非 Android 组件引用 UI/Android 组件。系统保留引用在它上面,不能通过垃圾收集完全回收它。

7.1 申明 Android 作用域

要限定 Android 组件上的依赖关系,您必须使用如下所示的块声明一个作用域:scope

class MyPresenter()
class MyAdapter(val presenter : MyPresenter)

module {
// Declare scope for MyActivity
scope<MyActivity> {
// get MyPresenter instance from current scope
scoped { MyAdapter(get()) }
scoped { MyPresenter() }
}
}

7.2 Android Scope 类

Koin 提供了 Android 生命周期组件相关的 Scope 类ScopeActivity Retained ScopeActivity ScopeFragment

class MyActivity : ScopeActivity() {

// MyPresenter is resolved from MyActivity's scope
val presenter : MyPresenter by inject()
}

Android Scope 需要与接口一起使用来实现这样的字段:AndroidScopeComponent scope

abstract class ScopeActivity(
@LayoutRes contentLayoutId: Int = 0,
) : AppCompatActivity(contentLayoutId), AndroidScopeComponent {

override val scope: Scope by activityScope()

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

checkNotNull(scope)
}
}

我们需要使用接口并实现属性。这将设置类使用的默认 Scope。AndroidScopeComponent scope

7.3 Android Scope 接口

要创建绑定到 Android 组件的 Koin 作用域,只需使用以下函数:

createActivityScope()- 为当前 Activity 创建 Scope(必须声明 Scope 部分)

createActivityRetainedScope()- 为当前 Activity 创建 RetainedScope(由 ViewModel Lifecycle 支持)(必须声明 Scope 部分)

createFragmentScope()- 为当前 Fragment 创建 Scope 并链接到父 Activity Scope 这些函数可作为委托使用,以实现不同类型的作用域:

activityScope()- 为当前 Activity 创建 Scope(必须声明 Scope 部分)

activityRetainedScope()- 为当前 Activity 创建 RetainedScope(由 ViewModel Lifecycle 支持)(必须声明 Scope 部分)

fragmentScope()- 为当前 Fragment 创建 Scope 并链接到父 Activity Scope

class MyActivity() : AppCompatActivity(contentLayoutId), AndroidScopeComponent {

override val scope: Scope by activityScope()

}

我们还可以使用以下内容设置保留范围(由 ViewModel 生命周期提供支持):

class MyActivity() : AppCompatActivity(contentLayoutId), AndroidScopeComponent {

override val scope: Scope by activityRetainedScope()
}

如果您不想使用 Android Scope 类,则可以使用自己的类并使用 Scope 创建 API AndroidScopeComponent

7.4 Scope 链接

Scope 链接允许在具有自定义作用域的组件之间共享实例。在更广泛的用法中,您可以跨组件使用实例。例如,如果我们需要共享一个实例。Scope UserSession

首先声明一个范围定义:

module {
// Shared user session data
scope(named("session")) {
scoped { UserSession() }
}
}

当需要开始使用实例时,请为其创建范围:UserSession

val ourSession = getKoin().createScope("ourSession",named("session"))

// link ourSession scope to current `scope`, from ScopeActivity or ScopeFragment
scope.linkTo(ourSession)

然后在您需要的任何地方使用它:

class MyActivity1 : ScopeActivity() {

fun reuseSession(){
val ourSession = getKoin().createScope("ourSession",named("session"))

// link ourSession scope to current `scope`, from ScopeActivity or ScopeFragment
scope.linkTo(ourSession)

// will look at MyActivity1's Scope + ourSession scope to resolve
val userSession = get<UserSession>()
}
}
class MyActivity2 : ScopeActivity() {

fun reuseSession(){
val ourSession = getKoin().createScope("ourSession",named("session"))

// link ourSession scope to current `scope`, from ScopeActivity or ScopeFragment
scope.linkTo(ourSession)

// will look at MyActivity2's Scope + ourSession scope to resolve
val userSession = get<UserSession>()
}
}

8.Fragment Factory

由于 AndroidX 已经发布了软件包系列以扩展 Android 的功能 androidx.fragment Fragment

developer.android.com/jetpack/and…

8.1 Fragment Factory

自版本以来,已经引入了 ,一个专门用于创建类实例的类:2.1.0-alpha-3 FragmentFactory Fragment

developer.android.com/reference/k…

Koin 也提供了创建 Fragment 的工厂类 KoinFragmentFactory Fragment

8.2 设置 Fragment Factory

首先,在 KoinApplication 声明中,使用关键字设置默认实例:fragmentFactory() KoinFragmentFactory

 startKoin {
// setup a KoinFragmentFactory instance
fragmentFactory()

modules(...)
}

8.3 声明并注入 Fragment

声明一个 Fragment 并在 module 中注入

class MyFragment(val myService: MyService) : Fragment() {


}
val appModule = module {
single { MyService() }
fragment { MyFragment(get()) }
}

8.4 获取 Fragment

使用setupKoinFragmentFactory() 设置 FragmentFactory

查询您的 Fragment ,使用supportFragmentManager

supportFragmentManager.beginTransaction()
.replace<MyFragment>(R.id.mvvm_frame)
.commit()

加入可选参数

supportFragmentManager.beginTransaction()
.replace<MyFragment>(
containerViewId = R.id.mvvm_frame,
args = MyBundle(),
tag = MyString()
)

8.5 Fragment Factory & Koin Scopes

如果你想使用 Koin Activity Scope,你必须在你的 Scope 声明你的 Fragment 作为一个定义:scoped

val appModule = module {
scope<MyActivity> {
fragment { MyFragment(get()) }
}
}

并使用您的 Scope 设置您的 Koin Fragment Factory:setupKoinFragmentFactory(lifecycleScope)

class MyActivity : AppCompatActivity() {

override fun onCreate(savedInstanceState: Bundle?) {
// Koin Fragment Factory
setupKoinFragmentFactory(lifecycleScope)

super.onCreate(savedInstanceState)
//...
}
}

9. WorkManager 的 Koin 注入

koin 为 WorkManager 提供单独的组件包 koin-androidx-workmanager

首先,在 KoinApplication 声明中,使用关键字来设置自定义 WorkManager 实例:workManagerFactory()

class MainApplication : Application(), KoinComponent {

override fun onCreate() {
super.onCreate()
startKoin {
// setup a WorkManager instance
workManagerFactory()
modules(...)
}
setupWorkManagerFactory()
}

AndroidManifest.xml 修改,避免使用默认的

    <application . . .>
. . .
<provider
android:name="androidx.work.impl.WorkManagerInitializer"
android:authorities="${applicationId}.workmanager-init"
tools:node="remove" />
</application>

9.1 声明 ListenableWorker

val appModule = module {
single { MyService() }
worker { MyListenableWorker(get()) }
}

9.2 创建额外的 WorkManagerFactory

class MainApplication : Application(), KoinComponent {

override fun onCreate() {
super.onCreate()

startKoin {
workManagerFactory(workFactory1, workFactory2)
. . .
}

setupWorkManagerFactory()
}

}

如果 Koin 和 workFactory1 提供的 WorkManagerFactory 都可以实例化 ListenableWorker,则 Koin 提供的工厂将是使用的工厂。

9.3 更改 koin lib 本身的清单

如果 koin-androidx-workmanager 中的默认 Factory 被禁用,而应用程序开发人员不初始化 koin 的工作管理器基础架构,他最终将没有可用的工作管理器工厂。

针对上面的情况,我们做如下 DSL 改进:

val workerFactoryModule = module {
factory<WorkFactory> { WorkFactory1() }
factory<WorkFactory> { WorkFactory2() }
}

然后让 koin 内部做类似的事情

fun Application.setupWorkManagerFactory(
// no vararg for WorkerFactory
) {
. . .
getKoin().getAll<WorkerFactory>()
.forEach {
delegatingWorkerFactory.addFactory(it)
}
}

参考链接

insert-koin.io/

作者:Calvin873
来源:juejin.cn/post/7189917106580750395

收起阅读 »

终于理解~Android 模块化里的资源冲突

本文翻译自 Understanding resource conflicts in Android,原作者:Adam Campbell⚽ 前言作为 Android 开发者,我们常常需要去管理非常多不同的资源文件,编译时这些资源文件会被统一地收集和整合到同一个包...
继续阅读 »

本文翻译自 Understanding resource conflicts in Android,原作者:Adam Campbell

⚽ 前言

作为 Android 开发者,我们常常需要去管理非常多不同的资源文件,编译时这些资源文件会被统一地收集和整合到同一个包下面。根据官方的《Configure your build》文档介绍的构建过程可以总结这个过程:

  1. 编译器会将源码文件转换成包含了二进制字节码、能运行在 Android 设备上的 DEX 文件,而其他文件则被转换成编译后资源。

  2. APK 打包工具则会将 DEX 文件和编译后资源组合成独立的 APK 文件。

但如果资源的命名发生了碰撞、冲突,会对编译产生什么影响?

事实证明这个影响是不确定的,尤其是涉及到构建外部 Library。

本文将探究一些不同的资源冲突案例,并逐个说明怎样才能安全地命名资源

🇦🇷 App module 内资源冲突

先来看个最简单的资源冲突的案例:同一个资源文件中出现两个命名、类型一样的资源定义,比如:

 <!--strings.xml-->
<resources>
    <string name="hello_world">Hello World!</string>
    <string name="hello_world">Hello World!</string>
</resources>

试图去编译的话,会导致显而易见的错误提示:

 FAILURE: Build failed with an exception.

* What went wrong:
Execution failed for task ':app:mergeDebugResources'.
> /.../strings.xml: Error: Found item String/hello_world more than one time

类似的,另一种常见冲突是在多个文件里定义冲突的资源:

 <!--strings.xml-->
<resources>
    <string name="hello_world">Hello World!</string>
</resources>

<!--other_strings.xml-->
<resources>
    <string name="hello_world">Hello World!</string>
</resources>

我们会收到类似的编译错误,而这次的错误将列出所有发生冲突的具体文件位置。

 FAILURE: Build failed with an exception.

* What went wrong:
Execution failed for task ':app:mergeDebugResources'.
> [string/hello_world] /.../other_strings.xml
  [string/hello_world] /.../strings.xml: Error: Duplicate resources

Android 平台上资源的运作方式变得愈加清晰。我们需要为 App module 指定在类型、名称、设备配置等限定组合下的唯一资源。也就是说,当 App module 引用 string/hello_world 资源的时候,有且仅有一个值被解析出来。开发者们必须解决发生的资源冲突,可以选择删除那些内容重复的资源、重命名仍然需要的资源、亦或移动到其他限定条件下的资源文件。

更多关于资源和限定的信息可以参考官方的《App resources overview》 文档。

🇩🇪 Library 和 App module 的资源冲突

下面这个案例,我们将研究 Library module 定义了一个和 App module 重复的资源而引发的冲突。

 <!--app/../strings.xml-->
<resources>
    <string name="hello">Hello from the App!</string>
</resources>

<!--library/../strings.xml-->
<resources>
    <string name="hello">Hello from the Library!</string>
</resources>

当你编译上面的代码的时候,发现竟然通过了。从我们上个章节的发现来看,我们可以推测 Android 肯定采用了一个规则,去确保在这种场景下仍能够找到一个独有的 string/hello 资源值。

根据官方的《Create an Android library》文档:

编译工具会将来自 Library module 的资源和独立的 App module 资源进行合并。如果双方均具备一个资源 ID 的话,将采用 App 的资源。

这样的话,将会对模块化的 App 开发造成什么影响?比如我们在 Library 中定义了这么一个 TextView 布局:

 <!--library/../text_view.xml-->
<TextView
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:text="@string/hello"
    xmlns:android="http://schemas.android.com/apk/res/android" />

AS 中该布局的预览是这样的。


现在我们决定将这个 TextView 导入到 App module 的布局中:

 <!--app/../activity_main.xml-->
<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:gravity="center"
    tools:context=".MainActivity"
    >

    <include layout="@layout/text_view" />

</LinearLayout>

无论是 AS 中预览还是实际运行,我们可以看到下面的一个显示结果:


不仅是通过布局访问 string/hello 的 App module 会拿到 “Hello from the App!”,Library 本身拿到的也是如此。基于这个原因,我们需要警惕不要无意覆盖 Lbrary 中的资源定义。

🇧🇷 Library 之间的资源冲突

再一个案例,我们将讨论下当多个 Library 里定义了冲突的资源,会发生什么。

首先来看下如下的布局,如果这样写的话会产生什么结果?

 <!--library1/../strings.xml-->
<resources>
    <string name="hello">Hello from Library 1!</string>
</resources>

<!--library2/../strings.xml-->
<resources>
    <string name="hello">Hello from Library 2!</string>
</resources>

<!--app/../activity_main.xml-->
<TextView
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:text="@string/hello" />

string/hello 将会被显示成什么?

事实上这取决于 App build.gradle 文件里依赖这些 Library 的顺序。再次到官方的《Create an Android library》文档里找答案:

如果多个 AAR 库之间发生了冲突,依赖列表里第一个列出(在依赖关系块的顶部)的资源将会被使用。

假使 App module 有这样的依赖列表:

 dependencies {
implementation project(":library1")
implementation project(":library2")
...
}

最后 string/hello 的值将会被编译成 Hello from Library 1!

那么如果这两个 implementation 代码调换顺序,比如 implementation project(":library2") 在前、 implementation project(":library1") 在后,资源值则会被编译成 Hello from Library 2!

从这种微妙的变化可以非常直观地看到,依赖顺序可以轻易地改变 App 的资源展示结果。

🇪🇸 自定义 Attributes 的资源冲突

目前为止讨论的示例都是针对 string 资源的使用,然而需要特别留意的是自定义 attributes 这种有趣的资源类型。

看下如下的 attr 定义:

 <!--app/../attrs.xml-->
<resources>
<declare-styleable name="CustomStyleable">
<attr name="freeText" format="string"/>
</declare-styleable>

<declare-styleable name="CustomStyleable2">
<attr name="freeText" format="string"/>
</declare-styleable>
</resources>

大家可能都认为上面的写法能通过编译、不会报错,而事实上这种写法必将导致下面的编译错误:

 Execution failed for task ':app:mergeDebugResources'.
> /.../attrs.xml: Error: Found item Attr/freeText more than one time

但如果 2 个 Library 也采用了这样的自定义 attr 写法:

 <!--library1/../attrs.xml-->
<resources>
<declare-styleable name="CustomStyleable">
<attr name="freeText" format="string"/>
</declare-styleable>
</resources>

<!--library2/../attrs.xml-->
<resources>
<declare-styleable name="CustomStyleable2">
<attr name="freeText" format="string"/>
</declare-styleable>
</resources>

事实上它却能够通过编译。

然而,如果我们进一步将 Library2 的 attr 做些调整,比如改为 <attr name="freeText" format="boolean"/>。再次编译,它竟然又失败了,而且出现了更多令人费解的错误:

 * What went wrong:
Execution failed for task ':app:mergeDebugResources'.
> A failure occurred while executing com.android.build.gradle.internal.tasks.Workers$ActionFacade
> Android resource compilation failed
/.../library2/build/intermediates/packaged_res/debug/values/values.xml:4:5-6:25: AAPT: error: duplicate value for resource 'attr/freeText' with config ''.
/.../library2/build/intermediates/packaged_res/debug/values/values.xml:4:5-6:25: AAPT: error: resource previously defined here.
/.../app/build/intermediates/incremental/mergeDebugResources/merged.dir/values/values.xml: AAPT: error: file failed to compile.

上面错误的一个重点是: mergeDebugResources/merged.dir/values/values.xml: AAPT: error: file failed to compile

到底是怎么回事呢?

事实上 values.xml 的编译指的是为 App module 生成 R 类。编译期间,AAPT 会尝试在 R 类里为每个资源属性生成独一无二的值。而对于 styleable 类型里的每个自定义 attr,都会在 R 类里生成 2 个的属性值。

第一个是 styleable 命名空间属性值(位于 R.styleable 包下),第二个是全局的 attr 属性值(位于 R.attr 包下)。对于这个探讨的特殊案例,我们则遇到了全局属性值的冲突,并且由于此冲突造成存在 3 个属性值:

  • R.styleable.CustomStyleable_freeText:来自 Library1,用于解析 string 格式的、名称为 freeText 的 attr

  • R.styleable.CustomStyleable2_freeText:来自 Library2,用于解析 boolean 格式的、名称为 freeText 的 attr

  • R.attr.freeText:无法被成功解析,源自我们给它赋予了来自 2 个 Library 的数值,而它们的格式不同,造成了冲突

前面能通过编译的示例是因为 Library 间同名的 R.attr.freeText 格式也相同,最终为 App module 编译到的是独一无二的数值。需要注意:每个 module 具备自己的 R 类,我们不能总是指望属性的数值在 Library 间保持一致。

再次看下官方的《Create an Android library》文档的建议:

当你构建依赖其他 Library 的 App module 时,Library module 们将会被编译成 AAR 文件再添加到 App module 中。所以,每个 Library 都会具备自己的 R 类,用 Library 的包名进行命名。所有包都会创建从 App module 和 Library module 生成的 R 类,包括 App module 的包和 Library moudle 的包。

📝 结语

所以我们能从上面的这些探讨得到什么启发?

是资源编译过程的复杂和微妙吗?

确实是的。但是作为开发者,我们能为自己和团队做的是:解释清楚定义的资源想要做什么,也就是说可以加上名称前缀。我们最喜欢的官方文档《Create an Android library》也提到了这宝贵的一点:

通用的资源 ID 应当避免发生资源冲突,可以考虑使用前缀或其他一致的、对 module 来说独一无二的命名方案(抑或是整个项目都是独一无二的命名)。

根据这个建议,比较好的做法是在我们的项目和团队中建立一个模式:在 module 中的所有资源前加上它的 module 名称,例如library_help_text

这将带来两个好处:

  1. 大大降低了名称冲突的概率。

  2. 明确资源覆盖的意图。

    比如也在 App module 中创建 library_help_text 的话,则表明开发者是有意地覆盖 Library module 中的某些定义。有的时候我们的确会想去覆盖一些其他资源,而这样的编码方式可以明确地告诉自己和团队,在编译的时候会发生预期的覆盖。

抛开内部开发不谈,至少是所有公开的资源都应该加上前缀,尤其是作为一个供应商或者开源项目去发布我们的 library。

可以往的经验来看,Google 自己的 library 也没有对所有的资源进行恰当地前缀命名。这将导致意外的副作用:依赖我们发行的 library 可能会因为命名冲突引发 App 编译失败。

Not a great look!

例如,我们可以看到 Material Design library 会给它们的颜色资源统一地添加 mtrl 的前缀。可是 styleable 下嵌套的 attribute resources 却没有使用 material 之类的前缀。

所以你会看到:假使一个 module 依赖了 Material library,同时依赖的另一个 library 中包含了与 Material library 一样名称的 attribute,那么在为这个 moudle 生成 R 类的时候,会发生冲突的可能。

🙏 鸣谢

本篇文章受到了下面文章或文档的启发和帮助:

📚 原文

作者:TechMerger
来源:juejin.cn/post/7170562275374268447

收起阅读 »

Android电量优化,让你的手机续航更持久

节能减排,从我做起。一款Android应用如果非常耗电,是一定会被主人嫌弃的。自从Android手机的主人用了你开发的app,一天下来,也没干啥事,电就没了。那么他就会想尽办法找出耗电量杀手,当他找出后,很有可能你开发的app就被无情的卸载了。为了避免这种事情...
继续阅读 »

节能减排,从我做起。一款Android应用如果非常耗电,是一定会被主人嫌弃的。自从Android手机的主人用了你开发的app,一天下来,也没干啥事,电就没了。那么他就会想尽办法找出耗电量杀手,当他找出后,很有可能你开发的app就被无情的卸载了。为了避免这种事情发生,我们就要想想办法让我们的应用不那么耗电,电都用在该用的时候和地方。

通过power_profile.xml查看各个手机硬件的耗电量

Google要求手机硬件生产商都要放入power_profile.xml文件到ROM里面。有些不太负责的手机生产商,就乱配,也没有真正测试过。但我们还是可以大概知道耗电的硬件都有哪些。

先从ibotpeaches.github.io/Apktool/ 下载apktool反编译工具,然后执行adb命令,将手机framework的资源apk拉取出来。

adb pull /system/framework/framework-res.apk ./

然后我们用下载好的反编译工具,将framework-res.apk进行反编译。

java -jar apktool_2.7.0.jar d framework-res.apk

apktool_2.7.0.jar换成你下载的具体的jar包名称。 power_profile.xml文件的目录如下:

framework-res/res/xml/power_profile.xml

<?xml version="1.0" encoding="utf-8"?>
<device name="Android">
   <item name="ambient.on">0.1</item>
   <item name="screen.on">0.1</item>
   <item name="screen.full">0.1</item>
   <item name="bluetooth.active">0.1</item>
   <item name="bluetooth.on">0.1</item>
   <item name="wifi.on">0.1</item>
   <item name="wifi.active">0.1</item>
   <item name="wifi.scan">0.1</item>
   <item name="audio">0.1</item>
   <item name="video">0.1</item>
   <item name="camera.flashlight">0.1</item>
   <item name="camera.avg">0.1</item>
   <item name="gps.on">0.1</item>
   <item name="radio.active">0.1</item>
   <item name="radio.scanning">0.1</item>
   <array name="radio.on">
       <value>0.2</value>
       <value>0.1</value>
   </array>
   <array name="cpu.active">
       <value>0.1</value>
   </array>
   <array name="cpu.clusters.cores">
       <value>1</value>
   </array>
   <array name="cpu.speeds.cluster0">
       <value>400000</value>
   </array>
   <array name="cpu.active.cluster0">
       <value>0.1</value>
   </array>
   <item name="cpu.idle">0.1</item>
   <array name="memory.bandwidths">
       <value>22.7</value>
   </array>
   <item name="battery.capacity">1000</item>
   <item name="wifi.controller.idle">0</item>
   <item name="wifi.controller.rx">0</item>
   <item name="wifi.controller.tx">0</item>
   <array name="wifi.controller.tx_levels" />
   <item name="wifi.controller.voltage">0</item>
   <array name="wifi.batchedscan">
       <value>.0002</value>
       <value>.002</value>
       <value>.02</value>
       <value>.2</value>
       <value>2</value>
   </array>
   <item name="modem.controller.sleep">0</item>
   <item name="modem.controller.idle">0</item>
   <item name="modem.controller.rx">0</item>
   <array name="modem.controller.tx">
       <value>0</value>
       <value>0</value>
       <value>0</value>
       <value>0</value>
       <value>0</value>
   </array>
   <item name="modem.controller.voltage">0</item>
   <array name="gps.signalqualitybased">
       <value>0</value>
       <value>0</value>
   </array>
   <item name="gps.voltage">0</item>
</device>

抓到不负责任的手机生产商一枚,好家伙,这么多0.1,明眼人一看就知道这是为了应付Google。尽管这样,我们还是可以从中知道,耗电的有Screen(屏幕亮屏)、Bluetooth(蓝牙)、Wi-Fi(无线局域网)、Audio(音频播放)、Video(视频播放)、Radio(蜂窝数据网络)、Camera的Flashlight(相机闪光灯)和GPS(全球定位系统)等。

电量杀手简介

Screen

屏幕是非常耗电的一个硬件,不要问我为什么。屏幕主要有LCD和OLED两种。LCD屏幕白色光线从屏幕背后的灯管发出,尽管屏幕显示黑屏,依旧耗电,这种屏幕逐渐被淘汰,如果你翻出个早点的功能机,或许能看到。那么大部分Android手机都是OLED的屏幕,每个像素点都是独立的发光单元,屏幕黑屏时,所有像素都不发光。有必要时,让屏幕息屏很重要,当然手机也有自动息屏的时间设置,这个不太需要我们操心。

Radio数据网络和Wi-Fi无线网络

网络也是非常耗电的,其中又以数据网络的耗电更多于Wi-Fi的耗电。所以请尽量引导用户使用Wi-Fi网络使用app的部分功能,比如下载文件。

GPS

GPS也是很耗电的硬件,所以不要动不动就请求地理位置,GPS平常是要关闭的,除非你在使用定位和导航等功能,这样你的手机续航会更好。

WakeLock

如果使用了WakeLock,是可以有效防止息屏情况下的CPU休眠,但是如果不用了,你不释放掉锁的话,则会带来很大的电量的开销。

查看手机耗电的历史记录

// 上次拔掉电源到现在的耗电情况
adb shell dumpsys batterystats --unplugged

你在逗我?让我看命令行的输出?后面我们来使用Battery Historian的图表进行分析。

使用Battery Historian分析手机耗电量

安装Docker

Docker下载网址 docs.docker.com/desktop/ins…

使用Docker容器编排

docker run -p 9999:9999 gcr.io/android-battery-historian/stable:3.0 --port 9999

获取bugreport文件

Android7.0及以上

adb bugreport bugreport.zip

Android6.0及以下

adb bugreport > bugreport.txt

上传bugreport文件进行分析

在浏览器地址栏输入http://localhost:9999


点击Browse按钮并上传bugreport.zip或bugreport.txt生成分析图表。


我们可以通过时间轴来分析应用当下的电池使用情况,比较耗电的是哪部分硬件。

使用JobScheduler来合理执行后台任务

JobScheduler是Android5.0版本推出的API,允许开发者在符合某些条件时创建执行在后台的任务。比如接通电源的情况下才执行某些耗电量大的操作,也可以把一些不紧急的任务在合适的时候批量处理,还可以避开低电量的情况下执行某些任务。

作者:dora
来源:juejin.cn/post/7196321890301575226

收起阅读 »

安卓开发基础——弱引用的使用

前言起因今天开发遇到一个问题,就是在快速点击带点击事件的控件,如果控件里面写的是Dialog弹窗就有概率出现弹窗连续在界面上出现两次,也就是你关闭弹窗后发现还有一个一样的弹窗在界面,这样就会带来不好的体验。结果2月9日在网上查了许多解决方法,就有提到将该Dia...
继续阅读 »

前言

起因

今天开发遇到一个问题,就是在快速点击带点击事件的控件,如果控件里面写的是Dialog弹窗就有概率出现弹窗连续在界面上出现两次,也就是你关闭弹窗后发现还有一个一样的弹窗在界面,这样就会带来不好的体验。

结果

2月9日

在网上查了许多解决方法,就有提到将该Dialog变成类的成员变量,不用每次都new就可能避免这种情况出现,但我着实不清楚为什么以及具体怎么做,于是请教了组里的大哥,大哥和我说他之前也处理过这种问题,使用了弱引用,可我还是不知道具体的实现方式,于是便找到大哥的代码,并在网上了解了弱引用的具体作用。

2月10日

今天我请教了我们掘金开发群的Java大佬,他告诉我,我这个写法仍然避免不了弹两次Dialog的,并给出意见,可以使用共享状态,推荐我创建一个共享的ReentrantLock,不过我还没去实现,等有时间再看看。

下面就让我们看看弱引用到底是什么。

正篇

弱引用的概念

想知道弱引用,那就得知道几个名词:

  • 强引用

  • 软引用

  • 弱引用

  • 虚引用

首先我们来看看这些词的概念:

  1. 强引用

强引用(StrongReference):最传统的“引用”的定义,是指在程序代码之中普遍存在的引用赋值,即类似“Object obj = new Object()”这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。

  1. 软引用

软引用(SoftReference):在系统将要发生内存溢出之前,将会把这些对象列入回收范围之中进行第二次回收。如果这次回收后还没有足够的内存,才会抛出内存流出异常。

  1. 弱引用

弱引用(WeakReference):被弱引用关联的对象只能生存到下一次垃圾收集之前。当垃圾收集器工作时,无论内存空间是否足够,都会回收掉被弱引用关联的对象。

  1. 虚引用

虚引用(PhantomReference):一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来获得一个对象的实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。

以上定义都是参考自知乎回答 :强引用、软引用、弱引用、虚引用有什么区别?具体使用场景是什么? - 知乎 (zhihu.com),从这我们可以了解到其实我们Java中new对象就是强引用,强引用的对象是可触及的,垃圾收集器就永远不会回收掉被引用的对象,也就简而言之对象在引用时,不回收,上面说的文章中也举例说明了强引用的特点:


而我们本篇说的弱引用,则是发现即回收,它通常是用来描述那些非必需对象,只被弱引用关联的对象只能生存到下一次垃圾收集发生为止。在系统GC时,只要发现弱引用,不管系统堆空间使用是否充足,都会回收掉只被弱引用关联的对象。

但是,又因为垃圾回收器的线程通常优先级很低,所以,一般并不一定能很快地发现持有弱引用的对象,而在这种情况下,弱引用对象就可以存在较长的时间。

而如何使用弱引用,我们接着往下看:

使用方法

前言提到我们使用了弱引用在开发中大哥已经使用过,所以我就跟着后面仿写一下就好,而知乎的那篇文章也提到:


这就基本是弱引用的定义方法,因为之前前言说的Dialog问题弱引用并没有真正起效果,所以我们换一种方法去展示他在安卓上的使用,那就是在使用Bitmap时防止OOM,写法如下:

ImageView imageView = findViewById(R.id.vImage);
Bitmap bitmap = BitmapFactory.decodeResource(getResources(),R.drawable.ic_launcher_background);
Drawable drawable = new BitmapDrawable(getResources(), bitmap);
WeakReference<Drawable> weakDrawable = new WeakReference<>(drawable);
Drawable bgDrawable = weakDrawable.get();
if(bgDrawable != null) {
   imageView.setBackground(drawable);
}

我们再对比一下普通的强引用方法:

ImageView imageView = findViewById(R.id.vImage);
Bitmap bitmap = BitmapFactory.decodeResource(getResources(),R.drawable.ic_launcher_background);
Drawable drawable = new BitmapDrawable(getResources(), bitmap);
imageView.setBackground(drawable);

其实,就是对drawable对象从强引用转为弱引用,这样一旦出现内存不足,不会直接去使用drawable对象,让JVM自动回收这些缓存图片对象所占用的空间,从而有效地避免了OOM的问题。

总结

其实这块内容需要对GC机制很熟悉,我不是很熟,所以使用可能也出现不对,希望读者可以积极指正,谢谢观看!

作者:ObliviateOnline
来源:juejin.cn/post/7198519499867815997

收起阅读 »

Flutter Android多窗口方案落地(下)

接:Flutter Android多窗口方案落地(上)插件层封装。插件层就很简单了,创建好MethodCallHandler之后,直接持有单例的EngineManager就可以了。class FlutterMultiWindowsPlugin : Flutte...
继续阅读 »

接:Flutter Android多窗口方案落地(上)

  1. 插件层封装。插件层就很简单了,创建好MethodCallHandler之后,直接持有单例的EngineManager就可以了。

class FlutterMultiWindowsPlugin : FlutterPlugin, MethodCallHandler {
  companion object {
      private const val TAG = "MultiWindowsPlugin"
  }


   @SuppressLint("LongLogTag")
   override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
       Log.i(TAG, "onMessage: onAttachedToEngine")
       Log.i(TAG, "onAttachedToEngine: ${Thread.currentThread().name}")
       MessageHandle.init(flutterPluginBinding.applicationContext)

       MethodChannel(
           flutterPluginBinding.binaryMessenger,
           "flutter_multi_windows.messageChannel",
       ).setMethodCallHandler(this)
   }

   override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) {
       Log.i(TAG, "onDetachedFromEngine: ${Thread.currentThread().name}")
   }

   override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
       Log.i(TAG, "onMethodCall: thread : ${Thread.currentThread().name}")
       MessageHandle.onMessage(call, result)
   }
}
@SuppressLint("StaticFieldLeak")
internal object MessageHandle {
  private const val TAG = "MessageHandle"

   private var context: Context? = null
   private var manager: EngineManager? = null

   fun init(context: Context) {
       this.context = context
       if (manager != null)
           return
       // 必须单例调用
       manager = EngineManager.getInstance(this.context!!)
   }

   // 处理消息,所有管道通用。需要共享Flutter Activity
   fun onMessage(
       call: MethodCall, result: MethodChannel.Result
   ) {
       val params = call.arguments as Map<*, *>
       when (call.method) {
           "open" -> {
               Log.i(TAG, "onMessage: open")
               val map: HashMap<String, Any> = HashMap()
               map["needShowWindow"] = true
               map["name"] = params["name"] as String
               map["entryPoint"] = params["entryPoint"] as String
               map["width"] = (params["width"] as Double).toInt()
               map["height"] = (params["height"] as Double).toInt()
               map["gravityX"] = params["gravityX"] as Int
               map["gravityY"] = params["gravityY"] as Int
               map["paddingX"] = params["paddingX"] as Double
               map["paddingY"] = params["paddingY"] as Double
               map["draggable"] = params["draggable"] as Boolean
               map["type"] = params["type"] as String

               if (params["params"] != null) {
                   map["params"] = params["params"] as ArrayList<String>
               }
               result.success(manager?.showWindow(map, object : EngineCallback {
                   override fun onEngineDestroy(id: String) {
                   }
               }))
           }
           "close" -> {
               val windowId = params["windowId"] as String
               manager?.dismissWindow(windowId)
           }
           "executeTask" -> {
               Log.i(TAG, "onMessage: executeTask")
               val map: HashMap<String, Any> = HashMap()
               map["name"] = params["name"] as String
               map["entryPoint"] = params["entryPoint"] as String
               map["type"] = params["type"] as String
               result.success(manager?.executeTask(map))
           }
           "finishTask" -> {
               manager?.finishTask(params["taskId"] as String)
           }
           "setPosition" -> {
               val res = manager?.setPosition(
                   params["windowId"] as String,
                   params["x"] as Int,
                   params["y"] as Int
               )
               result.success(res)
           }
           "setAlpha" -> {
               val res = manager?.setAlpha(
                   params["windowId"] as String,
                   (params["alpha"] as Double).toFloat(),
               )
               result.success(res)
           }
           "resize" -> {
               val res = manager?.resetWindowSize(
                   params["windowId"] as String,
                   params["width"] as Int,
                   params["height"] as Int
               )
               result.success(res)
           }
           else -> {

           }
       }
   }
}

同时需要清楚,Engine通过传入的entryPoint,就可以找到Flutter层中的方法入口点,在入口点中runApp即可。

实现过程中的坑

在实现过程中我们遇到的值得分享的坑,就是Flutter GestureDetector和Window滑动事件的冲突。 由于悬浮窗是需要可滑动的,因此在原生层需要监听对应的事件;而Flutter的事件,是Android层分发给FlutterView的,两者形成冲突,导致Flutter内部滑动的时候,原生层也会捕获到,最终造成冲突。
如何解决?
从需求上来看,悬浮窗是否需要滑动,应该交给调用方决定,也就是由Flutter层来决定是否Android是否要对Flutter的滑动事件进行监听,即flutterView.setOnTouchListener。这里我们使用一种更轻量级的操作,FlutterView的监听默认加上,然后在事件处理中,我们通过变量来做处理;而Flutter通过MethodChannel改变这个变量,加快了通信速度,避免了事件来回监听和销毁。

flutterView.setOnTouchListener { _event ->
   when (event.action) {
       MotionEvent.ACTION_MOVE -> {
           if (dragging) {
               setPosition(
                   initialX + (event.rawX - startX).roundToInt(),
                   initialY + (event.rawY - startY).roundToInt()
              )
          }
      }
       MotionEvent.ACTION_UP -> {
           dragEnd()
      }
       MotionEvent.ACTION_DOWN -> {
           startX = event.rawX
           startY = event.rawY
           initialX = layoutParams.x
           initialY = layoutParams.y
           dragStart()
           windowManager.updateViewLayout(rootViewlayoutParams)
      }
  }
   false
}

dragging则是通过Flutter层去驱动的:FlutterMultiWindowsPlugin().dragStart();

private fun dragStart() {
   dragging = true
}

private fun dragEnd() {
   dragging = false
}

使用方式

目前我们内部已在4个应用落地了这个方案。应用方式有两种:一种是Flutter通过插件调用,也可以直接通过后台Service打开。效果尚佳,目的都是为了让Flutter的UI跨端使用。
另外,Flutter的方法入口点必须声明@pragma('vm:entry-point')

写在最后

目前来看这种方式可以完美支持Flutter在Android上开启多窗口,且能精准控制。但由于一个engine对应一个窗口,过多engine带来的内存隐患还是不可忽视的。我们希望Flutter官方能尽快的支持engine对应多个入口点,并且共享内存,只不过目前来看还是有点天方夜谭~~
这篇文章,需要有一定原生基础的同学才能看懂。只讲基础原理,代码不全,仅供参考! 另外多窗口的需求,不知道大家需求量如何,热度可以的话我再出个windows的多窗口实现!

作者:Karl_wei
来源:juejin.cn/post/7198824926722949179


收起阅读 »

Flutter Android多窗口方案落地(上)

前言Flutter在桌面端的多窗口需求,一直是个历史巨坑。随着Flutter的技术在我们windows、android桌面设备落地,我们发现多窗口需求必不可少,突破这个技术壁垒已经刻不容缓。实现原理1. 基本原理对于Android移动设备来说,多窗口的应用大多...
继续阅读 »

前言

Flutter在桌面端的多窗口需求,一直是个历史巨坑。随着Flutter的技术在我们windows、android桌面设备落地,我们发现多窗口需求必不可少,突破这个技术壁垒已经刻不容缓。

实现原理

1. 基本原理

对于Android移动设备来说,多窗口的应用大多是用于直播/音视频的悬浮弹窗,让用户离开应用后还能在小窗口中观看内容。实现原理是通过WindowManager创建和管理窗口,包括视图内容、拖拽、事件等操作。
我们都清楚Flutter只是一个可以做业务逻辑的UI框架,在Flutter中想要实现多窗口,也必须依赖Android的窗口管理机制。基于原生的Window,显示Flutter绘制的UI,从而实现跨平台的视图交互和业务逻辑。

2. 具体步骤

  • Android端基于Window Manager创建Window,管理窗口的生命周期和拖拽逻辑;

  • 使用FlutterEngineGroup来管理Flutter Engine,通过引擎吸附Flutter的UI,加入到原生的FlutterView;

  • 把FlutterView通过addView的方式加入到Window上。

3. 原理图


插件实现

基于上述原理,可以在Android的窗口显示Flutter的UI。但要真正提供给Flutter层使用,还需要再封装一个插件层。

  1. 通过单例管理多个窗口 由于是多窗口,可能项目中多个地方都会调用到,因此需要使用单例来统一管理所有窗口的生命周期,保证准确创建、及时销毁。

//引擎生命钩子回调,让调用方感知引擎状态
interface EngineCallback {
   fun onCreate(id:String)
   fun onEngineDestroy(idString)
}

class EngineManager private constructor(contextContext) {

   // 单例对象
   companion object :
       SingletonHolder<EngineManagerContext>(::EngineManager)

   // 窗口类型;如果是单一类型,那么同名窗口将返回上一次的未销毁的实例。
   private val TYPE_SINGLEString = "single"

   init {
       Log.d("EngineManager""EngineManager init")
  }

   data class Entry(
       val engineFlutterEngine,
       val windowAndroidWindow?
  )

   private var myContextContext = context

   private var engineGroupFlutterEngineGroup = FlutterEngineGroup(myContext)

   // 每个窗口对应一个引擎,基于引擎ID和名称存储多窗口的信息,以及查找
   private val engineMap = ConcurrentHashMap<StringEntry>() //搜索引擎,用作消息分发
   private val name2IdMap = ConcurrentHashMap<StringString>() //判断是否存在了任务
   private val id2NameMap = ConcurrentHashMap<StringString>() //根据任务获取name并清除
   private val engineCallback =
       ConcurrentHashMap<StringEngineCallback>() //通知调用方引擎状态 0-create 1-attach 2-destroy

   fun showWindow(
       paramsHashMap<StringAny>,
       engineStatusCallbackEngineCallback
  ): String? {
       val entryString?
       if (params.containsKey("entryPoint")) {
           entry = params["entryPoint"as String
      } else {
           return null
      }

       val nameString?
       if (params.containsKey("name")) {
           name = params["name"as String
      } else {
           return null
      }

       val type = params["type"]
       if (type == TYPE_SINGLE && name2IdMap[name!= null) {
           return name2IdMap[name]
      }

       val windowUid = UUID.randomUUID().toString()
       if (type == TYPE_SINGLE) {
           name2IdMap[name= windowUid
           id2NameMap[windowUid= name
           engineCallback[windowUid= engineStatusCallback
      }
       val dartEntrypoint = DartExecutor.DartEntrypoint(findAppBundlePath(), entry)
       val args = mutableListOf(windowUid)

       var userList<String>? = null
       if (params.containsKey("params")) {
           user = params["params"as List<String>
      }

       if (user != null) {
           args.addAll(user)
      }
       // 把调用方传递的参数回调给Flutter
       val option =
           FlutterEngineGroup.Options(myContext).setDartEntrypoint(dartEntrypoint)
              .setDartEntrypointArgs(
                   args
              )
       val engine = engineGroup.createAndRunEngine(option)
       val draggable = params["draggable"as Boolean? ?true
       val width = params["width"as Int? ?0
       val height = params["height"as Int? ?0

       val config = GravityConfig()
       config.paddingX = params["paddingX"as Double? ?0.0
       config.paddingY = params["paddingY"as Double? ?0.0
       config.gravityX = GravityForX.values()[params["gravityX"as Int? ?1]
       config.gravityY = GravityForY.values()[params["gravityY"as Int? ?1]
       // 把创建好的引擎传给AndroidWindow,由其去创建窗口
       val androidWindow =
           AndroidWindow(myContextdraggablewidthheightconfigengine)
       engineMap[windowUid= Entry(engineandroidWindow)
       androidWindow.open()
       engine.platformViewsController.attach(
           myContext,
           engine.renderer,
           engine.dartExecutor
      )
       return windowUid
  }

   fun setPosition(idString?xIntyInt): Boolean {
       id ?return false
       val entry = engineMap[id]
       entry ?return false
       entry.window?.setPosition(xy)
       return true
  }
   
   fun setSize(idString?widthdoubleheightdouble): Boolean {
       // ......
  }
}

通过代码我们可以看到,每个窗口都对应一个engine,通过name和生成的UUID做唯一标识,然后把engine传给AndroidWindow,在那里加入WindowManger,以及Flutter UI的获取。

  1. AndroidWindow的实现;通过context.getSystemService(Service.WINDOW_SERVICE) as WindowManager获取窗口管理器;同时创建FlutterView和LayoutInfalter,通过engine拿到视图吸附到FlutterView,把FlutterView加到Layout中,最后把Layout通过addView加到WindowManager中显示。

class AndroidWindow(
   private val contextContext,
   private val draggableBoolean,
   private val widthInt,
   private val heightInt,
   private val configGravityConfig,
   private val engineFlutterEngine
) {
   private var startX = 0f
   private var startY = 0f
   private var initialX = 0
   private var initialY = 0
   private var dragging = false
   private lateinit var flutterViewFlutterView
   private var windowManager = context.getSystemService(Service.WINDOW_SERVICEas WindowManager
   private val inflater =
       context.getSystemService(Service.LAYOUT_INFLATER_SERVICEas LayoutInflater
   private val metrics = DisplayMetrics()

   @SuppressLint("InflateParams")
   private var rootView = inflater.inflate(R.layout.floatingnullfalseas ViewGroup
   private val layoutParams = WindowManager.LayoutParams(
       dip2px(contextwidth.toFloat()),
       dip2px(contextheight.toFloat()),
       WindowManager.LayoutParams.TYPE_SYSTEM_ALERT// 系统应用才可使用此类型
       WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,
       PixelFormat.TRANSLUCENT
  )

   fun open() {
       @Suppress("Deprecation")
       windowManager.defaultDisplay.getMetrics(metrics)
       layoutParams.gravity = Gravity.START or Gravity.TOP
       selectMeasurementMode()

       // 设置位置
       val screenWidth = metrics.widthPixels
       val screenHeight = metrics.heightPixels
       when (config.gravityX) {
           GravityForX.Left -> layoutParams.x = config.paddingX!!.toInt()
           GravityForX.Center -> layoutParams.x =
              ((screenWidth - layoutParams.width/ 2 + config.paddingX!!).toInt()
           GravityForX.Right -> layoutParams.x =
              (screenWidth - layoutParams.width - config.paddingX!!).toInt()
           null -> {}
      }

       when (config.gravityY) {
           GravityForY.Top -> layoutParams.y = config.paddingY!!.toInt()
           GravityForY.Center -> layoutParams.y =
              ((screenHeight - layoutParams.height/ 2 + config.paddingY!!).toInt()
           GravityForY.Bottom -> layoutParams.y =
              (screenHeight - layoutParams.height - config.paddingY!!).toInt()
           null -> {}
      }

       windowManager.addView(rootViewlayoutParams)
       flutterView = FlutterView(inflater.contextFlutterSurfaceView(inflater.contexttrue))
       flutterView.attachToFlutterEngine(engine)
       if (draggable) {
           @Suppress("ClickableViewAccessibility")
           flutterView.setOnTouchListener { _event ->
               when (event.action) {
                   MotionEvent.ACTION_MOVE -> {
                       if (dragging) {
                           setPosition(
                               initialX + (event.rawX - startX).roundToInt(),
                               initialY + (event.rawY - startY).roundToInt()
                          )
                      }
                  }
                   MotionEvent.ACTION_UP -> {
                       dragEnd()
                  }
                   MotionEvent.ACTION_DOWN -> {
                       startX = event.rawX
                       startY = event.rawY
                       initialX = layoutParams.x
                       initialY = layoutParams.y
                       dragStart()
                       windowManager.updateViewLayout(rootViewlayoutParams)
                  }
              }
               false
          }
      }
       @Suppress("ClickableViewAccessibility")
       rootView.setOnTouchListener { _event ->
           when (event.action) {
               MotionEvent.ACTION_DOWN -> {
                   layoutParams.flags =
                       layoutParams.flags or WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
                   windowManager.updateViewLayout(rootViewlayoutParams)
                   true
              }
               else -> false
          }
      }

       engine.lifecycleChannel.appIsResumed()

       rootView.findViewById<FrameLayout>(R.id.floating_window)
          .addView(
               flutterView,
               ViewGroup.LayoutParams(
                   ViewGroup.LayoutParams.MATCH_PARENT,
                   ViewGroup.LayoutParams.MATCH_PARENT
              )
          )
       windowManager.updateViewLayout(rootViewlayoutParams)
  }
   // .....

续:Flutter Android多窗口方案落地(下)

作者:Karl_wei
来源:juejin.cn/post/7198824926722949179

收起阅读 »

AndroidQQ登录接入详细介绍

一、前言由于之前自己项目的账号系统不是非常完善,所以考虑接入QQ这个强大的第三方平台的接入,目前项目暂时使用QQ登录的接口进行前期的测试,这次从搭建到完善花了整整两天时间,不得不吐槽一下QQ互联的官方文档,从界面就可以看出了,好几年没维修了,示例代码也写的不是...
继续阅读 »

一、前言

由于之前自己项目的账号系统不是非常完善,所以考虑接入QQ这个强大的第三方平台的接入,目前项目暂时使用QQ登录的接口进行前期的测试,这次从搭建到完善花了整整两天时间,不得不吐槽一下QQ互联的官方文档,从界面就可以看出了,好几年没维修了,示例代码也写的不是很清楚,翻了好多源代码和官方的demo,这个demo可以作为辅助参考,官方文档的api失效了可以从里面找相应的替代,但它的代码也太多了,一个demo 一万行代码,心累,当时把demo弄到可以运行就花了不少时间,很多api好像是失效了,笔者自己做了一些处理和完善,几乎把sdk功能列表的登录相关的api都尝试了一下,真的相当的坑,正文即将开始,希望这篇文章能够给后来者一些参考和帮助。

二、环境配置

1.获取应用ID

这个比较简单,直接到QQ互联官网申请一个即可,官网地址

https://connect.qq.com

申请应用的时候需要注意应用名字不能出现违规词汇,否则可能申请不通过

应用信息的填写需要当前应用的包名和签名,这个腾讯这边提供了一个获取包名和签名的app供我们开发者使用,下载地址

https://pub.idqqimg.com/pc/misc/files/20180928/c982037b921543bb937c1cea6e88894f.apk

未通过审核只能使用调试的QQ号进行登录,通过就可以面向全部用户了,以下为审核通过的图片


2.官网下载相关的sdk

下载地址

https://tangram-1251316161.file.myqcloud.com/qqconnect/OpenSDK_V3.5.10/opensdk_3510_lite_2022-01-11.zip

推荐直接下载最新版本的,不过着实没看懂最新版本的更新公告,说是修复了retrofit冲突的问题,然后当时新建的项目没有用,结果报错,最后还是加上了,才可以


3. jar的引入

将jar放入lib包下,然后在app 同级的 build.gradle添加以下代码即完成jar的引用

dependencies {
...
   implementation fileTree(dir: 'libs', include: '*.jar')
  ...
}

4.配置Manifest

在AndroidManifest.xml中的application结点下增加以下的activity和启动QQ应用的声明,这两个activity无需我们在另外创建文件,引入的jar已经处理好了

 <application
      ...    
       <!--这里的权限为开启网络访问权限和获取网络状态的权限,必须开启,不然无法登录-->
       <uses-permission android:name="android.permission.INTERNET" />
  <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
       <activity
           android:name="com.tencent.tauth.AuthActivity"
           android:exported="true"
           android:launchMode="singleTask"
           android:noHistory="true">
           <intent-filter>
               <action android:name="android.intent.action.VIEW" />

               <category android:name="android.intent.category.DEFAULT" />
               <category android:name="android.intent.category.BROWSABLE" />

               <data android:scheme="tencent你的appId" />
           </intent-filter>
       </activity>
       <activity
           android:name="com.tencent.connect.common.AssistActivity"
           android:configChanges="orientation|keyboardHidden"
           android:screenOrientation="behind"
           android:theme="@android:style/Theme.Translucent.NoTitleBar" />

       <provider
           android:name="androidx.core.content.FileProvider"
           android:authorities="com.tencent.login.fileprovider"
           android:exported="false"
           android:grantUriPermissions="true">
           <meta-data
               android:name="android.support.FILE_PROVIDER_PATHS"
               android:resource="@xml/file_paths" />
       </provider>
...
   </application>

上面的哪个代码的最后提供了一个provider用于访问 QQ 应用的,需要另外创建一个 xml 文件,其中的 authorities 是自定义的名字,确保唯一即可,这边最下面那个provider是翻demo找的,文档没有写,在res文件夹中新增一个包xml,里面添加文件名为file_paths的 xml ,其内容如下

<?xml version="1.0" encoding="utf-8"?>
<paths>
   <external-files-path name="opensdk_external" path="Images/tmp"/>
   <root-path name="opensdk_root" path=""/>
</paths>

三、初始化配置

1.初始化SDK

加入以下代码在创建登录的那个activtiy下,不然无法拉起QQ应用的登录界面,至于官方文档所说的需要用户选择是否授权设备的信息的说明,这里通用的做法是在应用内部声明一个第三方sdk的列表,然后在里面说明SDK用到的相关设备信息的权限

Tencent.setIsPermissionGranted(true, Build.MODEL)

2.创建实例

这部分建议放在全局配置,这样可以实现登录异常强制退出等功能

/**
* 其中APP_ID是申请到的ID
* context为全局context
* Authorities为之前provider里面配置的值
*/
val mTencent = Tencent.createInstance(APP_ID, context, Authorities)

3.开启登录

在开启登录之前需要自己创建一个 UIListener 用来监听回调结果(文档没讲怎么创建的,找了好久的demo)这里的代码为基础的代码,比较容易实现,目前还没写回调相关的代码,主要是为了快速展示效果

open class BaseUiListener(private val mTencent: Tencent) : DefaultUiListener() {
private val kv = MMKV.defaultMMKV()
override fun onComplete(response: Any?) {
if (response == null) {
"返回为空,登录失败".showToast()
return
}
val jsonResponse = response as JSONObject
if (jsonResponse.length() == 0) {
"返回为空,登录失败".showToast()
return
}
"登录成功".showToast()
doComplete(response)
}

private fun doComplete(values: JSONObject?) {

}
override fun onError(e: UiError) {
Log.e("fund", "onError: ${e.errorDetail}")
}

override fun onCancel() {
"取消登录".showToast()
}
}

建立一个按钮用于监听,这里进行登录操作

button.setOnClickListener {

if (!mTencent.isSessionValid) {
//判断会话是否有效
when (mTencent.login(this, "all",iu)) {

//下面为login可能返回的值的情况
0 -> "正常登录".showToast()
1 -> "开始登录".showToast()
-1 -> "异常".showToast()
2 -> "使用H5登陆或显示下载页面".showToast()
else -> "出错".showToast()
}
}
}

这边对mTencent.login(this, "all",iu)中login的参数做一下解释说明

mTencent.login(this, "all",iu)
//这里Tencent的实例mTencent的login函数的三个参数
//1.为当前的context,
//2.权限,可选项,一般选择all即可,即全部的权限,不过目前好像也只有一个开放的权限了
//3.为UIlistener的实例对象

还差最后一步,获取回调的结果的代码,activity的回调,这边显示方法已经废弃了,本来想改造一下的,后面发现要改造的话需要动sdk里面的源码,有点麻烦就没有改了,等更新

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
//腾讯QQ回调,这里的iu仍然是相关的UIlistener
Tencent.onActivityResultData(requestCode, resultCode, data,iu)
if (requestCode == Constants.REQUEST_API) {
if (resultCode == Constants.REQUEST_LOGIN) {
Tencent.handleResultData(data, iu)
}
}
}

至此,已经可以正常登录了,但还有一件我们开发者最关心的事情没有做,获取的用户的数据在哪呢?可以获取QQ号吗?下面将为大家解答这方面的疑惑。

四、接入流程以及相关代码

首先回答一下上面提出的问题,可以获得两段比较关键的json数据,一个是 login 的时候获取的,主要是token相关的数据,还有一段就是用户的个人信息的 json 数据,这些都在 UIListener 中进行处理和获取。第二个问题能不能获取QQ号,答案是不能,我们只能获取与一个与QQ号一样具有唯一标志的id即open_id,显然这是出于用户的隐私安全考虑的,接下来简述一下具体的登录流程

1.登录之前检查是否有token缓存

  • 有,直接启动主activity

  • 无,进入登录界面

判断是否具有登录数据的缓存

//这里采用微信的MMKV进行储存键值数据
MMKV.initialize(this)
val kv = MMKV.defaultMMKV()
kv.decodeString("qq_login")?.let{
val gson = Gson()
val qqLogin = gson.fromJson(it, QQLogin::class.java)
QQLoginTestApplication.mTencent.setAccessToken(qqLogin.access_token,qqLogin.expires_in.toString())
QQLoginTestApplication.mTencent.openId = qqLogin.openid
}

检查token和open_id是否有效和token是否过期,这里采取不同于官方的推荐的用法,主要是api失效了或者是自己没用对方法,总之官方提供的api进行缓存还不如MMKV键值存login json来的实在,也很方便,这里建议多多使用日志,方便排查错误

//这里对于uiListener进行了重写,object的作用有点像java里面的匿名类
//用到了checkLogin的方法
mTencent.checkLogin(object : DefaultUiListener() {
override fun onComplete(response: Any) {
val jsonResp = response as JSONObject

if (jsonResp.optInt("ret", -1) == 0) {
val jsonObject: String? = kv.decodeString("qq_login")
if (jsonObject == null) {
"登录失败".showToast()

} else {
//启动主activity

}
} else {
"登录已过期,请重新登录".showToast()
//启动登录activity

}
}

override fun onError(e: UiError) {
"登录已过期,请重新登录".showToast()
//启动登录activity

}

override fun onCancel() {
"取消登录".showToast()
}
})

2.进入登录界面

在判断session有效的情况下,进入登录界面,对login登录可能出现的返回码做一下解释说明

Login.setOnClickListener {
if (!QQLoginTestApplication.mTencent.isSessionValid) {
when (QQLoginTestApplication.mTencent.login(this, "all",iu)) {
0 -> "正常登录".showToast()
1 -> "开始登录".showToast()
-1 -> {
"异常".showToast()
QQLoginTestApplication.mTencent.logout(QQLoginTestApplication.context)
}
2 -> "使用H5登陆或显示下载页面".showToast()
else -> "出错".showToast()
}
}
}
  • 1:正常登录

    这个就无需做处理了,直接在回调那里做相关的登录处理即可

  • 0:开始登录

    同正常登录

  • -1:异常登录

    这个需要做一点处理,当时第一次遇到这个情况就是主activity异常消耗退回登录的activity,此时在此点击登录界面的按钮导致了异常情况的出现,不过这个处理起来还是比较容易的,执行强制下线操作即可

    "异常".showToast()
    mTencent.logout(QQLoginTestApplication.context)
  • 2:使用H5登陆或显示下载页面

    通常情况下是未安装QQ等软件导致的,这种情况无需处理,SDK自动封装好了,这种情况会自动跳转QQ下载界面

同样的有出现UIListener就需要调用回调进行数据的传输

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
//腾讯QQ回调
Tencent.onActivityResultData(requestCode, resultCode, data,iu)
if (requestCode == Constants.REQUEST_API) {
if (resultCode == Constants.REQUEST_LOGIN) {
Tencent.handleResultData(data, iu)
}
}
}

3.进入主activity

这里需要放置一个按钮执行下线操作,方便调试,同时这里需要将之前的token移除重新获取token等数据的缓存

button.setOnClickListener {
mTencent.logout(this)
val kv = MMKV.defaultMMKV()
kv.remove("qq_login")
//返回登录界面的相关操作
"退出登录成功".showToast()
}

至此,其实还有一个很重要的东西没有说明,那就是token数据的缓存和个人信息数据的获取,这部分我写的登录的那个UIlistener里面了,登录成功的同时,获取login的response的json数据和个人信息的json数据

4.获取两段重要的json数据

  • login 的json数据

    这个比较容易,当我们登录成功的时候,oncomplete里面的response即我们想要的数据

    override fun onComplete(response: Any?) {
    if (response == null) {
    "返回为空,登录失败".showToast()
    return
    }
    val jsonResponse = response as JSONObject
    if (jsonResponse.length() == 0) {
    "返回为空,登录失败".showToast()
    return
    }
    //这个即利用MMKV进行缓存json数据
    kv.encode("qq_login",response.toString())
    "登录成功".showToast()
    }
  • 个人信息的数据

    这个需要在login有效的前提下才能返回正常的数据

    //首先需要用上一步获取的json数据对mTencent进行赋值,这部分放在doComplete方法中执行
    private fun doComplete(values: JSONObject?) {
    //利用Gson进行格式化成对象
    val gson = Gson()
    val qqLogin = gson.fromJson(values.toString(), QQLogin::class.java)
    mTencent.setAccessToken(qqLogin.access_token, qqLogin.expires_in.toString())
    mTencent.openId = qqLogin.openid
    Log.e("fund",values.toString())
    }

    创建一个get_info方法进行获取,注意这里需要对mTencent设置相关的属性才能获取正常获取数据

    private fun getQQInfo(){
       val qqToken = mTencent.qqToken
       //这里的UserInfo是sdk自带的类,传入上下文和token即可
       val info = UserInfo(context,qqToken)
       info.getUserInfo(object :BaseUiListener(mTencent){
           override fun onComplete(response: Any?){
               //这里对数据进行缓存
               kv.encode("qq_info",response.toString())          
          }
      })
    }

5.踩坑系列

这里主要吐槽一下关于腾讯的自带的session缓存机制,当时是抱着不用自己实现缓存直接用现成的机制去看的,很遗憾这波偷懒失败,这部分session的设置不知道具体的缓存机制,只知道大概是用share preference实现的,里面有saveSession,initSession,loadSession这三个方法,看上去很容易的样子,然后抱着这种心态去尝试了一波,果然不出意外空指针异常,尝试修改了一波回调的顺序仍然空指针异常,折腾了大概三个多小时,放弃了,心态给搞崩了,最终释然了,为什么要用腾讯提供的方法,这个缓存自己实现也是相当的容易,这时想到了MMKV,两行代码完成读取,最后只修改了少数的代码完成了登录的token的缓存机制,翻看demo里面的实现,里面好像是用这三种方法进行实现的,可能是某个实现机制没有弄明白,其实也不想明白,自己的思路比再去看demo容易多了,只是多了一个json的转对象的过程,其他的没有差别。所以建议后来者直接自己实现缓存,不用管sdk提供的那些方法,真的有点难用。

五、总结

总之这次完成QQ接入踩了许多的坑,不过幸好最终还是实现了,希望腾讯互联这个sdk能够上传github让更多的人参与和提供反馈,不然这个文档说是最差sdk体验也不为过。下面附上这次实现QQ登录的demo的github地址以及相关的demo apk供大家进行参考,大概总共就400行代码左右比官方的demo好很多,有问题欢迎留言

https://github.com/xyh-fu/QQLoginTest.git

作者:wresource
来源:juejin.cn/post/7072878774261383176

收起阅读 »

告诉你为什么视频广告点不了关闭

前言我们平时玩游戏多多少少会碰到一些视频广告,看完后是能领取游戏奖励的,然后你会发现有时候看完点击那个关闭按钮,结果是跳下载,你理所当然的认为是点击到了外边,事实真的是这样的吗?有些东西不好那啥,你们懂的,所以以下内容纯属我个人猜测,纯属虚构1. 整个广告流程...
继续阅读 »

前言

我们平时玩游戏多多少少会碰到一些视频广告,看完后是能领取游戏奖励的,然后你会发现有时候看完点击那个关闭按钮,结果是跳下载,你理所当然的认为是点击到了外边,事实真的是这样的吗?有些东西不好那啥,你们懂的,所以以下内容纯属我个人猜测,纯属虚构

1. 整个广告流程的各个角色

要想对广告这东西有个大概的了解,你得先知道你看广告的过程中都有哪些角色参与了进来。

简单来说,是有三方参与了进来:
(1)广告提供商:顾名思义负责提供广告,比如你看的广告是一款游戏的广告,那这个游戏的公司就是广告的提供商。
(2)当前应用:就是播放这个广告的应用。
(3)平台:播放广告这个操作就是平台负责的,它负责连接上面两方,从广告提供商中拿到广告,然后让当前应用接入。

平台有很多,比如字节、腾讯都有相对应的广告平台,或者一些小公司自己做广告平台。他们之间的py交易是这样的:所有广告的功能是由平台去开发,然后他会提供一套sdk或者什么的让应用接入,应用你接入之后每播放1次广告,平台就给你多少钱,但是播放的是什么广告,这个就是平台自己去下发。然后广告提供商就找到平台,和他谈商业合作,你帮我展示我家的产品的广告多少次,我给你多少钱。 简单来说他们之间的交易就是这样。

简单来说,就是广告提供商想要影响力,其它两方要钱,他们都希望广告能更多的展示。

2. 广告提供商的操作

广告提供商是花钱让平台推广广告的,那我肯定是希望尽量每次广告的展示都有用户去点击然后下载我们家的应用。

所以广告提供商想出了一个很坏的办法,相信大家都遇到过,就是我播放视频,我在视频的最后几帧里面的图片的右上角放一个关闭图片,误导用户这个关闭图片是点了之后能关闭的,其实它是视频的一部分,所以你点了就相当于点了视频,那就触发跳转下载应用这些操作。

破解的方法也很简单,你等到计算结束后的几秒再点关闭按钮,不要一看到关闭按钮的图片出来马上点。

3. 应用的操作

应用是很难在广告播放的时候去做手脚,因为这部分的代码不是他们写的,他们只是去调用平台写的代码。

那他们想让广告尽可能多的展示,唯一能做的就是把展示广告的地方增加,尽可能多的让更多场景能展示广告。当然这也有副作用,你要是这个应用点哪里都是广告,这不得把用户给搞吐了,砸了自己的口碑,如果只有一些地方有,用户还是能理解的,毕竟赚钱嘛,不寒参。

4. 平台的操作

平台的操作那就丰富了,代码是我写的,兄弟,我想怎么玩就怎么玩,我能有一百种方法算计你。

猜测的,注意,是猜测的[狗头]

有的人说,故意把关闭按钮设置小,让我们误触关闭按钮以外的区域。我只能说,你让我来做,我都不屑于把关闭按钮设置小。

我们都知道平时开发时,我们觉得点击按钮不灵,所以我们想扩大图标的点击区域,但是又不想改变图标的大小,所以我们用padding来实现。同样的,我也能做到不改变图标的大小,然后缩小点击的范围

我写一个自定义view(假设就是关闭图标)

public class TestV extends View {

   public TestV(Context context) {
       super(context);
  }

   public TestV(Context context, AttributeSet attrs) {
       super(context, attrs);
  }

   public TestV(Context context, AttributeSet attrs, int defStyleAttr) {
       super(context, attrs, defStyleAttr);
  }

   @Override
   public boolean dispatchTouchEvent(MotionEvent event) {
       if (event.getAction() == MotionEvent.ACTION_DOWN) {
           int w = getMeasuredWidth();
           int h = getMeasuredHeight();
           Log.d("mmp", "============ view点击");
           if (event.getX() < w / 4 || event.getX() > 3 * w / 4 || event.getY() < h / 4 || event.getY() > 3 * h / 4) {
               return super.dispatchTouchEvent(event);
          } else {
               Log.d("mmp", "============ view点击触发-》关闭");
               return true;
          }
      }
       return super.dispatchTouchEvent(event);
  }
}

代码很简单就不过多讲解,能看出我很简单就实现让点击范围缩小1/4。所以当你点到边缘的时候,其实就相当于点到了广告。

除了缩小范围之外,我还能设置2秒前后点击是不同的效果,你有没有一种感觉,第一次点关闭按钮就是跳到下载应用,然后返回再点击就是关闭,所以你觉得是你第一次点击的时候是误触了外边。

public class TestV extends View {

   private boolean canClose = true;

   public TestV(Context context) {
       super(context);
  }

   public TestV(Context context, AttributeSet attrs) {
       super(context, attrs);
  }

   public TestV(Context context, AttributeSet attrs, int defStyleAttr) {
       super(context, attrs, defStyleAttr);
  }

   @Override
   public void setVisibility(int visibility) {
       super.setVisibility(visibility);
       if (visibility == View.VISIBLE) {
           canClose = false;
      }
  }

   @Override
   public boolean dispatchTouchEvent(MotionEvent event) {
       if (event.getAction() == MotionEvent.ACTION_DOWN) {
           int w = getMeasuredWidth();
           int h = getMeasuredHeight();
           Log.d("mmp", "============ view点击");
           if (!canClose) {
               return super.dispatchTouchEvent(event);
          } else {
               Log.d("mmp", "============ view点击触发-》关闭");
               return true;
          }
      }
       return super.dispatchTouchEvent(event);
  }

   // 播放完成
   public void playFinish() {
       setVisibility(VISIBLE);
       Handler handler = new Handler(Looper.getMainLooper());
       handler.postDelayed(new Runnable() {
           @Override
           public void run() {
               canClose = true;
          }
      }, 2000);
  }

}

播放完成之后调用playFinish方法,然后会把canClose这个状态设置为false,2秒后再设为true。这样你在2秒前点击按钮,其实就是点击外部的效果,也就会跳去下载。

而且你注意,这些限制,我可以不写死在代码里面,可以用后台返回,比如这个2000,我可以后台返回。我就能做到比如第一天我返回0,你觉得没什么问题,能正常点关闭,我判断你是第二天,我直接返2000给你,然后你一想,之前都是正常的,那这次不正常,肯定是我点错。

你以为的意外只不过是我想让你以为是意外罢了。那这个如何去破解呢?我只能说无解,他能有100种方法让你点中跳出去下载,那还能有是什么解法?

作者:流浪汉kylin
来源:juejin.cn/post/7197611189244592186

收起阅读 »

Android App Bundle

1. Android App Bundle 是什么?从 2021 年 8 月起,新应用需要使用 Android App Bundle 才能在 Google Play 中发布。Android App Bundle是一种发布格式,打包出来的格式为aab,而之前我们...
继续阅读 »

1. Android App Bundle 是什么?

从 2021 年 8 月起,新应用需要使用 Android App Bundle 才能在 Google Play 中发布。

Android App Bundle是一种发布格式,打包出来的格式为aab,而之前我们打包出来的格式为apk。编写完代码之后,将其打包成aab格式(里面包含了所有经过编译的代码和资源),然后上传到Google Play。用户最后安装的还是apk,只不过不是一个,而是多个apk,这些apk是Google Play根据App Bundle生成的。

既然已经有了apk,那要App Bundle有啥用?咱之前打一个apk,会把各种架构、各种语言、各种分辨率的图片等全部放入一个apk中,但具体到某个用户的设备上,这个设备只需要一种so库架构、一种语言、一种分辨率的图片,那其他的东西都在apk里面,这就有点浪费了,不仅下载需要更多的流量,而且还占用用户设备更多的存储空间。当然,也可以通过在打包的时候打多个apk,分别支持各种密度、架构、语言的设备,但这太麻烦了。

于是,Google Play出手了。

App Bundle是经过签名的二进制文件,可将应用的代码和资源组织到不同的模块中。比如,当某个用户的设备是xxhdpi+arm64-v8a+values-zh环境,那Google Play后台会利用App Bundle中的对应的模块(xxhdpi+arm64-v8a+values-zh)组装起来,组成一个base apk和多个配置apk供该用户下载并安装,而不会去把其他的像armeabi-v7ax86之类的与当前设备无关的东西组装进apk,这样用户下载的apk体积就会小很多。体积越小,转化率越高,也更环保。

有了Android App Bundle之后,Google Play还提供了2个东西:Play Feature DeliveryPlay Asset Delivery。Play Feature Delivery可以按某种条件分发或按需下载应用的某些功能,从而进一步减小包体积。Play Asset Delivery是Google Play用于分发大体积应用的解决方案,为开发者提供了灵活的分发方式和极高的性能。

2. Android App Bundle打包

打Android App Bundle非常简单,直接通过Android Studio就能很方便地打包,当然命令行也可以的。

  • Android Studio打包:Build -> Generate Signed Bundle / APK -> 选中Android App Bundle -> 选中签名和输入密码 -> 选中debug或者release包 -> finish开始打包

  • gradle命令行打包:./gradlew bundleDebug 或者 ./gradlew bundleRelease

打出来之后是一个类似app-debug.aab的文件,可以将aab文件直接拖入Android Studio进行分析和查看其内部结构,很方便。

3. 如何测试Android App Bundle?

Android App Bundle包倒是打出来了,那怎么进行测试呢?我们设备上仅允许安装apk文件,aab是不能直接进行安装的。这里官方提供了3种方式可供选择:Android Studio 、Google Play 和 bundletool,下面我们一一来介绍。

3.1 Android Studio

利用Android Studio,在我们平时开发时就可以直接将项目打包成debug的aab并且运行到设备上,只需要点一下运行按钮即可(当然,这之前需要一些简单的配置才行)。Android Studio和Google Play使用相同的工具从aab中提取apk并将其安装在设备上,因此这种本地测试策略也是可行的。这种方式可以验证以下几点:

  • 该项目是否可以构建为app bundle

  • Android Studio是否能够从app bundle中提取目标设备配置的apk

  • 功能模块的功能与应用的基本模块是否兼容

  • 该项目是否可以在目标设备上按预期运行

默认情况下,设备连接上Android Studio之后,运行时打的包是apk。所以我们需要配置一下,改成运行时先打app bundle,然后再从app bundle中提取出该设备需要的配置apk,再组装成一个新的apk并签名,随后安装到设备上。具体配置步骤如下:

  1. 从菜单栏中依次选择 Run -> Edit Configurations。

  2. 从左侧窗格中选择一项运行/调试配置。

  3. 在右侧窗格中,选择 General 标签页。

  4. 从 Deploy 旁边的下拉菜单中选择 APK from app bundle。

  5. 如果你的应用包含要测试的免安装应用体验,请选中 Deploy as an instant app 旁边的复选框。

  6. 如果你的应用包含功能模块,你可以通过选中每个模块旁边的复选框来选择要部署的模块。默认情况下,Android Studio 会部署所有功能模块,并且始终都会部署基本应用模块。

  7. 点击 Apply 或 OK。

好了,现在已经配置好了,现在点击运行按钮,Android Studio会构建app bundle,并使用它来仅部署连接的设备及你选择的功能模块所需要的apk。

3.2 bundletool

bundletool 是一种命令行工具,谷歌开源的,Android Studio、Android Gradle 插件和 Google Play 使用这一工具将应用的经过编译的代码和资源转换为 App Bundle,并根据这些 Bundle 生成可部署的 APK。

前面使用Android Studio来测试app bundle比较方便,但是,官方推荐使用bundletool 从 app bundle 将应用部署到连接的设备。因为bundletool提供了专门为了帮助你测试app bundle并模拟通过Google Play分发而设计的命令,这样的话我们就不必上传到Google Play管理中心去测试了。

下面我们就来实验一把。

  1. 首先是下载bundletool,到GitHub上去下载bundletool,地址:github.com/google/bund…

  2. 然后通过Android Studio或者Gradle将项目打包成Android App Bundle,然后通过bundletool将Android App Bundle生成一个apk容器(官方称之为split APKs),这个容器以.apks作为文件扩展名,这个容器里面包含了该应用支持的所有设备配置的一组apk。这么说可能不太好懂,我们实操一下:

//使用debug签名生成apk容器
java -jar bundletool-all-1.14.0.jar build-apks --bundle=app-release.aab --output=my_app.apks

//使用自己的签名生成apk容器
java -jar bundletool-all-1.14.0.jar build-apks --bundle=app-release.aab --output=my_app.apks
--ks=keystore.jks
--ks-pass=file:keystore.pwd
--ks-key-alias=MyKeyAlias
--key-pass=file:key.pwd

ps: build-apks命令是用来打apks容器的,它有很多可选参数,比如这里的--bundle=path表示:指定你的 app bundle 的路径,--output=path表示:指定输出 .apks 文件的名称,该文件中包含了应用的所有 APK 零部件。它的其他参数大家感兴趣可以到bundletool查阅。

执行完命令之后,会生成一个my_app.apks的文件,我们可以把这个apks文件解压出来,看看里面有什么。

 toc.pb

└─splits
       base-af.apk
       base-am.apk
       base-ar.apk
       base-as.apk
       base-az.apk
       base-be.apk
       base-bg.apk
       base-bn.apk
       base-bs.apk
       base-ca.apk
       base-cs.apk
       base-da.apk
       base-de.apk
       base-el.apk
       base-en.apk
       base-es.apk
       base-et.apk
       base-eu.apk
       base-fa.apk
       base-fi.apk
       base-fr.apk
       base-gl.apk
       base-gu.apk
       base-hdpi.apk
       base-hi.apk
       base-hr.apk
       base-hu.apk
       base-hy.apk
       base-in.apk
       base-is.apk
       base-it.apk
       base-iw.apk
       base-ja.apk
       base-ka.apk
       base-kk.apk
       base-km.apk
       base-kn.apk
       base-ko.apk
       base-ky.apk
       base-ldpi.apk
       base-lo.apk
       base-lt.apk
       base-lv.apk
       base-master.apk
       base-mdpi.apk
       base-mk.apk
       base-ml.apk
       base-mn.apk
       base-mr.apk
       base-ms.apk
       base-my.apk
       base-nb.apk
       base-ne.apk
       base-nl.apk
       base-or.apk
       base-pa.apk
       base-pl.apk
       base-pt.apk
       base-ro.apk
       base-ru.apk
       base-si.apk
       base-sk.apk
       base-sl.apk
       base-sq.apk
       base-sr.apk
       base-sv.apk
       base-sw.apk
       base-ta.apk
       base-te.apk
       base-th.apk
       base-tl.apk
       base-tr.apk
       base-tvdpi.apk
       base-uk.apk
       base-ur.apk
       base-uz.apk
       base-vi.apk
       base-xhdpi.apk
       base-xxhdpi.apk
       base-xxxhdpi.apk
       base-zh.apk
       base-zu.apk

里面有一个toc.pb文件和一个splits文件夹(splits顾名思义,就是拆分出来的所有apk文件),splits里面有很多apk,base-开头的apk是主module的相关apk,其中base-master.apk是基本功能apk,base-xxhdpi.apk则是对资源分辨率进行了拆分,base-zh.apk则是对语言资源进行拆分。

我们可以将这些apk拖入Android Studio看一下里面有什么,比如base-xxhdpi.apk

│  AndroidManifest.xml
|  
| resources.arsc

├─META-INF
│     BNDLTOOL.RSA
│     BNDLTOOL.SF
│     MANIFEST.MF

└─res
   ├─drawable-ldrtl-xxhdpi-v17
   │     abc_ic_menu_copy_mtrl_am_alpha.png
   │     abc_ic_menu_cut_mtrl_alpha.png
   │     abc_spinner_mtrl_am_alpha.9.png
   
   ├─drawable-xhdpi-v4
   │     notification_bg_low_normal.9.png
   │     notification_bg_low_pressed.9.png
   │     notification_bg_normal.9.png
   │     notification_bg_normal_pressed.9.png
   │     notify_panel_notification_icon_bg.png
   
   └─drawable-xxhdpi-v4
           abc_textfield_default_mtrl_alpha.9.png
           abc_textfield_search_activated_mtrl_alpha.9.png
           abc_textfield_search_default_mtrl_alpha.9.png
           abc_text_select_handle_left_mtrl_dark.png
           abc_text_select_handle_left_mtrl_light.png
           abc_text_select_handle_middle_mtrl_dark.png
           abc_text_select_handle_middle_mtrl_light.png
           abc_text_select_handle_right_mtrl_dark.png
           abc_text_select_handle_right_mtrl_light.png

首先,这个apk有自己的AndroidManifest.xml,其次是resources.arsc,还有META-INF签名信息,最后是与自己名称对应的xxhdpi的资源。

再来看一个base-zh.apk:

│  AndroidManifest.xml
│ resources.arsc

└─META-INF
       BNDLTOOL.RSA
       BNDLTOOL.SF
       MANIFEST.MF

也是有自己的AndroidManifest.xml、resources.arsc、签名信息,其中resources.arsc里面包含了字符串资源(可以直接在Android Studio中查看)。

分析到这里大家对apks文件就有一定的了解了,它是一个压缩文件,里面包含了各种最终需要组成apk的各种零部件,这些零部件可以根据设备来按需组成一个完整的app。 比如我有一个设备是只支持中文、xxhdpi分辨率的设备,那么这个设备其实只需要下载部分apk就行了,也就是base-master.apk(基本功能的apk)、base-zh.apk(中文语言资源)和base-xxhdpi.apk(图片资源)给组合起来。到Google Play上下载apk,也是这个流程(如果这个项目的后台上传的是app bundle的话),Google Play会根据设备的特性(CPU架构、语言、分辨率等),首先下载基本功能apk,然后下载与之配置的CPU架构的apk、语言apk、分辨率apk等,这样下载的apk是最小的。

  1. 生成好了apks之后,现在我们可以把安卓测试设备插上电脑,然后利用bundletool将apks中适合设备的零部件apk挑选出来,并部署到已连接的测试设备。具体操作命令:java -jar bundletool-all-1.14.0.jar install-apks --apks=my_app.apks,执行完该命令之后设备上就安装好app了,可以对app进行测试了。bundletool会去识别这个测试设备的语言、分辨率、CPU架构等,然后挑选合适的apk安装到设备上,base-master.apk是首先需要安装的,其次是语言、分辨率、CPU架构之类的apk,利用Android 5.0以上的split apks,这些apk安装之后可以共享一套代码和资源。

3.3 Google Play

如果我最终就是要将Android App Bundle发布到Google Play,那可以先上传到Google Play Console的测试渠道,再通过测试渠道进行分发,然后到Google Play下载这个测试的App,这样肯定是最贴近于用户的使用环境的,比较推荐这种方式进行最后的测试。

4. 拆解Android App Bundle格式

首先,放上官方的格式拆解图(下图包含:一个基本模块、两个功能模块、两个资源包):


app bundle是经过签名的二进制文件,可将应用的代码和资源装进不同的模块中,这些模块中的代码和资源的组织方式和apk中相似,它们都可以作为单独的apk生成。Google Play会使用app bundle生成向用户提供的各种apk,如base apk、feature apk、configuration apks、multi-APKs。图中蓝色标识的目录(drawable、values、lib)表示Google Play用来为每个模块创建configuration apks的代码和资源。

  • base、feature1、feature2:每个顶级目录都表示一个不同的应用模块,基本模块是包含在app bundle的base目录中。

  • asset_pack_1asset_pack_2:游戏或者大型应用如果需要大量图片,则可以将asset模块化处理成资源包。资源包可以根据自己的需要,在合适的时机去请求到本地来。

  • BUNDLE-METADATA/:包含元数据文件,其中包含对工具或应用商店有用的信息。

  • 模块协议缓冲区(*pb)文件:元数据文件,向应用商店说明每个模块的内容。如:BundleConfig.pb 提供了有关 bundle 本身的信息(如用于构建 app bundle 的构建工具版本),native.pb 和 resources.pb 说明了每个模块中的代码和资源,这在 Google Play 针对不同的设备配置优化 APK 时非常有用。

  • manifest/:与 APK 不同,app bundle 将每个模块的 AndroidManifest.xml 文件存储在这个单独的目录中。

  • dex/:与 APK 不同,app bundle 将每个模块的 DEX 文件存储在这个单独的目录中。

  • res/lib/assets/:这些目录与典型 APK 中的目录完全相同。

  • root/:此目录存储的文件之后会重新定位到包含此目录所在模块的任意 APK 的根目录。

5. Split APKs

Android 5.0 及以上支持Split APKs机制,Split APKs与常规的apk相差不大,都是包含经过编译的dex字节码、资源和清单文件等。区别是:Android可以将安装的多个Split APKs视为一个应用,也就是虽然我安装了多个apk,但Android系统认为它们是同一个app,用户也只会在设置里面看到一个app被安装上了;而平时我们安装的普通apk,一个apk就对应着一个app。Android上,我们可以安装多个Split APK,它们是共用代码和资源的。

Split APKs的好处是可以将单体式app做拆分,比如将ABI、屏幕密度、语言等形式拆分成多个独立的apk,按需下载和安装,这样可以让用户更快的下载并安装好apk,并且占用更小的空间。

Android App Bundle最终也就是利用这种方式来进行安装的,比如我上面在执行完java -jar bundletool-all-1.14.0.jar install-apks --apks=my_app.apks命令之后,那么最后安装到手机上的apk文件如下:


ps:5.0以下不支持Split APKs,那咋办?没事,Google Play会为这些设备的用户安装一个全量的apk,里面什么都有,问题不大。

6. 国内商店支持Android App Bundle吗?

Android App Bundle不是Google Play的专有格式,它是开源的,任何商店想支持都可以的。

上面扯那么大一堆有的没的,这玩意儿这么好用,那国内商店的支持情况如何。我查了下,发现就华为可以支持,手动狗头。

华为 Android App Bundle developer.huawei.com/consumer/cn…

7. 小结

现在上架Google Play必须上传Android App Bundle才行了,所以有必要简单了解下。简单来说就是Android App Bundle是一种新的发布格式,上传到商店之后,商店会利用这个Android App Bundle生成一堆Split APKs,当用户要去安装某个app时,只需要按需下载Split APKs中的部分apk(base apk + 各种配置apk),进行安装即可,总下载量大大减少。

参考资料

作者:潇风寒月
来源:juejin.cn/post/7197246543207022629

收起阅读 »

android 微信抢红包工具 AccessibilityService(上)

一、目标二、实现流程我们把一个抢红包发的过程拆分来看,可以分为几个步骤:以上是一个抢红包的基本流程。1、收到通知 以及 点击通知栏Ⅰ、AccessibilityServiceⅡ、NotificationListenerService2、点击红包我们来分析一下,...
继续阅读 »

你有因为手速不够快抢不到红包而沮丧? 你有因为错过红包而懊恼吗? 没错,它来了。。。

一、目标

使用AccessibilityService的方式,实现微信自动抢红包(吐槽一下,网上找了许多文档,由于各种原因,无法实现对应效果,所以先给自己整理下),关于AccessibilityService的文章,网上有很多(没错,多的都懒得贴链接那种多),可自行查找。

二、实现流程

1、流程分析(这里只分析在桌面的情况)

我们把一个抢红包发的过程拆分来看,可以分为几个步骤:

收到通知 -> 点击通知栏 -> 点击红包 -> 点击开红包 -> 退出红包详情页

以上是一个抢红包的基本流程。

2、实现步骤

1、收到通知 以及 点击通知栏

接收通知栏的消息,介绍两种方式

Ⅰ、AccessibilityService

即通过AccessibilityService的AccessibilityEvent.TYPE_NOTIFICATION_STATE_CHANGED事件来获取到Notification

private fun handleNotification(eventAccessibilityEvent) {
   val texts = event.text
   if (!texts.isEmpty()) {
           for (text in texts) {
               val content = text.toString()
               //如果微信红包的提示信息,则模拟点击进入相应的聊天窗口
               if (content.contains("[微信红包]")) {
                   if (event.parcelableData != null && event.parcelableData is Notification) {
                       val notificationNotification? = event.parcelableData as Notification?
                       val pendingIntentPendingIntent = notification!!.contentIntent
                       try {
                           pendingIntent.send()
                      } catch (eCanceledException) {
                           e.printStackTrace()
                      }
                  }
              }
          }
      }

}
Ⅱ、NotificationListenerService

这是监听通知栏的另一种方式,记得要获取权限哦

class MyNotificationListenerService : NotificationListenerService() {

   override fun onNotificationPosted(sbnStatusBarNotification?) {
       super.onNotificationPosted(sbn)

       val extras = sbn?.notification?.extras
       // 获取接收消息APP的包名
       val notificationPkg = sbn?.packageName
       // 获取接收消息的抬头
       val notificationTitle = extras?.getString(Notification.EXTRA_TITLE)
       // 获取接收消息的内容
       val notificationText = extras?.getString(Notification.EXTRA_TEXT)
       if (notificationPkg != null) {
           Log.d("收到的消息内容包名:"notificationPkg)
           if (notificationPkg == "com.tencent.mm"){
               if (notificationText?.contains("[微信红包]"== true){
                   //收到微信红包了
                   val intent = sbn.notification.contentIntent
                   intent.send()
              }
          }
      }
       Log.d("收到的消息内容""Notification posted $notificationTitle & $notificationText")
  }

   override fun onNotificationRemoved(sbnStatusBarNotification?) {
       super.onNotificationRemoved(sbn)
  }
}

2、点击红包

通过上述的跳转,可以进入聊天详情页面,到达详情页之后,接下来就是点击对应的红包卡片,那么问题来了,怎么点?肯定不是手动点。。。

我们来分析一下,一个聊天列表中,我们怎样才能识别到红包卡片,我看网上有通过findAccessibilityNodeInfosByViewId来获取对应的View,这个也可以,只是我们获取id的方式需要借助工具,可以用Android Device Monitor,但是这玩意早就废废弃了,虽然在sdk的目录下存在monitor,奈何本人太菜,点击就是打不开


我本地的jdk是11,我怀疑是不兼容,毕竟Android Device Monitor太老了。换新的layout Inspector,也就看看本地的debug应用,无法查看微信的呀。要么就反编译,这个就先不考虑了,或者在配置文件中设置android:accessibilityFlags="flagReportViewIds",然后暴力遍历Node树,打印相应的viewId和className,找到目标id即可。当然也可以换findAccessibilityNodeInfosByText这个方法试试。

这个方法从字面意思能看出来,是通过text来匹配的,我们可以知道红包卡片上面是有“微信红包”的固定字样的,是不是可以通股票这个来匹配呢,这还有个其他问题,并不是所有的红包都需要点,比如已过期,已领取的是不是要过滤下,咋一看挺好过滤的,一个循环就好,仔细想,这是棵树,不太好剔除,所以换了个思路。

最终方案就是递归一棵树,往一个列表里面塞值,“已过期”和“已领取”的塞一个字符串“#”,匹配到“微信红包”的塞一个AccessibilityNodeInfo,这样如果这个红包不能抢,那肯定一前一后分别是一个字符串和一个AccessibilityNodeInfo,因此,我们读到一个AccessibilityNodeInfo,并且前一个值不是字符串,就可以执行点击事件,代码如下

private fun getPacket() {
   val rootNode = rootInActiveWindow
   val caches:ArrayList<Any> = ArrayList()
   recycle(rootNode,caches)
   if(caches.isNotEmpty()){
       for(index in 0 until caches.size){
           if(caches[indexis AccessibilityNodeInfo && (index == 0 || caches[index-1!is String )){
               val node = caches[indexas AccessibilityNodeInfo
               var parent = node.parent
               while (parent != null) {
                   if (parent.isClickable) {
                       parent.performAction(AccessibilityNodeInfo.ACTION_CLICK)
                       break
                  }
                   parent = parent.parent
              }
               break
          }
      }
  }

}

private fun recycle(nodeAccessibilityNodeInfo,caches:ArrayList<Any>) {
       if (node.childCount == 0) {
           if (node.text != null) {
               if ("已过期" == node.text.toString() || "已被领完" == node.text.toString() || "已领取" == node.text.toString()) {
                   caches.add("#")
              }

               if ("微信红包" == node.text.toString()) {
                   caches.add(node)
              }
          }
      } else {
           for (i in 0 until node.childCount) {
               if (node.getChild(i!= null) {
                   recycle(node.getChild(i),caches)
              }
          }
      }
  }

以上只点击了第一个能点击的红包卡片,想点击所有的可另行处理。

3、点击开红包

这里思路跟上面类似,开红包页面比较简单,但是奈何开红包是个按钮,在不知道id的前提下,我们也不知道则呢么获取它,所以采用迂回套路,找固定的东西,我这里发现每个开红包的页面都有个“xxx的红包”文案,然后这个页面比较简单,只有个关闭,和开红包,我们通过获取“xxx的红包”对应的View来获取父View,然后递归子View,判断可点击的,执行点击事件不就可以了吗

private fun openPacket() {
   val nodeInfo = rootInActiveWindow
   if (nodeInfo != null) {
       val list = nodeInfo.findAccessibilityNodeInfosByText ("的红包")
       for ( i in 0 until list.size) {
           val parent = list[i].parent
           if (parent != null) {
               for ( j in 0 until  parent.childCount) {
                   val child = parent.getChild (j)
                   if (child != null && child.isClickable) {
                       child.performAction(AccessibilityNodeInfo.ACTION_CLICK)
                  }
              }

          }

      }
  }

}

4、退出红包详情页

这里回退也是个按钮,我们也不知道id,所以可以跟点开红包一样,迂回套路,获取其他的View,来获取父布局,然后递归子布局,依次执行点击事件,当然关闭事件是在前面的,也就是说关闭会优先执行到

private fun close() {
   val nodeInfo = rootInActiveWindow
   if (nodeInfo != null) {
       val list = nodeInfo.findAccessibilityNodeInfosByText ("的红包")
       if (list.isNotEmpty()) {
           val parent = list[0].parent.parent.parent
           if (parent != null) {
               for ( j in 0 until  parent.childCount) {
                   val child = parent.getChild (j)
                   if (child != null && child.isClickable) {
                       child.performAction(AccessibilityNodeInfo.ACTION_CLICK)
                  }
              }

          }

      }
  }
}

三、遇到问题

1、AccessibilityService收不到AccessibilityEvent.TYPE_NOTIFICATION_STATE_CHANGED事件

android碎片问题很正常,我这边是使用NotificationListenerService来替代的。

2、需要点击View的定位

简单是就是到页面应该点哪个View,找到相应的规则,来过滤出对应的View,这个规则是随着微信的改变而变化的,findAccessibilityNodeInfosByViewId最直接,但是奈何工具问题,有点麻烦,遍历打印可以获取,但是id每个版本可能会变。还有就是通过文案来获取,即findAccessibilityNodeInfosByText,获取一些固定文案的View,这个相对而言在不改版,可能不会变,相对稳定些,如果这个文案的View本身没点击事件,可获取它的parent,尝试点击,或者遍历parent树,根据isClickable来判断是否可以点击。

划重点:

这里还有一种就是钉钉的开红包按钮,折腾了半天,始终拿不到,各种递归遍历,一直没有找到,最后换了个方式,通过AccessibilityService的模拟点击来做,也就是通过坐标来模拟点击,当然要在配置中开启android:canPerformGestures="true", 然后通过 accessibilityService.dispatchGesture() 来处理,具体坐标可以拿一个其他的View,然后通过比例来确定大概得位置,或者,看看能不能拿到外层的Layout也是一样的

object AccessibilityClick {
   fun click(accessibilityServiceAccessibilityServicexFloatyFloat) {
       val builder = GestureDescription.Builder()
       val path = Path()
       path.moveTo(xy)
       path.lineTo(xy)
       builder.addStroke(GestureDescription.StrokeDescription(path010))
       accessibilityService.dispatchGesture(builder.build(), object : AccessibilityService.GestureResultCallback() {
           override fun onCancelled(gestureDescriptionGestureDescription) {
               super.onCancelled(gestureDescription)
          }

           override fun onCompleted(gestureDescriptionGestureDescription) {
               super.onCompleted(gestureDescription)
          }
      }, null)
  }
}

续:android 微信抢红包工具 AccessibilityService(下)

作者:我有一头小毛驴你有吗
来源:juejin.cn/post/7196949524061339703

收起阅读 »

android 微信抢红包工具 AccessibilityService(下)

接:android 微信抢红包工具 AccessibilityService(上)MyNotificationListenerServiceclass MyNotificationListenerService : Notific...
继续阅读 »

接:android 微信抢红包工具 AccessibilityService(上)

四、完整代码

MyNotificationListenerService

class MyNotificationListenerService : NotificationListenerService() {

   override fun onNotificationPosted(sbnStatusBarNotification?) {
       super.onNotificationPosted(sbn)

       val extras = sbn?.notification?.extras
       // 获取接收消息APP的包名
       val notificationPkg = sbn?.packageName
       // 获取接收消息的抬头
       val notificationTitle = extras?.getString(Notification.EXTRA_TITLE)
       // 获取接收消息的内容
       val notificationText = extras?.getString(Notification.EXTRA_TEXT)
       if (notificationPkg != null) {
           Log.d("收到的消息内容包名:"notificationPkg)
           if (notificationPkg == "com.tencent.mm"){
               if (notificationText?.contains("[微信红包]"== true){
                   //收到微信红包了
                   val intent = sbn.notification.contentIntent
                   intent.send()
              }
          }
      } Log.d("收到的消息内容""Notification posted $notificationTitle & $notificationText")
  }

   override fun onNotificationRemoved(sbnStatusBarNotification?) {
       super.onNotificationRemoved(sbn)
  }
}

MyAccessibilityService

class MyAccessibilityService : AccessibilityService() {

   override fun onAccessibilityEvent(eventAccessibilityEvent) {
       val eventType = event.eventType
       when (eventType) {
           AccessibilityEvent.TYPE_NOTIFICATION_STATE_CHANGED -> handleNotification(event)
           AccessibilityEvent.TYPE_WINDOW_STATE_CHANGEDAccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED -> {
               val className = event.className.toString()
               Log.e("测试无障碍",className)
               when (className) {
                   "com.tencent.mm.ui.LauncherUI" -> {
                       // 我管这叫红包卡片页面
                       getPacket()
                  }
                   "com.tencent.mm.plugin.luckymoney.ui.LuckyMoneyReceiveUI" -> {
                       // 貌似是老UI debug没发现进来
                       openPacket()
                  }
                   "com.tencent.mm.plugin.luckymoney.ui.LuckyMoneyNotHookReceiveUI" -> {
                       // 应该是红包弹框UI新页面 debug进来了
                       openPacket()
                  }
                   "com.tencent.mm.plugin.luckymoney.ui.LuckyMoneyDetailUI" -> {
                       // 红包详情页面 执行关闭操作
                       close()
                  }
                   "androidx.recyclerview.widget.RecyclerView" -> {
                       // 这个比较频繁 主要是在聊天页面 有红包来的时候 会触发 当然其他有列表的页面也可能触发 没想到好的过滤方式
                       getPacket()
                  }
              }
          }
      }
  }

   /**
    * 处理通知栏信息
    *
    * 如果是微信红包的提示信息,则模拟点击
    *
    * @param event
    */
   private fun handleNotification(eventAccessibilityEvent) {
       val texts = event.text
       if (!texts.isEmpty()) {
           for (text in texts) {
               val content = text.toString()
               //如果微信红包的提示信息,则模拟点击进入相应的聊天窗口
               if (content.contains("[微信红包]")) {
                   if (event.parcelableData != null && event.parcelableData is Notification) {
                       val notificationNotification? = event.parcelableData as Notification?
                       val pendingIntentPendingIntent = notification!!.contentIntent
                       try {
                           pendingIntent.send()
                      } catch (eCanceledException) {
                           e.printStackTrace()
                      }
                  }
              }
          }
      }

  }

   /**
    * 关闭红包详情界面,实现自动返回聊天窗口
    */
   @TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR2)
   private fun close() {
       val nodeInfo = rootInActiveWindow
       if (nodeInfo != null) {
           val list = nodeInfo.findAccessibilityNodeInfosByText ("的红包")
           if (list.isNotEmpty()) {
               val parent = list[0].parent.parent.parent
               if (parent != null) {
                   for ( j in 0 until  parent.childCount) {
                       val child = parent.getChild (j)
                       if (child != null && child.isClickable) {
                           child.performAction(AccessibilityNodeInfo.ACTION_CLICK)
                      }
                  }

              }

          }
      }
  }

   /**
    * 模拟点击,拆开红包
    */
   @TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR2)
   private fun openPacket() {
       Log.e("测试无障碍","点击红包")
       Thread.sleep(100)
       val nodeInfo = rootInActiveWindow
       if (nodeInfo != null) {
           val list = nodeInfo.findAccessibilityNodeInfosByText ("的红包")
           for ( i in 0 until list.size) {
               val parent = list[i].parent
               if (parent != null) {
                   for ( j in 0 until  parent.childCount) {
                       val child = parent.getChild (j)
                       if (child != null && child.isClickable) {
                           Log.e("测试无障碍","点击红包成功")
                           child.performAction(AccessibilityNodeInfo.ACTION_CLICK)
                      }
              }

          }

          }
      }

  }

   /**
    * 模拟点击,打开抢红包界面
    */
   @TargetApi(Build.VERSION_CODES.JELLY_BEAN)
   private fun getPacket() {
       Log.e("测试无障碍","获取红包")
       val rootNode = rootInActiveWindow
       val caches:ArrayList<Any> = ArrayList()
       recycle(rootNode,caches)
       if(caches.isNotEmpty()){
           for(index in 0 until caches.size){
               if(caches[indexis AccessibilityNodeInfo && (index == 0 || caches[index-1!is String )){
                   val node = caches[indexas AccessibilityNodeInfo
//                   node.performAction(AccessibilityNodeInfo.ACTION_CLICK)
                   var parent = node.parent
                   while (parent != null) {
                       if (parent.isClickable) {
                           parent.performAction(AccessibilityNodeInfo.ACTION_CLICK)
                           Log.e("测试无障碍","获取红包成功")
                           break
                      }
                       parent = parent.parent
                  }
                   break
              }
          }
      }

  }

   /**
    * 递归查找当前聊天窗口中的红包信息
    *
    * 聊天窗口中的红包都存在"微信红包"一词,因此可根据该词查找红包
    *
    * @param node
    */
   private fun recycle(nodeAccessibilityNodeInfo,caches:ArrayList<Any>) {
       if (node.childCount == 0) {
           if (node.text != null) {
               if ("已过期" == node.text.toString() || "已被领完" == node.text.toString() || "已领取" == node.text.toString()) {
                   caches.add("#")
              }

               if ("微信红包" == node.text.toString()) {
                   caches.add(node)
              }
          }
      } else {
           for (i in 0 until node.childCount) {
               if (node.getChild(i!= null) {
                   recycle(node.getChild(i),caches)
              }
          }
      }
  }

   override fun onInterrupt() {}
   override fun onServiceConnected() {
       super.onServiceConnected()
       Log.e("测试无障碍id","启动")
       val infoAccessibilityServiceInfo = serviceInfo
       info.packageNames = arrayOf("com.tencent.mm")
       serviceInfo = info
  }
}

5、总结

此文是对AccessibilityService的使用的一个梳理,这个功能其实不麻烦,主要是一些细节问题,像自动领取支付宝红包,自动领取QQ红包或者其他功能等也都可以用类似方法实现。目前实现了微信和钉钉的,剩下的支付宝QQ啥的没啥人用,就不想做了,不过原理都是一样的,

源码地址: gitee.com/wlr123/acce…

使用时记得开启下对应权限,设置下后台运行权限,电量设置里面允许后台运行等,以及通知栏权限,以保证稳定运行


作者:我有一头小毛驴你有吗
来源:juejin.cn/post/7196949524061339703

收起阅读 »

DialogX 的一些骚包的高阶使用技巧

DialogX 的一些骚包的高阶使用技巧DialogX 是一款轻松易用的对话框组件,具备高扩展性和易上手的特点,包含各种自定义主题样式,可以快速实现各式各样的对话框效果,也避免了 AlertDialog 的诸多蛋疼的问题,详情可以参阅这篇文章:《使用 Dial...
继续阅读 »

DialogX 的一些骚包的高阶使用技巧

DialogX 是一款轻松易用的对话框组件,具备高扩展性和易上手的特点,包含各种自定义主题样式,可以快速实现各式各样的对话框效果,也避免了 AlertDialog 的诸多蛋疼的问题,详情可以参阅这篇文章:《使用 DialogX 快速构建 Android App 对话框》

本篇文章将介绍一些 DialogX 的使用技巧,也欢迎大家集思广益在评论区留下宝贵的建议,DialogX 自始至终的目标都是尽量让开发变得更加简单,基于此目的,DialogX 首先想做的就是避免重复性劳动,一般我们开发产品总会有一些各式各样的需要,比如关于对话框启动和关闭的动画。

局部>组件内>全局生效的属性

局部设置

DialogX 的很多属性都可以自定义调整,最简单的就是通过实例的 set 方法对属性进行调整,例如对于动画,你可以使用这些 set 方法进行调整:


但是,当我们的程序中有大量的对话框,但每个 MessageDialog 都需要调整,又不能影响其他对话框的动画,该怎么设置呢?

组件生效

此时就可以使用该对话框的静态方法直接进行设置,例如:

MessageDialog.overrideEnterDuration = 100;    //入场动画时长为100毫秒
MessageDialog.overrideExitDuration = 100;     //出场动画时长为100毫秒
MessageDialog.overrideEnterAnimRes = R.anim.anim_dialogx_top_enter; //入场动画资源
MessageDialog.overrideExitAnimRes = R.anim.anim_dialogx_top_exit;   //出场动画资源

如果要设置的属性想针对全局,也就是所有对话框都生效,此时可以使用全局设置进行调整:

全局设置

你可以随时召唤神龙 DialogX,直接修改静态属性,这里的设置都是针对全局的,可以快速完成需要的调整。

DialogX.enterAnimDuration = 100;
DialogX.exitAnimDuration = 100;

上边演示的是动画相关设置,除此之外,你还可以对对话框的标题文字样式、对话框OK按钮的样式、取消按钮的样式、正文内容的文字样式等等进行全局的调整,只需要知道属性生效的优先级是:

优先级为:实例使用set方法设置 > 组件override设置 > 全局设置。

额外的,如果需要对部分组件的行为进行调整,例如 PopTip 的默认显示位置位于屏幕底部,但产品或设计要求想显示到屏幕中央,但这个设置又取决于主题的限制,此时你可以通过重写主题的设置来实现调整:

覆盖主题设置

想要将 PopTip 吐司提示不按照主题的设定(例如屏幕底部)显示,而是以自己的要求显示(例如屏幕中央),但对于 PopTip 的 align 属性属于主题控制的,此时可以通过重写主题来调整对话框的部分行为,例如:

DialogX.globalStyle = new MaterialStyle(){
   @Override
   public PopTipSettings popTipSettings() {
       return new PopTipSettings() {
           @Override
           public ALIGN align() {
               return ALIGN.CENTER;
          }
      };
  }
};

DialogX 强大的扩展性允许你发挥更多想象空间!如果你的产品经理或者设计师依然不满足于简简单单的动画,想要定制更为丰富的入场/出场效果,此时可以利用 DialogX 预留的对话框动画控制接口对每一个对话框内的组件动画细节进行定制。

完全的动画细节定制

例如,我们可以针对一个对话框的背景遮罩进行透明度动画效果处理,但对于对话框内容部分进行一个从屏幕顶部进入的动画效果,其他的,请发挥你的想象进行设计吧!

使用 DialogXAnimInterface 接口可以完全自定义开启、关闭动画。

由于 DialogX 对话框组件的内部元素都是暴露的,你可以轻松获取并访问内部实例,利用这一点,再加上 DialogXAnimInterface 会负责对话框启动和关闭的动画行为,你可以充分利用它实现你想要的效果。

例如对于一个 CustomDialog,你可以这样控制其启动和关闭动画:

CustomDialog.show(new OnBindView<CustomDialog>(R.layout.layout_custom_dialog) {
           @Override
           public void onBind(final CustomDialog dialog, View v) {
               //...
          }
      })
       //实现完全自定义动画效果
      .setDialogXAnimImpl(new DialogXAnimInterface<CustomDialog>() {
           //启动对话框动画逻辑
           @Override
           public void doShowAnim(CustomDialog customDialog, ObjectRunnable<Float> animProgress) {
               //创建一个资源动画
               Animation enterAnim;
               int enterAnimResId = com.kongzue.dialogx.R.anim.anim_dialogx_top_enter;
               enterAnim = AnimationUtils.loadAnimation(me, enterAnimResId);
               enterAnim.setInterpolator(new DecelerateInterpolator(2f));
               long enterAnimDurationTemp = enterAnim.getDuration();
               enterAnim.setDuration(enterAnimDurationTemp);
               customDialog.getDialogImpl().boxCustom.startAnimation(enterAnim); //通过 getDialogImpl() 获取内部暴露的 boxCustom 元素
               //创建一个背景遮罩层的渐变动画
               ValueAnimator bkgAlpha = ValueAnimator.ofFloat(0f, 1f);
               bkgAlpha.setDuration(enterAnimDurationTemp);
               bkgAlpha.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
                   @Override
                   public void onAnimationUpdate(ValueAnimator animation) {
                       //汇报动画进度,同时 animation.getAnimatedValue() 将改变遮罩层的透明度
                       animProgress.run((Float) animation.getAnimatedValue());
                  }
              });
               bkgAlpha.start();
          }
           
           //关闭对话框动画逻辑
           @Override
           public void doExitAnim(CustomDialog customDialog, ObjectRunnable<Float> animProgress) {
               //创建一个资源动画
               int exitAnimResIdTemp = com.kongzue.dialogx.R.anim.anim_dialogx_default_exit;
               Animation exitAnim = AnimationUtils.loadAnimation(me, exitAnimResIdTemp);
               customDialog.getDialogImpl().boxCustom.startAnimation(exitAnim); //通过 getDialogImpl() 获取内部暴露的 boxCustom 元素
               //创建一个背景遮罩层的渐变动画
               ValueAnimator bkgAlpha = ValueAnimator.ofFloat(1f, 0f);
               bkgAlpha.setDuration(exitAnim.getDuration());
               bkgAlpha.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
                   @Override
                   public void onAnimationUpdate(ValueAnimator animation) {
                       //汇报动画进度,同时 animation.getAnimatedValue() 将改变遮罩层的透明度
                       animProgress.run((Float) animation.getAnimatedValue());
                  }
              });
               bkgAlpha.start();
          }
      });

对于 animProgress 它本质上是个反向回调执行器,因为动画时长不定,你需要通知 DialogX 当前你的动画到达哪个阶段了,对话框需要根据这个阶段进行操作处理,例如关闭动画执行过程应当是 1f 至 0f 的过程,完毕后应当销毁对话框,那么当 animProgress.run(0f) 时就会执行销毁流程,而启动动画应当是 0f 至 1f 的过程,当 animProgress.run(1f) 时启动对话框的动画完全执行完毕。

另外,你有没有注意到上述代码中的一个小细节?你可以通过 .getDialogImpl() 访问对话框的所有内部实例,这意味着,DialogX 中的所有实例事实上都是对外开放的,你可以在对话框启动后(DialogLifecycle#onShow)通过 DialogImpl 获取对话框的所有内容组件,对他们进行你想做的调整和设置,这都将极大程度上方便开发者对对话框内容进行定制。

正如我一开始所说,DialogX 将坚持努力打造一款更好用,更高效可定制化的对话框组件。

队列对话框

某些场景下需要有“模态”对话框的需要,即,一次性创建多个对话框,组成队列,逐一显示,当上一个对话框关闭时自动启动下一个对话框,此时可以使用队列对话框来完成。

示例代码如下,在 DialogX.showDialogList(...) 中构建多个对话框,请注意这些对话框必须是没有启动的状态,使用 .build() 方法完成构建,以 “,” 分隔组成队列,即可自动启动。

DialogX.showDialogList(
       MessageDialog.build().setTitle("提示").setMessage("这是一组消息对话框队列").setOkButton("开始").setCancelButton("取消")
              .setCancelButton(new OnDialogButtonClickListener<MessageDialog>() {
                   @Override
                   public boolean onClick(MessageDialog dialog, View v) {
                       dialog.cleanDialogList();
                       return false;
                  }
              }),
       PopTip.build().setMessage("每个对话框会依次显示"),
       PopNotification.build().setTitle("通知提示").setMessage("直到上一个对话框消失"),
       InputDialog.build().setTitle("请注意").setMessage("你必须使用 .build() 方法构建,并保证不要自己执行 .show() 方法").setInputText("输入文字").setOkButton("知道了"),
       TipDialog.build().setMessageContent("准备结束...").setTipType(WaitDialog.TYPE.SUCCESS),
       BottomDialog.build().setTitle("结束").setMessage("下滑以结束旅程,祝你编码愉快!").setCustomView(new OnBindView<BottomDialog>(R.layout.layout_custom_dialog) {
           @Override
           public void onBind(BottomDialog dialog, View v) {
               ImageView btnOk;
               btnOk = v.findViewById(R.id.btn_ok);
               btnOk.setOnClickListener(new View.OnClickListener() {
                   @Override
                   public void onClick(View v) {
                                       dialog.dismiss();
                                  }
              });
          }
      })
);

使用过程中,随时可以使用 .cleanDialogList() 来停止接下来的队列对话框的显示。

尾巴

DialogX 正在努力打造一款对开发者更友好,使用起来更为简单方便的对话框组件,若你有好的想法,也欢迎加入进来一起为 DialogX 添砖加瓦,通过 Github 一起让 DialogX 变得更加强大!

DialogX 路牌:github.com/kongzue/Dia…

作者:Kongzue
来源:juejin.cn/post/7197687219581993021

收起阅读 »

5分钟带你了解Android Progress Bar

1、前言 最近在开发中,同事对于android.widget下的控件一知半解,又恰好那天用到了Seekbar,想了想,那就从Seekbar's father ProgressBar 来说说android.widget下的常用控件和常用用法吧。后面也会根据这些控...
继续阅读 »

1、前言


最近在开发中,同事对于android.widget下的控件一知半解,又恰好那天用到了Seekbar,想了想,那就从Seekbar's father ProgressBar 来说说android.widget下的常用控件和常用用法吧。后面也会根据这些控件来进行仿写、扩展,做一些高度自定义的View啦。如果写的不好,或者有错误之处,恳请在评论、私信、邮箱指出,万分感谢🙏


2、ProgressBar


A user interface element that indicates the progress of an operation.


使用很简单,看看一些基本的属性


android:max:进度条的最大值
android:progress:进度条已完成进度值
android:progressDrawable:设置轨道对应的Drawable对象
android:indeterminate:如果设置成true,则进度条不精确显示进度(会一直进行动画)
android:indeterminateDrawable:设置不显示进度的进度条的Drawable对象
android:indeterminateDuration:设置不精确显示进度的持续时间
android:secondaryProgress:二级进度条(使用场景不多)
复制代码

直接在布局中使用即可


        <ProgressBar
style="@android:style/Widget.ProgressBar.Small"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="10dp" />

<ProgressBar
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="10dp" />

<ProgressBar
style="@android:style/Widget.ProgressBar.Large"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="10dp" />

<ProgressBar
android:id="@+id/sb_no_beautiful"
style="@android:style/Widget.ProgressBar.Horizontal"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:max="100"
android:progress="50"
android:secondaryProgress="70" />

<ProgressBar
android:id="@+id/sb_no_beautiful2"
style="@android:style/Widget.Holo.ProgressBar.Horizontal"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:indeterminate="true"
android:max="100"
android:progress="50"
android:secondaryProgress="70" />
复制代码

分别就对应以下图片咯


image-20230206162049591

但是这种样式,不得不怀疑Google之前的审美,肯定是不满意的,怎么换样式呢。


看看XML文件,很容易发现,这几个ProgressBar的差异是因为style引起的,随手点开一个@android:style/Widget.ProgressBar.Horizontal 看看。


    <style name="Widget.ProgressBar.Horizontal">
<item name="indeterminateOnly">false</item>
<item name="progressDrawable">@drawable/progress_horizontal</item>
<item name="indeterminateDrawable">@drawable/progress_indeterminate_horizontal</item>
<item name="minHeight">20dip</item>
<item name="maxHeight">20dip</item>
<item name="mirrorForRtl">true</item>
</style>
复制代码

很好,估摸着样式就出在progressDrawable/indeterminateDrawable上面,看看 @drawable/progress_horizontal 里面


<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:id="@android:id/background">
<shape>
<corners android:radius="5dip" />
<gradient
android:startColor="#ff9d9e9d"
android:centerColor="#ff5a5d5a"
android:centerY="0.75"
android:endColor="#ff747674"
android:angle="270"/>
</shape>
</item>
<item android:id="@android:id/secondaryProgress">
<clip>
<shape>
<corners android:radius="5dip" />
<gradient
android:startColor="#80ffd300"
android:centerColor="#80ffb600"
android:centerY="0.75"
android:endColor="#a0ffcb00"
android:angle="270"/>
</shape>
</clip>
</item>
<item android:id="@android:id/progress">
<clip>
<shape>
<corners android:radius="5dip" />
<gradient
android:startColor="#ffffd300"
android:centerColor="#ffffb600"
android:centerY="0.75"
android:endColor="#ffffcb00"
android:angle="270"/>
</shape>
</clip>
</item>
</layer-list>


复制代码

一个样式文件,分别操控了background/secondaryProgress/progress,这样我们很容易推测出


image-20230206112729207

再看看 @drawable/progress_indeterminate_horizontal


<animation-list xmlns:android="http://schemas.android.com/apk/res/android" android:oneshot="false">
<item android:drawable="@drawable/progressbar_indeterminate1" android:duration="200" />
<item android:drawable="@drawable/progressbar_indeterminate2" android:duration="200" />
<item android:drawable="@drawable/progressbar_indeterminate3" android:duration="200" />
</animation-list>
复制代码

显而易见,这是indeterminate模式下的样式啊,那我们仿写一个不同样式,就很简单了,动手。



styles.xml



<style name="ProgressBar_Beautiful" >
<item name="android:indeterminateOnly">false</item>
<item name="android:progressDrawable">@drawable/progress_horizontal_1</item>
<item name="android:indeterminateDrawable">@drawable/progress_indeterminate_beautiful</item>
<item name="android:mirrorForRtl">true</item>
</style>
复制代码


progress_horizontal_1.xml



<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:id="@android:id/background">
<shape>
<corners android:radius="25dp" />
<solid android:color="#FFF0F0F0"/>
</shape>
</item>

<item android:id="@android:id/secondaryProgress">
<clip>
<shape>
<corners android:radius="25dp" />
<solid android:color="#FFC0EC87"/>

</shape>
</clip>
</item>

<item android:id="@android:id/progress">
<clip>
<shape>
<corners android:radius="25dp" />
<solid android:color="#FFA5E05B"/>
</shape>
</clip>
</item>
</layer-list>
复制代码


progress_indeterminate_beautiful.xml



<?xml version="1.0" encoding="utf-8"?>
<animation-list xmlns:android="http://schemas.android.com/apk/res/android"
android:oneshot="false">
<item android:drawable="@drawable/bg_progress_001" android:duration="200" />
<item android:drawable="@drawable/bg_progress_002" android:duration="200" />
<item android:drawable="@drawable/bg_progress_003" android:duration="200" />
<item android:drawable="@drawable/bg_progress_004" android:duration="200" />
</animation-list>
复制代码

吭呲吭呲就写出来了,看看效果


2023-02-06_16-24-14 (2)


换了个颜色,加了个圆角/ 换了个图片,还行。


我没有去再写环形的ProgressBar了,因为它就是个一个图,疯狂的在旋转。


<animated-rotate xmlns:android="http://schemas.android.com/apk/res/android"
android:drawable="@drawable/spinner_white_76"
android:pivotX="50%"
android:pivotY="50%"
android:framesCount="12"
android:frameDuration="100" />
复制代码

还有一些属性我就不赘述了。你可以根据官方的样式,修一修、改一改,就可以满足一些基本的需求了。


用起来就这么简单,就是因为太简单,更复杂的功能就不是ProgressBar能直接实现的了。比如带个滑块?


3、SeekBar


好吧,ProgressBar的一个子类,也在android.widget下,因为是直接继承,而且就加了个滑块相关的代码,实际上它也非常简单,然我们来看看


<SeekBar
android:id="@+id/sb_01"
style="@style/ProgressBar_Beautiful"
android:layout_width="match_parent"
android:layout_height="20dp"
android:layout_marginVertical="10dp"
android:thumbOffset="1dp"
android:max="100"
android:progress="50"
android:secondaryProgress="70"
android:splitTrack="false"
android:thumb="@drawable/icon_seekbar_thum" />

<SeekBar
android:id="@+id/sb_02"
style="@style/ProgressBar_Beautiful"
android:layout_width="match_parent"
android:layout_height="20dp"
android:layout_marginVertical="10dp"
android:max="100"
android:progress="50"
android:secondaryProgress="70"
android:thumb="@drawable/icon_seekbar_thum" />

<SeekBar
android:id="@+id/sb_03"
style="@style/ProgressBar_Beautiful"
android:layout_width="match_parent"
android:layout_height="20dp"
android:layout_marginVertical="10dp"
android:max="100"
android:progress="100"
android:secondaryProgress="70"
android:splitTrack="false"
android:thumb="@drawable/icon_seekbar_thum" />

<SeekBar
android:id="@+id/sb_04"
style="@style/ProgressBar_Beautiful"
android:layout_width="match_parent"
android:layout_height="20dp"
android:layout_marginVertical="10dp"
android:thumbOffset="1dp"
android:max="100"
android:progress="100"
android:secondaryProgress="70"
android:splitTrack="false"
android:thumb="@drawable/icon_seekbar_thum" />

<SeekBar
android:id="@+id/sb_05"
style="@style/ProgressBar_Beautiful"
android:layout_width="match_parent"
android:layout_height="20dp"
android:layout_marginVertical="10dp"
android:max="100"
android:paddingHorizontal="0dp"
android:progress="50"
android:secondaryProgress="70"
android:thumb="@drawable/icon_seekbar_thum" />


<SeekBar
android:id="@+id/sb_06"
style="@style/ProgressBar_Beautiful"
android:layout_width="match_parent"
android:layout_height="20dp"
android:layout_marginVertical="10dp"
android:max="100"
android:progress="50"
android:secondaryProgress="70"
android:thumb="@null" />
复制代码

样式就在下面了



因为Seekbar相较而言就多了个thumb(就是那个滑块),所以就着重说一下滑块,其他的就一笔带过咯。


主要了解的是如何设置自己的thumb和thumb的各种问题


android:thumb="@drawable/icon_seekbar_thum"
复制代码

设置就这么thumb简单,一个drawable文件解决,我这里对应的是单一图片,不过Google的是带有多种状态的thumb,我们来看看官方是如何实现的


<selector xmlns:android="http://schemas.android.com/apk/res/android"
android:constantSize="true">
<item android:state_enabled="false" android:state_pressed="true">
<bitmap android:src="@drawable/abc_scrubber_control_off_mtrl_alpha"android:gravity="center"/>
</item>
<item android:state_enabled="false">
<bitmap android:src="@drawable/abc_scrubber_control_off_mtrl_alpha"android:gravity="center"/>
</item>
<item android:state_pressed="true">
<bitmap android:src="@drawable/abc_scrubber_control_to_pressed_mtrl_005" android:gravity="center"/>
</item>
<item>
<bitmap android:src="@drawable/abc_scrubber_control_to_pressed_mtrl_000"android:gravity="center"/>
</item>
</selector>
复制代码

引用一个drawable,也是一个熟知的selector组,通过对应的item,我们就可以实现在不同的状态下显示不同的thumb了,具体的样式我就不写了,再说ProgressBar的样式的时候也是有类似的操作的


不过你可能发现了,其实这几个样式看起来都差不多,是因为都是我使用Seekbar遇到的问题以及解决方法,我们细说


(1) 自定义的thumb的背景会裁剪出一个正方形,这对于不规则图形来讲是非常难看的



很简单一行



android:splitTrack="false"



修复0。0


(2)thumb的中心点对齐bar的边界,所以thumb是允许超出进度条一点的。有时候我们不需要



很简单一行



android:thumbOffset="1dp"



修复0,0


(3) 你可能发现就算没有写margin和padding,seekbar也不会占满父布局的,是因为它自带padding,所以如果需要去掉



很简单一行



android:paddingHorizontal="0dp"



修复0>0


(4)最后一个,SeekBar但是不想要滑块!为什么不用ProgressBar呢?没别的就是头铁!


很简单一行



android:thumb="@null"



修复0」0


但是要注意的是,此时Seekbar还是能点击的!所以需要把点击事件拦截掉


sb02.setOnTouchListener { _, _ -> true }
复制代码

真的修复0[]0


好了好了,thumb的监听事件还没说呢


            sb01.setOnSeekBarChangeListener(object : OnSeekBarChangeListener {
override fun onProgressChanged(p0: SeekBar?, p1: Int, p2: Boolean) {
//进度发生改变时会触发
}

override fun onStartTrackingTouch(p0: SeekBar?) {
//按住SeekBar时会触发
}

override fun onStopTrackingTouch(p0: SeekBar?) {
//放开SeekBar时触发
}
})
复制代码

没啦,Seekbar就这么多。


还有一个,放在下次讲吧


对了,如果你感觉你的ProgressBar不够流畅,可以用以下这个


bar.setProgress(progress, true)
复制代码

4、结尾


更多复杂的进度条需求,靠widget的控件,肯定是难以实现的,我们接下来会讲述RatingBar,以及继承ProgressBar,做更多好看的进度条!


没啦,这次就这么多。


作者:AlbertZein
来源:juejin.cn/post/7196994916509286437
收起阅读 »

Android深思如何防止快速点击

前言其实快速点击是个很好解决的问题,但是如何优雅的去解决确是一个难题,本文主要是记录一些本人通过解决快速点击的过程中脑海里浮现的一些对这个问题的深思。1. AOP可以通过AOP来解决这个问题,而且AOP解决的方法也很优雅,在开源上也应该是能找到对应的成熟框架。...
继续阅读 »

前言

其实快速点击是个很好解决的问题,但是如何优雅的去解决确是一个难题,本文主要是记录一些本人通过解决快速点击的过程中脑海里浮现的一些对这个问题的深思。

1. AOP

可以通过AOP来解决这个问题,而且AOP解决的方法也很优雅,在开源上也应该是能找到对应的成熟框架。

AOP来解决这类问题其实是近些年一个比较好的思路,包括比如像数据打点,通过AOP去处理,也能得到一个比较优雅的效果。牛逼的人甚至可以不用别人写的框架,自己去封装就行,我因为对这个技术栈不熟,这里就不献丑了。
总之,如果你想快速又简单的处理这种问题,AOP是一个很好的方案

2. kotlin

使用kotlin的朋友有福了,kotlin中有个概念是扩展函数,使用扩展函数去封装放快速点击的操作逻辑,也能很快的实现这个效果。它的好处就是突出两个字“方便”

那是不是我用java,不用kotlin就实现不了kotlin这个扩展函数的效果?当然不是了。这让我想到一件事,我也有去看这类问题的文章,看看有没有哪个大神有比较好的思路,然后我注意到有人就说用扩展函数就行,不用这么麻烦。

OK,那扩展函数是什么?它的原理是什么?不就是静态类去套一层吗?那用java当然能实现,为什么别人用java去封装这套逻辑就是麻烦呢?代码不都是一样,只不过kotlin帮你做了而已。所以我觉得kotlin的扩展函数效果是方便,但从整体的解决思路上看,缺少点优雅。

3. 流

简单来说也有很多人用了Rxjava或者kotlin的flow去实现,像这种实现也就是能方便而已,在底层上并没有什么实质性的突破,所以就不多说了,说白了就是和上面一样。

4. 通过拦截

因为上面已经说了kt的情况,所以接下来的相关代码都会用java来实现。
通过拦截来达到防止快速点击的效果,而拦截我想到有2种方式,第一种是拦截事件,就是基于事件分发机制去实现,第二种是拦截方法。
相对而言,其实我觉得拦截方法会更加安全,举个场景,假如你有个页面,然后页面正在到计算,到计算完之后会显示一个按钮,点击后弹出一个对话框。然后过了许久,改需求了,改成到计算完之后自动弹出对话框。但是你之前的点击按钮弹出对话框的操作还需要保留。那就会有可能因为某些操作导致到计算完的一瞬间先显示按钮,这时你以迅雷不及掩耳的速度点它,那就弹出两次对话框。

(1)拦截事件

其实就是给事件加个判断,判断两次点击的时间如果在某个范围就不触发,这可能是大部分人会用的方式。

正常情况下我们是无法去入侵事件分发机制的,只能使用它提供的方法去操作,比如我们没办法在外部影响dispatchTouchEvent这些方法。当然不正常的情况下也许可以,你可以尝试往hook的方向去思考能不能实现,我这边就不思考这种情况了。

public class FastClickHelper {

   private static long beforeTime = 0;
   private static Map<View, View.OnClickListener> map = new HashMap<>();

   public static void setOnClickListener(View view, View.OnClickListener onClickListener) {
       map.put(view, onClickListener);
       view.setOnClickListener(new View.OnClickListener() {
           @Override
           public void onClick(View v) {
               long clickTime = SystemClock.elapsedRealtime();
               if (beforeTime != 0 && clickTime - beforeTime < 1000) {
                   return;
              }
               beforeTime = clickTime;

               View.OnClickListener relListener = map.get(v);
               if (relListener != null) {
                   relListener.onClick(v);
              }
          }
      });
  }

}

简单来写就是这样,其实这个就和上面说的kt的扩展函数差不多。调用的时候就

FastClickHelper.setOnClickListener(view, this);

但是能看出这个只是针对单个view去配置,如果我们想其实页面所有view都要放快速点击,只不过某个view需要快速点击,比如抢东西类型的,那肯定不能防。所以给每个view单独去配置就很麻烦,没关系,我们可以优化一下

public class FastClickHelper {

   private Map<View, Integer> map;
   private HandlerThread mThread;

   public void init(ViewGroup viewGroup) {
       map = new ConcurrentHashMap<>();
       initThread();
       loopAddView(viewGroup);

       for (View v : map.keySet()) {
           v.setOnTouchListener(new View.OnTouchListener() {
               @Override
               public boolean onTouch(View v, MotionEvent event) {
                   if (event.getAction() == MotionEvent.ACTION_DOWN) {
                       int state = map.get(v);
                       if (state == 1) {
                           return true;
                      } else {
                           map.put(v, 1);
                           block(v);
                      }
                  }
                   return false;
              }
          });
      }
  }

   private void initThread() {
       mThread = new HandlerThread("LAZY_CLOCK");
       mThread.start();
  }

   private void block(View v) {
       // 切条线程处理
       Handler handler = new Handler(mThread.getLooper());
       handler.postDelayed(new Runnable() {
           @Override
           public void run() {
               if (map != null) {
                   map.put(v, 0);
              }
          }
      }, 1000);
  }

   private void exclude(View... views) {
       for (View view : views) {
           map.remove(view);
      }
  }

   private void loopAddView(ViewGroup viewGroup) {
       for (int i = 0; i < viewGroup.getChildCount(); i++) {
           if (viewGroup.getChildAt(i) instanceof ViewGroup) {
               ViewGroup vg = (ViewGroup) viewGroup.getChildAt(i);
               map.put(vg, 0);
               loopAddView(vg);
          } else {
               map.put(viewGroup.getChildAt(i), 0);
          }
      }
  }

   public void onDestroy() {
       try {
           map.clear();
           map = null;
           mThread.interrupt();
      } catch (Exception e) {
           e.printStackTrace();
      }
  }

}

我把viewgroup当成入参,然后给它的所有子view都设置,因为onclicklistener比较常用,所以改成了设置setOnTouchListener,当然外部如果给view设置了setOnTouchListener去覆盖我这的set,那就只能自己做特殊处理了。

在外部直接调用

FastClickHelper fastClickHelper = new FastClickHelper();
fastClickHelper.init((ViewGroup) getWindow().getDecorView());

如果要想让某个view不要限制快速点击的话,就调用exclude方法。这里要注意使用完之后释放资源,要调用onDestroy方法释放资源。

关于这个部分的思考,其实上面的大家都会,也基本是这样去限制,但是就是即便我用第二种代码,也要每个页面都调用一次,而且看起来,多少差点优雅。

首先我想的办法是在事件分发下发的过程去做处理,就是在viewgroup的dispatchTouchEvent或者onInterceptTouchEvent这类方法里面,但是我简单看了源码是没有提供方法出来的,也没有比较好去hook的地方,所以只能暂时放弃思考在这个下发流程去做手脚。

补充一下,如果你是自定义view,那肯定不会烦恼这个问题,但是你总不能所有的view都做成自定义的吧。

其次我想怎么能通过不写逻辑代码能实现这个效果,但总觉得这个方向不就是AOP吗,或者不是通过开发层面,在开发结束后想办法去注入字节码等操作,我觉得要往这个方向思考的话,最终的实现肯定不是代码层面去实现的。

(2)拦截方法

上面也说了,相对于拦截事件,假设如果都能实现的情况下,我更倾向于去拦截方法。

因为从这层面上来说,如果实现拦截方法,或者说能实现中断方法,那就不只是能做到防快速点击,而是能给方法去定制相对应的规则,比如某个方法在1秒的间隔内只能调用一次,这个就是防快速点击的效果嘛,比如某个方法我限制只能调一次,如果能实现,我就不用再额外写判断这个方法调用一次过后我设置一个布尔类型,然后下次调用再判断这个布尔类型来决定是否调用,

那现在是没办法实现拦截方法吗?当然有办法,只不过会十分的不优雅,比如一个方法是这样的。

public void fun(){
   // todo 第1步
   // todo 第2步
   // todo ......
   // todo 第n步
}

那我可以封装一个类,里面去封装一些策略,然后根据策略再去决定方法要不要执行这些步骤,那可能就会写成

public void fun(){
   new FunctionStrategy(FunctionStrategy.ONLY_ONE, new CallBack{
       @Override
       public void onAction() {
           // todo 第1步
           // todo 第2步
           // todo ......
           // todo 第n步
      }
  })
}

这样就实现了,比如只调用一次,具体的只调用一次的逻辑就写在FunctionStrategy里面,然后第2次,第n次就不会回调。当然我这是随便乱下来表达这个思路,现实肯定不能这样写。首先这样写就很不优雅,其次也会存在很多问题,扩展性也很差。

那在代码层面还有其它办法拦截或者中断方法吗,在代码层还真有办法中断方法,没错,那就是抛异常,但是话说回来,你也不可能在每个地方都try-catch吧,不切实际。

目前对拦截方法或者中断方法,我是没想到什么好的思路了,但是我觉得如果能实现,对防止快速点击来说,肯定会是一个很好的方案。

作者:流浪汉kylin
来源:https://juejin.cn/post/7197337416096055351

收起阅读 »

在Android中实现python的功能

起因:为什么想写这样一篇文章呢,最开始是我的一个朋友和我说想换一个QQ头像但是苦于里面一个常用的模块被下架了在网页中找无关信息太多也显得杂乱,我就萌生了这样一个想法,我是一个程序员这种事能不能通过技术手段实现或者说简化一下说干咱就干(PS:目前只通过Java实...
继续阅读 »

起因:

为什么想写这样一篇文章呢,最开始是我的一个朋友和我说想换一个QQ头像但是苦于里面一个常用的模块被下架了在网页中找无关信息太多也显得杂乱,我就萌生了这样一个想法,我是一个程序员这种事能不能通过技术手段实现或者说简化一下说干咱就干

(PS:目前只通过Java实现了爬虫的功能就不多赘述了具体的可以自行百度,python的部分并未能全部实现故只介绍前期的准备流程及部分结果)

需要准备的工具:

Android Studio,adaconda

接下来让我们开始吧!

  1. 首先为了能在as中创建python文件我们需要先下载一个插件。在Plugins中搜索Python Community Edition插件下载,安装重启as后就可以在as中创建python文件了,因为Chaquopy没有与这个插件集成,所以.py文件中的代码会报错这是正常现象可以忽略,实际错误请以logcat为准
  2. 打开根目录下build.gradle文件引入chaquo模块
buildscript {
repositories {
xxx
maven { url 'https://jitpack.io' }
//引入chaquo模块
maven { url "https://chaquo.com/maven" }

}
dependencies {
classpath 'com.android.tools.build:gradle:4.1.3'
//如果该模块的版本引入不对会引起编译失败
//如果这里使用的版本是12.0.0及更早的版本会在模块启动时弹出吐司及通知栏显示许可证警告,并且一次只能运行五分钟
//想要删除限制需要在local.properties文件中引入chaquopy.license = free12.0.1及之后的版本则为开源的无需额外配置
classpath "com.chaquo.python:gradle:12.0.1"
}
}

local.properties文件中内容如下

#使用闭源 Chaquopy 版本(12.0.0 及更早版本)将在启动时显示许可证警告,并且一次只能运行 5 分钟。要删除这些限制,请将以下内容添加到您的项目.
#chaquopy.license=free
#如果使用闭源代码的Chapuopy版本来构建AAR,还需要增添如下标识将AAR内置到应用程序中
#chaquopy.applicationId=your.applicationId

3.接下来让我们打开app目录下的build.gradle文件加入以下引用

plugins {
//应用模块
id 'com.android.application'
id 'com.chaquo.python'
}
android {
ndk {
//引入python模块后不支持架构为armeabicpu类型
abiFilters 'armeabi-v7a', 'arm64-v8a', "x86", "x86_64"
// 还可以添加 'x86', 'x86_64', 'mips', 'mips64'
}

python {
//adaconda中的python编译器,目的引入虚拟环境让python文件在安卓应用中运行,buildPython中的路径需要替换为你自己的安装地址
buildPython "D:\\ana_2\\python.exe"
pip {
//指定库的镜像下载地址:阿里云,清华等
//options "--index-url", "https://mirrors.aliyun.com/pypi/simple/"
options "--extra-index-url", "https://pypi.tuna.tsinghua.edu.cn/simple/"
//install "opencv-python"
//下载的库,需要什么模块就自行下载下载什么模块,另有些模块不支持引入详情请参阅https://chaquo.com/chaquopy/doc/current/android.html#stdlib-unsupported
install "requests"
}
}
}

4.完成以上配置后就可以开始真正的旅程了

//初始化python模块的相关文件
void initPython() {
if (!Python.isStarted()) {
Python.start(new AndroidPlatform(this));
}
}

//调用python中的内容
void callPythonCode() {
Python py = Python.getInstance();
//getModule:py文件名,不用加.py的后缀;callAttr:方法名;如果方法有返回值那pyObject就是返回值
PyObject pyObject = py.getModule("SearchHeadImg").callAttr("sjs");
String a = String.valueOf(pyObject);
Log.e(".py返回值", a);
}

这样我们就可以在app中调用python的相关功能了!

这些内容虽说不多但也是我花了很长时间踩坑一步一步总结出来的,如果有问题或者缺失的内容欢迎大佬指正补充。

收起阅读 »

Android进阶宝典 -- 告别繁琐的AIDL吧,手写IPC通信框架,5行代码实现进程间通信(下)

接:Android进阶宝典 -- 告别繁琐的AIDL吧,手写IPC通信框架,5行代码实现进程间通信(上)2.3 内部通讯协议完善当客户端发起请求,想要执行某个方法的时候,首先服务端会先向Registery中查询注册的服务,从而找到这个要执行的方法,这个流程是在...
继续阅读 »

接:Android进阶宝典 -- 告别繁琐的AIDL吧,手写IPC通信框架,5行代码实现进程间通信(上)

2.3 内部通讯协议完善

当客户端发起请求,想要执行某个方法的时候,首先服务端会先向Registery中查询注册的服务,从而找到这个要执行的方法,这个流程是在内部完成。

override fun send(requestRequest?): Response? {
   //获取服务对象id
   val serviceId = request?.serviceId
   val methodName = request?.methodName
   val params = request?.params
   // 反序列化拿到具体的参数类型
   val neededParams = parseParameters(params)
   val method = Registry.instance.findMethod(serviceIdmethodNameneededParams)
   Log.e("TAG""method $method")
   Log.e("TAG""neededParams $neededParams")
   when (request?.type) {

       REQUEST_TYPE.GET_INSTANCE.ordinal -> {
           //==========执行静态方法
           try {
               var instanceAny? = null
               instance = if (neededParams == null || neededParams.isEmpty()) {
                   method?.invoke(null)
              } else {
                   method?.invoke(nullneededParams)
              }
               if (instance == null) {
                   return Response("instance == null"-101)
              }
               //存储实例对象
               Registry.instance.setServiceInstance(serviceId ?""instance)
               return Response(null200)
          } catch (eException) {
               return Response("${e.message}"-102)
          }
      }
       REQUEST_TYPE.INVOKE_METHOD.ordinal -> {
           //==============执行普通方法
           val instance = Registry.instance.getServiceInstance(serviceId)
           if (instance == null) {
               return Response("instance == null "-103)
          }
           //方法执行返回的结果
           return try {

               val result = if (neededParams == null || neededParams.isEmpty()) {
                   method?.invoke(instance)
              } else {
                   method?.invoke(instanceneededParams)
              }
               Response(gson.toJson(result), 200)
          } catch (eException) {
               Response("${e.message}"-104)
          }

      }
  }

   return null
}

当客户端发起请求时,会将请求的参数封装到Request中,在服务端接收到请求后,就会解析这些参数,变成Method执行时需要传入的参数。

private fun parseParameters(paramsArray<Parameters>?): Array<Any?>? {
   if (params == null || params.isEmpty()) {
       return null
  }
   val objects = arrayOfNulls<Any>(params.size)
   params.forEachIndexed { indexparameters ->
       objects[index=
           gson.fromJson(parameters.valueClass.forName(parameters.className))
  }
   return objects
}

例如用户中心调用setUserInfo方法时,需要传入一个User实体类,如下所示:

UserManager().setUserInfo(User("ming",25))

那么在调用这个方法的时候,首先会把这个实体类转成一个JSON字符串,例如:

{
  "name":"ming",
  "age":25
}

为啥要”多此一举“呢?其实这种处理方式是最快速直接的,转成json字符串之后,能够最大限度地降低数据传输的大小,等到服务端处理这个方法的时候,再把Request中的params反json转成User对象即可。

fun findMethod(serviceIdString?methodNameString?neededParamsArray<Any?>?): Method? {
   //获取服务
   val serviceClazz = serviceMaps[serviceId?return null
   //获取方法集合
   val methods = methodsMap[serviceClazz?return null
   return methods[rebuildParamsFunc(methodNameneededParams)]
}

private fun rebuildParamsFunc(methodNameString?paramsArray<Any?>?): String {

   val stringBuffer = StringBuffer()
   stringBuffer.append(methodName).append("(")

   if (params == null || params.isEmpty()) {
       stringBuffer.append(")")
       return stringBuffer.toString()
  }
   stringBuffer.append(params[0]?.javaClass?.name)
   for (index in 1 until params.size) {
       stringBuffer.append(",").append(params[index]?.javaClass?.name)
  }
   stringBuffer.append(")")
   return stringBuffer.toString()
}

那么在查找注册方法的时候就简单多了,直接抽丝剥茧一层一层取到最终的Method。在拿到Method之后,这里是有2种处理方式,一种是通过静态单例的形式拿到实例对象,并保存在服务端;另一种就是执行普通方法,因为在反射的时候需要拿到类的实例对象才能调用,所以才在GET_INSTANCE的时候存一遍

3 客户端 - connect

在第二节中,我们已经完成了通讯协议的建设,最终一步就是客户端通过绑定服务,向服务端发起通信了。

3.1 bindService

/**
* 绑定服务
*
*/
fun connect(
   contextContext,
   pkgNameString,
   actionString = "",
   serviceClass<out IPCService>
) {
   val intent = Intent()
   if (pkgName.isEmpty()) {
       //同app内的不同进程
       intent.setClass(contextservice)
  } else {
       //不同APP之间进行通信
       intent.setPackage(pkgName)
       intent.setAction(action)
  }
   //绑定服务
   context.bindService(intentIpcServiceConnection(service), Context.BIND_AUTO_CREATE)
}

inner class IpcServiceConnection(val simpleServiceClass<out IPCService>) : ServiceConnection {

   override fun onServiceConnected(nameComponentName?serviceIBinder?) {
       val mService = IIPCServiceInterface.Stub.asInterface(serviceas IIPCServiceInterface
       binders[simpleService= mService
  }

   override fun onServiceDisconnected(nameComponentName?) {
       //断连之后,直接移除即可
       binders.remove(simpleService)
  }
}

对于绑定服务这块,相信伙伴们也很熟悉了,这个需要说一点的就是,在Android 5.0以后,启动服务不能只依赖action启动,还需要指定应用包名,否则就会报错。

在服务连接成功之后,即回调onServiceConnected方法的时候,需要拿到服务端的一个代理对象,即IIPCServiceInterface的实例对象,然后存储在binders集合中,key为绑定的服务类class对象,value就是对应的服务端的代理对象。

fun send(
   typeInt,
   serviceClass<out IPCService>,
   serviceIdString,
   methodNameString,
   paramsArray<Parameters>
): Response? {
   //创建请求
   val request = Request(typeserviceIdmethodNameparams)
   //发起请求
   return try {
       binders[service]?.send(request)
  } catch (eException) {
       null
  }
}

当拿到服务端的代理对象之后,就可以在客户端调用send方法向服务端发送消息。

class Channel {

   //====================================
   /**每个服务对应的Binder对象*/
   private val bindersConcurrentHashMap<Class<out IPCService>IIPCServiceInterface> by lazy {
       ConcurrentHashMap()
  }

   //====================================

   /**
    * 绑定服务
    *
    */
   fun connect(
       contextContext,
       pkgNameString,
       actionString = "",
       serviceClass<out IPCService>
  ) {
       val intent = Intent()
       if (pkgName.isEmpty()) {
           intent.setClass(contextservice)
      } else {
           intent.setPackage(pkgName)
           intent.setAction(action)
           intent.setClass(contextservice)
      }
       //绑定服务
       context.bindService(intentIpcServiceConnection(service), Context.BIND_AUTO_CREATE)
  }

   inner class IpcServiceConnection(val simpleServiceClass<out IPCService>) : ServiceConnection {

       override fun onServiceConnected(nameComponentName?serviceIBinder?) {
           val mService = IIPCServiceInterface.Stub.asInterface(serviceas IIPCServiceInterface
           binders[simpleService= mService
      }

       override fun onServiceDisconnected(nameComponentName?) {
           //断连之后,直接移除即可
           binders.remove(simpleService)
      }
  }


   fun send(
       typeInt,
       serviceClass<out IPCService>,
       serviceIdString,
       methodNameString,
       paramsArray<Parameters>
  ): Response? {
       //创建请求
       val request = Request(typeserviceIdmethodNameparams)
       //发起请求
       return try {
           binders[service]?.send(request)
      } catch (eException) {
           null
      }
  }


   companion object {
       private val instance by lazy {
           Channel()
      }

       /**
        * 获取单例对象
        */
       fun getDefault(): Channel {
           return instance
      }
  }
}

3.2 动态代理获取接口实例

回到1.2小节中,我们定义了一个IUserManager接口,通过前面我们定义的通信协议,只要我们获取了IUserManager的实例对象,那么就能够调用其中的任意普通方法,所以在客户端需要设置一个获取接口实例对象的方法。

fun <T> getInstanceWithName(
   serviceClass<out IPCService>,
   classTypeClass<T>,
   clazzClass<*>,
   methodNameString,
   paramsArray<Parameters>
): T? {
   
   //获取serviceId
   val serviceId = clazz.getAnnotation(ServiceId::class.java)

   val response = Channel.getDefault()
      .send(REQUEST.GET_INSTANCE.ordinalserviceserviceId.namemethodNameparams)
   Log.e("TAG""response $response")
   if (response != null && response.result) {
       //请求成功,返回接口实例对象
       return Proxy.newProxyInstance(
           classType.classLoader,
           arrayOf(classType),
           IPCInvocationHandler()
      ) as T
  }

   return null
}

当我们通过客户端发送一个获取单例的请求后,如果成功了,那么就直接返回这个接口的单例对象,这里直接使用动态代理的方式返回一个接口实例对象,那么后续执行这个接口的方法时,会直接走到IPCInvocationHandler的invoke方法中。

class IPCInvocationHandler(
   val serviceClass<out IPCService>,
   val serviceIdString?
) : InvocationHandler {

   private val gson = Gson()

   override fun invoke(proxyAny?methodMethod?argsArray<out Any>?): Any? {

       //执行客户端发送方法请求
       val response = Channel.getDefault()
          .send(
               REQUEST.INVOKE_METHOD.ordinal,
               service,
               serviceId,
               method?.name ?"",
               args
          )
       //拿到服务端返回的结果
       if (response != null && response.result) {
           //反序列化得到结果
           return gson.fromJson(response.valuemethod?.returnType)
      }


       return null
  }

}

因为服务端在拿到Method的返回结果时,将javabean转换为了json字符串,因此在IPCInvocationHandler中,当调用接口中方法获取结果之后,用Gson将json转换为javabean对象,那么就直接获取到了结果。

3.3 框架使用

服务端:

UserManager2.getDefault().setUserInfo(User("ming"25))
IPC.register(UserManager2::class.java)

同时在服务端需要注册一个IPCService的实例,这里用的是IPCService01

<service
   android:name=".UserService"
   android:enabled="true"
   android:exported="true" />
<service
   android:name="com.lay.ipc.service.IPCService01"
   android:enabled="true"
   android:exported="true">
   <intent-filter>
       <action android:name="android.intent.action.GET_USER_INFO" />
   </intent-filter>
</service>

客户端:

调用connect方法,需要绑定服务端的服务,传入包名和action

IPC.connect(
   this,
   "com.lay.learn.asm",
   "android.intent.action.GET_USER_INFO",
   IPCService01::class.java
)

首先获取IUserManager的实例,注意这里要和服务端注册的UserManager2是同一个ServiceId,而且接口、javabean需要存放在与服务端一样的文件夹下

val userManager = IPC.getInstanceWithName(
   IPCService01::class.java,
   IUserManager::class.java,
   "getDefault",
   null
)
val info = userManager?.getUserInfo()

通过动态代理拿到接口的实例对象,只要调用接口中的方法,就会进入到InvocationHandler中的invoke方法,在这个方法中,通过查找服务端注册的方法名从而找到对应的Method,通过反射调用拿到UserManager中的方法返回值。

这样其实就通过5-6行代码,就完成了进程间通信,是不是比我们在使用AIDL的时候要方便地许多。

4 总结

如果我们面对下面这个类,如果这个类是个私有类,外部没法调用,想通过反射的方式调用其中某个方法。

@ServiceId(name = "UserManagerService")
public class UserManager2 implements IUserManager {

   private static UserManager2 userManager2 = new UserManager2();

   public static UserManager2 getDefault() {
       return userManager2;
  }

   private User user;

   @Nullable
   @Override
   public User getUserInfo() {
       return user;
  }

   @Override
   public void setUserInfo(@NonNull User user) {
       this.user = user;
  }

   @Override
   public int getUserId() {
       return 0;
  }

   @Override
   public void setUserId(int id) {

  }
}

那么我们可以这样做:

val method = UserManager2::class.java.getDeclaredMethod("getUserInfo")
method.isAccessible = true
method.invoke(this,params)

其实这个框架的原理就是上面这几行代码所能够完成的事;通过服务端注册的形式,将UserManager2中所有的方法Method收集起来;当另一个进程,也就是客户端想要调用其中某个方法的时候,通过方法名来获取到对应的Method,调用这个方法得到最终的返回值

作者:layz4android
来源:juejin.cn/post/7192465342159912997

收起阅读 »

Android进阶宝典 -- 告别繁琐的AIDL吧,手写IPC通信框架,5行代码实现进程间通信(上)

如果在Android中想要实现进程间通信,有哪些方式呢?(2)Socket通信:这种属于Linux层面的进程间通信了,除此之外,还包括管道、信号量等,像传统的IPC进程间通信需要数据二次拷贝,这种效率是最低的;那么本篇文章并不是说完全丢弃掉AIDL,它依然不失...
继续阅读 »

对于进程间通信,很多项目中可能根本没有涉及到多进程,很多公司的app可能就一个主进程,但是对于进程间通信,我们也是必须要了解的。

如果在Android中想要实现进程间通信,有哪些方式呢?

(1)发广播(sendBroadcast):e.g. 两个app之间需要通信,那么可以通过发送广播的形式进行通信,如果只想单点通信,可以指定包名。但是这种方式存在的弊端在于发送方无法判断接收方是否接收到了广播,类似于UDP的通信形式,而且存在丢数据的形式;

(2)Socket通信:这种属于Linux层面的进程间通信了,除此之外,还包括管道、信号量等,像传统的IPC进程间通信需要数据二次拷贝,这种效率是最低的;

(3)AIDL通信:这种算是Android当中主流的进程间通信方案,通过Service + Binder的形式进行通信,具备实时性而且能够通过回调得知接收方是否收到数据,弊端在于需要管理维护aidl接口,如果不同业务方需要使用不同的aidl接口,维护的成本会越来越高。

那么本篇文章并不是说完全丢弃掉AIDL,它依然不失为一个很好的进程间通信的手段,只是我会封装一个适用于任意业务场景的IPC进程间通讯框架,这个也是我在自己的项目中使用到的,不需要维护很多的AIDL接口文件。

有需要源码的伙伴,可以去我的github首页获取 FastIPC源码地址分支:feature/v0.0.1-snapshot有帮助的话麻烦给点个star⭐️⭐️⭐️

1 服务端 - register

首先这里先说明一下,就是对于传统的AIDL使用方式,这里就不再过多介绍了,这部分还是比较简单的,有兴趣的伙伴们可以去前面的文章中查看,本文将着重介绍框架层面的逻辑。

那么IPC进程间通信,需要两个端:客户端和服务端。服务端会提供一个注册方法,例如客户端定义的一些服务,通过向服务端注册来做一个备份,当客户端调用服务端某个方法的时候来返回值。

object IPC {

   //==========================================

   /**
    * 服务端暴露的接口,用于注册服务使用
    */
   fun register(serviceClass<*>) {
       Registry.instance.register(service)
  }

}

其实在注册的时候,我们的目的肯定是能够方便地拿到某个服务,并且能够调用这个服务提供的方法,拿到我想要的值;所以在定义服务的时候,需要注意以下两点:

(1)需要定义一个与当前服务一一对应的serviceId,通过serviceId来获取服务的实例;

(2)每个服务当中定义的方法同样需要对应起来,以便拿到服务对象之后,通过反射调用其中的方法。

所以在注册的时候,需要从这两点入手。

1.1 定义服务唯一标识serviceId

@Target(AnnotationTarget.TYPE)
@Retention(AnnotationRetention.RUNTIME)
annotation class ServiceId(
   val nameString
)

一般来说,如果涉及到反射,最常用的就是通过注解给Class做标记,因为通过反射能够拿到类上标记的注解,就能够拿到对应的serviceId。

class Registry {

   //=======================================
   /**用于存储 serviceId 对应的服务 class对象*/
   private val serviceMapsConcurrentHashMap<StringClass<*>> by lazy {
       ConcurrentHashMap()
  }

   /**用于存储 服务中全部的方法*/
   private val methodsMapConcurrentHashMap<Class<*>ConcurrentHashMap<StringMethod>> by lazy {
       ConcurrentHashMap()
  }


   //=======================================

   /**
    * 服务端注册方法
    * @param service 服务class对象
    */
   fun register(serviceClass<*>) {

       // 获取serviceId与服务一一对应
       val serviceIdAnnotation = service.getAnnotation(ServiceId::class.java)
           ?throw IllegalArgumentException("只有标记@ServiceId的服务才能够被注册")
       //获取serviceId
       val name = serviceIdAnnotation.name
       serviceMaps[name= service
       //temp array
       val methodsConcurrentHashMap<StringMethod> = ConcurrentHashMap()
       // 获取服务当中的全部方法
       for (method in service.declaredMethods) {

           //这里需要注意,因为方法中存在重载方法,所以不能把方法名当做key,需要加上参数
           val buffer = StringBuffer()
           buffer.append(method.name).append("(")
           val params = method.parameterTypes
           if (params.size > 0) {
               buffer.append(params[0].name)
          }
           for (index in 1 until params.size) {
               buffer.append(",").append(params[index].name)
          }
           buffer.append(")")
           //保存
           methods[buffer.toString()] = method
      }
       //存入方法表
       methodsMap[service= methods
  }

   companion object {
       val instance by lazy { Registry() }
  }
}

通过上面的register方法,当传入定义的服务class对象的时候,首先获取到服务上标记的@ServiceId注解,注意这里如果要注册必须标记,否则直接抛异常;拿到serviceId之后,存入到serviceMaps中。

然后需要获取服务中的全部方法,因为考虑到重载方法的存在,所以不能单单以方法名作为key,而是需要把参数也加上,因此这里做了一个逻辑就是将方法名与参数名组合一个key,存入到方法表中。

这样注册任务就完成了,其实还是比较简单的,关键在于完成2个表:服务表和方法表的初始化以及数据存储功能

1.2 使用方式

@ServiceId("UserManagerService")
interface IUserManager {

   fun getUserInfo()User?
   fun setUserInfo(userUser)
   fun getUserId()Int
   fun setUserId(idInt)
}

假设项目中有一个用户信息管理的服务,这个服务用于给所有的App提供用户信息查询。

@ServiceId("UserManagerService")
class UserManager : IUserManager {

   private var userUser? = null
   private var userIdInt = 0

   override fun getUserInfo(): User? {
       return user
  }

   override fun setUserInfo(userUser) {
       this.user = user
  }

   override fun getUserId()Int {
       return userId
  }

   override fun setUserId(idInt) {
       this.userId = id
  }

}

用户中心可以注册这个服务,并且调用setUserInfo方法保存用户信息,那么其他App(客户端)连接这个服务之后,就可以调用getUserInfo这个方法,获取用户信息,从而完成进程间通信。

2023-01-23 22:15:54.729 13361-13361/com.lay.learn.asm E/TAG: entrySet key class com.lay.learn.asm.binder.UserManager
2023-01-23 22:15:54.729 13361-13361/com.lay.learn.asm E/TAG: mapValue key setUserInfo(com.lay.learn.asm.binder.User)
2023-01-23 22:15:54.729 13361-13361/com.lay.learn.asm E/TAG: mapValue value public void com.lay.learn.asm.binder.UserManager.setUserInfo(com.lay.learn.asm.binder.User)
2023-01-23 22:15:54.729 13361-13361/com.lay.learn.asm E/TAG: mapValue key getUserInfo()
2023-01-23 22:15:54.729 13361-13361/com.lay.learn.asm E/TAG: mapValue value public com.lay.learn.asm.binder.User com.lay.learn.asm.binder.UserManager.getUserInfo()
2023-01-23 22:15:54.729 13361-13361/com.lay.learn.asm E/TAG: mapValue key getUserId()
2023-01-23 22:15:54.729 13361-13361/com.lay.learn.asm E/TAG: mapValue value public int com.lay.learn.asm.binder.UserManager.getUserId()
2023-01-23 22:15:54.729 13361-13361/com.lay.learn.asm E/TAG: mapValue key setUserId(int)
2023-01-23 22:15:54.729 13361-13361/com.lay.learn.asm E/TAG: mapValue value public void com.lay.learn.asm.binder.UserManager.setUserId(int)

我们看调用register方法之后,每个方法的key值都是跟参数绑定在一起,这样服务端注册就完成了。

2 客户端与服务端的通信协议

对于客户端的连接,其实就是绑定服务,那么这里就会使用到AIDL通信,但是跟传统的相比,我们是将AIDL封装到框架层内部,对于用户来说是无感知的。

2.1 创建IPCService

这个服务就是用来完成进程间通信的,客户端需要与这个服务建立连接,通过服务端分发消息,或者接收客户端发送来的消息。

abstract class IPCService : Service() {
   override fun onBind(intentIntent?)IBinder? {
       return null
  }
}

这里我定义了一个抽象的Service基类,为啥要这么做,前面我们提到过是因为整个项目中不可能只有一个服务,因为业务众多,为了保证单一职责,需要划分不同的类型,所以在框架中会衍生多个实现类,不同业务方可以注册这些服务,当然也可以自定义服务继承IPCService。

class IPCService01 : IPCService() {
}

在IPCService的onBind需要返回一个Binder对象,因此需要创建aidl文件。

2.2 定义通讯协议

像我们在请求接口的时候,通常也是向服务端发起一个请求(Request),然后得到服务端的一个响应(Response),因此在IPC通信的的时候,也可以根据这种方式建立通信协议。

data class Request(
   val type: Int,
   val serviceId: String?,
   val methodName: String?,
   val params: Array<Parameters>?
) : Parcelable {
   //=====================================
   /**请求类型*/
   //获取实例的对象
   val GET_INSTANCE = "getInstance"
   //执行方法
   val INVOKE_METHOD = "invokeMethod"
   
   //=======================================

   constructor(parcel: Parcel) : this(
       parcel.readInt(),
       parcel.readString(),
       parcel.readString(),
       parcel.createTypedArray(Parameters.CREATOR)
  )

   override fun writeToParcel(parcel: Parcel, flags: Int) {
       parcel.writeInt(type)
       parcel.writeString(serviceId)
       parcel.writeString(methodName)
  }

   override fun describeContents(): Int {
       return 0
  }

   override fun equals(other: Any?): Boolean {
       if (this === other) return true
       if (javaClass != other?.javaClass) return false

       other as Request

       if (type != other.type) return false
       if (serviceId != other.serviceId) return false
       if (methodName != other.methodName) return false
       if (params != null) {
           if (other.params == null) return false
           if (!params.contentEquals(other.params)) return false
      } else if (other.params != null) return false

       return true
  }

   override fun hashCode(): Int {
       var result = type
       result = 31 * result + (serviceId?.hashCode() ?: 0)
       result = 31 * result + (methodName?.hashCode() ?: 0)
       result = 31 * result + (params?.contentHashCode() ?: 0)
       return result
  }

   companion object CREATOR : Parcelable.Creator<Request> {
       override fun createFromParcel(parcel: Parcel): Request {
           return Request(parcel)
      }

       override fun newArray(size: Int): Array<Request?> {
           return arrayOfNulls(size)
      }
  }

}

对于客户端来说,致力于发起请求,请求实体类Request参数介绍如下:

type表示请求的类型,包括两种分别是:执行静态方法和执行普通方法(考虑到反射传参);

serviceId表示请求的服务id,要请求哪个服务,便可以获取到这个服务的实例对象,调用服务中提供的方法;

methodName表示要请求的方法名,也是在serviceId服务中定义的方法;

params表示请求的方法参数集合,我们在服务端注册的时候,方法名 + 参数名 作为key,因此需要知道请求的方法参数,以便获取到Method对象。

data class Response(
   val value:String?,
   val result:Boolean
):Parcelable {
   @SuppressLint("NewApi")
   constructor(parcelParcel) : this(
       parcel.readString(),
       parcel.readBoolean()
  )

   override fun writeToParcel(parcelParcelflagsInt) {
       parcel.writeString(value)
       parcel.writeByte(if (result1 else 0)
  }

   override fun describeContents()Int {
       return 0
  }

   companion object CREATOR : Parcelable.Creator<Response> {
       override fun createFromParcel(parcelParcel)Response {
           return Response(parcel)
      }

       override fun newArray(sizeInt)Array<Response?> {
           return arrayOfNulls(size)
      }
  }
}

对于服务端来说,在接收到请求之后,需要针对具体的请求返回相应的结果,Response实体类参数介绍:

result表示请求成功或者失败;

value表示服务端返回的结果,是一个json字符串。

因此定义aidl接口文件如下,输入一个请求之后,返回一个服务端的响应。

interface IIPCServiceInterface {
   Response send(in Request request);
}

这样IPCService就可以将aidl生成的Stub类作为Binder对象返回。

abstract class IPCService : Service() {
   
   override fun onBind(intentIntent?)IBinder? {
       return BINDERS
  }

   companion object BINDERS : IIPCServiceInterface.Stub() {
       override fun send(requestRequest?)Response? {

           when(request?.type){
               
               REQUEST.GET_INSTANCE.ordinal->{
                   
              }
               REQUEST.INVOKE_METHOD.ordinal->{
                   
              }
          }
           
           return null
      }
  }
}

续:Android进阶宝典 -- 告别繁琐的AIDL吧,手写IPC通信框架,5行代码实现进程间通信(下)

作者:layz4android

来源:juejin.cn/post/7192465342159912997

收起阅读 »

高仿B站自定义表情

在之前的文章给你的 Android App 添加自定义表情中我们介绍了自定义表情的原理,没看过的建议看一下。这一篇文章将介绍它的应用,这里以B站的自定义表情面板为例,效果如下:自定义表情的大小当我们写死表情的大小时,文字的 textSize 变大变小时都会有一...
继续阅读 »

在之前的文章给你的 Android App 添加自定义表情中我们介绍了自定义表情的原理,没看过的建议看一下。这一篇文章将介绍它的应用,这里以B站的自定义表情面板为例,效果如下:


自定义表情的大小

当我们写死表情的大小时,文字的 textSize 变大变小时都会有一点问题。

文字大于图片大小时,在多行的情况下,只有表情的行间距明显小于其他行的间距。如图:


为什么会出现这种情况呢?如下图所示,我在top, ascent, baseline, descent, bottom的位置标注了辅助线。


可以很清晰的看到,在只有表情的情况下,top, ascent, descent, bottom的位置有明显的问题。原因是 DynamicDrawableSpangetSize 方法里面对 FontMetricsInt 进行了修改。解决的方式很简单,就是注释掉修改代码就行,代码如下。修改后,效果如下图所示。

@Override
   public int getSize(@NonNull Paint paint, CharSequence text, int start, int end, @Nullable FontMetricsInt fm) {
         Drawable d = getDrawable();
         Rect rect = d.getBounds();
//
//       if (fm != null) {
//           fm.ascent = -rect.bottom;
//           fm.descent = 0;
//
//           fm.top = fm.ascent;
//           fm.bottom = 0;
//       }

       return rect.right;
  }


不知道你还记不记得,我们说过getSize 的返回值是表情的宽度。上面的注释代码其实是设置了表情的高度,如果文本的大小少于表情时,就会显示不全,如下图所示:


那这种情况下,应该怎么办?这里不卖关子了,最终代码如下。解决方式非常简单就是分情况来判断。当文本的高度小于表情的高度时,设置 fmtop, ascent, descent, bottom的值,让行的高度变大的同时让大的 emoji 图片居中。

 @Override
  public int getSize(@NonNull Paint paint, CharSequence text, int start, int end, @Nullable FontMetricsInt fm) {
      Drawable d = getDrawable();
      Rect rect = d.getBounds();

      float drawableHeight = rect.height();
      Paint.FontMetrics paintFm = paint.getFontMetrics();

      if (fm != null) {
          int textHeight = fm.bottom - fm.top;
          if(textHeight <= drawableHeight) {//当文本的高度小于表情的高度时
              //解决文字的大小小于图片大小的情况
              float textCenter = (paintFm.descent + paintFm.ascent) / 2;
              fm.ascent = fm.top = (int) (textCenter - drawableHeight / 2);
              fm.descent = fm.bottom = (int) (textCenter + drawableHeight / 2);
          }
      }
  return rect.right;
}

当然,你可能发现了,B站的 emoji 表情好像不是居中的。如下图所示,B站对 emoji 表情的处理类似基于 baseline 对齐。


上面最难理解的居中已经介绍,对于其他方式比如 baseline 和 bottom 就简单了。完整代码如下:

@Override
  public int getSize(@NonNull Paint paint, CharSequence text, int start, int end, @Nullable FontMetricsInt fm) {
      Drawable d = getDrawable();
      if(d == null) {
          return 48;
      }
      Rect rect = d.getBounds();

      float drawableHeight = rect.height();
      Paint.FontMetrics paintFm = paint.getFontMetrics();

      if (fm != null) {
          if (mVerticalAlignment == ALIGN_BASELINE) {
              fm.ascent = fm.top = (int) (paintFm.bottom - drawableHeight);
              fm.bottom = (int) (paintFm.bottom);
              fm.descent = (int) paintFm.descent;
          } else if(mVerticalAlignment == ALIGN_BOTTOM) {
              fm.ascent = fm.top = (int) (paintFm.bottom - drawableHeight);
              fm.bottom = (int) (paintFm.bottom);
              fm.descent = (int) paintFm.descent;
          } else if (mVerticalAlignment == ALIGN_CENTER) {
              int textHeight = fm.bottom - fm.top;
              if(textHeight <= rect.height()) {
                  float textCenter = (paintFm.descent + paintFm.ascent) / 2;
                  fm.ascent = fm.top = (int) (textCenter - drawableHeight / 2);
                  fm.descent = fm.bottom = (int) (textCenter + drawableHeight / 2);
              }
          }
      }

      return rect.right;
  }

动态表情

动态表情实际上就是 gif 图。我们可以使用 android-gif-drawable 来实现。在 build.gradle 中增加依赖:

dependencies {
  ...
  implementation 'pl.droidsonroids.gif:android-gif-drawable:1.2.25'
}

然后在我们创建自定义 ImageSpan 的时候传入参数就可以了:

val size = 192
val gifFromResource = GifDrawable(getResources(), gifData.drawableResource)
gifFromResource.stop()
gifFromResource.setBounds(0,0, size, size)
val content = mBinding.editContent.text as SpannableStringBuilder
val stringBuilder = SpannableStringBuilder(gifData.text)
stringBuilder.setSpan(BilibiliEmojiSpan(gifFromResource, ALIGN_BASELINE),
  0, stringBuilder.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)

关于 android-gif-drawable 更具体用法可以看 Android加载Gif动画android-gif-drawable的使用

作者:小墙程序员

来源:juejin.cn/post/7196592276159823931

收起阅读 »

首页弹框太多?Flow帮你“链”起来

很多App一打开,首页都会有各种各样的交互,比如权限授权,版本更新,阅读协议,活动介绍,用户权限变更等,这些交互大多数都是以弹框为主,也会有少数几个是以页面或者别的形式出现,但是无论是弹框还是页面,这些只是表现形式,这种交互难点在于如何去判断它们什么时候出来它...
继续阅读 »

很多App一打开,首页都会有各种各样的交互,比如权限授权,版本更新,阅读协议,活动介绍,用户权限变更等,这些交互大多数都是以弹框为主,也会有少数几个是以页面或者别的形式出现,但是无论是弹框还是页面,这些只是表现形式,这种交互难点在于

  1. 如何去判断它们什么时候出来

  2. 它们出来的先后次序是什么

  3. 中途需求如果增加或者删除一个弹框或者页面,我们应该改动哪些逻辑

常见的做法

可能这种需求刚开始由于弹框少,交互还简单,所以大多数的做法就是直接在首页用if-else去完成了

if(条件1){
  //弹框1
}else if(条件2){
   //弹框2
}

但是当需求慢慢迭代下去,首页弹框越来越多,判断的逻辑也越来越复杂,判断条件之间还存在依赖关系的时候,我们的代码就变得很可怕了

if(条件1 && 条件2 && 条件3){
  //弹框1
}else if(条件1 && (条件2 || 条件3)){
   //弹框2
}else if(条件2 && 条件3){
   //弹框3
}else if(....){
  ....
}

这种情况下,这些代码就变的越来越难维护,长久下来,造成的问题也越来越多,比如

  1. 代码可读性变差,不是熟悉业务的人无法去理解这些逻辑代码

  2. 增加或者减少弹框或者条件需要更改中间的逻辑,容易产生bug

  3. 每个分支的弹框结束后,需要重新从第一个if再执行一遍判断下一个弹框是哪一个,如果条件里面牵扯到io操作,也会产生一定的性能问题

设计思路

能否让每个弹框作为一个单独的任务,生成一条任务链,链上的节点为单个任务,节点维护任务执行的条件以及任务本身逻辑,节点之间无任何依赖关系,具体执行由任务链去管理,这样的话如果增加或者删除某一个任务,我们只需要插拔任务节点就可以


定义任务

首先我们先简单定一个任务,以及需要执行的操作

interface SingleJob {
   fun handle(): Boolean
   fun launch(context: Context, callback: () -> Unit)
}
  • handle():判断任务是否应该执行的条件

  • launch():执行任务,并在任务结束后通过callback通知任务链执行下一条任务

实现任务

定义一个TaskJobOne,让它去实现SingleJob

class TaskJobOne : SingleJob {
   override fun handle(): Boolean {
       println("start handle job one")
       return true
  }
   override fun launch(context: Context, callback: () -> Unit) {
       println("start launch job one")
       AlertDialog.Builder(context).setMessage("这是第一个弹框")
          .setPositiveButton("ok") {x,y->
               callback()
          }.show()
  }
}

这个任务里面,我们先默认handle的执行条件是true,一定会执行,实际开发过程中可以根据需要来定义条件,比如判断登录态等,lanuch里面我们简单的弹一个框,然后在弹框的关闭的时候,callback给任务链,为了调试的时候看的清楚一些,在这两个函数入口分别打印了日志,同样的任务我们再创建一个TaskJobTwo,TaskJobThree,具体实现差不多,就不贴代码了

任务链

首先思考下如何存放任务,由于任务之间需要体现出优先级关系,所以这里决定使用一个LinkedHashMap,K表示优先级,V表示任务

object JobTaskManager {
   val jobMap = linkedMapOf(
       1 to TaskJobOne(),
       2 to TaskJobTwo(),
       3 to TaskJobThree()
  )
}

接着就是思考如何设计整条任务链的执行任务,因为这个是对jobMap里面的任务逐个拿出来执行的过程,所以我们很容易就想到可以用Flow去控制这些任务,但是有两个问题需要去考虑下

  1. 如果直接将jobMap转成Flow去执行,那么出现的问题就是所有任务全部都一次性执行完,显然不符合设计初衷

  2. 我们都知道Flow是由上游发送数据,下游接收并处理数据的一个自上而下的过程,但是这里我们需要一个job执行完以后,通过callback通知任务链去执行下一个任务,任务的发送是由前一个任务控制的,所以必须设计出一个环形的过程

首先我们需要一个变量去保存当前需要执行的任务优先级,我们定义它为curLevel,并设置初始值为1,表示第一个执行的是优先级为1的任务

var curLevel = 1

这个变量将会在任务执行完以后,通过callback回调以后再自增,至于自增之后如何再去执行下一条任务,这个通知的事情我们交给StateFlow

val stateFlow = MutableStateFlow(curLevel)
fun doJob(context: Context, job: SingleJob) {
   if (job.handle()) {
       job.launch(context) {
           curLevel++
           if (curLevel <= jobMap.size)
               stateFlow.value = curLevel
      }
  } else {
       curLevel++
       if (curLevel <= jobMap.size)
           stateFlow.value = curLevel
  }
}

stateFlow初始值是curlevel,当上层开始订阅的时候,不给stateFlow设置value,那么stateFlow初始值1就会发送出去,开始执行优先级为1的任务,在doJob里面,当任务的执行条件不满足或者任务已经执行完成,就自增curLevel,再给stateFlow赋值,从而执行下一个任务,这样一个环形过程就有了,下面是在上层如何执行任务链

MainScope().launch {
   JobTaskManager.apply {
       stateFlow.collect {
           flow {
               emit(jobMap[it])
          }.collect {
               doJob(this@MainActivity, it!!)
          }
      }
  }
}

我们的任务链就完成了,看下效果


通过日志我们可以看到,的确是每次关闭一个弹框,才开始执行下一条任务,这样一来,如果某个任务的条件不满足,或者不想让它执行了,只需要改变对应job的handle条件就可以,比如现在把TaskJobOne的handel设置为false,在看下效果

class TaskJobOne : SingleJob {
   override fun handle(): Boolean {
       println("start handle job one")
       return false
  }
   override fun launch(context: Context, callback: () -> Unit) {
       println("start launch job one")
       AlertDialog.Builder(context).setMessage("这是第一个弹框")
          .setPositiveButton("ok") {x,y->
               callback()
          }.show()
  }
}


可以看到经过第一个task的时候,由于已经把handle条件设置成false了,所以直接跳过,执行下一个任务了

依赖于外界因素

上面只是简单的模拟了一个任务链的工作流程,实际开发过程中,我们有的任务会依赖于其他因素,最常见的就是必须等到某个接口返回数据以后才去执行,所以这个时候,执行你的任务需要判断的东西就更多了

  • 是否优先级已经轮到它了

  • 是否依赖于某个接口

  • 这个接口是否已经成功返回数据了

  • 接口数据是否需要传递给这个任务 鉴于这些,我们就要重新设计我们的任务与任务链,首先要定义几个状态值,分别代表任务的不同状态

const val JOB_NOT_AVAILABLE = 100
const val JOB_AVAILABLE = 101
const val JOB_COMBINED_BY_NOTHING = 102
const val JOB_CANCELED = 103
  • JOB_NOT_AVAILABLE:该任务还没有达到执行条件

  • JOB_AVAILABLE:该任务达到了执行任务的条件

  • JOB_COMBINED_BY_NOTHING:该任务不关联任务条件,可直接执行

  • JOB_CANCELED:该任务不能执行

接着需要去扩展一下SingleJob的功能,让它可以设置状态,获取状态,并且可以传入数据

interface SingleJob {
  ......
   /**
    * 获取执行状态
    */
   fun status():Int

   /**
    * 设置执行状态
    */
   fun setStatus(level:Int)

   /**
    * 设置数据
    */
   fun setBundle(bundle: Bundle)
}

更改一下任务的实现

class TaskJobOne : SingleJob {
   var flag = JOB_NOT_AVAILABLE
   var data: Bundle? = null
   override fun handle(): Boolean {
       println("start handle job one")
       return  flag != JOB_CANCELED
  }
   override fun launch(context: Context, callback: () -> Unit) {
       println("start launch job one")
       val type = data?.getString("dialog_type")
       AlertDialog.Builder(context).setMessage(if(type != null)"这是第一个${type}弹框" else "这是第一个弹框")
          .setPositiveButton("ok") {x,y->
               callback()
          }.show()
  }
   override fun setStatus(level: Int) {
       if(flag != JOB_COMBINED_BY_NOTHING)
           this.flag = level
  }
   override fun status(): Int = flag

   override fun setBundle(bundle: Bundle) {
       this.data = bundle
  }
}

现在的任务执行条件已经变了,变成了状态不是JOB_CANCELED的任务才可以执行,增加了一个变量flag表示这个任务的当前状态,如果是JOB_COMBINED_BY_NOTHING表示不依赖外界因素,外界也不能改变它的状态,其余状态则通过setStatus函数来改变,增加了setBundle函数允许外界向任务传入数据,并且在launch函数里面接收数据并展示在弹框上,我们在任务链里面增加一个函数,用来给对应优先级的任务设置状态与数据

fun setTaskFlag(level: Int, flag: Int, bundle: Bundle = Bundle()) {
       if (level > jobMap.size) {
           return
      }
       jobMap[level]?.apply {
           setStatus(flag)
           setBundle(bundle)
      }
  }

我们现在可以把任务链同接口一起关联起来了,首先我们先创建个viewmodel,在里面创建三个flow,分别模拟三个不同接口,并且在flow里面向下游发送数据

class MainViewModel : ViewModel(){
   val firstApi = flow {
       kotlinx.coroutines.delay(1000)
       emit("元宵节活动")
  }
   val secondApi = flow {
       kotlinx.coroutines.delay(2000)
       emit("端午节活动")
  }
   val thirdApi = flow {
       kotlinx.coroutines.delay(3000)
       emit("中秋节活动")
  }
}

接着我们如果想要去执行任务链,就必须等到所有接口执行完毕才可以,刚好flow里面的zip操作符就可以满足这一点,它可以让异步任务同步执行,等到都执行完任务之后,才将数据传递给下游,代码实现如下

val mainViewModel: MainViewModel by lazy {
   ViewModelProvider(this)[MainViewModel::class.java]
}

MainScope().launch {
   JobTaskManager.apply {
       mainViewModel.firstApi
           .zip(mainViewModel.secondApi) { a, b ->
               setTaskFlag( 1, JOB_AVAILABLE, Bundle().apply {
                   putString("dialog_type", a)
              })
               setTaskFlag( 2, JOB_AVAILABLE, Bundle().apply {
                   putString("dialog_type", b)
              })
          }.zip(mainViewModel.thirdApi) { _, c ->
               setTaskFlag( 3, JOB_AVAILABLE, Bundle().apply {
                   putString("dialog_type", c)
              })
          }.collect {
               stateFlow.collect {
                   flow {
                       emit(jobMap[it])
                  }.collect {
                       doJob(this@MainActivity, it!!)
                  }
              }
          }
  }
}

运行一下,效果如下


我们看到启动后第一个任务并没有立刻执行,而是等了一会才去执行,那是因为zip操作符是等所有flow里面的同步任务都执行完毕以后才发送给下游,flow里面已经执行完毕的会去等待还没有执行完毕的任务,所以才会出现刚刚页面有一段等待的时间,这样的设计一般情况下已经可以满足需求了,毕竟正常情况一个接口的响应时间都是毫秒级别的,但是难防万一出现一些极端情况,某一个接口响应忽然变慢了,就会出现我们的任务链迟迟得不到执行,产品体验方面就大打折扣了,所以需要想个方案解决一下这个问题

优化

首先我们需要当应用启动以后就立马执行任务链,判断当前需要执行任务的优先级与curLevel是否一致,另外,该任务的状态是可执行状态

/**
* 应用启动就执行任务链
*/
fun loadTask(context: Context) {
   judgeJob(context, curLevel)
}

/**
* 判断当前需要执行任务的优先级是否与curLevel一致,并且任务可执行
*/
private fun judgeJob(context: Context, cur: Int) {
   val job = jobMap[cur]
   if(curLevel == cur && job?.status() != JOB_NOT_AVAILABLE){
       MainScope().launch {
           doJob(context, cur)
      }
  }
}

我们更改一下doJob函数,让它成为一个挂起函数,并且在里面执行完任务以后,直接去判断它的下一级任务应不应该执行

private suspend fun doJob(context: Context, index: Int) {
   if (index > jobMap.size) return
   val singleJOb = jobMap[index]
   callbackFlow {
       if (singleJOb?.handle() == true) {
           singleJOb.launch(context) {
               trySend(index + 1)
          }
      } else {
           trySend(index + 1)
      }
       awaitClose { }
  }.collect {
       curLevel = it
       judgeJob(context,it)
  }
}

流程到了这里,如果所有任务都不依赖接口,那么这个任务链就能一直执行下去,如果遇到JOB_NOT_AVAILABLE的任务,需要等到接口响应的,那么任务链停止运行,那什么时候重新开始呢?就在我们接口成功回调之后给任务更改状态的时候,也就是setTaskFlag

fun setTaskFlag(context:Context,level: Int, flag: Int, bundle: Bundle = Bundle()) {
   if (level > jobMap.size) {
       return
  }
   jobMap[level]?.apply {
       setStatus(flag)
       setBundle(bundle)
  }
   judgeJob(context,level)
}

这样子,当任务链走到一个JOB_NOT_AVAILABLE的任务的时候,任务链暂停,当这个任务依赖的接口成功回调完成对这个任务状态的设置之后,再重新通过judgeJob继续走这条任务链,而一些优先级比较低的任务依赖的接口先完成了回调,那也只是完成对这个任务的状态更改,并不会执行它,因为curLevel还没到这个任务的优先级,现在可以试一下效果如何,我们把threeApi这个接口响应时间改的长一点

val thirdApi = flow {
   kotlinx.coroutines.delay(5000)
   emit("中秋节活动")
}

上层执行任务链的地方也改一下

MainScope().launch {
   JobTaskManager.apply {
       loadTask(this@MainActivity)
       mainViewModel.firstApi.collect{
           setTaskFlag(this@MainActivity, 1, JOB_AVAILABLE, Bundle().apply {
               putString("dialog_type", it)
          })
      }
       mainViewModel.secondApi.collect{
           setTaskFlag(this@MainActivity, 2, JOB_AVAILABLE, Bundle().apply {
               putString("dialog_type", it)
          })
      }
       mainViewModel.thirdApi.collect{
           setTaskFlag(this@MainActivity, 3, JOB_AVAILABLE, Bundle().apply {
               putString("dialog_type", it)
          })
      }
  }
}

应用启动就loadTask,然后三个接口已经从同步又变成异步操作了,运行一下看看效果


总结

大致的一个效果算是完成了,这只是一个demo,实际需求当中可能更复杂,弹框,页面,小气泡来回交互的情况都有可能,这里也只是想给一些正在优化项目的的同学提供一个思路,或者接手新需求的时候,鼓励多思考一下有没有更好的设计方案

作者:Coffeeee
来源:juejin.cn/post/7195336320435601467

收起阅读 »

Android通知栏增加快捷开关的技术实现

我们通常可以在通知栏上看到“飞行模式”、“移动数据”、“屏幕录制”等开关按钮,这些按钮都属于通知栏上的快捷开关,点击快捷开关可以轻易调用某种系统能力或打开某个应用程序的特定页面。那是否可以在通知栏上自定义一个快捷开关呢?答案是可以的,具体是通过TileServ...
继续阅读 »


我们通常可以在通知栏上看到“飞行模式”、“移动数据”、“屏幕录制”等开关按钮,这些按钮都属于通知栏上的快捷开关,点击快捷开关可以轻易调用某种系统能力或打开某个应用程序的特定页面。那是否可以在通知栏上自定义一个快捷开关呢?答案是可以的,具体是通过TileService的方案实现。

TileService继承自Service,所以它也是Android的四大组件之一,不过它是一个特殊的组件,开发者不需要手动开启调用,系统可以自动识别并完成调用,系统会通过绑定服务(bindService)的方式调用。

创建使用:

快捷开关是Android 7(target 24)的新能力,因此在使用该能力前必须先判断版本大小(大于等于target 24)。

1、自定义一个TileService类。

class MyQSTileService: TileService() {
 override fun onTileAdded() {    
     super.onTileAdded()  
}
 
 override fun onStartListening() {    
     super.onStartListening()  
}
 
 override fun onStopListening() {    
     super.onStopListening()  
}
 
 override fun onClick() {    
     super.onClick()  
}
 
 override fun onTileRemoved() {    
     super.onTileRemoved()  
}
}

TileService是通过绑定服务(bindService)的方式被调用的,因此,绑定服务生命周期包含的四种典型的回调方法(onCreate()、onBind()、onUnbind()和 onDestroy())都会被调用。但是,TileService也包含了以下特殊的生命周期回调方法:

  • onTileAdded():当用户从编辑栏添加快捷开关到通知栏的快速设置中会调用。

  • onTileRemoved():当用户从通知栏的快速设置移除快捷开关时调用。

  • onClick():当用户点击快捷开关时调用。

  • onStartListening():当用户打开通知栏的快速设置时调用。当快捷开关并没有从编辑栏拖到设置栏中不会调用。在TileAdded添加之后会调用一次。

  • onStopListening():当用户打开通知栏的快速设置时调用。当快捷开关并没有从编辑栏拖到设置栏中不会调用。在TileRemoved移除之前会调用一次。

2、在应用程序的清单文件中声明TileService

<service
    android:name=".MyQSTileService"
    android:label="@string/my_default_tile_label"  
    android:icon="@drawable/my_default_icon_label"
    android:exported="true"
    android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
    <intent-filter>
        <action android:name="android.service.quicksettings.action.QS_TILE" />
    </intent-filter>
</service>
  • name:自定义的TileService的类名。

  • label:快捷开关在通知栏上显示的名称。

  • icon:快捷开关在通知栏上显示的图标。

  • exported:该服务能否被外部应用调用。该属性必须为true。如果为false,那么快捷开关的功能将失效,原因是exported="false"时,TileService将不支持外部应用调起,手机系统自然不能再和该快捷开关交互。必须配置。

  • permission:需要给service配置的权限,BIND_QUICK_SETTINGS_TILE即允许应用程序绑定到第三方快速设置。必须配置。

  • intent-filter:意图过滤器,只有匹配内部的action,才能调起该service。必须配置。

监听模式

TileService的监听模式(或理解为启动模式)有两种,一种是主动模式,另一种是标准模式。

  • 主动模式

在主动模式下,TileService被请求时该服务会被绑定,并且TileService的onStartListening也会被调用。该模式需要在AndroidManifeast清单文件中声明:

<service ...>
  <meta-data android:name="android.service.quicksettings.ACTIVE_TILE"
        android:value="true" />
  ...
</service>

通过TileService.requestListeningState()这一静态方法,就可以实现对TileService的请求,示例如下:

      TileService.requestListeningState(
           applicationContext, ComponentName(
               BuildConfig.APPLICATION_ID,
               MyQSTileService::class.java.name
          )
      )

主动模式下值得注意的是:

  • 用户在通知栏快速设置的地方点击快捷开关时,TileService会自动完成绑定、TileService的onStartListening会被调用。

  • TileService无论是通过点击被绑定还是通过requestListeningState请求被绑定,TileService所在的进程都会被调起。

标准模式

在标准模式下,TileService可见时(即用户下拉通知栏看见快捷开关)该服务会被绑定,并且TileService的onStartListening也会被调用。标准模式不需要在AndroidManifeast清单文件中进行额外的声明,默认就是标准模式。

标准模式下值得注意的是:

  • 和主动模式相同,TileService被绑定时,TileService所在的进程就会被调起。

  • 而和主动模式不同的是,标准模式绑定TileService是通过用户下拉通知栏实现的,这意味着TileService所在的进程会被多次调起。因此为了避免主进程被频繁调起、避免DAU等数据统计受到影响,我们还需要为TileService指定一个特定的子进程,在Androidmanifest清单文件中设置:

      <service
          ......
          android:process="自定义子进程的名称">
          ......
      </service>

更新快捷开关

如果需要对快捷开关的数据进行更新,可以通过getQsTile()获取快捷开关的对象,然后通过setIcon(更新icon)、setLable(更新名称)、setState(更新状态,包括STATE_ACTIVE——表示开启或启用状态、STATE_INACTIVE——表示关闭或暂停状态、STATE_UNAVAILABLE:表示暂时不可用状态,在此状态下,用户无法与您的磁贴交互)等方法设置快捷开关新的数据,最后调用updateTile()方法实现。

  override fun onStartListening() {
  super.onStartListening()
  if (qsTile.state === Tile.STATE_ACTIVE) {
      qsTile.label = "inactive"
      qsTile.icon = Icon.createWithResource(context, R.drawable.inactive)
      qsTile.state = Tile.STATE_INACTIVE
  } else {
      qsTile.label = "active"
      qsTile.icon = Icon.createWithResource(context, R.drawable.active)
      qsTile.state = Tile.STATE_ACTIVE
  }
  qsTile.updateTile()
}

操作快捷开关

  • 如果想要实现点击快捷开关时、关闭通知栏并跳转到某个页面,可以调用以下方法:

startActivityAndCollapse(Intent intent)
  • 如果想要在点击快捷开关时弹出对话框进行交互,可以调用以下方法:

override fun onClick() {
   super.onClick()
   if(!isLocked()) {
       showDialog()
  }
}

因为快捷开关有可能在用户锁屏时出现,所以必须加上isLocked()的判断。只有非锁屏的情况下,对话框才会出现。

  • 如果快捷开关含有敏感信息,需要使用isSecure()进行设备安全性判断,当设备安全时,才能执行快捷开关相关的逻辑(如点击的逻辑)。当设备不安全时(手机处于锁屏状态时),可调用unlockAndRun(Runnable runnable),提示用户解锁屏幕并执行自定义的runnable操作。

以上是通知栏增加快捷开关的全部介绍。欢迎关注公众号度熊君,一起分享交流。

作者:度熊君
来源:juejin.cn/post/7190663063631036473

收起阅读 »

Android动态加载so!这一篇就够了!

背景对于一个普通的android应用来说,so库的占比通常都是巨高不下的,因为我们无可避免的在开发中遇到各种各样需要用到native的需求,所以so库的动态化可以减少极大的包体积,自从2020腾讯的bugly团队发部关于动态化so的相关文章后,已经过去两年了,...
继续阅读 »

背景

对于一个普通的android应用来说,so库的占比通常都是巨高不下的,因为我们无可避免的在开发中遇到各种各样需要用到native的需求,所以so库的动态化可以减少极大的包体积,自从2020腾讯的bugly团队发部关于动态化so的相关文章后,已经过去两年了,相关文章,经过两年的考验,实际上so动态加载也是非常成熟的一项技术了,但是很遗憾,许多公司都还没有这方面的涉略又或者说不知道从哪里开始进行,因为so动态其实涉及到下载,so版本管理,动态加载实现等多方面,我们不妨抛开这些额外的东西,从最本质的so动态加载出发吧!这里是本次的例子,我把它命名为sillyboy,欢迎pr还有后续点赞呀!

so动态加载介绍

动态加载,其实就是把我们的so库在打包成apk的时候剔除,在合适的时候通过网络包下载的方式,通过一些手段,在运行的时候进行分离加载的过程。这里涉及到下载器,还有下载后的版本管理等等确保一个so库被正确的加载等过程,在这里,我们不讨论这些辅助的流程,我们看下怎么实现一个最简单的加载流程。


从一个例子出发

我们构建一个native工程,然后在里面编入如下内容,下面是cmake

# For more information about using CMake with Android Studio, read the
# documentation: https://d.android.com/studio/projects/add-native-code.html

# Sets the minimum version of CMake required to build the native library.

cmake_minimum_required(VERSION 3.18.1)

# Declares and names the project.

project("nativecpp")

# Creates and names a library, sets it as either STATIC
# or SHARED, and provides the relative paths to its source code.
# You can define multiple libraries, and CMake builds them for you.
# Gradle automatically packages shared libraries with your APK.

add_library( # Sets the name of the library.
      nativecpp

      # Sets the library as a shared library.
      SHARED

      # Provides a relative path to your source file(s).
      native-lib.cpp)

add_library(
      nativecpptwo
      SHARED
      test.cpp

)

# Searches for a specified prebuilt library and stores the path as a
# variable. Because CMake includes system libraries in the search path by
# default, you only need to specify the name of the public NDK library
# you want to add. CMake verifies that the library exists before
# completing its build.

find_library( # Sets the name of the path variable.
      log-lib

      # Specifies the name of the NDK library that
      # you want CMake to locate.
      log)

# Specifies libraries CMake should link to your target library. You
# can link multiple libraries, such as libraries you define in this
# build script, prebuilt third-party libraries, or system libraries.

target_link_libraries( # Specifies the target library.
      nativecpp

      # Links the target library to the log library
      # included in the NDK.
      ${log-lib})


target_link_libraries( # Specifies the target library.
      nativecpptwo

      # Links the target library to the log library
      # included in the NDK.
      nativecpp
      ${log-lib})

可以看到,我们生成了两个so库一个是nativecpp,还有一个是nativecpptwo(为什么要两个呢?我们可以继续看下文) 这里也给出最关键的test.cpp代码

#include <jni.h>
#include <string>
#include<android/log.h>


extern "C"
JNIEXPORT void JNICALL
Java_com_example_nativecpp_MainActivity_clickTest(JNIEnv *env, jobject thiz) {
  // 在这里打印一句话
  __android_log_print(ANDROID_LOG_INFO,"hello"," native 层方法");

}

很简单,就一个native方法,打印一个log即可,我们就可以在java/kotin层进行方法调用了,即

public native void clickTest();

so库检索与删除

要实现so的动态加载,那最起码是要知道本项目过程中涉及到哪些so吧!不用担心,我们gradle构建的时候,就已经提供了相应的构建过程,即构建的task【 mergeDebugNativeLibs】,在这个过程中,会把一个project里面的所有native库进行一个收集的过程,紧接着task【stripDebugDebugSymbols】是一个符号表清除过程,如果了解native开发的朋友很容易就知道,这就是一个减少so体积的一个过程,我们不在这里详述。所以我们很容易想到,我们只要在这两个task中插入一个自定义的task,用于遍历和删除就可以实现so的删除化了,所以就很容易写出这样的代码

ext {
   deleteSoName = ["libnativecpptwo.so","libnativecpp.so"]
}
// 这个是初始化 -配置 -执行阶段中,配置阶段执行的任务之一,完成afterEvaluate就可以得到所有的tasks,从而可以在里面插入我们定制化的数据
task(dynamicSo) {
}.doLast {
   println("dynamicSo insert!!!! ")
   //projectDir 在哪个project下面,projectDir就是哪个路径
   print(getRootProject().findAll())

   def file = new File("${projectDir}/build/intermediates/merged_native_libs/debug/out/lib")
   //默认删除所有的so库
   if (file.exists()) {
       file.listFiles().each {
           if (it.isDirectory()) {
               it.listFiles().each {
                   target ->
                       print("file ${target.name}")
                       def compareName = target.name
                       deleteSoName.each {
                           if (compareName.contains(it)) {
                               target.delete()
                          }
                      }
              }
          }
      }
  } else {
       print("nil")
  }
}
afterEvaluate {
   print("dynamicSo task start")
   def customer = tasks.findByName("dynamicSo")
   def merge = tasks.findByName("mergeDebugNativeLibs")
   def strip = tasks.findByName("stripDebugDebugSymbols")
   if (merge != null || strip != null) {
       customer.mustRunAfter(merge)
       strip.dependsOn(customer)
  }

}

可以看到,我们定义了一个自定义task dynamicSo,它的执行是在afterEvaluate中定义的,并且依赖于mergeDebugNativeLibs,而stripDebugDebugSymbols就依赖于我们生成的dynamicSo,达到了一个插入操作。那么为什么要在afterEvaluate中执行呢?那是因为android插件是在配置阶段中才生成的mergeDebugNativeLibs等任务,原本的gradle构建是不存在这样一个任务的,所以我们才需要在配置完所有task之后,才进行的插入,我们可以看一下gradle的生命周期


通过对条件检索,我们就删除掉了我们想要的so,即ibnativecpptwo.so与libnativecpp.so。

动态加载so

根据上文检索出来的两个so,我们就可以在项目中上传到自己的后端中,然后通过网络下载到用户的手机上,这里我们就演示一下即可,我们就直接放在data目录下面吧


真实的项目过程中,应该要有校验操作,比如md5校验或者可以解压等等操作,这里不是重点,我们就直接略过啦!

那么,怎么把一个so库加载到我们本来的apk中呢?这里是so原本的加载过程,可以看到,系统是通过classloader检索native目录是否存在so库进行加载的,那我们反射一下,把我们自定义的path加入进行不就可以了吗?这里采用tinker一样的思路,在我们的classloader中加入so的检索路径即可,比如

private static final class V25 {
  private static void install(ClassLoader classLoader, File folder) throws Throwable {
      final Field pathListField = ShareReflectUtil.findField(classLoader, "pathList");
      final Object dexPathList = pathListField.get(classLoader);

      final Field nativeLibraryDirectories = ShareReflectUtil.findField(dexPathList, "nativeLibraryDirectories");

      List<File> origLibDirs = (List<File>) nativeLibraryDirectories.get(dexPathList);
      if (origLibDirs == null) {
          origLibDirs = new ArrayList<>(2);
      }
      final Iterator<File> libDirIt = origLibDirs.iterator();
      while (libDirIt.hasNext()) {
          final File libDir = libDirIt.next();
          if (folder.equals(libDir)) {
              libDirIt.remove();
              break;
          }
      }
      origLibDirs.add(0, folder);

      final Field systemNativeLibraryDirectories = ShareReflectUtil.findField(dexPathList, "systemNativeLibraryDirectories");
      List<File> origSystemLibDirs = (List<File>) systemNativeLibraryDirectories.get(dexPathList);
      if (origSystemLibDirs == null) {
          origSystemLibDirs = new ArrayList<>(2);
      }

      final List<File> newLibDirs = new ArrayList<>(origLibDirs.size() + origSystemLibDirs.size() + 1);
      newLibDirs.addAll(origLibDirs);
      newLibDirs.addAll(origSystemLibDirs);

      final Method makeElements = ShareReflectUtil.findMethod(dexPathList, "makePathElements", List.class);

      final Object[] elements = (Object[]) makeElements.invoke(dexPathList, newLibDirs);

      final Field nativeLibraryPathElements = ShareReflectUtil.findField(dexPathList, "nativeLibraryPathElements");
      nativeLibraryPathElements.set(dexPathList, elements);
  }
}

我们在原本的检索路径中,在最前面,即数组为0的位置加入了我们的检索路径,这样一来classloader在查找我们已经动态化的so库的时候,就能够找到!

结束了吗?

一般的so库,比如不依赖其他的so的时候,直接这样加载就没问题了,但是如果存在着依赖的so库的话,就不行了!相信大家在看其他的博客的时候就能看到,是因为Namespace的问题。具体是我们动态库加载的过程中,如果需要依赖其他的动态库,那么就需要一个链接的过程对吧!这里的实现就是Linker,Linker 里检索的路径在创建 ClassLoader 实例后就被系统通过 Namespace 机制绑定了,当我们注入新的路径之后,虽然 ClassLoader 里的路径增加了,但是 Linker 里 Namespace 已经绑定的路径集合并没有同步更新,所以出现了 libxxx.so 文件(当前的so)能找到,而依赖的so 找不到的情况。bugly文章

很多实现都采用了Tinker的实现,既然我们系统的classloader是这样,那么我们在合适的时候把这个替换掉不就可以了嘛!当然bugly团队就是这样做的,但是笔者认为,替换一个classloader显然对于一个普通应用来说,成本还是太大了,而且兼容性风险也挺高的,当然,还有很多方式,比如采用Relinker这个库自定义我们加载的逻辑。

为了不冷饭热炒,嘿嘿,虽然我也喜欢吃炒饭(手动狗头),这里我们就不采用替换classloader的方式,而是采用跟relinker的思想,去进行加载!具体的可以看到sillyboy的实现,其实就不依赖relinker跟tinker,因为我把关键的拷贝过来了,哈哈哈,好啦,我们看下怎么实现吧!不过在此这前,我们需要了解一些前置知识

ELF文件

我们的so库,本质就是一个elf文件,那么so库也符合elf文件的格式,ELF文件由4部分组成,分别是ELF头(ELF header)、程序头表(Program header table)、节(Section)和节头表(Section header table)。实际上,一个文件中不一定包含全部内容,而且它们的位置也未必如同所示这样安排,只有ELF头的位置是固定的,其余各部分的位置、大小等信息由ELF头中的各项值来决定。


那么我们so中,如果依赖于其他的so,那么这个信息存在哪里呢!?没错,它其实也存在elf文件中,不然链接器怎么找嘛,它其实就存在.dynamic段中,所以我们只要找打dynamic段的偏移,就能到dynamic中,而被依赖的so的信息,其实就存在里面啦 我们可以用readelf(ndk中就有toolchains目录后) 查看,readelf -d nativecpptwo.so 这里的 -d 就是查看dynamic段的意思


这里面涉及到动态加载so的知识,可以推荐大家一本书,叫做程序员的自我修养-链接装载与库这里就画个初略图


我们再看下本质,dynamic结构体如下,定义在elf.h中

typedef struct{
Elf32_Sword d_tag;
union{
Elf32_Addr d_ptr;
....
}
}

当d_tag的数值为DT_NEEDED的时候,就代表着依赖的共享对象文件,d_ptr表示所依赖的共享对象的文件名。看到这里读者们已经知道了,如果我们知道了文件名,不就可以再用System.loadLibrary去加载这个文件名确定的so了嘛!不用替换classloader就能够保证被依赖的库先加载!我们可以再总结一下这个方案的原理,如图


比如我们要加载so3,我们就需要先加载so2,如果so2存在依赖,那我们就调用System.loadLibrary先加载so1,这个时候so1就不存在依赖项了,就不需要再调用Linker去查找其他so库了。我们最终方案就是,只要能够解析对应的elf文件,然后找偏移,找到需要的目标项(DT_NEED)所对应的数值(即被依赖的so文件名)就可以了

public List<String> parseNeededDependencies() throws IOException {
  channel.position(0);
  final List<String> dependencies = new ArrayList<String>();
  final Header header = parseHeader();
  final ByteBuffer buffer = ByteBuffer.allocate(8);
  buffer.order(header.bigEndian ? ByteOrder.BIG_ENDIAN : ByteOrder.LITTLE_ENDIAN);

  long numProgramHeaderEntries = header.phnum;
  if (numProgramHeaderEntries == 0xFFFF) {
      /**
        * Extended Numbering
        *
        * If the real number of program header table entries is larger than
        * or equal to PN_XNUM(0xffff), it is set to sh_info field of the
        * section header at index 0, and PN_XNUM is set to e_phnum
        * field. Otherwise, the section header at index 0 is zero
        * initialized, if it exists.
        **/
      final SectionHeader sectionHeader = header.getSectionHeader(0);
      numProgramHeaderEntries = sectionHeader.info;
  }

  long dynamicSectionOff = 0;
  for (long i = 0; i < numProgramHeaderEntries; ++i) {
      final ProgramHeader programHeader = header.getProgramHeader(i);
      if (programHeader.type == ProgramHeader.PT_DYNAMIC) {
          dynamicSectionOff = programHeader.offset;
          break;
      }
  }

  if (dynamicSectionOff == 0) {
      // No dynamic linking info, nothing to load
      return Collections.unmodifiableList(dependencies);
  }

  int i = 0;
  final List<Long> neededOffsets = new ArrayList<Long>();
  long vStringTableOff = 0;
  DynamicStructure dynStructure;
  do {
      dynStructure = header.getDynamicStructure(dynamicSectionOff, i);
      if (dynStructure.tag == DynamicStructure.DT_NEEDED) {
          neededOffsets.add(dynStructure.val);
      } else if (dynStructure.tag == DynamicStructure.DT_STRTAB) {
          vStringTableOff = dynStructure.val; // d_ptr union
      }
      ++i;
  } while (dynStructure.tag != DynamicStructure.DT_NULL);

  if (vStringTableOff == 0) {
      throw new IllegalStateException("String table offset not found!");
  }

  // Map to file offset
  final long stringTableOff = offsetFromVma(header, numProgramHeaderEntries, vStringTableOff);
  for (final Long strOff : neededOffsets) {
      dependencies.add(readString(buffer, stringTableOff + strOff));
  }

  return dependencies;
}

扩展

我们到这里,就能够解决so库的动态加载的相关问题了,那么还有人可能会问,项目中是会存在多处System.load方式的,如果加载的so还不存在怎么办?比如还在下载当中,其实很简单,这个时候我们字节码插桩就派上用场了,只要我们把System.load替换为我们自定义的加载so逻辑,进行一定的逻辑处理就可以了,嘿嘿,因为笔者之前就有写一个字节码插桩的库的介绍,所以在本次就不重复了,可以看Sipder,同时也可以用其他的字节码插桩框架实现,相信这不是一个问题。

总结

看到这里的读者,相信也能够明白动态加载so的步骤了,最后源代码可以在SillyBoy,当然也希望各位点赞呀!当然,有更好的实现也欢迎评论!!

作者:Pika
来源:juejin.cn/post/7107958280097366030

收起阅读 »

基于 Android 系统方案适配 Night Mode 后,老板要再加一套皮肤?

背景说明原本已经基于系统方案适配了暗黑主题,实现了白/黑两套皮肤,以及跟随系统。后来老板研究学习友商时,发现友商 App 有三套皮肤可选,除了常规的亮白和暗黑,还有一套暗蓝色。并且在跟随系统暗黑模式下,用户可选暗黑还是暗蓝。这不,新的需求马上就来了。其实我们之...
继续阅读 »

背景说明

原本已经基于系统方案适配了暗黑主题,实现了白/黑两套皮肤,以及跟随系统。后来老板研究学习友商时,发现友商 App 有三套皮肤可选,除了常规的亮白和暗黑,还有一套暗蓝色。并且在跟随系统暗黑模式下,用户可选暗黑还是暗蓝。这不,新的需求马上就来了。

其实我们之前两个 App 的换肤方案都是使用 Android-skin-support 来做的,在此基础上再加套皮肤也不是难事。但在新的 App 实现多皮肤时,由于前两个 App 做了这么久都只有两套皮肤,而且新的 App 需要实现跟随系统,为了更好的体验和较少的代码实现,就采用了系统方案进行适配暗黑模式。

Android-skin-support 和系统两种方案适配经验来看,系统方案适配改动的代码更少,所花费的时间当然也就更少了。所以在需要新添一套皮肤的时候,也不可能再去切方案了。那么在使用系统方案的情况下,如何再加一套皮肤呢?来,先看源码吧。

源码分析

以下源码基于 android-31

首先,在代码中获取资源一般通过 Context 对象的一些方法,例如:

// Context.java

@ColorInt
public final int getColor(@ColorRes int id) {
   return getResources().getColor(id, getTheme());
}

@Nullable
public final Drawable getDrawable(@DrawableRes int id) {
   return getResources().getDrawable(id, getTheme());
}

可以看到 Context 是通过 Resources 对象再去获取的,继续看 Resources

// Resources.java

@ColorInt
public int getColor(@ColorRes int id, @Nullable Theme theme) throws NotFoundException {
   final TypedValue value = obtainTempTypedValue();
   try {
       final ResourcesImpl impl = mResourcesImpl;
       impl.getValue(id, value, true);
       if (value.type >= TypedValue.TYPE_FIRST_INT
           && value.type <= TypedValue.TYPE_LAST_INT) {
           return value.data;
      } else if (value.type != TypedValue.TYPE_STRING) {
           throw new NotFoundException("Resource ID #0x" + Integer.toHexString(id)                                 + " type #0x" + Integer.toHexString(value.type) + " is not valid");
      }
       // 这里调用 ResourcesImpl#loadColorStateList 方法获取颜色
       final ColorStateList csl = impl.loadColorStateList(this, value, id, theme);
       return csl.getDefaultColor();
  } finally {
    releaseTempTypedValue(value);
  }
}

public Drawable getDrawable(@DrawableRes int id, @Nullable Theme theme)
       throws NotFoundException {
   return getDrawableForDensity(id, 0, theme);
}

@Nullable
public Drawable getDrawableForDensity(@DrawableRes int id, int density, @Nullable Theme theme) {
   final TypedValue value = obtainTempTypedValue();
   try {
       final ResourcesImpl impl = mResourcesImpl;
       impl.getValueForDensity(id, density, value, true);
    // 看到这里
       return loadDrawable(value, id, density, theme);
  } finally {
    releaseTempTypedValue(value);
  }
}

@NonNull
@UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.R, trackingBug = 170729553)
Drawable loadDrawable(@NonNull TypedValue value, int id, int density, @Nullable Theme theme)
       throws NotFoundException {
   // 这里调用 ResourcesImpl#loadDrawable 方法获取 drawable 资源
   return mResourcesImpl.loadDrawable(this, value, id, density, theme);
}

到这里我们知道在代码中获取资源时,是通过 Context -> Resources -> ResourcesImpl 调用链实现的。

先看 ResourcesImpl.java

/**
* The implementation of Resource access. This class contains the AssetManager and all caches
* associated with it.
*
* {@link Resources} is just a thing wrapper around this class. When a configuration change
* occurs, clients can retain the same {@link Resources} reference because the underlying
* {@link ResourcesImpl} object will be updated or re-created.
*
* @hide
*/
public class ResourcesImpl {
  ...
}

虽然是 public 的类,但是被 @hide 标记了,意味着想通过继承后重写相关方法这条路行不通了,pass。

再看 Resources.java,同样是 public 类,但没被 @hide 标记。我们就可以通过继承 Resources 类,然后重写 Resources#getColorResources#getDrawableForDensity 等方法来改造获取资源的逻辑。

先看相关代码:

// SkinResources.kt

class SkinResources(context: Context, res: Resources) : Resources(res.assets, res.displayMetrics, res.configuration) {

   val contextRef: WeakReference<Context> = WeakReference(context)

   override fun getDrawableForDensity(id: Int, density: Int, theme: Theme?): Drawable? {
       return super.getDrawableForDensity(resetResIdIfNeed(contextRef.get(), id), density, theme)
  }

   override fun getColor(id: Int, theme: Theme?): Int {
       return super.getColor(resetResIdIfNeed(contextRef.get(), id), theme)
  }
 
   private fun resetResIdIfNeed(context: Context?, resId: Int): Int {
       // 非暗黑蓝无需替换资源 ID
       if (context == null || !UIUtil.isNightBlue(context)) return resId

       var newResId = resId
       val res = context.resources
       try {
           val resPkg = res.getResourcePackageName(resId)
           // 非本包资源无需替换
           if (context.packageName != resPkg) return newResId

           val resName = res.getResourceEntryName(resId)
           val resType = res.getResourceTypeName(resId)
        // 获取对应暗蓝皮肤的资源 id
           val id = res.getIdentifier("${resName}_blue", resType, resPkg)
           if (id != 0) newResId = id
      } finally {
           return newResId
      }
  }

}

主要原理与逻辑:

  • 所有资源都会在 R.java 文件中生成对应的资源 id,而我们正是通过资源 id 来获取对应资源的。

  • Resources 类提供了 getResourcePackageName/getResourceEntryName/getResourceTypeName 方法,可通过资源 id 获取对应的资源包名/资源名称/资源类型。

  • 过滤掉无需替换资源的场景。

  • Resources 还提供了 getIdentifier 方法来获取对应资源 id。

  • 需要适配暗蓝皮肤的资源,统一在原资源名称的基础上加上 _blue 后缀。

  • 通过 Resources#getIdentifier 方法获取对应暗蓝皮肤的资源 id。如果没找到,改方法会返回 0

现在就可以通过 SkinResources 来获取适配多皮肤的资源了。但是,之前的代码都是通过 Context 直接获取的,如果全部替换成 SkinResources 来获取,那代码改动量就大了。

我们回到前面 Context.java 的源码,可以发现它获取资源时,都是通过 Context#getResources 方法先得到 Resources 对象,再通过其去获取资源的。而 Context#getResources 方法也是可以重写的,这意味着我们可以维护一个自己的 Resources 对象。ApplicationActivity 也都是继承自 Context 的,所以我们在其子类中重写 getResources 方法即可:

// BaseActivity.java/BaseApplication.java

private Resources mSkinResources;

@Override
public Resources getResources() {
   if (mSkinResources == null) {
    mSkinResources = new SkinResources(this, super.getResources());
  }
   return mSkinResources;
}

到此,基本逻辑就写完了,马上 build 跑起来。

咦,好像有点不太对劲,有些 colordrawable 没有适配成功。

经过一番对比,发现 xml 布局中的资源都没有替换成功。

那么问题在哪呢?还是先从源码着手,先来看看 View 是如何从 xml 中获取并设置 background 属性的:

// View.java

public View(Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) {
this(context);

   // AttributeSet 是 xml 中所有属性的集合
   // TypeArray 则是经过处理过的集合,将原始的 xml 属性值("@color/colorBg")转换为所需的类型,并应用主题和样式
   final TypedArray a = context.obtainStyledAttributes(
           attrs, com.android.internal.R.styleable.View, defStyleAttr, defStyleRes);

  ...
   
   Drawable background = null;

  ...
   
   final int N = a.getIndexCount();
for (int i = 0; i < N; i++) {
    int attr = a.getIndex(i);
    switch (attr) {
           case com.android.internal.R.styleable.View_background:
               // TypedArray 提供一些直接获取资源的方法
            background = a.getDrawable(attr);
            break;
          ...
      }
  }
 
  ...
   
   if (background != null) {
    setBackground(background);
  }
 
  ...
}

再接着看 TypedArray 是如何获取资源的:

// TypedArray.java

@Nullable
public Drawable getDrawable(@StyleableRes int index) {
   return getDrawableForDensity(index, 0);
}

@Nullable
public Drawable getDrawableForDensity(@StyleableRes int index, int density) {
   if (mRecycled) {
    throw new RuntimeException("Cannot make calls to a recycled instance!");
  }

   final TypedValue value = mValue;
   if (getValueAt(index * STYLE_NUM_ENTRIES, value)) {
       if (value.type == TypedValue.TYPE_ATTRIBUTE) {
           throw new UnsupportedOperationException(
               "Failed to resolve attribute at index " + index + ": " + value);
      }

       if (density > 0) {
           // If the density is overridden, the value in the TypedArray will not reflect this.
           // Do a separate lookup of the resourceId with the density override.
           mResources.getValueForDensity(value.resourceId, density, value, true);
      }
    // 看到这里
       return mResources.loadDrawable(value, value.resourceId, density, mTheme);
  }
   return null;
}

TypedArray 是通过 Resources#loadDrawable 方法来加载资源的,而我们之前写 SkinResources 的时候并没有重写该方法,为什么呢?那是因为该方法是被 @UnsupportedAppUsage 标记的。所以,这就是 xml 布局中的资源替换不成功的原因。

这个问题又怎么解决呢?

之前采用 Android-skin-support 方案做换肤时,了解到它的原理,其会替换成自己的实现的 LayoutInflater.Factory2,并在创建 View 时替换生成对应适配了换肤功能的 View 对象。例如:将 View 替换成 SkinView,而 SkinView 初始化时再重新处理 background 属性,即可完成换肤。

AppCompat 也是同样的逻辑,通过 AppCompatViewInflater 将普通的 View 替换成带 AppCompat- 前缀的 View。

其实我们只需能操作生成后的 View,并且知道 xml 中写了哪些属性值即可。那么我们完全照搬 AppCompat 这套逻辑即可:

  • 定义类继承 LayoutInflater.Factory2,并实现 onCreateView 方法。

  • onCreateView 主要是创建 View 的逻辑,而这部分逻辑完全 copy AppCompatViewInflater 类即可。

  • onCreateView 中创建 View 之后,返回 View 之前,实现我们自己的逻辑。

  • 通过 LayoutInflaterCompat#setFactory2 方法,设置我们自己的 Factory2。

相关代码片段:

public class SkinViewInflater implements LayoutInflater.Factory2 {
   @Nullable
   @Override
   public View onCreateView(@Nullable View parent, @NonNull String name, @NonNull Context context, @NonNull AttributeSet attrs) {
    // createView 方法就是 AppCompatViewInflater 中的逻辑
       View view = createView(parent, name, context, attrs, false, false, true, false);
       onViewCreated(context, view, attrs);
       return view;
  }

   @Nullable
   @Override
   public View onCreateView(@NonNull String name, @NonNull Context context, @NonNull AttributeSet attrs) {
       return onCreateView(null, name, context, attrs);
  }

   private void onViewCreated(@NonNull Context context, @Nullable View view, @NonNull AttributeSet attrs) {
    if (view == null) return;
       resetViewAttrsIfNeed(context, view, attrs);
  }
 
   private void resetViewAttrsIfNeed(Context context, View view, AttributeSet attrs) {
    if (!UIUtil.isNightBlue(context)) return;
     
    String ANDROID_NAMESPACE = "http://schemas.android.com/apk/res/android";
    String BACKGROUND = "background";
     
    // 获取 background 属性值的资源 id,未找到时返回 0
    int backgroundId = attrs.getAttributeResourceValue(ANDROID_NAMESPACE, BACKGROUND, 0);
    if (backgroundId != 0) {
           view.setBackgroundResource(resetResIdIfNeed(context, backgroundId));
      }
  }
}
// BaseActivity.java

@Override
public void onCreate(Bundle savedInstanceState) {
   super.onCreate(savedInstanceState);
   SkinViewInflater inflater = new SkinViewInflater();
   LayoutInflater layoutInflater = LayoutInflater.from(this);
   // 生成 View 的逻辑替换成我们自己的
   LayoutInflaterCompat.setFactory2(layoutInflater, inflater);
}

至此,这套方案已经可以解决目前的换肤需求了,剩下的就是进行细节适配了。

其他说明

自定义控件与第三方控件适配

上面只对 background 属性进行了处理,其他需要进行换肤的属性也是同样的处理逻辑。如果是自定义的控件,可以在初始化时调用 TypedArray#getResourceId 方法先获取资源 id,再通过 context 去获取对应资源,而不是使用 TypedArray#getDrawable 类似方法直接获取资源对象,这样可以确保换肤成功。而第三方控件也可通过 background 属性同样的处理逻辑进行适配。

XML <shape> 的处理

<!-- bg.xml -->
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<corners android:radius="8dp" />
<solid android:color="@color/background" />
</shape>

上面的 bg.xml 文件内的 color 并不会完成资源替换,根据上面的逻辑,需要新增以下内容:

<!-- bg_blue.xml -->
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<corners android:radius="8dp" />
<solid android:color="@color/background_blue" />
</shape>

如此,资源替换才会成功。

设计的配合

这次对第三款皮肤的适配还是蛮轻松的,主要是有以下基础:

  • 在适配暗黑主题的时候,设计有出设计规范,后续开发按照设计规范来。

  • 暗黑和暗蓝共用一套图片资源,大大减少适配工作量。

  • 暗黑和暗蓝部份共用颜色值含透明度,同样减少了工作量,仅少量颜色需要新增。

这次适配的主要工作量还是来自 <shape> 的替换。

暗蓝皮肤资源文件的归处

我知道很多换肤方案都会将皮肤资源制作成皮肤包,但是这个方案没有这么做。一是没有那么多需要替换的资源,二是为了减少相应的工作量。

我新建了一个资源文件夹,与 res 同级,取名 res-blue。并在 gradle 配置文件中配置它。编译后系统会自动将它们合并,同时也能与常规资源文件隔离开来。

// build.gradle
sourceSets {
main {
java {
srcDir 'src/main/java'
}
res.srcDirs += 'src/main/res'
res.srcDirs += 'src/main/res-blue'
}
}

有哪些坑?

WebView 资源缺失导致闪退

版本上线后,发现有 android.content.res.Resources$NotFoundException 异常上报,具体异常堆栈信息:

android.content.res.ResourcesImpl.getValue(ResourcesImpl.java:321)
android.content.res.Resources.getInteger(Resources.java:1279)
org.chromium.ui.base.DeviceFormFactor.b(chromium-TrichromeWebViewGoogle.apk-stable-447211483:4)
org.chromium.content.browser.selection.SelectionPopupControllerImpl.n(chromium-TrichromeWebViewGoogle.apk-stable-447211483:1)
N7.onCreateActionMode(chromium-TrichromeWebViewGoogle.apk-stable-447211483:8)
Gu.onCreateActionMode(chromium-TrichromeWebViewGoogle.apk-stable-447211483:2)
com.android.internal.policy.DecorView$ActionModeCallback2Wrapper.onCreateActionMode(DecorView.java:3255)
com.android.internal.policy.DecorView.startActionMode(DecorView.java:1159)
com.android.internal.policy.DecorView.startActionModeForChild(DecorView.java:1115)
android.view.ViewGroup.startActionModeForChild(ViewGroup.java:1087)
android.view.ViewGroup.startActionModeForChild(ViewGroup.java:1087)
android.view.ViewGroup.startActionModeForChild(ViewGroup.java:1087)
android.view.ViewGroup.startActionModeForChild(ViewGroup.java:1087)
android.view.ViewGroup.startActionModeForChild(ViewGroup.java:1087)
android.view.ViewGroup.startActionModeForChild(ViewGroup.java:1087)
android.view.View.startActionMode(View.java:7716)
org.chromium.content.browser.selection.SelectionPopupControllerImpl.I(chromium-TrichromeWebViewGoogle.apk-stable-447211483:10)
Vc0.a(chromium-TrichromeWebViewGoogle.apk-stable-447211483:10)
Vf0.i(chromium-TrichromeWebViewGoogle.apk-stable-447211483:4)
A5.run(chromium-TrichromeWebViewGoogle.apk-stable-447211483:3)
android.os.Handler.handleCallback(Handler.java:938)
android.os.Handler.dispatchMessage(Handler.java:99)
android.os.Looper.loopOnce(Looper.java:233)
android.os.Looper.loop(Looper.java:334)
android.app.ActivityThread.main(ActivityThread.java:8333)
java.lang.reflect.Method.invoke(Native Method)
com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:582)
com.android.internal.os.ZygoteInit.main(ZygoteInit.java:1065)

经查才发现在 WebView 中长按文本弹出操作菜单时,就会引发该异常导致 App 闪退。

这是其他插件化方案也踩过的坑,我们只需在创建 SkinResources 之前将外部 WebView 的资源路径添加进来即可。

@Override
public Resources getResources() {
if (mSkinResources == null) {
WebViewResourceHelper.addChromeResourceIfNeeded(this);
mSkinResources = new SkinResources(this, super.getResources());
}
return mSkinResources;
}

RePlugin/WebViewResourceHelper.java 源码文件

具体问题分析可参考

Fix ResourceNotFoundException in Android 7.0 (or above)

最终效果图

skin_demo.gif

总结

这个方案在原本使用系统方式适配暗黑主题的基础上,通过拦截 Resources 相关获取资源的方法,替换换肤后的资源 id,以达到换肤的效果。针对 XML 布局换肤不成功的问题,复制 AppCompatViewInflater 创建 View 的代码逻辑,并在 View 创建成功后重新设置需要进行换肤的相关 XML 属性。同一皮肤资源使用单独的资源文件夹独立存放,可以与正常资源进行隔离,也避免了制作皮肤包而增加工作量。

目前来说这套方案是改造成本最小,侵入性最小的选择。选择适合自身需求的才是最好的。

作者:ONEW
来源:https://juejin.cn/post/7187282270360141879

收起阅读 »

自定义View模仿即刻点赞数字切换效果

即刻点赞展示点赞的数字增加和减少并不是整个替换,而是差异化替换。再加上动画效果就看的很舒服。自己如何实现这种数字切换呢?下面用一张图来展示我的思路:现在只需要根据这张图,写出对应的动画即可。 分为2种场景:数字+1:差异化的数字从3号区域由渐变动画(透明度 0...
继续阅读 »

即刻点赞展示


点赞的数字增加和减少并不是整个替换,而是差异化替换。再加上动画效果就看的很舒服。

自己如何实现这种数字切换呢?

下面用一张图来展示我的思路:


现在只需要根据这张图,写出对应的动画即可。 分为2种场景:

  • 数字+1:

    • 差异化的数字从3号区域由渐变动画(透明度 0- 255) + 偏移动画 (3号区域绘制文字的基线,2号区域绘制文字的基线),将数字移动到2号位置处

    • 差异化的数字从2号区域由渐变动画(透明度 255- 0) + 偏移动画(2号区域绘制文字的基线,1号区域绘制文字的基线),将数字移动到1号位置处

  • 数字-1

    • 差异化的数字从1号区域由渐变动画(透明度 0- 255) + 偏移动画 (1号区域绘制文字的基线,2号区域绘制文字的基线),将数字移动到2号位置处

    • 差异化的数字从2号区域由渐变动画(透明度 255- 0) + 偏移动画(2号区域绘制文字的基线,3号区域绘制文字的基线),将数字移动到3号位置处

公共部分就是: 不变的文字不需要做任何处理,绘制在2号区域就行。绘制差异化文字时,需要加上不变的文字的宽度就行。

效果展示


源码

class LikeView @JvmOverloads constructor(
  context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {

  private val paint = Paint().also {
      it.isAntiAlias = true
      it.textSize = 200f
  }

  private val textRect0 = Rect(300, 100, 800, 300)
  private val textRect1 = Rect(300, 300, 800, 500)
  private val textRect2 = Rect(300, 500, 800, 700)

  private var nextNumberAlpha: Int = 0
      set(value) {
          field = value
          invalidate()
      }

  private var currentNumberAlpha: Int = 255
      set(value) {
          field = value
          invalidate()
      }

  private var offsetPercent = 0f
      set(value) {
          field = value
          invalidate()
      }

  private val fontMetrics: FontMetrics = paint.fontMetrics
  private var currentNumber = 99
  private var nextNumber = 0
  private var motionLess = currentNumber.toString()
  private var currentMotion = ""
  private var nextMotion = ""

  private val animator: ObjectAnimator by lazy {
      val nextNumberAlphaAnimator = PropertyValuesHolder.ofInt("nextNumberAlpha", 0, 255)
      val offsetPercentAnimator = PropertyValuesHolder.ofFloat("offsetPercent", 0f, 1f)
      val currentNumberAlphaAnimator = PropertyValuesHolder.ofInt("currentNumberAlpha", 255, 0)
      val animator = ObjectAnimator.ofPropertyValuesHolder(
          this,
          nextNumberAlphaAnimator,
          offsetPercentAnimator,
          currentNumberAlphaAnimator
      )
      animator.duration = 200
      animator.interpolator = DecelerateInterpolator()
      animator.addListener(
          onEnd = {
              currentNumber = nextNumber
          }
      )
      animator
  }

  override fun onDraw(canvas: Canvas) {
      paint.alpha = 255

      paint.color = Color.LTGRAY
      canvas.drawRect(textRect0, paint)

      paint.color = Color.RED
      canvas.drawRect(textRect1, paint)

      paint.color = Color.GREEN
      canvas.drawRect(textRect2, paint)

      paint.color = Color.BLACK
      if (motionLess.isNotEmpty()) {
          drawText(canvas, motionLess, textRect1, 0f)
      }

      if (nextMotion.isEmpty() || currentMotion.isEmpty()) {
          return
      }

      val textHorizontalOffset =
          if (motionLess.isNotEmpty()) paint.measureText(motionLess) else 0f
      if (nextNumber > currentNumber) {
          paint.alpha = currentNumberAlpha
          drawText(canvas, currentMotion, textRect1, textHorizontalOffset, -offsetPercent)
          paint.alpha = nextNumberAlpha
          drawText(canvas, nextMotion, textRect2, textHorizontalOffset, -offsetPercent)
      } else {
          paint.alpha = nextNumberAlpha
          drawText(canvas, nextMotion, textRect0, textHorizontalOffset, offsetPercent)
          paint.alpha = currentNumberAlpha
          drawText(canvas, currentMotion, textRect1, textHorizontalOffset, offsetPercent)
      }
  }

  private fun drawText(
      canvas: Canvas,
      text: String,
      rect: Rect,
      textHorizontalOffset: Float = 0f,
      offsetPercent: Float = 0f
  ) {
      canvas.drawText(
          text,
          rect.left.toFloat() + textHorizontalOffset,
          rect.top + (rect.bottom - rect.top) / 2f - (fontMetrics.bottom + fontMetrics.top) / 2f + offsetPercent * 200,
          paint
      )
  }


  override fun onDetachedFromWindow() {
      super.onDetachedFromWindow()
      animator.end()
  }

  fun plus() {
      if (currentNumber == Int.MAX_VALUE) {
          return
      }
      nextNumber = currentNumber + 1

      processText(findEqualsStringIndex())

      if (animator.isRunning) {
          return
      }
      animator.start()
  }

  fun minus() {
      if (currentNumber == 0) {
          return
      }
      nextNumber = currentNumber - 1
      processText(findEqualsStringIndex())
      if (animator.isRunning) {
          return
      }
      animator.start()
  }

  private fun findEqualsStringIndex(): Int {
      var equalIndex = -1
      val nextNumberStr = nextNumber.toString()
      val currentNumberStr = currentNumber.toString()

      val endIndex = min(currentNumberStr.length, nextNumberStr.length) - 1

      for (index in 0..endIndex) {
          if (nextNumberStr[index] != currentNumberStr[index]) {
              break
          }
          equalIndex = index
      }
      return equalIndex
  }

  private fun processText(index: Int) {
      val currentNumberStr = currentNumber.toString()
      val nextNumberStr = nextNumber.toString()
      if (index == -1) {
          motionLess = ""
          currentMotion = currentNumberStr
          nextMotion = nextNumberStr
      } else {
          motionLess = currentNumberStr.substring(0, index + 1)
          currentMotion = currentNumberStr.substring(index + 1)
          nextMotion = nextNumberStr.substring(index + 1)
      }
  }
}

作者:timer
来源:juejin.cn/post/7179181214530551867

收起阅读 »

kotlin快速实现一款小游戏,糖果雨来啦

前言回想小时候,一到冬天就开始期盼着学校快点放寒假,期盼着快点过年。因为过年有放不完的鞭炮与吃不完的糖果,犹记得那时候我的口袋里总是充满着各式各样的糖果。今天就以糖果为主题,实现糖果雨来啦这个互动小游戏。效果展示开始引导页面糖果收集页面收集结束页面实现细节具体...
继续阅读 »

前言

回想小时候,一到冬天就开始期盼着学校快点放寒假,期盼着快点过年。因为过年有放不完的鞭炮与吃不完的糖果,犹记得那时候我的口袋里总是充满着各式各样的糖果。今天就以糖果为主题,实现糖果雨来啦这个互动小游戏。

效果展示

开始引导页面糖果收集页面收集结束页面



实现细节

具体实现其实也很简单,主要分为3块内容:

  1. 开始引导页面:提供开始按钮来告诉用户如何开始,3秒倒计时动画,让用户做好准备。

  2. 糖果收集页面:自动生成糖果并从上往下掉落,用户点击糖果完成收集(糖果消失 & 糖果收集总数加一)。

  3. 收集结束页面:告诉用户一共收集了多少糖果,提供再玩一次按钮入口。

引导动画

如果单单是一个静态页面,提供文字来提醒用户如何开始游戏,会略显单调,所以我加了一些自定义View动画,模拟点击动作,来达到提醒用户作用。

利用三个动画组合在一起同时执行,从达到该效果,分别是:

  1. 手指移动去点击动画。

  2. 点击后的水波纹动画。

  3. 点击后糖果+1动画。

这里我们以 点击后糖果+1动画 举例。

我们先建一个res/anim/candy_add_anim.xml文件,如下:

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

   <alpha
       android:duration="3000"
       android:fromAlpha="0.0"
       android:repeatCount="-1"
       android:repeatMode="restart"
       android:toAlpha="1.0" />

   <translate
       android:duration="3000"
       android:fromYDelta="0%"
       android:interpolator="@android:anim/accelerate_interpolator"
       android:repeatCount="-1"
       android:repeatMode="restart"
       android:toYDelta="-10%p" />

   <scale
       android:duration="3000"
       android:fromXScale="0"
       android:fromYScale="0"
       android:pivotX="50%"
       android:pivotY="50%"
       android:repeatCount="-1"
       android:repeatMode="restart"
       android:toXScale="1"
       android:toYScale="1" />

</set>

然后在指定的View中执行该动画,如下:

binding.candyAddOneTv.apply {
   val animation = AnimationUtils.loadAnimation(context, R.anim.candy_add_anim)
   startAnimation(animation)
}

糖果的生成

从效果展示图中也可以看出,糖果的样式是各式各样的且其位置坐标是随机的。

我通过代码动态生成一个大小固定的TextView,然后通过设置layoutParams.setMargins来确定其坐标,通过setBackground(drawable)来设置糖果背景(为了使生成的糖果是各式各样的,所以我找了一些糖果的SVG图来作为背景),然后加入到View.root

具体代码如下:

//随机生成X坐标
val leftMargin = (0..(getScreenWidth() - 140)).random()
TextView(this).apply {
   layoutParams = FrameLayout.LayoutParams(140, 140).apply {
       setMargins(leftMargin, -140, 0, 0)
  }
   background = ContextCompat.getDrawable(this@MainActivity, generateRandomCandy())
   binding.root.addView(this)
}

并且通过协程delay(250),来达到一秒钟生成4颗糖果。

fun generatePointViewOnTime() {
   viewModelScope.launch {
       for (i in 1..60) {
           Log.e(TAG, "generatePointViewOnTime: i = $i")
           pointViewLiveData.value = i
           if (i % 4 == 0) {
               countDownTimeLiveData.postValue(i / 4)
          }
           delay(250)
      }
  }

}

糖果的掉落

介绍完了糖果的生成,接着就是糖果的掉落效果实现。

这里我们同样使用View动画即可完成,通过translationY(getScreenHeight().toFloat() + 200)来让糖果从最上方平移出屏幕最下方,同时为其设置加速插值器,达到掉落速度越来越快的效果。

整个平移时间设置为3s,具体代码如下:

private fun startMoving(view: View) {
   view.apply {
       animate().apply {
           interpolator = AccelerateInterpolator()
           duration = 3000
           translationY(getScreenHeight().toFloat() + 200)
           start()
      }
  }
}

糖果的收集

点击糖果,糖果消失,糖果收集总数+1。所以我们只需为其设置点击监听器,在用户点击时,为TextView设置visibility以及catchNumber++即可。

TextView(this).apply {
   ···略···

   setOnClickListener {
       this.visibility = View.GONE
       Log.e(TAG, "onCreate: tag = ${it.tag}, id = ${it.id}")
       catchNumber++
       binding.catchNumberTv.text = getString(R.string.catch_number, catchNumber)
       doVibratorEffect()
  }
}

点击反馈

为了更好的用户体验,为点击设置震动反馈效果。

private fun doVibratorEffect() {
   val vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
       val vibratorManager =
           getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager
       vibratorManager.defaultVibrator
  } else {
       getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
  }

   if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
       vibrator.vibrate(VibrationEffect.createOneShot(30, VibrationEffect.DEFAULT_AMPLITUDE))
  } else {
       vibrator.vibrate(30)
  }
}

结束弹窗

当糖果收集结束后,弹出一个结束弹窗来告诉用户糖果收集情况,这里采用属性动画,让弹窗弹出的效果更加的生动。

private fun showAnimation(view: View) {
   view.scaleX = 0F
   view.scaleY = 0F

   //zoom in 放大;zoom out 缩小;normal 恢复正常
   val zoomInHolderX = PropertyValuesHolder.ofFloat("scaleX", 1.05F)
   val zoomInHolderY = PropertyValuesHolder.ofFloat("scaleY", 1.05F)
   val zoomOutHolderX = PropertyValuesHolder.ofFloat("scaleX", 0.8F)
   val zoomOutHolderY = PropertyValuesHolder.ofFloat("scaleY", 0.8F)
   val normalHolderX = PropertyValuesHolder.ofFloat("scaleX", 1F)
   val normalHolderY = PropertyValuesHolder.ofFloat("scaleY", 1F)
   val zoomIn = ObjectAnimator.ofPropertyValuesHolder(
       view,
       zoomInHolderX,
       zoomInHolderY
  )

   val zoomOut = ObjectAnimator.ofPropertyValuesHolder(
       view,
       zoomOutHolderX,
       zoomOutHolderY
  )
   zoomOut.duration = 400

   val normal = ObjectAnimator.ofPropertyValuesHolder(
       view,
       normalHolderX,
       normalHolderY
  )
   normal.duration = 500

   val animatorSet = AnimatorSet()
   animatorSet.playSequentially(zoomIn, zoomOut, normal)
   animatorSet.start()
}

总结

如果你对该小游戏有兴趣,想进一步了解一下代码,可以参考Github Candy-Catch,欢迎你给我点个小星星。

相信很多人都有这样的感受,随着年龄的增加,越来越觉得这年味越来越淡了,随之而来对过年的期盼度也是逐年下降。在这里,我愿大家童心未泯,归来仍是少年!

最后,给大家拜个早年,祝大家新春快乐

其实分享文章的最大目的正是等待着有人指出我的错误,如果你发现哪里有错误,请毫无保留的指出即可,虚心请教。

另外,如果你觉得文章不错,对你有所帮助,请给我点个赞,就当鼓励,谢谢~Peace~!

作者:Jere_Chen
来源:juejin.cn/post/7054194708410531876

收起阅读 »

运行环信Android Demo常见问题以及语音消息播放声音小的解决方法

运行Demo为什么会下载不下来aar 导致demo的项目无法正常运行打开到 buildgradle , 将MavenCental()至前,在maven库 阿里云和华为里 添加allowInsecureProtocol = true 添加后编译一下 可以在远...
继续阅读 »
运行Demo为什么会下载不下来aar 导致demo的项目无法正常运行
打开到 buildgradle , 将MavenCental()至前,在maven库 阿里云和华为里 添加
allowInsecureProtocol = true
添加后编译一下 可以在远程包里查看下是否下载成功 会 一般是4个aar  
1.easeimkit aar 2.easecallkit 3.easechat 4.rtc 3.6.2aar
1.环信3.9.3 sdk登录慢的问题
初始化打开 options.setFpaEnable(true)(全球加速)
2.播放语音时 语音声音小

1.首先要打开扬声器 如果觉得声音还是比较小
2.将ui库中调用的原声音量模式修改为媒体音量模式

3.发送语音、视频、文件体积超过10MB
相机是直接调用的系统的,跟随的是系统的大小,我拍摄15s视频大概18m左右。环信系统默认的是只能发送10M的视频文件,可以联系商务经理开通上调发送体积
4.关于百度地图切换至高德地图
demo中百度地图的so库是放在项目层的

1.因为百度地图将easeimkit中关于百度地图的集成去掉,改成高德地图;2.在chatfragment中重写位置的点击事件方法startMapLocation或者是直接在EaseChatFragment中直接修改点击事件startMapLocation跳转到高德地图;3.在调用环信api去发送地理位置消息时,传入高德获取到的经纬度
2.点击位置的点击事件更换 ,demo中的点击事件是在EaseChatFragment下的onExtendMenuItemClick里面官方提供了EaseBaiduMapActivity 这个定位页面。2.修改为高德其实非常简单只需要在ChatFragment操作就可以了2.1修改点击事件在ChatFragment的onExtendMenuItemClick方法中添加2.2 在自己实现高德地图的页面返回定位信息 参数名称不要修改 不然其它地方也要修改2.3接下来在ChatFragment中的onActivityResult中接收定位信息并发送消息走到这里从高德获取的位置消息已经成功发送给好友了 接下来是获取查看好友位置消息2.4 查看位置消息还是在ChatFragment里 通过getCustomChatRow方法LoccationAdapter 继承位置消息展示 重写了点击事件即可。
5.语音消息amr格式转为MP3格式
需要本地库倒入easeimkit

收起阅读 »

货拉拉SSL证书踩坑之旅

一、背景简介1、遇到的问题2020年,货拉拉运营部门和客户端开发对齐了https网络通信协议中的SSL网络证书校验方案;但是由于Android客户端的证书配置不规范,导致在客户端内置的SSL网络证书到期前十几天被发现证书校验异常,Android客户端面临全网访...
继续阅读 »

img

一、背景简介

1、遇到的问题

2020年,货拉拉运营部门和客户端开发对齐了https网络通信协议中的SSL网络证书校验方案;但是由于Android客户端的证书配置不规范,导致在客户端内置的SSL网络证书到期前十几天被发现证书校验异常,Android客户端面临全网访问异常的问题

2、本文内容

本文主要介绍解决货拉拉Android客户端SSL证书到期的解决方案及Android端SSL证书相关知识

二、SSL证书简介

1、SSL证书诞生背景

1994年,Netscape公司首先使用了SSL协议,SSL协议全称为:安全套接层协议(Secure Sockets Layer),它指定了在应用程序协议(如HTTP、Telnet、FTP)和TCP/IP之间提供数据安全性分层的机制,它是在传输通信协议(TCP/IP)上实现的一种安全协议,采用公开密钥技术,它为TCP/IP连接提供数据加密、服务器认证、消息完整性以及可选的客户端认证。由于SSL协议很好地解决了互联网明文传输的不安全问题,很快得到了业界的支持,并已经成为国际标准

HyperText Transfer Protocol over Secure Socket Layer。在HTTPS中,使用传输层安全性(TLS)或安全套接字层(SSL)对通信协议进行加密。也就是HTTP+SSL(TLS)=HTTPS

img

2、SSL证书简介

按类型划分,SSL证书包括CA证书、用户证书两种

(1)CA证书(Certification Authority证书颁发机构)

证书的签发机构(CA)颁发的电子证书,包含根证书和中间证书两种

[i]根证书

属于根证书颁发机构(CA)的公钥证书,是在公开密钥基础建设中,信任链的起点

一般客户端会内置

[ii]中间证书

因为根证书太宝贵了,直接颁发风险太大了。因此,为了保护根证书,CAs通常会颁发所谓的中间证书。CA使用它的私钥对中间证书签名,使它受到信任。然后CA使用中间证书的私钥签署和颁发终端用户SSL证书。这个过程可以执行多次,其中一个中间根对另一个中间根进行签名

(2)用户证书

用户证书是由CA中间证书签发给用户的证书,包含服务器证书、客户端证书

[i]服务器证书

组成Web服务器的SSL安全功能的唯一的数字标识。 通过CA签发,并为用户提供验证您Web站点身份的手段。

服务器证书包含详细的身份验证信息,如服务器内容附属的组织、颁发证书的组织以及称为公开密钥的唯一的身份验证文件

[ii]客户端证书

在双向https验证中,就必须有客户端证书,生成方式同服务器证书一样;

单向证书则不用生成

3、SSL证书链

SSL证书链是从用户证书、生成用户证书的CA中间证书、生成CA中间证书的CA中间证书...一直到CA根证书;其中根证书只能有一个,但是CA中间证书可以有多个

(1)以baidu的证书为例

img

(2)证书链

客户端(比如浏览器或者Android手机)验证我们SSL证书的有效性的时候,会一层层的去寻找颁发者的证书,直到自签名的根证书,然后通过相应的公钥再反过来验证下一级的数字签名的正确性

任何数字证书都必须要有根证书做支持,有了根证书的支持才说明这个数字证书是有效的是被信任的

img

4、SSL证书文件的后缀

证书的后缀主要有.key、.csr、.crt、.pem等

(1).key文件:密钥文件,SSL证书的私钥就包含在其中

(2).csr文件:这个文件里面包含着证书的公钥和其他一些公司信息,通过请求签名之后就可以直接生出证书

(3).crt文件:该文件中也包含了证书的公钥、签名信息以及根据不同类型证书携带不同的认证信息,如IP等(该文件在有些机构、系统中也可能表现为.cert后缀)

(4).pem文件:该文件相对比较少见,里面包含着证书的私钥以及部分证书信息

5、SSL用户证书类型

SSL用户证书主要分为(1)DV SSL证书 (2)OV SSL证书 (3)EV SSL证书

(1)DV SSL证书(域名验证型):只需验证域名所有权,无需人工验证申请单位真实身份,几分钟就可颁发的SSL证书。价格一般在百元至千元左右,适用于个人或者小型网站

(2)OV SSL证书(企业验证型):需要验证域名所有权以及企业身份信息,证明申请单位是一个合法存在的真实实体,一般在1~5个工作日颁发。价格一般在百元至几千元左右,适用于企业型用户申请

(3)EV SSL证书(扩展验证型):除了需要验证域名所有权以及企业身份信息之外,还需要提交一下扩展型验证,通常CA机构还会进行电话回访,一般在2~7个工作日颁发证书。价格一般在千元至万元左右,适用于在线交易网站、企业型网站

6、SSL证书结构

img

7、SSL证书查看

以Chorme上的baidu为例:

第1步

img

第2步

img

第3步

img

三、客户端SSL证书校验流程

1、客户端SSL证书校验主要是在网络连接的SSL/TLS握手环节校验

SSL/TLS握手(用非对称加密的手段传递密钥,然后用密钥进行对称加密传递数据)

img

校验流程主要在上述过程的第三步和第六步

第三步:Certificate

Server——>Client 服务端下发公钥证书

第六步:证书合法性校验

Client 对 Server下发的公钥证书进行合法性校验

2、客户端证书校验过程

img

(1)校验证书是否是受信任的CA根证书颁发机构颁发

客户端通过服务器证书 中签发机构信息,获取到中间证书公钥;利用中间证书公钥进行服务器证书的签名验证

a、中间证书公钥解密 服务器签名,得到证书摘要信息;

b、摘要算法计算 服务器证书 摘要信息;

c、然后对比两个摘要信息。

客户端通过中间证书中签发机构信息,客户端本地查找到根证书公钥;利用根证书公钥进行中间证书的签名验证

(2)客户端校验服务端证书公钥及摘要信息

客户端获取到服务端的公钥:Https请求 TLS握手过程中,服务器公钥会下发到请求的客户端。

客户端用存储在本地的CA机构的公钥,对服务端公钥中对应的摘要信息进行解密,获取到服务端公钥的摘要信息A;

客户端根据对服务端公钥进行摘要计算,得到摘要信息B;

对比摘要信息A与B,相同则证书验证通过

(3)校验证书是否在上级证书的吊销列表

若证书的申请主体出现:私钥丢失、申请证书无效等情况,CA机构需要废弃该证书

(详细策略见《四、Android端证书吊销校验策略》)

(4)校验证书是否过期

校验证书的有效期是否已经过期:主要判断证书中Validity period字段是否过期(ps:Android系统默认不校验证书有效期,但浏览器和ios系统默认会校验证书有效期)

(5)校验证书域名是否一致

校验证书域名是否一致:核查 证书域名*是否与当前的*访问域名 匹配

比如:我们请求的域名 http://www.huolala.cn 是否与证书文件DNS标签下所列的域名匹配

img

四、Android端证书吊销校验策略

1、证书吊销校验主要存在两类机制:CRL 与 OCSP

(1)证书吊销列表校验:CRL(Certificate Revocation List)

证书吊销列表:是一个单独的文件,该文件包含了 CA机构 已经吊销的证书序列号与吊销日期;

证书中一般会包含一个 URL 地址 CRL Distribution Point,通知使用者去哪里下载对应的 CRL 以校验证书是否吊销。

该吊销方式的优点是不需要频繁更新,但是不能及时吊销证书,这期间可能已经造成了极大损失

(2)证书状态在线查询:OCSP(Online Certificate Status Protocol)

证书状态在线查询协议:一个实时查询证书是否吊销的方式。

请求者发送证书的信息并请求查询,服务器返回正常、吊销或未知中的任何一个状态。

证书中一般也会包含一个 OCSP 的 URL 地址,要求查询服务器具有良好的性能。

部分 CA 或大部分的自签 CA (根证书)都是未提供 CRL 或 OCSP 地址的,对于吊销证书会是一件非常麻烦的事情

2、Android系统默认使用CRL方式来校验证书是否被吊销

核心实现类是CertBlocklistImpl(维护了本地黑名单列表),部分源码逻辑如下:

(1)TrustManagerImpl(证书校验核心类)

第1步循环校验信任证书

img

第2步检查该证书是否在黑名单列表里面

img

(2)CertBlocklistImpl(证书黑名单列表维护类)

黑名单校验逻辑:主要检查是否在黑名单列表里面

img

黑名单本地存储位置

img

可以看到黑名单文件储存在环境变量“ANDROID_DATA”/misc/keychain/pubkey_blacklist.txt;

可以通过adb shell--export--echo $ANDROID_DATA,拿到环境变量位置,一般在/data目录下

3、Android端自定义证书吊销校验逻辑

核心类在TrustManagerFactory、CertPathTrustManagerParameters、PKIXRevocationChecker

(1)TrustManagerFactory工厂模式的证书管理类

有两种init方式

[i]init(KeyStore ks) 默认使用

传递私钥,一般传递系统默认或者传空

以okhttp为例(默认传空)

img

[ii]init(ManagerFactoryParameters spec) 自定义方式

下面介绍下通过自定义方式来实现OCSP方式校验证书是否吊销

4、基于PKIXRevocationChecker方式自定义OCSP方式

(1)自定义TrustManagerFactory.init(ManagerFactoryParameters spec)

init方法传入基于CertPath的TrustManagerCertPathTrustManagerParameters,包装策略PKIXRevocationChecker

img

(2)PKIXRevocationChecker(用于检查PKIX算法的证书撤销状态)

默认使用OCSP方式校验,可以自定义使用OCSP策略还是CLR策略

参考谷歌开发者文档:developers.google.cn/j2objc/java…

img

五、Android端证书校验方式

主要有四种校验方式:

客户端单向认证服务端---证书锁定

客户端单向认证服务端---公钥锁定

客户端服务端双向认证

客户端信任所有证书

1、客户端单向认证服务端---证书锁定

(1)校验过程

校验服务端证书的subject信息和publickey信息是否与客户端内置证书一致,如果不一致会报错:

“java.security.cert.CertPathValidatorException: Trust anchor for certification path not found”

(2)实现方式

[i]network-security-config配置方式

(生效范围:app全局,包含webview请求)

(只支持android7.0及以上)

img

[ii]代码配置方式(生效范围:配置了该SSLParams的实例)

img

(3)优点

校验了subject信息和publickey信息,防信息篡改的安全等级高一点

(4)缺点

[i]因为一般网络证书的有效期是1-2年,所以面临过期之后可能校验异常的问题(ps:本次货拉拉客户端遇到的就是这种内置的网络证书快到期的case)

[ii]内置在app里面,证书容易泄漏

2、客户端单向认证服务端---公钥锁定

(1)校验过程

校验服务端证书的公钥信息是否与客户端内置证书的一致

(2)实现方式

[i]network-security-config配置方式

(生效范围:app全局,包含webview请求)

(只支持android7.0及以上)

img

[ii]代码配置方式(生效范围:配置了该参数的实例)

img

(3)优点

只要服务端的公钥保持不变,更换证书也能通过校验

(4)缺点

只校验了公钥,防信息篡改的安全等级低一点

3、客户端和服务端双向认证

(1)实现方式

自定义的SSLSocketFactory实现客户端和服务端双向认证

public class SSLHelper {

  /** * 存储客户端自己的密钥 */ private final static String CLIENT_PRI_KEY = "client.bks";

  /** * 存储服务器的公钥 */ private final static String TRUSTSTORE_PUB_KEY = "publickey.bks";

  /** * 读取密码 */ private final static String CLIENT_BKS_PASSWORD = "123321";

  /** * 读取密码 */ private final static String PUCBLICKEY_BKS_PASSWORD = "123321";

  private final static String KEYSTORE_TYPE = "BKS";

  private final static String PROTOCOL_TYPE = "TLS";

  private final static String CERTIFICATE_STANDARD = "X509";

  public static SSLSocketFactory getSSLCertifcation(Context context) {

      SSLSocketFactory sslSocketFactory = null;

      try {
          // 服务器端需要验证的客户端证书,其实就是客户端的keystore
          KeyStore keyStore = KeyStore.getInstance(KEYSTORE_TYPE);
          // 客户端信任的服务器端证书
          KeyStore trustStore = KeyStore.getInstance(KEYSTORE_TYPE);

          //读取证书
          InputStream ksIn = context.getAssets().open(CLIENT_PRI_KEY);
          InputStream tsIn = context.getAssets().open(TRUSTSTORE_PUB_KEY);

          //加载证书
          keyStore.load(ksIn, CLIENT_BKS_PASSWORD.toCharArray());
          trustStore.load(tsIn, PUCBLICKEY_BKS_PASSWORD.toCharArray());

          //关闭流
          ksIn.close();
          tsIn.close();

          //初始化SSLContext
          SSLContext sslContext = SSLContext.getInstance(PROTOCOL_TYPE);
          TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance(CERTIFICATE_STANDARD);
          KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance(CERTIFICATE_STANDARD);

          trustManagerFactory.init(trustStore);
          keyManagerFactory.init(keyStore, CLIENT_BKS_PASSWORD.toCharArray());

          sslContext.init(keyManagerFactory.getKeyManagers(), trustManagerFactory.getTrustManagers(), null);
          sslSocketFactory = sslContext.getSocketFactory();
      } catch (KeyStoreException e) {
          e.printStackTrace();
      } catch (IOException e) {
          e.printStackTrace();
      } catch (CertificateException e) {
          e.printStackTrace();
      } catch (NoSuchAlgorithmException e) {
          e.printStackTrace();
      } catch (UnrecoverableKeyException e) {
          e.printStackTrace();
      } catch (KeyManagementException e) {
          e.printStackTrace();
      }
      return sslSocketFactory;
  }

}

(2)优点

双向校验更安全

(3)缺点

需要服务端支持,TLS/SSL握手耗时增长

4、客户端信任所有证书

不检验任何证书,下面列两种常见的实现方式

(1)OkHttp版本

img

(2)HttpURLConnection版本

img

六、Android端一种源码调试的方式

背景:由于证书校验相关源码不在Android.jar中,为了方便调试证书校验的流程,这里简单介绍一种非android.jar包中的Android源码调试的方式

1、下载源码

(1)源码地址:android.googlesource.com/

android官方提供了各个模块的git仓库地址

img

(2)以SSL证书调试为例

我们只需要conscrypt部分的源码:android.googlesource.com/platform/ex…

注意点:选择的分支要和被调试的手机版本一致(因为不同系统版本下源码有点区别)

如果测试及时Android10.0系统,我们可以选择android10-release分支

img

2、源码导入

新建一个module 把刚才的系统源码复制进来,不需要依赖,只需要在setting.gradle中include,这样做隔离性好,方便移除

img

3、源码编译

导入源码之后,可能会有部分编译问题,可以解决的可以先解决,如果解决不了可以先注释;

需要注意点:

(1)不能修改行号,否则调试的时候走不到

(2)不能新增代码,新增的代码不会执行

4、断点调试

打好断点就可以发车了

可以看到app发起网络请求之后会走到TrustManagerImpl里面的checkServerTrusted校验服务端证书

img

七、Android端证书校验源码解析

1、证书校验主要分3步

(1)握手过程中验证证书

验证证书合法性,判断是否由合法的CA签发,由上面的Android系统根证书库来判断

img

(2)验证域名

判断服务端证书是否为特定域名签发,验证网站身份,这里如果出错就会抛出

SSLPeerUnverifiedException的异常

img

(3)验证证书绑定

img

2、Android根证书相关源码

Android会内置常用的根证书,系统根证书存放在/system/etc/security/cacerts 目录下,文件均为 PEM 格式的 X509 证书格式,包含明文base64编码公钥,证书信息,哈希等

Android系统的根证书管理类

位于/frameworks/base/core/java/android/security/net/config 目录下

以下是根证书管理类的类关系图

img

(1)CertificateSource

接口类,定义了对根证书可执行的获取和查询操作

img

有三个实现类,分别是KeyStoreCertificateSource、ResourceCertificateSource、DirectoryCertificateSource

(2)KeyStoreCertificateSource

从 KeyStore 中获取证书

img

(3)ResourceCertificateSource

基于 ResourceId 从资源目录读取文件并构造证书

img

(4)DirectoryCertificateSource(抽象类)

遍历指定的目录 mDir 读取证书;还提供了一个抽象方法 isCertMarkedAsRemoved() 用于判断证书是否被移除

img

SystemCertificateSourceUserCertificateSource 继承了DirectoryCertificateSource并且分别定义了系统和用户根证书库的路径,并实现抽象方法

[i]SystemCertificateSource

定义了系统证书查询路径,并且还指定了被移除的证书文件的目录

img

判断证书是否移除就是直接判断证书文件是否存在于指定的目录

img

[ii]UserCertificateSource

定义了用户证书指定查询路径,证书是否移除永远为false

img

3、Android证书校验源码

(以证书锁定方式的单向校验服务端证书为例)

核心类TrustManagerImpl、TrustedCertificateIndex、X500Principal

(1)第一步checkServerTrusted()

img

(2)第二步checkTrusted()

img

(3)第三步TrustedCertificateIndex类匹配证书issuer和signature信息

private final Map<X500Principal, List> subjectToTrustAnchors

= new HashMap<X500Principal, List>();

可以看到获取TrustAnchor是通过HashMap的key X500Principal匹配获取的,

img

(4)X500Principal

private transient X500Name thisX500Name;

查看X500Principal的源码可以看到它覆写了equals()方法,对比的是属性中的thisX500Name

调试下来发现我们客户端证书的 thisX500Name 的值为

“CN=*. huolala.cn , OU=IT, O=深圳货拉拉科技有限公司, L=深圳市, ST=广东省, C=CN”

(ps:后面会提到,货拉拉客户端证书异常主要因为新证书缺少了OU字段)

img

(5)subject和issue信息

img

八、货拉拉SSL证书踩坑流程

1、背景简介

2020年7月份的时候,货拉拉出现了因为网络证书过期导致的异常,所以运维的同事拉了客户端的同事一起对齐了方案,使用上述《客户端单向认证服务端---公钥锁定》的方式

由于历史原因:

货拉拉用户端使用了上述(三、1(2)客户端单向认证服务端---证书锁定,代码配置方式)

货拉拉司机端使用了上述(三、1(1)客户端单向认证服务端---证书锁定,network-security-config配置方式)

2021年7月份的时候,运维同事更新了服务端的证书,因为更换过程中没有出现异常,所以运维的同事以为android端都是按照之前约定的《客户端单向认证服务端---公钥锁定》方式

(但实际原因是用户和司机端提前内置了2022-8-19过期的证书)

2、线上出现异常

2022-8-1的时候,运维同事开始操作更新服务端2023年的证书,在更新了H5部分域名的证书之后,司机Android端出现部分网页白屏的问题

排查之后发现服务端更新了证书导致客户端证书校验证书非法导致异常

2022-8-2的时候开始排查用户端的逻辑,发现是《客户端单向认证服务端---证书锁定,代码配置方式》,测试之后发现

(1)删除app内置2022年的证书,只保留2020年的证书之后,native请求异常,无法进入app

(2)手动调整手机设备时间,发现native请求正常,webview白屏和图片加载失败

意味着在服务端更换的证书2022-8-19到期之后,客户端将面临全网访问异常的问题

3、第一次尝试解决

测试的时候发现,android端在证书过期时仍然可以访问服务端(客户端和服务端都保持一致的2022年的证书);

所以想的第1个解决方案是服务端仍然使用2022-8-19的证书,直到大部分用户升级上来之后再更换新证书;

但是ios和web发现如果服务端使用过期证书的情况,系统底层会拦截这个过期证书直接报错;

所以无法兼容所有客户端

4、第二次尝试解决

在查看源码TrustManagerImpl类源码的时候发现,TrustManagerImpl的服务端检验只是校验了publickey(公钥),所以如果2022年的旧证书和2023年的新证书如果公钥一致的话,可能可以校验通过;

所以想的第2个解决方案是服务端使用的新证书保持和2022-8-19的证书的公钥一致就可以;

但是测试的时候发现native请求还是会报错

“java.security.cert.CertPathValidatorException: Trust anchor for certification path not found”

5、第三次尝试解决

开发发现按照证书链的校验过程,如下:

img

如果有中间证书,那么这个中间证书机构颁发的任何服务器证书都可以都校验通过;

所以想出的第3个解决方案是服务器证书内置中间证书组成证书链;

但是排查之后发现服务器证书和客户端内置的证书里面都已经包含了中间证书,所以依然行不通

(ps:如果客户端内置的证书里面删除用户证书信息,只保留中间证书信息,那么只要是这家中间证书颁发的所有的服务器证书都是可以校验通过的,而且一般中间证书的有效期是10年,这也可以作为一个备选项,不过缺点是不安全)

6、第四次尝试解决

(1)测试同学在网上找到一篇《那些年踩过HTTPS的坑(二)——APP证书链mp.weixin.qq.com/s/yv_XcMLvr…

所以想到的解决方案是重新申请一个带OU字段的新服务器证书

(2)但是运维同事咨询了两家之前的中间商之后对方的回复都是新的证书已经不再提供OU字段,理由是

img

img

(3)最后历经一言难尽的各种插曲最后找UniTrust颁发了带OU字段的新证书

(ps:还在使用证书锁定方式校验的可以留意下证书里面的OU字段,后续证书都不会再提供)

九、Android端证书校验的解决方案

1、认证方式

按照安全等级划分,从高到低依次为:

(1)客户端和服务端双向认证,参考上述《五、Android端证书校验方式-3、客户端和服务端双向认证》

(2)客户端单向认证服务端---证书锁定,参考上述《五、Android端证书校验方式-1、客户端单向认证服务端---证书锁定》

(3)客户端单向认证服务端---公钥锁定,参考上述《五、Android端证书校验方式-2、客户端单向认证服务端---公钥锁定》

可以根据各自的安全需求选择合适的认证方式

2、校验方式

(1)证书校验

具体方式参考《五、Android端证书校验方式-1、客户端单向认证服务端---证书锁定》;

为了增强安全性,app可以内置加密后的证书,将解密信息存放在加固后的c++端,增强安全性

(2)公钥校验

具体方式参考《五、Android端证书校验方式-2、客户端单向认证服务端---公钥锁定》;

为了增强安全性,app可以内置加密后的公钥,将解密信息存放在加固后的c++端,增强安全性

3、配置降级

为了在出现异常情况时不影响app访问,可以添加动态配置和动态降级能力

(1)动态配置

动态下发公钥和证书信息,需要留意下发的时机要尽量早一点,避免证书异常时走不到下发的请求

(2)动态降级

动态降级证书校验功能,在客户端证书或者服务端证书出现异常时,支持动态关闭所有的证书校验的功能

十、总结

最后,总结一下整体的思路:

1、SSL证书分为CA证书和用户证书

2、客户端SSL证书校验是在网络连接的SSL/TLS握手环节进行校验

3、SSL证书的认证方式分为(1)单向认证(2)双向认证

4、SSL证书的校验方式分为(1)证书校验(2)公钥校验

5、SSL证书的校验流程主要是校验证书是否是由受信任的CA机构签发的合法证书

6、SSL证书的吊销校验策略分为(1)CRL本地校验证书吊销列表(2)OCSP证书状态在线查询

7、纵观本次踩坑之旅,也暴露出一个比较深刻的问题:大部分的客户端开发的认知还是停留在app上层,缺少对底层技术的认识和探索,导致一个很小的配置问题差点酿成大的事故;这也为想在客户端领域进一步提升提供了一个思路:多学习客户端的底层技术,包含网络底层实现、安全、系统底层源码等等

8、最后,解决技术类问题最核心的点还是学习和熟悉源代码;解决证书配置问题的过程中,走了不少弯路,本质上是最开始没有彻底掌握证书校验相关的系统源代码的逻辑,客观上是由于缺少非android.jar源码的调试手段导致阅读源码遗漏了部分校验逻辑,所以本次特意补上(六、Android端一种源码调试的方式),希望后续遇到系统级的疑难杂症可以用的上

参考:

http://www.cnblogs.com/xiaxveliang…

blog.csdn.net/weixin_3501…

作者:货拉拉技术
来源:https://juejin.cn/post/7186837003026038843

收起阅读 »

Android 中关于枚举的优化

概述Android 中使用 Kotlin 枚举 + when、java 枚举时,源代码编译后会产生额外的产物,进而带来一些额外开销,本文讲述了 Android 对枚举使用的优化的讲解和解决办法。参考ProGuard 的优化列表:http://www.guard...
继续阅读 »

概述

Android 中使用 Kotlin 枚举 + when、java 枚举时,源代码编译后会产生额外的产物,进而带来一些额外开销,本文讲述了 Android 对枚举使用的优化的讲解和解决办法。

参考

枚举的开销

详情描述

eg: 使用 enum 定义枚举类 ClazzEnum.

public enum ClazzEnum {
   ONE, TWO
}

enum 标识符声明的枚举类 ClazzEnum 默认继承自 java.lang.Enum, 每个枚举类成员默认都是 public static final 修饰,每个枚举常量都相当于是一个 ClazzEnum 对象,而 Enum 默认实现已经声明了一些枚举属性,所以枚举通常会比静态常量多两倍以上的内存占用,所以在过去 Android 中不推荐使用枚举。

解决办法

  1. 启用 R8 编译优化;

  2. 使用静态常量或TypeDef注解替换枚举;

R8 编译优化

R8 编译优化枚举,解决枚举造成的额外开销;

Android Studio 3.4.0+ 以后,在 build.gradle 编译配置中通过 minifyEnabled=true 开启 R8 编译优化,R8 会直接调用枚举的序数值(ordinal),在编译的时候将琐碎的枚举优化为整型,避免枚举造成的额外开销。

kotlin/java 代码的编译

为了更好的理解 R8 对枚举的优化,我们简单了解下kotlin/java 代码的编译流程。

在 Android 应用中,kotlin/java 代码的编译流程:

  1. kotlin/javac 编译器编译源代码文件为 java 字节码

    kotlin/javac 编译器会将代码转换为 java 字节码,Android 设备并不直接运行 Java 字节码,而是运行名为 DEX 的 Dalvik 可执行文件;

  2. D8 编译器将 java字节码转为 DEX 代码

  3. R8 (可选项,推荐 release 使用)优化:

    R8 在 build.gradle 中通将 minifyEnabled 设为 true 来开启,它将在所有其他编译工作后执行,来保证您获得的是一个缩减和优化过的应用。

Kotlin: 枚举+when

问题描述

在 Kotlin 中使用枚举时,也仅仅是将其转换为 Java 编程语言中的枚举而已,本身并不包含任何隐藏开销。但当 枚举+when 配合使用时,就会引入额外的开销。

我们举个例子:

package enums

fun main() {
   val age: Int = getAge(People.CHILD);
   println("ret: ${age}")
}

fun getAge(p: People): Int {
   return when (p) {
       People.ADULT -> 30
       People.CHILD -> 18
  }
}

enum class People {
   ADULT,
   CHILD
}

查看上述代码编译后的字节码:

# 查看字节码
# 方式一:IDEA(可能有些地方编译失败)
IDEA/AndroidStudio -> Tools -> Kotlin -> Show Kotlin Bytecode -> Decompile
# 方式二:kotlinc + JD-GUI
$ kotlinc test.kt -include-runtime -d ret.jar
// 编译后的字节码
public final class TestKt$WhenMappings {
  public static final int[] $EnumSwitchMapping$0 = new int[People.values().length];

  static {
     $EnumSwitchMapping$0[People.ADULT.ordinal()] = 1;
     $EnumSwitchMapping$0[People.CHILD.ordinal()] = 2;
  }
}

@Metadata(...)
public final class TestKt {
public static final void main() {
  int age = getAge(People.CHILD);
  String str = Intrinsics.stringPlus("ret: ", Integer.valueOf(age));
  boolean bool = false;
  System.out.println(str);
}
 
public static final int getAge(@NotNull People p) {
  Intrinsics.checkNotNullParameter(p, "p");
  People people = p;
  int i = WhenMappings.$EnumSwitchMapping$0[people.ordinal()];
  switch (i) {
    case 1:
     
    case 2:
     
  }
  throw new NoWhenBranchMatchedException();
}
}

在上述编译后的代码中可以发现,当使用 when 语句接受枚举作为参数时,编译后 when 转换成的 switch 并没有让 switch 语句直接接受枚举,而是接受了 p 枚举对应 ordinal 作为索引对应 TestKt$WhenMappings 数组中的元素值作为参数。

可以发现使用 when 语句时,编译后产物中会生成 TestKt$WhenMappings类,这个类里面有一个存储映射信息的数组 $EnumSwitchMapping$0,接下来则是一些执行映射操作的静态代码。

示例中是只有一个 when 语句时的情况,如果我们写了更多的 when 语句,那么每个 when 语句都会在 TestKt$WhenMappings类中生成一个对应的数组,即使这些 when 语句都在使用同一个枚举也一样。所以这就意味着,在您不知情的时候,会生成一个类,而且其中还包含了一些数组,这些都会让类加载和实例化消耗更多的时间。

解决办法

  1. Kotlin 中枚举可以用 Sealed Class 密封类替代;

  2. 启用 Android R8 编译会自动优化,避免生成类和映射数组,而且只会创建了您所需的最佳代码;

    // 启用 R8 编译优化后,会直接把 when 转为 switch, 并接收 Enum#ordinal 作为参数;
    public static final int getAge(@NotNull People p) {
       switch (p.ordinal()) {
           case 0:
           // ...
      }
    }

作者:呛呛cei
来源:juejin.cn/post/7070074670036287496

收起阅读 »

Android FCM接入

消息推送在现在的App中已经十分常见,我们经常会收到不同App的各种消息。消息推送的实现,国内与海外发行的App需要考虑不同的方案。国内发行的App,常见的有可以聚合各手机厂商推送功能的极光、个推等,海外发行的App肯定是直接使用Firebase Cloud ...
继续阅读 »

消息推送在现在的App中已经十分常见,我们经常会收到不同App的各种消息。消息推送的实现,国内与海外发行的App需要考虑不同的方案。国内发行的App,常见的有可以聚合各手机厂商推送功能的极光、个推等,海外发行的App肯定是直接使用Firebase Cloud Message(FCM)。

下面介绍下如何接入FCM与发送通知。

发送通知

FCM的SDK不包含创建和发送通知的功能,这部分需要我们自己实现。

在 Android 13+ 上请求运行时通知权限

Android 13 引入了用于显示通知的新运行时权限。这会影响在 Android 13 或更高版本上运行的所有使用 FCM 通知的应用。需要动态申请POST_NOTIFICATIONS权限后才能推送通知,代码如下:

class ExampleActivity : AppCompatActivity() {

   private val requestPermissionCode = this.hashCode()

   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
           if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
               ActivityCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
               // 申请通知权限
               ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.POST_NOTIFICATIONS), requestPermissionCode)
          }
  }

   override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
       super.onRequestPermissionsResult(requestCode, permissions, grantResults)
           if (requestCode == requestPermissionCode) {
               // 处理回调结果
          }
  }
}

创建通知渠道

从 Android 8.0(API 级别 26)开始,必须为所有通知分配渠道,否则通知将不会显示。通过将通知归类到不同的渠道中,用户可以停用您应用的特定通知渠道(而非停用您的所有通知),还可以控制每个渠道的视觉和听觉选项。

创建通知渠道代码如下:

class ExampleActivity : AppCompatActivity() {

   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       val notificationManager = NotificationManagerCompat.from(this)
       if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
           val applicationInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
               packageManager.getApplicationInfo(packageName, PackageManager.ApplicationInfoFlags.of(0))
          } else {
               packageManager.getApplicationInfo(packageName, 0)
          }
           val appLabel = getText(applicationInfo.labelRes)
           val exampleNotificationChannel = NotificationChannel("example_notification_channel", "$appLabel Notification Channel", NotificationManager.IMPORTANCE_DEFAULT).apply {
               description = "The description of this notification channel"
          }
           notificationManager.createNotificationChannel(minigameChannel)
      }
  }
}

创建并发送通知

创建与发送通知,代码如下:

class ExampleActivity : AppCompatActivity() {

   private var notificationId = 0

   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate()
       val notificationManager = NotificationManagerCompat.from(this)
      ...
       if (notificationManager.areNotificationsEnabled()) {
           val notification = NotificationCompat.Builder(this, "example_notification_channel")
               //设置小图标
              .setSmallIcon(R.drawable.notification)
               // 设置通知标题
              .setContentTitle("title")
               // 设置通知内容
              .setContentText("content")
               // 设置是否自动取消
              .setAutoCancel(true)
               // 设置通知声音
              .setSound(RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION))
               // 设置点击的事件
              .setContentIntent(PendingIntent.getActivity(this, requestCode, packageManager.getLaunchIntentForPackage(packageName)?.apply { putExtra("routes", "From notification") }, PendingIntent.FLAG_IMMUTABLE))
              .build()
           // notificationId可以记录下来
           // 可以通过notificationId对通知进行相应的操作
           notificationManager.notify(notificationId, notification)
      }
  }
}

注意,smallIcon必须设置,否则会导致崩溃。***

FCM

Firebase Cloud Message (FCM) 是一种跨平台消息传递解决方案,可让您免费可靠地发送消息。

官方接入文档

集成FCM

在项目下的build.gradle中添加如下代码:

buildscript {

   repositories {
       google()
       mavenCentral()
  }

   dependencies {
      ...
       classpath("com.google.gms:google-services:4.3.14")
  }
}

在app module下的build.gradle中添加代码,如下:

dependencies {
   // 使用Firebase Andorid bom(官方推荐)
   implementation platform('com.google.firebase:firebase-bom:31.1.0')
   implementation 'com.google.firebase:firebase-messaging'
   
   // 不使用bom
   implementation 'com.google.firebase:firebase-messaging:23.1.1'
}

在Firebase后台获取项目的google-services.json文件,放到app目录下


要接收FCM的消息推送,需要自定义一个Service继承FirebaseMessagingService,如下:

class ExampleFCMService : FirebaseMessagingService() {

   override fun onNewToken(token: String) {
       super.onNewToken(token)
       // FCM生成的令牌,可以用于标识用户的身份
  }

   override fun onMessageReceived(message: RemoteMessage) {
       super.onMessageReceived(message)
       // 接收到推送消息时回调此方法
  }

在AndroidManifest中注册Service,如下:

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
   <application>
       <service
           android:name="com.minigame.fcmnotificationsdk.MinigameFCMService"
           android:exported="false">
           <intent-filter>
               <action android:name="com.google.firebase.MESSAGING_EVENT" />
           </intent-filter>
       </service>
   </application>
</manifest>

通知图标的样式

当App处于不活跃状态时,如果收到通知,FCM会使用默认的图标与颜色来展示通知,如果需要更改的话,可以在AndroidManifest中通过meta-data进行配置,代码如下:

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
   <application>
       <!--修改默认图标-->
       <meta-data
           android:name="com.google.firebase.messaging.default_notification_icon"
           android:resource="@drawable/notification" />

       <!--修改默认颜色-->
       <meta-data
           android:name="com.google.firebase.messaging.default_notification_color"
           android:resource="@color/color_blue_0083ff" />
   </application>
</manifest>

修改前:


修改后:


避免自动初始化

如果有特殊的需求,不希望FCM自动初始化,可以通过在AndroidManifest中配置meta-data来实现,代码如下:

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
   <application>
       <meta-data
           android:name="firebase_messaging_auto_init_enabled"
           android:value="false" />
       
       <!--如果同时引入了谷歌分析,需要配置此参数-->
       <meta-data
           android:name="firebase_analytics_collection_enabled"
           android:value="false" />
   </application>
</manifest>

需要重新启动FCM自动初始化时,更改FirebaseMessagingisAutoInitEnabled的属性,代码如下:

FirebaseMessaging.getInstance().isAutoInitEnabled = true
// 如果同时禁止了Google Analytics,需要配置如下代码
FirebaseAnalytics.getInstance(context).setAnalyticsCollectionEnabled(true)

调用此代码后,下次App启动时FCM会自动初始化。

测试消息推送

在Firebase后台中,选择Messageing,并点击制作首个宣传活动,如图:


选择Firebase 通知消息,如图:


输入标题和内容后,点击发送测试消息,如图:


输入在FirebaseMessagingService的onNewToken方法中获取到的token,并点击测试,如图:


示例

已整合到demo中。

ExampleDemo github

ExampleDemo gitee

效果如图:


作者:ChenYhong
来源:juejin.cn/post/7180616999695810597

收起阅读 »

Android开发中那些与代码无关的技巧

1.如何找到代码作为客户端的开发,工作中经常遇到,后端的同事来帮忙找接口详情。产品经理来询问之前的某些功能的业务逻辑,而这些代码或者逻辑都是前人遗留下来的……没有人知道在哪。那如何快速的找到你想找到的代码位置呢?(1)无敌搜索大法双击shift键,页面上有什么...
继续阅读 »
1.如何找到代码

作为客户端的开发,工作中经常遇到,后端的同事来帮忙找接口详情。产品经理来询问之前的某些功能的业务逻辑,而这些代码或者逻辑都是前人遗留下来的……没有人知道在哪。那如何快速的找到你想找到的代码位置呢?

(1)无敌搜索大法

双击shift键,页面上有什么就在代码中全局搜索什么,比如标题,按钮名字~找到资源文件布局文件,再进一步搜索用到这些文件的代码位置。

(2)log输出大法

在不方便debug的时候,可以输出一些log,通过查看log的输出,可以明确的看出程序运行时的运行逻辑和变量值。

(3)profiler查看大法

我们要善于利用AndroidStudio提供的工具,比如profiler。在profiler中可以看到手机中正在运行的Activity的名字,甚至能看到网络请求的详情等等,功能很强大!

(4)万能法找到页面

在你的Application中注册一个Activity的生命周期监听,

ActivityLifeCycle lifecycleCallbacks = new Application.ActivityLifecycleCallbacks();
registerActivityLifecycleCallbacks(lifecycleCallbacks);

在进入到页面的时候,直接输出页面路径~

@Override
public void onActivityCreated(Activity activity, Bundle bundle) {
   Log.e(TAG, "onActivityCreated :" + getActivityName(activity));
}
2.如何解决bug

这里讨论的是那些第一时间没有思路不知道如何解决的bug。这些bug有的是因为开发过程中粗心写错变量名,变量值,使用了错误的方法,少执行了方法,之前修改bug时某些地方被遗漏了,或者不小心把不应该改动的地方做了改动。也可能是因为使用的第三方库存在缺陷,也可能是数据问题,接口返回的数据不正确,用户做了意料之外的操作没有被程序正确处理等等。

解决棘手的bug之前,首先要稳定自己的心态。记住,心态很重要。无论这个bug已经造成了线上多么大的影响,你的boss多么着急的催着你解决bug,要有一个平稳的心态才能解决问题,否者,慌慌忙忙紧紧张张的状态下去解决bug,很可能会造成更多的bug!

(1)先看再想最后动手

解决bug的第一步,当然是稳定的复现bug。根据我的经验,如果一个bug可以被稳定的复现,至少它就被解决了70%。

通过观察bug的现象,就可以对bug做个大致的归类或者定位了。是因为数据问题?还是第三方库的问题?还或者是代码的问题?

接着就是debug,看日志等常规操作了~

如果经过上面的操作,你还是一筹莫展,那么请往下看。

(2)改变现状

如果你真的是一点思路也没有,很可能某些可能造成bug的代码也看不太懂。我建议你做一些改变现状的操作,比如:注掉某些代码,尝试其他的输入数据或者操作。总而言之,就是让bug的现象出现改变! 那么你做的这些操作肯定是对这个bug是有影响的!!!然后再逐步恢复之前注掉的代码,直到恢复某些注掉代码之后,bug的现象恢复了。很有可能这里就是造成bug的位置。bug定位了之后,再去思考解决办法。

(3)是技术问题还是业务问题

在实际的开发过程中,很多问题是通过技术手段解决不了的。可能是业务逻辑就出现了矛盾,也有可能是是因为一些奇奇怪怪的王八的屁股。这类问题要早点发现,早点提出,才能早点解决。有些可能踩红线的问题,作为开发,不要试图通过技术去解决!!!否则可能要去踩缝纫机了~~~

(4)张张嘴远胜于动动手

我一直坚信,世界上有更多能力比我强的人。我现在面对的bug也肯定不是只有我面对了。张张嘴问问周围的同事,问问网站上的大神,现在网络这么发达,只要别人解决过的问题,就不是问题。

很多时候的bug可能只是因为你对某些领域不熟悉,去请教那些对这个领域熟悉的人,你的问题对他们来说可能不是问题。

(5)bug解决不了,那就解决提出bug的人

有的时候的bug可能不是bug。提出bug的人可能只是对某些操作或者现象不理解,或者没有达到他们的预期。他们就会提出来,他们觉得现在的程序是有问题的……这个时候可以去尝试解决这个提出bug的人!让他们觉得这不是一个bug。当然你没有这种“解决人”的能力的话,就还是老老实实去解决bug吧~

(6)解决了bug之后

人的成长在于,遇到了问题,敢于直面问题,解决问题,并让自己今后避免再出现类似的问题!

解决了bug,无论这个bug是自己造成的还是别人造成的。要善于总结,避免日后自己再写出类似的问题。

3.如何实现不会的功能
(1)不要急着拒绝

遇到如何实现不会的功能,内心首先不要着急抗拒。

人总要成长,开发的技能如何成长?总不是像流水线工人那样做些一些“熟练”操作吧?总要走出自己的舒适圈,尝试解决一些问题,突破自己的上限吧~

你要知道,在Android开发这个领域,其实没有什么逾越不了技术壁垒!只要别人家有的,你就可能有!别人家做出来的东西,你就能做出来。这种信心,至少要有的~

(2)大事化小小事化了

一个复杂的功能,通常可以分解成一些简单功能,简单的功能就可以攻克!

那么当你在面对要实现一个复杂功能或者没有接触过的功能开发的时候,你所要做的其实就是分解这个功能,然后处理分解后的小功能,最后再把这些小功能组合回去!

心态要稳,天塌了有个高的顶着

遇到问题,尝试解决,实在不行,就要及时向上级反馈。作为你的上级,他们有责任也有能力帮你解决问题,或者至少给你提供解决问题的一种思路。心态要稳,天塌了有个高的顶着。

工作不是生活的全部,工作只是为了更好的生活!不要让那些无聊的代码影响你的心情影响你的生活!

作者:我是绿色大米呀
来源:juejin.cn/post/7182379138752675898

收起阅读 »

徒手撸一个注解框架

运行时注解主要是通过反射来实现的,而编译时注解则是在编译期间帮助我们生成代码,所以编译时注解效率高,但是实现起来复杂一点,运行时注解效率较低,但是实现起来简单。 首先来看下运行时注解怎么实现的吧。1.运行时注解1.1定义注解首先定义两个运行时注解,其中Rete...
继续阅读 »

运行时注解主要是通过反射来实现的,而编译时注解则是在编译期间帮助我们生成代码,所以编译时注解效率高,但是实现起来复杂一点,运行时注解效率较低,但是实现起来简单。 首先来看下运行时注解怎么实现的吧。

1.运行时注解

1.1定义注解

首先定义两个运行时注解,其中Retention标明此注解在运行时生效,Target标明此注解的程序元范围,下面两个示例RuntimeBindView用于描述成员变量和类,成员变量绑定view,类绑定layout;RuntimeBindClick用于描述方法,让指定的view绑定click事件。

@Retention(RetentionPolicy.RUNTIME)//运行时生效
@Target({ElementType.FIELD,ElementType.TYPE})//描述变量和类
public @interface RuntimeBindView {
   int value() default View.NO_ID;
}

@Retention(RetentionPolicy.RUNTIME)//运行时生效
@Target(ElementType.METHOD)//描述方法
public @interface RuntimeBindClick {
   int[] value();
}

1.2反射实现

以下代码是用反射实现的注解功能,其中ClassInfo是一个能解析处类的各种成员和方法的工具类, 源码见github.com/huangbei199… 其实逻辑很简单,就是从Activity里面取出指定的注解,然后再调用相应的方法,如取出RuntimeBindView描述类的注解,然后得到这个注解的返回值,接着调用activity的setContentView将layout的id设置进去就可以了。

public static void bindId(Activity obj){
   ClassInfo clsInfo = new ClassInfo(obj.getClass());
   //处理类
   if(obj.getClass().isAnnotationPresent(RuntimeBindView.class)) {
       RuntimeBindView bindView = (RuntimeBindView)clsInfo.getClassAnnotation(RuntimeBindView.class);
       int id = bindView.value();
       clsInfo.executeMethod(clsInfo.getMethod("setContentView",int.class),obj,id);
  }

   //处理类成员
   for(Field field : clsInfo.getFields()){
       if(field.isAnnotationPresent(RuntimeBindView.class)){
           RuntimeBindView bindView = field.getAnnotation(RuntimeBindView.class);
           int id = bindView.value();
           Object view = clsInfo.executeMethod(clsInfo.getMethod("findViewById",int.class),obj,id);
           clsInfo.setField(field,obj,view);
      }
  }

   //处理点击事件
   for (Method method : clsInfo.getMethods()) {
       if (method.isAnnotationPresent(RuntimeBindClick.class)) {
           int[] values = method.getAnnotation(RuntimeBindClick.class).value();
           for (int id : values) {
               View view = (View) clsInfo.executeMethod(clsInfo.getMethod("findViewById", int.class), obj, id);
               view.setOnClickListener(v -> {
                   try {
                       method.invoke(obj, v);
                  } catch (Exception e) {
                       e.printStackTrace();
                  }
              });
          }
      }
  }
}

1.3使用

如下所示,将我们定义好的注解写到相应的位置,然后调用BindApi的bind函数,就可以了。很简单吧

@RuntimeBindView(R.layout.first)//类
public class MainActivity extends AppCompatActivity {

   @RuntimeBindView(R.id.jump)//成员
   public Button jump;

   @Override
   protected void onCreate(Bundle savedInstanceState) {
       super.onCreate(savedInstanceState);
       BindApi.bindId(this);//调用反射
  }

   @RuntimeBindClick({R.id.jump,R.id.jump2})//方法
   public void onClick(View view){
       Intent intent = new Intent(this,SecondActivity.class);
       startActivity(intent);
  }
}

2.编译时注解

编译时注解就是在编译期间帮你自动生成代码,其实原理也不难。

2.1定义注解

我们可以看到,编译时注解定义的时候Retention的值和运行时注解不同。

@Retention(RetentionPolicy.CLASS)//编译时生效
@Target({ElementType.FIELD,ElementType.TYPE})//描述变量和类
public @interface CompilerBindView {
   int value() default -1;
}

@Retention(RetentionPolicy.CLASS)//编译时生效
@Target(ElementType.METHOD)//描述方法
public @interface CompilerBindClick {
   int[] value();
}

2.2根据注解生成代码

1)准备工作

首先我们要新建一个java的lib库,因为接下需要继承AbstractProcessor类,这个类Android里面没有。


然后我们需要引入两个包,javapoet是帮助我们生成代码的包,auto-service是帮助我们自动生成META-INF等信息,这样我们编译的时候就可以执行我们自定义的processor了。

apply plugin: 'java-library'

dependencies {
   implementation fileTree(dir: 'libs', include: ['*.jar'])
   api 'com.squareup:javapoet:1.9.0'
   api 'com.google.auto.service:auto-service:1.0-rc2'
}


sourceCompatibility = "1.8"
targetCompatibility = "1.8"

2)继承AbstractProcessor

如下所示,我们需要自定义一个类继承子AbstractProcessor并复写他的方法,并加上AutoService的注解。 ClassElementsInfo是用来存储类信息的类,这一步先暂时不用管,下一步会详细说明。 其实从函数的名称就可以看出是什么意思,init初始化,getSupportedSourceVersion限定所支持的jdk版本,getSupportedAnnotationTypes需要处理的注解,process我们可以在这个函数里面拿到拥有我们需要处理注解的类,并生成相应的代码。


3)搜集注解

首先我们看下ClassElementsInfo这个类,也就是我们需要搜集的信息。 TypeElement为类元素,VariableElement为成员元素,ExecutableElement为方法元素,从中我们可以获取到各种注解信息。 classSuffix为前缀,例如原始类为MainActivity,注解生成的类名就为MainActivity+classSuffix

public class ClassElementsInfo {

   //类
   public TypeElement mTypeElement;
   public int value;
   public String packageName;

   //成员,key为id
   public Map<Integer,VariableElement> mVariableElements = new HashMap<>();

   //方法,key为id
   public Map<Integer,ExecutableElement> mExecutableElements = new HashMap<>();

   //后缀
   public static final String classSuffix = "proxy";

   public String getProxyClassFullName() {
       return mTypeElement.getQualifiedName().toString() + classSuffix;
  }
   public String getClassName() {
       return mTypeElement.getSimpleName().toString() + classSuffix;
  }
  ......
}

然后我们就可以开始搜集注解信息了, 如下所示,按照注解类型一个一个的搜集,可以通过roundEnvironment.getElementsAnnotatedWith函数拿到注解元素,拿到之后再根据注解元素的类型分别填充到ClassElementsInfo当中。 其中ClassElementsInfo是存储在Map当中,key是String是classPath。

private void collection(RoundEnvironment roundEnvironment){
   //1.搜集compileBindView注解
   Set<? extends Element> set = roundEnvironment.getElementsAnnotatedWith(CompilerBindView.class);
   for(Element element : set){
       //1.1搜集类的注解
       if(element.getKind() == ElementKind.CLASS){
           TypeElement typeElement = (TypeElement)element;
           String classPath = typeElement.getQualifiedName().toString();
           String className = typeElement.getSimpleName().toString();
           String packageName = mElementUtils.getPackageOf(typeElement).getQualifiedName().toString();
           CompilerBindView bindView = element.getAnnotation(CompilerBindView.class);
           if(bindView != null){
               ClassElementsInfo info = classElementsInfoMap.get(classPath);
               if(info == null){
                   info = new ClassElementsInfo();
                   classElementsInfoMap.put(classPath,info);
              }
               info.packageName = packageName;
               info.value = bindView.value();
               info.mTypeElement = typeElement;
          }
      }
       //1.2搜集成员的注解
       else if(element.getKind() == ElementKind.FIELD){
           VariableElement variableElement = (VariableElement) element;
           String classPath = ((TypeElement)element.getEnclosingElement()).getQualifiedName().toString();
           CompilerBindView bindView = variableElement.getAnnotation(CompilerBindView.class);
           if(bindView != null){
               ClassElementsInfo info = classElementsInfoMap.get(classPath);
               if(info == null){
                   info = new ClassElementsInfo();
                   classElementsInfoMap.put(classPath,info);
              }
               info.mVariableElements.put(bindView.value(),variableElement);
          }
      }
  }

   //2.搜集compileBindClick注解
   Set<? extends Element> set1 = roundEnvironment.getElementsAnnotatedWith(CompilerBindClick.class);
   for(Element element : set1){
       if(element.getKind() == ElementKind.METHOD){
           ExecutableElement executableElement = (ExecutableElement) element;
           String classPath = ((TypeElement)element.getEnclosingElement()).getQualifiedName().toString();
           CompilerBindClick bindClick = executableElement.getAnnotation(CompilerBindClick.class);
           if(bindClick != null){
               ClassElementsInfo info = classElementsInfoMap.get(classPath);
               if(info == null){
                   info = new ClassElementsInfo();
                   classElementsInfoMap.put(classPath,info);
              }
               int[] values = bindClick.value();
               for(int value : values) {
                   info.mExecutableElements.put(value,executableElement);
              }
          }
      }
  }
}

4)生成代码

如下所示使用javapoet生成代码,使用起来并不复杂。

public class ClassElementsInfo {
  ......
   public String generateJavaCode() {
       ClassName viewClass = ClassName.get("android.view","View");
       ClassName clickClass = ClassName.get("android.view","View.OnClickListener");
       ClassName keepClass = ClassName.get("android.support.annotation","Keep");
       ClassName typeClass = ClassName.get(mTypeElement.getQualifiedName().toString().replace("."+mTypeElement.getSimpleName().toString(),""),mTypeElement.getSimpleName().toString());

       //构造方法
       MethodSpec.Builder builder = MethodSpec.constructorBuilder()
              .addModifiers(Modifier.PUBLIC)
              .addParameter(typeClass,"host",Modifier.FINAL);
       if(value > 0){
           builder.addStatement("host.setContentView($L)",value);
      }

       //成员
       Iterator<Map.Entry<Integer,VariableElement>> iterator = mVariableElements.entrySet().iterator();
       while(iterator.hasNext()){
           Map.Entry<Integer,VariableElement> entry = iterator.next();
           Integer key = entry.getKey();
           VariableElement value = entry.getValue();
           String name = value.getSimpleName().toString();
           String type = value.asType().toString();
           builder.addStatement("host.$L=($L)host.findViewById($L)",name,type,key);
      }

       //方法
       Iterator<Map.Entry<Integer,ExecutableElement>> iterator1 = mExecutableElements.entrySet().iterator();
       while(iterator1.hasNext()){
           Map.Entry<Integer,ExecutableElement> entry = iterator1.next();
           Integer key = entry.getKey();
           ExecutableElement value = entry.getValue();
           String name = value.getSimpleName().toString();
           MethodSpec onClick = MethodSpec.methodBuilder("onClick")
                  .addAnnotation(Override.class)
                  .addModifiers(Modifier.PUBLIC)
                  .addParameter(viewClass,"view")
                  .addStatement("host.$L(host.findViewById($L))",value.getSimpleName().toString(),key)
                  .returns(void.class)
                  .build();
           //构造匿名内部类
           TypeSpec clickListener = TypeSpec.anonymousClassBuilder("")
                  .addSuperinterface(clickClass)
                  .addMethod(onClick)
                  .build();
           builder.addStatement("host.findViewById($L).setOnClickListener($L)",key,clickListener);
      }

       TypeSpec typeSpec = TypeSpec.classBuilder(getClassName())
              .addModifiers(Modifier.PUBLIC)
              .addAnnotation(keepClass)
              .addMethod(builder.build())
              .build();
       JavaFile javaFile = JavaFile.builder(packageName,typeSpec).build();
       return javaFile.toString();
  }
}

最终使用了注解之后生成的代码如下

package com.android.hdemo;

import android.support.annotation.Keep;
import android.view.View;
import android.view.View.OnClickListener;
import java.lang.Override;

@Keep
public class MainActivityproxy {
 public MainActivityproxy(final MainActivity host) {
   host.setContentView(2131296284);
   host.jump=(android.widget.Button)host.findViewById(2131165257);
   host.findViewById(2131165258).setOnClickListener(new View.OnClickListener() {
     @Override
     public void onClick(View view) {
       host.onClick(host.findViewById(2131165258));
    }
  });
   host.findViewById(2131165257).setOnClickListener(new View.OnClickListener() {
     @Override
     public void onClick(View view) {
       host.onClick(host.findViewById(2131165257));
    }
  });
}
}

5)让注解生效

我们生成了代码之后,还需要让原始的类去调用我们生成的代码

public class BindHelper {

   static final Map<Class<?>,Constructor<?>> Bindings = new HashMap<>();

   public static void inject(Activity activity){
       String classFullName = activity.getClass().getName() + ClassElementsInfo.classSuffix;
       try{
           Constructor constructor = Bindings.get(activity.getClass());
           if(constructor == null){
               Class proxy = Class.forName(classFullName);
               constructor = proxy.getDeclaredConstructor(activity.getClass());
               Bindings.put(activity.getClass(),constructor);
          }
           constructor.setAccessible(true);
           constructor.newInstance(activity);
      }catch (Exception e){
           e.printStackTrace();
      }
  }
}

2.3调试

首先在gradle.properties里面加入如下的代码

android.enableSeparateAnnotationProcessing = true
org.gradle.daemon=true
org.gradle.jvmargs=-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=8888

然后点击Edit Configurations


新建一个remote


然后填写相关的参数,127.0.0.1表示本机,port与刚才gradle.properties里面填写的保持一致,然后点击ok


然后将Select Run/Debug Configuration选项调整到刚才新建的Configuration上,然后点击Build--Rebuild Project,就可以开始调试了。


2.4使用

如下所示为原始的类

@CompilerBindView(R.layout.first)
public class MainActivity extends AppCompatActivity {

   @CompilerBindView(R.id.jump)
   public Button jump;

   @Override
   protected void onCreate(Bundle savedInstanceState) {
       super.onCreate(savedInstanceState);
       BindHelper.inject(this);
  }

   @CompilerBindClick({R.id.jump,R.id.jump2})
   public void onClick(View view){
       Intent intent = new Intent(this,SecondActivity.class);
       startActivity(intent);
  }
}

以下为生成的类

package com.android.hdemo;

import android.support.annotation.Keep;
import android.view.View;
import android.view.View.OnClickListener;
import java.lang.Override;

@Keep
public class MainActivityproxy {
 public MainActivityproxy(final MainActivity host) {
   host.setContentView(2131296284);
   host.jump=(android.widget.Button)host.findViewById(2131165257);
   host.findViewById(2131165258).setOnClickListener(new View.OnClickListener() {
     @Override
     public void onClick(View view) {
       host.onClick(host.findViewById(2131165258));
    }
  });
   host.findViewById(2131165257).setOnClickListener(new View.OnClickListener() {
     @Override
     public void onClick(View view) {
       host.onClick(host.findViewById(2131165257));
    }
  });
}
}

3.总结

注解框架看起来很高大上,其实弄懂之后也不难,都是一个套路。

作者:我是黄大仙
来源:juejin.cn/post/7180166142093656120

收起阅读 »

移动端防抓包实践

01.整体概述介绍1.1 项目背景通讯安全是App安全检测过程中非常重要的一项针对该项的主要检测手段就是使用中间人代理机制对网络传输数据进行抓包、拦截和篡改,以检验App在核心链路上是否有安全漏洞。保证数据安全通过charles等工具可以对app的网络请求进行...
继续阅读 »

01.整体概述介绍

1.1 项目背景

  • 通讯安全是App安全检测过程中非常重要的一项

    • 针对该项的主要检测手段就是使用中间人代理机制对网络传输数据进行抓包、拦截和篡改,以检验App在核心链路上是否有安全漏洞。

  • 保证数据安全

    • 通过charles等工具可以对app的网络请求进行抓包,这样这些信息就会被清除的提取出来,会被不法分子进行利用。

  • 不想被竞争对手逆向抓包

    • 不想自身App的数据被别人轻而易举地抓包获取到,从而进行类似业务或数据分析、爬虫或网络攻击等破坏性行为。

1.2 思考问题

  • 开发项目的时候,都需要抓包,很多情况下即使是Https也能正常抓包正常。那么问题来了:

    • 抓包的原理是?任何Https的 app 都能抓的到吗?如果不能,哪些情况下可以抓取,哪些情况下抓取不到?

  • 什么叫做中间人攻击?

    • 使用HTTPS协议进行通信时,客户端需要对服务器身份进行完整性校验,以确认服务器是真实合法的目标服务器。

    • 如果没有校验,客户端可能与仿冒的服务器建立通信链接,即“中间人攻击”。

1.3 设计目标

  • 防止App被各种方式抓包

    • 做好各种防抓包安全措施,避免各种黑科技抓包。

  • 沉淀为技术库复用

    • 目前只是针对App端有需要做防抓包措施,后期其他业务线可能也有这个需要。因此下沉为工具库,傻瓜式调用很有必要。

  • 该库终极设计目标如下所示

    • 第一点:必须是低入侵性,对原有代码改动少,最简单的加入是一行代码设置即可。完全解耦合。

    • 第二点:可以动态灵活配置,支持配置禁止代理,支持配置是否证书校验,支持配置域名合法性过滤,支持拦截器加解密数据。

    • 第三点:可以检测App是否在双开,挂载,Xposed攻击环境

    • 第四点:可以灵活设置加解密的key,可以灵活替换加解密方式,比如目前采用RC4,另一个项目想用DES,可以灵活更换。

1.4 收益分析

  • 抓包库收益

    • 提高产品App的数据安全,必须对数据传输做好安全保护措施和完整性校验,以防止自身数据在网络传输中裸奔,甚至是被三方恶意利用或攻击。

  • 技能的收益

    • 下沉为功能基础库,可以方便各个产品线使用,提高开发的效率。避免跟业务解耦合。傻瓜式调用,低成本接入!

02.市面抓包的分析

2.1 Https三要素

  • 要清楚HTTPS抓包的原理,首先需要先说清楚 HTTPS 实现数据安全传输的工作原理,主要分为三要素和三阶段。

  • Http传输数据目前存在的问题

    • 1.通信使用明文,内容可能被窃听;2.不验证通信方的身份,因此可能遭遇伪装;3.无法证明报文的完整性,所以有可能遭到篡改。


  • Https三要素分别是:

    • 1.加密:通过对称加密算法实现。

    • 2.认证:通过数字签名实现。(因为私钥只有 “合法的发送方” 持有,其他人伪造的数字签名无法通过验证)

    • 3.报文完整性:通过数字签名实现。(因为数字签名中使用了消息摘要,其他人篡改的消息无法通过验证)

  • Https三阶段分别是:

    • 1.CA 证书校验:CA 证书校验发生在 TLS 的前两次握手,客户端和服务端通过报文获得服务端 CA 证书,客户端验证 CA 证书合法性,从而确认 CA 证书中的公钥合法性(大多数场景不会做双向认证,即服务端不会认证客户端合法性,这里先不考虑)。

    • 2.密钥协商:密钥协商发生在 TLS 的后两次握手,客户端和服务端分别基于公钥和私钥进行非对称加密通信,协商获得 Master Secret 对称加密私钥(不同算法的协商过程细节略有不同)。

    • 3.数据传输:数据传输发生在 TLS 握手之后,客户端和服务端基于协商的对称密钥进行对称加密通信。

  • Https流程图如下


2.2 抓包核心原理

  • HTTPS抓包原理

    • Fiddler、Charles等抓包工具,其实都是采用了中间人攻击的方案: 将客户端的网络流量代理到MITM(中间人)主机,再通过一系列的面板或工具将网络请求结构化地呈现出来。

  • 抓包Https有两个突破点

    • CA证书校验是否合法;数据传递过程中的加密和解密。如果是要抓包,则需要突破这两点的技术,无非就是MITM(中间人)伪造证书和使用自己的加解密方式。

  • 抓包的工作流程如下

    • 中间人截获客户端向发起的HTTPS请求,佯装客户端,向真实的服务器发起请求;

    • 中间人截获真实服务器的返回,佯装真实服务器,向客户端发送数据;

    • 中间人获取了用来加密服务器公钥的非对称秘钥和用来加密数据的对称秘钥,处理数据加解密。

2.3 搞定CA证书

  • Https抓包核心CA证书

    • HTTPS抓包的原理还是挺简单的,简单来说,就是Charles作为“中间人代理”,拿到了服务器证书公钥和HTTPS连接的对称密钥。

    • 前提是客户端选择信任并安装Charles的CA证书,否则客户端就会“报警”并中止连接。这样看来,HTTPS还是很安全的。

  • 安装CA证书到手机中必须洗白

    • 抓包应用内置的 CA 证书要洗白,必须安装到系统中。而 Android 系统将 CA 证书又分为两种:用户 CA 证书和系统 CA 证书(必要Root权限)。

  • Android从7.0开始限制CA证书

    • 只有系统(system)证书才会被信任。用户(user)导入的Charles根证书是不被信任的。相当于可以理解Android系统增加了安全校验!

  • 如何绕过CA证书这种限制呢?已知有以下四种方式

    • 第一种方式:AndroidManifest 中配置 networkSecurityConfig,App 信任用户 CA 证书,让系统对用户 CA 证书的校验给予通过。

    • 第二种方式:调低 targetSdkVersion < 24,不过这种方式谷歌市场有限制,意味着抓 HTTPS 的包越来越难操作。

    • 第三种方式:挂载App抓包,VirtualApp 这种多开应用可以作为宿主系统来运行其它应用,利用xposed避开CA证书校验。

    • 第四种方式:Root手机,把 CA 证书安装到系统 CA 证书目录中,那这个假 CA 证书就是真正洗白了,难度较大。

2.4 突破CA证书校验

  • App版本如何让证书校验安全

    • 1.设置targetSdkVersion大于24,去掉清单文件中networkSecurityConfig文件中的system和user配置,设置不信任用户证书。

    • 2.公钥证书固定。指 Client 端内置 Server 端真正的公钥证书。在 HTTPS 请求时,Server 端发给客户端的公钥证书必须与 Client 端内置的公钥证书一致,请求才会成功。

      • 证书固定的一般做法是,将公钥证书(.crt 或者 .cer 等格式)内置到 App 中,然后创建 TrustManager 时将公钥证书加进去。

  • 那么如何突破CA证书校验

    • 第一种:JustTrustMe 破解证书固定。Xposed 和 Magisk 都有相应的模块,用来破解证书固定,实现正常抓包。破解的原理大致是,Hook 创建 SSLContext 等涉及 TrustManager 相关的方法,将固定的证书移除。

    • 第二种:基于 VirtualApp 的 Hook 机制破解证书固定。在 VirtualApp 中加入 Hook 代码,然后利用 VirtualApp 打开目标应用进行抓包。具体看:VirtualHook

2.5 如何搞定加解密

  • 目前使用对称加密和解密请求和响应数据

    • 加密和解密都是用相同密钥。只有一把密钥,如果密钥暴露,内容就会暴露。但是这一块逆向破解有些难度。而破解解密方式就是用密钥逆向解密,或者中间人冒充使用自己的加解密方式!

  • 加密后数据镇兼顾了安全性吗

    • 不一定安全。中间人伪造自己的公钥和私钥,然后拦截信息,进行篡改。

2.6 Charles原理

  • Charles类似代理服务器

    • Charles 通过将软件本身设置成系统的网络访问代理服务器,使得所有的网络请求都会走一遍 Charles 代理,从而 Charles 可以截取经过它的请求,然后我们就可以对其进行网络包的分析。

  • 截取设备网络封包数据

    • Charles对应设置:将代理功能打开,并设置一个固定的端口。默认情况下,端口号为:8888 。

    • 移动设备设置:在手机上设置 WIFI 的 HTTP 代理。注意这里的前提是,Phone 和 Charles 代理设备链接的是同一网络(同一个ip地址和端口号)。

  • 截取Https的网络封包

    • 正常情况下,Charles 是不能截取Https的网络包的,这涉及到 Https 的证书问题。

2.7 抓包原理图

  • Charles抓包原理图


  • Android上的网络抓包原来是这样工作的

    • Charles抓包

2.8 抓包核心流程

  • 抓包核心流程关键节点

    • 第一步,客户端向服务器发起HTTPS请求,charles截获客户端发送给服务器的HTTPS请求,charles伪装成客户端向服务器发送请求进行握手 。

    • 第二步,服务器发回相应,charles获取到服务器的CA证书,用根证书(这里的根证书是CA认证中心给自己颁发的证书)公钥进行解密,验证服务器数据签名,获取到服务器CA证书公钥。然后charles伪造自己的CA证书(这里的CA证书,也是根证书,只不过是charles伪造的根证书),冒充服务器证书传递给客户端浏览器。

    • 第三步,与普通过程中客户端的操作相同,客户端根据返回的数据进行证书校验、生成密码Pre_master、用charles伪造的证书公钥加密,并生成HTTPS通信用的对称密钥enc_key。

    • 第四步,客户端将重要信息传递给服务器,又被charles截获。charles将截获的密文用自己伪造证书的私钥解开,获得并计算得到HTTPS通信用的对称密钥enc_key。charles将对称密钥用服务器证书公钥加密传递给服务器。

    • 第五步,与普通过程中服务器端的操作相同,服务器用私钥解开后建立信任,然后再发送加密的握手消息给客户端。

    • 第六步,charles截获服务器发送的密文,用对称密钥解开,再用自己伪造证书的私钥加密传给客户端。

    • 第七步,客户端拿到加密信息后,用公钥解开,验证HASH。握手过程正式完成,客户端与服务器端就这样建立了”信任“。

  • 在之后的正常加密通信过程中,charles如何在服务器与客户端之间充当第三者呢?

    • 服务器—>客户端:charles接收到服务器发送的密文,用对称密钥解开,获得服务器发送的明文。再次加密, 发送给客户端。

    • 客户端—>服务端:客户端用对称密钥加密,被charles截获后,解密获得明文。再次加密,发送给服务器端。由于charles一直拥有通信用对称密钥enc_key,所以在整个HTTPS通信过程中信息对其透明。

03.防止抓包思路

3.1 先看如何抓包

  • 使用Charles需要做哪些操作

    • 1.电脑上需要安装证书。这个主要是让Charles充当中间人,颁布自己的CA证书。

    • 2.手机上需要安装证书。这个是访问Charles获取手机证书,然后安装即可。

    • 3.Android项目代码设置兼容。Google 推出更加严格的安全机制,应用默认不信任用户证书(手机里自己安装证书),自己的app可以通过配置解决,相当于信任证书的一种操作!

  • 尤其可知抓包的突破口集中以下几点

    • 第一点:必须链接代理,且跟Charles要具有相同ip。思路:客户端是否可以判断网络是否被代理了

    • 第二点:CA证书,这一块避免使用黑科技hook证书校验代码,或者拥有修改CA证书权限。思路:集中在可以判断是否挂载

    • 第三点:冒充中间人CA证书,在客户端client和服务端server之间篡改拦截数据。思路:可以做CA证书校验

    • 第四点:为了可以在7.0上抓包,App往往配置清单文件networkSecurityConfig。思路:线上环境去掉该配置

3.2 设置配置文件

  • 一个是CA证书配置文件

    • debug包为了能够抓包,需要配置networkSecurityConfig清单文件的system和user权限,只有这样才会信任用户证书。

  • 一个是检验证书配置

    • 不论是权威机构颁发的证书还是自签名的,打包一份到 app 内部,比如存放在 asset 里。然后用这个KeyStore去引导生成的TrustManager来提供证书验证。

  • 一个是检验域名合法性

    • Android允许开发者重定义证书验证方法,使用HostnameVerifier类检查证书中的主机名与使用该证书的服务器的主机名是否一致。

    • 如果重写的HostnameVerifier不对服务器的主机名进行验证,即验证失败时也继续与服务器建立通信链接,存在发生“中间人攻击”的风险。

  • 如何查看CA证书的数据

    • 证书验证网站 ;SSL配置检查网站

3.3 数据加密处理

  • 网络数据加密的需求

    • 为了项目数据安全性,对请求体和响应体加密,那肯定要知道请求体或响应体在哪里,然后才能加密,其实都一样不论是加密url里面的query内容还是加密body体里面的都一样。

  • 对数据哪里进行加密和解密

    • 目前对数据返回的data进行加解密。那么如何做数据加密呢?目前项目中采用RC4加密和解密数据。

  • 抓取到的内容为乱码

    • 有的APP为了防止抓取,在返回的内容上做了层加密,所以从Charles上看到的内容是乱码。这种情况下也只能反编译APP,研究其加密解密算法进行解密。难度极大!

3.4 避免黑科技抓包

  • 基于Xposed(或者)黑科技破解证书校验

    • 这种方式可以检查是否有Xposed环境,大概的思路是使用ClassLoader去加载固定包名的xp类,或者手动抛出异常然后捕获去判断是否包含Xposed环境。

  • 基于VirtualApp挂载App突破证书访问权限

    • 这个VirtualApp相当于是一个宿主App(可以把它想像成桌面级App),它突破证书校验。然后再实现挂载App的抓包。判断是否是双开环境!

04.防抓包实践开发

4.1 App安全配置

  • 添加配置文件

    • android:networkSecurityConfig="@xml/network_security_config"

  • 配置networkSecurityConfig抓包说明

    • 中间人代理之所有能够获取到加密密钥就是因为我们手机上安装并信任了其代理证书,这类证书安装后都会被归结到用户证书一类,而不是系统证书。

    • 那我们可以选择只信任系统内置的系统证书,而屏蔽掉用户证书(Android7.0以后就默认是只信任系统证书了),就可以防止数据被解密了。

  • 实现App防抓包安全配置方式有两种:

    • 一种是Android官方提供的网络安全配置;另一种也可以通过设置网络框架实现(以okhttp为例)。

    • 第一种:具体可以看清单配置文件,相当于base-config标签下去掉 这组标签。

    • 第二种:需要给okhttpClient配置 X509TrustManager 来监听校验服务端证书有效性。遍历设备上信任的证书,通过证书别名将用户证书(别名中含有user字段)过滤掉,只将系统证书添加到验证列表中。

  • 该方案优点和缺点分析说明

    • 优点:network_security_config配置简单,对整个app网络生效,无需修改代码;代码实现对通过该网络框架请求的生效,能兼容7.0以前系统。

    • 缺陷:network_security_config配置方式,7.0以前的系统配置不生效,依然可以通过代理工具进行抓包。okhttp配置的方式只能对使用该网络框架进行数据传输的接口生效,并不能对整个app生效。

    • 破解:将手机进行root,然后将代理证书放置到系统证书列表内,就可以绕过代码或配置检查了。

4.2 关闭代理

  • charles 和 fiddler 都使用代理来进行抓包,对网络客户端使用无代理模式即可防止抓包,如

    OkHttpClient.Builder()
       .proxy(Proxy.NO_PROXY)
       .build()
  • no_proxy实际上就是type属性为direct的一个proxy对象,这个type有三种

    • direct,http,socks。这样因为是直连,所以不走代理。所以charles等工具就抓不到包了,这样一定程度上保证了数据的安全,这种方式只是通过代理抓不到包。

  • 通常情况下上述的办法有用,但是无法防住使用 VPN 导流进行的抓包

    • 使用VPN抓包的原理是,先将手机请求导到VPN,再对VPN的网络进行Charles的代理,绕过了对App的代理。

  • 该方案优点和缺点分析说明

    • 优点:实现简单方便,无系统版本兼容问题。

    • 缺陷:该方案比较粗暴,将一切代理都切断了,对于有合理诉求需要使用网络代理的场景无法满足。

    • 破解:使用ProxyDroid全局代理工具通过iptables对请求进行强制转发,可以有效绕过代理检测。

4.3 证书校验(单向认证)

  • 下载服务器端公钥证书

    • 为了防止上面方案可能导致的“中间人攻击”,可以下载服务器端公钥证书,然后将公钥证书编译到Android应用中一般在assets文件夹保存,由应用在交互过程中去验证证书的合法性。

  • 如何设置证书校验

    • 通过OkHttp的API方法 sslSocketFactory(sslSocketFactory,trustManager) 设置SSL证书校验。

  • 如何设置域名合法性校验

    • 通过OkHttp的API方法 hostnameVerifier(hostnameVerifier) 设置域名合法性校验。

  • 证书校验的原理分析

    • 按CA证书去验证的,若不是CA可信任的证书,则无法通过验证。

  • 单向认证流程图


  • 该方案优点和缺点分析说明

    • 优点:安全性比较高,单向认证校验证书在代码中是方便的,安全性相对较高。

    • 缺陷:CA证书存在过期的问题,证书升级。

    • 破解:证书锁定破解比较复杂,比如老牌的JustTrustMe插件,通过hook各网络框架的证书校验方法,替换原有逻辑,使校验失效。

4.4 双向认证

  • 什么叫做双向认证

    • SSL/TLS 协议提供了双向认证的功能,即除了 Client 需要校验 Server 的真实性,Server 也需要校验 Client 的真实性。

  • 双向认证的原理

    • 双向认证需要 Server 支持,Client 必须内置一套公钥证书 + 私钥。在 SSL/TLS 握手过程中,Server 端会向 Client 端请求证书,Client 端必须将内置的公钥证书发给 Server,Server 验证公钥证书的真实性。

    • 用于双向认证的公钥证书和私钥代表了 Client 端身份,所以其是隐秘的,一般都是用 .p12 或者 .bks 文件 + 密钥进行存放。

  • 代码层面如何做双向认证

    • 双向校验就是自定义生成客户端证书,保存在服务端和客户端,当客户端发起请求时在服务端也校验客户端的证书合法性,如果不是可信任的客户端发送的请求,则拒绝响应。

    • 服务端根据自身使用语言和网络框架配置相应证书校验机制即可。

  • 双向认证流程图


  • 该方案优点和缺点分析说明

    • 优点:安全性非常高,使用三方工具不易破解。

    • 缺陷:服务端需要存储客户端证书,一般服务端会对应多个客户端,就需要分别存储和校验客户端证书,增加校验成本,降低响应速度。该方案比较适合对安全等级要求比较高的业务(如金融类业务)。

    • 破解:由于在服务端也做校验,在服务端安全的情况下很难被攻破。

4.5 防止挂载抓包

  • Xposed是一个牛逼的黑科技

    • Xposed + JustTrustMe 可以破解绕过校验CA证书。那么这样CA证书的校验就形同虚设了,对App的危险性也很大。

  • App多开运行在多个环境上

    • 多开App的原理类似,都是以新进程运行被多开的App,并hook各类系统函数,使被多开的App认为自己是一个正常的App在运行。

    • 一种是从多开App中直接加载被多开的App,如平行空间、VirtualApp等,另一种是让用户新安装一个App,但这个App本质上就是一个壳,用来加载被多开的App。

  • VirtualApp是一个牛逼的黑科技

    • 它破坏了Android 系统本身的隔离措施,可以进行免root hook和其他黑科技操作,你可以用这个做很多在原来APP里做不到事情,于此同时Virtual App的安全威胁也不言而喻。

  • 如何判断是否具有Xposed环境

    • 第一种方式:获取当前设备所有运行的APP,根据安装包名对应用进行检测判断是否有Xposed环境。

    • 第二种方式:通过自造异常来检测堆栈信息,判断异常堆栈中是否包含Xposed等字符串。

    • 第三种方式:通过ClassLoader检查是否已经加载了XposedBridge类和XposedHelpers类来检测。

    • 第四种方式:获取DEX加载列表,判断其中是否包含XposedBridge.jar等字符串。

    • 第五种方式:检测Xposed相关文件,通过读取/proc/self/maps文件,查找Xposed相关jar或者so文件来检测。

  • 如何判断是否是双开环境

    • 第一种方式:通过检测app私有目录,多开后的应用路径会包含多开软件的包名。还有一种思路遍历应用列表如果出现同样的包名,则被认为双开了。

    • 第二种方式:如果同一uid下有两个进程对应的包名,在"/data/data"下有两个私有目录,则该应用被多开了。

  • 判断了具有xposed或者多开环境怎么处理App

    • 目前使用VirtualApp挂载,或者Xposed黑科技去hook,前期可以先用埋点统计。测试学而思App发现挂载在VA上是推出App。

4.5 数据加解密

  • 针对数据加解密入口

    • 目前在网络请求类里添加拦截器,然后在拦截器中处理request请求和response响应数据的加密和解密操作。

  • 主要是加密什么数据

    • 在request请求数据阶段,如果是get请求加密url数据,如果是post请求则加密url数据和requestBody数据。

    • 在response响应数据阶段,

  • 如何进行加密:发起请求(加密)

    • 第一步:获取请求的数据。主要是获取请求url和requestBody,这一块需要对数据一块处理。

    • 第二步:对请求数据进行加密。采用RC4加密数据

    • 第三步:根据不同的请求方式构造新的request。使用 key 和 result 生成新的 RequestBody 发起网络请求

  • 如何进行解密:接收返回(解密)

    • 第一步:常规解析得到 result ,然后使用RC4工具,传入key去解密数据得到解密后的字符串

    • 第二步:将解密的字符串组装成ResponseBody数据传入到body对象中

    • 第三步:利用response对象去构造新的response,然后最后返回给App

4.7 证书锁定

  • 证书锁定是Google官方比较推荐的一种校验方式

    • 原理是在客户端中预先设置好证书信息,握手时与服务端返回的证书进行比较,以确保证书的真实性和有效性。

  • 如何实现证书锁定

    • 有两种实现方式:一种通过network_security_config.xml配置,另一种通过代码设置;

      //第一种方式:配置文件 api.zuoyebang.cn 38JpactkIAq2Y49orFOOQKurWxmmSFZhBCoQYcRhK90= 9k1a0LRMXouZHRC8Ei+4PyuldPDcf3UKgO/04cDM90K=

      //第二种方式:代码设置 fun sslPinning(): OkHttpClient { val builder = OkHttpClient.Builder() val pinners = CertificatePinner.Builder() .add("api.zuoyebang.cn", "sha256//89KpactkIAq2Y49orFOOQKurWxmmSFZhBCoQYcRh00L=") .add("api.zuoyebang.com", "sha256//a8za0LRMXouZHRC8Ei+4PyuldPDcf3UKgO/04cDM1o=09") .build() builder.apply { certificatePinner(pinners) } return builder.build() }

  • 该方案优点和缺点分析说明

    • 优点:安全性高,配置方式也比较简单,并能实现动态更新配置。

    • 缺陷:网络安全配置无法实现证书证书的动态更新,另外该配置也受Android系统影响,对7.0以前的系统不支持。代码配置相对灵活些。

    • 破解:证书锁定破解比较复杂,比如老牌的JustTrustMe插件,通过hook各网络框架的证书校验方法,替换原有逻辑,使校验失效

4.8 Sign签名

  • 先说一下背景和问题

    • api.test.com/getbanner?k…

    • 这种方式简单粗暴,通过调用getbanner方法即可获取轮播图列表信息,但是这样的方式会存在很严重的安全性问题,没有进行任何的验证,大家都可以通过这个方法获取到数据,导致产品信息泄露。

  • 在写开放的API接口时是如何保证数据的安全性的?

    • 请求来源(身份)是否合法?请求参数被篡改?请求的唯一性(不可复制)?

  • 问题的解决方案设想

    • 解决方案:为了保证数据在通信时的安全性,我们可以采用参数签名的方式来进行相关验证。

  • 最终决定的解决方案

    • 调用接口之前需要验证签名和有效时间,要生成一个sign签名。先拼接-后转码-再加密-再发请求!

  • sign签名校验实践

    • 需要对请求参数进行签名验证,签名方式如下:key1=value1&key2=value2&key3=value3&secret=yc 。对这个字符串进行md5一下。

    • 然后被sign后的接口就变成了:api.test.com/getbanner?k…

    • 为什么在获取sign的时候建议使用secret参数?secret仅作加密使用,添加在参数中主要是md5,为了保证数据安全请不要在请求参数中使用。

  • 服务端对sign校验

    • 这样请求的时候就需要合法正确签名sign才可以获取数据。这样就解决了身份验证和防止参数篡改问题,如果请求参数被人拿走,没事,他们永远也拿不到secret,因为secret是不传递的。再也无法伪造合法的请求。

  • 如何保证请求的唯一性

    • api.test.com/getbanner?k…

    • 通过stamp时间戳用来验证请求是否过期。这样就算被人拿走完整的请求链接也是无效的。

  • Sign签名安全性分析:

    • 通过上面的案例,安全的关键在于参与签名的secret,整个过程中secret是不参与通信的,所以只要保证secret不泄露,请求就不会被伪造。

05.架构设计说明

5.1 整体架构设计

  • 如下所示


5.2 关键流程图

5.3 稳定性设计

  • 对于请求和响应的数据加解密要注意

    • 在网络上交换数据(网络请求数据)时,可能会遇到不可见字符,不同的设备对字符的处理方式有一些不同。

    • Base64对数据内容进行编码来适合传输。准确说是把一些二进制数转成普通字符用于网络传输。统统变成可见字符,这样出错的可能性就大降低了。

5.4 降级设计

  • 可以一键配置AB测试开关

    .setMonitorToggle(object : IMonitorToggle {
       override fun isOpen(): Boolean {
           //todo 是否降级,如果降级,则不使用该功能。留给AB测试开关
          return false
      }
    })

5.5 异常设计说明

  • base64加密和解密导致错误问题

    • Android 有自带的Base64实现,flag要选Base64.NO_WRAP,不然末尾会有换行影响服务端解码。导致解码失败。

5.6 Api文档

  • 关于初始化配置

    NotCaptureHelper.getInstance().config = CaptureConfig.builder()
           //设置debug模式
      .setDebug(true)
           //设置是否禁用代理
      .setProxy(false)
           //设置是否进行数据加密和解密,
      .setEncrypt(true)
           //设置cer证书路径
      .setCerPath("")
           //设置是否进行CA证书校验
      .setCaVerify(false)
           //设置加密和解密key
      .setEncryptKey(key)
           //设置参数
      .setReservedQueryParam(OkHttpBuilder.RESERVED_QUERY_PARAM_NAMES)
      .setMonitorToggle(object : IMonitorToggle {
           override fun isOpen(): Boolean {
               //todo 是否降级,如果降级,则不使用该功能。留给AB测试开关
              return false
          }
      })
      .build()
  • 设置okHttp配置

    NotCaptureHelper.getInstance().setOkHttp(app,okHttpBuilder)
  • 如何设置自己的加解密方式

    NotCaptureHelper.getInstance().encryptDecryptListener = object : EncryptDecryptListener {
       /**
        * 外部实现自定义加密数据
        */
       override fun encryptData(key: String, data: String): String {
           LoggerReporter.report("NotCaptureHelper", "encryptData data : $data")
           val str = data.encryptWithRC4(key) ?: ""
           LoggerReporter.report("NotCaptureHelper", "encryptData str : $str")
           return str
      }
       /**
        * 外部实现自定义解密数据
        */
       override fun decryptData(key: String, data: String): String {
           LoggerReporter.report("NotCaptureHelper", "decryptData data : $data")
           val str = data.decryptWithRC4(key) ?: ""
           LoggerReporter.report("NotCaptureHelper", "decryptData str : $str")
           return str
      }
    }

5.7 防抓包功能自测

  • 网络请求测试

    • 正常请求,测试网络功能是否正常

  • 抓包测试

    • 配置fiddler,charles等工具

    • 手机上设置代理

    • 手机上安装证书

    • 单向认证测试:进行网络请求,会提示SSLHandshakeException即ssl握手失败的错误提示,即表示app端的单向认证成功。

    • 数据加解密:进行网络请求,看一下请求参数和响应body数据是否加密,如果看不到实际json实体则表示加密成功。

防抓包库:github.com/yangchong21…

综合库:github.com/yangchong21…

视频播放器:github.com/yangchong21…

作者:杨充
来源:juejin.cn/post/7175325220109025339

收起阅读 »

研究良久,终于发现了他代码写的快且bug少的原因

前言读者诸君,今日我们适当放松一下,不钻研枯燥的知识和源码,分享一套高效的摸鱼绝活。我有一位程序员朋友,当时在一个团队中开发Android应用,历经多次考核后发现:在组内以及与iOS团队的对比中:他的任务量略多但他的bug数量和严重度均低但他加班的时间又少于其...
继续阅读 »

前言

读者诸君,今日我们适当放松一下,不钻研枯燥的知识和源码,分享一套高效的摸鱼绝活。

我有一位程序员朋友,当时在一个团队中开发Android应用,历经多次考核后发现:

在组内以及与iOS团队的对比中:

  • 他的任务量略多

  • 但他的bug数量和严重度均低

  • 但他加班的时间又少于其他人

不禁令人产生好奇,他是如何做到代码别的又快,质量又高的

经过多次研究我终于发现了奥秘。

为了行文方便我用"老L"来代指这位朋友。

最常见的客户端bug

"老L,听说昨晚上线,你又坐那摸鱼看测试薅别人,有什么秘诀吗?"

老L:"秘诀?倒也谈不上,你这么说,我倒是有个问题,你觉得平日里最常见的bug有哪些?"

"emm,编码上不健壮的地方,例如NPE,IndexOutOfBoundsException,UI上的可就海了去了,文本长度不一导致显示不下,间距问题,乱七八糟的一大堆"

老L:"哈哈,都是些看起来很幼稚、愚蠢的问题吧?是不是测试挂嘴边的那句:' 你就不能跑一跑吗,你又不瞎,跑两下不就看到了,这么明显!!!' "

我突然来了兴致,"你是说我们有必要上 TDD(test-driven-develop),按照DevOps思想,在CI(Continuous Integration)的时候,顺带跑自动化测试用例发现问题?"

老L突然打断了我:"不要拽你那些词了,记住了,事情是要人干的,机器只能替代可重复劳动,现在还不能替代人的主观能动性,拽词并不能解决问题。我们已经找到了第一个问题的答案,现在换个角度"


平日里最常见的bug有哪些?

  • 编码不健壮, 例如NPE,IndexOutOfBoundsException

  • UI细节问题, 例如文本长度不一导致显示不下,间距,等

为什么很浅显的问题没有被发现


老L:"那么问题来了,为什么这些浅显的问题,在交测前没有被发现呢?"

我陷入了思考...

是开发们都很懒吗?也不至于啊!

是时间很紧来不及吗?确实节奏紧张,但也不至于不给调试就拿去测了!

"emm, 可能是迭代的节奏的太频繁,压力较大,并没有整块的时间用来自测联调"


老L接过话茬,"假定你说的是正确的,那么就有两种可能。"

"第一种,自测与联调要比开发还要耗费心思的一件事情。但实际上,你我都知道,这一点并站不住脚!"

"而第二种,就是在开发阶段无法及时测试,拖到开发完,简单测测甚至被催促着就交差了"

仔细的思考后

  • 业务逐步展开,无法在任意时间自由地进行有效的集成测试

  • 后端节奏并不比前端快多少,在前端的开发阶段,难以借助后端接口测试,也许接口也有问题

"确实,这是一个挺麻烦的问题,听你一说,我感觉除了多给几天,开发完集中自测一波才行" 我如是说到。


"NO NO NO",老L又打断了我:"你想的过多了,你想借助一个可靠的、已经完备的后端系统来进行自测。对于你的需求来说,这个要求过高了,你这是准备干QA的活"

"我帮你列举一下情况"

  1. 一些数据处理的算法,这种没有办法,老老实实写单元测试,在开发阶段就可以做好,保障可靠性

  2. UI呢,我们现在写的代码,基本都做到了UI与逻辑分层,只要能模拟数据,就能跑起来看页面

  3. 业务层,后端逻辑我们无法控制,但 Web-API 调用的情况可以分析下并做一下测试,而对于返回数据的JSON结构校验、约束性校验也可以考虑做一下测试

总而言之,我们只需要先排除掉浅显的错误。而这些浅显的错误,属于情况2、3

老L接着说道:"你先歇歇吧,我来说,你再插嘴这文章就太长了!"

接下来就可以实现矛盾转移:"如何模拟数据进行测试",准确的说,问题分成两个子问题:

  • 如何生成模拟数据

  • 如何从接缝中塞入数据,让系统得以使用

可能存在的接缝


先看问题2:"如何从接缝中塞入数据,让系统得以使用"

脑暴一下,可以得出结论:

  • 应用内部

    • 替换调用web-api的业务模块,使用假数据调用业务链,一般替换Presenter、Controller实例

    • 替换Model层,不调用web-api,返回假数据或用假数据调用回调链

    • 侵入网络层实现,不进行实际网络层交互,直接使用假数据

    • 遵循切面,向缓存等机制模块中植入假数据

  • 应用外部

    • 使用代理,返回假数据

    • 假数据服务器

简单分析:

  • "假数据服务器" ,并且使用逻辑编造假数据的代价太大,过。

  • "使用代理,返回假数据",可以用于特定问题的调试,不适用广泛情况,过。

  • "替换调用web-api的业务模块",成本过大,过。

  • "替换Model层",对项目的依赖注入管理具有较大挑战,备选,可能带来很多冗余代码。

  • "侵入网络层实现",优选。

  • "向缓存等机制模块中植入假数据",操作真实的缓存较复杂,但可以考虑增加一个 Mock缓存实现模块,基于SPI等机制,可以解决冗余代码问题,备选。

得出结论:

  • 方案1:"侵入网络层实现",优选

  • 方案2:"替换Model层",(项目的依赖注入做得很好时)作为备选,可能带来冗余代码

  • 方案3:"向缓存等机制模块中植入假数据",增加一个 Mock缓存实现模块,备选。(基于SPI等机制,可以解决冗余代码问题)

再仔细分析: 方案1和方案3可以合并,形成一个完整的方案,但未必需要限定在缓存机制中


OK 我们先搁置一下这个问题,看前一个问题。


创造假数据

简单脑暴一下,无非三种:

  • 人工介入,手动编写 -- 成本过大

    • 可能在前期准备好,基本是纯文本

    • 可能使用一个交互工具,在需要数据时介入,通过图形化操作和输入产生数据

  • 人工介入,逻辑编码

  • 基于反射等自省机制,并完全随机或者基于限制生成数据

"第一种代价过大,暂且抛弃"

"第二种可以采用,但是人力成本不容忽视! 一个可以说服我使用它的理由是:"可以精心设计单测数据,针对性的发现问题"

"第三种很轻松,例如使用Mockito,但生成合适的数据需要花费一定的精力"

我们来扒一扒第三种方式,其核心思想为:

  1. 获取类信息,得到属性集

  2. 遍历属性填充 >

  1. 基础类型、箱体类型,枚举,确定取值范围,使用Random取值,赋值

2. 普通类、泛型类,创建实例,回归步骤1
3. 集合、数组等,创建实例,回归步骤1,收集填充

不难得出结论,这一方法虽然很强大,但 创建高度定制化的数据 是一件有挑战的事情。

举个例子,模拟字符串时,一般会使用语料集作为枚举,进行取值。要得到“地址”、“邮箱”等特定风格的数据,需要结合框架做配置,客观上存在较高地学习、使用门槛。

你也知道,前几年我图好玩,写了个 mock库

必须强调的一点:“我并不认为我写的库比Mockito等库强大,仅仅是在我们开发人员够用的基础上,做到尽可能简单!”

你也知道,Google 在Androidx(前身为support)中提供了一套注解包: annotations。但Google并未提供bean validation 实现 ,我之前也基于此做过一套JSR303实现,有一次突发灵感,这套注解的含义同样适用于 声明假数据取值范围 !!!

所以,我能使用它便捷的生成合适的假数据,在开发阶段及时的进行 “伪集成”


此刻,我再也忍不住要发言了:“且慢,老L,你这个做法有一定的侵入性吧。而且,如果数据类在不同业务下复用的话,是否存在问题呢?”

老L顿了顿,“确实,google的annotations是源码级注解,并不是运行时,我为了保持简单,使用了运行时反射而非代码生成。所以确实存在一定的代码侵入性”。

但是,我们可以基于此建立一套简单的MOCK-API,这样就不存在代码侵入了。

另外,也可以增加一套Annotation-Processor 实现方案,这样就可以适当沿用项目中的注解约束了,但我个人认为华而不实。


看你的第二个问题,Mocker一开始确实存在这个问题,有一次从Spring的JSR380中得到灵感,我优化了注解规则,这个问题已经被解决了。得空你可以顺着这个图看看:


或者去看看代码和使用说明:github.com/leobert-lan…

再次审视如何处理接缝

此时我已经有点云里雾里,虽然听起来很牛,如何用起来呢?我还是很茫然,简直人麻了!不得不再次请教。

老L笑着说:“你问的是一个实践方案的问题,而这类问题没有银弹.不同的项目、不同的习惯都有最适宜的方法,我只能分享一下我的想法和做法,仅做参考”

在之前的项目中,我自己建了一个Mock-API,利用我的Mocker库,写一个假数据接口就是分分钟的事情。

测试机挂上charles代理,有需要的接口直接进行mapping,所以在客户端代码中,你看不到我做了啥。

当然,这个做法是在软件外部。

如果要在软件内部做,我个人认为这也是一个华而不实的事情。不过不得不承认是一件好玩的事情,那就提一些思路。

基于Retrofit的CallAdapter

public interface CallAdapter<R, T> {
   Type responseType();

   T adapt(Call<R> call);

   abstract class Factory {
       public abstract @Nullable
       CallAdapter<?, ?> get(Type returnType, Annotation[] annotations,
                             Retrofit retrofit);

       protected static Type getParameterUpperBound(int index,
                                                    ParameterizedType type) {
           return Utils.getParameterUpperBound(index, type);
      }

       protected static Class<?> getRawType(Type type) {
           return Utils.getRawType(type);
      }
  }
}

很明显,我们可以追加注解,用以区分是否需要考虑mock;

可选:对于有可能需要mock的接口,可以继续追加切面,实现在软件外部控制使用 mock数据真实数据

而Retrofit已经使用反射确定了方法的 return Type ,在Mocker中也有适应的API直接生成假数据

基于Retrofit的Interceptor

相比于上一种,拦截器已经在Retrofit处理流程中靠后,此时在 Chain 中能够得到的内容已经属于Okhttp库的范畴。

所以需要一定的前置措施用于确定 "return Type"、"是否需要Mock" 等信息。可以借助Tag机制:

@Documented
@Target(PARAMETER)
@Retention(RUNTIME)
public @interface Tag {
}

@GET("/")
Call<ResponseBody> foo(@Tag String tag);

最终从 Request#tag(type: Class<out T>): T? 方式获取,并接入mock,并生成 Response

其他针对Okhttp的封装

思路基本类似,不再展开。

写在最后

听完老L的思路,我若有所思,若有所悟。他的方案似乎很有效,而且直觉告诉我,这些方案中还有很多留白空间,例如:

  • 借用SPI等技术思路,可以轻易的解决 "Mock 模块集成与移除" 的问题

  • 提前外部控制是否Mock的接缝,可以在加一个工具APP、或者Socket+网页端工具 用以实现控制

但我似乎遗漏了问题的开始


是否原意做 用于约束假数据生成规则的基础建设工作呢??? 例如维护注解

事情终究是人干的,人原意做,办法总比困难多。

最后一个小问题:

作者:leobert-lan
来源:juejin.cn/post/7175772997582585917

收起阅读 »

做一个具有高可用性的网络库(下)

续 做一个具有高可用性的网络库(上)网速检测如果可以获取到当前手机的网速,就可以做很多额外的操作。 比如在图片场景中,可以基于当前的实时网速进行图片的质量的变换,在网速快的场景下,加载高质量的图片,在网速慢的场景下,加载低质量的图片。 我们如何去计算...
继续阅读 »

续 做一个具有高可用性的网络库(上)

网速检测

如果可以获取到当前手机的网速,就可以做很多额外的操作。 比如在图片场景中,可以基于当前的实时网速进行图片的质量的变换,在网速快的场景下,加载高质量的图片,在网速慢的场景下,加载低质量的图片。 我们如何去计算一个比较准确的网速呢,比如下面列举的几个场景

  • 当前app没有发起网络请求,但是存在其他进程在使用网络,占用网速

  • 当前app发起了一个网络请求,计算当前网络请求的速度

  • 当前app并发多个网络请求,导致每个网络请求的速度都比较慢

可能还会存在一些其他的场景,那么在这么复杂的场景,我们通过两种不同的计算方式进行合并计算

  1. 基于当前网络接口的response读取的速度,进行网速的动态计算

  2. 基于流量和时间计算出网速

通过计算出来的两者,取最大值的网速作为当前的网速值。

基于当前接口动态计算

基于前面网络请求的全流程监控,我们可以在全局添加所有网络接口的监听,在ResponseBody这个周期内,基于response的byte数和时间,可以计算每一个网络body读取速度。之所以要选取body读取的时间来计算网速,主要是为了防止把网络建连的耗时影响了最终的网速计算。 不过接口网速的动态计算需要针对不同场景去做不同的计算。

  • 当前只有一个网络请求 在当前只有一个网络请求的场景下, 当前body计算出来请求速度就是当前的网速。

  • 当前同时存在多个网络请求发起时 每一个请求都会瓜分网速,所以在这个场景下,每个网络请求的网速都有其对应的网速占比。比如当前有6个网络请求,每个网络请求的网速近似为1/6。

当然,为了防止网速的短时间的波动,每个网络请求对于当前的网速的影响是有固定的占比的, 比如我们可以设置的占比为5%。

currentSpeed = (requestSpeed * concurrentRequestCount - preSpeed) * ratePercent + preSpeed

其中

  • requestSpeed:表示为当前网络请求计算出来的网速。

  • concurrentRequestCount:表示当前网络请求的总数

  • preSpeed:表示先前计算出来的网速

  • ratePercent:表示当前计算出来网速对于真正的网速影响占比

为了防止body过小导致的计算出来网速不对的场景,我们选取当前body大小超过20K的请求参与进行计算。

基于流量动态计算

基于流量的计算,可以参照TrafficState进行计算。可以参照facebook的network-connection-class 。可以通过每秒获取系统当前进程的流量变化,网速 = 流量总量 / 计算时间。 它内部也有一个计算公式:

public void addMeasurement(double measurement) {
  double keepConstant = 1 - mDecayConstant;
  if (mCount > mCutover) {
    mValue = Math.exp(keepConstant * Math.log(mValue) + mDecayConstant * Math.log(measurement));
  } else if (mCount > 0) {
    double retained = keepConstant * mCount / (mCount + 1.0);
    double newcomer = 1.0 - retained;
    mValue = Math.exp(retained * Math.log(mValue) + newcomer * Math.log(measurement));
  } else {
    mValue = measurement;
  }
  mCount++;
}

自定义注解处理

假如我们现在有一个需求,在我们的网络库中有一套内置的接口加密算法,现在我们期望针对某几个网络请求做单独的配置,我们有什么样的解决方案呢? 比较容易能够想到的方案是添加给网络添加一个全局拦截器,在拦截器中进行接口加密,然后在拦截器中对符合要求的请求URL的进行加密。但是这个拦截器可能是一个网络库内部的拦截器,在这里面去过滤不同的url可能不太合适。 那么,有什么方式可以让这个配置通用并且简洁呢? 其中一种方式是通过接口配置的地方,添加一个Header,然后在网络库内部拦截器中获取Header中有没有这个key,但是这个这个使用起来并且没有那么方便。首先业务方并不知道header里面key的值是什么,其次在添加到header之后,内部还需要在拦截器中把这个header的key给移除掉。

最后我们决定对于网络库给单接口提供的能力都通过注解来提供。 就拿接口加密为例子,我们期望加密的配置方式如下所示

@Encryption
@POST("xxUrl)
fun testRequest(@Field("xxUrl"nicknameString)

这个注解是如何能够透传到网络库中的内部拦截器呢。首先需要把在Interface中配置的注解获取出来。CallAdapter.Factory可以拿到网络请求中配置的注解。

override fun get(returnTypeTypeannotationsArray<Annotation>retrofitRetrofit): CallAdapter<**> {}

我们可以在这里讲注解和同一个url的request的关联起来。 然后在拦截器中获取是否有对应的注解。

override fun intercept(chainInterceptor.Chain): Response {
       val request = chain.request()
       if (!NetAnnotationUtil.isAnntationExsit(requestEncryption::class)) {
           return chain.proceed(request)
      }
       //do encrypt we want
      ...
}

调试工具

对于网络能力,我们经常会去针对网络接口进行调试。最简单的方式是通过charles抓包。 通过charles抓包我们可以做到哪些调试呢?

  1. 查看请求参数、查看网络返回值

  2. mock网络数据 看着上面2个能力似乎已经满足我们了日常调试了,但是它还是有一些缺陷的:

  3. 必须要借助PC

  4. 在App关闭了可抓包能力之后,就不能再抓包了

  5. 无法针对于post请求参数区分

所以,我们需要有一个强大的网络调试能力,既满足了charles的能力, 也可以不借助PC,并且不论apk是否开启了抓包能力也能够允许抓包。 可以添加一个专门Debug网络拦截器,在拦截器中实现这个能力。

  1. 把网络的debug文件配置在本地Sdcard下(也可以配置在远端统一的地址中)

  2. 通过拦截器,进行url、参数匹配,如果命中,将本地json返回。否则,正常走网络请求。

data class GlobalDebugConfig(
    @SeerializedName("printToConsole"var printDataBoolean = false,
   @SeerializedName("printToPage"var printDataBoolean = false
)
data class NetDebugInfo(
       @SerializedName("filter"var debugFilterInfoNetDebugFilterInfo?,
       @SerializedName("response"var responseStringAny?,
       @SerializedName("code"var httpCodeInt,
       @SerializedName("message"var httpMessageString? = null,
       @SeerializedName("printToConsole"var printDataBoolean = true,
       @SeerializedName("printToPage"var printDataBoolean = true)

data class NetDebugFilterInfo(
       @SerializedName("host"var hostString? = null,
       @SerializedName("path"var pathString? = null,
       @SerializedName("parameter"var paramMapMap<StringString>? = null)

首先日志输出有个全局配置和单个接口的配置,单接口配置优于全局配置。

  • printToConsole表示输出到控制台

  • printToPage表示将接口记录到本地中,可以在本地页面查看请求数据

其次filterInfo就是我们针对接口请求的匹配规则。

  • host表示域名

  • path表示接口请求地址

  • parameter表示请求参数的值,如果是post请求,会自动匹配post请求的body参数。如果是get请求,会自动匹配get请求的query参数。

       val host = netDebugInfo.debugFilterInfo?.host
       if (!TextUtils.isEmpty(host) && UriUtils.getHost(request.url().toString()) !host) {
           return chain.proceed(request)
      }
       val filterPath = netDebugInfo.debugFilterInfo?.path
       if (!TextUtils.isEmpty(filterPath) && path !filterPath) {
           return chain.proceed(request)
      }
       val filterRequestFilterInfo = netDebugInfo.debugFilterInfo?.paramMap
       if (!filterRequestFilterInfo.isNullOrEmpty() && !checkParam(filterRequestFilterInforequest)) {
           return chain.proceed(request)
      }
       val resultResponseJsonObj = netDebugInfo.responseString
       if (resultResponseJsonObj == null) {
           return chain.proceed(request)
      }
       return Response.Builder()
               .code(200)
               .message("ok")
               .protocol(Protocol.HTTP_2)
               .request(request)
               .body(NetResponseHelper.createResponseBody(GsonUtil.toJson(resultResponseJsonObj)))
               .build()

对于配置文件,最好能够共同维护mock数据。 本地可以提供mock数据展示列表。

组件化上网络库的能力支持

在组件化中,各个组件都需要使用网络请求。 但是在一个App内,都会有一套统一的网络请求的Header,例如AppInfo,UA,Cookie等参数。。在组件化中,针对这几个参数的配置有下面几个比较容易想到的解决方案:

  1. 在各个组件单独配置这几个Header

  • 每个组件都需要但单独配置Header,会存在很多重复代码

  • 通用信息很大概率在各个组件中获取不到

  1. 由主工程实现代理发起网络请求 这种实现方式也有下面几个缺陷

  • 主工程需要关注所有组件,随着集成的组件越来越多,主工程需要初始化网络代理接口会越来越多

  • 由于主工程并不知道组件什么时候会启动,只能App启动就初始化网络代理,导致组件初始化提前

  • 所有直接和间接依赖的模块都需要由主工程来实现代理,很容易遗漏

通用信息拦截器自动注入

正因为上面两个实现方式或多或少都有问题,所以需要从网络库这一层来解决这个问题。 我们可以在网络层通过服务发现的能力,给外部提供一个通用网络信息拦截器注解, 一般由主工程实现, 完成默认信息的Header修改。创建网络Client实例时,自动查找app中被通用网络信息拦截器注解标注的拦截器。

线程池、连接池复用

各个组件都会有自己的网络Client实例,导致在同一个进程中,创建出来网络Client实例过多,同时线程池、连接池并没有复用。所以在网络库中,各个组件创建的网络Client默认会共享网络连接池和线程池,有特殊需要的模块,可以强制使用独立线程池和连接池。

作者:谢谢谢_xie
来源:juejin.cn/post/7074493841956405278

收起阅读 »

做一个具有高可用性的网络库(上)

Retrofit本身就是对于OkHttp库的封装,它的优点很很多,比如注解来实现的,配置简单,使用方便等。那为什么我们要做二次封装呢?最根本的原因还是我们现有的业务过于复杂,我们期望有更多的自定义的能力,有更好用的使用方式等。就好比下面这些自定义的能力这些目前...
继续阅读 »

在android中,网络模块是一个不可或缺的模块,相信很多公司都会有自建的网络库。目前市面上主流的网络请求框架都是基于okHttp做的延伸和扩展,并且android底层的网络库实现也使用OkHttp了,可见okHttp应用的广泛性。

Retrofit本身就是对于OkHttp库的封装,它的优点很很多,比如注解来实现的,配置简单,使用方便等。那为什么我们要做二次封装呢?最根本的原因还是我们现有的业务过于复杂,我们期望有更多的自定义的能力,有更好用的使用方式等。就好比下面这些自定义的能力

  1. 屏蔽底层的网络库实现

  2. 网络层统一处理code码和线程回调问题

  3. 网络请求绑定生命周期

  4. 网络层的全局监控

  5. 网络的调试能力

  6. 网络层对于组件化的通用能力支持

这些目前能力目前如果直接使用Retrofit,基本都是满足不了的。 本文是基于Retrofit + OkHttp提供的基础能力上,做的网络库的二次封装。主要介绍下如何在retrofit和Okhhtp的基础上,提供上述几个通用的能力。 本文需要有部分okHttp和retrofit源码的了解。 有兴趣的可以先查看官方文档,传送门:

屏蔽底层的网络库实现

虽然Retrofit是一个非常强大的封装框架,但是它并没有完全把网路库底层的实现的屏蔽掉。 默认的内部网络请求使用的okHttp,在我们创建Retrofit实例的时候,如果需要配置拦截器,就会直接依赖到底层的OkHttp,导致上层业务直接访问到了网络库的底层实现。这个对于后续的网络库底层的替换会是一个不小的成本。 因此,我们希望能够封装一层网络层,让业务的使用仅仅依赖到网络库的封装层,而不会使用到网络库的底层实现。 首先,我们需要先知道业务层当前使用到了哪些网络库底层的API, 其实最主要的还是拦截器这一层的封装。 拦截器这一层,主要涉及到几个类:

  1. Request

  2. Response

  3. Chain和Intercept 我们可以针对这几个类进行封装,定义对象接口,IRequest、IResponse、IChain和INetIntercept,这套接口不带任何具体实现。 然后在真正需要访问到具体的实例的时候,转化成具体的Request和Response等。我们可以看看在自己定义了一套拦截器之后,如何添加到之前OkHttp的流程中。 先看看IChain和INetIntercept的定义。

interface IChain {

   fun getRequestInfo(): IRequest

   @Throws(IOException::class)
   fun proceed(request: IRequest): IResponse?

}

interface INetInterceptor {
   @Throws(IOException::class)
   fun intercept(chain: IChain): IResponse?
}

在构造Retrofit的实例时,内部会尝试创建OkHttpClient,在此时把外部传入的INetInterceptor合并组装成一个OkHttp的拦截器,添加到OkHttpClient中。

 fun swicherToIntercept(list: MutableList<INetInterceptor>): Interceptor {
           return object: Interceptor {
               override fun intercept(chain: Interceptor.Chain): Response? {
                   val netRequest = IRequest(chain.request())
                   val realChain = IRealChain(0, netRequest, list as MutableList<IInterceptor>, chain, this)
                   val response: Response?
                   return (realChain.proceed(netRequest) as? IResponse)?.response
              }
          }
      }

整体修改后的拦截器的调用链如下所示:


上面举的只是在构建拦截器中的隔离,如果你们项目还有访问到其他内部的OkHttp的能力,也可以参照上面的封装流程,定义接口,在需要使用的地方转换为具体实现。

Retrofit的Call自定义

对于Retrofit,我们在接口中定义的方法就是每一个请求的配置,每一个请求都会被包装成Call。我们想要的请求做一些通用的逻辑处理和自定义,就比如在请求前做一些逻辑处理,请求后做一些逻辑处理,最后才返回给上层,就需要hook这个请求流程,可以做Retrofit的二次动态代理。 如果希望做一些更精细化的处理,hook能力就满足不了了。这种时候,可以选择使用自定义Call对象。如果整个Call对象都是我们提供的,我们当然可以在里面实现任何我们期望的逻辑。接下来简单介绍下如何自定义Retrofit的Call对象。

定义Call类型

class TestCall<T>(internal var call: Call<T>) {}

自定义CallAdapter

自定义CallAdapter时,需要使用我们前面自定义的返回值类型,并将call对象转化为我们我们自定义的返回值类型。

 class NetCallAdapter<R>(repsoneType: Type): CallAdapter<R, TestCall<R>> {
     override fun adapt(call: Call<R>): TestCall<R> {
       return TestCall(call)
  }
     override fun responseType(): Type {
       return responseType
  }
}
  1. 首先需要在class的继承关系上,显式的标明CallAdapter的第二个泛型参数是我们自定义的Call类型。

  2. 在adapt适配方法中,通过原始的call,转化为我们期望的TestCall。

自定义Factory

class NetCallAdapterFactory: CallAdapter.Factory() {
       override fun get(returnType: Type, annotations: Array<Annotation>, retrofit: Retrofit): CallAdapter<*, *>? {
       val rawType = getRawType(returnType)
       if (rawType == TestCall::class.java && returnType is ParameterizedType) {
           val callReturnType = getParameterUpperBound(0, returnType)
           return NetCallAdapter<ParameterizedType>(callReturnType)
      }
       return null
  }
}

在自定义的Factory中,根据从接口定义中获取到的网络返回值,匹配TestCall类型,如果匹配上,就返回我们定义的CallAdapter。

注册Factory

val builder = Retrofit.Builder()
   .baseUrl(retrofitBuilder.baseUrl!!)
   .client(client)
   .addCallAdapterFactory(NetCallAdapterFactory())

网络层统一处理code码和线程回调问题

code码统一处理

相信每一个产品都会定义业务错误码,每一个业务都可能有自己的一套错误码,有一些错误码可能是全局的,比如说登录过期、被封禁等,这种错误码可能跟特定的接口无关,而是一个全局的业务错误码,在收到这些错误码时,会有统一的逻辑处理。 我们可以先定义code码解析的接口

interface ICodehandler {
   fun handle(context: Context?, code: Int, message: String?, isBackGround: Boolean): Boolean
}

code码处理器的注册。

code码处理器的注册方式有两种,一种是全局的code码处理器。 在创建Retrofit实例的传入。

NetWorkClientBuilder()
       .addNetCodeHandler(SocialCodeHandler())
       .build()

另一种是在具体的网络请求时,传入错误码处理器,

TestInterface.inst.testCall().backGround(true)
       .withInterceptor(new CodeRespHandler() {
           @Override
           public boolean handle(int code, @Nullable String message) {
                ....
          }
      })
       .enqueue(null)

code码处理的调用

因为Call是我们自定义的,我们可以在网络成功的返回时,优先执行错误码处理器,如果命中业务错误码,那么对外返回失败。否则正常返回成功。

线程回调

OkHttp的callback线程回调默认是在子线程,retrofit的回调线程取决于创建实例时的配置,可以配置callbackExecutor,这个是对整个实例生效的,在这个实例内,所有的网络返回都会通过callbackExecutor。我们希望能够针对每一个接口单独配置回调的线程,所以同样基于自定义call的前提下,我们自定义Callback和UiCallback。

  • Callback: 表示当前回调线程无需主线程

  • UICallback: 表示当前回调线程需要在主线程

通用业务传入的接口类型就标识了当前回调的线程.

网络请求绑定生命周期

大部分网络请求都是异步发起的。所以可能会导致下面两个问题:

  • 内存泄漏问题

  • 空指针问题

先看一个比较常见的内存泄漏的场景

class XXXFragment {

   var unBinder: Unbinder? = null
   
   @BindView(R.id.xxxx)
   val view: AView;
   
    @Override
   public void onDestroyView() {
       unBinder?.unbind();
  }
   
   override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
     val view= super.onCreateView(inflater, container, savedInstanceState)
     unBinder = ButterKnife.bind(this, view)
     loadDataOfPay(1, 20)
     return view
  }
   
   private void testFun() {
       TestInterface.getInst().getTestFun()
              .enqueue(new UICallback<TestResponse>() {
                   @Override
                   public void onSuccessful(TestResponse test) {
                       view.xxxx = test.xxx
                  }

                   @Override
                   public void onFailure(@NotNull NetException e) {
                      ....
                  }
              });
  }
}

在上面的例子中,在testFun方法中编译后会创建匿名内部类,并且显式的调用了外部Fragment的View,一旦这个网络请求阻塞了,或者晚于这个Fragment的销毁时机回调,就会导致这个Fragment出现内存泄漏,直至这个请求正常结束返回。

更严重的是,这个被操作的view是通过ButterKnife绑定的,在Fragment走到onDestory之后就进行了解绑,会将这个View的值设置为null,导致在这个callback的回调时候,可能出现view为null的情况,导致空指针。 对于空指针的问题,我们可以看到有很多网络请求的回调都可能会出现类似下面的代码段。

 TestInterface.getInst().getTestFun()
               .enqueue(new UICallback<TestResponse>() {
                   @Override
                   public void onSuccessful(TestResponse test) {
                     if(!isFinishing() && view != null) {
                         view.xxxx = test.xxx
                    }  
                  }});

在匿名内部类回调时,通过判断页面是否已经销毁,以及view是否为空,再进行对应的UI操作。 我们通过动态代理来解决了这个空指针和内存泄漏的问题。 详细的方案可以阅读下这个文章匿名内部类导致内存泄漏的解决方案 因为我们把Activity、Fragment抽象为UIContext。在网络接口调用时,传入对应的UIContext,会将网络请求的Callabck通过动态代理,将Callback和UIContext进行关联,在页面销毁时,不进行回调。

自动Cancel无用请求

很多的业务场景中,在页面一进去就会触发很多网络请求,这个请求可能有一部分处于网络库的请求等待队列中,一部分处于进行中。当我们退出了这个页面之后,这些网络请求其实都已经没有了存在的意义。 所以我们可以在页面销毁时,取消还未发起和进行中的网络请求。 我们可以通过上面提过的UIContext,将网络请求跟页面进行关联。监听页面的生命周期,在页面关闭时,cancel掉对应的网络请求。

页面关联

在网络请求发起前,把当前的网络请求关联上对应的页面。

class TestCall {
   fun  enqueue(uiCallBack: Callback, uiContext: UIContext?) {
         LifeCycleRequestManager.registerCall(this, uiContext)
    ....
  }
   
}

internal object LifeCycleRequestManager {

   init {
       registerApplicationLifecycle()
  }
   private val registerCallMap = ConcurrentHashMap<Int, MutableList<BaseNetCall>>()

  }

ConcurrentHashMap的key为页面的HashCode,value的请求list。每一个页面都会关联一个请求List。

cancel请求

通过Application监听Activity、Fragment的生命周期。在页面销毁时,调用cancel取消对应的网络请求。

  private fun registerActivityLifecycle(app: Application) {
       app.registerActivityLifecycleCallbacks(object : Application.ActivityLifecycleCallbacks {
           override fun onActivityDestroyed(activity: Activity?) {
               registerCallMap.remove(activity.hashCode())
          }})
  }

这个是针对Activity的生命周期的监听。对于Fragment的生命周期的监听其实和Activity类似。

    private fun registerActivityLifecycle(app: Application) {
       app.registerActivityLifecycleCallbacks(object : Application.ActivityLifecycleCallbacks {
           override fun onActivityCreated(activity: Activity?, savedInstanceState: Bundle?) {
              (activity as? FragmentActivity)?.supportFragmentManager
                       ?.registerFragmentLifecycleCallbacks(fragmentLifecycleCallbacks, true)
          }})
  }

网络监听

网络模使用的场景非常多,当前出现问题的概率也更好,网络相关的问题非常多,比如网络异常、DNS解析失败、连接超时等。所以一套完善网络流程监控是非常有必要的,可以帮助我们在很多问题中快速分析出问题,提高我们的排查问题的效率

网络流程监控

根据OkHttp官方的EventListener提供的回调:OkHttpEvent事件,我们定义了以下几个一个网络请求中可能 触发的Action事件。

enum class NetEventType {
   EN_QUEUE, //入队
   NET_START, //网络请求真正开始执行
   DNS_START, //开始DNS解析
   DNS_END, //DNS解析结束
   CONNECT_START, //开始建立连接
   TLS_START, // TLS握手开始
   TLS_END, //TLS握手结束
   CONNECT_END, //建立连接结束
   RETRY, //尝试重新连接
   REUSE, //连接重用,从连接池中获取到连接
   CONNECTION_ACQUIRE, //获取到链接(可能不走连接建立,直接从连接池中获取)
   CONNECT_FAILED, // 连接失败
   REQUEST_HEADER_START, // request写Header开始
   REQUEST_HEADER_END, // request写Header结束
   REQUEST_BODY_START, // request写Body开始
   REQUEST_BODY_END, // request写Body结束
   RESPONSE_HEADER_START, // response写Header开始
   RESPONSE_HEADER_END, // response写Header结束
   RESPONSE_BODY_START, // response写Body开始
   RESPONSE_BODY_END, // response写Body结束
   FOLLOW_UP, // 是否发生重定向
   CALL_END, //请求正常结束
   CONNECTION_RELEASE, // 连接释放
   CALL_FAILED, // 请求失败
   NET_END, // 网络请求结束(包括正常结束和失败)

}

可以看到,除了okHttp原有的几个Event,还额外多了一个ENQUEUE事件。这个时机最主要的作用是计算出请求从调用到真正发起接口请求的等待时间。 当我们调用了RealCall.enqueue方法时,实际上这个接口请求并不是都会立即执行,OkHttp对于同一个时刻的请求数有限制。

  • 同一个Dispatcher,同一时刻并发数不能超过64

  • 同一个Host,同一时刻并发数不能超过5

 private final Deque<AsyncCall> readyAsyncCalls = new ArrayDeque<>();
private final Deque<AsyncCall> runningAsyncCalls = new ArrayDeque<>();

  synchronized void enqueue(AsyncCall call) {
   if (runningAsyncCalls.size() < maxRequests && runningCallsForHost(call) < maxRequestsPerHost) {
     runningAsyncCalls.add(call);
     executorService().execute(call);
  } else {
     readyAsyncCalls.add(call);
  }
}

所以一旦超过对应的阈值,当次请求就会被添加到readyAsyncCalls中,等待被执行。

根据这几个Action,我们可以将统计的时间分为下面几个阶段

enum class NetRecordItemType {
   WAIT, // 等待时间,入队到真正开始执行耗时
   DNS, // DNS耗时
   TLS, // TLS耗时
   RequestHeader, // request写入Header耗时
   RequestBody, // request写入Body耗时
   Request, // request写入header和body总耗时
   NetworkLatency, // 网络请求延时
   ResponseHeader, // response写入Header耗时
   ResponseBody, // response写入Body耗时
   Response, // response写入header和body总耗时
   Connect, // 连接建立总耗时
   RequestAndResponse, // 数据传输耗时
   CallTime, // 单次网络请求总耗时(包含排队时间)
   UNKNOWN
}

唯一ID

我们不仅仅想对整个网络的大盘进行监控,我们还希望能够精细化到每一个独立的网络请求进行监控。针对单个网络请求进行的监控的难点是我们如何去标志出来每一个网络请求,因为EventListener回调只会返回对应的call。

public abstract class EventListener {
   public void callStart(Call call) {}
   
   public void callEnd(Call call) {}
}

而这个Call没有办法与单个监控的请求进行关联。 并且在网络请求发起的阶段就需要标识出来,所以需要在Request创建的最前头就生成这个唯一ID。通过阅读源码,我们发现可以生成唯一id最早时机是在OkHttp的RealCall创建的最前头。

  RealCall(OkHttpClient client, Request originalRequest, boolean forWebSocket) {
  final EventListener.Factory eventListenerFactory = client.eventListenerFactory();

  this.client = client;
  this.originalRequest = originalRequest;
  this.forWebSocket = forWebSocket;
  this.retryAndFollowUpInterceptor = new RetryAndFollowUpInterceptor(client, forWebSocket);

  this.eventListener = eventListenerFactory.create(this);
}

其中,eventListenerFactory是由外部传递到Okhttp中的。

 public Builder eventListenerFactory(EventListener.Factory eventListenerFactory) {
     if (eventListenerFactory == null) {
       throw new NullPointerException("eventListenerFactory == null");
    }
     this.eventListenerFactory = eventListenerFactory;
     return this;
  }

因此,我们可以在EventListener.Factory中生成标记request的唯一Id。

internal class CallEventFactory(var configuration: CallEventConfiguration?) : EventListener.Factory {
   companion object {
       private val nextCallId = AtomicLong(1L)
  }

   override fun create(call: Call): EventListener {
        val callId = nextCallId.getAndIncrement()
  }
}

那生成的callId如何与request进行关联呢?最直接的是给Request添加一个Header的key。Request本身没有提供Api去修改Header。 所以这个时候就需要通过反射来设置, 先获取当前的Header,然后给header新增这个CallId,最后通过反射设置到request的header字段上。

fun appendToHeader(request: Request?, key: String?, value: String?) {
   key ?: return
   request ?: return
   value ?: return
   val headerBuilder = request.headers().newBuilder().add(key, value)
   ReflectUtils.setFieldValue(Request::class.java, request, NetCallAdapter.HEADER_NAME, headerBuilder.build())
  }

需要注意的是,因为使用了反射,所以需要在proguard文件中keep住request。 当然这个key最好能够不带到服务端,所以需要新增一个拦截器,添加到所有拦截器最后,这个这个唯一id的key就不会被添加到真正的请求上了。

class NetLastInterceptor: Interceptor {
   companion object {
       const val TAG = "NetLastInterceptor"

  }
   override fun intercept(chain: Interceptor.Chain): Response {
       val request = chain.request()
       val requestBuilder = request
              .newBuilder()
              .removeHeader(NetConstants.CALL_ID)
     
       return chain.proceed(requestBuilder.build())
  }
}

监控

在生成完唯一Id之后,我们再来看看如何外部是如何添加期望的网络监控的。

基于Client的监控

networkClient = NetWorkClientBuilder()
  .addLifecycleListener("*", object : INetLifecycleListener {
       override fun onLifecycle(info: INetLifecycleInfo) { }})
  .registerEventListener("xxxUrl", NetEventType.CALL_END, object : INetEventListener {
       override fun onEvent(event: NetEventType, request: NetRequest) { }})
      .build()

基于单个请求的监控

   TestInterface.inst.testFun()
          .addLifeCycleListener(object : INetLifecycleListener {
               override fun onLifecycle(info: INetLifecycleInfo) {} })
          .registerEventListener(mutableListOf(NetEventType.CALL_END, NetEventType.NET_START), object : INetEventListener {
               override fun onEvent(event: NetEventType, request: NetRequest) {} })
          .enqueue(null)

在创建EventListener时,按照下面的规则添加。

  1. 添加网络库系统的内部监听

  2. 添加OkHttpClient初始化配置的监听

  3. 添加单个请求配置的监听

基于单个请求的网络监控,需要提前把这个Request和网络监听的listener的关联关系存起来,因为EventListener的设置是针对整个OkHttpClient的生效的,所以需要在EventListener处理的过程中,获取当前的Request设置进去的listener。


续 做一个具有高可用性的网络库(下) 

作者:谢谢谢_xie
来源:juejin.cn/post/7074493841956405278

收起阅读 »

百度 Android 直播秒开体验优化

导读网络直播功能作为一项互联网基本能力已经越来越重要,手机中的直播功能也越来越完善,电商直播、新闻直播、娱乐直播等多种直播类型为用户提供了丰富的直播内容。随着直播的普及,为用户提供极速、流畅的直播观看体验也越来越重要。全文6657字,预计阅读时间17分钟。01...
继续阅读 »

导读

网络直播功能作为一项互联网基本能力已经越来越重要,手机中的直播功能也越来越完善,电商直播、新闻直播、娱乐直播等多种直播类型为用户提供了丰富的直播内容。随着直播的普及,为用户提供极速、流畅的直播观看体验也越来越重要。

全文6657字,预计阅读时间17分钟。

01 背景

百度 APP 作为百度的航母级应用为用户提供了完善的移动端服务,直播也作为其中一个必要功能为用户提供内容。随着直播间架构、业务能力逐渐成熟,直播间播放指标优化也越来越重要。用户点击直播资源时,可以快速的看到直播画面是其中一个核心体验,起播速度也就成了直播间优化中的一个关键指标。

02 现状

由于包体积等原因,百度 APP 的 Android 版中直播功能使用插件方式接入,在用户真正使用直播功能时才会将直播模块加载。为解决用户点击直播功能时需要等待插件下载、安装、加载等阶段及兼容插件下载失败的情况,直播团队将播放、IM 等核心能力抽到了一个独立的体积较小的一级插件并内置在百度 APP 中,直播间的挂件、礼物、关注、点赞等业务能力在另外一个体积较大的二级插件中。特殊的插件逻辑和复杂的业务场景使得 Android 版整体起播时长指标表现的不尽人意。

2022 年 Q1 直播间整体起播时长指标 80 分位在 3s 左右,其中二跳(直播间内上下滑)场景在 1s 左右,插件拆分上线后通过观察起播数据发现随着版本收敛,一跳进入直播间携带流地址(页面启动后会使用该地址预起播,与直播列表加载同步执行)场景起播时有明显的增长,从发版本初期 1.5s 左右,随版本收敛两周内会逐步增长到 2.5s+。也就是线上在直播间外点击直播资源进直播间时有很大一部分用户在点击后还需要等待 3s 甚至更长时间才能真正看到直播画面。这个时长对用户使用直播功能有非常大的负向影响,起播时长指标急需优化。

03 目标

△起播链路

起播过程简单描述就是用户点击直播资源,打开直播页面,请求起播地址,调用内核起播,内核起播完成,内核通知业务,业务起播完成打点。从对内核起播时长监控来看,直播资源的在内核中起播耗时大约为 600-700ms,考虑链路中其他阶段损耗以及二跳(直播间内上下滑)场景可以在滑动时提前起播,整体起播时长目标定位为1.5 秒;考虑到有些进入直播间的位置已经有了起播流地址,可以在某些场景省去 “请求起播地址” 这一个阶段,在这种直播间外已经获取到起播地址场景,起播时长目标定为 1.1 秒。

04 难点

特殊的插件逻辑和复杂的业务场景使得 Android 版每一次进入直播的起播链路都不会完全一样。只有一级插件且二级插件还未就绪时在一级插件中请求直播数据并起播,一二级插件都已加载时使用二级插件请求直播数据并处理起播,进直播间携带流地址时为实现秒开在 Activity 启动后就创建播放器使用直播间外携带的流地址起播。除了这几种链路,还有一些其他情况。复杂的起播链路就导致了,虽然在起播过程中主要节点间都有时间戳打点,也有天级别相邻两个节点耗时 80 分位报表,但线上不同场景上报的起播链路无法穷举,使用现有报表无法分析直播大盘起播链路中真正耗时位置。需要建立新的监控方案,找到耗时点,才能设计针对性方案将各个耗时位置进行优化。

05 解决方案

5.1 设计新报表,定位耗时点

△一跳有起播地址时起播链路简图

由于现有报表无法满足起播链路耗时阶段定位,需要设计新的监控方案。观察在打开直播间时有流地址场景的流程图(上图),进入直播间后就会同步创建直播间列表及创建播放器预起播,当直播间列表创建完毕且播放器收到首帧通知时起播流程结束。虽然用户点击到页面 Activity 的 onCreate 中可能有多个节点(一级插件安装、加载等),页面 onCreate 调用播放器预起播中可能多个节点,内核完成到直播业务收到通知中有多个节点,导致整个起播链路无法穷举。但是我们可以发现,从用户点击到 onCreate 这个路径是肯定会有的,onCreate 到创建播放器路径也是肯定有的。这样就说明虽然两个关键节点间的节点数量和链路无法确定,但是两个关键节点的先后顺序是一定的,也是必定会有的。由此,我们可以设计一个自定义链路起点和自定义链路终点的查询报表,通过终点和起点时间戳求差得到两个任意节点间耗时,将线上这两个节点所有差值求 80 分位,就可以得到线上起播耗时中这两个节点间耗时。将起播链路中所有核心关键节点计算耗时,就可以找到整个起播链路中有异常耗时的分段。

按照上面的思路开发新报表后,上面的链路各阶段耗时也就比较清晰了,见下图,这样我们就可以针对不同阶段逐个击破。

△关键节点间耗时

5.2 一跳使用一级插件起播

使用新报表统计的重点节点间耗时观察到,直播间列表创建(模版组件创建)到真正调用起播(业务视图就绪)中间耗时较长,且这个耗时随着版本收敛会逐步增加,两周内大约增加 1000ms,首先我们解决这两个节点间耗时增加问题。

经过起播链路观察和分析后,发现随版本收敛,这部分起播链路有较大变化,主要是因为随版本收敛,在二级插件中触发 “业务调用起播” 这个节点的占比增加。版本收敛期,进入直播间时大概率二级插件还未下载就绪或未安装,此时一级插件中可以很快的进行列表创建并创建业务视图,一级插件中在 RecyclerView 的 item attach 到视图树时就会触发起播,这个链路主要是等待内核完成首帧数据的拉取和解析。当二级插件逐渐收敛,进入直播间后一级插件就不再创建业务视图,而是有二级插件创建业务视图。由于二级插件中业务组件较多逐个加载需要耗时还有一级到二级中逐层调用或事件分发也存在一定耗时,这样二级插件起播场景就大大增加了直播间列表创建(模版组件创建)到真正调用起播(业务视图就绪)中间耗时。

5.2.1 一跳全部使用一级插件起播

基于上面的问题分析,我们修改了一跳场景起播逻辑,一跳全部使用一级插件起播。一级插件和二级插件创建的播放器父容器 id 是相同的,这样在一级插件中初始化播放器父容器后,当内核首帧回调时起播过程就可以结束了。二级插件中在初始化播放器父容器时也会通过 id 判断是否已经添加到视图树,只有在未添加的情况(二跳场景或一跳时出现异常)才会在二级中进行兜底处理。在一级插件中处理时速度可以更快,一级优先二级兜底逻辑保证了进入直播间后一定可以顺利初始化视图。

5.2.2 提前请求接口

使用由一起插件处理起播优化了二级插件链路层级较多问题,还有一个耗时点就是进直播间时只传入了房间 room_id 未携带流地址场景,此时需要通过接口请求获取起播数据后才能创建播放器和起播。为优化这部分耗时,我们设计了一个直播间数据请求管理器,提供了缓存数据和超时清理逻辑。在页面 onCreate 时就会触发管理器进行接口请求,直播间模版创建完成后会通过管理器获取已经请求到的直播数据,如果管理器接口请求还未结束,则会复用进行中请求,待请求结束后立刻返回数据。这样在进直播间未携带流数据时我们可以充分利用图中这 300ms 时间做更多必要的逻辑。


5.3 播放器Activity外预起播

通过进直播间播放器预创建、预起播、一跳使用一级插件起播等方案来优化进入直播间业务链路耗时后,业务链路耗时逐渐低于内核部分耗时,播放器内核耗时逐渐成为一跳起播耗时优化瓶颈。除了在内核内部探索优化方案,继续优化业务整个起播链路也是一个重要方向。通过节点间耗时可以发现,用户点击到 Activity 页面 onCrete 中间也是有 300ms 左右耗时的。当无法将这部分耗时缩到更短时,我们可以尝试在这段时间并行处理一些事情,减少页面启动后的部分逻辑。

一级插件在百度 APP 中内置后,设计并上线了插件预加载功能,上线后用户通过点击直播资源进入直播间的场景中,有 99%+ 占比都是直播一级插件已加载情况,一级插件加载这里就没有了更多可以的操作空间。但将预起播时机提前到用户点击处,可以将内核数据加载和直播间启动更大程度并行,这样来降低内核耗时对整个起播耗时影响。
△播放器在直播间外起播示意图

如上图,新增一个提前起播模块,在用户点击后与页面启动并行创建播放器起播并缓存,页面启动后创建播放器时会先从提前起播模块的缓存中尝试取已起播播放器,如果未获取到则走正常播放器创建起播逻辑,如果获取到缓存的播放器且播放器未发生错误,则只需要等待内核首帧即可。

播放器提前起播后首帧事件大概率在 Activity 启动后到达,但仍有几率会早于直播业务中设置首帧监听前到达,所以在直播间中使用复用内核的播放器时需要判断是否起播成功,如果已经起播成功需要马上分发已起播成功事件(含义区别于首帧事件,防止与首帧事件混淆)。

提前起播模块中还设计了超时回收逻辑,如果提前起播失败或 5s (暂定)内没有被业务复用(Activity 启动异常或其他业务异常),则主动回收缓存的播放器,防止直播间没有复用成功时提前创建的播放器占用较多内存及避免泄漏;超时时间是根据线上大盘起播时间决定,使用一个较大盘起播时间 80 分位稍高的值,防止起播还未完成时被回收,但也不能设置较长,防止不会被复用时内存占用较多。

通过提前起播功能,实验期命中提前起播逻辑较不进行提前起播逻辑,整体起播耗时 80 分位优化均值:450ms+。

5.4直播间任务打散

△内核首帧分发耗时

业务链路和内核链路耗时都有一定优化后,我们继续拆解重点节点间耗时。内核内部标记首帧通知到直播业务真正收到首帧通知之间耗时较长,如上图,线上内核首帧分发耗时 80 分位均值超过 1s,该分段对整体起播耗时优化影响较大。内核首帧是在子线程进行标记,通知业务时会通过主线程 Handler 分发消息,通过系统的消息分发机制将事件转到主线程。

通过排查内核标记首帧时间点到业务收到首帧通知事件时间点之间所有主线程任务,发现在首帧分发任务开始排队时,主线程任务队列中已有较多其他任务,其他事件处理时间较长,导致首帧分发排队时间较久,分发任务整体耗时也就较长。直播业务复杂度较高,如果内核首帧分发任务排队时直播间其他任务已在队列中或正在执行,首帧分发任务需要等直播任务执行完成后才能执行。

通过将直播间启动过程中所有主线程任务进行筛查,发现二级插件的中业务功能较多,整体加载任务执行时间较长,为验证线上也是由于二级业务任务阻塞了首帧分发任务,我们设计了一个二级组件加载需要等待内核首帧后才能进行的实验,通过实验组与对照组数据对比,在命中实验时首帧分发耗时和起播整体耗时全部都有明显下降,整体耗时有 500ms 左右优化。

通过实验验证及本地对起播阶段业务逻辑分析,定位到直播间各业务组件及对应视图的预加载数量较多且耗时比较明显,这个功能是二级插件为充分利用直播间接口数据返回前时间,二级插件加载后会与接口请求并行提前创建业务视图,提起初始化组件及视图为接口完成后组件渲染节省时间。如果不预创建,接口数据回来后初始化业务组件也会主动创建后设置数据。但将所有预创建任务全部串行执行耗时较长,会阻塞主线程,页面一帧中执行太多任务,也会造成页面明显卡顿。

发现这个阻塞问题后,我们设计了将预创建视图任务进行拆分打散,将一起执行的大任务拆分成多个小任务,每个组件的初始化都作为一个单独任务在主线程任务队列中进行排队等待执行。避免了一个大任务耗时特别长的问题。该功能上线后,整个二级插件中的组件加载大任务耗时降低了 40%+。

5.5 内核子线程分发首帧

由于主线程消息队列中任务是排队执行的,将阻塞首帧分发事件的大任务拆分成较多小任务后,还是无法解决首帧事件开始排队时这些小任务已经在主线程任务队列中排队问题。除了降低直播业务影响,还可以通过加快内核任务分发速度,使首帧分发耗时降低。需要设计一个在不影响内核稳定性与业务逻辑情况下内核首帧事件如何避免主线程排队或快速排队后被执行的方案。

为解决上面的问题, 我们推动内核,单独增加了一个子线程通知业务首帧事件能力。业务收到子线程中首帧回调后通过 Handler 的 postAtFrontOfQueue() 方法将一个新任务插到主线程任务队列最前面,这样主线程处理完当前任务后就可以马上处理我们新建的这个任务,在这个新任务中可以马上处理播放器上屏逻辑。无需等待播放内核原本的主线程消息。

主线程任务前插无法打断新任务排队时主线程中已经开始执行的任务,需要正在执行任务结束后才会被执行。为优化这个场景,内核通过子线程通知首帧后,播放器中需要记录这个状态,在一级插件及二级插件中的直播间业务任务执行开始前后,增加判断播放器中是否已经收到首帧逻辑,如果已经收到,就可以先处理上屏后再继续当前任务。

通过直播内核首帧消息在主线程任务队列前插和业务关键节点增加是否可上屏判断,就可以较快处理首帧通知,降低首帧分发对起播时长影响。

5.6 起播与完载指标平衡

直播间起播优化过程中,完载时长指标(完载时长:用户点击到直播间核心功能全部出现的时间,其中经历页面启动,直播间列表创建,二级插件下载、安装、加载,直播间接口数据请求,初始化直播间功能组件视图及渲染数据,核心业务组件显示等阶段)的优化也在持续进行。直播间二级插件是在使用二级插件中的功能时才会触发下载安装及加载逻辑,完载链路中也注意到了用户点击到页面 onCreate 这段耗时,见下图。

△页面启动耗时示意图

为优化直播间完载指标,直播团队考虑如果将插件加载与页面启动并行,那么完载耗时也会有一定的优化。直播团队继续设计了二级插件预加载方案,将二级插件加载位置提前到了用户点击的时候(该功能上线在 5.4、5.5 章节对应功能前)。该功能上线后试验组与对照组数据显示,实验组完载耗时较对照组确实有 300ms+ 优化。但起播耗时却出现了异常,实验组的起播耗时明显比对照组增长了 500ms+,且随版本收敛这个起播劣化还在增加。我们马上很快发现了这个异常,并通过数据分析确定了这个数据是正确的。完载的优化时如何引起起播变化的?

经过数据分析,我们发现起播受影响的主要位置还是内核首帧消息分发到主线程这个分段引起,也就是二级插件加载越早,内核首帧分发与二级组件加载时的耗时任务冲突可能性越大。确认问题原因后,我们做了 5.4、5.5 章节的功能来降低二级组件加载任务对起播影响。由于二级插件中的耗时任务完全拆分打散来缓解二级插件预下载带来的起播劣化方案复杂度较高,对直播间逻辑侵入太大,二级插件提前加载没有完全上线,完载的优化我们设计了其他方案来实现目标。

虽然不能在进入直播间时直接加载二级插件,但我们可以在进入直播间前尽量将二级插件下载下来,使用时直接加载即可,这个耗时相对下载耗时是非常小的。我们优化了插件预下载模块,在直播间外展示直播资源时触发该模块预下载插件。该模块会通过对当前设备网络、带宽、下载频次等条件综合判断,在合适的时机将匹配的二级插件进行下载,插件提前下载后对完载指标有较大优化。除了插件预下载,直播间内通过 5.4 章节直播间二级组件初始化拆分,也将全部组件初始化对主线程阻塞进行了优化,这样接口数据请求成功后可以优先处理影响完载统计的组件,其他组件可以在完载结束后再进行初始化,这个方案也对直播完载指标有明显优化。

除了以上两个优化方案,直播团队还在其他多个方向对完载指标进行了优化,同时也处理了完载时长与起播时长的指标平衡,没有因为一个指标优化而对其他指标造成劣化影响。最终实现了起播、完载指标全部达到目标。

06 收益

△2022 Android 端起播耗时走势

经过以上多个优化方案逐步迭代,目前 Android 端最新版本数据,大盘起播时间已经由 3s+ 降到 1.3s 左右;一跳带流地址时起播时长由 2.5s+ 左右降低到 1s 以内;二跳起播时长由 1s+ 降低到 700ms 以内,成功完成了预定目标。

07 展望

起播时长作为直播功能一个核心指标,还需要不断打磨和优化。除了业务架构上的优化,还有优化拉流协议、优化缓冲配置、自适应网速起播、优化 gop 配置、边缘节点加速等多个方向可以探索。百度直播团队也会持续深耕直播技术,为用户带来越来越好的直播体验。

作者:任雪龙
来源:百度Geek说 juejin.cn/post/7174596046641692709

收起阅读 »

Android Jetpack:利用Palette进行图片取色

与产品MM那些事新来一个产品MM,因为比较平,我们就叫她A妹吧。A妹来第一天就指出:页面顶部的Banner广告位的背景是白色的,太单调啦,人家不喜欢啦,需要根据广告图片的内容自动切换背景颜色,颜色要与广告图主色调一致。作为一名合格的码农我直接回绝了,我说咱们的...
继续阅读 »

与产品MM那些事

新来一个产品MM,因为比较平,我们就叫她A妹吧。A妹来第一天就指出:页面顶部的Banner广告位的背景是白色的,太单调啦,人家不喜欢啦,需要根据广告图片的内容自动切换背景颜色,颜色要与广告图主色调一致。作为一名合格的码农我直接回绝了,我说咱们的应用主打简洁,整这花里胡哨的干嘛,劳民伤财。A妹也没放弃,与我深入交流了一夜成功说服了我。

其实要实现这个需求也不难,Google已经为我们提供了一个方便的工具————Palette。

前言

Palette即调色板这个功能其实很早就发布了,Jetpack同样将这个功能也纳入其中,想要使用这个功能,需要先依赖库

implementation 'androidx.palette:palette:1.0.0'

本篇文章就来讲解一下如何使用Palette在图片中提取颜色。

创建Palette

创建Palette其实很简单,如下

var builder = Palette.from(bitmap)
var palette = builder.generate()

这样,我们就通过一个Bitmap创建一个Pallete对象。

注意:直接使用Palette.generate(bitmap)也可以,但是这个方法已经不推荐使用了,网上很多老文章中依然使用这种方式。建议还是使用Palette.Builder这种方式。

generate()这个函数是同步的,当然考虑图片处理可能比较耗时,Android同时提供了异步函数

public AsyncTask<BitmapVoidPalette> generate(
       @NonNull final PaletteAsyncListener listener) {

通过一个PaletteAsyncListener来获取Palette实例,这个接口如下:

public interface PaletteAsyncListener {
   /**
    * Called when the {@link Palette} has been generated. {@code null} will be passed when an
    * error occurred during generation.
    */
   void onGenerated(@Nullable Palette palette);
}

提取颜色

有了Palette实例,就可以通过Palette对象的相应函数就可以获取图片中的颜色,而且不只一种颜色,下面一一列举:

  • getDominantColor:获取图片中的主色调

  • getMutedColor:获取图片中柔和的颜色

  • getDarkMutedColor:获取图片中柔和的暗色

  • getLightMutedColor:获取图片中柔和的亮色

  • getVibrantColor:获取图片中有活力的颜色

  • getDarkVibrantColor:获取图片中有活力的暗色

  • getLightVibrantColor:获取图片中有活力的亮色

这些函数都需要提供一个默认颜色,如果这个颜色Swatch无效则使用这个默认颜色。光这么说不直观,我们来测试一下,代码如下:

var bitmap = BitmapFactory.decodeResource(resourcesR.mipmap.a)
var builder = Palette.from(bitmap)
var palette = builder.generate()
color0.setBackgroundColor(palette.getDominantColor(Color.WHITE))
color1.setBackgroundColor(palette.getMutedColor(Color.WHITE))
color2.setBackgroundColor(palette.getDarkMutedColor(Color.WHITE))
color3.setBackgroundColor(palette.getLightMutedColor(Color.WHITE))
color4.setBackgroundColor(palette.getVibrantColor(Color.WHITE))
color5.setBackgroundColor(palette.getDarkVibrantColor(Color.WHITE))
color6.setBackgroundColor(palette.getLightVibrantColor(Color.WHITE))

运行后结果如下:


这样各个颜色的差别就一目了然。除了上面的函数,还可以使用getColorForTarget这个函数,如下:

@ColorInt
public int getColorForTarget(@NonNull final Target target@ColorInt final int defaultColor) {

这个函数需要一个Target,提供了6个静态字段,如下:

/**
* A target which has the characteristics of a vibrant color which is light in luminance.
*/
public static final Target LIGHT_VIBRANT;

/**
* A target which has the characteristics of a vibrant color which is neither light or dark.
*/
public static final Target VIBRANT;

/**
* A target which has the characteristics of a vibrant color which is dark in luminance.
*/
public static final Target DARK_VIBRANT;

/**
* A target which has the characteristics of a muted color which is light in luminance.
*/
public static final Target LIGHT_MUTED;

/**
* A target which has the characteristics of a muted color which is neither light or dark.
*/
public static final Target MUTED;

/**
* A target which has the characteristics of a muted color which is dark in luminance.
*/
public static final Target DARK_MUTED;

其实就是对应着上面除了主色调之外的六种颜色。

文字颜色自动适配

在上面的运行结果中可以看到,每个颜色上面的文字都很清楚的显示,而且它们并不是同一种颜色。其实这也是Palette提供的功能。

通过下面的函数,我们可以得到各种色调所对应的Swatch对象:

  • getDominantSwatch

  • getMutedSwatch

  • getDarkMutedSwatch

  • getLightMutedSwatch

  • getVibrantSwatch

  • getDarkVibrantSwatch

  • getLightVibrantSwatch

注意:同上面一样,也可以通过getSwatchForTarget(@NonNull final Target target)来获取

Swatch类提供了以下函数:

  • getPopulation(): 样本中的像素数量

  • getRgb(): 颜色的RBG值

  • getHsl(): 颜色的HSL值

  • getBodyTextColor(): 能都适配这个Swatch的主体文字的颜色值

  • getTitleTextColor(): 能都适配这个Swatch的标题文字的颜色值

所以我们通过getBodyTextColor()getTitleTextColor()可以很容易得到在这个颜色上可以很好现实的标题和主体文本颜色。所以上面的测试代码完整如下:

var bitmap = BitmapFactory.decodeResource(resourcesR.mipmap.a)
var builder = Palette.from(bitmap)
var palette = builder.generate()

color0.setBackgroundColor(palette.getDominantColor(Color.WHITE))
color0.setTextColor(palette.dominantSwatch?.bodyTextColor ?Color.WHITE)

color1.setBackgroundColor(palette.getMutedColor(Color.WHITE))
color1.setTextColor(palette.mutedSwatch?.bodyTextColor ?Color.WHITE)

color2.setBackgroundColor(palette.getDarkMutedColor(Color.WHITE))
color2.setTextColor(palette.darkMutedSwatch?.bodyTextColor ?Color.WHITE)

color3.setBackgroundColor(palette.getLightMutedColor(Color.WHITE))
color3.setTextColor(palette.lightMutedSwatch?.bodyTextColor ?Color.WHITE)

color4.setBackgroundColor(palette.getVibrantColor(Color.WHITE))
color4.setTextColor(palette.vibrantSwatch?.bodyTextColor ?Color.WHITE)

color5.setBackgroundColor(palette.getDarkVibrantColor(Color.WHITE))
color5.setTextColor(palette.darkVibrantSwatch?.bodyTextColor ?Color.WHITE)

color6.setBackgroundColor(palette.getLightVibrantColor(Color.WHITE))
color6.setTextColor(palette.lightVibrantSwatch?.bodyTextColor ?Color.WHITE)

这样每个颜色上的文字都可以清晰的显示。

那么这个标题和主体文本颜色有什么差别,他们又是如何的到的?我们来看看源码:

/**
* Returns an appropriate color to use for any 'title' text which is displayed over this
* {@link Swatch}'s color. This color is guaranteed to have sufficient contrast.
*/
@ColorInt
public int getTitleTextColor() {
   ensureTextColorsGenerated();
   return mTitleTextColor;
}

/**
* Returns an appropriate color to use for any 'body' text which is displayed over this
* {@link Swatch}'s color. This color is guaranteed to have sufficient contrast.
*/
@ColorInt
public int getBodyTextColor() {
   ensureTextColorsGenerated();
   return mBodyTextColor;
}

可以看到都会先执行ensureTextColorsGenerated(),它的源码如下:

private void ensureTextColorsGenerated() {
   if (!mGeneratedTextColors) {
       // First check white, as most colors will be dark
       final int lightBodyAlpha = ColorUtils.calculateMinimumAlpha(
               Color.WHITEmRgbMIN_CONTRAST_BODY_TEXT);
       final int lightTitleAlpha = ColorUtils.calculateMinimumAlpha(
               Color.WHITEmRgbMIN_CONTRAST_TITLE_TEXT);

       if (lightBodyAlpha != -1 && lightTitleAlpha != -1) {
           // If we found valid light values, use them and return
           mBodyTextColor = ColorUtils.setAlphaComponent(Color.WHITElightBodyAlpha);
           mTitleTextColor = ColorUtils.setAlphaComponent(Color.WHITElightTitleAlpha);
           mGeneratedTextColors = true;
           return;
      }

       final int darkBodyAlpha = ColorUtils.calculateMinimumAlpha(
               Color.BLACKmRgbMIN_CONTRAST_BODY_TEXT);
       final int darkTitleAlpha = ColorUtils.calculateMinimumAlpha(
               Color.BLACKmRgbMIN_CONTRAST_TITLE_TEXT);

       if (darkBodyAlpha != -1 && darkTitleAlpha != -1) {
           // If we found valid dark values, use them and return
           mBodyTextColor = ColorUtils.setAlphaComponent(Color.BLACKdarkBodyAlpha);
           mTitleTextColor = ColorUtils.setAlphaComponent(Color.BLACKdarkTitleAlpha);
           mGeneratedTextColors = true;
           return;
      }

       // If we reach here then we can not find title and body values which use the same
       // lightness, we need to use mismatched values
       mBodyTextColor = lightBodyAlpha != -1
               ? ColorUtils.setAlphaComponent(Color.WHITElightBodyAlpha)
              : ColorUtils.setAlphaComponent(Color.BLACKdarkBodyAlpha);
       mTitleTextColor = lightTitleAlpha != -1
               ? ColorUtils.setAlphaComponent(Color.WHITElightTitleAlpha)
              : ColorUtils.setAlphaComponent(Color.BLACKdarkTitleAlpha);
       mGeneratedTextColors = true;
  }
}

通过代码可以看到,这两种文本颜色实际上要么是白色要么是黑色,只是透明度Alpha不同。

这里面有一个关键函数,即ColorUtils.calculateMinimumAlpha()

public static int calculateMinimumAlpha(@ColorInt int foreground@ColorInt int background,
       float minContrastRatio) {
   if (Color.alpha(background!= 255) {
       throw new IllegalArgumentException("background can not be translucent: #"
               + Integer.toHexString(background));
  }

   // First lets check that a fully opaque foreground has sufficient contrast
   int testForeground = setAlphaComponent(foreground255);
   double testRatio = calculateContrast(testForegroundbackground);
   if (testRatio < minContrastRatio) {
       // Fully opaque foreground does not have sufficient contrast, return error
       return -1;
  }

   // Binary search to find a value with the minimum value which provides sufficient contrast
   int numIterations = 0;
   int minAlpha = 0;
   int maxAlpha = 255;

   while (numIterations <= MIN_ALPHA_SEARCH_MAX_ITERATIONS &&
          (maxAlpha - minAlpha> MIN_ALPHA_SEARCH_PRECISION) {
       final int testAlpha = (minAlpha + maxAlpha/ 2;

       testForeground = setAlphaComponent(foregroundtestAlpha);
       testRatio = calculateContrast(testForegroundbackground);

       if (testRatio < minContrastRatio) {
           minAlpha = testAlpha;
      } else {
           maxAlpha = testAlpha;
      }

       numIterations++;
  }

   // Conservatively return the max of the range of possible alphas, which is known to pass.
   return maxAlpha;
}

它根据背景色和前景色计算前景色最合适的Alpha。这期间如果小于minContrastRatio则返回-1,说明这个前景色不合适。而标题和主体文本的差别就是这个minContrastRatio不同而已。

回到ensureTextColorsGenerated代码可以看到,先根据当前色调,计算出白色前景色的Alpha,如果两个Alpha都不是-1,就返回对应颜色;否则计算黑色前景色的Alpha,如果都不是-1,返回对应颜色;否则标题和主体文本一个用白色一个用黑色,返回对应颜色即可。

更多功能

上面我们创建Palette时先通过Palette.from(bitmap)的到了一个Palette.Builder对象,通过这个builder可以实现更多功能,比如:

  • addFilter:增加一个过滤器

  • setRegion:设置图片上的提取区域

  • maximumColorCount:调色板的最大颜色数 等等

总结

通过上面我们看到,Palette的功能很强大,但是它使用起来非常简单,可以让我们很方便的提取图片中的颜色,并且适配合适的文字颜色。同时注意因为ColorUtils是public的,所以当我们需要文字自动适配颜色的情况时,也可以通过ColorUtils的几个函数自己实现计算动态颜色的方案。

作者:BennuCTech
来源:juejin.cn/post/7077380907333582879

收起阅读 »

炸裂的点赞动画

前言之前偶然间看到某APP点赞有个炸裂的效果,觉得有点意思,就尝试了下,轻微还原,效果图如下封装粒子从动画效果中我们可以看到,当动画开始的时候,会有一组粒子从四面八方散射出去,然后逐渐消失,于是可以定义一个粒子类包含以下属性public class Parti...
继续阅读 »

前言

之前偶然间看到某APP点赞有个炸裂的效果,觉得有点意思,就尝试了下,轻微还原,效果图如下


封装粒子

从动画效果中我们可以看到,当动画开始的时候,会有一组粒子从四面八方散射出去,然后逐渐消失,于是可以定义一个粒子类包含以下属性

public class Particle {
  public float x, y;
  public float startXV;
  public float startYV;
  public float angle;
  public float alpha;
  public Bitmap bitmap;
  public int width, height;
}
  • x,y是粒子的位置信息

  • startXV,startYV是X方向和Y方向的速度

  • angle是发散出去的角度

  • alpha是粒子的透明度

  • bitmap, width, height即粒子图片信息 我们在构造函数中初始化这些信息,给定一些默认值

public Particle(Bitmap originalBitmap) {
  alpha = 1;
  float scale = (float) Math.random() * 0.3f + 0.7f;
  width = (int) (originalBitmap.getWidth() * scale);
  height = (int) (originalBitmap.getHeight() * scale);
  bitmap = Bitmap.createScaledBitmap(originalBitmap, width, height, true);

  startXV = new Random().nextInt(150) * (new Random().nextBoolean() ? 1 : -1);
  startYV = new Random().nextInt(170) * (new Random().nextBoolean() ? 1 : -1);
  int i = new Random().nextInt(360);
  angle = (float) (i * Math.PI / 180);

  float rotate = (float) Math.random() * 180 - 90;
  Matrix matrix = new Matrix();
  matrix.setRotate(rotate);
  bitmap = Bitmap.createBitmap(bitmap, 0, 0, width, height, matrix, false);
  originalBitmap.recycle();
}

仔细看效果动画,会发现同一个图片每次出来的旋转角度会有不同,于是,在创建bitmap的时候我们随机旋转下图片。

绘制粒子

有了粒子之后,我们需要将其绘制在View上,定义一个ParticleView,重写onDraw()方法,完成绘制

public class ParticleView extends View {
   Paint paint;
   List<Particle> particles = new ArrayList<>();
   //.....省略构造函数
   @Override
   protected void onDraw(Canvas canvas) {
       super.onDraw(canvas);
       for (Particle particle : particles) {
           paint.setAlpha((int) (particle.alpha * 255));
           canvas.drawBitmap(particle.bitmap, particle.x - particle.width / 2, particle.y - particle.height / 2, paint);
      }
  }
   public void setParticles(List<Particle> particles) {
       this.particles = particles;
  }
}

管理粒子

绘制的时候我们发现需要不断改变粒子的x,y值,才能使它动起来,所以我们需要一个ValueAnimator,然后通过监听动画执行情况,不断绘制粒子。

private void startAnimator(View emiter) {
  ValueAnimator valueAnimator = ObjectAnimator.ofInt(0, 1).setDuration(1000);
  valueAnimator.addUpdateListener(animation -> {
      for (Particle particle : particles) {
          particle.alpha = 1 - animation.getAnimatedFraction();
          float time = animation.getAnimatedFraction();
          time *= 10;
          particle.x = startX - (float) (particle.startXV * time * Math.cos(particle.angle));
          particle.y = startY - (float) (particle.startYV * time * Math.sin(particle.angle) - 9.8 * time * time / 2);
      }
      particleView.invalidate();
  });
  valueAnimator.start();
}

由于我们的点赞按钮经常出现在RecyclerView的item里面,而点赞动画又是全屏的,所以不可能写在item的xml文件里面,而且我们需要做到0侵入,在不改变原来的逻辑下添加动画效果。

我们可以通过activity.findViewById(android.R.id.content)获取FrameLayout然后向他添加子View

public ParticleManager(Activity activity, int[] drawableIds) {
  particles = new ArrayList<>();
  for (int drawableId : drawableIds) {
      particles.add(new Particle(BitmapFactory.decodeResource(activity.getResources(), drawableId)));
  }
  topView = activity.findViewById(android.R.id.content);
  topView.getLocationInWindow(parentLocation);
}

首先我们通过构造函数传入当前Activity以及我们需要的图片资源,然后将图片资源都解析成Particle对象,保存在particles中,然后获取topView以及他的位置信息。

然后需要知道动画从什么位置开始,传入一个view作为锚点

public void start(View emiter) {
  int[] location = new int[2];
  emiter.getLocationInWindow(location);
  startX = location[0] + emiter.getWidth() / 2 - parentLocation[0];
  startY = location[1] - parentLocation[1];
  particleView = new ParticleView(topView.getContext());
  topView.addView(particleView);
  particleView.setParticles(particles);
  startAnimator(emiter);
}

通过传入一个emiter,计算出起始位置信息并初始化particleView中的粒子信息,最后开启动画。

使用

val ids = ArrayList<Int>()
for (index in 1..18) {
   val id = resources.getIdentifier("img_like_$index", "mipmap", packageName);
   ids.add(id)
}
collectImage.setOnClickListener {
   ParticleManager(this, ids.toIntArray())
      .start(collectImage)
}

运行之后会发现基本和效果图一致,但是其实有个潜在的问题,我们只是向topView添加了view,并没有移除,虽然界面上看不到,其实只是因为我们的粒子在最后透明度都是0了,将粒子透明度最小值设置为0.1后运行会发现,动画结束之后粒子没有消失,会越堆积越多,所以我们还需要移除view。

valueAnimator.addListener(new AnimatorListenerAdapter() {
   @Override
   public void onAnimationStart(Animator animation, boolean isReverse) {
  }
   @Override
   public void onAnimationEnd(Animator animation) {
       topView.removeView(particleView);
       topView.postInvalidate();
  }
   @Override
   public void onAnimationCancel(Animator animation) {
       topView.removeView(particleView);
       topView.postInvalidate();
  }
});

移除的时机放在动画执行完成,所以继续使用之前的valueAnimator,监听他的完成事件,移除view,当然,如果动画取消了也需要移除。

作者:晚来天欲雪_
来源:juejin.cn/post/7086471790502871054

收起阅读 »

你需要了解的android注入技术

背景在android系统中,进程之间是相互隔离的,两个进程之间是没办法直接跨进程访问其他进程的空间信息的。那么在android平台中要对某个app进程进行内存操作,并获取目标进程的地址空间内信息或者修改目标进程的地址空间内的私有信息,就需要涉及到注入技术。通过...
继续阅读 »

背景

在android系统中,进程之间是相互隔离的,两个进程之间是没办法直接跨进程访问其他进程的空间信息的。那么在android平台中要对某个app进程进行内存操作,并获取目标进程的地址空间内信息或者修改目标进程的地址空间内的私有信息,就需要涉及到注入技术。

通过注入技术可以将指定so模块或代码注入到目标进程中,只要注入成功后,就可以进行访问和篡改目标进程空间内的信息,包括数据和代码。

Android的注入技术的应用场景主要是进行一些非法的操作和实现如游戏辅助功能软件、恶意功能软件。

zygote注入

zygote是一个在android系统中是非常重要的一个进程,因为在android中绝大部分的应用程序进程都是由它孵化(fork)出来的,fork是一种进程复用技术。也就是说在android系统中普通应用APP进程的父亲都是zygote进程。

zygote注入目的就是将指定的so模块注入到指定的APP进程中,这个注入过程不是直接向指定进程进程注入so模块,而是先将so模块注入到zygote进程。

在so模块注入到zygote进程后,在点击操作android系统中启动的应用程序APP进程,启动的App进程中包括需要注入到指定进程的so模块,太都是由zygote进程fork生成,因而在新创建的进程中都会包含已注入zygote进程的so模块。

这种的注入是通过间接注入方式完成的,也是一种相对安全的注入so模块方式。目前xposed框架就是基于zygote注入。

1.通过注入器将要注入的so模块注入到zygote进程;

2.手动启动要注入so模块的APP进程,由于APP进程是通过zygote进程fork出来的,所以启动的APP进程都包含zygote进程中所有模块;

3.注入的so模块劫持被注入APP进程的控制权,执行注入so模块的代码;

4.注入so模块归还APP进程的控制权,被注入进程正常运行。

(注入器主要是基于ptrace注入shellcode方式的进程注入)

通过ptrace进行附加到zygote进程。

调用mmap申请目标进程空间,用于保存注入的shellcode汇编代码。

执行注入shellcode代码(shellcode代码是注入目标进程中并执行的汇编代码)。

调用munmap函数释放申请的内存。

通过ptrace进行剥离zygote进程。

下面是关键的zygote代码注入实现




ptrace注入

ptrace注入实现上分类:

通过利用ptrace函数将shellcode注入远程进程的内存空间中,然后通过执行shellcode加载远程进程so模块。

通过直接远程调用dlopen、dlsym、dlclose等函数加载被注入so模块,并执行指定的代码。

ptrace直接调用函数注入流程:

通过利用ptrace进行附加到要注入的进程;

保存寄存环境;

远程调用mmap函数分配内存空间;

向远程进程内存空间写入加载模块名称和函数名称;

远程调用dlopen函数打开注入模块;

远程调用dlsym函数或需要调用的函数地址;

远程调用被注入模块的函数;

恢复寄存器环境;

利用ptrace从远程进程剥离。

关键的ptrace直接调用系统函数实现



shellcode注入就是通过将dlopen/dlsym库函数的操作放在shellcode代码中,注入函数只是通过对远程APP进程进行内存空间申请,接着修改shellcode 代码中有关dlopen、dlsymdlclose等函数使用到的参数信息,然后将shellcode代码注入到远程APP进程申请的空间中,最后通过修改PC寄存器的方式来执行shellcode 的代码。

关键 的ptrace注入shellcode代码实现



修改ELF文件注入

在android平台Native层的可执行文件SO文件,它是属于ELF文件格式,通过修改ELF文件格式可以实现对so文件的注入。

通过修改ELF二进制的可执行文件,并在ELF文件中添加自己的代码,使得可执行文件在运行时会先执行自定义添加的代码,最后在执行ELF文件的原始逻辑。

修改二进制ELF文件需要关注两个重要的结构体:

其中ELF Header 它是ELF文件中唯一的,一个固定位置的文件结构,它保存着Program Header Table和Section Header Table的位置和大小信息。

修改ELF文件实现so文件注入实现原理为:通过修改 Program Header Table中的依赖库信息,添加自定义的so文件信息,APP进程运行加载被该修改过的ELF文件,它也同时会加载并运行自定义的so文件。

Program Header Table表项结构


程序头表项中的类型选项有如下


当程序头表项结构中的类型为PT_DYNAMIC也就是动态链接信息的时候,它是由程序头表项的偏移(p_offset)和p_filesz(大小)指定的数据块指向.dynamic段。这个.dynamic段包含程序链接和加载时的依赖库信息。

关键ELF文件修改代码实现



作者:小道安全
来源:juejin.cn/post/7077940770941960223

收起阅读 »

开发一个APP多少钱?

开发一个APP多少钱?开发一个APP要多少钱?相信不光是客户有这个疑问,就算是一般的程序员也想知道答案。很多程序员想在业余时间接外包挣外快,但是他们常常不知道该如何定价,如何有说服力的要价。这是因为没有一套好的计算APP开发成本的方法。由于国内没有公开的数据,...
继续阅读 »

开发一个APP多少钱?

开发一个APP要多少钱?相信不光是客户有这个疑问,就算是一般的程序员也想知道答案。很多程序员想在业余时间接外包挣外快,但是他们常常不知道该如何定价,如何有说服力的要价。这是因为没有一套好的计算APP开发成本的方法。由于国内没有公开的数据,而且大家对于报价都喜欢藏着掖着,这里我们就整理了国外一些软件外包平台的资料,帮助大家对Flutter APP开发成本有一个直观而立体的认识。(注意,这里是以美元单位计算,请不要直接转换为RMB,应当根据消费力水平来衡量)

跨平台项目正在慢慢取代原生应用程序的开发。跨平台的方法更省时,也更节省成本。最近,原生应用程序的主要优势是其性能。但随着新的跨平台框架给开发者带来更多的力量,这不再是它们的强项。

Flutter就是其中之一。这个框架在2017年发布,并成为跨平台社区中最受推崇的框架之一。Statista称,Flutter是2021年十大最受欢迎的框架之一,并在最受欢迎的跨平台框架中排名第一。对于这样一项新技术来说,这是一个相当不错的结果。它的高需求使我们可以定义软件建设的大致成本。

Flutter应用程序的开发成本根据项目定义的工作范围而变化:

  • 简单的 Flutter 应用程序:$40,000 - $60,000

  • 中等复杂度应用程序:$60,000 – $120,000

  • 高度复杂的 Flutter 应用程序:$120,000 – $200,000+

有一些决定性的因素来回答Flutter应用开发的成本是多少。

在这篇文章中,我们将讨论不同行业的Flutter应用开发成本,找出如何计算精确的价格,以及如何利用这个框架削减项目开支。

Flutter应用的平均开发成本

应用程序的开发成本是一个复杂的数字,取决于各种因素 ——功能的复杂性,开发人员的位置,支持的平台,等等。如果不进行研究和了解所有的要求,就不可能得出项目的价格。

不过,你还是可以看看按项目复杂程度分类的估算:

  • 一个具有简单功能的软件,如带有锻炼建议、膳食计划、个人档案和体重日记的健身应用,其成本从26,000美元到34,800美元

  • 一个中等复杂度的软件,如带有语音通话、消息通信,Flutter应用的开发成本将从34,950美元到48,850美元不等

  • 开发一个像 Instagram 这样具有复杂功能的应用程序的成本将从41,500美元到55,000美元不等

影响价格的因素

为了明确 Flutter 应用开发成本的所有组成部分,我们将挑选出每个因素并分析其对价格的影响。

原生应用开发 vs. Flutter

当我们估算一个原生项目时,我们要考虑到两个平台的开发时间。Flutter是一个跨平台的框架,可以让开发者为Android和iOS编写同一个代码库。这一特点使开发时间减半,使Flutter应用程序的开发成本比原生的低

Flutter 的非凡之处在于它优化了代码并且没有性能问题。Flutter在所有设备上都能提供稳定的接近 60 FPS,如果设备支持,甚至可以提供120 FPS。

然而,Flutter也有一些缺点。如果你的项目需要Wear OS版本或智能电视应用,就会面临一些麻烦。从技术上讲,你可以为这些平台建立一个Flutter应用程序。但是,Flutter的很多开发功能并不被Wear OS所支持。在安卓电视的情况下,必须从头开始建立控制逻辑。原因是安卓电视只读取遥控器的输入,而Flutter则适用于触摸屏和鼠标移动。这一事实会减慢开发进程,给开发者带来麻烦,并增加Flutter应用的开发成本。

这就是为什么如果你的目标是特定的平台,最好去做原生开发。

功能的复杂性

功能是应用程序的主要组成部分。也是影响Flutter应用程序开发成本的主要因素。简单的功能(如登录)需要最少的工作量,而视频通话的集成可能需要长达 2-3 周的开发时间。

让我们想象一下,要建立一个类似 Instagram 的应用程序。照片上传功能需要大约13小时的开发时间。以每小时50美元的平均费率计算,这将花费650美元。然而,要建立用于照片编辑的过滤器,开发团队将不得不花费30至120小时,这取决于它们的类型和数量。一家软件开发公司将为这个功能收取1500-6000美元。

Flutter应用开发中最昂贵的功能

功能描述大约时间(小时)大约成本($50/h)
导航位置地图开发194$9,700
聊天视频、音频、文字聊天188$9,400
支付集成与 PayPal 集成,添加信用卡支付70$3,500

开发商的位置和所选择的雇用方式

影响总成本的另一个方面是你在雇用项目专家时选择的就业方式:

自由职业者

由于有机会减少开发费用,这种选择被广泛采用。然而,就Flutter应用的开发而言,无法保证自由职业者的能力和质量。此外,在支持、维护和更新服务方面,这样的专家也没有优势,因为他们可能会转到另一个项目,从而无法建立长期的合作伙伴关系。

内部团队

在这种情况下,你要负责项目开发管理,以及搜索和检查潜在雇主的经验和知识。此外,内部团队的聚集需要一排额外的费用,如购买硬件,租用办公室,病假,工资,等等。因此,这些条件大大增加了总成本。

外包公司

项目外包指的是已经组建的专家团队,具有成熟深入的资质,接手所有的创作过程。这种选择是一种节省开发投资和避免影响产品质量的好方法。除了这个事实之外,这里还有一些你将通过外包获得的好处。

  • 成本的灵活性。全球市场提供了很多准备以合理价格提供服务的外包软件开发公司。中欧已经成为实现这一目标的顶级地区,许多企业已经从来自该地的优秀开发人员的一流表现中受益。

  • 可扩展性。可以根据您的要求调整开发流程:此类公司的团队包括所有类型的开发人员,将在需要他们的能力时参与创建过程。此外,如果有必要的话,这也是加快项目完成的绝佳方式。外包提供了多种合作模式。 从专门的团队到工作人员的增援

  • 更快的产品交付。有了外包,就不需要在招聘上花费时间。你可以调整项目创建速度,例如,让更多的专家参与进来。因此,进入市场的时间缩短了,支出也减少了。只为已完成的工作付费。

  • 庞大的人才库。IT外包包括大量具有丰富专业知识和经验的技术专家。外包商为企业提供灵活的招聘机会。你可以在全球范围大量的的软件架构师中选择。

  • 可应用的技术非常多样化。根据你的项目要求,你可以从这些公司中选择一个具有相关专业知识的专家。

除了雇佣选择,开发团队的位置可能会对Flutter应用程序的开发成本产生很大的影响。在不同地区,开发人员有不同的价格。在美国,开发人员的平均费率是60美元/小时,而在爱沙尼亚,只有37美元/小时。

在下面的表格中,可以找到开发人员的每小时费率,并将它们进行比较。

Flutter开发人员在不同地区的费率:

地区每小时费率 ($)
北美$75 - $120
拉丁美洲$30 - $50
西欧$70 - $90
爱沙尼亚$30 - $50
印度$25 - $40
澳大利亚$41 - $70
非洲$20 - $49

如何计算 Flutter 应用开发成本

正如前面提到的,功能对Flutter应用开发成本的影响最大。Flutter 适用于不包含原生功能的项目。但是当涉及到地图、流媒体、AR和后台进程时,开发人员必须为iOS和Android单独构建这些功能,然后再与Flutter结合。

让我们回到例子上。如果是原生开发,你将需要大约60-130个小时在你的应用程序中实现AR过滤器。Flutter开发将需要约80-150小时,因为AR是一个原生功能。考虑到50美元/小时的费率,我们应该把它乘以开发时间。这个公式可以用来计算出最终的Flutter应用开发成本。

除了这个公式外,还有一件事在初始阶段很重要。

发现阶段

一个糟糕的发现阶段可能导致整个项目的崩溃。但为什么这个阶段如此重要?在发现阶段,业务分析人员和项目经理与你举行会议,找出可能的风险,并提出消除这些风险的解决方案

粗略估算

粗略估算的精确度从75%到25%不等。这个评估包括在客户和软件团队合作的初级阶段。它也有助于双方决定是否成为合作伙伴。粗略估算的主要目的是计算完成项目所需的最短和最长时间以及大致的总成本,以便客户知道在开发流程中需要多少投资。此外,这个估算包括整个创建过程,分为几个阶段。这个文件不应该被认为是有固定条款和条件的文件。它是为客户准备的,只是为了通知他们。

一个粗略的估算包括:

  • 主要部分包含准备工作。它们在不同的项目中都是一样的,包括产品描述、数据库设置、REST架构。该部分所指出的项目不一定一次就能完成。有些工作是在整个项目中完成的。

  • 开发与加密过程有关。这部分包括要实现的功能、屏幕和特性。开发部分包括 "业务逻辑 "和 "UI/UX "要求,以及某部分工作的小时数。

  • 为了更有效地实现功能,需要整合框架和库,并相应减少开发时间和相应的花费。

  • 非开发工作主要与技术写作有关。专家们准备详细的代码文档和准备有关产品创建的其他数据。

  • 建议部分包含了各种改进建议。

当所有的问题都解决后,会进入发现阶段并创建一个项目规范。客户必须积极参与,因为会根据客户提供的数据来建立项目规范。在下一个阶段,客户应当创建他们的应用程序草稿图。这是一个用户界面元素在屏幕上的位置示意图。

然后,开发人员和业务分析师会对客户的Flutter应用开发成本进行详细的估算。有了准确的预算、项目要求和草稿图,就可以签署合同并开始开发阶段。

如你所见,发现阶段是任何项目的关键部分。没有这个阶段,你就无法知道开发所需的价格和时间,因为会有太多的变数。如果在任何阶段出了问题,整个项目的计划就会出问题。这就是为什么客户必须与软件开发公司合作,使他们能够建立客户需要的项目。

额外费用

就像任何其他产品一样,客户的应用程序需要维护和更新,以便在市场上保持成功。这导致了影响Flutter应用程序开发成本的额外费用。

服务器

如果要处理和存储用户产生的数据,就必须考虑到服务器的问题。脆弱的服务器会导致用户方面的低性能和高响应时间。此外,不可靠的服务器和脆弱的保护系统会导致你的用户的个人数据泄露。为了减少风险,团队只信任可靠的供应商,如亚马逊EC2。根据AWS价格计算器,一台8核CPU和32G内存的工作服务器将花费大约1650美元/年。在计算整个Flutter应用程序的开发成本时,请牢记这笔费用。

UI/UX设计

移动应用的导航、排版和配色是UI/UX设计师应该注意的主要问题。他们还应该向你提供你的应用程序的原型。根据你的应用程序的复杂性,设计可能需要40到90多个小时。这一行的费用将使Flutter应用的开发成本提高到2000-4500美元

发布到应用商店

当你已经有了一个成品,你必须在某个地方发布它。Google Play和App Store是应用程序分发的主要平台。然而,这些平台在应用发布前会收取费用:

  • Google Play 帐号一次收取25美元,可以永久使用

  • 而Apple Store 收取99美元的年费,只要你的APP还想待在应用商店,每年都得花费这笔钱

除此之外,这两个平台对每次产生的应用内购买行为都有30%的分成。如果你通过订阅模式发布你的应用,那你只能得到70%收益。然而,最近Google Play和App Store已经软化了他们的政策。目前,他们对每一个购买了十二个月订阅的账户只收取15%的分成。

应用维护和更新

应用商店排行榜的应用能保持其地位是有原因的。他们通过不断的升级和全新的功能吸引客户。即使你的应用是完美的,但没有更新将导致停滞,用户可能卸载你的应用程序。在完美的构想里,你应该雇用一家开发应用程序的公司。他们从一开始就为你的项目工作。注意,应用程序的维护费用在应用程序的生命周期内会上升。公司通常将Flutter应用开发成本的15-20%纳入应用维护的预算。然而,你的应用程序拥有稳定受众的时间越长,需要投入的更新资金就越多。在一定时间内,你花在更新上的钱比花在实际开发上的钱多,这并不奇怪。尽管如此,但是你的应用产生的收入多于损失,所以这是一项值得的投资。不幸的是,随着新的功能发布可能出现新的错误和漏洞。你不能对这个问题视而不见,因为它使用户体验变差,并为欺诈者提供了新的漏洞。有一些软件开发公司会提供发布后的支持,包括开发新功能、测试和修复错误。

按类型划分的开发成本

由于你已经知道影响价格的主要和次要因素,现在是时候对不同应用程序的Flutter开发成本进行概述了。这里估算了来自不同行业和不同复杂程度的几个现有应用程序的开发成本。

分别是:

  • 交通运输

  • 流媒体

  • 社交媒体

Flutter 应用程序开发成本:交通运输

示例:BlaBlaCar

功能实现的大概时间:438 小时

大概费用:21,900 美元

运输应用程序需要用户档案、司机和乘客的角色、支付网关和GPS支持。请注意,如果你使用Flutter来构建地理定位等本地功能,整个项目的开发时间可能会增加。

请注意,下面的估算不包括代码文档、框架集成、项目管理等方面的时间。

下面是一个类似BlaBlaCar的应用程序的基本功能的粗略估计,基于Flutter的交通应用开发成本:

功能开发时间(小时)大概费用(美元)
注册28$1400
登录(通过电邮和 Facebook)22$1350
推送通知20$1000
用户资料77$3850
支付系统40$2000
乘车预订80$4000
乘车支付+优惠券42$2100
地理定位26$1300
司机端103$5150

Flutter应用程序开发成本:流媒体

例子: Twitch, Periscope, YouTube Live

功能实现的大概时间: 600小时

大概的成本: $30,000

流媒体应用程序是一个复杂的软件。它要求开发团队使用流媒体协议(这不是Flutter的强项),开发与观众沟通的文本聊天,推送通知,使用智能手机的摄像头,等等。其中一些有捐赠系统,与第三方的多种集成,甚至还有付费的表情符号。以下是一个类似Twitch的应用程序的基本功能的粗略估计。

基于Flutter的流媒体应用开发成本:

功能开发时间(小时)大概费用(美元)
注册20$1000
登录(通过电邮和 Facebook)23$1150
个人资料43$2150
搜索系统36$1800
流媒体协议20$1000
播放器集成33$1650
流管理(启动/关闭,设置比特率)120$6000
聊天146$7300
捐赠系统35$1750
支付网关64$3200
频道管理40$2000
推送通知20$1000

Flutter应用程序开发成本:消息通信

例子: Facebook Messenger, WhatsApp, Telegram

功能实现的大概时间: 589小时

估计成本: $29,450

消息通信工具的功能乍一看很简单,但详细的分析证明情况恰恰相反。整合各种状态的聊天(打字,在线/离线,阅读),文件传输,语音信息需要大量的时间。如果再加上语音通话和群组聊天,事情会变得更加复杂。

让我们单独列出每个功能及其成本,基于Flutter的消息通信应用开发成本:

功能开发时间(小时)大概费用(美元)
注册45$2250
登录27$1350
聊天156$7800
发送媒体文件40$2000
语音消息35$1750
群聊57$2850
语音电话100$5000
通知15$750
设置76$3800
搜索38$1900

作者:编程之路从0到1
来源:juejin.cn/post/7170168967690977293

收起阅读 »

Android性能优化方法论

作为一名开发,性能优化是永远绕不过去的话题,在日常的开发中,我们可肯定都会接触过。Android 的性能优化其实是非常成熟的了,成熟的套路,成熟的方法论,成熟的开源框架等等。对于接触性能优化经验较少的开发者来说,可能很少有机会能去总结或者学到这些成熟的套路,方...
继续阅读 »

作为一名开发,性能优化是永远绕不过去的话题,在日常的开发中,我们可肯定都会接触过。Android 的性能优化其实是非常成熟的了,成熟的套路,成熟的方法论,成熟的开源框架等等。

对于接触性能优化经验较少的开发者来说,可能很少有机会能去总结或者学到这些成熟的套路,方法论,或者框架。所以作为一位多年长期做性能优化的开发者,在这篇文章中对性能优化的方法论做一些总结,以供大家借鉴。


性能优化的本质

首先,我先介绍一下性能优化的本质。我对其本质的认知是这样的:性能优化的本质是合理且充分的使用硬件资源,让程序的表现更好,并且程序表现更好的目的则是为了获取更多来自客户的留存,使用时长,口碑、利润等收益。

所以基于本质来思考,性能优化最重要的两件事情:

  1. 合理且充分的使用硬件资源

  1. 让程序表现更好,并取得收益

下面讲一下这两件事情。

合理且充分的使用硬件资源

充分表示能将硬件的资源充分发挥出来,但充分不一定是合理的,比如我们一下子打了几百个线程,cpu 被充分发挥了,但是并不合理,所以合理表示所发挥出来的硬件资源能给程序表现有正向的作用。

硬件资源包括:CPU,内存,硬盘,电量,流量(不属于硬件资源,不过也归于需要合理使用的资源之一)等等。

下面举几个合理且充分的使用硬件资源的例子:

  1. CPU 资源的使用率高,但并不是过载的状态,并且 cpu 资源主要为当前场景所使用,而不是被全业务所分散消耗。比如我们优化页面打开速度,速度和 cpu 有很大的关系,那么我们首先要确保 cpu 被充分发挥出来了,我们可以使用多线程、页面打开前提前预加载等策略,来发挥手机的 cpu。但是在打开页面的时候,我们要合理的确保 cpu 资源主要被打开页面相关的逻辑所使用,比如组件创建,数据获取,页面渲染等等,至于其他和当前打开页面场景联系较少的逻辑,比如周期任务,监控,或者一些预加载等等都可以关闭或者延迟,以此减少非相关任务对 cpu 的消耗,

  1. 内存资源缓使用充分,并且又能将 OOM 等异常控制在合理范围内。比如我们做内存优化,内存优化并不是越少越好,相反内存占用多可能让程序更快,但是内存占用也不能太高,所以我们可以根据不同档次机型的 OOM 率,将内存的占用控制在充分使用并且合理的状态,低端机上,通过功能降级等优化,减少内存的使用,高端机上,则可以适当提升内存的占用,让程序表现的更好。

  1. ……

让程序表现更好,并取得收益

我们有很多直接的指标来度量我性能优化取得的收益,比如做内存优化可以用 pss,java 内存占用,native 内存占用等等;做速度优化,可以用启动速度,页面打开速度;做卡顿优化,这用帧率等等。掌握这些指标很重要,我们需要知道如何能正确并且低开销的监控这些指标数据。

除了上面的直接指标外,我们还需要了解性能优化的最终体现指标,用户留存率,使用时长,转换率,好评率等指标。有时候,这些指标才是最终度量我们性能优化成果的数据,比如我们做内存优化,pss 降低了 100 M,但仅仅只是内存占用少了 100M 并没有太大的收益,如果这个 100M 体现在对应用的存活时间,转化率的提升上,那这 100 M 的优化就是值得的,我们再向上报告我们产出时,也更容易获得认可。

如何做好性能优化

讲完了性能优化的本质,我再讲讲如何做好性能优化。我主要从下面这三个方面来讲解

  1. 知识储备

  1. 思考的角度和方式

  1. 形成完整的闭环

知识储备

想要做好性能优化,特别是原创性、或者完善并且体系的、或者效果很好的优化,不是我们从网上看一些文章然后模仿一下就能进行,需要我们有比较扎实的知识储备,然后基于这些知识储备,通过深入思考,去分析我们的应用,寻找优化点。我依然举一些例子,来说明硬件层面,系统层面和软件层面的知识对我们做好性能优化的帮助。

硬件层面

在硬件层面,我们需要处理器的体系结构,存储器的层次结构有一定的了解。如果我们如果不知道 cpu 由几个核组成,哪些是大核,哪些是小核,我们就不会想到将核心线程绑定大核来提升性能的优化方案;如果我们不了解存储结构中寄存器,高速缓存,主存的设计,我们就没法针对这一特效来提升性能,比如将核心数据尽量放在高速缓存中就能提升不少速度相关的性能。

系统层面

对操作系统的熟悉和了解,也是帮助我们做好性能优化不可缺少的知识。我在这里列一下系统层面需要掌握的知识,但不是全的,Linux的知识包括进行管理和调度,内存管理,虚拟内存,锁,IPC通信等。Android系统的知识包括虚拟机,核心服务如ams,wms等等,渲染,以及一些核心流程,如启动,打开activity,安装等等。

如果我们不了解Linux系统的进程调度系统,我们就没法充分利用进程优先来帮助我们提升性能;如果我们不熟悉 Android 的虚拟机,那么围绕这虚拟机一些相关的优化,比如 oom 优化,或者是 gc 优化等等都无法很好的开展。

软件层面

软件层面就是我们自己所开发的 App,在性能优化中,我们需要对自己所开发的应用尽可能得熟悉。比如我们需要知道自己所开发的 App 有哪些线程,都是干嘛的,这些线程的 cpu 消耗情况,内存占用多少,都是哪些业务占用的,缓存命中率多少等等。我们需要知道自己所开发的 App 有哪些业务,这些使用都是干嘛的,使用率多少,对资源的消耗情况等等。

除了上面提到的三个层面的知识,想要深入做好性能优化,还需要掌握更多的知识,比如汇编,编译器、编程语言、逆向等等知识。比如用c++ 写代码就比用java写代码运行更快,我们可以通过将一些业务替换成 c++ 来提高性能;比如编译期间的内联,无用代码消除等优化能减少包体积;逆向在性能优化上的用处也非常大,通过逆向我们可以修改系统的逻辑,让程序表现的更好。

可以看到,想要做好性能优化,需要庞大的知识储备,所以性能优化是很能体现开发者技术深度和广度的,这也是面试时,一定会问性能优化相关的知识的原因。这是知识储备不是一下就能形成的,需要我们慢慢的进行学习和积累。


思考的角度及方式

讲完了知识储备,再讲讲思考的角度和方式。需要注意它和知识储备没有先后关系,并不是说要有了足够的技术知识后才能开始考虑如何思考。思考的角度和方式体现在我们开发的所有生命周期中,即使是新入门的开发,也可以锻炼自己从不同的角度和方式去进行思考。下面就聊一聊我在做性能优化的过程中,在思考的角度和方式上的一些认知。为了让大家能更形象的理解,我就都以启动优化来讲解。

思考角度

我这里主要通过应用层,系统词,硬件层这三个角度来介绍我对启动速度优化的思考。

应用层

做启动速度优化时,如果从应用层来考虑,我会基于业务的维度考虑所加载的业务的使用率,必要性等等,然后制定优先级,在启动的时候只加载首屏使用,或者使用率高的业务。所以接着我就可以设计启动框架用来管理任务,启动框架要设计好优先级,并且能对这些初始化的任务有使用率或者其他性能方面的统计,比如这些任务初始化后,被使用率的概率是多少,又或者初始化之后,对业务的表现提升提现在哪,帮助有多大。

从应用层的思考主要是基于对业务的管控或者对业务进行优化来提升性能。

系统层

以及系统层来考虑启动优化也有很多点,比如线程和线程优先级维度,在启动过程中,如何控制好线程数量,如何提高主线程的优先级,如何减少启动过程中不相关的线程,比如 gc 线程等等。

硬件层

从硬件层来考虑启动优化,我们可以从 cpu 的利用率,高速缓存cache的命中率等维度来考虑优化。

除了上面提到的这几个角度,我们还可以有更多角度。比如跳出本设备之外来思考,是否可以用其他的设备帮助我们加速启动。google play 就有类似的优化,gp会上传一些其他机器已经编译好的机器码,然后相同的设备下载这个应用时,也会带着这些编译好的机器码一起下载。还有很常用的服务端渲染技术,也是让服务端线渲染好界面,然后直接暂时静态模块来提升页面打开速度;又或者站在用户的角度去思考,想一想到底什么样的优化对用户感知上是有好处的,比如有时候我们再做启动或者页面打开速度优化,会给用户一个假的静态页面让用户感知已经打开了,然后再去绑定真实的数据。

做性能优化时,考虑的角度多一些,全面一些,能帮助我们想出更多的优化方案。

思考方式

除了锻炼我们站在不同的角度思考问题,我们还可以锻炼自己思考问题的方式,这里介绍自上而下和自下而上两种思考方式。

自上而下

我们做启动优化,自上而下的优化思路可能是直接从启动出发,然后分析启动过程中的链路,然后寻找耗时函数,将耗时函数放子线程或者懒加载处理,但是这种方式会导致优化做的不全面。比如将耗时的任务都放在子线程,我们再高端机上速度确实变快了,但是在低端机上,可能会降低了启动速度,因为低端机的 cpu 很差,线程一多,导致 cpu 满载,主线程反而获取不到运行时间。其次,如果从上层来看,一个函数执行耗时久可能并不是这个函数的问题,也可能是因为该函数长时间没有获取到 cpu 时间。

自上而下的思考很容易让我们忽略本质,导致优化的效果不明显或者不完整。

自下而上

自下而上思考就是从底层开始思考,还是以启动优化为例子,自下而上的思考就不是直接分析启动链路,寻找慢函数,而是直接想着如何在启动过程中合理且充分的使用 cpu 资源,这个时候我们的方案就很多了,比如我们可能会想到不同的机型 cpu 能力是不一样的,所以我们会针对高端机和低端机来分别优化,高端机上,我们想办法让cpu利用率更高,低端机上想办法避免 cpu 的超载,同时配合慢函数,线程,锁等知识进行优化,就能制定一套体系并且完整的启动优化方案。


完整的闭环

上面讲的都是如何进行优化,优化很重要,但并不是全部,在实际的性能优化中,我们需要做的有监控,优化,防劣化,数据收益收集等等,这些部分都做好才能形成一个完整的闭环。我一一讲一下这几个部分:

  • 监控:完整的监控应用中各项性能的指标,仅仅有指标监控是不够的,我们还需要尽量做归因的监控。比如内存监控,我们不仅仅要监控我们应用的内存指标,还可以还要能监控到各个业务的内存使用占比,大集合,大图片,大对象等等归因项。并且我们的监控同样要基于性能考虑去设计。完整的监控能让我们更高效的发现和解决异常。

  • 优化:优化就是前面提到的,合理且充分的使用硬件资源,让程序的表现更好。

  • 防劣化:防劣化也是有很多事情可以做的,包括建立完善的线下性能测试,线上监控的报警等。比如内存,我们可以在线下每天通过monkey跑内存泄露并提前治理,这就是防劣化。

  • 数据收益收集。学会用好A/B测试,学会关注核心价值的指标。比如我们做内存优化,一味的追求降低应用内存的占用并不是最优,内存占用的多,可能会让我们的程序运行更快,用户体验更好,所以我们需要结合崩溃率,留存等等这种体验核心价值的指标,来确定内存到底要不要继续进行优化或者优化到多少。

小结

上面就是我在多年的性能优化经验中总结出来的认知及方法论。只有了解了这些方法论,我们才能在进行性能优化时,如鱼得水,游刃有余。

这篇文章也没有介绍具体的优化方案,因为性能优化的方案通过一篇文章是介绍不完的,大家有兴趣可以看看我写的掘金小册《Android 性能优化》,可以体系的学一学如何进行优化,上面讲解的方法论,也都会在这本小册中体现出来。

作者:helson赵子健
来源:juejin.cn/post/7169486107866824717

收起阅读 »

Android依赖冲突解决

一、背景工程中引用不同的库(库A和B),当不同的库又同时依赖了某个库的不同版本(如A依赖C的1.1版本,B依赖C2.2版本),这时就出现了依赖冲突。二、问题解决步骤查看依赖树运行android studio的中如下task任务即可生成依赖关系,查看冲突是由哪哪...
继续阅读 »

一、背景

工程中引用不同的库(库A和B),当不同的库又同时依赖了某个库的不同版本(如A依赖C的1.1版本,B依赖C2.2版本),这时就出现了依赖冲突。

二、问题解决步骤

查看依赖树

运行android studio的中如下task任务即可生成依赖关系,查看冲突是由哪哪些库引入的(即找到库A和库B)。


排除依赖

使用 exclude group:'group_name',module:'module_name'

//剔除rxpermissions这依赖中所有com.android.support相关的依赖,避免和我们自己的冲突
implementation 'com.github.tbruyelle:rxpermissions:0.10.2', {
exclude group: 'com.android.support'
}

注意:下图中红框处表示依赖的版本由1.0.0被提升到了1.1.0。如果对1.0.0的库中的group或module进行exclude时,当库的版本被提升时,exclude将会失效,解决办法时工程中修改库的依赖版本为被提升后的版本。

使用强制版本

冲突的库包含了多个版本,这时可直接使用强制版本。在项目的主module的build.gradle的dependencies节点里添加configurations.all {},{}中的前缀是 resolutionStrategy.force ,后面是指定各module强制依赖的包,如下图所示,强制依赖com.android.tools:sdklib包的30.0.0:


作者:Android_Developer
来源:juejin.cn/post/7042951122872434696

收起阅读 »

六年安卓开发的技术回顾和展望

本文字数:7190 字,阅读完需:约 5 分钟大家好,我是 shixin。一转眼,我从事安卓开发工作已经六年有余,对安卓开发甚至软件开发的价值,每年都有更进一步的认识。对未来的方向,也从刚入行的迷茫到现在逐渐清晰。我想是时候做一个回顾和展望了。这篇文章会先回顾...
继续阅读 »

本文字数:7190 字,阅读完需:约 5 分钟

大家好,我是 shixin。

一转眼,我从事安卓开发工作已经六年有余,对安卓开发甚至软件开发的价值,每年都有更进一步的认识。对未来的方向,也从刚入行的迷茫到现在逐渐清晰。我想是时候做一个回顾和展望了。

这篇文章会先回顾我从入行至今的一些关键点,然后讲一下经过这些年,我对软件开发的认知变化,最后分享一下后面的规划。

回顾

人太容易在琐碎生活中迷失,我们总是需要记住自己从哪里来,才能清楚要到哪里去。

入行至今的一些关键节点

2014~2015:开始安卓开发之旅

说起为什么做安卓开发,我很有感慨,差一点就“误入歧途”😄。

当初在大学时,加入了西电金山俱乐部,俱乐部里有很多方向:后端、前端、安卓、Windows Phone 等。


由于我当时使用的是三星 i917,WindowsPhone,所以就选了 WinPhone 方向。

当时还是 iOS、安卓、WinPhone、塞班四足鼎立的时代,WinPhone 的磁贴式设计我非常喜欢,加上设备的流畅性、像素高,一度让我觉得它可能会统治移动市场。

结果在学习不到 2 个月以后,我的 WinPhone 意外进水了!我当时非常难过,一方面是对手机坏了的伤痛,另一方面也是对无法继续做 WinPhone 开发很遗憾。对于当时的我来说,再换一台 WinPhone 过于昂贵,只好换一台更加便宜的安卓机,因此也就转向学习安卓开发。

后面的故事大家都知道了,因为 WindowsPhone 缺乏良好的开发生态,支持应用很少,所以用户也少,用户少导致开发者更少,恶性循环,如今市场份额已经少的可怜。

现在回想起来,对于这件事还很有感慨,有些事当时觉得是坏事,拉长时间线去看,未必是这样。

当时还有一件目前看来非常重要的决定:开始写博客,记录自己的所学所得。

在开发项目时,我经常需要去网上搜索解决方案,后来搜索的多了,觉得总不能一直都是索取,我也可以尝试去写一下。于是在 CSDN 注册了账号,并于 2014 年 10 月发布了我的第一篇原创文章

后来工作学习里新学到什么知识,我都会尽可能地把它转换成别人看得懂的方式,写到播客里。这个不起眼的开始,让我逐渐有了解决问题后及时沉淀、分享的习惯,受益匪浅。

2015~2017:明白项目迭代的全流程

在学习安卓开发时,我先看了一本明日科技的《Android 从入门到精通》,然后看了些校内网的视频,逐渐可以做一些简单的应用。安卓开发所见即所得的特点,让我很快就可以得到正反馈。后来又去参加一些地方性的比赛,获得一些名次,让我逐渐加强了从事这个行业的信心。


在 2015 年时,偶然参加了一家公司的招聘会,在面试时,面试官问了一些简单的 Java 、安卓和算法问题。其中印象最深的就是会不会使用四大组件和 ListView。在当时移动互联网市场飞速发展时,招聘要求就是这么低。以至于现在很多老安卓回忆起当初,都很有感慨:“当初会个 ListView 就能找工作了,现在都是八股文” 哈哈。

到公司实习后,我感触很多,之前都是自己拍脑袋写一些简单的功能,没有开发规范、发布规范,也没有工程结构设计、系统设计,更没有考虑性能是否有问题。真正的去开发一个商业项目,让我发现自己不足的太多了。


因此在完成工作的同时,我观察并记录了项目迭代的各个流程,同时对自己的技术点做查漏补缺,输出了一些 Java 源码分析、Android 进阶、设计模式文章,也是从那个时候开始,养成了定期复盘的习惯,每次我想回顾下过去,都会看看我的成长专栏

2017~2020:提升复杂项目的架构能力和做事意识

第一个项目中我基本掌握了从 0 到 1 开发一个安卓应用的流程,但对安卓项目架构还只停留在表面,没有足够实践。

在 2017 年,我开始做喜马拉雅直播项目,由于喜马拉雅在当时已经有比较多年的技术积累,加上业务比较复杂,在架构设计、编译加速、快速迭代相关都做了比较多的工作,让我大饱眼福。

同时直播业务本身也是比较复杂的,在一个页面里会集成 IM、推拉流等功能,同时还有大量的消息驱动 UI 刷新操作,要保证业务快速迭代,同时用户体验较好,需要下不少功夫。

为了能够提升自己的技术,在这期间我学习了公司内外很多框架的源码,通过分析这些框架的优缺点、核心机制、架构层级、设计模式,对如何开发一个框架算是有了基本的认识,也输出了一些文章,比如 《Android 进阶之路:深入理解常用框架实现原理》


有了这些知识,再去做复杂业务需求、基础框架抽取、内部 SDK 和优化,就容易多了。

在开发一些需求或者遇到复杂的问题时,我会先想想,之前看的这些三方框架或者系统源码里有没有类似的问题,它们是怎么解决的? 比如开发 PK 功能,这个需求的复杂性在于业务流程很多,分很多状态,咋一看好像很复杂,但如果了解了状态机模式,就会发现很简单。借用其他库的设计思路帮我解决了很多问题,这让我确信了学习优秀框架源码的价值

除了技术上的提升,在这几年里,我的项目全局思考能力也提升很多。

由于我性格外向,和各个职能的同学沟通交流比较顺畅,领导让我去做一个十人小组的敏捷组长,负责跟进需求的提出、开发、测试、上线、运营各个环节,保证项目及时交付并快速迭代。

一开始我还有些不习惯,写代码时总是被不同的人打断,比如产品需求评审、测试 bug 反馈、运营反馈线上数据有问题等等,经常刚想清楚代码怎么写,正准备动手,就被叫去开会,回来后重新寻找思路。

后来在和领导沟通、看一些书和分享后,逐渐对写代码和做事,有了不同的认识。代码只是中间产物,最终我们还是要拿到对用户有价值、给公司能带来收入的产品,要做到这个,眼里除了代码,还需要关注很多。

2020~至今:深入底层技术

在进入字节做基础技术后,我的眼界再一次被打开。

字节有多款亿级用户的产品,复杂的业务常常会遇到各种意想不到的问题,这些问题需要深入底层,对安卓系统的整个架构都比较熟悉,才能够解决。


上图是安卓系统架构图,之前我始终停留在一二层,在这一时期,终于有了纵深的实践经验。

比如帮业务方解决一个内存问题,除了要了解内存指标监控方式,还要知道分析不同类型内存使用的工具及基本原理,最后知道是哪里出了问题后,还要想如何进行体系化的工具,降低学习成本,提升排查效率。

问题驱动是非常好的学习方式。每次帮助业务解决一个新问题,我的知识库都会多一个点,这让我非常兴奋。之前不知道学来干什么的 Linux 编程、Android 虚拟机,终于在实际问题中明白了使用场景,学起来效率也高了很多。

对软件开发的认识

前面讲了个人的一些经历,包括我怎么入的行,做了什么项目,过程中有什么比较好的实践。下面讲一下我从这些具体的事里面,沉淀出哪些东西有价值的结论。

主要聊下对这两点的认识:

  • 职业发展的不同阶段

  • 技术的价值

职业发展的不同阶段

第一点是对职业发展的认识。我们在工作时,要对自己做的事有一个清晰的认识,它大概属于哪一个阶段,怎样做可以更好。

结合我这些年的工作内容、业内大佬所做的事情,我把软件开发者的职业发展分这几个阶段:

  1. 使用某个技术方向的一个点开发简单项目

  2. 使用某个技术方向的多个点及某条线,开发一个较为复杂的业务或系统

  3. 掌握某个方向的通用知识,有多个线的实践,可以从整体上认识和规划

  4. 不限于该方向,能从产品指标方面出发,提供全方位的技术支持业务角度,端到端关注指标

第一个阶段就是使用某个技术方向的一个点完成业务需求。拿安卓开发者来说,比如使用 Android SDK 自定义布局,完成产品要求的界面功能。这个阶段比较简单,只要能够仔细学习官方文档或者看一些书即可胜任。拿后端来说,比如刚接手一个小项目,日常工作就是使用 Spring 等库开发简单的接口,不涉及到上下游通信、数据库优化等。

第二个阶段,你做的项目更加复杂了,会涉及到一个技术方向的多个点,这时你需要能把这些点连起来,给出一个更体系化的解决方案。

拿安卓开发者来说,比如在自定义布局时,发现界面很卡顿,要解决这个问题的话,你就要去了解这个自定义 View 的哪些代码流程影响了这个页面的刷新速度。这就相当于是从一个点到另一个点。怎么连起来呢?你需要去研究渲染的基本原理,分析卡顿的工具,找到导致卡顿的原因,进行优化。这个过程会对流畅性有整体的认识,能够对相关问题有比较全面的分析思路、解决手段,从而可以开发相关的分析工具或优化库。 如果能达到这个程度,基本就算是一个高级工程师了,不只是做一个模块,还能够负责一个具体细分方向的工作。

第三个阶段,掌握某个技术方向的通用知识,有多个线的实践,能够连线为面,同时给工作做中长期的技术规划。

拿安卓开发来说,刚才提到你通过解决卡顿问题,在流畅性这方面有了比较多的实践;然后你又发现内存有问题,去了解了内存分配、回收原理,做出内存分析优化工具,这样就也有了内存的一个体系化的实践。再加一些其他的优化经验,比如启动速度、包大小等。把这些线连起来,就得到了一个性能监控平台,这就是有把多条线连成一个面。

还有比如说你发现项目打包和发布过程中的一些痛点,并且能够做一些实践解决,最后如果能够把这些优化项连起来做一个统一的系统,给出完整的 DevOps 方案,提升开发、发布、运维的效率。能够把这个系统搭建起来,有比较深入的经验,那就可以成为“技术专家”了。

再往上走就不只是做技术,而要更多思考业务。技术最终都是要为业务服务。职业发展的第四个阶段,就是不局限于某个技术方向,能够从产品的业务规划、业务指标出发,给产品提供技术支持。

你首先要明白公司业务的核心指标是什么,比如说拿一个短视频应用来说,它核心指标除了常规的日活、用户量,还更关注视频的播放率、停留时长、页面渗透率等。了解这些指标以后,你要思考做什么可以有助于公司提升这些指标。结合业务指标反思当前的项目哪里存在优化空间。

有了这个思路并且知道可以做什么以后,你可以做一个较为全面的规划,然后拉领导去讨论可行性。这时你不能再局限于某一端,不能说我只是个安卓开发,其他部分都找别人做。一般在项目的价值没有得到验证之前,领导不会轻易给你资源,因此第一个版本迭代肯定是要靠你自己,从前到后独立完成,做一个 MVP 版本,然后让领导认可了这个系统的价值,才有可能会分给你更多的资源做这件事。

总结一下对职业发展的认识:第一阶段只做一些具体的点;第二阶段做多个点,需要能够连点成线;第三个阶段需要围绕这些线提炼出通用的知识,然后做到对业务/技术项目有整体的认识;第四阶段能够从业务指标出发,做出有价值的系统/平台。

技术的价值

说完职业发展的不同阶段,接下来聊下技术对业务的价值。

技术是为业务服务的。根据业务的不同阶段,技术的价值也有所不同:

  1. 业务从 0 到 1 时,帮助业务快速确定模式

  2. 业务从 1 到 100 时,帮助业务快速扩大规模

  3. 最卓越的,用技术创新带动业务有新的发展 (Google、AWS、阿里云)

业务从 0 到 1 时

我一开始做的工作,业务就是处于确定模式期间。业务上反复试错,项目常常推倒重来,会让程序员觉得很有挫败感。

这个阶段很多程序员都会发挥复制粘贴大法,产品经理说要新增一个功能,就复制一份代码稍微改一改。

如果说目前就是在这种业务中,该怎么做呢?如果我回到当时那个情景,我可以做什么让公司业务变得更好呢?

我总结了两点:在高效高质量完成业务的同时,思考如何让业务试错成本更低。

如何让业务试错成本更低呢?大概可以有这些方式:

  • 提供可复用的框架

  • 提供便捷的数据反馈机制

  • 多了解一些竞品业务,在产品不确定的时候,给一些建议

第一点:尽可能的抽象相似点,减少重复成本。

如果产品每次都给你类似的需求,你可以考虑如何把这些重复需求抽象成一些可以复用的逻辑,做一个基本的框架,然后在下次开发的时候能够去直接用框架,而不是每次都从头开始。我平时工作也常常问自己“我现在做的事有哪些是重复的,哪些是可以下沉的”。

就安卓开发来说,这个阶段,可以做好基础建设,提供插件化、热修复、动态化框架,帮助业务快速发版,自研还是第三方看公司财力。

如果你说这些太复杂了我做不来,那就从更小的层面做起,比如某个功能原本需要多个接口多个界面,看能不能改成接口参数可配置,界面根据参数动态生成(也就是 DSL)。

第二点:提供便捷的数据反馈机制

在产品提需求时,你可以问问产品这个需求出于什么考虑,有没有数据支撑?比如说产品需求是某个按钮换个位置,那你要搞清楚,为什么要换,换完之后会导致页面打开率提升吗?要有这种数据驱动的理念。

如果公司做决策时缺乏相应的数据,你可以主动地去提供这种数据反馈机制。比如说开发一个埋点平台、数据监控平台。尽可能地让业务有数据可看,能够数据驱动,而不是像无头苍蝇一样盲目尝试。

如果无法做一个这么大的系统,那可以先从力所能及的做起,比如说战略上重视数据;做好数据埋点;思考做的功能,目前有哪些数据是核心的,这些数据有没有上报,不同版本的数据是升还是降等。

好,这是第一个阶段,技术对业务价值就是帮助业务快速确定模式。第二个阶段就是业务快速扩大规模时,技术的核心价值是什么呢?

业务从 1 到 100 时

业务正在快速扩大规模时,需要把当前跑通的业务模式复制到更多的地方,同时能够服务更多的用户。这个阶段,技术能够提供的价值主要是两点。

  1. 快速迭代(这一点其实无论什么阶段)

  2. 提升质量(用户规模日活上亿和日活一万,需要面对的挑战差异也是这个数量级)

第一点:快速迭代

虽然快速迭代是业务各个阶段都需要做到,但和从 0 到 1 相比,从 1 到 100 的阶段会有更多的挑战,除了个人速度,更要关注团队的速度。

团队的速度如何提升?可以参考后端的单体到微服务、前端的单仓到多仓的演变过程及原因。

这个阶段主要有这几点问题:

  1. 多人协作代码冲突

  2. 发布速度慢

  3. 出问题影响大,不好定位

具体到安卓项目,几百人开发和三两个人开发的,复杂度也是几百倍。我们可以做的是:

  1. 下沉基础组件,定义组件规范,收敛核心流程

  2. 拆分业务模块,设计业务模板,单独维护迭代

  3. 探索适合业务的新方式:跨端(RN Flutter KotlinMultiplatform)、动态化、多端逻辑一致(C/C++ Rust)

第二点:提升质量

和日活几万的项目相比,日活千万甚至上亿的产品,需要应对的质量问题更加显著。在这个阶段,我们不仅要满足于实现功能,还要能够写的好,更要能够了解底层原理,才能应对这样大的业务量。

有了大规模的用户后,你会遇到很多奇怪的问题,不能疲于每天去解决一样重复的问题,那你就需要从这些问题中找到一些共通的点,然后提炼出来,输出工具、解决方案甚至平台。

这就需要你从问题中磨练本领,站在更高的层面思考自己该具体的能力、思路和工具。

在解决问题的时候,除了当下这个问题,更需要做的是把这个问题解构、归类,抽象出不同问题的相似和差异,得出问题分析流程图。

同样是分析内存泄漏,有的人可能只知道使用 Leakcanary,但你还可以思考的更深入,比如:

  • 先定义问题。什么是泄露?

  • 泄露是申请了没有释放或者创建了没有回收

  • 内存泄露怎么分析?

  • 找到创建和销毁的点

  • 在创建的时候保存记录,销毁的时候删除这个记录,最终剩下来的就是泄露的

有了基础的逻辑,就可以把它套用到各种问题上:

  • Native 内存泄漏:在 Native 内存分配和释放 API,做记录

  • 图片使用不当:在图片创建、释放的 API 里做记录

  • 线程过多:在线程创建、释放的 API 里做记录

在遇到一个新问题时,发现和之前解决过的有点像,但又不知道哪里像。怎么办?回头去思考新旧的两个问题,它们的本质是什么?有什么相似的分析思路?

这个思考训练的目的,就是提升举一反三的能力。大规模应用可能各种问题,需要你一方面提升技术,另一方面分析问题的思路和能力上也要提升,不能看着一个问题就是一个问题,要做到看到一个问题,想到一类问题。

展望(后面的规划)

技术上达到一专多能,软实力上持续提升。

硬实力

专业

如果你是安卓开发,最好在某个有细分领域很擅长,比如音视频、跨端、动态化、性能优化。

我目前主要是做优化,后面需要继续补充的知识:

  • Linux 内核原理

  • Android 虚拟机原理

  • 项目从开发、编译、发布、数据分析各个流程的效率提升方式

多能

前面提到职业发展的第四个阶段:

不限于该方向,能从产品指标方面出发,提供全方位的技术支持

我希望可以具备独立完成一个系统从前到后的能力。

目前已有的经验:

  • 使用 TypeScript + React + Electron 开发桌面端软件

  • 使用 SpringMVC 开发简单的内部系统

后面需要加强的点:

  • 熟练掌握前端的 JS、打包、优化等知识

  • 后端技术达到中级

还有这些点需要长期关注:

  • Flutter 更新频繁,有一些尝试效果还不错,一套代码多端运行,节省开发成本

  • 掌握 DevOps 理念及实践

最终目的:

  • 具备独立完成一个有价值的系统的能力

  • 具备对研发整个流程的完善、优化能力

软实力

除了技术规划,我也有很多软实力需要继续提升,今年主要想提升的就是同频对话的能力。

什么是同频对话?

同频对话就是根据听众的角色和他的思考角度去转换你的表达内容。

比如说我们在和领导汇报的时候,你要去讲你做的一个系统,你就要从他角度去表达。他可能关注的是整体流程、系统的难点、瓶颈在哪里,带来的收益是什么。那你就不能只讲某个模块的细节,而要从更高的层面去思考和表达。

为什么要提升呢?

随着工作年限的增加,市场对我们的要求是越来越高的,除了写代码,对表达能力的要求也是越来越高的。

一开始刚入行,你就是做一个执行者,只要多动耳朵、眼睛、手,实现别人要求你做的功能。

后来你的能力逐渐提升以后,有机会设计一个模块的时候,你就需要多动脑力去思考,去复设计这个系统的输入输出、内部数据流转等。

再往后走的话,你可能会有一些资源,那就需要能把你的想法完整地表达出来,让别人帮你去贯彻落地。这其实是一种比较难得的能力。我今年计划通过多分享、多与不同的人交流等方式,提升自己的这种能力,争取做到满意的程度。

结束语

好了,这篇文章就到这里了,这就是我这六年的技术回顾和展望,感谢你的阅读❤️。

人生的多重境界:看山是山、看水是水;看山不是山、看水不是水;看山还是山、看水还是水。

我想,我对软件开发,还没有达到第三层,相信用不了多久,就会有不同的观点冒出来。

但,怕什么真理无穷,进一寸有一寸的欢喜!

作者:张拭心
来源:juejin.cn/post/7064960413280141348

收起阅读 »

Android 无所不能的 hook,让应用不再崩溃

之前推送了很多大厂分享,很多同学看完就觉得,大厂输出的理论知识居多,缺乏实践。那这篇文章,我们将介绍一个大厂的库,这个库能够实打实的帮助大家解决一些问题。今天的主角:初学者小张,资深研发老羊。三方库中的 bug这天 QA 上线前给小张反馈了一个 bug,应用启...
继续阅读 »

之前推送了很多大厂分享,很多同学看完就觉得,大厂输出的理论知识居多,缺乏实践。

那这篇文章,我们将介绍一个大厂的库,这个库能够实打实的帮助大家解决一些问题。

今天的主角:初学者小张,资深研发老羊。

三方库中的 bug

这天 QA 上线前给小张反馈了一个 bug,应用启动就崩溃,小张一点不慌,插入 USB,触发,一看日志,原来是个空指针。

想了想,空指针比较好修复,大不了判空防御一下,于是回答:这个问题交给我,马上修复。

根据堆栈,找到了空指针的元凶。

忽然间,小张愣住了,这个空指针是个三方库在初始化的时候获取用户剪切板出错了。

这可怎么解决呢?

本来以为判个空防御一下完事,这会遇到硬茬了。

毕竟是自己装的逼,含着泪也要修复了,我们模拟下现场。

/**
* 这是三方库中的调用
*/
public class Tools {
   
  public static String getClipBoardStr(Context context) {
      ClipboardManager clipboardManager = (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE);
      ClipData primaryClip = clipboardManager.getPrimaryClip();
      // NPE
      ClipData.Item itemAt = primaryClip.getItemAt(0);
      if (itemAt == null) {
          return "";
      }
      CharSequence text = itemAt.getText();
      if (text == null) {
          return "";
      }
      return text.toString();
  }
}

我们写个按钮来触发一下:


果然发生了崩溃,空指针发生在clipboardManager.getPrimaryClip(),当手机上没有过复制内容时,getPrimaryClip返回的就是 null。

马上就要上线了,但是这个问题,也不是修复不了,根据自己的经验,大多数系统服务都可以被 hook,hook 掉 ClipboradManager 的相关方法,保证返回的 getPrimaryClip 的不为 null 即可。

于是看了几个点:

public @Nullable ClipData getPrimaryClip() {
   try {
       return mService.getPrimaryClip(mContext.getOpPackageName(), mContext.getUserId());
  } catch (RemoteException e) {
       throw e.rethrowFromSystemServer();
  }
}

这个 mService 的初始化为:

mService = IClipboard.Stub.asInterface(
              ServiceManager.getServiceOrThrow(Context.CLIPBOARD_SERVICE));

这么看,已经八成可以 hook了,再看下我们自己能构造 ClipData 吗?

public ClipData(CharSequence label, String[] mimeTypes, Item item) {}

恩,hook 的思路基本可行。

小张内心暗喜,多亏是遇到了我呀,还好我实力扎实。

这时候,资深研发老羊过来问了句,马上就要上线了,你这干啥呢?

小张滔滔不绝的描述了一下当前遇到了问题,和自己的解决思路,本以为老羊这次会拍拍自己的肩膀「还好是你遇到了呀」来表示对自己的认可。

老羊开口说道:

getPrimaryClip返回 null 造成的空指针,那你在之前调用一个setPrimaryClip不就行了?

恩?卧槽...看一眼源码:

#ClipboardManager
public void setPrimaryClip(@NonNull ClipData clip) {
   try {
       Preconditions.checkNotNull(clip);
       clip.prepareToLeaveProcess(true);
       mService.setPrimaryClip(clip, mContext.getOpPackageName(), mContext.getUserId());
  } catch (RemoteException e) {
       throw e.rethrowFromSystemServer();
  }
}

还真有这个方法...

那试试吧。

添加了一行:

ClipboardManager clipboardManager = (ClipboardManager) getSystemService(CLIPBOARD_SERVICE);
clipboardManager.setPrimaryClip(new ClipData("bugfix", new String[]{"text/plain"}, new ClipData.Item("")));

果然不在崩溃了。

这时候老羊说了句:

你也想想,假设三方库里面真有个致命的 bug,然后你没找到合适的 hook 点你怎么处理?想好了过来告诉我。

致命 bug,没找到合适的 hook 点?

模拟下代码:

public class Tools {

  public static void evilCode() {
      int a = 1 / 0;
  }

  public static String getClipBoardStr(Context context) {
      evilCode();
      ClipboardManager clipboardManager = (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE);
      ClipData primaryClip = clipboardManager.getPrimaryClip();
      ClipData.Item itemAt = primaryClip.getItemAt(0);
      if (itemAt == null) {
          return "";
      }
      CharSequence text = itemAt.getText();
      if (text == null) {
          return "";
      }
      return text.toString();
  }


}

假设 getClipBoardStr 内部调用了一行 evilCode,执行到就crash。

一眼望去这个 evilCode 方法,简单是简单,但是在三方库里面怎么解决呢?

小张百思不得其解,忽然灵光一闪:

是不是老羊想考察我的推动能力,让我没事别瞎 hook 人家代码,这种问题当然找三方库那边修复,然后给个新版本咯。

于是跑过去,告诉老羊,我想到了,这种问题,我们应该及时推动三方库那边解决,然后我们升级版本即可。

老羊听了后,恩,确实要找他们,但是如果是上线前遇到,推动肯定是来不及了,就是人家立马给你个新版本,直接升级风险也是比较大的。

然后老羊说道:

我看你对于反射找 hook 点已经比较熟悉了,其实还有一类 hook 更加好用,也更加稳定。

叫做字节码 hook。

怎么说?

我们的代码在打包过程中,会经过如下步骤:

.java -> .class -> dex -> apk

上面那个类的 evil 方法,从 class 文件的角度来看,其实都是字节码。

假设我们在编译过程中,这么做:

.java -> .class -> 拿到 Tools.class,修正里面的方法 evil 方法 -> dex -> apk

这个时机,其实构建过程中也给我们提供了,也就是传说的 Transform 阶段(这里不讨论 AGP 7 之后的变化,还是有对应时机的)。

小张又问,这个时机我知道,Tools.class 文件怎么修改呢?

老羊说,这个你去看看我的博客:

Android 进阶之路:ASM 修改字节码,这样学就对了!

不过话说回来,既然你会遇到这样的痛点,那么别的开发者肯定也会遇到。

这个时候应该怎么想?

小张:肯定有人造了好用的轮子。

老羊:恩,99%的情况,轮子肯定都造好了,剩下 1%,那就是你的机会了。

轻量级 aop 框架 lancet 出现

饿了么,很早的时候就开源了一个框架,叫 lancet。

github.com/eleme/lance…

这个框架可以支持你,在不懂字节码的情况下,也能够完成对对应方法字节码的修改。

代入到我们刚才的思路:

.java -> .class -> lancet 拿到 Tools.class,修正里面的方法 evilCode 方法 -> dex -> apk

小张:怎么使用 lancet 来修改我们的 evilCode 方法呢?

引入框架

在项目的根目录添加:

classpath 'me.ele:lancet-plugin:1.0.6'

在 module 的build.gradle 添加依赖和 apply plugin:

apply plugin: 'me.ele.lancet'

dependencies {
  implementation 'me.ele:lancet-base:1.0.6' // 最好查一下,用最新版本
}

开始使用

然后,我们做一件事情,把Tools 里面的 evilCode方法:

public static void evilCode() {
   int a = 1 / 0;
}

里面的这个代码给去掉,让它变成空方法。

我们编写代码:

package com.imooc.blogdemo.blog04;

import me.ele.lancet.base.annotations.Insert;
import me.ele.lancet.base.annotations.TargetClass;

public class ToolsLancet {

   @TargetClass("com.imooc.blogdemo.blog04.Tools")
   @Insert("evilCode")
   public static void evilCode() {

  }

}

我们编写一个新的方法,保证其是个空方法,这样就完成让原有的 evilCode 中调用没有了。

其中:

  • TargetClass 注解:标识你要修改的类名;

  • Insert注解:表示你要往 evilCode 这个方法里面注入下面的代码

  • 下面的方法声明需要和原方法保持一致,如果有参数,参数也要保持一致(方法名、参数名不需要一致)

然后我们打包,看看背后发生了什么神奇的事情。

在打包完成后,我们反编译,看看 Tools.class

public class Tools {    
  //...
   public static void evilCode() {
       Tools._lancet.com_imooc_blogdemo_blog04_ToolsLancet_evilCode();
  }

   private static void evilCode$___twin___() {
       int a = 1 / 0;
  }

   private static class _lancet {
       private _lancet() {
      }

       @TargetClass("com.imooc.blogdemo.blog04.Tools")
       @Insert("evilCode")
       static void com_imooc_blogdemo_blog04_ToolsLancet_evilCode() {
      }
  }
}

可以看到,原本的evilCode方法中的校验,被换成了一个生成的方法调用,而这个生成的方法和我们编写的非常类似,并且其为空方法。

而原来的 evilCode 逻辑,放在一个evilCode$___twin___()方法中,可惜这个方法没地方调用。

这样原有的 evilCode 逻辑就变成了一个空方法了。

我们可以大致梳理下原理:

lancet 会将我们注明需要修改的方法调用中转到一个临时方法中,这个临时方法你可以理解为和我们编写的方法逻辑基本保持一致。

然后将该方法的原逻辑也提取到一个新方法中,以备使用。

小张:确实很神奇,那这个原方法我们什么时候会使用呢?

老羊:很多时候,可能原有逻辑只是个概率很低的问题,比如发送请求,只有在超时等情况才发生错误,你不能粗暴的把人家逻辑移除了,你可能更想加个 try-catch 然后给个提示什么的。

这个时候你可以这么改:

package com.imooc.blogdemo.blog04;

import me.ele.lancet.base.Origin;
import me.ele.lancet.base.annotations.Insert;
import me.ele.lancet.base.annotations.TargetClass;

public class ToolsLancet {

   @TargetClass("com.imooc.blogdemo.blog04.Tools")
   @Insert("evilCode")
   public static void evilCode() {
       try {
           Origin.callVoid();
      } catch (Exception e) {
           e.printStackTrace();
      }
  }

}

我们再来看下反编译代码:

public class Tools {

   public static void evilCode() {
       Tools._lancet.com_imooc_blogdemo_blog04_ToolsLancet_evilCode();
  }

   private static void evilCode$___twin___() {
       int a = 1 / 0;
  }

   private static class _lancet {
       @TargetClass("com.imooc.blogdemo.blog04.Tools")
       @Insert("evilCode")
       static void com_imooc_blogdemo_blog04_ToolsLancet_evilCode() {
           try {
               Tools.evilCode$___twin___();
          } catch (Exception var1) {
               var1.printStackTrace();
          }

      }
  }
}

看到没,不出所料中转方法内部调用了原有方法,然后外层包了个 try-catch。

是不是很强大,而且相对于运行时反射相关的 hook 更加稳定,其实他就像你写的代码,只不过是直接改的 class。

小张:所以我早上遇到的剪切板崩溃问题,其实也可以利用 lancet 加一个 try-catch。

老羊:是的,挺会举一反三的,当然也从侧面反映出来字节码 hook 的强大之处,几乎不需要找什么 hook 点,只要你有方法,就能干涉。

另外,我给你介绍的都是最基础的 api,你下去好好看看 lancet 的其他用法。

小张:好嘞,又学到了。

新的问题又来了

过了几日,忽然项目又遇到一个问题:

用户未授权读取剪切板之前,不允许有读取剪切板的行为,否则认定为不合规

小张听到这个任务,大脑快速运转:

这个读取剪切板行为的 API 是:

clipboardManager.getPrimaryClip();

搜索下项目中的调用,然后逐一修改。

先不说能不能搜索完整,这三方库里面肯定有,此外后续新增的代码如何控制呢?

另外之前学习 lancet,可以修改三方库代码,但是我也不能把包含clipboardManager.getPrimaryClip的方法全部列出来,一个个字节码修改?

还是解决不了后续新增,已经能保证全部搜出来呀。

最终心里嘀咕:别让我干,别让我干,八成是个坑。

这时候老羊来了句:这个简单,小张熟悉,他搞就行了。

小张:我...

重新思考一下,反正搜索出来,一一修改是不可能了。

那就从源头上解决:

系统肯定是通过framework,system 进程那边去判断是否读取剪切板的。

那么我们只要把:

clipboardManager.getPrimaryClip
IClipboard.getPrimaryClip(mContext.getOpPackageName(), mContext.getUserId());

内部的逻辑hook 掉,换掉IClipBoard 的实现,然后切到我们自己的逻辑即可。

懂了,这就是我之前想的系统服务的 hook 而已,难怪老羊安排给我,我给他说过这个。

于是乎...我开启了一顿写模式...

此处代码略。(确实可以,不过非本文主要内容)

正完成了 Android 10.0的测试,准备翻翻各个版本有没有源码修改,好适配适配,老羊走了过来。

说了句:这都两个小时过去了,你还没搞完?

小张:两个小时搞完?你来。

老羊:我让你自己看看 lancet 其他 api你没看?

这个用 lancet 就是送分题你知道吗?看好:

public class ToolsLancet {

   // 模拟用户同意后的状态
   public static boolean isAuth = true;

   @TargetClass("android.content.ClipboardManager")
   @Proxy("getPrimaryClip")
   public ClipData getPrimaryClip() {
       if (isAuth) {
           return (ClipData) Origin.call();
      }
       // 这里也可以 return null,毕竟系统也 return null
       return new ClipData("未授权呢", new String[]{"text/plain"}, new ClipData.Item(""));
  }
}

小张:这个不行呀,android.content.ClipboardManager类是系统的,不是我们写的,在打包阶段没有这个 class。

老羊:我当然知道,你仔细看,这次用的注解和上次有什么不同。

这次用的是:

  • @Proxy:意思就是代理,会代理ClipboardManager. getPrimaryClip到我们这个方法中来。

我们反编译看看:

原来的调用:

public static String getClipBoardStr(Context context) {
  ClipboardManager clipboardManager = (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE);
  ClipData primaryClip = clipboardManager.getPrimaryClip();
  ClipData.Item itemAt = primaryClip.getItemAt(0);
  if (itemAt == null) {
      return "";
  }
  CharSequence text = itemAt.getText();
  if (text == null) {
      return "";
  }
  return text.toString();
}

反编译的调用:

public class Tools {

   public static String getClipBoardStr(Context context) {
       ClipboardManager clipboardManager = (ClipboardManager)context.getSystemService("clipboard");
       ClipData primaryClip = Tools._lancet.com_imooc_blogdemo_blog04_ToolsLancet_getPrimaryClip(clipboardManager);
       Item itemAt = primaryClip.getItemAt(0);
       if (itemAt == null) {
           return "";
      } else {
           CharSequence text = itemAt.getText();
           return text == null ? "" : text.toString();
      }
  }

   private static class _lancet {
   
       @TargetClass("android.content.ClipboardManager")
       @Proxy("getPrimaryClip")
       static ClipData com_imooc_blogdemo_blog04_ToolsLancet_getPrimaryClip(ClipboardManager var0) {
           return ToolsLancet.isAuth ? var0.getPrimaryClip() : new ClipData("未授权呢", new String[]{"text/plain"}, new Item(""));
      }
  }
}

看到没有,clipboardManager.getPrimaryClip()方法变成了Tools._lancet.com_imooc_blogdemo_blog04_ToolsLancet_getPrimaryClip,中转到了我们的hook 实现。

这次明白了吧:

  1. lancet 对于我们自己的类中方法,可以使用@Insert 指令;

  2. 遇到系统的调用,我们可以针对调用函数使用@Proxy 指令将其中转到中转函数;

好了,lancet 还有一些 api,你再下去好好看看。

完结

终于结束了,大家退出小张和老羊的对话场景。

其实字节码 hook 在 Android 开发过程中更为强大,比我们传统的找 Hook 点(单例,静态变量),然后反射的方式方便太多了,还有个最大的优势就是稳定。

当然lancet hook 有个前提就是要明确知道方法调用,如果你想 hook 一个类的所有调用,那么写起来就有点费劲了,可能并不如动态代理那么方便。

好了,话说回来:

之前有个小伙去面试,被问到:

如何收敛三方库里面线程池的创建?

你有想法了吗?

作者:鸿洋
来源:juejin.cn/post/7034178205728636941

收起阅读 »

Android 实现卡片堆叠,钱包管理效果(带动画)

先上效果图源码 github.com/woshiwzy/Ca…实现原理:1.继承LinearLayout 2.重写onLayout,onMeasure 方法 3.利用ValueAnimator 实施动画 4.在动画回调中requestLayout 实现动画效果...
继续阅读 »


先上效果图


源码 github.com/woshiwzy/Ca…

实现原理:

1.继承LinearLayout
2.重写onLayout,onMeasure 方法
3.利用ValueAnimator 实施动画
4.在动画回调中requestLayout 实现动画效果

思路:

1.用Bounds 对象记录每一个CardView 对象的初始位置,当前位置,运动目标位置

2.点击时计算出对应的view以及可能会产生关联运动的View的运动的目标位置,从当前位置运动到目标位置,然后以这2个位置作为动画参数实施ValueAnimator动画,在动画回调中触发onLayout,达到动画的效果。

重写adView 方法,确保新添加的在这里确保所有的子view 都有一个初始化的bounds位置

   @Override
   public void addView(View child, ViewGroup.LayoutParams params) {
       super.addView(child, params);
       Bounds bounds = getBunds(getChildCount());
  }

确保每个子View的测量属性宽度填满父组件

    boolean mesured = false;
   @Override
   protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
       super.onMeasure(widthMeasureSpec, heightMeasureSpec);
       if (mesured == true) {//只需要测量一次
           return;
      }
       mesured = true;
       int childCount = getChildCount();
       int rootWidth = getWidth();
       int rootHeight = getHeight();
       if (childCount > 0) {
           View child0 = getChildAt(0);
           int modeWidth = MeasureSpec.getMode(child0.getMeasuredWidth());
           int sizeWidth = MeasureSpec.getSize(child0.getMeasuredWidth());

           int modeHeight = MeasureSpec.getMode(child0.getMeasuredHeight());
           int sizeHeight = MeasureSpec.getSize(child0.getMeasuredHeight());

           if (childCount > 0) {
               for (int i = 0; i < childCount; i++) {
                   View childView = getChildAt(i);
                   childView.measure(MeasureSpec.makeMeasureSpec(sizeWidth, MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(sizeHeight, MeasureSpec.EXACTLY));
                   int top = (int) (i * (sizeHeight * carEvPercnet));
                   getBunds(i).setTop(top);
                   getBunds(i).setCurrentTop(top);
                   getBunds(i).setLastCurrentTop(top);
                   getBunds(i).setHeight(sizeHeight);
              }

          }

      }
  }

重写onLayout 方法是关键,是动画触发的主要目的,这里layout参数并不是写死的,而是计算出来的(通过ValueAnimator 计算出来的)

@Override
   protected void onLayout(boolean changed, int sl, int st, int sr, int sb) {
       int childCount = getChildCount();
       if (childCount > 0) {
           for (int i = 0; i < childCount; i++) {
               View view = getChildAt(i);
               int mWidth = view.getMeasuredWidth();
               int mw = MeasureSpec.getSize(mWidth);
               int l = 0, r = l + mw;
               view.layout(l, getBunds(i).getCurrentTop(), r, getBunds(i).getCurrentTop() + getBunds(i).getHeight());
          }
      }
  }

源码

github: github.com/woshiwzy/Ca…

作者:Sand
来源:juejin.cn/post/7073371960150851615

收起阅读 »

Android 13这些权限废弃,你的应用受影响了吗?

无论是更改个人头像、分享照片、还是在电子邮件中添加附件,选择和分享媒体文件是用户最常见的操作之一。在听取了 Android 用户反馈之后,我们对应用程序访问媒体文件的方式做了一些改变。Android 13 已被废弃的权限许多用户告诉我们,文件和媒体权限让他们很...
继续阅读 »

无论是更改个人头像、分享照片、还是在电子邮件中添加附件,选择和分享媒体文件是用户最常见的操作之一。在听取了 Android 用户反馈之后,我们对应用程序访问媒体文件的方式做了一些改变。

Android 13 已被废弃的权限

许多用户告诉我们,文件和媒体权限让他们很困扰,因为他们不知道应用程序想要访问哪些文件。

在 Android 13 上废弃了 READ_EXTERNAL_STORAGEWRITE_EXTERNAL_STORAGE 权限,用更好的文件访问方式代替这些废弃的 API。

从 Android 10 开始向共享存储中添加文件不需要任何权限。因此,如果你的 App 只在共享存储中添加文件,你可以停止在 Android 10+ 上申请任何权限。

在之前的系统版本中 App 需要申请 READ_EXTERNAL_STORAGE 权限访问设备的文件和媒体,然后选择自己的媒体选择器,这为开发者增加了开发和维护成本,另外 App 依赖于通过 ACTION_GET_CONTENT 或者 ACTION_OPEN_CONTENT 的系统文件选择器,但是我们从开发者那里了解到,它感觉没有很好地集成到他们的 App 中。


图片选择器

在 Android 13 中,我们引入了一个新的媒体工具 Android 照片选择器。该工具为用户提供了一种选择媒体文件的方法,而不需要授予对其整个媒体库的访问权限。

它提供了一个简洁界面,展示照片和视频,按照日期排序。另外在 "Albums" 页面,用户可以按照屏幕截图或下载等等分类浏览,通过指定一些用户是否仅看到照片或视频,也可以设置选择最大文件数量,也可以根据自己的需求定制照片选择器。简而言之,这个照片选择器是为私人设计的,具有干净和简洁的 UI 易于实现。


我们还通过谷歌 Play 系统更新 (2022 年 5 月 1 日发布),将照片选择器反向移植到 Android 11 和 12 上,以将其带给更多的 Android 用户。

开发一个照片选择器是一个复杂的项目,新的照片选择器不需要团队进行任何维护。我们已经在 ActivityX 1.6.0 版本中为它创建了一个 ActivityResultContract。如果照片选择器在你的系统上可用,将会优先使用照片选择器。

// Registering Photo Picker activity launcher with a max limit of 5 items
val pickMultipleVisualMedia = registerForActivityResult(PickMultipleVisualMedia(5)) { uris ->
   // TODO: process URIs
}
// Launching the photo picker (photos & video included)
pickMultipleVisualMedia.launch(PickVisualMediaRequest(PickVisualMedia.ImageAndVideo))
复制代码

如果希望添加类型进行筛选,可以采用这种方式。

// Launching the photo picker (photos only)
pickMultipleVisualMedia.launch(PickVisualMediaRequest(PickVisualMedia.ImageOnly))
// Launching the photo picker (video only)
pickMultipleVisualMedia.launch(PickVisualMediaRequest(PickVisualMedia.VideoOnly))
// Launching the photo picker (GIF only)
pickMultipleVisualMedia.launch(PickVisualMediaRequest(PickVisualMedia.SingleMimeType("image/gif")))
复制代码

可以调用 isPhotoPickerAvailable 方法来验证在当前设备上照片选择器是否可用。

ACTION_GET_CONTENT 将会发生改变

正如你所见,使用新的照片选择器只需要几行代码。虽然我们希望所有的 Apps 都使用它,但在 App 中迁移可能需要一些时间。

这就是为什么我们使用 ACTION_GET_CONTENT 将系统文件选择器转换为照片选择器,而不需要进行任何代码更改,从而将新的照片选择器引入到现有的 App 中。


针对特定场景的新权限

虽然我们强烈建议您使用新的照片选择器,而不是访问所有媒体文件,但是您的 App 可能有一个场景,需要访问所有媒体文件(例如图库照片备份)。对于这些特定的场景,我们将引入新的权限,以提供对特定类型的媒体文件的访问,包括图像、视频或音频。您可以在文档中阅读更多关于它们的内容。

如果用户之前授予你的应用程序 READ_EXTERNAL_STORAGE 权限,系统会自动授予你的 App 访问权限。否则,当你的 App 请求任何新的权限时,系统会显示一个面向用户的对话框。

所以您必须始终检查是否仍然授予了权限,而不是存储它们的授予状态。


下面的决策树可以帮助您更好的浏览这些更改。


我们承诺在保护用户隐私的同时,继续改进照片选择器和整体存储开发者体验,以创建一个安全透明的 Android 生态系统。

新的照片选择器被反向移植到所有 Android 11 和 12 设备,不包括 Android Go 和非 gms 设备。


原文: medium.com/androiddeve…
译者:程序员 DHL
来源:
juejin.cn/post/7161230716838084616



收起阅读 »

安卓之如何优雅的处理Activity回收突发事件

情景与原因前面的文章说过,我的一个业务要从页面A进入页面B,也就意味着我的应用出现了在ActivityA的基础上启动了ActivityB的情景,这个时候ActivityA就进入了停止状态,但这个时候如果出现系统内存不足的情况,就会把ActivityA回收掉,此...
继续阅读 »

情景与原因

前面的文章说过,我的一个业务要从页面A进入页面B,也就意味着我的应用出现了在ActivityA的基础上启动了ActivityB的情景,这个时候ActivityA就进入了停止状态,但这个时候如果出现系统内存不足的情况,就会把ActivityA回收掉,此时用户按下Back键返回A,仍然会正常显示A,但此时的A是执行onCreate()方法加载的,而不是执行onRestart()方法,也就是说我们的ActivityA页面是被重新创建出来的。

那有什么区别呢,这就是我们平时网上填表之类的最讨厌的情景会浮现了,比如你好不容易把信息填好,点击下一步,然后发现想修改上个页面信息,哎,这时候你会发现由于系统内存不足回收掉了A,于是你辛辛苦苦填好的信息都没了,这太痛了。这一情景的出现就说明回收了A再重新创建会导致我们在A当中存的一些临时数据与状态都可能被丢失。

这就是我们今天要解决的问题。

解决方法

虽然出现这个问题是是否影响用户体验的,但解决方法是非常简单的,因为我们的Activity中还提供了一个onSaveInstanceState()回调方法,它就可以保证在Activity被回收之前一定被调用,而我们就可以利用这一特性去解决上述问题。

方法介绍

onSaveInstanceState()方法中会携带一个Bundle类型的参数,而Bundle提供了一系列方法用于保存我们想要的数据,其中如putString()就是用于保存字符串的,而putInt()方法显而易见也是保存整型数据的,而每个保存方法都需要传入两个参数,其中第一个参数我们称之为键,是用于在Bundle中取值的特定标记,第二个参数是我们要保存的内容,属于键值对类型,学过数据库的一般都知道。

写法

如下,我们可以这么去写:

override fun onSaveInstanceState(outState: Bundle) {
   super.onSaveInstanceState(outState)
   val tempData = "your temp data"
   outState.putString("data_key", tempData)
}
override fun onCreate(savedInstanceState: Bundle?) {
  super.onCreate(savedInstanceState)
  setContentView(R.layout.activity_main)
  if (savedInstanceState != null) {
      val tempData = savedInstanceState.getString("data_key")
  }
  ...
}

在onSaveInstanceState()中保存你想要的数据,在onCreate()中给取出来,这样就不用害怕再创建的时候数据丢失了,不过如果是横竖屏切换造成数据丢失还是依照前文ViewModel的写法更好。

结语

其实基础往往是最容易忽视的,我们之前光在那说高楼大厦如何建成,但地基并不牢靠,所以还是需要落实到基础上最好。

作者:ObliviateOnline
来源:juejin.cn/post/7158096746583687205

收起阅读 »

雪球 Android App 秒开实践

一、背景启动速度可以说是一个 APP 的门面,对用户体验至关重要。随着业务不断增加,需要初始化的任务也越来越多,如果放任不管,启动时长会逐步增加,为此雪球客户端针对应用启动时长做了大量优化工作。本文从应用启动基本原理出发,总结了雪球客户端启动优化的思路和遇到的...
继续阅读 »

一、背景

启动速度可以说是一个 APP 的门面,对用户体验至关重要。随着业务不断增加,需要初始化的任务也越来越多,如果放任不管,启动时长会逐步增加,为此雪球客户端针对应用启动时长做了大量优化工作。本文从应用启动基本原理出发,总结了雪球客户端启动优化的思路和遇到的问题。主要包括启动原理介绍、优化方案和线上验证等三方面内容。

二、启动原理

根据 Google 官方文档,应用启动分为以下三种类型:

  • 冷启动

  • 热启动

  • 温启动

冷启动

冷启动是指 APP 进程被杀死(系统回收、用户手动关闭等),启动 APP 需要系统重新创建应用进程,从用户点击应用桌面图标到第一个页面加载完成的全部过程。冷启动是启动类型中耗时最长的一种,也是启动优化最关键的优化点,下面我们来看一下冷启动的启动过程。


从上图可以看出 APP 冷启动可以分为以下三个过程:

  • 用户点击桌面 APP 图标,调用 Launcher.startActivity ,由 Binder 通知 system_server 进程,system_server 内部使用 ActivityManagerService 通知 zygote 创建应用子进程

  • 应用进程创建完成后,会加载 ActivityThread 并调用 ActivityThread.main 方法,用来实例化 ApplicationThread 、Lopper 和 Handler

  • ActivityThread 内部调用 attach 方法进行 Binder 通信回到 system_server 进程,执行 ActivityManagerService.attachApplication 完成 Application 的创建,同时启动第一个 Activity

我们可以换一种通俗易懂的描述:

想象一下把 Launcher 比做手机桌面,桌面里面很多 APP 可以理解成 Launcher 的孩子,zygote 就是一个进程,system_server 好比服务大管家,ActivityThread 好比每个 APP 进程自己的管家。

启动 APP 首先要通知服务大管家 (system_server),服务大管家 (system_server)收到通知后,会跟它的第一对接人 zygote 进程联系,请求 zygote 创建一个属于孩子的家,也就是 APP 自己的进程,进程创建完成后,接下来是属于孩子自己的工作,它开始使用自己的管家 ActivityThread 布置自己的家,可以简单把 Application 比做是大门,把 Activity 比作是卧室,AMS 是装修团队,ActivityThread 会不断和 AMS 交互,直到 Application 和 Activity 创建完毕,至此一个 APP 就启动完成了。

热启动

热启动是指应用程序从后台被唤起,此时应用进程仍然存在,应用启动无需创建子进程,但是可能会重新执行 Activity 的生命周期,在热启动中,系统的所有工作就是将您的 Activity 带到前台,只要应用的所有 Activity 仍驻留在内存中,应用就不必重复执行对象初始化、布局和绘制,例如用户按下 back 或者 home 键回到后台。

温启动

温启动包含了在冷启动期间发生的部分操作,同时它的开销要比热启动高,例如用户在退出应用后又重新启动应用,此时应用进程仍然存在,但应用必须通过调用 onCreate() 从头开始重新创建 Activity

冷启动是三种启动状态中最耗时的一种,启动优化也是在冷启动的基础上进行优化,热启动和温启动相对耗时较少,暂不考虑优化。

三、问题归因

工欲善其事必先利其器,要想优化启动时长,首先必须知道应用启动过程中发生了什么,以及耗时方法是哪些,下图列举了一些 APP 常用的性能检测工具:


adb shell

获取应用启动总时长 adb 命令:adb shell am start -W [packageName]/[packageName.xActivity]

详细使用可参考文档:developer.android.google.cn/studio/comm…


参数说明:

Activity:应用启动的第一个Activity

TotalTime:应用启动总时长,包括应用进程创建、Application 创建和第一个 Activity 创建并绘制完成到显示的所有过程,冷启动的情况下我们只需要关注 TotalTime 即可

Displayed

displayed 使用比较简单,我们只需要在 Logcat 中过滤关键字 displayed 即可看到应用启动的总时长,如下图所示,displayed 打印的时长跟 adb shell 几乎相同,也就是一次冷启动的总时长。


adb shell 和 displayed 都可以帮助我们快速获取应用启动时长,但是无法获取具体耗时方法的堆栈信息,应用启动的具体信息我们可以使用 Systrace 和 Traceview 来获取。

Systrace

Systrace 是 Android 平台自带的命令行工具,可记录短时间内的设备活动,并保存在压缩的文本文件中,该工具会生成一份报告,其中汇总了 Android 内核中的数据,例如 CPU 调度程序、磁盘活动和应用线程。

Systrace 工具默认在 Android SDK 里面,路径一般为 Android/sdk/platform-tools/systrace

使用 systrace 生成应用冷启动具体信息

  • 如果没有配置环境变量,先切到 systrace 目录下 cd ~/Library/Android/sdk/platform-tools/systrace

  • 执行 systrace.py -t 10 -o /Users/liuyakui/trace.html -a com.xueqiu.fund

或者直接用绝对路径执行 systrace

详细使用可参考文档:developer.android.google.cn/topic/perfo…

python ~/Library/Android/sdk/platform-tools/systrace/systrace.py -t 10 -o /Users/liuyakui/trace.html -a com.xueqiu.fund

systrace 报告如下图所示,这里仅摘取了启动优化所需要的主要信息:


  • 区域1代表 CPU 使用率,柱子越高,越密集代表 CPU 使用率越高

  • 区域2代表 CPU 编号,该设备是8核处理器,编号0-7,点击 CPU 编号区域,可以查看当前正在运行的任务

  • 区域3代表所有线程和方法具体耗时情况,可以帮助我们定位具体耗时方法

从上图可以看出在0-3秒内,CPU 平均利用率较低,特别是1-3秒这段时间,CPU 几乎处于闲置状态,提高 CPU 利用率,充分发挥 CPU 的性能,是我们主要的优化方向。

上述三部分区域所提供的信息,基本上可以帮助我们定位启动耗时问题,它提供了 CPU 使用情况以及每个线程工作情况,但它不能告诉我们具体的问题代码在哪里,我们要确定具体的耗时代码,可以使用 Traceview 工具。

Traceview

Traceview 能够以图形化的形式展示线程的工作状态,以及方法的调用堆栈和调用链,我们以 application onCreate 为例,统计 onCreate() 内部详细的方法调用,并生成 trace 报表。

详细使用可参考文档:developer.android.google.cn/studio/prof…

@Override
public void onCreate() {
   super.onCreate();
   Debug.startMethodTracing("app_trace");

   //初始化代码...

   //...

   Debug.stopMethodTracing();
}

应用启动完成后,会在 /sdcard/Android/data/com.xueqiu.fund/files 路径下生成一个 app_trace.trace 文件,直接用 AndroidStudio 打开即可,如下图所示:


trace 文件详细展示了每个线程的工作情况,以及每个线程内部具体的方法调用情况,下面简单介绍一下trace 报表中最重要的三块区域:

  • 区域1代表 CPU 使用情况,可以拖拽选择时间段

  • 区域2代表当前线程工作信息,截图所示为当前主线程在0-5s内所有的方法调用情况

  • 区域3代表当前线程内部的方法调用堆栈,以及方法耗时等信息,使用 Top Down 和 Bottom Up 可以对方法正反排序

trace 报表清晰的展示了每个线程对应的所有方法的调用链和耗时情况,很好的帮助我们定位启动过程中具体问题所在,为优化方案提供了重要的参考依据。

四、优化方案

经过上述分析,APP 启动问题主要集中在以下两个阶段:

  • Application 创建

  • 闪屏页绘制

因此下面主要是针对这两方面进行优化

Application 创建优化

从上述 Traceview 报表可以看出,影响 Application 创建的代码主要集中在 initThirdLibs 内部,我们来看一下 initThirdLibs 内部初始化代码执行流程。


initThirdLibs 内部包含了雪球客户端所有的初始化项,这些初始化任务不分主次和优先级都在主线程顺序执行,中间任意一个任务阻塞,都会影响 Application 的创建,而且随着业务不断迭代,初始化任务越来越多,Application 的创建时长也会继续累加。

因此梳理 initThirdLibs 内部任务的优先级,通过合理的方式统一调度,并对各个任务进行延迟初始化是优化 Application 创建的重要内容,延迟初始化主要实现的目标分为以下三点:

  • 提高 CPU 利用率,充分发挥 CPU 性能

  • 初始化任务 Task 处理,降低维护成本和提高任务调度的灵活性

  • 多线程处理,梳理各个 Task 的优先级,形成一个有向无环图

Task 任务流程图如下:


关于启动器实现核心逻辑为,自定义线程池,根据设备 CPU 情况动态计算线程数量,保证所有 Task 任务并发执行,并且相互独立,所有 Task 执行完毕后会最后执行 Final Task 用来做一些收尾的工作,或者有强依赖的任务,也可以放到 Final Task 中进行,这里推荐以下两种实现方式:

  • CountDownLatch

  • 自定义线程池

启动器伪代码如下:

//这里只是一段伪代码,帮助大家理解启动器的基本实现原理

TaskManager manager = new TaskManager();
ExecutorService service = createThreadPool();
final Task1 task1 = new Task1(1);
final Task2 task2 = new Task2(2);
final Task3 task3 = new Task3(3);
final Task4 task4 = new Task4(4);
for (int i = 0i < ni++) {
   Runnable runnable = new Runnable() {
       @Override
       public void run() {
           manager.get(i).start();
      }
  };
   service.execute(runnable);
}

Task 调度完成后,将不依赖主线程的初始化任务,移动到并发 Task 中进行延迟初始化,进行统一管理并且避免阻塞主线程,提高 CPU 利用率。

闪屏页绘制优化

目前闪屏页主要承载的是业务广告,通过优化广告加载的逻辑可以间接调整页面的布局结构。

布局结构

闪屏页会预加载广告数据存到本地,每次应用启动从本地读取广告数据,这里我们可以优化无广告页面展示的逻辑,目前闪屏页无广告的时候仍然会加载布局文件,并设置默认的页面停留时长,理论上如果页面无广告,闪屏页创建完成后可以直接进入首页,不用加载页面的布局文件从而减少页面绘制时间,调整后页面广告加载逻辑核心代码如下:

private void prepareSplashAd() {
   //读取广告数据
   String jsonString = PublicSetting.getInstance().getSplashAd();
   if (TextUtils.isEmpty(jsonString)) {
       //无广告,关闭页面,进入首页
       exitDelay();
       return;
  }

   //加载布局文件
   View parentView = inflateView();
   setContentView(parentView);
   //显示广告
   AD todayAd = ads.get(0);
   showSplashAd(todayAd.imgUrltodayAd.linkUrl);
}

优化结果

经过多个版本的线上数据采样,启动时长明显下降,以华为 Mate 30E Pro 为例,效果对比如下:

优化前


优化后


从上面对比中可以看到,在5年以内的旗舰机型上,启动时长从原来的 1.9s - 2.5s 降低到 0.75s - 1.2s ,整体降低60%左右,可以达到秒开的效果!CPU 活动转为密集型,充分发挥 CPU 的性能,提高了 CPU 的利用率。

五、总结

本文先介绍了应用启动的基本原理,以及如何通过各种检测工具定位影响启动速度的原因,最后重点阐述 Application 创建和闪屏页绘制两个阶段的优化方案。同时它也代表一组最佳实践,在后续的性能优化中,都是不错的选择。

其实启动优化的方案还有很多,但我们除了要关注启动优化本身,更需要制定长远的规划,设计适合自己的方案,为后续业务迭代做好铺垫,避免再次出现启动时长逐步增加的问题。

作者:雪球工程师团队
来源:juejin.cn/post/7081606242212413447

收起阅读 »