注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

Android 动画里的贝塞尔曲线

Android 动画里的贝塞尔曲线 对贝塞尔曲线的听闻,大概是源自 Photoshop 里的钢笔工具,用来画曲线的,但一直不明白这个曲线有什么用,接触学习到 Android 动画,又发现了贝塞尔曲线的身影,这玩意不是在绘图软件里画曲线的吗,怎么和动画扯上关系...
继续阅读 »

Android 动画里的贝塞尔曲线


贝塞尔曲线-钢笔.gif

对贝塞尔曲线的听闻,大概是源自 Photoshop 里的钢笔工具,用来画曲线的,但一直不明白这个曲线有什么用,接触学习到 Android 动画,又发现了贝塞尔曲线的身影,这玩意不是在绘图软件里画曲线的吗,怎么和动画扯上关系了,好吧,今天高低得来了解一下。


插值


首先我们得知道什么是插值,数学里面的插值(Interpolation),是一种通过已知的、离散的数据点,在范围内推求新数据点的过程或方法。


下面这个表给出了某个未知函数 f 的值,函数的具体表达式我们并不知道。


xf(x)
00
10.08415
20.9093
30.1411
4−0.7568
5−0.9589
6−0.2794

表中数据点在x-y平面上的绘图.jpg

现在让你估算出 x=2.5x=2.5 时,f(x)f(x) 的值。


简单嘛,已知 f(2)=0.9093f(2)=0.9093f(3)=0.1411f(3)=0.1411,连接两个已知点,f(2.5)f(2.5) 不就是线段中点嘛,(0.90930.1411)/2(0.9093-0.1411)/2,一下子就算出来了。这个通过已知的、离散的数据点,在范围内推求新数据点的过程其实就叫做 "插值"。


f(2.5)推算过程.gif

插值有多种方法,上面这种粗暴地将相邻已知点连接为线段,然后按比例取线段上某个点,来推求新数据点,属于"线性插值"。


虽然口头上表达这个过程不难,可如果这是一道数学试卷上面的解答题,请问阁下又该如何作答呢?


线性插值


咱们再来一道


Linear_interpolation.png

假设已知坐标 (x0,y0)\left ( {{x}_{0},\, {y}_{0}} \right )(x1,y1)\left ( {{x}_{1},\, {y}_{1}} \right ),求: [x0,x1]\left [ {{x}_{0},\, {x}_{1}} \right ] 区间内某一位置 xx 在所对应的 yy 值。


因为 (x0,y0)\left ( {{x}_{0},\, {y}_{0}} \right )(x,y)\left ( x,\, y \right ) 之间的斜率,与 (x0,y0)\left ( {{x}_{0},\, {y}_{0}} \right )(x1,y1)\left ( {{x}_{1},\, {y}_{1}} \right ) 之间的斜率相同,所以:


yy0xx0=y1y0x1x0{\frac {y-{y}_{0}} {x-{x}_{0}}=\frac {{y}_{1}-{y}_{0}} {{x}_{1}-{x}_{0}}\, }

其中 x0{x}_{0}y0{y}_{0}x1{x}_{1}y1{y}_{1}xx 都已知,那么:


y=y1y0x1x0(xx0)+y0=y0+(y1y0)xx0x1x0y=\frac {{y}_{1}-{y}_{0}} {{x}_{1}-{x}_{0}}·\left ( {x-{x}_{0}} \right )+{y}_{0}={y}_{0}+\left ( {{y}_{1}-{y}_{0}} \right )·\frac {x-{x}_{0}} {{x}_{1}-{x}_{0}}

线性插值公式.jpg

过程和上面的例子,线性插值推算未知函数 f(2.5)f(2.5) 的值其实是一样的,原来数学里线性插值的过程是这么表达的啊。


线性插值估算f(2.5).jpg

贝塞尔曲线


线性贝塞尔曲线


线性贝塞尔曲线是一条两点之间的直线,给定点 P0{P}_{0}P1{P}_{1},这条线由下式给出:


B(t)=P0+(P1P0)t,t[0,1]B\left ( {t} \right )={P}_{0}+\left ( {{P}_{1}-{P}_{0}} \right )·t,\, t\in \left [ {0,\, 1} \right ]

线性贝塞尔曲线演示动画.gif

等等,这不就是线性插值吗,和线性插值公式一样,而且线性插值的结果也在一条两点之间的直线上。


线性插值_直线.jpg

二次方贝塞尔曲线


既然两个点线性插值可以表示一条两点之间的直线,或者说是一条线性贝塞尔曲线,那...3个点线性插值的结果,几何表示会是什么样?


二次贝塞尔曲线的结构.jpg
二次贝塞尔曲线演示动画.gif

同时对 P0P1{P}_{0}{P}_{1}P1P2{P}_{1}{P}_{2} 进行插值:


P0P1{P}_{0}{P}_{1} 插值得到连续点 Q0{Q}_{0},描述线段 P0P1{P}_{0}{P}_{1},是一条线性贝塞尔曲线;


P1P2{P}_{1}{P}_{2} 插值得到连续点 Q1{Q}_{1},描述线段 P1P2{P}_{1}{P}_{2},是一条线性贝塞尔曲线;


P0P1{P}_{0}{P}_{1}P1P2{P}_{1}{P}_{2} 插值的同时,用同样的插值因子对得到的 Q0Q1{Q}_{0}{Q}_{1} 再插值,也就是对图中绿色线段进行插值,得到连续点 BB,追踪连续点 BB 的运动轨迹,得到曲线 P0P2{P}_{0}{P}_{2},也就是图中红色曲线,是一条二次贝塞尔曲线。


原来,两个及以上的点 线性插值函数 就是 贝塞尔曲线函数,我们可以简单地不断循坏迭代两点线性插值来得到最终结果。


三次方贝塞尔曲线 & 动画速度曲线


动画曲线.jpg
匀速和先加速后减速.gif

无论是网页设计里的 CSS 还是 Android 开发,它们里面的动画速度曲线其实是三次方贝塞尔曲线,由 4 个点不断两两插值得到。


三次贝塞尔曲线的结构.jpg
三次贝塞尔曲线演示动画.gif

我们自定义自己的动画曲线(三次方贝塞尔曲线)时,里面包含 4 个点的信息,其中第一个和最后一个点的坐标是 (0, 0) 和 (1, 1),我们还需要提供中间两个点的坐标。原来自定义动画曲线要填入 4 个数字的原因是这样啊,豁然开朗。


另外,你可以用网址 cubic-bezier 快速定制自己的动画曲线。


cubic-bezier.jpg

Ease / Easing


现实生活中极少存在线性匀速运动的场景,汽车启动、停下、自由落体运动等等都包含加速、减速,人的脑子里已经潜移默化地习惯了这种加速减速地运动。动画也是一种运动,设计动画的时候应该遵循现实世界的物理模型,让动画看起来更加自然,符合直觉。


md.gif


缓动 Ease,表示缓慢地移动(缓动),在 CSS 过渡动画里面,我们可以选择动画的缓动(Easing)类型,其中一些关键字有:



  • linear

  • ease-in

  • ease-out

  • ease-in-out


在经典动画中,开始阶段缓慢,然后加速的动作称为 "slow in";开始阶段运动较快,然后减速的动作称为 "slow out"。网络上面分别叫 "ease in" 和 "ease out",这里的 in/out 可以理解成一个动画里的一开始(start)或者最后(end)


slow in (ease in)

ease-in-details.jpg

比较适合出场动画,因为开始阶段比较慢,容易让人注意到哪个元素要开始移动,然后加速飞到视线之外。


好比你送朋友,看到朋友上了车,车子缓缓启动,然后加速驶去。


slow out (ease out)

ease-out-details.jpg

比较适合进场动画,因为结束阶段比较缓慢,能让人清楚看到是哪个元素飞了进来。


就像你站在公交车站,看到一辆公交车远远飞速驶来,减速停下。


ease in out

那 ease in out 又是啥呢?ease 是缓和的意思,而 in/out 前面说过可以看作是一次动画里面的开始或结束阶段。ease in out 自然就代表:在一次动画里的开始阶段和结束阶段,动作都是缓和的,仅中间阶段是加速的,能够将用户注意力集中在过渡的末端。这也是 Material Design 的标准缓动,由于现实世界中的物体不会立即开始或停止移动,这种缓动类型可以让动画更有质感。


ease-in-out-details.jpg

这种动画曲线比较适合转换动画,也就是说一个元素运动过程中,没有涉及入场与离场,它始终位于屏幕内,只是由一种形态变换为另一种形态。


fab_anim.gif

Jetpack Compose 里面,表示动画速度曲线的接口是 Easing,Compose 提供了 4 中常见的速度曲线:


/**
* Elements that begin and end at rest use this standard easing. They speed up quickly
* and slow down gradually, in order to emphasize the end of the transition.
*
* Standard easing puts subtle attention at the end of an animation, by giving more
* time to deceleration than acceleration. It is the most common form of easing.
*
* This is equivalent to the Android `FastOutSlowInInterpolator`
*/

val FastOutSlowInEasing: Easing = CubicBezierEasing(0.4f, 0.0f, 0.2f, 1.0f)

/**
* Incoming elements are animated using deceleration easing, which starts a transition
* at peak velocity (the fastest point of an element’s movement) and ends at rest.
*
* This is equivalent to the Android `LinearOutSlowInInterpolator`
*/

val LinearOutSlowInEasing: Easing = CubicBezierEasing(0.0f, 0.0f, 0.2f, 1.0f)

/**
* Elements exiting a screen use acceleration easing, where they start at rest and
* end at peak velocity.
*
* This is equivalent to the Android `FastOutLinearInInterpolator`
*/

val FastOutLinearInEasing: Easing = CubicBezierEasing(0.4f, 0.0f, 1.0f, 1.0f)

/**
* It returns fraction unmodified. This is useful as a default value for
* cases where a [Easing] is required but no actual easing is desired.
*/

val LinearEasing: Easing = Easing { fraction -> fraction }

LinearEasing 是匀速运动,最好理解了,另外 3 个和前面提到的 CSS 里的 ease-in-out、ease-out、ease-in 其实都是对应的



  • ease-in-out => FastOutSlowIn

  • ease-out => LinearOutSlowIn

  • ease-in => FastOutLinearIn


Compose Easing.jpg


...真是想不明白 Compose 官方这个命名是从哪个角度理解的


作者:bqliang
来源:juejin.cn/post/7311957976968708131
收起阅读 »

Android 图片描边效果

前言 先看下我们阔爱滴海绵宝宝,其原图是一张PNG图片,我们给宝宝加上描边效果,今天我们使用的是图片蒙版技术。 说到蒙版可能很多人想起PS抠图软件,Android上也一样,同一个大树上可能会长出两种果实,但果实的根基是一样的。 什么是蒙版:所谓蒙版是只保留了...
继续阅读 »

前言


先看下我们阔爱滴海绵宝宝,其原图是一张PNG图片,我们给宝宝加上描边效果,今天我们使用的是图片蒙版技术。


fire_78.gif


说到蒙版可能很多人想起PS抠图软件,Android上也一样,同一个大树上可能会长出两种果实,但果实的根基是一样的。


什么是蒙版:所谓蒙版是只保留了alpha通道的一种二维正交投影,简单的说就是你躺在地上,太阳光直射下来,背后的那片就是你的蒙版。因此,它既不存在三维特征,也不存在色彩特征,只有alpha特征。那只有alpha通道的图片是什么颜色,这块没有具体了解过,但是理论上取决于默认填充色,在Android上最终是白色的,其他平台暂时还没了解。


提取蒙版


Android上提取蒙版比想象的容易,按照以往的思路,我们是要进行图片扫描这里,其实就是把所有颜色的red、green、blue都排除掉,只保留alpha,相当于缩小了通道数,排除采样和缩小图片,当然这个工作量是很大的,尤其是超高清图片。


企业微信20231210-120604@2x.png


Android 上提取蒙版,只需要把原图绘制到alpha通道的Bitmap上


bms = decodeBitmap(R.mipmap.mm_07);
bmm = Bitmap.createBitmap(bms.getWidth(), bms.getHeight(), Bitmap.Config.ALPHA_8);
Canvas canvas = new Canvas(bmm);
canvas.drawBitmap(bms, 0, 0, null);

蒙版绘制


蒙版绘制和其他Bitmap绘制是有差异的,ARGB_8888和RGB_565等色彩格式的图片,其本身是具备颜色的,但是蒙版图片不一样,他没有颜色,所以你绘制的时候,bitmap的颜色是你画笔Paint的填充色,突然想到可以做一个人体扫描的动画效果或者人体热力图。


canvas.drawBitmap(bmm, x, y, paint);

扩大蒙版(影子)


要让蒙版比比原图大,理论上是需要等比例放大蒙版在平移,还有一种方式是进行偏移绘制,我们这里使用偏移绘制。当然,这里取一定360,保证尽可能每个方向都有偏移,这是看到的外国人的算法。至于step>0 但是也要控制粒度,太小可能绘制次数太多,太大可能有些边缘做不到偏移。


for (int i = 0; i < 360; i += step) {
float x = width * (float) Math.cos(Math.toRadians(i));
float y = width * (float) Math.sin(Math.toRadians(i));
canvas.drawBitmap(bmm, x, y, paint);
}

闪烁效果


我们价格颜色闪烁的效果,其实很简单,也不是本篇重要的部份,其实就是在色彩中间插入透明色,然后定时闪烁。


int index = -1;
int max = 15;
int[] colors = new int[max];
final int[] highlightColors = {0xfff00000,0,0xffff9922,0,0xff00ff00,0};

public void shake() {
index = 0;
for (int i = 0; i < max; i+=2) {
colors[i] = highlightColors[i % highlightColors.length];
}
postInvalidate();
}

总结


本篇到这里就结束了,希望利用蒙版+偏移做出更多东西。


全部代码


public class ViewHighLight extends View {
final Bitmap bms; //source 原图
final Bitmap bmm; //mask 蒙版
final Paint paint;
final int width = 4;
final int step = 15; // 1...45
int index = -1;
int max = 15;
int[] colors = new int[max];
final int[] highlightColors = {0xfff00000,0,0xffff9922,0,0xff00ff00,0};
public ViewHighLight(Context context) {
super(context);
bms = decodeBitmap(R.mipmap.mm_07);
bmm = Bitmap.createBitmap(bms.getWidth(), bms.getHeight(), Bitmap.Config.ALPHA_8);
Canvas canvas = new Canvas(bmm);
canvas.drawBitmap(bms, 0, 0, null);
paint = new Paint(Paint.ANTI_ALIAS_FLAG);
}

private Bitmap decodeBitmap(int resId) {
BitmapFactory.Options options = new BitmapFactory.Options();
options.inMutable = true;
return BitmapFactory.decodeResource(getResources(), resId, options);
}

@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
// draw blur shadow

for (int i = 0; i < 360; i += step) {
float x = width * (float) Math.cos(Math.toRadians(i));
float y = width * (float) Math.sin(Math.toRadians(i));
canvas.drawBitmap(bmm, x, y, paint);
}
canvas.drawBitmap(bms, 0, 0, null);

if(index == -1){
return;
}
index++;
if(index > max +1){
return;
}
if(index >= max){
paint.setColor(Color.TRANSPARENT);
}else{
paint.setColor(colors[index]);
}
postInvalidateDelayed(200);
}


public void shake() {
index = 0;
for (int i = 0; i < max; i+=2) {
colors[i] = highlightColors[i % highlightColors.length];
}
postInvalidate();
}
}

作者:时光少年
来源:juejin.cn/post/7310786575213920306
收起阅读 »

【内存泄漏】图解 Android 内存泄漏

内存泄漏简介 关于内存泄露的定义,想必大家已经烂熟于心了,简单一点的说,就是程序占用了不再使用的内存空间。 那在 Android 里边,怎样的内存空间是程序不再使用的内存空间呢? 这就不得不提到内存泄漏相关的检测了,拿 LeakCanary 的检测举例,销毁的...
继续阅读 »

内存泄漏简介


关于内存泄露的定义,想必大家已经烂熟于心了,简单一点的说,就是程序占用了不再使用的内存空间


那在 Android 里边,怎样的内存空间是程序不再使用的内存空间呢?


这就不得不提到内存泄漏相关的检测了,拿 LeakCanary 的检测举例,销毁的 ActivityFragment 、fragment ViewViewModel 如果没有被回收,当前应用被判定为发生了内存泄漏。LeakCanary 会生成引用链相关的日志信息。


有了引用链的日志信息,我们就可以开开心心的解决内存泄漏问题了。但是除了查看引用链还有更好的解决方式吗?答案是有的,那就是通过画图来解决,会更加的直观形象~


一个简单的例子


如下是一个 Handler 发生内存泄露的例子:


class MainActivity : ComponentActivity() {

private val handler = LeakHandler(Looper.getMainLooper())

override fun onCreate(savedInstanceState: Bundle?) {
// other code

// 发送了一个 100s 的延迟消息
handler.sendMessageDelayed(Message.obtain(), 100_000L)
}

private fun doLog() {
Log.d(TAG, "doLog")
}

private inner class LeakHandler(looper: Looper): Handler(looper) {
override fun handleMessage(msg: Message) {
doLog()
}
}
}

因为 LeakHandler 是一个内部类,持有了外部类 MainActivity 的引用。


其在如下场景会发生内存泄漏:onCreate 执行之后发送了一个 100s 的延迟消息,在 100s 以内旋转屏幕,MainActivity 进行了重建,上一次的 MainActivity 还被 LeakHandler 持有无法释放,导致内存泄露的产生。


引用链图示


如下是执行完 onCreate() 方法之后的引用链图示:


memory_leak_1.png



简单说明一下引用链 0 的位置,这里为了简化,直接使用 GCRoot 代替了,实际上存在这样的引用关系:GCRoot → ActivityThread → Handler → MessageQueue → Message。简单了解一下即可,不太清楚的话也不会影响接下来的问题解决。
同时,为了简单明了,文中还简化了一些相关但是不重要的引用链关系,比如 HandlerMessageQueue 的引用。



100s 以内旋转屏幕之后,引用链图示变成这样了:


memory_leak_2.png


之前的 Activity 被释放,引用链 4 被切断了。我们可以很清晰的看到,由于 LeakHandlerMainActivity 的强引用(引用链2),LeakHandler 间接被 GCRoot 节点强引用,导致 MainActivity 没办法释放。



MainActivity 指的是旋转屏幕之前的 Activity,不是旋转屏幕之后新建的



那么很显然,接下来我们就需要对引用链 0、1 或 2 进行一些操作了,这样才可以让 MainActivity 得到释放。


解决方案


方案一:


onDestroy() 的时候调用 removeCallbacksAndMessages(null) ,该方法会进行两步操作:移除该 Handler 发送的所有消息,并将 Message 回收到 MessagePool


override fun onDestroy() {
super.onDestroy()
handler.removeCallbacksAndMessages(null)
}

此时,在图示上的表现,就是移除了引用链 0 和 1。如此 MainActivity 就可以正常回收了。


memory_leak_3.png


方案二:


使用弱引用 + 静态内部类的方式,我们同样也可以解决这个内存泄漏问题,想必大家已经非常熟悉了。



这里再简单说明一下弱引用 + 静态内部类的原理:
弱引用的回收机制:在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只被弱引用引用的对象,不管当前内存空间足够与否,都会回收它的内存。
静态内部类:静态内部类不会持有外部类的引用,也就不会有 LeakHandler 直接引用 MainActivity 的情况出现



代码实现上,只需要传入 MainActivity 的弱引用给 NoLeakHandler 即可:


private val handler = NoLeakHandler(WeakReference(this), Looper.getMainLooper())

private class NoLeakHandler(
private val activity: WeakReference<MainActivity>,
looper: Looper
): Handler(looper) {
override fun handleMessage(msg: Message) {
activity.get()?.doLog()
}
}

下图中,2.1 表示的是 NoLeakHandlerWeakReference 的强引用,NoLeakHandler 通过 WeakReference 间接引用到了 MainActivity。我们可以很清楚的看到,在旋转屏幕之后,MainActivity 此时只被一个弱引用引用了(引用链 2.2,使用虚线表示),是可以正常被回收的。


memory_leak_4.png


另一个简单的例子


再来一个静态类持有 Activity 的例子,如下是关键代码:


object LeakStaticObject {
val activityCollection: MutableList<Activity> = mutableListOf()
}

class MainActivity : ComponentActivity() {

override fun onCreate(savedInstanceState: Bundle?) {
// other code

activityCollection.add(this)
}
}

正常运行的情况下,存在如下的引用关系:


memory_leak_5.png


在旋转屏幕之后,会发生内存泄漏,因为之前的 MainActivity 还被 GCRoot 节点(LeakStaticObject)引用着。那要怎么解决呢?相信大家已经比较清楚了,要么切断引用链 0,要么将引用链 0 替换成一个弱引用。由于比较简单,这里就不再单独画图说明了。


总结


本文介绍了一种使用画图来解决常见内存泄露的方法,会比直接查看引用链更加清晰具体。同时,其相比于一些归纳常见内存泄漏的方法,会更加的通用,很大程度上摆脱了对内存泄漏场景的强行记忆。


通过画图,找到引用路径之后,在引用链的某个节点上进行操作,切断强引用或者将强引用替换成弱引用,以此来解决问题。


总的来说,对于常见的内存泄漏场景,我们都可以通过画图来解决,本文为了介绍简便,使用了比较简单常见的例子,实际上,遇到复杂的内存泄漏,也可以通过画图的方式来解决。当然,熟练之后,省略画图的操作,也是可以的。


REFERENCE


wikipedia 内存泄漏


Excalidraw — Collaborative whiteboarding made easy


How LeakCanary works - LeakCanary


理解Java的强引用、软引用、弱引用和虚引用 - 掘金


作者:很好奇
来源:juejin.cn/post/7313242069099872306
收起阅读 »

Android 放大镜窥视效果

前言 放大镜效果是一种常用的局部图片观察效果,其本质原理依然是将原图片放大之后,经过范围裁剪然后会知道指定区域的一种效果。实际上放大效果有2种常见的效果,比如在一些购物网站,鼠标移动到的位置被放大,然后展示在侧边区域,这两者代码几乎一样,主要区别如下: 侧边...
继续阅读 »

前言


放大镜效果是一种常用的局部图片观察效果,其本质原理依然是将原图片放大之后,经过范围裁剪然后会知道指定区域的一种效果。实际上放大效果有2种常见的效果,比如在一些购物网站,鼠标移动到的位置被放大,然后展示在侧边区域,这两者代码几乎一样,主要区别如下:



  • 侧边区域观测要移动Shader或者在指定位置裁剪图像

  • 本文效果是移动区域,但是为了保证图片能尽可能对齐,需要将放大的图片向左上角偏移。


本文和上一篇《手电筒照亮效果》一样,如果没看过的先看上一篇,方便你理解本篇,因为同样的原理不会在这篇重新提及或者过多提及,都是局部区域效果实现。


效果预览


滑动放大效果


fire_62.gif


窥视效果


fire_63.gif


方法镜滑动放大实现方法


使用Shader作为载体


首先要做的是将图片放大,放大之后,我们可以利用Path裁剪图片或者Shader向裁剪区域绘制,这里我们依然使用Shader,毕竟优点很多,这里我们主要要实现2个目的。



  • Shader载入Bitmap,放大1.2倍

  • Shader向左上角偏移,对齐图片中心


      if (shader == null) {
float ratio = 1.2f;
scaledBitmap = Bitmap.createScaledBitmap(mBitmap, (int) (mBitmap.getWidth() * ratio), (int) (mBitmap.getHeight() * ratio), true);
shader = new BitmapShader(scaledBitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP);
// 做下偏移
matrix.setTranslate(-(scaledBitmap.getWidth() - mBitmap.getWidth())/2f ,-(scaledBitmap.getHeight() - mBitmap.getHeight())/2f);
shader.setLocalMatrix(matrix);
}

事件处理


其实处理事件有很多简便的方法,但是首先得拦截事件,Android种拦截事件的方法很多,clickable就是其中之一


setClickable(true); //触发hotspot

拦截按压移动事件,这里我们使用 HotSpot 机制,其实就是触点,西方人命名习惯使用HotSpot,通过下面就能处理事件,连onTouchEvent我们都不用搭理。


  @Override
public void dispatchDrawableHotspotChanged(float x, float y) {
super.dispatchDrawableHotspotChanged(x, y);
this.x = x;
this.y = y;
postInvalidate();
}

@Override
protected void dispatchSetPressed(boolean pressed) {
super.dispatchSetPressed(pressed);
postInvalidate();
}

裁剪Canvas区域为原图区域


为什么要裁剪Canvas区域内,主要是因为你的图片并不一定能完全填充整个View,但是你使用的TileMode肯定是CLAMP,这会使得放大镜中图像的边缘拉长,现象很奇怪,反正你可以去掉试试。另外说一下,Android中似乎新增加了一种TileMode,不过还没来得及试一下。


   int save = canvas.save();
canvas.clipRect(0,0,mBitmap.getWidth(),mBitmap.getHeight());
canvas.restoreToCount(save);

绘制核心逻辑


在核心逻辑中,我们有一步要绘制区域填充颜色,主要原因是非透明区域的绘制会导致出现透视效果。


    int save = canvas.save();
canvas.clipRect(0,0,mBitmap.getWidth(),mBitmap.getHeight());
//绘制原图
canvas.drawBitmap(mBitmap, 0, 0, null);
//区域用填充颜色,防止出现区域透视,上面的区域能看见下面的区域
mCommonPaint.setColor(Color.WHITE);
canvas.drawCircle( x , y,width/4f,mCommonPaint);
//绘制放大效果
mCommonPaint.setShader(shader);
canvas.drawCircle( x , y,width/4f,mCommonPaint);
mCommonPaint.setShader(null);

canvas.restoreToCount(save);

放大镜窥视效果


其实两者代码没有多大区别,滑动放大效果主要是移动镜子,而窥视效果镜子不动,使用移动图片的方式实现。


位置计算 & 绘制


固定镜子中心在右下角


//放大平移时需要偏移的距离
float offsetX = -(scaledBitmap.getWidth() - mBitmap.getWidth()) / 2f;
float offsetY = -(scaledBitmap.getHeight() - mBitmap.getHeight())/2f;
//窥视镜圆心
float mirrorCenterX = mBitmap.getWidth() - width / 4f;
float mirrorCenterY = mBitmap.getHeight() - width/4f;

图像平移距离


(mirrorCenterX - x) 
(mirrorCenterY - y)

矩阵变换,平移事件点位置图像到右下角圆的中心


//(mirrorCenterX - x) ,(mirrorCenterY-y) 是把当前中心点的图像平移到圆心哪里
matrix.setTranslate( offsetX + (mirrorCenterX - x) , offsetY + (mirrorCenterY-y));
shader.setLocalMatrix(matrix);

绘制镜子



int save = canvas.save();
canvas.clipRect(0,0,mBitmap.getWidth(),mBitmap.getHeight());
canvas.drawBitmap(mBitmap, 0, 0, null);
mCommonPaint.setColor(Color.DKGRAY);
canvas.drawCircle(mirrorCenterX , mirrorCenterY,width/4f,mCommonPaint);
mCommonPaint.setShader(shader);
canvas.drawCircle( mirrorCenterX , mirrorCenterY,width/4f,mCommonPaint);
mCommonPaint.setShader(null);

canvas.restoreToCount(save);

总结


本篇和之前的很多篇文章一样,都是实现Canvas图片绘制,很复杂的效果我们没有涉及到,但是在这些文章中,都会有各种各样的问题和思考。总之,我们要善于利用矩阵和设计思想,绘制我们的想象。


全部代码


按照惯例,提供全部代码


滑动放大代码


public class ScaleBigView extends View {
private final DisplayMetrics mDM;
private TextPaint mCommonPaint;
private Bitmap mBitmap;
private Shader shader = null;
private Matrix matrix = new Matrix();
private Bitmap scaledBitmap;

public ScaleBigView(Context context) {
this(context, null);
}

public ScaleBigView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public ScaleBigView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
mDM = getResources().getDisplayMetrics();
initPaint();
setClickable(true); //触发hotspot
}

private void initPaint() {
//否则提供给外部纹理绘制
mCommonPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG | Paint.FILTER_BITMAP_FLAG);
mCommonPaint.setAntiAlias(true);
mCommonPaint.setStyle(Paint.Style.FILL_AND_STROKE);
mCommonPaint.setStrokeCap(Paint.Cap.ROUND);
mCommonPaint.setFilterBitmap(true);
mCommonPaint.setDither(true);
mCommonPaint.setStrokeWidth(dp2px(20));
mBitmap = decodeBitmap(R.mipmap.mm_012);

}

private Bitmap decodeBitmap(int resId) {
BitmapFactory.Options options = new BitmapFactory.Options();
options.inMutable = true;
return BitmapFactory.decodeResource(getResources(), resId, options);
}

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);
if (widthMode != MeasureSpec.EXACTLY) {
widthSize = mDM.widthPixels / 2;
}
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);

if (heightMode != MeasureSpec.EXACTLY) {
heightSize = widthSize / 2;
}
setMeasuredDimension(widthSize, heightSize);

}
private float x;
private float y;
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
int width = getWidth();
int height = getHeight();
if (width < 1 || height < 1) {
return;
}
if (shader == null) {
float ratio = 1.2f;
scaledBitmap = Bitmap.createScaledBitmap(mBitmap, (int) (mBitmap.getWidth() * ratio), (int) (mBitmap.getHeight() * ratio), true);
shader = new BitmapShader(scaledBitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP);
matrix.setTranslate(-(scaledBitmap.getWidth() - mBitmap.getWidth())/2f ,-(scaledBitmap.getHeight() - mBitmap.getHeight())/2f);
shader.setLocalMatrix(matrix);
}


int save = canvas.save();
canvas.clipRect(0,0,mBitmap.getWidth(),mBitmap.getHeight());
//绘制原图
canvas.drawBitmap(mBitmap, 0, 0, null);
//区域用填充颜色,防止出现区域透视,上面的区域能看见下面的区域
mCommonPaint.setColor(Color.WHITE);
canvas.drawCircle( x , y,width/4f,mCommonPaint);
//绘制放大效果
mCommonPaint.setShader(shader);
canvas.drawCircle( x , y,width/4f,mCommonPaint);
mCommonPaint.setShader(null);

canvas.restoreToCount(save);
}

@Override
public void dispatchDrawableHotspotChanged(float x, float y) {
super.dispatchDrawableHotspotChanged(x, y);
this.x = x;
this.y = y;
postInvalidate();
}

@Override
protected void dispatchSetPressed(boolean pressed) {
super.dispatchSetPressed(pressed);
postInvalidate();
}

public float dp2px(float dp) {
return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, mDM);
}

}

窥视镜效果


public class ScaleBigView extends View {
private final DisplayMetrics mDM;
private TextPaint mCommonPaint;
private Bitmap mBitmap;
private Shader shader = null;
private Matrix matrix = new Matrix();
private Bitmap scaledBitmap;

public ScaleBigView(Context context) {
this(context, null);
}

public ScaleBigView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public ScaleBigView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
mDM = getResources().getDisplayMetrics();
initPaint();
setClickable(true); //触发hotspot
}

private void initPaint() {
//否则提供给外部纹理绘制
mCommonPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG | Paint.FILTER_BITMAP_FLAG);
mCommonPaint.setAntiAlias(true);
mCommonPaint.setStyle(Paint.Style.FILL_AND_STROKE);
mCommonPaint.setStrokeCap(Paint.Cap.ROUND);
mCommonPaint.setFilterBitmap(true);
mCommonPaint.setDither(true);
mCommonPaint.setStrokeWidth(dp2px(20));
mBitmap = decodeBitmap(R.mipmap.mm_012);

}

private Bitmap decodeBitmap(int resId) {
BitmapFactory.Options options = new BitmapFactory.Options();
options.inMutable = true;
return BitmapFactory.decodeResource(getResources(), resId, options);
}

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);
if (widthMode != MeasureSpec.EXACTLY) {
widthSize = mDM.widthPixels / 2;
}
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);

if (heightMode != MeasureSpec.EXACTLY) {
heightSize = widthSize / 2;
}
setMeasuredDimension(widthSize, heightSize);

}
private float x;
private float y;
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
int width = getWidth();
int height = getHeight();
if (width < 1 || height < 1) {
return;
}
if (shader == null) {
float ratio = 1.2f;
scaledBitmap = Bitmap.createScaledBitmap(mBitmap, (int) (mBitmap.getWidth() * ratio), (int) (mBitmap.getHeight() * ratio), true);
shader = new BitmapShader(scaledBitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP);

}
//放大平移
float offsetX = -(scaledBitmap.getWidth() - mBitmap.getWidth()) / 2f;
float offsetY = -(scaledBitmap.getHeight() - mBitmap.getHeight())/2f;

//窥视镜圆心
float mirrorCenterX = mBitmap.getWidth() - width / 4f;
float mirrorCenterY = mBitmap.getHeight() - width/4f;

//(mirrorCenterX - x) ,(mirrorCenterY-y) 是把当前中心点的图像平移到圆心哪里
matrix.setTranslate( offsetX + (mirrorCenterX - x) , offsetY + (mirrorCenterY-y));
shader.setLocalMatrix(matrix);

int save = canvas.save();
canvas.clipRect(0,0,mBitmap.getWidth(),mBitmap.getHeight());
canvas.drawBitmap(mBitmap, 0, 0, null);
mCommonPaint.setColor(Color.DKGRAY);
canvas.drawCircle(mirrorCenterX , mirrorCenterY,width/4f,mCommonPaint);
mCommonPaint.setShader(shader);
canvas.drawCircle( mirrorCenterX , mirrorCenterY,width/4f,mCommonPaint);
mCommonPaint.setShader(null);

canvas.restoreToCount(save);
}

@Override
public void dispatchDrawableHotspotChanged(float x, float y) {
super.dispatchDrawableHotspotChanged(x, y);
this.x = x;
this.y = y;
postInvalidate();
}

@Override
protected void dispatchSetPressed(boolean pressed) {
super.dispatchSetPressed(pressed);
postInvalidate();
}

public float dp2px(float dp) {
return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, mDM);
}

}

作者:时光少年
来源:juejin.cn/post/7310124656996302874
收起阅读 »

Android 手电筒照亮效果

前言 经常在掘金博客上看到使用前端技术实现的手电筒照亮效果,但是搜了一下android相关的,发现少得很,实际上这种效果在Android上实现会更简单,当然,条条大路通罗马,有很多技术手段去实现这种效果,今天我们选择一种相对比较好的方法来实现。 实现方法梳理 ...
继续阅读 »

前言


经常在掘金博客上看到使用前端技术实现的手电筒照亮效果,但是搜了一下android相关的,发现少得很,实际上这种效果在Android上实现会更简单,当然,条条大路通罗马,有很多技术手段去实现这种效果,今天我们选择一种相对比较好的方法来实现。


实现方法梳理



  • 第一种方法就是利用Path路径进行 Clip Outline,然后绘制不同的渐变效果即可,这种方法其实很适合蒙版切图,不过也能用于实现这种特效。

  • 第二种方法是利用Xfermode 进行中间图层镂空。

  • 第三种方法就是Shader,效率高且无锯齿。


效果


fire_61.gif


实现原理


其实本篇的核心就是Shader了,这次我们也用RadialGradient来实现,本篇几乎没有任何难度,关键技术难点就是Shader 的移动,其实最经典的效果是Facebook实现的光影文案,本质上时Matrix + Shader.setLocalMatrix 实现。


155007_4C1U_2256215.gif


Matrix涉及一些数学问题,Matrix初始化本身就是单位矩阵,几乎每个操作都是乘以另一个矩阵,属于线性代数的基本知识,难度其实并不高。


matrix.setTranslation(1,2) 可以看作,矩阵的乘法无非是行乘列,繁琐事繁琐,但是很容易理解



1,0,0, 1,0,1,
0,1,0, X 0,1,2,
0,0,1 0,0,1


我们来看看经典的facebook 出品代码


public class GradientShaderTextView extends TextView {

private LinearGradient mLinearGradient;
private Matrix mGradientMatrix;
private Paint mPaint;
private int mViewWidth = 0;
private int mTranslate = 0;

private boolean mAnimating = true;
private int delta = 15;
public GradientShaderTextView(Context ctx)
{
this(ctx,null);
}

public GradientShaderTextView(Context context, AttributeSet attrs) {
super(context, attrs);
}

@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
if (mViewWidth == 0) {
mViewWidth = getMeasuredWidth();
if (mViewWidth > 0) {
mPaint = getPaint();
String text = getText().toString();
// float textWidth = mPaint.measureText(text);
int size;
if(text.length()>0)
{
size = mViewWidth*2/text.length();
}else{
size = mViewWidth;
}
mLinearGradient = new LinearGradient(-size, 0, 0, 0,
new int[] { 0x33ffffff, 0xffffffff, 0x33ffffff },
new float[] { 0, 0.5f, 1 }, Shader.TileMode.CLAMP); //边缘融合
mPaint.setShader(mLinearGradient);
mGradientMatrix = new Matrix();
}
}
}

@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);

int length = Math.max(length(), 1);
if (mAnimating && mGradientMatrix != null) {
float mTextWidth = getPaint().measureText(getText().toString());
mTranslate += delta;
if (mTranslate > mTextWidth+1 || mTranslate<1) {
delta = -delta;
}
mGradientMatrix.setTranslate(mTranslate, 0); //自动平移矩阵
mLinearGradient.setLocalMatrix(mGradientMatrix);
postInvalidateDelayed(30);
}
}

}

本文案例


本文要实现的效果其实也是一样的方法,只不过不是自动移动,而是添加了触摸事件,同时加了放大缩小效果。


坑点


Shader 不支持矩阵Scale,本身打算利用Scale缩放光圈,但事与愿违,不仅不支持,连动都动不了了,因此,本文采用了两种Shader,按压时使用较大半径的Shader,手放开时使用默认的Shader。


知识点


canvas.drawPaint(mCommonPaint);

这个绘制并不是告诉你可以这么绘制,而是想说,设置了Shader之后,这样调用,Shader半径之外的颜色时Shader最后一个颜色值,我们最后一个颜色值时黑色,那就是黑色,我们改成白色当然也是白色,下图是改成白色之后的效果,周围都是白色


企业微信20231207-230353@2x.png


关键代码段


super.onDraw(canvas);
int width = getWidth();
int height = getHeight();
if (width < 1 || height < 1) {
return;
}
//大光圈shader
if (radialGradientLarge == null) {
radialGradientLarge = new RadialGradient(0, 0,
dp2px(100),
new int[]{Color.TRANSPARENT, 0x01ffffff, 0x33ffffff, 0x66000000, 0x77000000, 0xff000000},
new float[]{0.1f, 0.3f, 0.5f, 0.7f, 0.9f, 0.95f},
Shader.TileMode.CLAMP);
}
//默认光圈shader
if (radialGradientNormal == null) {
radialGradientNormal = new RadialGradient(0, 0,
dp2px(50),
new int[]{Color.TRANSPARENT, 0x01ffffff, 0x33ffffff, 0x66000000, 0x77000000, 0xff000000},
new float[]{0.1f, 0.3f, 0.5f, 0.7f, 0.9f, 0.95f},
Shader.TileMode.CLAMP);
}

//绘制地图
canvas.drawBitmap(mBitmap, 0, 0, null);

//移动shader中心点
matrix.setTranslate(x, y);
//设置到矩阵
radialGradientLarge.setLocalMatrix(matrix);
radialGradientNormal.setLocalMatrix(matrix);
if(isPressed()) {
//按压时
mCommonPaint.setShader(radialGradientLarge);
}else{
//松开时
mCommonPaint.setShader(radialGradientNormal);
}
//直接用画笔绘制,那么周围的颜色是Shader 最后的颜色
canvas.drawPaint(mCommonPaint);

好了,我们的效果基本实现了。


总结


本篇到这里就截止了,我们今天掌握的知识点是Shader相关的:



  • Shader 矩阵不能Scale

  • 设置完Shader 的画笔外围填充色为Ridial Shader最后的颜色

  • Canvas 可以直接drawPaint

  • Shader.setLocalMatrix是移动Shader中心点的方法


代码


按照惯例,给出全部代码


public class LightsView extends View {
private final DisplayMetrics mDM;
private TextPaint mCommonPaint;
private Bitmap mBitmap;
private RadialGradient radialGradientLarge = null;
private RadialGradient radialGradientNormal = null;
private float x;
private float y;
private boolean isPress = false;
private Matrix matrix = new Matrix();
public LightsView(Context context) {
this(context, null);
}

public LightsView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public LightsView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
mDM = getResources().getDisplayMetrics();
initPaint();
setClickable(true); //触发hotspot
}

private void initPaint() {
//否则提供给外部纹理绘制
mCommonPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG | Paint.FILTER_BITMAP_FLAG);
mCommonPaint.setAntiAlias(true);
mCommonPaint.setStyle(Paint.Style.FILL_AND_STROKE);
mCommonPaint.setStrokeCap(Paint.Cap.ROUND);
mCommonPaint.setFilterBitmap(true);
mCommonPaint.setDither(true);
mBitmap = decodeBitmap(R.mipmap.mm_06);

}

private Bitmap decodeBitmap(int resId) {
BitmapFactory.Options options = new BitmapFactory.Options();
options.inMutable = true;
return BitmapFactory.decodeResource(getResources(), resId, options);
}

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);
if (widthMode != MeasureSpec.EXACTLY) {
widthSize = mDM.widthPixels / 2;
}
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);

if (heightMode != MeasureSpec.EXACTLY) {
heightSize = widthSize / 2;
}
setMeasuredDimension(widthSize, heightSize);

}

@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
int width = getWidth();
int height = getHeight();
if (width < 1 || height < 1) {
return;
}
if (radialGradientLarge == null) {
radialGradientLarge = new RadialGradient(0, 0,
dp2px(100),
new int[]{Color.TRANSPARENT, 0x01ffffff, 0x33ffffff, 0x66000000, 0x77000000, 0xff000000},
new float[]{0.1f, 0.3f, 0.5f, 0.7f, 0.9f, 0.95f},
Shader.TileMode.CLAMP);
}
if (radialGradientNormal == null) {
radialGradientNormal = new RadialGradient(0, 0,
dp2px(50),
new int[]{Color.TRANSPARENT, 0x01ffffff, 0x33ffffff, 0x66000000, 0x77000000, 0xff000000},
new float[]{0.1f, 0.3f, 0.5f, 0.7f, 0.9f, 0.95f},
Shader.TileMode.CLAMP);
}

canvas.drawBitmap(mBitmap, 0, 0, null);

matrix.setTranslate(x, y);
radialGradientLarge.setLocalMatrix(matrix);
radialGradientNormal.setLocalMatrix(matrix);
if(isPressed()) {
mCommonPaint.setShader(radialGradientLarge);
}else{
mCommonPaint.setShader(radialGradientNormal);
}
canvas.drawPaint(mCommonPaint);
}

@Override
public void dispatchDrawableHotspotChanged(float x, float y) {
super.dispatchDrawableHotspotChanged(x, y);
this.x = x;
this.y = y;
postInvalidate();
}

@Override
protected void dispatchSetPressed(boolean pressed) {
super.dispatchSetPressed(pressed);
postInvalidate();
}

public float dp2px(float dp) {
return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, mDM);
}

}

作者:时光少年
来源:juejin.cn/post/7309687967064817716
收起阅读 »

转转的Flutter实践之路

前言 跨端技术一直是移动端开发领域的热门话题,Flutter 作为一种领先的移动跨端技术之一,凭借其快速的渲染引擎、丰富的UI组件库和强大的开发工具,成为了开发人员的首选之一。 从 Flutter 诞生之初,我们就一直关注着它的发展,Flutter 早期版本变...
继续阅读 »

前言


跨端技术一直是移动端开发领域的热门话题,Flutter 作为一种领先的移动跨端技术之一,凭借其快速的渲染引擎、丰富的UI组件库和强大的开发工具,成为了开发人员的首选之一。


从 Flutter 诞生之初,我们就一直关注着它的发展,Flutter 早期版本变更较为频繁,并且经常伴随着 Breaking Change,另外可用的三方插件较少且不稳定。直到2019年,Flutter 的热度暴涨,国内不少团队陆续把 Flutter 引入到了生产环境使用,社区也涌现出不少优秀的开源项目,我们也决定在这个时候做一些技术上的尝试。


经过这几年在 Flutter 技术上的不断学习、探索和积累,Flutter 已经成为了客户端技术体系中的重要组成部分。


回顾整个过程,我们大致经历了这么几个阶段:可行性验证、基建一期建设、小范围试验、基建二期建设、大范围推广、前端生态的探索,下文将分别对每个阶段展开进行介绍。


可行性验证


其实在这之前我们已经做过了一些调研,但许多结论都是来源于网上的一些文章或者其它团队的实践,这些结论是否靠谱是否真实还有待商榷,另外,网上的文章大都千篇一律,要么使劲吹捧,要么使劲贬低,要得出相对客观的结论还是得需要我们自己通过实践才能得出。


目标


我们确定了以下几个维度,用来评估 Flutter 是否值得我们进一步投入:



  • 开发效率

  • UI一致性

  • 性能体验

  • 学习成本

  • 发展趋势


由于前期对 Flutter 的熟练度不高,基础设施也还没有搭建起来,所以在开发效率上,我们期望的 Flutter 的开发耗时能保持在原生开发耗时的 1.5 倍以内,不然虽然实现了跨端,但是需求的开发周期反而被拉长了,这样得不偿失。在UI一致性上,我们期望同一份代码在两端的表现要基本达到一致,不需要额外的适配成本。在性能方面,尽量保证崩溃、卡顿、内存、帧率这些指标在可控范围内。


方案


我们希望用较小的代价完成上述维度的评估,所以在试验期间的架构及基础设施方面我们做的比较简单。


测试目标


当时我们正在做一个叫切克的 App,用户量级比较小,工程架构也相对简单一些,正好可以用来做一些技术方面的探索和验证。


我们选择的是切克的商品详情页,用 Flutter 技术实现了一个一模一样的商详,按1:1的流量分配给 Native 和 Flutter。


项目架构


由于我们的工程不是一个全新的项目,所以采用的是 Native 与 Flutter 混合开发的方式,Native 主工程只依赖 Flutter 产物即可,同时也尽量避免对原有工程的影响。


关于混合页面栈的问题,我们没有额外处理,因为暂时只测试一个页面,不会涉及到多页面混合栈的问题,所以暂时先忽略。


构建流程


为了降低验证成本,我们没有对接现有的 Native 的持续集成流程,而是直接在本地构建 Flutter 产物,然后上传到远程仓库。


结论


经过一段时间的线上验证,我对 Flutter 技术基本有了一个比较全面的了解:


在开发效率上由于基础库和基建的缺失,在处理 Flutter 业务跟 Native 业务的交互时需要更多的适配成本,包括像页面跳转、埋点上报、接口请求、图片加载等也需要额外的处理,但我们评估随着后续基建的不断完善,这部分的效率是可以逐步得到改善的;而在涉及UI开发方面,得益于热重载等技术,Flutter 的开发效率是要优于原生开发的。整体评估下来,在开发效率方面 Flutter 是符合我们的预期的。


在UI一致性上,除了在状态栏控制和文本在某些情况下需要特殊适配下外,其它控件在两端的表现基本一致。


在性能表现上,Flutter 会额外引入一些崩溃,内存占用也有所上涨,但还在可接受范围内。


Flutter 的学习成本相对还是比较高,毕竟需要单独学习一门语言,另外 Flutter 的渲染原理也跟原生有很多差异,需要转变思维才能更快的适应,此外 Flutter 还提供了众多的 Widget 组件,也需要较长时间学习。


在发展趋势上,Flutter 无疑是当时增长最快的跨端技术之一,社区的活跃程度以及官方的投入都非常高,国内不少团队也都在积极推进 Flutter 技术的发展,Flutter 正处在一个快速的上升期。


整体来说,Flutter 是满足我们团队对跨平台技术的需求的,我们计划在接下来的一段时间投入更多资源,把 Flutter 的基础设施逐渐建立起来。


基建一期建设


基建一期内容主要包括以下几个方面:



  • 工程架构

  • 开发框架

  • 脚本工具

  • 自动化构建


在基建一期完成后,我们的目标是要达到:



  • 基础能力足够支撑普通业务开发

  • 开发效率接近原生开发

  • 开发过程要基本顺畅


工程架构


工程架构指的是原生工程与 Flutter 工程之间的关系,以及 Flutter 工程与 Flutter 工程之间的关系。


原生工程与Flutter工程的关系


我们知道,使用 Flutter 开发通常有两种情况,一种是直接使用 Flutter 开发一个新的App,属于纯 Flutter 开发;一种是在已有的 Native 工程中引入,属于混合开发。我们当然属于后者。


而混合开发又可分为两种:源码集成和产物集成。源码集成需要改变原工程的项目结构,并且需要 Flutter 开发环境才能编译,而产物集成则不需要改动原工程的项目结构,只需把 Flutter 的构建产物当作普通的依赖库引入即可,原有 Native 工程和 Flutter 工程从物理上完全独立。显而易见的我们选择产物集成的方式,引入 Flutter对于原工程以及非 Flutter 开发人员来说,基本上是毫无感知的。


所以原生工程与 Flutter 工程之间的关系如下图所示:


原生工程与Flutter工程之间的关系


Flutter工程之间的关系


根据已有的客户端基建的开发经验,我们将所有 Flutter 工程分为了四层:



  • 壳工程

  • 业务层

  • 公共层

  • 容器层


容器层负责提供 Flutter 的基础运行环境,包括 Flutter 引擎管理、页面栈管理、网络框架、KV存储、数据库访问、埋点框架、Native 与 Flutter 通信通道和其它基础功能。


公共层包含一些通用的开源库、自定义UI组件、部分通用业务等。


业务层包含用户信息、商品、发布等业务组件。


壳工程负责集成各业务组件,最终构建出产物集成到 Native 主工程。


其中业务层、公共层、容器层都是由若干个独立的工程所组成,整体结构如下:


Flutter分层架构


开发框架


开发框架是为了提高开发效率、规范代码结构、减少维护成本等考虑而设计的一套软件框架,包括:基础能力、状态管理、页面栈管理等。


基础能力


开发框架需要提供各种必要的能力,比如:页面跳转、埋点、网络请求、图片加载、数据存储等,为了最大化减少研发成本,我们在底层定义了一套通用的数据交互协议,直接复用了现有的 Native 的各项能力,也使得 Native 的各种状态与 Flutter 侧能够保持统一。


状态管理


相信了解 Flutter 的同学一定知道状态管理,这也是跟 Native 开发区别较大的地方。在开发较为复杂的页面时,状态维护是非常繁琐的,在不引入状态管理框架的情况下,开发效率会受很大影响,后期的维护成本以及业务交接都是很大的问题。


另外,在开发框架设计之初,我们就期望从框架上能够在一定程度上限定代码结构、模块之间的交互方式、状态更新方式等,我们期望的是不同的人写出来的代码在逻辑、结构和风格上都能保持比较统一,即在提高开发效率的同时,也能保证项目后续的可维护性和扩展性,减少不同业务间的交接成本。


基于上述这些需求,在我们对比了多个开源项目后,FishRedux 的整体使用感受正好符合我们的要求。


如下图,两个页面的代码结构基本一致:


收藏详情和个人主页


页面栈管理


在早期版本,Flutter 引擎的实例占用内存较高,为了减少内存消耗,大家普遍采用单实例的模式,而在 Native 和 Flutter 混合开发的场景下就会存在一个问题,就是 Native 有自己的页面栈,而 Flutter 也维护着一套自己的页面栈,如果 Native 页面与 Flutter 页面穿插着打开,在没有特殊处理的情况下,页面栈会发生错乱。在调研了业内的各种开源方案后,我们选择引入 FlutterBoost 用来管理页面混合栈。


脚本工具


为了方便开发同学搭建 Flutter 的开发环境,同时能够管理使用的 Flutter 版本,我们开发了 zflutter 命令行工具,包含以下主要功能:



  • Flutter开发环境安装

  • Flutter版本管理

  • 创建模版工程(主工程、组件工程)

  • 创建模版页面(常规页面、列表页、瀑布流页面)

  • 创建页面模块

  • 组件工程发布

  • 构建Flutter产物

  • 脚本自更新


如图:


zflutter


自动化构建


客户端使用的是自研的 Beetle 平台(集工程管理、分支管理、编译、发布于一体),短时间内要支持上 Flutter 不太现实,基于此,我们先临时自己搭台服务器,通过 gitlab 的 webhook 功能结合 zflutter 工具简单实现了一套自动化构建的服务,待 Beetle 支持 Flutter 组件化开发功能后,再将工作流切回到 Beetle 平台。


小范围试验


在完成基建一期的开发工作后,我们决定通过开发几个实际业务来试验目前的基础设施是否达到既定目标。


我们以不影响主流程、能覆盖常见UI功能、并且能跟 Native 页面做AB测试(主要是方便在出问题时能够切换到 Native 版本)为条件挑选了个人资料页和留言列表页进行了 Flutter 化改造,如下图所示:


个人资料页/留言列表页


这两个页面涵盖了网络请求、图片加载、弹窗、列表、下拉刷新、上拉加载更多、左滑删除、埋点上报、页面跳转等常见功能,足以覆盖日常开发所需的基础能力。


经过完整的开发流程以及一段时间的线上观察,我们得出如下结论:


基础能力


目前已具备的基础能力已经足够支撑普通业务开发(开发过程中补足了一些缺失的能力)。


工作流


整个开发过程在工程依赖管理和分支管理方面的支持还比较缺失,比较依赖人工处理。


开发效率


我们在开发前根据页面功能同时做了纯 Native 开发排期和 Flutter 开发排期,按单人日的成本来对比的话,Flutter 实际开发耗时跟 Native 排期耗时比为 1.25:2,Native 是按照 Android+iOS 两端各一人算的,也就是1.25人/日比2人/日,如果后续对 Flutter 技术熟悉度提升后相信效率还可以进一步提升。


性能体验


线上两个 Flutter 页面的体验效果跟 Native 对比基本感觉不到差别,但是首次进入 Flutter 页面时会有短暂的白屏等待时间,这个是由于 Flutter 环境初始化导致的延迟,后续可以想办法优化。


包体积


在引入 Flutter 之后,转转的安装包体积在两端都分别有所增加:



  • Android增加6.1M

  • iOS增加14M


试验结果基本符合预期,包体积的增量也在我们的可接受范围内,接下来将进行基建二期的建设,补足目前缺失的能力。


基建二期建设


基建二期的内容主要包含以下工作:



  • 配合工程效率组完成 Beetle 对 Flutter 项目的支持

  • 组织客户端内部进行 Flutter 技术培训


Beetle支持Flutter


为了能让大家更清晰的了解 Beetle 的工程管理机制,这里先简单介绍下客户端的工程类型:



  • Native主工程(又分为 Android 和 iOS)

  • Native组件工程(又分为 Android 和 iOS)

  • Flutter主工程

  • Flutter组件工程(即 Flutter 插件工程)


举个例子,当有一个新版本需要开发时,先从 Native 主工程创建一个版本同时创建一个 Release 分支,即版本分支,然后从版本分支根据具体需求创建对应 Native 组件的版本分支,Flutter 主工程此时可看作是一个 Native 组件,比如此时创建了一个 Flutter 主工程的版本分支后,可以进入 Flutter 主工程再根据需要创建对应的 Flutter 组件工程的版本分支。


Beetle 目前已支持 Flutter 工程管理、分支管理、组件依赖管理以及组件的发布、Flutter 产物的构建等,Beetle 的作用贯穿从开发到上线的整个工作流。


Flutter技术培训


为了让大家更快的熟悉 Flutter 开发,我们在客户端内部组织了5次 Flutter 快速入门的系列分享:


Flutter快速入门系列


同时也逐步完善内部文档的建设,包括:FlutterSdk 源码维护策略、Flutter 入门指南、Flutter 混合开发方案、Flutter 与 Native 通信方案、Flutter 开发环境配置、Flutter 组件化工程结构、Flutter 开发与调试、Flutter 开发工作流、ZFlutter 工具使用介绍、Flutter 开发之 Beetle 使用指南等,涵盖了从环境搭建、开发调试到构建发布的整个过程。


大范围推广


在完成基建二期的建设后,整体基础设施已经能够支撑我们常见的业务,开发工作流也基本顺畅,于是我们开始了在内部大范围推广计划。


我们先后改造和新开发了个人主页、我发布的页面、微商详、奇趣数码页等业务,基本涵盖了常见的各种类型的页面和功能,整体开发效率与原生单端开发效率持平,但是在特别复杂的页面的性能表现上,Flutter 的表现相对要差一些。


部分页面如下图所示:


个人主页


微详情页/我发布的/奇趣数码


探索前端生态


在跨端技术领域我们知道 Web 技术是天然支持的,如果能把前端生态引入到 Flutter 中,那么对客户端来说,在业务的支持度上会更上一个台阶,Web 的体验得到提升的同时客户端也具备了动态化,基于此背景我们开始探索 Flutter 在 Web 上的可能性。


技术调研


当时可选的开源方案有:Kraken、MXFlutter、Flutter For Web。


Kraken


Kraken 是一款基于 W3C 标准的高性能渲染引擎。Kraken 底层基于 Flutter 进行渲染,通过其自绘渲染的特性,保证多端一致性。上层基于 W3C 标准实现,拥有非常庞大的前端开发者生态。


Kraken 的最上层是一个基于 W3C 标准而构建的 DOM API,在下层是所依赖的 JS 引擎,通过 C++ 构建一个 Bridge 与 Dart 通信。然后这个 C++ Bridge 把 JS 所调用的一些信息,转发到 Dart 层。Dart 层通过接收这些信息,会去调用 Flutter 所提供的一些渲染能力来进行渲染。


Kraken 是不依赖 Flutter Widget,而是依赖 Flutter Widget 的底层渲染数据结构 —— RenderObject。Kraken 实现了很多 CSS 相关的能力和一些自定义的 RenderObject,直接将生成的 RenderObject 挂载在 Flutter RenderView 上来进行渲染,通过这样的方式能够做到非常高效的渲染性能。


MXFlutter


MXFlutter 是一套使用 TypeScript/JavaScript 来开发 Flutter 应用的框架。


MXFlutter 把 Flutter 的渲染逻辑中的三棵树(即:WidgetTree、Element、RenderObject )中的第一棵(即:WidgetTree),放到 JavaScript 中生成。用 JavaScript 完整实现了 Flutter 控件层封装,实现了轻量的响应式 UI 框架,支撑JS WidgetTree 的 build逻辑,build 过程生成的UI描述, 通过Flutter 层的 UI 引擎转换成真正的 Flutter 控件显示出来。


Flutter For Web


Flutter 在 Web 平台上以浏览器的标准 API 重新实现了引擎。目前有两种在 Web 上呈现内容的选项:HTML 和 WebGL。



  • 在 HTML 模式下,Flutter 使用 HTML、CSS、Canvas 和 SVG 进行渲染。

  • 在 WebGL 模式下,Flutter 使用了一个编译为 WebAssembly 的 Skia 版本,名为 CanvasKit。


HTML 模式提供了最佳的代码大小,CanvasKit 则提供了浏览器图形堆栈渲染的最快途径,并为原生平台的内容提供了更高的图形保真度。


结论


我们对以上方案从接入成本、渲染性能、包体积、开发生态、学习成本等多维度进行了对比:



  • 接入成本:Kraken ≈ MXFlutter ≈ Flutter For Web

  • 渲染性能:Kraken > MXFlutter > Flutter For Web

  • 包体积增量:Flutter For Web < Kraken < MXFlutter

  • 开发生态:Kraken ≈ MXFlutter > Flutter For Web

  • 学习成本:Flutter For Web < Kraken ≈ MXFlutter


最终选择了 Kraken 作为我们的首选方案。


上线验证


为了使 Kraken 顺利接入转转App,我们做了以下几个方面的工作:



  • 升级 FlutterSdk 到最新版,满足接入 Kraken 的基础条件

  • 统一客户端容器接口,使得 Kraken 容器能够完美继承 Web 容器的能力

  • 自己维护 Kraken 源码,及时修复官方来不及修复的问题,方便增加转转特有的扩展能力

  • 制定 Kraken 容器与 Web 容器的降级机制

  • 兼容 HTML 加载,保持跟 Web 容器一致的加载方式

  • 添加监控埋点,量化指标,指导后续优化方向

  • 选择一个简单 Web 页并协助前端同学适配


上线后,我们对页面的各项指标进行了对比,使用 Kraken 容器加载比使用 WebView 加载,在首屏加载耗时的指标上平均增加了281毫秒,原因为:当前版本的 Kraken 容器不支持直接加载 HTML,且只能加载单个 JsBundle,导致加载效率比 WebView 差。


通过跟前端同学沟通,从开发效率上来看,Kraken 工程的开发周期会比实现同样需求的普通 Web 工程增加1.5到2倍的时间,主要原因是受到 CSS 样式、Api 差异,无法使用现有UI组件,另外 Kraken 的调试工具目前还不够完善,使用浏览器调试后还须在客户端容器中调试,整体下来导致开发 Kraken 工程会比开发普通Web工程耗费更多时间。


再次验证


由于之前选择的 Web 页面太过简单,不具备代表性,所以我们重新选定了“附近的人”页面做为改造目标,再次验证 Kraken 在实际开发过程中的效率及性能体验。页面如图所示:


附近的人


最终因为部分问题得不到解决,并且整体性能较差,导致页面没能成功上线。


存在的问题包括但不限于下面列举的一些:



  • 表现不一致问题

    1. CSS 定位、布局表现与浏览器表现不一致

    2. 部分 API 表现与浏览器不一致(getBoundingClientRect等)

    3. iOS,Android系统表现不一致



  • 重大 Bug

    1. 页面初始化渲染完成,动态修改元素样式,DOM不重新渲染

    2. 滑动监听计算导致 APP 崩溃



  • 调试成本高

    1. 不支持 vue-router,单项目单路由

    2. 不支持热更新,npm run build 预览

    3. 不支持 sourceMap,无法定位源代码

    4. 真机调试只支持 element 和 network;dom 和 element 无法互相选中;无法动态修改 dom 结构,无法直接修改样式.......

    5. 页面白屏,假死



  • 安全性问题

    1. 无浏览器中的“同源策略”限制



  • 兼容性

    1. npm 包不兼容等




通过这一系列的探索和尝试,我们了解到了 Kraken 目前还存在许多不足,如果继续应用会带来高额的开发调试以及维护成本,所以暂时停止了在 Kraken 方向上的投入,但我们仍然在这个方向上保持着关注。


结尾


目前转转在Flutter方向上的实践和探索只是一个起点,我们意识到仍然有很多工作需要去做。我们坚信Flutter作为一项领先的跨端技术,将为转转业务的发展带来巨大的潜力和机会。我们将持续努力,加强技术建设,不断完善实践经验,推动Flutter在转转的应用和发展,为用户提供更好的产品和体验。


作者:转转技术团队
来源:juejin.cn/post/7304831120709697588
收起阅读 »

Android 绘制你最爱的马赛克

前言 我们之前写过《Android 实现LED 展示效果》,在这篇文章中,我们使用了图像分块(或者是分片)的算法,这样做的目的是降低像素扫描的时间复杂度,并且也利于均色采样。其实图像分块是很常见的图像动效处理手段,一般主要用于场景变换,这项技术也和图像光栅化类...
继续阅读 »

前言


我们之前写过《Android 实现LED 展示效果》,在这篇文章中,我们使用了图像分块(或者是分片)的算法,这样做的目的是降低像素扫描的时间复杂度,并且也利于均色采样。其实图像分块是很常见的图像动效处理手段,一般主要用于场景变换,这项技术也和图像光栅化类似。


什么是光栅化


光栅化渲染(Rasterized Rendering)直译过来是栅格化渲染。寻找图像中被几何图形占据的所有像素的过程称为栅格化,因此对象顺序渲染(Object-order rendering)也可以称为栅格化渲染。


Qu-es-la-rasterizacin-y-cual-es-su-diferencia-con-el-Ray-Tracing.jpg


我们今天的所要用到的技术也是栅格化和像素采样技术。


LED原理简述


马赛克是一种图像编辑技术,广泛应用于隐私保护和涂鸦渲染,很多手机系统自带了这种效果,那如何才能实现这种技术呢?


了解过我之前的文章的知道,我们制作LED有几个特征



  • 每个LED单元要么亮要么不亮

  • 每个LED单元只有一种颜色

  • 每个LED单元和其他LED单元存在一定的间距

  • 所有LED的单元成网格排列

  • 每个LED单元大小一致


以上相当于顶点坐标信息,我们拿到网格的位置,就能拿到LED整个区域的片段,知道这个区域的片段我们就可以修改其像素。


着色采样:


即便是每个矩形区域,也有很多像素点,如果每个矩形区域每个像素都要进行均色计算的话,那10x10的也要100此,因此为了更快的效率,需要对LED 范围内的像素点采样,求出颜色均值,均值色就是LED最终展示的颜色。


避坑——修改像素


上一篇我们知道,通过Bitmap.setPixel方法修改像素效率是极低的,我曾经写过一篇通过修改像素生成圆形图片的文章,在那篇文章里我们看到,像素本身也是有size的,导致最终的圆形图片存在大量锯齿,主要原因是通过这种方式没法做到双线性过滤(图片放大之后会对边缘优化),还有另一个问题,就是效率极差。
总结一下修改像素的问题:



  • 无法抗锯齿

  • 效率低


避坑——透明色


像素中往往存在 color为0或者alpha通道为0的情况,甚至有的区域因为采样原因导致清晰度急剧下降,甚至出现了透明区域噪点,这些问题主要来自于alpha 通道引发的颜色稀释问题,因此在采样时一定要规避这两种情况,至于会不会失真?答案是如果采用alpha失真只会更严重。


清晰度问题


同样,清晰度也容易受到这olor为0或者alpha通道为0的情况情况干扰,除了这两种就是采样区域的大小了,理论上采样网格密度越密,清晰度越高,越接近原始图片,因此一定要权衡,太清晰不就很原图一样了么,还制作什么LED呢?


马赛克原理


实际上,马赛克原理和LED展示方式类似,为什么这么说呢?从特征来看,几乎一样,马赛克和LED效果只在两部分存在区别



  1. 马赛克网格之间不存在间距

  2. 马赛克采样次数比LED要少


马赛克没有LED间距很好理解,至于次数少的好处第一肯定是效率高,其次是采样太多容易接近原色,而LED是要有一定程度接近原色。


技术实现


本篇我们邀请一位可爱的猫猫,老师们太耀眼的图片就算了,不利于大家阅读。


ic_cat.png


我们接下来的任务是把给猫脸打上马赛克,了解完这项技术实现后,其实你不仅可以给猫脸打马赛克,自行涂鸦,指哪儿打哪儿。


基本信息


private Bitmap mBitmap; //猫图
private float blockWidth = 30; //30x30的像素快
private RectF blockRect = new RectF(); //猫头区域
private RectF gridRect = new RectF(); //网格区域

Canvas 包裹Bitmap


主要方便绘制和内存回收


static class BitmapCanvas extends Canvas {
Bitmap bitmap;
public BitmapCanvas(Bitmap bitmap) {
super(bitmap);
this.bitmap = bitmap;
}
public Bitmap getBitmap() {
return bitmap;
}
}


定位猫头位置


由于时间关系,我没有做TOUCH事件处理,就写了这个猫头区域


/ 这个区域到时候可以自己调制,想让什么部位有马赛克那就会有马赛克
blockRect.set(bitmapCanvas.bitmap.getWidth() - 400, 0, bitmapCanvas.bitmap.getWidth() - 100, 300);

网格分割


//根据平分猫头矩形区域
int col = (int) (blockRect.width() / blockWidth);
int row = (int) (blockRect.height() / blockWidth);

网格定位


float startX = blockRect.left;
float startY = blockRect.top;
for (int i = 0; i < row * col; i++) {
int x = i % col;
int y = (i / col);
gridRect.set(startX + x * blockWidth, startY + y * blockWidth, startX + x * blockWidth + blockWidth, startY + y * blockWidth + blockWidth);

}

采样和着色


float startX = blockRect.left;
float startY = blockRect.top;
for (int i = 0; i < row * col; i++) {
int x = i % col;
int y = (i / col);
gridRect.set(startX + x * blockWidth, startY + y * blockWidth, startX + x * blockWidth + blockWidth, startY + y * blockWidth + blockWidth);
//采样
int sampleColor = mBitmap.getPixel((int) gridRect.centerX(), (int) gridRect.centerY());
mCommonPaint.setColor(sampleColor);
//着色,我们这里不修改像素,而是drawRect,避免性能问题和锯齿问题
bitmapCanvas.drawRect(gridRect, mCommonPaint);
}

渲染到View上


canvas.drawBitmap(bitmapCanvas.bitmap, null, mainRect, mCommonPaint);

效果预览


fire_56.gif


避坑点


网格区间不易过小,和LED一样,越小清晰度越高,就会失去了处理的意义。


全部代码


public class MosaicView extends View {
private final DisplayMetrics mDM;
private TextPaint mCommonPaint;
private RectF mainRect = new RectF();

private BitmapCanvas bitmapCanvas; //Canvas 封装的
private Bitmap mBitmap; //猫图
private float blockWidth = 30; //30x30的像素快
private RectF blockRect = new RectF(); //猫头区域
private RectF gridRect = new RectF(); //网格区域
private boolean showMask = false;

public MosaicView(Context context) {
this(context, null);
}
public MosaicView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public MosaicView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
mDM = getResources().getDisplayMetrics();
initPaint();
}

private void initPaint() {
//否则提供给外部纹理绘制
mCommonPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG);
mCommonPaint.setAntiAlias(true);
mCommonPaint.setStyle(Paint.Style.FILL_AND_STROKE);
mCommonPaint.setStrokeCap(Paint.Cap.ROUND);
mBitmap = decodeBitmap(R.mipmap.ic_cat);

}
private Bitmap decodeBitmap(int resId) {
BitmapFactory.Options options = new BitmapFactory.Options();
options.inMutable = true;
return BitmapFactory.decodeResource(getResources(), resId, options);
}

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {

int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);

if (widthMode != MeasureSpec.EXACTLY) {
widthSize = mDM.widthPixels / 2;
}
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);

if (heightMode != MeasureSpec.EXACTLY) {
heightSize = widthSize / 2;
}
setMeasuredDimension(widthSize, heightSize);
mBitmap = decodeBitmap(R.mipmap.ic_cat);

}

@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
if (bitmapCanvas != null && bitmapCanvas.bitmap != null && !bitmapCanvas.bitmap.isRecycled()) {
bitmapCanvas.bitmap.recycle();
}
bitmapCanvas = null;

}

@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
int width = getWidth();
int height = getHeight();
if (width < 1 || height < 1) {
return;
}
if (bitmapCanvas == null || bitmapCanvas.bitmap == null) {
bitmapCanvas = new BitmapCanvas(Bitmap.createBitmap(mBitmap.getWidth(), mBitmap.getHeight(), Bitmap.Config.ARGB_8888));
} else {
bitmapCanvas.bitmap.eraseColor(Color.TRANSPARENT);
}
float radius = Math.min(width / 2f, height / 2f);

//关闭双线性过滤
// int flags = mCommonPaint.getFlags();
// mCommonPaint.setFlags(flags &~ Paint.FILTER_BITMAP_FLAG);
// mCommonPaint.setFilterBitmap(false);

int save = bitmapCanvas.save();
bitmapCanvas.drawBitmap(mBitmap, 0, 0, mCommonPaint);


// 这个区域到时候可以自己调制,想让什么部位有马赛克那就会有马赛克
blockRect.set(bitmapCanvas.bitmap.getWidth() - 560, 10, bitmapCanvas.bitmap.getWidth() - 100, 410);

if(showMask) {
//根据平分猫头矩形区域
int col = (int) (blockRect.width() / blockWidth);
int row = (int) (blockRect.height() / blockWidth);

float startX = blockRect.left;
float startY = blockRect.top;

for (int i = 0; i < row * col; i++) {
int x = i % col;
int y = (i / col);
gridRect.set(startX + x * blockWidth, startY + y * blockWidth, startX + x * blockWidth + blockWidth, startY + y * blockWidth + blockWidth);
//采样
int sampleColor = mBitmap.getPixel((int) gridRect.centerX(), (int) gridRect.centerY());
mCommonPaint.setColor(sampleColor);
//着色,我们这里不修改像素,而是drawRect,避免性能问题和锯齿问题
bitmapCanvas.drawRect(gridRect, mCommonPaint);
}
}else{
Paint.Style style = mCommonPaint.getStyle();
mCommonPaint.setStyle(Paint.Style.STROKE);
mCommonPaint.setColor(Color.MAGENTA);
mCommonPaint.setStrokeWidth(8);
bitmapCanvas.drawRect(blockRect, mCommonPaint);
mCommonPaint.setStyle(style);

}

bitmapCanvas.restoreToCount(save);
int saveCount = canvas.save();
canvas.translate(width / 2f, height / 2f);
mainRect.set(-radius, -radius, radius, radius);
canvas.drawBitmap(bitmapCanvas.bitmap, null, mainRect, mCommonPaint);
canvas.restoreToCount(saveCount);

}

public void openMask() {
showMask = true;
postInvalidate();
}

public void closeMask() {
showMask = false;
postInvalidate();

}

static class BitmapCanvas extends Canvas {
Bitmap bitmap;
public BitmapCanvas(Bitmap bitmap) {
super(bitmap);
//继承在Canvas的绘制是软绘制,因此理论上可以绘制出阴影
this.bitmap = bitmap;
}
public Bitmap getBitmap() {
return bitmap;
}
}
}

总结


实际上还有另一种方法,我们绘制图片时关闭双线性过滤


//关闭双线性过滤
// int flags = mCommonPaint.getFlags();
// mCommonPaint.setFlags(flags &~ Paint.FILTER_BITMAP_FLAG);
// mCommonPaint.setFilterBitmap(false);

然后将图片放到很大,这个时候你的图片就会产生一定的网格区域,截图然后进行一系列矩阵转换,最后把图贴到原处就出现了马赛克,但是这个有个问题,超高像素的图片得先缩小,然后再放大,显然处理步骤比较多。


下图是先缩小20倍然后画到原来大小的效果

企业微信20231205-221500@2x.png


实现代码


本来不打算放代码的,想想还是放上吧


public class BitmapMosaicView extends View {
private final DisplayMetrics mDM;
private TextPaint mCommonPaint;
private RectF mainRect = new RectF();
private BitmapCanvas bitmapCanvas; //Canvas 封装的
private BitmapCanvas srcThumbCanvas; //Canvas 封装的
private Bitmap mBitmap; //猫图
private RectF blockRect = new RectF(); //猫头区域

public BitmapMosaicView(Context context) {
this(context, null);
}
public BitmapMosaicView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public BitmapMosaicView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
mDM = getResources().getDisplayMetrics();
initPaint();
}

private void initPaint() {
//否则提供给外部纹理绘制
mCommonPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG);
mCommonPaint.setAntiAlias(true);
mCommonPaint.setStyle(Paint.Style.FILL_AND_STROKE);
mCommonPaint.setStrokeCap(Paint.Cap.ROUND);
mBitmap = decodeBitmap(R.mipmap.ic_cat);

}
private Bitmap decodeBitmap(int resId) {
BitmapFactory.Options options = new BitmapFactory.Options();
options.inMutable = true;
return BitmapFactory.decodeResource(getResources(), resId, options);
}

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {

int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);

if (widthMode != MeasureSpec.EXACTLY) {
widthSize = mDM.widthPixels / 2;
}
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);

if (heightMode != MeasureSpec.EXACTLY) {
heightSize = widthSize / 2;
}
setMeasuredDimension(widthSize, heightSize);

}

@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
if (bitmapCanvas != null && bitmapCanvas.bitmap != null && !bitmapCanvas.bitmap.isRecycled()) {
bitmapCanvas.bitmap.recycle();
}
if (srcThumbCanvas != null && srcThumbCanvas.bitmap != null && !srcThumbCanvas.bitmap.isRecycled()) {
srcThumbCanvas.bitmap.recycle();
}
bitmapCanvas = null;

}

private Rect srcRectF = new Rect();
private Rect dstRectF = new Rect();

@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
int width = getWidth();
int height = getHeight();
if (width < 1 || height < 1) {
return;
}
if (bitmapCanvas == null || bitmapCanvas.bitmap == null) {
bitmapCanvas = new BitmapCanvas(Bitmap.createBitmap(mBitmap.getWidth(), mBitmap.getHeight(), Bitmap.Config.ARGB_8888));
} else {
bitmapCanvas.bitmap.eraseColor(Color.TRANSPARENT);
}
if (srcThumbCanvas == null || srcThumbCanvas.bitmap == null) {
srcThumbCanvas = new BitmapCanvas(Bitmap.createBitmap(mBitmap.getWidth()/35, mBitmap.getHeight()/35, Bitmap.Config.ARGB_8888));
} else {
srcThumbCanvas.bitmap.eraseColor(Color.TRANSPARENT);
}
float radius = Math.min(width / 2f, height / 2f);

//关闭双线性过滤
int flags = mCommonPaint.getFlags();
mCommonPaint.setFlags(flags &~ Paint.FILTER_BITMAP_FLAG);
mCommonPaint.setFilterBitmap(false);
mCommonPaint.setDither(false);


srcRectF.set(0,0,mBitmap.getWidth(),mBitmap.getHeight());
dstRectF.set(0,0, srcThumbCanvas.bitmap.getWidth(), srcThumbCanvas.bitmap.getHeight());

int save = bitmapCanvas.save();
srcThumbCanvas.drawBitmap(mBitmap, srcRectF, dstRectF, mCommonPaint);

srcRectF.set(dstRectF);
dstRectF.set(0,0,bitmapCanvas.bitmap.getWidth(),bitmapCanvas.bitmap.getHeight());
bitmapCanvas.drawBitmap(srcThumbCanvas.bitmap, srcRectF,dstRectF, mCommonPaint);
// 这个区域到时候可以自己调制,想让什么部位有马赛克那就会有马赛克
blockRect.set(bitmapCanvas.bitmap.getWidth() - 560, 10, bitmapCanvas.bitmap.getWidth() - 100, 410);
bitmapCanvas.restoreToCount(save);
int saveCount = canvas.save();
canvas.translate(width / 2f, height / 2f);
mainRect.set(-radius, -radius, radius, radius);
canvas.drawBitmap(bitmapCanvas.bitmap, null, mainRect, mCommonPaint);
canvas.restoreToCount(saveCount);

}
static class BitmapCanvas extends Canvas {
Bitmap bitmap;
public BitmapCanvas(Bitmap bitmap) {
super(bitmap);
//继承在Canvas的绘制是软绘制,因此理论上可以绘制出阴影
this.bitmap = bitmap;
}
public Bitmap getBitmap() {
return bitmap;
}
}
}

总结下本文分享技术特点:

  • 网格化
  • 采样
  • canvas着色,不要去修改像素


作者:时光少年
来源:juejin.cn/post/7308925069916225588
收起阅读 »

Android 实现LED 展示效果

一、前言 LED以其卓越的亮度和醒目的文字和图案,已成为车水马龙的城市中充满烟火气息的象征,深层次的是您红灯的闪烁唤醒着人们的娱乐、怀旧、童年的记忆。当然对新时代来说这显然格格不入的,因此这种霓虹灯能存在多久显然还是个问题。 效果预览 二、实现原理 最初的设...
继续阅读 »

一、前言


LED以其卓越的亮度和醒目的文字和图案,已成为车水马龙的城市中充满烟火气息的象征,深层次的是您红灯的闪烁唤醒着人们的娱乐、怀旧、童年的记忆。当然对新时代来说这显然格格不入的,因此这种霓虹灯能存在多久显然还是个问题。


效果预览



二、实现原理


最初的设想是利用BitmapShader  + Shader 实现网格图片,但是最终是失败的,因此绘制出的网格不是纯色。


为什么是需要网格纯色呢 ,主要原因是LED等作为单独的实体,单个LED智能发出一种光,电视也是一样的道理,微小的发光单元不可能同时发出多种光源,这也是LED显示屏的制作原理。至于我们的自定义View,本身是细腻的屏幕上发出的,如果一个LED发出多种光,就会显得很假。但事实上,在绘制View时一个区域可能会出现多种颜色,如何平衡这种颜色也是个问题,优化方式当然是增加采样点;但是采样点多了也会带来新的副作用,一是性能问题,而是过多的全透明和alpha为0的情况,因为这种情况会过度稀释真是的颜色,造成模糊不清的问题,其次和View本身的背景穿透,形成较大范围的噪点,所以绘制过程中一定要控制采样点的数量,其次对alpha为0或者过小的的情况剔除,当然不用担心失真,因为过度的透明人眼会认为是全透明,没有太多意义,我们来做个总结:



  • LED 单元智能发出一种光,因此不适合BitampShader做风格渲染

  • 颜色逼真程度和采样点有关,采样点越多越逼近真色

  • 清晰程度和LED单元大小相关,LED单元越小越清晰

  • 剔除alpha通道过小和颜色值为0的采样点颜色 


三、核心逻辑


生成刷子纹理


     if (brushBitmap == null) {
brushBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
brushCanvas = new Canvas(brushBitmap);
}

for (int i = 0; i < drawers.size(); i++) {
int saveCount = brushCanvas.save();
drawers.get(i).draw(brushCanvas, width, height, mCommonPaint);
brushCanvas.restoreToCount(saveCount);
}

生成网格数据


        float blockWidth = (squareWidth + padding);
int w = width;
int h = height;
int columNum = (int) Math.ceil(w / blockWidth);
int rowNum = (int) Math.ceil(h / blockWidth);

if (gridRects.isEmpty() && squareWidth > 1f) {
//通过rowNum * columNum方式降低时间复杂度
for (int i = 0; i < rowNum * columNum; i++) {

int col = i % columNum;
int row = (i / columNum);

Rect rect = new Rect();
rect.left = (int) (col * blockWidth);
rect.top = (int) (row * blockWidth);
rect.right = (int) (col * blockWidth + squareWidth);
rect.bottom = (int) (row * blockWidth + squareWidth);
//记录网格点
gridRects.add(rect);
}

}

采样绘制


    //这里是重点 ,LED等可以看作一只灯泡,灯泡区域要么全亮,要么全不亮
for (int i = 0; i < gridRects.size(); i++) {
Rect rect = gridRects.get(i);

if (brushBitmap.getWidth() <= rect.right) {
continue;
}
if (brushBitmap.getHeight() <= rect.bottom) {
continue;
}

if (sampleColors == null) {
sampleColors = new int[9];
}

//取7个点采样,纯粹是为了性能考虑,如果想要更准确的颜色,可以多采样几个点

sampleColors[0] = brushBitmap.getPixel(rect.left, rect.top); // left-top
sampleColors[1] = brushBitmap.getPixel(rect.right, rect.top); // right-top
sampleColors[2] = brushBitmap.getPixel(rect.right, rect.bottom); // right-bottom
sampleColors[3] = brushBitmap.getPixel(rect.left, rect.bottom); // left-bottom
sampleColors[4] = brushBitmap.getPixel(rect.left + rect.width() / 2, rect.top + rect.height() / 2); //center

sampleColors[5] = brushBitmap.getPixel(rect.left + rect.width() / 2, rect.top + rect.height() / 4); //top line
sampleColors[6] = brushBitmap.getPixel(rect.left + rect.width() * 3 / 4, rect.top + rect.height() / 2); //right line
sampleColors[7] = brushBitmap.getPixel(rect.left + rect.width() / 4, rect.top + rect.height() / 2); // left line
sampleColors[8] = brushBitmap.getPixel(rect.left + rect.width() / 2, rect.top + rect.height() * 3 / 4); // bottom line

int alpha = 0;
int red = 0;
int green = 0;
int blue = 0;
int num = 0;

for (int c : sampleColors) {
if (c == Color.TRANSPARENT) {
//剔除全透明的颜色,必须剔除
continue;
}
int alphaC = Color.alpha(c);
if (alphaC <= 0) {
//剔除alpha为0的颜色,当然可以改大一点,防止降低清晰度
continue; }
alpha += alphaC;
red += Color.red(c);
green += Color.green(c);
blue += Color.blue(c);
num++;
}

if (num < 1) {
continue;
}

//求出平均值
int rectColor = Color.argb(alpha / num, red / num, green / num, blue / num);
if (rectColor != Color.TRANSPARENT) {
mGridPaint.setColor(rectColor);
// canvas.drawRect(rect, mGridPaint); //绘制矩形
canvas.drawCircle(rect.centerX(), rect.centerY(), squareWidth / 2, mGridPaint); //绘制圆
}
}

如果不剔除颜色,那么就会有噪点和清晰度问题



全部代码


public class LedDisplayView extends View {
private final DisplayMetrics mDM;
private TextPaint mGridPaint;
private TextPaint mCommonPaint;
private List<IDrawer> drawers = new ArrayList<>();
private Bitmap brushBitmap = null;
private float padding = 2; //分界线大小
private float squareWidth = 5; //网格大小
private List<Rect> gridRects = new ArrayList<>();
int[] sampleColors = null;
private Canvas brushCanvas = null;

public LedDisplayView(Context context) {
this(context, null);
}

public LedDisplayView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}

public LedDisplayView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
mDM = getResources().getDisplayMetrics();
initPaint();

}


@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);

int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);

if (widthMode != MeasureSpec.EXACTLY) {
widthSize = mDM.widthPixels / 2;
}

int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);

if (heightMode != MeasureSpec.EXACTLY) {
heightSize = widthSize / 2;
}
setMeasuredDimension(widthSize, heightSize);
}


public float dp2px(float dp) {
return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, mDM);
}

public float sp2px(float dp) {
return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, dp, mDM);
}


@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
if (brushBitmap != null && !brushBitmap.isRecycled()) {
brushBitmap.recycle();
}
brushBitmap = null;
}


@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);

int width = getWidth();
int height = getHeight();
if (width <= padding || height <= padding) {
return;
}

if (brushBitmap == null) {
brushBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
brushCanvas = new Canvas(brushBitmap);
}

for (int i = 0; i < drawers.size(); i++) {
int saveCount = brushCanvas.save();
drawers.get(i).draw(brushCanvas, width, height, mCommonPaint);
brushCanvas.restoreToCount(saveCount);
}


float blockWidth = (squareWidth + padding);
int w = width;
int h = height;
int columNum = (int) Math.ceil(w / blockWidth);
int rowNum = (int) Math.ceil(h / blockWidth);

if (gridRects.isEmpty() && squareWidth > 1f) {
//通过rowNum * columNum方式降低时间复杂度
for (int i = 0; i < rowNum * columNum; i++) {

int col = i % columNum;
int row = (i / columNum);

Rect rect = new Rect();
rect.left = (int) (col * blockWidth);
rect.top = (int) (row * blockWidth);
rect.right = (int) (col * blockWidth + squareWidth);
rect.bottom = (int) (row * blockWidth + squareWidth);
//记录网格点
gridRects.add(rect);
}

}
int color = mGridPaint.getColor();

//这里是重点 ,LED等可以看作一只灯泡,灯泡区域要么全亮,要们全不亮
for (int i = 0; i < gridRects.size(); i++) {
Rect rect = gridRects.get(i);

if (brushBitmap.getWidth() <= rect.right) {
continue;
}
if (brushBitmap.getHeight() <= rect.bottom) {
continue;
}

if (sampleColors == null) {
sampleColors = new int[9];
}

//取7个点采样,纯粹是为了性能考虑,如果想要更准确的颜色,可以多采样几个点

sampleColors[0] = brushBitmap.getPixel(rect.left, rect.top); // left-top
sampleColors[1] = brushBitmap.getPixel(rect.right, rect.top); // right-top
sampleColors[2] = brushBitmap.getPixel(rect.right, rect.bottom); // right-bottom
sampleColors[3] = brushBitmap.getPixel(rect.left, rect.bottom); // left-bottom
sampleColors[4] = brushBitmap.getPixel(rect.left + rect.width() / 2, rect.top + rect.height() / 2); //center

sampleColors[5] = brushBitmap.getPixel(rect.left + rect.width() / 2, rect.top + rect.height() / 4); //top line
sampleColors[6] = brushBitmap.getPixel(rect.left + rect.width() * 3 / 4, rect.top + rect.height() / 2); //right line
sampleColors[7] = brushBitmap.getPixel(rect.left + rect.width() / 4, rect.top + rect.height() / 2); // left line
sampleColors[8] = brushBitmap.getPixel(rect.left + rect.width() / 2, rect.top + rect.height() * 3 / 4); // bottom line

int alpha = 0;
int red = 0;
int green = 0;
int blue = 0;
int num = 0;

for (int c : sampleColors) {
if (c == Color.TRANSPARENT) {
//剔除全透明的颜色,必须剔除
continue;
}
int alphaC = Color.alpha(c);
if (alphaC <= 0) {
//剔除alpha为0的颜色,当然可以改大一点,防止降低清晰度
continue;
}
alpha += alphaC;
red += Color.red(c);
green += Color.green(c);
blue += Color.blue(c);
num++;
}

if (num < 1) {
continue;
}

//求出平均值
int rectColor = Color.argb(alpha / num, red / num, green / num, blue / num);
if (rectColor != Color.TRANSPARENT) {
mGridPaint.setColor(rectColor);
// canvas.drawRect(rect, mGridPaint); //绘制矩形
canvas.drawCircle(rect.centerX(), rect.centerY(), squareWidth / 2, mGridPaint); //绘制圆
}
}
mGridPaint.setColor(color);

}


private void initPaint() {
// 实例化画笔并打开抗锯齿
mGridPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG);
mGridPaint.setAntiAlias(true);
mGridPaint.setColor(Color.LTGRAY);
mGridPaint.setStyle(Paint.Style.FILL);
mGridPaint.setStrokeCap(Paint.Cap.ROUND); //否则网格绘制

//否则提供给外部纹理绘制
mCommonPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG);
mCommonPaint.setAntiAlias(true);
mCommonPaint.setStyle(Paint.Style.FILL_AND_STROKE);
mCommonPaint.setStrokeCap(Paint.Cap.ROUND);

}

public void addDrawer(IDrawer drawer) {
if (drawer == null) return;
this.drawers.add(drawer);
gridRects.clear();
postInvalidate();
}

public void removeDrawer(IDrawer drawer) {
if (drawer == null) return;
this.drawers.remove(drawer);
gridRects.clear();
postInvalidate();
}

public void clearDrawer() {
this.drawers.clear();
gridRects.clear();
postInvalidate();
}

public List<IDrawer> getDrawers() {
return new ArrayList<>(drawers);
}

public interface IDrawer {
void draw(Canvas canvas, int width, int height, Paint paint);
}

}

使用方式


       LedDisplayView displayView = findViewById(R.id.ledview);
final BitmapDrawable bitmapDrawable1 = (BitmapDrawable)getResources().getDrawable(R.mipmap.mm_07);
final BitmapDrawable bitmapDrawable2 = (BitmapDrawable)getResources().getDrawable(R.mipmap.mm_08);
ledDisplayView.addDrawer(new LedDisplayView.IDrawer() {

Matrix matrix = new Matrix();
@Override
public void draw(Canvas canvas, int width, int height, Paint paint) {
canvas.translate(width/2,height/2);
matrix.preTranslate(-width/2,-height/4);
Bitmap bitmap1 = bitmapDrawable1.getBitmap();
canvas.drawBitmap(bitmap1,matrix,paint);

matrix.postTranslate(width/2,height/4);
Bitmap bitmap2 = bitmapDrawable2.getBitmap();
canvas.drawBitmap(bitmap2,matrix,paint);
}
});
ledDisplayView.addDrawer(new LedDisplayView.IDrawer() {
@Override
public void draw(Canvas canvas, int width, int height, Paint paint) {
paint.setColor(Color.CYAN);
float textSize = paint.getTextSize();
paint.setTextSize(sp2px(50));
canvas.drawText("你好,L E D", 100, 200, paint);
canvas.drawText("85%", 100, 350, paint);

paint.setColor(Color.YELLOW);
canvas.drawCircle(width*3 / 4, height / 4, 100, paint);

paint.setTextSize(textSize);
}
});

四、总结


这个本质上的核心就是采样,通过采样我们最终实现了纹理贴图,这点类似open gl中的光栅化,将图形分割成小三角形一样,最后着色,理解本篇也能帮助大家理解open gl和led显示原理。


作者:时光少年
来源:juejin.cn/post/7304973928039153705
收起阅读 »

Android 图片分片过渡效果

前言 在之前的文章中,通过LED效果、马赛克效果两篇文章,介绍了分片绘制的效果的方法和原理,通过这两篇文章,相信大家都已经熟悉了分片绘制的思路。其实分片绘制不仅仅能实现LED、马赛克等特殊效果,实际上类似百叶窗、图片对角线锯齿过渡等,很多PPT中存在的特效,基...
继续阅读 »

前言


在之前的文章中,通过LED效果马赛克效果两篇文章,介绍了分片绘制的效果的方法和原理,通过这两篇文章,相信大家都已经熟悉了分片绘制的思路。其实分片绘制不仅仅能实现LED、马赛克等特殊效果,实际上类似百叶窗、图片对角线锯齿过渡等,很多PPT中存在的特效,基本上也是按照这种原理来实现的。


分片可以有很多种意想不到的效,我们再来说一下分片特点:



  • [1] 按一定的距离、大小、角度对区域进行对一张图片或者区域裁剪或者提取区域图像

  • [2] 对提取出来的区域进行一系列变换,如百叶窗、微信摇一摇等

  • [3] 被裁剪的区域可以还原回去


技术前景


其实单纯的分片可以做一些瓦片效果,当然还可以做一些组合效果,下面是一个github开源项目(Camera2DApplication)利用Camera和图片分片实现的效果,这个过程中对一张图片进行分片绘制。


fire_58.gif


代码中的逻辑不是很复杂,本质上就是利用2张图片实现的,我们先来看下代码实现,作者的代码很认真,注释都写了,涉及postTranslate比较难懂的操作我也进行了微调。


/**
* 3d旋转效果
*
* @param canvas
*/

private void drawModeNormal(Canvas canvas) {
//VERTICAL时使用rotateY,HORIZONTAL时使用rotateX
if (orientation == VERTICAL) {
//如果是前进,则画当前图,后退则画上一张图,注释用的是前进情况
matrix.reset();
camera.save();
//旋转角度 0 - -maxDegress
camera.rotateX(-degress);
camera.getMatrix(matrix);
camera.restore();

//绕着图片top旋转
matrix.preTranslate(-viewWidth / 2f, 0);
//旋转轴向下平移,则图片也向下平移
matrix.postTranslate(viewWidth / 2f, rotatePivotY);
//如果是前进,则画当前图,后退则画上一张图,因为后退时,这里画的是动画下方出来的图片,而下方的图片是前一张图
canvas.drawBitmap(getBitmapScale(bitmapResourceIds.get(isForward ? currentIndex : preIndex), viewWidth, viewHeight),
matrix, mPaint);

//在处理下一张图片
matrix.reset();
camera.save();
//旋转角度 maxDegress - 0
camera.rotateX(maxDegress - degress);
camera.getMatrix(matrix);
camera.restore();

//绕着图片bottom旋转
matrix.preTranslate(-viewWidth / 2f, -viewHeight);
//旋转轴向下平移,则图片也向下平移
matrix.postTranslate(viewWidth / 2f, rotatePivotY);
//如果是前进,则画下一张图,后退则画当前图,后退时,这边代码画的是动画上方的图片,上方的图片是当前图片
canvas.drawBitmap(getBitmapScale(bitmapResourceIds.get(isForward ? nextIndex : currentIndex), viewWidth, viewHeight),
matrix, mPaint);
} else {
//如果是前进,则画当前图,后退则画上一张图,注释用的是前进情况
matrix.reset();
camera.save();
//旋转角度 0 - maxDegress
camera.rotateY(degress);
camera.getMatrix(matrix);
camera.restore();

//绕着图片left旋转
matrix.preTranslate(0, -viewHeight / 2);
//旋转轴向右平移,则图片也向右平移
matrix.postTranslate(rotatePivotX, viewHeight / 2);
//如果是前进,则画当前图,后退则画上一张图,因为后退时,这里画的是动画右方出来的图片,而右方的图片是前一张图
canvas.drawBitmap(getBitmapScale(bitmapResourceIds.get(isForward ? currentIndex : preIndex), viewWidth, viewHeight),
matrix, mPaint);

//在处理下一张图片
matrix.reset();
camera.save();
//旋转角度 -maxDegress - 0
camera.rotateY(-maxDegress + degress);
camera.getMatrix(matrix);
camera.restore();

//绕着图片right旋转
matrix.preTranslate(-viewWidth, -viewHeight / 2f);
//旋转轴向右平移,则图片也向右平移
matrix.postTranslate(rotatePivotX, viewHeight / 2f);
//如果是前进,则画下一张图,后退则画当前图,后退时,这边代码画的是动画左方的图片,左方的图片是当前图片
canvas.drawBitmap(getBitmapScale(bitmapResourceIds.get(isForward ? nextIndex : currentIndex), viewWidth, viewHeight),
matrix, mPaint);
}
}

分片操作


下面是分片操作,这个地方其实可以不用创建Bitmap缓存,创建Path就行,绘制时对Path区域利用Shader贴图即可。


private Bitmap getBitmapScale(int resId, float width, float height) {
if (ImageCache.getInstance().getBitmapFromMemCache(String.valueOf(resId)) != null) {
return ImageCache.getInstance().getBitmapFromMemCache(String.valueOf(resId));
}
Bitmap bitmap = BitmapFactory.decodeResource(getResources(), resId);
//创建分片
Bitmap bitmapDst = Bitmap.createScaledBitmap(bitmap, (int) width, (int) height, false);
bitmap.recycle();

ImageCache.getInstance().addBitmapToMemoryCache(String.valueOf(resId)
, bitmapDst);
return bitmapDst;
}

小试一下


我们这里通过一个简单的Demo,实现一种特效,这次我们利用网格矩阵分片。说到矩阵,很多人面试的时候都会遇到一些算法题,比较幸运的人遇到的是矩阵旋转90度、逆时针打印矩阵、矩阵孤岛问题、从左上角开始进行矩阵元素搜索,运气稍差的会遇到由外到里顺时针打印矩阵和斜对角打印矩阵,后面两种看似简单的问题实际上做起来并不顺手,有点扯远了,我们来看看效果。


fire_59.gif


你没看错,这次遇到了算法问题,我这边用的空间换取时间的方法。


图像分片


将图片分片,计算出网格的列和行


int col = (int) Math.ceil(mBitmaps[index].getWidth() / blockWidth);
int row = (int) Math.ceil(mBitmaps[index].getHeight() / blockWidth);

分片算法


这个算法实际上是每次将列数 +1,然后按对角分割,把符合的区域添加到path中


int x = xPosition;
int y = 0;
while (x >= 0 && y <= row) {
if (x < col && y < row) {
dstRect.set((int) (x * blockWidth), (int) (y * blockWidth), (int) (x * blockWidth + blockWidth), (int) (y * blockWidth + blockWidth));
// bitmapCanvas.drawBitmap(mBitmaps[index], dstRect, dstRect, mCommonPaint);
path.addRect(dstRect, Path.Direction.CCW); //加入网格分片
}
x--;
y++;
}

Path 路径贴图



  • Path过程中我们添加的rect是闭合区域,是可以贴图的,当然,一般有三种方法:

  • Path的贴图一般使用 clipPath对图片裁剪然后贴图,当然还有将对应的图片区域绘制到View上

  • Path 是Rect,按照Rect将图片区域绘制到Rect区域

  • 使用BitmapShader一次性绘制


实际上我们应该尽可能使用Bitmap,因为BitmapShader唯一是不存在锯齿性能比较好的绘制方法。


int save = bitmapCanvas.save();
mCommonPaint.setShader(new BitmapShader(mBitmaps[index], Shader.TileMode.CLAMP, Shader.TileMode.CLAMP));
bitmapCanvas.drawPath(path,mCommonPaint);
bitmapCanvas.restoreToCount(save);

其实我们的核心代码到这里就结束了,我们可以看到,分片可以的意义很重要的,当然,借助其他工具也可以实现,不过代码实现的好处是可以编辑和交互,不是所有的动画都可以产生交互。


到此,我们还可以对今天的demo添加一些想象



  • 从中间外扩效果

  • 奇偶行切换效果

  • 国际象棋黑白格子变换效果

  • ......


总结


这是我们的第三篇关于图片分片特效的博客,希望通过一些了的文章,熟悉一些技术,往往看似高大上的效果,其实就是通过普普通通的方法叠加在一起的,当然,让你的技术承载你的想象,才是最重要的。


本篇demo全部代码


实际上代码贴太多很可能没人看,但是依照惯例,我们给出完整代码。


public class TilesView extends View {
private final DisplayMetrics mDM;
private TextPaint mCommonPaint;
private RectF mainRect = new RectF();
private BitmapCanvas bitmapCanvas; //Canvas 封装的
private Bitmap[] mBitmaps;
private RectF dstRect = new RectF();
Path path = new Path();
private float blockWidth = 50f;
private int xPosition = -2;
private int index = 0;
private boolean isTicking = false;
public TilesView(Context context) {
this(context, null);
}
public TilesView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}

public TilesView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
mDM = getResources().getDisplayMetrics();
initPaint();
}

private void initPaint() {
//否则提供给外部纹理绘制
mCommonPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG | Paint.FILTER_BITMAP_FLAG);
mCommonPaint.setAntiAlias(true);
mCommonPaint.setStyle(Paint.Style.FILL_AND_STROKE);
mCommonPaint.setStrokeCap(Paint.Cap.ROUND);
mCommonPaint.setFilterBitmap(true);
mCommonPaint.setDither(true);
mBitmaps = new Bitmap[3];
mBitmaps[0] = decodeBitmap(R.mipmap.mm_013);
mBitmaps[1] = decodeBitmap(R.mipmap.mm_014);
mBitmaps[2] = decodeBitmap(R.mipmap.mm_015);
}

private Bitmap decodeBitmap(int resId) {
BitmapFactory.Options options = new BitmapFactory.Options();
options.inMutable = true;
return BitmapFactory.decodeResource(getResources(), resId, options);
}

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {

int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);

if (widthMode != MeasureSpec.EXACTLY) {
widthSize = mDM.widthPixels / 2;
}
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);

if (heightMode != MeasureSpec.EXACTLY) {
heightSize = widthSize / 2;
}
setMeasuredDimension(widthSize, heightSize);

}

@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
if (bitmapCanvas != null && bitmapCanvas.bitmap != null && !bitmapCanvas.bitmap.isRecycled()) {
bitmapCanvas.bitmap.recycle();
}
bitmapCanvas = null;

}


@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
int width = getWidth();
int height = getHeight();
if (width < 1 || height < 1) {
return;
}
if (bitmapCanvas == null || bitmapCanvas.bitmap == null || bitmapCanvas.bitmap.isRecycled()) {
bitmapCanvas = new BitmapCanvas(Bitmap.createBitmap(mBitmaps[index].getWidth(), mBitmaps[index].getHeight(), Bitmap.Config.ARGB_8888));
}
int nextIndex = (index + 1) % mBitmaps.length;
canvas.drawBitmap(mBitmaps[nextIndex],0,0,mCommonPaint);

int col = (int) Math.ceil(mBitmaps[index].getWidth() / blockWidth);
int row = (int) Math.ceil(mBitmaps[index].getHeight() / blockWidth);
mCommonPaint.setStyle(Paint.Style.FILL);

// path.reset();
// for (int x = 0; x < row; x++) {
// for (int y = 0; y < col; y++) {
// gridRectF.set(x * blockWidth, y * blockWidth, x * blockWidth + blockWidth, y * blockWidth + blockWidth);
// canvas.drawRect(gridRectF, mCommonPaint);
// path.addRect(gridRectF, Path.Direction.CCW);
// }
// }

diagonalEffect(col,row,xPosition,path);
canvas.drawBitmap(bitmapCanvas.bitmap, 0, 0, mCommonPaint);

if (isTicking && xPosition >= 0 && xPosition < col * 2) {
clockTick();
} else if(isTicking){
xPosition = -1;
index = nextIndex;
isTicking = false;
}
}

private void diagonalEffect(int col, int row, int xPosition,Path path) {
int x = xPosition;
int y = 0;
while (x >= 0 && y <= row) {
if (x < col && y < row) {
dstRect.set((int) (x * blockWidth), (int) (y * blockWidth), (int) (x * blockWidth + blockWidth), (int) (y * blockWidth + blockWidth));
// bitmapCanvas.drawBitmap(mBitmaps[index], dstRect, dstRect, mCommonPaint);
path.addRect(dstRect, Path.Direction.CCW); //加入网格分片
}
x--;
y++;
}
int save = bitmapCanvas.save();
mCommonPaint.setShader(new BitmapShader(mBitmaps[index], Shader.TileMode.CLAMP, Shader.TileMode.CLAMP));
bitmapCanvas.drawPath(path,mCommonPaint);
bitmapCanvas.restoreToCount(save);

}

public void tick() {
isTicking = true;
xPosition = -1;
path.reset();
clockTick();
}

private void clockTick() {
xPosition += 1;
postInvalidateDelayed(16);
}


public float dp2px(float dp) {
return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, mDM);
}

public float sp2px(float dp) {
return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, dp, mDM);
}

static class BitmapCanvas extends Canvas {
Bitmap bitmap;
public BitmapCanvas(Bitmap bitmap) {
super(bitmap);
//继承在Canvas的绘制是软绘制,因此理论上可以绘制出阴影
this.bitmap = bitmap;
}

public Bitmap getBitmap() {
return bitmap;
}
}
}

作者:时光少年
来源:juejin.cn/post/7309329004497354804
收起阅读 »

flutter 响应式观察值并更新UI

响应式编程是一种以对数据随时间变化做出反应为中心的范式。它有助于自动传播更新,从而可确保 UI 和数据保持同步。用 Flutter 术语来说,这意味着只要状态发生变化就会自动触发重建。 Observables Observables是响应式编程的核心。这些数据...
继续阅读 »

响应式编程是一种以对数据随时间变化做出反应为中心的范式。它有助于自动传播更新,从而可确保 UI 和数据保持同步。用 Flutter 术语来说,这意味着只要状态发生变化就会自动触发重建。


Observables


Observables是响应式编程的核心。这些数据源会在数据发生变化时向订阅者发出更新。Dart 的核心可观察类型是 Stream。


当状态发生变化时,可观察对象会通知侦听器。从用户交互到数据获取操作的任何事情都可以触发此操作。这有助于 Flutter 应用程序实时响应用户输入和其他更改。


Flutter 有两种类型:ValueNotifierChangeNotifier,它们是类似 observable 的类型,但不提供任何真正的可组合性计算。


ValueNotifier


Flutter 中的类ValueNotifier在某种意义上是响应式的,因为当值发生变化时它会通知观察者,但您需要手动监听所有值的变化来计算完整的值。


1、监听


  // 初始化
final ValueNotifier<String> fristName = ValueNotifier('Tom');
final ValueNotifier<String> secondName = ValueNotifier('Joy');
late final ValueNotifier<String> fullName;

@override
void initState() {
super.initState();
fullName = ValueNotifier('${fristName.value} ${secondName.value}');

fristName.addListener(_updateFullName);
secondName.addListener(_updateFullName);
}

void _updateFullName() {
fullName.value = '${fristName.value} ${secondName.value}';
}


//更改值得时候
firstName.value = 'Jane'
secondName.value = 'Jane'

2、使用ValueListenableBuilder更新UI


 //通知观察者
ValueListenableBuilder<String>(
valueListenable: fullName,
builder: (context, value, child) => Text(
'${fristName.value} ${secondName.value}',
style: Theme.of(context).textTheme.headlineMedium,
),
),

ChangeNotifier


1、监听


  String _firstName = 'Jane';
String _secondName = 'Doe';

String get firstName => _firstName;
String get secondName => _secondName;
String get fullName => '$_firstName $_secondName';

set firstName(String newName) {
if (newName != _firstName) {
_firstName = newName;
// Triggers rebuild
notifyListeners();
}
}

set secondName(String newSecondName) {
if (newSecondName != _secondName) {
_secondName = newSecondName;
// Triggers rebuild
notifyListeners();
}
}

//更改值得时候
firstName.value = 'Jane'
secondName.value = 'Jane'

2、使用AnimatedBuilder更新UI


//通知观察者
AnimatedBuilder(
animation: fullName,
builder: (context, child) => Text(
fullName,
style: Theme.of(context).textTheme.headlineMedium,
),
),

get


GetX将响应式编程变得非常简单。



  • 您不需要创建 StreamController。

  • 您不需要为每个变量创建一个 StreamBuilder。

  • 你不需要为每个状态创建一个类。

  • 你不需要创造一个终极价值。


使用 Get 的响应式编程就像使用 setState 一样简单。
让我们想象一下,您有一个名称变量,并且希望每次更改它时,所有使用它的小组件都会自动刷新。


1、监听以及更新UI


//这是一个普通的字符串
var name = 'Jonatas Borges';
为了使观察变得更加可观察,你只需要在它的附加上添加“.obs”。
var name = 'Jonatas Borges'.obs;
而在UI中,当你想显示该值并在值变化时更新页面时,只需这样做。
Obx(() => Text("${controller.name}"));

Riverpod


final fristNameProvider = StateProvider<String>((ref) => 'Tom');
final secondNameProvider = StateProvider<String>((ref) => 'Joy');
final fullNameProvider = StateProvider<String>((ref) {
final fristName = ref.watch(fristNameProvider);
final secondName = ref.watch(secondNameProvider);
return '$fristName $secondName';
});

//更改值得时候
ref.read(fristNameProvider.notifier).state =
'Jane'
ref.read(secondName.notifier).state =
'BB'


2、使用ConsumerWidget更新UI


ref.read(surnameProvider) 读取某个值


ref.read(nameProvider.notifier).state 更新某个值的状态


class MyHomePage extends ConsumerWidget {
const MyHomePage({super.key});

@override
Widget build(BuildContext context, WidgetRef ref) =>
Scaffold(
appBar: AppBar(
title: const Text('Riverpod Example'),
),
body: Text(
ref.watch(fullNameProvider),
style: Theme.of(context).textTheme.headlineMedium,
),
);
}


这里Consumer组件是与状态交互所必需的,Consumer有一个非标准build方法,这意味着如果您需要更改状态管理解决方案,您还必须更改组件而不仅仅是状态。


RxDart


RxDart将ReactiveX的强大功能引入Flutter,需要明确的逻辑来组合不同的数据流并对其做出反应。


存储计算值:它不会以有状态的方式直接存储计算值,但它确实提供了有用的运算符(例如distinctUnique)来帮助您最大限度地减少重新计算。


RxDart 库还有一个流行的类型被称为BehaviorSubject。响应式编程试图解决的核心问题是当依赖图中的任何值(依赖项)发生变化时自动触发计算。如果有多个可观察值,并且您需要将它们合并到计算中,Rx 库自动为我们执行此操作并且自动最小化重新计算以提高性能。


该库向 Dart 的现有流添加了功能。它不会重新发明轮子,并使用其他平台上的开发人员熟悉的模式。


1、监听


 final fristName = BehaviorSubject.seeded('Tom');
final secondName = BehaviorSubject.seeded('Joy');

/// 更新值
fristName.add('Jane'),
secondName.add('Jane'),


2、使用StreamBuilder更新UI


 StreamBuilder<String>(
stream: Rx.combineLatest2(
fristName,
secondName,
(fristName, secondName) => '$fristName $secondName',
),
builder: (context, snapshot) => Text(
snapshot.data ?? '',
style: Theme.of(context).textTheme.headlineMedium,
),
),

Signals


Signals以其computed功能介绍了一种创新、优雅的解决方案。它会自动创建反应式计算,当任何依赖值发生变化时,反应式计算就会更新。


1、监听


  final name = signal('Jane');
final surname = signal('Doe');
late final ReadonlySignal<String> fullName =
computed(() => '${name.value} ${surname.value}');
late final void Function() _dispose;

@override
void initState() {
super.initState();
_dispose = effect(() => fullName.value);
}

2、使用watch更新UI


Text(
fullName.watch(context),
style: Theme.of(context).textTheme.headlineMedium,
),

作者:icc_tips
来源:juejin.cn/post/7309131109740724259
收起阅读 »

拥抱华为,困难重重,第一天开始学习 ArkUI,踩坑踩了一天

今天第一天正式开始学习鸿蒙应用开发。 本来想着,作为一个拥有 10 来年工作经验的资深开发,对 React/Vue,React Native,Android 开发都有丰富的实践经验,对 Swift UI 也有所涉猎,在大前端这一块可以说是信心满满,学习 Ark...
继续阅读 »

今天第一天正式开始学习鸿蒙应用开发。


本来想着,作为一个拥有 10 来年工作经验的资深开发,对 React/Vue,React Native,Android 开发都有丰富的实践经验,对 Swift UI 也有所涉猎,在大前端这一块可以说是信心满满,学习 ArkUI 应该信手拈来才对,谁知道学习的第一天,我就发现我太天真了。


HarmonyOS 与 ArkUI 给我了沉痛一击


学习第一天一点都不顺利,上午还算有所收获,下午直接毫无建树,踩在一个坑里出不来,人直接裂开,差点以为自己要创业未半而中道崩殂了。不过好在晚饭后,侥幸解决了下午遇到的坑


最终今天学习的成果如下


scroll.gif


导航栏的4个图标都是用了 lottie 的动画,因为使用了 gif 录制,可能有点感觉不太明显,真机上的感受非常舒适,用户体验极佳


今天已经学习过的内容包括



  • 基础项目结构

  • 基础布局组件

  • 容器布局组件

  • 滚动组件

  • 导航组件

  • ohpm 安装

  • 引入 lottie 动画

  • 属性动画

  • 配置 hot reload

  • 组件状态管理 @state @props @link

  • 组件逻辑表达式

  • 沉浸式状态栏

  • 真机调试


我的开发设备具体情况如下


MacOS M1
HarmonyOS API 9
华为 P40 pro+,已安装 HarmonyOS 4

作为一个把主要精力放在前端的开发者,做个记录分享一下学习体会


01


组件概念


在前端开发中,不管你是用 React 还是使用 Vue,我们只需要掌握一个概念:组件。复杂的组件是由小的组件组成,页面是由组件组成,项目是由组件组成,超大项目也是由组件组成。组件可以组成一切。因此 React/Vue 的学习会相对更简单一些


和 Android 一样,由于 HarmonyOS 有更复杂的应用场景、多端、分屏等,因此在这一块的概念也更多一些,目前我接触到的几个概念包括


Window 一个项目好像可以有多个窗口,由于接触的时间太短了暂时不是很确定,可以创建子窗口,可以管理窗口的相关属性,创建,销毁等


Ability 用户与应用交互的入口点,一个 app 可以有一个或者对个 Ability


page 页面,一个应用可以由多个 page 组成


Component 组件,可以组合成页面


由于目前接触的内容不够全面,因此对这几个概念的理解还不够笃定,只是根据自己以往的开发经验推测大概可能是什么情况,因此介绍得比较简单,但是可以肯定的是理解这些概念是必备的


02


基础布局


虽然 HarmonyOS 目前也支持 web 那一套逻辑开发,不过官方文档已经明确表示未来将会主推 arkUI,因此我个人觉得还是应该把主要学习重心放在 arkUI 上来


arkUI 的布局思路跟 html + css 有很大不同。


html + css 采用的是结构样式分离的方式,再通过 class/id 关联起来。因此,html + css 的布局写起来会简单很多,我们只需要先写结构,然后慢慢补充样式即可


arkUI 并未采用分离思路,而是把样式和结构紧密结合在一起,这样做的好处就是性能更强了,因为底层渲染引擎不用专门写一套逻辑去匹配结构和样式然后重新计算 render-tree,坏处就是...


代码看着有点糟心


比如下面这行代码,表示两段文字


Column() {
Text('一行文字')
.textAlign(TextAlign.Center)
.fontSize(30)
.width('100%')
.backgroundColor('#aabbcc')
Text('二行文字')
.textAlign(TextAlign.Center)
.fontSize(30)
.width('100%')
.backgroundColor('#aabbcc')
}.width('100%')
.height('100%')
.backgroundColor('red')

如果用 html 来表示的话....


<div>
<p>一行文字p>
<p>一行文字p>
div>

当然我期望能找到一种方式去支持属性的继承和复用。目前简单找了一下没找到,希望有吧 ~


由于 html 中 div 足以应付一切,因此许多前端开发者会在思考过程中忽视或者弱化容器组件的存在,反而 arkUI 的学习偏偏要从容器组件开始理解


我觉得这种思路会对解耦思路有更明确的训练。许多前端开发在布局时不去思考解耦问题,我认为这是一个坏处。


arkUI 的布局思路是:先考虑容器,再考虑子元素,并且要把样式特性结合起来一起思考。而不是只先思考结构,再考虑样式应该怎么写。


例如,上面的 GIF 图中, nav 导航区域是由 4 按钮组成。先考虑容器得是一个横向的布局


然后每一个按钮,包括一个图标和一个文字,他们是纵向的布局,于是结构就应该这样写


Row: 横向布局
Column: 竖向布局
Row() {
Column() { Lottie() Text() }
Column() { Lottie() Text() }
Column() { Lottie() Text() }
Column() { Lottie() Text() }
}

按照这个思路去学习,几个容器组件 Row/Column/FLex/Stack/GridContainer/SideBarContainer ... 很快就能掌握


03


引入 lottie


在引入 lottie 的时候遇到了几个坑。


一个是有一篇最容易找到的文章介绍如何在 arkUI 中引入 lottie,结果这篇文章是错误的。 ~ ~,这篇文章是在官方博客里首发,让我走了不少弯路。


image.png


这里面有两个坑,一个坑是 @ohos/lottie-ohos-ets 的好像库不见了。另外一个坑就是文章里指引我用 npm 下载这个库。但是当我用 npm 下载之后,文件会跑到项目中的 node_modules 目录下,不过如何在 arkUI 的项目中引入 node_modules 中的库,我还没找到方法,应该是要在哪里配置一下


最后在 gitee 的三方仓库里,找到了如下三方库


import lottie from '@ohos/lottie';

这里遇到的一个坑就是我的电脑上的环境变量不知道咋回事被改了,导致 ohpm 没了,找了半天才找到原因,又重新安装 ohpm,然后把环境变量改回来



  1. 到官方文档下载对应的工具包

  2. 把工具包放到你想要放的安装目录,然后解压,进去 ohpm/bin 目录,在该目录下执行 init 脚本开始安装


> init


  1. 然后使用如下指令查看当前文件路径


> pwd

然后执行如下指令


// OHPM_HOME 指的是你自己的安装路径
> export OHPM_HOME=/home/xx/Downloads/ohpm
> export PATH=${OHPM_HOME}/bin:${PATH}


  1. 执行如下指令检查是否安装成功


> ohpm -v

@ohos/lottie


使用如下指令下载 lottie


ohpm install @ohos/lottie

然后在 page 中引入


import lottie from '@ohos/lottie'

在类中通过定义私有变量的方式构建上下文


private mrs: RenderingContextSettings = new RenderingContextSettings(true)
private ctx: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.mrs)

并且用私有变量保存 lottie 数据路径或者内容


private path: string = 'common/lottie/home.json'

然后在 build 中,结合 Canvas 组件绘制


Canvas(this.ctx).onReady(() => {
lottie.loadAnimation({
container: this.ctx,
renderer: 'canvas',
loop: false,
autoplay: true,
path: this.path
})
})

参考文章:@ohos/lottie


04


hot reload


使用 commond + , 调出配置页面,然后通过如下路径找到配置选中 Perform hot reload


Tools -> Actions on Save -> Perform hot reload

image.png


然后在项目运行的入口处,选择 entry -> edit configrations,弹出如下界面,选中 Hot Reload 的 entry,做好与下图中一致的勾选,点击 apply 按钮之后启动项目即可实现 hot reload


image.png


不过呢,hot reload 在调试样式的时候还能勉强用一用,涉及到代码逻辑的更改,往往没什么用,属实是食之无味,弃之可惜


除此之外,也许 Previewer 更适合开发时使用


image.png


05


沉浸式状态栏


沉浸式状态栏是一款体验良好的 app 必备能力。因此我学会了基础知识之后,第一时间就想要研究一下再 HarmonyOS 中如何达到这个目的。


沉浸式状态栏指的就是下图中位置能够做到与页面标题栏,或者页面背景一致的样式。或者简单来说,可以由我们开发者来控制这一块样式。布局进入全屏模式。


image.png


在我们创建入口 Ability 时,可以在生命周期 onWindowStageCreate 中设置全屏模式


onWindowStageCreate(windowStage: window.WindowStage) {
windowStage.getMainWindow(err, mainWindow: window.Window) {
if (err.code) {
return
}
mainWindow.setWindowLayoutFullScreen(true)
}
}

setWindowLayoutFullScreen 是一个异步函数,因此如果你想要修改状态栏样式的话,可以在它的回调里,通过 setWindowSystemBarProperties 去设置


mainWindow.setWindowLayoutFullScreen(true, (err) => {
if (err) { return }
mainWindow.setWindowSystemBarProperties({ statusBarColor: '#FFF' })
})

具体的参数配置,可以在代码中,查看类型声明获悉。


这里有一个巨大的坑,就是在我的开发环境启动的模拟器中 API 9,当你设置了全屏模式之后,布局会发生混乱。真机调试又是正常的。


我刚开始以为是我的代码哪里没搞对,为了解决这个问题花了一个多小时的时间,结果最后才确定是模拟器的布局 bug...


真机调试


真机调试的设置方式主要跟其他 app 开发都一样,把手机设置为开发者模式即可。不过你需要通过如下方式,配置好一个应用签名才可以。因此你首先需要注册成为华为开发者


File -> Project Structure -> Signing Configs -> Sign in

跟着指引在后台创建项目,然后再回到开发者工具这个页面自动生成签名即可


image.png


真机调试有一个巨大无比的坑,那就是 API 9 创建的项目,在老版本的麒麟芯片上巨卡无比。连基本的点击都无法响应。


这就要了命了。如果连真机调试都做不到,那还拥抱个啥啊?


研究了很久,找到了几个解决这个问题的方法


1、换新机,只要你的手机不是华为被制裁之前的麒麟芯片,都不会存在这个问题


2、创建项目时,选择 API 8


3、在开发者选项的配置中,选择 显示面(surface)更新,虽然不卡了,不过闪瞎了我的狗眼


4、等明年 HarmonyOS next 出来之后再来学,官方说,API 10 将会解决这个问题


上面的解决办法或多或少都有一些坑点。我选择了一种方式可以很好的解决这个问题


那就是:投屏


如果你有一台华为电脑,这个投屏会非常简单。不过由于我是 mac M1,因此我选择的投屏方案是 scrcpy


使用 brew 安装


> brew install scrcpy

然后继续安装


> brew install android-platform-tools

启动


> scrcpy

启动之前确保只有一台手机已经通过 USB 连接到电脑,并允许电脑调试手机就可以成功投屏。在投屏中操作手机,就变得非常流畅了


不过目前我通过这种方式投屏之后,运行起来的项目经常闪退,具体是什么原因我还没找到,只能先忍了


总之就是坑是一个接一个 ~ ~


06


总结


一整天的学习,整体感受下就如标题说的那样:拥抱华为,困难重重。 还好我电脑性能强悍,要是内存少一点,又是虚拟机,又是投屏的,搞不好内存都不够用,可以预想,其他开发者还会遇到比我更多的坑 ~ ~


image.png


个人感觉华为相关的官方文档写得不是很友好,比较混乱,找资料很困难。反而在官方上把一堆莫名其妙的教学视频放在了最重要的位置,我不是很明白,到底是官方文档,还是视频教程网站 ~ ~


官方文档里还涉及了 FA mode 到 Stage mode 的更新,因此通过搜索引擎经常找到 FA mode 的相关内容,可是 FA mode 又是被弃用的,因为这个问题也给我的学习带来了不少的麻烦。由于遇到的坑太多了,以致于我到现在尝试点什么新东西都紧张兮兮的,生怕又是坑


总的来说,自学困难重重,扛得住坑的,才能成为最后的赢家,红利不是那么好吃的


作者:这波能反杀
来源:juejin.cn/post/7309734518586523657
收起阅读 »

Android 实现自动滚动布局

前言 在平时的开发中,有时会碰到这样的场景,设计上布局的内容会比较紧凑,导致部分机型上某些布局中的内容显示不完全,或者在数据内容多的情况下,单行无法显示所有内容。这时如果要进行处理,无非就那几种方式:换行、折叠、缩小、截取内容、布局自动滚动等。而这里可以简单介...
继续阅读 »

前言


在平时的开发中,有时会碰到这样的场景,设计上布局的内容会比较紧凑,导致部分机型上某些布局中的内容显示不完全,或者在数据内容多的情况下,单行无法显示所有内容。这时如果要进行处理,无非就那几种方式:换行、折叠、缩小、截取内容、布局自动滚动等。而这里可以简单介绍下布局自动滚动的一种实现方式。


1. 布局自动滚动的思路


要实现滚动的效果,在Android中无非两种,吸附式的滚动或者顺滑式的滚动,吸附式就是类似viewpager换页的效果,如果需求上是要实现这样的效果,可以使用viewpager进行实现,这个类型比较简单,这里就不过多介绍。另一种是顺滑的,非常丝滑的缓慢移动的那种,要实现这种效果,可以使用RecyclerView或者ScrollView来实现。我这里主要使用ScrollView会简单点。


滑动的控件找到了,那要如何实现丝滑的自动滚动呢?我们都知道ScrollView能用scrollTo和scrollBy去让它滚动到某个位置,但如何去实现丝滑的效果?


这里就用到了属性动画, 我之前的文章也提到过属性动画的强大 juejin.cn/post/714419…


所以我这边会使用ScrollView和属性动画来实现这个效果


2. 最终效果


可以写个Demo来看看最终的效果


799d8a54-ed7d-4137-8a00-ad6bed2e2499.gif


这就是一个横向自动滚动的效果。


3. 代码实现


先写个接口定义自动滚动的行为


interface Autoscroll {

// 开始自动滚动
fun autoStart()

// 停止自动滚动
fun autoStop()

}

然后自定义一个View继承ScrollView,方便阅读,在代码中加了注释


// 自定义View继承HorizontalScrollView,我这里演示横向滚动的,纵向可以使用ScrollView
class HorizontalAutoscrollLayout @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : HorizontalScrollView(context, attrs, defStyleAttr), Autoscroll {

// 一些流程上的变量,可以自己去定义,变量多的情况也可以使用builder模式
var isLoop = true // 滚动到底后,是否循环滚动
var loopDelay = 1000L // 滚动的时间
var duration = 1000L // 每一次滚动的间隔时间

private var offset: Int = 0
val loopHandler = Handler(Looper.getMainLooper())
var isAutoStart = false

private var animator: ValueAnimator? = null

override fun autoStart() {
// 需要计算滚动距离所以要把计算得代码写在post里面,等绘制完才拿得到宽度
post {
var childView = getChildAt(0)
childView?.let {
offset = it.measuredWidth - width
}

// 判断能否滑动,这里只判断了一个方向,如果想做两个方向的话,多加一个变量就行
if (canScrollHorizontally(1)) {
animator = ValueAnimator.ofInt(0, offset)
.setDuration(duration)
// 属性动画去缓慢改变scrollview的滚动位置,抽象上也可以说改变scrollview的属性
animator?.addUpdateListener {
val currentValue = it.animatedValue as Int
scrollTo(currentValue, 0)
}
animator?.addListener(object : Animator.AnimatorListener {
override fun onAnimationStart(animation: Animator) {

}

override fun onAnimationEnd(animation: Animator) {
// 动画结束后判断是否要重复播放
if (isLoop) {
loopHandler?.postDelayed({
if (isAutoStart) {
scrollTo(0, 0)
autoStart()
}
}, loopDelay)
}
}

override fun onAnimationCancel(animation: Animator) {

}

override fun onAnimationRepeat(animation: Animator) {

}

})
animator?.start()
isAutoStart = true
}

}
}

// 动画取消
override fun autoStop() {
animator?.cancel()
isAutoStart = false
loopHandler.removeCallbacksAndMessages(null)
}

}

能看到实现这个功能,写的代码不会很多。其中主要需要注意一些点:

(1)属性动画要熟,我这里只是简单的效果,但如果你对属性动画能熟练使用的话,你还可以做到加速、减速等效果

(2)页面关闭的时候要调用autoStop去关闭动画

(3)这里是用scrollTo去实现滚动的效果,scrollBy也可以,但是写法就不是这样了


从代码可以看出没什么难点,都是比较基础的知识,比较重要的知识就是属性动画,熟练的话做这种效果的上限就很高。其他的像这里为什么用post,为什么用scrollTo,这些就是比较基础的知识,就不扩展讲了。


最后看看使用的地方,先是Demo的布局


<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:gravity="center_vertical"
android:layout_width="match_parent"
android:layout_height="match_parent">

<com.kylin.testproject.HorizontalAutoscrollLayout
android:id="@+id/auto_scroll"
android:layout_width="150dp"
android:layout_height="wrap_content">

<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
>

<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="20sp"
android:text="小日本"
/>

<ImageView
android:layout_width="36dp"
android:layout_height="36dp"
android:scaleType="fitXY"
android:src="@drawable/a"
/>

<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="20sp"
android:text="排放核废水污染海洋"
/>

<ImageView
android:layout_width="36dp"
android:layout_height="36dp"
android:scaleType="fitXY"
android:src="@drawable/b"
/>

<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="20sp"
android:text=",必遭天谴!!"
/>

</LinearLayout>

</com.kylin.testproject.HorizontalAutoscrollLayout>

</LinearLayout>


然后在开始播放自动滚动(注意页面关闭的时候要手动停止)


override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.main)

val autoScroll: HorizontalAutoscrollLayout = findViewById(R.id.auto_scroll)
autoScroll.duration = 3000
autoScroll.loopDelay = 2000
autoScroll.autoStart()
}

4. 总结


代码比较简单,而且都加上了注释,所以没有其他要说明的。

前段时间太忙,所以这几个月都没时间写文章。想了一下,这个还是要坚持,如果有时间的话抽出点时间一天写一点,得保持一个常更新的状态。


作者:流浪汉kylin
来源:juejin.cn/post/7309392585679110194
收起阅读 »

【Flutter技术】如何识别一个应用是原生框架开发还是Flutter开发?

前言 根据Google官方的统计,截至2023年5月,已有超过100万个应用程序使用Flutter发布到数亿台设备上。国内许多知名公司也广泛的使用了Flutter技术,例如,腾讯、字节、阿里等等。Flutter也成为了最受欢迎的跨平台技术之一。 当前,我们手机...
继续阅读 »

前言


根据Google官方的统计,截至2023年5月,已有超过100万个应用程序使用Flutter发布到数亿台设备上。国内许多知名公司也广泛的使用了Flutter技术,例如,腾讯、字节、阿里等等。Flutter也成为了最受欢迎的跨平台技术之一。


当前,我们手机上以及各大应用市场有大量的应用采用了Flutter跨平台技术框架,例如,微信、微博、闲鱼等等。由于Flutter框架出色的性能表现,可能不被大家所感知,接下来,跟大家分享几个鉴别一个APP是否使用了Flutter开发的方法。


1 - 双指滚动


一个比较方便、且非常快速的方法就是打开一个可滚动的页面(可以用闲鱼的商品详情页面测试),用双指或者三指滑动,如果滚动的速度加快,是用单指滚动的两倍或者三倍,那么这个页面基本可以确定是用Flutter开发的。大家可以打开手机上应用尝试一下,例如闲鱼商品的详情页面。


这方法的原理是源于Flutter的一个祖传BUG ---> [#11884] Scrolling with two fingers scrolls twice as fast,在Android和iOS平台都是如此的变现,因此可以用来检查应用是否使用了Flutter开发。


当笔者看到这个BUG之后,惊掉了下巴(2017年的ISSUE,2023年才被修复!),于是尝试修复了这个BUG ---> [#136708] Introduce multi-touch drag strategies for DragGestureRecognizer,该Patch引入了一个新的属性MultitouchDragStrategy,可以自定义多指滚动的行为,同时将系统默认行为修改为Android的表现,并计划很快补充iOS的行为。


image.png


该Patch当前已经合入master分支,在release到stable分支之前,通过双指滚动来鉴定是否使用Flutter开发依然是最便捷的方法 :)


2 - 显示布局边界 + dumpsys activity


该方法适用于Android手机,打开手机的“开发者模式”,在设置页面搜索“边界”,找到“显示布局边界”并打开:


drawing
drawing

对于原生开发的Android应用,可以查看到所有元素的边界,例如闲鱼首页采用原生开发:


drawing


当进入分类页面之后,除了手机SystemUI可以看见元素的边界,页面内容的元素系统是识别不到边界的:


drawing


此时,我们通过adb shell命令dumpsys activity activities,可以看到TOP的Activity是MainActivity


drawing

以上识别的原理为:Flutter projects的默认入口为MainActivity,它又继承于FlutterActivity,而它的内部实现为SurfaceView,Flutter通过canvas自绘所有控件,正因为如此,Android是无法识别FlutterView里的元素边界的。


3 - 日志


一般来说,集成了Flutter框架的应用,在log里会有相关Flutter日志的输出,例如,我们在操作 微信 的时候,logcat里会有flutter日志的输出:


Image_20231206105420.png


Image_20231206105403.png


4 - 安装包文件


我们也可以通过安装包里是否集成了Flutter相关lib来推断是否使用了Flutter框架


以Android手机为例:


1,提取apk,方法如下:
# 首先确保已经将ADB工具添加到系统路径中
$ adb devices # 查看设备列表,确认设备正常连接

#
然后使用以下命令获取APK的位置信息
$ adb shell pm path com.example.appname
package:/data/app/com.example.appname-1234567890abcdefg/base.apk

#
最后使用以下命令复制APK到计算机上指定目录
$ adb pull /data/app/com.example.appname-1234567890abcdefg/base.apk ~/Desktop/my_app.apk

提取了apk文件之后,可以通过7-zip提取解压,然后搜索‘flutter’相关文件,如果使用了Flutter框架,会有flutter相关lib文件(闲鱼APK):


image.png


image.png


4 - FlutterShark


image.png


可以在Android手机上安装FlutterShark应用,在赋予它QUERY_ALL_PACKAGES权限后,他可以展示手机中所有使用了Flutter框架的应用:


Screenshot_2023-12-06-15-03-55-64[1].png


同时,FlutterShark还支持显示某个应用所依赖的三方package:


image.png


总结


以上跟大家分享了几种识别Flutter应用的方法,如果你还知道有其它的方法,请在评论区留言吧 : )


作者长期活跃在Flutter开源社区,欢迎大家一起参与开源社区的共建,如果您也有意愿参与Flutter社区的贡献,可以与作者联系。-->GITHUB


您也许还对这些Flutter技术分享感兴趣:



作者:xubaolin
来源:juejin.cn/post/7309065017191088143
收起阅读 »

Android 视频图像实时文字化

一、前言 在本篇之前,很多博客已经实现过图片文本化,但是由于渲染方式的不合理,大部分设计都做不到尽可能实时播放,本篇将在已有的基础上进行一些优化,使得视频文字化具备一定的实时性。 下图总体上视频和文字化的画面基本是实时的,上面是SurfaceView,下面是我...
继续阅读 »

一、前言


在本篇之前,很多博客已经实现过图片文本化,但是由于渲染方式的不合理,大部分设计都做不到尽可能实时播放,本篇将在已有的基础上进行一些优化,使得视频文字化具备一定的实时性。


下图总体上视频和文字化的画面基本是实时的,上面是SurfaceView,下面是我们自定义的View类,通过实时抓帧然后实时转bitmap,做到了基本同步。



二、现状


目前很多流行的方式是修改像素的色值,这个性能差距太大,导致卡顿非常严重,无法做到实时性。当然也有通过open gl mask实现的,但是在贴图这一块我们知道,open gl只支持绘制三角、点和线,因此“文字”纹理生成还得利用Canvas实现。



但对于对帧率要求不高的需求,是不是有更好的方案呢?


三、优化方案


优化点1: 使用Shader


网上很多博客都是利用Bitmap#getPixel和Bitmap#setPixel进行,这个计算量显然太大了,就算使用open gl 也未必好,因此首先解决的问题就是使用Shader着色。


优化点2: 提前计算好单个文字所占的最大空间


显然这个原因是更加整齐的排列文字,其次也可以做到降低计算量和提高灵活度


优化点3:使用队列


对于了编解码的开发而言,使用队列不仅可以复用buffer,而且还能提高绘制性能,另外必要时可以丢帧。


基于以上三点,基本可以做到实时字符化画面,当然,我们这里是彩色的,对于灰度图的需求,可通过设置Paint的ColorMatrix实现,总之,要避免遍历修改像素了RGB。


四、关键代码


使用shader着色


 this.bitmapShader = new BitmapShader(inputBitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP);
this.mCharPaint.setShader(bitmapShader);
//用下面方式清空bitmap
boardBitmap.bitmap.eraseColor(Color.TRANSPARENT);

计算字符size


    private Rect computeMaxCharWidth(TextPaint drawerPaint, String text) {
if (TextUtils.isEmpty(text)) {
return null;
}
Rect result = new Rect(); // 文字所在区域的矩形
Rect md = new Rect();
for (int i = 0; i < text.length(); i++) {
String s = text.charAt(i) + "";
if(TextUtils.isEmpty(s)) {
continue;
}
drawerPaint.getTextBounds(s, 0, 1, md);
if (md.width() > result.width()) {
result.right = md.width();
}
if (md.height() > result.height()) {
result.bottom = md.height();
}
}
return result;
}

定义双队列,实现控制和享元机制


    private BitmapPool bitmapPool = new BitmapPool();
private BitmapPool recyclePool = new BitmapPool();

static class BitmapPool {
int width;
int height;
private LinkedBlockingQueue<BitmapItem> linkedBlockingQueue = new LinkedBlockingQueue<>(5);
}

static class BitmapItem{
Bitmap bitmap;
boolean isUsed = false;
}

完整代码


public class WordBitmapView extends View {
private final DisplayMetrics mDM;
private TextPaint mCharPaint;
private TextPaint mDrawerPaint = null;
private Bitmap inputBitmap;
private Rect charMxWidth = null ;
private String text = "a1b2c3d4e5f6h7j8k9l0";
private float textBaseline;
private BitmapShader bitmapShader;
public WordBitmapView(Context context) {
this(context, null);
}
public WordBitmapView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public WordBitmapView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
mDM = getResources().getDisplayMetrics();
initPaint();
}

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);

int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);

if (widthMode != MeasureSpec.EXACTLY) {
widthSize = mDM.widthPixels / 2;
}

int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);

if (heightMode != MeasureSpec.EXACTLY) {
heightSize = widthSize / 2;
}
setMeasuredDimension(widthSize, heightSize);

textBaseline = getTextPaintBaseline(mDrawerPaint);
}


public float dp2px(float dp) {
return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, mDM);
}

public float sp2px(float dp) {
return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, dp, mDM);
}

@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
recyclePool.clear();
bitmapPool.clear();
}

Matrix matrix = new Matrix();
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
int width = getWidth();
int height = getHeight();
if (width <= 1 || height <= 1) {
return;
}
BitmapItem bitmapItem = bitmapPool.linkedBlockingQueue.poll();
if (bitmapItem == null || inputBitmap == null) {
return;
}
if(!bitmapItem.isUsed){
return;
}
canvas.drawBitmap(bitmapItem.bitmap,matrix,mDrawerPaint);
bitmapItem.isUsed = false;
try {
recyclePool.linkedBlockingQueue.offer(bitmapItem,16,TimeUnit.MILLISECONDS);
} catch (InterruptedException e) {
e.printStackTrace();
}
}

public static float getTextPaintBaseline(Paint p) {
Paint.FontMetrics fontMetrics = p.getFontMetrics();
return (fontMetrics.descent - fontMetrics.ascent) / 2 - fontMetrics.descent;
}

private Rect computeMaxCharWidth(TextPaint drawerPaint, String text) {
if (TextUtils.isEmpty(text)) {
return null;
}
Rect result = new Rect(); // 文字所在区域的矩形
Rect md = new Rect();
for (int i = 0; i < text.length(); i++) {
String s = text.charAt(i) + "";
if(TextUtils.isEmpty(s)) {
continue;
}
drawerPaint.getTextBounds(s, 0, 1, md);
if (md.width() > result.width()) {
result.right = md.width();
}
if (md.height() > result.height()) {
result.bottom = md.height();
}
}
return result;
}

private BitmapPool bitmapPool = new BitmapPool();
private BitmapPool recyclePool = new BitmapPool();

static class BitmapPool {
int width;
int height;
private LinkedBlockingQueue<BitmapItem> linkedBlockingQueue = new LinkedBlockingQueue<>(5);
public void clear(){
Iterator<BitmapItem> iterator = linkedBlockingQueue.iterator();
do{
if(!iterator.hasNext()) break;
BitmapItem next = iterator.next();
if(!next.bitmap.isRecycled()) {
next.bitmap.recycle();
}
iterator.remove();
}while (true);
}

public int getWidth() {
return width;
}

public int getHeight() {
return height;
}

public void setHeight(int height) {
this.height = height;
}

public void setWidth(int width) {
this.width = width;
}
}

class BitmapItem{
Bitmap bitmap;
boolean isUsed = false;
}


//视频图片入队
public void queueInputBitmap(Bitmap inputBitmap) {
this.inputBitmap = inputBitmap;

if(charMxWidth == null){
charMxWidth = computeMaxCharWidth(mDrawerPaint,text);
}
if(charMxWidth == null || charMxWidth.width() == 0){
return;
}

if(this.bitmapPool != null && this.inputBitmap != null){
if(this.bitmapPool.getWidth() != this.inputBitmap.getWidth()){
bitmapPool.clear();
recyclePool.clear();
}else if(this.bitmapPool.getHeight() != this.inputBitmap.getHeight()){
bitmapPool.clear();
recyclePool.clear();
}
}
bitmapPool.setWidth(inputBitmap.getWidth());
bitmapPool.setHeight(inputBitmap.getHeight());
recyclePool.setWidth(inputBitmap.getWidth());
recyclePool.setHeight(inputBitmap.getHeight());

BitmapItem boardBitmap = recyclePool.linkedBlockingQueue.poll();
if (boardBitmap == null && inputBitmap != null) {
boardBitmap = new BitmapItem();
boardBitmap.bitmap = Bitmap.createBitmap(inputBitmap.getWidth(), inputBitmap.getHeight(), Bitmap.Config.ARGB_8888);
}
boardBitmap.isUsed = true;
int bitmapWidth = inputBitmap.getWidth();
int bitmapHeight = inputBitmap.getHeight();
int unitWidth = (int) (charMxWidth.width() *1.5);
int unitHeight = charMxWidth.height() + 2;
int centerY = charMxWidth.centerY();
float hLineCharNum = bitmapWidth * 1F / unitWidth;
float vLineCharNum = bitmapHeight * 1F / unitHeight;


this.bitmapShader = new BitmapShader(inputBitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP);
this.mCharPaint.setShader(bitmapShader);

boardBitmap.bitmap.eraseColor(Color.TRANSPARENT);

Canvas drawCanvas = new Canvas(boardBitmap.bitmap);
int k = (int) (Math.random() * text.length());
for (int i = 0; i < vLineCharNum; i++) {
for (int j = 0; j < hLineCharNum; j++) {
int length = text.length();
int x = unitWidth * j;
int y = centerY + i * unitHeight;
String c = text.charAt(k % length) + "";
drawCanvas.drawText(c, x, y + textBaseline, mCharPaint);
k++;
}
}
try {
bitmapPool.linkedBlockingQueue.offer(boardBitmap,16, TimeUnit.MILLISECONDS);
} catch (InterruptedException e) {
e.printStackTrace();
}
postInvalidate();

}
private void initPaint() {
// 实例化画笔并打开抗锯齿
mCharPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG);
mCharPaint.setAntiAlias(true);
mCharPaint.setStyle(Paint.Style.FILL);
mCharPaint.setStrokeCap(Paint.Cap.ROUND);
mDrawerPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG);
mDrawerPaint.setAntiAlias(true);
mDrawerPaint.setStyle(Paint.Style.FILL);
mDrawerPaint.setStrokeCap(Paint.Cap.ROUND);
}


}

五、总结


Android中Shader是非常重要的工具,我们无需单独修改像素的情况下就能实现快速渲染字符,得意与Shader出色的渲染能力。另外由于时间原因,这里对字符的绘制并没有做到很精确,仅仅选了一些比较中规中列的排列,后续再继续完善吧。


作者:时光少年
来源:juejin.cn/post/7304531203772514339
收起阅读 »

Android 使用Xfermode合成TabBarView

一、前言 PorterDuffXfermode  作为Android重要的合成组件,可以通过区域叠加方式进行裁剪和合成,起作用和Path.Op 类似,对于音视频开发中使用蒙版抠图和裁剪的需求,这种情况一般生成不了Path时,因此只能使用PorterDuffXf...
继续阅读 »

一、前言


PorterDuffXfermode  作为Android重要的合成组件,可以通过区域叠加方式进行裁剪和合成,起作用和Path.Op 类似,对于音视频开发中使用蒙版抠图和裁剪的需求,这种情况一般生成不了Path时,因此只能使用PorterDuffXfermode进行合成,当然Paint设置Shader也具备一定的能力,但是还是无法做到很多效果。


二、案例



这个案例使用了Bitmap合成,在边缘区域对色彩裁剪,从而实现了圆觉裁剪。


模版



//裁剪区域



技术上没有太多难点,但要注意的是Xfermode是2个Bitmap之间只使用,不像Shader那样可以单独使用。


Canvas resultCanvas = new Canvas(resultBitmap);
resultCanvas.drawBitmap(dstBitmap, paddingLeft + point.x, paddingTop, mSolidPaint);
mSolidPaint.setXfermode(mPorterDuffXfermode);
resultCanvas.drawBitmap(srcRoundBitmap, 0, 0, mSolidPaint);
canvas.drawBitmap(resultBitmap, 0, 0, null);

另外一点就是速度计算,利用了没有时间的逼近减速公式,当然你可以使用动画去实现


 float speed = Math.abs((mTargetZone - 1) * (contentWidth / mDivideNumber) - point.x) / mSpeed;

下面是速度控制逻辑


    @Override
public void run() {
//计算速度,先按照最大速度5变化,如果小于5,则表示该减速停靠
float speed = Math.abs((mTargetZone - 1) * (contentWidth / mDivideNumber) - point.x) / mSpeed;
speed = (float) Math.max(1f, speed);
float vPos = (mTargetZone - 1) * (contentWidth / mDivideNumber);
if (point.x < vPos) {
point.x += speed;
if (point.x > vPos) {
point.x = vPos;
}
} else {
point.x -= speed;
if (point.x < vPos) {
point.x = vPos;
}
}
if (point.x == vPos) {
isSliding = false;
} else {
isSliding = true;
postDelayed(this, 20);
}
postInvalidate();
}

全部逻辑


public class TabBarView extends View implements Runnable {
//画笔
private Paint mSolidPaint;
//中间竖线与边框间隙
private int gapPadding = 0;
//平分量
private int mDivideNumber = 1;
//边框大小
private final float mBorderSize = 1.5f;
//避免重复绘制Bitmap,短暂保存底色bitmap
private Bitmap srcRoundBitmap;
//图片混合模式
private PorterDuffXfermode mPorterDuffXfermode;
private PointF point;
//内容区域大小
private float contentWidth;
private float contentHeight;
//滑动到的目标区域
private int mTargetZone;
//滑动速度
private float mSpeed;
//主调颜色
private int primaryColor;
//默认字体颜色
private int textColor;
//焦点字体颜色
private int selectedTextColor;
//item
private CharSequence[] mStringItems;
//字体大小
private float textSize;
//是否处于滑动
private boolean isSliding;

Bitmap dstBitmap;
Bitmap resultBitmap;

private RectF rectBound = new RectF();

public TabBarView(Context context) {
super(context);
init(null, 0);
}

public TabBarView(Context context, AttributeSet attrs) {
super(context, attrs);
init(attrs, 0);
}

public TabBarView(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
init(attrs, defStyle);
}

private void init(AttributeSet attrs, int defStyle) {
// Load attributes
final TypedArray a = getContext().obtainStyledAttributes(
attrs, R.styleable.TabBarView, defStyle, 0);

//参数值越大,速度越大,速度指数越小
mSpeed = Math.max(10 - Math.max(a.getInt(R.styleable.TabBarView_speed, 6), 6), 1);

mStringItems = a.getTextArray(R.styleable.TabBarView_tabEntries);
primaryColor = a.getColor(R.styleable.TabBarView_primaryColor, 0xFF4081);
textColor = a.getColor(R.styleable.TabBarView_textColor, primaryColor);
selectedTextColor = a.getColor(R.styleable.TabBarView_selectedTextColor, 0xffffff);
textSize = a.getDimensionPixelSize(R.styleable.TabBarView_textSize, 30);

if (mStringItems != null && mStringItems.length > 0) {
mDivideNumber = mStringItems.length;
}

a.recycle();

mSolidPaint = new Paint();
mSolidPaint.setFlags(Paint.ANTI_ALIAS_FLAG);
mPorterDuffXfermode = new PorterDuffXfermode(PorterDuff.Mode.SRC_IN);
point = new PointF(0, 0);
mTargetZone = 1;

invalidateTextPaintAndMeasurements();

}

private void invalidateTextPaintAndMeasurements() {
mSolidPaint.setColor(primaryColor);
mSolidPaint.setStrokeWidth(mBorderSize);
mSolidPaint.setTextSize(textSize);
mSolidPaint.setStyle(Paint.Style.STROKE);
mSolidPaint.setXfermode(null);
}



@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
recycleBitmap();
}

@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
int paddingLeft = getPaddingLeft();
int paddingTop = getPaddingTop();
int paddingRight = getPaddingRight();
int paddingBottom = getPaddingBottom();

contentWidth = getWidth() - paddingLeft - paddingRight;
contentHeight = getHeight() - paddingTop - paddingBottom;
float minContentSize = Math.min(contentWidth, contentHeight);

rectBound.set(paddingLeft, paddingTop, paddingLeft + contentWidth, paddingTop + contentHeight);
canvas.drawRoundRect(rectBound, minContentSize / 2F, minContentSize / 2F, mSolidPaint);
for (int i = 1; i < mDivideNumber; i++) {
canvas.drawLine(paddingLeft + 1F * contentWidth * i / mDivideNumber, paddingTop + gapPadding, paddingLeft + contentWidth * i / mDivideNumber, paddingTop + contentHeight - gapPadding, mSolidPaint);

}

if (srcRoundBitmap == null) {
srcRoundBitmap = Bitmap.createBitmap(getWidth(), getHeight(), Bitmap.Config.ARGB_8888);
Canvas srcCanvas = new Canvas(srcRoundBitmap);
mSolidPaint.setStyle(Paint.Style.FILL_AND_STROKE);
srcCanvas.drawRoundRect(rectBound, minContentSize / 2F, minContentSize / 2F, mSolidPaint);
}

if(dstBitmap == null) {
dstBitmap = Bitmap.createBitmap((int) (contentWidth / mDivideNumber), (int) contentHeight, Bitmap.Config.ARGB_8888);
}
dstBitmap.eraseColor(Color.TRANSPARENT);
Canvas dstCanvas = new Canvas(dstBitmap);
dstCanvas.drawColor(Color.YELLOW);

if(resultBitmap == null) {
resultBitmap = Bitmap.createBitmap(getWidth(), getHeight(), Bitmap.Config.ARGB_8888);
}
resultBitmap.eraseColor(Color.TRANSPARENT);
Canvas resultCanvas = new Canvas(resultBitmap);
resultCanvas.drawBitmap(dstBitmap, paddingLeft + point.x, paddingTop, mSolidPaint);
mSolidPaint.setXfermode(mPorterDuffXfermode);
resultCanvas.drawBitmap(srcRoundBitmap, 0, 0, mSolidPaint);
canvas.drawBitmap(resultBitmap, 0, 0, null);

invalidateTextPaintAndMeasurements();

if (mStringItems != null) {

for (int i = 0; i < mStringItems.length; i++) {
String itemChar = mStringItems[i].toString();
float textX = (contentWidth / mDivideNumber) * i / 2 + paddingLeft + (contentWidth * (i + 1) / mDivideNumber - mSolidPaint.measureText(itemChar)) / 2;
float textY = paddingTop + (contentHeight - mSolidPaint.getFontMetrics().bottom - mSolidPaint.getFontMetrics().ascent) / 2;
int color = mSolidPaint.getColor();
mSolidPaint.setStyle(Paint.Style.FILL);
if ((i + 1) == mTargetZone && !isSliding) {
mSolidPaint.setColor(selectedTextColor);
} else {
mSolidPaint.setColor(textColor);
}
canvas.drawText(itemChar, textX, textY, mSolidPaint);
mSolidPaint.setColor(color);
mSolidPaint.setStyle(Paint.Style.STROKE);
}
}
}


@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
if (checkLocationIsOk(event) && !isSliding) {
return true;
}
break;
case MotionEvent.ACTION_MOVE:
return checkLocationIsOk(event);
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_OUTSIDE:
if (checkLocationIsOk(event) && !isSliding) {
float x = event.getX() - getPaddingLeft();
mTargetZone = (int) (x / (contentWidth / mDivideNumber)) + 1;
//规避区域超出范围
mTargetZone = Math.min(mTargetZone, mDivideNumber);
postToMove();
}
break;
}
return super.onTouchEvent(event);
}

private void postToMove() {
if (point.x == (mTargetZone - 1) * (contentWidth / mDivideNumber)) {
return;
}
postDelayed(this, 20);
}

/**
* 检测位置是否可用
*
* @param event
* @return
*/
private boolean checkLocationIsOk(MotionEvent event) {
float x = event.getX();
float y = event.getY();
if (x - getPaddingLeft() > 0 && (getPaddingLeft() + contentWidth - x) > 0 && y - getPaddingTop() > 0 && (getPaddingTop() + contentHeight - y) > 0) {
return true;
}
return false;
}

private void recycleBitmap(Bitmap bmp) {
if (bmp != null && !bmp.isRecycled()) {
bmp.recycle();
}
}

@Override
protected void onDetachedFromWindow() {
super.onDetachedFromWindow();
getHandler().removeCallbacksAndMessages(null);
}

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);

if (widthMode != MeasureSpec.EXACTLY) {
widthSize = getResources().getDisplayMetrics().widthPixels / 2;
}

int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);

if (heightMode != MeasureSpec.EXACTLY) {
heightSize = widthSize / 2;
}
setMeasuredDimension(widthSize, heightSize);
}

@Override
public void run() {
//计算速度,先按照最大速度5变化,如果小于5,则表示该减速停靠
float speed = Math.abs((mTargetZone - 1) * (contentWidth / mDivideNumber) - point.x) / mSpeed;
speed = (float) Math.max(1f, speed);
float vPos = (mTargetZone - 1) * (contentWidth / mDivideNumber);
if (point.x < vPos) {
point.x += speed;
if (point.x > vPos) {
point.x = vPos;
}
} else {
point.x -= speed;
if (point.x < vPos) {
point.x = vPos;
}
}
if (point.x == vPos) {
isSliding = false;
} else {
isSliding = true;
postDelayed(this, 20);
}
postInvalidate();
}

public void setSelectedTab(int tabIndex) {
mTargetZone = Math.max(Math.min(mDivideNumber, tabIndex + 1), 1);
recycleBitmap();
postToMove();
}

public void setTabItems(CharSequence[] mStringItems) {
this.mStringItems = mStringItems;
recycleBitmap();
invalidate();
}

private void recycleBitmap() {
if(dstBitmap != null && !dstBitmap.isRecycled()){
dstBitmap.recycle();
}
if(resultBitmap != null && !resultBitmap.isRecycled()){
resultBitmap.recycle();
}
resultBitmap = null;
dstBitmap = null;
}
}

我们需要自定义一些属性


<declare-styleable name="TabBarView">

<attr name="speed" format="integer" />
<attr name="tabEntries" format="reference"/>
<attr name="primaryColor" format="color|reference"/>
<attr name="textSize" format="dimension"/>
<attr name="textColor" format="color|reference"/>
<attr name="selectedTextColor" format="color|reference"/>

</declare-styleable>

还有部分需要引用的 string-array


<string-array name="tabEntries_array">
<item>A</item>
<item>B</item>
<item>C</item>
<item>D</item>
</string-array>

然后是布局文件(片段)


<com.android.jym.widgets.TabBarView
android:layout_width="match_parent"
android:layout_height="40dp"
android:background="@android:color/transparent"
android:padding="10dp"
app:speed="4"
app:tabEntries="@array/tabEntries_array"
app:primaryColor="@color/colorAccent"
app:textColor="@color/colorPrimaryDark"
app:selectedTextColor="@android:color/white"
/>

三、总结


使用Xfermode + 蒙版进行抠图,是Android中重要的工具,本篇作为技术储备,后续会通过这种方式实现一些新的功能。


作者:时光少年
来源:juejin.cn/post/7306447610096975887
收起阅读 »

Android 侧滑布局逻辑解析

一、前言 测滑布局应用非常广泛,HorizontalScrollView 本身实现的滑动效果让实现变得很简单,实际上有很多种方式实现,有很多现有的方法可以直接调用。 二、逻辑实现 在Android 中,滑动分为2类,一类以ScrollView为代表布局,通过...
继续阅读 »

一、前言


测滑布局应用非常广泛,HorizontalScrollView 本身实现的滑动效果让实现变得很简单,实际上有很多种方式实现,有很多现有的方法可以直接调用。



二、逻辑实现


在Android 中,滑动分为2类,一类以ScrollView为代表布局,通过子View实现布局超出视区(ViewPort)之后,进行Scroll操作的,另一类事以修改Offset为代表的Recycler类,前者实时保持最大高度。形像的理解为前者是“齿轮传动派”,后者是“滑板派”,两派都有过出分头的时候,即便是个派弟子如NestedScrollView和RecyclerView争的你死我活,不过总体上齿轮传动派占在弱势地位。不过android的改版,让他们做了很多和平相处的事情,不如NestedScrolling机制的支持,让他们想传动就传动,想滑翔就滑翔。


齿轮传动派看家本领



  • scrollX,ScrollY,scrollTo等方法

  • 一个长得很长的独生子


滑板派的看家本领



  • offsetXXX方法

  • 被魔改的ScrollXXX

  • 一群会滑板的孩子

  • layout 方法也是他们的榜首


前者为了实现的简单的滑动,后者空间可以无限大,期间还可自由换孩子。


三、代码实现


有很多现成的example都是基于齿轮传动派的,但是如果使用,你得记住,齿轮传动派会的滑板派一定会,反过来就不一样了。


这里我们使用layout方法实现,核心代码


        View leftView = mWrapperView.getChildAt(0);
leftView.layout(l,t,l+leftView.getWidth(),t+leftView.getHeight());
maskAlpha = leftView.getLeft()*1.0f/leftView.getWidth();

之所以使用layout的原因是很多人都不记得ListView可以使用该方法实现吸顶效果,而RecyclerView因为为了兼容更多类型,导致他使用这个很难实现吸顶,但是没关系,child.layout和child.measure方法可以在类的任何地方调用,这个是必须要掌握的。


3.1 布局初始化


 @Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
super.onLayout(changed, l, t, r, b);
if(isFirstLayout && getRealChildCount()==2){
View leftView = mWrapperView.getChildAt(0);
scrollTo(leftView.getWidth(),0); //初始化状态让右侧View展示处理
}
isFirstLayout = true;
}

3.2 相对运动


滑动时让左侧View保持同样的滑动距离和方向


   View leftView = mWrapperView.getChildAt(0);
leftView.layout(l,t,l+leftView.getWidth(),t+leftView.getHeight());
maskAlpha = leftView.getLeft()*1.0f/leftView.getWidth();

3.3 全部代码


public class SlidingFoldLayout extends HorizontalScrollView {


private TextPaint mPaint = null;
private LinearLayout mWrapperView = null;
private boolean isFirstLayout = true;
private float maskAlpha = 1.0f;

public SlidingFoldLayout(Context context) {
this(context, null);
}

public SlidingFoldLayout(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}

public SlidingFoldLayout(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
LinearLayout linearLayout = getWrapperLayout(context);
setOverScrollMode(View.OVER_SCROLL_NEVER);
setWillNotDraw(false);
mPaint = createPaint();
addViewInLayout(linearLayout, 0, linearLayout.getLayoutParams(), true);
mWrapperView = linearLayout;
}


public LinearLayout getWrapperLayout(Context context) {
LinearLayout linearLayout = new LinearLayout(context);
HorizontalScrollView.LayoutParams lp = generateDefaultLayoutParams();
lp.width = LayoutParams.WRAP_CONTENT;
linearLayout.setLayoutParams(lp);
linearLayout.setOrientation(LinearLayout.HORIZONTAL);
linearLayout.setPadding(0, 0, 0, 0);
return linearLayout;
}

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int childCount = mWrapperView.getChildCount();
if (childCount == 0) {
return;
}
int leftMenuWidth = mWrapperView.getChildAt(0).getMeasuredWidth();
ViewGr0up.LayoutParams lp = (ViewGr0up.LayoutParams) getLayoutParams();
int width = getMeasuredWidth() - getPaddingRight() - getPaddingLeft();
if (lp instanceof ViewGr0up.MarginLayoutParams) {
width = width - ((MarginLayoutParams) lp).leftMargin - ((MarginLayoutParams) lp).rightMargin;
}
if (width <= leftMenuWidth) {
mWrapperView.getChildAt(0).getLayoutParams().width = (int) (width - dp2px(50));
measureChild(mWrapperView, widthMeasureSpec, heightMeasureSpec);
}
if (childCount != 2) {
return;
}
View rightView = mWrapperView.getChildAt(1);
int rightMenuWidth = rightView.getMeasuredWidth();
if (width != rightMenuWidth) {
rightView.getLayoutParams().width = width;
measureChild(mWrapperView, widthMeasureSpec, heightMeasureSpec);
rightView.bringToFront();
}
}

private float dp2px(int dp) {
return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, getResources().getDisplayMetrics());
}

@Override
public void addView(View child) {

int childCount = mWrapperView.getChildCount();
if (childCount > 2) {
throw new IllegalStateException("SlidingFoldLayout should host only two child");
}
ViewGr0up.LayoutParams lp = child.getLayoutParams();
if (lp != null && lp instanceof LinearLayout.LayoutParams) {
lp = new LinearLayout.LayoutParams(lp);
child.setLayoutParams(lp);
}

mWrapperView.addView(child);

}



public int getRealChildCount() {
if (mWrapperView == null) {
return 0;
}
return mWrapperView.getChildCount();
}



@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
super.onLayout(changed, l, t, r, b);
if(isFirstLayout && getRealChildCount()==2){
View leftView = mWrapperView.getChildAt(0);
scrollTo(leftView.getWidth(),0);
}
isFirstLayout = true;
}

@Override
protected void onScrollChanged(int l, int t, int oldl, int oldt) {
super.onScrollChanged(l, t, oldl, oldt);
int realCount = getRealChildCount();
if(realCount!=2) return;
View leftView = mWrapperView.getChildAt(0);
leftView.layout(l,t,l+leftView.getWidth(),t+leftView.getHeight());
maskAlpha = leftView.getLeft()*1.0f/leftView.getWidth();
}


@Override
public boolean onTouchEvent(MotionEvent ev) {
int action = ev.getAction();

switch (action){
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_OUTSIDE:
super.onTouchEvent(ev);
scrollToTraget();
break;
}

return super.onTouchEvent(ev);
}

private void scrollToTraget() {

int count = getRealChildCount();
if(count!=2) return;
int with = getWidth();
if(with==0) return;

View leftView = mWrapperView.getChildAt(0);

float x = leftView.getLeft()*1.0f/leftView.getWidth();
if(x > 0.5f){
smoothScrollTo(leftView.getWidth(),0);
}else{
smoothScrollTo(0,0);
}

}

@Override
public void addView(View child, int index) {

int childCount = mWrapperView.getChildCount();
if (childCount > 2) {
throw new IllegalStateException("SlidingFoldLayout should host only two child");
}
ViewGr0up.LayoutParams lp = child.getLayoutParams();
if (lp != null && lp instanceof LinearLayout.LayoutParams) {
lp = new LinearLayout.LayoutParams(lp);
child.setLayoutParams(lp);
}

mWrapperView.addView(child, index);
}

@Override
public void addView(View child, ViewGr0up.LayoutParams params) {
int childCount = mWrapperView.getChildCount();
if (childCount > 2) {
throw new IllegalStateException("SlidingFoldLayout should host only two child");
}
LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(params);
child.setLayoutParams(lp);
mWrapperView.addView(child, lp);

}

@Override
public void addView(View child, int index, ViewGr0up.LayoutParams params) {
int childCount = mWrapperView.getChildCount();
if (childCount > 2) {
throw new IllegalStateException("SlidingFoldLayout should host only two child");
}

LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(params);
child.setLayoutParams(lp);
mWrapperView.addView(child, index);
}

private TextPaint createPaint() {
// 实例化画笔并打开抗锯齿
TextPaint paint = new TextPaint(Paint.ANTI_ALIAS_FLAG);
paint.setAntiAlias(true);
return paint;
}
RectF rectF = new RectF();
@Override
protected void dispatchDraw(Canvas canvas) {
super.dispatchDraw(canvas);

int realCount = getRealChildCount();
if(realCount!=2) return;
View leftView = mWrapperView.getChildAt(0);
View rightView = mWrapperView.getChildAt(1);


rectF.top = leftView.getTop();
rectF.bottom = leftView.getBottom();
rectF.left = leftView.getLeft();
rectF.right = rightView.getLeft();
int alpha = (int) (153*maskAlpha);
mPaint.setColor(argb(alpha,0x00,0x00,0x00));
int saveId = canvas.save();
canvas.drawRect(rectF,mPaint);
canvas.restoreToCount(saveId);
}

public static int argb(
int alpha,
int red,
int green,
int blue) {
return (alpha << 24) | (red << 16) | (green << 8) | blue;
}

}

三、使用方式


使用方式简单清晰,没有看到ScrollView的独生子,原因是我们把他写到了类里面


  <com.cn.scrolllayout.view.SlidingFoldLayout
android:layout_width="match_parent"
android:layout_height="match_parent">

<LinearLayout
android:layout_width="300dp"
android:layout_height="match_parent"
android:gravity="center"
>
<ImageView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scaleType="centerCrop"
android:src="@mipmap/img_sample_text"
/>
</LinearLayout>
<LinearLayout
android:layout_width="500dp"
android:layout_height="match_parent"
android:background="@color/colorAccent"
>
<ImageView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scaleType="fitCenter"
android:src="@mipmap/img_sample_panda"
/>
</LinearLayout>
</com.cn.scrolllayout.view.SlidingFoldLayout>

四、总结


掌握ScrollX和OffsetX两种的滑动很重要,但是不能忘记layout的作用,本质上他属于一种OffsetX上层的封装。


作者:时光少年
来源:juejin.cn/post/7307989656288034851
收起阅读 »

Android 14 适配的那些事情

大家好,我叫 Jack Darren,目前主要负责国内游戏发行 Android SDK 开发简介距离 Android 14 发布已经有一段时间了,趁着这次机会,了解和熟悉了 Android 14 更新的内容,现在来和大家分享一下,大家喜欢的话可以点个赞多多支持...
继续阅读 »
  • 大家好,我叫 Jack Darren,目前主要负责国内游戏发行 Android SDK 开发

简介

  • 距离 Android 14 发布已经有一段时间了,趁着这次机会,了解和熟悉了 Android 14 更新的内容,现在来和大家分享一下,大家喜欢的话可以点个赞多多支持一下,文章的内容按照适配内容的重要程度进行排序。

targetSdk 版本要求

  • 在 Android 14 上面,新增了一个要求,要求新安装的应用的 targetSdkVersion 需要大于等于 23(即 Android 6.0 及以上),如果小于这个值将无法在 Android 14 的设备上面安装,此时大家心里可能有疑惑了,谷歌为什么要求那么做呢?我们来看看谷歌的原话是什么
恶意软件通常会以较旧的 API 级别为目标平台,
以绕过在较新版本 Android 中引入的安全和隐私保护机制。
例如,有些恶意软件应用使用 targetSdkVersion 22
以避免受到 Android 6.0 Marshmallow(API 级别 23)在 2015 年引入的运行时权限模型的约束。
这项 Android 14 变更使恶意软件更难以规避安全和隐私权方面的改进限制。
  • 从上面这段话不难看出来谷歌的用意,其实为了保障用户的手机安全,如果用户安装应用的 targetSdkVersion 版本过低,有一些恶意软件会利用高系统会兼容旧软件这一特性(漏洞),故意绕过系统的安全检查,从而会导致 Android 高版本上面一些安全特性无法生效,没有了系统的管束,这些恶意软件可能就会肆意乱来。
  • 另外你如果想在 Android 14 系统上面,仍然要安装 targetSdkVersion 小于 23 的应用,可以通过以下 adb 命令来安装 apk,这样就能绕过系统的安装限制。
adb install --bypass-low-target-sdk-block xxx.apk

前台服务类型要求

  • 如果你的应用 targetSdkVersion 升级到了 34(即 Android 14),并且在 Service 中调用了 startForeground 方法,那么就需要进行适配了,否则系统会抛出 MissingForegroundServiceTypeException 异常,这是因为在 Android 14 上面,要求应用在开启前台服务的时候,需要注明这个前台服务的用途,谷歌给我们列举了以下几种用途:
用途说明清单文件权限要求运行时要求
摄像头继续在后台访问相机,例如支持多任务的视频聊天应用FOREGROUND_SERVICE_CAMERA请求 CAMERA 运行时权限
连接的设备与需要蓝牙、NFC、IR、USB 或网络连接的外部设备进行互动FOREGROUND_SERVICE_CONNECTED_DEVICE必须至少满足以下其中一个条件:

在清单中至少声明以下其中一项权限:

CHANGE_NETWORK_STATE
CHANGE_WIFI_STATE
CHANGE_WIFI_MULTICAST_STATE
NFC
TRANSMIT_IR
至少请求以下其中一项运行时权限:

BLUETOOTH_CONNECT
BLUETOOTH_ADVERTISE
BLUETOOTH_SCAN
UWB_RANGING
调用 UsbManager.requestPermission()

数据同步数据传输操作,例如:

数据上传或下载
备份和恢复操作
导入或导出操作
获取数据
本地文件处理
通过网络在设备和云端之间传输数据
FOREGROUND_SERVICE_DATA_SYNC
健康为健身类别的应用(例如锻炼追踪器)提供支持的所有长时间运行的用例FOREGROUND_SERVICE_HEALTH必须至少满足以下其中一个条件:

在清单中声明 HIGH_SAMPLING_RATE_SENSORS 权限。

至少请求以下其中一项运行时权限:

BODY_SENSORS
ACTIVITY_RECOGNITION
位置需要位置信息使用权的长时间运行的用例,
例如导航和位置信息分享
FOREGROUND_SERVICE_LOCATION至少请求以下其中一项运行时权限:

ACCESS_COARSE_LOCATION
ACCESS_FINE_LOCATION
媒体在后台继续播放音频或视频。
在 Android TV 上支持数字视频录制 (DVR) 功能。
FOREGROUND_SERVICE_MEDIA_PLAYBACK
媒体投影使用 MediaProjection API 将内容投影到非主要显示屏或外部设备。这些内容不必全都为媒体内容。不包括 Cast SDKFOREGROUND_SERVICE_MEDIA_PROJECTION调用 createScreenCaptureIntent() 方法。 无
麦克风在后台继续捕获麦克风内容,例如录音器或通信应用FOREGROUND_SERVICE_MICROPHONE请求 RECORD_AUDIO 运行时权限
打电话使用 ConnectionService API 继续当前通话FOREGROUND_SERVICE_PHONE_CALL在清单文件中声明 MANAGE_OWN_CALLS 权限。
消息服务将短信从一台设备转移到另一台设备。在用户切换设备时,帮助确保用户消息任务的连续性FOREGROUND_SERVICE_REMOTE_MESSAGING
短期服务快速完成不可中断或推迟的关键工作。

这种类型有一些独特的特征:

只能持续运行一小段时间(大约 3 分钟)。
不支持粘性前台服务。
无法启动其他前台服务。
不需要类型专用权限,不过它仍需要 FOREGROUND_SERVICE 权限。
正在运行的前台服务不能更改为 shortService 类型或从该类型更改为其他类型。
特殊用途涵盖其他前台服务类型未涵盖的所有有效前台服务用例。

除了声明 FOREGROUND_SERVICE_TYPE_SPECIAL_USE 前台服务类型之外,开发者还应在清单中声明用例。为此,他们会在 `` 元素内指定  元素。当您在 Google Play 管理中心内提交应用时,我们会审核这些值和相应的用例。
FOREGROUND_SERVICE_SPECIAL_USE
系统豁免为系统应用和特定系统集成预留,
使其能继续使用前台服务。

如需使用此类型,应用必须至少满足以下条件之一:

设备处于演示模式状态
应用是设备所有者
应用是性能分析器所有者
属于具有 ROLE_EMERGENCY 角色的安全应用
属于设备管理应用
否则,声明此类型会导致系统抛出 ForegroundServiceTypeNotAllowedException
FOREGROUND_SERVICE_SYSTEM_EXEMPTED
  • 介绍完这几种前台服务类型,接下来介绍如何适配它,适配前台服务类型的特性方式具体有两种方式,一种是注册清单属性,另外一种是代码动态注册
<service
android:name=".XxxService"
android:foregroundServiceType="dataSync"
android:exported="false">

service>
startForeground(xxx, xxx, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC);
  • 另外附上前台服务类型对应的适配属性
用途清单文件属性值Java 常量值
摄像头cameraServiceInfo.FOREGROUND_SERVICE_TYPE_CAMERA
连接的设备connectedDeviceServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE
数据同步dataSyncServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
健康healthServiceInfo.FOREGROUND_SERVICE_TYPE_HEALTH
位置locationServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION
媒体mediaPlaybackServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK
媒体投影mediaProjectionServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION
麦克风microphoneServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
打电话phoneCallServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL
消息服务remoteMessagingServiceInfo.FOREGROUND_SERVICE_TYPE_REMOTE_MESSAGING
短期服务shortServiceServiceInfo.FOREGROUND_SERVICE_TYPE_SHORT_SERVICE
特殊用途specialUseServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
系统豁免systemExemptedServiceInfo.FOREGROUND_SERVICE_TYPE_SYSTEM_EXEMPTED

图片和视频的部分访问权限

  • 谷歌在 API 33(Android 13)上面引入了 READ_MEDIA_IMAGES 和 READ_MEDIA_VIDEO 这两个权限,目前针对这两个权限在 Android 14 上面有新的变动,具体的变动点就是新增了 READ_MEDIA_VISUAL_USER_SELECTED 权限,那么这个权限的作用是什么呢?我们都知道 READ_MEDIA_IMAGES 和 READ_MEDIA_VIDEO 是申请图片和视频权限的,但是这样会有一个问题,当第三方应用申请到权限后,就拥有了手机相册中所有照片和视频的访问权限,这是十分危险的,也是非常不可控的,因为用户也无法知道第三方应用会干什么,所以谷歌在 API 34(Android 14)引入了这个权限,这样用户拥有了更多的选择,可以将相册中所有的图片和视频授予给第三方应用,也可以将部分的图片和视频给第三方应用。
  • 讲完了这个特性的来龙去脉,那么接下来讲讲这个权限如何适配,如果你的应用申请了 READ_MEDIA_IMAGES 或者 READ_MEDIA_VIDEO 权限,并且 targetSdkVersion 大于等于 33(Android 13),那么需要在申请权限时携带上 READ_MEDIA_VISUAL_USER_SELECTED 权限方能正常申请,如果不携带上 READ_MEDIA_VISUAL_USER_SELECTED 权限就申请 READ_MEDIA_IMAGES 或者 READ_MEDIA_VIDEO 权限,会弹出权限询问对话框,但是如果用户是选择全部授予,那么 READ_MEDIA_IMAGES 或者 READ_MEDIA_VIDEO 权限状态是已授予的状态,如果用户是选择部分授予,那么 READ_MEDIA_IMAGES 或者 READ_MEDIA_VIDEO 权限状态是已拒绝的状态,假设此时有携带了 READ_MEDIA_VISUAL_USER_SELECTED 权限的情况下,那么 READ_MEDIA_VISUAL_USER_SELECTED 权限是已授予的状态。
  • 看到这里,脑洞大的同学可能有想法了,那我不申请 READ_MEDIA_IMAGES 或者 READ_MEDIA_VIDEO 权限,我就只申请 READ_MEDIA_VISUAL_USER_SELECTED 权限行不行啊?答案也是不行的,我替大家试验过了,这个权限申请会在不会询问用户的情况下,被系统直接拒绝掉。
  • 另外需要的一点是 READ_MEDIA_VISUAL_USER_SELECTED 属于危险权限,除了在运行时动态申请外,还需要在清单文件中进行注册。

registerReceiver 需要指定导出行为

  • 谷歌在 Android 12 (API 31)新增了四大组件需要指定 android:exported 属性的特性,这次在 Android 13 上面做了一些变动,因为谷歌之前只考虑到静态注册四大组件的情况,但是遗漏了一种情况,BroadcastReceiver 不仅可以静态注册,还可以动态注册,动态注册的广播不需要额外在 AndroidManifest.xml 中再进行静态注册,所以这次谷歌将这个规则漏洞补上了,并且要求开发者在动态注册广播的时候,能够指定 BroadcastReceiver 是否能支持导出,由此来保护应用免受安全漏洞的影响。
  • 到此,大家心中可能有一个疑惑,这里的支持导出是什么意思?有产生什么作用?可以先看一下谷歌官方的原话
为了帮助提高运行时接收器的安全性,Android 13 允许您指定您应用中的特定广播接收器是否应被导出以及是否对设备上的其他应用可见。
如果导出广播接收器,其他应用将可以向您的应用发送不受保护的广播。
此导出配置在以 Android 13 或更高版本为目标平台的应用中可用,有助于防止一个主要的应用漏洞来源。

在以前的 Android 版本中,设备上的任何应用都可以向动态注册的接收器发送不受保护的广播,除非该接收器受签名权限的保护。
  • 谷歌的解释很明了,如果广播支持导出,那么其他应用可以通过发送这个广播触发我们应用的逻辑,这可能会发生程序安全漏洞的问题。
  • 那么该如何适配这一特性呢?谷歌官方提供了一个 registerReceiver(BroadcastReceiver receiver, IntentFilter filter, int flags) API,flags 参数传入 Context.RECEIVER_EXPORTED(支持导出) 或 Context.RECEIVER_NOT_EXPORTED(不支持导出),具体的代码适配代码如下:
String action = "xxxxxx";
IntentFilter filter = new IntentFilter(action);
if (VERSION.SDK_INT >= VERSION_CODES.TIRAMISU) {
context.registerReceiver(new LocaleChangeReceiver(), filter, Context.RECEIVER_EXPORTED);
} else {
context.registerReceiver(new LocaleChangeReceiver(), filter);
}
  • 还有一种情况,不需要指定 flag 参数,就是当要注册的广播 action 隶属系统的 action 时候,这个时候可以不需要指定导出行为。

更安全的动态代码加载

  • 如果我们应用有动态加载代码的需求,并且此时 targetSdk 升级到了 API 34(即 Android 14),那么需要注意一个点,动态加载的文件(Jar、Dex、Apk 格式)需要设置成可读的,具体案例的代码如下:
File jar = new File("xxxx.jar");
try (FileOutputStream os = new FileOutputStream(jar)) {
jar.setReadOnly();
} catch (IOException e) { ... }
PathClassLoader cl = new PathClassLoader(jar, parentClassLoader);
  • 至于谷歌这样做的原因,我觉得十分简单,是为了程序的安全,防止有人抢先在动态加载之前先把动态文件替换了,那么会导致执行到一些恶意的代码,间接导致应用被入侵或者篡改。
  • 另外需要注意的一个点的是,如果你的应用 targetSdk 大于等于 API 34(即 Android 14),如果不去适配这一特性,那么运行在 Android 14 的手机上面系统会抛出异常。

屏幕截图检测

  • Android 14 新增引入了屏幕截图检测的 API,方便开发者更好地检测到用户的操作,具体的使用案例如下:

    1. 在清单文件中静态注册权限
    <uses-permission android:name="android.permission.DETECT_SCREEN_CAPTURE" />
    1. 创建监听器对象
final Activity.ScreenCaptureCallback screenCaptureCallback = new Activity.ScreenCaptureCallback() {

@Override
public void onScreenCaptured() {
// 监听到截图了
}
};
  1. 在合适的时机注册监听
public final class XxxActivity extends Activity {

@Override
protected void onStart() {
super.onStart();
registerScreenCaptureCallback(executor, screenCaptureCallback);
}
}
  1. 在合适的时机取消注册监听
public final class XxxActivity extends Activity {

@Override
protected void onStop() {
super.onStop();
unregisterScreenCaptureCallback(screenCaptureCallback);
}
}
  • 需要注意的是,如果使用的是 adb 进行的截图,并不会触发 onScreenCaptured 监听方法。
  • 如果不想你的应用能被系统截图,可以考虑给当前的 Window 窗口加上 WindowManager.LayoutParams.FLAG_SECURE 标记位。
  • 最后表达一下我对这个 API 看法,这个 API 设计得不是很好,比如应用想知道用户是否截图了,应用可能需要知道的是,截图文件的存放路径,但是 onScreenCaptured 是一个空参函数,也就意味着没有携带任何参数,如果要实现获取截图文件存放路径的需求,可能还需要沿用之前的老方式,即使用 ContentObserver 监听媒体数据库的变化,然后从几个维度(文件时间维度、文件路径维度、图片尺寸维度)判断新增的图片是否为用户的截图,这种实现的方式相对是比较麻烦的,但是也无发现更好的实现方式。
  • 完结,撒花 ✿✿ヽ(°▽°)ノ✿


    作者:37手游移动客户端团队
    来源:juejin.cn/post/7308434314777772042
    收起阅读 »

    Android 换种方式实现ViewPager

    一、可行性分析 ViewPager 是一款相对成熟的 Pager 切换 View,能够实现各种优秀的页面效果,也有不少问题,比如频繁会 requestLayout,另外的话如果是加载到 ListView 或者 RecyclerView 非固定头部,会偶现白屏或...
    继续阅读 »

    一、可行性分析


    ViewPager 是一款相对成熟的 Pager 切换 View,能够实现各种优秀的页面效果,也有不少问题,比如频繁会 requestLayout,另外的话如果是加载到 ListView 或者 RecyclerView 非固定头部,会偶现白屏或者 drawble 状态无法更新,还有就是 fragment 数量无法更新,需要重写 FragmentPagerAdapter 才行。


    使用 RecyclerView 相对 ViewPager 来说,会避免很多问题,比如如果是轮播组件 View 可以复用而且会避免白屏问题,当然今天我们使用 RecyclerView 代替 ViewPager 虽然也没有实现复用,但并不影响和 ViewPager 同样的体验。



    二、代码实现


    具体原理是我们在 RecyclerView.Adapter 的如下两个方法中实现 fragment 的 detach 和 attach,这样可以保证 Fragment 的生命周期得到准确执行。


    onViewAttachedToWindow

    onViewDetachedFromWindow

    FragmentPagerAdapter 源码如下(核心代码),另外需要指明的一点是我们使用 PagerSnapHelper 来辅助页面滑动:


    public abstract class FragmentPagerAdapter extends RecyclerView.Adapter<FragmentViewHolder> {

    private static final String TAG = "FragmentPagerAdapter";

    private final FragmentManager mFragmentManager;

    private Fragment mCurrentPrimaryItem = null;
    private PagerSnapHelper snapHelper;

    private RecyclerView.OnScrollListener onScrollListener = new RecyclerView.OnScrollListener() {
    @Override
    public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
    super.onScrollStateChanged(recyclerView, newState);
    if (newState != RecyclerView.SCROLL_STATE_IDLE) return;
    if (snapHelper == null) return;
    View snapView = snapHelper.findSnapView(recyclerView.getLayoutManager());
    if (snapView == null) return;
    FragmentViewHolder holder = (FragmentViewHolder) recyclerView.getChildViewHolder(snapView);
    setPrimaryItem(holder.getHelper().getFragment());

    }

    @Override
    public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
    super.onScrolled(recyclerView, dx, dy);
    }
    };

    public FragmentPagerAdapter(FragmentManager fm) {
    this.mFragmentManager = fm;

    }

    @Override
    public FragmentViewHolder onCreateViewHolder(ViewGr0up parent, int position) {
    RecyclerView recyclerView = (RecyclerView) parent;

    if (snapHelper == null) {
    snapHelper = new PagerSnapHelper();
    recyclerView.addOnScrollListener(onScrollListener);
    snapHelper.attachToRecyclerView(recyclerView);
    }

    FragmentHelper host = new FragmentHelper(recyclerView, getItemViewType(position));
    return new FragmentViewHolder(host);
    }

    @Override
    public void onBindViewHolder(FragmentViewHolder holder, int position) {
    holder.getHelper().updateFragment();

    }


    public abstract Fragment getFragment(int viewType);

    @Override
    public abstract int getItemViewType(int position);


    public Fragment instantiateItem(FragmentHelper host, int position, int fragmentType) {

    FragmentTransaction transaction = host.beginTransaction(mFragmentManager);

    final long itemId = getItemId(position);

    String name = makeFragmentName(host.getContainerId(), itemId, fragmentType);
    Fragment fragment = mFragmentManager.findFragmentByTag(name);
    if (fragment != null) {
    if (BuildConfig.DEBUG) Log.v(TAG, "Attaching item #" + itemId + ": f=" + fragment);
    transaction.attach(fragment);
    } else {
    fragment = getFragment(fragmentType);
    if (BuildConfig.DEBUG) Log.v(TAG, "Adding item #" + itemId + ": f=" + fragment);
    transaction.add(host.getContainerId(), fragment,
    makeFragmentName(host.getContainerId(), itemId, fragmentType));
    }
    if (fragment != mCurrentPrimaryItem) {
    fragment.setMenuVisibility(false);
    fragment.setUserVisibleHint(false);
    }

    return fragment;
    }


    @Override
    public abstract long getItemId(int position);

    @SuppressWarnings("ReferenceEquality")
    public void setPrimaryItem(Fragment fragment) {
    if (fragment != mCurrentPrimaryItem) {
    if (mCurrentPrimaryItem != null) {
    mCurrentPrimaryItem.setMenuVisibility(false);
    mCurrentPrimaryItem.setUserVisibleHint(false);
    }
    if (fragment != null) {
    fragment.setMenuVisibility(true);
    fragment.setUserVisibleHint(true);
    }
    mCurrentPrimaryItem = fragment;
    }
    }

    private static String makeFragmentName(int viewId, long id, int fragmentType) {
    return "android:recyclerview:fragment:" + viewId + ":" + id + ":" + fragmentType;
    }

    @Override
    public void onViewAttachedToWindow(FragmentViewHolder holder) {
    super.onViewAttachedToWindow(holder);
    FragmentHelper host = holder.getHelper();
    Fragment fragment = instantiateItem(holder.getHelper(), holder.getAdapterPosition(), getItemViewType(holder.getAdapterPosition()));
    host.setFragment(fragment);
    host.finishUpdate();
    if (BuildConfig.DEBUG) {
    Log.d("Fragment", holder.getHelper().getFragment().getTag() + " attach");
    }
    }


    @Override
    public void onViewDetachedFromWindow(FragmentViewHolder holder) {
    super.onViewDetachedFromWindow(holder);
    destroyItem(holder.getHelper(), holder.getAdapterPosition());
    holder.getHelper().finishUpdate();

    if (BuildConfig.DEBUG) {
    Log.d("Fragment", holder.getHelper().getFragment().getTag() + " detach");
    }
    }

    public void destroyItem(FragmentHelper host, int position) {
    FragmentTransaction transaction = host.beginTransaction(mFragmentManager);

    if (BuildConfig.DEBUG) Log.v(TAG, "Detaching item #" + getItemId(position) + ": f=" + host.getFragment()
    + " v=" + ((Fragment) host.getFragment()).getView());
    transaction.detach((Fragment) host.getFragment());
    }

    }

    ViewHolder 源码,本类的主要作用是给 FragmentManager 打桩,其次还有个作用是连接 FragmentHelper(负责 Fragment 的事务)


    public class FragmentViewHolder extends RecyclerView.ViewHolder {

    private FragmentHelper mHelper;

    public FragmentViewHolder(FragmentHelper host) {
    super(host.getFragmentView());
    this.mHelper = host;
    }

    public FragmentHelper getHelper() {
    return mHelper;
    }
    }

    FragmentHelper 源码


    public class FragmentHelper {

    private final int id;
    private final Context context;
    private Fragment fragment;
    private ViewGr0up containerView;
    private FragmentTransaction fragmentTransaction;

    public FragmentHelper(RecyclerView recyclerView, int fragmentType) {
    this.id = recyclerView.getId() + fragmentType + 1;
    // 本id依赖于fragment,因此为防止fragmentManager将RecyclerView视为容器,直接将View加载到RecyclerView中,这种View缺少VewHolder,会出现空指针问题,这里加1
    Activity activity = getRealActivity(recyclerView.getContext());
    this.id = getUniqueFakeId(activity,this.id);

    this.context = recyclerView.getContext();
    this.containerView = buildDefualtContainer(this.context,this.id);
    }

    public FragmentHelper(RecyclerView recyclerView,int layoutId, int fragmentType) {

    this.context = recyclerView.getContext();
    this.containerView = (ViewGr0up) LayoutInflater.from( this.context).inflate(layoutId,recyclerView,false);
    Activity activity = getRealActivity(recyclerView.getContext());
    this.id = getUniqueFakeId(activity,this.id);

    this.containerView.setId(id);
    // 本id依赖于fragment,因此为防止fragmentManager多次复用同一个view,这里加1
    }


    private int getUniqueFakeId(Activity activity, int id) {
    if(activity==null){
    return id;
    }
    int newId = id;
    do{
    View v = activity.findViewById(id);
    if(v!=null){
    newId += 1;
    continue;
    }
    newId = id;
    break;
    }while (true);
    return newId;
    }


    public void setFragment(Fragment fragment) {
    this.fragment = fragment;
    }

    public View getFragmentView() {

    return containerView;
    }

    private static ViewGr0up buildDefualtContainer(Context context,int id) {
    FrameLayout frameLayout = new FrameLayout(context);
    RecyclerView.LayoutParams lp = new RecyclerView.LayoutParams(ViewGr0up.LayoutParams.MATCH_PARENT, ViewGr0up.LayoutParams.MATCH_PARENT);
    frameLayout.setLayoutParams(lp);
    frameLayout.setId(id);
    return frameLayout;
    }

    public int getContainerId() {
    return id;
    }

    public void updateFragment() {

    }

    public Fragment getFragment() {
    return fragment;
    }

    public void finishUpdate() {
    if (fragmentTransaction != null) {
    fragmentTransaction.commitNowAllowingStateLoss();
    fragmentTransaction = null;
    }
    }

    public FragmentTransaction beginTransaction(FragmentManager fragmentManager) {
    if (this.fragmentTransaction == null) {
    this.fragmentTransaction = fragmentManager.beginTransaction();
    }
    return this.fragmentTransaction;
    }
    }

    以上提供了一个非常完美的 FragmentPagerAdapter,来支持 RecyclerView 加载 Fragment


    三、新问题


    在 Fragment 使用 RecyclerView 列表时会出现如下问题


    1、交互不准确,比如垂直滑动会变成 Pager 滑动效果


    2、页面 fling 效果出现闪动


    3、事件冲突,导致滑动不了


    因此为了解决上述问题,进行了一下规避


    public class RecyclerPager extends RecyclerView {

    private final DisplayMetrics mDisplayMetrics;
    private int pageTouchSlop = 0;
    float startX = 0;
    float startY = 0;
    boolean canHorizontalSlide = false;

    public RecyclerPager(Context context) {
    this(context, null);
    }

    public RecyclerPager(Context context, @Nullable AttributeSet attrs) {
    this(context, attrs, 0);
    }

    public RecyclerPager(Context context, @Nullable AttributeSet attrs, int defStyle) {
    super(context, attrs, defStyle);
    pageTouchSlop = ViewConfiguration.get(context).getScaledPagingTouchSlop();
    mDisplayMetrics = getResources().getDisplayMetrics();

    }

    private int captureMoveAction = 0;
    private int captureMoveCounter = 0;

    @Override
    public boolean dispatchTouchEvent(MotionEvent e) {

    switch (e.getAction()) {
    case MotionEvent.ACTION_DOWN:
    startX = e.getX();
    startY = e.getY();
    canHorizontalSlide = false;
    captureMoveCounter = 0;
    Log.w("onTouchEvent_Pager", "down startY=" + startY + ",startX=" + startX);
    break;
    case MotionEvent.ACTION_MOVE:
    float currentX = e.getX();
    float currentY = e.getY();
    float dx = currentX - startX;
    float dy = currentY - startY;

    if (!canHorizontalSlide && Math.abs(dy) > Math.abs(dx)) {
    startX = currentX;
    startY = currentY;
    if (tryCaptureMoveAction(e)) {
    canHorizontalSlide = false;
    return true;
    }
    break;
    }

    if (Math.abs(dx) > pageTouchSlop && canScrollHorizontally((int) -dx)) {
    canHorizontalSlide = true;
    }

    //这里取相反数,滑动方向与滚动方向是相反的

    Log.d("onTouchEvent_Pager", "move dx=" + dx +",dy="+dy+ ",currentX=" + currentX+",currentY="+currentY + ",canHorizontalSlide=" + canHorizontalSlide);
    if (canHorizontalSlide) {
    startX = currentX;
    startY = currentY;

    if (captureMoveAction == MotionEvent.ACTION_MOVE) {
    return super.dispatchTouchEvent(e);

    }
    if (tryCaptureMoveAction(e)) {
    canHorizontalSlide = false;
    return true;
    }

    }
    break;
    }

    return super.dispatchTouchEvent(e);
    }

    /**
    * 尝试捕获事件,防止事件后被父/子View主动捕获后无法改变捕获状态,简单的说就是没有cancel掉事件
    *
    * @param e 当前事件
    * @return 返回ture表示发送了cancel->down事件
    */

    private boolean tryCaptureMoveAction(MotionEvent e) {

    if (captureMoveAction == MotionEvent.ACTION_MOVE) {
    return false;
    }
    captureMoveCounter++;

    if (captureMoveCounter != 2) {
    return false;
    }
    MotionEvent eventDownMask = MotionEvent.obtain(e);
    eventDownMask.setAction(MotionEvent.ACTION_DOWN);
    Log.d("onTouchEvent_Pager", "事件转换");
    super.dispatchTouchEvent(eventDownMask);

    return true;

    }

    @Override
    public boolean onInterceptTouchEvent(MotionEvent e) {
    super.onInterceptTouchEvent(e); //该逻辑需要保留,因为recyclerView有自身事件处理
    captureMoveAction = e.getAction();

    switch (e.getActionMasked()) {
    case MotionEvent.ACTION_UP:
    case MotionEvent.ACTION_CANCEL:
    case MotionEvent.ACTION_OUTSIDE:
    canHorizontalSlide = false;//不要拦截该类事件
    break;

    }
    if (canHorizontalSlide) {
    return true;
    }
    return false;
    }

    @Override
    public boolean dispatchNestedPreScroll(int dx, int dy, int[] consumed, int[] offsetInWindow, int type) {
    consumed[1] = dy;
    return super.dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow, type);
    }

    @Override
    public int getMinFlingVelocity() {
    return (int) (super.getMinFlingVelocity() * mDisplayMetrics.density);
    }

    @Override
    public int getMaxFlingVelocity() {
    return (int) (super.getMaxFlingVelocity()* mDisplayMetrics.density);
    }

    @Override
    public boolean fling(int velocityX, int velocityY) {
    velocityX = (int) (velocityX / mDisplayMetrics.scaledDensity);
    return super.fling(velocityX, velocityY);
    }
    }

    四、使用


    创建一个 fragment


        @SuppressLint("ValidFragment")
    public static class TestFragment extends Fragment{

    private final int color;
    private String name;

    private int[] colors = {
    0xffDC143C,
    0xff66CDAA,
    0xffDEB887,
    Color.RED,
    Color.BLACK,
    Color.CYAN,
    Color.GRAY
    };
    public TestFragment(int viewType) {
    this.name = "id#"+viewType;
    this.color = colors[viewType%colors.length];
    }

    @Nullable
    @Override
    public View onCreateView(LayoutInflater inflater, @Nullable ViewGr0up container, @Nullable Bundle savedInstanceState) {

    View convertView = inflater.inflate(R.layout.test_fragment, container, false);
    TextView textView = convertView.findViewById(R.id.text);
    textView.setText("fagment: "+name);
    convertView.setBackgroundColor(color);

    if(BuildConfig.DEBUG){
    Log.d("Fragment","onCreateView "+name);
    }
    return convertView;

    }


    @Override
    public void onResume() {
    super.onResume();

    if(BuildConfig.DEBUG){
    Log.d("Fragment","onResume");
    }
    }

    @Override
    public void setUserVisibleHint(boolean isVisibleToUser) {
    super.setUserVisibleHint(isVisibleToUser);
    Log.d("Fragment","setUserVisibleHint"+name);
    }

    @Override
    public void onDestroyView() {
    super.onDestroyView();

    if(BuildConfig.DEBUG){
    Log.d("Fragment","onDestroyView" +name);
    }
    }
    }

    接着我们实现 FragmentPagerAdapter


     public static class MyFragmentPagerAdapter extends FragmentPagerAdapter{

    public MyFragmentPagerAdapter(FragmentManager fm) {
    super(fm);
    }

    @Override
    public Fragment getFragment(int viewType) {
    return new TestFragment(viewType);
    }

    @Override
    public int getItemViewType(int position) {
    return position;
    }

    @Override
    public long getItemId(int position) {
    return position;
    }

    @Override
    public int getItemCount() {
    return 3;
    }
    }

    下面设置 Adapter


     RecyclerView recyclerPagerView = findViewById(R.id.loopviews);
    recyclerPagerView.setLayoutManager(new
    LinearLayoutManager(this,LinearLayoutManager.HORIZONTAL,false));
    recyclerPagerView.setAdapter(new MyFragmentPagerAdapter(getSupportFragmentManager()));

    五、总结


    整个过程轻松而愉快,当然本篇主要学习的是RcyclerView事件冲突的解决,突发奇想然后就写了个轮子,看样子是没什么大问题。


    作者:时光少年
    来源:juejin.cn/post/7307887970664595456
    收起阅读 »

    鸿蒙 Ark ui 视频播放组件 我不允许你不会

    前言: 各位同学有段时间没有见面 因为一直很忙所以就没有去更新博客。最近有在学习这个鸿蒙的ark ui开发 因为鸿蒙不是发布了一个鸿蒙next的测试版本 明年会启动纯血鸿蒙应用 所以我就想提前给大家写一些博客文章 概述 在手机、平板或是智慧屏这些终端设备上,媒...
    继续阅读 »

    前言:


    各位同学有段时间没有见面 因为一直很忙所以就没有去更新博客。最近有在学习这个鸿蒙的ark ui开发 因为鸿蒙不是发布了一个鸿蒙next的测试版本 明年会启动纯血鸿蒙应用 所以我就想提前给大家写一些博客文章


    概述


    在手机、平板或是智慧屏这些终端设备上,媒体功能可以算作是我们最常用的场景之一。无论是实现音频的播放、录制、采集,还是视频的播放、切换、循环,亦或是相机的预览、拍照等功能,媒体组件都是必不可少的。以视频功能为例,在应用开发过程中,我们需要通过ArkUI提供的Video组件为应用增加基础的视频播放功能。借助Video组件,我们可以实现视频的播放功能并控制其播放状态。常见的视频播放场景包括观看网络上的较为流行的短视频,也包括查看我们存储在本地的视频内容


    效果图


    image.png


    image.png


    具体实现:




    • 1 添加网络权限




    在module.json5 里面添加网络访问权限


    "requestPermissions": [
    {
    "name": "ohos.permission.INTERNET"
    }
    ]

    image.png
    如果你是播放本地视频那么可以不添加这个 为了严谨我这边就提一下


    我们要播放视频需要用到 video 组件


    video 组件里面参数说明


    参数名参数类型必填
    srcstringResource
    currentProgressRatenumberstringPlaybackSpeed8+
    previewUristringPixelMap8+Resource
    controllerVideoController
    其他属性说明 :
    .muted(false) //是否静音。默认值:false
    .controls(true)//不显示控制栏
    .autoPlay(false) // 手动点击播放
    .loop(false) // 关闭循环播放
    .objectFit(ImageFit.Cover) //设置视频显示模式。默认值:Cover

    具体代码


    @Entry
    @Component
    struct Index {


    @Styles
    customMargin() {
    .margin({ left: 20, right: 20 })
    }

    @State message: string = 'Hello World'
    private controller: VideoController = new VideoController();
    build() {
    Row() {
    Column() {
    Video({
    src: $rawfile('video1.mp4'),
    previewUri: $r('app.media.image3'),
    controller: this.controller
    })
    .muted(false) //是否静音。默认值:false
    .controls(true)//不显示控制栏
    .autoPlay(false) // 手动点击播放
    .loop(false) // 关闭循环播放
    .objectFit(ImageFit.Cover) //设置视频显示模式。默认值:Cover
    .customMargin()// 样式
    .height(200) // 高度
    }
    .width('100%')
    }
    .height('100%')
    }
    }

    最后总结


    鸿蒙的视频播放和安卓还有iOS .里面差不多都有现成的组件使用, 但是底层还是有ffmpeg 的支持。 我们作为上层开发者只需要熟练掌握api使用即可做出来 一个实用的播放器 app, 还有很多细节 由于篇幅有限我就展开讲了 我们下一期再见 最后呢 希望我都文章能帮助到各位同学工作和学习 如果你觉得文章还不错麻烦给我三连 关注点赞和转发 谢谢


    作者:坚果派_xq9527
    来源:juejin.cn/post/7308620787329105971
    收起阅读 »

    前几天有个雏鹰问我,说怎么创建Menu???

    这个很简单了哈,直接上代码算了 自己在这个路径下面创建一个这个的这个这个这个,很直观吧 <?xml version="1.0" encoding="utf-8"?> <menu xmlns:android="http://schemas.a...
    继续阅读 »

    这个很简单了哈,直接上代码算了
    自己在这个路径下面创建一个这个的这个这个这个,很直观吧


    截屏2023-11-29 22.13.12.png


    <?xml version="1.0" encoding="utf-8"?>
    <menu xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

    <item
    android:id="@+id/list_view"
    android:title="@string/listview">

    <menu>
    <item
    android:id="@+id/list_view_vertical_only"
    android:title="垂直标准"
    tools:ignore="DuplicateIds" />

    <item
    android:id="@+id/list_view_vertical_reverse"
    android:title="垂直反向" />

    <item
    android:id="@+id/list_view_horizontal_only"
    android:title="水平标准" />

    <item
    android:id="@+id/list_view_horizontal_reverse"
    android:title="水平反转" />

    </menu>
    </item>
    </menu>

    然后读取目录路面的条目的时候有一个过滤器,把你自己添加的目录放进来,点击事件也帮你写好了,里面想怎么整自己搞,


    @Override
    public boolean onCreateOptionsMenu(Menu menu) {
    getMenuInflater().inflate(R.menu.menu, menu);
    return super.onCreateOptionsMenu(menu);
    }

    @SuppressLint("NonConstantResourceId")
    @Override
    public boolean onOptionsItemSelected(MenuItem item) {
    int itemId = item.getItemId();
    if (itemId != 0)
    switch (itemId){
    case R.id.list_view:
    break;
    case R.id.list_view_vertical_only:
    break;
    case R.id.list_view_vertical_reverse:
    break;
    case R.id.list_view_horizontal_only:
    break;
    case R.id.list_view_horizontal_reverse:
    break;
    }
    return super.onOptionsItemSelected(item);
    }

    结束结束,希望下次雏鹰可以自己看,或者自己搜下,很简单的东西


    作者:贾炬山
    来源:juejin.cn/post/7306706954678763556
    收起阅读 »

    Android APP合规检查,你可能需要这个工具~

    虽迟但到,这是一个通过拦截Java方法调用用以检测应用是否合规的工具,如果你的APP正饱受监管部门或应用市场时不时下发整改通知的折磨,那么用它来检查你的代码以及引用的三方库是再好不过的选择了! 如何引入 Step 1. 添加 mavenCentral all...
    继续阅读 »

    logo.png


    虽迟但到,这是一个通过拦截Java方法调用用以检测应用是否合规的工具,如果你的APP正饱受监管部门或应用市场时不时下发整改通知的折磨,那么用它来检查你的代码以及引用的三方库是再好不过的选择了!


    如何引入


    Step 1. 添加 mavenCentral


    allprojects {
    repositories {
    ...
    mavenCentral()
    }
    }

    Step 2. 添加 Gradle 依赖


    dependencies {
    ...
    implementation 'io.github.loper7:miit-rule-checker:0.1.1'
    }

    如何使用


    检查APP内是否存在不合规的方法调用



    检查MIITRuleChecker内置的不合规的方法,具体可见下方方法列表



     MIITRuleChecker.checkDefaults()


    如果内置的方法不满足当前需求,可自定义方法添加到list中进行检查;

    比如新增一个 MainActivity 的 onCreate 方法的调用检查;



    val list = MIITMethods.getDefaultMethods()
    list.add(MainActivity::class.java.getDeclaredMethod("onCreate" , Bundle::class.java)) MIITRuleChecker.check(list)

    当然,如果你想检查多个内置方法外的方法,只需要创建一个新的集合,往集合里放你想检查的方法member,然后传入 MIITRuleChecker.check()内即可。


    log打印如下所示:


    method_androidid.png


    检查指定方法调用并查看调用栈堆


    //查看 WifiInfo classgetMacAddress 的调用栈堆
    MIITRuleChecker.check(MIITMethods.WifiInfo.getMacAddress)

    log打印如下所示:


    method_macaddress.png


    检查一定时间内指定方法调用次数统计


    //多个方法统计 (deadline 为从方法调用开始到多少毫秒后截至统计)
    val list = mutableListOf().apply {
    add(MIITMethods.LocationManager.getLastKnownLocation)
    add(MIITMethods.LocationManager.requestLocationUpdates)
    add(MIITMethods.Secure.getString)
    }
    MIITMethodCountChecker.startCount(list , 20 * 1000)

    //单个方法统计(deadline 为从方法调用开始到多少毫秒后截至统计)
    MIITMethodCountChecker.startCount(MIITMethods.LocationManager.getLastKnownLocation , deadline = 20 * 1000)

    log打印如下所示:


    log_count.png


    检查完成并完成整改后务必移除方法 miit-rule-checker 库内的所有方法调用,将库一起移除最好


    内置方法表


    内置常量对应的系统方法备注
    MIITMethods.WifiInfo.getMacAddressandroid.net.wifi.WifiInfo.getMacAddress()获取MAC地址
    MIITMethods.WifiInfo.getIpAddressandroid.net.wifi.WifiInfo.getIpAddress()获取IP地址
    MIITMethods.LocationManager.getLastKnownLocationandroid.location.LocationManager.getLastKnownLocation(String)获取上次定位的地址
    MIITMethods.LocationManager.requestLocationUpdatesandroid.location.LocationManager.requestLocationUpdates(String,Long,Float,LocationListener)
    MIITMethods.NetworkInterface.getHardwareAddressjava.net.NetworkInterface.getHardwareAddress()获取主机地址
    MIITMethods.ApplicationPackageManager.getInstalledPackagesandroid.app.ApplicationPackageManager.getInstalledPackages(Int)获取已安装的应用
    MIITMethods.ApplicationPackageManager.getInstalledApplicationsandroid.app.ApplicationPackageManager.getInstalledApplications(Int)获取已安装的应用
    MIITMethods.ApplicationPackageManager.getInstallerPackageNameandroid.app.ApplicationPackageManager.getInstallerPackageName(String)获取应用安装来源
    MIITMethods.ApplicationPackageManager.getPackageInfoandroid.app.ApplicationPackageManager.getPackageInfo(String,Int)获取应用信息
    MIITMethods.PackageManager.getInstalledPackagesandroid.content.pm.PackageManager.getInstalledPackages(Int)获取已安装的应用
    MIITMethods.PackageManager.getInstalledApplicationsandroid.content.pm.PackageManager.getInstalledApplications(Int)获取已安装的应用
    MIITMethods.PackageManager.getInstallerPackageNameandroid.content.pm.PackageManager.getInstallerPackageName(String)获取应用安装来源
    MIITMethods.PackageManager.getPackageInfoandroid.content.pm.PackageManager.getPackageInfo(String,Int)获取应用信息
    MIITMethods.PackageManager.getPackageInfo1android.content.pm.PackageManager.getPackageInfo(String,PackageInfoFlags)获取应用信息(版本号大于33)
    MIITMethods.PackageManager.getPackageInfo2android.content.pm.PackageManager.getPackageInfo(VersionedPackage,Int)获取应用信息(版本号大于26)
    MIITMethods.PackageManager.getPackageInfo3android.content.pm.PackageManager.getPackageInfo(VersionedPackage,PackageInfoFlags)获取应用信息(版本号大于33)
    MIITMethods.Secure.getStringandroid.provider.Settings.Secure.getString(ContentResolver,String)获取androidId
    MIITMethods.TelephonyManager.getDeviceIdandroid.telephony.TelephonyManager.getDeviceId()获取 DeviceId
    MIITMethods.TelephonyManager.getDeviceIdWithIntandroid.telephony.TelephonyManager.getDeviceId(Int)获取 DeviceId
    MIITMethods.TelephonyManager.getImeiandroid.telephony.TelephonyManager.getImei()获取 Imei
    MIITMethods.TelephonyManager.getImeiWithIntandroid.telephony.TelephonyManager.getImei(Int)获取 Imei
    MIITMethods.TelephonyManager.getSubscriberIdandroid.telephony.TelephonyManager.getSubscriberId()获取 SubscriberId

    作者:LOPER7
    来源:juejin.cn/post/7307470097663688731
    收起阅读 »

    Android启动优化实践 - 秒开率从17%提升至75%

    一、前言 启动性能是App使用体验的门面,耗时过长会降低用户使用兴趣。对于开发者来说,通过各种技术手段来提升启动性能缩减启动时长,对整站业务的各项指标提升都会有较大帮助。因此,秒开率优化也成为了各个客户端团队在体验优化方向上十分重要的一环。 本文将会结合我自己...
    继续阅读 »

    一、前言


    启动性能是App使用体验的门面,耗时过长会降低用户使用兴趣。对于开发者来说,通过各种技术手段来提升启动性能缩减启动时长,对整站业务的各项指标提升都会有较大帮助。因此,秒开率优化也成为了各个客户端团队在体验优化方向上十分重要的一环。


    本文将会结合我自己在项目中优化启动速度的经验,跟大家分享下,我眼里的科学的启动速度优化思路。


    在我的眼里,科学的优化策略是通用的,不管是针对什么性能指标不管是针对什么课题,思路是差不多的。比如这期的分享是启动优化,其实跟上期分享的 如何科学的进行Android包体积优化 - 掘金 (juejin.cn) 是类似的,在那篇分享里我总结了基本思想:




    1. 抓各个环节

    2. 系统化方案先行,再来实操

    3. 明确风险收益比及成本收益比

    4. 明确指标以及形成一套监控防劣化体系

    5. 把包体积这个指标刻在脑子里



    那放在启动优化里,基本思想长啥样呢?其实是差不多的,我认为唯一的差别就是启动优化会包含着许多前期调研和计划过程考虑不到或者甚至无法提前考虑的问题点和可优化点。




    1. 抓各个环节

    2. 系统化方案先行,再来实操

    3. 从各个角度死磕各个角落,寻找可优化点

    4. 明确风险收益比及成本收益比

    5. 明确指标以及形成一套监控防劣化体系

    6. 把秒开率这个指标刻在脑子里



    我们调研了市面上几乎所有的优化方案,并结合项目现有情况,针对启动阶段各方面、各阶段进行针对性的优化,加上后期不断的调优,将Android客户端的主版本秒开率由 17% 优化到了 75+%,90 分位 App的启动耗时从 2800ms 优化到 1500 ms,大幅提升app的启动体验。此外,我们还建立了一系列的线上监控、防劣化体系,以确保优化效果可持续。


    二、评估优化需求


    在开展工作前,我们首先得先有两点判断:



    1. 是否需要进行优化

    2. 优化到多少算符合预期


    那具体应该如何进行判断呢?有个比较简单的方法就是对标,找到对标对象进行对标。


    对于小厂来说,一般对标对象有:



    1. 竞品App

    2. 业内人气App


    基于对标对象,我们可以粗略的有如下判断:



    1. 如果我们App跟竞品App启动速度差不多甚至略高,那就有必要进行启动速度优化。当然,在许多厂不缺人力和资源的时候,并不需要这一步判断过程哈

    2. 优化到业内人气App中启动速度最快那一档即可


    上述判断还是基于用户视角,从用户使用App的意愿去出发,那从业务或者技术的视角出发的话,肯定是越快越好,不废话了,下面直接进入主题。


    三、秒开率定义 / 启动速度指标定义


    既然要讨论秒开率,必须得明确以下问题:



    1. 用户启动app的耗时如何定义?

    2. 多长时间内app启动完成才算秒开?


    3.1 用户启动app的耗时如何定义?



    我们这里的定义是:进程启动 -> 用户看见Feed流卡片/内容



    业内常见的app启动过程阶段一般分为「启动阶段」和「首刷阶段」。



    • 启动阶段:指用户点击icon到见到app的首页

    • 首刷阶段:指用户见到app的首页到首页列表内容展现



    很多厂在谈论启动优化时,只涉及到第一步。对用户而言,他并不知道所谓的启动阶段和首刷阶段都是些什么东西,他只知道 看没看到预期的内容和能不能开始消费内容 ,既然要讨论秒开率,必然是用户期待的内容秒开,所以仅以「启动阶段」作为秒开率的指标依据并不能代表用户的真实用户体验。假如用户1s就看到首页了,但是feed流过了10s才有内容的展现,这显然不能算作秒开,这也不能代表用户的体验就是好的。当然,以 启动阶段 + 首刷阶段 认定为需要秒开的时间段,挑战也自然而然增大许多,环节越多,不可控和不可抗因素就越多。


    3.2多长时间内app启动完成才算秒开?



    1秒



    既然谈论的目标是秒开率,那必然是需要 1s 内完成用户的启动流程。


    四、认识自家App的启动流程


    为什么要认识自家App的启动过程?



    知彼知己者,百战不殆。 --《孙子·谋攻》



    对启动过程作业务层面、系统层面的全路径分析,有利于我们发现全路径上各个阶段的耗时点及潜在耗时点。


    4.1 从业务角度看启动过程


    为什么要从业务角度看启动过程?这是因为我们既然要优化的是秒开率,而我们的秒开率指标又是与业务强相关的指标,所以我们必须从业务的角度出发,找到对启动流程影响最大的以及会block启动流程的业务,以他们为切入点尝试寻求启动速度更优的解法。


    启动过程最大的不确定性因素来自于网络请求,如果App启动过程中,需要等待网络请求完成才能进入下一阶段,当出现弱网、慢网等情况时,启动时长就无法预估了,我们从下图中可以看到两处网络依赖:开屏广告请求、Feed列表请求。其他的初始化任务都不依赖网络,自然而然的执行时长在同一机器、同一环境是比较稳定的,也是可观测的。


    根据业务流程,我们想要优化启动速度,需要进行如下考虑:



    • 开屏广告接口尽量早的发出请求

    • 等待开屏接口过程中,尽量完成更多的对启动流程有 block 的启动任务

    • feed列表的第一屏数据尽量走缓存


    (下图画的有点粗略,意会就行)



    4.2 从系统角度看启动过程


    从系统角度来看自家App的启动路径,与大多数App是类似的。整体分为 Application 阶段、Activity阶段、RN阶段(业务页面阶段)。



    4.2.1 Application阶段


    在Application阶段中,有两个子阶段需要我们重点关注:



    1. Install provider,现在许多的三方库为了追求使用的便利性以及能够轻松的获取到Application上下文,会选择通过注册ContentProvider来实现库的初始化,然而正是由于过于便利,导致我们在排查启动过程的三方库初始化情况时,容易忽略掉这些隐式初始化的三方库。

    2. Application#onCreate,一般来说,项目本身模块的初始化、各种三方库初始化、业务前置环境初始化都会在 Application#onCreate 这个生命周期里干,往往这个生命周期里的任务是非常臃肿的,我们优化Application流程的大部分目光也集中在这里,也是我们通过异步、按需、预加载等各种手段做优化的主要时机。


    4.2.2 Activity阶段


    Activity阶段的起点来自于 ActivityThread#performLaunchActivity 的调用,在 performLaunchActivity 方法中,将会创建Activity的上下文,并且反射创建Activity实例,如果是App的冷启动(即 Application 并未创建),则会先创建Application并调用Application的onCreate方法,再初始化Activity,创建Window对象(PhoneWindow)并实现Activity和Window相关联,最终调用到Activity的onCreate生命周期方法。


    在启动优化的专项中,Activity阶段最关键的生命周期是 Activity#onCreate,这个阶段中包含了大量的 UI 构建、首页相关业务初始化等耗时任务,是我们在优化启动过程中非常重要的一环,我们可以通过异步、预加载、延迟执行等手段做各方面的优化。


    4.2.3 RN阶段(首页页面业务阶段)


    在我们App中,有着非常大量的 react native(RN) 技术栈的使用,面首页也是采用 RN 开发,在目前客户端团队配置下,对 RN 这个大项目的源码进行优化的空间是比较小的,考虑到成本收益比,本文几乎不会涉及对 RN 架构的直接正向优化,尽管 RN 的渲染性能可能是启动流程中非常大的瓶颈点。


    五、衡量App启动时长


    5.1 线上大盘观测


    为了量化指标、找出线上用户瓶颈以及衡量后续的优化效果,我们对线上用户的启动时长进行了埋点统计,用来观测用户从点击icon到看到feed流卡片的总时长以及各阶段的时长。


    通过细化、量化的指标监控,能够很好的观测线上用户启动耗时大盘的版本变化以及各个阶段的分位数版本变化,同时我们也需要依赖线上的性能监控统计来判断我们在某个版本上上线的某个优化是否有效,是否能真实反映在大盘指标以及用户体验上,因为本地用测试机器去跑平均启动时间,受限于运行环境的不稳定,总是会有数据波动的。当进行了某项优化之后,能通过本地测试大概跑出来是否有正向优化、优化了大概多少毫秒,但是具体反映到秒开率指标上,只能依赖大盘,本地无法做上线前的优化预估。


    启动流程终点选取



    终点不需要完全准确,尽量准就足够了



    大多数的 App 在选择冷启动启动流程终点时,会选择首页 Activity 的 onWindowFocusChanged 时机,此时首页 Activity 已经可见但其内部的 View 还不可见,对于用户侧已经可以看见首页背景,同时会将首页内 View 绘制归入首刷过程中。


    但是我们期望的终点是用户看到 Feed 流卡片的时刻,上面也说了,我们优化目标就是 「启动阶段」 + 「首刷阶段」,由于首页里的feed tab是RN 开发的,我们无法轻易的去精准到卡片的绘制阶段,于是我们将终点的选取选在了「ReactScrollView 的子 View onViewAttachedToWindow 回调时」,指RN feed流卡片View ready并且添加到了 window 上,可以大致认为卡片用户可见。


    启动性能指标校准


    由于启动路径十分复杂,在添加了相应的埋点之后还需要进行额外的校准,确保启动性能面板能正确反映用户正常打开app看到首页的完整不间断流程的性能。因此,我们对于许多的边缘case进行了剔除:



    • 进程后台被唤起,这种情况进程在后台早早的被唤起,三方sdk以及一些其他模块也早早的初始化完成,只是MainActivity没有被启动,这种case下,我们通过进程启动时,读取进程当前的 importance 进行过滤。

    • 启动过程中App退后台

    • 用户未登录场景

    • 特殊场景下的开屏广告,比如有复杂的联动动效

    • push、deeplink拉起

    • 点开app第一个页面非首页,这种场景常见的就是 push、deeplink,但是还会有一些其他的站内逻辑进其他tab或者其他二级页面,所以这里统一做一个过滤。


    启动性能大盘建设


    首先要明确我们建设的性能大盘是以什么形式从什么角度监控什么指标,在启动速度这种场景下,分位数指标更加适合去进行全面监控,因为其波动较小,不会被极端的case影响曲线,所以我们在进行启动性能的优化时,最关注的就是分位数的性能。所以我们的整体监控面板分为:



    • 秒开率、2秒开率、3秒开率、5秒以上打开率

    • 90分位总体性能、90分位各阶段性能。

    • 分版本各阶段各项指标、整体各阶段各项指标、主版本各阶段各项指标。

    • 分场景,如有无广告等等

    • ...


    5.2 Method Trace


    除了线上对性能指标进行监控,在开发环境下我们想要去优化启动时长,必须得有方法知道瓶颈在哪儿,是哪个方法太耗时,还是哪些逻辑不合理,哪些能优化,哪些没法优化。Method Trace就是其中手段之一,我们通过 Method Trace能看到每个线程的运行情况,每个方法、方法栈耗时情况如何。


    Profiler


    看method trace有两种方式,一种是直接启动 Android Studio 自带的Profiler工具,attach上进程之后就可以选择 “sample java methods” 来进行 cpu profiling了,但是这种方式不支持release包使用,只能在debug包上面使用,且性能损耗大,会让问题的分析产生误差


    Debug.startMethodTracingSamping


    我们也可以通过代码去抓取 method trace:


    Application#onCreate:
    File file = new File(FileUtils.getCacheDir(application), "trace_" + System.currentTimeMillis());
    Debug.startMethodTracing(file.getAbsolutePath(), 200000000);
    Debug.startMethodTracingSamping(file.getAbsolutePath(), 200000000, 100);

    StartupFlow#afterStartup:
    Debug.stopMethodTracing();

    在开启 MethodTracing 时,更加推荐使用 startMethodTracingSamping,这样性能损耗比调用startMethodTracing进行的完全的 Method Tracing低非常非常多, 这样抓到的性能窗口误差也小很多。而且抓到的 trace 文件也小很多,用Android Studio直接打开的话,不会有啥问题,当文件足够大时,用Android Studio打开可能会失败或者直接卡死。



    5.3 System Trace


    Perfetto


    Perfetto 是 Android 10 中引入的全新平台级跟踪工具。这是适用于 Android、Linux 和 Chrome 的更加通用和复杂的开源跟踪项目。与 Systrace 不同,它提供数据源超集,可让你以 protobuf 编码的二进制流形式记录任意长度的跟踪记录。你可以在 Perfetto 界面中打开这些跟踪记录,可以理解成如果开发机器是 Android 10 以下,就用 Systrace,如果是 Android 10 及以上,就用 Perfetto,但是 Perfetto跟Systrace一样,抓取到的报告是不包含 App 进程的代码执行情况的。文章后续也会给用Perfetto找到待优化点的案例。


    六、优化实践


    经过上面的理论分析、现状分析以及大盘指标的建立之后,其实大致对哪些需要优化以及大致如何排查、如何优化会有一个整体认知。在小厂里,由于开发资源有限,所以实际上在优化实践阶段对能进行的但是进行人力成本比较高的优化项会做一轮筛查,我们通过调研市面公开的资料、技术博客了解各大场以及各个博主分享的启动优化思路和方案,再结合自身场景做出取舍,在做取舍的过程中,衡量一个任务是否要启动有两个关键指标:“投入产出比”、“产出风险比”



    1. 投入产出比:很容易理解,当优化一个点需要 3人/天,能收获90分位 100ms 的优化产出,另一个点需要 3人/天,但只能收获90分位 10ms 的优化产出,谁先谁后、谁要做谁不做其实显而易见,因为优化的前期追求的一个很重要的点必然是优化收益。等到后续开启二期任务、三期任务需要做到更加极致时,才会考虑成本高但收益低的任务。

    2. 产出风险比:在我们做启动优化过程中,必然会有一些方案有产出但是可能会有风险点的,可能是影响某个业务,也可能影响所有业务,我们需要在启动优化的过程中,不断的衡量一个优化任务是否有风险,风险影响范围如何,风险影响大小如何,最后整体衡量甚至跟业务方进行商讨对他们造成的劣化是否能够接受,最后才能敲定相关的任务是否要排上日程。


    所以大致的思路可以总结为:



    1. 前期低成本低风险快速降低大盘启动耗时

    2. 后期高成本突破各个瓶颈

    3. 全期加强监控,做好防劣化


    下面我们就将会按照文章一开始提过的启动流程顺序来分享在启动加速项目中的一些案例。


    6.1 Application流程


    6.1.1 启动任务删减与重排



    这里我多提两嘴。我个人觉得在启动优化中,删减和重排启动任务是最为复杂的,特别是对于中大型App,业务过于多,甚至过于杂乱。但是在小厂中,完全可以冲着 删除和延后所有首页无关业务、模块、SDK代码 的目标去,前提是能理清所有业务的表面和隐藏逻辑,这里需要对整个App启动阶段的业务代码和业务流程全盘掌控。




    你也许可以通过奇技淫巧让启动过程中业务B反序列化时间无限变短,而我可以从启动过程中删掉业务B逻辑



    在App的启动流程中,有非常多的启动任务全部在Application的onCreate里被执行,有主线程的有非主线程的,但是不可避免的是,二者都会对启动的性能带来损耗。所以我们需要做的第一件重要的事情就是 减少启动任务。
    我们通过逐个排查启动任务,同时将他们分为几类:



    • 刚需任务:不可延迟,必须第一时间最高优先级执行完成,比如网络库、存储库等基础库的初始化。如果不在启动阶段初始化完成,根本无法进入到后续流程。

    • 非刚需高优任务:这类任务的特征就是高优,但是并非刚需,并不是说不初始化完成后续首页就没法进没法用,比如拉取ab实验配置、ip直连、push、长链接相关非刚需基础建设项,这类可以高优在启动阶段执行,但是没必要放在 UI 线程 block 执行,就可以放到启动阶段的后台工作线程中去跑。

    • 非刚需低优任务:这类任务常见的特征就是对业务能否运作无决定性影响或者业务本身流程靠后,完全可以放在我们认为的启动阶段结束之后再后台执行,比如 x5内核初始化、在线客服sdk预初始化 之类的。

    • 可删除任务:这类任务完全不需要存在于启动流程,可能是任务本身无意义,也可能是任务本身可以懒加载,即在用到的时候再初始化都不迟。


    将任务分类之后,我们就能大概知道如何去进行优化。



    • 拉高刚需任务优先级

    • 非刚需高优 异步化

    • 非刚需低优任务 异步化+延迟化

    • 可删除任务 删除


    6.1.2 任务排布框架


    为了更加方便的对启动任务进行排布,我们自己实现了一套用于启动过程的任务排布框架TaskManager。TaskManager具有以下几个特性:



    1. 支持优先级

    2. 支持依赖关系

    3. 提供超时、失败机制以供 fallback

    4. 支持在关键时间阶段运行任务,如MainActivity某个生命周期、启动流程结束后


    大致使用方式为:


    TaskManager.getInstance().beginWith(A)
    .then(B)
    .then(C, D)
    .then(E)
    .enqueue();

    TaskManager.getInstance().runAfterStartup({ xxx; })

    通过任务的大致非精细化的排布,我们不仅仅可以对启动任务能够很好的进行监控,还可以更加容易的找出不合理项。


    6.1.3 实现runAfterStartup机制 + idleHandler



    这玩意儿十分重要,我通过昏天黑地的梳理业务,将启动流程中原先可能超过一半的代码任务非常方便的放到了启动流程之后



    我们通过提供 runAfterStartup 的API,用于更加容易的支持各种场景、各种业务把自己的启动过程任务或者非启动过程任务放在启动流程结束之后运行,这也有助于我们自己在优化的过程中,更加轻松的将上面的非刚需低优任务进行排布。


    runAfterStartup的那些任务,应该在什么时候去执行呢?
    这里我们认定的启动流程结束是有几个关键点的:



    1. 首页tab的feed流渲染完成

    2. 首页tab加载、渲染失败

    3. 用户进入了二级页面

    4. 用户退后台

    5. 用户在首页有 tab 切换操作


    通过TaskManager的使用以及我们对各业务的逐一排查分析,我们将原先在启动阶段一股脑无脑运行的任务进行了拆解和细化,该延后的延后,该异步的异步,该按需的按需。


    6.2 Activity流程


    接下来将分享一下 Activity 阶段的一些相关优化的典型案例。


    6.2.1 SplashActivity与MainActivity合并


    原先的 launcher activity 是SplashActivity,主要承载广告逻辑,当App启动时,先进入SplashActivity,死等广告接口判断是否有开屏广告,如果有则展示,没有则跳转进MainActivity,这里流程的不合理性影响最大的点是:SplashActivity在死等开屏接口时,根本无法对业务本身做一些预加载或者并发加载,首页的业务都在MainActivity里面,同时启动阶段需要连续启动两个Activity,至少带来 百毫秒 级别的劣化。



    当然,将SplashActivity承接的所有内容转移到MainActivity上,有哪几个挑战又该如何解决?


    1. MainActivity 作为launch activity之后的单实例问题



    • MainAcitvity 的 launch mode 需要设置为 singleTop,否则会出现 App从后台进前台,非MainActivity走生命周期的现象

    • 同时,作为首页,需要满足另一个条件就是跳转到首页之后,其他二级页面需要全部都关闭掉,站内跳转到 MainActivity 则附带 CLEAR_TOP | NEW_TASK 的标记


    2. 广告以什么形式展现



    • 广告原先是以Activity的形式在展现,当 launcher 换掉之后,广告被抽离成 View 的形式去承载逻辑,在 MainActivity#onCreate 中,通过将广告View添加进 DecorView中,完成对首页布局的遮罩,这种方式还有一个好处就是在广告View展示白屏logo图时,底下首页框架是可以进行预加载的。

    • 这里其实还需要实现以下配套设施:

      • 首页弹出层管理器,管理弹窗、页面跳转等各种可能弹出的东西,在广告View覆盖在上面时,先暂停弹出层管理器的生命周期,避免出现其他内容盖住广告




    6.2.2 异步预加载布局



    使用异步加载布局时,可以对AsyncLayoutInflater小小改造下,如单线程变线程池,调高线程优先级等,提升预加载使用率



    在Android中,其实有封装较好的 AsyncLayoutInflater 用于进行布局的异步加载,我们在App的启动阶段启动异步加载View的任务,同时调高工作线程优先级以尽量在使用View之前就 inflate 结束,这样在首页上要使用该布局时,就可以直接从内存中读取。


    异步加载布局容易出问题的点有:



    1. Context的替换



      • 使用MutableContextWrapper,在使用时替换为对应的 Activity 即可



    2. theme问题



      • 当异步创建 TabLayout 等Matrials组件时,由于Application的主题并没有所谓的 AppCompat 主题,会抛出异常 You need to use a Theme.AppCompat theme 。这时需要在 xml 中加上 android:theme="@style/AppCompatTheme" 即可。




    但是异步预加载布局有一个点是非常关键的:使用前一定要确认,异步创建的这个布局大部分时候或者绝大部分时候,都能在使用前创建好,不然的话不仅没有优化的效果,还会增加启动阶段的任务从而对启动性能带来一定程度上的劣化。


    6.2.3 多Tab懒加载


    我们App的首页结构非常复杂,一共有着三层的tab切换器。底部tab负责切换大tab,首页的tab还有着两层tab用于切换二三级子页面。原先由于代码设计使然,首页Tab的其他所有子页面都会在 App 的启动阶段被加载,只是说不进行 RN 页面的渲染,这其实会占据 UI 线程非常多的时间。


    我们做启动优化的过程中,将一二三级tab全部懒加载,同时由于 我们App存在首页其他 Tab 的预加载技术建设,目的是为了实现当用户切到其他tab时,完全去除加载过程,因此我们也将预加载的逻辑延迟到了启动流程之后,即:


    StartupFlow.runAfterStartup{ preload() }

    6.2.4 懒加载布局


    Tab懒加载其实也算是布局懒加载的一部分,但又不全是,所以给拆开了。这部分讲的布局懒加载是指:



    • 启动过程不一定会需要用上的布局,可以完全在需要时被加载,比如:

      • 广告View,完全可以在后端广告接口返回可用的广告数据结构且经过了素材校验等流程确定要开始渲染广告时,进行布局的加载。

      • 首页上其他非全量的布局,比如其他广告位、首页上并不一定会出现的搜索框、banner 组件等。这些组件的特性是根据不同的配置来决定是否展示,跟广告类似。




    我们用上的布局懒加载的手段分几种:



    • ViewStub,在开屏广告的布局中非常常见,因为广告有多种类型,如视频、图片、其他类型广告等,每次的开屏又是确定的只有一种,因此就可以将不同类型的广告容器布局用 ViewStub 来处理

    • Kotlin by lazy,这种就是适用于 布局是动态加载的场景,假如上面描述的开屏广告的各种不同类型素材的布局都是在代码中动态的 inflate 并且 add 到根View上的话,其实也可以通过 by lazy 的方式来实现。所以其实很多时候 by lazy 用起来会更加方便,比如下图,对 binding 使用 by lazy ,这样只有在真正要使用 binding 时,才会去 进行 inflate。
      image.png


    6.2.5 xml2Code


    用开源项目可以,自己实现也可以,当然,搭配异步加载更可以。


    6.2.6 减少布局层级、优化过度绘制


    这个就需要自己通过LayoutInspector和Android的调试工具去各自分析了,如果打开LayoutInspector肉眼可见都是红色,赶紧改。。


    6.3 RN流程



    这里也顺带吐槽下吧,用 RN 写首页 feed 流的app真的不多哈,一般来说随着公司的发展,往往会越来越注重关键页面的性能,我们项目是我见过的为数不多的进 App 第一个页面还是RN的。如果首页不是RN,上面提到的秒开率指标、启动耗时应该会更加好一些才对。



    image.png


    先直面 RN 页面作为首页的加载流程,在页面进行渲染前,会比 native 页面多几个前置任务:



    1. RN 框架初始化(load各种so,初始化各种东西),RN init

    2. RN bundle 文件准备,find RN bundle

    3. RN bundle 文件加载,load RN bundle

    4. 开启RN渲染流程,start react application


    又因为 RN 的 js 和 native 层进行通信,又有引擎开销和线程调度开销,所以我个人认为 RN 是不足以胜任主流 app 的搭载首页业务的任务的


    吐槽归吐槽,能尽量优化点是一点:


    6.3.1 无限提前 RN init


    如题,由于后续所有的RN流程都依赖RN环境的初始化,所以必须将这坨初始化调用能放多前面,就放多前面,当然,该子线程的还是子线程哈,不可能主线程去初始化这东西。


    6.3.2 前置 RN bundle 的完整性校验


    当我们在使用 RN 的 bundle 文件时,往往需要校验一下 md5,看看从CDN上下载或者更新的 bundle 文件是否完整,但是启动流程如果进行 bundle 文件的 md5 校验,往往是一个比较2的举动,因此我们通过调整下载流程和加载流程,让下载/更新流程进行完整性校验的保护,确保要加载的所有 bundle 都是完整的可用的就行了,就不需要在启动流程校验bundle完整性了。


    6.3.3 page cache


    给首页的feed流增加页面级别缓存,让首页首刷不依赖接口返回


    6.3.4 三方库的初始化优化


    部分的三方 RN package,会在RN初始化的时候,同步的去初始化一堆耗时玩意或者执行耗时逻辑,可以通过修改三方库代码,让他们变成异步执行,不要拖慢整体的RN流程。


    6.4 其他优化


    6.4.1 Webview非预期初始化


    在我们使用 Perfetto 进行性能观测时,在 UI 线程发现了一段 几十毫秒接近百毫秒 的非预期Webview初始化的耗时(机器环境:小米10 pro),在线上用户机器上这段代码执行时间可能会更长。为什么说非预期呢:



    • 首页没有WebView的使用、预加载

    • X5内核的初始化也在启动流程之后


    1701274423005.png


    我们从perfetto的时序图中可以看到,堆栈的调用入口是WebViewChromiumAwInit.startChromiumLocked,由于 Perfetto 并看不到 App 相关的堆栈信息,所以我们无法直接知道到底是哪行代码引起的。这里感谢一下 抖音团队 分享的启动优化案例解析中的案例,了解到 WebViewFactory 实现的 WebViewFactoryProvider 接口的 getStatics、 getGeolocationPermission、createWebView 等多个方法的首次调用都会触发 WebViewChromiumAwInit#ensureChromiumStartedLocked ,随之往主线程 post 一个 runnable,这个runnable的任务体就是 startChromiumLocked 函数的调用。


    所以只要我们知道谁在调用 WebViewFactoryProvider 的接口方法,就能知道调用栈来自于哪儿。于是乎我们开始对 WebViewFactoryProvider 进行动态代理,用代理对象去替换掉 WebViewFactory 内部的 sProviderInstance。同时通过断点、打堆栈的形式来查找调用源头。


        ##WebViewFactory
    @SystemApi
    public final class WebViewFactory{
    //...
    @UnsupportedAppUsage
    private static WebViewFactoryProvider sProviderInstance;
    //...
    }


    ##动态代理
    try {
    Class clas = Class.forName("android.webkit.WebViewFactory");
    Method method = clas.getDeclaredMethod("getProvider");
    method.setAccessible(true);
    Object obj = method.invoke(null);

    Object hookService = Proxy.newProxyInstance(obj.getClass().getClassLoader(), obj.getClass().getSuperclass().getInterfaces(),
    new InvocationHandler() {
    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
    Log.d("zttt", "hookService method: " + method.getName());
    new RuntimeException(method.getName()).printStackTrace();
    return method.invoke(obj, args);
    }
    });

    Field field = clas.getDeclaredField("sProviderInstance");
    field.setAccessible(true);
    field.set(null, hookService);
    } catch (Exception e) {
    e.printStackTrace();
    }

    替换掉 sProviderInstance 之后,我们就可以在我们的代理逻辑中,加上断点来进行调试,最终找到了造成 WebView非预期初始化的始作俑者:WebSettings.getDefaultUserAgent



    事情到这里就好解决了,只需要对 WebSettings.getDefaultUserAgent 进行编译期的Hook重定向到带缓存defaultUserAgent 的相关方法就行了,本地有缓存则直接读取,本地没有则立即读取,得益于我们项目中使用方便的 配置化 Hook 框架,这种小打小闹的 Hook 工作不到一分钟就能完成。



    参考:


    基于 Booster ASM API的配置化 hook 方案封装 - 掘金 (juejin.cn)


    AndroidSimpleHook-github





    当然,这里还需要考虑一个问题,那就是当用户机器的 defaultUserAgent 发生变化之后,怎么才能及时的更新本地缓存以及网络请求中用上新的defaultUserAgent。我们的做法是:



    • 当本地没有缓存时,立刻调用 WebSettings.getDefaultUserAgent 拿值并更新缓存;

    • 每次App启动阶段结束之后,会在子线程中去调用WebSettings.getDefaultUserAgent 拿值并更新缓存。


    这样处理之后,将 defaultUserAgent 发生变化之后的影响最小化,系统 WebView 升级本身就是极度不频繁的事情,在这种 case 下我们舍弃了下一次 App 打开前几个网络请求的 defaultUserAgent 正确性也是合理的,这也是我们考量 「风险收益比」的一个经典case。


    6.4.2 启动阶段 IO 优化



    更加高级的做法是进行请求合并,当我们将50+个网络请求优化到十来个的时候,如果再对一些实时性不是极高的接口进行合并,会更加优雅。不过小厂没资源做。



    前面说的都是优化 UI 线程相关的耗时,实际上在启动阶段,不仅仅 UI 线程执行太多耗时任务会影响启动速度,工作线程执行太多的耗时任务也会影响到启动速度,特别是重IO的任务。


    在我们App的启动流程里,首页完成渲染前需要发送50+个网络请求,包含:



    • 业务预拉取数据

    • 业务拉取各种配置、开关、实验变量

    • 多次的badge请求

    • 多次的 IM 拉消息的请求

    • 各种首页阶段是否展示引导的请求,如push引导权限

    • 其他莫名奇妙的请求,可能业务都废弃了,还在发送请求


    在优化这一项时,还是秉承着前面所说的得谨慎的考虑 “收益风险比”,毕竟我们不是对所有业务都非常了解,且公司现有研发也不是对所有业务都非常了解。通过一波深入的调研和调整之后,我们将 App 首页渲染完成前的网络请求数量,控制在 10 个左右,大大的减少了启动阶段的网络请求量


    6.4.3 大对象反序列化优化


    我们App中,对象的反序列化、序列化用的是谷歌亲儿子 - Gson。Gson 是 Google 推出的一个 json 解析库,其具有接入成本低、使用便捷、功能扩展性良好等优点,但是其也有一个比较明显的弱点,那就是对于它在进行某个 Model 的首次解析时会比较耗时,并且随着 Model 复杂程度的增加,其耗时会不断膨胀。
    而我们在启动过程中,需要用 Gson 反序列化非常多的数据,特别是某些大对象,如:



    • Global config:顾名思义,是一个全局配置,里面包含着各个业务的配置信息,是非常大的

    • user info:这是用户信息的描述对象,里面包含着非常多的用户属性、标签,在App 启动过程中如果主线程去初始化,往往需要几十甚至上百毫秒。


    针对这种启动阶段必须进行复杂对象序列化,我们进行了:



    • 用Gson解的,自定义 TypeAdapter,属于正面优化反序列化操作本身(市面上有现成的一些通过添加注解自动生成TypeAdapter的框架,通过一个注解就能够很轻松的给对应的类生成 TypeAdapter并注册到 Gson 中)

    • 又大又乱又不好改的直接读磁盘然后 JSON 解析的大对象(没想到还有这种的吧),提前子线程预加载,避免在 UI 线程反序列化,能解决部分问题,并非完全的解法


    6.4.4 广告流程优化


    其实聊到启动优化,必然会涉及的肯定是 开屏广告 的业务流程。首先要搞清楚一个问题。


    启动优化优化的是什么?
    启动优化优化的是用户体验,优化的是用户看到内容的速度,那么开屏的内容怎么就不算呢?所以实际上加速用户看到开屏也能一定程度上让用户体感更加的好。而且由于我们进首页的时间依赖于广告流程结束,即需要等待广告流程结束,我们App才会跳过logo的全屏等待页面进入首页,那么优化广告流程耗时实际上也是在优化进入首页的速度,即用户可以更加快速的看到首页框架。



    原先的广告流程如上图,业务流程本身可能没什么问题,问题出在了两次的往主线程post runnable来控制流程前进。已知 App 启动流程是非常繁忙的,当一个 runnable 被post到 UI 线程的队列中之后不会立即执行,可能需要等上百甚至几百毫秒,而且由于启动过程中有着许多的耗时大的 runnable 在被执行,就算 mainHandler.postAtFrontOfQueue 都无济于事。



    因此我们对广告流程做了调整,去掉了其中一次的消息抛回主线程执行的逻辑,加快了广告业务的流程执行速度,同时,受益于我们前面说的 View 的异步预加载、懒加载等手段,广告流程的执行速度被全面优化。


    6.4.5 GC 抑制



    实现的花可以参考大佬博客: 速度优化:GC抑制 - 掘金 (juejin.cn)


    大家如果只是想本地测试下 GC 抑制在自己项目里的效果,反编译某些大厂App,从里面把相关 so 文件捞出来,copy 下JNI声明,放自己项目里测试用就行。



    自动垃圾回收的Java特性相对于C语言来说,确实是能够让开发人员在开发时提高效率,不需要去考虑手动分配内存和分配的内存什么时候去手动回收,但是对于Java程序来说,垃圾回收机制真实让人又爱又恨,不过如果开发人员仅在乎业务而不在乎性能的话,确实是不会垃圾回收恨起来。这里需要明确一点,垃圾回收是有代价的,会占 CPU 资源甚至导致我们线程被挂起。


    App在启动过程中,由于任务众多而且涉及到的sdk、模块也众多,非常容易在启动阶段发生一次或者多次的GC,这往往会带来比较多的性能损耗。


    针对这种启动容易触发 GC 的场景,我们有两种方式去减少 GC 次数以及降低 GC 发生的可能。



    • 通过 profiler 去排查启动过程中的对象创建、分配,找出分配过于频繁明显不正常的case

    • 影响 ART 虚拟机的默认垃圾回收行为和机制,进行 GC 抑制,这里由于我们 App 的 minSdk 是 Api 24,所以仅涉及 ART 虚拟机上的 GC 抑制。


    不过鉴于以下几点,我们最终并没有自己去实现和上线无稳定性、兼容性隐患的GC抑制能力:



    1. Android 10 及以上,官方实际上已经在 framework 中添加了 App 启动提高 GC 阈值的逻辑。cs.android.com/android/_/a…

    2. 由于我们在启动任务重排和删减用例很大,线上对 启动阶段的 GC 次数进行了统计,发现 80 分位的用户 GC 次数为0。也就是说启动优化上线之后线上至少 80% 的用户在启动阶段都不会发生 GC。监听 GC 发生可以简单的用 WeakReference 来包装重写了 finalize 方法的自定义对象。

    3. 不满足对于收益成本比、风险收益比的要求


    6.4.6 高频方法



    排查高频方法可以通过 method trace + 插桩记录函数调用来做



    比如在我们App的场景中,日志库、网络库上拼接公共参数时,会反复调用许多耗时且无缓存的方法去获取参数值,其中包括:



    • Binder调用,如 push enable 等

    • 每次都需要反序列化的取值,如 反序列化 global config,从中取得一个配置值。

    • ...


    针对这几类问题,我们做了如下优化:



    • 能加缓存加缓存

    • 拼接公参异步化


    6.4.7 binder 调用优化



    想要优化启动过程中的binder调用,必须得先知道有哪些binder调用,不考虑来自Framework代码的调用的话,可以通过hook工具来先检查一下。同时打印下调用耗时摸个底
    基于 Booster ASM API的配置化 hook 方案封装 - 掘金 (juejin.cn)



    binder是android提供的IPC的方式。android许多系统服务都是运行在system_server进程而非app进程,比如判断网络,获取电量,加密等,当通过binder调用去调用到相关的api之后,app线程会挂起等待binder驱动返回数据,因此IPC 调用是比较耗时的,而且可能会出现比预期之内的耗时更加耗时的现象。


    针对binder调用的耗时现象,主要做了:



    1. 对反复调用的 binder 调用结果进行缓存,合适的时机进行更新

    2. 通过 hook 框架统一收拢这些调用进缓存逻辑


    比如 push enable,这种总不能启动过程变来变去吧,再比如网络状态,也不能启动过程变来变去吧。


    当然,上面举的例子,也完全可以用于App全局场景,比如通知权限状态,完全可以app进前台检查下就行,比如网络状态,监听网络状态变化去更新就行。


    七、验收优化效果



    再次强调一下,我们统计的 App 启动耗时是「启动阶段」+ 「首刷阶段」



    7.1 App 启动耗时分位数


    90 分位 App的启动耗时从 2800 左右 下降到 1500 左右。降幅47%



    7.2 主版本秒开率



    从图中也能看到,整体稳定,但部分天波动较大,是因为开屏广告接入了程序化平台,接口时长、素材大小等都不是很好控制,尽管后端已经限制了请求程序化的超时时长,但是迫于无奈,无法将程序化平台接口请求超时时长设定在一个我满意的情况下,毕竟是收入部门。



    Android 主版本秒开率由原先的约 17% 提升到 76%



    7.3 两秒打开率


    Android 主版本两秒打开率由原先的 75% 提升到了 93%


    八、总结与展望


    回顾秒开率优化的这一期工作,是立项之后的第一期,在优化项选型过程中,除了优化效果之外,人力成本是我们考虑的最多的因素,由于团队人力不充裕,一些需要比较高成本去研究、去做的优化项,也都暂时搁置留做二期任务或者无限搁置。启动优化本身就是个需要持续迭代、持续打磨的任务,我们在优化的过程中始终秉承着 高收益成本比、低风险收益比 的思想,所以后续我们还会继续钻研,继续将之前没有开展的任务排上日程,技术之路,永无止境。


    防劣化跟优化一样重要


    在线上优化工作开展完成且取得了相应成果后,绝对不能放松警惕,优化这一下获得了效果并不是最重要的,最重要的是要有持续的、稳定的优化效果。对于线上用户来说,其实可能并不关心这个版本或者这几个版本是不是变快了,大家可能更需要的是长时间的良好体验,对于我们这些开发者来说,长时间的良好体验可能才能改变大家对 自家 App 的性能印象,让大家认可自家 App 的启动性能,这也是我们优化的初衷。因此,防劣化跟优化一样重要!


    其他


    做性能优化往往是比较枯燥的,可能很长时间没有进展,但是当有成果出来时,会收获到一些幸福感和满足感。希望大家都能在遇到瓶颈很难再往前迈步时,再努力挣扎一下,如果不出意外的话,这一路一定很精彩。


    作者:邹阿涛涛涛涛涛涛
    来源:juejin.cn/post/7306692609497546752
    收起阅读 »

    如何科学的进行Android包体积优化

    这篇文章会分享小厂如何做包体积优化相关主题,涉及内容包括:1) Android包体积优化的一种可能是比较标准的推进做法,2) 大致流程的心路历程和思考方式,3) 如何去总结和分享你们进行过的包体积优化项目。本文不仅仅是一篇分享,还是我个人比较喜欢的总结报告写法...
    继续阅读 »

    这篇文章会分享小厂如何做包体积优化相关主题,涉及内容包括:1) Android包体积优化的一种可能是比较标准的推进做法,2) 大致流程的心路历程和思考方式,3) 如何去总结和分享你们进行过的包体积优化项目。本文不仅仅是一篇分享,还是我个人比较喜欢的总结报告写法。



    一、前言


    移动 App 特别关注投放转化率指标,而 App 包体积是影响用户新增的重要因素,而 App 的包体积又是影响投放转化率的重要因素。


    Google 2016 年公布的研究报告显示,包体积每上升 6MB 就会带来下载转化率降低 1%, 当包体积增大到 100MB 时就会有断崖式的下跌 。对于应用商店,普遍有一个流量自动下载的最大阈值,如 应用宝,下载的app超过100M,用流量下载时,会弹窗提示用户是否继续下载,这对下载转化率影响是比较大的。


    现在流量虽然变得更廉价一点,但是用户的心理是不会变的,当 App 出现在应用市场的相同位置时,包体积越大,用户下载意愿可能越低。


    而且包体积或直接或间接地影响着下载转化率、安装时间、运行内存、磁盘空间等重要指标,所以投入精力扫除积弊、发掘更深层次的体积优化项是十分必要的。



    某手:



    • 1M = 一年kw级推广费


    某条极速版:



    • Google 2016 年公布的研究报告显示,包体积每上升 6MB 就会带来下载转化率降低 1%,当包体积增大到 100MB 时就会有断崖式的下跌。

    • 通过插件化,将常规优化后达 120M+的包体积降到了 13M 左右,最小版本降至 4M,包体积缩小至原先的 3.33%。


    某德:



    • 包体积大小,是俞xx直接拍的,就要求 x年x月x日 前削减到100M。


    某淘:



    • 包大小做得比较“霸权”“独裁”,新业务超过 1M 要总裁审批,一般在平台组都卡掉了。



    二、评估优化需求


    在开展工作前,我们首先得先有两点判断:



    1. 是否需要进行优化

    2. 优化到多少算符合预期


    那具体应该如何进行判断呢?有个比较简单的方法就是对标,找到对标对象进行对标。


    对于小厂来说,一般对标对象有:



    1. 竞品App

    2. 业内人气App


    基于对标对象,我们可以粗略的有如下判断:



    1. 如果我们App跟竞品App包体积差不多或略高,那就有必要进行包体积优化。

    2. 优化到跟业内人气App中包体积最小的那一档差不多即可。


    上述判断还是基于用户视角,假如你的 用户需求 比较简单,好几个app都可以满足, 你会安装200M的产品,还是50M的产品。再有,假如用户在App商店无意间看到你们的App,有点兴趣体验体验,但是看到包体积是200M,他有多大概率会继续下载,换成50M呢?


    三、包体积优化基本思想


    我们在做包体积优化前,一定要定好我们的大方向,我们怎么优化,能做哪些,哪些可以现在做,哪些没必要现在做,


    1. 抓各个环节


    我们最终是要优化App的包体积,那么App包组成部分有哪些,我们针对每一个部分去研究如何减少其体积,就可以达到我们最终的效果。


    2. 系统化方案先行,再来实操


    优化Android包体积这个事情,有一定的探索性,但是很多内容或者说手段都是业内在广为流传的,既然如此,我们应该总结业内可优化手段,并逐一进行分析,研究是否适合你们App,是否需要应用到你们App。如果没有方案的埋头扎进去,往往会因为陷入细节,而丢失了全局视野。


    3. 明确风险收益比及成本收益比


    方案与方案之间是有区别的。如果一个方案能减少包体积2M,但是线上可能会崩溃,你会做吗? 如果一个方案能减少包体积2M,但是开发成本要一个月,你会做吗?


    4. 明确指标以及形成一套监控防劣化体系


    干任何一件以优化为目标的事情时,一定要明确优化的指标,我们要做App的包体积优化,那么我们的指标为:减少App安装包 apk 的大小。


    当我们指标明确之后,我们还需要对现有指标进行监控,这样有两个好处:



    • 明确优化收益

    • 防止劣化


    那我们就可以在某个关键的时间节点进行包体积的统计和上报,一般时间节点有:



    • App发版打包时(粒度更粗)

    • 开发分支有新的 commit 合入时(粒度更细)


    两种粒度各有各的好处,但是目标是一样的:



    • 监控每次打包的包体积,可以行成指标曲线进行持续观察

    • 在包体积增加超出预期时进行及时报警


    5. 把包体积这个指标刻在脑子里


    自动化能发现已经发生的问题,但是把包体积这个指标刻在脑子里,能避免问题的发生。


    四、自家App包体积一览


    1. Android Apk结构


    APK 主要由五个部分组成,分别是:



    • Dex:.class 文件处理后的产物,Android 系统的可执行文件

    • Resource:资源文件,主要包括 layout、drawable、animator,通过 R.XXX.id 引用

    • Assets:资源文件,通过 AssetManager 进行加载

    • Library:so 库存放目录

    • META-INF:APK 签名有关的信息


    132d21f3-a1b6-43f6-99d0-fad7ffb81412.png


    2. 你们Apk各个部分都长啥样,长多大?


    这里选取示例App某个版本的安装包来做举例分析,下面是包结构图:


    QQ截图20231109021600.png


    浅浅分析一波包内内容


    成分体积备注
    assets文件夹77.8M下载.png 能看到Assets文件夹里,有着75M的RN bundle
    lib75.2Ma7d410c9-75ab-4c27-9d21-9c8953567d00.png 由于我们App是兼容包,即同时兼容64位、32位,所以lib目录很大
    dex16M这部分涉及到我们自己的代码及三方库代码
    res6.3M这里包含各种图片资源、布局文件等
    resources.arsc1.2M
    其他若干

    五、优化方案草案


    通过调研业内常规、非常规手段,以及结合你们App的包体积现状,可以提前对优化包体积做出比较详尽的可实现、低风险、高收益方案,注意这几个点非常重要:



    • 可实现 - 可实现可以简单理解为实现成本低,一般各种性能稳定性指标都是循序渐进的推进,所以往往一期优化时,选的实操方案都是实现成本比较低的,这样能相对快速的得到比较符合心里预期的优化效果。

    • 低风险 - 线上风险必须控制在可接受的程度,这里的风险不仅仅是说App运行的性能稳定性风险,还需要判断是否会增加线上问题排查的难度,当然还会有其他的我没提到的风险项。

    • 高收益 - 不解释


    所以基于我们需要的是可实现、低风险、高收益的方案,我们可以基于上面我贴的APK案例,来大致预演可能会采用哪些方案:


    1. 缩减动态化方案内置代码包、资源包


    一般的小厂都会比较大量的采用如RN、H5等动态化方案,不可避免的App内就会有一堆内置文件,我们看到我们示例的App中,RN内置包占了整个包体积超过 30%。当出现这种情况时,可以针对内置代码包、资源包单独立子项去推进。


    那么如何进行推进呢?有同学就会说了,业务方不让我把这些玩意儿从APK里面删掉,说影响他们打开页面速度,影响页面打开速度就会影响一级指标影响收入


    这时为了说服业务方,我们就得拿出一些证据,用来证明内置包的全部移除或者部分移除并不会对业务产生影响,或者说影响足够小。那就可以采取如下一些推进步骤:



    • 明确全部内置包或者部分内置包不内置的影响,假如内置包是 RN 的页面bundle,那给业务方两个数据基本上就能够说明影响



      • 页面bundle现下比例,假如因为本地没有内置的bundle,打开页面需要同步进行等待下载完成才能加载的话,现场下载比例是个比较有说服力的数据。

      • 线上bundle更新耗时,我们可以统计用户启动App后的一段指定时间,90分位能下载多少个bundle,50分位能下载多少个,10分位、5分位能下载多少个,来告诉业务方,老用户、新用户、老用户新登录等各种场景,到达业务页面的时候,有多少比例的用户能完成bundle更新。



    • 明确什么样的资源需要内置,同样用RN页面bundle举例,假如App的首页就是RN页面,那这玩意儿就必须内置了,假如一个页面在犄角旮旯,日pv才不到100,那就完全可以不需要内置。

    • 给出内置资源名单

    • 拿着内置名单和上面明确的不内置影响统计,找业务方拉会, 这一步最好是从上往下进行推进,而不是同级推进


    2. 分架构打包


    分架构打包能减少libs文件夹体积,libs文件夹里会包含不同架构的 so 库集合。


    首先我们最终apk包是要上传到应用商店的,应用商店得支持双包上传。答案确实是支持,且应用商店推荐双包上传。


    Android 官方也是有相关的api支持分架构打包:


    splits {
    // 基于不同的abi架构配置不同的apk
    abi {
    // 必须为true,打包才会为不同的abi生成不同的apk
    enable true
    // 默认情况下,包含了所有的ABI。
    // 所以使用reset()清空所有的ABI,再使用include指定我们想要生成的架构armeabi-v7a、arm-v8a
    reset()
    // 逗号分隔列表的形式指定 Gradle 应针对哪些 ABI 生成 APK。只与 reset() 结合使用,以指定确切的 ABI 列表。
    include "armeabi-v7a", "arm64-v8a"
    // 是否生成通用的apk,也就是包含所有ABI的apk。如果设为 true,那么除了按 ABI 生成的 APK 之外,Gradle 还会生成一个通用 APK。
    universalApk true
    }
    }

    这里需要注意的是,线上并不是所有的手机都支持 64位 的安装包,应用商店可以双包上传,线上灰度更新可以下发32位的安装包或者是 32/64 兼容包。


    3. So 压缩


    分架构打包是减少so的数量,so压缩是减少so的单个体积。


    android:extractNativeLibs="true"

    android:extractNativeLibs = true时,gradle打包时会对工程中的so库进行压缩,最终生成apk包的体积会减小。


    但用户在手机端进行apk安装时,系统会对压缩后的so库进行解压,从而造成用户安装apk的时间变长。


    若开发人员未对android:extractNativeLibs进行特殊配置,android:extractNativeLibs默认值:



    • minSdkVersion < 23 或 Android Gradle plugin < 3.6.0情况下,打包时 android:extractNativeLibs=true

    • minSdkVersion >= 23 并且 Android Gradle plugin >= 3.6.0情况下,打包时android:extractNativeLibs=false


    4. 大so动态下发


    我们能看到有些so库单个体积超大,放在apk里,就算能压缩,压缩后体积仍然很大,可能会占到 app体积超过 10%。针对这种情况,选择动态下发。


    a91934eb-420a-4e74-8b38-5e899876d89a.png


    动态下发的so如何进行加载


    我们采用ASM的方案,对代码中所有的 System.load、System.loadLibrary 进行hook,进入到我们自己的逻辑,这样我们就可以走下面流程:



    1. 下载so库

    2. 解压so库

    3. 校验so库

    4. 加载so库


    这里需要注意的一点就是,当动态下发的so没有下载、解压、校验、加载完之前,如果用户进入到了相关的业务场景,必须有兜底机制。比如在样例App的场景中,使用了 opencv 库来做图片的二维码识别,当so没下载下来时,要识别二维码就会被兜底到 zxing。


    而且由于我们有较好的Hook框架的封装,所以我们需要hook时,仅仅需要进行配置即可:



    这里可以参考我之前的博客和github上demo项目:


    基于 Booster ASM API的配置化 hook 方案封装 - 掘金 (juejin.cn)


    AndroidSimpleHook-github


    5. 大文件压缩优化,对内置的未压缩大文件进行,压缩文件用高压缩率的压缩算法


    假如apk里有内置的大文件,可以通过对其进行压缩从而减少包体积,压缩时可以选用高压缩率的算法。


    6. 代码优化



    • 去除无用代码、资源


    去除无用代码我们可以用官方的Lint检查工具



    • 去除无用三方库



    • 减少ENUM的使用


    每减少一个ENUM可以减少大约1.0到1.4 KB的大小,假如有10000个枚举对象,那不就减少了14M?美滋滋啊,但实际上具体还是要看项目代码情况来考虑,毕竟不是所有的项目里都有 10000 个枚举。


    7. 资源优化



    • 无用资源文件清理


    去除无用资源文件可以通过lint工具来做,也可以通过微信开源的 ApkChecker来完成。


    github.com/Tencent/mat…


    图片压缩、转webp

    图片压缩可以使用TinyPng,AndroidStudio也有相关插件,官方术语就是:


    使用智能的无损压缩技术来减少图片文件的大小,通过智能的选择颜色的数量,减少存储的字节,但是效果基本是和压缩前一样的。


    图片着色器

    相同图片只是颜色不同的话,完全可以只放一个图片,在内存里操作 Drawable,完成颜色替换。


    图片动态下发

    如果本地有大图,且使用要求为未压缩,或者压缩之后仍然很大,可以适当的选择动态下载该图。


    resources.arsc资源混淆

    resources.arsc这个文件是存放在APK包中的,他是由AAPT工具在打包过程中生成的,他本身是一个资源的索引表,里面维护者资源ID、Name、Path或者Value的对应关系,AssetManager通过这个索引表,就可以通过资源的ID找到这个资源对应的文件或者数据。


    通过对apk 中的resources.arsc进行内容修改,来对apk进行深度压缩。这里可以采用微信的AndResGuard方案。


    github.com/shwenzhang/…


    8. 三方库优化


    移除无用三方库

    移除无用三方库需要人肉扫描 build.gradle 文件,一个一个的去检查依赖的三方库是否被我们代码所使用。


    功能重复的三方库整合

    特别常见的case,RN 用的图片加载库是 Fresco,客户端用的图片加载库是 Glide,他们都是用来加载图片,可以通过删除一个库,让项目依赖的库少一个。



    • 修改三方库源码,不需要的代码进行剔除


    一个三方库往往不会被用到全部功能,比如曾经很火的 XUtils github.com/wyouflf/xUt…


    XUtils是一个工具大杂烩,但是假如我只用它来加载图片,其他工具是不是就完全无用,可以进行剔除。


    9. 去除 DebugItem 包含的 debug信息与行号信息


    在讲解什么是 deubg 信息与行号信息之前,我们需要先了解 Dex 的一些知识。


    我们都知道,JVM 运行时加载的是 .class 文件,而 Android 为了使包大小更加紧凑、运行时更加高效就发明了 Dalvik 和 ART 虚拟机,两种虚拟机运行的都是 .dex 文件,当然 ART 虚拟机还可以同时运行 oat 文件。


    所以 Dex 文件里的信息内容和 Class 文件包含的信息是一样的,不同的是 Dex 文件对 Class 中的信息做了去重,一个 Dex 包含了很多的 Class 文件,并且在结构上有比较大的差异,Class 是流式的结构,Dex 是分区结构,Dex 内部的各个区块间通过 offset 来进行索引。


    为了在应用出现问题时,我们能在调试的时候去显示相应的调试信息或者上报 crash 或者主动获取调用堆栈的时候能通过 debugItem 来获取对应的行号,我们都会在混淆配置中加上下面的规则:


    -keepattributes SourceFile, LineNumberTable

    这样就会保留 Dex 中的 debug 与行号信息。根据 Google 官方的数据,debugItem 一般占 Dex 的比例有 5% 左右


    10. ReDex


    ReDex 是 Facebook 开发的一个 Android 字节码的优化工具。它提供了 .dex 文件的读写和分析框架,并提供一组优化策略来提升字节码。官方提供预期优化效果:对dex文件优化为 8%


    github.com/facebook/re…


    11. R 文件瘦身


    当 Android 应用程序被编译,会自动生成一个 R 类,其中包含了所有 res/ 目录下资源的 ID。包括布局文件layout,资源文件,图片(values下所有文件)等。在写java代码需要用这些资源的时候,你可以使用 R 类,通过子类+资源名或者直接使用资源 ID 来访问资源。R.java文件是活动的Java文件,如MainActivity.java的和资源如strings.xml之间的胶水


    通过R文件常量内联,达到R文件瘦身的效果。


    github.com/bytedance/B…


    12. 可能的更多方案


    除了我上面列到的一些,市面上还有一些其他的方案,有复杂的有不复杂的,有收益高的有收益低的,大家可以在掘金上搜索Android包体积优化,就能搜到大部分了,当然,在大厂里,还会有很多很极致的方案,比如:



    • 去掉kotlin生成的许多模板代码、判空代码

    • 去除布局文件里不需要的冗余内容

    • ...


    思想是这么个思想,大家在实操的时候,思路就是先调研方案,调研完成之后再选型。


    六、基于风险收益比及成本收益比敲定最终实现方案


    这一步的重点是:明确风险收益比及成本收益比


    方案与方案之间是有区别的。



    • 如果一个方案能减少包体积2M,但是线上可能会崩溃,你会做吗?

    • 如果一个方案能减少包体积2M,但是开发成本要一个月,你会做吗?


    这里我们在示例App的基础上,对每个手段进行仔细分析,包括:



    1. 预期效果

    2. 成本

    3. 风险


    就这样,当我们制定完成我们的目标方案之后,就可以放手干了。


    手段预期效果成本是否要做进度备注
    重点优化项
    - 缩减RN 内置bundle预期效果:177.43M -> 114.43MRN 内置bundle缩减,xxxx版本带上
    - 分架构打包,64位、32位分开打包预期效果:32位:117.43M -> 71.9M 64位:117.43M -> 87.6Mxxxx
    - so压缩方案预期效果:32位:71.9M -> 55.5M 64位:87.6M -> 58.3Mxxxx
    - 大so文件动态下发预期效果:32位:55.5M -> 50.7M 64位:58.3M -> 51.7Mxxxx
    大文件优化
    - zip优化,对内置的压缩文件替换压缩算法预期针对 assets 文件针对不同类型文件选取不同高压缩率算法
    代码优化 (dex文件为 15.6M)
    - 去除无用代码Android Lintxxx
    - 减少ENUM的使用全部代码 enum类 一共60个,就算全删了也只是减少 84kxxx每减少一个ENUM可以减少大约1.0到1.4 KB的大小
    资源优化 (目前res目录大小为 6.3M,emoji目录大小为 770k)
    - 无用资源文件清理Android Lintxxx用ApkChecker再跑一次
    - 图片压缩、转webpTinyPngxxx
    - 图片着色器xxx
    - 图片动态下发主要是针对比较大的图,实际上经过TinyPng 压缩后,图片大小已经大大减小xxx
    - resources.arsc资源混淆AndResGuard两年不维护,花了一小时没完全跑起来,但看到了大致优化效果,1.3M -> 920kgithub.com/shwenzhang/…
    三方库优化 (dex文件为 15.6M)
    - 移除无用三方库检查一下
    - 移除无用三方so库
    - 功能重复三方库整合
    - 修改三方库源码,不需要的代码进行剔除
    极致优化,附 ByteX 插件情况
    - 去除 DebugItem 包含的 debug信息与行号信息mp.weixin.qq.com/s/_gnT2kjqp…
    - ReDex对dex文件优化为 8%,即在当前dex总和 15.6M的基础上,可以减少 1.2MDex 压缩,首次启动解压dexhttps://github.com/facebook/redexhttps://juejin.cn/post/7104228637594877965
    - R 文件瘦身现成方案:github.com/bytedance/B… failed for task ':app:transformClassesWithShrinkRFileForQaRelease'.> java.lang.RuntimeException: This feature requires ASM7

    七、确定优化效果


    当我们进行了一系列的或大或小的改动之后,如何描述最终优化效果?给两张对比图不就行了,无图言X。




    八、总结


    大家在进行一些有挑战性或者是比较有意义的项目时,其实可以多进行总结,总结的好处有什么我就不多解释了,懂的都懂哈。


    比如我们这里可以装模作样的这样总结一下:


    做的好的方面



    1. 足够系统化

    2. 前置调研足够充分

    3. 风险、收益、成本考虑足够充分

    4. 各方面沟通足够充分

    5. 优化决心足够大


    也可以告诉自己及读者几句话



    1. 这是一个系统的需要持续去投入人力的事情,万万不可有了一定结果之后放松警惕

    2. 别人能做的,我们也能做,只要有足够的决心去做

    3. 做事不能太讲究所谓的方法论,不然会掉入陷阱,但是确实要讲究方法论

    4. 有些事情你做好了,可能仅仅是因为做这个事情的人是你,如果是别人来做,也能将这件事情做好


    九、展望


    一般来说,进行总结之后,都得来一些展望,给未来的自己挖点坑,给总结的读者画点饼。比如我们这里就可以这样继续装模作样的展望一下:


    上面已经反复提及了,当前这一期的优化工作,重点考量的指标是风险收益比及成本收益比,所以一些极致的或者成本收益比较高的优化手段并没有被采用,所以后续还是有很多事情可以深入的干下去。



    1. resources.arsc资源混淆

    2. 去除 DebugItem 包含的 debug信息与行号信息

    3. ReDex

    4. R 文件瘦身

    5. So unwind 优化

    6. kotlin相关优化

    7. ...


    十、真正的总结


    这里我就发散性的随便总结下吧。。。也不深入纠结了。



    1. 包体积优化是个庞大的工程项目,不仅仅需要优化,还需要防劣化,优化过程中还会涉及到业务冲突,说白了就是某些东西从APK包中移除了,或多或少会有些影响,还需要去跟业务方达成意见一致。

    2. 大家不管在做什么优化课题时,最好是分步骤分工期的去进行,不要一口吃成胖子,如果上来就追求完全极致的优化效果,往往会带来两个负面风险:1)  优化工期拉长,时间成本成倍增加,2)可能影响线上App或者线下各种工具的运行稳定性。

    3. 系统化的调研、成本 + 风险 + 收益的总和考虑非常重要,任何优化项目开始进行或者进行过程中,都需要牢牢的印在脑子里,每日三省你身。

    4. 遇到困难不要畏惧,各种优化项目往往会遇到很多阻力,比如方案实现太难、业务沟通太难等等,一块石头硬磕磕不动的时候换个方向磕,换方向也磕不动那就换块石头磕,比如假设业务方沟通不动,那就换个角度,把你和业务方放在同一角色上,给业务方找收益或者。

    5. 做的项目是啥或者说研究的方向是啥其实不是最重要的,我们这种普通程序员更重要的是解决问题的能力,因为你们做的事情,换个人用同样的时间成本或者更多的时间成本,往往也能做好,所以是你做好的这件事情其实没那么重要,更重要的是遇到其他问题或者有其他的疑难杂症和系统性问题时,知道你一定能做好。


    作者:邹阿涛涛涛涛涛涛
    来源:juejin.cn/post/7302982924987039796
    收起阅读 »

    HarmonyOS 页面传值跳转

    介绍 本篇主要介绍如何在HarmonyOS中,在页面跳转之间如何传值 HarmonyOS 的页面指的是带有@Entry装饰器的文件,其不能独自存在,必须依赖UIAbility这样的组件容器 如下是官方关于State模型开发模式下的应用包结构示意图,Page就是...
    继续阅读 »

    介绍


    本篇主要介绍如何在HarmonyOS中,在页面跳转之间如何传值


    HarmonyOS 的页面指的是带有@Entry装饰器的文件,其不能独自存在,必须依赖UIAbility这样的组件容器


    如下是官方关于State模型开发模式下的应用包结构示意图,Page就是带有@Entry装饰器的文件


    0000000000011111111.20231123162458.56374277887047155204379708661912.png


    那么在页面跳转时,在代码层面最长路径其实是有两步 1,打开UIAbility 2. 打开Page


    整体交互效果


    页面传值demo.png


    传值理论



    1. 基于LocalStorage

    2. 基于EventHub

    3. 基于router


    准备


    请参照官方指导,创建一个Demo工程,选择Stage模型


    代码实践


    1.定制主入口页面


    功能



    1. 页面曝光停留时长计算

    2. 增加进入二级页面入口


    import systemDateTime from '@ohos.systemDateTime'
    import router from '@ohos.router'

    @Entry
    @Component
    struct Index {
    @State message: string = '页面跳转'

    private showDuration: number = 0

    onPageShow() {

    this.showDuration = 0
    systemDateTime.getCurrentTime(false, (error, data) => {
    if(!error){
    this.showDuration = data
    }
    })

    }

    build() {
    Row() {
    Column() {
    Text(this.message)
    .fontSize(50)
    .fontWeight(FontWeight.Bold)
    .onClick(()=>{
    systemDateTime.getCurrentTime(false, (error, data) => {
    router.pushUrl({ url: 'pages/OpenPage', params: {
    "from": "pages/Home.ets",
    "data": {
    "duration":(data - this.showDuration)
    }
    } })
    .then(() => {
    console.info('Succeeded in jumping to the second page.')
    }).catch((error) => {
    console.log(error)
    })
    })
    })
    }
    .width('100%')
    }
    .height('100%')
    }

    }

    2.添加二级页面


    注意

    OpenPage.ets需要在main_pages.json中的注册


    {
    "src": [
    "pages/Index" //主入口页面
    ,"pages/OpenPage" //二级页面
    ,"pages/Test" //三级页面
    ,"pages/LocalStorageAbilityPage" //三级页面
    ]
    }

    功能



    1. 展示主入口页面停留时间

    2. 添加通过UIAbility方式打开页面的入口

    3. 添加通过router.pushUrl方式打开页面的入口


    /**
    * 路由 3.1/4.0 文档
    * https://developer.harmonyos.com/cn/docs/documentation/doc-references-V3/js-apis-router-0000001478061893-V3#ZH-CN_TOPIC_0000001523808578__routerpushurl9
    *
    */

    import router from '@ohos.router';
    import common from '@ohos.app.ability.common';


    @Entry
    @Component
    struct OpenPageIndex{
    @State extParams: string = ''
    private expParamsO: Object
    private context = getContext(this) as common.UIAbilityContext;

    aboutToAppear(){
    this.expParamsO = router.getParams();
    this.extParams = JSON.stringify(this.expParamsO, null, '\t');
    }

    build(){
    Column(){

    List(){
    ListItemGr0up() {
    ListItem() {
    Text(this.extParams)
    .width('96%')
    .fontSize(18)
    .fontColor(Color.Green)
    .backgroundColor(Color.White)
    }.width('100%')
    .align(Alignment.Start)
    .backgroundColor(0xFFFFFF)
    .borderRadius('16vp')
    .padding('12vp')

    }.divider({
    strokeWidth: 1,
    startMargin: 0,
    endMargin: 0,
    color: '#ffe5e5e5'
    })

    ListItemGr0up() {

    ListItem() {
    Text('启动UIAbility页面')
    .width('96%')
    .fontSize(18)
    .fontColor(Color.Black)
    .backgroundColor(Color.White)
    }.width('100%')
    .height(50)
    .align(Alignment.Start)
    .backgroundColor(0xFFFFFF)
    .padding({ left: 10 })
    .onClick(() => {
    this.startAbilityTest('LocalStorageAbility')
    })

    ListItem() {
    Text('启动@Entry页面')
    .width('96%')
    .fontSize(18)
    .fontColor(Color.Black)
    .backgroundColor(Color.White)
    }.width('100%')
    .height(50)
    .align(Alignment.Start)
    .backgroundColor(0xFFFFFF)
    .padding({ left: 10 })
    .onClick(() => {
    router.pushUrl({ url: 'pages/Test', params: {
    "from": "pages/OpenPage.ets"
    } })
    .then(() => {
    console.info('Succeeded in jumping to the second page.')
    }).catch((error) => {
    console.log(error)
    })
    })

    }.divider({
    strokeWidth: 1,
    startMargin: 0,
    endMargin: 0,
    color: '#ffe5e5e5'
    })

    }.width('100%').height('90%')
    .divider({
    strokeWidth: px2vp(20),
    startMargin: 0,
    endMargin: 0,
    color: '#ffe5e5e5'
    })

    }.width('100%').height('100%')
    .padding({ top: px2vp(111) , left: '12vp', right: '12vp'})
    .backgroundColor('#ffe5e5e5')
    }

    async startAbilityTest(name: string) {
    try {
    let want = {
    deviceId: '', // deviceId为空表示本设备
    bundleName: 'com.harvey.testharmony',
    abilityName: name,
    parameters:{
    from: 'OpenPage.ets',
    data: {
    hello: 'word',
    who: 'please'
    }
    }
    };
    let context = getContext(this) as common.UIAbilityContext;
    await context.startAbility(want);
    console.info(`explicit start ability succeed`);
    } catch (error) {
    console.info(`explicit start ability failed with ${error.code}`);
    }

    }

    }


    3. 添加三级页面


    注意

    先要添加注册一个新的容器,这里命名为:LocalStorageAbility.ets
    容器需要在module.json5中声明


      {
    "name": "LocalStorageAbility",
    "srcEntry": "./ets/entryability/LocalStorageAbility.ets",
    "description": "$string:EntryAbility_desc",
    "icon": "$media:icon",
    "label": "$string:EntryAbility_label",
    "startWindowIcon": "$media:icon",
    "startWindowBackground": "$color:start_window_background"
    }

    import window from '@ohos.window';
    import UIAbility from '@ohos.app.ability.UIAbility';


    let para:Record<string,string> = { 'PropA': JSON.stringify({ 'from': 'LocalStorageAbility'}) };
    let localStorage: LocalStorage = new LocalStorage(para);

    export default class LocalStorageAbility extends UIAbility {

    storage: LocalStorage = localStorage

    onCreate(want, launchParam) {

    }

    onWindowStageCreate(windowStage: window.WindowStage) {
    super.onWindowStageCreate(windowStage)

    windowStage.loadContent('pages/LocalStorageAbilityPage', this.storage, (err, data) => {
    if (err.code) {
    return;
    }

    setTimeout(()=>{
    let eventhub = this.context.eventHub;
    console.log(para['PropA'])
    eventhub.emit('parameters', para['PropA']);
    }, 0)

    });
    }

    }

    Test.ets和LocalStorageAbilityPage.ets需要在main_pages.json中的注册


    {
    "src": [
    "pages/Index" //主入口页面
    ,"pages/OpenPage" //二级页面
    ,"pages/Test" //三级页面
    ,"pages/LocalStorageAbilityPage" //三级页面
    ]
    }

    功能



    1. 展示基于LocalStorage,EventHub,router 三种传值方式的数据


    LocalStorageAbilityPage.ets 文件



    • 展示LocalStorage,EventHub方式的数据


    import router from '@ohos.router';
    import common from '@ohos.app.ability.common';

    // 通过GetShared接口获取stage共享的LocalStorage实例
    let storage = LocalStorage.GetShared()

    @Entry(storage)
    @Component
    struct LocalStorageAbilityPageIndex {
    @State message: string = ''
    // can access LocalStorage instance using
    // @LocalStorageLink/Prop decorated variables
    @LocalStorageLink('PropA') extLocalStorageParms: string = '';

    context = getContext(this) as common.UIAbilityContext;

    aboutToAppear(){
    this.eventHubFunc()
    }

    build() {
    Row() {
    Column({space: 50}) {

    Column({space: 10}){
    Text('LocalStorage传值内容')
    Text(JSON.stringify(JSON.parse(this.extLocalStorageParms), null, '\t'))
    .fontSize(18)
    .fontColor(Color.Green)
    .backgroundColor(Color.White)
    .width('100%')
    .padding('12vp')
    .borderRadius('16vp')
    }

    Column({space: 10}){
    Text('eventHub传值内容')
    Text(this.message)
    .fontSize(18)
    .fontColor(Color.Green)
    .backgroundColor(Color.White)
    .width('100%')
    .padding('12vp')
    .borderRadius('16vp')
    }

    }.width('100%').height('100%')
    .padding({ top: px2vp(111) , left: '12vp', right: '12vp'})
    .backgroundColor('#ffe5e5e5')
    }
    .height('100%')

    }

    eventHubFunc() {
    this.context.eventHub.on('parameters', (...data) => {
    this.message = JSON.stringify(JSON.parse(data[0]), null, '\t')
    });
    }

    }

    作者:harvey_fly
    来源:juejin.cn/post/7306447457151942690
    收起阅读 »

    android之阿拉伯语适配及注意细节

    1.  AndroidManifest.xml配置文件中的 标签下,配置元素 android:supportsRtl="true"。此时当系统语言切换的时候,你的 App 也会跟着切换 UI 布局为镜像后的效果。若未增加该元素,在xml中切换语言时,...
    继续阅读 »

    1.  AndroidManifest.xml配置文件中的 标签下,配置元素 android:supportsRtl="true"。此时当系统语言切换的时候,你的 App 也会跟着切换 UI 布局为镜像后的效果。

    若未增加该元素,在xml中切换语言时,会提示 image.png 增加后,可在xml文件中查看反转后的效果 2.  新增value-ar文件夹

    image.png

    image.png

    image.png 把values/strings.xml文件复制到values-ar文件中,逐条翻译即可。

    1. layout中的Left/Right修改为Start/End

    可使用Android Studio中自带的工具:“工具栏”-“Refactor”-“Add right-to-Left(RTL)Support” image.png

    注意事项:

    • 1).此时会把所依赖gradle里的xml文件列出,记得删除,不要转换。

    image.png

    • 2). 该工具只适用于项目的app模块,无法直接应用于依赖模块。如果需要在依赖模块中进行RTL转换,要逐个打开并手动进行相应的修改。
    • 3). Start属性在LTR中对应Left,End属性在LTR中对应Right,在API 17开始支持,为了兼容低版本,可以同时有Left和Start。

        即在“Add right-to-Left(RTL)Support”工具中,不勾选“Replace Left/Right Properties with Start/End Properties”

    image.png

    1. 返回icon、下一个icon等,要针对阿拉伯语新建一个文件夹,放镜像后的图片,规则如下:

    mipmap-xhdpi->mipmap-ldrtl-xhdpi

    drawable->drawable-ldrtl

    最终镜像的图片要UI同事提供,临时修改看效果可以使用镜像图片的网站:http://www.lddgo.net/image/flip

    1. TextView、EditText:利用全局样式,在style.xml中定义,在xml里使用style=”@style/xxx”即可
    • 1). TextView
    <style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar">
           ...
           <item name="android:textViewStyle">@style/TextViewStyle.TextDirectionitem>
           ...
    style>
    <style name="TextViewStyle.TextDirection" parent="android:Widget.TextView">
            <item name="android:textDirection">localeitem>
    style>
    • 2). EditText
    <style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar">
           ...
           <item name="editTextStyle">@style/EditTextStyle.Alignmentitem>
           ...
    style>
    <style name="EditTextStyle.Alignment" parent="@android:style/Widget.EditText">
            <item name="android:textAlignment">viewStartitem>
            <item name="android:gravity">startitem>
            <item name="android:textDirection">localeitem>
    style>
    1. 其他细节
    • 1).固定ltr,如阿拉伯语下的“99%”要从左到右展示,可在xml中使用
    android:layoutDirection ="ltr"
    • 2).获取当前系统语言Locale.getDefault().getLanguage()

    判断是否为阿拉伯语:"ar".equals(Locale.getDefault().getLanguage())

    判断是否为英语:"en".equals(Locale.getDefault().getLanguage())

    • 3). drawable/xxx_selector.xml中item里有android:drawable,如勾选框。

    drawable有android:autoMirrored属性,将selector的该属性设置为true,就可以让drawable在RTL布局下进行反转

    image.png

    • 4).进度条的默认进度指示是从左到右,使用leftMargin;在阿拉伯语下,进度指示从右到左,使用rightMargin属性
    • 5).阿拉伯语环境下,使用SimpleDateFormat格式化时间字符串的时候,会显示为:٢٠١٥-٠٩-١٨ ٠٧:٠٣:٤٩。若要展示:2023-09-067:10:45,可以使用Locale.ENGLISH参数
    SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.ENGLISH);
    Date now=new Date();
    System.out.println(sdf .format(now));
    • 6). 加载html可用 tv.setText(Html.fromHtml(getResources().getString(R.String.xxx));
    • 7). 开机导航中设置了阿拉伯语,当前页面布局要刷新,可以重写activity的onConfigurationChanged()方法,如在该方法里重置下一步箭头、指示器样式等

    image.png

    • 8).ViewPager

    若是ViewPager,可使用第三方控件RtlViewPager替换: 521github.com/diego-gomez…,添加依赖,单纯替换原ViewPager即可

    implementation 'com.booking:rtlviewpager:1.0.1' 

    类似三方控件: 521github.com/duolingo/rt…

    或者使用androidx的ViewPager2替换: developer.android.google.cn/jetpack/and…,支持RTL布局

    image.png

    image.png

    • 9). 固定RTL字符串的顺序

    问题现象:EditText带hint,密码可见、不可见时,会调用如下方法进行设置

    image.png 此时会影响hint的展示:在勾选时,hint的结束字符在右侧;不勾选时,hint的结束字符在左侧。

    image.png

    image.png

    解决方法:此时要使用Unicode控制字符来限制整个字符串的显示方向:\u202B 和 \u202C。

    image.png

    有以下两种方法

    a.  java代码

    image.png

    b.  strings.xml

    image.png

    最终效果:

    image.png

    image.png

    10). Blankj的toast展示异常

    android工具类Blankj的toast工具类在展示阿拉伯语时为空或者部分展示,建议使用1.30.6 及以上版本

    image.png

    github.com/Blankj/Andr…

    11). RTL布局中出现双光标/光标截断的情形

    image.png

    在布局文件内加上如下两个属性即可:

    android:textDirection="anyRtl"
    android:textAlignment="viewStart"

    若还未解决

    1.可查看是否使用了android:textCursorDrawable=“@null”,若有,可尝试去掉该句。

    2.在AndroidManifest.xml中查看当前App/Activity的主题,比较老的项目可能使用了android:Theme.NotitleBar/android:Theme.Light等轻量级主题,如下所示:




    收起阅读 »

    得物App安卓冷启动优化-Application篇

    前言 冷启动指标是App体验中相当重要的指标,在电商App中更是对用户的留存意愿有着举足轻重的影响。通常是指App进程启动到首页首帧出现的耗时,但是在用户体验的角度来看,应当是从用户点击App图标,到首页内容完全展示结束。 将启动阶段工作分配为任务并构造出有向...
    继续阅读 »

    前言


    冷启动指标是App体验中相当重要的指标,在电商App中更是对用户的留存意愿有着举足轻重的影响。通常是指App进程启动到首页首帧出现的耗时,但是在用户体验的角度来看,应当是从用户点击App图标,到首页内容完全展示结束。


    将启动阶段工作分配为任务并构造出有向无环图的设计已经是现阶段组件化App的启动框架标配,但是受限于移动端的性能瓶颈,高并发度的设计使用不当往往会让锁竞争、磁盘IO阻塞等耗时问题频繁出现。如何百尺竿头更进一步,在启动阶段有限的时间里,将有限的资源最大化利用,在保障业务功能稳定的前提下尽可能压缩主线程耗时,是本文将要探讨的主题。


    本文将介绍我们是如何通过对启动阶段的系统资源做统一管控,按需分配和错峰加载等手段将得物App的线上启动指标降低10%,线下指标降低34%,并在同类型的电商App中提升至Top3


    一、指标选择


    传统的性能监控指标,通常是以Application的attachBaseContext回调作为起点,首页decorView.postDraw任务执行作为结束时间点,但是这样并不能统计到dex加载以及contentProvider初始化的耗时。


    因此为了更贴近用户真实体验,在启动速度监控指标的基础上,我们添加了一个线下的用户体感指标,通过对录屏文件逐帧分析,找到App图标点击动画开始播放(图标变暗)作为起始帧,首页内容出现的第一帧作为结束帧,计算出结果作为启动耗时。


    例:启动过程为03:00 - 03:88,故启动耗时为880ms。


    1.png


    二、Application优化


    App在不同的业务场景下可能会落到不同的首页(社区/交易/H5),但是Application运行的流程基本是固定的,且很少变更,因此Application优化是我们的首要选择。


    得物App的启动框架任务在近几年已经先后做过多轮优化,常规的抓trace寻找耗时点并异步化已经不能带来明显的收益,得从锁竞争,CPU利用率的角度去挖掘优化点,这类优化可能短期收益不会特别明显,但从长远来看能够提前规避很多劣化问题。


    1.WebView优化


    App在首次调用webview的构造方法时会拉起系统对webview的初始化流程,一般会耗时200+ms,如此耗时的任务常规思路都是直接丢到子线程去执行,但是chrome内核中加入了非常多的线程检查,使得webview只能在构造它的线程中使用。


    01.png


    为了加速H5页面的启动,App通常会选择在Application阶段就初始化webview并缓存,但是webview的初始化涉及跨进程交互和读文件,因此CPU时间片,磁盘资源和binder线程池中任何一种不足都会导致其耗时膨胀,而Application阶段任务繁多,恰恰很容易出现以上资源短缺的情况。


    02.png


    因此我们将webview拆分成三个步骤,分散到启动的不同阶段来执行,这样可以降低因为竞争资源导致的耗时膨胀问题,同时还可以大幅度降低出现ANR的几率。


    04.png


    1.1 任务拆分


    a. provider预加载


    WebViewFactoryProvider是用于和webview渲染进程交互的接口类,webview初始化的第一步就是加载系统webview的apk文件,构建出classloader并反射创建了WebViewFactoryProvider的静态实例,这一操作并没有涉及线程检查,因此我们可以直接将其交给子线程执行。


    10.png


    b. 初始化webview渲染进程


    这一步对应着chrome内核中的WebViewChromiumAwInit.ensureChromiumStartedLocked()方法,是webview初始化最耗时的部分,但是和第三步是连续执行的。走码分析发现WebViewFactoryProvider暴露给应用的接口中,getStatics这个方法会正好会触发ensureChromiumStartedLocked方法。


    至此,我们就可以通过执行WebSettings.getDefaultUserAgent()来达到仅初始化webview渲染进程的目的。


    150.png


    c. 构造webview


    即new Webview()


    1.2 任务分配


    为了最大程度缩短主线程耗时,我们的任务安排如下:



    • a. provider预加载,可以异步执行,且没有任何前置依赖,因此放在Application阶段最早的时间点异步执行即可。

    • b. 初始化webview渲染进程,必须在主线程,因此放到首页首帧结束之后。

    • c. 构造webview,必须在主线程,在第二步完成时post到主线程执行。这样可以确保和第二步不在同一个消息中,降低ANR的几率。


    160.png


    1.3 小结


    尽管我们已经将webview初始化拆分为了三个部分,但是耗时占比最高的第二步在低端机或者极端情况还是可能触达ANR的阈值,因此我们做了一些限制,例如当前设备会统计并记录webview完整初始化的耗时,仅当耗时低于配置下发的阈值时,开启上述的分段执行优化。


    App如果是通过推送、投放等渠道打开,一般打开的页面大概率是H5营销页,因此这类场景不适用于上述的分段加载,所以需要hook主线程的messageQueue,解析出启动页面的intent信息,再做判断。


    受限于开屏广告功能,我们目前只能对无开屏广告的启动场景开启此优化,后续将计划利用广告倒计时的间隙执行步骤2,来覆盖有开屏广告的场景。


    170.png


    2.ARouter优化


    在当下组件化流行的时代,路由组件已经几乎是所有大型安卓App必备的基础组件,目前得物使用的是开源的ARouter框架。


    ARouter 框架的设计是它默认会将注解中注册path路径中第一个路由层级 (例如 "/trade/homePage"中的trade)作为该路由信息所的Gr0up, 相同Gr0up路径的路由信息会合并到最终生成的同一个类 的注册函数中进行同步注册。在大型项目中,对于复杂业务线同一个Gr0up下可能包含上百个注册信息,注册逻辑执行过程耗时较长,以得物为例,路由最多的业务线在初始化路由上的耗时已经来到了150+ms。


    190.png


    路由的注册逻辑本身是懒加载的,即对应Gr0up之下的首个路由组件被调用时会触发路由注册操作。然而ARouter通过SPI(服务发现)机制来帮助业务组件对外暴露一些接口,这样不需要依赖业务组件就可以调用一些业务层的视线,在开发这些服务时,开发者一般会习惯性的按照其所属的组件为其设置路由path,这使得首次构造这些服务的时候也会触发同一个Gr0up下的路由加载。


    而在Application阶段肯定需要用到业务模块的服务中的一些接口,这就会提前触发路由注册操作,虽然这一操作可以在异步线程执行,但是Application阶段的绝大部分工作都需要访问这些服务,所以当这些服务在首次构造的耗时增大时,整体的启动耗时势必会随之增长。


    2.1 ARouter Service路由分离


    ARouter采用SPI设计的本意是为了解耦,Service的作用也应该只是提供接口,所以应当新增一个空实现的Service专门用于触发路由加载,而原先的Service则需要更换一个Gr0up,后续只用于提供接口,如此一来Application阶段的其他任务就不需要等待路由加载任务的完成。


    001.png


    2.2 ARouter支持并发装载路由


    我们在实现了路由分离之后,发现现有的热点路由装载耗时总和是大于Application耗时,而为了保证在进入闪屏页之前完成对路由的加载,主线程不得不sleep等待路由装载完毕。


    分析可知ARouter的路由装载方法加了类锁,因为他需要将路由装载到仓库类中的map,这些map是线程不安全的HashMap,相当于所有的路由装载操作其实都是在串行执行,而且存在锁竞争的情况,最终导致耗时累加大于Application耗时。


    002.png


    分析trace可知耗时主要来自频繁调用装载路由的loadInto操作,再分析这里锁的作用,可知加类锁是主要是为了确保对仓库WareHouse中map操作的线程安全。


    003.png


    因此我们可以将类锁降级对Gr0upMeta这个class对象加锁(这个class是ARouter apt生成的类,对应apk中的ARouterProviderProviderxxx类),来确保路由装载过程中的线程安全,至于在此之前对map操作的线程安全问题,则完全可以通过将这些map替换为concurrentHashMap解决,在极端并发情况下会有一些线程安全问题,也可以按照图中添加判空来解决。


    009.png


    010.png


    至此,我们就实现了路由的并发装载,随后我们根据木桶效应对要预载的service进行合理分组,再放到协程中并发执行,确保最终整体耗时最短。


    011.png


    012.png


    3.锁优化


    Application阶段执行的任务多为基础SDK的初始化,其运行的逻辑通常相对独立,但是SDK之间会有依赖关系(例如埋点库会依赖于网络库),且大部分都会涉及读文件,加载so库等操作,Application阶段为了压缩主线程的耗时,会尽可能地将耗时操作放到子线程中并发运行,充分利用CPU时间片,但是这也不可避免的会导致一些锁竞争的问题。


    3.1 Load so锁


    System.loadLibrary()方法用于加载当前apk中的so库,这个方法对Runtime对象加了锁,相当于一个类锁。


    基础SDK在设计上通常会将load so的操作写到类的静态代码块中,确保在SDK初始化代码执行之前就准备好了so库。如果这个基础SDK恰巧是网络库这类基础库,会被很多其他SDK调用,就会出现多个线程同时竞争这个锁的情况。那么在最坏的情况下,此时IO资源紧张,读so文件变慢,并且主线程是锁等待队列中最后一个,那么启动耗时将远超预期。


    034.png


    为此,我们需要将loadSo的操作统一管控并收敛到一个线程中执行,强制他们以串行的方式运行,这样就可以避免以上情况的出现。值得一提的是,前面webview的provider预加载的过程中也会加载webview.apk中的so文件,因此需要确保preloadProvider的操作也放到这个线程。


    so的加载操作会触发native层的JNI_onload方法,一些so可能会在其中执行一些初始化工作,因此我们不能直接调用System.loadLibrary()方法来进行so加载,否则可能会重复初始化出现问题。


    我们最终采用了类加载的方式,即将这些so加载的代码全部挪到相关类的静态代码块中,然后再去触发这些类的加载即可,利用类加载的机制确保这些so的加载操作不会重复执行,同时这些类加载的顺序也要按照这些so使用的顺序来编排。


    78.png


    除此之外,so的加载任务不建议和其他需要IO资源的任务并发执行,在得物App中实测这两种情况下该任务的耗时相差巨大。


    4.启动框架优化


    目前常见的启动框架设计是将启动阶段的工作分配到一组任务节点中,再由这些任务节点的依赖关系构造出一个有向无环图,但是随着业务迭代,一些历史遗留的任务依赖已经没有存在的必要,但是他会拖累整体的启动速度。


    启动阶段大部分工作都是基础SDK的初始化,他们之间往往有着复杂的依赖关系,而我们在做启动优化时为了压缩主线程的耗时,通常都会找出主线程的耗时任务并丢到子线程去执行,但是在依赖关系复杂的Application阶段,如果只是将其丢到异步执行未必能有预期的收益。


    99.png


    我们在做完webview优化之后发现启动耗时并没有和预期一样直接减少了webview初始化的耗时,而是只有预期的一半左右,经分析发现我们的主线程任务依赖着子线程的任务,所以当子线程任务没有执行完时,主线程会sleep等待。


    并且webview之所以放在这个时间点初始化不是因为有依赖限制这它,而是因为这段时间主线程正好有一段比较长的sleep时间可以利用起来,但是异步的任务工作量是远大于主线程的,即便是七个子线程并发在跑,其耗时也是大于主线程的任务。


    因此想进一步扩大收益,就得对启动框架中的任务依赖关系做优化。


    66.png


    671.jpeg


    以上第一张图为优化之前得物App启动阶段任务的有向无环图,红框表示该任务在主线程执行。我们着重关注阻塞主线程任务执行的任务。


    可以观察到主线程任务的依赖链路上存在几个出口和入口特别多的任务,出口多表明这类任务通常是非常重要的基础库(例如图中的网络库),而入口多表明这个任务的前置依赖太多,他开始执行的时间点波动较大。这两点结合起来就说明这个任务执行结束的时间点很不稳定,并且将直接影响到后续主线程的任务。


    这类任务优化的思路主要是:



    • 拆解任务自身,将可以提前执行或者延后执行的操作分出去,但是分出去之前要考虑到对应的时间段还有没有时间片余量,或者会不会加重IO资源竞争的情况出现;

    • 优化该任务的前置任务,让该任务执行结束的时间点尽可能提早,就可以降低后续任务等待该任务的耗时;

    • 移除非必要的依赖关系,例如埋点库初始化只是需要注册一个监听器到网络库,并非发起网络请求。(推荐)


    可以看到我们在优化之后的第二张有向无环图里,任务的依赖层级明显变少,入口和出口特别多的任务也都基本不再出现。


    044.png


    320.png


    对比优化前后的trace,也可以看到子线程的任务并发度明显提高,但是任务并发度并不是越高越好,在时间片本身就不足的低端机上并发度越高表现可能会越差,因为更容易出锁竞争,IO等待之类的问题,因此要适当留下一定空隙,并在中低端机上进行充分的性能测试之后再上线,或者针对高中低端机器使用不同的任务编排。


    三、首页优化


    1.通用布局耗时优化


    系统解析布局是通过inflate方法读取布局xml文件并解析构建出view树,这一过程涉及IO操作,很容易受到设备状态影响,因此我们可以在编译期通过apt解析布局文件生成对应的view构建类。然后在运行时提前异步执行这些类的方法来构建并组装好view树,这样可以直接优化掉页面inflate的耗时。


    601.png


    602.png


    2.消息调度优化


    在启动阶段我们通常会注册一些ActivityLifecycleListener来监听页面生命周期,或者是往主线程post了一些延时任务,如果这些任务中有耗时操作,将会影响到启动速度,因此可以通过hook主线程的消息队列,将页面生命周期回调和页面绘制相关的msg移动到消息队列的队头,这样就可以加快首页首帧内容展示的速度。


    102.png


    详情可期待本系列后续内容。


    四、稳定性


    性能优化对App只能算作锦上添花,稳定性才是生命红线,而启动优化改造的又都是执行时机非常早的Application阶段,稳定性风险程度非常高,因此务必要在准备好崩溃防护的前提下做优化,即便有不可避免的稳定性问题,也要将负面影响降到最低。


    1.崩溃防护


    由于启动阶段执行的任务都是重要的基础库初始化,因此发生崩溃时将异常识别并吃掉的意义不大,因为大概率会导致后续崩溃或功能异常,因此我们主要的防护工作都是发生问题之后的止血


    配置中心SDK的设计通常都是从本地文件中读出缓存的配置使用,待接口请求成功后再刷新。所以如果当启动阶段命中了配置之后发生了crash,是拉不到新配置的。这种情况下只能清空App缓存或者卸载重装,会造成非常严重的用户流失。


    109.png
    崩溃回退


    对所有改动点加上try-catch保护,捕捉到异常之后上报埋点并往MMKV中写入崩溃标记位,这样该设备在当前版本下都不会再开启启动优化相关的变更,随后再抛出原异常让他崩溃掉。至于native crash则是在Crash监控的native崩溃回调里执行同样操作即可。


    1100.png
    运行状态检测


    Java Crash我们可以通过注册unCaughtExceptionHandler来捕捉到,但是native crash则需要借助crash监控SDK来捕捉,但是crash监控未必能在启动最早的时间点初始化,例如Webview的Provider的预加载,以及so库的预加载都是早于crash监控,而这些操作都涉及native层的代码。


    为了规避这种场景下的崩溃风险,我们可以在Application的起始点埋入MMKV标记位,在结束点改为另一个状态,这样一些执行时间早于配置中心的代码就可以通过获取这个标记位来判断上一次运行是否正常,如果上次启动发生了一些未知的崩溃(例如发生在crash监控初始化之前的native崩溃),那么通过这个标记位就可以及时关闭掉启动优化的变更。


    结合崩溃之后自动重启的操作,在用户视角其实是观察不到闪退的,只是会感觉到启动的耗时约是平时的1-2倍。


    0456.png
    配置有效期


    线上的技改变更通常都会配置采样率,结合随机数实现逐渐放量,但是配置下发SDK的设计通常都是默认取上次的本地缓存,在发生线上崩溃等故障时,尽管及时回滚了配置,但是缓存的设计会导致用户还会因为缓存遭遇至少一次的崩溃。


    为此,我们可以为每一个开关配置加一个配套的过期时间戳,限制当前放量的开关只在该时间戳之前生效,这样在遇到线上崩溃等故障时确保可以及时止血,而且时间戳的设计也可以避免线上配置生效的滞后性导致的crash。


    457.png


    用户视角下,添加配置有效期前后对比:


    678.jpeg


    五、总结


    至此,我们已经对安卓App中比较通用的冷启动耗时案例做了分析,但是启动优化最大的痛点往往还是App自身的业务代码,应当结合业务需求合理的进行任务分配,如果一味的靠预加载,延迟加载和异步加载是不能从根本上解决耗时问题的,因为耗时并没有消失只是转移,随之而来的可能是低端机启动劣化或功能异常。


    做性能优化不仅需要站在用户的视角,还要有全局观,如果因为启动指标算是首页首帧结束就把耗时任务都丢到首帧之后,势必会造成用户后续的体验有卡顿甚至ANR。所以在拆分任务时不仅需要考虑是否会和与其并发的任务竞争资源,还需要考虑启动各个阶段以及启动后一段时间内的功能稳定性和性能是否会受之影响,并且需要在高中低端机器上都验证下,至少要确保都没有劣化的表现。


    1.防劣化


    启动优化绝不是一次性的工作,它需要长时间的维护和打磨,基础库的一次技改可能就会让指标一夜回到解放前,因此防劣化必须要尽早落地。


    通过在关键点添加埋点,可以做到在发现线上指标劣化时迅速定位到劣化代码大概位置(例如xxActivity的onCreate)并告警,这样不仅可以帮助研发迅速定位问题,还可以避免线上特定场景指标劣化线下无法复现的情况,因为单次启动的耗时波动范围最高能有20%,如果直接去抓trace分析可能连劣化的大概范围都难以定位。


    例如两次启动做trace对比时,其中一次因为遇到IO阻塞导致某次读文件的操作都明显变慢,而另一次IO正常,这就会误导开发者去分析这些正常的代码,而实际导致劣化的代码可能因为波动正好被掩盖。


    2.展望


    对于通过点击图标启动的普通场景,默认会在Application执行完整的初始化工作,但是一些层级比较深的功能,例如客服中心,编辑收货地址这类,即使用户以最快速度直接进入这些页面,也是需要至少1s以上的操作时间,所以这些功能相关的初始化工作也是可以推迟到Application之后的,甚至改为懒加载,视具体功能的重要性而定。


    通过投放,push来做召回/拉新的启动场景通常占比较少,但是其业务价值要远大于普通场景。由于目前启动耗时主要来源于webview初始化以及一些首页预载相关的任务,如果启动落地页并不需要所有基础库(例如H5页面),那么这些我们就可以将它不需要的任务统统延迟加载,这样启动速度可以得到大幅度增长,做到真正意义上的秒开。


    *文/Jordas


    作者:得物技术
    来源:juejin.cn/post/7306447634204770319
    收起阅读 »

    突发:鸿蒙之祖华为在 openInula 官网声称可“避免重复运行组件”但网友挖出“组件渲染次数”是写死的

    消息来源 看到群里这个消息,我的想法是:群里怎么炸锅了?华为之前的鸿蒙被指疑似安卓二开不说,现在出个 openInula 好像是什么欧拉又被人挖出幺蛾子?哦有 la 后缀但好像又不像欧拉。 到底怎么回事?我也不敢说话甚至都不敢参与讨论。 求真过程 不过华为好...
    继续阅读 »


    消息来源



    看到群里这个消息,我的想法是:群里怎么炸锅了?华为之前的鸿蒙被指疑似安卓二开不说,现在出个 openInula 好像是什么欧拉又被人挖出幺蛾子?哦有 la 后缀但好像又不像欧拉。


    到底怎么回事?我也不敢说话甚至都不敢参与讨论。


    求真过程


    不过华为好不好是个大企业,并且又是风口浪尖一样的存在,褒贬两级分化。真,搞得我一直到现在我都不知道遥遥领先到底是一个什么词语,时常怀疑我自己是不是出轨了。


    官网现象


    我打开 openInula 的官网 http://www.openinula.net/ ,看样子还是很高大尚的。


    Alt text


    上来就是 相比传统虚拟DOM方式,提升渲染效率30%以上。完全兼容React API,支持React应用无缝切换至openInula。 这种兼容传统但吊打传统的描述,很难让人不把他是电、他是光、他是唯一的希望等联想在一起。


    继续向下:我们看网友说的造假现象到底是不是存在。


    我小心翼翼的向下滑动页面,目不转睛的注视着每一个窗口,全神贯注的查找目标文字组件渲染次数,内心忐忑不安的希望这不是真的。


    但是,现实总是这么残酷,左右两栏的对比里,左边的数据是动态的,右边为什么就要写死?难道页面在跳动开发测试都看不出来吗?为了避免是我的错觉问题,我决定对其 GIF 录制:



    注意看

    注意看

    注意看,组件渲染次数1与源码中定死的1。



    Alt text


    好了,网友没说错。从结果上来看,数据确实就是写死的。这种行为如果用在官网和对比上,确实很难让人接受。


    但是我还注意到一个问题,官网上并没有华为的大 logo,那么这东西到底是不是华为的?别啥脏水都往华为身上泼好吧!


    然后我又再次陷入沉思:与华为什么关系?


    inula 与华为的关系




    9月21日,在华为全联接大会2023开源分论坛上,华为表示国内软件根技术创新之势已起,目前处于战略机遇期,有较大的市场空间。在这一契机下,华为发布了国内首个全面自研密码套件openHiTLS及全场景智慧前端框架openInula。这两款开源基础中间件助力软件根技术自主创新,对构筑业务数字化的核心竞争力有重要意义。



    开发团队合影:


    Alt text



    华为ICT开源产业与生态发展部总经理周俊懿在发布会上表示,“国内软件根技术创新之势已起,正处于发展战略机遇期。在此我们发布更快、更简单、更智能的新一代智慧前端框架openInula,构筑前端生态创新底座,共建国内前端繁荣生态。”



    Alt text



    目前,华为公司内部已有多个产品采用openInula作为其前端框架运用于实际商用项目,支撑上千万行前端业务代码。openInula带来的性能提升解决了产品较多的前端性能瓶颈问题,保证了项目健康、高效地运行。



    功能说明PPT:


    Alt text


    根据以上图文,我暂且觉得可以理解为 openInula 和华为有一定关系,是华为公司或旗下的团队。


    简直不敢相信自己的眼睛!


    Alt text


    肯定是什么地方弄错了,openInula 就是有这么牛笔,人家发布会在那,官网在那,仓库在那,地面在那,还有假?BUG!肯定是BUG!


    于是我开始从试图从代码上来实践看看,他确实 比传统虚拟DOM渲染效率更高、渲染次数更少


    代码实践一


    根据官网的步骤,我开始 npx create-inula <项目名>,这样就完全使用官方的脚手架模板生成项目,避免误差。



    然后根据官方指示运行命令 npm run start



    这怎么回事?我还没进入状态你就结束了?


    Alt text


    咦?不对,好像打开方式不对。


    看到了 node:path 勾起了我仅有的记忆,大概是 node 版本过低。


    这个我熟啊!于是我直接打开 package.json 文件,并加入以下代码:


    "env": {
    "node": "18.18.2"
    }

    然后再次运行命令,这时候项目就在 node v18 的环境下运行成功啦。




    注意看

    注意看

    注意看,上图中在 package.json 中声明了当前项目依赖的环境。



    当我打开控制台 url 时,页面并没有问题(没有使用官网声明的响应式API)。然后当我把官网上的响应式API示例代码放过来的时候:



    啊啊啊!页面一片空白。


    然后发现官网声明中的代码:


    import { useRef, useReactive, useComputed } from 'openinula';

    function ReactiveComponent() {
    const renderCount = ++useRef(0).current;

    const data = useReactive({ count: 0 });
    const countText = useComputed(() => {
    return `计时: ${data.count.get()}`;
    });

    setInterval(() => {
    data.count.set(c => c + 1);
    }, 1000);

    return (
    <div>
    <div>{countText}</div>
    <div>组件渲染次数:{renderCount}</div>
    </div>

    );
    }

    其中 openinula 的 useReactive, useComputed 两个 api 都是 undefined。


    官网 api 文档中全局搜索 useReactive 也无任何讯息。


    Alt text


    好了,我累了,毁灭吧。因为它可能还在 ppt 里或者还没有发布。


    然后我就开始相关如何结束本文,如何思考如何更中立一些,应放入哪些参考材料。


    比如我准备去找代码仓库、去找 npm 官方以及其他镜像仓库来大概看看 openinula 的下载量时:



    这是什么情况?



    • 版本 0.0.1,总历史版本 2个 -- 代表很新很新。难道是内网发布?

    • 周下载量 65 -- 代表 npm 上很少很少人用。 说好的支撑上千万行前端项目呢?难道是内网人用?

    • 代码仓库为空 -- ???

    • readem 为空 -- ???


    有一说一,如果就上面这样子的话,真的是一点都不像官网所描述的光环拉满的样子。


    真的,说实话到这里我的失望值已经累计到 0.2 了。


    但是我真的还想再挣扎一下:难道是因为这是国产框架,所以大都使用国内镜像,所以 npm 上的下载量少?


    然后去大家都在用的淘宝镜像上看了一下:



    我再次被震惊了,请看图片左边是淘宝镜像,为 0.0.4 版,右边是 npm 惊喜,为 0.0.1 版本。大家都知道,通常都是 taobao 镜像比 npm 仓库更新要慢一些。但 openinula 在这里怎么就直接遥遥领先了 3 个版本?



    虽然不理解,但大受震撼。不过,如果这是假的,我希望这是真的(因为我可以试试新的0.0.4版本)。如果这是真的,我希望这是假的,因为这太反常了。


    image-5.png


    所以,那就再试试淘宝镜像上的 0.0.4 是不是遥遥领先,吊打传统虚拟 dom hook api 吧。


    代码实践二


    这下我们直接:


    nrm use taobao
    npm i openinula@latest
    npm run start


    然后仍然是:



    Alt text


    页面一片空白,直接报错:jsx-dev-runtime 404 。



    注意看

    注意看

    注意看:上图浏览器网络控制台。



    真的,我累了,毁灭吧。


    结论


    确实在官网上,传统 Hook API 一栏中组件渲染次数是动态改变的,而在响应式 API声称响应式 API 避免重复运行组件,无需对比虚拟DOM树,可精准更新DOM,大幅提升网页性能。中组件渲染次数是写死的


    但是,这么做的原因到底是什么,有有意为之,还是不小心写错了?就得继续等待后续了。


    我斗胆猜测一下,有几个可能:


    一:还没有准备好


    虽然从发布会上和官网上来看,光环拉满,但从已发布的包、仓库、文档来看,还亟待完善……


    二:失误


    失误就是,真的那个地方是要再加个 t,让其出现动态渲染的效果。不过我尝试修复了这个问题,把 t 加上,也发现只是简单的定时器,所以应该不是少加个 t 的事情。




    注意看

    注意看

    注意看上面的动图中,响应式 API 真的动起来了!



    那种有没有可能是,这个地方是真的想做个动态渲染效果比较,但还没做出来?


    另外,根据官方代码仓库中的源码(是活跃分支,最近提交2小时前)看来:


    readme 中的开发工具都还是随手写的错误网址:


    Alt text


    package.json 声明当前版本为 0.0.1 (那 taobao 上的 0.0.4 是怎么回事)。
    Alt text


    三:有意为之


    在不少关于 Inula 的文章,以及发表大会中,都是以响应式、区别传统虚拟DOM、提高效率号称的。并且代码也是开源的,如果是如实开源的情况下,到底效果是不是如官网所说,大佬们一挖代码便知。虽然目前官网也没有提供详细评测仓库,或三方评测结果。


    可能真是有那么强的,并且也是有意在官网中用渲染次数1来体现效果以达到视觉要求,但没想到有些程序员非要扒代码看说这个地方一定要写个实例动态渲染出来。


    参考



    作者:程序媛李李李李李蕾
    来源:juejin.cn/post/7307125383786119209
    收起阅读 »

    现代化 Android 开发:基础架构

    Android 开发经过 10 多年的发展,技术在不断更迭,软件复杂度也在不断提升。到目前为止,虽然核心需求越来越少,但是对开发速度的要求越来越高。高可用、流畅的 UI、完善的监控体系等都是现在的必备要求了。国内卷的方向又还包括了跨平台、动态化、模块化。 目前...
    继续阅读 »

    Android 开发经过 10 多年的发展,技术在不断更迭,软件复杂度也在不断提升。到目前为止,虽然核心需求越来越少,但是对开发速度的要求越来越高。高可用、流畅的 UI、完善的监控体系等都是现在的必备要求了。国内卷的方向又还包括了跨平台、动态化、模块化。


    目前的整体感觉就是,移动开发基本是奄奄一息了。不过也不用过于悲观:一是依旧有很多存量的 App 堪称屎山,是需要有维护人员的,就跟现在很多人去卷 framework 层一样,千万行代码中找 bug。 二是 AI 日益成熟,那么应用层的创新也会出现,在没有更简洁的设备出现前,手机还是主要载体,总归是需要移动开发去接入的,如果硬件层越来越好,模型直接跑在手机上也不是不可能,所以对跨平台技术也会是新一层的考验,有可能直接去跨平台化了。毕竟去中台化也成了历史的选择。


    因而,在这个存量市场,虽然竞争压力很大,但是如果技术过硬,还是能寻求一席之地的。因而我决定用几篇文章来介绍下,当前我认为的现代化 Android 开发是怎样的。其目录为:



    • 现代化 Android 开发:基础架构(本文)

    • 现代化 Android 开发:数据类

    • 现代化 Android 开发:逻辑层

    • 现代化 Android 开发:组件化与模块化的抉择

    • 现代化 Android 开发:多 Activity 多 Page 的 UI 架构

    • 现代化 Android 开发:Jetpack Compose 最佳实践

    • 现代化 Android 开发:性能监控


    Scope


    提到 Android 基础架构,大家可能首先想到的是 MVCMVPMVVMMVI 等分层架构。但针对现代化的 Android 开发,我们首要有的是 scope 的概念。其可以分两个方面:



    • 结构化并发之 CoroutineScope:目前协程基本已经是最推荐的并发工具了,CoroutineScope 的就是对并发任务的管理,例如 viewModelScope 启动的任务的生命周期就小于 viewModel 的存活周期。

    • 依赖注入之 KoinScope:虽然官方推荐的是 hilt,但其实它并没有 koin 好用与简洁,所以我还是推荐 koinKoinScope 是对实例对象的管理,如果 scope 结束, 那么 scope 管理的所有实例都被销毁。


    一般应用总会有登录,所以大体的 scope 管理流程图是这样的:


    scope



    • 我们启动 app, 创建 AppScope,对于 koin 而言就是用于存放单例,对于协程来说就是全局任务

    • 当我们登录后,创建 AuthSessionScope, 对于 koin 而言,就是存放用户相关的单例,对于协程而言就是用户执行相关的任务。当退出登录时,销毁当前的 AuthSessionScope,那么其对应的对象实例、任务全部都会被销毁。用户再次登录,就再次重新创建 AuthSessionScope。目前很多 App 对于用户域内的实例,基本上还是用单例来实现,退出登录时,没得办法,就只能杀死整个进程再重启, 所以会有黑屏现象,实现不算优雅。而用 scope 管理后,就是一件很自然而实现的事情了。所以尽量用依赖注入,而不要用单例模式

    • 当我们进入界面后,一般都是从逻辑层获取数据进行渲染,所以依赖注入没多大用了。而协程的 lifecycleScopeviewModelScope 就比较有用,管理界面相关的异步任务。


    所以我们在做架构、做某些业务时,首要考虑 scope 的问题。我们可以把 CoroutineScope 也作为实例存放到 KoinScope 里,也可以把 KoinScope 作为 Context 存放到 CorutineScope 里。


    岐黄小筑是将 CoroutineScope 放到 koin 里去以便依赖查找


    val sessionCoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob() + coroutineLogExceptionHandler(TAG))
    val sessionKoinScope = GlobalContext.get().createScope(...)
    sessionKoinScope.declare(sessionCoroutineScope)


    其实我们也完全可以用 CoroutineScopeContext 来做实例管理,而移除 koin 的使用。但是 Context 的使用并没有那么便捷,或许以后它可以进化为完全取代 koin



    架构分层


    随着软件复杂度的提升,MVCMVPMVVMMVI 等先后被提出,但我觉得目前所有的开发,都大体遵循某一模式而又不完全遵循,很容易因为业务的节奏,很容易打破,变成怎么方便怎么来。所以使用简单的分层 + 足够优秀的组件化,才是保证开发模式不被打破的最佳实践。下图是岐黄小筑的整体架构图:



    整体架构不算复杂,其实重点是在于组件库,emo 已经有 20 个子库了,然后岐黄小筑有一些对于通用逻辑的抽象与封装,使得逻辑层虽然都集中在 logic 层,但整体都是写模板式的代码,可以面向 copy-paste 编程。


    BookLogic 为例:



    // 通过依赖注入传参, 拿到 db 层、网络层、以及用户态信息的应用
    class BookLogic(
    val authSession: AuthSession,
    val kv: EmoKV,
    val db: AccountDataBase,
    private val bookApi: BookApi
    ) {
    // 并发请求复用管理
    private val concurrencyShare = ConcurrencyShare(successResultKeepTime = 10 * 1000L)

    // 加载书籍信息,使用封装好的通用请求组件
    fun logicBookInfo(bookId: Int, mode: Int = 0) = logic(
    scope = authSession.coroutineScope, // 使用用户 session 协程 scope,因为有请求复用,所以退出界面,再进入,会复用之前的网络请求
    mode = mode,
    dbAction = { // 从 db 读取本地数据
    db.bookDao().bookInfo(bookId)
    },
    syncAction = { // 从网络同步数据
    concurrencyShare.joinPreviousOrRun("syncBookInfo-$bookId") {
    bookApi.bookInfo(bookId).syncThen { _, data ->
    db.runInTransaction {
    db.userDao().insert(data.author)
    db.bookDao().insert(data.info)
    }
    SyncRet.Full
    }
    }
    }
    )
    // 类似的模板代码
    suspend fun logicBookClassicContent(bookId: Int, mode: Int = 0) = logic(...)
    suspend fun logicBookExpoundContent(bookId: Int, mode: Int = 0) = logic(...)
    ...
    }

    //将其注册到 `module` 中去,目前好像也可以通过注解的方式来做,不过我还没采用那种方式:
    scopedOf(::BookLogic)

    ViewModel 层浮层从 Logic 层读取数据,并可以进行特殊化处理:


    class BookInfoViewModel(navBackStackEntry: NavBackStackEntry) : ViewModel() {
    val bookId = navBackStackEntry.arguments?.getInt(SchemeConst.ARG_BOOK_ID) ?: throw RuntimeException("book_id is required!.")

    val bookInfoFlow = MutableStateFlow(logicResultLoading<BookInfoPojo>())

    init {
    viewModelScope.launch {
    runInBookLogic {
    logicBookInfo(bookId, mode).collectLatest {
    bookInfoFlow.emit(it)
    }
    }
    }
    }
    }

    Compose 界面再使用 ViewModel


    @ComposeScheme(
    action = SchemeConst.ACTION_BOOK_INFO,
    alternativeHosts = [BookActivity::class]
    )

    @SchemeIntArg(name = SchemeConst.ARG_BOOK_ID)
    @Composable
    fun BookInfoPage(navBackStackEntry: NavBackStackEntry) {
    LogicPage(navBackStackEntry = navBackStackEntry) {
    val infoVm = schemeActivityViewModel<BookInfoViewModel>(navBackStackEntry)
    val detailVm = schemeViewModel<BookDetailViewModel>(navBackStackEntry)
    val bookInfo by infoVm.bookInfoFlow.collectAsStateWithLifecycle()
    //...
    }
    }

    这样整个数据流从网络加载、到存储到数据库、到传递给 UI 进行渲染的整个流程就结束了。


    对于其中更多的细节,例如逻辑层具体是怎么封装的?UI 层具体是怎么使用多 ActivityPage?可以期待下之后的文章。


    作者:古哥E下
    来源:juejin.cn/post/7240636320762593338
    收起阅读 »

    Android - 你可能需要这样一个日志库

    前言 目前大多数库api设计都是Log.d("tag", "msg")这种风格,而且支持自定义日志存储的比较少, 所以作者想自己造一个轮子。 这种api风格有什么不好呢? 首先,它的tag是一个字符串,需要开发人员严格管理tag,要不然可能各种硬编码的tag满...
    继续阅读 »

    前言


    目前大多数库api设计都是Log.d("tag", "msg")这种风格,而且支持自定义日志存储的比较少,
    所以作者想自己造一个轮子。


    这种api风格有什么不好呢?


    首先,它的tag是一个字符串,需要开发人员严格管理tag,要不然可能各种硬编码的tag满天飞。


    另外,它也可能导致性能陷阱,假设有这么一段代码:


    // 打印一个List
    Log.d("tag", list.joinToString())

    此处使用Debug打印日志,生产模式下调高日志等级,不打印这一行日志,但是list.joinToString()这一行代码仍然会被执行,有可能导致性能问题。


    下文会分析作者期望的api是什么样的,本文演示代码都是用kotlin,库中好用的api也是基于kotlin特性来实现的。


    作者写库有个习惯,对外开放的类或者全局方法都会加一个前缀f,一个是为了避免命名冲突,另一个是为了方便代码检索,以下文章中会出现,这里做一下解释。


    期望


    什么样的api才能解决上面的问题呢?我们看一下方法的签名和打印方式


    inline fun <reified T : FLogger> flogD(block: () -> Any)

    interface AppLogger : FLogger

    flogD {
    list.joinToString { it }
    }

    flogD方法打印Debug日志,传一个Flogger的子类AppLogger作为日志标识,同时传一个block来返回要打印的日志内容。


    日志标识是一个类或者接口,所以管理方式比较简单不会造成tag混乱的问题,默认tag是日志标识类的短类名。生产模式下调高日志等级后,block就不会被执行了,避免了可能的性能问题。


    实现分析


    日志库的完整实现已经写好了,放在这里xlog



    • 支持限制日志大小,例如限制每天只能写入10MB的日志

    • 支持自定义日志格式

    • 支持自定义日志存储,即如何持久化日志


    这一节主要分析一下实现过程中遇到的问题。


    问题:如果App运行期间日志文件被意外删除了,怎么处理?


    在Android中,用java.io的api对一个文件进行写入,如果文件被删除,继续写入的话不会抛异常,导致日志丢失,该如何解决?


    有同学说,在写入之前先检查文件是否存在,如果存在就继续写入,不存在就创建后写入。


    检查一个文件是否存在通常是调用java.io.File.exist()方法,但是它比较耗性能,我们来做一个测试:


    measureTime {
    repeat(1_0000) {
    file.exists()
    }
    }.let {
    Log.i("MainActivity", "time:${it.inWholeMilliseconds}")
    }

    14:50:33.536 MainActivity            com.sd.demo.xlog                I  time:39
    14:50:35.872 MainActivity com.sd.demo.xlog I time:54
    14:50:38.200 MainActivity com.sd.demo.xlog I time:43
    14:50:40.028 MainActivity com.sd.demo.xlog I time:53
    14:50:41.693 MainActivity com.sd.demo.xlog I time:58

    可以看到1万次调用的耗时在50毫秒左右。


    我们再测试一下对文件写入的耗时:


    val output = filesDir.resolve("log.txt").outputStream().buffered()
    val log = "1".repeat(50).toByteArray()
    measureTime {
    repeat(1_0000) {
    output.write(log)
    output.flush()
    }
    }.let {
    Log.i("MainActivity", "time:${it.inWholeMilliseconds}")
    }

    14:57:56.092 MainActivity            com.sd.demo.xlog                I  time:38
    14:57:56.558 MainActivity com.sd.demo.xlog I time:57
    14:57:57.129 MainActivity com.sd.demo.xlog I time:57
    14:57:57.559 MainActivity com.sd.demo.xlog I time:46
    14:57:58.054 MainActivity com.sd.demo.xlog I time:54

    可以看到1万次调用,每次写入50个字符的耗时也在50毫秒左右。如果每次写入日志前都判断一下文件是否存在,那么实际上相当于2次写入的性能成本,这显然很不划算。


    还有同学说,开一个线程,定时判断文件是否存在,这样子虽然不会损耗单次写入的性能,但是又多占用了一个线程资源,显然也不符合作者的需求。


    其实Android已经给我们提供了这种场景的解决方案,那就是android.os.MessageQueue.IdleHandler,关于IdleHandler这里就不展开讨论了,简单来说就是当你注册一个IdleHandler后,它会在主线程空闲的时候被执行。


    我们可以在每次写入日志之后注册IdleHandler,等IdleHandler被执行的时候检查一下日志文件是否存在,如果不存在就关闭输出流,这样子在下一次写入的时候就会重新创建文件写入了。


    这里要注意每次写入日志之后注册IdleHandler,并不是每次都创建新对象,要判断一下如果原先的对象还未执行的话就不用注册一个新的IdleHandler,库中大概的代码如下:


    private class LogFileChecker(private val block: () -> Unit) {
    private var _idleHandler: IdleHandler? = null

    fun register(): Boolean {
    // 如果当前线程没有Looper则不注册,上层逻辑可以直接检查文件是否存在,因为是非主线程
    Looper.myLooper() ?: return false

    // 如果已经注册过了,直接返回
    _idleHandler?.let { return true }

    val idleHandler = IdleHandler {
    // 执行block检查任务
    libTryRun { block() }

    // 重置变量,等待下次注册
    _idleHandler = null
    false
    }

    // 保存并注册idleHandler
    _idleHandler = idleHandler
    Looper.myQueue().addIdleHandler(idleHandler)
    return true
    }
    }

    这样子文件被意外删除之后,就可以重新创建写入了,避免丢失大量的日志。


    问题:如何检测文件大小是否溢出


    库支持对每天的日志大小做限制,例如限制每天最多只能写入10MB,每次写入日志之后都会检查日志大小是否超过限制,通常我们会调用java.io.File.length()方法获取文件的大小,但是它也比较耗性能,我们来做一个测试:


    val file = filesDir.resolve("log.txt").apply {
    this.writeText("hello")
    }
    measureTime {
    repeat(1_0000) {
    file.length()
    }
    }.let {
    Log.i("MainActivity", "time:${it.inWholeMilliseconds}")
    }

    16:56:04.090 MainActivity            com.sd.demo.xlog                I  time:61
    16:56:05.329 MainActivity com.sd.demo.xlog I time:80
    16:56:06.382 MainActivity com.sd.demo.xlog I time:72
    16:56:07.496 MainActivity com.sd.demo.xlog I time:79
    16:56:08.591 MainActivity com.sd.demo.xlog I time:78

    可以看到耗时在60毫秒左右,相当于上面测试中1次文件写入的耗时。


    库中支持自定义日志存储,在日志存储接口中定义了size()方法,上层通过此方法来判断当前日志的大小。


    如果开发者自定义了日志存储,避免在此方法中每次调用java.io.File.length()来返回日志大小,应该维护一个表示日志大小的变量,变量初始化的时候获取一下java.io.File.length(),后续通过写入的数量来增加这个变量的值,并在size()方法中返回。库中默认的日志存储实现类就是这样实现的,有兴趣的可以看这里


    问题:文件大小溢出后怎么处理?


    假设限制每天最多只能写入10MB,那超过10MB后如何处理?有同学说直接删掉或者清空文件,重新写入,这也是一种策略,但是会丢失之前的所有日志。


    例如白天写了9.9MB,到晚上的时候写满10MB,清空之后,白天的日志都没了,这时候用户反馈白天遇到的一个bug,需要上传日志,那就芭比Q了。


    有没有办法少丢失一些呢?可以把日志分多个文件存储,为了便于理解假设分为2个文件存储,一天10MB,那1个文件最多只能写入5MB。具体步骤如下:



    1. 写入文件20231128.log

    2. 20231128.log写满5MB的时候关闭输出流,并把它重命名为20231128.log.1


    这时候继续写日志的话,发现20231128.log文件不存在就会创建,又跳到了步骤1,就这样一直重复1和2两个步骤,到晚上写满10MB的时候,至少还有5MB的日志内容保存在20231128.log.1文件中避免丢失全部的日志。


    分的文件数量越多,保留的日志就越多,实际上就是拿出一部分空间当作中转区,满了就向后递增数字重命名备份。目前库中只分为2个文件存储,暂时不开放自定义文件数量。


    问题:打印日志的性能


    性能,是这个库最关心的问题,通常来说文件写入操作是性能开销的大头,目前是用java.io相关的api来实现的,怎样提高写入性能作者也一直在探索,在demo中提供了一个基于内存映射的日志存储方案,但是稳定性未经测试,后续测试通过后可能会转正。有兴趣的读者可以看看这里


    还有一个比较影响性能的就是日志的格式化,通常要把一个时间戳转为某个日期格式,大部分人都会用java.text.SimpleDateFormat来格式化,用它来格式化年:月:日的时候问题不大,但是如果要格式化时:分:秒.毫秒那它就比较耗性能,我们来做一个测试:


    val format = SimpleDateFormat("HH:mm:ss.SSS")
    val millis = System.currentTimeMillis()
    measureTime {
    repeat(1_0000) {
    format.format(millis)
    }
    }.let {
    Log.i("MainActivity", "time:${it.inWholeMilliseconds}")
    }

    16:05:26.920 MainActivity            com.sd.demo.xlog                I  time:245
    16:05:27.586 MainActivity com.sd.demo.xlog I time:227
    16:05:28.324 MainActivity com.sd.demo.xlog I time:212
    16:05:29.370 MainActivity com.sd.demo.xlog I time:217
    16:05:30.157 MainActivity com.sd.demo.xlog I time:193

    可以看到1万次格式化耗时大概在200毫秒左右。


    我们再用java.util.Calendar测试一下:


    val calendar = Calendar.getInstance()
    // 时间戳1
    val millis1 = System.currentTimeMillis()
    // 时间戳2
    val millis2 = millis1 + 1000
    // 切换时间戳标志
    var flag = true
    measureTime {
    repeat(1_0000) {
    calendar.timeInMillis = if (flag) millis1 else millis2
    calendar.run {
    "${get(Calendar.HOUR_OF_DAY)}:${get(Calendar.MINUTE)}:${get(Calendar.SECOND)}.${get(Calendar.MILLISECOND)}"
    }
    flag = !flag
    }
    }.let {
    Log.i("MainActivity", "time:${it.inWholeMilliseconds}")
    }

    16:11:25.342 MainActivity            com.sd.demo.xlog                I  time:35
    16:11:26.209 MainActivity com.sd.demo.xlog I time:35
    16:11:27.316 MainActivity com.sd.demo.xlog I time:37
    16:11:28.057 MainActivity com.sd.demo.xlog I time:25
    16:11:28.825 MainActivity com.sd.demo.xlog I time:18


    这里解释一下为什么要用两个时间戳,因为Calendar内部有缓存,如果用同一个时间戳测试的话,没办法评估它真正的性能,所以这里每次格式化之后就切换到另一个时间戳,避免缓存影响测试。


    可以看到1万次的格式化耗时在30毫秒左右,差距很大。如果要自定义日志格式的话,建议用Calendar来格式化时间,有更好的方案欢迎和作者交流。


    问题:日志的格式如何显示


    手机的存储资源是宝贵的,如何定义日志格式也是一个比较重要的细节。



    • 优化时间显示


    目前库内部是以天为单位来命名日志文件的,例如:20231128.log,所以在格式化时间戳的时候只保留了时:分:秒.毫秒,避免冗余显示当天的日期。



    • 优化日志等级显示


    打印的时候提供了4个日志等级:Verbose, Debug, Info, Warning, Error,一般最常用的记录等级是Info,所以在格式化的时候如果等级是Info则不显示等级标志,规则如下:


    private fun FLogLevel.displayName(): String {
    return when (this) {
    FLogLevel.Verbose -> "V"
    FLogLevel.Debug -> "D"
    FLogLevel.Warning -> "W"
    FLogLevel.Error -> "E"
    else -> ""
    }
    }


    • 优化日志标识显示


    如果连续2条或多条日志都是同一个日志标识,那么就只有第1条日志会显示日志tag



    • 优化线程ID显示


    如果是主线程的话,不显示线程ID,只有非主线程才显示线程ID


    经过上面的优化之后,日志打印的格式是这样的:


    flogI { "1" }
    flogI { "2" }
    flogW { "3" }
    flogI { "user debug" }
    thread {
    flogI { "thread" }
    }

    19:19:43.961[AppLogger] 1
    19:19:43.974 2
    19:19:43.975[W] 3
    19:19:43.976[UserLogger] user debug
    19:19:43.977[12578] thread

    API


    这一节介绍一下库的API


    常用方法


    /**
    * 打开日志,文件保存目录:[Context.getFilesDir()]/flog,
    * 默认只打开文件日志,可以调用[FLog.enableConsoleLog()]方法开关控制台日志,
    */

    FLog.open(
    context = this,

    //(必传参数)日志等级 All, Verbose, Debug, Info, Warning, Error
    level = FLogLevel.All,

    //(可选参数)限制每天日志文件大小(单位MB),小于等于0表示不限制大小,默认100MB
    limitMBPerDay = 100,

    //(可选参数)自定义日志格式
    formatter = AppLogFormatter(),

    //(可选参数)自定义日志存储
    storeFactory = AppLogStoreFactory(),
    )


    // 是否打打印控制台日志
    FLog.enableConsoleLog(false)


    /**
    * 删除日志,参数saveDays表示要保留的日志天数,小于等于0表示删除全部日志,
    * 此处saveDays=1表示保留1天的日志,即保留当天的日志
    */

    FLog.deleteLog(1)


    // 关闭日志
    FLog.close()

    打印日志


    interface AppLogger : FLogger

    flogV { "Verbose" }
    flogD { "Debug" }
    flogI { "Info" }
    flogW { "Warning" }
    flogE { "Error" }

    // 打印控制台日志,不会写入到文件中,不需要指定日志标识,tag:DebugLogger
    fDebug { "console debug log" }

    配置日志标识


    可以通过FLog.config方法修改某个日志标识的配置信息,例如下面的代码:


    FLog.config {
    // 修改日志等级
    this.level = FLogLevel.Debug

    // 修改tag
    this.tag = "AppLoggerAppLogger"
    }

    自定义日志格式


    class AppLogFormatter : FLogFormatter {
    override fun format(record: FLogRecord): String {
    // 自定义日志格式
    return record.msg
    }
    }

    interface FLogRecord {
    /** 日志标识 */
    val logger: Class<out FLogger>

    /** 日志tag */
    val tag: String

    /** 日志内容 */
    val msg: String

    /** 日志等级 */
    val level: FLogLevel

    /** 日志生成的时间戳 */
    val millis: Long

    /** 日志是否在主线程生成 */
    val isMainThread: Boolean

    /** 日志生成的线程ID */
    val threadID: String
    }

    自定义日志存储


    日志存储是通过FLogStore接口实现的,每一个FLogStore对象负责管理一个日志文件。
    所以需要提供一个FLogStore.Factory工厂为每个日志文件提供FLogStore对象。


    class AppLogStoreFactory : FLogStore.Factory {
    override fun create(file: File): FLogStore {
    return AppLogStore(file)
    }
    }

    class AppLogStore(file: File) : FLogStore {
    // 添加日志
    override fun append(log: String) {}

    // 返回当前日志的大小
    override fun size(): Long = 0

    // 关闭
    override fun close() {}
    }

    结束


    库目前还处于alpha阶段,如果有开发者遇到问题了可以及时反馈给作者,最后感谢大家的阅读。


    作者:Sunday1990
    来源:juejin.cn/post/7306423214493270050
    收起阅读 »

    Android集成Flutter模块经验记录

    记录Android原生项目集成Flutter模块经验,其中不乏一些踩坑,也是几番查找资料之后才成功运用于实际开发。 主要为了记录,将使用简洁的描述。 Flutter开发环境 1. Flutter安装和环境配置 官方文档:flutter.cn/docs/get-...
    继续阅读 »

    记录Android原生项目集成Flutter模块经验,其中不乏一些踩坑,也是几番查找资料之后才成功运用于实际开发。

    主要为了记录,将使用简洁的描述。


    Flutter开发环境


    1. Flutter安装和环境配置


    官方文档:flutter.cn/docs/get-st…

    参照官方文档一步步按步骤即可

    下载SDK->解压->配置PATH环境变量

    其中配置PATH环境变量务必使其永久生效方式


    2. AS安装flutter和dart插件


    AS安装flutter和dart插件


    将 Flutter module 集成到 Android 项目


    官方文档:flutter.cn/docs/add-to…

    仍然主要是参照官方文档。

    有分为使用AS集成和不使用AS集成,其中使用AS集成有AAR集成和使用模块源码集成两种方式。



    • AAR 集成: AAR 机制可以为每个 Flutter 模块创建 Android AAR 作为依赖媒介。当你的宿主应用程序开发者不想安装 Flutter SDK 时,这是一个很好方案。但是每次修改都需要重新编译。

    • 模块源码集成:直接将 Flutter 模块的源码作为子项目的依赖机制是一种便捷的一键式构建方案,但此时需要另外安装 Flutter SDK,这是目前 Android Studio IDE 插件使用的机制。


    本文讲述的是使用模块源码集成的方式。


    1.创建Flutter Module


    使用File > New > New Flutter Project创建,选择Module,官方建议Flutter Module和Android项目在同一个目录下。
    创建Flutter Module


    2. 配置Module


    在Android项目的 settings.gradle中添加以下配置:flutter_module为创建的flutter module名称


    // Include the host app project.
    include ':app' // assumed existing content
    setBinding(new Binding([gradle: this])) // new
    evaluate(new File( // new
    settingsDir.parentFile, // new
    'flutter_module/.android/include_flutter.groovy' // new
    )) // new

    在应用中引入对 Flutter 模块的依赖:


    dependencies {
    implementation project(':flutter')
    }

    3. 编译失败报错:Failed to apply plugin class 'FlutterPlugin'


    gradle6.8后 在settings.gradle的dependencyResolutionManagement 下新增了如下配置:


    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)


    RepositoriesMode配置在构建中仓库如何设置,总共有三种方式:

    FAIL_ON_PROJECT_REPOS

    表示如果工程单独设置了仓库,或工程的插件设置了仓库,构建就直接报错抛出异常

    PREFER_PROJECT

    表示如果工程单独设置了仓库,就优先使用工程配置的,忽略settings里面的

    PREFER_SETTINGS

    表述任何通过工程单独设置或插件设置的仓库,都会被忽略



    settings.gradle里配置的是FAIL_ON_PROJECT_REPOS,Flutter插件又单独设置了repository,所以会构建报错,因此需要把FAIL_ON_PROJECT_REPOS改成PREFER_PROJECT。


    因为gradle调整,Android仓库配置都在settings.gradle中,但是因为设置了PREFER_PROJECT,settings.gradle被忽略了,那该怎么解决呢?发现虽然project的gradle文件虽然调整了,但是依然可以跟之前一样配置仓库这些,于是在项目build.gradle中加上settings.gradle中的所有仓库,成功解决问题并编译安装成功。


    allprojects{
    repositories {
    maven {
    url "https://plugins.gradle.org/m2/"
    }
    maven { url "https://s01.oss.sonatype.org/content/groups/public" }
    maven { url 'https://jitpack.io' }
    google()
    // 极光 fcm, 若不集成 FCM 通道,可直接跳过
    maven { url "https://maven.google.com" }
    maven {
    url 'https://artifact.bytedance.com/repository/pangle'
    }
    }
    }

    总结:需要先将settings.gradle中repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)替换为repositoriesMode.set(RepositoriesMode.PREFER_PROJECT),
    然后在项目build.gradle中添加settings.gradle中的所有仓库。


    添加Flutter页面


    官方文档:flutter.cn/docs/add-to…

    Flutter 提供了 FlutterActivity,用于在 Android 应用内部展示一个 Flutter 的交互界面。需要在清单文件中注册FlutterActivity。


    <activity
    android:name="io.flutter.embedding.android.FlutterActivity"
    android:screenOrientation="portrait"
    />

    然后加载FlutterActivity


    startActivity(
    FlutterActivity.createDefaultIntent(requireContext())
    )

    此外还有withNewEngine、withCachedEngine等多种加载方式,具体可见官方文档。


    添加Flutter视图


    官方文档:flutter.cn/docs/add-to…

    参考官方demo:github.com/flutter/sam…


    创建FlutterViewEngine用以管理FlutterView、FlutterEngine、Activity三者。
    FlutterEngine用以执行dart执行器,"showCell"为dart中方法名,FlutterEngine和FlutterView attach之后,会将"showCell"中生成的ui绘制到FlutterView上。


    val engine = FlutterEngine(BaseApplication.instance)
    engine.dartExecutor.executeDartEntrypoint(
    DartExecutor.DartEntrypoint(
    FlutterInjector.instance().flutterLoader().findAppBundlePath(),
    "showCell"))

    在原生页面里面添加FlutterView还是比较麻烦的,需要开发者自己管理FlutterView、FlutterEngine、Activity三者之间生命周期联系。


    作者:愿天深海
    来源:juejin.cn/post/7306703076337483802
    收起阅读 »

    基于模块暴露和Hilt的Android模块通信方案

    ModuleExpose 项目地址:github.com/JailedBird/… 序言 Android模块化必须要解决的问题是 如何实现模块间通信 ?而模块之间通信往往需要获取相同的实体类和接口,造成部分涉及模块通信的接口和实体类被迫下沉到基础模块,导致 基...
    继续阅读 »

    ModuleExpose


    项目地址:github.com/JailedBird/…


    序言


    Android模块化必须要解决的问题是 如何实现模块间通信 ?而模块之间通信往往需要获取相同的实体类和接口,造成部分涉及模块通信的接口和实体类被迫下沉到基础模块,导致 基础模块代码膨胀、模块代码分散和不便维护等问题;


    ModuleExpose方案使用模块暴露&依赖注入框架Hilt的方式,实现模块间通信:



    • 使用模块暴露(模块api化)解决基础模块下沉问题

    • 使用依赖注入框架Hilt实现基于接口的模块解耦方案


    简介


    ModuleExpose,是将module内部需要暴露的代码通过脚本自动暴露出来;不同于手动形式的接口下沉,ModuleExpose是直接将module中需要暴露的代码完整拷贝到module_expose模块,而module_expose模块的生成、拷贝和配置是由ModuleExpose脚本自动完成,并保证编译时两者代码的完全同步;


    最终,工程中包含如下几类核心模块:




    • 基础模块:基础代码封装,可供任何业务模块使用;




    • 业务模块:包含业务功能,业务模块可以依赖基础模块,但无法依赖其他业务模块(避免循环依赖);




    • 暴露模块:由脚本基于业务模块或基础模块自动拷贝生成,业务模块可依赖其他暴露模块(通过compileOnly方式,只参与编译不参与打包),避免模块通信所需的接口、数据实体类下沉到基础模块,造成基础模块膨胀、业务模块核心类分散到基础模块等问题;




    注意这种方案并非原创,原创出处如下:


    思路原创:微信Android模块化架构重构实践



    先寻找代码膨胀的原因。


    翻开基础工程的代码,我们看到除了符合设计初衷的存储、网络等支持组件外,还有相当多的业务相关代码。这些代码是膨胀的来源。但代码怎么来的,非要放这?一切不合理皆有背后的逻辑。在之前的架构中,我们大量使用Event事件总线作为模块间通信的方式,也基本是唯一的方式。使用Event作为通信的媒介,自然要有定义它的地方,好让模块之间都能知道Event结构是怎样的。这时候基础工程好像就成了存放Event的唯一选择——Event定义被放在基础工程中;接着,遇到某个模块A想使用模块B的数据结构类,怎么办?把类下沉到基础工程;遇到模块A想用模块B的某个接口返回个数据,Event好像不太适合?那就把代码下沉到基础工程吧……


    就这样越来越多的代码很“自然的”被下沉到基础工程中。


    implementation工程提供逻辑的实现。api工程提供对外的接口和数据结构。library工程,则提供该模块的一些工具类。



    项目原创: github/tyhjh/module_api



    如果每次有一个模块要使用另一个模块的接口都把接口和相关文件放到公共模块里面,那么公共模块会越来越大,而且每个模块都依赖了公共模块,都依赖了一大堆可能不需要的东西;


    所以我们可以提取出每个模块提供api的文件放到各种单独的模块里面;比如user模块,我们把公共模块里面的User和UserInfoService放到新的user-api模块里面,这样其他模块使用的时候可以单独依赖于这个专门提供接口的模块,以此解决公共模块膨胀的问题



    本人工作:



    • 使用kts和nio重写脚本,基于性能的考量,对暴露规则和生成方式进行改进;

    • nowinandroid项目编译脚本系统、Ksp版本的Hilt依赖注入框架、示例工程三者结合起来,完善基于 模块暴露&依赖注入框架 的模块解耦示例工程;

    • 将api改名expose(PS:因内部项目使用过之前的api方案,为避免冲突所以改名,也避免和大佬项目名字冲突😘 脚本中亦可自定义关键词)


    术语说明:



    • 部分博客中称这种方式为模块api化,我觉得这是合理的;本文的语境中的expose和api是等价的意思;


    模块暴露


    1、项目启用kts配置


    因为脚本使用kts编写,因此需要在项目中启用kts配置;如因为gradle版本过低等原因导致无法接入kts,那应该是无法使用的;后续默认都开启kts,并使用kts语法脚本;


    2、导入脚本到gradle目录&修改模板


    请拷贝示例工程gradle/expose目录到个人项目gradle目录,拷贝后目录如下:


    Path
    ModuleExpose\gradle

    gradle
    │ libs.versions.toml
    ├─expose
    │ build_gradle_template_android
    │ build_gradle_template_java
    │ build_gradle_template_expose
    │ expose.gradle.kts
    └─wrapper
    gradle-wrapper.jar
    gradle-wrapper.properties

    其中:expose.gradle.kts是模块暴露的核心脚本,包含若干函数和配置参数;


    其中:build_gradle_template_android和build_gradle_template_java脚本模板因项目不同而有所不同,需要自行根据项目修改,否则无法编译;




    • build_gradle_template_android,生成Android模块的脚本模板,注意高版本gradle必须配置namespace,因此最好保留如下的配置(细则见脚本如何处理的):


      android {
      namespace = "%s"
      }



    • build_gradle_template_java, 生成Java模块的脚本模板,配置较为简单;




    • includeWithApi函数使用build_gradle_template_android模板生成Android Library模块




    • includeWithJavaApi函数使用build_gradle_template_java模板生成Java Library模块




    • build_gradle_template_expose,不同于build_gradle_template_android、build_gradle_template_java的模板形式的配置,使用includeWithApi、includeWithJavaApi时,会优先检查模块根目录是否存在build_gradle_template_expose,如果存在则优先、直接将build_gradle_template_expose内容拷贝到module_expose, 作为build.gradle.kts ! 保留这个配置的原因在于:如果需要暴露的类,引用三方类如gson、但不便将三方库implementation到build_gradle_template_android,这会导致module_expose编译报错,因此为解决这样的问题,最好使用自定义module_expose脚本(拷贝module的配置、稍加修改即可)


      PS:注意这几个模板都是无后缀的,kts后缀文件会被IDE提示一大堆东西;




    注意: Java模块编译更快,但是缺少Activity、Context等Android环境,请灵活使用;当然最灵活的方式是为每个module_expose单独配置build_gradle_template_expose (稍微麻烦一点);另外,如果不用includeWithJavaApi,其实build_gradle_template_java也是不需要的;


    3、settings.gradle.kts导入脚本函数


    根目录settings.gradle.kts配置如下:


    apply(from = "$rootDir/gradle/expose/expose.gradle.kts")
    val includeWithApi: (projectPaths: String) -> Unit by extra
    val includeWithJavaApi: (projectPaths: String) -> Unit by extra

    (PS:只要正确启用kts,settings.gradle应该也是可以导入includeWithApi的,但是我没尝试;其次老项目针对ModuleExpose改造kts时,可以渐进式改造,即只改settings.gradle.kts即可)


    4、模块配置


    将需要暴露的模块,在settings.gradle.kts 使用includeWithApi(或includeWithJavaApi)导入;


    includeWithApi(":feature:settings")
    includeWithApi(":feature:search")

    即可自动生成新模块 ${module_expose};然后在模块源码目录下创建名为expose的目录,将需要暴露的文件放在expose目录下, expose目录下的文件即可在新模块中自动拷贝生成;


    生成细则:


    1、 模块支持多个expose目录(递归、含子目录)同时暴露,这可以避免将实体类,接口等全部放在单个expose,看着很乱


    2、 expose内部的文件,默认全部复制,但脚本提供了开关,可以自行更改并配置基于文件名的拷贝过滤;


    5、使用module_expose模块


    请使用 compileOnly 导入项目,如下:


    compileOnly(project(mapOf("path" to ":feature:search_expose")))

    错误:会导致资源冲突


    implementation(project(mapOf("path" to ":feature:search_expose")))

    原理解释:compileOnly只参与编译,不会被打包;implementation参与编译和打包;


    因此search_expose只能使用compileOnly导入,确保解耦的模块之间可以访问到类引用,但不会造成打包时2个类相同的冲突问题;


    依赖注入


    基于模块暴露的相关接口,可以使用依赖注入框架Hilt实现基于接口的解耦; 当然如果大家不使用Hilt技术栈的话,这节可以跳过;


    本节内容会以业务模块search和settings为例,通过代码展示:



    • search模块跳转到settings模块,打开SettingsActivity

    • settings模块跳转到search模块,打开SearchActivity


    PS:关于Hilt的配置和导入,本项目直接沿用nowinandroid工程中build-logic的配置,具体配置和使用请参考本项目和nowinandroid项目;


    1、 基本配置&工程结构:


    image.png


    导入脚本之后,使用includeWithApi导入三个业务模块,各自生成对应的module_expose;


    注意,请将*_expose/添加到gitignore,避免expose模块提交到git


    2、 业务模块接口暴露&实现


    settings模块expose目录下暴露SettingExpose接口, 脚本会自动将其同步拷贝到settings_expose中对应expose目录


    image.png


    exposeimpl/SettingExposeImpl实现SettingExpose接口的具体功能,完善跳转功能


    class SettingExposeImpl @Inject constructor() : SettingExpose {
    override fun startSettingActivity(context: Context) {
    SettingsActivity.start(context)
    }
    }

    3、 Hilt添加注入接口绑定


    使用Hilt绑定全局单例SettingExpose接口实现,其对应实现为SettingExposeImpl


    image.png


    4、 search模块compileOnly导入settings_expose


    compileOnly(projects.feature.settingsExpose)

    注意,模块暴露依赖只能使用compileOnly,保证编译时候能找到对应文件即可;另外projects.feature.settingsExpose这种项目导入方式,需要在settings.gradle.kts启用project类型安全配置;


     enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")

    5、 search注入并使用SettingExpose


    @AndroidEntryPoint
    class SearchActivity : AppCompatActivity() {
    @Inject
    lateinit var settingExpose: SettingExpose

    private val listener = object : AppSettingsPopWindow.Listener {

    override fun settings() {
    settingExpose.startSettingActivity(this@SearchActivity)
    }
    }
    }

    6、 实现解耦


    最终实现【search模块跳转到settings模块,打开SettingsActivity】, 至于【settings模块跳转到search模块,打开SearchActivity】的操作完全一致,不重复叙述了;


    参考资料


    1、思路原创:微信Android模块化架构重构实践


    2、项目原创:github/tyhjh/module_api


    3、脚本迁移:将 build 配置从 Groovy 迁移到 KTS


    4、参考文章:Android模块化设计方案之接口API化


    5、Nowinandroid:github.com/android/now…


    6、Dagger项目:github.com/google/dagg…


    7、Hilt官方教程:developer.android.com/training/de…


    作者:JailedBird
    来源:juejin.cn/post/7305977644499419190
    收起阅读 »

    鸿蒙 akr ui 自定义弹窗实现教程

    前言 各位同学有段时间没有见面 因为一直很忙所以就没有去更新博客。最近有在学习这个鸿蒙的ark ui开发 因为鸿蒙不是发布了一个鸿蒙next的测试版本 明年会启动纯血鸿蒙应用 所以我就想提前给大家写一些博客文章 效果图 具体实现: 1 弹窗部分布局 ...
    继续阅读 »

    前言


    各位同学有段时间没有见面 因为一直很忙所以就没有去更新博客。最近有在学习这个鸿蒙的ark ui开发 因为鸿蒙不是发布了一个鸿蒙next的测试版本 明年会启动纯血鸿蒙应用 所以我就想提前给大家写一些博客文章


    效果图


    image.png


    具体实现:




    • 1 弹窗部分布局




    image.png


    @CustomDialog
    struct CustomDialogExample {
    @Link textValue: string
    @Link inputValue: string
    controller: CustomDialogController
    // 若尝试在CustomDialog中传入多个其他的Controller,以
    // 实现在CustomDialog中打开另一个或另一些CustomDialog,
    // 那么此处需要将指向自己的controller放在最后
    cancel: () => void
    confirm: () => void

    build() {
    Column() {
    Text('改变文本').fontSize(20).margin({ top: 10, bottom: 10 })
    TextInput({ placeholder: '', text: this.textValue }).height(60).width('90%')
    .onChange((value: string) => {
    this.textValue = value
    })
    Text('是否更改文本?').fontSize(16).margin({top:20, bottom: 10 })
    Flex({ justifyContent: FlexAlign.SpaceAround }) {
    Button('取消')
    .onClick(() => {
    this.controller.close()
    this.cancel()
    }).backgroundColor(0xffffff).fontColor(Color.Black)
    Button('确认')
    .onClick(() => {
    this.inputValue = this.textValue
    this.controller.close()
    this.confirm()
    }).backgroundColor(0xffffff).fontColor(Color.Red)
    }.margin({ top:20,bottom: 10 })
    }.height('40%')
    // dialog默认的borderRadius为24vp,如果需要使用border属性,请和borderRadius属性一起使用。
    }
    }

    这边我们使用 Column 嵌套2个 text和一个 Flex 里面在嵌套2个 text来实现 :然后2个回调方法


    控制器实现:


    @Entry
    @Component
    struct CustomDialogUser {
    @State textValue: string = ''
    @State inputValue: string = '点击改变'
    dialogController: CustomDialogController = new CustomDialogController({
    builder: CustomDialogExample({
    cancel: this.onCancel,
    confirm: this.onAccept,
    textValue: $textValue,
    inputValue: $inputValue
    }),
    cancel: this.existApp,
    autoCancel: true,
    alignment: DialogAlignment.Center,
    offset: { dx: 0, dy: -20 },
    gridCount: 4,
    customStyle: false
    })

    // 在自定义组件即将析构销毁时将dialogController置空
    aboutToDisappear() {
    this.dialogController = undefined // 将dialogController置空
    }

    onCancel() {
    console.info('Callback when the first button is clicked')
    }

    onAccept() {
    console.info('Callback when the second button is clicked')
    }

    existApp() {
    console.info('点击退出app ')
    }

    build() {
    Column() {
    Button(this.inputValue)
    .onClick(() => {
    if (this.dialogController != undefined) {
    this.dialogController.open()
    }
    }).backgroundColor(0x317aff)
    }.width('100%').margin({ top: 50})
    }
    }

    我们实现一个控制器容纳再 弹窗的构造方法里面设置 回调和我们的弹窗弹出位置:


    dialogController: CustomDialogController = new CustomDialogController({
    builder: CustomDialogExample({
    cancel: this.onCancel,
    confirm: this.onAccept,
    textValue: $textValue,
    inputValue: $inputValue
    }),
    cancel: this.existApp,
    autoCancel: true,
    alignment: DialogAlignment.Center,
    offset: { dx: 0, dy: -20 },
    gridCount: 4,
    customStyle: false
    })

    在我们button点击后弹出


    build() {
    Column() {
    Button(this.inputValue)
    .onClick(() => {
    if (this.dialogController != undefined) {
    this.dialogController.open()
    }
    }).backgroundColor(0x317aff)
    }.width('100%').margin({ top: 50})
    }

    在自定义组件即将析构销毁时将controller置空


    // 在自定义组件即将析构销毁时将dialogController置空
    aboutToDisappear() {
    this.dialogController = undefined // 将dialogController置空
    }

    完整代码 :






    // xxx.ets
    @CustomDialog
    struct CustomDialogExample {
    @Link textValue: string
    @Link inputValue: string
    controller: CustomDialogController
    // 若尝试在CustomDialog中传入多个其他的Controller,以
    // 实现在CustomDialog中打开另一个或另一些CustomDialog,
    // 那么此处需要将指向自己的controller放在最后
    cancel: () => void
    confirm: () => void

    build() {
    Column() {
    Text('改变文本').fontSize(20).margin({ top: 10, bottom: 10 })
    TextInput({ placeholder: '', text: this.textValue }).height(60).width('90%')
    .onChange((value: string) => {
    this.textValue = value
    })
    Text('是否更改文本?').fontSize(16).margin({top:20, bottom: 10 })
    Flex({ justifyContent: FlexAlign.SpaceAround }) {
    Button('取消')
    .onClick(() => {
    this.controller.close()
    this.cancel()
    }).backgroundColor(0xffffff).fontColor(Color.Black)
    Button('确认')
    .onClick(() => {
    this.inputValue = this.textValue
    this.controller.close()
    this.confirm()
    }).backgroundColor(0xffffff).fontColor(Color.Red)
    }.margin({ top:20,bottom: 10 })
    }.height('40%')
    // dialog默认的borderRadius为24vp,如果需要使用border属性,请和borderRadius属性一起使用。
    }
    }






    @Entry
    @Component
    struct CustomDialogUser {
    @State textValue: string = ''
    @State inputValue: string = '点击改变'
    dialogController: CustomDialogController = new CustomDialogController({
    builder: CustomDialogExample({
    cancel: this.onCancel,
    confirm: this.onAccept,
    textValue: $textValue,
    inputValue: $inputValue
    }),
    cancel: this.existApp,
    autoCancel: true,
    alignment: DialogAlignment.Center,
    offset: { dx: 0, dy: -20 },
    gridCount: 4,
    customStyle: false
    })

    // 在自定义组件即将析构销毁时将dialogController置空
    aboutToDisappear() {
    this.dialogController = undefined // 将dialogController置空
    }

    onCancel() {
    console.info('Callback when the first button is clicked')
    }

    onAccept() {
    console.info('Callback when the second button is clicked')
    }

    existApp() {
    console.info('点击退出app ')
    }

    build() {
    Column() {
    Button(this.inputValue)
    .onClick(() => {
    if (this.dialogController != undefined) {
    this.dialogController.open()
    }
    }).backgroundColor(0x317aff)
    }.width('100%').margin({ top: 50})
    }
    }

    最后总结:


    鸿蒙ark ui 里面的自定义弹窗和我们安卓还有flutter里面的差不多我们学会自定义弹窗理论上那些 警告弹窗 列表选择器弹窗, 日期滑动选择器弹窗 ,时间滑动选择器弹窗 ,文本滑动选择器弹窗 ,我们都是可以自己自定义实现的。这里就不展开讲有兴趣的同学可以自己多花时间研究实现一下,最后呢 希望我都文章能帮助到各位同学工作和学习 如果你觉得文章还不错麻烦给我三连 关注点赞和转发 谢谢


    作者:坚果派_xq9527
    来源:juejin.cn/post/7305983336496496650
    收起阅读 »

    Android Path路径旋转矩阵计算

    一、前言 之前有一篇重点讲了三角形的绕环运动,主要重点内容是将不规则物体构造成一个正方形矩阵,便于计算中点,然后通过圆与切线的垂直关系计算出旋转角度。但实际上这种计算是利用了圆的特性,如果是不规则路径,物体该如何旋转呢 ? 实际上Android提供了一个非常强...
    继续阅读 »

    一、前言


    之前有一篇重点讲了三角形的绕环运动,主要重点内容是将不规则物体构造成一个正方形矩阵,便于计算中点,然后通过圆与切线的垂直关系计算出旋转角度。但实际上这种计算是利用了圆的特性,如果是不规则路径,物体该如何旋转呢 ?


    实际上Android提供了一个非常强大的工具——PathMeasure,可以通过片段计算出运动的向量,通过向量和x轴正方向的夹角的斜率就能计算出旋转角度 (这里就不推导了)。


    二、效果预览



    原理:


    通过PathMeasure测量出position和正切的斜率,注意tan和position都是数组,[0]为x或者x方向,[1]为y或者为y方向,当然tan是带方向的矢量,计算公式是 A = ( x1-x2,y1-y2),这些是PathMeasure计算好的。


    PathMeasure.getPosTan(mPathMeasure.getLength() * fraction, position, tan);

    三、案例


    下面是本篇自行车运行的轨迹


    public class PathMoveView extends View {
    private Bitmap mBikeBitmap;
    // 圆路径
    private Path mPath;
    // 路径测量
    private PathMeasure mPathMeasure;

    // 当前移动值
    private float fraction = 0;
    private Matrix mBitmapMatrix;
    private ValueAnimator animator;
    // PathMeasure 测量过程中的坐标
    private float[] position = new float[2];
    // PathMeasure 测量过程中矢量方向与x轴夹角的的正切值
    private float[] tan = new float[2];
    private RectF rectHolder = new RectF();
    private Paint mDrawerPaint;

    public PathMoveView(Context context) {
    super(context);
    init(context);

    }

    public PathMoveView(Context context, AttributeSet attrs) {
    super(context, attrs);
    init(context);

    }

    public PathMoveView(Context context, AttributeSet attrs, int defStyleAttr) {
    super(context, attrs, defStyleAttr);
    init(context);
    }

    protected void init(Context context) {
    // 初始化 画笔 [抗锯齿、不填充、红色、线条2px]
    mDrawerPaint = new Paint();
    mDrawerPaint.setAntiAlias(true);
    mDrawerPaint.setStyle(Paint.Style.STROKE);
    mDrawerPaint.setColor(Color.WHITE);
    mDrawerPaint.setStrokeWidth(2);

    // 获取图片
    mBikeBitmap = BitmapFactory.decodeResource(context.getResources(), R.mipmap.ic_bike, null);
    // 初始化矩阵
    mBitmapMatrix = new Matrix();

    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    super.onMeasure(widthMeasureSpec, heightMeasureSpec);
    int heightMode = MeasureSpec.getMode(heightMeasureSpec);
    int height = 0;
    if (heightMode == MeasureSpec.UNSPECIFIED) {
    height = (int) dp2px(120);
    } else if (heightMode == MeasureSpec.AT_MOST) {
    height = Math.min(getMeasuredHeight(), getMeasuredWidth());
    } else {
    height = MeasureSpec.getSize(heightMeasureSpec);
    }

    setMeasuredDimension(getMeasuredWidth(), height);
    }

    @Override
    protected void onDraw(Canvas canvas) {

    int width = getWidth();
    int height = getHeight();
    if (width <= 1 || height <= 1) {
    return;
    }

    if (mPath == null) {
    mPath = new Path();
    } else {
    mPath.reset();
    }
    rectHolder.set(-100, -100, 100, 100);

    mPath.moveTo(-getWidth() / 2F, 0);
    mPath.lineTo(-(getWidth() / 2F + 200) / 2F, -400);
    mPath.lineTo(-200, 0);
    mPath.arcTo(rectHolder, 180, 180, false);
    mPath.quadTo(300, -200, 400, 0);
    mPath.lineTo(500, 0);

    if (mPathMeasure == null) {
    mPathMeasure = new PathMeasure();
    mPathMeasure.setPath(mPath, false);
    }

    int saveCount = canvas.save();
    // 移动坐标矩阵到View中间
    canvas.translate(getWidth() / 2F, getHeight() / 2F);

    // 获取 position(坐标) 和 tan(正切斜率),注意矢量方向与x轴的夹角
    mPathMeasure.getPosTan(mPathMeasure.getLength() * fraction, position, tan);

    // 计算角度(斜率),注意矢量方向与x轴的夹角
    float degree = (float) Math.toDegrees(Math.atan2(tan[1], tan[0]));
    int bmpWidth = mBikeBitmap.getWidth();
    int bmpHeight = mBikeBitmap.getHeight();
    // 重置为单位矩阵
    mBitmapMatrix.reset();
    // 旋转单位举证,中心点为图片中心
    mBitmapMatrix.postRotate(degree, bmpWidth / 2, bmpHeight / 2);
    // 将图片中心和移动位置对齐
    mBitmapMatrix.postTranslate(position[0] - bmpWidth / 2,
    position[1] - bmpHeight / 2);


    // 画圆路径
    canvas.drawPath(mPath, mDrawerPaint);
    // 画自行车,使用矩阵旋转方向
    canvas.drawBitmap(mBikeBitmap, mBitmapMatrix, mDrawerPaint);
    canvas.restoreToCount(saveCount);
    }

    public void start() {

    if (animator != null) {
    animator.cancel();
    }
    ValueAnimator valueAnimator = ValueAnimator.ofFloat(0, 1f);
    valueAnimator.setDuration(6000);
    // 匀速增长
    valueAnimator.setInterpolator(new LinearInterpolator());
    valueAnimator.setRepeatCount(ValueAnimator.INFINITE);
    valueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
    @Override
    public void onAnimationUpdate(ValueAnimator animation) {
    // 第一种做法:通过自己控制,是箭头在原来的位置继续运行
    fraction = (float) animation.getAnimatedValue();
    postInvalidate();
    }
    });
    valueAnimator.start();
    this.animator = valueAnimator;
    }

    public void stop() {
    if (animator == null) return;
    animator.cancel();
    }

    public float dp2px(float dp) {
    return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, getResources().getDisplayMetrics());
    }

    }

    缺陷和问题处理:


    从图上我们看到,车轮在路线的地下,这种视觉问题需要不断的修正和偏移才能得到解决,比如一段直线和圆面要分别计算偏移。


    四、总结


    PathMeasure 功能非常强大,可用于一般的在2D游戏中地图路线的计算,因此掌握好路径测量工具,可以方便我们做更多的东西。


    作者:时光少年
    来源:juejin.cn/post/7305235970286370827
    收起阅读 »

    Android自定义控件:一款多特效的智能loadingView

    先上效果图(如果感兴趣请看后面讲解): 1、登录效果展示 2、关注效果展示 1、【画圆角矩形】 画图首先是onDraw方法(我会把圆代码写上,一步一步剖析): 首先在view中定义个属性:private RectF rectf = new RectF();...
    继续阅读 »

    先上效果图(如果感兴趣请看后面讲解):


    1、登录效果展示


    img


    2、关注效果展示


    img


    1、【画圆角矩形】


    画图首先是onDraw方法(我会把圆代码写上,一步一步剖析): 首先在view中定义个属性:private RectF rectf = new RectF();//可以理解为,装载控件按钮的区域


    rectf.left = current_left;
    rectf.top = 0; //(这2点确定空间区域左上角,current_left,是为了后面动画矩形变成等边矩形准备的,这里你可以看成0)
    rectf.right = width - current_left;
    rectf.bottom = height; //(通过改变current_left大小,更新绘制,就会实现了动画效果)
    //画圆角矩形
    //参数1:区域
    //参数2,3:圆角矩形的圆角,其实就是矩形圆角的半径
    //参数4:画笔
    canvas.drawRoundRect(rectf, circleAngle, circleAngle, paint);

    2、【确定控件的大小】


    上面是画圆角,那width和height怎么来呢当然是通过onMeasure;


    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    height = measuredHeight(heightMeasureSpec); //这里是测量控件大小
    width = measureWidth(widthMeasureSpec); //我们经常可以看到我们设置控件wrap_content,match_content或者固定值
    setMeasuredDimension(width, height);
    }

    下面以measureWidth为例:


    private int measureWidth(int widthMeasureSpec) {
    int result;
    int specMode = MeasureSpec.getMode(widthMeasureSpec);
    int specSize = MeasureSpec.getSize(widthMeasureSpec);
    //这里是精准模式,比如match_content,或者是你控件里写明了控件大小
    if (specMode == MeasureSpec.EXACTLY) {
    result = specSize;
    } else {
    //这里是wrap_content模式,其实这里就是给一个默认值
    //下面这段注销代码是最开始如果用户不设置大小,给他一个默认固定值。这里以字体长度来决定更合理
    //result = (int) getContext().getResources().getDimension(R.dimen.dp_150);
    //这里是我设置的长度,当然你写自定义控件可以设置你想要的逻辑,根据你的实际情况
    result = buttonString.length() * textSize + height * 5 / 3;
    if (specMode == MeasureSpec.AT_MOST) {
    result = Math.min(result, specSize);
    }
    }
    return result;
    }

    3、【绘制文字text】


    这里我是用自己的方式实现:当文字长度超过控件长度时,文字需要来回滚动。所以自定义控件因为你需要什么样的功能可以自己去实现(当然这个方法也是在onDraw里,为什么这么个顺序讲,目的希望我希望你能循序渐进的理解,如果你觉得onDraw方代码太杂,你可以用个方法独立出去,你可以跟作者一样用private void drawText(Canvas canvas) {}), //绘制文字的路径(文字过长时,文字来回滚动需要用到)


    private Path textPath = new Path():


    textRect.left = 0;
    textRect.top = 0;
    textRect.right = width;
    textRect.bottom = height; //这里确定文字绘制区域,其实就是控件区域
    Paint.FontMetricsInt fontMetrics = textPaint.getFontMetricsInt();
    //这里是获取文字绘制的y轴位置,可以理解上下居中
    int baseline = (textRect.bottom + textRect.top - fontMetrics.bottom - fontMetrics.top) / 2;
    //这里判断文字长度是否大于控件长度,当然我控件2边需要留文字的间距,所以不是大于width,这么说只是更好的理解
    //这里是当文字内容大于控件长度,启动回滚效果。建议先看下面else里的正常情况
    if ((buttonString.length() * textSize) > (width - height * 5 / 3)) {
    textPath.reset();
    //因为要留2遍间距,以heigh/3为间距
    textPath.moveTo(height / 3, baseline);
    textPath.lineTo(width - height / 3, baseline);
    //这里的意思是文字从哪里开始写,可以是居中,这里是右边
    textPaint.setTextAlign(Paint.Align.RIGHT);
    //这里是以路径绘制文字,scrollSize可以理解为文字在x轴上的便宜量,同时,我的混动效果就是通过改变scrollSize
    //刷新绘制来实现
    canvas.drawTextOnPath(buttonString, textPath, scrollSize, 0, textPaint);
    if (isShowLongText) {
    //这里是绘制遮挡物,因为绘制路径没有间距这方法,所以绘制遮挡物类似于间距方式
    canvas.drawRect(new Rect(width - height / 2 - textSize / 3, 0, width - height / 2, height),paintOval);
    canvas.drawRect(new Rect(height / 2, 0, height / 2 + textSize / 3, height), paintOval);
    //这里有个bug 有个小点-5 因画笔粗细产生
    canvas.drawArc(new RectF(width - height, 0, width - 5, height), -90, 180, true, paintOval);
    canvas.drawArc(new RectF(0, 0, height, height), 90, 180, true, paintOval);
    }

    if (animator_text_scroll == null) {
    //这里是计算混到最右边和最左边的距离范围
    animator_text_scroll = ValueAnimator.ofInt(buttonString.length() * textSize - width + height * 2 / 3,-textSize);
    //这里是动画的时间,scrollSpeed可以理解为每个文字滚动控件外所需的时间,可以做成控件属性提供出去
    animator_text_scroll.setDuration(buttonString.length() * scrollSpeed);
    //设置动画的模式,这里是来回滚动
    animator_text_scroll.setRepeatMode(ValueAnimator.REVERSE);
    //设置插值器,让整个动画流畅
    animator_text_scroll.setInterpolator(new LinearInterpolator());
    //这里是滚动次数,-1无限滚动
    animator_text_scroll.setRepeatCount(-1);
    animator_text_scroll.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
    @Override
    public void onAnimationUpdate(ValueAnimator animation) {
    //改变文字路径x轴的偏移量
    scrollSize = (int) animation.getAnimatedValue();
    postInvalidate();
    }
    });
    animator_text_scroll.start();
    }
    } else {
    //这里是正常情况,isShowLongText,是我在启动控件动画的时候,是否启动 文字有渐变效果的标识,
    //如果是长文字,启动渐变效果的话,如果控件变小,文字内容在当前控件外,会显得很难看,所以根据这个标识,关闭,这里你可以先忽略(同时因为根据路径绘制text不能有间距效果,这个标识还是判断是否在控件2遍绘制遮挡物,这是作者的解决方式,如果你有更好的方式可以在下方留言)
    isShowLongText = false;
    /**
    * 简单的绘制文字,没有考虑文字长度超过控件长度
    * */

    //这里是居中显示
    textPaint.setTextAlign(Paint.Align.CENTER);
    //参数1:文字
    //参数2,3:绘制文字的中心点
    //参数4:画笔
    canvas.drawText(buttonString, textRect.centerX(), baseline, textPaint);
    }

    4、【自定义控件属性】


    "1.0" encoding="utf-8"?>
    <resources>
    <declare-styleable name="SmartLoadingView">
    <attr name="textStr" format="string" />
    <attr name="errorStr" format="string" />
    <attr name="cannotclickBg" format="color" />
    <attr name="errorBg" format="color" />
    <attr name="normalBg" format="color" />
    <attr name="cornerRaius" format="dimension" />
    <attr name="textColor" format="color" />
    <attr name="textSize" format="dimension" />
    <attr name="scrollSpeed" format="integer" />
    declare-styleable>

    resources>

    这里以,文案为例, textStr。比如你再布局种用到app:txtStr="文案内容"。在自定义控件里获取如下:


    public SmartLoadingView(Context context, AttributeSet attrs, int defStyleAttr) {
    super(context, attrs, defStyleAttr);
    //自定义控件的3参方法的attrs就是我们设置自定义属性的关键
    //比如我们再attrs.xml里自定义了我们的属性,
    TypedArray typedArray = getContext().obtainStyledAttributes(attrs, R.styleable.SmartLoadingView);
    //这里是获取用户有没有设置整个属性
    //这里是从用户那里获取有没有设置文案
    String title = typedArray.getString(R.styleable.SmartLoadingView_textStr);
    if (TextUtils.isEmpty(title)){
    //如果获取来的属性是空,那么可以默认一个属性
    //(作者忘记设置了!因为已经发布后期优化,老尴尬了)
    buttonString ="默认文案";
    }else{
    //如果有设置文案
    buttonString = title;
    }

    }

    5、【设置点击事件,启动动画】


    为了点击事件的直观,也可以把处理防止重复点击事件封装在里面


    //这是我自定义登录点击的接口
    public interface LoginClickListener {
    void click();
    }

    public void setLoginClickListener(final LoginClickListener loginClickListener) {
    this.setOnClickListener(new OnClickListener() {
    @Override
    public void onClick(View v) {
    if (loginClickListener != null) {
    //防止重复点击
    if (!isAnimRuning) {
    start();
    loginClickListener.click();
    }

    }
    }
    });
    }

    6、【动画讲解】


    6.1、第一个动画,矩形到正方形,以及矩形到圆角矩形(这里是2个动画,只是同时进行)


    矩形到正方形(为了简化,我把源码一些其他属性去掉了,这样方便理解)


    //其中  default_all_distance = (w - h) / 2;除以2是因为2遍都往中间缩短
    private void set_rect_to_circle_animation() {
    //这是一个属性动画,current_left 会在duration时间内,从0到default_all_distance匀速变化
    //想添加多样化的话 还可以加入插值器。
    animator_rect_to_square = ValueAnimator.ofInt(0, default_all_distance);
    animator_rect_to_square.setDuration(duration);
    animator_rect_to_square.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
    @Override
    public void onAnimationUpdate(ValueAnimator animation) {
    //这里的current_left跟onDraw相关,还记得吗
    //onDraw里的控件区域
    //控件左边区域 rectf.left = current_left;
    //控件右边区域 rectf.right = width - current_left;
    current_left = (int) animation.getAnimatedValue();
    //刷新绘制
    invalidate();
    }
    });

    矩形到圆角矩形。就是从一个没有圆角的变成完全圆角的矩形,当然我展示的时候只有第三个图,最后一个按钮才明显了。


    其他的我直接设置成了圆角按钮,因为我把圆角做成了一个属性。


    还记得onDraw里的canvas.drawRoundRect(rectf, circleAngle, circleAngle, paint);circleAngle就是圆角的半径


    可以想象一下如果全是圆角,那么circleAngle会是多少,当然是height/2;没错吧,所以


    因为我把圆角做成了属性obtainCircleAngle是从xml文件获取的属性,如果不设置,则为0,就没有任何圆角效果


    animator_rect_to_angle = ValueAnimator.ofInt(obtainCircleAngle, height / 2);
    animator_rect_to_angle.setDuration(duration);
    animator_rect_to_angle.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
    @Override
    public void onAnimationUpdate(ValueAnimator animation) {
    //这里试想下如果是一个正方形,刚好是圆形的圆角,那就是一个圆
    circleAngle = (int) animation.getAnimatedValue();
    //刷新绘画
    invalidate();
    }
    });

    2个属性动画做好后,用 private AnimatorSet animatorSet = new AnimatorSet();把属性动画加进去,可以设置2个动画同时进行,还是先后顺序 这里是同时进行所用用with


    animatorSet
    .play(animator_rect_to_square).with(animator_rect_to_angle);

    6.2、变成圆形后,有一个loading加载动画


    这里就是画圆弧,只是不断改变,圆弧的起始点和终点,最终呈现loading状态,也是在onDraw里


    //绘制加载进度
    if (isLoading) {
    //参数1:绘制圆弧区域
    //参数2,3:绘制圆弧起始点和终点
    canvas.drawArc(new RectF(width / 2 - height / 2 + height / 4, height / 4, width / 2 + height / 2 - height / 4, height / 2 + height / 2 - height / 4), startAngle, progAngle, false, okPaint);

    //这里是我通过实践,实现最佳loading动画
    //当然这里有很多方式,因为我自定义这个view想把所有东西都放在这个类里面,你也可以有你的方式
    //如果有更好的方式,欢迎留言,告知我一下
    startAngle += 6;
    if (progAngle >= 270) {
    progAngle -= 2;
    isAdd = false;
    } else if (progAngle <= 45) {
    progAngle += 6;
    isAdd = true;
    } else {
    if (isAdd) {
    progAngle += 6;
    } else {
    progAngle -= 2;
    }
    }
    //刷新绘制,这里不用担心有那么多刷新绘制,会不会影响性能
    //
    postInvalidate();
    }

    6.3、loading状态,到打勾动画


    那么这里首先要把loading动画取消,那么直接改变isLoading=false;不会只它同时启动打勾动画;打勾动画的动画,这里比较麻烦,也是我在别人自定义动画里学习的,通过PathMeasure,实现路径动画


    /**
    * 路径--用来获取对勾的路径
    */

    private Path path = new Path();
    /**
    * 取路径的长度
    */

    private PathMeasure pathMeasure;

    //初始化打勾动画路径;
    private void initOk() {
    //对勾的路径
    path.moveTo(default_all_distance + height / 8 * 3, height / 2);
    path.lineTo(default_all_distance + height / 2, height / 5 * 3);
    path.lineTo(default_all_distance + height / 3 * 2, height / 5 * 2);
    pathMeasure = new PathMeasure(path, true);
    }

    //初始化打勾动画
    private void set_draw_ok_animation() {
    animator_draw_ok = ValueAnimator.ofFloat(1, 0);
    animator_draw_ok.setDuration(duration);
    animator_draw_ok.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
    @Override
    public
    void onAnimationUpdate(ValueAnimator animation) {
    startDrawOk = true;
    isLoading = false;
    float value = (Float) animation.getAnimatedValue();
    effect = new DashPathEffect(new float[]{pathMeasure.getLength(), pathMeasure.getLength()}, value * pathMeasure.getLength());
    okPaint.setPathEffect(effect);
    invalidate();

    }
    });
    }

    //启动打勾动画只需要调用
    animator_draw_ok.start();

    onDraw里绘制打勾动画


    //绘制打勾,这是onDraw的,startDrawOk是判断是否开启打勾动画的标识
    if (startDrawOk) {
    canvas.drawPath(path, okPaint);
    }

    6.4、loading状态下回到失败样子(有点类似联网失败了)


    之前6.1提到了矩形到圆角矩形和矩形到正方形的动画,


    那么这里只是前面2个动画反过来,再加上联网失败的文案,和联网失败的背景图即刻


    6.5、loading状态下启动扩散全屏动画(重点)


    这里我通过loginSuccess里参数的类型启动不同效果:


    1、启动扩散全屏动画
    public void loginSuccess(Animator.AnimatorListener endListener) {}

    2、启动打勾动画
    public void loginSuccess(AnimationOKListener animationOKListener) {}

    启动扩散全屏是本文的重点,里面还涉及到了一个自定义view


    CirclBigView,这个控件是全屏的,而且是从一个小圆不断改变半径变成大圆的动画,那么有人会问,全屏肯定不好啊,会影响布局,
    但是这里,我把它放在了activity的视图层:
    ViewGr0up activityDecorView = (ViewGr0up) ((Activity) getContext()).getWindow().getDecorView();
    ViewGr0up.LayoutParams layoutParams = new ViewGr0up.LayoutParams(ViewGr0up.LayoutParams.MATCH_PARENT, ViewGr0up.LayoutParams.MATCH_PARENT);
    activityDecorView.addView(circlBigView, layoutParams);

    这个灵感也是前不久在学习微信,拖拽退出的思路里发现的。全部代码如下:


    public void toBigCircle(Animator.AnimatorListener endListener) {
    //把缩小到圆的半径,告诉circlBigView
    circlBigView.setRadius(this.getMeasuredHeight() / 2);
    //把当前背景颜色告诉circlBigView
    circlBigView.setColorBg(normal_color);
    int[] location = new int[2];
    //测量当前控件所在的屏幕坐标x,y
    this.getLocationOnScreen(location);
    //把当前坐标告诉circlBigView,同时circlBigView会计算当前点,到屏幕4个点的最大距离,即是当前控件要扩散到的半径
    //具体建议读者看完本博客后,去下载玩耍下。
    circlBigView.setXY(location[0] + this.getMeasuredWidth() / 2, location[1]);
    if (circlBigView.getParent() == null) {
    ViewGr0up activityDecorView = (ViewGr0up) ((Activity) getContext()).getWindow().getDecorView();
    ViewGr0up.LayoutParams layoutParams = new ViewGr0up.LayoutParams(ViewGr0up.LayoutParams.MATCH_PARENT, ViewGr0up.LayoutParams.MATCH_PARENT);
    activityDecorView.addView(circlBigView, layoutParams);
    }
    circlBigView.startShowAni(endListener);
    isAnimRuning = false;
    }

    结束语


    因为项目是把之前的功能写成了控件,所以有很多地方不完善。希望有建议的大牛和小伙伴,提示提示我,让我完善的更好。谢谢


    作者:花海blog
    来源:juejin.cn/post/7300845863462436873
    收起阅读 »

    Android 九宫格视频展示

    一、前言 一个有趣的现象,抖音上一度热传九宫格视频,其本质都是利用视频合成算法将视频原有视频编辑裁剪,最终展示处理。但实际还有更简单的方法,无需编辑视频的情况下,同样也可以实现九宫格展示。 二、实现原理 2.1 原理 做音视频开发也有一段时间了,在这个领域很...
    继续阅读 »

    一、前言


    一个有趣的现象,抖音上一度热传九宫格视频,其本质都是利用视频合成算法将视频原有视频编辑裁剪,最终展示处理。但实际还有更简单的方法,无需编辑视频的情况下,同样也可以实现九宫格展示。



    二、实现原理


    2.1 原理


    做音视频开发也有一段时间了,在这个领域很多看似高大上的东西,实际上往往都有很多简便的方法去代替,从视频编辑到多屏投影,某些情况下并非一定要学习open gl才可以做到。


    Android中提供了Path工具,其功能非常强大,很多不规则形状往往都需要Path实现,同样,本篇会利用Path进行镂空视频画布。


    Path.Op 作为多个Path合成的重要操作符,其功能同样可以实现将Path闭合空间进行挖空的操作,目前主要有以下操作符。


    Path.Op.DIFFERENCE          Path1调用合并函数:减去Path2后Path1区域剩下的部分
    Path.Op.INTERSECT 保留Path2 和 Path1 共同的部分
    Path.Op.UNION 保留Path1 和 Path 2
    Path.Op.XOR 保留Path1 和 Path2 + 共同的部分
    Path.Op.REVERSE_DIFFERENCE 与 Path.Op.DIFFERENCE相反,减去Path1后Path2区域剩下的部分


    今天我们主要用到Path.Op.DIFFERENCE ,原因是XOR 多次存在叠加问题,下图Path节点的地方,实际上正如XOR所述进行了叠加,因此这里使用XOR效果不符合预期。



    2.2 核心代码


            float columWidth = clipRect.width() / col;  //每列的宽度
    float rowHeight = clipRect.height() / row; //每行的高度


    for (int i = 1; i < col; i++) {
    tmpPath.reset();
    float position = i * columWidth - lineWidth/2;
    tmpPath.addRect(offsetLeft + position, offsetTop, offsetLeft + position + lineWidth / 2, height - offsetBottom, Path.Direction.CCW);
    clipPath.op(tmpPath, Path.Op.XOR);
    }
    for (int i = 1; i < row; i++) {
    tmpPath.reset();
    float position = i * rowHeight - lineWidth/2;
    tmpPath.addRect(offsetLeft , offsetTop + position, width - offsetRight, offsetTop + position + lineWidth / 2, Path.Direction.CCW);
    clipPath.op(tmpPath, Path.Op.XOR);
    }

    尝试修改行列的效果



    三、完整代码


    public class GridFrameLayout extends FrameLayout {
    private Path clipPath;
    private Path tmpPath = new Path();
    private RectF clipRect;
    private Paint paint;
    //由于有的视频存在黑边,添加如下offset便于剔除黑边,保留纯画面区域
    private int offsetTop = 0;
    private int offsetBottom = 0;
    private int offsetRight = 0;
    private int offsetLeft = 0;
    private PaintFlagsDrawFilter mPaintFlagsDrawFilter;

    private int row = 3; //行数
    private int col = 4; //列数

    private int lineWidth = 0;


    public GridFrameLayout(Context context) {
    super(context);
    init();
    }

    public GridFrameLayout(Context context, AttributeSet attrs) {
    super(context, attrs);
    init();
    }

    public GridFrameLayout(Context context, AttributeSet attrs, int defStyleAttr) {
    super(context, attrs, defStyleAttr);
    init();
    }

    private void init() {
    mPaintFlagsDrawFilter = new PaintFlagsDrawFilter(0, Paint.ANTI_ALIAS_FLAG);
    paint = new Paint(Paint.ANTI_ALIAS_FLAG);
    paint.setColor(Color.WHITE);
    paint.setStrokeWidth(10);
    paint.setStyle(Paint.Style.STROKE);
    lineWidth = dpToPx(5);
    }

    public void setRow(int row) {
    this.row = row;
    }
    public void setColum(int col) {
    this.col = col;
    }
    public void setOffsetTop(int offsetTop) {
    this.offsetTop = offsetTop;
    }
    public void setOffsetBottom(int offsetBottom) {
    this.offsetBottom = offsetBottom;
    }

    public void setOffsetRight(int offsetRight) {
    this.offsetRight = offsetRight;
    }

    public void setOffsetLeft(int offsetLeft) {
    this.offsetLeft = offsetLeft;
    }

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
    super.onSizeChanged(w, h, oldw, oldh);
    clipRect = null;
    }

    @Override
    protected void dispatchDraw(Canvas canvas) {
    int height = getHeight();
    int width = getWidth();
    DrawFilter drawFilter = canvas.getDrawFilter();

    int saveCount = canvas.save();
    //保存当前状态

    canvas.setDrawFilter(mPaintFlagsDrawFilter);
    if (clipPath == null) {
    clipPath = new Path();
    }
    if (clipRect == null) {
    clipRect = new RectF(offsetLeft, offsetTop, width - offsetRight, height - offsetBottom);
    } else {
    clipRect.set(offsetLeft, offsetTop, width - offsetRight, height - offsetBottom);
    }

    clipPath.reset();
    float radius = dpToPx(10);
    float[] radii = new float[]{
    radius, radius,
    radius, radius,
    radius, radius,
    radius, radius
    };

    clipPath.addRoundRect(clipRect, radii, Path.Direction.CCW);

    float columWidth = clipRect.width() / col;
    float rowHeight = clipRect.height() / row;

    for (int i = 1; i < col; i++) {
    tmpPath.reset();
    float position = i * columWidth - lineWidth / 2;
    tmpPath.addRect(offsetLeft + position, offsetTop, offsetLeft + position + lineWidth / 2, height - offsetBottom, Path.Direction.CCW);
    clipPath.op(tmpPath, Path.Op.DIFFERENCE);
    }
    for (int i = 1; i < row; i++) {
    tmpPath.reset();
    float position = i * rowHeight - lineWidth / 2;
    tmpPath.addRect(offsetLeft, offsetTop + position, width - offsetRight, offsetTop + position + lineWidth / 2, Path.Direction.CCW);
    clipPath.op(tmpPath, Path.Op.DIFFERENCE);
    }

    canvas.clipPath(clipPath);
    //裁剪画布,注意,这里不仅裁剪外围,内部挖空区域也会被裁剪
    //为什么在dispatchDraw中使用,因为dispatchDraw方便控制子View的绘制
    super.dispatchDraw(canvas);

    canvas.restoreToCount(saveCount);
    //恢复到之前的区域

    canvas.setDrawFilter(drawFilter);
    if (hasFocus()) {
    canvas.drawPath(clipPath, paint); //有焦点时画一个边框
    }
    }
    private int dpToPx(int dps) {
    return Math.round(getResources().getDisplayMetrics().density * dps);
    }
    public void setLineWidth(int lineWidth) {
    this.lineWidth = lineWidth;
    }
    }

    四、总结


    Canvas 作为2D绘制常用的组件,其实有很高级功能,如Matrix、Camera、Shader、drawBitmapMesh等,正确的使用往往能带来事半功倍的效果,因此有必要通过不断的摸索才能发挥极致。


    作者:时光少年
    来源:juejin.cn/post/7304272076641484834
    收起阅读 »

    2023小红书Android面试之旅

    一面 自我介绍 看你写了很多文章,拿你理解最深刻的一篇出来讲一讲 讲了Binder相关内容 Binder大概分了几层 哪些方法调用会涉及到Binder通信 大概讲一下startActivity的流程,包括与AMS的交互 全页面停留时长埋...
    继续阅读 »

    一面




    • 自我介绍




    • 看你写了很多文章,拿你理解最深刻的一篇出来讲一讲


      讲了Binder相关内容




    • Binder大概分了几层




    • 哪些方法调用会涉及到Binder通信




    • 大概讲一下startActivity的流程,包括与AMS的交互




    • 全页面停留时长埋点是怎么做的


      我在项目中做过的内容,主要功能是计算用户在每个Activity的停留时长,并且支持多进程。这里的多进程支持主要是通过以ContentProvider作为中介,然后通过ContentResolver.call方法去调用它的各种方法以实现跨进程




    • 动态权限申请是什么


      详见 Android动态权限申请从未如此简单 这篇文章




    • 你做的性能监测工具,FPS是怎么采集的




    • 性能监测工具用在了什么场景




    • 有没有通过这个性能监测工具去做一些优化




    • 图片库,例如Glide,一般对Bitmap有哪些优化点




    • 过期的Bitmap可以复用吗




    • 有没有基于ASM插桩做过一些插件




    • 讲了一下当时做过的一个个人项目 FastInflate


      这个项目没能达到最终的目标,但通过做这个项目学习了很多新知识,比如APT代码生成、阅读了LayoutInflater源码、AppCompatDelegateImpl实现的LayoutInflater.Factory2会极大的拖慢布局创建的速度等




    • 怎么优化布局创建速度


      提示了预加载,但我当时脑抽在纠结xml的缓存,没想到可以提前把视图先创建好




    • 说一下你觉得你最擅长或者了解最透的点


      我回答的自定义View




    • 解决过View的滑动冲突吗




    • 讲解了一个之前写过的开源控件 SwipeLoadingLayout




    • 一般遇到困难的解决方案是什么




    • 算法题:反转链表




    • 反问阶段




      • 咱们组主要负责哪些内容




      • 主要使用Java还是Kotlin


        Kotlin




      • 小红书的面试一般是怎么个流程?多少轮?


        一般三轮技术面,一轮HR面




      • 面试完一般多久会给到结果


        比较快,一两天的样子






    二面




    • 自我介绍




    • 为什么这个时间节点想要出来换工作呢




    • 在B站这些年做了什么




    • 做了哪些基础组件


      讲解了一下之前写的 SwipeLoadingLayout




    • 介绍一下Android的事件传递机制




    • 你写的这个分享模块是如何设计的


      对外采用流式调用的形式,内部通过策略模式区分不同的平台以及分享类型,给每个平台创建了一个中间Activity作为分享SDK请求的发起方(SDK.getApi().share())以及分享结果的接收方(onActivityResult),然后通过广播将分享的结果送入到分享模块内进行处理,最终调用用户设置的分享回调告知结果




    • 看你之前在扇贝的时候有开发过一些性能监测工具,那有做过性能优化吗




    • 你是如何收集这些性能数据的




    • 有没有对哪方面做过一些针对性的优化




    • Android系统为什么会触发ANR,它的机制是什么




    • 有解过ANR相关的问题吗?有哪几种类型?




    • 算法题:二叉树的层序遍历




    • Queue除了LinkedList还有哪些实现类




    • 现在还在面其他公司吗?你自己后面职业生涯的选择是怎么样的?




    • 给我介绍了一下团队,说我面试的这个部门应该说是小红书最核心的团队,包括主页、搜索、图文、视频等等都在部门业务范畴内,部门主要分三层,除了业务层之外还有基础架构层以及性能优化层




    • 反问阶段




      • 部门分三层的话,那新人进来的话是需要从业务层做起吗?


        不是这样的,我们首先会考虑这个同学能干什么,然后会考虑这个同学愿意去做什么,进来后,有经验的同学也会来带你的,不会一上来就让你抗输出,总之会把人放到适合他的团队里




      • 小红书会使用到一些跨端技术吗?


        会,之前在一些新的App上使用的Flutter,现在主要用的是RN,还会使用到一些DSL,这个不能算跨段。为什么在小红书社区App中跨端技术提及的比较少,是因为小红书App非常重视用户体验,对性能的要求比较高






    三面




    • 自我介绍




    • 介绍一下目前负责的业务




    • 工作过程中有碰到过什么难题,最后是怎么解决的


      一开始脑抽了没想到该说什么,随便扯了一个没啥技术含量的东西,又扯了一个之前做的信号捕获的工具,后来回忆起来了,重新说了一个关于DEX编排的东西(主DEX中方法数超过65535导致打包失败,写了个脚本将一部分Class从主DEX中移除到其他DEX中)




    • 如何设计一个头像的自定义View,要求使头像展示出来是一个圆形




    • 介绍一下Android事件的分发流程




    • 如何处理View的防误触




    • 怎么处理滑动冲突




    • ActivityonCreate方法中调用了finish方法,那它的生命周期会是怎样的




    • 如果我想判断一个Activity中的一个View的尺寸,那我什么时候能够拿到




    • RecyclerView如何实现一个吸顶效果




    • JavaKoltin你哪个用的比较多




    • 有用过Kotlin的协程吗




    • Kotlin中的哪些Feature你用的多,觉得写的好呢




    • 你是怎么理解MVVM




    • 你有用过Jetpack Compose




    • 有用过kotlin中的by lazylateinit




    • kotlin中怎么实现单例,怎么定义一个类的静态变量




    • 算法题:增量元素之间的最大差值




    • 你这次看机会的原因是什么




    • 反问阶段我感觉之前问的差不多了,这次就没再问什么问题了




    HR面




    • 现在是离职还是在职状态




    • 介绍一下之前负责的工作




    • 用户量怎么样




    • 这个项目是从0到1开发的吗




    • 这个业务有什么特点,对于客户端开发有什么挑战与困难




    • 团队分工是怎样的




    • 这个项目能做成现在这个样子,你自己的核心贡献有哪些




    • 这个事情对你来说有什么收获吗




    • 在B站的工作节奏是怎么样的




    • 离职的原因是什么呢




    • 你自己希望找一个什么样的环境或者什么阶段的业务




    • 你对小红书有什么了解吗




    • 未来两三年对于职业发展的想法




    • 你觉得现在有什么限制了你或者你觉得你需要提升哪些部分




    • 反问阶段



      • 问了一些作息、福利待遇之类的问题




    总结


    小红书面试总体而言给我的体验是很好的,每轮面试后基本上都是当天就能出结果,然后约下一轮的面试。最终从一面到HR面结束出结果,一共花了9天时间,还是挺快的。二面结束后,一面的面试官加我微信说小红书目前很缺人,感兴趣的同学也可以来试试。


    作者:dreamgyf
    来源:juejin.cn/post/7304267413637333029
    收起阅读 »

    关于鸿蒙开发,我暂时放弃了

    起因 在最近鸿蒙各种新闻资讯说要鸿蒙不再兼容android之后,我看完了鸿蒙视频,并简单的撸了一个demo。 # 鸿蒙HarmonyOS从零实现类微信app效果第一篇,基础界面搭建 # 鸿蒙HarmonyOS从零实现类微信app效果第二篇,我的+发现...
    继续阅读 »

    image.png


    image.png


    起因


    在最近鸿蒙各种新闻资讯说要鸿蒙不再兼容android之后,我看完了鸿蒙视频,并简单的撸了一个demo。


    企业微信截图_6f8acb94-bd68-4f56-9460-4a59d2370a4a.png



    鸿蒙的arkui,使用typescript作为基调,然后响应式开发,对于我这个old android来说,确实挺惊艳的。而且在模拟器中运行起来也很快,写demo的过程鸡血满满,着实很愉快。


    后面自己写的文章,也在掘金站点上获得了不错的评价。


    企业微信截图_fa34f233-af43-4567-8dac-57ef5666f1bd.png


    image.png


    打击


    今天下午,刚好同事有一个遥遥领先(meta 40 pro),鸿蒙4.0版本


    怀着秀操作的想法,在同事手机上运行了起来。very nice。 一切出奇的顺利。


    but ...


    尼玛,点击的时候,直接卡住不对,黑屏。让人瞬间崩溃。


    本着优先怀疑自己的原则,我找了一个官方的demo。 运行起来。


    额...


    尼玛。还是点击之后卡住了,大概30s之后,才跳转到新的页面。


    image.png


    这一切,让我熬夜掉的头发瞬间崩溃。


    放弃了...


    放弃了...


    后续


    和其他学习鸿蒙的伙伴沟通,也遇到了同样的问题,真机不能运行,会卡线程。但是按下home键,再次回到界面,页面会刷新过来


    我个人暂时决定搁置对于鸿蒙开发的学习了,后续如果慢慢变得比较成熟之后,再次接触学习吧。


    作者:王先生技术栈
    来源:juejin.cn/post/7304538094736343052
    收起阅读 »

    Android 自定义理化表达式View

    一、前言 在 Android 中实现上下标我们一般使用 SpannableString 去完成,需要计算开始位置和结束位置,也要设置各种 Span,而且动态性不是很好,因为无法做到规则统一约束,因此有必要进行专有规则设定,提高代码使用的灵活程度。 当然,也有很...
    继续阅读 »

    一、前言


    在 Android 中实现上下标我们一般使用 SpannableString 去完成,需要计算开始位置和结束位置,也要设置各种 Span,而且动态性不是很好,因为无法做到规则统一约束,因此有必要进行专有规则设定,提高代码使用的灵活程度。


    当然,也有很多开源的项目,但是对于简单的数学和化学表达式,大多都缺少通用性,仅限于项目本身使用,这也是本篇实现的主要目的之一。对于其他类型如求和公式、平方根公式、分子分母其实也可以通过本篇的思想,进行一些列改造即可,当然也可以借助语法树,实现自己的公式编辑器。



    二、效果预览



    三、实现


    实现其实很简单,本身就是借助Canvas#drawTextXXX实现,但是我们这里仍然需要回顾的问题是字体测量和基线计算问题。


    3.1 字体测量


    常用的宽高测量如下


            //获取文本最小宽度(真实宽度)
    private static int getTextRealWidth(String text, Paint paint) {
    if (TextUtils.isEmpty(text)) return 0;
    Rect rect = new Rect(); // 文字所在区域的矩形
    paint.getTextBounds(text, 0, text.length(), rect);
    //获取最小矩形,该矩形紧贴文字笔画开始的位置
    return rect.width();
    }

    //获取文本最小高度(真实高度)
    private static int getTextRealHeight(String text, Paint paint) {
    if (TextUtils.isEmpty(text)) return 0;
    Rect rect = new Rect(); // 文字所在区域的矩形
    paint.getTextBounds(text, 0, text.length(), rect);
    //获取最小矩形,该矩形紧贴文字笔画开始的位置
    return rect.height();
    }

    //真实宽度 + 笔画左右两侧间隙(一般绘制的的时候建议使用这种,左右两侧的间隙和字形有关)
    private static int getTextWidth(String text, Paint paint) {
    if (TextUtils.isEmpty(text)) return 0;
    return (int) paint.measureText(text);
    }

    //真实宽度 + 笔画上下两侧间隙(符合文本绘制基线)
    private static int getTextHeight(Paint paint) {
    Paint.FontMetricsInt fm = paint.getFontMetricsInt();
    int textHeight = ~fm.top - (~fm.top - ~fm.ascent) - (fm.bottom - fm.descent);
    return textHeight;
    }

    3.2基线计算


    在Canvas 绘制,实际上Html中的Canvas一样都需要计算意义,因为文字的受到不同文化的影响,表现形式不同,另外音标等问题存在,所以使用基线来绘制更合理。



    推导算法如下


           /**
    * 基线到中线的距离=(Descent+Ascent)/2-Descent
    * 注意,实际获取到的Ascent是负数。公式推导过程如下:
    * 中线到BOTTOM的距离是(Descent+Ascent)/2,这个距离又等于Descent+中线到基线的距离,即(Descent+Ascent)/2=基线到中线的距离+Descent。
    */

    public static float getTextPaintBaseline(Paint p) {
    Paint.FontMetrics fontMetrics = p.getFontMetrics();
    return (fontMetrics.descent - fontMetrics.ascent) / 2 - fontMetrics.descent;
    }

    3.3 全部代码


    public class MathExpressTextView extends View {

    private final List<TextInfo> TEXT_INFOS = new ArrayList<>();
    private int textSpace = 15;
    private String TAG = "MathExpressTextView";
    protected Paint mTextPaint;
    protected Paint mSubTextPaint;
    protected Paint mMarkTextPaint;
    protected float mContentWidth = 0f;
    protected float mContentHeight = 0f;
    protected float mMaxSize = 0;


    public void setMaxTextSize(float sizePx) {
    mMaxSize = sizePx;

    mTextPaint.setTextSize(mMaxSize);
    mSubTextPaint.setTextSize(mMaxSize / 3f);
    mMarkTextPaint.setTextSize(mMaxSize / 2f);

    invalidate();
    }

    public void setTextSpace(int textSpace) {
    this.textSpace = textSpace;
    }

    public void setContentHeight(float height) {
    mContentHeight = height;
    invalidate();
    }

    public MathExpressTextView(Context context){
    this(context,null);
    }
    public MathExpressTextView(Context context, AttributeSet attrs) {
    super(context, attrs);
    init();
    setEditDesignTextInfos();
    }

    public MathExpressTextView setText(String text, String subText, String supText, float space) {
    this.TEXT_INFOS.clear();
    TextInfo.Builder tb = new TextInfo.Builder(text, mTextPaint, mSubTextPaint)
    .subText(subText)
    .supText(supText)
    .textSpace(space);

    this.TEXT_INFOS.add(tb.build());
    return this;
    }

    public MathExpressTextView appendMarkText(String text) {
    TextInfo.Builder tb = new TextInfo.Builder(text, mMarkTextPaint, mMarkTextPaint);
    this.TEXT_INFOS.add(tb.build());
    return this;
    }

    public MathExpressTextView appendText(String text, String subText, String supText, float space) {
    TextInfo.Builder tb = new TextInfo.Builder(text, mTextPaint, mSubTextPaint)
    .subText(subText)
    .supText(supText)
    .textSpace(space);

    this.TEXT_INFOS.add(tb.build());
    return this;
    }

    private void setEditDesignTextInfos() {

    if (!isInEditMode()) return;
    // setText("2H", "2", "", 10)
    // .appendMarkText("+");
    // appendText("O", "2", "", 10);
    // appendMarkText("=");
    // appendText("2H", "2", "", 10);
    // appendText("O", "", "", 10);

    // setText("sin(Θ+α)", "", "", 10)
    // .appendMarkText("=");
    // appendText("sinΘcosα", "", "", 10);
    // appendMarkText("+");
    // appendText("cosΘsinα", "", "", 10);

    setText("cos2Θ", "1", "", 10)
    .appendMarkText("=");
    appendText("cos", "", "2", 10);
    appendText("Θ", "1", "", 10);
    appendMarkText("-");
    appendText("sin", "", "2", 10);
    appendText("Θ", "1", "", 10);

    }
    public Paint getTextPaint() {
    return mTextPaint;
    }

    public Paint getSubTextPaint() {
    return mSubTextPaint;
    }

    public Paint getMarkTextPaint() {
    return mMarkTextPaint;
    }

    private float dpTopx(int dp) {
    return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, getResources().getDisplayMetrics());
    }


    private void init() {

    mTextPaint = new Paint();
    mTextPaint.setColor(Color.WHITE);
    mTextPaint.setAntiAlias(true);
    mTextPaint.setStyle(Paint.Style.STROKE);

    mMarkTextPaint = new Paint();
    mMarkTextPaint.setColor(Color.WHITE);
    mMarkTextPaint.setAntiAlias(true);
    mMarkTextPaint.setStyle(Paint.Style.STROKE);

    mSubTextPaint = new Paint();
    mSubTextPaint.setColor(Color.WHITE);
    mSubTextPaint.setAntiAlias(true);
    mSubTextPaint.setStyle(Paint.Style.STROKE);

    setMaxTextSize(dpTopx(30));

    }


    private void setSubTextShader() {
    if (this.colors != null) {
    float textHeight = mSubTextPaint.descent() - mSubTextPaint.ascent();
    float textOffset = (textHeight / 2) - mSubTextPaint.descent();
    Rect bounds = new Rect();
    mSubTextPaint.getTextBounds("%", 0, 1, bounds);
    mSubTextPaint.setShader(new LinearGradient(0, mContentHeight / 2 + textOffset - mMaxSize / 100 * 22f, 0,
    mContentHeight / 2 + textOffset - mMaxSize / 100 * 22f - bounds.height(), colors, positions, Shader.TileMode.CLAMP));
    } else {
    mSubTextPaint.setShader(null);
    }

    }

    private void setMarTextShader() {
    if (this.colors != null) {
    float textHeight = mMarkTextPaint.descent() - mMarkTextPaint.ascent();
    float textOffset = (textHeight / 2) - mMarkTextPaint.descent();
    Rect bounds = new Rect();
    mMarkTextPaint.getTextBounds("%", 0, 1, bounds);
    mMarkTextPaint.setShader(new LinearGradient(0, mContentHeight / 2 + textOffset - mMaxSize / 100 * 22f, 0,
    mContentHeight / 2 + textOffset - mMaxSize / 100 * 22f - bounds.height(), colors, positions, Shader.TileMode.CLAMP));
    } else {
    mMarkTextPaint.setShader(null);
    }

    }

    private void setTextShader() {
    if (this.colors != null) {
    float textHeight = mTextPaint.descent() - mTextPaint.ascent();
    float textOffset = (textHeight / 2) - mTextPaint.descent();
    Rect bounds = new Rect();
    mTextPaint.getTextBounds("A", 0, 1, bounds);
    mTextPaint.setShader(new LinearGradient(0, mContentHeight / 2 + textOffset, 0, mContentHeight / 2 + textOffset - bounds.height(), colors, positions, Shader.TileMode.CLAMP));
    } else {
    mTextPaint.setShader(null);
    }
    }


    public void setColor(int unitColor, int numColor) {
    mSubTextPaint.setColor(unitColor);
    mTextPaint.setColor(numColor);
    }

    RectF contentRect = new RectF();

    @Override
    protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);

    if (mContentWidth <= 0) {
    mContentWidth = getWidth();
    }
    if (mContentHeight <= 0) {
    mContentHeight = getHeight();
    }

    if (mContentWidth == 0 || mContentHeight == 0) return;

    setTextShader();
    setSubTextShader();
    setMarTextShader();

    if (TEXT_INFOS.size() == 0) return;

    int width = getWidth();
    int height = getHeight();


    contentRect.left = (width - mContentWidth) / 2f;
    contentRect.top = (height - mContentHeight) / 2f;
    contentRect.right = contentRect.left + mContentWidth;
    contentRect.bottom = contentRect.top + mContentHeight;

    int id = canvas.save();
    float centerX = contentRect.centerX();
    float centerY = contentRect.centerY();
    canvas.translate(centerX, centerY);

    contentRect.left = -centerX;
    contentRect.right = centerX;
    contentRect.top = -centerY;
    contentRect.bottom = centerY;


    float totalTextWidth = 0l;
    int textCount = TEXT_INFOS.size();

    for (int i = 0; i < textCount; i++) {
    totalTextWidth += TEXT_INFOS.get(i).getTextWidth();
    if (i < textCount - 1) {
    totalTextWidth += textSpace;
    }
    }

    drawGuideBaseline(canvas, contentRect, totalTextWidth);

    float startOffsetX = -(totalTextWidth) / 2f;
    for (int i = 0; i < textCount; i++) {
    TEXT_INFOS.get(i).draw(canvas, startOffsetX, contentRect.centerY());
    startOffsetX += TEXT_INFOS.get(i).getTextWidth() + textSpace;
    }

    canvas.restoreToCount(id);

    }

    private void drawGuideBaseline(Canvas canvas, RectF contentRect, float totalTextWidth) {

    if (!isInEditMode()) return;

    Paint guidelinePaint = new Paint();
    guidelinePaint.setAntiAlias(true);
    guidelinePaint.setStrokeWidth(0);
    guidelinePaint.setStyle(Paint.Style.FILL);

    RectF hline = new RectF();
    hline.top = -1;
    hline.bottom = 1;
    hline.left = -totalTextWidth / 2;
    hline.right = totalTextWidth / 2;
    canvas.drawRect(hline, guidelinePaint);

    RectF vline = new RectF();
    hline.left = -1;
    vline.top = contentRect.top;
    vline.bottom = contentRect.bottom;
    vline.right = 1;

    canvas.drawRect(vline, guidelinePaint);
    }


    private static class TextInfo {
    Paint subOrSupTextPaint = null;
    String subText = null;
    String supText = null;
    Paint textPaint = null;
    String text;
    float space;

    private TextInfo(String text, String subText, String supText, Paint textPaint, Paint subOrSupTextPaint, float space) {
    this.text = text;
    if (this.text == null) {
    this.text = "";
    }
    this.subText = subText;
    this.supText = supText;
    this.space = space;
    this.textPaint = textPaint;
    this.subOrSupTextPaint = subOrSupTextPaint;
    }

    public void draw(Canvas canvas, float startX, float startY) {

    if (this.textPaint == null) {
    return;
    }

    canvas.drawText(this.text, startX, startY + getTextPaintBaseline(this.textPaint), this.textPaint);

    if (this.subOrSupTextPaint == null) {
    return;
    }
    if (this.supText != null) {
    RectF rect = new RectF();
    rect.left = startX + space + getTextWidth(this.text, this.textPaint);
    rect.top = -getTextHeight(this.textPaint) / 2;
    rect.bottom = 0;
    rect.right = rect.left + getTextWidth(supText, this.subOrSupTextPaint);
    canvas.drawText(supText, rect.left, rect.centerY() + getTextPaintBaseline(this.subOrSupTextPaint), this.subOrSupTextPaint);
    }


    if (this.subText != null) {
    RectF rect = new RectF();
    rect.left = startX + space + getTextWidth(this.text, this.textPaint);
    rect.top = 0;
    rect.bottom = getTextHeight(this.textPaint) / 2;
    rect.right = rect.left + getTextWidth(subText, this.subOrSupTextPaint);
    canvas.drawText(subText, rect.left, rect.centerY() + getTextPaintBaseline(this.subOrSupTextPaint), this.subOrSupTextPaint);
    }

    }

    /**
    * 基线到中线的距离=(Descent+Ascent)/2-Descent
    * 注意,实际获取到的Ascent是负数。公式推导过程如下:
    * 中线到BOTTOM的距离是(Descent+Ascent)/2,这个距离又等于Descent+中线到基线的距离,即(Descent+Ascent)/2=基线到中线的距离+Descent。
    */

    public static float getTextPaintBaseline(Paint p) {
    Paint.FontMetrics fontMetrics = p.getFontMetrics();
    return (fontMetrics.descent - fontMetrics.ascent) / 2 - fontMetrics.descent;
    }


    public float getTextWidth() {

    if (textPaint == null) {
    return 0;
    }

    float width = 0;

    width = getTextWidth(this.text, textPaint);

    float subTextWidth = 0;
    if (this.subText != null && subOrSupTextPaint != null) {
    subTextWidth = getTextWidth(this.subText, subOrSupTextPaint) + space;
    }

    float supTextWidth = 0;
    if (this.supText != null && subOrSupTextPaint != null) {
    supTextWidth = getTextWidth(this.supText, subOrSupTextPaint) + space;
    }
    return width + Math.max(subTextWidth, supTextWidth);
    }


    //获取文本最小宽度(真实宽度)
    private static int getTextRealWidth(String text, Paint paint) {
    if (TextUtils.isEmpty(text)) return 0;
    Rect rect = new Rect(); // 文字所在区域的矩形
    paint.getTextBounds(text, 0, text.length(), rect);
    //获取最小矩形,该矩形紧贴文字笔画开始的位置
    return rect.width();
    }

    //获取文本最小高度(真实高度)
    private static int getTextRealHeight(String text, Paint paint) {
    if (TextUtils.isEmpty(text)) return 0;
    Rect rect = new Rect(); // 文字所在区域的矩形
    paint.getTextBounds(text, 0, text.length(), rect);
    //获取最小矩形,该矩形紧贴文字笔画开始的位置
    return rect.height();
    }

    //真实宽度 + 笔画左右两侧间隙(一般绘制的的时候建议使用这种,左右两侧的间隙和字形有关)
    private static int getTextWidth(String text, Paint paint) {
    if (TextUtils.isEmpty(text)) return 0;
    return (int) paint.measureText(text);
    }

    //真实宽度 + 笔画上下两侧间隙(符合文本绘制基线)
    private static int getTextHeight(Paint paint) {
    Paint.FontMetricsInt fm = paint.getFontMetricsInt();
    int textHeight = ~fm.top - (~fm.top - ~fm.ascent) - (fm.bottom - fm.descent);
    return textHeight;
    }


    private static class Builder {

    Paint subOrSupTextPaint = null;
    Paint textPaint = null;
    String subText = null;
    String supText = null;
    String text;
    float space;

    public Builder(String text, Paint textPaint, Paint subOrSupTextPaint) {
    this.text = text;
    this.textPaint = textPaint;
    this.subOrSupTextPaint = subOrSupTextPaint;
    }

    public Builder subText(String subText) {
    this.subText = subText;
    return this;
    }

    public Builder supText(String supText) {
    this.supText = supText;
    return this;
    }

    public Builder textSpace(float space) {
    this.space = space;
    return this;
    }

    public TextInfo build() {
    return new TextInfo(text, this.subText, this.supText, this.textPaint, this.subOrSupTextPaint, this.space);
    }
    }

    }
    private int[] colors = new int[]{
    0xC0FFFFFF, 0x9fFFFFFF,
    0x98FFFFFF, 0xA5FFFFFF,
    0xB3FFFFFF, 0xBEFFFFFF,
    0xCCFFFFFF, 0xD8FFFFFF,
    0xE5FFFFFF, 0xFFFFFFFF};
    private float[] positions = new float[]{
    0f, 0.05f,
    0.3f, 0.4f,
    0.5f, 0.6f,
    0.7f, 0.8f,
    0.9f, 1f};

    public void setShaderColors(int[] colors) {
    this.colors = colors;
    }

    public void setShaderColors(int c) {
    this.colors = new int[]{c, c, c, c,
    c, c, c, c, c, c};
    }

    }

    3.4 使用


            MathExpressTextView m1 = findViewById(R.id.math_exp_1);
    MathExpressTextView m2 = findViewById(R.id.math_exp_2);
    MathExpressTextView m3 = findViewById(R.id.math_exp_3);
    MathExpressTextView m4 = findViewById(R.id.math_exp_4);


    m1.setShaderColors(0xffFF4081);
    m1.setText("2H","2","",10)
    .appendMarkText("+")
    .appendText("O","2","",10)
    .appendMarkText("
    =")
    .appendText("
    2H","2","",10)
    .appendText("
    O","","",10);

    m2.setShaderColors(0xffff9922);
    m2.setText("
    2","","2",10)
    .appendMarkText("
    +")
    .appendText("
    5","","-1",10)
    .appendMarkText("
    =")
    .appendText("
    4.2","","",10);

    m3.setShaderColors(0xffFFEAC4);
    m3.setText("
    H","2","0",10)
    .appendMarkText("
    +")
    .appendText("
    Cu","","+2",10)
    .appendText("
    O","","-2",10)
    .appendMarkText("
    ==")
    .appendText("
    Cu","","0",10)
    .appendText("
    H","2","+1",10)
    .appendText("
    O","","-2",10);


    m4.setText("
    985","","GB",10)
    .appendMarkText("
    +")
    .appendText("
    211","","MB",10);

    四、总结


    相对来说本篇相对简单,没有过多复杂的计算。但是对于打算实现公式编辑器的项目,可参考本方案的设计思想:



    • 组合化:通过大公式,组合小公式,这样也方便使用语法树,提高通用性。

    • 对象化:单独描述单独片段

    • 规则化:对不同的片段进行规则化绘制,如appendMarkText方法


    作者:时光少年
    来源:juejin.cn/post/7303792111719792666
    收起阅读 »

    让Android开发Demo页面变得简单起来

    Github: github.com/eekidu/devl… DevLayout DevLayout支持使用代码的方式,快速添加常用调试控件,无需XML,简化调试页面开发过程 背景 我们在开发组件库的时候,通常会开发一个Demo页面,用于展示或者调试该组件库...
    继续阅读 »

    Screenshot_20231122_163444.png


    Github: github.com/eekidu/devl…


    DevLayout


    DevLayout支持使用代码的方式,快速添加常用调试控件,无需XML,简化调试页面开发过程


    背景


    我们在开发组件库的时候,通常会开发一个Demo页面,用于展示或者调试该组件库。
    这种页面对UI的美观度要求很低,注重的是快速实现

    使用XML布局方式开发会比较繁琐,该库会简化这一页面UI的开发流程:



    • 对常用的控件进行了封装,可以通过调用DevLayout的方法进行创建;

    • 并按流式布局或者线性布局的方式摆放到DevLayout中。


    image.png

    引入依赖


    在Project的build.gradle在添加以下代码


    allprojects {
    repositories {
    ...
    maven { url 'https://jitpack.io' }
    }
    }

    在Module的build.gradle在添加以下代码


    最新版本:


    implementation 'com.github.eekidu:devlayout:Tag'

    使用


    DevLayout是一个ViewGr0up,你可以把它摆放到页面上合适的位置,然后通过调用它的方法来添加需要子控件。


    //1、创建或者获取一个DevLaout实例
    var mDevLayout = findViewById<DevLayout>(R.id.devLayout)


    //2、调用方法添加调试控件

    /**
    * 添加功能按钮
    */

    mDevLayout.addButton("功能1") {
    //点击回调
    }

    /**
    * 添加开关
    */

    mDevLayout.addSwitch("开关1") { buttonView, isChecked ->
    //状态切换回调
    }

    /**
    * 添加SeekBar
    */

    mDevLayout.addSeekBar("参数设置1") { progress ->
    //进度回调
    }.setMax(1000).setProgress(50).setEnableStep(true)//启用步进


    /**
    * 添加输入框
    */

    mDevLayout.addEditor("参数设置") { inputText ->
    textView.text = inputText
    }

    /**
    * 单选,切换布局样式
    */

    mDevLayout.addRadioGr0up("布局方式")
    .addItem("流式布局") {
    mDevLayout.setIsLineStyle(false)
    }.addItem("线性布局") {
    mDevLayout.setIsLineStyle(true)
    }.setChecked(0)

    /**
    * 添加日志框
    */

    mDevLayout.addLogMonitor()

    /**
    * 输出日志
    */

    mDevLayout.log(msg)
    mDevLayout.logI(msg)
    mDevLayout.logD(msg)
    mDevLayout.logW(msg)
    mDevLayout.logE(msg)


    /**
    * 添加换行
    */

    mDevLayout.br()
    /**
    * 添加分割线
    */

    mDevLayout.hr()

    //其他类型控件见Demo MainActivity.kt


    耗时监控


    我们调试代码一个重要的目的就是:发现耗时方法从而进行优化,DevLayout提供一个简易的耗时打印功能,实现如下:
    大部分需要调试的代码,会在控件的回调中触发,那么对回调进行代理,在代理中监控原始回调的执行情况,就可以得到调试代码的执行耗时。


    伪代码如下:


    class ClickProxyListener(val realListener: OnClickListener) : OnClickListener {

    override fun onClick(v: View) {
    val startTime = Now()// 1、记录起始时间

    realListener.onClick(v)//原始回调执行

    val eTime = Now() - startTime//2、计算执行耗时
    log("执行耗时:${eTime}")
    }
    }

    //创建代理对象
    val listenerProxy = ClickProxyListener(realListener)

    由于控件种类很多,回调类的类型也都不一样,如何对形形色色的回调统一进行监控?


    动态代理:封装了ProxyListener代理类,对原始回调进行代理


    open class ProxyListener<T>(val realListener: T) : InvocationHandler {

    override fun invoke(proxy: Any, method: Method, args: Array<out Any>?): Any {
    val startTime = Now()// 1、记录起始时间

    val result = method.invoke(realListener, *(args ?: emptyArray()))//原始回调执行

    val eTime = Now() - startTime//2、计算执行耗时
    log("执行耗时:${eTime}")
    return result
    }
    }

    //动态创建代理对象
    val listener = Proxy.newProxyInstance(_, listenerInterface , ProxyListener(realListener))

    结合该例子感受动态代理的优点:




    • 灵活性:动态代理允许在运行时创建代理对象,而不需要在编译时指定具体的代理类。这使得代理对象可以根据需要动态地适应不同的接口和实现类。




    • 可扩展性:动态代理可以用于实现各种不同的功能,例如日志记录、性能监控、事务管理等。通过在代理对象的方法调用前后插入额外的逻辑,可以轻松地扩展现有的代码功能。




    • 解耦合:动态代理可以将代理逻辑与真实对象的实现逻辑分离。这样,代理对象可以独立于真实对象进行修改和维护,而不会影响到真实对象的代码。




    • 减少重复代码:通过使用动态代理,可以将一些通用的代码逻辑抽取到代理类中,从而减少代码的重复性。这样可以提高代码的可维护性和可读性。




    • 动态性:动态代理可以在运行时动态地创建代理对象,这意味着可以根据需要动态地修改代理对象的行为。这种灵活性使得动态代理在一些特定的场景下非常有用,例如AOP(面向切面编程)。




    日志


    日志是调试代码的重要方式,在某些场景下需要将日志输出到UI上,方便在设备没有连接Logcat,无法通过控制台监测日志时,也能对程序执行的中间过程或执行结果有一定的展示。


    我们可以添加一个日志框到UI界面上,以此来展示Log信息,方式如下:


    //添加日志框,默认尺寸,添加后也可以通过UI调整
    mDevLayout.addLogMonitor()
    mDevLayout.addLogMonitorSmall()
    mDevLayout.addLogMonitorLarge()

    //输出日志
    mDevLayout.log(msg)
    mDevLayout.logI(msg)
    mDevLayout.logD(msg)
    mDevLayout.logW(msg)
    mDevLayout.logE(msg)

    支持过滤:



    • 按等级过滤

    • 按关键词过滤,多关键字格式:key1,key2


    同时,日志信息会在Logcat控制台输出,通过 tag:DevLayout 进行过滤查看。


    image.png


    最后


    Github: github.com/eekidu/devl…


    欢迎Star,如果有更好的优化方案,欢迎在github上提出,我们一起互相学习!


    作者:Caohaikuan
    来源:juejin.cn/post/7304182005285584933
    收起阅读 »

    工信部又出新规!爬坑指南

    一、背景 工信部最近发布了新的入网要求,明确了app进网检测要求的具体变化,主要涉及到一些app权限调用,个人信息保护,软件升级以及敏感行为。为了不影响app的正常运行,依据工信部的文件进行相关整改,下文将从5个方向来阐述具体的解决思路。 二、整改 2.1 个...
    继续阅读 »

    一、背景


    工信部最近发布了新的入网要求,明确了app进网检测要求的具体变化,主要涉及到一些app权限调用,个人信息保护,软件升级以及敏感行为。为了不影响app的正常运行,依据工信部的文件进行相关整改,下文将从5个方向来阐述具体的解决思路。


    二、整改


    2.1 个人信息保护


    2.1.1 基本模式(无权限、无个人信息获取模式)


    这次整改涉及到最大的一个点就是基本模式,基本模式指的是在用户选择隐私协议弹窗时,不能点击“不同意”即退出应用,而是需要给用户提供一个除联网功能外,无任何权限,无任何个人信息获取的模式且用户能正常使用。


    这个说法有点抽象,我们来看下友商已经做好的案例。


    腾讯视频



    从腾讯视频的策略来看,用户第一次使用app,依旧会弹出一个“用户隐私协议”弹窗供用户选择,但是和以往不同的是,“不同意”按钮替换为了“不同意并进入基本功能模式”,用户点击“不同意并进入基本功能模式”则进入到一个简洁版的页面,只提供一些基本功能,当用户点击“进入全功能模式”,则再次弹出隐私协议弹窗。当杀死进程后,再次进入则直接进入基本模式。


    网易云音乐



    网易云音乐和腾讯视频的产品策略略有不同,在用户在一级授权弹窗点击“不同意”,会跳转至二级授权弹窗,当用户在二级弹窗上点击“不同意,进入基本功能模式”,才会进入基本功能页面,在此页面上点击“进入完整功能模式”后就又回到了二级授权页。当用户杀死进程,重新进入app时,还是会回到一级授权页。


    网易云音乐比腾讯视频多了一个弹窗,也只是为了提升用户进入完整模式的概率,并不涉及到新规。


    另外,B站、酷狗音乐等都已经接入了基本模式,有兴趣的伙伴可以自行下载体验。


    2.1.2 隐私政策内容


    如果app存在读取并传送用户个人信息的行为,需要检查其是否具备用户个人信息收集、使用规则,并明确告知读取和传送个人信息的目的、方式和范围。


    判断权限是否有读取、修改、传送行为,如果有,需要在隐私协议中明文告知。


    举个例子,app有获取手机号码并且保存在服务器,需要在协议中明确声明:读取并传送用户手机号码。


    2.2 app权限调用


    2.2.1 应用内权限调用



    1. 获取定位信息和生物特征识别信息


    在获取定位信息以及生物特征识别信息时需要在调用权限前,单独向用户明示调用权限的目的,不能用系统权限弹窗替代。



    如上图,申请位置权限,需要在申请之前,弹出弹窗供用户选择,用户同意调用后才可以申请位置权限。



    1. 其他权限


    其他权限如上面隐私政策一样,需要在调用时,声明是读取、修改、还是传送行为,如下图



    2.3 应用软件升级


    2.3.1 更新


    应用软件或插件更新应在用户授权的情况下进行,不能直接更新,另外要明确告知用户此行为包含下载和安装。


    简单来说,就是在app进行更新操作时,需要用弹窗告知用户,是否更新应用,更新的确认权交给用户,并且弹窗上需要声明此次更新有下载和安装两个操作。如下图



    2.4 应用签名


    需要保证签名的真实有效性。


    作者:付十一
    来源:juejin.cn/post/7253610755126476857
    收起阅读 »

    聊聊Android中的DRM工具-Widevine

    曾几何时,我一直好奇,像爱奇艺、腾讯视频、优酷这些视频平台是如何控制版权的,就比如,如何防止用户下载后发布到其他渠道,最近接触了DRM技术,瞬间就懂了。 DRM介绍 DRM(Digital Rights Management),即数字版权管理,是在数字内容交易...
    继续阅读 »

    曾几何时,我一直好奇,像爱奇艺、腾讯视频、优酷这些视频平台是如何控制版权的,就比如,如何防止用户下载后发布到其他渠道,最近接触了DRM技术,瞬间就懂了。


    DRM介绍


    DRM(Digital Rights Management),即数字版权管理,是在数字内容交易过程中,对知识产权进行保护的技术、工具和处理过程。它的目的是防止数字内容被未经授权的用户复制、修改和分发,以保护知识产权所有者的权益。在日常生活中,我们经常与 DRM 技术打交道。比如,电影上映前,我们不能在视频网站上观看电影,只能去电影院。这是内容提供(发行)商对自己的数字内容进行管理的一种结果。


    DRM工作原理


    先贴一张图,然后我们再做简单的说明drm工作原理


    上图中,RAM想要给SHYAM传递小纸条,但因为距离较远,中间需要三个人进行传达,为了防止这三个人偷看小纸条内容,他们找来了HARI,HARI手上有一本密码本,每次RAM传递小纸条之前先找HARI拿到密码本,然后根据密码本的规则对小纸条内容进行加密,然后再将加密后的小纸条传递给SHYAM,这样,即使中间三个人偷看了小纸条,因为没有密码本,所以也看不懂纸条的内容。SHYAM收到小纸条后,再向HARI获取密码本,然后对小纸条内容进行解密,这样SHYAM就能看到原始内容了。


    现在,我们把RAM看成是视频发行商,SHYAM看成是观众,HARI看成是版权管理商,就有了以下这种关系图


    drm工作原理2


    从上图中可以看出,我们想要向认证用户安全地发送一部电影。需要:



    • 向DRM厂商的服务器请求密码本

    • 然后使用密码本加密视频

    • 将电影视频发送给用户

    • 用户向DRM厂商的服务器请求密码本解密视频

    • 现在用户就可以观看电影了


    这下视频版权管理是不是就一目了然了。但以上只是最初DRM的设计思想,现实中却无法正常运行,因为还没有解决多种分辨率的问题,这就需要对视频进行切片(ABR)和打包。


    视频切片和打包


    ABR: 通过使用ABR技术,电影可以被编码成不同的码率-分辨率组合(也称为码率阶梯)并被分割成小的视频块或者切片。每个视频切片包含几秒钟视频,可以被单独解码。


    打包是指将电影分割成小的视频切片,并使用清单(manifest)或者播放列表对其进行描述。当用户想要播放电影的时候,他需要按照播放列表的信息播放。


    根据可用带宽,播放器请求特定码率版本的视频切片,CDN响应后返回被请求切片。


    drm工作原理3


    这就结束了吗?不,这里面还存在很大的一个问题需要解决,视频的加密问题。


    视频加密


    前面说,视频发行商在发布视频时,需要向DRM服务商获取密码本,这里的密码本实际上是一种授权,就是说经过DRM服务商的授权,他才会对你的视频进行版权保护,并不是对视频内容进行加密,真正的视频加密还得涉及到密码学相关的技术,最常用的加密方式是AES,AES属于对称加密,这就涉及到密钥的保存。在DRM中,密钥也保存在DRM服务商手上,随着视频清单一起发送给视频播放器


    drm工作原理4


    好了,DRM的核心原理大概就是这些,如果想了解更详细的内容,可阅读下面的参考文献。


    DRM厂商


    上述DRM工作原理图中,有一个很重要的角色就是DRM服务商,目前主要有三大服务商,分别对应自己的DRM技术方案,分别是:




    • Apple FairPlay




    • Google Widevine




    • Microsoft PlayReady




    国内爱奇艺最近也自主研发了自己的DRM解决方案:iQIYI DRM-S。而国内的视频平台几乎都是打包了所有的的DRM方案,以针对不同的平台和系统。以下是爱奇艺的整体DRM解决方案


    爱奇艺drm方案


    Widevine介绍


    Widevine仅适用于基于Chromium的操作系统、Android设备以及其他Google相关设备和浏览器。


    Widevine的安全级别



    • L1


    在L1级别,提供了最高的安全性。内容在设备内进行解密,并使用硬件保护,以防止原始数据泄露。通常用于高质量视频和高分辨率的流媒体。获得L1认证的设备可以播放高质量的内容。像Amazon Prime Video和Netflix等流媒体服务需要L1安全性。如果在未获得认证的设备上观看,无法播放高清或超高清的高质量内容。



    • L2


    L2具有较高的安全性,但不像L1那么严格。即使设备未获得L1认证,仍然可以播放内容。一些设备使用软件来保护数据。对于较低分辨率的视频和音乐内容,可能会使用L2。如果想要享受更高质量的内容,建议使用获得L1认证的设备,而不是L2。虽然L2可能不够满足要求,但某些内容仍然可能提供高质量的视频。因此,不能一概而论地认为必须使用L1。



    • L3


    L3的安全级别最低。主要用于模拟器和一些旧设备等情况,内容保护相对较弱,分析和复制相对容易。此外,一些服务如Amazon Prime Video和Netflix也可能使用L3。虽然可以使用L3,但风险较高,不应期望高质量的内容。使用L3时需要谨慎考虑这些因素。


    查看Widevine级别


    可以使用DRM Info App查看设备的widevine安全级别,该App可以在Google Play上找到,文末贴了App的下载链接。大多数主流制造商的智能手机通常都支持L1至L3的某一个级别。如果发现您的设备不支持Widevine,那可能是制造商为了简化流程或者您的智能手机不符合标准。


    image-20231121164459796


    如果app打开闪退,说明设备并不支持Widevine。


    测试Widevine功能


    许多流媒体app都使用了Widevine,比如Youku、腾讯视频、IQIYI、YouTube、Netflix等,这里推荐使用Google的官方播放器ExoPlayer进行测试,文末提供下载链接


    image-20231121165120972


    (重点)在Android中集成Widevine


    step1:获取Widevine源码


    官网下载Widevine源码,注意,AOSP默认是没有Widevine源码的,需要手动集成,因为需要跟Google签订法律协议,然后由Google授权访问Widevine代码库,具体见Google官网流程。


    step2:将源码放置到vendor目录下vendor/widevine/


    image-20231121170544865


    step3:添加编译配置


    device/qcom/{product combo name}/BoardConfig.mk中添加


    #这里设置的L3级别,L1级别需要跟Google签订协议,获取Keybox
    BOARD_WIDEVINE_OEMCRYPTO_LEVEL := 3

    device/qcom/{product combo name}/{product combo name}.mk中添加


    PRODUCT_PROPERTY_OVERRIDES += drm.service.enabled=true
    PRODUCT_PACKAGES += com.google.widevine.software.drm.xml \
    com.google.widevine.software.drm
    PRODUCT_PACKAGES += libwvdrmengine

    vendor/qcom/proprietary/common/config/device-vendor.mk中修改


    SECUREMSM += InstallKeybox
    #L3级别需要删除oemcrypto库
    #SECUREMSM += liboemcrypto
    #SECUREMSM += liboemcrypto.a
    SECUREMSM += libhdcpsrm

    最后编译刷机,使用app工具验证即可,如果能显示Widevine级别,说明集成成功。


    总结


    好了,现在你应该彻底知道Widevine是怎么回事了


    参考链接


    中学生也能看懂的DRM


    构建DRM系统的重要基石——EME、CDM、AES、CENC和密钥


    爱奇艺DRM修炼之路


    什么是Widevine?Widevine DRM详解


    Google Widevine


    Widevine安全级别查看app:


    链接:pan.baidu.com/s/1lIJq-_eg…
    提取码:fnk6


    ExoPlayer:


    链接:pan.baidu.com/s/1dUseWHIi…
    提取码:nszh


    作者:小迪vs同学
    来源:juejin.cn/post/7303723984180101139
    收起阅读 »

    从小米14安装不上应用说起【适配64位】

    一、原因 某天早上,同事突然对我说我换了小米14pro手机但是安装不了公司的打卡软件,怎么办呀。一时间,我也不知道原因,看到给我发的安装不上的截图陷入了沉思。随即打开在git仓库里找到这个项目,到本地编译打开,开始思考解决办法。 二、解决思路 从网上查询了一番...
    继续阅读 »

    一、原因


    某天早上,同事突然对我说我换了小米14pro手机但是安装不了公司的打卡软件,怎么办呀。一时间,我也不知道原因,看到给我发的安装不上的截图陷入了沉思。随即打开在git仓库里找到这个项目,到本地编译打开,开始思考解决办法。


    二、解决思路


    从网上查询了一番,小米14pro 只支持安装64位的应用,可能是老项目没有做64位的适配。等到项目编译好,打开模块下的build.gradle文件,果然如此,没有做64位的适配。


    ndk {
    abiFilters 'armeabi', "x86", "x86_64"
    }

    针对64位做适配,一般都是适配so库,一般来说,如果你的项目中没有使用到so库或者C,C++代码,都是支持64位的。
    这里再做下说明,ABI是Application Binary Interface(应用程序二进制接口)的缩写,在Android中,它指的是Android操作系统与设备硬件供应商提供的库之间的接口。ABI定义了程序各个组件在二进制级别上的交互方式,其中一个重要的方面是处理器使用的指令集架构。Android支持多种ABI以适应具有不同硬件架构的设备。



    • ARM(Advanced RISC Machine):

      • ARM是移动设备中常见的架构。

      • 变体:armv5、armv7-A、armv8-A(64位)等。



    • x86:

      • x86是台式机和笔记本电脑中常见的架构。

      • 变体:x86、x86_64(64位)。



    • MIPS(Microprocessor without Interlocked Pipeline Stages):

      • MIPS架构在过去在一些Android设备中被广泛使用,但近年来变得不那么常见。




    好了,回归到正题,就要针对项目中这种情况处理so库了,因为这个老项目是从其他项目演变过来的,用不到这些so库,所以我的解决办法就是全部删除掉(当然要对项目中的源代码进行处理),再进行打包处理。
    如果你们处理项目中的没有兼容的so库,推荐一个检测插件EasyPrivacy,接入这个就可以方便查看那些so库没有做适配了。
    找到没有适配的so库之后,需要找到提供者获取最新兼容的so库或者找到相关官网看是否提供64位的so库。当然代码中还需要进行处理。


    ndk {
    abiFilters 'armeabi-v7a', 'arm64-v8a', "x86", "x86_64"
    }

    这样子打包的时候,在apk中的libs文件夹下就会出现四个对应的文件夹,里面就是对应的so库了。但是这样会增大包的体积。在android{}中配置如下代码,这样子打包之后就会出现四种包对应不同架构的包,这样子包的体积也会减小。


    splits {
    abi {
    enable true
    reset()
    include 'armeabi-v7a', 'arm64-v8a', 'x86', 'x86_64' //select ABIs to build APKs for
    universalApk true //generate an additional APK that contains all the ABIs
    }
    }

    回归正题,当我把打好的包发给同事的时候,同事告诉我还是不行。思来想去,排除64位的问题,那么剩下的只有Android版本的问题了。新出的手机肯定是搭载最新的Android版本,目前Android 14 有什么新变动还没有了解到。
    image.png
    从官网看到,映入眼前的第一条就是这个,检查项目中的代码,发现targetSdkVersion 还是16,怪不得安装不上,至此所有问题解决。


    作者:风罗伊曼
    来源:juejin.cn/post/7303741345323221044
    收起阅读 »

    陪伴了 14 年的 API 下线了

    hi 大家好,我是 DHL。就职于美团、快手、小米。 Android 开发者应该深有感触,近几年 Android 每次的更新,对开发者的影响都非常的大,而这次 Android 14 的更新,直接让陪伴我们多年的老朋友 overridePendingTransi...
    继续阅读 »

    hi 大家好,我是 DHL。就职于美团、快手、小米。


    Android 开发者应该深有感触,近几年 Android 每次的更新,对开发者的影响都非常的大,而这次 Android 14 的更新,直接让陪伴我们多年的老朋友 overridePendingTransition 下线。


    这篇文章主要想介绍一下我们的老朋友 overridePendingTransition,它陪伴了我们度过了 14 年,如今也完成了它的使命,现已功成身退,这个方法在 Android 14 中被废弃了。



    在 2009 年的时候,正式将 overridePendingTransition 添加到 Android Eclair(2.0) 源码中,Android 开发者对它应该有一种熟悉有陌生的感觉吧,我们刚开始学 Android 写 Activity 跳转动画的时候,都接触过这个。


    Intent intent = new Intent(B.this, C.class);
    startActivity(intent);
    overridePendingTransition(R.anim.fade_in, R.anim.fade_out);

    这段代码对每个 Android 同学都非常熟悉,而且至今在项目里面,到处都有它的身影。如果我们要为 Antivity 添加进入或者退出动画,那么只需要在 startActivity() 或者 finish() 方法之后立即调用 overridePendingTransition 即可。


    14 年后的今天,Android 14 的横空出世 overridePendingTransition 也完成了它的使命,在 Android 14 的源码中正式被废弃了,感兴趣的小伙伴,可以打开 Android 14 的源码看一下。



    当得知它都被废弃了,确实感到有些意外,源码中推荐我们使用新方法 overrideActivityTransition 代替 overridePendingTransition


    我还以为是什么更好的方法,结果推荐的方法更加的难用,为了一个虚有其表的功能,废弃了这个 API,还给开发者增加了很大的负担。


    按照 Android 官方的解释和源码中的说明,废弃掉这个方法是因为在 Android 14 中引入了新的返回动画,而 overrideActivityTransition 方法不能和它很好的做兼容,所以需要用新的方法去替换。


    什么是新的返回动画


    比如使用返回手势可以在应用后面显示主屏幕的动画预览。



    小伙伴们一起来评评这个功能实用性怎么样,为了这个功能废弃掉我们的老朋友,如果是你,你会这么做吗?另外我们在看看新的 API 的设计。



    新的 API 相比于旧 API 多了一个参数 overrideType,一起来看看源码中是如何描述这个参数 overrideType


    For example, if we want to customize the opening transition when launching 
    Activity B which gets started from Activity A, we should call this method inside
    onCreate with overrideType = OVERRIDE_TRANSITION_OPEN because the Activity B
    will on top of the task. And if we want to customize the closing transition when
    finishing Activity B and back to Activity A, since B is still is above A, we
    should call this method in Activity B with overrideType = OVERRIDE_TRANSITION_CLOSE.

    If an Activity has called this method, and it also set another activity animation
    by Window#setWindowAnimations(int), the system will choose the animation set from
    this method.

    翻译一下就是,每次想使用过渡动画,都必须告诉系统 overrideType 使用什么参数,比如当我们从 Activity A 打开 Activity B 时,需要使用参数 overrideType = OVERRIDE_TRANSITION ,当我们从 Activity B 返回到 Activity A 时,需要使用参数 overrideType = OVERRIDE_TRANSITION_CLOSE


    这个参数不是应该由系统自动来处理吗,开发者只需要关心参数 enterAnimexitAnim 即可,这明显没有带来任何好处,还给开发者增加了很多负担。


    这只是其中一个改变,Android 开发者应该都深有感触,每次 Android 的更新,都有一堆无用的改变,还给开发者增加了很多负担,每次的适配都是一堆体力活,这样就导致了 App 对 SDK 的版本产生了强烈的依赖。


    不过好在有经验的开发者,经历过一次有一次的适配之后,积累了经验,在新的项目中,会对大部分 Android API 进行封装,如果 API 有大的变化,不需要对整个项目进行修改。




    全文到这里就结束了,感谢你的阅读,坚持原创不易,欢迎在看、点赞、分享给身边的小伙伴,我会持续分享原创干货!!!




    我开了一个云同步编译工具(SyncKit),主要用于本地写代码,同步到远程设备,在远程设备上进行编译,最后将编译的结果同步到本地,代码已经上传到 Github,欢迎前往仓库 hi-dhl/SyncKit 查看。



    作者:程序员DHL
    来源:juejin.cn/post/7303878037590442022
    收起阅读 »

    大型 APP 的性能优化思路

    做客户端开发都基本都做过性能优化,比如提升自己所负责的业务的速度或流畅性,优化内存占用等等。但是大部分开发者所做的性能优化可能都是针对中小型 APP 的,大型 APP 的性能优化经验并不会太多,毕竟大型 APP 就只有那么几个,什么是大型 APP 呢?以飞书来...
    继续阅读 »

    做客户端开发都基本都做过性能优化,比如提升自己所负责的业务的速度或流畅性,优化内存占用等等。但是大部分开发者所做的性能优化可能都是针对中小型 APP 的,大型 APP 的性能优化经验并不会太多,毕竟大型 APP 就只有那么几个,什么是大型 APP 呢?以飞书来说,他的业务有 im,邮箱,日历,小程序,文档,视频会议……等等,包体积就有大几百 M,像这种业务非常多且复杂的 APP 都可以认为是大型 APP。所以我在这篇文章主要讲一下大型 APP 是怎么做性能优化的,给大家在性能优化一块提供一个新的视角和启发。在这篇文章中,我主要会讲一下这两个主题:




    1. 大型 app 相比于中小型 app 在做性能优化时的异同点




    2. 大型 app 性能优化的思路




    大型和小型应用性能优化的异同点


    1.1 相同点


    性能优化在本质上以及在优化维度上都是一样的。性能优化的本质是合理且充分的使用硬件资源,让程序表现的更好;并且都需要基于应用层、系统层、硬件层三个维度来进行优化


    whiteboard_exported_image.png


    1.2 不同点


    针对系统层和硬件层的优化都是一样,有区别的主要是针对应用层的优化。


    中小型 app 做业务和做性能优化的往往是同一个人,在做优化的时候,只需要考虑单个业务最优即可,我们只需要给这些业务充足的硬件资源(比如给更多的内存资源:缓存更多的数据,给更多的 cpu 资源:用更多的线程来执行业务逻辑,给更多的磁盘资源:缓存足够的本地数据),并且合理的使用就能让业务表现的更好。只要这些单个的业务性能表现好,那么这款 app 的整体性能品质是不错


    whiteboard_exported_image-2.png


    和中小型 APP 不同的是,大型 APP 业务多且复杂,各个业务的团队很可能都不在一个部门,不在同一个城市。在这种情况下,如果每个业务也去追求自己业务的性能最优,同样是在自己的业务中使用更多的线程,使用更多的缓存,使用更多 cpu 的方式来使自己业务表现更好,那么就会导致 APP 整体的性能急剧劣化。因此大型 APP 需要有一个专门团队来做性能优化的,这个团队需要脱离某一个具体业务,站在全局的视角来让 APP 整体表现更优。


    whiteboard_exported_image-3.png


    大型应用性能优化方案


    总的来说由于资源是有限的,在中小型 APP 上,业务少,资源往往是充足的,我们做性能优化时往往考虑的是怎么将资源充分的发挥出来,而在大型 APP 上,资源往往是不足的,做性能优化时需要考虑即能充分发挥硬件资源,又需要进行合理的分配,当我们站在全局的视角来进行资源分配时,就需要考虑到这三个点:



    1. 如何管控业务对资源的使用

    2. 如何度量业务对资源的消耗

    3. 如何让业务在资源紧张时做出更优的策略


    下面我会针对速度及流畅性优化、内存优化这两个方向,讲一讲针对这这三点的体现。


    2.1 速度和流畅性优化:如何管控业务对资源的使用


    在速度和流畅性方向,中小型 APP 只需要分析和优化主路径的耗时逻辑;将同步任务尽量优化成异步任务;多进行预加载等方案,即能起很好的优化效果。但是对于大型 APP 来说,异步任务往往非常多,cpu 往往都是打满的情况,这种情况下主路径得不到足够的 cpu 资源,导致速度变慢。所以大型 app 中一般都会对业务的异步任务,如启动阶段的预加载进行管控,因此需要预加载框架或者类似的框架,来收敛、管控、以及调度所有业务的预加载任务。我们来看一下在大型 APP 中,通用的预加载框架是怎么做的。


    2.1.1 预加载框架


    想要管控业务的预加载任务,我们需要考虑这两个点:




    1. 预加载任务的添加方式




    2. 预加载任务调度和管理的机制




    3. 预加载任务的添加方式




    首先要将业务的预加载任务和业务进行解耦,要能做到即使该预加载任务不执行,也不会影响到业务的正常使用,并且将预加载任务封装成粒度最小的 task,然后直接将这些 task 丢到到预加载框架中,我们可以通过单例提供一个 addPreloadTask 方法,业务方只需要调用该接口,并传入预加载任务 task 以及一些属性及配置参数即可。将预加载任务添加到预加载框架后,业务方就不需要进行任何其他操作了,是否执行、什么时候执行,都交给预加载框架来管理。


    whiteboard_exported_image-4.png




    1. 预加载任务调度时机




    那么预加载框架对于添加进来的 task 如何调度呢?这就是一个预加载框架复杂的的地方的,我们可以有很多策略,比如这三种:



    1. 关键节点调度策略:比如各个生命周期阶段,页面渲染完成阶段等去执行,也可以在任务添加进来后立刻执行。

    2. 性能调度策略:比如判断 cpu 是否忙碌,温度是否过高,内存是否充足等,只有在性能较好的情况下才进行调度

    3. 用户行为调度策略:如果做的更复杂一些,还可以结合用户的行为指标,如该业务用户是否会使用,如果某一个用户从来不适用这个 app 里的这个功能,那么改业务添加进来的预加载任务就可以完全舍弃到,这里面可以用一些端智能的方案来精细化的控制预加载任务的调度


    每种调度策略不是单独执行的,我们可以将各种策略整合起来,形成一套完善的调度策略。


    whiteboard_exported_image-5.png


    2.2 速度和流畅性优化:如何让业务在资源紧张时做出更优的策略


    上面提到的是站在全局的视角,如何管控预加载任务的,除了预加载任务,还有很多其他的异步任务我们都可以用一些框架来规范化的管控起来,这里再举一个例子,对于大型 APP 来说,业务在使用的过程中很容易出现因为 cpu 或内存不足导致卡顿,响应慢等性能问题,所以在做性能优化时,是需要推动业务方在资源不足时,做出相应策略的,这个时候我们就需要降级框架来处理了。降级框架需要解决这两个问题:



    1. 性能指标的采集

    2. 降级任务的调度


    2.2.1 降级框架




    1. 性能指标的采集




    想要再资源紧张时让业务做出优化策略,那么对资源紧张的判断就是必不可少的一步。我们一般通过在程序运行过程中,采集设备性能指标来判断资源是否紧张,最基本的性能指标有 cpu 使用率,温度,Java 内存,机型等,除机型外其他性能指标一般都是以固定的频率进行采集,如 cpu 使用率可以 10s 采集一次,温度可以 30s 采集一次,java 内存可以 1 分钟采集一次,采集的频率需要考虑对性能的影响以及指标的敏感度,比如 cpu 的使用率采集,需要读取 proc/stat 的文件并解析,是有一定性能损耗的,所以我们在采集时,不能太频繁;温度的变化是比较慢的,我们采集的频率也可以长一些。降级框架需要整合这些性能指标的采集,减少各个业务自己采集造成不必要的性能损耗。


    当降级框架采集到性能指标,并判断当前资源异常时,通用的做法是通知各个业务,业务收到通知后再进行降级。比如系统的 lowmemorykiller 机制,都是采用通知的方式。


    whiteboard_exported_image-6.png


    但是在大型 APP 中,仅仅将触发性能阈值的通知给到各个业务方,效果不会太好,因为业务方可能并不会去响应通知,或者个别业务响应了,但是其他业务不响应,依然效果不佳。无法管控业务是否进行降级,这显然不符合在大型 APP 做性能优化的思路,那么我们要怎么做呢?




    1. 降级任务的调度




    添加任务:我们依然可以推动各个业务将降级的逻辑封装在 task 中,并且注册到降级框架中,并由降级框架来进行调度和管理。因为往降级框架注册 task 时,需要带上业务的名称,所以我们能也能清楚的知道,那些业务有降级处理逻辑,哪些业务没有,对于没有注册的业务,需要专门推动进行降级响应以及 task 的注册。


    调度任务:和预加载框架一样,对于注册进来的 task,降级框架的任务调度要考虑清楚调度的时机,以 cpu 使用率为例,不同的设备下的阈值也是不一样的,高端机型可能 cpu 的使用率在 70%以上,app 还是流畅的,但是低端机在 50%以上就开始卡顿了,因此不同的机型需要根据经验值或者线上数据设置一个合理的阈值。当 cpu 到达这个阈值时,降级框架便开始执行注册到 cpu 列表中的降级任务,在执行降级任务时,不需要将队列里的 task 全部执行,我们可以分批执行,如果执行到某一批降级 task 时,cpu 恢复到阈值以下了,后面的降级 task 就可以不用在执行了。可以看到,通过降级框架,我们就可以站在全局的维度,去进行更好的管控,比如我们可以度量业务做降级任务的效果,给到一个评分,对于效果不好的,可以推动优化。


    whiteboard_exported_image-7.png


    2.3 内存优化:如何度量业务对资源的消耗


    上面两个例子将的是在大型 app 中,如何管控业务对资源的使用,以及如何让业务在资源紧张时做出更优的策略的思路,我接着基于内存优化的方向,讲一讲如何度量业务对资源的消耗。


    当 app 运行过程中,往往只能获得整体的内存的数据占用,没法获的各个业务消耗了多少内存的,因为各个业务的数据都是放在同一个堆中的,对于小型 app 来说这种情况并不是问题,因为就那么几个业务在使用内存,但是对于大型 app 来说就是一个问题了,有些业务为了自己性能指标能更好,会占用更多的内存,导致整体的内存占用过高。所以我们需要弄清每个业务到底使用了多少内存才能推动业务进行优化。


    whiteboard_exported_image-10.png


    我们可以线下通过分析 hprof 文件或者其他调试的方式来弄清楚每个 app 的内存占用,但是很多时候没有充足的时间在版本都去统计一下,或者即使统计了,也可能因为路径没覆盖全导致数据不准确。所以我们最好能通过线上监控的方式,就能统计到业务的内存消耗,并且在内存消耗异常的时候进行上报。


    我在这里介绍一种思路。大部分的业务都是以 activity 呈现的,所以我们可以监听全局的 activity 创建,在业务的 onCreate 最前面统计一下 java 和 native 内存的大小,作为这个业务启动时的基准内存。然后在 acitvity 运行过程中,固定采集在当前 activity 下的内存并减去 onCreate 时的基准内存,我们就能度量出当前业务的一个内存消耗情况了。在该 acitvity 结束后,我们可以主动触发一下 gc,然后在和前面的基准内存 diff 一下,也能统计出该业务结束后的增量内存,理想情况下,增量内存应该是要小于零的,由于 gc 需要 cpu 资源,所以我们只需要开取小部分的采样率即可。


    whiteboard_exported_image-8.png


    当我们能在运行过程中,统计各个业务的内存消耗,那么就可以推动内存消耗高的业务进行优化,或者当某个版本的某个业务出现较大的劣化时,触发报警等。


    除了上面提到的思路,我们也可以统计在业务使用过程中的触顶次数,计算出一个触顶率的指标,触顶及 java 内存占用达到一个阈值,比如 80%,我们就可以认为触顶了,对于触顶次数高的业务,同样也可以进行异常上报,然后推动业务方进行修改。这些数据和指标的统计,都是无侵入的,所以并不需要我们了解业务的细节。


    如果我们想做的更细一些,还可以 hook 图片的创建,hook 集合的 add,remove 等方法,当监控到大图片和大集合时,打印堆栈,并将关键信息上报。在飞书,如果是低端机中,图片如果占用内存过大的,都会在 hook 方法中进行一些压缩或者降低质量的兜底处理。


    总结


    除了速度及流畅性,内存方向的优化外,还有其他方向的优化,如包体积,稳定性,功耗等,在大型 APP 上都要基于管控业务对资源的使用;度量业务对资源的消耗;让业务在资源紧张时做出更优的策略这三个方向去进行优化,这里我就不再一一展开讲了。


    whiteboard_exported_image-9.png


    当然我这里讲的优化思路并不是大型 app 做性能优化的全部,我讲的只是在做大型 app 的性能时相比于中小型 app 需要额外做的,并且也是效果最好的优化,这些方案在中小型 app 上可能并不需要。除了我这篇文章讲的内容外,还有很多优化的方案,这些方案不管是在大型 app 还是中小型 app 上都是通用的,比如深入了解业务,基于业务逻辑去做分析和优化,抓 trace,分析 trace 等等,或者基于系统层或者硬件层去做一些优化等等,这里就不再展开讲了。


    作者:helson赵子健
    来源:juejin.cn/post/7302740437529853963
    收起阅读 »

    鸿蒙开发,对于前端开发来说,究竟是福是祸呢?

    提前声明: 先说好哈,不要一上来就开喷,好吧,不感兴趣的话你可以不用看下文直接划走,直接喷俺有点承受不住,心脏不好。如果你感兴趣,你可以先把这篇文章看完,看完后感觉俺讲的还挺有道理的那就不喷,如果讲的你认为啥也不是,那就往死里喷,喷不动了俺也加入。 唠叨唠叨 ...
    继续阅读 »

    提前声明: 先说好哈,不要一上来就开喷,好吧,不感兴趣的话你可以不用看下文直接划走,直接喷俺有点承受不住,心脏不好。如果你感兴趣,你可以先把这篇文章看完,看完后感觉俺讲的还挺有道理的那就不喷,如果讲的你认为啥也不是,那就往死里喷,喷不动了俺也加入。


    唠叨唠叨


    最近,鸿蒙开发的风头也吹到俺这里了,于是我便上网看了看,就以俺的知识面去聊一聊鸿蒙,究竟是个啥,有啥用呢。


    在此之前,咱们可以先看个视频来大致了解一下鸿蒙系统究竟是干啥的,有啥好处:鸿蒙的官方定义哔哩哔哩bilibili(该视频为黑马的课程视频,原视频没暂时没找到,可跳到 03:46~12:1713:27~19:35 两个时间段)。


    如果你看了这个视频的话,相信你对鸿蒙也有了一定的了解了。


    为啥我想说鸿蒙呢



    最近一段时间,总是有人在说一些(俺认为哈,别人我就管不着了哈)有些莫名其妙的话术:什么前端以死呀、鸿蒙就是个安卓套壳呀、前端的春天要来了呀、等等之类的。是真的死了吗,俺不这样认为,只是技术门槛提高了而已,毕竟市场他是活的,人它也是活的,是活的话他就有变的时候,你的技术不变,不去进行升级的话,那就会被现有的市场所淘汰。优胜劣汰这个道理俺相信你们每个人都懂,只是有些人不想去面对而已,仅此而已。



    鸿蒙系统又是个啥


    俺简单来说哈,其实就一句话:鸿蒙系统是全场景 、面向未来、万物物联的


    如果这句话比较难理解,或者俺通过一张图让你更直观一点:


    Snipaste_2023-11-15_17-23-22.png


    如果你还是不理解的话,可以去华为官网看看官方对于鸿蒙系统的解释。


    那鸿蒙系统的特点有啥



    1. 统一OS,弹性部署


    一套操作系统,满足大大小小所有设备的需求,小到耳机,大到车机,智慧屏,手机等,让不同设备使用同一语言无缝沟通。



    1. 硬件互助,资源共享


    搭载 HarmonyOS 的每个设备都不是孤立的,在系统层让多终端融为一体,成为“超级终端”,终端之间能力互助共享,带来无缝协同体验。手机可以连接一切设备,可以将这些设备看作一个整体,如当手机需要操作自家的音响时,直接在手机上简单动一动手指头就行了,就像操作手机上的喇叭一样方便。



    1. 一次开发,多端部署


    开发者基于分布式应用框架,开发者只需要写一次逻辑代码,就可以部署在多种终端上,在多个设备上安装了。



    1. 应用自由跨端


    HarmonyOS 原子化服务是轻量化服务的新物种,它提供了全新的服务和交互方式,可分可合,可流转,支持免安装等特性,能够让应用化繁为简,让服务触手可及。



    • 咱们来以一个例子理解一下:


    假设咱们要用安卓操作系统去控制一台音响,这台音响有切歌功能、点歌功能、语音聊天功能,现在俺有点寂寞,需要音响陪我聊会天,俺只需要音响的语音聊天功能,但你必须要下载他的完整APP,并不能俺需要用啥功能就下载啥功能。而鸿蒙系统就可以做到。



    1. 用“简单”激活你的设备智能


    HarmonyOS 是新一代智能终端操作系统。为不同设备的智能化、互联与协同提供了统一的语言。设备可实现一碰入网,无屏变有屏,操作可视化,一键直达原厂服务等全新功能。通过简单而智能的服务,实现设备智能化产业升级。


    用安卓操作系统时,你需要下载设备对应的APP才能控制该设备,而鸿蒙操作系统,你直接将手机与设备上的芯片碰一碰,就可以直接通过手机来使用设备了。


    小提示: 俺家也没几个鸿蒙相关的设备,具体的俺也不是特别清楚,这些都是俺从网上了解到的。手机能连接上设备的前提是该设备的厂家与华为达成了合作才行吧(好像是这样的)。但俺用的是华为手机,路由器也是华为的,就这两个华为设备从俺的体验上来说哈,那还是不错的。


    可以与安卓做下对比



    1. 内核方面的对比


    安卓系统:


    是基于linux的宏内核设计 ,宏内核包含了操作系统绝大多数的功能和模块,而且这些功能和模块都具有最高的权限,只要一个模块出错,整个系统就会崩溃,这也是安卓系统容易崩溃的原因。好处就是系统开发难度低。


    鸿蒙系统:


    是微内核设计:微内核仅包括了操作系统必要的功能模块(任务管理、内存分配等)处在核心地位具有最高权限,其他模块不具有最高权限,也就是说其他模块出现问题,对于整个系统的运行是没有阻碍的。微内核稳定性很高。而且鸿蒙系统包含了两个内核,如果是手机APP是基于Linux内核,而如果是其他的硬件是基于LiteOS内核



    1. 运行速度的对比


    安卓系统:


    安卓程序不能与系统底层直接进行通信活动,是运行在虚拟机上的。如果虚拟机出了问题话的那系统就是卡住。


    鸿蒙系统:


    鸿蒙系统中的方舟编译器解决了这个问题的,任何由编译器编译的软件,是直接运行在操作系统中的,可以直接与系统底层进行通信。鸿蒙的运行速度更快



    1. 作为手机操作系统的对比


    安卓和鸿蒙都是基于安卓开源项目(AOSP)进行开发的。


    而安卓开源平台是可以在开源许可下自由使用和修改的。国内外很多手机厂商都根据这套代码制定了自己的操作系统,比如:三星、小米、魅族等。而华为也是基于这套开源体系,制定了鸿蒙操作系统。


    鸿蒙操作系统的构成:



    HarmonyOS = 安卓开放平台的开源代码 - GMS - 安卓UI + HMS + 鸿蒙UI + 分布式软总线 + 以Ability为核心的应用开发框架。




    1. 连接其他设备的对比


    安卓系统:


    安卓手机连接其他设备,不管从 app 开发方面,还有使用方面都非常麻烦,而且如果换一个第三方设备,还需要把发现,配对,连接,组合,验证的过程再次操作一遍。


    鸿蒙系统:


    但是鸿蒙就非常简单了,从 app 开发方面,只要写很少的代码就可以调用第三方硬件,从使用的角度来讲,不管是多少设备连在一起,鸿蒙的终极目标是都能像使用一台设备那样简单。


    那鸿蒙究竟是不是安卓的套壳呢



    网上有很多人说鸿蒙就是安卓的套壳,还用人说人家搞安卓开发的都是这样认为的。都不太看好鸿蒙,不要跟风,好吧。别人说是就是呀。你真的有去认真了解过吗。经过俺的一番捯饬后,俺大致的讲讲俺的理解哈。



    其实吧,为啥有这么多人说鸿蒙就是安卓的套壳呢,归根结底呀,是这两家的 “祖宗” 其实是一家人,也就是安卓和鸿蒙都是基于安卓开源项目 AOSP 进行开发的。而且 AOSP 里的代码,是全球很多开发者共同维护开发的,华为也是该代码的提供者之一,任何人都是可以在开源许可协议下去自由使用和二次修改的。而华为也是基于这套开源体系,制定了鸿蒙操作系统。这就是为啥都说鸿蒙是安卓的套壳的原因了。


    小提示: 可能会有人问俺 AOSP 又是啥东东,俺在网上找了一篇文章,你可以看看,了解一下:鸿蒙系统不是安卓系统?AOSP 为你揭秘! (baidu.com)


    所以呢,不是套壳、不是套壳、不是套壳重要的事说三遍哈。你要是还是那样认为那话,那俺只能说,我嘞个豆!!!


    就国家政策和市场形式


    其实从央视力挺华为就可以看出了,我国对鸿蒙系统还是相当重视的(网传,鸿蒙系统会上交给国家,俺也不知道是真是假)。


    就俺认为哈,代码这玩意都是老外搞出来的,一个操作系统能难倒他们,只是安卓和ios这两家独大,资历雄厚。可能有国外有好的操作系统出现,只是还没广为人知就已经被资本扼杀在摇篮里了。这又有谁知道呢。当然了这写只是俺的猜测而已。


    如果一个国家的操作系统多了,其实也不利于社会的管理和发展,国家一定会主推一个操作系统,然后其他系统为辅,从而形成 “百家争鸣” 的形式。


    另外哈,俺在招聘网上也查了看了一下,鸿蒙开发相关岗位的薪资大都与安卓开发平齐甚至有的还比安卓开发相关岗位的薪资要高得多(俺看到一家的鸿蒙开发的薪资,18~30K 16薪,说实话哈,俺是真的心动了)。


    声明一下: 以上有关的国家和社会的话术,都是俺自我认为的、理解的,请广大网友不必太纠结其对错,更不要上升到国家层面去给予评论和回复。谢谢!


    回归主题


    回归主题: 鸿蒙开发,对于前端开发来说,究竟是福是祸呢?


    看个人理解吧,俺认为哈,是福(俺已经开始学了)。就国内哈,如果明年华为推出的 HarmonyOS Next 将真的彻底抛弃 AOSP (华为的这个决定很大胆,这也是大部分的安卓开发者头痛的事,所以才会非常反感鸿蒙)。也就是说,明年,所有的安卓应用将不能在华为手机上使用,要想使用的话,就必须采用鸿蒙原生开发将应用改为鸿蒙应用程序。那你想想哈,我国有多少个应用,又有几个是用鸿蒙原生开发的或重构的,你再想想哈,这么多应用都要重构,那是不是这工作量非常之巨大,这么巨大的工作量,那公司是不是要招鸿蒙开发相关岗位了,薪资给少了你肯定不愿意去呀,那它公司又急需呀,那他的薪资待遇会不会被提高。那如果你会的话那你是不是就能上,那样的话害怕找不到工作。


    这就是相当于前端开发的一个红利期,而且这个红利期至少会持续两三年势头不会淡。其实俺说它是前端开发的春天的话也不为过,至少是在国内哈,国外俺就不知道啦。


    当然这还得等到明年华为推出的 HarmonyOS Next 是否真的彻底抛弃 AOSP ,如果是的话,那俺的认为就是对的。如果是假的话,那此上的一切都免谈,都是瞎扯淡。


    上手试试



    小提示哈: 如果你看完了上面的内容,你发现对鸿蒙开发产生了一定的好奇,你可以直接去官网注册个账号HarmonyOS应用开发官网 - 华为HarmonyOS打造全场景新服务,实名认证一下(俺建议采用银行卡的方式认证,这样通过认证更快),然后里面有在线的视频课程,它会带你具体了解如何开发鸿蒙原生应用。下面的内容你就可以忽略了。




    Snipaste_2023-11-15_18-26-04.png


    俺电脑上没装v16.19.1版本的node:俺用的是16.20.1的,不知道行不行,再装一个吧:


    Snipaste_2023-11-15_18-34-21.png


    路径与编译工具的安装地址是一致的:


    Snipaste_2023-11-15_18-37-10.png


    点击next,如果出现报红,选择第二个就可以了哈。


    Snipaste_2023-11-15_18-42-37.png


    创建个应用:


    Snipaste_2023-11-15_18-46-29.png


    Snipaste_2023-11-15_18-48-55.png


    创建第一个应用 FirstApp


    Snipaste_2023-11-15_18-55-34.png


    咻咻等待一下的啦,让项目配置一下资源。


    Snipaste_2023-11-15_19-04-17.png


    第一次运行会有上图的提示信息,将其 × 了就可以看到 Hello World 效果了


    Snipaste_2023-11-15_19-07-53.png


    小改一下:


    Snipaste_2023-11-15_19-09-37.png


    使用模拟器:


    Snipaste_2023-11-15_19-11-51.png


    Snipaste_2023-11-15_19-15-31.png


    登录后,选着P50机型模拟器调试:


    Snipaste_2023-11-15_19-42-35.png



    也不知道为啥,有时候就是无法用P50机型模拟器调试。后来俺还是用了本地模拟器。



    作者:doudou_sir
    来源:juejin.cn/post/7302254338855338003
    收起阅读 »

    Android进阶宝典 -- App线上网络问题优化策略

    在我们App开发过程中,网络是必不可少的,几乎很难想到有哪些app是不需要网络传输的,所以网络问题一般都是线下难以复现,一旦到了用户手里就会碰到很多疑难杂症,所以对于网络的监控是必不可少的,针对用户常见的问题,我们在实际的项目中也需要添加优化策略。 1 网络的...
    继续阅读 »

    在我们App开发过程中,网络是必不可少的,几乎很难想到有哪些app是不需要网络传输的,所以网络问题一般都是线下难以复现,一旦到了用户手里就会碰到很多疑难杂症,所以对于网络的监控是必不可少的,针对用户常见的问题,我们在实际的项目中也需要添加优化策略。


    1 网络的基础优化


    对于一些主流的网络请求框架,像OkHttp、Retrofit等,其实就是对Http协议做了封装,我们在使用的时候常见的就是POST或者GET请求,如果我们是做客户端开发,知道这些基本的内容好像也可以写代码,但是真正碰到了线上网络问题,反而摸不到头脑,其实最大的问题还是对于网络方面的知识储备不足,所以文章的开始,我们先来点基础的网络知识。


    1.1 网络连接的类型


    其实对于网络的连接,我们常见的就是向服务端发起请求,服务端返回对应的响应,但是在同一时刻,只能有一个方向的数据传输,这种连接方式称为半双工通信。


    类型描述举例
    单工在通信过程中,数据只能由一方发送到另一方常见的例如UDP协议;Android广播
    半双工在通信过程中,数据可以由一方A发送到另一方B,也可以从一方B发送到另一方A,但是同一时刻只能存在一方的数据传输常见的例如Http协议
    全双工在任意时刻,都会存在A到B和B到A的双向数据传输常见的例如Socket协议,长连接通道

    所以在Http1.0协议时,还是半双工的协议,因为默认是关闭长连接的,如果需要支持长连接,那么就需要在http头中添加字段:“Connection:Keep-Alive”;在Http 1.1协议时,默认是开启了长连接,如果需要关闭长连接,那么需要添加http请求头字段:“Connection:close”.


    那么什么时候或者场景下,需要用到长连接呢?其实很简单,记住一点即可,如果业务场景中对于消息的即时性有要求时,就需要与服务端建立长连接,例如IM聊天,视频通话等场景。


    1.2 DNS解析


    如果伙伴们在项目中有对网络添加trace日志,除了net timeout这种超时错误,应该也看到过UnknowHostException这种异常,这是因为DNS解析失败,没有解析获取到服务器的ip地址。


    像我们在家的时候,手机或者电脑都会连接路由器的wifi,而路由器是能够设置dns服务器地址的,


    image.png


    但是如果设置错误,或者被攻击篡改,就会导致DNS解析失败,那么我们app的网络请求都会出现异常,所以针对这种情况,我们需要加上自己的DNS解析策略。


    首先我们先看一个例子,假设我们想要请求百度域名获取一个数据,例如:


    object HttpUtil {

    private const val BASE_URL = "https://www.baidu.comxx"

    fun initHttp() {
    val client = OkHttpClient.Builder()
    .build()
    Request.Builder()
    .url(BASE_URL)
    .build().also {

    kotlin.runCatching {
    client.newCall(it).execute()
    }.onFailure {
    Log.e("OkHttp", "initHttp: error $it ")
    }

    }
    }
    }

    很明显,百度的域名是错误的,所以在执行网络请求的时候就会报错:


    java.net.UnknownHostException: Unable to resolve host "www.baidu.comxx": No address associated with hostname

    所以一旦我们的域名被劫持修改,那么整个服务就会处于宕机的状态,用户体感就会很差,因此我们可以通过OkHttp提供的自定义DNS解析器来做一个小的优化。


    public interface Dns {
    /**
    * A DNS that uses {@link InetAddress#getAllByName} to ask the underlying operating system to
    * lookup IP addresses. Most custom {@link Dns} implementations should delegate to this instance.
    */

    Dns SYSTEM = hostname -> {
    if (hostname == null) throw new UnknownHostException("hostname == null");
    try {
    return Arrays.asList(InetAddress.getAllByName(hostname));
    } catch (NullPointerException e) {
    UnknownHostException unknownHostException =
    new UnknownHostException("Broken system behaviour for dns lookup of " + hostname);
    unknownHostException.initCause(e);
    throw unknownHostException;
    }
    };

    /**
    * Returns the IP addresses of {@code hostname}, in the order they will be attempted by OkHttp. If
    * a connection to an address fails, OkHttp will retry the connection with the next address until
    * either a connection is made, the set of IP addresses is exhausted, or a limit is exceeded.
    */

    List<InetAddress> lookup(String hostname) throws UnknownHostException;
    }

    我们看下源码,lookup方法相当于在做DNS寻址,一旦发生异常那么就会抛出UnknownHostException异常;同时内部还定义了一个SYSTEM方法,在这个方法中会通过系统提供的InetAddress类进行路由寻址,同样如果DNS解析失败,那么也会抛出UnknownHostException异常。


    所以我们分两步走,首先使用系统能力进行路由寻址,如果失败,那么再走自定义的策略。


    class MyDNS : Dns {


    override fun lookup(hostname: String): MutableList<InetAddress> {

    val result = mutableListOf<InetAddress>()
    var systemAddressList: MutableList<InetAddress>? = null
    //通过系统DNS解析
    kotlin.runCatching {
    systemAddressList = Dns.SYSTEM.lookup(hostname)
    }.onFailure {
    Log.e("MyDNS", "lookup: $it")
    }

    if (systemAddressList != null && systemAddressList!!.isNotEmpty()) {
    result.addAll(systemAddressList!!)
    } else {
    //系统DNS解析失败,走自定义路由
    result.add(InetAddress.getByName("www.baidu.com"))
    }

    return result
    }
    }

    这样在www.baidu.comxx 解析失败之后,就会使用www.baidu.com 域名替换,从而避免网络请求失败的问题。


    1.3 接口数据适配策略


    相信很多伙伴在和服务端调试接口的时候,经常会遇到这种情况:接口文档标明这个字段为int类型,结果返回的是字符串“”;或者在某些情况下,我需要服务端返回一个空数组,但是返回的是null,对于这种情况,我们在数据解析的时候,无论是使用Gson还是Moshi,都会解析失败,如果处理不得当,严重的会造成崩溃。


    所以针对这种数据格式不匹配的问题,我们可以对Gson简单做一些适配处理,例如List类型:


    class ListTypeAdapter : JsonDeserializer<List<*>> {
    override fun deserialize(
    json: JsonElement?,
    typeOfT: Type?,
    context: JsonDeserializationContext?
    )
    : List<*> {
    return try {
    if (json?.isJsonArray == true) {
    Gson().fromJson(json, typeOfT)
    } else {
    Collections.EMPTY_LIST
    }
    } catch (e: Exception) {
    //
    Collections.EMPTY_LIST
    }
    }
    }

    如果json是List数组类型数据,那么就正常将其转换为List数组;如果不是,那么就解析为空数组。


    class StringTypeAdapter : JsonDeserializer<String> {

    override fun deserialize(
    json: JsonElement?,
    typeOfT: Type?,
    context: JsonDeserializationContext?
    )
    : String {
    return try {
    if (json?.isJsonPrimitive == true) {
    Gson().fromJson(json, typeOfT)
    } else {
    ""
    }
    } catch (e: Exception) {
    ""
    }
    }
    }

    对于String类型字段,首先会判断是否为基础类型(String,Number,Boolean),如果是基础类型那么就正常转换即可。


    GsonBuilder()
    .registerTypeAdapter(Int::class.java, IntTypeAdapter())
    .registerTypeAdapter(String::class.java, StringTypeAdapter())
    .registerTypeAdapter(List::class.java, ListTypeAdapter())
    .create().also {
    GsonConverterFactory.create(it)
    }

    这样在创建GsonConverterFactory时,就可以使用我们的策略来进行数据适配,但是在测试环境下,我们不建议这样使用,因为无法发现服务端的问题,在上线之后为了规避线上问题可以使用此策略。


    2 HTTPS协议


    http协议与https协议的区别,就是多了一个“s”,可别小看这一个“s”,它能够保证http数据传输的可靠性,那么这个“s”是什么呢,就是SSL/TLS协议。


    image.png


    从上图中看,在进入TCP协议之前会先走SSL/TLS协议.


    2.1 对称加密和非对称加密


    既然Https能保证传输的可靠性,说明它对数据进行了加密,以往http协议数据的传输都是明文传输,数据极容易被窃取和冒充,因此后续优化中,对于数据进行了加密传输,才有了Https协议诞生。


    常见的加密手段有两种:对称加密和非对称加密。


    2.1.1 对称加密


    首先对称加密,从名字就能知道具体的原理,看下图:


    image.png


    对称加密和解密的密钥是一把钥匙,需要双方约定好,发送方通过秘钥加密数据,接收方使用同一把秘钥解密获取传递的数据。


    所以使用对称加密非常简单,解析数据很快,但是安全性比较差,因为双方需要约定同一个key,key的传输有被劫持的风险,而统一存储则同样存在被攻击的风险。


    所以针对这种情况,应运而生出现了非对称加密。


    2.1.2 非对称加密


    非对称加密会有两把钥匙:私钥 + 公钥,对于公钥任何人都可以知道,发送方可以使用公钥加密数据,而接收方可以用只有自己知道的私钥解密拿到数据。


    image.png


    那么既然公钥所有人都知道,那么能够通过公钥直接推算出私钥吗?答案是目前不可能,未来可能会,得看全世界的密码学高手或者黑客能否解决这个问题。


    总结一下两种加密方式的优缺点:


    加密类型优点缺点
    对称加密流程简单,解密速度快不安全,秘钥管理有风险
    非对称加密私钥只有自己知道流程繁琐,解密速度慢

    2.2 公钥的安全保障


    通过2.1小节对于非对称加密的介绍,虽然看起来安全性更高了一些,但是对于公钥的传递有点儿太理想化,我们看下面的场景。


    image.png


    如果公钥在传输的过程中被劫持,那么发送方拿到的是黑客的公钥,后续所有的数据传输都被劫持了,所以问题来了,如何保证发送方拿到的公钥一定是接收方的?


    举个简单的例子:我们在马路上捡到了一张银行卡,想把里面的钱取出来,那么银行柜台其实就是接收方,银行卡就是公钥,那么银行就会直接把钱给我们了吗?肯定不可以,要么需要身-份-证,要么需要密码,能够证明这个银行卡是我们自己的,所以公钥的安全性保证就是CA证书(可以理解为我们的身-份-证)。


    那么首先接收方需要办一张身-份-证,需要通过CA机构生成一个数字签名,具体生成的规则如下:


    image.png


    那么最终发送给接收方的就是如下一张数字证书,包含的内容有:数字签名 + 公钥 + 接收方的个人信息等。


    image.png


    那么发送方接收到数字证书之后,就会检查数字证书是否合法,检测方式如下:


    image.png


    如果不是办的假证,这种可能性几乎为0,因为想要伪造一个域名的数字签名,根本不可能,CA机构也不是吃干饭的,所以只要通过证书认证了,那么就能保证公钥的安全性。


    image.png


    2.3 Https的传输流程


    其实一个Https请求,中间包含了2次Http传输,假如我们请求http://www.baidu.com 具体流程如下:


    (1)客户端向服务端发起请求,要访问百度,那么此时与百度的服务器建立连接;


    (2)此时服务端有公钥和私钥,公钥可以发送给客户端,然后给客户端发送了一个SSL证书,其中包括:CA签名、公钥、百度的一些信息,详情可见2.2小节最后的图;


    (3)客户端在接收到SSL证书后,对CA签名解密,判断证书是否合法,如果不合法,那么就断开此次连接;如果合法,那么就生成一个随机数,作为数据对称加密的密钥,通过公钥加密发送到服务端。


    (4)服务端接收到了客户端加密数据后,通过私钥解密,拿到了对称加密的密钥,然后将百度相关数据通过对称加密秘钥加密,发送到客户端。


    (5)客户端通过解密拿到了服务端的数据,此次请求结束。


    其实Https请求并不是完全是非对称加密,而是集各家之所长,因为对称加密密钥传递有风险,因此前期通过非对称加密传递对称加密密钥,后续数据传递都是通过对称加密,提高了数据解析的效率。


    但是我们需要了解的是,Https保障的只是通信双方当事人的安全,像测试伙伴通过Charles抓包这种中间人攻击方式,还是会导致数据泄露的风险,因为通过伪造证书或者不受信任的CA就可以实现。


    作者:layz4android
    来源:juejin.cn/post/7276368438146924563
    收起阅读 »

    华为鸿蒙app开发,真的遥遥领先?

    前言 最近刷头条,刷到很多开始鸿蒙系统app开发者说 鸿蒙系统要崛起了 属于国家意志。于是我也在周五空闲时间去华为官网学习一下,体验一下遥遥领先的感觉。 developer.huawei.com/ 官网下载下载DevEco Studio 下载流程就不用细说了 ...
    继续阅读 »

    前言


    最近刷头条,刷到很多开始鸿蒙系统app开发者说 鸿蒙系统要崛起了 属于国家意志。于是我也在周五空闲时间去华为官网学习一下,体验一下遥遥领先的感觉。
    developer.huawei.com/ 官网下载下载DevEco Studio


    下载流程就不用细说了 借鉴一下别人的文章,主要核心在于按照官网学习了一个ToDo的例子


    鸿蒙OS应用开发初体验


    启动页面


    image.png


    Setup


    image.png


    image.png



    HarmonyOS-SDK:鸿蒙操作系统软件开发工具包



    • Previewer:预览器

    • Toolchains:工具链



    OpenHarmony-SDK:开源鸿蒙操作系统软件开发工具包




    • ArkTS:鸿蒙生态的应用开发语言。

    • JS:JavaScript

    • Previewer:预览器

    • Toolchains:工具链



    image.png


    Create Project


    image.png image.png


    配置工程


    image.png 项目名称、包名、存储路径、编译SDK版本、模型,语言、设备类型等。


    工程目录结构


    image.png



    • AppScope:存放应用全局所需要的资源文件。

    • entry:应用主模块,存放HarmonyOS应用的代码、资源等。

    • on_modules:工程依赖包,存放工程依赖的源文件。

    • build-profile.json5是工程级配置信息,包括签名、产品配置等。

    • hvigorfile.ts是工程级编译构建任务脚本,hvigor是基于任务管理机制实现的一款全新的自动化构建工具,主要提供任务注册编排,工程模型管理、配置管理等核心能力。

    • oh-package.json5是工程级依赖配置文件,用于记录引入包的配置信息


    TODO例子


    这里我我干掉了初始代码 实现了一个TODO例子 源码贴出来了


    image.png



    import ArrayList from '@ohos.util.ArrayList'
    @Entry
    @Component
    struct Index {
    @State message: string = 'Hello World'
    private taskList:Array<String>=[
    '吃饭',
    '睡觉',
    '遛娃',
    '学习'
    ]
    build() {
    Column() {
    Text('待办')
    .fontSize(50)
    .fontWeight(FontWeight.Bold)
    .align(Alignment.Start)
    ForEach(this.taskList,(item)=>{
    ToDoItem({ content: item })
    })

    }.height('100%')
    .width('100%')
    .backgroundColor('#e6e6e6')

    }
    }

    @Component
    struct ToDoItem {
    private content: string;
    @State isComplete: boolean = false;
    @State isClicked: boolean = false;
    build() {
    Row() {
    Image($r('app.media.app_icon'))
    .width(20)
    .margin(10)
    Text(this.content)
    .fontSize(20)
    .fontWeight(FontWeight.Bold)
    .fontColor(Color.Black)
    .opacity(this.isComplete ? 0.4 : 1)
    .decoration({ type: this.isComplete ? TextDecorationType.Underline : TextDecorationType. })
    }.borderRadius(24)
    .width('100%')
    .padding(20)
    .backgroundColor(this.isClicked ? Color.Gray : Color.White)
    .margin(10)
    .onClick(
    ()=>{
    this.isClicked = true; // 设置点击状态为true
    setTimeout(() => {
    this.isClicked = false; // 0.5秒后恢复点击状态为false
    }, 500);
    this.isComplete=!this.isComplete
    }
    )
    }
    }

    总结


    在模拟器上 点击啥的效果还好 但是在我华为p40上的真机运行效果真的点击效果响应太慢了吧。本人也是华为手机的爱好者,但这一次真的不敢苟同谁敢用这样的平台开发app。有深入学习的大佬指点一下,望花粉勿喷。


    作者:阡陌昏晨
    来源:juejin.cn/post/7302070112639385651
    收起阅读 »

    鸿蒙OS应用开发初体验

    什么是HarmonyOS? HarmonyOS(鸿蒙操作系统)是华为公司开发的一款基于微内核的分布式操作系统。它是一个面向物联网(IoT)时代的全场景操作系统,旨在为各种类型的设备提供统一的操作系统平台和开发框架。HarmonyOS 的目标是实现跨设备的无缝协...
    继续阅读 »

    什么是HarmonyOS?


    HarmonyOS(鸿蒙操作系统)是华为公司开发的一款基于微内核的分布式操作系统。它是一个面向物联网(IoT)时代的全场景操作系统,旨在为各种类型的设备提供统一的操作系统平台和开发框架。HarmonyOS 的目标是实现跨设备的无缝协同和高性能。


    DevEco Studio



    对标Android Studio,开发鸿蒙OS应用的IDE。



    启动页面


    image.png


    Setup


    image.png


    image.png



    HarmonyOS-SDK:鸿蒙操作系统软件开发工具包



    • Previewer:预览器

    • Toolchains:工具链



    OpenHarmony-SDK:开源鸿蒙操作系统软件开发工具包




    • ArkTS:鸿蒙生态的应用开发语言。

    • JS:JavaScript

    • Previewer:预览器

    • Toolchains:工具链



    image.png


    Create Project


    image.png
    image.png


    配置工程


    image.png
    项目名称、包名、存储路径、编译SDK版本、模型,语言、设备类型等。


    工程目录结构


    image.png



    • AppScope:存放应用全局所需要的资源文件。

    • entry:应用主模块,存放HarmonyOS应用的代码、资源等。

    • on_modules:工程依赖包,存放工程依赖的源文件。

    • build-profile.json5是工程级配置信息,包括签名、产品配置等。

    • hvigorfile.ts是工程级编译构建任务脚本,hvigor是基于任务管理机制实现的一款全新的自动化构建工具,主要提供任务注册编排,工程模型管理、配置管理等核心能力。

    • oh-package.json5是工程级依赖配置文件,用于记录引入包的配置信息。


    Device Manager


    image.png


    创建好的模拟器会出现在这里。
    image.png


    启动模拟器之后,会在设备列表中出现。


    image.png


    编译运行


    image.png
    编译运行,可以从通知栏看到输出的文件并不是apk,而是hap(Harmony Application Package的缩写)。是鸿蒙操作系统设计的应用程序包格式。


    image.png
    .hap 文件包含了应用程序的代码、资源和元数据等信息,用于在 HarmonyOS 设备上安装和运行应用程序。


    image.png


    整体开发流程跟Android基本无差,所以熟悉Android开发的同学上手基本没啥难度。


    ArkTS



    ArkTS是鸿蒙生态的应用开发语言。它在保持TypeScript(简称TS)基本语法风格的基础上,对TS的动态类型特性施加更严格的约束,引入静态类型。同时,提供了声明式UI、状态管理等相应的能力,让开发者可以以更简洁、更自然的方式开发高性能应用。
    developer.harmonyos.com/cn/develop/…



    最简单例子:


    @Entry
    @Component
    struct Index {
    @State message: string = 'Hello World'

    build() {
    Row() {
    Column() {
    Text(this.message)
    .fontSize(50)
    .fontWeight(FontWeight.Bold)
    }
    .width('100%')
    }
    .height('100%')
    }
    }

    看起来非常简洁,采用的是声明式UI,写过Flutter的同学对声明式UI应该不会陌生。从最简单的例子初步了解下基本语法:



    • 装饰器,用于装饰类、结构、方法以及变量,并赋予其特殊的含义。如@Entry、@Component、@State都是装饰器。

    • 自定义组件:可复用的UI单元,可组合其他组件,如上述被@Component装饰的stuct Index。

    • UI 描述:以声明式的方式来描述UI的结构,如上述的build()方法中的代码块。

    • 系统组件:ArkUI框架中默认内置的基础和容器组件,可直接被开发者调用,比如示例中的Row、Column、Text。

    • 属性方法:组件可以通过链式调用配置多项属性,如fontSize()、width()、height()、backgroundColor()等。

    • 事件方法:组件可以通过链式调用设置多个事件的响应逻辑,本例代码不涉及,可以进一步学习文档。


    这里就不是Android熟悉的java或kotlin语言了,编程语言变成了类JavaScript的前端语言,这意味着我们需要适应用前端的思想去开发鸿蒙应用,比如状态管理。


    总结


    本文纯初体验遥遥领先背后的鸿蒙操作系统,基于开发者平台提供的IDE、鸿蒙生态的开发语言ArkTS,通过模拟器运行起来了鸿蒙OS版HelloWorld。对于已经有移动开发经验的同学来说上手可以说非常快,官方文档也非常详尽,ArkTS语法也非常简洁易学,如果大家对华为生态的应用开发感兴趣或者想深入学习借鉴华为做OS和物联网的思路,鸿蒙系统就是一个标杆。


    作者:巫山老妖
    来源:juejin.cn/post/7295576148363886631
    收起阅读 »

    扒一扒抖音是如何做线程优化的

    背景 最近在对一些大厂App进行研究学习,在对某音App进行研究时,发现其在线程方面做了一些优化工作,并且其解决的问题也是之前我在做线上卡顿优化时遇到的,因此对其具体实现方案做了深入分析。本文是对其相关源码的研究加上个人理解的一个小结。 问题 创建线程卡顿 在...
    继续阅读 »

    背景


    最近在对一些大厂App进行研究学习,在对某音App进行研究时,发现其在线程方面做了一些优化工作,并且其解决的问题也是之前我在做线上卡顿优化时遇到的,因此对其具体实现方案做了深入分析。本文是对其相关源码的研究加上个人理解的一个小结。


    问题


    创建线程卡顿


    在Java中,真正的内核线程被创建是在执行 start函数的时候, nativeCreate的具体流程可以参考我之前的一篇分析文章 Android虚拟机线程启动过程解析 。这里假设你已经了解了,我们可以可以知道 start()函数底层涉及到一系列的操作,包括 栈内存空间分配、内核线程创建 等操作,这些操作在某些情况下可能出现长耗时现象,比如由于linux系统中,所有系统线程的创建在内核层是由一个专门的线程排队实现,那么是否可能由于队列较长同时内核调度出现问题而出现长耗时问题? 具体的原因因为没有在线下复现过此类问题,因此只能大胆猜测,不过在线上确实收集到一些case, 以下是线上收集到一个阻塞现场样本:



    那么是不是不要直接在主线程创建其他线程,而是直接使用线程池调度任务就没有问题? 让我们看下 ThreadPoolExecutor.execute(Runnable command)的源码实现



    从文档中可以知道,execute函数的执行在很多情况下会创建(JavaThread)线程,并且跟踪其内部实现后可以发现创建Java线程对象后,也会立即在当前线程执行start函数。



    来看一下线上收集到的一个在主线程使用线程池调度任务依旧发生卡顿的现场。



    线程数过多的问题


    在ART虚拟机中,每创建一个线程都需要为其分配独立的Java栈空间,当Java层未显示设置栈空间大小时,native层会在FixStackSize函数会分配默认的栈空间大小.



    从这个实现中,可以看出每个线程至少会占用1M的虚拟内存大小,而在32位系统上,由于每个进程可分配的用户用户空间虚拟内存大小只有3G,如果一个应用的线程数过多,而当进程虚拟内存空间不足时,创建线程的动作就可能导致OOM问题.



    另一个问题是某些厂商的应用所能创建的线程数相比原生Android系统有更严格的限制,比如某些华为的机型限制了每个进程所能创建的线程数为500, 因此即使是64位机型,线程数不做控制也可能出现因为线程数过多导致的OOM问题。


    优化思路


    线程收敛


    首先在一个Android App中存在以下几种情况会使用到线程



    • 通过 Thread类 直接创建使用线程

    • 通过 ThreadPoolExecutor 使用线程

    • 通过 ThreadTimer 使用线程

    • 通过 AsyncTask 使用线程

    • 通过 HandlerThread 使用线程


    线程收敛的大致思路是, 我们会预先创建上述几个类的实现类,并在自己的实现类中做修改, 之后通过编译期的字节码修改,将App中上述使用线程的地方都替换为我们的实现类。


    使用以上线程相关类一般有几种方式:



    1. 直接通过 new 原生类 创建相关实例

    2. 继承原生类,之后在代码中 使用 new 指令创建自己的继承类实例


    因此这里的替换包括:



    • 修改类的继承关系,比如 将所有 继承 Thread类的地方,替换为 我们实现 的 PThread

    • 修改上述几种类直接创建实例的地方,比如将代码中存在 new ThreadPoolExecutor(..) 调用的地方替换为 我们实现的 PThreadPoolExecutor


    通过字码码修改,将代码中所有使用线程的地方替换为我们的实现类后,就可以在我们的实现类做一些线程收敛的操作。


    Thread类 线程收敛


    在Java虚拟机中,每个Java Thread 都对应一个内核线程,并且线程的创建实际上是在调用 start()函数才开始创建的,那么我们其实可以修改start()函数的实现,将其任务调度到指定的一个线程池做执行, 示例代码如下


    class ThreadProxy : Thread() {
    override fun start() {
    SuperThreadPoolExecutor.execute({
    this@ThreadProxy.run()
    }, priority = priority)
    }
    }

    线程池 线程收敛


    由于每个ThreadPoolExecutor实例内部都有独立的线程缓存池,不同ThreadPoolExecutor实例之间的缓存互不干扰,在一个大型App中可能存在非常多的线程池,所有的线程池加起来导致应用的最低线程数不容小视。


    另外也因为线程池是独立的,线程的创建和回收也都是独立的,不能从整个App的任务角度来调度。举个例子: 比如A线程池因为空闲正在释放某个线程,同时B线程池确可能正因为可工作线程数不足正在创建线程,如果可以把所有的线程池合并成 一个统一的大线程池,就可以避免类似的场景。


    核心的实现思路为:



    1. 首先将所有直接继承 ThreadPoolExecutor的类替换为 继承 ThreadPoolExecutorProxy,以及代码中所有new ThreadPoolExecutor(..)类 替换为 new ThreadPoolExecutorProxy(...)

    2. ThreadPoolExecutorProxy 持有一个 大线程池实例 BigThreadPool ,该线程池实例为应用中所有线程池共用,因此其核心线程数可以根据应用当前实际情况做调整,比如如果你的应用当前线程数平均是200,你可以将BigThreadPool 核心线程设置为150后,再观察其调度情况。

    3. 在 ThreadPoolExecutorProxy 的 addWorker 函数中,将任务调度到 BigThreadPool中执行



    AsyncTask 线程收敛


    对于AsyncTask也可以用同样的方式实现,在execute1函数中调度到一个统一的线程池执行



    public abstract class AsyncTaskProxy<Params,Progress,Result> extends AsyncTask<Params,Progress,Result>{

    private static final Executor THREAD_POOL_EXECUTOR = new PThreadPoolExecutor(0,20,
    3, TimeUnit.MILLISECONDS,
    new SynchronousQueue<>(),new DefaultThreadFactory("PThreadAsyncTask"));


    public static void execute(Runnable runnable){
    THREAD_POOL_EXECUTOR.execute(runnable);
    }

    /**
    * TODO 使用插桩 将所有 execute 函数调用替换为 execute1
    * @param params The parameters of the task.
    * @return This instance of AsyncTask.
    */

    public AsyncTask execute1(Params... params) {
    return executeOnExecutor(THREAD_POOL_EXECUTOR,params);
    }


    }

    Timer类


    Timer类一般项目中使用的地方并不多,并且由于Timer一般对任务间隔准确性有比较高的要求,如果收敛到线程池执行,如果某些Timer类执行的task比较耗时,可能会影响原业务,因此暂不做收敛。


    卡顿优化


    针对在主线程执行线程创建可能会出现的阻塞问题,可以判断下当前线程,如果是主线程则调度到一个专门负责创建线程的线程进行工作。


        private val asyncExecuteHandler  by lazy {
    val worker = HandlerThread("asyncExecuteWorker")
    worker.start()
    return@lazy Handler(worker.looper)
    }


    fun execute(runnable: Runnable, priority: Int) {
    if (Looper.getMainLooper().thread == Thread.currentThread() && asyncExecute
    ){
    //异步执行
    asyncExecuteHandler.post {
    mExecutor.execute(runnable,priority)
    }
    }else{
    mExecutor.execute(runnable, priority)
    }

    }

    32位系统线程栈空间优化


    在问题分析中的环节中,我们已经知道 每个线程至少需要占用 1M的虚拟内存,而32位应用的虚拟内存空间又有限,如果希望在线程这里挤出一点虚拟内存空间来,可以参考微信的一个方案, 其利用PLT hook需改了创建线程时的栈空间大小。


    而在另一篇 juejin.cn/post/720930… 技术文章中,也介绍了另一个取巧的方案 :在Java层直接配置一个 负值,从而起到一样的效果



    OOM了? 我还能再抢救下!


    针对在创建线程时由于内存空间不足或线程数限制抛出的OOM问题,可以做一些兜底处理, 比如将任务调度到一个预先创建的线程池进行排队处理, 而这个线程池核心线程和最大线程是一致的 因此不会出现创建线程的动作,也就不会出现OOM异常了。



    另外由于一个应用可能会存在非常多的线程池,每个线程池都会设置一些核心线程数,要知道默认情况下核心线程是不会被回收的,即使一直处于空闲状态,该特性是由线程池的 allowCoreThreadTimeOut控制。



    该参数值可通过 allowCoreThreadTimeOut(value) 函数修改



    从具体实现中可以看出,当value值和当前值不同 且 value 为true时 会触发 interruptIdleWorkers()函数, 在该函数中,会对空闲Worker 调用 interrupt来中断对应线程



    因此当创建线程出现OOM时,可以尝试通过调用线程池的 allowCoreThreadTimeOut 来触发 interruptIdleWorkers 实现空闲线程的回收。 具体实现代码如下:



    因此我们可以在每个线程池创建后,将这些线程池用弱引用队列保存起来,当线程start 或者某个线程池execute 出现OOM异常时,通过这种方式来实现线程回收。


    线程定位


    线程定位 主要是指在进行问题分析时,希望直接从线程名中定位到创建该线程的业务,关于此类优化的文章网上已经介绍的比较多了,基本实现是通过ASM 修改调用函数,将当前类的类名或类名+函数名作为兜底线程名设置。这里就不详细介绍了,感兴趣的可以看 booster 中的实现



    字节码修改工具


    前文讲了一些优化方式,其中涉及到一个必要的操作是进行字节码修改,这些需求可以概括为如下



    • 替换类的继承关系,比如将 所有继承于 java.lang.Thread的类,替换为我们自己实现的 ProxyThread

    • 替换 new 指令的实例类型,比如将代码中 所有 new Thread(..) 的调用替换为 new ProxyThread(...)


    针对这些通用的修改,没必要每次遇到类似需求时都 进行插件的单独开发,因此我将这种修改能力集成到开源库 LanceX插件中:github.com/Knight-ZXW/… ,我们可以通过以下 注解方便实现上述功能。


    替换 new 指令


    @Weaver
    @Gr0up("threadOptimize")
    public class ThreadOptimize {

    @ReplaceNewInvoke(beforeType = "java.lang.Thread",
    afterType = "com.knightboost.lancetx.ProxyThread")
    public static void replaceNewThread(){
    }

    }

    这里的 beforeType表示原类型,afterType 表示替换后的类型,使用该插件在项目编译后,项目中的如下源码



    会被自动替换为



    替换类的继承关系


    @Weaver
    @Gr0up("threadOptimize")
    public class ThreadOptimize {

    @ChangeClassExtends(
    beforeExtends = "java.lang.Thread",
    afterExtends = "com.knightboost.lancetx.ProxyThread"
    )
    public void changeExtendThread(){};



    }

    这里的beforeExtends表示 原继承父类,afterExtends表示修改后的继承父类,在项目编译后,如下源码



    会被自动替换为



    总结


    本文主要介绍了有关线程的几个方面的优化



    • 主线程创建线程耗时优化

    • 线程数收敛优化

    • 线程默认虚拟空间优化

    • OOM优化


    这些不同的优化手段需要根据项目的实际情况进行选择,比如主线程创建线程优化的实现方面比较简单、影响面也比较低,可以优先实施。 而线程数收敛需要涉及到字节码插桩、各种对象代理 复杂度会高一些,可以根据当前项目的实际线程数情况再考虑是否需要优化。


    线程OOM问题主要出现在低端设备 或一些特定厂商的机型上,可能对于某些大厂的用户基数来说有一定的收益,如果你的App日活并没有那么大,这个优化的优先级也是较低的。

    参考资料



    1.某音App


    2.内核线程创建流程


    3.juejin.cn/post/720930… 虚拟内存优化: 线程 + 多进程优化


    4.github.com/didi/booste…



    作者:卓修武K
    来源:juejin.cn/post/7212446354920407096
    收起阅读 »

    用Kotlin通杀“一切”进率换算

    用Kotlin通杀“一切”进率换算之存储容量 前言 在之前的文章《用Kotlin Duration来优化时间运算》 中,用Duration可以很方便的进行时间的单位换算和运算。我忽然想到平时的工作中经常用到的换算和运算。(人民币汇率;长度单位m,cm;质量单位...
    继续阅读 »

    用Kotlin通杀“一切”进率换算之存储容量


    前言


    在之前的文章《用Kotlin Duration来优化时间运算》
    中,用Duration可以很方便的进行时间的单位换算和运算。我忽然想到平时的工作中经常用到的换算和运算。(人民币汇率;长度单位m,cm;质量单位 kg,g,lb;存储容量单位 mb,gb,tb 等等)


    //进率为1024
    val tenMegabytes = 10 * 1024 * 1024 //10mb
    val tenGigabytes = 10 * 1024 * 1024 * 1024 //10gb

    这样的业务代码加入了单位换算后阅读性就变差了,能否有像Duration一样的api实现下面这样的代码呢?


    fun main() {
    1.kg = 2.20462262.lb; 1.m = 100.cm

    val fiftyMegabytes = 50.mb
    val divValue = fiftyMegabytes - 30.mb
    // 20mb
    val timesValue = fiftyMegabytes * 2.4
    // 120mb

    // 1G文件 再增加2个50mb的数据空间
    val fileSpace = fiftyMegabytes * 2 + 1.gb
    RandomAccessFile("fileName","rw").use {
    it.setLength(fileSpace.inWholeBytes)
    it.write(...)
    }
    }

    下面我们通过分析Duration源码了解原理,并且实现存储容量单位DataSize的换算和运算。


    简单拆解Duration


    kotlin没有提供,要做到上面的api那么我不会啊,但是我看到Duration可以做到,那我们来看看它的原理,进行仿写就行了。



    1. 枚举DurationUnit是用来定义时间不同单位,方便换算和转换的(详情看源码或上篇文)。

    2. Duration是如何做到不同单位的数据换算的,先看看Duration的创建函数和构造函数。toDuration把当前的值通过convertDurationUnit把时间换算成nanos或millis的值,再通过shl运算用来记录单位。
      //Long创建 Duration
      public fun Long.toDuration(unit: DurationUnit): Duration {
      //最大支持的 nanos值
      val maxNsInUnit = convertDurationUnitOverflow(MAX_NANOS, DurationUnit.NANOSECONDS, unit)
      //当前值如果在最大和最小值中间 表示不会溢出
      if (this in -maxNsInUnit..maxNsInUnit) {
      //创建 rawValue 是Nanos的 Duration
      return durationOfNanos(convertDurationUnitOverflow(this, unit, DurationUnit.NANOSECONDS))
      } else {
      //创建 rawValue 是millis的 Duration
      val millis = convertDurationUnit(this, unit, DurationUnit.MILLISECONDS)
      return durationOfMillis(millis.coerceIn(-MAX_MILLIS, MAX_MILLIS))
      }
      }
      // 用 nanos
      private fun durationOfNanos(normalNanos: Long) = Duration(normalNanos shl 1)
      // 用 millis
      private fun durationOfMillis(normalMillis: Long) = Duration((normalMillis shl 1) + 1)
      //不同os平台实现,肯定是 1小时60分 1分60秒那套算法
      internal expect fun convertDurationUnit(value: Long, sourceUnit: DurationUnit, targetUnit: DurationUnit): Long


    3. Duration是一个value class用来提升性能的,通过rawValue还原当前时间换算后的nanos或millis的数据value。为何不全部都用Nanos省去了这些计算呢,根据代码看应该是考虑了Nanos的计算会溢出。用一个long值可以还原构造对象前的所有参数,这代码设计真牛逼。
      @JvmInline
      public value class Duration internal constructor(private val rawValue: Long) : Comparable<Duration> {
      //原始最小单位数据
      private val value: Long get() = rawValue shr 1
      //单位鉴别器
      private inline val unitDiscriminator: Int get() = rawValue.toInt() and 1
      private fun isInNanos() = unitDiscriminator == 0
      private fun isInMillis() = unitDiscriminator == 1
      //还原的最小单位 DurationUnit对象
      private val storageUnit get() = if (isInNanos()) DurationUnit.NANOSECONDS else DurationUnit.MILLISECONDS


    4. Duration是如何做到算术运算的,是通过操作符重载实现的。不同单位Duration,持有的数据是同一个单位的那么是可以互相运算的,我们后面会着重介绍和仿写。

    5. Duration是如何做到逻辑运算的(>,<,>=,<=),构造函数实现了接口Comparable<Duration>重写了operator fun compareTo(other: Duration): Int,返回1,-1,0



    Duration主要依靠对象内部持有的rawValue: Long,由于value的单位是“相同”的,就可以实现不同单位的换算和运算。



    存储容量单位换算设计




    1. 存储容量的单位一般有比特(b),字节(B),千字节(KB),兆字节(MB),千兆字节(GB),太字节(TB),拍字节(PB),艾字节(EB),泽字节(ZB),尧字节(YB)
      ,考虑到实际应用和Long的取值范围我们最大支持PB即可。


      enum class DataUnit(val shortName: String) {
      BYTES("B"),
      KILOBYTES("KB"),
      MEGABYTES("MB"),
      GIGABYTES("GB"),
      TERABYTES("TB"),
      PETABYTES("PB")
      }



    2. 对于存储容量来说最小单位我们就定为Bytes,最大支持到PB,然后可以省去对数据过大的溢出的"单位鉴别器"设计。(注意使用pb时候,>= 8192.pb就会溢出)


      @JvmInline
      value class DataSize internal constructor(private val rawBytes: Long)



    3. 参照Duration在创建和最后单位换算时候都用到了convertDurationUnit函数,接受原始单位和目标单位。另外考虑到可能出现换算溢出使用Math.multiplyExact来抛出异常,防止数据计算异常无法追溯的问题。


      /** Bytes per Kilobyte.*/
      private const val BYTES_PER_KB: Long = 1024
      /** Bytes per Megabyte.*/
      private const val BYTES_PER_MB = BYTES_PER_KB * 1024
      /** Bytes per Gigabyte.*/
      private const val BYTES_PER_GB = BYTES_PER_MB * 1024
      /** Bytes per Terabyte.*/
      private const val BYTES_PER_TB = BYTES_PER_GB * 1024
      /** Bytes per PetaByte.*/
      private const val BYTES_PER_PB = BYTES_PER_TB * 1024

      internal fun convertDataUnit(value: Long, sourceUnit: DataUnit, targetUnit: DataUnit): Long {
      val valueInBytes = when (sourceUnit) {
      DataUnit.BYTES -> value
      DataUnit.KILOBYTES -> Math.multiplyExact(value, BYTES_PER_KB)
      DataUnit.MEGABYTES -> Math.multiplyExact(value, BYTES_PER_MB)
      DataUnit.GIGABYTES -> Math.multiplyExact(value, BYTES_PER_GB)
      DataUnit.TERABYTES -> Math.multiplyExact(value, BYTES_PER_TB)
      DataUnit.PETABYTES -> Math.multiplyExact(value, BYTES_PER_PB)
      }
      return when (targetUnit) {
      DataUnit.BYTES -> valueInBytes
      DataUnit.KILOBYTES -> valueInBytes / BYTES_PER_KB
      DataUnit.MEGABYTES -> valueInBytes / BYTES_PER_MB
      DataUnit.GIGABYTES -> valueInBytes / BYTES_PER_GB
      DataUnit.TERABYTES -> valueInBytes / BYTES_PER_TB
      DataUnit.PETABYTES -> valueInBytes / BYTES_PER_PB
      }
      }

      internal fun convertDataUnit(value: Double, sourceUnit: DataUnit, targetUnit: DataUnit): Double {
      val valueInBytes = when (sourceUnit) {
      DataUnit.BYTES -> value
      DataUnit.KILOBYTES -> value * BYTES_PER_KB
      DataUnit.MEGABYTES -> value * BYTES_PER_MB
      DataUnit.GIGABYTES -> value * BYTES_PER_GB
      DataUnit.TERABYTES -> value * BYTES_PER_TB
      DataUnit.PETABYTES -> value * BYTES_PER_PB
      }
      require(!valueInBytes.isNaN()) { "DataUnit value cannot be NaN." }
      return when (targetUnit) {
      DataUnit.BYTES -> valueInBytes
      DataUnit.KILOBYTES -> valueInBytes / BYTES_PER_KB
      DataUnit.MEGABYTES -> valueInBytes / BYTES_PER_MB
      DataUnit.GIGABYTES -> valueInBytes / BYTES_PER_GB
      DataUnit.TERABYTES -> valueInBytes / BYTES_PER_TB
      DataUnit.PETABYTES -> valueInBytes / BYTES_PER_PB
      }
      }



    4. 扩展属性和构造DataSize,rawBytes是Bytes因此所有的目标单位设置为DataUnit.BYTES,而原始单位就通过调用者告诉convertDataUnit


      fun Long.toDataSize(unit: DataUnit): DataSize {
      return DataSize(convertDataUnit(this, unit, DataUnit.BYTES))
      }
      fun Double.toDataSize(unit: DataUnit): DataSize {
      return DataSize(convertDataUnit(this, unit, DataUnit.BYTES).roundToLong())
      }
      inline val Long.bytes get() = this.toDataSize(DataUnit.BYTES)
      inline val Long.kb get() = this.toDataSize(DataUnit.KILOBYTES)
      inline val Long.mb get() = this.toDataSize(DataUnit.MEGABYTES)
      inline val Long.gb get() = this.toDataSize(DataUnit.GIGABYTES)
      inline val Long.tb get() = this.toDataSize(DataUnit.TERABYTES)
      inline val Long.pb get() = this.toDataSize(DataUnit.PETABYTES)

      inline val Int.bytes get() = this.toLong().toDataSize(DataUnit.BYTES)
      inline val Int.kb get() = this.toLong().toDataSize(DataUnit.KILOBYTES)
      inline val Int.mb get() = this.toLong().toDataSize(DataUnit.MEGABYTES)
      inline val Int.gb get() = this.toLong().toDataSize(DataUnit.GIGABYTES)
      inline val Int.tb get() = this.toLong().toDataSize(DataUnit.TERABYTES)
      inline val Int.pb get() = this.toLong().toDataSize(DataUnit.PETABYTES)

      inline val Double.bytes get() = this.toDataSize(DataUnit.BYTES)
      inline val Double.kb get() = this.toDataSize(DataUnit.KILOBYTES)
      inline val Double.mb get() = this.toDataSize(DataUnit.MEGABYTES)
      inline val Double.gb get() = this.toDataSize(DataUnit.GIGABYTES)
      inline val Double.tb get() = this.toDataSize(DataUnit.TERABYTES)
      inline val Double.pb get() = this.toDataSize(DataUnit.PETABYTES)



    5. 换算函数设计
      Duration用toLong(DurationUnit)或者toDouble(DurationUnit)来输出指定单位的数据,inWhole系列函数是对toLong(DurationUnit) 的封装。toLong和toDouble实现就比较简单了,把convertDataUnit传入输出单位,而原始单位就是rawValue的单位DataUnit.BYTES
      toDouble需要输出更加精细的数据,例如: 512mb = 0.5gb。


      val inWholeBytes: Long
      get() = toLong(DataUnit.BYTES)
      val inWholeKilobytes: Long
      get() = toLong(DataUnit.KILOBYTES)
      val inWholeMegabytes: Long
      get() = toLong(DataUnit.MEGABYTES)
      val inWholeGigabytes: Long
      get() = toLong(DataUnit.GIGABYTES)
      val inWholeTerabytes: Long
      get() = toLong(DataUnit.TERABYTES)
      val inWholePetabytes: Long
      get() = toLong(DataUnit.PETABYTES)

      fun toDouble(unit: DataUnit): Double = convertDataUnit(bytes.toDouble(), DataUnit.BYTES, unit)
      fun toLong(unit: DataUnit): Long = convertDataUnit(bytes, DataUnit.BYTES, unit)



    操作符设计


    在Kotlin 中可以为类型提供预定义的一组操作符的自定义实现,被称为操作符重载。这些操作符具有预定义的符号表示(如 + 或
    *)与优先级。为了实现这样的操作符,需要为相应的类型提供一个指定名称的成员函数或扩展函数。这个类型会成为二元操作符左侧的类型及一元操作符的参数类型。


    如果函数不存在或不明确,则导致编译错误(编译器会提示报错)。下面为常见操作符对照表:


    操作符函数名说明
    +aa.unaryPlus()一元操作 取正
    -aa.unaryMinus()一元操作 取负
    !aa.not()一元操作 取反
    a + ba.plus(b)二元操作 加
    a - ba.minus(b)二元操作 减
    a * ba.times(b)二元操作 乘
    a / ba.div(b)二元操作 除

    算术运算支持



    1. 这里用算术运算符+实现来举例:假如DataSize对象需要重载操作符+
      val a = DataSize(); val c: DataSize = a + b


    2. 需要定义扩展函数1或者添加成员函数2
      1. operator fun DataSize.plus(other: T): DataSize {...}
      2. class DataSize { operator fun plus(other: T): DataSize {...} }


    3. 函数中的参数other: T表示b的对象类型,例如
      // val a: DataSize; val b: DataSize; a + DataSize()
      operator fun DataSize.plus(other: DataSize): DataSize {...}
      // val a: DataSize; val b: Int; a + 1
      operator fun DataSize.plus(other: Int): DataSize {...}


    4. 为了阅读性,Duration不会和同类型的对象乘除法运算,而使用了Int或Double,因此重载运算符用了operator fun times(scale: Int): Duration

    5. 那么在DataSize中我们也重载(+,-,*,/),并且(*,/)重载的参数只支持Int和Double即可
      operator fun unaryMinus(): DataSize {
      return DataSize(-this.bytes)
      }
      operator fun plus(other: DataSize): DataSize {
      return DataSize(Math.addExact(this.bytes, other.bytes))
      }

      operator fun minus(other: DataSize): DataSize {
      return this + (-other) // a - b = a + (-b)
      }

      operator fun times(scale: Int): DataSize {
      return DataSize(Math.multiplyExact(this.bytes, scale.toLong()))
      }

      operator fun div(scale: Int): DataSize {
      return DataSize(this.bytes / scale)
      }

      operator fun times(scale: Double): DataSize {
      return DataSize((this.bytes * scale).roundToLong())
      }

      operator fun div(scale: Double): DataSize {
      return DataSize((this.bytes / scale).roundToLong())
      }

      上面的操作符重载中minus(),我们使用了 plus()unaryMinus()重载组合a-b = a+(-b),这样我们可以多一个-DataSize的操作符


    逻辑运算支持




    • (>,<,>=,<=)让DataSize构造函数实现了接口Comparable<DataSize>重写了operator fun compareTo(other: DataSize): Int,返回rawBytes对比值即可。




    • (==,!=)通过equals(other)函数实现的,value class默认为rawBytes的对比,可以通过java字节码看到。kotlin 1.9之前不支持重写value classs的equals和hashCode


      value class DataSize internal constructor(private val bytes: Long) : Comparable<DataSize> {
      override fun compareTo(other: DataSize): Int {
      return this.bytes.compareTo(other.bytes)
      }
      //示例
      600.mb > 0.5.gb //true
      512.mb == 0.5.gb




    操作符重载的目的是为了提升阅读性,并不是所有对象为了炫酷都可以用操作符重载,滥用反而会增加代码的阅读难度。例如给DataSize添加*操作符,5mb * 2mb 就让人头大。



    获取字符串形式


    为了方便打印和UI展示,一般我们需要重写toSting。Duration的toSting不需要指定输出单位,可以详细的输出当前对象的字符串格式(1h 0m 45.677s)算法比较复杂。我不太会,就简单实现指定输出单位的toString(DataUnit)


     override fun toString(): String = String.format("%dB", rawBytes)

    fun toString(unit: DataUnit, decimals: Int = 2): String {
    require(decimals >= 0) { "decimals must be not negative, but was $decimals" }
    val number = toDouble(unit)
    if (number.isInfinite()) return number.toString()
    val newDecimals = decimals.coerceAtMost(12)
    return DecimalFormat("0").run {
    if (newDecimals > 0) minimumFractionDigits = newDecimals
    roundingMode = RoundingMode.HALF_UP
    format(number) + unit.shortName
    }
    }

    单元测试


    功能都写好了需要验证期望的结果和实现的功能是否一直,那么这个时候就用单元测试最好来个100%覆盖。


    class ExampleUnitTest {
    @Test
    fun data_size() {
    val dataSize = 512.mb

    println("format bytes:$dataSize")
    // format bytes:536870912B
    println("format kb:${dataSize.toString(DataUnit.KILOBYTES)}")
    // format kb:524288.00KB
    println("format gb:${dataSize.toString(DataUnit.GIGABYTES)}")
    // format gb:0.50GB
    // 单位换算
    assertEquals(536870912, dataSize.inWholeBytes)
    assertEquals(524288, dataSize.inWholeKilobytes)
    assertEquals(512, dataSize.inWholeMegabytes)
    assertEquals(0, dataSize.inWholeGigabytes)
    assertEquals(0, dataSize.inWholeTerabytes)
    assertEquals(0, dataSize.inWholePetabytes)
    }

    @Test
    fun data_size_operator() {
    val dataSize1 = 512.mb
    val dataSize2 = 3.gb

    val unaryMinusValue = -dataSize1 //取负数
    println("unaryMinusValue :${unaryMinusValue.toString(DataUnit.MEGABYTES)}")
    // unaryMinusValue :-512.00MB

    val plusValue = dataSize1 + dataSize2 //+
    println("plus :${plusValue.toString(DataUnit.GIGABYTES)}")
    // plus :3.50GB

    val minusValue = dataSize1 - dataSize2 // -
    println("minus :${minusValue.toString(DataUnit.GIGABYTES)}")
    // minus :-2.50GB

    val timesValue = dataSize1 * 2 //乘法
    println("times :${timesValue.toString(DataUnit.GIGABYTES)}")
    // times :1.00GB

    val divValue = dataSize2 / 2 //除法
    println("div :${divValue.toString(DataUnit.GIGABYTES)}")
    // div :1.50GB
    }

    @Test(expected = ArithmeticException::class)
    fun data_size_overflow() {
    8191.pb
    8192.pb //溢出了不支持,如果要支持参考"单位鉴别器"设计
    }

    @Test
    fun data_size_compare() {
    assertTrue(600.mb > 0.5.gb)
    assertTrue(512.mb == 0.5.gb)
    }
    }

    总结


    通过学习Kotlin Duration的源码,举一反三应用到储存容量单位转换和运算中。Duration中的拆解计算api,还有toSting算法实现就留给大家学习吧。当然了你也可以实现和Duration一样更加精细的"单位鉴别器"设计,支持bit、ZB、BB等大单位。


    另外类似的进率场景也可以实现,用Kotlin通杀“一切进率换算”。比如Degrees角度计算 -90.0.degrees == 270.0.degrees;质量计算kg和磅,两等等1.kg == 2.20462262.lb;人民币汇率 (动态实现算法)8.dollar == 1.rmb 🐶


    github 代码: github.com/forJrking/K…


    操作符重载文档: book.kotlincn.net/text/operat…


    作者:forJrking
    来源:juejin.cn/post/7301145359852765218
    收起阅读 »