少年,你可知 Kotlin 协程最初的样子?
如果有人问你,怎么开启一个 Kotlin 协程?你可能会说通过runBlocking/launch/async,回答没错,这几个函数都能开启协程。不过这次咱们换个角度分析,通过提取这几个函数的共性,看看他们内部是怎么开启一个协程的。
相信通过本篇,你将对协程原理有个深刻的认识。
文章目录:
1、suspend 关键字背后的原理
2、如何开启一个原始的协程?
3、协程调用以及整体流程
4、协程代码我为啥看不懂?
1、suspend 关键字背后的原理
suspend 修饰函数
普通的函数
fun launchEmpty(block: () -> Unit) {
}
定义一个函数,形参为函数类型。
查看反编译结果:
public final class CoroutineRawKt {
public static final void launchEmpty(@NotNull Function0 block) {
}
}
可以看出,在JVM 平台函数类型参数最终是用匿名内部类表示的,而FunctionX(X=0~22) 是Kotlin 将函数类型映射为Java 的接口。
来看看Function0 的定义:
public interface Function0<out R> : Function<R> {
/** Invokes the function. */
public operator fun invoke(): R
}
有一个唯一的方法:invoke(),它没有任何参数。
可作如下调用:
fun launchEmpty(block: () -> Unit) {
block()//与block.invoke()等价
}
fun main(array: Array<String>) {
launchEmpty {
println("I am empty")
}
}
带suspend 的函数
以上写法大家都比较熟悉了,就是典型的高阶函数的定义和调用。
现在来改造一下函数类型的修饰符:
fun launchEmpty1(block: suspend () -> Unit) {
}
相较之前,加了"suspend"关键字。
老规矩,查看反编译结果:
public static final void launchEmpty1(@NotNull Function1 block) {
}
参数从Function0 变为了Function1:
/** A function that takes 1 argument. */
public interface Function1<in P1, out R> : Function<R> {
/** Invokes the function with the specified argument. */
public operator fun invoke(p1: P1): R
}
Function1 的invoke()函数多了一个入参。
也就是说,加了suspend 修饰后,函数会默认加个形参。
当我们调用suspend修饰的函数时:
"suspend"修饰的函数只能在协程里被调用或者是在另一个被"suspend"修饰的函数里调用。
suspend 作用
何为挂起
suspend 意为挂起、阻塞的意思,与协程相关。
当suspend 修饰函数时,表明这个函数可能会被挂起,至于是否被挂起取决于该函数里是否有挂起动作。 比如:
suspend fun testSuspend() {
println("test suspend")
}
这样的写法没意义,因为函数没有实现挂起功能。
你可能会说,挂起需要切换线程,好嘛,换个写法:
suspend fun testSuspend() {
println("test suspend")
thread {
println("test suspend in thread")
}
}
然而并没啥用,编译器依然提示:
意思是可以不用suspend 修饰,没啥意义。
挂起于协程的意义
第一点
当函数被suspend 修饰时,表明协程执行到此可能会被挂起,若是被挂起那么意味着协程将无法再继续往下执行,直到条件满足恢复了协程的运行。
fun main(array: Array<String>) {
GlobalScope.launch {
println("before suspend")//①
testSuspend()//挂起函数②
println("after suspend")//③
}
}
执行到②时,协程被挂起,将不会执行③,直到协程被恢复后才会执行③。
注:关于协程挂起的生动理解&线程的挂起 下篇将着重分析。
第二点
如果将suspend 修饰的函数类型看做一个整体的话:
suspend () -> T
无参,返回值为泛型。
Kotlin 里定义了一些扩展函数,可用来开启协程。
第三点 suspend 修饰的函数类型,当调用者实现其函数体时,传入的实参将会继承自SuspendLambda(这块下个小结详细分析)。
2、如何开启一个原始的协程?
##launch/async/runBlocking 如何开启协程
纵观这几种主流的开启协程方式,它们最终都会调用到:
#CoroutineStart.kt
public operator fun <R, T> invoke(block: suspend R.() -> T, receiver: R, completion: Continuation<T>): Unit =
when (this) {
DEFAULT -> block.startCoroutineCancellable(receiver, completion)
ATOMIC -> block.startCoroutine(receiver, completion)
UNDISPATCHED -> block.startCoroutineUndispatched(receiver, completion)
LAZY -> Unit // will start lazily
}
无论走哪个分支,都是调用block的函数,而block 就是我们之前说的被suspend 修饰的函数。
以DEFAULT 为例startCoroutineUndispatched接下来会调用到IntrinsicsJvm.kt里的:
#IntrinsicsJvm.kt
public actual fun <R, T> (suspend R.() -> T).createCoroutineUnintercepted(
receiver: R,
completion: Continuation<T>
)
该函数带了俩参数,其中的receiver 为接收者,而completion 为协程结束后调用的回调。
为了简单,我们可以省略掉receiver。
刚好IntrinsicsJvm.kt 里还有另一个函数:
#IntrinsicsJvm.kt
public actual fun <T> (suspend () -> T).createCoroutineUnintercepted(
completion: Continuation<T>
): Continuation<Unit>
createCoroutineUnintercepted 为 (suspend () -> T) 类型的扩展函数,因此只要我们的变量为 (suspend () -> T)类型就可以调用createCoroutineUnintercepted(xx)函数。
查找该函数的使用之处,发现Continuation.kt 文件里不少扩展函数都调用了它。
如:
#Continuation.kt
//创建协程的函数
public fun <T> (suspend () -> T).createCoroutine(
completion: Continuation<T>
): Continuation<Unit> =
SafeContinuation(createCoroutineUnintercepted(completion).intercepted(), COROUTINE_SUSPENDED)
其中Continuation 为接口:
#Continuation.kt
interface Continuation<in T> {
//协程上下文
public val context: CoroutineContext
//恢复协程
public fun resumeWith(result: Result<T>)
}
Continuation 接口很重要,协程里大部分的类都实现了该接口,通常直译过来为:"续体"。
创建完成后,还需要开启协程函数:
#Continuation.kt
//启动协程的函数
public inline fun <T> Continuation<T>.resume(value: T): Unit =
resumeWith(Result.success(value))
简单创建/调用协程
协程创建
由上分析可知,Continuation.kt 里有我们开启协程所需要的一些基本信息,接着来看看如何调用上述函数。
fun <T> launchFish(block: suspend () -> T) {
//创建协程,返回值为SafeContinuation(实现了Continuation 接口)
//入参为Continuation 类型,参数名为completion,顾名思义就是
//协程结束后(正常返回&抛出异常)将会调用它。
var coroutine = block.createCoroutine(object : Continuation<T> {
override val context: CoroutineContext
get() = EmptyCoroutineContext
//协程结束后调用该函数
override fun resumeWith(result: Result<T>) {
println("result:$result")
}
})
//开启协程
coroutine.resume(Unit)
}
定义了函数launchFish,该函数唯一的参数为函数类型参数,被suspend 修饰,而(suspend () -> T)定义一系列扩展函数,createCoroutine 为其中之一,因此block 可以调用createCoroutine。
createCoroutine 返回类型为SafeContinuation,通过SafeContinuation.resume()开启协程。
协程调用
fun main(array: Array<String>) {
launchFish {
println("I am coroutine")
}
}
打印结果:
3、协程调用以及整体流程
协程调用背后的玄机
反编译初窥门径
看到上面的打印大家可能比较晕,"println("I am coroutine")"是咋就被调用的?没看到有调用它的地方啊。
launchFish(block) 接收的是函数类型,当调用launchFish 时,在闭包里实现该函数的函数体即可,我们知道函数类型最终会替换为匿名内部类。
因为kotlin 有不少语法糖,无法一下子直击本质,老规矩,反编译看看结果:
public static final void main(@NotNull String[] array) {
launchFish((Function1)(new Function1((Continuation)null) {
int label;
@Nullable
public final Object invokeSuspend(@NotNull Object var1) {
Object var4 = IntrinsicsKt.getCOROUTINE_SUSPENDED();
switch(this.label) {
case 0:
//闭包里的内容
String var2 = "I am coroutine";
boolean var3 = false;
//打印
System.out.println(var2);
return Unit.INSTANCE;
}
}
@NotNull
public final Continuation create(@NotNull Continuation completion) {
//创建一个Continuation,可以认为是续体
Function1 var2 = new <anonymous constructor>(completion);
return var2;
}
public final Object invoke(Object var1) {
//Function1 接口里的方法
return ((<undefinedtype>)this.create((Continuation)var1)).invokeSuspend(Unit.INSTANCE);
}
}));
}
为了更直观,删除了一些不必要的信息。
看到这,你发现了什么?通常传入函数类型的实参最后将会被编译为对应的匿名内部类,此时应该编译为Function1, 实现其唯一的函数:invoke(xx),而我们发现实际上还多了两个函数:invokeSuspend(xx)与create(xx)。
我们有理由相信,invokeSuspend(xx)函数一定在某个地方被调用了,原因是:闭包里打印的字符串:"I am coroutine" 只在该函数里实现,而我们测试的结果是这个打印执行了。
还记得我们上面说的suspend 意义的第三点吗?
suspend 修饰的函数类型,其实参是匿名内部类,继承自抽象类:SuspendLambda。
也就是说invokeSuspend(xx)与create(xx) 的定义很有可能来自SuspendLambda,我们接着来分析它。
SuspendLambda 关系链
#ContinuationImpl.kt
internal abstract class SuspendLambda(
public override val arity: Int,
completion: Continuation<Any?>?
) : ContinuationImpl(completion), FunctionBase<Any?>, SuspendFunction {
constructor(arity: Int) : this(arity, null)
...
}
该类本身并没有太多内容,此处继承了ContinuationImpl类,查看该类也没啥特殊的,继续往上查找,找到BaseContinuationImpl类,在里面发现了线索:
#ContinuationImpl.kt
internal abstract class BaseContinuationImpl(
val completion: Continuation<Any?>?
) : Continuation<Any?>, CoroutineStackFrame, Serializable {
protected abstract fun invokeSuspend(result: Result<Any?>): Any?
open fun create(completion: Continuation<*>): Continuation<Unit> {
}
}
终于看到了眼熟的:invokeSuspend(xx)与create(xx)。
我们再回过头来捋一下类之间关系:
- 实现了Function1 接口,并实现了该接口里的invoke函数。
- 继承了SuspendLambda,并重写了invokeSuspend函数和create函数。
你可能会说还不够直观,那好,继续改写一下:
class MyAnonymous extends SuspendLambda implements Function1 {
int label;
public final Object invokeSuspend(@NotNull Object var1) {
Object var4 = IntrinsicsKt.getCOROUTINE_SUSPENDED();
switch(this.label) {
case 0:
String var2 = "I am coroutine";
boolean var3 = false;
System.out.println(var2);
return Unit.INSTANCE;
}
}
public final Continuation create(@NotNull Continuation completion) {
Intrinsics.checkNotNullParameter(completion, "completion");
Function1 var2 = new <anonymous constructor>(completion);
return var2;
}
public final Object invoke(Object var1) {
return ((<undefinedtype>)this.create((Continuation)var1)).invokeSuspend(Unit.INSTANCE);
}
}
public static final void launchFish(@NotNull MyAnonymous block) {
Continuation coroutine = ContinuationKt.createCoroutine(block, (new Continuation() {
@NotNull
public CoroutineContext getContext() {
return (CoroutineContext) EmptyCoroutineContext.INSTANCE;
}
public void resumeWith(@NotNull Object result) {
String var2 = "result:" + Result.toString-impl(result);
boolean var3 = false;
System.out.println(var2);
}
}));
//开启
coroutine.resumeWith(Result.constructor-impl(var3));
}
public static final void main(@NotNull String[] array) {
MyAnonymous myAnonymous = new MyAnonymous();
launchFish(myAnonymous);
}
这么看就比较清晰了,此处我们单独声明了一个MyAnonymous类,并构造对象传递给launchFish函数。
闭包的执行
既然匿名类的构造清晰了,接下来分析闭包是如何被执行的,也就是查找invokeSuspend(xx)函数是怎么被调用的?
将目光转移到launchFish 函数本身。
createCoroutine()
先看createCoroutine()函数调用,直接上代码:
#Continuation.kt
fun <T> (suspend () -> T).createCoroutine(
completion: Continuation<T>
): Continuation<Unit> =
//返回SafeContinuation 对象
//SafeContinuation 构造函数需要2个参数,一个是delegate,另一个是协程状态
//此处默认是挂起
SafeContinuation(createCoroutineUnintercepted(completion).intercepted(), COROUTINE_SUSPENDED)
#IntrinsicsJvm.kt
actual fun <T> (suspend () -> T).createCoroutineUnintercepted(
completion: Continuation<T>
): Continuation<Unit> {
val probeCompletion = probeCoroutineCreated(completion)
return if (this is BaseContinuationImpl)
//此处的this 即为匿名内部类对象 MyAnonymous,它间接继承了BaseContinuationImpl
//调用MyAnonymous 重写的create 函数
//create 函数里new 新的MyAnonymous 对象
create(probeCompletion)
else
createCoroutineFromSuspendFunction(probeCompletion) {
(this as Function1<Continuation<T>, Any?>).invoke(it)
}
}
#IntrinsicsJvm.kt
public actual fun <T> Continuation<T>.intercepted(): Continuation<T> =
//判断是否是ContinuationImpl 类型的Continuation
//我们的demo里是true,因此会继续尝试调用拦截器
(this as? ContinuationImpl)?.intercepted() ?: this
#ContinuationImpl.kt
public fun intercepted(): Continuation<Any?> =
//查看是否已经有拦截器,如果没有,则从上下文里找,上下文没有,则用自身,最后赋值。
//在我们的demo里上下文里没有,用的是自身
intercepted
?: (context[ContinuationInterceptor]?.interceptContinuation(this) ?: this)
.also { intercepted = it }
最后得出的Continuation 赋值给SafeContinuation 的成员变量:delegate。
至此,SafeContinuation 对象已经构造完毕,接着继续看如何用它开启协程。
再看 resume()
#SafeContinuationJvm.kt
actual override fun resumeWith(result: Result<T>) {
while (true) { // lock-free loop
val cur = this.result // atomic read
when {
//初始化状态为UNDECIDED,因此直接return
cur === CoroutineSingletons.UNDECIDED -> if (SafeContinuation.RESULT.compareAndSet(this,
CoroutineSingletons.UNDECIDED, result.value)) return
//如果是挂起,将它变为恢复状态,并调用恢复函数
//demo 里初始化状态为COROUTINE_SUSPENDED,因此会走到这
cur === COROUTINE_SUSPENDED -> if (SafeContinuation.RESULT.compareAndSet(this, COROUTINE_SUSPENDED,
CoroutineSingletons.RESUMED)) {
//delegate 为之前创建的Continuation,demo 里因为没有拦截,因此为MyAnonymous
delegate.resumeWith(result)
return
}
else -> throw IllegalStateException("Already resumed")
}
}
}
#ContinuationImpl.kotlin
#BaseContinuationImpl类的成员函数
override fun resumeWith(result: Result<Any?>) {
var current = this
var param = result
while (true) {
probeCoroutineResumed(current)
with(current) {
val completion = completion!!
val outcome: Result<Any?> =
try {
//invokeSuspend 即为MyAnonymous 里的方法
val outcome = invokeSuspend(param)
//如果返回值是挂起状态,则函数直接退出
if (outcome === kotlin.coroutines.intrinsics.COROUTINE_SUSPENDED) return
kotlin.Result.success(outcome)
} catch (exception: Throwable) {
kotlin.Result.failure(exception)
}
releaseIntercepted() // this state machine instance is terminating
if (completion is BaseContinuationImpl) {
current = completion
param = outcome
} else {
//执行到这,最终执行外层的completion,在demo里会输出"result:$result"
completion.resumeWith(outcome)
return
}
}
}
}
最后再回头看 invokeSuspend
public final Object invokeSuspend(@NotNull Object var1) {
Object var4 = IntrinsicsKt.getCOROUTINE_SUSPENDED();
switch(this.label) {
case 0:
ResultKt.throwOnFailure(var1);
String var2 = "I am coroutine";
boolean var3 = false;
System.out.println(var2);
return Unit.INSTANCE;
default:
throw new IllegalStateException("call to 'resume' before 'invoke' with coroutine");
}
}
你兴许已经发现了,此处的返回值永远是Unit.INSTANCE啊,那么协程永远不会挂起。
没有挂起功能的协程就是鸡肋...。
没错,咱们的demo里实现的是一个无法挂起的协程,回到最初的launchFish()的调用:
launchFish {
println("I am coroutine")
}
}
因为闭包里只有一个打印语句,根本没有挂起函数,当然就没有挂起的说法了。
协程调用整体流程
上面花很多篇幅去分析协程的调用,其实就是为了从kotlin 的简洁里脱离出来,从而真正了解其背后的原理。
Demo里的协程构造比较原始,相较于launch/async 等启动方式,它没有上下文、没有线程调度,但并不妨碍我们通过它去了解协程的运作。当我们了解了其运作的核心,到时候再去看launch/async/runBlocking 就非常容易了,毕竟它们都是提供给开发者更方便操作协程的工具,是在原始携程的基础上演变的。
协程创建调用栈简易图:
4、协程代码我为啥看不懂?
之前有一些小伙伴跟我反馈说:"小鱼人,我尝试去看协程源码,感觉找不到入口,又或是跟着源码跟到一半就断了... 你是咋阅读的啊?"
有一说一,协程源码确实不太好懂,若要比较顺畅读懂源码,根据个人经验可能需要以下前置条件:
1、kotlin 语法基础,这是必须的。
2、高阶函数&扩展函数。
3、平台代码差异,有一些类、函数是与平台相关,需要定位到具体平台,比如SafeContinuation,找到Java 平台的文件:SafeContinuationJvm.kt。
4、断点调试时,有些单步断点不会进入,需要指定运行到的位置。
5、有些代码是编译时期构造的,需要对照反编译结果查看。
6、还有些代码是没有源码的,可能是ASM插入的,此时只能靠肉眼理解了。
如果你对kotlin 基础/高阶函数 等有疑惑,请查看之前的文章。
本篇仅仅构造了一个简陋的协程,协程的最重要的挂起/恢复并没有涉及,下篇将会着重分析如何构造一个挂起函数,以及协程到底是怎么挂起的。
本文基于Kotlin 1.5.3,文中完整Demo请点击
作者:小鱼人爱编程
链接:https://juejin.cn/post/7109410972653060109
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
一个小故事讲明白进程、线程、Kotlin 协程到底啥关系?
相信稍微接触过Kotlin的同学都知道Kotlin Coroutine(协程)的大名,甚至有些同学认为重要到"无协程,不Kotlin"的地步,吓得我赶紧去翻阅了协程源码,同时也学习了不少博客,博客里比较典型的几个说法:
- 协程是轻量级线程、比线程耗费资源少
- 协程是线程框架
- 协程效率高于线程
- ...
一堆术语听起来是不是很高端的样子?这些表述正确吗?妥当吗?你说我学了大半天,虽然我也会用,但还是没弄懂啥是协程...
为了彻底弄懂啥是协程,需要将进程、线程拉进来一起pk。
通过本篇文章,你将了解到:
1、程序、进程、CPU、内存关系
2、进程与线程的故事
3、线程与Kotlin协程的故事
4、Kotlin 协程的使命
1、程序、进程、CPU、内存关系
如上图,平时我们打包好一个应用,放在磁盘上,此时我们称之为程序或者应用,是静态的。也就是咱们平常说的:我下载个程序,你传给apk给我,它们都是程序(应用)。
当我们执行程序(比如点击某个App),OS 会将它加载进内存,CPU 从内存某个起始地址开始读取指令并执行程序。
程序从磁盘上加载到内存并被CPU运行期间,称之为进程。因此我们通常说某个应用是否还在存活,实际上说的是进程是否还在内存里;也会说某某程序CPU占用率太高,实际上说的是进程的CPU占用率。
而操作系统负责管理磁盘、内存、CPU等交互,可以说是大管家。
2、进程与线程的故事
接下来我们以一个故事说起。
上古时代的合作
在上古时候,一个设备里只有一个CPU,能力比较弱,单位时间内能够处理的任务量有限,内存比较小,能加载的应用不多,相应的那会儿编写的程序功能单一,结构简单。
OS 说:"大家都知道,我们的情况比较具体,只有一个CPU,内存也很小,而现在有不少应用想要占用CPU和内存,无规矩不成方圆,我现在定下个规矩:"
每个应用加载到内存后,我将给他安排内存里的一块独立的空间,并记录它的一些必要信息,最后规整为一个叫进程的东西,就是代表你这个应用的所有信息,以后我就只管调度进程即可。
并且进程之间的内存空间是隔离的,无法轻易访问,特殊情况需要经过我的允许。
应用(程序)说:"哦,我知道了,意思就是:进程是资源分派的基本单位嘛"
OS 说:"对的,悟性真好,小伙子。"
规矩定下了,大家就开始干活了:
1、应用被加载到内存后,OS分派了一些资源给它。
2、CPU 从内存里逐一取出并执行进程。
3、其它没有得到CPU青睐的进程则静候等待,等待被翻牌。
中古时代的合作
一切都有条不紊的进行着,大家配合默契,其乐融融,直到有一天,OS 发现了一些端倪。
他发现CPU 在偷懒...找到CPU,压抑心中的愤怒说到:
"我发现你最近不是很忙哎,是不是工作量不饱和?"
CPU 忙不迭说到:"冤枉啊,我确实不是很忙,但这不怪我啊。你也知道我最近升级了频率,处理速度快了很多,进程每次给我的任务我都快速执行完了,不过它们却一直占用我,不让我处理其它进程的,我也是没办法啊。"
OS 大吃一惊到:"大胆进程,居然占着茅坑不拉屎!"
CPU 小声到:"我又不是茅坑..."
OS 找来进程劈头盖脸训斥一道:"进程你好大的胆,我之前不是给你说请CPU 做事情要讲究一个原则:按需占有,用完就退。你把我话当耳边风了?"
进程直呼:"此事与我无关啊,你知道的我最讲原则了,你之前说过对CPU 的使用:应占尽占。我现在不仅要处理本地逻辑,还要从磁盘读取文件,这个时候我虽然不占用CPU,但是我后面文件读结束还是需要他。"
OS 眉头紧皱,略微思索了一下对进程和CPU道:"此事前因后果均已知悉,容我斟酌几日。"
几天后,OS 过来对他俩说:"我现在重新拟定一个规则:进程不能一直占用CPU到任务结束为止,需要规定占用的时间片,在规定的时间片内进程能完成多少是多少,时间一到立即退出CPU换另一个进程上,没能完成任务的进程等下个轮到自己的时间片再上"
进程和CPU 对视一眼,立即附和:"谨遵钧令,使命必达!"
近现代的合作
自从实行新规定以来,进程们都有机会抢占CPU了,算是雨露均沾,很少出现某进程长期霸占CPU的现象了,OS 对此很是满意。
一则来自进程的举报打破这黎明前的宁静。
OS 收到一则举报:"我进程实名举报CPU 偷懒。"
OS 心里咯噔一跳,寻思着咋又是CPU,于是叫来CPU 对簿公堂。
CPU 听到OS 召唤,暗叫不妙,心里立马准备了一套说辞。
OS 对着CPU 和 进程说:"进程说你偷懒,你在服务进程的时间片内无所事事,我希望你能给我一个满意的答复。"
CPU 一听这话,心里一阵鄙视,果不出我所料,就知道你问这事。虽然心里诽腹不已,脸上却是郑重其事道:"这事是因为进程交给我的任务很快完成了,它去忙别的事了,让我等等他。"
OS 诧异道:"你这么快就将进程的任务处理完成了?"
CPU 面露得以之色道:"你知道的我一直追求进步,这不前阵子又升级了一下嘛,处理能力又提升了。如果说优秀是一种原罪的话,那这个罪名由我承担吧,再如果..."
OS 看了进程一眼,对CPU 说:"行行行,打住,此事确实与你无关。进程虽然你误会了CPU,但是你提出的问题确实是一个好的思考点,这个下来我想个方案,回头咱们评审一下。"
一个月后,OS 将进程和CPU召集起来,并拿出方案说:"我们这次将进行一次大的调整,鉴于CPU 处理能力提升,他想要承担更多的工作,而目前以进程为单位提交任务颗粒度太大了,需要再细化。我建议将进程划分为若干线程,这些线程共享进程的资源池,进程想要执行某任务直接交给线程即可,而CPU每次以线程为单位执行。接下来,你们说说各自的意见吧。"
进程说到:"这个方案很优秀,相当于我可以弄出分身,让各个分身干各项任务,处理UI一个线程,处理I/O是另一个线程,处理其它任务是其它线程,我只需要分派各个任务给线程,剩下的无需我操心了。CPU 你觉得呢?"
CPU 心底暗道:"你自己倒是简单,只管造分身,脏活累活都是我干..."
表面故作沉重说到:"这个改动有点大,我现在需要直接对接线程,这块需要下来好好研究一下,不过问题不大。"
进程补充道:"CPU 你可以要记清楚了,以后线程是CPU 调度的基本单位了。"
CPU 应道:"好的,好的,了解了(还用你复述OS 的话嘛...)。"
规矩定下了,大家热火朝天地干活。
进程至少有一个线程在运行,其余按需制造线程,多个线程共用进程资源,每个线程都被CPU 执行。
新时代的合作
OS 照例视察各个模块的合作,这天进程又向它抱怨了:"我最近各个线程的数据总是对不上,是不是内存出现了差错?"
OS 愣了一下,说到:"这问题我知道了,还没来得及和你说呢。最近咱们多放了几个CPU 模块提升设备的整体性能,你的线程可能在不同的CPU上运行,因此拿到的数据有点问题。"
进程若有所思道:"以前只有一个CPU,各个进程看似同时运行,实则分享CPU时间片,是并发行为。现在CPU 多了,不同的线程有机会同时运行,这就是真并行了吧。"
OS 道:"举一反三能力不错哦,不管并行还是并发,多个线程共享的数据有可能不一致,尤其加入了多CPU后,现象比较明显,这就是多线程数据安全问题。底层已经提供了一些基本的机制,比如CPU的MESI,但还是无法完全解决这问题,剩下的交给上层吧。"
进程道:"了解了,那我告诉各个线程,如果他们有共享数据的需求,自己协商解决一下。"
进程告知线程自己处理线程安全问题,线程答到:"我只是个工具人,谁用谁负责处理就好。"
一众编程语言答到:"我自己来处理吧。"
多CPU 如下,每个线程都有可能被其它CPU运行。
3、线程与Kotlin协程的故事
Java 线程调用
底层一众大佬已经将坑踩得差不多了,这时候得各个编程语言出场了。
C 语言作为骨灰级人物远近闻名,OS、驱动等都是由他编写,这无需介绍了。
之后如雨后春笋般又冒出了许多优秀的语言,如C++、Java、C#、Qt 等,本小结的主人公:Java。
Java 从小目标远大,想要跨平台运行,借助于JVM他可以实现这个梦想,每个JVM 实例对应一个进程,并且OS 还给了他操作线程的权限。
Java 想既然大佬这么支持,那我要撸起袖子加油干了,刚好在Android 上接到一个需求:
通过学生的id,向后台(联网)查询学生的基本信息,如姓名、年龄等。
Java 心想:"这还不简单,且看我猛如虎的操作。"
先定义学生Bean类型:
public class StudentInfo {
//学生id
private long stuId = 999;
private String name = "fish";
private int age = 18;
}
再定义一个获取的动作:
//从后台获取信息
public StudentInfo getWithoutThread(long stuId) {
try {
//模拟耗时操作
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
return new StudentInfo();
}
信心满满地运行,却被现实无情打脸,只见控制台显目的红色:
不能在主线程进行网络请求。
同步调用
Java 并不气馁,这问题简单,我开个线程取获取不就得了?
Callable<StudentInfo> callable = new Callable<StudentInfo>() {
@Override
public StudentInfo call() throws Exception {
try {
//模拟耗时操作
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
return new StudentInfo();
}
};
public StudentInfo getStuInfo(long stuId) {
//定义任务
FutureTask<StudentInfo> futureTask = new FutureTask<>(callable);
//开启线程,执行任务
new Thread(futureTask).start();
try {
//阻塞获取结果
StudentInfo studentInfo = futureTask.get();
return studentInfo;
} catch (ExecutionException e) {
e.printStackTrace();
} catch (InterruptedException e) {
e.printStackTrace();
}
return null;
}
而后,再在界面上弹出学生姓名:
JavaStudent javaStudent = new JavaStudent();
StudentInfo studentInfo = javaStudent.getWithoutThread(999);
Toast.makeText(this, "学生姓名:" + studentInfo.getName(), Toast.LENGTH_LONG).show();
刚开始能弹出Toast,然而后面动不动UI就卡顿,甚至出现ANR 弹窗。
Java 百思不得其解,后得到Android 本尊指点:
Android 主线程不能进行耗时操作。
Java 说到:"我就简单获取个信息,咋这么多限制..."
Android 答到:"Android 通常需要在主线程更新UI,主线程不能做过多耗时操作,否则影响UI 渲染流畅度。不仅是Android,你Java 本身的主线程(main线程)通常也不会做耗时啊,都是通过开启各个线程去完成任务,要不然每一步都要主线程等待,那主线程的其它关键任务就没法开启了。"
Java 沉思道:"有道理,容我三思。"
异步调用与回调
Java 果不愧是编程语言界的老手,闭关几天就想出了方案,直接show us code:
//回调接口
public interface Callback {
void onCallback(StudentInfo studentInfo);
}
//异步调用
public void getStuInfoAsync(long stuId, Callback callback) {
new Thread(() -> {
try {
//模拟耗时操作
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
StudentInfo studentInfo = new StudentInfo();
if (callback != null) {
//回调给调用者
callback.onCallback(studentInfo);
}
}).start();
}
在调用耗时方法时,只需要将自己的凭证(回调对象)传给方法即可,调用者不管方法里具体是咋实现的,才不管你开几个线程呢,反正你有结果通过回调给我。
调用者只需要在需要的地方实现回调接收即可:
JavaStudent javaStudent = new JavaStudent();
javaStudent.getStuInfoAsync(999, new JavaStudent.Callback() {
@Override
public void onCallback(StudentInfo studentInfo) {
//异步调用,回调从子线程返回,需要切换到主线程更新UI
runOnUiThread(() -> {
Toast.makeText(TestJavaActivity.this, "学生姓名:" + studentInfo.getName(), Toast.LENGTH_LONG).show();
});
}
});
异步调用的好处显而易见:
1、不用阻塞调用者,调用者可继续做其它事情。
2、线程没有被阻塞,相比同步调用效率更高。
缺点也是比较明显:
1、没有同步调用直观。
2、容易陷入多层回调,不利于阅读与调试。
3、从内到外的异常处理缺失传递性。
Kotlin 协程毛遂自荐
Java 靠着左手同步调用、右手异步调用的左右互搏技能,成功实现了很多项目,虽然异步调用有着一些缺点,但瑕不掩瑜。
这天,Java 又收到需求变更了:
通过学生id,获取学生信息,通过学生信息,获取他的语文老师id,通过语文老师id,获取老师姓名,最后更新UI。
Java 不假思索到:"简单,我再嵌套一层回调即可。"
//回调接口
public interface Callback {
void onCallback(StudentInfo studentInfo);
//新增老师回调接口
default void onCallback(TeacherInfo teacherInfo){}
}
//异步调用
public void getTeachInfoAsync(long stuId, Callback callback) {
//先获取学生信息
getStuInfoAsync(stuId, new Callback() {
@Override
public void onCallback(StudentInfo studentInfo) {
//获取学生信息后,取出关联的语文老师id,获取老师信息
new Thread(() -> {
try {
//模拟耗时操作
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
TeacherInfo teacherInfo = new TeacherInfo();
if (callback != null) {
//老师信息获取成功
callback.onCallback(teacherInfo);
}
}).start();
}
});
}
眼看Java 一下子实现了功能,Android再提需求:
通过老师id,获取他所在的教研组信息,再通过教研组id获取教研组排名...
Java 抗议道:"哪有这么奇葩的需求,那我不是要无限回调吗,我可以实现,但不好维护,过几天我自己看都看不懂了。"
Android:"不就是几个回调的问题嘛,亏你还是老员工,实在不行,我找其他人。"
Java:"...我再想想。"
正当Java 一筹莫展之际,吃饭时刚好碰到了Kotlin,Java 难得有时间和这位新入职的小伙伴聊聊天,发发牢骚。
Kotlin 听了Java 的遭遇,表达了同情并劝说Java 赶紧离职,Android 这块不适合他。
Kotlin 随后找到Android,略微紧张地说:"吾有一计,可安天下。"
Android 对于毛遂自荐的人才是非常欢迎的,问曰:"计将安出"
Kotlin 随后激动到:协程。
Android 诧异道:"协程,旅游?"
Kotlin 赶紧道:"非也,此协程非彼携程...而是它"
Android 说:"看这肌肉挺大的,想必比较强,请开始你的表演吧。"
Koltin 立马展示自己。
class StudentCoroutine {
private val FIXED_TEACHER_ID = 888
fun getTeachInfo(act: Activity, stuId: Long) {
GlobalScope.launch(Dispatchers.Main) {
var studentInfo: StudentInfo
var teacherInfo: TeacherInfo? = null
//先获取学生信息
withContext(Dispatchers.IO) {
//模拟网络获取
Thread.sleep(2000)
studentInfo = StudentInfo()
}
//再获取教师信息
withContext(Dispatchers.IO) {
if (studentInfo.lanTechId.toInt() === FIXED_TEACHER_ID) {
//模拟网络获取
Thread.sleep(2000)
teacherInfo = TeacherInfo()
}
}
//更新UI
Toast.makeText(act, "teacher name:${teacherInfo?.name}", Toast.LENGTH_LONG).show()
}
Toast.makeText(act, "主线程还在跑...", Toast.LENGTH_LONG).show()
}
}
外部调用:
var student = StudentCoroutine()
student.getTeachInfo(this@MainActivity, 999)
Android 一看,大吃一惊:"想不到,语言界竟然有如此厚颜无耻之...不对,如此简洁的写法。"
Kotlin 道:"协程这概念早就有了,其它兄弟语言Python、Go等也实现了,我也是站在巨人的肩膀上,秉着解决用户痛点的思路来设计的。"
Android 随即大手一挥道:"就冲着你这简洁的语法,今后Android 业务你来搞吧,希望你能够担起重担。"
Kotlin 立马道:"没问题,我本身也是跨平台的,只是Java 那边...。"
Android:"这个你无需顾虑,Java 的工作我来做,成年人应该知道这世界是残酷的。"
Java 听到Kotlin 逐渐蚕食了自己在Android上的业务,略微生气,于是看了Kotlin 的写法,最后长舒一口气:"确实比较简洁,看起来功能阻塞了主线程,实际并没有。其实就是 用同步的写法,表达异步的调用。"
Koltin :"知我者,老大哥Java 也。"
4、Kotlin 协程的使命
通过与Java 的比对,大家也知道了协程最大的特色:
将异步编程同步化。
当然还有一些特点,如异常处理、协程取消等。
再回过头来看看上面的疑问。
1、协程是轻量级线程、比线程耗费资源少
这话虽然是官方说的,但我觉得有点误导的作用,协程是语言层面的东西,线程是系统层面的东西,两者没有可比性。
协程就是一段代码块,既然是代码那就离不开CPU的执行,而CPU调度的基本单位是线程。
2、协程是线程框架
协程解决了移步编程时过多回调的问题,既然是异步编程,那势必涉及到不同的线程。Kotlin 协程内部自己维护了线程池,与Java 线程池相比有些优化的地方。在使用协程过程中,无需关注线程的切换细节,只需指定想要执行的线程即可,从对线程的封装这方面来说这说话也没问题。
3、协程效率高于线程
与第一点类似,协程在运行方面的高效率其实换成回调方式也是能够达成同样的效果,实际上协程内部也是通过回调实现的,只是在编译阶段封装了回调的细节而已。因此,协程与线程没有可比性。
阅读完上述内容,想必大家都知道进程、线程、协程的关系了,也许大家还很好奇协程是怎么做到不阻塞调用者线程的?它又是怎么在获取结果后回到原来的位置继续执行呢?线程之间如何做到丝滑般切换的?
不要着急,这些点我们一点点探秘,下篇文章开始徒手开启一个协程,并分析其原理。
Kotlin 源码阅读需要一定的Kotlin 基础,尤其是高阶函数,若是这方面还不太懂的同学可以查阅之前的文章:Kotlin 高阶函数从未如此清晰 系列
本文基于Kotlin 1.5.3,文中完整Demo请点击
作者:小鱼人爱编程
链接:https://juejin.cn/post/7108651566806073380
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
什么?你连个三色渐变圆角按钮都需要UI切图?
废话不多说,先上效果图:
该效果其实由三部分组成:
- 渐变
- 圆角
- 文本
渐变
关于渐变,估计大家都不会陌生,以往都是使用gradient
进行制作:
shape_gradient.xml
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<gradient
android:startColor="#B620E0"
android:endColor="#E38746" />
</shape>
<View
android:layout_width="match_parent"
android:layout_height="70dp"
android:background="@drawable/shape_gradient" />
但是,这个只能支持双色渐变,超过双色就无能为力了,所以,我们要考虑使用其它方式:
/**
* Create a shader that draws a linear gradient along a line.
*
* @param x0 The x-coordinate for the start of the gradient line
* @param y0 The y-coordinate for the start of the gradient line
* @param x1 The x-coordinate for the end of the gradient line
* @param y1 The y-coordinate for the end of the gradient line
* @param colors The colors to be distributed along the gradient line
* @param positions May be null. The relative positions [0..1] of
* each corresponding color in the colors array. If this is null,
* the the colors are distributed evenly along the gradient line.
* @param tile The Shader tiling mode
*/
public LinearGradient(float x0, float y0, float x1, float y1, @NonNull @ColorInt int colors[],
@Nullable float positions[], @NonNull TileMode tile)
/**
* x0、y0、x1、y1为决定渐变颜色方向的两个坐标点,x0、y0为起始坐标,x1、y1为终点坐标
* @param colors 所有渐变颜色的数组,即放多少个颜色进去,就有多少种渐变颜色
* @param positions 渐变颜色的比值,默认为均匀分布。
* 把总长度理解为1,假如里面的值为[0.3,0.2,0.5],那么,渐变的颜色就会以 0.3 : 0:2 :0.5 比例进行排版
* @param tile 着色器模式
*/
public LinearGradient(float x0, float y0, float x1, float y1, int colors[], float positions[],
TileMode tile)
创建自定义View
public class ColorView extends View {
public ColorView(Context context) {
super(context);
}
public ColorView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
}
public ColorView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
//获取宽高
int width = getWidth();
int height = getHeight();
//渐变的颜色
int colorStart = Color.parseColor("#E38746");
int color1 = Color.parseColor("#B620E0");
int colorEnd = Color.parseColor("#5995F6");
//绘画渐变效果
Paint paintColor = new Paint();
LinearGradient backGradient = new LinearGradient(0, height, width, 0, new int[]{colorStart, color1, colorEnd}, null, Shader.TileMode.CLAMP);
paintColor.setShader(backGradient);
canvas.drawRect(0, 0, width, height, paintColor);
}
}
<com.jm.xpproject.ColorView
android:layout_width="match_parent"
android:layout_height="70dp" />
效果:
圆角
关于圆角,我们需要使用到BitmapShader
,使用方式:
BitmapShader bitmapShaderColor = new BitmapShader(bitmapColor, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP);
// 初始化画笔
Paint paintFillet = new Paint();
paintFillet.setAntiAlias(true);
paintFillet.setShader(bitmapShaderColor);
//绘画到画布中
canvas.drawRoundRect(new RectF(0, 0, width, height), radius, radius, paintFillet);
由于这里的BitmapShader
是对于Bitmap
进行操作的,所以,对于渐变效果,我们不能直接把他绘画到原始画布上,而是生成一个Bitmap
,将渐变绘画记录下来:
还是刚刚的自定义View
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
//获取View的宽高
int width = getWidth();
int height = getHeight();
//第一步,绘画出一个渐变效果的Bitmap
//创建存放渐变效果的bitmap
Bitmap bitmapColor = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
Canvas canvasColor = new Canvas(bitmapColor);
//渐变的颜色
int colorStart = Color.parseColor("#E38746");
int color1 = Color.parseColor("#B620E0");
int colorEnd = Color.parseColor("#5995F6");
//绘画渐变效果
Paint paintColor = new Paint();
LinearGradient backGradient = new LinearGradient(0, height, width, 0, new int[]{colorStart, color1, colorEnd}, null, Shader.TileMode.CLAMP);
paintColor.setShader(backGradient);
canvasColor.drawRect(0, 0, width, height, paintColor);
//第二步,绘画出一个圆角渐变效果
//绘画出圆角渐变效果
BitmapShader bitmapShaderColor = new BitmapShader(bitmapColor, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP);
// 初始化画笔
Paint paintFillet = new Paint();
paintFillet.setAntiAlias(true);
paintFillet.setShader(bitmapShaderColor);
//绘画到画布中
canvas.drawRoundRect(new RectF(0, 0, width, height), 100, 100, paintFillet);
}
效果:
至于中间的空白部分,其实我们依葫芦画瓢,再画上一个白色的圆角Bitmap
即可:
//创建存放白底的bitmap
Bitmap bitmapWhite = Bitmap.createBitmap(width - colorWidth * 2, height - colorWidth * 2, Bitmap.Config.RGB_565);
bitmapWhite.eraseColor(Color.parseColor("#FFFFFF"));
BitmapShader bitmapShaderWhite = new BitmapShader(bitmapWhite, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP);
// 初始化画笔
Paint paintWhite = new Paint();
paintWhite.setAntiAlias(true);
paintWhite.setShader(bitmapShaderWhite);
// 将白色Bitmap绘制到画布上面
canvas.drawRoundRect(new RectF(colorWidth, colorWidth, width - colorWidth, height - colorWidth), radius, radius, paintWhite);
总体代码:
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
//获取View的宽高
int width = getWidth();
int height = getHeight();
//第一步,绘画出一个渐变效果的Bitmap
//创建存放渐变效果的bitmap
Bitmap bitmapColor = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
Canvas canvasColor = new Canvas(bitmapColor);
//渐变的颜色
int colorStart = Color.parseColor("#E38746");
int color1 = Color.parseColor("#B620E0");
int colorEnd = Color.parseColor("#5995F6");
//绘画渐变效果
Paint paintColor = new Paint();
LinearGradient backGradient = new LinearGradient(0, height, width, 0, new int[]{colorStart, color1, colorEnd}, null, Shader.TileMode.CLAMP);
paintColor.setShader(backGradient);
canvasColor.drawRect(0, 0, width, height, paintColor);
//第二步,绘画出一个圆角渐变效果
//绘画出圆角渐变效果
BitmapShader bitmapShaderColor = new BitmapShader(bitmapColor, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP);
// 初始化画笔
Paint paintFillet = new Paint();
paintFillet.setAntiAlias(true);
paintFillet.setShader(bitmapShaderColor);
//绘画到画布中
canvas.drawRoundRect(new RectF(0, 0, width, height), 100, 100, paintFillet);
//第三步,绘画出一个白色的bitmap覆盖上去
//创建存放白底的bitmap
Bitmap bitmapWhite = Bitmap.createBitmap(width - 5 * 2, height - 5 * 2, Bitmap.Config.RGB_565);
bitmapWhite.eraseColor(Color.parseColor("#FFFFFF"));
BitmapShader bitmapShaderWhite = new BitmapShader(bitmapWhite, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP);
// 初始化画笔
Paint paintWhite = new Paint();
paintWhite.setAntiAlias(true);
paintWhite.setShader(bitmapShaderWhite);
// 将白色Bitmap绘制到画布上面
canvas.drawRoundRect(new RectF(5, 5, width - 5, height - 5), 100, 100, paintWhite);
}
效果:
文本
像文本就简单了,使用drawText
即可,只要注意在绘画的时候,要对文本进行居中显示,因为 Android 默认绘画文本,是从左下角进行绘画的,就像这样:
Paint paintText = new Paint();
paintText.setAntiAlias(true);
paintText.setColor(Color.parseColor("#000000"));
paintText.setTextSize(100);
canvas.drawText("收藏", width / 2, height / 2, paintText);
canvas.drawLine(width / 2, 0, width / 2, height, paintText);
canvas.drawLine(0, height / 2, width, height / 2, paintText);
正确做法:
String text = "收藏";
Rect rect = new Rect();
Paint paintText = new Paint();
paintText.setAntiAlias(true);
paintText.setColor(Color.parseColor("#000000"));
paintText.setTextSize(100);
paintText.getTextBounds(text, 0, text.length(), rect);
int widthFont = rect.width();//文本的宽度
int heightFont = rect.height();//文本的高度
canvas.drawText(text, (width - widthFont) / 2, (height+heightFont) / 2, paintText);
至此,基本功能的制作就完成了
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
//获取View的宽高
int width = getWidth();
int height = getHeight();
//第一步,绘画出一个渐变效果的Bitmap
//创建存放渐变效果的bitmap
Bitmap bitmapColor = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
Canvas canvasColor = new Canvas(bitmapColor);
//渐变的颜色
int colorStart = Color.parseColor("#E38746");
int color1 = Color.parseColor("#B620E0");
int colorEnd = Color.parseColor("#5995F6");
//绘画渐变效果
Paint paintColor = new Paint();
LinearGradient backGradient = new LinearGradient(0, height, width, 0, new int[]{colorStart, color1, colorEnd}, null, Shader.TileMode.CLAMP);
paintColor.setShader(backGradient);
canvasColor.drawRect(0, 0, width, height, paintColor);
//第二步,绘画出一个圆角渐变效果
//绘画出圆角渐变效果
BitmapShader bitmapShaderColor = new BitmapShader(bitmapColor, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP);
// 初始化画笔
Paint paintFillet = new Paint();
paintFillet.setAntiAlias(true);
paintFillet.setShader(bitmapShaderColor);
//绘画到画布中
canvas.drawRoundRect(new RectF(0, 0, width, height), 100, 100, paintFillet);
//第三步,绘画出一个白色的bitmap覆盖上去
//创建存放白底的bitmap
Bitmap bitmapWhite = Bitmap.createBitmap(width - 5 * 2, height - 5 * 2, Bitmap.Config.RGB_565);
bitmapWhite.eraseColor(Color.parseColor("#FFFFFF"));
BitmapShader bitmapShaderWhite = new BitmapShader(bitmapWhite, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP);
// 初始化画笔
Paint paintWhite = new Paint();
paintWhite.setAntiAlias(true);
paintWhite.setShader(bitmapShaderWhite);
// 将白色Bitmap绘制到画布上面
canvas.drawRoundRect(new RectF(5, 5, width - 5, height - 5), 100, 100, paintWhite);
String text = "收藏";
Rect rect = new Rect();
Paint paintText = new Paint();
paintText.setAntiAlias(true);
paintText.setColor(Color.parseColor("#000000"));
paintText.setTextSize(100);
paintText.getTextBounds(text, 0, text.length(), rect);
int widthFont = rect.width();//文本的宽度
int heightFont = rect.height();//文本的高度
canvas.drawText(text, (width - widthFont) / 2, (height+heightFont) / 2, paintText);
}
封装
上面虽然已经把全部功能都讲解完了,但是,假如就直接这样放入项目中,是极其不规范的,无法动态设置文本、文本大小、颜色厚度等等
这里,我进行了简易封装,大家可以基于此进行业务修改:
attrs.xml
<declare-styleable name="GradientColorButton">
<attr name="btnText" format="string" />
<attr name="btnTextSize" format="dimension" />
<attr name="btnTextColor" format="color" />
<attr name="colorWidth" format="dimension" />
<attr name="colorRadius" format="dimension" />
</declare-styleable>
public class GradientColorButton extends View {
/**
* 文本
*/
private String text = "";
/**
* 文本颜色
*/
private int textColor;
/**
* 文本大小
*/
private float textSize;
/**
* 颜色的宽度
*/
private float colorWidth;
/**
* 圆角度数
*/
private float radius;
//渐变的颜色
private int colorStart = Color.parseColor("#E38746");
private int color1 = Color.parseColor("#B620E0");
private int colorEnd = Color.parseColor("#5995F6");
//控件的宽高
private int width;
private int height;
/**
* 渐变颜色的Bitmap
*/
private Bitmap bitmapColor;
//画笔
private Paint paintColor;
private Paint paintFillet;
private Paint paintWhite;
private Paint paintText;
//字体的宽高
private int widthFont;
private int heightFont;
public GradientColorButton(Context context) {
super(context);
}
public GradientColorButton(Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}
public GradientColorButton(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
//获取参数
TypedArray a = context.obtainStyledAttributes(attrs,
R.styleable.GradientColorButton, defStyleAttr, 0);
text = a.getString(R.styleable.GradientColorButton_btnText);
textColor = a.getColor(R.styleable.GradientColorButton_btnTextColor, Color.BLACK);
textSize = a.getDimension(R.styleable.GradientColorButton_btnTextSize, 16);
colorWidth = a.getDimension(R.styleable.GradientColorButton_colorWidth, 5);
radius = a.getDimension(R.styleable.GradientColorButton_colorRadius, 100);
}
@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
super.onLayout(changed, left, top, right, bottom);
//获取View的宽高
width = getWidth();
height = getHeight();
//制作一个渐变效果的Bitmap
createGradientBitmap();
//初始化圆角配置
initFilletConfiguration();
//初始化白色Bitmap配置
initWhiteBitmapConfiguration();
//初始化文本配置
initTextConfiguration();
}
/**
* 创建渐变颜色的Bitmap
*/
private void createGradientBitmap() {
//创建存放渐变效果的bitmap
bitmapColor = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
Canvas canvasColor = new Canvas(bitmapColor);
LinearGradient backGradient = new LinearGradient(0, height, width, 0, new int[]{colorStart, color1, colorEnd}, null, Shader.TileMode.CLAMP);
//绘画渐变效果
paintColor = new Paint();
paintColor.setShader(backGradient);
canvasColor.drawRect(0, 0, width, height, paintColor);
}
/**
* 初始化圆角配置
*/
private void initFilletConfiguration() {
//绘画出圆角渐变效果
BitmapShader bitmapShaderColor = new BitmapShader(bitmapColor, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP);
// 初始化画笔
paintFillet = new Paint();
paintFillet.setAntiAlias(true);
paintFillet.setShader(bitmapShaderColor);
}
/**
* 初始化白色Bitmap配置
*/
private void initWhiteBitmapConfiguration() {
//创建存放白底的bitmap
Bitmap bitmapWhite = Bitmap.createBitmap((int) (width - colorWidth * 2), (int) (height - colorWidth * 2), Bitmap.Config.RGB_565);
bitmapWhite.eraseColor(Color.parseColor("#FFFFFF"));
BitmapShader bitmapShaderWhite = new BitmapShader(bitmapWhite, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP);
// 初始化画笔
paintWhite = new Paint();
paintWhite.setAntiAlias(true);
paintWhite.setShader(bitmapShaderWhite);
}
/**
* 初始化文本配置
*/
private void initTextConfiguration() {
Rect rect = new Rect();
paintText = new Paint();
paintText.setAntiAlias(true);
paintText.setColor(textColor);
paintText.setTextSize(textSize);
if (!TextUtils.isEmpty(text)) {
paintText.getTextBounds(text, 0, text.length(), rect);
widthFont = rect.width();//文本的宽度
heightFont = rect.height();//文本的高度
}
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
//将圆角渐变bitmap绘画到画布中
canvas.drawRoundRect(new RectF(0, 0, width, height), radius, radius, paintFillet);
// 将白色Bitmap绘制到画布上面
canvas.drawRoundRect(new RectF(colorWidth, colorWidth, width - colorWidth, height - colorWidth), radius, radius, paintWhite);
if (!TextUtils.isEmpty(text)) {
canvas.drawText(text, (width - widthFont) / 2, (height + heightFont) / 2, paintText);
}
}
}
<com.jm.xpproject.GradientColorButton
android:layout_width="120dp"
android:layout_height="70dp"
android:layout_margin="10dp"
app:btnText="收藏"
app:btnTextColor="#123456"
app:btnTextSize="18sp"
app:colorRadius="50dp"
app:colorWidth="5dp" />
作者:不近视的猫
链接:https://juejin.cn/post/7110035318954262542
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
Kafka QUICKSTART
一. 安装和启动Kafka
我本地机器已经安装CDH 6.3.1版本,此处省略安装和启动Kafka的步骤。
Kafka版本:2.2.1
ps -ef|grep '/libs/kafka.\{2,40\}.jar'
1.1 Kafka的配置文件
[root@hp1 config]# find / -name server.properties
/opt/cloudera/parcels/CDH-6.3.1-1.cdh6.3.1.p0.1470567/etc/kafka/conf.dist/server.properties
常用的配置如下:
#broker 的全局唯一编号,不能重复
broker.id=0
#删除 topic 功能使能
delete.topic.enable=true
#处理网络请求的线程数量
num.network.threads=3
#用来处理磁盘 IO 的线程数量
num.io.threads=8
#发送套接字的缓冲区大小
socket.send.buffer.bytes=102400
#接收套接字的缓冲区大小
socket.receive.buffer.bytes=102400
#请求套接字的缓冲区大小
socket.request.max.bytes=104857600
#kafka 运行日志存放的路径
log.dirs=/opt/module/kafka/logs
#topic 在当前 broker 上的分区个数
num.partitions=1
#用来恢复和清理 data 下数据的线程数量
num.recovery.threads.per.data.dir=1
#segment 文件保留的最长时间,超时将被删除
log.retention.hours=168
#配置连接 Zookeeper 集群地址
zookeeper.connect=hadoop102:2181,hadoop103:2181,hadoop104:2181
二. 创建一个主题来存储事件
Kafka是一个分布式的事件流平台,可以让你跨多台机器读、写、存储和处理事件(在文档中也称为记录或消息)。
示例事件包括支付交易、来自移动电话的地理位置更新、发货订单、来自物联网设备或医疗设备的传感器测量,等等。这些事件被组织并存储在主题中。很简单,一个主题类似于文件系统中的一个文件夹,事件就是该文件夹中的文件。
2.1 创建主题
所以在你写你的第一个事件之前,你必须创建一个主题。打开另一个终端会话并运行:
/opt/cloudera/parcels/CDH-6.3.1-1.cdh6.3.1.p0.1470567/lib/kafka/bin/kafka-topics.sh --create --zookeeper localhost:2181 --replication-factor 1 --partitions 1 --topic first
2.2 查看当前事件描述
所有Kafka的命令行工具都有额外的选项:运行不带任何参数的Kafka -topics.sh命令来显示使用信息。例如,它还可以显示新主题的分区计数等详细信息:
-- 查看主题topic的描述
/opt/cloudera/parcels/CDH-6.3.1-1.cdh6.3.1.p0.1470567/lib/kafka/bin/kafka-topics.sh --describe --zookeeper localhost:2181 --topic first
-- 查看所有的topic的描述
/opt/cloudera/parcels/CDH-6.3.1-1.cdh6.3.1.p0.1470567/lib/kafka/bin/kafka-topics.sh --describe --zookeeper localhost:2181
一个分区一个副本
我们来看看创建多分区多副本
/opt/cloudera/parcels/CDH-6.3.1-1.cdh6.3.1.p0.1470567/lib/kafka/bin/kafka-topics.sh --create --zookeeper localhost:2181 --replication-factor 1 --partitions 1 --topic first_1_1
/opt/cloudera/parcels/CDH-6.3.1-1.cdh6.3.1.p0.1470567/lib/kafka/bin/kafka-topics.sh --create --zookeeper localhost:2181 --replication-factor 1 --partitions 2 --topic first_1_2
/opt/cloudera/parcels/CDH-6.3.1-1.cdh6.3.1.p0.1470567/lib/kafka/bin/kafka-topics.sh --create --zookeeper localhost:2181 --replication-factor 2 --partitions 2 --topic first_2_2
/opt/cloudera/parcels/CDH-6.3.1-1.cdh6.3.1.p0.1470567/lib/kafka/bin/kafka-topics.sh --create --zookeeper localhost:2181 --replication-factor 3 --partitions 3 --topic first_3_3
本地测试只有3台broker,所以最多只能创建3个replication-factor
2.3 删除主题
需要 server.properties中设置 delete.topic.enable=true否则只是标记删除。 否则只是标记删除。
cd /opt/cloudera/parcels/CDH-6.3.1-1.cdh6.3.1.p0.1470567/lib/kafka/bin
./kafka-topics.sh --zookeeper localhost:2181 --delete --topic first
三. 在主题中加入一些事件
Kafka客户端通过网络与Kafka的代理通信,用于写(或读)事件。一旦收到,代理将以持久和容错的方式存储事件,只要您需要—甚至永远。
运行控制台生成程序客户端,在主题中写入一些事件。默认情况下,您输入的每一行都将导致一个单独的事件被写入主题。
/opt/cloudera/parcels/CDH-6.3.1-1.cdh6.3.1.p0.1470567/lib/kafka/bin/kafka-console-producer.sh --broker-list 10.31.1.124:9092 --topic first
四. 读事件
打开另一个终端会话并运行控制台消费者客户端来读取你刚刚创建的事件:
/opt/cloudera/parcels/CDH-6.3.1-1.cdh6.3.1.p0.1470567/lib/kafka/bin/kafka-console-consumer.sh --from-beginning --bootstrap-server 10.31.1.124:9092 --topic first
--from-beginning:会把主题中以往所有的数据都读取出来。
您可以随时使用Ctrl-C停止客户端。
您可以自由地进行试验:例如,切换回您的生产者终端(上一步)来编写额外的事件,并查看这些事件如何立即显示在您的消费者终端上。
因为事件是持久性存储在Kafka中,它们可以被任意多的消费者读取。您可以通过再次打开另一个终端会话并再次运行前面的命令来轻松验证这一点。
六. 用kafka connect导入/导出你的数据作为事件流
您可能在现有系统(如关系数据库或传统消息传递系统)中有许多数据,以及许多已经使用这些系统的应用程序。Kafka Connect允许你不断地从外部系统获取数据到Kafka,反之亦然。因此,将现有系统与Kafka集成是非常容易的。为了使这个过程更容易,有数百个这样的连接器。
看看Kafka Connect部分,了解更多关于如何不断地导入/导出你的数据到Kafka。
七. 用kafka流处理你的事件
一旦你的数据以事件的形式存储在Kafka中,你就可以用Java/Scala的Kafka Streams客户端库来处理这些数据。它允许你实现关键任务实时应用和微服务,其中输入和/或输出数据存储在Kafka主题。Kafka Streams结合了客户端编写和部署标准Java和Scala应用程序的简单性和Kafka服务器端集群技术的优点,使这些应用程序具有高度的可扩展性、弹性、容错性和分布式。该库支持一次处理、有状态操作和聚合、窗口、连接、基于事件时间的处理等等。
kafka源码之旅------Kafka元数据管理
我们往kafka集群中发送数据的时候,kafka是怎么感知到需要发送到哪一台节点中呢?其实这其中的奥秘就在kafka的Metadata中。这一篇我们就来看看kafka中的Metadata管理。
我们来看看构建Kakfa中的代码片段:
KafkaProducer构造函数代码片段
从上面的代码片段可以看出,如果metadata变量不为空,直接赋值给KafkaProducer类成员变量metadata,否则需要新构建一个ProducerMetadata对象,然后根据用户传递的kafka集群服务器地址信息,构建Metadata类中cache成员变量的值,类型为MetadataCache。
下面我们来分析一下Metadata这个类,看看里面都封装了哪些属性。
refreshBackoffMs
这个参数的作用是防止轮询的过于频繁。用于设置两次元数据刷新之间,最小有效时间间隔,超过这个设置的时间间隔,则这次元数据刷新就失效了。默认值是100ms。
metadataExpireMs
这个参数的含义是如果不刷新,元数据可以保持有效的最大时间。默认值是5分钟。
updateVersion
这个参数对应每一个元数据的响应。每一次自增+1。
requestVersion
这个参数对应每一次创建一个新的Topic。每一次自增+1。
lastRefreshMs
这个参数的含义是上一次更新元数据的时间。
lastSuccessfulRefreshMs
这个参数的含义是上一次成功更新元数据的时间。正常情况下每一次更新元数据都应该是成功的,那么lastRefreshMs和lastSuccessfulRefreshMs的值,应该是一样的。但是如果出现更新没有成功的情况,那么lastRefreshMs的值大于lastSuccessfulRefreshMs的值。
fatalException
这个参数的类型是kafka自己封装的KafkaException。继承了RuntimeException。如果在元数据相关的操作中抛出了这种异常,kafka将停止元数据相关的操作。
invalidTopics
这个参数的含义是存储非法的Topic元数据信息。
unauthorizedTopics
这个参数的含义是存储未授权的Topic元数据信息。
cache
这个参数的含义是在Metadata类的内部构建一个MetadataCache对象,把元数据信息缓存起来,方便在集群中进行快速的数据获取。
needFullUpdate
这个参数的含义是Metadata是否需要全部更新。
needPartialUpdate
这个参数的含义是Metadata是否需要部分更新。
clusterResourceListeners
这个参数的含义是抽象了一个接收元数据更新集群资源的监听器集合。
lastSeenLeaderEpochs
这个参数是一个Map结构,映射的是TopicPartition和Integer之间的关系。也就是说某一个主题分区,它的主分区上一次更新的版本号是多少,在这个Map结构中存储。真正构建Metadata对象的时候,实现类是HashMap。
接下来我们来看看MetadataCache这个类,看看里面封装了哪些属性。这个类存在是kafka一种缓存的思想,把一些重要的属性用缓存来保存起来,提高Metadata的读取效率。
clusterId
这个参数用来标识整个kafka集群。
nodes
这个参数是一个Map类型,用来映射kafka集群中节点编号和节点的关系。
unauthorizedTopics
这个参数是一个Set类型,用来存储未授权的Topic集合。
invalidTopics
这个参数是一个Set类型,用来存储无效的Topic集合。
internalTopics
这个参数是一个Set类型,用来存储kafka内部的Topic集合,例如__consumer_offsets。
controller
这个参数是表示kafka controller所在broker。
metadataByPartition
这个参数是Map类型,用来存储分区和分区对应的元数据的映射关系。
clusterInstance
这个参数抽象了集群中的数据,我们接下来进行重点分析。
Cluster类是封装在MetadataCache中的,用来表示kafka的集群信息。
nodes
这个参数封装了集群中节点信息列表。
unauthorizedTopics
这个参数是一个Set类型,用来存储未授权的Topic集合。
invalidTopics
这个参数是一个Set类型,用来存储无效的Topic集合。
internalTopics
这个参数是一个Set类型,用来存储kafka内部的Topic集合,例如__consumer_offsets。
partitionsByTopicPartition
这个参数记录了TopicPartition与PartitionInfo的映射关系。
partitionsByTopic
这个参数记录了Topic名称与PartitionInfo的映射关系。可以按照Topic名称查询其中全部分区的详细信息。
availablePartitionsByTopic
这个参数记录了Topic与PartitionInfo的映射关系。这里的List<PartitionInfo>中存放的分区必须是有Leader副本的Partition,而partitionsByTopic中记录的分区则不一定有Leader副本,因为某些中间状态,例如Leader副本所在节点,发生了节点下线,进而触发了Leader副本的选举,在这一时刻分区不一定有Leader副本。
partitionsByNode
这个参数记录了Node与PartitionInfo的映射关系。可以按照节点Id查询该节点上分布的全部分区的详细信息。
nodesById
这个参数记录了BrokerId与Node节点之间的映射关系。方便使用BrokerId进行索引,可以根据BrokerId得到关联的Node节点信息。
clusterResource
这个参数是ClusterResource类型,这个类只是封装了一个clusterId成员属性,用于区分每一个kafka的集群。
我们再来看看Node这个类。Node这个类是对kafka集群中一个物理服务器的抽象,它所拥有的属性如下所示。
id
这个参数记录了kafka集群中的服务器编号,是我们配置参数的时候指定的。
host
这个参数记录了服务器的主机名。
port
这个参数记录了服务器的端口号。
rack
这个参数记录了服务器所属的机架。
我们再来看看TopicPartition这个类。这个类里面封装了主题,以及对应的一个分区。它所拥有的属性如下所示:
partition
这个参数记录了一个分区编号。
topic
这个参数记录了主题名称。
我们再来看看PartitionInfo这个类。这个类抽象了一个分区的详细信息,它所拥有的属性如下所示:
topic
这个参数记录了主题名称,表示这个分区是属于哪一个主题的。
partition
这个参数记录了分区编号。
leader
这个参数记录了分区主副本在哪台服务器上。
replicas
这个参数是Node类型的数组,记录了这个分区所有副本所在服务器。
inSyncReplicas
这个参数是Node类型的数组,记录了这个分区同步正常的副本所在服务器。
offlineReplicas
这个参数是Node类型的数组,记录了这个分区同步不正常的副本所在服务器。
本文转载自: https://www.jianshu.com/p/61a58cba354f
收起阅读 »Android实现消息总线的几种方式,你都会吗?
Android中消息总线的几种实现方式
前言
消息总线又叫事件总线,为什么我们需要一个消息总线呢?是因为随着项目变大,页面变多,我们可能出现跨页面、跨组件、跨线程、跨进程传递消息与数据,为了更方便的直接通知到指定的页面实现具体的逻辑,我们需要消息总线来实现。
从最基本的 BroadcastReceiver 到 EventBus 再到RxBus ,后来官方出了AndroidX jetpack 我们开始使用LiveDataBus,最后到Kotlin的流行出来了FlowBus。我们看看他们是怎么一步一步演变的。
一、BroadcastReceiver 广播
我们再初入 Android 的时候都应该学过广播接收者,分为静态广播和动态注册广播,在高版本的 Android 中限制了我们一些静态广播的使用,不过我们还是能通过动态注册的方式获取一些系统的状态改变。像常用的电量变化、网络状态变化、短信发送接收的状态等等。
比如网络变化的监听:
IntentFilter intentFilter = new IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION);
application.getApplicationContext().registerReceiver(InstanceHolder.INSTANCE, intentFilter);
在消息中线中,我们可以使用本地广播来实现 LocalBroadcastManager 消息的通知。
LocalBroadcastManager mLocalBroadcastManager = LocalBroadcastManager.getInstance(mContext);
BroadcastReceiver mLoginReceiver = new LoginSuccessReceiver();
mLocalBroadcastManager.registerReceiver(mLoginReceiver, new IntentFilter(Constants.ACTION_LOGIN_SUCCESS));
private class LoginSuccessReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
//刷新Home界面
refreshHomePage();
//刷新未读信息
requestUnreadNum();
}
}
//记得要解绑对应的接收器
mLocalBroadcastManager.unregisterReceiver(mLoginReceiver);
这样就可以实现一个消息通知了。相比 EventBus 它的性能和空间的消耗都是较大的,并且只能固定在主线程运行。
二、EventBus
EventBus最大的特点就是简洁、解耦,可以直接传递我们自定义的消息Message。EventBus简化了应用程序内各组件间、组件与后台线程间的通信。记得2015年左右是非常火爆的。
EventBus的调度灵活,不依赖于 Context,使用时无需像广播一样关注 Context 的注入与传递。可继承、优先级、粘滞,是 EventBus 比之于广播的优势。几乎可以满足我们全部的需求。
最初的EventBus其实就是一个方法的集合与查找,核心是通过register方法把带有@Subscrib注解的方法和参数之类的东西全部放入一个List集合,然后通过post方法去这个list循环查找到符合条件的方法去执行。
如何使用EventBus,一共分5步:
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_event_bus);
EventBus.getDefault().register(MainActivity.this); //1.注册广播
}
@Override
protected void onDestroy() {
super.onDestroy();
EventBus.getDefault().unregister(MainActivity.this); //2.解注册广播
}
/**
* 3.传递什么类型的。定义一个消息类
*/
public class MessageEvent {
public String name;
public MessageEvent(String name) {
this.name = name;
}
}
@OnClick({R.id.bt_eventbus_send_main, R.id.bt_eventbus_send_sticky})
public void onClick(View view) {
switch (view.getId()) {
case R.id.bt_eventbus_send_main:
//4.发送消息
EventBus.getDefault().post(new MessageEvent("我是主页面发送过来的消息"));
finish();
break;
}
}
/**
* 5.接受到消息。需要注解
*
* @param event
*/
@Subscribe(threadMode = ThreadMode.MAIN) //主线程执行
public void MessageEventBus(MessageEvent event) {
//5。显示接受到的消息
mTvEventbusResult.setText(event.name);
}
EventBus的性能开销其实不大,EventBus2.4.0 版是利用反射来实现的,后来改成 APT 实现之后会好很多。主要问题是需要定义很多的消息对象,消息太多之后就感觉管理起来很麻烦。当消息太多之后容器内部的查找会出现性能瓶颈。
就算如此 EventBus 也是值得大家使用的。
三、RxBus
RxBus是基于RxJava实现的,强大是强大,但是学习成本比较高,需要额外导入RxJava RxAndroid等库,这些库体积还是较大的。可以实现异步的消息等。
本身的实现是很简单的:
public class RxBus {
private volatile static RxBus mDefaultInstance;
private final Subject<Object> mBus;
private RxBus() {
mBus = PublishSubject.create().toSerialized();
}
public static RxBus getInstance() {
if (mDefaultInstance == null) {
synchronized (RxBus.class) {
if (mDefaultInstance == null) {
mDefaultInstance = new RxBus();
}
}
}
return mDefaultInstance;
}
/**
* 发送事件
*/
public void post(Object event) {
mBus.onNext(event);
}
/**
* 根据传递的 eventType 类型返回特定类型(eventType)的 被观察者
*/
public <T> Observable<T> toObservable(final Class<T> eventType) {
return mBus.ofType(eventType);
}
/**
* 判断是否有订阅者
*/
public boolean hasObservers() {
return mBus.hasObservers();
}
public void reset() {
mDefaultInstance = null;
}
}
定义消息对象:
public class MsgEvent {
private String msg;
public MsgEvent(String msg) {
this.msg = msg;
}
public String getMsg() {
return msg;
}
public void setMsg(String msg) {
this.msg = msg;
}
}
发送与接收:
RxBus.getInstance().toObservable(MsgEvent.class).subscribe(new Observer<MsgEvent>() {
@Override
public void onSubscribe(Disposable d) {
}
@Override
public void onNext(MsgEvent msgEvent) {
//处理事件
}
@Override
public void onError(Throwable e) {
}
@Override
public void onComplete() {
}
});
RxBus.getInstance().post(new MsgEvent("Java"));
缺点是容易内存泄露,我们需要使用rxlifecycle 或者使用CompositeDisposable 自己对生命周期进行处理解绑。
四、LiveDataBus
官方出了AndroidX jetpack 内部包含LiveData,它可以感知并遵循Activity、Fragment或Service等组件的生命周期。
为什么要使用LiveDataBus,正是基于LiveData对组件生命周期可感知的特点,因此可以做到仅在组件处于生命周期的激活状态时才更新UI数据。
一个简单的LiveDataBus的实现:
public final class LiveDataBus {
private final Map<String, BusMutableLiveData<Object>> bus;
private LiveDataBus() {
bus = new HashMap<>();
}
private static class SingletonHolder {
private static final LiveDataBus DEFAULT_BUS = new LiveDataBus();
}
public static LiveDataBus get() {
return SingletonHolder.DEFAULT_BUS;
}
public <T> MutableLiveData<T> with(String key, Class<T> type) {
if (!bus.containsKey(key)) {
bus.put(key, new BusMutableLiveData<>());
}
return (MutableLiveData<T>) bus.get(key);
}
public MutableLiveData<Object> with(String key) {
return with(key, Object.class);
}
private static class ObserverWrapper<T> implements Observer<T> {
private Observer<T> observer;
public ObserverWrapper(Observer<T> observer) {
this.observer = observer;
}
@Override
public void onChanged(@Nullable T t) {
if (observer != null) {
if (isCallOnObserve()) {
return;
}
observer.onChanged(t);
}
}
private boolean isCallOnObserve() {
StackTraceElement[] stackTrace = Thread.currentThread().getStackTrace();
if (stackTrace != null && stackTrace.length > 0) {
for (StackTraceElement element : stackTrace) {
if ("android.arch.lifecycle.LiveData".equals(element.getClassName()) &&
"observeForever".equals(element.getMethodName())) {
return true;
}
}
}
return false;
}
}
private static class BusMutableLiveData<T> extends MutableLiveData<T> {
private Map<Observer, Observer> observerMap = new HashMap<>();
@Override
public void observe(@NonNull LifecycleOwner owner, @NonNull Observer<T> observer) {
super.observe(owner, observer);
try {
hook(observer);
} catch (Exception e) {
e.printStackTrace();
}
}
@Override
public void observeForever(@NonNull Observer<T> observer) {
if (!observerMap.containsKey(observer)) {
observerMap.put(observer, new ObserverWrapper(observer));
}
super.observeForever(observerMap.get(observer));
}
@Override
public void removeObserver(@NonNull Observer<T> observer) {
Observer realObserver = null;
if (observerMap.containsKey(observer)) {
realObserver = observerMap.remove(observer);
} else {
realObserver = observer;
}
super.removeObserver(realObserver);
}
private void hook(@NonNull Observer<T> observer) throws Exception {
//get wrapper's version
Class<LiveData> classLiveData = LiveData.class;
Field fieldObservers = classLiveData.getDeclaredField("mObservers");
fieldObservers.setAccessible(true);
Object objectObservers = fieldObservers.get(this);
Class<?> classObservers = objectObservers.getClass();
Method methodGet = classObservers.getDeclaredMethod("get", Object.class);
methodGet.setAccessible(true);
Object objectWrapperEntry = methodGet.invoke(objectObservers, observer);
Object objectWrapper = null;
if (objectWrapperEntry instanceof Map.Entry) {
objectWrapper = ((Map.Entry) objectWrapperEntry).getValue();
}
if (objectWrapper == null) {
throw new NullPointerException("Wrapper can not be bull!");
}
Class<?> classObserverWrapper = objectWrapper.getClass().getSuperclass();
Field fieldLastVersion = classObserverWrapper.getDeclaredField("mLastVersion");
fieldLastVersion.setAccessible(true);
//get livedata's version
Field fieldVersion = classLiveData.getDeclaredField("mVersion");
fieldVersion.setAccessible(true);
Object objectVersion = fieldVersion.get(this);
//set wrapper's version
fieldLastVersion.set(objectWrapper, objectVersion);
}
}
}
注册与发送:
LiveDataBus.get()
.with("key_test", String.class)
.observe(this, new Observer<String>() {
@Override
public void onChanged(@Nullable String s) {
}
});
LiveDataBus.get().with("key_test").setValue(s);
LiveDataBus已经算是很好用的,自动注册解绑,根据Key传递泛型T对象,容易查找对应的接收者,也可以实现可见的触发和直接触发,可以实现跨进程,
LiveData有几点不足,只能在主线程更新数据,操作符无法转换数据,基于 Android Api 实现的,换一个平台无法适应,基于这几点又开发出了FlowBus。
五、FlowBus
很多人都说Flow 的出现导致 LiveData 没那么重要了,就是因为 LiveData 的场景 都可以使用 Flow 平替了,还能更为的强大和灵活。
StateFlow 可以 替代ViewModel中传递数据,SharedFlow 可以实现事件总线。(这两者的异同如果大家有兴趣,我可以单独开一篇讲下)。
SharedFlow 就是一种热流,可以实现一对多的关系,其构造方法支持天然支持普通的消息发送与粘性的消息发送。一般我们FlowBus都是基于 SharedFlow 来实现:
object FlowBus {
private val busMap = mutableMapOf<String, EventBus<*>>()
private val busStickMap = mutableMapOf<String, StickEventBus<*>>()
@Synchronized
fun <T> with(key: String): EventBus<T> {
var eventBus = busMap[key]
if (eventBus == null) {
eventBus = EventBus<T>(key)
busMap[key] = eventBus
}
return eventBus as EventBus<T>
}
@Synchronized
fun <T> withStick(key: String): StickEventBus<T> {
var eventBus = busStickMap[key]
if (eventBus == null) {
eventBus = StickEventBus<T>(key)
busStickMap[key] = eventBus
}
return eventBus as StickEventBus<T>
}
//真正实现类
open class EventBus<T>(private val key: String) : LifecycleObserver {
//私有对象用于发送消息
private val _events: MutableSharedFlow<T> by lazy {
obtainEvent()
}
//暴露的公有对象用于接收消息
val events = _events.asSharedFlow()
open fun obtainEvent(): MutableSharedFlow<T> = MutableSharedFlow(0, 1, BufferOverflow.DROP_OLDEST)
//主线程接收数据
fun register(lifecycleOwner: LifecycleOwner, action: (t: T) -> Unit) {
lifecycleOwner.lifecycle.addObserver(this)
lifecycleOwner.lifecycleScope.launch {
events.collect {
try {
action(it)
} catch (e: Exception) {
e.printStackTrace()
YYLogUtils.e("FlowBus - Error:$e")
}
}
}
}
//协程中发送数据
suspend fun post(event: T) {
_events.emit(event)
}
//主线程发送数据
fun post(scope: CoroutineScope, event: T) {
scope.launch {
_events.emit(event)
}
}
//自动销毁
@OnLifecycleEvent(Lifecycle.Event.ON_DESTROY)
fun onDestroy() {
YYLogUtils.w("FlowBus - 自动onDestroy")
val subscriptCount = _events.subscriptionCount.value
if (subscriptCount <= 0)
busMap.remove(key)
}
}
class StickEventBus<T>(key: String) : EventBus<T>(key) {
override fun obtainEvent(): MutableSharedFlow<T> = MutableSharedFlow(1, 1, BufferOverflow.DROP_OLDEST)
}
}
发送与接收消息
// 主线程-发送消息
FlowBus.with<String>("test-key-01").post(this@Demo11OneFragment2.lifecycleScope, "Test Flow Bus Message")
// 接收消息
FlowBus.with<String>("test-key-01").register(this) {
LogUtils.w("收到FlowBus消息 - " + it)
}
发送粘性消息
FlowBus.withStick<String>("test-key-02").post(lifecycleScope, "Test Stick Message")
FlowBus.withStick<String>("test-key-02").register(this){
LogUtils.w("收到粘性消息:$it")
}
Log如下:
总结
其实这么多消息总线框架,目前比较常用的是EventBus LiveDataBus FlowBus这三种。
总的来说,我们尽量不依赖第三方的框架来实现,那么 FlowBus 是语言层级的,基于Kotlin的特性实现,比较推荐了。LiveDataBus 是基于Android SDK 中的类实现的(我本人是比较喜欢用),只适应于 Android 开发,但也几乎能满足日常使用了。EventBus 是基于 Java 的语言特性注解和APT,也是比较好用的。
如果大家有源码方面的需求可以看看这里,上面的源码也都贴出来了。
本文的代码也只是简单的实现,只是为了抛砖引玉的实现几种基本的代码,如果大家需要在实战汇总使用,更推荐大家根据不同的类型自行去 Github 上面找对应的实现封装,功能会更多,健壮性也更好。
好了,关于消息总线就说到这了,如果觉得不错还请点赞
支持哦!
完结!
作者:newki
链接:https://juejin.cn/post/7108604765898014757
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
Flutter 中关于 angle 的坑
这个问题是我最近做业务开发和业余开发都遇到的,这里的 angle 指的是旋转弧度。不是旋转角度。
先看一下我使用 angle 的场景吧:
图一中使用了 canvas.drawArc,传入了 startAngle 和 sweepAngle。图二也是如此。图三是 Flutter ConstraintLayout 中圆形定位的 example,我没有使用 Flutter ConstraintLayout 自带的旋转能力,而是用了 Transform.rotate,传入了 angle。Flutter ConstraintLayout 自带的对 Widget 的旋转能力用了 canvas.rotate,也传入了 angle。
我现在还没搞明白弧度和角度的对应关系,官网文档中也没有详细说明。但对于我来说,我根本就不想去关心弧度是多少,我只关心角度,这个角度的范围是 [0.0, 360.0]。以图三中的时钟为例,旋转 0.0 或 360.0 度时,指针应该指向 12,旋转 90.0 度时,指针应该指向 3,旋转 180.0 度时,指针应该指向 6,旋转 270.0 度时,指针应该指向 9。
于是我们需要将旋转弧度转换成旋转角度,我研究出的转换公式如下:
Transform.rotate:
pi + pi * (angle / 180)
canvas.rotate:
angle * pi / 180
canvas.drawArc:
startAngle = -pi / 2
sweepAngle = angle * pi / 180
看见没有,这三类旋转的转换公式都不一样。我不明白 Flutter 官方为什么要这么设计,为啥这么优秀的 Flutter 引入了这么糟糕的 API。于是我带着气愤给官方提了个 Issue,想喷一喷设计这几个 API 的哥们:
结果我被反杀了。
冷静下来之后,我决定提交一个 Pull Request 来修正这个 API。但这需要时间,因为提交 Pull Request 的周期很长,上次我提了个 bug,Oppo 的一个哥们修复了它,Pull Request 等了将近两个月才合并。
作者:hackware
链接:https://juejin.cn/post/7112980784343941157
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
GraphQL在Flutter中的基本用法
GraphQL是一个用于API的查询语言,它可以使客户端准确地获得所需的数据,没有任何冗余。在Flutter项目中怎么使用Graphql呢?我们需要借助graphql-flutter插件
Tip: 这里以4.0.1为例
1. 添加依赖
首先添加到pubspec.yaml
然后我们再看看graphql-flutter(API)有什么,以及我们该怎么用。
2.重要API
GraphQLClient
client实例方法几乎跟apollo-client一致,如query 、mutate 、 subscribe,也有些许差别的方法watchQuery 、 watchMutation 等,后面具体介绍使用区别
Link
- 作为抽象类,提供了Link.concat 、 Link.from的等factory函数,如上
graphql-flutter里基于Link实现了一些比较使用的类,如下
HttpLink
- 设置请求地址,默认header等
AuthLink
- 通过函数的形式设置Authentication
ErrorLink
- 设置错误拦截
DedupeLink
- 请求去重
GraphQLCache
- 配置实体缓存,官方推荐使用 HiveStore 配置持久缓存
- HiveStore在项目中关于环境是Web还是App需要作判断,所以我们需要一个方法
综上各个Link以及Cache构成了Client,我们稍加对这些API做一个封装,以便在项目复用。
3.基本封装
- 代码及释义如下
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:graphql/client.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:path_provider/path_provider.dart'
show getApplicationDocumentsDirectory;
import 'package:path/path.dart' show join;
import 'package:hive/hive.dart' show Hive;
class Gql {
final String source;
final String uri;
final String token;
final Map<String, String> header;
HttpLink httpLink;
AuthLink authLink;
ErrorLink errorLink;
GraphQLCache cache;
GraphQLClient client;
String authHeaderKey = 'token';
String sourceKey = 'source';
Gql({
@required this.source,
@required this.uri,
this.token,
this.header = const {},
}) {
// 设置url,复写传入header
httpLink = HttpLink(uri, defaultHeaders: {
sourceKey: source,
...header,
});
// 通过复写getToken动态设置auth
authLink = AuthLink(getToken: getToken, headerKey: authHeaderKey);
// 错误拦截
errorLink = ErrorLink(
onGraphQLError: onGraphQLError,
onException: onException,
);
// 设置缓存
cache = GraphQLCache(store: HiveStore());
client = GraphQLClient(
link: Link.from([
DedupeLink(), // 请求去重
errorLink,
authLink,
httpLink,
]),
cache: cache,
);
}
static Future<void> initHiveForFlutter({
String subDir,
Iterable<String> boxes = const [HiveStore.defaultBoxName],
}) async {
if (!kIsWeb) { // 判断App获取path,初始化
var appDir = await getApplicationDocumentsDirectory(); // 获取文件夹路径
var path = appDir.path;
if (subDir != null) {
path = join(path, subDir);
}
Hive.init(path);
}
for (var box in boxes) {
await Hive.openBox(box);
}
}
FutureOr<String> getToken() async => null;
void _errorsLoger(List<GraphQLError> errors) {
errors.forEach((error) {
print(error.message);
});
}
// LinkError处理函数
Stream<Response> onException(
Request req,
Stream<Response> Function(Request) _,
LinkException exception,
) {
if (exception is ServerException) { // 服务端错误
_errorsLoger(exception.parsedResponse.errors);
}
if (exception is NetworkException) { // 网络错误
print(exception.toString());
}
if (exception is HttpLinkParserException) { // http解析错误
print(exception.originalException);
print(exception.response);
}
return _(req);
}
// GraphqlError
Stream<Response> onGraphQLError(
Request req,
Stream<Response> Function(Request) _,
Response res,
) {
// print(res.errors);
_errorsLoger(res.errors); // 处理返回错误
return _(req);
}
}
4. 基本使用
- main.dart
void main() async {
await Gql.initHiveForFlutter(); // 初始化HiveBox
runApp(App());
}
- clent.dart
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
const codeMessage = {
401: '登录失效,',
403: '用户已禁用',
500: '服务器错误',
503: '服务器错误',
};
// 通过复写,实现错误处理与token设置
class CustomGgl extends Gql {
CustomGgl({
@required String source,
@required String uri,
String token,
Map<String, String> header = const {},
}) : super(source: source, uri: uri, token: token, header: header);
String authHeaderKey = 'token';
@override
FutureOr<String> getToken() async { // 设置token
final sharedPref = await SharedPreferences.getInstance();
return sharedPref.getString(authHeaderKey);
}
@override
Stream<Response> onGraphQLError( // 错误处理并给出提示
Request req,
Stream<Response> Function(Request) _,
Response res,
) {
res.errors.forEach((error) {
final num code = error.extensions['exception']['status'];
Toast.error(message: codeMessage[code] ?? error.message);
print(error);
});
return _(req);
}
}
// 创建ccClient
final Gql ccGql = CustomGgl(
source: 'cc',
uri: 'https://xxx/graphql',
header: {
'header': 'xxxx',
},
);
- demo.dart
import 'package:flutter/material.dart';
import '../utils/client.dart';
import '../utils/json_view/json_view.dart';
import '../models/live_bill_config.dart';
import '../gql_operation/gql_operation.dart';
class GraphqlDemo extends StatefulWidget {
GraphqlDemo({Key key}) : super(key: key);
@override
_GraphqlDemoState createState() => _GraphqlDemoState();
}
class _GraphqlDemoState extends State<GraphqlDemo> {
ObservableQuery observableQuery;
ObservableQuery observableMutation;
Map<String, dynamic> json;
num pageNum = 1;
num pageSize = 10;
@override
void initState() {
super.initState();
Future.delayed(Duration(), () {
initObservableQuery();
initObservableMutation();
});
}
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Wrap(
spacing: 10.0,
runSpacing: 10.0,
children: [
RaisedButton(
onPressed: getLiveBillConfig,
child: Text('Basic Query'),
),
RaisedButton(
onPressed: sendPhoneAuthCode,
child: Text('Basic Mutation'),
),
RaisedButton(
onPressed: () {
pageNum++;
observableQuery.fetchMore(FetchMoreOptions(
variables: {
'pageNum': pageNum,
'pageSize': pageSize,
},
updateQuery: (prev, newData) => newData,
));
},
child: Text('Watch Query'),
),
RaisedButton(
onPressed: () {
observableMutation.fetchResults();
},
child: Text('Watch Mutation'),
),
],
),
Divider(),
if (json != null)
SingleChildScrollView(
child: JsonView.map(json),
scrollDirection: Axis.horizontal,
),
],
),
);
}
@override
dispose() {
super.dispose();
observableQuery.close();
}
void getLiveBillConfig() async {
Toast.loading();
try {
final QueryResult result = await ccGql.client.query(QueryOptions(
document: gql(LIVE_BILL_CONFIG),
fetchPolicy: FetchPolicy.noCache,
));
final liveBillConfig =
result.data != null ? result.data['liveBillConfig'] : null;
if (liveBillConfig == null) return;
setState(() {
json = LiveBillConfig.fromJson(liveBillConfig).toJson();
});
} finally {
if (Toast.loadingType == ToastType.loading) Toast.dismiss();
}
}
void sendPhoneAuthCode() async {
Toast.loading();
try {
final QueryResult result = await ccGql.client.mutate(MutationOptions(
document: gql(SEND_PHONE_AUTH_CODE),
fetchPolicy: FetchPolicy.cacheAndNetwork,
variables: {
'phone': '15883300888',
'authType': 2,
'platformName': 'Backend'
},
));
setState(() {
json = result.data;
});
} finally {
if (Toast.loadingType == ToastType.loading) Toast.dismiss();
}
}
void initObservableQuery() {
observableQuery = ccGql.client.watchQuery(
WatchQueryOptions(
document: gql(GET_EMPLOYEE_CONFIG),
variables: {
'pageNum': pageNum,
'pageSize': pageSize,
},
),
);
observableQuery.stream.listen((QueryResult result) {
if (!result.isLoading && result.data != null) {
if (result.isLoading) {
Toast.loading();
return;
}
if (Toast.loadingType == ToastType.loading) Toast.dismiss();
setState(() {
json = result.data;
});
}
});
}
void initObservableMutation() {
observableMutation = ccGql.client.watchMutation(
WatchQueryOptions(
document: gql(LOGIN_BY_AUTH_CODE),
variables: {
'phone': '15883300888',
'authCodeType': 2,
'authCode': '5483',
'statisticInfo': {'platformName': 'Backend'},
},
),
);
observableMutation.stream.listen((QueryResult result) {
if (!result.isLoading && result.data != null) {
if (result.isLoading) {
Toast.loading();
return;
}
if (Toast.loadingType == ToastType.loading) Toast.dismiss();
setState(() {
json = result.data;
});
}
});
}
}
总结
这篇文章介绍了如何在Flutter项目中简单快速的使用GraphQL。并实现了一个简单的Demo。但是上面demo将UI和数据绑定在一起,导致代码耦合性很高。在实际的公司项目中,我们都会将数据和UI进行分离,常用的做法就是将GraphQL的 ValueNotifier client 调用封装到VM层中,然后在Widget中把VM数据进行绑定操作。网络上已经有大量介绍Provider|Bloc|GetX的文章,这里以介绍GraphQL使用为主,就不再赘述了。
作者:CameIIia
链接:https://juejin.cn/post/7110596046144667655
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
Android UI 测试基础
UI 测试
UI 测试的一种方法是直接让测试人员对目标应用执行一系列用户操作,验证其行为是否正常。这种人工操作的方式一般非常耗时、繁琐、容易出错且 case 覆盖面不全。而另一种高效的方法是为编写 UI 测试,以自动化的方式执行用户操作。自动化方法可以可重复且快速可靠地运行测试。
使用 Android Studio 自动执行 UI 测试,需要在 src/AndroidTest/java
中实现测试代码,这种测试属于插桩单元测试。Android 的 Gradle 插件会根据测试代码构建一个测试应用,然后在目标应用所在的设备上加载该测试应用。在测试代码中,可以使用 UI 测试框架来模拟目标应用上的用户交互。
注意:并不是所有对 UI 的测试都是插桩单元测试,在本地单元测试中,也可以通过第三方框架(例如 Robolectric )来模拟 Android 运行环境,但这种测试是跑在开发计算机上的,基于 JVM 运行,而不是 Android 模拟器或物理设备的真实环境。
涉及 UI 测试的场景有两种情况:
- 单个 App 的 UI 测试:这种类型的测试可以验证目标应用在用户执行特定操作或在其 Activity 中输入特定内容时行为是否符合预期。Espresso 之类的 UI 测试框架可以实现通过编程的方式模拟用户交互。
- 流程涵盖多个 App 的 UI 测试:这种类型的测试可以验证不同 App 之间或是用户 App 与系统 App 之间的交互流程是否正常运行。比如在一个应用中打开系统相机进行拍照。UI Automator 框架可以支持跨应用交互。
Android 中的 UI 测试框架
Jetpack 包含了丰富的官方框架,这些框架提供了用于编写 UI 测试的 API:
- Espresso :提供了用于编写 UI 测试的 API ,可以模拟用户与单个 App 进行 UI 交互。使用 Espresso 的一个主要好处是它提供了测试操作与您正在测试的应用程序 UI 的自动同步。Espresso 会检测主线程何时空闲,因此它能够在适当的时间运行您的测试命令,从而提高测试的可靠性。
- Jetpack Compose :提供了一组测试 API 用来启动 Compose 屏幕和组件之间的交互,融合到了开发过程中。算是 Compose 的一个优势。
- UI Automator : 是一个 UI 测试框架,适用于涉及多个应用的操作流程的测试。
- Robolectric :在 JVM 上运行本地单元测试,而不是模拟器或物理设备上。可以配合 Espresso 或 Compose 的测试 API 与 UI 组件进行模拟交互。
异常行为和同步处理
因为 Android 应用是基于多线程实现的,所有涉及 UI 的操作都会发送到主线程排队执行,所以在编写测试代码时,需要处理这种异步存在的问题。当一个用户输入注入时,测试框架必须等待 App 对用户输入进行响应。当一个测试没有确定性行为的时候,就会出现异常行为。
像 Compose 或 Espresso 这样的现代框架在设计时就考虑到了测试场景,因此可以保证在下一个测试操作或断言之前 UI 将处于空闲状态,从而保证了同步行为。
流程图显示了在通过测试之前检查应用程序是否空闲的循环:
在测试中使用 sleep 会导致测试缓慢或者不稳定,如果有动画执行超过 2s 就会出现异常情况。
应用架构和测试
另一方面,应用的架构应该能够快速替换一些组件,以支持 mock 数据或逻辑进行测试,例如,在有异步加载数据的场景,但我们并不关心异步数据获取相关逻辑的情况下,仅关心获取到数据后的 UI 层测试,就可以将异步逻辑替换成假的数据源,从而能够更加高效的进行测试:
推荐使用 Hilt 框架实现这种注入数据的替换操作。
为什么需要自动化测试?
Android App 可以在不同的 API 版本的上千种不同设备上运行,并且手机厂商有可能修改系统代码,这意味着 App 可能会在一些设备上不正确地运行甚至导致 crash 。
UI 测试可以进行兼容性测试,验证 App 在不同环境中的行为。例如可以测试不同环境下的行为:
- API level 不同
- 位置和语言设置不同
- 屏幕方向不同
此外,还要考虑设备类型的问题,例如平板电脑和可折叠设备的行为,可能与普通手机设备环境下,产生不同的行为。
AndroidX 测试框架的使用
环境配置
- 修改根目录下的
build.gradle
文件,确保项目依赖仓库:
allprojects {
repositories {
jcenter()
google()
}
}
- 添加测试框架依赖:
dependencies {
// 核心框架
androidTestImplementation "androidx.test:core:$androidXTestVersion0"
// AndroidJUnitRunner and JUnit Rules
androidTestImplementation "androidx.test:runner:$testRunnerVersion"
androidTestImplementation "androidx.test:rules:$testRulesVersion"
// Assertions 断言
androidTestImplementation "androidx.test.ext:junit:$testJunitVersion"
androidTestImplementation "androidx.test.ext:truth:$truthVersion"
// Espresso 依赖
androidTestImplementation "androidx.test.espresso:espresso-core:$espressoVersion"
androidTestImplementation "androidx.test.espresso:espresso-contrib:$espressoVersion"
androidTestImplementation "androidx.test.espresso:espresso-intents:$espressoVersion"
androidTestImplementation "androidx.test.espresso:espresso-accessibility:$espressoVersion"
androidTestImplementation "androidx.test.espresso:espresso-web:$espressoVersion"
androidTestImplementation "androidx.test.espresso.idling:idling-concurrent:$espressoVersion"
// 下面的依赖可以使用 "implementation" 或 "androidTestImplementation",
// 取决于你是希望这个依赖出现在 Apk 中,还是测试 apk 中
androidTestImplementation "androidx.test.espresso:espresso-idling-resource:$espressoVersion"
}
发行版本号参阅: developer.android.com/jetpack/and…
另外值得注意的一点是 espresso-idling-resource
这个依赖在生产代码中使用的话,需要打包到 apk 中。
AndroidX 中的 Junit4 Rules
AndroidX 测试框架包含了一组配合 AndroidJunitRunner 使用的 Junit Rules。
关于什么是 JUnit Rules ,可以查看 wiki:github.com/junit-team/…
JUnit Rules 提供了更大的灵活性并减少了测试中所需的样板代码。可以将 JUnit Rules 理解为一些模拟环境用来测试的 API 。例如:
- ActivityScenarioRule : 用来模拟 Activity 。
- ServiceTestRule :可以用来模拟启动 Service 。
- TemporaryFolder :可以用来创建文件和文件夹,这些文件会在测试方法完成时被删除(若不能删除,会抛出异常)。
- ErrorCollector :发生问题后继续执行测试,最后一次性报告所有错误内容。
- ExpectedException :在测试过程中指定预期的异常。
除了上面几个例子,还有很多 Rules ,可以将 Rules 理解为用来在测试中快捷实现一些能力的 API 。
ActivityScenarioRule
ActivityScenarioRule 用来对单个 Activity 进行功能测试。声明一个 ActivityScenarioRule 实例:
@get:Rule
val activityRule = ActivityScenarioRule(MainActivity::class.java)
这个规则,会在执行标注有 @Test
注解的测试方法启动前,绑定构造参数中执行的 Activity ,并且在带有 @Test
测试方法执行前,先执行所有带有 @Before
注解的方法,并在执行的测试方法结束后,执行所有带有 @After
注解的方法。
@RunWith(AndroidJUnit4::class)
class MainActivityTest {
@Before
fun beforeActivityCreate() {
Log.d(TAG, "beforeActivityCreate")
}
@Before
fun beforeTest() {
Log.d(TAG, "beforeTest")
}
@Test
fun onCreate() {
activityRule.scenario.moveToState(Lifecycle.State.CREATED).onActivity {
Log.d(TAG, "in test thread: ${Thread.currentThread()}}")
}
}
@After
fun afterActivityCreate() {
Log.d(TAG, "afterActivityCreate")
}
// ...
}
执行这个带有 @Test
注解的 onCreate
方法,其日志为:
2022-06-17 17:29:07.341 I/TestRunner: started: onCreate(com.chunyu.accessibilitydemo.MainActivityTest)
2022-06-17 17:29:08.006 D/MainActivityTest: beforeTest
2022-06-17 17:29:08.006 D/MainActivityTest: beforeActivityCreate
2022-06-17 17:29:08.565 D/MainActivityTest: in ui thread: Thread[main,5,main]
2022-06-17 17:29:08.566 D/MainActivityTest: afterActivityCreate
2022-06-17 17:29:09.054 I/TestRunner: finished: onCreate(com.chunyu.accessibilitydemo.MainActivityTest)
在执行完所有的 @After
方法后,会终止模拟启动的这个 Activity 。
访问 Activity
测试方法中的重点是通过 ActivityScenarioRule 模拟构造 Activity ,并对其中的一些行为进行测试。
如果要在测试逻辑中访问指定的 Activity ,可以通过 ActivityScenarioRule.getScenario().onActivity{ ... }
回调中指定一些代码逻辑。例如上面的 onCreate()
测试方法中,稍加修改,就可以展示访问 Activity 的能力:
@Test
fun onCreate() {
activityRule.scenario.onActivity { it ->
Log.d(TAG, "${it.isFinishing}")
}
}
不光可以访问 Activity 中公开的属性和方法,还可以访问指定 Activity 中 public 的内容,例如:
@Test
fun test() {
activityRule.scenario.onActivity { it ->
it.button.performClick()
}
}
控制 Activity 的生命周期
在最开始的例子中,我们通过 moveToState
来控制了这个 Activity 的生命周期,修改代码:
@Test
fun onCreate() {
activityRule.scenario.moveToState(Lifecycle.State.CREATED).onActivity {
Log.d(TAG, "${it.lifecycle.currentState}")
}
}
我们在 onActivity
中打印 Activity 的当前生命周期,检查一下是否真的是在 moveToState
中指定的状态,打印结果:
2022-06-17 17:45:30.425 D/MainActivityTest: CREATED
moveToState
的确生效了,它可以将 Activity 控制到我们想要的状态。
通过 ActivityScenarioRule 的 getState()
,也可以直接获取到模拟的 Activity 的状态,这个方法可能存在的状态包括:
- State.CREATED
- State.STARTED
- State.RESUMED
- State.DESTROYED
而 moveToState
能够设置的值包括:
public enum State {
// 这个状态表示 Activity 已销毁
DESTROYED,
// 初始化状态,还没调用 onCreate
INITIALIZED,
// 存在两种情况,在 onCreate 开始后,onStop 结束前
CREATED,
// 存在两种情况,在 onStart 开始后,在 onPause 结束前。
STARTED,
// onResume 开始后调用。
RESUMED;
// ...
}
当 moveToState 设置为 DESTROYED ,再访问 Activity ,会抛出异常
java.lang.NullPointerException: Cannot run onActivity since Activity has been destroyed already
如果要测试 Fragment ,可以通过
FragmentScenario
进行,此类需要引用debugImplementation "androidx.fragment:fragment-testing:$fragment_version"
ServiceTestRule
ServiceTestRule 用来在单元测试情况下模拟启动指定的 Service ,包括 bindService
和 startService
两种方式,创建一个 ServiceTestRule 实例:
@get:Rule
val serviceTestRule = ServiceTestRule()
在测试方法中通过 ServiceTestRule 启动 Service ,下面是一个普通的服务,在真实环境下通过 startService
可以正常启动:
class RegularService: Service() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Log.d("onStartCommand", ": ${Thread.currentThread().name}")
Toast.makeText(this, "in Service", Toast.LENGTH_SHORT).show()
return super.onStartCommand(intent, flags, startId)
}
override fun onBind(intent: Intent?): IBinder? {
return null
}
}
startService
@Test
fun testService() {
serviceTestRule.startService(Intent(ApplicationProvider.getApplicationContext(), RegularService::class.java))
}
但是这样会抛出异常:
java.util.concurrent.TimeoutException: Waited for 5 SECONDS, but service was never connected
这是因为,通过 ServiceTestRule 的 startService(Intent)
启动一个 Service ,会在 5s 内阻塞直到 Service 已连接,即调用到了 ServiceConnection.onServiceConnected(ComponentName, IBinder)
。
也就是说,你的 Service 的 onBind(Intent)
方法,不能返回 null ,否则就会抛出 TimeoutException 。
修改 RegularService :
class RegularService: Service() {
private val binder = RegularBinder()
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Log.d("RegularServiceTest", "onStartCommand")
return super.onStartCommand(intent, flags, startId)
}
override fun onBind(intent: Intent?): IBinder? {
return binder
}
inner class RegularBinder: Binder() {
fun getService(): RegularService = this@RegularService
}
}
这样,通过 ServiceTestRule 的 startService 启动服务就可以正常运行了:
2022-06-17 19:51:59.772 I/TestRunner: started: testService(com.chunyu.accessibilitydemo.service.RegularServiceTest)
2022-06-17 19:51:59.777 D/RegularServiceTest: beforeService1
2022-06-17 19:51:59.777 D/RegularServiceTest: beforeService2
2022-06-17 19:51:59.795 D/RegularServiceTest: onStartCommand
2022-06-17 19:51:59.820 D/RegularServiceTest: afterService1
2022-06-17 19:51:59.820 D/RegularServiceTest: afterService2
2022-06-17 19:51:59.830 I/TestRunner: finished: testService(com.chunyu.accessibilitydemo.service.RegularServiceTest)
ServiceTestRule 和 ActivityScenarioRule 一样,都会在执行测试前执行所有的 @Before
方法,执行结束后,继续执行所有的 @After
方法。
bindService
@Test
fun testService() {
serviceTestRule.bindService(Intent(ApplicationProvider.getApplicationContext(), RegularService::class.java))
}
ServiceTestRule.bindService
效果和 Context.bindService
相同,都不走 onStartCommand
而是 onBind
方法。
2022-06-17 19:57:19.274 I/TestRunner: started: testService(com.chunyu.accessibilitydemo.service.RegularServiceTest)
2022-06-17 19:57:19.277 D/RegularServiceTest: beforeService1
2022-06-17 19:57:19.277 D/RegularServiceTest: beforeService2
2022-06-17 19:57:19.296 D/RegularServiceTest: onBind
2022-06-17 19:57:19.302 D/RegularServiceTest: afterService1
2022-06-17 19:57:19.302 D/RegularServiceTest: afterService2
2022-06-17 19:57:19.314 I/TestRunner: finished: testService(com.chunyu.accessibilitydemo.service.RegularServiceTest)
测试方法的执行顺序也是一样的。
访问 Service
startService
启动的 Service 无法获取到 Service 实例,ServiceTestRule 并没有像 ActivityScenarioRule 那样提供 onActivity {... }
回调方法。
bindService
的返回类型是 IBinder
,可以通过 IBinder 对象获取到 Service 实例:
@Test
fun testService() {
val binder = serviceTestRule.bindService(Intent(ApplicationProvider.getApplicationContext(), RegularService::class.java))
val service = (binder as? RegularService.RegularBinder)?.getService()
// access RegularService info
}
作者:自动化BUG制造器
链接:https://juejin.cn/post/7110184974791213064
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
学会使用LiveData和ViewModel,我相信会让你在写业务时变得轻松🌞
介绍
在2017年,那时,观察者模式有效的简化了开发,但是诸如RxJava一类的库有一些太过复杂,学习成本太高,为此,LiveData出现了,一个专用于Android的,具备自主生命周期感知能力的,可观测的数据存储类。同时也出现了ViewModel这个组件,配合LiveData,更方便的实现MVVM模式中Model与View的分离。那么就让本文来带大家来学习LiveData与ViewModel的使用吧。
LiveData和ViewModel的关系:
本文的案例代码:github.com/taxze6/Jetp…
LiveData
参考资料:
🌟官方文档:developer.android.google.cn/topic/libra…
🌟LiveData postValue详解:http://www.cnblogs.com/button123/p…
LiveData是一种可观察的数据存储器类(响应式编程,类似Vue)。与常规的可观察类不同,LiveData 具有生命周期感知能力。LiveData最重要的是它了解观察者的生命周期,如Activity和Fragment。
因此,当LiveData发送变化时,UI会收到通知,然后UI可以使用新数据重新绘制自己。换句话说,LiveData可以很容易地使屏幕上发生的事情与数据保持同步(响应式编程的核心)
使用 LiveData 具有以下优势:
UI与数据状态匹配
- LiveData 遵循观察者模式。当底层数据发生变化时,LiveData 会通知
Observer
对象。您可以整合代码以在这些Observer
对象中更新界面。这样一来,您无需在每次应用数据发生变化时更新界面,因为观察者会替您完成更新。
- LiveData 遵循观察者模式。当底层数据发生变化时,LiveData 会通知
提高代码的稳定性
代码稳定性在整个应用程序生命周期中增加:
- 活动停止时不会发生崩溃。如果应用程序组件处于非活动状态,则这些更改不受影响。因此,您在更新数据时无需担心应用程序组件的生命周期。对于后台堆栈中的活动,它不会接受任何LiveData事件
- 内存泄漏会减少,观察者会绑定到Lifecycle对象,并在其关联的生命周期遭到销毁后进行自我清理
- 取消订阅任何观察者时无需担心
- 如果由于配置更改(如设备旋转)而重新创建了 Activity 或 Fragment,它会立即接收最新的可用数据。
不再需要手动处理生命周期
界面组件只是观察相关数据,不会停止或恢复观察。LiveData 将自动管理所有这些操作,因为它在观察时可以感知相关的生命周期状态变化。
数据始终保持最新状态
如果生命周期变为非活跃状态,它会在再次变为活跃状态时接收最新的数据。例如,曾经在后台的 Activity 会在返回前台后立即接收最新的数据。
共享资源
像单例模式一样,我们也可以扩展我们的LiveData对象来包装系统服务,以便它们可以在我们的应用程序中共享。一旦LiveData对象连接到系统服务,任何需要资源的观察者可以轻松地观看LiveData对象。
在以下情况中,不要使用LiveData:
- 您需要在信息上使用大量运算符,尽管LiveData提供了诸如转换之类的工具,但只有Map和switchMap可以帮助您
- 您没有与信息的UI交互
- 您有一次性的异步操作
- 您不必将缓存的信息保存到UI中
如何使用LiveData
一般来说我们会在 ViewModel 中创建 Livedata 对象,保证app配置变更时,数据不会丢失,然后再 Activity/Fragment 的 onCreate 中注册 Livedata 监听(因为在 onStart 和 onResume 中进行监听可能会有冗余调用)
基础使用流程:
1.创建一个实例LiveData来保存某种类型的数据。一般在你创建的ViewModel类中完成
class MainViewModel : ViewModel() {
var mycount: MutableLiveData<Int> = MutableLiveData()
}
2.在Activity或者Fragment中获取到ViewModel,通过ViewModel获取到对应的LiveData
class MainActivity : AppCompatActivity() {
lateinit var viewModel: MainViewModel
override fun onCreate(savedInstanceState: Bundle?) {
...
/**记住绝对不可以直接去创建ViewModel实例
一定要通过ViewModelProvider(ViewModelStoreOwner)构造函数来获取。
因为每次旋转屏幕都会重新调用onCreate()方法,如果每次都创建新的实例的话就无法保存数据了。
用上述方法后,onCreate方法被再次调用,
它会返回一个与MainActivity相关联的预先存在的ViewModel,这就是保存数据的原因。*/
viewModel = ViewModelProvider(this@MainActivity,ViewModelProvider.
NewInstanceFactory()).get(MainViewModel::class.java)
}
}
3.给LiveData添加观察者监听,用来监听LiveData中的数据变化,在Observer的onChanged中使用监听回调数据
/**
* 订阅 ViewModel,mycount是一个LiveData类型 可以观察
* */
viewModel.mycount.observe(this@MainActivity) {
countTv.text = viewModel.mycount.value.toString()
}
// LiveData onchange会自动感应生命周期 不需要手动
// viewModel.mycount.observe(this, object : Observer<Int> {
// override fun onChanged(t: Int?) {
//
// }
// })
进阶用法:
Transformations.map
现在有一个场景:我们通过网络请求,获得了一个User类数据(LiveData),但是,我们只想把User.name暴露给外部观察者,这样我们就可以通过Transformations.map来转化LiveData的数据类型,从而来实现上述场景。这个函数常用于对数据的封装。
//实体类
data class User(var name: String)
...
//Transformations.map接收两个参数,第一个参数是用于转换的LiveData原始对象,第二个参数是转换函数。
private val userLiveData: MutableLiveData<User> = MutableLiveData()
val userNames: LiveData<String> = Transformations
.map(userLiveData) { user ->
user.name
}
Transformations.switchMap
switchMap是根据传入的LiveData的值,然后判断这个值,然后再去切换或者构建新的LiveData。比如我们有些数据需要依赖其他数据进行查询,就可以使用switchMap。
例如,有一个学生,他有两门课程的成绩,但是在UI组件中,我们一次只能显示一门课的成绩,在这个需要判断展示哪门课程成绩的需求下,我们就可以使用switchMap。
data class Student
(var englishScore: Double, var mathScore: Double, val scoreTAG: Boolean)
.....
class SwitchMapViewModel:ViewModel {
var studentLiveData = MutableLiveData<Student>()
val transformationsLiveData = Transformations.switchMap(studentLiveData) {
if (it.scoreTAG) {
MutableLiveData(it.englishScore)
} else {
MutableLiveData(it.mathScore)
}
}
}
//使用时:
var student = Student()
person.englishScore = 88.2
person.mathScore = 91.3
//判断显示哪个成绩
person.condition = true
switchMapViewModel.conditionLiveData.postValue(person)
MediatorLiveData
MediatorLiveData继承于MutableLiveData,在MutableLiveData的基础上,增加了合并多个LiveData数据源的功能。其实就是通过addSource()这个方法去监听多个LiveData。
例如:现在有一个存在本地的dbLiveData,还有一个网络请求来的LiveData,我们需要讲上面两个结果结合之后展示给用户,第一种做法是我们在Activity中分别注册这两个LiveData的观察者,当数据发生变化时去更新UI,但是我们其实使用MediatorLiveData可以简化这个操作。
class MediatorLiveDataViewModel : ViewModel() {
var liveDataA = MutableLiveData<String>()
var liveDataB = MutableLiveData<String>()
var mediatorLiveData = MediatorLiveData<String>()
init {
mediatorLiveData.addSource(liveDataA) {
Log.d("This is livedataA", it)
mediatorLiveData.postValue(it)
}
mediatorLiveData.addSource(liveDataB) {
Log.d("This is livedataB", it)
mediatorLiveData.postValue(it)
}
}
}
解释:
如果是第一次接触到LiveData的朋友可能会发现,我们虽然一直在提LiveData,但是用的时候却是MutableLiveData,这两个有什么关系呢,既然都没怎么用LiveData,那么把标题直接改成MutableLiveData吧
其实,LiveData与MutableLiveData在概念上是一模一样的。唯一的几个区别分别是:
💡“此处引用:LiveData与MutableLiveData的区别文章中的段落”
- MutableLiveData的父类是LiveData
- LiveData在实体类里可以通知指定某个字段的数据更新
- MutableLiveData则是完全是整个实体类或者数据类型变化后才通知.不会细节到某个字段。
原理探究:
对于LiveData的基础使用我们就讲到这里,想要探索LiveData原理的朋友可以从下面几个角度:
- LiveData的工作原理
- LiveData的observe方法源码分析
- LifecycleBoundObserver源码分析
- activeStateChanged源码分析(用于粘性事件)
- postValue和setValue
- considerNotify判断是否发送数据分析
- 粘性事件的分析
相信大家从以上几个角度去分析LiveData会有不小的收获💪
ViewModel
官方文档:developer.android.google.cn/topic/libra…
官方简介
ViewModel类旨在以注重生命周期的方式存储和管理界面相关的数据。ViewModel类让数据可在发生屏幕旋转等配置更改后继续留存。
生命周期
ViewModel的生命周期会比创建它的Activity、Fragment的生命周期都要长。所以ViewModel中的数据会一直存活在Activity/Fragment中。
基础使用流程:
1.构造数据对象
自定义ViewModel类,继承ViewModel,然后在自定义的ViewModel类中添加需要的数据对象
class MainViewModel : ViewModel() {
...
}
2.获取数据
有两种常见的ViewModel创建方式,第一种是在activity或fragment种直接基于 ViewModelProvider 获取。第二种是通过ViewModelFactory 创建
//第一种 ViewModelProvider直接获取
ViewModelProvider(this@MainActivity).get(MainViewModel::class.java)
//第二种 通过 ViewModelFactory 创建
class TestViewModelFactory(private val param: Int) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return TestViewModel(param) as T
}
}
ViewModelProvider(this@MainActivity,TestViewModelFactory(0)).get(TestViewModel::class.java)
使用ViewModel就是这么简单🚢
ViewModel常见的使用场景
- 使用ViewModel,在横竖屏切换后,Activity重建,数据仍可以保存
- 同一个Activity下,Fragment之间的数据共享
- 与LiveData配合实现代码的解耦
ViewModel和onSaveInstanceState的区别
我相信大家一定知道onSaveInstanceState,它也是用来保存UI状态的,你可以使用它保存你所想保存的东西,在Activity被杀死之前,它一般在onStop或者onPause之前触发。虽然ViewModel被设计为应用除了onSaveInstanceState的另一个选项,但是还是有一些明显的区别。由于资源限制,ViewModel无法在进程关闭后继续存在,但onSaveInstance包含执行此任务。ViewModel是存储数据的绝佳选择,而onSaveInstanceState bundles不是用于该目的的合适选项。
ViewModel用于存储尽可能多的UI数据。因此,在配置更改期间不需要重新加载或重新生成该数据。
另一方面,如果该进程被框架关闭,onSaveInstanceState应该存储回复UI状态所需的最少数据量。例如,可以将所有用户的数据存放在ViewModel中,而仅将用户的数据库ID存储在onSaveInstanceState中。
android onSaveInstanceState调用时机详细总结
ViewModel和Context
ViewModel不应该包含对Activity,Fragment或context的引用,此外,ViewModel不应包含对UI控制器(如View)的引用,因为这将创建对Context的间接引用。当您旋转Activity被销毁的屏幕时,您有一个ViewModel包含对已销毁Activity的引用,这就是内存泄漏。因此,如果需要使用上下文,则必须使用应用程序上下文 (AndroidViewModel) 。
LiveData和ViewModel的基本用法我们已经介绍完了,现在用几个例子带大家来更好的使用它们
案例一:计数器 — 两个Activity共享一个ViewModel
话不多说,先上效果图:
虽然这个案例是比较简单的,但是我相信可以帮助你更快的熟悉LiveData和ViewModel
想要实现效果图的话需要从下面几步来写(只讲解核心代码,具体代码请自己查看仓库):
第一步:创建ViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
class MainViewModel : ViewModel() {
private var _mycount: MutableLiveData<Int> = MutableLiveData()
//只暴露不可变的LiveData给外部
val mycount: LiveData<Int> get() = _mycount
init {
//初始化
_mycount.value = 0
}
/**
* mycount.value若为空就赋值为0,不为空则加一
* */
fun add() {
_mycount.value = _mycount.value?.plus(1)
}
/**
* mycount.value若为空就赋值为0,不为空则减一,可以为负数
* */
fun reduce() {
_mycount.value = _mycount.value?.minus(1)
}
/**
* 随机参数
* */
fun random() {
val random = (0..100).random()
_mycount.value = random
}
/**
* 清除数据
* */
fun clear() {
_mycount.value = 0
}
}
第二步:标记ViewModel的作用域
因为,我们是两个Activity共享一个ViewModel,所以我们需要标记ViewModel的作用域
import androidx.lifecycle.*
/**
* 用于标记viewmodel的作用域
*/
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.FIELD)
annotation
class VMScope(val scopeName: String) {}
private val vMStores = HashMap<String, VMStore>()
fun LifecycleOwner.injectViewModel() {
//根据作用域创建商店
this::class.java.declaredFields.forEach { field ->
field.getAnnotation(VMScope::class.java)?.also { scope ->
val element = scope.scopeName
var store: VMStore
if (vMStores.keys.contains(element)) {
store = vMStores[element]!!
} else {
store = VMStore()
vMStores[element] = store
}
val clazz = field.type as Class<ViewModel>
val vm = ViewModelProvider(store, ViewModelProvider.NewInstanceFactory()).get(clazz)
field.set(this, vm)
}
}
}
class VMStore : ViewModelStoreOwner {
private var vmStore: ViewModelStore? = null
override fun getViewModelStore(): ViewModelStore {
if (vmStore == null)
vmStore = ViewModelStore()
return vmStore!!
}
}
第三步:在Activity中使用(都是部分代码)
class MainActivity : AppCompatActivity() {
@VMScope("count") //设置作用域
lateinit var viewModel: MainViewModel
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
injectViewModel()
initEvent()
}
private fun initEvent() {
val cardReduce: CardView = findViewById(R.id.card_reduce)
.....
cardReduce.setOnClickListener {
//调用自定义ViewModel中的方法
viewModel.reduce()
}
.....
/**
* 订阅 ViewModel,mycount是一个LiveData类型 可以观察
* */
viewModel.mycount.observe(this@MainActivity) {
countTv.text = viewModel.mycount.value.toString()
}
}
在第二个Activity中也是类似...
这样就可以实现效果图啦🏀
案例二:同一个Activity下的两个Fragment共享一个ViewModel
话不多说,先上效果图
这个效果就很简单了,在同一个Activity下,有两个Fragment,这两个Fragment共享一个ViewModel
这个案例主要是想带大家了解一下ViewModel在Fragment中的使用
第一步:依旧是创建ViewModel
class BlankViewModel : ViewModel() {
private val numberLiveData = MutableLiveData<Int>()
private var i = 0
fun getLiveData(): LiveData<Int> {
return numberLiveData
}
fun addOne(){
i++
numberLiveData.value = i
}
}
非常简单的一个ViewModel
第二步:在Fragment中使用
//左Fragment
class LeftFragment : Fragment() {
private val viewModel:BlankViewModel by activityViewModels()
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.fragment_left, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
//对+1按钮监听
left_button.setOnClickListener {
viewModel.addOne()
}
activity?.let {it ->
viewModel.getLiveData().observe(it){
left_text.text = it.toString()
}
}
}
}
//右Fragment
class RightFragment : Fragment() {
private val viewModel: BlankViewModel by activityViewModels()
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.fragment_right, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
right_button.setOnClickListener {
viewModel.addOne()
}
activity?.let { it ->
viewModel.getLiveData().observe(it) {
right_text.text = it.toString()
}
}
}
}
这样,这个简单的案例就实现啦。
尾述
终于把LiveData和ViewModel的大致使用讲解了一遍,但仅仅这样还是不够的,你还需要在更多更多的实践中去熟悉,去深入学习....
作者:编程的平行世界
链接:https://juejin.cn/post/7111600906465968165
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
代码review,瑞出事来了!
不久之前,部门进行了一次代码评审。
代码整体比较简单,该吹B的地方都已经吹过了,无非是些if else的老问题而已。当翻到一段定时任务的一步执行代码时,我的双眼一亮,觉得该BB两句了。
谁知这群家伙,评审的时候满满的认同感,但评审结束不久,就给我冠了个事B
的称号。
今天我就把当时的这些话儿整理整理,让大家说道说道,我到底是不是个事B。淦!
一个任务处理例子
代码的结构大体是这样的。
通过定时,这段代码每天晚上凌晨都要对数据库的记录进行一遍对账。主要的逻辑,就是使用独立的线程,渐进式的读取数据库中的相关记录,然后把这些记录,放在循环中逐条进行处理。
ExecutorService service = Executors.newFixedThreadPool(10);
...
service.submit(()->{
while(true){
if(CollectionUtils.isEmpty(items)){
break;
}
List<Data> items = queryPageData(start, end); // 分页逻辑
for(Data item : items){
try {
Thread.sleep(10L);
} catch (InterruptedException e) {
//noop
}
processItem(item);
}
}
});
等一下。在代码马上被翻过去的时候,我叫停了,这里的processItem
没有捕获异常。
通常情况下,这不会有什么问题。但静好的岁月,总是偶尔会被一些随机的事故打断。如果这是你任务的完整代码,那它就有一种非常隐晦的故障处理方式。即使你的单元测试写的再好,这段代码我们依然可以通过远程投毒的方式,通过问题记录来让它产生问题。
是的。以上代码的根本原因,就是没有捕捉processItem
函数可能产生的异常。如果在记录处理的时候,有任何一条抛出了异常,不管是checked
异常还是unchecked
异常,整个任务的执行都会终止!
不要觉得简单哦,踩过这个坑的同学,请记得扣个666。或者翻一下你的任务执行代码,看看是不是也有这个问题。
Java编译器在很多情况下都会提示你把异常给捕捉了,但总有些异常会逃出去,比如空指针异常。如下图,RuntimeException和Error都属于unchecked异常。
RuntimeException
可以不用try...catch
进行处理,但是如果一旦出现异常,则会导致程序中断执行,JVM将统一处理这些异常。
你捕捉不到它,它自然会让你的任务完蛋。
如果你想要异步的执行一些任务,最好多花一点功夫到异常设计上面。在这上面翻车的同学比比皆是,这辆车并不介意再带上你一个。
评审的小伙很谦虚,马上就现场修改了代码。
不要生吞异常
且看修改后的代码。
ExecutorService service = Executors.newFixedThreadPool(10);
...
service.submit(()->{
while(true){
if(CollectionUtils.isEmpty(items)){
break;
}
List<Data> items = queryPageData(start, end); // 分页逻辑
for(Data item : items){
try {
Thread.sleep(10L);
} catch (InterruptedException e) {
//noop
}
try{
processItem(item);
}catch(Exception ex){
LOG.error(...,ex);
}
}
}
});
...
service.shutdownNow();
为了控制任务执行的频率,sleep大法是个有效的方法。
代码里考虑的很周到,按照我们上述的方式捕捉了异常。同时,还很贴心的把sleep相关的异常也给捕捉了。这里不贴心也没办法,因为不补齐这部分代码的话,编译无法通过,我们姑且认为是开发人员的水平够屌。
由于sleep抛出的是InterruptedException
,所以代码什么也没处理。这也是我们代码里常见的操作。不信打开你的项目,忽略InterruptedException的代码肯定多如牛毛。
此时,你去执行这段代码,虽然线程池使用了暴力的shutdownNow
函数,但你的代码依然无法终止,它将一直run下去。因为你忽略了InterruptedException异常。
当然,我们可以在捕捉到InterruptedException的时候,终止循环。
try {
Thread.sleep(10L);
} catch (InterruptedException e) {
break;
}
虽然这样能够完成预期,但一般InterruptedException却不是这么处理的。正确的处理方式是这样的:
while (true) {
Thread currentThread = Thread.currentThread();
if(currentThread.isInterrupted()){
break;
}
try {
Thread.sleep(1L);
} catch (InterruptedException e) {
currentThread.interrupt();
}
}
除了捕捉它,我们还要再次把interrupt状态给复位,否则它就随着捕捉给清除了。InterruptedException在很多场景非常的重要。当有些方法一直阻塞着线程,比如耗时的计算,会让整个线程卡在那里什么都干不了,InterruptedException可以中断任务的执行,是非常有用的。
但是对我们现在代码的逻辑来说,并没有什么影响。被评审的小伙伴不满意的说。
还有问题!
有没有影响是一回事,是不是好的习惯是另一回事 。我尽量的装了一下B,其实,你的异常处理代码里还有另外隐藏的问题。
还有什么问题?,大家都一改常日慵懒的表情,你倒是说说。
我们来看一下小伙伴现场改的问题。他直接使用catch捕获了这里的异常,然后记录了相应的日志。我要说的问题是,这里的Exception粒度是不对的,太粗鲁。
try{
processItem(item);
}catch(Exception ex){
LOG.error(...,ex);
}
processItem函数抛出了IOException,同时也抛出了InterruptedException,但我们都一致对待为普通的Exception,这样就无法体现上层函数抛出异常的意图。
比如processItem函数抛出了一个TimeoutExcepiton,期望我们能够基于它做一些重试;或者抛出了SystemBusyExcption,期望我们能够多sleep一会,给服务器一点时间。这种粗粒度的异常一股脑的将它们捕捉,在新异常添加的时候根本无法发现这些代码,会发生风险。
一时间会议室里寂静无比。
我觉得你说的很对 ,一位比较资深的老鸟说, 你的意思是把所有的异常情况都分别捕捉,进行精细化处理。但最后你还是要使用Exception来捕捉RuntimeException,异常还是捕捉不到啊。
果然是不同凡响的发问。
优秀的、标准的代码写法,其中无法实施的一个重要因素,就是项目中的其他代码根本不按规矩来。如果我们下层的代码,进行了正确的空指针判断、数组越界操作,或者使用类似guava的Preconditions这类API进行了前置的异常翻译,上面的这种问题根本不用回答。
但上面这种代码的情况,我们就需要手动的捕捉RuntimeException
,进行单独的处理。
你们这个项目,烂代码太多了,所以不好改。我虽然有情商,但我更有脾气。
大家不欢而散。
End
我实在是想不通,代码review就是用来发现问题的。结果这review会一开下来,大家都在背后讽刺我。这到底是我的问题呢?还是这个团队的问题呢?让人搞不懂。
你们在纠结使用Integer还是int的时候,我也没说什么呀,现在就谈点异常处理的问题,就那么玻璃心受不了了。这B不能全都让你们装了啊。
什么?你要review一下我的代码?看看我到底有没有像我说的一样写代码,有没有以身作则?是在不好意思,我可是架构师哎,我已经很多年没写代码了。
你的这个愿望让你落空了!
作者:小姐姐味道
链接:https://juejin.cn/post/7080155730694635534
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
领导:谁再用定时任务实现关闭订单,立马滚蛋!
在电商、支付等领域,往往会有这样的场景,用户下单后放弃支付了,那这笔订单会在指定的时间段后进行关闭操作,细心的你一定发现了像某宝、某东都有这样的逻辑,而且时间很准确,误差在1s内;那他们是怎么实现的呢?
一般的做法有如下几种
定时任务关闭订单
rocketmq延迟队列
rabbitmq死信队列
时间轮算法
redis过期监听
一、定时任务关闭订单(最low)
一般情况下,最不推荐的方式就是关单方式就是定时任务方式,原因我们可以看下面的图来说明
我们假设,关单时间为下单后10分钟,定时任务间隔也是10分钟;通过上图我们看出,如果在第1分钟下单,在第20分钟的时候才能被扫描到执行关单操作,这样误差达到10分钟,这在很多场景下是不可接受的,另外需要频繁扫描主订单号造成网络IO和磁盘IO的消耗,对实时交易造成一定的冲击,所以PASS
二、rocketmq延迟队列方式
延迟消息
生产者把消息发送到消息服务器后,并不希望被立即消费,而是等待指定时间后才可以被消费者消费,这类消息通常被称为延迟消息。
在RocketMQ开源版本中,支持延迟消息,但是不支持任意时间精度的延迟消息,只支持特定级别的延迟消息。
消息延迟级别分别为1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h,共18个级别。
发送延迟消息(生产者)
/**
* 推送延迟消息
* @param topic
* @param body
* @param producerGroup
* @return boolean
*/
public boolean sendMessage(String topic, String body, String producerGroup)
{
try
{
Message recordMsg = new Message(topic, body.getBytes());
producer.setProducerGroup(producerGroup);
//设置消息延迟级别,我这里设置14,对应就是延时10分钟
// "1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h"
recordMsg.setDelayTimeLevel(14);
// 发送消息到一个Broker
SendResult sendResult = producer.send(recordMsg);
// 通过sendResult返回消息是否成功送达
log.info("发送延迟消息结果:======sendResult:{}", sendResult);
DateFormat format =new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
log.info("发送时间:{}", format.format(new Date()));
return true;
}
catch (Exception e)
{
e.printStackTrace();
log.error("延迟消息队列推送消息异常:{},推送内容:{}", e.getMessage(), body);
}
return false;
}
消费延迟消息(消费者)
/**
* 接收延迟消息
*
* @param topic
* @param consumerGroup
* @param messageHandler
*/
public void messageListener(String topic, String consumerGroup, MessageListenerConcurrently messageHandler)
{
ThreadPoolUtil.execute(() ->
{
try
{
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer();
consumer.setConsumerGroup(consumerGroup);
consumer.setVipChannelEnabled(false);
consumer.setNamesrvAddr(address);
//设置消费者拉取消息的策略,*表示消费该topic下的所有消息,也可以指定tag进行消息过滤
consumer.subscribe(topic, "*");
//消费者端启动消息监听,一旦生产者发送消息被监听到,就打印消息,和rabbitmq中的handlerDelivery类似
consumer.registerMessageListener(messageHandler);
consumer.start();
log.info("启动延迟消息队列监听成功:" + topic);
}
catch (MQClientException e)
{
log.error("启动延迟消息队列监听失败:{}", e.getErrorMessage());
System.exit(1);
}
});
}
实现监听类,处理具体逻辑
/**
* 延迟消息监听
*
*/
@Component
public class CourseOrderTimeoutListener implements ApplicationListener<ApplicationReadyEvent>
{
@Resource
private MQUtil mqUtil;
@Resource
private CourseOrderTimeoutHandler courseOrderTimeoutHandler;
@Override
public void onApplicationEvent(ApplicationReadyEvent applicationReadyEvent)
{
// 订单超时监听
mqUtil.messageListener(EnumTopic.ORDER_TIMEOUT, EnumGroup.ORDER_TIMEOUT_GROUP, courseOrderTimeoutHandler);
}
}
/**
* 实现监听
*/
@Slf4j
@Component
public class CourseOrderTimeoutHandler implements MessageListenerConcurrently
{
@Override
public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> list, ConsumeConcurrentlyContext consumeConcurrentlyContext) {
for (MessageExt msg : list)
{
// 得到消息体
String body = new String(msg.getBody());
JSONObject userJson = JSONObject.parseObject(body);
TCourseBuy courseBuyDetails = JSON.toJavaObject(userJson, TCourseBuy.class);
// 处理具体的业务逻辑,,,,,
DateFormat format =new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
log.info("消费时间:{}", format.format(new Date()));
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}
}
这种方式相比定时任务好了很多,但是有一个致命的缺点,就是延迟等级只有18种(商业版本支持自定义时间),如果我们想把关闭订单时间设置在15分钟该如何处理呢?显然不够灵活。
三、rabbitmq死信队列的方式
Rabbitmq本身是没有延迟队列的,只能通过Rabbitmq本身队列的特性来实现,想要Rabbitmq实现延迟队列,需要使用Rabbitmq的死信交换机(Exchange)和消息的存活时间TTL(Time To Live)
死信交换机
一个消息在满足如下条件下,会进死信交换机,记住这里是交换机而不是队列,一个交换机可以对应很多队列。
一个消息被Consumer拒收了,并且reject方法的参数里requeue是false。也就是说不会被再次放在队列里,被其他消费者使用。
上面的消息的TTL到了,消息过期了。
队列的长度限制满了。排在前面的消息会被丢弃或者扔到死信路由上。
死信交换机就是普通的交换机,只是因为我们把过期的消息扔进去,所以叫死信交换机,并不是说死信交换机是某种特定的交换机
消息TTL(消息存活时间)
消息的TTL就是消息的存活时间。RabbitMQ可以对队列和消息分别设置TTL。对队列设置就是队列没有消费者连着的保留时间,也可以对每一个单独的消息做单独的设置。超过了这个时间,我们认为这个消息就死了,称之为死信。如果队列设置了,消息也设置了,那么会取值较小的。所以一个消息如果被路由到不同的队列中,这个消息死亡的时间有可能不一样(不同的队列设置)。这里单讲单个消息的TTL,因为它才是实现延迟任务的关键。
byte[] messageBodyBytes = "Hello, world!".getBytes();
AMQP.BasicProperties properties = new AMQP.BasicProperties();
properties.setExpiration("60000");
channel.basicPublish("my-exchange", "queue-key", properties, messageBodyBytes);
可以通过设置消息的expiration字段或者x-message-ttl属性来设置时间,两者是一样的效果。只是expiration字段是字符串参数,所以要写个int类型的字符串:当上面的消息扔到队列中后,过了60秒,如果没有被消费,它就死了。不会被消费者消费到。这个消息后面的,没有“死掉”的消息对顶上来,被消费者消费。死信在队列中并不会被删除和释放,它会被统计到队列的消息数中去
处理流程图
创建交换机(Exchanges)和队列(Queues)
创建死信交换机
如图所示,就是创建一个普通的交换机,这里为了方便区分,把交换机的名字取为:delay
创建自动过期消息队列
这个队列的主要作用是让消息定时过期的,比如我们需要2小时候关闭订单,我们就需要把消息放进这个队列里面,把消息过期时间设置为2小时
创建一个一个名为delay_queue1的自动过期的队列,当然图片上面的参数并不会让消息自动过期,因为我们并没有设置x-message-ttl参数,如果整个队列的消息有消息都是相同的,可以设置,这里为了灵活,所以并没有设置,另外两个参数x-dead-letter-exchange代表消息过期后,消息要进入的交换机,这里配置的是delay,也就是死信交换机,x-dead-letter-routing-key是配置消息过期后,进入死信交换机的routing-key,跟发送消息的routing-key一个道理,根据这个key将消息放入不同的队列
创建消息处理队列
这个队列才是真正处理消息的队列,所有进入这个队列的消息都会被处理
消息队列的名字为delay_queue2
消息队列绑定到交换机
进入交换机详情页面,将创建的2个队列(delayqueue1和delayqueue2)绑定到交换机上面
自动过期消息队列的routing key 设置为delay
绑定delayqueue2
delayqueue2 的key要设置为创建自动过期的队列的x-dead-letter-routing-key参数,这样当消息过期的时候就可以自动把消息放入delay_queue2这个队列中了
绑定后的管理页面如下图:
当然这个绑定也可以使用代码来实现,只是为了直观表现,所以本文使用的管理平台来操作
发送消息
String msg = "hello word";
MessageProperties messageProperties = newMessageProperties();
messageProperties.setExpiration("6000");
messageProperties.setCorrelationId(UUID.randomUUID().toString().getBytes());
Message message = newMessage(msg.getBytes(), messageProperties);
rabbitTemplate.convertAndSend("delay", "delay",message);
设置了让消息6秒后过期
注意:因为要让消息自动过期,所以一定不能设置delay_queue1的监听,不能让这个队列里面的消息被接受到,否则消息一旦被消费,就不存在过期了
接收消息
接收消息配置好delay_queue2的监听就好了
package wang.raye.rabbitmq.demo1;
import org.springframework.amqp.core.AcknowledgeMode;
import org.springframework.amqp.core.Binding;
import org.springframework.amqp.core.BindingBuilder;
import org.springframework.amqp.core.DirectExchange;
import org.springframework.amqp.core.Message;
import org.springframework.amqp.core.Queue;
import org.springframework.amqp.rabbit.connection.CachingConnectionFactory;
import org.springframework.amqp.rabbit.connection.ConnectionFactory;
import org.springframework.amqp.rabbit.core.ChannelAwareMessageListener;
import org.springframework.amqp.rabbit.listener.SimpleMessageListenerContainer;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
publicclassDelayQueue{
/** 消息交换机的名字*/
publicstaticfinalString EXCHANGE = "delay";
/** 队列key1*/
publicstaticfinalString ROUTINGKEY1 = "delay";
/** 队列key2*/
publicstaticfinalString ROUTINGKEY2 = "delay_key";
/**
* 配置链接信息
* @return
*/
@Bean
publicConnectionFactory connectionFactory() {
CachingConnectionFactory connectionFactory = newCachingConnectionFactory("120.76.237.8",5672);
connectionFactory.setUsername("kberp");
connectionFactory.setPassword("kberp");
connectionFactory.setVirtualHost("/");
connectionFactory.setPublisherConfirms(true); // 必须要设置
return connectionFactory;
}
/**
* 配置消息交换机
* 针对消费者配置
FanoutExchange: 将消息分发到所有的绑定队列,无routingkey的概念
HeadersExchange :通过添加属性key-value匹配
DirectExchange:按照routingkey分发到指定队列
TopicExchange:多关键字匹配
*/
@Bean
publicDirectExchange defaultExchange() {
returnnewDirectExchange(EXCHANGE, true, false);
}
/**
* 配置消息队列2
* 针对消费者配置
* @return
*/
@Bean
publicQueue queue() {
returnnewQueue("delay_queue2", true); //队列持久
}
/**
* 将消息队列2与交换机绑定
* 针对消费者配置
* @return
*/
@Bean
@Autowired
publicBinding binding() {
returnBindingBuilder.bind(queue()).to(defaultExchange()).with(DelayQueue.ROUTINGKEY2);
}
/**
* 接受消息的监听,这个监听会接受消息队列1的消息
* 针对消费者配置
* @return
*/
@Bean
@Autowired
publicSimpleMessageListenerContainer messageContainer2(ConnectionFactory connectionFactory) {
SimpleMessageListenerContainer container = newSimpleMessageListenerContainer(connectionFactory());
container.setQueues(queue());
container.setExposeListenerChannel(true);
container.setMaxConcurrentConsumers(1);
container.setConcurrentConsumers(1);
container.setAcknowledgeMode(AcknowledgeMode.MANUAL); //设置确认模式手工确认
container.setMessageListener(newChannelAwareMessageListener() {
publicvoid onMessage(Message message, com.rabbitmq.client.Channel channel) throwsException{
byte[] body = message.getBody();
System.out.println("delay_queue2 收到消息 : "+ newString(body));
channel.basicAck(message.getMessageProperties().getDeliveryTag(), false); //确认消息成功消费
}
});
return container;
}
}
这种方式可以自定义进入死信队列的时间;是不是很完美,但是有的小伙伴的情况是消息中间件就是rocketmq,公司也不可能会用商业版,怎么办?那就进入下一节
四、时间轮算法
(1)创建环形队列,例如可以创建一个包含3600个slot的环形队列(本质是个数组)
(2)任务集合,环上每一个slot是一个Set
同时,启动一个timer,这个timer每隔1s,在上述环形队列中移动一格,有一个Current Index指针来标识正在检测的slot。
Task结构中有两个很重要的属性:
(1)Cycle-Num:当Current Index第几圈扫描到这个Slot时,执行任务
(2)订单号,要关闭的订单号(也可以是其他信息,比如:是一个基于某个订单号的任务)
假设当前Current Index指向第0格,例如在3610秒之后,有一个订单需要关闭,只需:
(1)计算这个订单应该放在哪一个slot,当我们计算的时候现在指向1,3610秒之后,应该是第10格,所以这个Task应该放在第10个slot的Set中
(2)计算这个Task的Cycle-Num,由于环形队列是3600格(每秒移动一格,正好1小时),这个任务是3610秒后执行,所以应该绕3610/3600=1圈之后再执行,于是Cycle-Num=1
Current Index不停的移动,每秒移动到一个新slot,这个slot中对应的Set,每个Task看Cycle-Num是不是0:
(1)如果不是0,说明还需要多移动几圈,将Cycle-Num减1
(2)如果是0,说明马上要执行这个关单Task了,取出订单号执行关单(可以用单独的线程来执行Task),并把这个订单信息从Set中删除即可。
(1)无需再轮询全部订单,效率高
(2)一个订单,任务只执行一次
(3)时效性好,精确到秒(控制timer移动频率可以控制精度)
五、redis过期监听
1.修改redis.windows.conf配置文件中notify-keyspace-events的值
默认配置notify-keyspace-events的值为 ""
修改为 notify-keyspace-events Ex 这样便开启了过期事件
2. 创建配置类RedisListenerConfig(配置RedisMessageListenerContainer这个Bean)
package com.zjt.shop.config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
@Configuration
public class RedisListenerConfig {
@Autowired
private RedisTemplate redisTemplate;
/**
* @return
*/
@Bean
public RedisTemplate redisTemplateInit() {
// key序列化
redisTemplate.setKeySerializer(new StringRedisSerializer());
//val实例化
redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer());
return redisTemplate;
}
@Bean
RedisMessageListenerContainer container(RedisConnectionFactory connectionFactory) {
RedisMessageListenerContainer container = new RedisMessageListenerContainer();
container.setConnectionFactory(connectionFactory);
return container;
}
}
3.继承KeyExpirationEventMessageListener创建redis过期事件的监听类
package com.zjt.shop.common.util;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import com.zjt.shop.modules.order.service.OrderInfoService;
import com.zjt.shop.modules.product.entity.OrderInfoEntity;
import com.zjt.shop.modules.product.mapper.OrderInfoMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.connection.Message;
import org.springframework.data.redis.listener.KeyExpirationEventMessageListener;
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
import org.springframework.stereotype.Component;
@Slf4j
@Component
public class RedisKeyExpirationListener extends KeyExpirationEventMessageListener {
public RedisKeyExpirationListener(RedisMessageListenerContainer listenerContainer) {
super(listenerContainer);
}
@Autowired
private OrderInfoMapper orderInfoMapper;
/**
* 针对redis数据失效事件,进行数据处理
* @param message
* @param pattern
*/
@Override
public void onMessage(Message message, byte[] pattern) {
try {
String key = message.toString();
//从失效key中筛选代表订单失效的key
if (key != null && key.startsWith("order_")) {
//截取订单号,查询订单,如果是未支付状态则为-取消订单
String orderNo = key.substring(6);
QueryWrapper<OrderInfoEntity> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("order_no",orderNo);
OrderInfoEntity orderInfo = orderInfoMapper.selectOne(queryWrapper);
if (orderInfo != null) {
if (orderInfo.getOrderState() == 0) { //待支付
orderInfo.setOrderState(4); //已取消
orderInfoMapper.updateById(orderInfo);
log.info("订单号为【" + orderNo + "】超时未支付-自动修改为已取消状态");
}
}
}
} catch (Exception e) {
e.printStackTrace();
log.error("【修改支付订单过期状态异常】:" + e.getMessage());
}
}
}
4:测试
通过redis客户端存一个有效时间为3s的订单:
结果:
作者:程序员阿牛
链接:https://juejin.cn/post/6987233263660040206
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
什么是响应式编程:以RxJava为例
RxJava思想
文章概述:
- 本文围绕Rx编程思想(响应式编程)进行深入细致探讨;以获取服务器图片为例,通过传统方式与Rx方式对比进一步体现Rx 编程方式的魅力;借助卡片式编程思想,对Rx编程方式进行第一次优化;借助 Java泛型对Rx编程进一步优化;
Rx编程出现背景:改变思维来提升效率
通过事件流动,推进业务执行
从起点到终点,逻辑严密
- 下一层依赖上一层:体现在函数参数
链式调用只是里面的一环
样例:每一层逻辑上关联
- 起点(分发事件:点击登录)----------登录API-------请求服务器--------获取响应码----------> 终点(更新UI登录成功 消费事件)
RxJava 配合 Retrofit
业务逻辑:
- Retrofit通过OKHHTTP请求服务器拿到响应码,交给RxJava由RxJava处理数据
防抖:
- 一秒钟点击了20次,只响应一次
网络嵌套:拿到主数据再拿到item数据
doNext运用:异步与主线之间频繁切换
- 异步线程A拿到数据,切换至UI线程更新,再次切换到异步线程B,再拿到UI线程
对比说明Rx 编程优势:统一业务代码逻辑
- 主要内容:以获取服务器图片为例,通过传统方式与Rx方式对比进一步体现Rx 编程方式的魅力;
传统模式获取图片
实现效果:
传统编写思路:
弹出加载框
开启异步线程:此时有多种途径
- 封装方法....
- 全部写在一起
- new Thread
- 使用 线程池
将从服务器获取的图片转成Bitmap
从异步线程切换至UI线程更新UI
代码实现:
public void downloadImageAction(View view) {
progressDialog = new ProgressDialog(this);
progressDialog.setTitle("下载图片中...");
progressDialog.show();
// 异步线程处理耗时任务
new Thread(new Runnable() {
@Override
public void run() {
try {
URL url = new URL(PATH);
HttpURLConnection httpURLConnection = (HttpURLConnection) url.openConnection();
httpURLConnection.setConnectTimeout(5000);
int responseCode = httpURLConnection.getResponseCode(); // 才开始 request
if (responseCode == HttpURLConnection.HTTP_OK) {
InputStream inputStream = httpURLConnection.getInputStream();
// 图片丢给bitmap
Bitmap bitmap = BitmapFactory.decodeStream(inputStream);
// 使用Handler 进行切换
Message message = handler.obtainMessage();
message.obj = bitmap;
handler.sendMessage(message);
}
} catch (Exception e) {
e.printStackTrace();
}
}
}).start();
}
// 使用Handler处理问题
private final Handler handler = new Handler(new Handler.Callback() {
@Override
public boolean handleMessage(@NonNull Message msg) {
Bitmap bitmap = (Bitmap) msg.obj;
image.setImageBitmap(bitmap);
if (progressDialog != null) progressDialog.dismiss();
return false;
}
});
传统方式弊端:
在具体实现(切换线程)时,因为思维不统一,导致实现方式不同
RxJava思路:采用观察者设计模式,实现响应式(Rx)编程
以事件流动推进业务执行
角色:
起点:被观察者(为其分配异步线程--->请求服务器)
// 起点
Observable.just(PATH) // 内部会分发 PATH Stirng // TODO 第二步
终点:观察者(为其分配UI线程--->更新UI)
//终点
.subscribe(
new Observer<Bitmap>() {
//订阅
@Override
public void onSubscribe(Disposable d) {
}
//拿到事件:因为上一层是一个String类型的Path事件
@Override
public void onNext(@NonNull Bitmap bitmap) {
image.setImageBitmap(bitmap);
}
// 错误事件
@Override
public void onError(Throwable e) {
}
// 完成事件
@Override
public void onComplete() {
}
});
编写思路:框架在实际使用中是U型逻辑(终点--->起点--->终点--->……)
第一步:处理终点中拿到事件后的业务逻辑
//拿到事件:因为上一层是一个String类型的Path事件
@Override
public void onNext(@NonNull String s) {
image.setImageBitmap(bitmap);
}
细节:onNext的参数问题
Rx 整体是以事件流动推进业务逻辑,如果上一层是String类型的事件(Path)那么它的下一层应该也是String类型的事件(参数为String类型)
但Rx 中根据业务进行事件的拦截
- A层(String事件),B层(Bitmap事件),逻辑为A层--->B层
- 那么就需要在A层到B层之间添加一个拦截器,进行事件转换
第二步:在起点与终点之间添加拦截器
为什么要添加拦截器:业务需求是拿到一个Bitmap而起点提供的是String类型的事件
拦截器为map(K,V):K为上层事件,V为下层事件
//上层事件为String类型,由系统自动推断;但此时拦截器并不知道下一层是什么事件,因此为Object
.map(new Function<String, Object>() {
})
终点要求Bitmap事件
//根据业务将map 中的value改为 Bitmap类型
.map(new Function<String, Object>() {
})
终点完成事件(onNext报错,联动变化):注意由Rx思想决定,那么终点处的完成事件参数因为Bitmap
//由Rx思想决定,那么终点处的完成事件参数因为Bitmap
@Override
public void onNext(@NonNull Bitmap bitmap) {
image.setImageBitmap(bitmap);
}
整体事件流向:
第三步:在拦截器内添加网络请求
@Override
public Bitmap apply(@NonNull String s) throws Exception {
//处理网络请求:将String类型的Path事件处理为Bitmap实例
URL url = new URL(PATH);
HttpURLConnection httpURLConnection = (HttpURLConnection) url.openConnection();
int responseCode = httpURLConnection.getResponseCode();
if(responseCode == httpURLConnection.HTTP_OK){
InputStream inputStream = httpURLConnection.getInputStream();
Bitmap bitmap = BitmapFactory.decodeStream(inputStream);
return bitmap;
}
return null;
}
- 此时不要使用Handler,因为拦截器已经将String类型事件转为Bitmap类型了,将Bitmap流向终点进行显示
第四步:分配线程
起点到此时拦截器结束,应当分配异步线程(因为需要请求服务器)
//给上边代码分配异步线程,用于请求服务器
.subscribeOn(Schedulers.io())
拦截器结束位置到终点处,应当分配UI主线程(因为需要更新UI)
//给下边的代码分配主线程,用于更新UI
.observeOn(AndroidSchedulers.mainThread())
分配的主线程跟下面这个是一样的
// Thread.currentThread().getName(); == Android的主线程,这个跟RxJava切的android主线程是一样的
到此基础功能已经实现,为了使得用户友好,需要添加下列步骤
Rx 代码优化(一):卡片式编程
什么是卡片式编程:
- 因为Rx 响应式编程是依靠事件流动推进业务执行,那么我们可以在起点与终点之间添加卡片(拦截器)实现具体的业务功能
代码扩展:点击按钮后立即加载对话框,拿到图片并更新,随后关闭对话框
整体流程:
预处理:点击按钮后,立即加载对话框,开始准备事件分发
//在终点订阅开始处加载对话框(预处理操作)
// 订阅开始:一订阅就要显示对话框
@Override
public void onSubscribe(Disposable d) {
// 第一步:事件分发前预准备
progressDialog = new ProgressDialog(Test.this);
progressDialog.setTitle("开始下载");
progressDialog.show();
}
第一步:回到起点,开始分发事件
Observable.just(PATH)
第二步:拦截器工作将String事件转为Bitmap事件(附带网络请求,从服务器拿到数据)
第三步:抵达终点拿到事件处,更新UI
//拿到事件:因为上一层是一个String类型的Path事件
@Override
public void onNext(@NonNull Bitmap bitmap) {
image.setImageBitmap(bitmap);
}
第四步:抵达终点完成事件完成处,此时事件整体结束(Rx 编程结束尾巴)
// 完成事件
@Override
public void onComplete() {
//如果不为空那么就隐藏起来
if (progressDialog != null)
progressDialog.dismiss();
}
这种编程方式成为卡片式编程
好处:后期若需要添加功能,仅需在起点与重点之间添加对应的拦截器,在其中进行处理即可
图片示例:一开始的
- 事件流动顺序
运行结果:
图片示例:此时需要添加个需求,将下载下来的图片添加水印后再展示
事件流动顺序
添加代码:图片上绘制文字 加水印
// 图片上绘制文字 加水印
private final Bitmap drawTextToBitmap(Bitmap bitmap, String text, Paint paint, int paddingLeft, int paddingTop) {
Bitmap.Config bitmapConfig = bitmap.getConfig();
paint.setDither(true); // 获取跟清晰的图像采样
paint.setFilterBitmap(true);// 过滤一些
if (bitmapConfig == null) {
bitmapConfig = Bitmap.Config.ARGB_8888;
}
bitmap = bitmap.copy(bitmapConfig, true);
Canvas canvas = new Canvas(bitmap);
canvas.drawText(text, paddingLeft, paddingTop, paint);
return bitmap;
}
添加代码:在前面一个拦截器后添加
.map(new Function<Bitmap, Bitmap>() {
@Override
public Bitmap apply(@NonNull Bitmap bitmap) throws Exception {
//开始添加水印
Paint paint = new Paint();
paint.setTextSize(88);
paint.setColor(Color.GREEN);
return drawTextToBitmap(bitmap,"水印",paint,88,88);
}
})
运行结果:从服务器获取图片并添加水印
还可以添加:及时记录日志等功能
Rx 代码优化(二):封装代码部分功能提升程序结构
- 封装线程分配
//为上游(起点到拦截器结束)分配异步线程,为下游(拦截器结束位置到终点结束)分配android主线程
private final static <UD> ObservableTransformer<UD,UD> opMixed(){
return new ObservableTransformer<UD, UD>() {
@NonNull
@Override
//分配线程
public ObservableSource<UD> apply(@NonNull Observable<UD> upstream) {
return upstream.subscribeOn(Schedulers.io()).
observeOn(AndroidSchedulers.mainThread())
//继续链式调用
.map(new Function<UD, UD>() {
@Override
public UD apply(@NonNull UD ud) throws Exception {
Log.d(TAG,"日志记录")
return ud;
}
})
//还可以加卡片(拦截器)
;
}
};
}
- 仅需在终点前调用封装好的库就行了
……
//是需要在终点前调用封装好的东西就行了
.compose(opMixed())
//终点
.subscribe(
Rx 编程完整代码:
package com.xiangxue.rxjavademo.downloadimg;
import android.app.ProgressDialog;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.os.Bundle;
import android.os.Handler;
import android.os.Message;
import android.util.Log;
import android.view.View;
import android.widget.ImageView;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;
import com.xiangxue.rxjavademo.R;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import io.reactivex.Observable;
import io.reactivex.ObservableSource;
import io.reactivex.ObservableTransformer;
import io.reactivex.Observer;
import io.reactivex.android.schedulers.AndroidSchedulers;
import io.reactivex.disposables.Disposable;
import io.reactivex.functions.Function;
import io.reactivex.schedulers.Schedulers;
public class Test extends AppCompatActivity {
// 网络图片的链接地址,String类型的Path事件
private final static String PATH = "http://pic1.win4000.com/wallpaper/c/53cdd1f7c1f21.jpg";
// 弹出加载框
private ProgressDialog progressDialog;
// ImageView控件,用来显示结果图像
private ImageView image;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_download);
image = findViewById(R.id.image);
// Thread.currentThread().getName(); == Android的主线程,这个跟RxJava切的android主线程是一样的
}
// 通过订阅将 起点 和 终点 关联起来
public void rxJavaDownloadImageAction(View view) {
// 起点
Observable.just(PATH) // 内部会分发 PATH Stirng // TODO 第二步
//流程中的卡片
.map(new Function<String, Bitmap>() {
@Override
public Bitmap apply(@NonNull String s) throws Exception {
//处理网络请求:将String类型的Path事件处理为Bitmap实例
URL url = new URL(PATH);
HttpURLConnection httpURLConnection = (HttpURLConnection) url.openConnection();
int responseCode = httpURLConnection.getResponseCode();
if(responseCode == httpURLConnection.HTTP_OK){
InputStream inputStream = httpURLConnection.getInputStream();
Bitmap bitmap = BitmapFactory.decodeStream(inputStream);
return bitmap;
}
return null;
}
})
//给上边代码分配异步线程,用于请求服务器
.subscribeOn(Schedulers.io())
//给下边的代码分配主线程,用于更新UI
.observeOn(AndroidSchedulers.mainThread())
//终点
.subscribe(
new Observer<Bitmap>() {
// 订阅开始:一订阅就要显示对话框
@Override
public void onSubscribe(Disposable d) {
// 第一步:事件分发前预准备
progressDialog = new ProgressDialog(Test.this);
progressDialog.setTitle("开始下载");
progressDialog.show();
}
//拿到事件:因为上一层是一个String类型的Path事件
@Override
public void onNext(@NonNull Bitmap bitmap) {
image.setImageBitmap(bitmap);
}
// 错误事件
@Override
public void onError(Throwable e) {
}
// 完成事件
@Override
public void onComplete() {
//如果不为空那么就隐藏起来
if (progressDialog != null)
progressDialog.dismiss();
}
});
}
// 图片上绘制文字 加水印
private final Bitmap drawTextToBitmap(Bitmap bitmap, String text, Paint paint, int paddingLeft, int paddingTop) {
Bitmap.Config bitmapConfig = bitmap.getConfig();
paint.setDither(true); // 获取跟清晰的图像采样
paint.setFilterBitmap(true);// 过滤一些
if (bitmapConfig == null) {
bitmapConfig = Bitmap.Config.ARGB_8888;
}
bitmap = bitmap.copy(bitmapConfig, true);
Canvas canvas = new Canvas(bitmap);
canvas.drawText(text, paddingLeft, paddingTop, paint);
return bitmap;
}
}
作者:WAsbry
链接:https://juejin.cn/post/7112098300626485284
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
【面试黑洞】Android 的键值对存储有没有最优解?
正文
这是我在网上找到的一份 Android 键值对存储方案的性能测试对比(数越小越好):
可以看出,DataStore 的性能比 MMKV 差了一大截。MMKV 是腾讯在 2018 年推出的,而 DataStore 是 Android 官方在 2020 年推出的,并且它的正式版在 2021 年 8 月才发布。一个官方发布的、更(gèng)新的库,性能竟然比不过比它早两年发布的、第三方的库。而且我们能看到,更离谱的是,它甚至还比不过 SharedPreferences 。Android 官方当初之所以推出 DataStore,就是要替代掉 SharedPreferences,并且主要原因之一就是 SharedPreferences 有性能问题,可是测试结果却是它的性能不如 SharedPreferences。
所以,这到底是为什么?
啊,我知道了——因为 Google 是傻逼!
SharedPreferences:不知不觉被嫌弃
大家好,我是扔物线朱凯。
键值对的存储在移动开发里非常常见。比如深色模式的开关、软件语言、字体大小,这些用户偏好设置,很适合用键值对来存。而键值对的存储方案,最传统也最广为人知的就是 Android 自带的 SharedPreferences
。它里面的 -Preferences,就是偏好设置的意思,从名字也能看出它最初的定位。
SharedPreferences 使用起来很简单,也没什么问题,大家就这么用了很多年。——但!渐渐地,有人发现它有一个问题:卡顿,甚至有时候会出现 ANR。
MMKV:好快!
怎么办?换!2018 年 9 月,腾讯开源了一个叫做 MMKV 的项目。它和 SharedPreferences 一样,都是做键值对存储的,可是它的性能比 SharedPreferences 强很多。真的是强,很,多。在 MMKV 推出之后,很多团队就把键值对存储方案从 SharedPreferences 换到了 MMKV。
DataStore:官方造垃圾?
再然后,就是又过了两年,Google 自己也表示受不了 SharedPreferences 了,Android 团队公布了 Jetpack 的新库:DataStore,目标直指 SharedPreferences,声称它就是 Android 官方给出的 SharedPreferences 的替代品。
替代的理由,Android 团队列了好几条,但不出大家意料地,「性能」是其中之一:
也就是说,Android 团队直接抛弃了 SharedPreferences,换了个新东西来提供更优的性能。
但是,问题随之就出现了:大家一测试,发现这 DataStore 的性能并不强啊?跟 MMKV 比起来差远了啊?要知道,MMKV 的发布是比 DataStore 早两年的。DataStore 比人家晚两年发布,可是性能却比人家差一大截?甚至,从测试数据来看,它连要被它替代掉的 SharedPreferences 都比不过。这么弱?那它搞个毛啊!
Android 团队吭哧吭哧搞个新东西出来,竟然还没有市场上两年前就出现的东西强?这是为啥?
首先,肯定得排除「DataStore 是垃圾」这个可能性。虽然这猛一看、粗一想,明显就是 DataStore 垃圾、Google 傻逼,但是你仔细想想,这可能吗?
那如果不是的话,又是因为什么?——因为你被骗了。
MMKV 的一二三四
被谁骗了?不是被 MMKV 骗了,也不是具体的某个人。事情其实是这样的:
大家知道 MMKV 当初为什么会被创造出来吗?其实不是为了取代 SharedPreferences。
最早是因为微信的一个需求(来源:MMKV 组件现在开源了):
微信作为一个全民的聊天 App,对话内容中的特殊字符所导致的程序崩溃是一类很常见、也很需要快速解决的问题;而哪些字符会导致程序崩溃,是无法预知的,只能等用户手机上的微信崩溃之后,再利用类似时光倒流的回溯行为,看看上次软件崩溃的最后一瞬间,用户收到或者发出了什么消息,再用这些消息中的文字去尝试复现发生过的崩溃,最终试出有问题的字符,然后针对性解决。
那么这个「时光倒流」应该怎么做,就成了问题的关键。我们要知道,程序中的所有变量都是存活在内存里的,一旦程序崩溃,所有变量全都灰飞烟灭。
所以要想实现「时光倒流」,就需要把想回溯的时光预先记录下来。说人话就是,我们需要把界面里显示的文字写到手机磁盘里,才能在程序崩溃、重新启动之后,通过读取文件的方式来查看。
更麻烦的是,这种记录的目标是用来回溯查找「导致程序崩溃的那段文字」,而同时,正是因为没有人知道哪段文字会导致程序崩溃才去做的记录,这就要求每一段文字都需要先写入磁盘、然后再去显示,这样才能保证程序崩溃的时候那段导致崩溃的文字一定已经被记录到了磁盘。
对吧?
这就有点难了。
我们来想象一下实际场景:
- 如果用户的微信现在处于一个对话界面中,这时候来了一条新的消息,这条消息里可能会包含微信处理不了的字符,导致微信的崩溃。
- 而微信为了及时地找出导致崩溃的字符或者字符串,所以给程序增加了逻辑:所有的对话内容在显示之前,先保存到磁盘再显示:
val bubble: WxTextView = ...
recordTextToDisk(text) // 显示之前,先保存到磁盘
bubble.setText(text)
- 那么你想一下,这个「保存到磁盘」的行为,我应该做成同步的还是异步的?
- 为了不卡主线程,我显然应该做成异步的;
- 但这是马上就要显示的文字,如果做成异步的,就极有可能在程序崩溃的时候,后台线程还没来得及把文字存到磁盘。这样的话,就无法进行回溯,从而这种记录也就失去了价值。
- 所以从可用性的角度来看,我只能选择放弃性能,把它做成同步的,也就是在主线程进行磁盘的写操作。
- 一次磁盘的写操作,花个一两毫秒是很正常的,三五毫秒甚至超过 10 毫秒也都是有可能的。具体的方案可以选择
SharedPreferences
,也可以选择数据库,但不管选哪个,只要在主线程去完成这个写操作,这种耗时就绝对无法避免。一帧的时间也就 16 毫秒而已——那时候还没有高刷,我们就先不谈高刷了,一帧就是 16 毫秒——16 毫秒里来个写磁盘的操作,用户很可能就会感受到一次卡顿。
- 这还是相对比较好的情况。我们再想一下,如果用户点开了一个活跃的群,这个群里有几百条没看过的消息:
- 那么在他点开的一瞬间,是不是界面中会显示出好几条消息气泡?这几条消息的内容,哪些需要记录到磁盘?全都要记录的,因为谁也知道哪一条会导致微信的崩溃,任何一条都是可能的。
- 而如果把这几条消息都记录下来,是不是每条消息的记录都会涉及一次写磁盘的操作?这几次写磁盘行为,是发生在同一帧里的,所以在这一帧里因为记录文字而导致的主线程耗时,也会相比起刚才的例子翻上好几倍,卡顿时间就同样也会翻上好几倍。
- 还有更差的情况。如果用户看完这一页之后,决定翻翻聊天记录,看看大家之前都聊了什么:
- 这时候,是不是上方每一个新的聊天气泡的出现,都会涉及一次主线程上的写磁盘行为?
- 而如果用户把手猛地往下一滑,让上面的几十条消息依次滑动显示出来,这是不是就会导致一次爆发性的、集中式的对磁盘的写入?
- 用户的手机,一定会卡爆。
所以这种「高频、同步写入磁盘」的需求,让所有的现有方案都变得不可行了:不管你是用 SharedPreferences
还是用数据库还是别的什么,只要你在主线程同步写入磁盘,就一定会卡,而且是很卡。
但是微信还是有高手,还是有能想办法的人,最终微信找到了解决方案。他们没有用任何的现成方案,而是使用了一种叫做内存映射(mmap()
)的底层方法。
15.18.23@2x.png" loading="lazy">
它可以让系统为你指定的文件开辟一块专用的内存,这块内存和文件之间是自动映射、自动同步的关系,你对文件的改动会自动写到这块内存里,对这块内存的改动也会自动写到文件里。
更多更深的原理,说实话我也不是看得很懂,就不跟大家装了。但关键是,有了这一层内存作为中间人,我们就可以用「写入内存」的方式来实现「写入磁盘」的目标了。内存的速度多快呀,耗时几乎可以忽略,这样一下子就把写磁盘造成卡顿的问题解决了。
而且这个内存映射还有一点很方便的是,虽然这块映射的内存不是实时向对应的文件写入新数据,但是它在程序崩溃的时候,并不会随着进程一起被销毁掉,而是会继续有条不紊地把它里面还没同步完的内容同步到它所映射的文件里面去。
至于更下层的原理,我也说了,没看懂,你也别问我。
总之,有了这些特性,内存映射就可以让程序用往内存里写数据的速度实现往磁盘里写数据的实际效果,这样的话,「高频、同步写入磁盘」的需求就完美满足了。不管是用户打开新的聊天页面,还是滑动聊天记录来查看聊天历史,用内存映射的方式都可以既实时写入所有即将被渲染的文字,又不会造成界面的卡顿。这种性能,是 SharedPreferences
和数据库都做不到的——顺便提一句,虽然我总在提 SharedPreferences
,但其实这种做法本来是先在 iOS 版的微信里应用的,后来才移植到了 Android 版微信。这也是我刚才说的,MMKV 的诞生并不是为了取代 SharedPreferences。
再后来,就是 2018 年,微信把这个叫做 MMKV 的项目开源了。它的名字,我猜就是直白的「Memory-Map based Key-Value(方案)」,基于内存映射的键值对。不过没有找作者求证,如果说错了欢迎指正。
在 MMKV 开源之后,很多团队就把键值对存储方案从 SharedPreferences
迁移到了 MMKV。为什么?因为它快呀。
MMKV 并不总是快如闪电
不过……事情其实没那么简单。MMKV 虽然大的定位方向和 SharedPreferences
一样,都是对于键值对的存储,但它并不是一个全方位更优的方案。
比如性能。我前面一直在说 MMKV 的性能更强,对吧?但事实上,它并不是任何时候都更强。由于内存映射这种方案是自行管理一块独立的内存,所以它在尺寸的伸缩上面就比较受限,这就导致它在写大一点的数据的时候,速度会慢,而且可能会很慢。我做了一份测试:
在连续 1000 次写入 Int
值的场景中,SharedPreferences
的耗时是 1034 毫秒,也就是 1 秒多一点;而 MMKV 只有 2 毫秒,简直快得离谱;而且最离谱的是,Android 官方最新推出的 DataStore
是 1215 毫秒,竟然比 SharedPreferences
还慢。这个前面我也提过,别人的测试也是这样的结果。
可是,SharedPreferences
是有异步 API 的,而 DataStore 是基于协程的。这就意味着,它们实际占用主线程的时间是可以低于这份测试出的时间的,而界面的流畅在意的正是主线程的时间消耗。所以如果我统计的不是全部的耗时,而是主线程的耗时,那么统计出的 SharedPreferences
和 DataStore
的耗时将会大幅缩减:
还是比 MMKV 慢很多,是吧?但是这是对于 Int
类型的高频写入,Int 数据是很小的。而如果我把写入的内容换成长字符串,再做一次测试:
MMKV 就不具备优势了,反而成了耗时最久的;而这时候的冠军就成了 DataStore,并且是遥遥领先。这也就是我在开头说的:你可能被骗了。被谁骗了?被「耗时」这个词:我们关注性能,考量的当然是耗时,但要明确:是主线程的耗时。所以视频开头的那张图,是不具备任何参考意义的。
20.52.01@2x.png" loading="lazy">
但其实,它们都够快了
不过在换成了这种只看主线程的耗时的对比方案之后,我们会发现谁是冠军其实并不是很重要,因为从最终的数据来看,三种方案都不是很慢。虽然这半秒左右的主线程耗时看起来很可怕,但是要知道这是 1000 次连续写入的耗时,而我们在真正写程序的时候,怎么会一次性做 1000 次的长字符串的写入?所以真正在项目中的键值对写入的耗时,不管你选哪个方案,都会比这份测试结果的耗时少得多的,都少到了可以忽略的程度,这是关键。
各自的优势和弱点
那……既然它们的耗时都少到了可以忽略,不就是选谁都行?那倒不是。
MMKV 优势:写速度极快
我们来看一个 MMKV 官方给出的数据对比图:
从这张图看来,SharedPreferences
的耗时是 MMKV 的接近 60 倍。很明显,如果 SharedPreferences 用异步的 API 也就是 apply()
来保存的话,是不可能有这么差的性能的,这个一定是使用同步的 commit()
的性能来做的对比。那么为什么 MMKV 官方会这样做对比呢?这个又要说到它的诞生场景了:MMKV 最初的功能是在文字显示之前先把它记录到磁盘,然后如果接下来这个文字显示失败导致程序崩溃,稍后就可以从磁盘里把这段文字恢复出来,进行分析。而刚才我也说过,这种场景的特殊性在于,导致程序崩溃的文字往往是刚刚被记录下来,程序就崩溃了,所以如果采用异步处理的方案,就很有可能在文字还没来得及真正存储到磁盘的时候程序就发生了崩溃,那就没办法把它恢复出来进行分析了。因此这样的场景,是不能接受异步处理的方案的,只能同步进行。所以 MMKV 在意的,就是同步处理机制下的耗时,它不在意异步,因为它不接受异步。
而在同步处理的机制下,MMKV 的性能优势就太明显了。原因上面说过了,它写入内存就几乎等于写入了磁盘,所以速度巨快无比。这就是 MMKV 的优势之一:极高的同步写入磁盘的性能。
另外 MMKV 还有个特点是,它的更新并不像 SharedPreferences
那样全量重新写入磁盘,而是只把要更新的键值对写入,也就是所谓的增量式更新。这也会给它带来一些性能优势,不过这个优势并不算太核心,因为 SharedPreferences
虽然是全量更新的模式,但只要把保存的数据用合适的逻辑拆分到多个不同的文件里,全量更新并不会对性能造成太大的拖累。所以这个性能优势虽然有,但并不是关键。
还有刚才提到的,对于大字符串的场景,MMKV 的写入性能并不算快,甚至在我们的测试结果里是最慢的,对吧?这一点算是劣势。但是实事求是地说,我们在开发里不太可能连续不断地去写入大字符串吧?所以这个性能劣势虽然有,但也并不是关键。
整体来说,MMKV 比起 SharedPreferences 和 DataStore 来说,在写入小数据的情况下,具有很高的写入性能,这就让高频写入的场景非常适合使用 MMKV 来处理。因此如果你的项目里也有像微信的崩溃回溯的这种高频写入的需求,MMKV 就很可能是你的最佳方案。而如果你除了「高频写入」,还和微信一样要求「同步写入」,那 MMKV 就可能是你的唯一选择了。不过,如果你真的主要是存储大字符串的——例如你写的是一个文本编辑软件,需要保存的总是大块的文本——那么用 MMKV 不一定会更快了,甚至可能会比较慢。
MMKV 优势:支持多进程
另外,MMKV 还有一个巨大的优势:它支持多进程。
行业内也有很多公司选用 MMKV 并不是因为它快,而是因为它支持多进程。SharedPreferences 是不支持多进程的,DataStore 也不支持——从 DataStore 提交的代码来看,它已经在加入多进程的支持了,但目前还没有实现。所以如果你们公司的 App 是需要在多个进程里访问键值对数据,那么 MMKV 是你唯一的选择。
MMKV 劣势:丢数据
除了速度快和支持多进程这两个优势之外,MMKV 也有一个弱点:它会丢数据。
任何的操作系统、任何的软件,在往磁盘写数据的过程中如果发生了意外——例如程序崩溃,或者断电关机——磁盘里的文件就会以这种写了一半的、不完整的形式被保留。写了一半的数据怎么用啊?没法用,这就是文件的损坏。这种问题是不可能避免的,MMKV 虽然由于底层机制的原因,在程序崩溃的时候不会影响数据往磁盘的写入,但断电关机之类的操作系统级别的崩溃,MMKV 就没办法了,文件照样会损坏。对于这种文件损坏,SharedPreferences 和 DataStore 的应对方式是在每次写入新数据之前都对现有文件做一次自动备份,这样在发生了意外出现了文件损坏之后,它们就会把备份的数据恢复过来;而 MMKV,没有这种自动的备份和恢复,那么当文件发生了损坏,数据就丢了,之前保存的各种信息只能被重置。也就是说,MMKV 是唯一会丢数据的方案。
可能会有人好奇,为什么 MMKV 不做全自动的备份和恢复。我的猜测是这样的:MMKV 底层的原理是内存映射,而内存映射这种方式,它从内存往磁盘里同步写入的过程并不是实时的,也就是说并不是每次我们写入到映射的内存里就会立即从这块内存写入到磁盘,而是会有一些滞后。而如果我们要做全自动的备份,那就需要每次往内存里写入之后,立即手动把内存里最新的数据同步到磁盘。但这就和 MMKV 的定位不符了:因为这种「同步」本质上就是一次从内存到磁盘的写入,并且是同步的写入;而 MMKV 是要高频写入的,如果在高频写入内存的同时,还要实时地把数据从内存同步到磁盘,就会一下子把写入速度从内存级别下降到磁盘级别,MMKV 的性能优势也就荡然无存了。所以从原理上,自动备份是个很难实现的需求,因为它和 MMKV 的定位是矛盾的。不过正好 MMKV 所要记录的这些要显示的文字,也并不是不能丢失的内容——真要是丢了就丢了呗,反正是崩溃日志,丢了就不要了,我下次启动程序之后继续记录就是了——所以既然要求必须高频写入而导致很难实现自动备份,并且也确实能接受因为不做自动备份而导致的数据损坏,那就干脆不做自动备份了。不过这也是我猜的啊,大家如果有不同意见欢迎留言评论指正。
所以如果你要用 MMKV,一定要记得只能用它来存可以接受丢失、不那么重要的数据。或者你也可以选择对数据进行定期的手动备份——全自动的实时备份应该是会严重影响性能的,不过我没试过,你如果有兴趣可以试试。另外据我所知,国内在使用 MMKV 的团队里,几乎没有对 MMKV 数据做了备份和恢复的处理的。
那么说到这里,很容易引出一个问题:微信自己就不怕丢数据吗?(大字:微信就不怕丢数据?)关于这一点,我相信,微信绝对不会把用户登录状态相关的信息用 MMKV 保存并且不做任何的备份,因为这一定会导致每天都会有一些用户在新一次打开微信的时候发现自己登出了。这会是非常差的用户体验,所以微信一定不会让这种事发生。至于一些简单的用户设置,那我就不清楚了。比如深色主题重要吗?这是个不好说的事情:某个用户在打开软件的时候,发现自己之前设置的深色主题失效了,软件突然变回了亮色方案,这肯定是不舒服的事;但我们要知道,MMKV 的文件损坏终归是个概率极低的事件,所以偶尔地发生一次这样的事件在产品的角度是否可以接受,那可能是需要产品团队自身做一个综合考量的事了。对于不同的产品和团队,也许不可接受,也许无伤大雅。而对于你所开发的产品应该是怎样的判断,就得各位自己和团队去商量了。所以像深色主题这种「可以重要也可以不重要」的信息,用不用 MMKV 保存、用的时候做不做备份,大家需要自己去判断。
总之,大家要知道这件事:MMKV 是有数据损坏的概率的,这个在 MMKV 的官方文档就有说明:MMKV 的 GitHub wiki 页面显示,微信的 iOS 版平均每天有 70 万次的数据校验不通过(即数据损坏)。这还是 2020 年的数据,现在可能会更多。
所以我们在使用 MMKV 的时候,一定要考虑到这个问题,你要知道这件事。至于具体的应对,是接受它、坏就坏了,还是要认真应对、做好备份和恢复,这就是大家自己的决策了。
SharedPreferences 的优势:不丢数据
好,那么说完了 MMKV,我来说一下 SharedPreferences,这个最传统的方案。
它有什么优势呢?——它没有优势。跟 MMKV 比起来,它不会丢数据,这个倒是它比 MMKV 强的地方,但是我觉得更应该归为 MMKV 的劣势,而不是 SharedPreferences 的优势,因为只有 MMKV 会丢数据嘛,是吧?
不过不管是这个的优势还是那个的劣势,如果你不希望丢数据,并且也不想花时间去做手动的备份和恢复,同时对于 MMKV 的超高写入性能以及多进程支持都没有需求,那你其实更应该选择 SharedPreferences,而不是 MMKV。对吧?
SharedPreferences 的劣势:卡顿
但更进一步地说:如果你选择了 SharedPreferences,那么你更应该考虑 DataStore。因为 DataStore 是一个完全超越了 SharedPreferences 的存在。你看 SharedPreferences 和 MMKV 它俩是各有优劣对吧?虽然 MMKV 几乎完胜,但是毕竟 SharedPreferences 不会丢数据呀,所以它俩是各有优劣的。但当 DataStore 和 SharedPreferences 比起来,那就是 DataStore 完胜了。这其实也很合理,因为 DataStore 被创造出来,就是用于替代掉 SharedPreferences 的;而 MMKV 不一样,它的诞生有它独特的使命,它是为了「高频同步写入」而诞生的,所以不能全角度胜过 SharedPreferences 也很正常。
我们还说回 DataStore。DataStore 被创造出来的目标就是替代 SharedPreferences,而它解决的 SharedPreferences 最大的问题有两点:一是性能问题,二是回调问题。
先说性能问题:SharedPreferences 虽然可以用异步的方式来保存更改,以此来避免 I/O 操作所导致的主线程的耗时;但在 Activity 启动和关闭的时候,Activity 会等待这些异步提交完成保存之后再继续,这就相当于把异步操作转换成同步操作了,从而会导致卡顿甚至 ANR(程序未响应)。这是为了保证数据的一致性而不得不做的决定,但它也确实成为了 SharedPreferences 的一个弱点。而 MMKV 和 DataStore 用不同的方式各自都解决了这个问题——事实上,当初 MMKV 被公布的时候之所以在业界有相当大的反应,就是因为它解决了 SharedPreferences 的卡顿和 ANR 的问题。
不过有一点我的观点可能和一些人不同:SharedPreferences 所导致的卡顿和 ANR,其实并不是个很大的问题。它和 MMKV 的数据损坏一样,都是非常低概率的事件。它俩最大的区别在于其实是政治上的:SharedPreferences 的卡顿很容易被大公司的性能分析后台监测到,所以不解决的话会扣绩效,而解决掉它会提升绩效;而 MMKV 的数据损坏是无法被监测到的,所以……哈?事实上,大家想一下:卡顿和数据损坏,哪个更严重?当然是数据损坏了,对吧。
其实除了写数据时的卡顿,SharedPreferences 在读取数据的时候也会卡顿。虽然它的文件加载过程是在后台进行的,但如果代码在它加载完成之前就去尝试读取键值对,线程就会被卡住,直到文件加载完成,而如果这个读取的过程发生在主线程,就会造成界面卡顿,并且数据文件越大就会越卡。这种卡顿,不是 SharedPreferences 独有的,MMKV 也是存在的,因为它初始化的过程同样也是从磁盘里读取文件,而且是一股脑把整个文件读完,所以耗时并不会比 SharedPreferences 少。而 DataStore,就没有这种问题。DataStore 不管是读文件还是写文件,都是用的协程在后台进行读写,所有的 I/O 操作都是在后台线程发生的,所以不论读还是写,都不会卡主线程。
简单来说,SharedPreferences 会有卡顿的问题,这个问题 MMKV 解决了一部分(写时的卡顿),而 DataStore 完全解决了。所以如果你的目标在于全方位的性能,那么你应该考虑的是 DataStore,因为它是唯一完全不会卡顿的。
SharedPreferences 的劣势:回调
DataStore 解决的 SharedPreferences 的另一个问题就是回调。SharedPreferences 如果使用同步方式来保存更改(commit()
),会导致主线程的耗时;但如果使用异步的方式,给它加回调又很不方便,也就是如果你想做一些「等这个异步提交完成之后再怎么怎么样」的工作,会很麻烦。
而 DataStore 由于是用协程来做的,线程的切换是非常简单的,你就把「保存完成之后做什么」直接写在保存代码的下方就可以了,很直观、很简单。
对比来说,MMKV 虽然没有使用协程,但是它太快了,所以大多数时候并不需要切线程也不会卡顿。总之,在这件事上,只有 SharedPreferences 最弱。
总结
区别大概就是这么些区别了,大致总结一下就是:
如果你有多进程支持的需求,MMKV 是你唯一的选择;如果你有高频写入的需求,你也应该优先考虑 MMKV。但如果你使用 MMKV,一定要知道它是可能丢失数据的,不过概率很低就是了,所以你要在权衡之后做好决定:是自行实现数据的备份和恢复方案,还是直接接受丢数据的事实,在每次丢失数据之后帮用户把相应的数据进行初始化。当然了,一个最鸡贼的做法是:反正数据监测不会监测到 MMKV 的数据丢失,又不影响绩效,那就不管它呗!不过我个人是不太赞同这种策略的,有点不负责哈。
另外,如果你没有多进程的需求,也没有高频写入的需求,DataStore 作为性能最完美的方案,应该优先被考虑。因为它在任何时候都不会卡顿,而 MMKV 在写大字符串和初次加载文件的时候是可能会卡顿的,而且初次加载文件的卡顿不是概率性的,只要文件大到了引起卡顿的程度,就是 100% 的卡顿。不过如果你的团队没有在用协程,甚至没有在用 Kotlin,那 DataStore 也暂时不适合你们,因为它是完全依赖 Kotlin 协程来实现和使用的。
哦对了,其实我今天说的 DataStore 只是面向简单键值对存储的 DataStore 方案,它的全称叫 Preferences DataStore,而 DataStore 还有用于保存结构化数据的方案,叫做 Proto DataStore,它内部用的是 Protocol Buffer 作为数据结构的支持。但是这个有点跑题,我就不展开了。
至于 SharedPreferences 嘛,在这个时代,它真的可以被放弃了。除非——像我刚说的——如果你们还没在用协程,那 SharedPreferences 可能还能苟延残喘一下。
作者:扔物线
链接:https://juejin.cn/post/7112268981163016229
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
面试了十几个高级前端,竟然连(扁平数据结构转Tree)都写不出来
前言
招聘季节一般都在金三银四,或者金九银十。最近在这五六月份,陆陆续续面试了十几个高级前端。有一套考察算法的小题目。后台返回一个扁平的数据结构,转成树。
我们看下题目:打平的数据内容如下:
let arr = [
{id: 1, name: '部门1', pid: 0},
{id: 2, name: '部门2', pid: 1},
{id: 3, name: '部门3', pid: 1},
{id: 4, name: '部门4', pid: 3},
{id: 5, name: '部门5', pid: 4},
]
输出结果
[
{
"id": 1,
"name": "部门1",
"pid": 0,
"children": [
{
"id": 2,
"name": "部门2",
"pid": 1,
"children": []
},
{
"id": 3,
"name": "部门3",
"pid": 1,
"children": [
// 结果 ,,,
]
}
]
}
]
我们的要求很简单,可以先不用考虑性能问题。实现功能即可,回头分析了面试的情况,结果使我大吃一惊。
10%的人没思路,没碰到过这种结构
60%的人说用过递归,有思路,给他个笔记本,但就是写不出来
20%的人在引导下,磕磕绊绊能写出来
剩下10%的人能写出来,但性能不是最佳
感觉不是在招聘季节遇到一个合适的人真的很难。
接下来,我们用几种方法来实现这个小算法
什么是好算法,什么是坏算法
判断一个算法的好坏,一般从执行时间
和占用空间
来看,执行时间越短,占用的内存空间越小,那么它就是好的算法。对应的,我们常常用时间复杂度代表执行时间,空间复杂度代表占用的内存空间。
时间复杂度
时间复杂度的计算并不是计算程序具体运行的时间,而是算法执行语句的次数。
随着n
的不断增大
,时间复杂度不断增大
,算法花费时间
越多。 常见的时间复杂度有
- 常数阶
O(1)
- 对数阶
O(log2 n)
- 线性阶
O(n)
- 线性对数阶
O(n log2 n)
- 平方阶
O(n^2)
- 立方阶
O(n^3)
- k次方阶
O(n^K)
- 指数阶
O(2^n)
计算方法
- 选取相对增长最高的项
- 最高项系数是都化为1
- 若是常数的话用O(1)表示
举个例子:如f(n)=3*n^4+3n+300 则 O(n)=n^4
通常我们计算时间复杂度都是计算最坏情况。计算时间复杂度的要注意的几个点
- 如果算法的执行时间
不随n
的增加
而增长
,假如算法中有上千条
语句,执行时间也不过是一个较大的常数
。此类算法的时间复杂度是O(1)
。 举例如下:代码执行100次,是一个常数,复杂度也是O(1)
。
let x = 1;
while (x <100) {
x++;
}
- 有
多个循环语
句时候,算法的时间复杂度是由嵌套层数最多
的循环语句中最内层
语句的方法决定的。举例如下:在下面for循环当中,外层循环
每执行一次
,内层循环
要执行n
次,执行次数是根据n所决定的,时间复杂度是O(n^2)
。
for (i = 0; i < n; i++){
for (j = 0; j < n; j++) {
// ...code
}
}
- 循环不仅与
n
有关,还与执行循环判断条件
有关。举例如下:在代码中,如果arr[i]
不等于1
的话,时间复杂度是O(n)。如果arr[i]
等于1
的话,循环不执行,时间复杂度是O(0)
。
for(var i = 0; i<n && arr[i] !=1; i++) {
// ...code
}
空间复杂度
空间复杂度是对一个算法在运行过程中临时占用存储空间的大小。
计算方法:
- 忽略常数,用O(1)表示
- 递归算法的空间复杂度=(递归深度n)*(每次递归所要的辅助空间)
计算空间复杂度的简单几点
- 仅仅只复制单个变量,空间复杂度为O(1)。举例如下:空间复杂度为O(n) = O(1)。
let a = 1;
let b = 2;
let c = 3;
console.log('输出a,b,c', a, b, c);
- 递归实现,调用fun函数,每次都创建1个变量k。调用n次,空间复杂度O(n*1) = O(n)。
function fun(n) {
let k = 10;
if (n == k) {
return n;
} else {
return fun(++n)
}
}
不考虑性能实现,递归遍历查找
主要思路是提供一个递getChildren
的方法,该方法递归
去查找子集。
就这样,不用考虑性能,无脑去查,大多数人只知道递归,就是写不出来。。。
/**
* 递归查找,获取children
*/
const getChildren = (data, result, pid) => {
for (const item of data) {
if (item.pid === pid) {
const newItem = {...item, children: []};
result.push(newItem);
getChildren(data, newItem.children, item.id);
}
}
}
/**
* 转换方法
*/
const arrayToTree = (data, pid) => {
const result = [];
getChildren(data, result, pid)
return result;
}
从上面的代码我们分析,该实现的时间复杂度为O(2^n)
。
不用递归,也能搞定
主要思路是先把数据转成Map
去存储,之后遍历的同时借助对象的引用
,直接从Map
找对应的数据做存储
function arrayToTree(items) {
const result = []; // 存放结果集
const itemMap = {}; //
// 先转成map存储
for (const item of items) {
itemMap[item.id] = {...item, children: []}
}
for (const item of items) {
const id = item.id;
const pid = item.pid;
const treeItem = itemMap[id];
if (pid === 0) {
result.push(treeItem);
} else {
if (!itemMap[pid]) {
itemMap[pid] = {
children: [],
}
}
itemMap[pid].children.push(treeItem)
}
}
return result;
}
从上面的代码我们分析,有两次循环,该实现的时间复杂度为O(2n)
,需要一个Map把数据存储起来,空间复杂度O(n)
最优性能
主要思路也是先把数据转成Map
去存储,之后遍历的同时借助对象的引用
,直接从Map
找对应的数据做存储。不同点在遍历的时候即做Map
存储,有找对应关系。性能会更好。
function arrayToTree(items) {
const result = []; // 存放结果集
const itemMap = {}; //
for (const item of items) {
const id = item.id;
const pid = item.pid;
if (!itemMap[id]) {
itemMap[id] = {
children: [],
}
}
itemMap[id] = {
...item,
children: itemMap[id]['children']
}
const treeItem = itemMap[id];
if (pid === 0) {
result.push(treeItem);
} else {
if (!itemMap[pid]) {
itemMap[pid] = {
children: [],
}
}
itemMap[pid].children.push(treeItem)
}
}
return result;
}
从上面的代码我们分析,一次循环就搞定了,该实现的时间复杂度为O(n)
,需要一个Map把数据存储起来,空间复杂度O(n)
小试牛刀
方法 | 1000(条) | 10000(条) | 20000(条) | 50000(条) |
---|---|---|---|---|
递归实现 | 154.596ms | 1.678s | 7.152s | 75.412s |
不用递归,两次遍历 | 0.793ms | 16.499ms | 45.581ms | 97.373ms |
不用递归,一次遍历 | 0.639ms | 6.397ms | 25.436ms | 44.719ms |
从我们的测试结果来看,随着数量的增大,递归的实现会越来越慢,基本成指数的增长方式。
作者:杰出D
链接:https://juejin.cn/post/6983904373508145189
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
Kotlin中Channel的使用
什么是Channel
Channel API是用来在多个协程之间进行通信的,并且它是并发安全的。它的概念有点与BlockQueue
相似,都遵循先进先出的规则,差别就在于Channel使用挂起的概念替代了BlockQueque中的阻塞。使用它我们可以很轻易的构建一个生产者消费者模型。并且Channel
支持任意数量的生产者和消费者
从源码我们可以看出Channel主要实现了两个接口
public interface Channel<E> : SendChannel<E>, ReceiveChannel<E> {}
interface SendChannel<in E> {
suspend fun send(element: E)
public fun trySend(element: E): ChannelResult<Unit>
fun close(): Boolean
//...
}
interface ReceiveChannel<out E> {
suspend fun receive(): E
public fun tryReceive(): ChannelResult<E>
fun cancel(cause: CancellationException? = null)
// ...
}
SendChannel
: 用于添加元素到通道中和关闭通道;ReceiveChannel
:主要用于接收通道中的元素
你会发现SendChannel中的send()
和ReceiveChannel中的receive
方法都是挂起函数,为什么会怎么设计,在通道中如果存储元素的数量达到了我们设置的通道存储大小的时候,再通过send()
方法往通道中发送数据,就会挂起,直至通道有空闲空间是才会将挂起的发送动作恢复。同理,如果我们的通道中没有可用的元素时,这个时候我们通过receive
方法去接收数据,就会发现此操作将会被挂起,直到通道中存在可用元素为止。
如果我们需要在非挂起函数中去接收和发送数据,我们可以使用trySend
和tryReceive
,这两个操作都会立即返回一个ChannelResult
,结果中会包含此次操作的的结果以及数据,但是这两个操作只能使用在容量有限的通道上。
Channel的使用
下面我们通过构建一个简单的消费者和生产者模型了解以下Channel的使用
suspend fun main(): Unit = runBlocking {
val channel = Channel<String>()
//生产者协程
launch {
channel.send("Hello World!")
}
//消费者协程
launch {
val received = channel.receive()
println(received)
}
}
}
上面这种创建Channel的方式,在我们使用完通道之后很容易忘记一个close
操作,特别是如果其中一个生产者协程应为某些情况发生异常,停止了生产,那么消费者协程会一直挂起等待生产者生产完成进行消费。所以我们可以使用协程的一个扩展方法produce
,当协程发生异常,或者是协程完成时,它会自动去调用close
方法,并且它会返回一个ReceiveChannel
,下面我们就来看看怎么使用
runBlocking {
val channel = produce {
listOf("apple","banana","orange").forEach {
send(it)
}
}
for (element in channel){
print(element)
}
}
Channel有哪些
我们在使用Channel
函数在创建通道时,我们会指定通道的容量大小,然后会根据容量创建不同类型的通道
public fun <E> Channel(
capacity: Int = RENDEZVOUS,
onBufferOverflow: BufferOverflow = BufferOverflow.SUSPEND,
onUndeliveredElement: ((E) -> Unit)? = null
): Channel<E> =
when (capacity) {
RENDEZVOUS -> {
if (onBufferOverflow == BufferOverflow.SUSPEND)
RendezvousChannel(onUndeliveredElement)
else
ArrayChannel(1, onBufferOverflow, onUndeliveredElement)
}
CONFLATED -> {
require(onBufferOverflow == BufferOverflow.SUSPEND) {
"CONFLATED capacity cannot be used with non-default onBufferOverflow"
}
ConflatedChannel(onUndeliveredElement)
}
UNLIMITED -> LinkedListChannel(onUndeliveredElement)
BUFFERED -> ArrayChannel(
if (onBufferOverflow == BufferOverflow.SUSPEND)
CHANNEL_DEFAULT_CAPACITY
else 1,
onBufferOverflow, onUndeliveredElement
)
else -> {
if (capacity == 1 && onBufferOverflow == BufferOverflow.DROP_OLDEST)
ConflatedChannel(onUndeliveredElement)
else
ArrayChannel(capacity, onBufferOverflow, onUndeliveredElement)
}
}
可以从以上源码看出我们的通道主要分为4种类型
- RENDEZVOUS :默认容量为0,且生产者和消费者只有在相遇时才能进行数据的交换
- CONFLATED :容量大小为1,且每个新元素会替换前一个元素
- UNLIMITED: 无限容量缓冲区且
send
永不挂起的通道。 - BUFFERED : 默认容量为64,且在溢出时挂起的通道,可以通过设置JVM的 DEFAULT_BUFFER_PROPERTY_NAME来覆盖它
我们从Channel源码看出,Channel在创建时还会指定缓冲区溢出时的策略
public enum class BufferOverflow {
//缓冲区满时,将操作进行挂起,等待缓冲区有空间
SUSPEND,
//删除旧值
DROP_OLDEST,
//将即将要添加进缓冲区的值删除
DROP_LATEST
}
Channel函数还有一个可选参数onUndeliveredElement
,接收一个Lambda在元素被发送且未被消费时调用,我们通常使用它来关闭一些该通道发送的资源。
在Channel内部结构种维护的缓冲区结构除了ArrayChannel
内部自己维护了一个数组作为缓冲区,其余的都是使用AbstractSendChannel
的链表作为缓冲区
那么我们将两个管道的内容合并成一个呢
fun <T> CoroutineScope.fanIn(
channels: List<ReceiveChannel<T>>
): ReceiveChannel<T> = produce {
for (channel in channels) {
launch {
for (elem in channel) {
send(elem)
}
}
}
}
扇出:多个协程从单个通道接收数据。为了正确地接收数据,我们应该使用for循环(使用consumeEach
是不安全的)。
扇入:多个协程对单个通道发送数据
作者:阿sir学Android
链接:https://juejin.cn/post/7112032818972065799
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
Flutter布局指南之谁动了我的Key
Key用来干嘛
Flutter中的Key,一直都是作为一个可选参数在很多Widget中出现,那么它到底有什么用,它到底怎么用,本篇文章将带你从头到尾,好好理解下,Flutter中的Key。
我们首先来看下面这个Demo:
Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 100,
height: 100,
color: Colors.red,
),
Container(
width: 100,
height: 100,
color: Colors.blue,
),
],
)
展示为两个不同颜色的方块。
问题1
这时候,如果我们在代码中交换两个Container的位置,Hot reload之后,它们的位置会发生改变吗?
下面我们把Demo修改一下,将Container抽取出来,并在中间放一个Text用来做计时器,并改为StatefulWidget,代码如下。
class KeyBox extends StatefulWidget {
final Color color;
KeyBox(this.color);
@override
_KeyBoxState createState() => _KeyBoxState();
}
class _KeyBoxState extends State<KeyBox> {
var counter = 0;
@override
Widget build(BuildContext context) {
return Container(
width: 100,
height: 100,
color: widget.color,
child: Center(
child: TextButton(
onPressed: () {
setState(() => counter++);
},
child: Text(
counter.toString(),
style: const TextStyle(fontSize: 60),
),
),
),
);
}
}
Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
KeyBox(Colors.yellow),
KeyBox(Colors.green),
],
)
这样当我们点击计时器工作之后,展示如下。
问题2
这时候,如果我们在代码中交换两个Container的位置,Hot reload之后,它们的数字会发生改变吗?
问题3
如果我们删掉第一个Widget,Hot reload之后,显示的是数字几?
问题4
如果我们再重新把删掉的Widget加回来,Hot reload之后,又会如何显示?
问题5
如果在问题2的基础上,给第一个Widget外新增一个Center,那么又会如何显示呢?
如果你能完全回答上面的这几个问题并知道为什么,那么恭喜你,看完这篇文章,你会浪费十几分钟,当然,如果你不清楚,那么这十几分钟的时间,将给你带来不小的收益。
Key是什么
Flutter通过Widget来渲染UI,那么它是如何区分上面的两个不同颜色的Container的呢?通过颜色吗?当然不是,如果Container的颜色相同,那岂不是无法区分了?
所以,Key就成了Flutter区分不同Widget的依据,这就好比是Android中布局的ViewID。
知道Key是什么还不够,我们还得知道,我们为什么需要Key,首先,我们来看下上面的三个问题。
对于问题1,这个应该很简单了,Container是StatelessWidget,所以每次Hot reload都会重新build,因此颜色肯定会发生互换,这个很好理解。
那么对于问题2呢?StatelessWidget改成了StatefulWidget,这次再交换两个Widget的位置,你可以发现,虽然颜色互换了,但是数字没变。
要怎么解决这个问题呢?这就需要用到Key了,我们给KeyBox增加一个Key的参数。
新的Flutter Lint已经会提示你构造函数需要增加key的可选参数了。
const KeyBox(this.color, {Key? key}) : super(key: key);
在使用的地方,传入ValueKey即可。
KeyBox(Colors.yellow, key: ValueKey(2)),
SizedBox(height: 20),
KeyBox(Colors.cyan, key: ValueKey(1)),
这时候你再切换两个Container的位置,数字就会跟着变换了。
Key的原理
Key实际上是Flutter用来标记Widget的唯一标识,但是为什么需要Key,就要从Flutter的渲染流程上说起了。
Widget作为Flutter中的不可变数据,是作为渲染的数据类而存在的,它实际上就是内容的配置表,根据View的树形结构,自然而然模拟出了一个Widget Tree的概念。
Widget在运行时会创建Element实例,这些Element和Widget也组成了一一对应的关系,对于StatefulWidget来说,Widget中包含了组件的外观、位置等信息,而Element中,包含了State信息,这也是Flutter的核心原理。所以,在上面的Demo中,Counter作为State,被保存在Element中,而颜色,被保存在Widget中。
Widget和Element分离之后,如果修改颜色等Widget属性,那么可以直接创建新的Widget替换旧的Widget,同时还可以保留Element中的数据,因为创建Widget的成本是很低的,而Element则会高很多,所以Element会持续尽可能长的时间。
那么在Widget被改变之后,Element是如何和Widget进行关联的呢?这就需要两个东西了:
- runtimeType
- Key
所以Element会先对比当前新的Widget Tree中的新元素,是否跟当前Element的类型一致,如果不一致,那么说明Element已经无效了,只能重新创建,如果类型一致,那么就需要进一步判断Key了。
问题2的原因
所以,在问题2中,由于两个Widget的类型并没有发生变化,而又没有Key,所以,Widget被重新创建后,与原来的Element又关联起来了,看上去就是只修改了颜色。
那么在问题2的解法中,我们给Widget增加了Key,当我们调换两个Widget的位置时,虽然类型没有改变,但是Key发生了改变,Element在原来的位置找不到对应的Widget,那么这时候,它会选择在当前层级下,继续搜索这个Key。
这里要注意,Element只会在当前层级下搜索,如果这个Key的Widget被移入了其它层级,那么也是无法找到的,在问题2的场景下,由于只是交换了两个Widget的顺序,所以Element会在后面找到之前Key的Widget,同理,下一个Element也会找到,所以,两个Widget都被关联起来了,所以State也显示正确了。
问题3的原因
那么在问题3中,我们删除了第一个Widget,当没有Key时,Element会在Widget Tree中搜索,当它发现第二个Key类型是一样的时,它就以为它找到了,而第二个Element,因为找不到Widget,就销毁了。最终的效果就是剩下第二个Box的颜色和第一个Box的数字。
那么如果有Key呢?有Key的话,就不会找错了啊,所以自然能够对应上,与我们预想的也就是一样的了。
问题4的原因
理解了问题3,那么问题4就好理解了。当我们在开头创建同一个类型的Widget时,Element会把这个新增的Widget当作是以前的Widget,因为它们类型相同,所以Element被关联到了这个新的Widget,而另一个Widget发现已经没有Element了,所以会选择新建一个Element,这时候,数字就是默认值0了。
问题5的原因
对于问题5来说,实际上就是Element的搜寻机制,前面解释了,Element只会在当前层级进行搜索,所以Center的加入,改变了Widget的层级,Element无法对应了,所以它也选择了消耗重建,所以第一个Widget会显示默认值0。
但是要注意的是,如果类型不一致,那么Flutter会直接判断不相同,从而直接消耗重建,所以,在这些问题里,如果在KeyBox之间插上一些不同类型的Widget,那么就瞬间破防了,演示的效果就完全不同了。
Key有哪些Key
Key从整体上来说,分为两种,即:
- Local Key:分为Value Key、Object Key和Unique Key
- Global Key
Local Key顾名思义,指的是在当前Widget层级下,有唯一的Key属性,而Global Key,则是在全局APP中,具有唯一性。Global Key的性能会比Local Key差很多。
Value Key
在前面的Demo中,我们给KeyBox增加了Key之后,Widget在修改、移动之后,Element就可以正确的找到对应的Widget了,这里我们使用的是Value Key。
Value Key,顾名思义,就是使用Value来对Key做标识的Key,例如我们在Demo中使用的,ValueKey(1),value可以是任意类型,这里是1,其实更符合的场景,应该是用Color,或者是更加具有语义性的value来作为Key的value。
Value Key在同一层级下需要具有唯一性,所以当两个KeyBox都设置成ValueKey(1)时,程序就会报错,告诉你Key重复了。
Object Key
Object Key与Value Key类似,但是又不完全一样,Value Key对比的是Value,Value相等,就是相等,而Object Key,对比的是实例,实例相同,才是相等,就好比一个Java中的equals,一个是「==」。我们看下Object Key的源码就一目了然了。
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType)
return false;
return other is ObjectKey
&& identical(other.value, value);
}
假如我们有一个自定义的Class,重写了它的==函数,那么用Value Key,new两个同样的对象,它们就是相等的,而Object Key,则不相等,原因就是一个比较的是值,一个比较的是引用。
Unique Key
Unique Key自己都说了,它是独一无二的,也就是说,Unique Key只和自己相等,任意创建多个Unique Key,都是不相等的,相当于唯一标识了。
如果在Build函数中创建Unique Key,那么这个Key在大部分场景下就没有意义,因为Hot reload时,Build函数会重建,所以Unique Key被重建,而且和之前也不相等。
这就很奇怪了,这玩意有什么用呢?
用处确实不多,但一旦用到,就必须得用,例如下面这个例子。
假如我们要用AnimatedSwitcher来实现切换时的动画效果,这时候,我们需要让每次改变都要执行动画,那么这里就可以使用Unique Key,强制每一次都是新的Widget,这样才能有动画效果。
那么另一种使用场景,就是在无法使用Value Key和Object Key的时候使用,但是这时候,需要将Unique Key定义在Build函数之外,这样Unique Key只会创建一次,从而保证唯一性的同时,不用去创建value和Object。
Global Key
Global Key全局唯一且只和自己相等,还记得之前Element在关联新变化的Widget时是怎么比较Key的吗——Element为了效率问题,只会在当前层级下进行寻找,所以,在问题5中,一旦我们修改了某个Widget的层级,那么Element就会消耗重建,那么如果使用了Global Key呢?当Key的类型是Global Key时,Element会不惜代价在全局寻找这个Key,这也是为什么Global Key的效率会比较低的原因。
那么有了Global Key,即使Widget Tree发生了改变,也依然可以找到这个Widget进行关联,但是要注意的是,Global Key需要定义在Build函数之外,否则每次都会重新创建Global Key,那就没有意义了。
除此之外,Global Key还有一个作用,那就是给一个Widget增加一个全局标识,这样有点像命令式编程的意思,类似Android中的FindViewByID,通过Global Key就可以找到当前标记的这个Widget,从而获取它的一些相关信息。
final count = (globalKey.currentState as _KeyBoxState).counter;
print('count: $count');
final color = (globalKey.currentWidget as KeyBox).color;
print('color: $color');
final size = (globalKey.currentContext?.findRenderObject() as RenderBox).size;
print('size: $size');
final position = (globalKey.currentContext?.findRenderObject() as RenderBox).localToGlobal(Offset.zero);
print('position: $position');
// output
flutter: count: 0
flutter: color: MaterialColor(primary value: Color(0xff4caf50))
flutter: size: Size(100.0, 100.0)
flutter: position: Offset(145.0, 473.5)
由此可见,通过Global Key,我们可以拿到State、Widget、Element(Context)以及通过Element关联的RenderObject,这样就可以获取Widget中的一些配置参数,State中的数据变量,以及RenderObject中的绘制信息,例如尺寸、位置、约束等等。
作者:xuyisheng
链接:https://juejin.cn/post/7112249333218541581
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
FlutterWeb开发进出坑总结
一、启动运行乱码
没错,启动一个demo,遇到坑了,如图所示
点击Android Studio上方运行按钮,程序启动之后汉字文字显示乱码,这是由于flutter web有三种渲染模式,auto 、html 和 canvaskit,点击运行按钮(flutter build web命令)默认的渲染模式为auto,这种模式在移动端使用html渲染,在pc端使用canvaskit渲染。
解决办法 1: 用命令行运行,并指定渲染模式,就能解决问题。
// 指定渲染模式为html
flutter build web --web-renderer html
解决办法 2: 上面虽然能解决问题,但我习惯用按钮运行程序怎么办?当然也找到了其他办法。在程序包下web/index.html文件中body标签下copy如下代码。
<!--指定web运行模式-->
<!-- window.flutterWebRenderer = "canvaskit";-->
<script type="text/javascript">
window.flutterWebRenderer = "html";
</script>
<script src="main.dart.js" type="application/javascript"></script>
二、Debug启动运行断点失败
web开发和APP端开发一样,也可以断点。项目之初断点是可以的,但是不知道怎么的,debug可以运行,但断不到,很奇怪,花了一上午,发现同事因为发版改了下web/index.html中head->base标签下 href="***"的值。
解决办法 :
// 之前,断点可用
<base href="$FLUTTER_BASE_HREF">
// 同事改动,断点不可用
<base href="git/******">
// 修复后,断点可用
<base href="/">
不能断点开发实在是麻烦。
三、Hot Reload热重载、点击浏览器刷新,都会重启整个程序
在APP端开发时,在某个页面点hot reload按钮,只会重新运行当前页面,但是在web中,点热重载会重启,这只是开发中的不方便。已经上线的程序,用户只要点击浏览器刷新就会重启整个程序,无论在哪个页面,都会回到第一个页面,这与我浏览网页的习惯明显是不符的。
查找原因,发现是flutter底层问题,仔细观察web页面是通过不同的url来确定的,而Flutter从始至终都是一个url,只是flutter在一个网页中绘制了不同的页面(与APP端原理一致),所以想解决问题就是要每个页面都有自己的url。
解决办法 : 用静态路由的方式跳转页面和传参,具体代码如下。
// 跳转与传参
static Future toName(String pageName, Map<String, dynamic> params) {
var uri = Uri(scheme: RoutePath.scheme, host: pageName, queryParameters: params);
return Navigator.of(currentContext).pushNamed(uri.toString());
}
// 取参方式
static Route<dynamic> generateRoute(RouteSettings settings) {
return PageRouteBuilder(
settings: settings,
pageBuilder: (BuildContext c, Animation<double> a,Animation<double> sa) {
var uri = Uri.parse(settings.name ?? ''); //解析页面名
switch (uri.host) {
case RoutePath.name:
return NamePage(uri.queryParameters); 、、传参
default:
return Scaffold(
body: Center(
child: Text('没有找到对应的页面:${settings.name}'),
),
);
}
});
}
通过以上方式,跳转时每个页面都会有自己的url和拼接的参数,这样刷新的时候就不会重启整个程序,会停留在当前页面。
四、用静态路由的方式跳转,全局变量,单例对象丢失,页面栈记录丢失。
没错,坑是连着的,我也是服了。当在某页面热重载或点击浏览器刷新,会停留在当前页面,但是无法返回,就算点击跳转至其他页面,也会报错,因为全局变量都已经丢失,比如:登录信息,用户信息,已经初始化的工具类对象等。
已经有人提了Issues,国内也有大神分析了原因和不完全结局方案
目前flutter web对于浏览器还是没有适配完全,无论Navigator1.0还是Navigator2.0,都存在不可解决的严重问题。目前来看google的对flutter web的意图,还是开发移动web并在App中通过webkit这种内核使用,并没有想开发者使用flutter web来开发真正的web应用,或者后续会完善这部分。
我的解决方案
- 除了登录页和首页,其他页面不用静态路由的方式跳转,这样做即使用户刷新,也不会回到登录页,而是回首页。
- 在有刷新需求的页面上提供刷新图标,可触发刷新,避免用户点击浏览器的刷新。
- 全局变量持久化,用html.window.localStorage并配合工厂模式持久化数据,当被触发刷新,会从本地重新赋值,比如:登录信息等。
- 弱化全局成员变量,非必要不使用全局类的变量,数据尽量放云端,页面间不耦合。
五、检测浏览器/标签页关闭还是刷新
解决办法 : 可以使用函数onBeforeUnload来检查选项卡是否正在关闭。它也可能检测到页面刷新。
import 'dart:html' as html;
html.window.onBeforeUnload.listen((event) async{
// do something
});
或者
import 'dart:html' as html;
html.window.onUnload.listen((event) async{
// do something
});
六、引用 import 'dart:html' 运行提示报错
多端运行,如果引用了html会提示报错。
解决办法 : 可以引用第三方universal_html 2.0.8,帮封装了一层,支持多端。
universal_html :适用于所有平台的“dart:html”,包括 Flutter 和服务器端。简化跨平台开发和 HTML / XML 处理。
七、可点击提示
在平常浏览网页时,鼠标滑动到可点击的文字或按钮上,鼠标“箭头”会变成一个“小手”,或背景出现颜色变化提示。
Flutter中常用的GestureDetector()手势工具,虽然可以实现点击等回调,但是鼠标滑动到可点击区域,鼠标“箭头”并不会变成“小手”,在交互上不符合大众使用网页的习惯。
解决办法 : 使用InkWell替换GestureDetector,用InkWell包住的按钮或文字,鼠标悬停,就会出现小手。
Ink(
width: width,
height: height,
color: color,
child: InkWell(
focusColor: Colors.transparent,
highlightColor: Colors.transparent,
splashColor: Colors.transparent,
hoverColor: const Color(0x0818a7fb),
onTap: onTap,
child: Center(child: this)))
分析源码可知,内部用MouseRegion监听了鼠标位置,那什么是MouseRegion呢?
八、鼠标监听控件 MouseRegion
相对于APP端,web端多了个鼠标,可以实现app实现不了的交互效果,比如悬停,划过,进入退出某区域等,都可以用MouseRegion实现。
MouseRegion的属性和说明
字段 | 属性 | Col3 |
---|---|---|
onEnter | PointerEnterEventListener | 鼠标进入区域时的回调 |
onExit | PointerHoverEventListener | 鼠标退出区域时的回调 |
onHover | PointerExitEventListener | 鼠标在区域内移动时的回调 |
cursor | MouseCursor | 鼠标悬停区域时的光标样式 |
opaque | bool | 是否阻止检测鼠标 |
child | Widget | 子组件 |
最后
这是目前遇到有价值的坑,后面遇到新的也会持续更新。
Flutter开发web上,路由和全局变量上的坑还是挺严重的,但只要没有复杂的页面间逻辑,普通展示完全没问题。
作者:苏啵曼
链接:https://juejin.cn/post/7111984589086588959
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
Kotlin知识点的深入思考
Kotlin是基于JVM的一个语言,也是很时髦的语言。Java语言这几年的发展,借鉴了Kotlin的很多特性。Google把Kotlin作为Android的优先使用语言之后,更是应者影从。本文整理了在Kotlin学习和使用中总结整理出来的几个有意思的知识点,和大家学习和交流。
Coroutines ARE light-weight
fun main() = runBlocking {
repeat(100_000) { // launch a lot of coroutines
launch {
delay(5000L)
print(".")
}
}
}
以上代码在学习Kotlin协程的时候应该都见过了,是为了说明协程很轻量。原因也很简单,在一般的操作系统中,用户线程和内核线程是一对一的关系,操作用户线程的就是操作内核线程,每一个内核线程,都有专门的内核数据结构来管理,在Linux里面使用数据结构task_struct来管理的,这是一个很复杂的数据结构,内核线程的挂起和执行,都会涉及到变量和寄存器数据的保存和恢复,甚至内核线程本身的调度就需要消耗CPU的时间。但是协程完全是用户空间里面的事情,说的简单点,就是几个任务的执行队列的管理,当然协程也是运行中线程之上的。
有个疑问产生了?那为什么现在操作系统用户线程和内核线程是一对一的关系呢?
因为在早期的Java版本中,在单核CPU的时代,用户线程和内核线程的关系是多对一。在多核时代,也有多对多的模型。即多路复用多个用户级线程到同样数量或者更少数量的内核线程。在Solaris早几年的版本也是支持类似的多对多的模型,大家想过没有,为什么现在几乎所有的操作系统都使用一对一的模型了呢?
以下是一家之言,和大家探讨。OS本身越来越复杂,参与方也越来越多。之前线程这块分两层,内核层和用户线程库。用户线程库为程序员提供创建和管理线程的API。随着互联网的发展,有一些需求产生了,如高并发支持,OS这一层比较笨重的,很难快速满足越来越快的需求的变化。这个时候,一些语言,在设计之初就考虑来解决这些新产生的问题,一些时髦的语言,也非常快速的来响应这些需求。所以就有了在线程库之上,在语言的层面来解决这些问题,所以协程产生了,并且越来越多的语言支持了这些特性。
哈哈,为什么线程库为什么没有演进来支持协程呢?原因也很简单,线程库基本被定位成管理内核线程的接口,而且线程库的作者的主要精力也不在这个方向。线程库做好自己的事情(管理内核线程),然后把其他的交给别人。这也是自然形成的分工和分层。
想想这几年的Android应用开发的发展,AndroidX里的东西越来越多,演进也越来越快。这是因为Android系统的体量限制,不可能跑地很快,一年一次算得上是OS升级的极限了。所以必须把需要跑得快的东西剥离出来。这个道理和协程的发展也有异曲同工之处。
Lambda表达式捕获变量
Lambda表达式应该是一个历史比较悠久的东西了,由于函数式编程风行,Lambda表达式也是被非常广泛地使用。Java对Lambda的支持比较后知后觉,应该是在Java8才开始支持的吧,不过在JDK7的时候,JVM字节码引入了InvokeDynamic,后续应该会成为各个基于JVM语言解析Lambda表达式的统一的标准方法。后面会有单独一段来讨论这个InvokeDynamic。
Lambda本质上是一个函数,一块可以被执行的代码段。在函数式编程环境下,Lambda表达式可以被传递,保存和返回。在类似的C/C++的语言环境中,函数指针应该可以非常方便和高效的来实现Lambda,但是在Java和Kotlin这样的语言中,没有函数指针这样的东西,所以用对象来存储Lambda,这当然是在没有InvokeDynamic之前(Java7)。
说到Lambda表达式,大家还记不记得在Java中,如果Lambda要引入外部的一些变量时,这个变量一定要被声明为final。
public Runnable test() {
int i = 1000;
Runnable r = () -> System.out.println(i);
return r;
}
上面这段代码,变量i为实际上的final,编译器会把这个i变量自动加上final。
如下的代码端编译就会出错了,因为变量i不是final的。
public Runnable test() {
int i = 1000;
i++;
// Variable used in lambda expression should be final or effectively final
Runnable r = () -> System.out.println(i);
return r;
}
会什么会有这个限制呢?呵呵,你看上面test函数,如果调用test函数,然后把返回的对象保存下来后再执行,这个时候i这个变量已经在内存中销毁掉了,这个lambda也就没法执行了。因为i这个变量是栈变量,生命周期只在test函数执行期间存在。那么为什么声明称final就没事情了呢,因为在这种情况下,变量是final的,lambda可以把这个变量Copy过来。换句话说,lambda执行的是这个变量的Copy,而不是原始值。
讲到这里,如果你熟悉Kotlin的话,你知道Kotlin没有这个限制,引用的变量不是非得被声明为final啊。难道Kotlin就没有Java遇到的问题吗?
Kotlin一样会遇到同样的问题,只是Kotlin的编译器比较聪明能干啊,它把Lambda引用到的变量都变成final了啊。哈哈,可能你发现了,如果变量本身不是final的,强制变成final这就不会有问题吗?
fun test(): () -> Unit {
var i = 0
i++
return {
println("i = $i")
}
}
以上的代码是可以正常编译被执行的。原因就是编译器干了一些事情,它把i变成堆变量(IntRef)了,并且声明了一个final的栈变量来指向堆变量。
public static final Function0 test() {
final IntRef i = new IntRef();
i.element = 0;
int var10001 = i.element++;
return (Function0)(new Function0() {
// $FF: synthetic method
// $FF: bridge method
public Object invoke() {
this.invoke();
return Unit.INSTANCE;
}
public final void invoke() {
String var1 = "i = " + i.element;
System.out.println(var1);
}
});
}
呵呵,其实吧,这是违背函数式编程的原则的。函数只需要依赖函数的输入,如果引用外部变量,会导致函数输出的不确定性。可能会导致一些偶现的很难解决的bug。
尤其如果在函数里面修改这些变量的话,如在final的List对象里面进行add/remove,这样还会有并发的安全隐患。
Invokedynamic避免为Lambda创建匿名对象
先稍微介绍一下字节码InvokeDynamic指令,需要更详细可以查看官方文档。这个指令最开始是在JDK7引入的,为了支持运行在JVM上面的动态类型语言。
先看如下代码,一个简单的Lambda表达式。
public Consumer<Integer> test() {
Consumer<Integer> r = (Integer i) -> {
StringBuilder sb = new StringBuilder();
sb.append("hello world").append(i);
System.out.println(sb.toString());
};
return r;
}
查看编译之后的字节码如下:
public java.util.function.Consumer<java.lang.Integer> test();
descriptor: ()Ljava/util/function/Consumer;
flags: (0x0001) ACC_PUBLIC
Code:
stack=1, locals=2, args_size=1
0: invokedynamic #2, 0 // InvokeDynamic #0:accept:()Ljava/util/function/Consumer;
5: astore_1
6: aload_1
7: areturn
LineNumberTable:
line 7: 0
line 12: 6
Signature: #20 // ()Ljava/util/function/Consumer<Ljava/lang/Integer;>;
可以看到,具体Lambda表达式被invokedynamic取代,可以将实现Lambda表达式的这部分的字节码生成推迟的运行时。这样避免了匿名对象的创建,而且没有额外的开销,因为原本也是从Java字节码进行函数对象的创建。而且如果这个Lambda没有被使用到的话,这个过程也不会被创建。如果这个Lambda被调用多次的话,只会在第一次进行这样的转换,其后所有的Lambda调用直接调用之前的链接的实现。
Kotlin因为需要兼容Java6,没法使用invokedynamic,所以编译器会为每个Lambda生成一个.class文件。这些文件通常为XXX$1的形式出现。生成大量的类文件是对性能有负面影响的,因为每个类文件在使用之前都要进行加载和验证,这会影响应用的启动时间,尤其是Lambda被大量使用之后。不过虽然Kotlin现在不支持,但是应该会在不久的将来就支持了。
可以在一些合适的场景下,使用inline来避免匿名对象的创建,Kotlin内置的很多方法都是inline的。也要注意,如果inline关键字使用不当,也会造成字节码膨胀,并影响性能。
Callback转协程
现在很多库函数都使用回调来进行异步处理,但是回调会有一些问题。主要有两方面吧
- 错误处理比较麻烦。
- 在一些循环中处理回调也会是麻烦的事情。
所以如果我们在工程中遇到回调API的话,一般的做法会把这些回调转换成协程,这样就可以用协程进行统一处理了。
回调大致分两类:
- 一次性事件回调,用suspendCancellableCoroutine处理。
- 多次事件回调,用callbackFlow处理。
我们先来看下用suspendCancellableCoroutine,以下是模板代码,使用这段模板代码可以方便的把任意回调方便地转换成协程。
suspend fun awaitCallback(): T = suspendCancellableCoroutine { continuation ->
val callback = object : Callback { // Implementation of some callback interface
override fun onCompleted(value: T) {
// Resume coroutine with a value provided by the callback
continuation.resume(value)
}
override fun onApiError(cause: Throwable) {
// Resume coroutine with an exception provided by the callback
continuation.resumeWithException(cause)
}
}
// Register callback with an API
api.register(callback)
// Remove callback on cancellation
continuation.invokeOnCancellation { api.unregister(callback) }
// At this point the coroutine is suspended by suspendCancellableCoroutine until callback fires
}
接下来,我们来看看suspendCancellableCoroutine这个函数到底干了什么,在注释里面有相关的代码的解释。
public suspend inline fun <T> suspendCancellableCoroutine(
crossinline block: (CancellableContinuation<T>) -> Unit
): T =
suspendCoroutineUninterceptedOrReturn { uCont ->
// 从最开始调用suspend函数的地方获取Continuation对象,并对把对象转换成CancellableContinuation对象。
val cancellable = CancellableContinuationImpl(uCont.intercepted(), resumeMode = MODE_CANCELLABLE)
cancellable.initCancellability()
// 调用block进行回调的注册
block(cancellable)
// 这个函数有个逻辑,如果回调已经结束,直接返回,调用者不进行挂起
// 如果回调还没有结束,返回COROUTINE_SUSPENDED,调用者挂起。
cancellable.getResult()
}
这里有一个关键的函数suspendCoroutineUninterceptedOrReturn,第一次看到这个函数的时候,就感到困惑,uCont这个变量是从哪里来的,这个地方光看代码是看不出从哪里来的。原因是这个uCont变量最终是编译器来处理的。每个suspend函数在编译的时候都会在参数列表最后增加一个Continuation的变量,在调用suspendCoroutineUninterceptedOrReturn的时候,会把调用者的Continuation的对象赋值给uCont。
所有这个函数给了我们一个机会,手动来处理suspend关键字给我们增加的那个参数对象。为什么我们要手动来处理呢,因为我们要把Continuation的对象转换成CancellableContinuation对象,这样我们就可以在被取消的时候来把回调给取消掉了。
如果要完全看懂以上代码,需要知道suspend关键字后面的逻辑,后面会有专门一节来说明。
关于多次事件的回调处理callbackFlow,基本逻辑与关键知识点和上面说的一致,所以这里不对callbackFlow进行说明了。
在suspend关键字后面
Kotlin协程是一个用户空间(相对于内核空间)实现的异步编程的框架。suspend关键字的处理是其中比较关键的一部分,要理解Kotlin的协程如何实现挂起和恢复,就必须要了解suspend关键字的后面的故事。
在讲suspend之前,我们先来了解一下Continuation-passing style(CPS)。
先来一道开胃菜,已知直角三角形的两条直角边长度分别为a和b,求斜边的长度 ?
define (a, b) {
return sqrt(a * a + b * b)
}
用勾股定理,可以用上面的代码可以轻松解决。上面的写法是典型的命令式编程,通过命令的组合方式解决问题。
现在我们来看看CPS的方案的代码应该如何来写?
define (a, b, continuation: (x, y) -> sqrt(x + y)) {
return continuation(a * a, b * b)
}
这里的CPS写法,把勾股定理分成两部分,第一部分计算直角边的平方和,第二部分进行开方操作。开方作为函数的参数传入,当第一部分计算完成之后,进行第二部分的操作。
哈哈,这不就是Callback的吗?没错CPS的本质就是Callback,或者说CPS就是通过Callback来实现的。当然如果仅仅把CPS理解Callback也是不完全准确。CPS要求每个函数,都需要指定这个函数执行完成之后的接下来的操作,所以这个名词应该是continuation,而不是callback,一般情况下,代码里面不会返回callback的执行结果,因为callback的语义上不是继续要干的事情,Continuation才是继续要干的事情,然后把最终的结果返回。
Kotlin编译器就是把suspend函数变成CPS的函数,来实现函数的挂起和恢复的。我们先来看最简单的例子,这个函数没有参数,也没有返回。先打印hello,1s之后再打印world。
suspend fun test() {
println("hello")
delay(1000L)
println("world")
}
对于这个函数,编译器做了两个主要的事情:
- 通过状态机的机制,把这个函数逻辑上分为两个函数,第一个函数是第一次被调用的,另一个函数是在这个函数从挂起状态恢复的时候调用,也就是在delay 1s之后执行的。
- 给suspend函数的参数添加continuation的参数,并在函数体里面对这个参数进行处理。
下面来看下,这个函数再被编译器处理之后的代码,代码以伪代码的形式给出。
fun test(con: Continuation): Any? {
class MyContinuation(
continuation: Continuation<*>): ContinuationImpl(continuation) {
var result: Any? = null
var label = 0
override fun invokeSuspend(res: Any?): Any? {
this.result = res
return test(this);
}
}
val continuation = con as? MyContinuation
?: MyContinuation(con)
if (continuation.label == 0) {
println("hello")
continuation.label = 1
if (delay(1000, continuation) == COROUTINE_SUSPENDED) {
return COROUTINE_SUSPENDED
}
}
if (continuation.label == 1) {
println("world")
return Unit
}
error("error")
}
每一个suspend函数,都有一个continuation参数传入,自己也有一个continuation,包含传入的continuation,自己的continuation对象的类是自己独有的,一般会是一个匿名内部类,这里为了好理解,我把这个匿名的内部来变成普通的类,便于说明问题。在第一次调用这个函数的时候,会实例化自己的continuation对象。实例化的逻辑是
val continuation = con as? MyContinuation
?: MyContinuation(con)
这里有个特别关键的MyContinuation对象的变量label,初始化为0,所以函数第一次执行的是代码里面label等于0的分支,通过这样状态机的机制,把函数从逻辑上可以分成多个函数。
再来看下上面函数体里面的COROUTINE_SUSPENDED,当delay函数返回COROUTINE_SUSPENDED,这个函数也返回COROUTINE_SUSPENDED,同样,如果有函数调用这个函数的时候,也返回COROUTINE_SUSPENDED。这个标识就是用来指示函数进入了挂起状态,等着被回调了。所以函数挂起的实质是,这个函数在当前的label分支下返回了。
如果suspend函数没有返回COROUTINE_SUSPENDED呢,那就接着执行,执行函数下一个状态的逻辑。所以函数在进入当前的状态的时候,就要马上把下个状态设置好。
continuation.label = 1
。如果当前函数进入挂起状态,就会把当前的continuation对象传入到调用的函数中,当函数需要恢复的时候,会调用continuation的invokeSuspend的方法,就会重新执行这个函数,这里就是一个Callback了。当然会进入label等于1的分支。所以函数恢复的实质是,这个函数在新Label状态下被重新调用了。
注意了suspend函数不一定返回COROUTINE_SUSPENDED的,也可能返回具体的值。如以下的函数:
suspend fun test(): String {
return "hello"
}
这个函数就没必要进入挂起了,没有返回COROUTINE_SUSPENDED,在这种情况下,函数会执行下一个label分支。
这也是为什么每个suspend函数在编译器处理之后的函数返回值是Any?。这其实是一个union的结构体,只是现在Kotlin还不支持union这样的概念,不过Kotlin变化这么快,之后没准也会支持。
这里的MyContinuation继承了ContinuationImpl,所以看起来MyContinuation实现的比较简单,因为很多的复杂的逻辑都封装在ContinuationImpl中了。下面我们尝试用一个更复杂的例子,然后自己实现ContinuationImpl,更完整来看下背后的逻辑。
在下面的例子中,suspend会更复杂,有参数,有返回。
suspend fun test(token: String) {
println("hello")
val userId = getUserId(token) // suspending
println("userId: $userId")
val userName = getUserName(userId) // suspending
println("id: $userId, name: $userName")
println("world")
}
编译器处理过的代码大致如下:
fun test(
token: String,
con: Continuation
): Any? {
val continuation = con as? MyContinuation
?: MyContinuation(con)
var result: Result<Any>? = continuation.result
var userId: String? = continuation.userId
val userName: String
if (continuation.label == 0) {
println("hello")
continuation.label = 1
val res = getUserId(token, continuation)
if (res == COROUTINE_SUSPENDED) {
return COROUTINE_SUSPENDED
}
result = Result.success(res)
}
if (continuation.label == 1) {
userId = result.getOrThrow() as String
println("userId: $userId")
continuation.label = 2
continuation.userId = userId
val res = getUserName(userId, continuation)
if (res == COROUTINE_SUSPENDED) {
return COROUTINE_SUSPENDED
}
result = Result.success(res)
}
if (continuation.label == 2) {
userName = result.getOrThrow() as String
println("id: $userId, name: $userName")
println("world")
return Unit
}
error("error")
}
MyContinuation的代码如下:
class MyContinuation(
val completion: Continuation<Unit>,
val token: String
) : Continuation<String> {
override val context: CoroutineContext
get() = completion.context
var label = 0
var result: Result<Any>? = null
var userId: String? = null
override fun resumeWith(result: Result<String>) {
this.result = result
val res = try {
val r = test(token, this)
if (r == COROUTINE_SUSPENDED) return
Result.success(r as Unit)
} catch (e: Throwable) {
Result.failure(e)
}
completion.resumeWith(res)
}
}
还记得函数调用,一般都是通过Stack来处理,局部变量和函数的返回之后继续执行的地址都存在stack frame中,这里Continuation的作用就相当于这个Stack了。
还有一个小小的知识点,suspend函数可以调用suspend,所以总有一个最初始suspend函数的吧,不会不就没止境了啊。最初始的那个suspend函数一定是从Callback转换而来的,这里具体可以查看上一节关于Callback转suspend函数的介绍。
以上较多参考了Coroutines under the hood这篇文章,并加入了一些自己的思考,很多都是自己的理解,肯定有错误和不足之处,也请指正。
Corountine Job.join()的一个大坑
这问题起源于我的另外一篇文章,应用程序启动优化新思路 - Kotlin协程,文章讲的是应用启动时,通过Kotlin协程的方案,简化多任务并行初始化的代码逻辑。其实这类问题具有普遍性,我现在举另外一个例子来说明。
做过Android系统开发的工程师一定知道,编译整个Android系统是耗时的,因为里面有至少有数百个模块,模块和模块之间也可能存在依赖关系。这里一般系统都是支持多线程并行编译的,那如何来使用多线程来组织这些模块的编译呢?
考虑一个最简单的例子,现在有5个build tasks,依赖关系如下:
这个图是一个典型的有向无环图,按照拓扑排序的顺利来执行即可,下面考虑使用协程来多任务并行。首先,任务1和任务2没有被依赖,可以被启动,这里可以并行的执行。
suspend fun build() = coroutineScope {
// 调度协程
val job1 = launch(Dispatchers.Default) {
// start build task 1.
}
val job2 = launch(Dispatchers.Default) {
// start build task 2.
}
}
接下来有些难办,任务3,任务4,任务5都是有依赖的,但是我们没法知道任务1和任务2什么时候可以执行完成,所以我们使用了Kotlin协程系统的Join来进行等待。但是这里要注意,我们不能在调度协程里面进行对Job的Join操作。如以下的代码就会存在问题:
suspend fun build() = coroutineScope {
val job1 = launch(Dispatchers.Default) {
// start build task 1.
}
val job2 = launch(Dispatchers.Default) {
// start build task 2.
}
job1.join()
val job3 = launch(Dispatchers.Default) {
// start build task 3.
}
}
如果Task1很耗时,Task3需要等Task1完成之后执行,但是Task2很快就执行完了,可以安排Task5进行执行,如果在调度协程中进行Join,就会一直处于等待Task1执行完成,所以Join的等待不能在调度协程中,那怎么办呢? 我们可以在Task任务协程中进行等待,就可以解决这个问题了。如下面的代码。
suspend fun build() = coroutineScope {
val job1 = launch(Dispatchers.Default) {
// start build task 1.
}
val job2 = launch(Dispatchers.Default) {
// start build task 2.
}
val job3 = launch(Dispatchers.Default) {
job1.join()
// start build task 3.
}
val job4 = launch(Dispatchers.Default) {
job1.join()
job2.join()
// start build task 4.
}
val job5 = launch(Dispatchers.Default) {
job2.join()
// start build task 5.
}
}
以上代码运行良好,我们在测试的时候,所有的逻辑都按照我们所预想的方式执行。但是有一天,我们发现,有非常低的概率发生,这些任务会无法结束,也就是以上的build方法没办法返回,这是一个概率极低的事件,但是确实存在,哈哈,我们掉坑里去了。所以我们就去看了Join的源码,想看看到底发生了什么事情?首先,如果你看懂了上面关于Suspend的那节的话,你会清楚的知道Join是如何进行挂起的,重新恢复必然会走Continuation的resume方法。
以上是我们的大致的想法,然后我们来看一下Join函数到底干了什么?
public final override suspend fun join() {
if (!joinInternal()) { // fast-path no wait
coroutineContext.ensureActive()
return // do not suspend
}
return joinSuspend() // slow-path wait
}
上面Join函数两个分支,第一个分支的意思是,依赖的Job已经结束了,不需要等待了,可以执行返回了。第二个意思是依赖的任务还没有结束,我们需要等待。毫无疑问,我们出问题的代码是走的是第二个分支,那我们来看看第二个分支到底做了些什么?
private suspend fun joinSuspend() = suspendCancellableCoroutine<Unit> { cont ->
// We have to invoke join() handler only on cancellation, on completion we will be resumed regularly without handlers
cont.disposeOnCancellation(invokeOnCompletion(handler = ResumeOnCompletion(cont).asHandler))
}
哈哈,这不就是我们之前讨论的Callback转Suspend函数的代码吗?代码里面cont变量就是代表调用Join函数编译器加入的最后一个参数。我们可以看到,cont变量给了一个叫ResumeOnCompletion的类,那我们接着来看ResumeOnCompletion这个类的实现的吧。
private class ResumeOnCompletion(
private val continuation: Continuation<Unit>
) : JobNode() {
override fun invoke(cause: Throwable?) = continuation.resume(Unit)
}
我们找到了那个关键的代码了,continuation.resume(Unit)
,这个是Join函数返回最关键的代码了,所以我在这里这个函数上面下了断点,当函数执行到这里的时候,所有的调用栈清晰可见。原来是被依赖的Job里面有个list,里面放着所有这个Job的Join函数的ResumeOnCompletion,然后在Job结束的时候,会遍历这个list,然后执行resume函数,然后Join函数就会返回了。这里的返回只是感觉上的返回,如果你看了上面关于suspend的介绍的话,就会知道所谓的返回就是在新状态下从新执行了那个函数了。那这个ResumeOnCompletion是如何放到这个list的呢? 就是通过上面的invokeOnCompletion方法。如果需要更加细致的了解,可以自己调试一下这个代码。
说到这里,不知道大家是否意识到之前代码的问题所在了?
问题出现在,因为Join的代码有可能运行在另外的线程,所以当判断所依赖的任务没有结束,需要等待的时候,把自己的放到list的过程中,还没有放在list里面的那一刹那,Job刚好结束,然后通知list里面的任务可以重新开始了,但是那个任务刚好没有被放到list里面,所以一旦错过,就成了永远了。
所以吧,Kotlin的官方代码里面,所有的Join函数的执行,都是在launch这个Job的协程中执行的。一个协程,不同的时候,可能会运行在不同的线程上,但是一个协程本身是顺序执行的。
好吧,正确的代码如下:
suspend fun build() = coroutineScope {
val context = coroutineContext
val job1 = launch(Dispatchers.Default) {
// start build task 1.
}
val job2 = launch(Dispatchers.Default) {
// start build task 2.
}
val job3 = launch(Dispatchers.Default) {
withContext(context) {
job1.join()
}
// start build task 3.
}
val job4 = launch(Dispatchers.Default) {
withContext(context) {
job1.join()
job2.join()
}
// start build task 4.
}
val job5 = launch(Dispatchers.Default) {
withContext(context) {
job2.join()
}
// start build task 5.
}
}
以上的代码经过长时间的测试和验证,证明是可靠的。另外,如果想知道这个问题更详细的背景,请参看 应用程序启动优化新思路 - Kotlin协程
CoroutineContext vs CoroutineScope
这一节聊下在Kotlin协程中一些基本概念,这些知识点本身不难,但是对于初学者来说,比较容易搞混。下面尝试来试着说明。
首先,先来看一下CoroutineContext,这个比较好理解,就是协程的context。什么叫context,中文一般翻译成上下文,表示一些基本信息。对于Android Application的context,包含包名,版本信息,应用安装路径,应用的工作目录和缓存目录等等基本信息,是描述应用的一些基本信息的。同理协程的context当然就是协程的基本信息。CoroutineContext包含4类信息,如下:
coroutineContext[Job]
,Job的作用是管理协程的生命周期,和父子协程的关系,可以通过获取。coroutineContext[ContinuationInterceptor]
,协程工作线程的管理。coroutineContext[CoroutineExceptionHandler]
,错误处理。coroutineContext[CoroutineName]
,协程的名字,一般用作调试。
CoroutineScope这个概念,最开始看的时候和CoroutineContext有点分不清楚。其实你看CoroutineScope的接口代码,里面就包含且仅包含CoroutineContext,本质上,他们其实是一个东西。为什么要设计CoroutineScope呢?虽然这两个本质上是同一个东西,但是他们有不同的设计目的。Context是管理来协程的基本信息,而Scope是用来管理协程的启动。
一般的协程通过launch来启动,launch设计成CoroutineScope的扩展函数,非常有意思的设计是,launch的最后一个参数,新协程的执行体也是一个CoroutineScope的扩展函数。launch函数的第一个参数是一个Context,launch会把第一个参数的Context和本身的Context合成一个新的Context,这个新的Context会用来生成新协程的Context,注意这里不是作为新协程的Context。为什么呢,因为新协程为生成一个Job,这个Job和这个Context合成之后,才是作为新协程的Context。
这里两个知识点要注意,所有的Context都是Immutable的,如果要修改一个Context,就会新生成一个新的Context。另外,launch的第一个参数,一般没有指定Job的,一旦指定Job的话,会破会两个协程的父子关系。除非你很确定你要这么做。
所有这些概念的东西,本质上不难,但是对于初学者来说,会感到一头雾水,要深入了解这些概念,需要先去了解一下设计者的设计思路,这样才可以做到事半功倍。
结尾
以上希望可以给大家一些帮助。另外文章免不了一些疏忽和错误,不吝指正。
作者:KETIAN
链接:https://juejin.cn/post/7109300360614772749
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
AsyncTask源码分析
AsyncTask,Android 实现异步方式之一,即可以在子线程进行数据操作,然后在主线程进行 UI 操作
AsyncTask的简单使用
示例
同样的,我们先看看 AsyncTask 如何进行简单使用:
AsyncTask<Boolean, Integer, String> asyncTask = new AsyncTask<Boolean, Integer, String>() {
@Override
protected void onPreExecute() {
super.onPreExecute();
Log.i("TAG", "onPreExecute:正在执行前的准备操作");
}
@Override
protected String doInBackground(Boolean... booleans) {
Log.i("TAG", "doInBackground:获得参数" + booleans[0]);
for (int i = 0; i < 5; i++) {
publishProgress(i);
}
return "任务完成";
}
@Override
protected void onProgressUpdate(Integer... values) {
super.onProgressUpdate(values);
Log.i("TAG", "onProgressUpdate:进度更新" + values[0]);
}
@Override
protected void onPostExecute(String s) {
super.onPostExecute(s);
Log.i("TAG", "onPostExecute:" + s);
}
};
Log.i("TAG", "开始调用execute");
asyncTask.execute(true);
输出结果:
2020-04-02 17:14:42.029 4995-4995 I/TAG: 开始调用execute
2020-04-02 17:14:42.029 4995-4995 I/TAG: onPreExecute:正在执行前的准备操作
2020-04-02 17:14:42.030 4995-5118 I/TAG: doInBackground:获得参数true
2020-04-02 17:14:45.774 4995-4995 I/TAG: onProgressUpdate:进度更新0
2020-04-02 17:14:45.774 4995-4995 I/TAG: onProgressUpdate:进度更新1
2020-04-02 17:14:45.774 4995-4995 I/TAG: onProgressUpdate:进度更新2
2020-04-02 17:14:45.774 4995-4995 I/TAG: onProgressUpdate:进度更新3
2020-04-02 17:14:45.774 4995-4995 I/TAG: onProgressUpdate:进度更新4
2020-04-02 17:14:45.775 4995-4995 I/TAG: onPostExecute:任务完成
创建说明
首先,在创建 AsyncTask 的时候,需要传入三个泛型数据AsyncTask<Params, Progress, Result>
,其分别对应着
protected abstract Result doInBackground(Params... params);
@MainThread
protected void onProgressUpdate(Progress... values) {
}
@MainThread
protected void onPostExecute(Result result) {
}
从注解可以看出,onProgressUpdate
和onPostExecute
是在主线程执行。
执行流程
- AsyncTask 调用
execute(Params... params)
方法 onPreExecute()
被调用,该方法用于在执行后台操作前进行一些操作,例如:弹出个加载框等doInBackground(Params... params)
被调用,该方法用于进行一些复杂的数据处理,例如数据库操作等- 在
doInBackground
进行操作的过程中,可以通过publishProgress(Progress... values)
进行进度更新,从而自动调用onProgressUpdate(Progress... values)
- 当
doInBackground
执行完毕后,返回数据,将会调用onPostExecute(Result result)
源码分析(源码只保留关键部分,并非全部源码)
AsyncTask创建
public AsyncTask() {
this((Looper) null);
}
public AsyncTask(@Nullable Looper callbackLooper) {
//创建Handler,默认使用主线程的 Looper
mHandler = callbackLooper == null || callbackLooper == Looper.getMainLooper()
? getMainHandler()
: new Handler(callbackLooper);
//后面这段代码看起来有点长,其实就是使用了 Future 模式,
//先建立一个类继承 Callable 接口,再将该类赋值到 FutureTask 中,
//至于 call() 和 done() 方法里面具体内容可以先不用理会
//等 mFuture 被线程调用的时候,就会调用 call()
mWorker = new WorkerRunnable<Params, Result>() {
public Result call() throws Exception {
···
return result;
}
};
mFuture = new FutureTask<Result>(mWorker) {
@Override
protected void done() {
···
}
};
}
InternalHandler解析
着重看下getMainHandler()
private static Handler getMainHandler() {
synchronized (AsyncTask.class) {
if (sHandler == null) {
sHandler = new InternalHandler(Looper.getMainLooper());
}
return sHandler;
}
}
private static class InternalHandler extends Handler {
public InternalHandler(Looper looper) {
super(looper);
}
@SuppressWarnings({"unchecked", "RawUseOfParameterizedType"})
@Override
public void handleMessage(Message msg) {
AsyncTaskResult<?> result = (AsyncTaskResult<?>) msg.obj;
switch (msg.what) {
case MESSAGE_POST_RESULT:
// There is only one result
result.mTask.finish(result.mData[0]);
break;
case MESSAGE_POST_PROGRESS:
result.mTask.onProgressUpdate(result.mData);
break;
}
}
}
在创建 InternalHandler 的时候,传入了Looper.getMainLooper()
,说明了 InternalHandler 的handleMessage
方法可以执行 UI 操作。
我们在仔细看看handleMessage
里面有两种处理:
result.mTask.finish(result.mData[0]);
与
result.mTask.onProgressUpdate(result.mData);
其中,result 即为 AsyncTaskResult<?>
private static class AsyncTaskResult<Data> {
final AsyncTask mTask;
final Data[] mData;
AsyncTaskResult(AsyncTask task, Data... data) {
mTask = task;
mData = data;
}
}
所以result.mTask
其实就是 AsyncTask,result.mTask.finish
就是调用 AsyncTask 的 finish 方法:
private void finish(Result result) {
//判断当前 AsyncTask 是否已被取消,已取消则调用 onCancelled,未取消则调用 onPostExecute
if (isCancelled()) {
onCancelled(result);
} else {
onPostExecute(result);
}
mStatus = Status.FINISHED;
}
由此,可以证明onPostExecute
在 UI 线程执行
result.mTask.onProgressUpdate(result.mData);
就更容易理解了,直接调用onProgressUpdate
@MainThread
protected void onProgressUpdate(Progress... values) {
}
由此,可以证明onProgressUpdate
也在 UI 线程执行
AsyncTask执行
asyncTask.execute(true);
@MainThread
public final AsyncTask<Params, Progress, Result> execute(Params... params) {
return executeOnExecutor(sDefaultExecutor, params);
}
// sDefaultExecutor 为线程池
private static volatile Executor sDefaultExecutor = SERIAL_EXECUTOR;
public static final Executor SERIAL_EXECUTOR = new SerialExecutor();
@MainThread
public final AsyncTask<Params, Progress, Result> executeOnExecutor(Executor exec,
Params... params) {
//判断当前 AsyncTask 的运行状态,假如运行状态为 RUNNING 或者 FINISHED,则直接报错
if (mStatus != Status.PENDING) {
switch (mStatus) {
case RUNNING:
throw new IllegalStateException("Cannot execute task:"
+ " the task is already running.");
case FINISHED:
throw new IllegalStateException("Cannot execute task:"
+ " the task has already been executed "
+ "(a task can be executed only once)");
}
}
mStatus = Status.RUNNING;
onPreExecute();
mWorker.mParams = params;
exec.execute(mFuture);
return this;
}
从以上代码可以得出:
- AsyncTask 内部使用了线程池进行线程操作
- 每个 AsyncTask 对象只能执行一次!!!
???
使用线程池,却一个对象只能执行一次?这两个不是互相矛盾的吗?众所周知,线程池都是为了管理多线程而存在的。
我们再来仔细看下
private static volatile Executor sDefaultExecutor = SERIAL_EXECUTOR;
public static final Executor SERIAL_EXECUTOR = new SerialExecutor();
static volatile
、static final
,说明默认实现 AsyncTask 的对象都是共用该线程池,也就是说,所有使用 AsyncTask 默认生成方式,以及继承 AsyncTask 的类使用 AsyncTask 默认生成方式,他们的线程执行都是共用一个线程池,这就为什么 AsyncTask 里面使用线程池的原因。
好了,我们重新回来看看executeOnExecutor(Executor exec, Params... params)
onPreExecute();
mWorker.mParams = params;
exec.execute(mFuture);
首先,先执行onPreExecute()
,其次使用线程池调用mFuture
关于mFuture
,重点为call()
mWorker = new WorkerRunnable<Params, Result>() {
public Result call() throws Exception {
mTaskInvoked.set(true);
Result result = null;
try {
Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND);
//noinspection unchecked
//调用doInBackground
result = doInBackground(mParams);
Binder.flushPendingCommands();
} catch (Throwable tr) {
mCancelled.set(true);
throw tr;
} finally {
//调用postResult
postResult(result);
}
return result;
}
};
从这里可以看出,doInBackground
是被线程池调用的时候执行的,也就是说,doInBackground
在子线程中执行。而外,我们看看postResult(result)
private Result postResult(Result result) {
@SuppressWarnings("unchecked")
Message message = getHandler().obtainMessage(MESSAGE_POST_RESULT,
new AsyncTaskResult<Result>(this, result));
message.sendToTarget();
return result;
}
发送了一个MESSAGE_POST_RESULT
消息,也就是执行刚刚我们分析过的
case MESSAGE_POST_RESULT:
// There is only one result
result.mTask.finish(result.mData[0]);
break;
至此,onPreExecute
、doInBackground
、onPostExecute
的一个流程我们已经分析完了,大概流程如下:
execute(AsyncTask被调用的线程)-->onPreExecute(AsyncTask被调用的线程)-->doInBackground(子线程)-->onPostExecute(UI线程)
onProgressUpdate
剩下,我们来看遗漏的onProgressUpdate
。
首先,onProgressUpdate
要被调用的话,需要先调用publishProgress
:
@WorkerThread
protected final void publishProgress(Progress... values) {
if (!isCancelled()) {
getHandler().obtainMessage(MESSAGE_POST_PROGRESS,
new AsyncTaskResult<Progress>(this, values)).sendToTarget();
}
}
其实就是使用InternalHandler
发送MESSAGE_POST_PROGRESS
case MESSAGE_POST_PROGRESS:
result.mTask.onProgressUpdate(result.mData);
break;
作者:不近视的猫
链接:https://juejin.cn/post/7109716545705771039
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
Android与JavaScript交互上(获取Html内容)
在Android开发中,一般通过WebView实现与JavaScript的交互(还有其他更高级的方法!)。WebView用于加载网页内容,如果需要对该网页进行交互操作,可以通过添加一个
JavascriptInterface
交互对象,在恰当的时候调用Js语句运行接口中对应的方法,进行交互操作。
以交互掘金页面为例子。
- 开启JavaScript:拿到webView后设置
javaScriptEnabled=true
,开启JavaScript。 - 设置UserAgent:
userAgentString
非必需,如果需要对网页的某个元素进行解析,最好进行设置。因为一般网页会对不同浏览器进行适配,使得浏览器之间的Html代码会有差异,而导致解析失败。设置方法:电脑浏览器打开目标网页,F12开启后台模式,左上角切换到手机模式,找到user-agent(具体见下图)复制粘贴。 - 设置交互对象:
webView.addJavascriptInterface(JavaOBjectJsInterface(),"testJs")
添加一个用于交互的对象,交互方法在类JavaOBjectJsInterface()
中,testJs
是一个自定义的名字。Js语句通过testJs
调用到JavaOBjectJsInterface()
内的方法。 - 设置
webClient
开启交互:重写onPageFinished(view, url)
方法。该方法在网页加载完成时调用,一般这个时候可以拿到完整的网页Html代码。Js语句document.getElementsByTagName('body')[0].innerHTML
可以拿到网页<body>块代码,将该代码作为参数传入交互方法test(html)
中。webView运行javascript:window.testJs.test(document.getElementsByTagName('body')[0].innerHTML
,通过上一步设置交互对象传入的testJs
调用到接口对象中的test(html)
方法,实现交互。 - 实现交互:
test(html)
中,通过Jsoup(一个网页解析框架)对<body>块代码进行解析,通过在网页端获得的按钮class
名获取到目标元素。
val link="https://juejin.cn/?utm_source=gold_browser_extension"
val webView=findViewById<WebView>(R.id.webview)
webView.settings.run {
javaScriptEnabled=true //开启Js
userAgentString="Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.4951.64 Mobile Safari/537.36"
useWideViewPort=true
loadWithOverviewMode=true
setSupportZoom(true)
javaScriptCanOpenWindowsAutomatically=true
loadsImagesAutomatically=true
defaultTextEncodingName="utf - 8"
}
webView.webViewClient=object : WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) {
super.onPageFinished(view, url)
webView.loadUrl("javascript:window.testJs.test(document.getElementsByTagName('body')[0].innerHTML);")
}
}
webView.addJavascriptInterface(JavaOBjectJsInterface(),"testJs")
webView.loadUrl(link)
class JavaOBjectJsInterface {
private val TAG = JavaOBjectJsInterface::class.java.simpleName
@JavascriptInterface
fun test(html: String) {
val document = Jsoup.parse(html)
val btn=document.getElementsByClass("seach-icon-container")
Log.d(TAG, "btn:" + btn);
}
}
获取目标网页UserAgent
运行结果
作者:猫摸毛毛虫
链接:https://juejin.cn/post/7102445544437448717
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
90%的人都不懂的泛型,泛型的缺陷和应用场景
Hi 大家好,我是 DHL。公众号:ByteCode ,专注分享有趣硬核原创内容,Kotlin、Jetpack、性能优化、系统源码、算法及数据结构、动画、大厂面经
全文分为 视频版 和 文字版,
- 文字版: 文字侧重细节和深度,有些知识点,视频不好表达,文字描述的更加准确
- 视频版: 视频会更加的直观,看完文字版,在看视频,知识点会更加清楚
视频版 bilibili 地址:https://b23.tv/AdLtUGf
泛型对于每个开发者而言并不陌生,平时在项目中会经常见到,但是有很多小伙伴们,每次见到通配符 ? extends
、 ? super
、 out
、 in
都傻傻分不清楚它们的区别,以及在什么情况下使用。
通过这篇文章将会学习的到以下内容。
- 为什么要有泛型
- Kotlin 和 Java 的协变
- Kotlin 和 Java 的逆变
- 通配符
? extends
、? super
、out
、in
的区别和应用场景 - Kotlin 和 Java 数组协变的不同之处
- 数组协变的缺陷
- 协变和逆变的应用场景
为什么要有泛型
在 Java 和 Kotlin 中我们常用集合( List
、 Set
、 Map
等等)来存储数据,而在集合中可能存储各种类型的数据,现在我们有四种数据类型 Int
、 Float
、 Double
、 Number
,假设没有泛型,我们需要创建四个集合类来存储对应的数据。
class IntList{ }
class Floatlist{}
class DoubleList{}
class NumberList{}
......
更多
如果有更多的类型,就需要创建更多的集合类来保存对应的数据,这显示是不可能的,而泛型是一个 "万能的类型匹配器",同时有能让编译器保证类型安全。
泛型将具体的类型( Int
、 Float
、 Double
等等)声明的时候使用符号来代替,使用的时候,才指定具体的类型。
// 声明的时候使用符号来代替
class List<E>{
}
// 在 Kotlin 中使用,指定具体的类型
val data1: List<Int> = List()
val data2: List<Float> = List()
// 在 Java 中使用,指定具体的类型
List<Integer> data1 = new List();
List<Float> data2 = new List();
泛型很好的帮我们解决了上面的问题,但是随之而来出现了新的问题,我们都知道 Int
、 Float
、 Double
是 Number
子类型, 因此下面的代码是可以正常运行的。
// Kotlin
val number: Number = 1
// Java
Number number = 1;
我们花三秒钟思考一下,下面的代码是否可以正常编译。
List<Number> numbers = new ArrayList<Integer>();
答案是不可以,正如下图所示,编译会出错。
这也就说明了泛型是不可变的,IDE 认为 ArrayList<Integer>
不是 List<Number>
子类型,不允许这么赋值,那么如何解决这个问题呢,这就需要用到协变了,协变允许上面的赋值是合法的。
Kotlin 和 Java 的协变
- 在 Java 中用通配符
? extends T
表示协变,extends
限制了父类型T
,其中?
表示未知类型,比如? extends Number
,只要声明时传入的类型是Number
或者Number
的子类型都可以 - 在 Kotlin 中关键字
out T
表示协变,含义和 Java 一样
现在我们将上面的代码修改一下,在花三秒钟思考一下,下面的代码是否可以正常编译。
// kotlin
val numbers: MutableList<out Number> = ArrayList<Int>()
// Java
List<? extends Number> numbers = new ArrayList<Integer>();
答案是可以正常编译,协变通配符 ? extends Number
或者 out Number
表示接受 Number
或者 Number
子类型为对象的集合,协变放宽了对数据类型的约束,但是放宽是有代价的,我们在花三秒钟思考一下,下面的代码是否可以正常编译。
// Koltin
val numbers: MutableList<out Number> = ArrayList<Int>()
numbers.add(1)
// Java
List<? extends Number> numbers = new ArrayList<Integer>();
numbers.add(1)
调用 add()
方法会编译失败,虽然协变放宽了对数据类型的约束,可以接受 Number
或者 Number
子类型为对象的集合,但是代价是 无法添加元素,只能获取元素,因此协变只能作为生产者,向外提供数据。
为什么无法添加元素
因为 ?
表示未知类型,所以编译器也不知道会往集合中添加什么类型的数据,因此索性不允许往集合中添加元素。
但是如果想让上面的代码编译通过,想往集合中添加元素,这就需要用到逆变了。
Kotlin 和 Java 的逆变
逆变其实是把继承关系颠倒过来,比如 Integer
是 Number
的子类型,但是 Integer
加逆变通配符之后,Number
是 ? super Integer
的子类,如下图所示。
- 在 Java 中用通配符
? super T
表示逆变,其中?
表示未知类型,super
主要用来限制未知类型的子类型T
,比如? super Number
,只要声明时传入是Number
或者Number
的父类型都可以 - 在 Kotlin 中关键字
in T
表示逆变,含义和 Java 一样
现在我们将上面的代码简单修改一下,在花三秒钟思考一下是否可以正常编译。
// Kotlin
val numbers: MutableList<in Number> = ArrayList<Number>()
numbers.add(100)
// Java
List<? super Number> numbers = new ArrayList<Number>();
numbers.add(100);
答案可以正常编译,逆变通配符 ? super Number
或者关键字 in
将继承关系颠倒过来,主要用来限制未知类型的子类型,在上面的例子中,编译器知道子类型是 Number
,因此只要是 Number
的子类都可以添加。
逆变可以往集合中添加元素,那么可以获取元素吗?我们花三秒钟时间思考一下,下面的代码是否可以正常编译。
// Kotlin
val numbers: MutableList<in Number> = ArrayList<Number>()
numbers.add(100)
numbers.get(0)
// Java
List<? super Number> numbers = new ArrayList<Number>();
numbers.add(100);
numbers.get(0);
无论调用 add()
方法还是调用 get()
方法,都可以正常编译通过,现在将上面的代码修改一下,思考一下是否可以正常编译通过。
// Kotlin
val numbers: MutableList<in Number> = ArrayList<Number>()
numbers.add(100)
val item: Int = numbers.get(0)
// Java
List<? super Number> numbers = new ArrayList<Number>();
numbers.add(100);
int item = numbers.get(0);
调用 get()
方法会编译失败,因为 numbers.get(0)
获取的的值是 Object
的类型,因此它不能直接赋值给 int
类型,逆变和协变一样,放宽了对数据类型的约束,但是代价是 不能按照泛型类型读取元素,也就是说往集合中添加 int
类型的数据,调用 get()
方法获取到的不是 int
类型的数据。
对这一小节内容,我们简单的总结一下。
关键字(Java/Kotlin) | 添加 | 读取 | |
---|---|---|---|
协变 | ? extends / out | ❎ | ✅ |
逆变 | ? super / in | ✅ | ❎ |
Kotlin 和 Java 数组协变的不同之处
无论是 Kotlin 还是 Java 它们协变和逆变的含义的都是一样的,只不过通配符不一样,但是他们也有不同之处。
Java 是支持数组协变,代码如下所示:
Number[] numbers = new Integer[10];
但是 Java 中的数组协变有缺陷,将上面的代码修改一下,如下所示。
Number[] numbers = new Integer[10];
numbers[0] = 1.0;
可以正常编译,但是运行的时候会崩溃。
因为最开始我将 Number[]
协变成 Integer[]
,接着往数组里添加了 Double
类型的数据,所以运行会崩溃。
而 Kotlin 的解决方案非常的干脆,不支持数组协变,编译的时候就会出错,对于数组逆变 Koltin 和 Java 都不支持。
协变和逆变的应用场景
协变和逆变应用的时候需要遵循 PECS(Producer-Extends, Consumer-Super)原则,即 ? extends
或者 out
作为生产者,? super
或者 in
作为消费者。遵循这个原则的好处是,可以在编译阶段保证代码安全,减少未知错误的发生。
协变应用
- 在 Java 中用通配符
? extends
表示协变 - 在 Kotlin 中关键字
out
表示协变
协变只能读取数据,不能添加数据,所以只能作为生产者,向外提供数据,因此只能用来输出,不用用来输入。
在 Koltin 中一个协变类,参数前面加上 out
修饰后,这个参数在当前类中 只能作为函数的返回值,或者修饰只读属性 ,代码如下所示。
// 正常编译
interface ProduceExtends<out T> {
val num: T // 用于只读属性
fun getItem(): T // 用于函数的返回值
}
// 编译失败
interface ProduceExtends<out T> {
var num : T // 用于可变属性
fun addItem(t: T) // 用于函数的参数
}
当我们确定某个对象只作为生产者时,向外提供数据,或者作为方法的返回值时,我们可以使用 ? extends
或者 out
。
- 以 Kotlin 为例,例如
Iterator#next()
方法,使用了关键字out
,返回集合中每一个元素
- 以 Java 为例,例如
ArrayList#addAll()
方法,使用了通配符? extends
传入参数 Collection<? extends E> c
作为生产者给 ArrayList
提供数据。
逆变应用
- 在 Java 中使用通配符
? super
表示逆变 - 在 Kotlin 中使用关键字
in
表示逆变
逆变只能添加数据,不能按照泛型读取数据,所以只能作为消费者,因此只能用来输入,不能用来输出。
在 Koltin 中一个逆变类,参数前面加上 in
修饰后,这个参数在当前类中 只能作为函数的参数,或者修饰可变属性 。
// 正常编译,用于函数的参数
interface ConsumerSupper<in T> {
fun addItem(t: T)
}
// 编译失败,用于函数的返回值
interface ConsumerSupper<in T> {
fun getItem(): T
}
当我们确定某个对象只作为消费者,当做参数传入时,只用来添加数据,我们使用通配符 ? super
或者关键字 in
,
- 以 Kotlin 为例,例如扩展方法
Iterable#filterTo()
,使用了关键字in
,在内部只用来添加数据
- 以 Java 为例,例如
ArrayList#forEach()
方法,使用了通配符? super
不知道小伙伴们有没有注意到,在上面的源码中,分别使用了不同的泛型标记符 T
和 E
,其实我们稍微注意一下,在源码中有几个高频的泛型标记符 T
、 E
、 K
、 V
等等,它们分别应用在不同的场景。
标记符 | 应用场景 |
---|---|
T(Type) | 类 |
E(Element) | 集合 |
K(Key) | 键 |
V(Value) | 值 |
全文到这里就结束了,感谢你的阅读,坚持原创不易,欢迎在看、点赞、分享给身边的小伙伴,我会持续分享原创干货!!!
作者:程序员DHL
链接:https://juejin.cn/post/7111187038077976607
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
Java并发系列:详解Synchronized关键字
一、简介
为了提高效率,出现了多线程并发执行,并发执行处理共享变量就会带来安全性问题。那么,在java关键字synchronized就具有使每个线程依次排队操作共享变量的功能。很显然,这种同步机制效率很低,但synchronized是其他并发容器实现的基础,对它的理解也会大大提升对并发编程的感觉。
二、synchronized实现原理
2.1 monitor机制
2.1.1 Java对象头
Java中每个对象都有他的对象头,并且synchronized用的锁是存在对象头中的。
- Klass Word 指明对象的类型
- Mark Word 存储hashCode或锁信息
- 对于数组对象,还需要32位存储数组长度(32位虚拟机);
- 所以在java中,int类型和Integer类型所占的大小是不同的,int占4个字节,Integer是对象,本身8个字节和存储的值4个字节,总共占12个字节;
2.1.2 monitor
- 线程去执行临界区代码;
- Thread-2先获得锁,成为monitor的主人,其他线程都要到blocked队列中等待;
- 不加synchronized关键字,对象不会关联monitor,也就不会有上述情况。也就是说,被加锁的对象,才会关联monitor,那么多个线程操作这个对象,就会有上述情况。
2.2 synchronized内存语义
synchronized内存语义其实可以认为是锁的内存语义,也即锁的释放-获取的内存语义(个人理解,如有错误请指正)
在锁释放后,会将共享变量刷新到主内存中,保证其可见性,这里和volatile类似。
同理 synchronized的happens-before关系也就是JMM中happens-before规则中的监视器锁规则:对同一个锁的解锁,happens-before于对该锁的加锁。 同时其又与volatile的happens-before规则有相同的内存语义。
详细解释不再赘述。
三、synchronized拓展优化
3.1 CAS操作
链接: Java并发系列:CAS操作
3.2 锁升级
Java线程是映射到操作系统原生线程之上的,如果要阻塞或唤醒一个线程就需要操作系统的介入,需要在用户态和内核态转换。在Java早期版本中(Java5前),synchronized属于重量级锁,因为监视器锁是依赖于底层的操作系统的互斥量来实现的,挂起线程和恢复线程都要转到内核态去完成,非常消耗资源,因此引入偏向锁、轻量级锁,尽量避免用户态到内核态的切换。
==线程的访问方式==:
- 只有一个线程来访问,有且唯一Only One
- 有两个线程(两个线程交替访问)
- 多个线程访问,竞争激烈
锁指向:
3.2.1 偏向锁
==偏向锁:单线程竞争下==
当一段同步代码一直被同一个线程多次访问,由于只有一个线程,那么该线程在后续访问时便会自动获得锁。
线程并不会主动放弃偏向锁
偏向锁开启:
- 延迟时间为4秒;
偏向锁的撤销:
当有另外线程逐步来竞争锁的时候,就不能再使用偏向锁了,要升级为欸轻量级锁。竞争线程尝试CAS更新对象头但是失败了,那么会等待到==全局安全点==撤销偏向锁。全局安全的也即该时间点上没有字节码执行。
- 1属于上面所述的竞争失败,2属于竞争成功
- 升级为轻量级锁后,另一个线程在外面自旋,如果成功则进入,不成功则继续自旋。如果自旋次数太多,可能升级为重量级锁。
注:Java15后逐步废弃偏向锁,原因是维护成本过高,(JVM也在不断更新)
3.2.2 轻量级锁
两个线程,交替运行(基本上是轮流执行),锁的竞争不激烈,不必升级到重量级锁去阻塞线程。
Java6之后使用自适应自旋锁
轻量级锁和偏向锁区别:
3.2.3 重量级锁
四、参考资料
1、Java并发编程的艺术 方腾飞等著;
2、黑马JUC编程;
3、大厂学苑JUC
4、深入理解Java虚拟机
作者:兴涛
链接:https://juejin.cn/post/7110770652033843237
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
Java异常体系和分类
🥞异常概念
异常,就是不正常的意思。在生活中:医生说,你的身体某个部位有异常,该部位和正常相比有点不同,该部位的功能将受影响.在程序中的意思就是:
- 异常 :指的是程序在执行过程中,出现的非正常的情况,最终会导致JVM的非正常停止。
在Java等面向对象的编程语言中,异常本身是一个类,产生异常就是创建异常对象并抛出了一个异常对象。Java处理异常的方式是中断处理。
异常指的并不是语法错误,语法错了,编译不通过,不会产生字节码文件,根本不能运行.
🥪异常体系
异常机制其实是帮助我们找到程序中的问题,异常的根类是java.lang.Throwable
,其下有两个子类:java.lang.Error
与java.lang.Exception
,平常所说的异常指java.lang.Exception
。
Throwable体系:
- Error:严重错误Error,无法通过处理的错误,只能事先避免,好比绝症。
- Exception:表示异常,异常产生后程序员可以通过代码的方式纠正,使程序继续运行,是必须要处理的。好比感冒、阑尾炎。
Throwable中的常用方法:
public void printStackTrace()
:打印异常的详细信息。
包含了异常的类型,异常的原因,还包括异常出现的位置,在开发和调试阶段,都得使用printStackTrace。
public String getMessage()
:获取发生异常的原因。
提示给用户的时候,就提示错误原因。
public String toString()
:获取异常的类型和异常描述信息(不用)。
出现异常,不要紧张,把异常的简单类名,拷贝到API中去查。
🍿异常分类
我们平常说的异常就是指Exception,因为这类异常一旦出现,我们就要对代码进行更正,修复程序。
异常(Exception)的分类:根据在编译时期还是运行时期去检查异常?
- 编译时期异常:checked异常。在编译时期,就会检查,如果没有处理异常,则编译失败。(如日期格式化异常)
- 运行时期异常:runtime异常。在运行时期,检查异常.在编译时期,运行异常不会编译器检测(不报错)。(如数学异常)
🍝异常的产生过程解析
先运行下面的程序,程序会产生一个数组索引越界异常ArrayIndexOfBoundsException。我们通过图解来解析下异常产生的过程。
工具类
public class ArrayTools {
// 对给定的数组通过给定的角标获取元素。
public static int getElement(int[] arr, int index) {
int element = arr[index];
return element;
}
}
测试类
public class ExceptionDemo {
public static void main(String[] args) {
int[] arr = { 34, 12, 67 };
intnum = ArrayTools.getElement(arr, 4)
System.out.println("num=" + num);
System.out.println("over");
}
}
上述程序执行过程图解:
作者:共饮一杯无
链接:https://juejin.cn/post/7108535695358033927
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
线程池7个参数拿捏死死的,完爆面试官
线程池
- 上一章节我们介绍的四种创建线程的方式算是热身运动了。线程池才是我们的重点介绍对象。
- 这个是JDK对线程池的介绍。
- 但是你会问为什么上面我们创建线程池的方式是通过
Executors.newCachedThreadPool()
;
关于Exectors内部提供了很多快捷创建线程的方式。这些方法内部都是依赖
ThreadPoolExecutor
。所以线程池的学习就是ThreadPoolExecutor
。
线程池
ThreadPoolExecutor
正常情况下最好用线程工厂来创建线程。他的作用是用来处理每一次提交过来的任务;ThreadPoolExecutor
可以解决两个问题
- 在很大并发量的情况下线程池不仅可以提供稳定的处理还可以减少线程之间的调度开销。
- 并且线程池提供了对线程和资源的回收及管理。
另外在内部
ThreadPoolExecutor
提供了很多参数及可扩展的地方。同时他也内置了很多工厂执行器方法供我们快速使用,比如说Executors.newCacheThreadPool()
:无限制处理任务。 还有Executors.newFixedThreadPool()
:固定线程数量;这些内置的线程工厂基本上能满足我们日常的需求。如果内置的不满足我们还可以针对内部的属性进行个性化设置
- 通过跟踪源码我们不难发现,内置的线程池构建都是基于上面提到的7个参数进行设置的。下面我画了一张图来解释这7个参数的作用。
- 上面这张图可以解释
corePoolSize
、maxiumPoolSize
、keepAliveTime
、TimeUnit
、workQueue
这五个参数。关于threadFactory
、handler
是形容过程中的两个参数。 - 关于
ThreadPoolExecutor
我们还得知道他虽然是线程池但是也并不是一开始就初始化好线程的。而是根据任务实际需求中不断的构建符合自身的线程池。那么构建线程依赖谁呢?上面也提到了官方推荐使用线程工厂。就是我们这里的ThreadFactory
类。 - 比如
Executors.newFixedThreadPool
是设置了固定的线程数量。那么当任务超过线程数和队列长度总和时,该怎么办?如果真的发生那种情况我们只能拒绝提供线程给任务使用。那么该如何拒绝这里就涉及到我们的RejectExecutionHandler
- 点进源码我们可以看到默认的队列好像是
LinkedBlockingQueue
; 这个队列是链表结构的怎么会有长度呢? 的确是但是Executors
还给我们提供了很多扩展性。如果我们自定义的话我们能够发现还有其他的
核心数与总线程数
- 这里对应
corePoolSize
和maxiumPoolSize
。
final ThreadPoolExecutor executorService = (ThreadPoolExecutor) Executors.newFixedThreadPool(10);
executorService.execute(new Runnable() {
@Override
public void run() {
System.out.println("我是线程1做的事情");
}
});
- 我们已
newFixedThreadPool
来分析下。首先它需要一个整数型参数。
public static ExecutorService newFixedThreadPool(int nThreads) {
return new ThreadPoolExecutor(nThreads, nThreads,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>());
}
- 而实际上内部是构建一个最大线程数量为10,且10个线程都是核心线程(公司核心员工);这10个线程是不会有过期时间一说的。过期时间针对非核心线程存活时间(公司外包员工)。
- 当我们执行
execute
方法时。点进去看看我们发现
- 首先会判断当前任务数是否超过核心线程数,如果没有超过则会添加值核心线程队列中。注意这里并没有去获取是否有空闲线程。而是只要满足小于核心线程数,进来的任务都会优先分配线程。
- 但是当任务数处于(corePoolSize,maxiumPoolSize】之间时,线程池并没有立马创建非核心线程,这点我们从源码中可以求证。
- 这段代码时上面if 判断小于核心线程之后的if , 也就是如果任务数大于核心线程数。优先执行该if 分支。意思就是会将核心线程来不及处理的放在队列中,等待核心线程缓过来执行。像我们上面所说如果这个时候我们用的时有边界的队列的话,那么队列总有放满的时候。这个时候执行来到我们第三个if分支
- 这里还是先将任务添加到非核心队列中。false表示非核心。如果能添加进去说明还没有溢出非核心数。如果溢出了正好if添加就是false . 就会执行了拒绝策略。
- 下面时executor执行源码
int c = ctl.get();
if (workerCountOf(c) < corePoolSize) {
if (addWorker(command, true))
return;
c = ctl.get();
}
if (isRunning(c) && workQueue.offer(command)) {
int recheck = ctl.get();
if (! isRunning(recheck) && remove(command))
reject(command);
else if (workerCountOf(recheck) == 0)
addWorker(null, false);
}
else if (!addWorker(command, false))
reject(command);
}
思考
- 基于上面我们对核心数和总数的讲述,我们来看看下面这段代码是否能够正确执行吧。
public static void main(String[] args) throws InterruptedException {
ThreadPoolExecutor executorService = new ThreadPoolExecutor(10,20,0,TimeUnit.SECONDS,new ArrayBlockingQueue<>(10));
for (int i = 0; i < 100; i++) {
int finalI = i;
executorService.execute(new Runnable() {
@SneakyThrows
@Override
public void run() {
System.out.println(finalI);
TimeUnit.SECONDS.sleep(1000);
}
});
}
}
- 很不幸,我们的执行报错了。而且出发了
ThreadPoolExecutor
中的拒绝策略。而且分析日志我们能够发现成功执行的有20个任务。分别是【0,9】+【20,29】这20个任务。 - 拒绝我们很容易理解。因为我们设置了最大20个线程数加上长度为10的队列。所以该线程城同时最多只能支持30个任务的并发。另外因为我们每一个任务执行时间至少在1000秒以上,所以程序执行到第31个的时候其他都没有释放线程。没有空闲的线程给第31个任务所以直接拒绝了。
- 那么为什么是是【0,9】+【20,29】呢?上面源码分析我们也提到了,进来的任务优先分配核心线程数,然后是缓存到队列中。当队列满了之后才会分配非核心数。当第31个来临直接出发拒绝策略,所以不管是核心线程还是非核心线程都没有时间处理队列中的10个线程。所以打印是跳着的。
作者:zxhtom
链接:https://juejin.cn/post/7111131220548780062
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
奇怪,为什么ArrayList初始化容量大小为10?
背景
看ArrayList源码时,无意中看到ArrayList的初始化容量大小为10,这就奇怪了!我们都知道ArrayList和HashMap底层都是基于数组的,但为什么ArrayList不像用HashMap那样用16作为初始容量大小,而是采用10呢?
于是各方查找资料,求证了这个问题,这篇文章就给大家讲讲。
为什么HashMap的初始化容量为16?
在聊ArrayList的初始化容量时,要先来回顾一下HashMap的初始化容量。这里以Java 8源码为例,HashMap中的相关因素有两个:初始化容量及装载因子:
/**
* The default initial capacity - MUST be a power of two.
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
/**
* The load factor used when none specified in constructor.
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
在HashMap当中,数组的默认初始化容量为16,当数据填充到默认容量的0.75时,就会进行2倍扩容。当然,使用者也可以在初始化时传入指定大小。但需要注意的是,最好是2的n次方的数值,如果未设置为2的n次方,HashMap也会将其转化,反而多了一步操作。
关于HashMap的实现原理的内容,这里就不再赘述,网络上已经有太多文章讲这个了。有一点我们需要知道的是HashMap计算Key值坐标的算法,也就是通过对Key值进行Hash,进而映射到数组中的坐标。
此时,保证HashMap的容量是2的n次方,那么在hash运算时就可以采用位运行直接对内存进行操作,无需转换成十进制,效率会更高。
通常,可以认为,HashMap之所以采用2的n次方,同时默认值为16,有以下方面的考量:
- 减少hash碰撞;
- 提高Map查询效率;
- 分配过小防止频繁扩容;
- 分配过大浪费资源;
总之,HashMap之所以采用16作为默认值,是为了减少hash碰撞,同时提升效率。
ArrayList的初始化容量是10吗?
下面,先来确认一下ArrayList的初始化容量是不是10,然后在讨论为什么是这个值。
先来看看Java 8中,ArrayList初始化容量的源码:
/**
* Default initial capacity.
*/
private static final int DEFAULT_CAPACITY = 10;
很明显,默认的容器初始化值为10。而且从JDK1.2到JDK1.6,这个值也始终都为10。
从JDK1.7开始,在初始化ArrayList的时候,默认值初始化为空数组:
/**
* Shared empty array instance used for default sized empty instances. We
* distinguish this from EMPTY_ELEMENTDATA to know how much to inflate when
* first element is added.
*/
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};
/**
* Constructs an empty list with an initial capacity of ten.
*/
public ArrayList() {
this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}
此处肯定有朋友说,Java 8中ArrayList默认初始化大小为0,不是10。而且还会发现构造方法上的注释有一些奇怪:构造一个初始容量10的空列表。什么鬼?明明是空的啊!
保留疑问,先来看一下ArrayList的add方法:
public boolean add(E e) {
ensureCapacityInternal(size + 1); // Increments modCount!!
elementData[size++] = e;
return true;
}
在add方法中调用了ensureCapacityInternal方法,进入该方法一开始是一个空容器所以size=0
传入的minCapacity=1
:
private void ensureCapacityInternal(int minCapacity) {
ensureExplicitCapacity(calculateCapacity(elementData, minCapacity));
}
复制代码
上述方法中先通过calculateCapacity来计算容量:
private static int calculateCapacity(Object[] elementData, int minCapacity) {
if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
return Math.max(DEFAULT_CAPACITY, minCapacity);
}
return minCapacity;
}
会发现minCapacity
被重新赋值为10 (DEFAULT_CAPACITY=10
),传入ensureExplicitCapacity(minCapacity);
这minCapacity=10
,下面是方法体:
private void ensureExplicitCapacity(int minCapacity) {
modCount++;
// overflow-conscious code
if (minCapacity - elementData.length > 0)
grow(minCapacity);
}
private void grow(int minCapacity) {
// overflow-conscious code
int oldCapacity = elementData.length;
int newCapacity = oldCapacity + (oldCapacity >> 1);
if (newCapacity - minCapacity < 0)
newCapacity = minCapacity;
if (newCapacity - MAX_ARRAY_SIZE > 0)
newCapacity = hugeCapacity(minCapacity);
// minCapacity is usually close to size, so this is a win:
elementData = Arrays.copyOf(elementData, newCapacity);
}
上述代码中grow方法是用来处理扩容的,将容量扩容为原来的1.5倍。
了解上面的处理流程,我们会发现,本质上ArrayList的初始化容量还是10,只不过使用懒加载而已,这是Java 8为了节省内存而进行的优化而已。所以,自始至终,ArrayList的初始化容量都是10。
这里再多提一下懒加载的好处,当有成千上万的ArrayList存在程序当中,10个对象的默认大小意味着在创建时为底层数组分配10个指针(40 或80字节)并用空值填充它们,一个空数组(用空值填充)占用大量内存。如果能够延迟初始化数组,那么就能够节省大量的内存空间。Java 8的改动就是出于上述目的。
为什么ArrayList的初始化容量为10?
最后,我们来探讨一下为什么ArrayList的初始化容量为10。其实,可以说没有为什么,就是“感觉”10挺好的,不大不小,刚刚好,眼缘!
首先,在讨论HashMap的时候,我们说到HashMap之所以选择2的n次方,更多的是考虑到hash算法的性能与碰撞等问题。这个问题对于ArrayList的来说并不存在。ArrayList只是一个简单的增长阵列,不用考虑算法层面的优化。只要超过一定的值,进行增长即可。所以,理论上来讲ArrayList的容量是任何正值即可。
ArrayList的文档中并没有说明为什么选择10,但很大的可能是出于性能损失与空间损失之间的最佳匹配考量。10,不是很大,也不是很小,不会浪费太多的内存空间,也不会折损太多性能。
如果非要问当初到底为什么选择10,可能只有问问这段代码的作者“Josh Bloch”了吧。
如果你仔细观察,还会发现一些其他有意思的初始化容量数字:
ArrayList-10
Vector-10
HashSet-16
HashMap-16
HashTable-11
ArrayList与Vector初始化容量一样,为10;HashSet、HashMap初始化容量一样,为16;而HashTable独独使用11,又是一个很有意思的问题。
小结
有很多问题是没有明确原因、明确的答案的。就好像一个女孩儿对你没感觉,可能是因为你不够好,也可能是她已经爱上别人了,但也有很大可能你是不会知道答案。但在寻找原因和答案的过程中,还是能够学到很多,成长很多的。没有对比就没有伤害,比如HashMap与ArrayList的对比,没有对比就不知道是否适合,还比如HashMap与ArrayList。当然,你还可以试试特立独行的HashTable,或许适合你呢。
作者:程序新视界
链接:https://juejin.cn/post/7110504902463340574
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
Java中BufferedReader、BufferedWriter用法
FileWriter/FileReader
介绍
FileWriter 类从 OutputStreamWriter 类继承而来。该类按字符向流中写入数据。
构造
参数为 File 对象
FileWriter(File file)
参数是文件的路径及文件名(默认是当前执行文件的路径)
FileWrite(String filename)
等价于
OutputStreamWriter out = new OutputStreamWriter(
new FileOutputStream(File file))
方法
序号 | 方法描述 |
---|---|
1 | public void write(int c) throws IOException 写入单个字符c。 |
2 | public void write(char [] c, int offset, int len) 写入字符数组中开始为offset长度为len的某一部分。 |
3 | public void write(String s, int offset, int len) 写入字符串中开始为offset长度为len的某一部分。 |
栗子
public class Main {
public static void main(String[] args) throws Exception {
File file = new File("d:/abc/f10");
// 创建文件
file.createNewFile();
// creates a FileWriter Object
FileWriter writer = new FileWriter(file);
// 向文件写入内容
writer.write("This\n is\n an\n example\n");
writer.flush();
writer.close();
// 创建 FileReader 对象
FileReader fr = new FileReader(file);
char[] a = new char[50];
fr.read(a); // 从数组中读取内容
for (char c : a)
System.out.print(c); // 一个个打印字符
fr.close();
}
}
运行程序会在 D 盘 abc 文件夹下创建 f10,同时打印内容如下
BufferedReader/BufferedWriter
介绍
BufferedReader 类从字符输入流中读取文本并缓冲字符,以便有效地读取字符,数组和行。
可以通过构造函数指定缓冲区大小也可以使用默认大小。对于大多数用途,默认值足够大。
由 Reader 构成的每个读取请求都会导致相应的读取请求由基础字符或字节流构成,建议通过 BufferedReader 包装 Reader 的实例类以提高效率。(Reader 构成的对象是字符对象,每次的读取请求都会涉及到字节读取解码字符的过程,而 BufferedReader 类中有设计减少这样的解码次数的方法,进而提高转换效率)
创建对象
BufferedReader in = new BufferedReader(new FileReader(“foo.in”));
方法
BufferedReader 由 Reader 类扩展而来,提供通用的缓冲方式文本读取,而且提供了很实用的readLine()
,读取一个文本行,从字符输入流中读取文本,缓冲各个字符,从而提供字符、数组和行的高效读取。
readLine()
读取一行字符串,不含末尾换行符,读取结束再读取返回 null。
栗子1:写入
BufferedWriter bufw = new BufferedWriter(new FileWriter("d:/abc/f11"));
bufw.write("This");
bufw.newLine();
bufw.newLine();
bufw.write("is");
bufw.write("an");
bufw.write("example");
//使用缓冲区中的方法,将数据刷新到目的地文件中去。
bufw.flush();
//关闭缓冲区,同时关闭了fw流对象
bufw.close();
运行结果会在 D 盘 abc 文件夹下新建 f11 文件
栗子2:读取
//相接的字符流,只要读取字符,都要做编码转换
//只要使用字符流,必须要有转换流
BufferedReader in = new BufferedReader(
new InputStreamReader(
new FileInputStream("d:/abc/f11")));
String line;
while ((line = in.readLine()) != null) {
System.out.println(line);
}
in.close();
运行结果
作者:奔跑吧鸡翅
链接:https://juejin.cn/post/7018395699851034661
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
如何优雅地消除复杂条件表达式
在复杂的实际业务中,往往会出现各种嵌套的条件判断逻辑。我们需要考虑所有可能的情况。随着需求的增加,条件逻辑会变得越来越复杂,判断函数会变的相当长,而且也不能轻易修改这些代码。每次改需求的时候,都要保证所有分支逻辑判断的情况都改了。
面对这种情况,简化判断逻辑就是不得不做的事情,下面介绍几种方法。
一个实际例子
@GetMapping("/exportOrderRecords")
public void downloadFile(User user, HttpServletResponse response) {
if (user != null) {
if (!StringUtils.isBlank(user.role) && authenticate(user.role)) {
String fileType = user.getFileType(); // 获得文件类型
if (!StringUtils.isBlank(fileType)) {
if (fileType.equalsIgnoreCase("csv")) {
doDownloadCsv(); // 不同类型文件的下载策略
} else if (fileType.equalsIgnoreCase("excel")) {
doDownloadExcel(); // 不同类型文件的下载策略
} else {
doDownloadTxt(); // 不同类型文件的下载策略
}
} else {
doDownloadCsv();
}
}
}
}
public class User {
private String username;
private String role;
private String fileType;
}
上面的例子是一个文件下载功能。我们根据用户需要下载Excel、CSV或TXT文件。下载之前需要做一些合法性判断,比如验证用户权限,验证请求文件的格式。
使用断言
在上面的例子中,有四层嵌套。但是最外层的两层嵌套是为了验证参数的有效性。只有条件为真时,代码才能正常运行。可以使用断言Assert.isTrue()
。如果断言不为真的时候抛出RuntimeException
。(注意要注明会抛出异常,kotlin中也一样)
@GetMapping("/exportOrderRecords")
public void downloadFile(User user, HttpServletResponse response) throws Exception {
Assert.isTrue(user != null, "the request body is required!");
Assert.isTrue(StringUtils.isNotBlank(user.getRole()), "download file is for");
Assert.isTrue(authenticate(user.getRole()), "you do not have permission to download files");
String fileType = user.getFileType();
if (!StringUtils.isBlank(fileType)) {
if (fileType.equalsIgnoreCase("csv")) {
doDownloadCsv();
} else if (fileType.equalsIgnoreCase("excel")) {
doDownloadExcel();
} else {
doDownloadTxt();
}
} else {
doDownloadCsv();
}
}
可以看出在使用断言之后,代码的可读性更高了。代码可以分成两部分,一部分是参数校验逻辑,另一部分是文件下载功能。
表驱动
断言可以优化一些条件表达式,但还不够好。我们仍然需要通过判断filetype
属性来确定要下载的文件格式。假设现在需求有变化,需要支持word格式文件的下载,那我们就需要直接改这块的代码,实际上违反了开闭原则。
表驱动可以解决这个问题。
private HashMap<String, Consumer> map = new HashMap<>();
public Demo() {
map.put("csv", response -> doDownloadCsv());
map.put("excel", response -> doDownloadExcel());
map.put("txt", response -> doDownloadTxt());
}
@GetMapping("/exportOrderRecords")
public void downloadFile(User user, HttpServletResponse response) {
Assert.isTrue(user != null, "the request body is required!");
Assert.isTrue(StringUtils.isNotBlank(user.getRole()), "download file is for");
Assert.isTrue(authenticate(user.getRole()), "you do not have permission to download files");
String fileType = user.getFileType();
Consumer consumer = map.get(fileType);
if (consumer != null) {
consumer.accept(response);
} else {
doDownloadCsv();
}
}
可以看出在使用了表驱动之后,如果想要新增类型,只需要在map中新增一个key-value就可以了。
使用枚举
除了表驱动,我们还可以使用枚举来优化条件表达式,将各种逻辑封装在具体的枚举实例中。这同样可以提高代码的可扩展性。其实Enum本质上就是一种表驱动的实现。(kotlin中可以使用sealed class处理这个问题,只不过具实现方法不太一样)
public enum FileType {
EXCEL(".xlsx") {
@Override
public void download() {
}
},
CSV(".csv") {
@Override
public void download() {
}
},
TXT(".txt") {
@Override
public void download() {
}
};
private String suffix;
FileType(String suffix) {
this.suffix = suffix;
}
public String getSuffix() {
return suffix;
}
public abstract void download();
}
@GetMapping("/exportOrderRecords")
public void downloadFile(User user, HttpServletResponse response) {
Assert.isTrue(user != null, "the request body is required!");
Assert.isTrue(StringUtils.isNotBlank(user.getRole()), "download file is for");
Assert.isTrue(authenticate(user.getRole()), "you do not have permission to download files");
String fileType = user.getFileType();
FileType type = FileType.valueOf(fileType);
if (type!=null) {
type.download();
} else {
FileType.CSV.download();
}
}
策略模式
我们还可以使用策略模式来简化条件表达式,将不同文件格式的下载处理抽象成不同的策略类。
public interface FileDownload{
boolean support(String fileType);
void download(String fileType);
}
public class CsvFileDownload implements FileDownload{
@Override
public boolean support(String fileType) {
return "CSV".equalsIgnoreCase(fileType);
}
@Override
public void download(String fileType) {
if (!support(fileType)) return;
// do something
}
}
public class ExcelFileDownload implements FileDownload {
@Override
public boolean support(String fileType) {
return "EXCEL".equalsIgnoreCase(fileType);
}
@Override
public void download(String fileType) {
if (!support(fileType)) return;
//do something
}
}
@Autowired
private List<FileDownload> fileDownloads;
@GetMapping("/exportOrderRecords")
public void downloadFile(User user, HttpServletResponse response) {
Assert.isTrue(user != null, "the request body is required!");
Assert.isTrue(StringUtils.isNotBlank(user.getRole()), "download file is for");
Assert.isTrue(authenticate(user.getRole()), "you do not have permission to download files");
String fileType = user.getFileType();
for (FileDownload fileDownload : fileDownloads) {
fileDownload.download(fileType);
}
}
策略模式对提高代码可扩展性很有帮助。扩展新的类型只需要添加一个策略类
作者:谢天_bytedance
链接:https://juejin.cn/post/7106804286469701639
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
由浅入深 Android 混淆实战
许久没有做混淆相关的工作, 以前存储的知识遗忘得差不多了。停留在很多人的记忆里面混淆还不简单吗?不就是 -keep。这样说也没错,但是要把混淆做得精细精准还是不简单的,今天就一文带你全而透。
混淆的作用
我们为什么要做这个工作,有什么好处?
代码缩减(摇树优化):使用静态代码分析来查找和删除无法访问的代码和未实例化的类型,对规避 64k 引用限制非常有用;
资源缩减:移除不使用的资源,包括应用库依赖项中不使用的资源。
混淆代码:缩短类和成员的名称,从而减小 DEX 文件的大小,增加反编译成本。
优化代码:检查并重写代码,选择性内联,移除未使用的参数和类合并来优化代码大小。
减少调试信息 : 规范化调试信息并压缩行号信息。
混淆的情况
混淆的情况是指你接手混淆时候的状况,大致分两种。
- 一种是项目刚刚立项,这个时候你跟进混淆,随着你的代码量增多,和引入的第三方库&SDK 增多逐渐增加混淆规则,这是一个应该有的良性的状态,做到精准混淆也容易。
- 第二种情况是以前的维护者完全没有混淆,有海量的代码和第三方库,里面的反射注解和各种存在混淆风险的问题存在,这样想做到精准混淆并不容易
上文多次提到精准混淆,我理解的精准混淆是最细粒度的白名单,而不是如下反例:
-keep public class * extends java.lang.Object{*;}
混淆基础知识储备
开启和关闭混淆
开启混淆比较简单,一般来讲为了方便开发调试只混淆 release 版本:
buildTypes {
release {
shrinkResources true //开启资源压缩
minifyEnabled true //开启混淆
zipAlignEnabled true //k对齐
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
minifyEnabled 和 proguardFiles 是必选项其他为可选,关闭混淆的话就比较容易了直接 minifyEnabled 修饰为 false 即可。
proguard-android.txt 和 proguard-android-optimize.txt
我们经常在代码里面看到这样的语句:
proguard-rules.pro 我们知道就在 app/ 目录下,但是这个 getDefaultProguardFile 是什么?在哪里?有什么用?
getDefaultProguardFile 是 Android SDK 为我们提供的一些 Android 内置的混淆规则,一般来将这些是通用的,你要做到精通混淆必选知道它的位置以及他里面包含的内容和含义。
位置:android/sdk/tools/proguard/
# This is a configuration file for ProGuard.
# http://proguard.sourceforge.net/index.html#manual/usage.html
#
# This file is no longer maintained and is not used by new (2.2+) versions of the
# Android plugin for Gradle. Instead, the Android plugin for Gradle generates the
# default rules at build time and stores them in the build directory.
-dontusemixedcaseclassnames #混淆时不会产生形形色色的类名
-dontskipnonpubliclibraryclasses #指定不去忽略非公共类库
-verbose #输出生成信息
# Optimization is turned off by default. Dex does not like code run
# through the ProGuard optimize and preverify steps (and performs some
# of these optimizations on its own).
#-dontoptimize #不优化指定文件
-dontpreverify #不预校验
# Note that if you want to enable optimization, you cannot just
# include optimization flags in your own project configuration file;
# instead you will need to point to the
# "proguard-android-optimize.txt" file instead of this one from your
# project.properties file.
-keepattributes *Annotation*
-keep public class com.google.vending.licensing.ILicensingService
-keep public class com.android.vending.licensing.ILicensingService
# For native methods, see http://proguard.sourceforge.net/manual/examples.html#native
-keepclasseswithmembernames class * {
native <methods>;
}
# keep setters in Views so that animations can still work.
# see http://proguard.sourceforge.net/manual/examples.html#beans
-keepclassmembers public class * extends android.view.View {
void set*(***);
*** get*();
}
# We want to keep methods in Activity that could be used in the XML attribute onClick
-keepclassmembers class * extends android.app.Activity {
public void *(android.view.View);
}
# For enumeration classes, see http://proguard.sourceforge.net/manual/examples.html#enumerations
-keepclassmembers enum * {
public static **[] values();
public static ** valueOf(java.lang.String);
}
-keepclassmembers class * implements android.os.Parcelable {
public static final android.os.Parcelable$Creator CREATOR;
}
-keepclassmembers class **.R$* {
public static <fields>;
}
# The support library contains references to newer platform versions.
# Don't warn about those in case this app is linking against an older
# platform version. We know about them, and they are safe.
-dontwarn android.support.**
# Understand the @Keep support annotation.
-keep class android.support.annotation.Keep
-keep @android.support.annotation.Keep class * {*;}
-keepclasseswithmembers class * {
@android.support.annotation.Keep <methods>;
}
-keepclasseswithmembers class * {
@android.support.annotation.Keep <fields>;
}
-keepclasseswithmembers class * {
@android.support.annotation.Keep <init>(...);
}
mapping 文件
Mapping 非常重要,在 app/build/mapping 中生成的 mapping 文件是我们分析混淆是否生效,混淆后的崩溃寻因的重要依据,通过这个文件的映射我们能够在一堆杂乱无章的 a、 b、 c 中回溯到原始代码。例:
工具集
工欲善其事必先利其器,两款对混淆有着很大帮助的工具介绍
Android Studio APK Analysis
AS自带简单好用,对比包体积的占比分析也是相当不错,并且随着 AS 的迭代有着官方的支持相信功能会越来越强大,我们只需要简单的将 apk 包拖拽到 AS 中就会自动触发 AS 的 apk 解析功能:
Jadx
Jadx 的强大之处在于相较于 AS 自带的分析器,它还能直接查看源代码,AS 只能看到类名和方法名,简直是逆向神器。
更多介绍请移步 github.com/skylot/jadx
混淆实战
通过 demo 样例的混淆实战深刻理解每个混淆规则的含义,我们要做到的不是仅仅开启 minifyEnabled 然后应用通过,而是需要知到得更细更透彻,理解每个混淆语句规则的作用范围。
先定义一下基准包以及子包,还有类、内部类、接口、注解、方法、成员,然后我们分部对其进行混淆和 -keep 保持,记住下图的 proguard 开始的包类目录关系,我们后面一直要使用它。
后续的文章都会以这几个类做样例,所以我们把它罗列出来再加深一下印象:
- User.java
- Animal.java
- MethodLevel.java
- Student.java
- Teacher.java
- Company.java
- IBehaviour.java
部分样例类:
public class Teacher extends User implements IBehaviour {
@Override
public void doSomething() {
System.out.println("teaching ...");
}
@MethodLevel(value = 1)
private void waking(){
}
}
混淆中代码调用关系
先开启混淆,不添加任何规则。我们通过 jadx 看下混淆情况
proguard 包和类一个都找不到应该都是被混淆了,进一步印证一下我们的想法,我们去 mapping 文件里面找下映射关系,结果出乎意料,我没有在 mapping 中找到映射关系,只在 usage.txt 中找到了对应的全路径名
是不是我们的类申明了没有引用导致的呢?我们去 activity 做一下调用
果然和我们的预想的一样,如果类创建了没有使用,mapping 不会生成映射关系,甚至可能在打包的过程中给优化掉,再看加上调用关系后我们查询 mapping 文件:
上图可以得知,我们的 proguard 包和下面的所有内容全部都混淆了。
keep 探寻
网络上的大部分文章都千篇一律,简单的给出了一个 Keep 语句,实际用的时候都是 copy 对其作用范围不是很明确,接下来我们就一个一个来探寻
keep *
-keep class com.zxmdly.proguard.*
我们先加上这句看看打包后的变化
对比之前的结果,我们看到的是 proguard 的包和下面的类名被保留下来了,注意仅仅是包合类名被保留,类中的字段和成员是没有找到的,这是为什么呢?难道是字段没有被使用
我们去印证下
好了,到现在我们已经能够透彻的知道了 -keep * 的作用,总结作用范围:
能够保持该包和该包下的类、和静态内部类的类名保持,对字段和方法不额外起作用,子包不起作用,字段或者方法没有被调用则直接忽略。
keep **
-keep class com.zxmdly.proguard.**
通过查看上图和上面 keep * 的经验,我们可以得出结论:
- keep ** 能够保持该包和其子包的子类的所有类名(含注解、枚举)不被混淆,但是方法和字段不在其作用范围,字段或者方法没有被调用则直接忽略。
值得注意的是, keep ** 对于 keep * 是包含关系,声明了 keep ** 混淆规则就无需再声明 keep * 了。
keep ** {*;}
-keep class com.zxmdly.proguard.* {}
有了之前的经验,我们可以得出结论:
- keep ** {*;} 的作用范围特别大,能够保持该包及其子包、子类和其字段方法都不被混淆,相对来讲我们需要慎用这样的语句,因为可能导致混淆不够精准。
单个类名保持
-keep class com.zxmdly.proguard.Company
- 仅保持类名,字段和成员被混淆
保持方法
-keep class com.zxmdly.proguard.Company{
<methods>;
}
保持字段
-keep class com.zxmdly.proguard.Company{
<fields>;
}
实现关系保持
-keep public class * implements com.zxmdly.proguard.IBehaviour
-keep public class * implements com.zxmdly.proguard.IBehaviour {*;}
继承关系保持
-keep public class * extends com.zxmdly.proguard.base.User {*;}
指定保持具体方法或者字段
-keep class com.zxmdly.proguard.Company{
public java.lang.String address;
public void printlnAddress();
}
Tips 小技巧
在 gralde 中,我们可以通过下面配置将我们的混淆规则分门别类,指定多个混淆配置文件。
例如给第三方的 SDK 专门放到一个 Third 混淆配置文件,使用了这个小技巧加上注释,我们的混淆规则是不是更清晰了呢
结语
通过本文由浅入深的带大家进行混淆实战,相信 99% 的精准混淆工作已经难不倒你,当然混淆还有更深入和更细节的用法,篇幅关系我们下次再探。
作者:阿明的小蝴蝶
链接:https://juejin.cn/post/7104539442739838983
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
Android动态加载so!这一篇就够了!
背景
对于一个普通的android应用来说,so库的占比通常都是巨高不下的,因为我们无可避免的在开发中遇到各种各样需要用到native的需求,所以so库的动态化可以减少极大的包体积,自从2020腾讯的bugly团队发部关于动态化so的相关文章后,已经过去两年了,相关文章,经过两年的考验,实际上so动态加载也是非常成熟的一项技术了,但是很遗憾,许多公司都还没有这方面的涉略又或者说不知道从哪里开始进行,因为so动态其实涉及到下载,so版本管理,动态加载实现等多方面,我们不妨抛开这些额外的东西,从最本质的so动态加载出发吧!这里是本次的例子,我把它命名为sillyboy,欢迎pr还有后续点赞呀!
so动态加载介绍
动态加载,其实就是把我们的so库在打包成apk的时候剔除,在合适的时候通过网络包下载的方式,通过一些手段,在运行的时候进行分离加载的过程。这里涉及到下载器,还有下载后的版本管理等等确保一个so库被正确的加载等过程,在这里,我们不讨论这些辅助的流程,我们看下怎么实现一个最简单的加载流程。
从一个例子出发
我们构建一个native工程,然后在里面编入如下内容,下面是cmake
# For more information about using CMake with Android Studio, read the
# documentation: https://d.android.com/studio/projects/add-native-code.html
# Sets the minimum version of CMake required to build the native library.
cmake_minimum_required(VERSION 3.18.1)
# Declares and names the project.
project("nativecpp")
# Creates and names a library, sets it as either STATIC
# or SHARED, and provides the relative paths to its source code.
# You can define multiple libraries, and CMake builds them for you.
# Gradle automatically packages shared libraries with your APK.
add_library( # Sets the name of the library.
nativecpp
# Sets the library as a shared library.
SHARED
# Provides a relative path to your source file(s).
native-lib.cpp)
add_library(
nativecpptwo
SHARED
test.cpp
)
# Searches for a specified prebuilt library and stores the path as a
# variable. Because CMake includes system libraries in the search path by
# default, you only need to specify the name of the public NDK library
# you want to add. CMake verifies that the library exists before
# completing its build.
find_library( # Sets the name of the path variable.
log-lib
# Specifies the name of the NDK library that
# you want CMake to locate.
log)
# Specifies libraries CMake should link to your target library. You
# can link multiple libraries, such as libraries you define in this
# build script, prebuilt third-party libraries, or system libraries.
target_link_libraries( # Specifies the target library.
nativecpp
# Links the target library to the log library
# included in the NDK.
${log-lib})
target_link_libraries( # Specifies the target library.
nativecpptwo
# Links the target library to the log library
# included in the NDK.
nativecpp
${log-lib})
可以看到,我们生成了两个so库一个是nativecpp,还有一个是nativecpptwo(为什么要两个呢?我们可以继续看下文)
这里也给出最关键的test.cpp代码
#include <jni.h>
#include <string>
#include<android/log.h>
extern "C"
JNIEXPORT void JNICALL
Java_com_example_nativecpp_MainActivity_clickTest(JNIEnv *env, jobject thiz) {
// 在这里打印一句话
__android_log_print(ANDROID_LOG_INFO,"hello"," native 层方法");
}
很简单,就一个native方法,打印一个log即可,我们就可以在java/kotin层进行方法调用了,即
public native void clickTest();
so库检索与删除
要实现so的动态加载,那最起码是要知道本项目过程中涉及到哪些so吧!不用担心,我们gradle构建的时候,就已经提供了相应的构建过程,即构建的task【
mergeDebugNativeLibs】,在这个过程中,会把一个project里面的所有native库进行一个收集的过程,紧接着task【stripDebugDebugSymbols】是一个符号表清除过程,如果了解native开发的朋友很容易就知道,这就是一个减少so体积的一个过程,我们不在这里详述。所以我们很容易想到,我们只要在这两个task中插入一个自定义的task,用于遍历和删除就可以实现so的删除化了,所以就很容易写出这样的代码
ext {
deleteSoName = ["libnativecpptwo.so","libnativecpp.so"]
}
// 这个是初始化 -配置 -执行阶段中,配置阶段执行的任务之一,完成afterEvaluate就可以得到所有的tasks,从而可以在里面插入我们定制化的数据
task(dynamicSo) {
}.doLast {
println("dynamicSo insert!!!! ")
//projectDir 在哪个project下面,projectDir就是哪个路径
print(getRootProject().findAll())
def file = new File("${projectDir}/build/intermediates/merged_native_libs/debug/out/lib")
//默认删除所有的so库
if (file.exists()) {
file.listFiles().each {
if (it.isDirectory()) {
it.listFiles().each {
target ->
print("file ${target.name}")
def compareName = target.name
deleteSoName.each {
if (compareName.contains(it)) {
target.delete()
}
}
}
}
}
} else {
print("nil")
}
}
afterEvaluate {
print("dynamicSo task start")
def customer = tasks.findByName("dynamicSo")
def merge = tasks.findByName("mergeDebugNativeLibs")
def strip = tasks.findByName("stripDebugDebugSymbols")
if (merge != null || strip != null) {
customer.mustRunAfter(merge)
strip.dependsOn(customer)
}
}
可以看到,我们定义了一个自定义task dynamicSo,它的执行是在afterEvaluate中定义的,并且依赖于mergeDebugNativeLibs,而stripDebugDebugSymbols就依赖于我们生成的dynamicSo,达到了一个插入操作。那么为什么要在afterEvaluate中执行呢?那是因为android插件是在配置阶段中才生成的mergeDebugNativeLibs等任务,原本的gradle构建是不存在这样一个任务的,所以我们才需要在配置完所有task之后,才进行的插入,我们可以看一下gradle的生命周期
通过对条件检索,我们就删除掉了我们想要的so,即ibnativecpptwo.so与libnativecpp.so。
动态加载so
根据上文检索出来的两个so,我们就可以在项目中上传到自己的后端中,然后通过网络下载到用户的手机上,这里我们就演示一下即可,我们就直接放在data目录下面吧
真实的项目过程中,应该要有校验操作,比如md5校验或者可以解压等等操作,这里不是重点,我们就直接略过啦!
那么,怎么把一个so库加载到我们本来的apk中呢?这里是so原本的加载过程,可以看到,系统是通过classloader检索native目录是否存在so库进行加载的,那我们反射一下,把我们自定义的path加入进行不就可以了吗?这里采用tinker一样的思路,在我们的classloader中加入so的检索路径即可,比如
private static final class V25 {
private static void install(ClassLoader classLoader, File folder) throws Throwable {
final Field pathListField = ShareReflectUtil.findField(classLoader, "pathList");
final Object dexPathList = pathListField.get(classLoader);
final Field nativeLibraryDirectories = ShareReflectUtil.findField(dexPathList, "nativeLibraryDirectories");
List<File> origLibDirs = (List<File>) nativeLibraryDirectories.get(dexPathList);
if (origLibDirs == null) {
origLibDirs = new ArrayList<>(2);
}
final Iterator<File> libDirIt = origLibDirs.iterator();
while (libDirIt.hasNext()) {
final File libDir = libDirIt.next();
if (folder.equals(libDir)) {
libDirIt.remove();
break;
}
}
origLibDirs.add(0, folder);
final Field systemNativeLibraryDirectories = ShareReflectUtil.findField(dexPathList, "systemNativeLibraryDirectories");
List<File> origSystemLibDirs = (List<File>) systemNativeLibraryDirectories.get(dexPathList);
if (origSystemLibDirs == null) {
origSystemLibDirs = new ArrayList<>(2);
}
final List<File> newLibDirs = new ArrayList<>(origLibDirs.size() + origSystemLibDirs.size() + 1);
newLibDirs.addAll(origLibDirs);
newLibDirs.addAll(origSystemLibDirs);
final Method makeElements = ShareReflectUtil.findMethod(dexPathList, "makePathElements", List.class);
final Object[] elements = (Object[]) makeElements.invoke(dexPathList, newLibDirs);
final Field nativeLibraryPathElements = ShareReflectUtil.findField(dexPathList, "nativeLibraryPathElements");
nativeLibraryPathElements.set(dexPathList, elements);
}
}
我们在原本的检索路径中,在最前面,即数组为0的位置加入了我们的检索路径,这样一来claaloader在查找我们已经动态化的so库的时候,就能够找到!
结束了吗?
一般的so库,比如不依赖其他的so的时候,直接这样加载就没问题了,但是如果存在着依赖的so库的话,就不行了!相信大家在看其他的博客的时候就能看到,是因为Namespace的问题。具体是我们动态库加载的过程中,如果需要依赖其他的动态库,那么就需要一个链接的过程对吧!这里的实现就是Linker,Linker 里检索的路径在创建 ClassLoader 实例后就被系统通过 Namespace 机制绑定了,当我们注入新的路径之后,虽然 ClassLoader 里的路径增加了,但是 Linker 里 Namespace 已经绑定的路径集合并没有同步更新,所以出现了 libxxx.so 文件(当前的so)能找到,而依赖的so 找不到的情况。bugly文章
很多实现都采用了Tinker的实现,既然我们系统的classloader是这样,那么我们在合适的时候把这个替换掉不就可以了嘛!当然bugly团队就是这样做的,但是笔者认为,替换一个classloader显然对于一个普通应用来说,成本还是太大了,而且兼容性风险也挺高的,当然,还有很多方式,比如采用Relinker这个库自定义我们加载的逻辑。
为了不冷饭热炒,嘿嘿,虽然我也喜欢吃炒饭(手动狗头),这里我们就不采用替换classloader的方式,而是采用跟relinker的思想,去进行加载!具体的可以看到sillyboy的实现,其实就不依赖relinker跟tinker,因为我把关键的拷贝过来了,哈哈哈,好啦,我们看下怎么实现吧!不过在此这前,我们需要了解一些前置知识
ELF文件
我们的so库,本质就是一个elf文件,那么so库也符合elf文件的格式,ELF文件由4部分组成,分别是ELF头(ELF header)、程序头表(Program header table)、节(Section)和节头表(Section header table)。实际上,一个文件中不一定包含全部内容,而且它们的位置也未必如同所示这样安排,只有ELF头的位置是固定的,其余各部分的位置、大小等信息由ELF头中的各项值来决定。
那么我们so中,如果依赖于其他的so,那么这个信息存在哪里呢!?没错,它其实也存在elf文件中,不然链接器怎么找嘛,它其实就存在.dynamic段中,所以我们只要找打dynamic段的偏移,就能到dynamic中,而被依赖的so的信息,其实就存在里面啦
我们可以用readelf(ndk中就有toolchains目录后) 查看,readelf -d nativecpptwo.so 这里的 -d 就是查看dynamic段的意思
这里面涉及到动态加载so的知识,可以推荐大家一本书,叫做程序员的自我修养-链接装载与库这里就画个初略图
我们再看下本质,dynamic结构体如下,定义在elf.h中
typedef struct{
Elf32_Sword d_tag;
union{
Elf32_Addr d_ptr;
....
}
}
当d_tag的数值为DT_NEEDED的时候,就代表着依赖的共享对象文件,d_ptr表示所依赖的共享对象的文件名。看到这里读者们已经知道了如果我们知道了文件名,不就可以再用System.load去加载这个不就可以了嘛!不用替换classloader就能够保证被依赖的库先加载!我们可以再总结一下这个方案的原理,如图
比如我们要加载so3,我们就需要先加载so2,如果so2存在依赖,那我们就先加载so1,这个时候so1就不存在依赖项了,就不需要再调用Linker去查找其他so库了。我们最终方案就是,只要能够解析对应的elf文件,然后找偏移,找到需要的目标项(DT_NEED)就可以了
public List<String> parseNeededDependencies() throws IOException {
channel.position(0);
final List<String> dependencies = new ArrayList<String>();
final Header header = parseHeader();
final ByteBuffer buffer = ByteBuffer.allocate(8);
buffer.order(header.bigEndian ? ByteOrder.BIG_ENDIAN : ByteOrder.LITTLE_ENDIAN);
long numProgramHeaderEntries = header.phnum;
if (numProgramHeaderEntries == 0xFFFF) {
/**
* Extended Numbering
*
* If the real number of program header table entries is larger than
* or equal to PN_XNUM(0xffff), it is set to sh_info field of the
* section header at index 0, and PN_XNUM is set to e_phnum
* field. Otherwise, the section header at index 0 is zero
* initialized, if it exists.
**/
final SectionHeader sectionHeader = header.getSectionHeader(0);
numProgramHeaderEntries = sectionHeader.info;
}
long dynamicSectionOff = 0;
for (long i = 0; i < numProgramHeaderEntries; ++i) {
final ProgramHeader programHeader = header.getProgramHeader(i);
if (programHeader.type == ProgramHeader.PT_DYNAMIC) {
dynamicSectionOff = programHeader.offset;
break;
}
}
if (dynamicSectionOff == 0) {
// No dynamic linking info, nothing to load
return Collections.unmodifiableList(dependencies);
}
int i = 0;
final List<Long> neededOffsets = new ArrayList<Long>();
long vStringTableOff = 0;
DynamicStructure dynStructure;
do {
dynStructure = header.getDynamicStructure(dynamicSectionOff, i);
if (dynStructure.tag == DynamicStructure.DT_NEEDED) {
neededOffsets.add(dynStructure.val);
} else if (dynStructure.tag == DynamicStructure.DT_STRTAB) {
vStringTableOff = dynStructure.val; // d_ptr union
}
++i;
} while (dynStructure.tag != DynamicStructure.DT_NULL);
if (vStringTableOff == 0) {
throw new IllegalStateException("String table offset not found!");
}
// Map to file offset
final long stringTableOff = offsetFromVma(header, numProgramHeaderEntries, vStringTableOff);
for (final Long strOff : neededOffsets) {
dependencies.add(readString(buffer, stringTableOff + strOff));
}
return dependencies;
}
扩展
我们到这里,就能够解决so库的动态加载的相关问题了,那么还有人可能会问,项目中是会存在多处System.load方式的,如果加载的so还不存在怎么办?比如还在下载当中,其实很简单,这个时候我们字节码插桩就派上用场了,只要我们把System.load替换为我们自定义的加载so逻辑,进行一定的逻辑处理就可以了,嘿嘿,因为笔者之前就有写一个字节码插桩的库的介绍,所以在本次就不重复了,可以看Sipder,同时也可以用其他的字节码插桩框架实现,相信这不是一个问题。
总结
看到这里的读者,相信也能够明白动态加载so的步骤了,最后源代码可以在SillyBoy,当然也希望各位点赞呀!当然,有更好的实现也欢迎评论!!
作者:Pika
链接:https://juejin.cn/post/7107958280097366030
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
使用正则表达式解析短信内容
使用正则表达式解析短信内容
通常,Android手机自带的短信软件都可以将解析内容并且提取出里面的关键信息展示成卡片的样式或者提供让用户进一步操作的按钮。例如在坚果手机上验证码短信会展示成这样:
信用卡的消费短信会展示成这样:
这篇文章将讨论如何基于正则表达式实现类似上述的功能。
一、需求分析
需要在卡片上展示标题 标题可能是固定的比如像图上的信用卡消费或者是验证码,也可能是不固定,比如说来自短信的内容
卡片上将会醒目的展示出最核心的内容和类型,例如图上验证码信息和金额信息
其余的次重要信息按照键值对的形式分别展示出来,例如消费短信中的 账号、短信、时间等信息。
是否需要展示短信的原文内容,这个部分考虑支持可配。让使用者去决定当前的短信卡片是否要展示原文。
短信卡片展示的下一步动作,我们暂时仅考虑支持复制卡片上最醒目的信息,例如验证码,或者是消费金额(尽管好像没什么卵用)
二、关于正则
在整个短信解析的正则中最重要的就是捕获组的概念。
我们首先来看一下京东快递柜的短信提醒
【京东快递柜】凭取件码26558117到一个小区京东快递柜取件,电话12345678910,关注京东快递公众号扫码取件。
在这个短信中除了加粗的部分之外,其余的内容都是固定的,所以我们很容易的可以写出下面这样的正则表达式
【京东快递柜】凭取件码\d{8}到.*?京东快递柜取件,电话\d{11},关注京东快递公众号扫码取件。
我们需要在卡片上展示短信中的内容,我们要用到捕获命名组,我们加上命名捕获组后正则表达式会是下面这样
【京东快递柜】凭取件码(?<code>\d{8})到(?<location>.*?)京东快递柜取件,电话(?<phone>\d{11}),关注京东快递公众号扫码取件。
我们就可以在代码中通过名称组名来获取对应的内容。
三、规则数据存储
基于以上的需求我们就可以设计出如下的结构来存储一个短信内容的解析规则,毕竟一个正则是没有办法解析所有的短信的。
filter '过滤器,如果短信的内容符合过滤的规则,则使用 regex记录的正则来提取数据',
regex '用于数据提取的正则表达式',
group_names '内容分组的名称 使用 , 分割 ,例如记录上述京东快递柜短信提醒组的名称: code|取件码,location|位置,phone|联系电话'
sort '排序,规则的先后顺序,满足一个则后面的规则默认不在参与处理',
title '标题,标题可以写规定的内容,也可以引用捕获组的内容 ,例如 #P#code 使用捕获到组名为code的文本作为短信卡片的标题'
show_content '是否显示原文'
copy_main '是否可以复制名为 mian 组捕获到的内容到剪贴板'
在group_names 字段中,默认显示在卡片最醒目位置的文本的捕获组的名称命名为 main,例如快递柜通知短信要显示的最醒目的内容是取件码,那么取件码的组名就是 main
四、代码实现
有了上面的结构,对短信的解析就可以简单处理为让短信按照定义的sort
字段的顺序,逐个匹配 filter
记录的正则表达式,如果通过,则使用regex
记录的正则提取数据,并且group_name
将内容与记录的标题对应,给页面去渲染就可以了。
代码示例如下:
function resolveSMSContent(content, extractRules) {
for (let i = 0; i < extractRules.length; i++) {
let extractRule = extractRules[i];
let filter = eval(extractRule.filter);
let exec;
if (filter.test(content)) {
let patternStr = eval(extractRule.regex)
let mainContent;
let title = extractRule.title;
let groupNames = extractRule.groupNames.split(",");
let values = [];
patternStr = eval(patternStr)
exec = patternStr.exec(content);
for (let i = 0; i < groupNames.length; i++) {
let groupName = groupNames[i].split("|")[0];
let param = {
key: groupNames[i].split("|")[1],
text: exec.groups[groupName]
}
if (extractRule.title.startsWith("#P#") && extractRule.title.replace("#P#", "") === groupName) {
title = exec.groups[groupName];
continue;
}
if (groupNames[i].split("|")[0] === 'main') {
mainContent = param;
continue;
}
values.push(param)
}
let card = {
title: title,
mainContent: mainContent,
content: content,
param: values,
copyMain: extractRule.copyMain,
showContent: extractRule.showContent
}
if (card.copyMain) {
writeContentToClipBoard(card.mainContent.text);
}
console.log(card);
return card;
}
return '';
}
解析之后的数据:
{
"title": "京东快递柜", //卡片标题
"mainContent": {
"key": "取件码", //展示关键信息的名称
"text": "26558117" 关键信息的内容
},
"content": "【京东快递柜】凭取件码26558117到一个小区京东快递柜取件,电话12345678910,关注京东快递公众号扫码取件。", //短信原文
"param": [ //次重要参数
{
"key": "联系电话", //名称
"text": "12345678910" //内容
},
{
"key": "位置",
"text": "一个小区"
}
],
"copyMain": false //是否可以复制关键信息
}
有了上面的数据就可以很容易的展示成下面这样:
代码来自于Blue bird 这是一个将Android手机电量,短信,手机通知等数据发送给电脑端的项目,有兴趣可以自己打包部署使用一下,欢迎star
作者:TianYO
链接:https://juejin.cn/post/7105427579254865957
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
多线程原理和常用方法以及Thread和Runnable的区别
多线程原理
随机性打印
CPU有了两条执行的路径,CPU就有了选择 ,一会执行main方法 一会执行run方法。
也可以说两个线程,一个main线程 一个run线程 一起请求CPU的执行权(执行时间)谁抢到了就执行对应的代码
多线程内存图解
- main方法的第一步创建对象,创建对象开辟堆内存存储在堆内存中(地址值赋值给变量名0x11)
- mt.run()调用时 run方法被压栈进来 其实是一个单线程的程序(main线程,会先执行完run方法再执行主线程中的去其他方法)
- mt.start()调用时会开辟一个新的栈空间。执行run方法(run方法就不是在main线程执行,而是在新的栈空间执行,如果再start会再开辟一个栈空间再多一个线程)
对cpu而言,cpu就有了选择的权利 可以执行main方法、也可以执行两个run方法。
多线程好处:多线程执行时,在栈内存中,其实每一个执行线程都有一片自己所属的栈内存空间,多个线程互不影响 进行方法的压栈和弹栈。
Thread类的常用方法
获取线程名称 getName()
public static void main(String[] args) {
//创建Thread类的子类对象
MyThread mt = new MyThread();
//调用start方法,开启新线程,执行run方法
mt.start();
new MyThread().start();
new MyThread().start();
//链式编程
System.out.println(Thread.currentThread().getName());
}
/**
获取线程的名称:
1.使用Thread类中的方法getName()
String getName() 返回该线程的名称。
2.可以先获取到当前正在执行的线程,使用线程中的方法getName()获取线程的名称
static Thread currentThread() 返回对当前正在执行的线程对象的引用。
* @author zjq
*/
// 定义一个Thread类的子类
public class MyThread extends Thread{
//重写Thread类中的run方法,设置线程任务
@Override
public void run() {
//获取线程名称
//String name = getName();
//System.out.println(name);
//链式编程
System.out.println(Thread.currentThread().getName());
}
}
输出如下:
main
Thread-2
Thread-0
Thread-1
设置线程名称 setName() 或者 new Thread(“线程名字”)
使用Thread类中的方法setName(名字)
void setName(String name) 改变线程名称,使之与参数 name 相同。
创建一个带参数的构造方法,参数传递线程的名称;调用父类的带参构造方法,把线程名称传递给父类,让父类(Thread)给子线程起一个名字
Thread(String name) 分配新的 Thread 对象。
代码案例:
//开启多线程
MyThread mt = new MyThread();
mt.setName("小强");
mt.start();
//开启多线程
new MyThread("旺财").start();
使当前正在执行的线程以指定的毫秒数暂停 sleep(long millis)
代码案例:
public static void main(String[] args) {
//模拟秒表
for (int i = 1; i <=60 ; i++) {
System.out.println(i);
//使用Thread类的sleep方法让程序睡眠1秒钟
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
创建多线程程序的第二种方式-实现Runnable接口
实现Runnable接口实现多线程的步骤:
- 创建一个Runnable接口的实现类
- 在实现类中重写Runnable接口的run方法,设置线程任务
- 创建一个Runnable接口的实现类对象
- 创建Thread类对象,构造方法中传递Runnable接口的实现类对象
- 调用Thread类中的start方法,开启新的线程执行run方法
代码案例如下:
/**
* 1.创建一个Runnable接口的实现类
* @author zjq
*/
public class RunnableImpl implements Runnable{
//2.在实现类中重写Runnable接口的run方法,设置线程任务
@Override
public void run() {
for (int i = 0; i <20 ; i++) {
System.out.println(Thread.currentThread().getName()+"-->"+i);
}
}
}
public static void main(String[] args) {
//3.创建一个Runnable接口的实现类对象
RunnableImpl run = new RunnableImpl();
//4.创建Thread类对象,构造方法中传递Runnable接口的实现类对象
Thread t = new Thread(run);//打印线程名称
//5.调用Thread类中的start方法,开启新的线程执行run方法
t.start();
for (int i = 0; i <20 ; i++) {
System.out.println(Thread.currentThread().getName()+"-->"+i);
}
}
Thread和Runnable的区别
实现Runnable接口创建多线程程序的好处:
- 避免了单继承的局限性
一个类只能继承一个类(一个人只能有一个亲爹),类继承了Thread类就不能继承其他的类。
实现了Runnable接口,还可以继承其他的类,实现其他的接口。
- 增强了程序的扩展性,降低了程序的耦合性(解耦)
实现Runnable接口的方式,把设置线程任务和开启新线程进行了分离(解耦)。
实现类中,重写了run方法:用来设置线程任务。
创建Thread类对象,调用start方法:用来开启新线程。
使用匿名内部类开启线程
匿名内部类开启线程可以简化代码的编码。
代码案例如下:
/**
匿名内部类方式实现线程的创建
匿名:没有名字
内部类:写在其他类内部的类
匿名内部类作用:简化代码
把子类继承父类,重写父类的方法,创建子类对象合一步完成
把实现类实现类接口,重写接口中的方法,创建实现类对象合成一步完成
匿名内部类的最终产物:子类/实现类对象,而这个类没有名字
格式:
new 父类/接口(){
重复父类/接口中的方法
};
* @author zjq
*/
public class Demo01InnerClassThread {
public static void main(String[] args) {
//线程的父类是Thread
// new MyThread().start();
new Thread(){
//重写run方法,设置线程任务
@Override
public void run() {
for (int i = 0; i <20 ; i++) {
System.out.println(Thread.currentThread().getName()+"-->"+"詹");
}
}
}.start();
//线程的接口Runnable
//Runnable r = new RunnableImpl();//多态
Runnable r = new Runnable(){
//重写run方法,设置线程任务
@Override
public void run() {
for (int i = 0; i <20 ; i++) {
System.out.println(Thread.currentThread().getName()+"-->"+"线程");
}
}
};
new Thread(r).start();
//简化接口的方式
new Thread(new Runnable(){
//重写run方法,设置线程任务
@Override
public void run() {
for (int i = 0; i <20 ; i++) {
System.out.println(Thread.currentThread().getName()+"-->"+"zjq");
}
}
}).start();
}
}
作者:共饮一杯无
链接:https://juejin.cn/post/7108901990519799844
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
ASM 插桩采集方法入参,出参及耗时信息
前言
ASM
字节码插桩技术在Android
开发中有着广泛的应用,但相信很多人会不知道怎么上手,不知道该拿ASM
来做点什么。
学习一门技术最好的方法就是动手实践,本文主要通过ASM
插桩采集方法的入参,出参及耗时信息并打印,通过一个不大不小的例子快速上手ASM
插桩开发。
技术目标
我们先看下最终的效果
插桩前代码
首先来看下插桩前代码,就是一个sum
方法
private fun sum(i: Int, j: Int): Int {
return i + j
}
插桩后代码
接下来看下插桩后的代码
private final int sum(int i, int j) {
ArrayList arrayList = new ArrayList();
arrayList.add(Integer.valueOf(i));
arrayList.add(Integer.valueOf(j));
MethodRecorder.onMethodEnter("com.zj.android_asm.MainActivity", "sum", arrayList);
int i2 = i + j;
MethodRecorder.onMethodExit(Integer.valueOf(i2), "com.zj.android_asm.MainActivity", "sum", "I,I", "I");
return i2;
}
可以看出,方法所有参数都被添加到了一个arrayList
中,并且调用了MethodRecorder.onMethodEnter
方法
而在结果返回之前,则会调用MethodRecorder.onMethodExit
方法,并将返回值,参数类型,返回值类型等作为参数传递过支。
日志输出
在调用了onMethodExit
之后,会计算出方法耗时并输出日志,如下所示
类名:com.zj.android_asm.MainActivity
方法名:sum
参数类型:[I,I]
入参:[1,2]
返回类型:I
返回值:3
耗时:0 ms
技术实现
上面我们介绍了最后要实现的效果,下面就来看下怎么一步一步实现,主要分为以下3步:
- 在方法开始时采集方法参数
- 在方法结束时采集返回值
- 调用帮助类计算耗时及打印结果
ASM
采集方法参数
采集方法参数的方法也很简单,主要就是读取出所有参数的值并存储在一个List
中,主要问题在于我们需要用字节码来实现这些逻辑.
override fun onMethodEnter() {
// 方法开始
if (isNeedVisiMethod() && descriptor != null) {
val parametersIdentifier = MethodRecordUtil.newParameterArrayList(mv, this) //1. new一个List
MethodRecordUtil.fillParameterArray(methodDesc, mv, parametersIdentifier, access) //2. 填充列表
MethodRecordUtil.onMethodEnter(mv, className, name, parametersIdentifier) //3. 调用帮助类
}
super.onMethodEnter()
}
如上所示,采集方法参数也分为3步,接下来我来一步步看下代码
ASM
创建列表
fun newParameterArrayList(mv: MethodVisitor, localVariablesSorter: LocalVariablesSorter): Int {
// new一个ArrayList
mv.visitTypeInsn(AdviceAdapter.NEW, "java/util/ArrayList")
mv.visitInsn(AdviceAdapter.DUP)
mv.visitMethodInsn(
AdviceAdapter.INVOKESPECIAL,
"java/util/ArrayList",
"<init>",
"()V",
false
)
// 存储new出来的List
val parametersIdentifier = localVariablesSorter.newLocal(Type.getType(List::class.java))
mv.visitVarInsn(AdviceAdapter.ASTORE, parametersIdentifier)
// 返回parametersIdentifier,方便后续访问这个列表
return parametersIdentifier
}
逻辑其实很简单,主要问题在于需要用ASM
代码写,需要掌握一些字节码指令相关知识。不过我们也可以用asm-bytecode-outline来自动生成这段代码,这样难度可以降低不少。关于代码中各个指令的具体含义,可查阅Java虚拟机(JVM)字节码指令表
ASM
填充列表
接下来要做的就是读出所有的参数并填充到上面创建的列表中
fun fillParameterArray(
methodDesc: String,
mv: MethodVisitor,
parametersIdentifier: Int,
access: Int
) {
// 判断是不是静态函数
val isStatic = (access and Opcodes.ACC_STATIC) != 0
// 静态函数与普通函数的cursor不同
var cursor = if (isStatic) 0 else 1
val methodType = Type.getMethodType(methodDesc)
// 获取参数列表
methodType.argumentTypes.forEach {
// 读取列表
mv.visitVarInsn(AdviceAdapter.ALOAD, parametersIdentifier)
// 根据不同类型获取不同的指令,比如int是iload, long是lload
val opcode = it.getOpcode(Opcodes.ILOAD)
// 通过指令与cursor读取参数的值
mv.visitVarInsn(opcode, cursor)
if (it.sort >= Type.BOOLEAN && it.sort <= Type.DOUBLE) {
// 基本类型转换为包装类型
typeCastToObject(mv, it)
}
// 更新cursor
cursor += it.size
// 添加到列表中
mv.visitMethodInsn(
AdviceAdapter.INVOKEINTERFACE,
"java/util/List",
"add",
"(Ljava/lang/Object;)Z",
true
)
mv.visitInsn(AdviceAdapter.POP)
}
}
主要代码如上所示,代码中都有注释,主要需要注意以下几点:
- 静态函数与普通函数的初始
cursor
不同,因此需要区分开来 - 不同类型的参数加载的指令也不同,因此需要通过
Type.getOpcode
获取具体指令 - 为了将参数放在一个列表中,需要将基本类型转换为包装类型,比如
int
转换为Integer
ASM
调用帮助类
fun onMethodEnter(
mv: MethodVisitor,
className: String,
name: String?,
parametersIdentifier: Int
) {
mv.visitLdcInsn(className)
mv.visitLdcInsn(name)
mv.visitVarInsn(AdviceAdapter.ALOAD, parametersIdentifier)
mv.visitMethodInsn(
AdviceAdapter.INVOKESTATIC, "com/zj/android_asm/MethodRecorder", "onMethodEnter",
"(Ljava/lang/String;Ljava/lang/String;Ljava/util/List;)V", false
)
}
这个比较简单,主要就是通过ASM
调用MethodRecorder.onMethodEnter
方法
ASM
采集返回值
override fun onMethodExit(opcode: Int) {
// 方法结束
if (isNeedVisiMethod()) {
if ((opcode in IRETURN..RETURN) || opcode == ATHROW) {
when (opcode) {
// 基本类型返回
in IRETURN..DRETURN -> {
// 读取返回值
MethodRecordUtil.loadReturnData(mv, methodDesc)
MethodRecordUtil.onMethodExit(mv, className, name, methodDesc)
}
// 对象返回
ARETURN -> {
// 读取返回值
mv.visitInsn(DUP)
MethodRecordUtil.onMethodExit(mv, className, name, methodDesc)
}
// 空返回
RETURN -> {
mv.visitLdcInsn("void")
MethodRecordUtil.onMethodExit(mv, className, name, methodDesc)
}
}
}
}
super.onMethodExit(opcode);
}
采集返回值的逻辑也很简单,主要分为以下几步
- 判断当前指令,并且根据不同类型的返回添加不同的逻辑
- 通过
DUP
指令复制栈顶数值并将复制值压入栈顶,以读取返回值 - 读取方法参数类型与返回值类型,并调用
MethodRecorder.onMexthodExit
方法
帮助类实现
由于ASM
需要直接操作字节码,写起来终究不太方便,因此我们尽可能把代码转移到帮助类中,然后通过在ASM
中调用帮助类来简化开发,帮助类的代码如下所示:
object MethodRecorder {
private val mMethodRecordMap = HashMap<String, MethodRecordItem>()
@JvmStatic
fun onMethodEnter(className: String, methodName: String, parameterList: List<Any?>?) {
val key = "${className},${methodName}"
val startTime = System.currentTimeMillis()
val list = parameterList?.filterNotNull() ?: emptyList()
mMethodRecordMap[key] = MethodRecordItem(startTime, list)
}
@JvmStatic
fun onMethodExit(
response: Any? = null,
className: String,
methodName: String,
parameterTypes: String,
returnType: String
) {
val key = "${className},${methodName}"
mMethodRecordMap[key]?.let {
val parameters = it.parameterList.joinToString(",")
val duration = System.currentTimeMillis() - it.startTime
val result = "类名:$className \n方法名:$methodName \n参数类型:[$parameterTypes] \n入参:[$parameters] \n返回类型:$returnType \n返回值:$response \n耗时:$duration ms \n"
Log.i("methodRecord", result)
}
}
}
代码其实也很简单,主要逻辑如下:
- 方法开始时调用
onMethodEnter
方法,传入参数列表,并记录下方法开始时间 - 方法结束时调用
onMethodExit
方法,传入返回值,计算方法耗时并打印结果
总结
通过上述步骤,我们就把ASM
插桩实现记录方法入参,返回值以及方法耗时的功能完成了,通过插桩可以在方法执行的时候输出我们需要的信息。而这些信息的价值就是可以很好的让我们做一些程序的全链路监控以及工程质量验证。
总得来说,逻辑上其实并不复杂,主要问题可能在于需要熟悉如何直接操作字节码,我们可以通过asm-bytecode-outline等工具自动生成代码来简化开发,同时也可以通过尽量把逻辑迁移到帮助类中的方式来减少直接操作字节码的工作。
示例代码
本文所有源码可见:github.com/shenzhen201…
作者:程序员江同学
链接:https://juejin.cn/post/7108526362087915534
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
Activity 感知 Fragment 中的触摸事件
前言
Fragment
在 Activity
上,发现 Fragment
上的触摸事件会被 Activity
所接收。这在一些业务场景上很不适用,很多时候业务逻辑不想让我们Fragment
中的触摸事件被Activity
所感知,那应该怎么做呢?
举个例子吧
我们先建一个Activity
,然后在Activity
上放一个Fragment
,Fragment
位于整个屏幕的下半部分,然后尝试在Fragment
上点击,滑动,这时Activity
可以接收到这些触摸事件吗?
先将这个Demo
的代码写出来:
先为 MainActivity
布局,将我们的Fragment
位于整个屏幕的下半部分。
MainActivity.java
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="<http://schemas.android.com/apk/res/android>"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:textColor="@color/color_text_gray_light"
android:textSize="16sp"
android:layout_weight="1" />
<androidx.fragment.app.FragmentContainerView
android:id="@+id/liTestFcv"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_weight="1"
android:name="com.jmkj.VirtualCurrency.Home.Fragment.LiTestFragment"/>
</LinearLayout>
然后为我们的Fragment
布局,这里取名为 LiTestFragment
,放两个Button
,其余空间都空着,方便后续的点击、滑动等触摸事件处理。
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="<http://schemas.android.com/apk/res/android>"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<Button
android:id="@+id/btn1"
android:layout_width="match_parent"
android:layout_height="80dp"
android:text="button 1" />
<Button
android:id="@+id/btn2"
android:layout_width="match_parent"
android:layout_height="80dp"
android:text="button 2" />
</LinearLayout>
并且为这两个按钮添加点击事件,点击跳出Toast
提示。
btn1.setOnClickListener {
Toast.makeText(context, "btn 1 click", Toast.LENGTH_SHORT).show()
}
btn2.setOnClickListener {
Toast.makeText(context, "btn 2 click", Toast.LENGTH_SHORT).show()
}
接着,我们在 MainActivity.java
中重写 onTouchEvent
方法,进行对触摸事件的拦截。
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
Log.e(TAG, "onTouchEvent: ACTION_DOWN");
break;
case MotionEvent.ACTION_MOVE:
Log.e(TAG, "onTouchEvent: ACTION_MOVE");
break;
case MotionEvent.ACTION_UP:
Log.e(TAG, "onTouchEvent: ACTION_UP");
break;
}
return true;
}
猜想一下:
当我们在LiTestFragment
的空白区域进行点击、滑动时,MainActivity
可以接收到这些触摸事件吗?
答案:可以收到。
当我们在 LiTestFragment
空白区域滑动时,输出日志如下:
E/MainActivity: onTouchEvent: ACTION_DOWN
E/MainActivity: onTouchEvent: ACTION_MOVE
E/MainActivity: onTouchEvent: ACTION_MOVE
E/MainActivity: onTouchEvent: ACTION_MOVE
E/MainActivity: onTouchEvent: ACTION_MOVE
E/MainActivity: onTouchEvent: ACTION_MOVE
E/MainActivity: onTouchEvent: ACTION_MOVE
E/MainActivity: onTouchEvent: ACTION_UP
那再猜想一下:
当我点击LiTestFragment
中的两个按钮时,MainActivity
可以接收到这两个点击事件吗?
答案:收不到。
这是为什么呢?思考一下。
那如果我们想让在Fragment
中的触摸事件不被Activity
接收到,那又该怎么做呢?
在Fragment
中先行一步拦截掉,然后将触摸事件消费掉,这样就可以避免该触摸事件被Activity
所接收到。
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val view = inflater.inflate(R.layout.fragment_li_test, container, false)
view?.setOnTouchListener { v, event ->
v.performClick()
true
}
...省略代码...
return view
}
这里 v.performClick()
为调用该视图定义的 OnClickListener
方法,返回true
就是代表消耗该触摸事件。这样子,触摸事件就将在Fragment
中被消耗,所以MainActivity
也就收不到该触摸事件了。
这里我们回到刚刚的问题,为什么我在LiTestFragment
中点击两个按钮时,MainActivity
收不到该触摸事件?
因为这里我们是为整个Fragment
添加触摸事件监听,而我们的两个按钮就是该Fragment
的子view
。
当我们点击按钮时,触发其onTouchEvent
,然后执行 performClick()
,执行mOnClickListener.onClick(this)
,也就是我们为按钮添加的点击监听事件。
所以,如果我们不想让按钮的点击监听事件工作的话,我们只需要为按钮设置OnTouchEvent
,然后将事件消费掉就可以了。
findViewById<Button>(R.id.btn1).apply {
setOnTouchListener { v, event ->
Log.e(TAG, "operation: btn1 onTouchListener")
true
}
setOnClickListener {
Toast.makeText(context, "btn 1 click", Toast.LENGTH_SHORT).show()
}
}
总结
其实本文所述的内容都是属于Android事件分发的知识点,想要更好的理解本文,更好的理解Activity
、Fragment
以及子View
之间的触摸事件传递,就需要进一步学习一下Android事件分发知识点,我会在下一篇文章中做进一步分享。
作者:乐黎
链接:https://juejin.cn/post/7103823632958226469
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
Java多线程案例之线程池
⭐️前面的话⭐️
本篇文章将介绍多线程案例,线程池,线程在Linux中也叫做轻量级线程,尽管线程比进程较轻,但是如果线程的创建和销毁频率高了,开销也还是有的,为了进一步提高效率,引入了线程池,和字符串常量池类似,把线程提前创建好,放到一个“池子”里面,后面使用的时候,速度就快了,但是代价就是空间,线程池本质上也还是空间换时间。
📒博客主页:未见花闻的博客主页
🎉欢迎关注🔎点赞👍收藏⭐️留言📝
📌本文由未见花闻原创!
📆掘金首发时间:🌴2022年6月4日🌴
✉️坚持和努力一定能换来诗与远方!
💭参考书籍:📚《Java核心技术》,📚《Java编程思想》,📚《Effective Java》
💬参考在线编程网站:🌐牛客网🌐力扣
博主的码云gitee,平常博主写的程序代码都在里面。
博主的github,平常博主写的程序代码都在里面。
🍭作者水平很有限,如果发现错误,一定要及时告知作者哦!感谢感谢!
🎵1.线程池概述
🎶1.1什么是线程池
线程池和字符串常量池一样,都是为了提高程序运行效率而提出的效率,程序中每创建一个线程就会把该线程加载到一个“池子”中去,其实这个池子就是List,当程序下次需要调用该线程的时候,可以直接从线程池中去取,而不用花费更大的力气去重新创建和销毁线程,从而使程序的运行效率提高,线程池也是管理线程的方式之一。
🌳那为什么从线程池中“拿”线程会比直接创建线程要更加高效呢?
因为使用线程池调度线程是在用户态实现的,而线程的创建是基于内核态实现的。那为什么说用户态比内核态更加高效呢?因为你将任务交给内核态时,内核态不仅仅只去完成你交给它的任务,大概率还会伴随完成其他的任务,而你将任务交给用户态时,用户态只去完成你所交代的任务,所以综上所述,用户态效率更高。
🎶1.2Java线程池标准类
java也提供了相关行程池的标准类ThreadPoolExecutor
,也被称作多线程执行器,该类里面的线程包括两类,一类是核心线程,另一类是非核心线程,当核心线程全部跑满了还不能满足程序运行的需求,就会启用非核心线程,直到任务量少了,慢慢地,非核心线程也就退役了,通俗一点核心线程就相当于公司里面的正式工,非核心线程相当于临时工,当公司人手不够的时候就会请临时工来助力工作,当员工富余了,公司就会将临时工辞退。
jdk8中,提供了4个构造方法,我主要介绍参数最多的那一个构造方法,其他3个构造方法都是基于此构造方法减少了参数,所以搞懂最多参数的构造方法,其他构造方法也就明白了。
//参数最多的一个构造方法
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler)
corePoolSize
表示核心线程数。maximumPoolSize
表示最大线程数,就是核心线程数与非核心线程数之和。keepAliveTime
非核心线程最长等待新任务的时,就是非核心线程的最长摸鱼时间,超过此时间,该线程就会被停用。unit
时间单位。workQueue
任务队列,通过submit
方法将任务注册到该队列中。threadFactory
线程工厂,线程创建的方案。handler
拒绝策略,由于达到线程边界和队列容量而阻止执行时使用的处理策略。
其他几个构造方法:
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue)
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory)
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
RejectedExecutionHandler handler)
🌳那核心线程数最合适值是多少呢?假设CPU有N核心,最适核心线程数是N?是2N?是1.5N?只要你能够说出一个具体的数,那就错了,最适的核心线程数要视情况而定,没有一个绝对的标准的值。
在具体使用线程池时,往往使用的是Executor
,因为 Executor
是 ThreadPoolExecutor
所实现的一个接口,由于标准库中的线程池使用较复杂,对于ThreadPoolExecutor
类中的方法我们就不介绍了,最重要的一个方法是submit
方法,这个方法能够将任务交给线程池去执行,接下来我们来理一理线程池最基本的工作原理,我们来尝试实现一个简单的线程池。
🎵2.线程池的实现
🎶2.1线程池的基本工作原理
线程池是通过管理一系列的线程来执行程序员所传入的任务,这些任务被放在线程池对象中的一个阻塞队列中,然后线程池会调度线程来执行这些任务,优先调度核心线程(核心线程会在线程池对象构造时全部创建),如果核心线程不够用了,就会创建非核心线程来帮忙处理任务,当非核心线程一定的时间没有收到新任务时,非核心线程就会被销毁,我们实现线程池的目的是加深对线程池的理解,所以实现的过程中就不去实现非核心线程了,线程池里面的线程全部以核心线程的形式实现。
🌳我们需要实现一个线程池,根据以上的原理需要准备:
- 任务,可以使用Runnable。
- 组织任务的数据结构,可以使用阻塞对列。
- 工作线程(核心线程)的实现。
- 组织线程的数据结构,可以使用List。
- 新增任务的方法
submit
。
🎶2.2线程池的简单实现
关于任务和任务的组织就不用多说了,直接使用Runnable
和阻塞队列BlockingQueue<Runnable>
就可以了,重点说一下工作线程如何描述的,工作线程中需要有一个阻塞队列引用来获取我们存任务的那一个阻塞队列对象,然后重写run
方法通过循环不断的获取任务执行任务。
然后根据传入的核心线程数来创建并启动工作线程,将这些线程放入顺序表或链表中,便于管理。
最后就是创建一个submit
方法用来给用户或程序员派发任务到阻塞队列,这样线程池中的线程就会去执行我们所传入的任务了。
🌳实现代码:
class MyThreadPool {
//1.需要一个类来描述具体的任务,直接使用Runnable即可
//2.有了任务,我们需要将多个任务组织起来,可以使用阻塞队列
private final BlockingQueue<Runnable> queue = new LinkedBlockingQueue<>();
//3.组织好任务,就可以分配线程池中的线程来执行任务了,所以我们需要描述线程,专门来执行任务
static class Worker extends Thread {
//获取任务队列
private final BlockingQueue<Runnable> queue;
//构造线程时需要将任务队列初始化
public Worker(BlockingQueue<Runnable> queue) {
this.queue = queue;
}
//重写线程中的run方法,用来执行阻塞队列中的任务
@Override
public void run() {
while (true) {
try {
//获取任务
Runnable runnable = queue.take();
//执行任务
runnable.run();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
// 4.线程池中肯定存在不止一个线程,所以我们需要对线程进行组织,这里我们可以使用顺序表,使用链表也可以
private final List<Worker> workers = new ArrayList<>();
//根据构造方法指定的线程数将线程存入workers中
public MyThreadPool(int threadNums) {
for (int i = 0; i < threadNums; i++) {
Worker worker = new Worker(this.queue);
worker.start();
this.workers.add(worker);
}
}
// 5.创建一个方法,用来将任务存放到线程池中
public void submit(Runnable runnable) {
try {
this.queue.put(runnable);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
🌳我们来测试一下我们所实现的线程池:
import java.util.ArrayList;
import java.util.List;
public class ThreadPoolProgram {
private static int NUMS = 1;
public static void main(String[] args) throws InterruptedException {
MyThreadPool pool = new MyThreadPool(10);
for (int i = 0; i < 20; i++) {
pool.submit(new Runnable() {
@Override
public void run() {
System.out.println("第" + NUMS + "个任务!" );
}
});
Thread.sleep(200);
NUMS++;
}
}
🌳运行结果:
好了,你知道线程池的工作原理了吗?
作者:未见花闻
链接:https://juejin.cn/post/7105178100882898957
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
Flutter 小技巧之玩转字体渲染和问题修复
这次的 Flutter 小技巧是字体渲染,虽然是小技巧但是内容略长,可能大家在日常开发中不会特别关心字体相关的部分,而这将是一篇你平时可能用不到 ,但是遇到问题就会翻出来的文章。
本篇将快速普及一些字体渲染相关的基础,解决一些因为字体而导致的异常问题,并穿插一些实用小技巧,内容篇幅可能略长,建议先 Mark 后看。
一、字体库
首先,问一个我经常问的面试题:Flutter 在 Android 和 iOS 上使用了哪些字体?
如果你恰好看过 typography.dart
的源码和解释,你可以会有初步结论:
- Android 上使用的是
Roboto
字体; - iOS 上使用的是
.SF UI Display
或者.SF UI Text
字体;
但是,如果你再进一步去了解就会发现,在加上中文显示之后,结论应该是:
- 默认在 iOS 上:
- 中文字体:
PingFang SC
(繁体还有PingFang TC
、PingFang HK
) - 英文字体:
.SF UI Text
/.SF UI Display
- 中文字体:
- 默认在 Android 上:
- 中文字体:
Source Han Sans
/Noto
- 英文字体:
Roboto
- 中文字体:
那这时候你可能会问:.SF 没有中文,那可以使用 PingFang
显示英文吗? 答案是可以的,但是字形和字重会有微妙区别, 例如下图里的 G 就有很明显的不同。
那如果加上韩文呢?这时候 iOS 上的 PingFang
和 .SF
就不够用了,需要调用如 Apple SD Gothic Neo
这样的超集字体库,而说到这里就需要介绍一个 Flutter 上你可能会遇到的 Bug。
如下图所示,当在使用 Apple SD Gothic Neo
字体出现中文和韩文同时显示时,你可能会察觉一些字形很奇怪,比如【推广】这两个字,其中【广】这个字符在超集上是不存在的,所以会变成了中文的【广】,但是【推】字用的还是超集里的字形。
这种情况下,最终渲染的结果会如下图所示,解决的思路也很简单,小技巧就是给 TextStyle
或者 Theme
的 fontFamilyFallback
配置上 ["PingFang SC" , "Heiti SC"]
。
另外,如果你还对英文下 .SF UI Display
和 ``SF UI Text` 之间的关系困惑的话,那其实你不用太过纠结,因为从 SF 设计上大概意思上理解的话:
.SF Text 适用于更小的字体;.SF Display 则适用于偏大的字体,分水岭大概是 20pt 左右,不过 SF(San Francisco) 属于动态字体,系统会动态匹配。
二、Flutter Text
虽然上面介绍字体的一些相关内容,但是在 Flutter 上和原生还是有一些差异,在 Flutter 中的文本呈现逻辑是有分层的,其中:
- 衍生自 Minikin 的 libtxt 库用于字体选择,分隔行等;
- HartBuzz 用于字形选择和成型;
- Skia作为 渲染 / GPU后端;
- 在 Android / Fuchsia 上使用 FreeType 渲染,在 iOS 上使用CoreGraphics 来渲染字体 。
Text Height
那如果这时候我问你一个问题: 一个 fontSize: 100
的 H 字母需要占据多大的高度 ?你会回答多少?
首先,我们用一个 100 的红色 Container
和 fontSize: 100
的 H 文本做个对比,可以看到 H 文本所在的蓝色区域其实是需要大于 100 的红色区域的。
事实上,前面的蓝色区域是字体的行高,也就是 line height,关于这个行高,首先需要解释的就是 TextStyle
中的 height
参数。
默认情况下 height
参数是 null
,当我们把它设置为 1
之后,如下图所示,可以看到蓝色区域的高度和红色小方块对齐,变成了 100 的高度,也就是行高变成了 100 ,而 H 字母完整地显示在了蓝色区域内。
那 height
是什么呢?首先 TextStyle
中的 height
参数值在设置后,其效果值是 fontSize
的倍数:
- 当
height
为空时,行高默认是使用字体的量度(这个量度后面会有解释); - 当
height
不是空时,行高为height
*fontSize
的大小;
如下图所示,蓝色区域和红色区域的对比就是 height
为 null
和 1
的对比高度。
所以,看到这里你又知道了一个小技巧:当文字在 Container
“有限高度” 内容内无法居中时,可以考虑调整 TextStyle
中的 height
来实现 。
当然,这时候如果你把
Container
的height:50
去掉,又会是另外一个效果。
所以 height 参数和文本渲染的高度之间是成倍数关系,具体如下图所示,同时最需要注意的点就是:文本内容在 height 里并不是居中,这里的 height 可以类比于调整行高。
另外,文本中的除了 TextStyle
下的 height
之外,还是有 StrutStyle
参数下的 height
,它影响的是字体的整体量度,也就是如下图所示,影响的是 ascent - descent 的高度。
那你说它和 TextStyle
下的 height
有什么区别? 如下图所示例子:
StrutStyle
的froceStrutHeight
开启后,TextStyle
的height
不会生效;StrutStyle
设置fontSize:50
影响的内容和TextStyle
的fontSize:100
影响的内容不一样;
另外在 StrutStyle
里还有一个叫 leading
的 参数,加上了 leading
后才是 Flutter 中对字体行高完全的控制组合,leading
默认为 null
,同时它的效果也是 fontSize
的倍数,并且分布是上下均分。
所以,看到这里你又知道了一个小技巧:设置 leading
可以均分高度,所以如下图所示,也可以用于调整行间距。
更多行高相关可见 :《深入理解 Flutter 中的字体“冷”知识》
FontWeight
另外一个关于字体的知识点就是 FontWeight
,相信大家对 FontWeight
不会陌生,比如我们默认的 normal 是 w400,而常用的 bold 是 w700 ,整个 FontWeight
列表覆盖 100-900 的数值。
那么这里又有个问题:这些 Weight 在字体里都能找到对应的粗细吗?
答案是不行的,因为正常情况下如下图所示 ,有些字体库在某些 Weight 下是没有对应支持,例如
- Roboto 没有 w600
- PingFang 没有高于 w600
那你可能好奇,为什么这里要特意介绍 FontWeight ?因为在 Flutter 3.0 目前它对中文有 Bug!
从下面这张图你可以看到,在 Flutter 3.0 上中文从 100-500 的字重显示是不正常的,肉眼可以看出在 100 - 500 都显示同一个字重。
这个 Bug 来自于当
SkParagraph
调用onMatchFamilyStyleCharacter
时,onMatchFamilyStyleCharacter
的实现没有选择最接近TextStyle
的字体,所以在CTFontCreateWithFontDescriptor
时会带上 weight 参数但是却没有familyName
,所以 CTFontCreateWithFontDescriptor` 函数就会返回 Helvetica 字体的默认 weight。
临时解决小技巧也很简单:全局设置 fontFamilyFallback: ["PingFang SC"]
或者 fontFamily: 'PingFang SC'
就可以解决,又是 Fallback , 这时候你就会发现,前面介绍的字体常识,可以在这里快速被利用起来。
因为 iOS 上中文就是
PingFang SC
,只要 Fallback 回 PingFang 就可以正常渲染,而这个问题在 Android 模拟器、iOS 真机、Mac 上等会出现,但是 Android 真机上却不会,该问题我也提交在 #105014 下开始跟进。
添加的 Fallback 之后效果如上图左侧所示, 那 Fallback 的作用是什么?
前面我们介绍过,系统在多语言中渲染是需要多种字体库来支持,而当找不到字形时,就要依赖提供的 Fallback 里的有序列表,例如:
如果在 fontFamily 中找不到字形,则在 fontFamilyFallback 中搜索,如果没有找到,则会在返回默认字体。
另外关于 FontWeight
还有一个“小彩蛋”,在 iOS 上,当用户在辅助设置里开启 Bold Text 之后,如果你使用的是 Text
控件,那么默认情况下所有的字体都会变成 w700 的粗体。
因为在 Text
内使用了 MediaQuery.boldTextOverride
判断,Flutter 会接收到 iOS 上用户开启了 Bold Text ,从而强行将 fontWeight
设置为 FontWeight.bold
,当然如果你直接使用 RichText
就 没有这一行为。
这时候小技巧就又来了:如果你不希望这些系统行为干扰到你,那么你可以通过嵌套 MediaQuery
来全局关闭,而类似的行为还有 textScaleFactor
和 platformBrightness
等 。
return MediaQuery(
data: MediaQueryData.fromWindow(WidgetsBinding.instance!.window).copyWith(boldText: false),
child: MaterialApp(
useInheritedMediaQuery: true,
),
);
FontFeature
最后再介绍一个冷门参数 FontFeature 。
什么是 FontFeature
? 简单来说就是影响字体形状的一个属性 ,在前端的对应领域里应该是 font-feature-settings
,它有别于 FontFamily
,是用于指定字体内字的形状参数。
如下图所示是
frac
分数和tnum
表格数字的对比渲染效果,这种效果可以在不增加字体库时实现特殊的渲染,另外Feature
也有特征的意思,所以也可以理解为字体特征。
那 FontFeature 有什么用呢? 这里又有一个使用小技巧了:当出现数字和文本同时出现,导致排列不对齐时,可以通过给 Text
设置 fontFeatures: [FontFeature("tnum")]
来对齐。
例如下图左边是没有设置 fontFeatures 的情况,右边是设置了 FontFeature("tnum")
的情况,对比之下还是很明显的。
更多关于 FontFeature 的内容可见 《Flutter 上字体的另类玩法:FontFeature 》
三、最后
总结一下,本篇内容信息量相对比较密集,主要涉及:
- 字体基础
- Text Height
- FontWeight
- FontFeature
从以上四个方面介绍了 Flutter 开发里关于字体渲染的“冷知识”和小技巧,包括:解决多语言下的字体错误、如何正确调整行高、如何对其数字内容等相关小技巧。
如果你还有什么关于字体的疑问,欢迎留言讨论~
作者:恋猫de小郭
链接:https://juejin.cn/post/7108463516952035365
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
flutter简单优秀的开源dialog使用free_dialog
前言
今天我来介绍一款简单,易用的dialog,该dialog比较简洁,可以适应很多app(主要没有其他动画及以外的图片等,黑白风格可以适配多种样式的app)。如果你的app急需一款不错的dialog,并且你懒得开发,那么用这款就对了。
开始
集成dialog
dependencies:
free_dialog: ^0.0.1
git地址:github.com/smartbackme…
简单使用
例1(输入文字框):
FreeDialog(context: context,title: "请输入文字",
iWidget: EditWidget(_controller!),
btnOkOnPress: (a){
print(a);
},
btnCancelOnPress: (){
},
onDissmissCallback: (DismissType type){
print(type);
}
).show();
}, child: const Text("输入文字框")),
说明:free_dialog 提供了多种Widget 配置(目前有,list&edit两种),可以快速集成。
展示效果如下图:
例2(选择框):
FreeDialog(context: context,title: "请选择",
onDissmissCallback: (DismissType type){
print(type);
},
iWidget: ListWidget(["123","1233","12312","12312","12312","12312","12312","12312","12312","12312","12312","12312","12312"],(a){
print(a);
},)
).show();
}, child: const Text("选择框")),
展示效果如下图:
例3(内容多dialog):
FreeDialog(context: context,title: "提示",
desc: "123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321",
btnOkOnPress: (a){
print(a);
},
btnCancelOnPress: (){
},
onDissmissCallback: (DismissType type){
print(type);
}
).show();
展示效果如下图:
例4(内容多dialog,单按钮):
FreeDialog(context: context,title: "提示",
desc: "123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321123123211231232112312321",
btnOkOnPress: (a){
print(a);
},
onDissmissCallback: (DismissType type){
print(type);
}
).show();
展示效果如下图:
例5(内容少dialog):
FreeDialog(context: context,title: "提示",
desc: "111",
btnOkOnPress: (a){
print(a);
},
onDissmissCallback: (DismissType type){
print(type);
}
).show();
展示效果如图:
例6(单提示):
FreeDialog(context: context,title: "提示",
desc: "12312",
onDissmissCallback: (DismissType type){
print(type);
}
).show();
展示效果如图所示:
例7(禁止退出 dialog):
FreeDialog(context: context,title: "提示",
desc: "1111",
dismissOnTouchOutside: false,
dismissOnBackKeyPress: false,
btnCancelOnPress: (){
},
onDissmissCallback: (DismissType type){
print(type);
}
).show();
支持的定制
属性 | 类型 | 描述 | 默认属性 |
---|---|---|---|
width | double | dialog宽度 | 屏幕窄边的80% |
title | String | 设置title | 不传的话默认是没有title的 |
desc | String | 设置普通 框的文字内容 | 没有的话不展示,如果有设置body和iwidget的话也不展示 |
body | Widget | 自定义widget | Null |
context | BuildContext | @required | Null |
btnOkText | String | ok文字 | 'Ok' |
btnOkOnPress | Function | 点击ok | Null (如果传了则会展示ok) |
btnOkColor | Color | ok颜色 | Color(0xFF00CA71) |
btnOk | Widget | 传一个 ok组件 | null |
btnCancelText | String | 取消 | 'Cancel' |
btnCancelOnPress | Function | 点击取消 | Null (如果传了则会展示cancle) |
btnCancelColor | Color | 颜色 取消 | Colors.red |
btnCancel | Widget | 传一个cancle组件 | null |
dismissOnTouchOutside | bool | 点外部关闭 | true |
onDismissCallback | Function | 退出弹框回调 | Null |
animType | AnimType | 动画类型 | AnimType.SCALE |
alignment | AlignmentGeometry | 排版 | Alignment.center |
useRootNavigator | bool | 是否用 useRootNavigator | false |
autoHide | Duration | 自动消失 | null |
keyboardAware | bool | 是否随着键盘移动(填充键盘区域) | true |
dismissOnBackKeyPress | bool | 返回键退出 | true |
buttonsBorderRadius | BorderRadiusGeometry | 按钮 Radius | BorderRadius.all(Radius.circular(100) |
dialogBackgroundColor | Color | dialog背景 | Theme.of(context).cardColor |
borderSide | BorderSide | 整个弹窗形状 | null |
iWidget | IWidget | 通用定义widget(源码带有edit和list) | null |
一文搞明白协程的挂起和恢复
协程是使用非阻塞式挂起的方式来实现线程运行的。那协程又是如何挂起和恢复的,这里面的概念又是什么,带着这些问题就让我们重新探究下协程的挂起和恢复。
我们先创建个协程:
override fun initView() {
lifecycleScope.launch {
val num = dealA()
dealB(num)
}
}
private suspend fun dealA():Int {
withContext(Dispatchers.IO) {
delay(3000)
}
return 1
}
private suspend fun dealB(num:Int) {
withContext(Dispatchers.IO) {
delay(1000)
}
}
可以看到写协程的时候要在函数前面加上suspend
修饰,这也是常说的挂起函数,那挂起函数又是什么?
挂起函数
了解之前,我们先将上面的挂起函数dealA()
反编译成 Java,简单的看看编译后是什么样的?(省略了后面会着重解释的一些代码,主要先看挂起函数的方法)
private final Object dealA(Continuation var1) {
......
Object $result = ((<undefinedtype>)$continuation).result;
Object var4 = IntrinsicsKt.getCOROUTINE_SUSPENDED();
switch(((<undefinedtype>)$continuation).label) {
case 0:
ResultKt.throwOnFailure($result);
CoroutineContext var10000 = (CoroutineContext)Dispatchers.getIO();
Function2 var10001 = (Function2)(new Function2((Continuation)null) {
int label;
@Nullable
public final Object invokeSuspend(@NotNull Object $result) {
.......
return Unit.INSTANCE;
}
......
if (BuildersKt.withContext(var10000, var10001, (Continuation)$continuation) == var4) {
return var4;
}
break;
......
}
return Boxing.boxInt(1);
}
可以看到suspend
经过反编译后,会出现Continuation
类型的参数传进去,并且返回的是Object
对象。
是不是对Continuation
是什么很好奇,这也是协程的核心部分`:
public interface Continuation<in T> {
//对应于这个延续的协程的上下文
public val context: CoroutineContext
//继续执行相应的协程,传递一个成功或失败的 [result] 作为最后一个暂停点的返回值。
public fun resumeWith(result: Result<T>)
}
从定义上可以看出Continuation
其实就是一个带有泛型参数的callback
,而resumeWith
也就相当于onSuccess的成功回调,来恢复执行后面的代码,除这个之外,还有一个ContineContext
,它就是协程的上下文。
回到dealA
方法中,当执行到withContext
方法的时候,会返回CoroutineSingletons.COROUTINE_SUSPENDED
,表示函数被挂起了,到这里你是不是觉得就结束了,其实还没有。
在查看过程中是不是看到有个invokeSuspend
的回调方法还没有被调用,这又是在什么时候会被触发的?
那我们就从刚才执行到的withContext
那里进一步查看,写过协程的都知道这就是用来切换线程:
public suspend fun <T> withContext(
context: CoroutineContext,
block: suspend CoroutineScope.() -> T
): T {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
return suspendCoroutineUninterceptedOrReturn sc@ { uCont ->
// compute new context
val oldContext = uCont.context
val newContext = oldContext + context
// always check for cancellation of new context
newContext.ensureActive()
// FAST PATH #1 -- 新上下文与旧上下文相同
if (newContext === oldContext) {
val coroutine = ScopeCoroutine(newContext, uCont)
return@sc coroutine.startUndispatchedOrReturn(coroutine, block)
}
// FAST PATH #2 新的调度程序与旧的调度程序相同
if (newContext[ContinuationInterceptor] == oldContext[ContinuationInterceptor]) {
val coroutine = UndispatchedCoroutine(newContext, uCont)
// 上下文有变化,所以这个线程需要更新
withCoroutineContext(newContext, null) {
return@sc coroutine.startUndispatchedOrReturn(coroutine, block)
}
}
// SLOW PATH -- 使用新的调度程序
val coroutine = DispatchedCoroutine(newContext, uCont)
block.startCoroutineCancellable(coroutine, coroutine)
coroutine.getResult()
}
}
在withContext
方法中,传入了两个参数,一个是协程的上下文,另一个就是协程里的代码。可以看到不管新的调度和旧的调度一样最后都是会调用startCoroutineCancellable
方法:
internal fun <R, T> (suspend (R) -> T).startCoroutineCancellable(
receiver: R, completion: Continuation<T>,
onCancellation: ((cause: Throwable) -> Unit)? = null
) =
runSafely(completion) {
createCoroutineUnintercepted(receiver, completion).intercepted().resumeCancellableWith(Result.success(Unit), onCancellation)
}
而在startCoroutineCancellable
方法中,创建了Coroutination
,之后会调用resumeCancelableWith
方法:
public fun <T> Continuation<T>.resumeCancellableWith(
result: Result<T>,
onCancellation: ((cause: Throwable) -> Unit)? = null
): Unit = when (this) {
is DispatchedContinuation -> resumeCancellableWith(result, onCancellation)
else -> resumeWith(result)
}
在这里是不是看到了我们之前提到过的resumeWith
方法,之前也解释了下它就相当于一个回调。然后我们再来看下它的具体实现,是在ContinuationImpl
类中:
public final override fun resumeWith(result: Result<Any?>) {
var current = this
var param = result
while (true) {
probeCoroutineResumed(current)
with(current) {
val completion = completion!! // fail fast when trying to resume continuation without completion
val outcome: Result<Any?> =
try {
val outcome = invokeSuspend(param)
if (outcome === COROUTINE_SUSPENDED) return
Result.success(outcome)
} catch (exception: Throwable) {
Result.failure(exception)
}
.......
}
}
}
在resumeWith
方法中执行到了我们一直在找的invokeSuspend
,通过这个方法将result
回调了出去,并判断当前是不是COROUTINE_SUSPENDED
(挂起),是挂起直接退出,去执行上面说到的invokeSuspend
里面的内容。
在这里我们了解到invokeSuspend
是由resumeWith
所触发的,那接下来我们看看真正的挂起和恢复如何被执行的。
协程的启动
了解挂起和恢复的过程,要从协程的启动执行开始,我们还是跟刚才一样反编译启动协程的代码:
BuildersKt.launch$default((CoroutineScope)LifecycleOwnerKt.getLifecycleScope(this), (CoroutineContext)null, (CoroutineStart)null, (Function2)(new Function2((Continuation)null) {
int label;
@Nullable
public final Object invokeSuspend(@NotNull Object $result) {
......
}
@NotNull
public final Continuation create(@Nullable Object value, @NotNull Continuation completion) {
Intrinsics.checkNotNullParameter(completion, "completion");
Function2 var3 = new <anonymous constructor>(completion);
return var3;
}
public final Object invoke(Object var1, Object var2) {
return ((<undefinedtype>)this.create(var1, (Continuation)var2)).invokeSuspend(Unit.INSTANCE);
}
}), 3, (Object)null);
在协程启动的反编译代码我们又看到了ininvokeSuspend
方法,这个方法又是在最下面创建了Continuation
,之后在invoke
中被调用,更多的信息是看不出来了。我们还是回到launch
源码内部里面去寻找答案。
public fun CoroutineScope.launch(
context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> Unit
): Job {
val newContext = newCoroutineContext(context)
val coroutine = if (start.isLazy)
LazyStandaloneCoroutine(newContext, block) else
StandaloneCoroutine(newContext, active = true)
coroutine.start(start, coroutine, block)
return coroutine
}
//coroutine.start
public fun <R> start(start: CoroutineStart, receiver: R, block: suspend R.() -> T) {
start(block, receiver, this)
}
当查看到start
这里的时候,你会发现跟进不下去了。那我们就换种方法,还是将这个类反编译下,你会看到变成了这样:
public final void start(@NotNull CoroutineStart start, Object receiver, @NotNull Function2 block) {
Intrinsics.checkNotNullParameter(start, "start");
Intrinsics.checkNotNullParameter(block, "block");
start.invoke(block, receiver, (Continuation)this);
}
//使用此协程启动策略将带有接收器的相应块作为协程启动
@InternalCoroutinesApi
public operator fun <R, T> invoke(block: suspend R.() -> T, receiver: R, completion: Continuation<T>): Unit =
when (this) {
DEFAULT -> block.startCoroutineCancellable(receiver, completion)
ATOMIC -> block.startCoroutine(receiver, completion)
UNDISPATCHED -> block.startCoroutineUndispatched(receiver, completion)
LAZY -> Unit // will start lazily
}
在这里又看到了我们熟悉的startCoroutineCancellable
,由于默认值为CoroutineStart.DEFAULT
,所以该方法会被调用。后面会怎么调用,应该很清楚了,最后会一路调用到invokeSuspend
方法,所以这时候就会执行到suspend{}
代码块里面,协程启动!
协程的挂起
调用到invokeinvokeSuspend
函数里面的代码的时候,我们单拎出出来看下:
//launch
public final Object invokeSuspend(@NotNull Object $result) {
Object var3 = IntrinsicsKt.getCOROUTINE_SUSPENDED();
Object var10000;
ButtonTextActivity var4;
switch(this.label) {
case 0:
ResultKt.throwOnFailure($result);
var4 = ButtonTextActivity.this;
this.label = 1;
var10000 = var4.dealA(this);
if (var10000 == var3) {
return var3;
}
break;
case 1:
ResultKt.throwOnFailure($result);
var10000 = $result;
break;
case 2:
ResultKt.throwOnFailure($result);
return Unit.INSTANCE;
default:
throw new IllegalStateException("call to 'resume' before 'invoke' with coroutine");
}
int num = ((Number)var10000).intValue();
var4 = ButtonTextActivity.this;
this.label = 2;
if (var4.dealB(num, this) == var3) {
return var3;
} else {
return Unit.INSTANCE;
}
}
这里涉及到了label
状态机的分析,当label
为0时,会调用case为0下面的代码。在里面label
被设置为了1,又调用了var4.dealA(this)
这个挂起函数,从前面挂起函数的分析知道其会返回COROUTINE_SUSPENDED
标志,所以var10000
也就会得到COROUTINE_SUSPENDED
标志,此时会被判断相等,协程会被挂起。
协程的恢复
挂起后就要恢复了。在前面执行到的dealA
方法中,在withContext
的时候会触发dealA
中的invokeSuspend
方法。此时label
被设置为1,所以会被调用到case为1的代码:
//dealA
public final Object invokeSuspend(@NotNull Object $result) {
Object var2 = IntrinsicsKt.getCOROUTINE_SUSPENDED();
switch(this.label) {
case 0:
ResultKt.throwOnFailure($result);
this.label = 1;
if (DelayKt.delay(3000L, this) == var2) {
return var2;
}
break;
case 1:
ResultKt.throwOnFailure($result);
break;
default:
throw new IllegalStateException("call to 'resume' before 'invoke' with coroutine");
}
return Unit.INSTANCE;
}
return Boxing.boxInt(1);
执行了ResultKt.throwOnFailure($result)
,最后返回int的值。同时launch中的invokeSuspend
也被执行,上面已经将label设置为1,这里就会执行到case 1下的代码:
//launch invokeSuspend
switch(this.label) {
......
case 1:
ResultKt.throwOnFailure($result);
var10000 = $result;
break;
}
int num = ((Number)var10000).intValue();
var4 = ButtonTextActivity.this;
this.label = 2;
if (var4.dealB(num, this) == var3) {
return var3;
} else {
return Unit.INSTANCE;
}
对结果进行了失败处理,此时var10000也就是刚刚得到的int值,接着执行suspend
剩余的代码,在下面将lable
设置为了2,开始执行dealB的方法。
在dealB
方法中,跟之前分析的步骤一样,也会回到invokeSuspend
中:
private final Object dealB(int num, Continuation $completion) {
Object var10000 = BuildersKt.withContext((CoroutineContext)Dispatchers.getIO(), (Function2)(new Function2((Continuation)null) {
int label;
@Nullable
public final Object invokeSuspend(@NotNull Object $result) {
......
return Unit.INSTANCE;
}
......
return var10000 == IntrinsicsKt.getCOROUTINE_SUSPENDED() ? var10000 : Unit.INSTANCE;
}
最后当没有挂起函数的时候,会返回Unit.INSTANCE
,结束协程执行。
小结
协程通过suspend
来标识挂起点,但真正的挂起点还需要通过是否返回COROUTINE_SUSPENDED
来判断,而代码体现是通过状态机来处理协程的挂起与恢复。
在挂起和恢复的过程中,当判断挂起函数到返回值是COROUTINE_SUSPENDED
标志时,会挂起,在需要挂起的时候,状态机会把之前的结果以成员变量的方式保存在 continuation
中。在挂起函数恢复的时候,会调用Continuation的resumeWith
方法,继而触发invokeSuspend
。根据保存在Continuation
中的label,进入不同的 分支恢复之前保存的状态,进入下一个状态。
在挂起的时候并不会阻塞当前的线程,是因为挂起是在invokeSuspend方法中return出去的,而invokeSuspend之外的函数当然还是会继续执行。
作者:罗恩不带土
链接:https://juejin.cn/post/7103311646591811598
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
OkHttp源码分析
大家好,我是小黑,一个还没秃头的程序员~~~
路是走出来的,而不是空想出来的。
相信大家找工作的时候都会被问及到Okhttp的原理以及源码分析,好记性不如烂笔头,所以这次我打算把它记录下来方便日后复习查看,也和大家分享一下,如果有什么不对之处还请大家多多指教!
这次的分析分为三个步骤:
- 网络请求发出去后到了哪里
- 请求是怎么被处理的
- 请求结束后又会做什么
(一)网络请求发出去后到了哪里
OkHttp发送请求有两种方式:enqueue/execute,在enqueue异步请求之前我们需要调用newCall() ,newCall() 返回的是RealCall对象
/**
* Prepares the {@code request} to be executed at some point in the future.
*/
@Override public Call newCall(Request request) {
return new RealCall(this, request, false /* for web socket */);
}
所以我们找到RealCall类中的enqueue()
@Override public void enqueue(Callback responseCallback) {
synchronized (this) {
if (executed) throw new IllegalStateException("Already Executed");
executed = true;
}
captureCallStackTrace();
client.dispatcher().enqueue(new AsyncCall(responseCallback));
}
可以看到最终是执行了dispatcher().enqueue() 来完成的,我们进入Dispatcher类中,Dispatcher类是用来实现任务调度的,主要有以下变量
//最大并发请求数
private int maxRequests = 64;
//每个主机的最大请求数
private int maxRequestsPerHost = 5;
//消费者线程池
private ExecutorService executorService;
//等待中的请求队列
private final Deque<AsyncCall> readyAsyncCalls = new ArrayDeque<>();
//正在运行的异步请求队列
private final Deque<AsyncCall> runningAsyncCalls = new ArrayDeque<>();
//正在运行的同步请求队列
private final Deque<RealCall> runningSyncCalls = new ArrayDeque<>();
在Dispatcher类中,有两个构造函数,一个有传入线程池,一个没有传入线程池,没有传入线程池的话会在异步请求之前创建一个默认的线程池
public Dispatcher(ExecutorService executorService) {
this.executorService = executorService;
}
public Dispatcher() {
}
//创建线程池,SynchronousQueue为一个没有容量的队列
public synchronized ExecutorService executorService() {
if (executorService == null) {
executorService = new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60, TimeUnit.SECONDS,
new SynchronousQueue<Runnable>(), Util.threadFactory("OkHttp Dispatcher", false));
}
return executorService;
}
前面讲到执行RealCall的enqueue() 便会最终到Dispatcher的enqueue() ,它的代码如下
synchronized void enqueue(AsyncCall call) {
if (runningAsyncCalls.size() < maxRequests && runningCallsForHost(call) < maxRequestsPerHost) {
runningAsyncCalls.add(call);
executorService().execute(call);
} else {
readyAsyncCalls.add(call);
}
}
当正在运行的异步请求队列中的数量小于64并且正在运行的请求主机数小于5时,把请求加载到runningAsyncCalls队列中中并在线程池中执行,否则就加入到readyAsyncCalls队列中进行等待。到此为止,第一个问题就解决了,请求最终会被发送到两个队列中,要么被执行要么等待。
(二)请求是怎么被处理的
任务被放进队列中后,任务是AsyncCall类,是继承于NamedRunnable的一个类,执行AsyncCall的execute() 方法,代码如下:
@Override protected void execute() {
boolean signalledCallback = false;
try {
Response response = getResponseWithInterceptorChain();
if (retryAndFollowUpInterceptor.isCanceled()) {
signalledCallback = true;
responseCallback.onFailure(RealCall.this, new IOException("Canceled"));
} else {
signalledCallback = true;
responseCallback.onResponse(RealCall.this, response);
}
} catch (IOException e) {
if (signalledCallback) {
// Do not signal the callback twice!
Platform.get().log(INFO, "Callback failure for " + toLoggableString(), e);
} else {
responseCallback.onFailure(RealCall.this, e);
}
} finally {
client.dispatcher().finished(this);
}
}
上面的代码中,getResponseWithInterceptorChain() 返回了response,并在相应的回调中返回,这是处理请求的地方,这里会添加一个拦截器链,如是否重定向,缓存拦截器,自定义拦截器等,代码如下:
Response getResponseWithInterceptorChain() throws IOException {
// Build a full stack of interceptors.
List<Interceptor> interceptors = new ArrayList<>();
//自定义拦截器
interceptors.addAll(client.interceptors());
//重定向拦截器
interceptors.add(retryAndFollowUpInterceptor);
//桥接拦截器,设置请求头中的属性的
interceptors.add(new BridgeInterceptor(client.cookieJar()));
//缓存拦截器,有缓存就会取缓存,没有缓存再去连接服务器
interceptors.add(new CacheInterceptor(client.internalCache()));
//连接拦截器
interceptors.add(new ConnectInterceptor(client));
if (!forWebSocket) {
interceptors.addAll(client.networkInterceptors());
}
//最后一个拦截器,会对服务器进行网络调用,在intercept()方法中构建头部以及body,并获取返回
interceptors.add(new CallServerInterceptor(forWebSocket));
Interceptor.Chain chain = new RealInterceptorChain(
interceptors, null, null, null, 0, originalRequest);
return chain.proceed(originalRequest);
}
接下来我们看代码最后面的proceed() 方法,这个方法是RealInterceptorChain这个类实现的,代码如下:
@Override public Response proceed(Request request) throws IOException {
return proceed(request, streamAllocation, httpCodec, connection);
}
我们接着点进去 ,在这里面会去做关于response的返回,在这里从拦截器列表中取出拦截器,并使用通过各个拦截器通过intercept()方法的实现来获取拦截器的调用返回,拦截器是按顺序取出来处理的,代码如下:
public Response proceed(Request request, StreamAllocation streamAllocation, HttpCodec httpCodec,
Connection connection) throws IOException {
...
// Call the next interceptor in the chain.
RealInterceptorChain next = new RealInterceptorChain(
interceptors, streamAllocation, httpCodec, connection, index + 1, request);
Interceptor interceptor = interceptors.get(index);
Response response = interceptor.intercept(next);
...
return response;
}
到这里,问题二已解决了,请求是在getResponseWithInterceptorChain() 获取返回的。
(三)请求结束后又会做什么
上面提到任务执行的时候会调用到AsyncCall的execute() 方法,方法最后会调用client.dispatcher().finished(this) ,我们点进finished() 方法看一下,代码如下:
void finished(AsyncCall call) {
finished(runningAsyncCalls, call, true);
}
private <T> void finished(Deque<T> calls, T call, boolean promoteCalls) {
int runningCallsCount;
Runnable idleCallback;
synchronized (this) {
if (!calls.remove(call)) throw new AssertionError("Call wasn't in-flight!");
if (promoteCalls) promoteCalls();
runningCallsCount = runningCallsCount();
idleCallback = this.idleCallback;
}
if (runningCallsCount == 0 && idleCallback != null) {
idleCallback.run();
}
}
关键在于promoteCalls() 方法,代码如下:
private void promoteCalls() {
if (runningAsyncCalls.size() >= maxRequests) return; // 运行中的请求以及到了最大的请求数
if (readyAsyncCalls.isEmpty()) return; // 没有等待中的请求了
for (Iterator<AsyncCall> i = readyAsyncCalls.iterator(); i.hasNext(); ) {
AsyncCall call = i.next();
if (runningCallsForHost(call) < maxRequestsPerHost) {
i.remove();
runningAsyncCalls.add(call);
executorService().execute(call);
}
if (runningAsyncCalls.size() >= maxRequests) return; // Reached max capacity.
}
}
从代码中可以看出,在一个请求结束后,会去对运行中的请求以及等待中的请求数进行判断,并将等待队列中的请求移除一个并添加到线程池中进行处理,即进入运行队列中处理,这就是请求结束后的内容了,到此为止,OkHttp源码分析的三个步骤就介绍完毕,日后我会接着分享自己在阅读源码的体会与总结,最后,希望喜欢我文章的朋友们可以帮忙点赞、收藏、评论,也可以关注一下,如果有问题可以在评论区提出,谢谢大家的支持与阅读!
作者:移动端开发_小黑
链接:https://juejin.cn/post/7102072027409809422
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
Kotlin浅析之Contract
在进行kotlin的项目开发中,我们依赖kotlin语法糖相比java可以更高效地产出,kotlin的彩蛋众多,这篇文章着重跟大家聊一聊Contract,其实Contract在官方函数中其实也有被多次使用,比如我们常用的let、apply、also、isNullOrEmpty等函数:
@kotlin.internal.InlineOnly
public inline fun <T> T.apply(block: T.() -> Unit): T {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
block()
return this
}
@kotlin.internal.InlineOnly
@SinceKotlin("1.1")
public inline fun <T> T.also(block: (T) -> Unit): T {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
block(this)
return this
}
@kotlin.internal.InlineOnly
public inline fun CharSequence?.isNullOrEmpty(): Boolean {
contract {
returns(false) implies (this@isNullOrEmpty != null)
}
return this == null || this.length == 0
}
接下来,我们来了解一下contract到底是什么以及怎么用?
一、Contract是什么?
contract翻译过来意思是"契约",那么既然是"契约",约定的双方又是谁?
“我”和"你",心连心,同住地球村?搞叉了,再来!
契约的双方实际上是"开发者'和"编译器" ,我们都知道,kotlin编译器有着智能推断自动类型转换的功能。但实际上,它的智能推断有时候并不那么智能,下面会讲到,而官方为开发者预留了一个通道去与编译器沟通,这就是contract存在的意义。
二、Contact怎么用?
首先,我们定义一个String常规的判空扩展函数
/**
* 字符串扩展函数判空,常规方式
* @receiver String? 接收类型
* @return Boolean 是否为空
*/
fun String?.isNullOrEmptyWithoutContract(): Boolean {
return this == null || this.isEmpty()
}
然后,我们来调用看看
/**
* 问题示例1 使用自定义函数判空,编译器无感知
* @param name String? 传入的姓名字符串
*/
private fun problemNull(name: String?) {
// 用常规方式的自定义扩展函数对局部变量判空
if (!name.isNullOrEmptyWithoutContract()) {
//name.length报错,自定义扩展函数中的判空逻辑未同步到编译器 Only safe (?.) or non-null asserted (!!.) calls are allowed on a nullable receiver of type String?
Log.d(TAG, "name:$name,length:${name.length}")
}
}
结果,在函数内部调用外部自定义字符串判空函数,不起作用,这是因为编译器并不知道这种间接的判空是不是有效的,而这时候,我们请出contract来表演看看:
判空扩展函数contract returns改造
/**
* 字符串扩展函数判空,contract方式
* @receiver String? 接收类型
* @return Boolean 是否为空
*/
@ExperimentalContracts
fun String?.isNullOrEmptyWithContract(): Boolean {
contract {
returns(false) implies (this@isNullOrEmptyWithContract != null)
}
return this == null || this.isEmpty()
}
/**
* 解决问题1 自定义函数判空后结果同步编译器
* @param name String? 传入的姓名字符串
*/
@ExperimentalContracts
fun fixProblemNull(name: String?) {
// 用contract方式的自定义扩展函数对局部变量判空
if (!name.isNullOrEmptyWithContract()) {
//运行正常
Log.d(TAG, "name:$name,length:${name.length}")
}
}
可以看到,判空扩展函数加入了contract之后,编译器就懂事了,但编译器是如何懂事的呢?contract内部到底跟编译器说了什么悄悄话?咱们先分析下判空扩展函数的代码
contract {
returns(false) implies (this@isNullOrEmptyWithContract != null)
}
被contract
所包裹的语句,实际上就是我们要告诉编译器的逻辑,这里的returns(false)
代表当前函数isNullOrEmptyWithContract()
的返回值也就是 return this == null || this.isEmpty()
如果是false,那么会告知编译器implies
后面的表达式也就是this@isNullOrEmptyWithContract != null
成立,也就是调用者对象String不为空,那么后面在打印name.length
的时候编译器就知道name不为空拉,这就是开发者与编译器的契约!
其次,我们发现除了resturns的用法外,常用的apply扩展函数里面的contract是callsInPlace形式,那么callsInPlace又是什么意思?
/**
* 定义apply函数,常规方式
* @receiver T 接收类型
* @param block [@kotlin.ExtensionFunctionType] Function1<T, Unit> 函数入参
* @return T 返回类型
*/
fun <T> T.applyWithoutContract(block: T.() -> Unit): T {
block()
return this
}
/**
* 问题示例2 函数执行变量初始化,编译器无感知
*/
fun problemInit() {
var name: String
// 用常规方式的自定义扩展函数对局部变量赋值
applyWithoutContract {
// 编译器实际上不知道这个函数入参有没有被调用
name = "WenChangJi"
}
// 报错 'Variable 'name' must be initialized'
Log.d(TAG, "name:${name}")
}
这里我们给间接给局部变量name去赋值,但是后续使用时编译器报错声称name没有初始化,采取以往经验,我们加入contract去改造试试:
/**
*
* 定义apply函数,contract方式
* @receiver T 接收类型
* @param block [@kotlin.ExtensionFunctionType] Function1<T, Unit> 函数入参
* @return T 返回类型
*/
@ExperimentalContracts
fun <T> T.applyWithContract(block: T.() -> Unit): T {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
block()
return this
}
/**
* 解决问题2 函数执行变量初始化后同步编译器
*/
@ExperimentalContracts
fun fixProblemInit() {
var name: String
// 用contract方式的自定义扩展函数对局部变量赋值
applyWithContract {
// applyWithContract内部契约告知编译器,这里绝对会调用一次的,也就一定会初始化
name = "WenChangJi"
}
// 运行正常
Log.d(TAG, "name:${name}")
}
这里我们并没有采用returns
告知编译器在满足什么条件下什么表达式成立,而是采用callsInPlace
方式告知编译器入参函数block的调用规则,callsInPlace(block, InvocationKind.EXACTLY_ONCE)
即是告诉编译器block在内部会被调用一次,也就是后续调用时的语句name = "WenChangJi"
会被调用一次进行赋值,那么在使用name时编译器就不会说没有初始化之类的问题拉!
callsInPlace内部次数的常量值由以下几种:
常量值 | 含义 |
---|---|
- InvocationKind.AT_MOST_ONCE | 最多调用一次 |
InvocationKind.AT_LEAST_ONCE | 最少调用一次 |
InvocationKind.EXACTLY_ONCE | 调用一次 |
InvocationKind.UNKNOWN | 未知 |
最后,咱们这边文章只是讲解了Contract是什么和怎么用的部分场景,还有更多的场景以及具体的原理有兴趣的同学可以深挖~
感谢大家的观看!!!
作者:苏打水08
链接:https://juejin.cn/post/7102300475243888647
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
Flutter【移动端】如何进行多渠道打包发布
随着项目的运营推广,总少不了各种客户定制化的需求,当前大部分软件其实都离不开Saas的玩法;定制化需求虽然利润高(
特别是海外客户
),但对于开发人员来说却比较难搞,同一套代码需要支持不同的需求。
一般我们处理这种需求的时候会引入渠道包的概念,每个客户拥有独立渠道,通过渠道指定不同的资源、赋予不同的功能,从而编译出定制化的版本。
本篇文章将分享Flutter中如何进行移动端(iOS、Android
)的渠道编译,替换应用图标、名称、appkey等。
Android端
1、配置build.grade
Android端的打包配置,主要是通过build.grade文件进行配置,在android目录下加入flavorDimensions
,然后配置不同的风味维度;
android {
// ......
flavorDimensions 'channel'
productFlavors {
develop {
applicationId "${defaultConfig.applicationId}"
}
customer {
applicationId "${defaultConfig.applicationId}" // 可替换成客户的AppID
}
productFlavors.all {
// 遍历productFlavors多渠道,设置渠道名称,在flutter层也能取到
flavor -> flavor.manifestPlaceholders.put("CHANNEL", name)
}
}
}
之后我们为每个渠道设置资源的名称,每个渠道有不同的资源,避免不相关的资源打包进去,增加包大小。
productFlavors {
// 省略,见上
}
// 为不同渠道指定不同资源文件配置
sourceSets {
main.java.srcDirs += 'src/main/kotlin'
// develop无指定就默认使用src/main/res
squatz.res.srcDirs 'src/main/res-customer'
}
2、配置mainfest
Mainfest在<application>
下扩展一个元数据,字段名取build.grade中的风味秒速channel
,字段值则是put出去的CHANNEL
。其他的都不需要改变,因为mainfest所引用到的资源名称我们都没有改变。
<application>
<!-- 多渠道打包 -->
<meta-data
android:name="channel"
android:value="${CHANNEL}" />
</application>
3、新增对应资源
由于Mainfest的变量名没有变过,因此新增资源的名称就需要跟res中的保持一致。
4、打包编译
flutter build apk --flavor Customer --obfuscate --split-per-abi
打包命令非常简单,指定flavor为build.grade中配置的渠道名称即可,注意首字母大写!
iOS端
笔者并无iOS的实际开发经验,对iOS并不熟悉;但网上对这块的记录真的是少之又少,所以还是决定记录下来,接下来的内容虽成功实践过,但未必是最佳方法,欢迎大家一起交流。
1、分发Target
Target其实是贯穿iOS整个开发过程的,无论是运行目标还是UI控制器,都离不开target;Target
是工程编译的目标,其会继承Project
的编译设置,并可重新设置自己的编译配置,比如Build Setting
与Build Phases
。
- 新建Target,直接在原target右键分发一个出来,默认会复制原target的所有配置。
- 修改应用信息,注意图标、应用名称等资源另起一个文件夹去配置。
- 打包
自此iOS就有了多个打包目标,非常简单。这也是iOS体系开发比较好的一点,没有太多花里胡哨的玩法,跟着文档配置就好了。
flutter打包命令:flutter build ipa --flavor Customer --release
- 遇到问题
目前我们遇到如下问题,配置好后在flutter层执行flutter build ios --flavor Customer --release后,会导致xcode重新build项目,然后pod_Runner的动态依赖丢失,但是在xcode中执行又不会。
Flutter端区分渠道
在打包的时候我们可以使用参数-dart-define=CHANNEL=XXXX
,其中CHANNEL是参数key,xxxx是name,然后在flutter中使用String.fromEnvironment('CHANNEL', defaultValue: 'develop');
,即可获取到key为CHANNEL的值。
作者:Karl_wei
链接:https://juejin.cn/post/7105712170746249230
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
内存如何记录方法调用和返回过程
全文分为 视频版 和 文字版,
视频版:
通过语音和动画,能够更加直观的看到,内存记录方法调用和返回过程。
文字版
我们在写代码的时候有没有思考过 方法如何调用 、 方法执行完之后如何返回 、 内存如何记录方法调用过程 。而这也是今天这篇文章重点内容。
方法调用和返回过程涉及到了,虚拟机栈、程序计数器、局部变量表、操作数栈、方法返回地址、动态链接等等内容,涉及到知识点很多,同时这些内容也是高频面试题,所以我将拆分成多篇文章,针对每个知识点去做详细的分析。而今天这篇文章我们重点去看内存是如何记录方法调用和返回过程。
虚拟机栈
Java 方法以栈帧的形式,运行在虚拟机栈(Java 栈)中,栈是线程私有的,程序启动的时候,会创建一个 main 线程,操作系统会为每一个线程分配一段内存,线程创建的时候会创建一个虚拟机栈,虚拟机栈的生命周期和线程一样,线程结束了,虚拟机栈也销毁了。
每个 Java 方法,对应一个个栈帧,所以方法开始和结束,都是一个个栈帧入栈和出栈的过程,效果如下图所示。
栈帧
每个 Java 方法,都是一个个栈帧,每个栈帧包括了:局部变量表、操作数栈、方法返回地址、动态链接、附加信息。
- 局部变量表: 保存方法参数列表和方法内的局部变量,按照声明的顺序存储,它以数组的形式展示,如果是实例方法,索引为 0 是 this 的引用,如下图所示。
索引(Slot) | 名字(Name) |
---|---|
0 | this |
1 | num |
2 | res |
- 操作数栈: 保存方法执行过程中的临时结果
- 返回地址: 保存调用该方法的 pc 寄存器的值(即 JVM 指令地址),用于方法结束时,返回调用处,让调用者方法继续执行下去
- 动态链接: 指向运行时常量池中该栈帧所属方法的引用,即从常量池中找到目标方法的符号引用,然后转换为直接引用
附加信息:比如程序 debug 时添加的一些附件信息(不重要,不需要关心,可忽略)
这里只需要知道它们的作用即可,它们的数据结构、字节码的含义、执行过程等等,后续的文章我将会针对每个知识点去做详细的分析。
方法调用过程
先写一段方法调用的代码,首先会调用 main()
方法之后调用 fun1()
然后调用 fun2()
,如下图所示。
现在我们来演示一下 Java 虚拟机执行这些 JVM 指令的过程,首先会调用 main () 方法。
main () 方法
main()
方法执行流程动画效果如下所示。
- 执行指令
0: aload_0
,从局部变量表中,读取索引为 0 的值,压入操作数栈中,因为是实例方法,所以索引为 0 的值是 this 的引用
- 执行指令
1: iconst_5
,将常量 5 压入操作数栈中
- 执行指令
2: invokevirtual #7
,常量 5 和 this 从操作数栈中出栈,然后调用this.fun1(5)
首先从常量池中找到方法 fun1()
的符号引用,然后通过动态链接将符号引用转换成直接引用,之后调用 this.fun1(5)
,将方法 fun1()
作为栈帧压入虚拟机栈中,跳转到 fun1()
,继续往下执行。
如何从常量池中找到目标方法的符号引用,然后转换成直接引用的过程,将会在后面系列文章中详细分析
在调用 fun1()
之前,fun1()
的局部变量表和方法返回地址已经确定好了。
进入方法 fun1 (int num)
方法 fun1(int num)
执行流程动画效果如下所示。
- 执行指令
0: aload_0
,从局部变量表中,读取索引为 0 的值,压入操作数栈中。因为是实例方法,所以索引为 0 的值是 this 的引用
- 执行指令
1: iload_1
,从局部变量表中,读取索引为 1 变量 num 的值,并压入操作数栈
- 执行指令
2: invokevirtual #13
,num 和 this 从操作数栈中出栈,然后调用this.fun2(num)
首先从常量池中找到方法 fun2()
的符号引用,然后通过动态链接将符号引用转换成直接引用,之后调用 this.fun2(num)
,将方法 fun2()
作为栈帧压入虚拟机栈中,跳转到 fun2()
,继续往下执行,调用 fun2()
之前,fun2()
的局部变量表和方法返回地址已经确定好了。
进入方法 fun2 (int num)
方法 fun2(int num)
执行流程动画效果如下所示。
- 执行指令
0: iload_1
,从局部变量表中,读取索引为 1 变量 num 的值,并压入操作数栈中
- 执行指令
1: bipush
,将常量 10 压入操作数栈中
- 执行指令
3: iadd
,num 和常量 10 从操作数栈中出栈,然后进行相加,将结果压入操作数栈中
- 执行指令
4: istore_2
,从操作数栈中取出结果,并赋值给局部变量表中索引为 2 的变量 res2
- 执行指令
5: iload_2
,从局部变量表中,读取索引为 2 的变量 res2 的值,并压入操作数栈中
方法退出过程
每个方法即是一个栈帧,每个栈帧会保存方法返回地址,执行 return
系列指令时,会根据当前栈帧保存的返回地址,返回到调用的位置,继续往下执行。因此会分为两种情况。
异常退出
如果出现了异常且捕获了该异常,则会从异常表里查找 PC 寄存器的地址(JVM 指令地址),返回调用处继续执行。
正常退出时会做以下件事
- 恢复上一个栈帧局部变量表、操作数栈
- 如果有返回值,将返回值压入调用者栈帧的操作数栈,是否有返回值根据
return
JVM 指令:
ireturn
:返回值是boolean
、byte
、char
、short
和int
类型lreturn
:返回值是Long
类型freturn
:返回值是Float
类型dreturn
:返回值是Double
类型areturn
:返回值是引用类型return
:返回值类型为void
- 设置调用者栈帧的 JVM 指令地址
- 当前栈帧从虚拟机栈中出栈
这篇文章我们只分析正常退出流程,异常退出和正常退出的流程大致都是一样的。正常退出流程动画效果如下所示。
- 方法
fun2
结束时,执行最后一条指令ireturn
,当前栈帧从虚拟机栈中出栈,根据当前栈帧保存的方法返回地址,返回到 fun1 ,恢复 fun1 的局部变量表、操作数栈,将返回结果res2
保存到调用者(fun1)操作数栈中
- 回到方法
fun1(int num)
,执行指令5: istore_2
,变量res2
从操作数中出栈,赋值给局部变量表中索引为 2 的变量res1
- 执行指令
6: iload_2
,从局部变量表中,读取索引为 2 变量res1
的值,并压入操作数栈中
- 执行方法 fun1 最后一条指令
7: ireturn
,当前栈帧从虚拟机栈中出栈,根据当前栈帧保存的方法返回地址,返回到 main 方法,恢复 main 方法的局部变量表、操作数栈,将返回结果res2
保存到调用者(main)操作数栈中
- 最后回到方法
main
中,执行最后一条指令5: pop
,操作数栈中的元素出栈
- 执行最后一条指令
6: return
,至此所有的方法调用结束了,方法所占用的内存,也将返回给系统
每次的方法调用,即是栈帧入栈和出栈的过程,同时也需要占用部分内存,用于保存局部变量表、操作数栈、方法返回地址、动态链接、附加信息等等。
当方法执行完,即栈帧出栈,方法调用所占用的内存,也将返回给系统。
作者:DHL
链接:https://juejin.cn/post/7106378870135357454
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
kotlin - 你真的了解 by lazy吗
背景
kotlin中的语法糖by lazy相信都有用过,但是这里面的秘密却很少有人深究下去,还有网上充斥着大量的文章,却很少能说到本质的点上,所以本文以字节码的视角,揭开by lazy的秘密。
一个例子
class LazyClassTest {
val lazyTest :Test by lazy {
Log.i("hello","初始化") 1
Test()
}
fun test(){
Log.i("hello","$lazyTest")
Log.i("hello","$lazyTest")
}
}
如果执行test方法,请问代号为1的log会输出几次呢?答案是1次,明明我们在test方法中执行了两次lazyTest的获取,这其中有什么不为人知的事情吗!?其实这是kotlin在编译的时候给我们施加了魔法。
编译器背后的事情
为了看清楚编译器的事情,我们直接查看编译后的字节码,这里贴出来,后面解释
删除不必要的信息
// access flags 0x18
final static INNERCLASS com/example/newtestproject/LazyClassTest$lazyTest$2 null null
// access flags 0x12
private final Lkotlin/Lazy; lazyTest$delegate
@Lorg/jetbrains/annotations/NotNull;() // invisible
// access flags 0x1
public <init>()V
L0
LINENUMBER 5 L0
ALOAD 0
INVOKESPECIAL java/lang/Object.<init> ()V
L1
LINENUMBER 7 L1
ALOAD 0
GETSTATIC com/example/newtestproject/LazyClassTest$lazyTest$2.INSTANCE : Lcom/example/newtestproject/LazyClassTest$lazyTest$2;
CHECKCAST kotlin/jvm/functions/Function0
INVOKESTATIC kotlin/LazyKt.lazy (Lkotlin/jvm/functions/Function0;)Lkotlin/Lazy;
PUTFIELD com/example/newtestproject/LazyClassTest.lazyTest$delegate : Lkotlin/Lazy;
L2
LINENUMBER 5 L2
RETURN
L3
LOCALVARIABLE this Lcom/example/newtestproject/LazyClassTest; L0 L3 0
MAXSTACK = 2
MAXLOCALS = 1
// access flags 0x11
public final getLazyTest()Lcom/example/newtestproject/Test;
@Lorg/jetbrains/annotations/NotNull;() // invisible
L0
LINENUMBER 7 L0
ALOAD 0
GETFIELD com/example/newtestproject/LazyClassTest.lazyTest$delegate : Lkotlin/Lazy;
ASTORE 1
ALOAD 1
INVOKEINTERFACE kotlin/Lazy.getValue ()Ljava/lang/Object; (itf)
CHECKCAST com/example/newtestproject/Test
L1
LINENUMBER 7 L1
ARETURN
L2
LOCALVARIABLE this Lcom/example/newtestproject/LazyClassTest; L0 L2 0
MAXSTACK = 1
MAXLOCALS = 2
// access flags 0x11
public final test()V
L0
LINENUMBER 13 L0
LDC "hello"
ALOAD 0
INVOKEVIRTUAL com/example/newtestproject/LazyClassTest.getLazyTest ()Lcom/example/newtestproject/Test;
INVOKESTATIC java/lang/String.valueOf (Ljava/lang/Object;)Ljava/lang/String;
INVOKESTATIC android/util/Log.i (Ljava/lang/String;Ljava/lang/String;)I
POP
L1
LINENUMBER 14 L1
LDC "hello"
ALOAD 0
INVOKEVIRTUAL com/example/newtestproject/LazyClassTest.getLazyTest ()Lcom/example/newtestproject/Test;
INVOKESTATIC java/lang/String.valueOf (Ljava/lang/Object;)Ljava/lang/String;
INVOKESTATIC android/util/Log.i (Ljava/lang/String;Ljava/lang/String;)I
POP
L2
LINENUMBER 15 L2
RETURN
L3
LOCALVARIABLE this Lcom/example/newtestproject/LazyClassTest; L0 L3 0
MAXSTACK = 2
MAXLOCALS = 1
我们惊讶的发现,原本的类中居然多出了一个内部类com/example/newtestproject/LazyClassTest
2,命名这么长!没错,它就是编译的时候生成的“魔法的种子”,那么这里内部类有什么特别的地方吗?字节码层面是看不出来的,因为这个这只是编译时期的内容,我们在虚拟机运行的时候来看,它其实是一个实现了一个接口是Lazy的内部类public interface Lazy<out T> {
public abstract val value: T
public abstract fun isInitialized(): kotlin.Boolean
}
lazy背后的延时加载
为什么用了lazy就有懒加载的效果呢?其实关键就是这个,我们在init阶段可以看到
getstatic 'com/example/newtestproject/LazyClassTest$lazyTest$2.INSTANCE','Lcom/example/newtestproject/LazyClassTest$lazyTest$2;'
checkcast 'kotlin/jvm/functions/Function0'
INVOKESTATIC kotlin/LazyKt.lazy (Lkotlin/jvm/functions/Function0;)Lkotlin/Lazy;
putfield 'com/example/newtestproject/LazyClassTest.lazyTest$delegate','Lkotlin/Lazy;'
在初始化的时候,只是调用了kotlin/LazyKt.lazy类的一个静态方法,针对属性复制的putfield指令,也只是对LazyClassTest.lazyTest$delegate这个内部类的一个Lkotlin/Lazy对象进行赋值,看起来其实跟我们的lazyTest变量毫无关系。真相是lazyTest具体的赋值操作被隐藏了而已。从这里就可以看到,为什么lazy是如何实现延时加载的!本质就是在初始化的时候只是生成一个内部类,不进行任何对目标对象进行赋值操作罢了!
获取操作
我们再观察一下对于lazyTest变量的访问操作,从字节码看到,每次对变量的获取都调用了LazyClassTest的getLazyTest方法!这个也是编译器生成的方法,具体可以看到
public final com.example.newtestproject.Test getLazyTest() {
aload 0
getfield 'com/example/newtestproject/LazyClassTest.lazyTest$delegate','Lkotlin/Lazy;'
astore 1
aload 1
INVOKEINTERFACE kotlin/Lazy.getValue ()Ljava/lang/Object; (itf)
checkcast 'com/example/newtestproject/Test'
areturn
}
天呐!我们越来越接近终点了,首先是通过getfield指令获取了一个Lkotlin/Lazy变量,这个不就是上面我们赋值的东西吗!然后调用了一个普通的方法getValue就结束了,也就是说,每次对lazyTest变量的访问,都间接转发到了一个编译时生成的内部类中的一个特殊属性所调用的方法!看到这个,读者可能会思考,既然每次访问都是调用同一个方法,为什么我们by lazy时声明的lambad会只执行一次呢?编译时的字节码已经不能给我们带来答案了,这个因为像java虚拟机这种,关于具体类的调用会在运行时确定这个特性所带来的(区别于c/cpp)。
运行时的魔法
那好,我们还有最后一个神奇,就是debug,我们最终会发现,在运行时by lazy的调用,其实最终都会转到如下代码的执行
private class SynchronizedLazyImpl<out T>(initializer: () -> T, lock: Any? = null) : Lazy<T>, Serializable {
private var initializer: (() -> T)? = initializer
@Volatile private var _value: Any? = UNINITIALIZED_VALUE
// final field is required to enable safe publication of constructed instance
private val lock = lock ?: this
override val value: T
get() {
val _v1 = _value
if (_v1 !== UNINITIALIZED_VALUE) {
@Suppress("UNCHECKED_CAST")
return _v1 as T
}
return synchronized(lock) {
val _v2 = _value
if (_v2 !== UNINITIALIZED_VALUE) {
@Suppress("UNCHECKED_CAST") (_v2 as T)
} else {
val typedValue = initializer!!()
_value = typedValue
initializer = null
typedValue
}
}
}
这个类位于LazyJVM中(kotlin1.5.10),我们就找到最终的秘密了,原来一开始的时候变量就是UNINITIALIZED_VALUE,经过一次赋值操作后,就会变成实际的T所指代的类型,下次再访问的时候,就直接满足if条件返回了!所以这就是一次赋值的秘密!还有我们可以看到,默认的by lazy操作第一次赋值时,是采用了synchronized进行了加锁操作!
总结
我们已经全方位揭秘了by lazy的魔法面纱,相信也对这个语法糖有了自己更深的理解,之所以写这篇文,是因为好多网上资料要么是含糊不清要么是无法解释本质,这里作为一个记录分享
作者:Pika
链接:https://juejin.cn/post/7106320684275482631
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
Android自定义评分控件
无意中翻到几年前写过的一个RatingBar
,可以拖拽,支持自定义星星图片,间距大小等参数。
自定义参数
为了方便扩展,支持更多的样式,这里将大部分参数设置成支持外部可配置的形式。
<declare-styleable name="RatingBarPlus">
<attr name="hideImageResource" format="reference"/>
<attr name="showImageResource" format="reference"/>
<attr name="starSpace" format="dimension"/>
<attr name="maxStar" format="integer"/>
<attr name="stepSize" format="float"/>
<attr name="rating" format="float"/>
<attr name="starWidth" format="dimension"/>
<attr name="starHeight" format="dimension"/>
</declare-styleable>
- hideImageResource 暗星星图片id
- showImageResource 亮星星图片id
- starSpace 星星间距
- maxStar 星星最大个数
- stepSize 评分步长,即能不能选中0.1个星
- rating 默认评分
- starWidth 星星宽度
- starHeight 星星高度
解析参数
创建星星位图的时候需要根据配置的大小和图片本身的宽高进行缩放。
绘制
绘制完成之后我们就可以动态设置评分来回显之前的评分,但是经常我们需要与控件交互,动态地设置分数,所以我们还需要重写onTouchEvent
方法完成事件处理。
事件处理
评分需要随着手指的移动而动态变化,这里我们记录下当前手指所在的位置,如果在星星上面,就算出当前位置距离星星左边的长度占据整个星星宽度的百分比,然后根据设置的stepSize
参数动态微调总评分。
评分监听
我们还需要将评分暴露给外部,处理主动调用getRating()方法获取之外,我们还可以提供一个监听接口,实时提供回调。
功能事件比较简单,只需要在事件处理的时候,微调总评分完成之后回调一下数据就可以了。
if (onRatingChangeListener != null) {
onRatingChangeListener.onRatingChange(rating);
}
外部使用
ratingBar.setOnRatingChangeListener{
ratingText.text = "当前评分:${it}"
}
作者:任他明月下西楼
链接:https://juejin.cn/post/7102048576607354917
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
Gradle 渠道包配置
Gradle 渠道包配置
安卓项目中默认使用gradle作为构建工具,gradle默认提供了很多Task,开发者也可以自己新建Task构建脚本,让打包、开发达到事半功倍的效果。这篇文章主要讲解安卓项目中常见的打包脚本。
基本任务
用gradle创建一个简单的输出脚本。
- 在安卓项目的
build.gradle
中的android{}
中添加以下脚本
task myTask{
println 'this is my task'
}
- 点击
Sync Now
之后,在Terminal
中运行
./gradlew myTask
就可以打印出'this is my task',不仅仅是使用命令,也可以在开发工具AndroidStuido右侧的Gradle
中找到Task -> Other -> myTask
,点击运行也是一样的效果。
常见任务
渠道包配置
同一套代码可以打包出多个应用程序,它们的包名不同、图标不同、应用名称不同,这样就可以一个手机上共存多个应用程序。
如何操作:
- 在app的build.gradle文件的android{}标签内
productFlavors {
// 产品版本1
product1 {
applicationId "com.android.application1"
manifestPlaceholders = [app_name:"产品1", app_ico: "@mipmap/ico1"]
}
// 产品版本2
product2 {
applicationId "com.android.application2"
manifestPlaceholders = [app_name:"产品2", app_ico: "@mipmap/ico2"]
}
// 产品版本3
product3 {
applicationId "com.android.application3"
manifestPlaceholders = [app_name:"产品3", app_ico: "@mipmap/ico3"]
}
}
product1、product2、product3是指不同的版本,applicationId
对应的包名,manifestPlaceholders
中的app_name
、app_ico
代表的是应用名称和应用图标。
- 相应的让应用名称和应用图标生效,还需要在
AndroidManifest.xml
中添加“变量”
<application
android:icon="${app_ico}"
android:label="${app_name}"
android:roundIcon="${app_ico}"
>
- 在android标签内
defaultConfig
标签下添加
flavorDimensions "XXX"
flavorDimensions
比较特殊,有多维度的理解,比如
A公司的A渠道产品,A公司的B渠道产品,B公司的A渠道产品,B公司的B渠道产品
详细了解可以看这篇文章flavorDimensions
为渠道添加动态变量
添加buildConfigField
的内容
productFlavors {
// 产品版本1
product1 {
applicationId "com.android.application1"
manifestPlaceholders = [app_name:"产品1", app_ico: "@mipmap/ico1"]
buildConfigField "String","FLAVOR_NAME","\"product111\""
}
// 产品版本2
product2 {
applicationId "com.android.application2"
manifestPlaceholders = [app_name:"产品2", app_ico: "@mipmap/ico2"]
buildConfigField "String","FLAVOR_NAME","\"product222\""
}
// 产品版本3
product3 {
applicationId "com.android.application3"
manifestPlaceholders = [app_name:"产品3", app_ico: "@mipmap/ico3"]
buildConfigField "String","FLAVOR_NAME","\"product333\""
}
}
添加完成之后Rebuild Project
,然后在Activity中就使用BuildConfig.FLAVOR_NAME
可以进行判断使用了。
作者:Android唐浮
链接:https://juejin.cn/post/7104903667244859406
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
Java泛型详解
1 为什么需要泛型?
示例1:
/**
* @Description: 不使用泛型
* @CreateDate: 2022/3/18 3:08 下午
*/
public class NoGeneric {
private int addInt(int x, int y) {
return x + y;
}
private float addFloat(float x, float y) {
return x + y;
}
public static void main(String[] args) {
NoGeneric noGeneric = new NoGeneric();
System.out.println(noGeneric.addInt(1, 2));
System.out.println(noGeneric.addFloat(1f, 2f));
}
}
实际开发中,经常有数值类型求和的需求,例如实现int
类型的加法, 有时候还需要实现float
类型的求和, 如果还需要float
类型的求和,需要重新在重载一个输入是float
类型的add
方法。每种类型的数据都需要重载一个方法,非常繁琐。如果使用泛型,就可以只定义一个方法。
示例2:
不使用泛型
private static void noGeneric() {
List list = new ArrayList<>();
list.add("x");
list.add("y");
list.add(1);//不使用泛型,List中可以添加任何类型的元素,但是获取数据的时候会报错
for (int i = 0; i < list.size(); i++) {
String str = (String) list.get(i);
System.out.println(str);
}
}
定义了一个List
类型的集合,先向其中加入了两个字符串类型的值,随后加入一个Integer
类型的值。这是完全允许的,因为此时List
默认的类型为Object
类型。在之后的循环中,由于忘记了之前在List
中也加入了Integer
类型的值或其他编码原因,很容易出现类强转错误。因为编译阶段正常,而运行时会出现“java.lang.ClassCastException”异常。因此,导致此类错误编码过程中不易发现。
运行后会报类型转换异常:
使用泛型,编译器就会提示类型不匹配。
在如上的编码过程中,我们发现主要存在两个问题:
1.当我们将一个对象放入集合中,集合不会记住此对象的类型,当再次从集合中取出此对象时,改对象的编译类型变成了Object
类型,但其运行时类型任然为其本身类型。
2.因此,取出集合元素时需要人为的强制类型转化到具体的目标类型,且很容易出现“java.lang.ClassCastException”异常。
所以泛型的好处就是:
- 适用于多种数据类型执行相同的代码
- 泛型中的类型在使用时指定,不需要强制类型转换,避免了可能出现的类型转换异常
2 泛型类和泛型接口
泛型,即“参数化类型”。一提到参数,最熟悉的就是定义方法时有形参,然后调用此方法时传递实参。那么参数化类型怎么理解呢?
顾名思义,就是将类型由原来的具体的类型参数化,类似于方法中的变量参数,此时类型也定义成参数形式(可以称之为类型形参),然后在使用/调用时传入具体的类型(类型实参)。
泛型的本质是为了参数化类型(在不创建新的类型的情况下,通过泛型指定的不同类型来控制形参具体限制的类型)。也就是说在泛型使用过程中,操作的数据类型被指定为一个参数,这种参数类型可以用在类、接口和方法中,分别被称为泛型类、泛型接口、泛型方法。
引入一个类型变量T(其他大写字母都可以,不过常用的就是T,E,K,V等等),并且用<>括起来,并放在类名的后面。泛型类是允许有多个类型变量的。
泛型类
/**
* @Description: 泛型类
* @CreateDate: 2022/3/18 3:51 下午
*/
public class NormalGenericClass<T> {
private T data;
public T getData() {
return data;
}
public void setData(T data) {
this.data = data;
}
public static void main(String[] args) {
NormalGenericClass<String> normalGenericClass = new NormalGenericClass<>();
normalGenericClass.setData("A");
System.out.println(normalGenericClass.getData());
NormalGenericClass normalGenericClass2 = new NormalGenericClass();
normalGenericClass2.setData(1);
normalGenericClass2.setData("xyz");
}
}
/**
* @Description: 泛型类
* @CreateDate: 2022/3/18 3:58 下午
*/
public class NormalGenericClass2<T, R> {
private T data;
private R result;
public NormalGenericClass2() {
}
public NormalGenericClass2(T data) {
this();
this.data = data;
}
public NormalGenericClass2(T data, R result) {
this.data = data;
this.result = result;
}
public T getData() {
return data;
}
public void setData(T data) {
this.data = data;
}
public R getResult() {
return result;
}
public void setResult(R result) {
this.result = result;
}
public static void main(String[] args) {
NormalGenericClass2<String, Integer> normalGenericClass = new NormalGenericClass2<>();
normalGenericClass.setData("A");
System.out.println(normalGenericClass.getData());
normalGenericClass.setResult(1);
System.out.println(normalGenericClass.getResult());
}
}
泛型接口
而实现泛型接口的类,有两种实现方法:
先写一个泛型接口:
public interface IGeneric<T> {
T next();
}
1、未传入泛型实参
public class GenericImpl<T> implements IGeneric<T> {
private T data;
public T getData() {
return data;
}
public void setData(T data) {
this.data = data;
}
@Override
public T next() {
return data;
}
}
使用的时候需要指定具体类型:
GenericImpl<String> genericImpl = new GenericImpl<>();
genericImpl.setData("A");
System.out.println(genericImpl.getData());
2、传入泛型实参
public class GenericImpl2 implements IGeneric<String> {
@Override
public String next() {
return "Hello World";
}
}
使用的时候和普通类一样:
GenericImpl2 genericImpl2 = new GenericImpl2();
System.out.println(genericImpl2.next());
3 泛型方法
public class GenericMethod {
/**
* 方法名前边的T表示返回类型
*/
public <T> T genericMethod(T... t) {
return t[t.length / 2];
}
public static void main(String[] args) {
GenericMethod genericMethod = new GenericMethod();
System.out.println(genericMethod.genericMethod("A", "B", "C"));
System.out.println(genericMethod.genericMethod(11, 22, 33));
}
}
泛型方法,是在调用方法的时候指明泛型的具体类型 ,泛型方法可以在任何地方和任何场景中使用,包括普通类和泛型类。注意泛型类中定义的普通方法和泛型方法的区别。
/**
* @Description: 泛型类中普通方法和泛型方法
* @CreateDate: 2022/3/18 4:57 下午
*/
public class Generic<T> {
private T t;
public Generic(T t) {
this.t = t;
}
/**
* 普通方法
* 虽然此方法中使用了泛型,但这只是一个普通方法,只是它的返回值是泛型类中已经声明的泛型。
*
* @return T
*/
public T getData1() {
return t;
}
/**
* 泛型方法
* 在public和返回类型之间的<T>必不可少。表明了这是一个泛型方法。
*/
public <T> T getData(T t) {
return t;
}
}
4 限定类型变量
有时候,我们需要对类型变量加以约束,比如计算两个变量的最小,最大值。
这个方法需要确保传入的两个变量有compareTo
方法,那么就需要将T限制为实现了Comparable
的类,如下所示:
public static <T extends Comparable> T min(T a, T b) {
if (a.compareTo(b) > 0) {
return a;
} else {
return b;
}
}
T表示应该绑定类型的子类型,Comparable
表示绑定类型,子类型和绑定类型可以是类也可以是接口。如果这个时候,我们试图传入一个没有实现接口Comparable
的类的实例,将会发生编译错误。
同时extends
左右都允许有多个,如 K,T extends Comparable & Serializable
。注意限定类型中,只允许有一个类,而且如果有类,这个类必须是限定列表的第一个。这种类的限定既可以用在泛型方法上也可以用在泛型类上。
/**
* 限定一个接口
*/
public static <T extends Comparable> T min(T a, T b) {
if (a.compareTo(b) > 0) {
return a;
} else {
return b;
}
}
/**
* 限定多个接口
*/
public static <T extends Comparable & Serializable> T min2(T a, T b) {
if (a.compareTo(b) > 0) {
return a;
} else {
return b;
}
}
/**
* extends左侧也可以定义多个泛型
*/
public static <K, T extends Comparable & Serializable> T min3(T a, T b) {
if (a.compareTo(b) > 0) {
return a;
} else {
return b;
}
}
/**
* 如果限定类型中有类,类只能有一个,且必须放在第一个。比如该方法中的ArrayList类限定
*/
public static <K, T extends ArrayList & Comparable & Serializable> T min3(T a, T b) {
if (a.compareTo(b) > 0) {
return a;
} else {
return b;
}
}
5 泛型中的约束和局限性
首先定义泛型类:
public class GenericRestrict<T> {
//...
}
5.1 不能用基本类型实例化参数
//不能用基本数据类型实例化类型参数
//GenericRestrict<double> genericRestrict=new GenericRestrict<>();//不允许
GenericRestrict<Double> genericRestrict = new GenericRestrict<>();
5.2 运行时查询类型只适用于原始类型
//运行时查询类型只适用于原始类型
// if (genericRestrict instanceof GenericRestrict<Double>)//不允许
// if (genericRestrict instanceof GenericRestrict<T>) //不允许
GenericRestrict<String> stringGenericRestrict = new GenericRestrict<>();
System.out.println(genericRestrict.getClass() == stringGenericRestrict.getClass());
System.out.println(genericRestrict.getClass().getName());
System.out.println(stringGenericRestrict.getClass().getName());
运行结果:
true
site.exciter.learn.generic.GenericRestrict
site.exciter.learn.generic.GenericRestrict
5.3 泛型类的静态上下文中类型变量失效
//静态域或方法里不能引用类型变量
// private static T instance;//不允许
// private static T getInstance1(){//不允许
// return null;
// }
//静态方法 本身是泛型方法的话可以
private static <T> T getInstance2() {
return null;
}
不能在静态域或方法中引用类型变量。因为泛型是要在对象创建的时候才知道是什么类型的,而对象创建的代码执行先后顺序是static
的部分,然后才是构造函数等等。所以在对象初始化之前static
的部分已经执行了,如果你在静态部分引用的泛型,那么毫无疑问虚拟机根本不知道是什么东西,因为这个时候类还没有初始化。
5.4 不能创建参数化类型的数组
//不能创建参数化类型的数组
GenericRestrict<Float>[] arrayGenericRestrict;//允许
// GenericRestrict<Float>[] restricts=new GenericRestrict<Float>[10];//不允许
5.5 不能实例化类型变量
private T data;
//不能实例化类型变量
// public GenericRestrict() {
// this.data = new T();
// }
5.6 不能捕获泛型类的实例
public class GenericExceptionRestrict {
//泛型类型不能extends Exception/Throwable
// private class Problem<T> extends Exception{}
//不能捕获泛型类对象
// public <T extends Throwable> void doWork(T t){
// try {
//
// }catch (T e){
//
// }
// }
//但可以这样写
public <T extends Throwable> void doWork(T t) throws T {
try {
} catch (Throwable e) {
throw t;
}
}
}
6 泛型类的继承规则
定义一个游戏类和它的子类LOL:
public class Game {
private String name;
private String type;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getType() {
return type;
}
public void setType(String type) {
this.type = type;
}
}
public class LOL extends Game{
}
定义一个泛型类:
public class Pair<T> {
private T one;
private T two;
//...
}
那么Pair<Game>和Pair<LOL>
有继承关系吗?
他们没有任何继承关系。
Game game = new LOL();
// Pair<Game> gamePair2=new Pair<LOL>();//不允许
但是泛型类可以继承或者扩展其他泛型类:
private static class ExtendPair<T> extends Pair<T> {
}
Pair<Game> gamePair3 = new ExtendPair<>();
完整代码:
public class Pair<T> {
private T one;
private T two;
public T getOne() {
return one;
}
public void setOne(T one) {
this.one = one;
}
public T getTwo() {
return two;
}
public void setTwo(T two) {
this.two = two;
}
private static <T> void set(Pair<Game> p) {
}
public static void main(String[] args) {
//Pair<Game>和Pair<LOL>没有任何继承关系
Pair<Game> gamePair = new Pair<>();
Pair<LOL> lolPair = new Pair<>();
Game game = new LOL();
// Pair<Game> gamePair2=new Pair<LOL>();//不允许
Pair<Game> gamePair3 = new ExtendPair<>();
set(gamePair);
// set(lolPair);//不允许
}
private static class ExtendPair<T> extends Pair<T> {
}
}
7 通配符的类型
定义一个泛型类:
public class GenericType<T> {
private T data;
public T getData() {
return data;
}
public void setData(T data) {
this.data = data;
}
}
定义三个对象,继承关系如下:
/**
* @Description: 宠物
* @CreateDate: 2022/3/21 11:11 上午
*/
public class Pet {
private String color;
public String getColor() {
return color;
}
public void setColor(String color) {
this.color = color;
}
}
/**
* @Description: 猫
* @CreateDate: 2022/3/21 11:12 上午
*/
public class Cat extends Dog{
}
/**
* @Description: 狗
* @CreateDate: 2022/3/21 11:12 上午
*/
public class Dog extends Pet{
}
/**
* @Description: 哈士奇
* @CreateDate: 2022/3/21 11:14 上午
*/
public class Husky extends Dog{
}
这时候会出现以下情况:
public static void print(GenericType<Pet> g) {
System.out.println(g.getData().getColor());
}
public static void method() {
GenericType<Pet> g1 = new GenericType<>();
print(g1);
GenericType<Cat> g2 = new GenericType<>();
// print(g2);//不允许
GenericType<Dog> g3 = new GenericType<>();
// print(g3);//不允许
GenericType<Husky> g4 = new GenericType<>();
// print(g4);//不允许
}
为了解决这种问题,出现了通配符。
有两种使用方式:
?extends X
表示类型的上界,类型参数是X的子类。
?super X
表示类型的下界,类型参数是X的超类。
7.1 ?extends X
表示传递给方法的参数,必须是X的子类(包括X本身)。
public static void method2() {
GenericType<Pet> g1 = new GenericType<>();
print2(g1);
GenericType<Cat> g2 = new GenericType<>();
print2(g2);
GenericType<? extends Pet> g3 = new GenericType<>();
Pet pet = new Pet();
Cat cat = new Cat();
// g3.setData(pet);//不允许
// g3.setData(cat);//不允许
Pet p = g3.getData();
}
public static void print3(GenericType<? super Dog> g) {
System.out.println(g.getData());
}
但是对泛型类GenericType
来说,如果其中提供了get
和set
类型参数变量的方法的话,set
方法是不允许被调用的,会出现编译错误;get
方法则没问题,会返回一个Pet
类型的值。
道理很简单,? extends X
表示类型的上界,类型参数是X的子类,那么可以肯定的说,get
方法返回的一定是个X(不管是X或者X的子类)编译器是可以确定知道的。但是set
方法只知道传入的是个X,至于具体是X的那个子类,不知道。
总结:主要用于安全地访问数据,可以访问X及其子类型,并且不能写入非null的数据。
7.2 ?super X
表示传递给方法的参数,必须是X的超类(包括X本身)。
public static void method3() {
GenericType<Pet> petGenericType = new GenericType<>();
GenericType<Dog> dogGenericType = new GenericType<>();
GenericType<Husky> huskyGenericType = new GenericType<>();
GenericType<Cat> catGenericType = new GenericType<>();
print3(petGenericType);
print3(dogGenericType);
// print3(huskyGenericType);//不允许
// print3(catGenericType);//不允许
GenericType<? super Dog> g = new GenericType<>();
// g.setData(new Pet());//不允许
g.setData(new Dog());
g.setData(new Husky());
Object o = g.getData();
}
但是对泛型类GenericType
来说,如果其中提供了get
和set
类型参数变量的方法的话,set
方法可以被调用的,且能传入的参数只能是X或者X的子类;get
方法只会返回一个Object
类型的值。
? super X
表示类型的下界,类型参数是X的超类(包括X本身),那么可以肯定的说,get
方法返回的一定是个X的超类,那么到底是哪个超类?不知道,但是可以肯定的说,Object
一定是它的超类,所以get
方法返回Object
。编译器是可以确定知道的。对于set
方法来说,编译器不知道它需要的确切类型,但是X和X的子类可以安全的转型为X。
总结:主要用于安全地写入数据,可以写入X及其子类型。
7.3 无限定通配符 ?
表示对类型没有什么限制,可以把?看成所有类型的父类。
//指定集合元素只能是T类型
List<T> list=new ArrayList<>();
//集合元素可以是任意类型,这种没有意义,一般是方法中,只是为了说明用法。
List<?> list2=new ArrayList<>();
8 虚拟机是如何实现泛型的?
泛型思想早在C++语言的模板(Template)中就开始生根发芽,在Java语言处于还没有出现泛型的版本时,只能通过Object
是所有类型的父类和类型强制转换两个特点的配合来实现类型泛化。,由于Java语言里面所有的类型都继承于java.lang.Object
,所以Object
转型成任何对象都是有可能的。但是也因为有无限的可能性,就只有程序员和运行期的虚拟机才知道这个Object
到底是个什么类型的对象。在编译期间,编译器无法检查这个Object
的强制转型是否成功,如果仅仅依赖程序员去保障这项操作的正确性,许多ClassCastException
的风险就会转嫁到程序运行期之中。
泛型技术在C#和Java之中的使用方式看似相同,但实现上却有着根本性的分歧,C#里面泛型无论在程序源码中、编译后的IL中(Intermediate Language,中间语言,这时候泛型是一个占位符),或是运行期的CLR中,都是切实存在的,List<int>
与List<String>
就是两个不同的类型,它们在系统运行期生成,有自己的虚方法表和类型数据,这种实现称为类型膨胀,基于这种方法实现的泛型称为真实泛型。
Java语言中的泛型则不一样,它只在程序源码中存在,在编译后的字节码文件中,就已经替换为原来的原生类型(Raw Type,也称为裸类型)了,并且在相应的地方插入了强制转型代码,因此,对于运行期的Java语言来说,ArrayList<int>
与ArrayList<String>
就是同一个类,所以泛型技术实际上是Java语言的一颗语法糖,Java语言中的泛型实现方法称为类型擦除,基于这种方法实现的泛型称为伪泛型。
将一段Java代码编译成Class文件,然后再用字节码反编译工具进行反编译后,将会发现泛型都不见了,程序又变回了Java泛型出现之前的写法,泛型类型都变回了原生类型。
上面这段代码是不能被编译的,因为参数List<Integer>
和List<String>
编译之后都被擦除了,变成了一样的原生类型List<E>
,擦除动作导致这两种方法的特征签名变得一模一样。
由于Java泛型的引入,各种场景(虚拟机解析、反射等)下的方法调用都可能对原有的基础产生影响和新的需求,如在泛型类中如何获取传入的参数化类型等。因此,JCP组织对虚拟机规范做出了相应的修改,引入了诸如Signature
、LocalVariableTypeTable
等新的属性用于解决伴随泛型而来的参数类型的识别问题,Signature是其中最重要的一项属性,它的作用就是存储一个方法在字节码层面的特征签名[3],这个属性中保存的参数类型并不是原生类型,而是包括了参数化类型的信息。修改后的虚拟机规范要求所有能识别49.0以上版本的Class文件的虚拟机都要能正确地识别Signature参数。
另外,从Signature属性的出现我们还可以得出结论,擦除法所谓的擦除,仅仅是对方法的Code属性中的字节码进行擦除,实际上元数据中还是保留了泛型信息,这也是我们能通过反射手段取得参数化类型的根本依据。
作者:木水Code
链接:https://juejin.cn/post/7104835680559169550
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
Compose 动画边学边做 - 夏日彩虹
引言
Compose 在动画方面下足了功夫,提供了种类丰富的 API。但也正由于 API 种类繁多,如果想一气儿学下来,可能会消化不良导致似懂非懂。结合例子学习是一个不错的方法,本文就带大家边学边做,通过高仿微博长按点赞的彩虹动画,学习和实践 Compose 动画的相关技巧。
原版:微博长按点赞 | 本文:掘金夏日主题 |
---|---|
代码地址: github.com/vitaviva/An…
1. Compose 动画 API 概览
Compose 动画 API 在使用场景的维度上大体分为两类:高级别 API 和低级别 API。就像编程语
言分为高级语言和低级语言一样,这列高级低级指 API 的易用性:
- 高级别 API 主打开箱即用,适用于一些 UI 元素的展现/退出/切换等常见场景,例如常见的
AnimatedVisibility
以及AnimatedContent
等,它们被设计成 Composable 组件,可以在声明式布局中与其他组件融为一体。
//Text通过动画淡入
var editable by remember { mutableStateOf(true) }
AnimatedVisibility(visible = editable) {
Text(text = "Edit")
}
- 低级别 API 使用成本更高但是更加灵活,可以更精准地实现 UI 元素个别属性的动画,多个低级别动画还可以组合实现更复杂的动画效果。最常见的低级别
animateFloatAsState
系列了,它们也是 Composable 函数,可以参与 Composition 的组合过程。
//动画改变 Box 透明度
val alpha: Float by animateFloatAsState(if (enabled) 1f else 0.5f)
Box(
Modifier.fillMaxSize()
.graphicsLayer(alpha = alpha)
.background(Color.Red)
)
处于上层的 API 由底层 API 支撑实现,TargetBasedAnimation
是开发者可直接使用的最低级 API。Animatable 也是一个相对低级的 API,它是一个动画值的包装器,在协程中完成状态值的变化,向上提供对 animate*AsState
的支撑。它与其他 API 不同,是一个普通类而非一个 Composable 函数,所以可以在 Composable 之外使用,因此更具灵活性。本例子的动画主要也是依靠它完成的。
// Animtable 包装了一个颜色状态值
val color = remember { Animatable(Color.Gray) }
LaunchedEffect(ok) {
// animateTo 是个挂起函数,驱动状态之变化
color.animateTo(if (ok) Color.Green else Color.Gray)
}
Box(Modifier.fillMaxSize().background(color.value))
无论高级别 API 还是低级别 API ,它们都遵循状态驱动的动画方式,即目标对象通过观察状态变化实现自身的动画。
2. 长按点赞动画分解
长按点赞的动画乍看之下非常复杂,但是稍加分解后,不难发现它也是由一些常见的动画形式组合而成,因此我们可以对其拆解后逐个实现:
- 彩虹动画:全屏范围内不断扩散的彩虹效果。可以通过半径不断扩大的圆形图案并依次叠加来实现
- 表情动画:从按压位置不断抛出的表情。可以进一步拆解为三个动画:透明度动画,旋转动画以及抛物线轨迹动画。
- 烟花动画:抛出的表情在消失时会有一个烟花炸裂的效果。其实就是围绕中心的八个圆点逐渐消失的过程,圆点的颜色提取自表情本身。
传统视图动画可以作用在 View 上,通过动画改变其属性;也可以在 onDraw
中通过不断重绘实现逐帧的动画效果。 Compose 也同样,我们可以在 Composable 中观察动画状态,通过重组实现动画效果(本质是改变 UI 组件的布局属性),也可以在 Canvas 中观察动画状态,只在重绘中实现动画(跳过组合)。这个例子的动画效果也需要通过 Canvas 的不断重绘来实现。
Compose 的 Canvas 也可以像 Composable 一样声明式的调用,基本写法如下:
Canvas {
...
drawRainbow(rainbowState) //绘制彩虹
...
drawEmoji(emojiState) //绘制表情
...
drawFlow(flowState) //绘制烟花
...
}
State 的变化会驱动 Canvas 会自动重绘,无需手动调用 invalidate
之类的方法。那么接下来针对彩虹、表情、烟花等各种动画的实现,我们的工作主要有两个:
- 状态管理:定义相关 State,并在在动画中驱动其变化,如前所述这主要依靠 Animatable 实现。
- 内容绘制:通过 Canvas API 基于当前状态绘制图案
3. 彩虹动画
3.1 状态管理
对于彩虹动画,唯一的动画状态就是圆的半径,其值从 0F 过渡到 screensize,圆形面积铺满至整个屏幕。我们使用 Animatable
包装这个状态值,调用 animateTo
方法可以驱动状态变化:
val raduis = Animatable(0f) //初始值 0f
radius.animateTo(
targetValue = screenSize, //目标值
animationSpec = tween(
durationMillis = duration, //动画时长
easing = FastOutSlowInEasing //动画衰减效果
)
)
animationSpec
用来指定动画规格,不同的动画规格决定了了状态值变化的节奏。Compose 中常用的创建动画规格的方法有以下几种,它们创建不同类型的动画规格,但都是 AnimationSpec
的子类:
- tween:创建补间动画规格,补间动画是一个固定时长动画,比如上面例子中这样设置时长 duration,此外,tween 还能通过 easiing 指定动画衰减效果,后文详细介绍。
- spring: 弹跳动画:spring 可以创建基于物理特性的弹簧动画,它通过设置阻尼比实现符合物理规律的动画衰减,因此不需要也不能指定动画时长
- Keyframes:创建关键帧动画规格,关键帧动画可以逐帧设置当前动画的轨迹,后文会详细介绍。
AnimatedRainbow
要实现上面这样多个彩虹叠加的效果,我们还需有多个 Animtable
同时运行,在 Canvas 中依次对它们进行绘制。绘制彩虹除了依靠 Animtable 的状态值,还有 Color 等其他信息,因此我们定义一个 AnimatedRainbow
类保存包括 Animtable 在内的绘制所需的的状态
class AnimatedRainbow(
//屏幕尺寸(宽边长边大的一方)
private val screenSize: Float,
//RainbowColors是彩虹的候选颜色
private val color: Brush = RainbowColors.random(),
//动画时长
private val duration: Int = 3000
) {
private val radius = Animatable(0f)
suspend fun startAnim() = radius.animateTo(
targetValue = screenSize * 1.6f, // 关于 1.6f 后文说明
animationSpec = tween(
durationMillis = duration,
easing = FastOutSlowInEasing
)
)
}
animatedRainbows 列表
我们还需要一个集合来管理运行中的 AnimatedRainbow
。这里我们使用 Compose 的 MutableStateList
作为集合容器,MutableStateList
中的元素发生增减时,可以被观察到,而当我们观察到新的 AnimatedRainbow
被添加时,为它启动动画。关键代码如下:
//MutableStateList 保存 AnimatedRainbow
val animatedRainbows = mutableStateListOf<AnimatedRainbow>()
//长按屏幕时,向列表加入 AnimtaedRainbow, 意味着增加一个新的彩虹
animatedRainbows.add(
AnimatedRainbow(
screenHeightPx.coerceAtLeast(screenWidthPx),
RainbowColors.random()
)
)
我们使用 LaunchedEffect
+ snapshotFlow
观察 animatedRainbows 的变化,代码如下:
LaunchedEffect(Unit) {
//监听到新添加的 AnimatedRainbow
snapshotFlow { animatedRainbows.lastOrNull() }
.filterNotNull()
.collect {
launch {
//启动 AnimatedRainbow 动画
val result = it.startAnim()
//动画结束后,从列表移除,避免泄露
if (result.endReason == AnimationEndReason.Finished) {
animatedRainbows.remove(it)
}
}
}
}
LaunchedEffect
和 snapshotFlow
都是 Compose 处理副作用的 API,由于不是本文重点就不做深入介绍了,这里只需要知道 LaunchedEffect
是一个提供了执行副作用的协程环境,而 snapshotFlow
可以将 animatedRainbows
中的变化转化为 Flow 发射给下游。当通过 Flow 收集到新加入的 AnimtaedRainbow
时,调用 startAnim
启动动画,这里充分发挥了挂起函数的优势,同步等待动画执行完毕,从 animatedRainbows
中移除 AnimtaedRainbow
即可。
值得一提的是,MutableStateList
的主要目的是在组合中观察列表的状态变化,本例子的动画不发生在组合中(只发生在重绘中),完全可以使用普通的集合类型替代,这里使用 MutableStateList
有两个好处:
- 可以响应式地观察列表变化
- 在 LaunchEffect 中响应变化并启动动画,协程可以随当前 Composable 的生命周期结束而终止,避免泄露。
3.2 内容绘制
我们在 Canvas 中遍历 animatedRainbows 所有的 AnimtaedRainbow 完成彩虹的绘制。彩虹的图形主要依靠 DrawScope
的 drawCircle
完成,比较简单。一点需要特别注意,彩虹动画结束时也要以一个圆形图案逐渐退出直至漏出底部内容,要实现这个效果,用到一个小技巧,我们的圆形绘制使用空心圆 (Stroke ) 而非 实心圆( Fill )
- 出现彩虹:圆环逐渐铺满屏幕却不能漏出空心。这要求 StrokeWidth 宽度覆盖 ScreenSize,且始终保持 CircleRadius 的两倍
- 结束彩虹:圆环空心部分逐渐覆盖屏幕。此时要求 CircleRadius 减去 StrokeWidth / 2 之后依然能覆盖 ScreenSize
基于以上原则,我们为 AnimatedRainbow 添加单个 AnnimatedRainbow 的绘制方法:
fun DrawScope.draw() {
drawCircle(
brush = color, //圆环颜色
center = center, //圆心:点赞位置
radius = radius.value,// Animtable 中变化的 radius 值,
style = Stroke((radius.value * 2).coerceAtMost(_screenSize)),
)
}
如上,StrokeWidth 覆盖 ScreenSize 之后无需继续增长,而 CircleRadius 的最终尺寸除去 ScreenSize 之外还要将 StrokeWidth 考虑进去,因此前面代码中将 Animtable 的 targetValue 设置为 ScreenSize 的 1.6 倍。
4. 表情动画
4.1 状态管理
表情动画又由三个子动画组成:旋转动画、透明度动画以及抛物线轨迹动画。像 AnimtaedRainbow 一样,我们定义 AnimatedEmoji
管理每个表情动画的状态,AnimatedEmoji 中通过多个 Animatable 分别管理前面提到的几个子动画
AnimatedEmoji
class AnimatedEmoji(
private val start: Offset, //表情抛点位置,即长按的屏幕位置
private val screenWidth: Float, //屏幕宽度
private val screenHeight: Float, //屏幕高度
private val duration: Int = 1500 //动画时长
) {
//抛出距离(x方向移动终点),在左右一个屏幕之间取随机数
private val throwDistance by lazy {
((start.x - screenWidth).toInt()..(start.x + screenWidth).toInt()).random()
}
//抛出高度(y方向移动终点),在屏幕顶端到抛点之间取随机数
private val throwHeight by lazy {
(0..start.y.toInt()).random()
}
private val x = Animatable(start.x)//x方向移动动画值
private val y = Animatable(start.y)//y方向移动动画值
private val rotate = Animatable(0f)//旋转动画值
private val alpha = Animatable(1f)//透明度动画值
suspend fun CoroutineScope.startAnim() {
async {
//执行旋转动画
rotate.animateTo(
360f, infiniteRepeatable(
animation = tween(_duration / 2, easing = LinearEasing),
repeatMode = RepeatMode.Restart
)
)
}
awaitAll(
async {
//执行x方向移动动画
x.animateTo(
throwDistance.toFloat(),
animationSpec = tween(durationMillis = duration, easing = LinearEasing)
)
},
async {
//执行y方向移动动画(上升)
y.animateTo(
throwHeight.toFloat(),
animationSpec = tween(
duration / 2,
easing = LinearOutSlowInEasing
)
)
//执行y方向移动动画(下降)
y.animateTo(
screenHeight,
animationSpec = tween(
duration / 2,
easing = FastOutLinearInEasing
)
)
},
async {
//执行透明度动画,最终状态是半透明
alpha.animateTo(
0.5f,
tween(duration, easing = CubicBezierEasing(1f, 0f, 1f, 0.8f))
)
}
)
}
infiniteRepeatable
上面代码中,旋转动画的 AnimationSpec 使用 infiniteRepeatable
创建了一个无限循环的动画,RepeatMode.Restart
表示它的从 0F
过渡到 360F
之后,再次重复这个过程。
除了旋转动画之外,其他动画都会在 duration
之后结束,它们分别在 async
中启动并行执行,awaitAll
等待它们全部结束。而由于旋转动画不会结束,因此不能放到 awaitAll 中,否则 startAnim 的调用方将永远无法恢复执行。
CubicBezierEasing
透明度动画中的 easing
指定了一个 CubicBezierEasing
。easing 是动画衰减效果,即动画状态以何种速率逼近目标值。Compose 提供了几个默认的 Easing 类型可供使用,分别是:
//默认的 Easing 类型,以加速度起步,减速度收尾
val FastOutSlowInEasing: Easing = CubicBezierEasing(0.4f, 0.0f, 0.2f, 1.0f)
//匀速起步,减速度收尾
val LinearOutSlowInEasing: Easing = CubicBezierEasing(0.0f, 0.0f, 0.2f, 1.0f)
//加速度起步,匀速收尾
val FastOutLinearInEasing: Easing = CubicBezierEasing(0.4f, 0.0f, 1.0f, 1.0f)
//匀速接近目标值
val LinearEasing: Easing = Easing { fraction -> fraction }
上图横轴是时间,纵轴是逼近目标值的进度,可以看到除了 LinearEasing
之外,其它的的曲线变化都满足 CubicBezierEasing
三阶贝塞尔曲线,如果默认 Easing 不符合你的使用要求,可以使用 CubicBezierEasing
,通过参数,自定义合适的曲线效果。比如例子中曲线如下:
这个曲线前半程状态值进度非常缓慢,临近时间结束才快速逼近最终状态。因为我们希望表情动画全程清晰可见,透明度的衰减尽量后置,默认 easiing 无法提供这种效果,因此我们自定义 CubicBezierEasing
抛物线动画
再来看一下抛物线动画的实现。通常我们可以借助抛物线公式,基于一些动画状态变量计算抛物线坐标来实现动画,但这个例子中我们借助 Easing 更加巧妙的实现了抛物线动画。
我们将抛物线动画拆解为 x 轴和 y 轴两个方向两个并行执行的位移动画,x 轴位移通过 LinearEasing 匀速完成,y 轴又拆分成两个过程
- 上升到最高点,使用 LinearOutSlowInEasing 上升时速度加速衰减
- 下落到屏幕底端,使用 FastOutLinearInEasing 下落时速度加速增加
上升和下降的 Easing 曲线互相对称,符合抛物线规律
animatedEmojis 列表
像彩虹动画一样,我们同样使用一个 MutableStateList 集合管理 AnimatedEmoji 对象,并在 LaunchedEffect 中监听新元素的插入,并执行动画。只是表情动画每次会批量增加多个
//MutableStateList 保存 animatedEmojis
val animatedEmojis = mutableStateListOf<AnimatedEmoji>()
//一次增加 EmojiCnt 个表情
animatedEmojis.addAll(buildList {
repeat(EmojiCnt) {
add(AnimatedEmoji(offset, screenWidthPx, screenHeightPx, res))
}
})
//监听 animatedEmojis 变化
LaunchedEffect(Unit) {
//监听到新加入的 EmojiCnt 个表情
snapshotFlow { animatedEmojis.takeLast(EmojiCnt) }
.flatMapMerge { it.asFlow() }
.collect {
launch {
with(it) {
startAnim()//启动表情动画,等待除了旋转动画外的所有动画结束
animatedEmojis.remove(it) //从列表移除
}
}
}
}
4.2 内容绘制
单个 AnimatedEmoji 绘制代码很简单,借助 DrawScope
的 drawImage
绘制表情素材即可
//当前 x,y 位移的位置
val offset get() = Offset(x.value, y.value)
//图片topLeft相对于offset的距离
val d by lazy { Offset(img.width / 2f, img.height / 2f) }
//绘制表情
fun DrawScope.draw() {
rotate(rotate.value, pivot = offset) {
drawImage(
image = img, //表情素材
topLeft = offset - dCenter,//当前位置
alpha = alpha.value, //透明度
)
}
}
注意旋转动画实际上是借助 DrawScope
的 rotate
方法实现的,在 block 内部调用 drawImage
指定当前的 alpha
和 topLeft
即可。
5. 烟花动画
5.1 状态管理
烟花动画紧跟在表情动画结束时发生,动画不涉及位置变化,主要是几个花瓣不断缩小的过程。花瓣用圆形绘制,动画状态值就是圆形半径,使用 Animatable 包装。
AnimatedFlower
烟花的绘制还要用到颜色等信息,我们定义 AnimatedFlower 保存包括 Animtable 在内的相关状态。
class AnimatedFlower(
private val intial: Float, //花瓣半径初始值,一般是表情的尺寸
private val duration: Int = 2500
) {
//花瓣半径
private val radius = Animatable(intial)
suspend fun startAnim() {
radius.animateTo(0f, keyframes {
durationMillis = duration
intial / 3 at 0 with FastOutLinearInEasing
intial / 5 at (duration * 0.95f).toInt()
})
}
keyframes
这里又出现了一种 AnimationSpec,即帧动画 keyframes
,相对于 tween ,keyframes
可以更精确指定时间区间内的动画进度。比如代码中 radius / 3 at 0
表示 0 秒时状态值达到 intial / 3
,相当于以初始值的 1/3
尺寸出现,这是一般的 tween 难以实现的。另外我们希望花瓣可以持久可见,所以使用 keyframe
确保时间进行到 95% 时,radius 的尺寸仍然清晰可见。
animatedFlower 列表
由于烟花动画设计是表情动画的延续,所以它紧跟表情动画执行,共享 CoroutienScope ,不需要借助 LaunchedEffect ,所以使用普通列表定义 animatedFlower 即可:
//animatedFlowers 使用普通列表创建
val animatedFlowers = mutableListOf<AnimatedFlower>()
launch {
with(it) {//表情动画执行
startAnim()
animatedEmojis.remove(it)
}
//创建 AnimatedFlower 动画
val anim = AnimatedFlower(
center = it.offset,
//使用 Palette 从表情图片提取烟花颜色
color = Palette.from(it.img.asAndroidBitmap()).generate().let {
arrayOf(
Color(it.getDominantColor(Color.Transparent.toArgb())),
Color(it.getVibrantColor(Color.Transparent.toArgb()))
)
},
initial = it.img.run { width.coerceAtLeast(height) / 2 }.toFloat()
)
animatedFlowers.add(anim) //添加进列表
anim.startAnim() //执行烟花动画
animatedFlowers.remove(anim) //移除动画
}
5.2 内容绘制
烟花的内容绘制,需要计算每个花瓣的位置,一共8个花瓣,各自位置计算如下:
//计算 sin45 的值
val sin by lazy { sin(Math.PI / 4).toFloat() }
val points
get() = run {
val d1 = initial - radius.value
val d2 = (initial - radius.value) * sin
arrayOf(
center.copy(y = center.y - d1), //0点方向
center.copy(center.x + d2, center.y - d2),
center.copy(x = center.x + d1),//3点方向
center.copy(center.x + d2, center.y + d2),
center.copy(y = center.y + d1),//6点方向
center.copy(center.x - d2, center.y + d2),
center.copy(x = center.x - d1),//9点方向
center.copy(center.x - d2, center.y - d2),
)
}
center
是烟花的中心位置,随着花瓣的变小,同时越来越远离中心位置,因此 d1
和 d2
就是偏离 center 的距离,与 radius 大小成反比。
最后在 Canvas 中绘制这些 points 即可:
fun DrawScope.draw() {
points.forEachIndexed { index, point ->
drawCircle(color = color[index % 2], center = point, radius = radius.value)
}
}
6. 合体效果
最后我们定义一个 AnimatedLike
的 Composable ,整合上面代码
@Composable
fun AnimatedLike(modifier: Modifier = Modifier, state: LikeAnimState = rememberLikeAnimState()) {
LaunchedEffect(Unit) {
//监听新增表情
snapshotFlow { state.animatedEmojis.takeLast(EmojiCnt) }
.flatMapMerge { it.asFlow() }
.collect {
launch {
with(it) {
startAnim()
state.animatedEmojis.remove(it)
}
//添加烟花动画
val anim = AnimatedFlower(
center = it.offset,
color = Palette.from(it.img.asAndroidBitmap()).generate().let {
arrayOf(
Color(it.getDominantColor(Color.Transparent.toArgb())),
Color(it.getVibrantColor(Color.Transparent.toArgb()))
)
},
initial = it.img.run { width.coerceAtLeast(height) / 2 }.toFloat()
)
state.animatedFlowers.add(anim)
anim.startAnim()
state.animatedFlowers.remove(anim)
}
}
}
LaunchedEffect(Unit) {
//监听新增彩虹
snapshotFlow { state.animatedRainbows.lastOrNull() }
.filterNotNull()
.collect {
launch {
val result = it.startAnim()
if (result.endReason == AnimationEndReason.Finished) {
state.animatedRainbows.remove(it)
}
}
}
}
//绘制动画
Canvas(modifier.fillMaxSize()) {
//绘制彩虹
state.animatedRainbows.forEach { animatable ->
with(animatable) { draw() }
}
//绘制表情
state.animatedEmojis.forEach { animatable ->
with(animatable) { draw() }
}
//绘制烟花
state.animatedFlowers.forEach { animatable ->
with(animatable) { draw() }
}
}
}
我们使用 AnimatedLike
布局就可以为页面添加动画效果了,由于 Canvas 本身是基于 modifier.drawBehind
实现的,我们也可以将 AnimatedLike 改为 Modifier 修饰符使用,这里就不赘述了。
最后,复习一下本文例子中的内容:
Animatable
:包装动画状态值,并且在协程中执行动画,同步返回动画结果AnimationSpec
:动画规格,可以配置动画时长、Easing 等,例子中用到了 tween,keyframes,infiniteRepeatable 等多个动画规格Easing
:动画状态值随时间变化的趋势,通常使用默认类型即可, 也可以基于 CubicBezierEasing 定制。
一个例子不可能覆盖到 Compose 所有的动画 API,但是我们只要掌握了上述几个关键知识点,再学习其他 API 就是水到渠成的事情了。
作者:fundroid
链接:https://juejin.cn/post/7101836989602725901
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
Flutter——平台通信记录器 : channel_observer
前言
Flutter自身的定位,决定了基于其开发的项目在不断迭代的过程中,会有越来越多的平台通信。这些通信多来自各种平台端的sdk
,而这些sdk
一般是由不同人、团队甚至公司负责的,所以在sdk
变动过程中,可能由于沟通不够及时、或者疏忽大意而未能及时通知到客户端。
例如,某个字段类型由int
变为string
,如果这个字段涉及到核心业务线那么可能会在测试中及时发现,而如果是在非核心业务线则不一定能及时发现。 这种错误,在抵达flutter
侧时多为TypeCast Error
。 初期, 我们的APM
会将此类错误进行上报,但是由于platform channel
众多,很难确定是由哪个channel
引起的,为此我们增加了channel observer
用于记录最近n
条的平台通信记录。 当APM
再次上报类似错误后,会导出channel
记录一同上报,藉此便可排查出bug点。
下面我简单的介绍一下具体原理与实现。
原理与实现
Flutter-平台通信简介
Flutter
与平台端的通信连接层位于ServicesBinding
中,其主要负责监听平台信息(系统/自定义)
并将其转到defaultBinaryMessenger
中处理,其内部初始化方法:
mixin ServicesBinding on BindingBase, SchedulerBinding {
@override
void initInstances() {
super.initInstances();
_instance = this;
_defaultBinaryMessenger = createBinaryMessenger();
//...无关代码
}
@protected
BinaryMessenger createBinaryMessenger() {
return const _DefaultBinaryMessenger._();
}
}
通过createBinaryMessenger
方法,创建了一个_DefaultBinaryMessenger
对象,Flutter
和平台端
通信都由此类来负责,其内部实现如下:
class _DefaultBinaryMessenger extends BinaryMessenger {
const _DefaultBinaryMessenger._();
///当我们在调用 xxxChannel.invokeMethod()方法时,最终会调用到send()方法,
@override
Future<ByteData?> send(String channel, ByteData? message) {
final Completer<ByteData?> completer = Completer<ByteData?>();
///channel : 通道名
///message : 你的参数
///通过engine中转到平台端
ui.PlatformDispatcher.instance.sendPlatformMessage(channel, message, (ByteData? reply){
try {
///reply : 平台端返回的结果
completer.complete(reply);
} catch (exception, stack) {
FlutterError.reportError(FlutterErrorDetails(
exception: exception,
stack: stack,
library: 'services library',
context: ErrorDescription('during a platform message response callback'),
));
}
});
return completer.future;
}
///此方法与上面的 send 方法相对应,是服务于平台端调用flutter的方法。
///
///当我们通过方法 :
/// channel.setMethodCallHandler(xxHandler)
///在flutter侧对 channel绑定一个回调用于处理平台端的调用时,
///最终会转到此方法。
///
///通过channelBuffers,会记录下你的channel name以及对应的handler,
///当平台端调用flutter方法时,会查找对应channel的handler并执行。
@override
void setMessageHandler(String channel, MessageHandler? handler) {
if (handler == null) {
ui.channelBuffers.clearListener(channel);
} else {
ui.channelBuffers.setListener(channel, (ByteData? data, ui.PlatformMessageResponseCallback callback) async {
ByteData? response;
try {
response = await handler(data);
} catch (exception, stack) {
FlutterError.reportError(FlutterErrorDetails(
exception: exception,
stack: stack,
library: 'services library',
context: ErrorDescription('during a platform message callback'),
));
} finally {
callback(response);
}
});
}
}
}
通过上面的了解我们便知道了入手点:只需增加一个_DefaultBinaryMessenger
的代理类即可。
实现
首先,我们需要自定义WidgetsFlutterBinding
以混入我们自定义的ServicesBinding
:
class ChannelObserverBinding extends WidgetsFlutterBinding with ChannelObserverServicesBinding{
static WidgetsBinding ensureInitialized() {
if(WidgetsBinding.instance == null) {
ChannelObserverBinding();
}
return WidgetsBinding.instance!;
}
}
随后我们在自定义的ServicesBinding
中,添加我们的代理类BinaryMessengerProxy
。
mixin ChannelObserverServicesBinding on BindingBase, ServicesBinding{
late BinaryMessengerProxy _proxy;
@override
BinaryMessenger createBinaryMessenger() {
_proxy = BinaryMessengerProxy(super.createBinaryMessenger());
return _proxy;
}
}
这样我们就可以在代理类中,对平台通信进行记录了:
class BinaryMessengerProxy extends BinaryMessenger{
BinaryMessengerProxy(this.origin);
///....省略代码
@override
Future<void> handlePlatformMessage(String channel, ByteData? data, PlatformMessageResponseCallback? callback) {
return origin.handlePlatformMessage(channel, data, callback);
}
///这里我们对flutter的调用做记录
@override
Future<ByteData?>? send(String channel, ByteData? message) async {
//记录channel通信
final ChannelModel model = _recordChannel(channel, message, true);
if(model.isAbnormal) {
return origin.send(channel, message);
}
final ByteData? result = await origin.send(channel, message);
_resolveResult(model, result);
return result;
}
///这里我们可以对平台端的调用做记录
/// * 对MessageHandler增加一个代理即可。
@override
void setMessageHandler(String channel, MessageHandler? handler) {
origin.setMessageHandler(channel, handler);
}
}
效果图
当我们捕捉到TypeCast error
时,就可以将异常堆栈及channel
的通信记录一同上传。开发同学便可借助堆栈信息和调用记录,定位到具体的异常channel
。
其他
项目地址
作者:吉哈达
链接:https://juejin.cn/post/7103349119481184287
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
跟我学企业级flutter项目:如何重新定制cached_network_image的缓存管理与Dio网络请求
前言
flutter中需要展示网络图片时候,不建议使用flutter原本Image.network(),建议最好还是采用cached_network_image这个三方库。那么我今天就按照它来展开说明,我再做企业级项目时如何重新定制cached_network_image。
由于我的项目网络请求采用Dio库,所以我希望我的图片库也采用Dio来网络请求,也是为了方便请求日志打印(在做APM监控时候可以看到网络请求状态,方便定位问题)。
前期准备
准备好mime_converter类,由于cached_network_image中的manager这个文件不是export的状态,那么我们需要准备好该类,以便我们自己实现网络请求修改。
实现mime_converter
创建mime_converter 类,代码如下:
import 'dart:io';
///将最常见的MIME类型转换为最期望的文件扩展名。
extension ContentTypeConverter on ContentType {
String get fileExtension => mimeTypes[mimeType] ?? '.$subType';
}
///MIME类型的来源:
/// https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types
///2020年3月20日时更新
const mimeTypes = {
'application/vnd.android.package-archive': '.apk',
'application/epub+zip': '.epub',
'application/gzip': '.gz',
'application/java-archive': '.jar',
'application/json': '.json',
'application/ld+json': '.jsonld',
'application/msword': '.doc',
'application/octet-stream': '.bin',
'application/ogg': '.ogx',
'application/pdf': '.pdf',
'application/php': '.php',
'application/rtf': '.rtf',
'application/vnd.amazon.ebook': '.azw',
'application/vnd.apple.installer+xml': '.mpkg',
'application/vnd.mozilla.xul+xml': '.xul',
'application/vnd.ms-excel': '.xls',
'application/vnd.ms-fontobject': '.eot',
'application/vnd.ms-powerpoint': '.ppt',
'application/vnd.oasis.opendocument.presentation': '.odp',
'application/vnd.oasis.opendocument.spreadsheet': '.ods',
'application/vnd.oasis.opendocument.text': '.odt',
'application/vnd.openxmlformats-officedocument.presentationml.presentation':
'.pptx',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': '.xlsx',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document':
'.docx',
'application/vnd.rar': '.rar',
'application/vnd.visio': '.vsd',
'application/x-7z-compressed': '.7z',
'application/x-abiword': '.abw',
'application/x-bzip': '.bz',
'application/x-bzip2': '.bz2',
'application/x-csh': '.csh',
'application/x-freearc': '.arc',
'application/x-sh': '.sh',
'application/x-shockwave-flash': '.swf',
'application/x-tar': '.tar',
'application/xhtml+xml': '.xhtml',
'application/xml': '.xml',
'application/zip': '.zip',
'audio/3gpp': '.3gp',
'audio/3gpp2': '.3g2',
'audio/aac': '.aac',
'audio/x-aac': '.aac',
'audio/midi audio/x-midi': '.midi',
'audio/mpeg': '.mp3',
'audio/ogg': '.oga',
'audio/opus': '.opus',
'audio/wav': '.wav',
'audio/webm': '.weba',
'font/otf': '.otf',
'font/ttf': '.ttf',
'font/woff': '.woff',
'font/woff2': '.woff2',
'image/bmp': '.bmp',
'image/gif': '.gif',
'image/jpeg': '.jpg',
'image/png': '.png',
'image/svg+xml': '.svg',
'image/tiff': '.tiff',
'image/vnd.microsoft.icon': '.ico',
'image/webp': '.webp',
'text/calendar': '.ics',
'text/css': '.css',
'text/csv': '.csv',
'text/html': '.html',
'text/javascript': '.js',
'text/plain': '.txt',
'text/xml': '.xml',
'video/3gpp': '.3gp',
'video/3gpp2': '.3g2',
'video/mp2t': '.ts',
'video/mpeg': '.mpeg',
'video/ogg': '.ogv',
'video/webm': '.webm',
'video/x-msvideo': '.avi',
'video/quicktime': '.mov'
};
实现FileServiceResponse
FileServiceResponse是数据处理的关键,那么我们来实现该类
class DioGetResponse implements FileServiceResponse {
DioGetResponse(this._response);
final DateTime _receivedTime = clock.now();
final Response<ResponseBody> _response;
@override
int get statusCode => _response.statusCode!;
@override
Stream<List<int>> get content => _response.data!.stream;
@override
int? get contentLength => _getContentLength();
int _getContentLength() {
try {
return int.parse(
_header(HttpHeaders.contentLengthHeader) ?? '-1');
} catch (e) {
return -1;
}
}
String? _header(String name) {
return _response.headers[name]?.first;
}
@override
DateTime get validTill {
// Without a cache-control header we keep the file for a week
var ageDuration = const Duration(days: 7);
final controlHeader = _header(HttpHeaders.cacheControlHeader);
if (controlHeader != null) {
final controlSettings = controlHeader.split(',');
for (final setting in controlSettings) {
final sanitizedSetting = setting.trim().toLowerCase();
if (sanitizedSetting == 'no-cache') {
ageDuration = const Duration();
}
if (sanitizedSetting.startsWith('max-age=')) {
var validSeconds = int.tryParse(sanitizedSetting.split('=')[1]) ?? 0;
if (validSeconds > 0) {
ageDuration = Duration(seconds: validSeconds);
}
}
}
}
return _receivedTime.add(ageDuration);
}
@override
String? get eTag => _header(HttpHeaders.etagHeader);
@override
String get fileExtension {
var fileExtension = '';
final contentTypeHeader = _header(HttpHeaders.contentTypeHeader);
if (contentTypeHeader != null) {
final contentType = ContentType.parse(contentTypeHeader);
fileExtension = contentType.fileExtension;
}
return fileExtension;
}
}
实现FileService
实现FileService 参数为dio
class DioHttpFileService extends FileService {
final Dio _dio;
DioHttpFileService(this._dio);
@override
Future<FileServiceResponse> get(String url, {Map<String, String>? headers}) async {
Options options = Options(headers: headers ?? {}, responseType: ResponseType.stream);
Response<ResponseBody> httpResponse = await _dio.get<ResponseBody>(url, options: options);
return DioGetResponse(httpResponse);
}
}
制定框架缓存管理器
我在项目中,设定了缓存配置最多缓存 100 个文件,并且每个文件只应缓存 7天,如果需要使用日志拦截器的话,就在拦截器中增加日志拦截:
class LibCacheManager {
static const key = 'libCacheKey';
///缓存配置 {最多缓存 100 个文件,并且每个文件只应缓存 7天}
static CacheManager instance = CacheManager(
Config(
key,
stalePeriod: const Duration(days: 7),
maxNrOfCacheObjects: 100,
fileService : DioHttpFileService(Dio()))
),
);
}
项目中使用
使用如下
CachedNetworkImage(imageUrl: "https://t8.baidu.com/it/u=3845489932,4046458829&fm=74&app=80&size=f256,256&n=0&f=JPEG&fmt=auto?sec=1654102800&t=f6de842e1e7086ffc73536795d37fd2c",
cacheManager: LibCacheManager.instance,
width: 100,
height: 100,
placeholder: (context, url) => ImgPlaceHolder(),
errorWidget: (context, url, error) => ImgError(),
);
如上便是 如何重新定制cached_network_image的缓存管理与Dio网络请求
探究EventBus粘性事件实现机制
- 粘性事件观察者
@Subscribe(threadMode = ThreadMode.MAIN, sticky = true)
fun registerEventBus(o: Any) {
}
- 发送粘性事件
EventBus.getDefault().postSticky(Any())
- 注册
EventBus
EventBus.getDefault().register(this)
接下来我们就来探究下EventBus
的粘性事件是如何实现的。
postSticky()
内部机制
- 如果是发送的粘性事件,会添加到
stickyEvents
中,看下这个属性的实现:
可以看到这个属性是一个Map
集合,其中key为事件类型的class对象,value为对应的事件类型。
- 继续看下
post(Event)
方法:
首先将这个粘性事件添加到
PostingThreadState
(线程私有)的eventQueue
集合中
通过
isMainThread
方法判断当前是否为主线程,最终会调用到我们熟悉的Looper.getMainLooper() == Looper.myLooper()
进行判断
循环遍历
eventQueue
队列,不断的取出集合元素进行分发,看下postSinleEvent()
方法如何实现:
如果
eventInheritance
为true,会查找当前发送的粘性事件类型的父类型,并返回查找到的集合
接下来就会调用
postSingleEventForEventType()
方法来进行最终粘性事件的分发,即通知通过@Subscribe
注解注册的粘性事件观察者,看下具体实现:
- 调用
subscriptionsByEventType
获取注册该事件类型的所有订阅方法,但是由于这个时候我们是先发送的粘性事件再注册EventBus
,而subscriptionsByEventType
中集合元素的填充实在注册EventBus
发生的,所以通过subscriptionsByEventType
获取到的subscriptions
将是null的,所以接下来肯定不会走下面的if代码块中的逻辑了。
postSticky()
小结
上面这么多代码逻辑,其实只干了一件事,就是将这个粘性事件添加到了stickyEvents
这个集合中。之后的逻辑虽多,但和粘性事件没啥关系。
register
内部机制
findSubscriberMethods()
这个方法里面的逻辑就不带大家进行分析了,总之就干了一件事情:
查找当前类通过
@Subscribe
注册的所有事件订阅方法,并返回一个List<SubscriberMethod>
集合,其中SubscriberMethod
就是对每个注册的订阅方法和当前注册类的封装
subscribe
这个方法是关键,深入探究下:
第1、2、3、4步中其实就干了两件事情:
- 填充
subscriptionsByEventType
集合,key为事件类型,value为通过@Subscribe
订阅了该事件类型的方法集合 - 填充
typesBySubscriber
集合,key为注册EventBus的类,value为该类中所有@Subscribe
注解修饰的方法集合
- 填充
第5步就是实现粘性事件分发的关键地方
- 首先判断当前
@Subscribe
修饰的订阅方法是否为粘性,即@Subscribe(sticky = true)
中sticky
等于true - 是的话就从
stickyEvents
集合中判断是否存在和订阅方法中注册的事件类型相同的事件:
这个
stickyEvents
是不是很熟悉,就是我们之前发送粘性事件时,将粘性事件添加到的方法集合
- 如果存在,则就执行该粘性事件的分发,即调用执行该订阅方法,最终会调用到
invokeSubscriber()
方法:
- 首先判断当前
从上面可以看到,最终是通过反射来实现的订阅了粘性事件方法的执行。
register
小结
该方法最终会判断当前是否存在注册EventBus
前发送的粘性事件,且当前注册类中存在订阅该事件类型的方法,然后立即执行。
总结
以上就是EventBus
粘性事件的内部实现机制,总体来说不算复杂,大家看着文章跟着源码一步步分析应该就很容易理解这部分实现逻辑了。
作者:长安皈故里
链接:https://juejin.cn/post/7102815596621856799
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »