注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

移动端防抓包实践

01.整体概述介绍1.1 项目背景通讯安全是App安全检测过程中非常重要的一项针对该项的主要检测手段就是使用中间人代理机制对网络传输数据进行抓包、拦截和篡改,以检验App在核心链路上是否有安全漏洞。保证数据安全通过charles等工具可以对app的网络请求进行...
继续阅读 »

01.整体概述介绍

1.1 项目背景

  • 通讯安全是App安全检测过程中非常重要的一项

    • 针对该项的主要检测手段就是使用中间人代理机制对网络传输数据进行抓包、拦截和篡改,以检验App在核心链路上是否有安全漏洞。

  • 保证数据安全

    • 通过charles等工具可以对app的网络请求进行抓包,这样这些信息就会被清除的提取出来,会被不法分子进行利用。

  • 不想被竞争对手逆向抓包

    • 不想自身App的数据被别人轻而易举地抓包获取到,从而进行类似业务或数据分析、爬虫或网络攻击等破坏性行为。

1.2 思考问题

  • 开发项目的时候,都需要抓包,很多情况下即使是Https也能正常抓包正常。那么问题来了:

    • 抓包的原理是?任何Https的 app 都能抓的到吗?如果不能,哪些情况下可以抓取,哪些情况下抓取不到?

  • 什么叫做中间人攻击?

    • 使用HTTPS协议进行通信时,客户端需要对服务器身份进行完整性校验,以确认服务器是真实合法的目标服务器。

    • 如果没有校验,客户端可能与仿冒的服务器建立通信链接,即“中间人攻击”。

1.3 设计目标

  • 防止App被各种方式抓包

    • 做好各种防抓包安全措施,避免各种黑科技抓包。

  • 沉淀为技术库复用

    • 目前只是针对App端有需要做防抓包措施,后期其他业务线可能也有这个需要。因此下沉为工具库,傻瓜式调用很有必要。

  • 该库终极设计目标如下所示

    • 第一点:必须是低入侵性,对原有代码改动少,最简单的加入是一行代码设置即可。完全解耦合。

    • 第二点:可以动态灵活配置,支持配置禁止代理,支持配置是否证书校验,支持配置域名合法性过滤,支持拦截器加解密数据。

    • 第三点:可以检测App是否在双开,挂载,Xposed攻击环境

    • 第四点:可以灵活设置加解密的key,可以灵活替换加解密方式,比如目前采用RC4,另一个项目想用DES,可以灵活更换。

1.4 收益分析

  • 抓包库收益

    • 提高产品App的数据安全,必须对数据传输做好安全保护措施和完整性校验,以防止自身数据在网络传输中裸奔,甚至是被三方恶意利用或攻击。

  • 技能的收益

    • 下沉为功能基础库,可以方便各个产品线使用,提高开发的效率。避免跟业务解耦合。傻瓜式调用,低成本接入!

02.市面抓包的分析

2.1 Https三要素

  • 要清楚HTTPS抓包的原理,首先需要先说清楚 HTTPS 实现数据安全传输的工作原理,主要分为三要素和三阶段。

  • Http传输数据目前存在的问题

    • 1.通信使用明文,内容可能被窃听;2.不验证通信方的身份,因此可能遭遇伪装;3.无法证明报文的完整性,所以有可能遭到篡改。


  • Https三要素分别是:

    • 1.加密:通过对称加密算法实现。

    • 2.认证:通过数字签名实现。(因为私钥只有 “合法的发送方” 持有,其他人伪造的数字签名无法通过验证)

    • 3.报文完整性:通过数字签名实现。(因为数字签名中使用了消息摘要,其他人篡改的消息无法通过验证)

  • Https三阶段分别是:

    • 1.CA 证书校验:CA 证书校验发生在 TLS 的前两次握手,客户端和服务端通过报文获得服务端 CA 证书,客户端验证 CA 证书合法性,从而确认 CA 证书中的公钥合法性(大多数场景不会做双向认证,即服务端不会认证客户端合法性,这里先不考虑)。

    • 2.密钥协商:密钥协商发生在 TLS 的后两次握手,客户端和服务端分别基于公钥和私钥进行非对称加密通信,协商获得 Master Secret 对称加密私钥(不同算法的协商过程细节略有不同)。

    • 3.数据传输:数据传输发生在 TLS 握手之后,客户端和服务端基于协商的对称密钥进行对称加密通信。

  • Https流程图如下


2.2 抓包核心原理

  • HTTPS抓包原理

    • Fiddler、Charles等抓包工具,其实都是采用了中间人攻击的方案: 将客户端的网络流量代理到MITM(中间人)主机,再通过一系列的面板或工具将网络请求结构化地呈现出来。

  • 抓包Https有两个突破点

    • CA证书校验是否合法;数据传递过程中的加密和解密。如果是要抓包,则需要突破这两点的技术,无非就是MITM(中间人)伪造证书和使用自己的加解密方式。

  • 抓包的工作流程如下

    • 中间人截获客户端向发起的HTTPS请求,佯装客户端,向真实的服务器发起请求;

    • 中间人截获真实服务器的返回,佯装真实服务器,向客户端发送数据;

    • 中间人获取了用来加密服务器公钥的非对称秘钥和用来加密数据的对称秘钥,处理数据加解密。

2.3 搞定CA证书

  • Https抓包核心CA证书

    • HTTPS抓包的原理还是挺简单的,简单来说,就是Charles作为“中间人代理”,拿到了服务器证书公钥和HTTPS连接的对称密钥。

    • 前提是客户端选择信任并安装Charles的CA证书,否则客户端就会“报警”并中止连接。这样看来,HTTPS还是很安全的。

  • 安装CA证书到手机中必须洗白

    • 抓包应用内置的 CA 证书要洗白,必须安装到系统中。而 Android 系统将 CA 证书又分为两种:用户 CA 证书和系统 CA 证书(必要Root权限)。

  • Android从7.0开始限制CA证书

    • 只有系统(system)证书才会被信任。用户(user)导入的Charles根证书是不被信任的。相当于可以理解Android系统增加了安全校验!

  • 如何绕过CA证书这种限制呢?已知有以下四种方式

    • 第一种方式:AndroidManifest 中配置 networkSecurityConfig,App 信任用户 CA 证书,让系统对用户 CA 证书的校验给予通过。

    • 第二种方式:调低 targetSdkVersion < 24,不过这种方式谷歌市场有限制,意味着抓 HTTPS 的包越来越难操作。

    • 第三种方式:挂载App抓包,VirtualApp 这种多开应用可以作为宿主系统来运行其它应用,利用xposed避开CA证书校验。

    • 第四种方式:Root手机,把 CA 证书安装到系统 CA 证书目录中,那这个假 CA 证书就是真正洗白了,难度较大。

2.4 突破CA证书校验

  • App版本如何让证书校验安全

    • 1.设置targetSdkVersion大于24,去掉清单文件中networkSecurityConfig文件中的system和user配置,设置不信任用户证书。

    • 2.公钥证书固定。指 Client 端内置 Server 端真正的公钥证书。在 HTTPS 请求时,Server 端发给客户端的公钥证书必须与 Client 端内置的公钥证书一致,请求才会成功。

      • 证书固定的一般做法是,将公钥证书(.crt 或者 .cer 等格式)内置到 App 中,然后创建 TrustManager 时将公钥证书加进去。

  • 那么如何突破CA证书校验

    • 第一种:JustTrustMe 破解证书固定。Xposed 和 Magisk 都有相应的模块,用来破解证书固定,实现正常抓包。破解的原理大致是,Hook 创建 SSLContext 等涉及 TrustManager 相关的方法,将固定的证书移除。

    • 第二种:基于 VirtualApp 的 Hook 机制破解证书固定。在 VirtualApp 中加入 Hook 代码,然后利用 VirtualApp 打开目标应用进行抓包。具体看:VirtualHook

2.5 如何搞定加解密

  • 目前使用对称加密和解密请求和响应数据

    • 加密和解密都是用相同密钥。只有一把密钥,如果密钥暴露,内容就会暴露。但是这一块逆向破解有些难度。而破解解密方式就是用密钥逆向解密,或者中间人冒充使用自己的加解密方式!

  • 加密后数据镇兼顾了安全性吗

    • 不一定安全。中间人伪造自己的公钥和私钥,然后拦截信息,进行篡改。

2.6 Charles原理

  • Charles类似代理服务器

    • Charles 通过将软件本身设置成系统的网络访问代理服务器,使得所有的网络请求都会走一遍 Charles 代理,从而 Charles 可以截取经过它的请求,然后我们就可以对其进行网络包的分析。

  • 截取设备网络封包数据

    • Charles对应设置:将代理功能打开,并设置一个固定的端口。默认情况下,端口号为:8888 。

    • 移动设备设置:在手机上设置 WIFI 的 HTTP 代理。注意这里的前提是,Phone 和 Charles 代理设备链接的是同一网络(同一个ip地址和端口号)。

  • 截取Https的网络封包

    • 正常情况下,Charles 是不能截取Https的网络包的,这涉及到 Https 的证书问题。

2.7 抓包原理图

  • Charles抓包原理图


  • Android上的网络抓包原来是这样工作的

    • Charles抓包

2.8 抓包核心流程

  • 抓包核心流程关键节点

    • 第一步,客户端向服务器发起HTTPS请求,charles截获客户端发送给服务器的HTTPS请求,charles伪装成客户端向服务器发送请求进行握手 。

    • 第二步,服务器发回相应,charles获取到服务器的CA证书,用根证书(这里的根证书是CA认证中心给自己颁发的证书)公钥进行解密,验证服务器数据签名,获取到服务器CA证书公钥。然后charles伪造自己的CA证书(这里的CA证书,也是根证书,只不过是charles伪造的根证书),冒充服务器证书传递给客户端浏览器。

    • 第三步,与普通过程中客户端的操作相同,客户端根据返回的数据进行证书校验、生成密码Pre_master、用charles伪造的证书公钥加密,并生成HTTPS通信用的对称密钥enc_key。

    • 第四步,客户端将重要信息传递给服务器,又被charles截获。charles将截获的密文用自己伪造证书的私钥解开,获得并计算得到HTTPS通信用的对称密钥enc_key。charles将对称密钥用服务器证书公钥加密传递给服务器。

    • 第五步,与普通过程中服务器端的操作相同,服务器用私钥解开后建立信任,然后再发送加密的握手消息给客户端。

    • 第六步,charles截获服务器发送的密文,用对称密钥解开,再用自己伪造证书的私钥加密传给客户端。

    • 第七步,客户端拿到加密信息后,用公钥解开,验证HASH。握手过程正式完成,客户端与服务器端就这样建立了”信任“。

  • 在之后的正常加密通信过程中,charles如何在服务器与客户端之间充当第三者呢?

    • 服务器—>客户端:charles接收到服务器发送的密文,用对称密钥解开,获得服务器发送的明文。再次加密, 发送给客户端。

    • 客户端—>服务端:客户端用对称密钥加密,被charles截获后,解密获得明文。再次加密,发送给服务器端。由于charles一直拥有通信用对称密钥enc_key,所以在整个HTTPS通信过程中信息对其透明。

03.防止抓包思路

3.1 先看如何抓包

  • 使用Charles需要做哪些操作

    • 1.电脑上需要安装证书。这个主要是让Charles充当中间人,颁布自己的CA证书。

    • 2.手机上需要安装证书。这个是访问Charles获取手机证书,然后安装即可。

    • 3.Android项目代码设置兼容。Google 推出更加严格的安全机制,应用默认不信任用户证书(手机里自己安装证书),自己的app可以通过配置解决,相当于信任证书的一种操作!

  • 尤其可知抓包的突破口集中以下几点

    • 第一点:必须链接代理,且跟Charles要具有相同ip。思路:客户端是否可以判断网络是否被代理了

    • 第二点:CA证书,这一块避免使用黑科技hook证书校验代码,或者拥有修改CA证书权限。思路:集中在可以判断是否挂载

    • 第三点:冒充中间人CA证书,在客户端client和服务端server之间篡改拦截数据。思路:可以做CA证书校验

    • 第四点:为了可以在7.0上抓包,App往往配置清单文件networkSecurityConfig。思路:线上环境去掉该配置

3.2 设置配置文件

  • 一个是CA证书配置文件

    • debug包为了能够抓包,需要配置networkSecurityConfig清单文件的system和user权限,只有这样才会信任用户证书。

  • 一个是检验证书配置

    • 不论是权威机构颁发的证书还是自签名的,打包一份到 app 内部,比如存放在 asset 里。然后用这个KeyStore去引导生成的TrustManager来提供证书验证。

  • 一个是检验域名合法性

    • Android允许开发者重定义证书验证方法,使用HostnameVerifier类检查证书中的主机名与使用该证书的服务器的主机名是否一致。

    • 如果重写的HostnameVerifier不对服务器的主机名进行验证,即验证失败时也继续与服务器建立通信链接,存在发生“中间人攻击”的风险。

  • 如何查看CA证书的数据

    • 证书验证网站 ;SSL配置检查网站

3.3 数据加密处理

  • 网络数据加密的需求

    • 为了项目数据安全性,对请求体和响应体加密,那肯定要知道请求体或响应体在哪里,然后才能加密,其实都一样不论是加密url里面的query内容还是加密body体里面的都一样。

  • 对数据哪里进行加密和解密

    • 目前对数据返回的data进行加解密。那么如何做数据加密呢?目前项目中采用RC4加密和解密数据。

  • 抓取到的内容为乱码

    • 有的APP为了防止抓取,在返回的内容上做了层加密,所以从Charles上看到的内容是乱码。这种情况下也只能反编译APP,研究其加密解密算法进行解密。难度极大!

3.4 避免黑科技抓包

  • 基于Xposed(或者)黑科技破解证书校验

    • 这种方式可以检查是否有Xposed环境,大概的思路是使用ClassLoader去加载固定包名的xp类,或者手动抛出异常然后捕获去判断是否包含Xposed环境。

  • 基于VirtualApp挂载App突破证书访问权限

    • 这个VirtualApp相当于是一个宿主App(可以把它想像成桌面级App),它突破证书校验。然后再实现挂载App的抓包。判断是否是双开环境!

04.防抓包实践开发

4.1 App安全配置

  • 添加配置文件

    • android:networkSecurityConfig="@xml/network_security_config"

  • 配置networkSecurityConfig抓包说明

    • 中间人代理之所有能够获取到加密密钥就是因为我们手机上安装并信任了其代理证书,这类证书安装后都会被归结到用户证书一类,而不是系统证书。

    • 那我们可以选择只信任系统内置的系统证书,而屏蔽掉用户证书(Android7.0以后就默认是只信任系统证书了),就可以防止数据被解密了。

  • 实现App防抓包安全配置方式有两种:

    • 一种是Android官方提供的网络安全配置;另一种也可以通过设置网络框架实现(以okhttp为例)。

    • 第一种:具体可以看清单配置文件,相当于base-config标签下去掉 这组标签。

    • 第二种:需要给okhttpClient配置 X509TrustManager 来监听校验服务端证书有效性。遍历设备上信任的证书,通过证书别名将用户证书(别名中含有user字段)过滤掉,只将系统证书添加到验证列表中。

  • 该方案优点和缺点分析说明

    • 优点:network_security_config配置简单,对整个app网络生效,无需修改代码;代码实现对通过该网络框架请求的生效,能兼容7.0以前系统。

    • 缺陷:network_security_config配置方式,7.0以前的系统配置不生效,依然可以通过代理工具进行抓包。okhttp配置的方式只能对使用该网络框架进行数据传输的接口生效,并不能对整个app生效。

    • 破解:将手机进行root,然后将代理证书放置到系统证书列表内,就可以绕过代码或配置检查了。

4.2 关闭代理

  • charles 和 fiddler 都使用代理来进行抓包,对网络客户端使用无代理模式即可防止抓包,如

    OkHttpClient.Builder()
       .proxy(Proxy.NO_PROXY)
       .build()
  • no_proxy实际上就是type属性为direct的一个proxy对象,这个type有三种

    • direct,http,socks。这样因为是直连,所以不走代理。所以charles等工具就抓不到包了,这样一定程度上保证了数据的安全,这种方式只是通过代理抓不到包。

  • 通常情况下上述的办法有用,但是无法防住使用 VPN 导流进行的抓包

    • 使用VPN抓包的原理是,先将手机请求导到VPN,再对VPN的网络进行Charles的代理,绕过了对App的代理。

  • 该方案优点和缺点分析说明

    • 优点:实现简单方便,无系统版本兼容问题。

    • 缺陷:该方案比较粗暴,将一切代理都切断了,对于有合理诉求需要使用网络代理的场景无法满足。

    • 破解:使用ProxyDroid全局代理工具通过iptables对请求进行强制转发,可以有效绕过代理检测。

4.3 证书校验(单向认证)

  • 下载服务器端公钥证书

    • 为了防止上面方案可能导致的“中间人攻击”,可以下载服务器端公钥证书,然后将公钥证书编译到Android应用中一般在assets文件夹保存,由应用在交互过程中去验证证书的合法性。

  • 如何设置证书校验

    • 通过OkHttp的API方法 sslSocketFactory(sslSocketFactory,trustManager) 设置SSL证书校验。

  • 如何设置域名合法性校验

    • 通过OkHttp的API方法 hostnameVerifier(hostnameVerifier) 设置域名合法性校验。

  • 证书校验的原理分析

    • 按CA证书去验证的,若不是CA可信任的证书,则无法通过验证。

  • 单向认证流程图


  • 该方案优点和缺点分析说明

    • 优点:安全性比较高,单向认证校验证书在代码中是方便的,安全性相对较高。

    • 缺陷:CA证书存在过期的问题,证书升级。

    • 破解:证书锁定破解比较复杂,比如老牌的JustTrustMe插件,通过hook各网络框架的证书校验方法,替换原有逻辑,使校验失效。

4.4 双向认证

  • 什么叫做双向认证

    • SSL/TLS 协议提供了双向认证的功能,即除了 Client 需要校验 Server 的真实性,Server 也需要校验 Client 的真实性。

  • 双向认证的原理

    • 双向认证需要 Server 支持,Client 必须内置一套公钥证书 + 私钥。在 SSL/TLS 握手过程中,Server 端会向 Client 端请求证书,Client 端必须将内置的公钥证书发给 Server,Server 验证公钥证书的真实性。

    • 用于双向认证的公钥证书和私钥代表了 Client 端身份,所以其是隐秘的,一般都是用 .p12 或者 .bks 文件 + 密钥进行存放。

  • 代码层面如何做双向认证

    • 双向校验就是自定义生成客户端证书,保存在服务端和客户端,当客户端发起请求时在服务端也校验客户端的证书合法性,如果不是可信任的客户端发送的请求,则拒绝响应。

    • 服务端根据自身使用语言和网络框架配置相应证书校验机制即可。

  • 双向认证流程图


  • 该方案优点和缺点分析说明

    • 优点:安全性非常高,使用三方工具不易破解。

    • 缺陷:服务端需要存储客户端证书,一般服务端会对应多个客户端,就需要分别存储和校验客户端证书,增加校验成本,降低响应速度。该方案比较适合对安全等级要求比较高的业务(如金融类业务)。

    • 破解:由于在服务端也做校验,在服务端安全的情况下很难被攻破。

4.5 防止挂载抓包

  • Xposed是一个牛逼的黑科技

    • Xposed + JustTrustMe 可以破解绕过校验CA证书。那么这样CA证书的校验就形同虚设了,对App的危险性也很大。

  • App多开运行在多个环境上

    • 多开App的原理类似,都是以新进程运行被多开的App,并hook各类系统函数,使被多开的App认为自己是一个正常的App在运行。

    • 一种是从多开App中直接加载被多开的App,如平行空间、VirtualApp等,另一种是让用户新安装一个App,但这个App本质上就是一个壳,用来加载被多开的App。

  • VirtualApp是一个牛逼的黑科技

    • 它破坏了Android 系统本身的隔离措施,可以进行免root hook和其他黑科技操作,你可以用这个做很多在原来APP里做不到事情,于此同时Virtual App的安全威胁也不言而喻。

  • 如何判断是否具有Xposed环境

    • 第一种方式:获取当前设备所有运行的APP,根据安装包名对应用进行检测判断是否有Xposed环境。

    • 第二种方式:通过自造异常来检测堆栈信息,判断异常堆栈中是否包含Xposed等字符串。

    • 第三种方式:通过ClassLoader检查是否已经加载了XposedBridge类和XposedHelpers类来检测。

    • 第四种方式:获取DEX加载列表,判断其中是否包含XposedBridge.jar等字符串。

    • 第五种方式:检测Xposed相关文件,通过读取/proc/self/maps文件,查找Xposed相关jar或者so文件来检测。

  • 如何判断是否是双开环境

    • 第一种方式:通过检测app私有目录,多开后的应用路径会包含多开软件的包名。还有一种思路遍历应用列表如果出现同样的包名,则被认为双开了。

    • 第二种方式:如果同一uid下有两个进程对应的包名,在"/data/data"下有两个私有目录,则该应用被多开了。

  • 判断了具有xposed或者多开环境怎么处理App

    • 目前使用VirtualApp挂载,或者Xposed黑科技去hook,前期可以先用埋点统计。测试学而思App发现挂载在VA上是推出App。

4.5 数据加解密

  • 针对数据加解密入口

    • 目前在网络请求类里添加拦截器,然后在拦截器中处理request请求和response响应数据的加密和解密操作。

  • 主要是加密什么数据

    • 在request请求数据阶段,如果是get请求加密url数据,如果是post请求则加密url数据和requestBody数据。

    • 在response响应数据阶段,

  • 如何进行加密:发起请求(加密)

    • 第一步:获取请求的数据。主要是获取请求url和requestBody,这一块需要对数据一块处理。

    • 第二步:对请求数据进行加密。采用RC4加密数据

    • 第三步:根据不同的请求方式构造新的request。使用 key 和 result 生成新的 RequestBody 发起网络请求

  • 如何进行解密:接收返回(解密)

    • 第一步:常规解析得到 result ,然后使用RC4工具,传入key去解密数据得到解密后的字符串

    • 第二步:将解密的字符串组装成ResponseBody数据传入到body对象中

    • 第三步:利用response对象去构造新的response,然后最后返回给App

4.7 证书锁定

  • 证书锁定是Google官方比较推荐的一种校验方式

    • 原理是在客户端中预先设置好证书信息,握手时与服务端返回的证书进行比较,以确保证书的真实性和有效性。

  • 如何实现证书锁定

    • 有两种实现方式:一种通过network_security_config.xml配置,另一种通过代码设置;

      //第一种方式:配置文件 api.zuoyebang.cn 38JpactkIAq2Y49orFOOQKurWxmmSFZhBCoQYcRhK90= 9k1a0LRMXouZHRC8Ei+4PyuldPDcf3UKgO/04cDM90K=

      //第二种方式:代码设置 fun sslPinning(): OkHttpClient { val builder = OkHttpClient.Builder() val pinners = CertificatePinner.Builder() .add("api.zuoyebang.cn", "sha256//89KpactkIAq2Y49orFOOQKurWxmmSFZhBCoQYcRh00L=") .add("api.zuoyebang.com", "sha256//a8za0LRMXouZHRC8Ei+4PyuldPDcf3UKgO/04cDM1o=09") .build() builder.apply { certificatePinner(pinners) } return builder.build() }

  • 该方案优点和缺点分析说明

    • 优点:安全性高,配置方式也比较简单,并能实现动态更新配置。

    • 缺陷:网络安全配置无法实现证书证书的动态更新,另外该配置也受Android系统影响,对7.0以前的系统不支持。代码配置相对灵活些。

    • 破解:证书锁定破解比较复杂,比如老牌的JustTrustMe插件,通过hook各网络框架的证书校验方法,替换原有逻辑,使校验失效

4.8 Sign签名

  • 先说一下背景和问题

    • api.test.com/getbanner?k…

    • 这种方式简单粗暴,通过调用getbanner方法即可获取轮播图列表信息,但是这样的方式会存在很严重的安全性问题,没有进行任何的验证,大家都可以通过这个方法获取到数据,导致产品信息泄露。

  • 在写开放的API接口时是如何保证数据的安全性的?

    • 请求来源(身份)是否合法?请求参数被篡改?请求的唯一性(不可复制)?

  • 问题的解决方案设想

    • 解决方案:为了保证数据在通信时的安全性,我们可以采用参数签名的方式来进行相关验证。

  • 最终决定的解决方案

    • 调用接口之前需要验证签名和有效时间,要生成一个sign签名。先拼接-后转码-再加密-再发请求!

  • sign签名校验实践

    • 需要对请求参数进行签名验证,签名方式如下:key1=value1&key2=value2&key3=value3&secret=yc 。对这个字符串进行md5一下。

    • 然后被sign后的接口就变成了:api.test.com/getbanner?k…

    • 为什么在获取sign的时候建议使用secret参数?secret仅作加密使用,添加在参数中主要是md5,为了保证数据安全请不要在请求参数中使用。

  • 服务端对sign校验

    • 这样请求的时候就需要合法正确签名sign才可以获取数据。这样就解决了身份验证和防止参数篡改问题,如果请求参数被人拿走,没事,他们永远也拿不到secret,因为secret是不传递的。再也无法伪造合法的请求。

  • 如何保证请求的唯一性

    • api.test.com/getbanner?k…

    • 通过stamp时间戳用来验证请求是否过期。这样就算被人拿走完整的请求链接也是无效的。

  • Sign签名安全性分析:

    • 通过上面的案例,安全的关键在于参与签名的secret,整个过程中secret是不参与通信的,所以只要保证secret不泄露,请求就不会被伪造。

05.架构设计说明

5.1 整体架构设计

  • 如下所示


5.2 关键流程图

5.3 稳定性设计

  • 对于请求和响应的数据加解密要注意

    • 在网络上交换数据(网络请求数据)时,可能会遇到不可见字符,不同的设备对字符的处理方式有一些不同。

    • Base64对数据内容进行编码来适合传输。准确说是把一些二进制数转成普通字符用于网络传输。统统变成可见字符,这样出错的可能性就大降低了。

5.4 降级设计

  • 可以一键配置AB测试开关

    .setMonitorToggle(object : IMonitorToggle {
       override fun isOpen(): Boolean {
           //todo 是否降级,如果降级,则不使用该功能。留给AB测试开关
          return false
      }
    })

5.5 异常设计说明

  • base64加密和解密导致错误问题

    • Android 有自带的Base64实现,flag要选Base64.NO_WRAP,不然末尾会有换行影响服务端解码。导致解码失败。

5.6 Api文档

  • 关于初始化配置

    NotCaptureHelper.getInstance().config = CaptureConfig.builder()
           //设置debug模式
      .setDebug(true)
           //设置是否禁用代理
      .setProxy(false)
           //设置是否进行数据加密和解密,
      .setEncrypt(true)
           //设置cer证书路径
      .setCerPath("")
           //设置是否进行CA证书校验
      .setCaVerify(false)
           //设置加密和解密key
      .setEncryptKey(key)
           //设置参数
      .setReservedQueryParam(OkHttpBuilder.RESERVED_QUERY_PARAM_NAMES)
      .setMonitorToggle(object : IMonitorToggle {
           override fun isOpen(): Boolean {
               //todo 是否降级,如果降级,则不使用该功能。留给AB测试开关
              return false
          }
      })
      .build()
  • 设置okHttp配置

    NotCaptureHelper.getInstance().setOkHttp(app,okHttpBuilder)
  • 如何设置自己的加解密方式

    NotCaptureHelper.getInstance().encryptDecryptListener = object : EncryptDecryptListener {
       /**
        * 外部实现自定义加密数据
        */
       override fun encryptData(key: String, data: String): String {
           LoggerReporter.report("NotCaptureHelper", "encryptData data : $data")
           val str = data.encryptWithRC4(key) ?: ""
           LoggerReporter.report("NotCaptureHelper", "encryptData str : $str")
           return str
      }
       /**
        * 外部实现自定义解密数据
        */
       override fun decryptData(key: String, data: String): String {
           LoggerReporter.report("NotCaptureHelper", "decryptData data : $data")
           val str = data.decryptWithRC4(key) ?: ""
           LoggerReporter.report("NotCaptureHelper", "decryptData str : $str")
           return str
      }
    }

5.7 防抓包功能自测

  • 网络请求测试

    • 正常请求,测试网络功能是否正常

  • 抓包测试

    • 配置fiddler,charles等工具

    • 手机上设置代理

    • 手机上安装证书

    • 单向认证测试:进行网络请求,会提示SSLHandshakeException即ssl握手失败的错误提示,即表示app端的单向认证成功。

    • 数据加解密:进行网络请求,看一下请求参数和响应body数据是否加密,如果看不到实际json实体则表示加密成功。

防抓包库:github.com/yangchong21…

综合库:github.com/yangchong21…

视频播放器:github.com/yangchong21…

作者:杨充
来源:juejin.cn/post/7175325220109025339

收起阅读 »

2022年年终杂谈,如何成为出色的工程师

重新认识自己我一直对 NodeJS 工具方向比较感兴趣,今年终于有机会在公司内,开始工具项目的研发,调研并设计了整体架构、独立负责开发工作。去年年末时,我刚刚来到字节半年,其实心态还没有完全转换过来,之前在创业公司,涉及了很多不同端的开发工作。所以我对自己的定...
继续阅读 »

重新认识自己

我一直对 NodeJS 工具方向比较感兴趣,今年终于有机会在公司内,开始工具项目的研发,调研并设计了整体架构、独立负责开发工作。

去年年末时,我刚刚来到字节半年,其实心态还没有完全转换过来,之前在创业公司,涉及了很多不同端的开发工作。所以我对自己的定位,还处于一个支持业务开发的状态,对技术渴望度足够,但对于技术路线有些许迷茫。只能看到一些比较聚焦度比较高的技术名词,例如 WASMWebGL,会对这些技术存在追捧,却并没有做到脚踏实地。

相比去年的我,今年我对一些概念又有了许多新的见解,到底什么样才是出色的工程师?

当今国内工程师的问题

按照国内对工程师的区分,在各厂招聘列表中经常出现的是这几类,前端/后端/客户端工程师。

在我的工作中,经常会接触各端 SDK 的开发的同学,接触过程中,有时感觉到大家会存在一些 gap,也就是说 SDK 同学想去做一些事情,但是在他的角度他很明白底层逻辑,但对于其他端(前/后)同学,他们对底层原理其实并不了解。这就造成开发前,需要很长的时间去对齐功能的逻辑,合作同学也很难理解需求的意义与价值。

如果想摆脱这些困惑,那我认为你需要成为一名「全栈工程师」,其实说是全栈,不如是「软件工程师」,作为一名工程师,需要拥有一些闭环整体开发流程的能力。

例如,以 SDK 开发的角度来说,对于 SDK 同学,实际上最终只负责到上报这个动作,至于之后数据的流向,以及数据清洗,并不在掌控的范畴之内。这虽然降低了 SDK 侧上手的门槛,但并不利于长期的维护。

假如你是一个软件工程师,对于以下流程,你都了如指掌:

数据上报 -> 清洗 -> 存储 -> 消费

那你对于系统的整体认知就会提升,从优化上,可以给出更好的建议;在排查问题时,也可以更快速的定位问题。

何为工程师?

下面我详细谈一谈,如何成为一个有宏观视角的工程师。

首先,我目前的关注点主要在前端方向,如果你有仔细观察,你可以看到最终大部分比较厉害的前端,都是具有一些全栈能力的人。

对于服务端层面,我的建议是把 Golang 学好,这是一个还不错的方向。一个技术栈,如果有很多人关注聚焦,广泛地提出问题,那它的发展前景一定是不错的,起码不会垮掉,也就是说,开发生态是健康的。

对于前端层面,如果想去把前端学的很深入的话,那么前端工具以及工程化,必不可少。

在这一年内,我扩展一些自己原本不是很擅长的领域:

  • 产品

    • 竞品调研

    • PRD 撰写

  • 服务端方向

    • MySQL

    • Rust

    • Golang

  • 前端方向

    • 单测/e2e:Jest、@testing-library/react、Cypress

    • 工程化:Rush.js、Pnpm、Webpack、TypeScript

    • 工具:Babel、CLI 相关的 npm 包工具

    • 插件:Chrome、VSCode

  • 设计

    • Figma 学习

  • 英语学习

除了开发角色,一个合格的工程师,还应该掌握技术方案设计的能力,这样可以将整体的开发流程闭环。也就是说一个人扮演,调研、方案设计、编码、测试的工作。从我的 Roadmap 中,你也可以看出来这一点。为什么要闭环呢,当一个需求,有越来越多的角色参与进来的时候,你会发现方案细节的对齐,变成了一个不简单的工作。

有时候我们经常会讲一个词,融会贯通。当你把一整套研发体系都吃下来的时候,你会发现可以顺利地解决掉项目的问题。

我的工作场景

在我的工作中,会涉及到工具链的开发。首先在开始前,需要做一些竞品调研,方案设计的工作。

开发环节,对前端来说,按照目前的趋势,我们更好的方式是以 Monorepo 的形式去做开发。这里 Pnpm 就是一个很不错的选择,但接下来你会遇到一些问题,例如如何去做这些包的发版编排?

由于在 Workspace 中会存在一些包之间的相互引用,在发版时,也要按照拓扑排序的方式进行发版,这时,我们就可以用到 Rush.js 去做拓扑发版,以及自动生成 Changelog

工具链对于质量需要较强的把控,这时我们就要引入 Jest 做单测,但一些场景下,单测是不够的,这时我们需要引入 e2e 测试。

Monorepo 中,不像单仓中,可能只存在一个 tsconfig,这时会存在配置之间 extends 的关系,需要我们对 tsconfig 的配置了如指掌。

对于多种工具消费方式,例如 CLIChrome 插件等,实则需要公用一些方法与配置,这里就需要抽象出公用的 utils 等。

在开发中,可能会关注一些新闻,比如 Vite 4 启用了 SWC 替代 Babel做编译。那你是否有好奇过,为什么 SWC 会更快,这时候如果学过 Rust,就知道 Rust 特有的语言特性。

总结

我想说的是,作为一个工程师,不要去把自己划分为「前端/后端/ PM」这些更加细分的角色。你都可以去学习任何方面的知识。并且你学的一切知识,都是有意义的。虽然学习的道路很长,但只要坚持下去,你就会朝着优秀的工程师进发。

作者:EricLee
来源:juejin.cn/post/7181000277208760378

收起阅读 »

给你的网站接入 github 提供的第三方登录

web
什么年代了还在用传统账号密码登录?没钱买手机号验证码组合?直接把鉴权和用户系统全盘托出依赖第三方(又不是不能用),省去鉴权系列 SQL 攻击、密码加密、CSRF 攻击、XSS 攻击,老板再也不用担心黑产盗号了(我们的系统根本没有号)要实现上面的功能就得接入第三...
继续阅读 »

什么年代了还在用传统账号密码登录?没钱买手机号验证码组合?直接把鉴权和用户系统全盘托出依赖第三方(又不是不能用),省去鉴权系列 SQL 攻击、密码加密、CSRF 攻击、XSS 攻击,老板再也不用担心黑产盗号了(我们的系统根本没有号)


要实现上面的功能就得接入第三方登录,接下来就随着文章一起试试吧!

github

本章节将使用 github 作为第三方登录服务提供商

github 不愧是阿美力卡之光,极其简便的操作即可开启你的第三方登录之旅,经济又实惠,你可以通过快捷链接进入创建 OAuth 应用界面,也可以按照下面的顺序


然后填写相应的信息


生成你的密钥(Client secrets),就可以去试试第三方登录了


组合 URL

您可以在线查看本章节源代码

这里我使用的是 express-generator 去生成项目,并且前后端分离,在选项上不需要 HTML 渲染器

npx express --no-view your-project-path && cd your-project-path

前端部分简单设置一下跳转验证

<html>
 <body>
   <div>
    第三方登录
     <br />
     <button onclick="handleGithubLoginClick()">github</button>
   </div>
 </body>
 <script>
   const handleGithubLoginClick = () => {
     const state = Math.floor(Math.random() * Math.pow(10, 8));
     localStorage.setItem("state", state);
     window.open(
       `https://github.com/login/oauth/authorize?client_id=b351931efd1203b2230e&redirect_uri=http://localhost:8080&state=${state}`,
       "_blank"
    );
  };
 </script>
</html>

其中有三个比较重要的 params

  1. client_id - string - “必需”。 注册时从 GitHub 收到的客户端 ID。

  2. redirect_uri - string - 用户获得授权后被发送到的应用程序中的 URL。 请参阅以下有关重定向 URL 的详细信息。

  3. state - string - 不可猜测的随机字符串。 它用于防止跨站请求伪造攻击。

redirect_uri 默认是注册 OAuth 应用(Register a new OAuth application)是填写的授权回调 URLAuthorization callback URL

而对于 state 就在前端用随机字符串模拟,通常此类加密的敏感数据会再后端生成,而这里为了方便演示就采用了前端生成

详细参数请参考文档


鉴权验证

登录之后就可以进行相对应的验证,比如输入账号密码、授权、Github 客户端验证


成功鉴权后会再新弹出的页面重定向redirect_uri


注意要在属于用户操作的范畴下,比如点击按钮的操作,去使用 window.open(strUrl, strWindowName, [strWindowFeatures]) 这种方式去跳转鉴权,否则像 window.open("https://github.com...", "_blank") 这种常见的写法,会报错


浏览器会以为是弹窗式广告,所以我推荐使用直接在当前窗口跳转的方法,而不是选择新开窗口或者浮动窗口

window.location.href = "https://github.com/login/oauth/authorize?client_id=b351931efd1203b2230e&redirect_uri=http://localhost:8080";

处理回调

通过用户授权时,Github 的响应如下

GET redirect_uri

参数

名称类型说明
codestring鉴权通过的响应代码
statestring请求第三方登录时防 csrf 凭证

state 参数负责安全非常重要,想要快速通关的选手可以跳过这部分

对于这里的 state 处理可以分为前端处理和后端处理

前端处理

redirect_uri 是前端路由时,可以将之前提交的 statelocalStorage 或者 sessionStorage 中取出,验证是否一致,再去向后端请求并带上 statecode

优点

  1. 无需缓存 state

缺点

  1. 需要防止 XSSDOM 型攻击

后端处理

redirect_uri 是后端时,后端需要持有 state 的缓存,具体做法可以在前端处理第三方登录时同步随机生成的 state,并在后端缓存

优点

  1. 不需要防止 XSSDOM 型攻击

缺点

  1. 需要缓存 state

科普:早期 token 其实就是这里的 state

获取 token

第三方登录从本质上来讲就是获取到 token,在安全的拿到 codestate 之后,需要向 github 发送获取 token 请求,其文档如下

POST https://github.com/login/oauth/access_token

参数

名称类型说明
client_idstring必填。 从 GitHub 收到的 OAuth App 的客户端 ID。
client_secretstring必填。 从 GitHub 收到的 OAuth App 的客户端密码。
codestring必填。 收到的作为对步骤 1 的响应的代码。
redirect_uristring用户获得授权后被发送到的应用程序中的 URL。

响应

名称类型说明
access_tokenstringgithubtoken
scopestring参考文档
token_typestringtoken 类型

注意因为 client_secret 属于私钥,所以该请求必须放在后端,不能在前端请求!否则会失去登录的意义

const { default: axios } = require("axios");
const express = require("express");
const router = express.Router();

router.post("/redirect", function (req, res, next) {
 const { code } = req.body;
 axios({
   method: "POST",
   url: "https://github.com/login/oauth/access_token",
   headers: {
     "Accept": "application/json",
  },
   timeout: 60 * 1000,
   data: {
     client_id: "your_client_id",
     client_secret: "your_client_secret",
     code,
  },
})
  .then((response) => {
     res.send(response.data);
  })
  .catch((e) => {
     res.status(404);
  });
});

module.exports = router;

注意,由于 github 的服务器在国外,所以这个请求非常容易超时或者失效,建议做好对应的处理(或者设置一个比较长的时间)

最后拿到对应的 token


总结

如果还没有了解过第三方登录的同学可以试试,毕竟不需要审核,有对应的 github 账号就行,截至写完文章的现在,我仍然没有通过微博第三方登录的审核/(ㄒoㄒ)/~~

参考资料

  1. 授权 OAuth 应用 - Github Docs

  2. 给你的网站添加第三方登录以及短信验证功能

作者:2分钟速写快排
来源:juejin.cn/post/7181114761394782269

收起阅读 »

electron-egg 当代桌面开发框架,轻松入门electron

当前技术社区中出现了各种下一代技术或框架,却很少有当代可以用的,于是electron-egg就出现了。它愿景很大:希望所有开发者都能学会桌面软件开发当前桌面软件技术有哪些?语言技术优点缺点C#wpf专业的桌面软件技术,功能强大学习成本高Javaswing/ja...
继续阅读 »

当前技术社区中出现了各种下一代技术或框架,却很少有当代可以用的,于是electron-egg就出现了。

它愿景很大:希望所有开发者都能学会桌面软件开发

当前桌面软件技术有哪些?

语言技术优点缺点
C#wpf专业的桌面软件技术,功能强大学习成本高
Javaswing/javaFx跨平台和语言流行GUI库少,界面不美观
C++Qt跨平台,功能和类库丰富学习成本高
Swift非跨平台,文档不友好,UI库少
JSelectron跨平台,入门简单,UI强大,扩展性强内存开销大,包体大。

为什么使用electron?


某某说:我们的应用要兼容多个平台,原生开发效率低,各平台研发人员不足,我们没有资源。

也许你觉得只是中小公司没有资源,no!大公司更没有资源。因为软件体量越大,所需研发人员越多。再加上需要多平台支持的话,研发人员更是指数级增长的。

我们来看看QQ团队负责人最近的回应吧:

“感谢大家对新版桌面QQ NT的使用和关注,今年QQ团队启动了QQ的架构升级计划,第一站就是解决目前桌面端迭代慢的问题,我们使用新架构从前到后对QQ代码进行了重构,而其中选择使用Electron作为新版QQ桌面端UI跨平台解决方案,是基于提升研发效率、框架成熟度、团队技术及人才积累等几个方面综合考虑的结果。”

也许electron的缺点很明显,但它的投入产出比却是最高的。

所以,对企业而言,效率永远是第一位的。不要用程序员的思维去思考产品。

哪些企业或软件在使用electron?

国内:抖音客户端、百度翻译、阿里云盘、B站客户端、迅雷、网易有道云、QQ(doing) 等

国外:vscode、Slack、Atom、Discord、Skype、WhatsApp、等

你的软件用户体量应该没有上面这些公司多吧?所以你还有什么可担心的呢?

开发者 / 决策者不要去关心性能、包体大小这些东西,当你的产品用户少时,它没意义;当你的产品用户多时,找nb的人把它优化。

聊聊electron-egg框架

EE是一个业务框架;就好比 Spring之于java,thinkphp之于php,nuxt.js之于vue;electron只提供了基础的函数和api,但你写项目的时候,业务和代码工程化是需要自己实现的,ee就提供了这个工程化能力。

特性

  • 🍄 跨平台:一套代码,可以打包成windows版、Mac版、Linux版、国产UOS、Deepin、麒麟等

  • 🌹 简单高效:只需学习 js 语言

  • 🌱 前端独立:理论上支持任何前端技术,如:vue、react、html等等

  • 🌴 工程化:可以用前端、服务端的开发思维,来编写桌面软件

  • 🍁 高性能:事件驱动、非阻塞式IO

  • 🌷 功能丰富:配置、通信、插件、数据库、升级、打包、工具... 应有尽有

  • 🌰 安全:支持字节码加密、压缩混淆加密

  • 💐 功能demo:桌面软件常见功能,框架集成或提供demo

谁可以使用electron-egg?

前端、服务端、运维、游戏等技术人员皆可使用。我相信在你的工作生涯中,或多或少都接触过js,恭喜你,可以入门了。

为什么各种技术栈的开发者都能使用electron-egg?

这与它的架构有关。


第一:前端独立

你可以用vue、react、angular等开发框架;也可用antdesign、layui、bootstrap等组件库;或者你用cococreater开发游戏也行; 框架只需要最终构建的资源(html/css/js)。

第二:工程化-MVC编程模式

如果你是java、php、python等后端开发者,不懂js那一套编程模式怎么办?

没关系,框架已经为你提供了MVC(controller/service/model/view),是不是很熟悉?官方提供了大量业务场景demo,直接开始撸代码吧。

开箱即用

编程方法、插件、通信、日志、数据库、调试、脚本工具、打包工具等开发需要的东西,框架都已经提供好了,你只需要专注于业务的实现。

十分钟体验

安装
# 下载
git clone https://gitee.com/dromara/electron-egg.git

# 安装依赖
npm install

# 启动
npm run start
效果


界面中的功能是demo,方便初学者入门。

项目案例

EE框架已经应用于医疗、学校、政务、股票交易、ERP、娱乐、视频、企业等领域客户端

以下是部分开发者使用electron-egg开发的客户端软件,请看效果




后语

仓库地址,欢迎给项目点赞!

gitee gitee.com/dromara/ele… 2300+

github github.com/dromara/ele… 500+

关于 Dromara

Dromara 是由国内顶尖的开源项目作者共同组成的开源社区。提供包括分布式事务,流行工具,企业级认证,微服务RPC,运维监控,Agent监控,分布式日志,调度编排等一系列开源产品、解决方案与咨询、技术支持与培训认证服务。技术栈全面开源共建、 保持社区中立,致力于为全球用户提供微服务云原生解决方案。让参与的每一位开源爱好者,体会到开源的快乐。

Dromara开源社区目前拥有10+GVP项目,总star数量超过十万,构建了上万人的开源社区,有成千上万的个人及团队在使用Dromara社区的开源项目。

electron-egg已加入dromara组织。

作者:哆啦好梦
来源:juejin.cn/post/7181279242628366397

收起阅读 »

Android URL Scheme数据还原流程与踩坑分享

前言 最近在搞URL Scheme数据还原相关代码的重构工作,借此梳理一下整体的流程。并且在重构过程中呢,还遇到了一个天坑,拿出来与大家分享一下。如果大家有更好的方案,欢迎评论或私信我让我学习一下~ 前置知识点 首先我们对齐一下所需要的前置知识点,避免后面造成...
继续阅读 »

前言


最近在搞URL Scheme数据还原相关代码的重构工作,借此梳理一下整体的流程。并且在重构过程中呢,还遇到了一个天坑,拿出来与大家分享一下。如果大家有更好的方案,欢迎评论或私信我让我学习一下~


前置知识点


首先我们对齐一下所需要的前置知识点,避免后面造成理解上的冲突。


URL Scheme


URL Scheme指的是遵守以下格式的URL:


{scheme://action?param1=value1&param2=value2...}


APP识别到URL Scheme数据后,会根据action去执行相应的逻辑。


scheme通常由业务定义好,一般以app层级划分或业务域层级划分,比如"taobao://"、"douyin://",或者"tbSearch://"、"douyinSearch://"。action指的是行为,比如"user/detail"是打开个人详情页面,"item/detail"是打开商品详情页面等。再由后面的参数决定具体的页面数据。举个例子:


{wodeApp://user/detail?userId=123}


wodeApp识别到这个Url Scheme以wodeApp开头,就知道是它需要的数据,进而解析数据,打开userId为123的用户页面。


URL Scheme来源


Scheme数据的来源可以有很多,最常见的就是剪贴板、H5页面唤端、消息通知唤端、短信通知唤端等。因为后面的内容会涉及到数据来源,场景又比较复杂可能会比较混乱,所以这里我们先理清一下。


我们把所有的唤端(包括H5页面唤端、消息通知唤端、短信通知唤端)统一一下,都称为Intent唤端,因为他们最终给到App的数据都是放在Intent中的,所以后面讲到唤端就不再一一区分了。


那么我们现在能拿到URL Scheme的场景就分为四种:



  1. 冷启动时从剪贴板获取

  2. 热启动时从剪贴板获取

  3. 冷启动时从唤端Intent中获取

  4. 热启动时从唤端Intent中获取


为什么要分冷热启动呢?因为冷热启动,URL Scheme获取的方式是不一样的,具体后面会说到。


数据还原


数据还原,在产品上是非常重要的。最基本的一种数据还原,就是跳转目标页面。比如用户被消息推送了某个商品,点击进来后根据解析得到的Scheme数据我们需要跳转到指定的商品详情页面。另外,我们可能还需要根据解析的Scheme数据向服务端发起某个请求,比如从平价商品页面唤端来的用户我们需要打上用户标签。


所有的根据action指定的业务逻辑,我们都称之为数据还原。


产品的迭代历程


上面也讲到了,我是因为重构才有机会写这篇文章的。那为什么要重构呢?自然是代码hold不住产品的迭代速度了,这就要从产品的需求讲起。(当然,需求的迭代只是重构的原因之一,更主要的原因是之前的代码没封装,写的很乱,职责不清晰,所以才把重构提上日程的..)


有一天,PD找上门来
PD:咱们做个简单的唤端哈,从消息通知进来,或者从H5页面唤端进来,我们能打开相应的页面就行。另外,如果剪贴板里有这样的数据,也要能达到一样的效果。

程序员A:没问题,这项技术已经很成熟了,马上给你搞出来

最终这个需求的实现,也基本上不存在什么问题。唤端的Intent数据从闪屏页拿到后,传递到首页,首页再根据数据执行相应的Action。另外在首页onResume生命周期中获取剪贴板数据,如果符合Scheme数据协议,也去做相应的Action。


过了一个月,PD又找上门来
PD:咱们唤端需要再做一个通用能力哈。如果唤端数据带了某个api的某个参数,需要在下次请求这个api的时候把这个参数给带上,从而满足服务端数据的定制化能力。当然了,还是跟上次一样,如果剪贴板里有这样的数据,也要能达到一样的效果。

程序员A:为啥要这样搞啊?有啥用?

PD:你想啊,比如首页的推荐流理论上对每个人都是不一样的。那如何实现更精准地推送呢?唤端就是一个手段。每个唤端页面唤端的时候,都带上用户相关的数据,然后把这份数据作为接口参数传给服务端,不就可以实现定向推送了嘛。

程序员A:你很有想法,但是我得想一想...

糟了,之前的剪贴板相关的代码要重写了。为什么呢?因为之前是在首页onResume生命周期中获取剪贴板数据,如果剪贴板数据符合Scheme数据协议,就去做相应的Action。但这个新的需求,又必须保证得在首页请求发出去之前,就要拿到剪贴板数据并预埋好接口参数,否则就不会起作用了。比如用户冷启App时,如果不在闪屏页预先拿到剪贴板数据并预埋上首页的接口参数的话,到首页做这个逻辑就没法保证是在首页接口请求前完成参数的预埋了。


那这个逻辑是要放在闪屏页么?也不对,因为在热启App时,是不会经过闪屏页的,但热启时也要有这样的能力,这就要我们必须把解析剪贴板的这段逻辑放在BaseActivity中去


下面就来分享一下URL Scheme数据还原改善后的流程。


数据还原流程


剪贴板


冷启:闪屏页onWindowFocusChanged获取剪贴板数据->解析scheme数据(执行预埋接口参数等Action)->跳转首页->首页跳转至目标页面->清空剪贴板


热启:BaseActivity#onWindowFocusChanged获取剪贴板数据->解析(执行预埋接口参数等Action)->跳转目标页面->清空剪贴板


因为某些原因,我们的项目中闪屏页没有继承BaseActivity,所以这里分开了两个部分。如果大家都是统一继承BaseActivity的,那么这部分解析scheme的逻辑是可以合二为一的。


唤端


冷启:闪屏页onCreate获取唤端Intent->解析scheme数据(执行预埋接口参数等Action)->跳转首页->首页跳转至目标页面->清空剪贴板


热启:闪屏页onCreate获取唤端Intent->解析scheme数据(执行预埋接口参数等Action)->跳转目标页面->清空剪贴板


总结



  • 唤端的逻辑全部在闪屏页的onCreate生命周期做。只有在冷启唤端时需要先跳转至首页,首页再跳转至模板页面。

  • 剪贴板的逻辑,冷启时在闪屏页做剪贴板的获取与解析,热启时在页面基类做剪贴板的获取与解析,解析完数据后统一在页面基类进行目标页面的跳转。之所以放在页面基类而不是首页,是因为热启回APP后可能处于任意一个页面,所以这段逻辑只能放到基类里面去处理。


另外需要注意的一点是,闪屏页的LaunchMode需要设置为singleTask,否则唤端启动时新创建的闪屏页会到浏览器的栈去,不符合业务需求。


踩坑分享


在这个过程中,我也踩了一个大坑..没想到Android对剪贴板的获取有这样的限制。细心的同学可能已经发现了,在重构前我们是在首页的onResume生命周期去获取剪贴板的,去网上一搜获取剪贴板数据,大部分的回答都是这样:


override fun onResume() {
window.decorView.post{
val content = ClipboardService.getInstance().clipboardContent
}
}

那为什么在方案设计中,却是在onWindowFocusChanged回调中才去获取剪贴板数据呢?因为上面的代码,在部分场景(尤其是闪屏页),是没法保证能拿到剪贴板数据的。


原因


Android获取剪贴板存在限制,必须在当前Activity获得焦点的情况下才能成功获取到。


闪屏页的生命周期:onCreate->onResume->跳转页面->onPause


闪屏页获取焦点时的回调:onWindowFocusChanged(boolean hasFocus);当回调中hasFocus收到true时,表面当前Activity窗口获取到了焦点。


经试验,当闪屏页跳转页面过快,部分机型(如Redmi k40 pro)onWindowFocusChanged会回调false,收不到true,即一直没有获得过焦点,那么这种情况下就无法获取剪贴板数据(拿到是空字符串)。所以获取剪贴板数据的时机,不能太早,也不能太晚。不能在onCreate中去获取剪贴板数据,也不能等到发生跳转了再去拿。


其次,因为onWindowFocusChanged回调时机必在onResume之后,所以即使我们在onResume中post去拿剪贴板,我们也没法保证post的Runnable执行的时机是正正好的。有可能Runnable执行时,闪屏页已经发生跳转了。也有可能Runnable执行时,闪屏页还未获取到焦点。


所以呢,我们应该把获取剪贴板数据的时机放到onWindowFocusChanged中去,而闪屏页冷启跳转首页的逻辑,也要放到onWindowFocusChanged之后,保证闪屏页已经获取到焦点了,且成功获取到剪贴板数据了。


总结


通过这篇文章,我们知道了URL Scheme数据还原的整体流程。如果大家实际业务中没有类似“根据唤端数据,预埋首页接口参数”这样的需求,其实可以比较简单地就实现了。另外,分享了一下Android上获取剪贴板数据所存在的限制,以及在实际业务中遇到的坑该怎么解决。


文章不足之处,还望大家多多海涵,多多指点,先行谢过!


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

Android代码静态检查(lint、Checkstyle、ktlint、Detekt)

Android代码静态检查(lint、Checkstyle、ktlint、Detekt)在Android项目开发过程中,开发团队往往要花费大量的时间和精力发现并修改代码缺陷。静态代码分析工具能够在代码构建过程中帮助开发人员快速、有效的定位代码缺陷并及时纠正这些...
继续阅读 »

Android代码静态检查(lint、Checkstyle、ktlint、Detekt)

Android项目开发过程中,开发团队往往要花费大量的时间和精力发现并修改代码缺陷。

静态代码分析工具能够在代码构建过程中帮助开发人员快速、有效的定位代码缺陷并及时纠正这些问题,从而极大地提高软件可靠性

节省软件开发和测试成本。

Android目前主要使用的语言为kotlinjava,所以我们需要尽可能支持这两种语言。

Lint

Android Studio 提供的代码扫描工具。通过进行 lint 检查来改进代码

能检测什么?是否包含潜在错误,以及在正确性、安全性、性能、易用性、便利性和国际化方面是否需要优化改进,帮助我们发现代码结/质量问题,同时提供一些解决方案。每个问题都有信息描述和等级。

支持【300+】检测规则,支持Manifest文件XMLJavaKotlinJava字节码Gradle文件Proguard文件Propetty文件和图片资源;

基于抽象语法树分析,经历了LOMBOK-AST、PSI、UAST三种语法分析器;

主要包括以下几个方面

  • Correctness:不够完美的编码,比如硬编码、使用过时 API 等;
  • Performance:对性能有影响的编码,比如:静态引用,循环引用等;
  • Internationalization:国际化,直接使用汉字,没有使用资源引用等;
  • Security:不安全的编码,比如在 WebView 中允许使用 JavaScriptInterface 等

在module下的build.gradle中添加以下代码:

android {
lintOptions {
// true--关闭lint报告的分析进度
quiet true
// true--错误发生后停止gradle构建
abortOnError false
// true--只报告error
ignoreWarnings true
// true--忽略有错误的文件的全/绝对路径(默认是true)
//absolutePaths true
// true--检查所有问题点,包含其他默认关闭项
checkAllWarnings true
// true--所有warning当做error
warningsAsErrors true
// 关闭指定问题检查
disable 'TypographyFractions','TypographyQuotes'
// 打开指定问题检查
enable 'RtlHardcoded','RtlCompat', 'RtlEnabled'
// 仅检查指定问题
check 'NewApi', 'InlinedApi'
// true--error输出文件不包含源码行号
noLines true
// true--显示错误的所有发生位置,不截取
showAll true
// 回退lint设置(默认规则)
lintConfig file("default-lint.xml")
// true--生成txt格式报告(默认false)
textReport true
// 重定向输出;可以是文件或'stdout'
textOutput 'stdout'
// true--生成XML格式报告
xmlReport false
// 指定xml报告文档(默认lint-results.xml)
//xmlOutput file("lint-report.xml")
// true--生成HTML报告(带问题解释,源码位置,等)
htmlReport true
// html报告可选路径(构建器默认是lint-results.html )
//htmlOutput file("lint-report.html")
// true--所有正式版构建执行规则生成崩溃的lint检查,如果有崩溃问题将停止构建
checkReleaseBuilds true
// 在发布版本编译时检查(即使不包含lint目标),指定问题的规则生成崩溃
fatal 'NewApi', 'InlineApi'
// 指定问题的规则生成错误
error 'Wakelock', 'TextViewEdits'
// 指定问题的规则生成警告
warning 'ResourceAsColor'
// 忽略指定问题的规则(同关闭检查)
ignore 'TypographyQuotes'
}
}

运行./gradlew lint,检测结果在build/reports/lint/lint.html可查看详情。

lint-result-preview.png

CheckStyle

Java静态代码检测工具,主要用于代码的编码规范检测 。

CheckStyleGralde自带的PluginThe Checkstyle Plugin

通过分析源码,与已知的编码约定进行对比,以html或者xml的形式将结果展示出来。

其原理是使用Antlr库对源码文件做词语发分析生成抽象语法树,遍历整个语法树匹配检测规则。

目前不支持用户自定义检测规则,已有的【100+】规则中,有一部分规则是有属性的支持设置自定义参数。

在module下的build.gradle中添加以下代码:

/**
* The Checkstyle Plugin
*
* Gradle plugin that performs quality checks on your project's Java source files using Checkstyle
* and generates reports from these checks.
*
* Tasks:
* Run Checkstyle against {rootDir}/src/main/java: ./gradlew checkstyleMain
* Run Checkstyle against {rootDir}/src/test/java: ./gradlew checkstyleTest
*
* Reports:
* Checkstyle reports can be found in {project.buildDir}/build/reports/checkstyle
*
* Configuration:
* Checkstyle is very configurable. The configuration file is located at {rootDir}/config/checkstyle/checkstyle.xml
*
* Additional Documentation:
* https://docs.gradle.org/current/userguide/checkstyle_plugin.html
*/

apply plugin: 'checkstyle'
checkstyle {
//configFile = rootProject.file('checkstyle.xml')
configProperties.checkstyleSuppressionsPath = rootProject.file("suppressions.xml").absolutePath
// The source sets to be analyzed as part of the check and build tasks.
// Use 'sourceSets = []' to remove Checkstyle from the check and build tasks.
//sourceSets = [project.sourceSets.main, project.sourceSets.test]
// The version of the code quality tool to be used.
// The most recent version of Checkstyle can be found at https://github.com/checkstyle/checkstyle/releases
//toolVersion = "8.22"
// Whether or not to allow the build to continue if there are warnings.
ignoreFailures = true
// Whether or not rule violations are to be displayed on the console.
showViolations = true
}
task projectCheckStyle(type: Checkstyle) {
group 'verification'
classpath = files()
source 'src'
//include '**/*.java'
//exclude '**/gen/**'
reports {
html {
enabled = true
destination file("${project.buildDir}/reports/checkstyle/checkstyle.html")
}
xml {
enabled = true
destination file("${project.buildDir}/reports/checkstyle/checkstyle.xml")
}
}
}
tasks.withType(Checkstyle).each { checkstyleTask ->
checkstyleTask.doLast {
reports.all { report ->
// 检查生成报告中是否有错误
def outputFile = report.destination
if (outputFile.exists() && outputFile.text.contains("<error ") && !checkstyleTask.ignoreFailures) {
throw new GradleException("There were checkstyle errors! For more info check $outputFile")
}
}
}
}
// preBuild的时候,执行projectCheckStyle任务
//project.preBuild.dependsOn projectCheckStyle
project.afterEvaluate {
if (tasks.findByName("preBuild") != null) {
project.preBuild.dependsOn projectCheckStyle
println("project.preBuild.dependsOn projectCheckStyle")
}
}

默认情况下,Checkstyle插件希望将配置文件放在根项目中,但这可以更改。

<root>
└── config
└── checkstyle
└── checkstyle.xml //Checkstyle 配置
└── suppressions.xml //主Checkstyle配置文件

执行preBuild就会执行checkstyle并得到结果。

checkstyle-result-preview.png

支持Kotlin

怎么实现Kotlin的代码检查校验呢?我找到两个富有意义的方法。

1. Detekt — https://github.com/arturbosch/detekt 2. ktlint — https://github.com/shyiko/ktlint

KtLint

添加插件依赖

buildscript {
dependencies {
classpath "org.jlleitschuh.gradle:ktlint-gradle:11.0.0"
}
}

引入插件,完善相关配置:

apply plugin: "org.jlleitschuh.gradle.ktlint"
ktlint {
android = true
verbose = true
outputToConsole = true
outputColorName = "RED"
enableExperimentalRules = true
ignoreFailures = true
//["final-newline", "max-line-length"]
disabledRules = []
reporters {
reporter "plain"
reporter "checkstyle"
reporter "sarif"
reporter "html"
reporter "json"
}
}
project.afterEvaluate {
if (tasks.findByName("preBuild") != null) {
project.preBuild.dependsOn tasks.findByName("ktlintCheck")
println("project.preBuild.dependsOn tasks.findByName(\"ktlintCheck\")")
}
}

运行prebuild,检测结果在build/reports/ktlint/ktlintMainSourceSetCheck/ktlintMainSourceSetCheck.html可查看详情。

ktlint-result-preview.png

Detekt

添加插件依赖

buildscript {
dependencies {
classpath "io.gitlab.arturbosch.detekt:detekt-gradle-plugin:1.22.0"
}
}

引入插件,完善相关配置(PS:可以在yml文件配置相关的规则):

apply plugin: 'io.gitlab.arturbosch.detekt'
detekt {
// Version of Detekt that will be used. When unspecified the latest detekt
// version found will be used. Override to stay on the same version.
toolVersion = "1.22.0"

// The directories where detekt looks for source files.
// Defaults to `files("src/main/java", "src/test/java", "src/main/kotlin", "src/test/kotlin")`.
source = files(
"src/main/kotlin",
"src/main/java"
)

// Builds the AST in parallel. Rules are always executed in parallel.
// Can lead to speedups in larger projects. `false` by default.
parallel = false

// Define the detekt configuration(s) you want to use.
// Defaults to the default detekt configuration.
config = files("$rootDir/config/detekt/detekt-ruleset.yml")

// Applies the config files on top of detekt's default config file. `false` by default.
buildUponDefaultConfig = false

// Turns on all the rules. `false` by default.
allRules = false

// Specifying a baseline file. All findings stored in this file in subsequent runs of detekt.
//baseline = file("path/to/baseline.xml")

// Disables all default detekt rulesets and will only run detekt with custom rules
// defined in plugins passed in with `detektPlugins` configuration. `false` by default.
disableDefaultRuleSets = false

// Adds debug output during task execution. `false` by default.
debug = false

// If set to `true` the build does not fail when the
// maxIssues count was reached. Defaults to `false`.
ignoreFailures = true

// Android: Don't create tasks for the specified build types (e.g. "release")
//ignoredBuildTypes = ["release"]

// Android: Don't create tasks for the specified build flavor (e.g. "production")
//ignoredFlavors = ["production"]

// Android: Don't create tasks for the specified build variants (e.g. "productionRelease")
//ignoredVariants = ["productionRelease"]

// Specify the base path for file paths in the formatted reports.
// If not set, all file paths reported will be absolute file path.
//basePath = projectDir
}
tasks.named("detekt").configure {
reports {
// Enable/Disable XML report (default: true)
xml.required.set(true)
xml.outputLocation.set(file("build/reports/detekt/detekt.xml"))
// Enable/Disable HTML report (default: true)
html.required.set(true)
html.outputLocation.set(file("build/reports/detekt/detekt.html"))
// Enable/Disable TXT report (default: true)
txt.required.set(true)
txt.outputLocation.set(file("build/reports/detekt/detekt.txt"))
// Enable/Disable SARIF report (default: false)
sarif.required.set(true)
sarif.outputLocation.set(file("build/reports/detekt/detekt.sarif"))
// Enable/Disable MD report (default: false)
md.required.set(true)
md.outputLocation.set(file("build/reports/detekt/detekt.md"))
custom {
// The simple class name of your custom report.
reportId = "CustomJsonReport"
outputLocation.set(file("build/reports/detekt/detekt.json"))
}
}
}
project.afterEvaluate {
if (tasks.findByName("preBuild") != null) {
project.preBuild.dependsOn tasks.findByName("detekt")
println("project.preBuild.dependsOn tasks.findByName(\"detekt\")")
}
}

运行prebuild,检测结果在build/reports/detekt/detekt.html可查看详情。

detekt-result-preview.png

总结

GitHub Demo

CheckStyle不支持kotlinKtlinDetekt两者对比Ktlint它的规则不可定制,Detekt 工作得很好并且可以定制,尽管插件集成看起来很新。虽然输出的格式都支持html,但显然Detekt输出的结果的阅读体验更好一些。

以上相关的插件因为都支持命令行运行,所以都可以结合Git 钩子,它用于检查即将提交的快照,例如,检查是否有所遗漏,确保测试运行,以及核查代码。

不同团队的代码的风格不尽相同,不同的项目对于代码的规范也不一样。目前项目开发中有很多同学几乎没有用过代码检测工具,但是对于一些重要的项目中代码中存在的缺陷、性能问题、隐藏bug都是零容忍的,所以说静态代码检测工具尤为重要。


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

Flutter 实现手写签名效果

如何使用Flutter实现手写签名的效果 思路 需要监听用户触摸的起始点和结束点,并记录途经点,这里我使用了StreamController 将途经点从起始位置到结束位置绘制出来,这里用到CustomPainter 绘制流程 获取触摸点作为画笔的起始点...
继续阅读 »

如何使用Flutter实现手写签名的效果


Simulator Screen Shot - iPhone 12 - 2022-12-23 at 11.32.23.png


思路



  • 需要监听用户触摸的起始点和结束点,并记录途经点,这里我使用了StreamController

  • 将途经点从起始位置到结束位置绘制出来,这里用到CustomPainter


绘制流程



  1. 获取触摸点作为画笔的起始点

  2. 手机途经点

  3. 绘制途径路线

  4. 结束触摸点重置画笔


具体实现


需要一个Listener用来监听用户行为,并将这些行为的点添加到StreamController中,
两个变量



final List _points = []; //承载对应的点

final StreamController _controller = StreamController(); //数据通信



Widget _buildWriteWidget() {
return Stack(
children: [
Listener( //用来监听用户的触摸行为
child: Container(
color: Colors.transparent,
),
onPointerDown: (PointerDownEvent event) {
_points.add(event.localPosition);
_controller.sink.add([_points]); //起始点的记录
},
onPointerMove: (PointerMoveEvent event) {
_points.add(event.localPosition);
_controller.sink.add([_points]); //添加途经点
},
onPointerUp: (PointerUpEvent event) {
_points.add(Offset.zero); //结束的标记
},
),
StreamBuilder(
stream: _controller.stream,
builder: (BuildContext context, AsyncSnapshot snapshot) {
return snapshot.hasData
? CustomPaint(painter: LinePainter(snapshot.data)) //关联数据到Painter
: const SizedBox();
}),
Positioned(
bottom: 50,
right: 50,
child: FloatingActionButton(
onPressed: () {
_clear();
},
child: const Icon(Icons.cleaning_services),
))
],
);
}

清除StreamController的内容,重置数据


void _clear() {
_points.clear();
_controller.add(null);
}

dispose时释放StreamController


@override
void dispose() {
_controller.close();
super.dispose();
}

画笔Painter


class LinePainter extends CustomPainter {
final List<List<Offset>> lines;
final Color paintColor = Colors.black;
final Paint _paint = Paint();

LinePainter(this.lines);

@override
void paint(Canvas canvas, Size size) {
_paint.strokeCap = StrokeCap.round;
_paint.strokeWidth = 5.0;
if (lines.isEmpty) {
canvas.drawPoints(PointMode.polygon, [Offset.zero, Offset.zero], _paint);
} else {
for (int i = 0; i < lines.length; i++) {
for (int j = 0; j < lines[i].length - 1; j++) {
if (lines[i][j] != Offset.zero && lines[i][j + 1] != Offset.zero) {
canvas.drawLine(lines[i][j], lines[i][j + 1], _paint); //绘制相应的点
}
}
}
}
}

@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}

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

ChatGPT进入百度“弱智吧”后,疯了

无论你玩不玩贴吧,“弱智吧”的大名很多人应该听说过。如今弱智吧的关注人数已经超过了200万。。。不得不说,能将这么多“弱智”聚在一起,“弱智吧”撑起了后·百度贴吧时代的辉煌。。。来一起感受下“弱智吧”的日常:能问出这些问题,确实脑回路已经超越普通人了。。。弱智...
继续阅读 »

无论你玩不玩贴吧,“弱智吧”的大名很多人应该听说过。


如今弱智吧的关注人数已经超过了200万。。。

不得不说,能将这么多“弱智”聚在一起,“弱智吧”撑起了后·百度贴吧时代的辉煌。。。

来一起感受下“弱智吧”的日常:


能问出这些问题,确实脑回路已经超越普通人了。。。

弱智吧的存在已经够离谱了,更离谱的是,弱智吧官方微博把这几天火出圈的ChatGPT与“弱智吧”做了连接——让ChatGPT去回答弱智吧上的问题。


真是离谱他爸给离谱开门,离谱到家了。。。

“弱智”与“弱AI”的较量,从此揭开了序幕。来,一起感受下!

弱智提问1


弱智提问2


这个回答属实有点“社交牛逼症”了。我觉得别再沉溺于图灵测试了,这已经满足不了ChatGPT了。笔者觉得有必要直接给ChatGPT测下情商

弱智提问3


这个回答,让笔者一时分不清ChatGPT是认真的还是故意的。。。

弱智提问4


弱智提问5


弱智提问6


我已经20多岁了,还能开写轮眼吗?

弱智提问7


弱智提问8


突然不知道该怎么反驳,我果然既不如弱智,也不如AI

弱智提问9


弱智提问10


弱智提问11


弱智提问12


弱智提问13


AI,这波是你输了

弱智提问14


我觉得这波AI赢了

那么问题来了,你认为是人类创造的“弱智问题”赢了?还是AI创造的“机智回答”赢了?


最后,笔者还找了一些弱智吧的牛逼问题,手里有ChatGPT账号的读者小伙伴可以在评论区分享测试结果:

开放问题1


开放问题2


开放问题3


作者:兔子酱
来源:夕小瑶的卖萌屋

收起阅读 »

Java中多线程的ABA问题探讨

前言  本文是笔者在日常开发过程中遇到的对 CAS 、 ABA 问题以及 JUC(java.util.concurrent)中 AtomicReference 相关类的设计的一些思考记录。 对需要处理 ABA 问题,或有诸如笔者一样的设计疑问探索好奇心的读者可...
继续阅读 »

前言

  本文是笔者在日常开发过程中遇到的对 CAS 、 ABA 问题以及 JUC(java.util.concurrent)中 AtomicReference 相关类的设计的一些思考记录。 对需要处理 ABA 问题,或有诸如笔者一样的设计疑问探索好奇心的读者可能会带来一些启发。

本文主体由三部分构成:

  1. 首先阐述多线程场景数据同步的常用语言工具

  2. 接着阐述什么是 ABA 问题,以及产生的原因和可能带来的影响

  3. 再探索 JUC 中官方为解决 ABA 问题而做一些工具类设计

文章的最后会对多线程数据同步常用解决方案做了简短地经验性总结与概括。

受限于笔者的理解与知识水平,文章的一些术语表述难免可能会失偏颇,对于有理解歧义或争议的部分,欢迎大家探讨和指正。

一、异步场景常用工具

在Java中的多线程数据同步的场景,常会出现:

  1. 关键字 volatile

  2. 关键字 synchronized

  3. 可重入锁/读写锁 java.util.concurrent.locks.*

  4. 容器同步包装,如 Collections.synchronizedXxx()

  5. 新的线程安全容器,如 CopyOnWriteArrayList/ConcurrentHashMap

  6. 阻塞队列 java.util.concurrent.BlockingQueue

  7. 原子类 java.util.concurrent.atomic.*

  8. 以及 JUC 中其他工具诸如 CountDownLatch/Exchanger/FutureTask 等角色。

  其中 volatile 关键字用于刷新数据缓存,即保证在 A 线程修改某数据后,B 线程中可见,这里面涉及的线程缓存和指令重排因篇幅原因不在本文探讨范围之内。而不论是 synchronized 关键字下的对象锁,还是基于同步器 AbstractQueuedSynchronizerLock 实现者们,它们都属于悲观锁。而在同步容器包装、新的线程程安全容器和阻塞队列中都使用的是悲观锁;只是各类的内部使用不同的 Lock 实现类和 JUC 工具,另外不同容器在加锁粒度和加锁策略上分别做了处理和优化。

  这里值得一说的,也是本文聚焦的重点则是原子类,即 java.util.concurrent.atomic.* 包下的几个类库诸如 AtomicBoolean/AtomicInteger/AtomicReference

二、CAS 与 ABA 问题

  我们知道在使用悲观锁的场景中,如果有有一个线程抢先取得了锁,那么其他想要获得锁的线程就得被阻塞等待,直到占锁线程完成计算释放锁资源。而现代 CPU 提供了硬件级指令来实现同步原语,也就是说可以让线程在运行过程中检测是否有其他线程也在对同一块内存进行读写,基于此 Java 提供了使用忙循环来取代阻塞的系列工具类 AutomicXxx,这属于是一种乐观锁的实现。其常规使用方式形如:

public class Requester {
   private AtomicBoolean isRequesting = new AtomicBoolean(false)

   public void request() {
       // 修改成功时返回true;compareAndSet 方法由 Native 层调硬件指令实现
       if (!isRequesting.compareAndSet(false, true)) {
           return;
      }
       try {
           // do sth...
      } finally {
           isRequesting.set(false)
      }
  }
}

  进入到 JDK11 AtomicBoolean 的源码中,可以看到 compareAndSet 最终调用 Native 层的方式如下。其实在旧的版本中 JDK 是使用 Unsafe 类处理的,在入参数中有传入状态变量的字段偏移值,新版本则将两者封装到 VarHandle 中采用DL方式查找依赖(笔者猜测可能和JDK9模块化改造有关):

// 旧版
public class AtomicBoolean {
   private static final sun.misc.Unsafe U = sun.misc.Unsafe.getUnsafe();
   private static final long VALUE;
   static {
       try {
           VALUE = U.objectFieldOffset
              (AtomicBoolean.class.getDeclaredField("value"));
      } catch (ReflectiveOperationException e) {
           throw new Error(e);
      }
  }

   private volatile int value;

   public final boolean compareAndSet(boolean expect, boolean update) {
       return U.compareAndSwapInt(this, VALUE, (expect ? 1 : 0), (update ? 1 : 0));
  }
}

// 新版
public class AtomicBoolean {
   private static final VarHandle VALUE;
   static {
       try {
           MethodHandles.Lookup l = MethodHandles.lookup();
           VALUE = l.findVarHandle(AtomicBoolean.class, "value", int.class);
      } catch (ReflectiveOperationException e) {
           throw new ExceptionInInitializerError(e);
      }
  }

   private volatile int value;

   public final boolean compareAndSet(boolean expectedValue, boolean newValue) {
       return VALUE.compareAndSet(this, (expectedValue ? 1 : 0), (newValue ? 1 : 0));
  }
}

  犹如入仓有 thisvalue 的偏移值,则 Native 层可根据此二者值定位到某块栈内存,这样对于基本类型没什么问题。原子类型体系中使用 AtomicReference 来引用复合类型实例,但 Java 中 Object 类型在栈中保存的只是堆中对象数据块的地址,其结构形如下图:


  而实际运行过程中,调用 AtomicReference#compareAndSet() 时,Native层只会对比栈中内存的值,而不会关注其指向的堆中数据。这样说可能有点抽象,看一段实验代码:

StringBuilder varA = new StringBuilder("abc");
StringBuilder varB = new StringBuilder("123");

AtomicReference<StringBuilder> ref = new AtomicReference<>(varA);
ref.compareAndSet(varA, varB); // (1)
System.out.println(ref.get()); // (2) varB->123
varB.append('4'); // (3) changed varB->1234
if (ref.compareAndSet(varB, varA)) { // (4)
   System.out.println("CAS succeed"); // (5) CAS succeed
}
System.out.println(ref.get()); // abc

喜欢动手的读者可以尝试自定义一个类,观察下 Compare 过程是否真的没有调用对象的 equals 方法。

  ref 在经过处理后再 (2) 处引用变量B,而在注释 (3) 处将 B 值修改了,但由于原子类不会检查堆中数据,所以还是能通过注释 (4) 处的相等比较走到注释 (5) 。这也就引入了 所谓的 ABA 问题:

  1. 假设,线程 1 的任务希望将变量从 A 变为 C ,但执行到一半被线程 2 抢走 CPU

  2. 线程 2 将变量从 A 改成了 B ,此时 CPU 时间片又被系统分给了线程 3

  3. 线程 3 讲变量从 B 又设置成一个新的 A 。

  4. 线程 1 获取时间片,检查变量发现其仍然是 A(但 A 对象内部的数据已经改变了),检查通过将变量置为 C 。

  若业务场景中,线程 1 不在意变量经过了一轮变化,也不在意 A 中数据是否有变化,则该问题无关痛痒。而若线程 1 对这两个变化敏感,则将变量置为 C 的操作就不符合预期了。用维基百科的例子来表述,其大意是:

你提着有很多现金的包去机场,这时来了个辣妹挑逗你,并趁你不注意时用一个看起来一样的空包换了你的现金包,然后她就走了;此时你检查了下发现你的包还在,于是就匆忙拿着包赶飞机去了。

换个角度看这几个关键字:

  • 有现金的包:指向堆中数据的栈引用

  • 辣妹挑逗:其他线程抢占 CPU

  • 看起来一样空包:其他线程修改堆中数据

  • 发现包还在:仅检查栈中内存的地址值是否一致

三、用 JUC 工具处理 ABA 问题

  为处理 ABA 问题,JDK 提供了另外两个工具类:AtomicMarkableReferenceAtomicStampedReference 他们除了对比栈中对象的引用地址外,另外还保存了一个 booleanint 类型的标记值,用于 CAS 比较。

StringBuilder varA = new StringBuilder("abc");
StringBuilder varB = new StringBuilder("123");

AtomicStampedReference<StringBuilder> ref = new AtomicStampedReference<>(varA, varA.toString().hashCode());
ref.compareAndSet(varA, varB, varA.toString().hashCode(), varB.toString().hashCode());
System.out.println(ref.get(new int[1]));
varB.append('4');
// CAS失败,因为Stamp值对不上
if (ref.compareAndSet(varB, varA, varB.toString().hashCode(), varA.toString().hashCode())) {
   System.out.println("compareAndSet: succeed");
}
System.out.println(ref.get(new int[1]));

:这种设计和为快速判断文件是否相同,而比较文件摘要值(MD5、SHA值)和预期是否一致的思想倒有异曲同工之妙。

总结

  通常在多线程场景中,这些工具的应用场景具有各自的适用特征:

  1. 若各线程读写数据没有竞争关系,则可考虑仅使用 volatile 关键字;

  2. 若各线程对某数据的读写需要去重,则可优先考虑使用乐观锁实现,即用原子类型;

  3. 若各线程有竞争关系且不去重必须按顺序抢占某资源,即必须用锁阻塞,若没有多条件队列的诉求则可先考虑使用 synchronized 添加对象锁(但需注意锁对象的不可变和私有化),否则考虑用 Lock 实现类,但特别的如需读写分锁以实现共享锁则只能用 Lock 了。

  4. 若需使用线程安全容器,出于性能考虑优先考虑 java.util.concurrent.* 类,如 ConcurrentHashMapCopyOnWriteArrayList;再考虑使用容器同步包装 Collections.synchronizedXxx()。而阻塞队列则多用于生产-消费模型中的任务容器,典型如用在线程池中。

作者:Chavin
来源:juejin.cn/post/7181077489211408443

收起阅读 »

纯 JS 简单实现类似 404 可跳跃障碍物页面

web
废话开篇:一些 404 页面为了体现趣味性会添加一些简单的交互效果。 这里用纯 JS 简单实现类似 404 可跳跃障碍物页面,内容全部用 canvas 画布实现。一、效果展示二、画面拆解1、绘制地平线地平线这里就是简单的一条贯穿屏幕的线。2、绘制红色精灵绘制红...
继续阅读 »

废话开篇:一些 404 页面为了体现趣味性会添加一些简单的交互效果。 这里用纯 JS 简单实现类似 404 可跳跃障碍物页面,内容全部用 canvas 画布实现。

一、效果展示


二、画面拆解

1、绘制地平线

地平线这里就是简单的一条贯穿屏幕的线。

2、绘制红色精灵

绘制红色精灵分为两部分:

(1)上面圆

(2)下面定点与上面圆的切线。

绘制结果:


进行颜色填充,再绘制中小的小圆,绘制结果:


(3)绘制障碍物

这里绘制的是一个黑色的长方形。最后的实现效果:


三、逻辑拆解

1、全局定时器控制画布重绘

创建全局的定时器。

它有两个具体任务:

(1)全局定时刷新重置,将画布定时擦除之前的绘制结果。

(2)全局定时器刷新动画重绘新内容。

2、精灵跳跃动作

在接收到键盘 “空格” 点击的情况下,让精灵起跳一定高度,到达顶峰的时候进行回落,当然这里设计的是匀速。

3、障碍物移动动作

通过定时器,重绘障碍物从最右侧移动到最左侧。

4、检测碰撞

在障碍物移动到精灵位置时,进行碰撞检测,判断障碍物最上端的左、右顶点是否在精灵的内部。

5、绘制提示语

提示语也是用 canvas 绘制的,当障碍物已移动到左侧的时候进行,结果判断。如果跳跃过程中无碰撞,就显示 “完美跳跃~”,如果调跃过程中有碰撞,就显示 “再接再厉”。

四、代码讲解

1、HTML
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title></title>
<script src="./wsl404.js"></script>
</head>
<body>
<div id="content">
<canvas id="myCanvas">
</canvas>
</div>
</body>
<script>
elves.init();
</script>
</html>
2、JS
(function WSLNotFoundPage(window) {
var elves = {};//精灵对象
elves.ctx = null;//画布
elves.width = 0;//屏幕的宽度
elves.height = 0;//屏幕的高度
elves.point = null;//精灵圆中心
elves.elvesR = 20;//精灵圆半径
elves.runloopTargets = [];//任务序列(暂时只保存跳跃)
elves.upDistance = 50;//当前中心位置距离地面高度
elves.upDistanceInitNum = 50;//中心位置距离地面高度初始值
elves.isJumping = false;//是否跳起
elves.jumpTarget = null;//跳跃任务
elves.jumpTop = false;//是否跳到最高点
elves.maxCheckCollisionWith = 0;//碰撞检测的最大宽度尺寸
elves.obstaclesMovedDistance = 0;//障碍物移动的距离
elves.isCollisioned = false;//是否碰撞过
elves.congratulationFont = 13;//庆祝文字大小
elves.congratulationPosition = 40;//庆祝文字位移
elves.isShowCongratulation = false;//是否展示庆祝文字
elves.congratulationContent = "完美一跃~";
elves.congratulationColor = "red";

//初始化
elves.init = function(){
this.drawFullScreen("content");
this.drawElves(this.upDistance);
this.keyBoard();  
this.runloop();
}

//键盘点击事件
elves.keyBoard = function(){
var that = this;
document.onkeydown = function whichButton(event)
{
 if(event.keyCode == 32){
   //空格
   that.elvesJump();
  }
}
}

//开始跑圈
elves.runloop = function(){
var that = this;
setInterval(function(){
//清除画布
that.cleareAll();
//绘制障碍物
that.creatObstacles();
if(that.isJumping == false){
//未跳起时重绘精灵
that.drawElves(that.upDistanceInitNum);
}
//绘制地面
that.drawGround();

//跳起任务
for(index in that.runloopTargets){
let target = that.runloopTargets[index];
if(target.isRun != null && target.isRun == true){

if(target.runCallBack){
target.runCallBack();
}
}
}
//碰撞检测
that.checkCollision();
//展示庆祝文字
if(that.isShowCongratulation == true){
that.congratulation();
}

},10);
}

//画布
elves.drawFullScreen = function (id){
var element = document.getElementById(id);
this.height = window.screen.height - 200;
this.width = window.screen.width;
element.style.width = this.width + "px";
element.style.height = this.height + "px";
element.style.background = "white";
this.getCanvas("myCanvas",this.width,this.height);
}

elves.getCanvas = function(id,width,height){
var c = document.getElementById(id);
this.ctx = c.getContext("2d");
//锯齿修复
if (window.devicePixelRatio) {
  c.style.width = this.width + "px";
  c.style.height = this.height + "px";
  c.height = height * window.devicePixelRatio;
  c.width = width * window.devicePixelRatio;
  this.ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
}
};

//绘制地面
elves.drawGround = function() {
// 设置线条的颜色
this.ctx.strokeStyle = 'gray';
// 设置线条的宽度
this.ctx.lineWidth = 1;
// 绘制直线
this.ctx.beginPath();
// 起点
this.ctx.moveTo(0, this.height / 2.0 + 1);
// 终点
this.ctx.lineTo(this.width,this.height / 2.0);
this.ctx.closePath();
this.ctx.stroke();
}

//绘制精灵
elves.drawElves = function(upDistance){

//绘制圆
var angle = Math.acos(this.elvesR / upDistance);
this.point = {x:this.width / 3,y : this.height / 2.0 - upDistance};
this.ctx.fillStyle = "#FF0000";
this.ctx.lineWidth = 1;
this.ctx.beginPath();
this.ctx.arc(this.point.x,this.point.y,this.elvesR,Math.PI / 2 + angle,Math.PI / 2 - angle,false);

//绘制切线
var bottomPoint = {x:this.width / 3,y : this.point.y + this.upDistanceInitNum};

let leftPointY = this.height / 2.0 - (upDistance - Math.cos(angle) * this.elvesR);
let leftPointX = this.point.x - (Math.sin(angle) * this.elvesR);
var leftPoint = {x:leftPointX,y:leftPointY};

let rightPointY = this.height / 2.0 - (upDistance - Math.cos(angle) * this.elvesR);
let rightPointX = this.point.x + (Math.sin(angle) * this.elvesR);
var rightPoint = {x:rightPointX,y:rightPointY};

this.maxCheckCollisionWith = (rightPointX - leftPointX) * 20 / (upDistance - Math.cos(angle) * this.elvesR);
this.ctx.moveTo(bottomPoint.x, bottomPoint.y);
this.ctx.lineTo(leftPoint.x,leftPoint.y);
this.ctx.lineTo(rightPoint.x,rightPoint.y);

this.ctx.closePath();
this.ctx.fill();

//绘制小圆
this.ctx.fillStyle = "#FFF";
this.ctx.lineWidth = 1;
this.ctx.beginPath();
this.ctx.arc(this.point.x,this.point.y,this.elvesR / 3,0,Math.PI * 2,false);
this.ctx.closePath();
this.ctx.fill();
}

//清除画布
elves.cleareAll = function(){
this.ctx.clearRect(0,0,this.width,this.height);
}

//精灵跳动
elves.elvesJump = function(){
if(this.isJumping == true){
return;
}
this.isJumping = true;
if(this.jumpTarget == null){
var that = this;
this.jumpTarget = {type:'jump',isRun:true,runCallBack:function(){
let maxDistance = that.upDistanceInitNum + 55;
if(that.jumpTop == false){
if(that.upDistance > maxDistance){
that.jumpTop = true;
}
that.upDistance += 1;
} else if(that.jumpTop == true) {
that.upDistance -= 1;
if(that.upDistance < 50) {
that.upDistance = 50;
that.jumpTop = false;
that.jumpTarget.isRun = false;
that.isJumping = false;
}
}
that.drawElves(that.upDistance);
}};
this.runloopTargets.push(this.jumpTarget);
} else {
this.jumpTarget.isRun = true;
}
}

//绘制障碍物
elves.creatObstacles = function(){
let obstacles = {width:20,height:20};
if(this.obstaclesMovedDistance != 0){
this.ctx.clearRect(this.width - obstacles.width - this.obstaclesMovedDistance + 0.5, this.height / 2.0 - obstacles.height,obstacles.width,obstacles.height);
}
this.obstaclesMovedDistance += 0.5;
if(this.obstaclesMovedDistance >= this.width + obstacles.width) {
this.obstaclesMovedDistance = 0;
//重置是否碰撞
this.isCollisioned = false;
}
this.ctx.beginPath();
this.ctx.fillStyle = "#000";
this.ctx.moveTo(this.width - obstacles.width - this.obstaclesMovedDistance, this.height / 2.0 - obstacles.height);
this.ctx.lineTo(this.width - this.obstaclesMovedDistance,this.height / 2.0 - obstacles.height);
this.ctx.lineTo(this.width - this.obstaclesMovedDistance,this.height / 2.0);
this.ctx.lineTo(this.width - obstacles.width - this.obstaclesMovedDistance, this.height / 2.0);
this.ctx.closePath();
this.ctx.fill();
}

//检测是否碰撞
elves.checkCollision = function(){
var obstaclesMarginLeft = this.width - this.obstaclesMovedDistance - 20;
var elvesUpDistance = this.upDistanceInitNum - this.upDistance + 20;
if(obstaclesMarginLeft > this.point.x - this.elvesR && obstaclesMarginLeft < this.point.x + this.elvesR && elvesUpDistance <= 20) {
//需要检测的最大范围
let currentCheckCollisionWith = this.maxCheckCollisionWith * elvesUpDistance / 20;
if((obstaclesMarginLeft < this.point.x + currentCheckCollisionWith / 2.0 && obstaclesMarginLeft > this.point.x - currentCheckCollisionWith / 2.0) || (obstaclesMarginLeft + 20 < this.point.x + currentCheckCollisionWith / 2.0 && obstaclesMarginLeft + 20 > this.point.x - currentCheckCollisionWith / 2.0)){
this.isCollisioned = true;
}
}

//记录障碍物移动到精灵左侧
if(obstaclesMarginLeft + 20 < this.point.x - this.elvesR && obstaclesMarginLeft + 20 > this.point.x - this.elvesR - 1){
if(this.isCollisioned == false){
//跳跃成功,防止检测距离内重复得分置为true,在下一次循环前再置为false
this.isCollisioned = true;
//庆祝
if(this.isShowCongratulation == false) {
this.congratulationContent = "完美一跃~";
this.congratulationColor = "red";
this.isShowCongratulation = true;
}
} else {
//鼓励
if(this.isShowCongratulation == false) {
this.isShowCongratulation = true;
this.congratulationColor = "gray";
this.congratulationContent = "再接再厉~";
}
}
}
}

//庆祝绘制文字
elves.congratulation = function(){

this.congratulationFont += 0.1;
this.congratulationPosition += 0.1;
if(this.congratulationFont >= 30){
                       //重置
this.congratulationFont = 13;
this.congratulationPosition = 30;
this.isShowCongratulation = false;
return;
}
this.ctx.fillStyle = this.congratulationColor;        
this.ctx.font = this.congratulationFont + 'px "微软雅黑"';        
this.ctx.textBaseline = "bottom";        
this.ctx.textAlign = "center";
this.ctx.fillText( this.congratulationContent, this.point.x, this.height / 2.0 - this.upDistanceInitNum - this.congratulationPosition);  
}

window.elves = elves;
})(window)

五、总结与思考

逻辑注释基本都写在代码里,里面的一些计算可能会绕一些。

作者:头疼脑胀的代码搬运工
来源:juejin.cn/post/7056610619490828325

收起阅读 »

关于自建组件库的思考

web
很多公司都会有自己的组件库,但是在使用起来都不尽如人意,这里分享下我自己的一些观点和看法问题思考在规划这种整个团队都要用的工具之前要多思考,走一步想一步的方式是不可取的首先,在开发一个组件库之前先要明确以下几点:目前现状不自建的话会有哪些问题,为什么不用 an...
继续阅读 »

很多公司都会有自己的组件库,但是在使用起来都不尽如人意,这里分享下我自己的一些观点和看法

问题思考

在规划这种整个团队都要用的工具之前要多思考,走一步想一步的方式是不可取的

首先,在开发一个组件库之前先要明确以下几点:

  • 目前现状

    • 不自建的话会有哪些问题,为什么不用 antd/element

    • 哪些人提出了哪些的问题

    • 分析为什么会出现这些问题

    • 哪些问题是必须解决的,哪些是阶段推进的

  • 期望目标

    • 组件库的定位是什么

    • 自建组件库是为了满足什么场景

    • 阶段目标是什么

    • 最终期望达到什么效果

  • 具体实现

    • 哪些问题用哪些方法来解决

    • 关于后续迭代是怎么考虑的

目前现状

仅仅是因为前端开发为了部分代码或者样式不用重复写就封装一个组件甚至组件库是一件很搞笑的事情,最终往往会出现以下问题:

  • 代码分散但是却高耦合,存在很多职责不明确

  • 封装过于死板,且暴露的属性职责不明确

  • 可维护性低,无法应对不断变化的需求

  • 可靠性低,对上游数据不做错误处理,对下游使用者不做兼容处理

最后没法迭代,因为代码质量及版本问题,连原始开发者都改不动的,相关使用者怨声载道,然后又重构一遍,还是同样的设计思路,只不过基于已知业务场景改了写法,然后过一段时间又成为一个新的历史包袱。。。

当你为了方便改别人的代码而选择 fork 别人的组件库下来简单改改再输出时,难道你觉得别人不会对“你写的”这个组件库持同样的看法么?

你会发现,如果仅仅以一个业务员的角度去寻求解决办法的话,最后往往不能够得到其他业务员的认可的~

组件库的存在目的是为了提高团队的工作效率,不是单纯为了个别人能少写代码,前者才是目的,后者只是其中一种实现方式(这句话自己悟吧)

期望目标

一个合格的组件库应该要让使用者感受到两点:

  • 约束(为什么只能这样传嘛?)

  • 方便(只要这样传就可以耶~)

不合格的组件库往往只关注后者,但是其实前者更加重要

在能实现甲方的需求前提下,约束的树立会让团队对某一问题形成一个固有的解决方案,这个使用过程会促成惯性的产生

同时,这个惯性一旦建立,就能促成两个结果:

  • 弥合了人与人之间的差异

  • 提高了交流效率(不单单是开发,还包括设计、产品、测试等一条工作链路上的相关人)

要知道的是,团队合作过程中,效率最低的环节永远是沟通,一个好的团队不是全员大神,而是做什么事情以一个整体,每个人步调趋于一致,这样效率才高~

具体实现

编写一个公共库需要考虑很多东西,下面主要分三点来阐述

逻辑的分割

  • 避免一次性、不通用、没必要的封装

  • 不允许出现相互跨级或交叉引用的情况,应形成明确的上下级关系

  • 被抽离的逻辑代码应该尽可能的“独立“,避免变成”谁也离不开谁”

逻辑的封装

对于一个管理平台框架来说,宗旨是让开发少写代码、产品少写文档,不需要每次有新业务都要重复产出

对于开发来说,具体有两点:

  • 大部分情况下,能拷贝下 demo 即可实现各类交互效果

  • 小部分情况下,组件能提供其他更多的可能以满足特殊需求

封装过程中,仅暴露关键属性,提供多种可能,并且以比较常用的值作为“默认值”并明确定义,即可满足“大部分需求只需无脑引用,同时小部分的特殊需求也能被满足”

维护与开发

作为一个上游的 UI 库,要充分考虑下游使用者的情况

  • 做到升级后保证下游大部分情况下不需要改动

  • 组件的新增、删除、修改要有充分的理由(需求或 bug),并且要遵循最小影响原则

  • 组件的设计要充分考虑日后可能发生的变化

未来展望

仅靠一个 UI 框架难以解决问题,对于未来的想法有分成三个阶段:

  1. UI 库,沉淀稳定高效的组件

  2. 代码片段生成器,收集业务案例代码

  3. 页面生成器,输出有效模版

这里更多面向的是中后台项目的解决方案

总结

组件库输出约束统一解决办法,前者通过抚平团队中个体的差异提高团队的沟通效率,后者通过形成工作惯性提高团队的工作效率

作者:tellyourmad
来源:juejin.cn/post/7063017892714905608

收起阅读 »

RxJava观察者模式

1.RxJava的观察者模式 RxJava的观察者模式是扩展的观察者模式,扩展的地方主要体现在事件通知的方式有很多种 2.RxJava的观察者模式涉及到几个类 Observable:被观察者 Observer:观察者 Subscribe:订阅 Event:被...
继续阅读 »

1.RxJava的观察者模式


RxJava的观察者模式是扩展的观察者模式,扩展的地方主要体现在事件通知的方式有很多种


2.RxJava的观察者模式涉及到几个类



  • Observable:被观察者

  • Observer:观察者

  • Subscribe:订阅

  • Event:被观察者通知观察者的事件


3.Obsercerable与Observer通过Subscribe实现关联,Event主要向Observer通知Observeble的变化,Event有几个通知方式



  • Next:常规事件,可以传递各种各样的数据

  • Error:异常事件,当被观察者发送异常事件后那么其他的事件就不会再继续发送了

  • Completed:结束事件,当观察者接收到这个事件后就不会再接收后续被观察者发送过来的事件


4.代码实现



  • 首先定义一个观察者Observer


public abstract class Observer<T> {
//和被观察者订阅后,会回调这个方法
public static void onSubscribe(Emitter emitter);

// 传递常规事件,用于传递数据
public abstract void onNext(T t);

// 传递异常事件
public abstract void onError(Throwable e);

// 传递结束事件
public abstract void onComplete();
}

Observer中的方法都是回调,其中多了一个Emitter的接口类,他是一个发射器

public interface Emitter<T> {

void onNext(T t);

void onError(Throwable error);

void onCompleted();
}

实现逻辑就是通过包装Observer,里面最终是通过Observer进行回调的

public class CreateEmitter<T> implements Emitter<T> {

final Observer<T> observer;

CreateEmitter(Observer<T> observer) {
this.observer = observer;
}

@Override
public void onNext(T t) {
observer.onNext(t);
}

@Override
public void onError(Throwable error) {
observer.onError(error);
}

@Override
public void onComplete() {
observer.onComplete();
}
}


  • 被观察者的实现


public abstract class Observable<T>{

public void subscribe(Observer<T> observer) {
//通过传入的Observer包装成CreateEmitter,用于回调
CreateEmitter emitter = new CreateEmitter(observer);

//回调订阅成功的方法
observer.onSubscribe(emitter);

//回调发射器emitter
subscribe(emitter);
}

/**
* 订阅成功后,进行回调
*/
public abstract void subscribe(Emitter<T> emitter);
}

就两步,第一步用于订阅,第二步用于回调


  • 具体的使用


private void observer() {
// 第一步,创建被观察者
Observable<String> observable = new Observable<String>() {
@Override
public void subscribe(Emitter<String> emitter) {
emitter.onNext("第一次");

emitter.onNext("第二次");

emitter.onNext("第三次");

emitter.onComplete();
}
};

// 第二步,创建观察者
Observer<String> observer = new Observer<String>() {
@Override
public void onSubscribe(Emitter emitter) {
Log.i("TAG", " onSubscribe ");
}

@Override
public void onNext(String s) {
Log.i("TAG", " onNext s:" + s);
}

@Override
public void onError(Throwable e) {
Log.i("TAG", " onError e:" + e.toString());
}

@Override
public void onComplete() {
Log.i("TAG", " onComplete ");
}
};

// 第三步,被观察者订阅观察者
observable.subscribe(observer);
}

被订阅成功后,被观察者的subscribe里面就可以通过发射器发送事件了,最终在观察者的方法里进行回调。

RxJava也是观察者和被观察者订阅的过程,只不过被观察者有变化的时候是由发射器进行发送的,这样就不止有一种事件了



1.RxJava的装饰者模式





    • 装饰者模式:在不改变原有的架构基础上添加一些新的功能,是作为其原有结构的包装,这个过程称为装饰。

    • RxJava的装饰者模式主要是用于实现Observable和Observer的包装,主要是为了与RxJava的观察者模式配合实现代码的方式更简洁。

    • 拆解RxJava的装饰器模式










      • 被观察者Observable






参考手机包装的例子
第一步:要有一个抽象接口,在RxJava中这个抽象接口是ObservableSource,里面有一个方法subscribe

public interface ObservableSource<T> {

/**
* Subscribes the given Observer to this ObservableSource instance.
* @param observer the Observer, not null
* @throws NullPointerException if {@code observer} is null
*/
void subscribe(@NonNull Observer<? super T> observer);
}

第二步:要有一个包装类,实现了ObservableSource的,RxJava的包装类是Observable,实现了对应的接口,
并且在subscribe方法里通过调用抽象方法subscribeActual,来对观察者进行订阅
public abstract class Observable<T> implements ObservableSource<T> {
...

@Override
public final void subscribe(Observer<? super T> observer) {
...
subscribeActual(observer);
...
}

protected abstract void subscribeActual(Observer<? super T> observer);
...
}

第三步:这就是具体的包装类了如图所示


2.观察者Observer:



  • 第一步:要有一个抽象接口,而RxJava的接口是Emitter和Observer,里面有好几个方法基本一样,onNext,onError,onComplete,用于被观察者进行回调;

  • 第二步:要有一个包装类,实现了Emitter或者Observer,但是观察者比较特殊,没有一个基础的包装类,而是直接封装了很多的包装类



RxJava的的被观察者是在创建的时候进行包装的,例如第一步的Observable.create方法,通过Observable.create的创建后进行了第一层包装,结构如下



第二步的subscribeO方法调用时进行了第二层的包装,此时结构如下:


第三步的observerOn方法调用时,进行了第四层的包装,那么结构就是下面的样子


最终调用订阅方法的时候已经进行了四次包装,那么可以理解每调用一次操作符就会进行一层被观察者的包装。


那么这样包装的好处是什么呢?


这就是装饰者模式的特性,在不改变原有功能的基础上添加额外的功能。


5.总结


我们在创建被观察者的时候,会对被观察者做一层包装,创建几次就包装几次,然后在被观察者调用subscribe方法时,一层层回调被观察者的subscribeAcutal方法,而在被观察者的subscribeAcutal方法里,会对观察者做一层包装;


也就是说被观察者是在创建的时候进行包装,然后在subscribeActual中实现额外的功能;


而观察者是在被观察者调用subscribeActual方法里进行包装的,然后针对观察者实现自己额外的功能;


流程图如下:






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

RxJava装饰者模式

1.装饰者模式 装饰者模式时在保留原有结构的前提下添加新的功能,这些功能作为其原有结构的包装。 2.RxJava的装饰者模式 1.被观察者Observable 根据Observerable的源码可知Observable的结构接口是Observerable...
继续阅读 »

1.装饰者模式



  • 装饰者模式时在保留原有结构的前提下添加新的功能,这些功能作为其原有结构的包装。


2.RxJava的装饰者模式


1.被观察者Observable



  • 根据Observerable的源码可知Observable的结构接口是Observerablesource<T>,里面有一个方法subscribe用于和观察者实现订阅,源码如下


/**
* Represents a basic, non-backpressured {@link Observable} source base interface,
* consumable via an {@link Observer}.
*
* @param <T> the element type
* @since 2.0
*/
public interface ObservableSource<T> {

/**
* Subscribes the given Observer to this ObservableSource instance.
* @param observer the Observer, not null
* @throws NullPointerException if {@code observer} is null
*/
void subscribe(Observer<? super T> observer);
}


  • 然后需要一个包装类,就是实现ObservableSource接口的类,就是Observable<T>,它实现了ObservableSource并在subscribe方法中调用了subscribeActual方法与观察者实现订阅关系,源码如下


public abstract class Observable<T> implements ObservableSource<T> {
@Override
public final void subscribe(Observer<? super T> observer) {
...
subscribeActual(observer);
...
}

protected abstract void subscribeActual(Observer<? super T> observer);
}


  • 第三步就是包装类了,包装类有很多有一百多个,如ObservableAllObservableAnyObservableCache



2.观察者Observer



  • 第一步,Observer的结构的接口有EmitterObserver,两个接口中的方法差不多,都是onNextOnErrorOnComplete,用于被观察者的回调

  • 第二步,实现Emitter或者Observer接口的包装类,观察者中没有实现这两个接口的基础包装类,而是直接封装了很多包装类



3.被观察者和观察者的包装类有在创建的时候进行包装也有在调用的时候包装,那么他们的结构又是怎么样的


以RxJava的最基础用法来分析,Observable.create().subscribeOn().observeOn().subscribe()为例,层层调用后它的结构如下:



  • 首先是Observable.create,通过创建ObservableCreate对象进行第一层包装,把ObservableOnSubscribe包在了里面




  • 然后是Observable.create().subscribeOn(),调用时又进行了一层包装,把ObservableCreate包进去了




  • 再然后就分别是observeOn()了,结构如下




  • 总共进行了4层包装,可以理解为每调用一次操作符就会进行一层被观察者的包装,这样包装的好处就是为了添加额外的功能,那么每一层又添加了哪些额外的功能呢


4.被观察者的subscribe方法


调用subscribe方法后会从最外层的包装类一步一步的往里面调用,从被观察者的subscribe方法中可以得知额外功能的实现是在subscribeActual方法中,那么上面几层包装的subscribeActual方法中又做了什么呢,分析如下



  • 先看最外层的包装observerOnsubscribeActual方法做了什么,先看源码:


public final class ObservableObserveOn<T> extends AbstractObservableWithUpstream<T, T> {
final Scheduler scheduler;
final boolean delayError;
final int bufferSize;
public ObservableObserveOn(ObservableSource<T> source, Scheduler scheduler, boolean delayError, int bufferSize) {
super(source);
this.scheduler = scheduler;
this.delayError = delayError;
this.bufferSize = bufferSize;
}

@Override
protected void subscribeActual(Observer<? super T> observer) {
if (scheduler instanceof TrampolineScheduler) {
source.subscribe(observer);
} else {
Scheduler.Worker w = scheduler.createWorker();

source.subscribe(new ObserveOnObserver<T>(observer, w, delayError, bufferSize));
}
}
...
}


  • 源码中有一个source,这个source是上一层包装类的实例,在source.subscribe()中对观察者进行了一层包装,也就是ObserveOnObserver,它在onNext方法里面实现了线程切换,这个onNext是在被观察者在通知观察者时会被回调,然后通过包装类实现额外的线程切换,这里是切换到了主线程执行。此时观察者的结构如下:


@Override
public void onNext(T t) {
if (done) {
return;
}

if (sourceMode != QueueDisposable.ASYNC) {
queue.offer(t);
}
schedule();
}



  • 再看下一层的包装subscribeOnsubscribeActual方法做了什么,先看源码


public final class ObservableSubscribeOn<T> extends AbstractObservableWithUpstream<T, T> {
final Scheduler scheduler;

public ObservableSubscribeOn(ObservableSource<T> source, Scheduler scheduler) {
super(source);
this.scheduler = scheduler;
}

@Override
public void subscribeActual(final Observer<? super T> s) {
final SubscribeOnObserver<T> parent = new SubscribeOnObserver<T>(s);

s.onSubscribe(parent);

parent.setDisposable(scheduler.scheduleDirect(new Runnable() {
@Override
public void run() {
source.subscribe(parent);
}
}));
}
...
}

这里又对观察者进行了一层包装,也就是SubscribeOnObserver,这里面的额外功能就是资源释放,包装完后的结构如下


 static final class SubscribeOnObserver<T> extends AtomicReference<Disposable> implements Observer<T>, Disposable {

private static final long serialVersionUID = 8094547886072529208L;

...

@Override
public void dispose() {
DisposableHelper.dispose(s);
DisposableHelper.dispose(this);
}

@Override
public boolean isDisposed() {
return DisposableHelper.isDisposed(get());
}

void setDisposable(Disposable d) {
DisposableHelper.setOnce(this, d);
}
}


subscribeActual方法中有一个调用是source.subscribe(parent),这个source就是它的上一层的包装类ObservableCreate,那么ObservableCreatesubscribeActual方法就会在子线程执行。


ObservableCreatesubscribeActual方法做了什么,先看源码


public final class ObservableCreate<T> extends Observable<T> {
final ObservableOnSubscribe<T> source;

public ObservableCreate(ObservableOnSubscribe<T> source) {
this.source = source;
}

@Override
protected void subscribeActual(Observer<? super T> observer) {
CreateEmitter<T> parent = new CreateEmitter<T>(observer);
observer.onSubscribe(parent);

try {
source.subscribe(parent);
} catch (Throwable ex) {
Exceptions.throwIfFatal(ex);
parent.onError(ex);
}
}
...
}

源码中的source就是创建最原始的ObservableOnSubscribe,这里会回调到ObservableOnSubscribesubscribe方法,在subscribeActual方法中又对观察者进行了一层包装也就是CreateEmitter,这个类里面做的事情是判断线程是否被释放,如果释放了则不再进行回调,这时候结构如下图


 @Override
public void onNext(T t) {
if (t == null) {
onError(new NullPointerException("onNext called with null. Null values are generally not allowed in 2.x operators and sources."));
return;
}
if (!isDisposed()) {
observer.onNext(t);
}
}


这里由于上面的包装类已经切换到了子线程所以ObservableOnSubscribesubscribe方法的执行也是在子线程;


3.总结


在创建被观察者的时候会对被观察者进行层层的包装,创建几次就包装几次,然后在被观察者调用subscribe方法时,一层层回调被观察者的subscribeActual方法,而在被观察者subscribeActual方法中会对观察者做一层包装。也就是说被观察者是创建的时候包装,在subscribeActual方法中实现额外的功能,观察者是在被观察者调用subscribeActual方法时进行包装的,然后针对观察者实现自己的额外的功能,流程图如下:



最终的结构如下:



  • 第一步:创建被观察者时或者使用操作符时会对被观察者进行包装




  • 第二步:当被观察者和观察者产生订阅关系后,被观察者会一层层的回调被观察者的subscribeActual方法,在这个方法中对观察者进行包装,此时被观察者的功能实现是在subscribeActual中,观察者的实现是在包装类里




  • 第三步:被观察者和观察者不同的是,被观察者是在订阅成功后就执行了包装类相应的功能,而观察者是在事件回调的时候,会在观察者的包装类里实现相应的功能

  • 最终流程图



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

Flutter 源码阅读 - StatefulWidget 源码分析 & State 生命周期

一、StatefulWidgetStatefulWidget 也是继承自 Widget,重写了 createElement,并且添加了一个新的接口 createState,下面我们看一下它的源码:看起来是不是很简单,代码...
继续阅读 »

一、StatefulWidget

StatefulWidget 也是继承自 Widget,重写了 createElement,并且添加了一个新的接口 createState,下面我们看一下它的源码:

image.png

看起来是不是很简单,代码不足十行。

  • createElement 方法返回一个 StatefulElement 类型的 Element
  • createState 抽象方法返回一个 State 类型的实例对象。在给定的位置为 StatefulWidget 创建可变状态(state)。框架可以在 StatefulWidget生命周期内多次调用此方法,比如:将 StatefulWidget 插入到 Widget Tree 中的多个位置时,会创建多个单独的 State 实例,如果将 StatefulWidget 从 Widget Tree 中删除,稍后再次将琦插入到 Widget Tree 中,框架将会再次调用 createState创建一个新的 State 实例对象。

StatefulWidget 我们暂时就先讲到这里, 关于 State 和 StatefulElement 我们在下面会进行分析。


二、StatefulElement

上面讲到 StatefulWidget 中 createElement 会创建一个 StatefulElement 类型的 Element。下面我们就一起看下 StatefulElement 的源码。

image.png

在执行 StatefulWidget#createElement 时会把 this 传递进去,此时执行 StatefulElement 的构造方法中我们可以看出会做以下三件事情:

  • 首先通过 _state = widget.createState() 执行 StatefulWidget 中的 createState 进行闯将 State 实例;
  • 其次通过state._element = this 将当前对象赋值给 State 中的 _element 属性;
  • 最后通过 state._widget = widget,将 StatefulWidget 赋值给 State 中的 _widget 属性。

通过以上分析我们相应的可以得出以下结论:

  • StatefulElement 持有 State 状态;
  • State 中又会反过来持有 StatefulElement 和 StatefulWidget(当然,State 的源码我们还没有看到);
  • StatefulWidget 只是负责创建 StatefulElement 和 State,但是并不持有它们。

至此我们已经理清了 StatefulWidgetStatefulElement 和 State 三者之间的关系,关于 State 我们会在后面讲到。现在我们已经知道 StatefulWidget 中的 createState 在何时执行,那么 StatefulElement#createElement 又是在何时执行的呢?下面我们来看一个例子:

import 'package:flutter/material.dart';

void main() {
runApp(
const MyApp(),
);
}

class MyApp extends StatefulWidget {
const MyApp({super.key});

@override
State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
@override
Widget build(BuildContext context) {
return const ColoredBox(
color: Colors.red,
);
}
}

image.png

通过断点调试可以看出在 Element#inflateWidget 中 通过 newWidget.createElement() 来进行触发 StatefulWidget#createElement 的执行,进而执行 StatefulElement 的构造函数。

关于更多 StatefulElement 内部方法,将在 State 源码以及相关案例中穿插进行。


三、State

State 是一个抽象类,它只定义了一个 build 抽象方法,由于构建 Widget 对象。它是通过StatefulElement#build 方法进行调用的。

image.png

如下是 State 源码的部分截图:

image.png

从源码中我们也可以对上面的结论得到验证,State 持有 StatefulElement 、StatefulWidget,这里的泛型 T必须是 StatefulWidget 类型,如下图所示:

image.png

除此之外 State 中还持有 BuildContext,通过源码我们可以看出 BuildContext 其实就是 StatefulElement 。

  BuildContext get context {
return _element!;
}

那么现在我们可以思考一下 State 中的生命周期方法在何时调用以及在哪里调用呢?从上面我们得出的结论:StatefulElement 持有 State 状态,State 中又会反过来持有 StatefulElement 和 StatefulWidgetStatefulWidget 只是负责创建 StatefulElement 和 State,但是并不持有它们。不难猜测出,应该是在 StatefulElement 中来触发的,下面我通过一个小的案例来进行研究一下:

void main() {
runApp(
const WrapWidget(),
);
}
class WrapWidget extends StatelessWidget {
const WrapWidget({
Key? key,
}) : super(key: key);

@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(
title: const Text("StatefulWidget Demo"),
),
body: MyApp(),
),
);
}
}
class MyApp extends StatefulWidget {
const MyApp({
super.key,
});

@override
// ignore: no_logic_in_create_state
State<MyApp> createState() {
debugPrint("createState");
return _MyAppState();
}
}

class _MyAppState extends State<MyApp> {
late int _count = 0;

@override
void initState() {
debugPrint("initState");
super.initState();
}

@override
void didChangeDependencies() {
debugPrint("didChangeDependencies");
super.didChangeDependencies();
}

@override
void didUpdateWidget(MyApp oldWidget) {
debugPrint("didUpdateWidget");
super.didUpdateWidget(oldWidget);
}

@override
void deactivate() {
debugPrint("deactivate ");
super.deactivate();
}

@override
void dispose() {
debugPrint("dispose");
super.dispose();
}

@override
void reassemble() {
debugPrint("reassemble");
super.reassemble();
}

@override
Widget build(BuildContext context) {
debugPrint("build");
return Column(
children: [
Text('$_count'),
OutlinedButton(
onPressed: () {
setState(() {
_count++;
});
},
child: const Text('OnPress'),
),
],
);
}
}

程序刚运行时打印日志如下:

image.png

然后我们点击⚡️按钮热重载,控制台输出日志如下:

image.png

我们再次点击 OnPress 按钮时,打印日志如下:

image.png

此时我们注释掉 WrapWidget 中的 body: MyApp() 这行代码,打印日志如下:

image.png

此时结合源码,我们来一起看下各个生命周期函数:

  • initState: 当 Widget 第一次插入到 Widget Tree中,会执行一次,我们一般在这里可以做一些初始化状态的操作以及订阅通知事件等,通过源码我们可以看出它是在 Statefulelement#_firstBuild 中执行的;

    image.png

  • didChangeDependencies: 当 State 对象的依赖发生变化时会进行调用,例如:例如系统语言 Locale 或者应用主题等,通过源码我们可以看出它在 Statefulelement#_firstBuild 和 Statefulelement#performRebuild 中都会执行;

    image.png

  • build:在以下场景中都会调用:

    • initState 调用之后
    • didUpdateWidget 调用之后
    • setState 调用之后
    • didChangeDependencies 调用之后
    • 调用 deactivate 之后,然后又重新插入到 Widtget Tree

    通过源码可以看出它是在 Statefulelement#build 中执行的;

    image.png

  • reassemble:专门为了开发调试而提供的,在 hot reload 时会被调用,在 Release 模式下永远不会被调用,通过源码可以看出它是在 Statefulelement#reassemble 中执行的;

    image.png

  • didUpdateWidget:在 Widget 重新构建时,Flutter 框架会在 Element#updateChild 中通过Widget.canUpdate 判断是否需要进行更新,如果为 true 则进行更新;

    image.png

    在 canUpdate 源码中,新旧 widget 的 key 和 runtimeType 同时相等时会返回 true,也就是说在在新旧 widget 的 key 和 runtimeType 同时相等时 didUpdateWidget() 就会被调用;

    image.png

    image.png

  • deactivate:当 State 对象从树中被移除时将会调用,它将会在 Statefulelement#deactivate 中进行调用;

    image.png

  • dispose:当 State 对象从树中被永久移除时调用;通常在此回调中释放资源,它将会在 Statefulelement#unmount 中进行调用。

    image.png


总结

至此,结合一些小的案例和源码阅读,我们大致明白了 StatefulWidgetState 以及 StatefulElement 他们三者之间的关系以及 State 的生命周期,相信在以后的实际应用中会更加得心应手。


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

Jetpack Compose 十几行代码快速模仿即刻点赞数字切换效果

缘由 四点多刷掘金的时候,看到这样一篇文章: 自定义View模仿即刻点赞数字切换效果,作者使用自定义绘制的技术完成了数字切换的动态效果,也就是如图: 两图分别为即刻的效果和作者的实现 不得不说,作者模仿的很像,自定义绘制玩的炉火纯青,非常优秀。不过,即使是...
继续阅读 »

缘由


四点多刷掘金的时候,看到这样一篇文章:
自定义View模仿即刻点赞数字切换效果,作者使用自定义绘制的技术完成了数字切换的动态效果,也就是如图:


原效果


作者模仿的效果


两图分别为即刻的效果和作者的实现


不得不说,作者模仿的很像,自定义绘制玩的炉火纯青,非常优秀。不过,即使是这样简单的动效,使用 View 体系实现起来仍然相对麻烦。对上文来说,作者使用的 Kotlin 代码也达到了约 170 行。


Composable


如果换成 Compose 呢?作为声明式框架,在处理这类动画上会不会有奇效?


答案是肯定的!下面是最简单的实现:


Row(modifier = modifier) {
text.forEach {
AnimatedContent(
targetState = it,
transitionSpec = {
slideIntoContainer(AnimatedContentScope.SlideDirection.Up) with
fadeOut() + slideOutOfContainer(AnimatedContentScope.SlideDirection.Up)
}
) { char ->
Text(text = char.toString(), modifier = modifier.padding(textPadding), fontSize = textSize, color = textColor)
}
}
}

你没看错,这就是 Composable 对应的简单模仿,核心代码不过十行。它的大致效果如下:


20221221_174919.gif


能看到,在数字变化时,相应的动画效果已经非常相似。当然他还有小瑕疵,比如在 99 - 100 时,最后一位的 0 没有初始动画;比如在数字减少时,他的动画方向应该相反。但这两个问题都是可以加点代码解决的,这里核心只是思路


原理


与上文作者将每个数字当做一个整体对待不同,我将每一位独立处理。观察图片,动画的核心在于每一位有差异时要做动画处理,因此将每一位单独处理能更好的建立状态。


Jetpack Compose 是声明式 UI,状态的变化自然而然就导致 UI 的变化,我们所需要做的只是在 UI 变化时加个动画就可以。而刚好,对于这种内容的改变,Compose 为我们提供了开箱即用的微件:AnimatedContent


AnimatedContent


此 Composable 签名如下:


@Composable
fun <S> AnimatedContent(
targetState: S,
modifier: Modifier = Modifier,
transitionSpec: AnimatedContentScope<S>.() -> ContentTransform = {
...
},
contentAlignment: Alignment = Alignment.TopStart,
content: @Composable() AnimatedVisibilityScope.(targetState: S) -> Unit
)

重点在于 targetState,在 content 内部,我们需要获取到用到这个值,根据值的不同,呈现不同的 UI。AnimatedContent 会在 targetState 变化使自动对上一个 Composable 执行退出动画,并对新 Composable 执行进入动画 (有点幻灯片切换的感觉hh),在这里,我们的动画是这样的:


slideIntoContainer(AnimatedContentScope.SlideDirection.Up) 
with
fadeOut() + slideOutOfContainer(AnimatedContentScope.SlideDirection.Up)

上半部分的 slideIntoContainer 会执行进入动画,方向为自下向上;后半部分则是退出动画,由向上的路径动画和淡出结合而来。中缀函数 with 连接它们。这也体现了 Kotlin 作为一门现代化语言的优雅。


关于 Compose 的更多知识,可以参考 Compose 中文社区的大佬们共同维护的 Jetpack Compose 博物馆


代码


本文的所有代码如下:


import androidx.compose.animation.*
import androidx.compose.foundation.layout.*
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp

@OptIn(ExperimentalAnimationApi::class)
@Composable
fun NumberChangeAnimationText(
modifier: Modifier = Modifier,
text: String,
textPadding: PaddingValues = PaddingValues(horizontal = 8.dp, vertical = 12.dp),
textSize: TextUnit = 24.sp,
textColor: Color = Color.Black
) {
Row(modifier = modifier) {
text.forEach {
AnimatedContent(
targetState = it,
transitionSpec = {
slideIntoContainer(AnimatedContentScope.SlideDirection.Up) with
fadeOut() + slideOutOfContainer(AnimatedContentScope.SlideDirection.Up)
}
) { char ->
Text(text = char.toString(), modifier = modifier.padding(textPadding), fontSize = textSize, color = textColor)
}
}
}
}

@Composable
fun NumberChangeAnimationTextTest() {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
var text by remember { mutableStateOf("103") }
NumberChangeAnimationText(text = text)

Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) {
// 加一 和 减一
listOf(1, -1).forEach { i ->
TextButton(onClick = {
text = (text.toInt() + i).toString()
}) {
Text(text = if (i == 1) "加一" else "减一")
}
}
}
}
}

这个示例也被收录到了我的 JetpackComposeStudy: 本人 Jetpack Compose 主题文章所包含的示例,包括自定义布局、部分组件用法等 里,感兴趣的可以去那里查看更多代码。


Screenshot_1671617400.png


最近掘金开启了2022的年度人气创作者评选,如果您对我的文章认可的话,欢迎投给我宝贵的一票,感谢!本文有帮助的话,也欢迎点赞交流。


(现在6点13分,连写代码加写文章共用了一个多小时,嗯,收工~)


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

超级全面的Flutter性能优化实践

前言 Flutter是谷歌的移动UI框架,可以快速在iOS和Android上构建高质量的原生用户界面。 Flutter可以与现有的代码一起工作。在全世界,Flutter正在被越来越多的开发者和组织使用,并且Flutter是完全免费、开源的,可以用一套代码同时构...
继续阅读 »

前言


Flutter是谷歌的移动UI框架,可以快速在iOS和Android上构建高质量的原生用户界面。 Flutter可以与现有的代码一起工作。在全世界,Flutter正在被越来越多的开发者和组织使用,并且Flutter是完全免费、开源的,可以用一套代码同时构建Android和iOS应用,性能可以达到原生应用一样的性能。但是,在较为复杂的 App 中,使用 Flutter 开发也很难避免产生各种各样的性能问题。在这篇文章中,我将介绍一些 Flutter 性能优化方面的应用实践。


一、优化检测工具


flutter编译模式


Flutter支持Release、Profile、Debug编译模式。




  1. Release模式,使用AOT预编译模式,预编译为机器码,通过编译生成对应架构的代码,在用户设备上直接运行对应的机器码,运行速度快,执行性能好;此模式关闭了所有调试工具,只支持真机。




  2. Profile模式,和Release模式类似,使用AOT预编译模式,此模式最重要的作用是可以用DevTools来检测应用的性能,做性能调试分析。




  3. Debug模式,使用JIT(Just in time)即时编译技术,支持常用的开发调试功能hot reload,在开发调试时使用,包括支持的调试信息、服务扩展、Observatory、DevTools等调试工具,支持模拟器和真机。




通过以上介绍我们可以知道,flutter为我们提供 profile模式启动应用,进行性能分析,profile模式在Release模式的基础之上,为分析工具提供了少量必要的应用追踪信息。


如何开启profile模式?


如果是独立flutter工程可以使用flutter run --profile启动。如果是混合 Flutter 应用,在 flutter/packages/flutter_tools/gradle/flutter.gradle 的 buildModeFor 方法中将 debug 模式改为 profile即可。


检测工具


1、Flutter Inspector (debug模式下)


Flutter Inspector有很多功能,其中有两个功能更值得我们去关注,例如:“Select Widget Mode” 和 “Highlight Repaints”。


Select Widget Mode点击 “Select Widget Mode” 图标,可以在手机上查看当前页面的布局框架与容器类型。


image.png


通过“Select Widget Mode”我们可以快速查看陌生页面的布局实现方式。


1662522497922.jpg


Select Widget Mode模式下,也可以在app里点击相应的布局控件查看


Highlight Repaints


点击 “Highlight Repaints” 图标,它会 为所有 RenderBox 绘制一层外框,并在它们重绘时会改变颜色。
image.png


这样做帮你找到 App 中频繁重绘导致性能消耗过大的部分。


例如:一个小动画可能会导致整个页面重绘,这个时候使用 RepaintBoundary Widget 包裹它,可以将重绘范围缩小至本身所占用的区域,这样就可以减少绘制消耗。


image.png


2、Performance Overlay(性能图层)


在完成了应用启动之后,接下来我们就可以利用 Flutter 提供的渲染问题分析工具,即性能图层(Performance Overlay),来分析渲染问题了。


我们可以通过以下方式开启性能图层
image.png


性能图层会在当前应用的最上层,以 Flutter 引擎自绘的方式展示 GPU 与 UI 线程的执行图表,而其中每一张图表都代表当前线程最近 300 帧的表现,如果 UI 产生了卡顿,这些图表可以帮助我们分析并找到原因。
下图演示了性能图层的展现样式。其中,GPU 线程的性能情况在上面,UI 线程的情况显示在下面,蓝色垂直的线条表示已执行的正常帧,绿色的线条代表的是当前帧:


image.png


如果有一帧处理时间过长,就会导致界面卡顿,图表中就会展示出一个红色竖条。下图演示了应用出现渲染和绘制耗时的情况下,性能图层的展示样式:


image.png


如果红色竖条出现在 GPU 线程图表,意味着渲染的图形太复杂,导致无法快速渲染;而如果是出现在了 UI 线程图表,则表示 Dart 代码消耗了大量资源,需要优化代码执行时间。


3、CPU Profiler(UI 线程问题定位)


在视图构建时,在 build 方法中使用了一些复杂的运算,或是在主 Isolate 中进行了同步的 I/O 操作。
我们可以使用 CPU Profiler 进行检测:


image.png


你需要手动点击 “Record” 按钮去主动触发,在完成信息的抽样采集后,点击 “Stop” 按钮结束录制。这时,你就可以得到在这期间应用的执行情况了。


image.png


其中:


x 轴:表示单位时间,一个函数在 x 轴占据的宽度越宽,就表示它被采样到的次数越多,即执行时间越长。


y 轴:表示调用栈,其每一层都是一个函数。调用栈越深,火焰就越高,底部就是正在执行的函数,上方都是它的父函数。


通过上述CPU帧图我们可以大概分析出哪些方法存在耗时操作,针对性的进行优化


一般的耗时问题,我们通常可以 使用 Isolate(或 compute)将这些耗时的操作挪到并发主 Isolate 之外去完成。


例如:复杂JSON解析子线程化


Flutter的isolate默认是单线程模型,而所有的UI操作又都是在UI线程进行的,想应用多线程的并发优势需新开isolate 或compute。无论如何await,scheduleTask 都只是延后任务的调用时机,仍然会占用“UI线程”, 所以在大Json解析或大量的channel调用时,一定要观测对UI线程的消耗情况。


image.png


二、Flutter布局优化


Flutter 使用了声明式的 UI 编写方式,而不是 Android 和 iOS 中的命令式编写方式。




  1. 声明式:简单的说,你只需要告诉计算机,你要得到什么样的结果,计算机则会完成你想要的结果,声明式更注重结果。




  2. 命令式:用详细的命令机器怎么去处理一件事情以达到你想要的结果,命令式更注重执行过程。




flutter声明式的布局方式通过三棵树去构建布局,如图:


image.png




  • Widget Tree: 控件的配置信息,不涉及渲染,更新代价极低。




  • Element Tree : Widget树和RenderObject树之间的粘合剂,负责将Widget树的变更以最低的代价映射到RenderObject树上。




  • RenderObject Tree : 真正的UI渲染树,负责渲染UI,更新代价极大。




1、常规优化


常规优化即针对 build() 进行优化,build() 方法中的性能问题一般有两种:耗时操作和 Widget 层叠。


1)、在 build() 方法中执行了耗时操作


我们应该尽量避免在 build() 中执行耗时操作,因为 build() 会被频繁地调用,尤其是当 Widget 重建的时候。
此外,我们不要在代码中进行阻塞式操作,可以将一般耗时操作等通过 Future 来转换成异步方式来完成。
对于 CPU 计算频繁的操作,例如图片压缩,可以使用 isolate 来充分利用多核心 CPU。


2)、build() 方法中堆叠了大量的 Widget


这将会导致三个问题:


1、代码可读性差:画界面时需要一个 Widget 嵌套一个 Widget,但如果 Widget 嵌套太深,就会导致代码的可读性变差,也不利于后期的维护和扩展。


2、复用难:由于所有的代码都在一个 build(),会导致无法将公共的 UI 代码复用到其它的页面或模块。


3、影响性能:我们在 State 上调用 setState() 时,所有 build() 中的 Widget 都将被重建,因此 build() 中返回的 Widget 树越大,那么需要重建的 Widget 就越多,也就会对性能越不利。


所以,你需要 控制 build 方法耗时,将 Widget 拆小,避免直接返回一个巨大的 Widget,这样 Widget 会享有更细粒度的重建和复用。


3)、尽可能地使用 const 构造器


当构建你自己的 Widget 或者使用 Flutter 的 Widget 时,这将会帮助 Flutter 仅仅去 rebuild 那些应当被更新的 Widget。
因此,你应该尽量多用 const 组件,这样即使父组件更新了,子组件也不会重新进行 rebuild 操作。特别是针对一些长期不修改的组件,例如通用报错组件和通用 loading 组件等。


image.png


4)、列表优化




  • 尽量避免使用 ListView默认构造方法


    不管列表内容是否可见,会导致列表中所有的数据都会被一次性绘制出来




  • 建议使用 ListView 和 GridView 的 builder 方法


    它们只会绘制可见的列表内容,类似于 Android 的 RecyclerView。




image.png


其实,本质上,就是对列表采用了懒加载而不是直接一次性创建所有的子 Widget,这样视图的初始化时间就减少了。


2、深入光栅化优化


优化光栅线程


屏幕显示器一般以60Hz的固定频率刷新,每一帧图像绘制完成后,会继续绘制下一帧,这时显示器就会发出一个Vsync信号,按60Hz计算,屏幕每秒会发出60次这样的信号。CPU计算好显示内容提交给GPU,GPU渲染好传递给显示器显示。
Flutter遵循了这种模式,渲染流程如图:


image.png


flutter通过native获取屏幕刷新信号通过engine层传递给flutter framework
image.png


所有的 Flutter 应用至少都会运行在两个并行的线程上:UI 线程和 Raster 线程。




  • UI 线程


    构建 Widgets 和运行应用逻辑的地方。




  • Raster 线程


    用来光栅化应用。它从 UI 线程获取指令将其转换成为GPU命令并发送到GPU。




我们通常可以使用Flutter DevTools-Performance 进行检测,步骤如下:




  • 在 Performance Overlay 中,查看光栅线程和 UI 线程哪个负载过重。




  • 在 Timeline Events 中,找到那些耗费时间最长的事件,例如常见的 SkCanvas::Flush,它负责解决所有待处理的 GPU 操作。




  • 找到对应的代码区域,通过删除 Widgets 或方法的方式来看对性能的影响。




image.png


三、Flutter内存优化


1、const 实例化


const 对象只会创建一个编译时的常量值。在代码被加载进 Dart Vm 时,在编译时会存储在一个特殊的查询表里,仅仅只分配一次内存给当前实例。


我们可以使用 flutter_lints 库对我们的代码进行检测提示


2、检测消耗多余内存的图片


Flutter Inspector:点击 “Highlight Oversizeded Images”,它会识别出那些解码大小超过展示大小的图片,并且系统会将其倒置,这些你就能更容易在 App 页面中找到它。


image.png


通过下面两张图可以清晰的看出使用“Highlight Oversizeded Images”的检测效果
image.png
image.png


针对这些图片,你可以指定 cacheWidth 和 cacheHeight 为展示大小,这样可以让 flutter 引擎以指定大小解析图片,减少内存消耗。
image.png


3、针对 ListView item 中有 image 的情况来优化内存


ListView 不会销毁那些在屏幕可视范围之外的那些 item,如果 item 使用了高分辨率的图片,那么它将会消耗非常多的内存。


ListView 在默认情况下会在整个滑动/不滑动的过程中让子 Widget 保持活动状态,这一点是通过 AutomaticKeepAlive 来保证,在默认情况下,每个子 Widget 都会被这个 Widget 包裹,以使被包裹的子 Widget 保持活跃。
其次,如果用户向后滚动,则不会再次重新绘制子 Widget,这一点是通过 RepaintBoundaries 来保证,在默认情况下,每个子 Widget 都会被这个 Widget 包裹,它会让被包裹的子 Widget 仅仅绘制一次,以此获得更高的性能。
但,这样的问题在于,如果加载大量的图片,则会消耗大量的内存,最终可能使 App 崩溃。


image.png


通过将这两个选项置为 false 来禁用它们,这样不可见的子元素就会被自动处理和 GC。


4、多变图层与不变图层分离


在日常开发中,会经常遇到页面中大部分元素不变,某个元素实时变化。如Gif,动画。这时我们就需要RepaintBoundary,不过独立图层合成也是有消耗,这块需实测把握。


这会导致页面同一图层重新Paint。此时可以用RepaintBoundary包裹该多变的Gif组件,让其处在单独的图层,待最终再一块图层合成上屏。


image.png


5、降级CustomScrollView,ListView等预渲染区域为合理值


默认情况下,CustomScrollView除了渲染屏幕内的内容,还会渲染上下各250区域的组件内容,例如当前屏幕可显示4个组件,实际仍有上下共4个组件在显示状态,如果setState(),则会进行8个组件重绘。实际用户只看到4个,其实应该也只需渲染4个, 且上下滑动也会触发屏幕外的Widget创建销毁,造成滚动卡顿。高性能的手机可预渲染,在低端机降级该区域距离为0或较小值。


image.png


四、总结


Flutter为什么会卡顿、帧率低?总的来说均为以下2个原因:




  • UI线程慢了-->渲染指令出的慢




  • GPU线程慢了-->光栅化慢、图层合成慢、像素上屏慢




所以我们一般使用flutter布局尽量按照以下原则


Flutter优化基本原则:




  • 尽量不要为 Widget 设置半透明效果,而是考虑用图片的形式代替,这样被遮挡的 Widget 部分区域就不需要绘制了;




  • 控制 build 方法耗时,将 Widget 拆小,避免直接返回一个巨大的 Widget,这样 Widget 会享有更细粒度的重建和复用;




  • 对列表采用懒加载而不是直接一次性创建所有的子 Widget,这样视图的初始化时间就减少了。




五、其他


如果大家对flutter动态化感兴趣,我们也为大家准备了flutter动态化平台-Fair


欢迎大家使用 Fair,也欢迎大家为我们点亮star



Github地址:github.com/wuba/fair

Fair官网:fair.58.com


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

内存优化之掌握 APP 运行时的内存模型

为了让大家深入掌握 App 运行时的内存模型,这一节的内容按照由外到内、逐步深入的原则,分为了 3 个部分:内存描述指标内存数据获取内存模型详解话不多说,让我们马上开始这一章学习吧!内存描述指标在进行内存优化之前,我们必须要先熟悉常用的内存描述指标。内存描述指...
继续阅读 »

为了让大家深入掌握 App 运行时的内存模型,这一节的内容按照由外到内、逐步深入的原则,分为了 3 个部分:

  1. 内存描述指标

  2. 内存数据获取

  3. 内存模型详解

话不多说,让我们马上开始这一章学习吧!

内存描述指标

在进行内存优化之前,我们必须要先熟悉常用的内存描述指标。内存描述指标可以用来度量一个 App 的内存情况,也可以在我们做内存优化时,更直观地展示出优化前后的效果。

常用的内存描述指标有 6 个,我们先来简单了解一下。

  • PSS( Proportional Set Size ):实际使用的物理内存,会按比例分配共享的内存。比如一个应用有两个进程都用到了 Chrome 的 V8 引擎,那么每个进程会承担 50% 的 V8 这个 so 库的内存占用。PSS 是我们使用最频繁的一个指标,App 线上的内存数据统计一般都取这个指标。

  • RSS( Resident Set Size ):PSS 中的共享库会按比例分担,但是 RSS 不会,它会完全算进当前进程,所以把所有进程的 RSS 加总后得出来的内存会比实际高。按比例计算内存占用会有一定的消耗,因此当想要高性能的获取内存数据时便可以使用 RSS,Android 的 LowMemoryKiller 机制就是根据每个进程的 RSS 来计算进程优先级的。

  • Private Clean / Private Dirty:当我们执行 dump meminfo 时会看到这个指标,Private 内存是只被当前进程独占的物理内存。独占的意思是即使释放之后也无法被其他进程使用,只有当这个进程销毁后其他进程才能使用。Clean 表示该对应的物理内存已经释放了,Dirty 表示对应的物理内存还在使用。

  • Swap Pss Dirty:这个指标和上面的 Private 指标刚好相反,Swap 的内存被释放后,其他进程也可以继续使用,所以我们在 meminfo 中只看得到 Swap Pss Dirty,而看不到Swap Pss Clean,因为 Swap Pss Clean 是没有意义的。

  • Heap Alloc:通过 Malloc、mmap 等函数实际申请的虚拟内存,包括 Naitve 和虚拟机申请的内存。

  • Heap Free:空闲的虚拟内存。

内存描述指标并不多,上面这几个就完全够用了,而且我相信大家或多或少都接触过,所以这里列出来便于我们后面查阅。

内存数据获取

了解了内存的描述指标,我们再来看看如何获取内存的数据,主要有 2 种方式。

① 线下通过 adb 命令获取,一般用于线下调试:

adb shell
dumpsys meminfo 进程名/pid

② 线上通过代码获取,一般用于收集线上的内存数据:

ActivityManager am = (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE);
ActivityManager.MemoryInfo memoryInfo = new ActivityManager.MemoryInfo();

虽然获取方法不同,但这两种方式获取数据的原理完全一样,它们调用的都是 android_os_Debug.cpp 对象中的 android_os_Debug_getDirtyPagesPid 接口,它的源码如下:

static jboolean android_os_Debug_getDirtyPagesPid(JNIEnv *env, jobject clazz,
jint pid, jobject object)
{
bool foundSwapPss;
stats_t stats[_NUM_HEAP];
memset(&stats, 0, sizeof(stats));

//1. 加载maps文件,获取
if (!load_maps(pid, stats, &foundSwapPss)) {
return JNI_FALSE;
}

struct graphics_memory_pss graphics_mem;
//2. 获取graphics区域内存数据
if (read_memtrack_memory(pid, &graphics_mem) == 0) {
stats[HEAP_GRAPHICS].pss = graphics_mem.graphics;
stats[HEAP_GRAPHICS].privateDirty = graphics_mem.graphics;
stats[HEAP_GRAPHICS].rss = graphics_mem.graphics;
stats[HEAP_GL].pss = graphics_mem.gl;
stats[HEAP_GL].privateDirty = graphics_mem.gl;
stats[HEAP_GL].rss = graphics_mem.gl;
stats[HEAP_OTHER_MEMTRACK].pss = graphics_mem.other;
stats[HEAP_OTHER_MEMTRACK].privateDirty = graphics_mem.other;
stats[HEAP_OTHER_MEMTRACK].rss = graphics_mem.other;
}

//3. 获取Unkonw区域数据
for (int i=_NUM_CORE_HEAP; i<_NUM_EXCLUSIVE_HEAP; i++) {
stats[HEAP_UNKNOWN].pss += stats[i].pss;
stats[HEAP_UNKNOWN].swappablePss += stats[i].swappablePss;
stats[HEAP_UNKNOWN].rss += stats[i].rss;
stats[HEAP_UNKNOWN].privateDirty += stats[i].privateDirty;
stats[HEAP_UNKNOWN].sharedDirty += stats[i].sharedDirty;
stats[HEAP_UNKNOWN].privateClean += stats[i].privateClean;
stats[HEAP_UNKNOWN].sharedClean += stats[i].sharedClean;
stats[HEAP_UNKNOWN].swappedOut += stats[i].swappedOut;
stats[HEAP_UNKNOWN].swappedOutPss += stats[i].swappedOutPss;
}

//4. 将获取的数据存放到容器中
……
return JNI_TRUE;
}

这段源码比较长,我们一起来梳理下里面的逻辑,主要分为 4 部分。

  1. 读取 maps 文件,获取该进程的内存详情:通过上一节的学习,我们知道进程使用的内存都是虚拟内存,并且虚拟内存都以页为维度来管理和维护。这个进程的虚拟内存每一页上存放了什么数据,都会记录在 maps 文件中,maps 文件是一个很重要的文件,后面会详细介绍它。

  2. 调用 libmemtrack 接口获取 graphics 内存数据:Graphic 内存分配和使用方式具有特殊性,并没有全部映射到应用进程,需要通过 HAL 层(抽象硬件层)libmemtrack 的接口查询,才能完整得到使用的 graphics 内存数据。

  3. 分配 Unknow 区域的内存数据:根据前面的知识我们知道,mmap 除了做内存映射,还可以用来申请虚拟内存,如果在申请内存时是私有且匿名的( fd 如果为 -1,flag 入参为MAP_ANONYMOUS 或 MAP_PRIVATE )就会算入 Unknow 中,如果 mmap 申请内存时指定了申请这段内存的名字,就会算入 Other Dev 当中。因此,对这一区域内存问题的排查往往比较复杂,因为我们不知道内存的来源。

  4. 存放获取到的内存数据并返回:最后一部分就是将前面获取到的数据放到对应的数据结构中,并返回给接口调用方。

内存模型详解

我们已经知道如何获取内存数据,但是这些数据从哪儿来呢?毕竟只有知道来源,我们才能从源头进行治理。那接下来,我们就对 App 运行时的内存模型进行一个全面且详细的剖析。

我们以系统设置这个 App 为例子,通过 adb 命令获取的内存数据如下:

image.png

这里把上面的数据分为两个部分:A 区域和 B 区域。其中 A 区域的数据主要来自前面提到的 android_os_Debug_getMemInfo 接口,B 区域的数据则是对 A 区域中的数据做了汇总处理。

A区域

前面我们已经了解到,android_os_Debug_getMemInfo 接口的数据有两部分来源,一部分是读取 maps 文件解析到每块内存所属的数据,另一部分是读取 libmemtrack 接口的数据获取到的 graphic 内存数据。这两部分的数据来源就组成了 A 区域中的三块数据。下面我们分别来看看这三块数据。

数据 ①:maps 文件数据

maps 文件是分析内存很重要的一个文件,通过 maps 文件我们可以详细知道这个进程的内存中存放了哪些数据。maps 文件存放在 /proc/{ pid }/maps 路径中,该路径除了存放该进程的 maps 文件,还存放了该进程的所有其他信息的数据。如果你感兴趣可以深入了解一下。

对于 root 的手机,我们可以直接查看该目录下的 maps 文件。但是 maps 文件非常长,直接看会很吃力,所以我们一般会通过脚本对 maps 文件中的数据做分析和归类。下面还是以系统设置这个应用为例,它的 maps 文件的部分内容如下:

image.png

图中从左至右各个数据段的解释如下:

字段addressperms offsetoffsetdevinodepathname
数据12c00000-32c00000rw-p0000000000:000main space (region space)]
含义本段内存映射的虚拟地址空间范围读写权限本段映射地址在文件中的偏移所映射的文件所属设备的设备号文件的索引节点号对有名映射而言,pathname 是映射的文件名;对匿名映射来说,pathname 是此段内存在进程中的作用

如果手机没有 root 也没关系,我们可以在运行时通过 native 层的 c++ 代码读取该文件,可以看一下android_os_Debug_getMemInfo 接口中调用的 load_maps 方法,该方法读取 maps 文件后,还做了一个详细的分类操作,分完类之后就是我们看到的数据 ① 中的数据,这个方法比较长,所以我精简了部分代码。

static bool load_maps(int pid, stats_t* stats, bool* foundSwapPss)
{
*foundSwapPss = false;
uint64_t prev_end = 0;
int prev_heap = HEAP_UNKNOWN;

std::string smaps_path = base::StringPrintf("/proc/%d/smaps", pid);
auto vma_scan = [&](const meminfo::Vma& vma) {
int which_heap = HEAP_UNKNOWN;
int sub_heap = HEAP_UNKNOWN;
bool is_swappable = false;
std::string name;
if (base::EndsWith(vma.name, " (deleted)")) {
name = vma.name.substr(0, vma.name.size() - strlen(" (deleted)"));
} else {
name = vma.name;
}

uint32_t namesz = name.size();
// 解析Native Heap 内存
if (base::StartsWith(name, "[heap]")) {
which_heap = HEAP_NATIVE;
} else if (base::StartsWith(name, "[anon:libc_malloc]")) {
which_heap = HEAP_NATIVE;
} else if (base::StartsWith(name, "[anon:scudo:")) {
which_heap = HEAP_NATIVE;
} else if (base::StartsWith(name, "[anon:GWP-ASan")) {
which_heap = HEAP_NATIVE;
}

// 解析 stack 部分内存
else if (base::StartsWith(name, "[stack")) {
which_heap = HEAP_STACK;
} else if (base::StartsWith(name, "[anon:stack_and_tls:")) {
which_heap = HEAP_STACK;
}
// 解析 code 部分的内存
else if (base::EndsWith(name, ".so")) {
which_heap = HEAP_SO;
is_swappable = true;
} else if (base::EndsWith(name, ".jar")) {
which_heap = HEAP_JAR;
is_swappable = true;
} else if (base::EndsWith(name, ".apk")) {
which_heap = HEAP_APK;
is_swappable = true;
} else if (base::EndsWith(name, ".ttf")) {
which_heap = HEAP_TTF;
is_swappable = true;
} else if ((base::EndsWith(name, ".odex")) ||
(namesz > 4 && strstr(name.c_str(), ".dex") != nullptr)) {
which_heap = HEAP_DEX;
sub_heap = HEAP_DEX_APP_DEX;
is_swappable = true;
} else if (base::EndsWith(name, ".vdex")) {
which_heap = HEAP_DEX;
……
} else if (base::EndsWith(name, ".oat")) {
which_heap = HEAP_OAT;
is_swappable = true;
} else if (base::EndsWith(name, ".art") || base::EndsWith(name, ".art]")) {
which_heap = HEAP_ART;
……
} else if (base::StartsWith(name, "/dev/")) {
which_heap = HEAP_UNKNOWN_DEV;
// 解析 gl 区域内存
if (base::StartsWith(name, "/dev/kgsl-3d0")) {
which_heap = HEAP_GL_DEV;
}
// 解析 cursor 区域内存
else if (base::StartsWith(name, "/dev/ashmem/CursorWindow")) {
which_heap = HEAP_CURSOR;
} else if (base::StartsWith(name, "/dev/ashmem/jit-zygote-cache")) {
which_heap = HEAP_DALVIK_OTHER;
sub_heap = HEAP_DALVIK_OTHER_ZYGOTE_CODE_CACHE;
}
//解析ashmen匿名共享内存
else if (base::StartsWith(name, "/dev/ashmem")) {
which_heap = HEAP_ASHMEM;
}
} else if (base::StartsWith(name, "/memfd:jit-cache")) {
which_heap = HEAP_DALVIK_OTHER;
sub_heap = HEAP_DALVIK_OTHER_APP_CODE_CACHE;
} else if (base::StartsWith(name, "/memfd:jit-zygote-cache")) {
which_heap = HEAP_DALVIK_OTHER;
sub_heap = HEAP_DALVIK_OTHER_ZYGOTE_CODE_CACHE;
}

//解析java Heap内存
else if (base::StartsWith(name, "[anon:")) {
which_heap = HEAP_UNKNOWN;
if (base::StartsWith(name, "[anon:")) {
which_heap = HEAP_DALVIK_OTHER;
if (base::StartsWith(name, "[anon:dalvik-LinearAlloc")) {
sub_heap = HEAP_DALVIK_OTHER_LINEARALLOC;
} else if (base::StartsWith(name, "[anon:dalvik-alloc space") ||
base::StartsWith(name, "[anon:dalvik-main space")) {
// This is the regular Dalvik heap.
which_heap = HEAP_DALVIK;
sub_heap = HEAP_DALVIK_NORMAL;
} else if (base::StartsWith(name,
"[anon:dalvik-large object space") ||
base::StartsWith(
name, "[anon:dalvik-free list large object space")) {
which_heap = HEAP_DALVIK;
sub_heap = HEAP_DALVIK_LARGE;
} else if (base::StartsWith(name, "[anon:dalvik-non moving space")) {
which_heap = HEAP_DALVIK;
sub_heap = HEAP_DALVIK_NON_MOVING;
} else if (base::StartsWith(name, "[anon:dalvik-zygote space")) {
which_heap = HEAP_DALVIK;
sub_heap = HEAP_DALVIK_ZYGOTE;
} else if (base::StartsWith(name, "[anon:dalvik-indirect ref")) {
sub_heap = HEAP_DALVIK_OTHER_INDIRECT_REFERENCE_TABLE;
} else if (base::StartsWith(name, "[anon:dalvik-jit-code-cache") ||
base::StartsWith(name, "[anon:dalvik-data-code-cache")) {
sub_heap = HEAP_DALVIK_OTHER_APP_CODE_CACHE;
} else if (base::StartsWith(name, "[anon:dalvik-CompilerMetadata")) {
sub_heap = HEAP_DALVIK_OTHER_COMPILER_METADATA;
} else {
sub_heap = HEAP_DALVIK_OTHER_ACCOUNTING; // Default to accounting.
}
}
} else if (namesz > 0) {
which_heap = HEAP_UNKNOWN_MAP;
} else if (vma.start == prev_end && prev_heap == HEAP_SO) {
// bss section of a shared library
which_heap = HEAP_SO;
}

prev_end = vma.end;
prev_heap = which_heap;

const meminfo::MemUsage& usage = vma.usage;
if (usage.swap_pss > 0 && *foundSwapPss != true) {
*foundSwapPss = true;
}

uint64_t swapable_pss = 0;
if (is_swappable && (usage.pss > 0)) {
float sharing_proportion = 0.0;
if ((usage.shared_clean > 0) || (usage.shared_dirty > 0)) {
sharing_proportion = (usage.pss - usage.uss) / (usage.shared_clean + usage.shared_dirty);
}
swapable_pss = (sharing_proportion * usage.shared_clean) + usage.private_clean;
}

// 将获取的数据进行累加
……

};

//for循环函数,执行maps文件的读取
return meminfo::ForEachVmaFromFile(smaps_path, vma_scan);
}

通过上面对 maps 的解析函数,我们不仅可以看到 maps 中的数据类型及格式,也可以知道 Dalvik Heap,Native Heap 等数据的组成。在做内存的线上异常监控时,异常情况下,也可以将 maps 文件上传到服务端,服务端对 maps 文件进行解析和分类,这样我们就能非常方便的定位和排查线上内存问题。

数据②:graphic 相关数据

了解了 maps 文件中的内存数据,我们再来看看 graphic 的数据,graphic 的数据有 3 部分。

  1. Gfx dev:绘制时分配,并且已经映射到应用进程虚拟内存中。这里需要注意的是,只有高通的芯片才会将这一块的内存放在 /dev/kgsl-3d0 路径,并映射到进程的虚拟内存中,其他的芯片不会放在这个路径。在上面的 load_maps 方法中,我们也可以看到对这一块内存数据的解析逻辑。

  2. GL mtrack:绘制时分配,没有映射到应用地址空间,包括纹理、顶点数据、shader program 等。

  3. EGL mtrack:应用的 Layer Surface,通过 gralloc 分配,没有映射到应用地址空间。不熟悉 Layer Surface 的话,可以将一个界面理解成一个 Layer Surface,Surface 存储了界面的数据,并交给 GPU 绘制。

上面 1 的数据是通过 load_maps 函数解析获取的,2 和 3 的数据是通过 read_memtrack_memory 函数获取的。该函数会读取和解析路径为 /d/kgsl/proc/{ pid }/mem 的文件,这个文件节点中的数据是gpu driver写入的,该方法的实现可以参考下面高通855源码中的 kgsl_memtrack_get_memory 函数,下面是这个函数的主体逻辑代码。(官方源码:kgsl.c

int kgsl_memtrack_get_memory(pid_t pid, enum memtrack_type type,
struct memtrack_record *records,
size_t *num_records)
{
……
// 1. 设置目标文件路径
snprintf(tmp, sizeof(tmp), "/d/kgsl/proc/%d/mem", pid);
……
while (1) {
// 2. 读取并解析该文件
……
}

……

return 0;
}

我们也可以在 root 手机中,查看 kgsl_memtrack_get_memory 函数读取到该应用进程的数据,下面是系统设置这个应用的部分 graphic 数据。

/d/kgsl/proc/3160 # cat mem
gpuaddr useraddr size id flags type usage sglen mapcount eglsrf eglimg
0000000000000000 0 196608 1 --w---N-- gpumem any(0) 0 0 0 0
0000000000000000 0 16384 2 --w--pY-- gpumem command 0 1 0 0
0000000000000000 0 4096 3 --w--pY-- gpumem any(0) 0 1 0 0
0000000000000000 0 4096 4 --w--pY-- gpumem any(0) 0 1 0 0
0000000000000000 0 4096 5 --w--pY-- gpumem gl 0 1 0 0
0000000000000000 0 4096 6 --w--pY-- gpumem any(0) 0 1 0 0
0000000000000000 0 4096 7 --w--pY-- gpumem any(0) 0 1 0 0
0000000000000000 0 20480 8 --w--pY-- gpumem any(0) 0 1 0 0
0000000000000000 0 4096 9 --w--pY-- gpumem any(0) 0 1 0 0
0000000000000000 0 4096 10 --w--pY-- gpumem any(0) 0 1 0 0
0000000000000000 0 196608 11 --w---N-- gpumem any(0) 0 0 0 0
0000000000000000 0 16384 12 --w--pY-- gpumem command 0 1 0 0
0000000000000000 0 4096 13 --w--pY-- gpumem any(0) 0 1 0 0
0000000000000000 0 4096 14 --w--pY-- gpumem any(0) 0 1 0 0
0000000000000000 0 4096 15 --w--pY-- gpumem gl 0 1 0 0
0000000000000000 0 4096 16 --w--pY-- gpumem any(0) 0 1 0 0
0000000000000000 0 4096 17 --w--pY-- gpumem any(0) 0 1 0 0
0000000000000000 0 32768 18 --w--pY-- gpumem any(0) 0 1 0 0
0000000000000000 0 4096 19 --w--pY-- gpumem any(0) 0 1 0 0
0000000000000000 0 4096 20 --w--pY-- gpumem any(0) 0 1 0 0
0000000000000000 0 65536 21 --w--pY-- gpumem arraybuffer 0 1 0 0
0000000000000000 0 131072 22 --wl-pY-- gpumem command 0 1 0 0
0000000000000000 0 32768 23 --w--pY-- gpumem gl 0 1 0 0
0000000000000000 0 131072 24 --wl-pY-- gpumem gl 0 1 0 0
0000000000000000 0 8192 25 --w--pY-- gpumem command 0 1 0 0
0000000000000000 0 8192 26 --w--pY-- gpumem command 0 1 0 0
0000000000000000 0 16384 27 --w--pY-- gpumem command 0 1 0 0
0000000000000000 0 9469952 28 --wL--N-- ion egl_surface 152 0 1 1
0000000000000000 0 131072 29 --wl-pY-- gpumem command 0 1 0 0
0000000000000000 0 8192 30 --w--pY-- gpumem command 0 1 0 0
0000000000000000 0 4096 31 -----pY-- gpumem gl 0 1 0 0
0000000000000000 0 4096 32 --w--pY-- gpumem any(0) 0 1 0 0
0000000000000000 0 4096 33 -----pY-- gpumem gl 0 1 0 0
0000000000000000 0 4096 34 --w--pY-- gpumem any(0) 0 1 0 0
0000000000000000 0 4096 35 -----pY-- gpumem gl 0 1 0 0
……

数据③:Alloc 内存

内存描述指标这一部分,我们已经知道数据 ③ 中的数据是调用 malloc、mmap、calloc 等内存申请函数时积累的数据,想要获取这个数据,可以通过下面的接口实现。

  • 获取 Java 层申请的内存:会直接去 Art 虚拟机中获取虚拟机已经申请的内存大小。
Runtime runtime = Runtime.getRuntime();
//获取已经申请的Java内存 long usedMemory=runtime.totalMemory() ;
//获取申请但未使用Java内存 long freeMemory = runtime.freeMemory();
  • 获取 Native 申请的内存:会调用 android_os_Debug.cpp 对象中的android_os_Debug_getNativeHeapSize 接口获取数据,该接口又是调用的 mallinfo 函数,mallinfo 函数会返回 native 层已经申请的内存大小。
 //获取已经申请的Native内存
long nativeHeapSize = Debug.getNativeHeapSize()
//获取申请但未使用Native内存
long nativeHeapFreeSize = Debug.getNativeHeapFreeSize()

//Naitve层
static jlong android_os_Debug_getNativeHeapSize(JNIEnv *env, jobject clazz)
{
struct mallinfo info = mallinfo();
return (jlong) info.usmblks;
}

我们可以看下 mallinfo 函数的说明文档:

image.png

通过上面两个接口获取 Naitve 和 Java 的内存数据效率最高,性能消耗最小,所以适合在代码中做数据监控使用。通过读取和解析 maps 文件来获取内存数据对性能的开销较大,所以从 Android10 开始加了 5 分钟的频控。

B区域

B 区域的数据就是将 A 区域中的 ① 数据做了汇总操作,方便我们查看,并没有太特别的内容,这里就简单列一下了。

  • Java Heap:(Dalvik Heap 的 Private Dirty 数据) + ( .art mmap 部分的 Private Dirty 和 Private Clean 数据) + getOtherPrivate ( OTHER_ART ) 。这里的 .art 是应用的 dex 文件预编译后的 art 文件,所以也是属于该应用的 JavaHeap。

  • Native Heap:Native Heap 的 Private Dirty 数据。

  • Code:.so .jar .apk .ttf .dex .oat 等资源加总。

  • Stack:getOtherPrivateDirty ( OTHER_STACK )。

  • Graphics:gl,gfx,egl 的数据加总。

  • System:( Total Pss ) - ( Private Dirty 和 Private Clean 的总和)。主要是系统占用的内存,如共享的字体、图像资源等。

小结

想要深入掌握 App 运行时的内存模型,夯实内存优化的基础,首先我们要熟悉描述内存的指标,它们是度量我们内存优化效果的重要工具。

常用的指标有 6 个,分别是共享库按比例分担的 Pss;进程在 RAM 中实际保存的总内存 RSS;只被当前进程独占的物理内存 Private Clean / Private Dirty;和 Private 相反的 Swap Pss Dirty;以及 Heap Alloc 和空闲的虚拟内存 Heap Free。获取这些指标的方法有两个,线下可以通过 adb 命令获取,线上可以通过代码获取。

其次,我们需要从原理上深入了解内存的组成,以及这些组成的来源,这样我们才能在内存优化中,做到有的放矢。我们重点掌握 3 类数据:maps 文件数据、graphic 相关数据和 Alloc 内存。

这一章节的内容虽然属于基础知识,但掌握它们可以在后面的实战章节中,帮助我们更容易理解和上手。


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

算法| Java的int类型最大值为什么是21亿多?

int
开篇 本文主要介绍在Java中,为什么int类型的最大值为2147483647。 理论值 我们都知道在Java中,int 的长度为32位。 理论上,用二进制表示,32位每一位都是1的话,那么这个数是多少呢? 我们来计算一下,第0位可以用20^00表示,第1位...
继续阅读 »

开篇


本文主要介绍在Java中,为什么int类型的最大值为2147483647


理论值


我们都知道在Java中,int 的长度为32位。


理论上,用二进制表示,32位每一位都是1的话,那么这个数是多少呢?


image.png


我们来计算一下,第0位可以用20^0表示,第1位可以用21^1表示,第31位可以用231表示,那么32位二进制能够表示的最大值为232 - 1,所以理论上32位数值的取值范围为0 ~ 232 - 1


那么,Java的int最大值真的为232 - 1吗?


我们知道,232 - 1这个值为42亿多。而在Java中,int的最大值为2147483647也就是21亿多,为什么有这个差距呢?


分析


我们来看下,Javaint的最大值以及这个最大值的二进制数据。


image.png


可以看到,int的最大值的最高位为0,而不是1,也就是用31位来表示能够取到的最大值,而不是32位。
因为在Java中,整型是有符号整型,最高位是有特殊含义,代表符号,真正表示数据值的范围为0 ~ 30位。


所以,按照31位来表示的话,其最大值为231 - 1,而这个值就是2147483647即21亿多。


int数据有正负之分,所以最高位用来表示符号,0代表正数,1代表负数。因此Java中,int的数据范围为 -231 ~ 231 - 1


为啥减1


那为什么都是231, 正数的时候需要减1呢?


我们先来看一下,int的最大值和最小值:


image.png


不看符号位的话,最大值比最小值少了1个,这是因为0归到正数里面,所以占用了正数的一个位置。


拓展


负数表示


负数的二进制形式如何表示呢?


先看-100这个数的二进制形式:


image.png


最高位为1,就代表负数。值就为符号位后面的值取反再加上1。


image.png


二进制1100100对应的10进制就是100.


反码


反码就是,对一个数的二进制除符号位外,按位取反。取反就是二进制数,1变成0,0变成1,这个过程就是取反。


来看一个例子:


image.png


可以看到,ab两个数的二进制是完全相反的。


为什么要取反加1呢?为什么要设计的这么扭曲?到底是人性的扭曲还是道德的沦丧? 这样设计有什么好处?


在计算机系统里,加减乘除的运算,并不是我们想象中10进制的加减乘除,他最后都会被翻译成2进制的位运算来计算。


假如有2个数,ab都是整数,那么a + b 对应的二进制就是简单的相加。那么如果a为负数,b为正数呢?在执行a + b 的时候,难道还需要特殊处理一下吗?显然是不可能的,在二进制运算中,加减乘除运算只有各自的一套逻辑,无论符号两边的数是什么样子的。


a为负数,那么对a进行取反加1,再与b进行相加,可以按正常的相加逻辑,这样运算结果依然是正确的,而不是说,当a为负数时,计算机去执行另一套的相加逻辑。设计成取反加1,可以让相加运算不去关注两边的数据是正是负,只执行一套相加逻辑就可以了,这对计算机来说是一个性能的提升。


示例


从上面我们得知,负数的二进制表示为数值部分取反加1,以-100为例,那么可以得出-100 等于 ~100 + 1


image.png


知道负数的二进制的样子后,再看int最小值和-1的二进制数据,就不会惊讶了。要不然,当看到int的最小值的二进制居然是一堆0组成,而-1居然是一堆1,看到这样的数据,心里岂不是冒出一堆问号或者一群小羊飘过。


image.png


取反加1还是自己的数


有没有一个数,取反加1还是自己?有,0int的最小值,下面来看下:


image.png


先看下Integer.MIN_VALUE的取反加1的过程,可以看到,Integer.MIN_VALUE在取反后加上1,仍然还是他自己。


image.png


再看下0的取反加1过程,可以看到0再取反加1后,我嘞个去,居然溢出了!溢出怎么办?溢出就扔了吧不要了,结果还是他自己。


image.png


后记


本文主要介绍在Java中,为什么int类型的最大值为什么是21亿多,以及涉及到的知识点的拓展,如有错误欢迎之处。


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

让人恶心的多线程代码,性能怎么优化!

Java 中最烦人的,就是多线程,一不小心,代码写的比单线程还慢,这就让人非常尴尬。 通常情况下,我们会使用 ThreadLocal 实现线程封闭,比如避免 SimpleDateFormat 在并发环境下所引起的一些不一致情况。其实还有一种解决方式。通过对pa...
继续阅读 »

Java 中最烦人的,就是多线程,一不小心,代码写的比单线程还慢,这就让人非常尴尬。


通常情况下,我们会使用 ThreadLocal 实现线程封闭,比如避免 SimpleDateFormat 在并发环境下所引起的一些不一致情况。其实还有一种解决方式。通过对parse方法进行加锁,也能保证日期处理类的正确运行,代码如图。


image.png


1. 锁很坏


但是,锁这个东西,很坏。就像你的贞操锁,一开一闭热情早已烟消云散。


所以,锁对性能的影响,是非常大的。对资源加锁以后,资源就被加锁的线程所独占,其他的线程就只能排队等待这个锁。此时,程序由并行执行,变相的变成了顺序执行,执行速度自然就降低了。


下面是开启了50个线程,使用ThreadLocal和同步锁方式性能的一个对比。


Benchmark                                 Mode  Cnt     Score      Error   Units
SynchronizedNormalBenchmark.sync         thrpt   10  2554.628 ± 5098.059  ops/ms
SynchronizedNormalBenchmark.threadLocal  thrpt   10  3750.902 ±  103.528  ops/ms
========去掉业务影响========  
Benchmark                                 Mode  Cnt        Score        Error   Units
SynchronizedNormalBenchmark.sync         thrpt   10    26905.514 ±   1688.600  ops/ms
SynchronizedNormalBenchmark.threadLocal  thrpt   10  7041876.244 ± 355598.686  ops/ms

可以看到,使用同步锁的方式,性能是比较低的。如果去掉业务本身逻辑的影响(删掉执行逻辑),这个差异会更大。代码执行的次数越多,锁的累加影响越大,对锁本身的速度优化,是非常重要的。


我们都知道,Java 中有两种加锁的方式,一种就是常见的synchronized 关键字,另外一种,就是使用 concurrent 包里面的 Lock。针对于这两种锁,JDK 自身做了很多的优化,它们的实现方式也是不同的。


2. synchronied原理


synchronized关键字给代码或者方法上锁时,都有显示的或者隐藏的上锁对象。当一个线程试图访问同步代码块时,它首先必须得到锁,退出或抛出异常时必须释放锁。




  • 给普通方法加锁时,上锁的对象是this




  • 给静态方法加锁时,锁的是class对象。




  • 给代码块加锁,可以指定一个具体的对象作为锁




monitor,在操作系统里,其实就叫做管程。


那么,synchronized 在字节码中,是怎么体现的呢?参照下面的代码,在命令行执行javac,然后再执行javap -v -p,就可以看到它具体的字节码。可以看到,在字节码的体现上,它只给方法加了一个flag:ACC_SYNCHRONIZED


synchronized void syncMethod() {
  System.out.println("syncMethod");
}
======字节码=====
synchronized void syncMethod();
    descriptor: ()V
    flags: ACC_SYNCHRONIZED
    Code:
      stack=2, locals=1, args_size=1
         0: getstatic     #4                 
         3: ldc           #5                         
         5: invokevirtual #6           
         8: return

我们再来看下同步代码块的字节码。可以看到,字节码是通过monitorentermonitorexit两个指令进行控制的。


void syncBlock(){
    synchronized (Test.class){
    }
}
======字节码======
void syncBlock();
    descriptor: ()V
    flags:
    Code:
      stack=2, locals=3, args_size=1
         0: ldc           #2 
         2: dup
         3: astore_1
         4: monitorenter
         5: aload_1
         6: monitorexit
         7: goto          15
        10: astore_2
        11: aload_1
        12: monitorexit
        13: aload_2
        14: athrow
        15: return
      Exception table:
         from    to  target type
             5     7    10   any
            10    13    10   any

这两者虽然显示效果不同,但他们都是通过monitor来实现同步的。我们可以通过下面这张图,来看一下monitor的原理。


注意了,下面是面试题目高发地。


image.png


如图所示,我们可以把运行时的对象锁抽象的分成三部分。其中,EntrySet 和WaitSet 是两个队列,中间虚线部分是当前持有锁的线程。我们可以想象一下线程的执行过程。


当第一个线程到来时,发现并没有线程持有对象锁,它会直接成为活动线程,进入 RUNNING 状态。


接着又来了三个线程,要争抢对象锁。此时,这三个线程发现锁已经被占用了,就先进入 EntrySet 缓存起来,进入 BLOCKED 状态。此时,从jstack命令,可以看到他们展示的信息都是waiting for monitor entry


"http-nio-8084-exec-120" #143 daemon prio=5 os_prio=31 cpu=122.86ms elapsed=317.88s tid=0x00007fedd8381000 nid=0x1af03 waiting for monitor entry  [0x00007000150e1000]
   java.lang.Thread.State: BLOCKED (on object monitor)
    at java.io.BufferedInputStream.read(java.base@13.0.1/BufferedInputStream.java:263)
    - waiting to lock <0x0000000782e1b590> (a java.io.BufferedInputStream)
    at org.apache.commons.httpclient.HttpParser.readRawLine(HttpParser.java:78)
    at org.apache.commons.httpclient.HttpParser.readLine(HttpParser.java:106)
    at org.apache.commons.httpclient.HttpConnection.readLine(HttpConnection.java:1116)
    at org.apache.commons.httpclient.HttpMethodBase.readStatusLine(HttpMethodBase.java:1973)
    at org.apache.commons.httpclient.HttpMethodBase.readResponse(HttpMethodBase.java:1735)

处于活动状态的线程,执行完毕退出了;或者由于某种原因执行了wait 方法,释放了对象锁,就会进入 WaitSet 队列。这就是在调用wait之前,需要先获得对象锁的原因。就像下面的代码:


synchronized (lock){
    try {
         lock.wait();
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
}

此时,jstack显示的线程状态是 WAITING 状态,而原因是in Object.wait()


"wait-demo" #12 prio=5 os_prio=31 cpu=0.14ms elapsed=12.58s tid=0x00007fb66609e000 nid=0x6103 in Object.wait()  [0x000070000f2bd000]
   java.lang.Thread.State: WAITING (on object monitor)
    at java.lang.Object.wait(java.base@13.0.1/Native Method)
    - waiting on <0x0000000787b48300> (a java.lang.Object)
    at java.lang.Object.wait(java.base@13.0.1/Object.java:326)
    at WaitDemo.lambda$main$0(WaitDemo.java:7)
    - locked <0x0000000787b48300> (a java.lang.Object)
    at WaitDemo$$Lambda$14/0x0000000800b44840.run(Unknown Source)
    at java.lang.Thread.run(java.base@13.0.1/Thread.java:830)

发生了这两种情况,都会造成对象锁的释放。进而导致 EntrySet里的线程重新争抢对象锁,成功抢到锁的线程成为活动线程,这是一个循环的过程。


那 WaitSet 中的线程是如何再次被激活的呢?接下来,在某个地方,执行了锁的 notify 或者 notifyAll 命令,会造成WaitSet中 的线程,转移到 EntrySet 中,重新进行锁的争夺。


如此周而复始,线程就可按顺序排队执行。


3. 分级锁


JDK1.8中,synchronized 的速度已经有了显著的提升。那它都做了哪些优化呢?答案就是分级锁。JVM会根据使用情况,对synchronized 的锁,进行升级,它大体可以按照下面的路径:偏向锁->轻量级锁->重量级锁。


锁只能升级,不能降级,所以一旦升级为重量级锁,就只能依靠操作系统进行调度。


和锁升级关系最大的就是对象头里的 MarkWord,它包含Thread IDAgeBiasedTag四个部分。其中,Biased 有1bit大小,Tag 有2bit,锁升级就是靠判断Thread Id、Biased、Tag等三个变量值来进行的。


偏向锁


在只有一个线程使用了锁的情况下,偏向锁能够保证更高的效率。


具体过程是这样的。当第一个线程第一次访问同步块时,会先检测对象头Mark Word中的标志位Tag是否为01,以此判断此时对象锁是否处于无锁状态或者偏向锁状态(匿名偏向锁)。


01也是锁默认的状态,线程一旦获取了这把锁,就会把自己的线程ID写到MarkWord中。在其他线程来获取这把锁之前,锁都处于偏向锁状态。


轻量级锁


当下一个线程参与到偏向锁竞争时,会先判断 MarkWord 中保存的线程 ID 是否与这个线程 ID 相等,如果不相等,会立即撤销偏向锁,升级为轻量级锁。


轻量级锁的获取是怎么进行的呢?它们使用的是自旋方式。


参与竞争的每个线程,会在自己的线程栈中生成一个 LockRecord ( LR ),然后每个线程通过 CAS (自旋)的方式,将锁对象头中的 MarkWord 设置为指向自己的 LR 的指针,哪个线程设置成功,就意味着哪个线程获得锁。


当锁处于轻量级锁的状态时,就不能够再通过简单的对比Tag的值进行判断,每次对锁的获取,都需要通过自旋。


当然,自旋也是面向不存在锁竞争的场景,比如一个线程运行完了,另外一个线程去获取这把锁。但如果自旋失败达到一定的次数,锁就会膨胀为重量级锁。


重量级锁


重量级锁即为我们对synchronized的直观认识,这种情况下,线程会挂起,进入到操作系统内核态,等待操作系统的调度,然后再映射回用户态。系统调用是昂贵的,重量级锁的名称由此而来。


如果系统的共享变量竞争非常激烈,锁会迅速膨胀到重量级锁,这些优化就名存实亡。如果并发非常严重,可以通过参数-XX:-UseBiasedLocking禁用偏向锁,理论上会有一些性能提升,但实际上并不确定。


4. Lock


在 concurrent 包里,我们能够发现ReentrantLockReentrantReadWriteLock两个类。Reentrant就是可重入的意思,它们和synchronized关键字一样,都是可重入锁。


这里有必要解释一下可重入这个概念,因为在面试的时候经常被问到。它的意思是,一个线程运行时,可以多次获取同一个对象锁。这是因为Java的锁是基于线程的,而不是基于调用的。比如下面这段代码,由于方法a、b、c锁的都是当前的this,线程在调用a方法的时候,就不需要多次获取对象锁。


public synchronized void a(){
    b();
}
public synchronized void b(){
    c();
}
public synchronized void c(){
}

主要方法


LOCK是基于AQS(AbstractQueuedSynchronizer)实现的,而AQS 是基于 volitale 和 CAS 实现的。关于CAS,我们将在下一课时讲解。


Lock与synchronized的使用方法不同,它需要手动加锁,然后在finally中解锁。Lock接口比synchronized灵活性要高,我们来看一下几个关键方法。




  • lock: lock方法和synchronized没什么区别,如果获取不到锁,都会被阻塞




  • tryLock: 此方法会尝试获取锁,不管能不能获取到锁,都会立即返回,不会阻塞。它是有返回值的,获取到锁就会返回true




  • tryLock(long time, TimeUnit unit):  与tryLock类似,但它在拿不到锁的情况下,会等待一段时间,直到超时




  • lockInterruptibly: 与lock类似,但是可以锁等待可以被中断,中断后返回InterruptedException




一般情况下,使用lock方法就可以。但如果业务请求要求响应及时,那使用带超时时间的tryLock是更好的选择:我们的业务可以直接返回失败,而不用进行阻塞等待。tryLock这种优化手段,采用降低请求成功率的方式,来保证服务的可用性,高并发场景下经常被使用。


读写锁


但对于有些业务来说,使用Lock这种粗粒度的锁还是太慢了。比如,对于一个HashMap来说,某个业务是读多写少的场景,这个时候,如果给读操作也加上和写操作一样的锁的话,效率就会很慢。


ReentrantReadWriteLock是一种读写分离的锁,它允许多个读线程同时进行,但读和写、写和写是互斥的。使用方法如下所示,分别获取读写锁,对写操作加写锁,对读操作加读锁,并在finally里释放锁即可。


ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
    Lock readLock = lock.readLock();
    Lock writeLock = lock.writeLock();

    public void put(K k, V v) {
        writeLock.lock();
        try {
            map.put(k, v);
        } finally {
            writeLock.unlock();
        }
    }
...

那么,除了ReadWriteLock,我们能有更快的读写分离模式么?JDK1.8加入了哪个API?欢迎留言区评论。


公平锁与非公平锁


我们平常用到的锁,都是非公平锁。可以回过头来看一下monitor的原理。当持有锁的线程释放锁的时候,EntrySet里的线程就会争抢这把锁。这个争抢的过程,是随机的,也就是说你并不知道哪个线程会获取对象锁,谁抢到了就算谁的。


这就有一定的概率,某个线程总是抢不到锁,比如,线程通过setPriority 设置的比较低的优先级。这个抢不到锁的线程,就一直处于饥饿状态,这就是线程饥饿的概念。


公平锁通过把随机变成有序,可以解决这个问题。synchronized没有这个功能,在Lock中可以通过构造参数设置成公平锁,代码如下。


public ReentrantReadWriteLock(boolean fair) {
        sync = fair ? new FairSync() : new NonfairSync();
        readerLock = new ReadLock(this);
        writerLock = new WriteLock(this);
}

由于所有的线程都需要排队,需要在多核的场景下维护一个同步队列,在多个线程争抢锁的时候,吞吐量就很低。下面是20个并发之下锁的JMH测试结果,可以看到,非公平锁比公平锁性能高出两个数量级。


Benchmark                      Mode  Cnt      Score      Error   Units
FairVSNoFairBenchmark.fair    thrpt   10    186.144 ±   27.462  ops/ms
FairVSNoFairBenchmark.nofair  thrpt   10  35195.649 ± 6503.375  ops/ms

5. 锁的优化技巧


死锁


我们可以先看一下锁冲突最严重的一种情况:死锁。下面这段示例代码,两个线程分别持有了对方所需要的锁,进入了相互等待的状态,就进入了死锁。面试中手写这段代码的频率,还是挺高的。


public class DeadLockDemo {
    public static void main(String[] args) {
        Object object1 = new Object();
        Object object2 = new Object();
        Thread t1 = new Thread(() -> {
            synchronized (object1) {
                try {
                    Thread.sleep(200);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (object2) {
                }
            }
        }, "deadlock-demo-1");

        t1.start();
        Thread t2 = new Thread(() -> {
            synchronized (object2) {
                synchronized (object1) {
                }
            }
        }, "deadlock-demo-2");
        t2.start();
    }
}

使用我们上面提到的,带超时时间的tryLock方法,有一方让步,可以一定程度上避免死锁。


优化技巧


锁的优化理论其实很简单,那就是减少锁的冲突。无论是锁的读写分离,还是分段锁,本质上都是为了避免多个线程同时获取同一把锁。我们可以总结一下优化的一般思路:减少锁的粒度、减少锁持有的时间、锁分级、锁分离 、锁消除、乐观锁、无锁等。


image.png


减少锁粒度


通过减小锁的粒度,可以将冲突分散,减少冲突的可能,从而提高并发量。简单来说,就是把资源进行抽象,针对每类资源使用单独的锁进行保护。比如下面的代码,由于list1和list2属于两类资源,就没必要使用同一个对象锁进行处理。


public class LockLessDemo {
    List<String> list1 = new ArrayList<>();
    List<String> list2 = new ArrayList<>();
    public synchronized void addList1(String v){
        this.list1.add(v);
    }
    public synchronized void addList2(String v){
        this.list2.add(v);
    }
}

可以创建两个不同的锁,改善情况如下:


public class LockLessDemo {
    List<String> list1 = new ArrayList<>();
    List<String> list2 = new ArrayList<>();
    final Object lock1 = new Object();
    final Object lock2 = new Object();
    public void addList1(String v) {
        synchronized (lock1) {
            this.list1.add(v);
        }
    }
    public void addList2(String v) {
        synchronized (lock2) {
            this.list2.add(v);
        }
    }
}

减少锁持有时间通过让锁资源尽快的释放,减少锁持有的时间,其他线程可更迅速的获取锁资源,进行其他业务的处理。考虑到下面的代码,由于slowMethod不在锁的范围内,占用的时间又比较长,可以把它移动到synchronized代码快外面,加速锁的释放。


public class LockTimeDemo {
    List<String> list = new ArrayList<>();
    final Object lock = new Object();
    public void addList(String v) {
        synchronized (lock) {
            slowMethod();
            this.list.add(v);
        }
    }
    public void slowMethod(){
    }
}

锁分级锁分级指的是我们文章开始讲解的synchronied锁的锁升级,属于JVM的内部优化。它从偏向锁开始,逐渐会升级为轻量级锁、重量级锁,这个过程是不可逆的。


锁分离我们在上面提到的读写锁,就是锁分离技术。这是因为,读操作一般是不会对资源产生影响的,可以并发执行。写操作和其他操作是互斥的,只能排队执行。所以读写锁适合读多写少的场景。


锁消除通过JIT编译器,JVM可以消除某些对象的加锁操作。举个例子,大家都知道StringBuffer和StringBuilder都是做字符串拼接的,而且前者是线程安全的。


但其实,如果这两个字符串拼接对象用在函数内,JVM通过逃逸分析分析这个对象的作用范围就是在本函数中,就会把锁的影响给消除掉。比如下面这段代码,它和StringBuilder的效果是一样的。


String m1(){
    StringBuffer sb = new StringBuffer();
    sb.append("");
    return sb.toString();
}

End


Java中有两种加锁方式,一种是使用synchronized关键字,另外一种是concurrent包下面的Lock。本课时,我们详细的了解了它们的一些特性,包括实现原理。下面对比如下:















































类别SynchronizedLock
实现方式monitorAQS
底层细节JVM优化Java API
分级锁
功能特性单一丰富
锁分离读写锁
锁超时带超时时间的tryLock
可中断lockInterruptibly

Lock的功能是比synchronized多的,能够对线程行为进行更细粒度的控制。但如果只是用最简单的锁互斥功能,建议直接使用synchronized。有两个原因:




  • synchronized的编程模型更加简单,更易于使用




  • synchronized引入了偏向锁,轻量级锁等功能,能够从JVM层进行优化,同时,JIT编译器也会对它执行一些锁消除动作




多线程代码好写,但bug难找,希望你的代码即干净又强壮,兼高性能与高可靠于一身。


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

App实现JSBridge的最佳方案

前沿 写这篇文章的主要目的是对 App 的 JSBridge 做一个全面的介绍,同时根据不同的使用场景总结出一份 App 实现 JSBridge 的最佳方案。对于没有接触过 App 的同学能够对 JSBridge 有个大致的概念,对于做过 App 的 JSBr...
继续阅读 »

前沿


写这篇文章的主要目的是对 App 的 JSBridge 做一个全面的介绍,同时根据不同的使用场景总结出一份 App 实现 JSBridge 的最佳方案。对于没有接触过 App 的同学能够对 JSBridge 有个大致的概念,对于做过 App 的 JSBridge 开发的同学也能有更系统的认识,也是自己对于相关知识点的归纳总结。


一、概念


什么是 JSBridge ?


JSBridge 的全称:JavaScript Bridge,中文名 JS桥JS桥接器


JSBridge 是一种用于在 Android 和 iOS 应用与 H5 之间进行通信的技术。它允许应用开发者在原生代码中调用 JavaScript 函数,以及 在JavaScript 中调用原生代码函数。其通常用于移动应用开发中,可以使用 JSBridge 技术在原生应用中嵌入网页,并在网页与原生应用之间进行交互。


二、原理


JSBridge 通过在 WebView 中注册 JavaScript 函数来实现通信。WebView 是一种在应用中嵌入网页的组件,可以在应用中显示网页内容。JSBridge 通过在 WebView 中注册 JavaScript 函数,并在原生代码中调用这些函数来实现通信


例如,下面是一个使用 JSBridge 实现通信的示例代码:


/* Android 端实现 */
// 在WebView中注册JavaScript函数
webView.loadUrl("javascript:function myFunction() { /* JavaScript code here */ }");

// 在原生代码中调用JavaScript函数
webView.loadUrl("javascript:myFunction()");

/* iOS 端实现 */
// 在WebView中注册JavaScript函数
[self.webView stringByEvaluatingJavaScriptFromString:@"function myFunction() { /* JavaScript code here */ }"];

// 在原生代码中调用JavaScript函数
[self.webView stringByEvaluatingJavaScriptFromString:@"myFunction()"];

上面的代码通过在 WebView 中注册 JavaScript 函数 myFunction,并在原生代码中调用这个函数来实现通信。


在实际开发中,我们一般是创建一个 JSBridge 对象,然后通过 WebView 的 addJavascriptInterface 方法进行注册。


// WebView 的 addJavascriptInterface 方法源码
public void addJavascriptInterface(Object object, String name) {
checkThread();
if (object == null) {
throw new NullPointerException("Cannot add a null object");
}
if (name == null || name.length() == 0) {
throw new IllegalArgumentException("Invalid name");
}
mJavascriptInterfaces.put(name, object);
}

该方法首先检查当前线程是否是 UI 线程,以确保添加桥接对象的操作是在 UI 线程中进行的。接着,该方法会检查桥接对象和名称的有效性,确保它们都不为空。最后,该方法会把桥接对象与名称关联起来,并存储到 WebView 的 mJavascriptInterfaces 对象中。


当网页加载完成后,WebView 会把桥接对象的方法注入到网页中,使得网页能够调用这些方法。当网页中的 JavaScript 代码调用桥接对象的方法时,WebView 会把该方法调用映射到原生代码中,从而实现网页与原生应用之间的交互。


addJavascriptInterface 方法的主要作用是把桥接对象的方法注入到网页中,使得网页能够调用这些方法。它的具体实现方式可能会因平台而异,但是它的基本原理是一致的。


三、原生实现


以 H5 获取 App 的版本号为例。Android相关源码


要实现一个获取 App 版本号的 JSBridge,需要在 H5 中编写 JavaScript 代码,并在 Android 原生代码中实现对应的原生方法。


首先,需要在 H5 中编写 JavaScript 代码,用于调用 Android 的原生方法。例如,可以在 H5 中定义一个函数,用于调用 Android 的原生方法:


// assets/index.html
function getAppVersion() {
// 通过JSBridge调用Android的原生方法
JSBridge.getAppVersion(function(version) {
// 在这里处理获取到的Android版本号
});
}

然后,需要在 Android 的原生代码中实现对应的原生方法。例如,可以实现一个名为 getAppVersion 的方法,用于在 H5 中调用:


// com.fitem.webviewdemo.AppJSBridge
@JavascriptInterface
public String getAppVersion() {
// 获取App版本号
String version = BuildConfig.VERSION_NAME;

// 将App版本号返回给H5
return version;
}

最后通过 Webview 注入定义的 JavascriptInterface 方法的对象,在 H5 生成 window.jsBridge 对象进行调用。


// com.fitem.webviewdemo.MainActivity.kt
webView.addJavascriptInterface(jsBridge, "jsBridge")

iOS 的实现和 Android 类似:


- (void)getIOSVersion:(WVJBResponseCallback)callback {
// 获取App版本号
let version = Bundle.main.object(forInfoDictionaryKey:
"CFBundleShortVersionString") as! String

// 将App版本号返回给H5
callback(version);
}

// 在网页加载完成后设置JSBridge
- (void)webViewDidFinishLoad:(UIWebView *)webView {
// 设置JSBridge
[WebViewJavascriptBridge enableLogging];
self.bridge = [WebViewJavascriptBridge bridgeForWebView:webView];
[self.bridge setWebViewDelegate:self];
}

四、跨平台(Flutter)


1. JSBridge 实现


Flutter 实现 JSBridge 功能的插件有很多,但基本上大多数都是基于原生的 JSBridge 能力实现。这里主要介绍官方的 webview_flutter 插件。


webview_flutter 插件实现 App 与 H5 之前的通信分为:App 发送消息到 H5H5 发送消息到 APP 两部分。


H5 发送消息到 APP。首先在 Flutter 应用中添加 WebView 组件,并设置 JavascriptChannel


      WebView(
initialUrl: 'https://www.example.com',
javascriptMode: JavascriptMode.unrestricted,
javascriptChannels: {
// 设置JavascriptChannel
JavascriptChannel(
name: 'JSBridge',
onMessageReceived: (JavascriptMessage message) {
// 在这里处理来自H5的消息
},
),
},
),

在H5中,可以通过 JSBridge 对象来调用原生方法:


// 通过JSBridge调用原生方法
window.jsBridge.postMessage('Hello, world!');

App 发送消息到 H5。 在 Flutter 中,通过 WebViewController 的 runJavascrip 调用 H5 中 window 对象的方法


controller.runJavascript("receiveMessage(${json.encode(res)})")

在 H5 中,可以通过 onmessage 事件来接收来自原生的消息:


  // 接收来自原生的消息
window.receiveMessage = function receiveMessage(message) {
console.log(message);
};

2. 局限性


webview_flutter 最大的局限在于 App 端与 H5 端之间的通信只支持单向通信,无法通过一次调用直接获取另一端的返回值。


五、App 实现 JSBridge 的最佳方案


1. 实现目标




  1. H5 兼容原生老版本 JSBridge。




  2. 支持两端双向通信。针对 webview_flutter 的单向通信的局限性进行改造优化,使其能支持返回值的回调。




2. NativeBridge 插件开发


NativeBridge 本质上是对 webview_flutter 的单向通信能力进行扩展封装


NativeBridge 插件的使用和实现原理,请阅读之前的文章《Flutter插件之NativeBridge》和《NativeBridge实现原理解析》。


3. 实现效果



  1. H5 支持原生老版本 JSBridge 兼容。


  // 获取app版本号 返回String
async getVersionCode() {
// 是否是新的JSBridge
if (this.isNewJSBridge()) {
return await window.jsBridgeHelper.sendMessage('getVersionCode', null)
} else {
return window.iLotJsBridge.getVersionCode()
}
}


  1. 支持两端双向通信。


  // H5 获取 App 的值
const versionNo = await jsBridge.getVersionCode()

// App 获取 H5 的值
var isHome = await NativeBridgeHelper.sendMessage("isHome", null, webViewController).future ?? false;


  1. 新增超时连接机制


就像网络请求一样,我们不能让代码执行一直阻塞在获取返回值的位置上。因为单向发送消息是不可靠的,可能存在消息丢失,或者另一端不响应消息的情况。因此我们需要类似网络请求一样,增加超时回调机制。


   // 增加回调异常容错机制,避免消息丢失导致一直阻塞
Future.delayed(const Duration(milliseconds: 100), (){
var completer = _popCallback(callbackId);
completer?.complete(Future.value(null));
});

总结


我们首先介绍了 JSBridge 的概念和原理,然后通过在 Android 、iOS 和 Flutter 中实现 JSBridge 来理解原生和 Flutter 之前的差异,最后总结了在 App 中实现 JSBridge 的最佳方案,方案包括支持原生和 Flutter 的兼容,并优化 webview_flutter 只支持单向通信的局限性和增加超时回调机制。


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

Glide 原理探索

implementation 'com.github.bumptech.glide:glide:4.12.0' annotationProcessor 'com.github.bumptech.glide:compiler:4.12.0' ...
继续阅读 »
    implementation 'com.github.bumptech.glide:glide:4.12.0'
annotationProcessor 'com.github.bumptech.glide:compiler:4.12.0'

        Glide.with(this).load(url).into(imageView)

上面这行代码,是 Glide 最简单的使用方式了,下面我们来一个个拆解下。


with


with 就是根据传入的 context 来获取图片请求管理器 RequestManager,用来启动和管理图片请求。


  public static RequestManager with(@NonNull FragmentActivity activity) {
return getRetriever(activity).get(activity);
}

context 可以传入 Application,Activity 和 Fragment,这关系着图片请求的生命周期。通常使用当前页面的 context,这样当我们打开一个页面加载图片,然后退出页面时,图片请求会跟随页面的销毁而被取消,而不是继续加载浪费资源。


当 context 是 Application 时,获得的 RequestManager 是一个全局单例,图片请求的生命周期会跟随整个 APP 。



如果 with 发生在子线程,不管 context 是谁,都返回应用级别的 RequestManager 单例。



  private RequestManager getApplicationManager(@NonNull Context context) {
// Either an application context or we're on a background thread.
if (applicationManager == null) {
synchronized (this) {
if (applicationManager == null) {
// Normally pause/resume is taken care of by the fragment we add to the fragment or
// activity. However, in this case since the manager attached to the application will not
// receive lifecycle events, we must force the manager to start resumed using
// ApplicationLifecycle.

// TODO(b/27524013): Factor out this Glide.get() call.
Glide glide = Glide.get(context.getApplicationContext());
applicationManager =
factory.build(
glide,
new ApplicationLifecycle(),
new EmptyRequestManagerTreeNode(),
context.getApplicationContext());
}
}
}

return applicationManager;
}

当 context 是 Activity 时,会创建一个无界面的 Fragment 添加到 Activity,用于感知 Activity 的生命周期,同时创建 RequestManager 给该 Fragment 持有。


  private RequestManager supportFragmentGet(
@NonNull Context context,
@NonNull FragmentManager fm,
@Nullable Fragment parentHint,
boolean isParentVisible) {
SupportRequestManagerFragment current = getSupportRequestManagerFragment(fm, parentHint);
RequestManager requestManager = current.getRequestManager();
if (requestManager == null) {
// TODO(b/27524013): Factor out this Glide.get() call.
Glide glide = Glide.get(context);
requestManager =
factory.build(
glide, current.getGlideLifecycle(), current.getRequestManagerTreeNode(), context);
// This is a bit of hack, we're going to start the RequestManager, but not the
// corresponding Lifecycle. It's safe to start the RequestManager, but starting the
// Lifecycle might trigger memory leaks. See b/154405040
if (isParentVisible) {
requestManager.onStart();
}
current.setRequestManager(requestManager);
}
return requestManager;
}

load


load 方法会得到一个图片请求构建器 RequestBuilder,用来创建图片请求。


  public RequestBuilder<Drawable> load(@Nullable String string) {
return asDrawable().load(string);
}

into


首先是根据 ImageView 的 ScaleType,来配置参数.


  public ViewTarget<ImageView, TranscodeType> into(@NonNull ImageView view) {
Util.assertMainThread();
Preconditions.checkNotNull(view);

BaseRequestOptions<?> requestOptions = this;
if (!requestOptions.isTransformationSet()
&& requestOptions.isTransformationAllowed()
&& view.getScaleType() != null) {
// Clone in this method so that if we use this RequestBuilder to load into a View and then
// into a different target, we don't retain the transformation applied based on the previous
// View's scale type.
switch (view.getScaleType()) {
case CENTER_CROP:
requestOptions = requestOptions.clone().optionalCenterCrop();
break;
case CENTER_INSIDE:
requestOptions = requestOptions.clone().optionalCenterInside();
break;
case FIT_CENTER:
case FIT_START:
case FIT_END:
requestOptions = requestOptions.clone().optionalFitCenter();
break;
case FIT_XY:
requestOptions = requestOptions.clone().optionalCenterInside();
break;
case CENTER:
case MATRIX:
default:
// Do nothing.
}
}

return into(
glideContext.buildImageViewTarget(view, transcodeClass),
/*targetListener=*/ null,
requestOptions,
Executors.mainThreadExecutor());
}

继续跟进 into,会创建图片请求,获取 Target 载体已有的请求,对比两个请求,如果等效,启动异步请求,然后,图片载体绑定图片请求,也就是 ImageView setTag 为 request 。


  private <Y extends Target<TranscodeType>> Y into(
@NonNull Y target,
@Nullable RequestListener<TranscodeType> targetListener,
BaseRequestOptions<?> options,
Executor callbackExecutor) {
Preconditions.checkNotNull(target);
if (!isModelSet) {
throw new IllegalArgumentException("You must call #load() before calling #into()");
}

Request request = buildRequest(target, targetListener, options, callbackExecutor);

Request previous = target.getRequest();
if (request.isEquivalentTo(previous)
&& !isSkipMemoryCacheWithCompletePreviousRequest(options, previous)) {
// If the request is completed, beginning again will ensure the result is re-delivered,
// triggering RequestListeners and Targets. If the request is failed, beginning again will
// restart the request, giving it another chance to complete. If the request is already
// running, we can let it continue running without interruption.
if (!Preconditions.checkNotNull(previous).isRunning()) {
// Use the previous request rather than the new one to allow for optimizations like skipping
// setting placeholders, tracking and un-tracking Targets, and obtaining View dimensions
// that are done in the individual Request.
previous.begin();
}
return target;
}

requestManager.clear(target);
target.setRequest(request);
requestManager.track(target, request);

return target;
}

继续跟进异步请求 requestManager.track(target, request)


  synchronized void track(@NonNull Target<?> target, @NonNull Request request) {
targetTracker.track(target);
requestTracker.runRequest(request);
}

  public void runRequest(@NonNull Request request) {
requests.add(request);
if (!isPaused) {
request.begin();//开启图片请求
} else {
request.clear();
if (Log.isLoggable(TAG, Log.VERBOSE)) {
Log.v(TAG, "Paused, delaying request");
}
pendingRequests.add(request);//如果是暂停状态,就把请求存起来。
}
}

到这里就启动了图片请求了,我们继续跟进 request.begin()


  public void begin() {
synchronized (requestLock) {
//......
if (Util.isValidDimensions(overrideWidth, overrideHeight)) {
//如果有尺寸,开始加载
onSizeReady(overrideWidth, overrideHeight);
} else {
//如果无尺寸就先去获取
target.getSize(this);
}
//......
}
}

然后继续瞧瞧 onSizeReady


  public void onSizeReady(int width, int height) {
stateVerifier.throwIfRecycled();
synchronized (requestLock) {
//......
loadStatus =
engine.load(
glideContext,
model,
requestOptions.getSignature(),
this.width,
this.height,
requestOptions.getResourceClass(),
transcodeClass,
priority,
requestOptions.getDiskCacheStrategy(),
requestOptions.getTransformations(),
requestOptions.isTransformationRequired(),
requestOptions.isScaleOnlyOrNoTransform(),
requestOptions.getOptions(),
requestOptions.isMemoryCacheable(),
requestOptions.getUseUnlimitedSourceGeneratorsPool(),
requestOptions.getUseAnimationPool(),
requestOptions.getOnlyRetrieveFromCache(),
this,
callbackExecutor);

//......
}
}

跟进 engine.load


  public <R> LoadStatus load(
GlideContext glideContext,
Object model,
Key signature,
int width,
int height,
Class<?> resourceClass,
Class<R> transcodeClass,
Priority priority,
DiskCacheStrategy diskCacheStrategy,
Map<Class<?>, Transformation<?>> transformations,
boolean isTransformationRequired,
boolean isScaleOnlyOrNoTransform,
Options options,
boolean isMemoryCacheable,
boolean useUnlimitedSourceExecutorPool,
boolean useAnimationPool,
boolean onlyRetrieveFromCache,
ResourceCallback cb,
Executor callbackExecutor) {
long startTime = VERBOSE_IS_LOGGABLE ? LogTime.getLogTime() : 0;

EngineKey key =
keyFactory.buildKey(
model,
signature,
width,
height,
transformations,
resourceClass,
transcodeClass,
options);

EngineResource<?> memoryResource;
synchronized (this) {
//从内存加载
memoryResource = loadFromMemory(key, isMemoryCacheable, startTime);
if (memoryResource == null) { //如果内存里没有
return waitForExistingOrStartNewJob(
glideContext,
model,
signature,
width,
height,
resourceClass,
transcodeClass,
priority,
diskCacheStrategy,
transformations,
isTransformationRequired,
isScaleOnlyOrNoTransform,
options,
isMemoryCacheable,
useUnlimitedSourceExecutorPool,
useAnimationPool,
onlyRetrieveFromCache,
cb,
callbackExecutor,
key,
startTime);
}
}
cb.onResourceReady(
memoryResource, DataSource.MEMORY_CACHE, /* isLoadedFromAlternateCacheKey= */ false);
return null;
}

  private <R> LoadStatus waitForExistingOrStartNewJob(
GlideContext glideContext,
Object model,
Key signature,
int width,
int height,
Class<?> resourceClass,
Class<R> transcodeClass,
Priority priority,
DiskCacheStrategy diskCacheStrategy,
Map<Class<?>, Transformation<?>> transformations,
boolean isTransformationRequired,
boolean isScaleOnlyOrNoTransform,
Options options,
boolean isMemoryCacheable,
boolean useUnlimitedSourceExecutorPool,
boolean useAnimationPool,
boolean onlyRetrieveFromCache,
ResourceCallback cb,
Executor callbackExecutor,
EngineKey key,
long startTime) {

EngineJob<?> current = jobs.get(key, onlyRetrieveFromCache);
if (current != null) {
current.addCallback(cb, callbackExecutor);
if (VERBOSE_IS_LOGGABLE) {
logWithTimeAndKey("Added to existing load", startTime, key);
}
return new LoadStatus(cb, current);
}

EngineJob<R> engineJob =
engineJobFactory.build(
key,
isMemoryCacheable,
useUnlimitedSourceExecutorPool,
useAnimationPool,
onlyRetrieveFromCache);

DecodeJob<R> decodeJob =
decodeJobFactory.build(
glideContext,
model,
key,
signature,
width,
height,
resourceClass,
transcodeClass,
priority,
diskCacheStrategy,
transformations,
isTransformationRequired,
isScaleOnlyOrNoTransform,
onlyRetrieveFromCache,
options,
engineJob);

jobs.put(key, engineJob);

engineJob.addCallback(cb, callbackExecutor);
engineJob.start(decodeJob);

if (VERBOSE_IS_LOGGABLE) {
logWithTimeAndKey("Started new load", startTime, key);
}
return new LoadStatus(cb, engineJob);
}

DecodeJob 是一个 Runnable,它通过一系列的调用,会来到 HttpUrlFetcher 的 loadData 方法。


  public void loadData(
@NonNull Priority priority, @NonNull DataCallback<? super InputStream> callback) {
long startTime = LogTime.getLogTime();
try {
//获取输入流,此处使用的是 HttpURLConnection
InputStream result = loadDataWithRedirects(glideUrl.toURL(), 0, null, glideUrl.getHeaders());
//回调出去
callback.onDataReady(result);
} catch (IOException e) {
if (Log.isLoggable(TAG, Log.DEBUG)) {
Log.d(TAG, "Failed to load data for url", e);
}
callback.onLoadFailed(e);
} finally {
if (Log.isLoggable(TAG, Log.VERBOSE)) {
Log.v(TAG, "Finished http url fetcher fetch in " + LogTime.getElapsedMillis(startTime));
}
}
}

至此,网络请求结束,最后把图片设置上去就行了,在 SingleRequest 的 onResourceReady 方法,它会把结果回调给 Target 载体。


 target.onResourceReady(result, animation);

继续跟进它,最终会执行 setResource,把图片设置上去。


  protected void setResource(@Nullable Drawable resource) {
view.setImageDrawable(resource);
}

总结


with 根据传入的 context 获取图片请求管理器 RequestManager,当传入的 context 是 Application 时,图片请求的生命周期会跟随应用,当传入的是 Activity 时,会创建一个无界面的空 Fragment 添加到 Activity,用来感知 Activity 的生命周期。load 会得到了一个图片请求构建器 RequestBuilder,用来创建图片请求。into 开启加载,先会根据 ImageView 的 ScaleType 来配置参数,创建图片请求,图片载体绑定图片请求,然后开启图片请求,先从内存中加载,如果内存里没有,会创建一个 Runnable,通过一系列的调用,使用 HttpURLConnection 获取网络输入流,把结果回调出去,最后把回调结果设置上去就行了。


缓存


Glide 三级缓存原理:读取一张图片时,顺序是: 弱引用缓存,LruCache,磁盘缓存。



用 Glide 加载某张图片时,先去弱引用缓存中寻找图片,如果有则直接取出来使用,如果没有,则去 LruCache 中寻找,如果 LruCache 中有,则中取出使用,并将它放入弱引用缓存中,如果没有,则从磁盘缓存或网络中加载图片。



  private EngineResource<?> loadFromMemory(
EngineKey key, boolean isMemoryCacheable, long startTime) {
if (!isMemoryCacheable) {
return null;
}

EngineResource<?> active = loadFromActiveResources(key); //从弱引用获取图片
if (active != null) {
if (VERBOSE_IS_LOGGABLE) {
logWithTimeAndKey("Loaded resource from active resources", startTime, key);
}
return active;
}

EngineResource<?> cached = loadFromCache(key); //从 LruCache 获取缓存图片
if (cached != null) {
if (VERBOSE_IS_LOGGABLE) {
logWithTimeAndKey("Loaded resource from cache", startTime, key);
}
return cached;
}

return null;
}

不过,这会产生一个问题:Glide 加载图片时,URL 不变但是图片变了的这种情况,还是用以前的旧图片。因为 Glide 加载图片会将图片缓存到本地,如果 URL 不变则直接读取缓存不会再从网络上加载。


解决方案:



  1. 清除缓存

  2. 让后台每次都更改图片的名字

  3. 图片地址选用 ”url?key="+随机数这种格式


LruCache


LruCache 就是维护一个缓存对象列表,其中对象列表的排列方式是按照访问顺序实现的,即一直没访问的对象,将放在队尾,最先被淘汰,而最近访问的对象将放在队头,最后被淘汰。其内部维护了一个集合 LinkedHashMap,LinkHashMap 继承 HashMap,在 HashMap 的基础上,新增了双向链表结构,每次访问数据的时候,会更新被访问数据的链表指针,该 LinkedHashMap 是以访问顺序排序的,当调用 put 方法时,就会在集合中添加元素,判断缓存是否已满,如果满了就删除队尾元素,即近期最少访问的元素,当调用 LinkedHashMap 的 get 方法时,就会获得对应的集合元素,同时更新该元素到队头。



Glide 会为每个不同尺寸的 Imageview 缓存一张图片,也就是说不管这张图片有没有加载过,只要 Imageview 的尺寸不一样,Glide 就会重新加载一次,这时候,它会在加载 Imageview 之前从网络上重新下载,然后再缓存。举个例子,如果一个页面的 Imageview 是 100 * 100,另一个页面的 Imageview 是 800 * 800,它俩展示同一张图片的话,Glide 会下载两次图片,并且缓存两张图片,因为 Glide 缓存 Key 的生成条件之一就是控件的长宽。



由上可知,在图片加载中关闭页面,此页面也不会造成内存泄漏,因为 Glide 在加载资源的时候,如果是在 Activity 或 Fragment 这类有生命周期的组件上进行的话,会创建一个无界面的 Fragment 加入到 FragmentManager 之中,感知生命周期,当 Activity 或 Fragment 进入不可见或销毁的时候,Glide 会停止加载资源。但是,如果是在非生命周期的组件上进行时,一般会采用 Application 的生命周期贯穿整个应用,此时只有在应用程序关闭的时候才会停止加载。


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

Android的线程和线程池

从用途上来说Android的线程主要分为主线程和子线程两类,主线程主要处理和界面相关的工作,子线程主要处理耗时操作。除Thread之外,Android中还有其他扮演线程的角色如AsyncTask、IntentService、HandleThread,其中Asy...
继续阅读 »

从用途上来说Android的线程主要分为主线程和子线程两类,主线程主要处理和界面相关的工作,子线程主要处理耗时操作。除Thread之外,Android中还有其他扮演线程的角色如AsyncTask、IntentService、HandleThread,其中AsyncTask的底层用到了线程池,IntentService和HandleThread的底层直接使用了线程。


AsyncTask内部封装了线程池和Handler主要是为了方便开发者在在线程中更新UI;HandlerThread是一个具有消息循环的线程,它的内部可以使用Handler;IntentService是一个服务,系统对其进行了封装使其可以更方便的执行后台任务,IntentService内部采用HandleThread来执行任务,当任务执行完毕后IntentService会自动退出。IntentService是一个服务但是它不容易被系统杀死因此它可以尽量的保证任务的执行。


1.主线程和子线程


主线程是指进程所拥有的的线程,在Java中默认情况下一个进程只能有一个线程,这个线程就是主线程。主线程主要处理界面交互的相关逻辑,因为界面随时都有可能更新因此在主线程不能做耗时操作,否则界面就会出现卡顿的现象。主线程之外的线程都是子线程,也叫做工作线程。


Android沿用了Java的线程模型,也有主线程和子线程之分,主线程主要工作是运行四大组件及处理他们和用户的交互,子线程的主要工作就是处理耗时任务,例如网络请求,I/O操作等。Android3.0开始系统要求网络访问必须在子线程中进行否则就会报错,NetWorkOnMainThreadException


2.Android中的线程形态


2.1 AsyncTask


AsyncTask是一个轻量级的异步任务类,它可以在线程池中执行异步任务然后把执行进度和执行结果传递给主线程并在主线程更新UI。从实现上来说AsyncTask封装了Thread和Handler,通过AsyncTask可以很方便的执行后台任务以及主线程中访问UI,但是AsyncTask不适合处理耗时任务,耗时任务还是要交给线程池执行。


AsyncTask的四个核心类如下:





    • onPreExecute():主要用于做一些准备工作,在主线程中执行异步任务执行之前

    • doInBackground(Params ... params):在线程池执行,此方法用于执行异步任务,params表示输入的参数,在此方法中可以通过publishProgress方法来更新任务进度,publishProgress会调用onProgressUpdate

    • onProgressUpdate(Progress .. value):在主线程执行,当任务执行进度发生改变时会调用这个方法

    • onPostExecute(Result result):在主线程执行,异步任务之后执行这个方法,result参数是返回值,即doInBackground的返回值。




2.2 AsyncTask的工作原理


2.3 HandleThread


HandleThread继承自Thread,它是一种可以使用Handler的Thread,它的实现在run方法中调用Looper.prepare()来创建消息队列然后通过Looper.loop()来开启消息循环,这样在实际使用中就可以在HandleThread中创建Handler了。


@Override
public void run() {
mTid = Process.myTid();
Looper.prepare();
synchronized (this) {
mLooper = Looper.myLooper();
notifyAll();
}
Process.setThreadPriority(mPriority);
onLooperPrepared();
Looper.loop();
mTid = -1;
}

HandleThread和Thread的区别是什么?





    • Thread的run方法中主要是用来执行一个耗时任务;

    • HandleThread在内部创建了一个消息队列需要通过Handler的消息方式来通知HandleThread执行一个具体的任务,HandlerThread的run方法是一个无限循环因此在不使用是调用quit或者quitSafely方法终止线程的执行。HandleTread的具体使用场景是IntentService。




2.4 IntentService


IntentService继承自Service并且是一个抽象的类因此使用它时就必须创建它的子类,IntentService可用于执行后台耗时的任务,当任务执行完毕后就会自动停止。IntentService是一个服务因此它的优先级要比线程高并且不容易被系统杀死,因此可以利用这个特点执行一些高优先级的后台任务,它的实现主要是HandlerThread和Handler,这点可以从onCreate方法中了解。


//IntentService#onCreate
@Override
public void onCreate() {
// TODO: It would be nice to have an option to hold a partial wakelock
// during processing, and to have a static startService(Context, Intent)
// method that would launch the service & hand off a wakelock.

super.onCreate();
HandlerThread thread = new HandlerThread("IntentService[" + mName + "]");
thread.start();

mServiceLooper = thread.getLooper();
mServiceHandler = new ServiceHandler(mServiceLooper);
}

当IntentService第一次被启动时回调用onCreate方法,在onCreate方法中会创建HandlerThread,然后使用它的Looper创建一个Handler对象ServiceHandler,这样通过mServiceHandler把消息发送到HandlerThread中执行。每次启动IntentService都会调用onStartCommand,IntentService在onStartCommand中会处理每个后台任务的Intent。


//IntentService#onStartCommand
@Override
public int onStartCommand(@Nullable Intent intent, int flags, int startId) {
onStart(intent, startId);
return mRedelivery ? START_REDELIVER_INTENT : START_NOT_STICKY;
}

//IntentService#onStart
@Override
public void onStart(@Nullable Intent intent, int startId) {
Message msg = mServiceHandler.obtainMessage();
msg.arg1 = startId;
msg.obj = intent;
mServiceHandler.sendMessage(msg);
}

private final class ServiceHandler extends Handler {
public ServiceHandler(Looper looper) {
super(looper);
}

@Override
public void handleMessage(Message msg) {
onHandleIntent((Intent)msg.obj);
stopSelf(msg.arg1);
}
}

onStartCommand是如何处理外界的Intent的?


在onStartCommand方法中进入了onStart方法,在这个方法中IntentService通过mserviceHandler发送了一条消息,然后这个消息会在HandlerThread中被处理。mServiceHandler接收到消息后会把intent传递给onHandlerIntent(),这个intent跟启动IntentService时的startService中的intent是一样的,因此可以通过这个intent解析出启动IntentService传递的参数是什么然后通过这些参数就可以区分具体的后台任务,这样onHandleIntent就可以对不同的后台任务做处理了。当onHandleIntent方法执行结束后IntentService就会通过stopSelf(int startId)方法来尝试停止服务,这里不用stopSelf()的原因是因为这个方法被调用之后会立即停止服务但是这个时候可能还有其他消息未处理完毕,而采用stopSelf(int startId)方法则会等待所有消息都处理完毕后才会终止服务。调用stopSelf(int startId)终止服务时会根据startId判断最近启动的服务的startId是否相等,相等则立即终止服务否则不终止服务。


每执行一个后台任务就会启动一次intentService,而IntentService内部则通过消息的方式向HandlerThread请求执行任务,Handler中的Looper是顺序处理消息的,这就意味着IntentService也是顺序执行后台任务的,当有多个后台任务同时存在时这些后台任务会按照外界发起的顺序排队执行。


3.Android中的线程池


线程池的优点:





    • 线程池中的线程可重复使用,避免因为线程的创建和销毁带来的性能开销;

    • 能有效控制线程池中的最大并发数避免大量的线程之间因互相抢占系统资源导致的阻塞现象;

    • 能够对线程进行简单的管理并提供定时执行以及指定间隔循环执行等功能。




Android的线程池的概念来自于Java中的Executor,Executor是一个接口,真正的线程的实现是ThreadPoolExecutor,它提供了一些列参数来配置线程池,通过不同的参数可以创建不同的线程池。


3.1 ThreadPoolExecutor


public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler) {
if (corePoolSize < 0 ||
maximumPoolSize <= 0 ||
maximumPoolSize < corePoolSize ||
keepAliveTime < 0)
throw new IllegalArgumentException();
if (workQueue == null || threadFactory == null || handler == null)
throw new NullPointerException();
this.corePoolSize = corePoolSize;
this.maximumPoolSize = maximumPoolSize;
this.workQueue = workQueue;
this.keepAliveTime = unit.toNanos(keepAliveTime);
this.threadFactory = threadFactory;
this.handler = handler;
}

ThreadPoolExecutor是线程池的真正实现,它的构造函数中提供了一系列参数,先看一下每个参数的含义:





    • corePoolSize:线程池的核心线程数,默认情况下核心线程会在线程池中一直存活即使他们处于闲置状态。如果将ThreadPoolExecutor的allowCoreThreadTimeOut置为true那么闲置的核心线程在等待新的任务到来时会有超时策略,超时时间由keepAliveTime指定,当等待时间超过keepAliveTime设置的时间后核心线程就会被终止。

    • maxinumPoolSize:线程池中所能容纳的最大线程数,当活动线程达到做大数量时后续的新任务就会被阻塞。

    • keepAliveTime:非核心线程闲置时的超时时长,超过这个时长非核心线程就会被回收。

    • unit:用于指定超时时间的单位,常用单位有毫秒、秒、分钟等。

    • workQueue:线程池中的任务队列,通过线程池中的execute方法提交的Runnable对象会存储在这个参数中。

    • threadFactory:线程工厂,为线程池提供创建新的线程的功能。

    • handler:这个参数不常用,当线程池无法执行新的任务时,这可能是由于任务队列已满或者无法成功执行任务,这个时候ThreadPoolExecutor会调用handler的rejectExecution方法来通知调用者。




ThreadPoolExecutor执行任务时大致遵循如下规则:





    1. 如果线程池中的线程数量没有达到核心线程的数量那么会直接启动一个核心线程来执行任务;

    2. 如果线程池中线程数量已经达到或者超过核心线程的数量那么会把后续的任务插入到队列中等待执行;

    3. 如果任务队列也无法插入那么在基本可以确定是队列已满这时如果线程池中的线程数量没有达到最大值就会立刻创建非核心线程来执行任务;

    4. 如果非核心线程的创建已经达到或者超过线程池的最大数量那么就拒绝执行此任务,同时ThreadPoolExecutor会通过RejectedExecutionHandler抛出异常rejectedExecution。




3.2线程池的分类



  • FixedThreadPool:它是一种数量固定的线程池,当线程处于空闲状态时也不会被回收,除非线程池被关闭。当所有的线程都处于活动状态时,新任务都会处于等待状态,直到有空闲线程出来。FixedThreadPool只有核心线程并且不会被回收因此它可以更加快速的响应外界的请求。

  • CacheThreadPool:它是一种线程数量不定的线程池且只有非核心线程,线程的最大数量是Integer.MAX_VALUE,当线程池中的线程都处于活动状态时如果有新的任务进来就会创建一个新的线程去执行任务,同时它还有超时机制,当一个线程闲置超过60秒时就会被回收。

  • ScheduleThreadPool:它是一种拥有固定数量的核心线程和不固定数量的非核心线程的线程池,当非核心线程闲置时会立即被回收。

  • SignleThreadExecutor:它是一种只有一个核心线程的线程池,所有任务都在同一个线程中按顺序执行。

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

Android 位图(图片)加载引入的内存溢出问题分析

1.一些定义 在分析具体问题之前,我们先了解一些基本概念,这样可以帮助理解后面的原理部分。当然了,大家对于这部分定义已经了然于胸的,就可以跳过了。 什么是内存泄露? 我们知道Java GC管理的主要区域是堆,Java中几乎所有的实例对象数据实际是存储在堆上的(...
继续阅读 »

1.一些定义


在分析具体问题之前,我们先了解一些基本概念,这样可以帮助理解后面的原理部分。当然了,大家对于这部分定义已经了然于胸的,就可以跳过了。


什么是内存泄露?


我们知道Java GC管理的主要区域是堆,Java中几乎所有的实例对象数据实际是存储在堆上的(当然JDK1.8之后,针对于不会被外界调用的数据而言,JVM是放置于栈内的)。针对于某一程序而言,堆的大小是固定的,我们在代码中新建对象时,往往需要在堆中申请内存,那么当系统不能满足需求,于是产生溢出。或者可以这样理解堆上分配的内存没有被释放,从而失去对其控制。这样会造成程序能使用的内存越来越少,导致系统运行速度减慢,严重情况会使程序宕掉。


什么是位图?


位图使用我们称为像素的一格一格的小点来描述图像,计算机屏幕其实就是一张包含大量像素点的网格,在位图中,平时看到的图像将会由每一个网格中的像素点的位置和色彩值来决定,每一点的色彩是固定的,而每个像素点色彩值的种类,产生了不同的位图Config,常见的有:



ALPHA_8, 代表8位Alpha位图,每个像素占用1byte内存
RGB_565,代表8位RGB位图,每个像素占用2byte内存
ARGB_4444 (@deprecated),代表16位ARGB位图,每个像素占用2byte内存
ARGB_8888,代表32位ARGB位图,每个像素占用4byte内存



其实很好理解,我们知道RGB是指红蓝绿,不同的config代表,计算机中每种颜色用几位二进制位来表示,例如:RGB_565代表红5为、蓝6位、绿5为。


2.原理分析


2.1 原理分析一


由第一节的基础定义,我们知道不过JVM还是Android虚拟机,对于每个应用程序可用内存大小是有约束的,而针对于单个程序中Bitmap所占的内存大小也有约束(一般机器是8M、16M,大家可以通过查看build.prop文件去查看这个定义大小),一旦超过了这个大小,就会报OOM错误。
Android编程中,我们经常会使用ImageView 控件,加载图片,例如以下代码:


package com.itbird.BitmapOOM;

import android.graphics.BitmapFactory;
import android.os.Bundle;
import android.widget.ImageView;

import androidx.appcompat.app.AppCompatActivity;

import com.itbird.R;

public class ImageViewLoadBitmapTestActivity extends AppCompatActivity {

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.imageviewl_load_bitmap_test);
ImageView imageView = findViewById(R.id.imageview);
imageView.setImageResource(R.drawable.bigpic);
imageView.setBackgroundResource(R.drawable.bigpic);
imageView.setImageBitmap(BitmapFactory.decodeFile("path/big.jpg"));
imageView.setImageBitmap(BitmapFactory.decodeResource(getResources(), R.drawable.bigpic));
}
}

当图片很小时,一般不会有问题,当图片很大时,就会出现OOM错误,原因是直接调用decodeResource、setImageBitmap、setBackgroundResource时,实际上,这些函数在完成照片的decode之后,都是调用了java底层的createBitmap来完成的,需要消耗更多的内存。至于为什么会消耗那么多内存,如下面的源码分析:
android8.0之前Bitmap源码


public final class Bitmap implements Parcelable {
private static final String TAG = "Bitmap";
...
private byte[] mBuffer;
...
}

android8.0之后Bitmap源码


public final class Bitmap implements Parcelable {
...
// Convenience for JNI access
private final long mNativePtr;
...
}

对上上述两者,相信大家已经看出点什么了,android8.0之前,Bitmap在Java层保存了byte数组,而且细跟源码的话,您也会发现,8.0之前虽然调用了native函数,但是实际其实就是在native层创建Java层byte[],并将这个byte[]作为像素存储结构,之后再通过在native层构建Java Bitmap对象的方式,将生成的byte[]传递给Bitmap.java对象。(这里其实有一个小知识点,android6.0之前,源码里面很多这样的实现,通过C层来创建Java层对象)。
image.png


而android8.0之后,Bitmap在Java层保存的只是一个地址,,Bitmap像素内存的分配是在native层直接调用calloc,所以其像素分配的是在native heap上, 这也是为什么8.0之后的Bitmap消耗内存可以无限增长,直到耗尽系统内存,也不会提示Java OOM的原因。
image.png


2.2 原理分析二


看完上面的源码解读,大家一定想知道,那我如果在自己应用中的确有大图片的加载需求,那怎么办呢?调用哪个函数呢?
BitmapFactory.java中有一个Bitmap decodeStream(InputStream is)这个函数,我们可以查看源码,这个函数底层调用了native c函数
image.png
在底层进行了decode之后,转换为了bitmap对象,返回给Java层。


3 编程中如何避免图片加载的OOM错误


通过上面章节的知识探索,相信大家已经知道了加载图片时出现OOM错误的原因,其实真正的原因并未是网上很多文章说的,不要使用调用ImageView的某某函数、BitmapFactory的某某函数,真正的原因是,对于大图片,Java堆和Native堆无法申请到可用内存时,就会出现OOM错误,那么针对于不同的系统版本,Android存储、创建图片的方式又有所不同,带来了加载大图片时的OOM错误。
那么接下来,大家最关心的解决方案,有哪些?我们在日常编码中,应该如何编码,才能有效规避此类错误的出现,别急。


3.1 利用BitmapFactory.decodeStream加载InputStream图片字节流的方式显示图片


 /**
* 以最省内存的方式读取本地资源的图片
*/
public static Bitmap readBitMap(String path, BitmapFactory.Options opt, InputStream is) {
opt.inPreferredConfig = Bitmap.Config.RGB_565;
if (Build.VERSION.SDK_INT <=android.os.Build.VERSION_CODES.KITKAT ) {
opt.inPurgeable = true;
opt.inInputShareable = true;
}
opt.inSampleSize = 2;//二分之一缩放,可写1即100%显示
//获取资源图片
try {
is = new FileInputStream(path);
} catch (FileNotFoundException e) {
e.printStackTrace();
}
return BitmapFactory.decodeStream(is, null, opt);
}

大家可以看到上面的代码,实际上一方面针对Android 4.4之下的直接声明了opt属性,告诉系统可以回收,一方面直接进行了图片缩放。说到这里,大家会有疑问,为什么是android4.4以下加这两个属性,难道之后就不用了了。不要着急,我们看源码:
源码.png
可以看到源码上说明,此属性4.4之前有用,5.0之后即使设置了,底层也是忽略的。也许大家会问,难道5.0之后Bitmap的源码有什么大的改动吗?的确是,可以看一下以下源码。
8.0之后的Bitmap内存回收机制
NativeAllocationRegistry是Android 8.0引入的一种辅助自动回收native内存的一种机制,当Java对象因为GC被回收后,NativeAllocationRegistry可以辅助回收Java对象所申请的native内存,拿Bitmap为例,如下:


Bitmap(long nativeBitmap, int width, int height, int density,
boolean isMutable, boolean requestPremultiplied,
byte[] ninePatchChunk, NinePatch.InsetStruct ninePatchInsets) {
...
mNativePtr = nativeBitmap;
long nativeSize = NATIVE_ALLOCATION_SIZE + getAllocationByteCount();
<!--辅助回收native内存-->
NativeAllocationRegistry registry = new NativeAllocationRegistry(
Bitmap.class.getClassLoader(), nativeGetNativeFinalizer(), nativeSize);
registry.registerNativeAllocation(this, nativeBitmap);
if (ResourcesImpl.TRACE_FOR_DETAILED_PRELOAD) {
sPreloadTracingNumInstantiatedBitmaps++;
sPreloadTracingTotalBitmapsSize += nativeSize;
}
}

当然这个功能也要Java虚拟机的支持,有机会再分析。


**实际使用效果:**3M以内的图片加载没有问题,但是大家注意到一点,没我们代码中是固定缩放了一般,这时大家肯定有疑问,有没有可能,去动态根据图片的大小,决定缩放比例。


3.2 利用BitmapFactory.decodeStream通过按比例压缩方式显示图片


    /**
* 以计算的压缩比例加载大图片
*
* @param res
* @param resId
* @param reqWidth
* @param reqHeight
* @return
*/
public static Bitmap decodeCalSampledBitmapFromResource(Resources res, int resId, int reqWidth, int reqHeight) {
// 检查bitmap的大小
final BitmapFactory.Options options = new BitmapFactory.Options();
// 设置为true,BitmapFactory会解析图片的原始宽高信息,并不会加载图片
options.inJustDecodeBounds = true;
options.inPreferredConfig = Bitmap.Config.RGB_565;

BitmapFactory.decodeResource(res, resId, options);

// 计算采样率
options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight);

// 设置为false,加载bitmap
options.inJustDecodeBounds = false;

return BitmapFactory.decodeResource(res, resId, options);
}

/*********************************
* @function: 计算出合适的图片倍率
* @options: 图片bitmapFactory选项
* @reqWidth: 需要的图片宽
* @reqHeight: 需要的图片长
* @return: 成功返回倍率, 异常-1
********************************/
private static int calculateInSampleSize(BitmapFactory.Options options, int reqWidth,
int reqHeight) {
// 设置初始压缩率为1
int inSampleSize = 1;
try {
// 获取原图片长宽
int width = options.outWidth;
int height = options.outHeight;
// reqWidth/width,reqHeight/height两者中最大值作为压缩比
int w_size = width / reqWidth;
int h_size = height / reqHeight;
inSampleSize = w_size > h_size ? w_size : h_size; // 取w_size和h_size两者中最大值作为压缩比
Log.e("inSampleSize", String.valueOf(inSampleSize));
} catch (Exception e) {
return -1;
}
return inSampleSize;
}

大家可以看到,上面代码实际上使用了一个属性inJustDecodeBounds,当inJustDecodeBounds设为true时,不会加载图片仅获取图片尺寸信息,也就是说,我们先通过不加载实际图片,获取其尺寸,然后再按照一定算法(以需要的图片长宽与实际图片的长宽比例来计算)计算出压缩的比例,然后再进行图片加载。


**实际使用效果:**测试该方法可以显示出来很大的图片,只要你设定的长宽合理。


3,3 及时的回收和释放


直接上代码


 /**
* 回收bitmap
*/
private static void recycleBitmap(ImageView iv) {
if (iv != null && iv.getDrawable() != null) {
BitmapDrawable bitmapDrawable = (BitmapDrawable) iv.getDrawable();
iv.setImageDrawable(null);
if (bitmapDrawable != null) {
Bitmap bitmap = bitmapDrawable.getBitmap();
if (bitmap != null) {
bitmap.recycle();
}
}
}
}

/**
* 在Activity或Fragment的onDestory方法中进行回收(必须确保bitmap不在使用)
*/
public static void recycleBitmap(Bitmap bitmap) {
// 先判断是否已经回收
if (bitmap != null && !bitmap.isRecycled()) {
// 回收并且置为null
bitmap.recycle();
bitmap = null;
}
}

4.总结


4.1 OOM出现原因


对于大图片,直接调用decodeResource、setImageBitmap、setBackgroundResource时,这些函数在完成照片的decode之后,都是调用了java底层的createBitmap来完成的,需要消耗更多的内存。Java堆和Native堆无法申请到可用内存时,就会出现OOM错误,那么针对于不同的系统版本,Android存储、创建图片的方式又有所不同,带来了加载大图片时的OOM错误。


4.2 解决方案


1.针对于图片小而且频繁加载的,可以直接使用系统函数setImageXXX等
2针对于大图片,在进行ImageView setRes之前,需要先对图片进行处理
1)压缩
2)android4.4之前,需要设置opt,释放bitmap,android5.0之后即使设置,系统也会忽略
3)设置optConfig为565,降低每个像素点的色彩值
4)针对于频繁使用的图片,可以使用inBitmap属性
5)由于decodeStream直接读取的图片字节码,并不会根据各种机型做自动适配,所以需要在各个资源文件夹下放置相应的资源
6)及时回收


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

Handler就是一个简化的邮递系统么?

前置补充 关于本文的初衷不是讲很多细节,主要像聚焦在Handler的设计理念上,主要想讲述计算机系统中的很多事情在现实中其实有现成的例子可以参考理解,当然现实生活比程序肯定更复杂。 知行合一,想完全理解一个事物,肯定不能光靠看文章,还是要在实际的工作中...
继续阅读 »

前置补充




  • 关于本文的初衷不是讲很多细节,主要像聚焦在Handler的设计理念上,主要想讲述计算机系统中的很多事情在现实中其实有现成的例子可以参考理解,当然现实生活比程序肯定更复杂。




  • 知行合一,想完全理解一个事物,肯定不能光靠看文章,还是要在实际的工作中去使用。




  • 学习是渐进的,可能文中的一些知识点,笔者已经掌握,可能会一笔带过,大家有疑惑 ,建议或者文中的错误,多多提出意见和批评。




  • ThreadLocal推荐一本书 任玉刚老师的 Android开发艺术探索




  • 关于Java线程相关知识,推荐 杨冠宝/高海慧 老师的 码出高效:Java开发手册




正文


网上关于Handler的的文章已经有很多了,可能大家看了很多有的同学还是云里雾里,我写这篇文章的理念就是怎样将Handler讲述成我们平常经常使用的事物。


大家已经点进来了,就应该知道Handler是做什么用的,关于它的定义不在多言。




  • 我们用一个爱情故事来模仿这个通信的流程。




  • 1:MainThread(一个人见人爱的女生,我们就叫她main)。




  • 2:BThread (一个很倾慕main的男生,我们简称他为B)。




  • 3:剧情设定两个人无法直接通信(具体原因不赘述,大家可以百度一下ThreadLocal,本文不讲这个了)。




有了设定和人物,那么假如B想给main通信他需要怎么办呢,写信是一种方式。那我们就用写信来比喻Handler。那让我们来分析一下这个通信系统,首先来看Handler


本文采用6.0源代码


Handler系统


我们平常说的通过Handler进行线程间通信,通常是指的是通过整个Handler系统进行通信,Handler.java只是这个系统的一个入口.


Handler


分析一个东西,我们先从构造函数开始。


    public Handler() {
this(null, false);
}

public Handler(Callback callback) {
this(callback, false);
}

public Handler(Looper looper) {
this(looper, null, false);
}

public Handler(Looper looper, Callback callback) {
this(looper, callback, false);
}

public Handler(boolean async) {
this(null, async);
}

public Handler(Callback callback, boolean async) {
if (FIND_POTENTIAL_LEAKS) {
final Class<? extends Handler> klass = getClass();
if ((klass.isAnonymousClass() || klass.isMemberClass() || klass.isLocalClass()) &&
(klass.getModifiers() & Modifier.STATIC) == 0) {
Log.w(TAG, "The following Handler class should be static or leaks might occur: " +
klass.getCanonicalName());
}
}

mLooper = Looper.myLooper();
if (mLooper == null) {
throw new RuntimeException(
"Can't create handler inside thread that has not called Looper.prepare()");
}
mQueue = mLooper.mQueue;
mCallback = callback;
mAsynchronous = async;
}

public Handler(Looper looper, Callback callback, boolean async) {
mLooper = looper;
mQueue = looper.mQueue;
mCallback = callback;
mAsynchronous = async;
}

上面是Handler所有的构造函数,4个是没有实际的逻辑的,有实际的逻辑只有两个,我们就从这两个构造函数开始分析。


   public Handler(Callback callback, boolean async) {
if (FIND_POTENTIAL_LEAKS) {
final Class<? extends Handler> klass = getClass();
if ((klass.isAnonymousClass() || klass.isMemberClass() || klass.isLocalClass()) &&
(klass.getModifiers() & Modifier.STATIC) == 0) {
Log.w(TAG, "The following Handler class should be static or leaks might occur: " +
klass.getCanonicalName());
}
}

mLooper = Looper.myLooper();
if (mLooper == null) {
throw new RuntimeException(
"Can't create handler inside thread that has not called Looper.prepare()");
}
mQueue = mLooper.mQueue;
mCallback = callback;
mAsynchronous = async;
}

public Handler(Looper looper, Callback callback, boolean async) {
mLooper = looper;
mQueue = looper.mQueue;
mCallback = callback;
mAsynchronous = async;
}

两者的差别就是Looper是否是外部传入的,一个Looper使用的静态函数myLooper() 赋值的,我们暂时先放过这个静态函数一步一步来(放到Looper的环节中讲述)。不过我们看到这个mLooper如果为null就会抛出一个异常,可能很多同志都见到过这个异常Can't create handler inside thread that has not called Looper.prepare(),这个异常就是从这里来的。


分析以上的构造函数,我们发现在Handler整个系统中Looper是必须存在的一个事物。(有的同学会说,我可以在创建Handler的时候手动的传一个null进去,是的,这样的话会得到一个空指针异常)。


如果我们如开头所说,Handler来类比我们现实生活中的通信系统,我们通过它的构造函数得知这个通信系统有4个必须存在的参数,mLooper,mQueue,mCallback,mAsynchronous(mQueue包含在Looper中)。那我们再来一个一个的分析这4个参数,他们究竟在这个通信系统中扮演什么角色。首先先看Looper


Looper



  • mQueue包含在Looper中,放在一起看。


按照惯例,还是先看构造函数。


    private Looper(boolean quitAllowed) {
mQueue = new MessageQueue(quitAllowed);
mThread = Thread.currentThread();
}

是一个私有的构造函数,里面讲我们上文提到mQueue给赋值,还有就是将mThread变量赋值为当前所处的线程。Thread.currentThread()不理解请自行百度。


那我们看一下Looper对象既然外部无法通过new关键字直接创建,那么它通过什么方式创建的呢?


Looper源码中,函数返回类型为Looper的函数只有下面两个。 我们先分析getMainLooper()函数,函数中只是返回了一个sMainLooper


    public static @Nullable Looper myLooper() {
return sThreadLocal.get();
}

public static Looper getMainLooper() {
synchronized (Looper.class) {
return sMainLooper;
}
}

我们先看sMainLooper


    private static Looper sMainLooper;  // guarded by Looper.class

public static void prepareMainLooper() {
prepare(false);
synchronized (Looper.class) {
if (sMainLooper != null) {
throw new IllegalStateException("The main Looper has already been prepared.");
}
sMainLooper = myLooper();
}
}

大家发现饶了一圈,怎么有回到了myLooper()函数,那接下我们看myLooper()函数中的sThreadLocal是什么东西?


    static final ThreadLocal<Looper> sThreadLocal = new ThreadLocal<Looper>();

private static void prepare(boolean quitAllowed) {
if (sThreadLocal.get() != null) {
throw new RuntimeException("Only one Looper may be created per thread");
}
sThreadLocal.set(new Looper(quitAllowed));
}

我们发现sThreadLocal就是一个ThreadLocal,使用它来存储Looper对象。



  • (前文提过ThreadLocal,它是Java多线程中一个非常非常重要的概念,建议不懂这个的同志,先去看一下这个东西到底是什么?然后再回过头来看这篇文章)。


我们会发现创建Looper对象只能通过唯一入口prepare来创建它。创建Looper的时候,它顺手的将MessageQueue给创建了出来(在上文Looper的构造函数中)。



  • MessageQueue包含的任务是非常重要的,并且要写入一些c++代码来分析。我们暂且跳过,先得出一个结论之后,在来逆推MessageQueue到底做了什么。


mCallback && mAsynchronous


mCallback:可以从Handler中是可以为null,不传就默认为null,其实是比较容易理解的一个概念,回调函数,不多做解释了,非主线剧情。


mAsynchronous: 从名字来看就是是不是异步的意思,后面会解释一下这个异步的概念。


实际例子


我们上面将Handler想象成一个通信系统,设定了人物,也简单的分析了一下Handler,下面我们来看一个实际的写信流程。


public Thread MainThread = new Thread(new Runnable() {
@Override
public void run() {

}
});

public Thread BThread = new Thread(new Runnable() {
@Override
public void run() {

}
});

假如B想通过Handler通信系统给Main写信,那么第一步



  • 1: Main得在通信系统中创建Handler,这个时候Handler可以形容为一个地址。看如下代码:


public Handler mainHandler;

public Thread MainThread = new Thread(new Runnable() {
@Override
public void run() {
if (mainHandler == null) {
Looper.prepare();
mainHandler = new Handler(){
@Override
public void handleMessage(Message msg) {
super.handleMessage(msg);
Log.e(TAG, "handleMessage: "+ msg.obj);
}
};
Looper.loop();
}
}
});



  • 2 我们看在创建Handler之前,需要现在线程中使用 Looper.prepare() 创建一个Looper出来之后才能创建Handler(前文提到过原因)。那么Looper可以形容为什么呢,这个通信系统中的后台系统,我们接着往下看,看这个形容是否准确。




  • 3 :B拿到Main的Handler,就使用sendMessage()去给Main传递信息,sendMessage必须发送Message类型的消息,那么Message在通信系统中是什么角色呢,可以理解为信封和邮票,必须以规定好的方式去包装你写得信,这样才可以去发送。这个时候Handler扮演了一个投递入口的角色。




public Thread BThread = new Thread(new Runnable() {
@Override
public void run() {
if (mainHandler != null) {
Message message = Message.obtain();
message.obj= "I LOVE YOU";
mainHandler.sendMessage(message);
Log.e(TAG, "BThread sendMessage 发送了");

}
}
});


  • 4:从上面的例子代码和上文对Looper的分析中,我们没有看到Looper.loop()的作用,并且还有一个疑问,B只是投递了信息,谁帮忙传信的呢?我们看下是不是Looper.loop()。只展示关键代码,想看完整代码的同志请自行查看源码。


   public static void loop() {
final Looper me = myLooper();
if (me == null) {
throw new RuntimeException("No Looper; Looper.prepare() wasn't called on this thread.");
}
final MessageQueue queue = me.mQueue;

...

for (;;) {
Message msg = queue.next(); // might block
if (msg == null) {
// No message indicates that the message queue is quitting.
return;
}

// This must be in a local variable, in case a UI event sets the logger
Printer logging = me.mLogging;
if (logging != null) {
logging.println(">>>>> Dispatching to " + msg.target + " " +
msg.callback + ": " + msg.what);
}
//这里好像是发送了。
msg.target.dispatchMessage(msg);

...

}
}


  • 5: 我们在Looper.loop()中看到了一句msg.target.dispatchMessage(msg),这个从名字看上去很像一个传信的人,但是这个msg.target是个什么鬼东西啊,完全看不懂。从源码得知msg是一个Message类型的对象,那我们去看一下msg.target


public final class Message implements Parcelable {
...
/*package*/ Handler target;
...
}

target就是一个Handler啊,那它是在哪里赋值的呢?其实sendMessage最终会调用到enqueueMessage,具体的调用函数栈,就不贴出来了,有兴趣自行查看源码。


    private boolean enqueueMessage(MessageQueue queue, Message msg, long uptimeMillis) {
msg.target = this;
if (mAsynchronous) {
msg.setAsynchronous(true);
}
return queue.enqueueMessage(msg, uptimeMillis);
}

在这里我们看到target被赋值为调用者。也就是mainHandler.sendMessage(message);target就是mainHandler,看了下面的代码你更好理解


Message message = Message.obtain();
message.setTarget(mainHandler);
message.obj= "I LOVE YOU";
mainHandler.sendMessage(message);
Log.e(TAG, "BThread sendMessage 发送了");

Message每个信封都支持我们手动写地址的setTarget,但是很多人觉得麻烦,那么通信系统呢,就默认将拿到的地址作为你要传送的地址。也就支持了我们不需要必须调用setTarget()。(有的同学可能比较调皮,我用mainHandler,去发送,target写其他可以么,是可以的,但是系统会帮我们修正,大家可以尝试一下)


MessageQueue,隐藏在内部的工作者


看到这这里,如果不接着深入探究,基本上一个完整的链条已经存在,但是还是有很多疑点,之前提到的MessageQueue还没说到,整个链条就完整了么?其实MessageQueue已经出镜了。loop()函数虽然起了一个死循环,但是每一封信都是从MessageQueue.next()中取出来的。


   public static void loop() {
final Looper me = myLooper();
if (me == null) {
throw new RuntimeException("No Looper; Looper.prepare() wasn't called on this thread.");
}
final MessageQueue queue = me.mQueue;

...

for (;;) {
Message msg = queue.next(); // might block
if (msg == null) {
// No message indicates that the message queue is quitting.
return;
}

// This must be in a local variable, in case a UI event sets the logger
Printer logging = me.mLogging;
if (logging != null) {
logging.println(">>>>> Dispatching to " + msg.target + " " +
msg.callback + ": " + msg.what);
}
//这里好像是发送了。
msg.target.dispatchMessage(msg);

...

}
}

国际惯例,先看构造函数。


// True if the message queue can be quit.
private final boolean mQuitAllowed;
private long mPtr; // used by native code
MessageQueue(boolean quitAllowed) {
mQuitAllowed = quitAllowed;
mPtr = nativeInit();
}

构造函数,从名字来看mQuitAllowed是否允许关闭。退出机制比较复杂不想看的可以跳过,包含的知识点有点多。




  • 1:大家都知道Java的线程机制,1.5之前提供了stop函数,后面移除,移除的原因不赘述,现在线程退出机制就是代码执行完之后就会自动销毁。




  • 2:我们回头看下我们的例子代码,在调用Looper.loop()函数之后会启动一个死循环不停的取消息,一直到消息为null,才会returen。我们知道了退出的条件,我们看下系统怎么创造这个条件的。




public Thread MainThread = new Thread(new Runnable() {
@Override
public void run() {
Looper.prepare();
if (mainHandler == null) {
mainHandler = new Handler(){
@Override
public void handleMessage(Message msg) {
super.handleMessage(msg);
Log.e(TAG, "handleMessage: "+ msg.obj);
}
};
Looper.loop();
}
}
});

Looper.java

for (;;) {
Message msg = queue.next(); // might block
if (msg == null) {
// No message indicates that the message queue is quitting.
return;
}
....
}


  • 3:调用 Loooper.quit(),来主动退出这个这个死循环,下面就讲述一下这个退出死循环的流程


    public void quit() {
mQueue.quit(false);
}

void quit(boolean safe) {
//判断当前是否允许退出,不允许就抛出异常
if (!mQuitAllowed) {
throw new IllegalStateException("Main thread not allowed to quit.");
}

synchronized (this) {
//锁+ 标志位 ,防止重复执行,记住这标志位。 后面还要用到
if (mQuitting) {
return;
}
mQuitting = true;

if (safe) {
removeAllFutureMessagesLocked();
} else {
//退出是这个,清除所有的消息
removeAllMessagesLocked();
}

// We can assume mPtr != 0 because mQuitting was previously false.
//native 函数。 从名字上看是唤醒。
nativeWake(mPtr);
}
}

大家看到了熟悉的一个主动异常"Main thread not allowed to quit.",简单理解主线程不可以退出。主线程创建Looper的流程在本文不赘述,我们接着看调用MessageQueuequit函数的地方,



  • 4: 从上面的代码我们就看到了清除了缓存队列中的所有未发送的消息,然后唤醒?唤醒什么呢?不是退出么? 带着这三个疑问,走向更深的源码。


android_os_MessageQueue.cpp

{ "nativeWake", "(J)V", (void*)android_os_MessageQueue_nativeWake },

static void android_os_MessageQueue_nativeWake(JNIEnv* env, jclass clazz, jlong ptr) {
NativeMessageQueue* nativeMessageQueue = reinterpret_cast<NativeMessageQueue*>(ptr);
nativeMessageQueue->wake();
}

Looper.cpp

void Looper::wake() {
#if DEBUG_POLL_AND_WAKE
ALOGD("%p ~ wake", this);
#endif
uint64_t inc = 1;
ssize_t nWrite = TEMP_FAILURE_RETRY(write(mWakeEventFd, &inc, sizeof(uint64_t)));
if (nWrite != sizeof(uint64_t)) {
if (errno != EAGAIN) {
ALOGW("Could not write wake signal, errno=%d", errno);
}
}
}

这是一个到达Native的一个简单的逻辑顺序,Looper.cpp是对epoll的一个封装,我简单的描述一下这个过程


就是有(三个人都活着(线程),要喝水(用CPU),那么三个人要把水给平分(平分Cpu时间片)。


两个人没事干也不累,但是不能die,(还有一些专属任务,需要等待通知),那不干活就不应该喝水,要不就是资源浪费啊,怎么办?


epoll就是干这个的pollOnce就是通知线程进入休眠状态,等到有消息来的时候就会通知对应的人(对应的线程)去干活了,怎么通知呢? 就是通过wake函数。贴一下pollOnce的相关的关键代码,有兴趣的看一下


int Looper::pollOnce(int timeoutMillis, int* outFd, int* outEvents, void** outData) {
int result = 0;
for (;;) {
...
//这里
result = pollInner(timeoutMillis);
}
}

int Looper::pollInner(int timeoutMillis) {
#if DEBUG_POLL_AND_WAKE
ALOGD("%p ~ pollOnce - waiting: timeoutMillis=%d", this, timeoutMillis);
#endif

...
//这里 epoll出现 ,如果想把这个探究明白 建议读这个类的源码,是Android对epoll的封装了
int eventCount = epoll_wait(mEpollFd, eventItems, EPOLL_MAX_EVENTS, timeoutMillis);

// No longer idling.
mPolling = false;

// Acquire lock.
mLock.lock();

// Rebuild epoll set if needed.
if (mEpollRebuildRequired) {
mEpollRebuildRequired = false;
rebuildEpollLocked();
goto Done;
}

// Check for poll error.
if (eventCount < 0) {
if (errno == EINTR) {
goto Done;
}
ALOGW("Poll failed with an unexpected error, errno=%d", errno);
result = POLL_ERROR;
goto Done;
}

// Check for poll timeout.
if (eventCount == 0) {
#if DEBUG_POLL_AND_WAKE
ALOGD("%p ~ pollOnce - timeout", this);
#endif
result = POLL_TIMEOUT;
goto Done;
}
...
}

epoll.h

extern int epoll_wait (int __epfd, struct epoll_event *__events,
int __maxevents, int __timeout);


  • 5 :看到这里其实大部分人是很迷惑的,建议迷惑的同志单独深入探究,单独理解上层的同学就看到喝水的故事就好了。那么回到上文说的唤醒,我们知道唤醒之后的线程从休眠的地方开始执行,我们看看陷入休眠的时候在哪里呢?


    Message next() {
...
for (;;) {
if (nextPollTimeoutMillis != 0) {
Binder.flushPendingCommands();
}
//这里休眠的.
nativePollOnce(ptr, nextPollTimeoutMillis);
//唤醒之后从这里开始执行.
synchronized (this) {
...
//还记得这个标志么?在quit函数中赋值为ture的
if (mQuitting) {
dispose();
//这里reture 一个 null
return null;
}

...
}
}
}

(主线程)不会卡死的原因即 Looper退出总结,线程退出机制.


上面描述了退出的一个过程。在简单总结一下




  • 1: Looper.loop启动死循环,然而实际干的活是从MessageQueue.next()中一直取Message,如果没有Message MessageQueue 会调用nativePollOnce 让当前线程休眠(这就是为啥死循环不会卡死的原因,很浅显啊,只是简单论述,epoll 可以写好几篇文章了)。




  • 2: 发起退出死循环,终结线程,调用Looper.quit(),然后还是要调用MessageQueue.quit().




  • 3: MessageQueue.quit(),先判断当前是否允许退出,允许了将退出的标志位mQuitting设置为true,然后调用removeAllMessagesLocked()清除现在队列中的所有消息。然后唤醒线程




  • 4: 线程被唤醒了就回到第一步,当前没有消息你却唤醒线程,且退出标志位mQuitting设置为true了,MessageQueue.next()就会返回一个null。




  • 5: Looper.loop的死循环如果取到了的Messagenull,就会returen跳出死循环了。这样一个线程所有的代码执行完成之后,就会自然死亡了,这也是我们AndroidMain ThreadMessageQueue 不允许退出的原因。




大总结


整个大的线程通信系统




  • Handler就是一个门面,可以理解为地址。




  • Message像一个传递员,规定了信的格式和最后一公里的取信和传信。




  • Looper是一个后台系统,注册什么,所有的入口发起全在这里,让大家以为它把所有的活都干了。




  • MessageQueue位居后台的一个分拣员,和通知传递员去送信,这个核心就是它,就是所有人都看不到。


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

你真的了解 RSA 加密算法吗?

记得那是我毕业🎓后的第一个秋天,申请了域名,搭建了论坛。可惜好景不长,没多久进入论坛后就出现各种乱七八糟的广告,而这些广告压根都不是我加的。 这是怎么回事?后来我才知道,原来我的论坛没有加 HTTPS 也就是没有 SSL 证书。那这和数学中的素数...
继续阅读 »

记得那是我毕业🎓后的第一个秋天,申请了域名,搭建了论坛。可惜好景不长,没多久进入论坛后就出现各种乱七八糟的广告,而这些广告压根都不是我加的。





这是怎么回事?后来我才知道,原来我的论坛没有加 HTTPS 也就是没有 SSL 证书。那这和数学中的素数有啥关系呢?这是因为每一个 SSL 的生成都用到了 RSA 非对称加密,而 RSA 的加解密就是使用了两个互为质数的大素数生成公钥和私钥的。


这就是我们今天要分享的,关于素数在 RSA 算法中的应用。


一、什么是素数


素数(或质数)指的是大于1的且不能通过两个较小的自然数乘积得来的自然数。而大于1的自然数如果不是素数,则称之为合数。例如:7是素数,因为它的乘积只能写成 1 * 7 或者 7 * 1 这样。而像自然数 8 可以写成 2 * 4,因为它是两个较小数字的乘积。


通常在 Java 程序中,我们可以使用下面的代码判断一个数字是否为素数;


boolean isPrime = number > 0;
// 计算number的平方根为k,可以减少一半的计算量
int k = (int) Math.sqrt(number);
for (int i = 2; i <= k; i++) {
if (number % i == 0) {
isPrime = false;
break;
}
}
return isPrime;

二、对称加密和非对称加密


假如 Alice 时而需要给北漂搬砖的 Bob 发一些信息,为了安全起见两个人相互协商了一个加密的方式。比如 Alice 发送了一个银行卡密码 142857 给 Bob,Alice 会按照与 Bob 的协商方式,把 142857 * 2 = 285714 的结果传递给 Bob,之后 Bob 再通过把信息除以2拿到结果。


但一来二去,Alice 发的密码、生日、衣服尺寸、鞋子大小,都是乘以2的规律被别人发现。这下这个加密方式就不安全了。而如果每次都给不同的信息维护不同的秘钥又十分麻烦,且这样的秘钥为了安全也得线下沟通,人力成本又非常高。


所以有没有另外一种方式,使用不同的秘钥对信息的加密和解密。当 Bob 想从 Alice 那获取信息,那么 Bob 就给 Alice 一个公钥,让她使用公钥对信息进行加密,而加密后的信息只有 Bob 手里有私钥才能解开。那么这样的信息传递就变得非常安全了。如图所示。















对称加密非对称加密

三、算法公式推导





如果 Alice 希望更安全的给 Bob 发送的信息,那么就需要保证经过公钥加密的信息不那么容易被反推出来。所以这里的信息加密,会需用到求模运算。像计算机中的散列算法,伪随机数都是求模运算的典型应用。


例如;5^3 mod 7 = 6 —— 5的3次幂模7余6



  • 5相当于 Alice 要传递给 Bob 的信息

  • 3相当于是秘钥

  • 6相当于是加密后的信息


经过求模计算的结果6,很难被推到出秘钥信息,只能一个个去验证;


5^1 mod 7 = 5
5^2 mod 7 = 3
5^3 mod 7 = 6
5^4 mod 7 = 2
...

但如果求模的值特别大,例如这样:5^3 mod 78913949018093809389018903794894898493... = 6 那么再想一个个计算就有不靠谱了。所以这也是为什么会使用模运算进行加密,因为对于大数来说对模运算求逆根本没法搞。


根据求模的计算方式,我们得到加密和解密公式;—— 关于加密和解密的公式推到,后文中会给出数学计算公式。





对于两个公式我们做一下更简单的转换;





从转换后的公式可以得知,m 的 ed 次幂,除以 N 求求模可以得到 m 本身。那么 ed 就成了计算公钥加密的重要因素。为此这里需要提到数学中一个非常重要的定理,欧拉定理。—— 1763年,欧拉发现。


欧拉定理:m^φ(n) ≡ 1 (mod n) 对于任何一个与 n 互质的正整数 m,的 φ(n) 次幂并除以 n 去模,结果永远等于1。φ(n) 代表着在小于等于 n 的正整数中,有多少个与 n 互质的数。


例如:φ(8) 小于等于8的正整数中 1、2、3、4、5、6、7、8 有 1、3、5、7 与数字 8 互为质数。所以 φ(8) = 4 但如果是 n 是质数,那么 φ(n) = n - 1 比如 φ(7) 与7互为质数有1、2、3、4、5、6 所有 φ(7) = 6


接下来我们对欧拉公式做一些简单的变换,用于看出ed的作用;





经过推导的结果可以看到 ed = kφ(n) + 1,这样只要算出加密秘钥 e 就可以得到一个对应的解密秘钥 d。那么整套这套计算过程,就是 RSA 算法。

四、关于RSA算法


RSA加密算法是一种非对称加密算法,在公开秘钥加密和电子商业中被广泛使用。





于1977年,三位数学家;罗纳德·李维斯特(Ron Rivest)、阿迪·萨莫尔(Adi Shamir)和伦纳德·阿德曼(Leonard Adleman)设计了一种算法,可以实现非对称加密。这种算法用他们三个人的名字命名,叫做RSA算法。


1973年,在英国政府通讯总部工作的数学家克利福德·柯克斯(Clifford Cocks)在一个内部文件中提出了一个与之等效的算法,但该算法被列入机密,直到1997年才得到公开。


RSA 的算法核心在于取了2个素数做乘积求和、欧拉计算等一系列方式算得公钥和私钥,但想通过公钥和加密信息,反推出来私钥就会非常复杂,因为这是相当于对极大整数的因数分解。所以秘钥越长做因数分解越困难,这也就决定了 RSA 算法的可靠性。—— PS:可能以上这段话还不是很好理解,程序员👨🏻‍💻还是要看代码才能悟。接下来我们就来编写一下 RSA 加密代码。


五、实现RSA算法


RSA 的秘钥生成首先需要两个质数p、q,之后根据这两个质数算出公钥和私钥,在根据公钥来对要传递的信息进行加密。接下来我们就要代码实现一下 RSA 算法,读者也可以根据代码的调试去反向理解 RSA 的算法过程,一般这样的学习方式更有抓手的感觉。嘿嘿 抓手


1. 互为质数的p、q


两个互为质数p、q是选择出来的,越大越安全。因为大整数的质因数分解是非常困难的,直到2020年为止,世界上还没有任何可靠的攻击RSA算法的方式。只要其钥匙的长度足够长,用RSA加密的信息实际上是不能被破解的。—— 不知道量子计算机出来以后会不会改变。如果改变,那么程序员又有的忙了。


2. 乘积n


n = p * q 的乘积。


public long n(long p, long q) {
return p * q;
}

3. 欧拉公式 φ(n)


φ(n) = (p - 1) * (q - 1)


public long euler(long p, long q) {
return (p - 1) * (q - 1);
}

4. 选取公钥e


e 的值范围在 1 < e < φ(n)


public long e(long euler){
long e = euler / 10;
while (gcd(e, euler) != 1){
e ++;
}
return e;
}

5. 选取私钥d


d = (kφ(n) + 1) / e


public long inverse(long e, long euler) {
return (euler + 1) / e;
}

6. 加密


c = m^e mod n


public long encrypt(long m, long e, long n) {
BigInteger bM = new BigInteger(String.valueOf(m));
BigInteger bE = new BigInteger(String.valueOf(e));
BigInteger bN = new BigInteger(String.valueOf(n));
return Long.parseLong(bM.modPow(bE, bN).toString());
}

7. 解密


m = c^d mod n


public long decrypt(long c, long d, long n) {
BigInteger bC = new BigInteger(String.valueOf(c));
BigInteger bD = new BigInteger(String.valueOf(d));
BigInteger bN = new BigInteger(String.valueOf(n));
return Long.parseLong(bC.modPow(bD, bN).toString());
}

8. 测试


@Test
public void test_rsa() {
RSA rsa = new RSA();
long p = 3, // 选取2个互为质数的p、q
q = 11, // 选取2个互为质数的p、q
n = rsa.n(p, q), // n = p * q
euler = rsa.euler(p, q), // euler = (p-1)*(q-1)
e = rsa.e(euler), // 互为素数的小整数e | 1 < e < euler
d = rsa.inverse(e, euler), // ed = φ(n) + 1 | d = (φ(n) + 1)/e
msg = 5; // 传递消息 5

System.out.println("消息:" + msg);
System.out.println("公钥(n,e):" + "(" + n + "," + e + ")");
System.out.println("私钥(n,d):" + "(" + n + "," + d + ")");

long encrypt = rsa.encrypt(msg, e, n);
System.out.println("加密(消息):" + encrypt);

long decrypt = rsa.decrypt(encrypt, d, n);
System.out.println("解密(消息):" + decrypt);
}

测试结果


消息:5
公钥(n,e):(33,3)
私钥(n,d):(33,7)
加密(消息):26
解密(消息):5


  • 通过选取3、11作为两个互质数,计算出公钥和私钥,分别进行消息的加密和解密。如测试结果消息5的加密后的信息是26,解密后获得原始信息5


六、RSA数学原理


整个 RSA 的加解密是有一套数学基础可以推导验证的,这里小傅哥把学习整理的资料分享给读者,如果感兴趣可以尝试验证。这里的数学公式会涉及到;求模运算、最大公约数、贝祖定理、线性同于方程、中国余数定理、费马小定理。当然还有一些很基础的数论概念;素数、互质数等。以下推理数学内容来自博客:luyuhuang.tech/2019/10/24/…


1. 模运算


1.1 整数除法


定理 1 令 a 为整数, d 为正整数, 则存在唯一的整数 q 和 r, 满足 0⩽r<d, 使得 a=dq+r.


当 r=0 时, 我们称 d 整除 a, 记作 d∣a; 否则称 d 不整除 a, 记作 d∤a


整除有以下基本性质:


定理 2 令 a, b, c 为整数, 其中 a≠0a≠0. 则:



  • 对任意整数 m,n,如果 a∣b 且 a∣c, 则 a∣(mb + nc)

  • 如果 a∣b, 则对于所有整数 c 都有 a∣bc

  • 如果 a∣b 且 b∣c, 则 a∣c


1.2 模算术


在数论中我们特别关心一个整数被一个正整数除时的余数. 我们用 a mod m = b表示整数 a 除以正整数 m 的余数是 b. 为了表示两个整数被一个正整数除时的余数相同, 人们又提出了同余式(congruence).


定义 1 如果 a 和 b 是整数而 m 是正整数, 则当 m 整除 a - b 时称 a 模 m 同余 b. 记作 a ≡ b(mod m)


a ≡ b(mod m) 和 a mod m= b 很相似. 事实上, 如果 a mod m = b, 则 a≡b(mod m). 但他们本质上是两个不同的概念. a mod m = b 表达的是一个函数, 而 a≡b(mod m) 表达的是两个整数之间的关系.


模算术有下列性质:


定理 3 如果 m 是正整数, a, b 是整数, 则有


(a+b)mod m=((a mod m)+(b mod m)) mod m


ab mod m=(a mod m)(b mod m) mod m


根据定理3, 可得以下推论


推论 1 设 m 是正整数, a, b, c 是整数; 如果 a ≡ b(mod m), 则 ac ≡ bc(mod m)


证明 ∵ a ≡ b(mod m), ∴ (a−b) mod m=0 . 那么


(ac−bc) mod m=c(a−b) mod m=(c mod m⋅(a−b) mod m) mod m=0


∴ ac ≡ bc(mod m)


需要注意的是, 推论1反之不成立. 来看推论2:


推论 2 设 m 是正整数, a, b 是整数, c 是不能被 m 整除的整数; 如果 ac ≡ bc(mod m) , 则 a ≡ b(mod m)


证明 ∵ ac ≡ bc(mod m) , 所以有


(ac−bc)mod m=c(a−b)mod m=(c mod m⋅(a−b)mod m) mod m=0


∵ c mod m≠0 ,


∴ (a−b) mod m=0,


∴a ≡ b(mod m) .


2. 最大公约数


如果一个整数 d 能够整除另一个整数 a, 则称 d 是 a 的一个约数(divisor); 如果 d 既能整除 a 又能整除 b, 则称 d 是 a 和 b 的一个公约数(common divisor). 能整除两个整数的最大整数称为这两个整数的最大公约数(greatest common divisor).


定义 2 令 a 和 b 是不全为零的两个整数, 能使 d∣ad∣a 和 d∣bd∣b 的最大整数 d 称为 a 和 b 的最大公约数. 记作 gcd(a,b)


2.1 求最大公约数


如何求两个已知整数的最大公约数呢? 这里我们讨论一个高效的求最大公约数的算法, 称为辗转相除法. 因为这个算法是欧几里得发明的, 所以也称为欧几里得算法. 辗转相除法基于以下定理:


引理 1 令 a=bq+r, 其中 a, b, q 和 r 均为整数. 则有 gcd(a,b)=gcd(b,r)


证明 我们假设 d 是 a 和 b 的公约数, 即 d∣a且 d∣b, 那么根据定理2, d 也能整除 a−bq=r 所以 a 和 b 的任何公约数也是 b 和 r 的公约数;


类似地, 假设 d 是 b 和 r 的公约数, 即 d∣bd∣b 且 d∣rd∣r, 那么根据定理2, d 也能整除 a=bq+r. 所以 b 和 r 的任何公约数也是 a 和 b 的公约数;


因此, a 与 b 和 b 与 r 拥有相同的公约数. 所以 gcd(a,b)=gcd(b,r).


辗转相除法就是利用引理1, 把大数转换成小数. 例如, 求 gcd(287,91) 我们就把用较大的数除以较小的数. 首先用 287 除以 91, 得


287=91⋅3+14


我们有 gcd(287,91)=gcd(91,14) . 问题转换成求 gcd(91,14). 同样地, 用 91 除以 14, 得


91=14⋅6+7


有 gcd(91,14)=gcd(14,7) . 继续用 14 除以 7, 得


14=7⋅2+0


因为 7 整除 14, 所以 gcd(14,7)=7. 所以 gcd(287,91)=gcd(91,14)=gcd(14,7)=7.


我们可以很快写出辗转相除法的代码:


def gcd(a, b):
if b == 0: return a
return gcd(b, a % b)

2.2 贝祖定理


现在我们讨论最大公约数的一个重要性质:


定理 4 贝祖定理 如果整数 a, b 不全为零, 则 gcd(a,b)是 a 和 b 的线性组合集 {ax+by∣x,y∈Z}中最小的元素. 这里的 x 和 y 被称为贝祖系数


证明 令 A={ax+by∣x,y∈Z}. 设存在 x0x0, y0y0 使 d0d0 是 A 中的最小正元素, d0=ax0+by0 现在用 d0去除 a, 这就得到唯一的整数 q(商) 和 r(余数) 满足





又 0⩽r<d0, d0 是 A 中最小正元素


∴ r=0 , d0∣a.


同理, 用 d0d0 去除 b, 可得 d0∣b. 所以说 d0 是 a 和 b 的公约数.


设 a 和 b 的最大公约数是 d, 那么 d∣(ax0+by0)即 d∣d0


∴∴ d0 是 a 和 b 的最大公约数.


我们可以对辗转相除法稍作修改, 让它在计算出最大公约数的同时计算出贝祖系数.


def gcd(a, b):
if b == 0: return a, 1, 0
d, x, y = gcd(b, a % b)
return d, y, x - (a / b) * y

3. 线性同余方程


现在我们来讨论求解形如 ax≡b(modm) 的线性同余方程. 求解这样的线性同余方程是数论研究及其应用中的一项基本任务. 如何求解这样的方程呢? 我们要介绍的一个方法是通过求使得方程 ¯aa≡1(mod m) 成立的整数 ¯a. 我们称 ¯a 为 a 模 m 的逆. 下面的定理指出, 当 a 和 m 互素时, a 模 m 的逆必然存在.


定理 5 如果 a 和 m 为互素的整数且 m>1, 则 a 模 m 的逆存在, 并且是唯一的.


证明贝祖定理可知, ∵ gcd(a,m)=1 , ∴ 存在整数 x 和 y 使得 ax+my=1 这蕴含着 ax+my≡1(modm) ∵ my≡0(modm), 所以有 ax≡1(modm)


∴ x 为 a 模 m 的逆.


这样我们就可以调用辗转相除法 gcd(a, m) 求得 a 模 m 的逆.


a 模 m 的逆也被称为 a 在模m乘法群 Z∗m 中的逆元. 这里我并不想引入群论, 有兴趣的同学可参阅算法导论


求得了 a 模 m 的逆 ¯a 现在我们可以来解线性同余方程了. 具体的做法是这样的: 对于方程 ax≡b(modm)a , 我们在方程两边同时乘上 ¯a, 得 ¯aax≡¯ab(modm)


把 ¯aa≡1(modm) 带入上式, 得 x≡¯ab(modm)


x≡¯ab(modm) 就是方程的解. 注意同余方程会有无数个整数解, 所以我们用同余式来表示同余方程的解.





4. 中国余数定理


中国南北朝时期数学著作 孙子算经 中提出了这样一个问题:


有物不知其数,三三数之剩二,五五数之剩三,七七数之剩二。问物几何?


用现代的数学语言表述就是: 下列同余方程组的解释多少?





孙子算经 中首次提到了同余方程组问题及其具体解法. 因此中国剩余定理称为孙子定理.


定理 6 中国余数定理 令 m1,m2,…,mn 为大于 1 且两两互素的正整数, a1,a2,…,an 是任意整数. 则同余方程组





有唯一的模 m=m1m2…mnm=m1m2…mn 的解.


证明 我们使用构造证明法, 构造出这个方程组的解. 首先对于 i=1,2,…,ni=1,2,…,n, 令





即, MiMi 是除了 mimi 之外所有模数的积. ∵∵ m1,m2,…,mn 两两互素, ∴∴ gcd(mi,Mi)=1. 由定理 5 可知, 存在整数 yiyi 是 MiMi 模 mimi 的逆. 即





上式等号两边同时乘 aiai 得





就是第 i 个方程的一个解; 那么怎么构造出方程组的解呢? 我们注意到, 根据 Mi 的定义可得, 对所有的 j≠ij≠i, 都有 aiMiyi≡0(modmj). 因此我们令





就是方程组的解.


有了这个结论, 我们可以解答 孙子算经 中的问题了: 对方程组的每个方程, 求出 MiMi , 然后调用 gcd(M_i, m_i) 求出 yiyi:





最后求出 x=−2⋅35+3⋅21+2⋅15=23≡23(mod105)


5. 费马小定理


现在我们来看数论中另外一个重要的定理, 费马小定理(Fermat's little theorem)


定理 7 费马小定理 如果 a 是一个整数, p 是一个素数, 那么





当 n 不为 p 或 0 时, 由于分子有质数p, 但分母不含p; 故分子的p能保留, 不被约分而除去. 即 p∣(np).


令 b 为任意整数, 根据二项式定理, 我们有





令 a=b+1, 即得 a^p ≡ a(mod p)


当 p 不整除 a 时, 根据推论 2, 有 a^p−1 ≡ 1(mod p)


6. 算法证明


我们终于可以来看 RSA 算法了. 先来看 RSA 算法是怎么运作的:


RSA 算法按照以下过程创建公钥和私钥:



  1. 随机选取两个大素数 p 和 q, p≠qp≠q;

  2. 计算 n=pq

  3. 选取一个与 (p−1)(q−1) 互素的小整数 e;

  4. 求 e 模 (p−1)(q−1) 的逆, 记作 d;

  5. 将 P=(e,n)公开, 是为公钥;

  6. 将 S=(d,n)保密, 是为私钥.





所以 RSA 加密算法是有效的.

(1) 式表明, 不仅可以用公钥加密, 私钥解密, 还可以用私钥加密, 公钥解密. 即加密计算 C=M^d mod n, 解密计算 M=C^e mod n


RSA 算法的安全性基于大整数的质因数分解的困难性. 由于目前没有能在多项式时间内对整数作质因数分解的算法, 因此无法在可行的时间内把 n 分解成 p 和 q 的乘积. 因此就无法求得 e 模 (p−1)(q−1)的逆, 也就无法根据公钥计算出私钥.


七、常见面试题



  • 质数的用途

  • RSA 算法描述

  • RSA 算法加解密的过程

  • RSA 算法使用场景

  • 你了解多少关于 RSA 的数学数论知识

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

Flutter 组件分析之AspectRatio

引言 AspectRatio 可以根据具体的长宽比约束 child 的布局范围, 从而影响 child 的大小. 通常在视频、图像中会经常使用, 今天我们来分析一下它的实现原理. AspectRatio AspectRatio 的参数只有 key、aspect...
继续阅读 »

引言


AspectRatio 可以根据具体的长宽比约束 child 的布局范围, 从而影响 child 的大小. 通常在视频、图像中会经常使用, 今天我们来分析一下它的实现原理.


AspectRatio


AspectRatio 的参数只有 key、aspectRatio、child. 它会根据 aspectRatio 去重计算约束 child 的布局范围.
image
我们举一个例子:


    Center(
child: AspectRatio(
aspectRatio: 1.0,
child: Image.network('xx'),
),
)

以图片长宽比3:2为例子




  • 当 aspectRatio 为1.0时:

    由于图片的比例大于 1.0, aspectRatio 取 1.0 时, 以屏宽为基准, 1:1为比例, 构建了一个正方形的布局约束范围. 当图片比大于 1.0 时, 图片以屏宽为图片宽, 而图片高要小于约束高. 因此实际布局中, 图片在约束中央.


    image


  • 当 aspectRatio 为0.2时:

    由于图片的比例大于 1.0, aspectRatio 取 0.2 时, 屏幕宽高大于0.2. 以屏高为基准, 1:5为比例, 构建了一个矩形的布局约束范围. 当图片比大于 1.0 时, 图片以屏幕高的1/5为图片宽. 因此实际布局中, 图片会比正常小.


    image


  • 当 aspectRatio 为5.0时:

    由于图片的比例大于 1.0, aspectRatio 取 5.0 时, 屏幕宽高小于5.0. 以屏宽为基准, 5:1为比例, 构建了一个矩形的布局约束范围. 当图片比大于 1.0 时, 图片以屏幕高为图片的高. 因此实际布局中, 图片会比正常小.


    image


这一系列的原因都来自于内部的算法, 让我们一起进入源码中学习一下~


RenderAspectRatio


RenderAspectRatio 是 AspectRatio 的 RenderObject . 里面也封装了关于布局的计算规则, AspectRatio 的计算核心在于 _applyAspectRatio.


constraints.isTight


如果尺寸刚刚好合适的话, 会返回满足约束的最小大小

image.png


非constraints.isTight


这种情况下, width 会拥有默认赋值. 首先会等于约束的最大宽度. 如果宽度是有限的, 那么高度会根据 _aspectRatio 赋值. 反之, 高度会取约束限制的最大高, 同时将宽根据高度重赋值.在赋值完基础度宽高后, 会通过四个判断获取最后的尺寸.
image




四个判断如下:



  • width > constraints.maxWidth

    当宽度大于约束最大宽时, 会重新把宽赋值为约束的最大宽, 并重计算高

  • height > constraints.maxHeight

    当高度大于约束最大高时, 会重新把高赋值为约束的最大高, 并重计算宽

  • width < constraints.minWidth

    当宽小于约束的最小值时, 会把宽赋值为约束度最小值, 并重计算高

  • height < constraints.minHeight

    当高小于约束的最小值时, 会把高赋值为约束度最小值, 并重计算宽


image

在经过这一系列计算后, 宽高将会根据 aspectRatio 重计算直至符合 aspectRatio 并且能放进约束中.


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

Flutter 小技巧之快速理解手势逻辑

GestureDetector 不管你用 InkWell 、InkResponse 、TextButton 还是 ElevatedButton , 它们针对手势的处理逻辑都是来自于 GestureDetector ,也就是理解 Flutter 的手势处理...
继续阅读 »

GestureDetector


不管你用 InkWellInkResponseTextButton 还是 ElevatedButton , 它们针对手势的处理逻辑都是来自于 GestureDetector ,也就是理解 Flutter 的手势处理逻辑入门,核心可以从分析 GestureDetector 开始。



其实更严格意义上讲,手势事件是来自 ListenerGestureDetector 是针对 Listener 进行了封装,只是为了避免复杂的源码分析,这里就不做展开,你可以简单理解为:并不是所有的控件都会响应手势,只有带有 Listener 的才会响应,这主要体现在触摸事件的递归响应上。



GestureDetector 里关于事件的响应逻辑主要来自于各种 GestureRecognizer (手势识别)的实现逻辑,不同的手势识别逻辑会响应不同手势结果,相互竞争,最后在 GestureArenaManager (竞技场) 决定出一个胜利者。


简单来说,在竞技场里手势基本遵循两个逻辑:



  • 每个 Recognizer 都可以随时选择失败退出,当竞技场只有它一个的时候它就赢了

  • 每个 Recognizer 都可以随时宣布自己获得胜利,这时其他 Recognizer 也将不再响应


那么如下图所示,在 GestureDetector 里主要有这 8 种 GestureRecognizer 在处理不同的场景,他们会根据用户使用 GestureDetector 时的参数搭配来参与到事件竞技场里。



举个例子,当你使用了 GestureDetector 并配置了 onTaponLongPressonDoubleTap ,它们是如何分别响应手势事件的?


这里的核心逻辑就在于 deadline (时间) 的处理,不管是 onLongPress 还是 onDoubleTap 都是靠 deadline 来判断胜负



例如,当用户只是普通点击时,如下代码所示,因为默认 LongPressGestureRecognizer 的 deadline 是 500 毫秒,所以在定时器达到 500ms 响应之前,就会因为 PointerUpEvent 导致长按定时器停止,无法触发响应长按事件


反之如果没有 PointerUpEvent 产生,那么 500 ms 之后 LongPressGestureRecognizer 就会响应,直接宣布胜利(accepted)。




默认情况下 GestureDetector 是不支持修改 deadline ,只有直接使用 LongPressGestureRecognizer 时才可以修改 deadline 的时长。



类似的逻辑在 DoubleTapGestureRecognizer 下也有,DoubleTap 的 deadline 是 300 毫秒,当用户首次点击时会注册一个定时器,如果 300 毫秒以内用户没有产生新的点击,那么 DoubleTapGestureRecognizer 就会宣布“失败“退出竞技,反之如果在 300 毫秒内有新的点击,则直接宣布“获胜”,响应 DoubleTap 回调。



那这时候有人就要问了:“DoubleTap 过程中,为什么不会触发 onTap” ? 这就需要说到 TapGestureRecognizer 的触发逻辑。


继续前面 GestureDetector 并配置了 onTaponLongPressonDoubleTap 的例子,在用户只做普通点击的时候,前面说过:



  • LongPressGestureRecognizer 的定时器 deadline 还没到 500 毫秒会因为 Up 事件而导致失败退出

  • DoubleTapGestureRecognizer 会因为定时器超过 deadline 300 毫秒,没有下一个点击而宣布退出


那么在 Long 和 Double 都失败的情况下,此时 GestureArenaManager (竞技场) 里的成员就只有 TapGestureRecognizer ,这时候竞技场会 close ,会触发竞技场的 sweep 逻辑,直接让最后剩下来的 Recognizer “胜利”,响应 onTap 事件。



所以 TapGestureRecognizer 靠的是胜者为王。



所以基于这个例子,配合一开始说的两个逻辑,就可以直观的理解 Flutter 手势竞技场里的响应逻辑和关键 deadline 的作用。


多个 GestureDetector


那么前面都是只有一个 GestureDetector 的场景,如果有两个呢?如下代码所示,在嵌套两个 GestureDetector 下,它们的响应逻辑会是怎么样的?



当区域内有两个 GestureDetector 的时候,用户在普通点击时,因为 deadline 影响,依旧会是在竞技场 close 时才响应 onTap但是不同在于此时竞技场里还会有多个 Recognizer 存在,这时候只有排在列表的第一个的 Recognizer 可以赢得事件,也就是上门代码里的红色 200x200 小方块。



因为对于多个 GestureDetector 的情况, Recognizer 在竞技场列表(List<GestureArenaMember)里的顺序和 HitTest 时的递归调用有关系,简单说就是:递归调用会就让我们自下而上的得到一个 HitTestResult 列表,代码里最后的 child 会在最上面



同时对于单个 GestureDetector 而言,TapGestureRecognizer 会是 _recognizers 的第一个,所以 first 会是响应了 TapGestureRecognizer ,详细逻辑可以看 《面深入触摸和滑动原理》



所以简单理解:



  • 两个 GestureDetector 在竞技场里的 member 列表排序时,作为 child 的红色 GestureDetector 因为 HitTest 递归会被排前面

  • GestureDetector 内部 TapGestureRecognizer 会在其内部 _recognizers 排第一


所以 member.first 最终响应了 TapGestureRecognizer ,回到上面两个定律,如果结合多个 GestureDetector 的场景,就应该是:



  • 每个 Recognizer 都可以随时选择失败退出,当竞技场只有它一个的时候它就赢了;如果不止一个,那么在竞技场 close 时, member.first 会获得响应

  • 每个 Recognizer 都可以随时宣布自己获得胜利,这是其他 Recognizer 也将不再响应



进阶补充


前面简单介绍了 Flutter 的手势响应的基础逻辑,这里再额外补充两个知识点。


首先,当用户在长按的时候, GestureDetector 何时会发出 onTapDown 事件


这其实就涉及了另外一个 deadline 参数,当用户在长按的时候,Recognizer 还会触发另外一个定时器,然后通过执行 didExceedDeadline 来发出 onTapDown 事件。



那么问题又来了,既然长按会触发 onTapDown 事件,如果点击区域内有两个 TapGestureRecognizer ,长按的时候因为定时器都触发了 didExceedDeadline ,那是不是两个都会收到 onTapDown 事件 ?



答案是:会的!因为定时器都触发了 didExceedDeadline,从而都发出了 onTapDown 事件,所以两个 onTapDown 回调都会执行,但是后续竞争只会有一个控件能响应 onLongPress



另外,如果不是长按导致的 Down 事件, 是不会导致两个 GestureDetector 都触发回调 onTapDown 回调。



第二个补充的是 Listener , 如果你还想深入去看 GestureDetector 的实现,你就会发现 GestureDetectorListener 的封装也许和你想象不大一样, 因为 Listener 的封装只用到了 PointerDown ,并没有用到 onPointerUp ,那 GestureDetector 是怎么响应 Up 和 Move 事件?



这就需要说到前面介绍 《面深入触摸和滑动原理》 里的源码分析,但是为了简单,我们这里只说结论:



因为只有响应了 PointerDown 事件,对应的 GestureRecognizer 才能被添加到 GestureBindingPointerRouter 事件路由和 GestureArenaManager 事件竞技中,而后续的 Up 和 Move 事件主要是通过 GestureBinding 来处理



更简单的说,就是只有响应了 PointerDown 事件,控件的 Recognizer 才能响应后续统一处理的其他手势事件,而其他事件不需要在 Listener 这里获取回调


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

职场羊,不要再抱怨公司,看看领导怎么说

最近很多人羊了,抱怨公司,无力工作,甚至嗅觉还失灵了。在此,我想替领导们问问:为什么是你羊?不是别人羊?你羊的底层逻辑是什么?能解决什么问题?你羊的抓手在哪里?怎么羊的?为什么羊?如何证明你比别人羊的好?你这个职级,不是羊了就可以的,我是希望你羊了之后,能够拼...
继续阅读 »

最近很多人羊了,抱怨公司,无力工作,甚至嗅觉还失灵了。

在此,我想替领导们问问:

为什么是你羊?不是别人羊?

你羊的底层逻辑是什么?能解决什么问题?

你羊的抓手在哪里?怎么羊的?为什么羊?

如何证明你比别人羊的好?

你这个职级,不是羊了就可以的,我是希望你羊了之后,能够拼一把。

你的羊是否沉淀了一套可复用的康复论?

你的羊是否形成了毒株的差异性进化?

你的羊是否形成了流感传播的核心竞争力?

我希望看到你对羊的思考,而不仅仅是休息+应付。

提醒你一下,目前你的羊是有些单薄的,和同级相比,温度是不够的。

要到年底了,我希望你能加把劲,你看隔壁组的谁谁谁,39度羊都是在办公室打地铺的。

成长,一定是伴随着温度的。

今天最高的温度,就是明天最低的要求。

只有39度的时候,才是你成长最快的时候。

我希望看到你的沉淀,下班前写个总结给我,我向上汇报用。

加油!

收起阅读 »

不就是代码缩进吗?别打起来啊

web
本文不讨论两种缩进方式的优劣,只提供一种能够同时兼容大家关于缩进代码风格的解决方案很久之前,组内开发时发现大家的tab.size不一样,有的伙伴表示都能接受,有的伙伴习惯使用2字符缩进,有的伙伴习惯4字符缩进,导致开发起来很痛苦,一直在寻找兼容大家代码风格的办...
继续阅读 »

免战申明

本文不讨论两种缩进方式的优劣,只提供一种能够同时兼容大家关于缩进代码风格的解决方案

字符缩进,2还是4?

很久之前,组内开发时发现大家的tab.size不一样,有的伙伴表示都能接受,有的伙伴习惯使用2字符缩进,有的伙伴习惯4字符缩进,导致开发起来很痛苦,一直在寻找兼容大家代码风格的办法,今天终于研究出一种解决方案(不一定适用所有人)。

工具准备

  1. vscode

  2. prettierrc插件

解决方案

首先设置"editor.tabSize"为自己习惯的tabSize


设置tab按下时不插入空格"editor.insertSpaces": false


项目根目录下创建.prettierrc(可添加到.gitignore),设置"useTabs": true

{
   "printWidth": 180,
   "semi": true,
   "singleQuote": true,
   //使用tab进行格式化
   "useTabs": true
}


设置展示效果"editor.renderWhitespace": "selection"


最终效果





可以看到,编辑器内设置不同的代码缩进,展示效果不同,但最终提交的代码风格一致。 (小缺陷:对于强制要求使用空格代替tab的情况不适用)

作者:断律绎殇
来源:juejin.cn/post/7095001798120833061

收起阅读 »

细节决定成败:探究Mybatis中javaType和ofType的区别

一. 背景描述 今天,壹哥给学生讲解了Mybatis框架,学习了基础的ORM框架操作及多对一的查询。在练习的时候,小张同学突然举手求助,说在做预习作业使用一对多查询时,遇到了ReflectionException 异常 。 二. 情景再现 1. 实体类 为了给...
继续阅读 »

一. 背景描述


今天,壹哥给学生讲解了Mybatis框架,学习了基础的ORM框架操作及多对一的查询。在练习的时候,小张同学突然举手求助,说在做预习作业使用一对多查询时,遇到了ReflectionException 异常


二. 情景再现


1. 实体类


为了给大家讲清楚这个异常的产生原因,壹哥先列出今天案例中涉及到的两张表:书籍表和书籍类型表。这两张表中存在着简单的多对一关系,实体类如下:


@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Book {
private Integer id;
private String name;
private String author;
private String bookDesc;
private String createTime;
private BookType type;
private String imgPath;
}

@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class BookType {
private Integer id;
private String name;
}


2.BookMapper.xml映射文件


上课时,壹哥讲解的关联查询是通过查询书籍信息,并同时对书籍类型查询。即在查询Book对象时i,同时查询出BookType对象。BookMapper.xml映射文件如下:


<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.qf.day7.dao.BookDAO">
<resultMap id="booksMap" type="com.qf.day7.entity.Books">
<id property="id" column="id"></id>
<result property="name" column="name"></result>
<result property="author" column="author"></result>
<result property="bookDesc" column="book_desc"></result>
<result property="createTime" column="create_time"></result>
<result property="imgPath" column="img_path"></result>
<!-- 单个对象的关联,javaType是指实体类的类型-->
<association property="type" javaType="com.qf.day7.entity.BookType">
<id property="id" column="type_id"></id>
<result property="name" column="type_name"></result>
</association>
</resultMap>

<select id="findAll" resultMap="booksMap">
SELECT
b.id,
b.`name`,
b.author,
b.book_desc,
b.create_time,
b.img_path,
t.id type_id,
t.`name` type_name
FROM
books AS b
INNER JOIN book_type AS t ON b.type_id = t.id
</select>
</mapper>


3. 核心配置


核心配置文件如下:mybatisCfg.xml


<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE configuration
PUBLIC "-//mybatis.org//DTD Config 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-config.dtd">
<configuration>
<typeAliases>
<package name="com.qf.day7.entity"/>
</typeAliases>

<environments default="development">
<environment id="development">
<!-- 事务管理器-->
<transactionManager type="JDBC"></transactionManager>
<!-- 使用mybatis自带连接池-->
<dataSource type="POOLED">
<!-- jdbc四要素-->
<property name="driver" value="com.mysql.jdbc.Driver"/>
<property name="url"
value="jdbc:mysql://localhost:3306/books?useUnicode=true&amp;characterEncoding=utf-8&amp;useSSL=false"/>
<property name="username" value="root"/>
<property name="password" value="root"/>
</dataSource>
</environment>
</environments>

<mappers>
<mapper resource="mapper/BookMapper.xml"></mapper>
<mapper resource="mapper/BookTypeMapper.xml"></mapper>
</mappers>
</configuration>


4. 测试代码


接着我们对上面的配置进行测试。


public class BookDAOTest {
private SqlSessionFactory factory;

@Before
public void setUp() throws Exception {
final InputStream inputStream = Resources.getResourceAsStream("mybatisCfg.xml");
factory = new SqlSessionFactoryBuilder().build(inputStream);
}

@Test
public void findAll() {
final SqlSession session = factory.openSession();
final BookDAO bookDAO = session.getMapper(BookDAO.class);
final List<Book> list = bookDAO.findAll();
list.stream().forEach(System.out::println);
session.close();
}
}


学生按照我讲的内容,测试没有问题。在后续的预习练习中,要求实现在BookType中添加List属性books,在查询BookType对象同时将该类型的Book对象集合查出。小张同学有了如下实现思路。


5. 修改实体类


@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Book {
private Integer id;
private String name;
private String author;
private String bookDesc;
private String createTime;
private String imgPath;
}

@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class BookType {
private Integer id;
private String name;
private List<Book> books;
}


6. 添加映射文件BookTypeMapper.xml


<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.qf.day7.dao.BookTypeDAO">
<resultMap id="bookTypeMap" type="com.qf.day7.entity.BookType">
<id column="id" property="id"></id>
<result column="name" property="name"></result>
<collection property="books" javaType="com.qf.day7.entity.Book">
<id property="id" column="book_id"></id>
<result property="name" column="book_name"></result>
<result property="author" column="author"></result>
<result property="bookDesc" column="book_desc"></result>
<result property="createTime" column="create_time"></result>
<result property="imgPath" column="img_path"></result>
</collection>
</resultMap>

<select id="findById" resultMap="bookTypeMap">
SELECT
b.id book_id,
b.`name` book_name,
b.author,
b.book_desc,
b.create_time,
b.img_path,
t.id,
t.`name`
FROM
books AS b
INNER JOIN book_type AS t ON b.type_id = t.id
where t.id = #{typeId}
</select>
</mapper>


7. 编写测试类


public class BookTypeDAOTest {
private SqlSessionFactory factory;
@Before
public void setUp() throws Exception {
final InputStream inputStream = Resources.getResourceAsStream("mybatisCfg.xml");
factory = new SqlSessionFactoryBuilder().build(inputStream);
}

@Test
public void findById() {
final SqlSession session = factory.openSession();
final BookTypeDAO bookTypeDAO = session.getMapper(BookTypeDAO.class);
BookType bookType = bookTypeDAO.findById(1);
for (Book book : bookType.getBooks()) {
System.out.println(book.getName());
}
session.close();
}


然后就出现了一开始提到的异常:


org.apache.ibatis.exceptions.PersistenceException: 
### Error querying database. Cause: org.apache.ibatis.reflection.ReflectionException: Could not set property 'books' of 'class com.qf.day7.entity.BookType' with value 'Book(id=1, name=Java从入门到精通, author=千锋, bookDesc=很不错的Java书籍, createTime=2022-05-27, type=null, imgPath=174cc662fccc4a38b73ece6880d8c07e)' Cause: java.lang.IllegalArgumentException: argument type mismatch
### The error may exist in mapper/BookTypeMapper.xml
### The error may involve com.qf.day7.dao.BookTypeDAO.findById
### The error occurred while handling results
### SQL: SELECT b.id book_id, b.`name` book_name, b.author, b.book_desc, b.create_time, b.img_path, t.id, t.`name` FROM books AS b INNER JOIN book_type AS t ON b.type_id = t.id where t.id = ?
### Cause: org.apache.ibatis.reflection.ReflectionException: Could not set property 'books' of 'class com.qf.day7.entity.BookType' with value 'Book(id=1, name=Java从入门到精通, author=千锋, bookDesc=很不错的Java书籍, createTime=2022-05-27, type=null, imgPath=174cc662fccc4a38b73ece6880d8c07e)' Cause: java.lang.IllegalArgumentException: argument type mismatch


三. 异常分析


上面的 异常提示 说在 BookType类中的books属性设置有问题 我们来仔细查看一下代码,发现是因为直接 复制了之前的关系配置, 在配置文件中 使用javaType 节点 但正确的 应该 使用ofType。如下图所示:



四. 解析


那么为什么有的关系配置要使用javaType,而有的地方又要使用ofType呢?


这我们就不得不说说Mybatis的底层原理了!在关联映射中,如果是单个的JavaBean对象,那么可以使用javaType;而如果是集合类型,则需要写ofType。以下是Mybatis的官方文档原文:



五. 结尾


虽然上面的代码中只是因为一个单词的不同,却造成了不小的错误。我们的程序是严格的,小问题就可能会耽误你很久的时间。就比如我们的小张同学,在求助壹哥之前已经找bug找了一个小时......最后壹哥一眼就给他看出了问题所在,他都无语凝噎了.....


现在你明白javaType和ofType用法上的区别了吗?如果你还有其他什么问题,可以在评论区留言或私信哦!关注Java架构栈,干货天天都不断。


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

我尝试以最简单的方式帮你梳理 Lifecycle

前言 我们都知道 Activity 与 Fragment 都是有生命周期的,例如:onCreate()、onStop() 这些回调方法就代表着其生命周期状态。我们开发者所做的一些操作都应该合理的控制在生命周期内...
继续阅读 »

前言


我们都知道 Activity 与 Fragment 都是有生命周期的,例如:onCreate()onStop() 这些回调方法就代表着其生命周期状态。我们开发者所做的一些操作都应该合理的控制在生命周期内,比如:当我们在某个 Activity 中注册了广播接收器,那么在其 onDestory() 前要记得注销掉,避免出现内存泄漏。


生命周期的存在,帮助我们更加方便地管理这些任务。但是,在日常开发中光凭 Activity 与 Fragment 可不够,我们通常还会使用一些组件来帮助我们实现需求,而这些组件就不像 Activity 与 Fragment 一样可以很方便地感知到生命周期了。


假设当前有这么一个需求:



开发一个简易的视频播放器组件以供项目使用,要求在进入页面后注册播放器并加载资源,一旦播放器所处的页面不可见或者不位于前台时就暂停播放,等到页面可见或者又恢复到前台时再继续播放,最后在页面销毁时则注销掉播放器。



试想一下:如果现在让你来实现该需求?你会怎么去实现呢?


实现这样的需求,我们的播放器组件就需要获取到所处页面的生命周期状态,在 onCreate() 中进行注册,onResume() 开始播放,onStop() 暂停播放,onDestroy() 注销播放器。


最简单的方法:提供方法,暴露给使用方,供其自己调用控制。


class VideoPlayerComponent(private val context: Context) {

/**
* 注册,加载资源
*/
fun register() {
loadResource(context)
}

/**
* 注销,释放资源
*/
fun unRegister() {
releaseResource()
}

/**
* 开始播放当前视频资源
*/
fun startPlay() {
startPlayVideo()
}

/**
* 暂停播放
*/
fun stopPlay() {
stopPlayVideo()
}
}

然后,我们的使用方MainActivity自己,主动在其相对应的生命周期状态进行控制调用相对应的方法。


class MainActivity : AppCompatActivity() {
private lateinit var videoPlayerComponent: VideoPlayerComponent

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
videoPlayerComponent = VideoPlayerComponent(this)
videoPlayerComponent.register(this)
}

override fun onResume() {
super.onResume()
videoPlayerComponent.startPlay()
}

override fun onPause() {
super.onPause()
videoPlayerComponent.stopPlay()
}

override fun onDestroy() {
videoPlayerComponent.unRegister()
super.onDestroy()
}

}

虽然实现了需求,但显然这不是最优雅的实现方式。一旦使用方忘记在 onDestroy() 进行注销播放器,就容易造成内存泄漏,而忘记注销显然是一件很容易发生的事情😂 。


回想初衷,之所以将方法暴露给使用方来调用,就是因为我们的组件自身无法感知到使用者的生命周期。所以,一旦我们的组件自身可以感知到使用者的生命周期状态的话,我们就不需要将这些方法暴露出去了。


那么问题来了,组件如何才能感知到生命周期呢?


答:Lifecycle !


直接上案例,借助 Lifecycle 我们改进一下我们的播放器组件👇


class VideoPlayerComponent(private val context: Context) : DefaultLifecycleObserver {

override fun onCreate(owner: LifecycleOwner) {
super.onCreate(owner)
register(context)
}

override fun onResume(owner: LifecycleOwner) {
super.onResume(owner)
startPlay()
}

override fun onPause(owner: LifecycleOwner) {
super.onPause(owner)
stopPlay()
}

override fun onDestroy(owner: LifecycleOwner) {
super.onDestroy(owner)
unRegister()
}

/**
* 注册,加载资源
*/
private fun register(context: Context) {
loadResource(context)
}

/**
* 注销,释放资源
*/
private fun unRegister() {
releaseResource()
}

/**
* 开始播放当前视频资源
*/
private fun startPlay() {
startPlayVideo()
}

/**
* 暂停播放
*/
private fun stopPlay() {
stopPlayVideo()
}
}

改进完成后,我们的调用方MainActivity只需要一行代码即可。


class MainActivity : AppCompatActivity() {

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)

lifecycle.addObserver(VideoPlayerComponent(this))
}
}

这样是不是就优雅多了。


那这 Lifecycle 又是怎么感知到生命周期的呢?让我们这就带着问题,出发探一探它的实现方式与源码!


如果让你来做,你会怎么做


在查看源码前,让我们试着思考一下,如果让你来实现 Jetpack Lifecycle 这样的功能,你会怎么做呢?该从何入手呢?


我们的目的是不通过回调方法即可获取到生命周期,这其实就是解耦,实现解耦的一种很好方法就是利用观察者模式。


利用观察者模式,我们就可以这么设计👇


截屏2022-12-13 下午3.59.21.png


被观察者对象就是生命周期,而观察者对象则是需要知晓生命周期的对象,例如:我们的三方组件。


接着我们就具体探探源码,看一看Google是如何实现的吧。


Google 实现方式


Lifecycle



一个代表着Android生命周期的抽象类,也就是我们的抽象被观察者对象。



public abstract class Lifecycle {

public abstract void addObserver(@NonNull LifecycleObserver observer);

public abstract void removeObserver(@NonNull LifecycleObserver observer);

public enum Event {
ON_CREATE,
ON_START,
ON_RESUME,
ON_PAUSE,
ON_STOP,
ON_DESTROY,
ON_ANY;
}

public enum State {
DESTROYED,
INITIALIZED,
CREATED,
STARTED,
RESUMED;
}

}

内包含 State 与 Event 分别者代表生命周期的状态与事件,同时定义了抽象方法 addObserver(LifecycleObserver) 与removeObserver(LifecycleObserver) 方法用于添加与删除生命周期观察者。


Event 很好理解,就像是 Activity | Fragment 的 onCreate()onDestroy()等回调方法,它代表着生命周期的事件。


那这 State 又是什么呢?何为状态?他们之间又是什么关系呢?


Event 与 State 之间的关系


关于 Event 与 State 之间的关系,Google官方给出了这么一张两者关系图👇


theRelationOfEventAndState.png


乍一看,可能第一感觉不是那么直观,我整理了一下👇


event与state关系图.png



  • INITIALIZED:在 ON_CREATE 事件触发前。

  • CREATED:在 ON_CREATE 事件触发后以及 ON_START 事件触发前;或者在 ON_STOP 事件触发后以及 ON_DESTROY 事件触发前。

  • STARTED:在 ON_START 事件触发后以及 ON_RESUME 事件触发前;或者在 ON_PAUSE 事件触发后以及 ON_STOP 事件触发前。

  • RESUMED:在 ON_RESUME 事件触发后以及 ON_PAUSE 事件触发前。

  • DESTROYED:在 ON_DESTROY 事件触发之后。


Event 代表生命周期发生变化那个瞬间点,而 State 则表示生命周期的一个阶段。这两者结合的好处就是让我们可以更加直观的感受生命周期,从而可以根据当前所处的生命周期状态来做出更加合理操作行为。


例如,在LiveData的生命周期绑定观察者源码中,就会判断当前观察者对象的生命周期状态,如果当前是DESTROYED状态,则直接移除当前观察者对象。同时,根据观察者对象当前的生命周期状态是否 >= STARTED来判断当前观察者对象是否是活跃的。


class LifecycleBoundObserver extends ObserverWrapper implements LifecycleEventObserver {
......

@Override
boolean shouldBeActive() {
//根据观察者对象当前的生命周期状态是否 >= STARTED 来判断当前观察者对象是否是活跃的。
return mOwner.getLifecycle().getCurrentState().isAtLeast(STARTED);
}

@Override
public void onStateChanged(@NonNull LifecycleOwner source,
@NonNull Lifecycle.Event event) {
//根据当前观察者对象的生命周期状态,如果是DESTROYED,直接移除当前观察者
Lifecycle.State currentState = mOwner.getLifecycle().getCurrentState();
if (currentState == DESTROYED) {
removeObserver(mObserver);
return;
}
......
}
......

}

其实 Event 与 State 这两者之间的联系,在我们生活中也是处处可见,例如:自动洗车。


自动洗车.png


想必现在你对 Event 与 State 之间的关系有了更好的理解了吧。


LifecycleObserver



生命周期观察者,也就是我们的抽象观察者对象。



public interface LifecycleObserver {

}

所以,我们想成为观察生命周期的观察者的话,就需要具体实现该接口,也就是成为具体观察者对象。


换句话说,就是如果你想成为观察者对象来观察生命周期的话,那就必须实现 LifecycleObserver 接口。


例如Google官方提供的 DefaultLifecycleObserver、 LifecycleEventObserver 。


截屏2022-12-14 下午2.33.11.png


LifecycleOwner


正如其名字一样,生命周期的持有者,所以像我们的 Activity | Fragment 都是生命周期的持有者。


大白话很好理解,但代码应该如何实现呢?



抽象概念 + 具体实现



抽象概念:定义 LifecycleOwner 接口。


public interface LifecycleOwner {
@NonNull
Lifecycle getLifecycle();
}

具体实现:Fragment 实现 LifecycleOwner 接口。


public class Fragment implements ComponentCallbacks, OnCreateContextMenuListener, LifecycleOwner,
ViewModelStoreOwner, HasDefaultViewModelProviderFactory, SavedStateRegistryOwner,
ActivityResultCaller {

public Lifecycle getLifecycle() {
return mLifecycleRegistry;
}
......

}

具体实现:Activity 实现 LifecycleOwner 接口。


public class ComponentActivity extends androidx.core.app.ComponentActivity implements
ContextAware,
LifecycleOwner,
ViewModelStoreOwner,
HasDefaultViewModelProviderFactory,
SavedStateRegistryOwner,
OnBackPressedDispatcherOwner,
ActivityResultRegistryOwner,
ActivityResultCaller {

@NonNull
@Override
public Lifecycle getLifecycle() {
return mLifecycleRegistry;
}

......

}

这样,Activity | Fragment 就都是生命周期持有者了。


疑问?在上方 Activity | Fragment 的类中,getLifecycle() 方法中都是返回 mLifecycleRegistry,那这个 mLifecycleRegistry 又是什么玩意呢?


LifecycleRegistry



Lifecycle 的一个具体实现类。



LifecycleRegistry 负责管理生命周期观察者对象,并将最新的生命周期事件与状态及时通知给对应的生命周期观察者对象。


添加与删除观察者对象的具体实现方法。


//用户保存生命周期观察者对象
private FastSafeIterableMap<LifecycleObserver, ObserverWithState> mObserverMap = new FastSafeIterableMap<>();

@Override
public void addObserver(@NonNull LifecycleObserver observer) {
enforceMainThreadIfNeeded("addObserver");
State initialState = mState == DESTROYED ? DESTROYED : INITIALIZED;
//将生命周期观察者对象包装成带生命周期状态的观察者对象
ObserverWithState statefulObserver = new ObserverWithState(observer, initialState);
ObserverWithState previous = mObserverMap.putIfAbsent(observer, statefulObserver);
... 省略代码 ...
}

@Override
public void removeObserver(@NonNull LifecycleObserver observer) {
mObserverMap.remove(observer);
}

可以从上述代码中发现,LifecycleRegistry 还对生命周期观察者对象进行了包装,使其带有生命周期状态。


static class ObserverWithState {
//生命周期状态
State mState;
//生命周期观察者对象
LifecycleEventObserver mLifecycleObserver;

ObserverWithState(LifecycleObserver observer, State initialState) {
//这里确保observer为LifecycleEventObserver类型
mLifecycleObserver = Lifecycling.lifecycleEventObserver(observer);
//并初始化了状态
mState = initialState;
}

//分发事件
void dispatchEvent(LifecycleOwner owner, Event event) {
//根据 Event 得出当前最新的 State 状态
State newState = event.getTargetState();
mState = min(mState, newState);
//触发观察者对象的 onStateChanged() 方法
mLifecycleObserver.onStateChanged(owner, event);
//更新状态
mState = newState;
}
}

将最新的生命周期事件通知给对应的观察者对象。


public void handleLifecycleEvent(@NonNull Lifecycle.Event event) {
... 省略代码 ...
ObserverWithState observer = mObserverMap.entrySet().getValue();
observer.dispatchEvent(lifecycleOwner, event);

... 省略代码 ...
mLifecycleObserver.onStateChanged(owner, event);
}

那 handleLifecycleEvent() 方法在什么时候被调用呢?


相信看到下方这个代码,你就明白了。


public class FragmentActivity extends ComponentActivity {
......

final LifecycleRegistry mFragmentLifecycleRegistry = new LifecycleRegistry(this);

@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
mFragmentLifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE);
}

@Override
protected void onDestroy() {
mFragmentLifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY);
}

@Override
protected void onPause() {
mFragmentLifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_PAUSE);
}

@Override
protected void onStop() {
mFragmentLifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_STOP);
}

......

}

在 Activity | Fragment 的 onCreate()onStart()onPause()等生命周期方法中,调用LifecycleRegistry 的 handleLifecycleEvent() 方法,从而将生命周期事件通知给观察者对象。


总结


Lifecycle 通过观察者设计模式,将生命周期感知对象生命周期提供者充分解耦,不再需要通过回调方法来感知生命周期的状态,使代码变得更加的精简。


虽然不通过 Lifecycle,我们的组件也是可以获取到生命周期的,但是 Lifecycle 的意义就是提供了统一的调用接口,让我们的组件可以更加方便的感知到生命周期,方便广达开发者。而且,Google以此推出了更多的生命周期感知型组件,例如:ViewModelLiveData。正是这些组件,让我们的开发变得越来越简单。


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

Compose 为什么可以跨平台?

前言 Compose 不止能用于 Android 应用开发,借助其分层的架构设计以及 Kotlin 的跨平台优势,也是一个极具潜力的 Kotlin 跨平台框架。本文让我们从 Compose Runtime 的视角出发,看看 Compose 实现跨平台开发的基本...
继续阅读 »

前言


Compose 不止能用于 Android 应用开发,借助其分层的架构设计以及 Kotlin 的跨平台优势,也是一个极具潜力的 Kotlin 跨平台框架。本文让我们从 Compose Runtime 的视角出发,看看 Compose 实现跨平台开发的基本原理。


Compose Architecture Layers



Compose 作为一个框架,在架构上从下到上分成多层:



  • Compose Compiler:Kotlin 编译器插件,负责对 Composable 函数的静态检查以及代码生成等。

  • Compose Runtime:负责 Composable 函数的状态管理,以及执行后的渲染树生成和更新

  • Compose UI: 基于渲染树进行 UI 的布局、绘制等 UI 渲染工作

  • Compose Foundation: 提供用于布局的基础 Composable 组件,例如 ColumnRow 等。

  • Compose Material:提供上层的面向 Material 设计风格的 Composable 组件。
    各层的职责明确,其中 Compose Compiler 和 Runtime 是支撑整个声明式 UI 运转的基石。


Compose Compiler


我们先看一下 Compose Compiler 的作用:



左边的源码是一个非常简单的 Composable 函数,定义了个一大带有状态的 Button,点击按钮,Button 中显示的 count 数增加。


源码经 Compose Compiler 编译后变成右边这样,生成了很多代码。首先函数签名上多了几个参数,特别是多了 %composer 参数。然后函数体中插入了很多对 %composer 的调用,例如 startRestartGroup/endRestartGroup,startReplaceGroup/endReplaceGroup 等。这些生成代码用来完成 Compose Runtime 这一层的工作。接下来我们分析一下 Runtime 具体在做什么


Group & SlotTable


Composable 函数虽然没有返回值,但是执行过程中需要生成服务于 UI 渲染的产物,我们称之为 Composition。参数 %composer 就是 Composition 的维护者,用来创建和更新 Composition。Composition 中包含两棵树,一棵状态树和一棵渲染树。



关于两棵树:如果你了解 React,可以将这两棵树的关系类比成 React 中的 VIrtual DOM Tree 与 Real DOM Tree。Compose 中的这棵 “Virtual DOM” 用来记录 UI 显示所需要的状态信息, 所以我们称之为状态树。



状态树上的节点单元是 Group,编译器生成的 startXXXGroup 本质上就是在创建 Group 单元, startXXXGroup 与 endXXXGroup 之间产生的数据状态都归属当前 Group;产生的 Group 就成为子 Group,因此随着 Composable 的执行,基于 Group 的树型结构就被构建出来了。



关于 Group:Group 都是一些功能单元,比如 RestartGroup 是一个可重组的最小单元,ReplaceableGroup 是可以被动态插入的最小单元等,以 Group 为单位组织状态,可以更灵活的更新状态树。代码中什么位置插入什么样的 startXXXGroup 完全由 Compose Compiler 智能的帮我们生成,我们在写代码时不必付出这方面的思考。



状态树实际是使用一个被称作 Slot Table 的线性数据结构实现的,可以把他理解为一个数组,存储着状态树深度遍历的结果,数组的各个区间存储着对应 UI 节点上的状态。



Comopsable 首次执行时,产生的 Group 以及所瞎的状态会以此填充到 Slot Table 中,填充时会附带一个编译时给予代码位置生成的不重复的 key,所以 Slot Table 中的记录也被称作基于代码位置的存储(Positional Memoization)。当重组发生时, Composable 会再次遍历 SlotTable,并在 startXXXGroup 中根据 key 访问当前代码所需的状态,比如 count 就可以通过 remember 在重组中获取最近的值。


Applier & Node Tree


Slot Table 中的状态不能直接用来渲染,UI 的渲染依赖 Composition 中的另一棵树 - 渲染树。Slot Table 通过 Applier 转换成渲染树。渲染树是真真正的树形结构体 Node Tree。



Applier 是一个接口,从接口定义不难看出,它用于对一棵 Node 类型节点树进行增删改等维护工作。以一个 UI 的插入为例,我们在 Compoable 中的一段 if 语句就可以实现一个 UI 片段的插入。if 代码块在编译期会生成一个 ReplaceGroup,当重组中命中 if 条件执行到 startReplaceGroup 时,发现 Slot Table 中缺少 Group 对应 key 的信息,因此可以识别出是一个插入操作,然后插入新的 Group 以及所辖的 Node 信息,并通过 Applier 转换成 Node Tree 中新插入的节点。


SlotTable 中插入新元素后,后续元素会通过 Gap Buffer 机制进行后移,而不是直接删除。这样可以保证后续元素在 Node Tree 中的对应节点的保留,实现 Node Tree 的增量更新,实现局部刷新,提升性能。


Compose Phases


我们结合前面的介绍,整体看一下 Compose 从源码到上屏的全过程:




  • Composable 源码经 Compiler 处理后插入了用于更新 Composition 的代码。这部分工作由 Compose Compiler 完成。




  • 当 Compose 框架接收到系统侧发送的帧信号后,从顶层开始执行 Composable 函数,执行过程中依次更新 Composition 中的状态树和渲染树,这个过程即所谓的“组合”。这部分工作由 Compose Runtime 完成。




  • Compose 在 Android 平台的容器是 AndroidComposeView,当接收到系统发送的 disptachDraw 时,便开始驱动 Composition 的渲染树以及进行 Measure,Lyaout,Drawing 完成 UI 的渲染。这部分工作由 Compose UI 负责完成。






Comopse 渲染一帧的三个阶段 : Composition -> Layout -> Drawing。
传统视图开发中,渲染树(View Tree)的维护需要我们在代码逻辑中完成;Compose 渲染树的维护则交给了框架,所以多了 Composition 这一阶段。这也是 Compose 相对于自定义 View 代码更简单的根本原因。



把这整个过程从中间一分为二来看,Compose Compiler 与 Compose Runtime 负责驱动一棵节点树的更新,这部分与平台无关,节点树也可以是任意类型的节点树甚至是一颗渲染无关的树。不同平台的渲染机制不同,所以 Compose UI 与平台相关。 我们只要在 Compoe UI 这一层,针对不同平台实现自己的 Node Tree 和对应的 Applier,就可以在 Compose Runtime 的驱动下实现 UI 的声明式开发。


Compose for Android View


基于这一结论,我们做一个实验:使用 Compose Runtime 驱动 Android 原生 View 的渲染。


我们首先定义一个基于 View 类型节点的 Applier :ViewApplier


class ViewApplier(val view: FrameLayout) : AbstractApplier<View>(view) {
override fun onClear() {
(view as? ViewGroup)?.removeAllViews()
}

override fun insertBottomUp(index: Int, instance: View) {
(current as? ViewGroup)?.addView(instance, index)
}

override fun insertTopDown(index: Int, instance: View) {
}

override fun move(from: Int, to: Int, count: Int) {
// NOT Supported
TODO()
}

override fun remove(index: Int, count: Int) {
(view as? ViewGroup)?.removeViews(index, count)
}
}

然后,我们创建两个 Android View 对应的 Composable,TextView 和 LinearLayout:


@Composable
fun TextView(
text: String,
onClick: () -> Unit = {}
) {
val context = localContext.current
ComposeNode<TextView, ViewApplier>(
factory = {
TextView(context)
},
update = {
set(text) {
this.text = text
}
set(onClick) {
setOnClickListener { onClick() }
}
},
)
}

@Composable
fun LinearLayout(children: @Composable () -> Unit) {
val context = localContext.current
ComposeNode<LinearLayout, ViewApplier>(
factory = {
LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
)
}
},
update = {},
content = children,
)
}

ComposeNode 是 Compose Runtime 提供的 API,用来像 Slot Table 添加一个 Node 信息。Slot Tabl 通过 Applier 创建基于 View 的节点树时,会通过 Node 的 factory 创建对应的 View 节点。


有了上述实验,我们就可以使用 Compose 构建 Android View 了,同时可以通过 Compose 的 SnapshotState 驱动 View 的更新:


@Composable
fun AndroidViewApp() {

var count by remember { mutableStateOf(1) }

LinearLayout {
TextView(
text = "This is the Android TextView!!",
)
repeat(count) {
TextView(
text = "Android View!!TextView:$it $count",
onClick = {
count++
}
)
}

}
}

执行效果如下:


compose_for_view.gif


同样,我们也可以基于 Compose Runtime 为任意平台打造基于 Compose 的声明式 UI 框架。


Compose for Desktop & Web


JetBrains 在 Compose 多平台应用方面进行了很多尝试,并做出了很多成果。JetBrains 基于谷歌 Jetpack Compose 的 fork 相继发布了 Compose for Desktop 以及 Compose for Web。



Compose Desktop 与 Android 同样基于 LayoutNode 的渲染树,通过 Skia 引擎完成跨平台渲染。所以它们在渲染效果以及开发体验上都保持高度一致。Compose Desktop 依靠 Kotlin/JVM 编译成字节码产物,并使用 Jpackage 和 Jlink 打包成不同桌面系统的( Linux/Mac/Windows)的安装包,可以在脱离 JVM 的环境下直接运行。


Compose Web 使用了基于 W3C 标准的 DomNode 作为渲染树节点,在 Compose Runtime 驱动下生成 DOM Tree 。Compose Web 通过 Kotlin/JS 编译成 JavaScript 最终在浏览器中运行和渲染。Compose Web 中预制了更贴近 HTML 风格的 Composable API,所以 UI 代码上与 Android/Desktop 无法直接复用。


通过 compose-jb 官方的例子,感受一下 Desktop & Web 的不同



github.com/JetBrains/c…




上面使用 Compose 在各个平台实现的页面效果,Desktop 和 Android 的渲染效果完全一致,Web 与前两者在现实效果上不同,他们的代码分别如下所示:



Compose Desktop 与 Jetpack Compose 在代码上没有区别,而 Compose Web 使用 Div,Ul 这样与 HTML 标签同名的 Composable,而且使用 style { ...} 这样面向 CSS 的 DSL 替代 Modifier,开发体验更符合前端的习惯。虽然 UI 部分的代码在不同平台有差异,但是在逻辑部分,可以实现完全复用,各平台的 Comopse UI 都使用 component.models.subscribeAsState() 监听状态变化。


Compose for Multiplatform


JetBrains 将 Android,Desktop,Web 三个平台的 Compose 整合成统一 Group Id 的 Kotlin Multiplatform 库,便诞生了 Comopse Multiplatform。



Compose Mutiplatform 作为一个 KM 库,让一个 KMP (Kotlin Multiplatform Project) 中可共享的代码从 Data 层上升到 UI 层以及 UI 相关的 Logic 层。



使用 IntelliJ IDEA 可以创建一个 Compose Multiplatform 工程模版,在结构上与一个普通的 KMP 无异。




  • android/desktop/web 文件夹是各个平台的工程文件,基于 gradle 编译成目标平台的产物。




  • common 文件夹是 KMP 的核心。commonMain 中是完全共享的 Kt 代码,通过 expect/actual 关键字实现平台差异化开发。





我们先在 gradle 中依赖 Comopse Multiplatform 库,之后就可以在 commonMain 中开发共享基于 Compose 的 UI 代码了。Comopse Multiplatform 的各个组件将 Jetpack Compose 对应组件的 Group Id 中的 androidx 前缀替换为 org.jertbrains 前缀:


androidx.compose.runtime -> org.jetbrains.compose.runtime
androidx.compose.material -> org.jetbrains.compose.material
androidx.compose.foundation -> org.jetbrains.compose.foundation

最后



最后,我们来思考一下 Compose for MultiplatformCompose Multiplatform 这两个词的区别?在我看来,Compose Multiplatform 会让家将焦点放在 Multiplatform 上面,自然会拿来与 Flutter 等同类框架作对比。但是通过本文的介绍,大家已经知道了 Compose 并非一个专门为跨平台打造的框架,现阶段它并不追求渲染效果和开发体验完全一致,它的出现更像是 Kotlin 带来的增值服务。


而 Compose for Multiplatfom 的焦点则更应该放在 Compose 上,它表示 Compose 可以服务于更多平台,依托强大的 Compiler 和 Runtime 层,我们可以为更多平台打造声明式框架。扩大 Kotlin 的应用场景和 Kotlin 开发者的能力边界。希望今后再提到 Compose 跨平台式,大家可以多从 Compose for Multiplatform 的角度去看待他的意义和价值。


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

Suspend函数与回调的互相转换

前言 我们再来一期关于kotlin协程的故事,我们都知道在Coroutine没有出来之前,我们对于异步结果的处理都是采用回调的方式进行,一方面回调层次过多的话,容易导致“回调地狱”,另一方法也比较难以维护。当然,我们并不是否定了回调本身,回调本身同时也是具备很...
继续阅读 »

前言


我们再来一期关于kotlin协程的故事,我们都知道在Coroutine没有出来之前,我们对于异步结果的处理都是采用回调的方式进行,一方面回调层次过多的话,容易导致“回调地狱”,另一方法也比较难以维护。当然,我们并不是否定了回调本身,回调本身同时也是具备很多优点的,比如符合代码阅读逻辑,同时回调本身也是比较可控的。这一期呢,我们就是来聊一下,如何把回调的写法变成suspend函数,同时如何把suspend函数变成回调,从而让我们更加了解kotlin协程背后的故事


回调变成suspend函数


来一个回调


我们以一个回调函数作为例子,当我们normalCallBack在一个子线程中做一些处理,比如耗时函数,做完就会通过MyCallBack回调onCallBack,这里返回了一个Int类型,如下:


var myCallBack:MyCallBack?= null
interface MyCallBack{
fun onCallBack(result: Int)
}
fun normalCallBack(){
thread {
// 比如做一些事情
myCallBack?.onCallBack(1)
}
}

转化为suspend函数


此时我们可以通过suspendCoroutine函数,内部其实是通过创建了一个SafeContinuation并放到了我们suspend函数本身(block本身)启动了一个协程,我们之前在聊一聊Kotlin协程"低级"api 这篇文章介绍过


public suspend inline fun <T> suspendCoroutine(crossinline block: (Continuation<T>) -> Unit): T {
contract { callsInPlace(block, InvocationKind.EXACTLY_ONCE) }
return suspendCoroutineUninterceptedOrReturn { c: Continuation<T> ->
val safe = SafeContinuation(c.intercepted())
block(safe)
safe.getOrThrow()
}
}

这时候我们就可以直接写为,从而将回调消除,变成了一个suspend函数。


suspend fun mySuspend() = suspendCoroutine<Int> {
thread {
// 比如做一些事情
it.resume(1)
}
}

当然,如果我们想要支持一下外部取消,比如当前页面销毁时,发起的网络请求自然也就不需要再请求了,就可以通过suspendCancellableCoroutine创建,里面的Continuation对象就从SafeContinuation(见上文)变成了CancellableContinuation,变成了CancellableContinuation有一个invokeOnCancellation方便,支持在协程体被销毁时的逻辑。


public suspend inline fun <T> suspendCancellableCoroutine(
crossinline block: (CancellableContinuation<T>) -> Unit
): T =
suspendCoroutineUninterceptedOrReturn { uCont ->
val cancellable = CancellableContinuationImpl(uCont.intercepted(), resumeMode = MODE_CANCELLABLE)
/*
* For non-atomic cancellation we setup parent-child relationship immediately
* in case when `block` blocks the current thread (e.g. Rx2 with trampoline scheduler), but
* properly supports cancellation.
*/
cancellable.initCancellability()
block(cancellable)
cancellable.getResult()
}

此时我们就可以写出以下代码


suspend fun mySuspend2() = suspendCancellableCoroutine<Int> {
thread {
// 比如做一些事情
it.resume(1)
}
it.invokeOnCancellation {
// 取消逻辑
}
}

suspend函数变成回调


见到了回调如何变成suspend函数,那么我们反过来呢?有没有办法?当然有啦!当时suspend函数中有很多种区分,我们一一区分一下


直接返回的suspend函数
suspend fun myNoSuspendFunc():Int{
return 1
}

调用suspendCoroutine后直接resume的suspend函数
suspend fun myNoSuspendFunc() = suspendCoroutine<Int> {

continuation ->
continuation.resume(1)

}

调用suspendCoroutine后异步执行的suspend函数(这里异步可以是单线程也可以是多线程,跟线程本身无关,只要是异步就会触发挂起)
suspend fun myRealSuspendFunc() = suspendCoroutine<Int> {
thread {
Thread.sleep(300)
it.resume(2)
}

那么我们来想一下,这里真正发起挂起的函数是哪个?通过代码其实我们可以猜到,真正挂起的函数只有最后一个myRealSuspendFunc,其他都不是真正的挂起,这里的挂起是什么意思呢?我们从协程的状态就可以知道,当前处于CoroutineSingletons.COROUTINE_SUSPENDED时,就是挂起状态。我们回归一下,一个suspend函数有哪几种情况


image.png


这里的1,2,3就分别对应着上文demo中的例子



  1. 直接返回结果,不需要进入状态机判断,因为本身就没有启动协程

  2. 进入了协程,但是不需要进行SUSPEND状态就已经有了结果,所以直接返回了结果

  3. 进入了SUSPEND状态,之后才能获取结果


这里我们就不贴出来源码了,感兴趣可自己看Coroutine的实现,这里我们要明确一个概念,一个Suspend函数的运行机制,其实并不依靠了协程本身。


对应代码表现就是,这个函数的返回结果可能就是直接返回结果本身,另一种就是通过回调本身通知外部(这里我们还会以例子说明)


suspend函数转换为回调


这里有两种情况,我们分别以kotlin代码跟java代码表示:


kotlin代码


由于kotlin可以直接通过suspend的扩展函数startCoroutine启动一个协程,


fun myRealSuspendCallBack(){
::myRealSuspendFunc.startCoroutine(object :Continuation<Int>{
当前环境
override val context: CoroutineContext

get() = Dispatchers.IO
结果
override fun resumeWith(result: Result<Int>) {

if(result.isSuccess){
myCallBack?.onCallBack(result.getOrDefault(0))
}

}
})
}

其中Result就是一个内联类,属于kotlin编译器添加的装饰类,在这里我们无论是1,2,3的情况,都可以在resumeWith 中获取到结果,在这里通过callback回调即可


@JvmInline
public value class Result<out T> @PublishedApi internal constructor(
@PublishedApi
internal val value: Any?
) : Serializable {

Java代码


这里我们更正一个误区,就是suspend函数只能在kotlin中使用/Coroutine协程只能在kotlin中使用,这个其实是错误的,java代码也能够调起协程,只不过麻烦了一点,至少官方是没有禁止的。
比如我们需要调用startCoroutine,可直接调用


ContinuationKt.startCoroutine();

当然,我们也能够直接调用suspend函数


Object result = CallBack.INSTANCE.myRealSuspendFunc(new Continuation<Integer>() {
@NonNull
@Override
public CoroutineContext getContext() {
这里启动的环境其实协程没有用到,读者们可以思考一下为什么!这里就当一个谜题啦!可以在评论区说出你的想法(我会在评论区解答)
return (CoroutineContext) Dispatchers.getIO();
//return EmptyCoroutineContext.INSTANCE;

}

@Override
public void resumeWith(@NonNull Object o) {
情况3
Log.e("hello","resumeWith result is "+ o +" is main "+ (Looper.myLooper() == Looper.getMainLooper()));

// 回调处理即可
myCallBack?.onCallBack(result)
}
});

if(result != IntrinsicsKt.getCOROUTINE_SUSPENDED()){
情况1,2
Log.e("hello","func result is "+ result);
// 回调处理即可
myCallBack?.onCallBack(result)
}

这里我们需要注意的是,这里java代码比kotlin多了一个判断,同时resumeWith的参数不再是Result,而是一个Object


if(result != IntrinsicsKt.getCOROUTINE_SUSPENDED()){
}

这里脱去了kotlin给我们添加的各种外壳,其实这就是真正的对于suspend结果的处理(只不过kotlin帮我们包了一层)


我们上文说过,suspend函数对应的三种情况,这里的1,2都是直接返回结果的,因为没有走到SUSPEND状态(IntrinsicsKt.getCOROUTINE_SUSPENDED())这里需要读者好好阅读上文,因此
result != IntrinsicsKt.getCOROUTINE_SUSPENDED(),就会直接走到这里,我们就直接拿到了结果


if(result != IntrinsicsKt.getCOROUTINE_SUSPENDED()){
}

如果属于情况3,那么这里的result就不再是一个结果,而是当前协程的状态标记罢了,此时当协程完成执行的时候(调用resume的时候),就会回调到resumeWith,这里的Object类型o才是经过SUSPEND状态的结果!


总结


经过我们suspend跟回调的互相状态,能够明白了suspend背后的逻辑与挂起的细节,希望能帮到你!最后本篇还留下了一个小谜题,可以发挥你的理解在评论区说出你的想法!笔者之后会在评论区解答!


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

Android 线上卡顿监控

1. 卡顿与ANR的关系 卡顿是UI没有及时的按照用户的预期进行反馈,没有及时地渲染出来,从而看起来不连续、不一致。产生卡顿的原因太多了,很难一一列举,但ANR是Google人为规定的概念,产生ANR的原因最多只有4个。分别是: Service Timeou...
继续阅读 »

1. 卡顿与ANR的关系


卡顿是UI没有及时的按照用户的预期进行反馈,没有及时地渲染出来,从而看起来不连续、不一致。产生卡顿的原因太多了,很难一一列举,但ANR是Google人为规定的概念,产生ANR的原因最多只有4个。分别是:



  • Service Timeout:比如前台服务在20s内未执行完成,后台服务Timeout时间是前台服务的10倍,200s;

  • BroadcastQueue Timeout:比如前台广播在10s内未执行完成,后台60s

  • ContentProvider Timeout:内容提供者,在publish过超时10s;

  • InputDispatching Timeout: 输入事件分发超时5s,包括按键和触摸事件。


假如我在一个button的onClick事件中,有一个耗时操作,这个耗时操作的时间是10秒,但这个耗时操作并不会引发ANR,它只是一次卡顿。


一方面,两者息息相关,长时间的UI卡顿是导致ANR的最常见的原因;但另一方面,从原理上来看,两者既不充分也不必要,是两个纬度的概念。


市面上的一些卡顿监控工具,经常被用来监控ANR(卡顿阈值设置为5秒),这其实很不严谨:首先,5秒只是发生ANR的其中一种原因(Touch事件5秒未被及时消费)的阈值,而其他原因发生ANR的阈值并不是5秒;另外,就算是主线程卡顿了5秒,如果用户没有输入任何的Touch事件,同样不会发生ANR,更何况还有后台ANR等情况。真正意义上的ANR监控方案应该是类似matrix里面那样监控signal信号才算。


2. 卡顿原理


主线程从ActivityThread的main方法开始,准备好主线程的looper,启动loop循环。在loop循环内,无消息则利用epoll机制阻塞,有消息则处理消息。因为主线程一直在loop循环中,所以要想在主线程执行什么逻辑,则必须发个消息给主线程的looper然后由这个loop循环触发,由它来分发消息,然后交给msg的target(Handler)处理。举个例子:ActivityThread.H。


public static void loop() {
......
for (;;) {
Message msg = queue.next(); // might block
......
msg.target.dispatchMessage(msg);
}
}

loop循环中可能导致卡顿的地方有2个:



  1. queue.next() :有消息就返回,无消息则使用epoll机制阻塞(nativePollOnce里面),不会使主线程卡顿。

  2. dispatchMessage耗时太久:也就是Handler处理消息,app卡顿的话大多数情况下可以认为是这里处理消息太耗时了


3. 卡顿监控



  • 方案1:WatchDog,往主线程发消息,然后延迟看该消息是否被处理,从而得出主线程是否卡顿的依据。

  • 方案2:利用loop循环时的消息分发前后的日志打印(matrix使用了这个)


3.1 WatchDog


开启一个子线程,死循环往主线程发消息,发完消息后等待5秒,判断该消息是否被执行,没被执行则主线程发生ANR,此时去获取主线程堆栈。



  • 优点:简单,稳定,结果论,可以监控到各种类型的卡顿

  • 缺点:轮询不优雅,不环保,有不确定性,随机漏报


轮询的时间间隔越小,对性能的负面影响就越大,而时间间隔选择的越大,漏报的可能性也就越大。



  • UI线程要不断处理我们发送的Message,必然会影响性能和功耗

  • 随机漏报:ANRWatchDog默认的轮询时间间隔为5秒,当主线程卡顿了2秒之后,ANRWatchDog的那个子线程才开始往主线程发送消息,并且主线程在3秒之后不卡顿了,此时主线程已经卡顿了5秒了,子线程发送的那个消息也随之得到执行,等子线程睡5秒起床的时候发现消息已经被执行了,它没意识到主线程刚刚发生了卡顿。


假设将间隔时间改为


改进:



  • 监控到发生ANR时,除了获取主线程堆栈,再获取一下CPU、内存占用等信息

  • 还可结合ProcessLifecycleOwner,app在前台才开启检测,在后台停止检测


另外有些方案的思路,如果我们不断缩小轮询的时间间隔,用更短的轮询时间,连续几个周期消息都没被处理才视为一次卡顿。则更容易监控到卡顿,但对性能损耗大一些。即使是缩小轮询时间间隔,也不一定能监控到。假设每2秒轮询一次,如果连续三次没被处理,则认为发生了卡顿。在02秒之间主线程开始发生卡顿,在第2秒时开始往主线程发消息,这样在到达次数,也就是8秒时结束,但主线程的卡顿在68秒之间就刚好结束了,此时子线程在第8秒时醒来发现消息已经被执行了,它没意识到主线程刚刚发生了卡顿。


3.2 Looper Printer


替换主线程Looper的Printer,监控dispatchMessage的执行时间(大部分主线程的操作最终都会执行到这个dispatchMessage中)。这种方案在微信上有较大规模使用,总体来说性能不是很差,matrix目前的EvilMethodTracer和AnrTracer就是用这个来实现的。



  • 优点:不会随机漏报,无需轮询,一劳永逸

  • 缺点:某些类型的卡顿无法被监控到,但有相应解决方案


queue.next()可能会阻塞,这种情况下监控不到。


//Looper.java
for (;;) {
//这里可能会block,Printer无法监控到next里面发生的卡顿
Message msg = queue.next(); // might block

// This must be in a local variable, in case a UI event sets the logger
final Printer logging = me.mLogging;
if (logging != null) {
logging.println(">>>>> Dispatching to " + msg.target + " " +
msg.callback + ": " + msg.what);
}

msg.target.dispatchMessage(msg);

if (logging != null) {
logging.println("<<<<< Finished to " + msg.target + " " + msg.callback);
}
}

//MessageQueue.java
for (;;) {
if (nextPollTimeoutMillis != 0) {
Binder.flushPendingCommands();
}

nativePollOnce(ptr, nextPollTimeoutMillis);

//......

// Run the idle handlers.
// We only ever reach this code block during the first iteration.
for (int i = 0; i < pendingIdleHandlerCount; i++) {
final IdleHandler idler = mPendingIdleHandlers[i];
mPendingIdleHandlers[i] = null; // release the reference to the handler

boolean keep = false;
try {
//IdleHandler的queueIdle,如果Looper是主线程,那么这里明显是在主线程执行的,虽然现在主线程空闲,但也不能做耗时操作
keep = idler.queueIdle();
} catch (Throwable t) {
Log.wtf(TAG, "IdleHandler threw exception", t);
}

if (!keep) {
synchronized (this) {
mIdleHandlers.remove(idler);
}
}
}
//......
}


  1. 主线程空闲时会阻塞next(),具体是阻塞在nativePollOnce(),这种情况下无需监控

  2. Touch事件大部分是从nativePollOnce直接到了InputEventReceiver,然后到ViewRootImpl进行分发

  3. IdleHandler的queueIdle()回调方法也无法监控到

  4. 还有一类相对少见的问题是SyncBarrier(同步屏障)的泄漏同样无法被监控到


第一种情况我们不用管,接下来看一下后面3种情况下如何监控卡顿。


3.2.1 监控TouchEvent卡顿


首先,Touch是怎么传递到Activity的?给一个view设置一个OnTouchListener,然后看一些Touch的调用栈。


com.xfhy.watchsignaldemo.MainActivity.onCreate$lambda-0(MainActivity.kt:31)
com.xfhy.watchsignaldemo.MainActivity.$r8$lambda$f2Bz7skgRCh8TKh1SZX03s91UhA(Unknown Source:0)
com.xfhy.watchsignaldemo.MainActivity$$ExternalSyntheticLambda0.onTouch(Unknown Source:0)
android.view.View.dispatchTouchEvent(View.java:13695)
android.view.ViewGroup.dispatchTransformedTouchEvent(ViewGroup.java:3249)
android.view.ViewGroup.dispatchTouchEvent(ViewGroup.java:2881)
android.view.ViewGroup.dispatchTransformedTouchEvent(ViewGroup.java:3249)
android.view.ViewGroup.dispatchTouchEvent(ViewGroup.java:2881)
android.view.ViewGroup.dispatchTransformedTouchEvent(ViewGroup.java:3249)
android.view.ViewGroup.dispatchTouchEvent(ViewGroup.java:2881)
android.view.ViewGroup.dispatchTransformedTouchEvent(ViewGroup.java:3249)
android.view.ViewGroup.dispatchTouchEvent(ViewGroup.java:2881)
android.view.ViewGroup.dispatchTransformedTouchEvent(ViewGroup.java:3249)
android.view.ViewGroup.dispatchTouchEvent(ViewGroup.java:2881)
android.view.ViewGroup.dispatchTransformedTouchEvent(ViewGroup.java:3249)
android.view.ViewGroup.dispatchTouchEvent(ViewGroup.java:2881)
com.android.internal.policy.DecorView.superDispatchTouchEvent(DecorView.java:741)
com.android.internal.policy.PhoneWindow.superDispatchTouchEvent(PhoneWindow.java:2013)
android.app.Activity.dispatchTouchEvent(Activity.java:4180)
androidx.appcompat.view.WindowCallbackWrapper.dispatchTouchEvent(WindowCallbackWrapper.java:70)
com.android.internal.policy.DecorView.dispatchTouchEvent(DecorView.java:687)
android.view.View.dispatchPointerEvent(View.java:13962)
android.view.ViewRootImpl$ViewPostImeInputStage.processPointerEvent(ViewRootImpl.java:6420)
android.view.ViewRootImpl$ViewPostImeInputStage.onProcess(ViewRootImpl.java:6215)
android.view.ViewRootImpl$InputStage.deliver(ViewRootImpl.java:5604)
android.view.ViewRootImpl$InputStage.onDeliverToNext(ViewRootImpl.java:5657)
android.view.ViewRootImpl$InputStage.forward(ViewRootImpl.java:5623)
android.view.ViewRootImpl$AsyncInputStage.forward(ViewRootImpl.java:5781)
android.view.ViewRootImpl$InputStage.apply(ViewRootImpl.java:5631)
android.view.ViewRootImpl$AsyncInputStage.apply(ViewRootImpl.java:5838)
android.view.ViewRootImpl$InputStage.deliver(ViewRootImpl.java:5604)
android.view.ViewRootImpl$InputStage.onDeliverToNext(ViewRootImpl.java:5657)
android.view.ViewRootImpl$InputStage.forward(ViewRootImpl.java:5623)
android.view.ViewRootImpl$InputStage.apply(ViewRootImpl.java:5631)
android.view.ViewRootImpl$InputStage.deliver(ViewRootImpl.java:5604)
android.view.ViewRootImpl.deliverInputEvent(ViewRootImpl.java:8701)
android.view.ViewRootImpl.doProcessInputEvents(ViewRootImpl.java:8621)
android.view.ViewRootImpl.enqueueInputEvent(ViewRootImpl.java:8574)
android.view.ViewRootImpl$WindowInputEventReceiver.onInputEvent(ViewRootImpl.java:8959)
android.view.InputEventReceiver.dispatchInputEvent(InputEventReceiver.java:239)
android.os.MessageQueue.nativePollOnce(Native Method)
android.os.MessageQueue.next(MessageQueue.java:363)
android.os.Looper.loop(Looper.java:176)
android.app.ActivityThread.main(ActivityThread.java:8668)
java.lang.reflect.Method.invoke(Native Method)
com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:513)
com.android.internal.os.ZygoteInit.main(ZygoteInit.java:1109)

当有触摸事件时,nativePollOnce()会收到消息,然后会从native层直接调用InputEventReceiver.dispatchInputEvent()。


public abstract class InputEventReceiver {
public InputEventReceiver(InputChannel inputChannel, Looper looper) {
if (inputChannel == null) {
throw new IllegalArgumentException("inputChannel must not be null");
}
if (looper == null) {
throw new IllegalArgumentException("looper must not be null");
}

mInputChannel = inputChannel;
mMessageQueue = looper.getQueue();
//在这里进行的注册,native层会将该实例记录下来,每当有事件到达时就会派发到这个实例上来
mReceiverPtr = nativeInit(new WeakReference<InputEventReceiver>(this),
inputChannel, mMessageQueue);

mCloseGuard.open("dispose");
}

// Called from native code.
@SuppressWarnings("unused")
@UnsupportedAppUsage
private void dispatchInputEvent(int seq, InputEvent event) {
mSeqMap.put(event.getSequenceNumber(), seq);
onInputEvent(event);
}
}

InputReader(读取、拦截、转换输入事件)和InputDispatcher(分发事件)都是运行在system_server系统进程中,而我们的应用程序运行在自己的应用进程中,这里涉及到跨进程通信,这里的跨进程通信用的非binder方式,而是用的socket。


image.png


InputDispatcher会与我们的应用进程建立连接,它是socket的服务端;我们应用进程的native层会有一个socket的客户端,客户端收到消息后,会通知我们应用进程里ViewRootImpl创建的WindowInputEventReceiver(继承自InputEventReceiver)来接收这个输入事件。事件传递也就走通了,后面就是上层的View树事件分发了。


这里为啥用socket而不用binder?Socket可以实现异步的通知,且只需要两个线程参与(Pipe两端各一个),假设系统有N个应用程序,跟输入处理相关的线程数目是N+1(1是Input Dispatcher线程)。然而,如果用Binder实现的话,为了实现异步接收,每个应用程序需要两个线程,一个Binder线程,一个后台处理线程(不能在Binder线程里处理输入,因为这样太耗时,将会阻塞住发射端的调用线程)。在发射端,同样需要两个线程,一个发送线程,一个接收线程来接收应用的完成通知,所以,N个应用程序需要2(N+1)个线程。相比之下,Socket还是高效多了。


//frameworks/native/services/inputflinger/InputDispatcher.cpp
void InputDispatcher::startDispatchCycleLocked(nsecs_t currentTime,
const sp<Connection>& connection) {
......
status = connection->inputPublisher.publishKeyEvent(dispatchEntry->seq,
keyEntry->deviceId, keyEntry->source,
dispatchEntry->resolvedAction, dispatchEntry->resolvedFlags,
keyEntry->keyCode, keyEntry->scanCode,
keyEntry->metaState, keyEntry->repeatCount, keyEntry->downTime,
keyEntry->eventTime);
......
}

//frameworks/native/libs/input/InputTransport.cpp
status_t InputPublisher::publishKeyEvent(
uint32_t seq,
int32_t deviceId,
int32_t source,
int32_t action,
int32_t flags,
int32_t keyCode,
int32_t scanCode,
int32_t metaState,
int32_t repeatCount,
nsecs_t downTime,
nsecs_t eventTime) {
......

InputMessage msg;
......
msg.body.key.keyCode = keyCode;
......
return mChannel->sendMessage(&msg);
}

//frameworks/native/libs/input/InputTransport.cpp
//调用 socket 的 send 接口来发送消息
status_t InputChannel::sendMessage(const InputMessage* msg) {
size_t msgLength = msg->size();
ssize_t nWrite;
do {
nWrite = ::send(mFd, msg, msgLength, MSG_DONTWAIT | MSG_NOSIGNAL);
} while (nWrite == -1 && errno == EINTR);
......
}

有了上面的知识铺垫,现在回到我们的主问题上来,如何监控TouchEvent卡顿。既然它们是用socket来进行通信的,那么我们可以通过PLT Hook,去Hook这对socket的发送(send)和接收(recv)方法,从而监控Touch事件。当调用到了recvfrom时(send和recv最终会调用sendto和recvfrom,这2个函数的具体定义在socket.h源码),说明我们的应用接收到了Touch事件,当调用到了sendto时,说明这个Touch事件已经被成功消费掉了,当两者的时间相差过大时即说明产生了一次Touch事件的卡顿。


Touch事件的处理过程


PLT Hook是什么,它是一种native hook,另外还有一种native hook方式是inline hook。PLT hook的优点是稳定性可控,可线上使用,但它只能hook通过PLT表跳转的函数调用,这在一定程度上限制了它的使用场景。


对PLT Hook的具体原理感兴趣的同学可以看一下下面2篇文章:



目前市面上比较流行的PLT Hook开源库主要有2个,一个是爱奇艺开源的xhook,一个是字节跳动开源的bhook。我这里使用xhook来举例,InputDispatcher.cpp最终会被编译成libinput.so具体Android.mk信息看这里)。那我们就直接hook这个libinput.so的sendto和recvfrom函数。


理论知识有了,直接开干:


ssize_t (*original_sendto)(int sockfd, const void *buf, size_t len, int flags,
const struct sockaddr *dest_addr, socklen_t addrlen);
ssize_t my_sendto(int sockfd, const void *buf, size_t len, int flags,
const struct sockaddr *dest_addr, socklen_t addrlen) {
//应用端已消费touch事件
if (getCurrentTime() - lastTime > 5000) {
__android_log_print(ANDROID_LOG_DEBUG, "xfhy_touch", "Touch有点卡顿");
//todo xfhy 在这里调用java去dump主线程堆栈
}
long ret = original_sendto(sockfd, buf, len, flags, dest_addr, addrlen);
return ret;
}

ssize_t (*original_recvfrom)(int sockfd, void *buf, size_t len, int flags,
struct sockaddr *src_addr, socklen_t *addrlen);
ssize_t my_recvfrom(int sockfd, void *buf, size_t len, int flags,
struct sockaddr *src_addr, socklen_t *addrlen) {
//收到touch事件
lastTime = getCurrentTime();
long ret = original_recvfrom(sockfd, buf, len, flags, src_addr, addrlen);
return ret;
}

void Java_com_xfhy_touch_TouchTest_start(JNIEnv *env, jclass clazz) {
xhook_register(".*libinput\\.so$", "__sendto_chk",(void *) my_sendto, (void **) (&original_sendto));
xhook_register(".*libinput\\.so$", "sendto",(void *) my_sendto, (void **) (&original_sendto));
xhook_register(".*libinput\\.so$", "recvfrom",(void *) my_recvfrom, (void **) (&original_recvfrom));
}

上面这个是我写的demo,完整代码看这里,这个demo肯定是不够完善的。但方案是可行的。完善的方案请看matrix的Touch相关源码


3.2.2 监控IdleHandler卡顿


IdleHandler任务最终会被存储到MessageQueue的mIdleHandlers (一个ArrayList)中,在主线程空闲时,也就是MessageQueue的next方法暂时没有message可以取出来用时,会从mIdleHandlers 中取出IdleHandler任务进行执行。那我们可以把这个mIdleHandlers 替换成自己的,重写add方法,添加进来的 IdleHandler 给它包装一下,包装的那个类在执行 queueIdle 时进行计时,这样添加进来的每个IdleHandler在执行的时候我们都能拿到其 queueIdle 的执行时间 。如果超时我们就进行记录或者上报。


fun startDetection() {
val messageQueue = mHandler.looper.queue
val messageQueueJavaClass = messageQueue.javaClass
val mIdleHandlersField = messageQueueJavaClass.getDeclaredField("mIdleHandlers")
mIdleHandlersField.isAccessible = true

//虽然mIdleHandlers在Android Q以上被标记为UnsupportedAppUsage,但居然可以成功设置. 只有在反射访问mIdleHandlers时,才会触发系统的限制
mIdleHandlersField.set(messageQueue, MyArrayList())
}
class MyArrayList : ArrayList<IdleHandler>() {

private val handlerThread by lazy {
HandlerThread("").apply {
start()
}
}
private val threadHandler by lazy {
Handler(handlerThread.looper)
}

override fun add(element: IdleHandler): Boolean {
return super.add(MyIdleHandler(element, threadHandler))
}

}
class MyIdleHandler(private val originIdleHandler: IdleHandler, private val threadHandler: Handler) : IdleHandler {

override fun queueIdle(): Boolean {
log("开始执行idleHandler")

//1. 延迟发送Runnable,Runnable收集主线程堆栈信息
val runnable = {
log("idleHandler卡顿 \n ${getMainThreadStackTrace()}")
}
threadHandler.postDelayed(runnable, 2000)
val result = originIdleHandler.queueIdle()
//2. idleHandler如果及时完成,那么就移除Runnable。如果上面的Runnable得到执行,说明主线程的idleHandler已经执行了2秒还没执行完,可以收集信息,对照着检查一下代码了
threadHandler.removeCallbacks(runnable)
return result
}
}

反射完成之后,我们简单添加一个IdleHandler,然后在里面sleep(10000)测试一下,得到结果如下:


2022-10-17 07:33:50.282 28825-28825/com.xfhy.allinone D/xfhy_tag: 开始执行idleHandler
2022-10-17 07:33:52.286 28825-29203/com.xfhy.allinone D/xfhy_tag: idleHandler卡顿
java.lang.Thread.sleep(Native Method)
java.lang.Thread.sleep(Thread.java:443)
java.lang.Thread.sleep(Thread.java:359)
com.xfhy.allinone.actual.idlehandler.WatchIdleHandlerActivity$startTimeConsuming$1.queueIdle(WatchIdleHandlerActivity.kt:47)
com.xfhy.allinone.actual.idlehandler.MyIdleHandler.queueIdle(WatchIdleHandlerActivity.kt:62)
android.os.MessageQueue.next(MessageQueue.java:465)
android.os.Looper.loop(Looper.java:176)
android.app.ActivityThread.main(ActivityThread.java:8668)
java.lang.reflect.Method.invoke(Native Method)
com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:513)
com.android.internal.os.ZygoteInit.main(ZygoteInit.java:1109)

从日志堆栈里面很清晰地看到具体是哪里发生了卡顿。


3.2.3 监控SyncBarrier泄漏


什么是SyncBarrier泄漏?在说这个之前,我们得知道什么是SyncBarrier,它翻译过来叫同步屏障,听起来很牛逼,但实际上就是一个Message,只不过这个Message没有target。没有target,那这个Message拿来有什么用?当MessageQueue中存在SyncBarrier的时候,同步消息就得不到执行,而只会去执行异步消息。我们平时用的Message一般是同步的,异步的Message主要是配合SyncBarrier使用。当需要执行一些高优先级的事情的时候,比如View绘制啥的,就需要往主线程MessageQueue插个SyncBarrier,然后ViewRootlmpl 将mTraversalRunnable 交给 ChoreographerChoreographer 等到下一个VSYNC信号到来时,及时地去执行mTraversalRunnable ,交给Choreographer 之后的部分逻辑优先级是很高的,比如执行mTraversalRunnable 的时候,这种逻辑是放到异步消息里面的。回到ViewRootImpl之后将SyncBarrier移除。



关于同步屏障和Choreographer 的详细逻辑可以看我之前的文章:Handler同步屏障Choreographer原理及应用



@UnsupportedAppUsage
void scheduleTraversals() {
if (!mTraversalScheduled) {
mTraversalScheduled = true;
//插入同步屏障,mTraversalRunnable的优先级很高,我需要及时地去执行它
mTraversalBarrier = mHandler.getLooper().getQueue().postSyncBarrier();
//mTraversalRunnable里面会执行doTraversal
mChoreographer.postCallback(Choreographer.CALLBACK_TRAVERSAL, mTraversalRunnable, null);
notifyRendererOfFramePending();
pokeDrawLockIfNeeded();
}
}

void unscheduleTraversals() {
if (mTraversalScheduled) {
mTraversalScheduled = false;
mHandler.getLooper().getQueue().removeSyncBarrier(mTraversalBarrier);
mChoreographer.removeCallbacks(Choreographer.CALLBACK_TRAVERSAL, mTraversalRunnable, null);
}
}

void doTraversal() {
if (mTraversalScheduled) {
mTraversalScheduled = false;
//移除同步屏障
mHandler.getLooper().getQueue().removeSyncBarrier(mTraversalBarrier);
performTraversals();
}
}

再来说说什么是同步屏障泄露:我们看到在一开始的时候scheduleTraversals里面插入了一个同步屏障,这时只能执行异步消息了,不能执行同步消息。假设出现了某种状况,让这个同步屏障无法被移除,那么消息队列中就一直执行不到同步消息,可能导致主线程假死,你想想,主线程里面同步消息都执行不了了,那岂不是要完蛋。那什么情况下会导致出现上面的异常情况?



  1. scheduleTraversals线程不安全,万一不小心post了多个同步屏障,但只移除了最后一个,那有的同步屏障没被移除的话,同步消息无法执行

  2. scheduleTraversals中post了同步屏障之后,假设某些操作不小心把异步消息给移除了,导致没有移除该同步屏障,也会造成同样的悲剧


问题找到了,怎么解决?有什么好办法能监控到这种情况吗(虽然这种情况比较少见)?微信的同学给出了一种方案,我简单描述下:



  1. 开个子线程,轮询检查主线程的MessageQueue里面的message,检查是否有同步屏障消息的when已经过去了很久了,但还没得到移除

  2. 此时可以合理怀疑该同步屏障消息可能已泄露,但还不能确定(有可能是主线程卡顿,导致没有及时移除)

  3. 这个时候,往主线程发一个同步消息和一个异步消息(可以间隔地多发几次,增加可信度),如果同步消息没有得到执行,但异步消息得到执行了,这说明什么?说明主线程有处理消息的能力,不卡顿,且主线程的MessageQueue中有一个同步屏障一直没得到移除,所以同步消息才没得到执行,而异步消息得到执行了。

  4. 此时,可以激进一点,把这个泄露的同步泄露消息给移除掉。


下面是此方案的核心代码,完整源码在这里


override fun run() {
while (!isInterrupted) {
val messageHead = mMessagesField.get(mainThreadMessageQueue) as? Message
messageHead?.let { message ->
//该消息为同步屏障 && 该消息3秒没得到执行,先怀疑该同步屏障发生了泄露
if (message.target == null && message.`when` - SystemClock.uptimeMillis() < -3000) {
//查看MessageQueue#postSyncBarrier(long when)源码得知,同步屏障message的arg1会携带token,
// 该token类似于同步屏障的序号,每个同步屏障的token是不同的,可以根据该token唯一标识一个同步屏障
val token = message.arg1
startCheckLeaking(token)
}
}
sleep(2000)
}
}

private fun startCheckLeaking(token: Int) {
var checkCount = 0
barrierCount = 0
while (checkCount < 5) {
checkCount++
//1. 判断该token对应的同步屏障是否还存在,不存在就退出循环
if (isSyncBarrierNotExist(token)) {
break
}
//2. 存在的话,发1条异步消息给主线程Handler,再发1条同步消息给主线程Handler,
// 看一下同步消息是否得到了处理,如果同步消息发了几次都没处理,而异步消息则发了几次都被处理了,说明SyncBarrier泄露了
if (detectSyncBarrierOnce()) {
//发生了SyncBarrier泄露
//3. 如果有泄露,那么就移除该泄露了的同步屏障(反射调用MessageQueue的removeSyncBarrier(int token))
removeSyncBarrier(token)
break
}
SystemClock.sleep(1000)
}
}

private fun detectSyncBarrierOnce(): Boolean {
val handler = object : Handler(Looper.getMainLooper()) {
override fun handleMessage(msg: Message) {
super.handleMessage(msg)
when (msg.arg1) {
-1 -> {
//异步消息
barrierCount++
}
0 -> {
//同步消息 说明主线程的同步消息是能做事的啊,就没有SyncBarrier一说了
barrierCount = 0
}
else -> {}
}
}
}

val asyncMessage = Message.obtain()
asyncMessage.isAsynchronous = true
asyncMessage.arg1 = -1

val syncMessage = Message.obtain()
syncMessage.arg1 = 0

handler.sendMessage(asyncMessage)
handler.sendMessage(syncMessage)

//超过3次,主线程的同步消息还没被处理,而异步消息缺得到了处理,说明确实是发生了SyncBarrier泄露
return barrierCount > 3
}

4. 小结


文中详细介绍了卡顿与ANR的关系,以及卡顿原理和卡顿监控,详细捋下来可对卡顿有更深的理解。对于Looper Printer方案来说,是比较完善的,而且微信也在使用此方案,该踩的坑也踩完了。


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

日常思考,目前Kotlin协程能完全取代Rxjava吗

前言 自从jetbrains公司提出Kotlin协程用来解决异步线程问题,并且衍生出来了Flow作为响应式框架,引来了大量Android开发者的青睐;而目前比较稳定的响应式库当属Rxjava,这样以来目的就很明显了,旨在用Kotlin协程来逐步替代掉Rxjav...
继续阅读 »

前言


自从jetbrains公司提出Kotlin协程用来解决异步线程问题,并且衍生出来了Flow作为响应式框架,引来了大量Android开发者的青睐;而目前比较稳定的响应式库当属Rxjava,这样以来目的就很明显了,旨在用Kotlin协程来逐步替代掉Rxjava;


仔细思考下,真的可以完全替代掉Rxjava么,它的复杂性和多样化的操作符,而协程的许多API仍然是实验性的,目前为止,随着kt不断地进行版本迭代,越来越趋于稳定,对此我不能妄下断言;当然Rxjava无疑也是一个非常优秀的框架,值得我们不断深入思考,但是随着协程的出现,就个人而言我会更喜欢使用协程来作为满足日常开发的异步解决方案。



协程的本质和Rxjava是截然不同的,所以直接拿它们进行对比是比较棘手的;换一种思路,本文我们从日常开发中的异步问题出发,分别观察协程与Rxjava是如何提供相应的解决方案,依次来进行比对,探讨下 Kotlin协程是否真的足以取代Rxjava 这个话题吧



流类型的比较


现在我们来看下Rxjava提供的流类型有哪些,我们可以使用的基本流类型操作符如下图所示


Rxjava流类型@2x.png


它们的基本实现在下文会提及到,这里我们简单来讨论下在协程中是怎么定义这些流操作符的




  • Single<T>其实就是一个返回不可空值的suspend函数




  • Maybe<T>恰好相反,是一个返回可空的supspend函数




  • Completable不会发送事件,所以在协程中就是一个不返回任何东西的简单挂起函数




  • 对于ObservableFlowable,两者都可以发射多个事件,不同在于前者是没有背压管理的,后者才有,而他们在协程中我们可以直接使用Flow来完成,在异步数据流中按顺序发出值,所以只需要一个返回当前Data数据类型的Flow<T>



    值得注意的是,该函数本身是不需要supsend修饰符的,由于Flow是冷流,在进行收集\订阅之前是不会发射数据,只要在collect的时候才需要协程作用域中执行。为什么说Flow足以替代ObservableFlowable原因在与它处理背压(backpressure)的方式。这自然而然来源于协程中的设计与理念,不需要一些巧妙设计的解决方案来处理显示背压,Flow中所有Api基本上都带有suspend修复符,它也成为了解决背压的关键先生。其目的就是在不阻塞线程的情况下暂停调用者的执行,因此,当Flow<T>在同一个协程中发射和收集的时候,如果收集器跟不上数据流,它可以简单地暂停元素的发射,直到它准备好接收更多。





流类型比较的基本实现


好的小伙伴们,上文我们简单用协程写出Rxjava的几个基本流类型,现在让我们用几个详细的实例来看看他们的不同之处吧


Completable ---- 异步任务完成没有结果,可能会抛出错误

Rxjava中,我们使用Completable.create去创建,里面的CompletableEmitter中有onComplete表示完成的方法和一个onError传递异常的方法,如下代码所示


//completable in Rxjava
   fun completableRequest(): Completable {
       return Completable.create { emitter->
           try {
               emitter.onComplete()
          }catch (e:Exception) {
               emitter.onError(e)
          }
      }
  }
   fun main() {
       completableRequest()
          .subscribe {
               println("I,am done")
               println()
          }
  }

在协程当中,我们对应的就是调用一个不返回任何内容的挂起函数(returns Unit),就类似于我们调用一个普通函数一样


 fun completableCoroutine() = runBlocking {
       try {
           delay(500L)
           println("I am done")
      } catch (e: Exception) {
           println("Got an exception")
      }
  }


注意不要在生产环境代码使用runBlocking,你应该有一个合适的CoroutineScope,由于是测试代码本文都将使用runBlocking来辅助说明测试场景



Single ---- 必须返回或抛出错误的异步任务

RxJava 中,我们使用一个Single ,它里面有一个onSuccess传递返回值的方法和一个onError传递异常的方法。


```kotlin
/**
* Single in RxJava
*/
fun main() {
   singleResult()
      .subscribe(
          { result -> println(result) },
          { println("Got an exception") }
      )
}

fun singleResult(): Single<String> {
   return Single.create { emitter ->
       try {
           // process a request
           emitter.onSuccess("Some result")
      } catch (e: Exception) {
           emitter.onError(e)
      }
  }

```

而在协程中,我们调用一个返回非空值的挂起函数:


/**
* Single equivalent in coroutines
*/
fun main() = runBlocking {
   try {
       val result = getResult()
       println(result)
  } catch (e: Exception) {
       println("Got an exception")
  }
}

suspend fun getResult(): String {
   // process a request
   delay(100)
   return "Some result"
}

Maybe --- 可能返回结果或抛出错误的异步任务

RxJava 中,我们使用一个Maybe. 它里面有一个onSuccess传递返回值的方法onComplete,一个在没有值的情况下发出完成信号的方法,以及一个onError传递异常的方法。


/**
* Maybe in RxJava
*/
fun main() {
   maybeResult()
      .subscribe(
          { result -> println(result) },
          { println("Got an exception") },
          { println("Completed without a value!") }
      )
}

fun maybeResult(): Maybe<String> {
   return Maybe.create { emitter ->
       try {
           // process a request
           if (Random.nextBoolean()) {
               emitter.onSuccess("Some value")
          } else {
               emitter.onComplete()
          }
      } catch (e: Exception) {
           emitter.onError(e)
      }
  }
}

在协程中,我们调用一个返回可空值得挂起函数


/**
* Maybe equivalent in coroutines
*/
fun main() = runBlocking {
   try {
       val result = getNullableResult()
       if (result != null) {
           println(result)
      } else {
           println("Completed without a value!")
      }
  } catch (e: Exception) {
       println("Got an exception")
  }
}

suspend fun getNullableResult(): String? {
   // process a request
   delay(100)
   return if (Random.nextBoolean()) {
       "Some value"
  } else {
       null
  }
}

0..N事件的异步流

由于在Rxjava中,FlowableObservable都是属于0..N事件的异步流,但是Observable几乎没有做相应的背压管理,所以这里我们主要以Flowable为例子,onNext发出下一个流值的方法,一个onComplete表示流完成的方法,以及一个onError传递异常的方法。


/**
* Flowable in RxJava
*/
fun main() {
   flowableValues()
      .subscribe(
          { value -> println(value) },
          { println("Got an exception") },
          { println("I'm done") }
      )
}

fun flowableValues(): Flowable<Int> {
   val flowableEmitter = { emitter: FlowableEmitter<Int> ->
       try {
           for (i in 1..10) {
               emitter.onNext(i)
          }
      } catch (e: Exception) {
           emitter.onError(e)
      } finally {
           emitter.onComplete()
      }
  }

   return Flowable.create(flowableEmitter, BackpressureStrategy.BUFFER)
}

在协程中,我们只是创建一个Flow就可以完成这个方法


/**
* Flow in Kotlin
*/
fun main() = runBlocking {
   try {
       eventFlow().collect { value ->
           println(value)
      }
       println("I'm done")
  } catch (e: Exception) {
       println("Got an exception")
  }
}

fun eventFlow() = flow {
   for (i in 1..10) {
       emit(i)
  }
}


在惯用的 Kotlin 中,创建上述流程的方法之一是:fun eventFlow() = (1..10).asFlow()



如上面这些代码所见,我们基本可以使用协程涵盖Rxjava所有的主要基本用法,此外,协程的设计允许我们使用所有标准的Kotlin功能编写典型的顺序代码 ,它还消除了对onCompleteonError回调的需要。我们可以像在普通代码中那样捕获错误或设置协程异常处理程序。并且,考虑到当挂起函数完成时,协程继续按顺序执行,我们可以在下一行继续编写我们的“完成逻辑”。


值得注意的是,当我们进行调用collect收集的时候也是如此,在收集完所有元素后才会执行下一行代码


eventFlow().collect { value ->
   println(value)
}
println("I'm done")


Flow收集完所有元素后,才会调用打印I'm done



操作符的比较


总所周知,Rxjava的主要优势在于它拥有非常多的操作符,基本上可以应对日常开发中出现的各种情况,由于它种类特别繁多又比较难记忆,这里我只简单举些常见的操作符进行比较


COMPLETABLE,SINGLE, MAYBE


这里需要强调的是,在RxjavaCompletable,SingleMaybe都有许多相同的操作符,然而在协程中任何类型的操作符其实都是多余的,我们以Single中的map()简单操作符为例来看下:


/**
* Maps Single<String> to
* Single<User> synchronously
*/
fun main() {
   getUsername()
      .map { username ->
           User(username)
      }
      .subscribe(
          { user -> println(user) },
          { println("Got an exception") }
      )
}

map作为Rxjava中最常用的操作符,获取一个值并将其转换为另一个值,但是在协程中我们不需要.map()操作符就可以实现这种操作


fun main() = runBlocking {
   try {
       val username = getUsername() // suspend fun
       val user = User(username)
       println(user)
  } catch (e: Exception) {
       println("Got an exception")
  }
}

使用suspend挂起函数可以挂起当前函数,当执行完毕后在按顺序执行接下来的代码


Flow操作符与Rxjava操作符


现在让我们看看Flow中有哪些操作符,它们与Rxjava相比有什么不同,由于篇幅原因,这里我简单比较下日常开发中最常用的操作符


map()

对于map操作符,Flow中也具有相同的操作符


/**
* Maps Flow<String> to Flow<User>
*/
fun main() = runBlocking {
   usernameFlow()
      .map { username ->
           User(username)
      }
      .collect { user ->
           println(user)
      }
}

Flow中的map操作符 相当于Rxjava做了一定的简化处理,这是它的一个主要优势,可以看下它的源码


fun <T, R> Flow<T>.map(transform: suspend (T) -> R): Flow<R> = flow {
   collect { value -> emit(transform(value)) }
}

是不是非常简单,只是重新创建一个新的flow,它从从上游收集值transform并在当前函数应用后发出这些值;事实上大多数Flow的操作符都是这样工作的,不需要遵循严格的协议;对于大多数应用场景,标准Flow操作符就已经足够了,当然编写自定义操作符也是非常简单容易的;相对于Rxjava,如果想要编写自定义操作符,你必须非常了解Rxjava


Reactive Streams协议


flatmap()

另外,在Rxjava中我们经常使用的操作符还有flatmap(),同时还有很多种变体,例如.flatMapSingle()flatMapObservable(),flatMapIterable()等,简单来说,在Rxjava中我们如果需要对一个值进行同步转换,就使用map,进行异步转换的时候就需要使用flatMap();对此,Flow进行同步或者异步转换的时候不需要不同的操作符,仅仅使用map就足够了,由于它们都有supsend挂起函数进行修饰,不用担心同步性


可以看下在Rxjava中的示例


fun compareFlatMap() {
   getUsernames() //Flowable<String>
      .flatMapSingle { username ->
           getUserFromNetwork(username) // Single<User>
      }
      .subscribe(
          { user -> println(user) },
          { println("Got an exception") }
      )
}

好的,我们使用Flow来转换下上述的这一段代码,只需要使用map就可以以任何方式进行转换值,如下代码所示:


    runBlocking {
       flow {
           emit(User("Jacky"))
      }.map {
           getUserFromName(it) //suspend
      }.collect {
           println(it)
      }
  }

   suspend fun getUserFromName(user: User): String {
       return user.userName
  }

实际上使用Flow中的map操作符,就可以将上游流发出的值转换为新流,然后将所有流扁平化为一个,这和flatMap的功能几乎可以达到同样的效果


filter()

对于filter操作符,我们在Rxjava中并没有直接的方法进行异步过滤,这需要我们自己编写代码来进行过滤判断,如下所示


fun getUsernames(): Flowable<String> {
   val flowableEmitter = { emitter: FlowableEmitter<String> ->
       emitter.onNext("Jacky")
  }
   return Flowable.create(flowableEmitter, BackpressureStrategy.BUFFER)
}

fun isCorrectUserName(userName: String): Single<Boolean> {
   return Single.create { emitter ->
       runCatching {
           //名字判断....
           if (userName.isNotEmpty()) {
               emitter.onSuccess(true)
          } else {
               emitter.onSuccess(false)
          }
      }.onFailure {
           emitter.onError(it)
      }
  }
}

fun compareFilter() {
   getUsernames()//Flowable<String>
      .flatMapSingle { userName ->
           isCorrectUserName(userName)
              .flatMap { isCorrect ->
                   if (isCorrect) {
                       Single.just(userName)
                  } else {
                       Single.never()
                  }
              }
      }.subscribe {
           println(it)
      }

}

乍一看,是不是感觉有点麻烦,事实上这确实需要我们使用些小手段才能达到目的;而在Flow中,我们能够轻松地根据同步和异步调用过滤流


runBlocking {
       userNameFlow().filter { user ->
           isCorrectName(user.userName)
      }.collect { user->
           println(user)
      }
  }

suspend fun isCorrectName(userName: String): Boolean {
   return userName.isNotEmpty()
}

结语


由于篇幅原因,Rxjava和协程都是一个非常庞大的思考话题,它们之间的不同比较可以永远进行下去;事实上,在Kotlin协程被广泛使用之前,Rxjava作为项目中主要的异步解决方案,以至于到现在工作上还有很多项目用着Rxjava, 所以即使切换到Kotlin协程之后,还有相当长一段时间还在用着Rxjava;这并不代表Rxjava不够好,而是协程让代码变得更易读,更易于使用;


暂时先告一段落了,事实上证明协程确实能够满足我们日常开发的主要需求,下次将会对Rxjava中的背压和之前所讨论的Flow背压问题进行比较探讨,还有非常多的东西要学,共勉!!!!


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

某大龄单身程序员自曝:追求美团女员工,却被她欺骗利用,天天免费加班写代码!

一位程序员最近非常生气,因为他喜欢上一位美团的妹子,却被妹子骗去写代码做苦力,成了妥妥的工具人。该程序员一怒之下,把妹子和自己的聊天记录曝光了出来:另外,搜索公众号后端架构师后台回复“架构整洁”,获取一份惊喜礼包。楼主说,自己已经在美团官网举报了,该女生就等待...
继续阅读 »

一位程序员最近非常生气,因为他喜欢上一位美团的妹子,却被妹子骗去写代码做苦力,成了妥妥的工具人。


该程序员一怒之下,把妹子和自己的聊天记录曝光了出来:


另外,搜索公众号后端架构师后台回复“架构整洁”,获取一份惊喜礼包。


楼主说,自己已经在美团官网举报了,该女生就等待阳光职场通报吧。其实这几个月早就发现她有很多不对劲的地方,但希望她能良心发现,跟自己坦诚一下,然而并没有。


有人评价,这就是传说中的职场妲己?


有人嘲笑楼主被女生当成了工具人。


有人说,这种人不是一个,只是有的靠外面的备胎养,有的压榨下面的人。美团管理层很狂妄傲慢,一直坚持pua。


有人说,这种数据表严禁外露,这个女生可能有阳光职场的风险,估计要被开除了。


也有人说,这是私德问题,公司不管。看开点,愿意帮就帮,不愿意就算了。


有人说,工作是工作,感情是感情,楼主可以帮一部分,但不能帮全部。


还有人说,这就是一个舔狗舔而不得的故事,楼主追不到女生就恼羞成怒了,明显表白不成想报复,还用掐头去尾的聊天记录。其实帮她之前就该知道,帮不帮都不能影响她和自己的关系。


站在楼主的角度,我们完全能理解他为什么这么生气。在他的复杂情绪里,既有没追上妹子的失望沮丧,也有被妹子欺骗利用的愤怒。无论是谁,被别人当成工具人都会火冒三丈吧?

在现实生活中,的确有一些心术不正的人喜欢利用别人的感情,让别人成为自己职业生涯的跳板和垫脚石,无论男女,坏人不分性别。

所以,即使你对某人动了心,也一定要警惕别有用心的感情陷阱。一旦发现自己有成为工具人的苗头,赶紧保持冷静,好好看清目前的局势。不要变成恋爱脑,像王宝钏一样,为了等待一个不值得的人,挖着野菜苦守寒窑十八年,成了一个笑话。

来自:行者

收起阅读 »

面试官:你如何实现大文件上传

web
提到大文件上传,在脑海里最先想到的应该就是将图片保存在自己的服务器(如七牛云服务器),保存在数据库,不仅可以当做地址使用,还可以当做资源使用;或者将图片转换成base64,转换成buffer流,但是在javascript这门语言中不存在,但是这些只适用于一些小...
继续阅读 »

提到大文件上传,在脑海里最先想到的应该就是将图片保存在自己的服务器(如七牛云服务器),保存在数据库,不仅可以当做地址使用,还可以当做资源使用;或者将图片转换成base64,转换成buffer流,但是在javascript这门语言中不存在,但是这些只适用于一些小图片,对于大文件还是束手无策。

一、问题分析

如果将大文件一次性上传,会发生什么?想必都遇到过在一个大文件上传、转发等操作时,由于要上传大量的数据,导致整个上传过程耗时漫长,更有甚者,上传失败,让你重新上传!这个时候,我已经咬牙切齿了。先不说上传时间长久,毕竟上传大文件也没那么容易,要传输更多的报文,丢包也是常有的事,而且在这个时间段万不可以做什么其他会中断上传的操作;其次,前后端交互肯定是有时间限制的,肯定不允许无限制时间上传,大文件又更容易超时而失败....

一、解决方案

既然大文件上传不适合一次性上传,那么将文件分片散上传是不是就能减少性能消耗了。

没错,就是分片上传。分片上传就是将大文件分成一个个小文件(切片),将切片进行上传,等到后端接收到所有切片,再将切片合并成大文件。通过将大文件拆分成多个小文件进行上传,确实就是解决了大文件上传的问题。因为请求时可以并发执行的,这样的话每个请求时间就会缩短,如果某个请求发送失败,也不需要全部重新发送。

二、具体实现

1、前端
(1)读取文件

准备HTML结构,包括:读取本地文件(input类型为file)、上传文件按钮、上传进度。

<input type="file" id="input">
<button id="upload">上传</button>
<!-- 上传进度 -->
<div style="width: 300px" id="progress"></div>

JS实现文件读取:

监听inputchange事件,当选取了本地文件后,打印事件源可得到文件的一些信息:

let input = document.getElementById('input')
let upload = document.getElementById('upload')
let files = {}//创建一个文件对象
let chunkList = []//存放切片的数组

// 读取文件
input.addEventListener('change', (e) => {
   files = e.target.files[0]
   console.log(files);
   
   //创建切片
   //上传切片
})

观察控制台,打印读取的文件信息如下:


(2)创建切片

文件的信息包括文件的名字,文件的大小,文件的类型等信息,接下来可以根据文件的大小来进行切片,例如将文件按照1MB或者2MB等大小进行切片操作:

// 创建切片
function createChunk(file, size = 2 * 1024 * 1024) {//两个形参:file是大文件,size是切片的大小
  const chunkList = []
  let cur = 0
  while (cur < file.size) {
      chunkList.push({
              file: file.slice(cur, cur + size)//使用slice()进行切片
      })
      cur += size
  }
  return chunkList
}

切片的核心思想是:创建一个空的切片列表数组chunkList,将大文件按照每个切片2MB进行切片操作,这里使用的是数组的Array.prototype.slice()方法,那么每个切片都应该在2MB大小左右,如上文件的大小是8359021,那么可得到4个切片,分别是[0,2MB]、[2MB,4MB]、[4MB,6MB]、[6MB,8MB]。调用createChunk函数,会返回一个切片列表数组,实际上,有几个切片就相当于有几个请求。

调用创建切片函数:

//注意调用位置,不是在全局,而是在读取文件的回调里调用
chunkList = createChunk(files)
console.log(chunkList);

观察控制台打印的结果:


(3)上传切片

上传切片的个关键的操作:

第一、数据处理。需要将切片的数据进行维护成一个包括该文件,文件名,切片名的对象,所以采用FormData对象来进行整理数据。FormData 对象用以将数据编译成键值对,可用于发送带键数据,通过调用它的append()方法来添加字段,FormData.append()方法会将字段类型为数字类型的转换成字符串(字段类型可以是 Blob、File或者字符串:如果它的字段类型不是 Blob 也不是 File,则会被转换成字符串类

第二、并发请求。每一个切片都分别作为一个请求,只有当这4个切片都传输给后端了,即四个请求都成功发起,才上传成功,使用Promise.all()保证所有的切片都已经传输给后端。

//数据处理
async function uploadFile(list) {
   const requestList = list.map(({file,fileName,index,chunkName}) => {
       const formData = new FormData() // 创建表单类型数据
       formData.append('file', file)//该文件
       formData.append('fileName', fileName)//文件名
       formData.append('chunkName', chunkName)//切片名
       return {formData,index}
  })
      .map(({formData,index}) =>axiosRequest({
           method: 'post',
           url: 'http://localhost:3000/upload',//请求接口,要与后端一一一对应
           data: formData
      })
          .then(res => {
               console.log(res);
               //显示每个切片上传进度
               let p = document.createElement('p')
               p.innerHTML = `${list[index].chunkName}--${res.data.message}`
               document.getElementById('progress').appendChild(p)
          })
      )
       await Promise.all(requestList)//保证所有的切片都已经传输完毕
}

//请求函数
function axiosRequest({method = "post",url,data}) {
   return new Promise((resolve, reject) => {
       const config = {//设置请求头
           headers: 'Content-Type:application/x-www-form-urlencoded',
      }
       //默认是post请求,可更改
       axios[method](url,data,config).then((res) => {
           resolve(res)
      })
  })
}

// 文件上传
upload.addEventListener('click', () => {
   const uploadList = chunkList.map(({file}, index) => ({
       file,
       size: file.size,
       percent: 0,
       chunkName: `${files.name}-${index}`,
       fileName: files.name,
       index
  }))
   //发请求,调用函数
   uploadFile(uploadList)

})
2、后端
(1)接收切片

主要工作:

第一:需要引入multiparty中间件,来解析前端传来的FormData对象数据;

第二:通过path.resolve()在根目录创建一个文件夹--qiepian,该文件夹将存放另一个文件夹(存放所有的切片)和合并后的文件;

第三:处理跨域问题。通过setHeader()方法设置所有的请求头和所有的请求源都允许;

第四:解析数据成功后,拿到文件相关信息,并且在qiepian文件夹创建一个新的文件夹${fileName}-chunks,用来存放接收到的所有切片;

第五:通过fse.move(filePath,fileName)将切片移入${fileName}-chunks文件夹,最后向前端返回上传成功的信息。

//app.js
const http = require('http')
const multiparty = require('multiparty')// 中间件,处理FormData对象的中间件
const path = require('path')
const fse = require('fs-extra')//文件处理模块

const server = http.createServer()
const UPLOAD_DIR = path.resolve(__dirname, '.', 'qiepian')// 读取根目录,创建一个文件夹qiepian存放切片

server.on('request', async (req, res) => {
   // 处理跨域问题,允许所有的请求头和请求源
   res.setHeader('Access-Control-Allow-Origin', '*')
   res.setHeader('Access-Control-Allow-Headers', '*')

   if (req.url === '/upload') { //前端访问的地址正确
       const multipart = new multiparty.Form() // 解析FormData对象
       multipart.parse(req, async (err, fields, files) => {
           if (err) { //解析失败
               return
          }
           console.log('fields=', fields);
           console.log('files=', files);
           
           const [file] = files.file
           const [fileName] = fields.fileName
           const [chunkName] = fields.chunkName
           
           const chunkDir = path.resolve(UPLOAD_DIR, `${fileName}-chunks`)//在qiepian文件夹创建一个新的文件夹,存放接收到的所有切片
           if (!fse.existsSync(chunkDir)) { //文件夹不存在,新建该文件夹
               await fse.mkdirs(chunkDir)
          }

           // 把切片移动进chunkDir
           await fse.move(file.path, `${chunkDir}/${chunkName}`)
           res.end(JSON.stringify({ //向前端输出
               code: 0,
               message: '切片上传成功'
          }))
      })
  }
})

server.listen(3000, () => {
   console.log('服务已启动');
})

通过node app.js启动后端服务,可在控制台打印fields和files


(2)合并切片

第一:前端得到后端返回的上传成功信息后,通知后端合并切片:

// 通知后端去做切片合并
function merge(size, fileName) {
   axiosRequest({
       method: 'post',
       url: 'http://localhost:3000/merge',//后端合并请求
       data: JSON.stringify({
           size,
           fileName
      }),
  })
}

//调用函数,当所有切片上传成功之后,通知后端合并
await Promise.all(requestList)
merge(files.size, files.name)

第二:后端接收到合并的数据,创建新的路由进行合并,合并的关键在于:前端通过POST请求向后端传递的合并数据是通过JSON.stringify()将数据转换成字符串,所以后端合并之前,需要进行以下操作:

  • 解析POST请求传递的参数,自定义函数resolvePost,目的是将每个切片请求传递的数据进行拼接,拼接后的数据仍然是字符串,然后通过JSON.parse()将字符串格式的数据转换为JSON对象;

  • 接下来该去合并了,拿到上个步骤解析成功后的数据进行解构,通过path.resolve获取每个切片所在的路径;

  • 自定义合并函数mergeFileChunk,只要传入切片路径,切片名字和切片大小,就真的将所有的切片进行合并。在此之前需要将每个切片转换成流stream对象的形式进行合并,自定义函数pipeStream,目的是将切片转换成流对象,在这个函数里面创建可读流,读取所有的切片,监听end事件,所有的切片读取完毕后,销毁其对应的路径,保证每个切片只被读取一次,不重复读取,最后将汇聚所有切片的可读流汇入可写流;

  • 最后,切片被读取成流对象,可读流被汇入可写流,那么在指定的位置通过createWriteStream创建可写流,同样使用Promise.all()的方法,保证所有切片都被读取,最后调用合并函数进行合并。

if (req.url === '/merge') { // 该去合并切片了
       const data = await resolvePost(req)
       const {
           fileName,
           size
      } = data
       const filePath = path.resolve(UPLOAD_DIR, fileName)//获取切片路径
       await mergeFileChunk(filePath, fileName, size)
       res.end(JSON.stringify({
           code: 0,
           message: '文件合并成功'
      }))
}

// 合并
async function mergeFileChunk(filePath, fileName, size) {
   const chunkDir = path.resolve(UPLOAD_DIR, `${fileName}-chunks`)

   let chunkPaths = await fse.readdir(chunkDir)
   chunkPaths.sort((a, b) => a.split('-')[1] - b.split('-')[1])

   const arr = chunkPaths.map((chunkPath, index) => {
       return pipeStream(
           path.resolve(chunkDir, chunkPath),
           // 在指定的位置创建可写流
           fse.createWriteStream(filePath, {
               start: index * size,
               end: (index + 1) * size
          })
      )
  })
   await Promise.all(arr)//保证所有的切片都被读取
}

// 将切片转换成流进行合并
function pipeStream(path, writeStream) {
   return new Promise(resolve => {
       // 创建可读流,读取所有切片
       const readStream = fse.createReadStream(path)
       readStream.on('end', () => {
           fse.unlinkSync(path)// 读取完毕后,删除已经读取过的切片路径
           resolve()
      })
       readStream.pipe(writeStream)//将可读流流入可写流
  })
}

// 解析POST请求传递的参数
function resolvePost(req) {
   // 解析参数
   return new Promise(resolve => {
       let chunk = ''
       req.on('data', data => { //req接收到了前端的数据
           chunk += data //将接收到的所有参数进行拼接
      })
       req.on('end', () => {
           resolve(JSON.parse(chunk))//将字符串转为JSON对象
      })
  })
}

还未合并前,文件夹如下图所示:


合并后,文件夹新增了合并后的文件:


作者:来碗盐焗星球
来源:juejin.cn/post/7177045936298786872

收起阅读 »

你还在躺平摆烂?别人已经达到精神离职境界

Z近几个月,在年轻群体中,出现一个新鲜的热词—精神离职,从前的躺平、摆烂流行词已经过时,新的流行词不知不觉间已经取代了旧的。什么是精神离职?从哪里来?褒的贬的?员工为什么要精神离职?精神离职通常在哪个群体?精神离职的表现有哪些?应不应该精神离职?是做低级的精神...
继续阅读 »

Z近几个月,在年轻群体中,出现一个新鲜的热词—精神离职,从前的躺平、摆烂流行词已经过时,新的流行词不知不觉间已经取代了旧的。

什么是精神离职?从哪里来?褒的贬的?

员工为什么要精神离职?

精神离职通常在哪个群体?

精神离职的表现有哪些?

应不应该精神离职?

是做低级的精神离职,还是高级的精神离职?

员工精神离职,老板怎么应对?

还有人精神离职,Z后当了CEO,甚至当了老板,这是真的吗?

精神离职一词延伸出许多疑惑,我将分几期来谈论这个话题。


一、什么是精神离职?

Quiet Quitting翻译为安静地放弃,即精神离职,顾名思义,通俗易懂地讲,就是精神离了职,身体还在职。这个词是从欧美年轻群体传过来,在国外TK短视频火起来,Z近慢慢在中国开始受人关注。

精神离职是一种出勤不出力、人在心不在、有力不愿使、事不关己高高挂起的状态。也是一种拿多少钱办多少事、不内卷、不加班、不承担额外责任的工作心态。

精神离职的人只是把工作当成以出卖劳动和时间换取生存的手段,只是把工作当成一个需要扮演的角色。是一种自我保护,一种对现状的妥协。他们不屑于996和狼性文化,内卷也卷不动他们。

精神离职人的表现有哪些?到底什么造成这一现象?

二、精神离职人的表现

1、不违规违纪,相当隐蔽。是平静海底的暗流。

2、上下班特别规律。

3、公司不论怎么变,都岿然不动。

4、不管遇到什么事,不生气,特别平静。不抱怨,觉得一切都很正常,不正常的也是正常的。

5、一切照章办事,如机器人一般。

三、员工为什么要精神离职?

1、外部就业环境、经济环境、工作环境。

2、工作不能自由选择。

3、工作痛苦、无聊、无力、孤立。

4、感觉工作没有意义,没有价值。没有认同感和成就感。

5、企业不考虑员工感受,不听取员工意见与建议。

6、工作中受到打击,没有信心。

7、怀才不遇,没有伯乐。

8、员工心态不好,没有责任感、没有目标追求。

你精神离职了吗?又是哪一种原因的精神离职?

工作痛苦的时候,是选择精神离职,还是身体离职?应不应该精神离职?怎么样才是对自己好的精神离职?怎么样才是高级的精神离职?员工精神离职老板怎么应对?下一期继续。


工作痛苦怎么办?精神离职还是身体离职?

是不是有很多人都觉得工作很痛苦,很无聊,没有意义,没有价值感,没有成就感?甚至让人压抑崩溃?据统计,大部分人每天都有离职的念头。但因为各种原因,一直纠结该不该离职。

那我们工作痛苦时,到底应不应该离职?是选择精神离职还是身体离职?

选择精神离职

1、如果你喜欢你的行业和工作,但因为其它原因而痛苦,可以选择精神离职。

2、如果你的工作你所在公司很有前途,你也很有前途,工资很高,很有希望,可以选择精神离职。

3、如果你非常需要这份工作来养家糊口,经济压力很大,又暂时找不到其他合适的工作,你可以选择精神离职。

4、如果你能从这份工作中学到知识,得到成长,为未来铺路,你可以选择精神离职。

选择身体离职

1、活太多,工资太低,养不活自己。

2、学不到任何东西,未来没有希望。

3、被老板PUA,被人欺负,没有丝毫的受尊重感,每天都活得很憋屈。

4、整个环境让人压抑抑郁。同事素质普遍低下。

5、加班太多,身体被搞垮。

6、加班很多,还没加班费。

7、行业、公司没有前景。

8、特别讨厌这份工作,觉得像活在地Y

9、每天上班都很疲惫,工作让你觉得活着没意义。

10、有其它好的工作机会。

如果你的工作痛苦,你会选择精神离职还是身体离职?


精神离职=躺平摆烂?

你是低级的精神离职,还是高级的精神离职?

精神离职就是躺平摆烂吗?他们有区别吗?其实精神离职大于摆烂,小于内卷。

精神离职又分为高级的精神离职和低级的精神离职。看看你是哪一种?

低级的精神离职

1、上班时不开心,无聊,麻木,让干啥干啥,就一个机器人。

2、不在乎一切,不受尊重,被骂也无所谓,不想生气不想吵架。

3、没有梦想,没有目标,没有追求,混一天是一天,就是混工资求生存的。

4、不想沟通,不想交际,没有想法,没有建议。

5、没有责任感,事不关己高高挂起,我只做我该做的事,其它事情和我无关。

6、下班刷视频追剧玩网络游戏,继续躺平,娱乐至上。

7、每天浑浑噩噩,虚度人生。

高级的精神离职

1、上班虽然佛系,让干什么干什么,但心里有目标和想法。

2、暗地里为自己努力,让自己渐渐变强大。不加班是觉得加班没用,不如回家提升自己。

3、下班搞副业,或者学习,提升各种能力,给自己更多的机会。哪天副业上来了,就变成主业了。

4、反抗方式放在创造力上,文学、艺术、音乐、运动、写作等是工作痛苦的解Y

5、把更多的时间放在提升自己,或者维持健康,陪伴家人身上。

你是低级的精神离职还是高级的精神离职?都是精神离职,不如让自己成为高级的。

员工精神离职,老板怎么应对?怎么防止员工精神离职?

精神离职这个词近期突然火了起来,很多人都选择了精神离职,让自己身心舒坦一些。

那么员工精神离职,对企业有什么影响,老板应该怎么应对?

其实大部分精神离职,都是一种负面的,影响企业发展的因素,不能不重视。如果不及时纠正、引导、化解,一定如离岸流一般,拖累企业,让企业走向艰难的境地。

我们先来了解一下精神离职的人,以便对症下药。

精神离职人的特征:

1、精神离职的人不在意尊严荣誉,不在意自己的感受,没有目的追求,没有责任感,看不到努力的意义。不愿和公司沟通,选择一种被动攻击。

2、精神离职的人不再反思、选择,不再有目标、梦想等。

3、精神离职的人在工作中没有获得知识和成长,没有价值感、归属感、认同感、成就感,找不到自己在社会中的位置。

4、精神离职的人经常感觉无力,无意义,孤立,痛苦、无聊等。

5、精神离职的人觉得工作不能自由选择。

6、精神离职的人工作中受到打击,没有信心。企业不考虑员工感受,不听取员工意见与建议。

7、精神离职的人怀才不遇,感觉没有伯乐。

总结为两大点,一是对公司不满,二是对自己不满。

一般对公司不满时,有三种表现,一是直接身体离职,二是抱怨或提建议,三是沉默冷战,即精神离职。

精神离职其实是比较可怕的。因为它是一股暗流,你不知道的时候,它已经发展壮大,你可能没有机会挽救。

所以作为老板,要防止精神离职,或者在有苗头时掐灭。如果已经成形,便采取应对方法。

老板应对员工精神离职的方法:

1、管理者要从自负的控制者转变为谦逊的学习者。不要抑制员工的反馈与建议,不要不听负面的声音。

2、管理者要以互助学习模式来面对和解决工作问题。

3、公司要有公正的企业文化氛围, 有原则和规则的文化,能让员工获得信息沟通的安全感。缺少员工认同和员工互动的文化,不能称为企业文化。

4、征求员工意见,关注员工合理的需求。

5、多听合理的建议,解决企业的一些实际问题。

6、多采取激励方法,激励员工工作热情。

7、关心员工工作、生活和休息,注意假期安排,比如主动积极安排年休假等。

8、多肯定认可鼓励员工,肯定员工个人价值。

9、多增强团体信任感。

精神离职是一种安静社交,安静工作,因为感觉没有希望,所以沉默,愿我们都能在工作中找到真正的自己,快乐的自己。

来源:baijiahao.baidu.com/s?id=1749641382911734394

收起阅读 »

研究良久,终于发现了他代码写的快且bug少的原因

前言读者诸君,今日我们适当放松一下,不钻研枯燥的知识和源码,分享一套高效的摸鱼绝活。我有一位程序员朋友,当时在一个团队中开发Android应用,历经多次考核后发现:在组内以及与iOS团队的对比中:他的任务量略多但他的bug数量和严重度均低但他加班的时间又少于其...
继续阅读 »

前言

读者诸君,今日我们适当放松一下,不钻研枯燥的知识和源码,分享一套高效的摸鱼绝活。

我有一位程序员朋友,当时在一个团队中开发Android应用,历经多次考核后发现:

在组内以及与iOS团队的对比中:

  • 他的任务量略多

  • 但他的bug数量和严重度均低

  • 但他加班的时间又少于其他人

不禁令人产生好奇,他是如何做到代码别的又快,质量又高的

经过多次研究我终于发现了奥秘。

为了行文方便我用"老L"来代指这位朋友。

最常见的客户端bug

"老L,听说昨晚上线,你又坐那摸鱼看测试薅别人,有什么秘诀吗?"

老L:"秘诀?倒也谈不上,你这么说,我倒是有个问题,你觉得平日里最常见的bug有哪些?"

"emm,编码上不健壮的地方,例如NPE,IndexOutOfBoundsException,UI上的可就海了去了,文本长度不一导致显示不下,间距问题,乱七八糟的一大堆"

老L:"哈哈,都是些看起来很幼稚、愚蠢的问题吧?是不是测试挂嘴边的那句:' 你就不能跑一跑吗,你又不瞎,跑两下不就看到了,这么明显!!!' "

我突然来了兴致,"你是说我们有必要上 TDD(test-driven-develop),按照DevOps思想,在CI(Continuous Integration)的时候,顺带跑自动化测试用例发现问题?"

老L突然打断了我:"不要拽你那些词了,记住了,事情是要人干的,机器只能替代可重复劳动,现在还不能替代人的主观能动性,拽词并不能解决问题。我们已经找到了第一个问题的答案,现在换个角度"


平日里最常见的bug有哪些?

  • 编码不健壮, 例如NPE,IndexOutOfBoundsException

  • UI细节问题, 例如文本长度不一导致显示不下,间距,等

为什么很浅显的问题没有被发现


老L:"那么问题来了,为什么这些浅显的问题,在交测前没有被发现呢?"

我陷入了思考...

是开发们都很懒吗?也不至于啊!

是时间很紧来不及吗?确实节奏紧张,但也不至于不给调试就拿去测了!

"emm, 可能是迭代的节奏的太频繁,压力较大,并没有整块的时间用来自测联调"


老L接过话茬,"假定你说的是正确的,那么就有两种可能。"

"第一种,自测与联调要比开发还要耗费心思的一件事情。但实际上,你我都知道,这一点并站不住脚!"

"而第二种,就是在开发阶段无法及时测试,拖到开发完,简单测测甚至被催促着就交差了"

仔细的思考后

  • 业务逐步展开,无法在任意时间自由地进行有效的集成测试

  • 后端节奏并不比前端快多少,在前端的开发阶段,难以借助后端接口测试,也许接口也有问题

"确实,这是一个挺麻烦的问题,听你一说,我感觉除了多给几天,开发完集中自测一波才行" 我如是说到。


"NO NO NO",老L又打断了我:"你想的过多了,你想借助一个可靠的、已经完备的后端系统来进行自测。对于你的需求来说,这个要求过高了,你这是准备干QA的活"

"我帮你列举一下情况"

  1. 一些数据处理的算法,这种没有办法,老老实实写单元测试,在开发阶段就可以做好,保障可靠性

  2. UI呢,我们现在写的代码,基本都做到了UI与逻辑分层,只要能模拟数据,就能跑起来看页面

  3. 业务层,后端逻辑我们无法控制,但 Web-API 调用的情况可以分析下并做一下测试,而对于返回数据的JSON结构校验、约束性校验也可以考虑做一下测试

总而言之,我们只需要先排除掉浅显的错误。而这些浅显的错误,属于情况2、3

老L接着说道:"你先歇歇吧,我来说,你再插嘴这文章就太长了!"

接下来就可以实现矛盾转移:"如何模拟数据进行测试",准确的说,问题分成两个子问题:

  • 如何生成模拟数据

  • 如何从接缝中塞入数据,让系统得以使用

可能存在的接缝


先看问题2:"如何从接缝中塞入数据,让系统得以使用"

脑暴一下,可以得出结论:

  • 应用内部

    • 替换调用web-api的业务模块,使用假数据调用业务链,一般替换Presenter、Controller实例

    • 替换Model层,不调用web-api,返回假数据或用假数据调用回调链

    • 侵入网络层实现,不进行实际网络层交互,直接使用假数据

    • 遵循切面,向缓存等机制模块中植入假数据

  • 应用外部

    • 使用代理,返回假数据

    • 假数据服务器

简单分析:

  • "假数据服务器" ,并且使用逻辑编造假数据的代价太大,过。

  • "使用代理,返回假数据",可以用于特定问题的调试,不适用广泛情况,过。

  • "替换调用web-api的业务模块",成本过大,过。

  • "替换Model层",对项目的依赖注入管理具有较大挑战,备选,可能带来很多冗余代码。

  • "侵入网络层实现",优选。

  • "向缓存等机制模块中植入假数据",操作真实的缓存较复杂,但可以考虑增加一个 Mock缓存实现模块,基于SPI等机制,可以解决冗余代码问题,备选。

得出结论:

  • 方案1:"侵入网络层实现",优选

  • 方案2:"替换Model层",(项目的依赖注入做得很好时)作为备选,可能带来冗余代码

  • 方案3:"向缓存等机制模块中植入假数据",增加一个 Mock缓存实现模块,备选。(基于SPI等机制,可以解决冗余代码问题)

再仔细分析: 方案1和方案3可以合并,形成一个完整的方案,但未必需要限定在缓存机制中


OK 我们先搁置一下这个问题,看前一个问题。


创造假数据

简单脑暴一下,无非三种:

  • 人工介入,手动编写 -- 成本过大

    • 可能在前期准备好,基本是纯文本

    • 可能使用一个交互工具,在需要数据时介入,通过图形化操作和输入产生数据

  • 人工介入,逻辑编码

  • 基于反射等自省机制,并完全随机或者基于限制生成数据

"第一种代价过大,暂且抛弃"

"第二种可以采用,但是人力成本不容忽视! 一个可以说服我使用它的理由是:"可以精心设计单测数据,针对性的发现问题"

"第三种很轻松,例如使用Mockito,但生成合适的数据需要花费一定的精力"

我们来扒一扒第三种方式,其核心思想为:

  1. 获取类信息,得到属性集

  2. 遍历属性填充 >

  1. 基础类型、箱体类型,枚举,确定取值范围,使用Random取值,赋值

2. 普通类、泛型类,创建实例,回归步骤1
3. 集合、数组等,创建实例,回归步骤1,收集填充

不难得出结论,这一方法虽然很强大,但 创建高度定制化的数据 是一件有挑战的事情。

举个例子,模拟字符串时,一般会使用语料集作为枚举,进行取值。要得到“地址”、“邮箱”等特定风格的数据,需要结合框架做配置,客观上存在较高地学习、使用门槛。

你也知道,前几年我图好玩,写了个 mock库

必须强调的一点:“我并不认为我写的库比Mockito等库强大,仅仅是在我们开发人员够用的基础上,做到尽可能简单!”

你也知道,Google 在Androidx(前身为support)中提供了一套注解包: annotations。但Google并未提供bean validation 实现 ,我之前也基于此做过一套JSR303实现,有一次突发灵感,这套注解的含义同样适用于 声明假数据取值范围 !!!

所以,我能使用它便捷的生成合适的假数据,在开发阶段及时的进行 “伪集成”


此刻,我再也忍不住要发言了:“且慢,老L,你这个做法有一定的侵入性吧。而且,如果数据类在不同业务下复用的话,是否存在问题呢?”

老L顿了顿,“确实,google的annotations是源码级注解,并不是运行时,我为了保持简单,使用了运行时反射而非代码生成。所以确实存在一定的代码侵入性”。

但是,我们可以基于此建立一套简单的MOCK-API,这样就不存在代码侵入了。

另外,也可以增加一套Annotation-Processor 实现方案,这样就可以适当沿用项目中的注解约束了,但我个人认为华而不实。


看你的第二个问题,Mocker一开始确实存在这个问题,有一次从Spring的JSR380中得到灵感,我优化了注解规则,这个问题已经被解决了。得空你可以顺着这个图看看:


或者去看看代码和使用说明:github.com/leobert-lan…

再次审视如何处理接缝

此时我已经有点云里雾里,虽然听起来很牛,如何用起来呢?我还是很茫然,简直人麻了!不得不再次请教。

老L笑着说:“你问的是一个实践方案的问题,而这类问题没有银弹.不同的项目、不同的习惯都有最适宜的方法,我只能分享一下我的想法和做法,仅做参考”

在之前的项目中,我自己建了一个Mock-API,利用我的Mocker库,写一个假数据接口就是分分钟的事情。

测试机挂上charles代理,有需要的接口直接进行mapping,所以在客户端代码中,你看不到我做了啥。

当然,这个做法是在软件外部。

如果要在软件内部做,我个人认为这也是一个华而不实的事情。不过不得不承认是一件好玩的事情,那就提一些思路。

基于Retrofit的CallAdapter

public interface CallAdapter<R, T> {
   Type responseType();

   T adapt(Call<R> call);

   abstract class Factory {
       public abstract @Nullable
       CallAdapter<?, ?> get(Type returnType, Annotation[] annotations,
                             Retrofit retrofit);

       protected static Type getParameterUpperBound(int index,
                                                    ParameterizedType type) {
           return Utils.getParameterUpperBound(index, type);
      }

       protected static Class<?> getRawType(Type type) {
           return Utils.getRawType(type);
      }
  }
}

很明显,我们可以追加注解,用以区分是否需要考虑mock;

可选:对于有可能需要mock的接口,可以继续追加切面,实现在软件外部控制使用 mock数据真实数据

而Retrofit已经使用反射确定了方法的 return Type ,在Mocker中也有适应的API直接生成假数据

基于Retrofit的Interceptor

相比于上一种,拦截器已经在Retrofit处理流程中靠后,此时在 Chain 中能够得到的内容已经属于Okhttp库的范畴。

所以需要一定的前置措施用于确定 "return Type"、"是否需要Mock" 等信息。可以借助Tag机制:

@Documented
@Target(PARAMETER)
@Retention(RUNTIME)
public @interface Tag {
}

@GET("/")
Call<ResponseBody> foo(@Tag String tag);

最终从 Request#tag(type: Class<out T>): T? 方式获取,并接入mock,并生成 Response

其他针对Okhttp的封装

思路基本类似,不再展开。

写在最后

听完老L的思路,我若有所思,若有所悟。他的方案似乎很有效,而且直觉告诉我,这些方案中还有很多留白空间,例如:

  • 借用SPI等技术思路,可以轻易的解决 "Mock 模块集成与移除" 的问题

  • 提前外部控制是否Mock的接缝,可以在加一个工具APP、或者Socket+网页端工具 用以实现控制

但我似乎遗漏了问题的开始


是否原意做 用于约束假数据生成规则的基础建设工作呢??? 例如维护注解

事情终究是人干的,人原意做,办法总比困难多。

最后一个小问题:

作者:leobert-lan
来源:juejin.cn/post/7175772997582585917

收起阅读 »

跟报阳的朋友沟通的微信礼节

这篇文章适用于你和得病的朋友、熟人、同学和同事的沟通场景。如果你本人发烧了,不妨把这篇转出去。他们看了就算不能对你好一点儿,也能少说一点气人的话来激怒你。图片来自:作者提供1. 别人报阳千万别点赞遇到朋友报发烧、报抗原两杠、红码截图。普通熟人、朋友,可以用“辛...
继续阅读 »

这篇文章适用于你和得病的朋友、熟人、同学和同事的沟通场景。

如果你本人发烧了,不妨把这篇转出去。

他们看了就算不能对你好一点儿,也能少说一点气人的话来激怒你。


图片来自:作者提供

1. 别人报阳千万别点赞

遇到朋友报发烧、报抗原两杠、红码截图。

普通熟人、朋友,可以用“辛苦了”“保重啊”来评论,也可以用“拥抱”“咖啡”等表情。

如果是对你比较重要的人,建议你小窗发消息问候。

不建议只点一个赞,什么都不说。

即使是日常的损友也不要这么做。

2. 问候的三种方式

对方是病人,病人情绪不会太好,病痛会让他们比较易怒,问候宜简短、不要长篇大论。

我的同事欧小宅老师(今天刚退烧)总结说,问候有三种方式:

“你还好吗?”

“祝早日康复!”

“能帮你做点什么?”

你可能会觉得这些问候太书面、太客套,但是请明白一点,大多数别出心裁、自来熟的问候都会砸锅,我们探望病人问候病人的时候形成了这三种问候,是有原因的,因为它们不会出错。

3. 想帮忙该怎么说

如果对方是你比较亲近、比较重要的朋友,你可以把“能帮你做点什么”具体化。

“你的药够吗?如果缺药,我匀点给你。”

“我买了一箱黄桃罐头,可以分你四个,要不要?”

注意,后半句一定要有。

如果你只是简单地问:“你囤药了吗?”对方会摸不准你要做什么。

这句话是看不出“我要你给我药”还是“我有药可以分享给你的”。

明确地表达自己的意图,是跟病人高效沟通的关键。

4. 如何请病人坚持工作

如果你因为工作的缘故仍然要跟一个病人对接,请务必先行问候对方,再谈工作。

工作谈完之后,一定要说“好好休息”之类安慰和勉励的话。

尽快结束工作,比什么安慰都好。


图片来自:作者提供

5. 刨根问底很无聊

“你阳了吗?”

“阳了吗?阳了吗?阳了吗?”

“怎么不理我,我好心问候你,你到底阳了没有啊?”


图片来自:作者提供

“烧起来之后反正都要吃感冒药,不去测还更不容易感染别人。”

这种追问,是没有把对方当病人,而是把对方当风险源和管理对象。

小区门口的大白们都撤了,但有些人心中的防护服还没有脱下来——这种盘根问底,就是他代入了某种角色的表现。

6. 发烧的人没法特么关心世界

有的人其实是想问候病人的,但是一开口就是极其宏大的命题,比如:

“发烧是不是很疼啊?”(废话,你烧到39.5试试看。)

“你身边病倒的人多吗?”(朋友圈有两百多人,你要每个人的电话号码是吗?)

“北京那边形势怎么样?”(我两眼冒金星,你觉得我关心吗?)


图片来自:作者提供

你是出考研政治大题的吗?

开放式问题是问候病人的大忌。

7. 过来人如何给支持

昨天我的一位朋友张老师作为过来人给我分享了一个要诀。

“退热贴已经不好买了,如果买不到,可以把面膜放在冰箱里,烧厉害的时候替代退热贴。”

这种就是非常宝贵的信息,病人只能接受这样明确的信息。

如果你给一个已经病倒的人分享医生嘚嘚嘚讲如何防护的短视频,或者用药大名单之类的PDF,完全没意义了。

此外,每个人的体质不同,疼痛是一种主观感受,作为过来人,不要说“不疼”“没事儿”之类的话,只要告诉病人“我理解你的感受”就够了。

8. 遇到含糊的问题怎么办

刚才说的是如何问候病人。

如果你的领导含含糊糊地问你“你囤药了吗?”应该怎么回答?

建议你实话实说。

“布洛芬还有一盒,不多了。”

如果他是要给你药,你可以决定要不要,如果他是跟你要药,这会儿就会去找别人了。

9. 讨药的注意事项

中国不缺布洛芬或者对乙酰氨基酚,如果开足马力,一年能够30亿人轮番发烧吃的。

退烧药不会吃很多,大多数拍照发朋友圈的囤药者,未来都会剩很多药放在家里过期。

如果你断了药,就直接请那些发囤药照的朋友帮忙就好了。

鉴于发烧的人很多,可能跑腿快递也很难叫到,所以如果缺药,优先在邻居群里求。

如果要发朋友圈求助某类药品,最好是写上自己所在的位置,这样能得到最快的支援。

记得说谢谢,等一切都过去了,一定要坐下来吃个饭。


图片来自:作者提供

有些关系,是给出来的,有些关系,是要出来的。

10. 为什么要这样强调礼貌

这三年的经验就是,全靠自己做自了汉是不行的。

人需要互相扶持。

你今天安慰一个正在发烧的朋友,向他提供帮助,明天你病倒,而且危险的时候,他作为一个刚刚康复有抗体的人,可能就能送打不上车的你去医院(你不会相信等那三个数派车能轮到你吧)。

有一批错峰发病、互相关心的朋友,是我们健康平安的保险阀。

p.s

普通家庭肯定是一阳全阳的,没有双卫怎么保证不传染?

双卫了也照样……

专家都会告诉你说,把病人单独搁一个屋里,没病的在外面。我跟说,你真这样会没朋友的。

媳妇在屋里疼得哞哞哭,自己爬起来吃药倒水,你在外面看世界杯?转阴之日也就是你离异之时了。自己权衡一下,这就是个烧三天的病。

有时候就要冒着风险做该做的事。

活学活用,是生活的奥义。

作者:能老师
来源:mp.weixin.qq.com/s/PMj6gLj32AUNOXPvptYscA

收起阅读 »

大公司病了,这也太形象了吧!!!

作家采铜说过一个很有意思的比喻,他说,我们真的生活在一个肤浅的时代……希望今天的文章能够给你们带来收获,欢迎分享和点亮在看。.......................................................................
继续阅读 »

作家采铜说过一个很有意思的比喻,他说,我们真的生活在一个肤浅的时代……希望今天的文章能够给你们带来收获,欢迎分享和点亮在看。




......................................................


......................................................


......................................................


外国的神父呆了不久

留下几个 P 就走了,

一个 P 叫 BPR,


一个 P 叫 ERP。

......................................................

监院也没闲着,

他认为问题的关键在于

人才没有充分利用、

寺庙文化没有建设好,

于是就成立了


人力资源部和寺庙工会等等


......................................................


......................................................


......................................................


......................................................


最后决定,

成立专门的挑水部负责后勤

和专门的烧香部负责市场前台。

同时,为了更好地开展工作,

寺庙提拔了十几名和尚

分别担任副主持、主持助理,

并在每个部门任命了

部门小主持、副小主持、小主持助理。

......................................................

老问题终于得到缓解了,

可新的问题跟着又来了。


后台挑水的和尚也抱怨人手不足、

水的需求量太大而且没个准儿,

不好伺候。


为了便于沟通、协调,

每个部门都设立了对口的联系和尚。


协调虽然有了,但效果却不理想,

仔细一研究,

原来是由于水的需求量不准、

水井数量不足等原因造成的。

于是各部门又召开了几次会,


决定加强前台念经和尚对饮用水的预测

和念经和尚对挑水和尚满意度测评等,

让前后台签署协议、相互打分,

健全考核机制。


同时成立香火钱管理部、

香火钱出账部、

打井策略研究部、

打井建设部、


打井维护部等等。

由于各个系统出来的数总

不准确、都不一致,

于是又成立了技术开发中心,

负责各个系统的维护、

二次开发。

......................................................

由于部门太多、办公场地不足,


寺院专门成立了综合部

来解决这一问题


......................................................


同时,

为了精简机构、提高效率,


寺院还成立了精简机构办公室、


机构改革研究部等部门。

......................................................

一切似乎都合情合理,

但香火钱和喝水的问题

还是迟迟不能解决。

问题在哪呢?

有的和尚提出来每月应该开一次分析会,


于是经营分析部就应运而生了。


寺院空前地热闹起来,

有的和尚在拼命挑水、

有的和尚在拼命念经、

有的和尚在拼命协调、

有的和尚在拼命分析……

忙来忙去,水还是不够喝,

香火钱还是不够用。

什么原因呢?

这个和尚说流程不顺、


那个和尚说任务分解不合理,


这个和尚说部门职责不清、


那个和尚说考核力度不够。

只有三个人最清楚问题之关键所在,

那三个人就是最早的那三个和尚。


......................................................


......................................................


三个人忍无可忍,斗胆向上汇报,

要求增加挑水的人手,

越过数个层级之后,

主持和监院总算收到了这个请求。

经过各个部门季度会议的总结和分析,

经过了数次激烈的探讨,

总算可以从其他部门抽调过来

一些和尚进行支援,

但这些跨部门过来的和尚

根本挑不动水,

还对挑水的这几个和尚指手画脚,

挑水的和尚再次请求,

自己担任挑水的和尚团队负责人。


......................................................

又过了一年,寺院黄了,


大部分和尚都死了


......................................................



大企业管理特色:

总部愈来愈庞大,基层愈来愈忙碌,

成本愈来愈高,客户愈来愈不满。

来源:芝麻观点

收起阅读 »

拒绝躺平,来自底层前端的2022总结

一.求学之路首先说一下自己的背景:由于家庭原因高中辍学,后面报了一个成人专科,浑浑噩噩在学校呆了3年,没有学到什么有用的东西(浪了3年)。我在17年的时候通过自学前端的知识找到了人生的第一份前端开发的工作,当时真的是培训班盛行,那些培训班打着面试的旗号让你进去...
继续阅读 »

一.求学之路

首先说一下自己的背景:由于家庭原因高中辍学,后面报了一个成人专科,浑浑噩噩在学校呆了3年,没有学到什么有用的东西(浪了3年)。我在17年的时候通过自学前端的知识找到了人生的第一份前端开发的工作,当时真的是培训班盛行,那些培训班打着面试的旗号让你进去培训班,出来打工还债。我也是差点就被带进去了。后来还是抵住了诱惑,通过自己学习前端找到了工作。

第一家公司没有明确的分开前后端,项目也是没有前后端分离的,当时他们主要使用的语言是C# + .net 开发。我当时没有接触过C#(只会一些基本的语法)。那时候我也是心非常的慌,害怕好不容易找到的工作就这样丢了,于是每天晚上回去都会去自学C#的基础。幸运的是遇到一个非常好的同事与领导带着我去做项目,我也是顺利的转正了。公司的主要业务是做客户端系统,业务很复杂,通过在公司一年多的磨练,我从一个什么都不会的小白,变成了一个什么都会一点(前端,后端,sql,运维)的萌新了。

当时公司的前端主要框架是JQuery,当时我还不知道Vue,React这种数据驱动的框架,公司也没有其他真正的前端来教我(没错,我是那个公司的唯一一个前端)。后面通过自学,学习了Vue的框架,想在公司中推广这种架构。对于我这个人微言轻的小萌新来说,显然是失败的,大概就是公司不想冒风险,毕竟公司需要求稳。

没办法,当时的我觉得在这里已经没有办法能提高了,毕竟是没有人带,但是那边的老板非常看重我,也是希望我留下,在经过一系列的思想斗争后,还是离开了在这里的呆了一年半的公司,从广州跑去了深圳

在深圳,再一次被社会毒打。由于是专科的学历,而且不是全日制,找工作处处碰壁,经过一个月的艰苦找工作之路,拿到了两个offer,一个是主要是做邮箱后台的,使用的技术栈是Vue,薪资8K;另外是一个新成立的部门,主要的业务是小程序与后台,但是所有东西都是从零开始搭,薪资6K。当时我希望学习的更多的东西,所以我选择了后者。现在看来,我的选择是正确的,在这个公司,我自学的node,帮助公司搭起架构,学会了服务器运维,同时也学会了Vue。

在那时,我是深刻地意识到,没有学历真的寸步难行。别人轻易拿到的东西,我们需要拼尽全力才能拿到。同时我见识到了成人本科的摆烂行为。我不想混一个本科既然要拿本科,那自己也要学习到对得起学历的知识。所以我选择了自考。我没有报班,坚持自己的学习计划:每天6点起来学习,下班回去复习,周末没事就去图书馆学习。

这是部分自考的书籍

或许我不是一个特别聪明的人,书上的很多概念有很多很多,因为我是自学的,没有人给我总结重点,所以我认为整本书都是重点,自己去手抄每一个知识点加深记忆,通过三年时间的学习,我写满了二十几个笔记簿(下面只是部分)。没有人教我,那我就去网上自学,刷题。(不得不说通信原理自学是真的很难(傅里叶变换,傅里叶级数...),网上教学也很笼统,只能自己硬啃,我挂了两次!!)


在自考路上的同时,我也不忘深入地学习前端的知识。所以想起当时的自己总是很忙,工作,自考,提升技术,没有时间去做其他的事情。

通过三年(2019-2022)的自学。我终于拿到了学位证。或许这就是给自己努力的回报吧~!


(最差的英语一遍过了!!)
(仅仅过关的学位考试)

(校园随拍)

(校园随拍)

(毕业设计)

(毕业设计)

(毕业设计)

(学历信息)

(学位证)

要说最高兴的不是我拿到了学位证,是我在自考的过程中真正地学习到了知识。我报的是网络工程专业,在自考之前,除了数据结构和程序设计,其他专业课与基础课基本我都没有学习过。通过自考这个渠道,我学习了高数,线代,网络原理,通信原理,多路复用,信号传输原理,如何搭建网络,如何设计一个属于自己的网络协议等很多很多的知识,这种学习到自己喜欢的专业知识是非常让人兴奋的。

还有就是,这是我第一次通过学习得到了老师的肯定——毕业论文的导师愿意帮我写推荐信,可把我高兴得泪目了。


很显然,经过社会毒打四年多的我拿到这一个本科学历绝对不是终点,我希望再次进入学校学习(其实就是我不想去做公司的那些重复无聊的表单设计前端工作)。于是在我面前有3条路可走:1.躺平,2.考研,3.留学。

18岁时我没得选,现在我再一次站在了人生的十字路口中。这次我选择的是后者,考研和留学(希望这次我不会选错吧)。
经过几年的工作,也有一些积蓄去支撑我到国外留学,那就先试试留学吧,不行就去考研。于是我就一步一步按着学校的流程准备资料。

留学最大的难度就是英语,我自认为自己最差的就是英语了,总是学不会。但我不会向困难屈服的,觉得自己英语不好,那就从背单词开始,每天背一点,一直坚持了几年(期间换了一个APP),也总算把初中高中的词汇量补回来了。可以开始下一步的学习了


于是现在我除了工作,就是学英语。我是这样想的,即使我留学申请都没过,但雅思过了,多少也能提升一点竞争力,让社会资源多倾向自己多一点(这就是我几个月没更文的原因😢)。

(凌乱的书桌)

(雅思开始迫在眉睫,压力山大)

二.是什么驱动着我去学习

我觉得,当我们有一个目标,而且这个目标的吸引力足够大的时候,人们就会将逼迫着自己去努力,去拿到自己想要的东西。就比如高考,有的人希望自己能考一个好的学校,于是他很努力地想要达到自己想要的结果,有人却无所谓,没有了驱动力,通常情况是不会得到好的结果。


对我而言,我的目标就是,我不希望被其他人歧视自己是非全日制的学生,还有一点小小的梦想——能稍微改变一下这个社会对于非全日制但是却有足够能力的人的看法。人就是这么与无力,这样一个目标就足以让我们奋斗一生。

对我而言,遇到社会的不公已经是习以为常,甚至已经麻木了。没办法,一步错步步错,没有得到社会资源的倾斜也是自己不够努力的结果。前面我也说了,我是一个要强的人,这种社会的毒打对我而言就是一种动力,只会让我更加努力,让那些看不起我们的人后悔或是另眼相看。

说一个我亲身经历的例子,19年的时候我入职了一个node开发的岗位,入职的时候HR看到我的bi业证是业余大专的时候,他给我发了信息说:你这个大专不是全日制的啊?我说对,后面她也没说什么,只是说好吧。

估计那时候HR已经有不想要的意思了,我甚至都可能撑不过试用期。但是经过三个月的工作,我完美地完成了公司的工作,还优化了公司的后端基础工程,在转正答辩的时候得到所有领导的同意转正。那个HR从此路转粉甚至还加了我的私人微信。

可能是我运气比较好的原因吧,如果我遇到的是一些规定严格的公司,我估计一点机会都不会有。毕竟全日制的学历的学生出现差错的概率比非全日制学历的学生小得多,没有哪一个人愿意冒着风险去请一个人有可能踩雷的员工。对吧?

三.2022的成长

毫无意外,2022也是忙碌的一年,除了准备学位考试,同时还对外输出了文章,参加了两次掘金更文活动


自学了go基础,用go语言将自己的博客后端服务重构了


做了一个读书笔记的网站


做了一个自用的cli工具源码


为了了解非关系型数据库,自己手写了一个类似非关系型的数据存储项目,源码


除此之外,在公司中,我给公司创建了公共UI库,通用请求队列库,异常捕捉系统,低代码项目等前端基础工程~

四.保持自己的危机感

第一次听到危机意识这个词,是我在第一个公司的时候,带我的一个同事跟我说的。

其实无论是否躺平,我们都需要保持自己的危机意识,不能说在一个公司里面很闲,工作很轻松,就觉得可以放松下去了。万一遇到一些突发情况(例如:被毕业之类的),自己的处境就会很被动了。

当然过度的紧张也会适得其反。如果有🐟可摸,我一般都会抽出一半的时间去学习,让自己保持学习的状态。

我在这个公司已经工作一年半了。怎么说呢,一开始这个公司说是弄SCRM的,结果入职后天天搞小程序和管理系统,而且都是一些基础的表单UI,对我而言,我在没有什么可以学习的。

其实我也不是第一次遇到这种情况了,毕竟除了第一个公司,后面的公司我都是靠自学一个人走过来的。遇到这种情况,首先我做的是在空闲的时间输出一些便于开发公共库,在公司的期间,我也开了一个关于微前端的内部分享会,同时我也写了一遍关于微前端框架的文章从0到1实现一个微前端框架

(分享会的PPT)

为了应对长期的活动页面需求(基本上一周需要上线一个小程序活动页),于是我在摸鱼的时候给小程序做了一个低代码生成活动页的模块,很愉快地将需求甩给了其他人😄,给自己挣到了摸鱼的时间!!

(低代码后台)

我是什么时候萌生跑路的意思的,其实也都是一些人际关系的问题,还有就是工作对于我而言了,我希望向难度挑战。

先说一下人际关系吧,就是在一次需求评审过程中,我第一次听到资源这个词。没错,他们那些项目经理把我们当成是资源供他们调度,后面听说他们之间还有一个资源群,这让我更加反感了。

怎么说呢,对我来说就是不把人当人,我们只是他们的一个棋子的意思吧,所以我很反感这种,所以我受不了,决定到过年前就跑路了。这几年也没怎么真正休息过,正好趁着这次机会休息一下吧~

(聊天记录)

五.明年目标

前面我也说过,做人还是得有目标,才会有动力去做事情,每年给自己定一些小目标。

  • 首先持续输出技术文章这个肯定是要做的,希望明年能到LV5

  • 如果有哪个学校肯收留我了,那我就去读书了(这回我肯定拼尽全力地学习了)!!如果没有收留,那就开始着手准备考研的事情。

  • 第三个就是英语,希望雅思6.5。这个是属于挑战自己最不擅长的事情了,希望能做到~!

  • 如果进去学校了,我会开始研究物联网相关的知识。

作者:Ichmag
来源:juejin.cn/post/7174340400151265294

收起阅读 »

一个33岁老程序员的感悟

一、在中国你千万不要以为学习技术就可以换来稳定的生活和高的薪水待遇,你更不要认为那些从事市场开发,跑腿的人,没有前途。不清楚你是不是知道,咱们中国有相当大的一部分软件公司,他们的软件开发团队都小的可怜,甚至只有1-3个人,连一个项目小组都算不上,而这样的团队却...
继续阅读 »

一、在中国你千万不要以为学习技术就可以换来稳定的生活和高的薪水待遇,你更不要认为那些从事市场开发,跑腿的人,没有前途。

不清楚你是不是知道,咱们中国有相当大的一部分软件公司,他们的软件开发团队都小的可怜,甚至只有1-3个人,连一个项目小组都算不上,而这样的团队却要承担一个软件公司所有的软件开发任务,在软件上线和开发的关键阶段需要团队的成员没日没夜的加班,还需要为测试出的BUG和不能按时提交的软件模块功能而心怀忐忑,有的时候如果你不幸加入现场开发的团队你则需要背井离乡告别你的女友,进行封闭开发,你平时除了编码之外就是吃饭和睡觉(有钱的公司甚至请个保姆为你做饭,以让你节省出更多的时间来投入到工作中,让你一直在那种累了就休息,不累就立即工作的状态)

更可怕的是,会让你接触的人际关系非常单一,除了有限的技术人员之外你几乎见不到做其他行业工作和职位的人,你的朋友圈子小且单一,甚至破坏你原有的爱情(想象一下,你在外地做现场开发2个月以上,却从没跟女友见过一面的话,你的女友是不是会对你呲牙裂嘴)。

也许你拿到了所谓的白领的工资,但你却从此失去享受生活的自由,如果你想做技术人员尤其是开发人员,我想你很快就会理解,你多么想在一个地方长期待一段时间,认识一些朋友,多一些生活时间的愿望。

比之于我们的生活和人际关系及工作,那些从事售前和市场开发的朋友,却有比我们多的多的工作之外的时间,甚至他们工作的时间有的时候是和生活的时间是可以兼顾的,他们可以通过市场开发,认识各个行业的人士,可以认识各种各样的朋友,他们比我们坦率说更有发财和发展的机会,只要他们跟我们一样勤奋。(有一种勤奋的普通人,如果给他换个地方,他马上会成为一个勤奋且出众的人。)

二、在学习技术的时候千万不要认为如果做到技术最强,就可以成为100%受尊重的人。

有一次一个人在面试项目经理的时候说了这么一段话:我只用最听话的人,按照我的要求做只要是听话就要,如果不听话不管他技术再好也不要。随后这个人得到了试用机会,如果没意外的话,他一定会是下一个项目经理的继任者。

朋友们你知道吗?不管你技术有多强,你也不可能自由的腾出时间象别人那样研究一下LINUX源码,甚至写一个LINUX样的杰作来表现你的才能。需要做的就是按照要求写代码,写代码的含义就是都规定好,你按照规定写,你很快就会发现你昨天写的代码,跟今天写的代码有很多类似,等你写过一段时间的代码,你将领略:复制,拷贝,粘贴那样的技术对你来说是何等重要。(如果你没有做过1年以上的真正意义上的开发不要反驳我)。

如果你幸运的能够听到市场人员的谈话,或是领导们的谈话,你会隐约觉得他们都在把技术人员当作编码的机器来看,你的价值并没有你想象的那么重要。而在你所在的团队内部,你可能正在为一个技术问题的讨论再跟同事搞内耗,因为他不服你,你也不服他,你们都认为自己的对,其实你们两个都对,而争论的目的就是为了在关键场合证明一下自己比对方技术好,比对方强。(在一个项目开发中,没有人愿意长期听别人的,总想换个位置领导别人。)

三、你更不要认为,如果我技术够好,我就自己创业,自己有创业的资本,因为自己是搞技术的。

如果你那样认为,真的是大错特错了,你可以做个调查在非技术人群中,没有几个人知道C#与JAVA的,更谈不上来欣赏你的技术是好还是不好。一句话,技术仅仅是一个工具,善于运用这个工具为别人干活的人,却往往不太擅长用这个工具来为自己创业,因为这是两个概念,训练的技能也是完全不同的。

创业最开始的时候,你的人际关系,你处理人际关系的能力,你对社会潜规则的认识,还有你明白不明白别人的心,你会不会说让人喜欢的话,还有你对自己所提供的服务的策划和推销等等,也许有一万,一百万个值得我们重视的问题,但你会发现技术却很少有可能包含在这一万或一百万之内,如果你创业到了一个快成功的阶段,你会这样告诉自己:我干吗要亲自做技术,我聘一个人不就行了,这时候你才真正会理解技术的作用,和你以前做技术人员的作用。

小结

基于上面的讨论,我奉劝那些学习技术的朋友,千万不要拿科举考试样的心态去学习技术,对技术的学习几近的痴迷,想掌握所有所有的技术,以让自己成为技术领域的权威和专家,以在必要的时候或是心里不畅快的时候到网上对着菜鸟说自己是前辈。

技术仅仅是一个工具,是你在人生一个阶段生存的工具,你可以一辈子喜欢他,但最好不要一辈子靠它生存。

掌握技术的唯一目的就是拿它找工作(如果你不想把技术当作你第二生命的话),就是干活。所以你在学习的时候千万不要去做那些所谓的技术习题或是研究那些帽泡算法,最大数算法了,什么叫干活?

就是做一个东西让别人用,别人用了,可以提高他们的工作效率,想象吧,你做1万道技术习题有什么用?只会让人觉得酸腐,还是在学习的时候,多培养些自己务实的态度吧,比如研究一下当地市场目前有哪些软件公司用人,自己离他们的要求到底有多远,自己具体应该怎么做才可以达到他们的要求。等你分析完这些,你就会发现,找工作成功,技术的贡献率其实并没有你原来想象的那么高。

不管你是学习技术为了找工作还是创业,你都要对技术本身有个清醒的认识,在中国不会出现Bill Gates,因为,中国目前还不是十分的尊重技术人才,还仅仅的停留在把软件技术人才当作人才机器来用的尴尬境地。(如果你不理解,一种可能是你目前仅仅从事过技术工作,你的朋友圈子里技术类的朋友占了大多数,一种可能是你还没有工作,但喜欢读比尔·盖茨的传记)。

总结

“千万不要一辈子靠技术生存”,这是一句比较现实的话。很多人觉得自己现在20多岁,月入2~3W或者更多了,很OK呀。

理解这句话的前提是,你不满足于现在的收入(如果是工作年限比较短的,你可以看看这个行业做的比较好的人的收入,你能否满足),对自己的未来或者行业有感到担忧,那么你才能很好的理解这句话。

这也是为什么能理解这句话的人,大多是到了35岁左右的。诚然,对于一个工作7、8年或者不到的程序员,这个阶段技术是必须的,要深、要有一个今天被开,我可以保证明天找到工作的技术能力; 如果你足够幸运,能有在某一个领域做到专家级的、后面的小辈无法替代你,那"千万不要一辈子靠技术生存"这句话当然也就不适合你了,大牛,请受吾一拜。 但是,对于大多数人,都无法做到在一个领域无可替代(机遇与天赋),那么就要想办法保证在上了年纪、上有老下有下的时候不被公司裁掉、收入不减、生活质量不降。

如果在这个阶段你还在研究这个功能怎么实现、这个算法是多么的精妙,我觉得你不是太单纯,就是在借技术之名在逃避现实。 说一句庸俗的话,我满脑子想得都是怎么搞钱,怎么让家人生活的更好,做技术的在35岁之前没达到这一点(且不论财务自由),你觉得35岁以后还有机会吗?或者说扪心自问一下,你所做的事情有多少是你能做,别人不能做的,有多少技术含量自己心里应该也有点数。 所以,技术只是现阶段谋生的一项技能。

每个人的技术都是有天花板的,你的技术到了天花板的时候,你的收入能否满足你,这个是需要考虑的。当然,你家里有矿或者北京二环内有几套房,那你完全可以把技术当爱好。

作者:小伙子有前途
来源:juejin.cn/post/7175009448854257725

收起阅读 »

我最喜欢高效学习前端的两种方式

先说结论:看经典书看官方文档为什么是经典书籍我买过很多本计算机、前端、JavaScript方面的书,在这方面也踩过一些坑,分享下我的经验。在最早我也买过亚马逊的kindle,kindle的使用体验还不错,能达到和纸书差不多的阅读体验,而且很便携,但由于上面想看...
继续阅读 »

先说结论:

  • 看经典书

  • 看官方文档

为什么是经典书籍

我买过很多本计算机、前端、JavaScript方面的书,在这方面也踩过一些坑,分享下我的经验。

在最早我也买过亚马逊的kindle,kindle的使用体验还不错,能达到和纸书差不多的阅读体验,而且很便携,但由于上面想看的书很多都没有,又懒得去折腾,所以后来就卖了。

之后就转到了纸书上,京东经常搞100减50的活动,买了很多的书,这些书有的只翻了几页,有的翻来覆去看了几遍。

翻了几页的也有两种情况,一种是内容质量太差,完全照抄文档,而且还是过时的文档,你们都知道,前端的技术更新是比较快的,框架等更新也很快,所以等书出版了,技术可能就已经翻篇了。

另一种就是过于专业的,比较复杂难懂,比如编译原理,深入了解计算机系统 算法(第4版)等这种计算机传世经典之作。

纸书其实也有缺点,它真的是太沉了,如果要是出差的话想要带几本书的话,都得去考虑考虑,带多了是真的重。

还有就是在搬家的时候,真的就是噩梦,我家里有将近100本的纸书,每次搬家真的就是累死了。

所以最近一两年我都很少买纸书了,如果有需要我都会尽量选择电子版。

电子版书走到哪里都能看,不会有纸书那么多限制,唯一缺点可能就是没有纸书那股味道。

还有就是电子书平台的问题,一个平台的书可能不全,比如我就有微信图书和京东这两个,这也和听歌看剧一样,想看个东西,还得去多个平台,如果要是能够统一的话就好了。

还有就是盗版的pdf,这个我也看过,有一些已经买不到的书,没办法只能去网上寻找资源了。建议大家如果能支持正版,还是支持正版,如果作者赚不到钱,慢慢就没有人愿意创作优质内容,久而久之形成了恶性循环。

看经典书学习前端,是非常好的方式之一,因为书是一整套系统的内容,它不同于网上的碎片化文章。同时好书也是经过成千上万人验证后的,我们只需选择对的就可以了。

我推荐几本我读过的比较好的前端方面的书

  1. javascript高级程序设计

  2. 你不知道的javascript 上 中 下卷

  3. 狼书 卷1 卷2

关于计算机原理方面的书

  1. 编码:隐匿在计算机软硬件背后的语言

  2. 算法图解

  3. 图解http

  4. 大话数据结构

上面的书都是我买过,看过的,可能还有我不知道的,欢迎在评论中留言

这些书都有一些共同的特征,就是能经过时间的检验,不会过时,可以重复的去阅读,学习。

为什么是官方API文档

除了经典书之外,就是各种语言、框架的官方文档,这里一定注意是“官方文档”,因为百度里面搜索的结果里,有很多镜像的文档网站,官方第一时间发布的更新,他们有时并不能及时同步,所以接受信息就比人慢一步。所以一定要看“官方文档”。

比如要查询javascript、css的内容,就去mdn上查看。要去看nodejs就去nodejs的官网,要去看react、vue框架就去官网。尽量别去那些第三方网站。

作者:小帅的编程笔记
来源:juejin.cn/post/7060102025232515086

收起阅读 »

做一个具有高可用性的网络库(下)

续 做一个具有高可用性的网络库(上)网速检测如果可以获取到当前手机的网速,就可以做很多额外的操作。 比如在图片场景中,可以基于当前的实时网速进行图片的质量的变换,在网速快的场景下,加载高质量的图片,在网速慢的场景下,加载低质量的图片。 我们如何去计算...
继续阅读 »

续 做一个具有高可用性的网络库(上)

网速检测

如果可以获取到当前手机的网速,就可以做很多额外的操作。 比如在图片场景中,可以基于当前的实时网速进行图片的质量的变换,在网速快的场景下,加载高质量的图片,在网速慢的场景下,加载低质量的图片。 我们如何去计算一个比较准确的网速呢,比如下面列举的几个场景

  • 当前app没有发起网络请求,但是存在其他进程在使用网络,占用网速

  • 当前app发起了一个网络请求,计算当前网络请求的速度

  • 当前app并发多个网络请求,导致每个网络请求的速度都比较慢

可能还会存在一些其他的场景,那么在这么复杂的场景,我们通过两种不同的计算方式进行合并计算

  1. 基于当前网络接口的response读取的速度,进行网速的动态计算

  2. 基于流量和时间计算出网速

通过计算出来的两者,取最大值的网速作为当前的网速值。

基于当前接口动态计算

基于前面网络请求的全流程监控,我们可以在全局添加所有网络接口的监听,在ResponseBody这个周期内,基于response的byte数和时间,可以计算每一个网络body读取速度。之所以要选取body读取的时间来计算网速,主要是为了防止把网络建连的耗时影响了最终的网速计算。 不过接口网速的动态计算需要针对不同场景去做不同的计算。

  • 当前只有一个网络请求 在当前只有一个网络请求的场景下, 当前body计算出来请求速度就是当前的网速。

  • 当前同时存在多个网络请求发起时 每一个请求都会瓜分网速,所以在这个场景下,每个网络请求的网速都有其对应的网速占比。比如当前有6个网络请求,每个网络请求的网速近似为1/6。

当然,为了防止网速的短时间的波动,每个网络请求对于当前的网速的影响是有固定的占比的, 比如我们可以设置的占比为5%。

currentSpeed = (requestSpeed * concurrentRequestCount - preSpeed) * ratePercent + preSpeed

其中

  • requestSpeed:表示为当前网络请求计算出来的网速。

  • concurrentRequestCount:表示当前网络请求的总数

  • preSpeed:表示先前计算出来的网速

  • ratePercent:表示当前计算出来网速对于真正的网速影响占比

为了防止body过小导致的计算出来网速不对的场景,我们选取当前body大小超过20K的请求参与进行计算。

基于流量动态计算

基于流量的计算,可以参照TrafficState进行计算。可以参照facebook的network-connection-class 。可以通过每秒获取系统当前进程的流量变化,网速 = 流量总量 / 计算时间。 它内部也有一个计算公式:

public void addMeasurement(double measurement) {
  double keepConstant = 1 - mDecayConstant;
  if (mCount > mCutover) {
    mValue = Math.exp(keepConstant * Math.log(mValue) + mDecayConstant * Math.log(measurement));
  } else if (mCount > 0) {
    double retained = keepConstant * mCount / (mCount + 1.0);
    double newcomer = 1.0 - retained;
    mValue = Math.exp(retained * Math.log(mValue) + newcomer * Math.log(measurement));
  } else {
    mValue = measurement;
  }
  mCount++;
}

自定义注解处理

假如我们现在有一个需求,在我们的网络库中有一套内置的接口加密算法,现在我们期望针对某几个网络请求做单独的配置,我们有什么样的解决方案呢? 比较容易能够想到的方案是添加给网络添加一个全局拦截器,在拦截器中进行接口加密,然后在拦截器中对符合要求的请求URL的进行加密。但是这个拦截器可能是一个网络库内部的拦截器,在这里面去过滤不同的url可能不太合适。 那么,有什么方式可以让这个配置通用并且简洁呢? 其中一种方式是通过接口配置的地方,添加一个Header,然后在网络库内部拦截器中获取Header中有没有这个key,但是这个这个使用起来并且没有那么方便。首先业务方并不知道header里面key的值是什么,其次在添加到header之后,内部还需要在拦截器中把这个header的key给移除掉。

最后我们决定对于网络库给单接口提供的能力都通过注解来提供。 就拿接口加密为例子,我们期望加密的配置方式如下所示

@Encryption
@POST("xxUrl)
fun testRequest(@Field("xxUrl"nicknameString)

这个注解是如何能够透传到网络库中的内部拦截器呢。首先需要把在Interface中配置的注解获取出来。CallAdapter.Factory可以拿到网络请求中配置的注解。

override fun get(returnTypeTypeannotationsArray<Annotation>retrofitRetrofit): CallAdapter<**> {}

我们可以在这里讲注解和同一个url的request的关联起来。 然后在拦截器中获取是否有对应的注解。

override fun intercept(chainInterceptor.Chain): Response {
       val request = chain.request()
       if (!NetAnnotationUtil.isAnntationExsit(requestEncryption::class)) {
           return chain.proceed(request)
      }
       //do encrypt we want
      ...
}

调试工具

对于网络能力,我们经常会去针对网络接口进行调试。最简单的方式是通过charles抓包。 通过charles抓包我们可以做到哪些调试呢?

  1. 查看请求参数、查看网络返回值

  2. mock网络数据 看着上面2个能力似乎已经满足我们了日常调试了,但是它还是有一些缺陷的:

  3. 必须要借助PC

  4. 在App关闭了可抓包能力之后,就不能再抓包了

  5. 无法针对于post请求参数区分

所以,我们需要有一个强大的网络调试能力,既满足了charles的能力, 也可以不借助PC,并且不论apk是否开启了抓包能力也能够允许抓包。 可以添加一个专门Debug网络拦截器,在拦截器中实现这个能力。

  1. 把网络的debug文件配置在本地Sdcard下(也可以配置在远端统一的地址中)

  2. 通过拦截器,进行url、参数匹配,如果命中,将本地json返回。否则,正常走网络请求。

data class GlobalDebugConfig(
    @SeerializedName("printToConsole"var printDataBoolean = false,
   @SeerializedName("printToPage"var printDataBoolean = false
)
data class NetDebugInfo(
       @SerializedName("filter"var debugFilterInfoNetDebugFilterInfo?,
       @SerializedName("response"var responseStringAny?,
       @SerializedName("code"var httpCodeInt,
       @SerializedName("message"var httpMessageString? = null,
       @SeerializedName("printToConsole"var printDataBoolean = true,
       @SeerializedName("printToPage"var printDataBoolean = true)

data class NetDebugFilterInfo(
       @SerializedName("host"var hostString? = null,
       @SerializedName("path"var pathString? = null,
       @SerializedName("parameter"var paramMapMap<StringString>? = null)

首先日志输出有个全局配置和单个接口的配置,单接口配置优于全局配置。

  • printToConsole表示输出到控制台

  • printToPage表示将接口记录到本地中,可以在本地页面查看请求数据

其次filterInfo就是我们针对接口请求的匹配规则。

  • host表示域名

  • path表示接口请求地址

  • parameter表示请求参数的值,如果是post请求,会自动匹配post请求的body参数。如果是get请求,会自动匹配get请求的query参数。

       val host = netDebugInfo.debugFilterInfo?.host
       if (!TextUtils.isEmpty(host) && UriUtils.getHost(request.url().toString()) !host) {
           return chain.proceed(request)
      }
       val filterPath = netDebugInfo.debugFilterInfo?.path
       if (!TextUtils.isEmpty(filterPath) && path !filterPath) {
           return chain.proceed(request)
      }
       val filterRequestFilterInfo = netDebugInfo.debugFilterInfo?.paramMap
       if (!filterRequestFilterInfo.isNullOrEmpty() && !checkParam(filterRequestFilterInforequest)) {
           return chain.proceed(request)
      }
       val resultResponseJsonObj = netDebugInfo.responseString
       if (resultResponseJsonObj == null) {
           return chain.proceed(request)
      }
       return Response.Builder()
               .code(200)
               .message("ok")
               .protocol(Protocol.HTTP_2)
               .request(request)
               .body(NetResponseHelper.createResponseBody(GsonUtil.toJson(resultResponseJsonObj)))
               .build()

对于配置文件,最好能够共同维护mock数据。 本地可以提供mock数据展示列表。

组件化上网络库的能力支持

在组件化中,各个组件都需要使用网络请求。 但是在一个App内,都会有一套统一的网络请求的Header,例如AppInfo,UA,Cookie等参数。。在组件化中,针对这几个参数的配置有下面几个比较容易想到的解决方案:

  1. 在各个组件单独配置这几个Header

  • 每个组件都需要但单独配置Header,会存在很多重复代码

  • 通用信息很大概率在各个组件中获取不到

  1. 由主工程实现代理发起网络请求 这种实现方式也有下面几个缺陷

  • 主工程需要关注所有组件,随着集成的组件越来越多,主工程需要初始化网络代理接口会越来越多

  • 由于主工程并不知道组件什么时候会启动,只能App启动就初始化网络代理,导致组件初始化提前

  • 所有直接和间接依赖的模块都需要由主工程来实现代理,很容易遗漏

通用信息拦截器自动注入

正因为上面两个实现方式或多或少都有问题,所以需要从网络库这一层来解决这个问题。 我们可以在网络层通过服务发现的能力,给外部提供一个通用网络信息拦截器注解, 一般由主工程实现, 完成默认信息的Header修改。创建网络Client实例时,自动查找app中被通用网络信息拦截器注解标注的拦截器。

线程池、连接池复用

各个组件都会有自己的网络Client实例,导致在同一个进程中,创建出来网络Client实例过多,同时线程池、连接池并没有复用。所以在网络库中,各个组件创建的网络Client默认会共享网络连接池和线程池,有特殊需要的模块,可以强制使用独立线程池和连接池。

作者:谢谢谢_xie
来源:juejin.cn/post/7074493841956405278

收起阅读 »

做一个具有高可用性的网络库(上)

Retrofit本身就是对于OkHttp库的封装,它的优点很很多,比如注解来实现的,配置简单,使用方便等。那为什么我们要做二次封装呢?最根本的原因还是我们现有的业务过于复杂,我们期望有更多的自定义的能力,有更好用的使用方式等。就好比下面这些自定义的能力这些目前...
继续阅读 »

在android中,网络模块是一个不可或缺的模块,相信很多公司都会有自建的网络库。目前市面上主流的网络请求框架都是基于okHttp做的延伸和扩展,并且android底层的网络库实现也使用OkHttp了,可见okHttp应用的广泛性。

Retrofit本身就是对于OkHttp库的封装,它的优点很很多,比如注解来实现的,配置简单,使用方便等。那为什么我们要做二次封装呢?最根本的原因还是我们现有的业务过于复杂,我们期望有更多的自定义的能力,有更好用的使用方式等。就好比下面这些自定义的能力

  1. 屏蔽底层的网络库实现

  2. 网络层统一处理code码和线程回调问题

  3. 网络请求绑定生命周期

  4. 网络层的全局监控

  5. 网络的调试能力

  6. 网络层对于组件化的通用能力支持

这些目前能力目前如果直接使用Retrofit,基本都是满足不了的。 本文是基于Retrofit + OkHttp提供的基础能力上,做的网络库的二次封装。主要介绍下如何在retrofit和Okhhtp的基础上,提供上述几个通用的能力。 本文需要有部分okHttp和retrofit源码的了解。 有兴趣的可以先查看官方文档,传送门:

屏蔽底层的网络库实现

虽然Retrofit是一个非常强大的封装框架,但是它并没有完全把网路库底层的实现的屏蔽掉。 默认的内部网络请求使用的okHttp,在我们创建Retrofit实例的时候,如果需要配置拦截器,就会直接依赖到底层的OkHttp,导致上层业务直接访问到了网络库的底层实现。这个对于后续的网络库底层的替换会是一个不小的成本。 因此,我们希望能够封装一层网络层,让业务的使用仅仅依赖到网络库的封装层,而不会使用到网络库的底层实现。 首先,我们需要先知道业务层当前使用到了哪些网络库底层的API, 其实最主要的还是拦截器这一层的封装。 拦截器这一层,主要涉及到几个类:

  1. Request

  2. Response

  3. Chain和Intercept 我们可以针对这几个类进行封装,定义对象接口,IRequest、IResponse、IChain和INetIntercept,这套接口不带任何具体实现。 然后在真正需要访问到具体的实例的时候,转化成具体的Request和Response等。我们可以看看在自己定义了一套拦截器之后,如何添加到之前OkHttp的流程中。 先看看IChain和INetIntercept的定义。

interface IChain {

   fun getRequestInfo(): IRequest

   @Throws(IOException::class)
   fun proceed(request: IRequest): IResponse?

}

interface INetInterceptor {
   @Throws(IOException::class)
   fun intercept(chain: IChain): IResponse?
}

在构造Retrofit的实例时,内部会尝试创建OkHttpClient,在此时把外部传入的INetInterceptor合并组装成一个OkHttp的拦截器,添加到OkHttpClient中。

 fun swicherToIntercept(list: MutableList<INetInterceptor>): Interceptor {
           return object: Interceptor {
               override fun intercept(chain: Interceptor.Chain): Response? {
                   val netRequest = IRequest(chain.request())
                   val realChain = IRealChain(0, netRequest, list as MutableList<IInterceptor>, chain, this)
                   val response: Response?
                   return (realChain.proceed(netRequest) as? IResponse)?.response
              }
          }
      }

整体修改后的拦截器的调用链如下所示:


上面举的只是在构建拦截器中的隔离,如果你们项目还有访问到其他内部的OkHttp的能力,也可以参照上面的封装流程,定义接口,在需要使用的地方转换为具体实现。

Retrofit的Call自定义

对于Retrofit,我们在接口中定义的方法就是每一个请求的配置,每一个请求都会被包装成Call。我们想要的请求做一些通用的逻辑处理和自定义,就比如在请求前做一些逻辑处理,请求后做一些逻辑处理,最后才返回给上层,就需要hook这个请求流程,可以做Retrofit的二次动态代理。 如果希望做一些更精细化的处理,hook能力就满足不了了。这种时候,可以选择使用自定义Call对象。如果整个Call对象都是我们提供的,我们当然可以在里面实现任何我们期望的逻辑。接下来简单介绍下如何自定义Retrofit的Call对象。

定义Call类型

class TestCall<T>(internal var call: Call<T>) {}

自定义CallAdapter

自定义CallAdapter时,需要使用我们前面自定义的返回值类型,并将call对象转化为我们我们自定义的返回值类型。

 class NetCallAdapter<R>(repsoneType: Type): CallAdapter<R, TestCall<R>> {
     override fun adapt(call: Call<R>): TestCall<R> {
       return TestCall(call)
  }
     override fun responseType(): Type {
       return responseType
  }
}
  1. 首先需要在class的继承关系上,显式的标明CallAdapter的第二个泛型参数是我们自定义的Call类型。

  2. 在adapt适配方法中,通过原始的call,转化为我们期望的TestCall。

自定义Factory

class NetCallAdapterFactory: CallAdapter.Factory() {
       override fun get(returnType: Type, annotations: Array<Annotation>, retrofit: Retrofit): CallAdapter<*, *>? {
       val rawType = getRawType(returnType)
       if (rawType == TestCall::class.java && returnType is ParameterizedType) {
           val callReturnType = getParameterUpperBound(0, returnType)
           return NetCallAdapter<ParameterizedType>(callReturnType)
      }
       return null
  }
}

在自定义的Factory中,根据从接口定义中获取到的网络返回值,匹配TestCall类型,如果匹配上,就返回我们定义的CallAdapter。

注册Factory

val builder = Retrofit.Builder()
   .baseUrl(retrofitBuilder.baseUrl!!)
   .client(client)
   .addCallAdapterFactory(NetCallAdapterFactory())

网络层统一处理code码和线程回调问题

code码统一处理

相信每一个产品都会定义业务错误码,每一个业务都可能有自己的一套错误码,有一些错误码可能是全局的,比如说登录过期、被封禁等,这种错误码可能跟特定的接口无关,而是一个全局的业务错误码,在收到这些错误码时,会有统一的逻辑处理。 我们可以先定义code码解析的接口

interface ICodehandler {
   fun handle(context: Context?, code: Int, message: String?, isBackGround: Boolean): Boolean
}

code码处理器的注册。

code码处理器的注册方式有两种,一种是全局的code码处理器。 在创建Retrofit实例的传入。

NetWorkClientBuilder()
       .addNetCodeHandler(SocialCodeHandler())
       .build()

另一种是在具体的网络请求时,传入错误码处理器,

TestInterface.inst.testCall().backGround(true)
       .withInterceptor(new CodeRespHandler() {
           @Override
           public boolean handle(int code, @Nullable String message) {
                ....
          }
      })
       .enqueue(null)

code码处理的调用

因为Call是我们自定义的,我们可以在网络成功的返回时,优先执行错误码处理器,如果命中业务错误码,那么对外返回失败。否则正常返回成功。

线程回调

OkHttp的callback线程回调默认是在子线程,retrofit的回调线程取决于创建实例时的配置,可以配置callbackExecutor,这个是对整个实例生效的,在这个实例内,所有的网络返回都会通过callbackExecutor。我们希望能够针对每一个接口单独配置回调的线程,所以同样基于自定义call的前提下,我们自定义Callback和UiCallback。

  • Callback: 表示当前回调线程无需主线程

  • UICallback: 表示当前回调线程需要在主线程

通用业务传入的接口类型就标识了当前回调的线程.

网络请求绑定生命周期

大部分网络请求都是异步发起的。所以可能会导致下面两个问题:

  • 内存泄漏问题

  • 空指针问题

先看一个比较常见的内存泄漏的场景

class XXXFragment {

   var unBinder: Unbinder? = null
   
   @BindView(R.id.xxxx)
   val view: AView;
   
    @Override
   public void onDestroyView() {
       unBinder?.unbind();
  }
   
   override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
     val view= super.onCreateView(inflater, container, savedInstanceState)
     unBinder = ButterKnife.bind(this, view)
     loadDataOfPay(1, 20)
     return view
  }
   
   private void testFun() {
       TestInterface.getInst().getTestFun()
              .enqueue(new UICallback<TestResponse>() {
                   @Override
                   public void onSuccessful(TestResponse test) {
                       view.xxxx = test.xxx
                  }

                   @Override
                   public void onFailure(@NotNull NetException e) {
                      ....
                  }
              });
  }
}

在上面的例子中,在testFun方法中编译后会创建匿名内部类,并且显式的调用了外部Fragment的View,一旦这个网络请求阻塞了,或者晚于这个Fragment的销毁时机回调,就会导致这个Fragment出现内存泄漏,直至这个请求正常结束返回。

更严重的是,这个被操作的view是通过ButterKnife绑定的,在Fragment走到onDestory之后就进行了解绑,会将这个View的值设置为null,导致在这个callback的回调时候,可能出现view为null的情况,导致空指针。 对于空指针的问题,我们可以看到有很多网络请求的回调都可能会出现类似下面的代码段。

 TestInterface.getInst().getTestFun()
               .enqueue(new UICallback<TestResponse>() {
                   @Override
                   public void onSuccessful(TestResponse test) {
                     if(!isFinishing() && view != null) {
                         view.xxxx = test.xxx
                    }  
                  }});

在匿名内部类回调时,通过判断页面是否已经销毁,以及view是否为空,再进行对应的UI操作。 我们通过动态代理来解决了这个空指针和内存泄漏的问题。 详细的方案可以阅读下这个文章匿名内部类导致内存泄漏的解决方案 因为我们把Activity、Fragment抽象为UIContext。在网络接口调用时,传入对应的UIContext,会将网络请求的Callabck通过动态代理,将Callback和UIContext进行关联,在页面销毁时,不进行回调。

自动Cancel无用请求

很多的业务场景中,在页面一进去就会触发很多网络请求,这个请求可能有一部分处于网络库的请求等待队列中,一部分处于进行中。当我们退出了这个页面之后,这些网络请求其实都已经没有了存在的意义。 所以我们可以在页面销毁时,取消还未发起和进行中的网络请求。 我们可以通过上面提过的UIContext,将网络请求跟页面进行关联。监听页面的生命周期,在页面关闭时,cancel掉对应的网络请求。

页面关联

在网络请求发起前,把当前的网络请求关联上对应的页面。

class TestCall {
   fun  enqueue(uiCallBack: Callback, uiContext: UIContext?) {
         LifeCycleRequestManager.registerCall(this, uiContext)
    ....
  }
   
}

internal object LifeCycleRequestManager {

   init {
       registerApplicationLifecycle()
  }
   private val registerCallMap = ConcurrentHashMap<Int, MutableList<BaseNetCall>>()

  }

ConcurrentHashMap的key为页面的HashCode,value的请求list。每一个页面都会关联一个请求List。

cancel请求

通过Application监听Activity、Fragment的生命周期。在页面销毁时,调用cancel取消对应的网络请求。

  private fun registerActivityLifecycle(app: Application) {
       app.registerActivityLifecycleCallbacks(object : Application.ActivityLifecycleCallbacks {
           override fun onActivityDestroyed(activity: Activity?) {
               registerCallMap.remove(activity.hashCode())
          }})
  }

这个是针对Activity的生命周期的监听。对于Fragment的生命周期的监听其实和Activity类似。

    private fun registerActivityLifecycle(app: Application) {
       app.registerActivityLifecycleCallbacks(object : Application.ActivityLifecycleCallbacks {
           override fun onActivityCreated(activity: Activity?, savedInstanceState: Bundle?) {
              (activity as? FragmentActivity)?.supportFragmentManager
                       ?.registerFragmentLifecycleCallbacks(fragmentLifecycleCallbacks, true)
          }})
  }

网络监听

网络模使用的场景非常多,当前出现问题的概率也更好,网络相关的问题非常多,比如网络异常、DNS解析失败、连接超时等。所以一套完善网络流程监控是非常有必要的,可以帮助我们在很多问题中快速分析出问题,提高我们的排查问题的效率

网络流程监控

根据OkHttp官方的EventListener提供的回调:OkHttpEvent事件,我们定义了以下几个一个网络请求中可能 触发的Action事件。

enum class NetEventType {
   EN_QUEUE, //入队
   NET_START, //网络请求真正开始执行
   DNS_START, //开始DNS解析
   DNS_END, //DNS解析结束
   CONNECT_START, //开始建立连接
   TLS_START, // TLS握手开始
   TLS_END, //TLS握手结束
   CONNECT_END, //建立连接结束
   RETRY, //尝试重新连接
   REUSE, //连接重用,从连接池中获取到连接
   CONNECTION_ACQUIRE, //获取到链接(可能不走连接建立,直接从连接池中获取)
   CONNECT_FAILED, // 连接失败
   REQUEST_HEADER_START, // request写Header开始
   REQUEST_HEADER_END, // request写Header结束
   REQUEST_BODY_START, // request写Body开始
   REQUEST_BODY_END, // request写Body结束
   RESPONSE_HEADER_START, // response写Header开始
   RESPONSE_HEADER_END, // response写Header结束
   RESPONSE_BODY_START, // response写Body开始
   RESPONSE_BODY_END, // response写Body结束
   FOLLOW_UP, // 是否发生重定向
   CALL_END, //请求正常结束
   CONNECTION_RELEASE, // 连接释放
   CALL_FAILED, // 请求失败
   NET_END, // 网络请求结束(包括正常结束和失败)

}

可以看到,除了okHttp原有的几个Event,还额外多了一个ENQUEUE事件。这个时机最主要的作用是计算出请求从调用到真正发起接口请求的等待时间。 当我们调用了RealCall.enqueue方法时,实际上这个接口请求并不是都会立即执行,OkHttp对于同一个时刻的请求数有限制。

  • 同一个Dispatcher,同一时刻并发数不能超过64

  • 同一个Host,同一时刻并发数不能超过5

 private final Deque<AsyncCall> readyAsyncCalls = new ArrayDeque<>();
private final Deque<AsyncCall> runningAsyncCalls = new ArrayDeque<>();

  synchronized void enqueue(AsyncCall call) {
   if (runningAsyncCalls.size() < maxRequests && runningCallsForHost(call) < maxRequestsPerHost) {
     runningAsyncCalls.add(call);
     executorService().execute(call);
  } else {
     readyAsyncCalls.add(call);
  }
}

所以一旦超过对应的阈值,当次请求就会被添加到readyAsyncCalls中,等待被执行。

根据这几个Action,我们可以将统计的时间分为下面几个阶段

enum class NetRecordItemType {
   WAIT, // 等待时间,入队到真正开始执行耗时
   DNS, // DNS耗时
   TLS, // TLS耗时
   RequestHeader, // request写入Header耗时
   RequestBody, // request写入Body耗时
   Request, // request写入header和body总耗时
   NetworkLatency, // 网络请求延时
   ResponseHeader, // response写入Header耗时
   ResponseBody, // response写入Body耗时
   Response, // response写入header和body总耗时
   Connect, // 连接建立总耗时
   RequestAndResponse, // 数据传输耗时
   CallTime, // 单次网络请求总耗时(包含排队时间)
   UNKNOWN
}

唯一ID

我们不仅仅想对整个网络的大盘进行监控,我们还希望能够精细化到每一个独立的网络请求进行监控。针对单个网络请求进行的监控的难点是我们如何去标志出来每一个网络请求,因为EventListener回调只会返回对应的call。

public abstract class EventListener {
   public void callStart(Call call) {}
   
   public void callEnd(Call call) {}
}

而这个Call没有办法与单个监控的请求进行关联。 并且在网络请求发起的阶段就需要标识出来,所以需要在Request创建的最前头就生成这个唯一ID。通过阅读源码,我们发现可以生成唯一id最早时机是在OkHttp的RealCall创建的最前头。

  RealCall(OkHttpClient client, Request originalRequest, boolean forWebSocket) {
  final EventListener.Factory eventListenerFactory = client.eventListenerFactory();

  this.client = client;
  this.originalRequest = originalRequest;
  this.forWebSocket = forWebSocket;
  this.retryAndFollowUpInterceptor = new RetryAndFollowUpInterceptor(client, forWebSocket);

  this.eventListener = eventListenerFactory.create(this);
}

其中,eventListenerFactory是由外部传递到Okhttp中的。

 public Builder eventListenerFactory(EventListener.Factory eventListenerFactory) {
     if (eventListenerFactory == null) {
       throw new NullPointerException("eventListenerFactory == null");
    }
     this.eventListenerFactory = eventListenerFactory;
     return this;
  }

因此,我们可以在EventListener.Factory中生成标记request的唯一Id。

internal class CallEventFactory(var configuration: CallEventConfiguration?) : EventListener.Factory {
   companion object {
       private val nextCallId = AtomicLong(1L)
  }

   override fun create(call: Call): EventListener {
        val callId = nextCallId.getAndIncrement()
  }
}

那生成的callId如何与request进行关联呢?最直接的是给Request添加一个Header的key。Request本身没有提供Api去修改Header。 所以这个时候就需要通过反射来设置, 先获取当前的Header,然后给header新增这个CallId,最后通过反射设置到request的header字段上。

fun appendToHeader(request: Request?, key: String?, value: String?) {
   key ?: return
   request ?: return
   value ?: return
   val headerBuilder = request.headers().newBuilder().add(key, value)
   ReflectUtils.setFieldValue(Request::class.java, request, NetCallAdapter.HEADER_NAME, headerBuilder.build())
  }

需要注意的是,因为使用了反射,所以需要在proguard文件中keep住request。 当然这个key最好能够不带到服务端,所以需要新增一个拦截器,添加到所有拦截器最后,这个这个唯一id的key就不会被添加到真正的请求上了。

class NetLastInterceptor: Interceptor {
   companion object {
       const val TAG = "NetLastInterceptor"

  }
   override fun intercept(chain: Interceptor.Chain): Response {
       val request = chain.request()
       val requestBuilder = request
              .newBuilder()
              .removeHeader(NetConstants.CALL_ID)
     
       return chain.proceed(requestBuilder.build())
  }
}

监控

在生成完唯一Id之后,我们再来看看如何外部是如何添加期望的网络监控的。

基于Client的监控

networkClient = NetWorkClientBuilder()
  .addLifecycleListener("*", object : INetLifecycleListener {
       override fun onLifecycle(info: INetLifecycleInfo) { }})
  .registerEventListener("xxxUrl", NetEventType.CALL_END, object : INetEventListener {
       override fun onEvent(event: NetEventType, request: NetRequest) { }})
      .build()

基于单个请求的监控

   TestInterface.inst.testFun()
          .addLifeCycleListener(object : INetLifecycleListener {
               override fun onLifecycle(info: INetLifecycleInfo) {} })
          .registerEventListener(mutableListOf(NetEventType.CALL_END, NetEventType.NET_START), object : INetEventListener {
               override fun onEvent(event: NetEventType, request: NetRequest) {} })
          .enqueue(null)

在创建EventListener时,按照下面的规则添加。

  1. 添加网络库系统的内部监听

  2. 添加OkHttpClient初始化配置的监听

  3. 添加单个请求配置的监听

基于单个请求的网络监控,需要提前把这个Request和网络监听的listener的关联关系存起来,因为EventListener的设置是针对整个OkHttpClient的生效的,所以需要在EventListener处理的过程中,获取当前的Request设置进去的listener。


续 做一个具有高可用性的网络库(下) 

作者:谢谢谢_xie
来源:juejin.cn/post/7074493841956405278

收起阅读 »

JS封装覆盖水印

web
废话开篇:简单实现一个覆盖水印的小功能,水印一般都是添加在图片上,然后直接加载处理过的图片url即可,这里并没有修改图片,而是直接的在待添加水印的 dom 上添加一个 canvas 蒙版。一、效果处理之前DIVIMG处理之后DIVIMG这里添加 “水印”(其实...
继续阅读 »

废话开篇:简单实现一个覆盖水印的小功能,水印一般都是添加在图片上,然后直接加载处理过的图片url即可,这里并没有修改图片,而是直接的在待添加水印的 dom 上添加一个 canvas 蒙版。

一、效果

处理之前

DIV


IMG


处理之后

DIV


IMG


这里添加 “水印”(其实并不是真正的水印) 到 DIV 的时候按钮点击事件并不会因为有蒙版遮挡而无法点击

二、JS 代码

class WaterMark{
   //水印文字
   waterTexts = []
   //需要添加水印的dom集合
   needAddWaterTextElementIds = null
   //保存添加水印的dom
   saveNeedAddWaterMarkElement = []
   //初始化
   constructor(waterTexts,needAddWaterTextElementIds){
       if(waterTexts && waterTexts.length != 0){
           this.waterTexts = waterTexts
      } else {
           this.waterTexts = ['水印文字哈哈哈哈','2022-12-08']
      }
       this.needAddWaterTextElementIds = needAddWaterTextElementIds
  }
   
   //开始添加水印
   startWaterMark(){
       const self = this
       if(this.needAddWaterTextElementIds){
           this.needAddWaterTextElementIds.forEach((id)=>{
               let el = document.getElementById(id)
               self.saveNeedAddWaterMarkElement.push(el)
          })
      } else {
           this.saveNeedAddWaterMarkElement = Array.from(document.getElementsByTagName('img'))
      }
       this.saveNeedAddWaterMarkElement.forEach((el)=>{
           self.startWaterMarkToElement(el)
      })
  }

   //添加水印到到dom对象
   startWaterMarkToElement(el){
       let nodeName = el.nodeName
       if(['IMG','img'].indexOf(nodeName) != -1){
           //图片,需要加载完成进行操作
           this.addWaterMarkToImg(el)
      } else {
           //普通,直接添加
           this.addWaterMarkToNormalEle(el)
      }
  }
       
   //给图片添加水印
   async addWaterMarkToImg(img){
       if(!img.complete){
           await new Promise((resolve)=>{
               img.onload = resolve
          })
      }
       this.addWaterMarkToNormalEle(img)
  }
   
   //给普通dom对象添加水印
   addWaterMarkToNormalEle(el){
       const self = this
       let canvas = document.createElement('canvas')
       canvas.width = el.width ? el.width : el.clientWidth
       canvas.height = el.height ? el.height : el.clientHeight
       let ctx = canvas.getContext('2d')
       let maxSize = Math.max(canvas.height, canvas.width)
       let font = (maxSize / 25)
       ctx.font = font + 'px "微软雅黑"'
       ctx.fillStyle = "rgba(195,195,195,1)"
       ctx.textAlign = "left"
       ctx.textBaseline = "top"
       ctx.save()
       let angle = -Math.PI / 10.0
       //进行平移,计算平移的参数
       let translateX = (canvas.height) * Math.tan(Math.abs(angle))
       let translateY = (canvas.width - translateX) * Math.tan(Math.abs(angle))
       ctx.translate(-translateX / 2.0, translateY / 2.0)
       ctx.rotate(angle)
       //起始坐标
       let x = 0
       let y = 0
       //一组文字之间间隔
       let sepY = (font / 2.0)
       while(y < canvas.height){
           //当前行的y值
           let rowCurrentMaxY = 0
           while(x < canvas.width){
               let totleMaxX = 0
               let currentY = 0
               //绘制水印
               this.waterTexts.forEach((text,index)=>{
                   currentY += (index * (sepY + font))
                   let rect = self.drawWater(ctx,text,x,y + currentY)
                   let currentMaxX = (rect.x + rect.width)
                   totleMaxX = (currentMaxX > totleMaxX) ? currentMaxX: totleMaxX
                   rowCurrentMaxY = currentY
              })
               x = totleMaxX + 20
          }
           //重置x,y值
           x = 0
           y += (rowCurrentMaxY + (sepY + font + (canvas.height / 5)))
      }
       ctx.restore()
       //添加canvas
       this.addCanvas(canvas,el)
  }

   //绘制水印
   drawWater(ctx,text,x,y){
       //绘制文字
       ctx.fillText(text,x,y)
       //计算尺度
       let textRect = ctx.measureText(text)
       let width = textRect.width
       let height = textRect.height
       return {x,y,width,height}
  }

   //添加canvas到当前标签的父标签上
   addCanvas(canvas,el){
       //创建div(canvas需要依赖一个div进行位置设置)
       let warterMarDiv = document.createElement('div')
       //关联水印dom对象
       el.warterMark = warterMarDiv
       //添加样式
       this.resetCanvasPosition(el)
       //添加水印
       warterMarDiv.appendChild(canvas)
       //添加到父标签
       el.parentElement.insertBefore(warterMarDiv,el)
  }

   //重新计算位置
   resetCanvasPosition(el){
       if(el.warterMark){
           //设置父标签的定位
           el.parentElement.style.cssText = `position: relative;`
           //设施水印载体的定位
           el.warterMark.style.cssText = 'position: absolute;top: 0px;left: 0px;pointer-events:none'
      }
  }
}

用法

<div>
   <!-- 待加水印的IMG -->
   <img style="width: 100px;height: auto" src="" alt="">
</div>

let waterMark = new WaterMark()
waterMark.startWaterMark();

ctx.save()ctx.restore() 其实在这里的作用不是很大,但还是添加上了,目的是保存添加水印前的上下文,跟结束绘制后恢复水印前的上下文,这样,这些斜体字只在这两行代码之间生效,下面如果再绘制其他,那么,将不受影响。

防止蒙版水印遮挡底层按钮或其他事件,需要添加 pointer-events:none 属性到蒙版标签上。

添加水印的标签外需要添加一个 父标签 ,这个 父标签 的作用就是添加约束 蒙版canvas 的位置,这里想通过 MutationObserver 观察 body 的变化来进行更新 蒙版canvas 的位置,这个尝试失败了,因为复杂的布局只要变动会都在这个回调里触发。因此,直接在添加水印的标签外需要添加一个 父标签 ,用这个 父标签 来自动约束 蒙版canvas 的位置。

MutationObserver 逻辑如下,在监听回调里可以及时修改布局或者其他操作(暂时放弃)。

var MutationObserver = window.MutationObserver || window.webkitMutationObserver || window.MozMutationObserver;
var mutationObserver = new MutationObserver(function (mutations) {
   //修改水印位置
})

mutationObserver.observe(document.getElementsByTagName('body')[0], {
   childList: true, // 子节点的变动(新增、删除或者更改)
   attributes: true, // 属性的变动
   characterData: true, // 节点内容或节点文本的变动
   subtree: true // 是否将观察器应用于该节点的所有后代节点
})

图片的大小只有在加载完成之后才能确定,所以,对于 IMG 的操作,需要观察它的 complete 事件。

三、总结与思考

canvas ctx.drawImage(img, 0, 0) 进行绘制,再将 canvas.toDataURL('image/png') 生成的 url 加载到之前的图片上,也是一种方式,但是,有时候会因为图片的原因导致最后的合成图片的 base64 数据是空,所以,直接增加一个蒙版,本身只是为了显示,并不是要生成真正的合成图片。实现了简单的伪水印,没有特别复杂的代码,代码拙劣,大神勿笑。

作者:头疼脑胀的代码搬运工
来源:juejin.cn/post/7174695149195231293

收起阅读 »

百度 Android 直播秒开体验优化

导读网络直播功能作为一项互联网基本能力已经越来越重要,手机中的直播功能也越来越完善,电商直播、新闻直播、娱乐直播等多种直播类型为用户提供了丰富的直播内容。随着直播的普及,为用户提供极速、流畅的直播观看体验也越来越重要。全文6657字,预计阅读时间17分钟。01...
继续阅读 »

导读

网络直播功能作为一项互联网基本能力已经越来越重要,手机中的直播功能也越来越完善,电商直播、新闻直播、娱乐直播等多种直播类型为用户提供了丰富的直播内容。随着直播的普及,为用户提供极速、流畅的直播观看体验也越来越重要。

全文6657字,预计阅读时间17分钟。

01 背景

百度 APP 作为百度的航母级应用为用户提供了完善的移动端服务,直播也作为其中一个必要功能为用户提供内容。随着直播间架构、业务能力逐渐成熟,直播间播放指标优化也越来越重要。用户点击直播资源时,可以快速的看到直播画面是其中一个核心体验,起播速度也就成了直播间优化中的一个关键指标。

02 现状

由于包体积等原因,百度 APP 的 Android 版中直播功能使用插件方式接入,在用户真正使用直播功能时才会将直播模块加载。为解决用户点击直播功能时需要等待插件下载、安装、加载等阶段及兼容插件下载失败的情况,直播团队将播放、IM 等核心能力抽到了一个独立的体积较小的一级插件并内置在百度 APP 中,直播间的挂件、礼物、关注、点赞等业务能力在另外一个体积较大的二级插件中。特殊的插件逻辑和复杂的业务场景使得 Android 版整体起播时长指标表现的不尽人意。

2022 年 Q1 直播间整体起播时长指标 80 分位在 3s 左右,其中二跳(直播间内上下滑)场景在 1s 左右,插件拆分上线后通过观察起播数据发现随着版本收敛,一跳进入直播间携带流地址(页面启动后会使用该地址预起播,与直播列表加载同步执行)场景起播时有明显的增长,从发版本初期 1.5s 左右,随版本收敛两周内会逐步增长到 2.5s+。也就是线上在直播间外点击直播资源进直播间时有很大一部分用户在点击后还需要等待 3s 甚至更长时间才能真正看到直播画面。这个时长对用户使用直播功能有非常大的负向影响,起播时长指标急需优化。

03 目标

△起播链路

起播过程简单描述就是用户点击直播资源,打开直播页面,请求起播地址,调用内核起播,内核起播完成,内核通知业务,业务起播完成打点。从对内核起播时长监控来看,直播资源的在内核中起播耗时大约为 600-700ms,考虑链路中其他阶段损耗以及二跳(直播间内上下滑)场景可以在滑动时提前起播,整体起播时长目标定位为1.5 秒;考虑到有些进入直播间的位置已经有了起播流地址,可以在某些场景省去 “请求起播地址” 这一个阶段,在这种直播间外已经获取到起播地址场景,起播时长目标定为 1.1 秒。

04 难点

特殊的插件逻辑和复杂的业务场景使得 Android 版每一次进入直播的起播链路都不会完全一样。只有一级插件且二级插件还未就绪时在一级插件中请求直播数据并起播,一二级插件都已加载时使用二级插件请求直播数据并处理起播,进直播间携带流地址时为实现秒开在 Activity 启动后就创建播放器使用直播间外携带的流地址起播。除了这几种链路,还有一些其他情况。复杂的起播链路就导致了,虽然在起播过程中主要节点间都有时间戳打点,也有天级别相邻两个节点耗时 80 分位报表,但线上不同场景上报的起播链路无法穷举,使用现有报表无法分析直播大盘起播链路中真正耗时位置。需要建立新的监控方案,找到耗时点,才能设计针对性方案将各个耗时位置进行优化。

05 解决方案

5.1 设计新报表,定位耗时点

△一跳有起播地址时起播链路简图

由于现有报表无法满足起播链路耗时阶段定位,需要设计新的监控方案。观察在打开直播间时有流地址场景的流程图(上图),进入直播间后就会同步创建直播间列表及创建播放器预起播,当直播间列表创建完毕且播放器收到首帧通知时起播流程结束。虽然用户点击到页面 Activity 的 onCreate 中可能有多个节点(一级插件安装、加载等),页面 onCreate 调用播放器预起播中可能多个节点,内核完成到直播业务收到通知中有多个节点,导致整个起播链路无法穷举。但是我们可以发现,从用户点击到 onCreate 这个路径是肯定会有的,onCreate 到创建播放器路径也是肯定有的。这样就说明虽然两个关键节点间的节点数量和链路无法确定,但是两个关键节点的先后顺序是一定的,也是必定会有的。由此,我们可以设计一个自定义链路起点和自定义链路终点的查询报表,通过终点和起点时间戳求差得到两个任意节点间耗时,将线上这两个节点所有差值求 80 分位,就可以得到线上起播耗时中这两个节点间耗时。将起播链路中所有核心关键节点计算耗时,就可以找到整个起播链路中有异常耗时的分段。

按照上面的思路开发新报表后,上面的链路各阶段耗时也就比较清晰了,见下图,这样我们就可以针对不同阶段逐个击破。

△关键节点间耗时

5.2 一跳使用一级插件起播

使用新报表统计的重点节点间耗时观察到,直播间列表创建(模版组件创建)到真正调用起播(业务视图就绪)中间耗时较长,且这个耗时随着版本收敛会逐步增加,两周内大约增加 1000ms,首先我们解决这两个节点间耗时增加问题。

经过起播链路观察和分析后,发现随版本收敛,这部分起播链路有较大变化,主要是因为随版本收敛,在二级插件中触发 “业务调用起播” 这个节点的占比增加。版本收敛期,进入直播间时大概率二级插件还未下载就绪或未安装,此时一级插件中可以很快的进行列表创建并创建业务视图,一级插件中在 RecyclerView 的 item attach 到视图树时就会触发起播,这个链路主要是等待内核完成首帧数据的拉取和解析。当二级插件逐渐收敛,进入直播间后一级插件就不再创建业务视图,而是有二级插件创建业务视图。由于二级插件中业务组件较多逐个加载需要耗时还有一级到二级中逐层调用或事件分发也存在一定耗时,这样二级插件起播场景就大大增加了直播间列表创建(模版组件创建)到真正调用起播(业务视图就绪)中间耗时。

5.2.1 一跳全部使用一级插件起播

基于上面的问题分析,我们修改了一跳场景起播逻辑,一跳全部使用一级插件起播。一级插件和二级插件创建的播放器父容器 id 是相同的,这样在一级插件中初始化播放器父容器后,当内核首帧回调时起播过程就可以结束了。二级插件中在初始化播放器父容器时也会通过 id 判断是否已经添加到视图树,只有在未添加的情况(二跳场景或一跳时出现异常)才会在二级中进行兜底处理。在一级插件中处理时速度可以更快,一级优先二级兜底逻辑保证了进入直播间后一定可以顺利初始化视图。

5.2.2 提前请求接口

使用由一起插件处理起播优化了二级插件链路层级较多问题,还有一个耗时点就是进直播间时只传入了房间 room_id 未携带流地址场景,此时需要通过接口请求获取起播数据后才能创建播放器和起播。为优化这部分耗时,我们设计了一个直播间数据请求管理器,提供了缓存数据和超时清理逻辑。在页面 onCreate 时就会触发管理器进行接口请求,直播间模版创建完成后会通过管理器获取已经请求到的直播数据,如果管理器接口请求还未结束,则会复用进行中请求,待请求结束后立刻返回数据。这样在进直播间未携带流数据时我们可以充分利用图中这 300ms 时间做更多必要的逻辑。


5.3 播放器Activity外预起播

通过进直播间播放器预创建、预起播、一跳使用一级插件起播等方案来优化进入直播间业务链路耗时后,业务链路耗时逐渐低于内核部分耗时,播放器内核耗时逐渐成为一跳起播耗时优化瓶颈。除了在内核内部探索优化方案,继续优化业务整个起播链路也是一个重要方向。通过节点间耗时可以发现,用户点击到 Activity 页面 onCrete 中间也是有 300ms 左右耗时的。当无法将这部分耗时缩到更短时,我们可以尝试在这段时间并行处理一些事情,减少页面启动后的部分逻辑。

一级插件在百度 APP 中内置后,设计并上线了插件预加载功能,上线后用户通过点击直播资源进入直播间的场景中,有 99%+ 占比都是直播一级插件已加载情况,一级插件加载这里就没有了更多可以的操作空间。但将预起播时机提前到用户点击处,可以将内核数据加载和直播间启动更大程度并行,这样来降低内核耗时对整个起播耗时影响。
△播放器在直播间外起播示意图

如上图,新增一个提前起播模块,在用户点击后与页面启动并行创建播放器起播并缓存,页面启动后创建播放器时会先从提前起播模块的缓存中尝试取已起播播放器,如果未获取到则走正常播放器创建起播逻辑,如果获取到缓存的播放器且播放器未发生错误,则只需要等待内核首帧即可。

播放器提前起播后首帧事件大概率在 Activity 启动后到达,但仍有几率会早于直播业务中设置首帧监听前到达,所以在直播间中使用复用内核的播放器时需要判断是否起播成功,如果已经起播成功需要马上分发已起播成功事件(含义区别于首帧事件,防止与首帧事件混淆)。

提前起播模块中还设计了超时回收逻辑,如果提前起播失败或 5s (暂定)内没有被业务复用(Activity 启动异常或其他业务异常),则主动回收缓存的播放器,防止直播间没有复用成功时提前创建的播放器占用较多内存及避免泄漏;超时时间是根据线上大盘起播时间决定,使用一个较大盘起播时间 80 分位稍高的值,防止起播还未完成时被回收,但也不能设置较长,防止不会被复用时内存占用较多。

通过提前起播功能,实验期命中提前起播逻辑较不进行提前起播逻辑,整体起播耗时 80 分位优化均值:450ms+。

5.4直播间任务打散

△内核首帧分发耗时

业务链路和内核链路耗时都有一定优化后,我们继续拆解重点节点间耗时。内核内部标记首帧通知到直播业务真正收到首帧通知之间耗时较长,如上图,线上内核首帧分发耗时 80 分位均值超过 1s,该分段对整体起播耗时优化影响较大。内核首帧是在子线程进行标记,通知业务时会通过主线程 Handler 分发消息,通过系统的消息分发机制将事件转到主线程。

通过排查内核标记首帧时间点到业务收到首帧通知事件时间点之间所有主线程任务,发现在首帧分发任务开始排队时,主线程任务队列中已有较多其他任务,其他事件处理时间较长,导致首帧分发排队时间较久,分发任务整体耗时也就较长。直播业务复杂度较高,如果内核首帧分发任务排队时直播间其他任务已在队列中或正在执行,首帧分发任务需要等直播任务执行完成后才能执行。

通过将直播间启动过程中所有主线程任务进行筛查,发现二级插件的中业务功能较多,整体加载任务执行时间较长,为验证线上也是由于二级业务任务阻塞了首帧分发任务,我们设计了一个二级组件加载需要等待内核首帧后才能进行的实验,通过实验组与对照组数据对比,在命中实验时首帧分发耗时和起播整体耗时全部都有明显下降,整体耗时有 500ms 左右优化。

通过实验验证及本地对起播阶段业务逻辑分析,定位到直播间各业务组件及对应视图的预加载数量较多且耗时比较明显,这个功能是二级插件为充分利用直播间接口数据返回前时间,二级插件加载后会与接口请求并行提前创建业务视图,提起初始化组件及视图为接口完成后组件渲染节省时间。如果不预创建,接口数据回来后初始化业务组件也会主动创建后设置数据。但将所有预创建任务全部串行执行耗时较长,会阻塞主线程,页面一帧中执行太多任务,也会造成页面明显卡顿。

发现这个阻塞问题后,我们设计了将预创建视图任务进行拆分打散,将一起执行的大任务拆分成多个小任务,每个组件的初始化都作为一个单独任务在主线程任务队列中进行排队等待执行。避免了一个大任务耗时特别长的问题。该功能上线后,整个二级插件中的组件加载大任务耗时降低了 40%+。

5.5 内核子线程分发首帧

由于主线程消息队列中任务是排队执行的,将阻塞首帧分发事件的大任务拆分成较多小任务后,还是无法解决首帧事件开始排队时这些小任务已经在主线程任务队列中排队问题。除了降低直播业务影响,还可以通过加快内核任务分发速度,使首帧分发耗时降低。需要设计一个在不影响内核稳定性与业务逻辑情况下内核首帧事件如何避免主线程排队或快速排队后被执行的方案。

为解决上面的问题, 我们推动内核,单独增加了一个子线程通知业务首帧事件能力。业务收到子线程中首帧回调后通过 Handler 的 postAtFrontOfQueue() 方法将一个新任务插到主线程任务队列最前面,这样主线程处理完当前任务后就可以马上处理我们新建的这个任务,在这个新任务中可以马上处理播放器上屏逻辑。无需等待播放内核原本的主线程消息。

主线程任务前插无法打断新任务排队时主线程中已经开始执行的任务,需要正在执行任务结束后才会被执行。为优化这个场景,内核通过子线程通知首帧后,播放器中需要记录这个状态,在一级插件及二级插件中的直播间业务任务执行开始前后,增加判断播放器中是否已经收到首帧逻辑,如果已经收到,就可以先处理上屏后再继续当前任务。

通过直播内核首帧消息在主线程任务队列前插和业务关键节点增加是否可上屏判断,就可以较快处理首帧通知,降低首帧分发对起播时长影响。

5.6 起播与完载指标平衡

直播间起播优化过程中,完载时长指标(完载时长:用户点击到直播间核心功能全部出现的时间,其中经历页面启动,直播间列表创建,二级插件下载、安装、加载,直播间接口数据请求,初始化直播间功能组件视图及渲染数据,核心业务组件显示等阶段)的优化也在持续进行。直播间二级插件是在使用二级插件中的功能时才会触发下载安装及加载逻辑,完载链路中也注意到了用户点击到页面 onCreate 这段耗时,见下图。

△页面启动耗时示意图

为优化直播间完载指标,直播团队考虑如果将插件加载与页面启动并行,那么完载耗时也会有一定的优化。直播团队继续设计了二级插件预加载方案,将二级插件加载位置提前到了用户点击的时候(该功能上线在 5.4、5.5 章节对应功能前)。该功能上线后试验组与对照组数据显示,实验组完载耗时较对照组确实有 300ms+ 优化。但起播耗时却出现了异常,实验组的起播耗时明显比对照组增长了 500ms+,且随版本收敛这个起播劣化还在增加。我们马上很快发现了这个异常,并通过数据分析确定了这个数据是正确的。完载的优化时如何引起起播变化的?

经过数据分析,我们发现起播受影响的主要位置还是内核首帧消息分发到主线程这个分段引起,也就是二级插件加载越早,内核首帧分发与二级组件加载时的耗时任务冲突可能性越大。确认问题原因后,我们做了 5.4、5.5 章节的功能来降低二级组件加载任务对起播影响。由于二级插件中的耗时任务完全拆分打散来缓解二级插件预下载带来的起播劣化方案复杂度较高,对直播间逻辑侵入太大,二级插件提前加载没有完全上线,完载的优化我们设计了其他方案来实现目标。

虽然不能在进入直播间时直接加载二级插件,但我们可以在进入直播间前尽量将二级插件下载下来,使用时直接加载即可,这个耗时相对下载耗时是非常小的。我们优化了插件预下载模块,在直播间外展示直播资源时触发该模块预下载插件。该模块会通过对当前设备网络、带宽、下载频次等条件综合判断,在合适的时机将匹配的二级插件进行下载,插件提前下载后对完载指标有较大优化。除了插件预下载,直播间内通过 5.4 章节直播间二级组件初始化拆分,也将全部组件初始化对主线程阻塞进行了优化,这样接口数据请求成功后可以优先处理影响完载统计的组件,其他组件可以在完载结束后再进行初始化,这个方案也对直播完载指标有明显优化。

除了以上两个优化方案,直播团队还在其他多个方向对完载指标进行了优化,同时也处理了完载时长与起播时长的指标平衡,没有因为一个指标优化而对其他指标造成劣化影响。最终实现了起播、完载指标全部达到目标。

06 收益

△2022 Android 端起播耗时走势

经过以上多个优化方案逐步迭代,目前 Android 端最新版本数据,大盘起播时间已经由 3s+ 降到 1.3s 左右;一跳带流地址时起播时长由 2.5s+ 左右降低到 1s 以内;二跳起播时长由 1s+ 降低到 700ms 以内,成功完成了预定目标。

07 展望

起播时长作为直播功能一个核心指标,还需要不断打磨和优化。除了业务架构上的优化,还有优化拉流协议、优化缓冲配置、自适应网速起播、优化 gop 配置、边缘节点加速等多个方向可以探索。百度直播团队也会持续深耕直播技术,为用户带来越来越好的直播体验。

作者:任雪龙
来源:百度Geek说 juejin.cn/post/7174596046641692709

收起阅读 »

本轮疫情期间的金庸梗大全

文/萧十一事情是这样的。就在几天前,关于“神雕大侠”的梗火了。该“阳过”的谐音梗一出,一时间,大有宝刀屠龙重出江湖之势。引得各路金庸迷们都撸起袖子,拔出各自的倚天剑,皆来争锋。先有影视迷,很快就凑了个“杨过”系列合集:后有位擅长漫画的,叫“李点点”的博主也迅速...
继续阅读 »


文/萧十一


事情是这样的。就在几天前,关于“神雕大侠”的梗火了。


该“阳过”的谐音梗一出,一时间,大有宝刀屠龙重出江湖之势。

引得各路金庸迷们都撸起袖子,拔出各自的倚天剑,皆来争锋。

先有影视迷,很快就凑了个“杨过”系列合集:

后有位擅长漫画的,叫“李点点”的博主也迅速跟上,其杨康画得颇有神韵:

接着进入第二赛段。

围绕杨过的周边相关人物的造梗开始了。

比如一辈子都没杨过的郭襄:


这也少不了博主小林的漫画版:


小林这版漫画还将内容扩充到了小龙女:

最后,还有位最夸张的影视迷,集齐杨过的所有“周边”,像是凑了个六大门派:

嗯,也还有这样的……

批判这种梗。

最后,赛梗进入冲刺赛段。

造梗的中心从杨过,也慢慢地过渡到更多的其他角色

本人自己也跟着造了个纪晓芙的:

后来才发现,还有这样的:
这样的:

甚至这样的:

当然了,这些梗仅供大家娱乐。

评论区留言:
  • 有请 全冠清 出来走两步[得意]
  • 郭襄也阳过,她住襄阳……

  • 大家多开窗通风,因为风清扬(阳)

  • 刘兰芳:今天我给大家讲的是满门忠烈《🐏家将》!

  • 好像明白了为何昨天突然郭襄上了微博热搜第一了

  • 想要不🐑去南阳(难阳)






收起阅读 »

前端实现电子签名(web、移动端)通用

web
前言在现在的时代发展中,从以前的手写签名,逐渐衍生出了电子签名。电子签名和纸质手写签名一样具有法律效应。电子签名目前主要还是在需要个人确认的产品环节和司法类相关的产品上较多。举个常用的例子,大家都用过钉钉,钉钉上面就有电子签名,相信大家这肯定是知道的。那作为前...
继续阅读 »

前言

在现在的时代发展中,从以前的手写签名,逐渐衍生出了电子签名。电子签名和纸质手写签名一样具有法律效应。电子签名目前主要还是在需要个人确认的产品环节和司法类相关的产品上较多。

举个常用的例子,大家都用过钉钉,钉钉上面就有电子签名,相信大家这肯定是知道的。

那作为前端的我们如何实现电子签名呢?其实在html5中已经出现了一个重要级别的辅助标签,是啥呢?那就是canvas

什么是canvas

Canvas(画布)是在HTML5中新增的标签用于在网页实时生成图像,并且可以操作图像内容,基本上它是一个可以用JavaScript操作的位图(bitmap)Canvas 对象表示一个 HTML 画布元素 -。它没有自己的行为,但是定义了一个 API 支持脚本化客户端绘图操作。

大白话就是canvas是一个可以在上面通过javaScript画图的标签,通过其提供的context(上下文)Api进行绘制,在这个过程中canvas充当画布的角色。

<canvas></canvas>

如何使用

canvas给我们提供了很多的Api,供我们使用,我们只需要在body标签中创建一个canvas标签,在script标签中拿到canvas这个标签的节点,并创建context(上下文)就可以使用了。

...
<body>
   <canvas></canvas>
</body>
<script>
   // 获取canvas 实例
   const canvas = document.querySelector('canvas')
   canvas.getContext('2d')
</script>
...

步入正题。

实现电子签名

知道几何的朋友都很清楚,线有点绘成,面由线绘成。

多点成线,多线成面。

所以我们实际只需要拿到当前触摸的坐标点,进行成线处理就可以了。

body中添加canvas标签

在这里我们不仅需要在在body中添加canvas标签,我们还需要添加两个按钮,分别是取消保存(后面我们会用到)。

<body>
   <canvas></canvas>
   <div>
       <button>取消</button>
       <button>保存</button>
   </div>
</body>

添加文件

我这里全程使用js进行样式设置及添加。

// 配置内容
   const config = {
       width: 400, // 宽度
       height: 200, // 高度
       lineWidth: 5, // 线宽
       strokeStyle: 'red', // 线条颜色
       lineCap: 'round', // 设置线条两端圆角
       lineJoin: 'round', // 线条交汇处圆角
  }

获取canvas实例

这里我们使用querySelector获取canvas的dom实例,并设置样式和创建上下文。

    // 获取canvas 实例
   const canvas = document.querySelector('canvas')
   // 设置宽高
   canvas.width = config.width
   canvas.height = config.height
   // 设置一个边框,方便我们查看及使用
   canvas.style.border = '1px solid #000'
   // 创建上下文
   const ctx = canvas.getContext('2d')

基础设置

我们将canvas的填充色为透明,并绘制填充一个矩形,作为我们的画布,如果不设置这个填充背景色,在我们初识渲染的时候是一个黑色背景,这也是它的一个默认色。

    // 设置填充背景色
   ctx.fillStyle = 'transparent'
   // 绘制填充矩形
   ctx.fillRect(
       0, // x 轴起始绘制位置
       0, // y 轴起始绘制位置
       config.width, // 宽度
       config.height // 高度
  );

上次绘制路径保存

这里我们需要声明一个对象,用来记录我们上一次绘制的路径结束坐标点及偏移量。

  • 保存上次坐标点这个我不用说大家都懂;

  • 为啥需要保存偏移量呢,因为鼠标和画布上的距离是存在一定的偏移距离,在我们绘制的过程中需要减去这个偏移量,才是我们实际的绘制坐标。

  • 但我发现chrome中不需要减去这个偏移量,拿到的就是实际的坐标,之前在微信小程序中使用就需要减去偏移量,需要在小程序中使用的朋友需要注意这一点哦。

    // 保存上次绘制的 坐标及偏移量
   const client = {
       offsetX: 0, // 偏移量
       offsetY: 0,
       endX: 0, // 坐标
       endY: 0
  }

设备兼容

我们需要它不仅可以在web端使用,还需要在移动端使用,我们需要给它做设备兼容处理。我们通过调用navigator.userAgent获取当前设备信息,进行正则匹配判断。

    // 判断是否为移动端
   const mobileStatus = (/Mobile|Android|iPhone/i.test(navigator.userAgent))

初始化

这里我们在监听鼠标按下(mousedown)(web端)/触摸开始(touchstart)的时候进行初始化,事件监听采用addEventListener

    // 创建鼠标/手势按下监听器
window.addEventListener(mobileStatus ? "touchstart" : "mousedown", init)

三元判断说明: 这里当mobileStatustrue时则表示为移动端,反之则为web端,后续使用到的三元依旧是这个意思。

声明初始化方法

我们添加一个init方法作为监听鼠标按下/触摸开始的回调方法。

这里我们需要获取到当前鼠标按下/触摸开始的偏移量和坐标,进行起始点绘制。

Tips:web端可以直接通过event中取到,而移动端则需要在event.changedTouches[0]中取到。

这里我们在初始化后再监听鼠标的移动。

    // 初始化
const init = event => {
// 获取偏移量及坐标
const { offsetX, offsetY, pageX, pageY } = mobileStatus ? event.changedTouches[0] : event

// 修改上次的偏移量及坐标
client.offsetX = offsetX
client.offsetY = offsetY
client.endX = pageX
client.endY = pageY

// 清除以上一次 beginPath 之后的所有路径,进行绘制
ctx.beginPath()

// 根据配置文件设置进行相应配置
ctx.lineWidth = config.lineWidth
ctx.strokeStyle = config.strokeStyle
ctx.lineCap = config.lineCap
ctx.lineJoin = config.lineJoin

// 设置画线起始点位
ctx.moveTo(client.endX, client.endY)

// 监听 鼠标移动或手势移动
window.addEventListener(mobileStatus ? "touchmove" : "mousemove", draw)
}

绘制

这里我们添加绘制draw方法,作为监听鼠标移动/触摸移动的回调方法。

    // 绘制
const draw = event => {
// 获取当前坐标点位
const { pageX, pageY } = mobileStatus ? event.changedTouches[0] : event
// 修改最后一次绘制的坐标点
client.endX = pageX
client.endY = pageY

// 根据坐标点位移动添加线条
ctx.lineTo(pageX , pageY )

// 绘制
ctx.stroke()
}

结束绘制

添加了监听鼠标移动/触摸移动我们一定要记得取消监听并结束绘制,不然的话它会一直监听并绘制的。

这里我们创建一个cloaseDraw方法作为鼠标弹起/结束触摸的回调方法来结束绘制并移除鼠标移动/触摸移动的监听。

canvas结束绘制则需要调用closePath()让其结束绘制

    // 结束绘制
const cloaseDraw = () => {
// 结束绘制
ctx.closePath()
// 移除鼠标移动或手势移动监听器
window.removeEventListener("mousemove", draw)
}

添加结束回调监听器

    // 创建鼠标/手势 弹起/离开 监听器
window.addEventListener(mobileStatus ? "touchend" :"mouseup", cloaseDraw)

ok,现在我们的电子签名功能还差一丢丢可以实现完了,现在已经可以正常的签名了。

我们来看一下效果:


取消功能/清空画布

我们在刚开始创建的那两个按钮开始排上用场了。

这里我们创建一个cancel的方法作为取消并清空画布使用

    // 取消-清空画布
   const cancel = () => {
       // 清空当前画布上的所有绘制内容
       ctx.clearRect(0, 0, config.width, config.height)
  }

然后我们将这个方法和取消按钮进行绑定

     <button onclick="cancel()">取消</button>

保存功能

这里我们创建一个save的方法作为保存画布上的内容使用。

将画布上的内容保存为图片/文件的方法有很多,比较常见的是blobtoDataURL这两种方案,但toDataURL这哥们没blob强,适配也不咋滴。所以我们这里采用a标签 ➕ blob方案实现图片的保存下载。

    // 保存-将画布内容保存为图片
   const save = () => {
       // 将canvas上的内容转成blob流
       canvas.toBlob(blob => {
           // 获取当前时间并转成字符串,用来当做文件名
           const date = Date.now().toString()
           // 创建一个 a 标签
           const a = document.createElement('a')
           // 设置 a 标签的下载文件名
           a.download = `${date}.png`
           // 设置 a 标签的跳转路径为 文件流地址
           a.href = URL.createObjectURL(blob)
           // 手动触发 a 标签的点击事件
           a.click()
           // 移除 a 标签
           a.remove()
      })
  }

然后我们将这个方法和保存按钮进行绑定

    <button onclick="save()">保存</button>

我们将刚刚绘制的内容进行保存,点击保存按钮,就会进行下载保存


完整代码

<!DOCTYPE html>
<html lang="en">
<head>
   <meta charset="UTF-8">
   <meta http-equiv="X-UA-Compatible" content="IE=edge">
   <meta name="viewport" content="width=device-width, initial-scale=1.0">
   <title>Document</title>
   <style>
      * {
          margin: 0;
          padding: 0;
      }
   </style>
</head>
<body>
   <canvas></canvas>
   <div>
       <button onclick="cancel()">取消</button>
       <button onclick="save()">保存</button>
   </div>
</body>
<script>
  // 配置内容
  const config = {
      width: 400, // 宽度
      height: 200, // 高度
      lineWidth: 5, // 线宽
      strokeStyle: 'red', // 线条颜色
      lineCap: 'round', // 设置线条两端圆角
      lineJoin: 'round', // 线条交汇处圆角
  }

  // 获取canvas 实例
  const canvas = document.querySelector('canvas')
  // 设置宽高
  canvas.width = config.width
  canvas.height = config.height
  // 设置一个边框
  canvas.style.border = '1px solid #000'
  // 创建上下文
  const ctx = canvas.getContext('2d')

  // 设置填充背景色
  ctx.fillStyle = 'transparent'
  // 绘制填充矩形
  ctx.fillRect(
      0, // x 轴起始绘制位置
      0, // y 轴起始绘制位置
      config.width, // 宽度
      config.height // 高度
  );

  // 保存上次绘制的 坐标及偏移量
  const client = {
      offsetX: 0, // 偏移量
      offsetY: 0,
      endX: 0, // 坐标
      endY: 0
  }

  // 判断是否为移动端
  const mobileStatus = (/Mobile|Android|iPhone/i.test(navigator.userAgent))

  // 初始化
  const init = event => {
      // 获取偏移量及坐标
      const { offsetX, offsetY, pageX, pageY } = mobileStatus ? event.changedTouches[0] : event

      // 修改上次的偏移量及坐标
      client.offsetX = offsetX
      client.offsetY = offsetY
      client.endX = pageX
      client.endY = pageY

      // 清除以上一次 beginPath 之后的所有路径,进行绘制
      ctx.beginPath()
      // 根据配置文件设置相应配置
      ctx.lineWidth = config.lineWidth
      ctx.strokeStyle = config.strokeStyle
      ctx.lineCap = config.lineCap
      ctx.lineJoin = config.lineJoin
      // 设置画线起始点位
      ctx.moveTo(client.endX, client.endY)
      // 监听 鼠标移动或手势移动
      window.addEventListener(mobileStatus ? "touchmove" : "mousemove", draw)
  }
  // 绘制
  const draw = event => {
      // 获取当前坐标点位
      const { pageX, pageY } = mobileStatus ? event.changedTouches[0] : event
      // 修改最后一次绘制的坐标点
      client.endX = pageX
      client.endY = pageY

      // 根据坐标点位移动添加线条
      ctx.lineTo(pageX , pageY )

      // 绘制
      ctx.stroke()
  }
  // 结束绘制
  const cloaseDraw = () => {
      // 结束绘制
      ctx.closePath()
      // 移除鼠标移动或手势移动监听器
      window.removeEventListener("mousemove", draw)
  }
  // 创建鼠标/手势按下监听器
  window.addEventListener(mobileStatus ? "touchstart" : "mousedown", init)
  // 创建鼠标/手势 弹起/离开 监听器
  window.addEventListener(mobileStatus ? "touchend" :"mouseup", cloaseDraw)
   
  // 取消-清空画布
  const cancel = () => {
      // 清空当前画布上的所有绘制内容
      ctx.clearRect(0, 0, config.width, config.height)
  }
  // 保存-将画布内容保存为图片
  const save = () => {
      // 将canvas上的内容转成blob流
      canvas.toBlob(blob => {
          // 获取当前时间并转成字符串,用来当做文件名
          const date = Date.now().toString()
          // 创建一个 a 标签
          const a = document.createElement('a')
          // 设置 a 标签的下载文件名
          a.download = `${date}.png`
          // 设置 a 标签的跳转路径为 文件流地址
          a.href = URL.createObjectURL(blob)
          // 手动触发 a 标签的点击事件
          a.click()
          // 移除 a 标签
          a.remove()
      })
  }
</script>
</html>

各内核和浏览器支持情况

Mozilla 程序从 Gecko 1.8 (Firefox 1.5 (en-US)) 开始支持 <canvas>。它首先是由 Apple 引入的,用于 OS X Dashboard 和 Safari。Internet Explorer 从 IE9 开始支持<canvas> ,更旧版本的 IE 中,页面可以通过引入 Google 的 Explorer Canvas 项目中的脚本来获得<canvas>支持。Google Chrome 和 Opera 9+ 也支持 <canvas>

小程序中提示

在小程序中我们如果需呀实现的话,也是同样的原理哦,只是我们需要将创建实例和上下文Api进行修改,因为小程序中是没有dom,既然没有dom,哪来的操作dom这个操作呢。

作者:桃小瑞
来源:juejin.cn/post/7174251833773752350

收起阅读 »