注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

Kotlin 源码 | 降低代码复杂度的法宝

随着码龄增大,渐渐意识到团队代码中的最大的敌人是“复杂度”。不合理的复杂度是降低代码质量,增加沟通成本的元凶。Kotlin 在降低代码复杂度方面有着诸多法宝。这一篇就以两个常见的业务场景来剖析下简单和复杂的关系。若要用一句话概括这关系,我最喜欢这一句:“一切简...
继续阅读 »

随着码龄增大,渐渐意识到团队代码中的最大的敌人是“复杂度”。不合理的复杂度是降低代码质量,增加沟通成本的元凶。

Kotlin 在降低代码复杂度方面有着诸多法宝。这一篇就以两个常见的业务场景来剖析下简单和复杂的关系。若要用一句话概括这关系,我最喜欢这一句:“一切简单的背后都蕴藏着复杂”。

启动线程和读取文件容是 Android 开发中两个颇为常见的场景。分别给出 Java 和 Kotlin 的实现,在惊叹两种语言表达力上悬殊的差距的同时,逐层剖析 Kotlin 语法简单背后的复杂。

启动线程

先看一个简单的业务场景,在 java 中用下面的代码启动一个新线程:

 Thread thread = new Thread() {
@Override
public void run() {
doSomething() // 业务逻辑
super.run();
}
};
thread.setDaemon(false);
thread.setPriority(-1);
thread.setName("thread");
thread.start();

启动线程是一个常用操作,其中除了 doSomething() 之外的其他代码都具有通用性。难道每次启动线程时都复制粘贴这一坨代码吗?不优雅!得抽象成一个静态方法以便到处调用:

public class ThreadUtil {
public static Thread startThread(Callback callback) {
Thread thread = new Thread() {
@Override
public void run() {
if (callback != null) callback.action();
super.run();
}
};
thread.setDaemon(false);
thread.setPriority(-1);
thread.setName("thread");
thread.start();
return thread;
}

public interface Callback {
void action();
}
}

仔细分析下这里引入的复杂度,一个新的类ThreadUtil及静态方法startThread(),还有一个新的接口Callback

然后就可以像这样构建线程了:

ThreadUtil.startThread( new Callback() {
@Override
public void action() {
doSomething();
}
})

对比下 Kotlin 的解决方案thread()

public fun thread(
start:
Boolean = true,
isDaemon:
Boolean = false,
contextClassLoader:
ClassLoader? = null,
name:
String? = null,
priority:
Int = -1,
block: () ->
Unit
)
: Thread {
val thread = object : Thread() {
public override fun run() {
block()
}
}
if (isDaemon)
thread.isDaemon = true
if (priority > 0)
thread.priority = priority
if (name != null)
thread.name = name
if (contextClassLoader != null)
thread.contextClassLoader = contextClassLoader
if (start)
thread.start()
return thread
}

thread()方法把构建线程的细节全都隐藏在方法内部。

然后就可以像这样启动一个新线程:

thread { doSomething() }

这简洁的背后是一系列语法特性的支持:

1. 顶层函数

Kotlin 中把定义在类体外,不隶属于任何类的函数称为顶层函数thread()就是这样一个函数。这样定义的好处是,可以在任意位置,方便地访问到该函数。

Kotlin 的顶层函数被编译成 java 代码后就变成一个类中的静态函数,类名是顶层函数所在文件名+Kt 后缀。

2. 高阶函数

若函数的参数或者返回值是 lambda 表达式,则称该函数为高阶函数

thread()方法的最后一个参数是 lambda 表达式。在 Kotlin 中当调用函数只传入一个 lambda 类型的参数时,可以省去括号。所以就有了thread { doSomething() }这样简洁的调用。

3. 参数默认值 & 命名参数

thread()函数包含了 6 个参数,为啥在调用时可以只传最后一个参数?因为其余的参数都在定义时提供了默认值。这个语法特性叫参数默认值

当然也可以忽略默认值,重新为参数赋值:

thread(isDaemon = true) { doSomething() }

当只想重新为某一个参数赋值时,不用将其余参数都重写一遍,只需用参数名 = 参数值,这个语法特性叫命名参数

逐行读取文件内容

再看一个稍复杂的业务场景:“读取文件中每一行的内容并打印”,用 Java 实现的代码如下:

File file = new File(path)
BufferedReader bufferedReader = null;
try {
bufferedReader = new BufferedReader(new InputStreamReader(new FileInputStream(file)));
String line;
// 循环读取文件中的每一行并打印
while ((line = bufferedReader.readLine()) != null) {
System.out.println(line);
}
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
} finally {
// 关闭资源
if (bufferedReader != null) {
try {
bufferedReader.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}

对比一下 Kotlin 的解决方案:

File(path).readLines().foreach { println(it) }

一句话搞定,就算没学过 Kotlin 也能猜到这是在干啥,语义是如此简洁清晰。这样的代码写的时候畅快,读的时候悦目。

之所以简单,是因为 Kotlin 通过各种语法特性将复杂度分层并隐藏在了后背。

1. 扩展方法

拨开简单的面纱,探究背后隐藏的复杂:

// 为 File 扩展方法 readLines()
public fun File.readLines(charset: Charset = Charsets.UTF 8): List {
// 构建字符串列表
val result = ArrayList()
// 遍历文件的每一行并将内容添加到列表中
forEachLine(charset) { result.add(it) }
// 返回列表
return result
}

扩展方法是 Kotlin 在类体外给类新增方法的语法,它用类名.方法名()表达。

把 Kotlin 编译成 java,扩展方法就是新增了一个静态方法:

final class FilesKt  FileReadWriteKt {
// 静态函数的第一个参数是 File
public static final List readLines(@NotNull File $this$readLines, @NotNull Charset charset) {
Intrinsics.checkNotNullParameter($this$readLines, "$this$readLines");
Intrinsics.checkNotNullParameter(charset, "charset");
final ArrayList result = new ArrayList();
FilesKt.forEachLine($this$readLines, charset, (Function1)(new Function1() {
public Object invoke(Object var1) {
this.invoke((String)var1);
return Unit.INSTANCE;
}

public final void invoke(@NotNull String it) {
Intrinsics.checkNotNullParameter(it, "it");
result.add(it);
}
}));
return (List)result;
}
}

静态方法中的第一个参数是被扩展对象的实例,所以在扩展方法中可以通过this访问到类实例及其公共方法。

File.readLines() 的语义简单明了:遍历文件的每一行,将其添加到列表中并返回。

复杂度都被隐藏在了forEachLine(),它也是 File 的扩展方法,此处应该是this.forEachLine(charset) { result.add(it) },this 通常可以省略。forEachLine()是个好名字,一眼看去就知道是在遍历文件的每一行。

public fun File.forEachLine(charset: Charset = Charsets.UTF 8, action: (line: String) -> Unit): Unit {
BufferedReader(InputStreamReader(FileInputStream(this), charset)).forEachLine(action)
}

forEachLine()中将 File 层层包裹最终形成一个 BufferReader 实例,并且调用了 Reader 的扩展方法forEachLine()

public fun Reader.forEachLine(action: (String) -> Unit): Unit = 
useLines { it.forEach(action) }

forEachLine()调用了同是 Reader 的扩展方法useLines(),从名字细微的差别就可以看出uselines()完成了文件所有行内容的整合,而且这个整合的结果是可以被遍历的。

2. 泛型

哪个类能整合一组元素,并可以被遍历?沿着调用链继续往下:

public inline fun  Reader.useLines(block: (Sequence<String>) -> T): T =
buffered().use { block(it.lineSequence()) }

Reader 在useLines()中被缓冲化:

public inline fun Reader.buffered(bufferSize: Int = DEFAULT BUFFER SIZE): BufferedReader =
// 如果已经是 BufferedReader 则直接返回,否则再包一层
if (this is BufferedReader) this else BufferedReader(this, bufferSize)

紧接着调用了use(),使用 BufferReader:

// Closeable 的扩展方法
public inline fun T.use(block: (T) -> R): R {
contract {
callsInPlace(block, InvocationKind.EXACTLY ONCE)
}
var exception: Throwable? = null
try {
// 触发业务逻辑(扩展对象实例被传入)
return block(this)
} catch (e: Throwable) {
exception = e
throw e
} finally {
// 无论如何都会关闭资源
when {
apiVersionIsAtLeast(1, 1, 0) -> this.closeFinally(exception)
this == null -> {}
exception == null -> close()
else ->
try {
close()
} catch (closeException: Throwable) {}
}
}
}

这次的扩展函数不是一个具体类,而是一个泛型,并且该泛型的上界是Closeable,即为所有可以被关闭的类新增一个use()方法。

use()扩展方法中,lambda 表达式block代表了业务逻辑,扩展对象作为实参传入其中。业务逻辑在try-catch代码块中被执行,最后在finally中关闭了资源。上层可以特别省心地使用这个扩展方法,因为不再需要在意异常捕获和资源关闭。

3. 重载运算符 & 约定

读取文件内容的场景中,use() 中的业务逻辑是将BufferReader转换成LineSequence,然后遍历它。这里的遍历和类型转换分别是怎么实现的?

// 将 BufferReader 转化成 Sequence
public fun BufferedReader.lineSequence(): Sequence =
LinesSequence(this).constrainOnce()

还是通过扩展方法,直接构造了LineSequence对象并将BufferedReader传入。这种通过组合方式实现的类型转换和装饰者模式颇为类似(关于装饰者模式的详解可以点击使用组合的设计模式 | 美颜相机中的装饰者模式

LineSequence 是一个 Sequence:

// 序列
public interface Sequence<out T> {
// 定义如何构建迭代器
public operator fun iterator(): Iterator
}

// 迭代器
public interface Iterator<out T> {
// 获取下一个元素
public operator fun next(): T
// 判断是否有后续元素
public operator fun hasNext(): Boolean
}

Sequence是一个接口,该接口需要定义如何构建一个迭代器iterator。迭代器也是一个接口,它需要定义如何获取下一个元素及是否有后续元素。

2 个接口中的 3 个方法都被保留词operator修饰,它表示重载运算符,即重新定义运算符的语义。Kotlin 中预定义了一些函数名和运算符的对应关系,称为约定。当前这个约定就是iterator() + next() + hasNext()for循环的约定。

for 循环在 Kotlin 中被定义为“遍历迭代器提供的元素”,需要和in保留词一起使用:

public inline fun  Sequence.forEach(action: (T) -> Unit): Unit {
for (element in this) action(element)
}

Sequence 有一个扩展法方法forEach()来简化遍历语法,内部就使用了“for + in”来遍历序列中所有的元素。

所以才可以在Reader.forEachLine()中用如此简单的语法实现遍历文件中的所有行。

public fun Reader.forEachLine(action: (String) -> Unit): Unit = 
useLines { it.forEach(action) }

关于 Sequence 的用法实例可以点击Kotlin 基础 | 望文生义的 Kotlin 集合操作

LineSequence 的语义是 Sequence 中每一个元素都是文件中的一行,它在内部实现iterator()接口,构造了一个迭代器实例:

// 行序列:在 BufferedReader 外面包一层 LinesSequence
private class LinesSequence(private val reader: BufferedReader) : Sequence {
override public fun iterator(): Iterator {
// 构建迭代器
return object : Iterator {
private var nextValue: String? = null // 下一个元素值
private var done = false // 迭代是否结束

// 判断迭代器中是否有下一个元素,并顺便获取下一个元素存入 nextValue
override public fun hasNext(): Boolean {
if (nextValue == null && !done) {
// 下一个元素是文件中的一行内容
nextValue = reader.readLine()
if (nextValue == null) done = true
}
return nextValue != null
}

// 获取迭代器中下一个元素
override public fun next(): String {
if (!hasNext()) {
throw NoSuchElementException()
}
val answer = nextValue
nextValue = null
return answer!!
}
}
}
}

LineSequence 内部的迭代器在hasNext()中获取了文件中一行的内容,并存储在nextValue中,完成了将文件中每一行的内容转换成 Sequence 中的一个元素。

当在 Sequence 上遍历时,文件中每一行的内容就一个个出现在迭代中。这样做的好处是对内存更加友好,LineSequence 并没有持有文件中所有行的内容,它只是定义了如何获取文件中下一行的内容,所有的内容只有等待遍历时,才一个个地浮现出来。

用一句话总结 Kotlin 逐行读取文件内容的算法:用缓冲流(BufferReader)包裹文件,再用行序列(LineSequence)包裹缓冲流,序列迭代行为被定义为读取文件中一行的内容。遍历序列时,文件内容就一行行地被添加到列表中。

总结

顶层函数、高阶函数、默认参数、命名参数、扩展方法、泛型、重载运算符,Kotlin 利用了这些语法特性隐藏了实现常用业务功能的复杂度,并且在内部将复杂度分层。

分层是降低复杂度的惯用手段,它不仅让复杂度分散,使得同一时刻只需面对有限的复杂度,并且可以通过对每一层取一个好名字来概括本层的语义。除此之外,它还有助于定位问题(缩小问题范围)并增加代码可复用性(每层单独复用)。

是不是也可以效仿这种分层的思想方法,在写代码之前,琢磨一下,复杂度是不是太高了?可以运用那些语言特性实现合理的抽象将复杂度分层?以避免复杂度在一个层次被铺开。

收起阅读 »

Android内存优化工具

整理下Android内存优化常用的几种工具,top命令、adb shell dumpsys meminfo、Memory Profiler、LeakCanary、MAT1. toptop命令是Linux下常用的性能分析工具,能够实时显示系统中各个进程的资源占用...
继续阅读 »

整理下Android内存优化常用的几种工具,top命令、adb shell dumpsys meminfo、Memory Profiler、LeakCanary、MAT

1. top

top命令是Linux下常用的性能分析工具,能够实时显示系统中各个进程的资源占用状况。

查看top命令的用法

$ adb shell top --help
usage: top [-Hbq] [-k FIELD,] [-o FIELD,] [-s SORT] [-n NUMBER] [-m LINES] [-d SECONDS] [-p PID,] [-u USER,]

Show process activity in real time.

-H Show threads
-k Fallback sort FIELDS (default -S,-%CPU,-ETIME,-PID)
-o Show FIELDS (def PID,USER,PR,NI,VIRT,RES,SHR,S,%CPU,%MEM,TIME+,CMDLINE)
-O Add FIELDS (replacing PR,NI,VIRT,RES,SHR,S from default)
-s Sort by field number (1-X, default 9)
-b Batch mode (no tty)
-d Delay SECONDS between each cycle (default 3)
-m Maximum number of tasks to show
-n Exit after NUMBER iterations
-p Show these PIDs
-u Show these USERs
-q Quiet (no header lines)

Cursor LEFT/RIGHT to change sort, UP/DOWN move list, space to force
update, R to reverse sort, Q to exit.

使用top命令显示一次进程信息,以便讲解进程信息中各字段的含义

^[[41;173RTasks: 754 total,   1 running, 753 sleeping,   0 stopped,   0 zombie
Mem: 5.5G total, 5.4G used, 165M free, 76M buffers
Swap: 2.5G total, 789M used, 1.7G free, 2.4G cached
800%cpu 100%user 3%nice 54%sys 641%idle 0%iow 3%irq 0%sirq 0%host
PID USER PR NI VIRT RES SHR S[%CPU] %MEM TIME+ ARGS
15962 u0 a894 10 -10 6.6G 187M 76M S 75.6 3.2 8:16.55 asia.bluepay.cl+
785 system -2 -8 325M 13M 7.6M S 29.7 0.2 84:03.91 surfaceflinger
25255 shell 20 0 35M 2.7M 1.6M R 21.6 0.0 0:00.16 top -n 1
739 system -3 -8 177M 3.6M 2.2M S 10.8 0.0 16:00.36 android.hardwar+
16154 u0 i9086 10 -10 1.3G 40M 19M S 5.4 0.6 0:46.18 com.google.andr+
13912 u0 a87 20 0 17G 197M 86M S 5.4 3.4 23:56.88 com.tencent.mm
24789 root RT -2 0 0 0 D 2.7 0.0 0:01.36 [mdss fb0]
24704 root 20 0 0 0 0 S 2.7 0.0 0:01.20 [kworker/u16:12]
20096 u0 a94 30 10 6.1G 137M 53M S 2.7 2.3 0:31.45 com.xiaomi.mark+
2272 system 18 -2 8.7G 407M 267M S 2.7 7.1 191:11.32 system server
744 system RT 0 1.3G 1.6M 1.4M S 2.7 0.0 72:22.41 android.hardwar+
442 root RT 0 0 0 0 S 2.7 0.0 5:59.68 [cfinteractive]
291 root -3 0 0 0 0 S 2.7 0.0 5:00.17 [kgsl worker th+
10 root 20 0 0 0 0 S 2.7 0.0 1:55.84 [rcuop/0]
7 root 20 0 0 0 0 S 2.7 0.0 2:46.82 [rcu preempt]
25186 shell 20 0 34M 1.9M 1.4M S 0.0 0.0 0:00.71 logcat -v long +
25181 root 20 0 0 0 0 S 0.0 0.0 0:00.00 [kworker/2:3]
25137 root 20 0 0 0 0 S 0.0 0.0 0:00.00 [kworker/1:3]
25118 system 20 0 5.2G 83M 54M S 0.0 1.4 0:01.05 com.android.set+
24946 u0 a57 20 0 5.1G 60M 37M S 0.0 1.0 0:00.82 com.xiaomi.acco+
复制代码
第 1 行:进程信息
  • 总共(total):754个
  • 运行中(running)状态:1个
  • 休眠(sleeping)状态:753个
  • 停止(stopped)状态:0个
  • 僵尸(zombie)状态:0个
第 2 行:内存信息
  • 5.5G total:物理内存总量
  • 5.4G used:使用中的内存量
  • 165M free:空闲内存量
  • 76M buffers: 缓存的内存量
第 3 行:Swap分区信息
  • 2.5G total:交换区总量
  • 789M used:使用的交换区大小
  • 1.7G free:空闲交换区大小
  • 2.4G cached:缓冲的交换区大小

内存监控时,可以监控swap交换分区的used,如果这个数值在不断的变化,说明内核在不断进行内存和swap的数据交换,这是内存不够用了。

第 4 行:CPU信息
  • 800%cpu:8核cpu
  • 100%user:用户进程使用CPU占比
  • 3%nice:优先值为负的进程占比
  • 54%sys:内核进程使用CPU占比
  • 641%idle:除IO等待时间以外的其它等待时间占比
  • 0%iow:IO等待时间占比
  • 3%irq:硬中断时间占比
  • 0%sirq:软中断时间占比
第 5 行及以下:各进程的状态监控
  • PID:进程id
  • USER:进程所属用户
  • PR:进程优先级
  • NI:nice值,负值表示高优先级,正值表示低优先级
  • VIRT:进程使用的虚拟内存总量,VIRT=SWAP+RES
  • RES:进程使用的、未被换出的物理内存大小,RES=CODE+DATA
  • SHR:共享内存大小
  • S:进程状态
  • %CPU:上次更新到现在的CPU占用时间比
  • %MEM:使用物理内存占比
  • TIME+:进程时间的CPU时间总计,单位1/100秒
  • ARGS:进程名

2. dumpsys meminfo

首先了解下Android中最重要的四大内存指标的概念

指标全称含义等价
USSUnique Set Size独占物理内存进程独占的内存
PSSProportional Set Size实际使用物理内存PSS = USS + 按比例包含共享库内存
RSSResident Set Size实际使用物理内存RSS = USS + 包含共享库内存
VSSVirtual Set Size虚拟耗用内存VSS = 进程占用内存(包括虚拟耗用) + 共享库(包括比例分配部分)

我们主要使用USS和PSS来衡量进程的内存使用情况

dumpsys meminfo命令展示的是系统整体内存情况,内存项按进程进行分类

$ adb shell dumpsys meminfo
Applications Memory Usage (in Kilobytes):
Uptime: 168829244 Realtime: 1465769995

// 根据进程PSS占用值从大到小排序
Total PSS by process:
272,029K: system (pid 2272)
234,043K: com.tencent.mm (pid 13912 / activities)
185,914K: com.android.systemui (pid 13606)
107,294K: com.tencent.mm:appbrand0 (pid 5563)
101,526K: com.tencent.mm:toolsmp (pid 9287)
96,645K: com.miui.home (pid 15116 / activities)
...

// 以oom来划分,会详细列举所有的类别的进程
Total PSS by OOM adjustment:
411,619K: Native
62,553K: android.hardware.camera.provider@2.4-service (pid 730)
21,630K: logd (pid 579)
16,179K: surfaceflinger (pid 785)
...
272,029K: System
272,029K: system (pid 2272)
361,942K: Persistent
185,914K: com.android.systemui (pid 13606)
37,917K: com.android.phone (pid 2836)
23,510K: com.miui.contentcatcher (pid 3717)
...
36,142K: Persistent Service
36,142K: com.android.bluetooth (pid 26472)
101,198K: Foreground
72,743K: com.miui.securitycenter.remote (pid 4125)
28,455K: com.android.settings (pid 30919 / activities)
338,088K: Visible
96,645K: com.miui.home (pid 15116 / activities)
46,939K: com.miui.personalassistant (pid 31043)
36,491K: com.xiaomi.xmsf (pid 4197)
...
47,703K: Perceptible
17,826K: com.xiaomi.metoknlp (pid 4477)
10,748K: com.lbe.security.miui (pid 5097)
10,528K: com.xiaomi.location.fused (pid 4563)
8,601K: com.miui.mishare.connectivity (pid 4227)
13,088K: Perceptible Low
13,088K: com.miui.analytics (pid 19306)
234,043K: Backup
234,043K: com.tencent.mm (pid 13912 / activities)
22,028K: A Services
22,028K: com.miui.powerkeeper (pid 29762)
198,787K: Previous
33,375K: com.android.quicksearchbox (pid 31023)
23,278K: com.google.android.webview:sandboxed process0:org.chromium.content.app.SandboxedProcessService0:0 (pid 16154)
171,434K: B Services
45,962K: com.tencent.mm:push (pid 14095)
31,514K: com.tencent.mobileqq:MSF (pid 12051)
22,691K: com.xiaomi.mi connect service (pid 22821)
...
538,062K: Cached
107,294K: com.tencent.mm:appbrand0 (pid 5563)
101,526K: com.tencent.mm:toolsmp (pid 9287)
72,112K: com.tencent.mm:tools (pid 9187)
...

// 按内存的类别来进行划分
Total PSS by category:
692,040K: Native
328,722K: Dalvik
199,826K: .art mmap
129,981K: .oat mmap
126,624K: .dex mmap
124,509K: Unknown
92,666K: .so mmap
68,189K: Dalvik Other
53,491K: .apk mmap
44,104K: Gfx dev
28,099K: Other mmap
24,960K: .jar mmap
7,956K: Ashmem
3,700K: Stack
3,368K: Other dev
450K: .ttf mmap
4K: Cursor
0K: EGL mtrack
0K: GL mtrack
0K: Other mtrack

// 手机整体内存使用情况
Total RAM: 5,862,068K (status normal)
Free RAM: 3,794,646K ( 538,062K cached pss + 3,189,244K cached kernel + 0K cached ion + 67,340K free)
Used RAM: 2,657,473K (2,208,101K used pss + 449,372K kernel)
Lost RAM: 487,987K
ZRAM: 219,996K physical used for 826,852K in swap (2,621,436K total swap)
Tuning: 256 (large 512), oom 322,560K, restore limit 107,520K (high-end-gfx)
复制代码

查看单个进程的内存信息,命令如下

adb shell dumpsys meminfo [pid | packageName]
复制代码

我们查看下微信的内存信息

$ adb shell dumpsys meminfo com.tencent.mm
Applications Memory Usage (in Kilobytes):
Uptime: 169473031 Realtime: 1466413783

** MEMINFO in pid 13912 [com.tencent.mm] **
Pss Private Private SwapPss Heap Heap Heap
Total Dirty Clean Dirty Size Alloc Free
------ ------ ------ ------ ------ ------ ------
Native Heap 51987 51924 0 61931 159044 139335 19708
Dalvik Heap 74302 74272 8 2633 209170 184594 24576
Dalvik Other 10136 10136 0 290
Stack 84 84 0 8
Ashmem 2 0 0 0
Gfx dev 8808 8808 0 0
Other dev 156 0 156 0
.so mmap 9984 984 7436 8493
.jar mmap 1428 0 560 0
.apk mmap 2942 0 1008 0
.ttf mmap 1221 0 1064 0
.dex mmap 31302 44 30004 528
.oat mmap 2688 0 232 0
.art mmap 2792 2352 40 3334
Other mmap 6932 2752 632 0
Unknown 4247 4232 4 7493
TOTAL 293721 155588 41144 84710 368214 323929 44284

App Summary
Pss(KB)
------
Java Heap: 76664
Native Heap: 51924
Code: 41332
Stack: 84
Graphics: 8808
Private Other: 17920
System: 96989

TOTAL: 293721 TOTAL SWAP PSS: 84710

Objects
Views: 623 ViewRootImpl: 1
AppContexts: 9 Activities: 1
Assets: 12 AssetManagers: 0
Local Binders: 198 Proxy Binders: 183
Parcel memory: 46 Parcel count: 185
Death Recipients: 125 OpenSSL Sockets: 1
WebViews: 0

SQL
MEMORY USED: 156
PAGECACHE OVERFLOW: 13 MALLOC SIZE: 117

DATABASES
pgsz dbsz Lookaside(b) cache Dbname
4 28 46 721/26/4 /data/user/0/com.tencent.mm/databases/Scheduler.db

Asset Allocations
: 409K
: 12K
: 1031K
复制代码
  1. App Summary各项指标解读如下,通常我们需要重点关注Java Heap和Native Heap的大小,如果持续上升,有可能存在内存泄露。
属性内存组成
Java HeapDalvik Heap的Private Dirty + .art mmap的Private Dirty&Private Clean
Native HeapNative Heap的Private Dirty
Code.so mmap + .jar mmap + .apk mmap + .ttf.mmap + .dex.mmap + .oat mmap的Private Dirty&Private Clean
StackStack的Private Dirty
GraphicsGfx dev + EGL mtrack + GL mtrack的Private Dirty&Private Clean
  1. Objects中Views、Activities、AppContexts的异常可以判断有内存泄露,比如刚退出应用,查看Activites是否为0,如果不为0,则有Activity没有销毁。

3. Memory Profiler

Memory Profiler是 Android Profiler 中的一个组件,实时图表展示应用内存使用量,识别内存泄露和抖动,提供捕获堆转储,强制GC以及跟踪内存分配的能力。

Android Profiler官方文档

4. Leak Canary

非常好用的内存泄露检测工具,对于Activity/Fragment的内存泄露检测非常方便。

Square公司开源 官网地址,原理后面单独分析。

5. MAT

MAT是Memory Analyzer tool的缩写,是一个非常全面的分析工具,使用相对复杂点。 关于安装和配置有很多很好的文章结束,这里就不单独讲了,后面分析具体案例。

Android 内存优化篇 - 使用profile 和 MAT 工具进行内存泄漏检测

使用Android Studio和MAT进行内存泄漏分析

内存问题高效分析方法

  1. 接入LeakCanary,监控所有Activity和Fragment的释放,App所有功能跑一遍,观察是否有抓到内存泄露的地方,分析引用链找到并解决问题,如此反复,直到LeakCanary检查不到内存泄露。
  2. adb shell dumpsys meminfo命令查看退出界面后Objects的Views和Activities数目,特别是退出App后数目为否为0。
  3. 打开Android Studio Memory Profiler,反复打开关闭页面多次,点击GC,如果内存没有恢复到之前的数值,则可能发生了内存泄露。再点击Profiler的垃圾桶图标旁的heap dump按钮查看当面内存堆栈情况,按包名找到当前测试的Activity,如果存在多份实例,则很可能发生了内存泄露。
  4. 对于可疑的页面dump出内存快照文件,转换后用MAT打开,针对性的分析。
  5. 观察Memory Profiler每个页面打开时的内存波峰和抖动情况,针对性分析。
  6. 开发者选项中打开“不保留后台活动”,App运行一段时间后退到后台,触发GC,dump内存快照。MAT分析静态内容是否有可以优化的地方,比如图片缓存、单例、内存缓存等。
收起阅读 »

环信IM会话列表和聊天界面修改头像和昵称

如何修改会话列表和聊天界面的头像和昵称?方法简单,但这里先说明一下设计思路:MVVMModel view viewModel思路明确后,我们需要拿到其中的viewModel,然后修改其中的值.会话列表控制器和viewModel聊天控制器和viewModel如果...
继续阅读 »

如何修改会话列表和聊天界面的头像和昵称?


方法简单,但这里先说明一下设计思路:

MVVM

Model view viewModel

思路明确后,我们需要拿到其中的viewModel,然后修改其中的值.




会话列表控制器和viewModel



聊天控制器和viewModel


如果我们不考虑其中的结构/思路/思想,单纯为了解决问题,那么上述截图已经可以解决问题了.


我的理解:
为什么返回的viewModel一定是遵循某协议的?



我们正常理解的协议是:制定协议,指定委托,实现协议方法.

小了!格局小了!

当我思考上面截图这个协议之后.才明白,这里的协议是为了要求子类遵循标准.

这里协议本意并非是为了让实现什么,而是为了限定参数类型/参数名.是对数据模型的一种约束.

对于一个类型,无论是这个类型持有的方法还是属性,都是其特有的特点,既然是特点,便可继承.而这些方法啊,属性啊,不都是对此类型的一种约束吗?所以,我们可以看做 类型持有其特有的属性和方法,一些属性和一些方法约束了某一个类型.

如果同时了解java的同学都知道.java中有一个类型关键字为interface,我们称之为接口类,抽象类的一种,那么本意指的是,它也是一个类,只是无法实例化.

回头再看oc语言中的protocol,不就是java中的interface吗?

看到如此高质量的demo,使我的技术提升很大.多看大神的代码和多思考其思路,都是学习机会.

收起阅读 »

Android字体系列 (四):全局替换字体方式

前言 很高兴遇见你~ 在本系列的上一篇文章中,我们了解了 Xml 中的字体,还没有看过上一篇文章的朋友,建议先去阅读Android字体系列 (三):Xml中的字体,有了前面的基础,接下来我们就看下 Android 中全局替换字体的几种方式 注意:本文所展...
继续阅读 »

前言


很高兴遇见你~


在本系列的上一篇文章中,我们了解了 Xml 中的字体,还没有看过上一篇文章的朋友,建议先去阅读

Android字体系列 (三):Xml中的字体

,有了前面的基础,接下来我们就看下 Android 中全局替换字体的几种方式


注意:本文所展示的系统源码都是基于Android-30 ,并提取核心部分进行分析


Github Demo 地址 , 大家可以看 Demo 跟随我的思路一起分析


一、方式一:通过遍历 ViewTree,全局替换字体


之前我讲过:在 Android 中,我们一般会直接或间接的通过 TextView 控件去承载字体的显示,因为关于 Android 提供的承载字体显示的控件都会直接或间接继承 TextView。


那么这就是一个突破口:我们可以在 Activity 或 Fragment 的基类里面获取当前布局的 ViewTree,遍历 ViewTree ,获取 TextView 及其子类,批量修改它们的字体,从而达到全局替换字体的效果。


代码如下:


//全局替换字体工具类
object ChangeDefaultFontUtils {

private const val NOTO_SANS_BOLD = R.font.noto_sans_bold
/**
* 方式一: 遍历布局的 ViewTree, 找到 TextView 及其子类进行批量替换
*
*
@param mContext 上下文
*
@param rootView 根View
*/

fun changeDefaultFont(mContext: Context?, rootView: View?){
when(rootView){
is ViewGroup -> {
rootView.forEach {
changeDefaultFont(mContext,it)
}
}
is TextView -> {
try {
val typeface = ResourcesCompat.getFont(mContext!!, NOTO_SANS_BOLD)
val fontStyle = rootView.typeface?.style ?: Typeface.NORMAL
rootView.setTypeface(typeface,fontStyle)
} catch (e: Exception) {
e.printStackTrace()
}
}
}
}
}

//Activity 基类
abstract class BaseActivity: AppCompatActivity(){

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val mRootView = LayoutInflater.from(this).inflate(getLayoutId(), null)
setContentView(mRootView)
ChangeDefaultFontUtils.changeDefaultFont(this,mRootView)
initView()
}

/**获取布局Id*/
abstract fun getLayoutId(): Int

/**初始化*/
abstract fun initView()
}

//MainActivity
class MainActivity : BaseActivity() {

override fun getLayoutId(): Int {
return R.layout.activity_main
}

override fun initView() {

}
}

上述代码:


1、创建了一个全局替换字体的工具类,主要逻辑:


判断当前 rootView 是否是一个 ViewGroup,如果是,遍历取出其所有的子 View,然后递归调用 changeDefaultFont 方法。再判断是否是 TextView 或其子类,如果是就替换字体


2、创建了一个 Activity 基类,并在其中写入字体替换的逻辑


3、最后让上层 Activity 继承基类 Activity


逻辑很简单,在看下我们编写的 Xml 的一个效果:


image-20210616144417422


接下来我们运行看下实际替换后的一个效果:


image-20210616144927196

可以看到,字体被替换了。


现在我们来讨论一下这种方式的优缺点:


优点:我们不需要修改 Xml 布局,不需要重写多个控件,只需要在 inflate View 之后调一下就可以了


缺点:不难发现这种方式会遍历 Xml 文件中的所有 View 和 ViewGroup,但是如果出现 RecyclerView , ListView,或者其他 ViewGroup 里面动态添加 View,那么我们还是需要去手动添加替换的逻辑,否则字体不会生效。而且它每次递归遍历 ViewTree,性能上多少会有点影响


接下来我们看第二种方式


二、方式二:通过 LayoutInflater,全局替换字体


讲这种方式前,我们首先要对 LayoutInflater 的 inflate 过程有一定的了解,以 AppCompatActivity 的 setContentView 为例大致说下流程:


我们在 Activity 的 setContentView 中传入一个布局 Xml,Activity 会通过代理类 AppCompatDelegateImpl 把它交由 LayoutInflater 进行解析,解析出来后,会交由自己的 3 个工厂去创建 View,优先级分别是mFactory2、mFactory、mPrivateFactory


流程大概就说到这里,具体过程我后续会写一篇文章专门去讲。


mFactory2、mFactory ,系统提供了开放的 Api 给我们去设置,如下:


//以下两个方法在 LayoutInflaterCompat.java 文件中
@Deprecated
public static void setFactory(@NonNull LayoutInflater inflater, @NonNull LayoutInflaterFactory factory) {
if (Build.VERSION.SDK_INT >= 21) {
inflater.setFactory2(factory != null ? new Factory2Wrapper(factory) : null);
} else {
final LayoutInflater.Factory2 factory2 = factory != null
? new Factory2Wrapper(factory) : null;
inflater.setFactory2(factory2);

final LayoutInflater.Factory f = inflater.getFactory();
if (f instanceof LayoutInflater.Factory2) {
forceSetFactory2(inflater, (LayoutInflater.Factory2) f);
} else {
forceSetFactory2(inflater, factory2);
}
}
}

public static void setFactory2(@NonNull LayoutInflater inflater, @NonNull LayoutInflater.Factory2 factory) {
inflater.setFactory2(factory);

if (Build.VERSION.SDK_INT < 21) {
final LayoutInflater.Factory f = inflater.getFactory();
if (f instanceof LayoutInflater.Factory2) {
forceSetFactory2(inflater, (LayoutInflater.Factory2) f);
} else {
forceSetFactory2(inflater, factory);
}
}
}

这两个方法在 LayoutInflaterCompat 这个类中,LayoutInflaterCompat 是 LayoutInflater 一个辅助类,可以看到:


1、setFactory 方法使用了 @Deprecated 注解表示这个 Api 被弃用


2、setFactory2 是 Android 3.0 引入的,它和 setFactory 功能是一致的,区别就在于传入的接口参数不一样,setFactory2 的接口参数要多实现一个方法


利用 setFactory 系列方法,我们可以:


1)、拿到 LayoutInflater inflate 过程中 Xml 控件对应的名称和属性


2)、我们可以对控件进行替换或者做相关的逻辑处理


看个实际例子:还是方式一的代码,我们在 BaseActivity 中增加如下代码:


//Activity 基类
abstract class BaseActivity: AppCompatActivity(){

//新增部分
private val TAG: String? = javaClass.simpleName

override fun onCreate(savedInstanceState: Bundle?) {
//...
//新增部分,其余代码省略
LayoutInflaterCompat.setFactory2(layoutInflater,object : LayoutInflater.Factory2{
override fun onCreateView(parent: View?, name: String, context: Context, attrs: AttributeSet
)
: View? {
Log.d(TAG, "name: $name" )
for (i in 0 until attrs.attributeCount){
Log.d(TAG, "attr: ${attrs.getAttributeName(i)} ${attrs.getAttributeValue(i)}")
}
return null
}

override fun onCreateView(name: String, context: Context, attrs: AttributeSet): View? {
return null
}

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

//...
}

注意:上面 LayoutInflaterCompat.setFactory2 方法必须放在 super.onCreate(savedInstanceState) 的前面,不然会报错,因为系统会在 AppCompatActivity 的 oncreate 方法给 LayoutInflater 设置一个 Factory,而如果在已经设置的情况下再去设置,LayoutInflater 的 setFactory 系列方法就会抛异常,源码如下:


//AppCompatActivity 的 oncreate
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
final AppCompatDelegate delegate = getDelegate();
//调用 AppCompatDelegateImpl 的 installViewFactory 设置 Factory
delegate.installViewFactory();
//...
}

//AppCompatDelegateImpl 的 installViewFactory
@Override
public void installViewFactory() {
LayoutInflater layoutInflater = LayoutInflater.from(mContext);
if (layoutInflater.getFactory() == null) {
//如果当前 LayoutInflater 的 Factory 为空,则进行设置
LayoutInflaterCompat.setFactory2(layoutInflater, this);
} else {
//如果不为空,则进行 Log 日志打印
if (!(layoutInflater.getFactory2() instanceof AppCompatDelegateImpl)) {
Log.i(TAG, "The Activity's LayoutInflater already has a Factory installed"
+ " so we can not install AppCompat's");
}
}
}

//LayoutInflater 的 setFactory2
public void setFactory2(Factory2 factory) {
//如果已经设置,则抛异常
if (mFactorySet) {
throw new IllegalStateException("A factory has already been set on this LayoutInflater");
}
if (factory == null) {
throw new NullPointerException("Given factory can not be null");
}
mFactorySet = true;
//...
}

注意:上面 AppCompatActivity 中设置 Factory 是 android.appcompat 1.1.0 版本,而如果是更高的版本,如 1.3.0,可能设置的地方会有点变化,但是不影响我们设置位置的变化,感兴趣的可以去看下源码,这里你只要知道我们必须在 Activity 的 super.onCreate(savedInstanceState) 之前设置 Factory 就可以了


运行应用程序,看下几个主要控件的截图打印信息:


image-20210616150016885

从 Log 输出可以看出,你所有的 Xml 控件,都会经过 LayoutInflaterFactory.onCreateView 方法走一遍去实现初始化的过程,在其中可以有效的分辨出是什么控件,以及它有什么属性。并且 onCreateView 方法的返回值就是一个 View,因此我们在此处可以对控件进行替换或者做相关的逻辑处理


到这里,你是否有了全体替换字体的思路了呢?


答案已经很明了:利用自定义的 Factory 进行字体的替换


这种方式我们只需要在 BaseActivity 里面操作就可以了,而且有效的解决了方式一带来的问题,提高了效率,如下:


abstract class BaseActivity: AppCompatActivity(){

override fun onCreate(savedInstanceState: Bundle?) {
LayoutInflaterCompat.setFactory2(layoutInflater,object : LayoutInflater.Factory2{
override fun onCreateView(parent: View?, name: String, context: Context, attrs: AttributeSet
)
: View? {
var view: View? = null
if(1 == name.indexOf(".")){
//表示自定义 View
//通过反射创建
view = layoutInflater.createView(name,null,attrs)
}

if(view == null){
//通过系统创建一系列 appcompat 的 View
view = delegate.createView(parent, name, context, attrs)
}

if(view is TextView){
//如果是 TextView 或其子类,则进行字体的替换
ChangeDefaultFontUtils.changeDefaultFont(this@BaseActivity,view)
}

return view
}

override fun onCreateView(name: String, context: Context, attrs: AttributeSet): View? {
return null
}

})
super.onCreate(savedInstanceState)
setContentView(getLayoutId())
initView()
}

/**获取布局Id*/
abstract fun getLayoutId(): Int

/**初始化*/
abstract fun initView()
}

上述代码我们做了:


1、判断是自定义 View ,通过反射创建


2、判断是系统提供的一些控件,使用 appcompat 系列 View 进行替换


3、判断是 TextView 或其子类,进行字体的替换


运行应用程序,最终实现了和方式一一样的效果:


image-20210616144927196

三、方式三:通过配置应用主题,全局替换默认字体


这种方式挺简单的,在 application 中,通过 android:theme 来配置一个 App 的主题。一般新创建的项目,都是会有一个默认基础主题。在其中追加关于字体的属性,就可以完成全局默认字体的替换,在主题中我们可以对以下三个属性进行配置:


 <item name="android:typeface">item>
<item name="android:fontFamily">item>
<item name="android:textStyle">item>

这三者的设置和关系我们在本系列的第一篇文章中已经讲过,还不清楚的可以去看下 传送门


关于 Xml 中使用字体的功能,我们上篇文章也已经讲过,还不清楚的可以去看下 传送门


因为我们只需要配置默认字体,所以新增一行如下配置,就可以实现全局替换默认字体的效果了:


<style name="Theme.ChangeDefaultFontDemo" parent="Theme.MaterialComponents.DayNight.DarkActionBar.Bridge">
//...
<item name="android:fontFamily">@font/noto_sans_bolditem>

//...
style>

那么凡事都有意外,假如你的 Activity 引用了自定义主题,且自定义主题没有继承基础主题,那么你就需要补上这一行配置,不然配置的默认字体不会生效


四、方式四:通过反射,全局替换默认字体


通过反射修改,其实和方式三有点类似。因为在 Android Support Library 26 之前,我们不能直接在 Xml 中设置第三方字体,而只能设置系统提供的一些默认字体,所以通过反射这种方式,可以把系统默认的字体替换为第三方的字体。而现在我们使用的版本基本上都会大于等于 26,因此通过配置应用主题的方式就可以实现全局替换默认字体的效果。但是这里并不妨碍我们讲反射修改默认字体。


1、步骤一:在 App 的主题配置默认字体


<style name="Theme.ChangeDefaultFontDemo" parent="Theme.MaterialComponents.DayNight.DarkActionBar.Bridge">
//...
<item name="android:typeface">serifitem>

//...
style>

这里随便选一个默认字体,后续我们反射的时候需要拿到你这个选的默认字体,然后进行一个替换


注意: 这里必须配置 android:typeface ,其他两个不行,在本系列的第一篇中,关于 typeface,textStyle 和 fontFamily 属性三者的关系我们分析过,还不清楚的可以去看看 传送门


setTypefaceFromAttrs 方法是 TextView 最终设置字体的方法,当 typeface 和 familyName 都为空,则会根据 typefaceIndex 的值取相应的系统默认字体。当我们设置 android:typeface 属性时,会将对应的属性值赋给 typefaceIndex ,并把 familyName 置为 null,而 typeface 默认为 null,因此满足条件


2、通过反射修改 Typeface 默认字体


注意:Google 在 Android 9.0 及之后对反射做了限制,被使用 @hide 标记的属性和方法通过反射拿不到


在 Typeface 中,自带的一些默认字体被标记的是 public static final,因此这里无需担心反射的限制


image-20210618174439624


因为在上一步配置的主题中,我们设置的是 serif ,所以这里替换它就好了,完整的方法就是通过反射拿到 Typeface 的默认字体 SERIF,然后使用反射将它修改成我们需要的字体即可:


object ChangeDefaultFontUtils {
const val NOTO_SANS_BOLD = R.font.noto_sans_bold

fun changeDefaultFont(mContext: Context) {
try {
val typeface = ResourcesCompat.getFont(mContext, NOTO_SANS_BOLD)
val defaultField = Typeface::class.java.getDeclaredField("SERIF")
defaultField.isAccessible = true
defaultField[null] = typeface
} catch (e: Exception) {
e.printStackTrace()
}
}
}

3、在 Application 里面,调用替换的方法


class MyApplication : Application() {

override fun onCreate() {
super.onCreate()
ChangeDefaultFontUtils.changeDefaultFont(this)
}
}

那么经过上面的三个步骤,我们同样可以实现全局替换默认字体的效果


五、项目实践


回到我们剩下的需求:全局替换默认字体


1、方式一和方式二都是全局替换字体,会将我们之前已经设置好的字体给覆盖,因此并不适合


2、方式三和方式四都是全局替换默认字体,我们之前已经设置好的字体不会被覆盖,满足我们的要求,但是方式四通过反射,是因为之前我们不能直接在 Xml 里面设置第三方字体。从 Android Support Library 26 及之后支持在 Xml 里面设置默认字体了,因此我在项目实践中,最终选择了方式三实现了全局替换默认字体的效果,需求完结 ?


六、总结


最后回顾一下我们讲的重点知识:


1、通过遍历 ViewTree,全局替换字体,这种方式每次都需要递归遍历,有性能问题


2、通过 LayoutInflater 设置自定义 Factory 全局替换字体,效率高


3、通过配置应用主题全局替换默认字体,简单高效


4、通过反射全局替换默认字体,相对于 3,性能会差点,使用步骤也相对复杂


5、我在项目实践过程中的一个选择


好了,本系列文章到这里就结束了,希望能给你带来帮助 ?


感谢你阅读这篇文章


参考和推荐


全局修改默认字体,通过反射也能做到


作者:sweetying
链接:https://juejin.cn/post/6975333037240221727
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

Android字体系列 (三):Xml中的字体

前言 很高兴遇见你~ 在本系列的上一篇文章中,我们对 Typeface 进行了深入的解析,还没有看过上一篇文章的朋友,建议先去阅读 Android字体系列(二):Typeface完全解析。接下来我们看下 Google 推出的 Xml 中使用字体 ...
继续阅读 »

前言


很高兴遇见你~


在本系列的上一篇文章中,我们对 Typeface 进行了深入的解析,还没有看过上一篇文章的朋友,建议先去阅读 

Android字体系列(二):Typeface完全解析

。接下来我们看下 Google 推出的 Xml 中使用字体


一、Xml 中字体介绍


Google 在 Android Support Library 26 引入了 Xml 中设置字体这项新功能,它可以让你将字体当成资源去使用,你可以在 res/font/ 文件夹中添加 font 文件,将字体捆绑为资源。这些字体会在 R 文件中编译,可直接在 Android Studio 中使用,如:


@font/myfont 
R.font.myfont

注意:要使用 Xml 字体功能,需引入 Android Support Library 26 及更高版本且要在 Android 4.1 及更高版本的设备


二、使用步骤


1、右键点击 res 文件夹,然后转到 New > Android resource directory


2、在 Resource type 列表中,选择 font,然后点击 OK


image-20210616203615018

3、在 font 文件夹中添加字体文件



关于字体,推荐两个免费下载的网站


fonts.google.com/


http://www.1001freefonts.com/



image-20210616203940427

添加之后就会生成 R.font.ma_shan_zhenng_regular 和 R.font.noto_sans_bold


4、双击字体文件可预览当前字体


image-20210616204148155


以上 4 个步骤完成后我们就可以在 Xml 中使用字体了


5、创建 font family


1)、右键点击 font 文件夹,然后转到 New > Font resource file。此时将显示 New Resource File 窗口。


2)、输入文件名,然后点击 OK。新的字体资源 Xml 会在编辑器中打开。


3)、将各个字体文件、样式和粗细属性都封装在 元素中。如下:



<font-family xmlns:tools="http://schemas.android.com/tools"
xmlns:android="http://schemas.android.com/apk/res/android"
tools:ignore="UnusedAttribute">


<font
android:fontStyle="normal"
android:fontWeight="400"
android:font="@font/ma_shan_zheng_regular"
tools:ignore="UnusedAttribute" />


<font
android:fontStyle="normal"
android:fontWeight="400"
android:font="@font/noto_sans_bold"
/>

font-family>

实践发现使用 font family 存在一些坑:


1、例如我上面添加了两个 font 标签,这个时候在 Xml 里面引用将不会有任何效果,而且设置的 fontStyle 等属性不会生效。


2、当只添加了一个 font 标签,此时字体会生效,但是设置的 fontStyle 等属性还是不会生效


因此我们在使用的时候建议直接对字体资源进行引用,样式粗细这些在进行单独的设置


三、在 XML 布局中使用字体


直接在布局 Xml 中使用 fontFamily 属性进行引用,如下图:


image-20210616205129045


四、在样式中添加并使用字体


1、在 style.xml 中添加样式


<style name="customfontstyle" parent="Theme.ChangeDefaultFontDemo">
<item name="android:fontFamily">@font/noto_sans_bolditem>

style>

2、在布局 Xml 中使用,如下图:


image-20210616205611588


五、在代码中使用字体


在代码中,我们可以通过 ResourcesCompat 或 Resource 的 gontFont 方法拿到 Typeface 对象,然后调用相关的 Api 去设置就行了,例如:


//方式1
val typeface = ResourcesCompat.getFont(context, R.font.myfont)
//方式2
val typeface = resources.getFont(R.font.myfont)
//设置字体
textView.typeface = typeface

为了方便在代码中使用,我们可以进行合理的封装:


object FontUtil {

const val NOTO_SANS_BOLD = R.font.noto_sans_bold
const val MA_SHAN_ZHENG_REGULAR = R.font.ma_shan_zheng_regular

/**缓存字体 Map*/
private val cacheTypeFaceMap: HashMap<Int,Typeface> = HashMap()

/**
* 设置 NotoSanUIBold 字体
*/

fun setNotoSanUIBold(mTextView: TextView){
try {
mTextView.typeface = getTypeface(NOTO_SANS_BOLD)
} catch (e: Exception) {
e.printStackTrace()
}
}

/**
* 设置 MaShanZhengRegular 字体
*/

fun setMaShanZhengRegular(mTextView: TextView){
try {
mTextView.typeface = getTypeface(MA_SHAN_ZHENG_REGULAR)
} catch (e: Exception) {
e.printStackTrace()
}
}

/**
* 获取字体 Typeface 对象
*/

fun getTypeface(fontResName: Int): Typeface? {
val cacheTypeface = cacheTypeFaceMap[fontResName]
if (cacheTypeface != null) {
return cacheTypeface
}
return try {
val typeface: Typeface? = ResourcesCompat.getFont(MyApplication.mApplication, fontResName)
cacheTypeFaceMap[fontResName] = typeface!!
typeface
} catch (e: Exception) {
e.printStackTrace()
Typeface.DEFAULT
}
}
}

那么后续我们在代码中使用字体,就只需调一行代码就 Ok 了


FontUtil.setMaShanZhengRegular(mTextView1)
FontUtil.setNotoSanUIBold(mTextView2)

六、项目需求实践


回顾一下我接到的项目需求:全局替换当前项目中的默认字体,并引入 UI 设计师提供的一些新字体


在学习本篇文章之前,我们引入字体都是放在 assets 文件目录下,这个目录下的字体文件,我们只能在代码中获取并使用。那么通过本篇文章的讲解,我们不仅可以在代码中进行使用,还可以在 Xml 中进行使用。现在我们解决了一半的需求,关于全局替换默认字体还需等到下一篇文章?


七、总结


回顾下本篇文章我们讲的一些重点内容:


1、将字体放在 res 的 font 目录下,这样我们就可以在 Xml 中使用字体了


2、通过字体 R 资源索引获取字体文件,封装相应的字体工具类,在代码中优雅的使用


好了,本篇文章到这里就结束了,希望能给你带来帮助 ?


Github Demo 地址


感谢你阅读这篇文章


下篇预告


下篇文章我会讲 Android 全局替换字体的几种方式,敬请期待吧 ?


参考和推荐


XML 中的字体



作者:sweetying
链接:https://juejin.cn/post/6974388756275019812
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

Android字体系列(二):Typeface完全解析

前言 很高兴遇见你~ 在本系列的上一篇文章中,我们介绍了关于 Android 字体的一些基础知识,还没有看过上一篇文章的朋友,建议先去阅读 Android字体系列 (一):Android字体基础,你会发现,我们设置的那三个属性最终都会去构建一个 ...
继续阅读 »

前言


很高兴遇见你~


在本系列的上一篇文章中,我们介绍了关于 Android 字体的一些基础知识,还没有看过上一篇文章的朋友,建议先去阅读 

Android字体系列 (一):Android字体基础

,你会发现,我们设置的那三个属性最终都会去构建一个 Typeface 对象,今天我们就好好的来讲讲它


注意:本文所展示的系统源码都是基于Android-30 ,并提取核心部分进行分析


一、Typeface 介绍


Typeface 负责 Android 字体的加载以及对上层提供相关字体 API 的调用


如果你想要操作字体,无论是使用 Android 系统自带的字体,还是加载自己内置的 .ttf(TureType) 或者 .otf(OpenType) 格式的字体文件,你都需要使用到 Typeface 这个类。因此我们要全局修改字体,首先就要把 Typeface 给弄明白


二、Typeface 源码分析


源码分析环节可能比较枯燥,坚持就是胜利 ??


1、Typeface 初始化


Typeface 这个类会在 Android 应用程序启动的过程中,通过反射的方式被加载。点击源码可以看到它里面有一个 static 代码块,它会随着类的加载而加载,并且只会加载一次,Typeface 就是通过这种方式来进行初始化的,如下:


static {
//创建一个存放字体的 Map
final HashMap systemFontMap = new HashMap<>();
//将系统的一些默认字体放入 Map 中
initSystemDefaultTypefaces(systemFontMap,SystemFonts.getRawSystemFallbackMap(),SystemFonts.getAliases());
//unmodifiableMap 方法的作用就是将当前 Map 进行包装,返回一个不可修改的Map,如果调用修改方法就会抛异常
sSystemFontMap = Collections.unmodifiableMap(systemFontMap);

// We can't assume DEFAULT_FAMILY available on Roboletric.
/**
* 设置系统默认字体 DEFAULT_FAMILY = "sans-serif";
* 因此系统默认的字体就是 sans-serif
*/

if (sSystemFontMap.containsKey(DEFAULT_FAMILY)) {
setDefault(sSystemFontMap.get(DEFAULT_FAMILY));
}

// Set up defaults and typefaces exposed in public API
//一些系统默认的字体
DEFAULT = create((String) null, 0);
DEFAULT_BOLD = create((String) null, Typeface.BOLD);
SANS_SERIF = create("sans-serif", 0);
SERIF = create("serif", 0);
MONOSPACE = create("monospace", 0);
//初始化一个 sDefaults 数组,并预加载好粗体、斜体等一些常用的 Style
sDefaults = new Typeface[] {
DEFAULT,
DEFAULT_BOLD,
create((String) null, Typeface.ITALIC),
create((String) null, Typeface.BOLD_ITALIC),
};

//...
}

上述代码写了详细的注释,我们可以发现,Typeface 初始化主要做了:


1、将系统的一些默认字体放入一个 Map 中


2、设置默认的字体


3、初始化一些默认字体


4、初始化一个 sDefaults 数组,存放一些常用的 Style


完成了 Typeface 的初始化,接下来看 Typeface 提供了一系列创建字体的 API ,其中对上层开放调用的有如下几个:


image-20210614130149262.png


下面我们来重点分析这几个方法


2、通过 Typeface 和 Style 获取新的 Typeface


对应上面截图的第一个 API , 看下它的源码:


public static Typeface create(Typeface family, @Style int style) {
//判断当前是否设置了 style , 如果没有设置,置为 NORMAL
if ((style & ~STYLE_MASK) != 0) {
style = NORMAL;
}
//判断当前传入的 Typeface 是否为空,如果是,置为默认字体
if (family == null) {
family = sDefaultTypeface;
}

// Return early if we're asked for the same face/style
//如果当前 Typeface 的 mStyle 属性和传入的 style 相同,直接返回 Typeface 对象
if (family.mStyle == style) {
return family;
}

final long ni = family.native_instance;

Typeface typeface;
//使用 sStyledCacheLock 保证线程安全
synchronized (sStyledCacheLock) {
//从缓存中获取存放 Typeface 的 SparseArray
SparseArray styles = sStyledTypefaceCache.get(ni);
if (styles == null) {
//存放 Typeface 的 SparseArray 为空,新创建一个,容量为 4
styles = new SparseArray(4);
//将当前 存放 Typeface 的 SparseArray 放入缓存中
sStyledTypefaceCache.put(ni, styles);
} else {
//存放 Typeface 的 SparseArray 不为空,直接获取 Typeface 并返回
typeface = styles.get(style);
if (typeface != null) {
return typeface;
}
}

//通过 native 层构建创建 Typeface 的参数并创建 Typeface 对象
typeface = new Typeface(nativeCreateFromTypeface(ni, style));
//将新创建的 Typeface 对象放入 SparseArray 中缓存起来
styles.put(style, typeface);
}
return typeface;
}

从上述代码我们可以知道:


1、当你设置的 Typeface 和 Style 为 null 和 0 时,会给它们设置一个默认值


注意:这里的 Style ,对应上一篇中讲的 android:textStyle 属性传递的值,用于设定字体的粗体、斜体等参数


2、如果当前设置的 Typeface 的 mStyle 属性和传入的 Style 相同,直接将 Typeface 给返回


3、从缓存中获取存放 Typeface 的容器,如果缓存中存在,则从容器中取出该 Typeface 并返回


4、如果不存在,则创建新的容器并加入缓存,然后通过 native 层创建 Typeface,并把当前 Typeface 放入到容器中


因此我们在使用的时候无需担心效率问题,它会把我们传入的字体进行一个缓存,后续都是从缓存中去拿的


3、通过字体名称和 Style 获取字体


对应上面截图的第二个 API:


public static Typeface create(String familyName, @Style int style) {
//调用截图的第一个 API
return create(getSystemDefaultTypeface(familyName), style);
}

//获取系统提供的一些默认字体,如果获取不到则返回系统的默认字体
private static Typeface getSystemDefaultTypeface(@NonNull String familyName) {
Typeface tf = sSystemFontMap.get(familyName);
return tf == null ? Typeface.DEFAULT : tf;
}

1、这个创建 Typeface 的 API 很简单,就是调用它的一个重载方法,我们已经分析过


2、getSystemDefaultTypeface 主要是通过 sSystemFontMap 获取字体,而这个 sSystemFontMap 在 Typeface 初始化的时候会存放系统提供的一些默认字体,因此这里直接取就可以了


4、通过 Typeface 、weight(粗体) 和 italic(斜体) 获取新的 Typeface


对应上面截图的第三个 API


public static @NonNull Typeface create(@Nullable Typeface family,
@IntRange(from = 1, to = 1000) int weight, boolean italic)
{
//校验传入的 weight 属性是否在范围内
Preconditions.checkArgumentInRange(weight, 0, 1000, "weight");
if (family == null) {
//如果当前传入的 Typeface 为 null, 则置为默认值
family = sDefaultTypeface;
}
//调用 createWeightStyle 方法创建 Typeface
return createWeightStyle(family, weight, italic);
}

private static @NonNull Typeface createWeightStyle(@NonNull Typeface base,
@IntRange(from = 1, to = 1000) int weight, boolean italic)
{
final int key = (weight << 1) | (italic ? 1 : 0);

Typeface typeface;
//使用 sWeightCacheLock 保证线程安全
synchronized(sWeightCacheLock) {
SparseArray innerCache = sWeightTypefaceCache.get(base.native_instance);
if (innerCache == null) {
//缓存 Typeface 的 SparseArray 为 null, 新建并缓存
innerCache = new SparseArray<>(4);
sWeightTypefaceCache.put(base.native_instance, innerCache);
} else {
//从缓存中拿取 typeface 并返回
typeface = innerCache.get(key);
if (typeface != null) {
return typeface;
}
}
//通过 native 创建 Typeface 对象
typeface = new Typeface(
nativeCreateFromTypefaceWithExactStyle(base.native_instance, weight, italic));
//将 Typeface 加入缓存
innerCache.put(key, typeface);
}
return typeface;
}

通过上述代码可以知道,他与截图一 API 的源码很类似,无非就是将之前需要设置的 Style 换成了 weight 和 italic,里面的实现机制是类似的


5、通过 AssetManager 和对应字体路径获取字体


对应上面截图的第四个 API


public static Typeface createFromAsset(AssetManager mgr, String path) {
//参数检查
Preconditions.checkNotNull(path); // for backward compatibility
Preconditions.checkNotNull(mgr);

//通过 Typeface 的 Builder 模式构建 typeface
Typeface typeface = new Builder(mgr, path).build();
//如果构建的 typeface 不为空则返回
if (typeface != null) return typeface;
// check if the file exists, and throw an exception for backward compatibility
//看当前字体路径是否存在,不存在直接抛异常
try (InputStream inputStream = mgr.open(path)) {
} catch (IOException e) {
throw new RuntimeException("Font asset not found " + path);
}
//如果构建的字体为 null 则返回默认字体
return Typeface.DEFAULT;
}

//接着看 Typeface 的 Builder 模式构建 typeface
//Builder 构造方法 主要就是初始化 mFontBuilder 和一些参数
public Builder(@NonNull AssetManager assetManager, @NonNull String path, boolean isAsset,
int cookie)
{
mFontBuilder = new Font.Builder(assetManager, path, isAsset, cookie);
mAssetManager = assetManager;
mPath = path;
}

//build 方法
public Typeface build() {
//如果 mFontBuilder 为 null,则会调用 resolveFallbackTypeface 方法
//resolveFallbackTypeface 内部会调用 createWeightStyle 创建 Typeface 并返回
if (mFontBuilder == null) {
return resolveFallbackTypeface();
}
try {
//通过 mFontBuilder 构建 Font
final Font font = mFontBuilder.build();
//使用 createAssetUid 方法获取到这个字体的唯一 key
final String key = mAssetManager == null ? null : createAssetUid(
mAssetManager, mPath, font.getTtcIndex(), font.getAxes(),
mWeight, mItalic,
mFallbackFamilyName == null ? DEFAULT_FAMILY : mFallbackFamilyName);
if (key != null) {
// Dynamic cache lookup is only for assets.
//使用 sDynamicCacheLock 保证线程安全
synchronized (sDynamicCacheLock) {
//通过 key 从缓存中拿字体
final Typeface typeface = sDynamicTypefaceCache.get(key);
//如果当前字体不为 null 直接返回
if (typeface != null) {
return typeface;
}
}
}
//如果当前字体不存在,通过 Builder 模式构建 FontFamily 对象
//通过 FontFamily 构建 CustomFallbackBuilder 对象
//最终通过 CustomFallbackBuilder 构建 Typeface 对象
final FontFamily family = new FontFamily.Builder(font).build();
final int weight = mWeight == RESOLVE_BY_FONT_TABLE
? font.getStyle().getWeight() : mWeight;
final int slant = mItalic == RESOLVE_BY_FONT_TABLE
? font.getStyle().getSlant() : mItalic;
final CustomFallbackBuilder builder = new CustomFallbackBuilder(family)
.setStyle(new FontStyle(weight, slant));
if (mFallbackFamilyName != null) {
builder.setSystemFallback(mFallbackFamilyName);
}
//builder.build 方法内部最终会通过调用 native 层创建 Typeface 对象
final Typeface typeface = builder.build();
//缓存 Typeface 对象并返回
if (key != null) {
synchronized (sDynamicCacheLock) {
sDynamicTypefaceCache.put(key, typeface);
}
}
return typeface;
} catch (IOException | IllegalArgumentException e) {
//如果流程有任何异常,则内部会调用 createWeightStyle 创建 Typeface 并返回
return resolveFallbackTypeface();
}
}

上述代码步骤:


1、大量运用了 Builder 模式去构建相关对象


2、具体逻辑就是使用 createAssetUid 方法获取到当前字体的唯一 key ,通过这个唯一 key ,从缓存中获取已经被加载过的字体,如果没有,则创建一个 FontFamily 对象,经过一系列 Builder 模式,最终调用 native 层创建 Typeface 对象,并将这个 Typeface 对象加入缓存并返回


3、如果流程有任何异常,内部会调用 createWeightStyle 创建 Typeface 并返回


6、通过字体文件获取字体


对应上面截图的第五个 API


public static Typeface createFromFile(@Nullable File file) {
// For the compatibility reasons, leaving possible NPE here.
// See android.graphics.cts.TypefaceTest#testCreateFromFileByFileReferenceNull
//通过 Typeface 的 Builder 模式构建 typeface
Typeface typeface = new Builder(file).build();
if (typeface != null) return typeface;

// check if the file exists, and throw an exception for backward compatibility
//文件不存在,抛异常
if (!file.exists()) {
throw new RuntimeException("Font asset not found " + file.getAbsolutePath());
}
//如果构建的字体为 null 则返回默认字体
return Typeface.DEFAULT;
}

//Builder 另外一个构造方法 主要是初始化 mFontBuilder
public Builder(@NonNull File path) {
mFontBuilder = new Font.Builder(path);
mAssetManager = null;
mPath = null;
}

从上述代码可以知道,这种方式主要也是通过 Builder 模式去构建 Typeface 对象,具体逻辑我们刚才已经分析过


7、通过字体路径获取字体


对应上面截图的第六个 API


public static Typeface createFromFile(@Nullable String path) {
Preconditions.checkNotNull(path); // for backward compatibility
return createFromFile(new File(path));
}

这个就更简单了,主要就是创建文件对象然后调用另外一个重载方法


8、Typeface 相关 Native 方法


在 Typeface 中,所有最终操作到加载字体的部分,全部都是 native 的方法。而 native 方法就是以效率著称的,这里只需要保证不频繁的调用(Typeface 已经做好了缓存,不会频繁的调用),基本上也不会存在效率的问题。


private static native long nativeCreateFromTypeface(long native_instance, int style);
private static native long nativeCreateFromTypefaceWithExactStyle(
long native_instance, int weight, boolean italic)
;
// TODO: clean up: change List to FontVariationAxis[]
private static native long nativeCreateFromTypefaceWithVariation(
long native_instance, List axes)
;
@UnsupportedAppUsage
private static native long nativeCreateWeightAlias(long native_instance, int weight);
@UnsupportedAppUsage
private static native long nativeCreateFromArray(long[] familyArray, int weight, int italic);
private static native int[] nativeGetSupportedAxes(long native_instance);

@CriticalNative
private static native void nativeSetDefault(long nativePtr);

@CriticalNative
private static native int nativeGetStyle(long nativePtr);

@CriticalNative
private static native int nativeGetWeight(long nativePtr);

@CriticalNative
private static native long nativeGetReleaseFunc();

private static native void nativeRegisterGenericFamily(String str, long nativePtr);

到这里,关于 Typeface 源码部分我们就介绍完了,下面看下它的一些其他细节


三、Typeface 其它细节


1、默认使用


在初始化那部分,Typeface 对字体和 Style 有一些默认实现


如果我们只想用系统默认的字体,直接拿上面的常量用就 ok 了,如:


Typeface.DEFAULT
Typeface.DEFAULT_BOLD
Typeface.SANS_SERIF
Typeface.SERIF
Typeface.MONOSPACE

而如果想要设置 Style ,我们不能通过 sDefaults 直接去拿,因为上层调用不到 sDefaults,但是可以通过 Typeface 提供的 API 获取:


public static Typeface defaultFromStyle(@Style int style) {
return sDefaults[style];
}

//具体调用
Typeface.defaultFromStyle(Typeface.NORMAL)
Typeface.defaultFromStyle(Typeface.BOLD)
Typeface.defaultFromStyle(Typeface.ITALIC)
Typeface.defaultFromStyle(Typeface.BOLD_ITALIC)

2、Typeface 中的 Style


1)、Typeface 中的 Style 可以通过 android:textStyle 属性去设置粗体、斜体等样式


2)、在 Typeface 中,这些样式也对应了一个个的常量,并且 Typeface 也提供了对应的 Api,让我们获取到当前字体的样式


// Style
public static final int NORMAL = 0;
public static final int BOLD = 1;
public static final int ITALIC = 2;
public static final int BOLD_ITALIC = 3;

/** Returns the typeface's intrinsic style attributes */
public @Style int getStyle() {
return mStyle;
}

/** Returns true if getStyle() has the BOLD bit set. */
public final boolean isBold() {
return (mStyle & BOLD) != 0;
}

/** Returns true if getStyle() has the ITALIC bit set. */
public final boolean isItalic() {
return (mStyle & ITALIC) != 0;
}

3、FontFamily 介绍


FontFamily 主要就是用来构建 Typeface 的一个类,注意和在 Xml 属性中设置的 android:fontFamily 区分开来就好了


四、总结


总结下本篇文章所讲的一些重点内容:


1、Typeface 初始化对字体和 Style 会有一些默认实现


2、Typeface create 系列方法支持从系统默认字体、 assets 目录、字体文件以及字体路径去获取字体


3、Typeface 本身支持缓存,我们在使用的时候无需注意效率问题


好了,本篇文章到这里就结束了,希望能给你带来帮助 ?


感谢你阅读这篇文章


下篇预告


下篇文章我会讲在 Xml 中使用字体,敬请期待吧 ?


参考和推荐


Android 修改字体,跳不过的 Typeface


作者:sweetying
链接:https://juejin.cn/post/6973553157326503943
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

Android字体系列 (一):Android字体基础

前言 很高兴遇见你~ 最近接到一个需求,大致内容是:全局替换当前项目中的默认字体,并引入 UI 设计师提供的一些新字体。于是对字体做了些研究,把自己的一些心得分享给大家。 注意:本文所展示的系统源码都是基于Android-30 ,并提取核心部分进行分析 ...
继续阅读 »

前言


很高兴遇见你~


最近接到一个需求,大致内容是:全局替换当前项目中的默认字体,并引入 UI 设计师提供的一些新字体。于是对字体做了些研究,把自己的一些心得分享给大家。


注意:本文所展示的系统源码都是基于Android-30 ,并提取核心部分进行分析


一、Android 默认字体介绍


1、Android 系统默认使用的是一款叫做 Roboto 的字体,这也是 Google 推荐使用的一款字体 传送门。它提供了多种字体形式的选择,例如:粗体,斜体等等。


2、在 Android 中,我们一般会直接或间接的通过 TextView 控件去承载字体的显示,因为关于 Android 提供的承载字体显示的控件都会直接或间接继承 TextView,例如:EditText,Button 等等,下面给出一张 TextView 继承图:


image-20210612124458481


3、TextView 中有三个属性可以设置字体的显示:


1)、textStyle


2)、typeface


3)、fontFamily


下面我们重点介绍下这三个属性


二、textStyle


textStyle 主要用来设置字体的样式,我们看下它在 TextView 的自定义属性中的一个体现:


//TextView 的自定义属性 textStyle
<attr name="textStyle">
<flag name="normal" value="0" />
<flag name="bold" value="1" />
<flag name="italic" value="2" />
</attr>

从上述自定义属性中我们可以知道:


1、textStyle 主要有 3 种样式:



  • normal:默认字体

  • bold:粗体

  • italic:斜体


2、textStyle 是用 flag 来承载的,flag 表示的值可以做或运算,也就是说我们可以设置多种字体样式进行叠加


接下来我们在 xml 中设置一下,如下图:


image-20210612205549971


可以看到,我们给 TextView 的 textStyle 属性设置了粗体和斜体两种样式叠加,右边可以看到预览效果


同样我们也可以在代码中对其进行设置,但是在代码中设置字体样式只能设置一种,不能叠加:


mTextView.setTypeface(null, Typeface.BOLD)

三、typeface


typeface 主要用于设置 TextView 的字体,我们看下它在 TextView 的自定义属性中的一个体现:


//TextView 的自定义属性 typeface
<attr name="typeface">
<enum name="normal" value="0" />
<enum name="sans" value="1" />
<enum name="serif" value="2" />
<enum name="monospace" value="3" />
</attr>

从上述自定义属性中我们可以知道:


1、typeface 提供了 4 种字体:



  • noraml:普通字体,系统默认使用的字体

  • sans:非衬线字体

  • serif:衬线字体

  • monospace:等宽字体


2、typeface 是用 enum 来承载的,enum 表示枚举类型,每次只能选择一个,因此我们每次只能设置一种字体,不能叠加


接下来我们在 xml 中设置一下,如下图:


image-20210612133722082


简单介绍这几种字体的区别:


serif (衬线字体):在字的笔划开始及结束的地方有额外的装饰,而且笔划的粗细会因直横的不同而有不同相


sans (非衬线字体):没有 serif 字体这些额外的装饰,和 noraml 字体是一样的


image-20210612134441993


monospace (等宽字体):限制每个字符的宽度,让它们达到一个等宽的效果


同样我们也可以在代码中进行设置:


mTv.setTypeface(Typeface.SERIF)

四、fontFamily


fontFamily 相当于是加强版的 typeface,它表示 android 系统支持的一系列字体,每个字体都有一个别名,我们通过别名就能设置这种字体,看下它在 TextView 的自定义属性中的一个体现:


//TextView 的自定义属性 fontFamily
<attr name="fontFamily" format="string" />

从上述自定义属性中我们可以知道:


fontFamily 接收的是一个 String 类型的值,也就是我们可以通过字体别名设置这种字体,如下图:


fontFamily


可以看到,它细致的区分了每个系列字体的样式,同样我们在 xml 中对它进行一个设置:


image-20210612212209243 我们在代码中在对他进行一个设置:


mTv.setTypeface(Typeface.create("sans-serif-medium",Typeface.NORMAL))

值的注意的是:fontFamily 设置的某些字体有兼容性问题,如我上面设置的 sans-serif-medium 字体,它在 Android 系统版本大于等于 21 才会生效,如果小于 21 ,则会使用默认字体,因此我们在使用 fontFamily 属性时,需要注意这个问题


到这里,我们就把影响 Android 字体的 3 个属性给讲完了,但是我心里有个疑问?? ?假设我这三个属性同时设置,会一起生效吗?


带着这个问题,我们探索一下源码


五、textStyle,typeface,fontFamily 三者关系分析


TextView 在我们使用它之前需进行一个初始化,最终会调用它参数最多的那个构造方法:


public TextView(Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
//省略成吨代码.....
//读取设置的属性
readTextAppearance(context, appearance, attributes, false /* styleArray */);
//设置字体
applyTextAppearance(attributes);
}

private void applyTextAppearance(TextAppearanceAttributes attributes) {
//省略成吨代码.....
setTypefaceFromAttrs(attributes.mFontTypeface, attributes.mFontFamily,
attributes.mTypefaceIndex, attributes.mTextStyle, attributes.mFontWeight);
}

上面这条调用链,首先会读取 TextView 设置的相关属性,我们看下与字体相关的几个:


private void readTextAppearance(Context context, TypedArray appearance,
TextAppearanceAttributes attributes, boolean styleArray)
{
//...
switch (index) {
case com.android.internal.R.styleable.TextAppearance_typeface:
attributes.mTypefaceIndex = appearance.getInt(attr, attributes.mTypefaceIndex);
if (attributes.mTypefaceIndex != -1 && !attributes.mFontFamilyExplicit) {
attributes.mFontFamily = null;
}
break;
case com.android.internal.R.styleable.TextAppearance_fontFamily:
if (!context.isRestricted() && context.canLoadUnsafeResources()) {
try {
attributes.mFontTypeface = appearance.getFont(attr);
} catch (UnsupportedOperationException | Resources.NotFoundException e) {
// Expected if it is not a font resource.
}
}
if (attributes.mFontTypeface == null) {
attributes.mFontFamily = appearance.getString(attr);
}
attributes.mFontFamilyExplicit = true;
break;
case com.android.internal.R.styleable.TextAppearance_textStyle:
attributes.mTextStyle = appearance.getInt(attr, attributes.mTextStyle);
break;
//...
default:
}
}

从上述代码中我们可以看到:


1、当我们设置 typeface 属性时,会将对应的属性值赋给 mTypefaceIndex ,并把 mFontFamily 置为 null


2、当我们设置 fontFamily 属性时,首先会通过 appearance.getFont() 方法去获取字体文件,如果能获取到,则赋值给 mFontTypeface,如果获取不到,则通过 appearance.getString() 方法取获取当前字体别名并赋值给 mFontFamily


注意:当我们给 fontFamily 设置了一些第三方字体,那么此时 appearance.getFont() 方法就获取不到字体


3、当我们设置 textStyle 属性时,会将获取的属性值赋给 mTextStyle


上述方法走完了,会调 setTypefaceFromAttrs() 方法,这个方法就是最终 TextView 设置字体的方法,我们来解析下这个方法:


private void setTypefaceFromAttrs(@Nullable Typeface typeface, @Nullable String familyName,
@XMLTypefaceAttr int typefaceIndex, @Typeface.Style int style,
@IntRange(from = -1, to = FontStyle.FONT_WEIGHT_MAX) int weight)
{
if (typeface == null && familyName != null) {
// Lookup normal Typeface from system font map.
final Typeface normalTypeface = Typeface.create(familyName, Typeface.NORMAL);
resolveStyleAndSetTypeface(normalTypeface, style, weight);
} else if (typeface != null) {
resolveStyleAndSetTypeface(typeface, style, weight);
} else { // both typeface and familyName is null.
switch (typefaceIndex) {
case SANS:
resolveStyleAndSetTypeface(Typeface.SANS_SERIF, style, weight);
break;
case SERIF:
resolveStyleAndSetTypeface(Typeface.SERIF, style, weight);
break;
case MONOSPACE:
resolveStyleAndSetTypeface(Typeface.MONOSPACE, style, weight);
break;
case DEFAULT_TYPEFACE:
default:
resolveStyleAndSetTypeface(null, style, weight);
break;
}
}
}

上述代码步骤:


1、当 typeface 为空并且 familyName 不为空时,取 familyName 的字体


2、当 typeface 不为空并且 familyName 为空时,取 typeface 的字体


3、当 typeface 和 familyName 都为空,则根据 typefaceIndex 的值取相应的字体


4、typeface ,familyName 和 typefaceIndex 在我们分析的 readTextAppearance 方法会被赋值


5、resolveStyleAndSetTypefce 方法会进行字体和字体样式的设置


6、style 是在 readTextAppearance 方法中赋值的,他和设置字体并不冲突


好,现在代码分析的差不多了,我们再来看下上面那个疑问?我们使用假设法来进行推导:


假设在 Xml 中, typeface,familyName 和 textStyle 我都设置了,那么根据上面分析:


1、textStyle 肯定会生效


2、当设置了 typeface 属性,typefaceIndex 会被赋值,同时 familyName 会置为空


3、当设置了 familyName 属性,分情况:1、如果设置的是系统字体,typeface 会被赋值,familyName 还是为空。2、如果设置的是第三方字体,typeface 为空,familyName 被赋值


因此,当我们设置了这个三个属性,typeface 和 familyName 总有一个不会为空,因此不会走第三个条件体,那么 typeface 设置的属性就不会生效了,而剩下的两个属性都能够生效


最后对这三个属性做一个总结:


1、fontFamily、typeface 属性用于字体设置,如果都设置了,优先使用 fontFamily 属性,typeface 属性不会生效


2、textStyle 用于字体样式设置,与字体设置不会产生冲突


上面这段源码分析可能有点绕,如果有不清楚的地方,欢迎评论区给我留言提问


六、TextView 设置字体属性源码分析


通过上面源码的分析,我们清楚了 fontFamily,typeface 和 textStyle 这三者的关系。接下来我们研究一下,我们设置的这些属性是怎么实现这些效果的呢?又到了源码分析环节?,可能会有点枯燥,但是如果你能够认真看完,一定会收获很多,干就完了


我们上面用 Xml 或代码设置的字体属性,最终都会走到 TextView 的 setTypeface 重载方法:


//重载方法一
public void setTypeface(@Nullable Typeface tf) {
if (mTextPaint.getTypeface() != tf) {
//通过 mTextPaint 设置字体
mTextPaint.setTypeface(tf);

//刷新重绘
if (mLayout != null) {
nullLayouts();
requestLayout();
invalidate();
}
}
}

//重载方法二
public void setTypeface(@Nullable Typeface tf, @Typeface.Style int style) {
if (style > 0) {
if (tf == null) {
tf = Typeface.defaultFromStyle(style);
} else {
tf = Typeface.create(tf, style);
}
//调用重载方法一,设置字体
setTypeface(tf);
//经过一些算法
int typefaceStyle = tf != null ? tf.getStyle() : 0;
int need = style & ~typefaceStyle;
//打开画笔的粗体和斜体
mTextPaint.setFakeBoldText((need & Typeface.BOLD) != 0);
mTextPaint.setTextSkewX((need & Typeface.ITALIC) != 0 ? -0.25f : 0);
} else {
mTextPaint.setFakeBoldText(false);
mTextPaint.setTextSkewX(0);
setTypeface(tf);
}
}

分析下上述代码:


重载方法一:


TextView 设置字体实际上就是操作 mTextPaint,mTextPaint 是 TextPaint 的类对象,继承自 Paint 即画笔,因此我们设置的字体实际上会通过调用画笔的方法来进行绘制


重载方法二:


相对于重载方法一,法二多传递了一个 textStyle 参数,主要用来标记粗体和斜体的:


1)、如果设置了 textStyle ,进入第一个条件体,分情况:1、如果传进来的 tf 为 null ,则会根据传入的 style 去获取 Typeface 字体,2、如果不为 null ,则会根据传入的 tf 和 style 去获取 Typeface 字体。设置好字体后,接下来还会打开画笔的粗体和斜体设置


2)、如果没有设置 textStyle,则只会设置字体,并把画笔的粗斜体设置置为 false 和 0


从上述分析我们可以得知:TextView 设置字体和字体样式最终都是通过画笔来完成的


七、总结


本篇文章主要讲了:


1、Android 字体大概的一个介绍


2、关于影响 Android 字体显示的三个属性


3、textStyle,typeface,fontFamily 三者的一个关系


4、设置的这三个属性是怎么实现这些效果的?




好了,本篇文章到这里就结束了,如果有任何问题,欢迎给我留言,我们评论区一起讨论?


感谢你阅读这篇文章


作者:sweetying
链接:https://juejin.cn/post/6973064546420260878
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

【Kotlin篇】差异化分析,let,run,with,apply及also

作用域函数是Kotlin比较重要的一个特性,共分为以下5种:let、run、with、apply 以及 also,这五个函数的工作方式可以说非常相似,但是我们需要了解的是这5种函数的差异,以便在不同的场景更好的利用它。 读完这篇文章您将了解到: 什么是...
继续阅读 »

作用域函数是Kotlin比较重要的一个特性,共分为以下5种:letrunwithapply 以及 also,这五个函数的工作方式可以说非常相似,但是我们需要了解的是这5种函数的差异,以便在不同的场景更好的利用它。 读完这篇文章您将了解到:



  • 什么是Kotlin的作用域函数?

  • letrunwithapply 以及 also这5种作用域函数各自的角色定位;

  • 5种作用域函数的差异区分;

  • 何时何地使用这5种作用域?


Kotlin的作用域函数



Kotlin 标准库包含几个函数,它们的唯一目的是在对象的上下文中执行代码块。当对一个对象调用这样的函数并提供一个 lambda 表达式时,它会形成一个临时作用域。在此作用域中,可以访问该对象而无需其名称。这些函数称为作用域函数。



简单来说,作用域函数是为了方便对一个对象进行访问和操作,你可以对它进行空检查或者修改它的属性或者直接返回它的值等操作,下面提供了案例对作用域函数进行了详细说明。


角色定位


2.1 let


public inline fun <T, R> T.let(block: (T) -> R): R 

let函数是参数化类型 T 的扩展函数。在let块内可以通过 it 指代该对象。返回值为let块的最后一行或指定return表达式。


我们以一个Book对象为例,类中包含Book的name和price,如下:


class Book() {
var name = "《数据结构》"
var price = 60
fun displayInfo() = print("Book name : $name and price : $price")
}

fun main(args: Array<String>) {
val book = Book().let {
it.name = "《计算机网络》"
"This book is ${it.name}"
}
print(book)
}

控制台输出:
This book is 《计算机网络》

在上面案例中,我们对Book对象使用let作用域函数,在函数块的最后一句添加了一行字符串代码,并且对Book对象进行打印,我们可以看到最后控制台输出的结果为字符串“This book is 《计算机网络》”。


按照我们的编程思想,打印一个对象,输出必定是对象,但是使用let函数后,输出为最后一句字符串。这是由于let函数的特性导致。因为在Kotlin中,如果let块中的最后一条语句是非赋值语句,则默认情况下它是返回语句。


那如果我们将let块中最后一条语句修改为赋值语句,会发生什么变化?


fun main(args: Array<String>) {
val book = Book().let {
it.name = "《计算机网络》"
}
print(book)
}

控制台输出:
kotlin.Unit

可以看到我们将Book对象的name值进行了赋值操作,同样对Book对象进行打印,但是最后控制台的输出结果为“kotlin.Unit”,这是因为在let函数块的最后一句是赋值语句,print则将其当做是一个函数来看待。


这是let角色设定的第一点:1??



  • let块中的最后一条语句如果是非赋值语句,则默认情况下它是返回语句,反之,则返回的是一个 Unit类型


我们来看let第二点:2??



  • let可用于空安全检查。


如需对非空对象执行操作,可对其使用安全调用操作符 ?. 并调用 let 在 lambda 表达式中执行操作。如下案例:


var name: String? = null
fun main(args: Array<String>) {
val nameLength = name?.let {
it.length
} ?: "name为空时的值"
print(nameLength)
}

我们设置name为一个可空字符串,利用name?.let来进行空判断,只有当name不为空时,逻辑才能走进let函数块中。在这里,我们可能还看不出来let空判断的优势,但是当你有大量name的属性需要编写的时候,就能发现let的快速和简洁。


let第三点:3??



  • let可对调用链的结果进行操作。


关于这一点,官方教程给出了一个案例,在这里就直接使用:


fun main(args: Array<String>) { 
val numbers = mutableListOf("One","Two","Three","Four","Five")
val resultsList = numbers.map { it.length }.filter { it > 3 }
print(resultsList)
}

我们的目的是获取数组列表中长度大于3的值。因为我们必须打印结果,所以我们将结果存储在一个单独的变量中,然后打印它。但是使用“let”操作符,我们可以将代码修改为:


fun main(args: Array<String>) {
val numbers = mutableListOf("One","Two","Three","Four","Five")
numbers.map { it.length }.filter { it > 3 }.let {
print(it)
}
}

使用let后可以直接对数组列表中长度大于3的值进行打印,去掉了变量赋值这一步。


另外,let函数还存在一个特点。


let第四点:4??



  • let可以将“It”重命名为一个可读的lambda参数。


let是通过使用“It”关键字来引用对象的上下文,因此,这个“It”可以被重命名为一个可读的lambda参数,如下将it重命名为book


fun main(args: Array<String>) {
val book = Book().let {book ->
book.name = "《计算机网络》"
}
print(book)
}

2.2 run


run函数以“this”作为上下文对象,且它的调用方式与let一致。


另外,第一点:1?? 当 lambda 表达式同时包含对象初始化和返回值的计算时,run更适合


这句话是什么意思?我们还是用案例来说话:


fun main(args: Array<String>) {

Book().run {
name = "《计算机网络》"
price = 30
displayInfo()
}
}

控制台输出:
Book name : 《计算机网络》 and price : 30

如果不使用run函数,相同功能下代码会怎样?来看一看:


fun main(args: Array<String>) {

val book = Book()
book.name = "《计算机网络》"
book.price = 30
book.displayInfo()
}

控制台输出:
Book name : 《计算机网络》 and price : 30

输出结果还是一样,但是run函数所带来的代码简洁程度已经显而易见。


除此之外,让我们来看看run函数的其他优点:


通过查看源码,了解到run函数存在两种声明方式,


1、与let一样,run是作为T的扩展函数;


inline fun <T, R> T.run(block: T.() -> R): R 

2、第二个run的声明方式则不同,它不是扩展函数,并且块中也没有输入值,因此,它不是用于传递对象并更改属性的类型,而是可以使你在需要表达式的地方就可以执行一个语句。


inline fun <R> run(block: () -> R): R

如下利用run函数块执行方法,而不是作为一个扩展函数:


run {
val book = Book()
book.name = "《计算机网络》"
book.price = 30
book.displayInfo()
}

2.3 with


inline fun <T, R> with(receiver: T, block: T.() -> R): R 

with属于非扩展函数,直接输入一个对象receiver,当输入receiver后,便可以更改receiver的属性,同时,它也与run做着同样的事情。


还是提供一个案例说明:


fun main(args: Array<String>) {
val book = Book()

with(book) {
name = "《计算机网络》"
price = 40
}
print(book)
}

以上面为例,with(T)类型传入了一个参数book,则可以在with的代码块中访问book的name和price属性,并做更改。


with使用的是非null的对象,当函数块中不需要返回值时,可以使用with。


2.4 apply


inline fun <T> T.apply(block: T.() -> Unit): T

apply是 T 的扩展函数,与run函数有些相似,它将对象的上下文引用为“this”而不是“it”,并且提供空安全检查,不同的是,apply不接受函数块中的返回值,返回的是自己的T类型对象。


fun main(args: Array<String>) {
Book().apply {
name = "《计算机网络》"
price = 40

}
print(book)
}

控制台输出:
com.fuusy.kotlintest.Book@61bbe9ba

前面看到的 letwithrun 函数返回的值都是 R。但是,apply 和下面查看的 also 返回 T。例如,在 let 中,没有在函数块中返回的值,最终会成为 Unit 类型,但在 apply 中,最后返回对象本身 (T) 时,它成为 Book 类型。


apply函数主要用于初始化或更改对象,因为它用于在不使用对象的函数的情况下返回自身。


2.5 also


inline fun <T> T.also(block: (T) -> Unit): T 

also是 T 的扩展函数,返回值与apply一致,直接返回T。also函数的用法类似于let函数,将对象的上下文引用为“it”而不是“this”以及提供空安全检查方面


因为T作为block函数的输入,可以使用also来访问属性。所以,在不使用或不改变对象属性的情况下也使用also。


fun main(args: Array<String>) {
val book = Book().also {
it.name = "《计算机网络》"
it.price = 40
}
print(book)
}

控制台输出:
com.fuusy.kotlintest.Book@61bbe9ba

差异化


3.1 let & run



  • let将上下文对象引用为it ,而run引用为this;

  • run无法将“this”重命名为一个可读的lambda参数,而let可以将“it”重命名为一个可读的lambda参数。 在let多重嵌套时,就可以看到这个特点的优势所在。


3.2 with & run


with和run其实做的是同一种事情,对上下文对象都称之为“this”,但是他们又存在着不同,我们来看看案例。


先使用with函数:



fun main(args: Array<String>) {
val book: Book? = null
with(book){
this?.name = "《计算机网络》"
this?.price = 40
}
print(book)

}

我们创建了一个可空对象book,利用with函数对book对象的属性进行了修改。代码很直观,那么我们接着将with替换为run,代码更改为:


fun main(args: Array<String>) {
val book: Book? = null
book?.run{
name = "《计算机网络》"
price = 40
}
print(book)
}

首先run函数的调用省略了this引用,在外层就进行了空安全检查,只有非空时才能进入函数块内对book进行操作。



  • 相比较with来说,run函数更加简便,空安全检查也没有with那么频繁。


3.3 apply & let



  • apply不接受函数块中的返回值,返回的是自己的T类型对象,而let能返回。

  • apply上下文对象引用为“this”,let为“it”。


何时应该使用 apply、with、let、also 和 run ?



  • 用于初始化对象或更改对象属性,可使用apply

  • 如果将数据指派给接收对象的属性之前验证对象,可使用also

  • 如果将对象进行空检查并访问或修改其属性,可使用let

  • 如果是非null的对象并且当函数块中不需要返回值时,可使用with

  • 如果想要计算某个值,或者限制多个本地变量的范围,则使用run


总结


以上便是Kotlin作用域函数的作用以及使用场景,在Android实际开发中,5种函数使用的频次非常高,在使用过程中发现,当代码逻辑少的时候,作用域函数能带给我们代码的简洁性可读性,但是当逻辑复杂时,使用不同的函数,多次叠加都将降低可读性。这就要我们去区分它们各自的特点,以便在适合且复杂的场景下去使用它。


希望这篇文章能帮到您,感谢阅读。




作者:付十一
链接:https://juejin.cn/post/6975384870675546126
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

收起阅读 »

iOS开发中的小玩意儿-加速计和陀螺仪

前言最近因为工作需要对加速计和陀螺仪进行学习和了解,过程中有所收获。正文一、加速计iPhone在静止时会受到地球引力,以屏幕中心为坐标原点,建立一个三维坐标系(如右图),此时iPhone收到的地球引力会分布到三个轴上。iOS开发者可以通过CoreMotion框...
继续阅读 »

前言

最近因为工作需要对加速计和陀螺仪进行学习和了解,过程中有所收获。

正文

一、加速计

iPhone在静止时会受到地球引力,以屏幕中心为坐标原点,建立一个三维坐标系(如右图),此时iPhone收到的地球引力会分布到三个轴上。
iOS开发者可以通过CoreMotion框架获取分布到三个轴的值。如果iPhone是如图放置,则分布情况为x=0,y=-1.0,z=0。
在CoreMotion中地球引力(重力)的表示为1.0。

手机如果屏幕朝上的放在水平桌面上,此时的(x,y,z)分布是什么?


上面答案是(0,0, -1.0);

如何检测手机的运动?
CoreMotion框架中有CMDeviceMotion类,其中的gravity属性用来描述前面介绍的重力;另外的userAcceleration是用来描述手机的运动。
当手机不动时,userAcceleration的(x, y, z)为(0, 0, 0);
当手机运动,比如在屏幕水平朝上的自由落体时,检测到的(x, y, z)将为(0, 0, 1);
当手机屏幕水平朝上,往屏幕左边以9.8m/s2的加速度运动时,检测到的(x, y, z)将为(1, 0, 0);

1、gravity是固定不变,因为地球引力的不变;但是xyz的分布会变化,收到手机朝向的影响;
2、userAcceleration是手机的运动相关属性,但是检测到的值为运动加速度相反的方向;
3、一种理解加速计的方式:在水平的路上有一辆车,车上有一个人;当车加速向右运动时,人会向左倾斜;此时可以人不需要知道外面的环境如何,根据事先在车里建立好的方向坐标系,可以知道车在向右加速运动。

二、加速计的简单应用

图片悬浮
手机旋转,但是图片始终保持水平。


实现流程
1、加载图片,创建CMMotionManager;
2、监听地球重力的变化,根据x和y轴的重力变化计算出来手机与水平面的夹角;
3、将图片逆着旋转相同的角度;
x、y轴和UIKit坐标系相反,原点在屏幕中心,向上为y轴正方向,向右为x轴正方向,屏幕朝外是z轴正方向;
在处理图片旋转角度时需要注意。

三、陀螺仪

如图,建立三维坐标系;
陀螺仪描述的是iPhone关于x、y、z轴的旋转速率;
静止时(x, y, z)为(0, 0, 0);
当右图手机绕Y轴正方向旋转,速率为每秒180°,则(x, y, z)为(0, 0, 3.14);


陀螺仪和加速计是同样的坐标系,但是新增了旋转的概念,可以用右手法则来辅助记忆;
陀螺仪回调结构体的单位是以弧度为单位,这个不是加速度而是速率;

四、CoreMotion的使用
CoreMotion的使用有两种方式 :

1、Push方式:设置间隔,由manager不断回调;

self.motionManager = [[CMMotionManager alloc] init];
self.motionManager.deviceMotionUpdateInterval = 0.2;
[self.motionManager startDeviceMotionUpdatesToQueue:[NSOperationQueue mainQueue]
withHandler:^(CMDeviceMotion * _Nullable motion, NSError * _Nullable error) {

}];

2、Pull方式:启动监听,自定义定时器,不断读取manager的值;

self.motionManager = [[CMMotionManager alloc] init];
self.motionManager.deviceMotionUpdateInterval = 0.2;
[self.motionManager startDeviceMotionUpdates];
// self.motionManager.deviceMotion 后续通过这个属性可以直接读取结果

iOS系统在监听到运动信息的时候,需要把信息回调给开发者,方式就有push和pull两种;
push 是系统在规定的时间间隔,不断的回调;
pull 是由开发则自己去读取结果值,但同样需要设定一个更新频率;
两种方式的本质并无太大区别,都需要设置回调间隔,只是读取方式的不同;
在不使用之后(比如说切后台)要关闭更新,这是非常耗电量的操作。

五、demo实践

基于加速计,做了一个小游戏,逻辑不复杂详见具体代码,分享几个处理逻辑:

1、圆球的边界处理;(以球和右边界的碰撞为例)

if (self.ballView.right > self.gameContainerView.width) {
self.ballView.right = self.gameContainerView.width;
self.ballSpeedX /= -1;
}

2、圆球是否触碰目标的检测;

- (BOOL)checkTarget {
CGFloat disX = (self.ballView.centerX - self.targetView.centerX);
CGFloat disY = (self.ballView.centerY - self.targetView.centerY);
return sqrt(disX * disX + disY * disY) <= (kConstBallLength / 2 + kConstTargetLength / 2);
}

3、速度的平滑处理;

static CGFloat lySlowLowPassFilter(NSTimeInterval elapsed,
GLfloat target,
GLfloat current) {
return current + (4.0 * elapsed * (target - current));
}


总结

加速计和陀螺仪的原理复杂但使用简单,实际应用也比较广。
之前就用过加速计和陀螺仪,但是没有系统的学习过。在完整的学习一遍之后,我才知道原来加速计的单位是以重力加速度(9.8 m/s2)为标准单位,陀螺仪的数据仅仅是速率,单位是弧度每秒。
上面的小游戏代码地址在Github

链接:https://www.jianshu.com/p/6d6b213912f5

收起阅读 »

当前端基建任务落到你身上,该如何推动协作?

前言 作为一名野生的前端开发,自打本猿入行起,就未经过什么系统的学习,待过的团队也是大大小小没个准儿: 要么大牛带队,但是后端大牛。要么临时凑的团队,受制于从前,前端不自由。要么从0到项目部署,都是为了敏捷而敏捷,颇不规范。 话虽如此,经过4年生涯摧残的废猿...
继续阅读 »

前言


作为一名野生的前端开发,自打本猿入行起,就未经过什么系统的学习,待过的团队也是大大小小没个准儿:


要么大牛带队,但是后端大牛。
要么临时凑的团队,受制于从前,前端不自由。
要么从0到项目部署,都是为了敏捷而敏捷,颇不规范。


话虽如此,经过4年生涯摧残的废猿我,也是有自己的一番心得体会的。


1. 从DevOps流程看前端基建



很多专注于切图的萌新前端看到这张图是蒙圈的:


DevOps是什么?这些工具都是啥?我在哪?


很多前端在接触到什么前端工程化,什么持续构建/集成相关知识时就犯怂。也有觉得这与业务开发无关,不必理会。


但是往长远想,切图是不可能一辈子切图的,你业务再怎么厉害,前端代码再如何牛,没有了后端运维测试大佬们相助,一个完整的软件生产周期就没法走完。


成为一名全栈很难,更别说全链路开发者了。


言归正传,当你进入一个新团队,前端从0开始,怎样从DevOps的角度去提高团队效能呢?



一套简易的DevOps流程包含了协作、构建、测试、部署、运行。


而前端常说的开发规范、代码管理、测试、构建部署以及工程化其实都是在这一整个体系中。


当然,中小团队想玩好DevOps整套流程,需要的时间与研发成本,不比开发项目少。


DevOps核心思想就是:“快速交付价值,灵活响应变化”。其基本原则如下:


高效的协作和沟通;
自动化流程和工具;
快速敏捷的开发;
持续交付和部署;
不断学习和创新。


接下来我将从协作、构建、测试、部署、运行五个方面谈谈,如何快速打造用于中小团队的前端基建。


2. 在团队内/外促进协作


前端基建协作方面可以写的东西太多了,暂且粗略分为:团队内 与 团队外。



以下可能是前端们都能遇到的问题:


成员间水平各异,编写代码的风格各不相同,项目间难以统一管理。
不同项目Webpack配置差异过大,基础工具函数库和请求封装不一样。
项目结构与技术栈上下横跳,明明是同一UI风格,基础组件没法复用,全靠复制粘贴。
代码没注释,项目没文档,新人难以接手,旧项目无法维护。


三层代码规范约束



  • 第一层,ESLint


常见的ESLint风格有:airbnb,google,standard


在多个项目间,规则不应左右横跳,如果项目周期紧张,可以适当放宽规则,让warning类弱警告可以通过。且一般建议成员的IDE和插件要统一,将客观因素影响降到最低。



  • 第二层,Git Hooks


git 自身包含许多 hooks,在 commitpushgit 事件前后触发执行。


husky能够防止不规范代码被commitpushmerge等等。


代码提交不规范,全组部署两行泪。


npm install husky pre-commit  --save-dev


拿我以前的项目为例子:


// package.json
"scripts": {
// ...
"lint": "node_modules/.bin/eslint '**/*.{js,jsx}' && node_modules/.bin/stylelint '**/*.{css,scss}'",
"lint:fix": "node_modules/.bin/eslint '**/*.{js,jsx}' --fix && node_modules/.bin/stylelint '**/*.{css,scss}' --fix"
},
"husky": {
"hooks": {
"pre-commit": "npm run lint",
"commit-msg": "commitlint -E HUSKY_GIT_PARAMS"
}
},


通过简单的安装配置,无论你通过命令行还是Sourcetree提交代码,都需要通过严格的校验。



建议在根目录README.md注明提交规范:


## Git 规范

使用 [commitlint](https://github.com/conventional-changelog/commitlint
) 工具,常用有以下几种类型:

-
feat :新功能
- fix :修复 bug
- chore :对构建或者辅助工具的更改
- refactor :既不是修复 bug 也不是添加新功能的代码更改
- style :不影响代码含义的更改 (例如空格、格式化、少了分号)
- docs : 只是文档的更改
- perf :提高性能的代码更改
- revert :撤回提交
- test :添加或修正测试

举例
git commit -m 'feat: add list'



  • 第三层,CI(持续集成)。



《前端代码规范最佳实践》



前两步的校验可以手动跳过(找骂),但CI中的校验是绝对绕不过的,因为它在服务端校验。使用 gitlab CI 做持续集成,配置文件 .gitlab-ci.yaml 如下所示:


lint:
stage:lint
only:
-/^feature\/.*$/
script:
-npmlint


这层校验,一般在稍大点的企业中,会由运维部的配置组完成。



统一前端物料


公共组件、公共UI、工具函数库、第三方sdk等该如何规范?


如何快速封装部门UI组件库?

  • 将业务从公共组件中抽离出来。

  • 在项目中安装StoryBook(多项目时另起)

  • 按官方文档标准,创建stories,并设定参数(同时也建议先写Jest测试脚本),写上必要的注释。

  • 为不同组件配置StoryBook控件,最后部署。

如何统一部门所用的工具函数库和第三方sdk


其实这里更多的是沟通的问题,首先需要明确的几点:



  • 部门内对约定俗成的工具库要有提前沟通,不能这头装一个MomentJs,另一头又装了DayJS。一般的原则是:轻量的自己写,超过可接受大小的找替代,譬如:DayJS替代MomentJsImmerJS替代immutableJS等。

  • 部门间的有登录机制,请求库封装协议等。如果是SSO/扫码登录等,就协定只用一套,不允许后端随意变动。如果是请求库封装,就必须要后端统一Restful风格,相信我,不用Restful规范的团队都是灾难。前端联调会生不如死。

  • Mock方式、路由管理以及样式写法也应当统一。


在团队外促进协作


核心原则就是:“能用文档解决的就尽量别BB。”


虽说现今前端的地位愈发重要,但我们经常在项目开发中遇到以下问题:


不同的后端接口规范不一样,前端需要耗费大量时间去做数据清洗兼容。
前端静态页开发完了,后端迟迟不给接口,因为没有接口文档,天天都得问。
测试反馈的问题,在原型上没有体现。


首先是原型方面:

  • 一定要看明白产品给的原型文档!!!多问多沟通,这太重要了。

  • 好的产品一般都会提供项目流程详图,但前端还是需要基于实际,做一张页面流程图。

  • 要产品提供具体字段类型相关定义,不然得和后端扯皮。。。

其次是后端:

执行Restful接口规范,不符合规范的接口驳回。

劝退师就经历过,前东家有个JAVA架构师,连跨域和Restful都不知道,定的规范不成规范,一个简单查询接口返回五六级,其美名曰:“结构化数据”

遇到这种沉浸于自己世界不听劝的后端,我只有一句劝:要么把他搞走,要么跑路吧

必要的接口文档站点与API测试(如SwaggerApidoc),不接受文件传输形式的接口

早期的联调都是通过呐喊告知对方接口的标准。刚开始有什么不清楚的直接问就好了,但是到了后面的时候连写接口代码的那个人都忘了这接口怎么用,维护成本巨高

在没有接口文档站点出现前,接口文档以word文档出现,辅以postmanhttpcurl等工具去测试。但仍然不够直观,维护起来也难

以web交互为主的Swagger解决了测试,维护以及实时性的问题。从一定程度上也避免了扯皮问题:只有你后端没更新文档,这联调滞后时间就不该由前端担起。

最后是运维方面:

除了CI/CD相关的,其实很可以和运维一起写写nginx和插件开发。

效率沟通工具


可能大家比较习惯的是使用QQ或者微信去传输文件,日常沟通还行,就是对开发者不太友好。


如何是跨国家沟通,一般都是建议jira+slack的组合,但这两个工具稍微有些水土不服。


这四个工具随意选择都不会有太大问题。


链接:https://juejin.cn/post/6844904145602740231

收起阅读 »

手把手带你入门Webpack Plugin

关于 Webpack 在讲 Plugin 之前,我们先来了解下 Webpack。本质上,Webpack 是一个用于现代 JavaScript 应用程序的静态模块打包工具。它能够解析我们的代码,生成对应的依赖关系,然后将不同的模块达成一个或多个 bundle。 ...
继续阅读 »

关于 Webpack


在讲 Plugin 之前,我们先来了解下 Webpack。本质上,Webpack 是一个用于现代 JavaScript 应用程序的静态模块打包工具。它能够解析我们的代码,生成对应的依赖关系,然后将不同的模块达成一个或多个 bundle。



Webpack 的基本概念包括了如下内容:



  1. Entry:Webpack 的入口文件,指的是应该从哪个模块作为入口,来构建内部依赖图。

  2. Output:告诉 Webpack 在哪输出它所创建的 bundle 文件,以及输出的 bundle 文件该如何命名、输出到哪个路径下等规则。

  3. Loader:模块代码转化器,使得 Webpack 有能力去处理除了 JS、JSON 以外的其他类型的文件。

  4. Plugin:Plugin 提供执行更广的任务的功能,包括:打包优化,资源管理,注入环境变量等。

  5. Mode:根据不同运行环境执行不同优化参数时的必要参数。

  6. Browser Compatibility:支持所有 ES5 标准的浏览器(IE8 以上)。


了解完 Webpack 的基本概念之后,我们再来看下,为什么我们会需要 Plugin。


Plugin 的作用


我先举一个我们政采云内部的案例:


在 React 项目中,一般我们的 Router 文件是写在一个项目中的,如果项目中包含了许多页面,不免会出现所有业务模块 Router 耦合的情况,所以我们开发了一个 Plugin,在构建打包时,该 Plugin 会读取所有文件夹下的 index.js 文件,再合并到一起形成一个统一的 Router 文件,轻松解决业务耦合问题。这就是 Plugin 的应用(具体实现会在最后一小节说明)。


来看一下我们合成前项目代码结构:


├── package.json
├── README.md
├── zoo.config.js
├── .eslintignore
├── .eslintrc
├── .gitignore
├── .stylelintrc
├── buildWebpack 配置目录)
│ └── webpack.dev.conf.js
├── src
│ ├── index.hbs
│ ├── main.js (入口文件)
│ ├── common (通用模块,包权限,统一报错拦截等)
│ └── ...
│ ├── components (项目公共组件)
│ └── ...
│ ├── layouts (项目顶通)
│ └── ...
│ ├── utils (公共类)
│ └── ...
│ ├── routes (页面路由)
│ │ ├── Hello (对应 Hello 页面的代码)
│ │ │ ├── config (页面配置信息)
│ │ │ └── ...
│ │ │ ├── modelsdva数据中心)
│ │ │ └── ...
│ │ │ ├── services (请求相关接口定义)
│ │ │ └── ...
│ │ │ ├── views (请求相关接口定义)
│ │ │ └── ...
│ │ │ └── index.jsrouter定义的路由信息)
├── .eslintignore
├── .eslintrc
├── .gitignore
└── .stylelintrc


再看一下经过 Plugin 合成 Router 之后的结构:


├── package.json
├── README.md
├── zoo.config.js
├── .eslintignore
├── .eslintrc
├── .gitignore
├── .stylelintrc
├── buildWebpack 配置目录)
│ └── webpack.dev.conf.js
├── src
│ ├── index.hbs
│ ├── main.js (入口文件)
│ ├── router-config.js (合成后的router文件)
│ ├── common (通用模块,包权限,统一报错拦截等)
│ └── ...
│ ├── components (项目公共组件)
│ └── ...
│ ├── layouts (项目顶通)
│ └── ...
│ ├── utils (公共类)
│ └── ...
│ ├── routes (页面路由)
│ │ ├── Hello (对应 Hello 页面的代码)
│ │ │ ├── config (页面配置信息)
│ │ │ └── ...
│ │ │ ├── modelsdva数据中心)
│ │ │ └── ...
│ │ │ ├── services (请求相关接口定义)
│ │ │ └── ...
│ │ │ ├── views (请求相关接口定义)
│ │ │ └── ...
├── .eslintignore
├── .eslintrc
├── .gitignore
└── .stylelintrc


总结来说 Plugin 的作用如下:



  1. 提供了 Loader 无法解决的一些其他事情

  2. 提供强大的扩展方法,能执行更广的任务


了解完 Plugin 的大致作用之后,我们来聊一聊如何创建一个 Plugin。


创建一个 Plugin


Hook


在聊创建 Plugin 之前,我们先来聊一下什么是 Hook。


Webpack 在编译的过程中会触发一系列流程,而在这样一连串的流程中,Webpack 把一些关键的流程节点暴露出来供开发者使用,这就是 Hook,可以类比 React 的生命周期钩子。


Plugin 就是在这些 Hook 上暴露出方法供开发者做一些额外操作,在写 Plugin 的时候,也需要先了解我们应该在哪个 Hook 上做操作。


如何创建 Plugin


我们先来看一下 Webpack 官方给的案例:


const pluginName = 'ConsoleLogOnBuildWebpackPlugin';

class ConsoleLogOnBuildWebpackPlugin {
apply(compiler) {
// 代表开始读取 records 之前执行
compiler.hooks.run.tap(pluginName, compilation => {
console.log("webpack 构建过程开始!");
});
}
}


从上面的代码我们可以总结如下内容:



  • Plugin 其实就是一个类。

  • 类需要一个 apply 方法,执行具体的插件方法。

  • 插件方法做了一件事情就是在 run 这个 Hook 上注册了一个同步的打印日志的方法。

  • apply 方法的入参注入了一个 compiler 实例,compiler 实例是 Webpack 的支柱引擎,代表了 CLI 和 Node API 传递的所有配置项。

  • Hook 回调方法注入了 compilation 实例,compilation 能够访问当前构建时的模块和相应的依赖。


Compiler 对象包含了 Webpack 环境所有的的配置信息,包含 options,loaders,plugins 这些信息,这个对象在 Webpack 启动时候被实例化,它是全局唯一的,可以简单地把它理解为 Webpack 实例;

Compilation 对象包含了当前的模块资源、编译生成资源、变化的文件等。当 Webpack 以开发模式运行时,每当检测到一个文件变化,一次新的 Compilation 将被创建。Compilation 对象也提供了很多事件回调供插件做扩展。通过 Compilation 也能读取到 Compiler 对象。
—— 摘自「深入浅出 Webpack」



  • compiler 实例和 compilation 实例上分别定义了许多 Hooks,可以通过 实例.hooks.具体Hook 访问,Hook 上还暴露了 3 个方法供使用,分别是 tap、tapAsync 和 tapPromise。这三个方法用于定义如何执行 Hook,比如 tap 表示注册同步 Hook,tapAsync 代表 callback 方式注册异步 hook,而 tapPromise 代表 Promise 方式注册异步 Hook,可以看下 Webpack 中关于这三种类型实现的源码,为方便阅读,我加了些注释。


// tap方法的type是sync,tapAsync方法的type是async,tapPromise方法的type是promise
// 源码取自Hook工厂方法:lib/HookCodeFactory.js
create(options) {
this.init(options);
let fn;
// Webpack 通过new Function 生成函数
switch (this.options.type) {
case "sync":
fn = new Function(
this.args(), // 生成函数入参
'"use strict";\n' +
this.header() + // 公共方法,生成一些需要定义的变量
this.contentWithInterceptors({ // 生成实际执行的代码的方法
onError: err => `throw ${err};\n`, // 错误回调
onResult: result => `return ${result};\n`, // 得到值的时候的回调
resultReturns: true,
onDone: () => "",
rethrowIfPossible: true
})
);
break;
case "async":
fn = new Function(
this.args({
after: "_callback"
}),
'"use strict";\n' +
this.header() + // 公共方法,生成一些需要定义的变量
this.contentWithInterceptors({
onError: err => `_callback(${err});\n`, // 错误时执行回调方法
onResult: result => `_callback(null, ${result});\n`, // 得到结果时执行回调方法
onDone: () => "_callback();\n" // 无结果,执行完成时
})
);
break;
case "promise":
let errorHelperUsed = false;
const content = this.contentWithInterceptors({
onError: err => {
errorHelperUsed = true;
return `_error(${err});\n`;
},
onResult: result => `_resolve(${result});\n`,
onDone: () => "_resolve();\n"
});
let code = "";
code += '"use strict";\n';
code += this.header(); // 公共方法,生成一些需要定义的变量
code += "return new Promise((function(_resolve, _reject) {\n"; // 返回的是 Promise
if (errorHelperUsed) {
code += "var _sync = true;\n";
code += "function _error(_err) {\n";
code += "if(_sync)\n";
code +=
"_resolve(Promise.resolve().then((function() { throw _err; })));\n";
code += "else\n";
code += "_reject(_err);\n";
code += "};\n";
}
code += content; // 判断具体执行_resolve方法还是执行_error方法
if (errorHelperUsed) {
code += "_sync = false;\n";
}
code += "}));\n";
fn = new Function(this.args(), code);
break;
}
this.deinit(); // 清空 options 和 _args
return fn;
}


Webpack 共提供了以下十种 Hooks,代码中所有具体的 Hook 都是以下这 10 种中的一种。


// 源码取自:lib/index.js
"use strict";

exports.__esModule = true;
// 同步执行的钩子,不能处理异步任务
exports.SyncHook = require("./SyncHook");
// 同步执行的钩子,返回非空时,阻止向下执行
exports.SyncBailHook = require("./SyncBailHook");
// 同步执行的钩子,支持将返回值透传到下一个钩子中
exports.SyncWaterfallHook = require("./SyncWaterfallHook");
// 同步执行的钩子,支持将返回值透传到下一个钩子中,返回非空时,重复执行
exports.SyncLoopHook = require("./SyncLoopHook");
// 异步并行的钩子
exports.AsyncParallelHook = require("./AsyncParallelHook");
// 异步并行的钩子,返回非空时,阻止向下执行,直接执行回调
exports.AsyncParallelBailHook = require("./AsyncParallelBailHook");
// 异步串行的钩子
exports.AsyncSeriesHook = require("./AsyncSeriesHook");
// 异步串行的钩子,返回非空时,阻止向下执行,直接执行回调
exports.AsyncSeriesBailHook = require("./AsyncSeriesBailHook");
// 支持异步串行 && 并行的钩子,返回非空时,重复执行
exports.AsyncSeriesLoopHook = require("./AsyncSeriesLoopHook");
// 异步串行的钩子,下一步依赖上一步返回的值
exports.AsyncSeriesWaterfallHook = require("./AsyncSeriesWaterfallHook");
// 以下 2 个是 hook 工具类,分别用于 hooks 映射以及 hooks 重定向
exports.HookMap = require("./HookMap");
exports.MultiHook = require("./MultiHook");


举几个简单的例子:



  • 上面官方案例中的 run 这个 Hook,会在开始读取 records 之前执行,它的类型是 AsyncSeriesHook,查看源码可以发现,run Hook 既可以执行同步的 tap 方法,也可以执行异步的 tapAsync 和 tapPromise 方法,所以以下写法也是可以的:


const pluginName = 'ConsoleLogOnBuildWebpackPlugin';

class ConsoleLogOnBuildWebpackPlugin {
apply(compiler) {
compiler.hooks.run.tapAsync(pluginName, (compilation, callback) => {
setTimeout(() => {
console.log("webpack 构建过程开始!");
callback(); // callback 方法为了让构建继续执行下去,必须要调用
}, 1000);
});
}
}



  • 再举一个例子,比如 failed 这个 Hook,会在编译失败之后执行,它的类型是 SyncHook,查看源码可以发现,调用 tapAsync 和 tapPromise 方法时,会直接抛错。


对于一些同步的方法,推荐直接使用 tap 进行注册方法,对于异步的方案,tapAsync 通过执行 callback 方法实现回调,如果执行的方法返回的是一个 Promise,推荐使用 tapPromise 进行方法的注册


Hook 的类型可以通过官方 API 查询,地址传送门


// 源码取自:lib/SyncHook.js
const TAP_ASYNC = () => {
throw new Error("tapAsync is not supported on a SyncHook");
};

const TAP_PROMISE = () => {
throw new Error("tapPromise is not supported on a SyncHook");
};

function SyncHook(args = [], name = undefined) {
const hook = new Hook(args, name);
hook.constructor = SyncHook;
hook.tapAsync = TAP_ASYNC;
hook.tapPromise = TAP_PROMISE;
hook.compile = COMPILE;
return hook;
}


讲解完具体的执行方法之后,我们再聊一下 Webpack 流程以及 Tapable 是什么。


Webpack && Tapable


Webpack 运行机制


要理解 Plugin,我们先大致了解 Webpack 打包的流程



  1. 我们打包的时候,会先合并 Webpack config 文件和命令行参数,合并为 options。

  2. 将 options 传入 Compiler 构造方法,生成 compiler 实例,并实例化了 Compiler 上的 Hooks。

  3. compiler 对象执行 run 方法,并自动触发 beforeRun、run、beforeCompile、compile 等关键 Hooks。

  4. 调用 Compilation 构造方法创建 compilation 对象,compilation 负责管理所有模块和对应的依赖,创建完成后触发 make Hook。

  5. 执行 compilation.addEntry() 方法,addEntry 用于分析所有入口文件,逐级递归解析,调用 NormalModuleFactory 方法,为每个依赖生成一个 Module 实例,并在执行过程中触发 beforeResolve、resolver、afterResolve、module 等关键 Hooks。

  6. 将第 5 步中生成的 Module 实例作为入参,执行 Compilation.addModule() 和 Compilation.buildModule() 方法递归创建模块对象和依赖模块对象。

  7. 调用 seal 方法生成代码,整理输出主文件和 chunk,并最终输出。



Tapable


Tapable 是 Webpack 核心工具库,它提供了所有 Hook 的抽象类定义,Webpack 许多对象都是继承自 Tapable 类。比如上面说的 tap、tapAsync 和 tapPromise 都是通过 Tapable 进行暴露的。源码如下(截取了部分代码):


// 第二节 “创建一个 Plugin” 中说的 10 种 Hooks 都是继承了这两个类
// 源码取自:tapable.d.ts
declare class Hook {
tap(options: string | Tap & IfSet, fn: (...args: AsArray) => R): void;
}

declare class AsyncHook extends Hook {
tapAsync(
options: string | Tap & IfSet,
fn: (...args: Append, InnerCallback>) => void
): void;
tapPromise(
options: string | Tap & IfSet,
fn: (...args: AsArray) => Promise
): void;
}



常见 Hooks API


可以参考 Webpack


本文列举一些常用 Hooks 和其对应的类型:


Compiler Hooks
































Hooktype调用
runAsyncSeriesHook开始读取 records 之前
compileSyncHook一个新的编译 (compilation) 创建之后
emitAsyncSeriesHook生成资源到 output 目录之前
doneSyncHook编译 (compilation) 完成

Compilation Hooks



























Hooktype调用
buildModuleSyncHook在模块构建开始之前触发
finishModulesSyncHook所有模块都完成构建
optimizeSyncHook优化阶段开始时触发

Plugin 在项目中的应用


讲完这么多理论知识,接下来我们来看一下 Plugin 在项目中的实战:如何将各个子模块中的 router 文件合并到 router-config.js 中。


背景:


在 React 项目中,一般我们的 Router 文件是写在一个项目中的,如果项目中包含了许多页面,不免会出现所有业务模块 Router 耦合的情况,所以我们开发了一个 Plugin,在构建打包时,该 Plugin 会读取所有文件夹下的 Router 文件,再合并到一起形成一个统一的 Router Config 文件,轻松解决业务耦合问题。这就是 Plugin 的应用。


实现:


const fs = require('fs');
const path = require('path');
const _ = require('lodash');

function resolve(dir) {
return path.join(__dirname, '..', dir);
}

function MegerRouterPlugin(options) {
// options是配置文件,你可以在这里进行一些与options相关的工作
}

MegerRouterPlugin.prototype.apply = function (compiler) {
// 注册 before-compile 钩子,触发文件合并
compiler.plugin('before-compile', (compilation, callback) => {
// 最终生成的文件数据
const data = {};
const routesPath = resolve('src/routes');
const targetFile = resolve('src/router-config.js');
// 获取路径下所有的文件和文件夹
const dirs = fs.readdirSync(routesPath);
try {
dirs.forEach((dir) => {
const routePath = resolve(`src/routes/${dir}`);
// 判断是否是文件夹
if (!fs.statSync(routePath).isDirectory()) {
return true;
}
delete require.cache[`${routePath}/index.js`];
const routeInfo = require(routePath);
// 多个 view 的情况下,遍历生成router信息
if (!_.isArray(routeInfo)) {
generate(routeInfo, dir, data);
// 单个 view 的情况下,直接生成
} else {
routeInfo.map((config) => {
generate(config, dir, data);
});
}
});
} catch (e) {
console.log(e);
}

// 如果 router-config.js 存在,判断文件数据是否相同,不同删除文件后再生成
if (fs.existsSync(targetFile)) {
delete require.cache[targetFile];
const targetData = require(targetFile);
if (!_.isEqual(targetData, data)) {
writeFile(targetFile, data);
}
// 如果 router-config.js 不存在,直接生成文件
} else {
writeFile(targetFile, data);
}

// 最后调用 callback,继续执行 webpack 打包
callback();
});
};
// 合并当前文件夹下的router数据,并输出到 data 对象中
function generate(config, dir, data) {
// 合并 router
mergeConfig(config, dir, data);
// 合并子 router
getChildRoutes(config.childRoutes, dir, data, config.url);
}
// 合并 router 数据到 targetData 中
function mergeConfig(config, dir, targetData) {
const { view, models, extraModels, url, childRoutes, ...rest } = config;
// 获取 models,并去除 src 字段
const dirModels = getModels(`src/routes/${dir}/models`, models);
const data = {
...rest,
};
// view 拼接到 path 字段
data.path = `${dir}/views${view ? `/${view}` : ''}`;
// 如果有 extraModels,就拼接到 models 对象上
if (dirModels.length || (extraModels && extraModels.length)) {
data.models = mergerExtraModels(config, dirModels);
}
Object.assign(targetData, {
[url]: data,
});
}
// 拼接 dva models
function getModels(modelsDir, models) {
if (!fs.existsSync(modelsDir)) {
return [];
}
let files = fs.readdirSync(modelsDir);
// 必须要以 js 或者 jsx 结尾
files = files.filter((item) => {
return /\.jsx?$/.test(item);
});
// 如果没有定义 models ,默认取 index.js
if (!models || !models.length) {
if (files.indexOf('index.js') > -1) {
// 去除 src
return [`${modelsDir.replace('src/', '')}/index.js`];
}
return [];
}
return models.map((item) => {
if (files.indexOf(`${item}.js`) > -1) {
// 去除 src
return `${modelsDir.replace('src/', '')}/${item}.js`;
}
});
}
// 合并 extra models
function mergerExtraModels(config, models) {
return models.concat(config.extraModels ? config.extraModels : []);
}
// 合并子 router
function getChildRoutes(childRoutes, dir, targetData, oUrl) {
if (!childRoutes) {
return;
}
childRoutes.map((option) => {
option.url = oUrl + option.url;
if (option.childRoutes) {
// 递归合并子 router
getChildRoutes(option.childRoutes, dir, targetData, option.url);
}
mergeConfig(option, dir, targetData);
});
}

// 写文件
function writeFile(targetFile, data) {
fs.writeFileSync(targetFile, `module.exports = ${JSON.stringify(data, null, 2)}`, 'utf-8');
}

module.exports = MegerRouterPlugin;



结果:


合并前的文件:


module.exports = [
{
url: '/category/protocol',
view: 'protocol',
},
{
url: '/category/sync',
models: ['sync'],
view: 'sync',
},
{
url: '/category/list',
models: ['category', 'config', 'attributes', 'group', 'otherSet', 'collaboration'],
view: 'categoryRefactor',
},
{
url: '/category/conversion',
models: ['conversion'],
view: 'conversion',
},
];



合并后的文件:


module.exports = {
"/category/protocol": {
"path": "Category/views/protocol"
},
"/category/sync": {
"path": "Category/views/sync",
"models": [
"routes/Category/models/sync.js"
]
},
"/category/list": {
"path": "Category/views/categoryRefactor",
"models": [
"routes/Category/models/category.js",
"routes/Category/models/config.js",
"routes/Category/models/attributes.js",
"routes/Category/models/group.js",
"routes/Category/models/otherSet.js",
"routes/Category/models/collaboration.js"
]
},
"/category/conversion": {
"path": "Category/views/conversion",
"models": [
"routes/Category/models/conversion.js"
]
},
}



最终项目就会生成 router-config.js 文件



结尾


希望大家看完本章之后,对 Webpack Plugin 有一个初步的认识,能够上手写一个自己的 Plugin 来应用到自己的项目中。


文章中如有不对的地方,欢迎指正。


链接:https://juejin.cn/post/6968988552075952141

收起阅读 »

当面试官问Webpack的时候他想知道什么

前言 在前端工程化日趋复杂的今天,模块打包工具在我们的开发中起到了越来越重要的作用,其中webpack就是最热门的打包工具之一。 说到webpack,可能很多小伙伴会觉得既熟悉又陌生,熟悉是因为几乎在每一个项目中我们都会用上它,又因为webpack复杂的配置和...
继续阅读 »

前言


在前端工程化日趋复杂的今天,模块打包工具在我们的开发中起到了越来越重要的作用,其中webpack就是最热门的打包工具之一。


说到webpack,可能很多小伙伴会觉得既熟悉又陌生,熟悉是因为几乎在每一个项目中我们都会用上它,又因为webpack复杂的配置和五花八门的功能感到陌生。尤其当我们使用诸如umi.js之类的应用框架还帮我们把webpack配置再封装一层的时候,webpack的本质似乎离我们更加遥远和深不可测了。


当面试官问你是否了解webpack的时候,或许你可以说出一串耳熟能详的webpack loaderplugin的名字,甚至还能说出插件和一系列配置做按需加载和打包优化,那你是否了解他的运行机制以及实现原理呢,那我们今天就一起探索webpack的能力边界,尝试了解webpack的一些实现流程和原理,拒做API工程师。


CgqCHl6pSFmAC5UzAAEwx63IBwE024.png


你知道webpack的作用是什么吗?


从官网上的描述我们其实不难理解,webpack的作用其实有以下几点:




  • 模块打包。可以将不同模块的文件打包整合在一起,并且保证它们之间的引用正确,执行有序。利用打包我们就可以在开发的时候根据我们自己的业务自由划分文件模块,保证项目结构的清晰和可读性。




  • 编译兼容。在前端的“上古时期”,手写一堆浏览器兼容代码一直是令前端工程师头皮发麻的事情,而在今天这个问题被大大的弱化了,通过webpackLoader机制,不仅仅可以帮助我们对代码做polyfill,还可以编译转换诸如.less, .vue, .jsx这类在浏览器无法识别的格式文件,让我们在开发的时候可以使用新特性和新语法做开发,提高开发效率。




  • 能力扩展。通过webpackPlugin机制,我们在实现模块化打包和编译兼容的基础上,可以进一步实现诸如按需加载,代码压缩等一系列功能,帮助我们进一步提高自动化程度,工程效率以及打包输出的质量。




说一下模块打包运行原理?


如果面试官问你Webpack是如何把这些模块合并到一起,并且保证其正常工作的,你是否了解呢?


首先我们应该简单了解一下webpack的整个打包流程:



  • 1、读取webpack的配置参数;

  • 2、启动webpack,创建Compiler对象并开始解析项目;

  • 3、从入口文件(entry)开始解析,并且找到其导入的依赖模块,递归遍历分析,形成依赖关系树;

  • 4、对不同文件类型的依赖模块文件使用对应的Loader进行编译,最终转为Javascript文件;

  • 5、整个过程中webpack会通过发布订阅模式,向外抛出一些hooks,而webpack的插件即可通过监听这些关键的事件节点,执行插件任务进而达到干预输出结果的目的。


其中文件的解析与构建是一个比较复杂的过程,在webpack源码中主要依赖于compilercompilation两个核心对象实现。


compiler对象是一个全局单例,他负责把控整个webpack打包的构建流程。
compilation对象是每一次构建的上下文对象,它包含了当次构建所需要的所有信息,每次热更新和重新构建,compiler都会重新生成一个新的compilation对象,负责此次更新的构建过程。


而每个模块间的依赖关系,则依赖于AST语法树。每个模块文件在通过Loader解析完成之后,会通过acorn库生成模块代码的AST语法树,通过语法树就可以分析这个模块是否还有依赖的模块,进而继续循环执行下一个模块的编译解析。


最终Webpack打包出来的bundle文件是一个IIFE的执行函数。


// webpack 5 打包的bundle文件内容

(() => { // webpackBootstrap
var __webpack_modules__ = ({
'file-A-path': ((modules) => { // ... })
'index-file-path': ((__unused_webpack_module, __unused_webpack_exports, __webpack_require__) => { // ... })
})

// The module cache
var __webpack_module_cache__ = {};

// The require function
function __webpack_require__(moduleId) {
// Check if module is in cache
var cachedModule = __webpack_module_cache__[moduleId];
if (cachedModule !== undefined) {
return cachedModule.exports;
}
// Create a new module (and put it into the cache)
var module = __webpack_module_cache__[moduleId] = {
// no module.id needed
// no module.loaded needed
exports: {}
};

// Execute the module function
__webpack_modules__[moduleId](module, module.exports, __webpack_require__);

// Return the exports of the module
return module.exports;
}

// startup
// Load entry module and return exports
// This entry module can't be inlined because the eval devtool is used.
var __webpack_exports__ = __webpack_require__("./src/index.js");
})


webpack4相比,webpack5打包出来的bundle做了相当的精简。在上面的打包demo中,整个立即执行函数里边只有三个变量和一个函数方法,__webpack_modules__存放了编译后的各个文件模块的JS内容,__webpack_module_cache__ 用来做模块缓存,__webpack_require__Webpack内部实现的一套依赖引入函数。最后一句则是代码运行的起点,从入口文件开始,启动整个项目。


其中值得一提的是__webpack_require__模块引入函数,我们在模块化开发的时候,通常会使用ES Module或者CommonJS规范导出/引入依赖模块,webpack打包编译的时候,会统一替换成自己的__webpack_require__来实现模块的引入和导出,从而实现模块缓存机制,以及抹平不同模块规范之间的一些差异性。


你知道sourceMap是什么吗?


提到sourceMap,很多小伙伴可能会立刻想到Webpack配置里边的devtool参数,以及对应的evaleval-cheap-source-map等等可选值以及它们的含义。除了知道不同参数之间的区别以及性能上的差异外,我们也可以一起了解一下sourceMap的实现方式。


sourceMap是一项将编译、打包、压缩后的代码映射回源代码的技术,由于打包压缩后的代码并没有阅读性可言,一旦在开发中报错或者遇到问题,直接在混淆代码中debug问题会带来非常糟糕的体验,sourceMap可以帮助我们快速定位到源代码的位置,提高我们的开发效率。sourceMap其实并不是Webpack特有的功能,而是Webpack支持sourceMap,像JQuery也支持souceMap


既然是一种源码的映射,那必然就需要有一份映射的文件,来标记混淆代码里对应的源码的位置,通常这份映射文件以.map结尾,里边的数据结构大概长这样:


{
"version" : 3, // Source Map版本
"file": "out.js", // 输出文件(可选)
"sourceRoot": "", // 源文件根目录(可选)
"sources": ["foo.js", "bar.js"], // 源文件列表
"sourcesContent": [null, null], // 源内容列表(可选,和源文件列表顺序一致)
"names": ["src", "maps", "are", "fun"], // mappings使用的符号名称列表
"mappings": "A,AAAB;;ABCDE;" // 带有编码映射数据的字符串
}


其中mappings数据有如下规则:



  • 生成文件中的一行的每个组用“;”分隔;

  • 每一段用“,”分隔;

  • 每个段由1、4或5个可变长度字段组成;


有了这份映射文件,我们只需要在我们的压缩代码的最末端加上这句注释,即可让sourceMap生效:


//# sourceURL=/path/to/file.js.map

有了这段注释后,浏览器就会通过sourceURL去获取这份映射文件,通过解释器解析后,实现源码和混淆代码之间的映射。因此sourceMap其实也是一项需要浏览器支持的技术。


如果我们仔细查看webpack打包出来的bundle文件,就可以发现在默认的development开发模式下,每个_webpack_modules__文件模块的代码最末端,都会加上//# sourceURL=webpack://file-path?,从而实现对sourceMap的支持。


sourceMap映射表的生成有一套较为复杂的规则,有兴趣的小伙伴可以看看以下文章,帮助理解soucrMap的原理实现:


Source Map的原理探究


Source Maps under the hood – VLQ, Base64 and Yoda


是否写过Loader?简单描述一下编写loader的思路?


从上面的打包代码我们其实可以知道,Webpack最后打包出来的成果是一份Javascript代码,实际上在Webpack内部默认也只能够处理JS模块代码,在打包过程中,会默认把所有遇到的文件都当作 JavaScript代码进行解析,因此当项目存在非JS类型文件时,我们需要先对其进行必要的转换,才能继续执行打包任务,这也是Loader机制存在的意义。


Loader的配置使用我们应该已经非常的熟悉:


// webpack.config.js
module.exports = {
// ...other config
module: {
rules: [
{
test: /^your-regExp$/,
use: [
{
loader: 'loader-name-A',
},
{
loader: 'loader-name-B',
}
]
},
]
}
}

通过配置可以看出,针对每个文件类型,loader是支持以数组的形式配置多个的,因此当Webpack在转换该文件类型的时候,会按顺序链式调用每一个loader,前一个loader返回的内容会作为下一个loader的入参。因此loader的开发需要遵循一些规范,比如返回值必须是标准的JS代码字符串,以保证下一个loader能够正常工作,同时在开发上需要严格遵循“单一职责”,只关心loader的输出以及对应的输出。


loader函数中的this上下文由webpack提供,可以通过this对象提供的相关属性,获取当前loader需要的各种信息数据,事实上,这个this指向了一个叫loaderContextloader-runner特有对象。有兴趣的小伙伴可以自行阅读源码。


module.exports = function(source) {
const content = doSomeThing2JsString(source);

// 如果 loader 配置了 options 对象,那么this.query将指向 options
const options = this.query;

// 可以用作解析其他模块路径的上下文
console.log('this.context');

/*
* this.callback 参数:
* error:Error | null,当 loader 出错时向外抛出一个 error
* content:String | Buffer,经过 loader 编译后需要导出的内容
* sourceMap:为方便调试生成的编译后内容的 source map
* ast:本次编译生成的 AST 静态语法树,之后执行的 loader 可以直接使用这个 AST,进而省去重复生成 AST 的过程
*/
this.callback(null, content);
// or return content;
}

更详细的开发文档可以直接查看官网的 Loader API


是否写过Plugin?简单描述一下编写plugin的思路?


如果说Loader负责文件转换,那么Plugin便是负责功能扩展。LoaderPlugin作为Webpack的两个重要组成部分,承担着两部分不同的职责。


上文已经说过,webpack基于发布订阅模式,在运行的生命周期中会广播出许多事件,插件通过监听这些事件,就可以在特定的阶段执行自己的插件任务,从而实现自己想要的功能。


既然基于发布订阅模式,那么知道Webpack到底提供了哪些事件钩子供插件开发者使用是非常重要的,上文提到过compilercompilationWebpack两个非常核心的对象,其中compiler暴露了和 Webpack整个生命周期相关的钩子(compiler-hooks),而compilation则暴露了与模块和依赖有关的粒度更小的事件钩子(Compilation Hooks)。


Webpack的事件机制基于webpack自己实现的一套Tapable事件流方案(github


// Tapable的简单使用
const { SyncHook } = require("tapable");

class Car {
constructor() {
// 在this.hooks中定义所有的钩子事件
this.hooks = {
accelerate: new SyncHook(["newSpeed"]),
brake: new SyncHook(),
calculateRoutes: new AsyncParallelHook(["source", "target", "routesList"])
};
}

/* ... */
}


const myCar = new Car();
// 通过调用tap方法即可增加一个消费者,订阅对应的钩子事件了
myCar.hooks.brake.tap("WarningLampPlugin", () => warningLamp.on());

Plugin的开发和开发Loader一样,需要遵循一些开发上的规范和原则:



  • 插件必须是一个函数或者是一个包含 apply 方法的对象,这样才能访问compiler实例;

  • 传给每个插件的 compilercompilation 对象都是同一个引用,若在一个插件中修改了它们身上的属性,会影响后面的插件;

  • 异步的事件需要在插件处理完任务时调用回调函数通知 Webpack 进入下一个流程,不然会卡住;


了解了以上这些内容,想要开发一个 Webpack Plugin,其实也并不困难。


class MyPlugin {
apply (compiler) {
// 找到合适的事件钩子,实现自己的插件功能
compiler.hooks.emit.tap('MyPlugin', compilation => {
// compilation: 当前打包构建流程的上下文
console.log(compilation);

// do something...
})
}
}

更详细的开发文档可以直接查看官网的 Plugin API


最后


本文也是结合一些优秀的文章和webpack本身的源码,大概地说了几个相对重要的概念和流程,其中的实现细节和设计思路还需要结合源码去阅读和慢慢理解。


Webpack作为一款优秀的打包工具,它改变了传统前端的开发模式,是现代化前端开发的基石。这样一个优秀的开源项目有许多优秀的设计思想和理念可以借鉴,我们自然也不应该仅仅停留在API的使用层面,尝试带着问题阅读源码,理解实现的流程和原理,也能让我们学到更多知识,理解得更加深刻,在项目中才能游刃有余的应用。




链接:https://juejin.cn/post/6943468761575849992

收起阅读 »

是什么让尤大选择放弃Webpack?面向未来的前端构建工具 Vite

前两天在知乎看到过一篇文章,大致意思是讲:字节跳动已经开始“弃用Webpack”,尝试在自研构建工具中使用类似Vite的ESmodule构建方式。 引起下方一大片焦虑: Webpack是不是要被取代了?现在学Vite就行了吧 Webpack还没学会,就又来新...
继续阅读 »

前两天在知乎看到过一篇文章,大致意思是讲:字节跳动已经开始“弃用Webpack”,尝试在自研构建工具中使用类似Vite的ESmodule构建方式。


引起下方一大片焦虑:



  • Webpack是不是要被取代了?现在学Vite就行了吧

  • Webpack还没学会,就又来新的了!


甚至有人搬出了去年尤大所发的一个动态:再也回不去Webpack了。


在这里插入图片描述



PS:最近的vite比较火,而且发布了2.0版本,vue的作者尤雨溪也是在极力推荐


全方位对比vite和webpack


webpack打包过程


1.识别入口文件


2.通过逐层识别模块依赖。(Commonjs、amd或者es6的import,webpack都会对其进行分析。来获取代码的依赖)


3.webpack做的就是分析代码。转换代码,编译代码,输出代码


4.最终形成打包后的代码


webpack打包原理


1.先逐级递归识别依赖,构建依赖图谱


2.将代码转化成AST抽象语法树


3.在AST阶段中去处理代码


4.把AST抽象语法树变成浏览器可以识别的代码, 然后输出



重点:这里需要递归识别依赖,构建依赖图谱。图谱对象就是类似下面这种



{ './app.js':
{ dependencies: { './test1.js': './test1.js' },
code:
'"use strict";\n\nvar _test = _interopRequireDefault(require("./test1.js"));\n\nfunction _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; }\n\nconsole.log(test
1);' },
'./test1.js':
{ dependencies: { './test2.js': './test2.js' },
code:
'"use strict";\n\nvar _test = _interopRequireDefault(require("./test2.js"));\n\nfunction _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; }\n\nconsole.log(\'th
is is test1.js \', _test["default"]);' },
'./test2.js':
{ dependencies: {},
code:
'"use strict";\n\nObject.defineProperty(exports, "__esModule", {\n value: true\n});\nexports["default"] = void 0;\n\nfunction test2() {\n console.log(\'this is test2 \');\n}\n\nvar _default = tes
t2;\nexports["default"] = _default;' } }


在这里插入图片描述


Vite原理


当声明一个 script 标签类型为 module 时





浏览器就会像服务器发起一个GET


http://localhost:3000/src/main.js请求main.js文件:

// /src/main.js:
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')


浏览器请求到了main.js文件,检测到内部含有import引入的包,又会对其内部的 import 引用发起 HTTP 请求获取模块的内容文件



Vite 的主要功能就是通过劫持浏览器的这些请求,并在后端进行相应的处理将项目中使用的文件通过简单的分解与整合,然后再返回给浏览器,vite整个过程中没有对文件进行打包编译,所以其运行速度比原始的webpack开发编译速度快出许多!


webpack缺点一:缓慢的服务器启动


当冷启动开发服务器时,基于打包器的方式是在提供服务前去急切地抓取和构建你的整个应用。


Vite改进



  • Vite 通过在一开始将应用中的模块区分为 依赖 和 源码 两类,改进了开发服务器启动时间。

  • 依赖 大多为纯 JavaScript 并在开发时不会变动。一些较大的依赖(例如有上百个模块的组件库)处理的代价也很高。依赖也通常会以某些方式(例如 ESM 或者 CommonJS)被拆分到大量小模块中。

  • Vite 将会使用 esbuild 预构建依赖。Esbuild 使用 Go 编写,并且比以 JavaScript 编写的打包器预构建依赖快 10-100 倍。

  • 源码 通常包含一些并非直接是 JavaScript 的文件,需要转换(例如 JSX,CSS 或者 Vue/Svelte 组件),时常会被编辑。同时,并不是所有的源码都需要同时被加载。(例如基于路由拆分的代码模块)。

  • Vite 以 原生 ESM 方式服务源码。这实际上是让浏览器接管了打包程序的部分工作:Vite 只需要在浏览器请求源码时进行转换并按需提供源码。根据情景动态导入的代码,即只在当前屏幕上实际使用时才会被处理。


webpack缺点2:使用的是node.js去实现


在这里插入图片描述
Vite改进


Vite 将会使用 esbuild 预构建依赖。Esbuild 使用 Go 编写,并且比以 Node.js 编写的打包器预构建依赖快 10-100 倍。


webpack致命缺点3:热更新效率低下



  • 当基于打包器启动时,编辑文件后将重新构建文件本身。显然我们不应该重新构建整个包,因为这样更新速度会随着应用体积增长而直线下降。

  • 一些打包器的开发服务器将构建内容存入内存,这样它们只需要在文件更改时使模块图的一部分失活[1],但它也仍需要整个重新构建并重载页面。这样代价很高,并且重新加载页面会消除应用的当前状态,所以打包器支持了动态模块热重载(HMR):允许一个模块 “热替换” 它自己,而对页面其余部分没有影响。这大大改进了开发体验 - 然而,在实践中我们发现,即使是 HMR 更新速度也会随着应用规模的增长而显著下降。


Vite改进



  • 在 Vite 中,HMR 是在原生 ESM 上执行的。当编辑一个文件时,Vite 只需要精确地使已编辑的模块与其最近的 HMR 边界之间的链失效(大多数时候只需要模块本身),使 HMR 更新始终快速,无论应用的大小。

  • Vite 同时利用 HTTP 头来加速整个页面的重新加载(再次让浏览器为我们做更多事情):源码模块的请求会根据 304 Not Modified 进行协商缓存,而依赖模块请求则会通过 Cache-Control: max-age=31536000,immutable 进行强缓存,因此一旦被缓存它们将不需要再次请求。


Vite缺点1:生态,生态,生态不如webpack


wepback牛逼之处在于loader和plugin非常丰富,不过我认为生态只是时间问题,现在的vite,更像是当时刚出来的M1芯片Mac,我当时非常看好M1的Mac,毫不犹豫买了,现在也没什么问题


Vite缺点2:prod环境的构建,目前用的Rollup


原因在于esbuild对于css和代码分割不是很友好


Vite缺点3:还没有被大规模使用,很多问题或者诉求没有真正暴露出来


vite真正崛起那一天,是跟vue3有关系的,当vue3广泛开始使用在生产环境的时候,vite也就大概率意味着被大家慢慢开始接受了


总结


1.Vite,就像刚出来的M1芯片Mac,都说好,但是一开始买的人不多,担心生态问题,后面都说真香


2.相信vue3作者的大力支持下,vite即将大放异彩!


3.但是 Webpack 在现在的前端工程化中仍然扮演着非常重要的角色。


4.vite相关生态没有webpack完善,vite可以作为开发的辅助。



链接:https://juejin.cn/post/6975038104650383374

收起阅读 »

Vue3发布半年我不学,摸鱼爽歪歪,哎~就是玩儿

vue
是从 Vue 2 开始学基础还是直接学 Vue 3 ?尤雨溪给出的答案是:“直接学 Vue 3 就行了,基础概念是一模一样的。” 以上内容源引自最新一期的《程序员》期刊,原文链接为《直接学 Vue 3 吧 —— 对话 Vue.js 作者尤雨溪》。 前言 Vue...
继续阅读 »

是从 Vue 2 开始学基础还是直接学 Vue 3 ?尤雨溪给出的答案是:“直接学 Vue 3 就行了,基础概念是一模一样的。”


以上内容源引自最新一期的《程序员》期刊,原文链接为《直接学 Vue 3 吧 —— 对话 Vue.js 作者尤雨溪》


前言


Vue 3.0 出来之后,我一直在不断的尝试学习和接受新的概念。没办法,作为一个前端开发,并且也不是毕业于名校或就职于大厂,不断地学习,培养学习能力,才是我们这些普通前端开发的核心竞争力。


当然,有些同学抬杠,我专精一门技术,也能开发出自己的核心竞争力。好!!!有志气。但是多数同学,很难有这种意志力。如 CSS 大佬张鑫旭Canva 大佬老姚、可视化大佬月影大大、面试题大佬敖丙等等等等。这些大佬在一件事情上花费的精力,是需要极高的意志力和执行力才能做到的。我反正做不到(逃)。


学无止境!


一定要动手敲代码。仅仅学习而不实践,这种做法也不可取。


本文主要是介绍一些我学习 Vue 3.0 期间,看过的一些比较有用的资源,和大家分享一下,不喜勿喷,喷了我也学着 @尼克陈 顺着网线找到你家。


我与 Vue 3.0


其实一直都有在关注 Vue 3.0 相关的进度和新闻,不过真正学习是在它正式 release 后,2020 年 9 月我也发布了一篇文章《Vue 3.0 来了,我们该做些什么?》阐述了自己的看法,也制定了自己的学习计划。


其实,学习任何一门新技术的步骤都一样:


看文档 → 学习新语法 → 做小 demo → 做几个实战项目 → 看源码 → 整理心得并分享。


学习 Vue 3.0 亦是如此,虽然我这个人比较爱开玩笑,也爱写段子,标题取的也吊儿郎当,但是学习和行动起来我可不比别人差。


学习过程中看文档、做 demo,然后也一直在学习和分享 Vue3 的知识点,比如发布一些 Vue3 的教程:



也做了几个 Vue 3.0 实战的项目练手,之后发布到也开源了 GitHub 中,访问地址如下:



in GitHub : github.com/newbee-ltd


in Gitee : gitee.com/newbee-ltd



一个是 Vue3 版本的商城项目:


img


一个是 Vue3 版本的后台管理项目:


panban1 (1)


源码全部开放,后台 API 也有,都是很实用的项目。目前的反响还不错,得到了很多的正向反馈,这些免费的开源项目让大家有了一个不错的 Vue3 练手项目,顺利的完成了课程作业或者在简历里多了一份项目经验,因此也收到了很多感谢的话。


接下来就是学习过程中,我觉得非常有用的资源了,大家在学习 Vue 3 时可以参考和使用。


image-20210228175425067


Vue 3.0 相关技术栈



















































相关库名称在线地址 🔗
Vue 3.0 官方文档(英文)在线地址
Vue 3.0 中文文档在线地址 国内加速版
Composition-API手册在线地址
Vue 3.0 源码学习在线地址
Vue-Router 官方文档在线地址
Vuex 4.0Github
vue-devtoolsGithub(Vue3.0 需要使用最新版本)
Vite 源码学习线上地址
Vite 2.0 中文文档线上地址
Vue3 新动态线上地址

Vue3 新动态 这个仓库我经常看,里面有最新的 Vue 3 文章、仓库等等,都是中文的,作者应该是咱们的大兄弟,大家也可以关注一下。


更新 Vue 3.0 的开源 UI 组件库


Vue 2.0 时期,产生了不少好的开源组件库,这些组件库伴随着我们的成长,我们看看哪些组件库更新了 Vue 3.0 版本。


Element-plus


简介:大家想必也不陌生,它的 Vue 2.0 版本是 Element-UI,后经坤哥和他的小伙伴开发出了 Vue 3.0 版本的  Element-plus,确实很优秀,目前点赞数快破万了,持续关注。


仓库地址 🏠 :github.com/element-plu… ⭐ : 9.8k


文档地址 📖 :element-plus.gitee.io/#/zh-CN


开源项目 🔗 :



目前 Element-plus 的开源项目还不多,之前 Element-UI 相关开源项目,大大小小都在做 Element-plus 的适配。在此也感谢坤哥和他的小伙伴们,持续 Element 系列的维护,这对 Vue 生态是非常强大的贡献。


Ant Design of Vue


简介:它是最早一批做 Vue 3.0 适配的组件库, Antd 官方推荐的组件库。


仓库地址 🏠 :github.com/vueComponen… ⭐ : 14.8k


文档地址 📖 :antdv.com/docs/vue/in…


开源项目 🔗 :



他们的更新维护还是很积极的,最近一次更新实在 2021 年 2 月 27 号,可见这个组件库还是值得信赖的,有问题可以去 issue 提。


Vant


简介:国内移动端首屈一指的组件库,用过的都说好,个人已经在两个项目中使用过该组件库,也算是比较早支持 Vue 3.0 的框架,该有的都有。


仓库地址 🏠 :github.com/youzan/vant ⭐ : 16.9k


文档地址 📖 :vant-contrib.gitee.io/vant/v3/#/z…


开源项目 🔗 :



NutUI 3


简介:京东团队开发的移动端组件库,近期才升级到 Vue 3.0 版本,文章在此。虽然我没有使用过这个组件库,但是从他们的更新速度来看,比其他很多组件库要快,说明对待最近技术,还是有态度的。


仓库地址 🏠 :github.com/jdf2e/nutui ⭐ : 3.1k


文档地址 📖 :nutui.jd.com (看看这简短的域名,透露出壕的气息)


开源项目 🔗 :基本上还没有见到有公开的开源项目,如果有还望大家积极评论


链接:https://juejin.cn/post/6955129410705948702

收起阅读 »

iOS-使用SDCycleScrollView定制各种自定义样式的上下滚动的跑马灯

SDCycleScrollView的优点及实现技巧:1.利用UICollectionView的复用机制,只会创建屏幕可见个cell。2.如果是无限循环 ,会存在100*self.imagePathsGroup.count个item,第一次出现的位置在(100*...
继续阅读 »

SDCycleScrollView的优点及实现技巧:

1.利用UICollectionView的复用机制,只会创建屏幕可见个cell。
2.如果是无限循环 ,会存在100*self.imagePathsGroup.count个item,第一次出现的位置在(100*self.imagePathsGroup.count)/2的位置。
3.每次滚动到100*self.imagePathsGroup.count位置的item自动切换到(100*self.imagePathsGroup.count)/2的位置。
4.使用取余index % self.imagePathsGroup.count确定现在显示的imageView

缺点:

手动拖拽到最后、不会跳到初始位置

原因:

因为作者设置的100足够大、未对拖拽最后一个item做处理

解决方法:

同时监听NSTimer和拖拽,在(100 - 1)*self.imagePathsGroup.count和self.imagePathsGroup.count位置时实现切换到(100*self.imagePathsGroup.count)/2的位置

使用SDCycleScrollView制作各种自定义样式的上下滚动的跑马灯

效果图:


.m

@interface ViewController () <SDCycleScrollViewDelegate>
@end
@implementation ViewController
{
NSArray *_imagesURLStrings;
SDCycleScrollView *_customCellScrollViewDemo;
}

- (void)customCellScrollView {

// 如果要实现自定义cell的轮播图,必须先实现customCollectionViewCellClassForCycleScrollView:和 setupCustomCell:forIndex:代理方法

_customCellScrollViewDemo = [SDCycleScrollView cycleScrollViewWithFrame:CGRectMake(0, 820, w, 40) delegate:self placeholderImage:[UIImage imageNamed:@"placeholder"]];
_customCellScrollViewDemo.currentPageDotImage = [UIImage imageNamed:@"pageControlCurrentDot"];
_customCellScrollViewDemo.pageDotImage = [UIImage imageNamed:@"pageControlDot"];
_customCellScrollViewDemo.imageURLStringsGroup = imagesURLStrings;
_customCellScrollViewDemo.scrollDirection = UICollectionViewScrollDirectionVertical;
_customCellScrollViewDemo.showPageControl = NO;
[demoContainerView addSubview:_customCellScrollViewDemo];
}

// 不需要自定义轮播cell的请忽略下面的代理方法

// 如果要实现自定义cell的轮播图,必须先实现customCollectionViewCellClassForCycleScrollView:和setupCustomCell:forIndex:代理方法
- (Class)customCollectionViewCellClassForCycleScrollView:(SDCycleScrollView *)view
{
if (view != _customCellScrollViewDemo) {
return nil;
}
return [CustomCollectionViewCell class];
}

- (void)setupCustomCell:(UICollectionViewCell *)cell forIndex:(NSInteger)index cycleScrollView:(SDCycleScrollView *)view
{
CustomCollectionViewCell *myCell = (CustomCollectionViewCell *)cell;
//[myCell.imageView sd_setImageWithURL:_imagesURLStrings[index]];

NSArray *titleArray = @[@"新闻",
@"娱乐",
@"体育"];
NSArray *contentArray = @[@"新闻新闻新闻新闻新闻新闻新闻新闻新闻新闻新闻新闻",
@"娱乐娱乐娱乐娱乐娱乐娱乐娱乐娱乐娱乐娱乐",
@"体育体育体育体育体育体育体育体育体育体育体育体育"];
myCell.titleLabel.text = titleArray[index];
myCell.contentLabel.text = contentArray[index];
}

自定义cell-根据不同的cell定制各种自定义样式的上下滚动的跑马灯

.h
#import <UIKit/UIKit.h>

@interface CustomCollectionViewCell : UICollectionViewCell

@property (nonatomic, strong) UIImageView *imageView;
@property (nonatomic, strong) UILabel *titleLabel;
@property (nonatomic, strong) UILabel *contentLabel;

@end
.m
#import "CustomCollectionViewCell.h"
#import "UIView+SDExtension.h"

@implementation CustomCollectionViewCell

#pragma mark - 懒加载
- (UIImageView *)imageView {
if (!_imageView) {
_imageView = [UIImageView new];
_imageView.layer.borderColor = [[UIColor redColor] CGColor];
_imageView.layer.borderWidth = 0;
_imageView.hidden = YES;
}
return _imageView;
}
- (UILabel *)titleLabel {
if (!_titleLabel) {
_titleLabel = [[UILabel alloc]init];
_titleLabel.text = @"新闻";
_titleLabel.textColor = [UIColor redColor];
_titleLabel.numberOfLines = 0;
_titleLabel.textAlignment = NSTextAlignmentCenter;
_titleLabel.font = [UIFont systemFontOfSize:12];
_titleLabel.backgroundColor = [UIColor yellowColor];
_titleLabel.layer.masksToBounds = YES;
_titleLabel.layer.cornerRadius = 5;
_titleLabel.layer.borderColor = [UIColor redColor].CGColor;
_titleLabel.layer.borderWidth = 1.f;
}
return _titleLabel;
}
- (UILabel *)contentLabel {
if (!_contentLabel) {
_contentLabel = [[UILabel alloc]init];
_contentLabel.text = @"我是label的内容";
_contentLabel.textColor = [UIColor blackColor];
_contentLabel.numberOfLines = 0;
_contentLabel.font = [UIFont systemFontOfSize:12];
}
return _contentLabel;
}
#pragma mark - 页面初始化
- (instancetype)initWithFrame:(CGRect)frame {
if (self = [super initWithFrame:frame]) {
self.contentView.backgroundColor = [UIColor whiteColor];
[self setupViews];
}
return self;
}

#pragma mark - 添加子控件
- (void)setupViews {
[self.contentView addSubview:self.imageView];
[self.contentView addSubview:self.titleLabel];
[self.contentView addSubview:self.contentLabel];
}

#pragma mark - 布局子控件
- (void)layoutSubviews {
[super layoutSubviews];
_imageView.frame = self.bounds;
_titleLabel.frame = CGRectMake(15, 10, 45, 20);
_contentLabel.frame = CGRectMake(15 + 45 + 15, 10, 200, 20);
}

实际情况自己可下载SDCycleScrollView自行研究。。。

转自:https://www.jianshu.com/p/641403879f7b

收起阅读 »

国内知名Wchat团队荣誉出品顶级IM通讯聊天系统

iOS
国内知名Wchat团队荣誉出品顶级IM通讯聊天系统团队言语在先:想低价购买者勿扰(团队是在国内首屈一指的通信公司离职后组建,低价购买者/代码代码贩子者/同行勿扰/)。想购买劣质低等产品者勿扰(行业鱼龙混杂,想购买类似低能协议xmpp者勿扰)。想购买由类似ope...
继续阅读 »



国内知名Wchat团队荣誉出品顶级IM通讯聊天系统



团队言语在先:

想低价购买者勿扰(团队是在国内首屈一指的通信公司离职后组建,低价购买者/代码代码贩子者/同行勿扰/)

。想购买劣质低等产品者勿扰(行业鱼龙混杂,想购买类似低能协议xmpp者勿扰)

。想购买由类似openfire第三方开源改造而来的所谓第三方通信server者勿扰

。想购买没有做任何安全加密场景者勿扰(随便一句api 一个接口就构成了红包收发/转账/密码设置等没有任何安全系数可言的低质产品)

。想购买非运营级别通信系统勿扰(到处呼喊:最稳定/真正可靠/大并发/真正安全!所有一切都需要实际架构支撑以及理论数值测验)

。想购买无保障/无支撑者勿扰(1W/4W/10W低质产品不可谓没有,必须做到:大并发支持合同保障/合作支持运维保障/在线人数支持架构保障)

。想购买消息丢包者勿扰(满天飞的所谓消息确认机制,最简单的测验既是前端支持消息收发demo测试环境,低质产品一秒收发百条消息必丢必崩,

别提秒发千条/万条,更低质产品可测验:同时发九张图片/根据数字12345678910发送出去,必丢!android vs ios)

。想购买大容量群uer者勿扰(随便宣传既是万人大群/几千大群/群组无限,小团队产品群组上线用户超过4000群消息体量不用很大手机前端必卡)

。最重要一点:口口声声说要运营很大的系统 却想出十几个money的人群勿扰,买产品做系统一要稳定二要长久用三要抛开运维烦恼,预算有限那就干脆

别买,买了几万的系统你一样后面用不起来会烂掉!

。产品体系包括:android ios server adminweb maintenance httpapi h5 webpc (支持server压测/前端消息收发压测/httpapi压测)

。。支持源码,但需要您拿去做一个伟大的系统出来!

。。团队产品目前国内没有同质化,客户集中在国外,有求高质量产品的个人或团队可通过以下方式联系到我们(低价者勿扰!)

。。。球球:383189941 q 513275129

。。。。产品不多介绍直接加我 测试产品更直接

。。。。。创新从未停止 更新不会终止 大陆唯一一家支持大并发保障/支持合同费用包含运维支撑的团队 

收起阅读 »

iOS第三方——JazzHands

JazzHands是UIKit一个简单的关键帧基础动画框架。可通过手势、scrollView,kvo或者ReactiveCocoa控制动画。JazzHands很适合用来创建很酷的引导页。Swift中的JazzHands想在Swift中使用Jazz Hands?...
继续阅读 »

JazzHands是UIKit一个简单的关键帧基础动画框架。可通过手势、scrollView,kvo或者ReactiveCocoa控制动画。JazzHands很适合用来创建很酷的引导页。


Swift中的JazzHands

想在Swift中使用Jazz Hands?可以试试RazzleDazzle。

安装

JazzHands可以通过CocoaPods安装,在Podfile中加入如下的一行:

pod "JazzHands"

你也可以把JazzHands文件夹的内容复制到工程中。

快速开始

首先,在UIViewController中加入JazzHands:

#import <IFTTTJazzHands.h>

现在创建一个Animator来管理UIViewController中所有的动画。

@property (nonatomic, strong) IFTTTAnimator *animator;

// later...

self.animator = [IFTTTAnimator new];

为你想要动画的view,创建一个animation。这儿有许多可以应用到view的animation。例如,我们使用IFTTTAlphaAnimation,可以使view淡入淡出。

IFTTTAlphaAnimation *alphaAnimation = [IFTTTAlphaAnimation animationWithView: viewThatYouWantToAnimate];

使用animator注册这个animation。

[self.animator addAnimation: alphaAnimation];

为animation添加一些keyframe关键帧。我们让这个view在times的30和60之间变淡(Let’s fade this view out between times 30 and 60)。

[alphaAnimation addKeyframeForTime:30 alpha:1.f];
[alphaAnimation addKeyframeForTime:60 alpha:0.f];

现在,让view动起来,要让animator知道what time it is。例如,把这个animation和UIScrollView绑定起来,在scroll的代理方法中来通知animator。

- (void)scrollViewDidScroll:(UIScrollView *)scrollView
{
[super scrollViewDidScroll:scrollView];
[self.animator animate:scrollView.contentOffset.x];
}

这样会产生的效果是,view在滚动位置的0到30之间时,view会淡入,变的可见。在滚动位置的30到60之间,view会淡出,变的不可见。而且在滚动位置大于60的时候会保持fade out。

动画的类型

Jazz Hands支持多种动画:

IFTTTAlphaAnimation 动画的是 alpha 属性 (创造的是淡入淡出的效果).
IFTTTRotationAnimation 动画的是旋转变换 (旋转效果).
IFTTTBackgroundColorAnimation 动画的是 backgroundColor 属性.
IFTTTCornerRadiusAnimation 动画的是 layer.cornerRadius 属性.
IFTTTHideAnimation 动画的是 hidden属性 (隐藏和展示view).
IFTTTScaleAnimation 应用一个缩放变换 (缩放尺寸).
IFTTTTranslationAnimation 应用一个平移变换 (平移view的位置).
IFTTTTransform3DAnimation 动画的是 layer.transform 属性 (是3D变换).
IFTTTTextColorAnimation 动画的是UILabel的 textColor 属性。
IFTTTFillColorAnimation 动画的是CAShapeLayer的fillColor属性。
IFTTTStrokeStartAnimation 动画的是CAShapeLayer的strokeStart属性。(does not work with IFTTTStrokeEndAnimation).
IFTTTStrokeEndAnimation 动画的是CAShapeLayer的strokeEnd属性。 (does not work with IFTTTStrokeStartAnimation).
IFTTTPathPositionAnimation 动画的是UIView的layer.position属性。
IFTTTConstraintConstantAnimation animates an AutoLayout constraint constant.
IFTTTConstraintMultiplierAnimation animates an AutoLayout constraint constant as a multiple of an attribute of another view (to offset or resize views based on another view’s size)
IFTTTScrollViewPageConstraintAnimation animates an AutoLayout constraint constant to place a view on a scroll view page (to position views on a scrollView using AutoLayout)
IFTTTFrameAnimation animates the frame property (moves and sizes views. Not compatible with AutoLayout).

更多例子

Easy Paging Scrollview Layouts in an AutoLayout World
JazzHands的IFTTTAnimatedPagingScrollViewController中的 keepView:onPage:方法,可以非常简单的在scroll view上布局分页。

调用keepView:onPages: 可以在多个pages上展示一个view,当其它view滚动的时候。

具体应用的例子

在开源项目coding/Coding-iOS中的IntroductionViewController有使用到,IntroductionViewController继承自IFTTTAnimatedPagingScrollViewController。

- (void)configureTipAndTitleViewAnimations{
for (int index = 0; index < self.numberOfPages; index++) {
NSString *viewKey = [self viewKeyForIndex:index];
UIView *iconView = [self.iconsDict objectForKey:viewKey];
UIView *tipView = [self.tipsDict objectForKey:viewKey];
if (iconView) {
if (index == 0) {//第一个页面
[self keepView:iconView onPages:@[@(index +1), @(index)] atTimes:@[@(index - 1), @(index)]];

[iconView mas_makeConstraints:^(MASConstraintMaker *make) {
make.top.mas_equalTo(kScreen_Height/7);
}];
}else{
[self keepView:iconView onPage:index];

[iconView mas_makeConstraints:^(MASConstraintMaker *make) {
make.centerY.mas_equalTo(-kScreen_Height/6);//位置往上偏移
}];
}
IFTTTAlphaAnimation *iconAlphaAnimation = [IFTTTAlphaAnimation animationWithView:iconView];
[iconAlphaAnimation addKeyframeForTime:index -0.5 alpha:0.f];
[iconAlphaAnimation addKeyframeForTime:index alpha:1.f];
[iconAlphaAnimation addKeyframeForTime:index +0.5 alpha:0.f];
[self.animator addAnimation:iconAlphaAnimation];
}
if (tipView) {
[self keepView:tipView onPages:@[@(index +1), @(index), @(index-1)] atTimes:@[@(index - 1), @(index), @(index + 1)]];

IFTTTAlphaAnimation *tipAlphaAnimation = [IFTTTAlphaAnimation animationWithView:tipView];
[tipAlphaAnimation addKeyframeForTime:index -0.5 alpha:0.f];
[tipAlphaAnimation addKeyframeForTime:index alpha:1.f];
[tipAlphaAnimation addKeyframeForTime:index +0.5 alpha:0.f];
[self.animator addAnimation:tipAlphaAnimation];

[tipView mas_makeConstraints:^(MASConstraintMaker *make) {
make.top.equalTo(iconView.mas_bottom).offset(kScaleFrom_iPhone5_Desgin(45));
}];
}
}
}

效果如下:


转自:https://blog.csdn.net/u014084081/article/details/53610215

收起阅读 »

网易换肤第二篇:本地换肤实现!

完整脑图:https://note.youdao.com/s/V2csJmYS Demo源码:点击下载 技术分析 我们在换肤的第一篇介绍了换肤的核心思想。就是在setContentView()之前调用setFactory2()。 第一篇的Demo利...
继续阅读 »


在这里插入图片描述
完整脑图:https://note.youdao.com/s/V2csJmYS


Demo源码:点击下载


技术分析




我们在换肤的第一篇介绍了换肤的核心思想。就是在setContentView()之前调用setFactory2()


第一篇的Demo利用的是AOP切面方法registerActivityLifecycleCallbacks(xxx)回调在setContentView()之前,从而在registerActivityLifecycleCallbacks的onActivityCreated()方法中设置Factory。如此就能拦截到控件的属性,根据拦截到的控件的属性,重新赋值控件的textColor、background等属性,从而实现换肤的。


本Demo的实现,主要基于以下两个狙击点。



1、super.onCreate(savedInstanceState)方法
2、Activity实现了Factory接口



前面说过,只要在setContentView()之前setFactory2()就行。super.onCreate(savedInstanceState)方法就是在setContentView()方法之前执行的。


一直跟踪super.onCreate(savedInstanceState)方法,最终会发现setFactory的逻辑,如下:


AppCompatDelegateImpl.java(1008)


public void installViewFactory() {
LayoutInflater layoutInflater = LayoutInflater.from(this.mContext);
if (layoutInflater.getFactory() == null) {
LayoutInflaterCompat.setFactory2(layoutInflater, this);
} else if (!(layoutInflater.getFactory2() instanceof AppCompatDelegateImpl)) {
Log.i("AppCompatDelegate", "The Activity's LayoutInflater already has a Factory installed so we can not install AppCompat's");
}
}

它这里传了this,可以预见AppCompatDelegateImpl是实现了Factory接口的,最后会通过AppCompatDelegateImpl自身的onCreateView()方法创建的View。


onCreateView()中如何创建的View的,下面再看源码,先知道是通过AppCompatViewInflater来做控件的具体初始化的。


第一个狙击点可以抽出下图内容:


在这里插入图片描述
细心地同学肯定注意到了AppCompatDelegateImpl的installViewFactory()方法中,只有当layoutInflater.getFactory() == null的时候,才会去setFactory。


也就是说我在super.onCreate(savedInstanceState)之前,先给它setFactory就能走自己Factory的onCreateView()回调。


换肤第一篇中我们是自己去实现Factory2接口,在本例中,就用到了我们第二个狙击点。


Activity实现了Factory接口!!!


在这里插入图片描述


也就是说,只要我们在super.onCreate(savedInstanceState)之前,setFactory的时候,传this,就能走ActivityonCreateView()回调,来对控件属性做操作。


用归纳法,见下图:


在这里插入图片描述
最后,也就剩下Activity的onCreateView()中的回调怎么实现了。


直接模拟super.onCreate(savedInstanceState)中AppCompatViewInflater类中的实现就好了。


在这里插入图片描述
参考代码:


/**
* 自定义控件加载器(可以考虑该类不被继承)
*/

public final class CustomAppCompatViewInflater extends AppCompatViewInflater {

private String name; // 控件名
private Context context; // 上下文
private AttributeSet attrs; // 某控件对应所有属性

public CustomAppCompatViewInflater(@NonNull Context context) {
this.context = context;
}

public void setName(String name) {
this.name = name;
}

public void setAttrs(AttributeSet attrs) {
this.attrs = attrs;
}

/**
* @return 自动匹配控件名,并初始化控件对象
*/

public View autoMatch() {
View view = null;
switch (name) {
case "LinearLayout":
// view = super.createTextView(context, attrs); // 源码写法
view = new SkinnableLinearLayout(context, attrs);
this.verifyNotNull(view, name);
break;
case "RelativeLayout":
view = new SkinnableRelativeLayout(context, attrs);
this.verifyNotNull(view, name);
break;
case "TextView":
view = new SkinnableTextView(context, attrs);
this.verifyNotNull(view, name);
break;
case "ImageView":
view = new SkinnableImageView(context, attrs);
this.verifyNotNull(view, name);
break;
case "Button":
view = new SkinnableButton(context, attrs);
this.verifyNotNull(view, name);
break;
}

return view;
}

/**
* 校验控件不为空(源码方法,由于private修饰,只能复制过来了。为了代码健壮,可有可无)
*
* @param view 被校验控件,如:AppCompatTextView extends TextView(v7兼容包,兼容是重点!!!)
* @param name 控件名,如:"ImageView"
*/

private void verifyNotNull(View view, String name) {
if (view == null) {
throw new IllegalStateException(this.getClass().getName() + " asked to inflate view for <" + name + ">, but returned null");
}
}
}

详细实现就参考Demo吧,思路其实很简单,只是会有对setFactory这块逻辑的流程不了解的。建议跟踪着点几遍源码。





————————————————
版权声明:本文为CSDN博主「csdn小瓯」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/u014158743/article/details/117995256

收起阅读 »

网易换肤第一篇:换肤技术解密!

参考 脑图:https://note.youdao.com/s/Q1e6r39j 最终效果: Demo源码:点击跳转 技术点分析 换肤的核心思路主要是在setContentView()之前调用setFactory2()来收集控件属性,然后在F...
继续阅读 »


参考




脑图:https://note.youdao.com/s/Q1e6r39j


最终效果:
在这里插入图片描述
Demo源码:点击跳转


技术点分析




换肤的核心思路主要是在setContentView()之前调用setFactory2()来收集控件属性,然后在Factory的onCreateView()中利用收集到的属性来创建view。


不懂?没事,往下看。


在这里插入图片描述
弄明白换肤技术的实现之前,得有上图这几个知识储备。


首先得知道控件是在setContentView()方法中通过XmlPullParser解析我们在xml中定义的控件,然后显示在界面上的


LayoutInflater.java(451,注:本文源码为安卓9.0,api 28,下同


public View inflate(XmlPullParser parser, @Nullable ViewGroup root, boolean attachToRoot) {
...
if (TAG_MERGE.equals(name)) {
...
rInflate(parser, root, inflaterContext, attrs, false);
} else {
// Temp is the root view that was found in the xml
final View temp = createViewFromTag(root, name, inflaterContext, attrs);
...
}
...

而且在createViewFromTag()方法中,有一个判断:当mFactory2 != null的时候,就会把从xml中解析到的属性等传给mFactory2.onCreateView(parent, name, context, attrs)方法,利用mFactory2来创建view。


先看源码片段:
LayoutInflater.java(748)


View createViewFromTag(View parent, String name, Context context, AttributeSet attrs,
boolean ignoreThemeAttr) {
...

try {
View view;
if (mFactory2 != null) {
view = mFactory2.onCreateView(parent, name, context, attrs);
} else if (mFactory != null) {
view = mFactory.onCreateView(name, context, attrs);
} else {
view = null;
}

if (view == null && mPrivateFactory != null) {
view = mPrivateFactory.onCreateView(parent, name, context, attrs);
}

if (view == null) {
final Object lastContext = mConstructorArgs[0];
mConstructorArgs[0] = context;
try {
if (-1 == name.indexOf('.')) {
view = onCreateView(parent, name, attrs);
} else {
view = createView(name, null, attrs);
}
} finally {
mConstructorArgs[0] = lastContext;
}
}

return view;
} catch (InflateException e) {
...
}
}

所以,我们只要通过LayoutlnflaterCompat.setFactory2(xx, yу)设置了Factory,就可以拦截到所有控件及其在xml中定义的属性了。


如此一来,问题就变成了如何在setContentView(R.layout.xxx)之前setFactory2()


答案就是利用AOP方法切面:registerActivityLifecycleCallbacks(xxx)ActivityLifecycleCallbacksonActivityCreated()方法正是在setContentView(R.layout.xxx)之前执行。


所以,我们可以实现Application.ActivityLifecycleCallbacks,然后在onActivityCreated()方法中LayoutInflaterCompat.setFactory2(xx, yy),这样换肤技术的核心部分,就被我们突破了。


参考代码:


public class SkinActivityLifecycleCallbacks implements Application.ActivityLifecycleCallbacks {
...
@Override
public void onActivityCreated(Activity activity, Bundle savedInstanceState) {
...

skinFactory = new SkinFactory(activity);
// mFactorySet = true是无法设置成功的(源码312行)
LayoutInflaterCompat.setFactory2(layoutInflater, skinFactory);

// 注册观察者(监听用户操作,点击了换肤,通知观察者更新)
SkinEngine.getInstance().addObserver(skinFactory);
}

...
}





————————————————
版权声明:本文为CSDN博主「csdn小瓯」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/u014158743/article/details/117921500

收起阅读 »

带着问题学,协程到底是什么?

前言 随着kotlin在Android开发领域越来越火,协程在各个项目中的应用也逐渐变得广泛 但是协程到底是什么呢? 协程其实是个古老的概念,已经非常成熟了,但大家对它的概念一直存在各种疑问,众说纷纷 有人说协程是轻量级的线程,也有人说kotlin协程其...
继续阅读 »



前言


随着kotlinAndroid开发领域越来越火,协程在各个项目中的应用也逐渐变得广泛


但是协程到底是什么呢?


协程其实是个古老的概念,已经非常成熟了,但大家对它的概念一直存在各种疑问,众说纷纷
有人说协程是轻量级的线程,也有人说kotlin协程其实本质是一套线程切换方案


显然这对初学者不太友好,当不清楚一个东西是什么的时候,就很难进入为什么怎么办的阶段了
本文主要就是回答这个问题,主要包括以下内容
1.关于协程的一些前置知识
2.协程到底是什么?
3.kotlin协程的一些基本概念,挂起函数,CPS转换,状态机等
以上问题总结为思维导图如下:



1. 关于协程的一些前置知识


为了了解协程,我们可以从以下几个切入点出发
1.什么是进程?为什么要有进程?
2.什么是线程?为什么要有线程?进程和线程有什么区别?
3.什么是协作式,什么是抢占式?
4.为什么要引入协程?是为了解决什么问题?


1.1 什么是进程?


我们在背进程的定义的时候,可能会经常看到一句话



进程是资源分配的最小单位



这个资源分配怎么理解呢?


在单核CPU中,同一时刻只有一个程序在内存中被CPU调用运行



假设有AB两个程序,A正在运行,此时需要读取大量输入数据(IO操作),那么CPU只能干等,直到A数据读取完毕,再继续往下执行,A执行完,再去执行程序B,白白浪费CPU资源。



这种方式会浪费CPU资源,我们可能更想要下面这种方式



当程序A读取数据的时,切换 到程序B去执行,当A读取完数据,让程序B暂停,切换 回程序A执行?



在计算机里 切换 这个名词被细分为两种状态:



挂起:保存程序的当前状态,暂停当前程序; 激活:恢复程序状态,继续执行程序;



这种切换,涉及到了 程序状态的保存和恢复,而且程序AB所需的系统资源(内存、硬盘等)是不一样的,那还需要一个东西来记录程序AB各自需要什么资源,还有系统控制程序AB切换,要一个标志来识别等等,所以就有了一个叫 进程的抽象。


1.1.1 进程的定义


进程是一个具有一定独立功能的程序在一个数据集上的一次动态执行的过程,是操作系统进行资源分配和调度的一个独立单位,是应用程序运行的载体主要由以下三部分组成:


1.程序:描述进程要完成的功能及如何完成;
2.数据集:程序在执行过程中所需的资源;
3.进程控制块:记录进程的外部特征,描述执行变化过程,系统利用它来控制、管理进程,系统感知进程存在的唯一标志。


1.1.2 为什么要有进程


其实上文我们已经分析过了,操作系统之所以要支持多进程,是为了提高CPU的利用率
而为了切换进程,需要进程支持挂起恢复,不同进程间需要的资源不同,所以这也是为什么进程间资源需要隔离,这也是进程是资源分配的最小单位的原因


1.2 什么是线程?


1.2.1 线程的定义


轻量级的进程,基本的CPU执行单元,亦是 程序执行过程中的最小单元,由 线程ID程序计数器寄存器组合堆栈 共同组成。
线程的引入减小了程序并发执行时的开销,提高了操作系统的并发性能。


1.2.2 为什么要有线程?


这个问题也很好理解,进程的出现使得多个程序得以 并发 执行,提高了系统效率及资源利用率,但存在下述问题:




  1. 单个进程只能干一件事,进程中的代码依旧是串行执行。

  2. 执行过程如果堵塞,整个进程就会挂起,即使进程中某些工作不依赖于正在等待的资源,也不会执行。

  3. 多个进程间的内存无法共享,进程间通讯比较麻烦。



线程的出现是为了降低上下文切换消耗,提高系统的并发性,并突破一个进程只能干一件事的缺陷,使得进程内并发成为可能。


1.2.3 进程与线程的区别



  • 1.一个程序至少有一个进程,一个进程至少有一个线程,可以把进程理解做 线程的容器;

  • 2.进程在执行过程中拥有 独立的内存单元,该进程里的多个线程 共享内存;

  • 3.进程可以拓展到 多机,线程最多适合 多核;

  • 4.每个独立线程有一个程序运行的入口、顺序执行列和程序出口,但不能独立运行,需依存于应用程序中,由应用程序提供多个线程执行控制;

  • 5.「进程」是「资源分配」的最小单位,「线程」是 「CPU调度」的最小单位

  • 6.进程和线程都是一个时间段的描述,是 CPU工作时间段的描述,只是颗粒大小不同。


1.3 协作式 & 抢占式


单核CPU,同一时刻只有一个进程在执行,这么多进程,CPU的时间片该如何分配呢?


1.3.1 协作式多任务


早期的操作系统采用的就是协作时多任务,即:由进程主动让出执行权,如当前进程需等待IO操作,主动让出CPU,由系统调度下一个进程。
每个进程都循规蹈矩,该让出CPU就让出CPU,是挺和谐的,但也存在一个隐患:单个进程可以完全霸占CPU


计算机中的进程良莠不齐,先不说那种居心叵测的进程了,如果是健壮性比较差的进程,运行中途发生了死循环、死锁等,会导致整个系统陷入瘫痪!
在这种鱼龙混杂的大环境下,把执行权托付给进程自身,肯定是不科学的,于是由操作系统控制的抢占式多任务横空出世


1.3.2 抢占式多任务


由操作系统决定执行权,操作系统具有从任何一个进程取走控制权和使另一个进程获得控制权的能力。
系统公平合理地为每个进程分配时间片,进程用完就休眠,甚至时间片没用完,但有更紧急的事件要优先执行,也会强制让进程休眠。
这就是所谓的时间片轮转调度



时间片轮转调度是一种最古老,最简单,最公平且使用最广的算法。每个进程被分配一个时间段,称作它的时间片,即该进程允许运行的时间。
如果在时间片结束时进程还在运行,则CPU将被剥夺并分配给另一个进程。如果进程在时间片结束前阻塞或结束,则CPU当即进行切换。调度程序所要做的就是维护一张就绪进程列表,当进程用完它的时间片后,它被移到队列的末尾。



有了进程设计的经验,线程也做成了抢占式多任务,但也带来了新的——线程安全问题,这个一般通过加锁的方式来解决,这里就不缀述了。


1.4 为什么要引入协程?


上面介绍进程与线程的时候也提到了,之所以引入进程与线程是为了异步并发的执行任务,提高系统效率及资源利用率
但作为Java开发者,我们很清楚线程并发是多么的危险,写出来的异步代码是多么的难以维护。


Java中,我们一般通过回调来处理异步任务,但是当异步任务嵌套时,往往程序就会变得很复杂与难维护


举个例子,当我们需要完成这样一个需求:查询用户信息 --> 查找该用户的好友列表 --> 查找该好友的动态
看一下Java回调的代码


getUserInfo(new CallBack() {
@Override
public void onSuccess(String user) {
if (user != null) {
System.out.println(user);
getFriendList(user, new CallBack() {
@Override
public void onSuccess(String friendList) {
if (friendList != null) {
System.out.println(friendList);
getFeedList(friendList, new CallBack() {
@Override
public void onSuccess(String feed) {
if (feed != null) {
System.out.println(feed);
}
}
});
}
}
});
}
}
});

这就是传说中的回调地狱,如果用kotlin协程实现同样的需求呢?


val user = getUserInfo()
val friendList = getFriendList(user)
val feedList = getFeedList(friendList)

相比之下,可以说是非常简洁了


Kotlin 协程的核心竞争力在于:它能简化异步并发任务,以同步方式写异步代码
这也是为什么要引入协程的原因了:简化异步并发任务


2.到底什么是协程


2.1 什么是协程?


一种非抢占式(协作式)的任务调度模式,程序可以主动挂起或者恢复执行。


2.2 协程与线程的区别是什么?


协程基于线程,但相对于线程轻量很多,可理解为在用户层模拟线程操作;每创建一个协程,都有一个内核态进程动态绑定,用户态下实现调度、切换,真正执行任务的还是内核线程。


线程的上下文切换都需要内核参与,而协程的上下文切换,完全由用户去控制,避免了大量的中断参与,减少了线程上下文切换与调度消耗的资源。


线程是操作系统层面的概念,协程是语言层面的概念


线程与协程最大的区别在于:线程是被动挂起恢复,协程是主动挂起恢复


2.3 协程可以怎样分类?


根据 是否开辟相应的函数调用栈 又分成两类:



  • 有栈协程:有自己的调用栈,可在任意函数调用层级挂起,并转移调度权;

  • 无栈协程:没有自己的调用栈,挂起点的状态通过状态机或闭包等语法来实现;


2.4 Kotlin中的协程是什么?


"假"协程,Kotlin在语言级别并没有实现一种同步机制(锁),还是依靠Kotlin-JVM的提供的Java关键字(如synchronized),即锁的实现还是交给线程处理
因而Kotlin协程本质上只是一套基于原生Java线程池 的封装。


Kotlin 协程的核心竞争力在于:它能简化异步并发任务,以同步方式写异步代码。
下面介绍一些kotin协程中的基本概念


3. 什么是挂起函数?


我们知道使用suspend关键字修饰的函数叫做挂起函数,挂起函数只能在协程体内或者其他挂起函数内使用.


协程内部挂起函数的调用处被称为挂起点,挂起点如果出现异步调用,那么当前协程就被挂起,直到对应的Continuationresume函数被调用才会恢复执行


我们下面来看看挂起函数具体执行的细节



可以看出kotlin协程可以做到一行代码切换线程
这些是怎么做到的呢,主要是通过suspend关键字


3.1 什么是suspend


suspend 的本质,就是 CallBack


suspend fun getUserInfo(): String {
withContext(Dispatchers.IO) {
delay(1000L)
}
return "BoyCoder"
}

不过当我们写挂起函数的时候,并没有写callback,所谓的callback从何而来呢?
我们看下反编译的结果


//                              Continuation 等价于 CallBack
// ↓
public static final Object getUserInfo(Continuation $completion) {
...
return "BoyCoder";
}

public interface Continuation<in T> {
public val context: CoroutineContext
// 相当于 onSuccess 结果
// ↓ ↓
public fun resumeWith(result: Result<T>)
}
复制代码

可以看出


1.编译器会给挂起函数添加一个Continuation参数,这被称为CPS 转换(Continuation-Passing-Style Transformation)
2.suspend函数不能在协程体外调用的原因也可以知道了,就是因为这个Continuation实例的传递


4. 什么是CPS转换


下面用动画演示挂起函数在 CPS 转换过程中,函数签名的变化:


可以看出主要有两点变化
1.增加了Continuation类型的参数
2.返回类型从String转变成了Any


参数的变化我们之前讲过,为什么返回值要变呢?


4.1 挂起函数返回值


挂起函数经过 CPS 转换后,它的返回值有一个重要作用:标志该挂起函数有没有被挂起。
听起来有点奇怪,挂起函数还会不挂起吗?



只要被suspend修饰的函数都是挂起函数,但是不是所有挂起函数都会被挂起
只有当挂起函数里包含异步操作时,它才会被真正挂起



由于 suspend 修饰的函数,既可能返回 CoroutineSingletons.COROUTINE_SUSPENDED,表示挂起
也可能返回同步运行的结果,甚至可能返回 null为了适配所有的可能性,CPS 转换后的函数返回值类型就只能是 Any?了。


4.2 小结


1.suspend修饰的函数就是挂起函数
2.挂起函数,在执行的时候并不一定都会挂起
3.挂起函数只能在其他挂起函数中被调用
4.挂起函数里包含异步操作的时候,它才会真正被挂起


5. Continuation是什么?


Continuation词源是continue,也就是继续,接下来要做的事的意思
放到程序中Continuation则代表了,接下来要执行的代码
以上面的代码为例,当程序运行 getUserInfo() 的时候,它的 Continuation则是下图红框的代码:


Continuation 就是接下来要运行的代码,剩余未执行的代码
理解了 Continuation,以后,CPS就容易理解了,它其实就是:将程序接下来要执行的代码进行传递的一种模式


CPS 转换,就是将原本的同步挂起函数转换成CallBack 异步代码的过程。
这个转换是编译器在背后做的,我们程序员对此无感知。


当然有人会问,这么简单粗暴?三个挂起函数最终变成三个 Callback 吗?
当然不是,思想仍然是CPS的思想,不过需要结合状态机
CPS状态机就是协程实现的核心


6. 状态机


kotlin协程的实现依赖于状态机
想要查看其实现,可以将kotin源码反编译成字节码来查看编译后的代码
关于字节码的分析之前已经有很多人做过了,而且做的很好。下面给出状态机的演示。



  1. 协程实现的核心就是CPS变换与状态机

  2. 协程执行到挂起函数,一个函数如果被挂起了,它的返回值会是:CoroutineSingletons.COROUTINE_SUSPENDED

  3. 挂起函数执行完成后,通过Continuation.resume方法回调,这里的Continuation是通过CPS传入的

  4. 传入的Continuation实际上是ContinuationImpl,resume方法最后会再次回到invokeSuspend方法中

  5. invokeSuspend方法即是我们写的代码执行的地方,在协程运行过程中会执行多次

  6. invokeSuspend中通过状态机实现状态的流转

  7. continuation.label 是状态流转的关键,label改变一次代表协程发生了一次挂起恢复

  8. 通过break label实现goTo的跳转效果

  9. 我们写在协程里的代码,被拆分到状态机里各个状态中,分开执行

  10. 每次协程切换后,都会检查是否发生异常

  11. 切换协程之前,状态机会把之前的结果以成员变量的方式保存在 continuation 中。


以上是状态机流转的大概流程,读者可跟着参考链接,过一下编译后的字节码执行流程后,再来判断这个流程是否正确


7. CoroutineContext是什么?


我们上面说了Continuation是继续要执行的代码,在实现上它也是一个接口


public interface Continuation<in T> {
public val context: CoroutineContext
public fun resumeWith(result: Result<T>)
}

1.Continuation主要由两部分组成,一个context,一个resumeWith方法
2.通过resumeWith方法执行接下去的代码
3.通过context获取上下文资源,保存挂起时的一些状态与资源



CoroutineContext即上下文,主要承载了资源获取,配置管理等工作,是执行环境相关的通用数据资源的统一提供者



CoroutineContext是一个特殊的集合,这个集合它既有Map的特点,也有Set的特点


集合的每一个元素都是Element,每个Element都有一个Key与之对应,对于相同KeyElement是不可以重复存在的Element之间可以通过+号组合起来,Element有几个子类,CoroutineContext也主要由这几个子类组成:



  • Job:协程的唯一标识,用来控制协程的生命周期(newactivecompletingcompletedcancellingcancelled);

  • CoroutineDispatcher:指定协程运行的线程(IODefaultMainUnconfined);

  • CoroutineName: 指定协程的名称,默认为coroutine;

  • CoroutineExceptionHandler: 指定协程的异常处理器,用来处理未捕获的异常.


7.1 CoroutineContext的数据结构


先来看看CoroutineContext的全家福


public interface CoroutineContext {

//操作符[]重载,可以通过CoroutineContext[Key]这种形式来获取与Key关联的Element
public operator fun <E : Element> get(key: Key<E>): E?

//它是一个聚集函数,提供了从left到right遍历CoroutineContext中每一个Element的能力,并对每一个Element做operation操作
public fun <R> fold(initial: R, operation: (R, Element) -> R): R

//操作符+重载,可以CoroutineContext + CoroutineContext这种形式把两个CoroutineContext合并成一个
public operator fun plus(context: CoroutineContext): CoroutineContext

//返回一个新的CoroutineContext,这个CoroutineContext删除了Key对应的Element
public fun minusKey(key: Key<*>): CoroutineContext

//Key定义,空实现,仅仅做一个标识
public interface Key<E : Element>

//Element定义,每个Element都是一个CoroutineContext
public interface Element : CoroutineContext {

//每个Element都有一个Key实例
public val key: Key<*>

//...
}
}

1.CoroutineContext内主要存储的就是Element,可以通过类似map[key] 来取值


2.Element也实现了CoroutineContext接口,这看起来很奇怪,为什么元素本身也是集合呢?主要是为了API设计方便,Element内只会存放自己


3.除了plus方法,CoroutineContext中的其他三个方法都被CombinedContextElementEmptyCoroutineContext重写


4.CombinedContext就是CoroutineContext集合结构的实现,它里面是一个递归定义,Element就是CombinedContext中的元素,而EmptyCoroutineContext就表示一个空的CoroutineContext,它里面是空实现


7.2 为什么CoroutineContext可以通过+号连接


CoroutineContext能通过+号连接,主要是因为重写了plus方法
当通过+号连接时,实际上是包装到了CombinedContext中,并指向上一个Context


如上所示,是一个单链表结构,在获取时也是通过这种方式去查询对应的key,操作大体逻辑都是先访问当前element,不满足,再访问leftelement,顺序都是从rightleft


最近我整理一些Android 开发相关的学习文档、面试题,希望能帮助到大家学习提升,如有需要参考的可以点击链接领取**点击这里免费领取点击这里免费领取





————————————————
版权声明:本文为CSDN博主「码农 小生」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/m0_58350991/article/details/117933297

收起阅读 »

Android tess_two Android图片文字识别

ocr
先看效果图 我主要是识别截图,所以图片比较规范,识别率应该很高。 简介什么都不说了,直接看简单的用法吧 首先肯定是引入依赖了 dependencies { compile 'com.rmtheis:tess-two:6.2.0' } 简单的用法...
继续阅读 »


先看效果图


我主要是识别截图,所以图片比较规范,识别率应该很高。


简介什么都不说了,直接看简单的用法吧


首先肯定是引入依赖了


dependencies {
compile 'com.rmtheis:tess-two:6.2.0'
}

简单的用法其实就几行代码:


TessBaseAPI tessBaseAPI = new TessBaseAPI();
tessBaseAPI.init(DATAPATH, DEFAULT_LANGUAGE);//参数后面有说明。
tessBaseAPI.setImage(bitmap);
String text = tessBaseAPI.getUTF8Text();

就这样简单的把一个bitmap设置进去,就能识别到里面的文字并输出了。
但是真正用的时候还是遇到了点麻烦,虽然只是简单的识别。
主要是tessBaseAPI.init(DATAPATH, DEFAULT_LANGUAGE)这个方法容易出错。
先看一下这个方法的源码吧:


public boolean init(String datapath, String language) {
return init(datapath, language, OEM_DEFAULT);
}
/**
* Initializes the Tesseract engine with the specified language model(s). Returns
* true on success.
*
* @see #init(String, String)
*
* @param datapath the parent directory of tessdata ending in a forward
* slash
* @param language an ISO 639-3 string representing the language(s)
* @param ocrEngineMode the OCR engine mode to be set
* @return true on success
*/

public boolean init(String datapath, String language, int ocrEngineMode) {
if (datapath == null)
throw new IllegalArgumentException("Data path must not be null!");
if (!datapath.endsWith(File.separator))
datapath += File.separator;

File datapathFile = new File(datapath);
if (!datapathFile.exists())
throw new IllegalArgumentException("Data path does not exist!");

File tessdata = new File(datapath + "tessdata");
if (!tessdata.exists() || !tessdata.isDirectory())
throw new IllegalArgumentException("Data path must contain subfolder tessdata!");

//noinspection deprecation
if (ocrEngineMode != OEM_CUBE_ONLY) {
for (String languageCode : language.split("\\+")) {
if (!languageCode.startsWith("~")) {
File datafile = new File(tessdata + File.separator +
languageCode + ".traineddata");
if (!datafile.exists())
throw new IllegalArgumentException("Data file not found at " + datafile);
}
}
}

boolean success = nativeInitOem(mNativeData, datapath, language, ocrEngineMode);

if (success) {
mRecycled = false;
}

return success;
}

注意


从下面的方法中抛出的几个异常可以看出来,初始化的时候,第一个参数是个文件夹,而且这个文件夹中必须有一个tessdata的文件夹;而且这个文件夹中要有个文件叫做 第二个参数.traineddata 。具体的可以看下面代码里的注释。这些文件夹和文件没有的一定要创建好,不然会报错。


第二个参数.traineddata 是个什么文件呢?
这个是识别用到的语言库还是文字库什么的,按那个初始化方法的意思是哟啊放到SD卡中的。可以在下面的地址下载。我的demo里把这个文件放在了assets中,启动的时候复制到内存卡里。
https://github.com/tesseract-ocr/tessdata


chi_sim.traineddata应该是健体中文吧,我用的是这个。中英文都能识别。


代码


下面是主要代码:


import android.Manifest;
import android.content.pm.PackageManager;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.os.Build;
import android.os.Bundle;
import android.os.Environment;
import android.support.annotation.NonNull;
import android.support.v7.app.AppCompatActivity;
import android.util.Log;
import android.view.View;
import android.widget.Button;
import android.widget.TextView;

import com.googlecode.tesseract.android.TessBaseAPI;

import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;

public class MainActivity extends AppCompatActivity {

private static final String TAG = "MainActivity";
private Button btn;
private TextView tv;

/**
* TessBaseAPI初始化用到的第一个参数,是个目录。
*/

private static final String DATAPATH = Environment.getExternalStorageDirectory().getAbsolutePath() + File.separator;
/**
* 在DATAPATH中新建这个目录,TessBaseAPI初始化要求必须有这个目录。
*/

private static final String tessdata = DATAPATH + File.separator + "tessdata";
/**
* TessBaseAPI初始化测第二个参数,就是识别库的名字不要后缀名。
*/

private static final String DEFAULT_LANGUAGE = "chi_sim";
/**
* assets中的文件名
*/

private static final String DEFAULT_LANGUAGE_NAME = DEFAULT_LANGUAGE + ".traineddata";
/**
* 保存到SD卡中的完整文件名
*/

private static final String LANGUAGE_PATH = tessdata + File.separator + DEFAULT_LANGUAGE_NAME;

/**
* 权限请求值
*/

private static final int PERMISSION_REQUEST_CODE=0;

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
btn = (Button) findViewById(R.id.btn);
tv = (TextView) findViewById(R.id.tv);

if (Build.VERSION.SDK_INT >= 23) {
if (checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED ||
checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
requestPermissions(new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE, Manifest.permission.READ_EXTERNAL_STORAGE}, PERMISSION_REQUEST_CODE);
}
}

//Android6.0之前安装时就能复制,6.0之后要先请求权限,所以6.0以上的这个方法无用。
copyToSD(LANGUAGE_PATH, DEFAULT_LANGUAGE_NAME);

btn.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
new Thread(new Runnable() {
@Override
public void run() {
Log.i(TAG, "run: kaishi " + System.currentTimeMillis());

Bitmap bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.quanbu);
Log.i(TAG, "run: bitmap " + System.currentTimeMillis());

TessBaseAPI tessBaseAPI = new TessBaseAPI();

tessBaseAPI.init(DATAPATH, DEFAULT_LANGUAGE);

tessBaseAPI.setImage(bitmap);
final String text = tessBaseAPI.getUTF8Text();
Log.i(TAG, "run: text " + System.currentTimeMillis() + text);
runOnUiThread(new Runnable() {
@Override
public void run() {
tv.setText(text);
}
});

tessBaseAPI.end();
}
}).start();
}
});

}

/**
* 将assets中的识别库复制到SD卡中
* @param path 要存放在SD卡中的 完整的文件名。这里是"/storage/emulated/0//tessdata/chi_sim.traineddata"
* @param name assets中的文件名 这里是 "chi_sim.traineddata"
*/

public void copyToSD(String path, String name) {
Log.i(TAG, "copyToSD: "+path);
Log.i(TAG, "copyToSD: "+name);

//如果存在就删掉
File f = new File(path);
if (f.exists()){
f.delete();
}
if (!f.exists()){
File p = new File(f.getParent());
if (!p.exists()){
p.mkdirs();
}
try {
f.createNewFile();
} catch (IOException e) {
e.printStackTrace();
}
}

InputStream is=null;
OutputStream os=null;
try {
is = this.getAssets().open(name);
File file = new File(path);
os = new FileOutputStream(file);
byte[] bytes = new byte[2048];
int len = 0;
while ((len = is.read(bytes)) != -1) {
os.write(bytes, 0, len);
}
os.flush();
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
if (is != null)
is.close();
if (os != null)
os.close();
} catch (IOException e) {
e.printStackTrace();
}
}

}

/**
* 请求到权限后在这里复制识别库
* @param requestCode
* @param permissions
* @param grantResults
*/

@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
Log.i(TAG, "onRequestPermissionsResult: "+grantResults[0]);
switch (requestCode){
case PERMISSION_REQUEST_CODE:
if (grantResults.length>0&&grantResults[0]==PackageManager.PERMISSION_GRANTED){
Log.i(TAG, "onRequestPermissionsResult: copy");
copyToSD(LANGUAGE_PATH, DEFAULT_LANGUAGE_NAME);
}
break;
default:
break;
}
}
}



GitHub:https://github.com/rmtheis/tess-two

Demo的GitHub地址:https://github.com/wangyisll/TessTwoDemo

下载地址:tess-two

收起阅读 »

Android 注解知多少

注解的概念什么是注解?注解又称为标注,用于为代码提供元数据。 作为元数据,注解不直接影响你的代码执行,但也有一些类型的注解实际上可以用于这一目的。可以作用在类、方法、变量、参数和包等上。 你可以通俗的理解成“标签”,这个标签可以标记类、方法、变量、参数和包。什...
继续阅读 »

注解的概念

什么是注解?

注解又称为标注,用于为代码提供元数据。 作为元数据,注解不直接影响你的代码执行,但也有一些类型的注解实际上可以用于这一目的。可以作用在类、方法、变量、参数和包等上。 你可以通俗的理解成“标签”,这个标签可以标记类、方法、变量、参数和包。

什么用处?

  1. 生成文档;
  2. 标识代码,便于查看;
  3. 格式检查(编译时);
  4. 注解处理(编译期生成代码、xml文件等;运行期反射解析;常用于三方框架)。

分类

  1. 元注解

元注解是用于定义注解的注解,元注解也是 Java 自带的标准注解,只不过用于修饰注解,比较特殊。 2. 内置的标准注解 就是用在代码上的注解,不同的语言或环境提供有不同的注解(Java Kotlin Android)。使用这些注解后编译器就会进行检查。 3. 自定义注解 用户可以根据自己的需求定义注解。

标准的注解讲解

Java 的标准注解

元注解在后面讲解自定义注解时再一起介绍,这里只先介绍标准注解。

名称描述
@Override检查该方法是否正确地重写了父类的方法。如果重写错误,会报编译错误;
@Deprecated标记过时方法。如果使用该方法,会报编译警告;
@SuppressWarnings指示编译器去忽略注解中声明的警告;
@SafeVarargs忽略任何使用参数为泛型变量的方法或构造函数调用产生的警告;(Java 7 开始支持)
@FunctionalInterface标识一个匿名函数或函数式接口(Java 8 开始支持)

Android 注解库

support.annotation 是 Android 提供的注解库,与 Android Studio 内置的代码检查工具配合,注解可以帮助检测可能发生的问题,例如 null 指针异常和资源类型冲突等。

使用前配置 在 Module 的 build.gradle 中添加配置:

implementation 'com.android.support:support-annotations:版本号'

注意:如果您使用 appcompat 库,则无需添加 support-annotations 依赖项。因为 appcompat 库已经依赖注解库。(一般创建项目时已自动导入)

Null 性注解

  • @Nullable 可以为 null
  • @NonNull 不可为 null

用于给定变量、参数或返回值是否可以为 null 。

import android.support.annotation.NonNull;
...
@NonNull // 检查 onCreateView() 方法本身是否会返回 null。
@Override
public View onCreateView(String name, @NonNull Context context,
@NonNull AttributeSet attrs) {
...
}
...

资源注解

验证资源类型时非常有用,因为 Android 对资源的引用以整型形式传递。如果代码需要一个参数来引用特定类型的资源,可以为该代码传递预期的引用类型 int,但它实际上会引用其他类型的资源,如 R.string.xxx 资源。

Android 中的资源类型有很多,Android 注解为每种资源类型都提供了相对应的注解。

  • AnimatorRes //动画资源(一般为属性动画)
  • AnimRes //动画资源(一般为视图动画)
  • AnyRes //任何类型的资源引用,int 格式
  • ArrayRes //数组资源 e.g. android.R.array.phoneTypes
  • AttrRes //属性资源 e.g. android.R.attr.action
  • BoolRes //布尔资源
  • ColorRes //颜色资源
  • DimenRes //尺寸资源
  • DrawableRes //可绘制资源
  • FontRes //字体资源
  • FractionRes //百分比数字资源
  • IdRes //Id 引用
  • IntegerRes //任意整数类型资源引用
  • InterpolatorRes //插值器资源 e.g. android.R.interpolator.cycle
  • LayoutRes //布局资源
  • MenuRes //菜单资源
  • NavigationRes //导航资源
  • PluralsRes //字符串集合资源
  • RawRes //Raw 资源
  • StringRes //字符串资源
  • StyleableRes //样式资源
  • StyleRes //样式资源
  • TransitionRes //转场动画资源
  • XmlRes //xml 资源
  • 使用 @AnyRes 可以指明添加了此类注解的参数可以是任何类型的 R 资源。
  • 尽管可以使用 @ColorRes 指定某个参数应为颜色资源,但系统不会将颜色整数(采用 RRGGBB 或 AARRGGBB 格式)识别为颜色资源。您可以改用 @ColorInt 注解来指明某个参数必须为颜色整数。

线程注解

线程注解可以检查某个方法是否从特定类型的线程调用。支持以下线程注解:

  • @MainThread
  • @UiThread
  • @WorkerThread
  • @BinderThread
  • @AnyThread

注意:构建工具会将 @MainThread 和 @UiThread 注解视为可互换,因此您可以从 @MainThread 方法调用 @UiThread 方法,反之亦然。不过,如果系统应用有多个视图在不同的线程上,那么界面线程可能会与主线程不同。因此,您应使用 @UiThread 为与应用的视图层次结构关联的方法添加注解,并使用 @MainThread 仅为与应用生命周期关联的方法添加注解。

如果某个类中的所有方法具有相同的线程要求,您可以为该类添加一个线程注解,以验证该类中的所有方法是否从同一类型的线程调用。

值约束注解

值约束注解可以验证所传递参数的值是否在指定范围内:

  • @IntRange
  • @FloatRange
  • @Size

@IntRange 和 @FloatRange 在应用到用户可能会弄错范围的参数时最为有用。

// 确保 alpha 参数是包含 0 到 255 之间的整数值
public void setAlpha(@IntRange(from=0,to=255) int alpha) { ... }

// 确保 alpha 参数是包含 0.0 到 1.0 之间的浮点值
public void setAlpha(@FloatRange(from=0.0, to=1.0) float alpha) {...}

@Size 注解可以检查集合或数组的大小,以及字符串的长度。@Size 注解可用于验证以下特性:

  • 最小大小(例如 @Size(min=2)
  • 最大大小(例如 @Size(max=2)
  • 确切大小(例如 @Size(2)
  • 大小必须是指定数字的倍数(例如 @Size(multiple=2)

例如,@Size(min=1) 可以检查某个集合是否不为空,@Size(3) 可以验证某个数组是否正好包含三个值。

// 确保 location 数组至少包含一个元素
void getLocation(View button, @Size(min=1) int[] location) {
button.getLocationOnScreen(location);
}

权限注解

使用 @RequiresPermission 注解可以验证方法调用方的权限。要检查有效权限列表中是否存在某个权限,请使用 anyOf 属性。要检查是否具有某组权限,请使用 allOf 属性。

// 以确保 setWallpaper() 方法调用方具有 permission.SET_WALLPAPERS 权限
@RequiresPermission(Manifest.permission.SET_WALLPAPER)
public abstract void setWallpaper(Bitmap bitmap) throws IOException;
// 要求 copyImageFile() 方法的调用方具有对外部存储空间的读取权限,以及对复制的映像中的位置元数据的读取权限
@RequiresPermission(allOf = {
Manifest.permission.READ_EXTERNAL_STORAGE,
Manifest.permission.ACCESS_MEDIA_LOCATION})

public static final void copyImageFile(String dest, String source) {
//...
}

对于 intent 的权限,请在用来定义 intent 操作名称的字符串字段上添加权限要求:

@RequiresPermission(android.Manifest.permission.BLUETOOTH)
public static final String ACTION_REQUEST_DISCOVERABLE =
"android.bluetooth.adapter.action.REQUEST_DISCOVERABLE";

如果您需要对内容提供程序拥有单独的读取和写入访问权限,则需要将每个权限要求封装在 @RequiresPermission.Read 或 @RequiresPermission.Write 注解中:

@RequiresPermission.Read(@RequiresPermission(READ_HISTORY_BOOKMARKS))
@RequiresPermission.Write(@RequiresPermission(WRITE_HISTORY_BOOKMARKS))
public static final Uri BOOKMARKS_URI = Uri.parse("content://browser/bookmarks");

返回值注解

使用 @CheckResult 注解可检查是否对方法的返回值进行处理,验证是否实际使用了方法的结果或返回值。

这个可能比较难理解,这里借助 Java String.trim() 举个例子解释一下(通过例子应该能够很直观的理解了,不需要过多解释了):

String str = new String("    http://www.ocnyang.com    ");
// 删除头尾空白
System.out.println("网站:" + str.trim() + "。");//打印结果:网站:www.ocnyang.com。
System.out.println("网站:" + str + "。");//打印结果:网站: http://www.ocnyang.com

以下示例为 checkPermissions() 方法添加了注解,以确保会实际引用该方法的返回值。此外,这还会将 enforcePermission() 方法指定为要向开发者建议的替代方法:

@CheckResult(suggest="#enforcePermission(String,int,int,String)")
public abstract int checkPermission(@NonNull String permission, int pid, int uid);

CallSuper 注解

@CallSuper 注解主要是用来强调在覆盖父类方法的时候,在实现父类的方法时及时调用对应的 super.xxx() 方法,当使用 @CallSuper 修饰了某个方法,如果子类覆盖父类该方法后没有实现对父类方法的调用就会报错。

Keep 注解

使用 @Keep 注解可以确保在构建混淆缩减代码大小时,不会移除带有该注解的类或方法。 该注解通常添加到通过反射访问的方法和类,以防止编译器将代码视为未使用。

注意:使用 @Keep 添加注解的类和方法会始终包含在应用的 APK 中,即使您从未在应用逻辑中引用这些类和方法也是如此。

代码公开范围注解(了解)

单元测试中可能要访问到一些不可见的类、函数或者变量,这时可以使用@VisibleForTesting 注解来对其可见。

Typedef 注解

枚举 Enum 在 Java 中是一个完整的类。而枚举中的每一个值在枚举类中都是一个对象。所以在我们使用时枚举的值将比整数常量消耗更多的内存。 那么我们最好使用常量来替代枚举。可是使用了常量代替后又不能限制取值了。上面这两个注解就是为了解决这个问题的。

@IntDef 和 @StringDef 注解是 Android 提供的魔术变量注解,您可以创建整数集和字符串集的枚举来代理 Java 的枚举类。 它将帮助我们在编译代码时期像 Enum 那样选择变量的功能。 @IntDef 和 typedef 作用非常类似,你可以创建另外一个注解,然后用 @IntDef 指定一个你期望的整型常量值列表,最后你就可以用这个定义好的注解修饰你的 API 了。接下来我们来使用 @IntDef 来替换 Enum 看一下.

public class MainActivity extends Activity {
public static final int SUNDAY = 0;
public static final int MONDAY = 1;
{...省略部分}

@IntDef({SUNDAY, MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY})
@Retention(RetentionPolicy.SOURCE)
public @interface WeekDays {
}

@WeekDays
int currentDay = SUNDAY;

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);

setCurrentDay(WEDNESDAY);

@WeekDays int today = getCurrentDay();
switch (today) {
case SUNDAY:
break;
case MONDAY:
break;
{...省略部分}
default:
break;
}
}

/**
* 参数只能传入在声明范围内的整型,不然编译通不过
* @param currentDay
*/

public void setCurrentDay(@WeekDays int currentDay) {
this.currentDay = currentDay;
}

@WeekDays
public int getCurrentDay() {
return currentDay;
}
}

说明:

  1. 声明一些必要的 int 常量
  2. 声明一个注解为 WeekDays
  3. 使用 @IntDef 修饰 WeekDays,参数设置为待枚举的集合
  4. 使用 @Retention(RetentionPolicy.SOURCE) 指定注解仅存在与源码中,不加入到 class 文件中

需要在调用时只能传入指定类型,如果传入类型不对,编译不通过。

我们也可以指定整型值作为标志位,也就是说这些整型值可以使用 ’|’ 或者 ’&’ 进行与或等操作。如果我们把上面代码中的注解定义为如下标志位:

@IntDef(flag = true, value = {SUNDAY, MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY})
public @interface Flavour {
}

那么可以如下调用:

setCurrentDay(SUNDAY & WEDNESDAY);

@StringDef 同理。

自定义注解

Java 元注解

名字描述
@Retention标识这个注解怎么保存,是只在代码中,还是编入class文件中,或者是在运行时可以通过反射访问
@Documented标记这些注解是否包含在用户文档中,即包含到 Javadoc 中去
@Target标记这个注解的作用目标
@Inherited标记这个注解是继承于哪个注解类
@RepeatableJava 8 开始支持,标识某注解可以在同一个声明上使用多次

@Retention
表示注解保留时间长短。可选的参数值在枚举类型 java.lang.annotation.RetentionPolicy 中,取值为:

  • RetentionPolicy.SOURCE:注解只在源码阶段保留,在编译器进行编译时它将被丢弃忽视,不会写入 class 文件;
  • RetentionPolicy.CLASS:注解只被保留到编译进行的时候,会写入 class 文件,它并不会被加载到 JVM 中;
  • RetentionPolicy.RUNTIME:注解可以保留到程序运行的时候,它会被加载进入到 JVM 中,所以在程序运行时可以反射获取到它们。

@Target
用于指明被修饰的注解最终可以作用的目标是谁,也就是指明,你的注解到底是用来修饰方法的?修饰类的?还是用来修饰字段属性的。 可能的值在枚举类 java.lang.annotation.ElementType 中,包括:

  • ElementType.TYPE:允许被修饰的注解作用在类、接口和枚举上;
  • ElementType.FIELD:允许作用在属性字段上;
  • ElementType.METHOD:允许作用在方法上;
  • ElementType.PARAMETER:允许作用在方法参数上;
  • ElementType.CONSTRUCTOR:允许作用在构造器上;
  • ElementType.LOCAL_VARIABLE:允许作用在本地局部变量上;
  • ElementType.ANNOTATION_TYPE:允许作用在注解上;
  • ElementType.PACKAGE:允许作用在包上。

@Target 注解的参数也可以接收一个数组,表示可以作用在多种目标类型上,如: @Target({ElementType.FIELD, ElementType.LOCAL_VARIABLE})

自定义注解

你可以根据需要自定义一些自己的注解,然后要在需要的地方加上自定义的注解。需要注意的是每当自定义注解时,相对应的一定要有处理这些自定义注解的流程,要不然可以说是没有实用价值的。注解真真的发挥作用,主要就在于注解处理方法。 注解的处理一般分为两种:

  • 保留注解信息到运行时,这时通过反射操作获取到类、方法和字段的注解信息,然后做相对应的处理
  • 保留到编译期,一般此方式是利用 APT 注释解释器,根据注解自动生成代码。简单来说,可以通过 APT,根据规则,帮我们生成代码、生成类文件。ButterKnife、Dagger、EventBus 等开源库都是利用注解实现的。

因为自定义注解的涉及到的内容较多。本期先不对自定义注解详细展开介绍,后续找时间再对它进行单独的文章讲解。

收起阅读 »

手把手带你走一遍Compose重组流程

前言我们都知道 Jetpack Compose 是一套声明式 UI 系统,当 UI 组件所依赖的状态发生改变时会自动发生重绘刷新,这个过程被官方称作重组,前面已经有人总结过 Compose 的重组范围了,文章详见 《Compose 的重组会影响性能吗?聊一聊 ...
继续阅读 »

前言

我们都知道 Jetpack Compose 是一套声明式 UI 系统,当 UI 组件所依赖的状态发生改变时会自动发生重绘刷新,这个过程被官方称作重组,前面已经有人总结过 Compose 的重组范围了,文章详见 《Compose 的重组会影响性能吗?聊一聊 recomposition scope》 ,并且也有人总结过重组过程使用到的快照系统,文章详见《Jetpack Compose · 快照系统》。本文就就带领大家一起来看看 Compose 源码中从状态更新到 recompose 过程的发生到底是如何进行的,并且快照系统是在 recompose 过程中如何被使用到的。

意义

本文将通过阅读源码的方式来解读 recompose 流程,阅读源码其实每个人都可以做到,但阅读源码本身是一个非常枯燥的过程,源码中存在着大量逻辑分支导致许多人看着看着就被绕晕了。本文将带领大家以 recompose 主线流程为导向来进行源码过程分析,许多与主线流程无关的逻辑分支都已被我剔除了,大家可以放心进行阅读。希望后来者能够在本文源码过程分析基础上继续深入探索下去。

⚠️ Tips:由于 recompose 流程十分复杂,本文目前仅对 recompose 主线流程进行了描述,其中很多很多技术细节没有深挖,等待后续进行补充。本人采用动静结合的方式进行源码分析,可能有些case流程没有覆盖到,如果文章存在错误欢迎在评论区进行补充。

recompose 流程分析

从 MutableState 更新开始

当你为 MutableState 赋值时将会默认调用 MutableState 的扩展方法 MutableState.setValue

// androidx.compose.runtime.SnapshotState
inline operator fun MutableState.setValue(thisObj: Any?, property: KProperty<*>, value: T) {
this.value = value
}

通过查看 mutableStateOf 源码我们可以发现 MutableState 实际上是一个 SnapshotMutableStateImpl 类型实例

// androidx.compose.runtime.SnapshotState
fun mutableStateOf(
value: T,
policy: SnapshotMutationPolicy<T> = structuralEqualityPolicy()
): MutableState = createSnapshotMutableState(value, policy)

// androidx.compose.runtime.ActualAndroid.android
internal actual fun createSnapshotMutableState(
value: T,
policy: SnapshotMutationPolicy<T>
): SnapshotMutableState = ParcelableSnapshotMutableState(value, policy)

// androidx.compose.runtime.ParcelableSnapshotMutableState
internal class ParcelableSnapshotMutableState<T>(
value: T,
policy: SnapshotMutationPolicy
) : SnapshotMutableStateImpl(value, policy), Parcelable

当 value 属性发生改变时会调用这个属性的 setter ,当然如果读取状态时也会走 getter。

此时的next是个 StateStateRecord 实例,其真正记录着当前state状态信息(通过当前value的getter与setter就可以看出)。此时首先会对当前值和要更新的值根据规则进行diff判断。当确定发生改变时会调用到 StateStateRecord 的 overwritable 方法。

internal open class SnapshotMutableStateImpl<T>(
value: T,
override val policy: SnapshotMutationPolicy
) : StateObject, SnapshotMutableState {
@Suppress("UNCHECKED_CAST")
override var value: T
get() = next.readable(this).value
set(value) = next.withCurrent {
if (!policy.equivalent(it.value, value)) {
// 此时的this还是当前SnapshotMutableStateImpl
next.overwritable(this, it) {
this.value = value // 此时的this指向的next,这部操作也就是更新next其中的value
}
}
}
...
private var next: StateStateRecord = StateStateRecord(value)
}

接下来会通过 Snapshot.current 获取当前上下文中的 Snapshot,如果你对 mutableState 更新操作在非Compose Scope中,其返回的实例类型是 GlobalSnapshot ,否则就是一个 MutableSnapShot。这将会影响到后续写入通知的执行流程(因为毕竟需要进行 recompose 嘛)。

⚠️ Tips:GlobalSnapshot 是 MutableSnapShot 的子类

// androidx.compose.runtime.snapshots.Snapshot
internal inline fun T.overwritable(
state: StateObject,
candidate: T,
block: T.() -> R
): R {
var snapshot: Snapshot = snapshotInitializer
return sync {
snapshot = Snapshot.current
this.overwritableRecord(state, snapshot, candidate).block() // 更新 next
}.also {
notifyWrite(snapshot, state) // 写入通知
}
}

我们进入 overwritableRecord 看看其中做了什么,注意此时 state 其实是 mutableState。在这其中通过 recordModified 方法记录了修改。我们可以看到此时将当前修改的 state 添加到当前 Snapshot 的 modified 中了,这个后续会用到的。

// androidx.compose.runtime.snapshots.Snapshot
internal fun T.overwritableRecord(
state: StateObject,
snapshot: Snapshot,
candidate: T
): T {
if (snapshot.readOnly) {
snapshot.recordModified(state)
}
val id = snapshot.id

if (candidate.snapshotId == id) return candidate

val newData = newOverwritableRecord(state, snapshot)
newData.snapshotId = id

snapshot.recordModified(state) // 记录修改

return newData
}

// androidx.compose.runtime.snapshots.Snapshot
override fun recordModified(state: StateObject) {
(modified ?: HashSet().also { modified = it }).add(state)
}

可能你对 mutableState 更新操作是否在 ComposeScope 中而感到困惑,举个例子其实就明白了。recompose 能够执行到就在 ComposeScope 中,不能执行到就不在 ComposeScope 中。

这个在后面 takeMutableSnapshot读观察者与写观察者 部分是会进行解释。

var display by mutableStateOf("Init")
@Preview
@Composable
fun Demo() {
Text (
text = display,
fontSize = 50.sp,
modifier = Modifier.clickable {
display = "change" // recompose不能执行到,此时是 GlobalSnapshot
}
)
display = "change" // recompose能够执行到,此时是 MutableSnapShot
}

接下来就是通过 notifyWrite 执行事件通知此时可以看到调用了写观察者 writeObserver 。

// androidx.compose.runtime.snapshots.Snapshot
@PublishedApi
internal fun notifyWrite(snapshot: Snapshot, state: StateObject) {
snapshot.writeObserver?.invoke(state)
}

此时会根据当前 Snapshot 不同而调用到不同的写观察者 writeObserver 。

GlobalSnapshot 写入通知

全局的写入观察者是在 setContent 时就进行了注册, 此时会回调 registerGlobalWriteObserver 的尾lambda,可以看到这里就一个channel (没错就是Kotlin协程那个热数据流Channel),我门可以看到很容易看到在上方以AndroidUiDispatcher.Main 作为调度器的 CoroutineScope 中进行了挂起等待消费,所以执行流程自然会进入 sendApplyNotifications() 之中。 (AndroidUiDispatcher.Main 与 Choreographer 息息相关,篇幅有限就不展开讨论了,有兴趣可以自己去跟源码)

internal object GlobalSnapshotManager {
private val started = AtomicBoolean(false)

fun ensureStarted() {
if (started.compareAndSet(false, true)) {
val channel = Channel<Unit>(Channel.CONFLATED)
CoroutineScope(AndroidUiDispatcher.Main).launch {
channel.consumeEach {
Snapshot.sendApplyNotifications()
}
}
Snapshot.registerGlobalWriteObserver {
channel.offer(Unit)
}
}
}
}

sendApplyNotifications

接下来,我们进入 sendApplyNotifications() 其中看看做了什么,可以看到这里使用我们前面提到的那个 modified ,当发生修改时 changes 必然为 true,所以接着会调用到 advanceGlobalSnapshot

// androidx.compose.runtime.snapshots.Snapshot
fun sendApplyNotifications() {
val changes = sync {
currentGlobalSnapshot.get().modified?.isNotEmpty() == true
}
if (changes)
advanceGlobalSnapshot()
}

我们继续往下跟下去走到了 advanceGlobalSnapshot ,此时将所有 modified 取出并便利调用 applyObservers 中包含的所有观察者。

// androidx.compose.runtime.snapshots.Snapshot
private fun advanceGlobalSnapshot() = advanceGlobalSnapshot { }

private fun advanceGlobalSnapshot(block: (invalid: SnapshotIdSet) -> T): T {
val previousGlobalSnapshot = currentGlobalSnapshot.get()
val result = sync {
takeNewGlobalSnapshot(previousGlobalSnapshot, block)
}
val modified = previousGlobalSnapshot.modified
if (modified != null) {
val observers: List<(Set, Snapshot) -> Unit> = sync { applyObservers.toMutableList() }
observers.fastForEach { observer ->
observer(modified, previousGlobalSnapshot)
}
}
....
return result
}

applyObservers之recompositionRunner

据我调查此时 applyObservers 中包含的观察者仅有两个,一个是 SnapshotStateObserver.applyObserver 用来更新快照状态信息,另一个就是 recompositionRunner 用来处理 recompose流程 的。由于我们是在研究recompose 流程的所以就不分开去讨论了。我们来看看处理 recompose 的 observer 都做了什么,首先他将所有改变的 mutableState 添加到了 snapshotInvalidations,这个后续会用到。后面可以看到有一个resume,说明lambda的最后调用的 deriveStateLocked 返回了一个协程 Continuation 实例。使得挂起点位置恢复执行,所以我们进入deriveStateLocked 看看这个协程 Continuation 实例到底是谁。

// androidx.compose.runtime.Recomposer
@OptIn(ExperimentalComposeApi::class)
private suspend fun recompositionRunner(
block: suspend CoroutineScope.(parentFrameClock: MonotonicFrameClock) -> Unit
) {
withContext(broadcastFrameClock) {
...
// 负责处理 recompose 的 observer 就是他
val unregisterApplyObserver = Snapshot.registerApplyObserver {
changed, _ ->
synchronized(stateLock) {
if (_state.value >= State.Idle) {
snapshotInvalidations += changed
deriveStateLocked()
} else null
}?.resume(Unit)
}
....
}
}

通过函数返回值可以看到这是一个可取消的Continuation实例 workContinuation

// androidx.compose.runtime.Recomposer
private fun deriveStateLocked(): CancellableContinuation<Unit>? {
....
return if (newState == State.PendingWork) {
workContinuation.also {
workContinuation = null
}
} else null
}

那这个workContinuation是在哪里赋值的呢,我们很容易就找到了其唯一被赋值的地方。此时 workContinuation 就是 co,此时resume也就是恢复执行 awaitWorkAvailable 调用挂起点。

// androidx.compose.runtime.Recomposer
private suspend fun awaitWorkAvailable() {
if (!hasSchedulingWork) {
suspendCancellableCoroutine<Unit> { co ->
synchronized(stateLock) {
if (hasSchedulingWork) {
co.resume(Unit)
} else {
workContinuation = co
}
}
}
}
}

runRecomposeAndApplyChanges 三步骤

我们可以找到在 runRecomposeAndApplyChanges 中调用 awaitWorkAvailable 而产生了挂起,所以此时会恢复调用 runRecomposeAndApplyChanges ,这里主要有三步操作接下来进行介绍

// androidx.compose.runtime.Recomposer
suspend fun runRecomposeAndApplyChanges() = recompositionRunner { parentFrameClock ->
val toRecompose = mutableListOf()
val toApply = mutableListOf()
while (shouldKeepRecomposing) {
awaitWorkAvailable()
// 从这开始恢复执行
if (
synchronized(stateLock) {
if (!hasFrameWorkLocked) {
// 步骤1
recordComposerModificationsLocked()
!hasFrameWorkLocked
} else false
}
) continue

// 等待Vsync信号,类似于传统View系统中scheduleTraversals?
parentFrameClock.withFrameNanos { frameTime ->
...
trace("Recomposer:recompose") {
synchronized(stateLock) {
recordComposerModificationsLocked()
// 步骤2
compositionInvalidations.fastForEach { toRecompose += it }
compositionInvalidations.clear()
}

val modifiedValues = IdentityArraySet()
val alreadyComposed = IdentityArraySet()
while (toRecompose.isNotEmpty()) {
try {
toRecompose.fastForEach { composition ->
alreadyComposed.add(composition)
// 步骤3
performRecompose(composition, modifiedValues)?.let {
toApply += it
}
}
} finally {
toRecompose.clear()
}
....
}
....
}
}
}
}

对于这三个步骤,我们分别来看首先是步骤1调用了 recordComposerModificationsLocked 方法, 还记得 snapshotInvalidations 嘛, 他记录着所有更改的 mutableState,此时回调所有已知composition的recordModificationsOf 方法。

// androidx.compose.runtime.Recomposer
private fun recordComposerModificationsLocked() {
if (snapshotInvalidations.isNotEmpty()) {
snapshotInvalidations.fastForEach { changes ->
knownCompositions.fastForEach { composition ->
composition.recordModificationsOf(changes)
}
}
snapshotInvalidations.clear()
if (deriveStateLocked() != null) {
error("called outside of runRecomposeAndApplyChanges")
}
}
}

经过一系列调用会将所有依赖当前 mutableState 的所有 Composable Scope 存入到 compositionInvalidations 这个 List 中。

// androidx.compose.runtime.Recomposer
internal override fun invalidate(composition: ControlledComposition) {
synchronized(stateLock) {
if (composition !in compositionInvalidations) {
compositionInvalidations += composition
deriveStateLocked()
} else null
}?.resume(Unit)
}

步骤2就很简单了,将 compositionInvalidations 的所有元素转移到了 toRecompose,而步骤3则是 recompose的重中之重,通过 performRecompose 使所有受到影响的 Composable Scope 重新执行。

performRecompose

我们可以看到 performRecompose 中间接调用了 composing ,而其中最关键 recompose 也在回调中完成,那么我们需要再进入 composing 看看什么时候会回调。

// androidx.compose.runtime.Recomposer
private fun performRecompose(
composition: ControlledComposition,
modifiedValues: IdentityArraySet<Any>?
): ControlledComposition? {
if (composition.isComposing || composition.isDisposed) return null
return if (
composing(composition, modifiedValues) {
if (modifiedValues?.isNotEmpty() == true) {
composition.prepareCompose {
modifiedValues.forEach { composition.recordWriteOf(it) }
}
}
composition.recompose() // 真正发生recompose的地方
}
) composition else null
}

composing 内部首先拍摄了一次快照,然后将我们的recompose过程在这次快照中执行,最后进行了apply。又关于快照系统的讲解详见 《Jetpack Compose · 快照系统》

// androidx.compose.runtime.Recomposer
private inline fun composing(
composition: ControlledComposition,
modifiedValues: IdentityArraySet<Any>?,
block: () -> T
): T {
val snapshot = Snapshot.takeMutableSnapshot(
readObserverOf(composition), writeObserverOf(composition, modifiedValues)
)
try {
return snapshot.enter(block)
} finally {
applyAndCheck(snapshot)
}
}

takeMutableSnapshot 读观察者与写观察者

值得注意的是此时调用的 takeMutableSnapshot 方法同时传入了一个读观察者和写观察者,而这两个观察者在什么时机回调呢?当我们每次 recompose 时都会拍摄一次快照,然后我们的重新执行过程在这次快照中执行,在重新执行过程中如果出现了 mutableState 的读取或写入操作都会相应的回调这里的读观察者和写观察者。也就说明每次recompose都会进行重新一次绑定。 读观察者回调时机比较好理解,写观察者在什么时机回调呢? 还记得我们刚开始说的 GlobalSnapshot 和 MutableSnapshot 嘛?

到这里我们一直都在分析 GlobalSnapshot 这条执行过程,通过调用 takeMutableSnapshot 将返回一个 MutableSnapshot 实例,我们的recompose重新执行过程发生在当前MutableSnapshot 实例的enter 方法中,此时重新执行过程中通过调用Snapshot.current 将返回当前MutableSnapshot 实例,所以重新执行过程中发生的写操作就会回调 takeMutableSnapshot 所传入的写观察者。也就是以下这种情况,当 Demo 发生recompose时 display所在 Snapshot 就是拍摄的MutableSnapshot 快照。

var display by mutableStateOf("Init")
@Preview
@Composable
fun Demo() {
Text (
text = display,
fontSize = 50.sp
)
display = "change" // recompose能够执行到,此时是 MutableSnapShot
}

MutableSnapshot 写入通知

接下来,我们来看看 takeMutableSnapshot 的写观察者是如何实现的。此时会将更新的值传入当前recompose composition 的 recordWriteOf 方法。

// androidx.compose.runtime.Recomposer
private fun writeObserverOf(
composition: ControlledComposition,
modifiedValues: IdentityArraySet<Any>?
): (Any) -> Unit {
return { value ->
composition.recordWriteOf(value)
modifiedValues?.add(value)
}
}

通过对于流程分析发现,实际上在recompose过程中进行状态写入操作时,并不会通过写观察者立即进行recompose 过程,而是等待到当前recompose过程结束后进行 apply 时再进行重新 recompose。

applyAndCheck

让我们回到Recomposer的 composing 方法,我们通过 applyAndCheck 完成后续 apply 操作。applyAndCheck 内部使用了 MutableSnapshot.apply

// androidx.compose.runtime.Recomposer
private inline fun composing(
composition: ControlledComposition,
modifiedValues: IdentityArraySet<Any>?,
block: () -> T
): T {
val snapshot = Snapshot.takeMutableSnapshot(
readObserverOf(composition), writeObserverOf(composition, modifiedValues)
)
try {
return snapshot.enter(block)
} finally {
applyAndCheck(snapshot) // 在这里
}
}

private fun applyAndCheck(snapshot: MutableSnapshot) {
val applyResult = snapshot.apply()
if (applyResult is SnapshotApplyResult.Failure) {
error(
"Unsupported concurrent change during composition. A state object was " +
"modified by composition as well as being modified outside composition."
)
}
}

apply中使用的applyObservers

我们再进入MutableSnapshot.apply 一探究竟,此时将当前 modified 在 snapshot.recordModified(state) 已经更新过了,忘记的话可以回头看看,前面已经讲过了。此时仍然使用了 applyObservers 进行遍历通知。这个applyObservers 其实是个静态变量,所以不同的 GlobalSnapshot 与MutableSnapshot 可以共享,接下来仍然通过预先订阅好的 recompositionRunner 用来处理 recompose 过程,详见 applyObservers之recompositionRunner,接下来的recompose流程就完全相同了。

// androidx.compose.runtime.snapshots.Snapshot
open fun apply(): SnapshotApplyResult {
val modified = modified
....
val (observers, globalModified) = sync {
validateOpen(this)
if (modified == null || modified.size == 0) {
....
} else {
....
applyObservers.toMutableList() to globalModified
}
}
....
if (modified != null && modified.isNotEmpty()) {
observers.fastForEach {
it(modified, this)
}
}
return SnapshotApplyResult.Success
}
收起阅读 »

偷师 - Kotlin 委托

关键字synchorinzedCAS委托/代理模式委托要理解 kotlin-委托 的作用和用法首先要理解什么是委托。初看委托二字如果不太理解的话不妨转换成代理二字。委托模式和代理模式是一种设计模式的两种称呼而已。委托/代理模式代理模式,字面...
继续阅读 »

关键字

  • synchorinzed
  • CAS
  • 委托/代理模式

委托

要理解 kotlin-委托 的作用和用法首先要理解什么是委托。初看委托二字如果不太理解的话不妨转换成代理二字。委托模式和代理模式是一种设计模式的两种称呼而已。

委托/代理模式

代理模式,字面理解就是自己不方便做或者不能做的事情,需要第三方代替来做,最终通过第三方来达到自己想要的目的或效果。举例:员工小李在B总公司打工,B总成天让小李加班不给加班费,小李忍受不住了,就想去法院告B总。虽然法律上允许打官司不请律师,允许自辩。但是小李第一不熟悉法律起诉的具体流程,第二嘴比较笨,人一多腿就抖得厉害。因此,小李决定去找律师帮忙打官司。找律师打官司和自己打官司相比,有相同的地方,也有不同的地方。

相同的地方在于:

  • 都需要提交原告的资料,如姓名、年龄、事情缘由、想达到的目的。
  • 都需要经过法院的取证调查,开庭争辩等过程。
  • 最后拿到审判结果。

不同地方在于:

  • 小李省事了,让专业的人做专业的事,不需要自己再去了解法院那一套繁琐复杂的流程。
  • 把握更大了。

通过上面的例子,我们注意到代理模式有几个重点。

  • 被代理的角色(小李)
  • 代理角色(律师)
  • 协议(不管是代理和被代理谁去做,都需要做的事情,抽象出来就是协议)

UML 类图: image

代码实现如下:

//协议
interface Protocol{
//登记资料
public void register(String name);
//调查案情,打官司
public void dosomething();
//官司完成,通知雇主
public void notifys();
}

//代理角色:律师类
class LawyerProxy implements Protocol{
private Employer employer;
public LawyerProxy(Employer employer){
this.employer=employer;
}
@Override
public void register(String name) {
// TODO Auto-generated method stub
this.employer.register(name);
}
public void collectInfo(){
System.out.println("作为律师,我需要根据雇主提供的资料,整理与调查,给法院写出书面文字,并提供证据。");
}
@Override
public void dosomething() {
// TODO Auto-generated method stub
collectInfo();
this.employer.dosomething();
finish();
}
public void finish(){
System.out.println("本次官司打完了...............");
}
@Override
public void notifys() {
// TODO Auto-generated method stub
this.employer.notifys();
}
}

//被代理角色:雇主类
class Employer implements Protocol{
String name=null;
@Override
public void register(String name) {
// TODO Auto-generated method stub
this.name=name;
}
@Override
public void dosomething() {
// TODO Auto-generated method stub
System.out.println("我是'"+this.name+"'要告B总,他每天让我不停的加班,还没有加班费。");
}
@Override
public void notifys() {
// TODO Auto-generated method stub
System.out.println("法院裁定,官司赢了,B总需要赔偿10万元精神补偿费。");
}
}

public class Client {
public static void main(String[] args) {
Employer employer=new Employer();
System.out.println("我受不了了,我要打官司告老板");
System.out.println("找律师解决一下吧......");
Protocol lawyerProxy=new LawyerProxy(employer);
lawyerProxy.register("朵朵花开");
lawyerProxy.dosomething();
lawyerProxy.notifys();
}
}
复制代码

运行后,打印如下:

我受不了了,我要打官司告老板
找律师解决一下吧......
作为律师,我需要根据雇主提供的资料,整理与调查,给法院写出书面文字,并提供证据。
我是'朵朵花开'要告B总,他每天让我不停的加班,还没有加班费。
本次官司打完了...............
法院裁定,官司赢了,B总需要赔偿10万元精神补偿费。
复制代码

类委托

对代理模式有了一些了解之后我们再来看 kotlin-类委托 是如何实现的:

interface Base {
fun print()
}

class BaseImpl(val x: Int) : Base {
override fun print() { print(x) }
}

class Derived(b: Base) : Base by b

fun main() {
val b = BaseImpl(10)
Derived(b).print()
}
复制代码

这是Kotlin 语言中文站的示例,转成 Javaa 代码如下:


public interface Base {
void print();
}

// BaseImpl.java
public final class BaseImpl implements Base {
private final int x;

public void print() {
int var1 = this.x;
boolean var2 = false;
System.out.print(var1);
}

public final int getX() {
return this.x;
}

public BaseImpl(int x) {
this.x = x;
}
}

// Derived.java
public final class Derived implements Base {
// $FF: synthetic field
private final Base $$delegate_0;

public Derived(@NotNull Base b) {
Intrinsics.checkNotNullParameter(b, "b");
super();
this.$$delegate_0 = b;
}

public void print() {
this.$$delegate_0.print();
}
}

// DelegateTestKt.java
public final class DelegateTestKt {
public static final void main() {
BaseImpl b = new BaseImpl(10);
(new Derived((Base)b)).print();
}

// $FF: synthetic method
public static void main(String[] var0) {
main();
}
}
复制代码

可以看到在 Derived 中已经实现了 Base 接口的抽象方法,而且方法的实际调用者是构造对象时传入的 Base 实例对象,也就是 BaseImpl 的实例对象。

对比上文介绍的代理模式:

  • Base:代理协议
  • BaseImpl:代理角色
  • Derived:被代理被代理角色

这样看的话,d上文类委托示例的结果包括重写方法实现和成员变量产生的结果的原因也就清晰明了了。

属性委托

kotlin 标准库中提供的属性委托有:

  • lazy:延迟属性;
  • Delegates.notNull():不能为空;
  • Delegates.observable():可观察属性;
  • Delegates.vetoable():可观察属性,可拒绝修改属性;

lazy 延迟属性下面再来分析,先来看 Delegates 的几个方法。

在 Delegate.kt 文件中定义了提供的标准属性委托方法,代码量很少就不贴代码了。可以看到三种委托方法都返回 ReadWriteProperty 接口的实例对象,它们的顶层接口是 ReadOnlyProperty 接口。名字就很提现它们各自的功用了:

  • ReadOnlyProperty:仅用于可读属性,val
  • ReadWriteProperty:用于可读-写属性,var

在属性委托的实现里,对应代理模式的角色如下:

  • 协议:ReadOnlyProperty 和 ReadWriteProperty
  • 代理者:Delegate
  • 被代理者:实际使用属性。

Delegates.notNull() 比较简单,拿它来分析下属性委托是如何实现的。

private class NotNullVar<T : Any>() : ReadWriteProperty<Any?, T> {
private var value: T? = null

public override fun getValue(thisRef: Any?, property: KProperty<*>): T {
return value ?: throw IllegalStateException("Property ${property.name} should be initialized before get.")
}

public override fun setValue(thisRef: Any?, property: KProperty<*>, value: T) {
this.value = value
}
}
复制代码
class DelegateTest {
private val name: String by Delegates.notNull()
}
复制代码

kotlin 转 Java

public final class DelegateTest {
// $FF: synthetic field
static final KProperty[] $$delegatedProperties = new KProperty[]{(KProperty)Reflection.property1(new PropertyReference1Impl(DelegateTest.class, "name", "getName()Ljava/lang/String;", 0))};
private final ReadWriteProperty name$delegate;

private final String getName() {
return (String)this.name$delegate.getValue(this, $$delegatedProperties[0]);
}

public DelegateTest() {
this.name$delegate = Delegates.INSTANCE.notNull();
}
}
复制代码

可以看到 name 属性委托给了 NotNullVar 的 value 属性。当访问 name 属性时,其实访问的是 NotNullVar 的 value 属性。

自定义委托

上文提到 Delegates 中的委托方法都返回 ReadWriteProperty 接口的实例对象。如果需要自定义委托的话当然也是通过实现 ReadWriteProperty 接口了。

  • var 属性自定义委托:继承 ReadWriteProperty 接口,并实现 getValue()、setValue() 方法;
  • val 属性自定义委托:实现 ReadOnlyProperty 接口,并实现 getValue 方法。
public override operator fun getValue(thisRef: T, property: KProperty<*>): V

public operator fun setValue(thisRef: T, property: KProperty<*>, value: V)
复制代码

参数如下:

  • thisRef —— 必须与属性所有者类型相同或者是其超类型,通俗说就是属性所在类的类型或其父类型;
  • property —— 必须是 KProperty<*> 类型或其超类型。

Lazy

lazy 放到这里来分析是因为它虽然也是将属性委托给了其他类的属性,但它并没有继承 ReadWriteProperty 或 ReadOnlyProperty 接口并不是标准的属性委托。

lazy 源码如下:

public actual fun <T> lazy(initializer: () -> T): Lazy<T> = SynchronizedLazyImpl(initializer)

public actual fun <T> lazy(mode: LazyThreadSafetyMode, initializer: () -> T): Lazy<T> =
when (mode) {
LazyThreadSafetyMode.SYNCHRONIZED -> SynchronizedLazyImpl(initializer)
LazyThreadSafetyMode.PUBLICATION -> SafePublicationLazyImpl(initializer)
LazyThreadSafetyMode.NONE -> UnsafeLazyImpl(initializer)
}
复制代码

lazy 函数接收两个参数:

  • LazyThreadSafetyMode:线程安全模式;
  • initializer:初始化函数。

LazyThreadSafetyMode:不同模式的作用如下:

  • SYNCHRONIZED:通过 Volatile + synchorinzed 锁的方式保证在多线程情况下初始化函数仅调用一次,变量仅赋值一次;
  • PUBLICATION:通过 Volatile + CAS 的方式保证在多线程情况下变量仅赋值一次;
  • NONE:线程不安全。

lazy 函数返回 Lazy 接口实例。

注意:除非你能保证 lazy 实例的永远不会在多个线程初始化,否则不应该使用 NONE 模式。

lazy 函数会根据所选模式的不同返回不同的实例对象:SynchronizedLazyImplSafePublicationLazyImplUnsafeLazyImpl。这三者之间最大的的区别在于 getter() 函数的实现,但不管如何最终都是各自类中的 value 属性代理 lazy 函数所修饰的属性。

synchorinzedCAS 都是多线程中实现锁的常用烦恼干是,关于他们的介绍可以看我之前的文章:

应用

在项目中可以应用 kotlin 委托 可以辅助简写如下功能:

  • Fragment / Activity 传参
  • ViewBinding

本节所写的两个示例是摘自

Kotlin | 委托机制 & 原理 & 应用 -- 彭丑丑 View Binding 与Kotlin委托属性的巧妙结合,告别垃圾代码! -- Kirill Rozov 著,依然范特稀西 译

kotlin 委托 + Fragment / Activity 传参

示例来源: 彭丑丑 - Kotlin | 委托机制 & 原理 & 应用 项目地址: Github - DemoHall

属性委托前:

class OrderDetailFragment : Fragment(R.layout.fragment_order_detail) {

private var orderId: Int? = null
private var orderType: Int? = null

companion object {

const val EXTRA_ORDER_ID = "orderId"
const val EXTRA_ORDER_TYPE = "orderType";

fun newInstance(orderId: Int, orderType: Int?) = OrderDetailFragment().apply {
Bundle().apply {
putInt(EXTRA_ORDER_ID, orderId)
if (null != orderType) {
putInt(EXTRA_ORDER_TYPE, orderType)
}
}.also {
arguments = it
}
}
}

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

arguments?.let {
orderId = it.getInt(EXTRA_ORDER_ID, 10000)
orderType = it.getInt(EXTRA_ORDER_TYPE, 2)
}
}
}
复制代码

定义 ArgumentDelegate.kt

fun <T> fragmentArgument() = FragmentArgumentProperty<T>()

class FragmentArgumentProperty<T> : ReadWriteProperty<Fragment, T> {

override fun getValue(thisRef: Fragment, property: KProperty<*>): T {
return thisRef.arguments?.getValue(property.name) as? T
?: throw IllegalStateException("Property ${property.name} could not be read")
}

override fun setValue(thisRef: Fragment, property: KProperty<*>, value: T) {
val arguments = thisRef.arguments ?: Bundle().also { thisRef.arguments = it }
if (arguments.containsKey(property.name)) {
// The Value is not expected to be modified
return
}
arguments[property.name] = value
}
}
复制代码

使用属性委托后:

class OrderDetailFragment : Fragment(R.layout.fragment_order_detail) {

private lateinit var tvDisplay: TextView

private var orderId: Int by fragmentArgument()
private var orderType: Int? by fragmentArgumentNullable(2)

companion object {
fun newInstance(orderId: Int, orderType: Int?) = OrderDetailFragment().apply {
this.orderId = orderId
this.orderType = orderType
}
}

override fun onViewCreated(root: View, savedInstanceState: Bundle?) {
// Try to modify (UnExcepted)
this.orderType = 3
// Display Value
tvDisplay = root.findViewById(R.id.tv_display)
tvDisplay.text = "orderId = $orderId, orderType = $orderType"
}
}
复制代码

kotlin 委托 + ViewBinding

示例来源: ViewBindingPropertyDelegate

属性委托前:

class ProfileActivity : AppCompatActivity(R.layout.activity_profile) {

private var binding: ActivityProfileBinding? = null

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

binding = ActivityProfileBinding.inflate(layoutInflater)
binding!!.profileFragmentContainer
}
}
复制代码

属性委托后:

class ProfileActivity : AppCompatActivity(R.layout.activity_profile) {

private val viewBinding: ActivityProfileBinding by viewBinding()

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
with(viewBinding) {
profileFragmentContainer
}
}
}
复制代码

使用过后代码非常的简洁而且也不需要再用 !! 或者定义一个新的变量,有兴趣的同学可以去看下源码。

收起阅读 »

【iOS】自动布局之Purelayout

masonry这个第三方库件在github上很出名,貌似也很好用,但是我在看过masonry的介绍和使用方法之后,觉得有点隐隐的蛋疼。因为本人工作时间不多,加上一直都用的是Objective-C,看着masonry提供的方法基本上都是点语法,我的[]呢?!!怎...
继续阅读 »

masonry这个第三方库件在github上很出名,貌似也很好用,但是我在看过masonry的介绍和使用方法之后,觉得有点隐隐的蛋疼。
因为本人工作时间不多,加上一直都用的是Objective-C,看着masonry提供的方法基本上都是点语法,我的[]呢?!!怎么不在了?

于是在github上搜索到另外一个较出名的布局,便有了这段Purelayout的尝试。

生成一个UIView:

UIView *view = [UIView newAutoLayoutView];
+ (instancetype)newAutoLayoutView
{
ALView *view = [self new];
view.translatesAutoresizingMaskIntoConstraints = NO;
return view;
}

newAutoLayoutView是UIView的一个扩展方法,其实达到的目的就是生成一个UIView实例,并把该实例的translatesAutoresizingMaskIntoConstraints属性置为NO。这个属性值在默认情况下是YES,如果设置为 NO,那么在运行时,程序不会自动将AutoresizingMask转化成 Constraint。

1.view相对于父容器间距的位置

[view autoPinEdgeToSuperviewEdge:ALEdgeTop withInset:10];//相对于父容器顶部距离10
[view autoPinEdgeToSuperviewEdge:ALEdgeLeft withInset:10];//相对于父容器左部距离10
[view autoPinEdgeToSuperviewEdge:ALEdgeRight withInset:10];//相对于父容器右部距离10
[view autoPinEdgeToSuperviewEdge:ALEdgeBottom withInset:10];//相对于父容器底部距离10

值得注意的是Purelayout对UILabel做了一些人性化的处理:
在有的国家地区文字是从右至左的,以下代码就是将label的起始位置距离父容器10

[label autoPinEdgeToSuperviewEdge:ALEdgeLeading withInset:10];

2.相对于父容器的中心位置:

[view autoCenterInSuperview];//view在父容器中心位置
[view autoAlignAxisToSuperviewAxis:ALAxisHorizontal];//view在父容器水平中心位置
[view autoAlignAxisToSuperviewAxis:ALAxisVertical];//view在父容器垂直中心位置

3.设置大小

[view autoSetDimensionsToSize:CGSizeMake(300, 300)];//设置view的大小为300*300
[view autoSetDimension:ALDimensionHeight toSize:300];//设置view的高度为300
[view autoSetDimension:ALDimensionWidth toSize:300];//设置view的宽度为300

4.相对位置
NSLayoutRelation是一个枚举类型:

typedef NS_ENUM(NSInteger, NSLayoutRelation) {
NSLayoutRelationLessThanOrEqual = -1,
NSLayoutRelationEqual = 0,
NSLayoutRelationGreaterThanOrEqual = 1,
};

见名知意,你懂的。

[view1 autoPinEdge:ALEdgeTop toEdge:ALEdgeBottom ofView:view2 withOffset:20 relation:NSLayoutRelationEqual];//view1的顶部在view2的底部的20像素的位置

5.中心对齐

[view1 autoAlignAxis:ALAxisVertical toSameAxisOfView:view2];//view1相对于view2保持在同一个垂直中心上

view1相对于view2保持在同一个垂直中心上

6.相对大小

[view1 autoMatchDimension:ALDimensionWidth toDimension:ALDimensionWidth ofView:view2];

view1的宽度和view2的宽度相等

在使用purelayout的时候值得注意:
1.purelayout提供的方法有些是只支持iOS8及以上的,如果iOS7及以下的调用了是会奔溃的,本人就是因为这个被搞得欲仙欲死。好在purelayout在方法中都有介绍。以上介绍的几种使用场景的方法,也都是支持iOS7及以下系统的。
2.在view父容器为nil的时候,执行purelayout的方法会崩溃。

有兴趣的可以直接去github下载官方的demo,写的也是相当ok的。

持续更新~~~

链接:https://www.jianshu.com/p/15bb1bfec5e9

收起阅读 »

【开源项目】使用环信IM开发的一款仿微信APP

项目背景:为了让更多的小伙伴们能够使用环信快速开发出一款自己的社交通讯APP,现进行开源 产品功能:易用IM是一款仿微信APP,包含以下主要功能:1. 单聊,群聊,群聊天中可发随机红包2. 通讯录:管理好友和群组3. 朋友圈:展示自己和好友发的全部可见的动态,...
继续阅读 »

项目背景

为了让更多的小伙伴们能够使用环信快速开发出一款自己的社交通讯APP,现进行开源

 

产品功能:

易用IM是一款仿微信APP,包含以下主要功能:

1. 单聊群聊,群聊天中可发随机红包

2. 通讯录:管理好友和群组

3. 朋友圈展示自己和好友发的全部可见的动态,可点赞、评论、回复和收藏

4. 支付宝充值余额、提现

5. 余额充值提现功能

6. 表情商店:后台维护表情包,用户可一键添加到自己的聊天中

 

软件架构

1. 使用ThinkPHP3.2.3框架开发

2. 数据库mysql5.7

3. IM功能集成环信即时通讯

4. 集成极光推送、阿里云OSS

5. 百度地图



资源地址:

服务端 https://gitee.com/491290710/EasyIM_Service.git

安卓端 https://gitee.com/491290710/EasyIM_Android.git

IOS端 https://gitee.com/491290710/EasyIM_IOS.git 

 

安装教程

1. 服务器建议使用centos7+,运行环境使用lnmp1.5-1.6一键安装

2. 第三方开发参数请在Application/Common/Conf/config.php中进行配置

3. WEB端代码在layim目录中,访问方式为 您的域名/layim

4. 推荐使用阿里云服务器ECS,优惠购买请点击

https://partner.aliyun.com/shop/20690101/newusers?marketer=286

 

使用说明:

1. WEB端体验地址 http://weixin.pro2.liuniukeji.net/layim

2. 可自行注册账号,注册时验证码输入 654321

 

 

项目截图:

 

 

 

 

 

安卓端下载地址:



本开源项目仅做个人学习使用如需商业合作,请联系:

电话: 18660911357

微信  liuniukeji-js

公司官网: https://www.liuniukeji.com/index/easemob

收起阅读 »

SVProgressHUD简单使用以及自定义动画

SVProgressHUD 是一个干净,易于使用的HUD,旨在显示iOS和tvOS正在进行的任务的进展。常用的还有MBProgressHUD.这两个都是很常用的HUD,大体相似,但是还是有一些不同的.MBProgressHUD和SVProgressHUD的区别...
继续阅读 »

SVProgressHUD 是一个干净,易于使用的HUD,旨在显示iOS和tvOS正在进行的任务的进展。
常用的还有MBProgressHUD.这两个都是很常用的HUD,大体相似,但是还是有一些不同的.
MBProgressHUD和SVProgressHUD的区别:
svprogresshud 使用起来很方便,但 可定制 差一些,看它的接口貌似只能添加一个全屏的HUD,不能把它添加到某个视图上面去.
MBProgressHUD 功能全一些,可定制 高一些,而且可以指定加到某一个View上去.用起来可能就没上面那个方便了.
具体还要看你的使用场景.
附上GitHub源码地址:
SVProgressHUD:https://github.com/SVProgressHUD/SVProgressHUD
MBProgressHUD:https://github.com/jdg/MBProgressHUD
今天我们不对二者的区别做详解,有空我会专门写文章对它们的区别做一个详解.
今天我们主要简单介绍一下SVProgressHUD的使用.


安装

通过CocoaPods安装,在Podfile中加入pod 'SVProgressHUD',这里不多做介绍.可以参考文章: CocoaPods的简单使用

使用

SVProgressHUD是已经被创建为单例的,所以不需要被实例化了,可以直接使用.调用它的方法[SVProgressHUD method].

[SVProgressHUD show ];
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT,0),^ {
//耗时的任务
dispatch_async(dispatch_get_main_queue(),^ {
[SVProgressHUD dismiss ];
});
});

显示HUD

可以在下拉刷新或者执行其他耗时任务的时候,使用下面方法之一,来显示不确定任务的状态:

+ (void)show;
+ (void)showWithStatus:(NSString*)string;

效果图分别为:



如果你希望HUD反应任务的进度,可以使用下面方法的其中一个:

+ (void)showProgress:(CGFloat)progress;
+ (void)showProgress:(CGFloat)progress status:(NSString*)status;

通过其他方式可以实现进度条的速度把控.比如:

- (IBAction)clickButtonsShowWithProgress:(id)sender {
progress = 0.0f;
[SVProgressHUD showProgress:0 status:@"Loading"];
[self performSelector:@selector(increaseProgress) withObject:nil afterDelay:0.1f];
}

- (void)increaseProgress {
progress += 0.05f;
[SVProgressHUD showProgress:progress status:@"xuanhe Loading"];

if(progress < 1.0f){
[self performSelector:@selector(increaseProgress) withObject:nil afterDelay:0.1f];
} else {
[self performSelector:@selector(dismiss) withObject:nil afterDelay:0.4f];
}
}

效果如下


还有其他常用的语法:

+(void)showInfoWithStatus :( NSString *)string;
+(void)showSuccessWithStatus :( NSString *)string;
+(void)showErrorWithStatus :( NSString *)string;
+(void)showImage:(UIImage *)image status :( NSString *)string;

取消HUD

HUD可以使用以下方式解除:

+(void)dismiss;
+(void)dismissWithDelay :( NSTimeInterval)delay;
+ (void)dismissWithDelay:(NSTimeInterval)delay completion:(SVProgressHUDDismissCompletion)completion;

可以对这些代码进行改进,比如,在弹框结束后执行其他操作.可以封装一个方法,弹框结束后,执行Block.

定制

SVProgressHUD 可以通过以下方法定制:

+ (void)setDefaultStyle:(SVProgressHUDStyle)style;                  // default is SVProgressHUDStyleLight
+ (void)setDefaultMaskType:(SVProgressHUDMaskType)maskType; // default is SVProgressHUDMaskTypeNone
+ (void)setDefaultAnimationType:(SVProgressHUDAnimationType)type; // default is SVProgressHUDAnimationTypeFlat
+ (void)setContainerView:(UIView*)containerView; // default is window level
+ (void)setMinimumSize:(CGSize)minimumSize; // default is CGSizeZero, can be used to avoid resizing
+ (void)setRingThickness:(CGFloat)width; // default is 2 pt
+ (void)setRingRadius:(CGFloat)radius; // default is 18 pt
+ (void)setRingNoTextRadius:(CGFloat)radius; // default is 24 pt
+ (void)setCornerRadius:(CGFloat)cornerRadius; // default is 14 pt
+ (void)setBorderColor:(nonnull UIColor*)color; // default is nil
+ (void)setBorderWidth:(CGFloat)width; // default is 0
+ (void)setFont:(UIFont*)font; // default is [UIFont preferredFontForTextStyle:UIFontTextStyleSubheadline]
+ (void)setForegroundColor:(UIColor*)color; // default is [UIColor blackColor], only used for SVProgressHUDStyleCustom
+ (void)setBackgroundColor:(UIColor*)color; // default is [UIColor whiteColor], only used for SVProgressHUDStyleCustom
+ (void)setBackgroundLayerColor:(UIColor*)color; // default is [UIColor colorWithWhite:0 alpha:0.4], only used for SVProgressHUDMaskTypeCustom
+ (void)setImageViewSize:(CGSize)size; // default is 28x28 pt
+ (void)setInfoImage:(UIImage*)image; // default is the bundled info image provided by Freepik
+ (void)setSuccessImage:(UIImage*)image; // default is bundled success image from Freepik
+ (void)setErrorImage:(UIImage*)image; // default is bundled error image from Freepik
+ (void)setViewForExtension:(UIView*)view; // default is nil, only used if #define SV_APP_EXTENSIONS is set
+ (void)setGraceTimeInterval:(NSTimeInterval)interval; // default is 0 seconds
+ (void)setMinimumDismissTimeInterval:(NSTimeInterval)interval; // default is 5.0 seconds
+ (void)setMaximumDismissTimeInterval:(NSTimeInterval)interval; // default is CGFLOAT_MAX
+ (void)setFadeInAnimationDuration:(NSTimeInterval)duration; // default is 0.15 seconds
+ (void)setFadeOutAnimationDuration:(NSTimeInterval)duration; // default is 0.15 seconds
+ (void)setMaxSupportedWindowLevel:(UIWindowLevel)windowLevel; // default is UIWindowLevelNormal
+ (void)setHapticsEnabled:(BOOL)hapticsEnabled; // default is NO

样式

作为标准SVProgressHUD提供两种预先配置的样式:

SVProgressHUDStyleLight白色背景黑色图标和文字
SVProgressHUDStyleDark黑色背景与白色图标和文本
如果要使用自定义颜色使用setForegroundColor和setBackgroundColor:。这些方法将HUD的风格置为SVProgressHUDStyleCustom。

触觉反馈

对于具有较新设备的用户(从iPhone 7开始),SVProgressHUD可以根据显示的HUD来自动触发触觉反馈。反馈图如下:

showSuccessWithStatus: < - > UINotificationFeedbackTypeSuccess

showInfoWithStatus: < - > UINotificationFeedbackTypeWarning

showErrorWithStatus: < - > UINotificationFeedbackTypeError

要启用此功能,请使用setHapticsEnabled: 。

具有iPhone 7之前的设备的用户将不会改变功能。

通知

SVProgressHUD发布四个通知,NSNotificationCenter以响应被显示/拒绝:

SVProgressHUDWillAppearNotification 提示框即将出现
SVProgressHUDDidAppearNotification 提示框已经出现
SVProgressHUDWillDisappearNotification 提示框即将消失
SVProgressHUDDidDisappearNotification 提示框已经消失

每个通知通过一个userInfo保存HUD状态字符串(如果有的话)的字典,可以通过检索SVProgressHUDStatusUserInfoKey。

SVProgressHUD SVProgressHUDDidReceiveTouchEventNotification当用户触摸整个屏幕或SVProgressHUDDidTouchDownInsideNotification用户直接触摸HUD时也会发布。由于此通知userInfo未被传递,而对象参数包含UIEvent与触摸相关的参数。

应用扩展

这里对这个功能不做详解.自行摸索.

自定义动画

SVProgressHUD提供了方法可以自定义图片.但是不支持gif格式,直接利用下面的方法依然显示一张静态的图片

[SVProgressHUD showImage:[UIImage imageNamed:@"loading.gif"] status:@"加载中..."];

我们可以把gif转化为一个动态的image.
下面是我在百度上搜的一个方法.仅供参考.

#import <UIKit/UIKit.h>

typedef void (^GIFimageBlock)(UIImage *GIFImage);
@interface UIImage (GIFImage)

/** 根据本地GIF图片名 获得GIF image对象 */
+ (UIImage *)imageWithGIFNamed:(NSString *)name;

/** 根据一个GIF图片的data数据 获得GIF image对象 */
+ (UIImage *)imageWithGIFData:(NSData *)data;

/** 根据一个GIF图片的URL 获得GIF image对象 */
+ (void)imageWithGIFUrl:(NSString *)url and:(GIFimageBlock)gifImageBlock;

下面是.m的方法实现.

#import "UIImage+GIFImage.h"
#import <ImageIO/ImageIO.h>
@implementation UIImage (GIFImage)
+ (UIImage *)imageWithGIFData:(NSData *)data{

if (!data) return nil;
CGImageSourceRef source = CGImageSourceCreateWithData((__bridge CFDataRef)data, NULL);
size_t count = CGImageSourceGetCount(source);
UIImage *animatedImage;
if (count <= 1) {
animatedImage = [[UIImage alloc] initWithData:data];
} else {
NSMutableArray *images = [NSMutableArray array];
NSTimeInterval duration = 0.0f;
for (size_t i = 0; i < count; i++) {
// 拿出了Gif的每一帧图片
CGImageRef image = CGImageSourceCreateImageAtIndex(source, i, NULL);
//Learning... 设置动画时长 算出每一帧显示的时长(帧时长)
NSTimeInterval frameDuration = [UIImage sd_frameDurationAtIndex:i source:source];
duration += frameDuration;
// 将每帧图片添加到数组中
[images addObject:[UIImage imageWithCGImage:image scale:[UIScreen mainScreen].scale orientation:UIImageOrientationUp]];
// 释放真图片对象
CFRelease(image);
}
// 设置动画时长
if (!duration) {
duration = (1.0f / 10.0f) * count;
}
animatedImage = [UIImage animatedImageWithImages:images duration:duration];
}

// 释放源Gif图片
CFRelease(source);
return animatedImage;
}
+ (UIImage *)imageWithGIFNamed:(NSString *)name{
NSUInteger scale = (NSUInteger)[UIScreen mainScreen].scale;
return [self GIFName:name scale:scale];
}

+ (UIImage *)GIFName:(NSString *)name scale:(NSUInteger)scale{
NSString *imagePath = [[NSBundle mainBundle] pathForResource:[NSString stringWithFormat:@"%@@%zdx", name, scale] ofType:@"gif"];
if (!imagePath) {
(scale + 1 > 3) ? (scale -= 1) : (scale += 1);
imagePath = [[NSBundle mainBundle] pathForResource:[NSString stringWithFormat:@"%@@%zdx", name, scale] ofType:@"gif"];
}
if (imagePath) {
// 传入图片名(不包含@Nx)
NSData *imageData = [NSData dataWithContentsOfFile:imagePath];
return [UIImage imageWithGIFData:imageData];
} else {
imagePath = [[NSBundle mainBundle] pathForResource:name ofType:@"gif"];
if (imagePath) {
// 传入的图片名已包含@Nx or 传入图片只有一张 不分@Nx
NSData *imageData = [NSData dataWithContentsOfFile:imagePath];
return [UIImage imageWithGIFData:imageData];
} else {
// 不是一张GIF图片(后缀不是gif)
return [UIImage imageNamed:name];
}
}
}
+ (void)imageWithGIFUrl:(NSString *)url and:(GIFimageBlock)gifImageBlock{
NSURL *GIFUrl = [NSURL URLWithString:url];
if (!GIFUrl) return;
dispatch_async(dispatch_get_global_queue(0, 0), ^{
NSData *CIFData = [NSData dataWithContentsOfURL:GIFUrl];
// 刷新UI在主线程
dispatch_async(dispatch_get_main_queue(), ^{
gifImageBlock([UIImage imageWithGIFData:CIFData]);
});
});
}
#pragma mark - <关于GIF图片帧时长(Learning...)>
+ (float)sd_frameDurationAtIndex:(NSUInteger)index source:(CGImageSourceRef)source {
float frameDuration = 0.1f;
CFDictionaryRef cfFrameProperties = CGImageSourceCopyPropertiesAtIndex(source, index, nil);
NSDictionary *frameProperties = (__bridge NSDictionary *)cfFrameProperties;
NSDictionary *gifProperties = frameProperties[(NSString *)kCGImagePropertyGIFDictionary];
NSNumber *delayTimeUnclampedProp = gifProperties[(NSString *)kCGImagePropertyGIFUnclampedDelayTime];
if (delayTimeUnclampedProp) {
frameDuration = [delayTimeUnclampedProp floatValue];
}
else {
NSNumber *delayTimeProp = gifProperties[(NSString *)kCGImagePropertyGIFDelayTime];
if (delayTimeProp) {
frameDuration = [delayTimeProp floatValue];
}
}
// Many annoying ads specify a 0 duration to make an image flash as quickly as possible.
// We follow Firefox's behavior and use a duration of 100 ms for any frames that specify
// a duration of <= 10 ms. See and
// for more information.
if (frameDuration < 0.011f) {
frameDuration = 0.100f;
}
CFRelease(cfFrameProperties);
return frameDuration;
}
@end

这个是UIimage的分类,在用到的控制器里面调用代码方法即可.这个分类实现我也不太懂.只会用.

_imgView1.image = [UIImage imageWithGIFNamed:@"xuanxuan"];

NSString *path = [[NSBundle mainBundle] pathForResource:@"xuanxuan" ofType:@"gif"];
NSData *imgData = [NSData dataWithContentsOfFile:path];
_imgView2.image = [UIImage imageWithGIFData:imgData];


[UIImage imageWithGIFUrl:@"https://timgsa.baidu.com/timg?image&quality=80&size=b9999_10000&sec=1495708809771&di=da92fc5cf3bdd684711ab5124ee43183&imgtype=0&src=http%3A%2F%2Fimgsrc.baidu.com%2Fforum%2Fw%253D580%2Fsign%3D91bd6cd2d42a60595210e1121835342d%2F212eb9389b504fc215d0301ee6dde71190ef6d1a.jpg" and:^(UIImage *GIFImage) {
_imgView3.image = GIFImage;
}];

转自:https://www.jianshu.com/p/fa22b7c27e1d

收起阅读 »

IPFS对标HTTP,IPFS的优势是什么?

FIL
区块链技术的高速发展,离不开底层技术的支持,而且肯定先于区块链技术的发展。目前来看,IPFS—Filecoin是最有可能成为区块链底层基础设施的技术。这也表明IPFS—Filecoin必然会随之快速发展。造成这一现象的原因之一在于区块链技术本身的限制,它不能存...
继续阅读 »

区块链技术的高速发展,离不开底层技术的支持,而且肯定先于区块链技术的发展。目前来看,IPFS—Filecoin是最有可能成为区块链底层基础设施的技术。这也表明IPFS—Filecoin必然会随之快速发展。造成这一现象的原因之一在于区块链技术本身的限制,它不能存储存储数据,这也是自区块链技术诞生之后限制区块链技术发展的重要原因之一。IPFS矿机布局,避免踩坑(FIL37373)

Filecoin与IPFS(InterPlanetary File System,星际文件系统)是近两年来非常热门的概念。所谓IPFS是一个基于内容寻址的、分布式的、新型超媒体传输协议。IPFS支持创建完全分布式的应用。它旨在使用网络更快、更安全、更开放。IPFS是一个分布式文件系统,它的目标是将所有计算设备连接到同一个文件系统,从而成为一个全球统一的储存系统。而Filecoin是IPFS的激励层。

IPFS对标HTTP,IPFS的优势是什么?

IPFS星际文件存储系统,是一种p2p协议。相对于传统云存储有以下几个优点:

1. 便宜。IPFS存储空间不由服务商提供,而是接入网络的节点来提供,可以说是任何人都可以成为节点的一部分,所以非常便宜。

2. 速度快。IPFS协议下,文件冗余存储在世界各地,类似于CDN一样。当用户发起下载请求时,附近的借点都会收到信息并传送文件给你,而你只接收最先到达的文件。而传统云服务依赖于中心服务器到你的主机的线路和带宽。IPFS矿机布局,避免踩坑(FIL37373)

3. 安全性高。目前没有任何云存储敢保证自己的服务器不会遭到黑客袭击并保证数据安全。但是IPFS协议下文件在上传的时候会在每个节点保留其记录,系统检测单到文件丢失的时候会自动恢复。且由于其分布性存储的特征,黑客无法同时攻击所有节点。IPFS矿机布局,避免踩坑(FIL37373)

4.隐私保护。对于加密文件的上传使用非对称加密的方式,即除非对方掌握了私钥,否则无法破解。

IPFS分布式存储结构,各项数值优于HTTP,且发布区块链项目Filecoin,能够为IPFS技术存储提供足够的微型存储空间(节点),IPFS,与Filecoin即形成紧密的共生关系,相辅相成。

IPFS网络要想稳定运行需要用户贡献他们的存储空间、网络带宽,如果没有恰当的奖励机制,那么巨大的资源开销很难维持网络持久运转。受到比特币网络的启发,将Filecoin作为IPFS的激励层就是一种解决方案了。对于用户而言,Filecoin能够提高存取速度和效率,能带来去中心化的应用;对于矿工,贡献网络资源可以获得一笔不错的收益。

收起阅读 »

iOS缓存设计(YYCache思路)

iOS缓存设计(YYCache思路)前言:前段时间业务有缓存需求,于是结合YYCache和业务需求,做了缓存层(内存&磁盘)+ 网络层的方案尝试由于YYCache 采用了内存缓存和磁盘缓存组合方式,性能优良,这里拿它的原理来说下如何设计一套缓存的思路,...
继续阅读 »

iOS缓存设计(YYCache思路)

前言:
前段时间业务有缓存需求,于是结合YYCache和业务需求,做了缓存层(内存&磁盘)+ 网络层的方案尝试
由于YYCache 采用了内存缓存和磁盘缓存组合方式,性能优良,这里拿它的原理来说下如何设计一套缓存的思路,并结合网络整理一套完整流程

目录

初步认识缓存
如何优化缓存(YYCache设计思想)
网络和缓存同步流程
一、初步认识缓存

1. 什么是缓存?

我们做一个缓存前,先了解它是什么,缓存是本地数据存储,存储方式主要包含两种:磁盘储存和内存存储

1.1 磁盘存储

磁盘缓存,磁盘也就是硬盘缓存,磁盘是程序的存储空间,磁盘缓存容量大速度慢,磁盘是永久存储东西的,iOS为不同数据管理对存储路径做了规范如下:
1、每一个应用程序都会拥有一个应用程序沙盒。
2、应用程序沙盒就是一个文件系统目录。
沙盒根目录结构:Documents、Library、temp。

磁盘存储方式主要有文件管理和数据库,其特性:


1.2 内存存储

内存缓存,内存缓存是指当前程序运行空间,内存缓存速度快容量小,它是供cpu直接读取,比如我们打开一个程序,他是运行在内存中的,关闭程序后内存又会释放。
iOS内存分为5个区:栈区,堆区,全局区,常量区,代码区

栈区stack:这一块区域系统会自己管理,我们不用干预,主要存一些局部变量,以及函数跳转时的现场保护。因此大量的局部变量,深递归,函数循环调用都可能导致内存耗尽而运行崩溃。
堆区heap:与栈区相对,这一块一般由我们自己管理,比如alloc,free的操作,存储一些自己创建的对象。
全局区(静态区static):全局变量和静态变量都存储在这里,已经初始化的和没有初始化的会分开存储在相邻的区域,程序结束后系统会释放
常量区:存储常量字符串和const常量
代码区:存储代码

在程序中声明的容器(数组 、字典)都可看做内存中存储,特性如下:


2. 缓存做什么?

我们使用场景比如:离线加载,预加载,本地通讯录...等,对非网络数据,使用本地数据管理的一种,具体使用场景有很多

3. 怎么做缓存?

简单缓存可以仅使用磁盘存储,iOS主要提供四种磁盘存储方式:

NSKeyedArchiver: 采用归档的形式来保存数据, 该数据对象需要遵守NSCoding协议, 并且该对象对应的类必须提供encodeWithCoder:和initWithCoder:方法.

//自定义Person实现归档解档
//.h文件
#import <Foundation/Foundation.h>
@interface Person : NSObject<NSCoding>
@property(nonatomic,copy) NSString * name;

@end

//.m文件
#import "Person.h"
@implementation Person
//归档要实现的协议方法
- (void)encodeWithCoder:(NSCoder *)aCoder {
[aCoder encodeObject:_name forKey:@"name"];
}
//解档要实现的协议方法
- (instancetype)initWithCoder:(NSCoder *)aDecoder {
if (self = [super init]) {
_name = [aDecoder decodeObjectForKey:@"name"];
}
return self;
}
@end

使用归档解档

// 将数据存储在path路径下归档文件
[NSKeyedArchiver archiveRootObject:p toFile:path];
// 根据path路径查找解档文件
Person *p = [NSKeyedUnarchiver unarchiveObjectWithFile:path];

缺点:归档的形式来保存数据,只能一次性归档保存以及一次性解压。所以只能针对小量数据,如果想改动数据的某一小部分,需要解压整个数据或者归档整个数据。

NSUserDefaults: 用来保存应用程序设置和属性、用户保存的数据。用户再次打开程序或开机后这些数据仍然存在。
NSUserDefaults可以存储的数据类型包括:NSData、NSString、NSNumber、NSDate、NSArray、 NSDictionary。

// 以键值方式存储
[[NSUserDefaults standardUserDefaults] setObject:@"value" forKey:@"key"];
// 以键值方式读取
[[NSUserDefaults standardUserDefaults] objectForKey:@"key"];

Write写入方式:永久保存在磁盘中。具体方法为:

//将NSData类型对象data写入文件,文件名为FileName
[data writeToFile:FileName atomically:YES];
//从FileName中读取出数据
NSData *data=[NSData dataWithContentsOfFile:FileName options:0 error:NULL];

SQLite:采用SQLite数据库来存储数据。SQLite作为⼀一中小型数据库,应用ios中跟其他三种保存方式相比,相对复杂一些

//打开数据库
if (sqlite3_open([databaseFilePath UTF8String], &database)==SQLITE_OK) {
NSLog(@"sqlite dadabase is opened.");
} else { return;}//打开不成功就返回

//在打开了数据库的前提下,如果数据库没有表,那就开始建表了哦!
char *error;
const char *createSql="create table(id integer primary key autoincrement, name text)"; if (sqlite3_exec(database, createSql, NULL, NULL, &error)==SQLITE_OK) {
NSLog(@"create table is ok.");
} else {
sqlite3_free(error);//每次使用完毕清空error字符串,提供给下⼀一次使用
}

// 建表完成之后, 插入记录
const char *insertSql="insert into a person (name) values(‘gg’)";
if (sqlite3_exec(database, insertSql, NULL, NULL, &error)==SQLITE_OK) {
NSLog(@"insert operation is ok.");
} else {
sqlite3_free(error);//每次使用完毕清空error字符串,提供给下一次使用
}

上面提到的磁盘存储特性,具备空间大、可持久、但是读取慢,面对大量数据频繁读取时更加明显,以往测试中磁盘读取比内存读取保守测量低于几十倍,那我们怎么解决磁盘读取慢的缺点呢? 又如何利用内存的优势呢?

二、 如何优化缓存(YYCache设计思想)

YYCache背景知识:
源码中由两个主要类构成


YYMemoryCache (内存缓存)
操作YYLinkedMap中数据, 为实现内存优化,采用双向链表数据结构实现 LRU算法,YYLinkedMapItem 为每个子节点
YYDiskCache (磁盘缓存)
不会直接操作缓存对象(sqlite/file),而是通过 YYKVStorage 来间接的操作缓存对象。
容量管理:

ageLimit :时间周期限制,比如每天或每星期开始清理
costLimit: 容量限制,比如超出10M后开始清理内存
countLimit : 数量限制, 比如超出1000个数据就清理
这里借用YYCache设计, 来讲述缓存优化

1. 磁盘+内存组合优化
利用内存和磁盘特性,融合各自优点,整合如下:


APP会优先请求内存缓冲中的资源
如果内存缓冲中有,则直接返回资源文件, 如果没有的话,则会请求资源文件,这时资源文件默认资源为本地磁盘存储,需要操作文件系统或数据库来获取。
获取到的资源文件,先缓存到内存缓存,方便以后不再重复获取,节省时间。
然后就是从缓存中取到数据然后给app使用。
这样就充分结合两者特性,利用内存读取快特性减少读取数据时间,

YYCache 源码解析:

- (id<NSCoding>)objectForKey:(NSString *)key {
// 1.如果内存缓存中存在则返回数据
id<NSCoding> object = [_memoryCache objectForKey:key];
if (!object) {
// 2.若不存在则查取磁盘缓存数据
object = [_diskCache objectForKey:key];
if (object) {
// 3.并将数据保存到内存中
[_memoryCache setObject:object forKey:key];
}
}
return object;
}

2. 内存优化-- 提高内存命中率

但是我们想在基础上再做优化,比如想让经常访问的数据保留在内存中,提高内存的命中率,减少磁盘的读取,那怎么做处理呢? -- LRU算法


LRU算法:我们可以将链表看成一串数据链,每个数据是这个串上的一个节点,经常访问的数据移动到头部,等数据超出容量后从链表后面的一些节点销毁,这样经常访问数据在头部位置,还保留在内存中。

链表实现结构图:


YYCache 源码解析

/**
A node in linked map.
Typically, you should not use this class directly.
*/
@interface _YYLinkedMapNode : NSObject {
@package
__unsafe_unretained _YYLinkedMapNode *_prev; // retained by dic
__unsafe_unretained _YYLinkedMapNode *_next; // retained by dic
id _key;
id _value;
NSUInteger _cost;
NSTimeInterval _time;
}
@end
@implementation _YYLinkedMapNode
@end
/**
A linked map used by YYMemoryCache.
It's not thread-safe and does not validate the parameters.
Typically, you should not use this class directly.
*/
@interface _YYLinkedMap : NSObject {
@package
CFMutableDictionaryRef _dic; // do not set object directly
NSUInteger _totalCost;
NSUInteger _totalCount;
_YYLinkedMapNode *_head; // MRU, do not change it directly
_YYLinkedMapNode *_tail; // LRU, do not change it directly
BOOL _releaseOnMainThread;
BOOL _releaseAsynchronously;
}

/// Insert a node at head and update the total cost.
/// Node and node.key should not be nil.
- (void)insertNodeAtHead:(_YYLinkedMapNode *)node;

/// Bring a inner node to header.
/// Node should already inside the dic.
- (void)bringNodeToHead:(_YYLinkedMapNode *)node;

/// Remove a inner node and update the total cost.
/// Node should already inside the dic.
- (void)removeNode:(_YYLinkedMapNode *)node;

/// Remove tail node if exist.
- (_YYLinkedMapNode *)removeTailNode;

/// Remove all node in background queue.
- (void)removeAll;

@end

_YYLinkedMapNode *_prev 为该节点的头指针,指向前一个节点
_YYLinkedMapNode *_next为该节点的尾指针,指向下一个节点
头指针和尾指针将一个个子节点串连起来,形成双向链表

来看下bringNodeToHead:的源码实现,它是实现LRU算法主要方法,移动node子结点到链头。

(详细已注释在代码中)

- (void)bringNodeToHead:(_YYLinkedMapNode *)node {
if (_head == node) return; // 如果当前节点是链头,则不需要移动

// 链表中存了两个指向链头(_head)和链尾(_tail)的指针,便于链表访问
if (_tail == node) {
_tail = node->_prev; // 若当前节点为链尾,则更新链尾指针
_tail->_next = nil; // 链尾的尾节点这里设置为nil
} else {
// 比如:A B C 链表, 将 B拿走,将A C重新联系起来
node->_next->_prev = node->_prev; // 将node的下一个节点的头指针指向node的上一个节点,
node->_prev->_next = node->_next; // 将node的上一个节点的尾指针指向node的下一个节点
}
node->_next = _head; // 将当前node节点的尾指针指向之前的链头,因为此时node为最新的第一个节点
node->_prev = nil; // 链头的头节点这里设置为nil
_head->_prev = node; // 之前的_head将为第二个节点
_head = node; // 当前node成为新的_head
}

其他方法就不挨个举例了,具体可翻看源码,这些代码结构清晰,类和函数遵循单一职责,接口高内聚,低耦合,是个不错的学习示例!

3. 磁盘优化 - 数据分类存储

YYDiskCache 是一个线程安全的磁盘缓存,基于 sqlite 和 file 来做的磁盘缓存,我们的缓存对象可以自由的选择存储类型,
下面简单对比一下:

sqlite: 对于小数据(例如 NSNumber)的存取效率明显高于 file。
file: 对于较大数据(例如高质量图片)的存取效率优于 sqlite。
所以 YYDiskCache 使用两者配合,灵活的存储以提高性能。

另外:
YYDiskCache 具有以下功能:

它使用 LRU(least-recently-used) 来删除对象。
支持按 cost,count 和 age 进行控制。
它可以被配置为当没有可用的磁盘空间时自动驱逐缓存对象。
它可以自动抉择每个缓存对象的存储类型(sqlite/file)以便提供更好的性能表现。
YYCache源码解析

// YYKVStorageItem 是 YYKVStorage 中用来存储键值对和元数据的类
// 通常情况下,我们不应该直接使用这个类
@interface YYKVStorageItem : NSObject
@property (nonatomic, strong) NSString *key; ///< key
@property (nonatomic, strong) NSData *value; ///< value
@property (nullable, nonatomic, strong) NSString *filename; ///< filename (nil if inline)
@property (nonatomic) int size; ///< value's size in bytes
@property (nonatomic) int modTime; ///< modification unix timestamp
@property (nonatomic) int accessTime; ///< last access unix timestamp
@property (nullable, nonatomic, strong) NSData *extendedData; ///< extended data (nil if no extended data)
@end


/**
YYKVStorage 是基于 sqlite 和文件系统的键值存储。
通常情况下,我们不应该直接使用这个类。

@warning
这个类的实例是 *非* 线程安全的,你需要确保
只有一个线程可以同时访问该实例。如果你真的
需要在多线程中处理大量的数据,应该分割数据
到多个 KVStorage 实例(分片)。
*/
@interface YYKVStorage : NSObject

#pragma mark - Attribute
@property (nonatomic, readonly) NSString *path; /// storage 路径
@property (nonatomic, readonly) YYKVStorageType type; /// storage 类型
@property (nonatomic) BOOL errorLogsEnabled; /// 是否开启错误日志

#pragma mark - Initializer
- (nullable instancetype)initWithPath:(NSString *)path type:(YYKVStorageType)type NS_DESIGNATED_INITIALIZER;

#pragma mark - Save Items
- (BOOL)saveItem:(YYKVStorageItem *)item;
...

#pragma mark - Remove Items
- (BOOL)removeItemForKey:(NSString *)key;
...

#pragma mark - Get Items
- (nullable YYKVStorageItem *)getItemForKey:(NSString *)key;
...

#pragma mark - Get Storage Status
- (BOOL)itemExistsForKey:(NSString *)key;
- (int)getItemsCount;
- (int)getItemsSize;

@end

我们只需要看一下 YYKVStorageType 这个枚举,它决定着 YYKVStorage 的存储类型。

YYKVStorageType

/**
存储类型,指示“YYKVStorageItem.value”存储在哪里。

@discussion
通常,将数据写入 sqlite 比外部文件更快,但是
读取性能取决于数据大小。在测试环境 iPhone 6s 64G,
当数据较大(超过 20KB)时从外部文件读取数据比 sqlite 更快。
*/
typedef NS_ENUM(NSUInteger, YYKVStorageType) {
YYKVStorageTypeFile = 0, // value 以文件的形式存储于文件系统
YYKVStorageTypeSQLite = 1, // value 以二进制形式存储于 sqlite
YYKVStorageTypeMixed = 2, // value 将根据你的选择基于上面两种形式混合存储
};

总结:

这里说了YYCache几个主要设计优化之处,其实细节上也有很多不错的处理,比如:

线程安全
如果说 YYCache 这个类是一个纯逻辑层的缓存类(指 YYCache 的接口实现全部是调用其他类完成),那么 YYMemoryCache 与 YYDiskCache 还是做了一些事情的(并没有 YYCache 当甩手掌柜那么轻松),其中最显而易见的就是 YYMemoryCache 与 YYDiskCache 为 YYCache 保证了线程安全。
YYMemoryCache 使用了 pthread_mutex 线程锁来确保线程安全,而 YYDiskCache 则选择了更适合它的 dispatch_semaphore,上文已经给出了作者选择这些锁的原因。

性能

YYCache 中对于性能提升的实现细节:

异步释放缓存对象
锁的选择
使用 NSMapTable 单例管理的 YYDiskCache
YYKVStorage 中的 _dbStmtCache
甚至使用 CoreFoundation 来换取微乎其微的性能提升

3. 网络和缓存同步流程

结合网络层和缓存层,设计了一套接口缓存方式,比较灵活且速度得到提升; 比如首页界面可能由多个接口提供数据,没有采用整块存储而是将存储细分到每个接口中,有API接口控制,基本结构如下:

主要分为:

应用层 :显示数据
管理层: 管理网络层和缓存层,为应用层提供数据支持
网络层: 请求网络数据
缓存层: 缓存数据
层级图:


服务端每套数据对应一个version (或时间戳),若后台数据发生变更,则version发生变化,在返回客户端数据时并将version一并返回。
当客户端请求网络时,将本地上一次数据对应version上传。
服务端获取客户端传来得version后,与最新的version进行对比,若version不一致,则返回最新数据,若未发生变化,服务端不需要返回全部数据只需返回304(No Modify) 状态值
客户端接到服务端返回数据,若返回全部数据非304,客户端则将最新数据同步到本地缓存中;客户端若接到304状态值后,表示服务端数据和本地数据一致,直接从缓存中获取显示
这也是ETag的大致流程;详细可以查看 https://baike.baidu.com/item/ETag/4419019?fr=aladdin

源码示例

- (void)getDataWithPage:(NSNumber *)page pageSize:(NSNumber *)pageSize option:(DataSourceOption)option completion:(void (^)(HomePageListCardModel * _Nullable, NSError * _Nullable))completionBlock {
NSString *cacheKey = CacheKey(currentUser.userId, PlatIndexRecommendation);// 全局静态常量 (userid + apiName)
// 根据需求而定是否需要缓存方式,网络方式走304逻辑
switch (option) {
case DataSourceCache:
{
if ([_cache containsObjectForKey:cacheKey]) {
completionBlock((HomePageListCardModel *)[self->_cache objectForKey:cacheKey], nil);
} else {
completionBlock(nil, LJDError(400, @"缓存中不存在"));
}
}
break;
case DataSourceNetwork:
{
[NetWorkServer requestDataWithPage:page pageSize:pageSize completion:^(id _Nullable responseObject, NSError * _Nullable error) {
if (responseObject && !error) {
HomePageListCardModel *model = [HomePageListCardModel yy_modelWithJSON:responseObject];
if (model.errnonumber == 304) { //取缓存数据
completionBlock((HomePageListCardModel *)[self->_cache objectForKey:cacheKey], nil);
} else {
completionBlock(model, error);
[self->_cache setObject:model forKey:cacheKey]; //保存到缓存中
}
} else {
completionBlock(nil, error);
}
}];
}
break;

default:
break;
}
}

这样做好处:

对于不频繁更新数据的接口,节省了大量JSON数据转化时间
节约流量,节省加载时长
用户界面显示加快
总结:项目中并不一定完全这样做,有时候过渡设计也是一种浪费,多了解其他设计思路后,针对项目找到适合的才是最好的!

参考文献:
YYCache: https://github.com/ibireme/YYCache
YYCache 设计思路 :https://blog.ibireme.com/2015/10/26/yycache/

链接:https://www.jianshu.com/p/b592ee20f09a

收起阅读 »

iOS进阶:WebViewJavascriptBridge源码解读

WebViewJavascriptBridge GitHub地址jsBridge框架是解决客户端与网页交互的方法之一。最主要的实现思路是客户端在webivew的代理方法中拦截url,根据url的类型来做不同处理。接下去会以jsBridge提供demo中的为例,...
继续阅读 »

WebViewJavascriptBridge GitHub地址

jsBridge框架是解决客户端与网页交互的方法之一。最主要的实现思路是客户端在webivew的代理方法中拦截url,根据url的类型来做不同处理。接下去会以jsBridge提供demo中的为例,从使用的角度,一步步分析它是如何实现的。

注:在iOS8后,苹果推出了WKWebView。对于UIWebView和WKWebView,jsBridge都能实现客户端与网页交互,且实现的方式类似,因此本文会以UIWebView为例来分析。

本文会通过以下几点来介绍框架的实现:

框架结构
WebViewJavascriptBridge_JS
WebViewJavascriptBridge WKWebViewJavascriptBridge
WebViewJavascriptBridgeBase
网页通知客户端的实现
客户端通知网页的实现
js环境注入问题
总结

框架结构


WebViewJavascriptBridge_JS

WebViewJavascriptBridge_JS 简单的说就是网页的js环境,需要客户端在网页初始化的时候注入到网页中去。如果不注入就无法实现网页与客户端的交互。该类只有一个返回值为NSString 的方法:NSString * WebViewJavascriptBridge_js(); 。

至于究竟何时注入,如何注入,会在接下去的分析中写到。

WebViewJavascriptBridge WKWebViewJavascriptBridge

这两个类分别对应UIWebView和WKWebView。看名字就可以知道这两个类是交互的桥梁,不管是网页同时客户端还是客户端通知网页,都是通过这两个类来完成通知的。

WebViewJavascriptBridgeBase

WebViewJavascriptBridgeBase个人认为类似数据处理工具类。

该类中存着客户端注册的方法以及对应实现:@property (strong, nonatomic) NSMutableDictionary* messageHandlers;

也存着客户端通知网页后的回调实现:@property (strong, nonatomic) NSMutableDictionary* responseCallbacks;

同时,该类还实现了之前提的网页js环境注入方法:-(void)injectJavascriptFile;

还有一些url类别判断方法,这里不一一举例了。

网页通知客户端的实现

要让客户端能够响应网页的通知,首先必须使用桥梁注册方法名和实现,然后存起来,等待网页的通知。

[_bridge registerHandler:@"testObjcCallback" handler:^(id data, WVJBResponseCallback responseCallback) {
NSLog(@"testObjcCallback called: %@", data);
responseCallback(@"Response from testObjcCallback");
}];

客户端注册方法时,bridge做了些什么事情呢?其实bridge只是简单地将方法名和实现block分别作为键值存到了messageHandlers属性中。

- (void)registerHandler:(NSString *)handlerName handler:(WVJBHandler)handler {
_base.messageHandlers[handlerName] = [handler copy];
}

接下来,网页想要调用客户端的testObjcCallback方法了。网页上有一个按钮,点击后调用客户端方法,网页的js代码如下:

var callbackButton = document.getElementById('buttons').appendChild(document.createElement('button'))
callbackButton.innerHTML = 'Fire testObjcCallback'
callbackButton.onclick = function(e) {
bridge.callHandler('testObjcCallback', {'foo': 'bar'}, function(response) {
log('JS got response', response)
})
}

这里网页调用的方法为bridge.callHandler,这里你可能会有疑问,为什么bridge对象哪来的,callHandler方法又是哪来的。关于这个,这边先简单的说一下:这个bridge其实就是我们之前提到的js环境提供的,callHandler方法也是环境中的代码实现的,如果没有js环境,网页就拿不到bridge,也就无法成功调起客户端的方法。这边可以简单的理解为这个环境就相当于是我们客户端的WebViewJavascriptBridge框架,客户端如果不导入,也就无法使用jsbridge。网页也是类似,如果不注入,就无法使用jsbridge。而区别就在于,客户端的这个框架是运行前导入的,而网页这个环境是由客户端加载到该网页时,动态注入的。

至于详细的注入,会在下文中分析说明。

js环境文件中,bridge.callHandler方法实现:

function callHandler(handlerName, data, responseCallback) {
if (arguments.length == 2 && typeof data == 'function') {
responseCallback = data;
data = null;
}
_doSend({ handlerName:handlerName, data:data }, responseCallback);
}


function _doSend(message, responseCallback) {
if (responseCallback) {
var callbackId = 'cb_'+(uniqueId++)+'_'+new Date().getTime();
responseCallbacks[callbackId] = responseCallback;
message['callbackId'] = callbackId;
}
sendMessageQueue.push(message);
messagingIframe.src = CUSTOM_PROTOCOL_SCHEME + '://' + QUEUE_HAS_MESSAGE;
}

由于本质上网页处理发送通知的思路和客户端的一致,而我们队客户端的oc代码更好理解,因此我打算将这段代码的分析跳过,等到分析客户端通知网页时,再仔细讲。这边只需要知道

1.字典中加了一个callbackId字段,这个字段是用来等客户端调用完方法后,网页能找到对应的实现的。同时网页将实现存到了它管理的字典中:responseCallbacks[callbackId] = responseCallback;

2.网页最终将字典压到了sendMessageQueue中,并调用了messagingIframe.src = CUSTOM_PROTOCOL_SCHEME + '://' + QUEUE_HAS_MESSAGE;

var CUSTOM_PROTOCOL_SCHEME = 'https';
var QUEUE_HAS_MESSAGE = '__wvjb_queue_message__';

3.字典中的数据是:

{   
handlerName : "testObjcCallback",
data : {'foo': 'bar'},
callbackId : 'cb_'+(uniqueId++)+'_'+new Date().getTime()
}

这时,客户端的webview代码方法就能拦截到url:


正是网页调用的:https://__wvjb_queue_message__/。然后客户端是如果去判断url并做相应处理呢?下面为拦截的源码:

- (BOOL)webView:(UIWebView *)webView shouldStartLoadWithRequest:(NSURLRequest *)request navigationType:(UIWebViewNavigationType)navigationType {
if (webView != _webView) { return YES; }

NSURL *url = [request URL];
__strong WVJB_WEBVIEW_DELEGATE_TYPE* strongDelegate = _webViewDelegate;
if ([_base isWebViewJavascriptBridgeURL:url]) {
if ([_base isBridgeLoadedURL:url]) {
[_base injectJavascriptFile];
} else if ([_base isQueueMessageURL:url]) {
NSString *messageQueueString = [self _evaluateJavascript:[_base webViewJavascriptFetchQueyCommand]];
[_base flushMessageQueue:messageQueueString];
} else {
[_base logUnkownMessage:url];
}
return NO;
} else if (strongDelegate && [strongDelegate respondsToSelector:@selector(webView:shouldStartLoadWithRequest:navigationType:)]) {
return [strongDelegate webView:webView shouldStartLoadWithRequest:request navigationType:navigationType];
} else {
return YES;
}
}

这时,由于传过来的是https://__wvjb_queue_message__/,会进[_base isQueueMessageURL:url]的判断中,然后做以下处理:

NSString *messageQueueString = [self _evaluateJavascript:[_base webViewJavascriptFetchQueyCommand]];
[_base flushMessageQueue:messageQueueString];

第一行代码为从网页的sendMessageQueue中获取到数据,还记得之前网页把调用的相关数据存到了sendMessageQueue中吗?这个时候,客户端又把它取出来了。然后第二行代码,客户端开始处理这个数据:

- (void)flushMessageQueue:(NSString *)messageQueueString{
if (messageQueueString == nil || messageQueueString.length == 0) {
NSLog(@"WebViewJavascriptBridge: WARNING: ObjC got nil while fetching the message queue JSON from webview. This can happen if the WebViewJavascriptBridge JS is not currently present in the webview, e.g if the webview just loaded a new page.");
return;
}

id messages = [self _deserializeMessageJSON:messageQueueString];
for (WVJBMessage* message in messages) {
if (![message isKindOfClass:[WVJBMessage class]]) {
NSLog(@"WebViewJavascriptBridge: WARNING: Invalid %@ received: %@", [message class], message);
continue;
}
[self _log:@"RCVD" json:message];

NSString* responseId = message[@"responseId"];
if (responseId) {
WVJBResponseCallback responseCallback = _responseCallbacks[responseId];
responseCallback(message[@"responseData"]);
[self.responseCallbacks removeObjectForKey:responseId];
} else {
WVJBResponseCallback responseCallback = NULL;
NSString* callbackId = message[@"callbackId"];
if (callbackId) {
responseCallback = ^(id responseData) {
if (responseData == nil) {
responseData = [NSNull null];
}

WVJBMessage* msg = @{ @"responseId":callbackId, @"responseData":responseData };
[self _queueMessage:msg];
};
} else {
responseCallback = ^(id ignoreResponseData) {
// Do nothing
};
}

WVJBHandler handler = self.messageHandlers[message[@"handlerName"]];

if (!handler) {
NSLog(@"WVJBNoHandlerException, No handler for message from JS: %@", message);
continue;
}

handler(message[@"data"], responseCallback);
}
}
}

这段代码有点多,核心思路是将获得的数据转换成字典,然后从客户端的messageHandlers中取出方法名对应的block,并调用:handler(message[@"data"], responseCallback);


这边还需要特别注意的是,callbackId问题。在这个例子中,是存在callbackId的,因为网页是有写调用完客户端后的回调的,所以这边做了处理,如果有callbackId的话,再创建一个responseCallback,等客户端调用完网页通知的方法后再调用。

还记得当初客户端注册方法时的代码吗:

[_bridge registerHandler:@"testObjcCallback" handler:^(id data, WVJBResponseCallback responseCallback) {
NSLog(@"testObjcCallback called: %@", data);
responseCallback(@"Response from testObjcCallback");
}];

这边就将这个handler的block取出来,然后将message[@"data"]和responseCallback作为参数调用。调用完后又调用了responseCallback,将数据又发回网页去。这边具体的发送会在下文客户端通知网页分析中写到。这边这需要知道,如果存在callbackId,就会将callbackId和数据又发回网页。

WVJBMessage* msg = @{ @"responseId":callbackId, @"responseData":responseData };
[self _queueMessage:msg];

以上就是网页通知客户端的大致实现。

客户端通知网页

其实客户端通知网页的大致思路是和上文类似的。在客户端调用之前,网页肯定是已经注册好了客户端要调用的方法,就如上文中,客户端也已经注册好了网页通知的方法一样。下面为网页注册的代码:

bridge.registerHandler('testJavascriptHandler', function(data, responseCallback) {
log('ObjC called testJavascriptHandler with', data)
var responseData = { 'Javascript Says':'Right back atcha!' }
log('JS responding with', responseData)
responseCallback(responseData)
})

看看registerHandler方法如何实现:

function registerHandler(handlerName, handler) {
messageHandlers[handlerName] = handler;
}

恩,是不是和客户端的注册非常相似?

接下来再看看客户端是如何调用的:

- (void)callHandler:(id)sender {
id data = @{ @"greetingFromObjC": @"Hi there, JS!" };
[_bridge callHandler:@"testJavascriptHandler" data:data responseCallback:^(id response) {
NSLog(@"testJavascriptHandler responded: %@", response);
}];
}

callHandler方法实现:

- (void)callHandler:(NSString *)handlerName data:(id)data responseCallback:(WVJBResponseCallback)responseCallback {
[_base sendData:data responseCallback:responseCallback handlerName:handlerName];
}

sendData实现:

- (void)sendData:(id)data responseCallback:(WVJBResponseCallback)responseCallback handlerName:(NSString*)handlerName {
NSMutableDictionary* message = [NSMutableDictionary dictionary];

if (data) {
message[@"data"] = data;
}

if (responseCallback) {
NSString* callbackId = [NSString stringWithFormat:@"objc_cb_%ld", ++_uniqueId];
self.responseCallbacks[callbackId] = [responseCallback copy];
message[@"callbackId"] = callbackId;
}

if (handlerName) {
message[@"handlerName"] = handlerName;
}
[self _queueMessage:message];
}

客户端将数据封装成一个字段,这时这个字典的值为:

{
callbackId = "objc_cb_1";
data = {
greetingFromObjC = "Hi there, JS!";
};
handlerName = testJavascriptHandler;
}

还是和网页的处理非常一致。下面看看客户端是如何通知网页的:

- (void)_queueMessage:(WVJBMessage*)message {
if (self.startupMessageQueue) {
[self.startupMessageQueue addObject:message];
} else {
[self _dispatchMessage:message];
}
}

- (void)_dispatchMessage:(WVJBMessage*)message {
NSString *messageJSON = [self _serializeMessage:message pretty:NO];
[self _log:@"SEND" json:messageJSON];
messageJSON = [messageJSON stringByReplacingOccurrencesOfString:@"\\" withString:@"\\\\"];
messageJSON = [messageJSON stringByReplacingOccurrencesOfString:@"\"" withString:@"\\\""];
messageJSON = [messageJSON stringByReplacingOccurrencesOfString:@"\'" withString:@"\\\'"];
messageJSON = [messageJSON stringByReplacingOccurrencesOfString:@"\n" withString:@"\\n"];
messageJSON = [messageJSON stringByReplacingOccurrencesOfString:@"\r" withString:@"\\r"];
messageJSON = [messageJSON stringByReplacingOccurrencesOfString:@"\f" withString:@"\\f"];
messageJSON = [messageJSON stringByReplacingOccurrencesOfString:@"\u2028" withString:@"\\u2028"];
messageJSON = [messageJSON stringByReplacingOccurrencesOfString:@"\u2029" withString:@"\\u2029"];

NSString* javascriptCommand = [NSString stringWithFormat:@"WebViewJavascriptBridge._handleMessageFromObjC('%@');", messageJSON];
if ([[NSThread currentThread] isMainThread]) {
[self _evaluateJavascript:javascriptCommand];

} else {
dispatch_sync(dispatch_get_main_queue(), ^{
[self _evaluateJavascript:javascriptCommand];
});
}
}

客户端将字段转成js字符串,然后注入到网页中实现通知。具体方法是调用了js环境中的_handleMessageFromObjC方法,参数为字典转换后的字符串。下面看看_handleMessageFromObjC方法的实现:

function _handleMessageFromObjC(messageJSON) {
_dispatchMessageFromObjC(messageJSON);
}

function _dispatchMessageFromObjC(messageJSON) {
if (dispatchMessagesWithTimeoutSafety) {
setTimeout(_doDispatchMessageFromObjC);
} else {
_doDispatchMessageFromObjC();
}

function _doDispatchMessageFromObjC() {
var message = JSON.parse(messageJSON);
var messageHandler;
var responseCallback;

if (message.responseId) {
responseCallback = responseCallbacks[message.responseId];
if (!responseCallback) {
return;
}
responseCallback(message.responseData);
delete responseCallbacks[message.responseId];
} else {
if (message.callbackId) {
var callbackResponseId = message.callbackId;
responseCallback = function(responseData) {
_doSend({ handlerName:message.handlerName, responseId:callbackResponseId, responseData:responseData });
};
}

var handler = messageHandlers[message.handlerName];
if (!handler) {
console.log("WebViewJavascriptBridge: WARNING: no handler for message from ObjC:", message);
} else {
handler(message.data, responseCallback);
}
}
}
}

这边的处理其实和上文客户端处理message字典时没什么区别的。

这边要提一下的是这个responseId的判断逻辑,还记得网页通知客户端分析中,由于网页有实现通知完客户端后的代码,所以客户端将网页传递过来的callbackId作为responseId参数又传回去了:

WVJBMessage* msg = @{ @"responseId":callbackId, @"responseData":responseData };
[self _queueMessage:msg];

这边网页的处理是,从responseCallbacks中根据这个"responseId":callbackId字段取出block并调用,代码如下:

if (message.responseId) {
responseCallback = responseCallbacks[message.responseId];
if (!responseCallback) {
return;
}
responseCallback(message.responseData);
delete responseCallbacks[message.responseId];
}

如果看到这里有点乱了,可以再看看网页通知客户端时对于字典的处理部分。

以上就是客户端通知网页的大致实现。

js环境注入问题

上文一提到这个,就说下文讲解,现在终于可以分析这一块了。

其实这个比较简单,本质上就是网页调用了一个特殊的,jsbridge规定的url,使得客户端可以拦截到并分析出是需要注入js环境的通知。然后客户端开始注入。

网页部分的代码:

WVJBIframe.src = 'https://__bridge_loaded__';

一般这个是放在网页代码的最前面的。这样做可以让客户端在最早的情况下将环境注入到网页中。

而客户端是如何处理的呢?

if ([_base isBridgeLoadedURL:url]) {
[_base injectJavascriptFile];
}
- (void)injectJavascriptFile {
NSString *js = WebViewJavascriptBridge_js();
[self _evaluateJavascript:js];
}

看到了吧,客户端调用WebViewJavascriptBridge_JS类的唯一的方法:NSString * WebViewJavascriptBridge_js(); ,然后通过_evaluateJavascript注入。

总结

以网页通知客户端为例:客户端会将要被调用的方法存到字典中,同时拦截网页的调用,当网页调用时,从字典中取出方法并调用。调用完后,判断网页是否有调用完的回调,如果有,再将回调的id和参数通过客户端调用网页的方式通知过去。这就完成了网页通知客户端的总体流程。

最后

这个框架是在去年就已经看完了,由于忙+懒,拖到今天才终于准备写一下。花了一下午的时间,将大体的逻辑理清楚并用文字的方式表达出来,但是由于昨晚没睡舒服,现在脑子还是有点乱,所以文章中应该有部分错别字,麻烦看到了指出一下方便我改正。还有一点,对于之前没接触过的同学,由于在调用时有responseId和callbackId,会比较乱,在此建议多看几遍。如果实在理解不了,可以评论或加我微信,我会尽我努力让你理解。最后,谢谢你的耐心阅读😆😆

链接:https://www.jianshu.com/p/7bd7260daf94

收起阅读 »

Flutter IM跨端架构设计和实现

作者:闲鱼技术——祈晴1. 闲鱼IM现状闲鱼IM框架构建于2016-2017年,期间多次迭代升级导致历史包袱累积多,后经IM界面Flutter化,造成架构更复杂,开发层面总结闲鱼当前架构主要存在如下几个问题:•研发效率较低:当前架构开发需求涉及到Android...
继续阅读 »

作者:闲鱼技术——祈晴

1. 闲鱼IM现状

闲鱼IM框架构建于2016-2017年,期间多次迭代升级导致历史包袱累积多,后经IM界面Flutter化,造成架构更复杂,开发层面总结闲鱼当前架构主要存在如下几个问题:

•研发效率较低:当前架构开发需求涉及到Android/iOS双端的逻辑代码以及Flutter的UI界面代码,定位问题往往只能从Flutter UI表相追查到Native逻辑漏洞;•架构层次较差:架构设计上分层不清晰,业务逻辑夹杂在核心的逻辑层致使代码变更风险大;•性能测试略差:核心数据源存储Native内存,需经Flutter Plugin将数据源序列化上抛Flutter侧,在大批量数据源情况下性能表现较差;

从舆情层面总结闲鱼IM当前架构的主要问题如下:

•定位问题困难:线上舆情反馈千奇百怪,测试始终无法复现相关场景,因此很多时候只能靠现象猜测本质;•疑难杂症较多:架构不稳定性造成出现的问题反复出现,当前疑难杂症主要包括未读红点计数,iPhone5C低端机器架构,以及多媒体发送等多个问题;•问题差异性大:Android和iOS两端逻辑代码差异大,包括现存埋点逻辑都不尽相同,因此排查问题根源时候双端都会有不同问题根因,解决问题方案也不相同;

2.业界跨端方案

为解决当前IM痛点,闲鱼今年特起关于IM架构升级项目,重在解决客户端中双端一致性痛点,初步设想方案就是实现跨端统一的Android/iOS逻辑架构;在当前行业内跨端方案可初步归类如下图架构,在GUI层面的跨端方案有Weex,ReactNative,H5,Uni-APP等,其内存模型大多需要通过桥接到Native模式存储;在逻辑层面的跨端方案大致有C/C++等与虚拟机无关语言实现跨端,当然汇编语言也可行;此外有两个独立于上述体系之外的架构就是Flutter和KMM(谷歌基于Kotlin实现类似Flutter架构),其中Flutter运行特定DartVM,将内存数据挂载其自身的isolate中;undefined

考虑闲鱼是Flutter的前沿探索者,方案上优先使用Flutter;然而Flutter的isolate更像一个进程的概念(底层实现非使用进程模式),相比Android,同一进程场景中,Android的Dalvik虚拟机多个线程运行共享一个内存Heap,而DartVM的Isolate运行隔离各自的Heap,因而isolate之间通讯方式比较繁琐(需经过序列化反序列化过程);整个模型如下图所示:undefined

若按官方混合架构实现Flutter应用,开启多个FlutterAcitivty/FlutterController,底层会生成多个Engine,对应会存在多个isolate,而isolate通讯类似于进程通讯(类似socket或AIDL),这里借鉴闲鱼FlutterBoost的设计理念,FlutterIM架构将多个页面的Engine共享,则内存模型就天然支持共享读取,原理图如下:

undefined

3.Flutter IM架构设计

3.1 新老架构对比

如下图是一个老架构方案,其核心问题主要集中于Native逻辑抽象差,其中逻辑层面还设计到多线程并发使得问题倍增,Android/iOS/Flutter交互繁杂,开发维护成本高,核心层耦合较为严重,无插拔式概念;undefined

考虑到历史架构的问题,演进如下新架构设计undefined

架构从上至下依次为业务层分发层逻辑层以及数据源层,数据源层来源于推送或网络请求,其封装于Native层,通过Flutter插件将消息协议数据上抛到Flutter侧的核心逻辑层,处理完成后变成Flutter DB的Enitity实体,实体中挂载一些消息协议实体;核心逻辑层将繁杂数据扁平化打包挂载到分发层中的会话内存模型数据或消息内存模型数据,最后通过观察者模式的订阅分发到业务逻辑中;Flutter IM重点集中改造逻辑层和分发层,将IM核心逻辑和业务层面数据模型进行封装隔离,核心逻辑层和数据库交互后将数据封装到分发层的moduleData中,通过订阅方式分发到业务层数据模型中;此外在IM模型中DB也是重点依赖的,个人对DB数据库管理进行全面封装解,实现一种轻量级,性能佳的Flutter DB管理框架;

3.2 DB存储模型

Flutter IM架构的DB存储依赖数据库插件,目前主流插件是Sqflite,其存储模型如下:undefined依据上图Sqflite插件的DB存储模型会有2个等待队列,一个是Flutter层同步执行队列,一个是Native层的线程执行队列,其Android实现机制是HandlerThread,因此Query/Save读写在会同一线程队列中,导致响应速度慢,容易造成DB SQL堆积,此外缺失缓存模型,于是个人定制如下改进方案undefinedFlutter侧通过表的主键设计查询时候会优先从Entity Cache层去获取,若缓存不存在,则通过Sqflite插件查询,同时改造Sqflite插件成支持sync/Async同步异步两种方式操作,对应到Native侧也会有同步线程队列和异步线程队列,保证数据吞吐率;但是这里建议查询使用异步,存储使用同步更稳妥,主要怕出现多个相同的数据元model同一时间进入异步线程池中,存储先后顺序无法有效的保证;

3.3 ORM数据库方案

IM架构重度依赖DB数据库,而当前业界还没有一个完备的数据库ORM管理方案,参考了Android的OrmLite/GreenDao,个人自行设计一套Flutter ORM数据库管理方案,其核心思想如下:undefined由于Flutter不支持反射,因此无法直接像Android的开源数据库方式操作,但可通过APT方式,将Entity和Orm Entity绑定于一身,操作OrmEntity即操作Entity,整个代码风格设计也和OrmLite极其相似,参考代码如下:

undefined

3.4 IM内存数据模型

FlutterIM架构在内存数据模型主要划分为会话和消息两个颗粒度,会话内存数据模型交托于SessionModuleData,消息内存数据模型交托于MessageModuleData;会话内存数据有一个根节点RootNotice,然后其挂载PSessionMessageNotice(这里PSessionMessageNotice是ORM映射的会话DB表模型)子节点集合;消息内存数据会有一个MessageConatiner容器管理,其内部挂载此会话中的PMessage(PMessage是ORM映射的消息DB表模型)消息集合;

依据上一章节,PSessionMessageNotice设计了一个OrmEnitity Cache,考虑到IM中会话数是有限的,因此PSessionMessageNotice都是直接缓存到Cache中,这种做法的好处是各地去拿会话数据元时候都是缓存中同一个对象,容易保证多次重复读写的数据一致性;而PSessionMessageNotice考虑到其数量可以无限多的特殊性,因此这里将其挂载到MessageContainer的内存管理中,在退出会话的时机会校验容器中PMessage集合的数量,适当缩容可以减少内存开销,模型如下图所示:undefined

3.5 状态管理方案

Flutter IM状态管理方案比较简单,对数据源Session/Message维度使用观察者模式的订阅分发方式实现,架构类似于EventBus模式,页面级的状态管理无论使用fish-redux,scopeModel或者provider几乎影响面不大,核心还是需保留一种插拔式抽象更重要;架构如下图:undefined

3.6 IM同步模型方案

如下是当前现状的消息同步模型,模型中存在ACCS Thread/Main Thread/Region Thread等多线程并发场景,导致易出现多线程高并发的问题;native的推送和网络请求同步的隔离方案通过Lock的锁机制,并且通过队列降频等方式处理,流程繁琐且易出错。整体通过Region Version Gap去判断是否有域空洞,进而执行域同步补充数据。undefined改进的同步模型如下,在Flutter侧天然没多线程场景,通过一种标记位的转化同步异步实现类似Handler消息队列,架构清晰简约了很多,避免锁带来的开销以及同步问题,undefined

4.进展以及性能对比

•针对架构层面:在FlutterIM架构中,重点将双端逻辑差异性统一成同一份Dart代码,完全磨平Android/iOS的代码差异性带来的问题,降低开发维护,测试回归,视觉验收的一半成本,极大提高研发效率;架构上进行重构分层,实现一种解耦合,插拔式的IM架构;同时Native到Flutter侧的大量数据上抛序列化过程改造程Flutter引用传递,解决极限测试场景下的私聊卡顿问题;•针对线上舆情:补齐UT和TLog的集团日志方式做到可追踪,可排查;另外针对于很多现存的疑难杂症重点集中专项解决,比如iphone5C的架构在Flutter侧统一规划,未读红点计数等问题也在架构模型升级中修复,此外多媒体音视频发送模块进行改造升级;•性能数据对比:当IM架构的逻辑层和UI层都切换成Flutter后,和原先架构模式初步对比,整体内存水位持平,其中私聊场景下小米9测试结构内存下降40M,功耗降低4mah,CPU降低1%;极限测试场景下新架构内存数据相比于旧架构有一个较为明显的改观,主要由于两个界面都使用Flutter场景下,页面切换的开销降低很多;

5.展望

JS跨端不安全,C++跨端成本有点高,Flutter会是一个较好选择;彼时闲鱼FlutterIM架构升级根本目的从来不是因Flutter而Flutter,是由于历史包袱的繁重,代码层面的维护成本高,新业务的扩展性差,人力配比不协调以及疑难杂症的舆情持续反馈等等因素造成我们不得不去探索新方案。经过闲鱼IM超复杂业务场景验证Flutter模式的逻辑跨端可行性,闲鱼在Flutter路上会一直保持前沿探索,最后能反馈到生态圈;总结一句话,探索过程在于你勇于迈出第一步,后面才会不断惊喜发现

收起阅读 »

Jetpack—架构组件—App Startup

App Startup介绍作用这是官网的截图,大意就是 App Startup 是一种用来在 app 启动时候规范初始化数据的 library。同时使用 App Startup 可以解决我们平时滥用 ContentProvider 导致的启动变慢问题。还有一点...
继续阅读 »

App Startup

介绍作用

这是官网的截图,大意就是 App Startup 是一种用来在 app 启动时候规范初始化数据的 library。同时使用 App Startup 可以解决我们平时滥用 ContentProvider 导致的启动变慢问题。

还有一点,App Startup 可以用于 app 开发,也可以用来进行 sdk 开发

App Startup 的优势

  1. 平时使用 ContentProvider 自动获取 ApplicationContext 的方式管理混乱,并且多个 ContentProvider 初始化的方式也无法保证初始化的顺序

  2. 统一管理的方式可以明显提升 app 初始化速度,注:仅限于用较多 ContentProvider 来初始化应用的 app,反之不是不能用,只是没有优化效果

依赖

dependencies {
implementation("androidx.startup:startup-runtime:1.0.0")
}
复制代码

使用 AppStartup 初始化全局单例对象(main 分支)

  1. Car 对象
class Car(private val name: String) {
companion object {
var instance: Car? = null
fun getInstance(name: String): Car {
if (instance == null) {
instance = Car(name)
}
return instance!!
}
}

override fun toString(): String {
return "$name ${Random.nextInt(100)}"
}
}
复制代码
  1. 首先需要实现一个 Initializer
class AndroidInitializer : Initializer<Car> {
override fun create(context: Context): Car {
return Car.getInstance("出租车")
}

override fun dependencies(): MutableList<Class<out Initializer<*>>> {
return mutableListOf()
}
}
复制代码
  1. 在代码中注册 AndroidInitializer
 <provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
>
<meta-data
android:name="com.ananananzhuo.appstartupdemo.AndroidInitializer"
android:value="androidx.startup" />
</provider>
复制代码
  1. 分析

本例中 Car 对象,Car 对象内部维护了一个全局单例方法 getInstance。

前面说了,AppStartup 是用来维护全局单例的,那么实际上这个单例的初始化就是通过我们定义的 AndroidInitializer 对象 create 方法来初始化的。

  1. 我们会在 MainActivity 中调用 Car 的 toString 方法,代码如下
 logEE(Car.getInstance("小汽车:").toString())
logEE(Car.getInstance("小汽车:").toString())
logEE(Car.getInstance("小汽车:").toString())
复制代码

我们调用了,三次 toString 方法

代码输出如下:

我们 MainActivity 中代码 getInstance 传入的参数是 "小汽车",但是打印的却是 "出租车"。查看 AndroidInitializer 中的代码发现,我们在 AndroidInitializer 中的 create 方法中创建对象的参数是 "出租车"。

由此可以证明,我们的全局 Car 单例在 AndroidInitializer 中就已经初始化完成了。

手动初始化组件

上一节中我们使用在 Manifest 中注册组件的方式实现 Car 对象的自动初始化。

但是,实际上我们是可以不在 Manifest 中注册的方式实现初始化的,手动初始化的方式如下:

 AppInitializer.getInstance(this)
.initializeComponent(AndroidInitializer::class.java)
复制代码

这种方式的弊端是一次只能初始化一个组件

实现相互依赖的多实例的初始化(分支:multimodule)

通过上一节的学习,你可能会有这样的疑问:AppStartup 啥用没有吧,我直接在 Application 中一行代码初始化不香吗,非要用你这种方式???

那么现在我就要用 AppStartup 实现多实例的初始化,让你进一步了解 AppStartup 的应用

我们这一节的逻辑先描述一下:

本例中我们需要创建两个对象,Person 和 Noodle,两者都是全局单例的。

Person 持有 Noodle 对象的引用,

Person 中有一个 eat 方法,本例中我们的 eat 会输出一行 "某某人" 吃 "面条" 的日志

废话不多说,上代码:

不要嫌代码长,都是一看就懂的逻辑

  1. Person 和 Noodle
class Person(val name:String) {
private var noodle: Noodle? = null
companion object {
private var instance: Person? = null
fun getInstance(name:String): Person {
if (instance == null) {
instance = Person(name)
}
return instance!!
}
}

fun addNoodle(paramsnoodle: Noodle) {
noodle = paramsnoodle
}


fun eat() {
logEE("${name} 吃 ${noodle?.name}")
}
}
复制代码
class Noodle {
val name = "面条"

companion object {
private var instance: Noodle? = null
fun getInstance(): Noodle {
if (instance == null) {
instance = Noodle()
}
return instance!!
}
}
}
复制代码
  1. PersonInitializer、NoodleInitializer
class PersonInitializer : Initializer<Person> {
override fun create(context: Context): Person {
return Person.getInstance("李白").apply {
addNoodle(Noodle.getInstance())
}
}

override fun dependencies(): MutableList<Class<out Initializer<*>>> {
return mutableListOf(NoodleInitializer::class.java)
}
}
复制代码

class NoodleInitializer:Initializer<Noodle> {
override fun create(context: Context): Noodle {
return Noodle.getInstance()
}

override fun dependencies(): MutableList<Class<out Initializer<*>>> {
return mutableListOf()
}
}
复制代码

这两个组件中 PersonInitializer 的 create 方法中创建了 Person 的实例,并向里面添加 Noodle 的实例。

划重点:

PersonInitializer 的 dependencies 方法中返回了 mutableListOf(NoodleInitializer::class.java)。这句代码的意思是在 PersonInitializer 中的 Person 初始化之前会先初始化 NoodleInitializer 中的 Noodle 实例,然后当 PersonInitializer 中 addNoodle 的时候 Noodle 全局单例已经创建好了。

  1. 调用吃面条方法
Person.getInstance("杜甫").eat()
复制代码
  1. 打印日志输出

日志输出符合我们的预期

多实例的注册组件方式如下,我们将 PersonInitializer、NoodleInitializer 都被注册到 meta-data 中了。

实际上,NoodleInitializer 的组件是完全可以不注册的,因为在 PersonInitializer 的 dependencies 中已经声明了 NoodleInitializer 组件。

  <provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false">
<meta-data
android:name="com.ananananzhuo.appstartupdemo.PersonInitializer"
android:value="androidx.startup" />
<meta-data
android:name="com.ananananzhuo.appstartupdemo.NoodleInitializer"
android:value="androidx.startup" />
</provider>
复制代码

使用 AppStartup 进行 sdk 开发(分支:sdk_develop)

本例介绍 sdk 开发中 AppStartup 的使用,实际上与应用开发是一样的,但是感觉还是有必要说一下。

在本例中我们新建了一个 library 的 module,在 library 里面编写了我们的 AppStartup 的代码逻辑,然后将 Library 打包成 arr,集成到 app 模块中,在 app 的 Manifest 中注册组件,并调用组件的相关方法。

  1. aar 集成 

  2. library 中的代码

class LibraryInitializer:Initializer<Student> {
override fun create(context: Context): Student {
return Student.getInstance()
}

override fun dependencies(): MutableList<Class<out Initializer<*>>> {
return mutableListOf()
}
}
复制代码
class Student(val name: String) {
companion object {
private val student = Student("安安安安卓")
fun getInstance(): Student {
return student
}
}

fun study() {
Log.e("tag", "${name} 好好学习")
}
}
复制代码
  1. Manifest 中注册组件
 <provider
android:name="androidx.startup.InitializationProvider"
android:authorities="com.ananananzhuo.appstartupdemo.androidx-startup"
android:exported="false"
>
<meta-data
android:name="com.ananananzhuo.library.LibraryInitializer"
android:value="androidx.startup" />
</provider>
复制代码
  1. 日志打印

  1. 结论

通过这种方式,第三方 sdk 只需要定义自己的 AppStartup 组件就可以,我们在注册组件的时候在 manifest 中添加第三方组件的信息就可以完成第三方组件的初始化了。

这极大的避免了某些自以为是的 sdk,打着方便我们集成的名义搞 ContentProvider 初始化恶心我们

以后如果你合作的第三方 sdk 提供方再出现 ContentProvider 的初始化方式恶心你,那么拿出我的文章好好教他做人。

收起阅读 »

SpannableStringBuiler封装Kotlin

前言SpannableStringBuilder和SpannableString功能基本一样,不过SpannableStringBuilder可以拼接,主要是通过setSpan来实现各种效果,主要的方法如下:start: 指定Span的开始位置 end: 指定...
继续阅读 »

前言

SpannableStringBuilder和SpannableString功能基本一样,不过SpannableStringBuilder可以拼接,主要是通过setSpan来实现各种效果,主要的方法如下:

start: 指定Span的开始位置
end: 指定Span的结束位置,并不包括这个位置。
flags:取值有如下四个
Spannable. SPAN_INCLUSIVE_EXCLUSIVE:前面包括,后面不包括,即在文本前插入新的文本会应用该样式,而在文本后插入新文本不会应用该样式
Spannable. SPAN_INCLUSIVE_INCLUSIVE:前面包括,后面包括,即在文本前插入新的文本会应用该样式,而在文本后插入新文本也会应用该样式
Spannable. SPAN_EXCLUSIVE_EXCLUSIVE:前面不包括,后面不包括
Spannable. SPAN_EXCLUSIVE_INCLUSIVE:前面不包括,后面包括
what: 对应的各种Span,不同的Span对应不同的样式。已知的可用类有:
BackgroundColorSpan : 文本背景色
ForegroundColorSpan : 文本颜色
MaskFilterSpan : 修饰效果,如模糊(BlurMaskFilter)浮雕
RasterizerSpan : 光栅效果
StrikethroughSpan : 删除线
SuggestionSpan : 相当于占位符
UnderlineSpan : 下划线
AbsoluteSizeSpan : 文本字体(绝对大小)
DynamicDrawableSpan : 设置图片,基于文本基线或底部对齐。
ImageSpan : 图片
RelativeSizeSpan : 相对大小(文本字体)
ScaleXSpan : 基于x轴缩放
StyleSpan : 字体样式:粗体、斜体等
SubscriptSpan : 下标(数学公式会用到)
SuperscriptSpan : 上标(数学公式会用到)
TextAppearanceSpan : 文本外貌(包括字体、大小、样式和颜色)
TypefaceSpan : 文本字体
URLSpan : 文本超链接
ClickableSpan : 点击事件

简单使用示例

初始化SpannableString或SpannableStringBuilder,然后设置对应的setPan就可以实现对应的效果。

SpannableString spannableString = new SpannableString("要设置的内容");
ForegroundColorSpan colorSpan = new ForegroundColorSpan(Color.parseColor("#009ad6"));
spannableString.setSpan(colorSpan, 0, 8, Spannable.SPAN_EXCLUSIVE_INCLUSIVE);
((TextView)findViewById(R.id.mode1)).setText(spannableString);

具体使用详情可以参考:强大的SpannableStringBuilder

封装使用

对很多功能都可以封装,简化使用,这里使用了扩展函数,更方便在Kotlin中使用,不过在Java中也可以使用,使用方法如下:

第一种情况,要设置的内容已经是一段完整的内容

注意:链式调用时,只需要初始化第一个src就可以了,后续都会默认使用第一个,如果后续继续初始化src, 会导致前面的设置无效,只有最后一个生效。target和range都是为了确定要改变的文字的范围,两个初始化一个即可。

  1. 对整个字符串设置效果

    src 和target默认等于TextView的text

    //对整个 text 设置方式一,textView已经设置过内容,可以不用初始化src
    tvTvOne.sizeSpan(textSize = 20f)
    //对整个 text 设置方式二
    tvTvOne2.typeSpan(src = "全部文字加粗",target = "全部文字加粗",
    type = SsbKtx.type_bold)
  2. 设置部分文字效果

    type 有3个,对应加粗,倾斜,加粗倾斜

    //设置部分文字效果
    //tvTv2.typeSpan(range = 2..4,type = SsbKtx.type_bold)
    tvTv2.typeSpan(target = "部分",type = SsbKtx.type_bold)
    //设置加粗倾斜效果
    tvTv3.typeSpan(range = 0..4,type = SsbKtx.type_bold_italic)
  3. 对同一个文字设置多个效果

    对同一个部分做多种效果,只能第一个设置 src, 后续设置会导致前面的无效。

    //        tvTv4.typeSpan(range = 0..4,type = SsbKtx.type_bold_italic)
    // .foregroundColorIntSpan(range = 0..4,color = Color.GREEN)
    // .strikethroughSpan(range = 0..4)
    tvTv4.typeSpan(src = "只能这个可以设置 src,后面的再设置会导致前面效果无效",
    range = 0..4,type = SsbKtx.type_bold_italic)
    .foregroundColorIntSpan(range = 0..4,color = Color.GREEN)
    .strikethroughSpan(range = 0..4)
  4. 对多个不同的文字分别设置不同的效果

     tvTv5.typeSpan(range = 0..4,type = SsbKtx.type_bold_italic)
    .foregroundColorIntSpan(range = 7..11,color = Color.BLUE)
  5. 设置部分点击

    tvTv6.clickIntSpan(range = 0..4){
    Toast.makeText(this, "hello", Toast.LENGTH_SHORT).show()
    }
  6. 设置部分超链接

    tvTv7.urlSpan(range = 0..4,url = "https://www.baidu.com")

第二种情况,拼接成一个完整的字符串

  1. 拼接成完整的内容

     tvTv8.text = "拼接一段文字"
    tvTv8.appendTypeSpan("加粗",SsbKtx.type_bold)
    .strikethroughSpan(target = "加粗")//对同一部分文字做多个效果
    .appendForegroundColorIntSpan("改变字体颜色",Color.RED)

    如果想对拼接的内容做多个效果,可以在其后面调用对应的方法,只要traget或是range正确即可。

完整代码

object SsbKtx {
const val flag = SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE
const val type_bold = Typeface.BOLD
const val type_italic = Typeface.ITALIC
const val type_bold_italic = Typeface.BOLD_ITALIC

}
//-------------------CharSequence相关扩展-----------------------
/**
*CharSequence不为 null 或者 empty
*/
fun CharSequence?.isNotNullOrEmpty() = !isNullOrEmpty()

/**
*获取一段文字在文字中的范围
* @param target
* @return
*/
fun CharSequence.range(target: CharSequence): IntRange {
val start = this.indexOf(target.toString())
return start..(start + target.length)
}

/**
*将一段指定的文字改变大小
* @return
*/
fun CharSequence.sizeSpan(range: IntRange, textSize: Int): CharSequence {
return SpannableStringBuilder(this).apply {
setSpan(AbsoluteSizeSpan(textSize), range.first, range.last, SsbKtx.flag)
}
}


/**
*设置文字颜色
* @param range
* @return
*/
fun CharSequence.foregroundColorSpan(range: IntRange, color: Int = Color.RED): CharSequence {
return SpannableStringBuilder(this).apply {
setSpan(ForegroundColorSpan(color), range.first, range.last, SsbKtx.flag)
}
}

/**
*设置click,将一段文字中指定range的文字添加颜色和点击事件
* @param range
* @return
*/
fun CharSequence.clickSpan(
range: IntRange,
color: Int = Color.RED,
isUnderlineText: Boolean = false,
clickAction: () -> Unit
): CharSequence {
return SpannableString(this).apply {
val clickableSpan = object : ClickableSpan() {
override fun onClick(widget: View) {
clickAction()
}

override fun updateDrawState(ds: TextPaint) {
ds.color = color
ds.isUnderlineText = isUnderlineText
}
}
setSpan(clickableSpan, range.first, range.last, SsbKtx.flag)
}
}


//-------------------TextView相关扩展--------------------------
/**
*设置目标文字大小, src,target 为空时,默认设置整个 text
* @return
*/
fun TextView?.sizeSpan(
src: CharSequence? = this?.text,
target: CharSequence? = this?.text,
range: IntRange? = null,
@DimenRes textSize: Int
): TextView? {
return when {
this == null -> this
src.isNullOrEmpty() -> this
target.isNullOrEmpty() && range == null -> this
textSize == 0 -> this
range != null -> {
text = src.sizeSpan(range, ResUtils.getDimensionPixelSize(textSize))
this
}
target.isNotNullOrEmpty() -> {
text = src.sizeSpan(src.range(target!!), ResUtils.getDimensionPixelSize(textSize))
this
}
else -> this
}
}

/**
*设置目标文字大小, src,target 为空时,默认设置整个 text
* @return
*/
fun TextView?.sizeSpan(
src: CharSequence? = this?.text,
target: CharSequence? = this?.text,
range: IntRange? = null,
textSize: Float
): TextView? {
return when {
this == null -> this
src.isNullOrEmpty() -> this
target.isNullOrEmpty() && range == null -> this
textSize == 0f -> this
range != null -> {
text = src.sizeSpan(range, DensityUtils.dp2px(textSize))
this
}
target.isNotNullOrEmpty() -> {
text = src.sizeSpan(src.range(target!!), DensityUtils.dp2px(textSize))
this
}
else -> this
}
}

/**
*追加内容设置字体大小
* @param str
* @param textSize
* @return
*/
fun TextView?.appendSizeSpan(str: String?, textSize: Float): TextView? {
str?.let {
this?.append(it.sizeSpan(0..it.length, DensityUtils.dp2px(textSize)))
}
return this
}

fun TextView?.appendSizeSpan(str: String?, @DimenRes textSize: Int): TextView? {
str?.let {
this?.append(it.sizeSpan(0..it.length, ResUtils.getDimensionPixelSize(textSize)))
}
return this
}

/**
*设置目标文字类型(加粗,倾斜,加粗倾斜),src,target 为空时,默认设置整个 text
* @return
*/
fun TextView?.typeSpan(
src: CharSequence? = this?.text,
target: CharSequence? = this?.text,
range: IntRange? = null,
type: Int
): TextView? {
return when {
this == null -> this
src.isNullOrEmpty() -> this
target.isNullOrEmpty() && range == null -> this
range != null -> {
text = src.typeSpan(range, type)
this
}
target.isNotNullOrEmpty() -> {
text = src.typeSpan(src.range(target!!), type)
this
}
else -> this
}
}

fun TextView?.appendTypeSpan(str: String?, type: Int): TextView? {
str?.let {
this?.append(it.typeSpan(0..it.length, type))
}
return this
}

/**
*设置目标文字下划线
* @return
*/
fun TextView?.underlineSpan(
src: CharSequence? = this?.text,
target: CharSequence? = this?.text,
range: IntRange? = null
): TextView? {
return when {
this == null -> this
src.isNullOrEmpty() -> this
target.isNullOrEmpty() && range == null -> this
range != null -> {
text = src.underlineSpan(range)
this
}
target.isNotNullOrEmpty() -> {
text = src.underlineSpan(src.range(target!!))
this
}
else -> this
}
}


/**
*设置目标文字对齐方式
* @return
*/
fun TextView?.alignSpan(
src: CharSequence? = this?.text,
target: CharSequence? = this?.text,
range: IntRange? = null,
align: Layout.Alignment
): TextView? {
return when {
this == null -> this
src.isNullOrEmpty() -> this
target.isNullOrEmpty() && range == null -> this
range != null -> {
text = src.alignSpan(range, align)
this
}
target.isNotNullOrEmpty() -> {
text = src.alignSpan(src.range(target!!), align)
this
}
else -> this
}
}

fun TextView?.appendAlignSpan(str: String?, align: Layout.Alignment): TextView? {
str?.let {
this?.append(it.alignSpan(0..it.length, align))
}
return this
}

/**
*设置目标文字超链接
* @return
*/
fun TextView?.urlSpan(
src: CharSequence? = this?.text,
target: CharSequence? = this?.text,
range: IntRange? = null,
url: String
): TextView? {
return when {
this == null -> this
src.isNullOrEmpty() -> this
target.isNullOrEmpty() && range == null -> this
range != null -> {
movementMethod = LinkMovementMethod.getInstance()
text = src.urlSpan(range, url)
this
}
target.isNotNullOrEmpty() -> {
movementMethod = LinkMovementMethod.getInstance()
text = src.urlSpan(src.range(target!!), url)
this
}
else -> this
}
}

fun TextView?.appendUrlSpan(str: String?, url: String): TextView? {
str?.let {
this?.append(it.urlSpan(0..it.length, url))
}
return this
}

/**
*设置目标文字点击
* @return
*/
fun TextView?.clickIntSpan(
src: CharSequence? = this?.text,
target: CharSequence? = this?.text,
range: IntRange? = null,
color: Int = Color.RED,
isUnderlineText: Boolean = false,
clickAction: () -> Unit
): TextView? {
return when {
this == null -> this
src.isNullOrEmpty() -> this
target.isNullOrEmpty() && range == null -> this
range != null -> {
movementMethod = LinkMovementMethod.getInstance()
highlightColor = Color.TRANSPARENT // remove click bg color
text = src.clickSpan(range, color, isUnderlineText, clickAction)
this
}
target.isNotNullOrEmpty() -> {
movementMethod = LinkMovementMethod.getInstance()
highlightColor = Color.TRANSPARENT // remove click bg color
text = src.clickSpan(src.range(target!!), color, isUnderlineText, clickAction)
this
}
else -> this
}
}

fun TextView?.appendClickIntSpan(
str: String?, color: Int = Color.RED,
isUnderlineText: Boolean = false,
clickAction: () -> Unit
): TextView? {
str?.let {
this?.append(it.clickSpan(0..it.length, color, isUnderlineText, clickAction))
}
return this
}

/**
*设置目标文字点击
* @return
*/
fun TextView?.clickSpan(
src: CharSequence? = this?.text,
target: CharSequence? = this?.text,
range: IntRange? = null,
@ColorRes color: Int,
isUnderlineText: Boolean = false,
clickAction: () -> Unit
): TextView? {
return when {
this == null -> this
src.isNullOrEmpty() -> this
target.isNullOrEmpty() && range == null -> this
range != null -> {
movementMethod = LinkMovementMethod.getInstance()
highlightColor = Color.TRANSPARENT // remove click bg color
text = src.clickSpan(range, ResUtils.getColor(color), isUnderlineText, clickAction)
this
}
target.isNotNullOrEmpty() -> {
movementMethod = LinkMovementMethod.getInstance()
highlightColor = Color.TRANSPARENT // remove click bg color
text = src.clickSpan(
src.range(target!!),
ResUtils.getColor(color),
isUnderlineText,
clickAction
)
this
}
else -> this
}
}

fun TextView?.appendClickSpan(
str: String?,
@ColorRes color: Int,
isUnderlineText: Boolean = false,
clickAction: () -> Unit
): TextView? {
str?.let {
this?.append(
it.clickSpan(
0..it.length,
ResUtils.getColor(color),
isUnderlineText,
clickAction
)
)
}
return this
}

里面的ResUtils只是简单的获取资源文件,如果想直接引入,可以参考Github直接使用gradle依赖。

收起阅读 »

iOS组件化开发实践

目录:1.组件化需求来源2.组件化初识3.组件化必备的工具使用4.模块拆分5.组件工程兼容swift环境6.组件之间的通讯7.组件化后的资源加载8.OC工程底层换swift代码9.总结1. 组件化需求来源起初的这个项目,App只有一条产品线,代码逻辑相对比较清...
继续阅读 »

目录:

1.组件化需求来源
2.组件化初识
3.组件化必备的工具使用
4.模块拆分
5.组件工程兼容swift环境
6.组件之间的通讯
7.组件化后的资源加载
8.OC工程底层换swift代码
9.总结

1. 组件化需求来源

起初的这个项目,App只有一条产品线,代码逻辑相对比较清晰,后期随着公司业务的迅速发展,现在App里面承载了大概五六条产品线,每个产品线的流程有部分是一样的,也有部分是不一样的,这就需要做各种各样的判断及定制化需求。大概做了一年多后,出现了不同产品线提过来的需求,开发人员都需要在主工程中开发,但是开发人员开发的是不同的产品线,也得将整个工程跑起来,代码管理、并行开发效率、分支管理、上线时间明显有所限制。大概就在去年底,我们的领导提出了这个问题,希望作成组件化,将代码重构拆分成模块,在主工程中组装拆分的模块,形成一个完整的App。

2. 组件化初识

随着业务线的增多,业务的复杂度增加,App的代码逻辑复杂度也增加了,后期的开发维护成本也增加了,为什么这么说呢?业务逻辑没有分类,查找问题效率降低(针对新手),运行也好慢哦,真的好烦哦......我们要改变这种局面。而组件化开发,就是将一个臃肿,复杂的单一工程的项目, 根据功能或者属性进行分解,拆分成为各个独立的功能模块或者组件 ; 然后根据项目和业务的需求,按照某种方式, 任意组织成一个拥有完整业务逻辑的工程。

组件化开发的缺点:

1、代码耦合严重
2、依赖严重
3、其它app接入某条产品线难以集成
4、项目复杂、臃肿、庞大,编译时间过长
5、难以做集成测试
6、对开发人员,只能使用相同的开发模式
......
组件化开发的优点:

1、项目结构清晰
2、代码逻辑清晰
3、拆分粒度小
4、快速集成
5、能做单元测试
6、代码利用率高
7、迭代效率高
......
组件化的实质:就是对现有项目或新项目进行基础、功能及业务逻辑的拆分,形成一个个的组件库,使宿主工程能在拆分的组件库里面查找需要的功能,组装成一个完整的App。

3. 组件化必备的工具使用

组件的存在方式是以每个pod库的形式存在的。那么我们组合组件的方法就是通过利用CocoaPods的方式添加安装各个组件,我们就需要制作CocoaPods远程私有库,将其发不到公司的gitlab或GitHub,使工程能够Pod下载下来。

Git的基础命令:

echo "# test" >> README.md
git init
git add README.md
git commit -m "first commit"
git remote add origin https://github.com/c/test.git
git push -u origin master

CocoaPods远程私有库制作:
1、Create Component Project

pod lib create ProjectName

2、Use Git

echo "# test" >> README.md
git init
git add README.md
git commit -m "first commit"
git remote add origin https://github.com/c/test.git
git push -u origin master

3、Edit podspec file

vim CoreLib.podspec
Pod::Spec.new do |s|
s.name = '组件工程名'
s.version = '0.0.1'
s.summary = 'summary'

s.description = <<-DESC
description
DESC

s.homepage = '远程仓库地址'
s.license = { :type => 'MIT', :file => 'LICENSE' }
s.author = { '作者' => '作者' }
s.source = { :git => '远程仓库地址', :tag => s.version.to_s }

s.ios.deployment_target = '8.0'

s.source_files = 'Classes/**/*.{swift,h,m,c}'
s.resources = 'Assets/*'

s.dependency 'AFNetworking', '~> 2.3'
end

4、Create tag

//create local tag
git tag '0.0.1'

git tag 0.0.1

//local tag push to remote
git push --tags

git push origin 0.0.1

//delete local tag
git tag -d 0.0.1

//delete remote tag
git tag origin :0.0.1

5、Verify Component Project

pod lib lint --allow-warnings --no-clean

6、Push To CocoaPods

pod repo add CoreLib git@git.test/CoreLib.git
pod repo push CoreLib CoreLib.podspec --allow-warnings

4. 模块拆分


基础组件库:
基础组件库放一些最基础的工具类,比如金额格式化、手机号/shenfen证/邮箱的有效校验,实质就是不会依赖业务,不会和业务牵扯的文件。

功能组件库:
分享的封装、图片的轮播、跑马灯功能、推送功能的二次封装,即开发一次,以后都能快速集成的功能。

业务组件库:
登录组件、实名组件、消息组件、借款组件、还款组件、各条产品线组件等。

中间件(组件通讯):
各个业务组件拆分出来后,组件之间的通讯、传参、回调就要考虑了,此时就需要一个组件通讯的工具类来处理。

CocoaPods远程私有库:
每个拆分出去的组件存在的形式都是以Pod的形式存在的,并能达到单独运行成功。

宿主工程:
宿主工程就是一个壳,在组件库中寻找这个工程所需要的组件,然后拿过来组装成一个App。

5. 组件工程兼容swift环境

在做组件化之前,这个项目使用的是Objective-C语言写的,还没有支持在项目里面使用Swift语言的能力,考虑到后期肯定会往Swift语言切过去的,于是借着这次重构的机会,创建的组件工程都是swift工程。

Podfile文件需要添加==use_frameworks!==

source 'https://github.com/CocoaPods/Specs.git'
platform :ios, '8.0'
inhibit_all_warnings!
use_frameworks!

target 'CoreLib_Example' do
pod 'CoreLib', :path => '../'
end

这里其实有个大坑需要特别注意,在支持Swift环境后,部分Objective-C语言的三方库采用的是==静态库==,在OC文件中引用三方库头文件,会一直报头文件找不到,我们在遇到这个问题时找遍了百度,都没找到解决方案,整整花了一个星期的时间尝试。

解决方案:我们对这些三方库(主要有:UMengAnalytics、Bugly、AMapLocation-NO-IDFA)再包一层,使用CocoaPods远程私有库管理,对外暴露我们写的文件,引用我们写的头文件,就能调用到。

Pod::Spec.new do |s|
s.name = ''
s.version = '0.0.1'
s.summary = '包装高德地图、分享、友盟Framework.'

s.description = <<-DESC
DESC

s.homepage = ''
s.license = { :type => 'MIT', :file => 'LICENSE' }
s.author = { '' => '' }
s.source = { :git => '', :tag => s.version.to_s }

s.ios.deployment_target = '8.0'

s.source_files = ['Classes/UMMob/**/*.{h,m}','Classes/Bugly/**/*.{h,m}','Classes/AMap/**/*.{h,m}']
s.public_header_files = ['Classes/*.h']
s.libraries = 'sqlite3', 'c++', 'z', 'z.1.1.3', 'stdc++', 'stdc++.6.0.9'
s.frameworks = 'SystemConfiguration', 'CoreTelephony', 'JavaScriptcore', 'CoreLocation', 'Security', 'Foundation'
s.vendored_frameworks = 'Frameworks/**/*.framework'
s.xcconfig = { "FRAMEWORK_SEARCH_PATHS" => "Pods/WDContainerLib/Frameworks" }

s.requires_arc = true
end

6. 组件之间的通讯

在将业务控制器拆分出去后,如果一个组件要调用另一个组件里面的控制器,平常的做法是直接==#import "控制器头文件"==,现在在不同的组件里面是无法import的,那该怎么做呢?答案就是使用==消息发送机制==。

思路:

1.每个业务组件库里面会有一个控制器的配置文件(路由配置文件),标记着每个控制器的key;
2.在App每次启动时,组件通讯的工具类里面需要解析控制器配置文件(路由配置文件),将其加载进内存;
3.在内存中查询路由配置,找到具体的控制器并动态生成类,然后使用==消息发送机制==进行调用函数、传参数、回调,都能做到。

((id (*)(id, SEL, NSDictionary *)) objc_msgSend)((id) cls, @selector(load:), param);
((void(*)(id, SEL,NSDictionary*))objc_msgSend)((id) vc, @selector(callBack:), param);

Or

[vc performSelector:@selector(load:) withObject:param];
[vc performSelector:@selector(callBack:) withObject:param];

好处:

解除了控制器之间的依赖;
使用iOS的消息发送机制进行传参数、回调参数、透传参数;
路由表配置文件,能实现界面动态配置、动态生成界面;
路由表配置文件放到服务端,还可以实现线上App的跳转逻辑;
将控制器的key提供给H5,还可以实现H5跳转到Native界面;

7. 组件化后的资源加载

新项目已开始就采用组件化开发,还是特别容易的,如果是老项目重构成组件化,那就比较悲剧了,OC项目重构后,app包里面会有一个==Frameworks==文件夹,所有的组件都在这个文件夹下,并且以==.framework==(比如:WDComponentLogin.framework)结尾。在工程中使用的==xib、图片==,使用正常的方式加载,是加载不到的,原因就是xib、图片的路径==(工程.app/Frameworks/WDComponentLogin.framework/LoginViewController.nib、工程.app/Frameworks/WDComponentLogin.framework/login.png)==发生了变化。

以下是在组件库中加载nib文件/图片文件的所有情况:

/**
从主工程mainBundle或从所有的组件(组件名.framework)中加载图片

@param imageName 图片名称
@return 返回查找的图片结果
*/
+ (UIImage *_Nullable)loadImageNamed:(NSString *_Nonnull)imageName;

/**
从指定的组件中加载图片,主要用于从当前组件加载其他组件中的图片

@param imageName 图片名称
@param frameworkName 组件名称
@return 返回查找的图片结果
*/
+ (UIImage *_Nullable)loadImageNamed:(NSString *_Nonnull)imageName frameworkName:(NSString *_Nonnull)frameworkName;

/**
从指定的组件的Bundle文件夹中加载图片,主要用于从当前组件加载其他组件Bundle文件夹中的图片

@param imageName 图片名称
@param bundleName Bundle文件夹名
@param frameworkName 组件名称
@return 返回查找的图片结果
*/
+ (UIImage *_Nullable)loadImageNamed:(NSString *_Nonnull)imageName bundleName:(NSString *_Nonnull)bundleName frameworkName:(NSString *_Nonnull)frameworkName;

/**
从主工程mainBundle的指定Bundle文件夹中去加载图片

@param imageName 图片名称
@param bundleName Bundle文件夹名
@return 返回查找的图片结果
*/
+ (UIImage *_Nullable)loadImageNamed:(NSString *_Nonnull)imageName bundleName:(NSString *_Nonnull)bundleName;

/**
从指定的组件(组件名.framework)中加载图片
说明:加载组件中的图片,必须指明图片的全名和图片所在bundle的包名

@param imageName 图片名称
@param targetClass 当前类
@return 返回查找的图片结果
*/
+ (UIImage *_Nullable)loadImageNamed:(NSString *_Nonnull)imageName targetClass:(Class _Nonnull)targetClass;

/**
从指定的组件(组件名.framework)中的Bundle文件夹中加载图片
说明:加载组件中的图片,必须指明图片的全名和图片所在bundle的包名

@param imageName 图片名称
@param bundleName Bundle文件夹名
@param targetClass 当前类
@return 返回查找的图片结果
*/
+ (UIImage *_Nullable)loadImageNamed:(NSString *_Nonnull)imageName bundleName:(NSString *_Nonnull)bundleName targetClass:(Class _Nonnull)targetClass;

/**
加载工程中的nib文件
eg:[_tableview registerNib:[WDLoadResourcesUtil loadNibClass:[WDRepaymentheaderView class]] forHeaderFooterViewReuseIdentifier:kWDRepaymentheaderView]
@param class nib文件名
@return 返回所需要的nib对象
*/
+ (UINib *_Nullable)loadNibClass:(NSObject *_Nonnull)targetClass;

控制器加载方式:

@implementation WDBaseViewController

- (instancetype)initWithNibName:(NSString *)nibNameOrNil bundle:(NSBundle *)nibBundleOrNil {
NSString *classString = [[NSStringFromClass(self.class) componentsSeparatedByString:@"."] lastObject];
if ([[NSBundle bundleForClass:[self class]] pathForResource:classString ofType:@"nib"] != nil) {
//有xib
return [super initWithNibName:classString bundle:[NSBundle bundleForClass:[self class]]];
}else if ([[NSBundle mainBundle] pathForResource:classString ofType:@"nib"] == nil) {
//没有xib
return [super initWithNibName:nil bundle:nibBundleOrNil];
} else {
return [super initWithNibName:(nibNameOrNil == nil ? classString : nibNameOrNil) bundle:nibBundleOrNil];
}
}
@end

UIView视图加载方式:

OC版本

+ (id)loadFromNIB {
if ([[NSFileManager defaultManager] fileExistsAtPath:[NSBundle bundleForClass:[self class]].bundlePath]) {
return [[[NSBundle bundleForClass:[self class]] loadNibNamed:[self description]
owner:self
options:nil] lastObject];
}else{
return [[[NSBundle mainBundle] loadNibNamed:[self description] owner:self options:nil] lastObject];
}

}

+ (id)loadFromNIB:(NSInteger)index {
if ([[NSFileManager defaultManager] fileExistsAtPath:[NSBundle bundleForClass:[self class]].bundlePath]) {
return [[NSBundle bundleForClass:[self class]] loadNibNamed:[self description]
owner:self
options:nil][index];
}else{
return [[NSBundle mainBundle] loadNibNamed:[self description] owner:self options:nil][index];
}

}

Swift版本

// MARK: - 通过nib加载视图
@objc public static func loadFromNIB() -> UIView! {
return (Bundle(for: self.classForCoder()).loadNibNamed(self.description().components(separatedBy: ".")[1], owner: self, options: nil)?.first as? UIView)!
}

8. OC工程底层换swift代码

目前正在做OC底层的统一,换成swift写的代码。

1、控制器Base、Web控制器Base使用OC代码,因为OC控制器不能继承Swift,而Swift控制器可以继承OC写的控制器。
2、导航栏、工具栏、路由、基础组件、功能组件、混合开发插件都是用Swift语言。
3、Swift移动组件大部分完成,OC工程、Swift工程都统一使用开发的移动组件库。

9. 总结

经过半年的努力重构,终于将工程拆分成组件化开发了,也从中学到了很多,希望自己能再接再厉和同事一起进步。

链接:https://www.jianshu.com/p/196ec57cdc75

收起阅读 »

APP路由框架与组件化简析

前端开发经常遇到一个词:路由,在Android APP开发中,路由还经常和组件化开发强关联在一起,那么到底什么是路由,一个路由框架到底应该具备什么功能,实现原理是什么样的?路由是否是APP的强需求呢?与组件化到底什么关系,本文就简单分析下如上几个问题。 路由...
继续阅读 »

前端开发经常遇到一个词:路由,在Android APP开发中,路由还经常和组件化开发强关联在一起,那么到底什么是路由,一个路由框架到底应该具备什么功能,实现原理是什么样的?路由是否是APP的强需求呢?与组件化到底什么关系,本文就简单分析下如上几个问题。


路由的概念


路由这个词本身应该是互联网协议中的一个词,维基百科对此的解释如下:


路由(routing)就是通过互联的网络把信息从源地址传输到目的地址的活动。路由发生在OSI网络参考模型中的第三层即网络层。

个人理解,在前端开发中,路由就是通过一串字符串映射到对应业务的能力。APP的路由框首先能够搜集各组件的路由scheme,并生成路由表,然后,能够根据外部输入字符串在路由表中匹配到对应的页面或者服务,进行跳转或者调用,并提供会获取返回值等,示意如下


image.png


所以一个基本路由框架要具备如下能力:





    1. APP路由的扫描及注册逻辑




    1. 路由跳转target页面能力




    1. 路由调用target服务能力



APP中,在进行页面路由的时候,经常需要判断是否登录等一些额外鉴权逻辑所以,还需要提供拦截逻辑等,比如:登陆。


三方路由框架是否是APP强需求


答案:不是,系统原生提供路由能力,但功能较少,稍微大规模的APP都采用三方路由框架。


Android系统本身提供页面跳转能力:如startActivity,对于工具类APP,或单机类APP,这种方式已经完全够用,完全不需要专门的路由框架,那为什么很多APP还是采用路由框架呢?这跟APP性质及路由框架的优点都有关。比如淘宝、京东、美团等这些大型APP,无论是从APP功能还是从其研发团队的规模上来说都很庞大,不同的业务之间也经常是不同的团队在维护,采用组件化的开发方式,最终集成到一个APK中。多团队之间经常会涉及业务间的交互,比如从电影票业务跳转到美食业务,但是两个业务是两个独立的研发团队,代码实现上是完全隔离的,那如何进行通信呢?首先想到的是代码上引入,但是这样会打破了低耦合的初衷,可能还会引入各种问题。例如,部分业务是外包团队来做,这就牵扯到代码安全问题,所以还是希望通过一种类似黑盒的方式,调用目标业务,这就需要中转路由支持,所以国内很多APP都是用了路由框架的。其次我们各种跳转的规则并不想跟具体的实现类扯上关系,比如跳转商详的时候,不希望知道是哪个Activity来实现,只需要一个字符串映射过去即可,这对于H5、或者后端开发来处理跳转的时候,就非常标准。


原生路由的限制:功能单一,扩展灵活性差,不易协同


传统的路由基本上就限定在startActivity、或者startService来路由跳转或者启动服务。拿startActivity来说,传统的路由有什么缺点:startActivity有两种用法,一种是显示的,一种是隐式的,显示调用如下:


<!--1 导入依赖-->
import com.snail.activityforresultexample.test.SecondActivity;

public class MainActivity extends AppCompatActivity {

void jumpSecondActivityUseClassName(){
<!--显示的引用Activity类-->
Intent intent =new Intent(MainActivity.this, SecondActivity.class);
startActivity(intent);
}


显示调用的缺点很明显,那就是必须要强依赖目标Activity的类实现,有些场景,尤其是大型APP组件化开发时候,有些业务逻辑出于安全考虑,并不想被源码或aar依赖,这时显式依赖的方式就无法走通。再来看看隐式调用方法。


第一步:manifest中配置activity的intent-filter,至少要配置一个action


<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.snail.activityforresultexample">
<application
...
<activity android:name=".test.SecondActivity">
<intent-filter>
<!--隐式调用必须配置android.intent.category.DEFAULT-->
<category android:name="android.intent.category.DEFAULT"/>
<!--至少配置一个action才能通过隐式调用-->
<action android:name="com.snail.activityforresultexample.SecondActivity" />
<!--可选-->
<!-- <data android:mimeType="video/mpeg" android:scheme="http" ... />-->
</intent-filter>
</activity>
</application>
</manifest>

第二步:调用


void jumpSecondActivityUseFilter() {
Intent intent = new Intent();
intent.setAction("com.snail.activityforresultexample.SecondActivity");
startActivity(intent);
}

如果牵扯到数据传递写法上会更复杂一些,隐式调用的缺点有如下几点:



  • 首先manifest中定义复杂,相对应的会导致暴露的协议变的复杂,不易维护扩展。

  • 其次,不同Activity都要不同的action配置,每次增减修改Activity都会很麻烦,对比开发者非常不友好,增加了协作难度。

  • 最后,Activity的export属性并不建议都设置成True,这是降低风险的一种方式,一般都是收归到一个Activity,DeeplinkActivitiy统一处理跳转,这种场景下,DeeplinkActivitiy就兼具路由功能,隐式调用的场景下,新Activitiy的增减势必每次都要调整路由表,这会导致开发效率降低,风险增加。


可以看到系统原生的路由框架,并没太多考虑团队协同的开发模式,多限定在一个模块内部多个业务间直接相互引用,基本都要代码级依赖,对于代码及业务隔离很不友好。如不考虑之前Dex方法树超限制,可以认为三方路由框架完全是为了团队协同而创建的


APP三方路由框架需具备的能力


目前市面上大部分的路由框架都能搞定上述问题,简单整理下现在三方路由的能力,可归纳如下:



  • 路由表生成能力:业务组件**[UI业务及服务]**自动扫描及注册逻辑,需要扩展性好,无需入侵原有代码逻辑

  • scheme与业务映射逻辑 :无需依赖具体实现,做到代码隔离

  • 基础路由跳转能力 :页面跳转能力的支持

  • 服务类组件的支持 :如去某个服务组件获取一些配置等

  • [扩展]路由拦截逻辑:比如登陆,统一鉴权

  • 可定制的降级逻辑:找不到组件时的兜底


可以看下一个典型的Arouter用法,第一步:对新增页面添加Router Scheme 声明,


	@Route(path = "/test/activity2")
public class Test2Activity extends AppCompatActivity {
...
}

build阶段会根据注解搜集路由scheme,生成路由表。第二步使用


        ARouter.getInstance()
.build("/test/activity2")
.navigation(this);

如上,在ARouter框架下,仅需要字符串scheme,无需依赖任何Test2Activity就可实现路由跳转。


APP路由框架的实现


路由框架实现的核心是建立scheme和组件**[Activity或者其他服务]**的映射关系,也就是路由表,并能根据路由表路由到对应组件的能力。其实分两部分,第一部分路由表的生成,第二部分,路由表的查询


路由表的自动生成


生成路由表的方式有很多,最简单的就是维护一个公共文件或者类,里面映射好每个实现组件跟scheme,


image.png


不过,这种做法缺点很明显:每次增删修改都要都要修改这个表,对于协同非常不友好,不符合解决协同问题的初衷。不过,最终的路由表倒是都是这条路,就是将所有的Scheme搜集到一个对象中,只是实现方式的差别,目前几乎所有的三方路由框架都是借助注解+APT[Annotation Processing Tool]工具+AOP(Aspect-Oriented Programming,面向切面编程)来实现的,基本流程如下:


image.png


其中牵扯的技术有注解、APT(Annotation Processing Tool)、AOP(Aspect-Oriented Programming,面向切面编程)。APT常用的有JavaPoet,主要是遍历所有类,找到被注解的Java类,然后聚合生成路由表,由于组件可能有很多,路由表可能也有也有多个,之后,这些生成的辅助类会跟源码一并被编译成class文件,之后利用AOP技术【如ASM或者JavaAssist】,扫描这些生成的class,聚合路由表,并填充到之前的占位方法中,完成自动注册的逻辑。



JavaPoet如何搜集并生成路由表集合?



以ARouter框架为例,先定义Router框架需要的注解如:


@Target({ElementType.TYPE})
@Retention(RetentionPolicy.CLASS)
public @interface Route {

/**
* Path of route
*/
String path();

该注解用于标注需要路由的组件,用法如下:


@Route(path = "/test/activity1", name = "测试用 Activity")
public class Test1Activity extends BaseActivity {
@Autowired
int age = 10;

之后利用APT扫描所有被注解的类,生成路由表,实现参考如下:


@Override
public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
if (CollectionUtils.isNotEmpty(annotations)) {
<!--获取所有被Route.class注解标注的集合-->
Set<? extends Element> routeElements = roundEnv.getElementsAnnotatedWith(Route.class);
<!--解析并生成表-->
this.parseRoutes(routeElements);
...
return false;
}

<!--生成中间路由表Java类-->
private void parseRoutes(Set<? extends Element> routeElements) throws IOException {
...
// Generate groups
String groupFileName = NAME_OF_GROUP + groupName;
JavaFile.builder(PACKAGE_OF_GENERATE_FILE,
TypeSpec.classBuilder(groupFileName)
.addJavadoc(WARNING_TIPS)
.addSuperinterface(ClassName.get(type_IRouteGroup))
.addModifiers(PUBLIC)
.addMethod(loadIntoMethodOfGroupBuilder.build())
.build()
).build().writeTo(mFiler);

产物如下:包含路由表,及局部注册入口。


image.png



自动注册:ASM搜集上述路由表并聚合插入Init代码区



为了能够插入到Init代码区,首先需要预留一个位置,一般定义一个空函数,以待后续填充:


	public class RouterInitializer {

public static void init(boolean debug, Class webActivityClass, IRouterInterceptor... interceptors) {
...
loadRouterTables();
}
//自动注册代码
public static void loadRouterTables() {

}
}

首先利用AOP工具,遍历上述APT中间产物,聚合路由表,并注册到预留初始化位置,遍历的过程牵扯是gradle transform的过程,



  • 搜集目标,聚合路由表

      /**扫描jar*/
    fun scanJar(jarFile: File, dest: File?) {

    val file = JarFile(jarFile)
    var enumeration = file.entries()
    while (enumeration.hasMoreElements()) {
    val jarEntry = enumeration.nextElement()
    if (jarEntry.name.endsWith("XXRouterTable.class")) {
    val inputStream = file.getInputStream(jarEntry)
    val classReader = ClassReader(inputStream)
    if (Arrays.toString(classReader.interfaces)
    .contains("IHTRouterTBCollect")
    ) {
    tableList.add(
    Pair(
    classReader.className,
    dest?.absolutePath
    )
    )
    }
    inputStream.close()
    } else if (jarEntry.name.endsWith("HTRouterInitializer.class")) {
    registerInitClass = dest
    }
    }
    file.close()
    }

  • 对目标Class注入路由表初始化代码

      fun asmInsertMethod(originFile: File?) {

    val optJar = File(originFile?.parent, originFile?.name + ".opt")
    if (optJar.exists())
    optJar.delete()
    val jarFile = JarFile(originFile)
    val enumeration = jarFile.entries()
    val jarOutputStream = JarOutputStream(FileOutputStream(optJar))

    while (enumeration.hasMoreElements()) {
    val jarEntry = enumeration.nextElement()
    val entryName = jarEntry.getName()
    val zipEntry = ZipEntry(entryName)
    val inputStream = jarFile.getInputStream(jarEntry)
    //插桩class
    if (entryName.endsWith("RouterInitializer.class")) {
    //class文件处理
    jarOutputStream.putNextEntry(zipEntry)
    val classReader = ClassReader(IOUtils.toByteArray(inputStream))
    val classWriter = ClassWriter(classReader, ClassWriter.COMPUTE_MAXS)
    val cv = RegisterClassVisitor(Opcodes.ASM5, classWriter,tableList)
    classReader.accept(cv, EXPAND_FRAMES)
    val code = classWriter.toByteArray()
    jarOutputStream.write(code)
    } else {
    jarOutputStream.putNextEntry(zipEntry)
    jarOutputStream.write(IOUtils.toByteArray(inputStream))
    }
    jarOutputStream.closeEntry()
    }
    //结束
    jarOutputStream.close()
    jarFile.close()
    if (originFile?.exists() == true) {
    Files.delete(originFile.toPath())
    }
    optJar.renameTo(originFile)
    }


最终RouterInitializer.class的 loadRouterTables会被修改成如下填充好的代码:


 public static void loadRouterTables() {

<!---->
register("com.alibaba.android.arouter.routes.ARouter$$Root$$modulejava");
register("com.alibaba.android.arouter.routes.ARouter$$Root$$modulekotlin");
register("com.alibaba.android.arouter.routes.ARouter$$Root$$arouterapi");
register("com.alibaba.android.arouter.routes.ARouter$$Interceptors$$modulejava");
...
}

如此就完成了路由表的搜集与注册,大概的流程就是如此。当然对于支持服务、Fragment等略有不同,但大体类似。


Router框架对服务类组件的支持


通过路由的方式获取服务属于APP路由比较独特的能力,比如有个用户中心的组件,我们可以通过路由的方式去查询用户是否处于登陆状态,这种就不是狭义上的页面路由的概念,通过一串字符串如何查到对应的组件并调用其方法呢?这种的实现方式也有多种,每种实现方式都有自己的优劣。



  • 一种是可以将服务抽象成接口,沉到底层,上层实现通过路由方式映射对象

  • 一种是将实现方法直接通过路由方式映射


先看第一种,这种事Arouter的实现方式,它的优点是所有对外暴露的服务都暴露接口类【沉到底层】,这对于外部的调用方,也就是服务使用方非常友好,示例如下:



先定义抽象服务,并沉到底层



image.png


public interface HelloService extends IProvider {
void sayHello(String name);
}


实现服务,并通过Router注解标记



@Route(path = "/yourservicegroupname/hello")
public class HelloServiceImpl implements HelloService {
Context mContext;

@Override
public void sayHello(String name) {
Toast.makeText(mContext, "Hello " + name, Toast.LENGTH_SHORT).show();
}


使用:利用Router加scheme获取服务实例,并映射成抽象类,然后直接调用方法。



  ((HelloService) ARouter.getInstance().build("/yourservicegroupname/hello").navigation()).sayHello("mike");

这种实现方式对于使用方其实是很方便的,尤其是一个服务有多个可操作方法的时候,但是缺点是扩展性,如果想要扩展方法,就要改动底层库。


再看第二种:将实现方法直接通过路由方式映射


服务的调用都要落到方法上,参考页面路由,也可以支持方法路由,两者并列关系,所以组要增加一个方法路由表,实现原理与Page路由类似,跟上面的Arouter对比,不用定义抽象层,直接定义实现即可:



定义Method的Router



	public class HelloService {

<!--参数 name-->
@MethodRouter(url = {"arouter://sayhello"})
public void sayHello(String name) {
Toast.makeText(mContext, "Hello " + name, Toast.LENGTH_SHORT).show();
}


使用即可



 RouterCall.callMethod("arouter://sayhello?name=hello");

上述的缺点就是对于外部调用有些复杂,尤其是处理参数的时候,需要严格按照协议来处理,优点是,没有抽象层,如果需要扩展服务方法,不需要改动底层。


上述两种方式各有优劣,不过,如果从左服务组件的初衷出发,第一种比较好:对于调用方比较友好。另外对于CallBack的支持,Arouter的处理方式可能也会更方便一些,可以比较方便的交给服务方定义。如果是第二种,服务直接通过路由映射的方式,处理起来就比较麻烦,尤其是Callback中的参数,可能要统一封装成JSON并维护解析的协议,这样处理起来,可能不是很好。


路由表的匹配


路由表的匹配比较简单,就是在全局Map中根据String输入,匹配到目标组件,然后依赖反射等常用操作,定位到目标。


组件化与路由的关系


组件化是一种开发集成模式,更像一种开发规范,更多是为团队协同开发带来方便。组件化最终落地是一个个独立的业务及功能组件,这些组件之间可能是不同的团队,处于不同的目的在各自维护,甚至是需要代码隔离,如果牵扯到组件间的调用与通信,就不可避免的借助路由,因为实现隔离的,只能采用通用字符串scheme进行通信,这就是路由的功能范畴。


组件化需要路由支撑的根本原因:组件间代码实现的隔离


总结



  • 路由不是一个APP的必备功能,但是大型跨团队的APP基本都需要

  • 路由框架的基本能力:路由自动注册、路由表搜集、服务及UI界面路由及拦截等核心功能

  • 组件化与路由的关系:组件化的代码隔离导致路由框架成为必须




作者:看书的小蜗牛
链接:https://juejin.cn/post/6973905775940861966
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

浅谈Android插件化

一、认识插件化 1.1 插件化起源 插件化技术最初源于免安装运行 Apk的想法,这个免安装的 Apk 就可以理解为插件,而支持插件的 app 我们一般叫 宿主。 想必大家都知道,在 Android 系统中,应用是以 Apk 的形式存在的,应用都需要安装才...
继续阅读 »

一、认识插件化


1.1 插件化起源


插件化技术最初源于免安装运行 Apk的想法,这个免安装的 Apk 就可以理解为插件,而支持插件的 app 我们一般叫 宿主。


想必大家都知道,在 Android 系统中,应用是以 Apk 的形式存在的,应用都需要安装才能使用。但实际上 Android 系统安装应用的方式相当简单,其实就是把应用 Apk 拷贝到系统不同的目录下、然后把 so 解压出来而已。


常见的应用安装目录有:



  • /system/app:系统应用

  • /system/priv-app:系统应用

  • /data/app:用户应用


那可能大家会想问,既然安装这个过程如此简单,Android 是怎么运行应用中的代码的呢,我们先看 Apk 的构成,一个常见的 Apk 会包含如下几个部分:



  • classes.dexJava 代码字节码

  • res:资源文件

  • libso 文件

  • assets:静态资产文件

  • AndroidManifest.xml:清单文件


其实 Android 系统在打开应用之后,也只是开辟进程,然后使用 ClassLoader 加载 classes.dex 至进程中,执行对应的组件而已。


那大家可能会想一个问题,既然 Android 本身也是使用类似反射的形式加载代码执行,凭什么我们不能执行一个 Apk 中的代码呢?


1.2 插件化优点


插件化让 Apk 中的代码(主要是指 Android 组件)能够免安装运行,这样能够带来很多收益:



  • 减少安装Apk的体积、按需下载模块

  • 动态更新插件

  • 宿主和插件分开编译,提升开发效率

  • 解决方法数超过65535的问题


想象一下,你的应用拥有 Native 应用一般极高的性能,又能获取诸如 Web 应用一样的收益。


嗯,理想很美好不是嘛?


1.3 与组件化的区别



  • 组件化:是将一个App分成多个模块,每个模块都是一个组件(module),开发过程中可以让这些组件相互依赖或独立编译、调试部分组件,但是这些组件最终会合并成一个完整的Apk去发布到应用市场。

  • 插件化:是将整个App拆分成很多模块,每个模块都是一个Apk(组件化的每个模块是一个lib),最终打包的时候将宿主Apk和插件Apk分开打包,只需发布宿主Apk到应用市场,插件Apk通过动态按需下发到宿主Apk。


二、插件化的技术难点


? 想让插件的Apk真正运行起来,首先要先能找到插件Apk的存放位置,然后我们要能解析加载Apk里面的代码。


? 但是光能执行Java代码是没有意义的,在Android系统中有四大组件是需要在系统中注册的,具体来说是在 Android 系统的 ActivityManagerService (AMS)PackageManagerService (PMS) 中注册的,而四大组件的解析和启动都需要依赖 AMSPMS,如何欺骗系统,让他承认一个未安装的 Apk 中的组件,如何让宿主动态加载执行插件Apk中 Android 组件(即 ActivityServiceBroadcastReceiverContentProviderFragment)等是插件化最大的难点。


? 另外,应用资源引用(特指 R 中引用的资源,如 layoutvalues 等)也是一大问题,想象一下你在宿主进程中使用反射加载了一个插件 Apk,代码中的 R 对应的 id 却无法引用到正确的资源,会产生什么后果。


总结一下,其实做到插件化的要点就这几个:



  • 如何加载并执行插件 Apk 中的代码(ClassLoader Injection

  • 让系统能调用插件 Apk 中的组件(Runtime Container

  • 正确识别插件 Apk 中的资源(Resource Injection


当然还有其他一些小问题,但可能不是所有场景下都会遇到,我们后面再单独说。


三、ClassLoader Injection


ClassLoader 是插件化中必须要掌握的,因为我们知道Android 应用本身是基于魔改的 Java 虚拟机的,而由于插件是未安装的 apk,系统不会处理其中的类,所以需要使用 ClassLoader 加载 Apk,然后反射里面的代码。


3.1 java 中的 ClassLoader



  • BootstrapClassLoader 负责加载 JVM 运行时的核心类,比如 JAVA_HOME/lib/rt.jar 等等


  • ExtensionClassLoader 负责加载 JVM 的扩展类,比如 JAVA_HOME/lib/ext 下面的 jar 包


  • AppClassLoader 负责加载 classpath 里的 jar 包和目录



3.2 android 中的 ClassLoader


在Android系统中ClassLoader是用来加载dex文件的,有包含 dex 的 apk 文件以及 jar 文件,dex 文件是一种对class文件优化的产物,在Android中应用打包时会把所有class文件进行合并、优化(把不同的class文件重复的东西只保留一份),然后生成一个最终的class.dex文件



  • PathClassLoader 用来加载系统类和应用程序类,可以加载已经安装的 apk 目录下的 dex 文件

    public class PathClassLoader extends BaseDexClassLoader {
    public PathClassLoader(String dexPath, ClassLoader parent) {
    super(dexPath, null, null, parent);
    }

    public PathClassLoader(String dexPath, String libraryPath,
    ClassLoader parent)
    {
    super(dexPath, null, libraryPath, parent);
    }
    }

  • DexClassLoader 用来加载 dex 文件,可以从存储空间加载 dex 文件。

    public class DexClassLoader extends BaseDexClassLoader {
    public DexClassLoader(String dexPath, String optimizedDirectory,
    String libraryPath, ClassLoader parent)
    {
    super(dexPath, new File(optimizedDirectory), libraryPath, parent);
    }
    }


我们在插件化中一般使用的是 DexClassLoader。


3.3 双亲委派机制


每一个 ClassLoader 中都有一个 parent 对象,代表的是父类加载器,在加载一个类的时候,会先使用父类加载器去加载,如果在父类加载器中没有找到,自己再进行加载,如果 parent 为空,那么就用系统类加载器来加载。通过这样的机制可以保证系统类都是由系统类加载器加载的。 下面是 ClassLoader 的 loadClass 方法的具体实现。


    protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
// First, check if the class has already been loaded
Class<?> c = findLoadedClass(name);
if (c == null) {
try {
if (parent != null) {
// 先从父类加载器中进行加载
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
}

if (c == null) {
// 没有找到,再自己加载
c = findClass(name);
}
}
return c;
}
复制代码

3.4 如何加载插件中的类


要加载插件中的类,我们首先要创建一个 DexClassLoader,先看下 DexClassLoader 的构造函数需要哪些参数。


public class DexClassLoader extends BaseDexClassLoader {
public DexClassLoader(String dexPath, String optimizedDirectory, String librarySearchPath, ClassLoader parent) {
// ...
}
}
复制代码

构造函数需要四个参数: dexPath 是需要加载的 dex / apk / jar 文件路径 optimizedDirectory 是 dex 优化后存放的位置,在 ART 上,会执行 oat 对 dex 进行优化,生成机器码,这里就是存放优化后的 odex 文件的位置 librarySearchPath 是 native 依赖的位置 parent 就是父类加载器,默认会先从 parent 加载对应的类


创建出 DexClassLaoder 实例以后,只要调用其 loadClass(className) 方法就可以加载插件中的类了。具体的实现在下面:


    // 从 assets 中拿出插件 apk 放到内部存储空间
private fun extractPlugin() {
var inputStream = assets.open("plugin.apk")
File(filesDir.absolutePath, "plugin.apk").writeBytes(inputStream.readBytes())
}

private fun init() {
extractPlugin()
pluginPath = File(filesDir.absolutePath, "plugin.apk").absolutePath
nativeLibDir = File(filesDir, "pluginlib").absolutePath
dexOutPath = File(filesDir, "dexout").absolutePath
// 生成 DexClassLoader 用来加载插件类
pluginClassLoader = DexClassLoader(pluginPath, dexOutPath, nativeLibDir, this::class.java.classLoader)
}


3.5 执行插件类的方法


通过反射来执行类的方法


val loadClass = pluginClassLoader.loadClass(activityName)
loadClass.getMethod("test",null).invoke(loadClass)

我们称这个过程叫做 ClassLoader 注入。完成注入后,所有来自宿主的类使用宿主的 ClassLoader 进行加载,所有来自插件 Apk 的类使用插件 ClassLoader 进行加载,而由于 ClassLoader 的双亲委派机制,实际上系统类会不受 ClassLoader 的类隔离机制所影响,这样宿主 Apk 就可以在宿主进程中使用来自于插件的组件类了。


四、Runtime Container


我们之前说到 Activity 插件化最大的难点是如何欺骗系统,让他承认一个未安装的 Apk 中的组件。 因为插件是动态加载的,所以插件的四大组件不可能注册到宿主的 Manifest 文件中,而没有在 Manifest 中注册的四大组件是不能和系统直接进行交互的。 如果直接把插件的 Activity 注册到宿主 Manifest 里就失去了插件化的动态特性,因为每次插件中新增 Activity 都要修改宿主 Manifest 并且重新打包,那就和直接写在宿主中没什么区别了。


4.1 为什么没有注册的 Activity 不能和系统交互


这里的不能直接交互的含义有两个



  1. 系统会检测 Activity 是否注册 如果我们启动一个没有在 Manifest 中注册的 Activity,会发现报如下 error:

    android.content.ActivityNotFoundException: Unable to find explicit activity class {com.zyg.commontec/com.zyg.plugin.PluginActivity}; have you declared this activity in your AndroidManifest.xml?



这个 log 在 Instrumentation 的 checkStartActivityResult 方法中可以看到:


public class Instrumentation {
public static void checkStartActivityResult(int res, Object intent) {
if (!ActivityManager.isStartResultFatalError(res)) {
return;
}

switch (res) {
case ActivityManager.START_INTENT_NOT_RESOLVED:
case ActivityManager.START_CLASS_NOT_FOUND:
if (intent instanceof Intent && ((Intent)intent).getComponent() != null)
throw new ActivityNotFoundException(
"Unable to find explicit activity class "
+ ((Intent)intent).getComponent().toShortString()
+ "; have you declared this activity in your AndroidManifest.xml?");
throw new ActivityNotFoundException(
"No Activity found to handle " + intent);
...
}
}
}



  1. Activity 的生命周期无法被调用,其实一个 Activity 主要的工作,都是在其生命周期方法中调用了,既然上一步系统检测了 Manifest 注册文件,启动 Activity 被拒绝,那么其生命周期方法也肯定不会被调用了。从而插件 Activity 也就不能正常运行了。


4.2 运行时容器技术


由于Android中的组件(Activity,Service,BroadcastReceiver和ContentProvider)是由系统创建的,并且由系统管理生命周期。 仅仅构造出这些类的实例是没用的,还需要管理组件的生命周期。其中以Activity最为复杂,不同框架采用的方法也不尽相同。插件化如何支持组件生命周期的管理。 大致分为两种方式:



  • 运行时容器技术(ProxyActivity代理)

  • 预埋StubActivity,hook系统启动Activity的过程


我们的解决方案很简单,即运行时容器技术,简单来说就是在宿主 Apk 中预埋一些空的 Android 组件,以 Activity 为例,我预置一个 ContainerActivity extends Activity 在宿主中,并且在 AndroidManifest.xml 中注册它。


它要做的事情很简单,就是帮助我们作为插件 Activity 的容器,它从 Intent 接受几个参数,分别是插件的不同信息,如:



  • pluginName

  • pluginApkPath

  • pluginActivityName


等,其实最重要的就是 pluginApkPathpluginActivityName,当 ContainerActivity 启动时,我们就加载插件的 ClassLoaderResource,并反射 pluginActivityName 对应的 Activity 类。当完成加载后,ContainerActivity 要做两件事:



  • 转发所有来自系统的生命周期回调至插件 Activity

  • 接受 Activity 方法的系统调用,并转发回系统


我们可以通过复写 ContainerActivity 的生命周期方法来完成第一步,而第二步我们需要定义一个 PluginActivity,然后在编写插件 Apk 中的 Activity 组件时,不再让其集成 android.app.Activity,而是集成自我们的 PluginActivity


public class ContainerActivity extends Activity {
private PluginActivity pluginActivity;

@Override
protected void onCreate(Bundle savedInstanceState) {
String pluginActivityName = getIntent().getString("pluginActivityName", "");
pluginActivity = PluginLoader.loadActivity(pluginActivityName, this);
if (pluginActivity == null) {
super.onCreate(savedInstanceState);
return;
}

pluginActivity.onCreate();
}

@Override
protected void onResume() {
if (pluginActivity == null) {
super.onResume();
return;
}
pluginActivity.onResume();
}

@Override
protected void onPause() {
if (pluginActivity == null) {
super.onPause();
return;
}
pluginActivity.onPause();
}

// ...
}

public class PluginActivity {
private ContainerActivity containerActivity;

public PluginActivity(ContainerActivity containerActivity) {
this.containerActivity = containerActivity;
}

@Override
public <T extends View> T findViewById(int id) {
return containerActivity.findViewById(id);
}
// ...
}

// 插件 `Apk` 中真正写的组件
public class TestActivity extends PluginActivity {
// ......
}

是不是感觉有点看懂了,虽然真正搞的时候还有很多小坑,但大概原理就是这么简单,启动插件组件需要依赖容器,容器负责加载插件组件并且完成双向转发,转发来自系统的生命周期回调至插件组件,同时转发来自插件组件的系统调用至系统。


4.3 字节码替换


该方式虽然能够很好的实现启动插件Activity的目的,但是由于开发式侵入性很强,插件中的Activity必须继承PluginActivity,如果想把之前的模块改造成插件需要很多额外的工作。


class TestActivity extends Activity {}
->
class TestActivity extends PluginActivity {}

有没有什么办法能让插件组件的编写与原来没有任何差别呢?


Shadow 的做法是字节码替换插件,这是一个非常棒的想法,简单来说,Android 提供了一些 Gradle 插件开发套件,其中有一项功能叫 Transform Api,它可以介入项目的构建过程,在字节码生成后、dex 文件生成前,对代码进行某些变换,具体怎么做的不说了,可以自己看文档。


实现的功能嘛,就是用户配置 Gradle 插件后,正常开发,依然编写:


class TestActivity extends Activity {}

然后完成编译后,最后的字节码中,显示的却是:


class TestActivity extends PluginActivity {}

到这里基本的框架就差不多结束了。


五、Resource Injection


最后要说的是资源注入,其实这一点相当重要,Android 应用的开发其实崇尚的是逻辑与资源分离的理念,所有资源(layoutvalues 等)都会被打包到 Apk 中,然后生成一个对应的 R 类,其中包含对所有资源的引用 id


资源的注入并不容易,好在 Android 系统给我们留了一条后路,最重要的是这两个接口:



  • PackageManager#getPackageArchiveInfo:根据 Apk 路径解析一个未安装的 ApkPackageInfo

  • PackageManager#getResourcesForApplication:根据 ApplicationInfo 创建一个 Resources 实例


我们要做的就是在上面 ContainerActivity#onCreate 中加载插件 Apk 的时候,用这两个方法创建出来一份插件资源实例。具体来说就是先用 PackageManager#getPackageArchiveInfo 拿到插件 ApkPackageInfo,有了 PacakgeInfo 之后我们就可以自己组装一份 ApplicationInfo,然后通过 PackageManager#getResourcesForApplication 来创建资源实例,大概代码像这样:


PackageManager packageManager = getPackageManager();
PackageInfo packageArchiveInfo = packageManager.getPackageArchiveInfo(
pluginApkPath,
PackageManager.GET_ACTIVITIES
| PackageManager.GET_META_DATA
| PackageManager.GET_SERVICES
| PackageManager.GET_PROVIDERS
| PackageManager.GET_SIGNATURES
);
packageArchiveInfo.applicationInfo.sourceDir = pluginApkPath;
packageArchiveInfo.applicationInfo.publicSourceDir = pluginApkPath;

Resources injectResources = null;
try {
injectResources = packageManager.getResourcesForApplication(packageArchiveInfo.applicationInfo);
} catch (PackageManager.NameNotFoundException e) {
// ...
}

拿到资源实例后,我们需要将宿主的资源和插件资源 Merge 一下,编写一个新的 Resources 类,用这样的方式完成自动代理:


public class PluginResources extends Resources {
private Resources hostResources;
private Resources injectResources;

public PluginResources(Resources hostResources, Resources injectResources) {
super(injectResources.getAssets(), injectResources.getDisplayMetrics(), injectResources.getConfiguration());
this.hostResources = hostResources;
this.injectResources = injectResources;
}

@Override
public String getString(int id, Object... formatArgs) throws NotFoundException {
try {
return injectResources.getString(id, formatArgs);
} catch (NotFoundException e) {
return hostResources.getString(id, formatArgs);
}
}

// ...
}

然后我们在 ContainerActivity 完成插件组件加载后,创建一份 Merge 资源,再复写 ContainerActivity#getResources,将获取到的资源替换掉:


public class ContainerActivity extends Activity {
private Resources pluginResources;

@Override
protected void onCreate(Bundle savedInstanceState) {
// ...
pluginResources = new PluginResources(super.getResources(), PluginLoader.getResources(pluginApkPath));
// ...
}

@Override
public Resources getResources() {
if (pluginActivity == null) {
return super.getResources();
}
return pluginResources;
}
}

这样就完成了资源的注入。



作者:QiShare
链接:https://juejin.cn/post/6973888932572315678
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

二阶贝塞尔仿微信扔炸弹动画

前言 新出来的微信炸屎动画很多人都玩过了,所以先仿照一个微信扔炸弹的动画,在后续有时间会做一个完整的,效果如下: 具体实现 其中最麻烦的就是绘制抛物线了,爆炸的效果只是播放了一个动画,另外微信貌似都是通过代码绘制的,可能不是动画,奈何没有人家那技术,...
继续阅读 »

前言


新出来的微信炸屎动画很多人都玩过了,所以先仿照一个微信扔炸弹的动画,在后续有时间会做一个完整的,效果如下:



具体实现


其中最麻烦的就是绘制抛物线了,爆炸的效果只是播放了一个动画,另外微信貌似都是通过代码绘制的,可能不是动画,奈何没有人家那技术,只能找一张动画来凑合。


二阶贝塞尔曲线


抛物线在这里是通过二阶贝塞尔曲线来完成,所以先来了解下什么是二阶贝塞尔曲线,从下图中可以发现,二阶贝塞尔曲线有三个关键点,我们可以称作起点坐标、终点坐标,还有控制点。


录屏_选择区域_20210615170032.gif


起点和终点坐标好理解,控制点可以理解成开始下降的转折点,而古老的数学大神早就提供好了公式,我们只需要向这个公式提供这几个参数即可得到x、y,当然还有个参数是时间,有了时间控制,我们可以在指定秒内把他平滑的绘制完成。


公式如下:


x = (1 - t)^2 * 0 + 2 t (1 - t) * 1 + t^2 * 1 = 2 t (1 - t) + t^2
y= (1 - t)^2 * 1 + 2 t (1 - t) * 1 + t^2 * 0 = (1 - t)^2 + 2 t (1 - t)

自定义二阶贝塞尔曲线计算器


提到动画,首先可能会想到ObjectAnimator类,没错,抛物线也是通过ObjectAnimator来完成的,只不过我们需要自定义一个TypeEvaluator,用来提供二阶贝塞尔曲线的x和y。


TypeEvaluator只有一个方法,定义如下:


public abstract T evaluate (float fraction, 
T startValue,
T endValue)



fraction表示开始值和结束值之间的比例,startValue、endValue分别是开始值和结束值,这个比例也可以当作是时间,可能官方一点叫比例,他会自动计算,值的范围是0-1,比如取值0.5的时候就是动画完成了一半,1的时候动画完成。


所以套入二阶贝塞尔曲线公式得到如下代码:


class PointFTypeEvaluator(var control: PointF) : TypeEvaluator<PointF> {
override fun evaluate(fraction: Float, startValue: PointF, endValue: PointF): PointF {
return getPointF(startValue, endValue, control, fraction)
}

private fun getPointF(start: PointF, end: PointF, control: PointF, t: Float): PointF {
val pointF = PointF()
pointF.x = (1 - t) * (1 - t) * start.x + 2 * t * (1 - t) * control.x + t * t * end.x
pointF.y = (1 - t) * (1 - t) * start.y + 2 * t * (1 - t) * control.y + t * t * end.y
return pointF
}

}

播放动画


然后使用ObjectAnimator进行播放。


 val animator = ObjectAnimator.ofObject(activityMainBinding.boom, "mPointF",
PointFTypeEvaluator(controlP), startP, endP)

注意的是这个View需要有point方法,参数是PointF,方法内主要完成x和y的设置。


 public void setPoint(PointF pointF) {
setX(pointF.x);
setY(pointF.y);
}

当然微信炸弹落地的位置是随机的,我们也加个随机。


class MainActivity : AppCompatActivity() {
lateinit var binding: ActivityMainBinding;

private fun getRandom(max: Int, min: Int): Int {
val random = java.util.Random()
return random.nextInt(max - min + 1) + min
}

private fun getRandomPointF():PointF{
val outMetrics = DisplayMetrics()
val offset = 100
windowManager.defaultDisplay.getMetrics(outMetrics)
val width = outMetrics.widthPixels
val height = outMetrics.heightPixels
return PointF(getRandom(width / 2 + offset, width / 2 - offset).toFloat(), getRandom(height / 2 + offset, height / 2 - offset).toFloat())
}


override fun onCreate(savedInstanceState: Bundle?)
{
super.onCreate(savedInstanceState)
binding = DataBindingUtil.setContentView<ActivityMainBinding>(this, R.layout.activity_main);



binding.button.setOnClickListener {
binding!!.boom.visibility = View.VISIBLE
val startP = PointF()
val endP = PointF()
val controlP = PointF()
val randomPointF = getRandomPointF()
startP.x = 916f
startP.y = 1353f
endP.x = randomPointF.x
endP.y = randomPointF.y
controlP.x = randomPointF.x + getRandom(200, 50)
controlP.y = randomPointF.y - getRandom(200, 50)
val animator = ObjectAnimator.ofObject(binding.boom, "point",
PointFTypeEvaluator(controlP), startP, endP)

animator.start()
}
}

}

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


<data>

</data>

<RelativeLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:context=".MainActivity">



<com.airbnb.lottie.LottieAnimationView
android:visibility="gone"
android:id="@+id/lottie"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:lottie_fileName="boom.json">
</com.airbnb.lottie.LottieAnimationView>

<Button
android:id="@+id/button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="开始">
</Button>

<com.example.kotlindemo.widget.MyImageView
android:id="@+id/boom"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/ic_boom"
android:visibility="gone">
</com.example.kotlindemo.widget.MyImageView>
</RelativeLayout>
</layout>

效果如下:


录屏_选择区域_20210615174149.gif


爆炸效果


爆炸效果是使用的动画,用的lottie框架,这里提供爆炸文件的下载地址。


https://lottiefiles.com/download/public/9990-explosion

有了结束的坐标点,只需要吧LottieAnimationView移动到对应位置进行播放即可,播放后隐藏,完整代码如下:


package com.example.kotlindemo

import android.animation.Animator
import android.animation.ObjectAnimator
import android.content.Context
import android.content.Intent
import android.graphics.BitmapFactory
import android.graphics.PointF
import android.media.projection.MediaProjectionManager
import android.os.Build
import android.os.Bundle
import android.util.DisplayMetrics
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.databinding.DataBindingUtil
import com.example.kotlindemo.databinding.ActivityMainBinding
import com.example.kotlindemo.widget.PointFTypeEvaluator
import meow.bottomnavigation.MeowBottomNavigation
import kotlin.random.Random


class MainActivity : AppCompatActivity() {
lateinit var binding: ActivityMainBinding;

private fun getRandom(max: Int, min: Int): Int {
val random = java.util.Random()
return random.nextInt(max - min + 1) + min
}

private fun getRandomPointF():PointF{
val outMetrics = DisplayMetrics()
val offset = 100
windowManager.defaultDisplay.getMetrics(outMetrics)
val width = outMetrics.widthPixels
val height = outMetrics.heightPixels
return PointF(getRandom(width / 2 + offset, width / 2 - offset).toFloat(), getRandom(height / 2 + offset, height / 2 - offset).toFloat())
}


override fun onCreate(savedInstanceState: Bundle?)
{
super.onCreate(savedInstanceState)
binding = DataBindingUtil.setContentView<ActivityMainBinding>(this, R.layout.activity_main);


binding!!.button.setOnClickListener {
binding!!.boom.visibility = View.VISIBLE
val startP = PointF()
val endP = PointF()
val controlP = PointF()
val randomPointF = getRandomPointF()
startP.x = 916f
startP.y = 1353f
endP.x = randomPointF.x
endP.y = randomPointF.y
controlP.x = randomPointF.x + getRandom(200, 50)
controlP.y = randomPointF.y - getRandom(200, 50)
val animator = ObjectAnimator.ofObject(binding.boom, "point",
PointFTypeEvaluator(controlP), startP, endP)
animator.duration = 600
animator.addListener(object : Animator.AnimatorListener {
override fun onAnimationStart(animation: Animator) {}
override fun onAnimationEnd(animation: Animator) {
val measuredHeight = binding.lottie.measuredHeight
val measuredWidth = binding.lottie.measuredWidth
binding.lottie.x = randomPointF.x - measuredWidth / 2
binding.lottie.y = randomPointF.y - measuredHeight / 2
binding.lottie.visibility = View.VISIBLE
binding.boom.visibility = View.GONE
binding.lottie.playAnimation()
binding.lottie.addAnimatorListener(object : Animator.AnimatorListener {
override fun onAnimationStart(animation: Animator) {}
override fun onAnimationEnd(animation: Animator) {
binding.lottie.visibility = View.GONE
}

override fun onAnimationCancel(animation: Animator) {}
override fun onAnimationRepeat(animation: Animator) {}
})
}

override fun onAnimationCancel(animation: Animator) {}
override fun onAnimationRepeat(animation: Animator) {}
})
animator.start()
}

}

}




作者:i听风逝夜
链接:https://juejin.cn/post/6973957344845627399
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

Android App唤醒丶保活详解 , 以及代码展示

安卓进程进程保活分为: 黑色保活,白色保活,灰色保活 黑色保活: 可以说黑色保活,可以通过网络切换,拍照,拍视频,开机,利用系统产生的广播唤醒app,接入三方的sdk也会唤醒一些app,如支付宝,微信..........这样的话,这样的话,不敢想象系统存...
继续阅读 »

安卓进程进程保活分为:


黑色保活,白色保活,灰色保活


黑色保活:


可以说黑色保活,可以通过网络切换,拍照,拍视频,开机,利用系统产生的广播唤醒app,接入三方的sdk也会唤醒一些app,如支付宝,微信..........这样的话,这样的话,不敢想象系统存活会给系统带来多大的负担,所以我们的安卓手机也变得卡了,google官方可能也认识了这么一点,所以取消了


ACTION_NEW_PICTURE(拍照),ACTION_NEW_VIDEO(拍视频),CONNECTIVITY_ACTION(网络切换)


app也会随着做一点改变,(不过sdk的使用还是会通过一个app启动相关的一些app , 黑色保活我个人认为不推荐使用,毕竟为了我们广大安卓用户。)


白色保活:


白色保活手段非常简单,就是调用系统api启动一个前台的Service进程,这样会在系统的通知栏生成一个Notification,用来让用户知道有这样一个app在运行着,哪怕当前的app退到了后台。




不过用户看到这个图标的时候,都会把它清空的。。。。



灰色保活:


可以说,灰色保活是用的最多,当用户不知不觉中这个app程序已经在后台运行了。


它是利用系统的漏洞来启动一个前台的Service进程,与普通的启动方式区别在于,它不会在系统通知栏处出现一个Notification,看起来就如同运行着一个后台Service进程一样。这样做带来的好处就是,用户无法察觉到你运行着一个前台进程(因为看不到Notification),但你的进程优先级又是高于普通后台进程的。API < 18,启动前台Service时直接传入new Notification();API >= 18,同时启动两个id相同的前台Service,然后再将后启动的Service做stop处理;


安卓app唤醒:


其实app唤醒的介绍很好说,app唤醒就是当打开一个app的时候,另一个app里有对应刚打开那个app的属性标志,根据你想要的唤醒方式,执行不同的代码操作,这样就可以唤醒另一个没打开的app了。(代码在最下面)


下面我展示一下这几种状态下的代码:


这个是xml布局,主要是为了展示我所介绍的几种保活方式:


<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:paddingBottom="@dimen/activity_vertical_margin"
android:paddingLeft="@dimen/activity_horizontal_margin"
android:paddingRight="@dimen/activity_horizontal_margin"
android:paddingTop="@dimen/activity_vertical_margin">

<Button
android:id="@+id/mBtn_white"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="白色保活" />

<Button
android:id="@+id/mBtn_gray"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="灰色保活" />

<Button
android:id="@+id/mBtn_black"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="黑色保活(发广播)" />

<Button
android:id="@+id/mBtn_background_service"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="普通后台 Service 进程" />

</LinearLayout>

下面是主要实现类:


WakeReceiver


import android.app.Notification;
import android.app.Service;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.os.Build;
import android.os.IBinder;
import android.util.Log;

public class WakeReceiver extends BroadcastReceiver {
private final static String TAG = WakeReceiver.class.getSimpleName();
private final static int WAKE_SERVICE_ID = -1111;
/**
* 灰色保活手段唤醒广播的action
*/
public final static String GRAY_WAKE_ACTION = "com.wake.gray";
@Override
public void onReceive(Context context, Intent intent) {
String action = intent.getAction();
if (GRAY_WAKE_ACTION.equals(action)) {
Log.i(TAG, "wake !! wake !! ");

Intent wakeIntent = new Intent(context, WakeNotifyService.class);
context.startService(wakeIntent);
}
}
/**
* 用于其他进程来唤醒UI进程用的Service
*/
public static class WakeNotifyService extends Service {

@Override
public void onCreate() {
Log.i(TAG, "WakeNotifyService->onCreate");
super.onCreate();
}

@Override
public int onStartCommand(Intent intent, int flags, int startId) {
Log.i(TAG, "WakeNotifyService->onStartCommand");
if (Build.VERSION.SDK_INT < 18) {
startForeground(WAKE_SERVICE_ID, new Notification());//API < 18 ,此方法能有效隐藏Notification上的图标
} else {
Intent innerIntent = new Intent(this, WakeGrayInnerService.class);
startService(innerIntent);
startForeground(WAKE_SERVICE_ID, new Notification());
}
return START_STICKY;
}

@Override
public IBinder onBind(Intent intent) {
// TODO: Return the communication channel to the service.
throw new UnsupportedOperationException("Not yet implemented");
}

@Override
public void onDestroy() {
Log.i(TAG, "WakeNotifyService->onDestroy");
super.onDestroy();
}
}

/**
* 给 API >= 18 的平台上用的灰色保活手段
*/
public static class WakeGrayInnerService extends Service {

@Override
public void onCreate() {
Log.i(TAG, "InnerService -> onCreate");
super.onCreate();
}

@Override
public int onStartCommand(Intent intent, int flags, int startId) {
Log.i(TAG, "InnerService -> onStartCommand");
startForeground(WAKE_SERVICE_ID, new Notification());
//stopForeground(true);
stopSelf();
return super.onStartCommand(intent, flags, startId);
}

@Override
public IBinder onBind(Intent intent) {
// TODO: Return the communication channel to the service.
throw new UnsupportedOperationException("Not yet implemented");
}

@Override
public void onDestroy() {
Log.i(TAG, "InnerService -> onDestroy");
super.onDestroy();
}
}
}

BackGroundService


import android.app.Service;
import android.content.Intent;
import android.os.IBinder;
import android.util.Log;

/**
* 普通的后台Service进程
*
* @author clock
* @since 2016-04-12
*/
public class BackgroundService extends Service {

private final static String TAG = BackgroundService.class.getSimpleName();

@Override
public void onCreate() {
Log.i(TAG, "onCreate");
super.onCreate();
}

@Override
public IBinder onBind(Intent intent) {
// TODO: Return the communication channel to the service.
throw new UnsupportedOperationException("Not yet implemented");
}

@Override
public void onDestroy() {
Log.i(TAG, "onDestroy");
super.onDestroy();
}
}

GrayService


import android.app.AlarmManager;
import android.app.Notification;
import android.app.PendingIntent;
import android.app.Service;
import android.content.Context;
import android.content.Intent;
import android.os.Build;
import android.os.IBinder;
import android.util.Log;
import com.example.renzheng.receiver.WakeReceiver;

/**
* 灰色保活手法创建的Service进程
*
* @author Clock
* @since 2016-04-12
*/
public class GrayService extends Service {

private final static String TAG = GrayService.class.getSimpleName();
/**
* 定时唤醒的时间间隔,5分钟
*/
private final static int ALARM_INTERVAL = 5 * 60 * 1000;
private final static int WAKE_REQUEST_CODE = 6666;

private final static int GRAY_SERVICE_ID = -1001;

@Override
public void onCreate() {
Log.i(TAG, "GrayService->onCreate");
super.onCreate();
}

@Override
public int onStartCommand(Intent intent, int flags, int startId) {
Log.i(TAG, "GrayService->onStartCommand");
if (Build.VERSION.SDK_INT < 18) {
startForeground(GRAY_SERVICE_ID, new Notification());//API < 18 ,此方法能有效隐藏Notification上的图标
} else {
Intent innerIntent = new Intent(this, GrayInnerService.class);
startService(innerIntent);
startForeground(GRAY_SERVICE_ID, new Notification());
}

//发送唤醒广播来促使挂掉的UI进程重新启动起来
AlarmManager alarmManager = (AlarmManager) getSystemService(Context.ALARM_SERVICE);
Intent alarmIntent = new Intent();
alarmIntent.setAction(WakeReceiver.GRAY_WAKE_ACTION);
PendingIntent operation = PendingIntent.getBroadcast(this, WAKE_REQUEST_CODE, alarmIntent, PendingIntent.FLAG_UPDATE_CURRENT);
alarmManager.setInexactRepeating(AlarmManager.RTC_WAKEUP, System.currentTimeMillis(), ALARM_INTERVAL, operation);

return START_STICKY;
}

@Override
public IBinder onBind(Intent intent) {
throw new UnsupportedOperationException("Not yet implemented");
}

@Override
public void onDestroy() {
Log.i(TAG, "GrayService->onDestroy");
super.onDestroy();
}

/**
* 给 API >= 18 的平台上用的灰色保活手段
*/
public static class GrayInnerService extends Service {

@Override
public void onCreate() {
Log.i(TAG, "InnerService -> onCreate");
super.onCreate();
}

@Override
public int onStartCommand(Intent intent, int flags, int startId) {
Log.i(TAG, "InnerService -> onStartCommand");
startForeground(GRAY_SERVICE_ID, new Notification());
//stopForeground(true);
stopSelf();
return super.onStartCommand(intent, flags, startId);
}

@Override
public IBinder onBind(Intent intent) {
// TODO: Return the communication channel to the service.
throw new UnsupportedOperationException("Not yet implemented");
}

@Override
public void onDestroy() {
Log.i(TAG, "InnerService -> onDestroy");
super.onDestroy();
}
}
}

WhileService


import android.app.Notification;
import android.app.PendingIntent;
import android.app.Service;
import android.content.Intent;
import android.os.IBinder;
import android.support.v7.app.NotificationCompat;
import android.util.Log;
import com.example.renzheng.MainActivity;
import com.example.renzheng.R;
/**
* 正常的系统前台进程,会在系统通知栏显示一个Notification通知图标
*
* @author clock
* @since 2016-04-12
*/
public class WhiteService extends Service {

private final static String TAG = WhiteService.class.getSimpleName();
private final static int FOREGROUND_ID = 1000;

@Override
public void onCreate() {
Log.i(TAG, "WhiteService->onCreate");
super.onCreate();
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
Log.i(TAG, "WhiteService->onStartCommand");
NotificationCompat.Builder builder = new NotificationCompat.Builder(this);
builder.setSmallIcon(R.mipmap.ic_launcher);
builder.setContentTitle("Foreground");
builder.setContentText("I am a foreground service");
builder.setContentInfo("Content Info");
builder.setWhen(System.currentTimeMillis());
Intent activityIntent = new Intent(this, MainActivity.class);
PendingIntent pendingIntent = PendingIntent.getActivity(this, 1, activityIntent, PendingIntent.FLAG_UPDATE_CURRENT);
builder.setContentIntent(pendingIntent);
Notification notification = builder.build();
startForeground(FOREGROUND_ID, notification);
return super.onStartCommand(intent, flags, startId);
}

@Override
public IBinder onBind(Intent intent) {
// TODO: Return the communication channel to the service.
throw new UnsupportedOperationException("Not yet implemented");
}

@Override
public void onDestroy() {
Log.i(TAG, "WhiteService->onDestroy");
super.onDestroy();
}
}

MainActivity


import android.content.Intent;
import android.os.Bundle;
import android.support.v7.app.AppCompatActivity;
import android.view.View;
import com.example.renzheng.service.BackgroundService;
import com.example.renzheng.service.GrayService;
import com.example.renzheng.service.WhiteService;

public class MainActivity extends AppCompatActivity implements View.OnClickListener {

private final static String TAG = MainActivity.class.getSimpleName();
/**
* 黑色唤醒广播的action
*/
private final static String BLACK_WAKE_ACTION = "com.wake.black";

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
findViewById(R.id.mBtn_white).setOnClickListener(this);
findViewById(R.id.mBtn_gray).setOnClickListener(this);
findViewById(R.id.mBtn_black).setOnClickListener(this);
findViewById(R.id.mBtn_background_service).setOnClickListener(this);
}

@Override
public void onClick(View v) {
int viewId = v.getId();
if (viewId == R.id.mBtn_white) { //系统正常的前台Service,白色保活手段
Intent whiteIntent = new Intent(getApplicationContext(), WhiteService.class);
startService(whiteIntent);

} else if (viewId == R.id.mBtn_gray) {//利用系统漏洞,灰色保活手段(API < 18 和 API >= 18 两种情况)
Intent grayIntent = new Intent(getApplicationContext(), GrayService.class);
startService(grayIntent);

} else if (viewId == R.id.mBtn_black) { //拉帮结派,黑色保活手段,利用广播唤醒队友
Intent blackIntent = new Intent();
blackIntent.setAction(BLACK_WAKE_ACTION);
sendBroadcast(blackIntent);

} else if (viewId == R.id.mBtn_background_service) {//普通的后台进程
Intent bgIntent = new Intent(getApplicationContext(), BackgroundService.class);
startService(bgIntent);
}
}
}

代码注册权限:


 


<receiver
android:name=".receiver.WakeReceiver"
android:enabled="true"
android:exported="false">
<intent-filter>
<action android:name="com.wake.gray" />
</intent-filter>
</receiver>

<service
android:name=".service.WhiteService"
android:enabled="true"
android:exported="false"
android:process=":white" />
<service
android:name=".service.GrayService"
android:enabled="true"
android:exported="false"
android:process=":gray" />
<service
android:name=".service.GrayService$GrayInnerService"
android:enabled="true"
android:exported="false"
android:process=":gray" />
<service
android:name=".service.BackgroundService"
android:enabled="true"
android:exported="false"
android:process=":bg" />
<service
android:name=".receiver.WakeReceiver$WakeNotifyService"
android:enabled="true"
android:exported="false" />

<service
android:name=".receiver.WakeReceiver$WakeGrayInnerService"
android:enabled="true"
android:exported="false" />

 


下面是app唤醒代码:


有2个APP,分别为A和B,当A活着的时候,试着开启B的后台服务,将原本杀死的B的后台服务程序活起来。反之也一样。


1.先看B的代码:


创建一个服务B,给服务添加一个process属性,设置action。


 

<service
android:name=".B"
android:process=":test">
<intent-filter>
<action android:name="yangyang" />
</intent-filter>
</service>

B的代码,在onStartCommand方法中弹出toast:


public class B extends Service {
@Nullable
@Override
public IBinder onBind(Intent intent) {
return null;
}

@Override
public int onStartCommand(Intent intent, int flags, int startId) {

Toast.makeText(this, "B 已经唤醒", Toast.LENGTH_SHORT).show();
return START_STICKY;
}
}

2.看A的代码,在MainActivity中点击开启B应用的B服务的代码:


public class MainActivity extends AppCompatActivity {

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);

setContentView(R.layout.activity_main);

Button btn = (Button) findViewById(R.id.btn);

btn.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
sendService();
}
});
}

private void sendService() {
boolean find = false;

ActivityManager mActivityManager = (ActivityManager) getSystemService(Context.ACTIVITY_SERVICE);
Intent serviceIntent = new Intent();

for (ActivityManager.RunningServiceInfo runningServiceInfo : mActivityManager.getRunningServices(100)) {
if (runningServiceInfo.process.contains(":test")) {//判断service是否在运行
Log.e("zhang", "process:" + runningServiceInfo.process);
find = true;
}
}
//判断服务是否起来,如果服务没起来,就唤醒
if (!find) {
serviceIntent.setPackage("com.example.b);
serviceIntent.setAction("yangyang");
startService(serviceIntent);
Toast.makeText(this, "开始唤醒 B", Toast.LENGTH_SHORT).show();
}else {
Toast.makeText(this, "B 不用唤醒", Toast.LENGTH_SHORT).show();
}
}
}

这里只是写了A启动B服务的代码,反之也是一样的。被启动应用的Servcie在AndroidMainfest.xml中注册时注意,添加process属性,和设置action匹配规则。


————————————————
版权声明:本文为CSDN博主「看美丽风晴」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/nazicsdn/article/details/79752617

收起阅读 »

有“声”聚一堂|RTE 2021 编程挑战赛圆满收官啦!

6 月 12 日,由声网Agora 与环信联合主办的“RTE 2021 编程挑战赛”圆满落幕。从 200+ 支参赛队伍中冲出重围的 46 支决赛队伍用精彩的答辩为历时 2 个多月的大赛划下了圆满的句号。今年的“RTE 2021 创新编程挑战赛”共分为 2 个赛...
继续阅读 »

6 月 12 日,由声网Agora 与环信联合主办的“RTE 2021 编程挑战赛”圆满落幕。从 200+ 支参赛队伍中冲出重围的 46 支决赛队伍用精彩的答辩为历时 2 个多月的大赛划下了圆满的句号。

今年的“RTE 2021 创新编程挑战赛”共分为 2 个赛道:应用创新赛道延续了「使用声网Agora SDK 开发应用」的赛题;技术创新赛道开发者可以「利用声网云市场插件接口,开发自研插件与功能演示 Demo」。


尽管此次的赛制与赛题对参赛队伍提出了更高的要求,但同时也为大家提供了独有的技术创新空间。相较去年而言,两个赛道的报名队伍及提交作品几乎都是去年的两倍。

本次大赛的决赛和颁奖都是通过 Agora Video Call App 在线上进行的,全程通过 B 站进行了直播。 最终,决赛共诞生了应用创新赛道的一、二、三等奖团队各一名,“环信专项奖”一名,以及“优秀奖” 六名;技术创新赛道“技术创新专项奖”一名,“优秀奖”一名。



应用创新赛道

一等奖:Agora Home AI

随着智能设备性能提升和网络的快速发展,以音视频为基础的智能硬件也正在蓬勃发展中。跨品牌、跨产品的设备管理也成为萦绕在用户日常使用中绕不开的一个话题。

「Agora Home AI」 系统以智能家居为主题,使用云信令 SDK 实现了IoT 设备远程控制。同时,通过声网Agora RTC SDK 实现人与机器的 1V1 视频,将机器人端采集到的视频发送至 PC 控制中心,进行 AI 智能检测,触发事件响应。


系统采用开源了 Yolo V3 算法进行各种视频数据的处理,支持 C#、C++ 调用;Unity 3D、VS 系列开发。目前已支持 Yolo 基础 80 种物体识别、安全帽识别、冰球识别文件等。采用声网提供的云信令 SDK 进行远程设备控制,构建群组房间进行消息实时通信,支持通过自定义协议进行智能硬件的控制。

「Agora Home AI」可以帮助用户实现可穿戴设备、智能家具设备、视频监控设备接入何控制。包括智能灯光、智能门窗、智能门锁、智能安防、智能手环监测、智能家电控制等配套产品,让用户实现多种品牌的智能设备在统一的交互平台内互联互通、统一管理、智能联动。为给用户创造更舒适、更安全、更节能的家居生活环境。


二等奖:Agora FIow

获得第二名的作品「Agora Flow」是一个基于声网+环信 SDK 搭建的音视频 Low Code Web 共享编辑器。

作品的灵感来源于在使用声网Agora SDK 的过程中,创作者一直在思考关于音视频服务除了以 SDK 的形式来提供服务和为开发者赋能外,还有没有别的形式呢?Low Code 就是这样一个可能的解决方案。将音视频相关功能进行模块化集成,提供一个图形化界面,让开发者可以用做 PPT 的形式来完成想要实现的功能。
作品通过声网的音视频传输及云信令 SDK 产品,提供了基于 Web 的集成了 RTC Chat SDK 的模版工程,通过 CodeGen 来生成配置项。实现了在线流程图编辑器 Low Code 项目的自动生成。作品中的一切的操作几乎都可以通过拖拽来完成。


有开发者开玩笑说,这次的大赛作品很多都是开发了一个 App,而「Agora Flow」则是做了一个帮助开发者能更好开发 App 的项目。



三等奖:都市探险家

「都市探险家」项目是一款利用地图 LBS + 云信令 SDK + 实时音视频构建的社交产品。这款产品为想要寻找共同爱好的新朋友并一起在都市进行旅游、探索的小伙伴而设计。


产品的使用十分简单便捷,用户注册登录后,通过 LBS 地图会自动更新用户所在位置,只要点击“发起任务”并选择“探险”人数,用户就可以与小伙伴进行一次全新的都市探险啦。
对于 RTE 场景而言,产品中实现了多人语聊房场景。并且,通过云信令 SDK 的使用结合了实际的业务场景,对于当下的语聊房场景进行了拓展。 产品未来也会接入视频聊天的功能,让没有办法即时出行的小伙伴也能共同参与到城市的探险当中。



环信专项奖:忘忧馆

「忘忧馆」是一个很有温度的作品,希望可以帮助现代生活中的人们通过彼此倾诉忘掉烦恼、解除忧愁,传播正能量。


这是一款陌生人社交 App,包含信息流。结合了几种最常见的社交产品形态,包括文字聊天,通话等等。让一些不方便与亲人和朋友诉说的烦恼,可以在和陌生人交流时找到共鸣与安慰。


优秀奖:Vchat

「Vchat」利用人脸骨骼识别和云信令 SDK 实现了虚拟 3D 角色的实时通话。使用 tensorflow.js 的 WebGL 引擎作为后端,使用现有开源的人脸识别模型通过摄像头识别人脸的位置以及五官的状态。再通过 Three.js 和 Vrm.js 将人脸数据实时更新到虚拟的 3D 模型上。


在视频部分,通过实时消息 RTM SDK 将人脸骨骼数据实时传输到频道中让其他用户订阅还原人脸。而语音部分则是通过 RTC SDK 将声音进行实时传输并让用户进行订阅。可实现同步换脸、变声聊天等功能。
除了上述的「Vchat」以外,还有「灵动课堂答题组件」、「Agora X-Runtime」、「Weln」、「欢信(bla-bla.app)」、「智能AR毛笔临摹教学系统/CopyTeachWorks」作品获得了此次大赛“应用创新赛道”的优秀奖。关于这些优秀的作品可能没有办法在这里跟大家一一呈现,感兴趣的小伙伴可以前往参赛作品的 Github 仓库进行查看:https://github.com/AgoraIO-Community/RTE-2021-Innovation-Challenge/tree/master/Application-Challenge


技术创新赛道

技术创新专项奖:人脸识别

「技术创新专项奖」是为“技术创新赛道”专门设置的一个奖项。获奖作品是一个在 iOS 平台上使用使用 AgoraEngineKit2 开发接入一个基于 C++ 语言封装的「人脸识别」插件。

作品通过 TYSMExtensionManger 类与对外交互,对内则处理插件实现的相关逻辑。将 IExtensionProvider、IVideoFilter 和自己的开发的 Processer 都放在同一个地方。用 framework 方式对外公开两个文件,既方便开发者查阅,同时也可作为作为参数传递,增强代码可阅读性。 


该插件可以支持人脸检测、追踪、以及多脸的追踪识别,对脸部轮廓、眼睛、眉毛、鼻子、嘴巴等识别到的区域以 3D 点状作出反馈。



优秀奖:Water Mask

「Water Mask」项目是“技术赛道”中的参赛作品,通过在声网 SDK 的视频采集或者播放环节,在 YUV 域上或者编码后添加图片或文字类型的隐性水印。

隐性水印(盲水印)添加后,用户不能直接看到视频中的水印信息。在保护视频发布者版权的同时,也保障了用户的视频观看体验。未来,「Water Mask」还希望在音频处理上,可以扩展声纹水印,在视频版权追溯、认证防伪等场景为行业带来更多、更好的体验。
以上就是本届 「RTE 2021 编程挑战赛」的部分获奖作品及团队情况。关于本次挑战赛的更多作品情况将开源在 Github,感兴趣的小伙伴可前往进行查看:

https://github.com/AgoraIO-Community/RTE-2021-Innovation-Challenge



收起阅读 »

ios中应用Lottie解决动画问题

Lottie的简单介绍:使用Lottie开发的流程是: 设计师在AE中设计完成你的动画,通过bodymoving插件导出纪录动画信息的JSON文件,然后开发人员使用 Lottie 的Android,iOS,React Native apps开源动画库读取这份J...
继续阅读 »

Lottie的简单介绍:

使用Lottie开发的流程是: 设计师在AE中设计完成你的动画,通过bodymoving插件导出纪录动画信息的JSON文件,然后开发人员使用 Lottie 的Android,iOS,React Native apps开源动画库读取这份JSON文件, 解析动画结构和参数信息并渲染。

Lottie的优点:

1、设计即所见: 设计师用AE设计好动画后直接导出Json文件,Lottie 解析Json文件后调Core Animation的API绘制渲染。还原度更好,开发成本更低。
2、跨平台: 支持iOS、Android、React Native。
3、性能:Lottie对于从AE导出的Json文件,用Core Animation做矢量动画, 性能较佳。Lottie 对解析后的数据模型有内存缓存。但是对多图片帧动画,性能比较差。
支持动画属性多:比起脸书的Keyframes,Lottie支持了更多AE动画属性,比如Mask, Trim Paths,Stroke (shape layer)等。
4、包大小,相比动辄上百K的帧动画,Json文件包大小很小。有图片资源的情况下,同一张图片也可以被多个图层复用,而且运行时内存中只有一个UIImage对象(iOS)。

Lottie在iOS中的使用

1、pod 'lottie-ios' 使用cocoaPods来加载Lottie。
2、在使用的界面添加头文件#import <Lottie/Lottie.h>
3、简单的使用介绍(要想深入学习,还需要自己点击进入源代码中去深究每一个方法和属性,在此就不一一列举了)

LOTAnimationView * animation = [LOTAnimationView animationNamed:@"HappyBirthday"];
animation.loopAnimation = YES; //是否是循环播放
animation.frame = self.view.bounds;
[self.view addSubview:animation];
animation.backgroundColor = [UIColor whiteColor];
[animation playWithCompletion:^(BOOL animationFinished) {
//播放完成,循环播放则不进入此方法
}];
//可以以动画为北京来添加子控件
UILabel * newV = [[UILabel alloc]initWithFrame:CGRectMake(100,100,200,100)];
newV.backgroundColor = [UIColor clearColor];
newV.textColor = [UIColor blackColor];
newV.text = @"Lottie的使用教程";
[animation addSubview:newV];

另外的创建方法

/// Load animation by name from the default bundle, Images are also loaded from the bundle
+ (nonnull instancetype)animationNamed:(nonnull NSString *)animationName NS_SWIFT_NAME(init(name:));

/// Loads animation by name from specified bundle, Images are also loaded from the bundle
+ (nonnull instancetype)animationNamed:(nonnull NSString *)animationName inBundle:(nonnull NSBundle *)bundle NS_SWIFT_NAME(init(name:bundle:));

/// Creates an animation from the deserialized JSON Dictionary
+ (nonnull instancetype)animationFromJSON:(nonnull NSDictionary *)animationJSON NS_SWIFT_NAME(init(json:));

/// Loads an animation from a specific file path. WARNING Do not use a web URL for file path.
+ (nonnull instancetype)animationWithFilePath:(nonnull NSString *)filePath NS_SWIFT_NAME(init(filePath:));

/// Creates an animation from the deserialized JSON Dictionary, images are loaded from the specified bundle
+ (nonnull instancetype)animationFromJSON:(nullable NSDictionary *)animationJSON inBundle:(nullable NSBundle *)bundle NS_SWIFT_NAME(init(json:bundle:));

/// Creates an animation from the LOTComposition, images are loaded from the specified bundle
- (nonnull instancetype)initWithModel:(nullable LOTComposition *)model inBundle:(nullable NSBundle *)bundle;

/// Loads animation asynchrounously from the specified URL
- (nonnull instancetype)initWithContentsOfURL:(nonnull NSURL *)url;

LOTAnimationView的属性

/// Flag is YES when the animation is playing
@property (nonatomic, readonly) BOOL isAnimationPlaying;

/// Tells the animation to loop indefinitely.
@property (nonatomic, assign) BOOL loopAnimation;

/// The animation will play forward and then backwards if loopAnimation is also YES
@property (nonatomic, assign) BOOL autoReverseAnimation;

/// Sets a progress from 0 - 1 of the animation. If the animation is playing it will stop and the compeltion block will be called.
/// The current progress of the animation in absolute time.
/// e.g. a value of 0.75 always represents the same point in the animation, regardless of positive
/// or negative speed.
@property (nonatomic, assign) CGFloat animationProgress;

/// Sets the speed of the animation. Accepts a negative value for reversing animation.
@property (nonatomic, assign) CGFloat animationSpeed;

/// Read only of the duration in seconds of the animation at speed of 1
@property (nonatomic, readonly) CGFloat animationDuration;

/// Enables or disables caching of the backing animation model. Defaults to YES
@property (nonatomic, assign) BOOL cacheEnable;

/// Sets a completion block to call when the animation has completed
@property (nonatomic, copy, nullable) LOTAnimationCompletionBlock completionBlock;

/// Set the amimation data
@property (nonatomic, strong, nullable) LOTComposition *sceneModel;

4、简单应用的场景:(1)App的动画引导页。(2)一些特定的动画界面。(3)来作为Tabbar来使用。
5、这里来介绍下作为Tabbar的使用gitHub上原作者
6、Lottie动画资源网站
7、后续有新的学习会更新的。

链接:https://www.jianshu.com/p/7af085a6a20a

收起阅读 »

iOS - Block 准备面试必须了解的东西

一.Block的本质        block本质是一个OC对象,它里面有个isa指针,封装了函数调用环境的OC对象,封装了函数调用上下文的OC对象。查看Block源码:struct __block_impl {    void*isa;    int Fla...
继续阅读 »

一.Block的本质

        block本质是一个OC对象,它里面有个isa指针,封装了函数调用环境的OC对象,封装了函数调用上下文的OC对象。


查看Block源码:

struct __block_impl {

    void*isa;

    int Flags;

    int Reserved;

    void *FuncPtr;

};

struct __main_block_impl_0 {

  struct __block_impl impl;

  struct__main_block_desc_0* Desc;

  // 构造函数(类似于OC的init方法),返回结构体对象

  __main_block_impl_0(void*fp,struct__main_block_desc_0 *desc,intflags=0) {

    impl.isa = &_NSConcreteStackBlock;

    impl.Flags = flags;

    impl.FuncPtr = fp;

    Desc = desc;

  }

};

// 封装了block执行逻辑的函数

static void __main_block_func_0(struct __main_block_impl_0 *__cself) {

            NSLog((NSString *)&__NSConstantStringImpl__var_folders_2r__m13fp2x2n9dvlr8d68yry500000gn_T_main_c60393_mi_0);

        }

static struct __main_block_desc_0 {

  size_treserved;

  size_tBlock_size;

} __main_block_desc_0_DATA = { 0, sizeof(struct __main_block_impl_0)};

int main(intargc,constchar* argv[]) {

    /* @autoreleasepool */{__AtAutoreleasePool__autoreleasepool;

        // 定义block变量

        void(*block)(void) = &__main_block_impl_0(

                                                   __main_block_func_0,

                                                   &__main_block_desc_0_DATA

                                                   );

        // 执行block内部的代码

        block->FuncPtr(block);

    }

    return0;

}

说明:FuncPtr:指向调用函数的地址,__main_block_desc_0 :block描述信息,Block_size:block的大小

二.Block变量的捕获

2.1局部变量的捕获

        对于 block 外的变量引用,block 默认是将其复制到其数据结构中来实现访问的。也就是说block的自动变量截获只针对block内部使用的自动变量, 不使用则不截获, 因为截获的自动变量会存储于block的结构体内部, 会导致block体积变大。特别要注意的是默认情况下block只能访问不能修改局部变量的值。

int age=10;

void(^Block)(void)=^{

NSLog(@"age:%d",age);

};

age=20;

Block();

2.2__block 修饰的外部变量

        对于用 __block 修饰的外部变量引用,block 是复制其引用地址来实现访问的。block可以修改__block 修饰的外部变量的值

__block int age=10;

myBlock block=^{

NSLog(@"age = %d",age);

};

age=18;

block();

输出:18;

auto int age=10;

static int num=25;

void(^Block)(void)=^{

NSLog(@"age:%d,num:%d",age,num);

};

age=20;

num=11;

Block();

        输出结果为:age:10,num:11,auto变量block访问方式是值传递,也就是当block定义的时候,值已经传到block里面了,static变量block访问方式是指针传递,auto自动变量可能会销毁的,内存可能会消失,不采用指针访问;static变量一直保存在内存中,指针访问即可,block不需要对全局变量捕获,都是直接采用取值的,局部变量的捕获是因为考虑作用域的问题,需要跨函数访问,就需要捕获,当出了作用域,局部变量已经被销毁,这时候如果block访问,就会出问题。

2.2.block变量捕获机制




 block里访问self,self是当调用block函数的参数,参数是局部变量,self指向调用者,所以它也会捕获self,block里访问成员,成员变量的访问其实是self->xx,先捕获self,再通过self访问里面的成员变量。

3.3Block的类型

        block的类型,取决于isa指针,可以通过调用class方法或者isa指针查看具体类型,最终都是继承自NSBlock类型

__NSGlobalBlock __ ( _NSConcreteGlobalBlock )全局block即数据区

__NSStackBlock __ ( _NSConcreteStackBlock )堆区block

__NSMallocBlock __ ( _NSConcreteMallocBlock )栈区block

        说明:堆区,程序员自己控制,程序员自己管理,栈区,系统自动控制,一般我们使用最多的是堆区Block,判断类型的根据是没有访问auto变量的block是__NSGlobalBlock __ ,放在数据段访问了auto变量的block是__NSStackBlock __;[__NSStackBlock __ copy]操作就变成了__NSMallocBlock __,__NSGlobalBlock __ 调用copy操作后,什么也不做__NSStackBlock __ 调用copy操作后,复制效果是:从栈复制到堆;副本存储位置是堆__NSMallocBlock __ 调用copy操作后,复制效果是:引用计数增加;副本存储位置是堆,在ARC环境下,编译器会根据情况自动将栈上的block复制到堆上的几种情况是:

                1.block作为函数返回值时

                2.将block赋值给__strong指针时

                3.block作为Cocoa API中方法名含有usingBlock的方法参数时

                4.block作为GCD API的方法参数时

三.对象类型的auto变量

typedefvoid(^XBTBlock)(void);

XBTBlock block;

{

Person*p=[[Person alloc]init];

p.age=10;

block=^{

NSLog(@"======= %d",p.age);

};}

Person.m

-(void)dealloc{

NSLog(@"Person - dealloc");

}

        说明:block为堆block,block里面有一个Person指针,Person指针指向Person对象。只要block还在,Person就还在。block强引用了Person对象。在MRC下,就会打印,因为堆空间的block会对Person对象retain操作,拥有一次Person对象。无论MRC还是ARC,栈空间上的block,不会持有对象;堆空间的block,会持有对象。

特别说明:block内部访问了对象类型的auto变量时,是否会强引用?

栈block

a) 如果block是在栈上,将不会对auto变量产生强引用

b) 栈上的block随时会被销毁,也没必要去强引用其他对象

堆block

1.如果block被拷贝到堆上:

a) 会调用block内部的copy函数

b) copy函数内部会调用_Block_object_assign函数

c) _Block_object_assign函数会根据auto变量的修饰符(__strong、__weak、__unsafe_unretained)做出相应的操作,形成强引用(retain)或者弱引用

2.如果block从堆上移除

a) 会调用block内部的dispose函数

b) dispose函数内部会调用_Block_object_dispose函数

c) _Block_object_dispose函数会自动释放引用的auto变量(release)

正确答案:

如果block在栈空间,不管外部变量是强引用还是弱引用,block都会弱引用访问对象

如果block在堆空间,如果外部强引用,block内部也是强引用;如果外部弱引用,block内部也是弱引用

3.2gcd的block中引用 Person对象什么时候销毁?

eg:-(void)touchesBegan:(NSSet *)toucheswithEvent:(UIEvent*)event{

    Person*person = [[Personalloc]init];

    person.age=10;

    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{

        NSLog(@"age:%d",person.age);

    });

    NSLog(@"touchesBegan");

}

输出:touchesBegan

            age:10

            Person-dealloc

        说明:gcd的block默认会做copy操作,即dispatch_after的block是堆block,block会对Person强引用,block销毁时候Person才会被释放,如果上诉Person用__weak。即添加代码为__weak Person*weakPerson=person;,在Block中变成NSLog(@"age:%p",weakPerson);,它就不输出age,使用__weak修饰过后的对象,堆block会采用弱引用,无法延时Person的寿命,所以在touchesBegan函数结束后,Person就会被释放,gcd就无法捕捉到Person,gcd内部只要有强引用Person,Person就会等待执行完再销毁!如果gcd内部先强引用后弱引用,Person会等待强引用执行完毕后释放,只要强引用执行完,就不会等待后执行的弱引用,会直接释放的

eg:-(void)touchesBegan:(NSSet *)toucheswithEvent:(UIEvent*)event{

    Person*person = [[Personalloc]init];

    person.age=10;

    __weakPerson*weakPerson = person;

    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(4.0 * NSEC_PER_SEC)),

                   dispatch_get_main_queue(), ^{

        dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(3.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{

            NSLog(@"2-----age:%p",weakPerson);

        });

        NSLog(@"1-----age:%p",person);

    });

    NSLog(@"touchesBegan");

}

四.Block的修饰符

        block在修改NSMutableArray,不需要加__block,auto修饰变量,block无法修改,因为block使用的时候是内部创建了变量来保存外部的变量的值,block只有修改内部自己变量的权限,无法修改外部变量的权限。

        static修饰变量,block可以修改,因为block把外部static修饰变量的指针存入,block直接修改指针指向变量值,即可修改外部变量值。全局变量值,全局变量无论哪里都可以修改,当然block内部也可以修改。

eg:__block int age = 10,系统做了哪些---》编译器会将__block变量包装成一个对象

__block 修饰符作用:

        __block可以用于解决block内部无法修改auto变量值的问题

        __block不能修饰全局变量、静态变量(static)

        编译器会将__block变量包装成一个对象

        __block修改变量:age->__forwarding->age        

        __Block_byref_age_0结构体内部地址和外部变量age是同一地址

        __block的内存管理---->当block在栈上时,并不会对__block变量产生强引用

block的属性修饰词为什么是copy?

        block一旦没有进行copy操作,就不会在堆上

        block在堆上,程序员就可以对block做内存管理等操作,可以控制block的生命周期,会调用block内部的copy函数

        copy函数内部会调用_Block_object_assign函数

        _Block_object_assign函数会对__block变量形成强引用(retain)

        对于__block 修饰的变量 assign函数对其强引用;对于外部对象 assign函数根据外部如何引用而引用,当block从堆中移除时,会调用block内部的dispose函数dispose函数内部会调用_Block_object_dispose函数_Block_object_dispose函数会自动释放引用的__block变量(release),当block在栈上时,对它们都不会产生强引用,当block拷贝到堆上时,都会通过copy函数来处理它们,对于__block 修饰的变量 assign函数对其强引用;对于外部对象 assign函数根据外部如何引用而引用

__block的__forwarding指针说明:

        栈上__block的__forwarding指向本身

        栈上__block复制到堆上后,栈上block的__forwarding指向堆上的block,堆上block的__forwarding指向本身

五. block循环引用

        1.ARC下如何解决block循环引用的问题?

        三种方式:__weak、__unsafe_unretained、__block

        1)第一种方式:__weak

        Person*person=[[Person alloc]init];

        // __weak Person *weakPerson = person;

        __weaktypeof(person)weakPerson=person;

        person.block=^{

            NSLog(@"age is %d",weakPerson.age);

        };

        2)第二种方式:__unsafe_unretained

        __unsafe_unretained Person*person=[[Person alloc]init];

        person.block=^{

            NSLog(@"age is %d",weakPerson.age);

        };

        3)第三种方式:__block

        __block Person*person=[[Person alloc]init];

        person.block=^{

            NSLog(@"age is %d",person.age);

            person=nil;

        };

        person.block();

三种方法比较:__weak:不会产生强引用,指向的对象销毁时,会自动让指针置为nil,__unsafe_unretained:不会产生强引用,不安全,指向的对象销毁时,指针存储的地址值不变,__block:必须把引用对象置位nil,并且要调用该block









作者:枫紫
链接:https://www.jianshu.com/p/4bde3936b154






收起阅读 »

iOS - Metal的认识

一.Metal 简介        在 WWDC 2014 上,Apple为游戏开发者推出了新的平台技术 Metal,该技术能够为 3D 图像提高 10 倍的渲...
继续阅读 »

一.Metal 简介

        在 WWDC 2014 上,Apple为游戏开发者推出了新的平台技术 Metal,该技术能够为 3D 图像提高 10 倍的渲染性能,并支持大家熟悉的游戏引擎及公司。

        Metal 是一种低层次的渲染应用程序编程接口,提供了软件所需的最低层,保证软件可以运行在不同的图形芯片上。Metal 提升了 A7 与 A8 处理器效能,让其性能完全发挥。

        Metal,充分利用GPU的运算能力,在现阶段,AVFoundation ⼈脸识别/.... 等大量需要显示计算的时候,苹果采用了硬件加速器驱动GPU工作,在音视频方面,⾳频编码/解码 / 视频编码/解码 ->压缩任务 ->都与硬件加速器分不开,苹果提供的Metal,能发挥GPU/CPu的最大性能,并且管理我们的资源。

二.Metal的渲染流程

        Metal的渲染流程借鉴了OpenGLES的流程,它通过控制顶点着色器/片元着色器(Metal里面叫顶点函数/片元函数),交给帧缓冲区,最后显示到屏幕上





值得注意的是,在OpenGlES中,图元装配有9中,在Metal中,图元装配只有五种,他们分别是:

                 MTLPrimitiveTypePoint = 0, 点

                 MTLPrimitiveTypeLine = 1, 线段

                 MTLPrimitiveTypeLineStrip = 2, 线环

                 MTLPrimitiveTypeTriangle = 3,  三角形

                 MTLPrimitiveTypeTriangleStrip = 4, 三角型扇

三.Metal的初级准备工作

3.1Metal的注意事项

        在讲Metal的初级使用之前,我们先来看看苹果爸爸给我们的建议,首先,苹果建议我们Separate Your Rendering Loop,即分离我们渲染,Metal给我们提供了一个View,叫MTKView,它继承自UiView,它主要的渲染是通过MTKViewDelegate协议回调实现,两个重要的协议方法是:

        1)当MTKView视图发生大小改变时调用

        /*!

         @method mtkView:drawableSizeWillChange:

         @abstract Called whenever the drawableSize of the view will change

         @discussion Delegate can recompute view and projection matricies or regenerate any buffers to be compatible with the new view size or resolution

         @paramviewMTKView which called this method

         @paramsizeNew drawable size in pixels

         */

- (void)mtkView:(nonnull MTKView *)view drawableSizeWillChange:(CGSize)size;

        2)每当视图需要渲染时调用

        /*!

         @method drawInMTKView:

         @abstract Called on the delegate when it is asked to render into the view

         @discussion Called on the delegate when it is asked to render into the view

         */

        - (void)drawInMTKView:(nonnullMTKView*)view;

    3.2  Metal是如何驱动GPU工作的?



相关对应代码:在ViewController中,我们把当前的View变成MTKView,当然你也可以用self.view添加一个子视图View,CCRenderer是自定义的一个类,主要是分离MTview的渲染,

         _view.device = MTLCreateSystemDefaultDevice();一个MTLDevice 对象就代表这着一个GPU,通常我们可以调用方法MTLCreateSystemDefaultDevice()来获取代表默认的GPU单个对象.

        在CCRenderer中的初始化方法中- (id)initWithMetalKitView:(MTKView *)mtkView我们拿到device,创建newCommandQueue队列:

                _commandQueue = [_device newCommandQueue];

        所有应用程序需要与GPU交互的第一个对象是一个对象->MTLCommandQueue. 你使用MTLCommandQueue 去创建对象,并且加入MTLCommandBuffer 对象中.确保它们能够按照正确顺序发送到GPU.对于每一帧,一个新的MTLCommandBuffer 对象创建并且填满了由GPU执行的命令.

        在CCRenderer中,我们实现了MTKView的协议代理方法,在- (void)drawInMTKView:(nonnullMTKView*)view中,我们通过创建好的队列再创建命令缓冲区并且加入到MTCommandBuffer对象中去:

                id<MTLCommandBuffer> commandBuffer = [_commandQueue commandBuffer];

        值得注意的是,在创建好命令缓冲区后,Metal提出了一个概念叫渲染描述符:(个人理解这个渲染描述符是给每个命令打上一个标记,GPU在工作的时候通过这个渲染描述符取出相应的命令,如果说的不对,请大神指点)从视图绘制中,获得渲染描述符:

                MTLRenderPassDescriptor *renderPassDescriptor = view.currentRenderPassDescriptor;

通过渲染描述符renderPassDescriptor创建MTLRenderCommandEncoder                

                id<MTLRenderCommandEncoder> renderEncoder = [commandBuffer renderCommandEncoderWithDescriptor:renderPassDescriptor];

        最后 [renderEncoderendEncoding];

当编码器结束之后,命令缓存区就会接受到2个命令.

         1) present

         2) commit

         因为GPU是不会直接绘制到屏幕上,因此你不给出去指令.是不会有任何内容渲染到屏幕上.

        [commandBuffer presentDrawable:view.currentDrawable];

        [commandBuffercommit];

        至此,Metal的准备工作已经完成

四.用Metal渲染一个简单的三角形

在做好上面的准备的准备工作后:


//初始化MTKView

- (nonnull instancetype)initWithMetalKitView:(nonnull MTKView *)mtkView

{

    self= [superinit];

    if(self)

    {

        NSError*error =NULL;


        //1.获取GPU 设备

        _device= mtkView.device;

        //2.在项目中加载所有的(.metal)着色器文件

        // 从bundle中获取.metal文件

        id defaultLibrary = [_devicenewDefaultLibrary];

        //从库中加载顶点函数

        id vertexFunction = [defaultLibrarynewFunctionWithName:@"vertexShader"];

        //从库中加载片元函数

        id fragmentFunction = [defaultLibrarynewFunctionWithName:@"fragmentShader"];

        //3.配置用于创建管道状态的管道

        MTLRenderPipelineDescriptor *pipelineStateDescriptor = [[MTLRenderPipelineDescriptor alloc] init];

        //管道名称

        pipelineStateDescriptor.label=@"Simple Pipeline";

        //可编程函数,用于处理渲染过程中的各个顶点

        pipelineStateDescriptor.vertexFunction= vertexFunction;

        //可编程函数,用于处理渲染过程中各个片段/片元

        pipelineStateDescriptor.fragmentFunction= fragmentFunction;

        //一组存储颜色数据的组件

        pipelineStateDescriptor.colorAttachments[0].pixelFormat= mtkView.colorPixelFormat;


        //4.同步创建并返回渲染管线状态对象

        _pipelineState= [_devicenewRenderPipelineStateWithDescriptor:pipelineStateDescriptorerror:&error];

        //判断是否返回了管线状态对象

        if (!_pipelineState)

        {


            //如果我们没有正确设置管道描述符,则管道状态创建可能失败

            NSLog(@"Failed to created pipeline state, error %@", error);

            returnnil;

        }

        //5.创建命令队列

        _commandQueue = [_device newCommandQueue];

    }

    return self;

}

//每当视图需要渲染帧时调用

- (void)drawInMTKView:(nonnullMTKView*)view

{

    //1. 顶点数据/颜色数据

    staticconstCCVertextriangleVertices[] =

    {

        //顶点,    RGBA 颜色值

        { {  0.5, -0.25,0.0,1.0}, {1,0,0,1} },

        { { -0.5, -0.25,0.0,1.0}, {0,1,0,1} },

        { { -0.0f,0.25,0.0,1.0}, {0,0,1,1} },

    };

    //2.为当前渲染的每个渲染传递创建一个新的命令缓冲区

    id<MTLCommandBuffer> commandBuffer = [_commandQueue commandBuffer];

    //指定缓存区名称

    commandBuffer.label=@"MyCommand";


    //3.

    // MTLRenderPassDescriptor:一组渲染目标,用作渲染通道生成的像素的输出目标。

    MTLRenderPassDescriptor *renderPassDescriptor = view.currentRenderPassDescriptor;

    //判断渲染目标是否为空

    if(renderPassDescriptor !=nil)

    {

        //4.创建渲染命令编码器,这样我们才可以渲染到something

        id renderEncoder =[commandBufferrenderCommandEncoderWithDescriptor:renderPassDescriptor];

        //渲染器名称

        renderEncoder.label=@"MyRenderEncoder";

        //5.设置我们绘制的可绘制区域

        /*

        typedef struct {

            double originX, originY, width, height, znear, zfar;

        } MTLViewport;

         */

        //视口指定Metal渲染内容的drawable区域。 视口是具有x和y偏移,宽度和高度以及近和远平面的3D区域

        //为管道分配自定义视口需要通过调用setViewport:方法将MTLViewport结构编码为渲染命令编码器。 如果未指定视口,Metal会设置一个默认视口,其大小与用于创建渲染命令编码器的drawable相同。

        MTLViewportviewPort = {

            0.0,0.0,_viewportSize.x,_viewportSize.y,-1.0,1.0

        };

        [renderEncodersetViewport:viewPort];

        //[renderEncoder setViewport:(MTLViewport){0.0, 0.0, _viewportSize.x, _viewportSize.y, -1.0, 1.0 }];


        //6.设置当前渲染管道状态对象

        [renderEncodersetRenderPipelineState:_pipelineState];



        //7.从应用程序OC 代码 中发送数据给Metal 顶点着色器 函数

        //顶点数据+颜色数据

        //  1) 指向要传递给着色器的内存的指针

        //  2) 我们想要传递的数据的内存大小

        //  3)一个整数索引,它对应于我们的“vertexShader”函数中的缓冲区属性限定符的索引。

        [renderEncodersetVertexBytes:triangleVertices

                               length:sizeof(triangleVertices)

                              atIndex:CCVertexInputIndexVertices];

        //viewPortSize 数据

        //1) 发送到顶点着色函数中,视图大小

        //2) 视图大小内存空间大小

        //3) 对应的索引

        [renderEncodersetVertexBytes:&_viewportSize

                               length:sizeof(_viewportSize)

                              atIndex:CCVertexInputIndexViewportSize];



        //8.画出三角形的3个顶点

        // @method drawPrimitives:vertexStart:vertexCount:

        //@brief 在不使用索引列表的情况下,绘制图元

        //@param 绘制图形组装的基元类型

        //@param 从哪个位置数据开始绘制,一般为0

        //@param 每个图元的顶点个数,绘制的图型顶点数量

        /*

         MTLPrimitiveTypePoint = 0, 点

         MTLPrimitiveTypeLine = 1, 线段

         MTLPrimitiveTypeLineStrip = 2, 线环

         MTLPrimitiveTypeTriangle = 3,  三角形

         MTLPrimitiveTypeTriangleStrip = 4, 三角型扇

         */


        [renderEncoderdrawPrimitives:MTLPrimitiveTypeTriangle

                          vertexStart:0

                          vertexCount:3];

        //9.表示已该编码器生成的命令都已完成,并且从NTLCommandBuffer中分离

        [renderEncoderendEncoding];

        //10.一旦框架缓冲区完成,使用当前可绘制的进度表

        [commandBufferpresentDrawable:view.currentDrawable];

    }

    //11.最后,在这里完成渲染并将命令缓冲区推送到GPU

    [commandBuffercommit];

}



 

Metal文件:(语法下篇介绍)

#include 

//使用命名空间 Metal

using namespace metal;

// 导入Metal shader 代码和执行Metal API命令的C代码之间共享的头

#import "CCShaderTypes.h"

// 顶点着色器输出和片段着色器输入

//结构体

typedef struct

{

    //处理空间的顶点信息

    float4clipSpacePosition [[position]];

    //颜色

    float4color;

} RasterizerData;

//顶点着色函数

vertex RasterizerData

vertexShader(uintvertexID [[vertex_id]],

             constantCCVertex*vertices [[buffer(CCVertexInputIndexVertices)]],

             constantvector_uint2*viewportSizePointer [[buffer(CCVertexInputIndexViewportSize)]])

{

    /*

     处理顶点数据:

        1) 执行坐标系转换,将生成的顶点剪辑空间写入到返回值中.

        2) 将顶点颜色值传递给返回值

     */


    //定义out

    RasterizerDataout; 

//    //初始化输出剪辑空间位置

//    out.clipSpacePosition = vector_float4(0.0, 0.0, 0.0, 1.0);

//

//    // 索引到我们的数组位置以获得当前顶点

//    // 我们的位置是在像素维度中指定的.

//    float2 pixelSpacePosition = vertices[vertexID].position.xy;

//

//    //将vierportSizePointer 从verctor_uint2 转换为vector_float2 类型

//    vector_float2 viewportSize = vector_float2(*viewportSizePointer);

//

//    //每个顶点着色器的输出位置在剪辑空间中(也称为归一化设备坐标空间,NDC),剪辑空间中的(-1,-1)表示视口的左下角,而(1,1)表示视口的右上角.

//    //计算和写入 XY值到我们的剪辑空间的位置.为了从像素空间中的位置转换到剪辑空间的位置,我们将像素坐标除以视口的大小的一半.

//    out.clipSpacePosition.xy = pixelSpacePosition / (viewportSize / 2.0);

    out.clipSpacePosition= vertices[vertexID].position;

    //把我们输入的颜色直接赋值给输出颜色. 这个值将于构成三角形的顶点的其他颜色值插值,从而为我们片段着色器中的每个片段生成颜色值.

    out.color= vertices[vertexID].color;

    //完成! 将结构体传递到管道中下一个阶段:

    returnout;

}

//当顶点函数执行3次,三角形的每个顶点执行一次后,则执行管道中的下一个阶段.栅格化/光栅化.

// 片元函数

//[[stage_in]],片元着色函数使用的单个片元输入数据是由顶点着色函数输出.然后经过光栅化生成的.单个片元输入函数数据可以使用"[[stage_in]]"属性修饰符.

//一个顶点着色函数可以读取单个顶点的输入数据,这些输入数据存储于参数传递的缓存中,使用顶点和实例ID在这些缓存中寻址.读取到单个顶点的数据.另外,单个顶点输入数据也可以通过使用"[[stage_in]]"属性修饰符的产生传递给顶点着色函数.

//被stage_in 修饰的结构体的成员不能是如下这些.Packed vectors 紧密填充类型向量,matrices 矩阵,structs 结构体,references or pointers to type 某类型的引用或指针. arrays,vectors,matrices 标量,向量,矩阵数组.

fragmentfloat4fragmentShader(RasterizerDatain [[stage_in]])

{

    //返回输入的片元颜色

    returnin.color;

}

用于OC和Metal桥接的文件:

/*

 介绍:

 头文件包含了 Metal shaders 与C/OBJC 源之间共享的类型和枚举常数

*/

#ifndef CCShaderTypes_h

#define CCShaderTypes_h

// 缓存区索引值 共享与 shader 和 C 代码 为了确保Metal Shader缓存区索引能够匹配 Metal API Buffer 设置的集合调用

typedef enum CCVertexInputIndex

{

    //顶点

    CCVertexInputIndexVertices    =0,

    //视图大小

    CCVertexInputIndexViewportSize =1,

} CCVertexInputIndex;

//结构体: 顶点/颜色值

typedef struct

{

    // 像素空间的位置

    // 像素中心点(100,100)

    vector_float4 position;

    // RGBA颜色

    vector_float4 color;

} CCVertex;

#endif


作者:枫紫
链接:https://www.jianshu.com/p/a6f3c90d6ba5





收起阅读 »

iOS KVO底层原理&&KVO的isa指向

一.简单复习一下KVO的使用定义一个类,继承自NSObject,并添加一个name的属性#import <Foundation/Foundation.h> NS_ASSUME_NONNULL_BEGIN @interface TCPerson ...
继续阅读 »

一.简单复习一下KVO的使用

  • 定义一个类,继承自NSObject,并添加一个name的属性
#import <Foundation/Foundation.h>

NS_ASSUME_NONNULL_BEGIN

@interface TCPerson : NSObject

@property (nonatomic, copy) NSString *name;

@end

NS_ASSUME_NONNULL_END

  • 在ViewController我们简单的使用一下KVO
#import "ViewController.h"
#import "TCPerson.h"
@interface ViewController ()
@property (nonatomic, strong) TCPerson *person1;
@end

@implementation ViewController

- (void)viewDidLoad {
[super viewDidLoad];
self.person1 = [[TCPerson alloc]init];
self.person1.name = @"liu yi fei";
[self.person1 addObserver:self forKeyPath:@"name" options:NSKeyValueObservingOptionNew|NSKeyValueObservingOptionOld context:nil];
}

/// 点击屏幕出发改变self.person1的name
/// @param touches touches description
/// @param event event description
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
self.person1.name = @"cang lao shi";
}

/// 监听回调
/// @param keyPath 监听的属性名字
/// @param object 被监听的对象
/// @param change 改变的新/旧值
/// @param context context description
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context{
NSLog(@"监听到%@对象的%@发生了改变%@",object,keyPath,change);
}

/// 移除观察者
- (void)dealloc{
[self.person1 removeObserver:self forKeyPath:@"name"];
}
@end


当点击屏幕的时候,控制台输出:

2020-09-24 15:53:52.527734+0800 KVO_TC[9255:98204] 监听到<TCPerson: 0x600003444d10>对象的name发生了改变{
kind = 1;
new = "cang lao shi";
old = "liu yi fei";
}

二.深入剖析KVO的底层

  • 在- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
    self.person1.name = @"cang lao shi";
    }我们知道self.person1.name的本质是[self.person1 setName:@"cang lao shi"];

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
// self.person1.name = @"cang lao shi";
[self.person1 setName:@"cang lao shi"];
}
在TCPerson的.m文件,我们从写setter方法并打断点,可以看到当我们点击屏幕的时候,我们发现进入了setter方法:

- (void)setName:(NSString *)name{
_name = name;
}

  • 在ViewController我们新建一个person2,代码变成了:
#import "ViewController.h"
#import "TCPerson.h"
@interface ViewController ()
@property (nonatomic, strong) TCPerson *person1;
@property (nonatomic, strong) TCPerson *person2;
@end

@implementation ViewController

- (void)viewDidLoad {
[super viewDidLoad];
self.person1 = [[TCPerson alloc]init];
self.person1.name = @"liu yi fei";
[self.person1 addObserver:self forKeyPath:@"name" options:NSKeyValueObservingOptionNew|NSKeyValueObservingOptionOld context:nil];


self.person2 = [[TCPerson alloc] init];
self.person2.name = @"yyyyyyyy";
}

/// 点击屏幕出发改变self.person1的name
/// @param touches touches description
/// @param event event description
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
self.person1.name = @"cang lao shi";
// [self.person1 setName:@"cang lao shi"];

self.person2.name = @"ttttttttt";
}

/// 监听回调
/// @param keyPath 监听的属性名字
/// @param object 被监听的对象
/// @param change 改变的新/旧值
/// @param context context description
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context{
NSLog(@"监听到%@对象的%@发生了改变%@",object,keyPath,change);
}

/// 移除观察者
- (void)dealloc{
[self.person1 removeObserver:self forKeyPath:@"name"];
}
@end

  • 注意:当我们点击屏幕的时候输出的结果是:

2020-09-24 16:10:36.750153+0800 KVO_TC[9313:105906] 监听到<TCPerson: 0x600002ce8230>对象的name发生了改变{
kind = 1;
new = "cang lao shi";
old = "liu yi fei";
}

  • 既然我们改变name的值的时候走的都是setName:setter方法,按理说观察属性变化的时候,person2的值也应该被观察到,为什么它不会观察到person2?



三.KVO的isa指向

  • 上篇文章中我分析了实例对象,类对象,元类对象的isa,既然当我们改变属性值的时候,其本质是调用setter方法,那么在KVO中,person1和person2的setName方法应该存储在类对象中,我们先来看看这两个实例对象的isa指向:
    打开lldb

(lldb) p self.person1.isa
(Class) $0 = NSKVONotifying_TCPerson
Fix-it applied, fixed expression was:
self.person1->isa
(lldb) p self.person2.isa
(Class) $1 = TCPerson
Fix-it applied, fixed expression was:
self.person2->isa
(lldb)
  • 从上面的打印我们看到 self.person1的isa指向了NSKVONotifying_TCPerson,而没有添加观察着的self.person2的isa却指向的是TCPerson
  • NSKVONotifying_TCPerson是runtime动态创建的类,继承自TCPerson,其内部实现可以看成(模拟的NSKVONotifying_TCPerson流程,下面代码不能在xcode中运行):

  • #import "NSKVONotifying_TCPerson.h"

    @implementation NSKVONotifying_TCPerson
    //NSKVONotifying_TCPerson的set方法实现,其本质来自于foundation框架
    - (void)setName:(NSString *)name{
    _NSSetIntVaueAndNotify();
    }
    //改变过程
    void _NSSetIntVaueAndNotify(){
    [self willChangeValueForKey:@"name"];
    [super setName:name];
    [self didChangeValueForKey:@"name"];
    }
    //通知观察者
    - (void)didChangeValueForKey:(NSString *key){
    [observe observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context];
    }
    @end
    未添加观察self.person2实例对象的isa指向流程图:



    添加观察self.person1实例对象的isa指向流程图:




    所以KVO其本质是动态生成一个NSKVONotifying_TCPerson
    类,继承自TCPerson,当实例对象添加观察着之后,实例对象的isa指向了这个动态创建的类,当其属性发生改变时,调用的是该类的setter方法,而不是父类的类对象中的setter方法



    作者:枫紫_6174
    链接:https://www.jianshu.com/p/0b6083b91b04
    来源:简书
    著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。







    收起阅读 »

    View系列:事件分发(二)

    滑动冲突常见场景:内外层滑动方向不一致(如:ViewPager中嵌套竖向滑动的RecyclerView)内外层滑动方向一致(如:RecyclerView嵌套)一般从2个角度出发:父View自己主动拦截,或子View申请父View进行拦截父View事件发送方,父...
    继续阅读 »

    滑动冲突

    常见场景:

    1. 内外层滑动方向不一致(如:ViewPager中嵌套竖向滑动的RecyclerView)
    2. 内外层滑动方向一致(如:RecyclerView嵌套)

    image-20210602150942026

    一般从2个角度出发:父View自己主动拦截,或子View申请父View进行拦截

    父View

    事件发送方,父View拦截。

    父View根据自己的需求,选择在何时给onInterceptTouchEvent返回true,使事件直接分发给自己处理(前提:子View未设置requestDisallowInteceptTouchEvent(true),否则根本就不会经过onInterceptTouchEvent方法)。

    • DOWN不要拦截,否则根据事件分发逻辑,事件直接给父View自己处理了
    • UP不要拦截,否则子View无法出发click事件,无法移除longClick消息
    • 在MOVE中根据逻辑需求判断是否拦截
        public boolean onInterceptTouchEvent(MotionEvent event) {
    boolean intercepted = false;
    switch (event.getAction()) {
    case MotionEvent.ACTION_DOWN: {
    intercepted = false;
    break;
    }
    case MotionEvent.ACTION_UP: {
    intercepted = false;
    break;
    }
    case MotionEvent.ACTION_MOVE: {
    if (满足父容器的拦截要求) {
    intercepted = true;
    } else {
    intercepted = false;
    }
    break;
    }
    }
    return intercepted;
    }

    子View

    事件接收方,内部拦截

    事件已经传递到子View,子View只有选择是否消费该事件,或者向父View申请拦截事件。

    注意:申请拦截事件,不代表就以后就收不到事件了。request只是会清除FLAG_DISALLOW_INTERCEPT标记,导致父View检查onInterceptTouchEvent方法,仅此而已(恢复到默认状态)。主要看父View.onInterceptTouchEvent中的返回值。

        public boolean dispatchTouchEvent(MotionEvent event) {//或 onTouchEvent
    int x = (int) event.getX();
    int y = (int) event.getY();

    switch (event.getAction()) {
    case MotionEvent.ACTION_DOWN: {
    parent.requestDisallowInterceptTouchEvent(true);//不许拦截
    break;
    }
    case MotionEvent.ACTION_MOVE: {
    int deltaX = x - mLastX;
    int deltaY = y - mLastY;
    if (父容器需要此类点击事件) {
    parent.requestDisallowInterceptTouchEvent(false);//申请拦截
    }
    break;
    }
    case MotionEvent.ACTION_UP: {
    break;
    }
    }
    return super.dispatchTouchEvent(event);
    }

    :cry:多点触控

    安卓自定义View进阶-多点触控详解

    自由地对图片进行缩放和移动

    多点触控相关的事件:

    事件简介
    ACTION_DOWN第一个 手指 初次接触到屏幕 时触发。
    ACTION_MOVE手指 在屏幕上滑动 时触发,会多次触发(单个或多个手指)。
    ACTION_UP最后一个 手指 离开屏幕时触发。
    ACTION_POINTER_DOWN有非主要的手指按下(即按下之前已经有手指在屏幕上)。
    ACTION_POINTER_UP有非主要的手指抬起(即抬起之后仍然有手指在屏幕上)。
    以下事件类型不推荐使用---以下事件在2.0开始,在 2.2 版本以上被废弃---
    ACTION_POINTER_1_DOWN第 2 个手指按下,已废弃,不推荐使用。
    ACTION_POINTER_2_DOWN第 3 个手指按下,已废弃,不推荐使用。
    ACTION_POINTER_3_DOWN第 4 个手指按下,已废弃,不推荐使用。
    ACTION_POINTER_1_UP第 2 个手指抬起,已废弃,不推荐使用。
    ACTION_POINTER_2_UP第 3 个手指抬起,已废弃,不推荐使用。
    ACTION_POINTER_3_UP第 4 个手指抬起,已废弃,不推荐使用。

    多点触控相关的方法:

    方法简介
    getActionMasked()与 getAction() 类似,多点触控需要使用这个方法获取事件类型
    getActionIndex()获取该事件是哪个指针(手指)产生的。
    getPointerCount()获取在屏幕上手指的个数。
    getPointerId(int pointerIndex)获取一个指针(手指)的唯一标识符ID,在手指按下和抬起之间ID始终不变。
    findPointerIndex(int pointerId)通过PointerId获取到当前状态下PointIndex,之后通过PointIndex获取其他内容。
    getX(int pointerIndex)获取某一个指针(手指)的X坐标
    getY(int pointerIndex)获取某一个指针(手指)的Y坐标

    index和pointId

    在 2.2 版本以上,我们可以通过getActionIndex() 轻松获取到事件的索引(Index),Index 变化有以下几个特点:

    1、从 0 开始,自动增长。 2、之前落下的手指抬起,后面手指的 Index 会随之减小。 (0、1、2 --> 第2个手指抬起 --> 第三个手指变为1 --> 0、1) 3、Index 变化趋向于第一次落下的数值(落下手指时,前面有空缺会优先填补空缺)。 4、对 move 事件无效。 **getActionIndex()**获取到的始终是数值 0

    相同点不同点
    1. 从 0 开始,自动增长。
    2. 落下手指时优先填补空缺(填补之前抬起手指的编号)。
    Index 会变化,pointId 始终不变。

    pointerIndex 与 pointerId

    pointerIndex 和 actionIndex 区别并不大,两者的数值是相同的,可以认为 pointerIndex 是特地为 move 事件准备的 actionIndex。

    类型简介
    pointerIndex用于获取具体事件,可能会随着其他手指的抬起和落下而变化
    pointerId用于识别手指,手指按下时产生,手指抬起时回收,期间始终不变

    这两个数值使用以下两个方法相互转换:

    方法简介
    getPointerId(int pointerIndex)获取一个指针(手指)的唯一标识符ID,在手指按下和抬起之间ID始终不变。
    findPointerIndex(int pointerId)通过 pointerId 获取到当前状态下 pointIndex,之后通过 pointIndex 获取其他内容。

    自定义View示例

    img
    /**
    * Created by Varmin
    * on 2017/7/5 16:16.
    * 文件描述:left,content,right三个tag,在布局中给每个部分设置该tag。用于该ViewGroup内部给子View排序。
    * 功能:默认全部关闭左右滑动。分别设置打开
    */
    public class SlideView extends ViewGroup implements View.OnClickListener, View.OnLongClickListener {
    private static final String TAG = "SlideView";
    public final String LEFT = "left";
    public final String CONTENT = "content";
    public final String RIGHT = "right";
    private Scroller mScroller;
    /**
    * scroller滑动时间。默认250ms
    */
    public static final int DEFAULT_TIMEOUT = 250;
    public static final int SLOW_TIMEOUT = 500;
    /**
    * 左右View的宽度
    */
    private int leftWidth;
    private int rightWidth;
    private GestureDetector mGesture;
    private ViewConfiguration mViewConfig;

    public SlideView(Context context) {
    super(context);
    init(context);
    }

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

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

    private void init(Context context) {
    mScroller = new Scroller(context);
    //都是自己处理的,这里没有用到该手势方法
    //缺点:误差有些大。这种精确滑动的,最好自己判断
    mGesture = new GestureDetector(context, new SlideGestureDetector());
    mViewConfig = ViewConfiguration.get(context);
    //默认false
    setClickable(true);
    }

    /**
    * 所有的子View都映射完xml,该方法最早能获取到childCount
    * 在onMeasuer/onLayout中获取,注册监听的话,会多次被调用
    * 在构造方法中,不能获取到childCount。
    */
    @Override
    protected void onFinishInflate() {
    super.onFinishInflate();
    initListener();
    }

    private void initListener() {
    for (int i = 0; i < getChildCount(); i++) {
    View childView = getChildAt(i);
    childView.setClickable(true);
    childView.setOnClickListener(this);
    if (CONTENT.equals(childView.getTag())) {
    childView.setOnLongClickListener(this);
    }
    }

    }
    @Override
    public void onClick(View v) {
    String tag = (String) v.getTag();
    switch (tag) {
    case LEFT:
    Toast.makeText(getContext(), "Left", Toast.LENGTH_SHORT).show();
    break;
    case CONTENT:
    Toast.makeText(getContext(), "Content", Toast.LENGTH_SHORT).show();
    closeAll(SLOW_TIMEOUT);
    break;
    case RIGHT:
    Toast.makeText(getContext(), "Right", Toast.LENGTH_SHORT).show();
    break;
    }
    }

    @Override
    public boolean onLongClick(View v) {
    Toast.makeText(getContext(), "Content_LongClick", Toast.LENGTH_SHORT).show();
    return true;
    }

    /**
    * 每个View的大小都是由父容器给自己传递mode来确定。
    * 每个View的位置都是由父容器给自己设定好自己在容器中的左上右下来确定位置。
    * 所以,继承至ViewGroup的容器,要在自己内部实现对子View大小和位置的确定。
    */

    /**
    * 子View不会自己测量自己的,所以在这里测量各个子View大小
    * 另外,处理自己是wrap的情况,给自己一个确定的值。
    */
    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    int widthSize = MeasureSpec.getSize(widthMeasureSpec);
    int heightSize = MeasureSpec.getSize(heightMeasureSpec);
    //测量子View
    measureChildren(widthMeasureSpec, heightMeasureSpec);
    //测量自己
    //默认是给该ViewGroup设置固定宽高,假设不纯在wrap情况,onlayout中也不考虑此情况
    super.onMeasure(widthMeasureSpec, heightMeasureSpec);
    }

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
    int childCount = getChildCount();
    for (int i = 0; i < childCount; i++) {
    View childView = getChildAt(i);
    int childWidth = childView.getMeasuredWidth();
    int childHeight = childView.getMeasuredHeight();
    String tag = (String) childView.getTag();
    switch (tag) {
    case LEFT:
    leftWidth = childWidth;
    childView.layout(-childWidth, 0, 0, childHeight);
    break;
    case CONTENT:
    childView.layout(0, 0, childWidth, childHeight);
    break;
    case RIGHT:
    rightWidth = childWidth;
    childView.layout(getMeasuredWidth(), 0,
    getMeasuredWidth() + childWidth, childHeight);
    break;
    }
    }

    }


    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
    boolean handled = super.onInterceptTouchEvent(ev);
    if (handled) {
    return true;
    }
    switch (ev.getActionMasked()) {
    case MotionEvent.ACTION_DOWN:
    mInitX = (int) ev.getX();
    mInitY = (int) ev.getY();
    break;
    case MotionEvent.ACTION_MOVE:
    int offsetX = (int) (ev.getX() - mInitX);
    int offsetY = (int) (ev.getY() - mInitY);
    /**
    * 判断可以横向滑动了
    * 1,拦截自己的子View接收事件
    * 2,申请父ViewGroup不要看拦截事件。
    */
    if ((Math.abs(offsetX) - Math.abs(offsetY)) > mViewConfig.getScaledTouchSlop()) {
    requestDisallowInterceptTouchEvent(true);
    return true;
    }
    break;
    case MotionEvent.ACTION_UP:
    //重置回ViewGroup默认的拦截状态
    requestDisallowInterceptTouchEvent(false);
    break;
    }
    return handled;
    }

    private int mInitX;
    private int mOffsetX;
    private int mInitY;
    private int mOffsetY;
    @Override
    public boolean onTouchEvent(MotionEvent event) {
    boolean handled = false;
    switch (event.getActionMasked()) {
    case MotionEvent.ACTION_DOWN:
    break;
    case MotionEvent.ACTION_MOVE:
    mOffsetX = (int) (event.getX() - mInitX);
    mOffsetY = (int) (event.getY() - mInitY);
    if (Math.abs(mOffsetX) - Math.abs(mOffsetY) > 0) {//横向触发条件
    //预估,偏移offsetX后的大小
    int mScrollX = getScrollX() + (-mOffsetX);
    if (mScrollX <= 0) {//向右滑动,显示leftView:110
    //上面的是预估,如果预估大于目标:你不能return放弃了,要调整mOffsetX的值使其刚好等于目标
    if (Math.abs(mScrollX) > leftWidth) {
    mOffsetX = leftWidth - Math.abs(getScrollX());
    //return true;
    }
    }else {//向左滑动,显示rightView:135
    if (mScrollX > rightWidth) {
    mOffsetX = getScrollX() - rightWidth;
    //return true;
    }
    }
    this.scrollBy(-mOffsetX,0);
    mInitX = (int) event.getX();
    mInitY = (int) event.getY();
    return true;
    }

    break;
    case MotionEvent.ACTION_UP:
    int upScrollX = getScrollX();
    if (upScrollX > 0) {//向左滑动,显示rightView
    if (upScrollX >= (rightWidth/2)) {
    mOffsetX = upScrollX - rightWidth;
    }else {
    mOffsetX = upScrollX;
    }
    }else {//向右,显示leftView
    if (Math.abs(upScrollX) >= (leftWidth/2)) {
    mOffsetX = leftWidth - Math.abs(upScrollX);
    }else {
    mOffsetX = upScrollX;
    }
    }
    // this.scrollBy(-mOffsetX,0);//太快
    // startScroll(-mOffsetX, 0, 1000);//直接放进去,不行?
    /**
    * 注意startX。dx表示的是距离,不是目标位置
    */
    mScroller.startScroll(getScrollX(), getScrollY(), -mOffsetX, 0,SLOW_TIMEOUT);
    invalidate();

    break;
    }

    if (!handled) {
    handled = super.onTouchEvent(event);
    }
    return handled;
    }


    @Override
    public void computeScroll() {
    if (mScroller.computeScrollOffset()) {
    scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
    invalidate();
    }
    }


    /**
    * 虽然传入的dx、dy并不是scrollTo实际要到的点,dx,dy只是一小段距离。
    * 但是computeScroll()我们scrollTo的是:现在位置+dx的距离 = 目标位置
    *
    * @param dx //TODO *距离!距离!并不是说要到达的目标。*
    * @param dy
    * @param duration 默认的滑动时间是250,复位的时候如果感觉太快可以自己设置事件.
    *
    */
    private void startScroll(int dx, int dy, int duration) {
    mScroller.startScroll(mScroller.getFinalX(), mScroller.getFinalY(), dx, dy, duration);
    //mScroller.extendDuration(duration); 在250ms基础上增加。构造函数传入的话,就是duration的时间。
    invalidate();
    }


    /**
    * 是否打开,ListView中复用关闭
    * @return
    */
    public boolean isOpened(){
    return getScrollX() != 0;
    }
    public void closeAll(int duration){
    mScroller.startScroll(getScrollX(), getScrollY(), (-getScrollX()), 0, duration);
    invalidate();
    }
    }

    Tips

    scrollTo/By

    通过三种方式可以实现View的滑动:

    1. 通过View本身提供的scrollTo/scrollBy方法;

    2. 通过动画使Veiw平移。

    3. 通过改变View的LayoutParams属性值。

    **setScrollX/Y、scrollTo: **移动到x,y的位置

    **scrollBy: **移动x,y像素的距离

        public void setScrollX(int value) {
    scrollTo(value, mScrollY);
    }

    public void scrollBy(int x, int y) {
    scrollTo(mScrollX + x, mScrollY + y);
    }

    public void scrollTo(int x, int y) {
    if (mScrollX != x || mScrollY != y) {
    int oldX = mScrollX;
    int oldY = mScrollY;
    mScrollX = x;
    mScrollY = y;
    invalidateParentCaches();
    onScrollChanged(mScrollX, mScrollY, oldX, oldY);
    }
    }

    **注意:**假如scrollTo(30,10),按照View右下正,左上负的概念,因该是向右滑动30,向下滑动10。


    作者:Varmin_
    链接:https://juejin.cn/post/6972431645429202980
    来源:掘金
    著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

    收起阅读 »

    View系列:事件分发(一)

    基础相关View坐标系MotionEvent当用户触摸屏幕的时候,产生Touch事件,事件的相关细节(发生触摸的位置、时间等)被封装成MotionEvent对象事件类型具体动作MotionEvent.ACTION_DOWN按下View(所有事件的开始)Moti...
    继续阅读 »

    基础相关

    View坐标系

    View坐标系

    MotionEvent

    当用户触摸屏幕的时候,产生Touch事件,事件的相关细节(发生触摸的位置、时间等)被封装成MotionEvent对象

    image-20210531100221285

    image-20210531100250617

    事件类型具体动作
    MotionEvent.ACTION_DOWN按下View(所有事件的开始)
    MotionEvent.ACTION_MOVE滑动View
    MotionEvent.ACTION_UP抬起View(与DOWN对应)
    MotionEvent.ACTION_CANCEL结束事件
    MotionEvent.ACTION_OUTSIDE事件发生在视图范围外

    辅助类

    辅助类-dev

    View触摸相关工具类全解

    ViewConfiguration

    获取 Android 系统常用的距离、速度、时间等常量

    VelocityTracker

    跟踪触摸事件的速度。此设置对于手势标准中包含速度的手势(例如滑动)非常有用。

    GestureDetector

    手势检测,该类支持的一些手势包括 onDown()、onLongPress()、onFling() 等。可以将 GestureDetector 与onTouchEvent() 方法结合使用。

    OverScroller

    回弹工具类,不同的回弹效果可以自定义不同的动画插值器

    TouchDelegate

    扩展子视图的可轻触区域

    img

    view1.post(new Runnable() {
    @Override
    public void run() {
    Rect bounds = new Rect();
    // 获取View2占据的矩形区域在其父View(也就是View1)中的相对坐标
    view2.getHitRect(bounds);
    // 计算扩展后的矩形区域Bounds相对于View1的坐标
    bounds.left -= 100;
    bounds.top -= 50;
    bounds.right += 100;
    bounds.bottom += 50;
    TouchDelegate touchDelegate = new TouchDelegate(bounds, view2);
    // 为View1设置TouchDelegate
    view1.setTouchDelegate(touchDelegate);
    }
    });

    事件处理

    image-20210531100411928

    • 每一个DOWN / MOVE / UP / CANCLE都是一个事件,并不是连起来才是一个事件
    • 事件的消费,是看返回true/false,而不是看有没有处理操作
    • Activity、ViewGroup、View
      • 都有分发、消费事件的能力
      • 只有ViewGroup有拦截事件的能力

    事件分发

    window中的View是树形结构,可能会重叠在一起,当我们点击的区域有多个View都可以响应的时候,事件分发机制决定了这个点击事件应该给谁处理。

    分发机制类似洋葱模型、责任链模式、冒泡...

    分发:Activity -> PhoneWindow -> DecorView -> ViewGroup ->  @1 -> ... -> View
    消费:Activity <- PhoneWindow <- DecorView <- ViewGroup <- @1 <- ... <- View
    • 如果事件被消费,就意味着事件信息传递终止 如果在@1处消费事件,就不在往下传递了,直接返回
    • 如果事件一直没有被消费,最后会传给Activity,如果Activity也不需要就被抛弃事

    image

    View

    优先级:

    1. OnTouchListener.onTouch
    2. onTouchEven

    注意:OnTouchListener.onTouch返回false,并不代表该View不消费事件了,得看dispatchTouchEvent返回的结果

    public boolean dispatchTouchEvent(MotionEvent event) {
    ...
    // 被遮盖,不响应事件
    if (onFilterTouchEventForSecurity(event)) {
    ...
    //setOnTouchListener设置的监听,优先级高
    ListenerInfo li = mListenerInfo;
    if (li != null && li.mOnTouchListener != null
    && (mViewFlags & ENABLED_MASK) == ENABLED
    && li.mOnTouchListener.onTouch(this, event)) {
    result = true;
    }

    // 系统已实现好的,优先级低。
    if (!result && onTouchEvent(event)) {
    result = true;
    }
    }
    ...
    return result;
    }

    onTouchEvent:

    • View即使设置了setEnable(false),只要是可点击状态就会消费事件,只是不做出回应
    • 只要进入CLICKABLE判断,就返回true消费时间
    事件处理
    DOWN发送LongClick延迟消息,过期触发
    MOVE移除LongClick消息
    CANCLE移除LongClick消息
    UP移除LongClick消息
    触发Click事件
    <!--只关注事件的分发,不关注其它状态的变化-->
    public boolean onTouchEvent(MotionEvent event) {
    final float x = event.getX();
    final float y = event.getY();
    final int action = event.getAction();

    //View被禁用的话,如果是可以点击的,一样返回true,表示消费了事件。只是不作出回应。
    if ((viewFlags & ENABLED_MASK) == DISABLED) {
    return (((viewFlags & CLICKABLE) == CLICKABLE
    || (viewFlags & LONG_CLICKABLE) == LONG_CLICKABLE)
    || (viewFlags & CONTEXT_CLICKABLE) == CONTEXT_CLICKABLE);
    }

    // 委托:扩大点击事件、委托其它处理
    if (mTouchDelegate != null) {
    if (mTouchDelegate.onTouchEvent(event)) {
    return true;
    }
    }

    /**
    * 只要进入该if,就返回true,消费事件
    */

    if (((viewFlags & CLICKABLE) == CLICKABLE ||
    (viewFlags & LONG_CLICKABLE) == LONG_CLICKABLE) ||
    (viewFlags & CONTEXT_CLICKABLE) == CONTEXT_CLICKABLE) {
    switch (action) {
    case MotionEvent.ACTION_DOWN:
    if (isInScrollingContainer) {
    } else {
    //长按事件,发送延时消息到队列
    checkForLongClick(0, x, y);
    }
    break;
    case MotionEvent.ACTION_MOVE:
    if (!pointInView(x, y, mTouchSlop)) {
    if ((mPrivateFlags & PFLAG_PRESSED) != 0) {
    //移除长按事件的消息。
    removeLongPressCallback();
    setPressed(false);
    }
    }
    break;
    case MotionEvent.ACTION_UP:
    if ((mPrivateFlags & PFLAG_PRESSED) != 0 || prepressed) {
    if (!mHasPerformedLongPress && !mIgnoreNextUpEvent) {
    // 移除长按事件的消息
    removeLongPressCallback();

    //点击事件: 可知onclick事件是在UP的时候触发
    if (!focusTaken) {
    if (!post(mPerformClick)) {
    performClick();
    }
    }
    }
    }
    break;
    case MotionEvent.ACTION_CANCEL:
    //移除长按事件
    removeLongPressCallback();
    mHasPerformedLongPress = false;
    break;
    }
    return true;
    }

    return false;
    }

    ViewGroup

    1. DOWN事件:
      • 清除之前状态,mFirstTouchTarget = null
      • 进入逻辑1、2寻找接收事件的子View
        • mFirstTouchTarget = null,进入逻辑3
        • mFirstTouchTarget != null, 进入逻辑4
    2. MOVE/UP事件:
      • mFirstTouchTarget = null,注释1处不满足逻辑1判断条件,进入逻辑3
      • mFirstTouchTarget != null,不满足逻辑2判断条件,进入逻辑4
    3. CANCLE事件:
      • mFirstTouchTarget = null,注释2处不满足逻辑1判断条件,进入逻辑3
      • mFirstTouchTarget != null,注释2处不满足逻辑1判断条件,进入逻辑4

    总结,

    • DOWN事件就是用来清理状态、寻找新接收事件子View的

    • DOWN事件的后续事件:

      • 未找到子View接收情况下,直接自己处理
      • 找到子View接收的情况下,直接给子View
        @Override
    public boolean dispatchTouchEvent(MotionEvent ev) {
    ....
    // 如果该View被遮蔽,并且在被遮蔽时不响应点击事件,则不分发该触摸事件,即返回false。
    if (onFilterTouchEventForSecurity(ev)) {
    final int action = ev.getAction();
    final int actionMasked = action & MotionEvent.ACTION_MASK;

    /**
    * step1:DOWN事件的时候,表示最初开始事件,清除之前的状态。
    */

    if (actionMasked == MotionEvent.ACTION_DOWN) {
    // 关键:每次DOWN的时候,清除前一个手势的mFirstTouchTarget = null
    cancelAndClearTouchTargets(ev);
    // 清除状态
    resetTouchState();
    }


    /**
    * step2:拦截判断
    */

    final boolean intercepted;
    // ACTION_DOWN(初始状态)或 有子View处理事件:判断是否拦截
    if (actionMasked == MotionEvent.ACTION_DOWN || mFirstTouchTarget != null) {
    //默认没有该标记位,返回false
    final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;
    if (!disallowIntercept) {//requestDisallowInterceptTouchEvent(false)
    //默认返回false,并不是每次都会调用
    intercepted = onInterceptTouchEvent(ev);
    ev.setAction(action);
    } else {//requestDisallowInterceptTouchEvent(true)
    intercepted = false;
    }
    } else {
    //[注释1],没有子View接收事件,拦截
    intercepted = true;
    }


    /**
    * step3:找能接收事件的子View,并赋值给mFirstTouchTarget
    */

    final boolean canceled = resetCancelNextUpFlag(this)
    || actionMasked == MotionEvent.ACTION_CANCEL; //[注释2]
    // *****每次都会初始化这两个变量****
    TouchTarget newTouchTarget = null;
    boolean alreadyDispatchedToNewTouchTarget = false;
    //如果在这一层不满足判断条件,直接就到[逻辑3,4]了。
    //[逻辑1]


    /*
    step4:到这,已经跳出了上面的大嵌套判断!--上面的大嵌套就是用来找接收事件的子View的。
    一旦确定找到了或者没有接收者,后面的事件:
    1. 检查intercepte状态。
    2. 进入下面的逻辑,后面的事件直接确定分发给谁
    */

    // 没有找到接收事件的View,以后的move/up也通过这一步给ViewGroup
    [逻辑3] if (mFirstTouchTarget == null) {
    //没有接收事件的子View,调用自己的dispatchTouchEvent
    handled = dispatchTransformedTouchEvent(ev, canceled, null,
    TouchTarget.ALL_POINTER_IDS);
    [逻辑4] } else {//找到了接收事件的View
    TouchTarget predecessor = null;
    TouchTarget target = mFirstTouchTarget;
    while (target != null) {
    final TouchTarget next = target.next;
    // 在DOWN找到接受事件的子View时,赋值alreadyDispatchedToNewTouchTarget = true
    // 此时已经消费了事件,所以直接返回true
    // 后面的其它事件中,alreadyDispatchedToNewTouchTarget被重置,不在满足该条件
    if (alreadyDispatchedToNewTouchTarget && target == newTouchTarget) {
    handled = true;
    } else {
    // 判断是否 收到CANCEL事件 或 需要拦截事件
    final boolean cancelChild = resetCancelNextUpFlag(target.child)
    || intercepted;
    // 子View消费事件
    //如果cancelChild为true,给子View发送cancle事件
    [逻辑5] if (dispatchTransformedTouchEvent(ev, cancelChild,
    get.child, target.pointerIdBits)) {
    handled = true;
    }
    // 修改mFirstTouchTarget,使原来的子View不再接收事件
    if (cancelChild) {
    if (predecessor == null) {
    mFirstTouchTarget = next;
    } else {
    predecessor.next = next;
    }
    target.recycle();
    target = next;
    continue;
    }
    }
    }
    }
    }
    return handled;
    }

    Activity

    Touch事件先是传递到Activity,接着由Activity传递到最外层布局,然后一层层遍历循环到View

        public boolean dispatchTouchEvent(MotionEvent ev) {
    if (ev.getAction() == MotionEvent.ACTION_DOWN) {
    // 交互 空实现
    onUserInteraction();
    }
    // DecorView实际是ViewGroup的dispatchTouchEvent方法
    if (getWindow().superDispatchTouchEvent(ev)) {
    return true;
    }
    // down点击到外部区域,消费事件,finish
    return onTouchEvent(ev);
    }

    onUserInteraction()

    这是一个空实现,用的也比较少,不深究: 此方法是activity的方法,当此activity在栈顶时,触屏点击按home,back,menu键等都会触发此方法。下拉statubar、旋转屏幕、锁屏不会触发此方法。所以它会用在屏保应用上,因为当你触屏机器 就会立马触发一个事件,而这个事件又不太明确是什么,正好屏保满足此需求;或者对于一个Activity,控制多长时间没有用户点响应的时候,自己消失等。

    onTouchEvent(event)

        public boolean onTouchEvent(MotionEvent event) {
    if (mWindow.shouldCloseOnTouch(this, event)) {
    finish();
    return true;
    }
    return false;
    }

    mWindow即使PhoneWindow,该方法是@hide,并且在Window类中定义。

        /** @hide */
    public boolean shouldCloseOnTouch(Context context, MotionEvent event) {
    if (mCloseOnTouchOutside && event.getAction() == MotionEvent.ACTION_DOWN
    && isOutOfBounds(context, event) && peekDecorView() != null) {
    return true;
    }
    return false;
    }
    • mCloseOnTouchOutside是一个boolean变量,它是由Window的android:windowCloseOnTouchOutside属性值决定。
    • isOutOfBounds(context, event)是判断该event的坐标是否在context(对于本文来说就是当前的Activity)之外。是的话,返回true;否则,返回false。
    • peekDecorView()则是返回PhoneWindow的mDecor。

    总的来说:如果设置了android:windowCloseOnTouchOutside为true,并且是DOWN事件点击了Activity外部区域(比如Activity是一个Dialog),返回true,消费事件,并且finish。

    ACTION_CANCEL

    子View在接收事件过程中,被中断,父View会传给子View一个CANCEL事件

     [逻辑4]      } else {//找到了接收事件的View
    TouchTarget predecessor = null;
    TouchTarget target = mFirstTouchTarget;
    while (target != null) {
    if (alreadyDispatchedToNewTouchTarget && target == newTouchTarget) {
    } else {
    // 判断是否 收到CANCEL事件 或 需要拦截事件
    final boolean cancelChild = resetCancelNextUpFlag(target.child)
    || intercepted; //注释1

    //如果cancelChild为true,给子View发送cancle事件
    [逻辑5] if (dispatchTransformedTouchEvent(ev, cancelChild,
    get.child, target.pointerIdBits)) {
    handled = true;
    }
    // 修改mFirstTouchTarget,使原来的子View不再接收事件
    if (cancelChild) {
    if (predecessor == null) {
    mFirstTouchTarget = next;
    } else {
    predecessor.next = next;
    }
    target.recycle();
    target = next;
    continue;
    }
    //...
    }
    }
    }


    private boolean dispatchTransformedTouchEvent(MotionEvent event, boolean cancel,
    View child, int desiredPointerIdBits) {
    final boolean handled;
    final int oldAction = event.getAction();
    if (cancel || oldAction == MotionEvent.ACTION_CANCEL) {
    event.setAction(MotionEvent.ACTION_CANCEL);
    if (child == null) {
    handled = super.dispatchTouchEvent(event);
    } else {
    //发送CANCEL事件给子View
    handled = child.dispatchTouchEvent(event);
    }
    event.setAction(oldAction);
    return handled;
    }
    //...
    }

    ACTION_OUTSIDE

    设置了FLAG_WATCH_OUTSIDE_TOUCH,事件发生在当前视图的范围之外

    例如,点击音量键之外的区域取消音量键显示:

    //frameworks/base/packages/SystemUI/src/com/android/systemui/volume/VolumeDialogImpl.java        
    // 给音量键Window设置FLAG_WATCH_OUTSIDE_TOUCH
    mDialog = new CustomDialog(mContext);
    mWindow = mDialog.getWindow();
    mWindow.addFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
    | WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN
    | WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL
    | WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED
    | WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH //设置Window Flag
    | WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED);
    ......

    // 重写onTouchEvent并处理ACTION_OUTSIDE事件
    @Override
    public boolean onTouchEvent(MotionEvent event) {
    if (mShowing) {
    if (event.getAction() == MotionEvent.ACTION_OUTSIDE) {
    dismissH(Events.DISMISS_REASON_TOUCH_OUTSIDE);
    return true;
    }
    }
    return false;
    }

    事件拦截

    一文解决Android View滑动冲突

    只有ViewGroup有事件拦截的能力,View可根据情况申请父View进行拦截

    image-20210531100411928

    View

    View没有拦截事件的能力,只能根据不同需求调用mParent.requestDisallInterceptTouchEvent(true/false) 申请父View是否进行拦截。

    注意:如果在子View接收事件的过程中被父View拦截,父View会给子View一个CANCEL事件,注意处理相关逻辑。

    ViewGroup

    onInterceptTouchEvent
    • 设置了FLAG_DISALLOW_INTERCEPT标记时,不会调用
    • 其它时候都会调用
        /**
    * ViewGroup事件分发时的拦截检查机制
    */

    //默认没有该标记位,返回false
    final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;//注释1
    if (!disallowIntercept) {//requestDisallowInterceptTouchEvent(false)
    intercepted = onInterceptTouchEvent(ev);//默认返回false
    } else {
    intercepted = false;//requestDisallowInterceptTouchEvent(true)
    }


    /**
    * 默认返回false
    */

    public boolean onInterceptTouchEvent(MotionEvent ev) {
    if (ev.isFromSource(InputDevice.SOURCE_MOUSE)
    && ev.getAction() == MotionEvent.ACTION_DOWN
    && ev.isButtonPressed(MotionEvent.BUTTON_PRIMARY)
    && isOnScrollbarThumb(ev.getX(), ev.getY())) {
    return true;
    }
    return false;
    }

    /*
    * disallowIntercept = true时,不允许拦截,注释1为true
    * disallowIntercept = false时,允许拦截,注释1为false
    */

    @Override
    public void requestDisallowInterceptTouchEvent(boolean disallowIntercept) {
    // We're already in this state, assume our ancestors are too
    if (disallowIntercept == ((mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0)) {
    return;
    }

    if (disallowIntercept) {
    mGroupFlags |= FLAG_DISALLOW_INTERCEPT;// 添加标记,使得注释1为true
    } else {
    mGroupFlags &= ~FLAG_DISALLOW_INTERCEPT;// 清除标记,使得注释1为false
    }

    if (mParent != null) {
    mParent.requestDisallowInterceptTouchEvent(disallowIntercept);
    }
    }
    requestDisallowInterceptTouchEvent
    • true,不允许拦截,注释1为true,不会调用onInterceptTouchEvent
    • false,允许拦截,注释1为false(默认),调用onInterceptTouchEvent

    注意:调用requestDisallowInterceptTouchEvent(false)申请拦截,并不会真的就被父View拦截了。它只是一个标记,使得父View会检查onInterceptTouchEvent这个方法(默认也会调用)。 它只会影响 mGroupFlags & FLAG_DISALLOW_INTERCEPT值,真正决定要不要被拦截是看 onInterceptTouchEvent的返回值。如果为true:

    在注释1处cancelChild = true,会导致给子类发送CANCEL事件,然后修改mFirstTouchTarget,不再给子View传递事件。

    [逻辑4]      } else {//找到了接收事件的View
    TouchTarget predecessor = null;
    TouchTarget target = mFirstTouchTarget;
    while (target != null) {
    if (alreadyDispatchedToNewTouchTarget && target == newTouchTarget) {
    } else {
    // 判断是否 收到CANCEL事件 或 需要拦截事件
    final boolean cancelChild = resetCancelNextUpFlag(target.child)
    || intercepted; //注释1
    // 子View消费事件
    //如果cancelChild为true,给子View发送cancle事件
    [逻辑5] if (dispatchTransformedTouchEvent(ev, cancelChild,
    get.child, target.pointerIdBits)) {
    handled = true;
    }
    // 修改mFirstTouchTarget,使原来的子View不再接收事件
    if (cancelChild) {
    if (predecessor == null) {
    mFirstTouchTarget = next;
    } else {
    predecessor.next = next;
    }
    target.recycle();
    target = next;
    continue;
    }
    }
    }
    }

    private boolean dispatchTransformedTouchEvent(MotionEvent event, boolean cancel,
    View child, int desiredPointerIdBits) {
    final boolean handled;
    final int oldAction = event.getAction();
    if (cancel || oldAction == MotionEvent.ACTION_CANCEL) {
    event.setAction(MotionEvent.ACTION_CANCEL);
    if (child == null) {
    handled = super.dispatchTouchEvent(event);
    } else {
    handled = child.dispatchTouchEvent(event);
    }
    event.setAction(oldAction);
    return handled;
    }
    //...
    }

    Activity

    Activity没有onInterceptTouchEvent方法,也没有mParent,不具备主动或被动拦截能力

    收起阅读 »

    View系列:动画

    View Animation(视图动画)最大的特点是:并没有改变目标实际的属性(宽高/位置等)。例如:移动后,点击原来的位置出发点击事件;移动后再旋转,还是回到原来的位置旋转。Tween Animation(补间动画)锚点可以是数值、百分数、百分数p三种样式,...
    继续阅读 »

    View Animation(视图动画)

    最大的特点是:并没有改变目标实际的属性(宽高/位置等)。例如:移动后,点击原来的位置出发点击事件;移动后再旋转,还是回到原来的位置旋转。

    Tween Animation(补间动画)

    锚点

    可以是数值、百分数、百分数p三种样式,比如50、50%、50%p。[不是只有pivotx/y才可以用这3中样式,其它变换的属性也可以]

    • 当为数值时,表示在当前View的左上角,即原点处加上50px,做为起始缩放点;
    • 如果是50%,表示在当前控件的左上角加上自己宽度的50%做为起始点;
    • 如果是50%p,那么就是表示在当前的左上角加上父控件宽度的50%做为起始点x轴坐标(是在目标的左上角原点加上相对于父控件宽度的距离,不是锚点在父控件的那个位置)。

    fromX/toX等等类型的数据也可以用上面的3中数据 类型,只不过有的不适合。比如scale用%p就没意义了。养成好习惯,只在锚点的属性上随便用这3中类型,from/to属性分清类型用相应的数值(浮点倍数/角度...)。

    从Animation继承的属性
    android:duration 动画持续时间,以毫秒为单位 
    android:fillAfter 如果设置为true,控件动画结束时,将保持动画最后时的状态
    android:fillBefore 如果设置为true,控件动画结束时,还原到开始动画前的状态
    android:fillEnabled 与android:fillBefore 效果相同,都是在动画结束时,将控件还原到初始化状态
    android:repeatCount 重复次数
    android:repeatMode 重复类型,有reverse和restart两个值,reverse表示倒序回放,restart表示重新放一遍,必须与repeatCount一起使用才能看到效果。因为这里的意义是重复的类型,即回放时的动作。
    android:interpolator 设定插值器,其实就是指定的动作效果,比如弹跳效果等,不在这小节中讲解,后面会单独列出一单讲解。
    scale
    <?xml version="1.0" encoding="utf-8"?>
    <scale xmlns:android="http://schemas.android.com/apk/res/android"
    android:interpolator="@android:anim/accelerate_decelerate_interpolator"
    android:duration="700"
    android:fromXScale="50%" //也可以用上面的3中类型
    android:fromYScale="50%"
    android:toXScale="200%"
    android:toYScale="200%"
    android:pivotX="0.5"
    android:pivotY="0.5"
    android:repeatCount = "2"
    android:repeatMode = "reverse"
    android:fillAfter = "true"
    />

    alpha
    <?xml version="1.0" encoding="utf-8"?>
    <alpha xmlns:android="http://schemas.android.com/apk/res/android"
    android:interpolator="@android:anim/accelerate_interpolator"
    android:fromAlpha="0.1"
    android:toAlpha="1"
    android:duration="1500"
    android:repeatMode = "reverse"
    android:repeatCount = "2"
    android:fillAfter = "true"
    >

    </alpha>
    rotate
    <?xml version="1.0" encoding="utf-8"?>
    <rotate xmlns:android="http://schemas.android.com/apk/res/android"
    android:interpolator="@android:anim/accelerate_interpolator"
    android:fromDegrees="0"
    android:toDegrees="270"
    android:pivotX="50%"
    android:pivotY="50%"
    android:duration="700"
    android:repeatMode = "reverse"
    android:repeatCount = "3"
    android:fillAfter = "true"
    >

    </rotate>
    translate
    <?xml version="1.0" encoding="utf-8"?>
    <translate xmlns:android="http://schemas.android.com/apk/res/android"
    android:interpolator="@android:anim/accelerate_interpolator"
    android:duration="700"
    android:fillAfter="true"
    android:fromXDelta="50"
    android:fromYDelta="50%p"
    android:repeatCount="3"
    android:repeatMode="reverse"
    android:toXDelta="70%p"
    android:toYDelta="80%p">

    </translate>
    AnimationSet animSet = new AnimationSet(false);
    Animation scaleAnim = AnimationUtils.loadAnimation(this, R.anim.scale_anim); //资源文件
    Animation rotateAnim = AnimationUtils.loadAnimation(this, R.anim.rotate_anim);
    AlphaAnimation alphaAnim = new AlphaAnimation(0.2f, 1.0f); //代码生成
    //valueType 3中类型的数据(px, 自身%, 父类%p),这里已自身为参照物。
    TranslateAnimation traslateAnim = new TranslateAnimation(
    Animation.RELATIVE_TO_SELF, 0.2f,
    Animation.RELATIVE_TO_SELF, 3.0f,
    Animation.RELATIVE_TO_SELF, 0f,
    Animation.RELATIVE_TO_SELF, 1.0f);
    ivTarget.startAnimation(animSet);
    自定义Animation
    private class MoveAnimation extends Animation {
    @Override
    protected void applyTransformation(float interpolatedTime, Transformation t) {
    super.applyTransformation(interpolatedTime, t);
    mInterpolatedTime = interpolatedTime;
    invalidate();
    }
    }

    Frame Animation(逐帧动画)

    <?xml version="1.0" encoding="utf-8"?>
    <animation-list xmlns:android="http://schemas.android.com/apk/res/android"
    android:oneshot="true">
    //false 一直重复执行,true执行一次。
    <item
    android:duration="200"
    android:drawable="@drawable/frame_anim_1"/>

    <item
    android:duration="200"
    android:drawable="@drawable/frame_anim_2"/>

    <item
    android:duration="200"
    android:drawable="@drawable/frame_anim_3"/>

    <item
    android:duration="200"
    android:drawable="@drawable/frame_anim_4"/>

    <item
    android:duration="200"
    android:drawable="@drawable/frame_anim_4"/>

    </animation-list>
    • 需要注意的是,动画的启动需要在view和window建立连接后才可以绘制,比如上面代码是在用户触摸后启动。如果我们需要打开界面就启动动画的话,则可以在Activity的onWindowFocusChanged()方法中启动。

    Property Animation(属性动画)

    属性动画是指通过改变View属性来实现动画效果,包括:ValueAnimator、ObjectAnimator、TimeAnimator

    ValueAnimator

    该类主要针对数值进行改变,不对View进行操作

    ValueAnimator animator = ValueAnimator.ofInt(0,400);  
    animator.setDuration(1000);
    animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
    @Override
    public void onAnimationUpdate(ValueAnimator animation) {
    //拿到监听结果,自己处理。
    int curValue = (int)animation.getAnimatedValue();
    tvTextView.layout(curValue,curValue,curValue+tv.getWidth(),curValue+tv.getHeight());
    }
    });
    animator.setInterpolator(new LinearInterpolator());
    animator.start();

    监听:

    /**
    * 监听器一:监听动画变化时的实时值
    * 添加方法为:public void addUpdateListener(AnimatorUpdateListener listener)
    */

    public static interface AnimatorUpdateListener {
    void onAnimationUpdate(ValueAnimator animation);
    }
    /**
    * 监听器二:监听动画变化时四个状态
    * 添加方法为: public void addListener(AnimatorListener listener)
    */

    public static interface AnimatorListener {
    void onAnimationStart(Animator animation);
    void onAnimationEnd(Animator animation);
    void onAnimationCancel(Animator animation);
    void onAnimationRepeat(Animator animation);
    }


    /**
    * 移除AnimatorUpdateListener
    */

    void removeUpdateListener(AnimatorUpdateListener listener);
    void removeAllUpdateListeners();
    /**
    * 移除AnimatorListener
    */

    void removeListener(AnimatorListener listener);
    void removeAllListeners();

    ObjectAnimator

    ValueAnimator只能对数值进行计算,不能直接操作View,需要我们在监听器中自己去操作控件。这样就有点麻烦了,于是Google在ValueAmimator的基础上又派生出了ObjerctAnimator类,让动画直接与控件关联起来。

     	ObjectAnimator rotateObject = ObjectAnimator.ofFloat(tvPropertyTarget, 
    "Rotation",
    0, 20, -20, 40, -40, 0);
    rotateObject.setDuration(2000);
    rotateObject.start();
    setter/getter 属性名

    在View中已经实现了一些属性的setter/getter方法,在构造动画时可以直接对控件使用。

    • 要使用一个属性,必须在控件中有对应的setter/getter方法,属性setter/getter方法的命名必须以驼峰方式
    • ObjectAnimator在使用该属性的时候,会把setter/getter和属性第一个字母大写转换后的字段拼接成方法名,通过反射的方式调用该方法传值。 所以,上文中"Rotation/rotation"可以首字母可以大小写都行
    //1、透明度:alpha  
    public void setAlpha(float alpha)

    //2、旋转度数:rotation、rotationX、rotationY
    public void setRotation(float rotation) //围绕Z轴旋转
    public void setRotationX(float rotationX)
    public void setRotationY(float rotationY)

    //3、平移:translationX、translationY
    public void setTranslationX(float translationX)
    public void setTranslationY(float translationY)

    //缩放:scaleX、scaleY
    public void setScaleX(float scaleX)
    public void setScaleY(float scaleY)

    image-20210603130556023

    自定义属性做动画
    public class PointView extends View {
    private float mRadius = 0;
    public PointView(Context context, @Nullable AttributeSet attrs) {
    super(context, attrs);
    }
    @Override
    protected void onDraw(Canvas canvas) {
    }

    public void setRadius(float radius){
    this.mRadius = radius;
    invalidate();
    }

    public float getRadius(){
    return mRadius;
    }
    }

    //radius属性首字母大小写无所谓,最后都是要转成大些的。
    ObjectAnimator pointAnim = ObjectAnimator.ofFloat(pointPropertyAnim,
    "Radius",
    10, 40, 40, 80, 60, 100, 80, 120,60);
    pointAnim.start();

    什么时候需要用到get方法呢? 前面构造动画时传入的取值范围都是多个参数,Animator知道是从哪个值变化到哪个值。当只传入一个参数的时候,Animator怎么知道哪里是起点?这时通过get方法找到初始值。 如果没有找到get方法,会用该参数类型的默认初始值复制。如:ofInt方法传入一个值,找不到get方法时,默认给的初始值是Int类型的初始值0.

    原理

    image-20210603131108900ObjectAnimator的方便之处在于:

    ValueAnimator只负责把数值给监听器,ObjectAnimator只负责调用set方法。至于实现,都是靠我们自己或者set中的方法。

    插值器

    设置动画运行过程中的进度比例,类似匀速变化、加速变化、回弹等

    • 参数input:是一个float类型,它取值范围是0到1,表示当前动画的进度,取0时表示动画刚开始,取1时表示动画结束,取0.5时表示动画中间的位置,其它类推。
    • 返回值:表示当前实际想要显示的进度。取值可以超过1也可以小于0,超过1表示已经超过目标值,小于0表示小于开始位置。(给估值器使用
    • 插值器默认每10ms刷新一次
    public class PointInterpolator implements Interpolator {
    /**
    * input 是实际动画执行的时间比例 0~1
    * newInput 你想让动画已经执行的比例 0~1。
    * 注意:都是比例,而不是实际的值。
    *
    * setDuration(1000)情况下:前200ms走了3/4的路程比例,后800ms走了1/4的路程比例。
    */

    @Override
    public float getInterpolation(float input) {
    if (input <= 0.2) {//后1/4的时间,输出3/4的比例
    float newInput = input*4;
    return newInput;
    }else {//后3/4的时间,输出1/4的比例
    float newInput = (float) (input - 0.2)/4 + 0.8f;
    return newInput;
    }
    }
    }

    使用方式和默认插值器

    在xml和代码中使用插值器,省略代码中使用方式

    <?xml version="1.0" encoding="utf-8"?>
    <scale xmlns:android="http://schemas.android.com/apk/res/android"
    // 通过资源ID设置插值器
    android:interpolator="@android:anim/overshoot_interpolator"
    android:duration="3000"
    android:fromXScale="0.0"
    android:fromYScale="0.0"
    android:pivotX="50%"
    android:pivotY="50%"
    android:toXScale="2"
    android:toYScale="2" />

    内置插值器动画展示

    Android动画之Interpolator

    Android动画插值器

    作用资源ID对应的Java类
    动画加速进行@android:anim/accelerte_interpolatorAcceleraterplator
    快速完成动画,超出再回到到结束样式@android:anim/overshoot_interpolatorOvershootInterpolator
    先加速再减速@android:anim/accelerate_decelerate_interpolatorAccelerateDecelerateInterpolator
    先退后再加速前进@android:anim/anticipate_interpolatorAnticipateInterpolator
    先退后再加速前进,超出终点后再回终点@android:anim/anticipate_overshoot_interpolatorAnticipateOvershootInterpolator
    最后阶段弹球效果@android:anim/bounce_interpolatorBounceInterpolator
    周期运动@android:anim/cycle_interpolatorCycleInterpolator
    减速@android:anim/decelerate_interpolatorDecelerateInterpolator
    匀速@android:anim/linear_interpolatorLinearInterpolator

    估值器

    设置 属性值 从初始值过渡到结束值 的变化具体数值

    • 参数fraction: 表示当前动画的进度(插值器返回值
    • 返回值:表示当前对应类型的取值,也就是UpdateListener接口方法中传入的值
    public class PointEvaluator implements TypeEvaluator<Point> {
    @Override
    public Point evaluate(float fraction, Point startValue, Point endValue) {
    int radius = (int) (startValue.getRadius() +
    fraction*(endValue.getRadius() - startValue.getRadius()));
    return new Point(radius);
    }
    }

    自定义插值器、估值器、属性的使用:

    public void doAnimation(){
    //ObjectAnimator animator = ObjectAnimator.ofInt(mView, "Radius", 20, 80);
    ValueAnimator animatior = new ValueAnimator();
    animatior.setObjectValues(new Point(20), new Point(80));
    animatior.setInterpolator(new PointInterpolator());
    animatior.setEvaluator(new PointEvaluator());

    animatior.setDuration(2000);
    animatior.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
    @Override
    public void onAnimationUpdate(ValueAnimator animation) {
    mPoint = (Point) animation.getAnimatedValue();
    invalidate();
    }
    });
    animatior.start();
    }

    PropertyValuesHolder

    它其中保存了动画过程中所需要操作的属性和对应的值

    通过ObjectAnimator.ofFloat(Object target, String propertyName, float… values)构造的动画,ofFloat()的内部实现其实就是将传进来的参数封装成PropertyValuesHolder实例来保存动画状态,后期的各种操作也是以PropertyValuesHolder为主。

    //将需要操作的多个属性和值封装起来,一起放到ObjectAnimator中,相当于set操作。
    PropertyValuesHolder rotateHolder = PropertyValuesHolder.ofFloat("Rotation", 0, 360, 0);
    PropertyValuesHolder scaleXHolder = PropertyValuesHolder.ofFloat("scaleX", 1, 2, 1,2,1);
    PropertyValuesHolder scaleYHolder = PropertyValuesHolder.ofFloat("scaleY", 1, 2, 1,2,1);
    ObjectAnimator objectAnim = ObjectAnimator.ofPropertyValuesHolder(ivHolderTarget,
    rotateHolder,
    scaleXHolder,
    scaleYHolder);
    objectAnim.setDuration(2000);
    objectAnim.setInterpolator(new LinearInterpolator());
    objectAnim.start();

    KeyFrame(主要帧)

    如果想要更精确的控制动画,想要控制整个动画过程的某个点或某个时段达到的值,可以通过自定义插值器或估值器来实现,但是那样又有些费事,并且不容易计算这段时间内值的变化。 这时可以用Keyframe来实现,即设置好某个时间点和值,系统会自动计算该点和上个点之间,值的变化。

    /***
    * 实现左右摇晃,每边最后有震动的效果。
    * 摇晃角度100度:0.2f/0.2~0.4/0.4~0.5,分别设置不同的角度和加速器。
    * 每个比例点达到哪个角度,这在估值器中也能做到,但是需要自己算每个时间段内值的变化过程。
    * KeyFrame可以设置好 比例-值 以后,系统根据默认或设置的加速器改变:上个点和该点内的值如何变换。
    * 这样可以更精确的控制动画过程,同时也不用自己费劲去计算值因该如何变换。
    */

    Keyframe kfRotation1 = Keyframe.ofFloat(0, 0); //第一帧,如果没有该帧,会直接跳到第二帧开始动画。
    //第二帧 0.2f时达到60度,线性加速应该作用于从0~0.2f的这段时间,而不是作用在0.2~0.4f这段。因为已经定好60度是要的结果了,那么实现就应该在前面这段。
    Keyframe kfRotation2 = Keyframe.ofFloat(0.2f, 60);
    kfRotation2.setInterpolator(new LinearInterpolator());
    Keyframe kfRotation3 = Keyframe.ofFloat(0.4f, 100);
    kfRotation3.setInterpolator(new BounceInterpolator());
    Keyframe kfRotation4 = Keyframe.ofFloat(0.5f, 0);
    kfRotation4.setInterpolator(new LinearInterpolator()); //最少有2帧
    Keyframe kfRotation5 = Keyframe.ofFloat(0.7f, -60);
    kfRotation5.setInterpolator(new LinearInterpolator());
    Keyframe kfRotation6 = Keyframe.ofFloat(0.9f, -100);
    kfRotation6.setInterpolator(new BounceInterpolator());
    Keyframe kfRotation7 = Keyframe.ofFloat(1f, 0);//最后一帧,如果没有该帧,会以最后一个KeyFrame做结尾
    kfRotation7.setInterpolator(new LinearInterpolator());

    Keyframe kfScaleX1 = Keyframe.ofFloat(0, 1);
    Keyframe kfScaleX2 = Keyframe.ofFloat(0.01f,2.8f);
    Keyframe kfScaleX3 = Keyframe.ofFloat(0.8f,2.0f);
    Keyframe kfScaleX4 = Keyframe.ofFloat(1f,1.0f);

    Keyframe kfScaleY1 = Keyframe.ofFloat(0, 1);
    Keyframe kfScaleY2 = Keyframe.ofFloat(0.01f,2.8f);
    Keyframe kfScaleY4 = Keyframe.ofFloat(0.8f,2.0f);
    Keyframe kfScaleY5 = Keyframe.ofFloat(1f,1.0f);

    PropertyValuesHolder rotationHolder = PropertyValuesHolder.ofKeyframe("rotation", kfRotation1, kfRotation2, kfRotation3,kfRotation4, kfRotation5, kfRotation6, kfRotation7);
    PropertyValuesHolder scaleXHolder = PropertyValuesHolder.ofKeyframe("scaleX", kfScaleX1, kfScaleX2, kfScaleX3, kfScaleX4);
    PropertyValuesHolder scaleYHolder = PropertyValuesHolder.ofKeyframe("scaleY", kfScaleY1, kfScaleY2, kfScaleY4, kfScaleY5);


    ObjectAnimator objectAnim = ObjectAnimator.ofPropertyValuesHolder(ivHolderTarget,
    rotationHolder,
    scaleXHolder,
    scaleYHolder);
    objectAnim.setDuration(1500);

    AnimatorSet

    AnimatorSet针对ValueAnimator和ObjectAnimator都是适用的,但一般而言,我们不会用到ValueAnimator的组合动画。

    playTogether/playSequentially

    无论是playTogether还是playSequentially方法,它们只是,仅仅是激活了动画什么时候开始,并不参与动画的具体操作。 例如:如果是playTogether,它只负责这个动画什么时候一起激活,至于anim1/anim2/anim3...哪个马上开始,哪个有延迟,哪个会无限重复,set都不管,只负责一起激活。 如果是playSequentially,它只负责什么时候开始激活第一个(因为有可能set设置延迟),并在第一个动画结束的时候,激活第二个,以此类推。

    ObjectAnimator anim1 = ObjectAnimator.ofInt(mTv1, "BackgroundColor",  0xffff00ff, 0xffffff00, 0xffff00ff);

    ObjectAnimator anim2 = ObjectAnimator.ofFloat(mTv1, "translationY", 0, 400, 0);
    anima2.setStartDelay(2000);
    anima2.setRepeatCount(ValueAnimator.INFINITE);

    ObjectAnimator anim3 = ObjectAnimator.ofFloat(mTv2, "translationY", 0, 400, 0);
    anim3.setStartDelay(2000);

    AnimatorSet animatorSet = new AnimatorSet();
    animatorSet.playTogether(anim1, anim2, anim3);//playSequentially(按次序播放)
    animatorSet.setDuration(2000);
    animatorSet.setStartDelay(2000);
    animatorSet.start();
    play(x).with(x)
    • play(anim1).with(anim2):2000ms后set开始激活动画,anim1启动,再过2000ms后anim2启动。
    • play(anim2).with(anim1):2000ms后set开始激活动画,再过2000ms后启动anim2,并且启动anim1.
    set监听

    addListener监听的是AnimatorSet的start/end/cacle/repeat。不会监听anim1/anim2的动画状态的。

    联合动画XML实现
    单独设置和Set中设置
    • 以set为准:
    //设置单次动画时长
    public AnimatorSet setDuration(long duration);
    //设置加速器
    public void setInterpolator(TimeInterpolator interpolator)
    //设置ObjectAnimator动画目标控件
    public void setTarget(Object target)
    ObjectAnimator anim1 = ObjectAnimator.ofFloat(mTv1, "translationY", 0, 400, 0);
    anim1.setDuration(500000000);

    ObjectAnimator anim2 = ObjectAnimator.ofFloat(mTv2, "translationY", 0, 400, 0);
    anim2.setDuration(3000);//每次3000,而不是3次3000ms
    anim2.setRepeatCount(3);

    AnimatorSet animatorSet = new AnimatorSet();
    animatorSet.play(tv2TranslateY).with(tv1TranslateY);
    animatorSet.setDuration(2000);//以Set为准
    animatorSet.start();

    setDuration()是指单个动画的时间,并不是指总共做完这个动画过程的时间。比如:anim2中设置了3000ms,重复3次。是指每次3000ms,不是3次3000ms。
    另外animatorSet设置了时间以后,anim1/anim2虽然也设置了,但是这时以set为准。即,anim1/anim2的单个动画时间为2000ms。只不过anim2是每次2000ms,重复3次,共6000ms。

    • 不以set为准:setStartDelay
    ObjectAnimator anim1 = ObjectAnimator.ofFloat(mTv1, "translationY", 0, 400, 0);
    ObjectAnimator anim2 = ObjectAnimator.ofFloat(mTv2, "translationY", 0, 400, 0);
    anim2.setStartDelay(2000);

    AnimatorSet animatorSet = new AnimatorSet();
    animatorSet.addListener(new Animator.AnimatorListener(){...});
    animatorSet.play(anim1).with(anim2);
    animatorSet.setStartDelay(3000);//指的是Set的激活延迟,而不是动画延迟
    animatorSet.setDuration(2000);
    animatorSet.start();

    setStartDelay不会覆盖单个动画的该方法,只会延长set的激活时间。所以,上面代码中动画的启动过程是:3000ms后set开始激活动画,anim1启动,再过2000ms后anim2启动。

    ViewPropertyAnimator

    属性动画已不再是针对于View而进行设计的了,而是一种对数值不断操作的过程,我们将属性动画对数值的操作过程设置到指定对象的属性上来,从而形成一种动画的效果。 虽然属性动画给我们提供了ValueAnimator类和ObjectAnimator类,在正常情况下,基本都能满足我们对动画操作的需求,但ValueAnimator类和ObjectAnimator类本身并不是针对View对象的而设计的,而我们在大多数情况下主要都还是对View进行动画操作的。

    因此Google官方在Android 3.1系统中补充了ViewPropertyAnimator类,这个类便是专门为View动画而设计的。

    • 专门针对View对象动画而操作的类
    • 更简洁的链式调用设置多个属性动画,这些动画可以同时进行
    • 拥有更好的性能,多个属性动画是一次同时变化,只执行一次UI刷新(也就是只调用一次invalidate,而n个ObjectAnimator就会进行n次属性变化,就有n次invalidate)
    • 每个属性提供两种类型方法设置。scaleX()/scaleXBy()
    • 该类只能通过View的animate()获取其实例对象的引用
    • 自动调用start
    btn.animate()
    .alpha(0.5f)
    .rotation(360)
    .scaleX(1.5f).scaleY(1.5f)
    .translationX(50).translationY(50)
    .setDuration(5000);

    image-20210604100429893

    layoutAnimation

    布局动画,api1,该属性只对创建ViewGroup时,对其子View有动画。已经创建过了该ViewGroup的话,再向其添加子View不会有动画。

    • onCreat创建加载布局时:
    //anim -> rotate_anim.xml
    <?xml version="1.0" encoding="utf-8"?>


    // layoutAnimation标签
    <?xml version="1.0" encoding="utf-8"?>
    <layoutAnimation
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:delay="1"
    android:animationOrder="normal"
    android:animation="@anim/rotate_anim">

    </layoutAnimation>

    //定义在LinearLayout上,在该界面生成时,Button显示动画。但是,后面在LinearLayout中添加Button时,不再有动画。
    <LinearLayout
    android:id="@+id/ll_tips_target_animation"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layoutAnimation="@anim/layout_animation"
    android:tag="在xml中设置的layoutAnimation"
    android:orientation="vertical">

    <Button
    style="@style/base_button"
    android:text="ViewGroup初始化时,子View有动画"/>

    </LinearLayout>
    • 代码中动态设置layoutAnimation,添加View
            //代码生成ViewGroup
    LinearLayout linear = new LinearLayout(this);

    Animation animation = AnimationUtils.loadAnimation(this, R.anim.rotate_anim);
    LayoutAnimationController controller = new LayoutAnimationController(animation);
    controller.setDelay(1);
    //动画模式,正常/倒叙/随机
    controller.setOrder(LayoutAnimationController.ORDER_NORMAL);
    //设置layoutAnimation
    linear.setLayoutAnimation(controller);
    linear.setLayoutAnimationListener(new Animation.AnimationListener() {

    });

    LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
    LinearLayout.LayoutParams.MATCH_PARENT,
    LinearLayout.LayoutParams.WRAP_CONTENT);
    linear.setLayoutParams(params);
    //给该ViewGroup添加子View,子View会有动画。
    addVeiw(linear,null);
    llTargetAnim.addView(linear, 0);

    使用场景:

    该属性只有ViewGroup创建的时候才能有效果,所以不适合动态添加子View的操作显示动画。一般做界面显示的时候的入场动画,比如打开一个界面,多个固定不变的item有动画的显示出来。(进入设置界面,信息展示界面)。

    android:animateLayoutChanges属性:

    Api11后,添加/移除子View时所带的默认动画,在Xml中设置。不能自定义动画,只能使用默认的。所以,使用范围较小。

    <LinearLayout
    android:animateLayoutChanges="true"
    />

    image


    收起阅读 »

    「Java 路线」| 关于泛型能问的都在这里了(含Kotlin)

    前言 泛型(Generic Type) 无论在哪一门语言里,都是最难语法的存在,细节之繁杂、理解之困难,令人切齿; 在这个系列里,我将总结Java & Kotlin中泛型的知识点,带你从 语法 & 原理 全面理解泛型。追求简单易懂又...
    继续阅读 »

    前言



    • 泛型(Generic Type) 无论在哪一门语言里,都是最难语法的存在,细节之繁杂、理解之困难,令人切齿;

    • 在这个系列里,我将总结Java & Kotlin中泛型的知识点,带你从 语法 & 原理 全面理解泛型。追求简单易懂又不失深度,如果能帮上忙,请务必点赞加关注!

    • 首先,尝试回答这些面试中容易出现的问题,相信看完这篇文章,这些题目都难不倒你:


    1、下列代码中,编译出错的是:
    public class MyClass<T> {
    private T t0; // 0
    private static T t1; // 1
    private T func0(T t) { return t; } // 2
    private static T func1(T t) { return t; } // 3
    private static <T> T func2(T t) { return t; } // 4
    }
    2、泛型的存在是用来解决什么问题?
    3、请说明泛型的原理,什么是泛型擦除机制,具体是怎样实现的?



    目录





    1. 泛型基础




    • 问:什么是泛型,有什么作用?


    答:在定义类、接口和方法时,可以附带类型参数,使其变成泛型类、泛型接口和泛型方法。与非泛型代码相比,使用泛型有三大优点:更健壮(在编译时进行更强的类型检查)、更简洁(消除强转,编译后自动会增加强转)、更通用(代码可适用于多种类型)



    • 问:什么是类型擦除机制?


    答:泛型本质上是 Javac 编译器的一颗 语法糖,这是因为:泛型是 JDK1.5 中引进的新特性,为了 向下兼容,Java 虚拟机和 Class 文件并没有提供泛型的支持,而是让编译器擦除 Code 属性中所有的泛型信息,需要注意的是,泛型信息会保留在类常量池的属性中。



    • 问:类型擦除的具体步骤?


    答:类型擦除发生在编译时,具体分为以下 3 个步骤:



    • 1:擦除所有类型参数信息,如果类型参数是有界的,则将每个参数替换为其第一个边界;如果类型参数是无界的,则将其替换为 Object

    • 2:(必要时)插入类型转换,以保持类型安全

    • 3:(必要时)生成桥接方法以在子类中保留多态性


    举个例子:


    源码:
    public class Parent<T> {
    public void func(T t){
    }
    }

    public class Child<T extends Number> extends Parent<T> {
    public T get() {
    return null;
    }
    public void func(T t){
    }
    }

    void test(){
    Child<Integer> child = new Child<>();
    Integer i = child.get();
    }
    ---------------------------------------------------------
    字节码:
    public class Parent {
    public void func(Object t){
    }
    }

    public class Child extends Parent {
    public Number get() {
    return null;
    }
    public void func(Number t) {
    }

    桥方法 - synthetic
    public void func(Object t){
    func((Number)t);
    }
    }

    void test() {
    Child<Integer> child = new Child();
    // 插入强制类型转换
    Integer i = (Integer) child.get();
    }

    步骤1:Parent 中的类型参数 T 被擦除为 Object,而 Child 中的类型参数 T 被擦除为 Number;


    步骤2:child.get(); 插入了强制类型转换


    步骤3:在 Child 中生成桥方法,桥方法是编译器生成的,所以会带有 synthetic 标志位。为什么子类中需要增加桥方法呢,可以先思考这个问题:假如没有桥方法,会怎么样?你可以看看下列代码调用的是子类还是父类方法:


    Parent<Integer> child = new Child<>();
    Parent<Integer> parent = new Parent<>();

    child.func(1); // Parent#func(Object);
    parent.func(1); // Parent#func(Object);

    这两句代码都会调用到 Parent#func(),如果你看过之前我写过的一篇文章,相信难不到你:《Java | 深入理解方法调用的本质(含重载与重写区别)》。在这里我简单分析下:



    1、方法调用的本质是根据方法的符号引用确定方法的直接引用(入口地址)


    2、这两句代码调用的方法符号引用为:


    child.func(new Object()) => com/xurui/Child.func(Object)


    parent.func(new Object()) => com/xurui/Parent.func(Object)


    3、这两句方法调用的字节码指令为 invokevirtual


    4、类加载解析阶段解析类的继承关系,生成类的虚方法表


    5、调用阶段(动态分派):Child 没有重写 func(Object),所以 Child 的虚方法表中存储的是Parent#func(Object);Parent 的虚方法表中存储的是Parent#func(Object);



    可以看到,即使使用对象的实际类型为 Child ,这里调用的依旧是父类的方法。这样就 失去了多态性。 因此,才需要在泛型子类中添加桥方法。



    • 问:为什么擦除后,反编译还是看到类型参数 T ?


    反编译Parent.class,可以看到 T ,不是已经擦除了吗?

    public class Parent<T> {
    public Parent() {
    }

    public void func(T t) {
    }
    }

    答:泛型中所谓的类型擦除,其实只是擦除Code 属性中的泛型信息,在类常量池属性(Signature 属性、LocalVariableTypeTable 属性)中其实还保留着泛型信息,这也是在运行时可以反射获取泛型信息的根本依据,我在第 4 节说。



    • 问:泛型的限制 & 类型擦除会带来什么影响?


    由于类型擦除的影响,在运行期是不清楚类型实参的实际类型的。为了避免程序的运行结果与程序员语义不一致的情况,泛型在使用上存在一些限制。好处是类型擦除不会为每种参数化类型创建新的类,因此泛型不会增大内存消耗。


    泛型的限制




    2. Kotlin的实化类型参数


    前面我们提到,由于类型擦除的影响,在运行期是不清楚类型实参的实际类型的。例如下面的代码是不合法的,因为T并不是一个真正的类型,而仅仅是一个符号:


    在这个函数里,我们传入一个List,企图从中过滤出 T 类型的元素:

    Java:
    <T> List<T> filter(List list) {
    List<T> result = new ArrayList<>();
    for (Object e : list) {
    if (e instanceof T) { // compiler error
    result.add(e);
    }
    }
    return result;
    }
    ---------------------------------------------------
    Kotlin:
    fun <T> filter(list: List<*>): List<T> {
    val result = ArrayList<T>()
    for (e in list) {
    if (e is T) { // cannot check for instance of erased type: T
    result.add(e)
    }
    }
    return result
    }

    Kotlin中,有一种方法可以突破这种限制,即:带实化类型参数的内联函数


    Kotlin:
    inline fun <reified T> filter(list: List<*>): List<T> {
    val result = ArrayList<T>()
    for (e in list) {
    if (e is T) {
    result.add(e)
    }
    }
    return result
    }

    关键在于inlinereified,这两者的语义是:



    • inline(内联函数): Kotlin编译器将内联函数的字节码插入到每一次调用方法的地方

    • reified(实化类型参数): 在插入的字节码中,使用类型实参的确切类型代替类型实参


    规则很好理解,对吧。很明显,当发生方法内联时,方法体字节码就变成了:


    调用:
    val list = listOf("", 1, false)
    val strList = filter<String>(list)
    ---------------------------------------------------
    内联后:
    val result = ArrayList<String>()
    for (e in list) {
    if (e is String) {
    result.add(e)
    }
    }

    需要注意的是,内联函数整个方法体字节码会被插入到调用位置,因此控制内联函数体的大小。如果函数体过大,应该将不依赖于T的代码抽取到单独的非内联函数中。



    注意,无法从 Java 代码里调用带实化类型参数的内联函数



    实化类型参数的另一个妙用是代替 Class 对象引用,例如:


    fun Context.startActivity(clazz: Class<*>) {
    Intent(this, clazz).apply {
    startActivity(this)
    }
    }

    inline fun <reified T> Context.startActivity() {
    Intent(this, T::class.java).apply {
    startActivity(this)
    }
    }

    调用方:
    context.startActivity(MainActivity::class.java)
    context.startActivity<MainActivity>() // 第二种方式会简化一些



    3. 变型:协变 & 逆变 & 不变


    变型(Variant)描述的是相同原始类型的不同参数化类型之间的关系。说起来有点绕,其实就是说:IntegerNumber的子类型,问你List<Integer>是不是List<Number>的子类型?


    变型的种类具体分为三种:协变型 & 逆变型 & 不变型



    • 协变型(covariant): 子类型关系被保留

    • 逆变型(contravariant): 子类型关系被翻转

    • 不变型(invariant): 子类型关系被消除


    在 Java 中,类型参数默认是不变型的,例如:


    List<Number> l1;
    List<Integer> l2 = new ArrayList<>();
    l1 = l2; // compiler error

    相比之下,数组是支持协变型的:


    Number[] nums;
    Integer[] ints = new Integer[10];
    nums = ints; // OK 协变,子类型关系被保留

    那么,当我们需要将List<Integer>类型的对象,赋值给List<Number>类型的引用时,应该怎么做呢?这个时候我们需要限定通配符



    • <? extends> 上界通配符


    要想类型参数支持协变,需要使用上界通配符,例如:


    List<? extends Number> l1;
    List<Integer> l2 = new ArrayList<>();
    l1 = l2; // OK

    但是这会引入一个编译时限制:不能调用参数包含类型参数 E 的方法,也不能设置类型参数的字段,简单来说,就是只能访问不能修改(非严格):


    // ArrayList.java
    public boolean add(E e) {
    ...
    }

    l1.add(1); // compiler error


    • <? super> 下界通配符


    要想类型参数支持逆变,需要使用下界通配符,例如:


    List<? super Integer> l1;
    List<Number> l2 = new ArrayList<>();
    l1 = l2; // OK

    同样,这也会引入一个编译时限制,但是与协变相反:不能调用返回值为类型参数的方法,也不能访问类型参数的字段,简单来说,就是只能修改不能访问(非严格):


    // ArrayList.java
    public E get(int index) {
    ...
    }

    Integer i = l1.get(0); // compiler error


    • <?> 无界通配符

    其实很简单,很多资料其实都解释得过于复杂了。 < ?> 其实就是 的缩写。例如:
    List<?> l1;
    List<Integer> l2 = new ArrayList<>();
    l1 = l2; // OK

    理解了这点,这个问题就很好回答了:



    • 问:List 与 List<?>有什么区别?


    答:List 是原生类型,可以添加或访问元素,不具备编译期安全性,而 List 其实是 List的缩写,是协变型的(可引出协变型的特点与限制);从语义上,List 表明使用者清楚变量是类型安全的,而不是因为疏忽而使用了原生类型 List。



    泛型代码的设计,应遵循PECS原则(Producer extends Consumer super):



    • 如果只需要获取元素,使用 <? extends T>

    • 如果只需要存储,使用<? super T>


    举例:


    // Collections.java public static void copy(List<? super T> dest, List<? extends T> src) { }



    在 Kotlin 中,变型写法会有些不同,但是语义是完全一样的:


    协变:
    val l0: MutableList<*> 相当于MutableList<out Any?>
    val l1: MutableList<out Number>
    val l2 = ArrayList<Int>()
    l0 = l2 // OK
    l1 = l2 // OK
    ---------------------------------------------------
    逆变:
    val l1: MutableList<in Int>
    val l2 = ArrayList<Number>()
    l1 = l2 // OK

    另外,Kotlin 的in & out不仅仅可以用在类型实参上,还可以用在泛型类型声明的类型参数上。其实这是一种简便写法,表示类设计者知道类型参数在整个类上只能协变或逆变,避免在每个使用的地方增加,例如 Kotlin 的List被设计为不可修改的协变型:


    public interface List<out E> : Collection<E> {
    ...
    }


    注意:在 Java 中,只支持使用点变型,不支持 Kotlin 类似的声明点变型



    小结一下:





    4. 使用反射获取泛型信息


    前面提到了,编译期会进行类型擦除,Code 属性中的类型信息会被擦除,但是在类常量池属性(Signature属性、LocalVariableTypeTable属性)中还保留着泛型信息,因此我们可以通过反射来获取这部分信息。


    获取泛型类型实参:需要利用Type体系


    4.1 获取泛型类 & 泛型接口声明


    TypeVariable ParameterizedType GenericArrayType WildcardType


    Gson TypeToken


    Editting....




    5. 总结



    • 应试建议

      • 1、第 1 节非常非常重点,着重记忆:泛型的本质和设计缘由、泛型擦除的三个步骤、限制和优点,已经总结得很精华了,希望能帮到你;

      • 2、着重理解变型(Variant)的概念,以及各种限定符的含义;

      • 3、Kotlin 相关的部分,作为知识积累和思路扩展为主,非应试重点。







    作者:彭丑丑
    链接:https://juejin.cn/post/6888345234653052941
    来源:掘金
    著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

    收起阅读 »

    「Java 路线」| 反射机制(含 Kotlin)

    前言 反射(Reflection)是一种在运行时 动态访问类型信息 的机制。 在这篇文章里,我将带你梳理Java & Kotlin反射的使用攻略,追求简单易懂又不失深度,如果能帮上忙,请务必点赞加关注! 目录 1. 类型系统...
    继续阅读 »

    前言



    • 反射(Reflection)是一种在运行时 动态访问类型信息 的机制。

    • 在这篇文章里,我将带你梳理Java & Kotlin反射的使用攻略,追求简单易懂又不失深度,如果能帮上忙,请务必点赞加关注!




    目录



    1. 类型系统的基本概念


    首先,梳理一一下类型系统的基础概念:



    • 问:什么是强 / 弱类型语言?


    答:强 / 弱类型语言的区分,关键在于变量是否 (倾向于) 类型兼容。例如,Java 是强类型语言,变量有固定的类型,以下代码在 Java 中是非法的:


    public class MyRunnable {
    public abstract void run();
    }

    // 编译错误:Incompatible types
    java.lang.Runnable runnable = new MyRunnable() {
    @Override
    public void run() {

    }
    }
    runnable.run(); // X

    相对地,JavaScript 是弱类型语言,一个变量没有固定的类型,允许接收不同类型的值:


    function MyRunnable(){
    this.run = function(){
    }
    }
    function Runnable(){
    this.run = function(){
    }
    }
    var ss = new MyRunnable();
    ss.run(); // 只要对象有相同方法签名的方法即可
    ss = new Runnable();
    ss.run();

    更具体地描述,Java的强类型特性体现为:变量仅允许接收相同类型或子类型的值。 嗯(黑人问号脸)?和你的理解一致吗?请看下面代码,哪一行是有问题的:


    注意,请读者假设 1 ~ 4 号代码是单独运行的

    long numL = 1L;
    int numI = 0;
    numL = numI; // 1
    numI = (int)numL; // 2

    Integer integer = new Integer(0);
    Object obj = new Object();
    integer = (Integer) obj; // 3 ClassCastException
    obj = integer; // 4

    在这里,第 3 句代码会发生运行时异常,结论:



    • 1:调用字节码指令 i2l,将 int 值转换为 long 值。(此时,numL 变量接收的是相同类型的值,命题正确)


    • 2:调用字节码指令 l2i,将 long 值转换为 int 值。(此时,numI 变量接收的是相同类型的值,命题正确)


    • 3:调用字节码指令 checkcast,发现 obj 变量的值不是 Integer 类型,抛出 ClassCastException。(此时,Integer 变量不允许接收 Object 对象,命题正确)


    • 4:integer 变量的值是 obj 变量的子类型,可以接收。(此时,Object 变量允许接收 Integer 对象,命题正确)



    用一张图概括一下:






    • 问:什么是静态 / 动态类型语言?


    答:静态 / 动态类型语言的区分,关键在于类型检查是否 (倾向于) 编译时执行。例如, Java & C/C++ 是静态类型语言,而 JavaScript 是动态类型语言。需要注意的是,这个定义并不是绝对的,例如 Java 也存在运行时类型检查的方式,例如上面提到的 checkcast 指令本质上是在运行时检查变量的类型与对象的类型是否相同。 那么 Java 是如何在运行时获得类型信息的呢?这就是我们下一节要讨论的问题。




    2. 反射的基本概念



    • 问:什么是反射?为什么要使用反射?


    答:反射(Reflection)是一种在运行时 动态访问类型信息 的机制。Java 是静态强类型语言,它倾向于在编译时进行类型检查,因此当我们访问一个类时,它必须是编译期已知的,而使用反射机制可以解除这种限制,赋予 Java 语言动态类型的特性。例如:


    void func(Object obj) {
    try {
    Method method = obj.getClass().getMethod("run",null);
    method.invoke(obj,null);
    }
    ... 省略 catch
    }
    func(runnable); 调用 Runnale#run()
    func(myRunnable); 调用 MyRunnale#run()


    • 问:Java 运行时类型信息是如何表示的?


    所有的类在第一次使用时动态加载到内存中,并构造一个 Class 对象,其中包含了与类有关的所有信息,Class 对象是运行时访问类型信息的入口。需要注意的是,每个类 / 内部类 / 接口都拥有各自的 Class 对象。



    • 问:获取 Class 对象有几种方式,有什么区别?


    答:获取 Class 对象是反射的起始步骤,具体来说,分为以下三种方式:



    • 问:为什么反射性能差,怎么优化?


    答:主要有以下原因:


    性能差原因优化方法
    产生大量中间变量缓存元数据对象
    增加了检查可见性操作调用Method#setAccessible(true),减少不必要的检查
    Inflation 机制会生成字节码,而这段字节码没有经过优化/
    缺少编译器优化,普通调用有一系列优化手段,例如方法内联,而反射调用无法应用此优化/
    增加了装箱拆箱操作,反射调用需要构建包装类/


    3. 反射调用的 Inflation 机制


    反射调用是反射的一个较为常用的场景,这里我们来分析下反射调用的源码。反射调用需要使用Method#invoke(...),源码如下:


    Method.java


    public Object invoke(Object obj, Object... args) {
    MethodAccessor ma = methodAccessor;
    if (ma == null) {
    ma = acquireMethodAccessor();
    }
    return ma.invoke(obj, args);
    }

    NativeMethodAccessorImpl.java


    class NativeMethodAccessorImpl extends MethodAccessorImpl {
    private final Method method;
    private DelegatingMethodAccessorImpl parent;
    private int numInvocations;

    NativeMethodAccessorImpl(Method var1) {
    this.method = var1;
    }

    public Object invoke(Object var1, Object[] var2) {
    1. 检查调用次数是否超过阈值
    if (++this.numInvocations > ReflectionFactory.inflationThreshold() && !ReflectUtil.isVMAnonymousClass(this.method.getDeclaringClass())) {
    2. ASM 生成新类
    MethodAccessorImpl var3 = (MethodAccessorImpl)(new MethodAccessorGenerator()).generateMethod(this.method.getDeclaringClass(), this.method.getName(), this.method.getParameterTypes(), this.method.getReturnType(), this.method.getExceptionTypes(), this.method.getModifiers());
    3. 设置为代理
    this.parent.setDelegate(var3);
    }
    4. 调用 native 方法
    return invoke0(this.method, var1, var2);
    }

    void setParent(DelegatingMethodAccessorImpl var1) {
    this.parent = var1;
    }

    private static native Object invoke0(Method var0, Object var1, Object[] var2);
    }

    ReflectionFactory.java


    public class ReflectionFactory {

    private static int inflationThreshold = 15;

    static int inflationThreshold() {
    return inflationThreshold;
    }
    }

    可以看到,反射调用最终会委派给 NativeMethodAccessorImpl ,要点如下:



    • 当反射调用执行次数较少时,直接通过 native 方法调用;

    • 当反射调用执行次数较多时,则通过 ASM 字节码生成技术生成新的类,以后的反射调用委派给新生成的类来处理。



    提示: 为什么不一开始就生成新类呢?因为生成字节码的时间成本高于执行一次 native 方法的时间成本,所以在反射调用执行次数较少时,就直接调用 native 方法了。





    4. 反射的应用场景


    4.1 类型判断



    4.2 创建对象



    • 1、使用 Class.newInstance(),适用于类拥有无参构造方法


    Class classType = Class.forName("java.lang.String");
    String str= (String) classType.newInstance();


    • 2、Constructor.newInstance(),适用于使用带参数的构造方法


    Class classType = Class.forName("java.lang.String");
    Constructor constructor = classType.getConstructor(new Class[]{String.class});
    constructor.setAccessible(true);
    String employee3 = (String) constructor.newInstance(new Object[]{"123"});

    4.3 创建数组


    创建数组需要元素的 Class 对象作为 ComponentType:



    • 1、创建一维数组


    Class classType = Class.forName("java.lang.String");
    String[] array = (String[]) Array.newInstance(classType, 5); 长度为5
    Array.set(array, 3, "abc"); 设置元素
    String string = (String) Array.get(array,3); 读取元素


    • 2、创建多维数组


    Class[] dimens = {3, 3};
    Class[][] array = (Class[][]) Array.newInstance(int.class, dimens);

    4.3 访问字段、方法


    Editting...


    4.4 获取泛型信息


    我们知道,编译期会进行类型擦除,Code 属性中的类型信息会被擦除,但是在类常量池属性(Signature属性、LocalVariableTypeTable属性)中还保留着泛型信息,因此我们可以通过反射来获取这部分信息。在这篇文章里,我们详细讨论:《Java | 关于泛型能问的都在这里了(含Kotlin)》,请关注!


    4.5 获取运行时注解信息


    注解是一种添加到声明上的元数据,而RUNTIME注解在类加载后会保存在 Class 对象,可以反射获取。在这篇文章里,我们详细讨论:《Java | 这是一篇全面的注解使用攻略(含 Kotlin)》,请关注!






    作者:彭丑丑
    链接:https://juejin.cn/post/6889833658669072397
    来源:掘金
    著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
    收起阅读 »

    Java | JDK 动态代理的原理其实很简单

    前言 代理模式(Proxy Pattern)也称委托模式(Delegate Pattern),是一种结构型设计模式,也是一项基础设计技巧; 其中,动态代理有很多有意思的应用场景,比如 AOP、日志框架、全局性异常处理、事务处理等。这篇文章,我们主要...
    继续阅读 »

    前言



    • 代理模式(Proxy Pattern)也称委托模式(Delegate Pattern),是一种结构型设计模式,也是一项基础设计技巧;

    • 其中,动态代理有很多有意思的应用场景,比如 AOP、日志框架、全局性异常处理、事务处理等。这篇文章,我们主要讨论最基本的 JDK 动态代理。




    目录





    前置知识


    这篇文章的内容会涉及以下前置 / 相关知识,贴心的我都帮你准备好了,请享用~





    1. 概述



    • 什么是代理 (模式)? 代理模式 (Proxy Pattern) 也称委托模式 (Deletage Pattern),属于结构型设计模式,也是一项基本的设计技巧。通常,代理模式用于处理两种问题:

      • 1、控制对基础对象的访问

      • 2、在访问基础对象时增加额外功能



    这是两种非常朴素的场景,正因如此,我们常常会觉得其它设计模式中存在代理模式的影子。UML 类图和时序图如下:





    • 代理的基本分类: 静态代理 + 动态代理,分类的标准是 “代理关系是否在编译期确定;


    • 动态代理的实现方式: JDK、CGLIB、Javassist、ASM





    2. 静态代理


    2.1 静态代理的定义


    静态代理是指代理关系在编译期确定的代理模式。使用静态代理时,通常的做法是为每个业务类抽象一个接口,对应地创建一个代理类。举个例子,需要给网络请求增加日志打印:


    1、定义基础接口
    public interface HttpApi {
    String get(String url);
    }

    2、网络请求的真正实现
    public class RealModule implements HttpApi {
    @Override
    public String get(String url) {
    return "result";
    }
    }

    3、代理类
    public class Proxy implements HttpApi {
    private HttpApi target;

    Proxy(HttpApi target) {
    this.target = target;
    }

    @Override
    public String get(String url) {
    // 扩展的功能
    Log.i("http-statistic", url);
    // 访问基础对象
    return target.get(url);
    }
    }

    2.2 静态代理的缺点



    • 1、重复性: 需要代理的业务或方法越多,重复的模板代码越多;

    • 2、脆弱性: 一旦改动基础接口,代理类也需要同步修改(因为代理类也实现了基础接口)。




    3. 动态代理


    3.1 动态代理的定义


    动态代理是指代理关系在运行时确定的代理模式。需要注意,JDK 动态代理并不等价于动态代理,前者只是动态代理的实现之一,其它实现方案还有:CGLIB 动态代理、Javassist 动态代理和 ASM 动态代理等。因为代理类在编译前不存在,代理关系到运行时才能确定,因此称为动态代理。


    3.2 JDK 动态代理示例


    我们今天主要讨论JDK 动态代理(Dymanic Proxy API),它是 JDK1.3 中引入的特性,核心 API 是 Proxy 类和 InvocationHandler 接口。它的原理是利用反射机制在运行时生成代理类的字节码。


    我们继续用打印日志的例子,使用动态代理时:


    public class ProxyFactory {
    public static HttpApi getProxy(HttpApi target) {
    return (HttpApi) Proxy.newProxyInstance(
    target.getClass().getClassLoader(),
    target.getClass().getInterfaces(),
    new LogHandler(target));
    }

    private static class LogHandler implements InvocationHandler {
    private HttpApi target;

    LogHandler(HttpApi target) {
    this.target = target;
    }
    // method底层的方法无参数时,args为空或者长度为0
    @Override
    public Object invoke(Object proxy, Method method, @Nullable Object[] args)
    throws Throwable
    {
    // 扩展的功能
    Log.i("http-statistic", (String) args[0]);
    // 访问基础对象
    return method.invoke(target, args);
    }
    }
    }

    如果需要兼容多个业务接口,可以使用泛型:


    public class ProxyFactory {
    @SuppressWarnings("unchecked")
    public static T getProxy(T target) {
    return (T) Proxy.newProxyInstance(
    target.getClass().getClassLoader(),
    target.getClass().getInterfaces(),
    new LogHandler(target));
    }

    private static class LogHandler implements InvocationHandler {
    // 同上
    }
    }

    客户端调用:


    HttpAPi proxy = ProxyFactory.getProxy(target);
    OtherHttpApi proxy = ProxyFactory.getProxy(otherTarget);

    通过泛型参数传递不同的类型,客户端可以按需实例化不同类型的代理对象。基础接口的所有方法都统一到 InvocationHandler#invoke() 处理。静态代理的两个缺点都得到解决:



    • 1、重复性:即使有多个基础业务需要代理,也不需要编写过多重复的模板代码;

    • 2、脆弱性:当基础接口变更时,同步改动代理并不是必须的。


    3.3 静态代理 & 动态代理对比



    • 共同点:两种代理模式实现都在不改动基础对象的前提下,对基础对象进行访问控制和扩展,符合开闭原则。

    • 不同点:静态代理存在重复性和脆弱性的缺点;而动态代理(搭配泛型参数)可以实现了一个代理同时处理 N 种基础接口,一定程度上规避了静态代理的缺点。从原理上讲,静态代理的代理类 Class 文件在编译期生成,而动态代理的代理类 Class 文件在运行时生成,代理类在 coding 阶段并不存在,代理关系直到运行时才确定。




    4. JDK 动态代理源码分析


    这一节,我们来分析 JDK 动态代理的源码,核心类是 Proxy,主要分析 Proxy 如何生成代理类,以及如何将方法调用统一分发到 InvocationHandler 接口。


    4.1 API 概述


    Proxy 类主要包括以下 API:



























    Proxy 描述
    getProxyClass(ClassLoader, Class...) : Class 获取实现目标接口的代理类 Class 对象
    newProxyInstance(ClassLoader,Class[],InvocationHandler) : Object 获取实现目标接口的代理对象
    isProxyClass(Class) : boolean 判断一个 Class 对象是否属于代理类
    getInvocationHandler(Object) : InvocationHandler 获取代理对象内部的 InvocationHandler

    4.2 核心源码


    Proxy.java


    1、获取代理类 Class 对象
    public static Class getProxyClass(ClassLoader loader,Class... interfaces){
    final Class[] intfs = interfaces.clone();
    ...
    1.1 获得代理类 Class 对象
    return getProxyClass0(loader, intfs);
    }

    2、实例化代理类对象
    public static Object newProxyInstance(ClassLoader loader,Class[] interfaces,InvocationHandler h){
    ...
    final Class[] intfs = interfaces.clone();
    2.1 获得代理类 Class对象
    Class cl = getProxyClass0(loader, intfs);
    ...
    2.2 获得代理类构造器 (接收一个 InvocationHandler 参数)
    // private static final Class[] constructorParams = { InvocationHandler.class };
    final Constructor cons = cl.getConstructor(constructorParams);
    final InvocationHandler ih = h;
    ...
    2.3 反射创建实例
    return newInstance(cons, ih);
    }

    可以看到,实例化代理对象也需要先通过 getProxyClass0(...) 获取代理类 Class 对象,而 newProxyInstance(...) 随后会获取参数为 InvocationHandler 的构造函数实例化一个代理类对象。


    我们先看下代理类 Class 对象是如何获取的:


    Proxy.java


    -> 1.12.1 获得代理类 Class对象
    private static Class getProxyClass0(ClassLoader loader,Class... interfaces) {
    ...
    从缓存中获取代理类,如果缓存未命中,则通过ProxyClassFactory生成代理类
    return proxyClassCache.get(loader, interfaces);
    }

    private static final class ProxyClassFactory implements BiFunction[], Class>{

    3.1 代理类命名前缀
    private static final String proxyClassNamePrefix = "$Proxy";

    3.2 代理类命名后缀,从 0 递增(原子 Long)
    private static final AtomicLong nextUniqueNumber = new AtomicLong();

    @Override
    public Class apply(ClassLoader loader, Class[] interfaces)
    {
    Map, Boolean> interfaceSet = new IdentityHashMap<>(interfaces.length);
    3.3 参数校验
    for (Class intf : interfaces) {
    // 验证参数 interfaces 和 ClassLoder 中加载的是同一个类
    // 验证参数 interfaces 是接口类型
    // 验证参数 interfaces 中没有重复项
    // 否则抛出 IllegalArgumentException
    }
    // 验证所有non-public接口来自同一个包

    3.4(一般地)代理类包名
    // public static final String PROXY_PACKAGE = "com.sun.proxy";
    String proxyPkg = ReflectUtil.PROXY_PACKAGE + ".";

    3.5 代理类的全限定名
    long num = nextUniqueNumber.getAndIncrement();
    String proxyName = proxyPkg + proxyClassNamePrefix + num;

    3.6 生成字节码数据
    byte[] proxyClassFile = ProxyGenerator.generateProxyClass(proxyName, interfaces);

    3.7 从字节码生成 Class 对象
    return defineClass0(loader, proxyName,proxyClassFile, 0, proxyClassFile.length);
    }
    }

    -> 3.6 生成字节码数据
    public static byte[] generateProxyClass(final String var0, Class[] var1) {
    ProxyGenerator var2 = new ProxyGenerator(var0, var1);
    ...
    final byte[] var3 = var2.generateClassFile();
    return var3;
    }

    ProxyGenerator.java


    private byte[] generateClassFile() {
    3.6.1 只代理Object的hashCode、equals和toString
    this.addProxyMethod(hashCodeMethod, Object.class);
    this.addProxyMethod(equalsMethod, Object.class);
    this.addProxyMethod(toStringMethod, Object.class);

    3.6.2 代理接口的每个方法
    ...
    for(var1 = 0; var1 < this.interfaces.length; ++var1) {
    ...
    }

    3.6.3 添加带有 InvocationHandler 参数的构造器
    this.methods.add(this.generateConstructor());
    var7 = this.proxyMethods.values().iterator();
    while(var7.hasNext()) {
    ...
    3.6.4 在每个代理的方法中调用InvocationHandler#invoke()
    }

    3.6.5 输出字节流
    ByteArrayOutputStream var9 = new ByteArrayOutputStream();
    DataOutputStream var10 = new DataOutputStream(var9);
    ...
    return var9.toByteArray();
    }

    以上代码已经非常简化了,主要关注核心流程:JDK 动态代理生成的代理类命名为 com.sun.proxy$Proxy[从0开始的数字](例如:com.sun.proxy$Proxy0),这个类继承自 java.lang.reflect.Proxy。其内部还有一个参数为 InvocationHandler 的构造器,对于代理接口的方法调用都会分发到 InvocationHandler#invoke()。


    UML 类图如下,需要注意图中红色箭头,表示代理类和 HttpApi 接口的代理关系在运行时才确定:




    提示: Android 系统中生成字节码和从字节码生成 Class 对象的步骤都是 native 方法:



    • private static native Class generateProxy(…)

    • 对应的native方法:dalvik/vm/native/java_lang_reflect_Proxy.cpp



    4.3 查看代理类源码


    可以看到,ProxyGenerator#generateProxyClass() 其实是一个静态 public 方法,所以我们直接调用,并将代理类 Class 的字节流写入磁盘文件,使用 IntelliJ IDEA 的反编译功能查看源代码。


    输出字节码:


    byte[] classFile = ProxyGenerator.generateProxyClass("$proxy0",new Class[]{HttpApi.class});
    // 直接写入项目路径下,方便使用IntelliJ IDEA的反编译功能
    String path = "/Users/pengxurui/IdeaProjects/untitled/src/proxy/HttpApi.class";
    try(FileOutputStream fos = new FileOutputStream(path)){
    fos.write(classFile);
    fos.flush();
    System.out.println("success");
    } catch (Exception e){
    e.printStackTrace();
    System.out.println("fail");
    }

    反编译结果:


    public final class $proxy0 extends Proxy implements HttpApi {
    //反射的元数据Method存储起来,避免重复创建
    private static Method m1;
    private static Method m2;
    private static Method m3;
    private static Method m0;

    public $proxy0(InvocationHandler var1) throws {
    super(var1);
    }

    /**
    * Object#hashCode()
    * Object#equals(Object)
    * Object#toString()
    */


    // 实现了HttpApi接口
    public final String get() throws {
    try {
    //转发到Invocation#invoke()
    return (String)super.h.invoke(this, m3, (Object[])null);
    } catch (RuntimeException | Error var2) {
    throw var2;
    } catch (Throwable var3) {
    throw new UndeclaredThrowableException(var3);
    }
    }

    static {
    try {
    //Object#hashCode()
    //Object#equals(Object)
    //Object#toString()
    m3 = Class.forName("HttpApi").getMethod("get");
    } catch (NoSuchMethodException var2) {
    throw new NoSuchMethodError(var2.getMessage());
    } catch (ClassNotFoundException var3) {
    throw new NoClassDefFoundError(var3.getMessage());
    }
    }
    }

    4.4 常见误区



    • 基础对象必须实现基础接口,否则不能使用动态代理


    这个想法可能来自于一些没有实现任何接口的类,因此就没有办法得到接口的Class对象作为Proxy#newProxyInstance() 的参数,这确实会带来一些麻烦,举个例子:


    package com.domain;
    public interface HttpApi {
    String get();
    }

    // 另一个包的non-public接口
    package com.domain.inner;
    /**non-public**/interface OtherHttpApi{
    String get();
    }

    package com.domain.inner;
    // OtherHttpApiImpl类没有实现HttpApi接口或者没有实现任何接口
    public class OtherHttpApiImpl /**extends OtherHttpApi**/{
    public String get() {
    return "result";
    }
    }

    // Client:
    HttpApi api = (HttpApi) Proxy.newProxyInstance(...}, new InvocationHandler() {
    OtherHttpApiImpl impl = new OtherHttpApiImpl();

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
    // TODO:扩展的新功能
    // IllegalArgumentException: object is not an instance of declaring class
    return method.invoke(impl,args);
    }
    });
    api.get();

    在这个例子里,OtherHttpApiImpl 类因为历史原因没有实现 HttpApi 接口,虽然方法签名与 HttpApi 接口的方法签名完全相同,但是遗憾,无法完成代理。也有补救的办法,找到 HttpApi 接口中签名相同的 Method,使用这个 Method 来转发调用。例如:


    HttpApi api = (HttpApi) Proxy.newProxyInstance(...}, new InvocationHandler() {
    OtherHttpApiImpl impl = new OtherHttpApiImpl();

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
    // TODO:扩展的新功能
    if (method.getDeclaringClass() != impl.getClass()) {
    // 找到相同签名的方法
    Method realMethod = impl.getClass().getDeclaredMethod(method.getName(), method.getParameterTypes());
    return realMethod.invoke(impl, args);
    }else{
    return method.invoke(impl,args);
    }
    }
    });



    5. 总结


    今天,我们讨论了静态代理和动态代理两种代理模式,静态代理在设计模式中随处可见,但存在重复性和脆弱性的缺点,动态代理的代理关系在运行时确定,可以实现一个代理处理 N 种基础接口,一定程度上规避了静态代理的缺点。在我们熟悉的一个网络请求框架中,就充分利用了动态代理的特性,你知道是在说哪个框架吗?




    参考资料







    作者:彭丑丑
    链接:https://juejin.cn/post/6974018412158664734
    来源:掘金
    著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
    收起阅读 »

    这一次,彻底搞懂SparseArray实现原理

    最近在整理SparseArray这一知识点的时候,发现网上大多数SparseArray原理分析的文章都存在很多问题(可以说很多作者并没有读懂SparseArray的源码),也正因此,才有了这篇文章。我们知道,SparseArray与ArrayMap是Andro...
    继续阅读 »

    最近在整理SparseArray这一知识点的时候,发现网上大多数SparseArray原理分析的文章都存在很多问题(可以说很多作者并没有读懂SparseArray的源码),也正因此,才有了这篇文章。我们知道,SparseArray与ArrayMap是Android中高效存储K-V的数据结构,也是是Android面试中的常客,弄懂它们的实现原理是很有必要的,本篇文章就以SparseArray的源码为例进行深入分析。


    一、SparseArray的类结构


    SparseArray可以翻译为稀疏数组,从字面上可以理解为松散不连续的数组。虽然叫做Array,但它却是存储K-V的一种数据结构。其中Key只能是int类型,而Value是Object类型。我们来看下它的类结构:


    public class SparseArray<E> implements Cloneable {
    // 用来标记此处的值已被删除
    private static final Object DELETED = new Object();
    // 用来标记是否有元素被移除
    private boolean mGarbage = false;
    // 用来存储key的集合
    private int[] mKeys;
    // 用来存储value的集合
    private Object[] mValues;
    // 存入的元素个数
    private int mSize;

    // 默认初始容量为10
    public SparseArray() {
    this(10);
    }

    public SparseArray(int initialCapacity) {
    if (initialCapacity == 0) {
    mKeys = EmptyArray.INT;
    mValues = EmptyArray.OBJECT;
    } else {
    mValues = ArrayUtils.newUnpaddedObjectArray(initialCapacity);
    mKeys = new int[mValues.length];
    }
    mSize = 0;
    }

    // ...省略其他代码

    }

    可以看到SparseArray仅仅实现了Cloneable接口并没有实现Map接口,并且SparseArray内部维护了一个int数组和一个Object数组。在无参构造方法中调用了有参构造,并将其初始容量设置为了10。


    二、SparseArray的remove()方法


    是不是觉得很奇怪?作为一个容器类,不先讲put方法怎么先将remove呢?这是因为remove方法的一些操作会影响到put的操作。只有先了解了remove才能更容易理解put方法。我们来看remove的代码:



    // SparseArray
    public void remove(int key) {
    delete(key);
    }

    public void delete(int key) {
    int i = ContainerHelpers.binarySearch(mKeys, mSize, key);

    if (i >= 0) {
    if (mValues[i] != DELETED) {
    mValues[i] = DELETED;
    mGarbage = true;
    }
    }
    }

    可以看到remove方法直接调用了delete方法。而在delete方法中会先通过二分查找(二分查找代码后边分析)找到key所在的位置,然后将这一位置的value值置为DELETE,注意,这里还将mGarbage设置为了true来标记集合中存在删除元素的情况。想象一下,在删除多个元素后这个集合中是不是就可能会出现不连续的情况?大概这也是SparseArray名字的由来吧。


    三、SparseArray的put()方法


    作为一个存储K-V类型的数据结构,put方法是key和value的入口。也是SparseArray中最重要的一个方法。先来看下put方法的代码:


    // SparseArray
    public void put(int key, E value) {
    int i = ContainerHelpers.binarySearch(mKeys, mSize, key);

    if (i >= 0) { // 意味着之前mKeys中已经有对应的key存在了,第i个位置对应的就是key。
    mValues[i] = value; // 直接更新value
    } else { // 返回负数说明未在mKeys中查找到key

    // 取反得到待插入key的位置
    i = ~i;

    // 如果插入位置小于size,并且这个位置的value刚好是被删除掉的,那么直接将key和value分别插入mKeys和mValues的第i个位置
    if (i < mSize && mValues[i] == DELETED) {
    mKeys[i] = key;
    mValues[i] = value;
    return;
    }
    // mGarbage为true说明有元素被移除了,此时mKeys已经满了,但是mKeys内部有被标记为DELETE的元素
    if (mGarbage && mSize >= mKeys.length) {
    // 调用gc方法移动mKeys和mValues中的元素,这个方法可以后边分析
    gc();

    // 由于gc方法移动了数组,因此插入位置可能有变化,所以需要重新计算插入位置
    i = ~ContainerHelpers.binarySearch(mKeys, mSize, key);
    }
    // GrowingArrayUtils的insert方法将会将插入位置之后的所有数据向后移动一位,然后将key和value分别插入到mKeys和mValue对应的第i个位置,如果数组空间不足还会开启扩容,后边分析这个insert方法
    mKeys = GrowingArrayUtils.insert(mKeys, mSize, i, key);
    mValues = GrowingArrayUtils.insert(mValues, mSize, i, value);
    mSize++;
    }
    }

    虽然这个方法只有寥寥数行,但是想要完全理解却并非易事,即使写了很详细的注释也不容易读懂。我们不妨来详细分析一下。第一行代码通过二分查找得到了一个index。看下二分查找的代码:


    // ContainerHelpers
    static int binarySearch(int[] array, int size, int value) {
    int lo = 0;
    int hi = size - 1;

    while (lo <= hi) {
    final int mid = (lo + hi) >>> 1;
    final int midVal = array[mid];

    if (midVal < value) {
    lo = mid + 1;
    } else if (midVal > value) {
    hi = mid - 1;
    } else {
    return mid; // value found
    }
    }
    return ~lo; // value not present
    }

    关于二分查找相信大家都是比较熟悉的,这一算法用于在一组有序数组中查找某一元素所在位置的。如果数组中存在这一元素,则将这个元素对应的位置返回。如果不存在那么此时的lo就是这个元素的最佳存储位置。上述代码中将lo取反作为了返回值。因为lo一定是大于等于0的数,因此取反后的返回值必定小于等于0.明白了这一点,再来看put方法中的这个if...else是不是很容易理解了?


    // SparseArray
    public void put(int key, E value) {

    if (i >= 0) {
    mValues[i] = value; // 直接更新value
    } else {
    i = ~i;
    // ... 省略其它代码
    }
    }

    如果i>=0,意味着当前的这个key已经存在于mKeys中了,那么此时put只需要将最新的value更新到mValues中即可。而如果i<=0就意味着mKeys中之前没有对应的key。因此就需要将key和value分别插入到mKeys和mValues中。而插入的最佳位置就是对i取反。


    得到插入位置之后,如果这个位置是被标记为删除的元素,那么久可以直接将其覆盖掉了,因此有以下代码:


    public void put(int key, E value) {
    // ...
    if (i >= 0) {
    // ...
    } else {
    // 如果i对应的位置是被删除掉的,可以直接将其覆盖
    if (i < mSize && mValues[i] == DELETED) {
    mKeys[i] = key;
    mValues[i] = value;
    return;
    }
    // ...
    }

    }

    如果上边条件不满足,那么继续往下看:


    public void put(int key, E value) {
    // ...
    if (i >= 0) {
    // ...
    } else {
    // mGarbage为true说明有元素被移除了,此时mKeys已经满了,但是mKeys内部有被标记为DELETE的元素
    if (mGarbage && mSize >= mKeys.length) {
    // 调用gc方法移动mKeys和mValues中的元素,这个方法可以后边分析
    gc();

    // 由于gc方法移动了数组,因此插入位置可能有变化,所以需要重新计算插入位置
    i = ~ContainerHelpers.binarySearch(mKeys, mSize, key);
    }
    // ...
    }

    }

    上边我们已经知道,在remove元素的时候mGarbage会被置为true,这段代码意味着有被移除的元素,被移除的位置并不是要插入的位置,并且如果mKeys已经满了,那么就调用gc方法来移动元素填充被移除的位置。由于mKeys中元素位置发生了变化,因此key插入的位置也可能改变,因此需要再次调用二分法来查找key的插入位置。


    以上代码最终会确定key被插入的位置,接下来调用GrowingArrayUtils的insert方法来进行key的插入操作:


    // SparseArray
    public void put(int key, E value) {
    int i = ContainerHelpers.binarySearch(mKeys, mSize, key);

    if (i >= 0) {
    // ...
    } else {
    // ...

    // GrowingArrayUtils的insert方法将会将插入位置之后的所有数据向后移动一位,然后将key和value分别插入到mKeys和mValue对应的第i个位置,如果数组空间不足还会开启扩容,后边分析这个insert方法
    mKeys = GrowingArrayUtils.insert(mKeys, mSize, i, key);
    mValues = GrowingArrayUtils.insert(mValues, mSize, i, value);
    mSize++;
    }
    }

    GrowingArrayUtils的insert方法代码如下:


    // GrowingArrayUtils
    public static <T> T[] insert(T[] array, int currentSize, int index, T element) {
    assert currentSize <= array.length;
    // 如果插入后数组size小于数组长度,能进行插入操作
    if (currentSize + 1 <= array.length) {
    // 将index之后的所有元素向后移动一位
    System.arraycopy(array, index, array, index + 1, currentSize - index);
    // 将key插入到index的位置
    array[index] = element;
    return array;
    }

    // 来到这里说明数组已满,需需要进行扩容操作。newArray即为扩容后的数组
    T[] newArray = ArrayUtils.newUnpaddedArray((Class<T>)array.getClass().getComponentType(),
    growSize(currentSize));
    System.arraycopy(array, 0, newArray, 0, index);
    newArray[index] = element;
    System.arraycopy(array, index, newArray, index + 1, array.length - index);
    return newArray;
    }

    // 返回扩容后的size
    public static int growSize(int currentSize) {
    return currentSize <= 4 ? 8 : currentSize * 2;
    }

    insert方法的代码比较容易理解,如果数组容量足够,那么就将index之后的元素向后移动一位,然后将key插入index的位置。如果数组容量不足,那么则需要进行扩容,然后再进行插入操作。


    四、SparseArray的gc()方法


    这个方法其实很容易理解,我们知道Java虚拟机在内存不足时会进行GC操作,标记清除法在回收垃圾对象后为了避免内存碎片化,会将存活的对象向内存的一端移动。而SparseArray中的这个gc方法其实就是借鉴了垃圾收集整理碎片空间的思想。


    关于mGarbage这个参数上边已经有提到过了,这个变量会在删除元素的时候被置为true。如下:


    // SparseArray中所有移除元素的方法中都将mGarbage置为true

    public E removeReturnOld(int key) {
    int i = ContainerHelpers.binarySearch(mKeys, mSize, key);

    if (i >= 0) {
    if (mValues[i] != DELETED) {
    final E old = (E) mValues[i];
    mValues[i] = DELETED;
    mGarbage = true;
    return old;
    }
    }
    return null;
    }

    public void delete(int key) {
    int i = ContainerHelpers.binarySearch(mKeys, mSize, key);

    if (i >= 0) {
    if (mValues[i] != DELETED) {
    mValues[i] = DELETED;
    mGarbage = true;
    }
    }
    }


    public void removeAt(int index) {
    if (index >= mSize && UtilConfig.sThrowExceptionForUpperArrayOutOfBounds) {
    throw new ArrayIndexOutOfBoundsException(index);
    }
    if (mValues[index] != DELETED) {
    mValues[index] = DELETED;
    mGarbage = true;
    }
    }



    而SparseArray中所有插入和查找元素的方法中都会判断如果mGarbage为true,并且mSize >= mKeys.length时调用gc,以append方法为例,代码如下:


    public void append(int key, E value) {

    if (mGarbage && mSize >= mKeys.length) {
    gc();
    }

    // ... 省略无关代码
    }

    源码中调用gc方法的地方多达8处,都是与添加和查找元素相关的方法。例如put()、keyAt()、setValueAt()等方法中。gc的实现其实比较简单,就是将删除位置后的所有数据向前移动一下,代码如下:


    private void gc() {
    // Log.e("SparseArray", "gc start with " + mSize);

    int n = mSize;
    int o = 0;
    int[] keys = mKeys;
    Object[] values = mValues;

    for (int i = 0; i < n; i++) {
    Object val = values[i];

    if (val != DELETED) {
    if (i != o) {
    keys[o] = keys[i];
    values[o] = val;
    values[i] = null;
    }

    o++;
    }
    }

    mGarbage = false;
    mSize = o;

    // Log.e("SparseArray", "gc end with " + mSize);
    }

    五、SparseArray的get()方法


    这个方法就比较简单了,因为put的时候是维持了一个有序数组,因此通过二分查找可以直接确定key在数组中的位置。


    public E get(int key, E valueIfKeyNotFound) {
    int i = ContainerHelpers.binarySearch(mKeys, mSize, key);

    if (i < 0 || mValues[i] == DELETED) {
    return valueIfKeyNotFound;
    } else {
    return (E) mValues[i];
    }
    }

    六、总结


    可见SparseArray是一个使用起来很简单的数据结构,但是它的原理理解起来似乎却没那么容易。这也是网上大部分文章对应SparseArray的解析都是含糊不清的原因。相信通过本篇文章的学习一定对SparseArray的实现有了新的认识!


    作者:我赌一包辣条
    链接:https://juejin.cn/post/6972985532397649933
    来源:掘金
    著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
    收起阅读 »