注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

最近很火的反调试,你知道它是什么吗?

前言 我们日常开发中,永远离不开debug调试,断点技术一直是我们排查bug的有力手段之一!随着网络安全意识的逐步提高,对app安全的要求就越来越高,反调试的技术也渐渐深入我们开发者的眼帘,那么我们来具体看看,android中,同时也是linux内核中,是怎么...
继续阅读 »

前言


我们日常开发中,永远离不开debug调试,断点技术一直是我们排查bug的有力手段之一!随着网络安全意识的逐步提高,对app安全的要求就越来越高,反调试的技术也渐渐深入我们开发者的眼帘,那么我们来具体看看,android中,同时也是linux内核中,是怎么处理调试程序的!


执行跟踪


无论是断点还是其他debug手段,其实都可以总结为一个技术手段,就是执行跟踪,含义就是一个程序监视另一个程序的技术,被跟踪的程序通过一步步执行,知道收到一个信号或者系统调用停止!


在linux内核中,就是通过ptrace系统调用进行的执行跟踪


#include <sys/ptrace.h> 
long ptrace(enum __ptrace_request request, pid_t pid, void *addr, void *data);

随着我们对linux的了解,那么就离不开对权限的讨论!一个程序跟踪另一个程序这种东西,按照linux风格,肯定是具有某种权限才可以执行!这个权限就是设置了CAP_SYS_PTRACE 权限的进程,就可以跟踪系统中除了init进程(linux第一个进程)外的任何进程!当然!就算一个进程没有CAP_SYS_PTRACE权限,也可以跟踪一个与被监视进程有相同属组的进程,比如父进程可以通过ptrace跟踪子进程!执行跟踪还有一个非常重要的特点,就是两个进程不能同时跟踪一个进程


我们再回到ptrace函数调用,可以看到第一个参数是一个枚举值,其实就是发出ptrace的当前行为,它有以下可选命令(仅部分举例):


image.png
其他的参数含义如下:
pid参数标识目标进程,addr参数表明执行peek(读操作)和poke(写操作)操作的地址,data参数则对于poke操作,指明存放数据的地址,对于peek操作,指明获取数据的地址。


ptrace设计探讨


我们了解了linux提供的系统api,那么我们还是从设计者角度出发,我们想要跟踪一个进程的话,我们需要干点什么?来来来,我们来想一下,可能就会有以下几个问题吧



  1. 被跟踪进程与跟踪进程怎么建立联系

  2. 如果使程序停止在我们想要停止的点(比如断点)

  3. 跟踪进程与被跟踪进程怎么进行数据交换,又或者我们怎么样看到被跟踪进程中当前的数据


下面我们逐步去探讨一下这几个问题吧!(以PTRACE_ATTACH 作为例子)首先对于问题1,我们怎么建立起被跟踪进程与跟踪进程之间的联系呢?linux中进程存在父子关系,兄弟关系对吧!这些进程就可以通过相对便捷的方式进行通信,同时linux也有定义了特殊的信号提供给父子进程的通信。看到这里,相信大家能够猜到ptrace究竟干了啥!就是通过调用ptrace系统调用,把被跟踪进程(第二个参数pid)的进程描述符号中的p_pptr字段指向了跟踪进程!毕竟linux判断进程的描述,就靠着进程描述符,想要建立父子关系,修改进程描述符即可,就这么简单!这里补充一下部分描述符号:


image.png


那么好!我们建立进程之间的联系了,那么当执行跟踪终止的时候,我们就可以调用ptrace 第一个参数为PTRACE_DETACH 命令,把p_pptr恢复到原来的数据即可!(那么有人会问,原来的父进程描述符保存在哪里了,嘿嘿,在p_opptr中,也就是他的祖先中,这里我们不深入讨论)


接下来我们来讨论一下问题2和问题3,怎么使程序停止呢?(这里我们讨论常用的做法,以linux内核2.4版本为准,请注意细微的区别)其实就是被监控进程在读取指令前,就会执行被嵌入的监控代码,如果我想要停止在代码的某一行,这个时候cpu会执行一条“陷阱指令”也称为Debug指令,一般来说,这条指令作用只是为了使程序停止,然后发出一个SIGCHLD信号给父进程(不了解信号的知识可以看看这篇),嘿嘿,那么这个父进程是谁呢?没错,就是我们刚刚改写的监控进程,这样一来,我们的监控进程就能够收到被监控进程的消息,此时就可以继续调用其他的ptrace调用(第一个参数指定为其他需要的枚举值),查看当前寄存器或者其他的数据


image.png


这么说下来可能会有人还是不太懂,我们举个例子,我们的单步调试是怎么样做的:
还是上面的步骤,子进程发送一个SIGCHLD给父进程,此时身为父进程的监控线程就可以再调用ptrace(PTRACE_SINGLESTEP, *, *, * )方法给子进程的下一条指令设置陷阱指令,进行单步调试,此时控制权又会给到子进程,子进程执行完一个指令,就会又发出SIGCHLD给父进程,如此循环下去!


反调试


最近隐私合规与app安全性能被各大app所重视,对于app安全性能来说,反调试肯定是最重要的一环!看到上面的这些介绍,我们应该也明白了ptrace的作用,下面我们介绍一下几种常见的反调试方案:



  1. ptrace占位:利用ptrace的机制,我们知道一个进程只能被一个监控进程所监控,所以我们可以提前初始化一个进程,用这个进程对我们自身app的进程调用一次ptrace即可

  2. 轮询进程状态:可以通过轮训的手段,查看进程当前的进程信息:proc/pid/status


Name: test\
Umask: 0022\
State: D (disk sleep)-----------------------表示此时线程处于sleeping,并且是uninterruptible状态的wait。

Tgid: 157-----------------------------------线程组的主pid\
Ngid: 0\
Pid: 159------------------------------------线程自身的pid\
PPid: 1-------------------------------------线程组是由init进程创建的。\
TracerPid: 0\ **这里是关键**
Uid: 0 0 0 0\
Gid: 0 0 0 0\
FDSize: 256---------------------------------表示到目前为止进程使用过的描述符总数。\
Groups: 0 10 \
VmPeak: 1393220 kB--------------------------虚拟内存峰值大小。\
VmSize: 1390372 kB--------------------------当前使用中的虚拟内存,小于VmPeak。\
VmLck: 0 kB\
VmPin: 0 kB\
VmHWM: 47940 kB-----------------------------RSS峰值。\
VmRSS: 47940 kB-----------------------------RSS实际使用量=RSSAnon+RssFile+RssShmem。\
RssAnon: 38700 kB\
RssFile: 9240 kB\
RssShmem: 0 kB\
VmData: 366648 kB--------------------------进程数据段共366648KB。\
VmStk: 132 kB------------------------------进程栈一共132KB。\
VmExe: 84 kB-------------------------------进程text段大小84KB。\
VmLib: 11488 kB----------------------------进程lib占用11488KB内存。\
VmPTE: 1220 kB\
VmPMD: 0 kB\
VmSwap: 0 kB\
Threads: 40-------------------------------进程中一个40个线程。\
SigQ: 0/3142------------------------------进程信号队列最大3142,当前没有pending

如果TracerPid不为0,那么就存在被监控的进程,此时如果该进程不是我们所信任的进程,就调用我们指定好的程序重启即可!读取这个proc/pid/status文件涉及到的相关处理可以自行google,这里就不再重复列举啦!


总结


看到这里,我们也能够明白debug在linux内核中的处理流程啦!最后,点个赞再走呗!



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

我在代码里面故意留个漏洞,违法吗?

逛知乎的时候,看到了这么一个问题:我看到了三个非常有意思的回答,分享给大家一看。首先是这个为了防止项目交付后收不到尾款埋下后门的回答:早年给某台企做外包项目,定制一个Android系统的ROM。开发费用16万,一年期维护费用2万。开发费用分三期打款,订金4万,...
继续阅读 »

逛知乎的时候,看到了这么一个问题:

我看到了三个非常有意思的回答,分享给大家一看。

首先是这个为了防止项目交付后收不到尾款埋下后门的回答:

早年给某台企做外包项目,定制一个Android系统的ROM。开发费用16万,一年期维护费用2万。

开发费用分三期打款,订金4万,生产环境ROM交付8万,验收并交付源码后打尾款4万。

生产环境ROM交付前留了一手,加了时间戳校验,混杂在驱动程序里,6个月后不能开机。

果不其然,过了4个月对方也没把尾款打过来,显然是用着没什么毛病,源码不打算要了,维护费用也一起省了。每次催款都用各种理由搪塞。

又过了2个月,埋的雷爆了,他们的下游客户开始各种投诉。这才把剩余款项收回来。

懒得说这家公司的名字,挺有名的公司,估计很多人用过他们的产品。

如果不留这一手,估计就要吃哑巴亏了,毕竟台湾省的官司打起来费劲儿。在这种情况下,这叫自我保护,不违法。

原回答链接:https://www.zhihu.com/question/531724027/answer/2487270093

这个回答让我想起了多年前我接私活的时候,给别人开发的软件交付后就玩消失的经历,那时候年轻,不知道做个时间限制啥的···不说了,说多了都是泪。

话说回来,真像这位答主这样弄个后门,违不违法,答主说了不算,还得具体问题具体分析,法院说了才算,不过这种做法还是比较危险,慎重。

那到底法律如何界定这种问题呢,来看一下网络安全界的大佬TK教主的回答:

我国没有仅针对后门本身进行处罚的法律。主要原因是“后门”难以客观界定。

比如,自动更新机制是不是后门?热补丁机制是不是后门?远程维护机制是不是后门?家里宽带有问题,你打运营商客服电话,运营商那边就能远程调整你的光猫——这是不是后门?

所以现在法律在处理后门相关问题时,是根据利用行为定罪的。你留了后门,一辈子不用,没事。用来干坏事了,那就根据你具体干了什么坏事定罪量刑。

原回答链接:https://www.zhihu.com/question/531724027/answer/2539891264

代码里面藏后门属于初级玩家,来看一下高级的后门长啥样:

Ken Thompson在贝尔实验室的时候,他总是能在一台装了Unix的服务器上黑进他人的账户,不管他人怎么修改账户密码都没有用,当时贝尔实验室里面聚集的都是智商爆表、专业知识过硬的科学家,Ken的行为无疑让他们非常不爽。

有个人分析了Unix的代码之后,找到了后门,重新编译部署了Uinx,但是让他们崩溃的事情再次发生,Ken还是能黑进他们的账户,这个事情让他们百思不得其解。

一直到1983年,Ken获得图灵奖,在大会上解开了这个秘密,原来这个密码后门是通过他写的一个C编译器植入的,而当时那台Unix的机器必须通过这个C编译器编译之后才能运行,所以不管unix怎么修改都没有用,毕竟是要编译的。

前几年发生的Xcode Ghost事件,就是用类似的方式操作的,所以真正的大神留的黑洞,一般人根本防不住,除非遇到同样的大神,而且人家告诉你在哪里了,才有可能破解。这就是为啥有的单位,人家不连外网,因为根本不知道装的系统有没有别人留下的漏洞。

低级的代码层次

中级的在工具链上

高级的在编译器层次

终极的在机器内部,这个根本防不胜防。

所以对程序员好一点。

原回答链接:https://www.zhihu.com/question/531724027/answer/2487130220

这让我想起了不久前发生的一件事:有黑客组织在IDA里面投毒。IDA是安全人员逆向分析的重要软件,给这里面投毒,属于定向攻击搞安全的人了,真是防不胜防啊。

收起阅读 »

Android: Shape 的使用

Android Shape 的使用 在Android开发中,我们可以使用shape定义各种各样的形状,也可以定义一些图片资源。相对于传统 图片来说,使用shape可以减少资源占用,减少安装包大小,还能够很好地适配不同尺寸的手机。 1. shape属性 sha...
继续阅读 »

Android Shape 的使用


在Android开发中,我们可以使用shape定义各种各样的形状,也可以定义一些图片资源。相对于传统
图片来说,使用shape可以减少资源占用,减少安装包大小,还能够很好地适配不同尺寸的手机。


1. shape属性



  • shape 属性基本语法示例:


<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<!-- 圆角属性-->
<corners
android:bottomLeftRadius="10dp"
android:bottomRightRadius="10dp"
android:radius="5dp"
android:topLeftRadius="15dp"
android:topRightRadius="15dp" />
<!-- 渐变属性-->
<gradient
android:angle="-45"
android:centerColor="#ff0099"
android:centerX="20"
android:centerY="30"
android:endColor="#80FF00"
android:gradientRadius="45dp"
android:startColor="#FF0089BD"
android:type="linear"
android:useLevel="false" />
<!-- 边距属性-->
<padding
android:bottom="12dp"
android:left="10dp"
android:right="15dp"
android:top="10dp" />
<!--大小属性-->
<size
android:width="200dp"
android:height="200dp" />
<!-- 填充属性-->
<!-- <solid android:color="#ffff9d"/>-->
<!-- 描边属性-->
<stroke
android:width="2dp"
android:color="#dcdcdc" />
</shape>

2. 基本属性


Shape可以定义控件的一些展示效果,例如圆角,渐变,填充,描边,大小,边距; shape 子标签就可以实现这些效果, shape 子标签有下面几个属性:corners,gradient,padding,size,solid,stroke:



  • corners(圆角)是用来字义圆角


<corners //定义圆角
android:radius="10dp" //全部的圆角半径;
android:topLeftRadius="5dp" //左上角的圆角半径;
android:topRightRadius="5dp" //右上角的圆角半径;
android:bottomLeftRadius="5dp" //左下角的圆角半径;
android:bottomRightRadius="5dp" /> //右下角的圆角半径。


  • solid(填充色)是用以指定内部填充色;


  <solid android:color="#ffff00"/> //内部填充色


  • gradient(渐变)用以定义渐变色,可以定义两色渐变和三色渐变,及渐变样式;


<gradient
android:type=["linear" | "radial" | "sweep"] //共有3中渐变类型,线性渐变
(默认)/放射渐变/扫描式渐变;
android:angle="90" //渐变角度,必须为45的倍数,0为从左到右,90为从上到下;
android:centerX="0.5" //渐变中心X的相当位置,范围为0~1;
android:centerY="0.5" //渐变中心Y的相当位置,范围为0~1;
android:startColor="#24e9f2" //渐变开始点的颜色;
android:centerColor="#2564ef" //渐变中间点的颜色,在开始与结束点之间;
android:endColor="#25f1ef" //渐变结束点的颜色;
android:gradientRadius="5dp" //渐变的半径,只有当渐变类型为radial时才能使用;
android:useLevel="false" /> //使用LevelListDrawable时就要设置为true。设为
false时才有渐变效果。


  • stroke(描边)是描边属性,可以定义描边的宽度,颜色,虚实线等;


<stroke
android:width="1dp" //描边的宽度
android:color="#ff0000" //描边的颜色
// 以下两个属性设置虚线
android:dashWidth="1dp" //虚线的宽度,值为0时是实线
android:dashGap="1dp" />//虚线的间隔


  • padding(内边距)是用来定义内部边距


<padding
android:left="10dp" //左内边距;
android:top="10dp" //上内边距;
android:right="10dp" //右内边距;
android:bottom="10dp" /> //下内边距。


  • size(大小)标签是用来定义图形的大小的


<size
android:width="50dp" //宽度
android:height="50dp" />// 高度

3. 特殊属性


Shape可以定义当前Shape的形状的,比如矩形,椭圆形,线形和环形;这些都是通过 shape 标签属性来定义的, shape 标签有下面几个属性:rectangle,oval,line,ring:


<shape xmlns:android="http://schemas.android.com/apk/res/android"
//shape的形状,默认为矩形,可以设置为矩形(rectangle)、椭圆形(oval)、线性形状(line)环形(ring)
android:shape=["rectangle" | "oval" | "line" | "ring"]
//下面的属性只有在android:shape="ring"时可用:
android:innerRadius="10dp" // 内环的半径;
android:innerRadiusRatio="2" // 浮点型,以环的宽度比率来表示内环的半径;
android:thickness="3dp" // 环的厚度;
android:thicknessRatio="2" // 浮点型,以环的宽度比率来表示环的厚度;
android:useLevel="false"> // boolean值,如果当做是LevelListDrawable使用时值为
true,否则为false。
</shape>


  • rectangle(矩形)


<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/colorPrimary"/>
</shape>


  • oval(椭圆)


<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="@color/colorPrimary"/>
<size android:height="100dp"
android:width="100dp"/>
</shape>


  • line(线)


<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="line">
<stroke
android:width="1dp"
android:color="@color/colorAccent"
android:dashGap="3dp"//虚线间距
android:dashWidth="4dp"/>//虚线宽度
<size android:height="3dp"/>
</shape>


  • ring(圆环)


<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="ring"
android:useLevel="false"
android:innerRadius="20dp" // 内环的半径
android:thickness="10dp"> // 圆环宽度
<!--useLevel需要设置为false-->
<solid android:color="@color/colorAccent"/>
</shape>

4.shape用法



  • 在res/drawable下新建 shape_text.xml 文件;


//参考 1.shape属性
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<!-- 圆角属性-->
<corners ... />
<!-- 渐变属性-->
<gradient ... />
<!-- 边距属性-->
<padding ... />
<!--大小属性-->
<size ... />
<!-- 描边属性-->
<stroke ... />
</shape>


  • 在布局中引用 shape_text.xml 文件;


<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/shape_text"
android:text="Shape测试"
android:textColor="@android:color/black"
android:textSize="15sp" />
</LinearLayout>

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

Android技术分享|【Android踩坑】怀疑人生,主线程修改UI也会崩溃?

前言 某天早晨,吃完早餐,坐回工位,打开电脑,开启chrome,进入友盟页面,发现了一个崩溃信息: java.lang.RuntimeException: Unable to resume activity {com.youdao.youdaomath/com...
继续阅读 »

前言


某天早晨,吃完早餐,坐回工位,打开电脑,开启chrome,进入友盟页面,发现了一个崩溃信息:


java.lang.RuntimeException: Unable to resume activity {com.youdao.youdaomath/com.youdao.youdaomath.view.PayCourseVideoActivity}: android.view.ViewRootImpl$CalledFromWrongThreadException: Only the original thread that created a view hierarchy can touch its views.
at android.app.ActivityThread.performResumeActivity(ActivityThread.java:3824)
at android.app.ActivityThread.handleResumeActivity(ActivityThread.java:3856)
at android.app.servertransaction.ResumeActivityItem.execute(ResumeActivityItem.java:51)
at android.app.servertransaction.TransactionExecutor.executeLifecycleState(TransactionExecutor.java:145)
at android.app.servertransaction.TransactionExecutor.execute(TransactionExecutor.java:70)
at android.app.ActivityThread$H.handleMessage(ActivityThread.java:1831)
at android.os.Handler.dispatchMessage(Handler.java:106)
at android.os.Looper.loop(Looper.java:201)
at android.app.ActivityThread.main(ActivityThread.java:6806)
at java.lang.reflect.Method.invoke(Native Method)
at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:547)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:873)
Caused by: android.view.ViewRootImpl$CalledFromWrongThreadException: Only the original thread that created a view hierarchy can touch its views.
at android.view.ViewRootImpl.checkThread(ViewRootImpl.java:8000)
at android.view.ViewRootImpl.requestLayout(ViewRootImpl.java:1292)
at android.view.View.requestLayout(View.java:23147)
at android.view.View.requestLayout(View.java:23147)
at android.widget.TextView.checkForRelayout(TextView.java:8914)
at android.widget.TextView.setText(TextView.java:5736)
at android.widget.TextView.setText(TextView.java:5577)
at android.widget.TextView.setText(TextView.java:5534)
at android.widget.Toast.setText(Toast.java:332)
at com.youdao.youdaomath.view.common.CommonToast.showShortToast(CommonToast.java:40)
at com.youdao.youdaomath.view.PayCourseVideoActivity.checkNetWork(PayCourseVideoActivity.java:137)
at com.youdao.youdaomath.view.PayCourseVideoActivity.onResume(PayCourseVideoActivity.java:218)
at android.app.Instrumentation.callActivityOnResume(Instrumentation.java:1413)
at android.app.Activity.performResume(Activity.java:7400)
at android.app.ActivityThread.performResumeActivity(ActivityThread.java:3816)

一眼看上去似乎是比较常见的子线程修改UI的问题。并且是在Toast上面报出的,常识告诉我Toast在子线程弹出是会报错,但是应该是提示Looper没有生成的错,而不应该是上面所报出的错误。那么会不会是生成Looper以后报的错的?


一、Demo 验证


所以我先做了一个demo,如下:


    @Override
protected void onResume() {
super.onResume();
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
Toast.makeText(MainActivity.this,"子线程弹出Toast",Toast.LENGTH_SHORT).show();
}
});
thread.start();
}

运行一下,果不其然崩溃掉,错误信息就是提示我必须准备好looper才能弹出toast:


    java.lang.RuntimeException: Can't toast on a thread that has not called Looper.prepare()
at android.widget.Toast$TN.<init>(Toast.java:393)
at android.widget.Toast.<init>(Toast.java:117)
at android.widget.Toast.makeText(Toast.java:280)
at android.widget.Toast.makeText(Toast.java:270)
at com.netease.photodemo.MainActivity$1.run(MainActivity.java:22)
at java.lang.Thread.run(Thread.java:764)

接下来就在toast里面准备好looper,再试试吧:


        Thread thread = new Thread(new Runnable() {
@Override
public void run() {
Looper.prepare();
Toast.makeText(MainActivity.this,"子线程弹出Toast",Toast.LENGTH_SHORT).show();
Looper.loop();
}
});
thread.start();

运行发现是能够正确的弹出Toast的:


在这里插入图片描述


那么问题就来了,为什么会在友盟中出现这个崩溃呢?


二、再探堆栈


然后仔细看了下报错信息有两行重要信息被我之前略过了:


at com.youdao.youdaomath.view
.PayCourseVideoActivity.onResume(PayCourseVideoActivity.java:218)
android.widget.Toast.setText(Toast.java:332)

发现是在主线程报了Toast设置Text的时候的错误。这就让我很纳闷了,子线程修改UI会报错,主线程也会报错?
感觉这么多年Android白做了。这不是最基本的知识么?
于是我只能硬着头皮往源码深处看了:
先来看看Toast是怎么setText的:


    public static Toast makeText(@NonNull Context context, @Nullable Looper looper,
@NonNull CharSequence text, @Duration int duration) {
Toast result = new Toast(context, looper);

LayoutInflater inflate = (LayoutInflater)
context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
View v = inflate.inflate(com.android.internal.R.layout.transient_notification, null);
TextView tv = (TextView)v.findViewById(com.android.internal.R.id.message);
tv.setText(text);

result.mNextView = v;
result.mDuration = duration;

return result;
}

很常规的一个做法,先是inflate出来一个View对象,再从View对象找出对应的TextView,然后TextView将文本设置进去。


至于setText在之前有详细说过,是在ViewRootImpl里面进行checkThread是否在主线程上面。所以感觉似乎一点问题都没有。那么既然出现了这个错误,总得有原因吧,或许是自己源码看漏了?


那就重新再看一遍ViewRootImpl#checkThread方法吧:


    void checkThread() {
if (mThread != Thread.currentThread()) {
throw new CalledFromWrongThreadException(
"Only the original thread that created a view hierarchy can touch its views.");
}
}

这一看,还真的似乎给我了一点头绪,系统在checkThread的时候并不是将Thread.currentThread和MainThread作比较,而是跟mThread作比较,那么有没有一种可能mThread是子线程?


一想到这里,我就兴奋了,全类查看mThread到底是怎么初始化的:


    public ViewRootImpl(Context context, Display display) {
...代码省略...
mThread = Thread.currentThread();
...代码省略...
}

可以发现全类只有这一处对mThread进行了赋值。那么会不会是子线程初始化了ViewRootimpl呢?似乎我之前好像也没有研究过Toast为什么会弹出来,所以顺便就先去了解下Toast是怎么show出来的好了:


    /**
* Show the view for the specified duration.
*/
public void show() {
if (mNextView == null) {
throw new RuntimeException("setView must have been called");
}

INotificationManager service = getService();
String pkg = mContext.getOpPackageName();
TN tn = mTN;
tn.mNextView = mNextView;

try {
service.enqueueToast(pkg, tn, mDuration);
} catch (RemoteException e) {
// Empty
}
}

调用Toast的show方法时,会通过Binder获取Service即NotificationManagerService,然后执行enqueueToast方法(NotificationManagerService的源码就不做分析),然后会执行Toast里面如下方法:


        @Override
public void show(IBinder windowToken) {
if (localLOGV) Log.v(TAG, "SHOW: " + this);
mHandler.obtainMessage(SHOW, windowToken).sendToTarget();
}

发送一个Message,通知进行show的操作:


        @Override
public void show(IBinder windowToken) {
if (localLOGV) Log.v(TAG, "SHOW: " + this);
mHandler.obtainMessage(SHOW, windowToken).sendToTarget();
}

在Handler的handleMessage方法中找到了SHOW的case,接下来就要进行真正show的操作了:


        public void handleShow(IBinder windowToken) {
if (localLOGV) Log.v(TAG, "HANDLE SHOW: " + this + " mView=" + mView
+ " mNextView=" + mNextView);
// If a cancel/hide is pending - no need to show - at this point
// the window token is already invalid and no need to do any work.
if (mHandler.hasMessages(CANCEL) || mHandler.hasMessages(HIDE)) {
return;
}
if (mView != mNextView) {
// remove the old view if necessary
handleHide();
mView = mNextView;
Context context = mView.getContext().getApplicationContext();
String packageName = mView.getContext().getOpPackageName();
if (context == null) {
context = mView.getContext();
}
mWM = (WindowManager)context.getSystemService(Context.WINDOW_SERVICE);
// We can resolve the Gravity here by using the Locale for getting
// the layout direction
final Configuration config = mView.getContext().getResources().getConfiguration();
final int gravity = Gravity.getAbsoluteGravity(mGravity, config.getLayoutDirection());
mParams.gravity = gravity;
if ((gravity & Gravity.HORIZONTAL_GRAVITY_MASK) == Gravity.FILL_HORIZONTAL) {
mParams.horizontalWeight = 1.0f;
}
if ((gravity & Gravity.VERTICAL_GRAVITY_MASK) == Gravity.FILL_VERTICAL) {
mParams.verticalWeight = 1.0f;
}
mParams.x = mX;
mParams.y = mY;
mParams.verticalMargin = mVerticalMargin;
mParams.horizontalMargin = mHorizontalMargin;
mParams.packageName = packageName;
mParams.hideTimeoutMilliseconds = mDuration ==
Toast.LENGTH_LONG ? LONG_DURATION_TIMEOUT : SHORT_DURATION_TIMEOUT;
mParams.token = windowToken;
if (mView.getParent() != null) {
if (localLOGV) Log.v(TAG, "REMOVE! " + mView + " in " + this);
mWM.removeView(mView);
}
if (localLOGV) Log.v(TAG, "ADD! " + mView + " in " + this);
// Since the notification manager service cancels the token right
// after it notifies us to cancel the toast there is an inherent
// race and we may attempt to add a window after the token has been
// invalidated. Let us hedge against that.
try {
mWM.addView(mView, mParams);
trySendAccessibilityEvent();
} catch (WindowManager.BadTokenException e) {
/* ignore */
}
}
}

代码有点长,我们最需要关心的就是mWm.addView方法。


相信看过ActivityThread的同学应该知道mWm.addView方法是在ActivityThread的handleResumeActivity里面也有调用过,意思就是进行ViewRootImpl的初始化,然后通过ViewRootImp进行View的测量,布局,以及绘制。


看到这里,我想到了一个可能的原因:


那就是我的Toast是一个全局静态的Toast对象,然后第一次是在子线程的时候show出来,这个时候ViewRootImpl在初始化的时候就会将子线程的对象作为mThread,然后下一次在主线程弹出来就出错了吧?想想应该是这样的。


三、再探Demo


所以继续做我的demo来印证我的想法:


    @Override
protected void onResume() {
super.onResume();
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
Looper.prepare();
sToast = Toast.makeText(MainActivity.this,"子线程弹出Toast",Toast.LENGTH_SHORT);
sToast.show();
Looper.loop();
}
});
thread.start();
}

public void click(View view) {
sToast.setText("主线程弹出Toast");
sToast.show();
}

做了个静态的toast,然后点击按钮的时候弹出toast,运行一下:


在这里插入图片描述


发现竟然没问题,这时候又开始怀疑人生了,这到底怎么回事。ViewRootImpl此时的mThread应该是子线程啊,没道理还能正常运行,怎么办呢?debug一步一步调试吧,一步一步调试下来,发现在View的requestLayout里面parent竟然为空了:


在这里插入图片描述


然后在仔细看了下当前View是一个LinearLayout,然后这个View的子View是TextView,文本内容是"主线程弹出toast",所以应该就是Toast在new的时候inflate的布局


View v = inflate.inflate(com.android.internal.R.layout.transient_notification, null);

找到了对应的toast布局文件,打开一看,果然如此:


<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:background="?android:attr/toastFrameBackground">

<TextView
android:id="@android:id/message"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginHorizontal="24dp"
android:layout_marginVertical="15dp"
android:layout_gravity="center_horizontal"
android:textAppearance="@style/TextAppearance.Toast"
android:textColor="@color/primary_text_default_material_light"
/>

</LinearLayout>

也就是说此时的View已经是顶级View了,它的parent应该就是ViewRootImpl,那么为什么ViewRootImpl是null呢,明明之前已经show过了。看来只能往Toast的hide方法找原因了


四、深入源码


所以重新回到Toast的类中,查看下Toast的hide方法(此处直接看Handler的hide处理,之前的操作与show类似):


public void handleHide() {
if (localLOGV) Log.v(TAG, "HANDLE HIDE: " + this + " mView=" + mView);
if (mView != null) {
// note: checking parent() just to make sure the view has
// been added... i have seen cases where we get here when
// the view isn't yet added, so let's try not to crash.
if (mView.getParent() != null) {
if (localLOGV) Log.v(TAG, "REMOVE! " + mView + " in " + this);
mWM.removeViewImmediate(mView);
}

// Now that we've removed the view it's safe for the server to release
// the resources.
try {
getService().finishToken(mPackageName, this);
} catch (RemoteException e) {
}

mView = null;
}
}

此处调用了mWm的removeViewImmediate,即WindowManagerImpl里面的removeViewImmediate方法:


    @Override
public void removeViewImmediate(View view) {
mGlobal.removeView(view, true);
}

会调用WindowManagerGlobal的removeView方法:


public void removeView(View view, boolean immediate) {
if (view == null) {
throw new IllegalArgumentException("view must not be null");
}

synchronized (mLock) {
int index = findViewLocked(view, true);
View curView = mRoots.get(index).getView();
removeViewLocked(index, immediate);
if (curView == view) {
return;
}

throw new IllegalStateException("Calling with view " + view
+ " but the ViewAncestor is attached to " + curView);
}
}

然后调用removeViewLocked方法:


private void removeViewLocked(int index, boolean immediate) {
ViewRootImpl root = mRoots.get(index);
View view = root.getView();

if (view != null) {
InputMethodManager imm = InputMethodManager.getInstance();
if (imm != null) {
imm.windowDismissed(mViews.get(index).getWindowToken());
}
}
boolean deferred = root.die(immediate);
if (view != null) {
//此处调用View的assignParent方法将viewParent置空
view.assignParent(null);
if (deferred) {
mDyingViews.add(view);
}
}
}

所以也就是说在Toast时间到了以后,会调用hide方法,此时会将parent置成空,所以我刚才试的时候才没有问题。那么按道理说只要在Toast没有关闭的时候点击再次弹出toast应该就会报错。


所以还是原来的代码,再来一次,这次不等Toast关闭,再次点击:


在这里插入图片描述


果然如预期所料,此时在主线程弹出Toast就会崩溃。


五、发现原因


那么问题原因找到了:


是在项目子线程中有弹出过Toast,然后Toast并没有关闭,又在主线程弹出了同一个对象的toast,会造成崩溃。


此时内心有个困惑:


如果是子线程弹出Toast,那我就需要写Looper.prepare方法和Looper.loop方法,为什么我自己一点印象都没有。


于是我全局搜索了Looper.prepare,发现并没有找到对应的代码。所以我就全局搜索了Toast调用的地方,发现在JavaBridge的回调当中找到了:


    class JSInterface {
@JavascriptInterface
public void handleMessage(String msg) throws JSONException {
LogHelper.e(TAG, "msg::" + msg);
JSONObject jsonObject = new JSONObject(msg);
String callType = jsonObject.optString(JS_CALL_TYPE);
switch (callType) {
...代码省略..
case JSCallType.SHOW_TOAST:
showToast(jsonObject);
break;
default:
break;
}
}
}

/**
* 弹出吐司
* @param jsonObject
* @throws JSONException
*/
public void showToast(JSONObject jsonObject) throws JSONException {
JSONObject payDataObj = jsonObject.getJSONObject("data");
String message = payDataObj.optString("data");
CommonToast.showShortToast(message);
}

但是看到这段代码,又有疑问了,我并没有在Javabridge的回调中看到有任何准备Looper的地方,那么为什么Toast没有崩溃掉?


所以在此处加了一段代码:


    class JSInterface {
@JavascriptInterface
public void handleMessage(String msg) throws JSONException {
LogHelper.e(TAG, "msg::" + msg);
JSONObject jsonObject = new JSONObject(msg);
String callType = jsonObject.optString(JS_CALL_TYPE);
Thread currentThread = Thread.currentThread();
Looper looper = Looper.myLooper();
switch (callType) {
...代码省略..
case JSCallType.SHOW_TOAST:
showToast(jsonObject);
break;
default:
break;
}
}
}

并且加了一个断点,来查看下此时的情况:


在这里插入图片描述


确实当前线程是JavaBridge线程,另外JavaBridge线程中已经提前给开发者准备好了Looper。所以也难怪一方面奇怪自己怎么没有写Looper的印象,一方面又很好奇为什么这个线程在开发者没有准备Looper的情况下也能正常弹出Toast。


总结


至此,真相终于找出来了。


相比较发生这个bug 的原因,解决方案就显得非常简单了。


只需要在CommonToast的showShortToast方法内部判断是否为主线程调用,如果不是的话,new一个主线程的Handler,将Toast扔到主线程弹出来。


这样就会避免了子线程弹出。


PS:本人还得吐槽一下Android,Android官方一方面明明宣称不能在主线程以外的线程进行UI的更新,**另一方面在初始化ViewRootImpl的时候又不把主线程作为成员变量保存起来,而是直接获取当前所处的线程作为mThread保存起来,这样做就有可能会出现子线程更新UI的操作。**从而引起类似我今天的这个bug。


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

Android性能优化 -- 大图治理

在实际的Android项目开发中,图片是必不可少的元素,几乎所有的界面都是由图片构成的;像列表页、查看大图页等,都是需要展示图片,而且这两者是有共同点的,列表展示的Item数量多,如果全部加载进来势必会造成OOM,因此列表页通常采用分页加载,加上Recycle...
继续阅读 »

在实际的Android项目开发中,图片是必不可少的元素,几乎所有的界面都是由图片构成的;像列表页、查看大图页等,都是需要展示图片,而且这两者是有共同点的,列表展示的Item数量多,如果全部加载进来势必会造成OOM,因此列表页通常采用分页加载,加上RecyclerView的复用机制,一般很少会发生OOM。


但是对于大图查看,通常在外界展示的是一张缩略图,点开之后放大就是原图,如果图片很大,OOM发生也是正常的,因此在加载大图的时候,可以看下面这张图


image.png


一张图片如果很大,在手机屏幕中并不能完全展示,那么其实就没有必要讲图片完全加载进来,而是可以采用分块加载的方式,只展示显示的那一部分,当图片向上滑动的时候,之前展示的区域内存能够复用,不需要开辟新的内存空间来承接新的模块,从而达到了大图的治理的目的。


1 自定义大图View


像在微信中点击查看大图,查看大图的组件就是一个自定义View,能够支持滑动、拖拽、放大等功能,因此我们也可以自定义一个类似于微信的大图查看器,从中了解图片加载优化的魅力


1.1 准备工作


class BigView : View{

constructor(context: Context):super(context){
initBigView(context)
}
constructor(context: Context,attributeSet: AttributeSet):super(context,attributeSet){
initBigView(context)
}

private fun initBigView(context: Context) {

}

}

本节使用的语言为kotlin,需要java代码的伙伴们可以找我私聊哦。


网站总数据测评展示信息长图设计__2022-08-13+16_36_25.jpeg
这个是我从网站上找的一张长图,大概700K左右,需要的可以自行下载,其实想要了解其中的原理和实现,不一定要找一张特别大的图片,所有的问题都是举一反三的。


class BigView : View, GestureDetector.OnGestureListener, View.OnTouchListener {

//分块加载
private lateinit var mRect: Rect

//内存复用
private lateinit var mOptions: BitmapFactory.Options

//手势
private lateinit var mGestureDetector: GestureDetector

//滑动
private lateinit var mScroller: Scroller

constructor(context: Context) : super(context) {
initBigView(context)
}

constructor(context: Context, attributeSet: AttributeSet) : super(context, attributeSet) {
initBigView(context)
}

private fun initBigView(context: Context) {
mRect = Rect()
mOptions = BitmapFactory.Options()
mGestureDetector = GestureDetector(context, this)
mScroller = Scroller(context)
setOnTouchListener(this)
}

override fun onDown(e: MotionEvent?): Boolean {
return false
}

override fun onShowPress(e: MotionEvent?) {

}

override fun onSingleTapUp(e: MotionEvent?): Boolean {
return false
}

override fun onScroll(
e1: MotionEvent?,
e2: MotionEvent?,
distanceX: Float,
distanceY: Float
): Boolean {
return false
}

override fun onLongPress(e: MotionEvent?) {

}

override fun onFling(
e1: MotionEvent?,
e2: MotionEvent?,
velocityX: Float,
velocityY: Float
): Boolean {
return false
}

override fun onTouch(v: View?, event: MotionEvent?): Boolean {
return false
}

}

前面我们提到的分块加载、内存复用、手势等操作,直接在view初始化时完成,这样我们前期的准备工作就完成了。


1.2 图片宽高适配


当我们加载一张图片的时候,要让这张图片完全展示在手机屏幕上不被裁剪,就需要做宽高的适配;如果这张图片大小是80M,那么为了获取宽高而将图片加载到内存中肯定会OOM,那么在图片加载到内存之前就像获取图片的宽高该怎么办呢?BitmapFactory.Options就提供了这个手段


fun setImageUrl(inputStream: InputStream) {
//获取图片宽高
mOptions.inJustDecodeBounds = true

BitmapFactory.decodeStream(inputStream,null,mOptions)
imageWidth = mOptions.outWidth
imageHeight = mOptions.outHeight

mOptions.inJustDecodeBounds = false

//开启复用
mOptions.inMutable = true
mOptions.inPreferredConfig = Bitmap.Config.RGB_565

//创建区域解码器
try {
BitmapRegionDecoder.newInstance(inputStream,false)
}catch (e:Exception){

}
requestLayout()
}

当设置inJustDecodeBounds为true(记住要成对出现,使用完成之后需要设置为false),意味着我调用decodeStream方法的时候,不会将图片的内存加载而是仅仅为了获取宽高。


然后拿到了图片的宽高之后呢,调用requestLayout方法,会回调onMeasure方法,这个方法大家就非常熟悉了,能够拿到view的宽高,从而完成图片的适配


override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
//适配
viewWidth = measuredWidth
viewHeight = measuredHeight

originScale = viewWidth / imageWidth.toFloat()
mScale = originScale

//分块加载首次进入展示的rect
mRect.left = 0
mRect.top = 0
mRect.right = imageWidth
mRect.bottom = (viewHeight / mScale).toInt()

}

这里设置Rect的right就是图片的宽度,因为原始图片的宽度可能比控件的宽度要宽,因此是将控件的宽度与图片的宽度对比获取了缩放比,那么Rect的bottom就需要等比缩放


这里的mRect可以看做是这张图片上的一个滑动窗口,无论是放大还是缩小,只要在屏幕上看到的区域,都可以看做是mRect在这张图片上来回移动截取的目标区域


1.3 BitmapRegionDecoder


在onMeasure中,我们定义了需要加载的图片的Rect,这是一块区域,那么我们通过什么样的方式能够将这块区域的图片加载出来,就是通过BitmapRegionDecoder区域解码器。


区域解码器,顾名思义,能够在某个区域进行图片解码展示


//创建区域解码器
try {
BitmapRegionDecoder.newInstance(inputStream,false)
}catch (e:Exception){

}

在传入图片流的时候,我们就已经创建了BitmapRegionDecoder,同时将图片流作为参数构建了解码器,那么这个解码器其实已经拿到了整张图片的资源,因此任意一块区域,通过BitmapRegionDecoder都能够解码展示出来


override fun onDraw(canvas: Canvas?) {
super.onDraw(canvas)
mRegionDecoder ?: return

//复用bitmap
mOptions.inBitmap = mutableBitmap
mutableBitmap = mRegionDecoder?.decodeRegion(mRect, mOptions)
//画出bitmap
val mMatrix = Matrix()
mMatrix.setScale(mScale, mScale)
mutableBitmap?.let {
canvas?.drawBitmap(it, mMatrix, null)
}
}

首先我们想要进行内存复用,需要调用BitmapFactory.Options的inBitmap,这个参数的含义就是,当我们在某块区域加载图片之后,如果图片上滑那么就需要重新加载,那么这个时候就不会重新开辟一块内存空间,而是复用之前的这块区域,所以调用BitmapRegionDecoder的decodeRegion方法,传入需要展示图片的区域,就能够给mutableBitmap赋值,这样就达成了一块内存空间,多次复用的效果。


image.png


这样通过压缩之后,在屏幕中展示了这个长图的最上边部分,那么剩下就需要做的是手势事件的处理。


2 大图View的手势事件处理


通过前期的准备工作,我们已经实现了图片的区域展示,那么接下来关键在于,我们通过手势来查看完整的图片,对于手势事件的响应,在onTouch方法中处理。


override fun onTouch(v: View?, event: MotionEvent?): Boolean {
return mGestureDetector.onTouchEvent(event)
}

2.1 GestureDetector


通常来说,手势事件的处理都是通过GestureDetector来完成,因此当onTouch方法监听到手势事件之后,直接传给GestureDetector,让GestureDetector来处理这个事件。


override fun onDown(e: MotionEvent?): Boolean {
return false
}

override fun onShowPress(e: MotionEvent?) {

}

override fun onSingleTapUp(e: MotionEvent?): Boolean {
return false
}

override fun onScroll(
e1: MotionEvent?,
e2: MotionEvent?,
distanceX: Float,
distanceY: Float
): Boolean {
return false
}

override fun onLongPress(e: MotionEvent?) {

}

override fun onFling(
e1: MotionEvent?,
e2: MotionEvent?,
velocityX: Float,
velocityY: Float
): Boolean {
return false
}

首先,我们先看下之前注册的GestureDetector.OnGestureListener监听器中实现的方法:


(1)onDown


override fun onDown(e: MotionEvent?): Boolean {

if(!mScroller.isFinished){
mScroller.forceFinished(true)
}
return true
}

当手指按下时,因为滑动的惯性,所以down事件的处理就是如果图片还在滑动时,按下就停止滑动;


(2)onScroll


那么当你的手指按下之后,可能还会继续滑动,那么就是会回调到onScroll方法,在这个方法中,主要做滑动的处理


override fun onScroll(
e1: MotionEvent?,
e2: MotionEvent?,
distanceX: Float,
distanceY: Float
): Boolean {

mRect.offset(0, distanceY.toInt())
//边界case处理
if (mRect.bottom > imageHeight) {
mRect.bottom = imageHeight
mRect.top = imageHeight - (viewHeight / mScale).toInt()
}

if (mRect.top < 0) {
mRect.top = 0
mRect.bottom = (viewHeight / mScale).toInt()
}
postInvalidate()
return false
}

在onScroll方法中,其实已经对滑动的距离做了计算(这个真的太nice了,不需要我们自己手动计算),因此只需要对mRect展示区域进行变换即可;


但是这里会有两个边界case,例如滑动到底部时就不能再滑了,这个时候,mRect的底部很可能都已经超过了图片的高度,因此需要做边界的处理,那么滑动到顶部的时候同样也是需要做判断。


图片滑动到底部的展示


(3)onFling


惯性滑动。我们在使用列表的时候,我们在滑动的时候,虽然手指的滑动距离很小,但是列表划出去的距离却很大,就是因为惯性,所以GestureDetector中对惯性也做了处理。


override fun onFling(
e1: MotionEvent?,
e2: MotionEvent?,
velocityX: Float,
velocityY: Float
): Boolean {

mScroller.fling(0, mRect.top, 0, -velocityY.toInt(), 0, 0, 0, imageHeight - viewHeight)

return false
}


//计算惯性
override fun computeScroll() {
super.computeScroll()
if (mScroller.isFinished) {
return
}
if (mScroller.computeScrollOffset()) {
//正在滑动
mRect.top = mScroller.currY
mRect.bottom = mScroller.currY + (viewHeight / mScale).toInt()
postInvalidate()
}
}

这个还是比较好理解的,就是设置最大的一个惯性滑动距离,无论怎么滑动,边界值就是从顶部一划到底,这个最大的距离就是 imageHeight - viewHeight


设置了惯性滑动的距离,那么在惯性滑动时,也需要实时改变mRect的解码范围,需要重写computeScroll方法,判断如果是正在滑动(通过 mScroller.computeScrollOffset() 判断),那么需要改变mRect的位置。


2.2 双击放大效果处理


我们在使用app时,双击某张图片或者双指拉动某张图片的时候,都会讲图片放大,这也是业内主流的两种图片放大的方式。


override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
//适配
viewWidth = measuredWidth
viewHeight = measuredHeight

//缩放比
val radio = viewWidth / imageWidth.toFloat()
//分块加载首次进入展示的rect
mRect.left = 0
mRect.top = 0
mRect.right = imageWidth
mRect.bottom = viewHeight

}

我们先看一下不能缩放时,mRect的赋值;那么当我们双击放大时,left和top的位置不会变,因为图片放大了,但是控件的大小不会变,因此left的最大值就是控件的宽度,bottom的最大值就是控件的高度。


override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
//适配
viewWidth = measuredWidth
viewHeight = measuredHeight

originScale = viewWidth / imageWidth.toFloat()
mScale = originScale

//分块加载首次进入展示的rect
mRect.left = 0
mRect.top = 0
mRect.right = Math.min(imageWidth, viewWidth)
mRect.bottom = Math.min((viewHeight / mScale).toInt(), viewHeight)

}

这里就将onMeasure进行改造;那么对于双击事件的处理,可以使用GestureDetector.OnDoubleTapListener来处理,在onDoubleTap事件中回调。


override fun onDoubleTap(e: MotionEvent?): Boolean {

if (mScale < originScale * 2) {
mScale = originScale * 2
} else {
mScale = originScale
}

postInvalidate()
return false
}

这里做了缩放就是判断mScale的值,因为一开始进来不是缩放的场景,因此 mScale = originScale,当双击之后,需要将mScale扩大2倍,当重新绘制的时候,Bitmap就放大了2倍。


那么当图片放大之后,之前横向不能滑动现在也可以滑动查看图片,所以需要处理,同时也需要考虑边界case


override fun onDoubleTap(e: MotionEvent?): Boolean {

if (mScale < originScale * 2) {
mScale = originScale * 2
} else {
mScale = originScale
}
//
mRect.right = mRect.left + (viewWidth / mScale).toInt()
mRect.bottom = mRect.top + (viewHeight / mScale).toInt()

if (mRect.bottom > imageHeight) {
mRect.bottom = imageHeight
mRect.top = imageHeight - (viewHeight / mScale).toInt()
}

if (mRect.top < 0) {
mRect.top = 0
mRect.bottom = (viewHeight / mScale).toInt()
}

if(mRect.right > imageWidth){
mRect.right = imageWidth
mRect.left = imageWidth - (viewWidth / mScale).toInt()
}

if(mRect.left < 0){
mRect.left = 0
mRect.right = (viewWidth / mScale).toInt()
}

postInvalidate()
return false
}

当双击图片之后,mRect解码的区域也随之改变,因此需要对right和bottom做相应的改变,图片放大或者缩小,都是在控件宽高的基础之上


override fun onScroll(
e1: MotionEvent?,
e2: MotionEvent?,
distanceX: Float,
distanceY: Float
): Boolean {

mRect.offset(distanceX.toInt(), distanceY.toInt())
//边界case处理
if (mRect.bottom > imageHeight) {
mRect.bottom = imageHeight
mRect.top = imageHeight - (viewHeight / mScale).toInt()
}

if (mRect.top < 0) {
mRect.top = 0
mRect.bottom = (viewHeight / mScale).toInt()
}

if(mRect.left < 0){
mRect.left = 0
mRect.right = (viewWidth / mScale).toInt()
}

if(mRect.right > imageWidth){
mRect.right = imageWidth
mRect.left = imageWidth - (viewWidth / mScale).toInt()
}

postInvalidate()
return false
}

因为需要左右滑动,那么onScroll方法也需要做相应的改动,mRect的offset需要加上x轴的偏移量。


2.3 手指放大效果处理


上一小节介绍了双击事件的效果处理,那么这一节就介绍另一个主流的放大效果实现 - 手指缩放,是依赖
ScaleGestureDetector,其实跟GestureDetector的使用方式一致,这里就不做过多的赘述。


mScaleGestureDetector = ScaleGestureDetector(context, ScaleGesture())
复制代码

在初始化ScaleGestureDetector的时候,需要传入一个ScaleGesture内部类,集成ScaleGestureDetector.SimpleOnScaleGestureListener,在onScale方法中获取缩放因子来绘制


inner class ScaleGesture : ScaleGestureDetector.SimpleOnScaleGestureListener() {
override fun onScale(detector: ScaleGestureDetector?): Boolean {

var scale = detector?.scaleFactor ?: mScale//可以代替mScale
if (scale < originScale) {
scale = originScale
} else if (scale > originScale * 2) {
scale = originScale * 2
}

//在原先基础上缩放
mRect.right = mRect.left + (viewWidth / scale).toInt()
mRect.bottom = mRect.top + (viewHeight / scale).toInt()

mScale = scale
postInvalidate()

return super.onScale(detector)
}
}

这里别忘记了别事件传递出来,对于边界case可自行处理


override fun onTouch(v: View?, event: MotionEvent?): Boolean {
mGestureDetector.onTouchEvent(event)
mScaleGestureDetector.onTouchEvent(event)
return true
}

下面附上大图治理的流程图


image.png


黄颜色模块: BitmapFactory.Options配置,避免整张大图直接加载在内存当中,通过开启内存复用(inMutable),使用区域解码器,绘制一块可见区域‘


浅黄色模块: View的绘制流程


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

分享Kotlin协程在Android中的使用

前言 之前我们学了几个关于协程的基础知识,本文将继续分享Kotlin协程的知识点~挂起,同时介绍协程在Android开发中的使用。 正文 挂起 suspend关键字 说到挂起,那么就会离不开suspend关键字,它是Kotlin中的一个关键字,它的中文意思是暂...
继续阅读 »

前言


之前我们学了几个关于协程的基础知识,本文将继续分享Kotlin协程的知识点~挂起,同时介绍协程在Android开发中的使用。


正文


挂起


suspend关键字


说到挂起,那么就会离不开suspend关键字,它是Kotlin中的一个关键字,它的中文意思是暂停或者挂起。


以下是通过suspend修饰的方法:


suspend fun suspendFun(){
   withContext(Dispatchers.IO){
       //do db operate
  }
}

通过suspend关键字来修饰方法,限制这个方法只能在协程里边调用,否则编译不通过。


suspend方法其实本身没有挂起的作用,只是在方法体力便执行真正挂起的逻辑,也相当于提个醒。如果使用suspend修饰的方法里边没有挂起的操作,那么就失去了它的意义,也就是无需使用。


虽然我们无法正常去调用它,但是可以通过反射去调用:


suspend fun hello() = suspendCoroutine<Int> { coroutine ->
   Log.i(myTag,"hello")
   coroutine.resumeWith(kotlin.Result.success(0))
}

//通过反射来调用:
fun helloTest(){
   val helloRef = ::hello
   helloRef.call()
}
//抛异常:java.lang.IllegalArgumentException: Callable expects 1 arguments, but 0 were provided.

fun helloTest(){
   val helloRef = ::hello
   helloRef.call(object : Continuation<Int>{
       override val context: CoroutineContext
           get() = EmptyCoroutineContext

       override fun resumeWith(result: kotlin.Result<Int>) {
           Log.i(myTag,"result : ${result.isSuccess} value:${result.getOrNull()}")
      }
  })
}
//输出:hello

挂起与恢复


看一个方法:


public suspend inline fun <T> suspendCancellableCoroutine(
   crossinline block: (CancellableContinuation<T>) -> Unit
): T =
   suspendCoroutineUninterceptedOrReturn { uCont ->
       val cancellable = CancellableContinuationImpl(uCont.intercepted(), resumeMode = MODE_CANCELLABLE)
       block(cancellable)
       cancellable.getResult()
  }

这是Kotlin协程提供的api,里边虽然只有短短的三句代码,但是实现甚是复杂而且关键。


继续跟进看看getResult()方法:


internal fun getResult(): Any? {
   installParentCancellationHandler()
   if (trySuspend()) return COROUTINE_SUSPENDED//返回此对象表示挂起
   
   val state = this.state
   if (state is CompletedExceptionally) throw recoverStackTrace(state.cause, this)
   
   if (resumeMode == MODE_CANCELLABLE) {//检查
       val job = context[Job]
       if (job != null && !job.isActive) {
           val cause = job.getCancellationException()
           cancelResult(state, cause)
           throw recoverStackTrace(cause, this)
      }
  }
   return getSuccessfulResult(state)//返回结果
}

最后写一段代码,然后转为Java看个究竟:


fun demo2(){
   GlobalScope.launch {
       val user = requestUser()
       println(user)
       val state = requestState()
       println(state)
  }
}

编译后生成的代码大致流程如下:


 public final Object invokeSuspend(Object result) {
      ...
       Object cs = IntrinsicsKt.getCOROUTINE_SUSPENDED();//上面所说的那个Any:CoroutineSingletons.COROUTINE_SUSPENDED
       switch (this.label) {
           case 0:
               this.label = 1;
               user = requestUser(this);
               if(user == cs){
                   return user
                }
               break;
           case 1:
               this.label = 2;
               user = result;
               println(user);
               state = requestState(this);
               if(state == cs){
                   return state
                }
               break;
           case 2:
              state = result;
              println(state)
               break;
      }
  }

当协程挂起后,然后恢复时,最终会调用invokeSuspend方法,而协程的block代码会封装在invokeSuspend方法中,使用状态来控制逻辑的实现,并且保证顺序执行。


通过以上我们也可以看出:



  • 本质上也是一个回调,Continuation

  • 根据状态进行流转,每遇到一个挂起点,就会进行一次状态的转移。


协程在Android中的使用


举个例子,使用两个UseCase来模拟挂起的操作,一个是网络操作,另一个是数据库的操作,然后操作ui,我们分别看一下代码上面的区别。


没有使用协程:


//伪代码
mNetworkUseCase.run(object: Callback {
onSuccess(user: User) {
    mDbUseCase.insertUser(user, object: Callback{
        onSuccess() {
            MainExcutor.excute({
                 tvUserName.text = user.name
              })
          }
      })
  }
})

我们可以看到,用回调的情况下,只要对数据操作稍微复杂点,回调到主线程进行ui操作时,很容易就嵌套了很多层,导致了代码难以看清楚。那么如果使用协程的话,这种代码就可以得到很大的改善。


使用协程:


private fun requestDataUseGlobalScope(){
  GlobalScope.launch(Dispatchers.Main){
//模拟从网络获取用户信息
       val user = mNetWorkUseCase.requireUser()
//模拟将用户插入到数据库
       mDbUseCase.insertUser(user)
//显示用户名
       mTvUserName.text = user.name
  }
}

对以上函数作说明:



  • 通过GlobalScope开启一个顶层协程,并制定调度器为Main,也就是该协程域是在主线程中运行的。

  • 从网络获取用户信息,这是一个挂起操作

  • 将用户信息插入到数据库,这也是一个挂起操作

  • 将用户名字显示,这个操作是在主线程中。


由此在这个协程体中就可以一步一步往下执行,最终达到我们想要的结果。


如果我们需要启动的线程越来越多,可以通过以下方式:


private fun requestDataUseGlobalScope1(){
   GlobalScope.launch(Dispatchers.Main){
       //do something
  }
}

private fun requestDataUseGlobalScope2(){
   GlobalScope.launch(Dispatchers.IO){
       //do something
  }
}

private fun requestDataUseGlobalScope3(){
   GlobalScope.launch(Dispatchers.Main){
       //do something
  }
}

但是平时使用,我们需要注意的就是要在适当的时机cancel掉,所以这时我们需要对每个协程进行引用:



private var mJob1: Job? = null
private var mJob2: Job? = null
private var mJob3: Job? = null

private fun requestDataUseGlobalScope1(){
   mJob1 = GlobalScope.launch(Dispatchers.Main){
       //do something
  }
}

private fun requestDataUseGlobalScope2(){
   mJob2 = GlobalScope.launch(Dispatchers.IO){
       //do something
  }
}

private fun requestDataUseGlobalScope3(){
   mJob3 = GlobalScope.launch(Dispatchers.Main){
       //do something
  }
}

如果是在Activity中,那么可以在onDestroy中cancel掉


override fun onDestroy() {
   super.onDestroy()
   mJob1?.cancel()
   mJob2?.cancel()
   mJob3?.cancel()
}

可能你发现了一个问题:如果启动的协程不止是三个,而是更多呢?


没错,如果我们只使用GlobalScope,虽然能够达到我们的要求,但是每次我们都需要去引用他,不仅麻烦,还有一点是它开启的顶层协程,如果有遗漏了,则可能出现内存泄漏。所以我们可以使用kotlin协程提供的一个方法MainScope()来代替它:


private val mMainScope = MainScope()

private fun requestDataUseMainScope1(){
   mMainScope.launch(Dispatchers.IO){
       //do something
  }
}
private fun requestDataUseMainScope2(){
   mMainScope.launch {
       //do something
  }
}
private fun requestDataUseMainScope3(){
   mMainScope.launch {
       //do something
  }
}

可以看到用法基本一样,但有一点很方便当我们需要cancel掉所有的协程时,只需在onDestroy方法cancel掉mMainScope就可以了:


override fun onDestroy() {
   super.onDestroy()
   mMainScope.cancel()
}

MainScope()方法:


@Suppress("FunctionName")
public fun MainScope(): CoroutineScope = ContextScope(SupervisorJob() + Dispatchers.Main)

使用的是一个SupervisorJob(制定的协程域是单向传递的,父传给子)和Main调度器的组合。


在平常开发中,可以的话使用类似于MainScope来启动协程。


结语


本文的分享到这里就结束了,希望能够给你带来帮助。关于Kotlin协程的挂起知识远不止这些,而且也不容易理清,还是会继续去学习它到底用哪些技术点,深入探究原理究竟是如何。


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

Flutter中的ValueNotifier和ValueListenableBuilder

在这篇文章中,我们将深入探讨ValueNotifier及其相应的主题。 ValueNotifier简介 ValueNotifier是继承自ChangeNotifier的一个类。该类可以保存单个值,并每当其持有值更改时会通知正在监听它的Widget。ValueN...
继续阅读 »

在这篇文章中,我们将深入探讨ValueNotifier及其相应的主题。


ValueNotifier简介


ValueNotifier是继承自ChangeNotifier的一个类。该类可以保存单个值,并每当其持有值更改时会通知正在监听它的WidgetValueNotifier还是非常有用得,性能高效,因为它只重建使用ValueListenableBuilder监听它的Widget


ValueNotifier使用


将ValueNotifier视为保留值的数据流。我们为它提供一个值,每个监听器都会收到值变化的通知。

我们可以创建任何类型的int、bool、list或任何自定义数据类型的ValueNotifier。您可以像这样创建一个ValueNotifier对象:


ValueNotifier<int> counter = ValueNotifier<int>(0);

我们可以像这样更新值:


counter.value = counter.value++;
//或者
counter.value++;

此外,我们可以像这样监听ValueNotifier


counter.addListener((){
print(counter.value);
});

删除值通知监听器


如果我们手动监听ValueNotifier,当前页面上不使用时,我们可以使用removeListener函数从ValueNotifier中手动删除侦听器。


ValueNotifier<int> valueNotifier = ValueNotifier(0);

void remove() {
valueNotifier.removeListener(doTaskWhenNotified);
}

void add(){
valueNotifier.addListener(doTaskWhenNotified);
}

void doTaskWhenNotified() {
print(valueNotifier.value);
}

释放ValueNotifier


当不再使用时调用dispose方法是一个良好做法,否则可能会导致内存泄漏。ValueNotifier上的dispose方法将释放任何订阅的监听器。


@override
void dispose() {
counter.dispose();
super.dispose();
}

什么是ValueListenableBuilder?


Flutter中有许多类型的构建器,如StreamBuilderAnimatedBuilderFutureBuilder等,他们的名字表明他们是消费的对象类型。ValueListenableBuilder使用ValueNotifier对象,如果我们想在Widget中的监听某一个值,我们可以使用ValueListenableBuilder,每次我们收到值更新时,都会执行构建器方法。当我们路由到另一个页面时,ValueListenableBuilder会自动在内部删除监听。


const ValueListenableBuilder({
required this.valueListenable,
required this.builder,
this.child,
})

这是ValueListenableBuilder的构造函数。在这里,valueListenable是要收听的ValueNotifier。构建器函数接收3个参数(BuildContext context, dynamic value, Widget child),该value是从提供的valueNotifier收到的数据。可以使用子参数。如果子构建成本高,并且不依赖于通知符的值,我们将使用它进行优化。


使用Value Notifier的计数器应用程序


使用ValueNotiferValueListenableBuilder的计数器应用程序,这里没有使用setState,当值发生改变的时候,我们只重建文本部分。


import 'package:flutter/material.dart';

void main() {
runApp(const App());
}

class App extends StatelessWidget {
const App({Key? key}) : super(key: key);

@override
Widget build(BuildContext context) {
return const MaterialApp(
home: HomePage(),
);
}
}

class HomePage extends StatefulWidget {
const HomePage({Key? key}) : super(key: key);

@override
State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
final _counterNotifier = ValueNotifier<int>(0);

@override
Widget build(BuildContext context) {
print('HOMEPAGE BUILT');
return Scaffold(
appBar: AppBar(title: const Text('Counter App')),
body: Center(
child: ValueListenableBuilder(
valueListenable: _counterNotifier,
builder: (context, value, _) {
return Text('Count: $value');
},
),
),
floatingActionButton: FloatingActionButton(
onPressed: () {
_counterNotifier.value++;
},
child: const Icon(Icons.add),
),
);
}

@override
void dispose() {
_counterNotifier.dispose();
super.dispose();
}
}

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

千万不要在方法上打断点!有大坑!

你好呀,我是歪歪。我上周遇到了一个莫名其妙的搞心态的问题,浪费了我好几个小时。气死我了,拿这几个小时来敲(摸)代(摸)码(鱼)不香吗?主要是最后问题的解决方式也让我特别的无语,越想越气,写篇文章吐槽一下。先说结论,也就是标题:在本地以 Debug 模式启动项目...
继续阅读 »

千万不要在方法上打断点!有大坑!

你好呀,我是歪歪。

我上周遇到了一个莫名其妙的搞心态的问题,浪费了我好几个小时。

气死我了,拿这几个小时来敲(摸)代(摸)码(鱼)不香吗?

主要是最后问题的解决方式也让我特别的无语,越想越气,写篇文章吐槽一下。

先说结论,也就是标题:

在本地以 Debug 模式启动项目的时候,千万不要在方法上打断点!千万不要!

首先什么是方法断点呢?

比如这样的,打在方法名这一行的断点:

你点击 IDEA 里面的下面这个图标,View Breakpoints,它会给你弹出一个框。

这个弹框里面展示的就是当前项目里面所有的断点,其中有一个复选框,Java Method Breakpoints,就是当前项目里面所有的“方法断点”:

那么这个玩意到底有什么坑呢?

当项目以 Debug 模式启动的时候,非常非常非常严重的拖慢启动速度。

给你看两个截图。

下面这个是我本地的一个非常简单的项目,没有方法断点的时候,只要 1.753 秒就启动完成了:

但是当我加上一个方法断点的时候,启动时间直接来到了 35.035 秒:

从 1.7 秒直接飙升到 35 秒,启动时间涨幅 2000%。

你说遭不遭得住?

遭不住,对不对。

那么我是怎么踩到这个坑的呢?

一个同事说他项目里面遇到一个匪夷所思的 BUG,想让我帮忙一起看看。

于是我先把项目拉了下来,然后简单的看了一下代码,准备把项目先在本地跑起来调试一下。

然而半个小时过去了,项目还没起来。我问他:这个项目本地启动时间怎么这么长呢?

他答:正常来说半分钟应该就启动起来了呀。

接着他还给我演示了一下,在他那边确实 30 多秒就启动成功了。

很明显,一样的代码,一个地方启动慢,一个地方启动快,首先怀疑环境问题。

于是我准备按照下面的流程走一次。

检查设置 -> 清空缓存 -> 换workspace -> 重启 -> 换电脑 -> 辞职

我检查了所有的配置、启动项、网络连接什么的,确保和他本地的环境是一模一样的。

这一套操作下来,差不多一小时过去了,并没有找到什么头绪。

但是那个时候我一点都不慌,我还有终极绝招:重启。

毕竟我的电脑已经好几个月没有关闭过了,重启一下也挺好的。

果然,重启了电脑之后,还是没有任何改变。

正在焦头烂额之际,同事过来问我啥进度了。

我能怎么说?

我只能说:从时间上来说应该解决了,但是实际上我连项目都还没启动成功。

听到这话,他坐在我的工位,准备帮我看一下。

半分钟之后,一个神奇的场景出现了,他在我的电脑上直接就把项目启动起来了。

一盘问,他并没有以 Debug 的模式启动,而是直接运行的。

用脚趾头想也知道,肯定是 Debug 模式在搞事情。

然后基于面向浏览器编程的原则,我现在有了几个关键词:IDEA debug 启动缓慢。

然后发现有很多人遇到了类似的问题,解决方法就是启动的时候取消项目里面的“方法断点”。

但是,遗憾的是,没有大多数文章都是说这样做就好了。但是并没有告诉我为什么这样做就好了。

我很想知道为什么会有这个坑,因为我用方法断点用的还是很多的,关键是以前在使用的过程中完全没有注意到还有这个坑。

“方法断点”还是非常实用的,比如我随便个例子。

之前写事务相关的文章的时候,提到过这样的一个方法:

java.sql.Connection#setAutoCommit

setAutoCommit 这个方法有好几个实现类,我也不知道具体会走哪一个:

所以,调试的时候可以在下面这个接口打上一个断点:

然后重启程序,IDEA 会自动帮你判断走那个实现类的:

但是需要特别说明的是,不是所有的方法断点都会导致启动缓慢的问题。至少在我本地看起来是这样的。

当我把方法断点加在 Mapper 的接口里面的时候,能稳定复现这个问题:

当把方法断点加在项目的其他方法上的时候,不是必现的,偶尔才会出现这个问题。

另外,其实当你以 Debug 模式启动且带有方法断点的时候,IDEA 是会弹出这个提醒,告诉你方法断点会导致 Debug 缓慢的问题:

但是,真男人,从不看提醒。反正我是直接就忽略了,根本没有关心弹窗的内容。

至于为什么会在 Mapper 的接口上打方法断点?

都怪我手贱,行了吧。

到底为什么

在找答案的过程中,我发现了这个 idea 的官方社区的链接:

intellij-support.jetbrains.com/hc/en-us/ar…

这个贴子,是 JetBrains Team 发布的,关于 Debug 功能可能会导致的性能缓慢的问题。

在这个帖子中,第一个性能点,就是 Method breakpoints。

官方是怎么解释这个问题的呢?

我给你翻译一波。

Method breakpoints will slow down debugger a lot because of the JVM design, they are expensive to evaluate.

他们说由于 JVM 的设计,方法断点会大大降低调试器的速度,因为这玩意的 “evaluate” 成本很高。

evaluate,四级单词,好好记一下,考试会考:

大概就是说你要用方法断点的功能,在启动过程中,就涉及到一个关于该断点进行“评估”的成本。成本就是启动缓慢。

怎么解决这个“评估”带来的成本呢?

官方给出的方案很简单粗暴:

不要使用方法断点,不就没有成本了?

所以,Remove,完事:

Remove method breakpoints and consider using the regular line breakpoints.

删除方法断点并考虑使用常规的 line breakpoints。

官方还是很贴心的,怕你不知道怎么 Remove 还专门补充了一句:

To verify that you don't have any method breakpoints open .idea/workspace.xml file in the project root directory (or .iws file if you are using the old project format) and look for any breakpoints inside the method_breakpoints node.

可以通过下面这个方法去验证你是否打开了方法断点。

就是去 .idea/workspace.xml 文件中,找到 method_breakpoints 这个 Node,如果有就 Remove 一下。

然后我看了一下我项目里面对应的文件,没有找到 method_breakpoints 关键字,但是找到了下面这个。

应该是文档发生了变化,问题不大,反正是一个意思,

其实官方给出的这个方法,虽然逼格稍微高一点,但还是我前面给的这个操作更简单:

针对“到底为什么”这个问题。

在这里,官方给的回答,特别的模糊:because of the JVM design。

别问,问就是由于 JVM 设计如此。

我觉得这不是我想要的答案,但是好在我在这个帖子下面找到了一个“好事之人”写的回复:

这个好事之人叫做 Gabi 老铁,我看到他回复的第一句话 “I made some research”,我就知道,这波稳了,找对地方了,答案肯定就藏在他附上的这个链接里面。

Gabi 老铁说:哥子们,我研究了一下这个方法断点为啥会慢的原因,研究报告在这里:

http://www.smartik.net/2017/11/met…

他甚至还来了一个概要:To make the long story short,长话短时。

他真的很贴心,我哭死。

他首先指出了问题的根本原因:

it seems that the root issue is that Method Breakpoints are implemented by using JDPA's Method Entry & Method Exit feature.

根本问题在于方法断点是通过使用 JDPA 的 Method Entry & Method Exit 特性实现的。

有同学就要问了,JDPA,是啥?

是个宝贝:

docs.oracle.com/javase/8/do…

JPDA,全称 Java Platform Debugger Architecture。

IDEA 里面的各种 Debug 功能,就是基于这个玩意来实现的。

不懂也没关系,这个东西面试又不考,在这里知道有这个技术就行。

接着,他用了四个 any 来完成了跳句四押:

This implementation requires the JVM to fire an event each time any thread enters any method and when any thread exits any method.

这个实现,要求 JVM,每次,在任何(any)线程进入任何(any)方法时,以及在任何(any)线程退出任何(any)方法时触发事件。

好家伙,这不就是个 AOP 吗?

这么一说,我就明白为什么方法断点的性能这么差了。要触发这么多进入方法和退出方法的事件,可不得耗费这么多时间吗?

具体的细节,他在前面说的研究报告里面都写清楚了,如果你对细节感兴趣的话,可以咨询阅读一下他的那篇报告。

话说他这个报告的名字也起的挺唬人的:Method Breakpoints are Evil。

我带你看两个关键的地方。

第一个是关于 Method Entry & Method Exit 的:

  • IDE 将断点添加到其内部方法断点 list 中
  • IDE 告诉前端启用 Method Entry & Method Exit 事件
  • 前端(调试器)通过代理将请求传递给 VM
  • 在每个 Method Entry & Method Exit 事件中,通过整个链将通知转发到 IDE
  • IDE 检查其方法断点 list 是否包含当前的这个方法。
  • 如果发现包含,说明这个方法上有一个方法断点,则 IDE 将向 VM 发送一个 SetBreakpoint 请求,打上断点。否则,VM 的线程将被释放,不会发生任何事情

这里是表明,前面我说的那个类似 AOP 的稍微具体一点的操作。

核心意思就一句话:触发的事件太多,导致性能下降厉害。

第二个关键的地方是这样的:

文章的最后给出了五个结论:

  • 方法断点 IDE 的特性,不是 JPDA 的特性
  • 方法断点是真的邪恶,evil 的一比
  • 方法断点将极大的影响调试程序
  • 只有在真正需要时才使用它们
  • 如果必须使用方法作为断点,请考虑关闭方法退出事件

前面四个点没啥说的了。

最后一个点:考虑关闭方法退出事件。

这个点验证起来非常简单,在方法断点上右键可以看到这个选项,Method Entry & Method Exit 默认都是勾选上了:

所以我在本地随便用一个项目验证了一下。

打开 Method Exit 事件,启动耗时:113.244 秒。

关闭 Method Exit 事件,启动耗时:46.754 秒。

你别说,还真有用。

现在我大概是知道为什么方法断点这么慢了。

这真不是 BUG,而是 feature。

而关于方法断点的这个问题,我顺便在社区搜索了一下,最早我追溯到了 2008 年:

这个老哥说他调试 Web 程序的速度慢到无法使用的程度。他的项目只启用了一行断点,没有方法断点。

请求大佬帮他看看。

然后大佬帮他一顿分析也没找到原因。

他自己也特别的纳闷,说:

我啥也没动,太奇怪了。这玩意有时可以,有时不行。

像不像一句经典台词:

但是问题最后还是解决了。怎么解决的呢?

他自己说:

确实是有个方法断点,他也不知道怎么打上这个断点的,可能和我一样,是手抖了吧。

意外收获

在前面出现的官方帖子的最下面,有这样的两个链接:

它指向了这个地方:

http://www.jetbrains.com/help/idea/d…

我把这部分链接都打开看了一遍,经过鉴定,这可真是好东西啊。

这是官方在手摸手教学,教你如何使用 Debug 模式。

我之前看过的一些调试小技巧相关的文章,原来就是翻译自官方这里啊。

我在这里举两个例子,算是一个导读,强烈推荐那些在 Debug 程序的时候,只知道不停的下一步、跳过当前断点等这样的基本操作的同学去仔细阅读,动手实操一把。

首先是这个:

针对 Java 的 Streams 流的调试。

官方给了一个调试的代码示例,我做了一点点微调,你粘过去就能跑:

class PrimeFinder {

    public static void main(String[] args) {
        IntStream.iterate(1, n -> n + 1)
                .limit(100)
                .filter(PrimeTest::isPrime)
                .filter(value -> value > 50)
                .forEach(System.out::println);
    }
}

class PrimeTest {
    static boolean isPrime(int candidate) {
        return candidate == 91 ||
                IntStream.rangeClosed(2, (int) Math.sqrt(candidate))
                        .noneMatch(n -> (candidate % n == 0));
    }
}
复制代码

代码逻辑很简单,就是找 100 以内的,大于 50 的素数。

很明显,在 isPrime 方法里面对 91 这个非素数做了特殊处理,导致程序最终会输出 91,也就是出 BUG 了。

虽然这个 BUG 一目了然,但是不要笑,要忍住,要假装不知道为什么。

现在我们要通过调试的方式找到 BUG。

断点打在这个位置:

以 Debug 的模式运行的时候,有这样的一个图标:

点击之后会有这样的一个弹窗出来:

上面框起来的是对应着程序的每一个方法调用顺序,以及调用完成之后的输出是什么。

下面框起来的这个 “Flat Mode” 点击之后是这样的:

最右边,也就是经过 filter 之后输出的结果。

里面就包含了 91 这个数:

点击这个 “91”,发现在经过第一个 filter 之后,91 这个数据还在。

说明这个地方出问题了。

而这个地方就是前面提到的对 “91” 做了特殊处理的 isPrime 方法。

这样就能有针对性的去分析这个方法,缩小问题排除范围。

这个功能怎么说呢,反正我的评论是:

总之,以上就是 IDEA 对于 Streams 流进行调试的一个简单示例。

接着再演示一个并发相关的:

官方给了这样的一个示例:

public class ConcurrencyTest {
    static final List a = Collections.synchronizedList(new ArrayList());

    public static void main(String[] args) {
        Thread t = new Thread(() -> addIfAbsent(17));
        t.start();
        addIfAbsent(17);
        t.join();
        System.out.println(a);
    }

    private static void addIfAbsent(int x) {
        if (!a.contains(x)) {
            a.add(x);
        }
    }
}
复制代码

代码里面搞一个线程安全的 list 集合,然后主线程和一个异步线程分别往这个 list 里面塞同一个数据。

按照 addIfAbsent 方法的意思,如果要添加的元素在 list 里面存在了,则不添加。

你说这个程序是线程安全的吗?

肯定不是。

你想想,先判断,再添加,经典的非原子性操作。

但是这个程序你拿去直接跑,又不太容易跑出线程不安全的场景:

怎么办?

Debug 就来帮你干这个事儿了。

在这里打一个断点,然后右键断点,选择 “Thread”:

这样程序跑起来的时候主线程和异步线程都会在这个地方停下来:

可以通过 “Frames” 中的下拉框分别选择 Debug 主线程还是异步线程。

由于两个线程都执行到了 add 方法,所以最终的输出是这样的:

这不就出现线程不安全了吗?

即使你知道这个地方是线程不安全的,但是如果没有 Debug 来帮忙调试,要通过程序输出来验证还是比较困难的。

毕竟多线程问题,大多数情况下都不是每次都能必现的问题。

定位到问题之后,官方也给出了正确的代码片段:

好了,说好了是导读,这都是基本操作。还是那句话,如果感兴趣,自己去翻一下,跟着案例操作一下。

就算你看到有人把 Debug 源码,玩出花来了,也无外乎不过是这样的几个基础操作的组合而已。

回首往事

让我们再次回到官方的“关于 Debug 功能可能会导致的性能缓慢的问题”这个帖子里面:

当我看到方框里面框起来的 “Collections classes” 和 “toString()” 方法的时候,眼泪都快下来了。

我最早开始写文章的时候,曾经被这个玩意坑惨了。

三年前,2019 年,我写了这篇文章《这道Java基础题真的有坑!我也没想到还有续集。》

当时 Debug 调试 ArrayList 的时候遇到一个问题,我一度以为我被质子干扰了:

一句话汇总就是在单线程的情况下,程序直接运行的结果和 Debug 输出的结果是不一样的。

当时我是百思不得其解。

直到 8 个月后,写《JDK的BUG导致的内存溢出!反正我是没想到还能有续集》这篇文章的时候才偶然间找到问题的答案。

根本原因就是在 Debug 模式下,IDEA 会自动触发集合类的 toString 方法。而在某些集合类的 toString 方法里面,会有诸如修改头节点的逻辑,导致程序运行结果和预期的不匹配。

也就是对应这句话:

翻译过来就是:老铁请注意,如果 toString 方法中的代码更改了程序的状态,则在 debug 状态下运行时,这些方法也可以更改应用程序的运行结果。

最后的解决方案就是关闭 IDEA 的这两个配置:

同时,我也在官方文档中找到了这个两个配置的解释:

http://www.jetbrains.com/help/idea/c…

主要是为了在 Debug 的过程中用更加友好的形式显示集合类。

啥意思?

给你看个例子。

这是没有勾选前面说的配置的时候,map 集合在 Debug 模式下的样子:

这是勾选之后,map 集合在 Debug 模式下的样子:

很明显,勾选了之后的样子,更加友好。

收起阅读 »

记录一次React程序死循环

一、错误复现开发环境报如下错误。Maximum update depth exceeded. This can happen when a component repeatedly calls setState inside componentWillUpda...
继续阅读 »

一、错误复现

开发环境报如下错误。

Maximum update depth exceeded. This can happen when a component repeatedly calls setState inside componentWillUpdate or componentDidUpdate. React limits the number of nested updates to prevent infinite loops.

Call Stack
 checkForNestedUpdates
  website/./node_modules/react-dom/cjs/react-dom.development.js:4013:321
 scheduleUpdateOnFiber
  website/./node_modules/react-dom/cjs/react-dom.development.js:3606:187
 dispatchAction
  website/./node_modules/react-dom/cjs/react-dom.development.js:2689:115
 eval
  website/./src/components/FileUpload.jsx:73:7
 invokePassiveEffectCreate
  website/./node_modules/react-dom/cjs/react-dom.development.js:3960:1047
 HTMLUnknownElement.callCallback
  website/./node_modules/react-dom/cjs/react-dom.development.js:657:119
 Object.invokeGuardedCallbackDev
  website/./node_modules/react-dom/cjs/react-dom.development.js:677:45
 invokeGuardedCallback
  website/./node_modules/react-dom/cjs/react-dom.development.js:696:126
 flushPassiveEffectsImpl
  website/./node_modules/react-dom/cjs/react-dom.development.js:3968:212
 unstable_runWithPriority
  website/./node_modules/scheduler/cjs/scheduler.development.js:465:16

二、错误排查

  1. 通过注释代码的方式,发现出问题的地方,是Assets组件中引用的FileUpload出了问题。正好最近也修改过FileUpload组件。
  2. 通过sourcetree对比git记录,看FileUpload组件被修改了什么?如下图。
  3. 再对比错误提示中的描述,其中componentWillUpdate or componentDidUpdate,推测就是指新增的useEffect代码片断。
  4. 将上述useEffect代码片断注释掉,果然错误消失。

三、原因分析

useEffect的特性表明,只要initFiles发生了改变,46-48行代码就会执行。
既然上述useEffect代码片断事实上造成了死循环,就还说明了一点:

  • setFileList(initFiles)改变了initFiles,才使得useEffect中的函数再次被调用。

那么,initFiles到底是经历了怎样的变化,才使得调用能够循环往复地发生呢?

输出fileListinitFiles

console.log(fileList === initFiles)

可以发现,只有第一次render时输出true,后续全部是false

  • 第一次输出true,表明useState的入参为array时,只是简单的赋值关系,fileListinitFiles指定了同一个内存地址。
  • setFileList函数实际上是做了一次浅拷贝,然后赋值给fileList,改变了fileList的内存指向,也就是改变了最新initFiles的内存指向。同时React保留了之前initFiles的值用来做依赖对比。
  • useEffect在对比引用类型的依赖,比如object/array时,采用的是简单的===操作符,也就是说比较内存地址是否一致。
  • 前后两次initFiles虽然内部数据相同,但内存指向不同,就被useEffect认为【依赖发生了改变】,从而导致了死循环。

四、解决方案1

  • 尽量不直接使用object或者array作为依赖项,而是使用值类型来代替引用类型

    useEffect(() => {
    //...
    }, [initFiles.length])

五、解决方案2

是不是在调用useState时,拷贝initFiles就可以了呢?

const [fileList, setFileList] = useState([...initFiles])

useEffect(() => {
if (fileList.length === 0) {
setFileList([...initFiles])
}
}, [initFiles])

这样依然会报同样的死循环错误,这又是为什么呢?

initFiles是从父组件传入的,会不会是FileUpload组件重新render的时候,initFiles已经被重新赋值了呢?接下来的两个demo,证明了这个推测。

  • Demo1 - 慎重打开。打开后会卡死浏览器标签: initFiles初始化时,使用[]作为默认值,结果出现死循环。
  • Demo1 - 放心打开。打开后不执行JS,不会卡死浏览器,可放心查看代码。
  • Demo2:initFiles初始化时,不使用默认值,且父组件不更新,结果不出现死循五。

Demo1中,initFiles作为一个prop,每次render时,都会被赋值为一个新的空数组,改变了其内存指向。导致useEffect不断执行。

const FileUpload = ({initFiles=[]}) => {}

Demo2中,initFiles的值完全由父组件传入,父组件的变量不变化时,initFiles没有改变。

const FileUpload = ({initFiles=[]}) => {}
const App = () => {
return <FileUpload initFiles={[]} />
}

也就是说,只要保障initFiles不被循环赋值,就能够避免死循环。

六、结论

不建议将引用类型如array/object作为useEffect的依赖项,因欺触发bug的可能性很大,而且排查错误比较困难。

建议使用一到多个值类型作为useEffect依赖项。

原文链接:https://segmentfault.com/a/1190000042302716


收起阅读 »

Java四大引用详解:强引用、软引用、弱引用、虚引用

面试官考察Java引用会问到强引用、弱引用、软引用、虚引用,具体有什么区别?本篇单独来详解 @mikechen Java引用 从JDK 1.2版本开始,对象的引用被划分为4种级别,从而使程序能更加灵活地控制对象的生命周期,这4种级别由高到低依次为:强引用、软引...
继续阅读 »

面试官考察Java引用会问到强引用、弱引用、软引用、虚引用,具体有什么区别?本篇单独来详解 @mikechen


Java引用


从JDK 1.2版本开始,对象的引用被划分为4种级别,从而使程序能更加灵活地控制对象的生命周期,这4种级别由高到低依次为:强引用、软引用、弱引用和虚引用。


强引用


强引用是最普遍的引用,一般把一个对象赋给一个引用变量,这个引用变量就是强引用。


比如:


//  强引用
MikeChen mikechen=new MikeChen();

在一个方法的内部有一个强引用,这个引用保存在Java栈中,而真正的引用内容(MikeChen)保存在Java堆中。



如果一个对象具有强引用,垃圾回收器不会回收该对象,当内存空间不足时,JVM 宁愿抛出 OutOfMemoryError异常。


如果强引用对象不使用时,需要弱化从而使GC能够回收,如下:


//帮助垃圾收集器回收此对象
mikechen=null;

显式地设置mikechen对象为null,或让其超出对象的生命周期范围,则GC认为该对象不存在引用,这时就可以回收这个对象,具体什么时候收集这要取决于GC算法。


举例:


package com.mikechen.java.refenence;

/**
* 强引用举例
*
* @author mikechen
*/
public class StrongRefenenceDemo {

    public static void main(String[] args) {
        Object o1 = new Object();
        Object o2 = o1;
        o1 = null;
        System.gc();
        System.out.println(o1);  //null
        System.out.println(o2);  //java.lang.Object@2503dbd3
    }
}

StrongRefenenceDemo 中尽管 o1已经被回收,但是 o2 强引用 o1,一直存在,所以不会被GC回收。


 


软引用


软引用是一种相对强引用弱化了一些的引用,需要用java.lang.ref.SoftReference 类来实现。


比如:


String str=new String("abc");                                     // 强引用
SoftReference<String> softRef=new SoftReference<String>(str);     // 软引用

如果一个对象只具有软引用,则内存空间足够,垃圾回收器就不会回收它,如果内存空间不足了,就会回收这些对象的内存。


先通过一个例子来了解一下软引用:


/**
* 弱引用举例
*
* @author mikechen
*/
Object obj = new Object();
SoftReference softRef = new SoftReference<Object>(obj);//删除强引用
obj = null;//调用gc

// 对象依然存在
System.gc();System.out.println("gc之后的值:" + softRef.get());

软引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用对象被垃圾回收,Java虚拟机就会把这个软引用加入到与之关联的引用队列中。


ReferenceQueue<Object> queue = new ReferenceQueue<>();
Object obj = new Object();
SoftReference softRef = new SoftReference<Object>(obj,queue);//删除强引用
obj = null;//调用gc
System.gc();
System.out.println("gc之后的值: " + softRef.get()); // 对象依然存在
//申请较大内存使内存空间使用率达到阈值,强迫gc
byte[] bytes = new byte[100 * 1024 * 1024];//如果obj被回收,则软引用会进入引用队列
Reference<?> reference = queue.remove();if (reference != null){
    System.out.println("对象已被回收: "+ reference.get());  // 对象为null
}

软引用通常用在对内存敏感的程序中,比如高速缓存就有用到软引用,内存够用的时候就保留,不够用就回收。


我们看下 Mybatis 缓存类 SoftCache 用到的软引用:


public Object getObject(Object key) {
    Object result = null;
    SoftReference<Object> softReference = (SoftReference)this.delegate.getObject(key);
    if (softReference != null) {
        result = softReference.get();
        if (result == null) {
            this.delegate.removeObject(key);
        } else {
            synchronized(this.hardLinksToAvoidGarbageCollection) {
                this.hardLinksToAvoidGarbageCollection.addFirst(result);
                if (this.hardLinksToAvoidGarbageCollection.size() > this.numberOfHardLinks) {
                    this.hardLinksToAvoidGarbageCollection.removeLast();
                }
            }
        }
    }
    return result;}

注意:软引用对象是在jvm内存不够的时候才会被回收,我们调用System.gc()方法只是起通知作用,JVM什么时候扫描回收对象是JVM自己的状态决定的,就算扫描到软引用对象也不一定会回收它,只有内存不够的时候才会回收。


 


弱引用


弱引用的使用和软引用类似,只是关键字变成了 WeakReference:


MikeChen mikechen = new MikeChen();
WeakReference<MikeChen> wr = new WeakReference<MikeChen>(mikechen );

弱引用的特点是不管内存是否足够,只要发生 GC,都会被回收。


举例说明:


package com.mikechen.java.refenence;

import java.lang.ref.WeakReference;

/**
* 弱引用
*
* @author mikechen
*/
public class WeakReferenceDemo {
    public static void main(String[] args) {
        Object o1 = new Object();
        WeakReference<Object> w1 = new WeakReference<Object>(o1);

        System.out.println(o1);
        System.out.println(w1.get());

        o1 = null;
        System.gc();

        System.out.println(o1);
        System.out.println(w1.get());
    }
}

 


弱引用的应用


WeakHashMap


public class WeakHashMapDemo {

    public static void main(String[] args) throws InterruptedException {
        myHashMap();
        myWeakHashMap();
    }

    public static void myHashMap() {
        HashMap<String, String> map = new HashMap<String, String>();
        String key = new String("k1");
        String value = "v1";
        map.put(key, value);
        System.out.println(map);

        key = null;
        System.gc();

        System.out.println(map);
    }

    public static void myWeakHashMap() throws InterruptedException {
        WeakHashMap<String, String> map = new WeakHashMap<String, String>();
        //String key = "weak";
        // 刚开始写成了上边的代码
        //思考一下,写成上边那样会怎么样? 那可不是引用了
        String key = new String("weak");
        String value = "map";
        map.put(key, value);
        System.out.println(map);
        //去掉强引用
        key = null;
        System.gc();
        Thread.sleep(1000);
        System.out.println(map);
    }}

当key只有弱引用时,GC发现后会自动清理键和值,作为简单的缓存表解决方案。


ThreadLocal


static class ThreadLocalMap {

    static class Entry extends WeakReference<ThreadLocal<?>> {
        Object value;

        Entry(ThreadLocal<?> k, Object v) {
            super(k);
            value = v;
        }
    }
    //......}

ThreadLocal.ThreadLocalMap.Entry 继承了弱引用,key为当前线程实例,和WeakHashMap基本相同。


 


虚引用


虚引用”顾名思义,就是形同虚设,与其他几种引用都不同,虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收器回收。


虚引用也称为“幽灵引用”或者“幻影引用”,它是最弱的一种引用关系。


虚引用需要java.lang.ref.PhantomReference 来实现:


A a = new A();
ReferenceQueue<A> rq = new ReferenceQueue<A>();
PhantomReference<A> prA = new PhantomReference<A>(a, rq);
复制代码

虚引用主要用来跟踪对象被垃圾回收器回收的活动。


虚引用与软引用和弱引用的一个区别在于:虚引用必须和引用队列 (ReferenceQueue)联合使用,当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之 关联的引用队列中。


 


Java引用总结


java4种引用的级别由高到低依次为:强引用 > 软引用 > 弱引用 > 虚引用。



以上


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

iOS 消息调用过程

iOS
iOS 消息调用属于基本知识,苹果官方有一个详细的介绍图:iOS 工程中,调用对象的方法,就是向对象发送消息。我们知道,iOS 中的方法分为实例方法和对象方法。iOS 所有的对象都是继承至 NSObject, 编译完成后,在对象的定义中,存在一个实例方法链表、...
继续阅读 »

iOS 消息调用属于基本知识,苹果官方有一个详细的介绍图:


iOS 工程中,调用对象的方法,就是向对象发送消息。我们知道,iOS 中的方法分为实例方法和对象方法。iOS 所有的对象都是继承至 NSObject, 编译完成后,在对象的定义中,存在一个实例方法链表、一个缓存方法链表。当对实例 son 发送消息后,会在 son 缓存方法链表中寻找;缓存中没有时,向实例方法链表寻找;再找不到,会向父类的实例方法缓存链表 -> 父类的实例方法链表寻找,直至 NSObject。在 NSObject 中会经历以下两个步骤:
1 - (BOOL)resolveInstanceMethod:(SEL)sel ; 
2 - (id)forwardingTargetForSelector:(SEL)aSelector ;

如果在步骤 2 中范围 nil, 就会触发 iOS 的崩溃。

当向 Son 发送类方法时,会首先向 Son 的元类 metaClass 中的类缓存方法链表中寻找,然后类方法链表,然后直接在 NSObject 进行缓存方法链表 -> 类方法链表的寻找路径 . 在 NSObject 中会经历如下两个步骤:


实例的 methodList 链表中寻找方法,找不到时会寻找 Son 的类方法,仍然找不到时,会寻找父类的方法链表,直到 NSObject 。


其中不同对象间的切换,通过 isa 指针完成,实例 son 的 isa 指向类 Son, 类 Son 的 isa 指向元类,元类的 isa 指向父类的元类, 父类的元类向上传递,直至 NSObject .


NSObject 的指针 isa 指向其本身,在想 NSObject 发送消息时,会经历如下步骤:

1 + (BOOL)resolveClassMethod:(SEL)sel ; 
2 - (void)doesNotRecognizeSelector:(SEL)aSelector ;
当调用方法 2 时,会触发 iOS 的崩溃。利用以上机制,可以对resolveInstanceMethod 和 resolveClassMethod 两个方法进行方法交换,拦截可能出现的 iOS 崩溃,然后自定义处理。
作者:iOS猿_员
链接:https://www.jianshu.com/p/1a76ccad4e73
来源:简书
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

iOS面试--虎牙最新iOS开发面试题

iOS
关于面试题,可能没那么多时间来总结答案,有什么需要讨论的地方欢迎大家指教。主要记录一下准备过程,和面试的一些总结,希望能帮助到正在面试或者将要面试的同学吧。 一面 项目架构,项目是自己写的吗 fps是怎么计算的 除了用cadisplay,还有什么方法吗 kv...
继续阅读 »

关于面试题,可能没那么多时间来总结答案,有什么需要讨论的地方欢迎大家指教。主要记录一下准备过程,和面试的一些总结,希望能帮助到正在面试或者将要面试的同学吧。


一面



  • 项目架构,项目是自己写的吗

  • fps是怎么计算的

  • 除了用cadisplay,还有什么方法吗

  • kvo怎么实现

  • leaks怎么实现

  • 如何代码实现监听僵尸对象

  • imageWithName什么时候发生编解码,在什么线程

  • isa指针里面有什么

  • 消息发送和消息转发流程

  • 函数里面的参数怎么存储

  • oc一个空函数里面有参数吗

  • 他们存在栈还是寄存器

  • 红黑树等查找时间复杂度

  • nsdictionary的实现

  • iOS的各种锁

  • 如何实现dispatch once,要考虑什么问题

  • 同一线程里面使用两个@synconize会怎么样,是递归锁还是非递归锁

  • 如何增加按钮点击范围


二面



  • 说一下ARC

  • autoreleasepool可以用来干嘛

  • 里面的对象什么时候释放,是出来就释放吗

  • 消息转发可以用来干什么

  • runloop是干什么,你用来干什么了

  • 说一下c++多态和虚函数表

  • TCP如何保证数据传输完整性

  • TCP为什么三次握手

  • http和https,全程都是非对称加密吗

  • 开放性问题,很多乱序数据过来,你要怎么考虑排序方法的设计

  • 对RxSwift的看法,有用过吗?


三面



  • iOS对象指针大小

  • 对象分配到堆还是栈

  • http怎么区分header和body

  • 多线程可以访问同一个对象吗,多进程呢

  • 视频pts和dts

  • 视频丢帧丢哪个好点

  • iOS各种锁的性能,琐是毫秒级别还是微妙级别

  • http请求是异步还是同步

  • 怎么看待rn和flutter


作者:iOS弗森科
链接:https://www.jianshu.com/p/17849abb722c
来源:简书
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

iOS之iOS13适配总结

iOS
前言 随便iOS开发开始更新变成Xcode11,适配iOS13变成了现在的当务之急。 新特性适配 一、新添加的Dark Mode iOS 13 推出暗黑模式,UIKit 提供新的系统颜色和 api 来适配不同颜色模式,xcassets 对素材适配也做了调整,具...
继续阅读 »

前言


随便iOS开发开始更新变成Xcode11,适配iOS13变成了现在的当务之急。


新特性适配


一、新添加的Dark Mode


iOS 13 推出暗黑模式,UIKit 提供新的系统颜色和 api 来适配不同颜色模式,xcassets 对素材适配也做了调整,具体适配可见: Implementing Dark Mode on iOS


切换、修改当前 UIViewController 或 UIView的模式。只要设置了控制器为暗黑模式,那么它子view也会对应的修改。



  • 只修改当前UIViewController或UIView的模式。

  • 只要设置了控制器为暗黑模式,那么它子view也会对应的修改。


代码如下:

if (@available(iOS 13.0, *)) {
self.overrideUserInterfaceStyle = UIUserInterfaceStyleDark;//UIUserInterfaceStyleLight
} else {
// Fallback on earlier versions
}
注意当我们在window上设置 overrideUserInterfaceStyle的时候,就会影响 window下所有的controller,view,包括后续推出的 controller。



二、使用KVC访问私有变量已发崩溃


iOS13之后就不能通过KVC访问修改私有属性,不然就会找不到这个key,从而引发崩溃。


目前搜集到的KVC访问权限引发崩溃的方法:



  1. UIApplication -> _statusBar

  2. UITextField -> _placeholderLabel

  3. UITabBarButton -> _info

  4. UISearchBar -> _searchField

  5. UISearchBar -> _cancelButton

  6. UISearchBar -> _cancelButtonText

  7. UISearchBar -> UISearchBarBackground


1、UIApplication -> _statusBar 获取状态栏崩溃


在iOS13上获取状态栏statusBar,不能直接用KVC。要使用performSelector

UIStatusBarManager *statusBarManager = [UIApplication sharedApplication].keyWindow.windowScene.statusBarManager;
UIView *statusBar;
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wundeclared-selector"
if([statusBarManager respondsToSelector:@selector(createLocalStatusBar)]) {

UIView *localStatusBar= [statusBarManager performSelector:@selector(createLocalStatusBar)];
if ([localStatusBar respondsToSelector:@selector(statusBar)]) {

statusBar = [localStatusBar performSelector:@selector(statusBar)];
}
}



适配的时候就是iOS13和非iOS13

if(@available(iOS 13.0, *)) {

//上面获取statusBar代码
} else {

UIView *statusBar = [[UIApplication sharedApplication]
valueForKey:@"statusBar"];

}



2、UITextField -> _placeholderLabel


在iOS13 UITextField通过KVC来获取_placeholderLabel会引发崩溃。

//在ios13使用会引发崩溃
[self.textField setValue:self.placeholderColor
forKeyPath:@"_placeholderLabel.textColor"];



崩溃如下:

'Access to UITextField's _placeholderLabel ivar is prohibited. 
This is an application bug'

解决方案:UITextField有个attributedPlaceholder的属性,我们可以自定义这个富文本来达到我们需要的结果。

NSMutableAttributedString *placeholderString = [[NSMutableAttributedString alloc] initWithString:placeholder 
attributes:@{NSForegroundColorAttributeName : self.placeholderColor}];
_textField.attributedPlaceholder = placeholderString;



3、UISearchBar 黑线处理导致崩溃


iOS13之前为了处理搜索框的黑线问题,通常会遍历searchBar的 subViews,找到并删除UISearchBarBackground。


在 iOS13 中这么做会导致UI渲染失败,然后直接崩溃,崩溃信息如下:

erminating app due to uncaught exception'NSInternalInconsistencyException', reason: 'Missing or detached view for search bar layout'



解决方案:修改方法为:设置 UISearchBarBackground 的 layer.contents 为 nil

for (UIView *view in _searchBar.subviews.lastObject.subviews) {
if ([view isKindOfClass:NSClassFromString(@"UISearchBarBackground")]) {
view.layer.contents = nil;
break;
}
}



4、iOS UISearchBar通过kvc获取_cancelButtonText、_cancelButton、_searchField引发崩溃。


先说一下_searchField来说明的解决方案。


在iOS13之前,我们通过"_searchField"来获取UISearchTextField来修改一些属性。

UITextField *searchFiled = [self valueForKey:@"_searchField"];



但在iOS13会引发崩溃,解决方案就是在iOS13中引入了名为searchTextField的属性。

@property (nonatomic, readonly) UISearchTextField *searchTextField;

查看一下UISearchTextField

UIKIT_CLASS_AVAILABLE_IOS_ONLY(13.0)
@interface UISearchTextField : UITextField
///功能省略
@end



发现UISearchTextField继承UITextField,代码实现:

UITextField *searchField;
if(@available(iOS 13.0, *)) {
//UISearchBar的self.searchTextField属性是readonly,不能直接用
searchField = self.searchTextField;
} else {
searchField = [self valueForKey:@"_searchField"];
}

三、presentViewController 默认弹出样式



  • 苹果将 UIViewController 的 modalPresentationStyle 属性的默认值改成了新加的一个枚举值 UIModalPresentationAutomatic,对于多数 UIViewController,此值会映射成 UIModalPresentationPageSheet。

  • iOS13系统的默认样式是: UIModalPresentationAutomatic

  • iOS12及以下系统的默认样式是:UIModalPresentationFullScreen;


想要改成以前默认的样式

- (UIModalPresentationStyle)modalPresentationStyle {
return UIModalPresentationFullScreen;
}



四、AVPlayerViewController 替换MPMoviePlayerController


在 iOS 9 之前播放视频可以使用 MediaPlayer.framework 中的MPMoviePlayerController类来完成,它支持本地视频和网络视频播放。但是在 iOS 9 开始被弃用,如果在 iOS 13 中继续使用的话会直接抛出异常:

'MPMoviePlayerController is no longer available. Use AVPlayerViewController in AVKit.'

解决方案:

既然不能再用了,那只能换掉了。替代方案就是AVKit里面的那套播放器。


五、废弃UIWebview 改用 WKWebView


iOS13 开始苹果将 UIWebview 支持的系统(iOS2.0-iOS12.0),目前提交苹果应用市场(App Store)会发送邮件提示你在下一次提交时将应用中UIWebView的api移除。


虽然暂时没有强制必须替换WKWebView,但是在iOS13开始UIWebView已是废弃的API,所以还是越早换越好。


六、iOS13 获取window适配


在iOS13通过UIWindowScene的方式获取window

UIWindow* window = nil;
if (@available(iOS 13.0, *)) {
for (UIWindowScene* windowScene in [UIApplication sharedApplication].connectedScenes) {
if (windowScene.activationState == UISceneActivationStateForegroundActive) {
window = windowScene.windows.firstObject;
break;
}
}
}else{
window = [UIApplication sharedApplication].keyWindow;
}



七、iOS13 废弃LaunchImage


从iOS8的时候,苹果就引入了LaunchScreen,我们可以设置 LaunchScreen来作为启动页。当然,现在你还可以使用LaunchImage来设置启动图。


但是从2020年4月开始,所有使⽤ iOS13 SDK的 App将必须提供 LaunchScreen,LaunchImage即将退出历史舞台。使用LaunchScreen有点:



  • 不需要单独适配种屏幕尺寸的启动图

  • LaunchScreen是支持AutoLayout+SizeClass的,所以适配各种屏幕都不在话下


七、iOS13 适配UISegmentedControl


默认样式变为白底黑字,如果设置修改过颜色的话,页面需要修改。

如下图:



其次设置选中颜色的tintColor属性在iOS13已经失效,所以在iOS13新增新增了selectedSegmentTintColor 属性用以修改选中的颜色。


适配代码如下:

if ( @available(iOS 13.0, *)) {
self.segmentedControl.selectedSegmentTintColor = [UIColor yellowcolor];
} else {
self.segmentedControl.tintColor = [UIColor yellowcolor];
}
作者:枫叶无处漂泊
链接:https://www.jianshu.com/p/acde9bc3fc97
来源:简书
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

iOS推送通知及静默推送相关

iOS
    在IOS推送服务中,Apple提供了两种不同方式的推送形式,一种是在通知栏上面显示的推送;另一种则是不带消息提醒的推送,俗称“静默消息”。1. 普通推送和静默推送的区别      &...
继续阅读 »

    在IOS推送服务中,Apple提供了两种不同方式的推送形式,一种是在通知栏上面显示的推送;另一种则是不带消息提醒的推送,俗称“静默消息”。

1. 普通推送和静默推送的区别

        普通推送:收到推送后(有文字有声音),点开通知,进入APP后,才执行

- (void)application:(UIApplication didReceiveRemoteNotification:(NSDictionary fetchCompletionHandler:(void result))handler *)application *)userInfo (^)(UIBackgroundFetchResult


        静默推送:(Silent Push)并不是必须要“静默”(通常是没有文字没有声音),只要推送payload中aps字典里包含了"content-available": 1的键值对,都具有静默推送的特性,不用点开通知,不用打开APP,就能执行

-(void)application:(UIApplication )application)userInfo didReceiveRemoteNotification:(NSDictionary fetchCompletionHandler:(void (^)(UIBackgroundFetchResultresult))handler


用户完全感觉不到所以静默推送又被我们称做 Background Remote Notification(后台远程推送)。

        静默推送是在iOS7之后推出的一种推送方式。它与其他推送的区别在于允许应用收到通知后在后台(background)状态下运行一段代码,可用于从服务器获取内容更新。

PS:注册消息通知时通常的弹窗询问权限有什么用呢?其实只是请求用户允许在推送通知到来时能够有alert, badge和sound,而并不是在请求注册推送本身的权限。静默推送即使用户不允许应用的推送,静默推送依然会送达用户设备,只是不会有alert, badge和sound。这也符合静默推送的正常使用场景。



2. 远程推送时 , 应用的几种状态及对应回调方法

     (1) . 应用开启时 , 应用在前台

     (2) . 应用开启时 , 应用在后台

     (3) . 应用未启动(应用被杀死)

从苹果APNS服务器远程推送时:

不使用时(iOS10以后可用)

1 . 如果应用处于 (1) 状态 , 则不会发出声音 , 会直接调用appDelegate的代理方法didReceiveRemoteNotification(didReceiveRemoteNotification:fetchCompletionHandler:)

2 . 如果应用处于 (2) 状态 , 则会发出提示音, 点击推送消息 , 则会调用appDelegate的代理方法didReceiveRemoteNotification

3 . 如果应用处于 (3) 状态,则会发出提示音 , 点击推送消息 , 则会开启应用 , 在下面这个方法中会带上launchOptions这个参数,如果实现了application:didReceiveRemoteNotification:fetchCompletionHandler:这个方法,则还会调用这个方法

注:didReceiveRemoteNotification指以下两个方法。两个方法互斥。在两方法都实现的情况下方法2优先级高

1. - (void)application:(UIApplication *)application didReceiveRemoteNotification:(NSDictionary *)userInfo

2. - (void)application:(UIApplication *)application didReceiveRemoteNotification:(NSDictionary *)userInfo fetchCompletionHandler:(void (^)(UIBackgroundFetchResult))completionHandler


iOS10使用

1 . 如果应用处于 (1) 状态 , 会发出声音 , 会直接调用appDelegate的代理方法userNotificationCenter:willPresentNotification:withCompletionHandler

2 . 如果应用处于 (2) 状态 , 则会发出提示音, 点击推送消息 , 则会调用appDelegate的代理方法

userNotificationCenter:didReceiveNotificationResponse:withCompletionHandler

3 . 如果应用处于 (3) 状态,则会发出提示音 , 点击推送消息 , 则会开启应用 , 在下面这个方法中会带上launchOptions这个参数,如果实现了userNotificationCenter:didReceiveNotificationResponse:withCompletionHandler这个方法,则还会调用这个方法



2. 静默推送及app的状态切换

        在大多数情况下,启动一个app后都是进入前台,比如我们点击应用图标或点推送通知来启动应用。其实app在某些后台事件和特定条件下是可以直接启动到后台(launch into the background)的。

    2.1 应用状态之一Suspended

        这种状态其实和Background类似,而且从用户角度讲应用现在看起来确实是在“后台”,但它和Background状态不同的是Suspended下已经不能执行代码了。应用何时会进Suspended就是玄学了,这是由iOS系统自动控制的,而且不会有任何回调,可以看到UIApplicationDelegate里并没有像applicationWillBecomeSuspended:这种东西。这种状态下的应用虽然还在内存中,但是一旦设备内存吃尽,比如开了炉石传说的游戏,那么系统就会优先干掉(文档上用的是purge这个词)处于Suspended状态的应用,而且也不会有回调。

    2.2 应用启动到前台的生命周期(以点击应用图标开始)

    AppDelegate中走的回调方法 

 · application:willFinishLaunchingWithOptions:

· application:didFinishLaunchingWithOptions:

· applicationDidBecomeActive:


    静默推送可以使应用启动到后台

        前提是应用先被退到后台,过一段时间被系统移入Suspended状态,然后又被系统在内存吃紧时回收了内存(相当于应用已经被系统正当杀掉,而非用户双击Home键杀掉),在这以后,该应用收到静默推送即会启动应用到后台。

    AppDelegate中走的回调方法变为

 · application:willFinishLaunchingWithOptions:

· application:didFinishLaunchingWithOptions:

· applicationDidEnterBackground:


        这个过程中,系统不会显示应用的window,就是说我们不会看到手机屏幕上突然鬼畜一下应用启动,但是应用的第一屏会被加载和渲染,比如你的window.rootViewController是一个TabBarController,那么它及其默认选中的selectedViewController都会被加载和渲染。这是因为系统认为在后台执行完任务后可能会有UI上的更新,所以在applicationDidEnterBackground:方法执行结束后便会有个快速的截图,来更新用户双击Home时看到的那个应用截图。


3. 收到静默推送时的后续该如何处理。

        application:didReceiveRemoteNotification:fetchCompletionHandler:

        这是应用收到静默推送的回调方法,我们最多有30s的时间来处理数据,比如静默推送表示某个列表或资源有更新,你可以在此处下载数据,在下载处理完数据后需要尽快调用completionHandler(...)告诉系统处理完毕。

- (void)application:(UIApplication *)application didReceiveRemoteNotification:(NSDictionary *)userInfo fetchCompletionHandler:(void (^)(UIBackgroundFetchResult))completionHandler {

[Downloader fetchData:^(id x){ // 处理数据,更新UI 等

completionHandler(UIBackgroundFetchResultNewData);

}];

}


        如果这次是启动到后台的情况,调用completionHandler(...)后会使应用马上进入之前的状态。那就有可能遇到这样的问题:很多时候我们需要在启动时发送一堆业务上的API请求,如果这次静默推送没有数据需要下载和处理,就会刚把启动处的API请求发出,就调用了completionHandler(...),导致发出的这些请求在下次打开应用时显示超时。这种情况下我们可以强行延时下completionHandler(...)的调用,来保证能在这次收到那些API的返回。

dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(5 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{

completionHandler(UIBackgroundFetchResultNoData);

});



4. 静默推送


应用想收到静默推送需要满足的条件:

1.应用在前台/后台 (应用被杀死就收不到了)

2.应用实现了

application:didReceiveRemoteNotification:fetchCompletionHandler:/application:didReceiveRemoteNotification:

3. 消息定义时需设置:"content-available" = 1

流程:

  1. 移动端注册消息,向APNs服务器获取deviceToken,并提交给后台保存;

  2. 后台定义消息,并推送给APNs服务器。APNs根据deviceToken做分发。

  3. 移动端收到推送消息后的逻辑处理。

消息定义示例:

特殊说明:

1. APNS去掉alert、badge、sound字段实现静默推送,增加增加字段"content-available":1,也可以在后台做一些事情。

//静默推送消息格式

{

"aps":{

"alert":"",

"content-available":1

},

"userInfo":"test"

}


*/

小结:

1.应用在后台/前台/被杀死,都可以收到普通的远程推送

2.应用在后台/前台时,可以通过静默推送,修改一些数据

3.应用被杀死时(相当于应用已经被系统正当杀掉,而非用户双击Home键杀掉),可以通过Background Fetch短时间唤醒应用



作者:Aliv丶Zz
链接:https://www.jianshu.com/p/0275d9a9592b
来源:简书
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

iOS获取设备的网络状态(已适配iOS13,iOS14无变化)

iOS
前言 小编最近在项目中遇到了一个问题,除刘海屏以外的iOS设备可以正常的搜索到硬件设备,但是刘海屏就不行。因此,小编花了一点时间研究了一下iOS设备获取当前设备的网络状态。 实现 因为iOS的系统是封闭的,所以是没有直接的APi去获取当前的网络状态。但是道高一...
继续阅读 »

前言


小编最近在项目中遇到了一个问题,除刘海屏以外的iOS设备可以正常的搜索到硬件设备,但是刘海屏就不行。因此,小编花了一点时间研究了一下iOS设备获取当前设备的网络状态。


实现


因为iOS的系统是封闭的,所以是没有直接的APi去获取当前的网络状态。但是道高一尺,魔高一尺。开发者总会有办法获取自己想要的东西。


1.网络状态获取


获取当前的网络类型

获取当前的网络类型是通过获取状态栏,然后遍历状态栏的视图完成的。

先导入头文件,如下:

#import "AppDelegate.h"

实现方法如下:

+ (NSString *)getNetworkType
{
UIApplication *app = [UIApplication sharedApplication];
id statusBar = nil;
// 判断是否是iOS 13
NSString *network = @"";
if (@available(iOS 13.0, *)) {
UIStatusBarManager *statusBarManager = [UIApplication sharedApplication].keyWindow.windowScene.statusBarManager;

#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wundeclared-selector"
if ([statusBarManager respondsToSelector:@selector(createLocalStatusBar)]) {
UIView *localStatusBar = [statusBarManager performSelector:@selector(createLocalStatusBar)];
if ([localStatusBar respondsToSelector:@selector(statusBar)]) {
statusBar = [localStatusBar performSelector:@selector(statusBar)];
}
}
#pragma clang diagnostic pop

if (statusBar) {
// UIStatusBarDataCellularEntry
id currentData = [[statusBar valueForKeyPath:@"_statusBar"] valueForKeyPath:@"currentData"];
id _wifiEntry = [currentData valueForKeyPath:@"wifiEntry"];
id _cellularEntry = [currentData valueForKeyPath:@"cellularEntry"];
if (_wifiEntry && [[_wifiEntry valueForKeyPath:@"isEnabled"] boolValue]) {
// If wifiEntry is enabled, is WiFi.
network = @"WIFI";
} else if (_cellularEntry && [[_cellularEntry valueForKeyPath:@"isEnabled"] boolValue]) {
NSNumber *type = [_cellularEntry valueForKeyPath:@"type"];
if (type) {
switch (type.integerValue) {
case 0:
// 无sim卡
network = @"NONE";
break;
case 1:
network = @"1G";
break;
case 4:
network = @"3G";
break;
case 5:
network = @"4G";
break;
default:
// 默认WWAN类型
network = @"WWAN";
break;
}
}
}
}
}else {
statusBar = [app valueForKeyPath:@"statusBar"];

if ([[[self alloc]init]isLiuHaiScreen]) {
// 刘海屏
id statusBarView = [statusBar valueForKeyPath:@"statusBar"];
UIView *foregroundView = [statusBarView valueForKeyPath:@"foregroundView"];
NSArray *subviews = [[foregroundView subviews][2] subviews];

if (subviews.count == 0) {
// iOS 12
id currentData = [statusBarView valueForKeyPath:@"currentData"];
id wifiEntry = [currentData valueForKey:@"wifiEntry"];
if ([[wifiEntry valueForKey:@"_enabled"] boolValue]) {
network = @"WIFI";
}else {
// 卡1:
id cellularEntry = [currentData valueForKey:@"cellularEntry"];
// 卡2:
id secondaryCellularEntry = [currentData valueForKey:@"secondaryCellularEntry"];

if (([[cellularEntry valueForKey:@"_enabled"] boolValue]|[[secondaryCellularEntry valueForKey:@"_enabled"] boolValue]) == NO) {
// 无卡情况
network = @"NONE";
}else {
// 判断卡1还是卡2
BOOL isCardOne = [[cellularEntry valueForKey:@"_enabled"] boolValue];
int networkType = isCardOne ? [[cellularEntry valueForKey:@"type"] intValue] : [[secondaryCellularEntry valueForKey:@"type"] intValue];
switch (networkType) {
case 0://无服务
network = [NSString stringWithFormat:@"%@-%@", isCardOne ? @"Card 1" : @"Card 2", @"NONE"];
break;
case 3:
network = [NSString stringWithFormat:@"%@-%@", isCardOne ? @"Card 1" : @"Card 2", @"2G/E"];
break;
case 4:
network = [NSString stringWithFormat:@"%@-%@", isCardOne ? @"Card 1" : @"Card 2", @"3G"];
break;
case 5:
network = [NSString stringWithFormat:@"%@-%@", isCardOne ? @"Card 1" : @"Card 2", @"4G"];
break;
default:
break;
}

}
}

}else {

for (id subview in subviews) {
if ([subview isKindOfClass:NSClassFromString(@"_UIStatusBarWifiSignalView")]) {
network = @"WIFI";
}else if ([subview isKindOfClass:NSClassFromString(@"_UIStatusBarStringView")]) {
network = [subview valueForKeyPath:@"originalText"];
}
}
}

}else {
// 非刘海屏
UIView *foregroundView = [statusBar valueForKeyPath:@"foregroundView"];
NSArray *subviews = [foregroundView subviews];

for (id subview in subviews) {
if ([subview isKindOfClass:NSClassFromString(@"UIStatusBarDataNetworkItemView")]) {
int networkType = [[subview valueForKeyPath:@"dataNetworkType"] intValue];
switch (networkType) {
case 0:
network = @"NONE";
break;
case 1:
network = @"2G";
break;
case 2:
network = @"3G";
break;
case 3:
network = @"4G";
break;
case 5:
network = @"WIFI";
break;
default:
break;
}
}
}
}
}

if ([network isEqualToString:@""]) {
network = @"NO DISPLAY";
}
return network;
}
获取当前的Wifi信息

获取当前的Wifi信息需要借助系统的SystemConfiguration这个库。
先导入头文件,如下:

#import <SystemConfiguration/CaptiveNetwork.h>

实现方法如下:

#pragma mark 获取Wifi信息
+ (id)fetchSSIDInfo
{
NSArray *ifs = (__bridge_transfer id)CNCopySupportedInterfaces();
id info = nil;
for (NSString *ifnam in ifs) {
info = (__bridge_transfer id)CNCopyCurrentNetworkInfo((__bridge CFStringRef)ifnam);

if (info && [info count]) {
break;
}
}
return info;
}
#pragma mark 获取WIFI名字
+ (NSString *)getWifiSSID
{
return (NSString *)[self fetchSSIDInfo][@"SSID"];
}
#pragma mark 获取WIFI的MAC地址
+ (NSString *)getWifiBSSID
{
return (NSString *)[self fetchSSIDInfo][@"BSSID"];
}
获取当前的Wifi信号强度

获取信号强度与获取网络状态有点类似,通过遍历状态栏,从而获取WIFI图标的信号强度。在获取前需注意当前状态是否为WIFI。如下:

+ (int)getWifiSignalStrength{

int signalStrength = 0;
// 判断类型是否为WIFI
if ([[self getNetworkType]isEqualToString:@"WIFI"]) {
// 判断是否为iOS 13
if (@available(iOS 13.0, *)) {
UIStatusBarManager *statusBarManager = [UIApplication sharedApplication].keyWindow.windowScene.statusBarManager;

id statusBar = nil;
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wundeclared-selector"
if ([statusBarManager respondsToSelector:@selector(createLocalStatusBar)]) {
UIView *localStatusBar = [statusBarManager performSelector:@selector(createLocalStatusBar)];
if ([localStatusBar respondsToSelector:@selector(statusBar)]) {
statusBar = [localStatusBar performSelector:@selector(statusBar)];
}
}
#pragma clang diagnostic pop
if (statusBar) {
id currentData = [[statusBar valueForKeyPath:@"_statusBar"] valueForKeyPath:@"currentData"];
id wifiEntry = [currentData valueForKeyPath:@"wifiEntry"];
if ([wifiEntry isKindOfClass:NSClassFromString(@"_UIStatusBarDataIntegerEntry")]) {
// 层级:_UIStatusBarDataNetworkEntry、_UIStatusBarDataIntegerEntry、_UIStatusBarDataEntry

signalStrength = [[wifiEntry valueForKey:@"displayValue"] intValue];
}
}
}else {
UIApplication *app = [UIApplication sharedApplication];
id statusBar = [app valueForKey:@"statusBar"];
if ([[[self alloc]init]isLiuHaiScreen]) {
// 刘海屏
id statusBarView = [statusBar valueForKeyPath:@"statusBar"];
UIView *foregroundView = [statusBarView valueForKeyPath:@"foregroundView"];
NSArray *subviews = [[foregroundView subviews][2] subviews];

if (subviews.count == 0) {
// iOS 12
id currentData = [statusBarView valueForKeyPath:@"currentData"];
id wifiEntry = [currentData valueForKey:@"wifiEntry"];
signalStrength = [[wifiEntry valueForKey:@"displayValue"] intValue];
// dBm
// int rawValue = [[wifiEntry valueForKey:@"rawValue"] intValue];
}else {
for (id subview in subviews) {
if ([subview isKindOfClass:NSClassFromString(@"_UIStatusBarWifiSignalView")]) {
signalStrength = [[subview valueForKey:@"_numberOfActiveBars"] intValue];
}
}
}
}else {
// 非刘海屏
UIView *foregroundView = [statusBar valueForKey:@"foregroundView"];

NSArray *subviews = [foregroundView subviews];
NSString *dataNetworkItemView = nil;

for (id subview in subviews) {
if([subview isKindOfClass:[NSClassFromString(@"UIStatusBarDataNetworkItemView") class]]) {
dataNetworkItemView = subview;
break;
}
}

signalStrength = [[dataNetworkItemView valueForKey:@"_wifiStrengthBars"] intValue];

return signalStrength;
}
}
}
return signalStrength;
}

2.Reachability的使用

下载开源类Reachability,然后根据文档使用即可(该类把移动网络统称为WWAN):+ (NSString *)getNetworkTypeByReachability

{
NSString *network = @"";
switch ([[Reachability reachabilityForInternetConnection]currentReachabilityStatus]) {
case NotReachable:
network = @"NONE";
break;
case ReachableViaWiFi:
network = @"WIFI";
break;
case ReachableViaWWAN:
network = @"WWAN";
break;
default:
break;
}
if ([network isEqualToString:@""]) {
network = @"NO DISPLAY";
}
return network;
}

上次发布了这篇文章之后,有人问我,怎么才能获取设备的IP地址呢?在这里,小编附上获取iP地址的方法。
先导入头文件,如下:

#import <ifaddrs.h>
#import <arpa/inet.h>

实现方法,如下:

#pragma mark 获取设备IP地址
+ (NSString *)getIPAddress
{
NSString *address = @"error";
struct ifaddrs *interfaces = NULL;
struct ifaddrs *temp_addr = NULL;
int success = 0;
// 检索当前接口,在成功时,返回0
success = getifaddrs(&interfaces);
if (success == 0) {
// 循环链表的接口
temp_addr = interfaces;
while(temp_addr != NULL) {
// 开热点时本机的IP地址
if ([[NSString stringWithUTF8String:temp_addr->ifa_name] isEqualToString:@"bridge100"]
) {
address = [NSString stringWithUTF8String:inet_ntoa(((struct sockaddr_in *)temp_addr->ifa_addr)->sin_addr)];
}
if(temp_addr->ifa_addr->sa_family == AF_INET) {
// 检查接口是否en0 wifi连接在iPhone上
if([[NSString stringWithUTF8String:temp_addr->ifa_name] isEqualToString:@"en0"]) {
// 得到NSString从C字符串
address = [NSString stringWithUTF8String:inet_ntoa(((struct sockaddr_in *)temp_addr->ifa_addr)->sin_addr)];
}
}
temp_addr = temp_addr->ifa_next;
}
}
// 释放内存
freeifaddrs(interfaces);
return address;
}


收起阅读 »

神奇的共享内存

前言 共享内存(shared memory)是最常见的ipc进程之间通讯的方式之一了,很多linux书籍上,都将共享内存评价为“最有用的ipc机制”,就连Binder机制盛行的android体系,同样也离不开共享内存的应用!在所以ipc方式中,共享内存以“快”...
继续阅读 »

前言


共享内存(shared memory)是最常见的ipc进程之间通讯的方式之一了,很多linux书籍上,都将共享内存评价为“最有用的ipc机制”,就连Binder机制盛行的android体系,同样也离不开共享内存的应用!在所以ipc方式中,共享内存以“快”赢得了很多开发者的掌声,我们下面深入看看!


共享内存相关函数


image.png
首先讲到共享内存,那么肯定离不开要介绍几个函数


shmget


int shmget(key_t key, size_t size, int shmflg);

shmget函数用来获取一个内存区的ipc标识,这个标识在内核中,属于一个身份标识符号(ipc标识符,正常情况下是不会重复的,但是标识符也有限制的,比如linux2.4最大为32768,用完了就会重新计算),通过shmget调用,会返回给我们当前的ipc标识,如果这个共享内存区本来就不存在,就直接创建,否则就把当前标识直接返回给我们!说了一大堆,其实很简单,就相当于给我们返回了一个代表该共享内存的标识罢了!


shmat


void *shmat(int shmid, const void *shmaddr, int shmflg);

shmat把一个共享内存区域添加到进程上,我们之前在mmap这一章节有提到过线性区的概念,就是进程可用的一组地址(可以用,但是用的时候才真正分配),而shmat就把共享内存的这块地址,通过(shmid shmget可以获取到的)放到了进程中的可用地址范围内,用范围内的合适地址(shmaddr这里指进程想要发生映射的可用地址)指向了共享内存实际的地址,可以见上图!


shmdt


int shmdt(const void *shmaddr);

用于从当前进程把指定的共享内存shmaddr地址分离出去,这里只是分离,只是从当前进程中不可见了,但是对于其他进程来说,还是依旧存在的,再拿上面的图举例子,如果进程1中调用了shmadt,那么当前状态就如下图所示


image.png
同时这里有个非常需要注意的点,就是就算共享内存没有被其他任何进程使用,它所占有的页也是不能直接被删除的,只能用“页的换出”操作代替不用的页(留个疑问,后文解析)


image.png
当然,为了避免用户态过程中共享内存的过分创建,一般的限制大小为4096个


共享内存本质


看到这里的朋友,包括我,一定会想问,共享内存最本质是个什么东西呀?为什么linux会创建处理这么一个神奇的东西?在这里我可以告诉大家,共享内存其实就是一个“文件”!不光如此,我们所熟知的ipc方式,比如管道,消息队列,共享内存,其实就是对文件的操作!我的天,我们嗤之以鼻的“文件”,最不起眼不被用的ipc方式,只是换了个名称,就让大家高攀不起了!是的,共享内存的本质,其实就是shm特殊文件系统的一个文件罢了!因为shm文件系统在linux系统中没有安装点,即没有可视化的文件路径,普通用户无法“看到”或者“摸到”,就给我们产生了一个错觉,以为是一个很高深的东西,其实并没有啦!一个共享内存,其实就是一个文件,只不过这个文件我们看不到罢了,但是linux内核能看到,就这么简单!(以后面试官问到ipc有哪些,回答“文件”即可哈哈哈,手动狗头)


那么接下来又有一个问题了,为什么一个文件能有这么大的奇效,我们常说的共享内存只需要一次拷贝(假如进程a写入到进程b可见算一次)呀,面试官还经常问我们呢!一个小小文件怎么做到的?没错,没错!就是mmap搞得鬼呀!属于共享内存的这个文件,在进程中其实就是使用了mmap操作,把进程的地址映射到了这个文件,所以写入一次就对其他同样进行mmap的进程可见罢了!这个mmap,是通过shm_mmap函数实现的(细节可看官网,这里就不贴出来了)最后我们再看一下共享内存的核心数据结构,shmid_kernel


struct shmid_kernel /* private to the kernel */
{
struct kern_ipc_perm shm_perm; //描述进程间通信许可的结构
struct file * shm_file; //指向共享内存文件的指针
unsigned long shm_nattch; //挂接到本段共享内存的进程数
unsigned long shm_segsz; //段大小
time_t shm_atim; //最后挂接时间
time_t shm_dtim; //最后解除挂接时间
time_t shm_ctim; //最后变化时间
pid_t shm_cprid; //创建进程的PID
pid_t shm_lprid;//最后使用进程的PID

....
};

共享内存页回收问题


我们刚刚留下了一个疑问点,就是共享内存的页就算没有进程引用,也不能被直接删除,而是采用换出的方式!为什么不能被删除呢?因为在正常情况下,linux内核中对于页删除有比较严格的判断,页被删除的前提需要页被标记被脏,触发磁盘写回的操作,然后才会从删除这个页!但是共享内存的页其实在磁盘上是没有存在映射的索引节点的,因此写回磁盘这个操作前提就不成立,所以正常的处理是这个页会被保留,但是页的内容会被其他有需要的页的“伙伴”被复用,做到只是数据的删除页不删除!这是需要注意的点!当然,在紧急内存不足的情况下,系统也会调用try_to_swap_out方法,回收一般页,但是共享内存的页会有定制的shmem_write_page,会进行页的copy操作,防止了属于共享内存的页被“直接删除”。


Android中的共享内存


Android中也有很多地方用到了共享内存,比如ContentProvider中数据的交换,比如CursorWindow的数据交换,里面其实就是利用了共享内存。还有就是传递给SurfaceFlinger的渲染数据,也就是通过共享内存完成的。之所以使用共享内存,还是得益于共享内存的设计,效率较高且没有像管道这种多拷贝的情况,不使用Binder是也是因为Binder依赖的Parcel数据传输,在大数据上并没有很大的优势!当然,相比于Binder,共享内存算是作为最底层api,并没有提供同步机制!当然,Binder同时也用了mmap(binder_mmap),在这基础上通过mutex_lock进行了同步机制,算是比共享内存有了更加契合Android的设计


image.png


总结


看完这里,应该都会用共享内存进行我们所需的开发了,无论是Binder还是共享内存,只有在合适自己的场合使用,才能获得最大收益!最后!


image.png


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

Transform 被废弃,TransformAction 了解一下~

前言 Transform API 是 AGP1.5 就引入的特性,主要用于在 Android 构建过程中,在 Class转Dex的过程中修改 Class 字节码。利用 Transform API,我们可以拿到所有参与构建的 Class 文件,然后可以借助ASM...
继续阅读 »

前言


Transform APIAGP1.5 就引入的特性,主要用于在 Android 构建过程中,在 ClassDex的过程中修改 Class 字节码。利用 Transform API,我们可以拿到所有参与构建的 Class 文件,然后可以借助ASM 等字节码编辑工具进行修改,插入自定义逻辑。


国内很多团队都或多或少的用 AGPTransform API 来搞点儿黑科技,比如无痕埋点,耗时统计,方法替换等。但是在AGP7.0Transform已经被标记为废弃了,并且将在AGP8.0中移除。


Transrom被废弃之后,它的代替品则是Transform Action,它是由Gradle提供的产物变换API


Transform API是由AGP提供的,而Transform Action则是由Gradle提供。不光是 AGP 需要 TransformJava 也需要,所以由 Gradle 来提供统一的 Transform API 也合情合理。


当然如果你只是想利用ASM对字节码插桩,AGP提供了对基于TransformActionASM插桩的封装,只需要使用AsmClassVisitorFactory即可,关于具体的使用可见:Transform 被废弃,ASM 如何适配?


而本文主要包括以下内容:



  1. TransformAction是什么?

  2. 如何自定义TransformAction

  3. TransformActionAGP中的应用


TransformAction是什么?


简单来说,TransformAction就是Gradle提供的产物转换API,可以注册两个属性间的转换Action,将依赖从一个状态切换到另一个状态


我们在项目中的依赖,可能会有多个变体,例如,一个依赖可能有以下两种变体:classesorg.gradle.usage=java-api, org.gradle.libraryelements=classes )或JARorg.gradle.usage=java-api, org.gradle.libraryelements=jar)


它们的主要区别就在于,一个的产物是jar,一个则是classes(类目录)


Gradle解析配置时,解析的配置上的属性将确定请求的属性,并选中匹配属性的变体。例如,当配置请求org.gradle.usage=java-api, org.gradle.libraryelements=classes时,就会选择classes目录作为输入。


但是如果依赖项没有所请求属性的变体,那么解析配置就会失败。有时我们可以将依赖项的产物转换为请求的变体。


例如,解压缩JarTranformAction会将 java-api,jars转换为java-api,classes变体。

这种转换可以对依赖的产物进行转换,所以称为“产物转换” 。Gradle允许注册产物转换,并且当依赖项没有所请求的变体时,Gradle将尝试查找一系列产物转换以创建变体。


TransformAction选择和执行逻辑


如上所述,当Gradle解析配置并且配置中的依赖关系不具有带有所请求属性的变体时,Gradle会尝试查找一系列TransformAction以创建变体。


每个注册的转换都是从一组属性转换为一组属性。例如,解压缩转换可以从org.gradle.usage=java-api, org.gradle.libraryelements=jars转换至org.gradle.usage=java-api, org.gradle.libraryelements=classes


为了找到一条这样的链,Gradle从请求的属性开始,然后将所有修改某些请求的属性的TransformAction视为通向那里的可能路径。


例如,考虑一个minified属性,它有两个值: truefalseminified属性表示是否删除了不必要的类文件。


如果我们的依赖只有minified=false的变体,并且我们的配置中请求了minified=true的属性,如果我们注册了minify的转换,那么它就会被选中


在找到的所有变换链中,Gradle尝试选择最佳的变换链:



  1. 如果只有一个转换链,则选择它。

  2. 如果有两个变换链,并且一个是另一个的后缀,则将其选中。

  3. 如果存在最短的变换链,则将其选中。

  4. 在所有其他情况下,选择将失败并报告错误。


同时还有两个特殊情况:



  1. 当已经存在与请求属性匹配的依赖项变体时,Gradle不会尝试选择产物转换。

  2. artifactType属性是特殊的,因为它仅存在于解析的产物上,而不存在于依赖项上。因此任何只变换artifactTypeTransformAction,只有在使用ArtifactView时才会考虑使用


自定义TransformAction


下面我们就以自定义一个MinifyTransform为例,来看看如何自定义TransformAction,主要用于过滤产物中不必要的文件


定义TransformAction


abstract class Minify : TransformAction<Minify.Parameters> {   // (1)
interface Parameters : TransformParameters { // (2)
@get:Input
var keepClassesByArtifact: Map<String, Set<String>>

}

@get:PathSensitive(PathSensitivity.NAME_ONLY)
@get:InputArtifact
abstract val inputArtifact: Provider<FileSystemLocation>

override
fun transform(outputs: TransformOutputs) {
val fileName = inputArtifact.get().asFile.name
for (entry in parameters.keepClassesByArtifact) { // (3)
if (fileName.startsWith(entry.key)) {
val nameWithoutExtension = fileName.substring(0, fileName.length - 4)
minify(inputArtifact.get().asFile, entry.value, outputs.file("${nameWithoutExtension}-min.jar"))
return
}
}
println("Nothing to minify - using ${fileName} unchanged")
outputs.file(inputArtifact) // (4)
}

private fun minify(artifact: File, keepClasses: Set<String>, jarFile: File) {
println("Minifying ${artifact.name}")
// Implementation ...
}
}

代码很简单,主要分为以下几步:



  1. 实现TransformAction接口并声明参数类型

  2. 实现参数接口,实现自定义参数

  3. 获取输入并实现transform逻辑

  4. 输出变换结果,当不需要变换时直接将输入作为变换结果


其实一个TransformAction主要就是输入,输出,变换逻辑三个部分


注册TransformAction


接下来就是注册了,您需要注册TransformAction,并在必要时提供参数,以便在解析依赖项时可以选择它们


val artifactType = Attribute.of("artifactType", String::class.java)
val minified = Attribute.of("minified", Boolean::class.javaObjectType)

val keepPatterns = mapOf(
"guava" to setOf(
"com.google.common.base.Optional",
"com.google.common.base.AbstractIterator"
)
)

dependencies {
attributesSchema {
attribute(minified) // <1>
}
artifactTypes.getByName("jar") {
attributes.attribute(minified, false) // <2>
}
}

configurations.all {
afterEvaluate {
if (isCanBeResolved) {
attributes.attribute(minified, true) // <3>
}
}
}

dependencies { // (4)
implementation("com.google.guava:guava:27.1-jre")
implementation(project(":producer"))
}

dependencies {
registerTransform(Minify::class) { // <5>
from.attribute(minified, false).attribute(artifactType, "jar")
to.attribute(minified, true).attribute(artifactType, "jar")

parameters {
keepClassesByArtifact = keepPatterns
// Make sure the transform executes each time
timestamp = System.nanoTime()
}
}
}

tasks.register<Copy>("resolveRuntimeClasspath") {
from(configurations.runtimeClasspath)
into(layout.buildDirectory.dir("runtimeClasspath"))
}

注册TransformAction也分为以下几步:



  1. 添加minified属性

  2. 将所有JAR文件的minified属性设置为false

  3. 在所有可解析的配置上设置请求的属性为minified=true

  4. 添加将要转换的依赖项

  5. 注册Transformaction,设置fromto的属性,并且传递自定义参数


运行TransformAction


在定义与注册了TransformAction之后,下一步就是运行了


上面我们自定义了resolveRuntimeClasspathTask,Minify转换会在我们请求minified=true的变体时调用


当我们运行gradle resolveRuntimeClasspath时就可以得到如下输出


> Task :resolveRuntimeClasspath
Nothing to minify - using jsr305-3.0.2.jar unchanged
Minifying guava-27.1-jre.jar
Nothing to minify - using failureaccess-1.0.1.jar unchanged
Nothing to minify - using listenablefuture-9999.0-empty-to-avoid-conflict-with-guava.jar unchanged
Nothing to minify - using j2objc-annotations-1.1.jar unchanged
Nothing to minify - using checker-qual-2.5.2.jar unchanged
Nothing to minify - using error_prone_annotations-2.2.0.jar unchanged
Nothing to minify - using animal-sniffer-annotations-1.17.jar unchanged

可以看出,当我们执行task的时候,gradle自动调用了TransformAction,对guava.jar进行了变换,并将结果存储在layout.buildDirectory.dir("runtimeClasspath")


变换ArtifactTypeTransformAction


上文提到,artifactType属性是特殊的,因为它仅存在于解析的产物上,而不存在于依赖项上。因此任何只变换artifactTypeTransformAction,只有在使用ArtifactView时才会考虑使用


其实在AGP中,相当一部分自定义TransformAction都是属于只变换ArtifactType的,下面我们来看下如何自定义一个这样的TransformAction


class TransformActionPlugin : Plugin<Project> {
override fun apply(project: Project) {
project.run {
val artifactType = Attribute.of("artifactType", String::class.java)
dependencies.registerTransform(MyTransform::class.java) { // 1
it.from.attribute(artifactType, "jar")
it.to.attribute(artifactType, "my-custom-type")
}
val myTaskProvider = tasks.register("myTask", MyTask::class.java) {
it.inputCount.set(10)
it.outputFile.set(File("build/myTask/output/file.jar"))
}
val includedConfiguration = configurations.create("includedConfiguration") // 2
dependencies.add(includedConfiguration.name, "org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.10")

val combinedInputs = project.files(includedConfiguration, myTaskProvider.map { it.outputFile })
val myConfiguration = configurations.create("myConfiguration")
dependencies.add(myConfiguration.name, project.files(project.provider { combinedInputs }))

tasks.register("consumerTask", ConsumerTask::class.java) { // 3
it.artifactCollection = myConfiguration.incoming.artifactView {viewConfiguration ->
viewConfiguration.attributes.attribute(artifactType, "my-custom-type")
}.artifacts
it.outputFile.set(File("build/consumerTask/output/output.txt"))
}
}
}
}

主要分为以下几步:



  1. 声明与注册自定义Transform,指定输入与输出的artifactType

  2. 创建自定义的 configuration,指定输入的依赖是什么(当然也可以直接用AGP已有的configuration)

  3. 在使用时,通过自定义configurationartifactView,获取对应的产物

  4. ConsumerTask中消费自定义TransformAction的输出产物


然后我们运行./gradlew consumerTask就可以得到以下输出


> Task :app:consumerTask
Processing ~/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-stdlib-common/1.7.10/bac80c520d0a9e3f3673bc2658c6ed02ef45a76a/kotlin-stdlib-common-1.7.10.jar. File exists = true
Processing ~/AndroidProject/2022/argust/GradleTutorials/app/build/myTask/output/file.jar. File exists = true
Processing ~/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-stdlib-jdk8/1.7.10/d70d7d2c56371f7aa18f32e984e3e2e998fe9081/kotlin-stdlib-jdk8-1.7.10.jar. File exists = true
Processing ~/.gradle/caches/modules-2/files-2.1/org.jetbrains/annotations/13.0/919f0dfe192fb4e063e7dacadee7f8bb9a2672a9/annotations-13.0.jar. File exists = true
Processing ~/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-stdlib/1.7.10/d2abf9e77736acc4450dc4a3f707fa2c10f5099d/kotlin-stdlib-1.7.10.jar. File exists = true
Processing ~/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-stdlib-jdk7/1.7.10/1ef73fee66f45d52c67e2aca12fd945dbe0659bf/kotlin-stdlib-jdk7-1.7.10.jar. File exists = true

可以看出,当运行consumerTask时,执行了 MyTransform,并将jar类型的产物转化成了my-custom-type


TransformActionAGP中的应用


现在AGP中的Transform已经基本上都改成TransformAction了,我们一起来看几个例子


AarTransform


Android ARchive,也就是.aar后缀的资源包,gradle是如何使用它的呢?


如果有同学尝试过就知道,如果是默认使用java-libray的工程,肯定无法依赖并使用aar的,引入时会报Could not resolve ${dependencyNotation},说明在Android Gradle Plugin当中,插件对aar包的依赖进行了处理,只有通过了插件处理,才能正确使用aar内的资源。那就来看看AGP是如何在TransformAction的帮助下做到这点的


Aar转换的实现就是AarTransform,我们一起来看下源码:


// DependencyConfigurator.kt
for (transformTarget in AarTransform.getTransformTargets()) {
registerTransform(
AarTransform::class.java,
AndroidArtifacts.ArtifactType.EXPLODED_AAR,
transformTarget
) { params ->
params.targetType.setDisallowChanges(transformTarget)
params.sharedLibSupport.setDisallowChanges(sharedLibSupport)
}
}

public abstract class AarTransform implements TransformAction<AarTransform.Parameters> {

@NonNull
public static ArtifactType[] getTransformTargets() {
return new ArtifactType[] {
ArtifactType.SHARED_CLASSES,
ArtifactType.JAVA_RES,
ArtifactType.SHARED_JAVA_RES,
ArtifactType.PROCESSED_JAR,
ArtifactType.MANIFEST,
ArtifactType.ANDROID_RES,
ArtifactType.ASSETS,
ArtifactType.SHARED_ASSETS,
ArtifactType.JNI,
ArtifactType.SHARED_JNI,
// ...
};
}

@Override
public void transform(@NonNull TransformOutputs transformOutputs) {
// 具体实现
}

代码也比较简单,主要做了下面几件事:



  1. DependencyConfigurator中注册Aar转换成各种类型资源的TransformAction

  2. AarTransform中根据类型将aar包中的文件解压到输出到各个目录


JetifyTransform


Jetifier也是在迁移到AndroidX之后的常用功能,它可以将引用依赖内的android.support.*引用都替换为对androidx的引用,从而实现对support包的兼容


下面我们来看一下JetifyTransform的代码


// com.android.build.gradle.internal.DependencyConfigurator

if (projectOptions.get(BooleanOption.ENABLE_JETIFIER)) {
registerTransform(
JetifyTransform::class.java,
AndroidArtifacts.ArtifactType.AAR,
jetifiedAarOutputType
) { params ->
params.ignoreListOption.setDisallowChanges(jetifierIgnoreList)
}
registerTransform(
JetifyTransform::class.java,
AndroidArtifacts.ArtifactType.JAR,
AndroidArtifacts.ArtifactType.PROCESSED_JAR
) { params ->
params.ignoreListOption.setDisallowChanges(jetifierIgnoreList)
}
}

// com.android.build.gradle.internal.dependency.JetifyTransform
override fun transform(transformOutputs: TransformOutputs) {
val inputFile = inputArtifact.get().asFile

val outputFile = transformOutputs.file("jetified-${inputFile.name}")
jetifierProcessor.transform2(
input = setOf(FileMapping(inputFile, outputFile)),
copyUnmodifiedLibsAlso = true,
skipLibsWithAndroidXReferences = true
)
}


  1. 读取并判断ENABLE_JETIFIER属性,这就是我们在gradle.properties中配置的jetifier开关

  2. aarjar类型的依赖都注册JetifyTransform转换

  3. transform中对support包的依赖进行替换,完成后会将处理过的资源重新压缩,并且会带上jetified的前缀


总结


本文主要讲解了TransformAction是什么,TransformAction自定义,以及TransformActionAGP中的应用,可以看出,目前AGP中的产物转换已经基本上都用TransformAction来实现了


事实上,AGPTransformAction进行了一定的封装,如果你只是想利用ASM实现字节码插桩,那么直接使用AsmClassVisitorFactory就好了。但如果想要阅读AGP的源码,了解AGP构建的过程,还是需要了解一下TransformAction的基本使用与原理的


示例代码


本文所有代码可见:github.com/RicardoJian…


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

使用 Kotlin 对 XML 文件解析、修改及创建

一 XML 基本概念 XML 全称 ExtensibleMarkupLanguage,中文称可扩展标记语言。它是一种通用的数据交换格式,具有平台无关性、语言无关性、系统无关性的优点,给数据集成与交互带来了极大的方便。XML 在不同的语言环境中解析方式都是一样的...
继续阅读 »

一 XML 基本概念


XML 全称 ExtensibleMarkupLanguage,中文称可扩展标记语言。它是一种通用的数据交换格式,具有平台无关性、语言无关性、系统无关性的优点,给数据集成与交互带来了极大的方便。XML 在不同的语言环境中解析方式都是一样的,只不过实现的语法不同而已。


XML 可用来描述数据、存储数据、传输数据/交换数据。


XML 文档形成了一种树结构,它从"根部"开始,然后扩展到"枝叶"。DOM 又是基于树形结构的 XML 解析方式,能很好地呈现这棵树的样貌。XML 文档节点的类型主要有:


各节点定义:




















































Node描述子节点
DocumentXML document 的根节点Element, ProcessingInstruction, DocumentType, Comment
DocumentType文档属性No children
Element元素Element, Text, Comment, ProcessingInstruction, CDATASection, EntityReference
Attr属性Text, EntityReference
ProcessingInstruction处理指令No children
Comment注释
No children
Text文本No children
Entity实体类型项目Element, Text, Comment, ProcessingInstruction, CDATASection, EntityReference 

二 XML 解析方式


一个 XML 文档的生命周期应该包括两部分:



  • 解析文档

  • 操作文档数据
    那么接下来介绍如何来解析 XML 以及解析之后如何使用。


根据底层原理的不同,解析 XML 文件一般分为两种形式,一种是基于树形结构来解析的称为 DOM;另一种是基于事件流的形式称为 SAX


2.1 DOM(Document Object Model)


DOM 是用与平台和语言无关的方式表示 XML 文档的官方 W3C 标准。是基于树形结构的 XML 解析方式,它会将整个 XML 文档读入内存并构建一个 DOM 树,基于这棵树形结构对各个节点(Node)进行操作。


优点



  1. 允许随机读取访问数据,因为整个 Dom 树都加载到内存中

  2. 允许随机的对文档结构进行增删


缺点



  1. 耗时,整个 XML 文档必须一次性解析完

  2. 占内存,整个 Dom 树都要加载到内存中


适用于:文档较小,且需要修改文档内容


2.1.1 DOM 解析 XML


第一步:建立一个 Stuff.xml 文件


<?xml version="1.0"?>
<company>
<staff id="1001">
<firstname>Jack</firstname>
<lastname>Ma</lastname>
<nickname>Hui Chuang A Li</nickname>
<salary currency="USD">100000</salary>
</staff>
<staff id="2001">
<firstname>Pony</firstname>
<lastname>Ma</lastname>
<nickname>Pu Tong Jia Ting</nickname>
<salary currency="RMB">200000</salary>
</staff>
</company>

第二步:DOM 解析


package com.elijah.kotlinlearning

import org.w3c.dom.Element
import org.w3c.dom.Node
import org.w3c.dom.NodeList
import java.io.File
import javax.xml.parsers.DocumentBuilderFactory



fun main(args: Array<String>) {

// Instantiate the Factory
val dbf = DocumentBuilderFactory.newInstance()

try {
// parse XML file
val xlmFile = File("${projectPath}/src/res/Staff.xml")
val xmlDoc= dbf.newDocumentBuilder().parse(xlmFile)
xmlDoc.documentElement.normalize()

println("Root Element :" + xmlDoc.documentElement.nodeName)
println("--------")

// get <staff>
val staffList: NodeList = xmlDoc.getElementsByTagName("staff")

for (i in 0 until staffList.length) {
var staffNode = staffList.item(i)

if (staffNode.nodeType === Node.ELEMENT_NODE) {

val element = staffNode as Element

// get staff's attribute
val id = element.getAttribute("id")

// get text
val firstname = element.getElementsByTagName("firstname").item(0).textContent
val lastname = element.getElementsByTagName("lastname").item(0).textContent
val nickname = element.getElementsByTagName("nickname").item(0).textContent

val salaryNodeList = element.getElementsByTagName("salary")
val salary = salaryNodeList.item(0).textContent

// get salary's attribute
val currency = salaryNodeList.item(0).attributes.getNamedItem("currency").textContent

println("Current Element : ${staffNode.nodeName}")
println("Staff Id : $id")
println("First Name: $firstname")
println("Last Name: $lastname")
println("Nick Name: $nickname")
println("Salary [Currency] : ${salary.toLong()} [$currency]")
}
}
} catch (e: Throwable) {
e.printStackTrace()
}
}

第三步:解析结果输出


Root Element :company
--------
Current Element : staff
Staff Id : 1001
First Name: Jack
Last Name: Ma
Nick Name: Hui Chuang A Li
Salary [Currency] : 100000 [USD]
Current Element : staff
Staff Id : 2001
First Name: Pony
Last Name: Ma
Nick Name: Pu Tong Jia Ting
Salary [Currency] : 200000 [RMB]

2.1.2 DOM 创建、生成 XML


第一步:创建新的 XML 并填充内容


package com.elijah.kotlinlearning

import org.w3c.dom.Document
import org.w3c.dom.Element
import java.io.File
import javax.xml.parsers.DocumentBuilderFactory
import javax.xml.transform.OutputKeys
import javax.xml.transform.TransformerFactory
import javax.xml.transform.dom.DOMSource
import javax.xml.transform.stream.StreamResult



fun main(args: Array<String>) {

// Instantiate the Factory
val docFactory = DocumentBuilderFactory.newInstance()

try {
// root elements
val docBuilder = docFactory.newDocumentBuilder()
val doc = docBuilder.newDocument()
val rootElement: Element = doc.createElement("company")
doc.appendChild(rootElement)

// add xml elements: staff 1001
val staff = doc.createElement("staff")
staff.setAttribute("id", "1001")

// set staff 1001's attribute
val firstname = doc.createElement("firstname")
firstname.textContent = "Jack"
staff.appendChild(firstname)
val lastname = doc.createElement("lastname")
lastname.textContent = "Ma"
staff.appendChild(lastname)
val nickname = doc.createElement("nickname")
nickname.textContent = "Hui Chuang A Li"
staff.appendChild(nickname)
val salary: Element = doc.createElement("salary")
salary.setAttribute("currency", "USD")
salary.textContent = "100000"
staff.appendChild(salary)
rootElement.appendChild(staff)

// add xml elements: staff 1002
val staff2: Element = doc.createElement("staff")
rootElement.appendChild(staff2)
staff2.setAttribute("id", "1002")

// set staff 1002's attribute
val firstname2 = doc.createElement("firstname")
firstname2.textContent = "Pony"
staff2.appendChild(firstname2)
val lastname2 = doc.createElement("lastname")
lastname2.textContent = "Ma"
staff2.appendChild(lastname2)
val nickname2 = doc.createElement("nickname")
nickname2.textContent = "Pu Tong Jia Ting"
staff2.appendChild(nickname2)
val salary2= doc.createElement("salary")
salary2.setAttribute("currency", "RMB")
salary2.textContent = "200000"
staff2.appendChild(salary2)
rootElement.appendChild(staff2)

val newXmlFile = File("${projectPath}/src/res/", "generatedXml.xml")

// write doc to new xml file
generateXml(doc, newXmlFile)
} catch (e: Throwable) {
e.printStackTrace()
}
}

// write doc to new xml file
private fun generateXml(doc: Document, file: File) {
// Instantiate the Transformer
val transformerFactory = TransformerFactory.newInstance()
val transformer = transformerFactory.newTransformer()

// pretty print
transformer.setOutputProperty(OutputKeys.INDENT, "yes")
val source = DOMSource(doc)
val result = StreamResult(file)
transformer.transform(source, result)
}

第二步:生成 XML 文件 generatedXml.xml


<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<company>
<staff id="1001">
<firstname>Jack</firstname>
<lastname>Ma</lastname>
<nickname>Hui Chuang A Li</nickname>
<salary currency="USD">100000</salary>
</staff>
<staff id="1002">
<firstname>Pony</firstname>
<lastname>Ma</lastname>
<nickname>Pu Tong Jia Ting</nickname>
<salary currency="RMB">200000</salary>
</staff>
</company>

2.2 SAX(Simple API for XML)


SAX 处理的特点是基于事件流的。分析能够立即开始,而不是等待所有的数据被处理。而且,由于应用程序只是在读取数据时检查数据,因此不需要将数据存储在内存中。这对于大型文档来说是个巨大的优点。


优点:



  1. 访问能够立即进行,不需要等待所有数据被加载

  2. 只在读取数据时检查数据,不需要保存在内存中

  3. 占用内存少,不需要将整个数据都加载到内存中

  4. 允许注册多个 Handler,可以用来解析文档内容,DTD 约束等等


缺点:



  1. 需要应用程序自己负责 TAG 的处理逻辑(例如维护父/子关系等),文档越复杂程序就越复杂

  2. 单向导航,无法定位文档层次,很难同时访问同一文档的不同部分数据,不支持 XPath

  3. 不能随机访问 xml 文档,不支持原地修改 xml


适用于: 文档较大,只需要读取文档数据。


2.2.1 SAX 解析 XML


第一步:新建 ContentHandler 解析类


package com.elijah.kotlinlearning

import org.xml.sax.Attributes
import org.xml.sax.helpers.DefaultHandler

class ContentHandler: DefaultHandler(){

private var nodeName :String? = null // 当前节点名
private lateinit var firstname: StringBuilder // 属性:firstname
private lateinit var lastname: StringBuilder // 属性:lastname
private lateinit var nickname: StringBuilder // 属性:nickname
private lateinit var salary: StringBuilder // 属性:salary

// 开始解析文档
override fun startDocument() {
firstname = StringBuilder()
lastname = StringBuilder()
nickname = StringBuilder()
salary = StringBuilder()
}

// 开始解析节点
override fun startElement(
uri: String?,
localName: String?,
qName: String?,
attributes: Attributes?
) {
nodeName = localName
}

// 开始解析字符串
override fun characters(ch: CharArray?, start: Int, length: Int) {
// 判断节点名称
when (nodeName) {
"firstname" -> {
firstname.append(ch, start, length)
}
"lastname" -> {
lastname.append(ch, start, length)
}
"nickname" -> {
nickname.append(ch, start, length)
}
"salary" -> {
salary.append(ch, start, length)
}
}
}

// 结束解析节点
override fun endElement(uri: String?, localName: String?, qName: String?) {
// 打印出来解析结果
if (localName == "staff") {
println("Staff is : $nodeName")
println("First Name: ${firstname.toString()}")
println("Last Name: ${lastname.toString()}")
println("Nick Name: ${nickname.toString()}")
println("Salary [Currency] : ${salary.toString()}")

// 清空, 不妨碍下一个 staff 节点的解析
firstname.clear()
lastname.clear()
nickname.clear()
salary.clear()
}
}

// 结束解析文档
override fun endDocument() {
super.endDocument()
}
}

第二步:新建解析器对指定 XML 进行解析


package com.elijah.kotlinlearning

import org.xml.sax.InputSource
import java.io.File
import javax.xml.parsers.SAXParserFactory

fun main(args: Array<String>) {
try{
// 新建解析器工厂
val saxParserFactory = SAXParserFactory.newInstance()
// 通过解析器工厂获得解析器对象
val saxParser = saxParserFactory.newSAXParser()
// 获得 xmlReader
val xmlReader = saxParser.xmlReader
// 设置解析器中的解析类
xmlReader.contentHandler = ContentHandler()
// 设置解析内容
val inputStream = File("${projectPath}/src/res/Staff.xml").inputStream()
xmlReader.parse(InputSource(inputStream))
} catch(e: Throwable){
e.printStackTrace()
}
}

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

Flutter 语法进阶 | 抽象类和接口本质的区别

1. 接口存在的意义? 在 Dart 中 接口 定义并没有对应的关键字。可能有些人觉得 Dart 中弱化了 接口 的概念,其实不然。我们一般对接口的理解是:接口是更高级别的抽象,接口中的方法都是 抽象方法 ,没有方法体。通过接口的定义,我们可以通过定义接口来声...
继续阅读 »

1. 接口存在的意义?


Dart接口 定义并没有对应的关键字。可能有些人觉得 Dart 中弱化了 接口 的概念,其实不然。我们一般对接口的理解是:接口是更高级别的抽象,接口中的方法都是 抽象方法 ,没有方法体。通过接口的定义,我们可以通过定义接口来声明功能,通过实现接口来确保某类拥有这些功能。


不过你有没有仔细想过,为什么接口会存在,引入接口的概念是为了解决什么问题?可能有人会说,通过接口,可以规范一类事物的功能,可以面向接口进行操作,从而可以更加灵活地进行拓展。其实这只是接口的作用,而且这些功能 抽象类 也可以支持。所以接口一定存在什么特殊的功能,是抽象类无法做到的。


都是抽象方法的抽象类,和接口有什么本质的区别呢?在我的初入编程时,这个问题就伴随着我,但渐渐地,这个问题好像对编程没有什么影响,也就被遗忘了。网上很多文章介绍 抽象类接口 的区别,只是在说些无关痛痒的形式区别,并不能让我觉得接口存在有什么必要性。


思考一件事物存在的本质意义,可以从没有这个事物会产生什么后果来分析。现在想一下,如果没有接口,一切的抽象行为仅靠 抽象类 完成会有什么局限性 或说 弊端。没有接口,就没有 实现 (implements) 的概念,其实这就等价于在问 implements 消失了,对编程有什么影响。没有实现,类之间就只能通过 继承 (extends) 来维护 is-a 的关系。所以就等价于在问 extends 有什么局限性 或说 弊端。答案呼之欲出:多继承的二义性


那问题来了,为什么类不能支持 多继承 ,而接口可以支持 多实现继承实现 有什么本质的区别呢?为什么 实现 不会带来 二义性 的问题,这是理解接口存在关键。




2. 继承 VS 实现


下面我们来探讨一下 继承实现 的本质区别。如下 AB 类,有一个相同的成员变量和成员方法:


class A{
String name;

A(this.name);

void run(){ print("B"); }
}

class B{
String name;

B(this.name);

void run(){ print("B"); }
}

对于继承而言 派生类 会拥有基类的成员变量与成员方法,如果支持多继承,就会出现两个问题:



  • 问题一 : 基类中有同名 成员变量 ,无法确定成员的归属类

  • 问题二: 基类中有同名 成员方法 ,且子类未覆写。在调用时,无法确定执行哪个。


class C extends A , B {
C(String name) : super(name); // 如果多继承,该为哪个基类的 name 成员赋值 ??
}

void main(){
C c = C("hello")
c.run(); // 如果多继承,该执行哪个基类的 run 方法 ??
}



其实仔细思考一下,一般意义上的接口之所以能够 多实现 ,就是通过限制,对这两个问题进行解决。比如 Java 中:



  • 不允许在接口中定义普通的 成员变量 ,解决问题一。

  • 在接口中只定义抽象成员方法,不进行实现。而是强制派生类进行实现,解决问题二。


abstract class A{
void run();
}

abstract class B{
void run();
}

class C implements A,B{
@override
void run() {
print("C");
}
}

到这里,我们就认识到了为什么接口不存在 多实现 的二义性问题。这就是 继承实现 最本质的区别,也是 抽象类接口 最重要的差异。从这里可以看出,接口就是为了解决多继承二义性的问题,而引入的概念,这就是它存在的意义。




3. Dart 中接口与实现的特殊性


Dart 中并不像 Java 那样,有明确的关键字作为 接口类 的标识。因为 Dart 中的接口概念不再是 传统意义 上的狭义接口。而是 Dart 中的任何类都可以作为接口,包括普通的类,这也是为什么 Dart 不提供关键字来表示接口的原因。


既然普通类可以作为接口,那多实现中的 二义性问题 是必须要解决的,Dart 中是如何处理的呢? 如下是 AB 两个普通类,其中有两个同名 run 方法:


class A{
void run(){
print("run in a");
}
}

class B{
void run(){
print("run in a");
}

void log(){
print("log in a");
}
}

C 类实现 AB 接口,必须强制覆写 所有 成员方法 ,这点解决了二义性的 问题二





问题一 中的 成员变量 的歧义如何解决呢?如下,在 AB 中添加同名的成员变量:


class A{
final String name;
A(this.name);
// 略同...
}

class B{
final String name;
B(this.name);
// 略同...
}

C 类实现 AB 接口,必须强制覆为 所有 成员变量提供 get 方法 ,这点解决了二义性的 问题一



这样,C 就可以实现两个普通类,而避免了二义性问题:


class C implements A, B {
@override
String get name => "C";

@override
void log() {}

@override
void run() {}
}

其实,这是 Dartimplements 关键字的功能加强,迫使派生类必须提供 所有 成员变量的 get 方法,必须覆写 所有 成员方法。这样就可以让 接口 成为两个独立的概念,一个 class 既可以是类,也可以是接口,具有双重身份。其区别在于,在 extend 关键字后,表示继承,是作为类来对待;在 implements 关键字之后,表示实现,是作为接口来对待。




4.Dart 中抽象类作为接口的小细节


我们知道,抽象类中允许定义 普通成员变量/方法 。下面举个小例子说明一下 继承 extend实现 implements 的区别。对于继承来说,派生类只需要实现抽象方法即可,抽象基类 中的普通成员方法可以不覆写:





而前面说过,implements 关键字要求派生类必须覆写 接口 中的 所有 方法 。也就表示下面的 C implements A 时,也必须覆写 log 方法。从这个例子中,可以很清楚地看出 继承实现 的差异性。



抽象类接口 的区别,就是 继承实现 的区别,在代码上的体现是 extendimplements 关键字功能的区别。只有理解 继承 的局限性,才能认清 接口 存在的必要性。那本文就到这了,谢谢观看 ~


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

灵隐寺高僧汇报 “数字化寺院” 方案,走红网络! “系统可用性” 随缘、KPI 随心?

最近,一张灵隐寺高僧汇报 “数字化寺院” 方案的图片,走红网络!许多从事IT工作的网友沸腾了,很好奇 “数字化寺院”平台的系统可用性,做到几个9 ? 灵隐寺的IT高僧 KPI 是否随缘?下面给大家拆解下这个大屏背后的技术架构是怎样的?对寺院的实时业务信息做到了...
继续阅读 »

最近,一张灵隐寺高僧汇报 “数字化寺院” 方案的图片,走红网络!


许多从事IT工作的网友沸腾了,很好奇 “数字化寺院”平台的系统可用性,做到几个9 ? 灵隐寺的IT高僧 KPI 是否随缘?

下面给大家拆解下这个大屏背后的技术架构是怎样的?

对寺院的实时业务信息做到了及时分析和展示,后台可能还接入到了杭州文旅以及杭州城市大脑,是杭州城市大脑的组成部分;

灵隐寺的数字化面临非常多的问题:如何吸引游客来?如何促进游客消费?如何及时应急和预警?如何提供更佳服务?如何定位精确客群?……从知名度、转化率、安全保障、互动体验到精准营销各方面的问题都需要解决,只有这些问题都解决了,才能成功转型。

而解决这些问题的关键钥匙最终还是落在数据上,数据驱动是涉旅企业和单位转型智慧旅游的必经之路。只有打通数据孤岛,建立全域数据共享中心,从而推动企业和单位从旅游行业格局、空间格局、旅游方式、商业模式上寻找转型点,而不是靠领导拍板、经验转型。


灵隐寺搭建了“灵隐寺一体化综合服务”应用场景数字大厅,深度开发了“一屏、一中心、四件事、六大管理平台”有效实现数字赋能现代寺院管理。

智慧寺院,出家人不打诳语

7月下旬,杭州灵隐寺举办了“智慧寺院”应用场景上线仪式。


“智慧寺院”上线仪式上,光泉法师表示,数字技术的飞速发展为数字赋能现代寺院智慧管理带来了新的契机,数字化不仅是佛教适应时代发展的具体路径,更是佛教自我健康传承的迫切需求。

今后,灵隐寺将不断推进应用场景优化升级,推动数字化改革成果持续输出,为推进我国宗教中国化和宗教事务治理现代化贡献智慧和力量。出家人不打诳语。智慧寺院确实有点东西。

杭州灵隐寺“智慧寺院”应用场景上线仪式成功举办,各界领导均出席了上线仪式。



省民宗委副主任、一级巡视员陈振华讲话

陈振华指出,杭州灵隐寺一直以来积极探索实践宗教活动场所数字化管理新路径,在全省争当表率、走在前列,为我省建设宗教中国化和宗教事务治理现代化先行省作出了贡献。他强调:

要勇于变革,做宗教活动场所数字化改革的探索者。

坚持数字赋能,勇于变革创新,当好宗教活动场所现代化管理的引领者,发挥好信教群众与党委政府之间的桥梁纽带作用。

要实战实效,做宗教活动场所智慧化管理的领跑者

进一步健全体制机制,重构管理体系,不断做好场景迭代升级和功能拓展,争取打造最佳应用,形成可在全省寺院推广运行的数字化管理新模式。

要守正创新,做宗教活动场所中国化建设的示范者

将“智慧寺院”应用场景与浙江省宗教中国化场所建设“151”指标体系相结合,在宗教中国化场所创建中继续做好示范,当好表率,以数字赋能牵引推动宗教中国化落实落地。


市委统战部副部长、市民宗局局长邵根松讲话

邵根松指出,杭州灵隐寺率先成立首家佛教数字赋能研究中心,在探索数字赋能现代寺院智慧管理,激发1700年江南名刹数字活力等方面倾注了大量精力,为打造具有鲜明杭州辨识度的数字化应用场景树立了榜样。


省佛教协会会长、灵隐寺方丈光泉法师致辞

光泉法师表示,数字技术的飞速发展为数字赋能现代寺院智慧管理带来了新的契机,数字化不仅是佛教适应时代发展的具体路径,更是佛教自我健康传承的迫切需求。今后,灵隐寺将不断推进应用场景优化升级,推动数字化改革成果持续输出,为推进我国宗教中国化和宗教事务治理现代化贡献智慧和力量。

来源:https://mp.weixin.qq.com/s/T5AKGcltiU5rjvOjiTs11Q

收起阅读 »

Android 开发还有必要深耕吗?现状怎么样?未来前景将会怎样?

截止到今天,Android的生态发生了不少变化以前的鼎盛时期,堪称是个公司就做App,由于当时市场上缺乏Android开发,招聘往往是低要求、高薪资,只要你面试说得上四大组件,第二天马上拎包入职,线下的Android培训也是一抓一大把,吸引了一大批人涌入And...
继续阅读 »

截止到今天,Android的生态发生了不少变化

以前的鼎盛时期,堪称是个公司就做App,由于当时市场上缺乏Android开发,招聘往往是低要求、高薪资,只要你面试说得上四大组件,第二天马上拎包入职,线下的Android培训也是一抓一大把,吸引了一大批人涌入Android开发行业。Android招聘市场的需求逐渐被填充,招聘要求逐步提高……

随着“互联网寒冬”的到来,大批互联网公司纷纷倒闭,大厂也纷纷裁员节流,人才供给大幅增加、需求大幅降低,造成当时的市场迅速达到饱和。培训出来的初级Android开发找不到工作,大厂被裁员的Android开发放不下薪资要求,这批人找不到工作,再加上当时自媒体的大肆渲染,Android开发可不就“凉了”吗?

毫不夸张的说,早期说得上四大组件稍微能做一点点,拿个15-20k是比较轻松的,要是你还有过完整开发经验,30k真是一点都不过分,而在“寒冬”之后,当招聘市场供给过剩时,面试官有了充分的选择权,你会四大组件,那我就有完整App独立开发经验,另一人或许有过十万级App开发经验,你说面试官会收下谁呢?岗位招聘要求也是在这时迅速拔高,整个Android市场逐渐趋于平稳,大家感觉Android开发来到了内卷期……

再来说现在:

Android凉了吗?

其实并不是Android凉了,而是技术不过硬的Android凉了

被高薪晃晕了头脑放不下身段的假高工凉了

现在的Android市场,Android初级工程师早就已经严重饱和了,供远大于求。这就导致了很多Android开发会面临被优化、被毕业、找不到工作这种情况,然后这部分人又出来说Android凉了,如此循环之下,以致于很多人都觉得Android凉了……

其核心原因只是Android开发市场由鼎盛的疯狂逐渐趋于平稳

这里也给出Android开发薪资/年限图给大家参考:


也不缺少学历突出的、能力突出的、努力突出的,这三类都可以拿到比图中同级别更可观的薪资

当然,我们并不能以薪资作为职级的标准,决定一个Android工程师到底是初级、中级、高级还是资深的,永远都不会是开发年限!

只有技术才能客观的作为衡量标准!

不管是几年经验,如果能力与工作年限不匹配,都会有被毕业的风险,如果掌握的技术达不到对应职级的标准,那别想了,毕业警告……

在很多人觉得Android凉了的时候,也不乏有Android开发跳槽进大厂拿高薪,不少在闷头提升技术水平,迄今为止还没有听过哪个Android开发大牛说“Android凉了”,当大家达到一定的高度之后,就会得知谁谁谁跳槽美团,几百万;某某某又跳进了阿里、腾讯……

不管在任何行业,任何岗位,初级技术人才总是供大于求;不管任何行业、岗位,技术过硬的也都是非常吃香的!

在初级市场”凉了“的同时,高级市场几乎是在抢人!

很多高薪、急招岗位挂上了招聘网站,往往一整年都面试不了几场,自打挂上来,就没动过了……

所以说,Android开发求职,质量才是关键!

再说到转行问题

我一直都比较佩服有大勇气转行的朋友,因为转行需要我们抛弃现有的知识技能,重新起航

佩服归佩服,身边不少之前是Android开发的朋友转行Java、Python,但他们对于目前市场还是过于乐观了,Python很火,它竞争不大吗?部分转行从0开始的,甚至连应届生都比不过~

不要轻易转行,如果要转一定要尽早转

转行有两种我认为是正常的,一种是行业消失了、没落了,继续留在业内无法施展才华。另一种是兴趣压根就不在本行,因此选一个自己感兴趣的。而现在大部分转行都是为了跟风,为了那看得见但摸不着的”风口“,而忽略了长期的发展潜力。


不管是学习力也好,精力也好,大部分人在35岁之前都属于加速期,加速期的一些选择,决定了35岁之后到底是上升还是衰落。

以Android开发转Python来说,一个Android高级转行Python会变为Python初级,这时从事Python的人都在加速提高,要想赶超在你之前的拥有同样学习力的人是不可能办到的,这就导致在转行前期极为被动,还要保证在35岁前成为Python专家或者Leader才有可能在35岁后不进入衰落期,当然这时你的Android基本也就荒废了,不说很难成为专家,高级也成为了一个很大的门槛。

如果你还想要在对应的技术领域走的更远,就不要轻易选择转行,如果实在想要转,那么越早越好、越快越好,你的竞争者都在加速提升技术水平,职场上,没人会停下等你的……

转行大部分都产生不了质变

我们所说的质变可以理解为在一个技术领域的大幅提升,或者是不相关领域的跨界

比如由高级开发变为专家,或者是由高级开发升到Leader,再或者跨界开始做一些技术相关的博客、培训、演讲、出书等等而被人所熟知。

凡是能帮助你在职业生涯中后期进入上升期的都可以看做是一次质变,而转行很少是质变,更多的都是倒退回到原点重新出发,形象点来说,你只是换了个不同的砖头接着搬砖而已。因此我们更应该去追求质变,而不是平行或者倒退,一次倒退或许可以承受,多次倒退就很难在职业生涯中后期再进入上升期。

其实不少转行的人都没有起到积极作用,毕竟都是从0开始,精进到专家绝不是一朝一夕可以完成的

或许到时又会有同样的问题:

前端凉了?前景怎么样?

Java凉了?前景怎么样?

大数据凉了?前景怎么样?

人工智能凉了?前景怎么样?

……

而另一类人,其实不管在哪个行业都可以混的风生水起!

如果是这种,那么想必也不需要考虑转行了。

所以根本不用想着Android凉了或是说要转行,与其焦虑不安,不如努力提升技术水平,毕竟在这时代,有硬技术的人到哪都吃香。

我们想要往高级进阶,建立属于自己的系统化知识体系才是最重要的,高工所需要掌握的技术不是通过蹭热点和玩黑科技,而是需要真正深入到核心技术的本质,知晓原理,知其然知其所以然。

可能不少人会觉得Android技术深度不深,技术栈不庞大,Android职业发展有限,这就真是个天大的误解。

先说技术上,Android的技术栈随着时间的推移变得越来越庞大,细分领域也越来越多,主要有应用开发、逆向安全、音视频、车联网、物联网、手机开发和SDK开发等等,每个细分领域都有很多技术栈组成,深度都足够精深,就拿所有细分领域通用的Android系统底层源码来说,就会叫你学起来生不如死。

还有AI、大数据、边缘计算、VR/AR,很多新的技术浪潮也都可以结合进移动开发的技术范畴……

那么现在Android怎么学?学什么?

这几年Android新技术的迭代明显加速了,有来自外部跨平台新物种的冲击,有去Java化的商业考量,也有Jetpack等官方自建平台的加速等多种原因。

作为Android开发者,我们需要密切关注的同时也不要盲目跟随,还是要认清趋势,结合项目现状学习。

Kotlin

Kotlin已经成为Android开发的官方语言,Android的新的文档和Sample代码都开始转向 Kotlin,在未来Java将加速被 Kotlin替代。

刚推出时,很多人都不愿意学习,但现在在面试中已经是经常会出现了,很多大公司也都已经拥抱新技术了。现在Kotlin是一个很明显的趋势了,不少新技术都需要结合Kotlin来使用,未来在工作中、面试中所占的比重肯定会更大。

Jetpack+Compose

Jetpack的意义在于帮我们在SDK基础上提供了一系列中间件工具,让我们可以摆脱不断造轮子抄轮子的窘境。同类的解决方案首先考虑Jetpack其次考虑第三方实现,没毛病。

Jetpack本身也会不断吸收优秀的第三方解决方案进来。所以作为开发者实时关注其最新动态就可以了。

Compose是Google I/O 2019 发布的新的声明式的UI框架。其实Google内部自2017年便开始立项,目前API已稳定,构建,预览等开发体验已经趋于完整。

而且新的设计思想绝对是趋势,已经在react和flutter等前端领域中得到验证,ios开发中同期推出的swiftUI更是证明了业界对于这种声明式UI开发趋势的共识。这必将是日后Android app极为重要的编程方式。

开源框架底层原理

现在的面试从头到尾都是比较有深度的技术问题,虽然那些问题看上去在网上都能查到相关的资料,但面试官基本都是根据你的回答持续深入,如果没有真正对技术原理和底层逻辑有一定的了解是无法通过的。

很多看似无理甚至无用的问题,比如 “Okhttp请求复用有没有了解”,其实是面试官想借此看看你对网络优化和Socket协议的理解情况和掌握程度,类似问题都是面试官想借此看看你对相关原理的理解情况和掌握程度,甚至进而引伸到你对架构,设计模式的理解。只有在熟知原理的前提下,你才能够获得面试官的青睐。

Framework

Framework作为Android的框架层,为App提供了很多API调用,但很多机制都是Framework包装好后直接给App用的,如果不懂这些机制的原理,就很难在这基础上进行优化。

像启动监控、掉帧监控、函数插桩、慢函数检测、ANR监控,都需要比较深入的了解Framework,才能知道怎么去监控、利用什么机制监控、函数插桩插到哪里、反射调用该反射哪个类哪个方法哪个属性……

性能优化

性能优化是软件工程的深水区,也是衡量一个程序员能力高低的标准

想要搞清楚性能优化,必须对各种底层原理有着深度的了解,对各种 case非常丰富的经验;很多朋友经常遇到措手不及的问题,大多是因为对出现问题的情况和处理思路模糊不清,导致此原因就是因为没有彻底搞懂底层原理。

性能优化始终穿插在 App 整个研发生命周期中,不管是从 0 到 1 的建立阶段,还是从 1 到 N 打磨阶段,都离不开性能优化。

音视频

伴随着疫情的反复以及5G的普及,本就火爆的音视频技术是越来越热,很多大小厂在这几年也都纷纷入局。但音视频学习起来门槛比较高,没有比较系统的教程或者书籍,网上的博客文章也都是比较零散的。

招聘市场上,同级别的音视频开发要比应用开发薪资高出30%以上。

车载

在智能手机行业初兴起时,包括BAT在内许多传统互联网企业都曾布局手机产业,但是随着手机市场的基本定型,造车似乎又成了各大资本下一个追逐的方向。百度、小米先后宣布造车,阿里巴巴则与上汽集团共同投资创立了,面向汽车全行业提供智能汽车操作系统和智能网联汽车整体解决方案的斑马网络,一时间造车俨然成了资本市场的下一个风口。

而作为移动端操作系统的霸主Android,也以一种新的姿态高调侵入造车领域

关于学习

在学习的过程中,可能会选择看博客自学、看官方文档、看书、看大厂大牛整理的知识点文档、看视频,但要按学习效率来排序的话:报培训班>看视频>知识点>书籍>官方文档>博客

报班,可能很多朋友对于报班这个事情比较抵触,但不可否认,报一个培训班是可以学到很多深层次的、成体系的技术,像之前读书一样,都是捣碎了喂给你,并且培训班其实对于新技术、新趋势是相当敏锐的,可以第一时间接触,也会规避开自学的烦恼。

看视频,基本也是由别人捣碎知识点教会你,但较之培训班的话,视频的知识成体系吗?有没有过时?

大厂大牛整理的知识点文档,大厂大牛技术还是比较可靠的,这类型的知识点文档初版基本是可以放心享用,但如果只是少数人甚至是一个人进行维护的话,当整个文档的知识体系越来越广时,其中的部分知识点可能已经过时但一直没有时间更新

书籍,相比前者就更甚了,一个技术出来,先研究、再整理、修正……直到最后出版被你买到,中间经过的这段时间就是你落后于其他人的地方了,但其中的知识点基本可以肯定成体系、无重大错误。学习比较底层的,不会有很大改动的知识点还是相当不错的。

官方文档,这一块也是我思考了很久才排好,官方文档往往是第一手资源,对于有能力看懂的朋友来说,可以直接上手品尝。但其实很多开发拿到官方文档还是看的一知半解,再者说,自己看可能会有遗漏,还是没有别人一点一点将重点翻开来解读更好

博客,网络上的博客水平参差不齐,通常大家擅长的也不是同一个技术领域,往往是学习一块看A的,另一块看B的,而且网上很多博客都是抄来自己记录的,很多API已经过时了,甚至不少连代码都是完全错误的,这样的学习,可想而知……

最后

一些个人见解,也参考了不少大佬的观点,希望可以给大家带来一些帮助,如果大家有什么不同看法,也欢迎在评论区一起讨论交流

Android路漫漫,共勉!


作者:像程序一样思考
来源:juejin.cn/post/7128425172998029320

收起阅读 »

浅谈Kotlin编程-Kotlin基础语法和编码规范

前言 上一篇我们认识了Kotlin编程语言,也搭建好开发环境。本篇就进入Kotlin的基础语法介绍,与其他编程语言一样,Kotlin也有自己的一套编码规范。 文章总览 1.Kotlin基本语法 1.1 函数声明 使用关键字 fun 声明: fun sum(a...
继续阅读 »

前言


上一篇我们认识了Kotlin编程语言,也搭建好开发环境。本篇就进入Kotlin的基础语法介绍,与其他编程语言一样,Kotlin也有自己的一套编码规范。


文章总览


Kotlin基础.png


1.Kotlin基本语法


1.1 函数声明


使用关键字 fun 声明:


fun sum(a: Int, b: Int): Int { return a + b }

以上函数有俩个 int 参数:a , b;返回值为 Int 类型值。


在Kotlin中,返回值类型可以自行推断,函数体可以是表达式:这与Java是有区别的,直接用 = 相连


fun sum(a: Int, b: Int) = a + b

无返回值的函数,使用 Unit 为写法更简便可以将 Unit 省略。


fun printSum(a: Int, b: Int): Unit { 
println("sum of $a and $b is ${a + b}")
}
// Unit 返回类型可以省略

1.2 程序主入口


Kotlin 程序的入口是 main函数,与 Java 是一样的。


fun main() { 
println("Hello world!") // 打印字符串
}

程序在执行时,会先进入 main 函数开始执行。


1.3 变量



  • 只读局部变量(常量) 使用 val 定义


val a: Int = 1 // ⽴即赋值 
val b = 2 // ⾃动推断出 `Int` 类型
val c: Int // 如果没有初始值类型不能省略
c = 3 // 明确赋值


  • 可重新赋值变量 使用 var 定义


var x = 5 // ⾃动推断出 `Int` 类型 
x += 1 // x重新赋值

这与 Java 有很大区别,不用指定变量的类型,有编译器自动推断出来。


1.4 条件表达式


与 Java 中的 if 语句一样


if (a > b) { 
return a
} else {
return b
}

在 Kotlin中 if 也可以⽤作表达式,更加简便


fun max(a: Int, b: Int) = if (a > b) a else b

1.5 when表达式


when 将它的参数与所有的分⽀条件顺序⽐较,直到某个分⽀满⾜条件


when (obj) { 
1 -> "One"
"Hello" -> "Greeting"
is Long -> "Long"
!is String -> "Not a string"
else -> "Unknown"
}

可以类比 Java 中的 switch 语句。


1.6 空值与空检测


一个表达式或者一个变量可以为Null, 在Kotlin中可以使用 来结尾表示


fun parseInt(str: String): Int? { // …… }  
// 函数返回值可为空,当返回值 不是 Int 类型,返回值就是Null

这一特性解决了 Java 中一老大难的问题:NullpointException 空指针报错问题,在日常开发中帮开发者提高了不少开发效率和减少了不少bug。


1.7 区间使用


使⽤ in 操作符来检测某个数字是否在指定区间内


val x = 10 
val y = 9
if (x in 1..y+1) {
println("in range")
}

这个特性可以运用到 区间和数列中。


2.Kotlin编码规范


Kotlin项目结构.png




  • 目录结构:可以类比 Java 项目,包名的规则:小写字母,公司/组织域名反写




  • 代码源文件:以 .kt 为扩展名,命名规则首字母大写的驼峰风格,例如 HelloWorld.kt




  • 命名规则:



    • 类与对象的名称以大写字母开头并使用驼峰风格

    • 包的名称总是小写且不使用下划线




  • 文档注释:



    • 多行注释

    • 单行注释




  • 代码缩进风格要统一




  • 注解:将注解放在单独的⾏上,在它们所依附的声明之前,并使⽤相同的缩进




  • 链式调用:对链式调⽤换⾏时,将 . 字符或者 ?. 操作符放在下⼀⾏,带有缩进




  • 不在 . 或者 ?. 左右留空格: foo.bar().filter { it > 2 }.joinToString() , foo?.bar()




  • // 之后留⼀个空格: // 这是⼀条注释




  • 不要在⽤于指定类型参数的尖括号前后留空格: class Map { …… }




  • 不要在 :: 前后留空格: Foo::class 、String::length




  • 不要在⽤于标记可空类型的 ? 前留空格: String?




总结


本文主要讲解 Kotlin 常用的基本语法,后续会针对特定的知识点展开学习,同时学习了Kotlin 编码规范,对日常规范编写代码是非常有帮助。


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

Android—以面试角度剖析HashMap源码

前言 HashMap 这个词想必大家都挺熟悉的!但往往大多数都知其所用,而不知其原理,导致面试的处处碰壁!因此,这一篇的作用就是以面试的角度剖析HashMap!话不多说,直接开始! 温馨提示:此文有点长,建议先插眼,等有空闲时间观看 1、为什么要学HashMa...
继续阅读 »

前言


HashMap 这个词想必大家都挺熟悉的!但往往大多数都知其所用,而不知其原理,导致面试的处处碰壁!因此,这一篇的作用就是以面试的角度剖析HashMap!话不多说,直接开始!


温馨提示:此文有点长,建议先插眼,等有空闲时间观看


1、为什么要学HashMap?


刚刚说了本篇是以面试角度剖析HashMap,那么面试常见的问题有哪些呢?



  • HashMap的原理?内部数据结构?

  • HashMap中put方法的过程是怎样实现的?

  • HashMap中hash函数是怎样实现的?

  • HashMap是怎样扩容的呢?

  • HashMap中某个Entry链太长,查找时间复杂度可能达到O(n),怎么优化?


2、剖析HashMap


2.1 HashMap初始化


虽然这一步大家很熟悉,但过程还是少补了!


HashMap hashMap = new HashMap<>(6, 1);
HashMap hashMap2 = new HashMap<>();

源码解析


这个就很简单了,初始化HashMap有两个构造器,一个无参,一个有参。(泛型就不说了吧)


那就从简先看无参的!



/**
* The default initial capacity - MUST be a power of two.
*/

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

/**
* The load factor used when none specified in constructor.
*/

static final float DEFAULT_LOAD_FACTOR = 0.75f;

public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}

源码解析


这里我们看到:




  1. 初始化给this.loadFactor赋值为0.75f




  2. 而这个0.75f就是该HashMap对应的扩展因子。(扩展因子:当长度大于等于 容量长度*扩展因子时,需要对该map进行扩容)




  3. 而 map默认长度就是DEFAULT_INITIAL_CAPACITY=1 << 4也就是默认16




  4. 结合扩展因子一起看,也就是说,当map长度大于等于 16*0.75f的时候,对应map需要扩容!(至于怎么扩容,下面会讲解)




这里看完了无参的,趁热打铁看看有参数的!


public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);
}

源码解析


这里我们看到代码瞬间多了起来,不过前面那几个if判断都是对入参进行一系列校验,核心代码在最后两句:



  1. this.loadFactor = loadFactor这个在上面讲过,就是给扩展因子赋值,只不过由默认变成了手动

  2. this.threshold = tableSizeFor(initialCapacity); 这里我们看到调用了tableSizeFor方法,并将入参一带入该方法中!


那么这个神奇的tableSizeFor方法到底做了甚么呢???


2.1.1 tableSizeFor 方法剖析



/**
* The maximum capacity, used if a higher value is implicitly specified
* by either of the constructors with arguments.
* MUST be a power of two <= 1<<30.
*/

static final int MAXIMUM_CAPACITY = 1 << 30;


/**
* Returns a power of two size for the given target capacity.
*/

static final int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

在进行源码解析前,先对这个方法里的两个操作符进行讲解:




  1. >>>表示无符号右移,也叫逻辑右移,即若该数为正,则高位补0,而若该数为负数,则右移后高位同样补0。
    按二进制形式把所有的数字向右移动对应的位数,低位移出(舍弃),高位的空位补零。对于正数来说和带符号右移相同,对于负数来说不同。其他结构和>>相似。




  2. |表示的是或运算,即两个二进制数同位中,只要有一个为1则结果为1,若两个都为1其结果也为1,换句话说就是取并集。




源码解析


就以刚刚入参为6为例(cap=6):




  1. int n = cap - 1 这个时候 n=5




  2. n |= n >>> 1 这个时候需要将这句代码拆成两部解析




image.png



  1. 继续往下走当执行n |= n >>> 2


image.png



  1. 此时不管是n >>> 4 还是n >>> 16 因为是取并集结果都为 0000 0111 转为十进制为 n=7

  2. 那么看看最后一句(n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1; 最终结果为n=8


那我们把入参稍微调大一点,17为例(cap=17)


image.png


如图所示


我们可以得出一个结论,通过这个方法tableSizeFor计算出的值,将大于等于cap的最近的一个2的n次方的一个值,而这对应的值就是该map的初始化容量长度


OK!到这HashMap的初始化已经剖析完成了。接下来该剖析HashMap的put操作!


2.2 HashMap对应put操作


敲黑板!!核心内容来了!!!


public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}

源码解析


这里我们可以看到,该方法调用了两个方法:hash(xx)以及putVal(xx,xx,xx,xx,xx)


因为hash(xx)作为putVal方法的入参,因此,我们先看hash方法是怎么工作的


2.2.1 HashMap对应Hash算法


static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

源码解析


这里我们可以看到 使用了^运算符,它的意思总结起来就是一句话:同为假,异为真


比如说:


 0000 0000 0001 0001 
0000 0000 0001 0111
——————————
0000 0000 0000 0110

这里我们看到:相同的,运算后都为0;不同的,运算后为1


了解这个算法后,我们来看看这个方法运行:


image.png


如图所示


现在我们看到,经过一系列计算发现最终结果居然还是为 key.hashCode(),那为啥还要与 (h>>>16) 进行 ^ 异或运算呢?能不能直接return key.hashCode()呢?


答案是:当然肯定不能!!!那它为什么要这样写呢???为什么非要用^异或运算呢?


回答这个问题之前,我们先来熟悉一下:或、与、异或 这三者运算规则;


image.png


如图所示



  • 或运算:(只要有1,那么就是1) 0|0=0 ,1|0=1 ,0|1=1 ,1|1=1 我们看到有三者都为1

  • 与运算:(都是1时,结果才为1) 0&0=0 ,0&1=0 ,1&0=0 ,1&1=1 我们看到有三者都为0

  • 异或预算:(只要一样结果就是0)0^0=0 ,0^1=1 ,1^0=1 ,1^1=0 我们看到有两者为0,两者为1


总结


从这三者运算结果看,只有异或运算 真假各占50% ,也就是说,当使用异或运算时,对应的Key更具有散列性。为什么要有散列性,下文会体现出来!


image.png


如图所示


当key比较复杂时,返回结果已经和key.hashCode有所不同了,因此对应的(h = key.hashCode()) ^ (h >>> 16)还是很有必要的


到这Hash算法差不多结束了。接下来继续下一步操作!


按理说,下一步应该剖析putVal(xx,xx,xx,xx,xx)方法源码。但仔细想了哈,还是先吧结果说出来,最后将结果带进去阅读源码应该会更好一点。


2.2.2 HashMap内部构造结构


image.png


如图所示



  • HashMap内部构造为数组+链表的形式,而数组的默认长度要么是标准的16,要么就是tableSizeFor方法返回的结果

  • 当链表长度大于等于8时,将会转为红黑树结构


刚刚我们说的是,将结果带入源码解析。那我们再来分析一下这张图


试想一下,这种结构该如何保存值呢??



  1. 因为它是数组结构,所以第一时间得要找到能存储该值的下标,只有找到对应下标了才能更好的保存值

  2. 找到对应下标了,再看该下标是否存在链表结构,如果不存在则创建新的链表结构,并将对应key-value存储起来

  3. 如果存在对应链表结构,则判断该链表是否转化为红黑树,如果真,则按红黑树原理存储或者替换对应值

  4. 如果非红黑树结构,则判断对应key是否在该链表中,如果在链表中,则直接替换原有值

  5. 如果对应key不存在原有链表中,则先判断该链表长度是否大于等于7,如果真,则创建新的单元格按红黑树的原理存储对应元素,最终长度自增1位;(因为长度满足8位就是红黑树结构,因此要在自增前判断是否满足要求)

  6. 如果链表长度小于7,那么创建新的单元格直接存入该链表中,并与上一个单元格next相互关联


到这!大部分的概念理论叙述完了,接下来到了剖析源码验证环节了!!!


2.2.3 putVal方法剖析




/**
* The next size value at which to resize (capacity * load factor).
*
* @serial
*/


int threshold;



static final int TREEIFY_THRESHOLD = 8;


/**
* Implements Map.put and related methods
*
* @param hash hash for key
* @param key the key
* @param value the value to put
* @param onlyIfAbsent if true, don't change existing value
* @param evict if false, the table is in creation mode.
* @return previous value, or null if none
*/

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict)
{
Node
[] tab; Node p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length; //分析点1

if ((p = tab[i = (n - 1) & hash]) == null) //分析点2

tab[i] = newNode(hash, key, value, null);
else {
Node
e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p; //分析点3

else if (p instanceof TreeNode) //分析点 4
e = ((TreeNode
)p).putTreeVal(this, tab, hash, key, value);

else {

for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null); // 分析点5

if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash); // 分析点5-1

break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break; //分析点6

p = e; //分析点7

}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null) //分析点8
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold)
resize(); //分析点9
afterNodeInsertion(evict);
return null;
}

源码分析




  • 分析点1:当该map第一次进行put操作时,对应的tab数组并未初始化。因此这里需要调用resize()方法,并给变量n赋值(分析点9会单独讲解该方法




  • 分析点2:这里使用了(p = tab[i = (n - 1) & hash]) == null这句代码,从该方法前两句可以看出



    • n表示该HashMap对应数组长度

    • tab表示该HashMap对应数组

    • hash表示该方法的第一个入参,是由上一个方法根据hash算法推算出具有散列性的值

    • i = (n - 1) & hash 这句代码就是通过 hash算法推算的值与数组长度-1进行运算,取出对应的下标,因为hash具有散列性(平均),因此能够均匀的分配对应数组单元格


    image.png



    • 如果通过下标找到元素为空,那么就创建新的链表结构,并将当前key-value存入对应链表结构中




  • 分析点3:这里使用了p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))) 这句代码,结合分析点2一起看可以得出:



    • p 表示通过下标找到的对应链表结构,并且非空

    • k 表示该方法第二个入参,表示对应key值


    image.png



    • 因此该判断条件意思:如果输入的key,与链表第一个元素的key相同,那么将该单元格赋值给创建的e节点 (分析点8还会继续讲解该变量




  • 分析点4:这里使用了e = ((TreeNode)p).putTreeVal(this, tab, hash, key, value) 结合判断条件可以看出



    • p 表示通过下标找到的对应链表结构,并且非空

    • p instanceof TreeNode 这句判断条件表示,该链表结构是否 TreeNode 类型(红黑树结构)

    • 这里红黑树就不详解了,结构组成总结起来就一句话:你比我大,那去这一边,比我小,那就去另外一边,一直往下每个节点都这样判断


    image.png



    • 因此这里具体意思就是:如果为红黑树结构,那就按照红黑树结构存储替换值,并且将对应节点返回赋值给上面创建的e节点(分析点8还会继续讲解该变量




  • 分析点5:


    image.png



    • 逻辑执行此处,这说明已经不满足上述分析点,也就是说,处理的单位只会存在标注的红框里

    • (e = p.next) == null 这句代码表示,如果往下找已经没有节点了,那么执行p.next = newNode(hash, key, value, null) 创建新的单元格并将对应key-value存储起来并与p.next相互关联




  • 分析点5-1:



    • 结合分析点5一起看,上一步将创建的单元格与p.next相关关联后

    • TREEIFY_THRESHOLD 该变量=8

    • binCount >= TREEIFY_THRESHOLD - 1 这句代码意思是,判断当前链表是否大于等于7 ,因为自增在下文,因此这里需要减一。

    • treeifyBin(tab, hash); 这句代码意思是,满足上面判断条件,将当前链表转为红黑树结构




  • 分析点6:


    image.png



    • e 在分析5 执行了 e = p.next 并且不为null

    • (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))) 这句意思表示在红框标注里是否通过key找到了对应的单元格,如果真则跳出循环;如果假则执行分析7




  • 分析点7:


    image.png



    • 结合分析6一起看,如果当前key与当前单元格对应key不等,那么就执行p = e; 指向下一个单元格




  • 分析点8:




    • onlyIfAbsent 该变量为方法的第4个入参,value=false




    • 能进入该逻辑,因此对应e不为空!上述条件中,满足条件有:分析3、分析4、分析6




    • 这三者条件都是满足对应key相同则赋值,那么这里就是替换对应相同key对应的value值






  • 分析点9:



    • 能进分析9,则说明满足++size > threshold条件

    • size表示该map中所有key-value 的总长度

    • threshold 表示 达到扩容条件的目标值

    • resize()方法,那就是扩容了。那么这个方法到底做了甚么呢?




2.2.3.1 扩容 resize() 方法

讲解扩容之前先整理下,在哪些情况会调用该方法?



  1. 分析点1 在table=null 或者table.length=0的时候会调用该方法

  2. 分析点9 在满足++size > threshold条件时,会调用该方法


因此我们得结合这两种情况来阅读该方法!


当执行分析点1逻辑时


我们可以删掉该源方法的一部分逻辑,因此




static final float DEFAULT_LOAD_FACTOR = 0.75f;


static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16


final Node
[] resize() {
Node
[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) {
.....
}
else if (oldThr > 0){

//初始化HashMap时,调用了有参构造器,就会进入该逻辑
newCap = oldThr;

}
else {
//初始化HashMap时,调用无参构造器,就会进入该逻辑
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) {

//初始化HashMap时,调用了有参构造器,就会进入该逻辑
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
Node
[] newTab = (Node[])new Node[newCap];
table = newTab;
.....
return newTab;
}

源码解析


这里我们可以看到,通过分析点1进入该方法时:




  • 已知条件为:table =null , oldCap=0




  • 默认情况下将会直接进入else 相关逻辑 ,如果用户初始化HashMap调用的有参构造器,那么就会执行代码注释标注的部分(下面所有都按初始化时,调用无参构造器讲解)




  • 当执行newCap = DEFAULT_INITIAL_CAPACITY 对应newCap=16




  • 当执行(int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY)对应 newThr=16*0.75f




  • 当执行threshold = newThr 对应threshold=12




  • 当执行Node[] newTab = (Node[])new Node[newCap]以及table = newTab 对应table长度为newCap也就是默认16




这个就是通过分析点1进入该方法的所有逻辑!


那通过分析点9进入该方法呢?


当执行分析点9逻辑时


对应代码逻辑:


static final float DEFAULT_LOAD_FACTOR = 0.75f;


static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16


static final int MAXIMUM_CAPACITY = 1 << 30;


final Node[] resize() {
Node[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) { //分析点10
threshold = Integer.MAX_VALUE;
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY) //分析点11
newThr = oldThr << 1; // double threshold
}

//删除上面已经讲解过的代码.....

threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
Node[] newTab = (Node[])new Node[newCap]; //分析点12
table = newTab;
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
Node e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
((TreeNode)e).split(this, newTab, j, oldCap);
else { // preserve order
Node loHead = null, loTail = null;
Node hiHead = null, hiTail = null;
Node next;
do {
next = e.next;
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}

源码解析




  • 分析点10:这里是为了做最大限制扩容,如果扩容前的长度已经达到了 1<<30 ,那么此次扩容长度将会是最大值Integer.MAX_VALUE




  • 分析点11:我们来拆分一下这段条件判断代码:(newCap=oldCap<<1=DEFAULT_INITIAL_CAPACITY




    • 执行newCap=oldCap<<1 时,对应 newCap=扩容前的长度<<1 ,也就是 16<<1 ,最终结果为 32




    • 在判断逻辑里,当执行newThr = oldThr << 1 时,也就是 12<<1,最终结果为 24






  • 分析点12:将分析11的结果创建了一个全新的数组,并在下面的循环中,将原有数组里的内容赋值给这个全新数组




到这里,整个扩容机制已经讲解完了!趁热打铁,继续下一个!


2.3 HashMap对应get操作


public V get(Object key) {
Node e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}

源码解析




  • 这里我们可以看到 依然调用了两个方法 hash(xx)getNode(xx,xx)




  • hash(xx)这个在put操作里讲解过,这里不再赘述




  • 因此现在只需要讲解这个方法getNode(xx,xx) 即可




2.3.1 getNode 方法剖析


final Node getNode(int hash, Object key) {
Node[] tab; Node first, e; int n; K k;
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) { //分析点1

if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first; //分析点2

if ((e = first.next) != null) {
//分析点3
if (first instanceof TreeNode)
return ((TreeNode)first).getTreeNode(hash, key);
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null; //分析点4
}

源码解析




  • 分析点1:这里仅仅是对map里面的数组进行判断,看是否为有效有值的数组,并将有效值给first赋值




  • 分析点2:这里表示如果是在第一层节点通过key找到对应节点时,那就直接返回对应节点


    image.png




  • 分析点3:这里就和分析2相反,第一层找不到,那就只有遍历下面对应节点的下一层。如果是链表,那就按链表形式查找;如果是红黑树,那就按照红黑树形式查找;如果找到了,就将对应的节点向上一层返回


    image.png




  • 分析点4:到这里这说明上面所有方式都没有找到对应key相关的节点,因此返回null




好了!到这里HashMap相关源码已全部剖析完毕!现在来结合上文面试题总结一下!


3、总结




  • HashMap的原理?内部数据结构?



    • HashMap底层它是有哈希表组成,当链条过长时,将会转化为红黑树结构




  • HashMap中put方法的过程是怎样实现的?



    1. 对key求hash值,然后再计算下标

    2. 如果没有碰撞,直接放入数组中

    3. 如果碰撞了,就根据key判断是否存在于链表中,存在则直接覆盖值,不存在则以链表的方式链接到后面

    4. 如果链表长度过长(>=8),此时链表将转为红黑树

    5. 如果桶满了(容量*加载因子),那么就需要调用resize方法进行扩容




  • HashMap中hash函数是怎样实现的?



    • 高16bit不变,低16bit和高16bit做了一个异或

    • 通过(n-1)&hash 得到对应的下标




  • HashMap是怎样扩容的呢?



    1. 在resize方法里,首先通过(容量*加载因子)计算出下一次扩容所需要达到的条件

    2. 当在putVal,如果对应长度达到了扩容的条件那么就会再次调用resize方法,通过 原长度<<1 移位操作 进行扩容

    3. 而对应的扩容条件也会跟随这 原扩容因子<<1 移位操作




  • HashMap中某个Entry链太长,查找时间复杂度可能达到O(n),怎么优化?



    • 其实上面已经答了,就是将链表转化为红黑树操作!




到这里,本篇内容已经进入尾声了!相信能坚持看到这里的小伙伴,已经对hashMap有了充分的认知!


下一篇准备来个手写HashMap,来巩固HashMap知识点!!!


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

Android drawFunctor 原理及应用

一. 背景 蚂蚁 NativeCanvas 项目 Android 平台中使用了基于 TextureView 环境实现 GL 渲染的技术方案,而 TextureView 需使用与 Activity Window 独立的 GraphicBuffer,RenderT...
继续阅读 »

一. 背景


蚂蚁 NativeCanvas 项目 Android 平台中使用了基于 TextureView 环境实现 GL 渲染的技术方案,而 TextureView 需使用与 Activity Window 独立的 GraphicBuffer,RenderThread 在上屏 TextureView 内容时需要将 GraphicBuffer 封装为 EGLImage 上传为纹理再渲染,内存占用较高。为降低内存占用,经仔细调研 Android 源码,发现其中存在一种称为 drawFunctor 的技术,用来将 WebView 合成后的内容同步到 Activity Window 内上屏。经过一番探索成功实现了基于 drawFunctor 实现 GL 注入 RenderThread 的功能,本文将介绍这是如何实现的。


二. drawFunctor 原理介绍


drawFunctor 是 Android 提供的一种在 RenderThread 渲染流程中插入执行代码机制,Android 框架是通过以下三步来实现这个机制的:



  • 在 UI 线程 View 绘制流程 onDraw 方法中,通过 RecordingCanvas.invoke 接口,将 functor 插入 DisplayList 中

  • 在 RenderThread 渲染 frame 时执行 DisplayList,判断如果是 functor 类型的 op,则保存当前部分 gl 状态

  • 在 RenderThread 中真正执行 functor 逻辑,执行完成后恢复 gl 状态并继续


目前只能通过 View.OnDraw 来注入 functor,因此对于非 attached 的 view 是无法实现注入的。Functor 对具体要执行的代码并未限制,理论上可以插入任何代码的,比如插入一些统计、性能检测之类代码。系统为了 functor 不影响当前 gl context,执行 functor 前后进行了基本的状态保存和恢复工作。


另外,如果 View 设置了使用 HardwareLayer, 则 RenderThread 会单独渲染此 View,具体做法是为 Layer 生成一块 FBO,View 的内容渲染到此 FBO 上,然后再将 FBO 以 View 在 hierachy 上的变换绘制 Activity Window Buffer 上。 对 drawFunctor 影响的是, 会切换到 View 对应的 FBO 下执行 functor, 即 functor 执行的结果是写入到 FBO 而不是 Window Buffer。


三. 利用 drawFunctor 注入 GL 渲染


根据上文介绍,通过 drawFunctor 可以在 RenderThread 中注入任何代码,那么也一定可以注入 OpenGL API 来进行渲染。我们知道 OpenGL API 需要执行 EGL Context 上,所以就有两种策略:一种是利用 RenderThread 默认的 EGL Context 环境,一种是创建与 RenderThread EGL Context share 的 EGL Context。本文重点介绍第一种,第二种方法大同小异。


Android Functor 定义


首先找到 Android 源码中 Functor 的头文件定义并引入项目:



namespace android {

class Functor {

public:

Functor() {}

virtual ~Functor() {}

virtual int operator()(int /*what*/, void * /*data*/) { return 0; }

};

}

RenderThread 执行 Functor 时将调用 operator()方法,what 表示 functor 的操作类型,常见的有同步和绘制, 而 data 是 RenderThread 执行 functor 时传入的参数,根据源码发现是 data 是 android::uirenderer::DrawGlInfo 类型指针,包含当前裁剪区域、变换矩阵、dirty 区域等等。


DrawGlInfo 头文件定义如下:



namespace android {

namespace uirenderer {


/**

* Structure used by OpenGLRenderer::callDrawGLFunction() to pass and

* receive data from OpenGL functors.

*/

struct DrawGlInfo {

// Input: current clip rect

int clipLeft;

int clipTop;

int clipRight;

int clipBottom;


// Input: current width/height of destination surface

int width;

int height;


// Input: is the render target an FBO

bool isLayer;


// Input: current transform matrix, in OpenGL format

float transform[16];



// Input: Color space.

// const SkColorSpace* color_space_ptr;

const void* color_space_ptr;



// Output: dirty region to redraw

float dirtyLeft;

float dirtyTop;

float dirtyRight;

float dirtyBottom;



/**

* Values used as the "what" parameter of the functor.

*/

enum Mode {

// Indicates that the functor is called to perform a draw

kModeDraw,

// Indicates the the functor is called only to perform

// processing and that no draw should be attempted

kModeProcess,

// Same as kModeProcess, however there is no GL context because it was

// lost or destroyed

kModeProcessNoContext,

// Invoked every time the UI thread pushes over a frame to the render thread

// *and the owning view has a dirty display list*. This is a signal to sync

// any data that needs to be shared between the UI thread and the render thread.

// During this time the UI thread is blocked.

kModeSync

};



/**

* Values used by OpenGL functors to tell the framework

* what to do next.

*/

enum Status {

// The functor is done

kStatusDone = 0x0,

// DisplayList actually issued GL drawing commands.

// This is used to signal the HardwareRenderer that the

// buffers should be flipped - otherwise, there were no

// changes to the buffer, so no need to flip. Some hardware

// has issues with stale buffer contents when no GL

// commands are issued.

kStatusDrew = 0x4

};

}; // struct DrawGlInfo



} // namespace uirenderer

} // namespace android

Functor 设计


operator()调用时传入的 what 参数为 Mode 枚举, 对于注入 GL 的场景只需处理 kModeDraw 即可,c++ 侧类设计如下:



// MyFunctor定义

namespace android {

class MyFunctor : Functor {

public:

MyFunctor();

virtual ~MyFunctor() {}

virtual void onExec(int what,

android::uirenderer::DrawGlInfo* info);

virtual std::string getFunctorName() = 0;

int operator()(int /*what*/, void * /*data*/) override;

private:


};


}


// MyFunctor实现

int MyFunctor::operator() (int what, void *data) {

if (what == android::uirenderer::DrawGlInfo::Mode::kModeDraw) {

auto info = (android::uirenderer::DrawGlInfo*)data;

onExec(what, info);

}

return android::uirenderer::DrawGlInfo::Status::kStatusDone;

}


void MyFunctor::onExec(int what, android::uirenderer::DrawGlInfo* info) {

// 渲染实现

}

因为 functor 是 Java 层调度的,而真正实现是在 c++ 的,因此需要设计 java 侧类并做 JNI 桥接:



// java MyFunctor定义

class MyFunctor {

private long nativeHandle;

public MyFunctor() {

nativeHandle = createNativeHandle();

}

public long getNativeHandle() {

return nativeHanlde;

}

private native long createNativeHandle();

}


// jni 方法:

extern "C" JNIEXPORT jlong JNICALL

Java_com_test_MyFunctor_createNativeHandle(JNIEnv *env, jobject thiz) {

auto p = new MyFunctor();

return (jlong)p;

}

在 View.onDraw () 中调度 functor


框架在 java Canvas 类上提供了 API,可以在 onDraw () 时将 functor 记录到 Canvas 的 DisplayList 中。不过由于版本迭代的原因 API 在各版本上稍有不同,经总结可采用如下代码调用,兼容各版本区别:



public class FunctorView extends View {

...

private static Method sDrawGLFunction;

private MyFunctor myFunctor = new MyFunctor();


@Override

public void onDraw(Canvas cvs) {

super.onDraw(cvs);

getDrawFunctorMethodIfNot();

invokeFunctor(cvs, myFunctor);

}


private void invokeFunctor(Canvas canvas, MyFunctor functor) {

if (functor.getNativeHandle() != 0 && sDrawGLFunction != null) {

try {

sDrawGLFunction.invoke(canvas, functor.getNativeHandle());

} catch (Throwable t) {

// log

}

}

}


public synchronized static Method getDrawFunctorMethodIfNot() {

if (sDrawGLFunction != null) {

return sDrawGLFunction;

}

hasReflect = true;



String className;

String methodName;

Class<?> paramClass = long.class;



try {

if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P) {

className = "android.graphics.RecordingCanvas";

methodName = "callDrawGLFunction2";

} else if (Build.VERSION.SDK_INT > Build.VERSION_CODES.LOLLIPOP_MR1) {

className = "android.view.DisplayListCanvas";

methodName = "callDrawGLFunction2";

} else if (Build.VERSION.SDK_INT == Build.VERSION_CODES.LOLLIPOP) {

className = "android.view.HardwareCanvas";

methodName = "callDrawGLFunction";

} else if (Build.VERSION.SDK_INT == Build.VERSION_CODES.LOLLIPOP_MR1) {

className = "android.view.HardwareCanvas";

methodName = "callDrawGLFunction2";

} else {

className = "android.view.HardwareCanvas";

methodName = "callDrawGLFunction";

paramClass = int.class;

}


Class<?> canvasClazz = Class.forName(className);

sDrawGLFunction = SystemApiReflector.getInstance().

getDeclaredMethod(SystemApiReflector.KEY_GL_FUNCTOR, canvasClazz,

methodName, paramClass);

} catch (Throwable t) {

// 异常

}



if (sDrawGLFunction != null) {

sDrawGLFunction.setAccessible(true);

} else {

// (异常)

}

return sDrawGLFunction;

}



}


注意上述代码反射系统内部 API,Android 10 之后做了 Hidden API 保护,直接反射会失败,此部分可网上搜索解决方案,此处不展开。


四. 实践中遇到的问题


GL 状态保存&恢复


Android RenderThread 在执行 drawFunctor 前会保存部分 GL 状态,如下源码:



// Android 9.0 code

// 保存状态

void RenderState::interruptForFunctorInvoke() {

mCaches->setProgram(nullptr);

mCaches->textureState().resetActiveTexture();

meshState().unbindMeshBuffer();

meshState().unbindIndicesBuffer();

meshState().resetVertexPointers();

meshState().disableTexCoordsVertexArray();

debugOverdraw(false, false);

// TODO: We need a way to know whether the functor is sRGB aware (b/32072673)

if (mCaches->extensions().hasLinearBlending() &&

mCaches->extensions().hasSRGBWriteControl()) {

glDisable(GL_FRAMEBUFFER_SRGB_EXT);

}

}


// 恢复状态

void RenderState::resumeFromFunctorInvoke() {

if (mCaches->extensions().hasLinearBlending() &&

mCaches->extensions().hasSRGBWriteControl()) {

glEnable(GL_FRAMEBUFFER_SRGB_EXT);

}

glViewport(0, 0, mViewportWidth, mViewportHeight);

glBindFramebuffer(GL_FRAMEBUFFER, mFramebuffer);

debugOverdraw(false, false);

glClearColor(0.0f, 0.0f, 0.0f, 0.0f);

scissor().invalidate();

blend().invalidate();

mCaches->textureState().activateTexture(0);

mCaches->textureState().resetBoundTextures();

}

可以看出并没有保存所有 GL 状态,可以增加保存和恢复所有其他 GL 状态的逻辑,也可以针对实际 functor 中改变的状态进行保存和恢复;特别注意 functor 执行时的 GL 状态是非初始状态,例如 stencil、blend 等都可能被系统 RenderThread 修改,因此很多状态需要重置到默认。


View变换处理


当承载 functor 的 View 外部套 ScrollView、ViewPager,或者 View 执行动画时,渲染结果异常或者不正确。例如水平滚动条中 View 使用 functor 渲染,内容不会随着滚动条移动调整位置。进一步研究源码 Android 发现,此类问题原因都是 Android 在渲染 View 时加入了变换,变换采用标准 4x4 变换列矩阵描述,其值可以从 DrawGlInfo::transform 字段中获取, 因此渲染时需要处理 transform,例如将 transform 作为模型变换矩阵传入 shader。


ContextLost


Android framework 在 trimMemory 时在 RenderThread 中会销毁当前 GL Context 并创建一个新 Context, 这样会导致 functor 的 program、shader、纹理等 GL 资源都不可用,再去渲染的话可能会导致闪退、渲染异常等问题,因此这种情况必须处理。


首先,需要响应 lowMemory 事件,可以通过监听 Application 的 trimMemory 回调实现:



activity.getApplicationContext().registerComponentCallbacks(

new ComponentCallbacks2() {

@Override

public void onTrimMemory(int level) {

if (level == 15) {

// 触发functor重建

}

}

@Override

public void onConfigurationChanged(Configuration newConfig) {

}

@Override

public void onLowMemory() {

}

});

然后,保存 & 恢复 functor 的 GL 资源和执行状态,例如 shader、program、fbo 等需要重新初始化,纹理、buffer、uniform 数据需要重新上传。注意由于无法事前知道 onTrimMemory 发生,上一帧内容是无法恢复的,当然知道完整的状态是可以重新渲染出来的。


鉴于存在无法提前感知的 ContextLost 情况,建议采用基于 commandbuffer 的模式来实现 functor 渲染逻辑。


五. 效果


我们用一个 OpenGL 渲染的简单 case (分辨率1080x1920),对使用 TextureView 渲染和使用 drawFunctor 渲染的方式进行了比较,结果如下:






















Simple Case内存CPU 占用
基于 TextureView100 M ( Graphics 38 M )6%
基于 GLFunctor84 M ( Graphics 26 M )4%

从上述结果可得出结论,使用 drawFunctor 方式在内存、CPU 占用上具有优势, 可应用于局部页面的互动渲染、视频渲染等场景。


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

Android Gradle 三方依赖管理

发展历史 Gradle 的依赖管理是一个从开始接触 Android 开发就一直伴随着我们的问题(作者是Android开发,仅以此为例),从最初的 没有统一管理 到 通过.gradle或gradle.properties管理,再到 Kotlin 出现之后使用 b...
继续阅读 »

发展历史


Gradle 的依赖管理是一个从开始接触 Android 开发就一直伴随着我们的问题(作者是Android开发,仅以此为例),从最初的 没有统一管理通过.gradle或gradle.properties管理,再到 Kotlin 出现之后使用 buildSrc 管理 以及在这基础上优化的 Composing BuildsGradle 依赖管理一直在不断的发展、更新,而到了 Gradle 7.0Gradle 本身又专门提供了全新的 Version Catalogs 用于依赖管理,今天我们就来说说这些方式的优劣及使用方式吧。


最原始的依赖


当我们通过 Android Studio 创建一个新项目,这个项目里面默认的依赖就是最原始的,没有经过统一管理的;如果你的项目中只有一个 module,那么这种默认的管理方式也是可以接受的,是否对它进行优化,这取决于你是否愿意投入成本去修改,谈不上什么优劣。


使用 .gradle 配置


当你的项目中 module 的数量超过一个甚至越来越多的时候,对 Gradle 依赖进行统一管理就变得重要起来,因为你不会想在升级一个三方依赖的版本后发现冲突,然后一个个打开各个 modulebuild.gradle 文件,找到你升级的那个依赖引用,重复的进行版本修改;


因此我们有了初步的优化方案:



  1. 在项目根目录下创建 config.gradle 文件,在其中按照以下格式添加相关配置;


ext {
android = [
compileSdkVersion: 30
]
dependencies = [
"androidx-core-ktx" : "androidx.core:core-ktx:1.3.2",
"androidx-appcompat": "androidx.appcompat:appcompat:1.2.0",
"google-material" : "com.google.android.material:material:1.3.0"
]
}


  1. 在项目根目录下的 build.gradle 文件顶部添加 apply from: "config.gradle"

  2. 在各个 modulebuild.gradle 中就可以通过 rootProject 来引用对应的依赖及参数了;


... 
android {
compileSdkVersion rootProject.ext.android.compileSdkVersion
}
...
dependencies {
implementation rootProject.ext.dependencies["androidx-core-ktx"]
implementation rootProject.ext.dependencies["androidx-appcompat"]
implementation rootProject.ext.dependencies["google-material"]
}
...

使用这种方式,我们就能够将项目中的版本配置、三方依赖统一管理起来了,但是这种方式还是有缺陷的,我们无法像正常代码中一样便捷的跳转到依赖定义的地方,也不能简单的找到定义的依赖在哪些地方被使用。


使用 gradle.properties 配置


这个方式和上面的方式类似,把依赖相关数据定义到 gradle.properties 文件中:


...
androidx-core-ktx = androidx.core:core-ktx:1.3.2
androidx-appcompat = androidx.appcompat:appcompat:1.2.0
androidx-material = com.google.android.material:material:1.3.0

在各个 modulebuild.gradle 中使用;


...
dependencies {
implementation "${androidx-core-ktx}"
implementation "${androidx-appcompat}"
implementation "${google-material}"
}

这种方式相对于 .gradle 方式不需要单独创建 config.gradle 文件,但是同样的也无法快速定位到定义的地方及快速跳转到依赖使用。


使用 buildSrc 配置


Kotlin 的支持下,我们又有了新的方案,这个方案依赖于 IDEA 会将 buildSrc 路径作为插件编译到项目以及 Kotlin dsl 的支持,并且解决上面两个方案依赖无法快速跳转问题;


使用方式如下:



  1. 在项目根目录新建文件夹 buildSrc,并在该路径下新建 build.gradle.kts 文件,该文件使用 Kotlin 语言配置


repositories {
google()
mavenCentral()
}

plugins {
// 使用 kotlin-dsl 插件
`kotlin-dsl`
}


  1. buildSrc 中添加源码路径 src/main/kotlin,并在源码路径下添加依赖配置 Dependencies.kt


object Dependencies {
const val ANDROIDX_CORE_KTX = "androidx.core:core-ktx:1.3.2"
const val ANDROIDX_APPCOMPAT = "androidx.appcompat:appcompat:1.2.0"
const val GOOGLE_MATERIAL = "com.google.android.material:material:1.3.0"
}


  1. 在各个 module 中的 build.gradle.kts 文件中使用依赖


...

dependencies {
implementation(Dependencies.ANDROIDX_CORE_KTX)
implementation(Dependencies.ANDROIDX_APPCOMPAT)
implementation(Dependencies.GOOGLE_MATERIAL)
}

这个方案的优点正如上面所说的,能够快速方便的定位到依赖的定义及使用,其确定就在于因为需要 Kotlin 支持,所以需要向项目中引入 Kotlin 的依赖,并且各个 modulebuild.gradle 配置文件需要转换为 build.gradle.kts 格式。


使用 Composing Builds 配置


Composing Builds 方案的本质和 buildSrc 方案是一样的,都是将对应 module 中的代码编译作为插件,在 build.gradle.kts 中可以直接引用,那为什么还要有 Composing Builds 这种方案呢?这是因为 buildSrc 方案中,如果 buildSrc 中的配置有修改,会导致整个项目都会进行重新构建,如果项目较小可能影响不大,但如果项目过大,那这个缺点显然是无法接受的,Composing Builds 方案应运而生。


使用方式:



  1. 在项目根目录创建 module 文件夹,名称随意,这里使用 plugin-version,并在文件夹中创建 build.gradle.kts 配置文件,内容如下:


plugins {
id("java-gradle-plugin")
id("org.jetbrains.kotlin.jvm") version "1.7.10"
}

repositories {
google()
mavenCentral()
gradlePluginPortal()
}

java {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}

dependencies {
// 添加Gradle相关的API,否则无法自定义Plugin和Task
implementation(gradleApi())
implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:1.7.10")
}

gradlePlugin {
plugins {
create("version") {
// 添加插件,下面是包名
id = "xx.xx.xx"
// 在源码路径创建类继承 Plugin<Project>
implementationClass = "xx.xx.xx.VersionPlugin"
}
}
}


  1. 创建源码目录及包路径 src/main/kotlin/xx.xx.xx,在包中新建类 VersionPlugin 继承 org.gradle.api.Plugin


class VersionPlugin : Plugin<Project> {
override fun apply(target: Project) {
}
}


  1. 在项目根目录下的 settings.gradle.kts 文件中添加 includeBuild("plugin-version")

  2. 最后和 buildSrc 方案一样,在源码路径下新增相关依赖配置,在各个 module 中引用即可。


Version Catalogs 配置


Gradle 7.0 开始,Gradle 新增了 Version Catalogs 功能,用于在项目之间共享依赖项版本, Gradle 文档中列出的一下优点:



  1. 对于每个 CatelogGradle 都会生成类型安全的访问器,可以轻松的在 IDE 中使用,完成添加依赖;

  2. 每个 Catelog 对生成的所有项目都可见,可以确保依赖版本同步到所有子项目;

  3. Catelog 可以声明依赖关系包,这些捆绑包是通常在一起使用的依赖关系组;

  4. Catelog 可以将依赖项的组、名称和实际版本分开,改用版本引用,从而可以在多个依赖项中共享版本声明。


接下来我们来学习这种方案的具体使用。


开始使用


使用 Version Catalogs 首先当然是需要项目 Gradle 版本高于 7.0,之后在项目根路径下的 settings.gradle.kts 中添加配置(因为作者项目用的是 .ktsgroovy 按对应语法添加即可)


dependencyResolutionManagement {
// 版本目录配置
versionCatalogs {
// 创建一个名称为 libs 的版本目录
create("libs") {
// 声明 groovy 依赖
library("groovy-core", "org.codehaus.groovy:groovy:3.0.5")
}
}
}

在上面的配置之后,你就可以在项目中使用对应依赖了。例:build.gradle.kts


dependencies {
implementation(libs.groovy.core)
}

这里有细心的小伙伴就会发现,我们声明的是 groovy-core,使用的时候却是 libs.groovy.core,这是因为 Version Catalogs 在根据别名生成依赖时对安全访问器的映射要求,别名必须由 ascii 字符组成,后跟数字,中间分隔只支持 短划线-下划线_点.,因此声明别名时可以使用groovy-coregroovy_coregroovy.core,最终生成的都是 libs.groovy.core


使用 settings.gradle.kts 配置


就如上面的示例中,我们就是在 settings.gradle.kts 中声明了 groovy-core 的依赖,并且需要的地方使用,接下来我们详细说明对依赖项声明的语法:


dependencyResolutionManagement {
// 版本目录配置
versionCatalogs {
// 创建一个名称为 libs 的版本目录
create("libs") {
// 声明 kotlin 版本
version("kotlin", "1.7.10")
// 声明 groovy 版本
version("groovy", "3.0.5")

// 声明 groovy 依赖
library("groovy-core", "org.codehaus.groovy:groovy:3.0.5")
// 声明 groovy 依赖
library("groovy-nio", "org.codehaus.groovy", "groovy-nio").version("3.05")
// 声明 groovy 依赖使用版本引用
library("groovy-json", "org.codehaus.groovy", "groovy-json").versionRef("groovy")

// 声明 groovy 依赖组
bundle("groovy", listOf("groovy-core", "groovy-json", "groovy-nio"))

// 声明 kotlin 序列化插件
plugin("kotlin-serialization", "org.jetbrains.kotlin.plugin.serialization").versionRef("kotlin")
}
}


这种方式相对统一了依赖版本,却无法做到多项目统一。


使用 libs.versions.toml 配置


还是先看示例代码:


dependencyResolutionManagement {
// 版本目录配置
versionCatalogs {
// 创建一个名称为 libs 的版本目录
create("libs") {
// 不能如此配置,会抛出异常
from(files("./gradle/libs.versions.toml"))
// 可以添加此配置
from(files("./gradle/my-libs.versions.toml"))
}
// 创建一个名称为 configLibs 的版本目录
create("configLibs") {
// 添加配置文件
from(files("./gradle/configLibs.versions.toml"))
}
}
}

在配置版本目录后,出了直接在 .kts 里面添加依赖定义,还可以通过 from 方法从 .toml 文件中加载,.toml 文件一般放在项目根路径下的 gradle 文件夹中。


这里需要注意的是,gradle 有一个默认配置名称为 libs,如果你创建的版本目录名称是 libs,那么你就无需通过 from 方法加载 libs.versions.toml 文件,因为 gradle 会默认此配置,你只需在 ./gradle 路径下创建 libs.versions.toml 文件即可,重复添加会导致编译失败;如果你已经有了一个 libs.versions.toml 你也可以在添加以下配置来修改默认配置名称:


dependencyResolutionManagement {
defaultLibrariesExtensionName.set("projectLibs")
}

如果你创建的版本目录名称不是默认配置名称,那么就需要你手动添加 from 方法加载配置;所有版本目录名称建议以 Libs 结尾,否则会有 warning,提示后续将不支持此命名。


接下来我们来看 .toml 文件的配置规则:


# 声明版本号
[versions]
kotlin = "1.7.10"
groovy = "3.0.5"

# 声明依赖
[libraries]
# groovy
groovy-core = "org.codehaus.groovy:groovy:3.0.5"
groovy-json = { module = "org.codehaus.groovy:groovy-json", version = "3.0.5" }
groovy-nio = { group = "org.codehaus.groovy", name = "groovy-nio", version.ref = "groovy" }

# 声明依赖组
[bundles]
groovy = ["groovy-core", "groovy-json", "groovy-nio"]

# 声明插件
[plugins]
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }

这种方式在统一单一项目依赖版本的同时,可以通过分享 .toml 文件来达成多项目依赖版本的统一,但是同样的,同样的文件在不同项目中不可避免是会被修改的,用着用着就不一致了。


使用插件配置


虽然从本地文件导入很方便,但是并不能解决多项目共享版本目录的问题,gradle 提供了新的解决方案,我们可以在一个独立的项目中配置好各个三方依赖,然后将其发布到 maven 等三方仓库中,各个项目再从 maven 仓库中统一获取依赖


插件配置


为了实现此功能,gradle 提供了 version-catalog 插件,再配合 maven-publish 插件,就能很方便的生产插件并发布到 maven 仓库。


新建 gradle 插件项目,修改 build.gradle.kts


plugins {
`maven-publish`
`version-catalog`
}

// 版本目录配置
catalog {
versionCatalog {
// 在这里配置各个三方依赖
from(files("./gradle/libs.versions.toml"))
version("groovy", "3.0.5")
library("groovy-json", "org.codehaus.groovy", "groovy-json").versionRef("groovy")
}
}

// 配置 publishing
publishing {
publications {
create<MavenPublication>("maven") {
from(components["versionCatalog"])
}
}
}

这里需要注意的是,插件项目的 gradle 版本必须要高于 7.0 并且低于使用该插件的项目的版本,否则将无法使用。


插件使用


配置从 maven 仓库加载版本目录


dependencyResolutionManagement {
// 版本目录配置
versionCatalogs {
// 创建一个名称为 libs 的版本目录
create("libs") {
// 从 maven 仓库获取依赖
from("io.github.wangjie0822:catalog:1.1.3")
}
}
}

重写版本


maven 仓库中获取版本目录一般来讲就不应该修改了,但是仅一份依赖清单怎么满足我们的开发需求呢,不说各个依赖库都在不断的持续更新,如果我们需要使用的依赖没有在版本目录里面声明呢?我们不可能为了修改一个依赖的版本或者添加一个依赖就频繁的发布Catalog插件版本,这样成本太高,这就需要我们进行个性化配置了


dependencyResolutionManagement {
// 版本目录配置
versionCatalogs {
// 创建一个名称为 libs 的版本目录
create("libs") {
// 从 maven 仓库获取依赖
from("io.github.wangjie0822:catalog:1.1.3")
// 添加仓库里面没有的依赖
library("tencent-mmkv", "com.tencent", "mmkv").version("1.2.14")
// 修改groovy版本
version("groovy", "3.0.6")
}
}
}

请注意,我们只能重写版本目录里面定义的版本号,所以在定义版本目录时尽量将所有版本号都是用版本引用控制。


使用方式


上面说了那么多的配置定义方式,下面来看看Version Catalogs的使用方式:


plugins {
// 可以直接使用定义的 version 版本号
kotlin("plugin.serialization") version libs.versions.kotlin
// 也可以直接使用定义的插件
alias(libs.plugin.kotlin.serialization)
}

android {
defaultConfig {

// 其它非依赖的字段可以在版本目录的版本中定义 通过 versions 获取
minSdk = configLibs.versions.minSdk.get().toInt()
targetSdk = configLibs.versions.targetSdk.get().toInt()

versionCode = configLibs.versions.versionCode.get().toInt()
versionName = configLibs.versions.versionName.get()
}
}

dependencies {
// 使用 groovy 依赖
implementation(libs.groovy.core)

// 使用包含 groovy-core groovy-json groovy-no 三个依赖的依赖组
implementation(libs.bundles.groovy)

// 使用 configLibs 中定义的依赖
implementation(configLibs.groovy.core)
}

上面我们已经说过这种方案的优点,可以让我们在所有项目中保持依赖版本的统一,甚至可以分享出去让其他开发者使用;同时也有着和 buildSrcComposing Builds一样的可跳转、可追溯的优点;


但是相比于这两个方案,Version Catalogs生成的代码只有默认的注释,并且无法直接看到使用的依赖的版本号,而在 buildSrcComposing Builds 中我们能够对依赖的功能进行详细的注释,甚至添加上对应的使用文档地址、Github 地址等,如果支持自定义注释,那这个功能就更完美了。


总结


Android 发展至今,各种新技术层出不穷,版本管理也出现了很多方案,这些方案并没有绝对的优劣,还是需要结合实际项目需求来选择的,但是新的方案还是需要学习了解的。


关于 Version Catalogs 插件项目,可以参照 WangJie0822/Catalog (github.com)


关于 Version Catalogs 的方案使用,可以参照 WangJie0822/Cashbook: 记账本 (github.com) 最新代码


如果想要了解 buildSrc 方案,可以参照 WangJie0822/Cashbook: 记账本 (github.com)


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

从 React 原理来看 ahooks 是怎么解决 React 的闭包问题的?

本文是深入浅出 ahooks 源码系列文章的第三篇,该系列已整理成文档-地址。觉得还不错,给个 star 支持一下哈,Thanks。本文来探索一下 ahooks 是怎么解决 React 的闭包问题的?。React 的闭包问题先来看一个例子:...
继续阅读 »

本文是深入浅出 ahooks 源码系列文章的第三篇,该系列已整理成文档-地址。觉得还不错,给个 star 支持一下哈,Thanks。

本文来探索一下 ahooks 是怎么解决 React 的闭包问题的?。

React 的闭包问题

先来看一个例子:

import React, { useState, useEffect } from "react";

export default () => {
const [count, setCount] = useState(0);

useEffect(() => {
setInterval(() => {
console.log("setInterval:", count);
}, 1000);
}, []);

return (
<div>
count: {count}
<br />
<button onClick={() => setCount((val) => val + 1)}>增加 1</button>
</div>
);
};

当我点击按钮的时候,发现 setInterval 中打印出来的值并没有发生变化,始终都是 0。这就是 React 的闭包问题。


产生的原因

为了维护 Function Component 的 state,React 用链表的方式来存储 Function Component 里面的 hooks,并为每一个 hooks 创建了一个对象。

type Hook = {
memoizedState: any,
baseState: any,
baseUpdate: Update<any, any> | null,
queue: UpdateQueue<any, any> | null,
next: Hook | null,
};

这个对象的 memoizedState 属性就是用来存储组件上一次更新后的 statenext 指向下一个 hook 对象。在组件更新的过程中,hooks 函数执行的顺序是不变的,就可以根据这个链表拿到当前 hooks 对应的 Hook 对象,函数式组件就是这样拥有了state的能力

同时制定了一系列的规则,比如不能将 hooks 写入到 if...else... 中。从而保证能够正确拿到相应 hook 的 state。

useEffect 接收了两个参数,一个回调函数和一个数组。数组里面就是 useEffect 的依赖,当为 [] 的时候,回调函数只会在组件第一次渲染的时候执行一次。如果有依赖其他项,react 会判断其依赖是否改变,如果改变了就会执行回调函数。

回到刚刚那个例子:

const [count, setCount] = useState(0);

useEffect(() => {
setInterval(() => {
console.log("setInterval:", count);
}, 1000);
}, []);

它第一次执行的时候,执行 useState,count 为 0。执行 useEffect,执行其回调中的逻辑,启动定时器,每隔 1s 输出 setInterval: 0

当我点击按钮使 count 增加 1 的时候,整个函数式组件重新渲染,这个时候前一个执行的链表已经存在了。useState 将 Hook 对象 上保存的状态置为 1, 那么此时 count 也为 1 了。但是执行 useEffect,其依赖项为空,不执行回调函数。但是之前的回调函数还是在的,它还是会每隔 1s 执行 console.log("setInterval:", count);,但这里的 count 是之前第一次执行时候的 count 值,因为在定时器的回调函数里面被引用了,形成了闭包一直被保存。

解决的方法

解决方法一:给 useEffect 设置依赖项,重新执行函数,设置新的定时器,拿到最新值。

// 解决方法一
useEffect(() => {
if (timer.current) {
clearInterval(timer.current);
}
timer.current = setInterval(() => {
console.log("setInterval:", count);
}, 1000);
}, [count]);

解决方法二:使用 useRef。
useRef 返回一个可变的 ref 对象,其 .current 属性被初始化为传入的参数(initialValue)。

useRef 创建的是一个普通 Javascript 对象,而且会在每次渲染时返回同一个 ref 对象,当我们变化它的 current 属性的时候,对象的引用都是同一个,所以定时器中能够读到最新的值。

const lastCount = useRef(count);

// 解决方法二
useEffect(() => {
setInterval(() => {
console.log("setInterval:", lastCount.current);
}, 1000);
}, []);

return (
<div>
count: {count}
<br />
<button
onClick={() => {
setCount((val) => val + 1);
// +1
lastCount.current += 1;
}}
>
增加 1
</button>
</div>
);

useRef => useLatest

终于回到我们 ahooks 主题,基于上述的第二种解决方案,useLatest 这个 hook 随之诞生。它返回当前最新值的 Hook,可以避免闭包问题。实现原理很简单,只有短短的十行代码,就是使用 useRef 包一层:

import { useRef } from 'react';
// 通过 useRef,保持每次获取到的都是最新的值
function useLatest<T>(value: T) {
const ref = useRef(value);
ref.current = value;

return ref;
}

export default useLatest;

useEvent => useMemoizedFn

React 中另一个场景,是基于 useCallback 的。

const [count, setCount] = useState(0);

const callbackFn = useCallback(() => {
console.log(`Current count is ${count}`);
}, []);

以上不管,我们的 count 的值变化成多少,执行 callbackFn 打印出来的 count 的值始终都是 0。这个是因为回调函数被 useCallback 缓存,形成闭包,从而形成闭包陷阱。

那我们怎么解决这个问题呢?官方提出了 useEvent。它解决的问题:如何同时保持函数引用不变与访问到最新状态。使用它之后,上面的例子就变成了。

const callbackFn = useEvent(() => {
console.log(`Current count is ${count}`);
});

在这里我们不细看这个特性,实际上,在 ahooks 中已经实现了类似的功能,那就是 useMemoizedFn。

useMemoizedFn 是持久化 function 的 Hook,理论上,可以使用 useMemoizedFn 完全代替 useCallback。使用 useMemoizedFn,可以省略第二个参数 deps,同时保证函数地址永远不会变化。以上的问题,通过以下的方式就能轻松解决:

const memoizedFn = useMemoizedFn(() => {
console.log(`Current count is ${count}`);
});

我们来看下它的源码,可以看到其还是通过 useRef 保持 function 引用地址不变,并且每次执行都可以拿到最新的 state 值。

function useMemoizedFn<T extends noop>(fn: T) {
// 通过 useRef 保持其引用地址不变,并且值能够保持值最新
const fnRef = useRef<T>(fn);
fnRef.current = useMemo(() => fn, [fn]);
// 通过 useRef 保持其引用地址不变,并且值能够保持值最新
const memoizedFn = useRef<PickFunction<T>>();
if (!memoizedFn.current) {
// 返回的持久化函数,调用该函数的时候,调用原始的函数
memoizedFn.current = function (this, ...args) {
return fnRef.current.apply(this, args);
};
}

return memoizedFn.current as T;
}

总结与思考

React 自从引入 hooks,虽然解决了 class 组件的一些弊端,比如逻辑复用需要通过高阶组件层层嵌套等。但是也引入了一些问题,比如闭包问题。

这个是 React 的 Function Component State 管理导致的,有时候会让开发者产生疑惑。开发者可以通过添加依赖或者使用 useRef 的方式进行避免。

ahooks 也意识到了这个问题,通过 useLatest 保证获取到最新的值和 useMemoizedFn 持久化 function 的方式,避免类似的闭包陷阱。

值得一提的是 useMemoizedFn 是 ahooks 输出函数的标准,所有的输出函数都使用 useMemoizedFn 包一层。另外输入函数都使用 useRef 做一次记录,以保证在任何地方都能访问到最新的函数。

原文:https://segmentfault.com/a/1190000042299974



收起阅读 »

雷军:穿越人生低谷的感悟

如何面对这些连续不断的巨大变化,不少人感到迷茫,甚至非常焦虑。这次我聊三个故事,第一个故事就是,我在非常年轻的时候就遭遇了“产品失败、业务崩盘、公司差点关门”的困境。DOS 时代 WPS 非常流行,几乎装在中国每台电脑上,金山如日中天。同时也有很大的隐忧,微软...
继续阅读 »

这三年来,全球发生了巨大的变化,已经深刻地影响了我们每个人的生活和工作。

如何面对这些连续不断的巨大变化,不少人感到迷茫,甚至非常焦虑。

今天的演讲,我们就不聊小米,聊点不一样的话题:我是如何穿越人生低谷,又从中获得了哪些重要的感悟

这次我聊三个故事,第一个故事就是,我在非常年轻的时候就遭遇了“产品失败、业务崩盘、公司差点关门”的困境。

故事要从三十年前说起。

DOS 时代 WPS 非常流行,几乎装在中国每台电脑上,金山如日中天。


同时也有很大的隐忧,微软已经带着 Windows 和 Office 进入了中国市场。我们要尽快研发出新的办公软件,才有机会正面和微软 Office 抗衡。

初生牛犊不怕虎,我们也没有细想,直接开干,还给项目取了一个气势磅礴的名字,叫“盘古”,我们希望盘古在 Windows 平台上开天辟地,能把 WPS 辉煌推到一个新的高度。


一个国内小公司,要抗衡微软,这个难度真的不是一般的大。为了这个宏伟的梦想,我们压上了几乎全部的家底,抽调了几乎所有的程序员,没日没夜,干了整整三年。到了 1995 年 4 月,盘古终于发布了。

闭关 3 年,只等大成的这一天,我们连庆功宴都提前精心准备好了。

01 生死关头


1.1 盘古受挫

但谁也没有想到,销量极为惨淡,不及预期的十分之一。

我的心态直接崩了,同事们的心情也从云端直接跌落到了谷底。

没有办法,我拼命给大家打鸡血,鼓励大家继续奋斗,就这样,我们坚持到 1995 年底 1996 年初,盘古依然没有任何起色。

更大的麻烦是,WPS 也卖不动了,收入锐减。

公司到了生死存亡的关头。

那年我 26 岁,担任北京金山的总经理,第一次面对这样的困局,有点束手无策。

每到发工资的那天,都是我最难熬的时候。我记得,最惨的时候,账上只有十几万,眼看着下个月就发不出工资。

同事们也都很绝望,不少人离开了,办公室开始有点空空荡荡的。那个时候我经常彻夜睡不着。我还记得,好多个晚上,我独自坐在沙发上,静静的看着窗外,眼睁睁地看着对面楼里的灯一盏一盏熄灭,再看着天色一点一点亮起来。这种痛苦,只有经历过的人,才真的明白。

1.2 站店的故事

金山这样的金字招牌,盘古这么好的产品,怎么可能卖不动呢?

我有点想不通,下定决心要“一杆子捅到底”,到第一线去把问题搞清楚。

我决定亲自去站店卖货。站店卖货,刚开始我信心十足。我特意选了当时中关村最大的一家软件店。虽然没有销售经验,但产品是我们自己做的,我相信我自己肯定没问题,一定可以搞定。


每个客户进店,我像见到亲人一样,直接迎上去,热情接待,细心讲解。有时候,一个客户,我能滔滔不绝的讲上半个小时。一遍又一遍,一整天讲了八个小时,讲得口干舌燥,也累得晕头转向。当晚回家,连饭都不想吃,直接躺在床上就睡着了。


我是一个程序员,每天都坐在电脑前写程序。第一次站店,就站了八个小时。辛苦的程度,大家想想就可以理解了。

如此辛苦的一天,业绩如何呢?

真的不好意思说,我一套都没有卖出去。

“一定是运气不好”,我给自己打气,毕竟是头一次干,没关系,第二天继续努力。

第二天,同样八个小时站下来,还是一套没有卖出去,我就有些懵了。

到了第三天,依然颗粒无收,尤其是看到其他店员成了一单又一单,我都有点怀疑自己了,真心觉得销售不简单。

第四天,我干脆就不卖货了。干什么呢?看看别人是如何卖货的。于是,我跟着店里最好的销售转了一整天,还真学了不少东西。

比如说,我一见到客户,就会滔滔不绝,而他,其实话并不多,先听听客户的想法,再顺着客户说。我懂技术,总是希望把技术讲明白,反而容易把问题讲复杂了,而他,总是拿着产品或者宣传页,三言两语,客户就明白了。让我最羡慕的是,他和客户交流轻松自如。对我来说,和陌生人交流,始终是个巨大的挑战。

第五天,我一边琢磨一边练,到了中午,终于开张了!

那一瞬间,我激动得就像赢得了整个世界一样。

我逐渐找到了跟客户沟通的感觉,自信心也开始恢复了。

第五天、第六天,我的业绩逐渐好起来。

第七天,我居然成为了店里的销售冠军,太不可思议了!

1.3 站店的收获

讲到这里,大家可能会觉得我在讲“一个促销员七天速成”的故事。

其实,这是“一个研发负责人在产品失败后,深入销售一线,站了七天店面”的故事。

站店,非常累,但收获巨大。

给大家举个例子。那个时候,电脑刚刚开始普及。站店那几天,每天总有几个客户,一进店就问,有没有电脑入门的软件。


我有点想不通,学电脑,买本书看不就行了,为什么还要买个软件教吗?我总是非常耐心劝他们,“确实没有这样的软件,你出门左转就是一家书店,买本书跟着学就好了”。

直到被问很多次以后,我终于恍然大悟,既然这么多人想买,做一个不就完了吗?说实话,和盘古一比,这类软件确实没啥技术含量,我们立刻开发了一个,就叫《电脑入门》,以最快的速度推向市场。结果大获成功,迅速登上了软件畅销排行榜。

这 7 天的站店卖货,我学会了:作为一个工程师,一定要做用户需要的产品,而不是做那些看起来只是高大上的产品。只要能做出用户想要的产品,销售就不是问题。有了这样的顿悟,再做产品就容易了。后来,我们陆续发布了《金山词霸》《金山毒霸》等十多款成功的软件。

回头再看盘古的问题,原因非常简单,就是因为“闭门造车”。

我非常庆幸,如果不是这次危机,我可能完全没有机会补上这么关键的一课。

这次站店经历,也成为了我日后职业生涯中最宝贵的财富。

站过店之后,再去逛店,感觉就完全不同。


我逛店就不仅仅在看商品,也在琢磨别人的店面,比如装修、动线、货架、堆头、海报等等,开始有点“内行看门道”的感觉了。从那以后,无论多忙,我一定会抽空到店里去转转。哪怕到国外出差,也会千方百计到当地最好的零售店去学习一下。我对零售行业有了一定的了解。

就这样,金山找到了活下去的路!

但我自己却陷入了新的迷茫,开始了我人生最灰暗的一段时间。

02 迷失彷徨


2.1 泡酒吧

那时候,我一直沉浸在盘古失败的情绪中,非常低落。

和微软抗衡,完全看不到赢的希望。如果只是做一家挣扎活着的公司,这绝对不是我的追求。

我彻底失去了理想,4 月底,直接交了辞职信。求伯君反复挽留我,最后决定,让我先歇半年再说。

面对这样的人生逆境,这里问大家一个问题:你们是如何解压的?

当时,我的方法就是经常和几个朋友去泡吧。那时,蹦迪特别风行,我们能疯狂蹦一晚上,都不怎么歇的。


也是这个阶段,我开始喜欢上了重金属摇滚。之前完全不能接受,但现在不一样了,沉浸在音乐中,能让自己暂时忘掉所有的烦恼。

那段时间经常泡吧,我还真的琢磨过,是不是干脆转行,在三里屯也开家酒吧。

2.2 BBS 论坛

很快,我就找到了更有趣的事,那就是 BBS。


我有了足够多的时间,整天泡在 CFIDO BBS 上。那时候,互联网还没普及,CFIDO 是爱好者自建的电话拨号连接的网络,聚集了国内一大群极客。上 BBS,可以自由自在地和远方朋友交流,这在当时,是件非常神奇的事情。这种神奇感,就特别像今天的元宇宙。

就这样,我找到了新的寄托。


那时候,在论坛发帖,要求还是蛮高的。大家都是拨号上网,上网费特别贵。如果你总是发水帖,就一定会被痛骂的。当时我有足够的时间认真准备每条帖子。

而且我有个特别强的优势,就是打字超快,多的时候一天就能写一百帖。其中最长的一个帖子,从晚上写到天亮,大约有上万字。直到今天,在网上还能搜到几篇我当年写的帖子。


就这样,我的发帖量,经常都排在全站飙信排行榜上的前几位。像我这样疯狂的人其实还不少,当时我在论坛中认识一个朋友,每天也是上百帖,他打字也很快,但他不会盲打,只会用两个手指打字,弹指如飞,号称“二指禅”,据说打坏了好几个键盘。

帖子写得多了,我就成了版主。大家别看版主官不大,事还不少,有点像今天的“群主”。首先自己要发帖,接着要拉一大帮朋友来玩,还要负责搞气氛。最难的是,大家因为观点不同经常吵架,解决网友各种纠纷就成了版主最重要的工作。

为了当好版主,我经常早上 7 点就开始上网,一直忙到凌晨,好像比上班还累。

不过,过得非常充实。有朋友认为我在逃避现实、不务正业,甚至有人为我惋惜,觉得我是在浪费生命。对于这种关心,我非常感谢,但并不是特别认可。

这半年,没有任何目的,非常纯粹的玩,玩得也特别开心。

就是在这样放松的氛围里,我的情绪也在一点点恢复。

2.3 意外收获

多年以后,我才明白了这段经历真正的价值。


2010 年我创办小米,第一步要先把论坛做好。我们非常顺利,不到一年就成了最火的手机发烧友论坛。怎么做到的?其实没啥,就是靠当年玩 BBS 当版主时学会的那几招。

很多人总觉得,看小说、上网、打游戏只会玩物丧志。

我个人的体会是,只要有一定的自制力,娱乐也是很好的学习方式。


不带任何目的,学起东西来,反而特别轻松,也特别快。

不过,玩也要玩出点名堂,不要瞎玩,瞎玩只是浪费时间。

当年在 BBS 上,我还认识了不少朋友,有的到今天,非常成功:

有一位,在一家叫润迅的寻呼机公司做程序员,叫马化腾;

还有一位,在宁波电信局做工程师,叫丁磊。

当年他们为什么有那么高的热情、那么多的时间泡论坛,我没有问过。

但我相信,这段经历,对他们后来的事业有着决定性的影响。

沉浸在论坛的这半年时间里,逐渐恢复了状态。1996 年 11 月,我结束了半年假期,正式回归了金山。


主要原因,还是放不下民族软件这份光荣与梦想。

我们下决心,要跟微软打一场“持久战”:一边在办公软件的正面战场和微软死磕,一边做各种应用软件和游戏,赚钱养家,以战养战

一家差点关门的小公司,经过二十多年的苦心经营,重新崛起了。

到今天,WPS 月活跃设备数超过了 5 亿。

我为 WPS 取得的成就感到特别自豪。

我们在网上找到了将近 20 年前的采访视频,大家一起看下。

03 错失互联网


3.1 环境变了

就在我埋头重整金山的时候,外面的世界已经天翻地覆:

大洋彼岸,网景 1995 年上市,雅虎 1996 年上市…… 互联网的第一波浪潮已经蓬勃发展。


很快,国内也开始了,1997 年网易创办,1998 年腾讯创办,中国互联网第一波浪潮汹涌而至。


而我忙着做软件,不知不觉,与这一波浪潮擦肩而过。

直到 1998 年 10 月,我才真正意识到这波浪潮已不可阻挡。我说服董事会,我们可以采用收购方式快速出击。

这些年来,网上有很多传言,有的说马化腾刚创业的时候要把 QQ 卖给我,我没买;还有的说马云刚创业的时候找我投资,我没投;这些传言整得我好像很没有眼光一样。这里,我要澄清一下,他们没有找过我,这些全是谣传。

我认真谈过的收购只有一家,就是网易。

那时网易成立一年多,公司大概五六个人,主要做个人主页托管服务,做得非常好,甚至还帮广州电信做了一套邮件系统。

我开出了 1000 万的价钱,这在当时,真的算是一个不小的数字了,但丁磊很快拒绝了。

仅仅 2 个月后,网易就融到了 1000 万美元,估值达到了 6000 多万美元,按当时的美元汇率,大约 5 亿元。

真是一个疯狂的时代,我看不懂,但大受震撼。


3.2 卓越网

收购不成,我们只能自己干。试了差不多一年,对互联网业务有了一定的认知。

当时,我以为自己完全搞明白了,“互联网就是工具。未来所有的公司都会用的,电商最有前途。”


做电商,说干就干。

2000 年 5 月,卓越网正式上线,在网上主卖图书和音像制品。


这次我信心爆棚,志在必得,力主自己掏钱干。就在这时,全球互联网泡沫破灭,进入了资本市场的寒冬。这样局面也没有动摇我们的决心,第一笔大约 2000 万的投入,全部来自于金山股东。

电商本质还是零售,我把当年站店的经验全部用上了,选品策略、定价模式、店面布置等,我们迅速用到电商上。

同时,我们在管理上也下了功夫。电商的核心就是运营,需要非常认真把每个细节做好。

每天早上一上班,我先把首页所有的 banner 广告和商品链接都检查一遍。我总是能发现一些链接错误的地方。这让我想起了,我从软件工程中学到的最重要的道理:“可能出错的地方,一定会出错”。


每天晚上,我们坚持做当天业务的复盘和总结,做完后才下班。


每个星期,我在卓越网上都要买上好多单,来测试我们的选品和物流。于是,我书架上,堆满了各种图书、电影电视剧、音乐 CD,很多连包装都没拆。那段时间,同事们特别喜欢到我办公室开会,离开的时候,到书架上带走几本。

卓越网还做了很多敢为人先的创新,开创了最早的多地仓储、最早的自建物流等,还有,北上广深 4 小时送达服务,很难想象,这些在 20 年前就已经实现了。

我们从零起步,两三年时间,就成为当时最大的 B2C 电商。

现在还有不少朋友记得,他们在卓越网上买过《大话西游》、《小王子》、《东京爱情故事》等,这些就是中国电商第一代爆品。

3.3 融资

卓越的业务非常顺利,困难只有一个:钱不够。电商平台实在太烧钱了。


全球互联网泡沫破灭后,资本市场低迷,融资非常困难。那时的 VC,基本都是外资。像我这样没有留过洋,英文又不好的本土创业者,找钱就更难了。我们至少谈了 30 家,把所有能找的 VC 基本都找了一个遍,没有一家愿意投钱。

后来,实在没有办法,才从朋友那里融了 100 万美元,金山股东不得不又追加了 100 万美元,才勉强完成融资。但仅仅一年时间,又烧光了。

卓越网创业历程,就是我们拼命找钱的过程,相当煎熬。

到了 2004 年 9 月,实在熬不下去了,我们只能忍痛把卓越网卖给了亚马逊,成了亚马逊中国。


谁也没想到,仅仅半年多之后,新一轮互联网热潮又来了,B2C 电商迎来了全面崛起的盛世。

卓越网,创办于互联网泡沫破裂之后,倒下于电商全面崛起之前。


这真的是一段刻骨铭心的经历。

3.4 痛定思痛

很多人问过我,卖掉卓越,你后不后悔?我咬着牙说,不后悔。

卖掉卓越网,对我来说,是一次重创,内心无比痛苦。

首先情感上,就像失去了自己的孩子一样,那段时间,我努力切割和卓越网的联系,强迫自己不再去卓越网办公室,不再到卓越网买东西,也尽量不见老同事了。

更痛苦的是,作为一个创业者,错失了整个互联网时代。

在那段痛苦的煎熬里,反复问自己,我们到底输在哪里?好像运气特别差。

首先,“卓越网创办于互联网泡沫破灭之后”,一上来,我们就输在起跑线上了,错过了最好的时机点。

接着,“卓越网倒下于电商全面崛起之前”,我们融资能力不足,没有资金也没有信心坚持到胜利到来的那一刻。

创业,确实需要运气,需要对大势的精准把握。这也是后来我特别强调“风口”的原因。

过了大半年时间,我才慢慢缓过来,彻底想明白了一些东西。

“互联网不仅仅是一次技术革命,更是一次观念的革命;未来互联网将融合到各行各业。”


我想通的道理,本质上就是后来大家谈的“互联网思维”和“互联网 +”。

有了这样的思考,我看很多问题的思路发生了巨大改变,我用全新的视角重新扫描了产业里的一些新机会。比如,2005 年,市场上一直传言 3G 网络即将开通。我隐约看到了一个大机会。

04 移动互联网


4.1 下一个机会

经过深入研究后,我越来越坚定:我们即将迎来移动互联网时代,而且,未来十年都将是移动互联网的十年,规模会是 PC 互联网的十倍。

想明白这些事情后,我做了一个决定:立刻行动。


我主要做了两件事:


第一、我要成为最早一批真正的用户。

要真正了解移动互联网,只有一个办法,就是自己亲自用。这是一个“笨”办法,但也是最好的办法。

十多年前,市场上主要是功能手机,无线网络是 2G。

这么小的屏幕,慢到无法忍受的网速,用这样的手机上网,是不是想想都很崩溃?


有电脑的人,绝对不愿意用手机上网。但不管多难用,我下定决心,从现在开始,所有事情都要在手机上完成,尽量不用电脑。

在这个痛苦的过程中,更深理解了:哪里有用户的痛点,哪里就有创业者的机会。


后来我做移动互联网的各种产品思路,就是这样来的。

第二、我要成为这波浪潮最早的参与者。

最快的办法,就是用天使投资的方式投资一批初创公司。几个月后,我就投资了第一家,后来陆续投了十多家。

其中,最出名的就是 UC 浏览器。


在这个过程中,我越发确信:一个全新的大时代,真的来了。


4.2 离开金山

2007 年,金山终于上市了。


接着,我离开了这家我奋斗了 16 年的公司,开始了不一样的人生。

接下来的三年里,我一边做天使投资,一边对过去经历进行系统性反思和总结。

这一系列的反思,为下一段旅程做了充分的准备。

4.3 永远相信美好的事情即将发生

今天跟大家分享的是三个我经历人生低谷的故事。


面对这些挫折、失败,我也迷茫过、动摇过、甚至放弃过。

如果没有这些挫折,没有这些挫折带来的积累,就不会有今天的我。

没有任何人会喜欢挫折、失败,但每个人不可避免一定会经历,甚至,不少人现在正在经历。

既然这些痛苦难以回避,那我们能做的,就是直面这些痛苦,在痛苦中坚持前行,让痛苦来塑造更好的我们,这就是痛苦的意义、挫折的馈赠。

而你所经历的所有挫折、失败,甚至那些看似毫无意义消磨时间的事情,都将成为你最宝贵的财富。


人生很长,无论如何,让我们保持信念:永远相信美好的事情即将发生。


05 雷军首部商业思考著作《小米创业思考》发布

今天提到的站店、泡论坛和卓越网这三段经历,都为小米的创办做了至关重要的准备。2010 年 4 月 6 号,在北京中关村一间不起眼的办公室里,我和十多个同事一起喝了碗小米粥,就创办了小米。


一转眼,小米已经 12 岁了。


小米十周年时,我们内部进行过长达半年的深入复盘总结,收获特别大。当时我就萌发了一个想法,要把这些思考和总结写下来,让更多小米同学们了解,并分享给所有关心小米的朋友们。

但这件事情比我想象得要复杂,我们下了很大功夫,花了两年时间,忙到今天才出版,这就是《小米创业思考》。


这本书包含了小米创办前后我大量的思考,以及小米创业历程、小米方法论和一些实战案例。刚在各个电商平台上架,大家如果感兴趣,现在就可以买来看看。

大家还记得小米三大铁律吗?“技术为本,性价比为纲,做最酷的产品”。


我们始终极度重视技术投入,去年我们的研发投入 132 亿元,今年预计会达到 170 亿元。


未来五年研发投入将超过 1000 亿。


正是永不止步的创新精神和不断增加的研发投入,确保了我们能一直不断研发出最酷的产品。

作者:雷军(2022年度演讲)

收起阅读 »

ios - 真机无法运行

iOS
iOS 开发小记8.10日遇见问题新接手的苹果账号无法真机运行,查询一番以为是证书的问题。登录到苹果的官网发现手机有个7天无效的问题。最终解决的方式是换了个手机 添加到真机运行中就可以了但是无法运行的手机,估计是需要等到七天之后查看结果。七天之后应该是有所变化...
继续阅读 »

iOS 开发小记

8.10日遇见问题新接手的苹果账号无法真机运行,查询一番以为是证书的问题。登录到苹果的官网发现手机有个7天无效的问题。

最终解决的方式是换了个手机 添加到真机运行中就可以了

但是无法运行的手机,估计是需要等到七天之后查看结果。七天之后应该是有所变化

产生这个问题的原因:有大佬解答是因为 苹果账号被封过。导致手机的UUID被标记。换到其他的苹果账号会有这种情况的发生

(我遇到过的是被封过开发者账号的,一个开发者账号被封,里面的测试机也会被苹果标记,再换其他的开发者账号,就可以关联到一起了)

收起阅读 »

线程池及使用场景说明

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第22天,点击查看活动详情newFixedThreadPool(固定大小的线程池):public static ExecutorService newFixedThreadPool(int n...
继续阅读 »

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第22天,点击查看活动详情

newFixedThreadPool(固定大小的线程池):

public static ExecutorService newFixedThreadPool(int nThreads) {
return new ThreadPoolExecutor(nThreads, nThreads,
0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>());
}

该线程池特点:

1.核心线程数和最大线程数大小一样

2.keepAliveTime为0

3.阻塞队列使用的是LinkedBlockingQuene(无界队列)

该线程池工作机制:

1.线程数少于核心线程数,新建线程执行任务

2.线程数等于核心线程数时,将任务加到阻塞队列(最大值为Integer.MAX_VALUE),可以一直加加加(可能会出现OOM)

newSingleThreadExecutor(单线程线程池)

 
public static ExecutorService newSingleThreadExecutor() {
return new FinalizableDelegatedExecutorService (new ThreadPoolExecutor(1, 1, 0L,
TimeUnit.MILLISECONDS, ew LinkedBlockingQueue<Runnable>()));
}

该线程池特点:

1.核心线程数和最大线程数大小一样且都是1

2.keepAliveTime为0

3.阻塞队列是LinkedBlockingQuene

该线程池工作机制:

1.线程中没有线程时,新建线程执行任务

2有一个线程以后,将任务加到阻塞队列(最大值为Integer.MAX_VALUE),可以一直加加加


#### 该线程池特点:

1.核心线程数为0,且最大线程数为Integer.MAX_VALUE

2.阻塞队列是SynchronousQuene(同步队列)

SynchronousQuene:一个不存储元素的阻塞队列。每个插入操作必须等到另一个线程调用移除操作,否则插入操作一直处于阻塞状态,吞吐量要高于LInkedBlockQuene。

锁当提交任务的速度大于处理任务的速度时,每次提交一个任务,就必然会创建一个线程。极端的情况下会创建过多的线程,耗尽CPU和内存资源。由于空闲60秒的线程会被终止,长时间保持空闲的CachedThreadPool不会占用任何资源。

#### 该线程池工作机制:

1.没有核心线程时,直接向SynchronousQuene中提交任务

2.执行完任务的线程有60秒处理时间

newScheduledThreadPoo

该线程池特点:

1.最大线程数为Integer.MAX_VALUE

2.阻塞队列是DelayedWorkQuene(延迟队列)

DelayedWorkQuene中封装了一个优先级队列,这个队列会对队列中的ScheduleFutureTask进行排序,两个任务的执行Time不同时,time小的先执行; 否则比较添加队列中的ScheduledFutureTask的顺序号sequenceNumber,先提交的先执行。

API

ScheduledThreadPoolExecutor添加任务提供了另外两个方法:

1.scheduleAtFixedRate():按某种速率周期执行

2.scheduleWithFixedDelay():在某个延迟后执行

两种方法的内部实现都是创建了一个ScheduledFutureTask对象封装了任务的延迟执行时间及执行周期,并调用decorateTask()方法转成RunnableScheduledFuture对象,然后添加到队列中。

该线程池工作机制:

1.调用上面两个方法添加一个任务

2.线程池中的线程从DelayQuene中取任务

3.然后执行任务


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

收起阅读 »

为什么要使用Kotlin 对比 Java,Kotlin简介

什么是Kotlin 打开Kotlin编程语言的官网,里面大大的写着, A modern programming languagethat makes developers happier. 是一门让程序员写代码时更有幸福感的现代语言 Kotlin语法...
继续阅读 »

什么是Kotlin


打开Kotlin编程语言的官网,里面大大的写着,



A modern programming languagethat makes developers happier.




是一门让程序员写代码时更有幸福感的现代语言




  • Kotlin语法糖非常多,可以写出更为简洁的代码,便于阅读。

  • Kotlin提供了空安全的支持,可以的让程序更为稳定。

  • Kotlin提供了协程支持,让异步任务处理起来更为方便。

  • Google:Kotlin-first,优先支持kotlin,使用kotlin可以使用更多轮子


接下来对比Java举一些例子。


简洁


当定义一个网络请求的数据类时


Java


public class JPerson {
private String name;
private int age;
//getter
//setter
//hashcode
//copy
//equals
//toString
}

Kotlin


data class KPerson(val name: String,val age: Int)

这里用的是Kotlin 的data class 在class 前面加上data 修饰后,kotlin会自动为我们生成上述Java类注释掉的部分


当我们想从List中筛掉某些我们不想要的元素时


Java


List<Integer> list = new ArrayList<>();  

List<Integer> result = new ArrayList<>();
for (Integer integer : list) {
if (integer > 0) { //只要值>0的
result.add(integer);
}
}

System.out.println(result);

Kotlin


val list: List<Int> = ArrayList()

println(list.filter { it > 0 })

如上代码,都能达到筛选List中 值>0 的元素的效果。


这里的filter是Kotlin提供的一个拓展函数,拓展函数顾名思义就是拓展原来类中没有的函数,当然我们也可以自定义自己的拓展函数。


当我们想写一个单例类时


Java


public class PersonInJava {
public static String name = "Jayce";
public static int age = 10;

private PersonInJava() {
}
private static PersonInJava instance;
static {
instance = new PersonInJava();
}
public static PersonInJava getInstance() {
return instance;
}
}

Kotlin


object PersonInKotlin {
val name: String = "Jayce"
val age: Int = 10
}

是的,只需要把class换成object就可以了,两者的效果一样。


还有很多很多,就不一一举例了,接下来看看空安全。


安全


空安全


var name: String = "Jayce" //name的定义是一个非空的String
name = null //将name赋值为null,IDE会报错,编译不能通过,因为name是非空的String

var name: String? = "Jayce" //String后面接"?"说明是一个可空的String
name.length //直接使用会报错,需要提前判空
//(当然,Kotlin为我们提供了很多语法糖,我们可以很方便的进行判空)

类型转换安全


fun gotoSleep(obj: Any) {
if (obj is PersonInKotlin) {//判断obj是不是PersonInKotlin
obj.sleep() // 在if的obj已经被认为是PersonInKotlin类型,所以可以直接调用他的函数,调用前不需要类型转换
}
}

携程


这里只是简单的举个例子


Kotlin的协程不是传统意义上那个可以提高并发性能的协程序


官方的对其定义是这样的



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

  • 程序的逻辑可以在协程中顺序地表达,而底层库会为我们解决其异步性。


当我们用Java请求网络数据时,一般是这么写的。


 getPerson(new Callback<Person>() {//这里有一个回调            
@Override
public void success(Person person) {
runOnUiThread(new Runnable() { //切换线程
@Override
public void run() {
updateUi(person)
}
})
}

@Override
public void failure(Exception e) {
...
}
});

Kotlin协程后我们只需要这么写


 CoroutineScope(Dispatchers.Main).launch { //启动一个协程
val person = withContext(Dispatchers.IO) {//切换IO线程
getPerson() //请求网络
}
updateUi(person)//主线程更新UI
}

他们两个都干的同一件事,最明显的区别就是,代码更为简洁了,如果在回调里面套回调的话回更加明显,用Java的传统写法就会造成人们所说的CallBack Hell。


除此之外协程还有如下优点



  • 轻量

  • 更少的内存泄漏

  • 内置取消操作

  • 集成了Jatpack


这里就不继续深入了,有兴趣的同学可以参考其他文章。


Kotlin-first


在Google I/O 2019的时候,谷歌已经宣布Kotlin-first,建议Android开发将Kotlin作为第一开发语言。


为什么呢,总结就是因为Kotlin简洁、安全、兼容Java、还有协程。


至于有没有其他原因,我也不知道。(手动狗头)


Google将为更多的投入到Kotlin中来,比如




  • 为Kotlin提供特定的APIs (KTX, 携程, 等)




  • 提供Kotlin的线上练习




  • 示例代码优先支持Kotlin




  • Jetpack Compose,这个是用Kotlin开发的,没得选。。。。。




  • 跨平台开发,用Kotlin实现跨平台开发。






好的Kotlin就先介绍到这里,感兴趣的同学就快学起来吧~
接下来在其他文章会对Kotlin和携程进行详细的介绍。


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

从权限系统的菜单管理看算法和数据结构

菜单管理,感觉上是个小模块,但实际做下来的感触是,要做的好用,不容易。算法和数据结构,长期活跃在面试题中,实际业务中好像接触的不多,但如果能用好,可以解决大问题。如上图,是我在开源世界找到的一个菜单管理的设计页面,其上可以看到,菜单管理主要管理一颗菜单树,可以...
继续阅读 »

菜单管理,感觉上是个小模块,但实际做下来的感触是,要做的好用,不容易。
算法和数据结构,长期活跃在面试题中,实际业务中好像接触的不多,但如果能用好,可以解决大问题。

cccc.jpeg

如上图,是我在开源世界找到的一个菜单管理的设计页面,其上可以看到,菜单管理主要管理一颗菜单树,可以增删改查、排序等功能。接下来,我们就一步一步的实现菜单管理的各个功能,这里我主要介绍后端的设计方案,不涉及前端页面的处理和展示。


type IMenu interface {
MenuList(ctx context.Context, platformId int, menuName string, status int) ([]*model.MenuListResp, response.Error)
GetMenuInfo(ctx context.Context, menuId int) (*model.MenuInfo, response.Error)
EditMenu(ctx context.Context, params *model.MenuEditParams) response.Error
DeleteMenu(ctx context.Context, platformId, menuId int) response.Error
AddMenu(ctx context.Context, params *model.MenuAddParams) response.Error

MenuExport(ctx context.Context, platformId int) ([]byte, response.Error)
MenuImport(ctx context.Context,platformId int,jsonData []byte) response.Error

MenuSort(ctx context.Context, platformId int, tree []*model.MenuTree) response.Error
}

可以看到,菜单管理模块主要分三块,一是对菜单的增删改查、二是菜单的导入导出、三是菜单的排序。 由于菜单模块天然的就是一个树状结构,所以我就想到能不能在代码中用树作为处理菜单时的数据结构,把这个模块做完后,我发现这种思路是对的,可以解决菜单树的重排问题,无需在手动填写排序字段(就是人工为菜单的顺序赋值)。

首先我们设计数据库,由于我们确认了以树处理菜单的思路,所以在表设计时,我把菜单信息和菜单树剥离,首先避免了字段过多,其次在用menu_id去获取单个菜单信息时,就完全不用遍历树了,直接从菜单信息表中拿数据,而且表分开后,代码逻辑也更清晰了。

截屏2022-08-05 22.35.37.png

介绍一下表中的关键字段,其它字段可忽略:

表名字段名说明
menu_treeplatform_id这颗菜单树属于哪个业务系统
menu_treeparent_menu_id节点的父节点id,没有父则为0
menu_treemenu_id节点自己的id,就是 menu_info 表的主键
menu_treeindex排序字段
menu_infoalias菜单唯一标识

一、 添加菜单

添加菜单接口所需的信息如下:

type MenuAddParams struct {
PlatformId int `json:"platform_id" v:"required"` // 平台ID
Type int64 `json:"type" v:"required|in:1,2,3,4,5"` // 菜单类型 1菜单2子菜单3按钮4页签5数据标签
ParentMenuId int `json:"parent_menu_id"` // 上级菜单,没有则传0

// 菜单信息
Name string `json:"name" v:"required|length:3,20"` // 菜单名
...
}

添加菜单很简单,由于前端参数中已经存在父菜单id,我们只需要把这个菜单节点加到这个父菜单的子节点列表的末尾即可。

这里唯一需要注意的就是,我们需要 select MAX(index) 获取父节点的子节点列表中index最大的值,然后+1作为新节点的index,这样就可以把新节点添加到列表末尾。但要注意并发问题,比如两个人同时添加菜单,有可能会导致index一样。

解决方案就是把newIndex放到redis里,然后在添加时判断redis里的index是否>=数据库的maxIndex,理论上一定成立,不成立则说明newIndex还未写进db,流程图如下:

使用分布式锁,或者修改select MAX(index)的事务隔离级别也可以实现。

不存在
存在
Y
N
获取数据库的MAX_INDEX
获取Redis存的index
Redis index是否存在
newIndex = MAX_INDEX+1
newIndex 存Redis
newIndex作为新节点的index入库
redisIndex < MAX_INDEX
并发错误停止添加节点
删除redisIndex
END

二、菜单列表

菜单列表与其说是列表,不如说是菜单树,树状返回整个菜单结构,不分页,两个原因:一、我们要在菜单列表上做拖曳排序,需要整个菜单树结构;二、菜单数据有它本身的特点,再多也就千级别顶天了,而且我们在数据存储时就区分了菜单树表(menu_tree)和(menu_info),这时获取菜单列表只需要访问 menu_tree,菜单信息可以懒加载,需要时再通过 menu_id 主键id获取,速度很快。

接口返回结构设计如下:

{
"data": {
"children": [
{
"children": [
{
"children": [],
"menu_id": 2,
"name": "子菜单1",
"type": 1
}
],
"menu_id": 1,
"name": "菜单1",
"type": 1
}
],
"menu_id": 0,
"name": "",
"type": 0
},
"errmsg": "",
"errno": 0
}

我们用一个 menu_id 为0 的节点作为根节点,因为树需要一个唯一的根。
这里有人可能会说,不对啊,你 menu_tree 表里没有name和type,你不还是得去menu_info里获取吗?确实,实践中我确实有访问menu_info,但我感觉这两个字段其实可以冗余存储到 menu_tree中,这样优化后,就真的不用访问 menu_info了,只不过我模块已经写完了,就懒得改了,哈哈。

这个数据返给前端,他直接渲染成树即可,点击树中某个节点,前端拿到这个节点的menu_id,去GetMenuInfo获取菜单信息,前面说过,这个获取很简单,就是主键id查找。

要建立这样一个返回结构,用如下关键代码实现即可:

type MenuTree struct {
MenuId int `json:"menu_id"` // 菜单ID
Name string `json:"name"` // 菜单名
Type int64 `json:"type"` // 菜单类型 1菜单2子菜单3按钮4页签5数据标签

Children []*MenuTree `json:"children"` // 子菜单
}

func (s *sMenu) MenuTree(ctx context.Context, platformId int) (*model.MenuTree, error) {
// 构造一个根节点
root := &model.MenuTree{}

err := s.menuTreeChildren(ctx, root)
if err != nil{
return nil, err
}
return root, nil
}

func (s *sMenu) menuTreeChildren(ctx context.Context, node *model.MenuTree) (error) {
if node.MenuId != 0{
menuInfo, err := dao.MenuInfo.GetMenuInfo(
ctx,
node.MenuId,
dao.MenuInfo.Columns().Id,
dao.MenuInfo.Columns().Name,
dao.MenuInfo.Columns().Type,
)
if err != nil {
return gerror.Wrap(err, "")
}
node.Name = menuInfo.Name
node.Type = menuInfo.Type
}

// 获取子节点列表
// select * from menu_tree where platform_id = ? and parent_menu_id = ?
childs, err := dao.MenuTree.GetMenuTreeChild(ctx, platformId, tree.MenuId)
if err != nil {
return gerror.Wrap(err, "")
}

treeChilds := make([]*model.MenuTree, len(childs))
for i, e := range childs {
tree := &model.MenuTree{
MenuId: e.Id,
}
err := s.menuTreeChildren(ctx, platformId, tree)
if err != nil{
return err
}
treeChilds[i] = tree
}

node.Children = treeChilds
return nil
}

这个构造过程有没有一点点熟悉?这不就是树的dfs前序遍历算法的递归版本吗?

通过这种方式,我们把数据库中的树加载到内存中,后续的很多操作,都依赖对树的遍历实现。

三、编辑菜单、获取菜单信息

这两个操作都是只针对 menu_info 表的,用menu_id主键操作就行,没什么技术含量。

四、删除菜单

删除怎么实现呢?前面说过,后续的很多操作都是在对树进行遍历,那么删除也就很简单了,我们通过menuId确定要删除的节点,并遍历它的所有子节点,放到 treeIds 这个收集器中,当子节点遍历完毕时,把treeIds中收集的id取出来,去删除这条记录即可。

// menuIds 负责在遍历过程中收集所有的 menus_ids,treeIds 负责在遍历过程中收集 menu_tree表的主键
func (s *sMenu) deleteMenuTree(ctx context.Context, platformId, menuId int, menuIds []int, treeIds []int) ([]int, []int, error) {
// 把menuId从菜单树中删除,并递归删除它的所有子菜单
child, err := dao.MenuTree.GetMenuTreeChild(ctx, platformId, menuId)
if err != nil {
return menuIds, treeIds, err
}
menuIds = append(menuIds, menuId)
menuTree, err := dao.MenuTree.GetMenuTreeByMenuId(ctx, platformId, menuId, dao.MenuTree.Columns().Id)
if err != nil {
return menuIds, treeIds, err
}
treeIds = append(treeIds, int(menuTree.Id))

for _, e := range child {
menuIds, treeIds, err = s.deleteMenuTree(ctx, platformId, int(e.MenuId), menuIds, treeIds)
if err != nil {
return menuIds, treeIds, err
}
}
return menuIds, treeIds, nil
}

后续的删除代码就不再展示了,主键都收集完毕了,DELETE FROM ... WHERE id IN () 一条语句搞定即可。

五、菜单排序

来到本文的重点,菜单重排序,很多系统都是直接让用户填排序字段,非常容易出错,用过的人都知道,不太好用。我们直接来实现拖曳排序,而且可以任意拖曳,把节点拖到其它父节点,把节点拖到顶级菜单等,都可以实现。

首先前端同学需要实现拖曳组件,然后直接把拖曳后的整个菜单树回给我们即可,我们负责检查树的节点发生了什么变动。

前端同学也可以直接检测被拖曳的菜单id,和拖曳后的位置,把这些信息发给后端即可。不过为了让前端同学早点下班,这些活还是我们交给我们来干把。

我们现在拿到了前端发回的树结构,字段和数据跟我们通过菜单列表返回的json数据一致,只是其中有一个被拖曳的节点,不再原来的位置,它可能跟兄弟节点交换了顺序,可能变更了父节点,也可能变为顶级菜单。如何找到被拖曳的是哪个节点,并找到它拖曳后的位置和顺序呢?

在算法知识中,有一个很重要的思想,就是分治,当我们碰到比较复杂的问题时候,就一定要把它拆解为几个子问题解决,针对这个场景,我们可以拆分为以下几个问题:

  • 如果被拖曳的节点变更了父节点,我们如何找到它的位置和顺序?
    我们先序dfs遍历这个新的菜单树,每个节点都去数据库查询它的parent_menu_id,如果发现数据库的父节点id跟新菜单树的父节点id对不上,则可以断定这个节点是被拖曳过来的,同时也就知道了它的新位置和顺序。
  • 如果被拖曳的节点未变更父节点,只是变更了顺序,我们如何找到它?
    我们后序dfs遍历这个新菜单树,收集当前节点的子节点列表list1,并且从数据库中拉出当前节点的子节点列表list2,
    1. 如果 list1.len < list2.len
      说明这个节点有子节点被拖曳走了,我们不需要管这个节点,因为我们不关注被拖曳节点原先在哪儿。这个情况直接忽略即可
    2. 如果 list1.len > list2.len 说明有节点被拖曳进来,那我们遍历list1和list2找不同即可。
    3. 如果 list1.len == list2.len 也是遍历list1和list2,看看它们两是不是完全一样。
  • 找到被拖曳的节点以及它的新位置和顺序后,如何更新到数据库?
    更新这个节点的父节点id,并要判断它的顺序
    1. 它的新顺序就在父节点的子节点列表末尾
      子节点列表(有序的)最后一个节点的index + 1即可
    2. 它在子节点列表的开头或者中间
      它获得原节点的index,后续节点的index依次+1
    3. 原先就没有子节点,它是第一个
      那它的index设为1即可

具体的代码实现如下:

func (s *sMenu) MenuSort(ctx context.Context, platformId int, tree []*model.MenuTree) response.Error {
s.lockSortMenu(ctx, platformId)
defer s.unlockSortMenu(ctx, platformId)

err = s.dfsTreeSort(ctx, platformId, &model.MenuTree{
Children: tree,
}, 0)
if err != nil && err != StopDFS {
return nil, response.NewErrorAutoMsg(
http.StatusServiceUnavailable,
response.ServerError,
).WithErr(err)
}
return
}

var StopDFS = gerror.New("STOP")

// 这个dfsTreeSort遍历时有个隐含条件,由于我们知道被拖曳的节点只有一个,所以我们找到这个节点后,马上就可以终止遍历
func (s *sMenu) dfsTreeSort(ctx context.Context, platformId int, node *model.MenuTree, parentTreeId int) error {
if node == nil {
return nil
}

childsMenus := make([]*model.MenuTree, 0)
for i := 0; i < len(node.Children); i++ {
n := node.Children[i]

mt, err := dao.MenuTree.GetMenuTreeByMenuId(ctx, platformId, n.MenuId, dao.MenuTree.Columns().ParentTreeId)
if err != nil {
return err
}

if int(mt.PlatformId) != parentTreeId {
// 发现被移动节点
err = s.swapTreeNode(ctx, platformId, n, parentTreeId, i)
if err != nil {
return err
}
return StopDFS // 终止遍历
}
// 收集子菜单
childsMenus = append(childsMenus, n)

err = s.dfsTreeSort(ctx, platformId, n, n.MenuId)
if err != nil {
return err
}
}

// 判断子节点列表的顺序
if node.MenuId != 0 && len(childsMenus) > 0 {
oldChilds, err := dao.MenuTree.GetMenuTreeChild(ctx, platformId, node.MenuId)
if err != nil {
return err
}

if len(childsMenus) < len(oldChilds) {
// 这个情况不处理
return nil
}

for i := 0; i < len(childsMenus); i++ {
if i < len(oldChilds) && childsMenus[i].MenuId != int(oldChilds[i].MenuId) {
// 发现被移动节点
err = s.swapTreeNode(ctx, platformId, childsMenus[i], parentTreeId, i)
if err != nil {
return err
}
return StopDFS
}
}
// 前面顺序如果都一样,那必然是最后一个节点新增的
if len(childsMenus) > len(oldChilds){
L := len(childsMenus) - 1

err = s.swapTreeNode(ctx, platformId, childsMenus[L], parentTreeId, L)
if err != nil {
return err
}
return StopDFS
}
}
return nil
}

func (s *sMenu) swapTreeNode(ctx context.Context, platformId int, node *model.MenuTree, newParentMenuId int, index int) error {
tx, err := g.DB().Begin(ctx)
if err != nil {
return gerror.Wrap(err, "")
}
ctx = context.WithValue(ctx, "tx", tx)

childs, err := dao.MenuTree.GetMenuTreeChild(ctx, platformId, newParentMenuId)
if err != nil {
return err
}

var newIndex int64 = 1
if len(childs) == 0 {
// 原先没有子节点
newIndex = 1
}
if len(childs) != 0 && index > len(childs)-1 {
// 在末尾
newIndex = childs[len(childs)-1].Index + 1
}

if len(childs) != 0 {
// 在中间 或者在开头
for i := index; i < len(childs); i++ {
newIndex = childs[i].Index

for j, e := range childs[i:] {
fileds := make([]interface{}, 0)
fileds = append(fileds, dao.MenuTree.Columns().Index)
if int(e.MenuId) == node.MenuId{
// 不遍历到自己
continue
}

err = dao.MenuTree.EditMenuTree(ctx, &entity.MenuTree{
Id: e.Id,
Index: newIndex + int64(j) + 1,
}, fileds...)
if err != nil {
tx.Rollback()
return err
}
}
}
}

fileds := make([]interface{}, 0)
fileds = append(fileds, dao.MenuTree.Columns().ParentTreeId)
fileds = append(fileds, dao.MenuTree.Columns().Index)

err = dao.MenuTree.EditMenuTreeByMenuId(ctx, &entity.MenuTree{
MenuId: int64(node.MenuId),
PlatformId: int64(platformId),
ParentTreeId: int64(newParentMenuId),
Index: newIndex,
}, fileds...)
if err != nil {
tx.Rollback()
return err
}
tx.Commit()
return nil
}

在菜单排序操作时,最好也加上分布式锁,菜单排序时禁止添加菜单、导入菜单等操作。

六、菜单导入、导出

菜单导入、导出在实践中也是非常有用的,常用的场景是:测试环境添加、更新、删除菜单后,想同步到正式环境,难道要再操作一遍吗?简单的办法就是导出测试环境的菜单,再导入到正式环境即可。

1. 菜单导出

既然已经获取到了菜单列表,那么把它导出成json文件也不是什么难事,这里的矛盾是,菜单列表里返回的菜单树信息是不全的,我们需要补充信息,导出一颗完整的菜单树:


type MenuExportTree struct {
MenuInfo

Children []*MenuExportTree `json:"children"` // 子菜单
}

type MenuTree struct {
MenuId int `json:"id"` // 菜单ID
ParentMenuId int `json:"parent_id"` // 父菜单ID
Name string `json:"name"` // 菜单名
Type int64 `json:"type"` // 菜单类型 1菜单2子菜单3按钮4页签5数据标签

Children []*MenuTree `json:"lists"` // 子菜单
}

菜单列表返回的字段是不全的,我们需要拿到 menu_info 表的所有字段,这里有两种思路,一种是仿照菜单列表的写法再写一遍,但是这次建立树节点时要获取下 menu_info 表的信息;另一种做法则是调用菜单列表获取到菜单树,然后做一个树克隆的算法,在克隆的过程中,把 menu_info 的信息写进去。

这里介绍第二种做法,原树的节点(MenuTree)克隆一个新树(MenuExportTree),这里我们为了炫技复习基础,换用BFS来遍历树和克隆树。

// BFS 树克隆,原树 node ,新树 newNode
// BFS 遍历原树,在遍历过程中,建立新树节点
func (s *sMenu) bfsTreeCopy(node *model.MenuTree, newNode *model.MenuExportTree) {
p := node
if p == nil {
return
}
q := newNode

// isVisit是防止树中有回环指向,在菜单树中其实不存在回环,其实可以不要。
isVisit := make(map[*model.MenuTree]int)
queueP := list.New() // P 原树队列
queueQ := list.New() // Q 新树队列
queueP.PushBack(p)
queueQ.PushBack(q)

for queueP.Len() != 0 {
size := queueP.Len()
for i := 0; i < size; i++ {
e := queueP.Front()
eq := queueQ.Front()
p = e.Value.(*model.MenuTree)
q = eq.Value.(*model.MenuExportTree)

if _, ok := isVisit[p]; !ok {
q.MenuId = p.MenuId
q.Children = make([]*model.MenuExportTree, 0)
if q.MenuId != 0 {
// 获取 menu_info 表数据
menuInfo, _ := dao.MenuInfo.GetMenuInfo(
context.Background(),
q.MenuId,
dao.MenuInfo.Columns().Status,
dao.MenuInfo.Columns().Icon,
dao.MenuInfo.Columns().CreateTime,
)
q.MenuInfo = menuInfo
}
isVisit[p] = 1
}

for _, child := range p.Children {
queueP.PushBack(child)
t := &model.MenuExportTree{}
q.Children = append(q.Children, t)
queueQ.PushBack(t) // 推一个空的新节点到queueQ,下次循环会为其赋值

}
queueP.Remove(e)
queueQ.Remove(eq)
}
}
}

BFS 比较擅长处理需要针对每层节点进行操作的情况, DFS则可以在遍历时方便的获取到父节点的id,大部分时候我们选择一种遍历算法使用即可。

2. 菜单导入

导入则比较简单了,这里的导入是指我们用导入数据覆盖原数据,比较简单。如果要支持导入部分树节点,则可能比较麻烦,不能用 menu_id 数据库主键作为菜单唯一标识了,因为不同环境的主键不同。需要为菜单生成唯一标识,比如 menu_key 之类的字段,然后用它作为导入时定位菜单的依据。

所以不如简单点,导入就是用导入的数据覆盖原来数据,步骤就是,删除原来的菜单树,然后建立一颗新的菜单树。

// 根据 MenuExportTree 建立一个新的菜单树写入数据库中
func (s *sMenu) dfsTreeImport(ctx context.Context, tx *gdb.TX, root *model.MenuExportTree, parentMenuId int, index int) error {
if root == nil {
return nil
}
menuId := 0

// 前序遍历,写入 menu_info 表
if len(root.Name) != 0 {
menuInfo := &entity.MenuInfo{
PlatformId: int64(root.PlatformId),
Name: root.Name,
Type: root.Type,
Icon: root.Icon,
IsOutlink: root.IsOutLink,
RouteUrl: root.RouteData,
Status: root.Status,
ShowTime: root.Showtime,
BackendApi: root.BackendApi,
DataLabels: gjson.New(root.DataLabels.Data),
}

err := dao.MenuInfo.AddMenuInfoReturnId(ctx, menuInfo)
if err != nil {
_ = tx.Rollback()
return gerror.Wrap(err, "")
}
menuId = int(menuInfo.Id)
}

for i := 0; i < len(root.Children); i++ {
n := root.Children[i]

err := s.dfsTreeImport(ctx, tx, n, menuId, i+1)
if err != nil {
_ = tx.Rollback()
return err
}
}

// 当遍历完这个节点的子节点后,把这个节点写入 menu_tree
if len(root.Name) != 0 {
tree := &entity.MenuTree{
PlatformId: int64(root.PlatformId),
ParentTreeId: int64(parentMenuId),
MenuId: int64(menuId),
Index: int64(index),
}
err := dao.MenuTree.AddMenuTree(ctx, tree)
if err != nil {
_ = tx.Rollback()
return err
}
}
return nil
}

七、参考文献

  1. LeetCode
  2. gitee.com/fe.zookeepe… (图源)
  3. 代码风格(GoFrameV2)


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

收起阅读 »

Android代码检查之自定义Lint

概述Lint 是 Android studio 提供的一款静态代码检查工具,它可以帮助我们检查 Android 项目源文件是否有潜在的 bug,以及在正确性、安全性、性能、易用性、无障碍性和国际化方面是否需要优化改进。Lint 的好处不言而喻,它能够...
继续阅读 »

概述

Lint 是 Android studio 提供的一款静态代码检查工具,它可以帮助我们检查 Android 项目源文件是否有潜在的 bug,以及在正确性、安全性、性能、易用性、无障碍性和国际化方面是否需要优化改进。Lint 的好处不言而喻,它能够在编码阶段就帮我们提前发现代码中的“坏味道”,显著降低线上问题出现的概率;同时也能有效促进团队的开发规范的统一。

lint-process.png

关于执行 lint 检查的几种方式不多做赘述,接下来着重来看下如何实现自定义 Lint 规则并应用到实际项目中。

自定义 Lint 接入方案

自定义 Lint 规则最终都会打成 JAR 包,只需将该输出 JAR 提供给其他组件使用即可。目前有两种方式可供选择:

全局方案

把此 jar 拷贝到 ~/.android/lint/ 目录中即可。缺点显而易见:针对所有工程生效,会影响同一台机器其他工程的 Lint 检查。即便触发工程时拷贝过去,执行完删除,但其他进程或线程使用 ./gradlew lint 仍可能会受到影响。

AAR 壳方案

custom-lint-compose.jpg

另一种实现方式是将 jar 置于一个 aar 中,如果某个工程想要接入执行自定义的 lint 规则,只需依赖这个发布后的 aar 即可,如此一来,新增的 lint 规则就可将影响范围控制在单个项目内了。另外,该方案也是 Google 目前推荐的方式,aar 内容也支持 lint.jar 条目:

AAR 文件的文件扩展名为 .aar,Maven 工件类型应该也是 aar。此文件本身是一个 zip 文件。唯一的必需条目是 /AndroidManifest.xml。AAR 文件可能包含以下一个或多个可选条目:
xx.aar
|-/classes.jar
|-/res/
|-/R.txt
|-/public.txt
|-/assets/
|-/libs/name.jar
|-/jni/abi_name/name.so(其中 abi_name 是 Android 支持的 ABI 之一)
|-/proguard.txt
|-/lint.jar
|-/api.jar
|-/prefab/(用于导出原生库)

具体可参考 Android 官方对于 aar 的介绍:developer.android.com/studio/proj…

编写自定义 Lint 规则

接下来主要从以下几个方面来介绍自定义 Lint 的开发流程。

1. 创建 java-library & 配置 lint 依赖

自定义的 lint 规则最终输出格式为 jar 包,所以我们只需要创建一个 java-library 即可,build.gradle 配置如下:

lint-rules/build.gradle

plugins {
   id 'java-library'
   id 'org.jetbrains.kotlin.jvm'
}

dependencies {
// 官方提供的Lint相关API,并不稳定,每次AGP升级都可能会更改,且并不是向下兼容的
   compileOnly "com.android.tools.lint:lint-api:${rootProject.ext.lintVersion}"
// 目前Android中内置的lint检测规则
   compileOnly "com.android.tools.lint:lint-checks:${rootProject.ext.lintVersion}"
   compileOnly "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlinVersion"

   testImplementation "junit:junit:4.13.2"
   testImplementation "com.android.tools.lint:lint:$lintVersion"
   testImplementation "com.android.tools.lint:lint-tests:$lintVersion"
}

java {
   sourceCompatibility = JavaVersion.VERSION_1_8
   targetCompatibility = JavaVersion.VERSION_1_8
}

jar {
   manifest {
       // Only use the "-v2" key here if your checks have been updated to the
       // new 3.0 APIs (including UAST)
       attributes('Lint-Registry-V2': 'com.dorck.lint.rules.old.MyCustomIssueRegistry')
  }
}

configurations {
   lintJarOutput
}
dependencies {
   lintJarOutput files(jar)
}
defaultTasks 'assemble'

配置期间如果发现如下问题:

java_version_1.7_error.png 需要将 java 闭包中的 sourceCompatibility 和 targetCompatibility 改为 1.8。

此外,如果你创建 module 时选择的是 kotlin 语言,还可能会遇到以下这个坑:

prepare_lint_for_publish_error.png 只需要将 kotlin 标准库依赖方式改为 compileOnly 即可:

compileOnly "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlinVersion"

2. 编写 lint-rules

平时经常使用 kotlin 开发项目的同学应该都遇到过这种情况:一旦我们希望类 A 实现一个接口 B,那么通过 AS 快捷键 option+ enter 选择 implement members 后就会为我们的类 A 自动实现 B 中接口,并加了一堆 TODO 方法:

todo_interface_not_impl.png

目前编码环境并不会提示任何错误,然而,如果我们粗心忘记去掉上面接口实现中的 TODO 方法,一旦我们其他类调用到这个类 SomethingNew,程序就立马抛出一个 NotImplementedError 异常。显然,如果前置静态代码检查阶段没有拦住这个问题进而跑到了线上,那么就只能祈祷别人不会去调用了,否则故障在所难免了。好了,既然需求过来了,我就来尝试通过自定义 Lint 帮助团队其他成员在编码阶段就发现问题并强制处理。

首先,在上一步中,我们在 lint-rules/build.gradle 中指定了自定义的 MyCustomIssueRegistry,现在里面空空如也,我们需要先创建一个 Detector 用于检测 Standard.kt 中的 TODO() 方法:

@Suppress("UnstableApiUsage")
class KotlinTodoDetector : Detector(), Detector.UastScanner {

   override fun getApplicableMethodNames(): List<String> {
       return listOf("TODO")
  }

   override fun visitMethodCall(context: JavaContext, node: UCallExpression, method: PsiMethod) {
       println("KotlinTodoDetector >>> matched TODO in [${method.parent.containingFile.toString()}]")
       if (context.evaluator.isMemberInClass(method, "kotlin.StandardKt__StandardKt")) {
           val deleteFix = fix().name("Delete this TODO method")
              .replace().all().with("").build()
           context.report(
               ISSUE,
               context.getLocation(node),
               "You must fix `TODO()` first.", deleteFix)
      }
  }

   companion object {
       private const val ISSUE_ID = "KotlinTodo"
       val ISSUE = Issue.create(
           ISSUE_ID,
           "Detecting `TODO()` method from kotlin/Standard.kt.",
           """
               You have unimplemented method or undo work marked by `TODO()`,
               please implement it or remove dangerous TODO.
               """,
           category = Category.CORRECTNESS,
           priority = 9,
           severity = Severity.ERROR,
           implementation = Implementation(KotlinTodoDetector::class.java, Scope.JAVA_FILE_SCOPE),
      )
  }
}

此处我们需要检测的对象是 Java 源文件,这里只需要继承自 Detector 并实现 Detector.UastScanner 接口即可。当然,我们也可以选择按组合方式实现更多其他 Scanner,这取决于我们希望扫描的文件范围。目前支持的扫描范围有:

  • UastScanner:扫描 Java 或者 kotlin 源文件
  • ClassScanner:扫描字节码或编译的类文件
  • BinaryResourceScanner:扫描二进制资源文件(res/raw/bitmap等)
  • ResourceFolderScanner:扫描资源文件夹
  • XmlScanner:扫描 xml 格式文件
  • GradleScanner:扫描 Gradle 格式文件
  • OtherFileScanner:其他类型文件

检测 Java 源文件,可以通过 getApplicableMethodNames 指定扫描的方法名,其他还有类名、文件名、属性名等等,并通过 visitMethodCall 接受检测到的方法。这里我们只需要检测 Kotlin 标准库中的 Standard.kt 中的 TODO 方法,匹配到后通过 context.report 来报告具体问题,这里需要指定一个 Issue 对象来描述问题具体信息,相关字段如下:

  • id : 唯一值,应该能简短描述当前问题。利用 Java 注解或者 XML 属性进行屏蔽时,使用的就是这个 id。
  • summary : 简短的总结,通常5-6个字符,描述问题而不是修复措施。
  • explanation : 完整的问题解释和修复建议。
  • category : 问题类别。常见的有:CORRECTNESS、SECURITY、COMPLIANCE、USABILITY、LINT等等。
  • priority : 优先级。1-10 的数字,10 为最重要/最严重。
  • severity : 严重级别:Fatal, Error, Warning, Informational, Ignore。
  • Implementation : 为 Issue 和 Detector 提供映射关系,Detector 就是当前 Detector。声明扫描检测的范围Scope,Scope 用来描述 Detector 需要分析时需要考虑的文件集,包括:Resource文件或目录、Java文件、Class文件等。

此外,我们还可以设置出现该 issue 上报时的默认解决方案 fix,这里我们创建了一个 deleteFix 实现开发者快速移除报错位置的 TODO 代码。

最后,只需要自定义一个 Registry 声明自己需要检测的 Issues 即可:

@Suppress("UnstableApiUsage")
class MyCustomIssueRegistry : IssueRegistry() {
   init {
       println("MyCustomIssueRegistry, run...")
  }

   override val issues: List<Issue>
       get() = listOf(
           JcenterDetector.ISSUE,
           KotlinTodoDetector.ISSUE,
      )

   override val minApi: Int
       get() = 8 // works with Studio 4.1 or later; see com.android.tools.lint.detector.api.Api / ApiKt

   override val api: Int
       get() = CURRENT_API

   override val vendor: Vendor
       get() = Vendor(
           vendorName = "Dorck",
           contact = "xxx@gmail.com"
      )

}

更多关于 AST 相关类及语法介绍可参考官方指导文档或者 Lint 源码,此处不多做介绍,这里很难一言以蔽之。

3. Lint 发布&接入

文章开头部分已经介绍了 Lint 的相关接入方案,出于灵活性和可用性角度考虑自然选择 aar 壳的方式。经过这几年 lint 的发展,实现起来也很简单:只需要创建一个 Android-Library module,然后稍微配置下 gradle 即可:

lint-aar:

plugins {
   id 'com.android.library'
   id 'org.jetbrains.kotlin.android'
}
dependencies {
   lintPublish project(':checks')
   // other dependencies
}

就是这么简单,此处的 lintPublish 配置允许我们引用另一个 module,它会获取该组件输出的 jar 并将其打包为 lint.jar 然后放到自身的 AAR 中。

lintPublish_output.png

最后,我们在 app 模块中依赖一下 lint-aar 这个组件,并编写以下测试代码:

interface SimpleInterface {
   fun initialize()
   fun doSomething()
}

class SomethingNew : SimpleInterface {
       override fun initialize() {
           TODO("Not yet implemented")
      }

       override fun doSomething() {
           TODO("Not yet implemented")
      }

  }

接下来执行一下 ./gradlew :app:lint 即可看到控制台输出以下内容:

todo_rule_run_output.png

我们也可以点击 Lint 输出的测试报告链接去查看详细信息:

todo_rule_run_html_output.png

Note:AGP 7.0 开始,执行 ./gradlew :app:lint 只会作用于默认变体 lint 任务上,而不是诸如此前的执行所有变体 lint 任务。

例如:我们此前执行 ./gradlew :app:lint 可能会导致 debugLintreleaseLintreleaseChinaLint 等诸多变体 lint 任务的执行,严重拖慢了编译速度,所以一般要指定特定变体的 lint 任务来执行:./gradlew :app:lintDebug。而7.0开始将无需如此麻烦,尽管放心使用 ./gradlew :app:lint 即可。

最后,在 Android studio 中我们也可以看到编译器给我们的代码警告了:

todo_rule_as_preview.png

并且我们上面设置的 deleteFix 也生效了,即点击 Delete this TODO method 就可以轻松移除 TODO() 方法,快速解决问题。

4. 编写测试代码

TTD(Test-Driven Development)是一个不错的习惯,很多时候作为开发人员大多时候无需关心最新编写的 lint 组件发布状态,因为不断发布和集成到示例代码中测试是一个比较糟糕的体验,严重消耗我们的精力。如此一来,我们就不得不了解下 lint 规则编码时的单测流程了,我相信能够显著提升你的开发效率。

首先,我们需要依赖 Lint 的单测组件:

testImplementation "com.android.tools.lint:lint-tests:$lintVersion"

接着,在 lint-rules 模块中创建单测文件用于验证我们之前的 KotlinTodo 规则:

todo_rule_test_dir.png

最后来看下 KotlinTodoDetectorTest 如何实现的:

package com.dorck.lint.examples

import com.android.tools.lint.checks.infrastructure.TestFiles.kotlin
import com.android.tools.lint.checks.infrastructure.TestLintTask.lint
import com.dorck.lint.rules.old.issues.KotlinTodoDetector
import org.junit.Test

@Suppress("UnstableApiUsage")
class KotlinTodoDetectorTest {

   @Test
   fun sampleTest() {
       lint().files(
           kotlin(
               """
                   package test.pkg
                   class SimpleInterfaceImpl : SimpleInterface {
                   
                       override fun doSomething(){
                           TODO("Not yet implemented")
                       }
                   }
                   interface SimpleInterface {
                       fun doSomething()
                   }
               """.trimIndent()
          ))
          .issues(KotlinTodoDetector.ISSUE)
          .run()
          .expect(
               """
                   src/test/pkg/SimpleInterfaceImpl.kt:5: Error: You must fix TODO() first. [KotlinTodo]
                           TODO("Not yet implemented")
                           ~~~~~~~~~~~~~~~~~~~~~~~~~~~
                   1 errors, 0 warnings
               """.trimIndent()
          )
     .expectFixDiffs(
               """
                   Fix for src/test/pkg/SimpleInterfaceImpl.kt line 5: Delete this TODO method:
                   @@ -5 +5
                   -         TODO("Not yet implemented")
               """.trimIndent()
          )
  }
}

其实也很简单,只需要模拟创建一个 Java/kotlin/Gradle/xml 等格式的源文件,然后在 java() 或 koltin() 方法参数里面写上测试代码,并指定要验证的 Issue 以及期待的反馈内容。当然,expect() 中期待的输出检查结果我们是无法知晓的,我们只需要先设置为空字符串,然后先跑一下测试用例,预期肯定会失败,如此,我们只需要将终端输出的实际错误信息 copy 到 expect() 中即可:

todo_rule_lint_test_output.png

最后,重新 run 一下单测,就会发现能够正常通过测试了。更多关于 Lint 单元测试的用法可以参考:Lint unit testing

5. 忽略某些规则检查

某些情况下,我们希望忽略某些 Lint 规则的检查或者更改 Lint 规则的严重级别,那么,我们可以选择增加一个 Lint 配置文件,用于解决上述问题。我们可以手动在项目 app/根目录下创建一个名为 lint.xml 的文件:

<?xml version="1.0" encoding="UTF-8"?>
<lint>
   <!-- list of issues to configure -->
   <issue id="DefaultLocale" severity="ignore"/>
   <issue id="DeprecatedProvider" severity="ignore"/>
   <issue id="ObsoleteLayoutParam">
       <!-- The <ignore> tag has two possible attributes: path and regexp (see below) -->
       <ignore path="res/layout-xlarge/activation.xml" />
       <!-- You can use globbing patterns in the path strings -->
       <ignore path="**/layout-x*/onclick.xml" />
       <ignore path="res/**/activation.xml" />
   </issue>

<issue id="MissingTranslation" severity="ignore"/>
<issue id="KotlinTodo" severity="ignore"/>
</lint>

在 lint.xml 中我们可以选择更改某条规则的严重级别,使原本不受重视的规则更加引人注意或者放宽其他规则的级别。当然,我们也可以指定某条规则在特定匹配路径下被忽略,这将取决于我们自己设定的 regex 匹配规则。

Note:如果我们创建了 lint.xml (文件名强约定),并且 build.gradle 的 lintOptions 中没有自定义设定 lint 配置文件的名称和路径,则 AGP自动在临近目录中寻找名为 lint.xml 的配置文件。

详细参考:Configure by lint xml

此外,我们也可以通过在 build.gradle >> lintOptions DSL 中设置开启或者关闭某些特定规则,当然也可以配置报告的输出格式以及路径:

lintOptions {
   textReport false
   lintConfig file('default-lint.xml') // At `app/default-lint.xml`
   disable 'KotlinTodo', 'MissingTranslation'
   xmlOutput file("lint-report.xml")
}

更多关于 LintOptions DSl 的配置可查看官方文档:LintOptions-dsl

Note:如果你项目中使用了 lint plugin,那么可以参考 lint DSL的相关释义:AGP-lint-dsl

其他的设置 lint 配置的方式还有手动在 Android studio 的工具栏 Analyze > Inspect Code > Specify Inspection Scope 中或者通过 Lint 命令行工具来配置,这两种方式就不具体介绍了,感兴趣的朋友可以去看下官方文档的介绍。

版本迭代过程

lint_plugin_update.png

AGP 4.0开始,Android studio 支持了独立的 com.android.lint 插件,进一步降低了自定义 lint 的成本。借助此插件,在上述 lint-rules/build.gradle 中通过在 manifest 中注册自定义 Registry 改为通过服务表单注册(当然,以前的方式目前还是可以用的)。以下是基于官方最新推荐的方式来配置和注册自定义规则的:

plugins {
   id 'java-library'
   id 'org.jetbrains.kotlin.jvm'
   id 'com.android.lint'
}

dependencies {
   // 官方提供的Lint相关API,并不稳定,每次AGP升级都可能会更改,且并不是向下兼容的
   compileOnly "com.android.tools.lint:lint-api:${rootProject.ext.lintVersion}"
   // 目前Android中内置的lint检测规则
   compileOnly "com.android.tools.lint:lint-checks:${rootProject.ext.lintVersion}"
   compileOnly "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlinVersion"

   testImplementation "junit:junit:4.13.2"
   testImplementation "com.android.tools.lint:lint:$lintVersion"
   testImplementation "com.android.tools.lint:lint-tests:$lintVersion"
}

java {
   sourceCompatibility = JavaVersion.VERSION_1_8
   targetCompatibility = JavaVersion.VERSION_1_8
}
lint_spi_usage.png

可以看到,以此插件方式,我们需要关注的额外配置更少了,很大程度上降低了接入成本。

下面再来谈谈 AGP-7.0 开始的改动。其一变动是上面谈及过的执行 ./gradlew lint 只会作用于默认变体的 Lint 任务上,而不是以前的所有变体任务。

另外一项是在 7.0 中,lint 最终将能够跨模块增量运行,这意味着如果我们只更改一个模块中的代码,lint 只需在该模块下游的模块上重新运行分析检测。对于具有许多模块的大型项目,这应该是一项重大的改进。

开发技巧

1. 借助 Psi 工具查看 AST 语法树

Lint 检查的实质是对代码的 AST(Abstract Syntax Tree,即抽象语法树)数据进行检查分析,故而会用到大量 AST 与 lombok.ast 开源库相关知识。阅读源码是一种不错的分析语法树方式,不过我们可以借助 AS 的一些插件帮我们快速便捷解析类的节点树并加以解读。

psiviewer_usage.png

利用 PsiViewer 就可以查看类的 AST 构造,如此一来我就可以另辟蹊径找到特定的属性来匹配特定代码了。值得注意的是,上面的 AST viewer 插件对 kotlin 代码支持不是很好,如果有需要,建议先将 kotlin 反编译为 java 再分析。

2. 参考 Android 内置 Lint 规则

我发现官方近期对于 Lint 的技术推进很上心,各路文档和 FAQ 陆续补齐了。关于内置规则,Android官方团队也对每条做了详细说明和用法指导,详细参考:googlesamples.github.io/android-cus…


作者:道可诉
链接:https://juejin.cn/post/7130410607534145544
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

收起阅读 »

我用vue3和egg开发了一个早报学习平台,带领群友走向技术大佬

web
该项目的出发点是获取最新最值得推荐的文章以及面经,供群友们学习使用。带领前端阳光的群友们一起成为技术大佬。当点击掘金的时候,就会获取掘金当前推荐的前端文章当点击牛客网的时候,就会获取到最新的前端面经点击【查看】就会跳到文章详情页勾选后点击确认,就会把文章标题拼...
继续阅读 »

项目功能介绍

该项目的出发点是获取最新最值得推荐的文章以及面经,供群友们学习使用。带领前端阳光的群友们一起成为技术大佬。


当点击掘金的时候,就会获取掘金当前推荐的前端文章


当点击牛客网的时候,就会获取到最新的前端面经


点击【查看】就会跳到文章详情页


勾选后点击确认,就会把文章标题拼接到右边的输入框中,然后点击发送,就会将信息发送到学习群里供大家阅读。


项目启动:分别进入server和client项目,执行npm i安装相关依赖,然后启动即可。

技术栈介绍

本项目采用的是前后端分离方案

前端使用:vue3 + ts + antd

后端使用:egg.js + puppeter

前端实现

创建项目

使用vue-cli 创建vue3的项目。


按需引入antd组件

借助babel-plugin-import实现按需引入

npm install babel-plugin-import --dev

然后创建配置.babelrc文件就可以了。

{
 "plugins": [
  ["import", { "libraryName": "ant-design-vue", "libraryDirectory": "es", "style": "css" }] // `style: true` 会加载 less 文件
]
}

我们可以把需要引入的组件统一写在一个文件里

antd.ts

import {
 Button,
 Row,
 Col,
 Input,
 Form,
 Checkbox,
 Card,
 Spin,
 Modal,
} from "ant-design-vue";

const FormItem = Form.Item;

export default [
 Button,
 Row,
 Col,
 Input,
 Form,
 FormItem,
 Checkbox,
 Card,
 Spin,
 Modal,
];

然后在入口文件里面use应用它们 main.js

import { createApp } from "vue";
import App from "./App.vue";
import antdCompArr from "@/antd";

const app = createApp(App);
antdCompArr.forEach((comp) => {
 app.use(comp);
});

app.mount("#app");

首页

其实就一个页面,所以,直接写在App.vue了

布局比较简单,直接亮html

<template>
 <div class="pape-wrap">
   <a-row :gutter="16">
     <a-col :span="16">
       <a-card
         v-for="group in paperList"
         :key="group.name"
         class="box-card"
         shadow="always"
       >
         <div class="clearfix">
           <span>{{ group.name }}span>
         div>
         <div class="channels">
           <a-button
             :style="{ 'margin-top': '10px', 'margin-left': '10px' }"
             size="large"
             v-for="item in group.list"
             :key="item.href"
             class="btn-channel"
             @click="onClick(item)"
           >
            {{ item.name }}
           a-button>
         div>
       a-card>
     a-col>
     <a-col :span="8">
       <a-form>
         <a-form-item
           :laba-col="{ span: 24 }"
           label="支持markdown输入"
           label-align="left"
         >
           <a-textarea
             v-model:value="content"
             placeholder="暂支持mardown语法"
             show-count
           />
         a-form-item>
         <a-form-item>
           <a-button @click="handleSendMsg"> 发消息 a-button>
         a-form-item>
       a-form>
     a-col>
   a-row>

   <a-modal
     v-model:visible="visible"
     custom-class="post-modal"
     title="文章列表"
     @ok="handleComfirm"
   >
     <a-spin tip="Loading..." :spinning="isLoading">
       <div class="post-list">
         <div :style="{ borderBottom: '1px solid #E9E9E9' }">
           <a-checkbox
             v-model="checkAll"
             :indeterminate="indeterminate"
             @change="handleCheckAll"
             >全选a-checkbox
           >
         div>
         <br />
         <a-checkbox-group v-model:value="checkedList">
           <a-checkbox
             :value="item.value"
             v-for="item in checkoptions"
             :key="item.value"
           >
            {{ item.label }}
             <a
               class="a-button--text"
               style="font-size: 14px"
               target="_blank"
               :href="item.value"
               @click.stop
             >
                  查看a
             >
           a-checkbox>
         a-checkbox-group>
       div>
     a-spin>

     <span>
       <a-button @click="handleComfirm">确认a-button>
     span>
   a-modal>
 div>
template>

主要就是遍历了paperList,而paperList的值是前端写死的。在constant文件里

export const channels = [
{
   name: "前端",
   list: [
    {
       name: "掘金",
       bizType: "juejin",
       url: "https://juejin.cn/frontend",
    },
    {
       name: "segmentfault",
       bizType: "segmentfault",
       url: "https://segmentfault.com/channel/frontend",
    },
    {
       name: "Chrome V8 源码",
       bizType: "zhihu",
       url: "https://zhuanlan.zhihu.com/v8core",
    },
    {
       name: "github-Sunny-Lucky前端",
       bizType: "githubIssues",
       url: "https://github.com/Sunny-lucking/blog/issues",
    },
  ],
},
{
   name: "Node",
   list: [
    {
       name: "掘金-后端",
       bizType: "juejin",
       url: "https://juejin.cn/frontend/Node.js",
    },
  ],
},
{
   name: "面经",
   list: [
    {
       name: "牛客网",
       bizType: "newcoder",
       url: "https://www.nowcoder.com/discuss/experience?tagId=644",
    },
  ],
},
];


点击按钮的时候,出现弹窗,然后向后端发起请求,获取相应的文章。

点击方法如下:

const onClick = async (item: any) => {
 visible.value = true;
 currentChannel.value = item.url;
 if (cache[currentChannel.value]?.list.length > 0) {
   const list = cache[currentChannel.value].list;
   state.checkedList = cache[currentChannel.value].checkedList || [];
   state.postList = list;
   return list;
}
 isLoading.value = true;
 state.postList = [];
 const { data } = await getPostList({
   link: item.url,
   bizType: item.bizType,
});
 if (data.success) {
   isLoading.value = false;
   const list = data.data || [];
   state.postList = list;
   cache[currentChannel.value] = {};
   cache[currentChannel.value].list = list;
} else {
   message.error("加载失败!");
}
};

获得文章渲染之后,勾选所选项之后,点击确认,会将所勾选的内容拼接到content里

const updateContent = () => {
 const date = moment().format("YYYY/MM/DD");
 // eslint-disable-next-line no-useless-escape
 const header = `前端早报-${date},欢迎大家阅读。\n>`;
 const tail = `本服务由**前端阳光**提供技术支持`;
 const body = state.preList
  .map((item, index) => `#### ${index + 1}. ${item}`)
  .join("\n");
 state.content = `${header}***\n${body}\n***\n${tail}`;
};

const handleComfirm = () => {
 visible.value = false;
 const selectedPosts = state.postList.filter((item: any) =>
   state.checkedList.includes(item.href as never)
);
 const selectedList = selectedPosts.map((item, index) => {
   return `[${item.title.trim()}](${item.href})`;
});
 state.preList = [...new Set([...state.preList, ...selectedList])];
 updateContent();
};

然后点击发送,就可以将拼接的内容发送给后端了,后端拿到后再转发给企业微信群

const handleSendMsg = async () => {
const params = {
content: state.content,
};
await sendMsg(params);
message.success("发送成功!");
};

前端的内容就讲到这里,大家可以直接去看源码:github.com/Sunny-lucki…

后端实现

创建项目

后端是使用egg框架实现的

快速生成项目

npm init egg

可以直接看看morningController的业务逻辑,其实主要实现了两个方法,一个是获取文章列表页返回给前端,一个是发送消息。

export default class MorningPaper extends Controller {
public async index() {
const link = this.ctx.query.link;
const bizType = this.ctx.query.bizType;
let html = '';
if (!link) {
this.fail({
msg: '入参校验不通过',
});
return;
}
const htmlResult = await this.service.puppeteer.page.getHtml(link);
if (htmlResult.status === false) {
this.fail({
msg: '爬取html失败,请稍后重试或者调整超时时间',
});
return;
}
html = htmlResult.data as string;
const links = this.service.morningPaper.index.formatHtmlByBizType(bizType, html) || [];
this.success({
data: links.filter(item => !item.title.match('招聘')),
});
return;
}

/**
* 推送微信机器人消息
*/

async sendMsg2Weixin() {
const content = this.ctx.query.content;
if (!content) {
this.fail({
resultObj: {
msg: '入参数据异常',
},
});
return;
}
const token = this.service.morningPaper.index.getBizTypeBoken();
const status = await this.service.sendMsg.weixin.index(token, content);
if (status) {
this.success({
resultObj: {
msg: '发送成功',
},
});
return;
}

this.fail({
resultObj: {
msg: '发送失败',
},
});
return;
}
}

文章的获取

先看看文章是怎么获取的。

首先是调用了puppeter.page的getHtml方法

该方法是利用puppeter生成一个模拟的浏览器,然后模拟浏览器去浏览页面的逻辑。

 public async getHtml(link) {
const browser = await puppeteer.launch(this.launch);
const page: any = await browser.newPage();
await page.setViewport(this.viewport);
await page.setUserAgent(this.userAgent);
await page.goto(link);
await waitTillHTMLRendered(page);
const html = await page.evaluate(() => {
return document?.querySelector('html')?.outerHTML;
});
await browser.close();
return {
status: true,
data: html,
};
}

这里需要注意的是,需要await waitTillHTMLRendered(page);,它的作用是检查页面是否已经加载完毕。

因为,进入页面,page.evaluate的返回可能是页面还在加载列表当中,所以需要waitTillHTMLRendered判断当前页面的列表是否加载完毕。

看看这个方法的实现:每隔一秒钟就判断页面的长度是否发生了变化,如果三秒内没有发生变化,默认页面已经加载完毕

const waitTillHTMLRendered = async (page, timeout = 30000) => {
 const checkDurationMsecs = 1000;
 const maxChecks = timeout / checkDurationMsecs;
 let lastHTMLSize = 0;
 let checkCounts = 1;
 let countStableSizeIterations = 0;
 const minStableSizeIterations = 3;

 while (checkCounts++ <= maxChecks) {
   const html = await page.content();
   const currentHTMLSize = html.length;

   // eslint-disable-next-line no-loop-func
   const bodyHTMLSize = await page.evaluate(() => document.body.innerHTML.length);

   console.log('last: ', lastHTMLSize, ' <> curr: ', currentHTMLSize, ' body html size: ', bodyHTMLSize);

   if (lastHTMLSize !== 0 && currentHTMLSize === lastHTMLSize) { countStableSizeIterations++; } else { countStableSizeIterations = 0; } // reset the counter

   if (countStableSizeIterations >= minStableSizeIterations) {
     console.log('Page rendered fully..');
     break;
  }

   lastHTMLSize = currentHTMLSize;
   await page.waitForTimeout(checkDurationMsecs);
}
};

分析html,获取文章列表

上述的行为只会获取了那个页面的整个html,接下来需要分析html,然后获取文章列表。

html的分析其实 是用到了cheerio,cheerio的用法和jQuery一样,只不过它是在node端使用的。

已获取掘金文章列表为例子:可以看到是非常简单地就获取到了文章列表,接下来只要返回给前端就可以了。

  getHtmlContent($): Link[] {
   const articles: Link[] = [];
   $('.entry-list .entry').each((index, ele) => {
     const title = $(ele).find('a.title').text()
      .trim();
     const href = $(ele).find('a.title').attr('href');
     if (title && href) {
       articles.push({
         title,
         href: this.DOMAIN + href,
         index,
      });
    }
  });
   return articles;
}

发送信息到企业微信群

这个业务逻辑主要有两步,

首先要获取我们企业微信群的机器人的token,

接下来就将token 拼接成下面这样一个url

`https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=${token}`

然后利用egg 的curl方法发送信息就可以了

export default class Index extends BaseService {
 public async index(token, content): Promise<boolean> {
   const url = `https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=${token}`;
   const data = {
     msgtype: 'markdown',
     markdown: {
       content,
     },
   };
   const result: any = await this.app.curl(url, {
     method: 'POST',
     headers: {
       'Content-Type': 'application/json',
     },
     data,
   });
   if (result.status !== 200) {
     return false;
   }
   return true;
 }
}

后端的实现大抵如此,大家可以看看源码实现:github.com/Sunny-lucki…

总结

至此,一个伟大的工程就打造完毕。

群员在我的带领下,技术突飞猛进。。。

撒花撒花。。

作者:阳光是sunny
来源:juejin.cn/post/7129692007584235551

收起阅读 »

远离那些过度激情的人

有些人很有激情,甚至过度,这样的人不是很好,反正我会远离他们。我有一任领导,叫我去他办公室,他说:“我们的新项目是千载难逢的机会,我想交给你来做。你来安排人员,公司的人由你调配,资金任你使用,该加班就加班,项目完成了每人一万块钱加班费,我保证会有,不然我就没法...
继续阅读 »

有些人很有激情,甚至过度,这样的人不是很好,反正我会远离他们。

我有一任领导,叫我去他办公室,他说:“我们的新项目是千载难逢的机会,我想交给你来做。你来安排人员,公司的人由你调配,资金任你使用,该加班就加班,项目完成了每人一万块钱加班费,我保证会有,不然我就没法树立威信了,给你十万。这都是小数,因为我们项目一上线就是上亿。做好了我们飞黄腾达,投入全部身心吧,少年”。

我是相信的,真的很用心,都按要求做的尽善尽美。结果未能如愿,这套系统只是意淫,不是刚需。反而耗费不少人力财力,对下无法和弟兄们交代承诺,对上还需要背负领导决策的失误,导致我万劫不复。

我还见过一个技术大牛,经朋友介绍,受到某煤老板赏识,成立科技公司,要求建立一支强劲的技术团队。老板对大牛说,我只负责业务方面,你来负责技术方面,你可以海阔天空,你可以提任何需求,你可以有独立的管理体系,我们各自半壁江山,我们要做的是一生的事业,这个平台将会价值4600亿,两年之后,你将会是2000亿身价。大牛感动涕零。

大牛调动毕生各种资源,招揽亲朋好友,遍访业内名士,众人纷纷辞职来投,终于凑齐了一支队伍,准备干一场轰轰烈烈的事业。这时,老板说,其实我们的平台并不明朗,还是不干了吧

有激情是好事,但是只有一时激情很容易出现很多问题。

第一:过于夸大目标,忽视现实情况。动辄就是全球市场,上亿资产。心太远则舍近求远。他们的着眼点是未来,他们一般骂行业内知名的竞品都是大傻X,只有他是集大成者。忽略脚下,忽略困难,结果往往一步踏空,直坠悬崖。

第二:短暂激情,轻许诺言。当想做的时候,这件事是他的全部,他会许给你全世界,但是过几天激情退去了,原来的承诺化为乌有,让人失望。更甚者,撂摊子走人,让你不知所措。哀莫大于心死。

第三:太激情,容不下反对的声音。他激情四射,要求所有人都得激情四射。凡是他想的事情,如果你说半个不字,你便有滔天大罪。但凡有其他想法的,他们不是反思和安抚,反而直接将你清出团队。

所以,当有激情的朋友找我时,我一般让他等两天再来。当领导很有激情的给我许下承诺的时候,我会微微一笑。

做好事情是对得起自己,和有激情的人无关。

我觉得自己是平凡人,但不是个平庸的人。

平凡人就是不可能一步登天,无论多么好的平台都不可能。

平庸人就是没有追求,随波逐流,整天混日子。


来源:juejin.cn/post/7129666593918812197

收起阅读 »

分库分表后路由策略设计

概述 分库分表后设计到的第一个问题就是,如何选择路由key,应该如何对key进行路由。路由key应该在每个表中都存在而且唯一。路由策略应尽量保证数据能均匀进行分布。 如果是对大数据量进行归档类的业务可以选择时间作为路由key。比如按数据的创建时间作为路由key...
继续阅读 »

概述


分库分表后设计到的第一个问题就是,如何选择路由key,应该如何对key进行路由。路由key应该在每个表中都存在而且唯一。路由策略应尽量保证数据能均匀进行分布。


如果是对大数据量进行归档类的业务可以选择时间作为路由key。比如按数据的创建时间作为路由key,每个月或者每个季度创建一个表。按时间作为分库分表后的路由策略可以做到数据归档,历史数据访问流量较小,流量都会打到最新的数据库表中。


也可以设计其与业务相关的路由key。这样可以保证每个数据库的资源都能很好的承担流量。


支持场景


外卖订单平台分库分表后需要支持的场景,用户的角度,需要实时查看所点外卖订单的状态,跟踪订单信息。商家需要查询订单信息,通过订单分析菜品的质量,进行商业决策。



用户Consumer = C端 商家Business = B端



image.png


用户下单后订单可能会落到不同的表中,查询的时候可能需要查询多张表。


image.png


路由策略


如果创建订单时随机插入到某一张表中,或者不知道插入到那张表中,查询订单的时候都需要查询所有的表才能确保查询的准确信。


如果在插入订单的时候有一定的规则,根据这个规则插入到数据库中,查询的时候也执行相应的规则到对应的表中进行查询。这样就能减少数据操作的复杂性。可以通过设计路由策略来实现,用户和商家查询数据的时候都遵循相同的路由策略。


image.png


image.png


用户端路由key


根据上一小节的路由策略分析,现在需要选定一个路由key。用户端让同一个用户id的数据保存到某固定的表中,所以可以选用用户id最为路由key。


在单库的情况下,用户下单,生成一个订单,把用户id作为路由key,对user_id取hash值然后对表的数量进行取模,得到对应需要路由的表,然后写入数据。


image.png


多库多表的情况下需要先找到对应的库然后再找到对应的表。多库多表的路由策略:用户下达->生成订单->路由策略:根据用户id的hash值对数据库的数量进行取模找到对应的数据库->根据用户id的hash值除以对表的数量,然后在对表的数量进行取模即可找到对应的表。


image.png


路由策略设计的要点是根据具体的业务业务场景设计,跟用户信息关联度比较大的作为路由key进行hash值取模


商家路由key


单独为商家B端设计了一套表(C端和B端是独立的)。


image.png
用户的角度以user_id作为路由key,商户的角度以商家id作为路由key。商家是如何通过路由key路由数据的呢。游湖在下单的时候把队友的订单号发送到MQ里,商家可以去消费这个MQ,然后根据订单号获取订单信息,然后再把订单信息插入到商户的数据库表当中。商户的路由策略和用户的路由策略是一样的。


image.png


用户端和商户端的完整数据流程图:


image.png


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

自定义过滤器和拦截器实现ThreadLocal线程封闭

线程封闭 线程封闭一般通过以下三个方法: Ad-hoc线程封闭:程序控制实现,最糟糕,忽略 堆栈封闭:局部变量,无并发问题 ThreadLocal线程封闭:特别好的封闭方法 方法2是最常用的,变量定义在接口内,本文主要讲解方法三,SpringBoot项目通...
继续阅读 »

线程封闭


线程封闭一般通过以下三个方法:



  1. Ad-hoc线程封闭:程序控制实现,最糟糕,忽略

  2. 堆栈封闭:局部变量,无并发问题

  3. ThreadLocal线程封闭:特别好的封闭方法


方法2是最常用的,变量定义在接口内,本文主要讲解方法三,SpringBoot项目通过自定义过滤器和拦截器实现ThreadLocal线程封闭。实现Filter接口自定义过滤器和继承HandlerInterceptorAdapter自定义拦截器。


ThreadLocal线程封闭实现步骤


封装ThredLocal的方法


/**
* <p>自定义RequestHolder</p></p>
*
* @Author zjq
* @Date 2021/12
*/
public class RequestHolder {

private final static ThreadLocal<Long> requestHolder = new ThreadLocal<>();

public static void set(Long id) {
requestHolder.set(id);
}

public static Long get() {
return requestHolder.get();
}

public static void remove() {
requestHolder.remove();
}

}

自定义过滤器


自定义定义拦截器继承Filter接口,实现ThredLocal.add()方法


/**
* <p>自定义过滤器</p>
*
* @Author zjq
* @Date 2021/12/7
*/
@Slf4j
public class HttpFilter implements Filter {

/**
* 为Filter初始化 提供支持
*
* @param filterConfig
* @throws ServletException
*/
@Override
public void init(FilterConfig filterConfig) throws ServletException {

}

/**
* 拦截到要执行的请求时,doFilter就会执行。这里我们可以写对请求和响应的预处理。
* FilterChain把请求和响应传递给下一个 Filter处理
*
* @param servletRequest
* @param servletResponse
* @param filterChain
* @throws IOException
* @throws ServletException
*/
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
//把普通servlet强转成httpServlet
HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;
Long threadId = Thread.currentThread().getId();
log.info("do filter,threadId:{} servletPath:{}", threadId, httpServletRequest.getServletPath());
//把当前线程id放入requestHolder
RequestHolder.set(threadId);
//放行
filterChain.doFilter(httpServletRequest, servletResponse);
}

/**
* Filter 实例销毁前的准备工作
*/
@Override
public void destroy() {

}
}

自定义拦截器


自定义拦截器在线程使用完毕后移除ThredLocal中内容,避免内存溢出


/**
* <p>自定义拦截器</p>
*
* @Author zjq
* @Date 2021/12/7
*/
@Slf4j
public class HttpInterceptor extends HandlerInterceptorAdapter {

/**
* 拦截处理程序的执行。在 HandlerMapping 确定合适的处理程序对象之后,在 HandlerAdapter 调用处理程序之前调用。
* @param request
* @param response
* @param handler
* @return
* @throws Exception
*/
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
log.info("preHandle执行。。。");
return true;
}

/**
* 请求处理完成后(渲染视图后)的回调。将在处理程序执行的任何结果上调用,从而允许进行适当的资源清理。
* @param request
* @param response
* @param handler
* @param ex
* @throws Exception
*/
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
RequestHolder.remove();
log.info("afterCompletion执行。。。");
return;
}
}

Application类启动类中配置自定义过滤器和拦截器


/**
*
* @author zjq
*/
@SpringBootApplication
public class Application extends WebMvcConfigurationSupport {

public static void main(String[] args) {
SpringApplication.run(ConcurrencyApplication.class, args);
}

/**
* 自定义过滤器
* @return
*/
@Bean
public FilterRegistrationBean filterRegistrationBean(){
FilterRegistrationBean filterRegistrationBean = new FilterRegistrationBean();
filterRegistrationBean.setFilter(new HttpFilter());
//设置自定义过滤器拦截的url
filterRegistrationBean.addUrlPatterns("/threadLocal/*");
return filterRegistrationBean;
}

/**
* 定义自定义拦截器原先需要继承WebMvcConfigurerAdapter
* SpringBoot2.0后WebMvcConfigurerAdapter被定义成过时了,推荐使用继承WebMvcConfigurationSupport
* @param registry
*/
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new HttpInterceptor()).addPathPatterns("/**");
}
}

定义调用接口


/**
* ThreadLocal测试controller
* @author zjq
*/
@Controller
@RequestMapping("/threadLocal")
public class ThreadLocalController {

@RequestMapping("/test")
@ResponseBody
public Long test() {
return RequestHolder.get();
}
}

请求访问验证


访问调用接口,控制台输出如下:
image.png


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

【flutter进阶】Widget源码详解-如何实现自由组合,动态刷新,布局绘制?

看到结局的问题:如何区分StatelessWidget 和 StatefulWidget 的使用场景,不禁开始自问,对于StatefulWidget ,StatelessWidget,以及flutter中Widget的众多子类我真的足够了解吗? 对于自己经常要...
继续阅读 »

看到结局的问题:如何区分StatelessWidgetStatefulWidget 的使用场景,不禁开始自问,对于StatefulWidget ,StatelessWidget,以及flutter中Widget的众多子类我真的足够了解吗?


对于自己经常要打交道的东西,如果只是一知半解则不利于进步。


下面就从源码的角度来学习下flutter基础的几个Widget 都起到了什么作用。


image.png


先给个简单总结:



  • 其中StatelessWidget 和 StatefulWidget 起到了组织组合子组件的作用。

  • RenderObjectWidget 起到渲染作用。包含绘制偏移和测量信息。

  • ProxyWidget 可以携带信息,以供其他组件使用。


一、探索StatelessWidget的组件构建


在使用StatelessWidget的时候,通常只需要实现一个build方法。就拿我们常用的Container组件举例,他就是StatelessWidget 的子类。他的build方法返回的就是各种组件的组合嵌套。
img


他的各种成员属性也只是用来配置子组件的组合方式而已。


1. StatelessWidget 的build调用时机,以及widget树遍历流程


Container组件是StatelessWidget的经典子类。


我们通过断点调试看看Container 组件build方法的调用堆栈


img


ComponentElementperformRebuild 方法调用的时候,触发了build方法,从stateless中获取了build返回的Widget,而又在performRebuild 调用了updateChild方法,对所有的子孙Element进行build遍历。



ComponentElement是Widget对应元素StatelessElementStatefulElement的父类。



我们拉到最初的调用栈。Element栈调用的起点在于attachRootWidget方法。


还记得我们flutter app开发的起点吗?就是runApp(App())方法,开启了整个flutter app。
attachRootWidget方法正是我们在调用runApp的时候执行的。


在其中,执行了RenderObjectToWidgetAdapter组件的初始化,将renderViewrootWidget作为入参。并且调用attachToRenderTree返回元素树顶点的Element。


img


三颗树的顶点


其中renderViewRenderObject树的顶点,_renderViewElementElement树的顶点。匿名的RenderObjectToWidgetAdapter则是Widget树的顶点,但是他没有被引用。Widget树的维护依赖于Element树,rootWidget就是我们的runApp组件节点,被作为参数挂载到RenderObjectToWidgetAdapter根组件中,被后续的Element挂载循环使用。


Element中也存放了_parent变量,所以我们通过Element对象可以轻松的追溯到祖先节点。


img


我们从上面的分析可以得出ComponentElement 的 performRebuild方法是element.build传承关键方法 ,mount方法也能由此挂载出所有子树(其他类型的Element实现方案略有不同)


在ComponentElement中。也由performRebuild构建出一层层的子孙节点。代码如下,注意红色方框的代码。


img


第一个红框中是build()方法的执行。意味着每次performRebuild被调用的时候,子组件都会被build出来,由此可知widget是唯一的,每次更新都会有新的Widget生成。


updateChild的过程中,如果子element还未生成,就会调用widget.createElement()方法获得element


我们再看StatelessWidget 的源码,实现了createElement方法返回了自定义的StatelessElement


img


生成的子Element 都会在ComponentElement中被持有,以便后续更新


img


由此可知,ComponentElement维系了祖孙关系,其子类Element对应的 StatelessWidget,StatefulWidget,ParentDataWidget 和 InheritedWidget都天然拥有子孙关系能力。


如下所示,StatefulElementComponentElement 的子类。
img


2. StatelessWidget 和Element在渲染中的更新


widget的创建都是在element树遍历的过程中执行的。
widget树依赖于element树,在Element创建的时候widget实例将会被持有。
StatelessWidget在布局和渲染流程中依赖Element维系,树关系被Element挖掘。
img


Element performeRebuild重新构建的时候,有一个是否更新Element的判定机制,以优化性能。
不管是更新update还是挂载mount,每次子widget都会先build()出来。再进行新旧比较。Widget都是一次性的,如果有状态需要保存是由其他方式实现的。
我们再看updateChild方法。上面一小节提到在子element为空的时候,会在其中createElement。而在子Element不为空的时候,会根据新旧Widget 的不同,进行不同的操作。
img


其中通过新旧widgetequals判定。决定是否复用之前的element。如果复用了element,根据canUpdate方法的返回值,来执行child.update方法。所以我们可以得出这样一个结论。


widgetcanUpdate 实现,将很大程度上决定 Element 的复用。减少重新绘制,对State重新赋值,甚至状态丢失的资源浪费。


3. 探索key的作用


canUpdate的默认实现中以Widget的类型和key作为关键字进行判断。如果有对key定义,那么Key的一致性就会对widget的更新显得尤为关键。


这也是我们在做性能优化的时候需要注意的。可以利用Key的配置,来控制组件是否需要更新。


static bool canUpdate(Widget oldWidget, Widget newWidget) {
return oldWidget.runtimeType == newWidget.runtimeType
&& oldWidget.key == newWidget.key;
}
复制代码

Key的几种子类基本上都是根据需求,对== 操作符做不同的实现。以更好的自定义 canUpdate 的结果。


其中GlobalKey比较特殊。作为全局的唯一秘钥。提供了对应 widgetBuildContextwidget 的访问方式。并针对 StatefulWidget。还提供了 State 的访问。


以便用户对状态进行全局的更新。比如我们需要在外部使用 BuildContext 进行初始化的时候,可以进行这样调用


img


4. 小结


通过以上对StatelessWidgetComponentElement 的分析,可以得出以下的判断。
StatelessWidget 基于 ComponentElement。主要功能就是提供了组合各种widget的能力,并维持了祖孙的build传承。


当然在探索当中也发现了一些技术债务,由于我们已经知道了statelesswidget的使用场景,对于具体的源码细节先按下不表,在此只记录



  • 生命周期_lifecycleState 起到什么作用

  • _dirty 标记和 markNeedsBuild 的用法和原理是什么

  • BuildOwner 的作用是什么


二、探索StatefulWidget的动态刷新机制


StatefulWidgetStateflessWidget 有很多共同之处。最主要的原因就是他们创建的元素都是ComponentElement的子类,其提供了widget子孙build传承的能力。


可知StatefulWidgetStateflessWidget一样,也是一个有能力组合各种widget的组件。


1. State生命周期分析


StatefulWidget 定义了createState方法。提供了状态刷新能力。
img


再次从StatefullElementbuild方法入手。直接调用了state.build(this)。代理了state的构建行为。


performRebuild方法中也进行了state.didChangeDependencies生命周期回调。


img


在State中,除了生命周期方法外, 最重要的就是build方法了。作用和StatelessWidget的build方法一致。都是提供了组合widget的能力。
initState则给用户提供了初始化state状态的机会。断点调试看看调用栈如何。


img


调试中直观看到,在firstBuld的时候,stateinitState被调用。并在之后调用了didChangeDependencies生命周期方法,和build方法。


img


代码中也对方法做了限制,不可以返回Future类型。
所以我们可以在initState中放心做一些初始化工作,没有异步参与,工作将会在build之前完成。


2. setState方法刷新页面方式分析


对于setState方法。除开生命周期的判断之外,关键代码只有一句,就是调用了element 的markNeedsBuild()
img


该方法将对应的element标记为dirty。并且调用owner``!.scheduleBuildFor(``this``);将其加入到 BuildOwner的脏列表(_dirtyElements)中。
将会在下次帧刷新的时候调用BuildOwner.owner.buildScope 重新构建该列表中的元素。


3. 小结


StatelessWidget给使用者提供了一个便捷的布局刷新入口,我们可以利用setState刷新布局。该方法会将对应Element标记为待刷新元素,在下次帧刷新的时候重建布局。状态的改动将会被重建的布局重新获取。


三、探索SingleChildRenderObjectWidget


SingleChildRenderObjectWidget对应的元素类是SingleChildRenderObjectElement
我们作为开发者,布局过程中SingleChildRenderObjectWidget 的子类使用频率非常频繁,布局的约束,偏移和渲染都是由RenderObjectWidget 实现的,SingleChildRenderObjectWidget继承了RenderObjectWidget的渲染能力,并提供了单子传承的能力。布局的过程中该对象的子类不可或缺,flutter框架中也有不少对应的实现类。


Flutter 框架中实现的SingleChildRenderObjectWidget有以下几种。



  1. SizedBox

  2. LimitedBox

  3. ShaderMask

  4. RotatedBox

  5. SizedOverflowBox

  6. Padding

  7. ...


1. 探索SingleChildRenderObjectElement中对于子widget的挂载和更新


SingleChildRenderObjectElement`的`mount` 和 `update`方法都很简单,都是直接调用了`updateChild`方法,传进去的子widget直接是`widget.child

img


这个方法和ComponentElement基本上一样,都是利用canUpdate的结果进行更新或者是创建子Element


1. 以Padding为例了解RenderObjectWidget 的布局和绘制实现。


名词解释


RenderObject:渲染对象,flutter对象布局的约束,绘制,位移全是由该对象实现,RenderObject树的祖孙中传递着约束,以做到布局大小的传承影响。


RenderObject的创建


RenderObjectWidget 会在mount挂载的时候,创建RenderObject,直接调用widge.createRenderObject。我们的约束,绘制,位移全是由RenderObject传递和实现的。


img


RenderPadding的布局实现


Padding为例。createRenderObject创建了RenderPadding实例,widget的成员原封不动交给了该实例。


img


约束(BoxConstraint)是Flutter确定布局大小的方案,各种RenderObject对于约束的传递都有自己的实现。


下方是RenderPaddingperformLayout代码。红框标记起来的代码中就展示了Padding的约束传承逻辑。
其父布局传给自己约束基础上减去Padding再传递给子RenderObject


观察performLayout方法可以发现,该方法完成了约束的传递,计算了偏移量Offset,并确定了自己的大小。


img


确定大小约束之后,就会在paint中绘制自己和子孙。RenderPadding没有自定义绘制,直接使用了父类RenderShiftedBox的实现。RenderShiftedBox 提供了offset偏移。在绘制子renderObject的时候,为其施加绘制偏移量。有些需要计算子布局偏移的widget,如PaddingAlign等,都对RenderShiftedBox进行了实现。
img


可以看到子布局的offset存在他的parentData中。PaddingRender使用的parentDataBoxParentData,内部提供了offset变量以供父布局使用。


/// Parent data used by [RenderBox] and its subclasses.
class BoxParentData extends ParentData {
/// The offset at which to paint the child in the parent's coordinate system.
Offset offset = Offset.zero;
@override
String toString() => 'offset=$offset';
}

所有的RenderBox都持有BoxParentData对象,用于存储位移信息,在setUpPrentData的时候进行的初始化。红框中的代码展示了这一细节。


img


到此,就能了解RenderObject是如何被约束BoxConstraint,如何被布局layout,以及如何被绘制paint


1. RenderObjectElement的传承方式


RenderObjectElement 的父子传承在两个子类中实现,在第1小结中已经提到SingleChildRenderObjectWidgetComponentElement十分类似,只是直接把widget.child拿来传承,而不再提供build方法以供子组件组合。


MultiChildRenderObjectElement 也类似,只不过作为多子组件,三棵树分叉的主要因子,维护的是children 列表。
img


在mount 和 update 的时候,子孙组件会像点了爆竹一样被逐一构建和更新。


1. 小结


每个SingleChildRenderObjectWidget组件都实现了各自的布局和绘制方案,也各自处理了约束并传递下去。


比如ColordBox作为绘制组件,借助了RenderColord,绘制了自身颜色,约束则取得是父约束的最小值。Align作为定位组件,借助了RenderPositionedBox,布局的时候计算了对应的偏移量offset,在绘制子布局的时候使用,约束则在传递的时候转了松约束。


诸如此类,所有组件都利用了对应的RenderObject满足了各自布局和渲染的所有需求。我们自己当然也可以自定义对应的RenderObject实现自己的布局。
MultiChildRenderObjectWidgetSingleChildRenderObjectWidget类似,只是维护一个子widget变成了多个子widget。


他的RenderObject基本上都是ContainerRenderObjectMixinRenderBox的子类,内部维护了头尾两个子节点,并利用存储在parentData中的双相链表维护所有的子RenderObject


四、谈谈ProxyWidget


最后稍微提一下ProxyWidgetProxyElement也上ComponentElement的子类。和StatefulWidget 以及StatelessWidget是兄弟关系。也有子孙维系的能力,只不过他的build方法是固定的,返回的就是child。
UML 图.jpg


1. InheritedWidget


我们获取 Theme,MediaQuery数据的时候,都是使用了InheritedWidget


MediaQuery.of(context).size.width;
Theme.of(context).appBarTheme;

通过context 也就是Element实例,获取祖先节点的数据。实现数据共享的效果。
Element中维护了祖先的所有InheritedElement映射,就可以在需要的时候直接通过子孙Element获取。


2. ParentDataWidget


ParentDataWidget提供了子组件向父组件传递渲染信息的能力。
FlexiblePositioned 等组件都是ParentDataWidget 的子类。


需要注意的是:ParentDataWidget只用于渲染信息的传递


在Element.attachRenderObject的时候会调用updateParentData,然后会辗转调用到对应的ParentDataWidget.applyParentData。可以看出只有子组件是RenderObjectWidget子类的时候才会应用对应的ParentDataWidget传递信息。


img


由此可知,只有在子节点渲染的时候,才会应用RenderObject的数据传递赋值。
img


子节点的ParentData对象由父布局创建代码如下,创建时机在子节点插入的时候执行。
img


img


最后


作为开发者,很多时候完成一个任务只会建立在使用的层面。对于为什么这么使用往往不甚了解。
如果我们能更多的学习他的原理。那么如果在开发中碰到问题,我们能够更加得心应手得去解决。
flutter布局渲染的原理以前总是一层雾蒙在我地眼前。但现在,终于有一片薄雾散去,内部轮廓在我面前变得清晰。
坚持学习,见识真实的世界。


小试


我们最后尝试一下一个简单地布局,分析其三棵树结构。嵌套结构如下。其中builderStatelessWidgetColumnMultiChildRenderObjectWidget其他都是SingleChildRenderObjectWidget


void main() {
runApp(Builder(builder: (context) {
return Column(
mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Center(
child: SizedBox(
width: 100,
height: 100,
child: ColoredBox(color: Colors.blue),
),
),
Expanded(
child: ColoredBox(color: Colors.red),
),
],
);
}));
}

展示出来的样式如下。


img

分析得出的三棵树如下,源头从RenderView而起,然后构建出RenderObjectToWidgetAdapter,再构建出RootRenderObjectElement。由此从根开始三棵树的循环,直到叶子节点。


RenderObjectWidget并非一一对应,只有RenderObjjectWidget才有,但是RenderObject能自动找出自己的组件RenderObjject 自动插入到其child中,所以也能自动成树。


流程图.jpg


至此,我们的Widget初步了解完结。


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

Flutter中如何独立绘制每一个像素点?

Flutter中如何独立绘制每一个像素点? 前提 前一阵我参照教程:GAMEBOY 仿真器 做了一个game boy模拟器,经过漫长的调试,终于成功的在电脑上运行了起来,但作为一个移动端开发者,我最终还是想要在手机上运行,在经过一番研究后,我卡在了第一个难点:...
继续阅读 »

Flutter中如何独立绘制每一个像素点?


前提


前一阵我参照教程:GAMEBOY 仿真器 做了一个game boy模拟器,经过漫长的调试,终于成功的在电脑上运行了起来,但作为一个移动端开发者,我最终还是想要在手机上运行,在经过一番研究后,我卡在了第一个难点:在Flutter中如何单独绘制每一个像素点呢?


Gamyboy的显示器尺寸是160 * 144,素点的格式是RGB,模拟器大概每隔16ms生成一帧画面,当模拟器运行时,我就能源源不断的拿到每一帧的像素数据(这里的像素数据可以看做是一个int32数组,长度为160 * 144),我要做的就是找到一种方法,将每一帧的像素数据绘制到屏幕上。一番搜素后,终于在How do I Render Individual Pixels in Flutter?上找到了答案


绘制像素点


想要直接绘制原始的像素点,需要用到Canvas的一个方法:


/// Draws the given [Image] into the canvas with its top-left corner at the
/// given [Offset]. The image is composited into the canvas using the given [Paint].
void drawImage(Image image, Offset offset, Paint paint)

从方法签名中可以看出,canvas绘制的是一个Image对象,它持有原始的像素数据,所以我们需要先将像素数据转换成Image对象,可以使用decodeImageFromPixels方法


/// Convert an array of pixel values into an [Image] object.
///
/// The `pixels` parameter is the pixel data in the encoding described by
/// `format`.
/// ...
void decodeImageFromPixels(
Uint8List pixels,
int width,
int height,
PixelFormat format,
ImageDecoderCallback callback, {
int? rowBytes,
int? targetWidth,
int? targetHeight,
bool allowUpscaling = true,
})


  • pixels是一个一维数组,每一个元素是一个字节

  • widthheight代表图片的宽和高

  • format用来设置像素点的格式,比如:PixelFormat.rgba8888表示一个像素点由四个字节组成,分别表示红,绿,蓝,透明度等信息

  • callback为图片解码完成后的回调函数,函数参数为最终生成的Image对象


/// Callback signature for [decodeImageFromList].
typedef ImageDecoderCallback = void Function(Image result);

至此,整个流程已经走通,共分为三步:



  1. 生成像素数据

  2. 调用decodeImageFromPixels方法将像素数据转换为Image对象

  3. 调用CanvasdrawImage方法绘制像素数据


读到这里,有些朋友可能会有疑惑,我从哪里去获取Canvas对象呢? 如何做到实时更新每一帧画面呢?接下来,我将用一个案例将整个流程串起来


演示案例



作为演示,这里用生成的雪花噪点数据来代替模拟器生成的像素数据,完整的案例请看:github.com/hcoderLee/f…



首先我们需要一个不断生成像素数据的类


import 'dart:ui' as ui;

class Emulator {
/// 每一帧生成的像素所对应的Image对象
ui.Image? _image;

ui.Image? get image => _image;

bool _isRunning = false;
Timer? _timer;

/// 用于生成雪花噪点数据
int xorshift32(int x) {
x ^= x << 13;
x ^= x >> 17;
x ^= x << 5;
return x;
}

int seed = 0xDEADBEEF;

/// 生成原始像素数据,并转换为Image对象
Future<ui.Image> makeImage() {
final c = Completer<ui.Image>();
final pixels = Int32List(lcdWidth * lcdHeight);
for (int i = 0; i < pixels.length; i++) {
seed = pixels[i] = xorshift32(seed);
}
void decodeCallback(ui.Image image) {
c.complete(image);
}

// 将像素数据转换为Image对象
ui.decodeImageFromPixels(
pixels.buffer.asUint8List(),
lcdWidth,
lcdHeight,
ui.PixelFormat.rgba8888,
decodeCallback,
);
return c.future;
}

/// 不断的生成每一帧的画面
void run() {
if (_isRunning) {
return;
}
_isRunning = true;

_timer?.cancel();
/// 每隔16ms(更新一帧的时间)更新一次画面
_timer = Timer.periodic(const Duration(milliseconds: 16), (timer) async {
final newImage = await makeImage();
_image?.dispose();
_image = newImage;
});
}

void dispose() {
_timer?.cancel();
_timer = null;
_image?.dispose();
}
}

当有了Image对象后,需要调用CanvasdrawImage方法来绘制,这里使用CustomPaint组件来获取Canvas对象:


CustomPaint(
painter: _LCD(
emulator: _emulator,
timer: _timer,
),
);

class _LCD extends CustomPainter {
final Emulator emulator;

_LCD({
required this.emulator,
required _Timer timer,
}) : super(repaint: timer);

@override
void paint(ui.Canvas canvas, ui.Size size) {
final image = emulator.image;
if (image != null) {
canvas.drawImage(image, Offset.zero, Paint());
}
}

@override
bool shouldRepaint(covariant CustomPainter oldDelegate) {
return true;
}
}

CustomPaint组件有一个重要的参数: painter, 它是一个CustomPainter对象,我们可以自定义一个类去继承CustomPainter, 实现paint方法,获取到Canvas对象,至此,我们可以利用canvas去绘制任何我们想要的东西


这里我们定义了_LCD去继承CustomPainter,它持有Emulator对象,从而获取要绘制的Image对象,这里有两个地方需要重点关注一下:




  1. shouldRepaint方法表示如果上层组件发生重建,生成了新的CustomPaint对象,是否需要重新调用paint方法绘制内容,因为我的需求是每一帧都要绘制新的画面,所以这里直接返回true(表示需要重新调用paint方法),真实的业务场景需要根据具体情况去判断是否返回true




  2. 构造函数中有一个_Timer对象,并调用了super(repaint: timer),那么这个_Timer对象是用来做什么的呢?




查看CustomPainter的文档,有这么一段说明:


/// The painter will repaint whenever `repaint` notifies its listeners.

repaint会通知CustomPainter去重新绘制画面,我们再查看repaint的类型是Listenable,这里我们自定义一个_Timer类,用来在每一帧更新的时候去通知CustomPainter重绘


class _Timer extends ChangeNotifier {
final TickerProvider _vsync;
late final Ticker _ticker;

_Timer(this._vsync) {
_ticker = _vsync.createTicker(_onTick);
_ticker.start();
}

void _onTick(Duration elapsed) {
notifyListeners();
}

@override
void dispose() {
_ticker.stop();
_ticker.dispose();
super.dispose();
}
}

这里我们继承了ChangeNotifier,因此间接继承了Listenable
在构造函数里创建了一个Ticker对象,用来获取每一帧更新的时机,_onTick方法会在每一帧更新的时候调用,并通知CustomPaint去重绘。实际上,Flutter的动画也是使用Ticker对象,在每一帧更新的时候触发组件重绘
为了创建Ticker对象,需要用到TickerProvider对象,它提供了创建Ticker对象的方法(createTicker),并确保onTick回调函数只在组件处在前台活跃状态的时候才触发。为了获得TickerProvider对象,最常用的做法是创建一个StatefullWidget,并给State添加SingleTickerProviderStateMixin mixin,如果大家写过动画相关的代码,对这一套应该不陌生:


class _GameView extends StatefulWidget {
const _GameView({Key? key}) : super(key: key);

@override
State<_GameView> createState() => _GameViewState();
}

class _GameViewState extends State<_GameView>
with SingleTickerProviderStateMixin {
late final Emulator _emulator;
/// 在每一帧更新的时候去通知_LCD重绘
late final _Timer _timer;

@override
void initState() {
super.initState();
_emulator = Emulator();
// 运行模拟器,不断的产生每一帧的像素数据
_emulator.run();
_timer = _Timer(this);
}

@override
Widget build(BuildContext context) {
return SizedBox(
width: lcdWidth.toDouble(),
height: lcdHeight.toDouble(),
child: CustomPaint(
painter: _LCD(
emulator: _emulator,
timer: _timer,
),
),
);
}

@override
void dispose() {
_emulator.dispose();
_timer.dispose();
super.dispose();
}
}

至此,我们已经完成了所有的步骤,看一下运行效果:


1659887972838472.gif


总结


本文讲述了Flutter中如何实时绘制自己生成的像素数据,有以下几个步骤:



  1. 生成像素数据

  2. 调用decodeImageFromPixels方法将像素数据转换为Image对象

  3. 调用CanvasdrawImage方法绘制像素数据

  4. 使用Ticker对象获取每一帧更新的时机,并通知CustomPainter去重绘


如果有错误,还请大家帮忙指正, 希望能够对大家有所帮助


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

React 官网为什么那么快?

web
当我们打开 React 官网时,会发现从浏览器上输入url 到页面首屏完全展示这一过程所花的时间极短,而且在页面中点击链接切换路由的操作非常顺滑,几乎页面可以达到“秒切”的效果,根本不会有卡顿等待的情况发生,于是带着“react官网到底是怎么做的”疑问开始了本...
继续阅读 »

当我们打开 React 官网时,会发现从浏览器上输入url 到页面首屏完全展示这一过程所花的时间极短,而且在页面中点击链接切换路由的操作非常顺滑,几乎页面可以达到“秒切”的效果,根本不会有卡顿等待的情况发生,于是带着“react官网到底是怎么做的”疑问开始了本次探索,发现其主要用了以下的优化手段


静态站点生成 SSG


下面是react官方中文文档首页的截图,大家注意下方的红色区域,后面会作为推断的一个理由



当我们打开控制台之后,点击network并选择 DOC文档请求,就会发现有一个请求路径为https://zh-hans.reactjs.org/GET请求,响应结果为一个 html文档,里面刚好能找到对应上图中红色区域文字的文本,这也就佐证了这个html文档所对应的页面就是react官网首页,而这种渲染页面的方式只有两种,一种是服务端渲染 SSR,还有一种是静态站点生成 SSG



很多人总是分不清客户端渲染CSR、服务端渲染SSR还有静态站点生成SSG,下面我们简单介绍一下它们各自的特点,看完之后相信你就能清晰的感受到它们的区别所在了


页面的渲染流程


在开始之前,我们先来回顾一下页面最基本的渲染流程是怎么样的?



  • 浏览器通过请求得到一个 HTML文本

  • 渲染进程解析 HTML 文本,构建 DOM

  • 浏览器解析 HTML 的同时,如果遇到内联样式或者样本样式,则下载并构建样式规则(stytle rules)。若遇到 Javascript 脚本,则会下载并执行脚本

  • DOM 树和样式规则构建完成之后,渲染进程将两者合并成渲染树(render tree

  • 渲染进程开始对渲染树进行布局,生成布局树(layout tree

  • 渲染进程对布局树进行绘制,生成绘制记录

  • 渲染进程对布局树进行分层,分别栅格化每一层并得到合成帧

  • 渲染进程将合成帧发送给 GPU 进程将图像绘制到页面中



可以看到,页面的渲染其实就是浏览器将HTML文本转化为页面帧的过程,下面我们再来看看刚刚提到的技术:


客户端渲染 CSR


如今我们大部分 WEB 应用都是使用 JavaScript 框架(VueReactAngular)进行页面渲染的,页面中的大部分DOM元素都是通过Javascript插入的。也就是说,在执行 JavaScript 脚本之前,HTML 页面已经开始解析并且构建 DOM 树了,JavaScript 脚本只是动态的改变 DOM 树的结构,使得页面成为希望成为的样子,这种渲染方式叫动态渲染,也就是平时我们所称的客户端渲染 CSRclient side render


下面代码为浏览器请求 react 编写的单页面应用网页时响应回的HTML文档,其实它只是一个空壳,里面并没有具体的文本内容,需要执行 JavaScript 脚本之后才会渲染我们真正想要的页面


<!doctype html>
<html lang="en">

<head>
<meta charset="utf-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="Web site created using create-react-app" />
<link rel="apple-touch-icon" href="/logo192.png" />
<link rel="manifest" href="/manifest.json" />
<title>Jira任务管理系统</title>
<script
type="text/javascript">!function (n) { if ("/" === n.search[1]) { var a = n.search.slice(1).split("&").map((function (n) { return n.replace(/~and~/g, "&") })).join("?"); window.history.replaceState(null, null, n.pathname.slice(0, -1) + a + n.hash) } }(window.location)</script>
<link href="/static/css/2.4ddacf8e.chunk.css" rel="stylesheet">
<link href="/static/css/main.cecc54dc.chunk.css" rel="stylesheet">
</head>

<body><noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script>!function (e) { function r(r) { for (var n, a, i = r[0], c = r[1], f = r[2], s = 0, p = []; s < i.length; s++)a = i[s], Object.prototype.hasOwnProperty.call(o, a) && o[a] && p.push(o[a][0]), o[a] = 0; for (n in c) Object.prototype.hasOwnProperty.call(c, n) && (e[n] = c[n]); for (l && l(r); p.length;)p.shift()(); return u.push.apply(u, f || []), t() } function t() { for (var e, r = 0; r < u.length; r++) { for (var t = u[r], n = !0, i = 1; i < t.length; i++) { var c = t[i]; 0 !== o[c] && (n = !1) } n && (u.splice(r--, 1), e = a(a.s = t[0])) } return e } var n = {}, o = { 1: 0 }, u = []; function a(r) { if (n[r]) return n[r].exports; var t = n[r] = { i: r, l: !1, exports: {} }; return e[r].call(t.exports, t, t.exports, a), t.l = !0, t.exports } a.e = function (e) { var r = [], t = o[e]; if (0 !== t) if (t) r.push(t[2]); else { var n = new Promise((function (r, n) { t = o[e] = [r, n] })); r.push(t[2] = n); var u, i = document.createElement("script"); i.charset = "utf-8", i.timeout = 120, a.nc && i.setAttribute("nonce", a.nc), i.src = function (e) { return a.p + "static/js/" + ({}[e] || e) + "." + { 3: "20af26c9", 4: "b947f395", 5: "ced9b269", 6: "5785ecf8" }[e] + ".chunk.js" }(e); var c = new Error; u = function (r) { i.onerror = i.onload = null, clearTimeout(f); var t = o[e]; if (0 !== t) { if (t) { var n = r && ("load" === r.type ? "missing" : r.type), u = r && r.target && r.target.src; c.message = "Loading chunk " + e + " failed.\n(" + n + ": " + u + ")", c.name = "ChunkLoadError", c.type = n, c.request = u, t[1](c) } o[e] = void 0 } }; var f = setTimeout((function () { u({ type: "timeout", target: i }) }), 12e4); i.onerror = i.onload = u, document.head.appendChild(i) } return Promise.all(r) }, a.m = e, a.c = n, a.d = function (e, r, t) { a.o(e, r) || Object.defineProperty(e, r, { enumerable: !0, get: t }) }, a.r = function (e) { "undefined" != typeof Symbol && Symbol.toStringTag && Object.defineProperty(e, Symbol.toStringTag, { value: "Module" }), Object.defineProperty(e, "__esModule", { value: !0 }) }, a.t = function (e, r) { if (1 & r && (e = a(e)), 8 & r) return e; if (4 & r && "object" == typeof e && e && e.__esModule) return e; var t = Object.create(null); if (a.r(t), Object.defineProperty(t, "default", { enumerable: !0, value: e }), 2 & r && "string" != typeof e) for (var n in e) a.d(t, n, function (r) { return e[r] }.bind(null, n)); return t }, a.n = function (e) { var r = e && e.__esModule ? function () { return e.default } : function () { return e }; return a.d(r, "a", r), r }, a.o = function (e, r) { return Object.prototype.hasOwnProperty.call(e, r) }, a.p = "/", a.oe = function (e) { throw console.error(e), e }; var i = this.webpackJsonpjira = this.webpackJsonpjira || [], c = i.push.bind(i); i.push = r, i = i.slice(); for (var f = 0; f < i.length; f++)r(i[f]); var l = c; t() }([])</script>
<script src="/static/js/2.2b45c055.chunk.js"></script>
<script src="/static/js/main.3224dcfd.chunk.js"></script>
</body>

</html>
复制代码

服务端渲染 SSR


顾名思义,服务端渲染就是在浏览器请求页面 URL 的时候,服务端将我们需要的 HTML 文本组装好,并返回给浏览器,这个 HTML 文本被浏览器解析之后,不需要经过 JavaScript 脚本的下载过程,即可直接构建出我们所希望的 DOM 树并展示到页面中。这个服务端组装HTML的过程就叫做服务端渲染 SSR


下面是服务端渲染时返回的 HTML 文档,由于代码量实在是太多,所以只保留了具有象征意义的部分代码,但不难发现,服务端渲染返回的HTML文档中具有页面的核心文本


<!DOCTYPE html>
<html lang="zh-hans">

<head>
<link rel="preload" href="https://unpkg.com/docsearch.js@2.4.1/dist/cdn/docsearch.min.js" as="script" />
<meta name="generator" content="Gatsby 2.24.63" />
<style data-href="/styles.dc271aeba0722d3e3461.css">
/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */
html {
line-height: 1.15;
-webkit-text-size-adjust: 100%
}

/* ....many CSS style */
</style>
</head>

<body>
<script>
(function () {
/*
BE CAREFUL!
This code is not compiled by our transforms
so it needs to stay compatible with older browsers.
*/

var activeSurveyBanner = null;
var socialBanner = null;
var snoozeStartDate = null;
var today = new Date();

function addTimes(date, days) {
var time = new Date(date);
time.setDate(time.getDate() + days);
return time;
}
// ...many js code
})();
</script>
<div id="___gatsby">
<!-- ...many html dom -->
<div class="css-1vcfx3l">
<h3 class="css-1qu2cfp">一次学习,跨平台编写</h3>
<div>
<p>无论你现在使用什么技术栈,在无需重写现有代码的前提下,通过引入 React 来开发新功能。</p>
<p>React 还可以使用 Node 进行服务器渲染,或使用 <a href="https://reactnative.dev/" target="_blank"
rel="nofollow noopener noreferrer">React Native</a> 开发原生移动应用。</p>
</div>
</div>
<!-- ...many html dom -->
</div>

</body>

</html>
复制代码

静态站点生成 SSG


这也就是React官网所用到的技术,与SSR的相同之处就是对应的服务端同样是将已经组合好的HTML文档直接返回给客户端,所以客户端依旧不需要下载Javascript文件就能渲染出整个页面,那不同之处又有哪些呢?


使用了SSG技术搭建出的网站,每个页面对应的HTML文档在项目build打包构建时就已经生成好了,用户请求的时候服务端不需要再发送其它请求和进行二次组装,直接将该HTML文档响应给客户端即可,客户端与服务端之间的通信也就变得更加简单


但读到这里很容易会发现它有几个致命的弱点:



  • HTML文档既然是在项目打包时就已经生成好了,那么所有用户看到的都只能是同一个页面,就像是一个静态网站一样,这也是这项技术的关键字眼——静态

  • 每次更改内容时都需要构建和部署应用程序,所以其具有很强的局限性,不适合制作内容经常会变换的网站


但每项技术的出现都有其对应的使用场景,我们不能因为某项技术的某个缺点就否定它,也不能因为某项技术的某个优点就滥用它!
该技术还是有部分应用场景的,如果您想要搭建一个充满静态内容的网站,比如个人博客、项目使用文档等Web应用程序,使用SSG再适合不过了,使用过后我相信你一定能感受到这项技术的强大之处!


问题解答


现在我们就可以回答为什么react官网要使用SSG这项技术去做了?


因为相对于客户端渲染,服务端渲染和静态网点生成在浏览器请求URL之后得到的是一个带有数据的HTML文本,并不是一个HTML空壳。浏览器只需要解析HTML,直接构建DOM树就可以了。而客户端渲染,需要先得到一个空的HTML页面,这个时候页面已经进入白屏,之后还需要经过加载并执行 JavaScript、请求后端服务器获取数据、JavaScript 渲染页面几个过程才可以看到最后的页面。特别是在复杂应用中,由于需要加载 JavaScript 脚本,越是复杂的应用,需要加载的 JavaScript脚本就越多、越大,这会导致应用的首屏加载时间非常长,从而降低了体验感


至于SSRSSG的选取,我们要从应用场景出发,到底是用户每次请求都在服务端重新组装一个HTML文档?还是在项目构建的时候就生成一个唯一的HTML文档呢?


React团队成员在开发官网的时候肯定早就想到了这个问题,既然是官网,那肯定没有权限之分,所有进入到该网站的人看到的内容应该是一样的才对,那每次请求都在服务端组装一个一模一样的HTML有什么意义呢? 为什么不提前在服务端渲染好,然后发给每个人,这样N次渲染就变成了1次渲染,大大减少了客户端与服务端通信的时间,进而提升了用户体验


总结


无论是哪种渲染方式,一开始都是要请求一个 HTML 文本,但是区别就在于这个文本是否已经被服务端组装好了



  • 客户端渲染还需要去下载和执行额外的Javascript脚本之后才能得到我们想要的页面效果,所以速度会比服务端渲染慢很多

  • 服务端渲染得到的HTML文档就已经组合好了对应的文本,浏览器请求到之后直接解析渲染出来即可,不需要再去下载和执行额外的Javasript 脚本,所以速度会比客户端渲染快很多

  • 对于一些内容不经常变化的网站,我们甚至可以在服务端渲染的基础上予以改进,将每次请求服务端都渲染一次HTML文档改成总共就只渲染一次,这就是静态站点生成技术


下图是客户端渲染和服务端渲染的流程图:



一些预加载/预处理资源的方式


研究完首屏渲染之后,我们再来研究一下路由跳转后内容的切换。经常看 react 文档的朋友可能早就发现了,其路由跳转无比丝滑,感觉就像是一个静态页面一样,完全没有发送网络请求的痕迹,比如我现在处在hook 简介这一个板块,当我点击 hook 规则 目录之后



发现页面瞬间秒切了过去,内容也瞬间展现在了出来,没有一丝卡顿,用户体验直接爆炸,这到底是怎么做到的呢?



下面我们就来一点一点分析它的每个优化手段


preload



在当前页面中,你可以指定可能或很快就需要的资源在其页面生命周期的早期——浏览器的主渲染机制介入前就进行预加载,这可以让对应的资源更早的得到加载并使用,也更不易阻塞页面的初步渲染,进而提升性能



关键字 preload 作为元素 <link> 的属性 rel的值,表示用户十分有可能需要在当前浏览中加载目标资源,所以浏览器必须预先获取和缓存对应资源 。下面我们来看一个示例:


<link as="script" rel="preload" href="/webpack-runtime-732352b70a6d0733ac95.js">
复制代码

这样做的好处就是让在当前页面中可能被访问到的资源提前加载但并不阻塞页面的初步渲染,进而提升性能


下面是 react文档中对 preload关键字的使用,告诉浏览器等等可能需要这个资源,希望能够尽早下载下来



可以预加载的资源有很多,现在浏览器支持的主要有:



  • audio:音频文件,通常用于 audio 标签

  • document: 旨在由 frame 或嵌入的 HTML 文档

  • embed:要嵌入到 embed 元素中的资源

  • fetch:要通过 fetch 或 XHR 请求访问的资源,例如 ArrayBuffer 或 JSON 文件

  • font: 字体文件

  • image: 图像文件

  • object:要嵌入到 object 元素中的资源

  • script: JavaScript 文件

  • style: CSS 样式表

  • track: WebVTT 文件

  • worker:一个 JavaScript 网络工作者或共享工作者

  • video:视频文件,通常用于 video 标签



注意:使用 preload作为 link标签rel属性的属性值的话一定要记得在标签上添加 as属性,其属性值就是要预加载的内容类型



preconnect



元素属性的关键字preconnect是提示浏览器用户可能需要来自目标域名的资源,因此浏览器可以通过抢先启动与该域名的连接来改善用户体验 —— MDN



下面来看一个用法示例:


<link rel="preconnect" href="https://www.google-analytics.com">
复制代码

下面是 react官方文档中的使用:



简单来说就是提前告诉浏览器,在后面的js代码中可能会去请求这个域名下对应的资源,你可以先去把网络连接建立好,到时候发送对应请求时也就更加快速


dns-prefetch



DNS-prefetch (DNS 预获取) 是尝试在请求资源之前解析域名。这可能是后面要加载的文件,也可能是用户尝试打开的链接目标 —— MDN



那我们为什么要进行域名预解析呢?这里面其实涉及了一些网络请求的东西,下面简单介绍一下:


当浏览器从(第三方)服务器请求资源时,必须先将该跨域域名解析为 IP 地址,然后浏览器才能发出请求。此过程称为 DNS 解析。DNS 缓存可以帮助减少此延迟,而 DNS 解析可以导致请求增加明显的延迟。对于打开了与许多第三方的连接的网站,此延迟可能会大大降低加载性能。预解析域名就是为了在真正发请求的时候减少延迟,从而在一定程度上提高性能


用法示例:


<link rel="dns-prefetch" href="https://www.google-analytics.com">
复制代码

下面是 react官方文档中的使用:



通俗点来说,dns-prefetch 的作用就是告诉浏览器在给第三方服务器发送请求之前去把指定域名的解析工作给做了,这个优化方法一般会和上面的preconnect一起使用,这些都是性能优化的一些手段,我们也可以在自己项目中合适的地方来使用


prefetch



关键字 prefetch 作为元素 的属性 rel 的值,是为了提示浏览器,用户未来的浏览有可能需要加载目标资源,所以浏览器会事先获取和缓存对应资源,优化用户体验 ——MDN



上面的解释已经很通俗易懂了,就是告诉浏览器用户未来可能需要这些资源,这样浏览器可以提前获取这些资源,等到用户真正需要使用这些资源的时候一般都已经加载好了,内容展示就会十分的流畅


用法示例:


<link rel="prefetch" href="/page-data/docs/getting-started.html/page-data.json" crossorigin="anonymous" as="fetch">
复制代码

可以看到 react文档在项目中大量使用到了 prefetch来优化项目



那么我们在什么情况下使用 prefetch才比较合适呢?


react文档一样,当你的页面中具有可能跳转到其他页面的路由链接时,就可以使用prefetch 预请求对应页面的资源了


但如果一个页面中这样的路由链接很多呢?那岂不是要大量的发送网络请求,虽然现在流量很便宜,但你也不能那么玩啊!(doge)


React 当然考虑到了这个问题,因为在它的文档中包含有大量的路由链接,不可能全部都发一遍请求,这样反而不利于性能优化,那react是怎么做的呢?


通过监听 Link元素,当其出现到可见区域时动态插入带有prefetch属性值的link标签到HTML文档中,从而去预加载对应路由页面的一些资源,这样当用户点击路由链接跳转过去时由于资源已经请求好所以页面加载会特别快


举个例子,还没有点击下图中划红线的目录时,由于其子目录没有暴露到视图窗口中,所以页面中并没有对应的标签,而当点击了该目录后,其子目录就会展示在视图窗口中,react会自动将暴露出来的路由所对应的数据通过prefetch提前请求过来,这样当用户点击某个子目录的时候,由于已经有了对应的数据,直接获取内容进行展示即可。用这样的方法,我们感受到的速度能不快吗?



下面是我们在network查看到的结果



补充



  1. react官网其实并不完全是由react这个框架进行开发的,能做上述所说的那么多性能优化其实得益于Gatsby这个库


Snipaste_2022-08-08_13-17-23.png



Gatsby 是一个性能很好,开发很自由的,基于 ReactGraphQL 来构建网站的库。一般用于构建静态网站,比如博客、企业官网等,或者说静态内容相对比较多的网站



它在打包的时候就生成了所有页面对应的 HTML文件以及数据文件等,这样当你访问某个页面时,服务端可以直接返回HTML ,另外一方面当页面中有使用 Link 时,会提前加载这个页面所对应的数据,这样点击跳转后页面加载速度就会很快。所以上文中所说的优化手段,其实是 Gatsby帮助实现的,有兴趣的朋友可以去它的官网了解更多相关知识



  1. 至于这个监听Link元素是怎么实现的呢?


具体实现是使用 Intersection Observer ,相关介绍见 IntersectionObserver API 使用教程 - 阮一峰的网络日志 ,有写到图片懒加载和无限滚动也可以使用这个 API 去实现,只不过现在有个别浏览器还没有支持,所以在兼容性上存在一些阻拦,导致这个 Api现在还没有被普及


参考


本篇文章参考了以下几篇文章并结合上了自己的理解,下面文章个人觉得质量真的很高,大家也可以去看看。另外大家在文章中如果发现问题可以在评论区中指出,大家共同进步~


github.com/findxc/blog…


github.com/findxc/blog…


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

互联网打工人9大美德

打工人的美德:贞洁:单纯不想上班勤奋:每天都不想上班慷慨:邀请同事一起摆烂温和:被领导骂完继续上班节制:一天只有23小时不想上班宽容:等发完工资再离职自信:公司没了我马上倒闭勇敢:不干了,你把我开了吧认真:摸鱼一目十行来源:网络
打工人的美德:
贞洁:单纯不想上班
勤奋:每天都不想上班
慷慨:邀请同事一起摆烂
温和:被领导骂完继续上班
节制:一天只有23小时不想上班
宽容:等发完工资再离职
自信:公司没了我马上倒闭
勇敢:不干了,你把我开了吧
认真:摸鱼一目十行










来源:网络

Java转Android:第8天 Service帮你背诵古诗词

一、我讲 今天,我主要讲一下android里面的服务Service,以及它的使用方法。 1.1 服务 Service Service其实是Android的四大组件之一。 安卓有四大组件,前面我们说的Activity和BroadCastReceiver,其实都属...
继续阅读 »

一、我讲


今天,我主要讲一下android里面的服务Service,以及它的使用方法。


1.1 服务 Service


Service其实是Android的四大组件之一。


安卓有四大组件,前面我们说的ActivityBroadCastReceiver,其实都属于四大组件。你看,我没有一上来就讲让你先记住四大组件,而是慢慢渗透,因为没有例子的应用,记那些没有意义。


Android中,能看到的属于Activity,看不到的属于Service


image3.png


Service的运行不依赖于任何用户界面,即使程序被切换到后台,或者用户打开了另一个应用程序,Service仍然能够保持正常运行。


1.2 Service的使用


创建


可选中包名,右键点击菜单,输入你的服务的名称进行创建。


image.png


其实,你这步操作,影响了两处代码。


第一,新建了一个继承Service的类。


public class MyService extends Service {

@Override
public void onCreate() {
super.onCreate();
}

@Override
public int onStartCommand(final Intent intent, int flags, int startId) {
// 巴拉巴拉
return super.onStartCommand(intent, flags, startId);
}

@Override
public void onDestroy() {
super.onDestroy();
}
}

当服务每次启动时,都会执行onStartCommand方法,所以我们终点会把逻辑写到这里面。


第二,在AndroidManifest.xml中做了注册。


<?xml version="1.0" encoding="utf-8"?>
<manifest>
<application>
……
<service
android:name=".MyService"
android:enabled="true"
android:exported="true"></service>

</application>
</manifest>

启动和停止


服务的启动和停止,一般通过IntentActivity中进行。


Intent intent = new Intent(this, MyService.class);
startService(intent);
stopService(intent);

通过Intent构建要提供哪一个服务,然后调用startService启动服务,stopService停止服务。


二、你做


我们要搞一个这样的功能:



启动服务后,在服务里面循环播放古诗,退出程序也不打断,看其他应用也不打断。这样,能播放一整天,你看抖音时也弹。这导致你,无奈地就学会了一首诗。



image8.GIF


虽然,我们说服务没有界面,但是启动它要界面,我们写在MainActivity中,它的布局是activity_main.xml,就两个按钮,一个启动,一个关闭。


<?xml version="1.0" encoding="utf-8"?>
<ConstraintLayout tools:context=".MainActivity">
<Button
android:id="@+id/button1"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="启动服务"
android:onClick="start" />
<Button
android:id="@+id/button2"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="关闭服务"
android:onClick="stop" />
</ConstraintLayout>

又看到了熟悉的onClick=""这个我们之前讲过


对应下面的逻辑控制代码,里面有服务的启动和停止。


public class MainActivity extends Activity {

Intent intent;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
intent = new Intent(this, MyService.class);
}


public void start(View view) {
startService(intent);
}

public void stop(View view) {
stopService(intent);
}
}

最后,也是重点,就是我们的服务实现逻辑。


先定义一个字符串数组texts用于存储每一句诗词。


然后在onStartCommand里,启动一个线程。


线程里有一个循环,每休眠3秒钟,就往外发送一条消息。


这条消息由Handler发送和接收,接收到后切换诗句,然后通过Toast弹出来。


public class MyService extends Service {

String[] texts = new String[]{"窗前明月光","疑是地上霜","举头望明月","低头思故乡"};
int index = 0;
boolean isRun = true;

……

@Override
public int onStartCommand(final Intent intent, int flags, int startId) {

new Thread(){

@Override
public void run() {
while (isRun){
if (index+1 > texts.length){
index = 0;
}
handler.sendEmptyMessage(index);
sleep(3000);
index++;
}
}
}.start();

return super.onStartCommand(intent, flags, startId);
}

Handler handler = new Handler(){
@Override
public void handleMessage(Message msg) {
super.handleMessage(msg);
Toast.makeText(MyService.this, ""+texts[msg.what], Toast.LENGTH_SHORT).show();
}
};

@Override
public void onDestroy() {
super.onDestroy();
isRun = false;
}
}

需要注意,isRun是控制是否还循环,如果为false了,while循环就不执行了,线程结束。


另外,Service也是有生命周期的,这个生命周期和Activity的生命周期类似,当 onDestroy() 销毁时,需要做一些收尾工作。


好了,最后点击运行,启动服务,去看看效果吧。


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

发布Android库至Maven Central详解

最近,使用compose编写了一个类QQ的image picker。完成android library的编写,在此记录下发布这个Library到maven central的流程以及碰到的问题。 maven:mvnrepository.com/artifact/...
继续阅读 »

最近,使用compose编写了一个类QQ的image picker。完成android library的编写,在此记录下发布这个Library到maven central的流程以及碰到的问题。


maven:mvnrepository.com/artifact/io…


github:github.com/huhx/compos…


Sonatype 账号


MavenCentral 和 Sonatype 的关系

















库平台运营商管理后台
MavenCentralSonatypes01.oss.sonatype.org

因此我们要发布Library到Maven Central的话,首先需要Sonatype的账号以及权限。


申请 Sonatype 账号


申请账号地址: issues.sonatype.org/secure/Sign…


登录账号创建issue


创建issue地址:issues.sonatype.org/secure/View…


image-20220805223118398.png


点击 Create 按钮, 然后会弹出 Create Issue的窗口:


image-20220805224204197.png


点击Configure Fields, 选择 Custom 选项


image-20220805224557793.png



grouId的话最好使用: io.github.github_name, 要不然使用其他的还需要在 DNS 配置中配置一个TXT记录来验证域名所有权



填写完所有的信息点击创建,一个新的issue就创建成功了,以下就是我创建的issue,附上链接:issues.sonatype.org/browse/OSSR…


image-20220805225725812.png


值得注意的是sonatype要求我们创建一个github仓库来验证我们的github账号。创建完仓库之后,我们回复热心的工作人员,接下来就是等他们的处理结果了。大概30分钟就能好吧


image-20220805230217988.png
收到这样的回复,代表一切ready了你可以上传package到maven central


编写gradle脚本上传Lib


这篇文章里面,我是使用的android library做例子的。如果你想要发布java的Library,可以参考:docs.gradle.org/current/use…


In module project, build.gradle file


// add maven-publish and signing gradle plugin
plugins {
id 'maven-publish'
id 'signing'
}

// add publish script
publishing {
publications {
release(MavenPublication) {
pom {
name = 'Image Picker Compose'
description = 'An Image Picker Library for Jetpack Compose'
url = 'https://github.com/huhx/compose_image_picker'

licenses {
license {
name = 'The Apache License, Version 2.0'
url = 'http://www.apache.org/licenses/LICENSE-2.0.txt'
}
}

developers {
developer {
id = 'huhx'
name = 'hongxiang'
email = 'gohuhx@gmail.com'
}
}

scm {
connection = 'https://github.com/huhx/compose_image_picker.git'
developerConnection = 'https://github.com/huhx/compose_image_picker.git'
url = 'https://github.com/huhx/compose_image_picker'
}
}

groupId "io.github.huhx"
artifactId "compose-image-picker"
version "1.0.2"

afterEvaluate {
from components.release
}
}
}
repositories {
maven {
url "https://s01.oss.sonatype.org/service/local/staging/deploy/maven2/"
credentials {
username ossrhUsername // ossrhUsername is your sonatype username
password ossrhPassword // ossrhUsername is your sonatype password
}
}
}
}

// signing, this need key, secret, we put it into gradle.properties
signing {
sign publishing.publications.release
}

ossrhUsernameossrhPassword 是我们在第一步注册的sonatype账号。用户名和密码是敏感信息,我们放在gradle.properties并且不会提交到github。因此在 gradle.properties文件中,我们添加了以下内容:


# signing information
signing.keyId=key
signing.password=password
signing.secretKeyRingFile=file path

# sonatype account
ossrhUsername=username
ossrhPassword=password

其中包含了签名的三个重要信息,这个我们会在下面详细讲解


创建gpg密钥


我使用的是mac,这里就拿mac来说明如何创建gpg密钥。以下是shell脚本


# 安佳 gpg
> brew install gpg

# 创建gpg key,过程中会提示你输入密码。
# 记住这里要输入的密码就是上述提到你需要配置的signing.password
> gpg --full-gen-key

# 切换目录到~/.gnupg/openpgp-revocs.d, 你会发现有一个 .rev文件。
# 这个文件名称的末尾8位字符就是上述提到你需要配置的signing.keyId
> cd ~/.gnupg/openpgp-revocs.d && ls

# 创建secretKeyRingFile, 以下命令会创建一个文件secret.gpg
# 然后~/.gnupg/secret.gpg就是上述提到你需要配置的signing.secretKeyRingFile
> cd ~/.gnupg/ && gpg --export-secret-keys -o secret.gpg

把signing相关的信息成功填写到gradle.properties之后,我们就可以借助maven-publish插件发布我们的andoird包到maven的中心仓库了


maven publish的gradle task


# 这个是发布到我们的本地,你可以在~/.m2/repository/的目录找到你发布的包
> ./gradlew clean publishToMavenLocal

# 这个是发布到maven的中心仓库,你可以在https://s01.oss.sonatype.org/#stagingRepositories找到
> ./gradlew clean publish

我们执行./gradlew clean publish命令之后,访问地址:s01.oss.sonatype.org/#stagingRep…


image-20220805233310825.png


可以看到我们的android包已经在nexus repository了,接下来你要做的两步就是Close and Release。


Maven检验以及发布


第一步:点击Close按钮,它会触发对发布包的检验。我在这个过程中碰到一个signature validation失败的问题。


# 失败原因:No public key in hkp://keyserver.ubuntu.com:11371,是因为同步key可能会花些时间。这里我们可以手动发布我们的key到相应的服务器上
> gpg --keyserver hkp://keyserver.ubuntu.com:11371 --send-keys signing.keyId

第二步:确保你填入的信息是满足要求之后,Release按钮就会被激活。点击Release,接下来就是等待时间了,不出意外的话。30分钟你可以在nexus repository manager找到,但是在mvnrepository.com/找到的话得花更长的时间。


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

Android系统编译优化:使用Ninja加快编译

背景Android系统模块代码的编译实在是太耗时了,即使寥寥几行代码的修改,也能让一台具有足够性能的编译服务器工作十几分钟以上(模块单编),只为编出一些几兆大小的jar和dex。这里探究的是系统完成过一次整编后进行的模块单编,即m、mm、mmm等命令。除此之外...
继续阅读 »

背景

Android系统模块代码的编译实在是太耗时了,即使寥寥几行代码的修改,也能让一台具有足够性能的编译服务器工作十几分钟以上(模块单编),只为编出一些几兆大小的jar和dex。

这里探究的是系统完成过一次整编后进行的模块单编,即m、mm、mmm等命令。

除此之外,一些不会更新源码、编译配置等文件的内容的操作,如touch、git操作等,会被Android系统编译工具识别为有差异,从而在编译时重新生成编译配置,重新编译并没有更新的源码、重新生成没有差异的中间文件等一系列严重耗时操作。

本文介绍关于编译过程中的几个阶段,以及这些阶段的耗时点/耗时原因,并最后给出一个覆盖一定应用场景的基于ninja的加快编译的方法(实际上是裁剪掉冗余的编译工作)

环境

编译服务器硬件及Android信息:

  • Ubuntu 18.04.4 LTS
  • Intel(R) Xeon(R) CPU E5-2680 v4 @ 2.40GHz (28核56超线程)
  • MemTotal: 65856428 kB (62.8GiB)
  • AOSP Android 10.0
  • 仅修改某个Java文件内部的boolean初始化值(true改false)
  • 不修改其他任何内容,包括源码、mk、bp的情况下,使用m单编模块(在清理后,使用对比的ninja进行单编)
  • 使用time计时
  • 此前整个系统已经整编过一次
  • 编译时不修改任何编译配置文件如Android.mk

之所以做一个代码修改量微乎其微的case,是因为要分析编译性能瓶颈,代码变更量越小的情况下,瓶颈就越明显,越有利于分析

关键编译阶段和耗时分析

由于Makefile结构复杂、不易调试、难以扩展,因此Android决定将它替换掉。Android在7.0时引入了Soong,它将Android从Makefile的编译架构带入到了ninja的时代。

Soong包含两大模块,其中Kati负责解析Makefile并转换为.ninja,第二个模块Ninja则基于生成的.ninja完成编译。

Kati是对GNU Make的clone,并将编译后端实现切换到ninja。Kati本身不进行编译,仅生成.ninja文件提供给Ninja进行编译。

Makefile/Android.mk -> Kati -> Ninja
Android.bp -> Blueprint -> Soong -> Ninja

因此在执行编译之前(即Ninja真正开动时),还有一些生成.ninja的步骤。关键编译阶段如下:

  1. Soong的自举(Bootstrap),将Soong本身编译出来
    1. 系统代码首次编译会比较耗时,其中一个原因是Soong要全新编译它自己
  2. 遍历源码树,收集所有编译配置文件(Makefile/Android.mk/Android.bp)
    1. 遍历、验证非常耗时,多么强劲配置的机器都将受限于单线程效率和磁盘IO效率
    2. 由于Android系统各模块之间的依赖、引入,因此即使是单编模块,Soong(Kati)也不得不确认目标模块以外的路径是否需要重新跟随编译
  3. 验证编译配置文件的合法性、有效性、时效性、是否应该加入编译,生成.ninja
    1. 如果没有任何更改,.ninja不需要重新生成
    2. 最终生成的.ninja文件很大(In my case,1GB以上),有很明显的IO性能效率问题,显然在查询效率方面也很低下
  4. 最后一步,真正执行编译,调用ninja进入多线程编译
    1. 由于Android加入了大量的代码编译期工作,如API权限控制检查、API列表生成等工作(比如,生成系统API保护名单、插桩工作等等),因此编译过程实际上不是完全投入到编译中
    2. 编译过程穿插“泛打包工作”,如生成odex、art、res资源打包。虽然不同的“泛打包”可以多线程并行进行,但是每个打包本身只能单线程进行

下面将基于模块单编(因开发环境系统全新编译场景频率较低,不予考虑),对这四个关键阶段进行性能分析。

阶段一:Soong bootstrap

在系统已经整编过一次的情况下,Soong已经完成了编译,因此其预热过程占整个编译时间的比例会比较小。

在“环境”下,修改一行Framework代码触发差异进行编译。并且使用下面的命令进行编译。

time m services framework -j57

编译实际耗时22m37s:

 build completed successfully (22:37 (mm:ss)) ####
real 22m37.504s
user 110m25.656s
sys 12m28.056s

对应的分阶段耗时如下图。

  • 可以看到,包括Soong bootstrap流程在内的预热耗时占比非常低,耗时约为11.6s,总耗时约为1357s,预热耗时占比为0.8%

Soong编译耗时占比

  • Kati和ninja,也就是上述编译关键流程的第2步和第3步,分别占了接近60%(820秒,13分钟半)和约35%(521秒,8分钟半)的耗时,合计占比接近95%的耗时。

注:这个耗时是仅小幅度修改Java代码后测试的耗时。如果修改编译配置文件如Android.mk,会有更大的耗时。

小结:看来在完成一次整编后的模块单编,包括Soong bootstrap、执行编译准备脚本、vendorsetup脚本的耗时占比很低,可以完全排除存在性能瓶颈的可能。

阶段二:Kati遍历、mk搜集与ninja生成

从上图可以看到,Kati耗时占比很大,它的任务是遍历源码树,收集所有的编译配置文件,经过验证和筛选后,将它们解析并转化为.ninja

从性能角度来看,它的主要特点如下:

  1. 它要遍历源码树,收集所有mk文件(In my case,有983个mk文件)
  2. 解析mk文件(In my case,framework/base/Android.mk耗费了~6800ms)
  3. 生成并写入对应的.ninja
  4. 单线程

直观展示如下,它是一个单线程的、IO速度敏感、CPU不敏感的过程:

Soong编译-Kati耗时细节.png

Kati串行地处理文件,此时对CPU利用率很低,对IO的压力也不高。

小结:可以确定它的性能瓶颈来源于IO速度,单纯为编译实例分配更多的CPU资源也无益于提升Kati的速度。

阶段三:Ninja编译

SoongClone了一份GNU Make,并将其改造为Kati。即使我们没有修改任何mk文件,前面Kati仍然会花费数分钟到数十分钟的工作耗时,只为了生成一份能够被Ninja.ninja的生成工具能够识别的文件。接下来是调用Ninja真正开始编译工作。

从性能角度来看,它的主要特点如下:

  1. 根据目标target及依赖,读取前面生成的.ninja配置,进行编译
  2. 比较独立,不与前面的组件,如blueprint、kati等耦合,只要.ninja文件中能找到target和build rule就能完成编译
  3. 多线程

直观展示如下,Ninja将会根据传入的并行任务数参数启动对应数量的线程进行编译。Ninja编译阶段会真正的启动多线程。但做不到一直多线程编译,因为部分阶段如部分编译目标(比如生成一个API文档)、泛打包阶段等本身无法多线程并行执行。

Soong编译-ninja耗时.png

可以看到此时CPU利用率应该是可以明显上升的。但是耗时较大的阶段仅启用了几个线程,后面的阶段和最后的图形很细(时间占比很小)的阶段才用起来更多的线程。

其中,一些阶段(图中时间占比较长的几条记录)没能跑满资源的原因是这些编译目标本身不支持并行,且本次编译命令指定的目标已经全部“安排”了,不需要调动更多资源启动其他编译目标的工作。当编译整个系统时就能够跑满了。

最后一个阶段(图中最后的几列很细的记录)虽然跑满了所有线程资源,但是运行时间很短。这是因为本case进行编译分析的过程中,仅修改了一行代码来触发编译。因编译工作量很小,所以这几列很细。

小结:我们看到,Ninja编译启动比较快,这表明Ninja.ninja文件的读取解析并不敏感。整个过程也没有看到显著的耗时点。且最后面编译量很小,表明Ninja能够确保增量编译、未更新不编译。

编译优化

本节完成点题——Android系统编译优化:使用Ninja加快编译。

根据前面分析的小结,可以总结性能瓶颈:

  1. Kati遍历、生成太慢,受限于IO速率
  2. Kati吞吐量太低,单线程
  3. 不论有无更新均重新解析Makefile

利用Ninja进行编译优化的思路是,大多数场景,可以舍弃Kati的工作,仅执行Ninja的工作,以节省掉60%以上的时间。其核心思路,也是制约条件,即在不影响编译正确性的前提下,舍弃不必要的Kati编译工作

  • 使用Ninja直接基于.ninja文件进行编译来改善耗时:

结合前面的分析,容易想到,如果目标被构建前,能够确保mk文件没有更新也不需要重新生成一长串的最终编译目标(即.ninja),那么make命令带来的Soong bootstrap、Kati等工作完全是重复的冗余的——这个性质Soong和Kati自己识别不出来,它们会重复工作一次。

既重新生成.ninja是冗余的,那么直接命令编译系统根据指定的.ninja进行编译显然会节省大量的工作耗时。ninja命令is the key:

使用源码中自带的ninja:

./prebuilts/build-tools/linux-x86/bin/ninja --version
1.8.2.git

对比最上面列出的make命令的编译,这里用ninja编译同样的目标:

 time ./prebuilts/build-tools/linux-x86/bin/ninja -j 57 -v -f out/combined-full_xxxxxx.ninja services framework

ninja自己识别出来CPU平台后,默认使用-j58。这里为了对比上面的m命令,使用-j57编译

-f参数指定.ninja文件。它是编译配置文件,在Android中由Kati生成。这里文件名用'x'替换修改

编译结果,对比上面的m,有三倍的提升:

real    7m57.835s
user 97m12.564s
sys 8m31.756s

编译耗时为8分半,仅make的三分之一。As we can see,当能够确保编译配置没有更新,变更仅存在于源码范围时,使用Ninja直接编译,跳过Kati可以取得很显著的提升

直接使用ninja:

./prebuilts/build-tools/linux-x86/bin/ninja -j $MAKE_JOBS -v -f out/combined-*.ninja <targets...>

对比汇总

这里找了一个其他项目的编译Demo,该Demo的特点是本身代码较简单,编译配置也较简单,整体编译工作较少,通过make编译的大部分耗时来自soong、make等工具自身的消耗,而真正执行编译的ninja耗时占比极其低。由于ninja本身跳过了soong,因此可以跳过这一无用的繁琐的耗时。可以看到下面,ninja编译iperf仅花费10秒。这个时间如果给soong来编译,预热都不够。

$ -> f_ninja_msf iperf
Run ninja with out/combined-full_xxxxxx.ninja to build iperf.
====== ====== ======
Ninja: ./prebuilts/build-tools/linux-x86/bin/ninja@1.8.2.git
Ninja: build with out/combined-full_xxxxxx.ninja
Ninja: build targets iperf
Ninja: j72
====== ====== ======
time /usr/bin/time ./prebuilts/build-tools/linux-x86/bin/ninja -j 72 -f out/combined-full_xxxxxx.ninja iperf

[24/24] Install: out/target/product/xxxxxx/system/bin/iperf
53.62user 11.09system 0:10.17elapsed 636%CPU (0avgtext+0avgdata 5696772maxresident)
4793472inputs+5992outputs (4713major+897026minor)pagefaults 0swaps

real 0m10.174s
user 0m53.624s
sys 0m11.096s

下面给出soong编译的恐怖耗时:

$ -> rm out/target/product/xxxxxx/system/bin/iperf
$ -> time m iperf -j72

...

[100% 993/993] Install: out/target/product/xxxxxx/system/bin/iperf

#### build completed successfully (14:45 (mm:ss)) ####


real 14m45.164s
user 23m40.616s
sys 11m46.248s

As we can see,m和ninja一个是10+ minutes,一个是10+ seconds,比例是88.5倍。


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

收起阅读 »

Kotlin 标准库随处可见的 contract 到底是什么?

Kotlin 的标准库提供了不少方便的实用工具函数,比如 with, let, apply 之流,这些工具函数有一个共同特征:都调用了 contract() 函数。@kotlin.internal.I...
继续阅读 »

Kotlin 的标准库提供了不少方便的实用工具函数,比如 withletapply 之流,这些工具函数有一个共同特征:都调用了 contract() 函数

@kotlin.internal.InlineOnly
public inline fun <T, R> with(receiver: T, block: T.() -> R): R {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
return receiver.block()
}

@kotlin.internal.InlineOnly
public inline fun repeat(times: Int, action: (Int) -> Unit) {
contract { callsInPlace(action) }

for (index in 0 until times) {
action(index)
}
}

contract?协议?它到底是起什么作用?

函数协议

contract 其实就是一个顶层函数,所以可以称之为函数协议,因为它就是用于函数约定的协议

@ContractsDsl
@ExperimentalContracts
@InlineOnly
@SinceKotlin("1.3")
@Suppress("UNUSED_PARAMETER")
public inline fun contract(builder: ContractBuilder.() -> Unit) { }

用法上,它有两点要求:

  • 仅用于顶层方法
  • 协议描述须置于方法开头,且至少包含一个「效应」(Effect)

可以看到,contract 的函数体为空,居然没有实现,真是一个神奇的存在。这么一来,此方法的关键点就只在于它的参数了。

ContractBuilder

contract的参数是一个将 ContractBuilder 作为接受者的lambda,而 ContractBuilder 是一个接口:

@ContractsDsl
@ExperimentalContracts
@SinceKotlin("1.3")
public interface ContractBuilder {
@ContractsDsl public fun returns(): Returns
@ContractsDsl public fun returns(value: Any?): Returns
@ContractsDsl public fun returnsNotNull(): ReturnsNotNull
@ContractsDsl public fun <R> callsInPlace(lambda: Function<R>, kind: InvocationKind = InvocationKind.UNKNOWN): CallsInPlace
}

其四个方法分别对应了四种协议类型,它们的功能如下:

  • returns:表明所在方法正常返回无异常
  • returns(value: Any?):表明所在方法正常执行,并返回 value(其值只能是 true、false 或者 null)
  • returnsNotNull():表明所在方法正常执行,且返回任意非 null 值
  • callsInPlace(lambda: Function<R>, kind: InvocationKind = InvocationKind.UNKNOWN):声明 lambada 只在所在方法内执行,所在方法执行完毕后,不会再被其他方法调用;可通过 kind 指定调用次数

前面已经说了,contract 的实现为空,所以作为接受着的 ContractBuilder 类型,根本没有实现类 —— 因为没有地方调用,就不需要啊。它的存在,只是为了声明所谓的协议代编译器使用。

InvocationKind

InvocationKind 是一个枚举类型,用于给 callsInPlace 协议方法指定执行次数的说明

@ContractsDsl
@ExperimentalContracts
@SinceKotlin("1.3")
public enum class InvocationKind {
// 函数参数执行一次或者不执行
@ContractsDsl AT_MOST_ONCE,
// 函数参数至少执行一次
@ContractsDsl AT_LEAST_ONCE,
// 函数参数执行一次
@ContractsDsl EXACTLY_ONCE,
// 函数参数执行次数未知
@ContractsDsl UNKNOWN
}

InvocationKind.UNKNOWN,次数未知,其实就是指任意次数。标准工具函数中,repeat 就指定的此类型,因为其「重复」次数由参数传入,确实未知;而除它外,其余像 letwith 这些,都是用的InvocationKind.EXACTLY_ONCE,即单次执行。

Effect

Effect 接口类型,表示一个方法的执行协议约定,其不同子接口,对应不同的协议类型,前面提到的 ReturnsReturnsNotNullCallsInPlace 均为它的子类型。

public interface Effect

public interface ConditionalEffect : Effect

public interface SimpleEffect : Effect {
public infix fun implies(booleanExpression: Boolean): ConditionalEffect
}

public interface Returns : SimpleEffect

public interface ReturnsNotNull : SimpleEffect

public interface CallsInPlace : Effect

简单明了,全员接口!来看一个官方使用,以便理解下这些接口的意义和使用:

public inline fun Array<*>?.isNullOrEmpty(): Boolean {
contract {
returns(false) implies (this@isNullOrEmpty != null)
}

return this == null || this.isEmpty()
}

这里涉及到两个 Effect:Returns 和 ConditionalEffect。此方法的功能为:判断数组为 null 或者是无元素空数组。它的 contract 约定是这样的:

  1. 调用 returns(value: Any?) 获得 Returns 协议(当然也就是 SimpleEffect 协议),其传入值是 false
  2. 第1步的 Returns 调用 implies 方法,条件是「本对象非空」,得到了一个 ConditionalEffect
  3. 于是,最终协议的意思是:函数返回 false 意味着 接受者对象非空

isNullOrEmpty() 的功能性代码给出了返回值为 true 的条件。虽然反过来说,不满足该条件,返回值就是 false,但还是通过 contract 协议里首先说明了这一点。

协议的意义

讲到这里,contract 协议涉及到的基本类型及其使用已经清楚了。回过头来,前面说到,contract() 的实现为空,即函数体为空,没有实际逻辑。这说明,这个调用是没有实际执行效果的,纯粹是为编译器服务。

不妨模仿着 let 写一个带自定义 contract 测试一下这个结论:

// 类比于ContractBuilder
interface Bonjour {

// 协议方法
fun <R> parler(f: Function<R>) {
println("parler something")
}
}


// 顶层协议声明工具,类比于contract
inline fun bonjour(b: Bonjour.() -> Unit) {}


// 模仿let
fun<T, R> T.letForTest(block: (T) -> R): R {
println("test before")
bonjour {
println("test in bonjour")
parler<String> {
""
}
}
println("test after")
return block(this)
}

fun main(args: Array<String>) {
"abc".letForTest {
println("main: $it called")
}
}

letForTest() 是类似于 let 的工具方法(其本身功能逻辑不重要)。执行结果:

test before
test after
main: abc called

如预期,bonjour 协议以及 Bonjour 协议构造器中的所有日志都未打印,都未执行。

这再一次印证,contract 协议仅为编译器提供信息。那协议对编码来说到底有什么意义呢?来看看下面的场景:

fun getString(): String? {
TODO()
}

fun String?.isAvailable(): Boolean {
return this != null && this.length > 0
}

getString() 方法返回一个 String 类型,但是有可能为 null。isAvailable 是 String? 类型的扩展,用以判断是否一个字符串非空且长度大于 0。使用如下:

val target = getString()
if (target.isAvailable()) {
val result: String = target
}

按代码的设计初衷,上述调用没问题,target.isAvailable() 为 true,证明 target 是非空且长度大于 0 的字符串,然后内部将它赋给 String 类型 —— 相当于 String? 转换成 String。

可惜,上述代码,编译器不认得,报错了:

Type mismatch.
Required:
String
Found:
String?

编译器果然没你我聪明啊!要解决这个问题,自然就得今天的主角上场了:

fun String?.isAvailable(): Boolean {
contract {
returns(true) implies (this@isAvailable != null)
}
return this != null && this.length > 0
}

使用 contract 协议指定了一个 ConditionalEffect,描述意思为:如果函数返回true,意味着 Receiver 类型非空。然后,编译器终于懂了,前面的错误提示消失。

这就是协议的意义所在:让编译器看不懂的代码更加明确清晰

小结

函数协议可以说是写工具类函数的利器,可以解决很多因为编译器不够智能而带来的尴尬问题。不过需要明白的是,函数协议还是实验性质的,还没有正式发布为 stable 功能,所以是有可能被 Kotlin 官方 去掉的。


作者:王可大虾
链接:https://juejin.cn/post/7128258776376803359
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

收起阅读 »

来,跟我一起撸Kotlin runBlocking/launch/join/async/delay 原理&使用

之前一些列的文章重点在于分析协程本质原理,了解了协程的内核再来看其它衍生的知识就比较容易了。 接下来这边文章着重分析协程框架提供的一些重要的函数原理,通过本篇文章,你将了解到: runBlocking 使用与原理 launch 使用与原理 join 使用与...
继续阅读 »

之前一些列的文章重点在于分析协程本质原理,了解了协程的内核再来看其它衍生的知识就比较容易了。

接下来这边文章着重分析协程框架提供的一些重要的函数原理,通过本篇文章,你将了解到:




  1. runBlocking 使用与原理

  2. launch 使用与原理

  3. join 使用与原理

  4. async/await 使用与原理

  5. delay 使用与原理



1. runBlocking 使用与原理


默认分发器的runBlocking


使用


老规矩,先上Demo:


    fun testBlock() {
println("before runBlocking thread:${Thread.currentThread()}")
//①
runBlocking {
println("I'm runBlocking start thread:${Thread.currentThread()}")
Thread.sleep(2000)
println("I'm runBlocking end")
}
//②
println("after runBlocking:${Thread.currentThread()}")
}

runBlocking 开启了一个新的协程,它的特点是:



协程执行结束后才会执行runBlocking 后的代码。



也就是① 执行结束后 ② 才会执行。



image.png


可以看出,协程运行在当前线程,因此若是在协程里执行了耗时函数,那么协程之后的代码只能等待,基于这个特性,runBlocking 经常用于一些测试的场景。


runBlocking 可以定义返回值,比如返回一个字符串:


    fun testBlock2() {
var name = runBlocking {
"fish"
}
println("name $name")
}

原理


    #Builders.kt
public fun <T> runBlocking(context: CoroutineContext = EmptyCoroutineContext, block: suspend CoroutineScope.() -> T): T {
//当前线程
val currentThread = Thread.currentThread()
//先看有没有拦截器
val contextInterceptor = context[ContinuationInterceptor]
val eventLoop: EventLoop?
val newContext: CoroutineContext
//----------①
if (contextInterceptor == null) {
//不特别指定的话没有拦截器,使用loop构建Context
eventLoop = ThreadLocalEventLoop.eventLoop
newContext = GlobalScope.newCoroutineContext(context + eventLoop)
} else {
eventLoop = (contextInterceptor as? EventLoop)?.takeIf { it.shouldBeProcessedFromContext() }
?: ThreadLocalEventLoop.currentOrNull()
newContext = GlobalScope.newCoroutineContext(context)
}
//BlockingCoroutine 顾名思义,阻塞的协程
val coroutine = BlockingCoroutine<T>(newContext, currentThread, eventLoop)
//开启
coroutine.start(CoroutineStart.DEFAULT, coroutine, block)
//等待协程执行完成----------②
return coroutine.joinBlocking()
}

重点看①②。


先说①,因为我们没有指定分发器,因此会使用loop,实际创建的是BlockingEventLoop,它继承自EventLoopImplBase,最终继承自CoroutineDispatcher(注意此处是个重点)。

根据我们之前分析的协程知识可知,协程启动后会构造DispatchedContinuation,然后依靠dispatcher将runnable 分发执行,而这个dispatcher 即是BlockingEventLoop。


    #EventLoop.common.kt
//重写dispatch函数
public final override fun dispatch(context: CoroutineContext, block: Runnable) = enqueue(block)

public fun enqueue(task: Runnable) {
//将task 加入队列,task = DispatchedContinuation
if (enqueueImpl(task)) {
unpark()
} else {
DefaultExecutor.enqueue(task)
}
}

BlockingEventLoop 的父类EventLoopImplBase 里有个成员变量:_queue,它是个队列,用来存储提交的任务。


再看②:

协程任务已经提交到队列里,就看啥时候取出来执行了。


#Builders.kt
fun joinBlocking(): T {
try {
try {
while (true) {
//当前线程已经中断了,直接退出
if (Thread.interrupted()) throw InterruptedException().also { cancelCoroutine(it) }
//如果eventLoop!= null,则从队列里取出task并执行
val parkNanos = eventLoop?.processNextEvent() ?: Long.MAX_VALUE
//协程执行结束,跳出循环
if (isCompleted) break
//挂起线程,parkNanos 指的是挂起时间
parkNanos(this, parkNanos)
//当线程被唤醒后,继续while循环
}
} finally { // paranoia
}
}
//返回结果
return state as T
}

#EventLoop.common.kt
override fun processNextEvent(): Long {
//延迟队列
val delayed = _delayed.value
//延迟队列处理,这里在分析delay时再解释
//从队列里取出task
val task = dequeue()
if (task != null) {
//执行task
task.run()
return 0
}
return nextTime
}

上面代码的任务有两个:




  1. 尝试从队列里取出Task。

  2. 若是没有则挂起线程。



结合①②两点,再来过一下场景:




  1. 先创建协程,包装为DispatchedContinuation,作为task。

  2. 分发task,将task加入到队列里。

  3. 从队列里取出task执行,实际执行的即是协程体。

  4. 当3执行完毕后,runBlocking()函数也就退出了。




image.png


其中虚线箭头表示执行先后顺序。

由此可见,runBlocking()函数需要等待协程执行完毕后才退出。


指定分发器的runBlocking


上个Demo在使用runBlocking 时没有指定其分发器,若是指定了又是怎么样的流程呢?


    fun testBlock3() {
println("before runBlocking thread:${Thread.currentThread()}")
//①
runBlocking(Dispatchers.IO) {
println("I'm runBlocking start thread:${Thread.currentThread()}")
Thread.sleep(2000)
println("I'm runBlocking end")
}
//②
println("after runBlocking:${Thread.currentThread()}")
}

指定在子线程里进行分发。

此处与默认分发器最大的差别在于:



默认分发器加入队列、取出队列都是同一个线程,而指定分发器后task不会加入到队列里,task的调度执行完全由指定的分发器完成。



也就是说,coroutine.joinBlocking()后,当前线程一定会被挂起。等到协程执行完毕后再唤醒当前被挂起的线程。

唤醒之处在于:


#Builders.kt
override fun afterCompletion(state: Any?) {
// wake up blocked thread
if (Thread.currentThread() != blockedThread)
//blockedThread 即为调用coroutine.joinBlocking()后阻塞的线程
//Thread.currentThread() 为线程池的线程
//唤醒线程
unpark(blockedThread)
}


image.png


红色部分比紫色部分先执行,因此红色部分执行的线程会阻塞,等待紫色部分执行完毕后将它唤醒,最后runBlocking()函数执行结束了。


不管是否指定分发器,runBlocking() 都会阻塞等待协程执行完毕。


2. launch 使用与原理


想必大家刚接触协程的时候使用最多的还是launch启动协程吧。

看个Demo:


    fun testLaunch() {
var job = GlobalScope.launch {
println("hello job1 start")//①
Thread.sleep(2000)
println("hello job1 end")//②
}
println("continue...")//③
}

非常简单,启动一个线程,打印结果如下:



image.png


③一定比①②先打印,同时也说明launch()函数并不阻塞当前线程。

关于协程原理,在之前的文章都有深入分析,此处不再赘述,以图示之:



image.png


3. join 使用与原理


虽然launch()函数不阻塞线程,但是我们就想要知道协程执行完毕没,进而根据结果确定是否继续往下执行,这时候该Job.join()出场了。

先看该函数的定义:


#Job.kt
public suspend fun join()

是个suspend 修饰的函数,suspend 是咱们的老朋友了,说明协程执行到该函数会挂起(当前线程不阻塞,另有他用)。

继续看其实现:


    #JobSupport.kt
public final override suspend fun join() {
//快速判断状态,不耗时
if (!joinInternal()) { // fast-path no wait
coroutineContext.ensureActive()
return // do not suspend
}
//挂起的地方
return joinSuspend() // slow-path wait
}

//suspendCancellableCoroutine 典型的挂起操作
//cont 是封装后的协程
private suspend fun joinSuspend() = suspendCancellableCoroutine<Unit> { cont ->
//执行完这就挂起
//disposeOnCancellation 是将cont 记录在当前协程的state里,构造为node
cont.disposeOnCancellation(invokeOnCompletion(handler = ResumeOnCompletion(cont).asHandler))
}

其中suspendCancellableCoroutine 是挂起的核心所在,关于挂起的详细分析请移步:讲真,Kotlin 协程的挂起没那么神秘(原理篇)


joinSuspend()函数有2个作用:




  1. 将当前协程体存储到Job的state里(作为node)。

  2. 将当前协程挂起。



什么时候恢复呢?当然是协程执行完成后。


#JobSupport.kt
private class ResumeOnCompletion(
private val continuation: Continuation<Unit>
) : JobNode() {
//continuation 为协程的包装体,它里面有我们真正的协程体
//之后重新进行分发
override fun invoke(cause: Throwable?) = continuation.resume(Unit)
}

当协程执行完毕,会例行检查当前的state是否有挂着需要执行的node,刚好我们在joinSuspend()里放了node,于是找到该node,进而找到之前的协程体再次进行分发。根据协程状态机的知识可知,这是第二次执行协程体,因此肯定会执行job.join()之后的代码,于是乎看起来的效果就是:



job.join() 等待协程执行完毕后才会往下执行。



语言比较苍白,来个图:



image.png


注:此处省略了协程挂起等相关知识,如果对此有疑惑请阅读之前的文章。


4. async/await 使用与原理


launch 有2点不足之处:协程执行没有返回值。

这点我们从它的定义很容易获悉:



image.png


然而,在有些场景我们需要返回值,此时轮到async/await 出场了。


    fun testAsync() {
runBlocking {
//启动协程
var job = GlobalScope.async {
println("job1 start")
Thread.sleep(10000)
//返回值
"fish"
}
//等待协程执行结束,并返回协程结果
var result = job.await()
println("result:$result")
}
}

运行结果:



image.png


接着来看实现原理。


    public fun <T> CoroutineScope.async(
context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> T
): Deferred<T> {
val newContext = newCoroutineContext(context)
//构造DeferredCoroutine
val coroutine = if (start.isLazy)
LazyDeferredCoroutine(newContext, block) else
DeferredCoroutine<T>(newContext, active = true)
//coroutine == DeferredCoroutine
coroutine.start(start, coroutine, block)
return coroutine
}

与launch 启动方式不同的是,async 的协程定义了返回值,是个泛型。并且async里使用的是DeferredCoroutine,顾名思义:延迟给结果的协程。

后面的流程都是一样的,不再细说。


再来看Job.await(),它与Job.join()类似:




  1. 先判断是否需要挂起,若是协程已经结束/被取消,当然就无需等待直接返回。

  2. 先将当前协程体包装到state里作为node存放,然后挂起协程。

  3. 等待async里的协程执行完毕,再重新调度执行await()之后的代码。

  4. 此时协程的值已经返回。



这里需要重点关注一下返回值是怎么传递过来的。



image.png


将testAsync()反编译:


    public final Object invokeSuspend(@NotNull Object $result) {
//result 为协程执行结果
Object var6 = IntrinsicsKt.getCOROUTINE_SUSPENDED();
Object var10000;
switch(this.label) {
case 0:
//第一次执行这
ResultKt.throwOnFailure($result);
Deferred job = BuildersKt.async$default((CoroutineScope) GlobalScope.INSTANCE, (CoroutineContext)null, (CoroutineStart)null, (Function2)(new Function2((Continuation)null) {
int label;
@Nullable
public final Object invokeSuspend(@NotNull Object var1) {
Object var4 = IntrinsicsKt.getCOROUTINE_SUSPENDED();
switch(this.label) {
case 0:
ResultKt.throwOnFailure(var1);
String var2 = "job1 start";
boolean var3 = false;
System.out.println(var2);
Thread.sleep(10000L);
return "fish";
default:
throw new IllegalStateException("call to 'resume' before 'invoke' with coroutine");
}
}
}), 3, (Object)null);
this.label = 1;
//挂起
var10000 = job.await(this);
if (var10000 == var6) {
return var6;
}
break;
case 1:
//第二次执行这
ResultKt.throwOnFailure($result);
//result 就是demo里的"fish"
var10000 = $result;
break;
default:
throw new IllegalStateException("call to 'resume' before 'invoke' with coroutine");
}

String result = (String)var10000;
String var4 = "result:" + result;
boolean var5 = false;
System.out.println(var4);
return Unit.INSTANCE;
}

很明显,外层的协程(runBlocking)体会执行2次。

第1次:调用invokeSuspend(xx),此时参数xx=Unit,后遇到await 被挂起。

第2次:子协程执行结束并返回结果"fish",恢复外部协程时再次调用invokeSuspend(xx),此时参数xx="fish",并将参数保存下来,因此result 就有了值。


值得注意的是:

async 方式启动的协程,若是协程发生了异常,不会像launch 那样直接抛出,而是需要等待调用await()时抛出。


5. delay 使用与原理


线程可以被阻塞,协程可以被挂起,挂起后的协程等待时机成熟可以被恢复。


    fun testDelay() {
GlobalScope.launch {
println("before getName")
var name = getUserName()
println("after getName name:$name")
}
}
suspend fun getUserName():String {
return withContext(Dispatchers.IO) {
//模拟网络获取
Thread.sleep(2000)
"fish"
}
}

获取用户名字是在子线程获取的,它是个挂起函数,当协程执行到此时挂起,等待获取名字之后再恢复运行。


有时候我们仅仅只是想要协程挂起一段时间,并不需要去做其它操作,这个时候我们可以选择使用delay(xx)函数:


    fun testDelay2() {
GlobalScope.launch {
println("before delay")
//协程挂起5s
delay(5000)
println("after delay")
}
}

再来看看其原理。


#Delay.kt
public suspend fun delay(timeMillis: Long) {
//没必要延时
if (timeMillis <= 0) return // don't delay
return suspendCancellableCoroutine sc@ { cont: CancellableContinuation<Unit> ->
//封装协程为cont,便于之后恢复
if (timeMillis < Long.MAX_VALUE) {
//核心实现
cont.context.delay.scheduleResumeAfterDelay(timeMillis, cont)
}
}
}

主要看context.delay 实现:


#DefaultExecutor.kt
internal actual val DefaultDelay: Delay = kotlinx.coroutines.DefaultExecutor

//单例
internal actual object DefaultExecutor : EventLoopImplBase(), Runnable {
const val THREAD_NAME = "kotlinx.coroutines.DefaultExecutor"
//...
private fun createThreadSync(): Thread {
return DefaultExecutor._thread ?: Thread(this, DefaultExecutor.THREAD_NAME).apply {
DefaultExecutor._thread = this
isDaemon = true
start()
}
}
//...
override fun run() {
//循环检测队列是否有内容需要处理
//决定是否要挂起线程
}
//...
}

DefaultExecutor 是个单例,它里边开启了线程,并且检测队列里任务的情况来决定是否需要挂起线程等待。


先看队列的出入队情况。


放入队列

我们注意到DefaultExecutor 继承自EventLoopImplBase(),在最开始分析runBlocking()时有提到过它里面有成员变量_queue 存储队列元素,实际上它还有另一个成员变量_delayed:


#EventLoop.common.kt
internal abstract class EventLoopImplBase: EventLoopImplPlatform(), Delay {
//存放正常task
private val _queue = atomic<Any?>(null)
//存放延迟task
private val _delayed = atomic<EventLoopImplBase.DelayedTaskQueue?>(null)
}

private inner class DelayedResumeTask(
nanoTime: Long,
private val cont: CancellableContinuation<Unit>
) : EventLoopImplBase.DelayedTask(nanoTime) {
//协程恢复
override fun run() { with(cont) { resumeUndispatched(Unit) } }
override fun toString(): String = super.toString() + cont.toString()
}

delay.scheduleResumeAfterDelay 本质是创建task:DelayedResumeTask,并将该task加入到延迟队列_delayed里。


从队列取出

DefaultExecutor 一开始就会调用processNextEvent()函数检测队列是否有数据,如果没有则将线程挂起一段时间(由processNextEvent()返回值确定)。

那么重点转移到processNextEvent()上。


##EventLoop.common.kt
override fun processNextEvent(): Long {
if (processUnconfinedEvent()) return 0
val delayed = _delayed.value
if (delayed != null && !delayed.isEmpty) {
//调用delay 后会放入
//查看延迟队列是否有任务
val now = nanoTime()
while (true) {
//一直取任务,直到取不到(时间未到)
delayed.removeFirstIf {
//延迟任务时间是否已经到了
if (it.timeToExecute(now)) {
//将延迟任务从延迟队列取出,并加入到正常队列里
enqueueImpl(it)
} else
false
} ?: break // quit loop when nothing more to remove or enqueueImpl returns false on "isComplete"
}
}
// 从正常队列里取出
val task = dequeue()
if (task != null) {
//执行
task.run()
return 0
}
//返回线程需要挂起的时间
return nextTime
}

而执行任务最终就是执行DelayedResumeTask.run()函数,该函数里会对协程进行恢复。


至此,delay 流程就比较清晰了:




  1. 构造task 加入到延迟队列里,此时协程挂起。

  2. 有个单独的线程会检测是否需要取出task并执行,没到时间的话就要挂起等待。

  3. 时间到了从延迟队列里取出并放入正常的队列,并从正常队列里取出执行。

  4. task 执行的过程就是协程恢复的过程。



老规矩,上图:



image.png


图上虚线紫色框部分表明delay 执行到此就结束了,协程挂起(不阻塞当前线程),剩下的就交给单例的DefaultExecutor 调度,等待延迟的时间结束后通知协程恢复即可。


关于协程一些常用的函数分析到此就结束了,下篇开始我们一起探索协程通信(Channel/Flow 等)相关知识。

由于篇幅原因,省略了一些源码的分析,若你对此有疑惑,可评论或私信小鱼人。


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

润了!大龄码农从北京到荷兰的躺平生活

今天在知乎刷到了一篇大龄码农从北京到荷兰的日记,看完后着实令人羡慕不已,国外不仅生活环境和工作强度,都要比国内轻松很多,以下为原文。作者:小李在荷兰 | 编辑:对白的算法屋https://zhuanlan.zhihu.com/p/469261829一. 背景介...
继续阅读 »

今天在知乎刷到了一篇大龄码农从北京到荷兰的日记,看完后着实令人羡慕不已,国外不仅生活环境和工作强度,都要比国内轻松很多,以下为原文。

作者:小李在荷兰 | 编辑:对白的算法屋

https://zhuanlan.zhihu.com/p/469261829

一. 背景介绍

光阴似箭,今年已经41岁的老码农一枚。在家乡生活了19年,然后到北京学习工作了19年。一直希望去北美发展,由于种种原因最终还是没有去成。出国前在朋友的公司干了一年多,本来想着趁没到40岁再拼一把,结果很遗憾,失败离场。一晃眼已经要到孩子上学的年龄,所以决定快刀斩乱麻果断出国。

二. 工作机会

在前同事(我的前Manager)的推荐下,成功通过荷兰http://Booking.com的面试。

拿到offer签完合同后,Booking会非常贴心地安排几个专员协助后续的流程,包括:搬家,签证,机票,接机,抵达荷兰后入住的酒店,租房中介服务,银行账户,市政厅档案提交等等一些列大大小的事务,整个流程专业且流畅。

三. 搬家

决定使用Booking提供的员工搬家服务后,对接国际搬家服务的公司和我联系并上门初步估算需要搬运的家具等讲占据的空间,Booking最多提供一个集装箱的空间帮助入职员工搬家。搬家相关的一切事务由搬家公司负责,从时间规划,空间大小估算,上门打包,装车,报关等。老实说当时我很忐忑,这么多东西得搬到什么时候啊!但是令我很吃惊的是,专业公司就是不一样,只用了2个小时(6个人),差不多就把我北京的家搬空了。一句话概括:这次搬家是我有史以来感觉到最满意的搬家服务。

另外,Booking还支持将车运到荷兰,以及支持宠物搬家(有限报销)。

四. 签证

Booking提供HSM工作签证,签证会有专业的移民公司跟进。公司协助:递交给大使馆的签证材料清单,提交给荷兰市政厅的材料清单,获得 30% ruling材料清单。签证材料其实是比较麻烦的,其中最麻烦的是出生证明,但是这个方面每个人的情况不同,此处不表。另外,所有材料都需要经过公证(含翻译),然后中国大使馆,荷兰大使馆双认证。我本人怕麻烦,花钱找的中介办理的,一家三口的签证材料也花了不少钱。

当年9月份,在北京亮马河的荷兰大使馆提交完材料后,整个人都轻松了,出国前最麻烦的一个阶段终于结束。


提交完签证去了蓝色港湾走一走

五. 出发前的准备

  1. 打点好北京的房子、车子

  1. 去银行取了前期在荷兰生活用的现金(人生头一次拥有那么多欧元现钞)

  1. 办理小孩用的国际疫苗本,给小孩办理幼儿园的退学手续(仅仅上了一个礼拜)

  1. 注销不用的手机卡

  1. 将自己很多重要的文件档案扫描备用

  1. 吃了好多好多小龙虾

  1. 带着孩子在北京周边尽情地玩耍

  1. 和好友,同事们道别

  1. 和荷兰的租房中介公司沟通,此时他们已经开始进入协助租房的流程了

  1. 开始了解荷兰的幼儿园和小学情况,Booking有相应的服务协助家属融入荷兰社会

  1. 与此同时,Booking开始为入职做准备工作

六. 出发

按照预定好的出发日期,家人将我们一家三口送到了机场。本来一切都很平静,家属之前也去过荷兰。只是登机前,女儿问了我一句:爸爸我们要去荷兰了嘛?我们还回北京吗?瞬间有点要飙泪的感觉。


七. 抵达

10个小时后,我们一家人顺利抵达了荷兰阿姆斯特丹斯基浦机场。天气很好,心情愉悦。

海关一听到是Booking的员工,直接就盖戳了,家属也没有问问题。出关后,去公司指定的柜台等待接机的司机到来,然后带着行李前往酒店。


八. 入住酒店

阿姆斯特丹很小,我们很快就抵达了酒店。

Booking很贴心地根据我们的人数给提供了两室一厅的公寓酒店,可以自己做饭。我们去超市买了水果牛奶面包和三文鱼,吃完就早早休息倒时差了。





九. 开始工作

正式入职后,第一个月公司将新入职员工分配到了Bootcamp,进行入职培训。

当时第一个月基本每天就是早上来喝喝咖啡,参加一下入职培训和各种手续:包括完善员工资料、工作技能培训、公司文化培训、保险知识、租房买房知识、税务问题等。与此同时,需要去和中介公司去看房(下面会提到),争取早日租到房搬离酒店公寓。用当时他们(我当时接触过的几个Manager)的话来说就是:这个阶段你最重要的任务是全家安顿下来。

入职培训差不多结束时,我被现在所在组的TL领了过去,换了办公室,正式开始工作。

说一下WLB的问题,我所在公司这方面非常好,大部分同事都是早上9点-10点陆陆续续到公司,吃早饭接水接咖啡开会,中午12:00-1:00去食堂吃午饭,下午4:30-5:30就走了。我被TL找过两次非正式谈话,说我工作太卖力了,5点就走吧。其实我不是想做奋斗逼,只是有时进入状态了实在不想放下手中的活。试过几次呆到8点多,然后被保安轰走:先生,我要下班了。

疫情前,每天下班感觉都特别好:

  1. 下了班就不用管了,所以一下班就特别轻松,没有OnCall因为有SRE,没有人会给你发消息(特别极端的情况会,但是目前还没有遇到过)。

  1. 每天都特别有仪式感,特别是下班出门:把桌上的水果皮扔了,把喝咖啡的杯子放回去,把笔记本放入包中,带上围巾,带上耳机,穿上外套,背上背包,根据GoogleMap计算好的时间听着音乐准时走到公交车站...


收起阅读 »

Elasticsearch 为什么会产生文档版本冲突?如何避免?

先让大家直观的看到 Elasticsearch 文档版本冲突。模拟脚本1:循环写入数据 index.sh。模拟脚本2:循环update_by_query 批量更新数据 update.sh。由于:写入脚本 index.sh 比更新脚本 update.sh (执行...
继续阅读 »

1、Elasticsearch 版本冲突复现

先让大家直观的看到 Elasticsearch 文档版本冲突。

1.1 场景1:create 场景

DELETE my-index-000001
# 执行创建并写入
PUT my-index-000001/_create/1
{
"@timestamp": "2099-11-15T13:12:00",
"message": "GET /search HTTP/1.1 200 1070000",
"user": {
  "id": "kimchy"
}
}

# 再次执行会报版本冲突错误。
# 报错信息:[1]: version conflict, document already exists (current version [1])
PUT my-index-000001/_create/1
{
"@timestamp": "2099-11-15T13:12:00",
"message": "GET /search HTTP/1.1 200 1070000",
"user": {
  "id": "kimchy"
}
}


1.2 场景2:批量更新场景模拟

模拟脚本1:循环写入数据 index.sh。


模拟脚本2:循环update_by_query 批量更新数据 update.sh。


由于:写入脚本 index.sh 比更新脚本 update.sh (执行一次,休眠1秒)执行要快,所以更新获取的版本较写入的最新版本要低,会导致版本冲突如下图所示:


1.3 场景3:批量删除场景模拟

写入脚本 index.sh 不变。

删除脚本 delete.sh 如下:


和更新原因一致,由于:写入脚本 index.sh 比删除脚本 delete.sh (执行一次,休眠1秒)执行要快,所以删除获取的版本较写入的最新版本要低,会导致版本冲突如下图所示:


2、Elasticsearch 文档版本定义

执行:

GET test/_doc/1

召回结果如下:


这里的 version 代表文档的版本。

  • 当我们在 Elasticsearch 中创建一个新文档时,它会为该文档分配一个_version: 1。

  • 当我们对该文档进行任何后续更新(更新 update、索引 index 或删除 delete)时,_version都会增加 1。

一句话:Elasticsearch 使用_version来鉴别文档是否已更改。

3、Elasticsearch 文档版本产生背景

试想一下,如果没有文档版本?当有并发访问会怎么办?

前置条件:Elasticsearch 从写入到被检索的时间间隔是由刷新频率 refresh_interval 设定的,该值可以更新,但默认最快是 1 秒。


如上图所示,假设我们有一个人们用来评价 T 恤设计的网站。网站很简单,仅列出了T恤设计,允许用户给T恤投票。如果顺序投票,没有并发请求,直接发起update更新没有问题。

但是,在999累计投票数后,碰巧小明同学和小红同学两位同时(并发)发起投票请求,这时候,如果没有版本控制,将导致最终结果不是预期的1001,而是1000。

所以,为了处理上述场景以及比上述更复杂的并发场景,Elasticsearch 亟需一个内置的文档版本控制系统。这就是 _version 的产生背景。

https://kb.objectrocket.com/elasticsearch/elasticsearch-version-history-what-it-does-and-doesnt-do-501

https://www.elastic.co/cn/blog/elasticsearch-versioning-support

4、常见的并发控制策略

并发控制可以简记为:“防止两个或多个用户同时编辑同一记录而导致最终结果和预期不一致”。

常见的并发控制策略:悲观锁、乐观锁。

4.1 悲观锁

悲观锁,又名:悲观并发控制,英文全称:"Pessimistic Concurrency Control",缩写“PCC”,是一种并发控制的方法。

  • 悲观锁本质:在修改数据之前先锁定,再修改。

  • 悲观锁优点:采用先锁定后修改的保守策略,为数据处理的安全提供了保证。

  • 悲观锁缺点:加锁会有额外的开销,还会增加产生死锁的机会。

  • 悲观锁应用场景:比较适合写入操作比较频繁的场景。

4.2 乐观锁

乐观锁,又名:乐观并发控制,英文全称:“Optimistic Concurrency Control”,缩写OCC”,也是一种并发控制的方法。

  • 乐观锁本质:假设多用户并发的事务在处理时不会彼此互相影响,各事务能够在不产生锁的情况下处理各自影响的那部分数据。在提交数据更新之前,每个事务会先检查在该事务读取数据后,有没有其他事务又修改了该数据。如果其他事务有更新的话,正在提交的事务会进行回滚。

  • 乐观锁优点:“胆子足够大,足够乐观”,直到提交的时候才去锁定,不会产生任何锁和死锁。

  • 乐观锁缺点:并发写入会有问题,需要有冲突避免策略补救。

  • 乐观锁应用场景:数据竞争(data race)不大、冲突较少的场景、比较适合读取操作比较频繁的场景,确保比其他并发控制方法(如悲观锁)更高的吞吐量。

这里要强调的是,Elasticsearch 采用的乐观锁的机制来处理并发问题。

Elasticsearch 乐观锁本质是:没有给数据加锁,而是基于 version 文档版本实现。每次更新或删除数据的时候,都需要对比版本号。

5、Elasticsearch 文档版本冲突的本质

一句话,Elasticsearch 文档冲突的本质——老版本覆盖掉了新版本。

6、如何解决或者避免 Elasticsearch 文档版本冲突?

6.1 external 外部控制版本号

“external”——我的理解就是“简政放权”,交由外部的数据库或者更确切的说,是写入的数据库或其他第三方库来做控制。

版本号可以设置为外部值(例如,如果在数据库中维护)。要启用此功能,version_type应设置为 external。

使用外部版本类型 external 时,系统会检查传递给索引请求的版本号是否大于当前存储文档的版本。

  • 如果为真,也就是新版本大于已有版本,则文档将被索引并使用新的版本号。

  • 如果提供的值小于或等于存储文档的版本号,则会发生版本冲突,索引操作将失败。

好处:不论何时,ES 中只有最新版本的数据,借助 external 相对有效的解决版本冲突问题。

实战一把:

如果没有 external,执行如下命令:

PUT my-index-000001/_doc/1?version=2
{
"user": {
  "id": "elkbee"
}
}

报错如下:

{
"error" : {
  "root_cause" : [
    {
      "type" : "action_request_validation_exception",
      "reason" : "Validation Failed: 1: internal versioning can not be used for optimistic concurrency control. Please use `if_seq_no` and `if_primary_term` instead;"
    }
  ],
.......省略2行......
"status" : 400
}

啥意思呢?内部版本控制(internal)不能用于乐观锁,也就是直接使用 version 是不可以的。需要使用:if_seq_noif_primary_term,它俩的用法,后文会有专门解读。

如果用 external,执行如下命令:

PUT my-index-000001/_doc/1?version=2&version_type=external
{
"user": {
  "id": "elkbee"
}
}

执行结果如下:

{
"_index" : "my-index-000001",
"_type" : "_doc",
"_id" : "1",
"_version" : 2,
"result" : "updated",
"_shards" : {
  "total" : 2,
  "successful" : 1,
  "failed" : 0
},
"_seq_no" : 1,
"_primary_term" : 1
}

相比于之间没有加 external,加上 external 后,可以实现基于version的文档更新操作。

external_gt 和 external_gte的用法见官方文档,本文不展开,原理同 external。

https://www.elastic.co/guide/en/elasticsearch/reference/8.1/docs-index_.html#index-versioning

6.2 通过if_seq_no 和 if_primary_term 唯一标识避免冲突

索引操作(Index,动词)是有条件的,并且只有在对文档的最后修改分配了由 if_seq_no 和 if_primary_term 参数指定的序列号和 primary term specified(翻译起来拗口,索性用英文)才执行。

如果检测到不匹配,该操作将产生一个 VersionConflictException 409 的状态码。

Step1:写入数据

DELETE products_001
PUT products_001/_doc/1567
{
"product" : "r2d2",
"details" : "A resourceful astromech droid"
}


# 查看ifseqno 和 ifprimaryterm
GET products_001/_doc/1567

返回:

{
"_index" : "products_001",
"_type" : "_doc",
"_id" : "1567",
"_version" : 1,
"_seq_no" : 0,
"_primary_term" : 1,
"found" : true,
"_source" : {
  "product" : "r2d2",
  "details" : "A resourceful astromech droid"
}
}

Step2:以这种方式更新,前提是先拿到 if_seq_no 和 if_primary_term

# 模拟数据打tag 过程
PUT products_001/_doc/1567?if_seq_no=0&if_primary_term=1
{
"product": "r2d2",
"details": "A resourceful astromech droid",
"tags": [ "droid" ]
}


# 再获取数据
GET products_001/_doc/1567

返回:

{
"_index" : "products_001",
"_type" : "_doc",
"_id" : "1567",
"_version" : 2,
"_seq_no" : 1,
"_primary_term" : 1,
"found" : true,
"_source" : {
  "product" : "r2d2",
  "details" : "A resourceful astromech droid",
  "tags" : [
    "droid"
  ]
}
}

step2 更新数据的时候,是在 step1 的获取已写入文档的 if_seq_no=0 和 if_primary_term=1 基础上完成的。

这样能有效避免冲突。

6.3 批量更新和批量删除忽略冲突实现

如下是在开篇的基础上加了:conflicts=proceed。

conflicts 默认值是终止,而 proceed 代表继续。

POST test/_update_by_query?conflicts=proceed
{
"query": {
  "match": {
    "name": "update"
  }
},
"script": {
  "source": "ctx._source['foo'] = '123ss'",
  "lang": "painless"
}
}

conflicts=proceed 的本质——告诉进程忽略冲突并继续更新其他文档。

开篇不会报 409 错误了,但依然会有版本冲突。但,某些企业级场景是可以用的。


同理,delete_by_query 参数及返回结果均和 update_by_query 一致。


扩展:单个更新 update (区别于批量更新:update_by_query)有 retry_on_conflict 参数,可以设置冲突后重试次数。

7、关于频繁更新带来的性能问题

正如文章开篇演示的,并发更新或者并发删除可能会导致版本冲突。

除了并发性和正确性之外,请注意,非常频繁地更新文档可能会导致性能下降。

如果更新了尚未写入段(segment)的文档,将会导致刷新操作。而刷新频率越小(企业级咨询我见过设置小于1s的,不推荐),势必会导致写入低效。

更多探讨推荐阅读:

https://discuss.elastic.co/t/handling-conflicts/135240/2

8、小结

从实际问题抽象出模拟脚本,让大家看到文档版本冲突是如何产生的。而后,定义了版本冲突并指出了其产生的背景。

接着,详细讲解了解决冲突的两种机制:乐观锁、悲观锁。探讨、验证了解决文档版本冲突的几种方案。

你有没有遇到过本文提及的问题,如何解决的呢?欢迎留言交流。

参考

[1] https://www.anycodings.com/1questions/160352/why-bulk-update-never-conflicts-with-update-by-query-requests-in-elasticsearch

[2] https://learnku.com/articles/43867

[3] https://www.elastic.co/guide/en/elasticsearch/reference/current/optimistic-concurrency-control.html

[4] https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-index_.html#optimistic-concurrency-control-index

[5] https://www.elastic.co/guide/en/elasticsearch/reference/8.1/docs-index_.html#index-versioning

来源:mp.weixin.qq.com/s/9XzOogqfz4tavXqDt1pxgA

收起阅读 »

封装一个有趣的 Loading 组件

前言在上一篇普通的加载千篇一律,有趣的 loading 万里挑一 中,我们介绍了使用Path类的PathMetrics属性来控制绘制点在路径上运动来实现比较有趣的loading效果。有评论说因为是黑色背景,所以看着好看。黑色背景确实显得高端一点,但是...
继续阅读 »

前言

在上一篇普通的加载千篇一律,有趣的 loading 万里挑一 中,我们介绍了使用Path类的PathMetrics属性来控制绘制点在路径上运动来实现比较有趣的loading效果。有评论说因为是黑色背景,所以看着好看。黑色背景确实显得高端一点,但是并不是其他配色也不行,本篇我们来封装一个可以自定义配置前景色和背景色的Loading组件。

组件定义

loading组件共定义4个入口参数:

  • 前景色:绘制图形的前景色;
  • 背景色:绘制图形的背景色;
  • 图形尺寸:绘制图形的尺寸;
  • 加载文字:可选,如果有文字就显示,没有就不显示。

得到的Loading组件类如下所示:

class LoadingAnimations extends StatefulWidget {
final Color bgColor;
final Color foregroundColor;
String? loadingText;
final double size;
LoadingAnimations(
{required this.foregroundColor,
required this.bgColor,
this.loadingText,
this.size = 100.0,
Key? key})
: super(key: key);

@override
_LoadingAnimationsState createState() => _LoadingAnimationsState();
}

圆形Loading

我们先来实现一个圆形的loading,效果如下所示。 circle_loading.gif 这里绘制了两组沿着一个大圆运动的轴对称的实心圆,半径依次减小,圆心间距随着动画时间逐步拉大。实际上实现的核心还是基于PathPathMetrics。具体实现代码如下:

_drawCircleLoadingAnimaion(
Canvas canvas, Size size, Offset center, Paint paint) {
final radius = boxSize / 2;
final ballCount = 6;
final ballRadius = boxSize / 15;

var circlePath = Path()
..addOval(Rect.fromCircle(center: center, radius: radius));

var circleMetrics = circlePath.computeMetrics();
for (var pathMetric in circleMetrics) {
for (var i = 0; i < ballCount; ++i) {
var lengthRatio = animationValue * (1 - i / ballCount);
var tangent =
pathMetric.getTangentForOffset(pathMetric.length * lengthRatio);

var ballPosition = tangent!.position;
canvas.drawCircle(ballPosition, ballRadius / (1 + i), paint);
canvas.drawCircle(
Offset(size.width - tangent.position.dx,
size.height - tangent.position.dy),
ballRadius / (1 + i),
paint);
}
}
}

其中路径比例为lengthRatio,通过animationValue乘以一个系数使得实心圆的间距越来越大 ,同时通过Offset(size.width - tangent.position.dx, size.height - tangent.position.dy)绘制了一组对对称的实心圆,这样整体就有一个圆形的效果了,动起来也会更有趣一点。

椭圆运动Loading

椭圆和圆形没什么区别,这里我们搞个渐变的效果看看,利用之前介绍过的Paintshader可以实现渐变色绘制效果。

oval_loading.gif

实现代码如下所示。

final ballCount = 6;
final ballRadius = boxSize / 15;

var ovalPath = Path()
..addOval(Rect.fromCenter(
center: center, width: boxSize, height: boxSize / 1.5));
paint.shader = LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [this.foregroundColor, this.bgColor],
).createShader(Offset.zero & size);
var ovalMetrics = ovalPath.computeMetrics();
for (var pathMetric in ovalMetrics) {
for (var i = 0; i < ballCount; ++i) {
var lengthRatio = animationValue * (1 - i / ballCount);
var tangent =
pathMetric.getTangentForOffset(pathMetric.length * lengthRatio);

var ballPosition = tangent!.position;
canvas.drawCircle(ballPosition, ballRadius / (1 + i), paint);
canvas.drawCircle(
Offset(size.width - tangent.position.dx,
size.height - tangent.position.dy),
ballRadius / (1 + i),
paint);
}
}

当然,如果渐变色的颜色更丰富一点会更有趣些。

colorful_loading.gif

贝塞尔曲线Loading

通过贝塞尔曲线构建一条Path,让一组圆形沿着贝塞尔曲线运动的Loading效果也很有趣。

bezier_loading.gif

原理和圆形的一样,首先是构建贝塞尔曲线Path,代码如下。

var bezierPath = Path()
..moveTo(size.width / 2 - boxSize / 2, center.dy)
..quadraticBezierTo(size.width / 2 - boxSize / 4, center.dy - boxSize / 4,
size.width / 2, center.dy)
..quadraticBezierTo(size.width / 2 + boxSize / 4, center.dy + boxSize / 4,
size.width / 2 + boxSize / 2, center.dy)
..quadraticBezierTo(size.width / 2 + boxSize / 4, center.dy - boxSize / 4,
size.width / 2, center.dy)
..quadraticBezierTo(size.width / 2 - boxSize / 4, center.dy + boxSize / 4,
size.width / 2 - boxSize / 2, center.dy);

这里实际是构建了两条贝塞尔曲线,先从左边到右边,然后再折回来。之后就是运动的实心圆了,这个只是数量上多了,ballCount30,这样效果看着就有一种拖影的效果。

var ovalMetrics = bezierPath.computeMetrics();
for (var pathMetric in ovalMetrics) {
for (var i = 0; i < ballCount; ++i) {
var lengthRatio = animationValue * (1 - i / ballCount);
var tangent =
pathMetric.getTangentForOffset(pathMetric.length * lengthRatio);

var ballPosition = tangent!.position;
canvas.drawCircle(ballPosition, ballRadius / (1 + i), paint);
canvas.drawCircle(
Offset(size.width - tangent.position.dx,
size.height - tangent.position.dy),
ballRadius / (1 + i),
paint);
}
}

这里还可以改变运动方向,实现一些其他的效果,例如下面的效果,第二组圆球的绘制位置实际上是第一组圆球的x、y坐标的互换。

bezier_loading_transform.gif

var lengthRatio = animationValue * (1 - i / ballCount);
var tangent =
pathMetric.getTangentForOffset(pathMetric.length * lengthRatio);

var ballPosition = tangent!.position;
canvas.drawCircle(ballPosition, ballRadius / (1 + i), paint);
canvas.drawCircle(Offset(tangent.position.dy, tangent.position.dx),
ballRadius / (1 + i), paint);

组件使用

我们来看如何使用我们定义的这个组件,使用代码如下,我们用Future延迟模拟了一个加载效果,在加载过程中使用loading指示加载过程,加载完成后显示图片。

class _LoadingDemoState extends State<LoadingDemo> {
var loaded = false;

@override
void initState() {
super.initState();
Future.delayed(Duration(seconds: 5), () {
setState(() {
loaded = true;
});
});
}

@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
title: Text('Loading 使用'),
),
body: Center(
child: loaded
? Image.asset(
'images/beauty.jpeg',
width: 100.0,
)
: LoadingAnimations(
foregroundColor: Colors.blue,
bgColor: Colors.white,
size: 100.0,
),
),
);
}

最终运行的效果如下,源码已提交至:绘图相关源码,文件名为loading_animations.dart

loading_usage.gif

总结

本篇介绍了Loading组件的封装方法,核心要点还是利用Path和动画控制绘制元素的运动轨迹来实现更有趣的效果。在实际应用过程中,也可以根据交互设计的需要,做一些其他有趣的加载动效,提高等待过程的趣味性。


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

收起阅读 »

关于mmap不为人知的秘密

mmap初入 我们常说的mmap,其实是一种内存映射文件的方法,mmap将一个文件或者其它对象映射进内存。但是更加确切的来说,其实是linux中的线性区提供的可以和基于磁盘文件系统的普通文件的某一个部分相关联的操作。线性区其实是由进程中连续的一块虚拟文件区域,...
继续阅读 »

mmap初入


我们常说的mmap,其实是一种内存映射文件的方法,mmap将一个文件或者其它对象映射进内存。但是更加确切的来说,其实是linux中的线性区提供的可以和基于磁盘文件系统的普通文件的某一个部分相关联的操作。线性区其实是由进程中连续的一块虚拟文件区域,由struct vm_area_struct结构体表示,我们的mmap操作,其实就是最本质的,就是通过其进行的内存操作。


按照归类的思想,其实mmap主要用到的分为两类(还有其他标识不讨论)



  1. 共享的:即对该线性区中的页(注意是以页为单位)的任何写操作,都会修改磁盘上的文件,并且如果一个进程对进行了mmap的页进行了写操作,那么对于其他进程(同样也通过mmap进行了映射),同样也是可见的

  2. 私有的:对于私有映射页的任何写操作,都会使linux内核停止映射该文件的页(注意,假如有进程a,b同时映射了该页,a对页进行了修改,此时这个页就相当于复制出来了一份,a以后的操作就在复制的该页进行操作,b还是引用原来的页,原来的页就可以继续参与内存映射,而复制出来的页,就被停止映射了),因此,在私有情况下,写操作是不会改变磁盘上的文件,同时所做的修改对于其他进程来说,就是不可见的。


我们看一下图片
共享模式下


image.png
私有模式下


image.png


mmap分析


概念我们有了,我们看一下代码mmap定义


#if defined(__USE_FILE_OFFSET64)
void* mmap(void* __addr, size_t __size, int __prot, int __flags, int __fd, off_t __offset) __RENAME(mmap64);
#else
void* mmap(void* __addr, size_t __size, int __prot, int __flags, int __fd, off_t __offset);
#endif

#if __ANDROID_API__ >= 21
/**
* mmap64() is a variant of mmap() that takes a 64-bit offset even on LP32.
*
* See https://android.googlesource.com/platform/bionic/+/master/docs/32-bit-abi.md
*
* mmap64 wasn't really around until L, but we added an inline for it since it
* allows a lot more code to compile with _FILE_OFFSET_BITS=64.
*/
void* mmap64(void* __addr, size_t __size, int __prot, int __flags, int __fd, off64_t __offset) __INTRODUCED_IN(21);
#endif

mmap分为好多个版本,但是其他的也是根据不同的Android版本或者abi进行部分的区分,我们来看一下具体含义:



  1. addr:参数addr指定文件应被映射到进程空间的起始地址,一般被指定一个空指针,此时选择起始地址的任务留给内核来完成。当然,我们也可以设定一个自己的地址,但是如果flag中设定起来MAP_FIXED标志,且内核也没办法从我们指定的线性地址开始分配新线性区的话,就会产生调用失败

  2. size:映射到调用进程地址空间的字节数,它从被映射文件开头offset个字节开始算起

  3. prot:指定对线性区的一组权限,比如读权限(PROT_READ),写权限(PROT_WRITE),执行权限(PROT_EXEC)

  4. flags:一组标志,比如我们上面说的共享模式(MAP_SHARED)或者私有模式(MAP_PRIVATE)等

  5. fd:文件描述符,要映射的文件

  6. offset:要映射的文件的偏移量(比如我想要从文件的某部分开始映射)


使用例子


下面是demo例子



#include <sys/mman.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/fcntl.h>
#include <errno.h>
#include <string.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

#define TRUE 1
#define FALSE -1
#define FILE_SIZE 100

#define MMAP_FILE_PATH "./mmap.txt"


int main(int argc, char **argv)
{
int fd = -1;
//char buff[100] = {0};
void *result;
int lseek_result = -1;
int file_length = -1;

// 1. open the file
fd = open(MMAP_FILE_PATH, O_RDWR|O_CREAT, S_IRUSR|S_IWUSR|S_IRGRP|S_IWGRP|S_IROTH|S_IWOTH);
if (-1 == fd) {
printf("open failed\n");
printf("%s\n", strerror(errno));
return FALSE;
}

//2. call mmap 这里可以尝试一下其他的flag,比如MAP_PRIVATE
result = mmap(0, 100(这里是demo文件长度), \
PROT_READ|PROT_WRITE, \
MAP_SHARED, \
fd, 0);
if (result == (void *)-1) {
printf("mmap failed\n");
printf("%s\n", strerror(errno));
return FALSE;
}

//3. release the file descriptor
close(fd);

//4. write something to mmap addr,
strncpy(result, "test balabala...", file_length);

//5. call munmap
munmap(0, file_length);

return 0;
}

深入了解mmap


不得不说,mmap的函数实现非常复杂,首先会调用到do_mmap_pgoff进行平台无关的代码分支,然后会调用到do_mmap里面,这个函数非常长!!!


我们挑几个关键的步骤:



  1. 先检查要映射的文件是否定义了mmap文件操作,(如果是目录就不存在mmap文件操作了)如果没有的话就直接调用失败

  2. 检查一系列的一致性检查,同时根据mmap的参数与打开文件的权限标志进行对比:比如检查当前文件是否有文件锁,如果是以MAP_SHARED模式打开进行写入的话,也要检查文件是否有可写的权限,正所谓权限要一致

  3. 设置线性区的vm_flag字段,因为我们mmap返回的地址肯定也是要给到当前进程的,这个线性区的权限所以也要设置,比如VM_READ,VM_WRITE等标识,表明了这块内存的权限

  4. 增加文件的引用计数,因为我们进程mmap用到了该文件对吧,所以计数器也要加一,这样系统才会知道当前文件被多少个进程引用了

  5. 对文件进行映射操作,并且初始化该线性区的页,注意这个时候并没有对页进行操作,因为有可能申请了这个页而没有操作,所以这个时候是以no_page标识的,这里不得不感慨linux的写时复制思想用到了各处!只有真正访问的时候,就会产生一个缺页异常,由内核调度请求完成对该页的填充


unsigned long do_mmap(struct file *file, unsigned long addr,
unsigned long len, unsigned long prot,
unsigned long flags, vm_flags_t vm_flags,
unsigned long pgoff, unsigned long *populate)
{
struct mm_struct *mm = current->mm; //获取该进程的memory descriptor
int pkey = 0;

*populate = 0;
//函数对传入的参数进行一系列检查, 假如任一参数出错,都会返回一个errno
if (!len)
return -EINVAL;

/*
* Does the application expect PROT_READ to imply PROT_EXEC?
*
* (the exception is when the underlying filesystem is noexec
* mounted, in which case we dont add PROT_EXEC.)
*/
if ((prot & PROT_READ) && (current->personality & READ_IMPLIES_EXEC))
if (!(file && path_noexec(&file->f_path)))
prot |= PROT_EXEC;
//假如没有设置MAP_FIXED标志,且addr小于mmap_min_addr, 因为可以修改addr, 所以就需要将addr设为mmap_min_addr的页对齐后的地址
if (!(flags & MAP_FIXED))
addr = round_hint_to_min(addr);

/* Careful about overflows.. */
len = PAGE_ALIGN(len); //进行Page大小的对齐
if (!len)
return -ENOMEM;

/* offset overflow? */
if ((pgoff + (len >> PAGE_SHIFT)) < pgoff)
return -EOVERFLOW;

/* Too many mappings? */
if (mm->map_count > sysctl_max_map_count) //判断该进程的地址空间的虚拟区间数量是否超过了限制
return -ENOMEM;

//get_unmapped_area从当前进程的用户空间获取一个未被映射区间的起始地址
addr = get_unmapped_area(file, addr, len, pgoff, flags);
if (offset_in_page(addr)) //检查addr是否有效
return addr;

if (prot == PROT_EXEC) {
pkey = execute_only_pkey(mm);
if (pkey < 0)
pkey = 0;
}

/* Do simple checking here so the lower-level routines won't have
* to. we assume access permissions have been handled by the open
* of the memory object, so we don't do any here.
*/
vm_flags |= calc_vm_prot_bits(prot, pkey) | calc_vm_flag_bits(flags) |
mm->def_flags | VM_MAYREAD | VM_MAYWRITE | VM_MAYEXEC;
//假如flags设置MAP_LOCKED,即类似于mlock()将申请的地址空间锁定在内存中, 检查是否可以进行lock
if (flags & MAP_LOCKED)
if (!can_do_mlock())
return -EPERM;

if (mlock_future_check(mm, vm_flags, len))
return -EAGAIN;

if (file) { // file指针不为nullptr, 即从文件到虚拟空间的映射
struct inode *inode = file_inode(file); //获取文件的inode

switch (flags & MAP_TYPE) { //根据标志指定的map种类,把为文件设置的访问权考虑进去
case MAP_SHARED:
if ((prot&PROT_WRITE) && !(file->f_mode&FMODE_WRITE))
return -EACCES;

/*
* Make sure we don't allow writing to an append-only
* file..
*/
if (IS_APPEND(inode) && (file->f_mode & FMODE_WRITE))
return -EACCES;

/*
* Make sure there are no mandatory locks on the file.
*/
if (locks_verify_locked(file))
return -EAGAIN;

vm_flags |= VM_SHARED | VM_MAYSHARE;
if (!(file->f_mode & FMODE_WRITE))
vm_flags &= ~(VM_MAYWRITE | VM_SHARED);

/* fall through */
case MAP_PRIVATE:
if (!(file->f_mode & FMODE_READ))
return -EACCES;
if (path_noexec(&file->f_path)) {
if (vm_flags & VM_EXEC)
return -EPERM;
vm_flags &= ~VM_MAYEXEC;
}

if (!file->f_op->mmap)
return -ENODEV;
if (vm_flags & (VM_GROWSDOWN|VM_GROWSUP))
return -EINVAL;
break;

default:
return -EINVAL;
}
} else {
switch (flags & MAP_TYPE) {
case MAP_SHARED:
if (vm_flags & (VM_GROWSDOWN|VM_GROWSUP))
return -EINVAL;
/*
* Ignore pgoff.
*/
pgoff = 0;
vm_flags |= VM_SHARED | VM_MAYSHARE;
break;
case MAP_PRIVATE:
/*
* Set pgoff according to addr for anon_vma.
*/
pgoff = addr >> PAGE_SHIFT;
break;
default:
return -EINVAL;
}
}

/*
* Set 'VM_NORESERVE' if we should not account for the
* memory use of this mapping.
*/
if (flags & MAP_NORESERVE) {
/* We honor MAP_NORESERVE if allowed to overcommit */
if (sysctl_overcommit_memory != OVERCOMMIT_NEVER)
vm_flags |= VM_NORESERVE;

/* hugetlb applies strict overcommit unless MAP_NORESERVE */
if (file && is_file_hugepages(file))
vm_flags |= VM_NORESERVE;
}
//一顿检查和配置,调用核心的代码mmap_region
addr = mmap_region(file, addr, len, vm_flags, pgoff);
if (!IS_ERR_VALUE(addr) &&
((vm_flags & VM_LOCKED) ||
(flags & (MAP_POPULATE | MAP_NONBLOCK)) == MAP_POPULATE))
*populate = len;
return addr;
}
do_mmap() 根据用户传入的参数做了一系列的检查,然后根据参数初始化 vm_area_struct 的标志 vm_flags,vma->vm_file = get_file(file) 建立文件与vma的映射, mmap_region() 负责创建虚拟内存区域:

unsigned long mmap_region(struct file *file, unsigned long addr,
unsigned long len, vm_flags_t vm_flags, unsigned long pgoff)
{
struct mm_struct *mm = current->mm; //获取该进程的memory descriptor
struct vm_area_struct *vma, *prev;
int error;
struct rb_node **rb_link, *rb_parent;
unsigned long charged = 0;

/* 检查申请的虚拟内存空间是否超过了限制 */
if (!may_expand_vm(mm, vm_flags, len >> PAGE_SHIFT)) {
unsigned long nr_pages;

/*
* MAP_FIXED may remove pages of mappings that intersects with
* requested mapping. Account for the pages it would unmap.
*/
nr_pages = count_vma_pages_range(mm, addr, addr + len);

if (!may_expand_vm(mm, vm_flags,
(len >> PAGE_SHIFT) - nr_pages))
return -ENOMEM;
}

/* 检查[addr, addr+len)的区间是否存在映射空间,假如存在重合的映射空间需要munmap */
while (find_vma_links(mm, addr, addr + len, &prev, &rb_link,
&rb_parent)) {
if (do_munmap(mm, addr, len))
return -ENOMEM;
}

/*
* Private writable mapping: check memory availability
*/
if (accountable_mapping(file, vm_flags)) {
charged = len >> PAGE_SHIFT;
if (security_vm_enough_memory_mm(mm, charged))
return -ENOMEM;
vm_flags |= VM_ACCOUNT;
}

//检查是否可以合并[addr, addr+len)区间内的虚拟地址空间vma
vma = vma_merge(mm, prev, addr, addr + len, vm_flags,
NULL, file, pgoff, NULL, NULL_VM_UFFD_CTX);
if (vma) //假如合并成功,即使用合并后的vma, 并跳转至out
goto out;
//如果不能和已有的虚拟内存区域合并,通过Memory Descriptor来申请一个vma
vma = kmem_cache_zalloc(vm_area_cachep, GFP_KERNEL);
if (!vma) {
error = -ENOMEM;
goto unacct_error;
}
//初始化vma
vma->vm_mm = mm;
vma->vm_start = addr;
vma->vm_end = addr + len;
vma->vm_flags = vm_flags;
vma->vm_page_prot = vm_get_page_prot(vm_flags);
vma->vm_pgoff = pgoff;
INIT_LIST_HEAD(&vma->anon_vma_chain);

if (file) { //假如指定了文件映射
if (vm_flags & VM_DENYWRITE) { //映射的文件不允许写入,调用deny_write_accsess(file)排斥常规的文件操作
error = deny_write_access(file);
if (error)
goto free_vma;
}
if (vm_flags & VM_SHARED) { //映射的文件允许其他进程可见, 标记文件为可写
error = mapping_map_writable(file->f_mapping);
if (error)
goto allow_write_and_free_vma;
}

//递增File的引用次数,返回File赋给vma
vma->vm_file = get_file(file);
error = file->f_op->mmap(file, vma); //调用文件系统指定的mmap函数
if (error)
goto unmap_and_free_vma;

/* Can addr have changed??
*
* Answer: Yes, several device drivers can do it in their
* f_op->mmap method. -DaveM
* Bug: If addr is changed, prev, rb_link, rb_parent should
* be updated for vma_link()
*/
WARN_ON_ONCE(addr != vma->vm_start);

addr = vma->vm_start;
vm_flags = vma->vm_flags;
} else if (vm_flags & VM_SHARED) {
error = shmem_zero_setup(vma); //假如标志为VM_SHARED,但没有指定映射文件,需要调用shmem_zero_setup(),实际映射的文件是dev/zero
if (error)
goto free_vma;
}
//将申请的新vma加入mm中的vma链表
vma_link(mm, vma, prev, rb_link, rb_parent);
/* Once vma denies write, undo our temporary denial count */
if (file) {
if (vm_flags & VM_SHARED)
mapping_unmap_writable(file->f_mapping);
if (vm_flags & VM_DENYWRITE)
allow_write_access(file);
}
file = vma->vm_file;
out:
perf_event_mmap(vma);
//更新进程的虚拟地址空间mm
vm_stat_account(mm, vm_flags, len >> PAGE_SHIFT);
if (vm_flags & VM_LOCKED) {
if (!((vm_flags & VM_SPECIAL) || is_vm_hugetlb_page(vma) ||
vma == get_gate_vma(current->mm)))
mm->locked_vm += (len >> PAGE_SHIFT);
else
vma->vm_flags &= VM_LOCKED_CLEAR_MASK;
}

if (file)
uprobe_mmap(vma);

/*
* New (or expanded) vma always get soft dirty status.
* Otherwise user-space soft-dirty page tracker won't
* be able to distinguish situation when vma area unmapped,
* then new mapped in-place (which must be aimed as
* a completely new data area).
*/
vma->vm_flags |= VM_SOFTDIRTY;

vma_set_page_prot(vma);

return addr;

unmap_and_free_vma:
vma->vm_file = NULL;
fput(file);

/* Undo any partial mapping done by a device driver. */
unmap_region(mm, vma, prev, vma->vm_start, vma->vm_end);
charged = 0;
if (vm_flags & VM_SHARED)
mapping_unmap_writable(file->f_mapping);
allow_write_and_free_vma:
if (vm_flags & VM_DENYWRITE)
allow_write_access(file);
free_vma:
kmem_cache_free(vm_area_cachep, vma);
unacct_error:
if (charged)
vm_unacct_memory(charged);
return error;
}

munmap


当进程准备清除一个mmap映射时,就可以调用munmap函数


int munmap(void* __addr, size_t __size);

如demo例子所示,第一个函数是要删除的线性地址的第一个单元的地址,第二个函数是线性区的长度,由于这个函数比较简单,就是系统调用do_munmap函数,这里就不详细分析啦!


数据写回磁盘


经过上面的mmap操作,我们也知道了mmap的使用了,不知道大家有没有留意一个细节,就是上面图中我们内存写入的下一步是页高速缓存,这个时候真正的数据保存,其实还是没有真正写入到磁盘里面的,真正写入其实是靠系统调用写入,即msync函数


int msync(void* __addr, size_t __size, int __flags);

mmap的数据写入依靠着这个系统调用保证,即当前进程被异常销毁了,也可以通过这个系统级别的调用,把属于内存映射的脏页数据写回去磁盘。参数1跟2需要写回去的头地址跟大小,我们重点看一下最后一个参数flags,它有以下几个参数选择:



  • MS_SYNC:要求系统挂起调用进程,直到I/O操作完成

  • MS_ASYNC:可以系统调用立即返回,不用挂起调用进程(大多数使用到mmap库选择)

  • MS_INVALLDATE:要求系统调用从进程地址空间删除mmap映射的所有页
    这个函数主要功能是进行脏页标记,并进行真正的磁盘写入,大概的路径就是通过调用flush_tlb_page把缓冲器标记为脏页标识,同时获取文件对应的索引节点i_sem信号量,进行写入时的上锁,然后刷新到磁盘!


这里可以看到,mmap依靠系统调用,把数据刷新回到磁盘!虽然这个刷新动作是由linux系统进行刷入的,保证了进程出问题的时候,也能够在系统级别刷入数据,比如MMKV的设计就采用了这点,但是这个也不是百分百可靠的,因为这个刷入操作是没有备份操作的/异常容灾处理,如果系统异常或者断电的情况,就会出现错误数据或者没有完全刷入磁盘的数据,造成数据异常,我们也可以看到mmkv介绍


image.png


mmap不足


mmap最大的不足,就是被映射的线性区只能是按照页的整数倍进行计算,如果说我们要映射的内存少于这个数,也是按照一个页进行映射的,当然,一个页长度会根据不同架构而不同,比如常见的4kb等,同时也要时刻注意映射区的生命周期,不然无脑映射也很容易造成oom


扩展


当然,说到mmap,肯定也离不开对mmkv的介绍,虽然mmkv很优秀,但是也不能无脑就使用,这方面可以参考朱凯大佬发布的文章Android 的键值对存储有没有最优解?,这里就不再赘述,按照自己所需的使用,当然,jetpack的DataStore也是很香啊!有机会的话也出一篇解析文!


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

Kotlin协程-协程的暂停与恢复 & suspendCancellableCoroutine的使用

前言 之前在网上看到有人问协程能不能像线程一样 wait(暂停) 和 notify(恢复) 。 应用场景是开启一个线程然后执行一段逻辑,得到了某一个数据,然后需要拿到这个数据去处理一些别的事情,需要把线程先暂停,然后等逻辑处理完成之后再把线程 notify。 ...
继续阅读 »

前言


之前在网上看到有人问协程能不能像线程一样 wait(暂停) 和 notify(恢复) 。


应用场景是开启一个线程然后执行一段逻辑,得到了某一个数据,然后需要拿到这个数据去处理一些别的事情,需要把线程先暂停,然后等逻辑处理完成之后再把线程 notify。


首先我们不说有没有其他的方式实现,我当然知道有其他多种其他实现的方式。单说这一种逻辑来看,我们使用协程能不能达到同样的效果?


那么问题来了,协程能像线程那样暂停与恢复吗?


协程默认是不能暂停与恢复的,不管是协程内部还是返回的Job对象,都不能暂停与恢复,最多只能delay延时一下,无法精准控制暂停与恢复。


但是我们可以通过 suspendCancellableCoroutine 来间接的实现这个功能。


那问题又来了,suspendCancellableCoroutine 是个什么东西?怎么用?


一、suspendCancellableCoroutine的用法


很多人不了解这个类,不知道它是干嘛的,其实我们点击去看源码就知道,源码已经给出了很清晰的注释,并且还附带了使用场景



简单的说就是把Java/Kotlin 的一些回调方法,兼容改造成 suspend 的函数,让它可以运行在协程中。


以一个非常经典的例子,网络请求我们可以通过 Retrofit+suspend 的方式,也可以直接使用 OkHttp 的方式,我们很早之前都是自己封装 OkHttpUtils 的,然后以回调的方式返回正确结果和异常处理。


我们就可以通过 suspendCancellableCoroutine 把 OkHttpUtils 的回调方式进行封装,像普通的 suspend 方法一样使用了。


    fun suspendSth() {

viewModelScope.launch {

val school = mRepository.getSchool() //一个是使用Retrofit + suspend

try {
val industry = getIndustry() //一个是OkHttpUtils回调的方式
} catch (e: Exception) {
e.printStackTrace() //捕获OkHttpUtils返回的异常信息
}

}

}

private suspend fun getIndustry(): String? {
return suspendCancellableCoroutine { cancellableContinuation ->

OkhttpUtil.okHttpGet("http://www.baidu.com/api/industry", object : CallBackUtil.CallBackString() {

override fun onFailure(call: Call, e: Exception) {
cancellableContinuation.resumeWithException(e)
}

override fun onResponse(call: Call, response: String?) {
cancellableContinuation.resume(response)
}

})
}
}

感觉使用起来真是方便呢,那除了 suspendCancellableCoroutine 有没有其他的方式转换回调?有,suspendCoroutine,那它们之间的区别是什么?


suspendCancellableCoroutine 和 suspendCoroutine 区别


SuspendCancellableCoroutine 返回一个 CancellableContinuation, 它可以用 resume、resumeWithException 来处理回调和抛出 CancellationException 异常。


它与 suspendCoroutine的唯一区别就是 SuspendCancellableCoroutine 可以通过 cancel() 方法手动取消协程的执行,而 suspendCoroutine 没有该方法。


所以尽可能使用 suspendCancellableCoroutine 而不是 suspendCoroutine ,因为协程的取消是可控的。


那我们不使用回调直接用行不行?当然可以,例如:


  fun suspendSth() {

viewModelScope.launch {

val school = mRepository.getSchool()

if (school is OkResult.Success) {

val lastSchool = handleSchoolData(school.data)

YYLogUtils.w("处理过后的School:" + lastSchool)
}

}


}

private suspend fun handleSchoolData(data: List<SchoolBean>?): SchoolBean? {

return suspendCancellableCoroutine {

YYLogUtils.w("通过开启一个线程延时5秒再返回")
thread {
Thread.sleep(5000)

it?.resume(mSchoolList?.last(), null)
}

}
}

那怎么能达到协程的暂停与恢复那种效果呢?我们把参数接收一下,变成成员变量不就行了吗?想什么时候resume就什么时候resume。


二、实现协程的暂停与恢复


我们定义一个方法开启一个协程,内部使用一个 suspendCancellableCoroutine 函数包裹我们的逻辑(暂停),再定义另一个方法内部使用 suspendCancellableCoroutine 的 resume 来返回给协程(恢复)。


  fun suspendSth() {

viewModelScope.launch {

val school = mRepository.getSchool() //网络获取数据

if (school is OkResult.Success) {

val lastSchool = handleSchoolData(school.data)

//下面的不会执行的,除非 suspendCancellableCoroutine 的 resume 来恢复协程,才会继续走下去

YYLogUtils.w("处理过后的School:" + lastSchool)
}

}


}

private var mCancellableContinuation: CancellableContinuation<SchoolBean?>? = null
private var mSchoolList: List<SchoolBean>? = null

private suspend fun handleSchoolData(data: List<SchoolBean>?): SchoolBean? {

mSchoolList = data

return suspendCancellableCoroutine {

mCancellableContinuation = it

YYLogUtils.w("开启线程睡眠5秒再说")
thread {
Thread.sleep(5000)

YYLogUtils.w("就是不返回,哎,就是玩...")

}

}
}

//我想什么时候返回就什么时候返回
fun resumeCoroutine() {

YYLogUtils.w("点击恢复协程-返回数据")

if (mCancellableContinuation?.isCancelled == true) {
return
}

mCancellableContinuation?.resume(mSchoolList?.last(), null)

}

使用: 点击开启协程暂停了,再点击下面的按钮即恢复协程


fun testflow() {
mViewModel.suspendSth()
}

fun resumeScope() {
mViewModel.resumeCoroutine()
}

效果是点击开启协程之后我等了20秒恢复了协程,打印如下:



总结


协程虽然默认是不支持暂停与恢复,但是我们可以通过 suspendCancellableCoroutine 来间接的实现。


虽然如此,但实例开发上我还是不太推荐这么用,这样的场景我们有多种实现方式。可以用其他很好的方法实现,比如用一个协程不就好了吗串行执行,或者并发协程然后使用协程的通信来传递,或者用线程+队列也能做等等。真的一定要暂停住协程吗?不是不能实现,只是感觉不是太优雅。


(注:不好意思,这里有点主观意识了,大家不一定就要参考,毕竟它也只是一种场景需求实现的方式而已,只要性能没问题,所有的方案都是可行,大家按需选择即可)


当然关于 suspendCancellableCoroutine 谷歌的本意是让回调也能兼容协程,这也是它最大的应用场景。


本期内容如讲的不到位或错漏的地方,希望同学们可以指出交流。


如果感觉本文对你有一点点点的启发,还望你能点赞支持一下,你的支持是我最大的动力。


Ok,这一期就此完结。



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