注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

你真的了解 RSA 加密算法吗?

沉淀、分享、成长,让自己和他人都能有所收获!😄记得那是我毕业🎓后的第一个秋天,申请了域名,搭建了论坛。可惜好景不长,没多久进入论坛后就出现各种乱七八糟的广告,而这些广告压根都不是我加的。这是怎么回事?后来我才知道,原来我的论坛没有加 HTTPS 也就是没有 S...
继续阅读 »

沉淀、分享、成长,让自己和他人都能有所收获!😄

记得那是我毕业🎓后的第一个秋天,申请了域名,搭建了论坛。可惜好景不长,没多久进入论坛后就出现各种乱七八糟的广告,而这些广告压根都不是我加的。


这是怎么回事?后来我才知道,原来我的论坛没有加 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 的数学数论知识


源码:github.com/fuzhengwei/…
作者:小傅哥
来源:juejin.cn/post/7173830290812370958

收起阅读 »

Android Jetpack:利用Palette进行图片取色

与产品MM那些事新来一个产品MM,因为比较平,我们就叫她A妹吧。A妹来第一天就指出:页面顶部的Banner广告位的背景是白色的,太单调啦,人家不喜欢啦,需要根据广告图片的内容自动切换背景颜色,颜色要与广告图主色调一致。作为一名合格的码农我直接回绝了,我说咱们的...
继续阅读 »

与产品MM那些事

新来一个产品MM,因为比较平,我们就叫她A妹吧。A妹来第一天就指出:页面顶部的Banner广告位的背景是白色的,太单调啦,人家不喜欢啦,需要根据广告图片的内容自动切换背景颜色,颜色要与广告图主色调一致。作为一名合格的码农我直接回绝了,我说咱们的应用主打简洁,整这花里胡哨的干嘛,劳民伤财。A妹也没放弃,与我深入交流了一夜成功说服了我。

其实要实现这个需求也不难,Google已经为我们提供了一个方便的工具————Palette。

前言

Palette即调色板这个功能其实很早就发布了,Jetpack同样将这个功能也纳入其中,想要使用这个功能,需要先依赖库

implementation 'androidx.palette:palette:1.0.0'

本篇文章就来讲解一下如何使用Palette在图片中提取颜色。

创建Palette

创建Palette其实很简单,如下

var builder = Palette.from(bitmap)
var palette = builder.generate()

这样,我们就通过一个Bitmap创建一个Pallete对象。

注意:直接使用Palette.generate(bitmap)也可以,但是这个方法已经不推荐使用了,网上很多老文章中依然使用这种方式。建议还是使用Palette.Builder这种方式。

generate()这个函数是同步的,当然考虑图片处理可能比较耗时,Android同时提供了异步函数

public AsyncTask<BitmapVoidPalette> generate(
       @NonNull final PaletteAsyncListener listener) {

通过一个PaletteAsyncListener来获取Palette实例,这个接口如下:

public interface PaletteAsyncListener {
   /**
    * Called when the {@link Palette} has been generated. {@code null} will be passed when an
    * error occurred during generation.
    */
   void onGenerated(@Nullable Palette palette);
}

提取颜色

有了Palette实例,就可以通过Palette对象的相应函数就可以获取图片中的颜色,而且不只一种颜色,下面一一列举:

  • getDominantColor:获取图片中的主色调

  • getMutedColor:获取图片中柔和的颜色

  • getDarkMutedColor:获取图片中柔和的暗色

  • getLightMutedColor:获取图片中柔和的亮色

  • getVibrantColor:获取图片中有活力的颜色

  • getDarkVibrantColor:获取图片中有活力的暗色

  • getLightVibrantColor:获取图片中有活力的亮色

这些函数都需要提供一个默认颜色,如果这个颜色Swatch无效则使用这个默认颜色。光这么说不直观,我们来测试一下,代码如下:

var bitmap = BitmapFactory.decodeResource(resourcesR.mipmap.a)
var builder = Palette.from(bitmap)
var palette = builder.generate()
color0.setBackgroundColor(palette.getDominantColor(Color.WHITE))
color1.setBackgroundColor(palette.getMutedColor(Color.WHITE))
color2.setBackgroundColor(palette.getDarkMutedColor(Color.WHITE))
color3.setBackgroundColor(palette.getLightMutedColor(Color.WHITE))
color4.setBackgroundColor(palette.getVibrantColor(Color.WHITE))
color5.setBackgroundColor(palette.getDarkVibrantColor(Color.WHITE))
color6.setBackgroundColor(palette.getLightVibrantColor(Color.WHITE))

运行后结果如下:


这样各个颜色的差别就一目了然。除了上面的函数,还可以使用getColorForTarget这个函数,如下:

@ColorInt
public int getColorForTarget(@NonNull final Target target@ColorInt final int defaultColor) {

这个函数需要一个Target,提供了6个静态字段,如下:

/**
* A target which has the characteristics of a vibrant color which is light in luminance.
*/
public static final Target LIGHT_VIBRANT;

/**
* A target which has the characteristics of a vibrant color which is neither light or dark.
*/
public static final Target VIBRANT;

/**
* A target which has the characteristics of a vibrant color which is dark in luminance.
*/
public static final Target DARK_VIBRANT;

/**
* A target which has the characteristics of a muted color which is light in luminance.
*/
public static final Target LIGHT_MUTED;

/**
* A target which has the characteristics of a muted color which is neither light or dark.
*/
public static final Target MUTED;

/**
* A target which has the characteristics of a muted color which is dark in luminance.
*/
public static final Target DARK_MUTED;

其实就是对应着上面除了主色调之外的六种颜色。

文字颜色自动适配

在上面的运行结果中可以看到,每个颜色上面的文字都很清楚的显示,而且它们并不是同一种颜色。其实这也是Palette提供的功能。

通过下面的函数,我们可以得到各种色调所对应的Swatch对象:

  • getDominantSwatch

  • getMutedSwatch

  • getDarkMutedSwatch

  • getLightMutedSwatch

  • getVibrantSwatch

  • getDarkVibrantSwatch

  • getLightVibrantSwatch

注意:同上面一样,也可以通过getSwatchForTarget(@NonNull final Target target)来获取

Swatch类提供了以下函数:

  • getPopulation(): 样本中的像素数量

  • getRgb(): 颜色的RBG值

  • getHsl(): 颜色的HSL值

  • getBodyTextColor(): 能都适配这个Swatch的主体文字的颜色值

  • getTitleTextColor(): 能都适配这个Swatch的标题文字的颜色值

所以我们通过getBodyTextColor()getTitleTextColor()可以很容易得到在这个颜色上可以很好现实的标题和主体文本颜色。所以上面的测试代码完整如下:

var bitmap = BitmapFactory.decodeResource(resourcesR.mipmap.a)
var builder = Palette.from(bitmap)
var palette = builder.generate()

color0.setBackgroundColor(palette.getDominantColor(Color.WHITE))
color0.setTextColor(palette.dominantSwatch?.bodyTextColor ?Color.WHITE)

color1.setBackgroundColor(palette.getMutedColor(Color.WHITE))
color1.setTextColor(palette.mutedSwatch?.bodyTextColor ?Color.WHITE)

color2.setBackgroundColor(palette.getDarkMutedColor(Color.WHITE))
color2.setTextColor(palette.darkMutedSwatch?.bodyTextColor ?Color.WHITE)

color3.setBackgroundColor(palette.getLightMutedColor(Color.WHITE))
color3.setTextColor(palette.lightMutedSwatch?.bodyTextColor ?Color.WHITE)

color4.setBackgroundColor(palette.getVibrantColor(Color.WHITE))
color4.setTextColor(palette.vibrantSwatch?.bodyTextColor ?Color.WHITE)

color5.setBackgroundColor(palette.getDarkVibrantColor(Color.WHITE))
color5.setTextColor(palette.darkVibrantSwatch?.bodyTextColor ?Color.WHITE)

color6.setBackgroundColor(palette.getLightVibrantColor(Color.WHITE))
color6.setTextColor(palette.lightVibrantSwatch?.bodyTextColor ?Color.WHITE)

这样每个颜色上的文字都可以清晰的显示。

那么这个标题和主体文本颜色有什么差别,他们又是如何的到的?我们来看看源码:

/**
* Returns an appropriate color to use for any 'title' text which is displayed over this
* {@link Swatch}'s color. This color is guaranteed to have sufficient contrast.
*/
@ColorInt
public int getTitleTextColor() {
   ensureTextColorsGenerated();
   return mTitleTextColor;
}

/**
* Returns an appropriate color to use for any 'body' text which is displayed over this
* {@link Swatch}'s color. This color is guaranteed to have sufficient contrast.
*/
@ColorInt
public int getBodyTextColor() {
   ensureTextColorsGenerated();
   return mBodyTextColor;
}

可以看到都会先执行ensureTextColorsGenerated(),它的源码如下:

private void ensureTextColorsGenerated() {
   if (!mGeneratedTextColors) {
       // First check white, as most colors will be dark
       final int lightBodyAlpha = ColorUtils.calculateMinimumAlpha(
               Color.WHITEmRgbMIN_CONTRAST_BODY_TEXT);
       final int lightTitleAlpha = ColorUtils.calculateMinimumAlpha(
               Color.WHITEmRgbMIN_CONTRAST_TITLE_TEXT);

       if (lightBodyAlpha != -1 && lightTitleAlpha != -1) {
           // If we found valid light values, use them and return
           mBodyTextColor = ColorUtils.setAlphaComponent(Color.WHITElightBodyAlpha);
           mTitleTextColor = ColorUtils.setAlphaComponent(Color.WHITElightTitleAlpha);
           mGeneratedTextColors = true;
           return;
      }

       final int darkBodyAlpha = ColorUtils.calculateMinimumAlpha(
               Color.BLACKmRgbMIN_CONTRAST_BODY_TEXT);
       final int darkTitleAlpha = ColorUtils.calculateMinimumAlpha(
               Color.BLACKmRgbMIN_CONTRAST_TITLE_TEXT);

       if (darkBodyAlpha != -1 && darkTitleAlpha != -1) {
           // If we found valid dark values, use them and return
           mBodyTextColor = ColorUtils.setAlphaComponent(Color.BLACKdarkBodyAlpha);
           mTitleTextColor = ColorUtils.setAlphaComponent(Color.BLACKdarkTitleAlpha);
           mGeneratedTextColors = true;
           return;
      }

       // If we reach here then we can not find title and body values which use the same
       // lightness, we need to use mismatched values
       mBodyTextColor = lightBodyAlpha != -1
               ? ColorUtils.setAlphaComponent(Color.WHITElightBodyAlpha)
              : ColorUtils.setAlphaComponent(Color.BLACKdarkBodyAlpha);
       mTitleTextColor = lightTitleAlpha != -1
               ? ColorUtils.setAlphaComponent(Color.WHITElightTitleAlpha)
              : ColorUtils.setAlphaComponent(Color.BLACKdarkTitleAlpha);
       mGeneratedTextColors = true;
  }
}

通过代码可以看到,这两种文本颜色实际上要么是白色要么是黑色,只是透明度Alpha不同。

这里面有一个关键函数,即ColorUtils.calculateMinimumAlpha()

public static int calculateMinimumAlpha(@ColorInt int foreground@ColorInt int background,
       float minContrastRatio) {
   if (Color.alpha(background!= 255) {
       throw new IllegalArgumentException("background can not be translucent: #"
               + Integer.toHexString(background));
  }

   // First lets check that a fully opaque foreground has sufficient contrast
   int testForeground = setAlphaComponent(foreground255);
   double testRatio = calculateContrast(testForegroundbackground);
   if (testRatio < minContrastRatio) {
       // Fully opaque foreground does not have sufficient contrast, return error
       return -1;
  }

   // Binary search to find a value with the minimum value which provides sufficient contrast
   int numIterations = 0;
   int minAlpha = 0;
   int maxAlpha = 255;

   while (numIterations <= MIN_ALPHA_SEARCH_MAX_ITERATIONS &&
          (maxAlpha - minAlpha> MIN_ALPHA_SEARCH_PRECISION) {
       final int testAlpha = (minAlpha + maxAlpha/ 2;

       testForeground = setAlphaComponent(foregroundtestAlpha);
       testRatio = calculateContrast(testForegroundbackground);

       if (testRatio < minContrastRatio) {
           minAlpha = testAlpha;
      } else {
           maxAlpha = testAlpha;
      }

       numIterations++;
  }

   // Conservatively return the max of the range of possible alphas, which is known to pass.
   return maxAlpha;
}

它根据背景色和前景色计算前景色最合适的Alpha。这期间如果小于minContrastRatio则返回-1,说明这个前景色不合适。而标题和主体文本的差别就是这个minContrastRatio不同而已。

回到ensureTextColorsGenerated代码可以看到,先根据当前色调,计算出白色前景色的Alpha,如果两个Alpha都不是-1,就返回对应颜色;否则计算黑色前景色的Alpha,如果都不是-1,返回对应颜色;否则标题和主体文本一个用白色一个用黑色,返回对应颜色即可。

更多功能

上面我们创建Palette时先通过Palette.from(bitmap)的到了一个Palette.Builder对象,通过这个builder可以实现更多功能,比如:

  • addFilter:增加一个过滤器

  • setRegion:设置图片上的提取区域

  • maximumColorCount:调色板的最大颜色数 等等

总结

通过上面我们看到,Palette的功能很强大,但是它使用起来非常简单,可以让我们很方便的提取图片中的颜色,并且适配合适的文字颜色。同时注意因为ColorUtils是public的,所以当我们需要文字自动适配颜色的情况时,也可以通过ColorUtils的几个函数自己实现计算动态颜色的方案。

作者:BennuCTech
来源:juejin.cn/post/7077380907333582879

收起阅读 »

炸裂的点赞动画

前言之前偶然间看到某APP点赞有个炸裂的效果,觉得有点意思,就尝试了下,轻微还原,效果图如下封装粒子从动画效果中我们可以看到,当动画开始的时候,会有一组粒子从四面八方散射出去,然后逐渐消失,于是可以定义一个粒子类包含以下属性public class Parti...
继续阅读 »

前言

之前偶然间看到某APP点赞有个炸裂的效果,觉得有点意思,就尝试了下,轻微还原,效果图如下


封装粒子

从动画效果中我们可以看到,当动画开始的时候,会有一组粒子从四面八方散射出去,然后逐渐消失,于是可以定义一个粒子类包含以下属性

public class Particle {
  public float x, y;
  public float startXV;
  public float startYV;
  public float angle;
  public float alpha;
  public Bitmap bitmap;
  public int width, height;
}
  • x,y是粒子的位置信息

  • startXV,startYV是X方向和Y方向的速度

  • angle是发散出去的角度

  • alpha是粒子的透明度

  • bitmap, width, height即粒子图片信息 我们在构造函数中初始化这些信息,给定一些默认值

public Particle(Bitmap originalBitmap) {
  alpha = 1;
  float scale = (float) Math.random() * 0.3f + 0.7f;
  width = (int) (originalBitmap.getWidth() * scale);
  height = (int) (originalBitmap.getHeight() * scale);
  bitmap = Bitmap.createScaledBitmap(originalBitmap, width, height, true);

  startXV = new Random().nextInt(150) * (new Random().nextBoolean() ? 1 : -1);
  startYV = new Random().nextInt(170) * (new Random().nextBoolean() ? 1 : -1);
  int i = new Random().nextInt(360);
  angle = (float) (i * Math.PI / 180);

  float rotate = (float) Math.random() * 180 - 90;
  Matrix matrix = new Matrix();
  matrix.setRotate(rotate);
  bitmap = Bitmap.createBitmap(bitmap, 0, 0, width, height, matrix, false);
  originalBitmap.recycle();
}

仔细看效果动画,会发现同一个图片每次出来的旋转角度会有不同,于是,在创建bitmap的时候我们随机旋转下图片。

绘制粒子

有了粒子之后,我们需要将其绘制在View上,定义一个ParticleView,重写onDraw()方法,完成绘制

public class ParticleView extends View {
   Paint paint;
   List<Particle> particles = new ArrayList<>();
   //.....省略构造函数
   @Override
   protected void onDraw(Canvas canvas) {
       super.onDraw(canvas);
       for (Particle particle : particles) {
           paint.setAlpha((int) (particle.alpha * 255));
           canvas.drawBitmap(particle.bitmap, particle.x - particle.width / 2, particle.y - particle.height / 2, paint);
      }
  }
   public void setParticles(List<Particle> particles) {
       this.particles = particles;
  }
}

管理粒子

绘制的时候我们发现需要不断改变粒子的x,y值,才能使它动起来,所以我们需要一个ValueAnimator,然后通过监听动画执行情况,不断绘制粒子。

private void startAnimator(View emiter) {
  ValueAnimator valueAnimator = ObjectAnimator.ofInt(0, 1).setDuration(1000);
  valueAnimator.addUpdateListener(animation -> {
      for (Particle particle : particles) {
          particle.alpha = 1 - animation.getAnimatedFraction();
          float time = animation.getAnimatedFraction();
          time *= 10;
          particle.x = startX - (float) (particle.startXV * time * Math.cos(particle.angle));
          particle.y = startY - (float) (particle.startYV * time * Math.sin(particle.angle) - 9.8 * time * time / 2);
      }
      particleView.invalidate();
  });
  valueAnimator.start();
}

由于我们的点赞按钮经常出现在RecyclerView的item里面,而点赞动画又是全屏的,所以不可能写在item的xml文件里面,而且我们需要做到0侵入,在不改变原来的逻辑下添加动画效果。

我们可以通过activity.findViewById(android.R.id.content)获取FrameLayout然后向他添加子View

public ParticleManager(Activity activity, int[] drawableIds) {
  particles = new ArrayList<>();
  for (int drawableId : drawableIds) {
      particles.add(new Particle(BitmapFactory.decodeResource(activity.getResources(), drawableId)));
  }
  topView = activity.findViewById(android.R.id.content);
  topView.getLocationInWindow(parentLocation);
}

首先我们通过构造函数传入当前Activity以及我们需要的图片资源,然后将图片资源都解析成Particle对象,保存在particles中,然后获取topView以及他的位置信息。

然后需要知道动画从什么位置开始,传入一个view作为锚点

public void start(View emiter) {
  int[] location = new int[2];
  emiter.getLocationInWindow(location);
  startX = location[0] + emiter.getWidth() / 2 - parentLocation[0];
  startY = location[1] - parentLocation[1];
  particleView = new ParticleView(topView.getContext());
  topView.addView(particleView);
  particleView.setParticles(particles);
  startAnimator(emiter);
}

通过传入一个emiter,计算出起始位置信息并初始化particleView中的粒子信息,最后开启动画。

使用

val ids = ArrayList<Int>()
for (index in 1..18) {
   val id = resources.getIdentifier("img_like_$index", "mipmap", packageName);
   ids.add(id)
}
collectImage.setOnClickListener {
   ParticleManager(this, ids.toIntArray())
      .start(collectImage)
}

运行之后会发现基本和效果图一致,但是其实有个潜在的问题,我们只是向topView添加了view,并没有移除,虽然界面上看不到,其实只是因为我们的粒子在最后透明度都是0了,将粒子透明度最小值设置为0.1后运行会发现,动画结束之后粒子没有消失,会越堆积越多,所以我们还需要移除view。

valueAnimator.addListener(new AnimatorListenerAdapter() {
   @Override
   public void onAnimationStart(Animator animation, boolean isReverse) {
  }
   @Override
   public void onAnimationEnd(Animator animation) {
       topView.removeView(particleView);
       topView.postInvalidate();
  }
   @Override
   public void onAnimationCancel(Animator animation) {
       topView.removeView(particleView);
       topView.postInvalidate();
  }
});

移除的时机放在动画执行完成,所以继续使用之前的valueAnimator,监听他的完成事件,移除view,当然,如果动画取消了也需要移除。

作者:晚来天欲雪_
来源:juejin.cn/post/7086471790502871054

收起阅读 »

记一次代码评鉴

web
前言近期公司组织了一次代码评鉴,在这边记录下学习到的一些规范吧案例案例1参数过多,改为对象好一些const start = (filename, version, isFirst, branch, biz) => {    // .....
继续阅读 »

前言

近期公司组织了一次代码评鉴,在这边记录下学习到的一些规范吧

案例

案例1

  • 参数过多,改为对象好一些

const start = (filename, version, isFirst, branch, biz) => {
   // ....
}

案例2

  • query不应该直接透传

  • 对象解构可能导致覆盖,可以调下顺序

// ...
await axios.post('xxx', {
   data: {
       host: 'xxx'
       ...getQuery()
  }
})

案例3

  • 超过三个条件的判断抽出为表达式或者函数

  • 魔法数字用变量代替

  • 与和非不一起使用

if (bottom < boxMaxH && topRemain < boxMax || top > 20) {
}

作者:沐晓
来源:juejin.cn/post/7173595497641443364

收起阅读 »

底层程序员4年的逆袭之旅:穷屌丝到小老板

我创业了3年前立的flag,现在做到了我当时难以想象的程度,我自己一直激励我自己,要努力,要坚持!结果如何,交给老天!我离职了,结束了4年的前端职业生涯,比我想象的要快很多!休息了几天,来聊一聊这几年的经历,希望能够给到大家帮助(挺后悔的,因为在这个时间点离职...
继续阅读 »

我创业了

  • 3年前立的flag,现在做到了我当时难以想象的程度,我自己一直激励我自己,要努力,要坚持!结果如何,交给老天!


  • 我离职了,结束了4年的前端职业生涯,比我想象的要快很多!休息了几天,来聊一聊这几年的经历,希望能够给到大家帮助(挺后悔的,因为在这个时间点离职,就意味着没有年终了,虽然已经说服了自己,但是,刚离职完,同事就和我说公司裁员了,血亏!!!!!!害想想就来气,我的N+1,4个月呢,十几万就这样飞了!好了不说了闲话了,难受!)

  • 月哥这一路走来充满鸡血,经常写鸡汤文章,激励大家,其实也在激励我自己在努力的奋斗,没办法,学历低,履历差,我哪有时间抱怨,我只能拼搏,奋斗,因为我知道我只有这样我才可能有一些,别人看不上的机会,但是需要我这种人拼尽全力,才能获取到的!

月哥这十年

  • 2012年上大学,因为穷,借了助学贷款,也在学校拿贫困生补助,安徽工程大学机电学院(三本计算机系)

  • 2014年参军-2016年退伍 中国人民解放军 陆军 某特殊兵种(其实当兵的主要原因就是穷,那时候当兵可以免学费,而且还有补助,我家里农村的,两个人上大学,欠了很多钱,学费也是东拼西凑,没办法!)

  • 2017年实习,学的java,实习岗位c#,在合肥,工资1900,干了29天被开。永远的痛啊

  • IT干不下去了,要生活,17年转行至游泳教练,龙格亲子游泳,干了一年,锻炼了自己很强的耐心,因为学员是0-6岁的宝宝!教他们游泳,玩水!


  • 18年5月底,说服自己重新干回前端,感觉自己在学习上从来没有做出过什么,高考也好,去部队也好,关于学习,一切都是不用心,除了学习其他都挺好,身体不错,但是始终在浑浑噩噩在逃避学习,不敢直面自己的内心,感觉天天泡水不是我自己想要的生活,同学们也在一个一个的找到不错的工作,甚至有的人还去了中大厂,又想到泡在水里的自己,心里很不是滋味,有一种叫做"挫败感"的东西,正在如影随形地跟着我;我决定再拼最后一把,失败与否,干一把;遂报名某马培训前端,

  • 18年6月底毕业!6年大学生涯,此时我24岁!属于大龄了!

  • 18年8月初,第一次找工作,学完jq,找到15k,因为没学框架,心里虚,拒绝了没去,然后自学node,vue

  • 18年9月中旬,出来面试,背完两本面试宝典!一天当场拿到两个offer,一个16k,一个17k,然后直接入职了;依稀的记得,那天下午面完试,我下了公司的楼,然后去小店买了包软中,蹲在路边抽,百感交集!10月底拿到19koffer,心虚没敢去!以前行情好,简历上不写项目都可以拿到offer,只要会吹,然后拿到了有生以来第一个学习上的第一名,某马上海前端25期,第一个找到工作的,也是工资最高的,5月中旬到9月18号找到工作,历时4个月

  • 入职3月就做了前端leader,优势能喝酒,能吹牛,能做事,敢承担,又是军人出身!领导比较喜欢!

  • 19年涨薪3千至2w

  • 19年10月出去面试,拿到某安 36w-42w offer,去了要降月base,年终8-12月,觉得不靠谱,没去!同时背调因为第一家公司的问题,背调出了问题,吃一堑长一智!

  • 20年7月跳槽至目前这家公司,工资23K * 14 ; 试用期6个月结束:表现突出,涨薪4k,年中又涨薪2k ,21年中旬,base到29k , 职级p6+,又带了小团队!

  • 21年出去面试,拿到(29+1)* 17 的offer,医疗行业,没去,还有其他一堆的offer!

  • 22年出去面试,拿到 44*16 offer,链圈技术专家岗位,没去!

  • 22年11月,离职创业!结束4年前端职业生涯,开始新的职业!

关于学习

大家都知道我比较的努力,喜欢写励志文章,因为我相信,当数量累计到一定程度的时候,就会发生质的变化,这句话也在我身上深刻的体现出来。学不会我就硬学,我智商不够体力来凑,结果????,不坚持做怎么知道结果是好是坏,于是乎我秉承大力出奇迹的思路,疯狂学习

  • 在培训班的时候,我先预习课程,因为可以在网上买到录屏课,然后代码先敲三遍,然后第二天上课的时候等于就在复习,我的学习节奏保持领先学校的课程一周,就这样还是效果不好,我抓狂啊,我怀疑我自己,表面上每天都在继续,每天似乎都在一样的过去,但某些在内心深处涌动的东西却在不断的积累,直到后来我才知道,那是斗志,来自狼血深处的斗志,终于在一天爆发了。我在电脑上冲动地敲上了给自己看的一行字,“要么赢得风风光光,要么输得精精光光”,狂怒的表达了那种必死的心理,近乎于歇斯底里的疯。

  • 然后学完js就是疯狂刷面试题,刚开始确实不会,也看不太懂,但是我猖狂啊,我尼玛,都这样,还能输啥,干 !我就背下来,两本面试宝典扎扎实实的背了下来,然后到天台,让同学随便出题,我来答,就这样循环往复,我背下来了,并且掌握了面试的答题套路,同时我也理解了很多知识点。

  • 学到js其实也是一知半解,然后开始抄笔记,疯狂抄,笔记本抄完**,发现复盘的时候翻页太麻烦,还是不好记,然后想到抄到a4纸上面,这样每一页的内容就是非常多了,然后 贴墙上,天天背,后面自然就通了**!



  • 然后就有了后来的故事,学完jq出去面试就拿到了15k,然后自学完了vue,node,拿到了16k,17k,19k,20k,29k,30多k,44k ,时间记录成长,虽然不跳槽,但是我没事喜欢出去面试,看看自己还值多少钱!




  • 时间记录了成长,努力见证了成果!入职之后也是抓一切能够学习的机会,地铁上摸鱼时间,早起... 反正就是学,狂学!


  • 大家看我的学习经历就知道,我不是一个智商很高的人,刚开始学什么都很慢,就是硬怼数量,硬坚持,虽然不知道能够带了什么样的结果,但是每次收获的结果都是令自己什么吃惊的!

焦虑,迷茫

  • 高强度的学习,肯定是充满疲惫,焦虑,同时没有取得成果之前,你自己也会很迷茫,我也是!

  • 我不知道我这么努力,我能不能取得一些成果,但是,我确信一点就是,我如果不努力,我肯定毛机会都没有。所以我强压自己,先做,坚持持续的做,每当焦虑的时候,我就会去抄书抄代码看书,这些真的很有效果,阅读可以让我安静下来,抄书也能够让我安静下来,忙起来就没有时间乱想了,前期是非常痛苦的,但是后面就是很舒服了。

  • 我专门弄了一个文件,然后就是在焦虑的时候狂抄代码,类似promise啊,各种手写,少则无脑写了10多遍,多则几十遍,那必然是都背下来了,那必然是理解了其中的逻辑!经过时间的积累,大家可以猜一猜这个文件的代码行数有多少了,有没有过20w行代码?所以焦虑一直伴随着我,直到现在,但是我不怕,焦虑了我就去学习,不管目标,就是干!

关于生活

  • 因为以前都很穷,物质欲望没有那么高,我刚来上海的时候,那时候工资3000多,刚开始住在宿舍,但是我觉得比较贵,每个月得快1000块钱,因为每个月还得存1000多,所以觉得成本太高了,当你穷的时候,1块钱都能难倒英雄汉呀!

  • 于是我搬家到了虹桥的华漕的民房,因为便宜,但是生活条件差了比较多,没有厕所,几平米的房间,连桌子都放不下,还得600多块,上厕所得跑到村里的公厕,冬天还能忍一忍,但是夏天不行,实在没法洗澡,身上臭的很,于是又换了旁边的二楼

  • 然后就是热,贼热,因为二楼就是顶楼,楼板房子,住过的应该都知道,当然肯定没有空调的,连电风扇都没有!对!我为了省钱,我连电风扇都没有买,就是这个刚!但是好处是门口有水龙头,实在太热,我就出来,冲凉,然后再回去睡

  • 终于买了电风扇,为什么呢,因为我的同学来上海找工作,然后住在我这里,当天晚上他就受不了了,他问我你怎么能住的下去的,然后我花了几十块钱,买了电风扇,于是我有的电风扇,然后他在这住了两周,就回了老家,说在上海实在是太艰苦了。先走了!

  • 上班的话,就骑不知道多少手的n手自行车,然后有时候骑车到淞虹路,有时候骑到二号航站楼,反正都是几公里。小黄车我肯定是不舍得骑的,毕竟基本每天都骑,挺贵的!

  • 累习惯,反正也能受得了,吃的饱就好,因为当过兵,在部队的时候经历的可能比这个艰苦多了,所以我觉得还挺好,挺幸福了!

  • 然后就是转行到了前端,17年来的上海,18年底才终于住了有空调的房子,然后就是一室户,然后就是一室一厅,然后就是三室一厅

  • 到现在,正在买房子,准备在上海定居了!

关于工作

  • 我是很卷的,很努力,领导都喜欢我这种人,听话,干活快,产出高,能加班,问题少,还能喝酒!

  • 我在这家公司,公司的领导,hr都知道我干副业,但是也没说啥,因为我干活还是很好的,产出高,bug少,第一家公司优秀员工,目前这家公司连续获得季度优秀员工!

  • 这样做的好处就是,每次我找领导加工资,都很有话语权,第一家公司,我要求加5000,然后加了3000,第二家,转正我就要加工资,加了4000,然后年中又加了2000;我就秉承着我干的好,你就必须给我加钱,不然我没有动力干,半年不加工资,我就感觉没有工作动力了,那我就得跑路(因为我有随时跳槽的能力)。因为表现确实不错,所以每次加薪都很顺利!

  • 我入职一家公司,我就开始准备跳槽了,因为我相信机会是留给有准备的人的,下面是我真实的简历,现在也用不到了,分享给大家看看!

  • 我在公司的人际关系我觉得还是很不错的,包括和后端,产品测试同学,我其实9月份就提离职了,我们领导说,你不如忍一忍,拿完年终再走,毕竟也不少钱,然后hr也是这样说,你不再考虑考虑拿完年终再走!但是现在实在太忙,我也实话实说,留下来也会影响到工作,虽然我不用怎么干活,熬到年后也是可以的,但是人过留名,我不想背骂名,哈哈!然后就愉快的离职了!

  • 要明确自己打工人的身份,我就要多挣钱,你只要给我画饼,我就要立马吃,顺着你的路走,你不得不给我钱。我们在公司就要自己的利益最大化,干好事是前提,不然你没有话语权!我只卷学习,技术越来越好,所以我干活效率高,干活快,每天又大量的时间摸鱼,那我就疯狂的学习,学源码!领导喜欢看,那我就做你喜欢的事,这样我找你加工资的时候,你就没话说的吧!职场小技巧,哈哈!

关于副业--->主业

  • 为什么离职,因为副业做的太大了,今年暴涨,学员也突破了千人,团队的规模也越来越大,开公司,整合团队,希望把这件事做的越来越好,目前团队有13个人,4名p8+级别大佬,3位p7大佬,有5名伙伴全职all in到这块,主要分为两块,培训(0基础,面试辅导,进阶辅导,晋升辅导,管理辅导),和接外包私活!

  • 这一年多以来,基本上很少的休息时间,因为周六日要给学员面试辅导,而且还要写文章,自己还得不断的学习,经常就是3-4点睡觉,然后7-8点起床去上班,有的时候每天只睡2个多小时,有时候凌晨我还在群里发消息,没办法,你做了这件事,就得把这件事做好!贼累,因为要同时兼顾好副业,和工作!

  • 我老婆之前问我,你不累吗?我说怎么可能不累,但是还能坚持!

  • 然后11月魔鬼训练营放开了报名,一下子报名了50多位同学,直接给我顶离职了,原来你用心做培训,是真的会被别人认可的!市场不会说谎!

争议,谩骂

  • 做培训,经常被骂被diss,说你割韭菜,就包括写文章,文末引流,被骂的太多了,但是了解下来的就会知道,我们不是卖课的,我们是做服务的,1对1辅导,你直接来吃我们这群里整理好的面试,项目经验,来1对1的给你把关,给你指导,你的提高能不快吗!

  • 而且看这个报名的人数就知道,我们效果差的话,怎么可能这么多人来报名,而且大家都是在职的,有的年入百万也来报名学习,很多大厂的同学也来报名辅导,因为这个不是吹出来的,真正的让他们看到了来到这边能够获取到价值的

  • 我的学员拿遍了全国所有大厂的offer,薪资比我高的有好多个,有的还是我的两倍,这是我最骄傲的事情,最远的学员,在美国,新加坡,目前也进了美国的top大厂,很开心,做培训最开心的事情莫过于学员能够超过自己,我们也是毫无保留的给他们辅导!

  • 骂我也好,说我割韭菜也罢,我现在也不太关注这些声音,我只要能给学员带来真正的提升就好。你不参与,你根本不知道我做的好不好,教的垃圾不垃圾!

关于身体

  • 很多人觉得我吃不消,虽然我很久没锻炼了,但是老本厚,来来来,上图!


  • 所以,加班熬夜我顶的住,然后现在离职了,也就开始有时间锻炼了!此处需要66个赞,哈哈!关于我怎么练的,我这么扣的人怎么可能去健身房,从小就壮,干农活,十多岁就能开拖拉机下地了,穷人的孩子早当家!

  • 大家一定要注意身体!别硬干!身体是革命的本钱,我也是日常去医院检查身体,切记,身体第一!

关于收入

  • 说一个笑话吧!以下纯属虚构--> 收入暴涨的心态,当第一个月收入过10万的时候,兴奋啊,激动啊,后面每个月,都越来越多之后,心态也就没那么激动了,很平常了!

  • 士兵突击里面的装甲团的团长说过一句话,一直激励着我,“想要,和得到之间还需要做到!”

关于未来

  • 我很早就清晰的明白,拼技术,很难拼的过各位大佬,你们随便学一学都比我强很多,这是必须要承认的;就像我们培训出来的一些校招拿到sp的同学,一毕业就30k,我这种学历怎么比,没法比呀!虽然说这四年看来在工作技术上还可以,但是对比顶层还有巨大的差距,这些差距不是努力能够追上的,因为别人也在努力,只有反其道而行,利用自己的优势,把自己的优势无限的扩大,才能够有机会。我技术上干不过你,那我就把你招进来,做我的合作伙伴!这种方式能够最快弥补自己短板,然后就没有短板了!以前不敢想,现在团队招来了这么多p7,p8的大佬,我在正确的道路上坚持的做着,就是为了能够给到学员带来巨大的价值,学各位大佬的精华,补充到自己身上!我们能够做的就是,想尽一切办法快速的提升实力,找这些大佬solo,无疑是最快的方式!

  • 我自身有很强的毅力和决心,以及很好的自律性,这些年的经历告诉我,韧性非常重要,坚持住,就算失败了,也无所谓,过程最精彩,结果就是成盒 ,so what!

  • 嗯嗯嗯,不知道能做到什么程度,但是不变的是,要努力,要坚持,要做学员的榜样,既然选择了创业,all in 在前后端培训上面,就要好好服务学员,给他们带来价值,让他们觉得花的钱太值了,那我就扛起枪,持续的战斗下去!结果交给老天,管它个锤子!

寄语

  • 抱怨没有用,焦虑是日常,当你抱怨时,当你焦虑中,其实你是对现状的不满,你内心肯定要往好的方面走,你还有变好的欲望,我们需要这些欲望,来当作我们的动力源泉,持续坚持下去!反向去利用,你会得到正向反馈!

  • 当你一点焦虑都没有的时候,你也不会抱怨的时候,那么恭喜你看透了

  • 很多人说努力没有用,我想问你真正努力过吗?这个你得和你自己对话,你才能够更加的清晰,不要假努力,结果不会说谎;你基础很好,算法很好,源码很好,项目也很有思考,你会拿到很差的offer吗? 我想大概率不会,你不一定能够进大厂,履历、学历、运气也有很大的关系,但是有的不错的涨薪还是能够做到的,和别人比太累,只和自己的昨天比。我进步了,我就很开心!大家加油,不卷,也不能躺平,别等到被裁,然后不好找工作才知道,再去后悔,那时候你会发现努力学习真的有用

  • 千万、一定,不要放弃努力!或许段时间内看不到结束,但厚积薄发才是最佳方式背水一战,逼到绝境反而可能练出真本事!因为没的选!

  • 好了,这是我的4年,大家看一看我是拼的学历、智商、履历还是拼的努力、坚持、韧性!我是月哥,我为自己代言!下一个四年拭目以待!

  • 感谢大家的观看点赞转发;也可添加月哥微信lisawhy0706沟通交流,结尾依旧是:长风破浪会有时,直挂云帆济沧海


作者:前端要努力
来源:juejin.cn/post/7170596452266147871

收起阅读 »

这10张图拿去,别再说学不会RecyclerView的缓存复用机制了!

ViewPager2是在RecyclerView的基础上构建而成的,意味着其可以复用RecyclerView对象的绝大部分特性,比如缓存复用机制等。 作为ViewPager2系列的第一篇,本篇的主要目的是快速普及必要的前置知识,而内容的核心,正是前面所提到的R...
继续阅读 »

ViewPager2是在RecyclerView的基础上构建而成的,意味着其可以复用RecyclerView对象的绝大部分特性,比如缓存复用机制等。


作为ViewPager2系列的第一篇,本篇的主要目的是快速普及必要的前置知识,而内容的核心,正是前面所提到的RecyclerView的缓存复用机制。




RecyclerView,顾名思义,它会回收其列表项视图以供重用


具体而言,当一个列表项被移出屏幕后,RecyclerView并不会销毁其视图,而是会缓存起来,以提供给新进入屏幕的列表项重用,这种重用可以:




  • 避免重复创建不必要的视图




  • 避免重复执行昂贵的findViewById




从而达到的改善性能、提升应用响应能力、降低功耗的效果。而要了解其中的工作原理,我们还得回到RecyclerView是如何构建动态列表的这一步。


RecyclerView是如何构建动态列表的?


与RecyclerView构建动态列表相关联的几个重要类中,Adapter与ViewHolder负责配合使用,共同定义RecyclerView列表项数据的展示方式,其中:




  • ViewHolder是一个包含列表项视图(itemView)的封装容器,同时也是RecyclerView缓存复用的主要对象




  • Adapter则提供了数据<->视图 的“绑定”关系,其包含以下几个关键方法:



    • onCreateViewHolder:负责创建并初始化ViewHolder及其关联的视图,但不会填充视图内容。

    • onBindViewHolder:负责提取适当的数据,填充ViewHolder的视图内容。




然而,这2个方法并非每一个进入屏幕的列表项都会回调,相反,由于视图创建及findViewById执行等动作都主要集中在这2个方法,每次都要回调的话反而效率不佳。因此,我们应该通过对ViewHolder对象积极地缓存复用,来尽量减少对这2个方法的回调频次。




  1. 最优情况是——取得的缓存对象正好是原先的ViewHolder对象,这种情况下既不需要重新创建该对象,也不需要重新绑定数据,即拿即用。




  2. 次优情况是——取得的缓存对象虽然不是原先的ViewHolder对象,但由于二者的列表项类型(itemType)相同,其关联的视图可以复用,因此只需要重新绑定数据即可。




  3. 最后实在没办法了,才需要执行这2个方法的回调,即创建新的ViewHolder对象并绑定数据。




实际上,这也是RecyclerView从缓存中查找最佳匹配ViewHolder对象时所遵循的优先级顺序。而真正负责执行这项查找工作的,则是RecyclerView类中一个被称为回收者的内部类——Recycler


Recycler是如何查找ViewHolder对象的?



/**
* ...
* When {@link Recycler#getViewForPosition(int)} is called, Recycler checks attached scrap and
* first level cache to find a matching View. If it cannot find a suitable View, Recycler will
* call the {@link #getViewForPositionAndType(Recycler, int, int)} before checking
* {@link RecycledViewPool}.
*
* 当调用getViewForPosition(int)方法时,Recycler会检查attached scrap和一级缓存(指的是mCachedViews)以找到匹配的View。
* 如果找不到合适的View,Recycler会先调用ViewCacheExtension的getViewForPositionAndType(RecyclerView.Recycler, int, int)方法,再检查RecycledViewPool对象。
* ...
*/
public abstract static class ViewCacheExtension {
...
}

    public final class Recycler {
...
/**
* Attempts to get the ViewHolder for the given position, either from the Recycler scrap,
* cache, the RecycledViewPool, or creating it directly.
*
* 尝试通过从Recycler scrap缓存、RecycledViewPool查找或直接创建的形式来获取指定位置的ViewHolder。
* ...
*/
@Nullable
ViewHolder tryGetViewHolderForPositionByDeadline(int position,
boolean dryRun, long deadlineNs) {
if (mState.isPreLayout()) {
// 0 尝试从mChangedScrap中获取ViewHolder对象
holder = getChangedScrapViewForPosition(position);
...
}
if (holder == null) {
// 1.1 尝试根据position从mAttachedScrap或mCachedViews中获取ViewHolder对象
holder = getScrapOrHiddenOrCachedHolderForPosition(position, dryRun);
...
}
if (holder == null) {
...
final int type = mAdapter.getItemViewType(offsetPosition);
if (mAdapter.hasStableIds()) {
// 1.2 尝试根据id从mAttachedScrap或mCachedViews中获取ViewHolder对象
holder = getScrapOrCachedViewForId(mAdapter.getItemId(offsetPosition),
type, dryRun);
...
}
if (holder == null && mViewCacheExtension != null) {
// 2 尝试从mViewCacheExtension中获取ViewHolder对象
final View view = mViewCacheExtension
.getViewForPositionAndType(this, position, type);
if (view != null) {
holder = getChildViewHolder(view);
...
}
}
if (holder == null) { // fallback to pool
// 3 尝试从mRecycledViewPool中获取ViewHolder对象
holder = getRecycledViewPool().getRecycledView(type);
...
}
if (holder == null) {
// 4.1 回调createViewHolder方法创建ViewHolder对象及其关联的视图
holder = mAdapter.createViewHolder(RecyclerView.this, type);
...
}
}

if (mState.isPreLayout() && holder.isBound()) {
...
} else if (!holder.isBound() || holder.needsUpdate() || holder.isInvalid()) {
...
// 4.1 回调bindViewHolder方法提取数据填充ViewHolder的视图内容
bound = tryBindViewHolderByDeadline(holder, offsetPosition, position, deadlineNs);
}

...

return holder;
}
...
}

结合RecyclerView类中的源码及注释可知,Recycler会依次从mChangedScrap/mAttachedScrap、mCachedViews、mViewCacheExtension、mRecyclerPool中尝试获取指定位置或ID的ViewHolder对象以供重用,如果全都获取不到则直接重新创建。这其中涉及的几层缓存结构分别是:


mChangedScrap/mAttachedScrap


mChangedScrap/mAttachedScrap主要用于临时存放仍在当前屏幕可见、但被标记为「移除」或「重用」的列表项,其均以ArrayList的形式持有着每个列表项的ViewHolder对象,大小无明确限制,但一般来讲,其最大数就是屏幕内总的可见列表项数。


    final ArrayList<ViewHolder> mAttachedScrap = new ArrayList<>();
ArrayList<ViewHolder> mChangedScrap = null;
复制代码

但问题来了,既然是当前屏幕可见的列表项,为什么还需要缓存呢?又是什么时候列表项会被标记为「移除」或「重用」的呢?


这2个缓存结构实际上更多是为了避免出现像局部刷新这一类的操作,导致所有的列表项都需要重绘的情形。


区别在于,mChangedScrap主要的使用场景是:



  1. 开启了列表项动画(itemAnimator),并且列表项动画的canReuseUpdatedViewHolder(ViewHolder viewHolder)方法返回false的前提下;

  2. 调用了notifyItemChanged、notifyItemRangeChanged这一类方法,通知列表项数据发生变化;


    boolean canReuseUpdatedViewHolder(ViewHolder viewHolder) {
return mItemAnimator == null || mItemAnimator.canReuseUpdatedViewHolder(viewHolder,
viewHolder.getUnmodifiedPayloads());
}

public boolean canReuseUpdatedViewHolder(@NonNull ViewHolder viewHolder,
@NonNull List<Object> payloads) {
return canReuseUpdatedViewHolder(viewHolder);
}

public boolean canReuseUpdatedViewHolder(@NonNull ViewHolder viewHolder) {
return true;
}

canReuseUpdatedViewHolder方法的返回值表示的不同含义如下:



  • true,表示可以重用原先的ViewHolder对象

  • false,表示应该创建该ViewHolder的副本,以便itemAnimator利用两者来实现动画效果(例如交叉淡入淡出效果)。


简单讲就是,mChangedScrap主要是为列表项数据发生变化时的动画效果服务的


mAttachedScrap应对的则是剩下的绝大部分场景,比如:



  • 像notifyItemMoved、notifyItemRemoved这种列表项发生移动,但列表项数据本身没有发生变化的场景。

  • 关闭了列表项动画,或者列表项动画的canReuseUpdatedViewHolder方法返回true,即允许重用原先的ViewHolder对象的场景。


下面以一个简单的notifyItemRemoved(int position)操作为例来演示:


notifyItemRemoved(int position)方法用于通知观察者,先前位于position的列表项已被移除, 其往后的列表项position都将往前移动1位。


为了简化问题、方便演示,我们的范例将会居于以下限制:



  • 列表项总个数没有铺满整个屏幕——意味着不会触发mCachedViews、mRecyclerPool等结构的缓存操作

  • 去除列表项动画——意味着调用notifyItemRemoved后RecyclerView只会重新布局子视图一次


  recyclerView.itemAnimator = null

理想情况下,调用notifyItemRemoved(int position)方法后,应只有位于position的列表项会被移除,其他的列表项,无论是位于position之前或之后,都最多只会调整position值,而不应发生视图的重新创建或数据的重新绑定,即不应该回调onCreateViewHolder与onBindViewHolder这2个方法。


为此,我们就需要将当前屏幕内的可见列表项暂时从当前屏幕剥离,临时缓存到mAttachedScrap这个结构中去。



等到RecyclerView重新开始布局显示其子视图后,再遍历mAttachedScrap找到对应position的ViewHolder对象进行复用。



mCachedViews


mCachedViews主要用于存放已被移出屏幕、但有可能很快重新进入屏幕的列表项。其同样是以ArrayList的形式持有着每个列表项的ViewHolder对象,默认大小限制为2。


    final ArrayList<ViewHolder> mCachedViews = new ArrayList<ViewHolder>();
int mViewCacheMax = DEFAULT_CACHE_SIZE;
static final int DEFAULT_CACHE_SIZE = 2;

比如像朋友圈这种按更新时间的先后顺序展示的Feed流,我们经常会在快速滑动中确定是否有自己感兴趣的内容,当意识到刚才滑走的内容可能比较有趣时,我们往往就会将上一条内容重新滑回来查看。


这种场景下我们追求的自然是上一条内容展示的实时性与完整性,而不应让用户产生“才滑走那么一会儿又要重新加载”的抱怨,也即同样不应发生视图的重新创建或数据的重新绑定。


我们用几张流程示意图来演示这种情况:


同样为了简化问题、方便描述,我们的范例将会居于以下限制:



  • 关闭预拉取——意味着之后向上滑动时,都不会再预拉取「待进入屏幕区域」的一个列表项放入mCachedView了


recyclerView.layoutManager?.isItemPrefetchEnabled = false


  • 只存在一种类型的列表项,即所有列表项的itemType相同,默认都为0。


我们将图中的列表项分成了3块区域,分别是被滑出屏幕之外的区域、屏幕内的可见区域、随着滑动手势待进入屏幕的区域。




  1. 当position=0的列表项随着向上滑动的手势被移出屏幕后,由于mCachedViews初始容量为0,因此可直接放入;




  1. 当position=1的列表项同样被移出屏幕后,由于未达到mCachedViews的默认容量大小限制,因此也可继续放入;





  1. 此时改为向下滑动,position=1的列表项重新进入屏幕,Recycler就会依次从mAttachedScrap、mCachedViews查找可重用于此位置的ViewHolder对象;




  2. mAttachedScrap不是应对这种情况的,自然找不到。而mCachedViews会遍历自身持有的ViewHolder对象,对比ViewHolder对象的position值与待复用位置的position值是否一致,是的话就会将ViewHolder对象从mCachedViews中移除并返回;




  3. 此处拿到的ViewHolder对象即可直接复用,即符合前面所述的最优情况






  1. 另外,随着position=1的列表项重新进入屏幕,position=7的列表项也会被移出屏幕,该位置的列表项同样会进入mCachedViews,即RecyclerView是双向缓存的。



mViewCacheExtension


mViewCacheExtension主要用于提供额外的、可由开发人员自由控制的缓存层级,属于非常规使用的情况,因此这里暂不展开讲。


mRecyclerPool


mRecyclerPool主要用于按不同的itemType分别存放超出mCachedViews限制的、被移出屏幕的列表项,其会先以SparseArray区分不同的itemType,然后每种itemType对应的值又以ArrayList的形式持有着每个列表项的ViewHolder对象,每种itemType的ArrayList大小限制默认为5。


    public static class RecycledViewPool {
private static final int DEFAULT_MAX_SCRAP = 5;
static class ScrapData {
final ArrayList<ViewHolder> mScrapHeap = new ArrayList<>();
int mMaxScrap = DEFAULT_MAX_SCRAP;
long mCreateRunningAverageNs = 0;
long mBindRunningAverageNs = 0;
}
SparseArray<ScrapData> mScrap = new SparseArray<>();
...
}

由于mCachedViews默认的大小限制仅为2,因此,当滑出屏幕的列表项超过2个后,就会按照先进先出的顺序,依次将ViewHolder对象从mCachedViews移出,并按itemType放入RecycledViewPool中的不同ArrayList。


这种缓存结构主要考虑的是随着被滑出屏幕列表项的增多,以及被滑出距离的越来越远,重新进入屏幕内的可能性也随之降低。于是Recycler就在时间与空间上做了一个权衡,允许相同itemType的ViewHolder被提取复用,只需要重新绑定数据即可。


这样一来,既可以避免无限增长的ViewHolder对象缓存挤占了原本就紧张的内存空间,又可以减少回调相比较之下执行代价更加昂贵的onCreateViewHolder方法。


同样我们用几张流程示意图来演示这种情况,这些示意图将在前面的mCachedViews示意图基础上继续操作:




  1. 假设目前存在于mCachedViews中的仍是position=0及position=1这两个列表项。




  2. 当我们继续向上滑动时,position=2的列表项会尝试进入mCachedViews,由于超出了mCachedViews的容量限制,position=0的列表项会从mCachedViews中被移出,并放入RecycledViewPool中itemType为0的ArrayList,即图中的情况①;




  3. 同时,底部的一个新的列表项也将随着滑动手势进入到屏幕内,但由于此时mAttachedScrap、mCachedViews、mRecyclerPool均没有合适的ViewHolder对象可以提供给其复用,因此该列表项只能执行onCreateViewHolder与onBindViewHolder这2个方法的回调,即图中的情况②;






  1. 等到position=2的列表项被完全移出了屏幕后,也就顺利进入了mCachedViews中。





  1. 我们继续保持向上滑动的手势,此时,由于下一个待进入屏幕的列表项与position=0的列表项的itemType相同,因此我们可以在走到从mRecyclerPool查找合适的ViewHolder对象这一步时,根据itemType找到对应的ArrayList,再取出其中的1个ViewHolder对象进行复用,即图中的情况①。




  2. 由于itemType类型一致,其关联的视图可以复用,因此只需要重新绑定数据即可,即符合前面所述的次优情况






  1. ②③ 情况与前面的一致,此处不再赘余。


最后总结一下,



































RecyclerView缓存复用机制
对象ViewHolder(包含列表项视图(itemView)的封装容器)
目的减少对onCreateViewHolder、onBindViewHolder这2个方法的回调
好处1.避免重复创建不必要的视图 2.避免重复执行昂贵的findViewById
效果改善性能、提升应用响应能力、降低功耗
核心类Recycler、RecyclerViewPool
缓存结构mChangedScrap/mAttachedScrap、mCachedViews、mViewCacheExtension、mRecyclerPool









































缓存结构容器类型容量限制缓存用途优先级顺序(数值越小,优先级越高)
mChangedScrap/mAttachedScrapArrayList无,一般为屏幕内总的可见列表项数临时存放仍在当前屏幕可见、但被标记为「移除」或「重用」的列表项0
mCachedViewsArrayList默认为2存放已被移出屏幕、但有可能很快重新进入屏幕的列表项1
mViewCacheExtension开发者自己定义提供额外的可由开发人员自由控制的缓存层级2
mRecyclerPoolSparseArray<ArrayList>每种itemType默认为5按不同的itemType分别存放超出mCachedViews限制的、被移出屏幕的列表项3

以上的就是RecyclerView缓存复用机制的核心内容了。


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

Gradle 依赖切换源码的实践

最近,因为开发的时候经改动依赖的库,所以,我想对 Gradle 脚本做一个调整,用来动态地将依赖替换为源码。这里以 android-mvvm-and-architecture 这个工程为例。该工程以依赖的形式引用了我的另一个工程 AndroidUtils。在之...
继续阅读 »

最近,因为开发的时候经改动依赖的库,所以,我想对 Gradle 脚本做一个调整,用来动态地将依赖替换为源码。这里以 android-mvvm-and-architecture 这个工程为例。该工程以依赖的形式引用了我的另一个工程 AndroidUtils。在之前,当我需要对 AndroidUtils 这个工程源码进行调整时,一般来说有两种解决办法。


1、一般的修改办法


一种方式是,直接修改 AndroidUtils 这个项目的源码,然后将其发布到 MavenCentral. 等它在 MavenCentral 中生效之后,再将项目中的依赖替换为最新的依赖。这种方式可行,但是修改的周期太长。


另外一种方式是,修改 Gradle 脚本,手动地将依赖替换为源码依赖。此时,需要做几处修改,


修改 1,在 settings.gradle 里面将源码作为子工程添加到项目中,


include ':utils-core', ':utils-ktx'
project(':utils-core').projectDir = new File('../AndroidUtils/utils')
project(':utils-ktx').projectDir = new File('../AndroidUtils/utils-ktx')

修改 2,将依赖替换为工程引用,


// implementation "com.github.Shouheng88:utils-core:$androidUtilsVersion"
// implementation "com.github.Shouheng88:utils-ktx:$androidUtilsVersion"
// 上面的依赖替换为下面的工程引用
implementation project(":utils-core")
implementation project(":utils-ktx")

这种方式亦可行,只不过过于繁琐,需要手动修改 Gradle 的构建脚本。


2、通过 Gradle 脚本动态修改依赖


其实 Gradle 是支持动态修改项目中的依赖的。动态修改依赖在上述场景,特别是组件化的场景中非常有效。这里我参考了公司组件化的切换源码的实现方式,用了 90 行左右的代码就实现了上述需求。


2.1 配置文件和工作流程抽象


这种实现方式里比较重要的一环是对切换源码工作机制的抽象。这里我重新定义了一个 json 配置文件,


[
{
"name": "AndroidUtils",
"url": "git@github.com:Shouheng88/AndroidUtils.git",
"branch": "feature-2.8.0",
"group": "com.github.Shouheng88",
"open": true,
"children": [
{
"name": "utils-core",
"path": "AndroidUtils/utils"
},
{
"name": "utils-ktx",
"path": "AndroidUtils/utils-ktx"
}
]
}
]

它内部的参数的含义分别是,



  • name:工程的名称,对应于 Github 的项目名,用于寻找克隆到本地的代码源码

  • url:远程仓库的地址

  • branch:要启用的远程仓库的分支,这里我强制自动切换分支时的本地分支和远程分支同名

  • group:依赖的 group id

  • open:表示是否启用源码依赖

  • children.name:表示子工程的 module 名称,对应于依赖中的 artifact id

  • children.path:表示子工程对应的相对目录


也就是说,



  • 一个工程下的多个子工程的 group id 必须相同

  • children.name 必须和依赖的 artifact id 相同


上述配置文件的工作流程是,


def sourceSwitches = new HashMap<String, SourceSwitch>()

// Load sources configurations.
parseSourcesConfiguration(sourceSwitches)

// Checkout remote sources.
checkoutRemoteSources(sourceSwitches)

// Replace dependencies with sources.
replaceDependenciesWithSources(sourceSwitches)


  • 首先,Gradle 在 setting 阶段解析上述配置文件

  • 然后,根据解析的结果,将打开源码的工程通过 project 的形式引用到项目中

  • 最后,根据上述配置文件,将项目中的依赖替换为工程引用


2.2 为项目动态添加子工程


如上所述,这里我们忽略掉 json 配置文件解析的环节,直接看拉取最新分支并将其作为子项目添加到项目中的逻辑。该部分代码实现如下,


/** Checkout remote sources if necessary. */
def checkoutRemoteSources(sourceSwitches) {
def settings = getSettings()
def rootAbsolutePath = settings.rootDir.absolutePath
def sourcesRootPath = new File(rootAbsolutePath).parent
def sourcesDirectory = new File(sourcesRootPath, "open_sources")
if (!sourcesDirectory.exists()) sourcesDirectory.mkdirs()
sourceSwitches.forEach { name, sourceSwitch ->
if (sourceSwitch.open) {
def sourceDirectory = new File(sourcesDirectory, name)
if (!sourceDirectory.exists()) {
logd("clone start [$name] branch [${sourceSwitch.branch}]")
"git clone -b ${sourceSwitch.branch} ${sourceSwitch.url} ".execute(null, sourcesDirectory).waitFor()
logd("clone completed [$name] branch [${sourceSwitch.branch}]")
} else {
def sb = new StringBuffer()
"git rev-parse --abbrev-ref HEAD ".execute(null, sourceDirectory).waitForProcessOutput(sb, System.err)
def currentBranch = sb.toString().trim()
if (currentBranch != sourceSwitch.branch) {
logd("checkout start current branch [${currentBranch}], checkout branch [${sourceSwitch.branch}]")
def out = new StringBuffer()
"git pull".execute(null, sourceDirectory).waitFor()
"git checkout -b ${sourceSwitch.branch} origin/${sourceSwitch.branch}"
.execute(null, sourceDirectory).waitForProcessOutput(out, System.err)
logd("checkout completed: ${out.toString().trim()}")
}
}
// After checkout sources, include them as subprojects.
sourceSwitch.children.each { child ->
settings.include(":${child.name}")
settings.project(":${child.name}").projectDir = new File(sourcesDirectory, child.path)
}
}
}
}

这里,我将子项目的源码克隆到 settings.gradle 文件的父目录下的 open_sources 目录下面。这里当该目录不存在的时候,我会先创建该目录。这里需要注意的是,我在组织项目目录的时候比较喜欢将项目的子工程放到和主工程一样的位置。所以,上述克隆方式可以保证克隆到的 open_sources 仍然在当前项目的工作目录下。


工程目录示例


然后,我对 sourceSwitches,也就是解析的 json 文件数据,进行遍历。这里会先判断指定的源码是否已经拉下来,如果存在的话就执行 checkout 操作,否则执行 clone 操作。这里在判断当前分支是否为目标分支的时候使用了 git rev-parse --abbrev-ref HEAD 这个 Git 指令。该指令用来获取当前仓库所处的分支。


最后,将源码拉下来之后通过 Settingsinclude() 方法加载指定的子工程,并使用 Settingsproject() 方法指定该子工程的目录。这和我们在 settings.gradle 文件中添加子工程的方式是相同的,


include ':utils-core', ':utils-ktx'
project(':utils-core').projectDir = new File('../AndroidUtils/utils')
project(':utils-ktx').projectDir = new File('../AndroidUtils/utils-ktx')

2.3 使用子工程替换依赖


动态替换工程依赖使用的是 Gradle 的 ResolutionStrategy 这个功能。也许你对诸如


configurations.all {
resolutionStrategy.force 'io.reactivex.rxjava2:rxjava:2.1.6'
}

这种写法并不陌生。这里的 forcedependencySubstitution 一样,都属于 ResolutionStrategy 提供的功能的一部分。只不过这里的区别是,我们需要对所有的子项目进行动态更改,因此需要等项目 loaded 完成之后才能执行。


下面是依赖替换的实现逻辑,


/** Replace dependencies with sources. */
def replaceDependenciesWithSources(sourceSwitches) {
def gradle = settings.gradle
gradle.projectsLoaded {
gradle.rootProject.subprojects {
configurations.all {
resolutionStrategy.dependencySubstitution {
sourceSwitches.forEach { name, sourceSwitch ->
sourceSwitch.children.each { child ->
substitute module("${sourceSwitch.artifact}:${child.name}") with project(":${child.name}")
}
}
}
}
}
}
}

这里使用 Gradle 的 projectsLoaded 这个点进行 hook,将依赖替换为子工程。


此外,也可以将子工程替换为依赖,比如,


dependencySubstitution {
substitute module('org.gradle:api') using project(':api')
substitute project(':util') using module('org.gradle:util:3.0')
}

2.4 注意事项


上述实现方式要求多个子工程的脚本尽可能一致。比如,在 AndroidUtils 的独立工程中,我通过 kotlin_version 这个变量指定 kotlin 的版本,但是在 android-mvvm-and-architecture 这个工程中使用的是 kotlinVersion. 所以,当切换了子工程的源码之后就会发现 kotlin_version 这个变量找不到了。因此,为了实现可以动态切换源码,是需要对 Gradle 脚本做一些调整的。


在我的实现方式中,我并没有将子工程的源码放到主工程的根目录下面,也就是将 open_sources 这个目录放到 appshell 这个目录下面。而是放到和 appshell 同一级别。


工程目录示例


这样做的原因是,实际开发过程中,通常我们会克隆很多仓库到 open_sources 这个目录下面(或者之前开发遗留下来的克隆仓库)。有些仓库虽然我们关闭了源码依赖,但是因为在 appshell 目录下面,依然会出现在 Android Studio 的工程目录里。而按照上述方式组织目录,我切换了哪个项目等源码,哪个项目的目录会被 Android Studio 加载。其他的因为不在 appshell 目录下面,所以会被 Android Studio 忽略。这种组织方式可以尽可能减少 Android Studio 加载的文本,提升 Android Studio 响应的速率。


总结


上述是开发过程中替换依赖为源码的“无痕”修改方式。不论在组件化还是非组件化需要开发中都是一种非常实用的开发技巧。按照上述开发开发方式,我们可以既能开发 android-mvvm-and-architecture 的时候随时随地打开 AndroidUtils 进行修改,亦可对 AndroidUtil 这个工程独立编译和开发。


源代码参考 android-mvvm-and-architecture 项目(当前是 feature-3.0 分支)的 AppShell 下面的 sources.gradle 文件。


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

BasicLibrary架构设计旅程(一)—Android必备技能

前言 2022年对大部分人来说真的是不容易的一年,有不少粉丝私信问我,今年行情不好,但是现在公司又不好怎么办,我的建议就是学习。无论过去,现在,未来,投资自己一定是不会错的,只有当你足够强大,哪怕生活一地鸡毛,你也能垫起脚尖独揽星空。 对于Android来说...
继续阅读 »

前言



  • 2022年对大部分人来说真的是不容易的一年,有不少粉丝私信问我,今年行情不好,但是现在公司又不好怎么办,我的建议就是学习。无论过去,现在,未来,投资自己一定是不会错的,只有当你足够强大,哪怕生活一地鸡毛,你也能垫起脚尖独揽星空。

  • 对于Android来说,我觉得有两个能力和一个态度一定要掌握

    • 阅读源码的能力

    • 阅读字节码的能力

    • 怀疑的态度




阅读源码的能力



  • 个人技巧:我个人阅读源码喜欢自己给自己提问题,随后带着问题去读源码的流程,当遇到不确定的可以看看别的大神写的博客和视频。


为什么需要具有阅读源码的能力呢?

当我们通过百度搜索视频,博客,stackOverflow找不到我们问题解决办法的时候,可以通过阅读源码来寻找问题,并解决问题,如以下两个案例


一、AppBarLayout阴影问题



  • 源码地址:github.com/Peakmain/Ba…

  • 我们每次在项目添加头部的时候,一般做法都是说定义一个公用的布局,但是这其实并不友好,而且每次都需要findVIewById,为了解决上述问题,我用了Builder设计模式设计了NavigationBar,可以动态添加头部

  • 其中有个默认的头部设计DefaultNavigationBar,使用的是AppBarLayout+ToolBar,AppBarLayout有个问题就是会存在阴影,我想要在不改变布局的情况下,动态设置取消阴影,在百度中得到的前篇一律的答案是,设置主题,布局中设置阴影


image.png



  • 既然说布局中设置elevation有效,那么是否可以通过findViewById找到AppBarLayout然后设置elevation=0


findViewById<AppBarLayout>(R.id.navigation_header_container).elevation=0f

运行之后,发现阴影还仍然存在



  • 既然布局中设置elevation有效,那它的源码怎么写的呢?
    我们可以在AppBarLayout的构造函数中找到这行代码


image.png


我们可以发现最终调用的是一个非公平类的静态方法,直接将方法拷贝到我们自己的项目,之后调用该方法


  static void setDefaultAppBarLayoutStateListAnimator(
@NonNull final View view, final float elevation) {
final int dur = view.getResources().getInteger(R.integer.app_bar_elevation_anim_duration);

final StateListAnimator sla = new StateListAnimator();

// Enabled and liftable, but not lifted means not elevated
sla.addState(
new int[] {android.R.attr.state_enabled, R.attr.state_liftable, -R.attr.state_lifted},
ObjectAnimator.ofFloat(view, "elevation", 0f).setDuration(dur));

// Default enabled state
sla.addState(
new int[] {android.R.attr.state_enabled},
ObjectAnimator.ofFloat(view, "elevation", elevation).setDuration(dur));

// Disabled state
sla.addState(new int[0], ObjectAnimator.ofFloat(view, "elevation", 0).setDuration(0));

view.setStateListAnimator(sla);
}

image.png


二、Glide加载图片读取设备型号问题



  • 再比如App加载网络图片时候,App移动应用检测的时候说我们应用自身获取个人信息行为,描述说的是我们有图片上传行为,看了堆栈,主要问题是加载图片的时候,user-Agent有读取设备型号行为


image.png



  • 关于这篇文章的源码分析,大家可以看我之前的文章:隐私政策整改之Glide框架封装

  • glide加载图片默认用的是HttpUrlConnection

  • 加载网络图片的时候,默认是在GlideUrl中设置了Headers.DEFAULT,它的内部会在static中添加默认的User-Agent。


小总结



  • 优秀的阅读源码能力可以帮我们快速定位并解决问题。

  • 优秀的阅读源码能力也可以让我们快速上手任何一个热门框架并了解其原理


阅读字节码的能力的重要性


当我们熟练掌握字节码能力,我们能够深入了解JVM,通过ASM实现一套埋点+拦截第三方频繁调用隐私方法的问题


字节码基础知识


  • 由于跨平台性的设计,java的指令都是根据栈来设计的,而这个栈指的就是虚拟机栈

  • JVM运行时数据区分为本地方法栈、程序计数器、堆、方法区和虚拟机栈


局部变量表



  • 每个线程都会创建一个虚拟机栈,其内部保存一个个栈帧,对应一次次方法的调用

  • 栈帧的内部结构是分为:局部变量表、操作数栈、动态链接(指向运行时常量池的方法引用)和返回地址

  • 局部变量表内部定义了一个数字数组,主要存储方法参数和定义在方法体内的局部变量

  • 局部变量表存储的基本单位是slot(槽),long和double存储的是2个槽,其他都是1个槽

  • 非静态方法,默认0槽位存的是this(指的是该方法的类对象)


操作数栈



  • 在方法执行过程中,根据字节码指令,往栈中写入数据或提取数据,即入栈和出栈

  • 主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间

  • 方法调用的开始,默认的操作数栈是空的,但是操作数栈的数组已经创建,并且大小已知

  • 操作数栈并非采用访问索引的方式来进行数据访问的,而是只能通过标准的入栈和出栈操作来完成一次数据访问


一些常用的助记符



  • 从局部变量表到操作数栈:iload,iload_,lload,lload_,fload,fload_,dload,dload_,aload,aload

  • 操作数栈放到局部变量表:istore,istore_,lstore,lstore_,fstore,fstore_,dstore,dstor_,astore,astore_

  • 把常数放到到操作数栈:bipush,sipush,ldc,ldc_w,ldc2_w,aconst_null,iconst_ml,iconst_,lconst_,fconst_,dconst_

  • 取出栈顶两个数进行相加,并将结果压入操作数栈:iadd,ladd,fadd,dadd

  • iinc:对局部变量表的值进行加1操作


i++和++i区别

public class Test {

public static void main(String[] args) {
int i=10;
int a=i++;
int j=10;
int b=++j;
System.out.println(i);
System.out.println(a);
System.out.println(j);
System.out.println(b);
}
}


  • 大家可以思考下,这个结果会是什么呢?

  • 结果分别是11 10 11 11


字节码结果分析



  • 查看字节码命令:javap -v Test.class

  • 大家也可以使用idea自带的jclasslib工具,或者ASM Bytecode Viewer工具


 0 bipush 10
2 istore_1
3 iload_1
4 iinc 1 by 1
7 istore_2
8 bipush 10
10 istore_3
11 iinc 3 by 1
14 iload_3
15 istore 4
17 getstatic #2 <java/lang/System.out : Ljava/io/PrintStream;>
20 iload_1
21 invokevirtual #3 <java/io/PrintStream.println : (I)V>
24 getstatic #2 <java/lang/System.out : Ljava/io/PrintStream;>
27 iload_2
28 invokevirtual #3 <java/io/PrintStream.println : (I)V>
31 getstatic #2 <java/lang/System.out : Ljava/io/PrintStream;>
34 iload_3
35 invokevirtual #3 <java/io/PrintStream.println : (I)V>
38 getstatic #2 <java/lang/System.out : Ljava/io/PrintStream;>
41 iload 4
43 invokevirtual #3 <java/io/PrintStream.println : (I)V>
46 return


  • 由于我们是非静态方法,所以局部变量表0的位置存储的是this
    image.png

  • bipush 10:将常量10压入操作数栈
    image.png

  • istore1:将操作数栈的栈顶元素放入到局部变量表1的位置
    image.png

  • iload1:将局部变量表1的位置放入到操作数栈


image.png



  • iinc 1 by 1:局部变量表1的位置的值+1


image.png



  • istore2:将操作栈的栈顶元素压入局部变量表2的位置
    image.png

  • 至此最上面两行代码执行完毕,下面的代码我就不再画图阐述了,我相信机智聪敏的你一定已经学会分析了

  • 最后来一个小小的总结吧

    • i++是先iload1,后局部变量表自增,再istore2,所以a的值还是10

    • ++i是先局部变量表自增,随后iload,再istore,所以b的值已经变成了11




ASM 解决隐私方法问题


  • 项目地址:github.com/Peakmain/As…

  • 大家可以去看下我的源码和文章,具体细节我就不阐述了,里面涉及到了大量的opcodec的操作符,比如Opcode.ILOAD
    image.png


怀疑的态度



  • 无论是视频还是博客,大家对不确认的知识保持一颗怀疑的态度,因为一篇文章或者视频都有可能是不对的,包括我现在写的这篇文章。


kotlin object实现的单例类是懒汉式还是饿汉式

image.png


image.png



  • 以上两个都是网上的文章截取的文章,那kotlin实现的object单例到底是饿汉式还是懒汉式的呢?

  • 假设我们有以下代码


object Test {
const val TAG="test"
}

通过工具看下反编译后的代码


image.png


image.png
static代码块什么时候初始化呢?



  • 首先我们需要知道JVM的类加载过程:loading->link->初始化

  • link又分为:验证、准备、解析

  • 而static代码块()是在初始化的过程中调用的

  • 虚拟机会必须保证一个类的方法在多线程下被同步加锁

  • Java使用方式分为两种:主动和被动
    image.png

  • 主动使用才会导致static代码块的调用


单例的懒汉式和饿汉式的区别是什么呢



  • 懒汉式:类加载不会导致该实例被创建,而是首次使用该对象才会被创建

  • 饿汉式:类加载就会导致该实例对象被创建


image.png


public class Test {
private static Test mInstance;
static {
System.out.println("static:"+mInstance);
}
private Test() {
System.out.println("init:"+mInstance);
}
public static Test getInstance() {
if (mInstance == null) {
mInstance = new Test();
}
return mInstance;
}
public static void main(String[] args) {
Test.getInstance();
}
}


  • 当调用getInstance的时候,类加载过程中会进行初始化,也就是调用static代码块

  • static代码块执行时,由于类没有实例化,所以获取到是null。

  • 也就是说,类加载的时候并没有对该实例进行创建(懒汉式)


public class Test1 {
private static final Test1 mInstance=new Test1();

private Test1(){
System.out.println("init:"+mInstance);
}
static {
System.out.println("static:"+mInstance);
}
public static Test1 getInstance(){
return mInstance;
}

public static void main(String[] args) {
Test1.getInstance();
}
}


  • 类的初始化顺序是由代码的顺序来决定的,上面的代码首先对mInstance进行初始化,但是由于此时构造函数执行完成后才完成类的初始化,所以构造函数返回的是null

  • static代码块执行的时候,类实例已经创建完毕

  • 正如上面说的static代码块执行的时候还处于类加载中的初始化状态,所以实例是在初始化之前完成(饿汉式)


我们现在回到kotlin的object,我们将其转成Java类


public class Test2 {
public static final String TAG = "test";
private Test2() {
System.out.println("init:" + mInstance);
}
public static Test2 mInstance;
static {
Test2 test2 = new Test2();
mInstance = test2;
System.out.println("static:" + mInstance);
}

public static void main(String[] args) {
System.out.println(Test2.TAG);
}
}


  • 上面代码在static代码块的时候(类加载的初始化时)进行了类的实例初始化(饿汉式)


总结



  • Android必备的技能,其实很多,比如JVM、高并发、binder、泛型、AMS,WMS等等

  • 我个人觉得源阅读码能力和掌握字节码属于必备技能,能提高自己知识领域

  • 当然如我上面所说,要保持怀疑的态度,本文说的可能也不对。

  • 下一篇文章,我将介绍BasicLibrary中基于责任链设计模式搭建的Activity Results API权限封装框架,欢迎大家讨论。

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

居家办公竟被读取脑电波?老板们为远程监控想出奇招

朋友居家办公期间,他们老板为了远程监控工作,要求大家必须装上专门的软件和摄像头。我还记得,Ta花了一个晚上来安装这些东西……但万万没想到,为了关怀员工监督工作,老板们的奇招简直一山更比一山高:连读取脑电波都想出来了。就算你居家办公,老板甚至还能观测到你的心情。...
继续阅读 »

朋友居家办公期间,他们老板为了远程监控工作,要求大家必须装上专门的软件和摄像头。

我还记得,Ta花了一个晚上来安装这些东西……

但万万没想到,为了关怀员工监督工作,老板们的奇招简直一山更比一山高:

连读取脑电波都想出来了。

就算你居家办公,老板甚至还能观测到你的心情。

而要实现这个神操作,只需要一个耳机就够了。


不同于普通耳机,这种耳机上还有几个专门读取人脑电波的电极。

戴上它后,既不用动手也不用说话,AI就能快速读心,帮你操控电脑。


好家伙,老板们竟然如此紧跟潮流,都把当下正红火的脑机接口搬进办公室了。

这种“读心耳机”的背后的开发者宣称:“戴上它,不仅能提高工作效率,还可以让员工更快乐。”


据IEEE Spectrum报道,关于这项新技术,有2家公司最为突出:

一个是来自以色列的InnerEye,另一个是硅谷神经技术公司Emotiv,不少老板都投资了他们。

那么,他们的耳机到底靠谱吗?

读取员工脑电波,AI快速做决策

先来看看InnerEye的头戴式耳机,它共有7个通道来读取人的脑电图。

他们的技术人员开发了一个专门的InnerEye软件,来接收、分析大脑信号,并和商业脑电扫描系统搭配使用。

AI可以整合人眼球活动信号、脑电波,以及电脑屏幕信息,快速做出决策。

举个栗子~

机场的安检员需要盯着X光扫描图流,判断行李中是否有违禁物品。

戴上InnerEye的耳机后,安检员每秒能处理3到10张图像,比纯用肉眼+手动记录快了一个量级。

至于原因嘛,一是安检员不用再敲键盘了,直接“意念控制”计算机就行;二是在人还没全完想清楚时,AI可能已经找出了违禁物品。

“这个系统的判断结果,和人类手动操作时一样准确”,InnerEye的研发副总裁Vaisman如是说道。

另外,当佩戴者闭眼或者注意力不集中时,AI还可以检测出来,并且再次显示错过的图像。


有意思的是,人类带着这个耳机处理任务时,AI还在继续根据人的大脑活动不断深度学习。

不过在这个过程中,人类虽然不一定要主动决策,但还得是懂行的(比如这里是职业安检员),并且要保持专注。

而比起InnerEye,Emotiv的耳机更加小巧

它的外观看起来很像蓝牙运动耳机,甚至连贴头皮的电极都无影无踪了。

不过玄机正藏在左右两个耳塞里,这里面有电极,用来读取脑电波。


Emotiv公司研发了一个叫MN8的系统,也和商业脑电扫描系统搭配使用。

通过这些系统,佩戴者可以看到他们个人的注意力集中程度以及压力水平。

实际上,脑电图技术早在1912年就被科学家发明出来了,随后很快在医学领域被普及。

但过去扫描脑电图时,需要用一种叫导电膏的东西来固定电极,并保证生物信号能够稳定准确地传输。

而且为了提升空间分辨率,电极或“通道”往往越多越好,有时一个脑电帽甚至有200多个电极。


脑电帽示意图

而现在,已经有了不需导电膏的“干式”电极,再加上AI也发展迅猛,于是出现了众多轻便的新式脑机接口设备,包括头戴式耳机。

既然操作简化、成本大幅下降,不难猜到,下一个动作应该就是商用普及了。

神经伦理专家表示担忧

虽然这种耳机的开发者很看好它的前景,甚至还说员工戴上之后可以更快乐。

但不少神经学伦理专家和学者却表示,并没看出哪里让人快乐了,而是感到害怕好吗…

比如,埃默里大学神经学和精神病学系的副教授Karen Rommelfanger说:

我认为老板对使用这种技术有很大的兴趣,但不知道雇员是否也有兴趣。

绝大多数研究脑机接口的公司,并没有为技术商用做好充分准备。

乔治敦大学的Mark Giordano教授也觉得,员工基本会对此产生抵触情绪,因为这涉嫌侵犯了他们的隐私和人权。

Giordano教授认为,这种技术对某些特定职业确实有些帮助,比如运输、建筑行业,可以用此检测工人的疲劳程度,保障他们的安全。

但对于办公室的白领而言,似乎没有明显的好处。

即便公司的初衷是提高员工福利,但可能很快就变味儿了。

如果员工的生产效率普遍提高,公司难免会跟着提高绩效标准,员工的压力反而变大了。(懂的都懂…)

但无论如何,专家们预测,就现在的发展趋势,这种读取脑电波的设备可能会很快普及。

所以,它们的诸多安全隐患,必须尽早解决。

背后的技术公司

话说回来,“读心耳机”背后的这两家公司,到底是什么来头?

其中,Emotiv成立于2011年,目前已经发布了三种型号的轻型脑电波扫描设备。

之前他们的产品主要是卖给神经科学研究人员;部分也会卖给一些开发者,这些人主要研究的是基于大脑控制的程序或游戏。

从今年起,Emotiv开始盯上了企业,他们发布了第四代MN8系统,并搞出了能读取脑电波的耳机。

Emotiv的CEO兼创始人Tan Le大胆猜测,五年后这种大脑追踪设备会变得随处可见。

至于安全隐患,Le表示Emotiv已经意识到了这些趋势,他们会严格审查合作公司,来保护员工隐私:

只有员工自己才能看到个人的注意力和压力水平,领导们只能看到团队的匿名数据汇总。

Innereye则成立于2013年,他们的官网上赫然写着公司愿景:

把人类的智慧与人工智能结合起来。


那么,戴上能读脑电波的耳机,是否可以算把人类智慧和AI的能力结合起来?

如果未来老板让你戴上能读取脑电波的东西,你会接受吗?


参考链接:
[1]https://www.iflscience.com/employers-are-investing-in-tech-that-constantly-reads-employee-brainwaves-to-optimize-performance-66426
[2]https://spectrum.ieee.org/neurotech-workplace-innereye-emotiv

来源:Alex 发自 凹非寺

收起阅读 »

10年老前端,开发的一款文档编辑器(年终总结)

web
2022年接近尾声,鸽了近一年,是时候补一下去年的年终总结了。2021年对我来说是一个意义重大的一年。这一年,我们团队开发出了一款基于canvas的类word文档编辑器,并因此产品获得了公司最高荣誉——产品创新奖。当时感慨良多,早该总结一下的,终因自己的懒惰,...
继续阅读 »

2022年接近尾声,鸽了近一年,是时候补一下去年的年终总结了。

2021年对我来说是一个意义重大的一年。

这一年,我们团队开发出了一款基于canvas的类word文档编辑器,并因此产品获得了公司最高荣誉——产品创新奖。

当时感慨良多,早该总结一下的,终因自己的懒惰,拖到了现在。

直到这周五晚上,在我想着罗织什么借口推迟,以便于周末能放飞自我的时候,老天终于看不下去了,我被电话告知了核酸同管阳性……

产品介绍

懒惰是可耻的,发自内心的忏悔过后,我还是要稍稍骄傲的介绍下编辑器产品:

整个编辑器都是用canvas底层API绘制的,包括了它的光标,滚动条。

除了弹窗及右键菜单的UI组件外,所有的核心功能都是手搓TS,没有用任何的插件。

包括:核心,排版,光标管理,分页,文本编辑,图片,表格,列表,表单控件,撤销还原,页面设置,页眉页脚等的所有功能,都只源于canvas提供的这几个底层的API接口:

  • 在直角坐标系下,从一点到另一点画一个矩形,或圆,或三角。

  • 测绘字体宽高。

  • 从某一点绘制一个指定样式的字。

接口简单,但是经过层层封装,配合健壮的架构和性能良好的算法,就实现了各种复杂的功能。

看一下几个特色功能:

  1. 丰富的排版形式:


  1. 复杂的表格拆分:


  1. 灵活的列表:


  1. 表单控件:


  1. 独有的字符对齐:


  1. 辅助输入


  1. 痕迹对比:


此外,我们开发了c++打印插件,可以灵活的定制各种打印功能。

基础的排版也不演示了,“,。》”等标点不能在行首,一些标点不能在行尾,文字基线等排版基础省略一百八十二个字,

性能也非常不错,三百页数据秒级加载。

提供全个功能的程序接口,借助模版功能,完成各种复杂的操作功能。

心路历程

开发

这么复杂的项目我们开发了多长时间呢?

答案是一年。事实是前年底立项,去年初开始开发,团队基本只有我一人(其实项目初期还有另一个老技术人员,技术也很强,很遗憾开始合作不到两周老技术员就离开这个项目了),一直到7月份团队进了4个强有力的新成员,又经过了半年的紧锣密鼓的开发,不出意外的就意外开发完了。

真实怀念那段忙碌的日子,仿佛一坐下一抬头就要吃午饭了,一坐一抬头又晚上了,晚上还要继续在小区里一圈圈散步考虑各种难点的实现技术方案。真是既充实又酣畅淋漓。

由衷的感谢每一位团队成员的辛苦付出,尽管除了我这个半混半就得老开发,其他还都是1年到4年开发经验的伪新兵蛋子,但是每个人都表现出了惊人的开发效率和潜力。

这让我深刻理解到,任何一个牛掰的项目,都是需要团队齐心协力完成的。现在这个战斗力超强的团队,也是我值得骄傲的底气。

上线,惨遭毒打

事实证明,打江山难,守江山更难,项目开发亦是如此,尤其是在项目刚刚面向用户使用阶段。

当我们还沉浸在获得成功的喜悦中时,因为糟糕的打印速度及打印清晰度问题被用户一顿骑脸输出,打印相关体验之前从未在我们的优化计划之内。而这是用户难以忍受的。

好在持续半个月驻现场加班加点,终于得到了一定的优化。后面我们也是自研c++打印插件,打印问题算是得到彻底解决。

之后仍然有大大小小的问题层出不穷,还好渐渐趋于稳定。

当然现在还是有一些小问题,这是属于这个产品成长的必经之路。

现在,该产品在成千上万用户手中得以稳定运行,偶尔博得称赞,既感到骄傲,又感觉所有辛苦与委屈不值一提。

未来

之前跟领导沟通过开源的问题,领导也有意向开源,佩服领导的远大格局及非凡气度。但现在还不太成熟,仍需从长计议。

随着编辑器功能的完善,一些难以解决的问题也浮出水面,例如对少数民族语言的支持。开源是一个好的方式,可以让大家一同来完善它。

感慨

  1. 勇气,是你最走向成功的首要前提。当我主动申请要做这个项目时,身边大部分人给我的忠告是不要做。不尝试一下,怎么知道能不能做好呢。不给自己设限,大胆尝试。

  2. 满足来源于专注。

  3. 小团队作战更有效率。

  4. 产品与技术不分家,既要精进技术,也要有产品思维。技术是产品的工具,产品是技术的目的。如何做出用户体验良好的产品,是高级研发的高级技能。

感悟很多,一时不知道说啥了,有时间单独再细聊聊。

碎碎念

不知道是幸运还是不幸,公司秃然安排研发在线版excel了,无缝衔接了属于是,身为高质量打工人,抖M属性值点满,没有困难创造困难也要上。

同时今年也发生了一件十分悲痛的事,好朋友的身体垮了。身体是革命的本钱。最后就总结三个重点:健康,健康,还是TMD健康。

作者:张三风
来源:juejin.cn/post/7172975010724708389

收起阅读 »

MyBatis-Plus联表查询的短板,终于有一款工具补齐了

mybatis-plus作为mybatis的增强工具,它的出现极大的简化了开发中的数据库操作,但是长久以来,它的联表查询能力一直被大家所诟病。一旦遇到left join或right join的左右连接,你还是得老老实实的打开xml文件,手写上一大段的sql语句...
继续阅读 »

mybatis-plus作为mybatis的增强工具,它的出现极大的简化了开发中的数据库操作,但是长久以来,它的联表查询能力一直被大家所诟病。一旦遇到left joinright join的左右连接,你还是得老老实实的打开xml文件,手写上一大段的sql语句。

直到前几天,偶然碰到了这么一款叫做mybatis-plus-join的工具(后面就简称mpj了),使用了一下,不得不说真香!彻底将我从xml地狱中解放了出来,终于可以以类似mybatis-plusQueryWrapper的方式来进行联表查询了,话不多说,我们下面开始体验。

引入依赖

首先在项目中引入引入依赖坐标,因为mpj中依赖较高版本mybatis-plus中的一些api,所以项目建议直接使用高版本。

<dependency>
   <groupId>com.github.yulichang</groupId>
   <artifactId>mybatis-plus-join</artifactId>
   <version>1.2.4</version>
</dependency>
<dependency>
   <groupId>com.baomidou</groupId>
   <artifactId>mybatis-plus-boot-starter</artifactId>
   <version>3.5.1</version>
</dependency>

引入相关依赖后,在springboot项目中,像往常一样正常配置数据源连接信息就可以了。

数据准备

因为要实现联表查询,所以我们先来建几张表进行测试。

订单表:


用户表,包含用户姓名:


商品表,包含商品名称和单价:


在订单表中,通过用户id和商品id与其他两张表进行关联。

修改Mapper

以往在使用myatis-plus的时候,我们的Mapper层接口都是直接继承的BaseMapper,使用mpj后需要对其进行修改,改为继承MPJBaseMapper接口。

@Mapper
public interface OrderMapper extends MPJBaseMapper<Order> {
}

对其余两个表的Mapper接口也进行相同的改造。此外,我们的service也可以选择继承MPJBaseServiceserviceImpl选择继承MPJBaseServiceImpl,这两者为非必须继承。

查询

Mapper接口改造完成后,我们把它注入到Service中,虽然说我们要完成3张表的联表查询,但是以Order作为主表的话,那么只注入这一个对应的OrderMapper就可以,非常简单。

@Service
@AllArgsConstructor
public class OrderServiceImpl implements OrderService {
   private final OrderMapper orderMapper;
}

MPJLambdaWrapper

接下来,我们体验一下再也不用写sql的联表查询:

public void getOrder() {
   List<OrderDto> list = orderMapper.selectJoinList(OrderDto.class,
    new MPJLambdaWrapper<Order>()
    .selectAll(Order.class)
    .select(Product::getUnitPrice)
    .selectAs(User::getName,OrderDto::getUserName)
    .selectAs(Product::getName,OrderDto::getProductName)
    .leftJoin(User.class, User::getId, Order::getUserId)
    .leftJoin(Product.class, Product::getId, Order::getProductId)
    .eq(Order::getStatus,3));

   list.forEach(System.out::println);
}

不看代码,我们先调用接口来看一下执行结果:


可以看到,成功查询出了关联表中的信息,下面我们一点点介绍上面代码的语义。

首先,调用mapperselectJoinList()方法,进行关联查询,返回多条结果。后面的第一个参数OrderDto.class代表接收返回查询结果的类,作用和我们之前在xml中写的resultType类似。

这个类可以直接继承实体,再添加上需要在关联查询中返回的列即可:

@Data
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
public class OrderDto extends Order {
   String userName;
   String productName;
   Double unitPrice;
}

接下来的MPJLambdaWrapper就是构建查询条件的核心了,看一下我们在上面用到的几个方法:

  • selectAll():查询指定实体类的全部字段

  • select():查询指定的字段,支持可变长参数同时查询多个字段,但是在同一个select中只能查询相同表的字段,所以如果查询多张表的字段需要分开写

  • selectAs():字段别名查询,用于数据库字段与接收结果的dto中属性名称不一致时转换

  • leftJoin():左连接,其中第一个参数是参与联表的表对应的实体类,第二个参数是这张表联表的ON字段,第三个参数是参与联表的ON的另一个实体类属性

除此之外,还可以正常调用mybatis-plus中的各种原生方法,文档中还提到,默认主表别名是t,其他的表别名以先后调用的顺序使用t1t2t3以此类推。

我们用插件读取日志转化为可读的sql语句,可以看到两条左连接条件都被正确地添加到了sql中:


MPJQueryWrapper

mybatis-plus非常类似,除了LamdaWrapper外还提供了普通QueryWrapper的写法,改造上面的代码:

public void getOrderSimple() {
   List<OrderDto> list = orderMapper.selectJoinList(OrderDto.class,
    new MPJQueryWrapper<Order>()
    .selectAll(Order.class)
    .select("t2.unit_price","t2.name as product_name")
    .select("t1.name as user_name")
    .leftJoin("t_user t1 on t1.id = t.user_id")
    .leftJoin("t_product t2 on t2.id = t.product_id")
    .eq("t.status", "3")
  );

   list.forEach(System.out::println);
}

运行结果与之前完全相同,需要注意的是,这样写时在引用表名时不要使用数据库中的原表名,主表默认使用t,其他表使用join语句中我们为它起的别名,如果使用原表名在运行中会出现报错。

并且,在MPJQueryWrapper中,可以更灵活的支持子查询操作,如果业务比较复杂,那么使用这种方式也是不错的选择。

分页查询

mpj中也能很好的支持列表查询中的分页功能,首先我们要在项目中加入分页拦截器:

@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor(){
   MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
   interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.H2));
   return interceptor;
}

接下来改造上面的代码,调用selectJoinPage()方法:

public void page() {
   IPage<OrderDto> orderPage = orderMapper.selectJoinPage(
     new Page<OrderDto>(2,10),
     OrderDto.class,
     new MPJLambdaWrapper<Order>()
      .selectAll(Order.class)
      .select(Product::getUnitPrice)
      .selectAs(User::getName, OrderDto::getUserName)
      .selectAs(Product::getName, OrderDto::getProductName)
      .leftJoin(User.class, User::getId, Order::getUserId)
      .leftJoin(Product.class, Product::getId, Order::getProductId)
      .orderByAsc(Order::getId));

   orderPage.getRecords().forEach(System.out::println);
}

注意在这里需要添加一个分页参数的Page对象,我们再执行上面的代码,并对日志进行解析,查看sql语句:


可以看到底层通过添加limit进行了分页,同理,MPJQueryWrapper也可以这样进行分页。

最后

经过简单的测试,个人感觉mpj这款工具在联表查询方面还是比较实用的,能更应对项目中不是非常复杂的场景下的sql查询,大大提高我们的生产效率。当然,在项目的issues中也能看到当前版本中也仍然存在一些问题,希望在后续版本迭代中能继续完善。

作者:码农参上
来源:juejin.cn/post/7173493838143553549

收起阅读 »

都2202年了,不会有人还不会发布npm包吧

web
背景恰逢最近准备写一个跨框架组件库(工作量很大,前端三个小伙伴利用空闲时间在卷,待组件库完善后会分享给大家,敬请期待),需要学习发布npm包,昨天就想着利用空闲时间把之前写的去除重复请求的axios封装发布为npm包,便于代码复用,回馈社区的同时也能学以致用。...
继续阅读 »

背景

恰逢最近准备写一个跨框架组件库(工作量很大,前端三个小伙伴利用空闲时间在卷,待组件库完善后会分享给大家,敬请期待),需要学习发布npm包,昨天就想着利用空闲时间把之前写的去除重复请求的axios封装发布为npm包,便于代码复用,回馈社区的同时也能学以致用。

阅读本文,你将收获:

  1. 从0开始创建并发布npm的全过程

  2. 一个持续迭代且简单实用的axios请求去重工具库

工具库准备

创建一个新项目,包含package.json

{
   "name": "drrq",
   "type": "module",
   "version": "1.0.0"
}

功能实现 /src/index.js

npm i qs axios

主要思路是用请求的url和参数作为key记录请求队列,当出现重复请求时,打断后面的请求,将前面的请求结果返回时共享给后面的请求。

import qs from "qs";
import axios from "axios";

let pending = []; //用于存储每个ajax请求的取消函数和ajax标识
let task = {}; //用于存储每个ajax请求的处理函数,通过请求结果调用,以ajax标识为key

//请求开始前推入pending
const pushPending = (item) => {
   pending.push(item);
};
//请求完成后取消该请求,从列表删除
const removePending = (key) => {
   for (let p in pending) {
       if (pending[p].key === key) {
           //当前请求在列表中存在时
           pending[p].cancelToken(); //执行取消操作
           pending.splice(p, 1); //把这条记录从列表中移除
      }
  }
};
//请求前判断是否已存在该请求
const existInPending = (key) => {
   return pending.some((e) => e.key === key);
};

// 创建task
const createTask = (key, resolve) => {
   let callback = (response) => {
       resolve(response.data);
  };
   if (!task[key]) task[key] = [];
   task[key].push(callback);
};
// 处理task
const handleTask = (key, response) => {
   for (let i = 0; task[key] && i < task[key].length; i++) {
       task[key][i](response);
  }
   task[key] = undefined;
};

const getHeaders = { 'Content-Type': 'application/json' };
const postHeaders = { 'Content-Type': 'application/x-www-form-urlencoded' };
const fileHeaders = { 'Content-Type': 'multipart/form-data' };

const request = (method, url, params, headers, preventRepeat = true, uploadFile = false) => {
   let key = url + '?' + qs.stringify(params);
   return new Promise((resolve, reject) => {
       const instance = axios.create({
           baseURL: url,
           headers,
           timeout: 30 * 1000,
      });

       instance.interceptors.request.use(
          (config) => {
               if (preventRepeat) {
                   config.cancelToken = new axios.CancelToken((cancelToken) => {
                       // 判断是否存在请求中的当前请求 如果有取消当前请求
                       if (existInPending(key)) {
                           cancelToken();
                      } else {
                           pushPending({ key, cancelToken });
                      }
                  });
              }
               return config;
          },
          (err) => {
               return Promise.reject(err);
          }
      );

       instance.interceptors.response.use(
          (response) => {
               if (preventRepeat) {
                   removePending(key);
              }
               return response;
          },
          (error) => {
               return Promise.reject(error);
          }
      );

       // 请求执行前加入task
       createTask(key, resolve);

       instance(Object.assign({}, { method }, method === 'post' || method === 'put' ? { data: !uploadFile ? qs.stringify(params) : params } : { params }))
          .then((response) => {
               // 处理task
               handleTask(key, response);
          })
          .catch(() => {});
  });
};

export const get = (url, data = {}, preventRepeat = true) => {
   return request('get', url, data, getHeaders, preventRepeat, false);
};
export const post = (url, data = {}, preventRepeat = true) => {
    return request('post', url, data, postHeaders, preventRepeat, false);
};
export const file = (url, data = {}, preventRepeat = true) => {
    return request('post', url, data, fileHeaders, preventRepeat, true);
};
export default { request, get, post, file };

新增示例代码文件夹/example

示例入口index.js

import { exampleRequestGet } from './api.js';
const example = async () => {
   let res = await exampleRequestGet();
   console.log('请求成功 ');
};
example();

api列表api.js

import { request } from './request.js';
// 示例请求Get
export const exampleRequestGet = (data) => request('get', '/xxxx', data);

// 示例请求Post
export const exampleRequestPost = (data) => request('post', '/xxxx', data);

// 示例请求Post 不去重
export const exampleRequestPost2 = (data) => request('post', '/xxxx', data, false);

// 示例请求Post 不去重
export const exampleRequestFile = (data) => request('file', '/xxxx', data, false);

全局请求封装request.js

import drrq from '../src/index.js';
const baseURL = 'https://xxx';

// 处理请求数据 (拼接url,data添加token等) 请根据实际情况调整
const paramsHandler = (url, data) => {
   url = baseURL + url;
   data.token = 'xxxx';
   return { url, data };
};

// 处理全局接口返回的全局处理相关逻辑 请根据实际情况调整
const resHandler = (res) => {
   // TODO 未授权跳转登录,状态码异常报错等
   return res;
};

export const request = async (method, _url, _data = {}, preventRepeat = true) => {
   let { url, data } = paramsHandler(_url, _data);
   let res = null;
   if (method == 'get' || method == 'GET' || method == 'Get') {
       res = await drrq.get(url, data, preventRepeat);
  }
   if (method == 'post' || method == 'POST' || method == 'Post') {
       res = await drrq.post(url, data, preventRepeat);
  }
   if (method == 'file' || method == 'FILE' || method == 'file') {
       res = await drrq.file(url, data, preventRepeat);
  }
   return resHandler(res);
};

测试功能

代码写完后,我们需要验证功能是否正常,package.json加上

    "scripts": {
       "test": "node example"
  },

执行npm run test


功能正常,工具库准备完毕。

(eslint和prettier读者可视情况选用)

打包

一般项目的打包使用webpack,而工具库的打包则使用rollup

安装 Rollup

通过下面的命令安装 Rollup:

npm install --save-dev rollup

创建配置文件

在根目录创建一个新文件 rollup.config.js

export default {
 input: "src/index.js",
 output: {
   file: "dist/drrp.js",
   format: "esm",
   name: 'drrp'
}
};
  • input —— 要打包的文件

  • output.file —— 输出的文件 (如果没有这个参数,则直接输出到控制台)

  • output.format —— Rollup 输出的文件类型

安装babel

如果要使用 es6 的语法进行开发,还需要使用 babel 将代码编译成 es5。因为rollup的模块机制是 ES6 Modules,但并不会对 es6 其他的语法进行编译。

安装模块

rollup-plugin-babel 将 rollup 和 babel 进行了完美结合。

npm install --save-dev rollup-plugin-babel@latest
npm install --save-dev @babel/core
npm install --save-dev @babel/preset-env

根目录创建 .babelrc

{
"presets": [
[
"@babel/preset-env",
{
"modules": false
}
]
]
}

兼容 commonjs

rollup 提供了插件 rollup-plugin-commonjs,以便于在 rollup 中引用 commonjs 规范的包。该插件的作用是将 commonjs 模块转成 es6 模块。

rollup-plugin-commonjs 通常与 rollup-plugin-node-resolve 一同使用,后者用来解析依赖的模块路径。

安装模块

npm install --save-dev rollup-plugin-commonjs rollup-plugin-node-resolve

压缩 bundle

添加 UglifyJS 可以通过移除注上释、缩短变量名、重整代码来极大程度的减少 bundle 的体积大小 —— 这样在一定程度降低了代码的可读性,但是在网络通信上变得更有效率。

安装插件

用下面的命令来安装 rollup-plugin-uglify

npm install --save-dev rollup-plugin-uglify

完整配置

rollup.config.js 最终配置如下

import resolve from 'rollup-plugin-node-resolve';
import commonjs from 'rollup-plugin-commonjs';
import babel from 'rollup-plugin-babel';
import { uglify } from 'rollup-plugin-uglify';
import json from '@rollup/plugin-json'

const paths = {
input: {
root: 'src/index.js',
},
output: {
root: 'dist/',
},
};

const fileName = `drrq.js`;

export default {
input: `${paths.input.root}`,
output: {
file: `${paths.output.root}${fileName}`,
format: 'esm',
name: 'drrq',
},
plugins: [
json(),
resolve(),
commonjs(),
babel({
exclude: 'node_modules/**',
runtimeHelpers: true,
}),
uglify(),
],
};

在package.json中加上

"scripts": {
"build": "rollup -c"
},

即可执行npm run build将/src/index.js打包为/dist/drrq.js

发包前的准备

准备npm账号,通过npm login或npm adduser。这里有一个坑,终端内连接不上npm源,需要在上网工具内复制终端代理命令后到终端执行才能正常连接。


准备一个简单清晰的readme.md


修改package.json

完整的package.json如下

{
   "name": "drrq",
   "private": false,
   "version": "1.3.5",
   "main": "/dist/drrq.js",
   "repository": "https://gitee.com/yuanying-11/drrq.git",
   "author": "it_yuanying",
   "license": "MIT",
   "description": "能自动取消重复请求的axios封装",
   "type": "module",
   "keywords": [
       "取消重复请求",
  ],
   "dependencies": {
       "axios": "^1.2.0",
       "qs": "^6.11.0"
  },
   "scripts": {
       "test": "node example",
       "build": "rollup -c"
  },
   "devDependencies": {
      ...
  }
}
  • name 包名称 一定不能与npm已有的包名重复,想一个简单易记的

  • private 是否为私有

  • version 版本

  • main 入口文件位置

  • repository git仓库地址

  • author 作者

  • license 协议

  • description 描述

  • keywords 关键词,便于检索

每个 npm 包都需要一个版本,以便开发人员在安全地更新包版本的同时不会破坏其余的代码。npm 使用的版本系统被叫做 SemVer,是 Semantic Versioning 的缩写。

不要过分担心理解不了相较复杂的版本名称,下面是他们对基本版本命名的总结: 给定版本号 MAJOR.MINOR.PATCH,增量规则如下:

  1. MAJOR 版本号的变更说明新版本产生了不兼容低版本的 API 等,

  2. MINOR 版本号的变更说明你在以向后兼容的方式添加功能,接下来

  3. PATCH 版本号的变更说明你在新版本中做了向后兼容的 bug 修复。

表示预发布和构建元数据的附加标签可作为 MAJOR.MINOR.PATCH 格式的扩展。

最后,执行npm publish就搞定啦



本文的完整代码已开源至gitee.com/yuanying-11… ,感兴趣的读者欢迎fork和star!

另外,本文参考了juejin.cn/post/684490…juejin.cn/post/684490…

作者:断律绎殇
来源:juejin.cn/post/7172240485778456606

收起阅读 »

我裁完兄弟们后,辞职了,转行做了一名小职员

那天早晨,我冲进总经理的办公室,发现人力资源总监也在,我说:真巧,真好,两位都在,我要辞职!我在马路上走着,头脑有些昏昏沉沉的。“大爷,我有故事你听听吗?”,扫马路的大爷没理我,提起垃圾桶,走了。“阿姨,你想听听我的经历不?”,等公交的大妈拦下一辆出租车,走了...
继续阅读 »

那天早晨,我冲进总经理的办公室,发现人力资源总监也在,我说:真巧,真好,两位都在,我要辞职!

我在马路上走着,头脑有些昏昏沉沉的。

大爷,我有故事你听听吗?”,扫马路的大爷没理我,提起垃圾桶,走了。

阿姨,你想听听我的经历不?”,等公交的大妈拦下一辆出租车,走了。

算了,年中总结到了,我就谈一谈我的上半身……上半生……不是,上半年吧。

一、十年职场终一梦,互联网里心不平

2022年,是我工作的第11年,这11年,我都在互联网中沉浮,而且一直是向着风口奔跑。

我一开始处于电信增值行业,我们叫SP业务。

现在的年轻人可能很难想象,以前的互动主要靠短信,就是1毛钱一条的短信。

比如某电视台有个话题,对于一个事件你是支持还是反对,如果支持发短信1,反对发送2。

这时候,发送一条短信的定价可以自己设置,比如2元1条,在运营商那里备个案就行,当时来说,这是合法的。

一般为了提高发短信的积极性,都会在最后搞一个抽奖,抽中之后送套茶壶啥的,这样,两块钱也就舍得花了。

因为自己有定价的权利,所以可玩的就有很多。

比如:

我做一个游戏嵌到手机里,想玩就发短信啊。

再比如:

我提供一个服务,每天给你发送天气预报信息,想订阅就发短信啊。

2011年,随着智能手机的兴起,短信有被网络消息取代的趋势,而且乱收费也受到了监管。

所以,一些SP企业就纷纷转型移动互联网,去做智能手机应用。

我此时毕业,因为学的就是智能手机应用开发专业,而且大学期间也自己搞了一些iOSAPP去运作,所以很顺利地就找到工作。

这一干就是5年,搞过手机游戏,搞过订餐系统,搞过电商,搞过O2O,搞过政务……因为企业转型,一般没有目标,什么火就搞什么

后来,我感觉干的太乱了,自己应该抓住一个行业去搞,于是在2016年就去了另一家公司,主要做在线教育平台

所有公司都一样,一个公司干久了,职位自然会提升,因为人都熬走了,只剩下你了,另外你跟各部门都熟,工作推进起来也方便。

我也是这么一步一步走过来,从普通开发到技术组长,从技术组长到技术经理,从技术经理再到项目经理。

工作内容也是越来越杂:

  • 普通开发时,只写客户端代码就可以。

  • 负责技术时,因为客户端嵌入了H5页面,客户端要调服务端的接口,所以我也学会了前端和后端的开发。实际工作中,你会发现,你不了解一线的实际操作,你是心虚的,你没法避免别人糊弄你,你也无法更公正地解决争端,所谓的什么“道理都是相通的”、“能管理好煤炭企业,也能管理好体育企业”这类管理理论,只是作为管理者懒政的借口。

  • 负责项目时,需要对产品原型、UI设计进行一个把握,需要对前期需求和后期售后进行一个兼顾,出个差,陪个酒也是常有的事情。有时候,哪里有空缺了,比如没有人设计原型,那么就要自己顶上去。

整体下来,自己基本上达到这么一种情况:做一个互联网项目,从需求到上线,如果有更专业的人,那么能干的更快更好,如果只有自己,也能磕磕碰碰地完成。

这种技能就是,要说没有本事吧,好像还能干不少事情。要说有本事呢,还真没有一样可以拿出手的绝活。

但是,这不重要。

我更关注的是,我供职的几家公司,包括身边的互联网公司,做产品也好,做平台也罢,都没有实现盈利

此处需要解释一下,我心中的盈利是指传统的盈利,就是通过销售的方式,产生了大于成本的收入,比如这软件花5块钱做的,卖了10块钱。

我供职的公司,基本上都融过资,从百万到千万都有,都是拿着一份未来的规划,就有人投钱。

没有实现盈利,却依然可以持续地生存下去,我认为这不是一种常态。

看不到自己的作品实打实地变现,我是心虚的。

十年互联网一场梦,看着一波又一波游走在风口的企业,虽然从没有耽误过我拿薪水,但是我却是担惊受怕的:

  • 这是泡沫吗?

  • 会破吗?

  • 哪一天会到来?

  • 我这10年的积累稳不稳定?

二、裁员浪潮突袭来,转行意识在徘徊

上半年,大家还在期待着加薪,没想到等到的是裁员。

还好,我是项目负责人,因为平时工作表现还可以,所以不裁我。不但不裁我,还给我升职加薪

但是,我也面临了一个问题:你裁谁?

我刚上任,哪里知道该裁谁,就这么推推搡搡,确定了一个名单。

裁人那天早上,我推门去找总经理,我说我要辞职了,于是就出现了开头的那一幕。

我走在街上,我觉得这是一个经过反复思考后的决定。

可能很多人觉得,疫情期间,能有一份工作不就挺好吗?

但是,我还考虑到了未来。

三十多岁的人了,只考虑眼下的工作吗?问过自己要什么吗?

1、你处的行业怎么样?
  教育行业,崩盘式堕落。
2、行业不行,公司有发展也可以啊,你公司发展怎么样?
  失去方向,驱逐人员。
3、公司不佳,工作内容有前途也可以,你的工作有没有挑战?
  重复性工作,得心应手。
复制代码

好像只剩下钱了,但是这钱,还能挣多久,现在挣得心虚吗?

不只是我这么想,我身边好多人都这么想。

于是,戏剧性的一幕发生了。

大家开始纷纷转行了,这个转行只是小转行,指的是IT行业内的工种转行。

  • 做前端开发的开始转行做后端开发。

  • 做后端开发的开始转行做项目管理。

  • 做项目管理的开始转行做产品管理。

有时候我在想,这个问题是转行就能破局的吗?

是跟整体经济形势有关呢,还是跟个人职业匹配有关呢,说不好,不好说,好像只能试试看了。

其实,我也准备转行了,从项目管理转向人工智能开发。

我从2018年开始,出于兴趣,就已经开始学习人工智能了,我认为写逻辑代码的顶峰就是无逻辑,那就是神经网络,就是人工智能。

4年的学习,也有了一些实践和应用,也该宝刀出鞘了。

三、出去面试吓一跳,行业经验很重要

我先是奔着人工智能算法工程师去投简历,但是我的简历太复杂了,啥都干过,起初我还认为这是优势。

直到碰到一个招聘主管点醒了我,她居然还是我的学姐,她说你把算法部分抽出来吧,面试啥岗位就写啥经历

我一想也对,以前自己也当过面试官,一般除非管理岗位,大公司都比较看重专业性,你招聘一个Android开发,结果简历上80%写的是PHP,这不合适。

我把其他项目都删除了,只保留算法相关的应用案例,基本上都是应用在教育教学方面的。

后来,面试机会真的多了。

但是问题也来了,这些招聘的企业,有的是搞煤炭的,有的是搞养殖的,你与他们很难对上话

比如他们说一个“倒线”,你听不明白,他们都觉得很奇怪,这不是行业基础知识吗?他们认为你应该明白

再后来,我还是决定去教育行业试试,这一去不要紧,一发不可收拾,什么“教材”、“章节”、“知识点”、“题库”、“资源”、“备授课”,搞了多少年了,而且既全面又深度。

最后,我还是选择了一家做算法的教育企业,这将作为我算法职业生涯的起点。

你看,是否教育行业已经不重要了,重要的是算法这个职业,这就是除了钱之外,我们另外追求的点

四、人到中年再重启,空杯心态学到底

这次我选择了做一名小职员,最最底层那种普通开发。

原因是你选择了算法,那么以你在算法领域的资历,当不了管理。

强行做,是会有问题的,所谓:德不配位,必有灾殃

而我也很坦然,做管理这么多年,沉下心来,踏踏实实学习一两年,不好吗?

入职新公司这两个月,我感受到了从来没有过的舒适,没有了没完没了的会议,没有了上午说完中午就交的方案,也没有了深夜打来处理现场问题的电话,只有我深爱的算法代码

而且,通过实际的项目,也让我对算法有了更深的见解,这两个月的收获也远远超过之前的两年

挺好的,善于舍得就会有更多的收获。

相信我通过几年的学习,再结合之前杂七杂八的经验,最终在人工智能产业方面可以做出一定的成绩,这也是我最新的规划。

看见没有,人一旦有了新的希望,就有了动力

我有时候就在思考一个问题,那就是换一个赛道的意义。

你在一个赛道里已经到了8分,换一个赛道再经过几年可能只到7分,换赛道究竟是逃避还是提升

这个真的不好说。

但是有一点可以肯定,你在8分的赛道里已经没有斗志了,换一个赛道你会充满求知欲,重新赋予它新的希望,将以往的成功或者失败的经验全部用来成就它,猜测它应该不会很差吧。

五、长江后浪推前浪,后浪有话对你讲

虽然这是我的总结,但是我也希望对你多少有些影响,该唾弃的唾弃,值得借鉴的借鉴。

对于职场新人,我想对你们说几句话:

1、从基层到管理,从单一到复杂,这是在向上走,肯定是进步的,但同时也在越走越窄。

不要觉得领导傻,尤其是大领导,你觉得一圈人都在骗他,他还不知道呢,就我知道。

其实,有可能是他在骗你们一圈人

能向上走就向上走。

古今中外,位置越高接触的信息就越多,决策也越正确,而这种正确不是你认为的正确。

我之前带过一个项目,开发人员很烂,产品逻辑很烂,我认为应该先梳理人和事,大领导确不以为然。我考虑的是怎样做好,做不好其他的都无从谈起。但是大领导考虑的是平台有没有,某个时间点没有,可能都不用做了。

但是,越往上路是越窄的。

  • 一个开发,可能有5000个合适的岗位。

  • 一个组长,可能有3000个合适的岗位。

  • 一个经理,可能只有1000个合适岗位。

  • 一个总监,可能只有50个岗位。

  • 一个总裁,可能找不到工作。

2、搞管理并一定是你能力强,和信息差有关系,这种能力不一定能平移到其他公司。

如果你当上了管理,也不要骄傲。

这个角色可能并不是你能力强,可能就是没有人愿意干,也可能是你在这公司待得住,甚至可能仅仅就是老板看你顺眼

不管怎样,你既然在这个职位上了,你就会去开各种,去参与各种决策,去描绘各种规划,这可能会让你产生一种自己优秀的错觉。

这种优秀,换一个公司就会把你打回原形。

平台和能力的故事,数不胜数。

所以,如果你是个管理者,不要变成行政管理,那就变成了员工的服务员,每天就是喝茶看新闻,收收报表啊,鼓励鼓励信心啊,你以为没有你的协调就转不起来,其实那是假象,久而久之你就废了。

一定要做业务领导,指导员工的行进路线,披荆斩棘,攻坚克难,培养人才,只有这样,你才能不管去哪里都能立起一杆大旗,这种能力只和有关。

3、民营企业,就是为了实现老板的个人想法,一个单位待得时间越久,你被定制化的就会越深。

很遗憾,这可能很打击人。

你不要谈什么行业规范,谈什么职业操守,起码在民营企业,真的就是为了实现老板的个人想法

他出钱,你干活,除了立马应验的坑,否则你不要去阻拦他、打断他、抵制他。

第一,他会不高兴。第二,你的判断未必对

一个企业,老板是第一责任人,员工是第一背锅人。

你想要在他这里发展,就要多和他站在统一战线上,但是站久了,也会让你忘了世界上还有别人。

有些事,是相同的。但是,有些事是千差万别的。

同一个行为,这个老板可能高度赞扬你,另一个老板就会极度批判你,对错很随机,这就是定制化人才。

就像高速路的收费员,她干了15年,结果来了ETC,她失业了,她说:我只会收费,你撤了,我以后还怎么活啊,我可是连续10年被评为优秀员工的。

你都按照领导说的做了,最终却导致你无路可走,这就是被深度定制

要防止被定制,就要多抬头看看,放眼行业,多思考你在行业中处于什么水平,而不是你在单位中处于什么地位。

一个人的职业生涯,总会受到行业的影响,行业又会受时代影响,各种影响下,我们太渺小了。好好把握机会,不要虚度时光,你努力过,以后不会后悔。有时候鸡汤也挺好,起码让你充实,让这一天积极地度过,这是会提高成功概率的。

作者:TF男孩
来源:juejin.cn/post/7110237776984932389

收起阅读 »

比 JSON.stringify 快两倍的fast-json-stringify

web
前言相信大家对JSON.stringify并不陌生,通常在很多场景下都会用到这个API,最常见的就是HTTP请求中的数据传输, 因为HTTP 协议是一个文本协议,传输的格式都是字符串,但我们在代码中常常操作的是 JSON 格式的数据,所以我们需要在返回响应数据...
继续阅读 »

前言

相信大家对JSON.stringify并不陌生,通常在很多场景下都会用到这个API,最常见的就是HTTP请求中的数据传输, 因为HTTP 协议是一个文本协议,传输的格式都是字符串,但我们在代码中常常操作的是 JSON 格式的数据,所以我们需要在返回响应数据前将 JSON 数据序列化为字符串。但大家是否考虑过使用JSON.stringify可能会带来性能风险🤔,或者说有没有一种更快的stringify方法。

JSON.stringify的性能瓶颈

由于 JavaScript 是动态语言,它的变量类型只有在运行时才能确定,所以 JSON.stringify 在执行过程中要进行大量的类型判断,对不同类型的键值做不同的处理。由于不能做静态分析,执行过程中的类型判断这一步就不可避免,而且还需要一层一层的递归,循环引用的话还有爆栈的风险。

我们知道,JSON.string的底层有两个非常重要的步骤:

  • 类型判断

  • 递归遍历

既然是这样,我们可以先来对比一下JSON.stringify与普通遍历的性能,看看类型判断这一步到底是不是影响JSON.stringify性能的主要原因。

JSON.stringify 与遍历对比

const obj1 = {}, obj2 = {}
for(let i = 0; i < 1000000; i++) {
   obj1[i] = i
   obj2[i] = i
}

function fn1 () {
   console.time('jsonStringify')
   const res = JSON.stringify(obj1) === JSON.stringify(obj2)
   console.timeEnd('jsonStringify')
}

function fn2 () {
   console.time("for");
   const res = Object.keys(obj1).every((key) => {
       if (obj2[key] || obj2[key] === 0) {
         return true;
      } else {
         return false;
      }
    });
   console.timeEnd("for");
}
fn1()
fn2()


从结果来看,两者的性能差距在4倍左右,那就证明JSON.string的类型判断这一步还是非常耗性能的。如果JSON.stringify能够跳过类型判断这一步是否对类型判断有帮助呢?

定制化更快的JSON.stringify

基于上面的猜想,我们可以来尝试实现一下:

现在我们有下面这个对象

const obj = {
 name: '南玖',
 hobby: 'fe',
 age: 18,
 chinese: true
}

上面这个对象经过JSON.stringify处理后是这样的:

JSON.stringify(obj)
// {"name":"南玖","hobby":"fe","age":18,"chinese":true}

现在假如我们已经提前知道了这个对象的结构

  • 键名不变

  • 键值类型不变

这样的话我们就可以定制一个更快的JSON.stringify方法

function myStringify(obj) {
   return `{"name":"${obj.name}","hobby":"${obj.hobby}","age":${obj.age},"chinese":${obj.chinese}}`
}

console.log(myStringify(obj) === JSON.stringify(obj))  // true

这样也能够得到JSON.stringify一样的效果,前提是你已经知道了这个对象的结构。

事实上,这是许多JSON.stringify加速库的通用手段:

  • 需要先确定对象的结构信息

  • 再根据结构信息,为该种结构的对象创建“定制化”的stringify方法

  • 内部实现依然是这种字符串拼接

更快的fast-json-stringify

fast-json-stringify 需要JSON Schema Draft 7输入来生成快速stringify函数。

这也就是说fast-json-stringify这个库是用来给我们生成一个定制化的stringily函数,从而来提升stringify的性能。

这个库的GitHub简介上写着比 JSON.stringify() 快 2 倍,其实它的优化思路跟我们上面那种方法是一致的,也是一种定制化stringify方法。

语法

const fastJson = require('fast-json-stringify')
const stringify = fastJson(mySchema, {
 schema: { ... },
 ajv: { ... },
 rounding: 'ceil'
})
  • schema: $ref 属性引用的外部模式。

  • ajv: ajv v8 实例对那些需要ajv.

  • rounding: 设置当integer类型不是整数时如何舍入。

  • largeArrayMechanism:设置应该用于处理大型(默认情况下20000或更多项目)数组的机制

scheme

这其实就是我们上面所说的定制化对象结构,比如还是这个对象:

const obj = {
 name: '南玖',
 hobby: 'fe',
 age: 18,
 chinese: true
}

它的JSON scheme是这样的:

{
 type: "object",
 properties: {
   name: {type: "string"},
   hobby: {type: "string"},
   age: {type: "integer"},
   chinese: {type: 'boolean'}
},
 required: ["name", "hobby", "age", "chinese"]
}

AnyOf 和 OneOf

当然除了这种简单的类型定义,JSON Schema 还支持一些条件运算,比如字段类型可能是字符串或者数字,可以用 oneOf 关键字:

"oneOf": [
{
   "type": "string"
},
{
   "type": "number"
}
]

fast-json-stringify支持JSON 模式定义的anyOfoneOf关键字。两者都必须是一组有效的 JSON 模式。不同的模式将按照指定的顺序进行测试。stringify在找到匹配项之前必须尝试的模式越多,速度就越慢。

anyOfoneOf使用ajv作为 JSON 模式验证器来查找与数据匹配的模式。这对性能有影响——只有在万不得已时才使用它。

关于 JSON Schema 的完整定义,可以参考 Ajv 的文档,Ajv 是一个流行的 JSON Schema验证工具,性能表现也非常出众。

当我们可以提前确定一个对象的结构时,可以将其定义为一个 Schema,这就相当于提前告诉 stringify 函数,需序列化的对象的数据结构,这样它就可以不必再在运行时去做类型判断,这就是这个库提升性能的关键所在。

简单使用

const fastJson = require('fast-json-stringify')
const stringify = fastJson({
 title: 'myObj',
 type: 'object',
 properties: {
   name: {
     type: 'string'
  },
   hobby: {
     type: 'string'
  },
   age: {
     description: 'Age in years',
     type: 'integer'
  },
   chinese: {
     type: 'boolean'
  }
}
})

console.log(stringify({
 name: '南玖',
 hobby: 'fe',
 age: 18,
 chinese: true
}))

生成 stringify 函数

fast-json-stringify是跟我们传入的scheme来定制化生成一个stringily函数,上面我们了解了怎么为我们对象定义一个scheme结构,接下来我们再来了解一下如何生成stringify

这里有一些工具方法还是值得了解一下的:

const asFunctions = `
function $asAny (i) {
   return JSON.stringify(i)
 }

function $asNull () {
   return 'null'
 }

function $asInteger (i) {
   if (isLong && isLong(i)) {
     return i.toString()
   } else if (typeof i === 'bigint') {
     return i.toString()
   } else if (Number.isInteger(i)) {
     return $asNumber(i)
   } else {
     return $asNumber(parseInteger(i))
   }
 }

function $asNumber (i) {
   const num = Number(i)
   if (isNaN(num)) {
     return 'null'
   } else {
     return '' + num
   }
 }

function $asBoolean (bool) {
   return bool && 'true' || 'false'
 }

 // 省略了一些其他类型......

从上面我们可以看到,如果你使用的是 any 类型,它内部依然还是用的 JSON.stringify。 所以我们在用TS进行开发时应避免使用 any 类型,因为如果是基于 TS interface 生成JSON Schema 的话,使用 any 也会影响到 JSON 序列化的性能。

然后就会根据 scheme 定义的具体内容生成 stringify 函数的具体代码。而生成的方式也比较简单:通过遍历 scheme,根据不同数据类型调用上面不同的工具函数来进行字符串拼接。感兴趣的同学可以在GitHub上查看源码

总结

事实上fast-json-stringify只是通过静态的结构信息将优化与分析前置了,通过开发者定义的scheme内容可以提前知道对象的数据结构,然后会生成一个stringify函数供开发者调用,该函数内部其实就是做了字符串的拼接。

  • 开发者定义 Object 的 JSON scheme

  • stringify 库根据 scheme 生成对应的模版方法,模版方法里会对属性与值进行字符串拼接

  • 最后开发者调用生成的stringify 方法

作者:前端南玖
来源:juejin.cn/post/7173482852695146510

收起阅读 »

为 Kotlin 的函数添加作用域限制(以 Compose 为例)

前言 不知道各位是否已经开始了解 Jetpack Compose? 如果已经开始了解并且上手写过。那么,不知道你们有没有发现,在 Compose 中对于作用域(Scopes)的应用特别多。比如, weight 修饰符只能用在 RowScope 或者 Colum...
继续阅读 »

前言


不知道各位是否已经开始了解 Jetpack Compose?


如果已经开始了解并且上手写过。那么,不知道你们有没有发现,在 Compose 中对于作用域(Scopes)的应用特别多。比如, weight 修饰符只能用在 RowScope 或者 ColumnScope 作用域中。又比如,item 组件只能用在 LazyListScope 作用域中。


如果你还没有了解过 Compose 的话,那你也应该知道,kotlin 标准库中有 5 个作用域函数:let() apply() also() with() run() ,这 5 个函数会以不同的方式持有和返回上下文对象,即调用这些函数时,在它们的 lambda 参数中写的代码将处于特定的作用域。


不知道你们有没有思考过,这些作用域限制是怎么实现的呢?如果我们想自定义一个 Composable 函数,只支持在特定的作用域中使用,应该怎么写呢?


本文将为你解开这个疑惑。


作用域


不过在正式开始之前我们还是先大概补充一点有关 kotlin 中作用域的基本知识。


什么是作用域


其实对于咱们程序员来说,不管学的是什么语言,对于作用域应该都是有一个了解的。


举个简单的例子:


val valueFile = "file"

fun a() {
val valueA = "a"
println(valueFile)
println(valueA)
println(valueB)
}

fun b() {
val valueB = "b"
println(valueFile)
println(valueA)
println(valueB)
}

这段代码不用运行都知道肯定会报错,因为在函数 a 中无法访问 valueB ;在函数 b 中无法访问 valueA 。但是这两个函数都可以成功访问 valueFile


这是因为 valueFile 的作用域是整个 .kt 文件,也就是说,只要是在这个文件中的代码,都可以访问到它。


valueAvalueB 的作用域则分别是在函数 a 和 b 中,显然只能在各自的作用域中使用。


同理,如果我们想要调用类的方法或者函数也需要考虑作用域:


class Test {
val valueTest = "test"

fun a(): String {
val valueA = "a"
println(valueTest)
println(valueA)

return "returnA"
}

fun b() {
println(valueA)
println(valueTest)
println(a())
}
}

fun main() {
println(valueTest)
println(valueA)
println(a())
}

这里举的例子可能不太恰当,但是这里是为了说明这个情况,不要过多纠结哦~


显然,上面这个代码,在 main 函数中是无法访问到变量 valueTestvalueA 的,并且也无法调用函数 a() ;而在 Test 类中的函数 a() 显然可以访问到 valueTestvalueA ,并且函数 b() 也可以调用函数 a(),可以访问变量 valueTest 但是无法访问变量 valueA


这是因为函数 a()b() 以及变量 valueTest 位于同一个作用域中,即类 Test 的作用域。


而变量 valueA 位于函数 a() 的作用域内,由于 a() 又位于 Test 的作用域内,所以实际上这里的 valueA 的作用域称为嵌套作用域,即同时位于 a()Test 的作用域内。


因为本节只是为了引出我们今天要介绍的内容,所以有关作用域的知识就简单介绍这么多,更多有关作用域的知识可以阅读参考资料 1 。


kotlin 标准库中的作用域函数


在前言中我们说过,kotlin标准库中有5个称之为作用域函数的东西:withrunletalsoapply


它们有什么作用呢?


先看一段我们经常会遇到的代码形式:


val person = Person()
person.fullName = "equationl"
person.lastName = "l"
person.firstName = "equation"
person.age = 24
person.gender = "man"

在某些情况下,我们可能会需要多次重复的写一堆 person,可读性很差,写起来也很繁琐。


此时我们就可以使用作用域函数,例如使用 with 改写:


with(person) {
fullName = "equationl"
lastName = "l"
firstName = "equation"
age = 24
gender = "man"
}

此时,我们就可以省略掉 person ,直接访问或修改它的属性值,这是因为 with 的第一个参数接收的是需要作为第二个参数的 lambda 上下文对象,即此时,第二个参数 lambda 匿名函数所在的作用域为第一个参数传入的对象,此时 IDE 的提示也指出了此时 with 的匿名函数中的作用域为 Person


1.png


所以在这个匿名函数中能直接访问或修改 Person 的属性。


同理,我们也可以使用 run 函数改写:


person.run {
fullName = "equationl"
lastName = "l"
firstName = "equation"
age = 24
gender = "man"
}

可以看出,runwith 非常相似,只是 run 是以扩展函数的形式接收上下文对象,它的参数只有一个 lambda 匿名函数。


后面还有 let


person.let {
it.fullName = "equationl"
it.lastName = "l"
it.firstName = "equation"
it.age = 24
it.gender = "man"
}

它与 run 的区别在于,匿名函数中的上下文对象不再是隐式接收器(this),而是作为一个参数(it)存在。


使用 also() 则是:


person.also {
it.fullName = "equationl"
it.lastName = "l"
it.firstName = "equation"
it.age = 24
it.gender = "man"
}

let 一样,它也是扩展函数,并且上下文也作为参数传入匿名函数,但是不同于 let ,它会返回上下文对象,这样可以方便的进行链式调用,如:


val personString = person
.also {
it.age = 25
}
.toString()

最后是 apply


person.apply {
fullName = "equationl"
lastName = "l"
firstName = "equation"
age = 24
gender = "man"
}

also 一样,它是扩展函数,也会返回上下文对象,但是它的上下文将作为隐式接收者,而不是匿名函数的一个参数。


下面是它们 5 个函数的对比图和表格:


2.png











































函数上下文形式返回值是否是扩展函数
with隐式接收者(this)lambda函数(Unit)
run隐式接收者(this)lambda函数(Unit)
let匿名函数的参数(it)lambda函数(Unit)
also匿名函数的参数(it)上下文对象
apply隐式接收者(this)上下文对象

Compose 中的作用域限制


在前言中我们说过,在 Compose 对作用域限制的应用非常多。


例如 Modifier 修饰符,从这个 Compose 修饰符列表 中,我们也能看到很多修饰符的作用域都做了限制:


3.png


这里需要对修饰符做限制的原因非常简单:



In the Android View system, there is no type safety. Developers usually find themselves trying out different layout params to discover which ones are considered and their meaning in the context of a particular parent.



在传统的 xml view 体系中就是没有对布局的参数做限制,这就导致所有的参数都可以用在任意布局中,这会导致一些问题。轻则参数无效,写了一堆无用参数;严重的可能会干扰到布局的正常使用。


当然,Modifier 修饰符限制只是 Compose 中其中一个应用,在 Compose 中还有很多作用域限制的例子,例如:


4.png


在上图中 item 只能在 LazyListScope 作用域使用,drawRect 只能在 DrawScope 作用域使用。


当然,正如我们前面说的,作用域中不只有函数和方法,还可以访问类的属性,例如,在 DrawScope 作用域提供了一个名为 size 的属性,可以通过它来拿到当前的画布大小:


5.png


那么,这些是怎么实现的呢?


自定义我们的作用域限制函数


原理


在开始实现我们自己的作用域函数之前,我们需要先了解一下原理。


这里我们以 Compose 的 Canvas 为例来看看。


首先是 Canvas 的定义:


6.png


可以看到这里 Canvas 接收了两个参数:modifier 和 onDraw 的 lambda ,且这个 lambda 的 Receiver(接收者) 为 DrawScope ,也就是说,onDraw 这个匿名函数的作用域被限制在了 DrawScope 内,这也意味着可以在匿名函数内部使用 DrawScope 作用域内的属性、方法等。


再来看看这个 DrawScope 是何方神圣:


7.png


可以看到这是一个接口,里面定义了一些属性变量(如我们上面说的 size) 和一些方法(如我们上面说的 drawRect )。


然后再实现这个接口,编写具体实现代码:


8.png


实现


所以总结来说,如果我们想实现自己的作用域限制大致分为三步:



  1. 编写作为作用域的接口

  2. 实现这个接口

  3. 在暴露的方法中将 lambda 参数接收者使用上面定义的接口


下面我们举个例子。


假如我们要在 Compose 中实现一个遮罩引导层,用于引导新用户操作,类似这样:


main_intro.gif


图源 Intro-showcase-view


但是我们希望引导层上的提示可以多样化,例如可以支持文字提示、图片提示、甚至播放视频或动图提示,但是我们不希望这些提示 item 在遮罩层以外的地方被调用,因为它们依赖于遮罩层的某些参数,如果在外部调用会出错。


这时候,使用作用域限制就非常合适。


首先,我们编写一个接口:


interface ShowcaseScreenScope {
val isShowOnce: Boolean

@Composable
fun ShowcaseTextItem()
}

在这个接口中我们定义了一个属性变量 isShowOnce 用于表示这个引导层是否只显示一次、定义一个方法 ShowcaseTextItem 表示在引导层上显示一串文字,同理我们还可以定义 ShowcaseImageItem 表示显示图片。


然后实现这个接口:


private class ShowcaseScopeImpl: ShowcaseScreenScope {

override val isShowOnce: Boolean
get() = TODO("在这里编写是否只显示一次的逻辑")

@Composable
override fun ShowcaseTextItem() {
// 在这里写你的实现代码
Text(text = "我是说明文字")
}
}

在接口实现中,根据我们的需求编写相应的实现逻辑代码。


最后,写一个提供给外部调用的 Composable:


@Composable
fun ShowcaseScreen(content: @Composable ShowcaseScreenScope.() -> Unit) {
// 在这里实现其他逻辑(例如显示遮罩)后调用 content
// ……
ShowcaseScopeImpl().content()
}

在这个 composable 中,我们可以先处理完其他逻辑,例如显示遮罩层 UI 或显示动画后再调用 ShowcaseScopeImpl().content() 将我们传递的子 Item 组合上去。


最后,使用时只需要调用:


ShowcaseScreen {
if (!isShowOnce) {
ShowcaseTextItem()
}
}

当然,这个 ShowcaseTextItem()isShowOnce 位于 ShowcaseScreenScope 作用域内,在外面是不能调用的:


9.png


总结


本文简要介绍了 Kotlin 中的作用域概念和标准库中的作用域函数,并引申到 Compsoe 中关于作用域的应用,最终分析实现原理并讲解如何自定义一个我们自己的 Compose 作用域函数。


本文写的可能比较浅显,很多知识点都是点到为止,没有过多讲解,推荐读者阅读完后,可以看看文末的参考链接中其他大佬写的文章。


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

检测Android应用使用敏感信息(mac地址、IMEI等)的方法

今天在提交app时,遇到授权前隐私不过的问题,所有的初始化都后置到授权后了,还是被报有获取mac地址和android_id的行为,这很是奇怪,自己也是无处下手,毕竟log里面是没有的,应用商店也没有提供堆栈。 经过一番查找,找到一套自测的工具,这里自己也记录并...
继续阅读 »

今天在提交app时,遇到授权前隐私不过的问题,所有的初始化都后置到授权后了,还是被报有获取mac地址和android_id的行为,这很是奇怪,自己也是无处下手,毕竟log里面是没有的,应用商店也没有提供堆栈。


经过一番查找,找到一套自测的工具,这里自己也记录并分享一下,手把手的来一步步操作,就可以自测了,废话不多说,下面按步骤来写了(无需ROOT):



  1. 下载虚拟系统:VirtualXposed 0.22.0版本这个反正我用有问题,就用了0.20.3了~

  2. 压缩包里面有VirtualXposed_for_GameGuardian_0.20.3.apkVirtualXposed_0.20.3.apk

  3. 将两个apk都安装到手机里面,桌面会看到VirtualXposed图标。

  4. 自行编译PrivacyCheck检测隐私打点工具或者可以用我编译好的 测试包privacy_check.apk

  5. 安装要检测的应用,我们这里随便拿个app来测试,就拿掘金来练手吧~ 现在桌面是这样的:991654613758_.pic.jpg

  6. 打开VirtualXposed,如果是全面屏记得恢复成普通导航,因为需要菜单功能。做安卓的应该知道菜单怎么调用,小米手机:长按任务键进入设置~~

  7. 点击添加应用:勾选PrivacyCheck稀土掘金,点击下面的安装按钮。

  8. 弹框选择:VIRTUALXPOSED,等待安装结束,点击完成! 界面如下:1001654614388_.pic.jpg

  9. 点击Xposed Installer,也就是最右面那个app。安装完成的样子:1011654614466_.pic.jpg

  10. Xposed Installerapp里面,左上角点击侧滑栏,点击模块,勾选PrivacyCheck,如图:1021654614595_.pic.jpg

  11. 返回到VirtualXposed界面,进入菜单,最下面有一个重启项,点击重启~ 很快就可以了~

  12. 返回到这个界面:1001654614388_.pic.jpg

  13. 点击PrivacyCheckapp,启动完成后,看到就一行字,无需关心,此时切换应用回:VirtualXposed界面。(不要返回,直接应用间切换就好了,保持PrivacyCheck没有杀死。

  14. 打开终端(mac),输入:adb logcat | grep PrivacyCheck,回车,会看到这样一行:E PrivacyCheck: 加载app 包名:com.test.privacycheck

  15. 打开要测试的app,这里是打开掘金app,不要点击同意,观察log输出:E PrivacyCheck: 加载app 包名:com.daimajia.gold,只输出了一行,看上去很不错,没有任何问题。

  16. 参考步骤5,打开其他测试app,比如我之前有问题的app,观察下log:image.png


可以很清楚的看到错误堆栈,看到我这里是因为调用页面start的统计造成的,一下就想起来自己统计根页面时路径导致的,很容易就解决了~~


最后问题改动很简单,但查找的过程还比较麻烦,同时也学到了这种排查隐私的方法,希望也能帮到需要的人~~


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

Android 一种点赞动画的实现

最近有个需求,需要仿照公司的H5实现一个游戏助手,其中一个点赞的按钮有动画效果,如下图: 分析一下这个动画,点击按钮后,拇指首先有个缩放的效果,然后有5个拇指朝不同的方向移动,其中部分有放大的效果。 点击后的缩放效果 本文通过ScaleAnimation 实...
继续阅读 »

最近有个需求,需要仿照公司的H5实现一个游戏助手,其中一个点赞的按钮有动画效果,如下图:


device-2022-12-03-17 -original-original.gif

分析一下这个动画,点击按钮后,拇指首先有个缩放的效果,然后有5个拇指朝不同的方向移动,其中部分有放大的效果。


点击后的缩放效果


本文通过ScaleAnimation 实现缩放效果,代码如下:


private fun playThumbUpScaleAnimator() {
// x、y轴方向都从1倍放大到2倍,以控件的中心为原点进行缩放
ScaleAnimation(1f, 2f, 1f, 2f, Animation.RELATIVE_TO_SELF, 0.5f, Animation.RELATIVE_TO_SELF, 0.5f).run {
// 先取消控件当前的动画效果(重复点击时)
view.clearAnimation()
// 设置动画的持续时间
duration = 300
// 开始播放动画
view.startAnimation(this)
}
}

拇指的散开效果


有5个拇指分别往不同的方向移动,本文通过动态添加View,并对View设置动画来实现。可以看到在移动的同时还有缩放的效果,所以需要同时播放几个动画。


本文通过ValueAnimatorAnimatorSet来实现该效果,代码如图:


// 此数组控制动画的效果
// 第一个参数控制X轴移动距离
// 第二个参数控制Y轴移动距离
// 第三个参数控制缩放的倍数(基于原大小)
val animatorConfig: ArrayList<ArrayList<Float>> = arrayListOf(
arrayListOf(-160f, 150f, 1f),
arrayListOf(80f, 130f, 1.1f),
arrayListOf(-120f, -170f, 1.3f),
arrayListOf(80f, -130f, 1f),
arrayListOf(-20f, -80f, 0.8f))

private fun playDiffusionAnimator() {
for (index in 0 until 5) {
binding.root.run {
if (this is ViewGroup) {
// 创建控件
val ivThumbUp = AppCompatImageView(context)
ivThumbUp.setImageResource(R.drawable.icon_thumb_up)
// 设置与原控件一样的大小
ivThumbUp.layoutParams = FrameLayout.LayoutParams(DensityUtil.dp2Px(25), DensityUtil.dp2Px(25))
// 先设置为全透明
ivThumbUp.alpha = 0f
addView(ivThumbUp)
// 设置与原控件一样的位置
ivThumbUp.x = binding.ivThumbUp.x
ivThumbUp.y = binding.ivThumbUp.y
AnimatorSet().apply {
// 设置动画集开始播放前的延迟
startDelay = 330L + index * 50L
// 设置动画监听
addListener(object : Animator.AnimatorListener {
override fun onAnimationStart(animation: Animator) {
// 开始播放时把控件设置为不透明
ivThumbUp.alpha = 1f
}

override fun onAnimationEnd(animation: Animator) {
// 播放结束后再次设置为透明,并从根布局中移除
ivThumbUp.alpha = 0f
ivThumbUp.clearAnimation()
ivThumbUp.post { removeView(ivThumbUp) }
}

override fun onAnimationCancel(animation: Animator) {}

override fun onAnimationRepeat(animation: Animator) {}
})
// 设置三个动画同时播放
playTogether(
// 缩放动画
ValueAnimator.ofFloat(1f, animatorConfig[index][2]).apply {
duration = 700
// 设置插值器,速度一开始快,快结束时减慢
interpolator = DecelerateInterpolator()
addUpdateListener { values ->
(values.animatedValue as Float).let { value ->
ivThumbUp.scaleX = value
ivThumbUp.scaleY = value
}
}
},
// X轴的移动动画
ValueAnimator.ofFloat(ivThumbUp.x, ivThumbUp.x + animatorConfig[index][0]).apply {
duration = 700
interpolator = DecelerateInterpolator()
addUpdateListener { values ->
ivThumbUp.x = values.animatedValue as Float
}
},
// Y轴的移动动画
ValueAnimator.ofFloat(ivThumbUp.y, ivThumbUp.y + animatorConfig[index][1]).apply {
duration = 700
interpolator = DecelerateInterpolator()
addUpdateListener { values ->
ivThumbUp.y = values.animatedValue as Float
}
})
}.start()
}
}
}
}

示例


整合之后做了个示例Demo,完整代码如下:


class AnimatorSetExampleActivity : BaseGestureDetectorActivity() {

private lateinit var binding: LayoutAnimatorsetExampleActivityBinding

private val animatorConfig: ArrayList<java.util.ArrayList<Float>> = arrayListOf(
arrayListOf(-160f, 150f, 1f),
arrayListOf(80f, 130f, 1.1f),
arrayListOf(-120f, -170f, 1.3f),
arrayListOf(80f, -130f, 1f),
arrayListOf(-20f, -80f, 0.8f))


override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = DataBindingUtil.setContentView(this, R.layout.layout_animatorset_example_activity)
binding.ivThumbUp.setOnClickListener {
playThumbUpScaleAnimator()
playDiffusionAnimator()
}
}

private fun playThumbUpScaleAnimator() {
// x,y轴方向都从1倍放大到2倍,以控件的中心为原点进行缩放
ScaleAnimation(1f, 2f, 1f, 2f, Animation.RELATIVE_TO_SELF, 0.5f, Animation.RELATIVE_TO_SELF, 0.5f).run {
// 先取消控件当前的动画效果(重复点击时)
binding.ivThumbUp.clearAnimation()
// 设置动画的持续时间
duration = 300
// 开始播放动画
binding.ivThumbUp.startAnimation(this)
}
}

private fun playDiffusionAnimator() {
for (index in 0 until 5) {
binding.root.run {
if (this is ViewGroup) {
// 创建控件
val ivThumbUp = AppCompatImageView(context)
ivThumbUp.setImageResource(R.drawable.icon_thumb_up)
// 设置与原控件一样的大小
ivThumbUp.layoutParams = FrameLayout.LayoutParams(DensityUtil.dp2Px(25), DensityUtil.dp2Px(25))
// 先设置为全透明
ivThumbUp.alpha = 0f
addView(ivThumbUp)
// 设置与原控件一样的位置
ivThumbUp.x = binding.ivThumbUp.x
ivThumbUp.y = binding.ivThumbUp.y
AnimatorSet().apply {
// 设置动画集开始播放前的延迟
startDelay = 330L + index * 50L
// 设置动画监听
addListener(object : Animator.AnimatorListener {
override fun onAnimationStart(animation: Animator) {
// 开始播放时把控件设置为不透明
ivThumbUp.alpha = 1f
}

override fun onAnimationEnd(animation: Animator) {
// 播放结束后再次设置为透明,并从根布局中移除
ivThumbUp.alpha = 0f
ivThumbUp.clearAnimation()
ivThumbUp.post { removeView(ivThumbUp) }
}

override fun onAnimationCancel(animation: Animator) {}

override fun onAnimationRepeat(animation: Animator) {}
})
// 设置三个动画同时播放
playTogether(
// 缩放动画
ValueAnimator.ofFloat(1f, animatorConfig[index][2]).apply {
duration = 700
// 设置插值器,速度一开始快,快结束时减缓
interpolator = DecelerateInterpolator()
addUpdateListener { values ->
(values.animatedValue as Float).let { value ->
ivThumbUp.scaleX = value
ivThumbUp.scaleY = value
}
}
},
// Y轴的移动动画
ValueAnimator.ofFloat(ivThumbUp.x, ivThumbUp.x + animatorConfig[index][0]).apply {
duration = 700
interpolator = DecelerateInterpolator()
addUpdateListener { values ->
ivThumbUp.x = values.animatedValue as Float
}
},
// X轴的移动动画
ValueAnimator.ofFloat(ivThumbUp.y, ivThumbUp.y + animatorConfig[index][1]).apply {
duration = 700
interpolator = DecelerateInterpolator()
addUpdateListener { values ->
ivThumbUp.y = values.animatedValue as Float
}
})
}.start()
}
}
}
}
}

效果如图:


device-2022-12-03-18 -original-original.gif

个人感觉还原度还是可以的哈哈。


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

反思:Google 为何把 SurfaceView 设计的这么难用?

启程 如果你有过 SurfaceView 的使用经历,那么你一定和我一样,曾经被它所引发出 层出不穷的异状 折磨的 怀疑人生—— 毕竟,作为一个有理想的开发者,在深入了解 SurfaceView 之前,你很难想通这样一个问题: 为什么 Google 把 Su...
继续阅读 »

启程


如果你有过 SurfaceView 的使用经历,那么你一定和我一样,曾经被它所引发出 层出不穷的异状 折磨的 怀疑人生—— 毕竟,作为一个有理想的开发者,在深入了解 SurfaceView 之前,你很难想通这样一个问题:



为什么 GoogleSurfaceView 设计的这么难用?



  • 不支持 transform 动画;

  • 不支持半透明混合;

  • 移动,大小改变,隐藏/显示操作引发的各种问题;



另一方面,即使你对 SurfaceView 使用不多,图形系统 的这朵乌云依然笼罩在每一位 Android 开发者的头顶,来看 Google 对其的 描述


1.png


最终我尝试走近这片迷雾,并一点点去思考下列问题的答案:





    1. SurfaceView 的设计初衷是为了解决什么问题?





    1. 实际开发中,SurfaceView 这么 难用 的根本原因是什么?





    1. 为了解决这些问题,Google 的工程师进行了哪些 尝试




接下来,读者可带着这些问题,跟随笔者一起,再次回顾 SurfaceView 设计和实现的精彩历程。


一、世界观


在了解 SurfaceView 的设计初衷之前,读者首先需要对 Android 现有的图形架构有一个基本的了解。


Android 系统采用一种称为 Surface 的图形架构,简而言之,每一个 Activity 都关联有至少一个 Window(窗口),每一个 Window 都对应有一个 Surface


Surface 这里直译过来叫做 绘图表面 ,顾名思义,其可在内存中生成一个图形缓冲区队列,用于描述 UI,经与系统服务的WindowServiceManager 通信后、通过 SurfaceFlinger 服务持续合成并送显到显示屏。


读者可通过下图,在印象上对整个流程建立一个简单的轮廓:


2.png


由此可见,通常情况下,一个 ActivityUI 渲染本质是 系统提供一块内存,并创建一个图形缓冲区进行维护;这块内存就是 Surface,最终页面所有 ViewUI 状态数据,都会被填充到同一个 Surface 中。


截至目前一切正常,但需要指出的是,现有图形系统的架构设计中还藏了一个线程相关的 隐患


二、设计起源


1.线程问题


问题点在于:我们还需保证 Surface 内部 Buffer 缓冲区的 线程安全


这样的描述,对于读者似乎太过飘渺,但从结论来说,最终,一条 Android开发者 耳熟能详 的规则因此而诞生:


主线程不能执行耗时操作


我们知道, UI 的所有操作,一定会涉及到视图(View 树) 内部大量状态的维护,而 Surface 内部的缓冲区也会不断地被读写,并交给系统渲染。因此,如果 UI 相关的操作,放在不同的线程中执行,而多线程对这一块内存区域的读写,势必会引发内部状态的混乱。


为了避免这个问题,设计者就需要通过某种手段保证线程同步(比如加锁),而这种同步所带来的巨大开销,对于开发者而言,是不可接受的。


因此,最合理的方案就是保证所有UI相关操作都在同一个线程,而这个线程也被称作 主线程UI 线程。


现在,我们将UI操作限制到主线程去执行,以解决了本小节开始时提到的线程问题,但开发者仍需小心—— 众所周知,主线程除了执行UI相关的操作之外,还负责接收各种各样的 输入事件(比如触摸、按键等),因此,为了保证用户的输入事件能够及时得到响应,我们就要保证 UI 操作的 稳定高效,尽可能避免耗时的 UI 操作。


2.动机


挑战随之而来。


当渲染的缓冲数据来自外部的其它系统服务或API时——比如系统媒体解码器的音视频数据,或者 Camera API 的相机数据等,这时 UI 渲染的效率要求会变得非常高。


开发者有了新的诉求:能否有这样一种特殊的视图,它拥有独立的 Surface ,这样就可以脱离现有 Activity 宿主的限制,在一个独立的线程中进行绘制。


由于该视图不会占用主线程资源,一方面可以实现复杂而高效的 UI 渲染,另一方面可以及时响应用户其它输入事件


因此,SurfaceView 应运而生:与常规视图控件不同,SurfaceView 拥有独立的 Surface,如果我们将一个 Surface 理解为一个层级 (Layer),最终 SurfaceFlinger 会将前后两者的2Layer 进行 合成渲染


4.jpg


现在,我们引用官方文档的描述,再次重申适用 SurfaceView 的场景:



在需要渲染到单独的 Surface(例如,使用 Camera APIOpenGL ES 上下文进行渲染)时,使用 SurfaceView 进行渲染很有帮助。使用 SurfaceView 进行渲染时,SurfaceFlinger 会直接将缓冲区合成到屏幕上。


如果没有 SurfaceView,您需要将缓冲区合成到屏幕外的 Surface,然后该 Surface 会合成到屏幕上,而使用 SurfaceView 进行渲染可以省去额外的工作。



3.具体思路


根据当前的设想,我们针对 SurfaceView 设计思路进行细化。


首先,我们需对现有的视图树结构进行改造。为了便于使用,我们允许开发者将 SurfaceView 直接加入到现有的视图树中(即作为控件,它受限于宿主 View Hierachy的结构关系),但在系统服务端中,对于 SurfaceFlinger 而言,SurfaceView 又是完全与宿主完全分离开的:


5.png


在上图中,我们可以看到,在 z 轴上,SurfaceView 默认是低于 DecorView 的,也就是说,SurfaceView 通常总是处于当前页面的最下方。


这似乎有些违反直觉,但仔细考虑 SurfaceView 的应用场景,无论是 Camera 相机应用、音视频播放页,亦或者是渲染游戏画面等,SurfaceView 承载的画面似乎总应该在页面的最下面。


实际设计中也是如此,用来描述 SurfaceViewLayer 或者 LayerBufferz 轴位置默认是低于宿主窗口的。与此同时,为了便于最底层的视图可见, SurfaceView 在宿主 Activity 的窗口上设置了一块透明区域(挖了一个洞)。


最终,SurfaceFlinger 把所有的 Layer 通过用统一流程来绘制和合成对应的 UI


在整个过程中,我们需更进一步深入研究几个细节:



  1. SurfaceView 与宿主视图树结构的关系,以及 挖洞 过程的实现;

  2. SurfaceView 与系统服务的通信创建 Surface的实现;

  3. SurfaceView 具体绘制流程的实现。


三、施工


1. 视图树与挖洞


一句话总结 SurfaceView 与视图树的关系: 在视图树内部,但又没完全在内部


首先,SurfaceView 的设计依然遵循 AndroidView 体系,继承了 View,这意味着使用时,它可以声明在 xml 布局文件中:


// /frameworks/base/core/java/android/view/SurfaceView.java
public class SurfaceView extends View { }


出于安全性的考量,SurfaceView 相关源码并未直接开放出来,开发者只能看到自动生成的一个接口类,源码可以借助梯子在 这里 查阅。



LayoutInflater 布局填充阶段,按既有的布局填充流程,将 SurfaceView 构造并加入到视图树的某个结点;接下来,根布局会通过深度遍历依次执行 onAttachedToWindow() 处理视图挂载窗口的事件:


// /frameworks/base/core/java/android/view/SurfaceView.java
@Override
protected void onAttachedToWindow() {
// ...
mParent.requestTransparentRegion(SurfaceView.this); // 1.
ViewTreeObserver observer = getViewTreeObserver();
observer.addOnPreDrawListener(mDrawListener); // 2.
}

@UnsupportedAppUsage
private final ViewTreeObserver.OnPreDrawListener mDrawListener = new ViewTreeObserver.OnPreDrawListener() {
@Override
public boolean onPreDraw() {
updateSurface(); // 3.
return true;
}
};

protected void updateSurface() {
// ...
mSurfaceSession = new SurfaceSession();
mSurfaceControl = new SurfaceControl.Builder(mSurfaceSession); // 4
//...
}

步骤 1 中,SurfaceView 会向父视图依次向上请求创造一份透明区域,根视图统计到最终的信息后,通过 Binder 通知 WindowManagerService 将对应区域设置为透明。


步骤 2、3、4 是在同一个方法的调用栈中,由此可见,SurfaceView 向系统请求透明区域后,会立即创建一个与绘图表面的连接 SurfaceSession ,并创建一个对应的控制器 SurfaceControl,便于对这个独立的绘图表面进行直接通信。


由此可见,Android 自有的视图树体系中,SurfaceView 作为一个普通的 View 被挂载上去之后,通过 Binder 通信,WindowManagerService 将其所在区域设置为透明(挖洞);并建立了与独立绘图表面的连接,后续便可与其直接通信。


2. 子图层类型


在阐述绘制流程之前,读者需简单了解 子图层类型 的概念。


上文说到,SurfaceView 的绝大多数使用场景中,其 z 轴的位置通常是在页面的 最下方 。但在实际开发中,随着业务场景复杂度的上升,仍然有部分场景是无法被满足的,比如:在页面的最上方播放一条全屏的视频广告。


因此,SurfaceView 的设计中引入了一个 子图层类型 的概念,用于定义这个独立的 Surface 相比较当前页面窗口 (即Activity) 的位置:


// /frameworks/base/core/java/android/view/SurfaceView.java
public class SurfaceView extends View {

// SurfaceView 的子图层类型
int mSubLayer = APPLICATION_MEDIA_SUBLAYER;

// SurfaceView 是否展示在当前窗口的最上方
// 该方法在挖洞和绘制流程中都有使用,最终影响到用户的视觉效果
private boolean isAboveParent() {
return mSubLayer >= 0;
}
}

// /frameworks/base/core/java/android/view/WindowManagerPolicyConstants.java
public interface WindowManagerPolicyConstants {
// ...
int APPLICATION_MEDIA_SUBLAYER = -2;
int APPLICATION_MEDIA_OVERLAY_SUBLAYER = -1;
int APPLICATION_PANEL_SUBLAYER = 1;
int APPLICATION_SUB_PANEL_SUBLAYER = 2;
int APPLICATION_ABOVE_SUB_PANEL_SUBLAYER = 3;
// ...
}

如代码所示,mSubLayer 默认值为 -2,这表示 SurfaceView 默认总是在 Activity 的下方,想要让 SurfaceView 展示在 Activity 上方,可以调用 setZOrderOnTop(true) 以修改 mSubLayer 的值:


// /frameworks/base/core/java/android/view/SurfaceView.java
public class SurfaceView extends View {

public void setZOrderOnTop(boolean onTop) {
if (onTop) {
mSubLayer = APPLICATION_PANEL_SUBLAYER;
} else {
mSubLayer = APPLICATION_MEDIA_SUBLAYER;
}
}

public void setZOrderMediaOverlay(boolean isMediaOverlay) {
mSubLayer = isMediaOverlay ? APPLICATION_MEDIA_OVERLAY_SUBLAYER : APPLICATION_MEDIA_SUBLAYER;
}
}

现在,无论是将 SurfaceView 放在页面的上方还是下方,都轻而易举。


但这仍然无法满足所有诉求,比如针对具有 alpha 通道的透明视频进行渲染时,产品希望其所在的图层位置能够更灵活(在两个 View 之间),但由于 SurfaceView 自身设计的原因,其并无法与视图树融合,这也正是 SurfaceView 饱受诟病的主要原因之一。


通过辩证的观点来看, SurfaceView 的这种设计虽然满足不了严苛的业务诉求,但在绝大多数场景下,独立绘图表面 这种设计都能够保证足够的渲染性能,同时不影响主线程输入事件的处理,绝对是一个优秀的设计。


3.子图层类型-插曲


值得一提的是,在 SurfaceView 的设计中,设计者还考虑到了音视频渲染时,字幕相关业务的场景,因此额外提供了一个 setZOrderMediaOverlay() 方法:


// /frameworks/base/core/java/android/view/SurfaceView.java
public class SurfaceView extends View {
public void setZOrderMediaOverlay(boolean isMediaOverlay) {
mSubLayer = isMediaOverlay ? APPLICATION_MEDIA_OVERLAY_SUBLAYER : APPLICATION_MEDIA_SUBLAYER;
}
}

该方法的设计说明了2点:


首先,由于 APPLICATION_MEDIA_SUBLAYERAPPLICATION_MEDIA_OVERLAY_SUBLAYER 都小于0,因此,无论如何,字幕始终被渲染在页面的下方。又因为视频理应渲染在字幕的下方,所以 不推荐 开发者在使用 SurfaceView 渲染视频时调用 setZOrderOnTop(true),将视频放在页面视图的顶层。


其次,同时具有 setZOrderOnTop()setZOrderMediaOverlay() 方法,显然是提供给两个不同 SurfaceView 分别使用的,以定义不同的渲染层级,因此同一个页面存在多个 SurfaceView 是正常的,开发者完全可以根据业务场景,合理运用。


4. 令人头大的黑屏问题


在使用 SurfaceView 的过程中,笔者最终也遇到了 默认黑屏 的问题:


由于视频本身的加载和编解码的耗时,用户总是会先看到 SurfaceView 的黑色背景一闪而过,然后视频才开始播放的情况,对于产品而言,这种交互体验是 不可容忍 的。


通过上文读者知道,SurfaceView 拥有独立的绘制表面,因此常规对付 View 的一些手段——比如 setVisibility()setAlpha()setBackgroundColor() 并不能解决上述问题;因此,想真正解决它,就必须先弄清楚 SurfaceView 底层的绘制流程。


SurfaceView 虽然特殊,但其作为视图树的一个结点,其依然参与到了视图树常规绘制流程,这里我们直接看 SurfaceViewdraw() 方法:


// /frameworks/base/core/java/android/view/SurfaceView.java
public class SurfaceView extends View {

//...
@Override
public void draw(Canvas canvas) {
if (mDrawFinished && !isAboveParent()) { // 1.
if ((mPrivateFlags & PFLAG_SKIP_DRAW) == 0) {
clearSurfaceViewPort(canvas);
}
}
super.draw(canvas);
}

private void clearSurfaceViewPort(Canvas canvas) {
// ...
canvas.drawColor(0, PorterDuff.Mode.CLEAR); // 2.
}
}

由此可见,当满足 !isAboveParent() 的条件——即 SurfaceView 的子图层类型位于宿主视图的下方时,SurfaceView 默认会将绘图表面的颜色指定为黑色。


显然,该问题最简单的解决方式就是对源码进行hook或者反射,遗憾的是,上文我们也提到了,出于安全性的考量,SurfaceView 的源码是没有公开暴露的。


设计者其实也想到了这个问题,因此额外提供了一个 SurfaceHolderAPI 接口,通过该接口,开发者可以直接拿到独立绘图表面的 Canvas 对象,以及对这个画布进行绘制操作:


// /frameworks/base/core/java/android/view/SurfaceHolder.java
public interface SurfaceHolder {
// ...
public Canvas lockCanvas();

public void unlockCanvasAndPost(Canvas canvas);
//...
}

遗憾的是,即使拿到 Canvas,开发者仍然会受到限制:


// /frameworks/base/core/java/com/android/internal/view/BaseSurfaceHolder.java
public abstract class BaseSurfaceHolder implements SurfaceHolder {

private final Canvas internalLockCanvas(Rect dirty, boolean hardware) {
if (mType == SURFACE_TYPE_PUSH_BUFFERS) {
throw new BadSurfaceTypeException("Surface type is SURFACE_TYPE_PUSH_BUFFERS");
}
// ...
}
}

这里的代码,笔者引用 罗升阳这篇文章 中的一段来解释:



注意,只有在一个 SurfaceView 的绘图表面的类型不是 SURFACE_TYPE_PUSH_BUFFERS 的时候,我们才可以自由地在上面绘制 UI。我们使用 SurfaceView 来显示摄像头预览或者播放视频时,一般就是会将它的绘图表面的类型设置为 SURFACE_TYPE_PUSH_BUFFERS 。在这种情况下,SurfaceView 的绘图表面所使用的图形缓冲区是完全由摄像头服务或者视频播放服务来提供的,因此,我们就不可以随意地去访问该图形缓冲区,而是要由摄像头服务或者视频播放服务来访问,因为该图形缓冲区有可能是在专门的硬件里面分配的。



由此可见,SurfaceView 黑屏问题的原因是综合且复杂的,无论是通过 setZOrderOnTop() 等方法设置为背景透明(但是会在页面层级的最上方),亦或者调整布局参数,都会有大大小小的一些问题。


小结


综合来看,SurfaceView 这些饱受争议的问题,从设计的角度来看,都是有其自身考量的。


而为了解决这些问题,官方后续提供了 TextureView 以替换 SurfaceViewTextureView 的原理是和 View 一样绘制到当前 Activity 的窗口上,因此不存在 SurfaceView 的这些问题。


换个角度来看,由于 TextureView 渲染依赖于主线程,因此也会导致了新的问题出现。除了性能比较 SurfaceView 会有明显下降外,还会有经常掉帧的问题,有机会笔者会另起一篇进行分享。


参考 & 感谢



细心的读者应该能够发现,关于 参考&感谢 一节,笔者着墨越来越多,原因无他,笔者 从不认为 一篇文章就能够讲一个知识体系讲解的面面俱到,本文亦如是。


因此,读者应该有选择性查看其它优质内容的权利,甚至是为其增加一些简洁的介绍(因为标题大多都很相似),而不是文章末尾甩一堆 https 开头的链接不知所云。


这也是对这些内容创作者的尊重,如果你喜欢本文,也同样希望你能够喜欢下面这些文章。



1. Android源码-frameworks-SurfaceView


阅读源码永远是学习最有效的方式,如果你想更进一步深入了解 SurfaceView,选它就对了。


2. Android官方文档-图形架构


遗憾的是,在笔者学习的过程中,官方文档并未给予到很大的帮助,相当一部分原因是因为文档中的内容太 规范 了,保持内容 精炼准确 的同时,也增加了读者的理解成本。


但无论如何,作为权威的官方文档,仍适合作为复习资料,反复阅读。


3. Android视图SurfaceView的实现原理分析 @罗升阳


神作, 我认为它是 最适合 进阶学习和研究 SurfaceView 源码的文章。


4. Android 5.0(Lollipop)中的SurfaceTexture,TextureView, SurfaceView和GLSurfaceView @ariesjzj


在笔者摸索学习,困惑于标题中这些概念的阶段,本文以浅显易懂的方式对它们进行了简单的总结,推荐。


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

超有用的Android开发技巧:拦截界面View创建

LayoutInflater.Factory2是个啥? Activity内界面的创建是由LayoutInflater负责,LayoutInflater最终会交给内部的一个类型为LayoutInflater.Factory2的factory2成员变量进行创建。 ...
继续阅读 »

LayoutInflater.Factory2是个啥?


Activity内界面的创建是由LayoutInflater负责,LayoutInflater最终会交给内部的一个类型为LayoutInflater.Factory2factory2成员变量进行创建。


这个属性值可以外部自定义传入,默认的实现类为AppCompatDelegateImpl


image.png


然后在AppCompatActivity的初始化构造方法中向LayoutInflater注入AppCompatDelegateImpl:


image.png


image.png


image.png


常见的ImageViewTextView被替换成AppcompatImageViewAppCompatTextView等就是借助AppCompatDelegateImpl进行实现的。


这里有个实现的小细节,在initDelegate()方法中,调用了addOnContextAvailableListener()方法传入一个监听事件实现的factory2注入,这个addOnContextAvailableListener()方法有什么魅力呢?


addOnContextAvailableListener()是干啥用的?


咱们先看下这个方法是干啥用的:


image.png


image.png


最终是将这个监听对象加入到了ContextAwareHelper类的内部mListeners集合中,咱们接下里看下这个监听对象集合最终是在哪里被调用的。


image.png


image.png


可以看到,这个集合最终在ComponetActivityonCreate()方法中调用,请注意,这个调用时机还是在父类的super.onCreate()方法前进行调用的。


所以我们可以得出结论,addOnContextAvailableListener()添加的监听器将在父类onCreate()方法前进行调用。


这个用处的场景还是比较多的,比如我们设置Activity的主题就必须在父类的onCreate()方法前调用,借助这个监听,可以轻松实现。


代码实战



请注意,这个factory2的设置必须在ActivityonCreate()方法前调用,所以我们可以直接借助addOnContextAvailableListener()进行实现,也可以重写onCreate()方法在指定位置实现。当然了,前者更加的灵活,这里我们还是以后者进行举例。



override fun onCreate(savedInstanceState: Bundle?) {
LayoutInflaterCompat.setFactory2(layoutInflater, object : LayoutInflater.Factory2 {
override fun onCreateView(
parent: View?,
name: String,
context: Context,
attrs: AttributeSet
): View? {

return if (name == "要替换的系统View名称") {
CustumeView()
} else delegate.createView(parent, name, context, attrs)
}
})
}

请注意,这里也有一个实现的小细节,如果当某个系统View不属于我们要替换的View,请继续委托给AppCompatDelegateImpl进行处理,这样就保证了实现系统组件特有功能的前提下,又能完成我们的View替换工作。


统一所有界面View的替换工作


如果要替换View的界面非常多,一个Activity一个Activity替换过去太麻烦 ,这个时候就可以使用我们经常使用到的ApplicationregisterActivityLifecycleCallbacks()监听所有Activity的创建流程,其中我们用到的方法就是onActivityPreCreated():


registerActivityLifecycleCallbacks(object : Application.ActivityLifecycleCallbacks {
override fun onActivityPreCreated(activity: Activity, savedInstanceState: Bundle?) {
LayoutInflaterCompat.setFactory2(activity.layoutInflater, object : LayoutInflater.Factory2 {
override fun onCreateView(
parent: View?,
name: String,
context: Context,
attrs: AttributeSet
): View? {

return if (name == "要替换的系统View名称") {
CustumeView()
} else (activity as? AppCompatActivity)?.delegate?.createView(parent, name, context, attrs) ?: null
}

override fun onCreateView(name: String, context: Context, attrs: AttributeSet): View? {
TODO("Not yet implemented")
}
})

}
}

不过这个Application.ActivityLifecycleCallbacks接口要重写好多无用的方法,太麻烦了,之前写过一篇关于接口优化相关的文章吗,详情可以参考:接口使用额外重写的无关方法太多?优化它


总结


之前看过很多换肤、埋点统计上报等相关文章,多多少少都介绍了向AppCompatActivity中注入factory2拦截系统View创建的思想,我们设置还可以借助此实现界面黑白化的效果,非常的好用,每个开发者都应该去了解掌握的知识点。


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

synchronized实现原理

synchronized作为java语言中的并发关键词,其在代码中出现的频率相当高频,大多数开发者在涉及到并发场景时,一般都会下意识得选取synchronized。 synchronized在代码中主要有三类用法,根据其用法不同,所获取的锁对象也不同,如下所示...
继续阅读 »

synchronized作为java语言中的并发关键词,其在代码中出现的频率相当高频,大多数开发者在涉及到并发场景时,一般都会下意识得选取synchronized。


synchronized在代码中主要有三类用法,根据其用法不同,所获取的锁对象也不同,如下所示:



  • 修饰代码块:这种用法通常叫做同步代码块,获取的锁对象是在synchronized中显式指定的

  • 修饰实例方法:这种用法通常叫做同步方法,获取的锁对象是当前的类对象

  • 修饰静态方法:这种用法通常叫做静态同步方法,获取的锁对象是当前类的类对象


下面我们一起来测试下三种方式下,对象锁的归属及锁升级过程,SynchronizedTestClass类代码如下:


 import org.openjdk.jol.info.ClassLayout;
 
 public class SynchronizedTestClass {
     private Object mLock = new Object();
     public void testSynchronizedBlock(){
         System.out.println("before get Lock in thread:"+Thread.currentThread().getName()+">>>"+ ClassLayout.parseInstance(mLock).toPrintable());
         synchronized (mLock) {
             System.out.println("testSynchronizedBlock start:"+Thread.currentThread().getName());
             System.out.println("after get Lock in thread:"+Thread.currentThread().getName()+">>>"+ ClassLayout.parseInstance(mLock).toPrintable());
             try {
                 Thread.sleep(10000);
            } catch (InterruptedException e) {
                 throw new RuntimeException(e);
            }
             System.out.println("testSynchronizedBlock end:"+Thread.currentThread().getName());
        }
    }
 
     public synchronized void testSynchronizedMethod() {
         System.out.println("after get Lock in thread:"+Thread.currentThread().getName()+">>>"+ ClassLayout.parseInstance(this).toPrintable());
         System.out.println("testSynchronizedMethod start:"+Thread.currentThread().getName());
         try {
             Thread.sleep(10000);
        } catch (InterruptedException e) {
             throw new RuntimeException(e);
        }
         System.out.println("testSynchronizedMethod end:"+Thread.currentThread().getName());
    }
 
     public static synchronized void testSynchronizedStaticMethod() {
         System.out.println("after get Lock in thread:"+Thread.currentThread().getName()+">>>"+ ClassLayout.parseInstance(SynchronizedTestClass.class).toPrintable());
         System.out.println("testSynchronizedStaticMethod start:"+Thread.currentThread().getName());
         try {
             Thread.sleep(10000);
        } catch (InterruptedException e) {
             throw new RuntimeException(e);
        }
         System.out.println("testSynchronizedStaticMethod end:"+Thread.currentThread().getName());
    }
 }

同步代码块


在main函数编写如下代码,调用SynchronizedTestClass类中包含同步代码块的测试方法,如下所示:


 public static void main(String[] args) {
     SynchronizedTestClass synchronizedTestClass = new SynchronizedTestClass();
     ExecutorService testSynchronizedBlock = Executors.newCachedThreadPool();
     testSynchronizedBlock.execute(new Runnable() {
         @Override
         public void run() {
             synchronizedTestClass.testSynchronizedBlock();
        }
    });
     testSynchronizedBlock.execute(new Runnable() {
         @Override
         public void run() {
             synchronizedTestClass.testSynchronizedBlock();
        }
    });
 }

运行结果如下:


1-4-10-1


从上图可以看出在线程2获取锁前,mLock处于无锁状态,等线程2获取锁后,mLock对象升级为轻量级锁,等线程1获取锁后升级为重量级锁,有同学要问了,你在多线程与锁中不是说了synchronized锁升级有四个吗?你是不是写BUG了,当然没有啊,现在我们来看看偏向锁去哪儿了?


偏向锁


对于不同版本的JDK而言,其针对偏向锁的开关和配置均有所不同,我们可以通过执行java -XX:+PrintFlagsFinal -version | grep BiasedLocking来获取偏向锁相关配置,执行命令输出如下:


1-4-10-2


从上图可以看出在JDK 1.8上,偏向锁默认开启,具有4秒延时,那么我们修改main内容,延时5秒开始执行,看看现象如何,代码如下:


 public static void main(String[] args) {
     try {
         Thread.sleep(5000);
    } catch (InterruptedException e) {
         throw new RuntimeException(e);
    }
     SynchronizedTestClass synchronizedTestClass = new SynchronizedTestClass();
 
     ExecutorService testSynchronizedBlock = Executors.newCachedThreadPool();
     testSynchronizedBlock.execute(new Runnable() {
         @Override
         public void run() {
             synchronizedTestClass.testSynchronizedBlock();
        }
    });
 
     testSynchronizedBlock.execute(new Runnable() {
         @Override
         public void run() {
             synchronizedTestClass.testSynchronizedBlock();
        }
    });
 }

输出如下:


1-4-10-3


从上图可以看出在延迟5s执行后,mLock锁变成了无锁可偏向状态,结合上面两个示例,我们可以看出,在轻量级锁和偏向锁阶段均有可能直接升级成重量级锁,是否升级依赖于当时的锁竞争关系,据此我们可以得到synchronized锁升级的常见过程,如下图所示:


synchronized


可以看出,我们遇到的两种情况分别对应升级路线1和升级路线4。


同步方法


使用线程池调用SynchronizedTestClass类中的同步方法,代码如下:


 public static void main(String[] args) {
     SynchronizedTestClass synchronizedTestClass = new SynchronizedTestClass();
 
     ExecutorService testSynchronizedBlock = Executors.newCachedThreadPool();
     testSynchronizedBlock.execute(new Runnable() {
         @Override
         public void run() {
             synchronizedTestClass.testSynchronizedMethod();
        }
    });
 
     testSynchronizedBlock.execute(new Runnable() {
         @Override
         public void run() {
             synchronizedTestClass.testSynchronizedMethod();
        }
    });
 }

运行结果如下:


1-4-10-4


可以看出,在调用同步方法时,直接升级为重量级锁,同一时刻,有且仅有一个线程在同步方法中执行,其他函数在同步方法入口处阻塞等待。


静态同步方法


使用线程池调用SynchronizedTestClass类中的静态同步方法,代码如下


     public static void main(String[] args) {
         ExecutorService testSynchronizedBlock = Executors.newCachedThreadPool();
         testSynchronizedBlock.execute(new Runnable() {
             @Override
             public void run() {
                 SynchronizedTestClass.testSynchronizedStaticMethod();
            }
        });
         testSynchronizedBlock.execute(new Runnable() {
             @Override
             public void run() {
                 SynchronizedTestClass.testSynchronizedStaticMethod();
            }
        });
    }

运行结果如下:


1-4-10-5


可以看出,在调用静态同步方法时,直接升级为重量级锁,同一时刻,有且仅有一个线程在静态同步方法中执行,其他函数在同步方法入口处阻塞等待。


前面我们看的是多个线程竞争同一个锁对象,那么假设我们有三个线程分别执行这三个函数,又会怎样呢?代码如下:


 public static void main(String[] args) {
     SynchronizedTestClass synchronizedTestClass = new SynchronizedTestClass();
 
     ExecutorService testSynchronizedBlock = Executors.newCachedThreadPool();
     testSynchronizedBlock.execute(new Runnable() {
         @Override
         public void run() {
             SynchronizedTestClass.testSynchronizedStaticMethod();
        }
    });
     testSynchronizedBlock.execute(new Runnable() {
         @Override
         public void run() {
             synchronizedTestClass.testSynchronizedMethod();
        }
    });
     testSynchronizedBlock.execute(new Runnable() {
         @Override
         public void run() {
             synchronizedTestClass.testSynchronizedBlock();
        }
    });
 }

运行结果:


1-4-10-10


可以看到,3个线程各自运行,互不影响,这也进一步印证了前文所说的锁对象以及MarkWord中标记锁状态的概念。


synchronized实现原理


上面已经学习了synchronized的常见用法,关联的锁对象以及锁升级的过程,接下来我们来看下synchronized实现原理,仍然以上面的SynchronizedTestClass为例,查看其生成的字节码来了解synchronized关键字的实现。


同步代码块


testSynchronizedBlock其所对应的字节码如下图所示:


1-4-10-6


从上图代码和字节码对应关系可以看出,在同步代码块中获取锁时使用monitorenter指令,释放锁时使用monitorexit指令,且会有两个monitorexit,确保在当前线程异常时,锁正常释放,避免其他线程等待死锁。


所以synchronized的同步机制是依赖monitorenter和monitorexit指令实现的,而这两个指令操作的就是mLock对象的monitor锁,monitorenter尝试获取mLock的monitor锁,如果获取成功,则monitor中的计数器+1,同时记录相关线程信息,如果获取失败,则当前线程阻塞。



Monitor锁就是存储在MarkWord中的指向重量级锁的指针所指向的对象,每个对象在构造时都会创建一个Monitor锁,用于监视当前对象的锁状态以及持锁线程信息,



同步方法


testSynchronizedMethod其所对应的字节码如下图所示:


1-4-10-7


可以看到同步方法依赖在函数声明时添加ACC_SYNCHRONIZED标记实现,在函数被ACC_SYNCHRONIZED修饰时,调用该函数会申请对象的Monitor锁,申请成功则进入函数,申请失败则阻塞当前线程。


静态同步方法


testSynchronizedStaticMethod其所对应的字节码如下图所示:


1-4-10-8


和同步方法相同,同步静态方法也是在函数声明部分添加了ACC_SYNCHRONIZED标记,也同步方法不同的是,此时申请的是该类的类对象的Monitor锁。




扩展


上文中针对synchronized的java使用以及字节码做了说明,我们可以看出synchronized是依赖显式的monitorenter,monitorexit指令和ACC_SYNCHRONIZED实现,但是字节码并不是最靠近机器的一层,相对字节码,汇编又是怎么处理synchronized相关的字节码指令的呢?


我们可以通过获取java代码的汇编代码来查看,查看Java类的汇编代码需要依赖hsdis工具,该工具可以从chriswhocodes.com/hsdis/下载(科学上网),下载完成后,在Intellij Idea中配置Main类的编译参数如下图所示:


1-4-10-11


其中vm options详细参数如下:


-XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -Xcomp -XX:CompileCommand=compileonly,*SynchronizedTestClass.testSynchronizedBlock -XX:CompileCommand=compileonly,*SynchronizedTestClass.testSynchronizedMethod -XX:+LogCompilation -XX:LogFile=/Volumes/Storage/hotspot.log


其中“compileOnly,”后面跟的是你要抓取的函数名称,格式为:*类名.函数名,LogFile=后指向的是存储汇编代码的文件。


环境变量配置如下:


LIBRARY_PATH=/Volumes/Storage/hsdis


这里的写法是:hsdis存储路径+/hsdis


随后再次运行Main.main即可看到相关汇编代码输出在运行窗口,通过分析运行窗口输出的内容,我们可以看到如下截图:


1-4-10-9


可以看出在运行时调用SynchronizedTestClass::testSynchronizedMethod时,进入synchronized需要执行lock cmpxchg以确保多线程安全,故synchronized的汇编实现为lock cmpxchg指令。


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

花里胡哨的文字特效,你学会了吗?

前言 我们的 App 大部分时候的文字都是一种颜色,实际上,文字的颜色也可以多姿多彩。我们今天就来介绍一个能够轻松实现文字渐变色的组件 —— ShaderMask。ShaderMask 能够构建一个着色器(shader),然后覆盖(mask)到它的子组件上,从...
继续阅读 »

前言


我们的 App 大部分时候的文字都是一种颜色,实际上,文字的颜色也可以多姿多彩。我们今天就来介绍一个能够轻松实现文字渐变色的组件 —— ShaderMaskShaderMask 能够构建一个着色器(shader),然后覆盖(mask)到它的子组件上,从而改变子组件的颜色。


ShaderMask 实现渐变色文字


ShaderMask 的构造函数定义如下。


const ShaderMask({
Key? key,
required this.shaderCallback,
this.blendMode = BlendMode.modulate,
Widget? child,
})

其中关键的参数是 shaderCallback回调方法,通过 回调方法可以构建一个着色器来为子组件着色,典型的做法是使用 Gradient 的子类(如 LinearGradientRadialGradial)来创建着色器。blendMode 参数则用于设置着色的方式。
因此,我们可以利用LinearGradient来实现渐变色文字,示例代码如下,其中 blendMode 选择为 BlendMode.srcIn 是忽略子组件原有的颜色,使用着色器来对子组件着色。


ShaderMask(
shaderCallback: (rect) {
return LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [
Colors.blue,
Colors.green[300]!,
Colors.orange[400]!,
Colors.red,
],
).createShader(rect);
},
blendMode: BlendMode.srcIn,
child: const Text(
'岛上码农',
style: TextStyle(
fontSize: 36.0,
fontWeight: FontWeight.bold,
),
),
),

实现效果如下图。


image.png
实际上,不仅仅能够对文字着色,还可以对图片着色,比如我们使用一个 Row 组件在文字前面增加一个Image 组件,可以实现下面的效果。


image.png


让渐变色动起来


静态的渐变色着色还不够,Gradient 还有个 transform 来实现三维空间变换的渐变效果,我们可以利用这个参数和动画组件实现动画效果,比如下面这样。


渐变动画.gif
这里其实就是使用了动画控制 transform 实现横向平移。由于 transform 是一个 GradientTransform 类,实现这样的效果需要定义一个GradientTransform子类,如下所示。


@immutable
class SweepTransform extends GradientTransform {
const SweepTransform(this.dx, this.dy);

final double dx;
final double dy;

@override
Matrix4 transform(Rect bounds, {TextDirection? textDirection}) {
return Matrix4.identity()..translate(dx, dy);
}

@override
bool operator ==(Object other) {
if (identical(this, other)) {
return true;
}
if (other.runtimeType != runtimeType) {
return false;
}
return other is SweepTransform && other.dx == dx && other.dy == dy;
}

@override
int get hashCode => dx.hashCode & dy.hashCode;
}

然后通过 Animation 动画对象的值控制渐变色平移的距离就可以实现渐变色横向扫过的效果了,代码如下所示。


ShaderMask(
shaderCallback: (rect) {
return LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [
Colors.blue,
Colors.green[300]!,
Colors.orange[400]!,
Colors.red,
],
transform: SweepTransform(
(_animation.value - 0.5) * rect.width, 0.0),
).createShader(rect);
},
blendMode: BlendMode.srcIn,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Image.asset(
'images/logo.png',
scale: 2,
),
const Text(
'岛上码农',
style: TextStyle(
fontSize: 36.0,
fontWeight: FontWeight.bold,
),
),
],
),
),

图片填充


除了使用渐变色之外,我们还可以利用 ImageShader 使用图片填充文字,实现一些其他的文字特效,比如用火焰图片作为背景,让文字看起来像燃烧了一样。


图片背景填充.gif


实现的代码如下,其中动效是通过 ImageShader 的构造函数的第4个参数的矩阵matrix4运算实现的,相当于是让填充图片移动来实现火焰往上升的效果。


ShaderMask(
shaderCallback: (rect) {

return ImageShader(
fillImage,
TileMode.decal,
TileMode.decal,
(Matrix4.identity()
..translate(-20.0 * _animation.value,
-150.0 * _animation.value))
.storage);
},
blendMode: BlendMode.srcIn,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Image.asset(
'images/logo.png',
scale: 2,
),
const Text(
'岛上码农',
style: TextStyle(
fontSize: 36.0,
fontWeight: FontWeight.bold,
),
),
],
),
)

总结


本篇介绍了 ShaderMask 组件的应用,通过 ShaderMask 组件我们可以对子组件进行着色,从而改变子组件原来的颜色,实现如渐变色填充、图片填充等效果。本篇完整源码已提交至:实用组件相关源码



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

从阅读仿真页看贝塞尔曲线

前言 一直觉得阅读器里面的仿真页很有意思,最近在看阅读器相关代码的时候发现仿真页是基于贝塞尔曲线去实现的,所以就有了此篇文章。 仿真页一般有两种实现方式: 将内容绘制在Bitmap上,基于Canvas去处理仿真页 OpenGl es 本篇文章我会向大家介绍...
继续阅读 »

前言


一直觉得阅读器里面的仿真页很有意思,最近在看阅读器相关代码的时候发现仿真页是基于贝塞尔曲线去实现的,所以就有了此篇文章。


仿真页一般有两种实现方式:



  1. 将内容绘制在Bitmap上,基于Canvas去处理仿真页

  2. OpenGl es


本篇文章我会向大家介绍如何使用Canvas绘制贝塞尔曲线,以及详细的像大家介绍仿真页的实现思路。


后续有机会的话,希望可以再向大家介绍方案二(OpenGL es 学习中...)。


一、贝塞尔曲线介绍


贝塞尔曲线是应用于二维图形应用程序的数学曲线,最初是用在汽车设计的。我们在绘图工具上也常常见到曲线,比如钢笔工具。


为了绘制出更加平滑的曲线,在 Android 中我们也可以使用 Path 去绘制贝塞尔曲线,比如这类曲线图或者描述声波的图:


dribbble-bezier-graphs


我们先简单的了解一下基础知识,可以在这个网站先体验一把如何控制贝塞尔曲线:



http://www.jasondavies.com/animated-be…



一阶到四阶都有。


1. 一阶贝塞尔曲线


给定点 P0 和 P1,一阶贝塞尔曲线是两点之间的直线,这条线的公式如下:


image-20221203172517917


图片表示如下:


一阶贝塞尔动画


2. 二阶贝塞尔曲线


从二阶开始,就变得复杂起来,对于给定的 P0、P1 和 P2,都对应的曲线:


二阶贝塞尔曲线


图片表示如下:


贝塞尔二阶动画


二阶的公式是如何得出来的?我们可以假设 P0 到 P1 点是 P3,P1 - P2 的点是P4,二阶贝塞尔也只是 P3 - P4 之间的动态点,则有:



P3 = (1-t) P0 + tP1


P4 = (1-t) P1 + tP2


二阶贝塞尔曲线 B(t) = (1-t)P3 + tP4 = (1-t)((1-t)P0 + tP1) + t((1-t)P1 + tP2) = (1-t)(1-t)P0 + 2t(1-t)P1 + ttP2



与最终的公式对应。


3. 三阶贝塞尔曲线


三阶贝塞尔曲线由四个点控制,对于给定的 P0、P1、P2 和 P3,有对应的曲线:


三阶公式


对应的图片:


三阶动画


同样的,三阶贝塞尔可以由二阶贝塞尔得出,从上面的知识我们可以得处,下图中的点 R0 和 R1 的路径其实是二阶的贝塞尔曲线:


三阶计算图片


对于给定的点 B,有如下的公式,将二阶贝塞尔曲线带入:



R0 = (1-t)(1-t)P0 + 2t(1-t)P1 + ttP2


R1 = (1-t)(1-t)P1 + 2t(1-t)P2 + ttP3


B(t) = (1-t)R0 + tR1 = (1-t)((1-t)(1-t)P0 + 2t(1-t)P1 + ttP2) + t((1-t)(1-t)P1 + 2t(1-t)P2 + ttP3)



最终的结果就是三阶贝塞尔曲线的最终公式。


4. 多阶贝塞尔曲线


多阶贝塞尔曲线我们就不细讲了,可以知道的是,每一阶都可以由它的上一阶贝塞尔曲线推导而出。就像我们之前由一阶推导二阶,由二阶推导出三阶。


二、Android对应的API


Android提供了 Path 供我们去绘制贝塞尔曲线。一阶贝塞尔是一条直线,所以不用处理了。


看一下 Path 对应的 API:



  • Path#quadTo(float x1, float y1, float x2, float y2):二阶

  • Path#cubicTo(float x1, float y1, float x2, float y2,float x3, float y3):三阶


对于一段贝塞尔曲线来说,由三部分组成:



  1. 一个开始点

  2. 一到多个控制点

  3. 一个结束点


使用的方法也很简单,先挪到开始点,然后将控制点和结束点统统加进来:


class BezierView @JvmOverloads constructor(
   context: Context,
   attributeSet: AttributeSet? = null,
   defStyle: Int = 0
) : View(context, attributeSet, defStyle) {

   private val path = Path()
   private val paint = Paint()

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

       paint.style = Paint.Style.STROKE
       paint.strokeWidth = 3f
       
       path.moveTo(0f, 200f)
       path.quadTo(200f, 0f, 400f, 200f)
       paint.color = Color.BLUE
       canvas?.drawPath(path, paint)

       path.rewind()
       path.moveTo(0f, 600f)
       path.cubicTo(100f, 400f, 200f, 800f, 300f, 600f)
       paint.color = Color.RED
       canvas?.drawPath(path, paint);
  }
}

最后的结果:


WechatIMG132


上面是二阶贝塞尔,下面是三阶贝塞尔,可以发现,控制点越多,就能设计出越复杂的曲线。如果想使用二阶贝塞尔实现三阶的效果,就得使用两个二阶贝塞尔曲线。


三、简单案例


既然刚刚画了两个曲线,我们可以利用这个方式简单模拟一个动态声波的曲线,像这样:


Screenshot_2022_1204_173610


这个动画只需要在刚刚的代码的基础上稍微改动一点:


class BezierView @JvmOverloads constructor(
   context: Context,
   attributeSet: AttributeSet? = null,
   defStyle: Int = 0
) : View(context, attributeSet, defStyle) {

   private val path = Path()
   private val paint = Paint()

   private var width = 0f
   private var height = 0f
   private var quadY = 0f
   private var cubicY = 0f

   private var per = 1.0f
   private var quadHeight = 100f
   private var cubicHeight = 200f

   private var bezierAnim: ValueAnimator? = null

   init {
       paint.style = Paint.Style.STROKE
       paint.strokeWidth = 3f
       paint.isDither = true
       paint.isAntiAlias = true
  }

   override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
       super.onSizeChanged(w, h, oldw, oldh)

       width = w.toFloat()
       height = h.toFloat()

       quadY = height / 4
       cubicY = height - height / 4
  }


   fun startBezierAnim() {
       bezierAnim?.cancel()
       bezierAnim = ValueAnimator.ofFloat(1.0f, 0f, 1.0f).apply {
           addUpdateListener {
               val value = it.animatedValue as Float
               per = value
               invalidate()
          }
           addListener(object :AnimatorListener{
               override fun onAnimationStart(animation: Animator?) {

              }

               override fun onAnimationEnd(animation: Animator?) {

              }

               override fun onAnimationCancel(animation: Animator?) {

              }

               override fun onAnimationRepeat(animation: Animator?) {
                   val random = Random(System.currentTimeMillis())
                   val one = random.nextInt(400).toFloat()
                   val two = random.nextInt(800).toFloat()

                   quadHeight = one
                   cubicHeight = two
              }

          })
           duration = 300
           repeatCount = -1
           start()
      }
  }


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

       var quadStart = 0f
       path.reset()
       path.moveTo(quadStart, quadY)
       while (quadStart <= width){
           path.quadTo(quadStart + 75f, quadY - quadHeight * per, quadStart + 150f, quadY)
           path.quadTo(quadStart + 225f, quadY + quadHeight * per, quadStart + 300f, quadY)
           quadStart += 300f
      }
       paint.color = Color.BLUE
       canvas?.drawPath(path, paint)

       path.reset()
       var cubicStart = 0f
       path.moveTo(cubicStart, cubicY)
       while (cubicStart <= width){
           path.cubicTo(cubicStart + 100f, cubicY - cubicHeight * per, cubicStart + 200f, cubicY + cubicHeight * per, cubicStart + 300f, cubicY)
           cubicStart += 300f
      }
       paint.color = Color.RED
       canvas?.drawPath(path, paint);
  }
}

上面基于二阶贝塞尔曲线,下面基于三阶贝塞尔曲线,加了一层属性动画。


四、仿真页的拆分


我们在本篇文章不会涉及到仿真页的代码,主要做一下仿真页的拆分。


下面的这套方案也是总结自何明桂大佬的方案。


Android图形架构


从图中的仿真页中我们可以看出,上下一共两页,我们需要处理:



  1. 第一页的内容

  2. 第一页的背面

  3. 第二页露出来的内容


这三部分中,除了 GE 和 FH 是两段曲线,其他都是直线,直线是比较好计算的,先看两段曲线。


通过观察发现,这里的 GE 和 FH 都是对称的,只有一个平滑的弯,用一个控制点就能应付,所以选择二阶贝塞尔曲线就够了。GE 这段二阶段贝塞尔曲线,对应的控制点是 C,FH 对应的控制点是 D。


1. 第一页正面


再看图片,路径 A - F - H - B - G - E - A 之外的就是第一页正面,将内容页和这个路径的 Path 取反即可。


具体的过程:



  1. 已知 A 是触摸点,B 是内容页的底角点,可以求出中点 M 的坐标

  2. AB 和 CD 相互垂直,所以可得 CD 的斜率,从 M 点坐标推出 CD 两点坐标

  3. E 是 AC 中点,F 是 AD 中点,那么 E 和 F 的点位置很容易推导出来


2. 第二页内容


第二页的重点 KLB 这个三角形,M 是 AB 的中点,J 是 AM 的中点,N 是 JM 的重点,通过斜率很容易推导出与边界相交的KL 两点,之后从内容页上裁出 KLB 这个Path,第二页的内容绘制在这个 Path 即可。


3. 第一页的背面


背面这一块儿绘制的区域是三角形 AOP,AC、AD 和 KL 都已知,求出相交的 KL 点即可。


但是我们还得将第一页底部的内容做一个旋转和偏移,再加上一层蒙层,就可以得到我们想要的背面内容。


总结


可以看出,学会了贝塞尔曲线以后,仿真页其实并不算特别复杂,但是整个数学计算还是很麻烦的。


让人头秃


下篇文章再和大家讨论具体的代码,如果觉得本文有什么问题,评论区见!


参考文章:



blog.csdn.net/hmg25


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

什么?还在傻傻地手写Parcelable实现?

什么?还在傻傻地手写Parcelable实现? 缘起 序列化已经是Android司空见惯的东西了,场景太多了。就拿Intent来说吧,extra能放的数据,除了基本类型外,就是序列化的数据了,有两种: Serializable:Java世界自带的序列化工具,...
继续阅读 »

什么?还在傻傻地手写Parcelable实现?


缘起


序列化已经是Android司空见惯的东西了,场景太多了。就拿Intent来说吧,extra能放的数据,除了基本类型外,就是序列化的数据了,有两种:



  • Serializable:Java世界自带的序列化工具,大道至简,是一个无方法接口

  • Parcelable:Android的官配序列化工具


这二者在性能、用法乃至适用场景上均有不同,网上的讨论已经很多了,这里不再赘述。


下面来看看官配正品怎么用的。


Android的Parcelable


首先看看官方示例:


public class MyParcelable implements Parcelable {
private int mData;

public int describeContents() {
return 0;
}

public void writeToParcel(Parcel out, int flags) {
out.writeInt(mData);
}

public static final Parcelable.Creator<MyParcelable> CREATOR
= new Parcelable.Creator<MyParcelable>() {
public MyParcelable createFromParcel(Parcel in) {
return new MyParcelable(in);
}

public MyParcelable[] newArray(int size) {
return new MyParcelable[size];
}
};

private MyParcelable(Parcel in) {
mData = in.readInt();
}
}

可以总结,实现Parcelable的数据类,有两个要点:



  1. 必须有一个 非空的、静态的且名为"CREATOR" 的对象,该对象实现 Parcelable.Creator 接口

  2. 实现方法 describeContents ,描述内容;
    实现方法 writeToParcel ,将类数据打入parcel内


示例中,实际的数据只有一个简单的整型。


实验:Intent中的Parcelable传递


这里通过一个案例来说明一下Parcelable的使用。


首先,定义一个数据类User,它包含一个String和一个Int:


class User() : Parcelable {

var name: String? = ""
var updatedTime: Long = 0L

constructor(parcel: Parcel) : this() {
name = parcel.readString()
updatedTime = parcel.readLong()
}

constructor(name: String?, time: Long) : this() {
this.name = name
updatedTime = time
}

override fun writeToParcel(parcel: Parcel, flags: Int) {
Log.d("p-test", "write to")
parcel.writeString(name)
parcel.writeLong(updatedTime)
}

override fun describeContents(): Int {
return 0
}

companion object CREATOR : Parcelable.Creator<User> {
override fun createFromParcel(parcel: Parcel): User {
Log.d("p-test", "createFromParcel")
return User(parcel)
}

override fun newArray(size: Int): Array<User?> {
return arrayOfNulls(size)
}
}

override fun toString(): String = "$name - [${
DateFormat.getInstance().format(Date(updatedTime))
}]"

}

启动方带上User数据:


Log.d("p-test", "navigate to receiver")
context.startActivity(Intent(context, ReceiverActivity::class.java).apply {
putExtra("user", User("Dale", System.currentTimeMillis())) // 调用Intent.putExtra(String name, @Nullable Parcelable value)
})

接收方读取并显示User数据:


Log.d("p-test", "onCreate")
val desc: User? = intent?.getParcelableExtra("user")
// 省略展示:desc?.toString()

来看看日志:


2022-05-18 11:45:28.280 26148-26148 p-test  com.jacee.example.parcelabletest     D  navigate to receiver
2022-05-18 11:45:28.282 26148-26148 p-test com.jacee.example.parcelabletest D write to
2022-05-18 11:45:28.342 26148-26148 p-test com.jacee.example.parcelabletest D onCreate
2022-05-18 11:45:28.343 26148-26148 p-test com.jacee.example.parcelabletest D createFromParcel

其过程为:



  1. 启动

  2. User类调用writeToParcel,将数据写入Parcel

  3. 接收

  4. CREATOR调用createFromParcel,从Parcel中读取数据,并构造相应的User数据类对象


界面上,User正确展示:


image.png


由此,Parcelable的数据类算是正确实现了。


看起来,虽然没有很难,但是,是真心有点儿烦啊,尤其是相较于Java的Serializable来说。有没有简化之法呢?当然有啊,要知道,现在可是Kotlin时代了!


kotlin-parcelize插件


隆重介绍kotlin-parcelize插件:它提供了一个 Parcelable 的实现生成器。有了此生成器,就不必再写如前的复杂代码了。


怎么使用呢?


首先,需要在gradle里面添加此插件:


plugins {
id 'kotlin-parcelize'
}

然后,在需要 Parcelable 的数据类上添加 @kotlinx.parcelize.Parcelize 注解就行了。


来吧,改造前面的例子:


import kotlinx.parcelize.Parcelize

@Parcelize
data class User(
val name: String?,
val updatedTime: Long
): Parcelable {
override fun toString(): String = "new: $name - [${
DateFormat.getInstance().format(Date(updatedTime))
}]"
}

哇,简化如斯,真能实现?还是来看看上述代码对应的字节码吧:


@Metadata(
mv = {1, 6, 0},
k = 1,
d1 = {"\u0000:\n\u0002\u0018\u0002\n\u0002\u0018\u0002\n\u0000\n\u0002\u0010\u000e\n\u0000\n\u0002\u0010\t\n\u0002\b\t\n\u0002\u0010\b\n\u0000\n\u0002\u0010\u000b\n\u0000\n\u0002\u0010\u0000\n\u0002\b\u0003\n\u0002\u0010\u0002\n\u0000\n\u0002\u0018\u0002\n\u0002\b\u0002\b\u0087\b\u0018\u00002\u00020\u0001B\u0017\u0012\b\u0010\u0002\u001a\u0004\u0018\u00010\u0003\u0012\u0006\u0010\u0004\u001a\u00020\u0005¢\u0006\u0002\u0010\u0006J\u000b\u0010\u000b\u001a\u0004\u0018\u00010\u0003HÆ\u0003J\t\u0010\f\u001a\u00020\u0005HÆ\u0003J\u001f\u0010\r\u001a\u00020\u00002\n\b\u0002\u0010\u0002\u001a\u0004\u0018\u00010\u00032\b\b\u0002\u0010\u0004\u001a\u00020\u0005HÆ\u0001J\t\u0010\u000e\u001a\u00020\u000fHÖ\u0001J\u0013\u0010\u0010\u001a\u00020\u00112\b\u0010\u0012\u001a\u0004\u0018\u00010\u0013HÖ\u0003J\t\u0010\u0014\u001a\u00020\u000fHÖ\u0001J\b\u0010\u0015\u001a\u00020\u0003H\u0016J\u0019\u0010\u0016\u001a\u00020\u00172\u0006\u0010\u0018\u001a\u00020\u00192\u0006\u0010\u001a\u001a\u00020\u000fHÖ\u0001R\u0013\u0010\u0002\u001a\u0004\u0018\u00010\u0003¢\u0006\b\n\u0000\u001a\u0004\b\u0007\u0010\bR\u0011\u0010\u0004\u001a\u00020\u0005¢\u0006\b\n\u0000\u001a\u0004\b\t\u0010\n¨\u0006\u001b"},
d2 = {"Lcom/jacee/example/parcelabletest/data/User;", "Landroid/os/Parcelable;", "name", "", "updatedTime", "", "(Ljava/lang/String;J)V", "getName", "()Ljava/lang/String;", "getUpdatedTime", "()J", "component1", "component2", "copy", "describeContents", "", "equals", "", "other", "", "hashCode", "toString", "writeToParcel", "", "parcel", "Landroid/os/Parcel;", "flags", "parcelable-test_debug"}
)
@Parcelize
public final class User implements Parcelable {
@Nullable
private final String name;
private final long updatedTime;
public static final android.os.Parcelable.Creator CREATOR = new User.Creator();

@NotNull
public String toString() {
return "new: " + this.name + " - [" + DateFormat.getInstance().format(new Date(this.updatedTime)) + ']';
}

@Nullable
public final String getName() {
return this.name;
}

public final long getUpdatedTime() {
return this.updatedTime;
}

public User(@Nullable String name, long updatedTime) {
this.name = name;
this.updatedTime = updatedTime;
}

@Nullable
public final String component1() {
return this.name;
}

public final long component2() {
return this.updatedTime;
}

@NotNull
public final User copy(@Nullable String name, long updatedTime) {
return new User(name, updatedTime);
}

// $FF: synthetic method
public static User copy$default(User var0, String var1, long var2, int var4, Object var5) {
if ((var4 & 1) != 0) {
var1 = var0.name;
}

if ((var4 & 2) != 0) {
var2 = var0.updatedTime;
}

return var0.copy(var1, var2);
}

public int hashCode() {
String var10000 = this.name;
return (var10000 != null ? var10000.hashCode() : 0) * 31 + Long.hashCode(this.updatedTime);
}

public boolean equals(@Nullable Object var1) {
if (this != var1) {
if (var1 instanceof User) {
User var2 = (User)var1;
if (Intrinsics.areEqual(this.name, var2.name) && this.updatedTime == var2.updatedTime) {
return true;
}
}

return false;
} else {
return true;
}
}

public int describeContents() {
return 0;
}

public void writeToParcel(@NotNull Parcel parcel, int flags) {
Intrinsics.checkNotNullParameter(parcel, "parcel");
parcel.writeString(this.name);
parcel.writeLong(this.updatedTime);
}

@Metadata(
mv = {1, 6, 0},
k = 3
)
public static class Creator implements android.os.Parcelable.Creator {
@NotNull
public final User[] newArray(int size) {
return new User[size];
}

// $FF: synthetic method
// $FF: bridge method
public Object[] newArray(int var1) {
return this.newArray(var1);
}

@NotNull
public final User createFromParcel(@NotNull Parcel in) {
Intrinsics.checkNotNullParameter(in, "in");
return new User(in.readString(), in.readLong());
}

// $FF: synthetic method
// $FF: bridge method
public Object createFromParcel(Parcel var1) {
return this.createFromParcel(var1);
}
}
}

嗯,十分眼熟 —— 这不就是 完美且完整地实现了Parcelable 吗?当然是能正确工作的!


2022-05-18 13:13:30.197 27258-27258 p-test   com.jacee.example.parcelabletest     D  navigate to receiver
2022-05-18 13:13:30.237 27258-27258 p-test com.jacee.example.parcelabletest D onCreate

image.png


复杂的序列化逻辑


如果需要添加更复杂的序列化逻辑,就需要额外通过伴随对象实现,该对象需要实现接口 Parceler


interface Parceler<T> {
/**
* Writes the [T] instance state to the [parcel].
*/
fun T.write(parcel: Parcel, flags: Int)

/**
* Reads the [T] instance state from the [parcel], constructs the new [T] instance and returns it.
*/
fun create(parcel: Parcel): T

/**
* Returns a new [Array]<T> with the given array [size].
*/
fun newArray(size: Int): Array<T> {
throw NotImplementedError("Generated by Android Extensions automatically")
}
}

看样子,Parceler 和原生 Parcelable.Creator 十分像啊,不过多了一个 write 函数 —— 其实就是对应了Parcelable.writeToParcel方法。


简单打印点日志模拟所谓的“复杂的序列化逻辑”:


@Parcelize
data class User(
val name: String?,
val updatedTime: Long
): Parcelable {
override fun toString(): String = "new: $name - [${
DateFormat.getInstance().format(Date(updatedTime))
}]"

private companion object : Parceler<User> {
override fun create(parcel: Parcel): User {
Log.d("p-test", "new: create")
return User(parcel.readString(), parcel.readLong())
}

override fun User.write(parcel: Parcel, flags: Int) {
Log.d("p-test", "new: write to")
parcel.writeString("【${name}】")
parcel.writeLong(updatedTime)
}

}
}

来看看:


2022-05-18 13:24:49.365 29603-29603 p-test  com.jacee.example.parcelabletest     D  navigate to receiver
2022-05-18 13:24:49.366 29603-29603 p-test com.jacee.example.parcelabletest D new: write to
2022-05-18 13:24:49.450 29603-29603 p-test com.jacee.example.parcelabletest D onCreate
2022-05-18 13:24:49.450 29603-29603 p-test com.jacee.example.parcelabletest D new: create

果然调用了,其中,接收方拿到的name,确实就是write函数改造过的(加了“【】”):


image.png


映射序列化


假如数据类不能直接支持序列化,那就可以通过自定义一个Parceler实现映射序列化


怎么理解呢?假如有一个数据类A,是一个普通实现,不支持序列化(或者有其他原因,总之是不支持),但是呢,我们又有需求是将它序列化后使用,这时候就可以实现 Parceler<A> 类,然后用包裹A的类B来实现序列化 —— 即,通过Parceler,将普通的A包裹成了序列化的B


// 目标数据类A
data class User(
val name: String?,
val updatedTime: Long
) {
override fun toString(): String = "new: $name - [${
DateFormat.getInstance().format(Date(updatedTime))
}]"
}

// 实现的Parceler<A>
object UserParceler: Parceler<User> {
override fun create(parcel: Parcel): User {
Log.d("djx_test", "1 new: create")
return User(parcel.readString(), parcel.readLong())
}

override fun User.write(parcel: Parcel, flags: Int) {
Log.d("djx_test", "1 new: write to")
parcel.writeString("【${name}】")
parcel.writeLong(updatedTime)
}
}

// 映射类B
@Parcelize
@TypeParceler<User, UserParceler>
class Target(val value: User): Parcelable // 这个类来实现Parcelable

如上就是 A -> B 的序列化映射,同样没问题:


2022-05-18 14:08:26.091 30639-30639 p-test   com.jacee.example.parcelabletest     D  navigate to receiver
2022-05-18 14:08:26.094 30639-30639 p-test com.jacee.example.parcelabletest D 1 new: write to
2022-05-18 14:08:26.148 30639-30639 p-test com.jacee.example.parcelabletest D onCreate
2022-05-18 14:08:26.148 30639-30639 p-test com.jacee.example.parcelabletest D 1 new: create

image.png


上面的映射类B,还可以这么写:


@Parcelize
class Target(@TypeParceler<User, UserParceler> val value: User): Parcelable

// 或

@Parcelize
class Target(val value: @WriteWith<UserParceler> User): Parcelable

总结


说了这么多,其实总结一下就是:


插件kotlin-parcelize接管了套路化、模版化的工作,帮我们自动生成了序列化的实现,它并没有改变 Parcelable 的实现方式


用它就对了!


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

聊一聊Kotlin协程"低级"api

聊一聊kotlin协程“低级”api Kotlin协程已经出来很久了,相信大家都有不同程度的用上了,由于最近处理的需求有遇到协程相关,因此今天来聊一Kotlin协程的“低级”api,首先低级api并不是它真的很“低级”,而是kotlin协程库中的基础api,我...
继续阅读 »

聊一聊kotlin协程“低级”api


Kotlin协程已经出来很久了,相信大家都有不同程度的用上了,由于最近处理的需求有遇到协程相关,因此今天来聊一Kotlin协程的“低级”api,首先低级api并不是它真的很“低级”,而是kotlin协程库中的基础api,我们一般开发用的,其实都是通过低级api进行封装的高级函数,本章会通过低级api的组合,实现一个自定义的async await 函数(下文也会介绍kotlin 高级api的async await),涉及的低级api有startCoroutineContinuationInterceptor


startCoroutine


我们知道,一个suspend关键字修饰的函数,只能在协程体中执行,伴随着suspend 关键字,kotlin coroutine common库(平台无关)也提供出来一个api,用于直接通过suspend 修饰的函数直接启动一个协程,它就是startCoroutine


@SinceKotlin("1.3")
@Suppress("UNCHECKED_CAST")
public fun <R, T> (suspend R.() -> T).startCoroutine(
作为Receiver
receiver: R,
当前协程结束时的回调
completion: Continuation<T>
) {
createCoroutineUnintercepted(receiver, completion).intercepted().resume(Unit)
}

可以看到,它的Receiver是(suspend R.() -> T),即是一个suspend修饰的函数,那么这个有什么作用呢?我们知道,在普通函数中无法调起suspend函数(因为普通函数没有隐含的Continuation对象,这里我们不在这章讲,可以参考kotlin协程的资料)


image.png
但是普通函数是可以调起一个以suspend函数作为Receiver的函数(本质也是一个普通函数)


image.png
其中startCoroutine就是其中一个,本质就是我们直接从外部提供了一个Continuation,同时调用了resume方法,去进入到了协程的世界



startCoroutine实现

createCoroutineUnintercepted(completion).intercepted().resume(Unit)

这个原理我们就不细讲下去原理,之前也有写过相关的文章。通过这种调用,我们其实就可以实现在普通的函数环境,开启一个协程环境(即带有了Continuation),进而调用其他的suspend函数。


ContinuationInterceptor


我们都知道拦截器的概念,那么kotlin协程也有,就是ContinuationInterceptor,它提供以AOP的方式,让外部在resume(协程恢复)前后进行自定义的拦截操作,比如高级api中的Diapatcher就是。当然什么是resume协程恢复呢,可能读者有点懵,我们还是以上图中出现的mySuspendFunc举例子


mySuspendFunc是一个suspned函数
::mySuspendFunc.startCoroutine(object : Continuation<Unit> {
override val context: CoroutineContext
get() = EmptyCoroutineContext

override fun resumeWith(result: Result<Unit>) {

}

})

它其实等价于


val continuation = ::mySuspendFunc.createCoroutine(object :Continuation<Unit>{
override val context: CoroutineContext
get() = EmptyCoroutineContext

override fun resumeWith(result: Result<Unit>) {
Log.e("hello","当前协程执行完成的回调")
}

})
continuation.resume(Unit)

startCoroutine方法就相当于创建了一个Continuation对象,并调用了resume。创建Continuation可通过createCoroutine方法,返回一个Continuation,如果我们不调用resume方法,那么它其实什么也不会执行,只有调用了resume等执行方法之后,才会执行到后续的协程体(这个也是协程内部实现,感兴趣可以看看之前文章)


而我们的拦截器,就相当于在continuation.resume前后,可以添加自己的逻辑。我们可以通过继承ContinuationInterceptor,实现自己的拦截器逻辑,其中需要复写的方法是interceptContinuation方法,用于返回一个自己定义的Continuation对象,而我们可以在这个Continuation的resumeWith方法里面(当调用了resume之后,会执行到resumeWith方法),进行前后打印/其他自定义操作(比如切换线程)


class ClassInterceptor() :ContinuationInterceptor {
override val key = ContinuationInterceptor
override fun <T> interceptContinuation(continuation: Continuation<T>): Continuation<T> =MyContinuation(continuation)

}
class MyContinuation<T>(private val continuation: Continuation<T>):Continuation<T> by continuation{
override fun resumeWith(result: Result<T>) {
Log.e("hello","MyContinuation start ${result.getOrThrow()}")
continuation.resumeWith(result)

Log.e("hello","MyContinuation end ")
}
}

其中的key是ContinuationInterceptor,协程内部会在每次协程恢复的时候,通过coroutineContext取出key为ContinuationInterceptor的拦截器,进行拦截调用,当然这也是kotlin协程内部实现,这里简单提一下。


实战


kotlin协程api中的 async await


我们来看一下kotlon Coroutine 的高级api async await用法


CoroutineScope(Dispatchers.Main).launch {
val block = async(Dispatchers.IO) {
// 阻塞的事项

}
// 处理其他主线程的事务

// 此时必须需要async的结果时,则可通过await()进行获取
val result = block.await()
}

我们可以通过async方法,在其他线程中处理其他阻塞事务,当主线程必须要用async的结果的时候,就可以通过await等待,这里如果结果返回了,则直接获取值,否则就等待async执行完成。这是Coroutine提供给我们的高级api,能够将任务简单分层而不需要过多的回调处理。


通过startCoroutine与ContinuationInterceptor实现自定义的 async await


我们可以参考其他语言的async,或者Dart的异步方法调用,都有类似这种方式进行线程调用


async {
val result = await {
suspend 函数
}
消费result
}

await在async作用域里面,同时获取到result后再进行消费,async可以直接在普通函数调用,而不需要在协程体内,下面我们来实现一下这个做法。


首先我们想要限定await函数只能在async的作用域才能使用,那么首先我们就要定义出来一个Receiver,我们可以在Receiver里面定义出自己想要暴露的方法


interface AsyncScope {
fun myFunc(){

}

}
fun async(
context: CoroutineContext = EmptyCoroutineContext,
block: suspend AsyncScope.() -> Unit
) {
// 这个有两个作用 1.充当receiver 2.completion,接收回调
val completion = AsyncStub(context)
block.startCoroutine(completion, completion)
}

注意这个类,resumeWith 只会跟startCoroutine的这个协程绑定关系,跟await的协程没有关系
class AsyncStub(override val context: CoroutineContext = EmptyCoroutineContext) :
Continuation<Unit>, AsyncScope {
override fun resumeWith(result: Result<Unit>) {

// 这个是干嘛的 == > 完成的回调
Log.e("hello","AsyncStub resumeWith ${Thread.currentThread().id} ${result.getOrThrow()}")
}
}

上面我们定义出来一个async函数,同时定义出来了一个AsyncStub的类,它有两个用处,第一个是为了充当Receiver,用于规范后续的await函数只能在这个Receiver作用域中调用,第二个作用是startCoroutine函数必须要传入一个参数completion,是为了收到当前协程结束的回调resumeWith中可以得到当前协程体结束回调的信息


await方法里面

suspend fun<T> AsyncScope.await(block:() -> T) = suspendCoroutine<T> {
// 自定义的Receiver函数
myFunc()

Thread{
切换线程执行await中的方法
it.resumeWith(Result.success(block()))
}.start()
}

在await中,其实是一个扩展函数,我们可以调用任何在AsyncScope中定义的方法,同时这里我们模拟了一下线程切换的操作(Dispatcher的实现,这里不采用Dispatcher就是想让大家知道其实Dispatcher.IO也是这样实现的),在子线程中调用it.resumeWith(Result.success(block())),用于返回所需要的信息


通过上面定的方法,我们可以实现


async {
val result = await {
suspend 函数
}
消费result
}

这种调用方式,但是这里引来了一个问题,因为我们在await函数中实际将操作切换到了子线程,我们想要将消费result的动作切换至主线程怎么办呢?又或者是加入我们希望获取结果前做一些调整怎么办呢?别急,我们这里预留了一个CoroutineContext函数,我们可以在外部传入一个CoroutineContext


public interface ContinuationInterceptor : CoroutineContext.Element
而CoroutineContext.Element又是继承于CoroutineContext
CoroutineContext.Element:CoroutineContext

而我们的拦截器,正是CoroutineContext的子类,我们把上文的ClassInterceptor修改一下



class ClassInterceptor() : ContinuationInterceptor {
override val key = ContinuationInterceptor
override fun <T> interceptContinuation(continuation: Continuation<T>): Continuation<T> =
MyContinuation(continuation)

}

class MyContinuation<T>(private val continuation: Continuation<T>) :
Continuation<T> by continuation {
private val handler = Handler(Looper.getMainLooper())
override fun resumeWith(result: Result<T>) {
Log.e("hello", "MyContinuation start ${result.getOrThrow()}")

handler.post {
continuation.resumeWith(Result.success(自定义内容))
}
Log.e("hello", "MyContinuation end ")
}
}

同时把async默认参数CoroutineContext实现一下即可


fun async(
context: CoroutineContext = ClassInterceptor(),
block: suspend AsyncScope.() -> Unit
) {
// 这个有两个作用 1.充当receiver 2.completion,接收回调
val completion = AsyncStub(context)
block.startCoroutine(completion, completion)
}

此后我们就可以直接通过,完美实现了一个类js协程的调用,同时具备了自动切换线程的能力


async {
val result = await {
test()
}
Log.e("hello", "result is $result ${Looper.myLooper() == Looper.getMainLooper()}")
}

结果


  E  start 
E MyContinuation start kotlin.Unit
E MyContinuation end
E end
E 执行阻塞函数 test 1923
E MyContinuation start 自定义内容数值
E MyContinuation end
E result is 自定义内容的数值 true
E AsyncStub resumeWith 2 kotlin.Unit

最后,这里需要注意的是,为什么拦截器回调了两次,因为我们async的时候开启了一个协程,同时await的时候也开启了一个,因此是两个。AsyncStub只回调了一次,是因为AsyncStub被当作complete参数传入了async开启的协程block.startCoroutine,因此只是async中的协程结束才会被回调。


image.png


本章代码



class ClassInterceptor() : ContinuationInterceptor {
override val key = ContinuationInterceptor
override fun <T> interceptContinuation(continuation: Continuation<T>): Continuation<T> =
MyContinuation(continuation)

}

class MyContinuation<T>(private val continuation: Continuation<T>) :
Continuation<T> by continuation {
private val handler = Handler(Looper.getMainLooper())
override fun resumeWith(result: Result<T>) {
Log.e("hello", "MyContinuation start ${result.getOrThrow()}")

handler.post {
continuation.resumeWith(Result.success(6 as T))
}
Log.e("hello", "MyContinuation end ")
}
}

interface AsyncScope {
fun myFunc(){

}

}
fun async(
context: CoroutineContext = ClassInterceptor(),
block: suspend AsyncScope.() -> Unit
) {
// 这个有两个作用 1.充当receiver 2.completion,接收回调
val completion = AsyncStub(context)
block.startCoroutine(completion, completion)
}

class AsyncStub(override val context: CoroutineContext = EmptyCoroutineContext) :
Continuation<Unit>, AsyncScope {
override fun resumeWith(result: Result<Unit>) {

// 这个是干嘛的 == > 完成的回调
Log.e("hello","AsyncStub resumeWith ${Thread.currentThread().id} ${result.getOrThrow()}")
}
}


suspend fun<T> AsyncScope.await(block:() -> T) = suspendCoroutine<T> {
myFunc()

Thread{
it.resumeWith(Result.success(block()))
}.start()
}

模拟阻塞
fun test(): Int {
Thread.sleep(5000)
Log.e("hello", "执行阻塞函数 test ${Thread.currentThread().id}")
return 5
}

async {
val result = await {
test()
}
Log.e("hello", "result is $result ${Looper.myLooper() == Looper.getMainLooper()}")
}

最后


我们通过协程的低级api,实现了一个与官方库不同版本的async await,同时也希望通过对低级api的设计,也能对Coroutine官方库的高级api的实现有一定的了解。


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

Flutter — 仅用三个步骤就能帮你把文本变得炫酷!

前言:前天,一位不愿意透露姓名的朋友找到我,问我怎么样才能把文本变得炫酷一些,他想用图片嵌入到自己的名字里去,用来当作朋友圈的背景。我直接回了一句,你PS下不就好了。他回我一句:想要这样效果的人比较多,全部都PS的话怕不是电脑要干冒烟...能不能用代码自动生成...
继续阅读 »

前言:

前天,一位不愿意透露姓名的朋友找到我,问我怎么样才能把文本变得炫酷一些,他想用图片嵌入到自己的名字里去,用来当作朋友圈的背景。我直接回了一句,你PS下不就好了。他回我一句:想要这样效果的人比较多,全部都PS的话怕不是电脑要干冒烟...能不能用代码自动生成下(请你喝奶茶🍹)。作为一个乐于助人的人,看到朋友有困难,而且实现起来也不复杂,那我必须要帮忙啊~

注:本文是一篇整活文,让大家看的开心最重要~文章只对核心代码做分析,完整代码在这里

话不多说,直接上图:

填入文本中的可以是手动上传的图片,也可以是彩色小块。

1.gif

2.png

功能实现步骤分析:

1.数据的获取 — 获取输入的文本数据、获取输入的图片数据。

2.将输入的文本生成为图片

3.解析文本图片,替换像素为图片

简单三步骤,实现朴素到炫酷的转换~

1.数据的获取 — 获取输入的文本数据、获取输入的图片数据。

  • 定义需要存放的数据

    //用于获取输入的文本
    TextEditingController textEditingController = TextEditingController();

    //存放输入的图片
    List<File> imagesPath = [];
  • 输入框
    3.png

    Container(
     margin: const EdgeInsets.all(25.0),
     child: TextField(
       controller: textEditingController,
       decoration: const InputDecoration(
           hintText: "请输入文字",
           border: OutlineInputBorder(
               borderRadius: BorderRadius.all(Radius.circular(16.0)))),
    ),
    ),
  • 九宫格图片封装

    4.png

    @override
    Widget build(BuildContext context) {
     var maxWidth = MediaQuery.of(context).size.width;

     //计算不同数量时,图片的大小
     var _ninePictureW = (maxWidth - _space * 2 - 2 * _itemSpace - lRSpace);
    ...

     return Offstage(
       offstage: imgData!.length == -1,
       child: SizedBox(
         width: _bgWidth,
         height: _bgHeight,
         child: GridView.builder(
             gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
               // 可以直接指定每行(列)显示多少个Item
               crossAxisCount: _crossAxisCount, // 一行的Widget数量
               crossAxisSpacing: _itemSpace, // 水平间距
               mainAxisSpacing: _itemSpace, // 垂直间距
               childAspectRatio: _childAspectRatio, // 子Widget宽高比例
            ),
             // 禁用滚动事件
             physics: const NeverScrollableScrollPhysics(),
             // GridView内边距
             padding: const EdgeInsets.only(left: _space, right: _space),
             itemCount:
                 imgData!.length < 9 ? imgData!.length + 1 : imgData!.length,
             itemBuilder: (context, index) {
               if (imgData!.isEmpty) {
                 return _addPhoto(context);
              } else if (index < imgData!.length) {
                 return _itemCell(context, index);
              } else if (index == imgData!.length) {
                 return _addPhoto(context);
              }
               return SizedBox();
            }),
      ),
    );
    }
  • 添加图片

    使用A佬的wechat_assets_picker,要的就是效率~

    Future<void> selectAssets() async {
     //获取图片
     final List<AssetEntity>? result = await AssetPicker.pickAssets(
       context,
    );
     List<File> images = [];
     //循环取出File
     if (result != null) {
       for (int i = 0; i < result.length; i++) {
         AssetEntity asset = result[i];
         File? file = await asset.file;
         if (file != null) {
           images.add(file);
        }
      }
    }
     //更新状态,修改存放File的数组
     setState(() {
       imagesPath = images;
    });
    }

2.将输入的文本生成为图片

  • 构建输入的文本布局

    RepaintBoundary(
       key: repaintKey,
       child: Container(
         color: Colors.white,
         width: MediaQuery.of(context).size.width,
         height: 300,
           //image是解析图片的数据
         child: image != null
             ? PhotoLayout(
                 n: 1080,
                 m: 900,
                 image: image!,
                 fileImages: widget.images)
            :
           //将输入的文本布局
           Center(
                 child: Text(
                   widget.photoText,
                   style: const TextStyle(
                       fontSize: 100, fontWeight: FontWeight.bold),
                ),
              ),
      )),
  • 通过RepaintBoundary将生成的布局生成Uint8List数据

    /// 获取截取图片的数据,并解码
     Future<img.Image?> getImageData() async {
       //生成图片数据
       BuildContext buildContext = repaintKey.currentContext!;
       Uint8List imageBytes;
       RenderRepaintBoundary boundary =
           buildContext.findRenderObject() as RenderRepaintBoundary;

       double dpr = ui.window.devicePixelRatio;
       ui.Image image = await boundary.toImage(pixelRatio: dpr);
       // image.width
       ByteData? byteData = await image.toByteData(format: ui.ImageByteFormat.png);
       imageBytes = byteData!.buffer.asUint8List();

       var tempDir = await getTemporaryDirectory();
       //生成file文件格式
       var file =
           await File('${tempDir.path}/image_${DateTime.now().millisecond}.png')
              .create();
       //转成file文件
       file.writeAsBytesSync(imageBytes);
       //存放生成的图片到本地
       // final result = await ImageGallerySaver.saveFile(file.path);
       return img.decodeImage(imageBytes);
    }

3.解析文本图片,替换像素为图片

  • 判断文本像素,在对应像素位置生成图片

    Widget buildPixel(int x, int y) {
     int index = x * n + y;
     //根据给定的x和y坐标,获取像素的颜色编码
     Color color = Color(image.getPixel(y, x));
     //判断是不是白色的像素点,如果是,则用SizedBox替代
     if (color == Colors.white) {
       return const SizedBox.shrink();
    }
     else {
       //如果不是,则代表是文本所在的像素,替换为输入的图片
       return Image.file(
           fileImages![index % fileImages!.length],
           fit: BoxFit.cover,
        );
    }
    }
  • 构建最终生成的图片

    @override
    Widget build(BuildContext context) {
     List<Widget> children = [];
       //按点去渲染图片的像素位置,每次加10是因为,图像的像素点很多,如果每一个点都替换为图片,第一是效果不好,第二是渲染的时间很久。
     for (int i = 0; i < n; i = i+10) {
       List<Widget> columnChildren = [];
       for (int x = 0; x < m; x = x+10) {
         columnChildren.add(
           Expanded(
             child: buildPixel(x, i),
          ),
        );
      }
       children.add(Expanded(
           child: Column(
         crossAxisAlignment: CrossAxisAlignment.stretch,
         children: columnChildren,
      )));
    }
     //CrossAxisAlignment.stretch:子控件完全填充交叉轴方向的空间
     return Row(
       crossAxisAlignment: CrossAxisAlignment.stretch,
       children: children,
    );
    }

这样就实现了文本替换为图片的功能啦~


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

Android 控件自动贴边实现

最近接到个需求,需要在用户与App交互时,把SDK中之前实现过的悬浮控件贴边隐藏,结束交互后延迟一段时间再自动显示。本篇文章介绍一下实现的思路。 判断交互 用户与App交互、结束交互可以通过监听触摸事件来实现。建议使用的Activity的dispatchTou...
继续阅读 »

最近接到个需求,需要在用户与App交互时,把SDK中之前实现过的悬浮控件贴边隐藏,结束交互后延迟一段时间再自动显示。本篇文章介绍一下实现的思路。


判断交互


用户与App交互、结束交互可以通过监听触摸事件来实现。建议使用的ActivitydispatchTouchEventActivity下的所有触摸事件分发时都会回调此方法,代码如下:


class AutoEdgeHideActivity : BaseGestureDetectorActivity() {

override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
when (ev.action) {
MotionEvent.ACTION_DOWN -> {
// 手指按下,开始本次交互
// 在此实现隐藏逻辑
}
MotionEvent.ACTION_UP -> {
// 手指抬起,结束本次交互
// 在此实现延迟显示功能
}
}
return super.dispatchTouchEvent(ev)
}
}

隐藏与显示


想要实现的效果是当用户与App交互时,悬浮控件平移贴边,但保留一部分显示。结束交互延迟一段时间后,悬浮控件平移回原来的位置。


此处通过ValueAnimator来实现,计算好控件的起始和结束位置,然后改变控件的x坐标,代码如下:


private fun xCoordinateAnimator(view: View, startX: Float, endX: Float) {
val animator = ValueAnimator.ofFloat(startX, endX)
animator.addUpdateListener {
// 不断更改控件的X坐标
view.x = it.animatedValue as Float
}
// 设置插值器,速度由快变慢
animator.interpolator = DecelerateInterpolator()
// 设置动画的持续时间
animator.duration = 500
animator.start()
}

示例


整合之后做了个示例Demo,完整代码如下:


class AutoEdgeHideActivity : BaseGestureDetectorActivity() {

private lateinit var binding: LayoutAutoEdgeHideActivityBinding

private var widthPixels: Int = 0

private val autoShowInterval = 2
private var interacting = false
private var hidden = false
private var lastPositionX: Float = 0f

private val handler = Handler(Looper.myLooper() ?: Looper.getMainLooper())
private val autoShowRunnable = Runnable { autoShow() }

@SuppressLint("SetTextI18n")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = DataBindingUtil.setContentView(this, R.layout.layout_auto_edge_hide_activity)
widthPixels = resources.displayMetrics.widthPixels
binding.includeTitle.tvTitle.text = "AutoEdgeHideExample"
binding.vFloatView.setOnClickListener {
if (hidden) {
// 当前为隐藏状态,先显示
// 把之前的延迟线程先取消
handler.removeCallbacks(autoShowRunnable)
autoShow()
Toast.makeText(this, "手动显示控件", Toast.LENGTH_SHORT).show()
} else {
// 相应正常的事件
Toast.makeText(this, "点击了浮标控件", Toast.LENGTH_SHORT).show()
}
}
}

override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
when (ev.action) {
MotionEvent.ACTION_DOWN -> {
if (!checkIsTouchFloatView(ev, binding.vFloatView)) {
// 起始ACTION_DOWN事件在浮标控件外,自动隐藏浮标控件,标记正在交互
interacting = true
handler.removeCallbacks(autoShowRunnable)
autoHide()
}
}
MotionEvent.ACTION_UP -> {
if (interacting) {
// 交互结束,一定时间后自动显示,时间可以自由配置
interacting = false
handler.postDelayed(autoShowRunnable, autoShowInterval * 1000L)
}
}
}
return super.dispatchTouchEvent(ev)
}

/**
* 检查是否触摸浮标控件
*/
private fun checkIsTouchFloatView(ev: MotionEvent, view: View): Boolean {
val screenLocation = IntArray(2)
view.getLocationOnScreen(screenLocation)
val viewX = screenLocation[0]
val viewY = screenLocation[1]
return (ev.x >= viewX && ev.x <= (viewX + view.width)) && (ev.y >= viewY && ev.y <= (viewY + view.height))
}

private fun autoShow() {
if (hidden) {
hidden = false
binding.vFloatView.let {
xCoordinateAnimator(it, it.x, lastPositionX)
}
}
}

private fun autoHide() {
if (!hidden) {
hidden = true
binding.vFloatView.let {
// 记录一下显示状态下的x坐标
lastPositionX = it.x
// 隐藏时的x坐标,留一点控件的边缘显示(示例中默认控件在屏幕右侧)
val endX = widthPixels - it.width * 0.23f
xCoordinateAnimator(it, lastPositionX, endX)
}
}
}

private fun xCoordinateAnimator(view: View, startX: Float, endX: Float) {
val animator = ValueAnimator.ofFloat(startX, endX)
animator.addUpdateListener {
view.x = it.animatedValue as Float
}
animator.interpolator = DecelerateInterpolator()
animator.duration = 500
animator.start()
}
}

效果如图:


device-2022-11-26-105111.gif

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

终于理解~Android 模块化里的资源冲突

⚽ 前言 作为 Android 开发者,我们常常需要去管理非常多不同的资源文件,编译时这些资源文件会被统一地收集和整合到同一个包下面。根据官方的《Configure your build》文档介绍的构建过程可以总结这个过程: 编译器会将源码文件转换成包含了...
继续阅读 »

⚽ 前言


作为 Android 开发者,我们常常需要去管理非常多不同的资源文件,编译时这些资源文件会被统一地收集和整合到同一个包下面。根据官方的《Configure your build》文档介绍的构建过程可以总结这个过程:




  1. 编译器会将源码文件转换成包含了二进制字节码、能运行在 Android 设备上的 DEX 文件,而其他文件则被转换成编译后资源。

  2. APK 打包工具则会将 DEX 文件和编译后资源组合成独立的 APK 文件。



但如果资源的命名发生了碰撞、冲突,会对编译产生什么影响?


事实证明这个影响是不确定的,尤其是涉及到构建外部 Library。


本文将探究一些不同的资源冲突案例,并逐个说明怎样才能安全地命名资源


🇦🇷 App module 内资源冲突


先来看个最简单的资源冲突的案例:同一个资源文件中出现两个命名、类型一样的资源定义,比如:


 <!--strings.xml-->
 <resources>
     <string name="hello_world">Hello World!</string>
     <string name="hello_world">Hello World!</string>
 </resources>

试图去编译的话,会导致显而易见的错误提示:


 FAILURE: Build failed with an exception.
 
 * What went wrong:
 Execution failed for task ':app:mergeDebugResources'.
 > /.../strings.xml: Error: Found item String/hello_world more than one time

类似的,另一种常见冲突是在多个文件里定义冲突的资源:


 <!--strings.xml-->
 <resources>
     <string name="hello_world">Hello World!</string>
 </resources>
 
 <!--other_strings.xml-->
 <resources>
     <string name="hello_world">Hello World!</string>
 </resources>

我们会收到类似的编译错误,而这次的错误将列出所有发生冲突的具体文件位置。


 FAILURE: Build failed with an exception.
 
 * What went wrong:
 Execution failed for task ':app:mergeDebugResources'.
 > [string/hello_world] /.../other_strings.xml
  [string/hello_world] /.../strings.xml: Error: Duplicate resources

Android 平台上资源的运作方式变得愈加清晰。我们需要为 App module 指定在类型、名称、设备配置等限定组合下的唯一资源。也就是说,当 App module 引用 string/hello_world 资源的时候,有且仅有一个值被解析出来。开发者们必须解决发生的资源冲突,可以选择删除那些内容重复的资源、重命名仍然需要的资源、亦或移动到其他限定条件下的资源文件。


更多关于资源和限定的信息可以参考官方的《App resources overview》 文档。


🇩🇪 Library 和 App module 的资源冲突


下面这个案例,我们将研究 Library module 定义了一个和 App module 重复的资源而引发的冲突。


 <!--app/../strings.xml-->
 <resources>
     <string name="hello">Hello from the App!</string>
 </resources>
 
 <!--library/../strings.xml-->
 <resources>
     <string name="hello">Hello from the Library!</string>
 </resources>

当你编译上面的代码的时候,发现竟然通过了。从我们上个章节的发现来看,我们可以推测 Android 肯定采用了一个规则,去确保在这种场景下仍能够找到一个独有的 string/hello 资源值。


根据官方的《Create an Android library》文档:



编译工具会将来自 Library module 的资源和独立的 App module 资源进行合并。如果双方均具备一个资源 ID 的话,将采用 App 的资源。



这样的话,将会对模块化的 App 开发造成什么影响?比如我们在 Library 中定义了这么一个 TextView 布局:


 <!--library/../text_view.xml-->
 <TextView
     android:layout_width="wrap_content"
     android:layout_height="wrap_content"
     android:text="@string/hello"
     xmlns:android="http://schemas.android.com/apk/res/android" />

AS 中该布局的预览是这样的。


Hello from the Library!


现在我们决定将这个 TextView 导入到 App module 的布局中:


 <!--app/../activity_main.xml-->
 <LinearLayout
     xmlns:android="http://schemas.android.com/apk/res/android"
     xmlns:tools="http://schemas.android.com/tools"
     android:layout_width="match_parent"
     android:layout_height="match_parent"
     android:gravity="center"
     tools:context=".MainActivity"
     >
 
     <include layout="@layout/text_view" />
 
 </LinearLayout>

无论是 AS 中预览还是实际运行,我们可以看到下面的一个显示结果:


Hello from the App!


不仅是通过布局访问 string/hello 的 App module 会拿到 “Hello from the App!”,Library 本身拿到的也是如此。基于这个原因,我们需要警惕不要无意覆盖 Lbrary 中的资源定义。


🇧🇷 Library 之间的资源冲突


再一个案例,我们将讨论下当多个 Library 里定义了冲突的资源,会发生什么。


首先来看下如下的布局,如果这样写的话会产生什么结果?


 <!--library1/../strings.xml-->
 <resources>
     <string name="hello">Hello from Library 1!</string>
 </resources>
 
 <!--library2/../strings.xml-->
 <resources>
     <string name="hello">Hello from Library 2!</string>
 </resources>
 
 <!--app/../activity_main.xml-->
 <TextView
     xmlns:android="http://schemas.android.com/apk/res/android"
     android:layout_width="wrap_content"
     android:layout_height="wrap_content"
     android:text="@string/hello" />

string/hello 将会被显示成什么?


事实上这取决于 App build.gradle 文件里依赖这些 Library 的顺序。再次到官方的《Create an Android library》文档里找答案:



如果多个 AAR 库之间发生了冲突,依赖列表里第一个列出(在依赖关系块的顶部)的资源将会被使用。



假使 App module 有这样的依赖列表:


 dependencies {
     implementation project(":library1")
     implementation project(":library2")
    ...
 }

最后 string/hello 的值将会被编译成 Hello from Library 1!


那么如果这两个 implementation 代码调换顺序,比如 implementation project(":library2") 在前、 implementation project(":library1") 在后,资源值则会被编译成 Hello from Library 2!


从这种微妙的变化可以非常直观地看到,依赖顺序可以轻易地改变 App 的资源展示结果。


🇪🇸 自定义 Attributes 的资源冲突


目前为止讨论的示例都是针对 string 资源的使用,然而需要特别留意的是自定义 attributes 这种有趣的资源类型。


看下如下的 attr 定义:


 <!--app/../attrs.xml-->
 <resources>
     <declare-styleable name="CustomStyleable">
         <attr name="freeText" format="string"/>
     </declare-styleable>
 
     <declare-styleable name="CustomStyleable2">
         <attr name="freeText" format="string"/>
     </declare-styleable>
 </resources>

大家可能都认为上面的写法能通过编译、不会报错,而事实上这种写法必将导致下面的编译错误:


 Execution failed for task ':app:mergeDebugResources'.
 > /.../attrs.xml: Error: Found item Attr/freeText more than one time

但如果 2 个 Library 也采用了这样的自定义 attr 写法:


 <!--library1/../attrs.xml-->
 <resources>
     <declare-styleable name="CustomStyleable">
         <attr name="freeText" format="string"/>
     </declare-styleable>
 </resources>
 
 <!--library2/../attrs.xml-->
 <resources>
     <declare-styleable name="CustomStyleable2">
         <attr name="freeText" format="string"/>
     </declare-styleable>
 </resources>

事实上它却能够通过编译。


然而,如果我们进一步将 Library2 的 attr 做些调整,比如改为 <attr name="freeText" format="boolean"/>。再次编译,它竟然又失败了,而且出现了更多令人费解的错误:


 * What went wrong:
 Execution failed for task ':app:mergeDebugResources'.
 > A failure occurred while executing com.android.build.gradle.internal.tasks.Workers$ActionFacade
    > Android resource compilation failed
      /.../library2/build/intermediates/packaged_res/debug/values/values.xml:4:5-6:25: AAPT: error: duplicate value for resource 'attr/freeText' with config ''.
      /.../library2/build/intermediates/packaged_res/debug/values/values.xml:4:5-6:25: AAPT: error: resource previously defined here.
      /.../app/build/intermediates/incremental/mergeDebugResources/merged.dir/values/values.xml: AAPT: error: file failed to compile.

上面错误的一个重点是: mergeDebugResources/merged.dir/values/values.xml: AAPT: error: file failed to compile


到底是怎么回事呢?


事实上 values.xml 的编译指的是为 App module 生成 R 类。编译期间,AAPT 会尝试在 R 类里为每个资源属性生成独一无二的值。而对于 styleable 类型里的每个自定义 attr,都会在 R 类里生成 2 个的属性值。


第一个是 styleable 命名空间属性值(位于 R.styleable 包下),第二个是全局的 attr 属性值(位于 R.attr 包下)。对于这个探讨的特殊案例,我们则遇到了全局属性值的冲突,并且由于此冲突造成存在 3 个属性值:



  • R.styleable.CustomStyleable_freeText:来自 Library1,用于解析 string 格式的、名称为 freeText 的 attr

  • R.styleable.CustomStyleable2_freeText:来自 Library2,用于解析 boolean 格式的、名称为 freeText 的 attr

  • R.attr.freeText:无法被成功解析,源自我们给它赋予了来自 2 个 Library 的数值,而它们的格式不同,造成了冲突


前面能通过编译的示例是因为 Library 间同名的 R.attr.freeText 格式也相同,最终为 App module 编译到的是独一无二的数值。需要注意:每个 module 具备自己的 R 类,我们不能总是指望属性的数值在 Library 间保持一致。


再次看下官方的《Create an Android library》文档的建议:



当你构建依赖其他 Library 的 App module 时,Library module 们将会被编译成 AAR 文件再添加到 App module 中。所以,每个 Library 都会具备自己的 R 类,用 Library 的包名进行命名。所有包都会创建从 App module 和 Library module 生成的 R 类,包括 App module 的包和 Library moudle 的包。



📝 结语


所以我们能从上面的这些探讨得到什么启发?


是资源编译过程的复杂和微妙吗?


确实是的。但是作为开发者,我们能为自己和团队做的是:解释清楚定义的资源想要做什么,也就是说可以加上名称前缀。我们最喜欢的官方文档《Create an Android library》也提到了这宝贵的一点:



通用的资源 ID 应当避免发生资源冲突,可以考虑使用前缀或其他一致的、对 module 来说独一无二的命名方案(抑或是整个项目都是独一无二的命名)。



根据这个建议,比较好的做法是在我们的项目和团队中建立一个模式:在 module 中的所有资源前加上它的 module 名称,例如library_help_text


这将带来两个好处:




  1. 大大降低了名称冲突的概率。




  2. 明确资源覆盖的意图。


    比如也在 App module 中创建 library_help_text 的话,则表明开发者是有意地覆盖 Library module 中的某些定义。有的时候我们的确会想去覆盖一些其他资源,而这样的编码方式可以明确地告诉自己和团队,在编译的时候会发生预期的覆盖。




抛开内部开发不谈,至少是所有公开的资源都应该加上前缀,尤其是作为一个供应商或者开源项目去发布我们的 library。


可以往的经验来看,Google 自己的 library 也没有对所有的资源进行恰当地前缀命名。这将导致意外的副作用:依赖我们发行的 library 可能会因为命名冲突引发 App 编译失败。


Not a great look!


例如,我们可以看到 Material Design library 会给它们的颜色资源统一地添加 mtrl 的前缀。可是 styleable 下嵌套的 attribute resources 却没有使用 material 之类的前缀。


所以你会看到:假使一个 module 依赖了 Material library,同时依赖的另一个 library 中包含了与 Material library 一样名称的 attribute,那么在为这个 moudle 生成 R 类的时候,会发生冲突的可能。


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

从0到1搭建前端监控平台,面试必备的亮点项目

web
前言常常会苦恼,平常做的项目很普通,没啥亮点;面试中也经常会被问到:做过哪些亮点项目吗?前端监控就是一个很有亮点的项目,各个大厂都有自己的内部实现,没有监控的项目好比是在裸奔文章分成以下六部分来介绍:自研监控平台解决了哪些痛点,实现了什么亮点功能?相比sent...
继续阅读 »

前言

常常会苦恼,平常做的项目很普通,没啥亮点;面试中也经常会被问到:做过哪些亮点项目吗?

前端监控就是一个很有亮点的项目,各个大厂都有自己的内部实现,没有监控的项目好比是在裸奔

文章分成以下六部分来介绍:

  • 自研监控平台解决了哪些痛点,实现了什么亮点功能?

  • 相比sentry等监控方案,自研监控的优势有哪些?

  • 前端监控的设计方案、监控的目的

  • 数据的采集方式:错误信息、性能数据、用户行为、加载资源、个性化指标等

  • 设计开发一个完整的监控SDK

  • 监控后台错误还原演示示例

痛点

某⼀天用户:xx商品无法下单!
⼜⼀天运营:xx广告在手机端打开不了!

大家反馈的bug,怎么都复现不出来,尴尬的要死!😢

如何记录项目的错误,并将错误还原出来,这是监控平台要解决的痛点之一

错误还原

web-see 监控提供三种错误还原方式:定位源码、播放录屏、记录用户行为

定位源码

项目出错,要是能定位到源码就好了,可线上的项目都是打包后的代码,也不能把 .map 文件放到线上

监控平台通过 source-map 可以实现该功能

最终效果:


播放录屏

多数场景下,定位到具体的源码,就可以定位bug,但如果是用户做了异常操作,或者是在某些复杂操作下才出现的bug,仅仅通过定位源码,还是不能还原错误

要是能把用户的操作都录制下来,然后通过回放来还原错误就好了

监控平台通过 rrweb 可以实现该功能

最终效果:

回放的录屏中,记录了用户的所有操作,红色的线代表了鼠标的移动轨迹

前端录屏确实是件很酷的事情,但是不能走极端,如果把用户的所有操作都录制下来,是没有意义的

我们更关注的是,页面报错的时候用户做了哪些操作,所以监控平台只把报错前10s的视频保存下来(单次录屏时长也可以自定义)

记录用户行为

通过 定位源码 + 播放录屏 这套组合,还原错误应该够用了,同时监控平台也提供了 记录用户行为 这种方式

假如用户做了很多操作,操作的间隔超过了单次录屏时长,录制的视频可能是不完整的,此时可以借助用户行为来分析用户的操作,帮助复现bug

最终效果:

用户行为列表记录了:鼠标点击、接口调用、资源加载、页面路由变化、代码报错等信息

通过 定位源码、播放录屏、记录用户行为 这三板斧,解决了复现bug的痛点

自研监控的优势

为什么不直接用sentry私有化部署,而选择自研前端监控?

这是优先要思考的问题,sentry作为前端监控的行业标杆,有很多可以借鉴的地方

相比sentry,自研监控平台的优势在于:

1、可以将公司的SDK统一成一个,包括但不限于:监控SDK、埋点SDK、录屏SDK、广告SDK等

2、提供了更多的错误还原方式,同时错误信息可以和埋点信息联动,便可拿到更细致的用户行为栈,更快的排查线上错误

3、监控自定义的个性化指标:如 long task、memory页面内存、首屏加载时间等。过多的长任务会造成页面丢帧、卡顿;过大的内存可能会造成低端机器的卡死、崩溃

4、统计资源缓存率,来判断项目的缓存策略是否合理,提升缓存率可以减少服务器压力,也可以提升页面的打开速度

设计思路

一个完整的前端监控平台包括三个部分:数据采集与上报、数据分析和存储、数据展示


监控目的


异常分析

按照 5W1H 法则来分析前端异常,需要知道以下信息

  1. What,发⽣了什么错误:JS错误、异步错误、资源加载、接口错误等

  2. When,出现的时间段,如时间戳

  3. Who,影响了多少用户,包括报错事件数、IP

  4. Where,出现的页面是哪些,包括页面、对应的设备信息

  5. Why,错误的原因是为什么,包括错误堆栈、⾏列、SourceMap、异常录屏

  6. How,如何定位还原问题,如何异常报警,避免类似的错误发生

错误数据采集

错误信息是最基础也是最重要的数据,错误信息主要分为下面几类:

  • JS 代码运行错误、语法错误等

  • 异步错误等

  • 静态资源加载错误

  • 接口请求报错

错误捕获方式

1)try/catch

只能捕获代码常规的运行错误,语法错误和异步错误不能捕获到

示例:

// 示例1:常规运行时错误,可以捕获 ✅
try {
  let a = undefined;
  if (a.length) {
    console.log('111');
  }
} catch (e) {
  console.log('捕获到异常:', e);
}

// 示例2:语法错误,不能捕获 ❌  
try {
 const notdefined,
} catch(e) {
 console.log('捕获不到异常:', 'Uncaught SyntaxError');
}
 
// 示例3:异步错误,不能捕获 ❌
try {
 setTimeout(() => {
   console.log(notdefined);
}, 0)
} catch(e) {
 console.log('捕获不到异常:', 'Uncaught ReferenceError');
}
复制代码

2) window.onerror

window.onerror 可以捕获常规错误、异步错误,但不能捕获资源错误

/**
* @param { string } message 错误信息
* @param { string } source 发生错误的脚本URL
* @param { number } lineno 发生错误的行号
* @param { number } colno 发生错误的列号
* @param { object } error Error对象
*/
window.onerror = function(message, source, lineno, colno, error) {
  console.log('捕获到的错误信息是:', message, source, lineno, colno, error )
}
复制代码

示例:

window.onerror = function(message, source, lineno, colno, error) {
console.log("捕获到的错误信息是:", message, source, lineno, colno, error);
};

// 示例1:常规运行时错误,可以捕获 ✅
console.log(notdefined);

// 示例2:语法错误,不能捕获 ❌
const notdefined;

// 示例3:异步错误,可以捕获 ✅
setTimeout(() => {
console.log(notdefined);
}, 0);

// 示例4:资源错误,不能捕获 ❌
let script = document.createElement("script");
script.type = "text/javascript";
script.src = "https://www.test.com/index.js";
document.body.appendChild(script);
复制代码

3) window.addEventListener

当静态资源加载失败时,会触发 error 事件, 此时 window.onerror 不能捕获到

示例:

<!DOCTYPE html>
<html lang="en">
<head>
 <meta charset="UTF-8">
</head>
<script>
window.addEventListener('error', (error) => {
  console.log('捕获到异常:', error);
}, true)
</script>

<!-- 图片、script、css加载错误,都能被捕获 ✅ -->
<img src="https://test.cn/×××.png">
<script src="https://test.cn/×××.js"></script>
<link href="https://test.cn/×××.css" rel="stylesheet" />

<script>
// new Image错误,不能捕获 ❌
// new Image运用的比较少,可以自己单独处理
new Image().src = 'https://test.cn/×××.png'
</script>
</html>
复制代码

4)Promise错误

Promise中抛出的错误,无法被 window.onerror、try/catch、 error 事件捕获到,可通过 unhandledrejection 事件来处理

示例:

try {
 new Promise((resolve, reject) => {
   JSON.parse("");
   resolve();
});
} catch (err) {
 // try/catch 不能捕获Promise中错误 ❌
 console.error("in try catch", err);
}

// error事件 不能捕获Promise中错误 ❌
window.addEventListener(
 "error",
 error => {
   console.log("捕获到异常:", error);
},
 true
);

// window.onerror 不能捕获Promise中错误 ❌
window.onerror = function(message, source, lineno, colno, error) {
 console.log("捕获到异常:", { message, source, lineno, colno, error });
};

// unhandledrejection 可以捕获Promise中的错误 ✅
window.addEventListener("unhandledrejection", function(e) {
 console.log("捕获到异常", e);
 // preventDefault阻止传播,不会在控制台打印
 e.preventDefault();
});

复制代码

Vue 错误

Vue项目中,window.onerror 和 error 事件不能捕获到常规的代码错误

异常代码:

export default {
 created() {
   let a = null;
   if(a.length > 1) {
       // ...
  }
}
};
复制代码

main.js中添加捕获代码:

window.addEventListener('error', (error) => {
 console.log('error', error);
});
window.onerror = function (msg, url, line, col, error) {
 console.log('onerror', msg, url, line, col, error);
};
复制代码

控制台会报错,但是 window.onerror 和 error 不能捕获到


vue 通过 Vue.config.errorHander 来捕获异常:

Vue.config.errorHandler = (err, vm, info) => {
   console.log('进来啦~', err);
}
复制代码

控制台打印:


errorHandler源码分析

src/core/util目录下,有一个error.js文件

function globalHandleError (err, vm, info) {
// 获取全局配置,判断是否设置处理函数,默认undefined
// 配置config.errorHandler方法
if (config.errorHandler) {
try {
// 执行 errorHandler
return config.errorHandler.call(null, err, vm, info)
} catch (e) {
// 如果开发者在errorHandler函数中,手动抛出同样错误信息throw err,判断err信息是否相等,避免log两次
if (e !== err) {
logError(e, null, 'config.errorHandler')
}
}
}
// 没有配置,常规输出
logError(err, vm, info)
}

function logError (err, vm, info) {
if (process.env.NODE_ENV !== 'production') {
warn(`Error in ${info}: "${err.toString()}"`, vm)
}
/* istanbul ignore else */
if ((inBrowser || inWeex) && typeof console !== 'undefined') {
console.error(err)
} else {
throw err
}
}
复制代码

通过源码明白了,vue 使用 try/catch 来捕获常规代码的报错,被捕获的错误会通过 console.error 输出而避免应用崩溃

可以在 Vue.config.errorHandler 中将捕获的错误上报

Vue.config.errorHandler = function (err, vm, info) { 
// handleError方法用来处理错误并上报
handleError(err);
}
复制代码

React 错误

从 react16 开始,官方提供了 ErrorBoundary 错误边界的功能,被该组件包裹的子组件,render 函数报错时会触发离当前组件最近父组件的ErrorBoundary

生产环境,一旦被 ErrorBoundary 捕获的错误,也不会触发全局的 window.onerror 和 error 事件

父组件代码:

import React from 'react';
import Child from './Child.js';

// window.onerror 不能捕获render函数的错误 ❌
window.onerror = function (err, msg, c, l) {
console.log('err', err, msg);
};

// error 不能render函数的错误 ❌
window.addEventListener( 'error', (error) => {
console.log('捕获到异常:', error);
},true
);

class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}

static getDerivedStateFromError(error) {
// 更新 state 使下一次渲染能够显示降级后的 UI
return { hasError: true };
}
componentDidCatch(error, errorInfo) {
// componentDidCatch 可以捕获render函数的错误
console.log(error, errorInfo)

// 同样可以将错误日志上报给服务器
reportError(error, errorInfo);
}
render() {
if (this.state.hasError) {
// 自定义降级后的 UI 并渲染
return <h1>Something went wrong.</h1>;
}
return this.props.children;
}
}

function Parent() {
return (
<div>
父组件
<ErrorBoundary>
<Child />
</ErrorBoundary>
</div>
);
}

export default Parent;
复制代码

子组件代码:

// 子组件 渲染出错
function Child() {
let list = {};
return (
<div>
子组件
{list.map((item, key) => (
<span key={key}>{item}</span>
))}
</div>
);
}
export default Child;
复制代码

同vue项目的处理类似,react项目中,可以在 componentDidCatch 中将捕获的错误上报

componentDidCatch(error, errorInfo) {
// handleError方法用来处理错误并上报
handleError(err);
}
复制代码

跨域问题

如果当前页面中,引入了其他域名的JS资源,如果资源出现错误,error 事件只会监测到一个 script error 的异常。

示例:

window.addEventListener("error", error => { 
console.log("捕获到异常:", error);
}, true );

// 当前页面加载其他域的资源,如https://www.test.com/index.js
<script src="https://www.test.com/index.js"></script>

// 加载的https://www.test.com/index.js的代码
function fn() {
JSON.parse("");
}
fn();
复制代码

报错信息:


只能捕获到 script error 的原因:

是由于浏览器基于安全考虑,故意隐藏了其它域JS文件抛出的具体错误信息,这样可以有效避免敏感信息无意中被第三方(不受控制的)脚本捕获到,因此,浏览器只允许同域下的脚本捕获具体的错误信息

解决方法:

前端script加crossorigin,后端配置 Access-Control-Allow-Origin

<script src="https://www.test.com/index.js" crossorigin></script>
复制代码

添加 crossorigin 后可以捕获到完整的报错信息:


如果不能修改服务端的请求头,可以考虑通过使用 try/catch 绕过,将错误抛出

<!doctype html>
<html>
<body>
<script src="https://www.test.com/index.js"></script>
<script>
window.addEventListener("error", error => {
console.log("捕获到异常:", error);
}, true );

try {
// 调用https://www.test.com/index.js中定义的fn方法
fn();
} catch (e) {
throw e;
}
</script>
</body>
</html>
复制代码

接口错误

接口监控的实现原理:针对浏览器内置的 XMLHttpRequest、fetch 对象,利用 AOP 切片编程重写该方法,实现对请求的接口拦截,从而获取接口报错的情况并上报

1)拦截XMLHttpRequest请求示例:

function xhrReplace() {
if (!("XMLHttpRequest" in window)) {
return;
}
const originalXhrProto = XMLHttpRequest.prototype;
// 重写XMLHttpRequest 原型上的open方法
replaceAop(originalXhrProto, "open", originalOpen => {
return function(...args) {
// 获取请求的信息
this._xhr = {
method: typeof args[0] === "string" ? args[0].toUpperCase() : args[0],
url: args[1],
startTime: new Date().getTime(),
type: "xhr"
};
// 执行原始的open方法
originalOpen.apply(this, args);
};
});
// 重写XMLHttpRequest 原型上的send方法
replaceAop(originalXhrProto, "send", originalSend => {
return function(...args) {
// 当请求结束时触发,无论请求成功还是失败都会触发
this.addEventListener("loadend", () => {
const { responseType, response, status } = this;
const endTime = new Date().getTime();
this._xhr.reqData = args[0];
this._xhr.status = status;
if (["", "json", "text"].indexOf(responseType) !== -1) {
this._xhr.responseText =
typeof response === "object" ? JSON.stringify(response) : response;
}
// 获取接口的请求时长
this._xhr.elapsedTime = endTime - this._xhr.startTime;

// 上报xhr接口数据
reportData(this._xhr);
});
// 执行原始的send方法
originalSend.apply(this, args);
};
});
}

/**
* 重写指定的方法
* @param { object } source 重写的对象
* @param { string } name 重写的属性
* @param { function } fn 拦截的函数
*/
function replaceAop(source, name, fn) {
if (source === undefined) return;
if (name in source) {
var original = source[name];
var wrapped = fn(original);
if (typeof wrapped === "function") {
source[name] = wrapped;
}
}
}
复制代码

2)拦截fetch请求示例:

function fetchReplace() {
if (!("fetch" in window)) {
return;
}
// 重写fetch方法
replaceAop(window, "fetch", originalFetch => {
return function(url, config) {
const sTime = new Date().getTime();
const method = (config && config.method) || "GET";
let handlerData = {
type: "fetch",
method,
reqData: config && config.body,
url
};

return originalFetch.apply(window, [url, config]).then(
res => {
// res.clone克隆,防止被标记已消费
const tempRes = res.clone();
const eTime = new Date().getTime();
handlerData = {
...handlerData,
elapsedTime: eTime - sTime,
status: tempRes.status
};
tempRes.text().then(data => {
handlerData.responseText = data;
// 上报fetch接口数据
reportData(handlerData);
});

// 返回原始的结果,外部继续使用then接收
return res;
},
err => {
const eTime = new Date().getTime();
handlerData = {
...handlerData,
elapsedTime: eTime - sTime,
status: 0
};
// 上报fetch接口数据
reportData(handlerData);
throw err;
}
);
};
});
}
复制代码

性能数据采集

谈到性能数据采集,就会提及加载过程模型图:


以Spa页面来说,页面的加载过程大致是这样的:


包括dns查询、建立tcp连接、发送http请求、返回html文档、html文档解析等阶段

最初,可以通过 window.performance.timing 来获取加载过程模型中各个阶段的耗时数据

// window.performance.timing 各字段说明
{
navigationStart, // 同一个浏览器上下文中,上一个文档结束时的时间戳。如果没有上一个文档,这个值会和 fetchStart 相同。
unloadEventStart, // 上一个文档 unload 事件触发时的时间戳。如果没有上一个文档,为 0。
unloadEventEnd, // 上一个文档 unload 事件结束时的时间戳。如果没有上一个文档,为 0。
redirectStart, // 表示第一个 http 重定向开始时的时间戳。如果没有重定向或者有一个非同源的重定向,为 0。
redirectEnd, // 表示最后一个 http 重定向结束时的时间戳。如果没有重定向或者有一个非同源的重定向,为 0。
fetchStart, // 表示浏览器准备好使用 http 请求来获取文档的时间戳。这个时间点会在检查任何缓存之前。
domainLookupStart, // 域名查询开始的时间戳。如果使用了持久连接或者本地有缓存,这个值会和 fetchStart 相同。
domainLookupEnd, // 域名查询结束的时间戳。如果使用了持久连接或者本地有缓存,这个值会和 fetchStart 相同。
connectStart, // http 请求向服务器发送连接请求时的时间戳。如果使用了持久连接,这个值会和 fetchStart 相同。
connectEnd, // 浏览器和服务器之前建立连接的时间戳,所有握手和认证过程全部结束。如果使用了持久连接,这个值会和 fetchStart 相同。
secureConnectionStart, // 浏览器与服务器开始安全链接的握手时的时间戳。如果当前网页不要求安全连接,返回 0。
requestStart, // 浏览器向服务器发起 http 请求(或者读取本地缓存)时的时间戳,即获取 html 文档。
responseStart, // 浏览器从服务器接收到第一个字节时的时间戳。
responseEnd, // 浏览器从服务器接受到最后一个字节时的时间戳。
domLoading, // dom 结构开始解析的时间戳,document.readyState 的值为 loading。
domInteractive, // dom 结构解析结束,开始加载内嵌资源的时间戳,document.readyState 的状态为 interactive。
domContentLoadedEventStart, // DOMContentLoaded 事件触发时的时间戳,所有需要执行的脚本执行完毕。
domContentLoadedEventEnd, // DOMContentLoaded 事件结束时的时间戳
domComplete, // dom 文档完成解析的时间戳, document.readyState 的值为 complete。
loadEventStart, // load 事件触发的时间。
loadEventEnd // load 时间结束时的时间。
}
复制代码

后来 window.performance.timing 被废弃,通过 PerformanceObserver 来获取。旧的 api,返回的是一个 UNIX 类型的绝对时间,和用户的系统时间相关,分析的时候需要再次计算。而新的 api,返回的是一个相对时间,可以直接用来分析

现在 chrome 开发团队提供了 web-vitals 库,方便来计算各性能数据

用户行为数据采集

用户行为包括:页面路由变化、鼠标点击、资源加载、接口调用、代码报错等行为

设计思路

1、通过Breadcrumb类来创建用户行为的对象,来存储和管理所有的用户行为

2、通过重写或添加相应的事件,完成用户行为数据的采集

用户行为代码示例:

// 创建用户行为类
class Breadcrumb {
// maxBreadcrumbs控制上报用户行为的最大条数
maxBreadcrumbs = 20;
// stack 存储用户行为
stack = [];
constructor() {}
// 添加用户行为栈
push(data) {
if (this.stack.length >= this.maxBreadcrumbs) {
// 超出则删除第一条
this.stack.shift();
}
this.stack.push(data);
// 按照时间排序
this.stack.sort((a, b) => a.time - b.time);
}
}

let breadcrumb = new Breadcrumb();

// 添加一条页面跳转的行为,从home页面跳转到about页面
breadcrumb.push({
type: "Route",
form: '/home',
to: '/about'
url: "http://localhost:3000/index.html",
time: "1668759320435"
});

// 添加一条用户点击行为
breadcrumb.push({
type: "Click",
dom: "<button id='btn'>按钮</button>",
time: "1668759620485"
});

// 添加一条调用接口行为
breadcrumb.push({
type: "Xhr",
url: "http://10.105.10.12/monitor/open/pushData",
time: "1668760485550"
});

// 上报用户行为
reportData({
uuid: "a6481683-6d2e-4bd8-bba1-64819d8cce8c",
stack: breadcrumb.getStack()
});
复制代码

页面跳转

通过监听路由的变化来判断页面跳转,路由有history、hash两种模式,history模式可以监听popstate事件,hash模式通过重写 pushState和 replaceState事件

vue项目中不能通过 hashchange 事件来监听路由变化,vue-router 底层调用的是 history.pushStatehistory.replaceState,不会触发 hashchange

vue-router源码:

function pushState (url, replace) {
saveScrollPosition();
var history = window.history;
try {
if (replace) {
history.replaceState({ key: _key }, '', url);
} else {
_key = genKey();
history.pushState({ key: _key }, '', url);
}
} catch (e) {
window.location[replace ? 'replace' : 'assign'](url);
}
}
...

// this.$router.push时触发
function pushHash (path) {
if (supportsPushState) {
pushState(getUrl(path));
} else {
window.location.hash = path;
}
}
复制代码

通过重写 pushState、replaceState 事件来监听路由变化

// lastHref 前一个页面的路由
let lastHref = document.location.href;
function historyReplace() {
function historyReplaceFn(originalHistoryFn) {
return function(...args) {
const url = args.length > 2 ? args[2] : undefined;
if (url) {
const from = lastHref;
const to = String(url);
lastHref = to;
// 上报路由变化
reportData("routeChange", {
from,
to
});
}
return originalHistoryFn.apply(this, args);
};
}
// 重写pushState事件
replaceAop(window.history, "pushState", historyReplaceFn);
// 重写replaceState事件
replaceAop(window.history, "replaceState", historyReplaceFn);
}

function replaceAop(source, name, fn) {
if (source === undefined) return;
if (name in source) {
var original = source[name];
var wrapped = fn(original);
if (typeof wrapped === "function") {
source[name] = wrapped;
}
}
}
复制代码

用户点击

给 document 对象添加click事件,并上报

function domReplace() {
document.addEventListener("click",({ target }) => {
const tagName = target.tagName.toLowerCase();
if (tagName === "body") {
return null;
}
let classNames = target.classList.value;
classNames = classNames !== "" ? `` : "";
const id = target.id ? ` id="${target.id}"` : "";
const innerText = target.innerText;
// 获取包含id、class、innerTextde字符串的标签
let dom = `<${tagName}${id}${
classNames !== "" ? classNames : ""
}>${innerText}</${tagName}>`;
// 上报
reportData({
type: 'Click',
dom
});
},
true
);
}
复制代码

资源加载

获取页面中加载的资源信息,比如它们的 url 是什么、加载了多久、是否来自缓存等

可以通过 performance.getEntriesByType('resource') 获取,包括静态资源和动态资源,同时可以结合 initiatorType 字段来判断资源类型,对资源进行过滤

其中 PerformanceResourceTiming 来分析资源加载的详细数据


获取资源加载时长为 duration 字段,即 responseEnd 与 startTime 的差值

获取加载资源列表:


一个真实的页面中,资源加载大多数是逐步进行的,有些资源本身就做了延迟加载,有些是需要用户发生交互后才会去请求一些资源

如果我们只关注首页资源,可以在 window.onload 事件中去收集

如果要收集所有的资源,需要通过定时器反复地去收集,并且在一轮收集结束后,通过调用 clearResourceTimings 将 performance entries 里的信息清空,避免在下一轮收集时取到重复的资源

个性化指标

long task

执行时间超过50ms的任务,被称为 long task 长任务

获取页面的长任务列表:

const entryHandler = list => {
for (const long of list.getEntries()) {
// 获取长任务详情
console.log(long);
}
};

let observer = new PerformanceObserver(entryHandler);
observer.observe({ entryTypes: ["longtask"] });
复制代码

memory页面内存

performance.memory 可以显示此刻内存占用情况,它是一个动态值,其中:

  • jsHeapSizeLimit 该属性代表的含义是:内存大小的限制。

  • totalJSHeapSize 表示总内存的大小。

  • usedJSHeapSize 表示可使用的内存的大小。

通常,usedJSHeapSize 不能大于 totalJSHeapSize,如果大于,有可能出现了内存泄漏

// load事件中获取此时页面的内存大小
window.addEventListener("load", () => {
console.log("memory", performance.memory);
});
复制代码

首屏加载时间

首屏加载时间和首页加载时间不一样,首屏指的是屏幕内的dom渲染完成的时间

比如首页很长需要好几屏展示,这种情况下屏幕以外的元素不考虑在内

计算首屏加载时间流程

1)利用MutationObserver监听document对象,每当dom变化时触发该事件

2)判断监听的dom是否在首屏内,如果在首屏内,将该dom放到指定的数组中,记录下当前dom变化的时间点

3)在MutationObserver的callback函数中,通过防抖函数,监听document.readyState状态的变化

4)当document.readyState === 'complete',停止定时器和 取消对document的监听

5)遍历存放dom的数组,找出最后变化节点的时间,用该时间点减去performance.timing.navigationStart 得出首屏的加载时间

监控SDK

监控SDK的作用:数据采集与上报

整体架构


整体架构使用 发布-订阅 设计模式,这样设计的好处是便于后续扩展与维护,如果想添加新的hook或事件,在该回调中添加对应的函数即可

SDK 入口

src/index.js

对外导出init事件,配置了vue、react项目的不同引入方式

vue项目在Vue.config.errorHandler中上报错误,react项目在ErrorBoundary中上报错误


事件发布与订阅

通过添加监听事件来捕获错误,利用 AOP 切片编程,重写接口请求、路由监听等功能,从而获取对应的数据

src/load.js


用户行为收集

core/breadcrumb.js

创建用户行为类,stack用来存储用户行为,当长度超过限制时,最早的一条数据会被覆盖掉,在上报错误时,对应的用户行为会添加到该错误信息中


数据上报方式

支持图片打点上报和fetch请求上报两种方式

图片打点上报的优势:
1)支持跨域,一般而言,上报域名都不是当前域名,上报的接口请求会构成跨域
2)体积小且不需要插入dom中
3)不需要等待服务器返回数据

图片打点缺点是:url受浏览器长度限制

core/transportData.js


数据上报时机

优先使用 requestIdleCallback,利用浏览器空闲时间上报,其次使用微任务上报


监控SDK,参考了 sentrymonitormitojs

项目后台demo

主要用来演示错误还原功能,方式包括:定位源码、播放录屏、记录用户行为


后台demo功能介绍:

1、使用 express 开启静态服务器,模拟线上环境,用于实现定位源码的功能

2、server.js 中实现了 reportData(错误上报)、getmap(获取 map 文件)、getRecordScreenId(获取录屏信息)、 getErrorList(获取错误列表)的接口

3、用户可点击 'js 报错'、'异步报错'、'promise 错误' 按钮,上报对应的代码错误,后台实现错误还原功能

4、点击 'xhr 请求报错'、'fetch 请求报错' 按钮,上报接口报错信息

5、点击 '加载资源报错' 按钮,上报对应的资源报错信息

通过这些异步的捕获,了解监控平台的整体流程

安装与使用

npm官网搜索 web-see


仓库地址

监控SDK: web-see

监控后台: web-see-demo

总结

目前市面上的前端监控方案可谓是百花齐放,但底层原理都是相通的。从基础的理论知识到实现一个可用的监控平台,收获还是挺多的

有兴趣的小伙伴可以结合git仓库的源码玩一玩,再结合本文一起阅读,帮助加深理解

作者:海阔_天空
来源:juejin.cn/post/7172072612430872584

收起阅读 »

Flutter App开发黑白化UI实现方案ColorFiltered

一、相信大家对App黑白化并不陌生,经常可以看到大厂的App在一定的时候会呈现黑白样式如下: 这种效果在原生开发上大家肯定或多或少都了解过,原理都是在根布局绘制的时候将画笔饱和度设置成0;具体实现大家可以搜一搜这里就不贴了。 二、下面就来说说在Flutte...
继续阅读 »

一、相信大家对App黑白化并不陌生,经常可以看到大厂的App在一定的时候会呈现黑白样式如下:



这种效果在原生开发上大家肯定或多或少都了解过,原理都是在根布局绘制的时候将画笔饱和度设置成0;具体实现大家可以搜一搜这里就不贴了。


二、下面就来说说在Flutter这一侧需要怎么实现



  • 原理和原生还是一样都是将饱和度设置成0,不过在Flutter这实现起来会比在原生更加的简单。

  • Flutter直接为我们提供了ColorFiltered组件(以Color作为源的混合模式Widget)。

  • 只需要将ColorFiltered做为根组件(包裹MaterialApp)即可改变整个应用的颜色模式。


实现的最终代码如下


class SaturationWidget extends StatelessWidget {
final Widget child;

///value [0,1]
final double saturation;

const SaturationWidget({
required this.child,
this.saturation = 0,
Key? key,
}) : super(key: key);

@override
Widget build(BuildContext context) {
return ColorFiltered(
colorFilter: ColorFilter.matrix(_saturation(saturation)),
child: child,
);
}

///Default matrix
List<double> get _matrix => [
1, 0, 0, 0, 0, //R
0, 1, 0, 0, 0, //G
0, 0, 1, 0, 0, //B
0, 0, 0, 1, 0, //A
];

///Generate a matrix of specified saturation
///[sat] A value of 0 maps the color to gray-scale. 1 is identity.
List<double> _saturation(double sat) {
final m = _matrix;
final double invSat = 1 - sat;
final double R = 0.213 * invSat;
final double G = 0.715 * invSat;
final double B = 0.072 * invSat;
m[0] = R + sat;
m[1] = G;
m[2] = B;
m[5] = R;
m[6] = G + sat;
m[7] = B;
m[10] = R;
m[11] = G;
m[12] = B + sat;
return m;
}
}


  • 通过4x5的R、G、B、A、颜色矩阵来生成一个colorFilter

  • 最终通过饱和度的值来计算颜色矩阵(饱和度计算算法从Android原生copy过来的)这样就轻松实现了整个App的黑白化(不过iOS的webview是不支持的)


三、最后来看下实现的效果



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

Kotlin协程之一文看懂Channel管道

概述 Channel 类似于 Java 的 BlockingQueue 阻塞队列,不同之处在于 Channel 提供了挂起的 send() 和 receive() 方法。另外,通道 Channel 可以被关闭表明不再有数据会进入 Channel, 而接收端可以...
继续阅读 »

概述


Channel 类似于 Java 的 BlockingQueue 阻塞队列,不同之处在于 Channel 提供了挂起的 send() 和 receive() 方法。另外,通道 Channel 可以被关闭表明不再有数据会进入 Channel, 而接收端可以通过 for 循环取出数据。


Channel 也是生产-消费者模式,这个设计模式在协程中很常见。


基本使用


val channel = Channel<Int>()

// 发送
launch {
repeat(10) {
channel.send(it)
delay(200)
}
// 关闭
channel.close()
}

// 接收
launch {
for (i in channel) {
println("receive: $i")
}
// 关闭后
println("closed")
}

produce 和 actor


produce 和 actor 是 Kotlin 提供的构造生产者与消费者的便捷方法。


其中 produce 方法用来启动一个生产者协程,并返回一个 ReceiveChannel 在其他协程中接收数据:


// produce 生产协程
val receiveChannel = CoroutineScope(Dispatchers.IO).produce {
repeat(10) {
send(it)
delay(200)
}
}

// 接收者 1
launch {
for (i in receiveChannel) {
println("receive-1: $i")
}
}

// 接收者 2
launch {
for (i in receiveChannel) {
println("receive-2: $i")
}
}

输出:


2022-11-29 10:48:03.045 I/System.out: receive-1: 0
2022-11-29 10:48:03.250 I/System.out: receive-1: 1
2022-11-29 10:48:03.451 I/System.out: receive-2: 2
2022-11-29 10:48:03.654 I/System.out: receive-1: 3
2022-11-29 10:48:03.856 I/System.out: receive-2: 4
2022-11-29 10:48:04.059 I/System.out: receive-1: 5
2022-11-29 10:48:04.262 I/System.out: receive-2: 6
2022-11-29 10:48:04.466 I/System.out: receive-1: 7
2022-11-29 10:48:04.669 I/System.out: receive-2: 8
2022-11-29 10:48:04.871 I/System.out: receive-1: 9

反之也可以用 actor 来启动一个消费协程:


// actor 消费协程
val sendChannel = CoroutineScope(Dispatchers.IO).actor<Int> {
while (true) {
println("receive: ${receive()}")
}
}

// 发送者 1
launch {
repeat(10) {
sendChannel.send(it)
delay(200)
}
}

// 发送者 2
launch {
repeat(10) {
sendChannel.send(it * it)
delay(200)
}
}

可以看出 produce 创建的是一个单生产者——多消费者的模型,而 actor 创建的是一个单消费者--多生产者的模型



不过这些相关的 API 要不就是 ExperimentalCoroutinesApi 实验性标记的,要不就是 ObsoleteCoroutinesApi 废弃标记的,个人感觉暂时没必要使用它们。



Channel 是公平的


发送和接收操作是公平的,它们遵守先进先出原则。官方也给了一个例子:


data class Ball(var hits: Int)

fun main() = runBlocking {
val table = Channel<Ball>() // 一个共享的 table(桌子)
launch { player("ping", table) }
launch { player("pong", table) }
table.send(Ball(0)) // 率先打出第一个球
delay(1000) // 延迟 1 秒钟
coroutineContext.cancelChildren() // 游戏结束,取消它们
}

suspend fun player(name: String, table: Channel<Ball>) {
for (ball in table) { // 在循环中接收球
ball.hits++
println("$name $ball")
delay(300) // 等待一段时间
table.send(ball) // 将球发送回去
}
}

由于 ping 协程首先被启动,所以它首先接收到了球,接着即使 ping 协程在将球发送后会立即开始接收,但是球还是被 pong 协程接收了,因为它一直在等待着接收球:


ping Ball(hits=1)
pong Ball(hits=2)
ping Ball(hits=3)
pong Ball(hits=4)

带缓冲的 Channel


前面已经说过 Channel 实际上是一个队列,那它当然也存在一个缓存区以及缓存满后的策略(处理背压之类的问题),在创建 Channel 时可以指定两个相关的参数:


public fun <E> Channel(
capacity: Int = RENDEZVOUS,
onBufferOverflow: BufferOverflow = BufferOverflow.SUSPEND,
onUndeliveredElement: ((E) -> Unit)? = null
): Channel<E>

这里的 Channel() 其实并不是构造函数,而是一个顶层函数,它内部会根据不同的入参来创建不同类型的 Channel 实例。其参数含义如下:



  • capacity: Channel 缓存区的容量,默认为 RENDEZVOUS = 0

  • onBufferOverflow: 缓冲区满后发送端的处理策略,默认挂起。当消费者处理数据比生产者生产数据慢时,新生产的数据会存入缓存区,当缓存区满后,生产者再调用 send() 方法会挂起,等待消费者处理数据。


看个小栗子:


// 创建缓存区大小为 4 的 Channel
val channel = Channel<Int>(4)

// 发送
launch {
repeat(10) {
channel.send(it)
println("send: $it")
delay(200)
}
}

// 接收
launch {
val channel = viewModel.channel
for (i in channel) {
println("receive: $i")
delay(1000)
}
}

输出结果:


2022-11-28 17:16:47.905 I/System.out: send: 0
2022-11-28 17:16:47.907 I/System.out: receive: 0
2022-11-28 17:16:48.107 I/System.out: send: 1
2022-11-28 17:16:48.310 I/System.out: send: 2
2022-11-28 17:16:48.512 I/System.out: send: 3
2022-11-28 17:16:48.715 I/System.out: send: 4
2022-11-28 17:16:48.910 I/System.out: receive: 1
2022-11-28 17:16:48.916 I/System.out: send: 5 // 缓存区满了, receive 后才能继续发送
2022-11-28 17:16:49.913 I/System.out: receive: 2
2022-11-28 17:16:49.914 I/System.out: send: 6
2022-11-28 17:16:50.917 I/System.out: receive: 3
2022-11-28 17:16:50.917 I/System.out: send: 7
2022-11-28 17:16:51.920 I/System.out: receive: 4
2022-11-28 17:16:51.920 I/System.out: send: 8
2022-11-28 17:16:52.923 I/System.out: receive: 5
2022-11-28 17:16:52.923 I/System.out: send: 9
2022-11-28 17:16:53.925 I/System.out: receive: 6
2022-11-28 17:16:54.928 I/System.out: receive: 7
2022-11-28 17:16:55.932 I/System.out: receive: 8
2022-11-28 17:16:56.935 I/System.out: receive: 9

Channel 构造类型


这一节来简单看看 Channel 构造的几种类型,为防止内容过于枯燥,就不深入剖析一些源码细节了。


Channel 构造


public fun <E> Channel(
capacity: Int = RENDEZVOUS,
onBufferOverflow: BufferOverflow = BufferOverflow.SUSPEND,
onUndeliveredElement: ((E) -> Unit)? = null
): Channel<E> =
when (capacity) {
RENDEZVOUS -> {
if (onBufferOverflow == BufferOverflow.SUSPEND)
RendezvousChannel(onUndeliveredElement)
else
ArrayChannel(1, onBufferOverflow, onUndeliveredElement)
}
CONFLATED -> {
require(onBufferOverflow == BufferOverflow.SUSPEND) {
"CONFLATED capacity cannot be used with non-default onBufferOverflow"
}
ConflatedChannel(onUndeliveredElement)
}
UNLIMITED -> LinkedListChannel(onUndeliveredElement) // ignores onBufferOverflow: it has buffer, but it never overflows
BUFFERED -> ArrayChannel( // uses default capacity with SUSPEND
if (onBufferOverflow == BufferOverflow.SUSPEND) CHANNEL_DEFAULT_CAPACITY else 1,
onBufferOverflow, onUndeliveredElement
)
else -> {
if (capacity == 1 && onBufferOverflow == BufferOverflow.DROP_OLDEST)
ConflatedChannel(onUndeliveredElement)
else
ArrayChannel(capacity, onBufferOverflow, onUndeliveredElement)
}
}

前面我们说了 Channel() 并不是构造函数,而是一个顶层函数,它内部会根据不同的入参来创建不同类型的 Channel 实例。我们看看入参可取的值:


public const val UNLIMITED: Int = Int.MAX_VALUE
public const val RENDEZVOUS: Int = 0
public const val CONFLATED: Int = -1
public const val BUFFERED: Int = -2

public enum class BufferOverflow {
SUSPEND, DROP_OLDEST, DROP_LATEST
}

其实光看这个构造的过程,以及两个入参的取值,我们基本上就能知道生成的这个 Channel 实例的表现了。


比如说 UNLIMITED 表示缓存区无限大的管道,它所创建的 Channel 叫 LinkedListChannel; 而 BUFFERED 或指定 capacity 大小的入参,创建的则是 ArrayChannel 实例,这也正是命名为 LinkedList(链表) 和 Array(数组) 的数据结构一个区别,前者可以视为无限大,后者有固定的容量大小。


比如说 SUSPEND 表示缓存区满后挂起, DROP_OLDEST 表示缓存区满后会删除缓存区里最旧的那个元素且把当前 send 的数据存入缓存区, DROP_LATEST 表示缓存区满后会删除缓存区里最新的那个元素且把当前 send 的数据存入缓存区。


Channel 类型


上面创建的这四种 Channel 都有一个共同的基类——AbstractChannel,简单看看他们的继承关系:


Channel类图.png


在 AbstractSendChannel 中有个重要的成员变量:


protected val queue = LockFreeLinkedListHead()

它是一个循环双向链表,形成了一个队列 queue 结构,send() 数据时存入链表尾部,receive() 数据时就从链表头第一个节点取。至于具体的挂起,恢复等流程,感兴趣的可以自己看看源码。


值得一提的是, queue 中的节点类型可以大体分为三种:



  • Send

  • Receive

  • Closed: 当调用 Channel.close() 方法时,会往 queue 队列中加入 Closed 节点,这样当 send or receive 时就知道 Channel 已经关闭了。


另外,对于 ArrayChannel 管道,它有一个成员变量:


private var buffer: Array<Any?> = arrayOfNulls<Any?>(min(capacity, 8)).apply { fill(EMPTY) }

这是一个数组类型,用来实现指定 capacity 的缓存区。但是它的初始大小不是 capacity, 主要是用来防止一些不必要的内存分配。


总结


Channel 类似于 BlockingQueue 阻塞队列,其不同之处是默认把阻塞行为换成了挂起,这也是协程的一大特性。它的思想是生产-消费模式(观察者模式)。


简单比较一下四种 Channel 类型:



  • RendezvousChannel: 翻译成约会类型,缓存区大小为0,且指定为 SUSPEND 挂起策略。发送者和接收者一对一出现,接收者没出现,则发送者 send 会被挂起;发送者没出现,则接收者 receive 会被挂起。

  • ConflatedChannel: 混合类型。发送者不会挂起,它只有一个 value 值,会被新的值覆盖掉;如果没有数据,则接收者会被挂起。

  • LinkedListChannel: 不限缓存区大小的类型。发送者不会挂起,能一直往队列里存数据;队列无数据时接收者会被挂起。

  • ArrayChannel: 指定缓存区大小的类型。当缓存区满时,发送者根据 BufferOverflow 策略来处理(是否挂起);当缓存区空时,接收者会被挂起。

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

如何启动协程

1.launch启动协程 fun main() = runBlocking { launch { delay(1000L) println("World!") } println("Hello") ...
继续阅读 »

1.launch启动协程


fun main() = runBlocking {
launch {
delay(1000L)
println("World!")
}
println("Hello")
}

fun main() {
GlobalScope.launch {
delay(1000L)
println("World!")
}

println("Hello")
Thread.sleep(2000L)
}

//输出结果
//Hello
//World!

上面是两段代码,这两段代码都是通过launch启动了一个协程并且输出结果也是一样的。


第一段代码中的runBlocking是协程的另一种启动方式,这里先看第二段代码中的launch的启动方式;



  • GlobalScope.launch


GlobalScope.launch是一个扩展函数,接收者是CoroutineScope,意思就是协程作用域,这里的launch等价于CoroutineScope的成员方法,如果要调用launch来启动一个协程就必须先拿到CoroutineScope对象。GlobalScope.launch源码如下


public fun CoroutineScope.launch(
context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> Unit
): Job {
val newContext = newCoroutineContext(context)
val coroutine = if (start.isLazy)
LazyStandaloneCoroutine(newContext, block) else
StandaloneCoroutine(newContext, active = true)
coroutine.start(start, coroutine, block)
return coroutine
}

里面有三个参数:



  • context: 意思是上下文,默认是EmptyCoroutineContext,有默认值就可以不传,但是也可以传递Kotlin提供的Dispatchers来指定协程运行在哪一个线程中;

  • start: CoroutineStart代表了协程的启动模式,不传则默认使用DEFAULT(根据上下文立即调度协程执行),除DEFAULT外还有其他类型:






    • LAZY:延迟启动协程,只在需要时才启动。

    • ATOMIC:以一种不可取消的方式,根据其上下文安排执行的协程;

    • UNDISPATCHED:立即执行协程,直到它在当前线程中的第一个挂起点;






  • block: suspend是挂起的意思,CoroutineScope.()是一个扩展函数,Unit是一个函数类似于Java的void,那么suspend CoroutineScope.() -> Unit就可以这么理解了:首先,它是一个挂起函数,然后它还是CoroutineScope类的成员或者扩展函数,参数为空,返回值类型为Unit




  • delay(): delay()方法从字面理解就是延迟的意思,在上面的代码中延迟了1秒再执行World,从源码可以看出来它跟其他方法不一样,多了一个suspend关键字


//      挂起
// ↓
public suspend fun delay(timeMillis: Long) {
if (timeMillis <= 0) return // don't delay
return suspendCancellableCoroutine sc@ { cont: CancellableContinuation<Unit> ->
// if timeMillis == Long.MAX_VALUE then just wait forever like awaitCancellation, don't schedule.
if (timeMillis < Long.MAX_VALUE) {
cont.context.delay.scheduleResumeAfterDelay(timeMillis, cont)
}
}
}

suspend的意思就是挂起,被它修饰的函数就是挂起函数, 这也就意味着delay()方法具有挂起和恢复的能力;



  • Thread.sleep(2000L)


这个是休眠2秒,那么这里为什么要有这个呢?要解答这疑问其实不难,将Thread.sleep(2000L)删除后在运行代码可以发现只打印了Hello然后程序就结束了,World!并没有被打印出来。


为什么? 将上面的代码转换成线程实现如下:


fun main() {
thread(isDaemon = true) {
Thread.sleep(1000L)
println("Hello World!")
}
}

如果不添加isDaemon = true结果输出正常,如果加了那么就没有结果输出。isDaemon的加入后其实是创建了一个【守护线程】,这就意味着主线程结束的时候它会跟着被销毁,所以对于将Thread.sleep删除后导致GlobalScope创建的协程不能正常运行的主要原因就是通过launch创建的协程还没开始执行程序就结束了。那么Thread.sleep(2000L)的作用就是为了不让主线程退出。


另外这里还有一点需要注意:程序的执行过程并不是按照顺序执行的。


fun main() {
GlobalScope.launch { // 1
println("Launch started!") // 2
delay(1000L) // 3
println("World!") // 4
}

println("Hello") // 5
Thread.sleep(2000L) // 6
println("Process end!") // 7
}

/*
输出结果:
Hello
Launch started!
World!
Process end!
*/

上面的代码执行顺序是1、5、6、2、3、4、7,这个其实好理解,首先执行1,然后再执行5,执行6的时候等待2秒,在这个等待过程中协程创建完毕了开始执行2、3、4都可以执行了,当2、3、4执行完毕后等待6执行完毕,最后执行7,程序结束。


2.runBlocking启动协程


fun main() {
runBlocking { // 1
println("launch started!") // 2
delay(1000L) // 3
println("World!") // 4
}

println("Hello") // 5
Thread.sleep(2000L) // 6
println("Process end!") // 7
}

上面这段代码只是将GlobalScope.launch改成了runBlocking,但是执行顺序却完全不一样,它的执行顺讯为代码顺序1~7,这是因为runBlocking是带有阻塞属性的,它会阻塞当前线程的执行。这是它跟launch的最大差异。


runBlockinglanuch的另外一个差异是GlobalScope,从代码中可以看出runBlocking并不需要这个,这点可以从源码中分析


public actual fun <T> runBlocking(
context: CoroutineContext,
block: suspend CoroutineScope.() -> T): T {
...
}

顶层函数:类似于Java中的静态函数,在Java中常用与工具类,例如StringUtils.lastElement();


runBlocking是一个顶层函数,因此可以直接使用它;在它的第二个参数block中有一个返回值类型:T,它刚好跟runBlocking的返回值类型是一样的,因此可以推测出runBlocking是可以有返回值的


fun main() {
val result = test(1)
println("result:$result")
}

fun test(num: Int) = runBlocking {
return@runBlocking num.toString()
}

//输出结果:
//result:1

但是,Kotlin在文档中注明了这个函数不应该从协程中使用。它的设计目的是将常规的阻塞代码与以挂起风格编写的库连接起来,以便在主函数和测试中使用。 因此在正式环境中这种方式最好不用。


3.async启动协程


在 Kotlin 当中,可以使用 async{} 创建协程,并且还能通过它返回的句柄拿到协程的执行结果。


fun main() = runBlocking {
val deferred = async {
1 + 1
}

println("result:${deferred.await()}")
}

//输出结果:
//result:2

上面的代码启动了两个协程,启动方式是runBlockingasync,因为async的调用需要一个作用域,而runBlocking恰好满足这个条件,GlobalScope.launch也可以满足这个条件但是GlobalScope也不建议在生产环境中使用,因为GlobalScope 创建的协程没有父协程,GlobalScope 通常也不与任何生命周期组件绑定。除非手动管理,否则很难满足我们实际开发中的需求。


上面的代码多了一个deferred.await()它就是获取最终结果的关键。


public fun <T> CoroutineScope.async(
context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> T
): Deferred<T> {
val newContext = newCoroutineContext(context)
val coroutine = if (start.isLazy)
LazyDeferredCoroutine(newContext, block) else
DeferredCoroutine<T>(newContext, active = true)
coroutine.start(start, coroutine, block)
return coroutine
}

asynclaunch一样也是一个扩展函数,也有三个参数,和launch的区别在于两点:



  • block的函数类型: launch返回的是Unit类型,async返回的是泛型T

  • 返回值不同: launch返回的是Jobasync返回的是Deffered<T>,而async可以返回执行结果的关键就在这里。


启动协程的三种方式都讲完了,这里存在一个疑问,launchasync都有返回值,为什么async可以获取执行结果,launch却不行?


这主要跟launch的返回值有关,launch的返回值Job代表的是协程的句柄,而句柄并不能返回协程的执行结果。


句柄: 句柄指的是中间媒介,通过这个中间媒介可以控制、操作某样东西。举个例子,door handle 是指门把手,通过门把手可以去控制门,但 door handle 并非 door 本身,只是一个中间媒介。又比如 knife handle 是刀柄,通过刀柄可以使用刀。


协程的三中启动方式区别如下:



  • launch:无法获取执行结果,返回类型Job,不会阻塞;

  • async:可获取执行结果,返回类型Deferred,调用await()会阻塞不调用则不会但也无法获取执行结果;

  • runBlocking:可获取执行结果,阻塞当前线程的执行,多用于Demo、测试,官方推荐只用于连接线程与协程。



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

Android-多套环境的维护

记录一下项目中多套环境维护的一种思路。 一、多套环境要注意的问题 1、方便使用灵活配置 2、配置安全不会被覆写 3、扩展灵活 4、安装包可动态切换环境,方便测试人员使用 二、解决思路 1、Android中的Properties文件是只读的,打包后不可修改,所以...
继续阅读 »

记录一下项目中多套环境维护的一种思路。


一、多套环境要注意的问题


1、方便使用灵活配置

2、配置安全不会被覆写

3、扩展灵活

4、安装包可动态切换环境,方便测试人员使用


二、解决思路


1、Android中的Properties文件是只读的,打包后不可修改,所以用Properties文件维护所有的配置。

2、在一个安装包内动态切换环境,方便测试人员切换使用,这一点用MMKV来动态存储。为了防止打包时可能出现的错误,这一点也需要Properties文件来控制。


三、Properties文件的封装


package com.abc.kotlinstudio

import android.content.Context
import java.io.IOException
import java.util.*


object PropertiesUtil {

private var pros: Properties? = null

fun init(c: Context) {
pros = Properties()
try {
val input = c.assets.open("appConfig.properties")
pros?.load(input)
} catch (e: IOException) {
e.printStackTrace()
}
}

private fun getProperty(key: String, default: String): String {
return pros?.getProperty(key, default) ?: default
}

/**
* 判断是否是国内版本
*/
fun isCN(): Boolean {
return getProperty("isCN", "true").toBoolean()

}

/**
* 判断是否是正式环境
*/
fun isRelease(): Boolean {
return getProperty("isRelease", "false").toBoolean()
}

/**
* 获取版本的环境 dev test release
* 如果isRelease为true就读Properties文件,为false就读MMKV存储的值
*/
fun getEnvironment(): Int = if (isRelease()) {
when (getProperty("environment", "test")) {
"dev" -> {
GlobalUrlConfig.EnvironmentConfig.DEV.value
}
"test" -> {
GlobalUrlConfig.EnvironmentConfig.TEST.value
}
"release" -> {
GlobalUrlConfig.EnvironmentConfig.RELEASE.value
}
else -> {
GlobalUrlConfig.EnvironmentConfig.TEST.value
}
}

} else {
when (CacheUtil.getEnvironment(getProperty("environment", "test"))) {
"dev" -> {
GlobalUrlConfig.EnvironmentConfig.DEV.value
}
"test" -> {
GlobalUrlConfig.EnvironmentConfig.TEST.value
}
"release" -> {
GlobalUrlConfig.EnvironmentConfig.RELEASE.value
}

else -> {
GlobalUrlConfig.EnvironmentConfig.TEST.value
}
}
}


/**
* 获取国内外环境
*/
fun getCN(): Int = if (isRelease()) {
when (getProperty("isCN", "true")) {
"true" -> {
GlobalUrlConfig.CNConfig.CN.value
}
"false" -> {
GlobalUrlConfig.CNConfig.I18N.value
}

else -> {
GlobalUrlConfig.CNConfig.CN.value
}
}

} else {
when (CacheUtil.getCN(getProperty("isCN", "true"))) {
"true" -> {
GlobalUrlConfig.CNConfig.CN.value
}
"false" -> {
GlobalUrlConfig.CNConfig.I18N.value
}

else -> {
GlobalUrlConfig.CNConfig.CN.value
}
}
}


}

注意二点,打包时如果Properties文件isRelease为true则所有配置都读Properties文件,如果为false就读MMKV存储的值;如果MMKV没有存储值,默认值也是读Properties文件。


image.png


内容比较简单:


isCN = true   //是否国内环境 
isRelease = false //是否release,比如日志的打印也可以用这个变量控制
#dev test release //三种环境
environment = dev //环境切换

四、MMKV封装


package com.abc.kotlinstudio

import android.os.Parcelable
import com.tencent.mmkv.MMKV
import java.util.*

object CacheUtil {

private var userId: Long = 0

//公共存储区的ID
private const val STORAGE_PUBLIC_ID = "STORAGE_PUBLIC_ID"

//------------------------公共区的键------------------
//用户登录的Token
const val KEY_PUBLIC_TOKEN = "KEY_PUBLIC_TOKEN"

//------------------------私有区的键------------------
//用户是否第一次登录
const val KEY_USER_IS_FIRST = "KEY_USER_IS_FIRST"


/**
* 设置用户的ID,根据用户ID做私有化分区存储
*/
fun setUserId(userId: Long) {
this.userId = userId
}

/**
* 获取MMKV对象
* @param isStoragePublic true 公共存储空间 false 用户私有空间
*/
fun getMMKV(isStoragePublic: Boolean): MMKV = if (isStoragePublic) {
MMKV.mmkvWithID(STORAGE_PUBLIC_ID)
} else {
MMKV.mmkvWithID("$userId")
}


/**
* 设置登录后token
*/
fun setToken(token: String) {
put(KEY_PUBLIC_TOKEN, token, true)
}


/**
* 获取登录后token
*/
fun getToken(): String = getString(KEY_PUBLIC_TOKEN)


/**
* 设置MMKV存储的环境
*/
fun putEnvironment(value: String) {
put("environment", value, true)
}

/**
* 获取MMKV存储的环境
*/
fun getEnvironment(defaultValue: String): String {
return getString("environment", true, defaultValue)
}

/**
* 设置MMKV存储的国内外环境
*/
fun putCN(value: String) {
put("isCN", value, true)
}

/**
* 获取MMKV存储的国内外环境
*/
fun getCN(defaultValue: String): String {
return getString("isCN", true, defaultValue)
}


//------------------------------------------基础方法区-----------------------------------------------

/**
* 基础数据类型的存储
* @param key 存储的key
* @param value 存储的值
* @param isStoragePublic 是否存储在公共区域 true 公共区域 false 私有区域
*/
fun put(key: String, value: Any?, isStoragePublic: Boolean): Boolean {
val mmkv = getMMKV(isStoragePublic)
return when (value) {
is String -> mmkv.encode(key, value)
is Float -> mmkv.encode(key, value)
is Boolean -> mmkv.encode(key, value)
is Int -> mmkv.encode(key, value)
is Long -> mmkv.encode(key, value)
is Double -> mmkv.encode(key, value)
is ByteArray -> mmkv.encode(key, value)
else -> false
}
}


/**
* 这里使用安卓自带的Parcelable序列化,它比java支持的Serializer序列化性能好些
* @param isStoragePublic 是否存储在公共区域 true 公共区域 false 私有区域
*/
fun <T : Parcelable> put(key: String, t: T?, isStoragePublic: Boolean): Boolean {
if (t == null) {
return false
}
return getMMKV(isStoragePublic).encode(key, t)
}

/**
* 存Set集合的数据
* @param isStoragePublic 是否存储在公共区域 true 公共区域 false 私有区域
*/
fun put(key: String, sets: Set<String>?, isStoragePublic: Boolean): Boolean {
if (sets == null) {
return false
}
return getMMKV(isStoragePublic).encode(key, sets)
}

/**
* 取数据,因为私有存储区用的多,所以这里给了默认参数为私有区域,如果公共区域取要记得改成true.下同
*/
fun getInt(key: String, isStoragePublic: Boolean = false, defaultValue: Int = 0): Int {
return getMMKV(isStoragePublic).decodeInt(key, defaultValue)
}

fun getDouble(
key: String,
isStoragePublic: Boolean = false,
defaultValue: Double = 0.00
): Double {
return getMMKV(isStoragePublic).decodeDouble(key, defaultValue)
}

fun getLong(key: String, isStoragePublic: Boolean = false, defaultValue: Long = 0L): Long {
return getMMKV(isStoragePublic).decodeLong(key, defaultValue)
}

fun getBoolean(
key: String,
isStoragePublic: Boolean = false,
defaultValue: Boolean = false
): Boolean {
return getMMKV(isStoragePublic).decodeBool(key, defaultValue)
}

fun getFloat(key: String, isStoragePublic: Boolean = false, defaultValue: Float = 0F): Float {
return getMMKV(isStoragePublic).decodeFloat(key, defaultValue)
}

fun getByteArray(key: String, isStoragePublic: Boolean = false): ByteArray? {
return getMMKV(isStoragePublic).decodeBytes(key)
}

fun getString(
key: String,
isStoragePublic: Boolean = false,
defaultValue: String = ""
): String {
return getMMKV(isStoragePublic).decodeString(key, defaultValue) ?: defaultValue
}

/**
* getParcelable<Class>("")
*/
inline fun <reified T : Parcelable> getParcelable(
key: String,
isStoragePublic: Boolean = false
): T? {
return getMMKV(isStoragePublic).decodeParcelable(key, T::class.java)
}

fun getStringSet(key: String, isStoragePublic: Boolean = false): Set<String>? {
return getMMKV(isStoragePublic).decodeStringSet(key, Collections.emptySet())
}

fun removeKey(key: String, isStoragePublic: Boolean = false) {
getMMKV(isStoragePublic).removeValueForKey(key)
}

fun clearAll(isStoragePublic: Boolean = false) {
getMMKV(isStoragePublic).clearAll()
}

}

五、URL的配置


假设有国内外以及host、h5_host环境 :


object GlobalUrlConfig {

private val BASE_HOST_CN_DEV = "https://cn.dev.abc.com"
private val BASE_HOST_CN_TEST = "https://cn.test.abc.com"
private val BASE_HOST_CN_RELEASE = "https://cn.release.abc.com"

private val BASE_HOST_I18N_DEV = "https://i18n.dev.abc.com"
private val BASE_HOST_I18N_TEST = "https://i18n.test.abc.com"
private val BASE_HOST_I18N_RELEASE = "https://i18n.release.abc.com"

private val BASE_HOST_H5_CN_DEV = "https://cn.dev.h5.abc.com"
private val BASE_HOST_H5_CN_TEST = "https://cn.test.h5.abc.com"
private val BASE_HOST_H5_CN_RELEASE = "https://cn.release.h5.abc.com"

private val BASE_HOST_H5_I18N_DEV = "https://i18n.dev.h5.abc.com"
private val BASE_HOST_H5_I18N_TEST = "https://i18n.test.h5.abc.com"
private val BASE_HOST_H5_I18N_RELEASE = "https://i18n.release.h5.abc.com"

private val baseHostList: List<List<String>> = listOf(
listOf(
BASE_HOST_CN_DEV,
BASE_HOST_CN_TEST,
BASE_HOST_CN_RELEASE
), listOf(
BASE_HOST_I18N_DEV,
BASE_HOST_I18N_TEST,
BASE_HOST_I18N_RELEASE
)
)

private val baseHostH5List: List<List<String>> = listOf(
listOf(
BASE_HOST_H5_CN_DEV,
BASE_HOST_H5_CN_TEST,
BASE_HOST_H5_CN_RELEASE
), listOf(
BASE_HOST_H5_I18N_DEV,
BASE_HOST_H5_I18N_TEST,
BASE_HOST_H5_I18N_RELEASE
)
)

//base
var BASE_HOST: String =
baseHostList[PropertiesUtil.getCN()][PropertiesUtil.getEnvironment()]
//base_h5
var BASE_H5_HOST: String =
baseHostH5List[PropertiesUtil.getCN()][PropertiesUtil.getEnvironment()]


enum class CNConfig(var value: Int) {
CN(0), I18N(1)
}

enum class EnvironmentConfig(var value: Int) {
DEV(0), TEST(1), RELEASE(2)
}

六、测试人员可在打好的App动态切换


可以弹Dialog动态切换环境,下面为测试代码:


//初始化
PropertiesUtil.init(this)
MMKV.initialize(this)
CacheUtil.setUserId(1000L)

val btSetCn = findViewById<AppCompatButton>(R.id.bt_set_cn)
val btSeti18n = findViewById<AppCompatButton>(R.id.bt_set_i8n)
val btSetDev = findViewById<AppCompatButton>(R.id.bt_set_dev)
val btSetTest = findViewById<AppCompatButton>(R.id.bt_set_test)
val btSetRelease = findViewById<AppCompatButton>(R.id.bt_set_release)

//App内找个地方弹一个Dialog动态修改下面的参数即可。

btSetCn.setOnClickListener {
CacheUtil.putCN("true")
//重启App(AndroidUtilCode工具类里面的方法)
AppUtils.relaunchApp(true)
}

btSeti18n.setOnClickListener {
CacheUtil.putCN("false")
AppUtils.relaunchApp(true)
}

btSetDev.setOnClickListener {
CacheUtil.putEnvironment("dev")
AppUtils.relaunchApp(true)
}

btSetTest.setOnClickListener {
CacheUtil.putEnvironment("test")
AppUtils.relaunchApp(true)
}

btSetRelease.setOnClickListener {
CacheUtil.putEnvironment("release")
AppUtils.relaunchApp(true)
}

总结


一般会有4套环境: 开发环境,测试环境,预发布环境,正式环境。如果再区分国内外则乘以2。除了base的主机一般还会引入其他主机,比如h5的主机,这样会导致整个环境复杂多变。


刚开始是给测试打多渠道包,测试抱怨切环境,频繁卸载安装App很麻烦,于是做了这个优化。上线时记得把Properties文件isRelease设置为true,则发布的包就不会有问题,这个一般都不会忘记,风险很小。相比存文件或者其他形式安全很多。


写的比较匆忙,代码略粗糙,主要体现思路。以上!


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

线程池封装及拒绝策略

前文提到线程的使用以及线程间通信方式,通常情况下我们通过new Thread或者new Runnable创建线程,这种情况下,需要开发者手动管理线程的创建和回收,线程对象没有复用,大量的线程对象创建与销毁会引起频繁GC,那么事否有机制自动进行线程的创建,管理和...
继续阅读 »

前文提到线程的使用以及线程间通信方式,通常情况下我们通过new Thread或者new Runnable创建线程,这种情况下,需要开发者手动管理线程的创建和回收,线程对象没有复用,大量的线程对象创建与销毁会引起频繁GC,那么事否有机制自动进行线程的创建,管理和回收呢?线程池可以实现该能力。


线程池的优点:



  • 线程池中线程重用,避免线程创建和销毁带来的性能开销

  • 能有效控制线程数量,避免大量线程抢占资源造成阻塞

  • 对线程进行简单管理,提供定时执行预计指定间隔执行等策略


线程池的封装实现


在java.util.concurrent包中提供了一系列的工具类以方便开发者创建和使用线程池,这些类的继承关系及说明如下:


threadpool_extend










































类名说明备注
ExecutorExecutor接口提供了一种任务提交后的执行机制,包括线程的创建与运行,线程调度等,通常不直接使用该类/
ExecutorServiceExecutorService接口,提供了创建,管理,终止Future执行的方法,用于跟踪一个或多个异步任务的进度,通常不直接使用该类/
ScheduledExecutorServiceExecutorService的实现接口,提供延时,周期性执行Future的能力,同时具备ExecutorService的基础能力,通常不直接使用该类/
AbstractExecutorServiceAbstractExecutorService是个虚类,对ExecutorService中方法进行了默认实现,其提供了newTaskFor函数,用于获取RunnableFuture对象,该对象实现了submit,invokeAny和invokeAll方法,通常不直接使用该类/
ThreadPoolExecutor通过创建该类对象就可以构建一个线程池,通过调用execute方法可以向该线程池提交任务。通常情况下,开发者通过自定义参数,构造该类对象就来获得一个符合业务需求的线程池/
ScheduledThreadPoolExecutor通过创建该类对象就可以构建一个可以周期性执行任务的线程池,通过调用schedule,scheduleWithFixedDelay等方法可以向该线程池提交任务并在指定时间节点运行。通常情况下,开发者通过构造该类对象就来获得一个符合业务需求的可周期性执行任务的线程池/

由上表可知,对于开发者而言,通常情况下我们可以通过构造ThreadPoolExecutor对象来获取一个线程池对象,通过其定义的execute方法来向该线程池提交任务并执行,那么怎么创建线程池呢?让我们一起看下


ThreadPoolExecutor


ThreadPoolExecutor完整参数的构造函数如下所示:


     /**
      * Creates a new {@code ThreadPoolExecutor} with the given initial
      * parameters.
      *
      * @param corePoolSize the number of threads to keep in the pool, even
      *       if they are idle, unless {@code allowCoreThreadTimeOut} is set
      * @param maximumPoolSize the maximum number of threads to allow in the
      *       pool
      * @param keepAliveTime when the number of threads is greater than
      *       the core, this is the maximum time that excess idle threads
      *       will wait for new tasks before terminating.
      * @param unit the time unit for the {@code keepAliveTime} argument
      * @param workQueue the queue to use for holding tasks before they are
      *       executed. This queue will hold only the {@code Runnable}
      *       tasks submitted by the {@code execute} method.
      * @param threadFactory the factory to use when the executor
      *       creates a new thread
      * @param handler the handler to use when execution is blocked
      *       because the thread bounds and queue capacities are reached
      * @throws IllegalArgumentException if one of the following holds:


      *         {@code corePoolSize < 0}


      *         {@code keepAliveTime < 0}


      *         {@code maximumPoolSize <= 0}


      *         {@code maximumPoolSize < corePoolSize}
      * @throws NullPointerException if {@code workQueue}
      *         or {@code threadFactory} or {@code handler} is null
      */

     public ThreadPoolExecutor(int corePoolSize,
                               int maximumPoolSize,
                               long keepAliveTime,
                               TimeUnit unit,
                               BlockingQueue 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.acc = System.getSecurityManager() == null ?
                 null :
                 AccessController.getContext();
         this.corePoolSize = corePoolSize;
         this.maximumPoolSize = maximumPoolSize;
         this.workQueue = workQueue;
         this.keepAliveTime = unit.toNanos(keepAliveTime);
         this.threadFactory = threadFactory;
         this.handler = handler;
    }

从上述代码可以看出,在构建ThreadPoolExecutor时,主要涉及以下参数:



  1. corePoolSize:核心线程个数,一般情况下可以使用 处理器个数/2 作为核心线程数的取值,可以通过Runtime.getRuntime().availableProcessors()来获取处理器个数

  2. maximumPoolSize:最大线程个数,该线程池支持同时存在的最大线程数量

  3. keepAliveTime:非核心线程闲置时的超时时长,超过这个时长,非核心线程就会被回收,我们也可以通过allowCoreThreadTimeOut(true)来设置核心线程闲置时,在超时时间到达后回收

  4. unit:keepAliveTime的时间单位

  5. workQueue:线程池中的任务队列,当核心线程数满或最大线程数满时,通过线程池的execute方法提交的Runnable对象存储在这个参数中,遵循先进先出原则

  6. threadFactory:创建线程的工厂 ,用于批量创建线程,统一在创建线程时进行一些初始化设置,如是否守护线程、线程的优先级等。不指定时,默认使用Executors.defaultThreadFactory() 来创建线程,线程具有相同的NORM_PRIORITY优先级并且是非守护线程

  7. handler:任务拒绝处理策略,当线程数量等于最大线程数且等待队列已满时,就会采用拒绝处理策略处理新提交的任务,不指定时,默认的处理策略是AbortPolicy,即抛弃该任务


综上,我们可以看出创建一个线程池最少需要明确核心线程数,最大线程数,超时时间及单位,等待队列这五个参数,下面我们创建一个核心线程数为1,最大线程数为3,5s超时回收,等待队列最多能存放5个任务的线程池,代码如下:


 ThreadPoolExecutor executor = new ThreadPoolExecutor(1,3,5,TimeUnit.SECONDS,new LinkedBlockingQueue<>(5));

随后我们使用for循环向该executor中提交任务,代码如下:


 public static void main(String[] args) {
     // 创建线程池
     ThreadPoolExecutor executor = new ThreadPoolExecutor(1,3,5,TimeUnit.SECONDS,new LinkedBlockingQueue<>(5));
     for (int i=0;i<10;i++) {
         int finalI = i;
         System.out.println("put runnable "+ finalI +"to executor");
         // 向线程池提交任务
         executor.execute(new Runnable() {
             @Override
             public void run()
{
                 System.out.println(Thread.currentThread().getName()+",runnable "+ finalI +"start");
                 try {
                     Thread.sleep(5000);
                } catch (InterruptedException e) {
                     throw new RuntimeException(e);
                }
                 System.out.println(Thread.currentThread().getName()+",runnable "+ finalI +"executed");
            }
        });
    }
 }

输出如下:


1-4-5-4


从输出可以看到,当提交一个任务到线程池时,其执行流程如下:


threadpoolexecutor_execute.drawio


线程池拒绝策略


线程池拒绝策略有四类,定义在ThreadPoolExecutor中,分别是:



  • AbortPolicy:默认拒绝策略,丢弃提交的任务并抛出RejectedExecutionException,在该异常输出信息中,可以看到当前线程池状态

  • DiscardPolicy:丢弃新来的任务,但是不抛出异常

  • DiscardOldestPolicy:丢弃队列头部的旧任务,然后尝试重新执行,如果再次失败,重复该过程

  • CallerRunsPolicy:由调用线程处理该任务


当然,如果上述拒绝策略不能满足需求,我们也可以自定义异常,实现RejectedExecutionHandler接口,即可创建自己的线程池拒绝策略,下面是使用自定义拒绝策略的示例代码:


 public static void main(String[] args) {
     RejectedExecutionHandler handler = new RejectedExecutionHandler() {
         @Override
         public void rejectedExecution(Runnable r, ThreadPoolExecutor executor)
{
             System.out.println("runnable " + r +" in executor "+executor+" is refused");
        }
    };
     ThreadPoolExecutor executor = new ThreadPoolExecutor(1,3,5,TimeUnit.SECONDS,new LinkedBlockingQueue<>(5),handler);
     for (int i=0;i<10;i++) {
         int finalI = i;
         Runnable runnable = new Runnable() {
             @Override
             public void run()
{
                 System.out.println(Thread.currentThread().getName()+",runnable "+ finalI +"start");
                 try {
                     Thread.sleep(5000);
                } catch (InterruptedException e) {
                     throw new RuntimeException(e);
                }
                 System.out.println(Thread.currentThread().getName()+",runnable "+ finalI +"executed");
            }
        };
         System.out.println("put runnable "+ runnable+" index:"+finalI +" to executor:"+executor);
         executor.execute(runnable);
    }
 }

输出如下:


1-4-5-5


任务队列


对于线程池而言,任务队列需要是BlockingQueue的实现类,BlockingQueue接口的实现类类图如下:


BlockingQueue.drawio


下面我们针对常用队列做简单了解:




  • ArrayBlockingQueue:ArrayBlockingQueue是基于数组的阻塞队列,在其内部维护一个定长数组,所以使用ArrayBlockingQueue时必须指定任务队列长度,因为不论对数据的写入或者读取都使用的是同一个锁对象,所以没有实现读写分离,同时在创建时我们可以指定锁内部是否采用公平锁,默认实现是非公平锁。



    非公平锁与公平锁


    公平锁:多个任务阻塞在同一锁时,等待时长长的优先获取锁


    非公平锁:多个任务阻塞在同一锁时,锁可获取时,一起抢锁,谁先抢到谁先执行





  • LinkedBlockingQueue:LinkedBlockingQueue是基于链表的阻塞队列,在创建时可不指定任务队列长度,默认值是Integer.MAX_VALUE,在LinkedBlockingQueue中读锁和写锁实现了分支,相对ArrayBlockingQueue而言,效率提升明显。




  • SynchronousQueue:SynchronousQueue是一个不存储元素的阻塞队列,也就是说当需要插入元素时,必须等待上一个元素被移出,否则不能插入,其适用于任务多但是执行比较快的场景。




  • PriorityBlockingQueue:PriorityBlockingQueue是一个支持指定优先即的阻塞队列,默认初始化长度为11,最大长度为Integer.MAX_VALUE - 8,可以通过让装入队列的对象实现Comparable接口,定义对象排序规则来指定队列中元素优先级,优先级高的元素会被优先取出。




  • DelayQueue:DelayQueue是一个带有延迟时间的阻塞队列,队列中的元素,只有等待延时时间到了才可以被取出,由于其内部用PriorityBlockingQueue维护数据,故其长度与PriorityBlockingQueue一致。一般用于定时调度类任务。




下表从一些角度对上述队列进行了比较:























































队列名称底层数据结构默认长度最大长度是否读写分离适用场景
ArrayBlockingQueue数组0开发者指定大小任务数量较少时使用
LinkedBlockingQueue链表Integer.MAX_VALUEInteger.MAX_VALUE大量任务时使用
SynchronousQueue公平锁-队列/非公平锁-栈0/任务多但是执行速度快的场景
PriorityBlockingQueue对象数组11Integer.MAX_VALUE-8有任务需要优先处理的场景
DelayQueue对象数组11Integer.MAX_VALUE-8定时调度类场景

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

简单封装一个易拓展的Dialog

Dialog,每个项目中多多少少都会用到,肯定也会有自己的一套封装逻辑,无论如何封装,都是奔着简单复用的思想,有的是深层次的封装,也就是把相关的UI效果直接封装好,暴露可以修改的属性和方法,让调用者根据实际业务,调用修改即可,当然也有简单的封装,只封装基本的功...
继续阅读 »

Dialog,每个项目中多多少少都会用到,肯定也会有自己的一套封装逻辑,无论如何封装,都是奔着简单复用的思想,有的是深层次的封装,也就是把相关的UI效果直接封装好,暴露可以修改的属性和方法,让调用者根据实际业务,调用修改即可,当然也有简单的封装,只封装基本的功能,其UI和实际的动作,交给调用者,两种封装方式,各有利弊,前者调用者不用自己创建UI和实现相关动作,只需要简单的调用即可,但是不易于扩展,效果比较局限,想要拓展其他的效果,就不得不自己动手实现;后者扩展性强,因为只提供基本的调用方式,也就是说,你想要什么效果都行,毕竟是所有的UI和动作都是你自己来实现,优点是它,其缺点也是它。


前者的封装司空见惯,大多数的公司也都是采取的这样的封装,毕竟调用者实现起来也是很方便,这里就不详细说了,具体我们谈一下后者的封装,后者的封装虽然调用者需要自己来实现,但是扩展性是很强的。


今天的内容大致如下:


1、效果及代码具体调用。


2、如何封装一个Dialog。


3、开源地址。


4、总结及注意事项。


一、效果及代码具体调用


通过Kotlin的扩展函数,参数以类做为扩展,封装之后,调用非常的便捷,只需要传递你要的视图即可,我们先看下具体的案例,代码如下:


                showVipDialog {
addLayout(R.layout.layout_dialog_custom)//传递dialog视图
set {
//Dialog操作,获取View及绑定数据
}
}

通过以上的代码,我们就实现了一个Dialog的弹出,addLayout方法传递视图,set扩展函数进行获取View和绑定数据,这样的一个简单的封装,我们就实现了Dialog的扩展操作,针对不同的Dialog样式,传递不同的xml视图即可。


1、快速使用


为了方便大家使用,目前已经上传到了远程maven,大家可以进行依赖使用,或者下载源码依赖也可以。


根项目build.gradle


allprojects {
repositories {
……
maven { url "https://gitee.com/AbnerAndroid/almighty/raw/master" }
}
}

在需要的Module下引入依赖


dependencies {
……
implementation "com.vip:dialog:1.0.0"
}

2、代码案例


源码下载之后,运行项目,就可以看到给大家提供的相关Demo,当然了,由于做到了可扩展,大家想实现什么样的效果都是可以的,毕竟视图都是自己传递的。



由于所有的案例都是调用开头的代码,就不一一列举了,简单的列举几个。


普通的提示框



普通的提示框,可以按照下面的代码逻辑进行调用。


showVipDialog {
addLayout(R.layout.layout_dialog_custom)//添加弹出的视图
set {//逻辑处理,获取view,绑定数据
setDialogCancelable(false)//点击空白不消失
val btnConfirm = findView<TextView>(R.id.dialog_button_confirm)//获取View
btnConfirm.setOnClickListener {
toast("确定")
dismiss()
}
}
}

方法一览














































方法名参数类型概述
addLayoutintxml视图
set无参逻辑处理
style无参dialog设置样式
setDialogCancelableBoolean点击空白是否消失,默认true消失,false为不消失
findViewint控件id,泛型为控件
dismiss无参隐藏dialog
getDialogView无参获取当前View视图

DataBinding形式的提示框


DataBinding形式和普通的区别在于,不用再获取View视图,由普通的set扩展函数改为bind扩展函数,泛型为Binding,记得把xml视图进行convert to data binding layout。


showVipDialog {
addLayout(R.layout.layout_dialog_custom)//添加弹出的视图
bind<LayoutDialogCustomBinding> {//逻辑处理,获取view,绑定数据
it.dialogButtonConfirm.setOnClickListener {
toast("确定")
dismiss()
}
}
}

方法一览

除了普通的方法调用之外,还可以调用下面的方法。



























方法名参数类型概述
bind无参和set一样进行逻辑处理,泛型为ViewDataBinding
getDataBinding无参获取当前的DataBinding,用于更新视图
setPendingBindingsint传递的BR,用于xml和Data数据进行绑定

具体的案例大家直接可以看源码,源码中提供了很多常见的效果,都是可以自定义实现的,具体的就不罗列了,本身没有多少难度。


确认框


输入框


底部列表


菊花加载


二、如何封装一个Dialog


这样的一个简单的Dialog如何进行封装呢?在封装之前,我们首先要明确封装思路,1、视图由调用者传递,2、逻辑操作由调用者处理,3、样式也由调用者进行设置,也就是说,我们只封装基本的dialog使用,也就是一个壳,具体的内容,统统交给调用者进行处理,有了这三个思路我们就可以进行着手封装了。


1、封装BaseDialog


封装Base的原因,在于统一管理子类,在于简化子类的代码逻辑,便于提供公共的方法让子类实现或调用,BaseDialog这里继承的是DialogFragment,最大的原因就是,容易通过生命周期回调来管理弹窗,还有对于复杂样式的弹窗,使用DialogFragment会更加方便和高效。


和之前封装Activity一样,做为一个抽象父类,子类要实现的无非就是,视图的传递和逻辑的处理,我们就可以在父类中进行定义抽象方法,Dialog一般有自己定义的样式,我们也可以定义一个初始化样式的方法。


  /**
* AUTHOR:AbnerMing
* INTRODUCE:初始化数据
*/
abstract fun initData()

/**
* AUTHOR:AbnerMing
* INTRODUCE:初始化样式
*/
abstract fun initStyle()

/**
* AUTHOR:AbnerMing
* INTRODUCE:传递的视图
*/
abstract fun getLayoutId(): Int

除了必要实现的方法之外,我们还可以把一些公用的方法,定义到Base里,如获取View的方法,获取控件的方法等,这么做的目的,便于子类自定义实现一些效果以及减少findViewById的调用次数。


 /**
* AUTHOR:AbnerMing
* INTRODUCE:获取View视图
*/
fun <V> findView(id: Int): View {
var view = mViewSparseArray[id]
if (view == null) {
view = mView?.findViewById(id)
mViewSparseArray.put(id, view)
}
return view
}

/**
* AUTHOR:AbnerMing
* INTRODUCE:获取当前View视图
*/
fun getDialogView(): View {
return mView!!
}

以上只是列举了几个实现的方法,完整的代码,大家可以看源码中的BaseDialog类。


2、拓展ViewDataBinding形式Dialog


正常的普通Dialog就可以继承BaseDialog,基本就可以满足需要的,若是要和ViewDataBinding进行结合,那么就需要拓展需求了,具体的拓展也很简单,一是绑定View,二是绑定数据,完整的代码,大家可以看源码中BaseBindingDialog类。


绑定View


通过DataBindingUtil的bind方法,得到ViewDataBinding。


 mBinding = DataBindingUtil.bind(getDialogView())
复制代码

绑定数据


完成xml视图和数据的绑定。


  mBinding.setVariable(variableId, t)
mBinding.executePendingBindings()

3、封装工具类,拓展相关功能


为了更加方便的让调用者使用,封装拓展函数是很有必要的,要不然,调用者每次都得要继承上边的两个父类,这样的代码就会增加很多,还会创建很多的类,我们需要单独的创建一个工具类,来实例化我们需要简化的功能逻辑。


提供添加xml视图的方法


很简单的一个普通方法,没什么好说的,把传递的xml,赋值给重写的getLayoutId方法即可。


   /**
* AUTHOR:AbnerMing
* INTRODUCE:设置layout
* @param mLayoutId xml布局
*/
fun addLayout(mLayoutId: Int): VipDialog {
this.mLayoutId = mLayoutId
return this
}

提供普通使用和DataBinding形式使用方法


普通和DataBinding方法,这里用到了接口回调,接口的实现则在initVMData方法里,两个方法本身功能是一样的,无非就是一个是普通,一个是返回ViewDataBinding。


    /**
* AUTHOR:AbnerMing
* INTRODUCE:初始化数据
*/
fun <VB : ViewDataBinding> bind(block: (bind: VB) -> Unit): VipDialog {
setDataCallBackListener(object : OnDialogDataCallbackListener {
override fun dataCallback() {
block.invoke(getDataBinding())
}
})
return this
}

/**
* AUTHOR:AbnerMing
* INTRODUCE:初始化数据
*/
fun set(block: () -> Unit): VipDialog {
setDataCallBackListener(object : OnDialogDataCallbackListener {
override fun dataCallback() {
block.invoke()
}
})
return this
}

提供设置样式的方法


样式的设置也就是使用了接口回调。


    /**
* AUTHOR:AbnerMing
* INTRODUCE:设置样式
*/
fun style(style: () -> Unit): VipDialog {
setStyleCallBackListener(object : OnStyleCallBackListener {
override fun styleCallback() {
style.invoke()
}
})
return this
}

提供获取ViewDataBinding的方法


这个方法的提供是便于拿到ViewDataBinding,有效的更新视图数据。


    /**
* AUTHOR:AbnerMing
* INTRODUCE:获取ViewDataBinding
*/
fun <VB : ViewDataBinding> getDataBinding(): VB {
return mBinding as VB
}

我们看下整体的代码,如下:


/**
*AUTHOR:AbnerMing
*DATE:2022/11/22
*INTRODUCE:实例化功能
*/
class VipDialog : BaseBindingDialog<ViewDataBinding>() {

companion object {
fun init(): VipDialog {
return VipDialog()
}
}

private var mLayoutId = 0

override fun initVMData() {
mOnDialogDataCallbackListener?.dataCallback()
}

override fun initStyle() {
mOnStyleCallBackListener?.styleCallback()
}

override fun getLayoutId(): Int {
return mLayoutId
}

/**
* AUTHOR:AbnerMing
* INTRODUCE:获取ViewDataBinding
*/
fun <VB : ViewDataBinding> getDataBinding(): VB {
return mBinding as VB
}


/**
* AUTHOR:AbnerMing
* INTRODUCE:设置layout
* @param mLayoutId xml布局
*/
fun addLayout(mLayoutId: Int): VipDialog {
this.mLayoutId = mLayoutId
return this
}

/**
* AUTHOR:AbnerMing
* INTRODUCE:初始化数据
*/
fun <VB : ViewDataBinding> bind(block: (bind: VB) -> Unit): VipDialog {
setDataCallBackListener(object : OnDialogDataCallbackListener {
override fun dataCallback() {
block.invoke(getDataBinding())
}
})
return this
}

/**
* AUTHOR:AbnerMing
* INTRODUCE:初始化数据
*/
fun set(block: () -> Unit): VipDialog {
setDataCallBackListener(object : OnDialogDataCallbackListener {
override fun dataCallback() {
block.invoke()
}
})
return this
}

/**
* AUTHOR:AbnerMing
* INTRODUCE:设置样式
*/
fun style(style: () -> Unit): VipDialog {
setStyleCallBackListener(object : OnStyleCallBackListener {
override fun styleCallback() {
style.invoke()
}
})
return this
}

private var mOnDialogDataCallbackListener: OnDialogDataCallbackListener? = null
private fun setDataCallBackListener(mOnDialogDataCallbackListener: OnDialogDataCallbackListener) {
this.mOnDialogDataCallbackListener = mOnDialogDataCallbackListener
}

private var mOnStyleCallBackListener: OnStyleCallBackListener? = null
private fun setStyleCallBackListener(mOnStyleCallBackListener: OnStyleCallBackListener) {
this.mOnStyleCallBackListener = mOnStyleCallBackListener
}

}

4、封装拓展函数,简化调用


dialog的弹出可能有很多场景,比如Activity里,比如Fragment里,比如一个工具类中,我们可以根据已知的场景,来定义我们的调用方式,目前,我定义了两种,在Activity或者Fragment里可以直接进行调用,也就是开头的调用方式,当然了,大家也可以自己拓展。


/**
* AUTHOR:AbnerMing
* INTRODUCE:Activity显示Dialog
*/
fun AppCompatActivity.showVipDialog(vipDialog: VipDialog.() -> Unit): VipDialog {
val dialog = VipDialog.init()
dialog.apply(vipDialog)
setActivityDialog(this.supportFragmentManager, dialog)
return dialog
}

/**
* AUTHOR:AbnerMing
* INTRODUCE:Fragment显示Dialog
*/
fun Fragment.showVipDialog(vipDialog: VipDialog.() -> Unit): VipDialog {
val dialog = VipDialog.init()
dialog.apply(vipDialog)
setActivityDialog(this.childFragmentManager, dialog)
return dialog
}

通过以上几步,我们就可以实现开头的简单调用,具体的大家可以查看相关源码。


三、开源地址


项目地址:github.com/AbnerMing88…


四、总结及注意事项


在开头已经阐述,这种方式易于拓展,但是代码量相对比较多,毕竟所有的UI和逻辑都必须独自来处理,在项目中的解决方式为,如果很多的弹框效果一样,建议再封装一层,抽取公共的工具类。


还有一个需要注意的,本身扩展函数showVipDialog返回的就是调用的类,也就是一个Dialog,大家可以直接获取变量,在其他的地方做更新Dialog或者销毁的操作。


val dialog=showVipDialog {
……
}

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

Flutter 玩转彩虹, 吃定彩虹

闲暇时,又听到了这首歌. 抑郁质性格的人难免会惆怅,美好的东西转瞬即逝.不过谁叫咱们是程序员呢~ 这就安排上.整上一个想看就看的彩虹! 玩转彩虹 彩虹,是气象中的一种光学现象,当太阳光照射到半空中的水滴,光线被折射及反射,在天空上形成拱形的七彩光谱,由外圈至...
继续阅读 »

闲暇时,又听到了这首歌. 抑郁质性格的人难免会惆怅,美好的东西转瞬即逝.不过谁叫咱们是程序员呢~ 这就安排上.整上一个想看就看的彩虹!
image


玩转彩虹


彩虹,是气象中的一种光学现象,当太阳光照射到半空中的水滴,光线被折射及反射,在天空上形成拱形的七彩光谱,由外圈至内圈呈红、橙、黄、绿、蓝、靛蓝、蓝紫七种颜色. 相信小伙伴们在大雨过后的不经意间都见过吧! 接下来,我们就自己手动绘制一下.一般这种, 我们都会分析一下绘制的步骤.


分析步骤


彩虹实际上就是7道拱桥型状的颜色堆积,绘制彩虹第一步我们不如先绘制一道拱桥形状的颜色块.也就是说, 本质上我们绘制一个半圆环即可解决问题.


绘制半圆环


在Flutter中, 半圆环都绘制有很多方法. 比如canvas中,有drawOval(rect,paint) 的方法,这种方法可以绘制出一整个圆环, 我们可以对它作切割即可. 不过这种方法不便利的是它控制不了圆环的进度, 有没有一种方法可以让我们自己去控制圆环绘制的进度呢? 答案就是Path, 好多伙伴们应该都对Path 有过或多或少都了解, 它不仅可以画直线、三角形、圆锥,更可以画优美的贝塞尔曲线. 这里我们调用它的acrTo(Rect rect, double startAngle, double sweepAngle, bool forceMoveTo) 方法, 它的参数:



  • rect: 给定一个矩形范围,在矩形范围中绘制弧形. 也就是我们如果是正方形的话,实际上绘制的便是一个圆形,如果是长方形的话最终产物就是椭圆形.
    image

  • startAngle: 起始的角度

  • sweepAngle: 扫过的角度
    实际上这里的坐标系和笛卡尔坐标系是一样的, 所以是从x轴开始算的, 也就是顺时针方向分别是0 -> pi/2 -> pi -> 3/2pi-> 2pi. 我们假设startAngle是0的话, sweepAngle为1/3pi, 那么最终的圆弧如图左示.
    image

  • forceMoveTo: false的时候,添加一个线段开始于当前点,结束于弧的起点.true时为原点.


理论知识了解完毕以后,我们通过如下代码进行绘制试一下:


{
Path path = Path();
path.moveTo(-width, 0.0);
path.arcTo(
Rect.fromCenter(center: Offset.zero, width: width, height: width),
-pi,
pi,
true,
);
}

结果如图:
image
第一道圆弧已经出来了, 说明理论上这样做可行.


多道圆弧


一道圆弧既然可以了, 我们首先记录下彩虹的颜色


  final List<Color> colors = const [
Color(0xFF8B00FF),
Color(0xFF0000FF),
Color(0xFF00FFFF),
Color(0xFF00FF00),
Color(0xFFFFFF00),
Color(0xFFFF7F00),
Color(0xFFFF0000),
];

记录好颜色后, 我们首先回顾一下. 刚刚一道圆弧是怎么绘制的呢? 通过path的arcTo()方法,起始在负x轴, 终止于x轴.也就是说我们重复的绘制上七道, 只需要半径不一样即可绘制出相互连接的颜色体.


    for (var color in colors) {
_paint.color = color;
// 绘制圆弧
drawArc();
canvas.drawPath(path, _paint);
// width 为每到圆弧的半径
width += widthStep;
}

嗯~ 没错, 结果确实和意料的一样
image
但是,总觉得有些不完美. 彩虹似乎都是有光晕的吧~


添加光晕


好, 光晕说来这不就来了.实际上我们可以通过画笔绘制周围部分作模糊当作光晕的形成, 恰恰Paint的mastFilter 也提供了这个方法.


{
_paint.maskFilter = const MaskFilter.blur(BlurStyle.solid, 6);
}

我们先简要分析一下MaskFilter.blur() 提供了参数有哪些用处吧~实际上也就是style和sigma.style控制最终绘制出来的效果.sigma控制效果的大小.这里我们使用BlurStyle.solid就可以绘制出光晕的效果
image


光晕也有了, 但是我感觉不够个性. 我希望它可以像扇子一样展开收起. 我们来看看怎么实现.


动画


实际上控制它的展开收起也就是在path中sweepAngle.我们最小扫过是0弧度,最大是pi.
我们控制了弧度变化也就控制了彩虹的展示大小.直接安排上repeat()动画


{
AnimationController _controller = AnimationController(
vsync: this,
// 这里需要把最大值改成pi, 这样才会完全展开
upperBound: pi,
duration: const Duration(seconds: 2),
);
_controller.repeat(reverse: true);
}

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

如何使用 uni-app 30分钟快速开发即时通讯应用|开发者活动

“一套代码,多端运行”是很多开发团队的梦想,基于 uni-app 跨平台框架支持 iOS、Android、Web以及各种小程序并支持平台间互通,快速实现搭建多端即时通讯功能,降低开发难度,提升开发效率。12月13日 晚 19:00,环信线上公开课《使用 uni...
继续阅读 »


“一套代码,多端运行”是很多开发团队的梦想,基于 uni-app 跨平台框架支持 iOS、Android、Web以及各种小程序并支持平台间互通,快速实现搭建多端即时通讯功能,降低开发难度,提升开发效率。
12月13日 晚 19:00,环信线上公开课《使用 uniapp 30分钟快速开发即时通讯应用》为题,讲解多端 uni-app 基础框架知识及搭建即时通讯功能项目实战技巧,掌握开发步骤及思路,大大增强代码复用率,提升效率。来直播间 get 环信 IM 的正确打开方式!

一、时间地点

活动时间:12 月 13 日(星期二)19:00-20:00
活动地点:线上直播

二、演讲大纲

  • uni-app 跨平台框架介绍
  • 使用uni-app 生成 Android&iOS 应用
  • 如何搭建自己的即时通讯应用
  • IM实战篇-uni-app 经典问题答疑

三、活动报名

报名链接:https://mudu.tv/live/watch/meddae1l





收起阅读 »

工程师的自我修养:了解技术的前世今生

——耶鲁大学校长 理查德莱文2017年,因为我接手一个Go语言新项目,作为研发的leader,需要建设临时的团队完成这件事。拉到的其中一个人是公司对口的外包资源的总接口人,这位接口人其实原本管管外包,做个管理者即可,但是他跟我说特别想要做技术,就跟我一起干点事...
继续阅读 »

真正的教育,不传授任何的知识和技能

——耶鲁大学校长 理查德莱文

1.一次飞速的转岗

2017年,因为我接手一个Go语言新项目,作为研发的leader,需要建设临时的团队完成这件事。拉到的其中一个人是公司对口的外包资源的总接口人,这位接口人其实原本管管外包,做个管理者即可,但是他跟我说特别想要做技术,就跟我一起干点事情。同时因为职务之便,也可以帮我甄别最适合的外包研发补充到队伍中。

作为leader,我则过着深圳北京2地周期往返的生活,这为后续我没办法很好的管理人的部分,埋下了伏笔。

这位外包leader很快为我物色了一位Java研发,他挺看好他的潜力,然而不到一周,这位研发就离开了队伍,理由是希望继续做深java这个语言,不想轻易换语言,赶上我不在现场,事出突然,我甚至没见过一面就这么离开了。

不可否认一门具体的编程语言的技术深度挺重要的,但是如果我在现场,或许有机会聊聊关于一些我曾经在Java,Ruby,python,nodejs间反复横跳,哪要救火就补哪的经历对我的帮助有哪些,即便留不住他,但或许我的观点对他未来的路有些帮助。

2.对领导者的失望

时间回到2015年,在另一家公司就职的我,听了高管在与研发的演讲中大概这么说:“大家不要看重编程语言啊,那只是一种具体的工具”。在台下的我深以为然。

没过多久,我短暂带过的一个研发离职了,临走时跟我说:你看这个高管说的话就是不重视技术,我还是走吧。听他这么说完,我直接愣了,虽然想要反驳,但是心想我年纪跟他一般,实在不配做教育他的那个人,毕竟不是他领导还是算了吧,毕竟离职已成定局。

现在的我不会那么腼腆,肯定会给他讲讲我背后的逻辑和观点。

那么我的观点是什么呢?我为何更认可高管。

3.第一次认知冲击

时间再回到2012年,工作了2年多的我入职这家公司。领导在面试我的最后,问的问题是我难以想象的题目:UTF8描述了什么,他的来历是怎样的,为何会有UTF8编码?

我直接放弃作答。领导说了至今让我受益终身的话,大概是这样的:了解技术本身的机制肯定是重要的,了解他背后产生的逻辑则更重要。面试就结束了,让我等消息

其实我现在再想想当初,领导或许只是想通过面试给我上一堂课吧(因为这问题问的“前不着村,后不着店”),但是却能一直不断影响我,我在进入任何一个技术领域后都将这种思维作为指导。

  • 进入云计算领域学完技术后,就把背后的发展历史搞清楚。

  • 从Java转Ruby就看看Ruby作者的一些思考,而不只是ruby语言的高级特性相关知识

等等,用这样的方式学习,我不会局限于工作安排所需我掌握的这些知识和技能,而是主动自学背后逻辑和发展演进历史。

4.高维度思考

那么领导想要我悟什么呢?相比知识和技能,更重要的是掌握产生这些东西的背后的思维逻辑是什么。不断积累这些思维,我才能逐渐的独立思考,创新。

  • 看看第一个离职的研发:研发在意的Java是一种知识和工具,而Java的作者除此之外还掌握了思维,我希望研发掌握的应该包含2者

  • 看看第二个离职的研发:高管期望大家不只是执着于工具,而是更高阶的思维,以创造新的商业模式,服务等等。而寻找技术和场景之间缺少的东西,跨越技术到商业成功的鸿沟,正是技术人员则无旁贷的事情,不积累是不行的。

去了解你所涉足的技术的前世今生,甚至细节到一个小小的功能特性,那么或许看透事物本质的你可以看到未来

来源:mp.weixin.qq.com/s/YBovCZ8OdELi17w_HFnvqg

收起阅读 »

这样封装列表 hooks,一天可以开发 20 个页面

web
前言在做移动端的需求时,我们经常会开发一些列表页,这些列表页大多数有着相似的功能:分页获取列表、上拉加载、下拉刷新···在 Vue 出来 compositionAPI之前,我们想要复用这样的逻辑还是比较麻烦的,好在现在 Vue2.7+都支持 compositi...
继续阅读 »

前言

在做移动端的需求时,我们经常会开发一些列表页,这些列表页大多数有着相似的功能:分页获取列表、上拉加载、下拉刷新···

Vue 出来 compositionAPI之前,我们想要复用这样的逻辑还是比较麻烦的,好在现在 Vue2.7+都支持 compositionAPI语法了,这篇文章我将 手把手带你用 compositionAPI封装一个名为 useListhooks来实现列表页的逻辑复用。

基础版

需求分析

一个列表,最基本的需求应该包括: 发起请求,获取到列表的数组,然后将该数组渲染成相应的 DOM 节点。要实现这个功能,我们需要以下变量:

  • list : 数组变量,用来存放后端返回的数据,并在 template模板中使用 v-for来遍历渲染成我们想要的样子。

  • listReq: 发起 http 请求的函数,一般是 axios的实例

代码实现

有了上面的分析,我们可以很轻松地在 setup中写出如下代码:

import { ref } from 'vue'
import axios from 'axios' // 简单示例,就不给出封装axios的代码了

const list = ref([])

const listReq = () => {
 axios.get('/url/to/getList').then((res) => {
   list.value = res.list
})
}

listReq()

这样,我们就完成了一个基本的列表需求的逻辑部分。大部分的列表需求都是类似的逻辑,既然如此,Don't Repeat Yourself!(不要重复写你的代码!),我们来把它封装成通用的方法:

  • 首先,既然是通用的,会在多个地方使用,那么数据肯定不能乱了,我们要在每次使用 useList的时候都拿到独属于自己的那一份数据。是不是感觉很熟悉?对的,就是以前的 data为什么是一个函数那个问题!所以我们的 useList是需要导出一个函数,我们从这个函数中获取数据与方法。让这个函数导出一个对象/数组,这样调用的时候 解构就可以拿到我们需要的变量和方法了

// useList.js 中

const useList = () => {
 // 待补充的函数体
 return {}
}

export default useList
  • 然后,不同的地方调用的接口肯定不一样,我们想一次封装,不再维护,那么咱们干脆在使用的时候,把调用接口的方法传进来就可以了

// useList.js 中
import { ref } from 'vue'
const useList = (listReq) => {
 if (!listReq) {
   return new Error('请传入接口调用方法!')
}
 const list = ref([])
 const getList = () => {
   listReq().then((res) => (list.value = res.list))
}

 return {
   list,
   getList,
}
}

export default useList

这样,我们就完成了一个简单的列表 hooks,使用的时候直接:

// setup中
import useList from '@/utils'
const { list, getList } = useList(axios.get('url/to/get/list'))
getList()

等等!列表好像不涉及到 DOM操作,那咱们再偷点懒,直接在 useList内部就调用了吧!

// useList.js中
import { ref } from 'vue'
const useList = (listReq) => {
 if (!listReq) {
   return new Error('请传入接口调用方法!')
}
 const list = ref([])
 const getList = () => {
   listReq().then((res) => (list.value = res.list))
}
 getList() // 直接初始化,省去在外面初始化的步骤
 return {
   list,
   getList,
}
}

export default useList

这时有老哥要说了,那我要是一个页面有多个列表怎么办?嘿嘿,别忘了,解构的时候是可以重命名的

// setup中

const { list: goodsList, getList: getGoodsList } = useList(
 axios.get('/url/get/goods')
)
const { list: recommendList, getList: getRecommendList } = useList(
 axios.get('/url/get/goods')
)

这样,我们就同时在一个页面里面,获取到了商品列表以及推荐列表所需要的变量与方法啦

带分页版

如果数据量比较大的话,所有的数据全部拿出来渲染显然不合理,所以我们一般要进行分页处理,我们来分析一下这个需求:

需求分析

  • 要分页,那咱们肯定要告诉后端当前请求的是第几页、每页多少条,可能有些地方还需要展示总共有多少条,为了方便管理,咱们把这些分页数据统一放到 pageInfo对象中

  • 分页了,那咱们肯定还有加载下一页的需求,需要一个 loadmore函数

  • 分页了,那咱们肯定还会有刷新的需求,需要一个 initList函数

代码实现

需求分析好了,代码实现起来就简单了,废话少说,上代码!

// useList.js中
import { ref } from 'vue'
const useList = (listReq) => {
 if (!listReq) {
   return new Error('请传入接口调用方法!')
}
 const list = ref([])

 // 新增pageInfo对象保存分页数据
 const pageInfo = ref({
   pageNum: 1,
   pageSize: 10,
   total: 0,
})
 const getList = () => {
   // 分页数据作为参数传递给接口调用函数即可
   // 将请求这个Promise返回出去,以便链式then
   return listReq(pageInfo.value).then((res) => {
     list.value = res.list
     // 更新总数量
     pageInfo.value.total = res.total
     // 返回出去,交给then默认的Promise,以便后续使用
     return res
  })
}

 // 新增加载下一页的函数
 const loadmore = () => {
   // 下一页,那咱们把当前页自增一下就行了
   pageInfo.value.pageNum += 1
   // 如果已经是最后一页了(本次获取到空数组)
   getList().then((res) => {
     if (!res.list.length) {
       uni.showToast({
         title: '没有更多了',
         icon: 'none',
      })
    }
  })
}

 // 新增初始化
 const initList = () => {
   // 初始化一般是要把所有的查询条件都初始化,这里只有分页,咱就回到第一页就行
   pageInfo.value.pageNum = 1
   getList()
}

 getList()
 return {
   list,
   getList,
   loadmore,
   initList,
}
}

export default useList

完工!跑起来试试,Perfec......等等,好像不太对...

加载更多,应该是把两次请求的数据合并到一起渲染出来才对,这怎么直接替换掉了?

回头看看代码,原来是咱们漏了拼接的逻辑,补上,补上

// useList.js中

// ...省略其余代码
const getList = () => {
 // 分页数据作为参数传递给接口调用函数即可
 return listReq(pageInfo.value).then((res) => {
   // 当前页不为1则是加载更多,需要拼接数据
   if (pageInfo.value.pageNum === 1) {
     list.value = res.list
  } else {
     list.value = [...list.value, ...res.list]
  }
   pageInfo.value.total = res.total
   return res
})
}
// ...省略其余代码

带 hooks 版

上面的分页版,我们给出了 加载更多初始化列表功能,但是还是要手动调用。仔细想想,咱们刷新列表,一般都是在页面顶部下拉的时候刷新的;而加载更多,一般都是在滚动到底部的时候加载的。既然都是一样的触发时机,那咱们继续封装吧!

需求分析

  • uni-app 中提供了 onPullDownRefreshonReachBottom钩子,在其中处理相关逻辑即可

  • 有些列表可能不是在页面中,而是在 scroll-view中,还是需要手动处理,因此上面的函数咱们依然需要导出

代码实现

钩子函数(hooks)接受一个回调函数作为参数,咱们直接把上面的函数传入即可

需要注意的是,uni-app 中,下拉刷新的动画需要手动关闭,咱们还需要改造一下 listReq函数


// useList中
import { onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'

// ...省略其余代码
onPullDownRefresh(initList)
onReachBottom(loadmore)

const getList = () => {
// 分页数据作为参数传递给接口调用函数即可
return listReq(pageInfo.value)
  .then((res) => {
    // ...省略其余代码
  })
  .finally((info) => {
    // 不管成功还是失败,关闭下拉刷新的动画
    uni.stopPullDownRefresh()
    // 在最后再把前面返回的消息return出去,以便后续处理
    return info
  })
}

// ...省略其余代码

带参数

其实在实际开发中,我们在发起请求时可能还需要其他的参数,上面我们都是固定的只有分页的参数,可以稍加改造

需求分析

可能大家第一反应是多一个参数,或者用 展开运算符 (...)再定义一个形参就行了。这么做肯定是没问题的,不过在这里的话不够优雅~

我们这里是要增加一个传给后端的参数,一般都是一起以 JSON 对象的形式传过去,既然如此,那咱们把所有的参数都用一个对象接受,发起请求的时候和分页参数对象合并为一个对象,代码的可读性会更高,使用者在使用时也可以自由地定义 key-value 键值对

代码实现

// useList中

const useList = (listReq, data) => {
 // ...省略其余代码

 // 判断第二个参数是否是对象,以免后面使用展开运算符时报错
 if (data && Object.prototype.toString.call(data) !== '[object Object]') {
   return new Error('额外参数请使用对象传入')
}
 const getList = () => {
   const params = {
     ...pageInfo.value,
     ...data,
  }
   return listReq(params).then((res) => {
     // ...省略其余代码
  })
}
 // ...省略其余代码
}

// ...省略其余代码

带默认配置版

有些时候我们的列表是在页面中间,不需要触底加载更多;有时候我们可能需要在不同的地方调用相同的接口,但是需要获取的数据量不一样....

为了适应各种各样的需求,我们可以稍加改造,添加一个带有默认值的配置对象,

// useList.js中

const defaultConfig = {
 pageSize: 10, // 每页数量,其实也可以在data里面覆盖
 needLoadMore: true, // 是否需要下拉加载
 data: {}, // 这个就是给接口用的额外参数了
 // 还可以根据自己项目需求添加其他配置
}

// 添加一个有默认值的参数,依然满足大部分列表页传入接口即可使用的需求
const useList = (listReq, config = defaultConfig) => {
 // 解构的时候赋上初始值,这样即使配置参数只传了一个参数,也不影响其他的配置
 const {
   pageSize = defaultConfig.pageSize,
   needLoadMore = defaultConfig.needLoadMore,
   data = defaultConfig.data,
} = config

 // 应用相应的配置
 if (needLoadMore) {
   onReachBottom(loadmore)
}

 const pageInfo = ref({
   pageNum: 1,
   pageSize,
   total: 0,
})

 // ...省略其余代码
}

// ...省略其余代码

这样一来,咱们就实现了一个满足大部分移动端列表页的逻辑复用 hooks

web 端的几乎只有加载更多(翻页)的时候逻辑不太一样,不需要拼接数据,在封装的时候可以把分页器的处理逻辑一起封装进来

总结

在这篇文章中,咱们从需求分析开始,到代码关键逻辑分析,再到实现后的 bug 修复,再到功能扩展,基本完整地复现了编码的思考过程,希望能给大家带来一些收获~

同时,欢迎大家在评论区和谐讨论~

作者:八宝粥要加纯牛奶
来源:juejin.cn/post/7165467345648320520

收起阅读 »

你需要了解的android注入技术

背景在android系统中,进程之间是相互隔离的,两个进程之间是没办法直接跨进程访问其他进程的空间信息的。那么在android平台中要对某个app进程进行内存操作,并获取目标进程的地址空间内信息或者修改目标进程的地址空间内的私有信息,就需要涉及到注入技术。通过...
继续阅读 »

背景

在android系统中,进程之间是相互隔离的,两个进程之间是没办法直接跨进程访问其他进程的空间信息的。那么在android平台中要对某个app进程进行内存操作,并获取目标进程的地址空间内信息或者修改目标进程的地址空间内的私有信息,就需要涉及到注入技术。

通过注入技术可以将指定so模块或代码注入到目标进程中,只要注入成功后,就可以进行访问和篡改目标进程空间内的信息,包括数据和代码。

Android的注入技术的应用场景主要是进行一些非法的操作和实现如游戏辅助功能软件、恶意功能软件。

zygote注入

zygote是一个在android系统中是非常重要的一个进程,因为在android中绝大部分的应用程序进程都是由它孵化(fork)出来的,fork是一种进程复用技术。也就是说在android系统中普通应用APP进程的父亲都是zygote进程。

zygote注入目的就是将指定的so模块注入到指定的APP进程中,这个注入过程不是直接向指定进程进程注入so模块,而是先将so模块注入到zygote进程。

在so模块注入到zygote进程后,在点击操作android系统中启动的应用程序APP进程,启动的App进程中包括需要注入到指定进程的so模块,太都是由zygote进程fork生成,因而在新创建的进程中都会包含已注入zygote进程的so模块。

这种的注入是通过间接注入方式完成的,也是一种相对安全的注入so模块方式。目前xposed框架就是基于zygote注入。

1.通过注入器将要注入的so模块注入到zygote进程;

2.手动启动要注入so模块的APP进程,由于APP进程是通过zygote进程fork出来的,所以启动的APP进程都包含zygote进程中所有模块;

3.注入的so模块劫持被注入APP进程的控制权,执行注入so模块的代码;

4.注入so模块归还APP进程的控制权,被注入进程正常运行。

(注入器主要是基于ptrace注入shellcode方式的进程注入)

通过ptrace进行附加到zygote进程。

调用mmap申请目标进程空间,用于保存注入的shellcode汇编代码。

执行注入shellcode代码(shellcode代码是注入目标进程中并执行的汇编代码)。

调用munmap函数释放申请的内存。

通过ptrace进行剥离zygote进程。

下面是关键的zygote代码注入实现




ptrace注入

ptrace注入实现上分类:

通过利用ptrace函数将shellcode注入远程进程的内存空间中,然后通过执行shellcode加载远程进程so模块。

通过直接远程调用dlopen、dlsym、dlclose等函数加载被注入so模块,并执行指定的代码。

ptrace直接调用函数注入流程:

通过利用ptrace进行附加到要注入的进程;

保存寄存环境;

远程调用mmap函数分配内存空间;

向远程进程内存空间写入加载模块名称和函数名称;

远程调用dlopen函数打开注入模块;

远程调用dlsym函数或需要调用的函数地址;

远程调用被注入模块的函数;

恢复寄存器环境;

利用ptrace从远程进程剥离。

关键的ptrace直接调用系统函数实现



shellcode注入就是通过将dlopen/dlsym库函数的操作放在shellcode代码中,注入函数只是通过对远程APP进程进行内存空间申请,接着修改shellcode 代码中有关dlopen、dlsymdlclose等函数使用到的参数信息,然后将shellcode代码注入到远程APP进程申请的空间中,最后通过修改PC寄存器的方式来执行shellcode 的代码。

关键 的ptrace注入shellcode代码实现



修改ELF文件注入

在android平台Native层的可执行文件SO文件,它是属于ELF文件格式,通过修改ELF文件格式可以实现对so文件的注入。

通过修改ELF二进制的可执行文件,并在ELF文件中添加自己的代码,使得可执行文件在运行时会先执行自定义添加的代码,最后在执行ELF文件的原始逻辑。

修改二进制ELF文件需要关注两个重要的结构体:

其中ELF Header 它是ELF文件中唯一的,一个固定位置的文件结构,它保存着Program Header Table和Section Header Table的位置和大小信息。

修改ELF文件实现so文件注入实现原理为:通过修改 Program Header Table中的依赖库信息,添加自定义的so文件信息,APP进程运行加载被该修改过的ELF文件,它也同时会加载并运行自定义的so文件。

Program Header Table表项结构


程序头表项中的类型选项有如下


当程序头表项结构中的类型为PT_DYNAMIC也就是动态链接信息的时候,它是由程序头表项的偏移(p_offset)和p_filesz(大小)指定的数据块指向.dynamic段。这个.dynamic段包含程序链接和加载时的依赖库信息。

关键ELF文件修改代码实现



作者:小道安全
来源:juejin.cn/post/7077940770941960223

收起阅读 »

关于无感刷新Token,我是这样子做的

web
JWT是全称是JSON WEB TOKEN,是一个开放标准,用于将各方数据信息作为JSON格式进行对象传递,可以对数据进行可选的数字加密,可使用RSA或ECDSA进行公钥/私钥使用场景源开销。比起传统的session认证方案,为了让服务器能识别是哪一个用户发过...
继续阅读 »

什么是JWT

JWT是全称是JSON WEB TOKEN,是一个开放标准,用于将各方数据信息作为JSON格式进行对象传递,可以对数据进行可选的数字加密,可使用RSAECDSA进行公钥/私钥签名。

使用场景

JWT最常见的使用场景就是缓存当前用户登录信息,当用户登录成功之后,拿到JWT,之后用户的每一个请求在请求头携带上Author ization字段来辨别区分请求的用户信息。且不需要额外的资源开销。

相比传统session的区别

比起传统的session认证方案,为了让服务器能识别是哪一个用户发过来的请求,都需要在服务器上保存一份用户的登录信息(通常保存在内存中),再与浏览器的cookie打交道。

  • 安全方面 由于是使用cookie来识别用户信息的,如果cookie被拦截,用户会很容易受到跨站请求伪造的攻击。

  • 负载均衡 当服务器A保存了用户A的数据之后,在下一次用户A服务器A时由于服务器A访问量较大,被转发到服务器B,此时服务器B没有用户A的数据,会导致session失效。

  • 内存开销 随着时间推移,用户的增长,服务器需要保存的用户登录信息也就越来越多的,会导致服务器开销越来越大。

为什么说JWT不需要额外的开销

JWT为三个部分组成,分别是HeaderPayloadSignature,使用.符号分隔。

// 像这样子
xxxxx.yyyyy.zzzzz

标头 header

标头是一个JSON对象,由两个部分组成,分别是令牌是类型(JWT)和签名算法(SHA256RSA

{
 "alg": "HS256",
 "typ": "JWT"
}

负荷 payload

负荷部分也是一个JSON对象,用于存放需要传递的数据,例如用户的信息

{
 "username": "_island",
 "age": 18
}

此外,JWT规定了7个可选官方字段(建议)

属性说明
issJWT签发人
expJWT过期时间
subJWT面向用户
audJWT接收方
nbfJWT生效时间
iatJWT签发时间
jtiJWT编号

签章 signature

这一部分,是由前面两个部分的签名,防止数据被篡改。 在服务器中指定一个密钥,使用标头中指定的签名算法,按照下面的公式生成这签名数据

HMACSHA256(
 base64UrlEncode(header) + "." +
 base64UrlEncode(payload),
 secret)

在拿到签名数据之后,把这三个部分的数据拼接起来,每个部分中间使用.来分隔。这样子我们就生成出一个了JWT数据了,接下来返回给客户端储存起来。而且客户端在发起请求时,携带这个JWT在请求头中的Authorization字段,服务器通过解密的方式即可识别出对应的用户信息。

JWT优势和弊端

优势

  • 数据体积小,传输速度快

  • 无需额外资源开销来存放数据

  • 支持跨域验证使用

弊端

  • 生成出来的Token无法撤销,即使重置账号密码之前的Token也是可以使用的(需等待JWT过期)

  • 无法确认用户已经签发了多少个JWT

  • 不支持refreshToken

关于refreshToken

refreshTokenOauth2认证中的一个概念,和accessToken一起生成出来的。

当用户携带的这个accessToken过期时,用户就需要在重新获取新的accessToken,而refreshToken就用来重新获取新的accessToken的凭证。

为什么要有refreshToken

当你第一次接触的时候,你有没有一个这样子的疑惑,为什么需要refreshToken这个东西,而不是服务器端给一个期限较长甚至永久性的accessToken呢?

抱着这个疑惑我在网上搜寻了一番,

其实这个accessToken的使用期限有点像我们生活中的入住酒店,当我们在入住酒店时,会出示我.们的证明来登记获取房卡,此时房卡相当于accessToken,可以访问对应的房间,当你的房卡过期之后就无法再开启房门了,此时就需要再到前台更新一下房卡,才能正常进入,这个过程也就相当于refreshToken

accessToken使用率相比refreshToken频繁很多,如果按上面所说如果accessToken给定一个较长的有效时间,就会出现不可控的权限泄露风险。

使用refreshToken可以提高安全性

  • 用户在访问网站时,accessToken被盗取了,此时攻击者就可以拿这个accessToke访问权限以内的功能了。如果accessToken设置一个短暂的有效期2小时,攻击者能使用被盗取的accessToken的时间最多也就2个小时,除非再通过refreshToken刷新accessToken才能正常访问。

  • 设置accessToken有效期是永久的,用户在更改密码之后,之前的accessToken也是有效的

总体来说有了refreshToken可以降低accessToken被盗的风险

关于JWT无感刷新TOKEN方案(结合axios)

业务需求

在用户登录应用后,服务器会返回一组数据,其中就包含了accessTokenrefreshToken,每个accessToken都有一个固定的有效期,如果携带一个过期的token向服务器请求时,服务器会返回401的状态码来告诉用户此token过期了,此时就需要用到登录时返回的refreshToken调用刷新Token的接口(Refresh)来更新下新的token再发送请求即可。

话不多说,先上代码

工具

axios作为最热门的http请求库之一,我们本篇文章就借助它的错误响应拦截器来实现token无感刷新功能。

具体实现

本次基于axios-bz代码片段封装响应拦截器 可直接配置到你的项目中使用 ✈️ ✈️

利用interceptors.response,在业务代码获取到接口数据之前进行状态码401判断当前携带的accessToken是否失效。 下面是关于interceptors.response中异常阶段处理内容。当响应码为401时,响应拦截器会走中第二个回调函数onRejected

下面代码分段可能会让大家阅读起来不是很顺畅,我直接把整份代码贴在下面,且每一段代码之间都添加了对应的注释

// 最大重发次数
const MAX_ERROR_COUNT = 5;
// 当前重发次数
let currentCount = 0;
// 缓存请求队列
const queue: ((t: string) => any)[] = [];
// 当前是否刷新状态
let isRefresh = false;

export default async (error: AxiosError<ResponseDataType>) => {
 const statusCode = error.response?.status;
 const clearAuth = () => {
   console.log('身份过期,请重新登录');
   window.location.replace('/login');
   // 清空数据
   sessionStorage.clear();
   return Promise.reject(error);
};
 // 为了节省多余的代码,这里仅展示处理状态码为401的情况
 if (statusCode === 401) {
   // accessToken失效
   // 判断本地是否有缓存有refreshToken
   const refreshToken = sessionStorage.get('refresh') ?? null;
   if (!refreshToken) {
     clearAuth();
  }
   // 提取请求的配置
   const { config } = error;
   // 判断是否refresh失败且状态码401,再次进入错误拦截器
   if (config.url?.includes('refresh')) {
   clearAuth();
  }
   // 判断当前是否为刷新状态中(防止多个请求导致多次调refresh接口)
   if (isRefresh) {
     // 设置当前状态为刷新中
     isRefresh = true;
     // 如果重发次数超过,直接退出登录
     if (currentCount > MAX_ERROR_COUNT) {
       clearAuth();
    }
     // 增加重试次数
     currentCount += 1;

     try {
       const {
         data: { access },
      } = await UserAuthApi.refreshToken(refreshToken);
       // 请求成功,缓存新的accessToken
       sessionStorage.set('token', access);
       // 重置重发次数
       currentCount = 0;
       // 遍历队列,重新发起请求
       queue.forEach((cb) => cb(access));
       // 返回请求数据
       return ApiInstance.request(error.config);
    } catch {
       // 刷新token失败,直接退出登录
       console.log('请重新登录');
       sessionStorage.clear();
       window.location.replace('/login');
       return Promise.reject(error);
    } finally {
       // 重置状态
       isRefresh = false;
    }
  } else {
     // 当前正在尝试刷新token,先返回一个promise阻塞请求并推进请求列表中
     return new Promise((resolve) => {
       // 缓存网络请求,等token刷新后直接执行
       queue.push((newToken: string) => {
         Reflect.set(config.headers!, 'authorization', newToken);
         // @ts-ignore
         resolve(ApiInstance.request<ResponseDataType<any>>(config));
      });
    });
  }
}

 return Promise.reject(error);
};

抽离代码

把上面关于调用刷新token的代码抽离成一个refreshToken函数,单独处理这一情况,这样子做有利于提高代码的可读性和维护性,且让看上去代码不是很臃肿

// refreshToken.ts
export default async function refreshToken(error: AxiosError<ResponseDataType>) {
   /*
  将上面 if (statusCode === 401) 中的代码贴进来即可,这里就不重复啦
  代码仓库地址: https://github.com/QC2168/axios-bz/blob/main/Interceptors/hooks/refreshToken.ts
  */
}

经过上面的逻辑抽离,现在看下拦截器中的代码就很简洁了,后续如果要调整相关逻辑直接在refreshToken.ts文件中调整即可。

import refreshToken from './refreshToken.ts'
export default async (error: AxiosError<ResponseDataType>) => {
 const statusCode = error.response?.status;

 // 为了节省多余的代码,这里仅展示处理状态码为401的情况
 if (statusCode === 401) {
   refreshToken()
}

 return Promise.reject(error);
};

者:_island

来源:juejin.cn/post/7170278285274775560

收起阅读 »

开发一个APP多少钱?

开发一个APP多少钱?开发一个APP要多少钱?相信不光是客户有这个疑问,就算是一般的程序员也想知道答案。很多程序员想在业余时间接外包挣外快,但是他们常常不知道该如何定价,如何有说服力的要价。这是因为没有一套好的计算APP开发成本的方法。由于国内没有公开的数据,...
继续阅读 »

开发一个APP多少钱?

开发一个APP要多少钱?相信不光是客户有这个疑问,就算是一般的程序员也想知道答案。很多程序员想在业余时间接外包挣外快,但是他们常常不知道该如何定价,如何有说服力的要价。这是因为没有一套好的计算APP开发成本的方法。由于国内没有公开的数据,而且大家对于报价都喜欢藏着掖着,这里我们就整理了国外一些软件外包平台的资料,帮助大家对Flutter APP开发成本有一个直观而立体的认识。(注意,这里是以美元单位计算,请不要直接转换为RMB,应当根据消费力水平来衡量)

跨平台项目正在慢慢取代原生应用程序的开发。跨平台的方法更省时,也更节省成本。最近,原生应用程序的主要优势是其性能。但随着新的跨平台框架给开发者带来更多的力量,这不再是它们的强项。

Flutter就是其中之一。这个框架在2017年发布,并成为跨平台社区中最受推崇的框架之一。Statista称,Flutter是2021年十大最受欢迎的框架之一,并在最受欢迎的跨平台框架中排名第一。对于这样一项新技术来说,这是一个相当不错的结果。它的高需求使我们可以定义软件建设的大致成本。

Flutter应用程序的开发成本根据项目定义的工作范围而变化:

  • 简单的 Flutter 应用程序:$40,000 - $60,000

  • 中等复杂度应用程序:$60,000 – $120,000

  • 高度复杂的 Flutter 应用程序:$120,000 – $200,000+

有一些决定性的因素来回答Flutter应用开发的成本是多少。

在这篇文章中,我们将讨论不同行业的Flutter应用开发成本,找出如何计算精确的价格,以及如何利用这个框架削减项目开支。

Flutter应用的平均开发成本

应用程序的开发成本是一个复杂的数字,取决于各种因素 ——功能的复杂性,开发人员的位置,支持的平台,等等。如果不进行研究和了解所有的要求,就不可能得出项目的价格。

不过,你还是可以看看按项目复杂程度分类的估算:

  • 一个具有简单功能的软件,如带有锻炼建议、膳食计划、个人档案和体重日记的健身应用,其成本从26,000美元到34,800美元

  • 一个中等复杂度的软件,如带有语音通话、消息通信,Flutter应用的开发成本将从34,950美元到48,850美元不等

  • 开发一个像 Instagram 这样具有复杂功能的应用程序的成本将从41,500美元到55,000美元不等

影响价格的因素

为了明确 Flutter 应用开发成本的所有组成部分,我们将挑选出每个因素并分析其对价格的影响。

原生应用开发 vs. Flutter

当我们估算一个原生项目时,我们要考虑到两个平台的开发时间。Flutter是一个跨平台的框架,可以让开发者为Android和iOS编写同一个代码库。这一特点使开发时间减半,使Flutter应用程序的开发成本比原生的低

Flutter 的非凡之处在于它优化了代码并且没有性能问题。Flutter在所有设备上都能提供稳定的接近 60 FPS,如果设备支持,甚至可以提供120 FPS。

然而,Flutter也有一些缺点。如果你的项目需要Wear OS版本或智能电视应用,就会面临一些麻烦。从技术上讲,你可以为这些平台建立一个Flutter应用程序。但是,Flutter的很多开发功能并不被Wear OS所支持。在安卓电视的情况下,必须从头开始建立控制逻辑。原因是安卓电视只读取遥控器的输入,而Flutter则适用于触摸屏和鼠标移动。这一事实会减慢开发进程,给开发者带来麻烦,并增加Flutter应用的开发成本。

这就是为什么如果你的目标是特定的平台,最好去做原生开发。

功能的复杂性

功能是应用程序的主要组成部分。也是影响Flutter应用程序开发成本的主要因素。简单的功能(如登录)需要最少的工作量,而视频通话的集成可能需要长达 2-3 周的开发时间。

让我们想象一下,要建立一个类似 Instagram 的应用程序。照片上传功能需要大约13小时的开发时间。以每小时50美元的平均费率计算,这将花费650美元。然而,要建立用于照片编辑的过滤器,开发团队将不得不花费30至120小时,这取决于它们的类型和数量。一家软件开发公司将为这个功能收取1500-6000美元。

Flutter应用开发中最昂贵的功能

功能描述大约时间(小时)大约成本($50/h)
导航位置地图开发194$9,700
聊天视频、音频、文字聊天188$9,400
支付集成与 PayPal 集成,添加信用卡支付70$3,500

开发商的位置和所选择的雇用方式

影响总成本的另一个方面是你在雇用项目专家时选择的就业方式:

自由职业者

由于有机会减少开发费用,这种选择被广泛采用。然而,就Flutter应用的开发而言,无法保证自由职业者的能力和质量。此外,在支持、维护和更新服务方面,这样的专家也没有优势,因为他们可能会转到另一个项目,从而无法建立长期的合作伙伴关系。

内部团队

在这种情况下,你要负责项目开发管理,以及搜索和检查潜在雇主的经验和知识。此外,内部团队的聚集需要一排额外的费用,如购买硬件,租用办公室,病假,工资,等等。因此,这些条件大大增加了总成本。

外包公司

项目外包指的是已经组建的专家团队,具有成熟深入的资质,接手所有的创作过程。这种选择是一种节省开发投资和避免影响产品质量的好方法。除了这个事实之外,这里还有一些你将通过外包获得的好处。

  • 成本的灵活性。全球市场提供了很多准备以合理价格提供服务的外包软件开发公司。中欧已经成为实现这一目标的顶级地区,许多企业已经从来自该地的优秀开发人员的一流表现中受益。

  • 可扩展性。可以根据您的要求调整开发流程:此类公司的团队包括所有类型的开发人员,将在需要他们的能力时参与创建过程。此外,如果有必要的话,这也是加快项目完成的绝佳方式。外包提供了多种合作模式。 从专门的团队到工作人员的增援

  • 更快的产品交付。有了外包,就不需要在招聘上花费时间。你可以调整项目创建速度,例如,让更多的专家参与进来。因此,进入市场的时间缩短了,支出也减少了。只为已完成的工作付费。

  • 庞大的人才库。IT外包包括大量具有丰富专业知识和经验的技术专家。外包商为企业提供灵活的招聘机会。你可以在全球范围大量的的软件架构师中选择。

  • 可应用的技术非常多样化。根据你的项目要求,你可以从这些公司中选择一个具有相关专业知识的专家。

除了雇佣选择,开发团队的位置可能会对Flutter应用程序的开发成本产生很大的影响。在不同地区,开发人员有不同的价格。在美国,开发人员的平均费率是60美元/小时,而在爱沙尼亚,只有37美元/小时。

在下面的表格中,可以找到开发人员的每小时费率,并将它们进行比较。

Flutter开发人员在不同地区的费率:

地区每小时费率 ($)
北美$75 - $120
拉丁美洲$30 - $50
西欧$70 - $90
爱沙尼亚$30 - $50
印度$25 - $40
澳大利亚$41 - $70
非洲$20 - $49

如何计算 Flutter 应用开发成本

正如前面提到的,功能对Flutter应用开发成本的影响最大。Flutter 适用于不包含原生功能的项目。但是当涉及到地图、流媒体、AR和后台进程时,开发人员必须为iOS和Android单独构建这些功能,然后再与Flutter结合。

让我们回到例子上。如果是原生开发,你将需要大约60-130个小时在你的应用程序中实现AR过滤器。Flutter开发将需要约80-150小时,因为AR是一个原生功能。考虑到50美元/小时的费率,我们应该把它乘以开发时间。这个公式可以用来计算出最终的Flutter应用开发成本。

除了这个公式外,还有一件事在初始阶段很重要。

发现阶段

一个糟糕的发现阶段可能导致整个项目的崩溃。但为什么这个阶段如此重要?在发现阶段,业务分析人员和项目经理与你举行会议,找出可能的风险,并提出消除这些风险的解决方案

粗略估算

粗略估算的精确度从75%到25%不等。这个评估包括在客户和软件团队合作的初级阶段。它也有助于双方决定是否成为合作伙伴。粗略估算的主要目的是计算完成项目所需的最短和最长时间以及大致的总成本,以便客户知道在开发流程中需要多少投资。此外,这个估算包括整个创建过程,分为几个阶段。这个文件不应该被认为是有固定条款和条件的文件。它是为客户准备的,只是为了通知他们。

一个粗略的估算包括:

  • 主要部分包含准备工作。它们在不同的项目中都是一样的,包括产品描述、数据库设置、REST架构。该部分所指出的项目不一定一次就能完成。有些工作是在整个项目中完成的。

  • 开发与加密过程有关。这部分包括要实现的功能、屏幕和特性。开发部分包括 "业务逻辑 "和 "UI/UX "要求,以及某部分工作的小时数。

  • 为了更有效地实现功能,需要整合框架和库,并相应减少开发时间和相应的花费。

  • 非开发工作主要与技术写作有关。专家们准备详细的代码文档和准备有关产品创建的其他数据。

  • 建议部分包含了各种改进建议。

当所有的问题都解决后,会进入发现阶段并创建一个项目规范。客户必须积极参与,因为会根据客户提供的数据来建立项目规范。在下一个阶段,客户应当创建他们的应用程序草稿图。这是一个用户界面元素在屏幕上的位置示意图。

然后,开发人员和业务分析师会对客户的Flutter应用开发成本进行详细的估算。有了准确的预算、项目要求和草稿图,就可以签署合同并开始开发阶段。

如你所见,发现阶段是任何项目的关键部分。没有这个阶段,你就无法知道开发所需的价格和时间,因为会有太多的变数。如果在任何阶段出了问题,整个项目的计划就会出问题。这就是为什么客户必须与软件开发公司合作,使他们能够建立客户需要的项目。

额外费用

就像任何其他产品一样,客户的应用程序需要维护和更新,以便在市场上保持成功。这导致了影响Flutter应用程序开发成本的额外费用。

服务器

如果要处理和存储用户产生的数据,就必须考虑到服务器的问题。脆弱的服务器会导致用户方面的低性能和高响应时间。此外,不可靠的服务器和脆弱的保护系统会导致你的用户的个人数据泄露。为了减少风险,团队只信任可靠的供应商,如亚马逊EC2。根据AWS价格计算器,一台8核CPU和32G内存的工作服务器将花费大约1650美元/年。在计算整个Flutter应用程序的开发成本时,请牢记这笔费用。

UI/UX设计

移动应用的导航、排版和配色是UI/UX设计师应该注意的主要问题。他们还应该向你提供你的应用程序的原型。根据你的应用程序的复杂性,设计可能需要40到90多个小时。这一行的费用将使Flutter应用的开发成本提高到2000-4500美元

发布到应用商店

当你已经有了一个成品,你必须在某个地方发布它。Google Play和App Store是应用程序分发的主要平台。然而,这些平台在应用发布前会收取费用:

  • Google Play 帐号一次收取25美元,可以永久使用

  • 而Apple Store 收取99美元的年费,只要你的APP还想待在应用商店,每年都得花费这笔钱

除此之外,这两个平台对每次产生的应用内购买行为都有30%的分成。如果你通过订阅模式发布你的应用,那你只能得到70%收益。然而,最近Google Play和App Store已经软化了他们的政策。目前,他们对每一个购买了十二个月订阅的账户只收取15%的分成。

应用维护和更新

应用商店排行榜的应用能保持其地位是有原因的。他们通过不断的升级和全新的功能吸引客户。即使你的应用是完美的,但没有更新将导致停滞,用户可能卸载你的应用程序。在完美的构想里,你应该雇用一家开发应用程序的公司。他们从一开始就为你的项目工作。注意,应用程序的维护费用在应用程序的生命周期内会上升。公司通常将Flutter应用开发成本的15-20%纳入应用维护的预算。然而,你的应用程序拥有稳定受众的时间越长,需要投入的更新资金就越多。在一定时间内,你花在更新上的钱比花在实际开发上的钱多,这并不奇怪。尽管如此,但是你的应用产生的收入多于损失,所以这是一项值得的投资。不幸的是,随着新的功能发布可能出现新的错误和漏洞。你不能对这个问题视而不见,因为它使用户体验变差,并为欺诈者提供了新的漏洞。有一些软件开发公司会提供发布后的支持,包括开发新功能、测试和修复错误。

按类型划分的开发成本

由于你已经知道影响价格的主要和次要因素,现在是时候对不同应用程序的Flutter开发成本进行概述了。这里估算了来自不同行业和不同复杂程度的几个现有应用程序的开发成本。

分别是:

  • 交通运输

  • 流媒体

  • 社交媒体

Flutter 应用程序开发成本:交通运输

示例:BlaBlaCar

功能实现的大概时间:438 小时

大概费用:21,900 美元

运输应用程序需要用户档案、司机和乘客的角色、支付网关和GPS支持。请注意,如果你使用Flutter来构建地理定位等本地功能,整个项目的开发时间可能会增加。

请注意,下面的估算不包括代码文档、框架集成、项目管理等方面的时间。

下面是一个类似BlaBlaCar的应用程序的基本功能的粗略估计,基于Flutter的交通应用开发成本:

功能开发时间(小时)大概费用(美元)
注册28$1400
登录(通过电邮和 Facebook)22$1350
推送通知20$1000
用户资料77$3850
支付系统40$2000
乘车预订80$4000
乘车支付+优惠券42$2100
地理定位26$1300
司机端103$5150

Flutter应用程序开发成本:流媒体

例子: Twitch, Periscope, YouTube Live

功能实现的大概时间: 600小时

大概的成本: $30,000

流媒体应用程序是一个复杂的软件。它要求开发团队使用流媒体协议(这不是Flutter的强项),开发与观众沟通的文本聊天,推送通知,使用智能手机的摄像头,等等。其中一些有捐赠系统,与第三方的多种集成,甚至还有付费的表情符号。以下是一个类似Twitch的应用程序的基本功能的粗略估计。

基于Flutter的流媒体应用开发成本:

功能开发时间(小时)大概费用(美元)
注册20$1000
登录(通过电邮和 Facebook)23$1150
个人资料43$2150
搜索系统36$1800
流媒体协议20$1000
播放器集成33$1650
流管理(启动/关闭,设置比特率)120$6000
聊天146$7300
捐赠系统35$1750
支付网关64$3200
频道管理40$2000
推送通知20$1000

Flutter应用程序开发成本:消息通信

例子: Facebook Messenger, WhatsApp, Telegram

功能实现的大概时间: 589小时

估计成本: $29,450

消息通信工具的功能乍一看很简单,但详细的分析证明情况恰恰相反。整合各种状态的聊天(打字,在线/离线,阅读),文件传输,语音信息需要大量的时间。如果再加上语音通话和群组聊天,事情会变得更加复杂。

让我们单独列出每个功能及其成本,基于Flutter的消息通信应用开发成本:

功能开发时间(小时)大概费用(美元)
注册45$2250
登录27$1350
聊天156$7800
发送媒体文件40$2000
语音消息35$1750
群聊57$2850
语音电话100$5000
通知15$750
设置76$3800
搜索38$1900

作者:编程之路从0到1
来源:juejin.cn/post/7170168967690977293

收起阅读 »

Android性能优化方法论

作为一名开发,性能优化是永远绕不过去的话题,在日常的开发中,我们可肯定都会接触过。Android 的性能优化其实是非常成熟的了,成熟的套路,成熟的方法论,成熟的开源框架等等。对于接触性能优化经验较少的开发者来说,可能很少有机会能去总结或者学到这些成熟的套路,方...
继续阅读 »

作为一名开发,性能优化是永远绕不过去的话题,在日常的开发中,我们可肯定都会接触过。Android 的性能优化其实是非常成熟的了,成熟的套路,成熟的方法论,成熟的开源框架等等。

对于接触性能优化经验较少的开发者来说,可能很少有机会能去总结或者学到这些成熟的套路,方法论,或者框架。所以作为一位多年长期做性能优化的开发者,在这篇文章中对性能优化的方法论做一些总结,以供大家借鉴。


性能优化的本质

首先,我先介绍一下性能优化的本质。我对其本质的认知是这样的:性能优化的本质是合理且充分的使用硬件资源,让程序的表现更好,并且程序表现更好的目的则是为了获取更多来自客户的留存,使用时长,口碑、利润等收益。

所以基于本质来思考,性能优化最重要的两件事情:

  1. 合理且充分的使用硬件资源

  1. 让程序表现更好,并取得收益

下面讲一下这两件事情。

合理且充分的使用硬件资源

充分表示能将硬件的资源充分发挥出来,但充分不一定是合理的,比如我们一下子打了几百个线程,cpu 被充分发挥了,但是并不合理,所以合理表示所发挥出来的硬件资源能给程序表现有正向的作用。

硬件资源包括:CPU,内存,硬盘,电量,流量(不属于硬件资源,不过也归于需要合理使用的资源之一)等等。

下面举几个合理且充分的使用硬件资源的例子:

  1. CPU 资源的使用率高,但并不是过载的状态,并且 cpu 资源主要为当前场景所使用,而不是被全业务所分散消耗。比如我们优化页面打开速度,速度和 cpu 有很大的关系,那么我们首先要确保 cpu 被充分发挥出来了,我们可以使用多线程、页面打开前提前预加载等策略,来发挥手机的 cpu。但是在打开页面的时候,我们要合理的确保 cpu 资源主要被打开页面相关的逻辑所使用,比如组件创建,数据获取,页面渲染等等,至于其他和当前打开页面场景联系较少的逻辑,比如周期任务,监控,或者一些预加载等等都可以关闭或者延迟,以此减少非相关任务对 cpu 的消耗,

  1. 内存资源缓使用充分,并且又能将 OOM 等异常控制在合理范围内。比如我们做内存优化,内存优化并不是越少越好,相反内存占用多可能让程序更快,但是内存占用也不能太高,所以我们可以根据不同档次机型的 OOM 率,将内存的占用控制在充分使用并且合理的状态,低端机上,通过功能降级等优化,减少内存的使用,高端机上,则可以适当提升内存的占用,让程序表现的更好。

  1. ……

让程序表现更好,并取得收益

我们有很多直接的指标来度量我性能优化取得的收益,比如做内存优化可以用 pss,java 内存占用,native 内存占用等等;做速度优化,可以用启动速度,页面打开速度;做卡顿优化,这用帧率等等。掌握这些指标很重要,我们需要知道如何能正确并且低开销的监控这些指标数据。

除了上面的直接指标外,我们还需要了解性能优化的最终体现指标,用户留存率,使用时长,转换率,好评率等指标。有时候,这些指标才是最终度量我们性能优化成果的数据,比如我们做内存优化,pss 降低了 100 M,但仅仅只是内存占用少了 100M 并没有太大的收益,如果这个 100M 体现在对应用的存活时间,转化率的提升上,那这 100 M 的优化就是值得的,我们再向上报告我们产出时,也更容易获得认可。

如何做好性能优化

讲完了性能优化的本质,我再讲讲如何做好性能优化。我主要从下面这三个方面来讲解

  1. 知识储备

  1. 思考的角度和方式

  1. 形成完整的闭环

知识储备

想要做好性能优化,特别是原创性、或者完善并且体系的、或者效果很好的优化,不是我们从网上看一些文章然后模仿一下就能进行,需要我们有比较扎实的知识储备,然后基于这些知识储备,通过深入思考,去分析我们的应用,寻找优化点。我依然举一些例子,来说明硬件层面,系统层面和软件层面的知识对我们做好性能优化的帮助。

硬件层面

在硬件层面,我们需要处理器的体系结构,存储器的层次结构有一定的了解。如果我们如果不知道 cpu 由几个核组成,哪些是大核,哪些是小核,我们就不会想到将核心线程绑定大核来提升性能的优化方案;如果我们不了解存储结构中寄存器,高速缓存,主存的设计,我们就没法针对这一特效来提升性能,比如将核心数据尽量放在高速缓存中就能提升不少速度相关的性能。

系统层面

对操作系统的熟悉和了解,也是帮助我们做好性能优化不可缺少的知识。我在这里列一下系统层面需要掌握的知识,但不是全的,Linux的知识包括进行管理和调度,内存管理,虚拟内存,锁,IPC通信等。Android系统的知识包括虚拟机,核心服务如ams,wms等等,渲染,以及一些核心流程,如启动,打开activity,安装等等。

如果我们不了解Linux系统的进程调度系统,我们就没法充分利用进程优先来帮助我们提升性能;如果我们不熟悉 Android 的虚拟机,那么围绕这虚拟机一些相关的优化,比如 oom 优化,或者是 gc 优化等等都无法很好的开展。

软件层面

软件层面就是我们自己所开发的 App,在性能优化中,我们需要对自己所开发的应用尽可能得熟悉。比如我们需要知道自己所开发的 App 有哪些线程,都是干嘛的,这些线程的 cpu 消耗情况,内存占用多少,都是哪些业务占用的,缓存命中率多少等等。我们需要知道自己所开发的 App 有哪些业务,这些使用都是干嘛的,使用率多少,对资源的消耗情况等等。

除了上面提到的三个层面的知识,想要深入做好性能优化,还需要掌握更多的知识,比如汇编,编译器、编程语言、逆向等等知识。比如用c++ 写代码就比用java写代码运行更快,我们可以通过将一些业务替换成 c++ 来提高性能;比如编译期间的内联,无用代码消除等优化能减少包体积;逆向在性能优化上的用处也非常大,通过逆向我们可以修改系统的逻辑,让程序表现的更好。

可以看到,想要做好性能优化,需要庞大的知识储备,所以性能优化是很能体现开发者技术深度和广度的,这也是面试时,一定会问性能优化相关的知识的原因。这是知识储备不是一下就能形成的,需要我们慢慢的进行学习和积累。


思考的角度及方式

讲完了知识储备,再讲讲思考的角度和方式。需要注意它和知识储备没有先后关系,并不是说要有了足够的技术知识后才能开始考虑如何思考。思考的角度和方式体现在我们开发的所有生命周期中,即使是新入门的开发,也可以锻炼自己从不同的角度和方式去进行思考。下面就聊一聊我在做性能优化的过程中,在思考的角度和方式上的一些认知。为了让大家能更形象的理解,我就都以启动优化来讲解。

思考角度

我这里主要通过应用层,系统词,硬件层这三个角度来介绍我对启动速度优化的思考。

应用层

做启动速度优化时,如果从应用层来考虑,我会基于业务的维度考虑所加载的业务的使用率,必要性等等,然后制定优先级,在启动的时候只加载首屏使用,或者使用率高的业务。所以接着我就可以设计启动框架用来管理任务,启动框架要设计好优先级,并且能对这些初始化的任务有使用率或者其他性能方面的统计,比如这些任务初始化后,被使用率的概率是多少,又或者初始化之后,对业务的表现提升提现在哪,帮助有多大。

从应用层的思考主要是基于对业务的管控或者对业务进行优化来提升性能。

系统层

以及系统层来考虑启动优化也有很多点,比如线程和线程优先级维度,在启动过程中,如何控制好线程数量,如何提高主线程的优先级,如何减少启动过程中不相关的线程,比如 gc 线程等等。

硬件层

从硬件层来考虑启动优化,我们可以从 cpu 的利用率,高速缓存cache的命中率等维度来考虑优化。

除了上面提到的这几个角度,我们还可以有更多角度。比如跳出本设备之外来思考,是否可以用其他的设备帮助我们加速启动。google play 就有类似的优化,gp会上传一些其他机器已经编译好的机器码,然后相同的设备下载这个应用时,也会带着这些编译好的机器码一起下载。还有很常用的服务端渲染技术,也是让服务端线渲染好界面,然后直接暂时静态模块来提升页面打开速度;又或者站在用户的角度去思考,想一想到底什么样的优化对用户感知上是有好处的,比如有时候我们再做启动或者页面打开速度优化,会给用户一个假的静态页面让用户感知已经打开了,然后再去绑定真实的数据。

做性能优化时,考虑的角度多一些,全面一些,能帮助我们想出更多的优化方案。

思考方式

除了锻炼我们站在不同的角度思考问题,我们还可以锻炼自己思考问题的方式,这里介绍自上而下和自下而上两种思考方式。

自上而下

我们做启动优化,自上而下的优化思路可能是直接从启动出发,然后分析启动过程中的链路,然后寻找耗时函数,将耗时函数放子线程或者懒加载处理,但是这种方式会导致优化做的不全面。比如将耗时的任务都放在子线程,我们再高端机上速度确实变快了,但是在低端机上,可能会降低了启动速度,因为低端机的 cpu 很差,线程一多,导致 cpu 满载,主线程反而获取不到运行时间。其次,如果从上层来看,一个函数执行耗时久可能并不是这个函数的问题,也可能是因为该函数长时间没有获取到 cpu 时间。

自上而下的思考很容易让我们忽略本质,导致优化的效果不明显或者不完整。

自下而上

自下而上思考就是从底层开始思考,还是以启动优化为例子,自下而上的思考就不是直接分析启动链路,寻找慢函数,而是直接想着如何在启动过程中合理且充分的使用 cpu 资源,这个时候我们的方案就很多了,比如我们可能会想到不同的机型 cpu 能力是不一样的,所以我们会针对高端机和低端机来分别优化,高端机上,我们想办法让cpu利用率更高,低端机上想办法避免 cpu 的超载,同时配合慢函数,线程,锁等知识进行优化,就能制定一套体系并且完整的启动优化方案。


完整的闭环

上面讲的都是如何进行优化,优化很重要,但并不是全部,在实际的性能优化中,我们需要做的有监控,优化,防劣化,数据收益收集等等,这些部分都做好才能形成一个完整的闭环。我一一讲一下这几个部分:

  • 监控:完整的监控应用中各项性能的指标,仅仅有指标监控是不够的,我们还需要尽量做归因的监控。比如内存监控,我们不仅仅要监控我们应用的内存指标,还可以还要能监控到各个业务的内存使用占比,大集合,大图片,大对象等等归因项。并且我们的监控同样要基于性能考虑去设计。完整的监控能让我们更高效的发现和解决异常。

  • 优化:优化就是前面提到的,合理且充分的使用硬件资源,让程序的表现更好。

  • 防劣化:防劣化也是有很多事情可以做的,包括建立完善的线下性能测试,线上监控的报警等。比如内存,我们可以在线下每天通过monkey跑内存泄露并提前治理,这就是防劣化。

  • 数据收益收集。学会用好A/B测试,学会关注核心价值的指标。比如我们做内存优化,一味的追求降低应用内存的占用并不是最优,内存占用的多,可能会让我们的程序运行更快,用户体验更好,所以我们需要结合崩溃率,留存等等这种体验核心价值的指标,来确定内存到底要不要继续进行优化或者优化到多少。

小结

上面就是我在多年的性能优化经验中总结出来的认知及方法论。只有了解了这些方法论,我们才能在进行性能优化时,如鱼得水,游刃有余。

这篇文章也没有介绍具体的优化方案,因为性能优化的方案通过一篇文章是介绍不完的,大家有兴趣可以看看我写的掘金小册《Android 性能优化》,可以体系的学一学如何进行优化,上面讲解的方法论,也都会在这本小册中体现出来。

作者:helson赵子健
来源:juejin.cn/post/7169486107866824717

收起阅读 »

万维网之父:Web3 根本不是 Web,我们应该忽略它

万维网之父、英国计算机科学家 Tim Berners-Lee 在 2022 年 Web 峰会上表示,区块链并不是构建下一代互联网的可行解决方案,我们应该忽略它。他有自己的 Web 去中心化项目,叫作 Solid。Berners-Lee 在里斯本举行的 Web ...
继续阅读 »

万维网之父、英国计算机科学家 Tim Berners-Lee 在 2022 年 Web 峰会上表示,区块链并不是构建下一代互联网的可行解决方案,我们应该忽略它。

他有自己的 Web 去中心化项目,叫作 Solid。

Berners-Lee 在里斯本举行的 Web 峰会上说,“在讨论新技术的影响时,你必须理解我们正在讨论的术语的真正含义,而不仅仅是停留在流行词的层面,这一点很重要。”

“事实上,Web3 被以太坊那班人用在了区块链上,这是一件可耻的事。事实上,Web3 根本就不是 Web。”

在科技行业,Web3 是一个模糊的术语,被用来描述一个假设的未来互联网版本,它比现在更加去中心化,不被亚马逊、微软和谷歌等少数巨头玩家所主导。

它涉及到一些新的技术,包括区块链、加密货币和非同质化的的代币。

虽然 Berners-Lee 的目标是将个人数据从大型科技公司的控制中解放出来,但他不相信支撑比特币等加密货币的分布式账本技术区块链会是解决方案。

他说,“区块链协议可能对某些事情有用,但对 Solid 来说不是。”Solid 是 Berners-Lee 领导的一个 Web 去中心化项目。“它们太慢、太贵、太公开。个人数据存储必须快速、廉价和私密。”

他说,“忽略所谓的 Web3,那些构建在区块链之上的随机的 Web3,我们不会把它用在 Solid 上。”

Berners-Lee 说,人们经常把 Web3 和“Web 3.0”混为一谈,而“Web 3.0”是他提出的重塑互联网的提议。他的初创公司 Inrupt 旨在让用户控制自己的数据,包括如何访问和存储数据。据 TechCrunch 报道,该公司在去年 12 月获得了一轮 3000 万美元的融资。

Berners-Lee 表示,个人数据被谷歌和 Facebook 等少数大型科技平台独自占有,它们利用这些数据“将我们锁定在它们的平台上”。

他说,“其结果就是一场大数据竞赛,赢家是控制最多数据的公司,其他的都是输家。”

他的初创公司旨在通过三种方式解决这个问题:

  • 全球“单点登录”功能,可以让任何人从任何地方登录。

  • 允许用户与其他人共享数据的登录 ID。

  • 一个“通用 API”或应用程序编程接口,允许应用程序从任何来源提取数据。

Berners-Lee 并不是唯一一个对 Web3 持怀疑态度的知名科技人士。一些硅谷领袖也对 Web3 提出了异议,比如推特联合创始人 Jack Dorsey 和特斯拉首席执行官 Elon Musk。

批评人士表示,Web3 容易出现与加密货币相同的问题,比如欺诈和安全缺陷。

原文链接:https://www.cnbc.com/2022/11/04/web-inventor-tim-berners-lee-wants-us-to-ignore-web3.html

作者 | Ryan Browne

译者 | 明知山

策划 | Tina

收起阅读 »

按时上班有全勤奖,按时下班叫什么奖?


网友评论:

@快溜儿的还我昵称:老板有话对你奖

@放学去后山:节约用电奖

@小镜子375:领导不鼓励下班

@钱灿灿秋啾啾:福报都不接吗?


来源于网络

Spring Boot 分离配置文件的 N 种方式

今天聊一个小伙伴在星球上的提问:问题不难,解决方案也有很多,因此我决定撸一篇文章和大家仔细说说这个问题。1. 配置文件位置首先小伙伴们要明白,Spring Boot 默认加载的配置文件是 application.properties 或者 applicatio...
继续阅读 »

今天聊一个小伙伴在星球上的提问:


问题不难,解决方案也有很多,因此我决定撸一篇文章和大家仔细说说这个问题。

1. 配置文件位置

首先小伙伴们要明白,Spring Boot 默认加载的配置文件是 application.properties 或者 application.yaml,默认的加载位置一共有五个,五个位置可以分为两类:

从 classpath 下加载,这个又细分为两种:

  1. 直接读取 classpath 下的配置文件,对应到 Spring Boot 项目中,就是 resources 目录下的配置。

  2. 读取 classpath:/config/ 目录下的文件,对应到 Spring Boot 项目中就是 resources/config 目录下的配置。

这两种情况如下图:


从项目所在的当前目录下加载,这个又细分为三种情况:

  1. 从项目当前目录下加载配置文件。

  2. 从项目当前目录下的 config 文件夹中加载配置文件。

  3. 从项目当前目录下的 config 文件夹的子文件夹中加载(孙子文件夹不可以)。

这三种情况如下图:


config 目录下的配置文件可以被加载,config/a 目录下的配置文件也可以被加载,但是 config/a/b 目录下的配置文件不会被加载,因为不是直接子文件夹。

配置文件可以放在这么多不同的位置,如果同一个属性在多个配置文件中都写了,那么后面加载的配置会覆盖掉前面的。例如在 classpath:application.yaml 中设置项目端口号是 8080,在 项目当前目录/config/a/application.yaml 中设置项目端口是 8081,那么最终的项目端口号就是 8081。

这是默认的文件位置。

如果你不想让自己的配置文件叫 application.properties 或者 application.yaml,那么也可以自定义配置文件名称,只需要在项目启动的时候指定配置文件名即可,例如我想设置我的配置文件名为 app.yaml,那么我们可以在启动 jar 包的时候按照如下方式配置,此时系统会自动去上面提到的五个位置查找对应的配置文件:

java -jar boot_config_file-0.0.1-SNAPSHOT.jar --spring.config.name=app

如果项目已经打成 jar 包启动了,那么前面所说的目录中,后三个中的项目当前目录就是指 jar 包所在的目录。

如果你不想去这五个位置查找,那么也可以在启动 jar 包的时候明确指定配置文件的位置和名称,如下:

java -jar boot_config_file-0.0.1-SNAPSHOT.jar --spring.config.location=optional:classpath:/app.yaml

注意,我在 classpath 前面加上了 optional: 表示如果这个配置文件不存在,则按照默认的方式启动,而不会报错说找不到这个配置文件。如果不加这个前缀,那么当系统找不到指定的配置文件时,就会抛出 ConfigDataLocationNotFoundException 异常,进而导致应用启动失败。

如果配置文件和 jar 包在相同的目录结构下,如下图:


那么启动脚本如下:

java -jar boot_config_file-0.0.1-SNAPSHOT.jar --spring.config.location=optional:javaboy/app.yaml

如果 spring.config.location 的配置,只是指定了目录,那么必须以 / 结尾,例如上面这个启动脚本,也可以按照如下方式启动:

java -jar boot_config_file-0.0.1-SNAPSHOT.jar --spring.config.location=optional:javaboy/ --spring.config.name=app

通过 spring.config.location 属性锁定配置文件的位置,通过 spring.config.name 属性锁定配置文件的文件名。

2. 额外位置

前面我们关于配置文件位置的设置,都是覆盖掉已有的配置,如果不想覆盖掉 Spring Boot 默认的配置文件查找策略,又想加入自己的,那么可以按照如下方式指定配置文件位置:

java -jar boot_config_file-0.0.1-SNAPSHOT.jar --spring.config.additional-location=optional:javaboy/app.yaml

如果这个额外指定的配置文件和已有的配置文件有冲突,那么还是以后来者为准。

3. 位置通配符

有一种情况,假设我有 redis 和 mysql 的配置,我想将之放在两个不同的文件夹中以便于管理,像下面这样:


那么在项目启动时,可以通过通配符 * 批量扫描相应的文件夹:

java -jar boot_config_file-0.0.1-SNAPSHOT.jar --spring.config.location=optional:config/*/

使用通配符批量扫描 mysql 和 redis 目录时,默认的加载顺序是按照文件夹的字母排序,即先加载 mysql 目录后加载 redis 目录。

需要注意的是,通配符只能用在外部目录中,不可以用在 classpath 中的目录上。另外,包含了通配符的目录,只能有一个通配符 *,不可以有多个,并且还必须是以 */ 结尾,即一个目录的最后部分可以不确定。

4. 导入外部配置

从 Spring Boot2.4 开始,我们也可以使用 spring.config.import 方法来导入配置文件,相比于 additional-location 配置,这个 import 导入更加灵活,可以导入任意名称的配置文件。

spring.config.import=optional:file:./dev.properties

甚至,这个 spring.config.import 还可以导入无扩展名的配置文件,例如我有一个配置文件,是 properties 格式的,但是这个这个配置文件没有扩展名,现在我想将之作为 properties 格式的配置文件导入,方式如下:

spring.config.import=optional:file:/Users/sang/dev[.properties]

好啦,看完上面的内容,文章一开始的问题答案就不用我多说了吧~

作者:江南一点雨
来源:juejin.cn/post/7168285587374342180

收起阅读 »

Android依赖冲突解决

一、背景工程中引用不同的库(库A和B),当不同的库又同时依赖了某个库的不同版本(如A依赖C的1.1版本,B依赖C2.2版本),这时就出现了依赖冲突。二、问题解决步骤查看依赖树运行android studio的中如下task任务即可生成依赖关系,查看冲突是由哪哪...
继续阅读 »

一、背景

工程中引用不同的库(库A和B),当不同的库又同时依赖了某个库的不同版本(如A依赖C的1.1版本,B依赖C2.2版本),这时就出现了依赖冲突。

二、问题解决步骤

查看依赖树

运行android studio的中如下task任务即可生成依赖关系,查看冲突是由哪哪些库引入的(即找到库A和库B)。


排除依赖

使用 exclude group:'group_name',module:'module_name'

//剔除rxpermissions这依赖中所有com.android.support相关的依赖,避免和我们自己的冲突
implementation 'com.github.tbruyelle:rxpermissions:0.10.2', {
exclude group: 'com.android.support'
}

注意:下图中红框处表示依赖的版本由1.0.0被提升到了1.1.0。如果对1.0.0的库中的group或module进行exclude时,当库的版本被提升时,exclude将会失效,解决办法时工程中修改库的依赖版本为被提升后的版本。

使用强制版本

冲突的库包含了多个版本,这时可直接使用强制版本。在项目的主module的build.gradle的dependencies节点里添加configurations.all {},{}中的前缀是 resolutionStrategy.force ,后面是指定各module强制依赖的包,如下图所示,强制依赖com.android.tools:sdklib包的30.0.0:


作者:Android_Developer
来源:juejin.cn/post/7042951122872434696

收起阅读 »

每个前端都应该掌握的7个代码优化的小技巧

web
本文将介绍7种JavaScript的优化技巧,这些技巧可以帮助你更好的写出简洁优雅的代码。1. 字符串的自动匹配(Array.includes)在写代码时我们经常会遇到这样的需求,我们需要检查某个字符串是否是符合我们的规定的字符串之一。最常见的方法就是使用||...
继续阅读 »

本文将介绍7种JavaScript的优化技巧,这些技巧可以帮助你更好的写出简洁优雅的代码。

1. 字符串的自动匹配(Array.includes

在写代码时我们经常会遇到这样的需求,我们需要检查某个字符串是否是符合我们的规定的字符串之一。最常见的方法就是使用||===去进行判断匹配。但是如果大量的使用这种判断方式,定然会使得我们的代码变得十分臃肿,写起来也是十分累。其实我们可以使用Array.includes来帮我们自动去匹配。

代码示例:

// 未优化前的写法
const isConform = (letter) => {
if (
  letter === "a" ||
  letter === "b" ||
  letter === "c" ||
  letter === "d" ||
  letter === "e"
) {
  return true;
}
return false;
};
// 优化后的写法
const isConform = (letter) =>
["a", "b", "c", "d", "e"].includes(letter);

2.for-offor-in自动遍历

for-offor-in,可以帮助我们自动遍历Arrayobject中的每一个元素,不需要我们手动跟更改索引来遍历元素。

注:我们更加推荐对象(object)使用for-in遍历,而数组(Array)使用for-of遍历

for-of

const arr = ['a',' b', 'c'];
// 未优化前的写法
for (let i = 0; i < arr.length; i++) {
const element = arr[i];
console.log(element);
}
// 优化后的写法
for (const element of arr) {
  console.log(element);
}
// expected output: "a"
// expected output: "b"
// expected output: "c"

for-in

const obj = {
a: 1,
b: 2,
c: 3,
};
// 未优化前的写法
const keys = Object.keys(obj);
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
const value = obj[key];
// ...
}
// 优化后的写法
for (const key in obj) {
const value = obj[key];
// ...
}

3.false判断

如果你想要判断一个变量是否为null、undefined、0、false、NaN、'',你就可以使用逻辑非(!)取反,来帮助我们来判断,而不用每一个值都用===来判断

// 未优化前的写法
const isFalsey = (value) => {
if (
  value === null ||
  value === undefined ||
  value === 0 ||
  value === false ||
  value === NaN ||
  value === ""
) {
  return true;
}
return false;
};
// 优化后的写法
const isFalsey = (value) => !value;

4.三元运算符代替(if/else

在我们编写代码的时候肯定遇见过if/else选择结构,而三元运算符可以算是if/else的一种语法糖,能够更加简洁的表示if/else

// 未优化前的写法
let info;
if (value < minValue) {
info = "Value is最小值";
} else if (value > maxValue) {
info = "Value is最大值";
} else {
info = "Value 在最大与最小之间";
}
//优化后的写法
const info =
value < minValue
  ? "Value is最小值"
  : value > maxValue ? "Value is最大值" : "在最大与最小之间";

5.函数调用的选择

三元运算符还可以帮我们判断当前情况下该应该调用哪一个函数,

function f1() {
// ...
}
function f2() {
// ...
}
// 未优化前的写法
if (condition) {
f1();
} else {
f2();
}
// 优化后的写法
(condition ? f1 : f2)();

6.用对象代替switch/case选择结构

switch case通常是有一个case值对应一个返回值,这样的结构就类似于我们的对象,也是一个键对应一个值。我们就可以用我们的对象代替我们的switch/case选择结构,使代码更加简洁

const dayNumber = new Date().getDay();

// 未优化前的写法
let day;
switch (dayNumber) {
case 0:
  day = "Sunday";
  break;
case 1:
  day = "Monday";
  break;
case 2:
  day = "Tuesday";
  break;
case 3:
  day = "Wednesday";
  break;
case 4:
  day = "Thursday";
  break;
case 5:
  day = "Friday";
  break;
case 6:
  day = "Saturday";
}
// 优化后的写法
const days = {
0: "Sunday",
1: "Monday",
2: "Tuesday",
3: "Wednesday",
4: "Thursday",
5: "Friday",
6: "Saturday",
};
const day = days[dayNumber];

7. 逻辑或(||)的运用

如果我们要获取一个不确定是否存在的值时,我们经常会运用if判断先去判断值是否存在,再进行获取。如果不存在我们就会返回另一个值。我们可以运用逻辑或(||)的特性,去优化我们的代码

// 未优化前的写法
let name;
if (user?.name) {
name = user.name;
} else {
name = "Anonymous";
}
// 优化后的写法
const name = user?.name || "Anonymous";

作者:zayyo
来源:juejin.cn/post/7169420903888584711

收起阅读 »

听说你学过架构设计?来,弄个短链系统

01 引言1)背景这是本人在面试“字节抖音”部门的一道系统设计题,岗位是“后端高级开发工程师”,二面的时候问到的。一开始,面试官笑眯眯地让我做个自我介绍,然后聊了聊项目。当完美无瑕(吞吞吐吐)地聊完项目,并写了一道算法题之后。面试官就开始发问了:小伙子,简历里...
继续阅读 »

01 引言

1)背景

这是本人在面试“字节抖音”部门的一道系统设计题,岗位是“后端高级开发工程师”,二面的时候问到的。一开始,面试官笑眯眯地让我做个自我介绍,然后聊了聊项目。

当完美无瑕(吞吞吐吐)地聊完项目,并写了一道算法题之后。

面试官就开始发问了:小伙子,简历里面写到了熟悉架构设计是吧,那你知道程序设计的‘三高’指什么吗?

我心想,那不是由于程序员的系统不靠谱,领导不当人,天天加班改 BUG,导致年纪轻轻都高血脂、高血压和高血糖嘛!

但是,既然是面试,那领导肯定不愿意听这,于是我回答:程序三高,就是系统设计时需要考虑的高并发、高性能和高可用:

  • 高并发就是在系统开发的过程中,需要保证系统可以同时并行处理很多请求;

  • 高性能是指程序需要尽可能地占用更少的内存和 CPU,并且处理请求的速度要快;

  • 高可用通常描述系统在一段时间内不可服务的时候很短,比如全年停机不超过 31.5 秒,俗称 6 个 9,即保证可用的时间为 99.9999%。

于是,面试官微微点头,心想小伙子还行,既然这难不住你,那我可得出大招了,就来道系统设计题吧!

2)需求说明

众所周知,当业务场景需要给用户发送网络地址或者二维码时,由于地址的长度比较长,通常为了占用更少的资源和提升用户体验。例如,谷歌搜索“计算机”词条地址如下:

https://www.google.com/search?q=%E8%AE%A1%E7%AE%97%E6%9C%BA&ei=KNZ5Y7y4MpiW-AaI4LSACw&ved=0ahUKEwi87MGgnbz7AhUYC94KHQgwDbAQ4dUDCBA&uact=5&oq=%E8%AE%A1%E7%AE%97%E6%9C%BA&gs_lcp=Cgxnd3Mtd2l6LXNlcnAQAzIECAAQQzIFCAAQgAQyBQgAEIAEMgUIABCABDIFCC4QgAQyBQgAEIAEMgUIABCABDIFCAAQgAQyBQgAEIAEMgUIABCABDoKCAAQRxDWBBCwAzoLCC4QgAQQxwEQ0QM6FggAEOoCELQCEIoDELcDENQDEOUCGAE6BwguENQCEENKBAhBGABKBAhGGABQpBZYzSVglydoA3ABeACAAZ0DiAGdD5IBCTAuNy4xLjAuMZgBAKABAbABCsgBCsABAdoBBAgBGAc&sclient=gws-wiz-serp

很明显,如果将这一长串网址发给用户,是十分不“体面”的。而且,遇到一些有字数限制的系统里面,比如微博发帖子就有字数限制,肯定无法发送这样的长链接地址。一般的短信链接中,大多也都是短链接地址:


所以,为了提升用户体验,以及日常业务的需要。我们需要设计一个短链接生成系统,除了业务功能实现以外,我们还得为全国的网络地址服务。在这么大的用户量下,数据该如何存储,高并发如何处理呢?

02 三种链接生成方法

1)需求分析

我心想,这面试官看着“慈眉善目”还笑眯眯的,但出的题目可不简单,这种类型的系统需要考虑的点太多了,绝对不能掉以轻心。

于是,我分别从链接生成、网址访问、缓存优化和高可用四个方面开始着手设计。

首先,生成短链地址,可以考虑用 UUID 或者自增 ID。对于每一个长链接转短链地址时,都必须生成一个全局唯一的短链值,不然就会发生冲突。所以,短链接的特点是:

  • 数据存储量很大,全国的网址每天至少都是百万个短链接地址需要生成;

  • 并发量也不小,遇到同时来访问系统,按一天 3600 秒来算,平均每秒至少上千个请求数;

  • 短链接不可重复,否则会引起数据访问冲突。

2)雪花算法

首先,生成短链接,可以用雪花算法+哈希的方式来实现。


雪花算法是在分布式场景下,根据时间戳、不同的机器 ID 以及序列号生成的唯一数。它的优点在于简单方便,随取随用。

通过雪花算法取到的唯一数,再用哈希映射,将数字转为一个随机的字符串,如果短链字符串比较长,可以直接取前 6 位。但是,由于哈希映射的结果可能会发生冲突,所以对哈希算法的要求比较高。

2)62 进制数生成短链接

除了雪花算法,还可以用 62 进制数(A-Za-z0-9)来生成短链接地址。首先得到一个自增 ID,再将此值转换为 62 进制(a-zA-Z0-9)的字符串,一个亿的数字转换后也就五六位(1亿 -> zAL6e)。

将短链接服务器域名,与这个字符串进行拼接,就能得到短链接的 URL,比如:t.cn/zAL6e。

而生成自增 ID 需要考虑性能影响和并发安全性,所以我们可以通过 Redis 的 incr 命令来做一个发号器,它是一个原子操作,因此我们不必担心数字的安全性。而 Redis 是内存操作,所以效率也挺高。

3)随机数+布隆过滤器

除了自增 ID 以外,我们还可以生成随机数再转 62 进制的方法来生成短链接。但是,由于随机数可能重复,因此我们需要用布隆过滤器来去重。

布隆过滤器是一个巧妙设计的数据结构,它的原理是将一个值多次哈希,映射到不同的 bit 位上并记录下来。当新的值使用时,通过同样的哈希函数,比对各个 bit 位上是否有值:如果这些 bit 位上都没有值,说明这个数是唯一的;否则,就可能不是唯一的。

当然,这可能会产生误判,布隆过滤器一定可以发现重复的值,但 也可能将不重复的值判断为重复值,误判率大概为 0.05%,是可以接受的范围,而且布隆过滤器的效率极高。

因此,通过布隆过滤器,我们能判断生成的随机数是否重复:如果重复,就重新生成一个;如果不重复,就存入布隆过滤器和数据库,从而保证每次取到的随机数都是唯一的。

4)将短链接存到数据库

存库时,可能会因为库存量和技术栈,选用不同的数据库。但由于公司部门用 MySQL 比较多,且当前题目未提及技术选型,所以我们还是选用 MySQL 作为持久化数据库。

每当生成一个短链接后,需要在 MySQL 存储短链接到长链接的映射关系并加上唯一索引,即 zAL6e -> 真实URL。

03 重定向过程

浏览器访问短链接服务时,根据短链地址取到原始 URL,然后进行网址重定向。我们通常有两种重定向方式:

  • 一种是返回给浏览器 301 响应码永久重定向,让其后续直接访问真实的 URL 地址;

  • 一种是 302 临时重定向,让浏览器当前这次访问真实 URL,但后续请求时还是根据短链地址访问。

虽然用 301 浏览器只需一次请求,后续可以直接从浏览器获取长链接,这种方法可以提升访问速度,但是它没法统计短链接的访问次数。

所以根据业务需要,我们一般选用 302 重定向。

04 缓存设计

由于短链接是分发到多个用户手里的,可能在短时间内会多次访问,所以从 MySQL 写入/获取到长链接后可以放入 redis 缓存。

1)加入缓存

并且,短链接和长链接的对应关系一般不会频繁修改,所以数据库和缓存的一致性通过简单的旁路缓存模式来保证:

  • 读(Read)数据时,若缓存未命中,则先读 DB,从 DB 中取出数据,放入缓存,同时返回响应;

  • 写(Write)数据时,先更新 DB,再删除缓存。

当用户需要生成短链接时,先到这个映射表中看一下有没有对应的短链接地址。有就直接返回,并将这个 key-value 的过期时间增加一小时;没有就重新生成,并且将对应关系存入这个映射表中。

缓存的淘汰策略可以选用:

  • LRU:Least Recently Used,最近最少使用算法,最近经常被读写的短链地址作为热点数据可以一直存在于缓存,淘汰那些很久没有访问过的短链 key;

  • LFU:Least Frequently Userd,最近最不频繁使用算法,最近访问频率高的短链地址作为热点数据,淘汰那些访问频率较低的短链 key。

2)缓存穿透

但是,使用缓存也防止不了一些异常情况,比如“缓存穿透”。所谓缓存穿透,就是查询一个缓存和数据库中都不存在的短链接,如果并发量很大,就会导致所有在缓存中不存在的请求都打到 MySQL 服务器上,导致服务器处理不了这么多请求而阻塞,甚至崩溃。

所以,为了防止不法分子通过类似“缓存穿透”的方式来攻击服务器,我们可以采用两种方法来应对:

  • 对不存在的短链地址加缓存,key 为短链接地址,value 值为空,过期时间可以设置得短一点;

  • 采用布隆过滤器将已有的短链接多次哈希后存起来,当有短链接请求时,先通过布隆过滤器判断一下该地址是否存在数据库中;如果不在,则说明数据库中不存在该地址,就直接返回。

05 高可用设计

由于缓存和数据库持久化依赖于 Redis 和 MySQL,因此 MySQL 和 Redis 的高可用性必须要保证。

1)MySQL 高可用

MySQL 数据库采用主从复制,进行读写分离。Master 节点进行写操作,Slave 节点用作读操作,并且可以用 Keepalived 来实现高可用。

Keepalived 的原理是采用虚拟 IP,检测入口的多个节点,选用一台热备服务器作为主服务器,并分配给它一个虚拟 IP,外部请求都通过这个虚拟 IP 来访问数据库。

同时,Keepalived 会实时检测多个节点的可用状态,当发现一台服务器宕机或出现故障时,会从集群中将这台服务器踢除。如果这台服务器是主服务器,keepalived 会触发选举操作,从服务器集群中再选出一个服务器充当 master 并分配给它相同的虚拟 IP,以此完成故障转移。

并且,在 Keepalived 的支持下,这些操作都不需要人工参与,只需修复故障机器即可。

2)Redis 高可用

由于在大数据高并发的场景下,写请求全部落在 Redis 的 master 节点上,压力太大。如果一味地采用增加内存和 CPU 这种纵向扩容的方式,那么一台机器所面临的磁盘 IO,网络等压力逐渐增大,也会影响性能。

所以 Redis 采用集群模式,实现数据分片。并且,加入了哨兵机制来保证集群的高可用。它的基本原理是哨兵节点监控集群中所有的主从节点,当主节点宕机或者发生故障以后,哨兵节点会标记它为主观下线;当足够多的哨兵节点将 Redis 主节点标记为主观下线,就将其状态改为客观下线

此时,哨兵节点们通过选举机制选出一个领头哨兵,对 Redis 主节点进行故障转移操作,以保障 Redis 集群的高可用,这整个流程都不需要人工干预。

3)系统容错

服务在上线之前,需要做好充分的业务量评估,以及性能测试。做好限流、熔断和服务降级的逻辑,比如:采用令牌桶算法实现限流,hystrix 框架来做熔断,并且将常用配置放到可以热更新的配置中心,方便对其实时更改。

当业务量过大时,将同步任务改为异步任务处理。通过这些服务治理方案,让系统更加稳定。

06 后记

当我答完最后一个字的时候,面试官看着我,眼神中充满了“欣赏”与疑惑。我想,他应该是被我这番表现给镇住了,此次面试应该是十拿九稳。

但是,出奇地,面试官没有对刚才的架构设计提出评价,只看了看我说:“那今天的面试就到这里,你有什么想要问的吗?”

这下,轮到我震惊了,那到底过不过呢?倒是给句话呀,于是我问道:“通过这次面试,您觉得我有哪些方面需要提升呢?”

“算法和项目需要再多练练,但是我发现了你一个优点。”面试官笑了笑接着说,“八股文背的倒是挺不错的!”

悬着的心总算放下,我心想:“哦,那稳了~”

作者:xin猿意码
来源:juejin.cn/post/7168090412370886686

收起阅读 »

女程序员做了个梦。。。

看看神级评论把那个女人的指针指向你即可;谁让你把男朋友设成 public 的;心真软,就该把他的接口屏蔽掉;protected 逛街(youOnly);设计问题,应该采用单例模式;没做回归测试;标准做法是做个断言;注释掉了,逛街的参数就不用改了吗?“最后含泪把...
继续阅读 »

昨晚梦见男朋友和别的女人在逛街,梦里我的第一反应是查源代码…结果调试半天查不出来为什么显示的是那个女人不是我,最后含泪把那个女人给注释掉了,再一运行就是我男朋友自己逛街了…醒来囧字脸呆了很久…囧rz


看看神级评论

亡羊补牢型

  1. 把那个女人的指针指向你即可;

  2. 谁让你把男朋友设成 public 的;

  3. 心真软,就该把他的接口屏蔽掉;

  4. protected 逛街(youOnly);

  5. 设计问题,应该采用单例模式;

  6. 没做回归测试;

  7. 标准做法是做个断言;

  8. 注释掉了,逛街的参数就不用改了吗?

  9. “最后含泪把那个女人注释掉了,再一运行就是我男朋友自己逛街了。”很明显是变量名作用域的问题,改个名字就行了;

  10. 还可以有个多线程的算法,把你的优先级设成 99,一个 idle 线程的优先级设成 50,把那个女人的优先级设成 49。酱紫就永远调度不到啦。

破罐破摔型

  1. 加个断点看看那个女人是谁;

  2. 那也没关系,那就老调用那个女人…你 BF 放在那里不动…养着…

  3. 上绝招,用 goto,做个死循环,让他们逛死;

  4. 善心点,别 goto 了,加个 exit 结束进程吧,冤冤相报何时了啊。

来源:http://www.douban.com/group/topic/14168111/

收起阅读 »

我为什么不愿意主动思考?

写在前面最近一直在想一个问题;我为什么不愿意主动思考?引发这个思考的是最近在开发中遇到的一个问题,问题并不难在这里就不多赘述了。遇到这个问题后,我的第一反应就是百度,百度无果后我请教了身边的同事、交流群里的大佬,还是没有解决(提供了一些思路)。没办法,我只能自...
继续阅读 »

写在前面

最近一直在想一个问题;我为什么不愿意主动思考?

引发这个思考的是最近在开发中遇到的一个问题,问题并不难在这里就不多赘述了。遇到这个问题后,我的第一反应就是百度,百度无果后我请教了身边的同事、交流群里的大佬,还是没有解决(提供了一些思路)。没办法,我只能自己思考、尝试,后来发现是某一项隐藏较深的配置有问题。解决这个问题后,我在想:为什么遇到问题的第一时间,我不愿意主动去思考,而是要在一系列的尝试无果后才愿意直面问题呢?是因为领导逼的紧,没有时间?还是能力有限毫无思路?都不是,是我自己本身不愿意去思考。

三重大脑


美国神经生物学家Paul Maclean曾提出一个叫”三重大脑“的理论。按照进化的顺序,把大脑分为三重:爬行动物时期的大脑,爬行脑,控制身体行为、识别危险,快速反应;哺乳动物时期的大脑,情绪脑,与情绪相关,根据相关情绪做出反应;灵长动物时期的大脑,理论脑,关于自尊、自信、自我认知、逻辑、思考等。从进化的时间和生存的重要性来看,爬行脑和情绪脑对人体的控制明显大于逻辑脑。这不难想象,当安全都存在问题时,谁还能静下心来思考,同样的,处于极度愤怒、悲伤的情绪中也没办法思考。因此,思考或者说情绪脑,优先级并不高。同时,思考这种行为相对消耗能量更高(大脑的神经元每天要消耗75%的肝脏血液,消耗占全身总消耗量20%的氧气),本身就被我们的身体排斥。那么,我们大脑不排斥什么,或者说喜欢什么?答案是即时满足。从进化角度来看,我们的祖先过的是茹毛饮血、饔飧不济的生活,对他们来说最重要的是当下,而不是将来,这是人类刻在骨子里的天性,也是我们大脑喜欢即时满足的原因。而思考这种延迟满足的行为是同时被身体和天性所排斥的,所以我们不喜欢思考。那么,我们该如何控制这种天性,如何彻底控制我们的大脑,让逻辑脑当大哥呢?

用进废退

和肌肉一样,当我们不断使用逻辑脑进行思考时,他的话语权会不断扩大,相应的爬行脑和情绪脑带来的不良影响会不断减小,此消彼长,最终形成思考的习惯。

间歇满足

自我控制很难,我们可以适当放松。当我们在进行思考、学习等行为时,大脑渴望即时满足的天性会不断出来作祟,再加上数字时代的今天,诱惑与满足无处不在,稍不注意就会前功尽弃,时时刻刻对抗天性这不现实。我们可以适当、短暂的满足它,如番茄工作法等。这种间歇性的满足是在我们的控制之中的,长此以往,能有效提升我们的自控能力。开始可能很难,坚持下去会越来越轻松。

思考正反馈

通过思考解决某个问题时,我们同样会得到满足,这种来之不易的满足,大脑会更喜欢。 这样的正反馈,可以让我们在提升自己的同时,保持一个长期的、积极的主动思考的状态。


耳濡目染

大脑获取信息的方式有两种,主动思考、被动接受。环境对人的影响是无可估量的,古有孟母三迁,今有高校保安。当我们无力改变当下的自己时,我们可以试着改变环境,再通过环境改变自己,好的环境能让我们在不知不觉中成长。

自我认知

主动思考往往源于自我认知。为什么别人的技术比我好?薪水比我高?是因为智商吗?可能会有一定影响,但占比非常低,更多的源于其自身的主动思考、学习所带来的差距。自我认知让我们发现这份差距,主动思考让我们知道如何弥补这份差距。

作者:侃如
来源:juejin.cn/post/7166658322995609636

收起阅读 »