注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

用做猪脚面的方式,理解下模版方法模式

模版方法模式 前言 模版方法模式,是行为型设计模式的一种。这个模式的使用频率没有那么高,以至于之前从来没有了解过该模式。不过兄弟们也不用怕,这个模式其实还是比较简单的。等会咱们举个例子,来理解一下这个模式。 介绍 概念理解 模版方法模式,个人理解是,将类中的一...
继续阅读 »

模版方法模式


前言


模版方法模式,是行为型设计模式的一种。这个模式的使用频率没有那么高,以至于之前从来没有了解过该模式。不过兄弟们也不用怕,这个模式其实还是比较简单的。等会咱们举个例子,来理解一下这个模式。


介绍


概念理解


模版方法模式,个人理解是,将类中的一些方法执行顺序进行排序。其中的部分方法可以被重写。排序后的方法就是模版方法。排序后的类就是模版类。这种代码设计思路就是模版方法模式


菜谱:猪脚面


上面的描述可能有点抽象。那么咱就换一个讲法来说一下这个模式。


从前呢,在京海市有一条街叫旧厂街,那里呢有一个菜市场,菜市场里有一个卖鱼的小老板他叫高启强。他呢有一个弟弟妹妹。兄妹三人啊从小就喜欢吃猪脚面。但是由于家里穷,所以三人只够吃一碗面。妹妹高启兰吃猪脚,弟弟高启盛吃面,而高启强就只能喝面汤。


由于的确穷,他就去找饭店老板要了一份菜谱。饭店老板看他可怜就给了他一份猪脚面的菜谱,具体如下:



  1. 把水烧开

  2. 放面条

  3. 放猪脚

  4. 放佐料

  5. 把面煮熟


他兴高采烈的按照菜谱做了一份猪脚面,给弟弟妹妹吃。可是结果却让他失望了。因为猪脚面的味道出了问题。


他去找了老板,老板对他说,阿强啊,我给你的菜谱肯定没问题,味道不对一定是哪个环节出错了。


于是他又给老板做了一遍。当他放完佐料的时候。老板立刻叫住了他,对他说。阿强,你其他的步骤都没有错,但是放佐料这一步和我有些不一样。


这一步这里你应该要放的是酱油和老抽,再用盐和鸡精调味。可这里你只用了醋来调味,所以味道不对。高启强满脸通红的对老板说,对不起啊老板,我家太穷类没有那些调理所以只能用醋代替了。


在上面这个例子中,这里面的菜谱就是模板也可以说是框架
菜谱执行顺序可以被看作是模板方法。而且这里的执行顺序是固定无法被改变的。
执行顺序无法改变,但是具体的做菜步骤却是可以被重写的。比如说放佐料。


例子中的高启强正是由于这一步的不同,导致他做出的猪脚面和老板的口味不一致。


2023春晚


上面这个可以看作是模版方法模式的一个简单举例。接下来咱们再举个有代码的例子加深下对模版方法模式的印象。


春晚模版类


SpringFestivalGala规定了春晚必须遵循的节目流程。这个代码中的start方法,可以看作是模版方法模式中最重要的一环,因为他就是规定了其他方法调用顺序模版方法



  1. 开场白

  2. 唱歌

  3. 跳舞

  4. 小品

  5. 难忘今宵


由于不同卫视的节目顺序都遵循这套模版。而且最后的节目难忘今宵是春晚保留节目,所以该节目必须所有春晚保持一致,具体代码如下所示:


/**
* Author(作者):jtl
* Date(日期):2023/2/10 20:05
* Detail(详情):春晚流程(春晚模版 )
*/
public abstract class SpringFestivalGala {
public void start(){
prologue();
song();
dance();
comedySketch();
unforgettableTonight();
}
//开场白
public abstract void prologue();

//歌曲节目
public abstract void song();

//小品节目
public abstract void comedySketch();

//舞蹈节目
public abstract void dance();

//难忘今宵
private void unforgettableTonight(){
System.out.println("结尾:难忘今宵");
}
}
复制代码

上面的代码中,不同的春晚,有着不同的小品舞蹈等节目,所以需要SpringFestivalGala的子类需要重写这几个方法。但是难忘今宵是所有春晚共同的节目。因此可以复用
start方法就可以看作是模版方法。它里面的节目执行顺序固定的无法被改变。


辽视春晚


辽宁春晚继承了春晚的固定模版。具体代码如下:


/**
* Author(作者):jtl
* Date(日期):2023/2/10 20:42
* Detail(详情):辽宁春晚
*/
public class SpringFestivalGalaOfLiaoning extends SpringFestivalGala{
@Override
public void prologue() {
System.out.println("开场白:欢迎来到,2023年,辽宁卫视春晚现场");
}

@Override
public void song() {
System.out.println("歌曲:孙楠,谭维维-追光");
}

@Override
public void comedySketch() {
System.out.println("小品:宋小宝-杨树林:非常营销");
}

@Override
public void dance() {
System.out.println("舞蹈:舞蹈-欢庆中国年");
}
}
复制代码

央视春晚


央视春晚同样遵循春晚的传统模版。有着开场白,歌曲等精彩的演出。尤其是小品初见照相馆一经播出,一己之力推动年轻人的离婚率,简直是今年节目之最!
央视春晚的具体代码如下:


/**
* Author(作者):jtl
* Date(日期):2023/2/10 20:53
* Detail(详情):CCTV 央视春晚
*/
public class CCTVSpringFestivalGala extends SpringFestivalGala{
@Override
public void prologue() {
System.out.println("开场白:欢迎来到,2023年,央视春晚的现场");
}

@Override
public void song() {
System.out.println("歌曲:邓超-好运全都来");
}

@Override
public void comedySketch() {
System.out.println("小品:于震-初见照相馆");
}

@Override
public void dance() {
System.out.println("舞蹈:辽宁芭蕾舞团:我们的田野上");
}
}

复制代码

客户端代码


调用这两个类的客户端代码


/**
* Author(作者):jtl
* Date(日期):2023/2/10 20:04
* Detail(详情):模版方法模式客户端
*/
public class Client {
public static void main(String[] args) {
CCTVSpringFestivalGala cctv = new CCTVSpringFestivalGala();
cctv.start();

System.out.println("----------------分割线----------------");

SpringFestivalGalaOfLiaoning liaoning = new SpringFestivalGalaOfLiaoning();
liaoning.start();
}
}
复制代码

运行结果


结果如图所示
模版方法模式.png


模版方法模式的模版



  1. 有一个固定的模版类A,它是一个抽象类

  2. 模版类A里有一些方法,这些方法里有需要子类重写的抽象方法

  3. 有一个模版方法,它里面有着这些方法的调用顺序。这个顺序是不能被改变的,也是模版方法模式的核心

  4. 子类继承模版类A,重写它的抽象方法


后记总结


至此,模版方法模式就算是介绍完毕了。细心的小伙伴可能发现了,模版方法模式的模版如果要扩展的话,就必须改了啊,他这违反了开闭原则啊。


没错,这是这个模式的一个缺陷。从模版方法模式的定义来看,它的概念就是给其他类提供一套固定的执行流程,这个执行流程就是模版方法。其他类只能修改其中的方法,不能修改执行流程,即不能修改模版方法。所以它从定义上就不存在修改执行流程这一可能。可能有点强行洗白,但是这也是一种解释方式。


还是那句话,对于设计模式来说,没有固定的套路。毕竟它只是人们经过长时间总结出来的代码经验。所以千万别被所谓的设计模式框架所拘束,只要符合要求,有利阅读扩展就是好的代码。


如果喜欢请点个赞,支持一下。有错误或不同想法请及时指正哈。辛苦您看到这里,下篇文章再见哈,👋👋👋


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

一起掌握Kotlin协程基础

前言 在平时的开发中,我们经常会跟线程打交道,当执行耗时操作时,便需要开启线程去执行,防止主线程阻塞。但是开启太多的线程,它们的切换就需要耗费很多的资源,并且难以去控制,没有及时停止或者控制不当便很可能会造成内存泄露。并且在开启多线程后,为了能够获取到计算结果...
继续阅读 »

前言


在平时的开发中,我们经常会跟线程打交道,当执行耗时操作时,便需要开启线程去执行,防止主线程阻塞。但是开启太多的线程,它们的切换就需要耗费很多的资源,并且难以去控制,没有及时停止或者控制不当便很可能会造成内存泄露。并且在开启多线程后,为了能够获取到计算结果,我们需要采用回调的方式来回调结果,但是回调多了,代码的可读性变得很差。kotlin协程是运行在线程之上,我们使用它时能够很好地去控制它,并且在切换方面,它消耗的CPU和内存大大地降低,它不会阻塞所在线程,可以在不用使用回调的情况下便可以直接获取计算结果。


正文


协程程序


GlobalScope.launch { // 在后台启动一个新的协程并继续
   println("hello Coroutine")
}
//输出:hello Coroutine

GlobalScope调用launch会开启一个协程。


协程的组成



  • CoroutineScope

  • CoroutineContext:可指定名称、Job(管理生命周期)、指定线程(Dispatchers.Default适合CUP密集型任务、Dispatchers.IO适合磁盘或网络的IO操作、Dispatchers.Main用于主线程)

  • 启动:launch(启动协程,返回一个job)、async(启动带返回结果的协程)、withContext(启动协程,可出阿如上下文改变协程的上下文)


作业


当我们开启协程后,可能需要对开启的协程进行控制,比如在不再需要该协程的返回结果时,可将其进行取消。好在调用launch函数后,会返回一个协程的job,我们可利用这个job来进行取消操作。


val job = GlobalScope.launch {
   delay(1000)
   println("world")
}
println("hello")

job.cancel()

//输出结果为:hello

可能有的同学会觉得奇怪为什么world没有输出,原因是当调用cancel时,会对该协程进程取消,也就是不再执行了直接停止。下面再看一个join方法:


val job = GlobalScope.launch {
   delay(1000)
   println("world")
}
println("hello")
job.join()
//输出结果:
//hello
//world

join方法会等待该协程执行结束。


超时


当一个协程执行超时,我们可能需要取消它,但手动跟踪它的超时可能会觉得麻烦,所以我们可以使用withTimeout方法来进程超时跟踪:


withTimeout(1300) {
   repeat(10){i->
       println("i-->$i")
       delay(500)
  }
}
//i-->0
//i-->1
//i-->2
//抛出TimeoutCancellationException异常

这个方法在设置的超时时间还没完成时,抛出TimeoutCancellationException异常。如果我们只是单纯防止超时而不抛出异常,则可使用:


val wton = withTimeoutOrNull(1300){
   repeat(10){i->
       println("i-->$i")
       delay(500)
  }
}

println("end -- $wton")
//i-->0
//i-->1
//i-->2
//end -- null

挂起函数


当我们在launch函数中写了很多代码,这看上去并不美观,为了可以抽取出逻辑放到一个单独的函数中,我们可以使用suspend 修饰符来修饰一个方法,这样的函数为挂起函数:


suspend fun doCalOne():Int{
   delay(1000)
   return 5
}

挂起函数需要在挂起函数或者协程中调用,普通方法不能调用挂起函数。


我们通过使用两个挂起函数来获取它们各自的计算结果,然后对获取的结果进一步操作:


suspend fun doCalOne():Int{
   delay(1000)
   return 5
}
suspend fun doCalTwo():Int{
   delay(1500)
   return 3
}

coroutineScope {
       val time = measureTimeMillis {
           //同步开始,需要按顺序等待
           val one = doCalOne()
           val two = doCalTwo()
           println("one + two = ${one + two}")
      }
       println("time is $time")
}
//one + two = 8
//time is 2512

我们可以看到,计算结果正确,说明能够正常返回,而且总共的耗时是跟两个方法所用的时间的总和(忽略其他),那我们有没有办法让两个计算方法并行运行能,答案是肯定的,我们只需使用async便可以实现:


coroutineScope {
       val time = measureTimeMillis {
           //异步开始
           val one = async{doCalOne()}
           val two = async{doCalTwo()}
           //同步开始,需要按顺序等待
           println("one + two = ${one.await() + two.await()}")
      }
       println("time is $time")
}
//one + two = 8
//time is 1519

我们可以看到,计算结果正确,并且所需时间大大减少,接近运行最长的计算函数。


async类似于launch函数,它会启动一个单独的协程,并且可以与其他协程并行。它返回的是一个Deferred(非阻塞式的feature),当我们调用await方法才可以得到返回的结果。


async有多种启动方式,下面实例为懒性启动:


coroutineScope {
   //调用await或者start协程才被启动
   val one = async(start = CoroutineStart.LAZY){doCalOne()}
   val two = async(start = CoroutineStart.LAZY){doCalTwo()}

   one.start()
   two.start()
}

我们可以调用start或者await来启动它。


结构化并发


虽然协程很轻量,但它运行时还是需要耗费一些资源,如果我们在使用的过程中,忘记对它进行引用,并且及时地停止它,那将会造成资源浪费或者出现内存泄露等问题。但是一个一个跟踪(也就是使用返回的job)很不方便,一个两个还好管理,但是多了却不方便管理。于是我们可以使用结构化并发,这样我们可以在指定的作用域中启动协程。这点跟线程的区别在于线程总是全局的。大致如图(图片):


image.png


在日常开发中,我们会经常开启网络请求,有时候需要同时发起多个网络请求,我们想要的是在挂起函数中启动多个请求,当挂起函数返回时,里边的请求都执行结束,那么我们可以使用coroutineScope 来进行指定一个作用域:


suspend fun twoFetch(){

   coroutineScope {
       launch {
           delay(1000L)
           doNetworkJob("url--1")
      }
       launch { doNetworkJob("url--2") }
  }
}

fun doNetworkJob(url : String){
   println(url)
}
//url--2
//url--1

coroutineScope等到在其里边开启的所有协程执行完成再返回。所以twoFetch不会在coroutineScope内部所启动的协程完成前返回。


当我们取消协程时,会通过层次结构来进行传递的。


suspend fun errCoroutineFun(){

   coroutineScope {
       try {
           failedCorou()
      }catch (e : RuntimeException) {
           println("fail with RuntimeException")
      }
  }

}

suspend fun failedCorou() {

   coroutineScope {

       launch {
           try {
               delay(Long.MAX_VALUE)
               println("after delay")
          } finally {
               println("one finally")
          }
      }

       launch {
           println("two throw execption")
           throw RuntimeException("")
      }
  }
}
//two throw execption
//one finally
//fail with RuntimeException

结语


本次的kotlin协程分享也结束了,内容篇基础,也算是对kotlin协程的一个入门。当对它的使用达到熟练时,会继续分享一篇关于较进阶的文章,希望大家喜欢。


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

因为使用 try-cache-finally 读取文件 ,导致我被开除了.(try-with-resources 的使用方法)

前些天项目经理找到我说,阿杰,你过来一下,我这有个小方法,你帮我写一下 需求: 提供一个文本文件,按行读取,然后给出一个回调函数,可以由调用者去实现对每行的处理. 我就想,你这不是瞧不起我吗.5分钟搞定!!嘴里却说,你这个有点难,我需要研究下大概今天下班前...
继续阅读 »

前些天项目经理找到我说,阿杰,你过来一下,我这有个小方法,你帮我写一下



  • 需求: 提供一个文本文件,按行读取,然后给出一个回调函数,可以由调用者去实现对每行的处理.


我就想,你这不是瞧不起我吗.5分钟搞定!!嘴里却说,你这个有点难,我需要研究下大概今天下班前能完成.



5分钟过去了----> 代码完成




摸鱼3小时 ----> ok 代码一发,收工准备下班



public void clean2(String path, Consumer<String> consumer){
FileReader fileReader = null;
BufferedReader br = null;
try{
fileReader = new FileReader(path);
br = new BufferedReader(fileReader);
String line;
while((line = br.readLine()) != null ){
consumer.accept(line);
}
}catch (IOException e){
// do
}finally {
try {
if (br != null){
br.close();
}
if (fileReader != null){
fileReader.close();
}
} catch (IOException e) {
// do
}
}
}

项目经理 😶😶😶😶: 你tm明天别来了,自己去财务把这个月的结了,3行代码就写完的功能写成这个鬼样子.


那我就想啊,我写的这么完美,那凭什么开除我,经过我九九八十一天的苦思冥想,终于找到了问题的原因!!


try-cache-finally


try-finally 是java SE7之前我们处理一些需要关闭的资源的做法,无论是否出现异常都要对资源进行关闭。*


如果try块和finally块中的方法都抛出异常那么try块中的异常会被抑制(suppress),只会抛出finally中的异常,而把try块的异常完全忽略。


这里如果我们用catch语句去获得try块的异常,也没有什么影响,catch块虽然能获取到try块的异常但是对函数运行结束抛出的异常并没有什么影响。


try-with-resources



try-with-resources语句能够帮你自动调用资源的close()函数关闭资源不用到finally块。


前提是只有实现了Closeable接口的才能自动关闭



public void clean(String path, Consumer<String> consumer) throws IOException {
try (BufferedReader br = new BufferedReader(new FileReader(path))) {
String line;
while((line = br.readLine()) != null ){
consumer.accept(line);
}
}
}

这是try-with-resources语句的结构,在try关键字后面的( )里new一些需要自动关闭的资源。


这个时候如果方法 readLine 和自动关闭资源的过程都抛出异常,那么:




  1. 函数执行结束之后抛出的是try块的异常,而try-with-resources语句关闭过程中的异常会被抑制,放在try块抛出的异常的一个数组里。(上面的非try-with-resources例子抛出的是finally的异常,而且try块的异常也不会放在fianlly抛出的异常的抑制数组里)




  2. 可以通过异常的public final synchronized Throwable[] getSuppressed() 方法获得一个被抑制异常的数组。




  3. try块抛出的异常调用getSuppressed()方法获得一个被它抑制的异常的数组,其中就有关闭资源的过程产生的异常。




try-with-resources 语句能放多个资源,使用 ; 分割


try (
BufferedReader br = new BufferedReader(new FileReader(path));
ZipFile zipFile = new ZipFile("");
FileReader fileReader = new FileReader("");
) {

}

最后任务执行完毕或者出现异常中断之后是根据new的反向顺序调用各资源的close()的。后new的先关。


try-with-resources 语句也可以有 catch 和 finally 块


public void clean3(String path, Consumer<String> consumer){
try (BufferedReader br = new BufferedReader(new FileReader(path))) {
System.out.println("RuntimeException 前");
int a = 1/0;
System.out.println("RuntimeException 后");
}catch (RuntimeException e){
System.out.println("抛出 RuntimeException");
}catch (IOException e){
System.out.println("抛出 RuntimeException");
}finally {
System.out.println("finally");
}
}

RuntimeException 前
抛出 RuntimeException
finally

作者:全村最野的狗
链接:https://juejin.cn/post/7198847208564015164
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

Compose自定义View——宇智波斑写轮眼

本章节是Compose自定义绘制的第二章,画的是一个之前设计给的一个比较复杂的,设计所谓的会呼吸的动画,那时候实现花了蛮长的时间,搬着电脑跟设计一帧一帧地对,没多久后来需求就被拿掉了,至于文章的标题哈哈随意起了一个,长得有点像而已。 Compose的实现,图形...
继续阅读 »

本章节是Compose自定义绘制的第二章,画的是一个之前设计给的一个比较复杂的,设计所谓的会呼吸的动画,那时候实现花了蛮长的时间,搬着电脑跟设计一帧一帧地对,没多久后来需求就被拿掉了,至于文章的标题哈哈随意起了一个,长得有点像而已。


Compose的实现,图形本身跟上一章节的LocationMarker其实差不太多,倒过来了而已,调整了P1跟P3, 基本图形的Path,这里不再做介绍,读者也可以去看代码实现。主要介绍一下动画吧。


首先看一下gif动图:


waterDrop_AdobeExpress .gif


整个图形分三层,最底层是灰色的背景,没有动画实现。


第二层是一个层变的动画,每层有个delay的不同延迟,对alpha最一个ObjectAnimator.ofFloat(water1, "alpha", 0f, 0.5f, 0.2f, 1f)渐变的动画,0.5f 到0.2f, 再到1f这个地方展现出所谓的呼吸的感觉。Compose目前写的不多,有些冗余代码没有抽象,先实现了功能效果。


@Composable
fun drawWaterDrop(){
 val waterDropModel by remember {
 mutableStateOf(WaterDropModel.waterDropM)
}
 val color1 = colorResource(id = waterDropModel.water1.colorResource)
 val color2 = colorResource(id = waterDropModel.water2.colorResource)
 val color3 = colorResource(id = waterDropModel.water3.colorResource)
 val color4 = colorResource(id = waterDropModel.water4.colorResource)
 val color5 = colorResource(id = waterDropModel.water5.colorResource)
 val color6 = colorResource(id = waterDropModel.water6.colorResource)
 val color7 = colorResource(id = waterDropModel.water7.colorResource)
 val color8 = colorResource(id = waterDropModel.water8.colorResource)

 val animAlpha1 = remember { Animatable(0f, Float.VectorConverter) }
 val animAlpha2 = remember { Animatable(0f, Float.VectorConverter) }
 val animAlpha3 = remember { Animatable(0f, Float.VectorConverter) }
 val animAlpha4 = remember { Animatable(0f, Float.VectorConverter) }
 val animAlpha5 = remember { Animatable(0f, Float.VectorConverter) }
 val animAlpha6 = remember { Animatable(0f, Float.VectorConverter) }
 val animAlpha7 = remember { Animatable(0f, Float.VectorConverter) }
 val animAlpha8 = remember { Animatable(0f, Float.VectorConverter) }


LaunchedEffect(Unit){
animAlpha1.animateTo(1f, animationSpec = myKeyframs(0))
}
LaunchedEffect(Unit){
animAlpha2.animateTo(1f, animationSpec = myKeyframs(1))
}
LaunchedEffect(Unit){
animAlpha3.animateTo(1f, animationSpec = myKeyframs(2))
}
LaunchedEffect(Unit){
animAlpha4.animateTo(1f, animationSpec = myKeyframs(3))
}
LaunchedEffect(Unit){
animAlpha5.animateTo(1f, animationSpec = myKeyframs(4))
}
LaunchedEffect(Unit){
animAlpha6.animateTo(1f, animationSpec = myKeyframs(5))
}
LaunchedEffect(Unit){
animAlpha7.animateTo(1f, animationSpec = myKeyframs(6))
}
LaunchedEffect(Unit){
animAlpha8.animateTo(1f, animationSpec = myKeyframs(7))
}

 Canvas(modifier = Modifier.fillMaxSize()){
 val contentWidth = size.width
 val contentHeight = size.height
 withTransform({
 translate(left = contentWidth / 2, top = contentHeight / 2)}) {
 drawPath(AndroidPath(waterDropModel.water8Path), color = color8, alpha = animAlpha8.value)
 drawPath(AndroidPath(waterDropModel.water7Path), color = color7, alpha = animAlpha7.value)
 drawPath(AndroidPath(waterDropModel.water6Path), color = color6, alpha = animAlpha6.value)
 drawPath(AndroidPath(waterDropModel.water5Path), color = color5, alpha = animAlpha5.value)
 drawPath(AndroidPath(waterDropModel.water4Path), color = color4, alpha = animAlpha4.value)
 drawPath(AndroidPath(waterDropModel.water3Path), color = color3, alpha = animAlpha3.value)
 drawPath(AndroidPath(waterDropModel.water2Path), color = color2, alpha = animAlpha2.value)
 drawPath(AndroidPath(waterDropModel.water1Path), color = color1, alpha = animAlpha1.value)
}
}
}

private fun myKeyframs(num:Int):KeyframesSpec<Float>{
return keyframes{
durationMillis = 3000
delayMillis = num * 2000
0.5f at 1000 with LinearEasing
0.2f at 2000 with LinearEasing
}
}

然后就是外层扫光的动画,像探照灯一样一圈圈的扫,一共扫7遍,代码跟层变动画差不多,也是对alpha值做渐变,目前代码是调用扫光动画7次,后续看看如何优化性能。每次调用传入不同的delay值即可。


@Composable
fun drawWaterDropScan(delayTime:Long){
   val waterDropModel by remember {
       mutableStateOf(WaterDropModel.waterDropMScan)
  }
   val color1 = colorResource(id = waterDropModel.water1.colorResource)
   val color2 = colorResource(id = waterDropModel.water2.colorResource)
   val color3 = colorResource(id = waterDropModel.water3.colorResource)
   val color4 = colorResource(id = waterDropModel.water4.colorResource)
   val color5 = colorResource(id = waterDropModel.water5.colorResource)
   val color6 = colorResource(id = waterDropModel.water6.colorResource)
   val color7 = colorResource(id = waterDropModel.water7.colorResource)
   val color8 = colorResource(id = waterDropModel.water8.colorResource)
   val animAlpha2 = remember { Animatable(0f, Float.VectorConverter) }
   val animAlpha3 = remember { Animatable(0f, Float.VectorConverter) }
   val animAlpha4 = remember { Animatable(0f, Float.VectorConverter) }
   val animAlpha5 = remember { Animatable(0f, Float.VectorConverter) }
   val animAlpha6 = remember { Animatable(0f, Float.VectorConverter) }
   val animAlpha7 = remember { Animatable(0f, Float.VectorConverter) }
   val animAlpha8 = remember { Animatable(0f, Float.VectorConverter) }

LaunchedEffect(Unit){
delay(delayTime)
val map = mutableMapOf<Float, Int>().apply { put(1f, 350) }
animAlpha2.animateTo(0f, animationSpec = myKeyframs2(700, 0, map))
}

LaunchedEffect(Unit){
delay(delayTime)
val map = mutableMapOf<Float, Int>().apply { put(0.8f, 315) }
animAlpha3.animateTo(0f, animationSpec = myKeyframs2(630, 233, map))
}

LaunchedEffect(Unit){
delay(delayTime)
val map = mutableMapOf<Float, Int>().apply { put(0.55f, 315) }
animAlpha4.animateTo(0f, animationSpec = myKeyframs2(630, 383, map))
}

LaunchedEffect(Unit){
delay(delayTime)
val map = mutableMapOf<Float, Int>().apply { put(0.5f, 325) }
animAlpha5.animateTo(0f, animationSpec = myKeyframs2(650, 533, map))
}

LaunchedEffect(Unit){
delay(delayTime)
val map = mutableMapOf<Float, Int>().apply { put(0.45f, 325) }
animAlpha6.animateTo(0f, animationSpec = myKeyframs2(650, 667, map))
}

LaunchedEffect(Unit){
delay(delayTime)
val map = mutableMapOf<Float, Int>().apply { put(0.35f, 283) }
animAlpha7.animateTo(0f, animationSpec = myKeyframs2(567, 816, map))
}

LaunchedEffect(Unit){
delay(delayTime)
val map = mutableMapOf<Float, Int>().apply { put(0.3f, 216) }
animAlpha8.animateTo(0f, animationSpec = myKeyframs2(433, 983, map))
}

 Canvas(modifier = Modifier.fillMaxSize()){
 val contentWidth = size.width
 val contentHeight = size.height

 withTransform({
 translate(left = contentWidth / 2, top = contentHeight / 2)
}) {
 drawPath(AndroidPath(waterDropModel.water8Path), color = color8, alpha = animAlpha8.value)
 drawPath(AndroidPath(waterDropModel.water7Path), color = color7, alpha = animAlpha7.value)
 drawPath(AndroidPath(waterDropModel.water6Path), color = color6, alpha = animAlpha6.value)
 drawPath(AndroidPath(waterDropModel.water5Path), color = color5, alpha = animAlpha5.value)
 drawPath(AndroidPath(waterDropModel.water4Path), color = color4, alpha = animAlpha4.value)
 drawPath(AndroidPath(waterDropModel.water3Path), color = color3, alpha = animAlpha3.value)
 drawPath(AndroidPath(waterDropModel.water2Path), color = color2, alpha = animAlpha2.value)
 drawPath(AndroidPath(waterDropModel.water1Path), color = color1)
}
}
}

private fun myKeyframs2(durationMillisParams:Int, delayMillisParams:Int, frames:Map<Float, Int>):KeyframesSpec<Float>{
return keyframes{
durationMillis = durationMillisParams
delayMillis = delayMillisParams
for ((valueF, timestamp) in frames){
valueF at timestamp
}
}
}


@Preview
@Composable
fun WaterDrop(){
   Box(modifier = Modifier.fillMaxSize()){
       drawWaterDropBg()
       drawWaterDrop()
       for (num in 1 .. 7){
           drawWaterDropScan(delayTime = num * 2000L)
      }
  }
}

代码跟LocationMarker在一个Project里面,暂时没有添加导航。github.com/yinxiucheng… 下的CustomerComposeView.


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

Android约束布局ConstraintLayout的使用

Android引入约束布局的目的是为了减少布局层级的嵌套,从而提升渲染性能。约束布局综合线性布局、相对布局、帧布局的部分功能,缺点也很明显,就是可能要多写几行代码。所以约束布局使用时,还得综合考虑代码量。提升性能也并不一定非得使用约束布局,也可以在ViewGr...
继续阅读 »

Android引入约束布局的目的是为了减少布局层级的嵌套,从而提升渲染性能。约束布局综合线性布局、相对布局、帧布局的部分功能,缺点也很明显,就是可能要多写几行代码。所以约束布局使用时,还得综合考虑代码量。提升性能也并不一定非得使用约束布局,也可以在ViewGroup上dispatchDraw。你需要根据业务的具体情况选择最合适的实现方式。我知道很多人一开始很不习惯使用约束布局,但既然你诚心诚意问我怎么使用了?于是我就大发慈悲告诉你怎么使用呗。


链式约束


用得最多的非链式约束莫属了。这看起来是不是类似于相对布局?那么有人问了,既然相对布局写法这么简洁,都不用强制你写另一个方向的占满屏幕的约束,为什么还要使用约束布局呢?约束布局它还是有它过布局之处的,比如以下一些功能,相对布局是做不到的。


<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ImageView
android:id="@+id/iv1"
android:layout_width="100dp"
android:layout_height="100dp"
android:background="#F0F"
app:layout_constraintVertical_chainStyle="spread"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintBottom_toTopOf="@+id/iv2"/>

<ImageView
android:id="@+id/iv2"
android:layout_width="100dp"
android:layout_height="100dp"
android:background="#FF0"
app:layout_constraintBottom_toTopOf="@id/iv3"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/iv1"/>
<ImageView
android:id="@+id/iv3"
android:layout_width="100dp"
android:layout_height="100dp"
android:background="#0FF"
app:layout_constraintBottom_toTopOf="@id/iv4"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/iv2"/>
<ImageView
android:id="@+id/iv4"
android:layout_width="100dp"
android:layout_height="100dp"
android:background="#0F0"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/iv3"/>
</androidx.constraintlayout.widget.ConstraintLayout>

我们可以在链首的控件添加一个layout_constraintVertical_chainStyle属性为spread,翻译成展开,在我看来就是排队,要保持间距一样,而且边缘不能站,默认不写也是指定的spread。


链式约束spread
如果你改成spread_inside,就会变成可以靠墙的情况。


链式约束spread inside
那如果你改成packed,就会贴在一起了。


链式约束packed


使用Group分组进行显示和隐藏


而如果你添加以下代码在布局中,就会将id为iv1和iv3点色块去掉,这样iv2和iv4就贴在一起了。


<androidx.constraintlayout.widget.Group
android:id="@+id/group"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
app:constraint_referenced_ids="iv1,iv3" />

分组约束


Guideline引导线


<androidx.constraintlayout.widget.Guideline
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
app:layout_constraintGuide_percent="0.5"/>

使用引导线,可以在预览布局的时候看到,在运行时是看不到的,可以作为布局的参考线。


引导线
切换到Design的选项卡你就能看到了。
引导线的另外两个属性是layout_constraintGuide_begin和layout_constraintGuide_end,一看就知道这个是使用边距定位的。


角度约束


角度约束的以下三个属性是一起使用的。


layout_constraintCircle  
layout_constraintCircleAngle
layout_constraintCircleRadius

<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">

<ImageView
android:id="@+id/iv1"
android:layout_width="100dp"
android:layout_height="100dp"
android:background="#FFC0C0"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintBottom_toBottomOf="parent"/>

<ImageView
android:id="@+id/iv2"
android:layout_width="100dp"
android:layout_height="100dp"
android:background="#FF0"
app:layout_constraintCircle="@id/iv1"
app:layout_constraintCircleAngle="30"
app:layout_constraintCircleRadius="150dp"/>
<androidx.constraintlayout.widget.Guideline
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
app:layout_constraintGuide_percent="0.5"/>
<androidx.constraintlayout.widget.Guideline
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
app:layout_constraintGuide_percent="0.5"/>

</androidx.constraintlayout.widget.ConstraintLayout>

知道你们喜欢粉嫩的,所以特地把色块的颜色换了一下。旋转角是以垂直向上为0度角,顺时针旋转30度。距离则是计算两控件重心的连线。在矩形区域中,重心就在对角线的交叉点。


角度约束


位置百分比偏移


<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">

<ImageView
android:id="@+id/iv1"
android:layout_width="100dp"
android:layout_height="100dp"
android:background="#FFC0C0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toTopOf="@+id/iv2"/>

<ImageView
android:id="@+id/iv2"
android:layout_width="100dp"
android:layout_height="100dp"
android:background="#FF0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.4"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toBottomOf="@+id/iv1"/>
<androidx.constraintlayout.widget.Guideline
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
app:layout_constraintGuide_percent="0.5"/>
</androidx.constraintlayout.widget.ConstraintLayout>

这里需要注意,不是只使用layout_constraintHorizontal_bias就可以了,原有该方向的约束也不能少。


百分比偏移


使用goneMargin设置被依赖的控件gone时,依赖控件的边距


goneMargin有以下属性:


layout_goneMarginStart  
layout_goneMarginEnd
layout_goneMarginTop
layout_goneMarginBottom

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">

<ImageView
android:id="@+id/iv1"
android:layout_width="100dp"
android:layout_height="100dp"
android:background="#FFC0C0"
android:visibility="gone"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintBottom_toTopOf="@+id/iv2"/>

<ImageView
android:id="@+id/iv2"
android:layout_width="100dp"
android:layout_height="100dp"
android:background="#FF0"
app:layout_goneMarginTop="10dp"
app:layout_constraintTop_toBottomOf="@+id/iv1"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"/>

<androidx.constraintlayout.widget.Guideline
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
app:layout_constraintGuide_begin="10dp"/>
</androidx.constraintlayout.widget.ConstraintLayout>

goneMargin


约束宽高比


<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">

<ImageView
android:id="@+id/iv1"
android:layout_width="0dp"
android:layout_height="100dp"
app:layout_constraintDimensionRatio="1:1"
android:background="#FFC0C0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toTopOf="@id/iv2"/>

<ImageView
android:id="@+id/iv2"
android:layout_width="100dp"
android:layout_height="0dp"
android:background="#FF0"
app:layout_constraintDimensionRatio="H,3:2"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/iv1"
app:layout_constraintBottom_toBottomOf="parent"/>
</androidx.constraintlayout.widget.ConstraintLayout>

我们可以将宽高的其中一个设置为0dp,然后这个宽或高根据相对于另一个的比例来。如果高度为0dp,需要根据宽度来确认高度,你可以直接赋值为3:2,也可以赋值为H,3:2,这也是推荐的写法,我一般省略W和H。如果高度为0dp,你本应该写H,而你写成了W,那就要把比例反过来看宽高比。


约束宽高比


权重约束


这个类似于线性布局的权重功能。


<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">

<ImageView
android:id="@+id/iv1"
android:layout_width="100dp"
android:layout_height="0dp"
app:layout_constraintVertical_weight="1"
android:background="#FFC0C0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toTopOf="@id/iv2"/>

<ImageView
android:id="@+id/iv2"
android:layout_width="100dp"
android:layout_height="0dp"
app:layout_constraintVertical_weight="2"
android:background="#FF0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/iv1"
app:layout_constraintBottom_toBottomOf="parent"/>
</androidx.constraintlayout.widget.ConstraintLayout>

权重约束


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

兔兔进度条——安卓WebView添加进度条

前言 本来准备过年时候慢慢写,但过完年才发现一篇都没写,真是难为情,今天我们就看看RabbitAPP中进入webview中使用的兔兔进度条,参考了51博客网的自定义progress的方法(自定义view之自定义电池效果_xiyangyang8110的技术博客_...
继续阅读 »

前言


本来准备过年时候慢慢写,但过完年才发现一篇都没写,真是难为情,今天我们就看看RabbitAPP中进入webview中使用的兔兔进度条,参考了51博客网的自定义progress的方法(自定义view之自定义电池效果_xiyangyang8110的技术博客_51CTO博客),其实还是挺简陋的,本来想画一个兔子跑的指示器的progress的,但是想了半天没动手,还是采用这种最简单的方法。


正篇


最终效果


首先我们来看看效果:


c5073ddb04cb10cfced2b237e4781e44.gif


由于网络非常好,所以加载速度也很快,我们可以看到兔子背景逐渐被红色覆盖。

实现方法


实现方法其实很简单,先给一张图片,然后调用ProgressBar控件覆盖它,并且重新写ProgressBar的样式:


image.png


<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="StyleRabbitProgressBar" parent="Widget.AppCompat.ProgressBar.Horizontal">
<item name="android:progressDrawable">@drawable/shape_progressbar</item>
</style>
</resources>

我们这里使用了ProgressBar的水平进度条样式,然后对其sprogressDrawable重新添加shape:


<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<!--progressbar的背景颜色-->
<!-- <item android:id="@android:id/background">-->
<!-- <shape>-->
<!-- <corners android:radius="5dip" />-->
<!-- <gradient-->
<!-- android:startColor="@color/black"-->
<!-- android:centerColor="@color/blue"-->
<!-- android:endColor="@color/black"-->
<!-- android:angle="270"-->
<!-- />-->
<!-- </shape>-->
<!-- </item>-->
<!--progressBar的缓冲进度颜色-->
<!-- <item android:id="@android:id/secondaryProgress">-->
<!-- <clip>-->
<!-- <shape>-->
<!-- <corners android:radius="5dip" />-->
<!-- <gradient-->
<!-- android:startColor="@color/white"-->
<!-- android:centerColor="@color/white"-->
<!-- android:endColor="@color/white"-->
<!-- android:angle="270"-->
<!-- />-->
<!-- </shape>-->
<!-- </clip>-->
<!-- </item>-->
<!--progressBar的最终进度颜色-->
<item android:id="@android:id/progress">
<clip>
<shape>
<corners android:radius="5dip" />
<gradient
android:startColor="#33E91E63"
android:centerColor="#33E91E63"
android:endColor="#33E91E63"
android:angle="270"
/>
</shape>
</clip>
</item>
</layer-list>

根据需要对进度颜色进行控制,我们最终采用棕红色,对进度条颜色变更,最后我们加入到webview页面的布局中:


<RelativeLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center">

<ImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@mipmap/rabbit_progress" />
<ProgressBar
android:id="@+id/progressRabbit"
android:layout_marginTop="5dp"
android:layout_marginStart="4dp"
style="@style/StyleRabbitProgressBar"
android:layout_width="130dp"
android:layout_height="120dp"
android:max="100" />
</RelativeLayout>

最后,再到webview页面的Activity代码中控制显示:


 binding.vWebView.webChromeClient = object : WebChromeClient() {
override fun onProgressChanged(view: WebView?, newProgress: Int) {
Log.i("onPageStarted", "页面加载")

binding.progressRabbit.progress = newProgress
}

我们通过WebView的webChromeClient方法对onProgressChanged复写,对其中的newProgress参数赋值给我们进度条控件的progress参数,这样就起到了对网页加载的可视化。


于是我们就可以在web加载的时候看到上面有个兔子,兔子的背景全红后就加载好网页了。


总结


这个进度条现在越看越难受,下一次会把进度条重新制作一遍,还是把它作为指示器去绘制一个进度条比较好,不过之前我写自定义view都是用Java,Kotlin中还是不会写,希望能尽快学会用Kotlin自定义view,感谢您的观看。


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

兔兔进度条Plus——SeekBar充当Progress

前言 之前写的progress其实根本没有起到进度条的作用,太显眼,而且并不好看,所以有了新的想法,我们将ProgressBar控件换成SeekBar控件,然后再将它的thumb指示器换成小兔子即可。 正篇 实现过程 首先,我们在需要进度条的页面布局的最开始加...
继续阅读 »

前言


之前写的progress其实根本没有起到进度条的作用,太显眼,而且并不好看,所以有了新的想法,我们将ProgressBar控件换成SeekBar控件,然后再将它的thumb指示器换成小兔子即可。


正篇


实现过程


首先,我们在需要进度条的页面布局的最开始加上下面代码:


<SeekBar
android:id="@+id/vSeekBar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:thumb="@mipmap/rabbit_progress"/>

其中thumb属性就是可以让你换指针样式的,而SeekBar其实也是多用于播放器的进度选择器之类的,由于seekbar是可以拖动的,所以我们得把控件拖动给禁止了:


binding.vSeekBar.isEnabled = false

接着,我们为了更好的展示效果,在seekbar控件下面加了一个Button:


image.png


binding.vButton.setOnClickListener {
if (binding.vSeekBar.visibility != View.GONE) {
binding.vSeekBar.progress += 10
}
if (binding.vSeekBar.progress == 100) {
binding.vSeekBar.progress = 0
}
}

添加完按钮后,我们为按钮设置点击事件,每点一次就会出现进度条加10的事件,到达100后再置为0重复操作:


f6e01d316d1532e92a789f5e2291e923.gif


这样,我们就有了一个兔子往前进的进度条,然后我们再把Button去除,再换到webview的webChromeClient中的重写方法onProgressChanged中控制进度条增加的逻辑即可:
```Kotlin
binding.vSeekBar.progress = newProgress
if (newProgress == 100) {
binding.vSeekBar.visibility = View.GONE
} else {
binding.vSeekBar.visibility = View.VISIBLE
}
```
当进度条加完后,就隐藏该控件,这样也就不会一直看到这个控件。
# 总结
虽然内容不多,但是问题还是很多的,如果可以再把style样式做一下,效果会更好,然后再重新定义一下进度条本体的颜色和形状,不过,目前我对这部分还看的比较少,网上看到的自定义也非常繁多,等后面用Kotlin自定义View熟练了再重新画一个Progress或SeekBar.

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

疫情过后的这个春招,真的会回暖吗?

哈喽大家好啊,我是Hydra。今天是正月初七,不知道大家有没有复工,反正我今天已经坐在办公室里开始码字了。这个春节假期相信大家过的都不错,可以看到今年无论是回家探亲、还是外出旅游的人数,都比疫情放开前两年有了爆发式的增长。假期我躺在被窝里刷抖音,每当刷到哪个景...
继续阅读 »

哈喽大家好啊,我是Hydra。

今天是正月初七,不知道大家有没有复工,反正我今天已经坐在办公室里开始码字了。

这个春节假期相信大家过的都不错,可以看到今年无论是回家探亲、还是外出旅游的人数,都比疫情放开前两年有了爆发式的增长。假期我躺在被窝里刷抖音,每当刷到哪个景点人满为患到走不动路的时候,都觉得自己宅在家里哪也不去真的是太对了。

好了回归正题,很多小伙伴们非常关注的一个问题,在经历了疫情放开、大规模感染的相对平稳后,这一届春招真的会回暖吗?

在聊春招之前,我觉得还是有必要再来科普一下春招的时间线。

  • 12月,一般只有少量的企业开始进行春招提前批预热,或是进行秋招的补录
  • 1月,部分公司开启春招正式批
  • 3-4月,才是春招的高峰期,大部分公司在这个时间段陆续开启春招
  • 5月,大部分的企业会结束招聘

为了了解今年的形势,我也逛了不少论坛,了解到有一些大厂在去年12月底的时候,就已经开始了秋招的补录,不少人收到了补录的通知。

通过整体氛围来看,今年春招大概率会比去年进行一波升温,在岗位的可选择性上,大伙可能也有更多的大厂岗位可以进行一波冲击。尽管如此我还是劝大家要尽早准备,因为虽然说是春招,但并不是真正到了春天才真正开始,并且春招的难度比秋招可能还要高上不少。

首先,相对于秋招来说,春招的岗位会少很多,因为春招更多是对于秋招的补充,是一个查漏补缺的过程,对秋招中没有招满、或者有新岗位出现的情况下,才会在春招中放出该岗位。少量的岗位,需要你能更好的把握信息资源,迅速出击。

其次,你可能拥有更多的竞争对手,考研、考公失利的同学如果不选择二战,将会大量涌入春招,而对于秋招找到的工作不满意想要跳槽的同学,有笔试面试经验、工作经历,将会成为你春招路上麻烦的对手。

所以说到底,大家还是不要过于盲目乐观,扎扎实实的准备肯定是不能少的,毕竟春招的难度摆在这里。在看到大规模补录的同时,我们也不能否认背后的裁员依旧存在。有可能你现在看到的hc,就是在不久前刚刚通过裁员所释放的。

另外,我还是得说点泼冷水的话,虽然看上去形势一片大好,岗位放开了很多,但不代表薪资待遇还是和以前一样的,从一个帖子中可以看到,即便是在杭州和成都的中厂里,降薪也是存在的。

因为说到底,疫情并不是经济下行的根本原因,想要寄希望于疫情放开后经济能够快速复苏基本是不可能的。

国内的互联网公司,已经过了那个爆发式发展的黄金时期,甚至说一句互联网公司规模已经能隐隐约约窥到顶峰也不过分。美联储加息、中概股暴跌、企业融资困难…面对这些困难的环境,即使疫情放开也于事无补。

尽管环境如此困难,我仍然认为互联网行业是小镇做题家们快速实现社会价值、积累财富的黄金职业。看看大厂里十几万、几十万的年终奖,并不是每个行业都能做到的。

最后还是建议大家,积极准备,不管这个春招是否回暖,还是要做到尽量不留遗憾,不要给自己找借口,再寄希望于下一个秋招。

2023年,我们一起加油!


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

卷王都在偷偷准备金三银四了

春节长假的尾巴怕是抓不住了,那就安心等五一吧。 可一回到岗位上,熟悉的办公室味道,熟悉的同事,月薪8000,互相笑嘻嘻。 突然迎来老板的动员大会,今年蒸蒸日上,大家共创辉煌,抿一口蛋糕,大家都是一家人。 但仔细想了想,你还欠我年终奖呢。 回到几平米的屋子,用身...
继续阅读 »

春节长假的尾巴怕是抓不住了,那就安心等五一吧。


可一回到岗位上,熟悉的办公室味道,熟悉的同事,月薪8000,互相笑嘻嘻。


突然迎来老板的动员大会,今年蒸蒸日上,大家共创辉煌,抿一口蛋糕,大家都是一家人。


但仔细想了想,你还欠我年终奖呢。


回到几平米的屋子,用身子量了量床,打开某脉,某乎,果然大家都一样,年终奖没发;简历石沉大海;发消息只读不回。


顺手打开某招聘,看了看岗位,这个厂还不错,可是要求好高,我啥都不会。


“哎,算了,我简历还没更新呢,我躺到6月份拿到年终奖再跑路。”


“Timi~”


这是不是你的状态呢?我们习惯被一些悲观的环境因素所影响,以至于在网络上寻求共鸣,麻痹自己停滞不前,甚至看到别人比自己惨内心略显喜悦。


最终一年一年下来,玩没玩到,钱没赚到,反倒把自己给内耗住了。


故此,写一篇求职经验相关的文章,此文不对职业规划做文章,也不是鼓励跳槽,更不是和任何人作对。


而是,刚毕业求职,打算跳槽,已经离职进行求职的一个经验分享。


也是以我经验以及身边大佬的一些经验进行分享。这里包含面试前的心态调整、简历制作、面试沟通指导等内容。


文末更有简历模板,全面的面试资料,以及面试经验指导。


心态篇


第一个需要转变的就是心态问题。在这里我其实主张“平等相待,泰然处之”。


很多伙伴对于求职的心态总是低于其一端,但是其本质是你提供劳动力,对方提供相应的报酬,一拍即合的事,没有因为对方提供报酬你就对他畏首畏尾。


从我毕业到现在,没有任何一场因为自己菜,对面试官的提问答不出来,题目做不出来,但是对面试官毕恭毕敬,甚至低头哈腰而促成一个岗位。


很简单,面试官也是一个员工,负责技术把关。当你实力不行但是态度很OK的时候,面试官点头同意,进入岗位后所造成的一切损失,面试官也会面临连带作用,被质疑技术行不行,看人准不准。他们也渴望一场势均力敌的较量呢。


所以,我们尽可能的对阵面试官,都能把心态置于同一水平上,不卑不亢,切磋技术,聊经验,聊项目,全当朋友一样聊天。


技术栈势均力敌就多沟通探讨一点,他强就虚心请教并告知某某知识点还没过多接触,你强就大致内容讲述出来,点到为止不要秀技即可。


我相信面试官基本在公司具备一定权重的,或许有可能就是入职引导你的人。也是很讲礼貌的,基础的问候相信大家都明白,也不讲述,讲多了画风就变了。


摆平心态不是几句劝诫的话就能做到的。


我以前比较弱势,对人心态总是低于一端,语气小,讲的内容不够自信甚至紧张,一紧张很多马脚露出来了,一些知识点的也连不上,支支吾吾作答。


所以我在2022年就专门花了心思去解决这一问题,具体行动往期文章也有透露,但帮助最大的还是反复阅读 《庄子.内篇》 以及B站的视频注解,让我的内心平淡了很多,也看开了很多,内篇中比较出名的在我们高中课本《逍遥游》也有学到。



“至人无己,神人无功,圣人无名。”


所有的一切,领导也好,老板也好,大佬也好。都是自己的意识有意给人贴上的标签,本质都是人,都是生物,多细胞生物。


或许吃穿奢靡,身居庙堂,那又如何呢?不都是碳基生物吗?


描述有些不当,本意是让大家心态放开点,没有什么大不了的。


依我个人经验,在自己的心结开放了之后,在面试求职时,基本对等谈话,会的知识点就多说一点,对知识点不那么熟就借助经验和案例来分析,不会就讲明没有过多了解,不丢人。


在平等状态下聊天,很多知识点能够由点连成片,顺势探讨下去,不仅你的感觉良好,面试官体验也很棒。


我在2022年国庆结束后,裸辞后选择继续求职,深圳和杭州都有offer,更有甚者免试用期,14薪,增加薪资邀请加入他们。


在那一刻明白,企业在面对一个真正人才时不会吝啬待遇,关键你是不是他要找的人。


简历篇


简历,或许是每一位求职者的第一道门槛,一个简历能够看出你对这份职业的用心程度,和你的细心严谨程度。


为什么这么说呢?


前阵子也帮一些伙伴检查过和更改过简历,一路下来更新简历版本中,出现过错别字,排版不雅观,描述有误,甚至有时候邮箱,电话号码都写错过。


纠结简历石沉大海,只读不回的原因,往往是一些细节导致。有些 HR 对于文字是很严苛的,一见到细微地方不对,就会联想到候选人不够严谨。毕竟作为一名程序员,对数据严谨和信息敏感难道不是应该的吗?


基础信息,岗位经历描述切勿忽视,文字表达也是需要斟酌,完事后多审查几遍,这个只有靠自己的习惯和用心程度。


连自己的简历都看不到 5 遍,这是对自己的自信还是对自己经历不忍直视,何况给人改简历挑毛病都要阅读几遍呢。


那我们回归到简历排版上,选择排版上也推崇精简排版,把一些基础信息,工作经历,技术栈描述清楚就OK,并不需要多花里胡哨。


在描述专业技能时,根据自身情况描述清楚,注意一些技术名词的写法,有些技术严谨的人对于写法还是尤其在意的。


曾有一次自己写的一篇文章对一个技术英文写法不正确,一部分的人对我进行批评和纠正。所以有一些细微的细节在自己看来微乎其微甚至无所谓,但总是有人会持有不同看法。



▲图/ 简历基础信息示例一


又如下图,精简模板即可,把自己的基本信息描述清楚即可,谨慎出现错别字和联系方式信息错误等。


image.png


▲图/ 简历基础信息示例二


有伙伴咨询过我,如果自己是专升本的情况该怎么填写简历。


如果是以上情况,可以准备多份简历,一份简历头部的基础,学历为本科,院校为本科院校,在后面的教育背景一栏,则一行为本科院校,第二行为专科院校。


另一份,则简历头部基础信息填写本科的学校信息,教育背景这一栏清除不填写或者只写本科一栏。



▲图/ 简历教育信息示例一


如果是你心仪的岗位,根据岗位要求,发送相应要求的简历,先获取展示自己的机会再说。


至于项目经历,这可能是第二重点,一些岗位会根据你的经历来招聘,上手会快一些,比如一家企业的岗位物联网方向较多,那他更加倾向于熟悉在物联网设备上有相关经历的伙伴。


同时在面试时,面试官更多可能通过你的项目经历以及用到的技术栈来考察你。


这里有个小技巧就是,你的技术栈和项目经历可以按实际需求写,当你发现有一些岗位是你心仪但是你又没有相关经验之后,你的技术栈和项目经历里可以稍微加上匹配岗位的技术栈和技术使用经历,这里虽然给自己留了坑,但是在获取面试机会之后需要自己补充相关的知识点。



▲图/ 简历项目经历示例一



▲图/ 简历项目经历示例二


以上两个案例,描述一个项目经历的基本信息,包含项目是什么,怎么做,做了什么,你负责并担任了什么,你的收获又是什么。


通俗一点,就是不要以自己的角度去写,要给到面试官角度去写,让面试官通过你的项目经历了解你的能力和经历,知道这个项目的权重比重是否大,你又负责了多少职责以及使用了什么手段去完成这个项目。


自我介绍一栏,阐明自己的一些辅助优点,例如你的自我评价是怎样的一个人,对于团队、岗位你能够具备什么样的素质,以及业余会干嘛,是否有更迭技术等等。



▲图/ 简历自我评价示例一



▲图/ 简历自我评价示例二


另外,简历文件的格式一定要规范化,文件命名名为:姓名+岗位+学历+联系方式。 例如:桑小榆-开发岗-本科-1517258505。


这里有伙伴不规范原因就是以自身角度想法,打开一看就知道是你,但是没经历过永远不知道一个 HR 面对一群人挑选简历时的心酸,命名的规范化突出略显重要。


面试篇


前期的准备,都是在做铺垫,为的就是和面试官阵面对决,这绝对是一个综合素质的体现,展示技术情况,沟通实力以及心理素质情况。


对于前期心态的准备以及简历的准备,很幸运的被邀请到了面试,这时候你需要准备的就是对被邀请的企业背景了解和岗位的大致了解。


以至于不那么被动的和面试官尬聊:你有什么想要问的吗?


这里面试的环境,基本包含了笔试,机试,面聊等等。


对于笔试和机试,那一定是对于自身知识储备的考验了,这里需要我们自身去积累和回顾了。


面试造飞机,工作拧螺丝或许是很常见的行为,这也不得不让我们需要对笔试题和机试题的一些提前准备了。


找工作不容易,大家何尝不想当一名八股文选手,怕就怕有人连八股文的苦都不想吃。


这里我也准备了 Web 岗位,.NET 岗位,和 JAVA 岗位的面试资料,更有简历模板奉上,大家也可在文末查看。


对于面试时自我介绍,如果你能够很好的讲诉自己那不用说,如果不是很清晰,可以自己写好一段自我介绍,记熟悉就好,面试的时候围绕着写的内容可以很好的完成自我介绍。


对于技术面试,大多会围绕你简历所写的知识进行提问,尽可能的熟悉你的简历和所写到的技术栈,在回答问题时尽量引导你熟悉的技术范围内,不要炫技或者提到你听过但是不熟悉的知识点,这样引导下去将会很糟糕。


我曾经有一次就是回答 AOP 编程思想时,讲完一些大概内容之后还提到了框架的使用,结果对面直接提问如果不用框架,自己手动代码实现会怎么做,这就往困难的方向了,好在心态比较稳加上有过经验一点一点回答上了。


结束篇


如果,老东家不是那么抠门,我找啥自行车啊。


如果,老东家体恤员工的不易,我也不用每天花力气惦记上个月的工资还没发,年终奖还欠我呢。


如果,老东家足够爽快,我也不用每天猜测啥时候涨薪,也不用每天刷刷岗位,偷偷打电话。


哎呀,人与人之间咋就这么复杂呢~


如果,你的老东家亏待了你,或者你看不到未来了,你可以试试以下步骤:



金三银四路线


1.着手准备自己技术栈的复盘和技术栈更迭;


2.打开LeetCode,每日练习算法题,开拓思路;


3.查看相关岗位并更新自己的简历;


4.提前准备好自我介绍,几个提问的问题;


5.交接好手头工作,善待和你一样的打工人并告辞后赴任。


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

为什么onMeasure会被执行两次

什么情况下会onMeasure会执行? 进入View的measure方法: void measure(){ boolean forceLayout = (mPrivateFlags & PFLAG_FORCE_LAYOUT) == PFLAG_...
继续阅读 »

什么情况下会onMeasure会执行?


进入Viewmeasure方法:


void measure(){
boolean forceLayout = (mPrivateFlags & PFLAG_FORCE_LAYOUT) == PFLAG_FORCE_LAYOUT;
boolean specChanged = widthMeasureSpec != mOldWidthMeasureSpec
|| heightMeasureSpec != mOldHeightMeasureSpec;
boolean isSepcExactly = MeasureSpec.getMode(widthMeasureSpec) == MeasureSpec.EXACTLY
&& MeasureSpec.getMode(heightMeasureSpec) == MeasureSpec.EXACTLY;
boolean matchesSpecSize = getMeasuredWidth() == MeasureSpec.getSize(widthMeasureSpec)
&& getMeasuredHeight() == MeasureSpec.getSize(heightMeasureSpec);
final boolean needsLayout = specChanged
&& (sAlwaysRemeasureExactly || !isSpecExactly || !matchesSpecSize);
if(forceLayout || needLayout){
int cacheIndex = forceLayout ? -1 : mMeasureCache.indexOfKey(key);
if (cacheIndex < 0 || sIgnoreMeasureCache) {
onMeasure(widthMeasureSpec, heightMeasureSpec);
mPrivateFlags3 &= ~PFLAG3_MEASURE_NEEDED_BEFORE_LAYOUT;
} else {
long value = mMeasureCache.valueAt(cacheIndex);
setMeasuredDimensionRaw((int) (value >> 32), (int) value);
mPrivateFlags3 |= PFLAG3_MEASURE_NEEDED_BEFORE_LAYOUT;
}
}
}

什么时候forceLayout=true:



  1. 调用requestLayout

  2. 调用forceRequestLayout


什么时候needsLayout=true:



  1. 当长宽发生改变


什么时候调用了onMeasure>方法:



  1. forceLayouy=true

  2. 或者mMeasureCache没有当前的缓存


所以总结:当调用了requestLayout一定会测发重测过程.当forceLayout=false的时候会去判断mMeasureCache值.现在研究下这个mMeasureCache


class View{
LongSparseLongArray mMeasureCache;
void measure(widthSpec,heightSpec){
---
long key = (long) widthMeasureSpec << 32 | (long) heightMeasureSpec & 0xffffffffL;
int cacheIndex = forceLayout ? -1 : mMeasureCache.indexOfKey(key);
if(cacheIndex<0){
onMeasure(widthSpec,heightSpec);
}

mOldWidthMeasureSpec = widthMeasureSpec;
mOldHeightMeasureSpec = heightMeasureSpec;
mMeasureCache.put(key,widhSpec|heightSpec);
---
}
}

这里可以看到oldWidthMeasureSpecmMeasureCache都是缓存上一次的值,那他们有什么不同呢?不同点就是,oldWidthMeasureSpec>不仅仅缓存了测量的spec模式而且缓存了size.但是mMeasureCache只缓存了size.从这行代码可以看出:


long key = (long) widthMeasureSpec << 32 | (long) heightMeasureSpec & 0xffffffffL;

这里一同运算就为了排除掉spec造成的影响.


//不信你可以试下下面的代码
public class Test {
public static void main(String[] args) {
long widthMeasureSpec = makeMeasureSpec(10,0);
long heightMeasureSpec = makeMeasureSpec(20,0);
long ss = widthMeasureSpec << 32 | (long) heightMeasureSpec & 0xffffffffL;
System.out.println("=========="+ss);
}

private static final int MODE_MASK = 0x3 << 30;

public static int makeMeasureSpec(int size,
int mode) {
return (size & ~MODE_MASK) | (mode & MODE_MASK);
}
}
//42949672980
//42949672980
//42949672980



什么时候mPrivateFlags会被赋值PFLAG_FORCE_LAYOUT.


view viewGrouup的构造函数里面会主动赋值一次,然后在ViewGroup.addView时候会给当前ViewmProvateFlags赋值PFLAG_FORCE_LAYOUT.




为什么onMeasure会被执行两次?


void measure(int widthMeasureSpec,int heightMeasureSpec){
----
boolean forceLayout = (mPrivateFlags & PFLAG_FORCE_LAYOUT) == PFLAG_FORCE_LAYOUT;
if(forceLayout | needsLayout){
onMeasure()
}
----
}
public void layout(int l, int t, int r, int b){
---
mPrivateFlags &= ~PFLAG_FORCE_LAYOUT;
---
}

在第一次触发到measure方法时,forceLayoyt=true needsLayout=true,但是layout方法还没触发到.

在第二次触发到measure>方法时,forceLayout=true needsLayout=false,所以还是会进入onMeasure方法.这次会执行layout方法.然后我们在下次的时候forceLayout就等于false了.上面的这一段分析是分析的measure内部如何防止多次调用onMeasure.


现在分析外部是如何多次调用measure方法的:

Activity执行到onResume生命周期的时候,会执行WindowManager.addView操作,WindowManager的具体实现类是WindowManagerImpl然后addView操作交给了代理类WindowManagerGlobal,然后在WindowManagerGlobaladdView里面执行了ViewRootImpl.setView操作(ViewRootImpl对象也是在这个时候创建的),在ViewRootImpl会主动调用一次requestLayout,也就开启了第一次的视图 测量 布局 绘制.


setView的时候主动调用了一次ViewRootImpl.requestLayout,注意这个requestLayoutViewRootImpl的内部方法,和view viewGroup那些requestLayout不一样.在ViewRootImpl.requestLayout内部调用了performTraversals方法:


class ViewRootImpl{
void performTraversals(){
if(layoutResuested){
//标记1
windowSizeMayChanged |= measureHierarchy(host,lp,res,desiredWindowWidth,desiredWindowHeight);
}
//标记2
performMeasure()
performLayout()
}
void measureHierarchy(){
performMeasure()
}
}

ViewRootImpl的执行逻辑你可以看出,在执行performLayout之前,他自己就已经调用了两次performMeasure方法.所以你现在就知道为啥了.


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

多渠道打包配置和打包脚本修改

之前的文章 《创造 | 一个强大的 Android 自动化打包脚本》 介绍了 Android 自动打包脚本的配置。其实之前的脚本里还有些多渠道打包的配置实现方式并不好,比如使用 32 位还是 64 位 NDK 的问题。最近我对这个配置的打包做了更新。此外,因为...
继续阅读 »

之前的文章 《创造 | 一个强大的 Android 自动化打包脚本》 介绍了 Android 自动打包脚本的配置。其实之前的脚本里还有些多渠道打包的配置实现方式并不好,比如使用 32 位还是 64 位 NDK 的问题。最近我对这个配置的打包做了更新。此外,因为 Google Play 检测出我在应用里面使用了友盟的 SDK,违反了谷歌的开发者政策,所以我决定将海外版本的应用的崩溃信息统计切换到谷歌的 Firebase,因此也需要做多渠道的配置。


QQ截图20221119112054.png


1、针对不同 NDK 的区分


首先,针对使用不同 NDK 的配置,我将 Gradle 配置文件修改位通过外部传入参数的形式进行设置,具体的脚本如下,


android {
compileSdkVersion rootProject.ext.compileSdkVersion
defaultConfig {
if (project.hasProperty("build_ndk_type") && build_ndk_type == "ndk_32") {
println(">>>>>>>> NDK option: using 32 bit version")
ndk {abiFilters 'armeabi-v7a', 'x86'}
} else if (project.hasProperty("build_ndk_type") && build_ndk_type == "ndk_32_64") {
println(">>>>>>>> NDK option: using 32 and 64 bit version")
ndk {abiFilters 'armeabi-v7a', 'x86', 'arm64-v8a', 'x86_64'}
} else {
// default is 64 bit version
print(">>>>>>>> NDK option: using 64 bit version")
ndk {abiFilters 'arm64-v8a', 'x86_64'}
}
}
}

这样,就可以通过打包命令的参数动态指定使用哪种形式的 NDK,


./gradlew resguardNationalDebug -Pbuild_ndk_type=ndk_32

2、针对海内和海外不同依赖的调整


这方面做了两个地方的调整。一个是因为对 Debug 和 Release 版本或者不同的 Flavor,Gradle 会生成不同的依赖命令,于是针对不同的渠道可以使用如下的命令进行依赖,


// apm
nationalImplementation "com.umeng.umsdk:common:$umengCommonVersion"
nationalImplementation "com.umeng.umsdk:asms:$umengAsmsVersion"
nationalImplementation "com.umeng.umsdk:apm:$umengApmVersion"
internationalImplementation 'com.google.firebase:firebase-analytics'
internationalImplementation platform("com.google.firebase:firebase-bom:$firebaseBomVersion")
internationalImplementation 'com.google.firebase:firebase-crashlytics'
// debugtools
debugImplementation 'com.squareup.leakcanary:leakcanary-android:1.6.2'
releaseImplementation 'com.squareup.leakcanary:leakcanary-android-no-op:1.6.1'
debugImplementation 'com.github.Shouheng88:uetool-core:1.0'
debugImplementation 'com.github.Shouheng88:uetool-base:1.0'
releaseImplementation 'com.github.Shouheng88:uetool-no-op:1.0'
debugImplementation "com.facebook.stetho:stetho:$stethoVersion"
debugImplementation "com.iqiyi.xcrash:xcrash-android-lib:$xCrashVersion"

这里针对了 national 和 international 两个 flavor 分别使用了 nationalImplementation 和 internationalImplementation 两个依赖命令。此外,针对一些只在 Debug 环境中使用的依赖,这里使用了 debugImplementation 声明为只在 Debug 包里使用。


另一个调整是,因为比如如果我们只在 Debug 环境中或者个别渠道中使用某些依赖的话,那么当打 Release 包或者其他渠道的时候就可能出现依赖找不到的情况。这种情况的一种处理措施是像 leakcanary 一样,声明一个 no-op 的依赖,只包含必要的类文件而不包含具体的实现。此外,也可以通过下面的方式解决。


首先在项目中添加一个 module 或者使用已有的 module,然后已 CompileOnly 的形式引用上述依赖,


// apm
compileOnly "com.umeng.umsdk:common:$umengCommonVersion"
compileOnly "com.umeng.umsdk:asms:$umengAsmsVersion"
compileOnly "com.umeng.umsdk:apm:$umengApmVersion"
compileOnly 'com.google.firebase:firebase-analytics'
compileOnly platform("com.google.firebase:firebase-bom:$firebaseBomVersion")
compileOnly 'com.google.firebase:firebase-crashlytics'
// debugtool
compileOnly "com.facebook.stetho:stetho:$stethoVersion"
compileOnly "com.iqiyi.xcrash:xcrash-android-lib:$xCrashVersion"

这样是否使用某个依赖就取决于主 module. 然后,对需要引用到的类做一层包装,主 module 不直接调用依赖中的类,而是调用我们包装过的类。


object UmengConfig {

/** Config umeng library. */
fun config(application: Application, isDev: Boolean) {
if (!AppEnvironment.DEPENDENCY_UMENG_ANALYTICS) {
return
}
if (!isDev) {
UMConfigure.setLogEnabled(isDev)
UMConfigure.init(application, UMConfigure.DEVICE_TYPE_PHONE, "")
MobclickAgent.setPageCollectionMode(MobclickAgent.PageMode.AUTO)
}
}
}

这样,主 module 只需要在 application 里面调用 UmengConfig 的 config 方法即可。这里我们可以通过是否为 Debug 包来决定是否调用 Umeng 的一些方法,所以,这种方式可以保证打包没问题,只要 Release 版本调用不到 Umeng SDK 的类也不会出现类找不到的异常。此外,也可以通过如下方式


public class AppEnvironment {

public static final boolean DEPENDENCY_UMENG_ANALYTICS;
public static final boolean DEPENDENCY_STETHO;
public static final boolean DEPENDENCY_X_CRASH;

static {
DEPENDENCY_UMENG_ANALYTICS = findClassByClassName("com.umeng.analytics.MobclickAgent");
DEPENDENCY_STETHO = findClassByClassName("com.facebook.stetho.Stetho");
DEPENDENCY_X_CRASH = findClassByClassName("xcrash.XCrash");
}

private static boolean findClassByClassName(String className) {
boolean hasDependency;
try {
Class.forName(className);
hasDependency = true;
} catch (ClassNotFoundException e) {
hasDependency = false;
}
return hasDependency;
}
}

即通过能否找到某个类来判断当前环境中是否引用了指定的依赖,如果没有指定的依赖,直接跳过某些类的调用即可。


用上面的方式即可以解决 Android 中的各种多渠道打包问题。


3、通过外部参数指定打包版本


这个比较简单,和配置 NDK 的形式类似,只需要通过判断指定的属性是否存在即可,


if (project.hasProperty("version_code")) {
println(">>>>>>>> Using version code: " + version_code)
versionCode = version_code.toInteger()
} else {
versionCode = rootProject.ext.versionCode
}
if (project.hasProperty("version_name")) {
println(">>>>>>>> Using version name: " + version_name)
versionName = version_name
} else {
versionName = rootProject.ext.versionName
}

这样配置之后打包的传参指令为,


./gradlew assembleNationalDebug -Pbuild_ndk_type=ndk_32 -Pversion_code=121 -Pversion_name=hah

这样打包的时候就无需修改 gradle 脚本,直接通过传参的形式打包即可,做到了真正的自动化。


4、打包脚本 autopackage 的一些更新


经过上述配置之后,我对 autopackage 打包脚本也相应地做了一些调整。


1、调用脚本的时候也支持外部传入参数,比如


python run.py -s config/config_product.yml -v 324 -n 3.8.1.2

用来指定打包的配置文件、版本号以及版本名称。其次对打包脚本的 NDK 和 Flavor 配置做了调整,本次使用枚举来声明,含义更加准确,


def assemble(bit: BitConfiguration, flavor: FlavorConfiguration) -> ApkInfo:
'''Assemble APK with bit and flavor and copy APK and mapping files to destination.'''
# ./gradlew assembleNationalDebug -Pbuild_ndk_type=ndk_32 -Pversion_code=322 -Pversion_name=3.8.0
assemble_command = "cd %s && gradlew clean %s -Pbuild_ndk_type=%s" \
% (config.gradlew_location, flavor.get_gradlew_command(), bit.get_gradlew_bit_param_value())
if len(build_config.version_code) != 0:
assemble_command = assemble_command + " -Pversion_code=" + build_config.version_code
if len(build_config.version_name) != 0:
assemble_command = assemble_command + " -Pversion_name=" + build_config.version_name
logi("Final gradlew command is [%s]" % assemble_command)
os.system(assemble_command)
info = _find_apk_under_given_directory(bit, flavor)
_copy_apk_to_directory(info)
_copy_mapping_file_to_directory(info, flavor)
return info

2、对 YAML 文件解析做了简化,调用方式将更加便捷,


class GlobalConfig:
def parse(self):
self._configurations = read_yaml(build_config.target_script)
self.publish_telegram_token = self._read_key('publish.telegram.token')

def _read_key(self, key: str):
'''Read key from configurations.'''
parts = key.split('.')
value = self._configurations
for part in parts:
value = value[part.strip()]
return value

3、生成 Git log 使用了标准的 Git 指令


首先,获取当前最新的 Git tag 使用了 Git 自带的指令,


git describe --abbrev=0 --tags

该指令可以以简单的形式输出最新的 tag 的名称。


此外,拿到了上述 tag 之后我们就可以自动获取提交到上一次提交之间的所有提交记录的信息。获取提交记录的指令也使用了 Git 自带的指令,


git log %s..HEAD --oneline

上述方式可以以更见简洁的代码实现自动生成当前版本 Git 变更日志的功能。


4、对 diff 输出的结果的展示进行了美化


之前发送邮件的时候使用的是纯文本,因为邮件系统的文字并不是等宽的,所以,导致了展示的时候格式化非常难看。本次使用了等宽的字体并且以 html 的形式发送邮件,相对来说输出的结果可视化程度更好了一些,


QQ截图20221119121158.png


以上就是脚本的更新,仓库地址是 github.com/Shouheng88/… 有兴趣自己参考。


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

高端操作!实现RecyclerView的上下拖拽

写在前面 最近工作强度好大,一天能敲10小时以上的代码,敲的我头疼。代码写多了,突然想起来,好像真的很久没发技术文了,原因有很多,就不说了。。都是借口,今天分享内容也是工作时遇上的一个小需求,觉得挺有意思,那就写篇文章吧!   需求描述大概是这样,一个页面有一...
继续阅读 »

写在前面


最近工作强度好大,一天能敲10小时以上的代码,敲的我头疼。代码写多了,突然想起来,好像真的很久没发技术文了,原因有很多,就不说了。。都是借口,今天分享内容也是工作时遇上的一个小需求,觉得挺有意思,那就写篇文章吧!

需求描述大概是这样,一个页面有一个列表,列表里有很多item,需要支持用户拖拽其中item到不同的位置,来对列表项进行重新排序。


要实现的效果大概如下:


1_实现效果演示


除去与业务相关的部分,我们只需关注如何让列表item支持上下拖拽就行,这也是这个需求的关键。



我们组安卓岗在半年前已经全部用kotlin进行开发了,所以后续我的文章也会以kotlin为主进行demo的编写。一些还没学过kotlin的朋友也不用担心,kotlin和java很像,只要你熟悉java,相信你也是可以看得懂的。



那么应该如何实现呢?我们需要写个接口去监听每个item的当前状态(是否被拖动)以及其当前所在的位置吗?不需要


得益于RecyclerView优秀的封装,系统内部默认提供了这样的接口给我们去调用。


ItemTouchHelper


简单介绍下这个类,系统将这些接口封装到了这个类里,看看这个类的描述,它继承自RecyclerView.ItemDecoration,实现了RecyclerView.OnChildAttachStateChangeListener接口。


public class ItemTouchHelper extends RecyclerView.ItemDecoration
implements RecyclerView.OnChildAttachStateChangeListener {}

ItemDecoration这个类比较熟悉,它可以用来让不同的子View的四周拥有不同宽度/高度的offset,换句话说,可以控制子View显示的位置。


而OnChildAttachStateChangeListener这个接口,则是用来回调当子View Attach或Detach到RecyclerView时的事件。


那怎么使用这个ItemTouchHelper呢?


val callback = object : Callback {...}
val itemTouchHelper = ItemTouchHelperImpl(callback)
itemTouchHelper.attachToRecyclerView(mRecyclerView)

首先定义一个callback,然后传给ItemTouchHelper生成实例,最后将实例与recyclerView进行绑定。


ItemTouchHelper只负责与recyclerView的绑定,剩下的操作都代理给了callback处理。


callback内部实现了许多方法,我们只需要关注里面几个比较重要的方法


getMovementFlags()

callback内部帮我们管理了item的两种状态,一个是用户长按后的拖拽状态,另一个是用户手指左右滑动的滑动状态(以竖向列表为例),这个方法返回允许用户拖拽或滑动时的方向。


override fun getMovementFlags(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder
): Int = makeMovementFlags(dragFlags, swipeFlags)

比如我们希望,竖向列表时,禁止用户的滑动操作,仅支持用户上、下方向的拖拽操作


因此我们可以这样定义:


val dragFlags = (ItemTouchHelper.UP or ItemTouchHelper.DOWN)
val swipeFlags = 0 // 0 表禁止用户各个方向的操作,即禁止用户滑动

然后传入makeMovementFlags(),这个方法是callback默认提供的,我们不需要关注它的内部实现。


onMove()

当用户正在拖动子View时调用,可以在这里进行子View位置的替换操作


onSwiped()

当用户正在滑动子View时调用,可以在这里进行子View的删除操作。


isItemViewSwipeEnabled(): Boolean

返回值表是否支持滑动


isLongPressDragEnabled(): Boolean

返回值表是否支持拖动


onSelectedChanged(ViewHolder viewHolder, int actionState)

当被拖动或者被滑动的ViewHolder改变时调用,actionState会返回当前viewHolder的状态,有三个值:




  • ACTION_STATE_SWIPE:当View刚被滑动时返回




  • ACTION_STATE_DRAG:当View刚被拖动时返回




  • ACTION_STATE_IDLE:当View即没被拖动也没被滑动时或者拖动、滑动状态还没被触发时,返回这个状态




在这个方法我们可以对View进行一些UI的更新操作,例如当用户拖动时,让View高亮显示等。


clearView()

当View被拖动或滑动完后并且已经结束了运动动画时调用,我们可以在这里进行UI的复原,例如当View固定位置后,让View的背景取消高亮。


真正的开始


简单介绍完这个Callback,接下来写我们的代码


首先准备好我们的列表,列表不需要复杂,够演示就行,就放一行文字,代码我就不贴了,RecyclerVIew、Adapter、ViewHolder相信大家都很熟悉了,我们直接进入主题。


新建一个ItemTouchImpl类,继承自ItemTouchHelper


class ItemTouchHelperImpl(private val callback: Callback): ItemTouchHelper(callback)

不需要实现任何方法,ItemTouchHelper将工作代理给了Callback,所以我们接下来要实现这个Callback。


新建一个ItemTouchHelperCallback,继承自ItemTouchHelper.Callback,默认情况下,我们需要至少实现getMovementFlags()onMove()onSwiped() 三个方法。


在这个需求中,我们不需要滑动的效果,所以onSwiped()空实现就好了,同时让getMovementFlags()返回只允许上下拖拽的标志位就行。


如果我们直接在ItemTouchHelperCallback中实现相关逻辑,那么相当于这个Callback只会被用来处理上下拖拽的情况,是一个定制的Callback。下次遇上点别的场景,我们依然需要重新建个类去实现getMovementFlags(),太麻烦了,也不够通用。


为了方便后面的开发者,我决定把它做成一个通用的组件,对外暴露需要的接口,需要用到的时候只需要按需实现需要的接口就行了。


新建个ItemTouchDelegate接口,分别空实现onMove(),onSwiped(),uiOnSwiping(),uiOnDragging(),uiOnClearView(),其中getMovementFlags()我们默认实现,让ItemTouchHelper进支持上下方向的拖动、其他行为禁止,也即能满足我们的需求。


interface ItemTouchDelegate {
fun getMovementFlags(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Array<Int> {
val layoutManager = recyclerView.layoutManager
var swipeFlag = 0
var dragFlag = 0
if (layoutManager is LinearLayoutManager) {
if (layoutManager.orientation == LinearLayoutManager.VERTICAL) {
swipeFlag = 0 // 不允许滑动
dragFlag = (UP or DOWN) // 允许上下拖拽
} else {
swipeFlag = 0
dragFlag = (LEFT or RIGHT) // 允许左右滑动
}
}

return arrayOf(dragFlag, swipeFlag)
}

fun onMove(srcPosition: Int, targetPosition:Int): Boolean = true

fun onSwiped(position: Int, direction: Int) {}

// 刚开始滑动时,需要进行的UI操作
fun uiOnSwiping(viewHolder: RecyclerView.ViewHolder?) {}

// 刚开始拖动时,需要进行的UI操作
fun uiOnDragging(viewHolder: RecyclerView.ViewHolder?) {}

// 用户释放与当前itemView的交互时,可在此方法进行UI的复原
fun uiOnClearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) {}
}

然后,新建一个ItemTouchHelperCallback,把ItemTouchDelegate作为参数传进构造方法内,具体看代码:


class ItemTouchHelperCallback(@NotNull val helperDelegate: ItemTouchDelegate): ItemTouchHelper.Callback() {
private var canDrag: Boolean? = null
private var canSwipe: Boolean? = null

fun setDragEnable(enable: Boolean) {
canDrag = enable
}

fun setSwipeEnable(enable: Boolean) {
canSwipe = enable
}

override fun getMovementFlags(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder
): Int {
val flags = helperDelegate.getMovementFlags(recyclerView, viewHolder)
return if (flags != null && flags.size >= 2) {
makeMovementFlags(flags[0], flags[1])
} else makeMovementFlags(0, 0)
}

override fun onMove(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder
): Boolean {
return helperDelegate.onMove(viewHolder.bindingAdapterPosition, target.bindingAdapterPosition)
}

override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
helperDelegate.onSwiped(viewHolder.bindingAdapterPosition, direction)
}

override fun isItemViewSwipeEnabled(): Boolean {
return canSwipe == true
}

override fun isLongPressDragEnabled(): Boolean {
return canDrag == true
}

/**
* 更新UI
*/
override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) {
super.onSelectedChanged(viewHolder, actionState)
when(actionState) {
ACTION_STATE_SWIPE -> {
helperDelegate.uiOnSwiping(viewHolder)
}
ACTION_STATE_DRAG -> {
helperDelegate.uiOnDragging(viewHolder)
}
}
}

/**
* 更新UI
*/
override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) {
super.clearView(recyclerView, viewHolder)
helperDelegate.uiOnClearView(recyclerView, viewHolder)
}
}

看代码应该就一目了然了,在onSelectedChanged()里根据actionState,将具体的事件分发给uiOnSwiping()和uiOnDragging(),同时让它默认不支持拖动和滑动,按业务需要打开。


最后修改下ItemTouchHelperImpl,将ItemTouchHelperCallback传进去。


class ItemTouchHelperImpl(private val callback: ItemTouchHelperCallback): ItemTouchHelper(callback) {

}

怎么使用


只需在recyclerView初始化后加这样一段代码


// 实现拖拽
val itemTouchCallback = ItemTouchHelperCallback(object : ItemTouchDelegate{

override fun onMove(srcPosition: Int, targetPosition: Int): Boolean {
if (mData.size > 1 && srcPosition < mData.size && targetPosition < mData.size) {
// 更换数据源中的数据Item的位置
Collections.swap(mData, srcPosition, targetPosition);
// 更新UI中的Item的位置
mAdapter.notifyItemMoved(srcPosition, targetPosition);
return true
}
return false
}

override fun uiOnDragging(viewHolder: RecyclerView.ViewHolder?) {
viewHolder?.itemView?.setBackgroundColor(Color.parseColor("#22000000"))
}

override fun uiOnClearView(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder
) {
viewHolder.itemView.setBackgroundColor(Color.parseColor("#FFFFFF"))
}

})

val itemTouchHelper = ItemTouchHelperImpl(itemTouchCallback)
itemTouchHelper.attachToRecyclerView(mRecycler)

我们只需要实现onMove(),在onMove()主要是更新数据源的位置,以及UI界面的位置,在uiOnDragging()和uiOnClearView()里对item进行高亮显示和复原。剩下的onSwiped()滑动那些不在需求范围内,不需要实现。


但还是不能用,还记得我们的helper是默认不支持滑动和滚动的吗,我们要使用的话,还需要打开开关,就可以实现本文开头那样的效果了


itemTouchCallback.setDragEnable(true) 

如果你需要支持滑动,只需要修改下重新实现getMovementFlags(),onSwiped(),同时设置setSwipeEnable() = true即可。


源码在这里,有需要的朋友麻烦自取哈


兄dei,如果觉得我写的还不错,麻烦帮个忙呗 :-)



  1. 给俺点个赞被,激励激励我,同时也能让这篇文章让更多人看见,(#^.^#)

  2. 不用点收藏,诶别点啊,你怎么点了?这多不好意思!

  3. 噢!还有,我维护了一个路由库。。没别的意思,就是提一下,我维护了一个路由库 =.= !!


拜托拜托,谢谢各位同学!


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

Redis中的HotKey如何解决

对于三高系统,Redis是必须/必需的,当并发高到一定的程度就可能会出现HotKey的问题,今天我们来看下Redis中的HotKey如何解决。 什么是HotKey 在较短的时间内,海量请求访问一个Key,这样的Key就被称为HotKey。 HotKey的危害 ...
继续阅读 »

对于三高系统,Redis是必须/必需的,当并发高到一定的程度就可能会出现HotKey的问题,今天我们来看下Redis中的HotKey如何解决。


什么是HotKey


在较短的时间内,海量请求访问一个Key,这样的Key就被称为HotKey。


HotKey的危害



  • 海量请求在较短的时间内,访问一个Key,势必会导致被访问的Redis服务器压力剧增,可能会将Redis服务器击垮,从而影响线上业务;

  • HotKey过期的一瞬间,海量请求在较短的时间内,访问这个Key,因为Key过期了,这些请求会走到数据库,可能会将数据库击垮,从而影响线上业务。(这是缓存击穿问题)


HotKey如何解决


HotKey如何解决是一个比较宽泛的问题,涉及到多个方面,我们一个个来看。


Redis部署


通常来说,Redis有两种集群形式:数据分片集群、主从+哨兵集群,其实这两种集群形式或多或少的都一定程度上缓解了HotKey的问题。


主从+哨兵集群


如果我们采用单主:



  • 所有的读请求都会打在仅有的一个Redis服务器,都不用管Key是什么,只要并发一高,就会导致Redis服务器压力剧增;

  • 一旦仅有的一个Redis服务器挂了,就没有第二个Redis服务器顶上去了,无法继续提供服务。


如果我们采用主从+哨兵集群:



  • 读请求会被分散到Master节点或者多台Slave节点,将请求进行了初步的分散;

  • Master节点挂了,Slave节点会升级为新的Master节点,继续提供服务。


数据分片集群


Key被分散在了不同的Redis节点,将请求进行了进一步的分散。


如果采用数据分片集群,同时也会部署主从+哨兵,这样又有了主从+哨兵集群的特性:



  • 读请求会被分散到Master节点或者多台Slave节点,将请求进行了初步的分散;

  • Master节点挂了,Slave节点会升级为新的Master节点,继续提供服务。



画外音:我以前一直以为大部分公司都已经采用了数据分片集群,其实不然,某个我认为不差钱的公司,在2021年采用的还是主从+哨兵集群,出了问题,才转变成数据分片集群,我到我们公司一瞧,才发现我们公司也是主从+哨兵集群。



隔离


不同的业务分配不同的Redis集群,不要将所有的业务都“混杂”在一个Redis集群。


只要可以做到集群+隔离,在一定程度上就已经避免了HotKey,但是对于超高并发的系统来说,可能还有点不够,所以才会有下面的更进一步的措施。


如何应对HotKey


这个问题,可以拆分成三个子问题:如何发现HotKey、如何通知HotKey的产生、如何对HotKey进行处理。


如何发现HotKey


如何发现HotKey的前提是知道每个Key的使用情况,并进行统计,所以这又拆成了两个更小的子问题:如何知道每个Key的使用情况,如何进行统计。


如何知道每个Key的使用情况


谁最清楚知道每个Key的使用情况,当然是客户端、代理层,所以我们可以在客户端或者代理层进行埋点。


客户端埋点

在客户端请求Redis的代码中进行埋点。


优点:



  • 实现较为简单

  • 轻量级

  • 几乎没有性能损耗


缺点:



  • 进行统一管理较为麻烦:如果想开启或者关闭埋点、上报,会比较麻烦

  • 升级、迭代较为麻烦:如果埋点、上报方式需要优化,就需要升级Jar包,再找一个黄道吉日进行发布

  • 客户端会有一定的压力:不管是实时上报使用情况,还是准实时上报使用情况,都会对客户端造成一定的压力


代理层埋点

客户端不直接连接Redis集群,而是连接Redis代理,在代理层进行埋点。


优点:



  • 客户端没有压力

  • 对客户端完全透明

  • 升级、迭代比较简单

  • 进行统一管理比较简单


缺点:



  • 实现复杂

  • 会有一定的性能损耗:代理层需要转发请求到真正的Redis集群

  • 单点故障问题:需要做到高可用,更复杂

  • 单点热点问题:代理层本身就是一个热点,需要分散热点,更复杂


如何上报每个Key的使用情况


我们在客户端或者代理层进行了埋点,自然是由它们上报每个Key的使用情况,如何上报又是一个小话题。


实时/准实时


  • 实时上报:每次请求,都进行上报

  • 准实时上报:积累一定量或者一定时间的请求,再进行上报


是否预统计

如果采用准实时上报,在客户端或者代理层是否对使用情况进行预统计:



  • 进行预统计:减少上报的数据量,减轻统计的压力,自身会有压力

  • 不进行预统计:上报的数据量比较多,自身几乎没有压力


如何统计


不管如何进行上报,使用情况最终都会通过Kafka,发送到统计端,这个时候统计端就来活了。
一般来说,这个时候会借助于大数据,较为简单的方式:Flink开一个时间窗口,消费Kafka的数据,对时间窗口内的数据进行统计,如果在一个时间窗口内,某个Key的使用达了一定的阈值,就代表这是一个HotKey。


如何通知HotKey的产生


经过上面的步骤,我们已经知道了某个HotKey产生了,这个时候就需要通知到客户端或者代理层,那如何通知HotKey的产生呢?



  • MQ:用MQ通知客户端或者代理层HotKey是什么

  • RPC/Http:通过RPC/Http通知客户端或者代理层HotKey是什么

  • 配置中心/注册中心指令:既然遇到了HotKey的问题,并且想解决,那基本上是技术实力非常强大的公司,应该有非常完善的服务治理体系,此时,可以通过配置中心/注册中心下发指令到客户端或者代理层,告知HotKey是什么


如何处理HotKey


客户端或者代理层已经知晓了HotKey产生了,就自动开启一定的策略,来避免HotKey带来的热点问题:



  • 使用本地缓存,不至于让所有请求都打到Redis集群

  • 将HotKey的数据复制多份,分散到不同的Redis节点上


在实际开发中,可能在很大程度上,都不会有埋点、上报、统计,通知、策略自动开启,这一套比较完善的Redis HotKey解决方案,我们能做到的就是预估某个Key可能会成为热点,就采用本地缓存+复制多份HotKey数据的方式来避免HotKey带来的热点问题。我们还经常会因为偷懒,所以设计了一个大而全的Key,所有的业务都从这个Key中读取数据,但是有些业务只需要其中的一小部分数据,有些业务只需要另外一小部分数据,如果不同的业务读取不同的Key,又可以将请求进行分散,这是非常简单,而且有效的方式。


End


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

对于单点登录,你不得不了解的CAS

大家好,我是车辙。之前我们通过面试的形式,讲了JWT实现单点登录(SSO)的设计思路,并且到最后也留下了疑问,什么是CAS。 没看过的同学建议点击下方链接先看下,两者还是有一定连贯性的。寒暄开始 今天是上班的第一天,刚进公司就见到了上次的面试官,穿着格子衬衫...
继续阅读 »

大家好,我是车辙。之前我们通过面试的形式,讲了JWT实现单点登录(SSO)的设计思路,并且到最后也留下了疑问,什么是CAS


没看过的同学建议点击下方链接先看下,两者还是有一定连贯性的。

寒暄开始



今天是上班的第一天,刚进公司就见到了上次的面试官,穿着格子衬衫和拖鞋,我们就叫他老余吧。老余看见我就开始勾肩搭背聊起来了,完全就是自来熟的样子,和我最近看的少年歌行里的某人很像。



什么是CAS呢


老余:上次你说到了CAS,你觉得CAS是什么?


我:之前我们面试的时候,我讲了JWT单点登录带来的问题,然后慢慢优化,最后衍变成了中心化单点登录系统,也就是CAS的方案。


CAS(Central Authentication Service),中心认证服务,就是单点登录的某种实现方案。你可以把它理解为它是一个登录中转站,通过SSO站点,既解决了Cookie跨域的问题,同时还通过SSO服务端实现了登录验证的中心化。



这里的SSO指的是:SSO系统



它的设计流程是怎样的


老余:你能不能讲下它的大致实现思路,这说的也太虚头巴脑了,简直是听君一席话,如听一席话。

我:你别急呀,先看下它的官方流程图。
image.png


重定向到SSO


首先,用户想要访问系统A的页面1,自然会调用http://www.chezhe1.com的限制接口,(比如说用户信息等接口登录后才能访问)。


接下来 系统A 服务端一般会在拦截器【也可以是过滤器,AOP 啥的】中根据Cookie中的SessionId判断用户是否已登录。如果未登录,则重定向到SSO系统的登录页面,并且带上自己的回调地址,便于用户在SSO系统登录成功后返回。此时回调地址是:http://www.sso.com?url=www.chezhe1.com



这个回调地址大家应该都不会陌生吧,像那种异步接口或者微信授权、支付都会涉及到这块内容。不是很了解的下面会解释~

另外这个回调地址还必须是前端页面地址,主要用于回调后和当前系统建立会话。



此时如下图所示:
image.png


用户登录



  1. 在重定向到SSO登录页后,需要在页面加载时调用接口,根据SessionId判断当前用户在SSO系统下是否已登录。【注意这时候已经在 SSO 系统的域名下了,也就意味着此时Cookie中的domain已经变成了sso.com



为什么又要判断是否登录?因为在 CAS 这个方案中,只有在SSO系统中为登录状态才能表明用户已登录。




  1. 如果未登录,展现账号密码框,让用户输入后进行SSO系统的登录。登录成功后,SSO页面和SSO服务端建立起了会话。
    此时流程图如下所示:


image.png


安全验证


老余:你这里有一个很大的漏洞你发现没有?

我:emm,我当然知道。


对于中心化系统,我们一般会分发对应的AppId,然后要求每个应用设置白名单域名。所以在这里我们还得验证AppId的有效性,白名单域名和回调地址域名是否匹配。否则有些人在回调地址上写个黄色网站那不是凉凉。


image.png

获取用户信息登录



  1. 在正常的系统中用户登录后,一般需要跳转到业务界面。但是在SSO系统登录后,需要跳转到原先的系统A,这个系统A地址怎么来?还记得重定向到SSO页面时带的回调地址吗?


image.png


通过这个回调地址,我们就能很轻易的在用户登录成功后,返回到原先的业务系统。



  1. 于是用户登录成功后根据回调地址,带上ticket,重定向回系统A,重定向地址为:http://www.chezhe1.com?ticket=123456a

  2. 接着根据ticket,从SSO服务端中获取Token。在此过程中,需要对ticket进行验证。

  3. 根据tokenSSO服务端中获取用户信息。在此过程中,需要对token进行验证。

  4. 获取用户信息后进行登录,至此系统A页面和系统A服务端建立起了会话,登录成功。


此时流程图如下所示:


image.png


别以为这么快就结束了哦,我这边提出几个问题,只有把这些想明白了,才算是真的清楚了。



  • 为什么需要 Ticket?

  • 验证 Ticket 需要验证哪些内容?

  • 为什么需要 Token?

  • 验证 Token 需要验证哪些内容?

  • 如果没有Token,我直接通过Ticket 获取用户信息是否可行?


为什么需要 Ticket


我们可以反着想,如果没有Ticket,我们该用哪种方式获取Token或者说用户信息?你又该怎么证明你已经登录成功?用Cookie吗,明显是不行的。


所以说,Ticket是一个凭证,是当前用户登录成功后的产物。没了它,你证明不了你自己。


验证 Ticket 需要验证哪些内容



  1. 签名:对于这种中心化系统,为了安全,绝大数接口请求都会有着验签机制,也就是验证这个数据是否被篡改。至于验签的具体实现,五花八门都有。

  2. 真实性:验签成功后拿到Ticket,需要验证Ticket是否是真实存在的,不能说随便造一个我就给你返回Token吧。

  3. 使用次数:为了安全性,Ticket只能使用一次,否则就报错,因为Ticket很多情况下是拼接在URL上的,肉眼可见。

  4. 有效期:另外则是Ticket的时效,超过一定时间内,这个Ticket会过期。比如微信授权的Code只有5分钟的有效期。

  5. ......


为什么需要 Token?


只有通过Token我们才能从SSO系统中获取用户信息,但是为什么需要Token呢?我直接通过Ticket获取用户信息不行吗?


答案当然是不行的,首先为了保证安全性Ticket只能使用一次,另外Ticket具有时效性。但这与某些系统的业务存在一定冲突。因此通过使用Token增加有效时间,同时保证重复使用。


验证 Token 需要验证哪些内容?


和验证 Ticket类似



  1. 签名 2. 真实性 3. 有效期


如果没有 Token,我直接通过 Ticket 获取用户信息是否可行?


这个内容其实上面已经给出答案了,从实现上是可行的,从设计上不应该,因为TicketToken的职责不一样,Ticket 是登录成功的票据,Token是获取用户信息的票据。


用户登录系统B流程


老余:系统A登录成功后,那系统B的流程呢?

我:那就更简单了。


比如说此时用户想要访问系统B,http://www.chezhe2.com的限制接口,系统B服务端一般会在拦截器【也可以是过滤器,AOP 啥的】中根据Cookie中的SessionId判断用户是否已登录。此时在系统B中该系统肯定未登录,于是重定向到SSO系统的登录页面,并且带上自己的回调地址,便于用户在SSO系统登录成功后返回。回调地址是:http://www.sso.com?url=www.chezhe2.com。


我们知道之前SSO页面已经与SSO服务端建立了会话,并且因为CookieSSO这个域名下是共享的,所以此时SSO系统会判断当前用户已登录。然后就是之前的那一套逻辑了。
此时流程图如下所示:


image.png


技术以外的事


老余:不错不错,理解的还可以。你发现这套系统里,做的最多的是什么,有什么技术之外的感悟没。说到这,老余叹了口气。


我:我懂,做的最多的就是验证了,验证真实性、有效性、白名单这些。明明一件很简单的事,最后搞的那么复杂。像现在银行里取钱一样,各种条条框框的限制。我有时候会在想,技术发展、思想变革对于人类文明毋庸置疑是有益的,但是对于我们人类真的是一件好事吗?如果我们人类全是机器人那样的思维是不是会更好点?


image.png

老余:我就随便一提,你咋巴拉巴拉了这么多。我只清楚一点,拥有七情六欲的人总是好过没有情感的机器人的。好了,干活去吧。


总结


这一篇内容就到这了,我们聊了下关于单点登录的 CAS 设计思路,其实CAS 往大了讲还能讲很多,可惜我的技术储备还不够,以后有机会补充。如果想理解的更深刻,也可以去看下微信授权流程,应该会有帮助。


最后还顺便提了点技术之外的事,记得有句话叫做:科学的尽头是哲学,我好像开始慢慢理解这句话的意思了。


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

Compose自定义View——LocationMarkerView

LocationMarker是运动轨迹上Start、End, 以及整公里点上笔者自定义绘制的一个MarkerView, 当时之所以没有用设计给的icon是这个MarkerView里需要填充动态的数字,自定义的话自主性比较大些也方面做动画,之前的Android ...
继续阅读 »

LocationMarker是运动轨迹上Start、End, 以及整公里点上笔者自定义绘制的一个MarkerView, 当时之所以没有用设计给的icon是这个MarkerView里需要填充动态的数字,自定义的话自主性比较大些也方面做动画,之前的Android 传统自定义View的实现可以看这篇文章介绍 运动App自定义LocationMarker


这里先看下gif动图:


LocationMarker.gif


LocationMarkerView图的绘制


绘制方面基本没有太多的逻辑,通过Compose的自定义绘制Canvas() 绘制 一个构建的Path,生成View的Path其实是主要的实现过程。


Canvas(modifier = Modifier.size(0.dp)){
 drawPath(AndroidPath(markerViewPath), color = color)
 drawPath(AndroidPath(bottomOval), color = colorOval)
}

这里Compose的path,还有好些接口对不上以及缺少API,所以通过AndroidPath(nativepath)接口进行转化进行绘制,bottomOval是 Start、End点底部阴影的Path。生成markerViewPath以及bottomOval的逻辑都在LocationMarker类中,LocationMarker主要包含了上下两套点 p1、p3(HPoint), 左右两套点p2、p4(VPoint), 以及绘制View的参数属性集合类MarkerParams.


获取markerViewPath, 首先给p1、p3(HPoint),p2、p4(VPoint)中8个点设置Value值,circleModel(radius),然后从底部p1底部点逆时针转圈依次调用三阶贝塞尔函数接口,最后close实现水滴倒置状态的Path,见实现:


fun getPath(radius: Float): Path{
 circleModel(radius)
 val path = Path()
 p1.setYValue(p1.y + radius * 0.2f * 1.05f) //设置 p1 底部左右两个点的y值
 p1.y += radius * 0.2f * 1.05f //设置 p1 自己的y值
 path.moveTo(p1.x, p1.y)
 path.cubicTo(p1.right.x, p1.right.y, p2.bottom.x, p2.bottom.y, p2.x, p2.y)
 path.cubicTo(p2.top.x, p2.top.y, p3.right.x, p3.right.y, p3.x, p3.y)
 path.cubicTo(p3.left.x, p3.left.y, p4.top.x, p4.top.y, p4.x, p4.y)
 path.cubicTo(p4.bottom.x, p4.bottom.y, p1.left.x, p1.left.y, p1.x, p1.y)
 path.close()
 val circle = Path()
 circle.addCircle(p3.x, p3.y + radius, markerParams.circleRadius.value, Path.Direction.CCW)
 path.op(circle, Path.Op.DIFFERENCE)
 return path
}

拿到相应的Path后,在Composeable函数里进行如上所述的绘制Path即可:


val locationMarker = LocationMarker(markerParams)
val markerViewPath = locationMarker.getPath(markerParams.radius.value)
val bottomOval = locationMarker.getBottomOval()
val color = colorResource(id = markerParams.wrapperColor)
val colorOval = colorResource(R.color.location_bottom_shader)

Canvas(modifier = Modifier.size(0.dp)){
 drawPath(AndroidPath(markerViewPath), color = color)
 drawPath(AndroidPath(bottomOval), color = colorOval)
}

绘制整公里的文字


Compose的Canvas 里目前的Version并不支持drawText的绘制,不过开放了一个调用原始drawText的转换API, 原始的drawText 是需要Paint参数的, 同时依赖Paint来计算Text 对应RectF的Height值,这里Paint()是Compose的一个Paint,需要调用asFrameworkPaint() 进行转化


val paint = Paint().asFrameworkPaint().apply {
 setColor(-0x1)
 style = android.graphics.Paint.Style.FILL
 strokeWidth = 1f
 isAntiAlias = true
 typeface = Typeface.DEFAULT_BOLD
 textSize = markerParams.txtSize.toFloat()
}

计算Text 绘制依赖的RectF,并将rectF.left作为drawText的X值,同时计算drawText的基线 baseLineY,最后传入nativeCanvas.drawText() 接口进行绘制。


val rectF = createTextRectF(locationMarker, paint, markerParams)
val baseLineY = getTextBaseY(rectF, paint)
Canvas(modifier = Modifier.size(0.dp)){
 drawIntoCanvas {
   it.nativeCanvas.drawText(markerParams.markerStr,  rectF.left, baseLineY, paint)
}
}

drawText获取绘制基线 baseLineY的工具类方法:


fun getTextBaseY(rectF: RectF, paint: Paint): Float {
   val fontMetrics = paint.fontMetrics
   return rectF.centerY() - fontMetrics.top / 2 - fontMetrics.bottom / 2
}

添加动画


这里简单的用一个放大的动画实现,跟原始的高德地图、Mapbox地图的一个growth过程的一个动画有些差距的,暂且先这样实现吧。首先是定义两个radius相关的State对象,具体来说是Proxy, 以及一个动画生长的大小控制的Float的变量Fraction,再通过自定义animateDpAsState作为 animation值的对象,最终给到MarkParams作为参数,animation值的变化,会导致MarkParams的变化,最后导致Recompose,形成动画。


  val circleRadius by rememberSaveable{ mutableStateOf(25) }
 val radius by rememberSaveable{ mutableStateOf(60) }
 var animatedFloatFraction by remember { mutableStateOf(0f) }
 val radiusDp by animateDpAsState(
   targetValue = (radius * animatedFloatFraction).dp,
   animationSpec = tween(
     durationMillis = 1000,
     delayMillis = 500,
     easing = LinearOutSlowInEasing
  )
)

 val circleRadiusDp by animateDpAsState(
   targetValue = (circleRadius * animatedFloatFraction).dp,
   animationSpec = tween(
     durationMillis = 1000,
     delayMillis = 500,
     easing = LinearOutSlowInEasing
  )
)

 val markerParams by remember {
   derivedStateOf { MarkerParams(radiusDp, circleRadiusDp, wrapperColor = wrapperColor) }
}
 

Compose 自定义View LocationMarkerView 主要通过drawPath,以及调用原生的drawText, 最后添加了一个scale类似的动画实现,最终实现运动轨迹里的一个小小的View的实现。


代码见:github.com/yinxiucheng… 下的CustomerComposeView


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

Kotlin | 这些隐藏的内存陷阱,你应该熟记于心

引言 Kotlin 是一个非常 yes 的语言,从 null安全 ,支持 方法扩展 与 属性扩展,到 内联方法、内联类 等,使用Kotlin变得越来越简单舒服。但编程从来不是一件简单的工作,所有简洁都是建立在复杂的底层实现上。那些看似简单的kt代码,内部往往隐...
继续阅读 »

引言


Kotlin 是一个非常 yes 的语言,从 null安全 ,支持 方法扩展属性扩展,到 内联方法内联类 等,使用Kotlin变得越来越简单舒服。但编程从来不是一件简单的工作,所有简洁都是建立在复杂的底层实现上。那些看似简单的kt代码,内部往往隐藏着不容忽视的内存开销。


介于此,本篇将根据个人开发经验,聊一聊 Kotlin 中那些隐藏的内存陷阱,也希望每一个同学都能在 性能优雅 之间找到合适的平衡。



本篇定位简单🔖,主要通过示例+相应字节码分析的方式,对日常开发非常有帮助。





密封类的小细节


密封类用来表示受限的类继承结构:当一个值为有限几种的类型、而不能有任何其他类型时。在某种意义上,他们是枚举类的扩展:枚举类型的值集合也是受限的,但每个枚举常量只存在一个实例,而密封类的一个子类可以有可包含状态的多个实例。摘自Kotlin中文文档



关于它用法,我们具体不再做赘述。



密封类虽然非常实用,经常能成为我们多type的绝佳搭配,但其中却藏着一些使用的小细节,比如 构造函数传值所导致的损耗问题。


错误示例


image.png
如题, 我们有一个公用的属性 sum ,为了便于复用,我们将其抽离到 Fruit 类构造函数中,让子类便于初始化时传入,而不用重复显式声明。


上述代码看着似乎没什么问题?按照传统的操作习惯,我们也很容易写出这种代码。


如果我们此时来看一下字节码:


image-20221022080336140


不难发现,无论是子类Apple还是父类Fruit,他们都生成了 getSum()setSum() 方法 与 sum 字段,而且,父类的 sum 完全处于浪费阶段,我们根本没法用到。😵‍💫


显然这并不是我们愿意看到的,我们接下来对其进行改造一下。


改造实践


我们对上述示例进行稍微改造,如下所示:


image.png
如题,我们将sum变量定义为了一个抽象变量,从而让子类自行实现。对比字节码可以发现,相比最开始的示例,我们的父类 Fruit 中减少了一个 sum 变量的损耗。




那有没有方法能不能把 getsum()setSum() 也一起移除呢?🙅‍♂️


答案是可以,我们利用 接口 改造即可,如下所示:


image-20221018100240436


如上所示,我们增加了一个名为 IFruit 的接口,并让 密封父类 实现了这个接口,子类默认在构造函数中实现该属性即可。


观察字节码可发现,我们的父类一干二净,无论是从包大小还是性能,我们都避免了没必要的损耗。


内联很好,但别太长


inline ,翻译过来为 内联 ,在 Kotlin 中,一般建议用于 高阶函数 中,目的是用来弥补其运行时的 额外开销


其原理也比较简单,在调用时将我们的代码移动到调用处使用,从而降低方法调用时的 栈帧 层级。



栈帧: 指的是虚拟机在进行方法调用和方法执行时的数据结构,每一个栈帧里都包含了相应的数据,比如 局部参数,操作数栈等等。


Jvm在执行方法时,每执行一个方法会产生一个栈帧,随后将其保存到我们当前线程所对应的栈里,方法执行完毕时再将此方法出栈,


所以内联后就相当于省了一个栈帧调用。



如果上述描述中,你只记住了后半句,降低栈帧 ,那么此时你可能已经陷入了一个使用陷阱?


错误示例


如下截图中所示,我们随便创建了一个方法,并增加了 inline 关键字:


image-20221012230619100


观察截图会发现,此时IDE已经给出了提示,它建议你移除 inline , Why? 为什么呢?🥲



不是说内联可以提高性能吗,那么不应该任何方法都应该加 inline 提高性能吗?(就是这么倔强🤌🏼)



上面我们提到了,内联是会将代码移动到调用处,降低 一层栈帧,但这个性能提升真的大吗?


再仔细想想,移动到调用处,移动到调用处。这是什么概念呢?



假设我们某个方法里代码只有两行(我想不会有人会某个方法只有一行吧🥲),这个方法又被好几处调用,内联是提高了调用性能,毕竟节省了一次栈帧,再加上方法行数少(暂时抛弃虚拟机优化这个底层条件)。


但如果方法里代码有几十行?每次调用都会把代码内联过来,那调用处岂不💥,带来的包大小影响某种程度上要比内联成本更高😵‍💫!



如下图所示,我们对上述示例做一个论证:


image-20221012232807919



Jvm: 我谢谢你。



推荐示例


我们在文章最开始提到了,Kotlin inline ,一般建议用于 高阶函数(lambda) 中。为什么呢?


如下示例:


image-20221013094634526


转成字节码后,可以发现,tryKtx() 被创建为了一个匿名内部类 (Simple$test|1) 。每次调用时,相当于需要创建匿名类的实例对象,从而导致二次调用的性能损耗。


那如果我们给其增加 inline 呢?🤖,反编译后相应的 java代码 如下:


image-20221013100057206


具体对比图如上所示,不难发现,我们的调用处已经被替换为原方法,相应的 lambda 也被消除了,从而显著减少了性能损耗。


Tips


如果查看官方库相应的代码,如下所示,比如 with :


image.png


不难发现,inline 的大多数场景仅且在 高阶函数 并且 方法行数较短 时适用。因为对于普通方法,jvm本身对其就会进行优化,所以 inline 在普通方法上的的意义几乎聊胜于无。


总结如下:



  • 因为内联函数会将方法函数移动到调用处,会增加调用处的代码量,所以对于较长的方法应该避免使用

  • 内联函数应该用于使用了 高阶函数(lambda) 的方法,而不是普通方法。



伴生对象,也许真的不需要


Kotlin 中,我们不能像 Java 一样,随便定义一个静态方法或者静态属性。此时 companion object(伴生对象)就会派上用场。


我们常常会用于定义一个 key 或者 TAG ,类似于我们在 Java 中定义一个静态的 Key。其使用起来也很简单,如下所示:


class Book {
companion object {
val SUM_MAX: Int = 13
}
}

这是一段普通的代码,我们在 Book 类中增加了一个伴生对象,其中有一个静态的字段 SUM_MAX。


上述代码看着似乎没什么问题,但如果我们将其转为字节码后再看一看:


image-20221024091359601


不难发现,仅仅只是想增加一个 静态变量 ,结果凭空增加了一个 静态对象 以及多增加了 get() 方法,这个成本可能远超出一个 静态参数 的价值。




const


抛开前者不谈(静态对象),那么我们有没有什么方法能让编译器少生成一个 get() 方法呢(非private)?


注意观察IDE提示,IDE会建议我们增加一个 const 的参数,如下所示:


companion object {
const val SUM_MAX: Int = 13
}

增加了 const 后,相应的 get() 方法也会消失掉,从而节省了一个 get() 方法。



const,在 Kotlin 中,用于修饰编译时已知的 val(只读,类似final) 标注的属性。



  • 只能用于顶层的class中,比如 object class 或者 companion object

  • 只能用于基本类型;

  • 不会生成get()方法。





JvmField


如果我们 某个字段不是 val 标注呢,其是 var (可变)修饰的呢,并且这个字段要对外暴漏(非private)。



此时不难猜测,相应的字节码后肯定会同时生成 set与get 方法。



此时就可以使用 @JvmField 来进行修饰。


如下所示:


class Book {
companion object {
@JvmField
var sum: Int = 0
}
}

相应的字节码如下:
image-20221024142110409


Tips


让我们再回到伴生对象本身,我们真的一定需要它吗?


对于和业务强关联的 key 或者 TAG ,可以选择使用伴生对象,并为其增加 const val,此时语义上的清晰比内存上的损耗更加重要,特别在复杂的业务背景下。


但如果仅用于保存一些key,那么完全可以使用 object Class 替代,如下所示,将其回归到一个类中:


object Keys {
const val DEFAULT_SUM = 10
const val DEFAULT_MIN = 1
const val LOGIN_KEY = 99
}



2022/12/6补充


使用 kotlin 文件形式去写。


这种写法属于以增加静态类的方式避免伴生对象的内存损耗,如果你的场景是单独的增加一个tag,那么这种写法比较推荐。



对于sdk的开发者,同时建议增加 @file:JvmName(“ 文件名”) ,从而禁止生成的 xxxkt类 在 java 语境下被调用到 (欺负java不识别空格🤪)。



@file:JvmName(" Testxx")

private const val TAG = "KEY_TEST_TAG"

class TestKt {
   private fun test() {
       println(TAG)
  }
}


Apply!=构造者模式


apply 作为开发中的常客,为我们带来了不少便利。其内部实现也非常简单,将我们的对象以函数的形式返回,this 作为接收者。从而以一种优雅的方式实现对对象方法、属性的调用。


但经常会看到有不少同学在构造者模式中写出以下代码,使用 apply 直接作为返回值,这种方式固然看着优雅,性能也几乎没有差别。但这种场景而言,如果我们注意到其字节码,会发现其并不是最佳之选。


示例


image-20221022082110693


如题,我们存在一个示例Builder,并在其中添加了两个方法,即 addTitle(),与 addSecondTitle() 。后者以 apply 作为返回值,代码可读性非常好,相比前者,在 kotlin 中其显得非常优雅。


但如果我们去看一眼字节码呢?


image-20221022082523238


如上所示,使用了 apply 后,我们的字节码中增加了多余步骤,相比不使用的,包大小会有一点影响,性能上几乎毫无差距。


Tips


apply 很好用,但需要区分场景。其可以改善我们在 kotlin 语义下的编程体验,但同时也不是任何场景都需要其。


如果你的方法中需要对某个对象操作多次,比如调用其方法或者属性,那么此时可以使用 apply ,反之,如果次数过少,其实你并不需要 apply 的优雅。


警惕,lazy 的使用方式


lazy,中文译名为延迟初始化,顾名思义,用于延迟初始化一些信息。


作用也相对直接,如果我们有某个对象或字段,我们可能只想使用时再初始化,此时就可以先声明,等到使用时再去初始化,并且这个初始化过程默认也是线程安全(不特定使用NONE)。这样的好处就是性能优势,我们不必应用或者页面加载时就初始化一切,相比过往的 var xx = null ,这种方式一定程度上也更加便捷。


相应的,lazy一共有三种模式,即:



  • SYNCHRONIZED(同步锁,默认实现)

  • PUBLICATION(CAS)

  • NONE(不作处理)


lazy 虽然使用简单,但在 Android 的开发背景下,lazy 经常容易使用不当🤦🏻‍♂️,也因此常常会出现为了[便利] 而造成的性能隐患。


示例如下:


image.png


如上所示,我们延迟初始化了一个点击事件,方便在 onCreate() 中进行设置 点击事件 以及后续复用


上述示例虽然看着似乎没什么问题。但放在这样的场景下,这个 mClickListener 本身的意义也许并不大。为什么这样说?




  1. 上述使用了 默认的lazy ,即同步锁,而Android默认线程为 UI线程 ,当前操作方法又是 onCreate() ,即当前本身就是线程安全。此时依然使用 lazy(sys) ,即浪费了一定初始化性能。

  2. MainActivity初始化时,会先在 构造函数 中初始化 lazy 对象,即 SYNCHRONIZED 对应的 SynchronizedLazyImpl。也就是说,我们一开始就已经多生成了一个对象。然后仅仅是为了一个点击事件,内部又会进行包装一次



相似的场景有很多,如果你的lazy是用于 Android生命周期组件 ,再加上本身会在 onCreate() 等中进行调用,那么很可能完全没有必要延迟初始化。


关于 arrayOf() 的使用细节


对于 arrayOf ,我们一般经常用于初始化一个数组,但其也隐藏着一些使用细节。


通常来说,对于基本类型的数组,建议使用默认已提供的函数比如,intArrayOf() 等等,从而便于提升性能。


至于原因,我们下面来分析,如下所示:


fun test() {
arrayOf(1, 2, 3)
}

fun testNoInteger() {
intArrayOf(1, 2, 3)
}

我们提供了两个方法,前者是默认方法,后者是带优化的方法,具体字节码如下:


image-20221022095310870


如题,不难发现,前者使用的是 java 中的 包装类型 ,使用时还需要经历 拆箱装箱 ,而后者是非包装类型,从而免除了这一操作,从而节省性能。



什么是装箱与拆箱?


背景:Java 中,万物皆对象,而八大基本类型不是对象,所以 Java 为每种基本类型都提供了相应的包装类型。


装箱就是指将基本类型转为包装类型,拆箱则是将包装类型转为基本类型。



总结


本篇中,我们以日常开发的视角,去探寻了 Kotlin 中那些 [隐藏] 的内存陷阱。


仔细回想,上述的不恰当用法都是建立在 [不熟练] 的背景下。Kotlin 本身的各种便利没有任何问题,其使得我们的 代码可读性开发舒适度 增强了太多。但如果同时,我们还能注意到其背后的实现,也是不是就能在 性能与优雅 之间找到了一种平衡。


所谓左眼 kt ,右眼 java,正是如此。作为一个 Kotlin 使用者,这也是我们所不断追寻的。



善用字节码分析,你的技艺也将更上一筹。



参阅



关于我


我是 Petterp ,一个三流 Kotlin 使用者,如果本文对你有所帮助,欢迎点赞评论收藏,你的支持是我持续创作的最大鼓励!


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

新项目为什么决定用 JDK 17了

最近在调研 JDK 17,并且试着将之前的一个小项目升级了一下,在测试环境跑了一段时间。最终,决定了,新项目要采用 JDK 17 了。 JDK 1.8:“不是说好了,他发任他发,你用 Java 8 吗?” 不光是我呀,连 Spring Boot 都开始要拥护 ...
继续阅读 »

最近在调研 JDK 17,并且试着将之前的一个小项目升级了一下,在测试环境跑了一段时间。最终,决定了,新项目要采用 JDK 17 了。


JDK 1.8:“不是说好了,他发任他发,你用 Java 8 吗?”


不光是我呀,连 Spring Boot 都开始要拥护 JDK 17了,下面这一段是 Spring Boot 3.0 的更新日志。



Spring Boot 3.0 requires Java 17 as a minimum version. If you are currently using Java 8 or Java 11, you'll need to upgrade your JDK before you can develop Spring Boot 3.0 applications.



Spring Boot 3.0 需要 JDK 的最低版本就是 JDK 17,如果你想用 Spring Boot 开发应用,你需要将正在使用的 Java 8 或 Java 11升级到 Java 17。


选用 Java 17,概括起来主要有下面几个主要原因:


1、JDK 17 是 LTS (长期支持版),可以免费商用到 2029 年。而且将前面几个过渡版(JDK 9-JDK 16)去其糟粕,取其精华的版本;


2、JDK 17 性能提升不少,比如重写了底层 NIO,至少提升 10% 起步;


3、大多数第三方框架和库都已经支持,不会有什么大坑;


4、准备好了,来吧。


拿几个比较好玩儿的特性来说一下 JDK 17 对比 JDK 8 的改进。


密封类


密封类应用在接口或类上,对接口或类进行继承或实现的约束,约束哪些类型可以继承、实现。例如我们的项目中有个基础服务包,里面有一个父类,但是介于安全性考虑,值允许项目中的某些微服务模块继承使用,就可以用密封类了。


没有密封类之前呢,可以用 final关键字约束,但是这样一来,被修饰的类就变成完全封闭的状态了,所有类都没办法继承。


密封类用关键字 sealed修饰,并且在声明末尾用 permits表示要开放给哪些类型。


下面声明了一个叫做 SealedPlayer的密封类,然后用关键字 permits将集成权限开放给了 MarryPlayer类。


public sealed class SealedPlayer permits MarryPlayer {
public void play() {
System.out.println("玩儿吧");
}
}

之后 MarryPlayer 就可以继承 SealedPlayer了。


public non-sealed class MarryPlayer extends SealedPlayer{
@Override
public void play() {
System.out.println("不想玩儿了");
}
}

继承类也要加上密封限制。比如这个例子中是用的 non-sealed,表示不限制,任何类都可以继承,还可以是 sealed,或者 final


如果不是 permits 允许的类型,则没办法继承,比如下面这个,编译不过去,会给出提示 "java: 类不得扩展密封类:org.jdk17.SealedPlayer(因为它未列在其 'permits' 子句中)"


public non-sealed class TomPlayer extends SealedPlayer {

@Override
public void play() {

}
}

空指针异常


String s = null;
String s1 = s.toLowerCase();

JDK1.8 的版本下运行:


Exception in thread "main" java.lang.NullPointerException
at org.jdk8.App.main(App.java:10)

JDK17的版本(确切的说是14及以上版本)


Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.toLowerCase()" because "s" is null
at org.jdk17.App.main(App.java:14)

出现异常的具体方法和原因都一目了然。如果你的一行代码中有多个方法、多个变量,可以快速定位问题所在,如果是 JDK1.8,有些情况下真的不太容易看出来。


yield关键字


public static int calc(int a,String operation){
var result = switch (operation) {
case "+" -> {
yield a + a;
}
case "*" -> {
yield a * a;
}
default -> a;
};
return result;
}

换行文本块


如果你用过 Python,一定知道Python 可以用 'hello world'"hello world"''' hello world '''""" hello world """ 四种方式表示一个字符串,其中后两种是可以直接支持换行的。


在 JDK 1.8 中,如果想声明一个字符串,如果字符串是带有格式的,比如回车、单引号、双引号,就只能用转义符号,例如下面这样的 JSON 字符串。


String json = "{\n" +
" \"name\": \"古时的风筝\",\n" +
" \"age\": 18\n" +
"}";

从 JDK 13开始,也像 Python 那样,支持三引号字符串了,所以再有上面的 JSON 字符串的时候,就可以直接这样声明了。


String json = """
{
"name": "古时的风筝",
"age": 18
}
""";

record记录类


类似于 Lombok 。


传统的Java应用程序通过创建一个类,通过该类的构造方法实例化类,并通过getter和setter方法访问成员变量或者设置成员变量的值。有了record关键字,你的代码会变得更加简洁。


之前声明一个实体类。


public class User {
private String name;

public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}

使用 Record类之后,就像下面这样。


public record User(String name) {

}

调用的时候像下面这样


RecordUser recordUser = new RecordUser("古时的风筝");
System.out.println(recordUser.name());
System.out.println(recordUser.toString());

输出结果



Record 类更像是一个实体类,直接将构造方法加在类上,并且自动给字段加上了 getter 和 setter。如果一直在用 Lombok 或者觉得还是显式的写上 getter 和 setter 更清晰的话,完全可以不用它。


G1 垃圾收集器


JDK8可以启用G1作为垃圾收集器,JDK9到 JDK 17,G1 垃圾收集器是默认的垃圾收集器,G1是兼顾老年代和年轻代的收集器,并且其内存模型和其他垃圾收集器是不一样的。


G1垃圾收集器在大多数场景下,其性能都好于之前的垃圾收集器,比如CMS。


ZGC


从 JDk 15 开始正式启用 ZGC,并且在 JDK 16后对 ZGC 进行了增强,控制 stop the world 时间不超过10毫秒。但是默认的垃圾收集器仍然是 G1。


配置下面的参数来启用 ZGC 。


-XX:+UseZGC

可以用下面的方法查看当前所用的垃圾收集器


JDK 1.8 的方法


jmap -heap 8877

JDK 1.8以上的版本


jhsdb jmap --heap --pid 8877

例如下面的程序采用 ZGC 垃圾收集器。



其他一些小功能


1、支持 List.of()、Set.of()、Map.of()和Map.ofEntries()等工厂方法实例化对象;


2、Stream API 有一些改进,比如 .collect(Collectors.toList())可以直接写成 .toList()了,还增加了 Collectors.teeing(),这个挺好玩,有兴趣可以看一下;


3、HttpClient重写了,支持 HTTP2.0,不用再因为嫌弃 HttpClient 而使用第三方网络框架了,比如OKHTTP;


升级 JDK 和 IDEA


安装 JDK 17,这个其实不用说,只是推荐一个网站,这个网站可以下载各种系统、各种版本的 JDK 。地址是 adoptium.net/


还有,如果你想在 IDEA 上使用 JDK 17,可能要升级一下了,只有在 2021.02版本之后才支持 JDK 17。


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

点兔换图——新年兔了个兔专题

前言 本篇是通过图片的点击事件去切换图片,实现图片点击轮播,而新年兔了个兔专题,当然是使用了一系列兔子的图片作为轮播图展示的,下面我们来看看怎么实现点兔换图的。 正篇 实现方法 其实安卓中实现方法很简单,我们可以轻松办到,就是ImageView中增加点击事件 ...
继续阅读 »

前言


本篇是通过图片的点击事件去切换图片,实现图片点击轮播,而新年兔了个兔专题,当然是使用了一系列兔子的图片作为轮播图展示的,下面我们来看看怎么实现点兔换图的。


正篇


实现方法


其实安卓中实现方法很简单,我们可以轻松办到,就是ImageView中增加点击事件


class RabbitFirst : AppCompatActivity() {

private lateinit var binding: ActivityRabbitFirstBinding
private var id by Delegates.notNull<Int>()

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

binding = ActivityRabbitFirstBinding.inflate(layoutInflater)

setContentView(binding.root)


id = 0

binding.img1.setOnClickListener {
if (id == 0) {
binding.img1.setImageBitmap(BitmapFactory.decodeResource(resources, R.drawable.rabit_c) )
id++
return@setOnClickListener
}
if (id == 1) {
binding.img1.setImageResource(R.drawable.rabit_b)
id++
return@setOnClickListener
}
if (id == 2) {
binding.img1.setImageResource(R.drawable.rabit_a)
id++
return@setOnClickListener
}
if (id == 3) {
binding.img1.setImageResource(R.drawable.rabit_d)
id = 0
return@setOnClickListener
}

Log.i("id ===$id", "is id")
}
}
}

如果图片多了可以使用数组去存,然后单独写方法去处理,这里只有四张图,所以我这里使用if判断,主要还是没找到有关setImageResource的对应方法,网上似乎说没有对应的get方法,可以使用加setTag和getTag方法去实现,和我的判断方法也类似,我的判断方法就是如果有四张图,我们就给它显示顺序,从0-3,开始,id为0-2时点击图片切换下一张,到id=3时再清空id值,置为0,这样又能回到第一张兔子图。


展示效果


最终效果如下,我们点击图片就可以进行图片轮换:


7ff3bf7180138f1af403f321b3f84b32.gif


ps:实现的时候出现了不能点击的问题,然后发现原来是在点击事件使用id全局变量增加时正好依次增加最后还是回到原图了,所以需要if里加上返回return,不经过下个if检查


总结


虽然形式很简单,但也是安卓的实现方法去做的,其实很多五花八门的效果都是从最简单的开始,然后添加各种新的技术最终才变得更加有趣好看。


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

高仿B站自定义表情

在之前的文章给你的 Android App 添加自定义表情 中我们介绍了自定义表情的原理,没看过的建议看一下。这一篇文章将介绍它的应用,这里以B站的自定义表情面板为例,效果如下: 自定义表情的大小 在给你的 Android App 添加自定义表情 的文章中,...
继续阅读 »

在之前的文章给你的 Android App 添加自定义表情
中我们介绍了自定义表情的原理,没看过的建议看一下。这一篇文章将介绍它的应用,这里以B站的自定义表情面板为例,效果如下:



自定义表情的大小


给你的 Android App 添加自定义表情


的文章中,我们说过当我们写死表情的大小时,文字的 textSize 变大变小时都会有一点问题。


文字大于图片大小时,在多行的情况下,只有表情的行间距明显小于其他行的间距。如图:



为什么会出现这种情况呢?如下图所示,我在top, ascent, baseline, descent, bottom的位置标注了辅助线。



可以很清晰的看到,在只有表情的情况下,top, ascent, descent, bottom的位置有明显的问题。原因是 DynamicDrawableSpangetSize 方法里面对 FontMetricsInt 进行了修改。解决的方式很简单,就是注释掉修改代码就行,代码如下。修改后,效果如下图所示。


@Override
public int getSize(@NonNull Paint paint, CharSequence text, int start, int end, @Nullable FontMetricsInt fm) {
Drawable d = getDrawable();
Rect rect = d.getBounds();
//
// if (fm != null) {
// fm.ascent = -rect.bottom;
// fm.descent = 0;
//
// fm.top = fm.ascent;
// fm.bottom = 0;
// }

return rect.right;
}


不知道你还记不记得,我们说过getSize 的返回值是表情的宽度。上面的注释代码其实是设置了表情的高度,如果文本的大小少于表情时,就会显示不全,如下图所示:



那这种情况下,应该怎么办?这里不卖关子了,最终代码如下。解决方式非常简单就是分情况来判断。当文本的高度小于表情的高度时,设置 fmtop, ascent, descent, bottom的值,让行的高度变大的同时让大的 emoji 图片居中。


 @Override
public int getSize(@NonNull Paint paint, CharSequence text, int start, int end, @Nullable FontMetricsInt fm) {
Drawable d = getDrawable();
Rect rect = d.getBounds();

float drawableHeight = rect.height();
Paint.FontMetrics paintFm = paint.getFontMetrics();

if (fm != null) {
int textHeight = fm.bottom - fm.top;
if(textHeight <= drawableHeight) {//当文本的高度小于表情的高度时
//解决文字的大小小于图片大小的情况
float textCenter = (paintFm.descent + paintFm.ascent) / 2;
fm.ascent = fm.top = (int) (textCenter - drawableHeight / 2);
fm.descent = fm.bottom = (int) (textCenter + drawableHeight / 2);
}
}
return rect.right;
}


当然,你可能发现了,B站的 emoji 表情好像不是居中的。如下图所示,B站对 emoji 表情的处理类似基于 baseline 对齐。



上面最难理解的居中已经介绍,对于其他方式比如 baseline 和 bottom 就简单了。完整代码如下:


@Override
public int getSize(@NonNull Paint paint, CharSequence text, int start, int end, @Nullable FontMetricsInt fm) {
Drawable d = getDrawable();
if(d == null) {
return 48;
}
Rect rect = d.getBounds();

float drawableHeight = rect.height();
Paint.FontMetrics paintFm = paint.getFontMetrics();

if (fm != null) {
if (mVerticalAlignment == ALIGN_BASELINE) {
fm.ascent = fm.top = (int) (paintFm.bottom - drawableHeight);
fm.bottom = (int) (paintFm.bottom);
fm.descent = (int) paintFm.descent;
} else if(mVerticalAlignment == ALIGN_BOTTOM) {
fm.ascent = fm.top = (int) (paintFm.bottom - drawableHeight);
fm.bottom = (int) (paintFm.bottom);
fm.descent = (int) paintFm.descent;
} else if (mVerticalAlignment == ALIGN_CENTER) {
int textHeight = fm.bottom - fm.top;
if(textHeight <= rect.height()) {
float textCenter = (paintFm.descent + paintFm.ascent) / 2;
fm.ascent = fm.top = (int) (textCenter - drawableHeight / 2);
fm.descent = fm.bottom = (int) (textCenter + drawableHeight / 2);
}
}
}

return rect.right;
}

动态表情


动态表情实际上就是 gif 图。我们可以使用 android-gif-drawable 来实现。在 build.gradle 中增加依赖:


dependencies {
...
implementation 'pl.droidsonroids.gif:android-gif-drawable:1.2.25'
}

然后在我们创建自定义 ImageSpan 的时候传入参数就可以了:


val size = 192
val gifFromResource = GifDrawable(getResources(), gifData.drawableResource)
gifFromResource.stop()
gifFromResource.setBounds(0,0, size, size)
val content = mBinding.editContent.text as SpannableStringBuilder
val stringBuilder = SpannableStringBuilder(gifData.text)
stringBuilder.setSpan(BilibiliEmojiSpan(gifFromResource, ALIGN_BASELINE),
0, stringBuilder.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)

关于 android-gif-drawable 更具体用法可以看 Android加载Gif动画android-gif-drawable的使用


总结


核心部分的代码已经介绍了,完整代码还在整理,后面放出来。最后求一个免费的赞吧🥺


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

android 自定义view 跑马灯-光圈效果

系统: mac android studio: 4.1.3 kotlin version: 1.5.0 gradle: gradle-6.5-bin.zip 本篇效果: 前沿 最近在bilibili看到一个跑马灯光圈效果挺好, 参考着思路写了一下. bili...
继续阅读 »

系统: mac


android studio: 4.1.3


kotlin version: 1.5.0


gradle: gradle-6.5-bin.zip


本篇效果:


8140FE3CF87738708E0C5D0E4F59704F


前沿


最近在bilibili看到一个跑马灯光圈效果挺好, 参考着思路写了一下.


bilibili地址,美中不足的是这是html代码 QaQ


实现思路




  • 将效果分为3层



    • 第一层: 背景

    • 第二层: 跑马灯光圈

    • 第三层: 展示区




如图所示:


Nov-28-2022 17-19-34



tips: 图片截取自上方bilibili视频



换到android中直接将view当作背景层, 在利用Canvas绘制跑马灯层即可


将View圆角化


 // 设置view圆角
 outlineProvider = object : ViewOutlineProvider() {
   override fun getOutline(view: View, outline: Outline) {
     // 设置圆角率为
     outline.setRoundRect(0, 0, view.width, view.height, RADIUS)
  }
 }
 clipToOutline = true

这段代码网上找的,源码还没有看, 有机会再看吧.


image-20221128173221355


来看看当前效果:


CD09F6ED6DBE6895E487C703B7DB64F0


自定义跑马灯光圈


这几个字可能有点抽象,所以来看看要完成的效果:


Nov-28-2022 17-45-34


接下来只需要吧黄框外面和里面的的去掉就完成了旋转的效果:


去掉外面:


Nov-28-2022 17-47-38


去掉里面:


Nov-28-2022 17-47-32


这都是html效果,接下来看看android怎么写:


 class ApertureView @JvmOverloads constructor(
     context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
 ) : View(context, attrs, defStyleAttr) {
     companion object {
         val DEF_WIDTH = 200.dp
         val DEF_HEIGHT = DEF_WIDTH
         private val RADIUS = 20.dp
    }
 
     private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
 
     private val rectF by lazy {
         val left = 0f + RADIUS / 2f
         val top = 0f + RADIUS / 2f
         val right = left + DEF_WIDTH - RADIUS
         val bottom = top + DEF_HEIGHT - RADIUS
         RectF(left, top, right, bottom)
    }
 
     override fun onDraw(canvas: Canvas) {
         val left = rectF.left + rectF.width() / 2f
         val right = rectF.right + rectF.width()
         val top = rectF.top + rectF.height() / 2f
         val bottom = rectF.bottom + rectF.height() / 2f
 
         // 绘制渐变view1
         paint.color = Color.GREEN
         canvas.drawRect(left, top, right, bottom, paint)
 
         // 绘制渐变view2
         paint.color = Color.RED
         canvas.drawRect(left, top, -right, -bottom, paint)
 
    }
 }

这里就是计算偏移量等,都比较简单:


542DD72464B89550F97E8BAD9EFE6FD5


因为咋们是view,并且已经测量了view的宽和高,所以超出的部分就不展示了


跑马灯动起来


这段代码比较简单,直接开一个animator即可


  private val animator by lazy {
    val animator = ObjectAnimator.ofFloat(this, "currentSpeed", 0f, 360f)
    animator.repeatCount = -1
    animator.interpolator = null
    animator.duration = 2000L
    animator
  }
 
 var currentSpeed = 0f
   set(value) {
     field = value
     invalidate()
  }
         
 override fun onDraw(canvas: Canvas) {
 
   // withSave 保存画布
   canvas.withSave {
     
   // 画布中心点旋转
   canvas.rotate(currentSpeed, width / 2f, height / 2f)
     // 绘制渐变view1 绘制渐变view2
    ...
  }
 }

14162A8D36FFE0BEB6CD9B9D5A67446F


'去掉'里面


去除里面部分有2种方式



  • 方式一: 利用 clipOutPath() 来clip掉中间区域, 这个api对版本有要求

  • 方式二: 重新绘制一个 RoundRect() 来覆盖掉中间区域


方式一:


 private val path by lazy {
     Path().also { it.addRoundRect(rectF, RADIUS, RADIUS, Path.Direction.CCW) }
 }
 
 override fun onDraw(canvas: Canvas) {
 
     // withSave 保存画布
     canvas.withSave {
       canvas.clipOutPath(path)
          // 画布中心点旋转
       canvas.rotate(currentSpeed, width / 2f, height / 2f)
       
       // 绘制渐变view1 ..view2...
    }
 }

方式二:


 override fun onDraw(canvas: Canvas) {
   // withSave 保存画布
   canvas.withSave {
 
     // 画布中心点旋转
     canvas.rotate(currentSpeed, width / 2f, height / 2f)
 
     // 绘制渐变view1
 
     // 绘制渐变view2
 
  }
 
   paint.color = Color.BLACK
   canvas.drawRoundRect(rectF, RADIUS, RADIUS, paint)
 }

来看看当前效果:


B9B3733C51780A7AFB53CBA080582B20


但是现在看起来还是有一点生硬, 可以让view渐变一下


 private val color1 by lazy {
   LinearGradient(width * 1f,height / 2f,width * 1f,height * 1f,
     intArrayOf(Color.TRANSPARENT, Color.RED), floatArrayOf(0f, 1f),
     Shader.TileMode.CLAMP
  )
 }
 
 private val color2 by lazy {
   LinearGradient( width / 2f,height / 2f,width / 2f, 0f,
     intArrayOf(Color.TRANSPARENT, Color.GREEN), floatArrayOf(0f, 1f),
     Shader.TileMode.CLAMP
  )
 }
 
 override fun onDraw(canvas: Canvas) {
 //
   canvas.withSave {
     canvas.rotate(currentSpeed, width / 2f, height / 2f)
    ...
     // 绘制渐变view1
     paint.shader = color1
     canvas.drawRect(left1, top1, right1, bottom1, paint)
     paint.shader = null
 
     // 绘制渐变view2
     paint.shader = color2
     canvas.drawRect(left1, top1, -right1, -bottom1, paint)
     paint.shader = null
  }
 
   // 中间rect
   canvas.drawRoundRect(rectF, RADIUS, RADIUS, paint)
 }

这样一来,就更有感觉了


效果图:


FBFD3920C18DA5E6821CA08C9CFB8052


基本效果就完成了,那么如何给其他view也可以轻松的添加这个炫酷的边框呢?


很显然,view是办不到的,所以我们只能自定义viewgroup


代码没有改变,只是在自定义viewgroup时,onDraw() 不会回调, 因为viewgroup主要就是用来管理view的,所以要想绘制viewgroup最好是重写dispatchDraw()方法,


在dispatchDraw()方法中,需要注意的是 super.dispatchDraw(canvas) , 这个super中会绘制children,


所以为了避免 view被跑马灯背景覆盖,需要将super.dispatchDraw(canvas) 写到最后一行


 #ApertureViewGroup.kt
 
 override fun dispatchDraw(canvas: Canvas) {
         val left1 = width / 2f
         val top1 = height / 2f
 
         val right1 = left1 + width
         val bottom1 = top1 + width
         canvas.withSave {
             canvas.rotate(currentSpeed, width / 2f, height / 2f
             // 绘制渐变view1
             paint.shader = color1
             canvas.drawRect(left1, top1, right1, bottom1, paint)
             paint.shader = null
 
             if (mColor2 != -1) {
                 // 绘制渐变view2
                 paint.shader = color2
                 canvas.drawRect(left1, top1, -right1, -bottom1, paint)
                 paint.shader = null
            }
        }
 
         paint.color = mMiddleColor
         canvas.drawRoundRect(rectF, mBorderAngle, mBorderAngle, paint)
 
 // 一定要写到最后一行,否则children会被跑马灯覆盖掉
         super.dispatchDraw(canvas)
    }

最后在调用的时候直接:


 <ApertureViewGroup
     android:layout_width="200dp"
     android:layout_height="200dp"
 
     // 边框颜色
     android:background="@color/cccccc"
                                                       
 // 边框宽度                                            
     app:aperture_border_width="50dp"
                       
 // 边框角度
     app:aperture_border_angle="20dp"                                               
 
 // 渐变颜色1
     app:aperture_color1="@color/purple_200"
                                                                                               
 // 渐变颜色2 如果不写,默认只有一个渐变在跑马灯
     app:aperture_color2="@color/color_FFC107"
                                                       
 // 旋转时间
     app:aperture_duration="3000"
                                                       
 // 中间空心颜色
     app:aperture_middle_color="@color/white">
 
     <XXXX View
         android:layout_width="wrap_content"
         android:layout_height="wrap_content"
         android:gravity="center" />
 </com.example.customviewproject.f.f2.ApertureViewGroup>

本篇代码比较简单,不过这个思路确实挺好玩的!


最终效果:


A051CC6A0481AE320B2371E271889D04


完整代码


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

【Android爬坑日记四】组合替代继承,减少Base类滥用

背景 先说一下背景,当接触了比较多的项目之后,其实会发现每一个项目都会封装BaseActivity、BaseFragment等等。其实初衷其实是好的。每一个Activity和Fragment都是很多模板代码的,为了减少模板代码,封装进Base类其实是一种比较方...
继续阅读 »

背景


先说一下背景,当接触了比较多的项目之后,其实会发现每一个项目都会封装BaseActivity、BaseFragment等等。其实初衷其实是好的。每一个Activity和Fragment都是很多模板代码的,为了减少模板代码,封装进Base类其实是一种比较方便且可行的选择。


Base类涵盖了抽象、继承等面向对象特性,用得好会减少很多样板代码,但是一旦滥用,会对项目有很多弊端。


举个例子


当项目大了,需要封装进Base类的逻辑会非常多,比如说打印生命周期、ViewBinding 或者DataBinding封装、埋点、监听广播、监听EventBus、展示加载界面、弹Dialog等等其他业务逻辑,更有甚者把需要Context的函数都封装进Base类中。


以下举一个BaseActivity的例子,里面封装了上面所说的大部分情况,实际情况可能更多。


abstract class BaseActivity<T: ViewBinding, VM: ViewModel>: AppCompatActivity {

protected lateinit var viewBinding: T

protected lateinit var viewModel: VM

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// 打印日志!!
ELog.debugLifeCycle("${this.localClassName} - onCreate")

// 初始化viewModel
viewModel = initViewModel()
// 初始化视图!!
initView()
// 初始化数据!!
initData()
// 注册广播监听!!
registerReceiver()
// 注册EventBus事件监听!!
registerEventBus()

// 省略一堆业务逻辑!

// 设置导航栏颜色!!
window.navigationBarColor = ContextCompat.getColor(this, R.color.primary_color)
}

protected fun initViewModel(): VM {
// 初始化viewModel
}

private fun initViewbinding() {
// 初始化viewBinding
}

// 让子类必须实现
abstract fun initView()

abstract fun initData()

private fun registerReceiver() {
// 注册广播监听
}

private fun unregisterReceiver() {
// 注销广播监听
}

private fun registerEventBus() {
// 注册EventBus事件监听
}

protected fun showDialog() {
// 需要用到Context,因此也封装进来了
}

override fun onResume() {
super.onResume()
ELog.debugLifeCycle("${this.localClassName} - onResume")
}

override fun onPause() {
super.onPause()
ELog.debugLifeCycle("${this.localClassName} - onPause")
}

override fun onDestroy() {
super.onDestroy()
ELog.debugLifeCycle("${this.localClassName} - onDestroy")
unregisterReceiver()
}
}

其实看起来还好,但是在使用的时候难免会遇到一些问题,对于中途接手项目的人来说问题更加明显。我们从中途接手项目的心路历程看看Base类的缺陷。


心路历程




  1. 当创建新的Activity或者Fragment的时候需要想想有没有逻辑可以复用,就去找Base类,或许写Base类的人不同,发现一个项目中可能会存在多个Base类,甚至Base类仍然有多个Base子类实现不同逻辑。这个时候就需要去查看分析每个Base类分别实现了什么功能,决定继承哪个。




  2. 如果一个项目中只有一个Base类的话,仍需要看看Base类实现了什么逻辑,没有实现什么逻辑,防止重复写样板代码。




  3. 当出现Base类实现了的,而自己本身并不想需要,例如不想监听广播或者不想用ViewModel,对于不想监听广播的情况就要特殊做适配,例如往Base类加标志位。对于不想用ViewModel但是由于泛型限制,还是只能传进去,不然没法继承。




  4. 当发现自己集成Base类出BUG了,就要考虑改子类还是改Base类,由于大量的类都集成了Base类,显然改Base类比较麻烦,于是改自己比较方便。




  5. 如果一个Activity中展示了多个Fragment,可能会有业务逻辑的重复,其实只需要一个就好了。




其实第一第二点还好,时间成本其实没有重复写样板代码那么高。但是第三点的话其实用标志位来决定Base类的功能哪个需要实现哪个不需要实现并不是一种优雅的方式,反而需要重写的东西多了几个。第四点归根到底就是Base类其实并不好维护。


爬坑


那么对于Base类怎样实践才比较优雅呢?在我看来组合替代继承其实是一种不错的思路。对于Kotlin first的Android项目来说,组合替代继承其实是比较容易的。以下仅代表个人想法,有不同意见可以交流一下。


成员变量委托


对于ViewModel、Handler、ViewBinding这些Base变量使用委托的方式是比较方便的。


对于ViewBinding委托可以看看我之前的文章,使用起来其实是非常简单的,只需要一行代码即可。


// Activity
private val binding by viewBinding(ActivityMainBinding::inflate)
// Fragment
private val binding by viewBinding(FragmentMainBinding::bind)

对于ViewModel委托,官方库则提供了一个viewBindings委托函数。


private val viewModel:HomeViewModel by viewModels()

需要在Gradle中引入ktx库


implementation 'androidx.fragment:fragment-ktx:1.5.1'
implementation 'androidx.activity:activity-ktx:1.5.1'

而对于Base变量则尽量少封装在Base类中,需要使用可以使用委托,因为如果实例了没有使用其实是比较浪费内存资源的,尽量按需实例。


扩展方法


对于需要用到Context上下文的逻辑封装到Base类中其实是没有必要的,在Kotlin还没有流行的时候,如果说需要使用到Context的工具方法,使用起来其实是不太优雅的。


例如展示一个Dialog:


class DialogUtils {
public static void showDialog(Activity activity, String title, String content) {
// 逻辑
}
}

使用起来就是这样:


class MyActivity : AppCompatActivity() {
...
fun initButton() {
button.setOnClickListener {
DialogUtils.showDialog(this, "title", "content")
}
}
}

使用起来可能就会有一些迷惑,第一个参数把自己传进去了,这对于展示Dialog的语义上是有些奇怪的。按理来说只需要传title和content就好了。


这个时候就会有人想着把这个封装到Base类中。


public abstract class BaseActivity extends AppCompatActivity {

protected void showDialog(String title, String content) {
// 这里就可以用Context了
}
}


使用起来就是这样:


class MyActivity : AppCompatActivity() {
...
fun initButton() {
button.setOnClickListener {
showDialog("title", "content")
}
}
}


是不是感觉好很多了。但是写在Base类中在Java中比较好用,对于Kotlin则完全可以使用扩展函数语法糖来替代了,在使用的时候和定义在Base类是一样的。


fun Activity.showDialog(title: String, content: String) {
// this就能获取到Activity实例
}

class MyActivity : AppCompatActivity() {
...
fun initButton() {
button.setOnClickListener {
// 使用起来和定义在Base类其实是一样的
showDialog("title", "content")
}
}
}


这也说明了,需要使用到Context上下文的函数其实不用在Base类中定义,直接定义在顶层就好了,可以减少Base类的逻辑。


注册监听器


对于注册监听器这种情况则需要分情况,监听器是需要根据生命周期来注册和取消注册的,防止内存泄漏。对于不是每个子类都需要的情况,有的人可能觉得提供一个标志位就好了,如果不需要的话让子类重写。如果定义成抽象方法则每个子类都要重写,如果不是抽象方法的话,子类可能就会忘记重写。在我看来获取生命周期其实是比较简单的事情。按需添加代码监听就好了。


那么什么情况需要封装在Base类中呢?




  • 怕之后接手项目的人忘记写这部分代码,则可以写到Base类中,例如打印日志或者埋点。




  • 而对于界面太多难以测试的功能,例如收到被服务器踢下线的消息跳到登录页面,这个可以写进Base类中,因为基本上每个类都需要监听这种消息。




总结


没有最优秀的架构,只有最适合的架构!对于Base类大家的看法都不一样,追求更少的工作量完成更多事情这个目的是统一的。而Base类一旦臃肿起来了会造成整个项目难以维护,因此对于Base类应该辩证看待,养成只有必要的逻辑才写在Base类中的习惯,feature类应该使用组合的方式来使用,这对于项目的可维护性和代码的可调试性是有好处的。


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

关于缓存,每个开发人员都应该知道的3个问题

前言 虽然缓存被认为是软件系统的性能助推器,但如果处理不当,它也容易出现错误。 在本文中,我将介绍 3 个有时可能会造成灾难性后果的常见缓存问题,希望大家在架构上引入缓存时,需要考虑到。 缓存击穿 缓存故障 当缓存键过期时会发生缓存故障,并且多个请求同时访问...
继续阅读 »

前言


虽然缓存被认为是软件系统的性能助推器,但如果处理不当,它也容易出现错误。


在本文中,我将介绍 3 个有时可能会造成灾难性后果的常见缓存问题,希望大家在架构上引入缓存时,需要考虑到。


缓存击穿



缓存故障


当缓存键过期时会发生缓存故障,并且多个请求同时访问数据库以查找相同的键。


让我们来看看它是如何工作的:



  • 热缓存键过期。

  • 多个并发请求进来搜索同一个键。

  • 服务器向数据库发起多个并发请求以查找相同的键。


缓存击穿会显著增加数据库的负载,尤其是当许多热键同时过期时。


下面是解决这个问题的2种解决方案:



  • 获取搜索到的key的分布式锁,当一个线程试图更新缓存时,其他线程需要等待。

  • 利用Refresh-ahead 策略异步刷新热数据,使热键永不过期。


缓存穿透



缓存穿透


当搜索到的key既不在缓存中, 也不在数据库中时,就会发生缓存穿透, 连数据库都穿透过去了。


让我们来看看它是如何工作的,当key既不在缓存中也不在数据库中时,就会发生这种情况。



  • 当用户查询key时,应用程序由于缓存未命中而去查询数据库数据库。

  • 由于数据库不包含该key并返回空结果,因此该key也不会被缓存。

  • 因此,每个查询最终都会导致缓存未命中,而命中数据库,直接进行查库。


虽然乍一看这似乎微不足道,但攻击者可以通过使用此类密钥启动大量搜索来尝试破坏你的数据库


为了解决这个问题,我们可以:



  • 缓存过期时间较短的空结果。

  • 使用布隆过滤器。在查询数据库之前,应用程序在布隆过滤器中查找key,如果key不存在则立即返回。


缓存雪崩



当对数据库的请求突然激增时,就会发生缓存雪崩


这发生在:



  • 许多缓存数据同时过期。

  • 缓存服务宕机,所有请求都直接访问数据库。


数据库流量的突然激增可能会导致级联效应,并可能最终导致您的服务崩溃。


下面是一些常见的解决方案:



  • 调整缓存键的过期时间,使它们不会同时过期。

  • 使用Refresh-ahead 策略异步刷新热数据,使其永不过期。

  • 使用缓存集群来避免单点故障。当主节点崩溃时,其中一个副本被提升为新的主节点。


总结


虽然这些缓存问题起初看起来微不足道,但有时可能会对我们的下游客户端和依赖项产生级联效应。事先了解它们可以让我们设计一个更强大的系统,也可以简化我们的故障排除过程。


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

浅谈TheadLocal的使用场景和注意事项

概念 ThreadLocal 是Java的一个类,是一个本地线程,提供了一种线程安全的方式,主要用来避免共享数据(线程变量隔离)。 有时候可能要避免共享变量,使用ThreadLocal辅助类为各个线程提供各自的实例;就是说,每个线程都有一个伴生的空间(Th...
继续阅读 »

概念



ThreadLocalJava的一个类,是一个本地线程,提供了一种线程安全的方式,主要用来避免共享数据(线程变量隔离)。



有时候可能要避免共享变量,使用ThreadLocal辅助类为各个线程提供各自的实例;就是说,每个线程都有一个伴生的空间(ThreadLocal),存储私有的数据,只要线程在,就能拿到对应线程的ThreadLocal中存储的值。


TheadLocal的使用场景和注意事项


ThreadLocalJava开发中非常常见,一般在以下情况会使用到ThreadLocal



  • 在进行对象跨层传递的时候,可以考虑使用ThreadLocal,避免方法多次传递,打破层次间的约束。

  • 线程间数据隔离,比如:上下文ActionContext、ApplicationContext

  • 进行事务处理,用于存储线程事务信息。


image.png


在使用ThreadLocal的时候,最常用的方法就是:initialValue()、set(T t)、get()、remove()


image.png


创建以及提供的方法


创建一个线程局部变量,其初始值通过调用给定的提供者(Supplier)生成;


public static <S> ThreadLocal<S> withInitial(Supplier<? extends S> supplier) {
return new SuppliedThreadLocal<>(supplier);
}

// InitialValue()初始化方式使用Java 8提供的Supplier函数接口会更加简介
ThreadLocal<String> userContext = ThreadLocal.withInitial(String::new);

这里就列出用的比较多的方法:


将此线程局部变量的当前线程副本设置为指定值;value表示要存储在此线程本地的当前线程副本中的值


public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
}

返回此线程局部变量的当前线程副本中的值。 如果该变量对于当前线程没有值,则首先将其初始化为调用initialValue方法返回的值


public T get() {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
return setInitialValue();
}

删除此线程局部变量的当前线程值


public void remove() {
ThreadLocalMap m = getMap(Thread.currentThread());
if (m != null)
m.remove(this);
}

项目实例


以下是个人使用的场景:


为什么会使用它,如果在项目中想直接获取当前登录用户的信息,这个功能就可以使用ThreadLocal实现。


/**
* 登录用户信息上下文
*
* @author: austin
* @since: 2023/2/8 13:47
*/
public class UserContext {

private static final ThreadLocal<User> USER_CONTEXT = ThreadLocal.withInitial(User::new);

public static void set(User user) {
if (user != null) {
USER_CONTEXT.set(user);
}
}

public static User get() {
return USER_CONTEXT.get();
}

public static void remove() {
USER_CONTEXT.remove();
}

public static User getAndThrow() {
User user = USER_CONTEXT.get();
if (user == null || StringUtils.isEmpty(user.getId())) {
throw new ValidationException("user info not found!");
}
return user;
}
}

上面其实是定义了一个用户信息上下文类,关于上下文(context),我们在开发的过程中经常会遇到,比如SpringApplicationContext,上下文是贯穿整个系统或者阶段生命周期的对象,其中包含一些全局的信息,比如:登录后用户信息、账号信息、地址区域信息以及在程序的每一个阶段运行时的数据。


👏有了这个用户上下文对象之后,接下来就可以在项目中使用:


在该项目中个人使用的地方在登录拦截器中,当对登录的信息检查成功后,那么将当前的用户对象加入到ThreadLocal中:


User currentUser = userService.login(token.getUsername(), String.valueOf(token.getPassword()));
// 用户登录认证成功,UserContext存储用户信息
UserContext.put(currentUser);

Serivce实现层使用的时候,直接调用ThreadLocal中的get方法,就可以获得当前登录用户的信息:


//获取当前在线用户信息
User user = UserContext.get();

资源调用完成后需要在拦截器中删除ThreadLocal资源,防止内存泄漏问题:


@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
//使用完的用户信息需要删除,防止内存泄露
UserContext.remove();
}

ThreadLocal的内存泄露问题🤢


如果我们在使用完该线程后不进行ThreadLocal中的变量进行删除,那么就会造成内存泄漏的问题,那么该问题是怎么出现的?


首先先分析一下ThreadLocal的内部结构:


ThreadLocal内部结构.png


先明确一个概念:对应在栈中保存的是对象的引用,对象的值是存储在堆中,如上图所示:其中Heap中的mapThreadLocalMap, 里面包含keyvalue, 其中value就是我们需要保存的变量数据,key则是ThreadLocal实例,上述图片的连接有实线和虚线,实线代表强引用,虚线表示弱引用。



即:每一个Thread维护一个ThreadLocalMap, key为使用 弱引用ThreadLocal实例,value为线程变量的副本。



扫盲强引用、软引用、弱引用、虚引用:😂


不同的引用类型呢,主要体现在对象的不同的可达性状态和对垃圾收集的影响:


强引用Java最常见的一种引用,只要还有强引用指向一个对象,那么证明该对象一定还活着,一定为可达性状态,不会被垃圾回收机制回收,因此,强引用是造成Java内存泄漏的主要原因。


软引用 是通过SoftReference实现的,如果一个对象只要软引用,那么在系统内存空间不足的时候会试图回收该引用指向的对象。


弱引用 是通过WeakReference实现的,如何一个对象只有弱引用,在垃圾回收线程扫描它所管辖的内存区域的时候,一旦发现只有弱引用指向的对象时候,不管当前的内存空间是否足够,垃圾回收器都会去回收这样的一个内存。


虚引用 形同虚设的东西,在任何情况下都可能被回收。


我们都知道,map中的value需要key找到,key没了,那么value就会永远的留在内存中,直到内存满了,导致OOM,所以我们就需要使用完以后进行手动删除,这样能保证不会出现因为GC的原因造成的OOM问题;当ThreadLocal Ref显示的指定为null时,关系链就变成了下面所示的情况:


ThreadLocal内存泄漏.png


ThreadLocal被显示显的指定为null之后,JVM执行GC操作,此时堆内存中的Thread-Local被回收,同时ThreadLocalMap中的Entry.key也成为了null,但是value将不会被释放,除非当前线程已经结束了生命周期的Thread引用被垃圾回收器回收。


ThreadLocal解决SimpleDateFormat非线程安全问题


为了找到问题所在,我们尝试查看SimpleDateFormatformat方法的源码来排查一下问题,format方法源码如下:


private StringBuffer format(Date date, StringBuffer toAppendTo,
FieldDelegate delegate) {

// 注意到此行setTime()方法代码
calendar.setTime(date);

boolean useDateFormatSymbols = useDateFormatSymbols();

for (int i = 0; i < compiledPattern.length; ) {
int tag = compiledPattern[i] >>> 8;
int count = compiledPattern[i++] & 0xff;
if (count == 255) {
count = compiledPattern[i++] << 16;
count |= compiledPattern[i++];
}

switch (tag) {
case TAG_QUOTE_ASCII_CHAR:
toAppendTo.append((char)count);
break;

case TAG_QUOTE_CHARS:
toAppendTo.append(compiledPattern, i, count);
i += count;
break;

default:
subFormat(tag, count, delegate, toAppendTo, useDateFormatSymbols);
break;
}
}
return toAppendTo;
}

从上述源码可以看出,在执行SimpleDateFormat.format()方法时,会使用calendar.setTime()方法将输入的时间进行转换,那么我们想象一下这样的场景:



  • 线程 1 执行了calendar.setTime(date) 方法,将用户输入的时间转换成了后面格式化时所需要的时间;

  • 线程 1 暂停执行,线程 2 得到CPU时间片开始执行;

  • 线程 2 执行了calendar.setTime(date)方法,对时间进行了修改;

  • 线程 2 暂停执行,线程 1 得出CPU时间片继续执行,因为线程 1 和线程 2 使用的是同一对象,而时间已经被线程 2 修改了,所以此时当线程 1 继续执行的时候就会出现线程安全的问题了。


正常情况下,程序执行是这样的:


image.png


非线程安全的执行流程是这样的:


image.png


了解了ThreadLocal的使用之后,我们回到本文的主题,接下来我们将使用ThreadLocal来实现100个时间的格式化,具体实现代码如下:


import java.text.DateFormat;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Date;

/**
* 多线程时间工具类:ConcurrentDateUtil
*
* @author: austin
* @since: 2023/2/8 15:36
*/
public class ConcurrentDateUtil {

private static final String date_format = "yyyy-MM-dd HH:mm:ss";
private static ThreadLocal<DateFormat> threadLocal = new ThreadLocal<DateFormat>();

public static DateFormat getDateFormat() {
DateFormat df = threadLocal.get();
if (df == null) {
df = new SimpleDateFormat(date_format);
threadLocal.set(df);
}
return df;
}

public static String formatDate(Date date) throws ParseException {
return getDateFormat().format(date);
}

public static Date parse(String strDate) throws ParseException {
return getDateFormat().parse(strDate);
}
}

当然也可以使用:



  • Apache commons包的DateFormatUtils或者FastDateFormat实现,宣称是既快又线程安全的SimpleDateFormat,并且更高效。

  • 使用Joda-Time类库来处理时间相关问题。


总结


本文简单的介绍了ThreadLocal的应用场景,其主要用在需要每个线程独占的元素上,例如SimpleDateFormat。然后,就是介绍了ThreadLocal的实现原理,详细介绍了set()get()方法,介绍了ThreadeLocalMap的数据结构,最后就是说到了ThreadLocal的内存泄露以及避免的方式。


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

android 微信抢红包工具 AccessibilityService

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

1、目标


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


2、实现流程


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


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


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


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


2、实现步骤


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


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


Ⅰ、AccessibilityService

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


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

}

Ⅱ、NotificationListenerService

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


class MyNotificationListenerService : NotificationListenerService() {

override fun onNotificationPosted(sbn: StatusBarNotification?) {
super.onNotificationPosted(sbn)

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

override fun onNotificationRemoved(sbn: StatusBarNotification?) {
super.onNotificationRemoved(sbn)
}
}

2、点击红包


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


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



我本地的jdk是11,我怀疑是不兼容,毕竟Android Device Monitor太老了。换新的layout Inspector,也就看看本地的debug应用,无法查看微信的呀。要么就反编译,这个就先不考虑了,换findAccessibilityNodeInfosByText这个方法试试。


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


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


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

}

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

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

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


3、点击开红包


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


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

}

}
}

}

4、退出红包详情页


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


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

}

}
}
}

3、遇到问题


1、AccessibilityService收不到AccessibilityEvent.TYPE_NOTIFICATION_STATE_CHANGED事件


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


2、需要点击View的定位


简单是就是到页面应该点哪个View,找到相应的规则,来过滤出对应的View,这个规则是随着微信的改变而变化的,findAccessibilityNodeInfosByViewId最直接,但是奈何工具问题,有点麻烦,于是采用取巧的办法,通过找到其他View来定位自身


4、完整代码


MyNotificationListenerService


class MyNotificationListenerService : NotificationListenerService() {

override fun onNotificationPosted(sbn: StatusBarNotification?) {
super.onNotificationPosted(sbn)

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

override fun onNotificationRemoved(sbn: StatusBarNotification?) {
super.onNotificationRemoved(sbn)
}
}

MyAccessibilityService


class RobService : AccessibilityService() {

override fun onAccessibilityEvent(event: AccessibilityEvent) {
val eventType = event.eventType
when (eventType) {
AccessibilityEvent.TYPE_NOTIFICATION_STATE_CHANGED -> handleNotification(event)
AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED, AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED -> {
val className = event.className.toString()
Log.e("测试无障碍",className)
if (className == "com.tencent.mm.ui.LauncherUI") {
getPacket()
} else if (className == "com.tencent.mm.plugin.luckymoney.ui.LuckyMoneyReceiveUI") {
openPacket()
} else if (className == "com.tencent.mm.plugin.luckymoney.ui.LuckyMoneyNotHookReceiveUI") {
openPacket()
} else if (className == "com.tencent.mm.plugin.luckymoney.ui.LuckyMoneyDetailUI") {
close()
}

}
}
}

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

}

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

}

}
}
}

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

}

}
}

}

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

}

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

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

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

5、总结


此文是对AccessibilityService的使用的一个梳理,这个功能其实不麻烦,主要是一些细节问题,像自动领取支付宝红包,自动领取QQ红包或者其他功能等也都可以用类似方法实现。


作者:我有一头小毛驴你有吗
链接:https://juejin.cn/post/7196949524061339703
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

APT-单例代码规范检查

前文提到注解按照Retention可以取值可以分为SOURCE,CLASS,RUNTIME三类,在定义注解完成后,可以结合APT进行注解解析,读取到注解相关信息后进行一些检查和设置。 接下来我们实现一个单例注解来修饰单例类,当开发人员写的单例类不符合编码规范时...
继续阅读 »

前文提到注解按照Retention可以取值可以分为SOURCE,CLASS,RUNTIME三类,在定义注解完成后,可以结合APT进行注解解析,读取到注解相关信息后进行一些检查和设置。


接下来我们实现一个单例注解来修饰单例类,当开发人员写的单例类不符合编码规范时,在编译过程中抛出异常。大家都知道,单例类应该具有以下特点:



  • 构造器私有

  • 具有public static修饰的getInstance方法


打开Android Studio,新建SingletonAnnotationDemo工程,随后在该工程中进行注解的定义和APT的开发,一般情况下注解和与之关联的APT都会以单独的Module声明在项目中,下面我们开始实践吧。


singleton-annotation 注解模块


新建singleton-annotation Java模块

打开新建的SingletonAnnotationDemo项目,在右上角切换至Project视图,如下图所示:


1-7-2-1


切换完成后,在项目名称上右键单击,在弹出的菜单中依此选择new->Module,如下图所示:


1-7-2-2


选择Module条目后,弹出如下对话框,依次操作如下图所示:


1-7-2-3


其中标记1表明我们创建的是Java或者Kotlin模块,标记2位置填写模块名称,这里输入singleton-annotation,标记3位置输入打算创建的类名,这里填写Singleton,标记4位置用于选择模块语言类型,这里选择java即可。


至此创建singleton-annotation模块完成,等待Android Studio构建完成即可。


新建Singleton注解

打开新建的singleton-annotation模块,进入Singleton.java文件中将其修改为注解,如上文描述,该注解运行在编译期,故Retention为SOURCE,作用在类上,故其Target取值为TYPE,完整代码如下:


 package com.poseidon.singleton_annotation;
 
 import java.lang.annotation.ElementType;
 import java.lang.annotation.Retention;
 import java.lang.annotation.RetentionPolicy;
 import java.lang.annotation.Target;
 
 @Retention(RetentionPolicy.SOURCE)
 @Target(ElementType.TYPE)
 public @interface Singleton {
 }

依赖singleton-annotation模块

在app模块添加对singleton-annotation模块的依赖,操作方式有两种:




  • 手动添加singleton-annotation依赖


    打开app模块的build.gradle文件,在其内部手动添加依赖,如下所示:


     dependencies {
        ...
         // 添加singleton-annotation模块依赖
         implementation project(path: ':singleton-annotation')
     
     }

    随后重新同步项目即可




  • 使用AS菜单添加singleton-annotation依赖


    在app模块右键选择Open Module Settings,在随后弹出的弹窗中添加singleton-annotation模块,操作指导如下图所示:


    1-7-2-4


    选择Open Module Settings后弹框如下图所示,选择dependencies,代表依赖管理,随后在右侧的Module列表中选择你要操作的模块,这里选择app,最后点击选择app模块后,其右侧依赖列表中的加号,选择Module Dependency,代表添加模块依赖



    Module Dependency:模块依赖,一般用于添加项目中的其他模块作为依赖


    Library Dependency:库依赖,一般用于添加上传到maven,google,jcenter等位置的开源库


    JAR/AAR Dependency:一般用于添加本地已有的jar或aar文件作为依赖时使用



    1-7-2-5


    选择添加模块依赖后,弹出窗体如下图所示:


    1-7-2-6


    在上图中标记1的位置勾选要添加的模块,在2的位置选择依赖方式,随后点击OK等待同步完成即可。




singleton-processor 注解处理模块


与创建singleton-annotation模块一样,以相同的方式创建一个名为singleton-processor的模块,其内部有一个Processor类,创建完成后,项目模块如下图所示(Processor类为创建模块时输入的类名,AS自动生成的):


1-7-2-7


添加注解处理器声明

将Processor.java类作为我们的注解处理器类,为了Android Studio能识别到该类,我们需要对该类进行声明,通常有两种声明方式:




  • 手动声明


    手动声明的主要实现方式是在main目录下创建resources/META-INF/services目录,在该目录下创建javax.annotation.processing.Processor文件,其内容如下所示:


     com.poseidon.singleton_processor.Processor

    可以看到其内部写的是注解处理器类完整路径(包名+类名),当有多个注解处理器类时,可以写多行,每次放置一条注解处理器信息即可




  • 借助AutoService库自动声明


    除了手动声明外,我们可以借助auto-service库进行注解处理器声明,其本身也是依赖注解实现,在singleton-processor模块的build.gradle中添加auto-service库依赖,如下所示:


     dependencies {
         implementation 'com.google.auto.service:auto-service:1.0'
         annotationProcessor 'com.google.auto.service:auto-service:1.0'
     }

    依赖添加完成后,使用@AutoService注解修饰我们的注解处理器类,代码如下:


     @AutoService(Processor.class)
     public class Processor extends AbstractProcessor {
         @Override
         public boolean process(Set<? extends TypeElement> set, RoundEnvironment roundEnvironment) {
             return false;
        }
     }

    随后运行该项目,可以看到在singleton-processor模块的build目录中自动生成了META-INF相关的目录,如下图所示:


    1-7-2-8


    其中javax.annotation.processing.Processor文件内容和我们手动添加时的内容一致。



    当然也可以参考上文在Library Dependency窗口添加auto-service依赖,大家可以自行探索下





依赖singleton-processor模块

与依赖singleton-annotation模块时方法类似,由于singleton-processor模块是注解处理模块,故依赖方式应使用annotationProcessor,在app模块的build.gradle文件的dependencies块中添加代码如下:


 annotationProcessor project(path: ':singleton-processor')

至此我们已经完成了新增模块的依赖以及注解的声明,接下来我们来看看注解处理器的实现。


注解处理器代码实现


在前文中我们已经将singleton-processor模块的Processor类声明为注解处理器,接下来我们来看下如何在注解处理器中处理我们的@Singleton注解,并对使用该注解的单例类完成检查。


自定义注解处理器一般继承自AbstractProcessor,AbstractProcessor是一个抽象类,其父类是Processor,在类编译成.class文件前,遍历整个项目里的所有代码,在获取到对应注解后,回调注解处理器的process方法,以便对注解进行处理。


当继承AbstractProcessor时,我们一般重写下列函数:



























函数名称函数说明
void init(ProcessingEnvironment processingEnv)初始化处理器环境,这里可以缓存处理器环境,在process中发生异常等,可以打断通过缓存的变量打断编译执行
boolean process(Set<? extends TypeElement> set, RoundEnvironment roundEnvironment)处理方法,类或成员等的注释,并返回该处理器的处理结果。 如果返回true ,则表明注解被当前处理器处理,并且不会要求后续处理器继续处理; 如果返回false ,则表示未处理传入的注解,继续传递给后续处理器处理. RoundEnvironment参数用于查找使用了指定注解的元素,这里的元素有多种,方法,成员,类等,和ElementType取值范围一致
Set getSupportedAnnotationTypes()获取注解处理器要处理的注解类型,如果在注解处理器类上使用了@SupportedAnnotationTypes注解修饰,则这里返回的Set应和注解取值一致
SourceVersion getSupportedSourceVersion()注解处理器支持的Java版本,如果在注解处理器类上使用了@SupportedSourceVersion注解修饰,则这里返回的取值应该和注解取值一致

下面我们按照上述描述重写Processor代码如下:


 @AutoService(Processor.class)
 public class Processor extends AbstractProcessor {
     // 注解处理器运行环境
     private ProcessingEnvironment mProcessingEnvironment;
     @Override
     public synchronized void init(ProcessingEnvironment processingEnv) {
         super.init(processingEnv);
         mProcessingEnvironment = processingEnv;
    }
 
     @Override
     public boolean process(Set<? extends TypeElement> set, RoundEnvironment roundEnvironment) {
         return false;
    }
 
     @Override
     public Set<String> getSupportedAnnotationTypes() {
         return super.getSupportedAnnotationTypes();
    }
 
     @Override
     public SourceVersion getSupportedSourceVersion() {
         // 支持到最新的java版本
         return SourceVersion.latestSupported();
    }
 }

由于该处理器主要处理的是@Singleton注解,故getSupportedAnnotationTypes实现如下(singleton-processor模块依赖singleton-annotation模块):


 @Override
 public Set<String> getSupportedAnnotationTypes() {
     HashSet<String> hashSet = new HashSet<>();
     // 添加注解类的完整名称到HashSet中
     hashSet.add(Singleton.class.getCanonicalName());
     return hashSet;
 }

随后我们来看下process函数的实现,process内部逻辑实现一般分为三步:



  1. 获取代码中被使用该注解的所有元素,这里的元素指的是组成程序的元素,可能是程序包,类本身、类的变量、方法等

  2. 筛选符合要求的元素,根据注解的使用场景筛选第一步中得到的所有元素,比如Singleton这个注解作用于类,就从第一步的结果中筛选出所有的类元素

  3. 遍历筛选出的元素,按照预设规则进行检查


按照上述步骤实现的Singleton注解处理器的process函数如下所示:


@Override
public boolean process(Set<? extends TypeElement> set, RoundEnvironment roundEnvironment) {
// 1.通过RoundEnvironment查找所有使用了Singleton注解的Element
// 2.随后通过ElementFilter获取该元素里面的所有类元素
// 3.遍历所有的类元素,针对自己关注的方法字段进行处理
for (TypeElement typeElement: ElementFilter.typesIn(roundEnvironment.getElementsAnnotatedWith(Singleton.class))) {
// 检查构造函数
if (!checkPrivateConstructor(typeElement)) {
return false;
}
// 检查getInstance方法
if (!checkGetInstanceMethod(typeElement)) {
return false;
}
}
return true;
}


ElementFilter.typesIn就是用来筛选查找出来的结果中的类元素,在ElementFilter类内部定义了五个元素组,如下所示:



  • CONSTRUCTOR_KIND:构造器元素组

  • FIELD_KINDS:成员变量元素组

  • METHOD_KIND:方法元素组

  • PACKAGE_KIND:包元素组

  • MODULE_KIND:模块元素组

  • TYPE_KINDS:类元素组


其中类元素组囊括的最多,包括CLASS,ENUM,INTERFACE等



checkPrivateConstructor

public boolean checkPrivateConstructor(TypeElement typeElement) {
// 通过typeElement.getEnclosedElements()获取在此类或接口中直接声明的字段,方法等元素,随后使用ElementFilter.constructorsIn筛选出构造方法
List<ExecutableElement> constructors = ElementFilter.constructorsIn(typeElement.getEnclosedElements());
for (ExecutableElement constructor : constructors) {
// 判断构造方式是否是Private修饰的
if (constructor.getModifiers().isEmpty() || !constructor.getModifiers().contains(Modifier.PRIVATE)) {
mProcessingEnvironment.getMessager().printMessage(Diagnostic.Kind.ERROR, "constructor of a singleton class must be private", constructor);
return false;
}
}
return true;
}

checkPrivateConstructor实现逻辑如上,代码比较简单,不做赘述。


checkGetInstanceMethod

public boolean checkGetInstanceMethod(TypeElement typeElement) {
// 通过ElementFilter.constructorsIn筛选出该类中声明的所有方法
List<ExecutableElement> methods = ElementFilter.methodsIn(typeElement.getEnclosedElements());
for (ExecutableElement method : methods) {
System.out.println(TAG+method.getSimpleName());
// 检查方法名称
if (method.getSimpleName().contentEquals("getInstance")) {
// 检查方法返回类型
if (mProcessingEnvironment.getTypeUtils().isSameType(method.getReturnType(), typeElement.asType())) {
// 检查方法修饰符
if (!method.getModifiers().contains(Modifier.PUBLIC)) {
mProcessingEnvironment.getMessager().printMessage(Diagnostic.Kind.ERROR, "getInstance method should have a public modifier", method);
return false;
}
if (!method.getModifiers().contains(Modifier.STATIC)) {
mProcessingEnvironment.getMessager().printMessage(Diagnostic.Kind.ERROR, "getInstance method should have a static modifier", method);
return false;
}
}
}
}
return true;
}

checkGetInstanceMethod实现逻辑如上,可以看出当不满足我们预设条件时会通过printMessage向外抛出异常,中断编译执行。


使用Singleton注解,查看注解处理器效果


在app模块中添加SingleTest.java并应用注解,代码如下:


@Singleton
public class SingletonTest {
private SingletonTest(){}
private static SingletonTest getInstance(){
return new SingletonTest();
}
}

可以看到该代码存在问题,我们要求getInstance方法要用public static修饰,这里使用的是private,运行程序,看我们的注解处理器是否能发现该问题并打断程序执行,运行结果如下图:


1-7-2-9


可以看到程序确实停止运行,并抛出了编译时异常,至此我们自定义编译时注解的操作就学习完了。


扩展


在注解使用方法一节中,我们提到编译时注解即Retention=RetentionPolicy.SOURCE的注解仅在源码中保留,接下来我们验证一下,反编译前文中通过注解处理器检查正常运行的apk,找到SingletonTest类,可以看到在其字节码文件中确实不存在注解代码,如下图所示:


1-7-2-10


作者:小海编码日记
链接:https://juejin.cn/post/7196977951970918460
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

Flutter混编工程之异常处理

Flutter App层和Framework层的异常,通常是不会引起Crash的,但是Engine层的异常会造成Crash。而Flutter Engine部分的异常,主要是libfutter.so发生的异常,这部分的异常,在Dart层无法捕获,一般会交给类似B...
继续阅读 »

Flutter App层和Framework层的异常,通常是不会引起Crash的,但是Engine层的异常会造成Crash。而Flutter Engine部分的异常,主要是libfutter.so发生的异常,这部分的异常,在Dart层无法捕获,一般会交给类似Bugly这样的平台来收集。


我们能主动监控的,主要是Dart层的异常,这些异常虽然不会让App crash,但是统计这些异常对于提高我们的用户体验,是非常有必要的。


同步异常与异步异常


对于同步异常来说,直接使用try-catch就可以捕获异常,如果要指定捕获的异常类型,可以使用on关键字。但是,try-catch不能捕获异步异常,就像下面的代码,是无法捕获的。


try {
Future.error("error");
} catch (e){
print(e)
}

这和在Java中,try-catch捕获Thread中的异常类似,对于异步异常来说,只能使用Future的catchError或者是onError来捕获异常,代码如下所示。


Future.delayed(Duration(seconds: 1)).then((value) => print(value), onError: (e) {});

Dart的执行队列是一个单线程模型,所以在事件循环队列中,当某个Task发生异常并没有被捕获时,程序并不会退出,只是当前的Task异常中止,也就是说一个Task发生的异常是不会影响其它Task执行的。


Widget Build异常


Widget在Build过程中如果发生异常,例如在build函数中出错(throw exception),我们会看见一个深红色的异常界面,这个就是Flutter自带的异常处理界面,我们来看下源代码中,Flutter对这类异常的处理方式。在ComponentElement的实现中,我们找到performRebuild函数,这个是函数是build时所调用的,我们在这里,可以找到相关的实现。


如下所示,在执行到build()函数如果出错时,就会被catch,从而创建一个ErrorWidget。
image-20220412151724451.png
再进入_debugReportException中一探究竟,你会发现,应用层的异常被catch之后,都是通过FlutterError.reportError来处理的。
image-20220412152002822.png
在reportError中,会调用onError来处理,默认的处理方式是dumpErrorToConsole,它就是onError的默认实现。
image-20220412153627080.png



在这里我们还能发现如何判断debug模式,看源码是不是很有意思。



通过上面的源码,我们就可以了解到,当Flutter应用层崩溃后,SDK的处理,简而言之,就是会构建一个错误界面,同时回调onError函数。在这里,我们可以通过修改这个静态的回调函数,来创建自己的处理方式。
image-20220414145625129.png
所以,很简单,我们只需要在main()中,执行下面的代码即可。


var defaultError = FlutterError.onError;
FlutterError.onError = (FlutterErrorDetails details) {
defaultError?.call(details);// 根据需要是否要保留default处理
reportException(details);
};

defaultError?.call(details)就是默认将异常日志打印到console的方法,如果不用,这里可以去掉。


重写错误界面


前面我们看到了,在源代码中,Flutter自定义了一个ErrorWidget作为默认的异常界面,在平时的开发中,我们可以自定义ErrorWidget.builder,实现一个更友好的错误界面,例如封装一个统一的异常提示界面。


ErrorWidget.builder = (FlutterErrorDetails details) {
return MaterialApp(
theme: ThemeData(primarySwatch: Colors.red),
home: Scaffold(
appBar: AppBar(
title: const Text('出错了,请稍后再试'),
),
body: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Text(details.toString()), // 后续修改为统一的错误页
)),
),
);
};

如上所示,通过修改ErrorWidget.builder,就可以将任意自定义的界面作为异常界面了。


全局未捕获异常


前面讲到的,都是属于被捕获的异常,而有一些异常,在代码中是没有被捕获的,这就类似Android的UncaughtExceptionHandler,Flutter也提供了一个全局的异常处理钩子函数,所有的未捕获异常,无论是同步异常还是异步异常,都会在这里被监听。


在Dart中,SDK提供了一个Zone的概念,一个Zone就类似一个沙箱,在Zone里面,可以拥有独立的异常处理、print函数等等功能,多个Zone之间是彼此独立的,所以,我们只需要将App运行在一个Zone里面,就可以借助它的handleUncaughtError来处理所有的未捕获异常了。下面是使用Zone的一个简单示例。


void main() {
runZoned(
() => runApp(const MyApp(color: Colors.blue)),
zoneSpecification: ZoneSpecification(
handleUncaughtError: (
Zone self,
ZoneDelegate parent,
Zone zone,
Object error,
StackTrace stackTrace,
) {
reportException(
FlutterErrorDetails(
exception: error,
stack: stackTrace,
),
);
},
),
);
}

根据文档中的提升,可以使用runZonedGuarded来进行简化,代码如下所示。


void main() {
runZonedGuarded(
() => runApp(const MyApp(color: Colors.blue)),
(Object error, StackTrace stack) {
reportException(
FlutterErrorDetails(
exception: error,
stack: stack,
),
);
},
);
}

封装


下面我们将前面的异常处理方式都合并到一起,并针对EngineGroup的多入口处理,封装一个类,代码如下所示。


class SafeApp {
run(Widget app) {
ErrorWidget.builder = (FlutterErrorDetails details) {
return MaterialApp(
theme: ThemeData(primarySwatch: Colors.red),
home: Scaffold(
appBar: AppBar(
title: const Text('出错了,请稍后再试'),
),
body: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Text(details.toString()), // 后续修改为统一的错误页
)),
),
);
};
FlutterError.onError = (FlutterErrorDetails details) {
Zone.current.handleUncaughtError(details.exception, details.stack!);
};
runZonedGuarded(
() => runApp(const MyApp(color: Colors.blue)),
(Object error, StackTrace stack) {
reportException(
FlutterErrorDetails(
exception: error,
stack: stack,
),
);
},
);
}
}

在这里,我们构建了下面这些异常处理的方式:



  • 统一的异常处理界面

  • 将Build异常统一转发到Zone中的异常处理函数来进行处理

  • 将所有的未捕获异常记录


这样的话,我们在使用时,只需要对原始的App进行下调用即可。


void main() => SafeApp().run(const MyApp(color: Colors.blue));

这样就完成了异常处理的封装。


上报


在Flutter侧,我们只是获取了异常的相关信息,如果需要上报,那么我们需要借助Channel,桥接的Native,使用Bugly或其它平台进行上报,我们可以借助Pigeon来进行处理,还不熟悉的朋友可以参考我前面的文章。
Flutter混编工程之高速公路Pigeon
Flutter混编工程之通讯之路
通过Channel,我们可以把异常数据报给Native侧,再让Native侧走自己的上报通道,例如Bugly等。


NativeCommonApi().reportException('------Flutter_Exception------\n${details.exceptionAsString()}\n${details.stack.toString()}');

同时,Flutter提供了exceptionAsString()方法,将异常信息展示的更加友好一点,我们可以借助它来做一些格式化的操作。


3.3版本API的改进


官方的API更新如下:
docs.flutter.dev/testing/err…
PlatformDispatcher.onError在以前的版本中,开发者必须手动配置自定义Zone才能捕获应用程序的所有异常和错误,但是自定义Zone对Dart核心库中的一些优化是有害的,这会减慢应用程序的启动时间。「在此版本中,开发者可以通过设置回调来捕获所有错误和异常,而不是使用自定义。」


所以,3.3之后,我们不用再设置Zone来捕获全局异常了,只用设置PlatformDispatcher.instance.onError即可。


import 'package:flutter/material.dart';
import 'dart:ui';

Future<void> main() async {
await myErrorsHandler.initialize();
FlutterError.onError = (details) {
FlutterError.presentError(details);
myErrorsHandler.onErrorDetails(details);
};
PlatformDispatcher.instance.onError = (error, stack) {
myErrorsHandler.onError(error, stack);
return true;
};
runApp(const MyApp());
}

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

@override
Widget build(BuildContext context) {
return MaterialApp(
builder: (context, widget) {
Widget error = const Text('...rendering error...');
if (widget is Scaffold || widget is Navigator) {
error = Scaffold(body: Center(child: error));
}
ErrorWidget.builder = (errorDetails) => error;
if (widget != null) return widget;
throw ('widget is null');
},
);
}
}

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

不就是一个空白页,有必要那么讲究吗?

前言 在各类软件应用中,会经常遇到空页面的情况,比如列表无数据、搜不到相应结果、用户数据没有添加等等。这种空页面看似很少出现,但是如果不注意体验的话,会让用户不快甚至是困惑。今天我们就来讲讲针对各类空页面,如何改善用户体验。 列表无数据 页面无数据通常会在列表...
继续阅读 »

前言


在各类软件应用中,会经常遇到空页面的情况,比如列表无数据、搜不到相应结果、用户数据没有添加等等。这种空页面看似很少出现,但是如果不注意体验的话,会让用户不快甚至是困惑。今天我们就来讲讲针对各类空页面,如何改善用户体验。


列表无数据


页面无数据通常会在列表类页面中出现,最为简单的方式就是用一个空图标+文字说明的方式告诉用户查询的结果为空,比如下面这样。


image.png


这是中规中矩的无数据空页面,遇到过奇葩的情况是直接给一个白屏 —— 你这是告诉用户是数据加载不出来呢还是没数据呢?
相比这种静态的空页面,我们可以使用 Lottie 加载一些带动效的无数据指示,会让用户体验好很多。而需要写的代码其实并没有几行。


empty-gif.gif


Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
title: const Text('抱歉'),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Lottie.asset(
'assets/empty.json',
repeat: true,
width: 200.0,
),
Text(
'暂无数据',
style: TextStyle(
color: Colors.grey[400],
fontSize: 14.0,
),
),
],
),
),
);
}

找不到搜索结果


对于搜索,在没有搜到对应的数内容时,相比给一个空页面,给一些用户感兴趣的相似内容可能会更好,一方面可以让用户浏览替代的内容,另一方面可以在一定程度上提高转化率。典型的例子就是在商品搜索的时候,如果没有找到对应的商品,会推荐系统里相关的商品,比如下面是京东的例子。


image.png


用户数据没有添加


这是需要用户主动提交才会有数据的情况。糟糕的体验是只告诉用户没有数据,而没有引导用户去添加数据。比如我们的地址管理,我们来看到下面两种体验,一对比高下立现。


image.png


guide-empty.gif


第一种一个是添加地址的按钮不够显眼,另外就是在需要用户操作的时候,缺乏引导。这会导致首次使用该功能的用户很迷茫,一时不知道从哪里添加收货地址。相比之下,下面的实现方式按钮位置更明显,而且通过动画能够让用户清楚地知道可以通过点击下面的按钮添加收货地址。


第二种方式实现的代码如下所示,这里的引导动画效果使用了 AnimatedPositioned 组件实现(相关文章可以参考:🚀🚀🚀庆祝神舟十三号发射成功,来一个火箭发射动画)。


Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('收货地址'),
),
body: Stack(
children: [
Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Image.asset('assets/common-empty.png', width: 100.0),
Text(
'暂无收货地址',
style: TextStyle(
color: Colors.grey[400],
fontSize: 16.0,
height: 2,
),
),
],
),
),
AnimatedPositioned(
duration: const Duration(milliseconds: 500),
bottom: _bottom,
height: guideIconSize,
left: MediaQuery.of(context).size.width / 2 - guideIconSize / 2,
onEnd: () {
setState(() {
if (_bottom == minBottom) {
_bottom = maxBottom;
} else {
_bottom = minBottom;
}
});
},
child: Icon(Icons.arrow_downward,
color: Theme.of(context).primaryColor, size: guideIconSize),
)
],
),
bottomSheet: Container(
height: 50.0,
width: MediaQuery.of(context).size.width,
color: Theme.of(context).primaryColor,
margin: const EdgeInsets.all(0.0),
child: TextButton(
onPressed: () {
if (kDebugMode) {
print('跳到添加地址!');
}
},
child: const Text('添加收货地址', style: TextStyle(color: Colors.white)),
),
),
);
}

网络连接问题


网络连接偶尔会出现短时间连接断开导致无法加载后端数据的问题,这个时候可不能直接放个网络错误的指示页面就完了,比如下面这样。
network-error.gif
动画确实改善了用户体验,但没有解决根本问题。我们来看一下我们自己网络连接有问题的时候的处理步骤:



  1. 如果手机的网络连接没问题,我们会希望当前页面能够重新加载;

  2. 如果手机网络有问题,我们可能会切换网络(比如切换到4G 网络),然后还是希望能够重新加载。


这个空页面没有提供重新加载的功能,这意味着用户需要返回到上一个页面,找到之前点击的内容,然后再进入这个页面来达到再次刷新的目的。这额外多了两个步骤,而且还需要用户记住之前点击的内容,体验就不怎么好了。这种情况只需要提供一个重新加载的按钮,体验就会好很多了。


image.png


总结


总结一下,如何提高空页面的用户体验,针对我们提到的4种情况有对应的4个原则:



  1. 对于确实无数据的情况,给出有好的提示,比如实用动画+文字的形式。千万不要认为反正后台有数据,不会出现空页面而什么都不做——结果就是让用户看着白屏一脸懵逼!

  2. 用户输入的搜索词可能会非常长(比如复制京东的商品名称去淘宝搜),很可能搜不到结果。如果可能,建议对于搜索词长的情况能够匹配一些标签,通过标签搜相关的内容推荐给用户,这比搜不到给一个空白页面的体验会好很多,而且海还会促进应用的内容或商品消费。

  3. 对于需要用户执行添加动作才会有的数据(比如收货地址、收藏夹、好友等),要给出合理的引导,让用户能够顺利地完成相应的动作,而不是让用户自己摸索。

  4. 对于因为网络、本机授权等导致出现错误无法加载数据的情况,除了给出友好的提示之外,要同时能够提供类似重新加载的功能,便于用户解决本机问题后,能够回来直接重新加载页面内容。


从心理上来说,人对于空白状态都是有点畏惧的。因此,开发出好的体验的空页面就是需要给用户合理的解释和必要的引导,让空页面不那么空!


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

Android 通过MotionLayot实现点赞动画

在之前的文章Android 一种点赞动画的实现中,通过Animation和Animator实现了一种点赞动画效果,有知友评论用MotionLayout是否会比较简单。我之前还没有使用过MotionLayout,刚好通过实现这个点赞动画来学习一下MotionLa...
继续阅读 »

在之前的文章Android 一种点赞动画的实现中,通过AnimationAnimator实现了一种点赞动画效果,有知友评论用MotionLayout是否会比较简单。我之前还没有使用过MotionLayout,刚好通过实现这个点赞动画来学习一下MotionLayout的使用。


MotionLayout


MotionLayoutConstraintLayout的子类,包含在ConstraintLayout库中,在ConstraintLayout的基础上,增加了管理控件动画的功能。


官方文档


添加库


如果之前没有使用ConstraintLayout,那么需要在app module下的build.gradle中添加代码,如下:


dependencies {
// 项目中使用AndroidX
implementation 'androidx.constraintlayout:constraintlayout:2.0.0-beta1'

// 项目中未使用AndroidX
implementation 'com.android.support.constraint:constraint-layout:2.0.0-beta1'
}

点赞效果的实现


尝试使用MotionLayout来实现之前的点赞动画,最终实现了缩放以及发散效果。


布局中添加MotionLayout


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

<!--根节点改为使用MotionLayout-->
<!--layoutDescription 配置MotionScene配置文件-->
<!--showPaths设置是否显示动画的轨迹-->
<androidx.constraintlayout.motion.widget.MotionLayout
android:id="@+id/motion_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
motion:layoutDescription="@xml/example_motion_scene"
tools:showPaths="true">

<include
android:id="@+id/include_title"
layout="@layout/layout_title" />

<androidx.appcompat.widget.AppCompatImageView
android:id="@+id/iv_thumb_up"
android:layout_width="25dp"
android:layout_height="25dp"
android:layout_marginTop="2dp"
android:src="@drawable/icon_thumb_up"
motion:layout_constraintBottom_toBottomOf="parent"
motion:layout_constraintEnd_toEndOf="parent"
motion:layout_constraintStart_toStartOf="parent"
motion:layout_constraintTop_toTopOf="parent" />

<androidx.appcompat.widget.AppCompatImageView
android:id="@+id/iv_thumb_up1"
android:layout_width="25dp"
android:layout_height="25dp"
android:layout_marginTop="2dp"
android:src="@drawable/icon_thumb_up"
android:visibility="gone"
motion:layout_constraintBottom_toBottomOf="parent"
motion:layout_constraintEnd_toEndOf="parent"
motion:layout_constraintStart_toStartOf="parent"
motion:layout_constraintTop_toTopOf="parent" />

<androidx.appcompat.widget.AppCompatImageView
android:id="@+id/iv_thumb_up2"
android:layout_width="25dp"
android:layout_height="25dp"
android:layout_marginTop="2dp"
android:src="@drawable/icon_thumb_up"
android:visibility="gone"
motion:layout_constraintBottom_toBottomOf="parent"
motion:layout_constraintEnd_toEndOf="parent"
motion:layout_constraintStart_toStartOf="parent"
motion:layout_constraintTop_toTopOf="parent" />

<androidx.appcompat.widget.AppCompatImageView
android:id="@+id/iv_thumb_up3"
android:layout_width="25dp"
android:layout_height="25dp"
android:layout_marginTop="2dp"
android:src="@drawable/icon_thumb_up"
android:visibility="gone"
motion:layout_constraintBottom_toBottomOf="parent"
motion:layout_constraintEnd_toEndOf="parent"
motion:layout_constraintStart_toStartOf="parent"
motion:layout_constraintTop_toTopOf="parent" />

<androidx.appcompat.widget.AppCompatImageView
android:id="@+id/iv_thumb_up4"
android:layout_width="25dp"
android:layout_height="25dp"
android:layout_marginTop="2dp"
android:src="@drawable/icon_thumb_up"
android:visibility="gone"
motion:layout_constraintBottom_toBottomOf="parent"
motion:layout_constraintEnd_toEndOf="parent"
motion:layout_constraintStart_toStartOf="parent"
motion:layout_constraintTop_toTopOf="parent" />

<androidx.appcompat.widget.AppCompatImageView
android:id="@+id/iv_thumb_up5"
android:layout_width="25dp"
android:layout_height="25dp"
android:layout_marginTop="2dp"
android:src="@drawable/icon_thumb_up"
android:visibility="gone"
motion:layout_constraintBottom_toBottomOf="parent"
motion:layout_constraintEnd_toEndOf="parent"
motion:layout_constraintStart_toStartOf="parent"
motion:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.motion.widget.MotionLayout>
</layout>

创建MotionScene配置文件


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

<!--配置动画的属性-->
<!--duration 配置动画的持续时间-->
<!--constraintSetStart 配置动画开始时,控件集的状态-->
<!--constraintSetEnd 配置动画结束时,控件集的状态-->
<!--motionInterpolator 配置动画的插值器,-->
<Transition
android:id="@+id/transition_thumb"
android:duration="1500"
motion:constraintSetEnd="@id/thumb_end"
motion:constraintSetStart="@id/thumb_start"
motion:motionInterpolator="linear">

<!--点击时触发动画-->
<!--targetId 配置触发事件的控件id-->
<!--clickAction 配置点击触发的效果-->
<!--clickAction toggle 当前控件集为开始状态,则播放动画切换至结束状态,反之亦然-->
<!--clickAction transitionToEnd 播放控件集开始到结束的动画-->
<!--clickAction transitionToStart 播放控件集结束到开始的动画-->
<!--clickAction jumpToEnd 不播放动画,控件集直接切换至结束状态-->
<!--clickAction jumpToStart 不播放动画,控件集直接切换至开始状态-->
<OnClick
motion:clickAction="transitionToEnd"
motion:targetId="@id/iv_thumb_up" />

<!--关键帧集合,用于实现缩放效果-->
<KeyFrameSet>

<!--修改属性-->
<!--framePosition 取值范围为0-100-->
<!--motionTarget 设置修改的对象-->
<!--scaleX 设置x轴缩放大小-->
<!--scaleY 设置y轴缩放大小-->
<KeyAttribute
android:scaleX="1.6"
android:scaleY="1.6"
motion:framePosition="25"
motion:motionTarget="@id/iv_thumb_up" />

<KeyAttribute
android:scaleX="1"
android:scaleY="1"
motion:framePosition="50"
motion:motionTarget="@id/iv_thumb_up" />
</KeyFrameSet>
</Transition>

<!--控件集 动画开始时的状态-->
<ConstraintSet android:id="@+id/thumb_start">

<!--与layout文件中的控件对应-->
<!--visibilityMode 如果需要改变控件的可见性,需要将此字段配置为ignore-->
<Constraint
android:id="@+id/iv_thumb_up"
android:layout_width="25dp"
android:layout_height="25dp"
android:layout_marginTop="2dp"
motion:layout_constraintBottom_toBottomOf="parent"
motion:layout_constraintEnd_toEndOf="parent"
motion:layout_constraintStart_toStartOf="parent"
motion:layout_constraintTop_toTopOf="parent"
motion:visibilityMode="ignore" />

<Constraint
android:id="@+id/iv_thumb_up1"
android:layout_width="25dp"
android:layout_height="25dp"
android:layout_marginTop="2dp"
motion:layout_constraintBottom_toBottomOf="parent"
motion:layout_constraintEnd_toEndOf="parent"
motion:layout_constraintStart_toStartOf="parent"
motion:layout_constraintTop_toTopOf="parent"
motion:visibilityMode="ignore">

<!--改变控件的属性-->
<!--attributeName 属性名-->
<!--customFloatValue Float类型属性值-->
<CustomAttribute
motion:attributeName="alpha"
motion:customFloatValue="1" />
</Constraint>

<Constraint
android:id="@+id/iv_thumb_up2"
android:layout_width="25dp"
android:layout_height="25dp"
android:layout_marginTop="2dp"
motion:layout_constraintBottom_toBottomOf="parent"
motion:layout_constraintEnd_toEndOf="parent"
motion:layout_constraintStart_toStartOf="parent"
motion:layout_constraintTop_toTopOf="parent"
motion:visibilityMode="ignore">

<CustomAttribute
motion:attributeName="alpha"
motion:customFloatValue="1" />
</Constraint>

<Constraint
android:id="@+id/iv_thumb_up3"
android:layout_width="25dp"
android:layout_height="25dp"
android:layout_marginTop="2dp"
motion:layout_constraintBottom_toBottomOf="parent"
motion:layout_constraintEnd_toEndOf="parent"
motion:layout_constraintStart_toStartOf="parent"
motion:layout_constraintTop_toTopOf="parent"
motion:visibilityMode="ignore">

<CustomAttribute
motion:attributeName="alpha"
motion:customFloatValue="1" />
</Constraint>

<Constraint
android:id="@+id/iv_thumb_up4"
android:layout_width="25dp"
android:layout_height="25dp"
android:layout_marginTop="2dp"
motion:layout_constraintBottom_toBottomOf="parent"
motion:layout_constraintEnd_toEndOf="parent"
motion:layout_constraintStart_toStartOf="parent"
motion:layout_constraintTop_toTopOf="parent"
motion:visibilityMode="ignore">

<CustomAttribute
motion:attributeName="alpha"
motion:customFloatValue="1" />
</Constraint>

<Constraint
android:id="@+id/iv_thumb_up5"
android:layout_width="25dp"
android:layout_height="25dp"
android:layout_marginTop="2dp"
motion:layout_constraintBottom_toBottomOf="parent"
motion:layout_constraintEnd_toEndOf="parent"
motion:layout_constraintStart_toStartOf="parent"
motion:layout_constraintTop_toTopOf="parent"
motion:visibilityMode="ignore">

<CustomAttribute
motion:attributeName="alpha"
motion:customFloatValue="1" />
</Constraint>
</ConstraintSet>

<!--控件集 动画结束时的状态-->
<ConstraintSet android:id="@+id/thumb_end">

<Constraint
android:id="@+id/iv_thumb_up"
android:layout_width="25dp"
android:layout_height="25dp"
android:layout_marginTop="2dp"
motion:layout_constraintBottom_toBottomOf="parent"
motion:layout_constraintEnd_toEndOf="parent"
motion:layout_constraintStart_toStartOf="parent"
motion:layout_constraintTop_toTopOf="parent"
motion:visibilityMode="ignore" />

<Constraint
android:id="@+id/iv_thumb_up1"
android:layout_width="25dp"
android:layout_height="25dp"
android:layout_marginTop="110dp"
android:layout_marginEnd="90dp"
motion:layout_constraintBottom_toBottomOf="parent"
motion:layout_constraintEnd_toEndOf="parent"
motion:layout_constraintStart_toStartOf="parent"
motion:layout_constraintTop_toTopOf="parent"
motion:visibilityMode="ignore">

<CustomAttribute
motion:attributeName="alpha"
motion:customFloatValue="0.5" />
</Constraint>

<Constraint
android:id="@+id/iv_thumb_up2"
android:layout_width="25dp"
android:layout_height="25dp"
android:layout_marginStart="70dp"
android:layout_marginTop="95dp"
motion:layout_constraintBottom_toBottomOf="parent"
motion:layout_constraintEnd_toEndOf="parent"
motion:layout_constraintStart_toStartOf="parent"
motion:layout_constraintTop_toTopOf="parent"
motion:visibilityMode="ignore">

<CustomAttribute
motion:attributeName="alpha"
motion:customFloatValue="0.4" />
</Constraint>

<Constraint
android:id="@+id/iv_thumb_up3"
android:layout_width="25dp"
android:layout_height="25dp"
android:layout_marginEnd="85dp"
android:layout_marginBottom="140dp"
motion:layout_constraintBottom_toBottomOf="parent"
motion:layout_constraintEnd_toEndOf="parent"
motion:layout_constraintStart_toStartOf="parent"
motion:layout_constraintTop_toTopOf="parent"
motion:visibilityMode="ignore">

<CustomAttribute
motion:attributeName="alpha"
motion:customFloatValue="0.6" />
</Constraint>

<Constraint
android:id="@+id/iv_thumb_up4"
android:layout_width="25dp"
android:layout_height="25dp"
android:layout_marginStart="60dp"
android:layout_marginBottom="120dp"
motion:layout_constraintBottom_toBottomOf="parent"
motion:layout_constraintEnd_toEndOf="parent"
motion:layout_constraintStart_toStartOf="parent"
motion:layout_constraintTop_toTopOf="parent"
motion:visibilityMode="ignore">

<CustomAttribute
motion:attributeName="alpha"
motion:customFloatValue="0.2" />
</Constraint>

<Constraint
android:id="@+id/iv_thumb_up5"
android:layout_width="25dp"
android:layout_height="25dp"
android:layout_marginEnd="20dp"
android:layout_marginBottom="60dp"
motion:layout_constraintBottom_toBottomOf="parent"
motion:layout_constraintEnd_toEndOf="parent"
motion:layout_constraintStart_toStartOf="parent"
motion:layout_constraintTop_toTopOf="parent"
motion:visibilityMode="ignore">

<CustomAttribute
motion:attributeName="alpha"
motion:customFloatValue="0" />
</Constraint>
</ConstraintSet>
</MotionScene>

监听动画状态


class MotionLayoutExampleActivity : AppCompatActivity() {

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val binding: LayoutMotionLayoutExampleActivityBinding = DataBindingUtil.setContentView(this, R.layout.layout_motion_layout_example_activity)
binding.motionLayout.setTransitionListener(object : MotionLayout.TransitionListener {
override fun onTransitionStarted(motionLayout: MotionLayout?, startId: Int, endId: Int) {
// 动画开始
// 把发散的按钮显示出来
binding.ivThumbUp1.visibility = View.VISIBLE
binding.ivThumbUp2.visibility = View.VISIBLE
binding.ivThumbUp3.visibility = View.VISIBLE
binding.ivThumbUp4.visibility = View.VISIBLE
binding.ivThumbUp5.visibility = View.VISIBLE
}

override fun onTransitionChange(motionLayout: MotionLayout?, startId: Int, endId: Int, progress: Float) {
// 动画进行中
}

override fun onTransitionCompleted(motionLayout: MotionLayout?, currentId: Int) {
// 动画完成
// 隐藏发散的按钮,将状态还原
binding.root.postDelayed({
binding.ivThumbUp1.visibility = View.GONE
binding.ivThumbUp2.visibility = View.GONE
binding.ivThumbUp3.visibility = View.GONE
binding.ivThumbUp4.visibility = View.GONE
binding.ivThumbUp5.visibility = View.GONE
binding.motionLayout.progress = 0f
}, 200)
}

override fun onTransitionTrigger(motionLayout: MotionLayout?, triggerId: Int, positive: Boolean, progress: Float) {

}
})
}
}

示例


已整合到demo中。


ExampleDemo github


ExampleDemo gitee


效果如图:


device-2023-02-04-103100.gif

大致还原了之前的动画效果,MotionLayout实现起来确实不复杂,但是目前还没有找到如何设置动画开始前的延时,因此点击完之后按钮的缩放效果与发散效果之间的间隔、发散出去的按钮之间的间隔无法完全复原。


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

Flutter 小技巧之 3.7 性能优化 background isolate

Flutter 3.7 的 background isolate 绝对是一大惊喜,尽管它在 release note 里被一笔带过 ,但是某种程度上它可以说是 3.7 里最实用的存在:因为使用简单,提升又直观。 Background isolate YYDS...
继续阅读 »

Flutter 3.7 的 background isolate 绝对是一大惊喜,尽管它在 release note 里被一笔带过 ,但是某种程度上它可以说是 3.7 里最实用的存在:因为使用简单,提升又直观



Background isolate YYDS



前言


我们知道 Dart 里可以通过新建 isolate 来执行”真“异步任务,而本身我们的 Dart 代码也是运行在一个独立的 isolate 里(简称 root isolate),而 isolate 之间不共享内存,只能通过消息传递在 isolates 之间交换状态。



所以 Dart 里不像 Java 一样需要线程锁。



而在 Dart 2.15 里新增了 isolate groups 的概念,isolate groups 中的 isolate 共享程序里的各种内部数据结构,也就是虽然 isolate groups 还是不允许 isolate 之间共享可变对象,但 groups 可以通过共享堆来实现结构共享,例如:



Dart 2.15 后可以将对象直接从一个 isolate 传递到另一 isolate,而在此之前只支持基础数据类型。



那么如果使用场景来到 Flutter Plugin ,在 Flutter 3.7 之前,我们只能从 root isolate 去调用 Platform Channels ,如果你尝试从其他 isolate 去调用 Platform Channels ,就会收获这样的错误警告:




例如,在 Flutter 3.7 之前,Platform Channels 是和 _DefaultBinaryMessenger 这个全局对象进行通信,但是一但切换了 isolate ,它就会变为 null ,因为 isolate 之间不共享内存。



而从 Flutter 3.7 开始,简单地说,Flutter 会通过新增的 BinaryMessenger 来实现非 root isolate 也可以和 Platform Channels 直接通信,例如:



我们可以在全新的 isolate 里,通过 Platform Channels 获取到平台上的原始图片后,在这个独立的 isolate 进行一些数据处理,然后再把数据返回给 root isolate ,这样数据处理逻辑既可以实现跨平台通用,又不会卡顿 root isolate 的运行。



Background isolate


现在 Flutter 在 Flutter 3.7 里引入了 RootIsolateTokenBackgroundIsolateBinaryMessenger 两个对象,当 background isolate 调用 Platform Channels 时, background isolate 需要和 root isolate 建立关联,所以在 API 使用上大概会是如下代码所示:


RootIsolateToken rootIsolateToken =
RootIsolateToken.instance!;

Isolate.spawn((rootIsolateToken) {
doFind2(rootIsolateToken);
}, rootIsolateToken);

doFind2(RootIsolateToken rootIsolateToken) {
// Register the background isolate with the root isolate.
BackgroundIsolateBinaryMessenger
.ensureInitialized(rootIsolateToken);
//......
}

通过 RootIsolateToken 的单例,我们可以获取到当前 root isolate 的 Token ,然后在调用 Platform Channels 之前通过 ensureInitialized 将 background isolate 需要和 root isolate 建立关联。



大概就是 token 会被注册到 DartPluginRegistrant 里,然后 BinaryMessenger_findBinaryMessenger 时会通过 BackgroundIsolateBinaryMessenger.instance 发送到对应的 listener



完整代码如下所示,逻辑也很简单,就是在 root isolate 里获取 RootIsolateToken ,然后在调用 Platform Channels 之前 ensureInitialized 关联 Token 。


 InkWell(
onTap: () {
///获取 Token
RootIsolateToken rootIsolateToken =
RootIsolateToken.instance!;
Isolate.spawn(doFind, rootIsolateToken);
},

////////////////

doFind(rootIsolateToken) async {
/// 注册 root isolaote
BackgroundIsolateBinaryMessenger.ensureInitialized(rootIsolateToken);

///获取 sharedPreferencesSet 的 isDebug 标识位
final Future<void> sharedPreferencesSet = SharedPreferences.getInstance()
.then((sharedPreferences) => sharedPreferences.setBool('isDebug', true));
/// 获取本地目录
final Future<Directory> tempDirFuture = path_provider.getTemporaryDirectory();

/// 合并执行
var values = await Future.wait([sharedPreferencesSet, tempDirFuture]);

final Directory? tempDir = values[1] as Directory?;
final String dbPath = path.join(tempDir!.path, 'database.db');
File file = File(dbPath);
if (file.existsSync()) {
///读取文件
RandomAccessFile reader = file.openSync();
List<int> buffer = List.filled(256, 0);
while (reader.readIntoSync(buffer) == 256) {
List<int> foo = buffer.takeWhile((value) => value != 0).toList();
///读取结果
String string = utf8.decode(foo);
print("######### $string");
}
reader.closeSync();
}
}


这里之所以可以在 isolate 里直接传递 RootIsolateToken ,就是得益于前面所说的 Dart 2.15 的 isolate groups



其实入下代码所示,上面的实现换成 compute 也可以正常执行,当然,如果是 compute 的话,有一些比较特殊情况需要注意


RootIsolateToken rootIsolateToken =    RootIsolateToken.instance!;
compute(doFind, rootIsolateToken);

如下代码所示, doFind2 方法在 doFind 的基础上,将 Future.waitawait 修改为 .then 去执行,如果这时候你再调用 spawncompute ,你就会发现 spawn 下代码依然可以正常执行,但是 compute 却不再正常执行


onTap: () {
RootIsolateToken rootIsolateToken =
RootIsolateToken.instance!;
compute(doFind2, rootIsolateToken);
},

onTap: () {
RootIsolateToken rootIsolateToken =
RootIsolateToken.instance!;
Isolate.spawn(doFind2, rootIsolateToken);
},


doFind2(rootIsolateToken) async {
/// 注册 root isolaote
BackgroundIsolateBinaryMessenger.ensureInitialized(rootIsolateToken);

///获取 sharedPreferencesSet 的 isDebug 标识位
final Future<void> sharedPreferencesSet = SharedPreferences.getInstance()
.then((sharedPreferences) => sharedPreferences.setBool('isDebug', true));

/// 获取本地目录
final Future<Directory> tempDirFuture = path_provider.getTemporaryDirectory();


///////////////////// Change Here //////////////////
/// 合并执行
Future.wait([sharedPreferencesSet, tempDirFuture]).then((values) {
final Directory? tempDir = values[1] as Directory?;
final String dbPath = path.join(tempDir!.path, 'database.db');
///读取文件
File file = File(dbPath);
if (file.existsSync()) {
RandomAccessFile reader = file.openSync();
List<int> buffer = List.filled(256, 0);
while (reader.readIntoSync(buffer) == 256) {
List<int> foo = buffer.takeWhile((value) => value != 0).toList();
String string = utf8.decode(foo);
print("######### $string");
}
reader.closeSync();
}
}).catchError((e) {
print(e);
});
}

为什么会这样?compute 不就是 Flutter 针对 Isolate.spawn 的简易封装吗?



其实原因就在这个封装上,compute 现在不是直接执行 Isolate.spawn 代码,而是执行 Isolate.run ,而 Isolate.run 针对 Isolate.spawn 做了一些特殊封装。



compute 内部会将执行对象封装成 _RemoteRunner 再交给 Isolate.spawn 执行,而 _RemoteRunner 在执行时,会在最后强制调用 Isolate.exit ,这就会导致前面的 Future.wait 还没执行,而 Isolate 就退出了,从而导致代码无效的原因。




另外在 Flutter 3.7 上 ,如果 background isolate 调用 Platform Channels 没有关联 root isolate,也能看到错误提示你初始化关联,所以这也是为什么我说它使用起来很简单的原因。



除此之外,最近刚好遇到有“机智”的小伙伴说 background isolate 无法正常调用,看了下代码是把 RootIsolateToken.instance!; 写到了 background isolate 执行的方法里。




你猜如果这样有效,为什么官方不直接把这个获取写死在 framewok?



其实这也是 isolates 经常引起歧义的原因,isolates 是隔离,内存不共享数据,所以 root isolate 里的 RootIsolateToken 在 background isolate 里直接获肯定是 null ,所以这也是 isolate 使用时需要格外注意的一些小细节。



另外还有如 #36983 等问题,也推动了前面所说的 compute 相关的更改。



最后,如果需要一个完整 Demo 的话,可以参考官方的 background_isolate_channels ,项目里主要通过 SimpleDatabase_SimpleDatabaseServer 的交互,来模拟展示 root isolate 和 background isolate 的调用实现。


最后


总的来说 background isolate 并不难理解,自从 2018 年在 issue #13937 被提出之后就饱受关注,甚至官方还建议过大家通过 ffi 另辟蹊径去实现,当时的 issue 也被搭上了 P5 的 Tag。



相信大家都知道 P5 意味着什么。



所以 background isolate 能在 Flutter 3.7 看到是相当难得的,当然这也离不开 Dart 的日益成熟的支持,同时 background isolate 也给我们带来了更多的可能性,其中最直观就是性能优化上多了新的可能,代码写起来也变得更顺畅。


期待 Flutter 和 Dart 在后续的版本中还能给我们带来更多的惊喜。


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

Android浅谈Webview的Loading

前言 在开发webview的loading效果的时候会有一些问题,这边记录一些碰到的常见的问题,并且设计出一套Loading的方案来解决相关的问题。 1. loading的选择 开发loading效果的原因在于webview加载页面的时候,有时候会耗时,导致不...
继续阅读 »

前言


在开发webview的loading效果的时候会有一些问题,这边记录一些碰到的常见的问题,并且设计出一套Loading的方案来解决相关的问题。


1. loading的选择


开发loading效果的原因在于webview加载页面的时候,有时候会耗时,导致不显示内容又没有任何提示,效果不太好,所以需要在webview使用的地方加上loading的效果,其实更好的体验是还要加上EmptyView,我这边主要就以loadingView来举例。


那开发这loading基本有两种方式,一种是使用window,也就是Dialog这些弹窗的方式,在加载时弹出弹窗,在加载结束后关闭弹窗,有些人可能会封装好一些loading弹窗,然后在这里复用。

这个方法的好处是如果你封装好了,能直接复用,省去很多代码。缺点也很明显,弹窗弹出的时候是否处于一个不允许交互的情况,如果这个流程有问题,那便一直无法和页面做交互


另一种方法是直接在webview的上层覆盖一个LoadingView,webview是继承FrameLayout,就是也可以直接addView。

这个方法的好处就是不会出现上面的问题,因为我webview所在的页面关闭了,它的loading也会跟着一起消失,而且显示的效果会好一些。缺点就是可能一些特殊的webview你会单独做操作,导致会多写一些代码


没有说哪种方法是实现会比较好,主要看使用的场景和具体的需求。


2. loading显示时机的问题


我们做loading的思路就是加载开始的时候显示,加载完成之后关闭,那选择这个开始的时机和结束的时机就比较重要了。


大多数人都会直接使用WebViewClient的onPageStarted回调作为开始时机,把onPageFinished的回调,觉得直接这样写就行了,无所谓,反正webview会出手。


这个思路确实能在正常的情况下显示正常,但是在弱网情况下呢?复杂的网络环境下呢?有些人可能也会碰到一些这样的情况,loading的show写在onPageStarted中,加载时会先白屏一下,才开始显示loading,但是这个白屏的时间很短,所以觉得无所谓。但有没有想过这在正常网络环境下的白屏一下放到复杂的有问题的网络环境中会被放大成什么样。


这个加载过程其实大体分为两个阶段,从loadurl到WebViewClient的onPageStarted和从WebViewClient的从onPageStarted到onPageFinished


所以我的做法是在loadurl的时候去start loading,而不是WebViewClient的onPageStarted回调的时候。


这个是开始的时机,那结束的时机会不会有问题,还真可能有,有时候你会发现一种现象,加载完之后,你的H5内容和loading会同时显示一段时间,才关闭loading(几年前有碰到过,写这篇文章的时候测试没有复现过,不知道是不是版本更新修复了这个问题)


那如果碰到这个问题该怎么解决呢?碰到这个问题,说明onPageFinished的回调时机在页面加载完之后,所以不可信。我们知道除了这个方法之外,BaseWebChromeClient也有个方法onProgressChanged表示加载的进度,当然这个进度你拿去判断也会有问题,因为它并不会每次都会回调100给你,可能有时候给你96,就没了。

我以前的做法是双重判断,判断是进度先返回>85还是onPageFinished先调用,只要有一个调用,我都会关闭loading


3. 体验优化


当然处理好显示的关闭的时机还不行,想想如果在loadurl中show loading会怎样,没错,就算网速快的情况,页面让loading一闪而过,那这样所造成的体验就很不好,所以我们需要做一个延迟显示,我个人习惯是延迟0.5秒。当然延迟显示也会有延迟显示的问题,比如延迟到0.3秒的时候你关闭页面怎么办,再0.2秒之后我总不不能让它显示吧。


说了显示,再说关闭。无论是onPageFinished方法还是onProgressChanged,你能保证它一定会有回调吗?这些代码都不是可控的,里面会不会出现既没抛异常,也没给回调的情况。也许有人说不会的,我都用了这么多年了,没出现过这种问题,但是既然不是我们可控的代码,加一层保险总没错吧。

其实这也简单,定一个timeout的逻辑就行,我个人是定义10秒超时时间,如果10秒后没有关闭loading,我就手动关闭并显示emptyview的error页面。这个超时时间还是比较实用,最上面说了loading的选择,如果你的loading做成view,那即便没有这个逻辑也影响不大,最多就会菊花一直转,但如果你是window做的,没有超时的处理,又没有回调,那你的window会一直显示卡住页面。


4. loading最终设计效果


基于上面的情况,我写个Demo,首先loading的选择,我选择基于view,所以要写个自定义View


public class WebLoadingView extends RelativeLayout {

private Context mContext;
// 0:正常状态;1:loading状态;2:显示loadingview状态
private AtomicInteger state;
private Handler lazyHandler;
private Handler timeOutHandler;

public BaseWebLoadingView(Context context) {
super(context);
init(context);
}

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

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

private void init(Context context) {
this.mContext = context;
state = new AtomicInteger(0);
lazyHandler = new Handler(Looper.getMainLooper());
timeOutHandler = new Handler(Looper.getMainLooper());
initView();
}

private void initView() {
LayoutInflater.from(mContext).inflate(R.layout.demo_loading, this, true);
}

public void show() {
if (state.compareAndSet(0, 1)) {
lazyHandler.postDelayed(new Runnable() {
@Override
public void run() {
if (state.compareAndSet(1, 2)) {
setVisibility(View.VISIBLE);
}
}
}, 500);

timeOutHandler.postDelayed(new Runnable() {
@Override
public void run() {
close();
}
}, 10000);
}
}

public void close() {
state.set(0);
setVisibility(View.GONE);

try {
lazyHandler.removeCallbacksAndMessages(null);
timeOutHandler.removeCallbacksAndMessages(null);
} catch (Exception e) {
e.printStackTrace();
}
}

}

代码应该都比较好理解,就不过多介绍了,然后在自定义webview的loadurl里面展示


@Override
public void loadUrl(String url) {
if (webLoadingView != null && !TextUtils.isEmpty(url) && url.startsWith("http")) {
webLoadingView.show();
}
super.loadUrl(url);
}

写这里主要是有个地方要注意,就是调方法时也会执行这个loadUrl,所以要判断是加载网页的时候才显示loading。


5. 总结


总结几个重点吧,第一个是对第三方的东西(webview这个也类似第三方吧,坑真的很多),我们没办法把控它的流程,或者说没办法把控它的生命周期,所以要封装一套流程逻辑去给调用端方便去使用。

第二个问题是版本的问题,也许会出现不同的版本所体现的效果不同,这个是需要留意的。


如果要完美解决这堆loading相关的问题,最好的方法就是看源码,你知道它里面是怎么实现的,为什么会出现onPageStarted之前还会有一段间隔时间,那就去看loadUrl和onPageStarted回调之间的源码,看它做了什么操作嘛。我个人是没看源码,所以这里只能说是浅谈。


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

首页弹框太多?Flow帮你“链”起来

很多App一打开,首页都会有各种各样的交互,比如权限授权,版本更新,阅读协议,活动介绍,用户权限变更等,这些交互大多数都是以弹框为主,也会有少数几个是以页面或者别的形式出现,但是无论是弹框还是页面,这些只是表现形式,这种交互难点在于 如何去判断它们什么时候出...
继续阅读 »

很多App一打开,首页都会有各种各样的交互,比如权限授权,版本更新,阅读协议,活动介绍,用户权限变更等,这些交互大多数都是以弹框为主,也会有少数几个是以页面或者别的形式出现,但是无论是弹框还是页面,这些只是表现形式,这种交互难点在于



  1. 如何去判断它们什么时候出来

  2. 它们出来的先后次序是什么

  3. 中途需求如果增加或者删除一个弹框或者页面,我们应该改动哪些逻辑


常见的做法


可能这种需求刚开始由于弹框少,交互还简单,所以大多数的做法就是直接在首页用if-else去完成了


if(条件1){
//弹框1
}else if(条件2){
//弹框2
}

但是当需求慢慢迭代下去,首页弹框越来越多,判断的逻辑也越来越复杂,判断条件之间还存在依赖关系的时候,我们的代码就变得很可怕了


if(条件1 && 条件2 && 条件3){
//弹框1
}else if(条件1 && (条件2 || 条件3)){
//弹框2
}else if(条件2 && 条件3){
//弹框3
}else if(....){
....
}

这种情况下,这些代码就变的越来越难维护,长久下来,造成的问题也越来越多,比如



  1. 代码可读性变差,不是熟悉业务的人无法去理解这些逻辑代码

  2. 增加或者减少弹框或者条件需要更改中间的逻辑,容易产生bug

  3. 每个分支的弹框结束后,需要重新从第一个if再执行一遍判断下一个弹框是哪一个,如果条件里面牵扯到io操作,也会产生一定的性能问题


设计思路


能否让每个弹框作为一个单独的任务,生成一条任务链,链上的节点为单个任务,节点维护任务执行的条件以及任务本身逻辑,节点之间无任何依赖关系,具体执行由任务链去管理,这样的话如果增加或者删除某一个任务,我们只需要插拔任务节点就可以


az1.png


定义任务


首先我们先简单定一个任务,以及需要执行的操作


interface SingleJob {
fun handle(): Boolean
fun launch(context: Context, callback: () -> Unit)
}


  • handle():判断任务是否应该执行的条件

  • launch():执行任务,并在任务结束后通过callback通知任务链执行下一条任务


实现任务


定义一个TaskJobOne,让它去实现SingleJob


class TaskJobOne : SingleJob {
override fun handle(): Boolean {
println("start handle job one")
return true
}
override fun launch(context: Context, callback: () -> Unit) {
println("start launch job one")
AlertDialog.Builder(context).setMessage("这是第一个弹框")
.setPositiveButton("ok") {x,y->
callback()
}.show()
}
}

这个任务里面,我们先默认handle的执行条件是true,一定会执行,实际开发过程中可以根据需要来定义条件,比如判断登录态等,lanuch里面我们简单的弹一个框,然后在弹框的关闭的时候,callback给任务链,为了调试的时候看的清楚一些,在这两个函数入口分别打印了日志,同样的任务我们再创建一个TaskJobTwo,TaskJobThree,具体实现差不多,就不贴代码了


任务链


首先思考下如何存放任务,由于任务之间需要体现出优先级关系,所以这里决定使用一个LinkedHashMap,K表示优先级,V表示任务


object JobTaskManager {
val jobMap = linkedMapOf(
1 to TaskJobOne(),
2 to TaskJobTwo(),
3 to TaskJobThree()
)
}

接着就是思考如何设计整条任务链的执行任务,因为这个是对jobMap里面的任务逐个拿出来执行的过程,所以我们很容易就想到可以用Flow去控制这些任务,但是有两个问题需要去考虑下



  1. 如果直接将jobMap转成Flow去执行,那么出现的问题就是所有任务全部都一次性执行完,显然不符合设计初衷

  2. 我们都知道Flow是由上游发送数据,下游接收并处理数据的一个自上而下的过程,但是这里我们需要一个job执行完以后,通过callback通知任务链去执行下一个任务,任务的发送是由前一个任务控制的,所以必须设计出一个环形的过程


首先我们需要一个变量去保存当前需要执行的任务优先级,我们定义它为curLevel,并设置初始值为1,表示第一个执行的是优先级为1的任务


var curLevel = 1

这个变量将会在任务执行完以后,通过callback回调以后再自增,至于自增之后如何再去执行下一条任务,这个通知的事情我们交给StateFlow


val stateFlow = MutableStateFlow(curLevel)
fun doJob(context: Context, job: SingleJob) {
if (job.handle()) {
job.launch(context) {
curLevel++
if (curLevel <= jobMap.size)
stateFlow.value = curLevel
}
} else {
curLevel++
if (curLevel <= jobMap.size)
stateFlow.value = curLevel
}
}

stateFlow初始值是curlevel,当上层开始订阅的时候,不给stateFlow设置value,那么stateFlow初始值1就会发送出去,开始执行优先级为1的任务,在doJob里面,当任务的执行条件不满足或者任务已经执行完成,就自增curLevel,再给stateFlow赋值,从而执行下一个任务,这样一个环形过程就有了,下面是在上层如何执行任务链


MainScope().launch {
JobTaskManager.apply {
stateFlow.collect {
flow {
emit(jobMap[it])
}.collect {
doJob(this@MainActivity, it!!)
}
}
}
}

我们的任务链就完成了,看下效果


a1111.gif


通过日志我们可以看到,的确是每次关闭一个弹框,才开始执行下一条任务,这样一来,如果某个任务的条件不满足,或者不想让它执行了,只需要改变对应job的handle条件就可以,比如现在把TaskJobOne的handel设置为false,在看下效果


class TaskJobOne : SingleJob {
override fun handle(): Boolean {
println("start handle job one")
return false
}
override fun launch(context: Context, callback: () -> Unit) {
println("start launch job one")
AlertDialog.Builder(context).setMessage("这是第一个弹框")
.setPositiveButton("ok") {x,y->
callback()
}.show()
}
}

a2222.gif


可以看到经过第一个task的时候,由于已经把handle条件设置成false了,所以直接跳过,执行下一个任务了


依赖于外界因素


上面只是简单的模拟了一个任务链的工作流程,实际开发过程中,我们有的任务会依赖于其他因素,最常见的就是必须等到某个接口返回数据以后才去执行,所以这个时候,执行你的任务需要判断的东西就更多了



  • 是否优先级已经轮到它了

  • 是否依赖于某个接口

  • 这个接口是否已经成功返回数据了

  • 接口数据是否需要传递给这个任务
    鉴于这些,我们就要重新设计我们的任务与任务链,首先要定义几个状态值,分别代表任务的不同状态


const val JOB_NOT_AVAILABLE = 100
const val JOB_AVAILABLE = 101
const val JOB_COMBINED_BY_NOTHING = 102
const val JOB_CANCELED = 103


  • JOB_NOT_AVAILABLE:该任务还没有达到执行条件

  • JOB_AVAILABLE:该任务达到了执行任务的条件

  • JOB_COMBINED_BY_NOTHING:该任务不关联任务条件,可直接执行

  • JOB_CANCELED:该任务不能执行


接着需要去扩展一下SingleJob的功能,让它可以设置状态,获取状态,并且可以传入数据


interface SingleJob {
......
/**
* 获取执行状态
*/
fun status():Int

/**
* 设置执行状态
*/
fun setStatus(level:Int)

/**
* 设置数据
*/
fun setBundle(bundle: Bundle)
}

更改一下任务的实现


class TaskJobOne : SingleJob {
var flag = JOB_NOT_AVAILABLE
var data: Bundle? = null
override fun handle(): Boolean {
println("start handle job one")
return flag != JOB_CANCELED
}
override fun launch(context: Context, callback: () -> Unit) {
println("start launch job one")
val type = data?.getString("dialog_type")
AlertDialog.Builder(context).setMessage(if(type != null)"这是第一个${type}弹框" else "这是第一个弹框")
.setPositiveButton("ok") {x,y->
callback()
}.show()
}
override fun setStatus(level: Int) {
if(flag != JOB_COMBINED_BY_NOTHING)
this.flag = level
}
override fun status(): Int = flag

override fun setBundle(bundle: Bundle) {
this.data = bundle
}
}

现在的任务执行条件已经变了,变成了状态不是JOB_CANCELED的任务才可以执行,增加了一个变量flag表示这个任务的当前状态,如果是JOB_COMBINED_BY_NOTHING表示不依赖外界因素,外界也不能改变它的状态,其余状态则通过setStatus函数来改变,增加了setBundle函数允许外界向任务传入数据,并且在launch函数里面接收数据并展示在弹框上,我们在任务链里面增加一个函数,用来给对应优先级的任务设置状态与数据


fun setTaskFlag(level: Int, flag: Int, bundle: Bundle = Bundle()) {
if (level > jobMap.size) {
return
}
jobMap[level]?.apply {
setStatus(flag)
setBundle(bundle)
}
}

我们现在可以把任务链同接口一起关联起来了,首先我们先创建个viewmodel,在里面创建三个flow,分别模拟三个不同接口,并且在flow里面向下游发送数据


class MainViewModel : ViewModel(){
val firstApi = flow {
kotlinx.coroutines.delay(1000)
emit("元宵节活动")
}
val secondApi = flow {
kotlinx.coroutines.delay(2000)
emit("端午节活动")
}
val thirdApi = flow {
kotlinx.coroutines.delay(3000)
emit("中秋节活动")
}
}

接着我们如果想要去执行任务链,就必须等到所有接口执行完毕才可以,刚好flow里面的zip操作符就可以满足这一点,它可以让异步任务同步执行,等到都执行完任务之后,才将数据传递给下游,代码实现如下


val mainViewModel: MainViewModel by lazy {
ViewModelProvider(this)[MainViewModel::class.java]
}

MainScope().launch {
JobTaskManager.apply {
mainViewModel.firstApi
.zip(mainViewModel.secondApi) { a, b ->
setTaskFlag( 1, JOB_AVAILABLE, Bundle().apply {
putString("dialog_type", a)
})
setTaskFlag( 2, JOB_AVAILABLE, Bundle().apply {
putString("dialog_type", b)
})
}.zip(mainViewModel.thirdApi) { _, c ->
setTaskFlag( 3, JOB_AVAILABLE, Bundle().apply {
putString("dialog_type", c)
})
}.collect {
stateFlow.collect {
flow {
emit(jobMap[it])
}.collect {
doJob(this@MainActivity, it!!)
}
}
}
}
}

运行一下,效果如下


a3333.gif


我们看到启动后第一个任务并没有立刻执行,而是等了一会才去执行,那是因为zip操作符是等所有flow里面的同步任务都执行完毕以后才发送给下游,flow里面已经执行完毕的会去等待还没有执行完毕的任务,所以才会出现刚刚页面有一段等待的时间,这样的设计一般情况下已经可以满足需求了,毕竟正常情况一个接口的响应时间都是毫秒级别的,但是难防万一出现一些极端情况,某一个接口响应忽然变慢了,就会出现我们的任务链迟迟得不到执行,产品体验方面就大打折扣了,所以需要想个方案解决一下这个问题


优化


首先我们需要当应用启动以后就立马执行任务链,判断当前需要执行任务的优先级与curLevel是否一致,另外,该任务的状态是可执行状态


/**
* 应用启动就执行任务链
*/
fun loadTask(context: Context) {
judgeJob(context, curLevel)
}

/**
* 判断当前需要执行任务的优先级是否与curLevel一致,并且任务可执行
*/
private fun judgeJob(context: Context, cur: Int) {
val job = jobMap[cur]
if(curLevel == cur && job?.status() != JOB_NOT_AVAILABLE){
MainScope().launch {
doJob(context, cur)
}
}
}

我们更改一下doJob函数,让它成为一个挂起函数,并且在里面执行完任务以后,直接去判断它的下一级任务应不应该执行


private suspend fun doJob(context: Context, index: Int) {
if (index > jobMap.size) return
val singleJOb = jobMap[index]
callbackFlow {
if (singleJOb?.handle() == true) {
singleJOb.launch(context) {
trySend(index + 1)
}
} else {
trySend(index + 1)
}
awaitClose { }
}.collect {
curLevel = it
judgeJob(context,it)
}
}

流程到了这里,如果所有任务都不依赖接口,那么这个任务链就能一直执行下去,如果遇到JOB_NOT_AVAILABLE的任务,需要等到接口响应的,那么任务链停止运行,那什么时候重新开始呢?就在我们接口成功回调之后给任务更改状态的时候,也就是setTaskFlag


fun setTaskFlag(context:Context,level: Int, flag: Int, bundle: Bundle = Bundle()) {
if (level > jobMap.size) {
return
}
jobMap[level]?.apply {
setStatus(flag)
setBundle(bundle)
}
judgeJob(context,level)
}

这样子,当任务链走到一个JOB_NOT_AVAILABLE的任务的时候,任务链暂停,当这个任务依赖的接口成功回调完成对这个任务状态的设置之后,再重新通过judgeJob继续走这条任务链,而一些优先级比较低的任务依赖的接口先完成了回调,那也只是完成对这个任务的状态更改,并不会执行它,因为curLevel还没到这个任务的优先级,现在可以试一下效果如何,我们把threeApi这个接口响应时间改的长一点


val thirdApi = flow {
kotlinx.coroutines.delay(5000)
emit("中秋节活动")
}

上层执行任务链的地方也改一下


MainScope().launch {
JobTaskManager.apply {
loadTask(this@MainActivity)
mainViewModel.firstApi.collect{
setTaskFlag(this@MainActivity, 1, JOB_AVAILABLE, Bundle().apply {
putString("dialog_type", it)
})
}
mainViewModel.secondApi.collect{
setTaskFlag(this@MainActivity, 2, JOB_AVAILABLE, Bundle().apply {
putString("dialog_type", it)
})
}
mainViewModel.thirdApi.collect{
setTaskFlag(this@MainActivity, 3, JOB_AVAILABLE, Bundle().apply {
putString("dialog_type", it)
})
}
}
}

应用启动就loadTask,然后三个接口已经从同步又变成异步操作了,运行一下看看效果


a4444.gif


总结


大致的一个效果算是完成了,这只是一个demo,实际需求当中可能更复杂,弹框,页面,小气泡来回交互的情况都有可能,这里也只是想给一些正在优化项目的的同学提供一个思路,或者接手新需求的时候,鼓励多思考一下有没有更好的设计方案


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

Kotlin SharedFlow&StateFlow 热流到底有多热?

1. 冷流与热流区别 2. SharedFlow 使用方式与应用场景 使用方式 流的两端分别是消费者(观察者/订阅者),生产者(被观察者/被订阅者),因此只需要关注两端的行为即可。 1. 生产者先发送数据 fun test1() { ...
继续阅读 »

1. 冷流与热流区别



image.png


2. SharedFlow 使用方式与应用场景


使用方式


流的两端分别是消费者(观察者/订阅者),生产者(被观察者/被订阅者),因此只需要关注两端的行为即可。


1. 生产者先发送数据


    fun test1() {
runBlocking {
//构造热流
val flow = MutableSharedFlow<String>()
//发送数据(生产者)
flow.emit("hello world")

//开启协程
GlobalScope.launch {
//接收数据(消费者)
flow.collect {
println("collect: $it")
}
}
}
}

Q:先猜测一下结果?

A:没有任何打印


我们猜测:生产者先发送了数据,因为此时消费者还没来得及接收,因此数据被丢弃了。


2. 生产者延后发送数据

我们很容易想到变换一下时机,让消费者先注册等待:


    fun test2() {
runBlocking {
//构造热流
val flow = MutableSharedFlow<String>()

//开启协程
GlobalScope.launch {
//接收数据(消费者)
flow.collect {
println("collect: $it")
}
}

//发送数据(生产者)
delay(200)//保证消费者已经注册上
flow.emit("hello world")
}
}

这个时候消费者成功打印数据。


3. 历史数据的保留(重放)

虽然2的方式连通了生产者和消费者,但是你对1的失败耿耿于怀:觉得SharedFlow有点弱啊,限制有点狠,LiveData每次新的观察者到来都能收到当前的数据,而SharedFlow不行。

实际上,SharedFlow对于历史数据的重放比LiveData更强大,LiveData始终只有个值,也就是每次只重放1个值,而SharedFlow可配置重放任意值(当然不能超过Int的范围)。

换一下使用姿势:


    fun test3() {
runBlocking {
//构造热流
val flow = MutableSharedFlow<String>(1)
//发送数据(生产者)
flow.emit("hello world")

//开启协程
GlobalScope.launch {
//接收数据(消费者)
flow.collect {
println("collect: $it")
}
}
}
}

此时达成的效果与2一致,MutableSharedFlow(1)表示设定生产者保留1个值,当有新的消费者来了之后将会获取到这个保留的值。

当然也可以保留更多的值:


    fun test3() {
runBlocking {
//构造热流
val flow = MutableSharedFlow<String>(4)
//发送数据(生产者)
flow.emit("hello world1")
flow.emit("hello world2")
flow.emit("hello world3")
flow.emit("hello world4")

//开启协程
GlobalScope.launch {
//接收数据(消费者)
flow.collect {
println("collect: $it")
}
}
}
}

此时消费者将打印出"hell world1~hello world4",此时也说明了不管有没有消费者,生产者都生产了数据,由此说明:



SharedFlow 是热流



4. collect是挂起函数

在2里,我们开启了协程去执行消费者逻辑:flow.collect,不单独开启协程执行会怎样?


    fun test4() {
runBlocking {
//构造热流
val flow = MutableSharedFlow<String>()

//接收数据(消费者)
flow.collect {
println("collect: $it")
}
println("start emit")//①
flow.emit("hello world")
}
}

最后发现①没打印出来,因为collect是挂起函数,此时由于生产者还没来得及生产数据,消费者调用collect时发现没数据后便挂起协程。



因此生产者和消费者要处在不同的协程里



5. emit是挂起函数

消费者要等待生产者生产数据,所以collect设计为挂起函数,反过来生产者是否要等待消费者消费完数据才进行下一次emit呢?


    fun test5() {
runBlocking {
//构造热流
val flow = MutableSharedFlow<String>()
//开启协程
GlobalScope.launch {
//接收数据(消费者)
flow.collect {
delay(2000)
println("collect: $it")
}
}

//发送数据(生产者)
delay(200)//保证消费者先执行
println("emit 1 ${System.currentTimeMillis()}")
flow.emit("hello world1")
println("emit 2 ${System.currentTimeMillis()}")
flow.emit("hello world2")
println("emit 3 ${System.currentTimeMillis()}")
flow.emit("hello world3")
println("emit 4 ${System.currentTimeMillis()}")
flow.emit("hello world4")
}
}

从打印可以看出,生产者每次emit都需要等待消费者消费完成之后才能进行下次emit。


6. 缓存的设定

在之前分析Flow的时候有说过Flow的背压问题以及使用Buffer来解决它,同样的在SharedFlow里也有缓存的概念。


    fun test6() {
runBlocking {
//构造热流
val flow = MutableSharedFlow<String>(0, 10)
//开启协程
GlobalScope.launch {
//接收数据(消费者)
flow.collect {
delay(2000)
println("collect: $it")
}
}
//发送数据(生产者)
delay(200)//保证消费者先执行
println("emit 1 ${System.currentTimeMillis()}")
flow.emit("hello world1")
println("emit 2 ${System.currentTimeMillis()}")
flow.emit("hello world2")
println("emit 3 ${System.currentTimeMillis()}")
flow.emit("hello world3")
println("emit 4 ${System.currentTimeMillis()}")
flow.emit("hello world4")
}
}

MutableSharedFlow(0, 10) 第2个参数10表示额外的缓存大小为10,生产者通过emit先将数据放到缓存里,此时它并没有被消费者的速度拖累。


7. 重放与额外缓存个数


public fun <T> MutableSharedFlow(
replay: Int = 0,//重放个数
extraBufferCapacity: Int = 0,//额外的缓存个数
onBufferOverflow: BufferOverflow = BufferOverflow.SUSPEND
):

重放主要用来给新进的消费者重放特定个数的历史数据,而额外的缓存个数是为了应付背压问题,总的缓存个数=重放个数+额外的缓存个数。


应用场景


如有以下需求,可用SharedFlow




  1. 需要重放历史数据

  2. 可以配置缓存

  3. 需要重复发射/接收相同的值



3. SharedFlow 原理不一样的角度分析


带着问题找答案


重点关注的无非是emit和collect函数,它俩都是挂起函数,而是否挂起取决于是否满足条件。同时生产者和消费出现的时机也会影响这个条件,因此列举生产者、消费者出现的时机即可。


只有生产者


当只有生产者没有消费者,此时生产者调用emit会挂起协程吗?如果不是,那么什么情况会挂起?

从emit函数源码入手:


    override suspend fun emit(value: T) {
//如果发射成功,则直接退出函数
if (tryEmit(value)) return // fast-path
//否则挂起协程
emitSuspend(value)
}

先看tryEmit(xx):


    override fun tryEmit(value: T): Boolean {
var resumes: Array<Continuation<Unit>?> = EMPTY_RESUMES
val emitted = kotlinx.coroutines.internal.synchronized(this) {
//尝试emit
if (tryEmitLocked(value)) {
//遍历所有消费者,找到需要唤醒的消费者协程
resumes = findSlotsToResumeLocked(resumes)
true
} else {
false
}
}
//恢复消费者协程
for (cont in resumes) cont?.resume(Unit)
//emitted==true表示发射成功
return emitted
}

private fun tryEmitLocked(value: T): Boolean {
//nCollectors 表示消费者个数,若是没有消费者则无论如何都会发射成功
if (nCollectors == 0) return tryEmitNoCollectorsLocked(value) // always returns true
if (bufferSize >= bufferCapacity && minCollectorIndex <= replayIndex) {
//如果缓存已经满并且有消费者没有消费最旧的数据(replayIndex),则进入此处
when (onBufferOverflow) {
//挂起生产者
BufferOverflow.SUSPEND -> return false // will suspend
//直接丢弃最新数据,认为发射成功
BufferOverflow.DROP_LATEST -> return true // just drop incoming
//丢弃最旧的数据
BufferOverflow.DROP_OLDEST -> {} // force enqueue & drop oldest instead
}
}
//将数据加入到缓存队列里
enqueueLocked(value)
//缓存数据队列长度
bufferSize++ // value was added to buffer
// drop oldest from the buffer if it became more than bufferCapacity
if (bufferSize > bufferCapacity) dropOldestLocked()
// keep replaySize not larger that needed
if (replaySize > replay) { // increment replayIndex by one
updateBufferLocked(replayIndex + 1, minCollectorIndex, bufferEndIndex, queueEndIndex)
}
return true
}

private fun tryEmitNoCollectorsLocked(value: T): Boolean {
kotlinx.coroutines.assert { nCollectors == 0 }
//没有设置重放,则直接退出,丢弃发射的值
if (replay == 0) return true // no need to replay, just forget it now
//加入到缓存里
enqueueLocked(value) // enqueue to replayCache
bufferSize++ // value was added to buffer
// drop oldest from the buffer if it became more than replay
//若是超出了重放个数,则丢弃最旧的值
if (bufferSize > replay) dropOldestLocked()
minCollectorIndex = head + bufferSize // a default value (max allowed)
//发射成功
return true
}

再看emitSuspend(value):


    private suspend fun emitSuspend(value: T) = suspendCancellableCoroutine<Unit> sc@{ cont ->
var resumes: Array<Continuation<Unit>?> = EMPTY_RESUMES
val emitter = kotlinx.coroutines.internal.synchronized(this) lock@{
...
//构造为Emitter,加入到buffer里
SharedFlowImpl.Emitter(this, head + totalSize, value, cont).also {
enqueueLocked(it)
//单独记录挂起的emit
queueSize++ // added to queue of waiting emitters
// synchronous shared flow might rendezvous with waiting emitter
if (bufferCapacity == 0) resumes = findSlotsToResumeLocked(resumes)
}
}
}

用图表示整个emit流程:



image.png


现在可以回到上面的问题了。




  1. 如果没有消费者,生产者调用emit函数永远不会挂起

  2. 有消费者注册了并且缓存容量已满并且最旧的数据没有被消费,则生产者emit函数有机会被挂起,如果设定了挂起模式,则会被挂起



最旧的数据下面会分析


只有消费者


当只有消费者时,消费者调用collect会被挂起吗?

从collect函数源码入手。


    override suspend fun collect(collector: FlowCollector<T>) {
//分配slot
val slot = allocateSlot()//①
try {
if (collector is SubscribedFlowCollector) collector.onSubscription()
val collectorJob = currentCoroutineContext()[Job]
while (true) {
//死循环
var newValue: Any?
while (true) {
//尝试获取值 ②
newValue = tryTakeValue(slot) // attempt no-suspend fast path first
if (newValue !== NO_VALUE)
break//拿到值,退出内层循环
//没拿到值,挂起等待 ③
awaitValue(slot) // await signal that the new value is available
}
collectorJob?.ensureActive()
//拿到值,消费数据
collector.emit(newValue as T)
}
} finally {
freeSlot(slot)
}
}

重点看三点:

① allocateSlot()

先看Slot数据结构:


    private class SharedFlowSlot : AbstractSharedFlowSlot<SharedFlowImpl<*>>() {
//消费者当前应该消费的数据在生产者缓存里的索引
var index = -1L // current "to-be-emitted" index, -1 means the slot is free now
//挂起的消费者协程体
var cont: Continuation<Unit>? = null // collector waiting for new value
}

每此调用collect都会为其生成一个AbstractSharedFlowSlot对象,该对象存储在AbstractSharedFlowSlot对象数组:slots里


allocateSlot() 有两个作用:




  1. 给slots数组扩容

  2. 往slots数组里存放AbstractSharedFlowSlot对象



② tryTakeValue(slot)

创建了slot之后就可以去取值了


    private fun tryTakeValue(slot: SharedFlowSlot): Any? {
var resumes: Array<Continuation<Unit>?> = EMPTY_RESUMES
val value = kotlinx.coroutines.internal.synchronized(this) {
//找到slot对应的buffer里的数据索引
val index = tryPeekLocked(slot)
if (index < 0) {
//没找到
NO_VALUE
} else {
//找到
val oldIndex = slot.index
//根据索引,从buffer里获取值
val newValue = getPeekedValueLockedAt(index)
//slot索引增加,指向buffer里的下个数据
slot.index = index + 1 // points to the next index after peeked one
//更新游标等信息,并返回挂起的生产者协程
resumes = updateCollectorIndexLocked(oldIndex)
newValue
}
}
//如果可以,则唤起生产者协程
for (resume in resumes) resume?.resume(Unit)
return value
}

该函数有可能取到值,也可能取不到。


③ awaitValue


    private suspend fun awaitValue(slot: kotlinx.coroutines.flow.SharedFlowSlot): Unit = suspendCancellableCoroutine { cont ->
kotlinx.coroutines.internal.synchronized(this) lock@{
//再次尝试获取
val index = tryPeekLocked(slot) // recheck under this lock
if (index < 0) {
//说明没数据可取,此时记录当前协程,后续恢复时才能找到
slot.cont = cont // Ok -- suspending
} else {
//有数据了,则唤醒
cont.resume(Unit) // has value, no need to suspend
return@lock
}
slot.cont = cont // suspend, waiting
}
}


image.png


对比生产者emit和消费者collect流程,显然collect流程比emit流程简单多了。


现在可以回到上面的问题了。



无论是否有生产者,只要没拿到数据,collect都会被挂起



slot与buffer


以上分别分析了emit和collect流程,我们知道了emit可能被挂起,被挂起后可以通过collect唤醒,同样的collect也可能被挂起,挂起后通过emit唤醒。

重点在于两者是如何交换数据的,也就是slot对象和buffer是怎么关联的?


image.png


如上图,简介其流程:




  1. SharedFlow设定重放个数为4,额外容量为3,总容量为4+3=7

  2. 生产者将数据堆到buffer里,此时消费者还没开始collect

  3. 消费者开始collect,因为设置了重放个数,因此构造Slot对象时,slot.index=0,根据index找到buffer下标为0的元素即为可以消费的元素

  4. 拿到0号数据后,slot.index=1,找到buffer下标为1的元素

  5. index++,重复4的步骤



因为collect消费了数据,因此emit可以继续放新的数据,此时又有新的collect加入进来:


image.png




  1. 新加入的消费者collect时构造Slot对象,因为此时的buffer最旧的值为buffer下标为2,因此Slot初始化Slot.index = 2,取第2个数据

  2. 同样的,继续往后取值



此时有了2个消费者,假设消费者2消费速度很慢,它停留在了index=3,而消费者1消费速度快,变成了如下图:


image.png




  1. 消费者1在取index=4的值(可以继续往后消费数据),消费者2在取index=3的值

  2. 生产者此时已经填充满buffer了,buffer里最旧的值为index=4,为了保证消费者2能够获取到index=4的值,此时它不能再emit新的数据了,于是生产者被挂起

  3. 等到消费者2消费了index=4的值,就会唤醒正在挂起的生产者继续生产数据



由此得出一个结论:



SharedFlow的emit可能会被最慢的collect拖累从而挂起



该现象用代码查看打印比较直观:


    fun test7() {
runBlocking {
//构造热流
val flow = MutableSharedFlow<String>(4, 3)
//开启协程
GlobalScope.launch {
//接收数据(消费者1)
flow.collect {
println("collect1: $it")
}
}
GlobalScope.launch {
//接收数据(消费者2)
flow.collect {
//模拟消费慢
delay(10000)
println("collect2: $it")
}
}
//发送数据(生产者)
delay(200)//保证消费者先执行
var count = 0
while (true) {
flow.emit("emit:${count++}")
}
}
}

4. StateFlow 使用方式与应用场景


使用方式


1. 重放功能

上面花了很大篇幅分析SharedFlow,而StateFlow是SharedFlow的特例,先来看其简单使用。


    fun test8() {
runBlocking {
//构造热流
val flow = MutableStateFlow("")
flow.emit("hello world")
flow.collect {
//消费者
println(it)
}
}
}

我们发现,并没有给Flow设置重放,此时消费者依然能够消费到数据,说明StateFlow默认支持历史数据重放。


2. 重放个数

具体能重放几个值呢?


    fun test10() {
runBlocking {
//构造热流
val flow = MutableStateFlow("")
flow.emit("hello world")
flow.emit("hello world1")
flow.emit("hello world2")
flow.emit("hello world3")
flow.emit("hello world4")
flow.collect {
//消费者
println(it)
}
}
}

最后发现消费者只有1次打印,说明StateFlow只重放1次,并且是最新的值。


3. 防抖


    fun test9() {
runBlocking {
//构造热流
val flow = MutableStateFlow("")
flow.emit("hello world")
GlobalScope.launch {
flow.collect {
//消费者
println(it)
}
}
//再发送
delay(1000)
flow.emit("hello world")
// flow.emit("hello world")
}
}

生产者发送了两次数据,猜猜此时消费者有几次打印?

答案是只有1次,因为StateFlow设计了防抖,当emit时会检测当前的值和上一次的值是否一致,若一致则直接抛弃当前数据不做任何处理,collect当然就收不到值了。若是我们将注释放开,则会有2次打印。


应用场景


StateFlow 和LiveData很像,都是只维护一个值,旧的值过来就会将新值覆盖。

适用于通知状态变化的场景,如下载进度。适用于只关注最新的值的变化。

如果你熟悉LiveData,就可以理解为StateFlow基本可以做到替换LiveData功能。


5. StateFlow 原理一看就会


如果你看懂了SharedFlow原理,那么对StateFlow原理的理解就不在话下了。


emit 过程


    override suspend fun emit(value: T) {
//value 为StateFlow维护的值,每次emit都会修改它
this.value = value
}

public override var value: T
get() = NULL.unbox(_state.value)//从state取出
set(value) { updateState(null, value ?: NULL) }


private fun updateState(expectedState: Any?, newState: Any): Boolean {
var curSequence = 0
var curSlots: Array<StateFlowSlot?>? = this.slots // benign race, we will not use it
kotlinx.coroutines.internal.synchronized(this) {
val oldState = _state.value
if (expectedState != null && oldState != expectedState) return false // CAS support
//新旧值一致,则无需更新
if (oldState == newState) return true // Don't do anything if value is not changing, but CAS -> true
//更新到state里
_state.value = newState
curSequence = sequence
//...
curSlots = slots // read current reference to collectors under lock
}

while (true) {
curSlots?.forEach {
//遍历消费者,修改状态或是将挂起的消费者唤醒
it?.makePending()
}
...
}
}

emit过程就是修改value值的过程,无论是否修改成功,emit函数都会退出,它不会被挂起。


collect 过程


    override suspend fun collect(collector: FlowCollector<T>) {
//分配slot
val slot = allocateSlot()
try {
if (collector is SubscribedFlowCollector) collector.onSubscription()
val collectorJob = currentCoroutineContext()[Job]
var oldState: Any? = null // previously emitted T!! | NULL (null -- nothing emitted yet)
while (true) {
val newState = _state.value
collectorJob?.ensureActive()
//值不相同才调用collect闭包
if (oldState == null || oldState != newState) {
collector.emit(NULL.unbox(newState))
oldState = newState
}
if (!slot.takePending()) { // try fast-path without suspending first
//挂起协程
slot.awaitPending() // only suspend for new values when needed
}
}
} finally {
freeSlot(slot)
}
}

StateFlow 也有slot,叫做StateFlowSlot,它比SharedFlowSlot简单多了,因为始终只需要维护一个值,所以不需要index。里面有个成员变量_state,该值既可以是消费者协程当前的状态,也可以表示协程体。

当表示为协程体时,说明此时消费者被挂起了,等到生产者通过emit唤醒该协程。


image.png


6. StateFlow/SharedFlow/LiveData 区别与应用




  1. StateFlow 是SharedFlow特例

  2. SharedFlow 多用于事件通知,StateFlow/LiveData多用于状态变化

  3. StateFlow 有默认值,LiveData没有,StateFlow.collect闭包可在子线程执行,LiveData.observe需要在主线程监听,StateFlow没有关联生命周期,LiveData关联了生命周期,StateFlow防抖,LiveData不防抖等等。



随着本篇的完结,Kotlin协程系列也告一段落了,接下来将重点放在协程工程架构实践上,敬请期待。


以上为Flow背压和线程切换的全部内容,下篇将分析Flow的热流。

本文基于Kotlin 1.5.3,文中完整Demo请点击


作者:小鱼人爱编程
链接:https://juejin.cn/post/7195569817940164668
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

为什么大家都说 SELECT * 效率低?

无论在工作还是面试中,关于SQL中不要用“SELECT *”,都是大家听烂了的问题,虽说听烂了,但普遍理解还是在很浅的层面,并没有多少人去追根究底,探究其原理。 效率低的原因 先看一下最新《阿里java开发手册(泰山版)》中 MySQL 部分描述: 【强制】在...
继续阅读 »

无论在工作还是面试中,关于SQL中不要用“SELECT *”,都是大家听烂了的问题,虽说听烂了,但普遍理解还是在很浅的层面,并没有多少人去追根究底,探究其原理。


效率低的原因


先看一下最新《阿里java开发手册(泰山版)》中 MySQL 部分描述:


【强制】在表查询中,一律不要使用 * 作为查询的字段列表,需要哪些字段必须明确写明。说明:



  • 增加查询分析器解析成本。

  • 增减字段容易与 resultMap 配置不一致。

  • 无用字段增加网络 消耗,尤其是 text 类型的字段。


开发手册中比较概括的提到了几点原因,让我们深入一些看看:


1. 不需要的列会增加数据传输时间和网络开销



  • 用“SELECT * ”数据库需要解析更多的对象、字段、权限、属性等相关内容,在 SQL 语句复杂,硬解析较多的情况下,会对数据库造成沉重的负担。

  • 增大网络开销;* 有时会误带上如log、IconMD5之类的无用且大文本字段,数据传输size会几何增涨。如果DB和应用程序不在同一台机器,这种开销非常明显

  • 即使 mysql 服务器和客户端是在同一台机器上,使用的协议还是 tcp,通信也是需要额外的时间。


2. 对于无用的大字段,如 varchar、blob、text,会增加 io 操作


准确来说,长度超过 728 字节的时候,会先把超出的数据序列化到另外一个地方,因此读取这条记录会增加一次 io 操作。(MySQL InnoDB)


3. 失去MySQL优化器“覆盖索引”策略优化的可能性


SELECT * 杜绝了覆盖索引的可能性,而基于MySQL优化器的“覆盖索引”策略又是速度极快,效率极高,业界极为推荐的查询优化方式。


例如,有一个表为t(a,b,c,d,e,f),其中,a为主键,b列有索引。


那么,在磁盘上有两棵 B+ 树,即聚集索引和辅助索引(包括单列索引、联合索引),分别保存(a,b,c,d,e,f)和(a,b),如果查询条件中where条件可以通过b列的索引过滤掉一部分记录,查询就会先走辅助索引,如果用户只需要a列和b列的数据,直接通过辅助索引就可以知道用户查询的数据。


如果用户使用select *,获取了不需要的数据,则首先通过辅助索引过滤数据,然后再通过聚集索引获取所有的列,这就多了一次b+树查询,速度必然会慢很多。


由于辅助索引的数据比聚集索引少很多,很多情况下,通过辅助索引进行覆盖索引(通过索引就能获取用户需要的所有列),都不需要读磁盘,直接从内存取,而聚集索引很可能数据在磁盘(外存)中(取决于buffer pool的大小和命中率),这种情况下,一个是内存读,一个是磁盘读,速度差异就很显著了,几乎是数量级的差异。


索引知识延申


上面提到了辅助索引,在MySQL中辅助索引包括单列索引、联合索引(多列联合),单列索引就不再赘述了,这里提一下联合索引的作用。


联合索引 (a,b,c)


联合索引 (a,b,c) 实际建立了 (a)、(a,b)、(a,b,c) 三个索引


我们可以将组合索引想成书的一级目录、二级目录、三级目录,如index(a,b,c),相当于a是一级目录,b是一级目录下的二级目录,c是二级目录下的三级目录。要使用某一目录,必须先使用其上级目录,一级目录除外。


image.png


联合索引的优势


1) 减少开销


建一个联合索引 (a,b,c) ,实际相当于建了 (a)、(a,b)、(a,b,c) 三个索引。每多一个索引,都会增加写操作的开销和磁盘空间的开销。对于大量数据的表,使用联合索引会大大的减少开销!


2)覆盖索引


对联合索引 (a,b,c),如果有如下 sql 的,


SELECT a,b,c from table where a='xx' and b = 'xx';

那么 MySQL 可以直接通过遍历索引取得数据,而无需回表,这减少了很多的随机 io 操作。减少 io 操作,特别是随机 io 其实是 DBA 主要的优化策略。所以,在真正的实际应用中,覆盖索引是主要的提升性能的优化手段之一。


3)效率高


索引列多,通过联合索引筛选出的数据越少。比如有 1000W 条数据的表,有如下SQL:


select col1,col2,col3 from table where col1=1 and col2=2 and col3=3;

假设:假设每个条件可以筛选出 10% 的数据。



  • A. 如果只有单列索引,那么通过该索引能筛选出 1000W 10%=100w 条数据,然后再回表从 100w 条数据中找到符合 col2=2 and col3= 3 的数据,然后再排序,再分页,以此类推(递归);

  • B. 如果是(col1,col2,col3)联合索引,通过三列索引筛选出 1000w 10% 10% *10%=1w,效率提升可想而知!


4)索引是建的越多越好吗


答案自然是否定的



  • 数据量小的表不需要建立索引,建立会增加额外的索引开销

  • 不经常引用的列不要建立索引,因为不常用,即使建立了索引也没有多大意义

  • 经常频繁更新的列不要建立索引,因为肯定会影响插入或更新的效率

  • 数据重复且分布平均的字段,因此他建立索引就没有太大的效果(例如性别字段,只有男女,不适合建立索引)

  • 数据变更需要维护索引,意味着索引越多维护成本越高。

  • 更多的索引也需要更多的存储空间

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

通过官网项目来学习——Jetpack之Startup库

nowinandroid项目作为目前google官方来演示MAD(现代Android开发技术)的示例项目,里面大量依赖运用了jetpack包下的各种库。通过分析学习这些库在项中的实际使用可以帮助我们比直接阅读库的文档来更好的理解和学习。希望通过学习后可以帮助到...
继续阅读 »

nowinandroid项目作为目前google官方来演示MAD(现代Android开发技术)的示例项目,里面大量依赖运用了jetpack包下的各种库。通过分析学习这些库在项中的实际使用可以帮助我们比直接阅读库的文档来更好的理解和学习。希望通过学习后可以帮助到我们能熟练地在我们自己的项目中正确高效的使用到jetpack里面的各种强大库。不废话了,下面进入我们今天的正题——Startup


简单认识一下Startup


image.png


App Startup  |  Android Developers 官网的指南有兴趣可以看看


我们今天不讲原理,你只需知道这个库比之前用多个content provider去实现初始化更高效,更精确,更显性,也就是说能合并content provider提升app的启动速度,能准确的控制初始化顺序,能清晰的从代码知道依赖关系。仅仅这些可能jym会说,我们项目不在乎那点启动速度的提升,也没有很多三方库需要走初始化等,根本用不到这个库。是的,我之前也是这么理解的,但是通过nowinandroid项目发现,有些jetpack内的其他库的初始化现在也交给Startup来完成了,这一点就很重要了。意味着我们可以少写很多样板代码,少写也意味着少犯错。所以我觉的还是有必要单独写一篇文章来说说Startup


编写初始化的代码步骤很简单主要就分3步:



  1. 定义实现Initializer接口的实现类

  2. 配置manifest

  3. 自动或手动调用初始化操作


OK了!就这简单3步,下面我们结合项目例子来看


项目代码



  • 先看第一步


object Sync {
// This method is a workaround to manually initialize the sync process instead of relying on
// automatic initialization with Androidx Startup. It is called from the app module's
// Application.onCreate() and should be only done once.
fun initialize(context: Context) {
AppInitializer.getInstance(context)
.initializeComponent(SyncInitializer::class.java)
}
}

internal const val SyncWorkName = "SyncWorkName"

/**
* Registers work to sync the data layer periodically on app startup.
*/
class SyncInitializer : Initializer<Sync> {
override fun create(context: Context): Sync {
WorkManager.getInstance(context).apply {
// Run sync on app startup and ensure only one sync worker runs at any time
enqueueUniqueWork(
SyncWorkName,
ExistingWorkPolicy.KEEP,
SyncWorker.startUpSyncWork(),
)
}

return Sync
}

override fun dependencies(): List<Class<out Initializer<*>>> =
listOf(WorkManagerInitializer::class.java)
}

定一个SyncInitializer类实现了泛型为SyncInitializer接口。需要重写接口定义的两个方法:



  •  create() 方法, 它包含初始化组件所需的所有操作,并返回一个Sync的实例.

  •  dependencies() 方法, 返回当前初始化器需要依赖的其他初始化器集合,我们可以用这个方法来变相的实现各个初始化器的执行顺序。


所以在create方法里面的执行WorkManager.getInstance(context)方法是安全的。我们这篇只关注Startup所以我们只用知道在这个地方WorkManager做了些事情就行,后面会另开一篇单独讲WorkManager。为啥是安全的呢?因为在dependencies方法里面先执行了WorkManagerInitializer::class.java初始化。我们再来看看这个类。


public final class WorkManagerInitializer implements Initializer<WorkManager> {

private static final String TAG = Logger.tagWithPrefix("WrkMgrInitializer");

@NonNull
@Override
public WorkManager create(@NonNull Context context) {
// Initialize WorkManager with the default configuration.
Logger.get().debug(TAG, "Initializing WorkManager with default configuration.");
//这个地方已经完成了单例的构建,后面再调用WorkManager.getInstance(context)获取实例,否则报错
WorkManager.initialize(context, new Configuration.Builder().build());
return WorkManager.getInstance(context);
}

@NonNull
@Override
public List<Class<? extends androidx.startup.Initializer<?>>> dependencies() {
//这里WorkManager的初始化不需要其他初始化构造器,所以返回的是个空集合
return Collections.emptyList();
}
}

以上我们就把第一步走完了,现在再来看第二步



  • 再看第二步


<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">

<application>
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
<!-- TODO: b/2173216 Disable auto sync startup till it works well with instrumented tests -->
<meta-data
android:name="com.google.samples.apps.nowinandroid.sync.initializers.SyncInitializer"
android:value="androidx.startup"
tools:node="remove" />
</provider>

</application>

</manifest>

这里需要注意的是tools:node="remove",在provider层级用的话是全局取消自动初始化,在meta-data层级用的话是单个组件取消自动初始化。例子展示的是单个组件取消自动初始化。另外注意的一点是被依赖的初始化组件是不需要再另外在manifest里面声明的,这就是为什么WorkManagerInitializer没有声明。



  • 最后一步


@HiltAndroidApp 
class NiaApplication : Application(), ImageLoaderFactory {
override fun onCreate() {
super.onCreate()
// Initialize Sync; the system responsible for keeping data in the app up to date.
Sync.initialize(context = this)
}

/**
* Since we're displaying SVGs in the app, Coil needs an ImageLoader which supports this
* format. During Coil's initialization it will call `applicationContext.newImageLoader()` to
* obtain an ImageLoader.
*
* @see <a href="https://github.com/coil-kt/coil/blob/main/coil-singleton/src/main/java/coil/Coil.kt">Coil</a>
*/
override fun newImageLoader(): ImageLoader {
return ImageLoader.Builder(this)
.components {
add(SvgDecoder.Factory())
}
.build()
}
}

上面的代码是app的Application,我们今天的重点是Startup,所以我们先不管其他的。只用看onCreate下的Sync.initialize(context = this)方法。


object Sync {
// This method is a workaround to manually initialize the sync process instead of relying on
// automatic initialization with Androidx Startup. It is called from the app module's
// Application.onCreate() and should be only done once.
fun initialize(context: Context) {
AppInitializer.getInstance(context)
.initializeComponent(SyncInitializer::class.java)
}
}

AppInitializer.getInstance(context).initializeComponent(SyncInitializer::class.java)传入SyncInitializer类,实现手动初始化完成。



以上就是nowinandroid项目对Startup库的使用,并且上面我们也知道了我们自定义的初始化器在初始化的时候通过WorkManager做了些事情。那么下篇我们还是通过这个例子来看看nowinandroid是怎么使用WorkManager这个库的。



水平有限,写作不易。各位jym高抬贵手点个赞留个言都是对我最大的鼓励


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

通过官方项目来学习——枚举、密封类、密封接口的区别和使用场景

相信点进来愿意看这篇文章的jym都用过密封类、枚举类甚至也已经用到了密封接口,但是多多少少可能还是有点迷惑。写这篇文章就是希望能跟大家一起梳理这三类的区别,搞清楚在哪种情况用哪种实现最合适。 从java到kotlin,大家肯定对枚举已经比较熟悉了,这里就不专门...
继续阅读 »

相信点进来愿意看这篇文章的jym都用过密封类、枚举类甚至也已经用到了密封接口,但是多多少少可能还是有点迷惑。写这篇文章就是希望能跟大家一起梳理这三类的区别,搞清楚在哪种情况用哪种实现最合适。


从java到kotlin,大家肯定对枚举已经比较熟悉了,这里就不专门说它了。但是密封类和密封接口的概念最先是在kotlin上实现的,java之前是没有的。那么我们可能会想,为什么在已经有枚举的情况下还要新增密封类和密封接口这两个新的概念出来呢?这三点又有什么不同?什么情况我们应该用他们呢?如果你没有一个清晰的答案,那么请带着这三个问题请继续看下去


下面用nowinandroid项目内的代码来当个例子


密封类


sealed class Icon {
data class ImageVectorIcon(val imageVector: ImageVector) : Icon()
data class DrawableResourceIcon(@DrawableRes val id: Int) : Icon()
}

这个密封类很简单。里面就封了两个数据类,两个类的参数类型不同。根据命名不难看出,Icon密封类是提供Icon给ui用的,ImageVectorIcon通过提供ImageVector实现icon显示,DrawableResourceIcon提供资源id来实现icon显示。那么很容易想象出来后面的使用场景无非就是通过一个类型判断,来执行对应条件下的加载就完了。这里大家只需留意一点,就是这个密封类我们是没有做任何初始化操作的。


枚举类


enum class TopLevelDestination(
val selectedIcon: Icon,
val unselectedIcon: Icon,
val iconTextId: Int,
val titleTextId: Int,
) {
FOR_YOU(
selectedIcon = DrawableResourceIcon(NiaIcons.Upcoming),
unselectedIcon = DrawableResourceIcon(NiaIcons.UpcomingBorder),
iconTextId = forYouR.string.for_you,
titleTextId = R.string.app_name,
),
BOOKMARKS(
selectedIcon = DrawableResourceIcon(NiaIcons.Bookmarks),
unselectedIcon = DrawableResourceIcon(NiaIcons.BookmarksBorder),
iconTextId = bookmarksR.string.saved,
titleTextId = bookmarksR.string.saved,
),
INTERESTS(
selectedIcon = ImageVectorIcon(NiaIcons.Grid3x3),
unselectedIcon = ImageVectorIcon(NiaIcons.Grid3x3),
iconTextId = interestsR.string.interests,
titleTextId = interestsR.string.interests,
),
}

现在我们来简单看一眼这个枚举类。4个参数组成了构造参数,前两个参数的类型是上面定义的Icon密封类。然后定义了三个值For_You,BOOKMARKS,INTERESTS。这里需要注意了,这三个值的参数都并必须完成初始化提供实例才行,而前面定义的密封类并不需要。那么我们发现了一个枚举类跟密封类的区别了。这个区别我个人觉得也是枚举类和密封类最大的一个区别-复杂度。这里的枚举类的使用场景是给app的底部导航栏用的,我们知道一般导航栏需要的东西很简单数量也不多,一般都是一个选中时的icon,未选中时的icon,一个标题就完了,所以很简单一点都不复杂。后期我们要添加多少个新的也都是这个模版,很方便我们统一维护。但是这里的Icon密封类就相对要复杂灵活点了,首先可以实现提供显示icon的方法有还很多种,我们不太可能一次把所有的方式都写进去,所以我们可以通过Icon密封类随时扩展,再则这个icon可能是会经常更换的(比方说版本更新,配合活动动态更换等),那么我们这个资源肯定就不能写死了,也就是不推荐用枚举去实现,不然换一次icon就要新建一个新值,维护起来麻烦也不优雅。那么这个时候的密封类就又起到作用了,我们只用替换原有枚举类初始化的资源就行了。



小结一下:简单稳定的用枚举,复杂灵活的用密封



密封接口


密封接口这个概念并不是跟密封类一同出现的。是先有的密封类后面高版本kotlin才出现的密封接口。刚出来的时候,我也不懂为啥还要特意再整个密封接口出来,正好在掘金上看到了大佬写的一篇很好的文章介绍了密封接口和密封类的区别,这里我就不再复述了,建议大家直接点链接去学习 Kotlin 1.5 新特性:密封接口比密封类强在哪? - 掘金 (juejin.cn)。下面还是提供一下nowinandroid项目里面部分用到密封接口地方的代码。


package com.google.samples.apps.nowinandroid.core.result

import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart

sealed interface Result<out T> {
data class Success<T>(val data: T) : Result<T>
data class Error(val exception: Throwable? = null) : Result<Nothing>
object Loading : Result<Nothing>
}


用来封装网络请求返回的几种情况,可读性强于密封类实现



sealed interface NewsFeedUiState {
/**
* The feed is still loading.
*/
object Loading : NewsFeedUiState

/**
* The feed is loaded with the given list of news resources.
*/
data class Success(
/**
* The list of news resources contained in this feed.
*/
val feed: List<UserNewsResource>,
) : NewsFeedUiState
}


封装页面加载的情况,可读性强于密封类实现


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

ARouter ksp注解处理器 实现思路

注解处理器到底承担了什么作用? 考虑清楚这个问题 对我们的ksp实现 会有非常大的帮助, 否则那一坨注解处理器的实现 你是根本不知道为什么要那样实现,自然ksp的实现你是无从下手的。 首先我们来看一个典型的例子, 我们有2个module 一个叫 moduleA...
继续阅读 »

注解处理器到底承担了什么作用?


考虑清楚这个问题 对我们的ksp实现 会有非常大的帮助, 否则那一坨注解处理器的实现 你是根本不知道为什么要那样实现,自然ksp的实现你是无从下手的。


首先我们来看一个典型的例子, 我们有2个module 一个叫 moduleA 一个叫moduleB , moduleA中的类 想跳转到moudleB中的类 要怎么做? 一般都是


startActivity(B.class)


这就会带来一个问题了, moduleA 要引用到moduleB 中的类, 你只能让moduleA依赖moduleB, 那如果反过来呢?moduleB还得依赖moduleA了, 这种相互间的引用 肯定是不行的了,那应该怎么做?


就是参考Arouter的做法就可以了, 例如moduleB中的B.class 我想对外暴露 ,让别的module中可以跳转到我这个activity ,那就我在B.class中 加一个注解


例如:
image.png


这是大家最熟悉的代码了,那么关键的地方就在于, 我的注解处理器 到底要做什么? 要生成什么样的类,来达成我们最终的目的。


本质上来说,我们一个apk,下面肯定有多个module, 不管他们的依赖关系如何,他的编译关系都是确定的,这句话怎么理解?


moduleA 编译以后生成一堆class文件,moduleB 编译以后也生成一堆class文件, 等等。 最终这些class文件
都是在我们的app moudle 编译时汇总的, 思考明白这个问题 那就好理解了,


回到前面的例子中,我们在moduleB中 加了注解,然后我们可以利用注解处理器 来生成一个类,这个类中维护一个map,这个map的key就是 我们注解中的path的字符串之,value 则是本身我们B.class


这样多个module 在app module 中汇总编译的时候 我们就可以拿到一个巨大的map 这个map中key就是path的值,value 就是目标的class


之后跳转的时候只要在navigation中传递一下path的值,然后根据再到map中寻找到对应的class就可以了。


你看,arouter的注解处理器 是干啥的,我们就想清楚了吧,就是生成一堆辅助类,这个辅助类的最终目的就是帮我们生成path 和class的对应关系的


理想和现实中的差别


在上一个小节中,我们基本弄清楚了arouter 注解处理器的作用, 但是仅靠这一节的知识要完全看懂arouter-compiler的代码还是不够, 因为实际上arouter 的map生成要比 我们前面一个小节 所说的要复杂的多。为什么?


你仔细思考一下, 如果是多个module 都使用了route注解,那这些注解的类中的path的值 是不是有可能是重复的?


比如moduleB中 有一个类叫X.class 他的path是 /x1/xc
moduleC中 有一个类叫Y.class 他的 path值也是 /x1/xc


这就会导致一个问题了,在app 编译的时候 同样一个path 会对应着2个class,此时跳转就会出现错误了。


我们来看下,Arouter中 是如何设计来解决这个问题的 他首先引入了一个Group的概念, 比如我们上面的path
x1 就是group, 当然你也可以手动指定group ,但是意思都是一样的


首先生成一个名为


Arouter$$Root$$moduleName

的类,这个类继承的是IRouteRoot这个接口


这里我们要关注的是moduleName ,我们在用annotaionProcessor 或者kapt 或者ksp的 这三种注解处理器的时候 都要传递一个参数的


image.png


image.png


然后再关注下 loadInto 这个方法


这个方法一看就是生成了一个map 对吧, 这个map的key 就是 group的值,而value则是注解处理器生成的一个类 实现了IRouteGroup接口


Arouter$$Group$$group的值

我们来看一下这个类里面干了啥


image.png


这个类也有一个loadInfo 方法


它的key 就是path的值, value 就是RouteMeta对象,注意这个对象中就具体包含了Activity.class了,


所以Arouter 实际上就是把我们的map给分了级,


首先是利用 moduleName 来生成 IRouteRoot的类 ,这样可以规避不同module之间有冲突的现象
其次是利用 group的概念 再次对路由进行分层, 这样一方面是降低冲突几率,另外一方面,利用group的概念,我们还可以做路由的懒加载,毕竟项目大了以后 一次性加载全部路由信息也是有成本的,有了group的概念,


我们就可以按照group的级别来加载了,实际上arouter本身路由加载也是这样做的。


路由利用group分组以后, 默认任何实际路由信息都不会加载, 当每次调用者发起一次路由加载事件时,都会按照group的信息来查找,第一次触发某个group 时,再去加载这个group下面的所有路由信息


ksp的基础实现


首先我们新建一个module ,命名大家随意,注意这个module的build 文件写法即可


apply plugin: 'java'
apply plugin: 'kotlin'

compileJava {
sourceCompatibility = '1.8'
targetCompatibility = '1.8'
}

sourceSets.main {
java.srcDirs("src/main/java")
}

dependencies {
implementation 'com.alibaba:arouter-annotation:1.0.6'
implementation("com.squareup:kotlinpoet:1.11.0")
implementation("com.squareup:kotlinpoet-ksp:1.11.0")
implementation("com.squareup:kotlinpoet-metadata:1.11.0")
implementation 'com.alibaba:fastjson:1.2.69'
implementation 'org.apache.commons:commons-lang3:3.5'
implementation 'org.apache.commons:commons-collections4:4.1'
implementation("com.google.devtools.ksp:symbol-processing-api:1.6.20-1.0.5")

}

apply from: rootProject.file('gradle/publish.gradle')

其次,去meta-inf 下 新建一个文件,文件名是固定的


com.google.devtools.ksp.processing.SymbolProcessorProvider


image.png


里面的内容就简单了,把我们的ksp注解处理器配置进去即可


com.alibaba.android.arouter.compiler.processor.RouteSymbolProcessorProvider
com.alibaba.android.arouter.compiler.processor.InterceptorSymbolProcessorProvider
com.alibaba.android.arouter.compiler.processor.AutowiredSymbolProcessorProvider

这里要注意一下,即使是一个纯java代码的module 也可以使用ksp来生成代码的


注解处理器如何debug?


注解处理器的代码其实还挺晦涩难懂的,全靠日志打印很麻烦,这里还是会debug 比较好


image.png


image.png


稍微配置一下即可, 然后打上断点,按下debug开关,rebuild 工程即可触发注解处理器的调试了


使用ksp 注解处理器来生成辅助类


这里篇幅有限, 我们只做辅助类的生成, 至于辅助类里面的loadInto方法 我们暂不做实现,具体的实现我们留到下一篇文章再说,这一节只做一下 辅助类生成这个操作


首先我们来配置一下 使用ksp的module


ksp {
arg("AROUTER_MODULE_NAME", project.getName())
}

ksp project(':arouter-compiler')

然后要注意的是,即使是纯java代码的module 也可以利用ksp来生成代码的, 唯一要注意的是你需要在这个module下 添加


apply plugin: 'kotlin-android'

现在注解处理器也配置好了, 我们就可以干活了。


先放一个基础类就行


class RouteSymbolProcessorProvider : SymbolProcessorProvider {
override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor {
return RouteSymbolProcessor(environment.options, environment.logger, environment.codeGenerator)
}
}

第一步,我们要取出moduleName,这个东西的作用前面已经介绍过了,


val moduleName = options[KEY_MODULE_NAME]

第二步,我们要取出项目中 使用Route注解的类,拿到这些类的信息


// 取出来 使用route注解的
val symbols = resolver.getSymbolsWithAnnotation(Route::class.qualifiedName!!)

// 先取出来 有哪些 类用了Route注解
val elements = symbols.filterIsInstance<KSClassDeclaration>().toList()

第三步, 也是最关键的一步,我们要取出Route 中的关键信息作一个map,key是group,value是path的list


其实也就是一个group 下面对应的所有path信息


这里有几个关键点, 要把Route中的path和group的值 都提取出来, 如果没有指定group 则 path的第一段作为group的值


另外就是在取的时候 要判断一下 这个element的注解是不是Route注解, 因为一个类可以有多个注解,我们要取特定的Route注解 才能取到我们想要的值


关键代码


val map = mutableMapOf<String, List<String>>()
elements.forEach {
it.annotations.toList().forEach { ks ->
// 防止多个注解的情况
if (ks.shortName.asString() == "Route") {
var path = ""
var group = ""
ks.arguments.forEach { ksValueA ->
if (ksValueA.name?.asString() == "path") {
path = ksValueA.value as String
}
if (ksValueA.name?.asString() == "group") {
group = ksValueA.value as String
}
}

// 如果没有配置group 则去path中取
if (group.isEmpty()) {
group = path.split("/")[1]
}

if (map.contains(group)) {
map[group] = map[group]!!.plus(path)
} else {
map[group] = listOf(path)
}
}
}
}

第四步,我们生成IRouteRoot 辅助类


这里有一个难点 就是 如何写这个方法参数的类型


image.png


看下具体代码 如何来解决这个问题


private fun String.quantifyNameToClassName(): com.squareup.kotlinpoet.ClassName {
val index = lastIndexOf(".")
return com.squareup.kotlinpoet.ClassName(substring(0, index), substring(index + 1, length))
}

// IRouteRoot 这个接口 方法参数的定义 MutableMap<String, Class<out IRouteGroup>>?
val parameterSpec = ParameterSpec.builder(
"routes",
MUTABLE_MAP.parameterizedBy(
String::class.asClassName(),
Class::class.asClassName().parameterizedBy(
WildcardTypeName.producerOf(
Consts.IROUTE_GROUP.quantifyNameToClassName()
)
)
).copy(nullable = true)
).build()

参数的这个问题解决掉以后 就很简单了


直接按照名字规则 生成一下 类即可


val rootClassName = "ARouter$$Root$$$moduleName"

val packageName = "com.alibaba.android.arouter"
val file = FileSpec.builder("$packageName.routes", rootClassName)
.addType(
TypeSpec.classBuilder(rootClassName).addSuperinterface(
com.squareup.kotlinpoet.ClassName(
"com.alibaba.android.arouter.facade.template",
"IRouteRoot"
)
).addFunction(
FunSpec.builder("loadInto").addModifiers(KModifier.OVERRIDE)
.addParameter(parameterSpec)
.addStatement("TODO()").build()
).build()
)
.build()

file.writeTo(codeGen, false)

最后一步, 我们要生成IRrouteGroup的辅助类,里面放入对应path的信息


这里path的信息 我用注释表示下即可,


// 生成group 辅助类
map.forEach { (key, value) ->

val rootClassName = "ARouter$$Group$$$key"

// IRouteGroup 这个接口 方法参数的定义 MutableMap<String,RouteMeta>?
val parameterSpec = ParameterSpec.builder(
"atlas",
MUTABLE_MAP.parameterizedBy(
String::class.asClassName(),
RouteMeta::class.asClassName()
).copy(nullable = true)
).build()

val packageName = "com.alibaba.android.arouter"
// val rootClass = com.squareup.kotlinpoet.ClassName("", rootClassName)
val file = FileSpec.builder("$packageName.routes", rootClassName)
.addType(
TypeSpec.classBuilder(rootClassName).addSuperinterface(
com.squareup.kotlinpoet.ClassName(
"com.alibaba.android.arouter.facade.template",
"IRouteGroup"
)
).addFunction(
FunSpec.builder("loadInto").addModifiers(KModifier.OVERRIDE)
.addParameter(parameterSpec)
.addComment("path: $value")
.addStatement("TODO()").build()
).build()
)
.build()

file.writeTo(codeGen, false)
}

最后看下实现效果:


对应的辅助类 应该是都生成了:


image.png


path的信息:


image.png


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

简单聊聊 compose 的remember

前言 在前面聊mutableStateOf 的时候用了这段代码来讲述了Compose 状态的更新。 code-1 val name = mutableStateOf("hello compose") setContent...
继续阅读 »

前言


在前面聊mutableStateOf 的时候用了这段代码来讲述了Compose 状态的更新。


code-1
val name = mutableStateOf("hello compose")
setContent {
    Text(name.value)
}

lifecycleScope.launch {
    delay(3000)
    name.value = "android"
}

接下来,我们继续通过这段代码来一起聊下 Composeremember


浅聊


我们先对code-1的代码稍微做下修改


code-2    
    setContent {
            var name by mutableStateOf("hello compose")
            Text(name)
            //ps:此处代码仅做演示使用,compose中协程的使用并非如此
            lifecycleScope.launch {
                delay(3000)
                name = "android"
            }
     }

当我们这样进行修改之后,发现3s过后,“hello compose” 并没有如期变成“android”。


这是为什么呢? 是协程没有执行吗?还是没有进行重组刷新?用最简单的方法,我们来加日志看一下执行。


code-3   
      setContent {
            Log.i("TAG", "setContent: ")
            var name by mutableStateOf("hello compose")
            Text(name)
            //ps:此处代码仅做演示使用,compose中协程的使用会另做讲解。
            lifecycleScope.launch {
                delay(3000)
                name = "android"
                Log.i("TAG", "launch: ")
            }
        }


可以看到,协程已经执行了,而且也进行了重组刷新,但是为什么值没有改变呢?


这是因为使用变量的组件会被包起来,当变量改变的时候会随之进行重组刷新,每次刷新的时候,就会重新创建一个MutableState对象,这个对象就会取默认值“hello compose”。所以才会看起来每次都进行了刷新,但是文字却没有任何改变。



刷新重组的范围就叫做重组作用域。



我们想让Text() 进行刷新怎么办?可以进行包一层。我们对code-2的代码稍微做下修改。


code-4           
setContent {
           var name by mutableStateOf("hello compose")
           Button(onClick = {}){
               Text(name)
           }
            //ps:此处代码仅做演示使用,compose中协程的使用会另做讲解。
            lifecycleScope.launch {
                delay(3000)
                name = "android"
            }
        }

我们让Button 对他进行一个包裹,然后来看看效果。





可以看到进行包裹了之后,文字发生了改变。虽然这样满足了我们的需求,但是不能每次有使用变量的组件,每次都进行一个包裹吧,这岂不是会疯掉。


接下来就需要有请我们今天的主角 remember了,它就是为了帮助我们解决这个问题。


它可以在每次重组的时候,去帮我们去拿已经缓存的值,不需要每次都是重新创建。


code-5             
setContent {
            var name by remember { mutableStateOf("hello compose") }
            Text(name)
            //ps:此处代码仅做演示使用,compose中协程的使用会另做讲解。
            lifecycleScope.launch {
                delay(3000)
                name = "android"
            }
}





remember 也是一个Composable 函数,因此只能在Composable 中调用。



现在,我们有个场景,我们需要频繁展示相同的数据,如果使用Text() 直接进行展示,就会每次就会重新计算,但是这些计算没必要的。


为了避免不必要的资源浪费,我们也可以使用remember 来解决。


code-6       
      setContent {
            var name by remember { mutableStateOf("hello compose") }
            ShowCharLenth(name)
            //ps:此处代码仅做演示使用,compose中协程的使用会另做讲解。
            lifecycleScope.launch {
                delay(3000)
                name = "android"
            }
        }

code-7
@Composable
fun ShowCharLenth(value: String) {
    val str = remember { value }
    Text(str)
}

这样使用,就避免了code-7 中的频繁计算重组。


可是这样还会产生一个问题,如果我们展示的数据 如果变了怎么办? 前面的数据进行了缓存,后面的数据即使变了 还会取之前缓存的数据,那直接产生的问题就是数据改变了,但是UI上没有变化。


remember 也早就对这种情况有了解决措施,而且非常简单。


code-8
@Composable
fun ShowCharLenth(value: String) {
    val str = remember(value) { value }
    Text(str)
}

小括号中的value 就是一个key值,根据key值 再去拿remember缓存的数据,这样就完美解决了我们的问题。


至此,我们从这段很短的代码中学到了 remember 的作用 以及使用,感兴趣的同学可以简单上手实践下。


总结


今天的内容就到这里了,非常的简短,主要就是介绍了一下 remember的作用和使用,希望对于新上手Compose的同学有所帮助。


作者:不说话的匹诺槽
链接:https://juejin.cn/post/7194816465290133560
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

如何让一套代码完美适配各种屏幕?

一、适配的目的 区别于iOS,android设备有不同的分辨率大小以及不同厂商的系统,目前市场的分辨率可以看下相关统计。 2021市场移动设备分辨率统计 可以看到主流的分辨率有10多种,当不做适配时,一套代码在不同设备上的效果偏大、偏小、截断以及留白严重,那一...
继续阅读 »

一、适配的目的


区别于iOS,android设备有不同的分辨率大小以及不同厂商的系统,目前市场的分辨率可以看下相关统计。


2021市场移动设备分辨率统计


可以看到主流的分辨率有10多种,当不做适配时,一套代码在不同设备上的效果偏大、偏小、截断以及留白严重,那一套代码如何完美的展示在不同的设备上,可以看下面的一些适配方案。


二、UI适配


2.1、常见的适配方式


2.1.1、xml布局控件适配



  1. 避免写死View的宽高,尽量使用warp_content和match_parent;

  2. 父布局为LinearLayout,选择使用android:layout_weight属性,为布局中的每个子View设置权重;

  3. 父布局为RelativeLayout,可以选择使用layout_centerInParent等属性,设置子View的相对位置;

  4. 谷歌官方在之前版本中提供了一个百分比布局方式:support:percent,它支持RelativeLayout和FrameLayout的百分比布局,但是目前官方已经不再维护,而将他取而代之的是新晋布局:ConstraintLayout,ConstraintLayout强大之处不仅在于它能够进行百分比布局,还可以进行相对定位、角度定位、尺寸约束、宽高比、Chainl链布局等,在不同设备间都能处理的游刃有余。


2.1.2、图片适配



  1. .9图

    .9.png图片本质上还是png图片,相对于普通png图来说,.9图可以让图片在指定的位置拉伸和在指定的位置显示内容且不会失真;

  2. 见2.1.4分辨率限定符;


2.1.3、依据产品设计适配


所谓产品设计适配,指的是产品流程在不同设备上有不同的展示方式,例如手机与Pad的区别,在手机设备上,一般来说具体Item列表是一个页面,点击每个Item会跳转至新的详情页;而在宽度>高度的Pad上,为了防止页面空白浪费,一般会要求屏幕左侧为Item列表,右侧即详情页,item与详情页会同时出现在用户的视觉内,如下图


Pad.png


关于这种类型的设计,其实郭霖《第一行代码》给出了一个方案,我在这里抛砖引玉一下,给出基本思路。


这种情况下,适配的核心在于利用android动态加载布局的机制,使得程序能够根据分辨率或者屏幕大小在运行时动态加载不同的布局,而动态加载就需要使用到限定符



  • 限定符
    所谓限定符,指的是给res目录中的子目录加上“-限定符”,可以给不同设备提供不同的资源以及布局,如下图,layout添加-large,-small。


image.png


layout-small:指的是提供给小屏幕设备的资源;

layout-large:指的是提供给大屏幕设备的资源;

layout/layout-normal:指的是提供给中等屏幕设备的资源,也就是默认状态;

layout-xlarge:值得是提供给超大屏幕设备的资源;


在上面所提出的情景下,Pad即指的大屏幕,手机一般可看作为中等屏幕设备,为了在大屏幕下显示双页模式,我们可以在layout-large和layout目录下新建同一个name的布局xml,在layout-large下的xml针对Pad做双页处理,即左半边View+右半边View样式,layout目录下xml还是做普通处理。


在最后项目运行时,会根据不同设备来加载不同目录下的xml资源,即Pad会加载layout-large目录下的xml,普通手机设备会加载layout目录下的xml资源。


从而实现一套代码在不同设备上产品逻辑。


限定符可以大范围的区分设备,但是你还是不知道-large代表是多大的设备,-small代表的是多小的设备,如果需要清楚的区分各个屏幕的大小,那就需要用到最小宽度限定符。



  • 最小宽度限定符(Smallest-width Qualifier),简称SW
    最小宽度限定符指的是,对屏幕的宽度设立一个最小的值(dp),当当前设备屏幕宽度大于这个值就加载一个布局,


image.png


例如在res下新建一个layout-sw720dp的文件夹,当屏幕宽度大于720dp时,项目就会加载layout-sw720dp/***.xml 资源文件。


2.1.4、限定符适配


在2.1.3中提到了限定符的概念,也解决了一部分的设计适配问题,但是还有一些限定符的概念没有涉及到,该目录下将会提到不同的限定符的概念,可以结合2.1.3一起食用。



  • 分辨率限定符
    在Android项目中,会把放置图片资源的文件夹分为drawable-hdpi、xhdpi xxhdpi xxxhdpi等,这些指的就是分辨率限定符。


Andriod系统会根据手机屏幕的大小及屏幕密度去选择不同文件夹下的图片资源,以此来实现在不同大小不同屏幕分辨率下适配的问题。


这里提一点AS对图片资源的匹配规则:


举个例子,当当前的设备密度为xhdpi,此时代码中ImageView需要去引用drawable中的图片,那么根据匹配规则,系统首先会在drawable-xhdpi文件夹中去搜索,如果需要的图片存在,那么直接显示;如果不存在,那么系统将会开始从更高dpi中搜索,例如drawable-xxhdpi,drawable-xxxhdpi,如果在高dpi中搜索不到需要的图片,那么就会去drawable-nodpi中搜索,有则显示,无则继续向低dpi,如drawable-hdpi,drawable-mdpi,drawable-ldpi等文件夹一级一级搜索.


当在比当前设备密度低的文件夹中搜到图片,那么在ImageView(宽高在wrap_content状态下)中显示的图片将会被放大.图片放大也就意味着所占内存也开始增多.这也就是为什么分辨率不高的图片随意放置在drawable中也会出现OOM,而在高密度文件夹中搜到图片,图片在该设备上将会被缩小,内存也就相应减少。


在理想的状态下,不同dpi的文件下应该放置相应dpi的图片资源,以对不同的设备进行适配。




  • 尺寸限定符和最小宽度限定符
    见2.1.3




  • 屏幕方向限定符
    屏幕方向限定符即“-land”、“-port”,分别代表横排和竖屏。




手机会存在横竖屏切换的场景,当设备横屏时,会主动加载layout-land/目录下的资源文件,当设备为竖屏时,则加载layout-port目录下的资源文件。


2.2、今日头条适配方式


在开始今日头条的适配方案之前,需要提及px、dpi、density的概念。


px:即像素,我们常看到的480 * 800 、720 * 1280、1080 * 1920指的就是像素值宽高的意思;


dpi:即densityDpi,每英寸中的像素数;


density:屏幕密度,density = dpi / 160;


scaledDensity:字体的缩放因子,正常情况下和density相等,但是调节系统字体大小后会改变这个值


android中的dp在渲染前会将dp转为px,计算公式:



  • px = density * dp


从dp和px的转换公式 :px = dp * density 可以看出,如果设计图宽为360dp,想要保证在所有设备计算得出的px值都正好是屏幕宽度的话,我们只能修改 density 的值。这就是该方案的核心。


那如何修改系统的density?


可以通过DisplayMetrics获取系统density和scaledDensity值,


val displayMetrics = application.resources.displayMetrics

val density = displayMetrics.density
val scaledDensity = displayMetrics.scaledDensity

设配的目的在于使用一套设计稿,能完好的展示在不同设备上,所以UI需要确定一个固定的尺寸,依据density=px / dp的公式,确定density的值,其中px指的是真实设备的值,
这里我们以设计稿的宽度作为一个纬度进行测算。


举个例子,如设计稿中固定宽度为360dp,当前设备的屏幕宽度为720,那么density = 720 / 360 = 2,其中当前设备的屏幕宽度也可以用DisplayMetrics来获取:


val targetDensity = displayMetrics.widthPixels / 360

整体思路


//0.获取当前app的屏幕显示信息
val displayMetrics = application.resources.displayMetrics
if (appDensity == 0f) {
//1.初始化赋值操作 获取app初始density和scaledDensity
appDensity = displayMetrics.density
appScaleDensity = displayMetrics.scaledDensity
}

/*
2.计算目标值density, scaleDensity, densityDpi
targetDensity为当前设备的宽度/设计稿固定的宽度
targetScaleDensity:目标字体缩放Density,等比例测算
targetDensityDpi:density = dpi / 160 即dpi = density * 160
*/
val targetDensity = displayMetrics.widthPixels / WIDTH
val targetScaleDensity = targetDensity * (appScaleDensity / appDensity)
val targetDensityDpi = (targetDensity * 160).toInt()

//3.替换Activity的density, scaleDensity, densityDpi
val dm = activity.resources.displayMetrics
dm.density = targetDensity
dm.scaledDensity = targetScaleDensity
dm.densityDpi = targetDensityDpi

三、刘海屏适配















image.pngimage.png


  • 有状态栏的界面:刘海区域会显示状态栏,无需适配;

  • 全屏界面:刘海区域可能遮挡内容,需要适配;


针对刘海屏适配,在Android P以上,谷歌官方给出了适配方案,可参考developer.android.google.cn/guide/topic… ,所以在 targetApi >= 28 上可以使用谷歌官方推荐的适配方案进行刘海屏适配。
而在Android O的设备上,如华为、小米、oppo等厂商给出了适配方案。


3.1、Android9.0官方适配


将内容呈现到刘海区域中,则可以使用 WindowInsets.getDisplayCutout() 来检索 DisplayCutout 对象,同时可以使用窗口布局属性 layoutInDisplayCutoutMode 控制内容如何呈现在刘海区域中。


layoutInDisplayCutoutMode



  • LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT :在竖屏模式下,内容会呈现到刘海区域中;但在横屏模式下,内容会显示黑边。

  • LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES:在竖屏模式和横屏模式下,内容都会呈现到刘海区域中。

  • LAYOUT_IN_DISPLAY_CUTOUT_MODE_NEVER:内容从不呈现到刘海区域中。


/**
* @param mode 刘海屏下内容显示模式,针对Android9.0
LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT = 0; //在竖屏模式下,内容会呈现到刘海区域中;但在横屏模式下,内容会显示黑边
LAYOUT_IN_DISPLAY_CUTOUT_MODE_NEVER = 2;//不允许内容延伸进刘海区
LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES = 1;//在竖屏模式和横屏模式下,内容都会呈现到刘海区域中
*/
@RequiresApi(Build.VERSION_CODES.P)
private fun setDisplayCutoutMode(mode: Int) {
window.attributes.apply {
this.layoutInDisplayCutoutMode = mode
window.attributes = this
}

}

判断是否当前设备是否有刘海:


/**
* 判断当前设备是否有刘海
*/
@RequiresApi(Build.VERSION_CODES.P)
private fun hasCutout(): Boolean {
window.decorView.rootWindowInsets?.let {
it.displayCutout?.let {
if (it.boundingRects.size > 0 && it.safeInsetTop > 0) {
return true
}
}
}
return false
}

在activity的
setContentView(R.layout.activity_main)之前设置layoutInDisplayCutoutMode。

















LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULTLAYOUT_IN_DISPLAY_CUTOUT_MODE_NEVERLAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
image.pngimage.pngimage.png

3.2、各大厂商适配方案(华为、小米、oppo等)


除了在AndroidP系统下官方给了适配方案,各大厂商针对自家系统也给出了相应的适配方案,可参考:


oppo

vivo

小米

华为


参考文档

今日头条适配方案

Android9.0官方适配方案


推荐阅读

视频直播小窗口(悬浮窗)展示方案

探究ANR原理-是谁控制了ANR的触发时间


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

Compose太香了,不想再写传统 xml View?教你如何在已有View项目中混合使用Compose

前言 在我的文章 记一次 kotlin 在 MutableList 中使用 remove 引发的问题 中,我提到有一个功能是将多张动图以N宫格的形式拼接,并且每个动图的宽保证一致,但是高不保证一致。 在原本项目中我使用的是传统 view 配合 Recycler...
继续阅读 »

前言


在我的文章 记一次 kotlin 在 MutableList 中使用 remove 引发的问题 中,我提到有一个功能是将多张动图以N宫格的形式拼接,并且每个动图的宽保证一致,但是高不保证一致。


在原本项目中我使用的是传统 view 配合 RecyclerView 和 GridLayout 布局方式进行拼图的预览,但是这会存在一个问题。


实际上是这样排列的:


s1.png


但是预想中应该是这样排列:


s2.png


可以看到,我们的需求应该是完全按照顺序来排列,但是瀑布流布局却是在每一行中,哪一列的高度最小就优先排到哪一列,而不是严格按照给定顺序排列。


显然,这是不符合我们的需求的。


我曾经试图找到其他的替代方式实现这个效果,或者试图找到 GridLayout 的某个参数可以修改为按顺序排列,但是一直无果。


最终,只能用自定义布局来实现我想要的效果了。但是对于原生 View 的自定义布局非常麻烦,我也没有接触过,所以就一直不了了之了。


最近一直在学习 compose ,发现 compose 的自定义布局还挺简单的,所以就萌生了使用 compose 的自定义布局来实现这个需求的想法。


由于这个项目是使用的传统 View ,并且已经上线运行很久了,不可能一蹴而就直接全部改成使用 compose,并且这个项目也还挺复杂的,移植起来也不简单。所以,我决定先只将此处的预览界面改为使用 compose,也就是混合使用 View 与 compose。


开始移植


compose 自定义布局


在开始之前我们需要先使用 compose 编写一个符合我们需求的自定义布局:


@Composable
fun TestLayout(
modifier: Modifier = Modifier,
columns: Int = 2,
content: @Composable ()->Unit
) {
Layout(
modifier = modifier,
content = content,
) { measurables: List<Measurable>, constrains: Constraints ->
val itemWidth = constrains.maxWidth / columns
val itemConstraints = constrains.copy(minWidth = itemWidth, maxWidth = itemWidth)
val placeables = measurables.map { it.measure(itemConstraints) }

val heights = IntArray(columns)
var rowNo = 0
layout(width = constrains.maxWidth, height = constrains.maxHeight){
placeables.forEach { placeable ->
placeable.placeRelative(itemWidth * rowNo, heights[rowNo])
heights[rowNo] += placeable.height

rowNo++
if (rowNo >= columns) rowNo = 0
}
}
}
}

这个自定义布局有三个参数:


modifier Modifier 这个不用过多介绍


columns 表示一行需要放多少个 item


content 放置于其中的 itam


布局的实现也很简单,首先由于每个子 item 的宽度都是一致的,所以我们直接定义 item 宽度为当前布局的最大可用尺寸除以一行的 item 数量: val itemWidth = constrains.maxWidth / columns


然后创建一个 Array 用于存放每一列的当前高度,方便后面摆放时计算位置: val heights = IntArray(columns)


接下来遍历所有子项 placeables.forEach { placeable -> } 。并使用绝对坐标放置子项,且 x 坐标为 宽度乘以当前列, y 坐标为 当前列高度 placeable.placeRelative(itemWidth * rowNo, heights[rowNo])


最后将高度累加 heights[rowNo] += placeable.height 并更新列数到下一列 rowNo++if (rowNo >= columns) rowNo = 0


下面预览一下效果:


@Composable
fun Test() {
Column(
Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
TestLayout {
Rectangle(height = 120, color = Color.Blue, index = "1")
Rectangle(height = 60, color = Color.LightGray, index = "2")
Rectangle(height = 140, color = Color.Yellow, index = "3")
Rectangle(height = 80, color = Color.Cyan, index = "4")
}
}
}


@Composable
fun Rectangle(height: Int, color: Color, index: String) {
Column(
modifier = Modifier
.size(width = 100.dp, height = height.dp)
.background(color),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(text = index, fontWeight = FontWeight.ExtraBold, fontSize = 24.sp)
}
}


@Preview(backgroundColor = 0xFFFFFFFF, showBackground = true)
@Composable
fun PreviewTest() {
Test()
}

效果如下:


s2.png


完美符合我们的需求。


增加修改 gradle 配置


为了给已有项目增加 compose 支持我们需要增加一些依赖以及更新一些参数配置。


检查 AGP 版本


首先,我们需要确保 Android Gradle Plugins(AGP)版本是最新版本。


如果不是的话需要升级到最新版本,确保 compose 的使用,例如我写作时最新稳定版是 7.3.0


点击 Tools - AGP Upgrade Assistant 打开 AGP 升级助手,选择最新版本后升级即可。


检查 kotlin 版本


不同的 Compose Compiler 版本对于 kotlin 版本有要求,具体可以查看 Compose to Kotlin Compatibility Map


例如,我们这里使用 Compose Compiler 版本为 1.3.2 则要求 kotlin 版本为 1.7.20


修改配置信息


首先确保 API 等级大于等于21,然后启用 compose:


buildFeatures {
// Enables Jetpack Compose for this module
compose true
}

配置 Compose Compiler 版本:


composeOptions {
kotlinCompilerExtensionVersion '1.3.2'
}

并且确保使用 JVM 版本为 Java 8 , 需要修改的所有配置信息如下:


android {
defaultConfig {
...
minSdkVersion 21
}

buildFeatures {
// Enables Jetpack Compose for this module
compose true
}
...

// Set both the Java and Kotlin compilers to target Java 8.
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}

composeOptions {
kotlinCompilerExtensionVersion '1.3.2'
}
}

添加依赖


dependencies {
// Integration with activities
implementation 'androidx.activity:activity-compose:1.5.1'
// Compose Material Design
implementation 'androidx.compose.material:material:1.2.1'
// Animations
implementation 'androidx.compose.animation:animation:1.2.1'
// Tooling support (Previews, etc.)
implementation 'androidx.compose.ui:ui-tooling:1.2.1'
// Integration with ViewModels
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.5.1'
// UI Tests
androidTestImplementation 'androidx.compose.ui:ui-test-junit4:1.2.1'
}

自此所有配置修改完成,Sync 一下吧~


将 view 替换为 compose


根据我们的需求,我们需要替换的是用于预览拼图的 RecyclerView:


<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".fragment.gifTools.JointGifPreviewFragment">

<!-- ... -->

<androidx.recyclerview.widget.RecyclerView
android:id="@+id/jointGif_preview_recyclerView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="48dp"
android:layout_marginBottom="8dp"
android:transitionName="shared_element_container_gifImageView"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />

<!-- ... -->

</androidx.constraintlayout.widget.ConstraintLayout>

将其替换为承载 compose 的 ComposeView:


<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".fragment.gifTools.JointGifPreviewFragment">

<!-- ... -->

<androidx.compose.ui.platform.ComposeView
android:id="@+id/jointGif_preview_recyclerView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="48dp"
android:layout_marginBottom="8dp"
android:transitionName="shared_element_container_gifImageView"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />

<!-- ... -->

</androidx.constraintlayout.widget.ConstraintLayout>

在原本初始化 RecyclerView 的地方,将我们上面写好的 composable 设置进去。


将:


override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)

// ...

initRecyclerView()

// ...
}

改为:


override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)

// ...

bind.jointGifPreviewRecyclerView.setContent {
Test()
}

// ...
}

ComposeViewsetContent(content: @Composable () -> Unit) 方法只有一个 content 参数,而这个参数是一个添加了 @Composable 注解的匿名函数,也就是说,在其中我们可以正常的使用 compose 了。


更改完成后看一下运行效果:


s3.png


可以看到,混合使用完全没有问题。


但是这里我们使用的是写死的 item 数据,而不是用户动态选择的图片数据,所以下一步我们需要搞定 compose 和 view 之间的数据交互。


数据交互


首先,因为我们需要显示的动图,所以需要引入一下对动图的支持,这里我们直接使用 coil 。


引入 coil 依赖:


// coil compose
implementation 'io.coil-kt:coil-compose:2.2.2'
// coil gif 解码支持
implementation 'io.coil-kt:coil-gif:2.2.2'

定义一个用于显示 gif 的 composable:


@Composable
fun GifImage(
uri: Uri,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val imageLoader = ImageLoader.Builder(context)
.components {
if (SDK_INT >= 28) {
add(ImageDecoderDecoder.Factory())
} else {
add(GifDecoder.Factory())
}
}
.build()

Image(
painter = rememberAsyncImagePainter(model = uri, imageLoader = imageLoader),
contentDescription = null,
modifier = modifier,
contentScale = ContentScale.FillWidth
)
}

其中,rememberAsyncImagePaintermodel 参数支持多种类型的图片,例如:File Uri String Drawable Bitmap 等,这里因为我们原本项目中使用的是 Uri ,所以我们也定义为使用 Uri。


而 coil 对于不同 API 版本支持两种解码器 ImageDecoderDecoderGifDecoder 按照官方的说法:



Coil includes two separate decoders to support decoding GIFs. GifDecoder supports all API levels, but is slower. ImageDecoderDecoder is powered by Android's ImageDecoder API which is only available on API 28 and above. ImageDecoderDecoder is faster than GifDecoder and supports decoding animated WebP images and animated HEIF image sequences.



简单翻译就是 GifDecoder 支持所有 API 版本,但是速度较慢; ImageDecoderDecoder 仅支持 API >= 28 但是速度较快。


因为我们的需求是宽度一致,等比缩放长度,所以需要给 Image 加上缩放类型 contentScale = ContentScale.FillWidth


之后把我们的自定义 Layout 改一下名字,其他内容不变: SquareLayout


增加一个 JointGifSquare 用作界面入口:


@Composable
fun JointGifSquare(
columns: Int,
uriList: ArrayList<Uri>,
) {
SquareLayout(columns = columns) {
uriList.forEachIndexed { index, uri ->
GifImage(
uri = uri,
)
}
}
}

其中 columns 表示每一行有多少列;uriList 表示需要显示 GIF 动图 Uri 列表。


最后,将 Fragmnet 中原本初始化 RecyclerView 的方法改为:


private fun initRecyclerView() {
val showGifResolutions = arrayListOf()

// 获取用户选择的图片列表,初始化 showGifResolutions

// ...

var lineLength = GifTools.JointGifSquareLineLength[gifUris!!.size]

bind.jointGifPreviewRecyclerView.setContent {
JointGifSquare(
lineLength,
gifUris!!
)
}
}

其中,GifTools.JointGifSquareLineLength 是我定义的一个 HashMap 用来存放所有图片数量与每一行数量的对应关系:


val JointGifSquareLineLength = hashMapOf(4 to 2, 9 to 3, 16 to 4, 25 to 5, 36 to 6, 49 to 7, 64 to 8, 81 to 9, 100 to 10)

从上面可以看出,其实要从 compose 中拿到 View 的数据也很简单,直接传值进去即可。


最终运行效果:


g1.gif


原本使用 view 的运行效果:


g2.gif


可以看到,使用 compose 重构后的排列方式才是符合我们预期的排列方式。


总结


自此,我们就完成了将 View 中的其中一个界面替换为使用 compose 实现,也就是混合使用 view 和 compose 。


其实这个功能还有两个特性没有移植,那就是支持点击预览中的任意图片后可以更换图片和长按图片可以拖拽排序。


这两个功能的界面实现非常简单,难点在于,我怎么把更换图片和重新排序图片后的状态传回给 View。


这个问题我们就留着以后再说吧。


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

你还在用merge么,了解了解rebase吧

前言 Git作为我们日常开发代码的版本管理,开发分支的管理方面起着很大作用,我们开发过程中分支通常有生产、预发、测试、开发这几个分支,我们会根据项目进行的某个阶段,将代码提交到某个版本上,正常流程是先开发 —>测试 —>预发—>生产,但是通常...
继续阅读 »

前言


Git作为我们日常开发代码的版本管理,开发分支的管理方面起着很大作用,我们开发过程中分支通常有生产、预发、测试、开发这几个分支,我们会根据项目进行的某个阶段,将代码提交到某个版本上,正常流程是先开发 —>测试 —>预发—>生产,但是通常会有很多版本,有先后上线顺序,并且我们的开发人员也会是多个,在各种因素下项目的开发版本远程分支,以及开发人员的本地分支管理就由为的关键。


普通开发流程


正常一个版本需要经过的几个阶段,分别是dev、test、uat、master,我们通过下面流程图这么做是没什么问题的,每个阶段去将从master拉取的版本分支,push到对应的分支上进行发布,正常预发和生产环境的代码应该保持一致,test分支由于会有多个版本并行开发,所以代码和预发和生产比起来会有一些不一样。


B6C4A36C-1922-4B72-954E-414C41A8D7D5.png


多版本并行开发


在多个版本并非开发的时候,对分支的管理就不像上面那么简单了,涉及到多个version,这些版本的上线时间节点也是不同的,意味着上test和uat的时间节点也是不一样的。


这里涉及到多种情况



  1. 在后端开发人员较少的情况下,通常2-3人为例,完全可以从master拉取一个开发分支,分支格式已 服务名+上线时间,例如xxx_20230130这个本地分支,后端开发人员一起在这个分支上进行并行开发,开发阶段将自己的本地分支merge到dev分支,因为只有2-3人所以冲突解决起来还好,有冲突解决冲突。

  2. 后端开发人员较多的情况,通常在5-8人为例,这时候从master分支拉取分支,分支格式就需要已 服务名+姓名缩写+上线时间来命名,尽量每个人在自己命名的分支下进行开发,这样在开发阶段本地测试的时候,可以做到相互不影响,但是在merge到远程分支的时候,解决代码冲突的时候需要认真仔细一些,这种活还是交给心细的人来做吧,测试的时候也需要根据版本上线的优先级进行测试。

  3. 版本比较多的情况,比如一个月会有4-5个版本的开发,那么上线时间也是分4-5个节点,这样就需要每次从先发上线的远程分支,将代码merge到下个版本的本地开发分支上,以此类推。


58C8F39E-4161-4FBA-9861-CF48E436F5AF.png


Git merge


作为git 合并分支的命令,也是在日常开发过程中经常用到的一个命令,通常我们会将拥有最新代码的一个版本merge到较老的一个版本,实现版本同步。


3EE8E0C2-67C1-4136-9703-67726D5B1005.png


大体就是这么一个步骤,从刚开始的公共分支,变为master和feature分支,
通过git merge master 命令将master分支merge到feature分支。
Merge命令会将前面featrue分支所有的commit提交全部合并为一个新的commit提交。
⚠️这里只有会在产生冲突的时候,才能产生新的commit记录。


可以理解为git pull =git fetch +git merge,拉取最新的远程分支,然后将这个分支合并到另一个分支。


在公司开发的时候,通常大家喜欢这个命令,因为简单粗暴,直接将其他分支合并到自己分支,简单好理解。


Git rebase


作为自己的个人喜好,比较喜欢rebase这个命令,核心理念就是“变基”。


3F362A81-B158-4CCA-86A8-FA7715E2FDF7.png



  1. 由上图可看见,通过reabse命令将feature分支延续到了master分支后面。

  2. 在多人开发过程中,如果其他人在master进行commit,这个时候你在feature分支提交了几个commit,这时候你使用rebase命令,会将你的commit提交记录放在master的commit记录的后面,而merge就会将不同分支的commit合并成一个新的commit记录,这就是merge和rebase的不同点。

  3. 本地feature分支和远端的master分支如果是同一条分支的话,可以使用rebase,保证commit的记录的清晰性,这个很关键!


⚠️不要在公共分支使用rebase命令,这样会污染公共分支,这样公共分支就会存在你的commit记录,别人拉取的时候会存在你的最新的commit记录。


总结


在开发中不仅需要代码质量高,在版本管理上也是由为的重要,上线前漏掉代码的事情,相信大家都曾遇到过,但是这种事情是很危险⚠️的,希望此文章能给大家在日常代码版本管理中提交警惕,合理合并分支,最后祝大家在新的一年,少出bug、多多学习、多多进步。


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

谷歌的bug:当 CompileSdk 33 遇上Kotlin

最近项目里compose 要升级到1.3, 要求compile sdk 也要到33版本,大家都知道 一般情况下,我们修改compilesdk 都不会有什么问题,最多就是一些api的适配,编译不过啥的, 但是不会引发线上故障,但是这里要注意了target sd...
继续阅读 »

最近项目里compose 要升级到1.3, 要求compile sdk 也要到33版本,大家都知道 一般情况下,我们修改compilesdk 都不会有什么问题,最多就是一些api的适配,编译不过啥的, 但是不会引发线上故障,但是这里要注意了target sdk 的修改 就要复杂的多了, 这里不多说,只介绍一下 我碰到的一个compilesdk 33的问题


在Compile sdk 33版本中,这个手势监听的接口 代码发生了一些变化:


image.png


在这些接口方法说 参数前面加上了一个NonNull的 注解,这个注解的意思就是 告诉开发者 这个参数不可能为空


image.png


注意了 在<=32的版本中 这个注解是没有的


image.png


对于java的开发者来说,这个影响微乎其微,但是如果你跟我一样是kotlin的开发者就要倒霉了,


因为在<=32的时候 你继承这个接口的时候 会提示你参数要定义成可空的


但是当你升级到33的sdk的时候,你就会发现编译不过了


image.png


为啥?


因为33的sdk 前面说过了,方法前面有了 不可空的注解了


要让他编译过很简单 我们只要把? 去掉即可


image.png


到这里还没结束,最坑的地方来了, 虽然你能编译过,但是在运行时,有可能会发生crash


image.png


为啥? 熟悉kotlin的人就知道了,当你定义一个参数为不可空的类型的时候,你如果传了一个null给这个参数,他就会报这个crash了,这种情况常见于 java代码调用kotlin代码的时候 这是kotlin编译器的魔法,有兴趣的可以自己反编译看一下字节码,实际上,当你定义一个变量为不可空的时候,如果传值给他 他就会校验这个值 是不是为null 为null 则直接抛异常


搞清楚问题所在以后 就得想想怎么解决了,目前的情况就是 如果不改,就编译不过,改了 在运行时会crash


另外:
这里有个链接,可以看下该问题的讨论,目前状态是显示 谷歌承认了该bug,看状态显示fixed,但是不知道为什么
还没有推送最新的33 sdk
issueTracker


实际上解决这个问题的方法有很多,


方法1: 这个接口的实现 我们不用kotlin写,用java写,即可 这个方案最简单,但是不太优雅


方法2: 魔改下android sdk 33版本的jar包,把注解去掉 这个方案也可以,但是有点麻烦


方法3: asm 字节码修改,把那个校验参数为null 就抛异常的代码删了就行了。 杀鸡焉用牛刀


方法4: 写一个delegate 即可,以后都用这个代理类去做监听, 这个方法我认为是最简单的,一劳永逸,而且成本极低



import android.content.Context;
import android.os.Handler;
import android.view.GestureDetector;
import android.view.MotionEvent;

import androidx.annotation.Nullable;

/**
* 在compile sdk 33 中 修复google的一个注解bug,该bug 会导致 要么kotlin代码编译失败
* 要么运行时crash,这里用代理模式 简单的规避此问题即可
*
*/
public class GestureDetectorDelegate extends GestureDetector {
/**
* @param listener
* @param handler
* @deprecated
*/
public GestureDetectorDelegate(OnGestureListener listener, Handler handler) {
super(listener, handler);
}

/**
* @param listener
* @deprecated
*/
public GestureDetectorDelegate(OnGestureListener listener) {
super(listener);
}

public GestureDetectorDelegate(Context context, OnGestureListenerDelegate listener) {
super(context, listener);
}

public GestureDetectorDelegate(Context context, OnGestureListener listener, Handler handler) {
super(context, listener, handler);
}

public GestureDetectorDelegate(Context context, OnGestureListener listener, Handler handler, boolean unused) {
super(context, listener, handler, unused);
}

/**
* 主要修改点就是在这里了,复写这些方法 标记这些参数为可空的即可
*/
public interface OnGestureListenerDelegate extends OnGestureListener {
boolean onDown(@Nullable MotionEvent e);

void onShowPress(@Nullable MotionEvent e);

boolean onSingleTapUp(@Nullable MotionEvent e);

boolean onScroll(@Nullable MotionEvent e1, @Nullable MotionEvent e2, float distanceX, float distanceY);

void onLongPress(@Nullable MotionEvent e);

boolean onFling(@Nullable MotionEvent e1, @Nullable MotionEvent e2, float velocityX, float velocityY);
}
}

方案5: 利用proguard混淆的配置规则


其实所谓的抛异常,就是kotlin在编译的时候 手动帮我们增加了判断是否null 然后抛异常的方法


image.png


那我们实际上最简单的方案就是 利用混淆的规则,在release包构建的时候 把这个代码去掉就可以了


-assumenosideeffects class kotlin.jvm.internal.Intrinsics {
public static void check*(...);
}

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

耗时一周,实现高仿微信渐变模糊效果——纯原生实现

最近一个需求要实现类似微信状态的模糊效果,还要求不能引入库,增加包的大小。网上搜了一圈,只有 Flutter 的实现。没办法只能自己开撸,实现效果如下,上面的图是我的实现效果,下面的是微信的实现效果。 实现原理 首先,我们观察一下下面的微信状态的实现效...
继续阅读 »

最近一个需求要实现类似微信状态的模糊效果,还要求不能引入库,增加包的大小。网上搜了一圈,只有 Flutter 的实现。没办法只能自己开撸,实现效果如下,上面的图是我的实现效果,下面的是微信的实现效果。



Screenshot_2022-11-12-14-46-40-11_2d4809a04714b92ad0ec5f736efb755b.jpg


956ac151237d8da1ad0bc9edebd6744a.jpg



实现原理


首先,我们观察一下下面的微信状态的实现效果。可以看出上部分是截取了头发部分进行了高斯模糊;而下面部分则是对围裙进行高斯模糊。


image.png


拿原图进行对比,我们可以发现,渐变高斯模糊的部分遮住了原图片,同时还有渐变的效果。最后,图片好像加了一层灰色的遮罩,整体偏灰。

接下来,我们要做的事情就清楚了。


第一步:选取原图片的上下两部分分别进行高斯模糊
第二步:自定义 OnDraw 方法,让高斯模糊的部分覆盖原图片的上下两部分
第三步:让高斯模糊的图片实现渐变效果


选取原图片的上下两部分分别进行高斯模糊


在开始高斯模糊前,我们需要先确定上下两部分的高度。需要注意的是,我们不能直接使用图片的高度,因为图片的宽不一定等于屏幕的宽度。因此,我们需要按照比例计算出图片缩放后的高度。代码如下:



//最后要求显示的图片宽度为屏幕宽度
int requireWidth = UIUtils.getScreenWidth(context);
int screenHeight = UIUtils.getScreenHeight(context);
//按照比例,计算出要求显示的图片高度
int requireHeight = requireWidth * source.getHeight() / source.getWidth();
int topOrBottomBlurImageHeight = (int) ((screenHeight - requireHeight) / 2 + requireHeight * 0.25f);

如下图所示,最后一步 (screenHeight - requireHeight) / 2 获取到缩放后的图片居中时的上下两部分的高度。但是,渐变高斯模糊的部分还需要增加 padding 来遮住原图片的部分内容,这里的 padding 取的是 requireHeight * 0.25f


企业微信截图_89ec2f08-1741-4789-9c7c-943be98e3f68.png


计算出高度后,我们还不能对图片直接进行高斯模糊,要先要对图片进行缩放。为什么要先进行压缩呢?有两点原因:



  1. 使用 RenderScript 进行高斯模糊,最大模糊半径是 25,模糊效果不理想

  2. 高斯模糊的半径超过 10 之后就有性能问题


为了解决上面的问题,我们需要先对图片进行缩放,再进行高斯模糊。核心代码如下,为了后面使用协程,这里是用 kotlin 实现的。



private val filter = PorterDuffColorFilter(Color.argb(140, 0, 0, 0), PorterDuff.Mode.SRC_ATOP)

private fun blurBitmap(
source: Bitmap,
radius: Int,
top: Boolean,
topOrBottomBlurImageHeight: Int,
screenHeight: Int,
context: Context?
): Bitmap? {

//第1部分
val cutImageHeight = topOrBottomBlurImageHeight * source.height / screenHeight
val sampling = 30

//第2部分
val outBitmap = Bitmap.createBitmap(source.width / sampling,
cutImageHeight / sampling, Bitmap.Config.ARGB_8888)
val canvas = Canvas(outBitmap)
canvas.scale(1 / sampling.toFloat(), 1 / sampling.toFloat())
val paint = Paint()
paint.flags = Paint.FILTER_BITMAP_FLAG or Paint.ANTI_ALIAS_FLAG
//过滤颜色值
paint.colorFilter = filter
val dstRect = Rect(0, 0, source.width, cutImageHeight)
val srcRect: Rect = if (top) {//截取顶部
Rect(0, 0, source.width, cutImageHeight)
} else {//截取底部
Rect(0, source.height - cutImageHeight, source.width, source.height)
}
canvas.drawBitmap(source, srcRect, dstRect, paint)

//高斯模糊
val result = realBlur(context, outBitmap, radius)

//创建指定大小的新 Bitmap,内部会对传入的原 Bitmap 进行拉伸
val scaled = Bitmap.createScaledBitmap(
result,
(source.width),
(cutImageHeight),
true)
return scaled
}

代码看不懂?没关系,下面会一一来讲解:


第1部分,这里定义了两个本地变量 cutImageHeightsamplingcutImageHeight 是要裁剪图片的高度,sampling 是缩放的比例。你可能会奇怪 cutImageHeight 的计算方式。如下图所示,cutImageHeight 是用 topOrBottomBlurImageHeight 占屏幕高度的比例计算的,目的是让不同的图片裁剪的高度不同,这也是微信状态模糊的效果。如果你想固定裁剪比例,完全可以修改 cutImageHeight 的计算方式。


image.png


第2部分,这里就做了一件事,就是截取原图的部分并压缩。这里比较难理解的就是为什么创建 Bitmap 时,它的宽高已经缩小了,但是还需要调用 canvas.scale。其实,canvas.scale 只会作用于 canvas.drawBitmap 里的原 Bitmap


高斯模糊这里可以采取你项目里之前使用的方式就行,如果之前没做过高斯模糊,可以看Android图像处理 - 高斯模糊的原理及实现。这里使用的是 Google 原生的方式,代码如下:


@Throws(RSRuntimeException::class)
private fun realBlur(context: Context?, bitmap: Bitmap, radius: Int): Bitmap {
var rs: RenderScript? = null
var input: Allocation? = null
var output: Allocation? = null
var blur: ScriptIntrinsicBlur? = null
try {
rs = RenderScript.create(context)
rs.messageHandler = RenderScript.RSMessageHandler()
input = Allocation.createFromBitmap(
rs, bitmap, Allocation.MipmapControl.MIPMAP_NONE,
Allocation.USAGE_SCRIPT
)
output = Allocation.createTyped(rs, input.type)
blur = ScriptIntrinsicBlur.create(rs, Element.U8_4(rs))
blur.setInput(input)
blur.setRadius(radius.toFloat())
blur.forEach(output)
output.copyTo(bitmap)
} finally {
rs?.destroy()
input?.destroy()
output?.destroy()
blur?.destroy()
}
return bitmap
}

还有一点细节,由于我们给高斯模糊的图片加了 filter ,为了保持一致性。我们也需要给原 Bitmap 进行过滤。代码如下:


private fun blurSrc(bitmap: Bitmap): Bitmap? {
if (bitmap.isRecycled) {
return null
}
val outBitmap = Bitmap.createBitmap(bitmap.width, bitmap.height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(outBitmap)
val paint = Paint()
paint.flags = Paint.FILTER_BITMAP_FLAG or Paint.ANTI_ALIAS_FLAG
paint.colorFilter = filter
canvas.drawBitmap(bitmap, 0f, 0f, paint)
return outBitmap
}

最后,我们可以使用协程来获取处理后的 Bitmap ,代码如下


fun wxBlurBitmap(source: Bitmap, topOrBottomBlurImageHeight: Int, screenHeight: Int, context: Context?, imageView: BlurImageView) {
if(source.isRecycled) {
return
}
GlobalScope.launch(Dispatchers.Default) {
val time = measureTimeMillis {
val filterBitmap = async {
blurSrc(source)
}
val topBitmap = async {
blurBitmap(source, 10, true, topOrBottomBlurImageHeight, screenHeight, context)
}
val bottomBitmap = async {
blurBitmap(source, 10, false, topOrBottomBlurImageHeight, screenHeight, context)
}
val src = filterBitmap.await()
val top = topBitmap.await()
val bottom = bottomBitmap.await()
launch(Dispatchers.Main) {
if(top == null || bottom == null) {
imageView.setImageBitmap(source)
} else {
imageView.setBlurBitmap(src, top, bottom, topOrBottomBlurImageHeight)

}

}
}
}
}

自定义 ImageView


上面的操作,我们获得了3个 Bitmap,要把它们正确的摆放就需要我们自定义一个 ImageView。如果对自定义 View 不了解的话,可以看看扔物线大佬的 Hencoder 的自定义View系列 教程。代码如下:


public class BlurImageView extends androidx.appcompat.widget.AppCompatImageView {

private Bitmap mSrcBitmap;
private Bitmap mTopBlurBitmap;
private Bitmap mBottomBlurBitmap;
private Matrix mDrawMatrix;
private Paint mPaint;
private Shader mTopShader;
private Shader mBottomShader;
private PorterDuffXfermode mSrcPorterDuffXfermode;
private PorterDuffXfermode mBlurPorterDuffXfermode;
private int mTopOrBottomBlurImageHeight;

public BlurImageView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
init();
}

/**
* 设置图片
* @param src 原图片的 Bitmap
* @param top 原图片top部分的 Bitmap
* @param bottom 原图片bottom部分的 Bitmap
* @param topOrBottomBlurImageHeight 模糊图片要求的高度
*/
public void setBlurBitmap(Bitmap src, Bitmap top, Bitmap bottom, int topOrBottomBlurImageHeight) {
this.mSrcBitmap = src;
this.mTopBlurBitmap = top;
this.mBottomBlurBitmap = bottom;
this.mTopOrBottomBlurImageHeight = topOrBottomBlurImageHeight;
invalidate();
}

private void init() {
mPaint = new Paint();
mDrawMatrix = new Matrix();
mPaint.setAntiAlias(true);
mSrcPorterDuffXfermode = new PorterDuffXfermode(PorterDuff.Mode.DST_ATOP);
mBlurPorterDuffXfermode = new PorterDuffXfermode(PorterDuff.Mode.DST_ATOP);
}

@Override
protected void onDraw(Canvas canvas) {
if(mSrcBitmap == null || mTopBlurBitmap == null || mBottomBlurBitmap == null) {
super.onDraw(canvas);
return;
}
if(mSrcBitmap.isRecycled() || mTopBlurBitmap.isRecycled() || mBottomBlurBitmap.isRecycled()) {
mSrcBitmap = null;
mTopBlurBitmap = null;
mBottomBlurBitmap = null;
return;
}

int save = canvas.saveLayer(null, null, Canvas.ALL_SAVE_FLAG);

//第1部分
final int srcWidth = mSrcBitmap.getWidth();
final int srcHeight = mSrcBitmap.getHeight();
final int topWidth = mTopBlurBitmap.getWidth();
final int topHeight = mTopBlurBitmap.getHeight();
final int contentWidth = getWidth() - getPaddingLeft() - getPaddingRight();
final int contentHeight = getHeight() - getPaddingTop() - getPaddingBottom();
float scrBitmapScale = (float) contentWidth / (float) srcWidth;
float srcTopOrBottomPadding = (contentHeight - srcHeight * scrBitmapScale) * 0.5f;
int requireBlurHeight = mTopOrBottomBlurImageHeight;
float overSrcPadding = requireBlurHeight - srcTopOrBottomPadding;//要求的模糊图片的高度
float dx = 0;//缩放后的模糊图片的x方向的偏移
float dy = 0;//缩放后的模糊图片的y方向的偏移
float blurScale = 0;//高斯模糊图片的缩放比例
if(requireBlurHeight * topWidth >= topHeight * contentWidth) {
//按照高缩放
blurScale = (float) requireBlurHeight / (float) topHeight;
dx = (contentWidth - topWidth * blurScale) * 0.5f;
} else {
//按照宽缩放,因为按照高缩放时,当前Bitmap无法铺满
blurScale = (float) contentWidth / (float) topWidth;
dy = (requireBlurHeight - topHeight * blurScale) * 0.5f;
}

//第2部分
//绘制上面模糊处理后的图片,注意如果作为RecyclerView的Item,则不能复用mTopShader,
//需要每次 new 一个新的对象
if(mTopShader == null) {
mTopShader = new LinearGradient((float) contentWidth / 2, requireBlurHeight, (float) contentWidth / 2, srcTopOrBottomPadding, new int[]{
0x00FFFFFF,
0xFFFFFFFF
}, null, Shader.TileMode.CLAMP);
}
mPaint.setShader(mTopShader);
mDrawMatrix.setScale(blurScale, blurScale);
mDrawMatrix.postTranslate(Math.round(dx), Math.round(dy));
canvas.drawBitmap(mTopBlurBitmap, mDrawMatrix, null);
mPaint.setXfermode(mBlurPorterDuffXfermode);
canvas.drawRect(0, srcTopOrBottomPadding, contentWidth, requireBlurHeight, mPaint);
//绘制下面模糊处理后的图片
float padding = contentHeight - requireBlurHeight;
mDrawMatrix.setScale(blurScale, blurScale);
mDrawMatrix.postTranslate(Math.round(dx), Math.round(padding + dy));
canvas.drawBitmap(mBottomBlurBitmap, mDrawMatrix, null);
//注意如果作为RecyclerView的Item,则不能复用mBottomShader,
//需要每次 new 一个新的对象
if(mBottomShader == null) {
mBottomShader = new LinearGradient((float) contentWidth/2, padding + overSrcPadding, (float) contentWidth/2, padding, new int[]{
0xFFFFFFFF,
0x00FFFFFF
}, null, Shader.TileMode.CLAMP);
}
mPaint.setShader(null);
mPaint.setShader(mBottomShader);
canvas.drawRect(0, padding + overSrcPadding, contentWidth, padding, mPaint);

//绘制中间的原图
mPaint.setShader(null);
mPaint.setXfermode(mSrcPorterDuffXfermode);
float srcScale = (float) contentWidth / (float) srcWidth;
mDrawMatrix.setScale(srcScale, srcScale);
mDrawMatrix.postTranslate(0, Math.round(srcTopOrBottomPadding));
canvas.drawBitmap(mSrcBitmap, mDrawMatrix, mPaint);
canvas.restoreToCount(save);
}
}

BlurImageView 得核心代码在 onDraw 里面。我们按照上面注释的顺序,一个一个来分析:


第1部分,我们声明了几个变量,用来辅助计算。为了方便理解,我画了如下示意图:


image.png


srcTopOrBottomPadding: 是原图按照比例缩放、居中摆放时空白的高度
overSrcPadding: 是模糊图片遮罩原图片的高度,也就是渐变模糊图片的高度
dx: 按照高度缩放时,缩放后的模糊图片的x方向的偏移
dy: 按照宽缩放时,缩放后的模糊图片的y方向的偏移
blurScale: 图上没有标出,是高斯模糊图片的缩放比例。确保高斯模糊的图片能够铺满


第2部分,这里的作用是绘制上下两部分的模糊图片,并对图片的部分进行渐变处理。以上面部分的图片为例,第一步先绘制已经处理好的 mTopBlurBitmap,这里设置了 Matrix ,在绘制过程中会对图片进行缩放和移动,让图片的位置摆放正确。第二步就是对部分图片进行渐变处理,这里合成模式选择了 DST_ATOP


最后一步绘制中间的原图,就大功告成了,点击启动就能看到渐变模糊效果了。文章最后就求一个免费的赞吧🥺🥺


作者:新一代螺丝工
链接:https://juejin.cn/post/7165030436366712845
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

Kotlin系列之听说这个函数的入参也是函数?

整洁是kotlin语法的一大特性,它主要体现在扩展函数,中缀调用,运算符重载,约定,以及对lambda表达式的活用,其实在用java写Android的时候我们已经遇到过lambda了,比如当你在设置一个控件的点击监听事件的时候 我们通常会这样写,然后就发现在...
继续阅读 »

整洁是kotlin语法的一大特性,它主要体现在扩展函数,中缀调用,运算符重载,约定,以及对lambda表达式的活用,其实在用java写Android的时候我们已经遇到过lambda了,比如当你在设置一个控件的点击监听事件的时候


image.png
我们通常会这样写,然后就发现在点击事件的参数部分,代码变灰然后还有一条波浪线,提示说匿名类View.OnClickListener()可以被替换成lambda表达式,所以我们就按照提示将这个点击事件转换成lambda


bindingView.button.setOnClickListener(v -> {});

很简洁的一行代码就生成了,其中v是参数,后面箭头紧跟着一个花括号,花括号里面就是你要写的逻辑代码,相信这个大家都清楚,而在kotlin中,做了进一步简化,它可以将这个lambda表达式放在括号外面,并且可以将参数省略


bindingView.button.setOnClickListener {}

代码更加简洁了,而lambda在kotlin中的表现远远不止这些,还可以将整个lambda作为一个函数的参数,典型的例子就是在使用标准库中的filter,map函数,或者Flow里面的操作符,举个例子,在一个名字的集合中,我们要对这个集合做一个过滤的操作,首字母为s的才可以被输出,代码如下


listOf("shifang","zhaoerzhu","sundashen").filter { it.startsWith("s") }

在这个例子中filter函数就是接收了一个lambda参数,我们将整个lambda表达式显示出来就是这样


listOf("shifang","zhaoerzhu","sundashen").filter { it -> it.startsWith("s") }

所以在kotlin中,将类似于filter这样可以接受lambda或者函数引用作为参数的函数,或者返回值是lambda或者函数引用的函数,称之为高阶函数,这篇文章,会从以下几点慢慢介绍高阶函数



  • 什么是函数类型

  • 如何去调用一个高阶函数

  • 给函数类型设置默认值

  • 返回值为函数类型的高阶函数

  • 内联函数

  • inline,noinline和crossinline修饰符

  • 在lambda中使用return


函数类型


我们刚开始学敲代码的时候,基本都是从数据类型开始学的,什么整数类型,浮点数类型,布尔值类型,都很熟悉了已经,到了kotlin这边,又多出来了一个函数类型,这是啥?我们刚刚说到filter是高阶函数,而入参是函数的才能被叫做是高阶函数,所以我们看看filter这个函数里面长什么样子的


ac1.png
我们看到filter的参数部分,predicate是变量,而冒号后面就是跟的参数类型了,我们终于看到函数类型长啥样了,一个括号,里面跟一个泛型T,其实也就是函数的参数类型,后面一个箭头,箭头后面跟着返回值类型,所以我们声明一个函数类型的变量可以这样做


val findName : (String) -> Boolean
val sum : (Int,Int) -> Int

括号里面就是函数的参数类型跟参数数量,箭头后面是函数的返回值类型,这个时候我们在想一个问题,既然是函数类型,那肯定接受的就是一个函数,我们知道在kotlin中一个函数如果什么也不用返回,那么这个函数的返回值可以用Unit的来表示


fun showMessage():Unit {
println()
}

但通常我们都是省略Unit


fun showMessage() {
println()
}

那是不是函数类型里面,返回值如果是Unit,我们也可以省略呢?这样是不行的,函数类型中就算这个函数什么都不返回,我们也要显示的将返回类型Unit表示出来,同样的,如果函数没有参数,也要指定一个空的括号,表示这个函数无参


val showMessage:() -> Unit

到了这里,我们就已经清楚了为什么在lambda表达式里{x,y -> x+y},或者开头那个例子,filter函数中{ it -> it.startsWith("s"),变量的类型都省略了,那就是因为这些变量类型已经在函数类型的声明中被指定了


当然函数类型也是可以为空的,同其他数据类型一样,当你要声明一个可空的函数类型的时候,我们可以这样做


val sum : (Int,Int) -> Int?

上述代码其实犯了一个错误,它并不能表示一个可空的函数类型,它只能表示这个函数的返回值可以为空,那如何表示一个可空的函数类型呢?我们应该在整个函数类型外面加一个括号,然后在括号后面指定它是可以为空的,像这样


val sum : ((Int,Int) -> Int)?

调用高阶函数


知道了函数类型以后,我们就要开始去手写高阶函数了,比如现在有一个需求,要求编辑框内输入的内容里面只能包含字母以及空格,其他的都要过滤掉,那我们就给String添加一个扩展函数吧,这个函数接受一个函数类型的变量,这个函数类型的参数是一个字符,返回类型是一个布尔值,表示符合条件的字符才可以被输出,我们看下这个函数如何实现


fun String.findLetter(judge:(Char) -> Boolean):String{
val mBuilder = StringBuilder()
for(index in indices){
if(judge(get(index))){
mBuilder.append(get(index))
}
}
return mBuilder.toString()
}

内部实现就是这样,对输入的字符串逐个字符进行遍历,通过调用judge函数来判断每个字符,符合条件的就输出,不符合的就过滤掉,高阶函数有了,我们现在去调用它


println("what 8is4 ko4tli3n".findLetter { it in 'a'..'z' || it == ' ' })
复制代码

整个花括号里面就是一个函数,它作为一个参数传递给findLetter,我们看下运行结果


 I  what is kotlin

完全按照条件输出,这样做的好处就是,如果下次需求变了,要求空格也不能输出,那么我们完全不需要去更改findLetter的代码,只需要更改一下作为函数类型的函数就可以了,就像这样


println("what 8is4 ko4tli3n".findLetter { it in 'a'..'z' })

运行结果就变成了


I  whatiskotlin

我们再换个例子,刚刚是给String定义了一个类似于过滤作用的函数,现在去定义一个映射作用函数,比如给输入的内容每个字符之间都用逗号隔开,我们该怎么做呢


fun String.turn(addSplit: (Char) -> String): String {
val mBuilder = StringBuilder()
for (index in indices){
if(index != indices.last){
mBuilder.append(addSplit(get(index)))
}else{
mBuilder.append(get(index))
}
}
return mBuilder.toString()
}

代码与findLetter相似,稍微做了一点变化,我们看到这个高阶函数的入参类型变成了(Char)->String,表示输入一个字符,返回的是一个字符串,函数类型addSplit在这里就充当着一个字符串的角色,我们看下如何去调用这个高阶函数


println("abcdefg".turn { "${it}," })

我们看见turn后面的花括号里面就一个字符串,这个字符串是每个字符后面追加一个逗号,我们看下运行结果


 I  a,b,c,d,e,f,g

函数类型的默认值


对于映射函数turn,我们再改下需求,某些场景下,我们输入什么就希望输出什么,比如用户设置昵称,基本是没有任何条件限制的,我们改造下turn函数,让它可以接收空的函数类型,那这个我们在刚刚函数类型那部分讲过,只需要在整个函数类型外面加个括号,然后加上可空标识就好了,改造完之后turn函数就变成了这样


fun String.turn(addSplit: ((Char) -> String)?): String {
val mBuilder = StringBuilder()
for (index in indices){
if(index != indices.last){
mBuilder.append(addSplit?.let { it(get(index)) })
}else{
mBuilder.append(get(index))
}
}
return mBuilder.toString()
}

看起来没什么问题,但是当你去调用这个turn函数,不传入任何函数类型参数的时候,我们发现代码提示报错了


ac2.png
理由是addSplit这个参数一定要有个值,也就是说必须得传点啥吗?也不一定,我们知道kotlin函数中,参数是可以设置默认值的,那么函数类型的参数当然也可以设置默认值,就算什么也不传,它默认有一种实现方式,这样不就好了吗,我们再改下turn函数


fun String.turn(addSplit: ((Char) -> String)? = { it.toString() }): String {
val mBuilder = StringBuilder()
for (index in indices) {
if (index != indices.last) {
mBuilder.append(addSplit?.let { it(get(index)) })
} else {
mBuilder.append(get(index))
}
}
return mBuilder.toString()
}

这样就不报错了,默认输入啥就输出啥,我们看下运行结果


 I  abcdefg

函数类型作为返回值


刚刚我们举的例子是作为入参的函数类型,现在我们看下作为返回值的函数类型,这个其实我们平时开发当中也经常遇到,比如在一段代码中由于某个或者某几个条件,决定的不是一个值,而是会走到不同的逻辑代码中,这个时候我们脑补下如果这些代码都写在一起那是不是一个函数就显的比较臃肿了,可读性也变差了,所以我们就像return某一个值一样,将一段逻辑代码也return出去,这样代码逻辑就显的清晰很多,我们新增一个combine函数,返回值是函数类型


fun String.combine(): (String) -> String {
val mBuilder = StringBuilder()
return {
mBuilder.append(it)
for (index in indices) {
if (index != indices.last) {
mBuilder.append("${get(index)},")
} else {
mBuilder.append(get(index))
}
}
mBuilder.toString()
}
}

combine不接收任何参数了,返回值变成了(String) -> String,我们现在尝试着调用combine函数看看会输出什么呢


println("abcdefg".combine())

  I  Function1<java.lang.String, java.lang.String>

我们看到并没有输出期望的结果,这个是为什么呢?我再回到代码中看看


image.png
我们发现在返回值代码的边上,标明的这个返回值是一个lambda,并不是一个String,这个也就是函数类型作为返回值造成的结果,返回的是一个函数,函数你不去执行它,怎么可能会有结果呢,所以执行这个函数的方法就是调用invoke


println("abcdefg".combine().invoke("转换字符串:"))

invoke方法我们还是很熟悉的,在java里面去反射某一个类里面的方法的时候,最终去执行这个method就是用的invoke,而kotlin里面的invoke其实还是一个约定,当lambda要去调用invoke函数去执行lambda本身的函数体时,invoke可以省略,直接在lambda函数体后面加()以及参数,至于约定这里就不展开说了,我会另起一篇文章单独讲,所以上面的代码我们还可以这样写


println("abcdefg".combine()("转换字符串:"))

两种写法的运行结果都一样的,结果都是


 I  转换字符串:a,b,c,d,e,f,g

内联函数


lambda带来的性能开销


我们刚刚看到一个lambda的函数需要调用invoke方法才可以执行,那么这个invoke方法从哪里来的呢?凭什么调用它这个函数就可以执行了呢?我们将之前写的代码转换成java找找原因


public static final Function1 combine(@NotNull final String $this$combine) {
final StringBuilder mBuilder = new StringBuilder();
return (Function1)(new Function1() {
public Object invoke(Object var1) {
return this.invoke((String)var1);
}

@NotNull
public final String invoke(@NotNull String it) {
Intrinsics.checkNotNullParameter(it, "it");
mBuilder.append(it);
int index = 0;
for(int var3 = ((CharSequence)$this$combine).length(); index < var3; ++index) {
if (index != StringsKt.getIndices((CharSequence)$this$combine).getLast()) {
mBuilder.append("" + $this$combine.charAt(index) + ',');
} else {
mBuilder.append($this$combine.charAt(index));
}
}
String var10000 = mBuilder.toString();
return var10000;
}
});
}

通过反编译我们看到,原来这个lambda表达式就是定义了一个回调方法是invoke的匿名类Function1,Function后面跟着的1其实就是参数个数,我们点到Function1里面看看


public interface Function1<in P1, out R> : Function<R> {
/** Invokes the function with the specified argument. */
public operator fun invoke(p1: P1): R
}

现在我们知道刚刚没有调用invoke方法的时候,为什么会输出那一段信息了,其实那个就是把整个接口名称输出打印出来,只有调用了invoke这个回调方法,才会真正的去执行逻辑代码,把真正的结果输出,与此同时,我们注意到在反编译代码中,每一次调用turn函数,都会生成一个Function1的对象,如果被多次调用的话,很容易会造成一定的性能损耗,针对这种情况,我们应该怎么去避免呢


inline


针对lamnda带来的性能开销,kotlin里面会使用inline修饰符去解决,用法也很简单,只要在高阶函数的最前面用inline去修饰就好了,我们新增一个inlineturn函数,与turn函数相似,只是用inline去修饰


fun String.turn(addSplit: (Char) -> String): String {
val mBuilder = StringBuilder()
for (index in indices) {
if (index != indices.last) {
mBuilder.append(addSplit(get(index)))
} else {
mBuilder.append(get(index))
}
}
return mBuilder.toString()
}

inline fun String.inlineturn(addSplit: (Char) -> String): String {
val mBuilder = StringBuilder()
for (index in indices) {
if (index != indices.last) {
mBuilder.append(addSplit(get(index)))
} else {
mBuilder.append(get(index))
}
}
return mBuilder.toString()
}

两个函数的代码基本相似,只是inlineturn函数是用inline修饰的,在kotlin里面,对这种用inline修饰的高阶函数称之为内联函数,我们去调用下这两个函数,然后反编译看看有什么区别吧


kotlin代码
println("abcdefg".turn { "${it}," })
println("abcdefg".inlineturn { "${it}," })

反编译后的java代码
String $this$inlineturn$iv = StringKt.turn("abcdefg", (Function1)null.INSTANCE);
System.out.println($this$inlineturn$iv);

$this$inlineturn$iv = "abcdefg";
int $i$f$inlineturn = false;
StringBuilder mBuilder$iv = new StringBuilder();
int index$iv = 0;
for(int var6 = ((CharSequence)$this$inlineturn$iv).length(); index$iv < var6; ++index$iv) {
if (index$iv != StringsKt.getIndices((CharSequence)$this$inlineturn$iv).getLast()) {
char it = $this$inlineturn$iv.charAt(index$iv);
int var8 = false;
String var10 = "" + it + ',';
mBuilder$iv.append(var10);
} else {
mBuilder$iv.append($this$inlineturn$iv.charAt(index$iv));
}
}
String var10000 = mBuilder$iv.toString();
$this$inlineturn$iv = var10000;
System.out.println($this$inlineturn$iv);

我们看到turn方法不出所料,每次调用都会生成一个Function1的对象,而inlineturn函数反编译后我们发现,这不就是将invoke方法里面的代码复制出来放到外面来执行吗,所以现在我们知道内联函数的工作原理了,就是将函数体复制到调用处去执行,而此时,内联函数inlineturn的函数类型参数addSplit就不再是一个对象,而只是一个函数体了


noinline和crossinline


我们现在已经有了一个概念了,inline修饰符什么时候适合使用



  • 当函数是一个高阶函数

  • 由于编译器需要将内联函数体代码复制到调用处,所以函数体代码量比较小的时候适合用inline修饰


但有些场景下,即使函数是高阶函数,也是不推荐使用inline修饰符的,比如说你的函数类型参数需要当作对象传给其他普通函数


inline fun String.inlineturn(addSplit: (Char)->String): String {
val mBuilder = StringBuilder()
for (index in indices) {
if (index != indices.last) {
mBuilder.append(addSplit(get(index)))
} else {
mBuilder.append(get(index))
}
}
turnAnother(addSplit)//这一行编译报错
return mBuilder.toString()
}

还有一种场景就是当你的函数类型参数是可空的


inline fun String.inlineturn(addSplit: ((Char)->String)?): String {//参数部分编译报错
val mBuilder = StringBuilder()
for (index in indices) {
if (index != indices.last) {
mBuilder.append(addSplit?.let { it(get(index)) })
} else {
mBuilder.append(get(index))
}
}
return mBuilder.toString()
}

这两段代码都会编译报错,而报错的信息也基本一致,信息当中都会有这一句提示



Add 'noinline' modifier to the parameter declaration



到了这里我们遇到了一个新的修饰符noinline,从字面意思上并联系上下文,我们知道了这个noinline的作用,就是在内联函数中,使用noinline修饰的函数类型参数可以不参与内联,它依然是一个对象,反编译的时候它依然会被转成一个匿名类,尽管它是在一个内联函数中。
我们使用noinline修饰符更改一下inlineturn函数,然后再反编译看看java代码中的区别


String $this$inlineturn$iv = StringKt.turn("abcdefg", (Function1)null.INSTANCE);
System.out.println($this$inlineturn$iv);

$this$inlineturn$iv = "abcdefg";
Function1 addSplit$iv = (Function1)null.INSTANCE;
int $i$f$inlineturn = false;
StringBuilder mBuilder$iv = new StringBuilder();
int index$iv = 0;
for(int var7 = ((CharSequence)$this$inlineturn$iv).length(); index$iv < var7; ++index$iv) {
if (index$iv != StringsKt.getIndices((CharSequence)$this$inlineturn$iv).getLast()) { mBuilder$iv.append((String)addSplit$iv.invoke($this$inlineturn$iv.charAt(index$iv)));
} else {
mBuilder$iv.append($this$inlineturn$iv.charAt(index$iv));
}
}
StringKt.turnAnother(addSplit$iv);
String var10000 = mBuilder$iv.toString();
$this$inlineturn$iv = var10000;
System.out.println($this$inlineturn$iv);

我们看到原本是将函数体复制出来的地方,现在变成了生成一个Function1的对象了,说明addSplit对象已经不参与内联了,而这个时候我们注意到了,inlineturn函数前面的inline修饰符有了一个警告,提示说这个修饰符已经不需要了,建议去掉


image.png
对于这种警告我觉得还是不能去忽略的,因为我们已经在反编译的代码中看到了,尽管addSplit不参与内联,但还是会将函数体的代码复制出来,对于编译器来讲还是会有损耗的,所以这种情况下还是把inline和noinline修饰符去掉,让它变成一个普通的高阶函数


现在我们再换个场景,有时候一个函数类型的对象它执行起来比较耗时,我们不能让它在主线程运行,那就必须在将这个对象套在一个线程里面运行


inline fun String.inlineturn(addSplit: (Char)->String): String {
val mBuilder = StringBuilder()
Runnable{
for (index in indices) {
if (index != indices.last) {
mBuilder.append(addSplit(get(index)))//addSplit这边编译报错
} else {
mBuilder.append(get(index))
}
}
}
return mBuilder.toString()
}

我们发现这边又编译报错了,内联函数怎么回事啊?事儿这么多。。。我们看下这次报错提示是什么



Can't inline 'addSplit' here: it may contain non-local returns. Add 'crossinline' modifier to parameter declaration 'addSplit'



意思是不能对addSplit进行内联,原因是调用函数类型addSplit的地方与内联函数inlineturn属于不同的域,或者在inlineturn里面调用addSplit属于间接调用,所以在kotlin里面,如果内联函数中调用的函数类型,与内联函数本身属于间接调用的关系,那么函数类型前面需要加上crossinline修饰符,表示加强内联关系,我们修改一下inlineturn函数,给addSplit加上
crossinline修饰符,代码就变成了


inline fun String.inlineturn(crossinline addSplit: ((Char)->String)): String {
val mBuilder = StringBuilder()
Runnable {
for (index in indices) {
if (index != indices.last) {
mBuilder.append(addSplit(get(index)))
} else {
mBuilder.append(get(index))
}
}
}
return mBuilder.toString()
}

学到这里我相信不少人已经对高阶函数有了一个比较清晰的了解了,其实我们在学习Flow的时候已经接触过这些高阶函数和内联函数了,比如我们看下map操作符里面


image.png
map就是一个内联函数,而它里面的transform参数就是一个被crossinline修饰的函数类型的挂起函数,因为map里面的函数体必需要运行在一个协程域里面,而map又是运行在另一个协程域里面,map与transform之间属于间接调用的关系,这才用crossinline修饰


在lambda中使用return


现在给String再增加一个扩展函数,功能很简单,遍历String里面的每个字符,然后将字符在lambda的参数里面打印出来,同时要求如果遍历到字母,那么就停止打印。


fun String.filterAndPrint(filter:(Char) -> Unit){
for (index in indices) {
filter(get(index))
}
}

private fun test() {
"153a667".filterAndPrint {
if(it in 'a'..'z'){
return
}
println(it)
}
println("outside of foreach")
}

代码大概就是这样去实现,但是我们发现写完代码后编译器在return的那个地方报错了,提示说这里不允许使用return



'return' is not allowed here



这个是什么原因呢,kotlin官方文档中有这么一段描述



要退出一个 lambda 表达式,我们必须使用一个标签,并且在 lambda 表达式内部禁止使用裸 return,因为 lambda 表达式不能使包含它的函数返回



kotlin为什么要这么设计呢?我们结合上面讲到的内联函数就清楚了,因为当我们在filterAndPrint函数里面return,退出的函数完全取决于它是不是内联函数,如果是,我们知道编译器会讲函数复制到外面调用处的位置,那么return的就是test函数,而如果不是内联,那么退出的就是filterAndPrint本身,所以对于这么一种可能会导致冲突的作法,kotlin就限制了在普通lambda表达式里面不能使用return,如果一定要用,必需加上标签,也就是在return后面加上@以及lambda所在的函数名,我们更改一下上面的test函数


private fun test() {
"153a667".filterAndPrint {
if(it in 'a'..'z'){
return@filterAndPrint
}
println(it)
}
println("outside of foreach")
}

加上标签以后编译器不报错了,我们看下运行结果


 I  1
I 5
I 3
I 6
I 6
I 7
I outside of foreach

我们看到reutrn@filterAndPrint的时候并没有跳出test函数,只是跳过了a,继续循环打印后面的字符,这个就很想java里面continue的作法,但我们的需求不是这样描述的,我们希望遇到字母以后就不打印后面的字符了,也就是直接跳出test函数,没错,就是将filterAndPrint变成内联函数就好了


inline fun String.filterAndPrint(filter:(Char) -> Unit){
for (index in indices) {
filter(get(index))
}
}

private fun test() {
"153a667".filterAndPrint {
if(it in 'a'..'z'){
return
}
println(it)
}
println("outside of foreach")
}

当lambda所在函数是内联函数的时候,lambda内部是可以return的,而且可以不用加标签,这个时候退出的函数就是调用内联函数所在的函数,也就是例子中的test(),我们把这种返回称为非局部返回,我们看下现在的运行结果


 I  1
I 5
I 3

现在这个才是我们想要的结果,现在回想一下当初刚开始学kotlin的时候,对没有break和continue关键字还有点不习惯,现在知道kotlin把这俩关键字去掉的原因了,因为完全不需要,一个return加上内联函数就够了,想在哪个地方退出循环就在哪个地方退出。


总结


这篇文章我们逐步从函数类型开始,慢慢的认识了高阶函数,会去写高阶函数,也掌握了inline,noinline,crossinline这些修饰符的作用以及使用场景,如果说之前你对高阶函数还很陌生的话,那么通过这篇文章,应该会对它熟悉一点了


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

从应用工程师的角度再谈车载 Android 系统

前言 根据中汽协数据显示,2022年8月中国汽车出口量达30.8万辆,同比增长65%,这也是历史上首次超过30万辆。从今年前八个月整体情况来看,我国汽车出口量已经超越德国,仅次于日本汽车出口量。其中,新能源汽车1-8月出口量同比增长超九成,贡献了重要的增量。...
继续阅读 »

前言



根据中汽协数据显示,2022年8月中国汽车出口量达30.8万辆,同比增长65%,这也是历史上首次超过30万辆。从今年前八个月整体情况来看,我国汽车出口量已经超越德国,仅次于日本汽车出口量。其中,新能源汽车1-8月出口量同比增长超九成,贡献了重要的增量。



众所周知,今年互联网行业发展的并不愉快,导致互联网行业就业形势不太理想,“开猿节流”的事情时有发生,于是不少Android开发萌生了转行做车载的想法,之前我其实写过一篇凑数用得 Android车载应用开发与分析(11)- 车载Android应用开发入门指南,这篇文章的初衷其实是劝Android开发的同学慎重转行搞车载!


不过还是有些和我一样是从手机应用转行做车载应用的同学读完后,希望我能再详细讲讲车载Android的学习,一些准备做车载的同学,也认为之前的博客写得太乱,于是决定从一个车载应用工程师的角度,重新来讲讲车载Android系统。


车载操作系统


汽车操作系统是从传统汽车电子不断演变而来的,传统汽车电子产品可分为两类:


一类是汽车电子控制装置,通过直接向执行机构(如电子阀门、继电器开关、执行马达)发送指令,以控 制车辆关键部件(如发动机、变速箱、动力电池)协同工作,这类系统一般统称为电子控制单元(ECU);


另一类是车载电子设备,如仪表、娱乐音响、导航系统、HUD等,这类系统不直接参与汽车行驶的控制 决策,不会对车辆行驶性能和安全产生影响,通常统称为车载信息娱乐系统(IVI)。这也是Android程序员主要负责的领域。


主流车载操作系统架构



当前国内主流车载操作系统的架构如上所示,左侧是汽车的中控、副驾屏幕,操作系统一般是Android,右侧是汽车的仪表屏幕,一般是QNX系统。



车载系统中还有一些Security、SOA、AutoSAR相关的模块,这些模块作为Android工程师属于知道了也插不上手,画出来也看不懂的东西,就全部省略了。



先来解释几个Android程序员可能不太熟悉的模块:


以太网


以太网(Ethernet),是一种计算机局域网技术,也是互联网从业者,天天打交道的东西。在汽车座舱中IVI硬件与其他硬件间通信有时需要借助以太网来实现,例如:MQTT、HTTP等。


CAN


控制器局域网 (Controller Area Network,简称CAN或者CAN bus) 是一种功能丰富的车用总线标准。被设计用于在不需要主机(Host)的情况下,允许网络上的单片机和仪器相互通信。 它基于消息传递协议,设计之初在车辆上采用复用通信线缆,以降低铜线使用量,后来也被其他行业所使用。


CAN 是车载领域很重要的一种通信总线,我们在中控屏上可以随时查看、设置车门、发动机、后备箱这些模块,其实就是借助CAN bus实现的,即使是Android程序员也经常要和它打交道,以后会详细讲讲这个东西。


MCU


微控制器单元,它负责着汽车很大一部分的功能,例如通过车载控制器对各项数据进行分析处理,以做出最优决策;负责对车辆的信息娱乐交互和运动控制等等。


总的来说,MCU可以应用于车辆的通讯、能源、存储、感知以及计算,对汽车行业有着重要的作用。


SOC


SoC的定义多种多样,由于其内涵丰富、应用范围广,很难给出准确定义。一般说来, SoC称为系统级芯片,也有称片上系统(System on Chip),意指它是一个产品,是一个有专用目标的集成电路,其中包含完整系统并有嵌入软件的全部内容。


车载Soc和常见的手机Soc非常类似,内部集成了CPU和GPU。目前最主流的车载Soc是高通的8155,它就是高通在手机Soc骁龙855的基础上发展而来的。


QNX


QNX是商业类Unix实时操作系统,主要针对嵌入式系统市场。QNX采取微核心架构,操作系统中的多数功能是以许多小型的task来执行,它们被称为server。这样的架构使得用户和开发者可以关闭不需要的功能,而不需要改变操作系统本身。


QNX的应用十分广泛,被广泛应用于汽车、轨道交通、航空航天等对安全性、实时性要求较高的领域,在汽车领域的市场占有率极高。



该产品开发于20世纪80年代初,后来改名为QNX软件系统公司,公司已被黑莓公司并购。



Hypervisor


一种运行在基础物理服务器和操作系统之间的中间软件层,可允许多个操作系统和应用共享硬件。也可叫做VMM( virtual machine monitor ),即虚拟机监视器。


目前国内主流的汽车座舱,都是在一个SOC上同时运行着两个不同特性的操作系统。对娱乐、应用生态有需求的中控、副驾一般由Android系统控制,而对稳定性、安全性要求较高的仪表盘,则由QNX系统直接控制,Android可以看做是一个运行在QNX上的虚拟系统,其底层技术原理就是Hypervisor。




其实以上说得这些都是从Android工程师角度看到的车载操作系统,实际上这只是车载操作系统的冰山一角,最底层的Other Hardware更能代表智能汽车操作系统的核心,它包含高级驾驶辅助系统、泊车辅助系统、自动驾驶系统、TCU、4G/5G网关、中央控制器等等。这些复杂的硬件与软件共同组成了一个智能汽车操作系统。


现代汽车的操作系统是如此的复杂,一些汽车的TCU、中央控制器甚至还额外运行着一套操作系统(例如linux),所以现在还没有哪一个汽车/主机厂商能够独立完成整套系统的开发,基本都需要依赖大量的第三方软、硬件供应商(笔者之前就是就职于一家汽车软件供应商,不过现在已经处于提桶状态了)。


好在作为Android程序员我们只需要关心Android系统的那部分。


车载 Android 系统


车载Android系统,又称Android Automotive,是对原始Android系统的一个功能扩充版本,在编译AOSP源码时可以看到相应的编译选项。



Android Automotive 编译后的原始界面如下所示,相信有过车载开发经验的同学对这个界面一定不陌生,我们正是在这个界面上把车载Android系统一点点搭建起来的。



Android Automotive


Android Automotive 是一个基于 Android 平台扩展后,适用于现代汽车的智能操作系统,可以直接运行为Android系统开发的应用。Android Automotive并非Android的分支或并行开发版本。它与手机和平板电脑等设备上搭载的Android使用相同的代码库,位于同一个存储区中。


Android Automotive与Android最大的区别在于,Android Automotive增加了对汽车特定要求、功能和技术的支持。



Google的官方文档:source.android.google.cn/docs/device…



Android Auto


除了Android Automotive,Google还推出了一个Android Auto。两者的命名方式可能有点让人迷惑不解。下面介绍了它们之间的区别:



  • Android Auto 是一个基于用户手机运行的平台,可通过 USB 连接将 Android Auto 用户体验投射到兼容的车载信息娱乐系统。Android Auto本质上就是一个运行在Android系统上的车载应用,与苹果的CarPlay,百度的CarLife类似。





  • Android Automotive 是一个可定制程度非常高的开源Android平台,它是一个完整的操作系统。




需要说明的是,使用Android Auto需要用户的手机支持Google服务框架,所以一般只在国内销售的汽车基本都不支持Android Auto,一些沿用了国外车机系统的合资车型可能会支持Android Auto。


车载 Android 应用


常见的车载应用


SystemUI


系统的UI。SystemUI是一个标准的android应用程序,它提供了系统UI的统一管理方案。
常见的状态栏、导航栏、消息中心、音量调节弹窗、蓝牙连接弹窗等一系列后台弹窗都是由SystemUI模块负责管理。


开发难度:SystemUI作为Android系统启动的第一个带有UI的应用程序,对启动性能和稳定性都有很高的要求。SystemUI需要管理的模块非常多,导致开发任务比较繁重,有的车载项目会要求SystemUI兼容原有的应用层API,那么开发难度还会上升。开发人员需要对Android原生的SystemUI源码有一定的了解。


Launcher


Android系统的桌面。


开发难度:Launcher是与用户交互最多的应用程序之一,同样对启动性能和稳定性都有很高的要求。Launcher开发难度主要集中在与3D车模的互动(如果有3D模型),可能需要支持Widget的显示(WidgetHost),各种应用的拖动和编辑等。开发人员最好对Android原生的Launcher源码有一定的了解。


Settings


系统设置,是车载Android系统中非常重要的一个系统级应用,是整个车载IVI系统的控制中心,整车的音效、无线通信、状态信息、安全信息等等都是需要通过系统设置来查看和控制。


开发难度:系统设置主要难度都集中在对Android Framework层API的理解上,例如蓝牙、Wi-Fi设置就需要开发人员对系统级API有一定的了解,这些内容往往都需要阅读Android原生应用的源码才能了解,所以系统设置也是一个开发难度比较大的车载应用。


CarService


车载Android系统的核心服务之一,所有应用都需要通过CarService来查询、控制整车的状态。例如:车辆的速度、档位、点火状态等等。


VehicleSettings


车辆设置,更常用的叫法是『车控车设』。负责管理整个车辆内外设置项的应用,主要与CarService进行数据交互。可设置项非常多。驾驶模式、方向盘助力、后视镜折叠、氛围灯、座舱监测、无线充电等等。


开发难度:主要难度集中在复杂多变的UI,有的主机厂商会在HMI中引入3D化的交互模型,就还需要考虑与3D模型间的通信,同时还需要熟练运用CAN工具来模拟汽车的CAN信号用于调试和开发。


HVAC


空调。负责管理整个车辆空调的应用,主要与CarService进行数据交互。


开发难度:和『车控车设』类似。


Map


地图,车载系统的核心功能之一,负责导航和语音提示等功能。不同的主机厂商有不同的开发方式。不外乎有三种:


1)选择使用百度、高德的地图SDK自行开发导航应用;


2)将导航模块外包给百度、高德,由地图供应商进行定制化开发;


3)直接集成地图供应商已有的车载版本的应用;


开发难度:主要集中在对地图SDK的运用和理解上,而且地图应用属于对性能要求较高的模块。


Multi-Media


多媒体应用。一般包含图片浏览、在线音视频播放器、USB音视频播放器、收音机等。




车载的应用远不止以上说得这些,根据不同的需求,还有非常多的Service需要做定制化开发,这里只列举了最常见的应用类型。


汽车上还会有一些第三方的应用,常见的有QQ音乐、微信、QQ、抖音、讯飞输入法等等,这些应用主机厂商不会获得源码,一般只会拿到一个apk,直接集成到Android系统中即可。


车载应用与移动应用的区别


夸张一点说,移动端的应用开发和车载应用开发,完全就不是一个技术思路。总结一下大致有以下几个区别:


1)应用级别不同


多数车载应用属于系统级应用,可以调用Android SDK内部隐藏的API,也不需要动态地向用户申请权限。移动应用是普通应用,系统对其限制很多,需要遵守Android应用的开发规范。


由于车载应用是系统级应用,所以移动端很多常用的技术比如热修复、插件化基本都不会采用,但是相对的进程保活、开机自启就变得非常简单了。


2)迭代方式不同


移动应用只要用户的手机接入了WiFi就可以进行在线升级,所以移动应用多采用小步快跑的形式,进行快速迭代。


车载系统级应用的更新只能以整车OTA的形式进行,而OTA可能会消耗宝贵的车机流量,所以车载应用在SOP(量产)前,就必须完成全部需求的开发,而且不能出现严重的bug。在正式交付用户前,车厂内部或4S店还会进行几次OTA升级用做最后的bug修复。(如果在交付用户后还有严重的bug或需求未完成,那么大概率项目经理、程序员都要祭天了)


3)技术路线不同


正是因为车载应用对稳定性的要求极高,所以车载应用在开发时,对待新型技术会非常的慎重,比如,目前只有少数主机厂商在使用Kotlin开发车载应用,毕竟Android Framework都还没有改成Kotlin,大部分厂商对Kotlin的积极性不高。而且车载应用也不允许随意使用开源框架,如果必须使用,务必注意框架的开源协议,以免给汽车厂商带来不必要的麻烦。


4)运行环境不同


车载应用的运行环境是经过高度定制化的Android系统,定制化也就意味着bug。移动端的应用出现bug时,我们的第一反应是应用的代码有缺陷。车载应用发现bug也要考虑到是不是系统本身出现了bug,这是一件非常有挑战性的事,应用开发与系统开发相互扯皮、泼脏水也属于家常便饭。


车载应用需要掌握的技能


除了一般Android开发需要学习的基础内容外,一名优秀的车载应用工程师还需要掌握以下的技能


1)MVVM架构


虽然如今一些移动端应用已经开始尝试MVI架构,但是就像前面说得,车载应用对待新技术都会持观望态度,目前主流的车载应用还是采用基于Jetpack组件的MVVM架构。


2)构建系统级应用


由于多数车载应用都属于系统级应用,所以必须了解如何构建一个系统级应用,这方面的内容可以看我之前写得Android车载应用开发与分析(11)- 车载Android应用开发入门指南,虽然写得比较乱凑活看吧。


还有一本比较老的书《Android深度探索:系统应用源代码分析与ROM定制》也可以看一看。


3)性能优化


应用的性能优化是个亘古不变的话题,掌握应用的各种性能优化方式,也是一个Android程序员必备的生存手段,汽车座舱的SOC性能比旗舰手机要差不少,如果优化好车载应用将是一个非常有挑战性的任务。


4)IPC通信


Android中最常用的跨进程通信手段是Binder,因为有大量的Service需要与应用进行交互,所以基于Binder的AIDL在车载应用开发中使用得非常广泛,学会使用AIDL也同样属于必备技能之一。


5)CAN仿真测试工具


CAN仿真测试工具包含了软件和硬件,在车载应用开发时我们需要借助这些工具来模拟发送CAN性能给到IVI来调试我们的应用,在实车调试阶段,也需要借助这些工具来捕获车辆的CAN信号来分析一些bug。常用的有CAN alyzer、CANoe、TS-Master等等,这些工具价格都极其昂贵,独自购买不现实,在车载应用开发务必把握学习和使用的机会。


6)系统应用源码


这一项是我认为最重要的,不少车载应用层项目都是反复定制各种SystemUI、Launcher、Settings等等,读懂Android系统应用源码对我们定制化开发这些应用有非常大的好处。




以上是一些我认为车载应用开发时需要掌握的技能,其他的一些诸如:adb调试指令、Linux操作系统的运用、AOSP源码编译也都需要额外学习,根据不同的需求,JNI、NDK等技术也有可能会用上。


车载应用开发者的未来


这篇文章的开头提到了一则新闻,中国今年的汽车出口量已经超越德国仅次于日本,这似乎是一个振奋人心的消息。汽车工业的高速发展,对我们这些车载程序员当然属于利好,但是最近的一则消息又让我改变了看法。



9月29日,零跑汽车正式赴港上市。成为众人意料之外继“蔚小理”后的又一新秀。但是零跑汽车的成绩似乎并没有得到资本市场的认可,在其上市首日,股价便遭遇大跌。根据数据显示,9月29日当日收盘,零跑汽车的股价为31.9港元/股票,相较发行价暴跌33.54%。而随后的半个月以来,零跑汽车的股价更是下跌56%,市值蒸发343亿港元。



一边是汽车出口量大增,另一边是新势力造车第二梯队的零跑上市即破发,并且两个交易日股价即腰斩,虽然有叠加疫情的影响,但这也说明了资本市场对造车企业的热情正在显著减弱,如果投资人赚不到丰厚的回报,那么以后的车企日后想要再从市场融资,恐怕不会是一件轻松的事。


以上说得都是大环境,但是作为技术人本职工作还是磨炼自己的技术为主。



回过头我们还要再看一下这张架构图,图中标蓝的部分是应用开发可以发挥的地方。不知道你有没有发现,应用实际上在车载操作系统中占据的比例很小,而且技术门槛也不高,这基本决定了在车载这个领域,单纯的Android应用开发前景并不广阔。


但是庞大的车载系统让应用开发者有了继续深入研究与实践的可能,那么是卷Framework还是Native或是HAL就需要你自己决定了!


最后总结一句,移动端的应用开发和车载应用开发,本质上走得两套技术路线,所以要慎重转行!如果已经决定,请务必趁早!


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

费解,遇到的一个 JDK 序列化 Bug !

1、背景 最近查看应用的崩溃记录的时候遇到了一个跟 Java 序列化相关的崩溃, 从崩溃的堆栈来看,整个调用堆栈里没有我们自己的代码信息。崩溃的起点是 Android 系统自动存储 Fragment 的状态,也就是将数据序列化并写入 Bundle 时。最终出...
继续阅读 »

1、背景


最近查看应用的崩溃记录的时候遇到了一个跟 Java 序列化相关的崩溃,


截屏2023-01-26 19.09.08.png


从崩溃的堆栈来看,整个调用堆栈里没有我们自己的代码信息。崩溃的起点是 Android 系统自动存储 Fragment 的状态,也就是将数据序列化并写入 Bundle 时。最终出现问题的代码则位于 ArrayList 的 writeObject() 方法。


这里顺带说明一下,一般我们在使用序列化的时候只需要让自己的类实现 Serializable 接口即可,最多就是为自己的类增加一个名为 SerialVersionUID 的静态字段以标志序列化的版本号。但是,实际上序列化的过程是可以自定义的,也就是通过 writeObject()readObject() 实现。这两个方法看上去可能比较古怪,因为他们既不存在于 Object 类,也不存在于 Serializable 接口。所以,对它们没有覆写一说,并且还是 private 的。从上述堆栈也可以看出,调用这两个方法是通过反射的形式调用的。


2、分析


从堆栈看出来是序列化过程中报错,并且是因为 Fragment 状态自动保存过程中报错,报错的位置不在我们的代码中,无法也不应该使用 hook 的方式解决。


再从报错信息看,是多线程修改导致的,也就是因为 ArrayList 并不是线程安全的,所以,如果在调用序列化的过程中其他线程对 ArrayList 做了修改,那么此时就会抛出 ConcurrentModificationException 异常。


但是! 再进一步看,为了解决 ArrayList 在多线程环境中不安全的问题,我这里是用了同步容器进行包装。从堆栈也可以看出,堆栈中包含如下一行代码,


Collections$SynchronizedCollection.writeObject(Collections.java:2125)

这说明,整个序列化的操作是在同步代码块中执行的。而就在执行过程中,其他线程完成了对 ArrayList 的修改。


再看一下报错的 ArrayList 的代码,


private void writeObject(java.io.ObjectOutputStream s) throws java.io.IOException {
// Write out element count, and any hidden stuff
int expectedModCount = modCount; // 1
s.defaultWriteObject();

// Write out size as capacity for behavioural compatibility with clone()
s.writeInt(size);

// Write out all elements in the proper order.
for (int i=0; i<size; i++) {
s.writeObject(elementData[i]);
}

if (modCount != expectedModCount) { // 2
throw new ConcurrentModificationException();
}
}

也就是说,在 writeObject 这个方法执行 1 和 2 之间的代码的时候,容器被修改了。


但是,该方法的调用是位于同步容器的同步代码块中的,这里出现同步错误,我首先想到的是如下几个原因:



  1. 同步容器的同步锁没有覆盖所有的方法:基本不可能,标准 JDK 应该还是严谨的 ...

  2. 外部通过反射直接调用了同步容器内的真实数据:一般不会有这种骚操作

  3. 执行序列化过程的过程跳过了锁:虽然是反射调用,但是代码逻辑的执行是在代码块内部的

  4. 执行序列化方法的过程中释放了锁


3、复现


带着上述问题,首先还是先复现该问题。


该异常还是比较容易复现,


private static final int TOTAL_TEST_LOOP = 100;
private static final int TOTAL_THREAD_COUNT = 20;

private static volatile int writeTaskNo = 0;

private static final List<String> list = Collections.synchronizedList(new ArrayList<>());

private static final Executor executor = Executors.newFixedThreadPool(TOTAL_THREAD_COUNT);

public static void main(String...args) throws IOException {
for (int i = 0; i < TOTAL_TEST_LOOP; i++) {
executor.execute(new WriteListTask());
for (int j=0; j<TOTAL_THREAD_COUNT-1; j++) {
executor.execute(new ChangeListTask());
}
}
}

private static final class ChangeListTask implements Runnable {

@Override
public void run() {
list.add("hello");
System.out.println("change list job done");
}
}

private static final class WriteListTask implements Runnable {

@Override
public void run() {
File file = new File("temp");
OutputStream os = null;
ObjectOutputStream oos = null;
try {
os = new FileOutputStream(file);
oos = new ObjectOutputStream(os);
oos.writeObject(list);
oos.flush();
os.flush();
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
oos.close();
os.close();
} catch (IOException e) {
e.printStackTrace();
}
}
System.out.println(String.format("write [%d] list job done", ++writeTaskNo));
}
}

这里创建了一个容量为 20 的线程池,遍历 100 次循环,每次往线程池添加一个序列化的任务以及 19 个修改列表的操作。


按照上述操作,基本 100% 复现这个问题。


4、解决


如果只是从堆栈看,这个问题非常“诡异”,它看上去是在执行序列化的过程中把线程的锁匙放了。所以,为了找到问题的原因我做了几个测试。


当然,我首先想到的是为了解决并发修改的问题,除了使用同步容器,另外一种方式是使用并发容器。ArrayList 对应的并发容器是 CopyOnWriteArrayList换了该容器之后可以修复这个问题。


此外,我用自定义同步锁的形式在序列化操作的外部对整个序列化过程进行同步,这种方式也可以解决上述问题


不过,虽然解决了这个问题,此时还存在一个疑问就是序列化过程中锁是如何“丢”了的。为了更好地分析问题,我 Copy 了一份 JDK 的 SynchronizedList 的源码,并使用 Copy 的代码复现上述问题,试了很多次也没有出现。所以,这成了“看上去一样的代码,但是执行起来结果不同” 😓


最后,我把这个问题放到了 StackOverflow 上面。国外的一个开发者解答了这个问题,


截屏2023-01-26 22.14.56.png


就是说,


这是 JDK 的一个 bug,并且到 OpenJDK 19.0.2 还没有解决的一个问题。bug 单位于,


bugs.openjdk.org/browse/JDK-…


这是因为当我们使用 Collections 的方法 synchronizedList 获取同步容器的时候,


public static <T> List<T> synchronizedList(List<T> list) {
return (list instanceof RandomAccess ?
new SynchronizedRandomAccessList<>(list) :
new SynchronizedList<>(list));
}

它会根据被包装的容器是否实现了 RandomAccess 接口来判断使用 SynchronizedRandomAccessList 还是 SynchronizedList 进行包装。RandomAccess 的意思是是否可以在任意位置访问列表的元素,显然 ArrayList 实现了这个接口。所以,当我们使用同步容器进行包装的时候,返回的是 SynchronizedRandomAccessList 这个类而不是 SynchronizedList 的实例.


SynchronizedRandomAccessList,它有一个 writeReplace() 方法


private Object writeReplace() {
return new SynchronizedList<>(list);
}

这个方法是用来兼容 1.4 之前版本的序列化的,所以,当对 SynchronizedRandomAccessList 执行序列化的时候会先调用 writeReplace() 方法,并将被包装的 list 对象传入,然后使用该方法返回的对象进行序列化而不是原始对象。


对于 SynchronizedRandomAccessList,它是 SynchronizedList 的子类,它们对私有锁的实现机制是相同的,即,两者都是对自身的实例 (也就是 this)进行加锁。所以,两者持有的 ArrayList 是同一实例,但是加锁的却是不同的对象。也就是说,序列化过程中加锁的对象是 writeReplace() 方法创建的 SynchronizedList 的实例,其他线程修改数据时加锁的是 SynchronizedRandomAccessList 对象。


验证的方式比较简单,在 writeObject() 出打断点获取 this 对象和最初的同步容器返回结果做一个对比即可。


总结


一个略坑的问题,问题解决比较简单,但是分析过程有些曲折,主要是被“锁在序列化过程被释放了”这个想法误导。而实际上之所以出现这个问题是因为加锁的是不同的对象。此外,还有一个原因是,序列化过程许多操作是反射执行的,比如 writeReplace()writeObject() 这些方法。


从这个例子中可以得出的另一个结论就是,同步容器和并发容器实现逻辑不同,看来在有些情形下两者起到的效果还是有区别的。序列化可能是一个极端的例子,但是下次序列化一个列表的时候是否应该考虑到 JDK 的这个 bug 呢?


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

@JvmDefaultWithCompatibility优化小技巧,了解一下~

今天写这篇文章主要是有两个原因: 不是为了卷,只是为了希望能在明天过年前升到5级,拿个优秀创作者的称号,提前祝大家新年快乐; 最近项目kotlin插件升级到了1.6.21,咬着牙把官方英文文档看了下,发现一些有用的知识分享给大家; 本篇文章主要是介...
继续阅读 »

今天写这篇文章主要是有两个原因:




  1. 不是为了卷,只是为了希望能在明天过年前升到5级,拿个优秀创作者的称号,提前祝大家新年快乐;




  2. 最近项目kotlin插件升级到了1.6.21,咬着牙把官方英文文档看了下,发现一些有用的知识分享给大家;




本篇文章主要是介绍下1.6.20提供了的一个新特性-Xjvm-default=all和搭配使用的@JvmDefaultWithCompatibility注解:


image.png


不过在讲解这个之前,我们需要一些准备知识。


前置知识-Kotlin接口默认方法实现机制


大家应该都知道Kotlin接口的方法是可以默认实现的:


interface ICallback {

fun execute() {
println("execute...")
}
}

看着确实是对接口方法实现了默认重写,但真的是表面上这样的吗?子类真的不需要实现方法了吗?


下面我们简单证明下:搞一个java类实现这个接口,不重写任何方法,看看会不会报错


image.png


很明显报错了,提示我们子类需要重写接口的execute()方法,所以我们可以得出一个结论:Kotlin接口的方法的默认实现是伪实现


那kotlin的这个伪实现的实现原理是啥呢,这里我们反编译下java代码看一看:


image.png


很明显的看到,ICallback接口的方法还是个抽象方法,并没有默认实现(这就是为什么java直接实现这个接口会报错的原因)。其次还生成了一个DefaultImpls中间类,这个中间类提供了真正默认实现的execute()方法逻辑。


当我们kotlin子类实现这个接口时:


class ICallbackChild2 : ICallback

这样写并不会发生任何报错,我们反编译成java代码看下:


image.png


可以看到,编译器会默认帮助我们实现接口的execute()方法,并调用了DefaultImpls类中的execute()完成了默认实现。


以上就是kotlin接口方法默认实现的原理,真正的实现逻辑通过一个默认生成的DefaultImpls类去完成。


现在我们思考下,为什么kotlin要这么实现呢,直接借助java的default关键字不可以吗,上面这种实现还多了一个类的开销?


Kotlin官方当然也发现了这个问题,所以在kotlin1.6.20提供了-Xjvm-default=all这个compile option来进行优化,接下来听我一一介绍。


-Xjvm-default=all登场


想要使用这个,需要在Android Studio中build.gradle增加下面配置:


kotlinOptions {
jvmTarget = '1.8'
freeCompilerArgs += "-Xjvm-default=all"
}

这个完成之后,我们还是拿上面的接口作为例子讲解:


interface ICallback {

fun execute() {
println("execute...")
}
}

我们再次反编译成java代码看下:


image.png


可以看到,借助了default关键字完成了接口方法的默认实现,并且没有生成上面的DefaultImpls中间类,算是一个很不错的优化。


如果我们项目中之前定义了很多的kotlin接口默认方法实现,那这个编译优化可以帮助你减少很多中间类的生成。


这里我们再次思考一下,我们突然增加了这个compile option消除了DefaultImpls类,但是假如之前的代码有使用到这个类怎么办呢?我们不太可能挨个每个地方的去调整原来的业务代码,这样工作量就非常大了。


所以kotlin官方贴心的提供了@JvmDefaultWithCompatibility注解做了一个兼容处理,接下来听我一一介绍。


@JvmDefaultWithCompatibility做个兼容


先上一张官方图,最需要注意的就是第一行和最后一行:


image.png


在我们增加了上面的-Xjvm-default=all之后,借助default消除了DefaultImpls这个帮助类后,我们还可以通过@JvmDefaultWithCompatibility这个注解指定哪个接口保留这个DefaultImpls类,因为其他地方可能需要显示调用这个类


这里我们还是以上面的ICallback接口为例:


@JvmDefaultWithCompatibility
interface ICallback {

fun execute() {
println("execute...")
}
}

我们反编译成java代码看下:


image.png


可以看到,使用default实现了默认方法,并且DefaultImpls类依然存在,这就对过去kotlin接口的方法默认实现保持了兼容,尽量避免对业务逻辑的影响。


总结


其实kotlin之前有提供-Xjvm-default=all-compatibility和注解@JvmDefaultWithoutCompatibility搭配,不过这样对于业务开发不太友好,比如新增接口容易漏掉注解添加,再比如可能会对业务逻辑非public部分代码入侵过深等。


所以这里官方又提供了-Xjvm-default=all@JvmDefaultCompatibility搭配使用。希望本篇文章对你有所帮助。


新年快乐



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

Android通知栏增加快捷开关的技术实现

我们通常可以在通知栏上看到“飞行模式”、“移动数据”、“屏幕录制”等开关按钮,这些按钮都属于通知栏上的快捷开关,点击快捷开关可以轻易调用某种系统能力或打开某个应用程序的特定页面。那是否可以在通知栏上自定义一个快捷开关呢?答案是可以的,具体是通过TileServ...
继续阅读 »

Android通知栏增加快捷开关的技术实现

我们通常可以在通知栏上看到“飞行模式”、“移动数据”、“屏幕录制”等开关按钮,这些按钮都属于通知栏上的快捷开关,点击快捷开关可以轻易调用某种系统能力或打开某个应用程序的特定页面。那是否可以在通知栏上自定义一个快捷开关呢?答案是可以的,具体是通过TileService的方案实现。   

TileService继承自Service,所以它也是Android的四大组件之一,不过它是一个特殊的组件,开发者不需要手动开启调用,系统可以自动识别并完成调用,系统会通过绑定服务(bindService)的方式调用。

创建使用:

快捷开关是Android 7(target 24)的新能力,因此在使用该能力前必须先判断版本大小(大于等于target 24)。

1、自定义一个TileService类。

class MyQSTileService: TileService() {
  override fun onTileAdded() {    
super.onTileAdded()
}

  override fun onStartListening() {    
super.onStartListening()
}

  override fun onStopListening() {    
super.onStopListening()
}

  override fun onClick() {    
super.onClick()
}

  override fun onTileRemoved() {    
super.onTileRemoved()
}
}

TileService是通过绑定服务(bindService)的方式被调用的,因此,绑定服务生命周期包含的四种典型的回调方法(onCreate()、onBind()、onUnbind()和 onDestroy())都会被调用。但是,TileService也包含了以下特殊的生命周期回调方法:

  • onTileAdded():当用户从编辑栏添加快捷开关到通知栏的快速设置中会调用。
  • onTileRemoved():当用户从通知栏的快速设置移除快捷开关时调用。
  • onClick():当用户点击快捷开关时调用。
  • onStartListening():当用户打开通知栏的快速设置时调用。当快捷开关并没有从编辑栏拖到设置栏中不会调用。在TileAdded添加之后会调用一次。
  • onStopListening():当用户打开通知栏的快速设置时调用。当快捷开关并没有从编辑栏拖到设置栏中不会调用。在TileRemoved移除之前会调用一次。

2、在应用程序的清单文件中声明TileService

<service
android:name=".MyQSTileService"
android:label="@string/my_default_tile_label"
android:icon="@drawable/my_default_icon_label"
android:exported="true"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter>
</service>

  • name:自定义的TileService的类名。
  • label:快捷开关在通知栏上显示的名称。
  • icon:快捷开关在通知栏上显示的图标。
  • exported:该服务能否被外部应用调用。该属性必须为true。如果为false,那么快捷开关的功能将失效,原因是exported="false"时,TileService将不支持外部应用调起,手机系统自然不能再和该快捷开关交互。必须配置。
  • permission:需要给service配置的权限,BIND_QUICK_SETTINGS_TILE即允许应用程序绑定到第三方快速设置。必须配置。
  • intent-filter:意图过滤器,只有匹配内部的action,才能调起该service。必须配置。

监听模式

TileService的监听模式(或理解为启动模式)有两种,一种是主动模式,另一种是标准模式。

  • 主动模式

在主动模式下,TileService被请求时该服务会被绑定,并且TileService的onStartListening也会被调用。该模式需要在AndroidManifeast清单文件中声明:

<service ...>
<meta-data android:name="android.service.quicksettings.ACTIVE_TILE"
android:value="true" />
...
</service>

通过TileService.requestListeningState()这一静态方法,就可以实现对TileService的请求,示例如下:

      TileService.requestListeningState(
applicationContext, ComponentName(
BuildConfig.APPLICATION_ID,
MyQSTileService::class.java.name
)
)

主动模式下值得注意的是:

  • 用户在通知栏快速设置的地方点击快捷开关时,TileService会自动完成绑定、TileService的onStartListening会被调用。
  • TileService无论是通过点击被绑定还是通过requestListeningState请求被绑定,TileService所在的进程都会被调起。

标准模式

     在标准模式下,TileService可见时(即用户下拉通知栏看见快捷开关)该服务会被绑定,并且TileService的onStartListening也会被调用。标准模式不需要在AndroidManifeast清单文件中进行额外的声明,默认就是标准模式。

标准模式下值得注意的是:

  • 和主动模式相同,TileService被绑定时,TileService所在的进程就会被调起。
  • 而和主动模式不同的是,标准模式绑定TileService是通过用户下拉通知栏实现的,这意味着TileService所在的进程会被多次调起。因此为了避免主进程被频繁调起、避免DAU等数据统计受到影响,我们还需要为TileService指定一个特定的子进程,在Androidmanifest清单文件中设置:
      <service
......
android:process="自定义子进程的名称">
......
</service>

更新快捷开关

如果需要对快捷开关的数据进行更新,可以通过getQsTile()获取快捷开关的对象,然后通过setIcon(更新icon)、setLable(更新名称)、setState(更新状态,包括STATE_ACTIVE——表示开启或启用状态、STATE_INACTIVE——表示关闭或暂停状态、STATE_UNAVAILABLE:表示暂时不可用状态,在此状态下,用户无法与您的磁贴交互)等方法设置快捷开关新的数据,最后调用updateTile()方法实现。

  override fun onStartListening() {
super.onStartListening()
if (qsTile.state === Tile.STATE_ACTIVE) {
qsTile.label = "inactive"
qsTile.icon = Icon.createWithResource(context, R.drawable.inactive)
qsTile.state = Tile.STATE_INACTIVE
} else {
qsTile.label = "active"
qsTile.icon = Icon.createWithResource(context, R.drawable.active)
qsTile.state = Tile.STATE_ACTIVE
}
qsTile.updateTile()
}

操作快捷开关

  • 如果想要实现点击快捷开关时、关闭通知栏并跳转到某个页面,可以调用以下方法:
startActivityAndCollapse(Intent intent)

  • 如果想要在点击快捷开关时弹出对话框进行交互,可以调用以下方法:
override fun onClick() {
super.onClick()
if(!isLocked()) {
showDialog()
}
}

因为快捷开关有可能在用户锁屏时出现,所以必须加上isLocked()的判断。只有非锁屏的情况下,对话框才会出现。

  • 如果快捷开关含有敏感信息,需要使用isSecure()进行设备安全性判断,当设备安全时,才能执行快捷开关相关的逻辑(如点击的逻辑)。当设备不安全时(手机处于锁屏状态时),可调用unlockAndRun(Runnable runnable),提示用户解锁屏幕并执行自定义的runnable操作。
收起阅读 »

Flutter图片与文件选择器

春节已过,今天是开工的第一天。我已经一个多星期没碰过电脑了,今日上班,打开电脑的第一件事就是想着写点什么。反正大家都还沉浸在节后的喜悦中,还没进入工作状态,与其浪费时间,不如做些更有意义的事情。 今天就跟大家简单分享一下Flutter开发过程中经常会用到的图片...
继续阅读 »

春节已过,今天是开工的第一天。我已经一个多星期没碰过电脑了,今日上班,打开电脑的第一件事就是想着写点什么。反正大家都还沉浸在节后的喜悦中,还没进入工作状态,与其浪费时间,不如做些更有意义的事情。


今天就跟大家简单分享一下Flutter开发过程中经常会用到的图片和文件选择器。


一、image_picker


一个适用于iOS和Android的Flutter插件,能够从图像库中选取图片、视频,还能够调用相机拍摄新的照片。


该插件由Flutter官方提供,github的Star高达16.7k,算是比较成熟且流行的插件了。


1、安装


flutter pub add image_picker

或者


/// pubspec.yaml文件添加依赖,并在执行flutter pub get命令
dependencies
image_picker: ^0.8.6+1

2、使用


import 'package:image_picker/image_picker.dart';

/// 图片选取
Future<void> getImage() async {
final XFile? file = await ImagePicker().pickImage(
source: ImageSource.gallery, // 图库选择
maxWidth: 1000.0, // 设置图片最大宽度,间接压缩了图片的体积
);

/// 选取图片失败file为null,要注意判断下。
/// 获取图片路径后可以上传到服务器上
print('${file?.path}');
}

/// 视频选取
Future<void> getImage() async {
final XFile? file = await ImagePicker().pickVideo(
source: ImageSource.camera, // 调用相机拍摄
);

print('${file?.path}');
}

在项目中,调用getImage方法就会打开图片选择器。


image.png


image.png


3、属性



  • source


图片来源,ImageSource.gallery图片库中选择,ImageSource.camera调用相机拍摄新图片。



  • maxWidth


图片的最大宽度,source为ImageSource.camera时有用,等于间接的压缩了图片的体积。如果不设置,以目前手机的相机性能,动不动就拍出了4、5M的照片,对于app来说,图片上传到服务端,将会很慢,建议设置此属性。


4、注意


iOS端如果出现闪退并且控制台报出:



The app's Info.plist must contain an NSPhotoLibraryAddUsageDescription key with a string value explaining to the user how the app uses this data.



那么,需要打开Xcode在Info.plist配置隐私提示语。


image.png


二、flutter_document_picker


文档选择器,image_picker只能选择图片和视频,如果要选择PDF,word文档、excel表格等就没办法了。这个时候可以使用flutter_document_picker插件,直接选取手机中的文件。


1、安装


flutter pub add flutter_document_picker

或者


/// pubspec.yaml文件添加依赖,并在执行flutter pub get命令
dependencies
flutter_document_picker: ^5.1.0

2、使用


import 'package:image_picker/image_picker.dart';

/// 图片选取
Future<void> getDocument() async {
FlutterDocumentPickerParams? params = FlutterDocumentPickerParams(
// 允许选取的文件拓展类型,不加此属性则默认支持所有类型
allowedFileExtensions: ['pdf', 'xls', 'xlsx', 'jpg', 'png', 'jpeg'],
);

String? path = await FlutterDocumentPicker.openDocument(
params: params,
);

print('$path');
}

image.png


总结


image_picker插件用于图片选取,而flutter_document_picker则用于文件选择,在日常开发中都是很常用的。在iOS中使用要注意隐私权限的配置,不然就会闪退。如果想了解更多的参数属性,可以查看官方文档:


image_picker document


flutter_document_picker document


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

[Flutter] 如何替换so文件来动态替换Flutter代码

一、Flutter代码的启动起点 我们在多数的业务场景下,使用的都是FlutterActivity、FlutterFragment。在在背后,我们知道有着FlutterEnigine、DartExecutor等等多个部件在支持它们的工作。我们所要探究的,就是,...
继续阅读 »

一、Flutter代码的启动起点


我们在多数的业务场景下,使用的都是FlutterActivityFlutterFragment。在在背后,我们知道有着FlutterEnigineDartExecutor等等多个部件在支持它们的工作。我们所要探究的,就是,它们是如何启动的,Dart代码是从何而来的,以实现动态替换libapp.so


以官方的计数器Demo为例,默认的Activity宿主,是实现了FlutterActivity的子类,对于一个Activity,我们最应该关心的就是它的onCreate方法:



  • FlutterActivity# onCreate


protected void onCreate(@Nullable Bundle savedInstanceState) {
switchLaunchThemeForNormalTheme();

super.onCreate(savedInstanceState);

delegate = new FlutterActivityAndFragmentDelegate(this);
delegate.onAttach(this);
delegate.onRestoreInstanceState(savedInstanceState);

lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_CREATE);

configureWindowForTransparency();
setContentView(createFlutterView());
configureStatusBarForFullscreenFlutterExperience();
}

其实过程很简单,FlutterActivity在这里做了一些主题的设置,因为毕竟FlutterActivity也是一个常规的Activity,它就必须按照Android的Activity的一些规范来进行设置。


第三行代码开始,就创建了一个我们所说的**FlutterActivityAndFragmentDelegate**对象,FlutterActivity将绝大多数的Flutter初始化相关逻辑委托给了它,而自身则专注于设置主题、窗口、StatusBar等等。


我们对delegate.onAttach(this);这一行代码的跟踪,最终能走到如下的一个创建流程:


FlutterActivity->
FlutterActivityAndFragmentDelegate->
onAttach()->
setupFlutterEngine->
1.尝试去Cache中获取Engine
2.尝试从Host中获取Engine
3.都没有的话创建一个新的Engine->
Engine #Constructor->
1. 会对Assets、DartExecutor、各种Channel、FlutterJNI做处理
2. 还会对FlutterLoader做处理->
startInitialization方法做初始化
-> 1. 必须在主线程初始化Flutter
-> 2. 先检查settings变量;
-> 3. 获取全局的ApplicationContext防止内存泄漏
-> 4. VsyncWaiter对象的初始化
-> 5. 最后会生成一个initTask交给线程池去执行

1.1 initTask对象


initTask是一个Callable对象,和Runnable类似的,我们可以将它理解成一个任务,也就是一段代码,他最终会被交给线程池去执行:


initResultFuture = Executors.newSingleThreadExecutor().submit(initTask);

initTask的代码如下


 // Use a background thread for initialization tasks that require disk access.
Callable<InitResult> initTask =
new Callable<InitResult>() {
@Override
public InitResult call() {
ResourceExtractor resourceExtractor = initResources(appContext);

flutterJNI.loadLibrary();

// Prefetch the default font manager as soon as possible on a background thread.
// It helps to reduce time cost of engine setup that blocks the platform thread.
Executors.newSingleThreadExecutor()
.execute(
new Runnable () {
@Override
public void run () {
flutterJNI.prefetchDefaultFontManager();
}
}
);

if (resourceExtractor != null) {
resourceExtractor.waitForCompletion();
}

return new InitResult(
PathUtils.getFilesDir(appContext),
PathUtils.getCacheDirectory(appContext),
PathUtils.getDataDirectory(appContext)
);
}
};

我们可以抓一下其中的关键字:



  • ResourceExtractor




  • FlutterJNI.loadLibrary




  • FlutterJNI.prefetchDefaultFontManager




  • PathUtils


不难发现,主要是在做一些资源的预取。


ResourceExtractor主要是针对在DEBUG或者是JIT模式下,针对安装包内资源的提取逻辑。


在DEBUG或者JIT模式下,需要提取Assets目录下的资源文件到存储中,Assets本质上还是Zip压缩包的一部分,没有自己的物理路径,所以需要提取,并返回真真实的物理路径。在DEBUG和JIT模式下,FlutterSDK和业务代码将被构建成Kernel格式的二进制文件,Engine将通过文件内存映射的方式进行加载。



详见:「三、libflutter.so和libapp.so」



1.2 ResourceExtractor



libflutter.so和libapp.so



在DEBUG | JIT模式下,我们是没有libapp.so的,而在release模式下,是有libapp.so文件的,我们分别解包两个不同的Apk文件,可以很清楚地看到这一点:



我们知道,libflutter.so是存放flutter的一些基础类库的so文件,而libapp.so则是存放我们业务代码的so文件,那如果在DEBUG|JIT模式下,没有libapp.so,那么我们的业务代码存储在哪里呢?


此时,我们就要看看ResourceExtractor的initResources方法,究竟干了些什么:


 /** Extract assets out of the APK that need to be cached as uncompressed files on disk. */
private ResourceExtractor initResources(@NonNull Context applicationContext) {
ResourceExtractor resourceExtractor = null;
if (BuildConfig.DEBUG || BuildConfig.JIT_RELEASE) {
final String dataDirPath = PathUtils.getDataDirectory(applicationContext);
final String packageName = applicationContext.getPackageName();
final PackageManager packageManager = applicationContext.getPackageManager();
final AssetManager assetManager = applicationContext.getResources().getAssets();
resourceExtractor =
new ResourceExtractor(dataDirPath, packageName, packageManager, assetManager);

// In debug/JIT mode these assets will be written to disk and then
// mapped into memory so they can be provided to the Dart VM.
resourceExtractor
.addResource(fullAssetPathFrom(flutterApplicationInfo.vmSnapshotData))
.addResource(fullAssetPathFrom(flutterApplicationInfo.isolateSnapshotData))
.addResource(fullAssetPathFrom(DEFAULT_KERNEL_BLOB));

resourceExtractor.start();
}
return resourceExtractor;
}

其中的addResource方法,分别提供了VM的快照数据、iSolate的快照数据DEFAULT_KERNEL_BLOB的数据。因为Flutter本身支持热重载的特性,保存状态和快照(Snapshot)之间必然是不可分割的。


而DEFAULT_KERNEL_BLOB是一个字符串常量: "kernel_blob.bin",结合前面的内容:



FlutterSDK和业务代码将被构建成Kernel格式的二进制文件



我们有理由猜测, "kernel_blob.bin" ,就是我们的业务代码,Flutter是支持逻辑代码热重载的,所以这个字面量加载的资源同样可能会被重新加载。



这也是为什么,如果我们在State中,新增了某个变量作为Widget的某个状态,在initState中调用了,然后使用热重载之后,会导致State中找不到这个变量,因为initState在初次启动时就被调用过了,后续的热重载只会将之前的Snapshot恢复回来,而不会走initState的逻辑。



我们可以在app-debug.apk的assets中,找到"kernel_blob.bin"文件,同样也可以找到isolate_snapshot_data、vm_snapshot_data文件,所以ResourceExtractor加载的,基本上都是这个文件夹中的文件。



但是,在非DEBUG|JIT模式下,就不需要通过ResourceExtractor来进行加载了。


回到initTask方法,只在resourceExtractor != null时,会去等待它的完成。


ResourceExtractor resourceExtractor = initResources(appContext);

flutterJNI.loadLibrary();

// Prefetch the default font manager as soon as possible on a background thread.
// It helps to reduce time cost of engine setup that blocks the platform thread.
Executors.newSingleThreadExecutor()
.execute(
new Runnable() {
@Override
public void run() {
flutterJNI.prefetchDefaultFontManager();
}
});

if (resourceExtractor != null) {
resourceExtractor.waitForCompletion();
}

1.3 FlutterJNI#loadLibrary


public void loadLibrary() {
if (FlutterJNI.loadLibraryCalled) {
Log.w(TAG, "FlutterJNI.loadLibrary called more than once" );
}

System.loadLibrary( "flutter" );
FlutterJNI.loadLibraryCalled = true;
}

代码比较简单,无非就是调用System.loadLibrary去加载Library文件。需要注意的是,表面上找到是flutter,但是在Native(C++)层中,会为它拼接上前缀和后缀:lib和.so,所以,实际上load行为查找的是位于apk包下的lib目录下的对应架构文件夹下的libflutter.so


initTask任务提交给线程池之后,就相当于startInitialization走完了。



你会发现有个问题,在Debug模式下,我们加载业务代码是从二进制文件:"kernel_blob.bin"中加载的,而Release模式下,实在libapp.so中加载的,上面已经出现了加载"kernel_blob.bin"和libflutter.so ,那么在release模式下,另一个Library文件:libapp.so是什么时候加载的呢?


所以,就要进入我们的第二个关键方法:ensureInitializationComplete



二、ensureInitializationComplete


实际上,ensureInitializationComplete和startInitialization在FlutterEngine的初始化代码中


flutterLoader.startInitialization(context.getApplicationContext());
flutterLoader.ensureInitializationComplete(context, dartVmArgs);

代码一百多行,但是大多都是一些配置性的代码:


public void ensureInitializationComplete(
@NonNull Context applicationContext, @Nullable String[] args) {
if (initialized) {
return;
}
if (Looper.myLooper() != Looper.getMainLooper()) {
throw new IllegalStateException(
"ensureInitializationComplete must be called on the main thread" );
}
if (settings == null) {
throw new IllegalStateException(
"ensureInitializationComplete must be called after startInitialization" );
}
try {
InitResult result = initResultFuture.get();

List<String> shellArgs = new ArrayList<>();
shellArgs.add( "--icu-symbol-prefix=_binary_icudtl_dat" );

shellArgs.add(
"--icu-native-lib-path="
+ flutterApplicationInfo.nativeLibraryDir
+ File.separator
+ DEFAULT_LIBRARY);
if (args != null) {
Collections.addAll(shellArgs, args);
}

String kernelPath = null;
if (BuildConfig.DEBUG || BuildConfig.JIT_RELEASE) {
String snapshotAssetPath =
result.dataDirPath + File.separator + flutterApplicationInfo.flutterAssetsDir;
kernelPath = snapshotAssetPath + File.separator + DEFAULT_KERNEL_BLOB;
shellArgs.add( "--" + SNAPSHOT_ASSET_PATH_KEY + "=" + snapshotAssetPath);
shellArgs.add( "--" + VM_SNAPSHOT_DATA_KEY + "=" + flutterApplicationInfo.vmSnapshotData);
shellArgs.add(
"--" + ISOLATE_SNAPSHOT_DATA_KEY + "=" + flutterApplicationInfo.isolateSnapshotData);
} else {
shellArgs.add(
"--" + AOT_SHARED_LIBRARY_NAME + "=" + flutterApplicationInfo.aotSharedLibraryName);

// Most devices can load the AOT shared library based on the library name
// with no directory path. Provide a fully qualified path to the library
// as a workaround for devices where that fails.
shellArgs.add(
"--"
+ AOT_SHARED_LIBRARY_NAME
+ "="
+ flutterApplicationInfo.nativeLibraryDir
+ File.separator
+ flutterApplicationInfo.aotSharedLibraryName);
}

shellArgs.add( "--cache-dir-path=" + result.engineCachesPath);
if (!flutterApplicationInfo.clearTextPermitted) {
shellArgs.add( "--disallow-insecure-connections" );
}
if (flutterApplicationInfo.domainNetworkPolicy != null) {
shellArgs.add( "--domain-network-policy=" + flutterApplicationInfo.domainNetworkPolicy);
}
if (settings.getLogTag() != null) {
shellArgs.add( "--log-tag=" + settings.getLogTag());
}

ApplicationInfo applicationInfo =
applicationContext
.getPackageManager()
.getApplicationInfo(
applicationContext.getPackageName(), PackageManager.GET_META_DATA);
Bundle metaData = applicationInfo.metaData;
int oldGenHeapSizeMegaBytes =
metaData != null ? metaData.getInt(OLD_GEN_HEAP_SIZE_META_DATA_KEY) : 0;
if (oldGenHeapSizeMegaBytes == 0) {
// default to half of total memory.
ActivityManager activityManager =
(ActivityManager) applicationContext.getSystemService(Context.ACTIVITY_SERVICE);
ActivityManager.MemoryInfo memInfo = new ActivityManager.MemoryInfo();
activityManager.getMemoryInfo(memInfo);
oldGenHeapSizeMegaBytes = (int) (memInfo.totalMem / 1e6 / 2);
}

shellArgs.add( "--old-gen-heap-size=" + oldGenHeapSizeMegaBytes);

if (metaData != null && metaData.getBoolean(ENABLE_SKPARAGRAPH_META_DATA_KEY)) {
shellArgs.add( "--enable-skparagraph" );
}

long initTimeMillis = SystemClock.uptimeMillis() - initStartTimestampMillis;

flutterJNI.init(
applicationContext,
shellArgs.toArray(new String[0]),
kernelPath,
result.appStoragePath,
result.engineCachesPath,
initTimeMillis);

initialized = true;
} catch (Exception e) {
Log.e(TAG, "Flutter initialization failed." , e);
throw new RuntimeException(e);
}
}

显然,ensureInitializationComplete也必须在主线程中进行调用,并且必须在startInitialization之后进行调用。此外,我们要注意另外一个东西:shellArgs。


2.1 ShellArgs


Shell是什么大家并不陌生,在计算机中,Shell通常作为系统调用用户操作之间的那么个东西,它存在的形式在Linux/Mac中一般就是一个Shell软件,通常运行在终端当中(你可以粗略地就将Shell 和终端划等号 )。


所以,Flutter的Shell自然而然地旨在设置Flutter运行的一个「基底」,ShellArgs,则是我们使用这么个「基底」的参数。


和之前提到的ResourceExtractor在JIT|DEBUG模式下主动去加载VM和Isoalte快照数据类似地,ShellArgs会在DEBUG和JIT模式下,去设置VM快照数据、Isolate快照数据和Kernel的地址。



别忘了,Kernel即上述的“kernel_blob.bin”二进制文件,是在Debug阶段我们的业务代码,和libapp.so是相对的。



而在除上述之外的条件下,Flutter设置了一个AOT_SHARED_LIBRARY_NAME的路径:


shellArgs.add(
"--" + AOT_SHARED_LIBRARY_NAME + "=" + flutterApplicationInfo.aotSharedLibraryName);

shellArgs.add(
"--"
+ AOT_SHARED_LIBRARY_NAME
+ "="
+ flutterApplicationInfo.nativeLibraryDir
+ File.separator
+ flutterApplicationInfo.aotSharedLibraryName);

在运行时,这个向shareArgs这个List中添加内容的两个字符串的内容,大致上就是指定了装载在系统的Apk安装包中的so文件的路径。


--aot-shared-library-name=libapp.so
--aot-shared-library-name=/data/app/~~RjRJYnLhVBHYW8pHHPeX2g==/com.example.untitled1-wcMTYW1VkfGA2LxW62gUFA==/lib/arm64/libapp.so


因为Tinker本身是支持二进制SO库的动态化的,之前尝试过去动态修改aotSharedLibraryName的值和路径,希望FlutterLoader从该地址去加载libapp.so,以实现Android侧借助Tinker热修复Flutter代码,但是并没有细看源码,打了N个Debug包去测试,结果现在发现这逻辑压根没走。




除了上述的两个libapp.so的名称和路径之外,在DEBUG | JIT模式下的ShellArgs的全家福大致如下:



其实你仔细看看,上述的Kernel的Path并没有在这里面,因为它作为参数,传递给了flutterJNI.init函数。


三、实践:自定义libapp.so的加载


至此,我们今天最开始的一个话题:Embdder和代码Dart代码从何而来, 便有了结果 。结合上述的内容,我们可以做一个小小的实践,我们通过传入ShellArgs,来加载指定的 libapp.so 文件。


回到我们最初的流程:


FlutterActivity->
FlutterActivityAndFragmentDelegate->
onAttach()->
setupFlutterEngine->
……
startInitialization
ensureInitializationComplete // alpha

我们需要在上述的过程的alpha之前,完成对***AOT_SHARED_LIBRARY_NAME*** 对应的路径(一模一样,也是 AOT_SHARED_LIBRARY_NAME )这两个字符串的内容替换,比如:


--aot-shared-library-name=libapp.so
--aot-shared-library-name= /data/app/~~RjRJYnLhVBHYW8pHHPeX2g==/com.example.untitled1-wcMTYW1VkfGA2LxW62gUFA==/lib/arm64/libapp.so

我们希望替换成:


--aot-shared-library-name=libfixedapp.so
--aot-shared-library-name= /temp/lib/arm64/libfixedapp.so

3.1 flutterApplicationInfo和FlutterActivity#getShellArgs()


这是FlutterLoader的一个实例对象,它在startInitialization阶段被赋值:


public void startInitialization(@NonNull Context applicationContext, @NonNull Settings settings) {
// ……
try {
final Context appContext = applicationContext.getApplicationContext();
// ……
flutterApplicationInfo = ApplicationInfoLoader.load(appContext);

……

所以,我们只需要在合适的时机去修改这个值即可。


但是并没有合适的时机,因为Flutter并没有为我们提供可以侵入去反射设置它的时机,如果在startInitialization,我们唯一可以侵入的时机是attach()函数,但是会让我们反射设置的值被覆盖掉。


但是,我们关注一下,在setupFlutterEngine时,我们new FlutterEngine的参数:


flutterEngine =
new FlutterEngine(
host.getContext(),
host.getFlutterShellArgs().toArray(),
/*automaticallyRegisterPlugins=*/ false,
/*willProvideRestorationData=*/ host.shouldRestoreAndSaveState());

此处的host,就是我们的FlutterActivity,因为FlutterActivity本身就是FlutterActivityAndFragmentDelegate.Host接口的实现类,而这个host.getFlutterShellArgs().toArray(),最终会作为我们在FlutterActivity预设的参数,在所其他系统预设参数被加入之前被加入到我们的shellArgs数组中。


所以,我们在FlutterActivity的子类,也就是MainActivity下,重写getFlutterShellArgs()方法:


class MainActivity: FlutterActivity() {
override fun getFlutterShellArgs(): FlutterShellArgs {
return super.getFlutterShellArgs().apply {
this.add( "--aot-shared-library-name=libfixedapp.so" )
this.add( "--aot-shared-library-name=/data/data/com.example.untitled1/libfixedapp.so" )
}
}
}

我们可以在debug模式下debug,看看有没有效果:



显然,是有效果的。



因为只能从几个特定的目录中去加载so库文件,我们必须将补丁SO文件放在/data/data/com.example.untitled1对应的目录之下。



接下来,我们先写一个有bug的Flutter代码,我们把标题改成:This is Counter Title with bug 并且新增一个 _decrementCounter() 并把计数器的加法按钮对应的增加按钮,改成减少调用。


然后在Flutter项目根目录使用安装Release包:


flutter build apk --release
adb install build/app/outputs/flutter-apk/app-release.apk

然后我们修复Bug,将代码恢复到最开始的默认状态,然后:


flutter build apk --release
open build/app/outputs/flutter-apk/

解压apk,然后把对应的so文件移出来,放到对应的文件夹下: /data/data/com.example.untitled1/libfixedapp.so 。完成之后,重新启动程序,即可从新的、我们指定的路径加载新的 libapp.so 了:



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

因为买不到烟花,所以我想用Compose来放烟花

再过几天就要过大年了,最近周围也是一到晚上就是到处都在放烟花,看得我十分眼馋也想搞点烟花来放放,可惜周围实在是买不到,一打听全是托人在外省买的,算了太麻烦了,那真的烟花放不了,我就干脆决定写个烟花出来吧,应应景,也烘托点年味儿出来~刚好最近学了点Compose...
继续阅读 »

再过几天就要过大年了,最近周围也是一到晚上就是到处都在放烟花,看得我十分眼馋也想搞点烟花来放放,可惜周围实在是买不到,一打听全是托人在外省买的,算了太麻烦了,那真的烟花放不了,我就干脆决定写个烟花出来吧,应应景,也烘托点年味儿出来~刚好最近学了点Compose的动画,所以这个烟花就拿Compose来写,先上个最终效果图


afire.gif


不好意思...放错效果图了...这个才是


afire9.gif


gif有点卡,真实效果还要流畅点,这些我们先不说,先来看看这个动画我们需要做些什么



  1. 一闪一闪(对..在闪)的小星星

  2. 逐渐上升的烟花火苗

  3. 烟花炸开的效果

  4. 炸开后闪光效果


开始开发


闪烁的星星


首先我们放烟花肯定是在晚上放烟花的,所以整体画布首先背景色就是黑色,模拟一个夜空的场景


Canvas(
modifier = Modifier
.size(screenWidth().dp, screenHeight().dp)
.background(color = Color.Black)
) {

}

确定好了画布以后,我们先来想想如何画星星,夜空中的星星其实就是在画布上画几个小圆点,然后小圆点的颜色是白色的,最后星星看起来都是有大有小的,因为距离我们的距离不一样,所以我们的小圆点也要看起来大小不一样,也就是圆点的半径不一样,知道这些以后我们开始设计代码,先确定好需要的变量,比如画布的中心点xy坐标,星星的xy坐标,以及星星的颜色


val drawColor = colorResource(id = R.color.white)

val centerX = screenWidth() / 2
val centerY = screenHeight() / 2
val starXList = listOf(
screenWidth() / 12, screenWidth() / 6, screenWidth() / 4,
screenWidth() / 3, screenWidth() * 5 / 12, screenWidth() / 2, screenWidth() * 7 / 12,
screenWidth() * 2 / 3, screenWidth() * 3 / 4, screenWidth() * 5 / 6, screenWidth() * 11 / 12
)
val starYList = listOf(
centerY / 12, centerY / 6, centerY / 4,
centerY / 3, centerY * 5 / 12, centerY / 2, centerY * 7 / 12,
centerY * 2 / 3, centerY * 3 / 4, centerY * 5 / 6, centerY * 11 / 12
)

starXList放星星的横坐标,横坐标就是把画布宽十二等分,starYList放星星的纵坐标,纵坐标就是把画布高的二分之一再十二等分,这样作法的目的就是最终画圆点的时候,两个List可以随机选取下标值,达到星星随机散布在夜空的效果


drawCircle(drawColor, 5f, Offset(starXList[0], starYList[10]))
drawCircle(drawColor, 4f, Offset(starXList[1], starYList[9]))
drawCircle(drawColor, 3f, Offset(starXList[2], starYList[4]))
drawCircle(drawColor, 5f, Offset(starXList[3], starYList[6]))
drawCircle(drawColor, 6f, Offset(starXList[4], starYList[3]))
drawCircle(drawColor, 5f, Offset(starXList[5], starYList[7]))
drawCircle(drawColor, 6f, Offset(starXList[6], starYList[2]))
drawCircle(drawColor, 2f, Offset(starXList[7], starYList[1]))
drawCircle(drawColor, 5f, Offset(starXList[8], starYList[0]))
drawCircle(drawColor, 2f, Offset(starXList[9], starYList[5]))
drawCircle(drawColor, 2f, Offset(starXList[10], starYList[8]))

然后一闪一闪的效果怎么做呢,一闪一闪也就是圆点的半径循环在变大变小,所以我们需要用到Compose的循环动画rememberInfiniteTransition,这个函数可以通过它的animateXXX函数来创建循环动画,它里面有三个这样的函数


image.png


我们这里就使用animateFloat来创建可以变化的半径


val startRadius by transition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(tween(1000, easing = LinearEasing))
)

这个函数返回的是一个Float类型的值,前两个参数很好理解,初始值跟最终值,第三个参数是一个
InfiniteRepeatableSpec的对象,它决定这个循环动画的一些参数,duration决定动画持续时间,delayMillis延迟执行的时间,easing决定动画执行的速度



  • LinearEasing 匀速执行

  • FastOutLinearInEasing 逐渐加速

  • FastOutSlowInEasing 先加速后减速

  • LinearOutSlowInEasing 逐渐减速


这里的星星的动画就选择匀速执行就好,我们把starRadius设置到星星的绘制流程里面去


drawCircle(drawColor, 5f + startRadius, Offset(starXList[0], starYList[10]))
drawCircle(drawColor, 4f + startRadius, Offset(starXList[1], starYList[9]))
drawCircle(drawColor, 3f + startRadius, Offset(starXList[2], starYList[4]))
drawCircle(drawColor, 5f + startRadius, Offset(starXList[3], starYList[6]))
drawCircle(drawColor, 6f + startRadius, Offset(starXList[4], starYList[3]))
drawCircle(drawColor, 5f + startRadius, Offset(starXList[5], starYList[7]))
drawCircle(drawColor, 6f + startRadius, Offset(starXList[6], starYList[2]))
drawCircle(drawColor, 2f + startRadius, Offset(starXList[7], starYList[1]))
drawCircle(drawColor, 5f + startRadius, Offset(starXList[8], starYList[0]))
drawCircle(drawColor, 2f + startRadius, Offset(starXList[9], starYList[5]))
drawCircle(drawColor, 2f + startRadius, Offset(starXList[10], starYList[8]))

效果就是这样的


afire3.gif


烟花火苗


现在开始绘制烟花部分,首先是上升的火苗,火苗也是个小圆点,它的起始坐标跟终点坐标很好确定,横坐标都是centerX即画布的一半,纵坐标开始位置是在画布高度位置,结束是在centerY即画布一半高度位置,而一次放烟花的过程中,烟花炸开的次数有很多次,伴随着火苗上升次数也很多次,所以这个也是个循环动画,整个过程代码实现如下


val fireDuration = 3000
val shootHeight by transition.animateFloat(
screenHeight(),
screenHeight() / 2,
animationSpec = InfiniteRepeatableSpec(tween(fireDuration,
easing = FastOutSlowInEasing))
)
Canvas(
modifier = Modifier
.size(screenWidth().dp, screenHeight().dp)
.background(color = Color.Black)
) {
drawCircle(drawColor, 6f, Offset(centerX, shootHeight))
}

由于火苗上升会随着重力逐渐减速,所以这里选择的是先快后慢的动画效果,效果如下


afire4.gif


烟花炸开


这一部分难度开始增加了,因为烟花炸开这个效果是要等到火苗上升到最高点的位置然后在炸开的,这两个动画有个先后关系,用惯了Androi属性动画的我刚开始还不以为然,认为肯定会有个动画回调或者监听器之类的东西,然而看了下循环动画的源码发现并没有找到想要的监听器


image.png
那只能换个思路了,刚刚说到炸开的动画是在火苗上升到最高点的时候才开始的,那这个最高点就是个开关,当火苗到达最高点的时候,让火苗的动画“暂停”,然后开始炸开的动画,现在问题的关键是,如何让火苗的动画“暂停”,我们知道火苗的动画是一个循环动画,循环动画是从初始值到最终值循环变化的过程,那么我们是不是只要让这两个值都为同一个,让它们没有变化的空间,是不是就等于让这个动画“暂停”了呢,我们开始设计这个过程


var turnOn by remember { mutableStateOf(false) }
val distance = remember { Animatable(screenHeight().dp, Dp.VectorConverter) }
LaunchedEffect(turnOn) {
distance.snapTo(if (turnOn) screenHeight().dp else 0.dp)
}

turnOn是个开关,true的时候表示火苗动画开始,false的时候表示火苗动画已经到达最高点,distance是一个Animatable的对象,Animatable是啥呢,从字面上就能理解它也是个动画,但与我们刚刚接触的循环动画不一样,它只有从初始值到最终值的单向变化,而后面的LaunchedEffect是啥呢,我们点到里面去看下它的源码


fun LaunchedEffect(
key1: Any?,
block: suspend CoroutineScope.() -> Unit
)
{
val applyContext = currentComposer.applyCoroutineContext
remember(key1) { LaunchedEffectImpl(applyContext, block) }
}

这里我们看到key1是任何值,被remember保存了起来,block是个挂起的函数类型对象,也就是block是运行在协程里面的,我们再去LaunchedEffectImpl里面看看


image.png


我们看到了这个协程是在被remember的值发生改变以后才去执行的,那现在清楚了,每次改变turnOn的值,distance就会来回从screenHeight()和0之间切换,而切换的条件就是火苗上升高度到达了画布的一半,我们改一下刚刚火苗的动画,让shootHeight随着distance变化而变化,另外我们给画布添加个点击事件,每次点击让turnOn的值发生改变,目的让动画多进行几次


val shootHeight by transition.animateFloat(
distance.value.value,
distance.value.value / 2,
animationSpec = InfiniteRepeatableSpec(tween(fireDuration,
easing = FastOutSlowInEasing))
)
Canvas(
modifier = Modifier
.size(screenWidth().dp, screenHeight().dp)
.background(color = Color.Black)
.clickable { turnOn = !turnOn }
) {
if (shootHeight.toInt() != screenHeight().toInt() / 2) {
if (shootHeight.toInt() != 0) {
drawCircle(drawColor, 6f, Offset(centerX, shootHeight))
}
} else {
turnOn = false
}
}

我们看下效果是不是我们想要的


afire5.gif


So far so good~动画已经分离开来了,现在就要开始炸开效果的开发,我们先脑补下炸开是什么样子的,是由火苗开始,向四周延伸出去若干条烟火,或者换句话说就是以火苗为圆心,向四周画线条,这样说我们思路有了,这是一个由圆心开始向外drawLine的过程,drawLine这个api大家很熟悉了,最主要的就是确定开始跟结束两处的坐标,但是无论开始还是结束,这两个坐标都是分布在一个圆周上的,所以我们第一步先要确定在哪几个角度上面画线


val anglist = listOf(30, 75, 120, 165, 210, 255, 300, 345)

知道了角度以后,就要去计算xy坐标了,这个就要用到正弦余弦公式


private fun calculateX(centerX: Float, fl: Int, endCor: Boolean): Float {
val angle = Math.toRadians(fl.toDouble())
return centerX - cos(angle).toFloat() * (if (endCor) screenWidth() / 2 else screenWidth() / 12)
}

private fun calculateY(centerY: Float, fl: Int, endCor: Boolean): Float {
val angle = Math.toRadians(fl.toDouble())
return centerY - sin(angle).toFloat() * (if (endCor) screenWidth() / 2 else screenWidth() / 12)
}

其中endColor是true就是画终点的坐标,false就是起点的坐标,我们先画一条线,剩下的线的代码都相同


val startfireOneX = calculateX(centerX, anglist[0], false)
val startfireOneY = calculateY(centerY, anglist[0], false)
val endfireOneX = calculateX(centerX, anglist[0], true)
val endfireOneY = calculateY(centerY, anglist[0], true)

var fireColor = colorResource(id = R.color.color_03DAC5)
var fireOn by remember { mutableStateOf(false) }
val fireOneXValue = remember { Animatable(startfireOneX, Float.VectorConverter) }
val fireOneYValue = remember { Animatable(startfireOneY, Float.VectorConverter) }
val fireStroke = remember { Animatable(0f, Float.VectorConverter) }

LaunchedEffect(fireOn){
fireStroke.snapTo(if(fireOn) 20f else 0f)
fireOneXValue.snapTo(if(fireOn) endfireOneX else startfireOneX)
fireOneYValue.snapTo(if(fireOn) endfireOneY else startfireOneY)
}

fireOneXValue是第一条线横坐标的变化动画,fireOneYValue是纵坐标的变化动画,它们的改变都有fireOn去控制,fireOn打开的时机就是火苗上升到最高点的时候,同时我们也增加了fireStroke,表示线条粗细的变化动画,也随着fireOn的改变而改变,我们现在去创建横坐标,纵坐标以及线条粗细的循环动画


val fireOneX by transition.animateFloat(
startfireOneX, fireOneXValue.value,
infiniteRepeatable(tween(fireDuration, easing = FastOutSlowInEasing))
)
val fireOneY by transition.animateFloat(
startfireOneY, fireOneYValue.value,
infiniteRepeatable(tween(fireDuration, easing = FastOutSlowInEasing))
)

val strokeW by transition.animateFloat(
initialValue = fireStroke.value/20,
targetValue = fireStroke.value,
animationSpec = infiniteRepeatable(tween(fireDuration,
easing = FastOutSlowInEasing))
)

我们现在可以去绘制第一根线了,在Canvas里面增加第一个drawLine


Canvas(
modifier = Modifier
.size(screenWidth().dp, screenHeight().dp)
.background(color = Color.Black)
.clickable { turnOn = !turnOn }
) {
if (shootHeight.toInt() != screenHeight().toInt() / 2) {
if (shootHeight.toInt() != 0) {
drawCircle(drawColor, 6f, Offset(centerX, shootHeight))
}
} else {
turnOn = false
fireOn = true
}
drawLine(
fireColor, Offset(startfireOneX, startfireOneY),
Offset(fireOneX, fireOneY), cap = StrokeCap.Round, strokeWidth = strokeW
)
}

到了这一步,我们应该考虑的是,如何让动画衔接起来,也就是炸开动画完成以后,继续执行火苗动画,那么我们就要找出炸开动画结束的那个点,这里总共有三个值,我们选择strokeW,当线条粗细到达最大值的时候,将fireOn关闭,将turnOn打开,我们在drawLine后面加上这段逻辑


Canvas(
modifier = Modifier
.size(screenWidth().dp, screenHeight().dp)
.background(color = Color.Black)
.clickable { turnOn = !turnOn }
) {
if (shootHeight.toInt() != screenHeight().toInt() / 2) {
if (shootHeight.toInt() != 0) {
drawCircle(drawColor, 6f, Offset(centerX, shootHeight))
}
} else {
turnOn = false
fireOn = true
}
drawLine(
fireColor, Offset(startfireOneX, startfireOneY),
Offset(fireOneX, fireOneY), cap = StrokeCap.Round, strokeWidth = strokeW
)
if(strokeW == 19){
fireOn = false
turnOn = true
}
}

这个时候,两个动画就连起来了,我们运行下看看效果


afire6.gif


一条线完成了,那么其余几根线道理也是一样的,代码有点多篇幅关系就不贴出来了,直接看效果图吧


afire7.gif


基本的样子已经出来了,现在给这个烟花优化一下,我们知道放烟花时候,每次炸开的样子都是不一样的,红橙黄绿啥颜色都有,我们这边也让每次炸开时候,颜色都不一样,那首先我们要弄一个颜色的集合


val colorList = listOf(
colorResource(id = R.color.color_03DAC5), colorResource(id = R.color.color_BB86FC),
colorResource(id = R.color.color_E6A639), colorResource(id = R.color.color_01B9FF),
colorResource(id = R.color.color_FF966B), colorResource(id = R.color.color_FFEBE7),
colorResource(id = R.color.color_FF4252), colorResource(id = R.color.color_EC4126)
)

并且让fireColor在每次炸开之前,更换一次颜色,随机换也行,按照下标顺序替换也行,这边我选择顺序换了,位置就是炸开动画开始的地方


var colorIndex by remember { mutableStateOf(0) }

Canvas(
modifier = Modifier
.size(screenWidth().dp, screenHeight().dp)
.background(color = Color.Black)
.clickable { turnOn = !turnOn }
) {
if (shootHeight.toInt() != screenHeight().toInt() / 2) {
if (shootHeight.toInt() != 0) {
drawCircle(drawColor, 6f, Offset(centerX, shootHeight))
}
} else {
if (strokeW.toInt() == 0) {
colorIndex += 1
if (colorIndex > 7) colorIndex = 0
fireColor = colorList[colorIndex]
}
turnOn = false
fireOn = true
}
drawLine(
fireColor, Offset(startfireOneX, startfireOneY),
Offset(fireOneX, fireOneY), cap = StrokeCap.Round, strokeWidth = strokeW
)
if(strokeW == 19){
fireOn = false
turnOn = true
}
}

我们再想想看,烟花结束以后是不是还会有一些闪光,有的烟花的闪光还会有声音,声音我们弄不出来,但是闪光还是可以的,还记得我们星星怎么画的吗,不就是几个圆圈在那里不断绘制,然后一闪一闪的效果就是不断改变圆圈的半径,那我们烟花的闪光效果也可以这么做,首先我们先确定好需要绘制圆圈的坐标


val endXAnimList = listOf(
calculatePointX(centerX, anglist[0]),
calculatePointX(centerX, anglist[1]),
calculatePointX(centerX, anglist[2]),
calculatePointX(centerX, anglist[3]),
calculatePointX(centerX, anglist[4]),
calculatePointX(centerX, anglist[5]),
calculatePointX(centerX, anglist[6]),
calculatePointX(centerX, anglist[7])
)
val endYAnimList = listOf(
calculatePointY(centerY, anglist[0]),
calculatePointY(centerY, anglist[1]),
calculatePointY(centerY, anglist[2]),
calculatePointY(centerY, anglist[3]),
calculatePointY(centerY, anglist[4]),
calculatePointY(centerY, anglist[5]),
calculatePointY(centerY, anglist[6]),
calculatePointY(centerY, anglist[7])
)

然后烟花放完以后会有个逐渐暗淡的过程,在这里我们就让圆圈的半径也有个逐渐变小的过程,那我们就创建个变小的动画


val pointDuration = 3000
val firePointRadius = remember{ Animatable(0f, Float.VectorConverter) }
val pointRadius by transition.animateFloat(
initialValue = firePointRadius.value,
targetValue = firePointRadius.value / 6,
animationSpec = infiniteRepeatable(tween(pointDuration,
easing = FastOutLinearInEasing))
)

有了这个闪光的动画以后,接下去就要让它跟炸开的动画衔接起来了,这边也跟其他动画一样,增加一个开关去控制,当开关打开之后,firePointRadius设置成最大,开启这个闪光动画,当开关关闭以后,就让firePointRadius设置为0,也就是关闭闪光动画,代码如下


var pointOn by remember { mutableStateOf(false) }
LaunchedEffect(pointOn) {
firePointRadius.snapTo(if (pointOn) 12f else 0f)
}

参数都设置好了以后,我们可以去绘制闪光的圆圈了,这边我们让闪光的开关在炸开动画完毕之后打开,原本要开启的火苗动画我们暂时先不打开,而闪光动画的颜色我们让它跟炸开的动画颜色一致,让整个过程看上去像是烟花自己炸开然后变成小颗粒的样子


Canvas(
modifier = Modifier
.size(screenWidth().dp, screenHeight().dp)
.background(color = Color.Black)
.clickable { turnOn = !turnOn }
) {
....此处省略前面两个烟花动画的绘制过程.....

if(strokeW == 19){
fireOn = false
pointOn = true
}
if(pointOn){
repeat(endXAnimList.size) {
drawCircle(
colorList[colorIndex], pointRadius,
Offset(endXAnimList[it], endYAnimList[it])
)
}
}
}

到了这里感觉好像漏了点什么,没错,之前我们暂时把火苗开关打开的时机取消了,那这个开关得打开呀,不然我们的烟花没办法连在一起放,现在就是要找到这个临界值,我们发现这个绘制圆圈的过程,只有圆圈的半径在随着时间的递进逐渐变小的,它的最小值是当pointOn开关打开之后,targetValue的值也就是2,所以我们可以判断当pointRadius变成2的时候,将闪光动画关闭,火苗动画打开,我们将这个判断加到绘制圆圈的后面


if(pointOn){
repeat(endXAnimList.size) {
drawCircle(
colorList[colorIndex], pointRadius,
Offset(endXAnimList[it], endYAnimList[it])
)
}
if (pointRadius.toInt() == 2) {
pointOn = false
turnOn = true
}
}

现在动画已经都衔接起来了,我们看下效果吧


afire8.gif


额~~感觉怪怪的,说好的闪光呢,但就动画而言圆圈的确是完成了半径逐渐变小的绘制过程,那么问题出在哪里呢?我们回到代码中再检查一遍,发现了这一处代码


Offset(endXAnimList[it], endYAnimList[it])
复制代码

这个圆点绘制的位置是均匀分布在一个圆周上的,也就是只绘制了八个圆点,但是真实效果里面的圆点有很多个,那我们是不是只要将endXAnimList,endYAnimList这两个数组里面的坐标打乱随机组成一个圆点不就好了,这样一来最多会绘制出64个圆点,再配合动画不就能达到闪光的效果了吗,所以我们先写一个随机函数


private fun randomCor(): Int {
return (Math.random() * 8).toInt()
}

然后将原来绘制圆点的坐标的下标改成随机数


if(pointOn){
repeat(endXAnimList.size) {
drawCircle(
colorList[colorIndex], pointRadius,
Offset(endXAnimList[randomCor()], endYAnimList[randomCor()])
)
}
if (pointRadius.toInt() == 2) {
pointOn = false
turnOn = true
}
}

现在我们再来看看效果如何


afire9.gif


总结


完整的动画效果已经出来了,整个开发过程还是相对来讲比较吃力的,我想这应该是刚开始接触Compose动画这一部分吧,后面再多开发几个动画应该会得心应手一些,但还是有点收获的,比如



  • 循环动画如果中途需要暂停,然后过段时间再打开的话,不能直接对它的initValue跟targetValue设置值,这样是无效的,必须搭配着Animatable动画一起使用才行

  • LaunchedEffect虽说是在Compose里面是提供给协程运行的函数,不看源码的话以为它里面只能做一件事情,其他事情会被堵塞,其实LaunchedEffect已经封装好了,它的block就是一个协程,所以无论在LaunchedEffect做几件事情,它们都只是运行在一个协程里面


也有一些遗憾与不足



  • 动画衔接的地方都是判断一个值有没有到达一个具体值,然后用开关去控制,感觉应该有更好的方式,比如可以配合着delayMillis,让动画延迟一会再开始

  • 烟花本身其实可以用曲线来代替直线,比如贝塞尔,这个是在开发过程中才想到的,我先去试试看,等龙年再画个更好的烟花~~

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