注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

由浅入深,详解 ViewModel 的那些事

引言 关于 ViewModel ,Android 开发的小伙伴应该都非常熟悉,无论是新项目还是老项目,基本都会使用到。而 ViewModel 作为 JetPack 核心组件,其本身也更是承担着不可或缺的作用。 因此,了解 ViewModel 的设计思想更是每个...
继续阅读 »

引言


关于 ViewModel ,Android 开发的小伙伴应该都非常熟悉,无论是新项目还是老项目,基本都会使用到。而 ViewModel 作为 JetPack 核心组件,其本身也更是承担着不可或缺的作用。


因此,了解 ViewModel 的设计思想更是每个应用层开发者必不可缺的基本功。


随着这两年 ViewModel 的逐步迭代,比如 SaveStateHandle 的加入等,ViewModel 也已经不是最初版本的样子。要完全理解其设计体系,往往也要伴随着其他组件的基础,所以并不是特别容易能被开发者吃透。


故本篇将以最新视角开始,与你一起,用力一瞥 ViewModel 的设计原理。


本文对应的组件版本:



  • Activity-ktx-1.5.1

  • ViewModel-ktx-2.5.1



本篇定位中等,将从背景与使用方式开始,再到源码解读。由浅入深,解析 ViewModel 的方方面面。



导航


学完本篇,你将了解或明白以下内容:



  • ViewModel 的使用方式;

  • SavedStateHandle 的使用方式;

  • ViewModel 创建与销毁流程;

  • SavedStateHandle 创建流程;


好了,让我们开始吧! 🐊


基础概念


在开始本篇前,我们先解释一些基础概念,以便更加清晰的了解后续的状态保存相关。


何谓配置变更?


配置变更指的是,应用在运行时,内置的配置参数变更从而触发的Activity重新创建


常见的场景有:旋转屏幕、深色模式切换、屏幕大小变化、更改了默认语言或者时区、更改字体大小或主题颜色等。


何谓异常重建?


异常重建指的是非配置变更情况下导致的 Activity 重新创建。


常见场景大多是因为 内存不足,从而导致后台应用被系统回收 ,当我们切换到前台时,从而触发的重建,这个机制在Android中为 Low Memory Killer 机制,简称 LMK



可以在开发者模式,限制后台任务数为1,从而测试该效果。



ViewModel存在之前的世界


ViewModel 出现之前,对于 View 逻辑与数据,我们往往都是直接存在 Activity 或者 Fragment 中,优雅一点,会细分到具体的单独类中去承载。当配置变更时,无可避免,会触发界面重绘。相应的,我们的数据在没有额外处理的情况下,往往也会被初始化,然后在界面重启时重新加载。


但如果当前页面需要维护某些状态不被丢失呢,比如 选择、上传状态 等等? 此时问题就变得棘手起来。


稍有经验同学会告诉你,在 onSaveInstanceState 中重写,使用bundle去存储相应的状态啊?➡️


但状态如果少点还可以,多一点就非常头痛,更别提包含继承关系的状态保存。 😶‍🌫️


所以,不出意外的话,我们 App 的 Activity-manifest 中通常默认都是下列写法:


android:configChanges="keyboard|orientation|uiMode|..."


这也是为啥Android程序普遍不支持屏幕旋转的一部分原因,从源头扼杀因部分配置变更导致的状态丢失问题。🐶保命



VideModel存在之后的世界


随着 ViewModel 组件推出之后,上述因配置变更而导致的状态丢失问题就迎刃而解。


ViewModel 可以做到在配置变更后依然持有状态。所以,在现在的开发中,我们开始将 View数据 与 逻辑 藏于 ViewModel 中,然后对外部暴漏观察者,比如我们常常会搭配 LiveData 一起使用,以此更容易的保持状态同步。


关于 ViewModel 的生命周期,具体如下图所示:


viewmodel-lifecycle


虽然 ViewModel 非常好用,但 ViewModel 也不是万能,其只能避免配置变更时避免状态丢失。比如如果我们的App是因为 内存不足 而被系统kill 掉,此时 ViewModel 也会被清除 🔺 。


不过对于这种情况,仍然有以下三个方法可以依然保存我们的状态:



  • 重写 onSaveInstanceState()onRestoreInstanceState();

  • 使用 SavedState,本质上其实还是 onSaveInstanceState()

  • 使用 SavedStateHandle ,本质上是依托于 SaveState 的实现;



上述的后两种都是随着 JetPack 逐步被推出,可以理解为是对原有的onSavexx的封装简化,从而使其变得更易用。



关于这三种方法,我们会在 SavedStateHandle 流程解析中再进行具体叙述,这里先提出来,留个伏笔。


ViewModel使用方式


作为文章的开始,我们还是要先聊一聊 ViewModel 的使用方式,如下例所示:


image



当然,你也可以选择引入 activity-ktx ,从而以更简便的写法去写:


implementation 'androidx.activity:activity-ktx:1.5.1'

private val mainModel by viewModels()


示例比较简单,我们创建了一个 ViewModel ,如上所示,并在 MainActivity 的 onCreate() 中进行了初始化。


这也是我们日常的使用方式,具体我们这里就不再做阐述。


SavedStateHandle使用方式


我们知道,ViewModel 可以处理因为配置更改而导致的的状态丢失,但并不保证异常终止的情况,而官方的 SavedStateHandle 正是用于这种情况的解决方式。


SavedStateHandle ,如名所示,用于保存状态的手柄。再细化点就是,用于保存状态的工具,从而配合 ViewModel 而使用,其内部使用一个 map 保存我们要存储的状态,并且其本身使用 operator 重载了 set()get() 方法,所以对于我们来说,可以直接使用 键值对 的形式去操作我们要保存的状态,这也是官方为什么称 SavedStateHandle 是一个 具有键值映射Map 特性的原因。



在 Fragment1.2 及 Activity1.1.0 之后, SavedStateHandle 可以作为 ViewModel 的构造函数,从而反射创建带有 SavedStateHandle 的 ViewModel 。



具体使用方式如下:


SavedStateHandle


我们在 MainViewModel 构造函数中新增了一个参数 state:SavedStateHandle ,这个参数在 ViewModel 初始化时,会帮我们自动进行注入。从而我们可以利用 SavedStateHandle 以key-value的形式去保存一些 自定义状态 ,从而在进程异常终止,Act重建后,也能获取到之前保存的状态。


至于为什么能实现保存状态呢?


主要是因为 SavedStateHandle 内部默认有一个 SavedStateRegistry.SavedStateProvider 状态保存提供者对象,该对象会在我们创建ViewModel 时绑定到 SavedStateRegistry 中,从而在我们 Activity 异常重建时做到状态的 恢复绑定 (通过重写 onSavexx()onCreate() 方法监听)。


关于这部分内容,我们下面的源码解析部分也会再聊到,这里我们只需要知道是这么回事即可。


ViewModel源码解析


本章节,我们将从 ViewModelProvider() 开始,理清 ViewModel创建销毁 流程,从而理解其背后的 [魔法]。


不过 ViewModel 的源码其实并不是很复杂,所以别担心😉。


仔细想想,要解析ViewModel的源码,应该从哪里入手呢?


ViewModelProvider(this).get(MainViewModel::class.java)

最简单的方式还是初始化这里,所以我们直接从 ViewModelProvider() 初始化开始->


ViewModelProvider(this)


public constructor(owner: ViewModelStoreOwner)
: this(owner.viewModelStore, defaultFactory(owner), defaultCreationExtras(owner))

相应的,这里开始,我们就涉及到了三个方面,即 viewModelStoreFactoryExras 。所以接下来我们就顺藤摸瓜,分别看看这三处的实现细节。


owner.viewModelStore


viewmodelprovider-owner


ViewModelStoreOwner 顾名思义,用于保存 ViewModelStore 对象。


ViewModelStore 是负责维护我们 ViewModel 实例的具体类,内部有一个 map 的合集,用于保存我们创建的所有 ViewModel ,并对外提供了 clear() 方法,以 便于非配置变更时清除缓存




defaultFactory(owner)


viewmodelprovder-defaultFactory


该方法用于初始化 ViewModel 默认的创造工厂🏭 。默认有两个实现,前者是 HasDefaultViewModelProviderFactory ,也是我们 Fragment 或者 ComponentActivity 都默认实现的接口,而后者是是指全局 NewInstanceFactory


两者的不同点在于,后者只能创建 空构造函数ViewModel ,而前者没有这个限制。


示例源码:


HasDefaultViewModelProviderFactory 在 ComponentActivity 中的实现如下:


componentAct-HasDefaultViewModelProviderFactory




defaultCreationExtras(owner)


用于辅助 ViewModel 初始化时需要传入的参数,具体源码如下:


petterp-image


如上所示,默认有两个实现,前者是 HasDefaultViewModelProviderFactory ,也就是我们 ComponentActivity 实现的接口,具体的实现如下:


petterp-image


默认会帮我们注入 application 以及 intent 等,注意这里还默认使用了 getIntent().getExtras() 作为 ViewModel默认状态 ,如果我们 ViewModel 构造函数中有 SavedStateHandle 的话。



更多关于 CreationExtras 可以了解这篇 创建 ViewModel 的新方式,CreationExtras 了解一下?





get(ViewModel::xx)


从缓存中获取现有的 ViewModel 或者 反射创建 新的 ViewModel


示例源码如下:


petterp-image


当我们使用 get() 方法获取具体的 ViewModel 对象时,内部会先利用 当前包名+ViewModel类名 作为 key ,然后从 viewModelStore 中取。如果当前已创建,则直接使用;反之则调用我们的 ViewModel工厂 create() 方法创建新的 ViewModel。 创建完成后,并将其保存到 ViewModelStore 中。




create(modelClass,extras)


具体的创造逻辑里,这里的 factory 正是我们在 ViewModelProvider 初始化时,默认构造函数 defaultFactory() 方法中生成的SavedStateViewModelFactory ,所以我们直接去看这个工厂类即可。


具体源码如下:


petterp-image




create(key,modelClass)


兼容旧的版本以及用户操作行为。


petterp-image


相应的,这里我们还需要再提一下,LegacySavedStateHandleController.create() 方法:


petterp-image


当我们调用创建 ViewModel 时,内部会调用具体的 ViewModel 工厂去创建,如果当前 ViewModel 已创建,则直接返回,否则调用其 create() 方法创建新的 ViewModel 。在具体的创建方法中,需要判断当前构造函数是不是带 application 或者 SaveStateHandle ,从而调用合适的 newInstance() 方法,最后再将创建好的 ViewModel 添加到 ViewModelStore缓存 中。




销毁流程


在初始化 ViewModelProvider 时,还记得我们需要传递的 ViewModelStoreOwner 吗?


而这个接口正是被我们的 ComponentActivity 或者 Fragment 各自实现,相应的 ViewModelStore 也是存在于我们的 ComponentActivity 中,所以我们直接去看示例代码即可:


以ComponentActivity为例,具体的源码如下:


petterp-image


如上所示:在初始化Activity时,内部会使用 lifecycle 添加一个生命周期观察者,并监听 onDestory() 通知(Act销毁),如果当前销毁的原因非配置更改导致,则调用 ViewModeltore.clear() ,即清空我们的ViewModel缓存列表,从而这也是为什么 ViewModel 不支持非配置更改的实例保存。


你可能会惊讶,那还怎么借助SavedStateHandle保存状态,viewModel已经被清空了啊🤔?


如果你记得 Activity 传统处理状态的方式,此时也就能理解为什么了?因为源头都是一个地方,而 SavedStateHandle 仅仅只是一个更简便的封装而已。不过关于这个问题具体解析,我们将在下面继续进行探讨,从而理解 SavedStateHandle 的完整流程。


SavedStateHandle流程解析


关于 SavedStateHandle 的使用方法我们在上面已经叙述过了,其相关的 api 使用源码也不是我们所关注的重点,因为并不复杂,而我们主要要探讨的是其整个流程。


要摸清 SavedStateHandle 的流程,无非就两个方向,即 从何而来 ,又 在哪里进行使用 🤔。


在上面探索 ViewModel 创建流程时,我们发现,在 get(ViewModel:xx) 方法内部,最终的 create() 方法里,存在两个分支:




  1. 存在附加参数extras(viewModel2.5.0新增);

  2. 不存在附加参数extras(兼容历史版本或者用户自定义的行为);



相应的,如果 ViewModel 的构造函数中存在 SavedStateHandle ,则各自的流程如下所示:



  • CreationExtras.createSavedStateHandle()

  • LegacySavedStateHandleController.create(xx).handle


前者使用了 CreationExtras 的扩展函数 createSavedStateHandle()


petterp-image


而后者使用了 LegacySavedStateHandleController 控制器去创建:


petterp-image


总结:


上述流程中,两者大致是一样的,都需要先调用 consumeRestoredStateForKey(key) 拿到要还原的 Bundle , 再调用 SavedStateHandle.createHandle() 去创建 SavedStateHandle


SavedStateRegistry 又是什么呢?



我们的插入点也就在于此开始。



我们暂时先不关注如何还原状态,而是先搞清楚 SavedStateRegistry 是什么,它又是从哪来而传递来的。然后再来看 状态如何被还原,以及 SavedStateHandle 的创建流程,最后再搞清与 SavedStateRegistry 又是如何进行关联。




SavedStateRegistry


其是一个用于保存状态的注册表,往往由 SavedStateRegistryOwner 接口所提供实现,从而以便与拥有生命周期的组件相关联。


比如我们常用的 ComponentActivity 或者 Fragment 默认都实现了该接口。


源码如下所示:


petterp-image


petterp-image


分析上面的代码不难发现,SavedStateRegistry 本身提供了状态 还原保存 的具体能力,并使用一个 map 保存当前所有的状态提供者,具体的状态提供者由 SavedStateProvider 接口实现。




SavedStateRegistryOwner


相当于是拥有 SavedStateRegistry 的具体类,因为本身继承了 LifecycleOwner 接口,故其也具备 生命感知 能力,如下所示:


interface SavedStateRegistryOwner : LifecycleOwner {
val savedStateRegistry: SavedStateRegistry
}

ComponentActivity 为例,我们会发现,ComponentActivity 默认实现 SavedStateRegistryOwner 接口。即 SavedStateRegistry 的创造以及状态的保存,肯定也是 经过我们Activity转发处理(不然它自己怎么处理呢😅)。


而在上面探索 ViewModel 初始化时,我们了解到,ComponentActivity 默认实现了 HasDefaultViewModelProviderFactory 接口,用于创建ViewModel工厂 。相应的,其接口方法 getDefaultViewModelProviderFactory() 默认返回的是 SavedStateViewModelFactory ,即支持状态保存的ViewModel工厂。而该工厂构造函数中正是需要接受一个 SavedStateRegistry 变量,也正是我们 ComponentActivity 中默认保存的实例,所以也不难猜测 ViewModel工厂 是如何与 SavedStateRegistry 如何关联的。


ComponentActivity 的实现为例,源码如下:


petterp-image


ComponentActivity 初始化时,会创建一个 用于保存状态注册表的控制器 SavedStateRegistryController 对象,见面知意,不难猜出,其是用于控制 SavedStateRegistry 的具体类。并且该控制器对象会在 onCreate() 中调用 performRestore() 还原状态,并在onSaveInstanceState() 中去保存状态,此时也就解释了为什么 SavedStateRegistry 能做到状态保存。


相应的,我们还是要再去看看 SavedStateRegistryController ,以便更好的理解。




SavedStateRegistryController


用于控制 SavedStateRegistry ,对外提供了 初始化 ,状态 还原保存 等方法,如下所示:


petterp-image


简而言之,其主要用于辅助 SavedStateRegistry 进行状态保存与还原。


小结


我们再回顾一下上面的步骤,在只关心 SavedStateHandle 如何被创建这样一个大背景下,我们大致可以梳理出这样的流程:


因为我们的 ComponentActivity 或者 Fragment 默认已经实现了 SavedStateRegistryOwner 接口,而且默认是由 SavedStateRegistryController 作为 SavedStateRegistry 的具体控制,因此具体的状态保存与还原都由该控制器去操作。


当我们的 Activity 因为异常生命周期重建时,此时会回调 onSaveInstanceState() 去保存状态,此时 SavedStateRegistryController 就会调用 performSave() 去保存当前状态(即将我们ViewModel的状态保存到bundle里),然后在 Activity 重建时,在 onCreate() 方法里进行还原(即从bundle里取出我们保存的状态)。


当我们创建 ViewModel 时,默认使用的 ViewModel 工厂是支持保存状态的 SavedStateViewModelFactory 。在初始化该工厂时,需要显式传递 SavedStateRegistryOwner 接口对象到该工厂中,而该工厂的构造函数内,会将 SavedStateRegistry 自行保存起来。


最后,如果要创建的 ViewModel 需要保存状态(即构造函数中存在SavedStateHadnle),则使用保存的 SavedStateRegistry 变量去获取我们将要还原的状态,然后再调用 SavedStateHandle.createHandle() 去创建具体的 SavedStateHadnle


由此结合 ViewModel 创建的流程,我们可以总结 SavedStateRegistry 的传递流程伪代码如下:


petterp-image




SavedStateHandle如何创建


在上面,我们聊完了 SavedStateRegistry 是如何被创建以及被传递给我们的 ViewModel工厂 ,而这一小节,我们将要聊聊 SavedStateHandle 如何被创建,以及状态是如何被还原的。


我们知道,当创建 SavedStateHandle 前,需要先获取已保存的状态,也即 consumeRestoredStateForKey() 方法,所以我们本章节的插入点也就是从这里开始。


而与 consumeRestoredStateForKey() 关联的类有两个, SavedStateHandlesProviderSavedStateRegistry



前者是 viewModel(2.5.0) 新提供的 创建SavedStateHandle 的方式,后者则是用于 适配 2.5.0 之前的方式。



SavedStateHandlesProvider 为例,源码如下:


petterp-image


当我们调用 consumeRestoredStateForKey() 获取具体状态时,内部先会调用 performRestore()SavedStateRegistry 获取我们保存的状态集,然后将其保存到 provider 中。再从这个总的 状态bundle 中获取我们当前 viewModel 所对应的状态。


相应的,我们再去看看 SavedStateHandle.createHandle() 方法,即 SavedStateHandle 最终被怎么创建出来。


源码如下:


petterp-image


上述的逻辑也比较简单,具体如源码中所示,当我们创建 SavedStateHandle 时,需要先从 SavedStateRegistry 获取我们的状态Bundle,然后再调用 createHandle() 方法创建具体的 SavedStateHandle。并在其 createHandle() 内将我们传入的 bundle 转为 Map 形式,从而传入 SavedStateHandle 的构造函数中用于初始化。


总结


在这一章节,我们主要探讨的是 SavedStateHandle 的创建流程,以 ComponentActivity 为例:


我们知道 Android 中关于状态的保存与还原,官方建议使用 onSaveInstanceState()onRestoreInstanceState() ,但随着JetPack组件库的完善,官方在这两个方法的基础上新增了 SavedState ,目的是简化状态保存的成本。从原理上,其创建了一个 状态保存的的注册表 SavedStateRegistry ,内部缓存着具体的 状态提供者合集(key为string,value为SavedStateProvider)。


当我们 Activity 因为配置更改或者不可控原因需要重建时,系统此时会主动调用 onSaveInstanceState() 方法,从而触发调用 savedStateRegistry.performSave() 去保存状态。该方法内部会创建一个新的 Bundle 对象,用于保存所有状态,然后再调用所有缓存的状态提供者(SavedStateProvider)的 saveState() 方法,从而将所有需要需要保存的状态以 key-value 的方式存到 Bundle 中去。最后再将这个整体的 bundle 存入 onSaveInstanceState() 方法参数提供的 bundle 中。


当我们的 Activity 重建完成后,在 onCreate() 方法中,再使用 SavedStateRegistry 还原我们自己保存的状态 restoredState


最后当我们创建 ViewModel 时,因为我们的 ViewModel工厂(SavedStateViewModelFactory) 持有了 SavedStateRegistry ,也即持有着我们要还原的状态(如果有)。在创建具体的 ViewModel 时,如果我们要创建的 ViewModel 构造函数中存在 SavedStateHandle 参数,则该 ViewModel 支持保存状态,所以需要先去使用 SavedStateRegistry 获取我们保存的状态,最后再调用 SavedStateHandle.create() 去创建具体 SaveStateHandle ,从而创建出支持保存状态 ViewModel


结语


在本篇中,我们从 ViewModel 的背景开始,再到 ViewModelSavedStateHandle 的使用方式,最后又从源码层级分析了两者的具体流程,从而较完整的解析了 ViewModel 的底层实现与 SavedStateHandle 的整体创建流程。


至于更加详细的使用方式,这也非本篇要深入探索的细节,具体可参照其他同学的教程即可。


至此,关于 ViewModel 设计思想 以及 状态保存原理 到这里就结束了。也相信读过本篇的你也将不会再有所疑惑 :)


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

兔年了,一起用Compose来画兔子吧

准备工作 兔子主要还是画在画布上面,所以我们首先得生成个Canvas,然后确定Canvas的宽高跟画笔颜色 val drawColor = colorResource(id = R.color.color_EC4126) Canvas( modifie...
继续阅读 »

准备工作


兔子主要还是画在画布上面,所以我们首先得生成个Canvas,然后确定Canvas的宽高跟画笔颜色


val drawColor = colorResource(id = R.color.color_EC4126)
Canvas(
modifier = Modifier
.size(screenWidth().dp, screenHeight().dp)
.background(color = Color.White)
) {

}

宽高这边只是写死的两个数值,我们也可以用系统api来获取真实的屏幕宽高,画笔颜色选用偏红的颜色,毕竟要过年了,喜庆点~,接下去开始准备画兔子


脑袋


脑袋其实就是个椭圆,我们用canvas的drawPath方法去绘制,我们需要做的就是确定这个椭圆的中心点坐标,以及绘制这个椭圆的左上坐标以及右下坐标


val startX = screenWidth() / 4
val startY = screenHeight() / 3
val headPath = Path()
headPath.moveTo(screenWidth() / 2, screenHeight() / 2)
headPath.addOval(Rect(startX, startY, screenWidth() - startX, screenHeight() - startY))
headPath.close()
Canvas(
modifier = Modifier
.size(screenWidth().dp, screenHeight().dp)
.background(color = Color.White)
) {
drawPath(path = headPath, color = drawColor, style = Stroke(width = 12f))
}

脑袋的中心点坐标x轴跟y轴分别就是画布宽高的一半,左上的x坐标就是画布宽的四分之一,y坐标就是画布高的三分之一,右下的x坐标就是画布宽减去左上的x坐标,右下的y坐标就是减去左上的y坐标,最终在Canvas里面将这个椭圆的path绘制出来,我们看下效果图


tt1.png


耳朵


画完脑袋我们接着画耳朵,两只耳朵其实也就是两个椭圆,分别以中心线左右对称,绘制思路同画脑袋一样,确定两个path的中心点坐标,以及各自左上跟右下的xy坐标


val leftEarPath = Path()
val leftEarPathX = screenWidth() * 3 / 8
val leftEarPathY = screenHeight() / 6
leftEarPath.moveTo(leftEarPathX, leftEarPathY)
leftEarPath.addOval(
Rect(
leftEarPathX - 60f,
leftEarPathY / 2,
leftEarPathX + 60f,
startY + 30f
)
)
leftEarPath.close()

val rightEarPath = Path()
val rightEarPathX = screenWidth() * 5 / 8
val rightEarPathY = screenHeight() / 6
rightEarPath.moveTo(rightEarPathX, rightEarPathY)
rightEarPath.addOval(
Rect(
rightEarPathX - 60f,
rightEarPathY / 2,
rightEarPathX + 60f,
startY + 30f
)
)
rightEarPath.close()
Canvas(
modifier = Modifier
.size(screenWidth().dp, screenHeight().dp)
.background(color = Color.White)
) {
drawPath(path = leftEarPath, color = drawColor, style = Stroke(width = 10f))
drawPath(path = rightEarPath, color = drawColor, style = Stroke(width = 10f))
}

看下效果图


tt2.png


内耳


这样感觉耳朵不是很立体,看起来有点平面,毕竟兔耳朵会有点往里凹的感觉,所以我们给这副耳朵加个内耳增加点立体感,内耳其实很简单,道理同外面的耳朵一样,只是中心点跟左上点,右下点的xy坐标会小一点,我们稍微改一下外耳的path就可以了


val leftEarSubPath = Path()
val leftEarSubPathX = screenWidth() * 3 / 8
val leftEarSubPathY = screenHeight() / 4
leftEarSubPath.moveTo(leftEarSubPathX, leftEarSubPathY)
leftEarSubPath.addOval(
Rect(
leftEarSubPathX - 30f,
screenHeight() / 6,
leftEarSubPathX + 30f,
startY + 30f
)
)
leftEarSubPath.close()

val rightEarSubPath = Path()
val rightEarSubPathX = screenWidth() * 5 / 8
val rightEarSubPathY = screenHeight() / 4
rightEarSubPath.moveTo(rightEarSubPathX, rightEarSubPathY)
rightEarSubPath.addOval(
Rect(
rightEarSubPathX - 30f,
screenHeight() / 6,
rightEarSubPathX + 30f,
startY + 30f
)
)
rightEarSubPath.close()
Canvas(
modifier = Modifier
.size(screenWidth().dp, screenHeight().dp)
.background(color = Color.White)
) {
drawPath(path = leftEarSubPath, color = drawColor, style = Stroke(width = 6f))
drawPath(path = rightEarSubPath, color = drawColor, style = Stroke(width = 6f))
}

看下效果图


tt31.png


有内味儿了,内耳的画笔粗细稍微调小了一点,为了突出个近大远小嘛哈哈哈,我们接着下一步


眼睛


画完耳朵我们开始画眼睛了,眼睛也很好画,主要是先找到中心点位置就好,中心点的x坐标其实跟耳朵的x坐标是一样的,y坐标在脑袋中心点y坐标稍微靠上一点的位置


val leftEyePath = Path()
val leftEyePathX = screenWidth() * 3 / 8
val leftEyePathY = screenHeight() * 11 / 24
leftEyePath.moveTo(leftEyePathX, leftEyePathY)
leftEyePath.addOval(
Rect(
leftEyePathX - 35f,
leftEyePathY - 35f,
leftEyePathX + 35f,
leftEyePathY + 35f
)
)
leftEyePath.close()

val rightEyePath = Path()
val rightEyePathX = screenWidth() * 5 / 8
val rightEyePathY = screenHeight() * 11 / 24
rightEyePath.moveTo(rightEyePathX, rightEyePathY)
rightEyePath.addOval(
Rect(
rightEyePathX - 35f,
rightEyePathY - 35f,
rightEyePathX + 35f,
rightEyePathY + 35f
)
)
rightEyePath.close()
Canvas(
modifier = Modifier
.size(screenWidth().dp, screenHeight().dp)
.background(color = Color.White)
) {
drawPath(path = leftEyePath, color = drawColor, style = Stroke(width = 10f))
drawPath(path = rightEyePath, color = drawColor, style = Stroke(width = 10f))
}

效果图如下


tt4.png


眼神有点空洞,无神是不,缺个眼珠子,那我们再给小兔子画上眼珠吧,眼珠就在眼睛的中心点位置,画一个圆点,圆点就要用到drawCircle,它有这些属性


fun drawCircle(
color: Color,
radius: Float = size.minDimension / 2.0f,
center: Offset = this.center,
/*@FloatRange(from = 0.0, to = 1.0)*/
alpha: Float = 1.0f,
style: DrawStyle = Fill,
colorFilter: ColorFilter? = null,
blendMode: BlendMode = DefaultBlendMode
)

我们不需要用到全部,只需要用到颜色color,也就是红色,圆点半径radius,肯定要比眼睛的半径要小一点,我们就设置为10f,圆点中心坐标center,就是眼睛的中心点坐标,知道了以后我们开始绘制眼珠


Canvas(
modifier = Modifier
.size(screenWidth().dp, screenHeight().dp)
.background(color = Color.White)
) {
drawCircle(color = drawColor, radius = 10f, center = Offset(leftEyePathX,leftEyePathY))
drawCircle(color = drawColor, radius = 10f, center = Offset(rightEyePathX,rightEyePathY))
}

我们再看下效果图


image.png


鼻子


接下去我们画鼻子,鼻子肯定在脑袋的中间,所以中心点x坐标就是脑袋中心点的x坐标,那鼻子的y坐标就设置成比中心点y坐标稍微高一点的位置,代码如下


val nosePath = Path()
val nosePathX = screenWidth() / 2
val nosePathY = screenHeight() * 13 / 24
nosePath.moveTo(nosePathX, nosePathY)
nosePath.addOval(Rect(nosePathX - 15f, nosePathY - 15f, nosePathX + 15f, nosePathY + 15f))
nosePath.close()
Canvas(
modifier = Modifier
.size(screenWidth().dp, screenHeight().dp)
.background(color = Color.White)
) {
drawPath(path = nosePath, color = drawColor, style = Stroke(width = 10f))
}

我们看下效果图


image.png


兔唇


兔子的样子逐渐出来了,画完鼻子我们接着画啥呢?没错,兔子最有特点的位置也就是兔唇,我们脑补下兔唇长啥样子,首先位置肯定是在鼻子的下面,然后从鼻子开始往两边分叉,也就是两个扇形,扇形怎么画呢,我们也有现成的api,drawArc,我们看下drawArc都提供了哪些属性


fun drawArc(
color: Color,
startAngle: Float,
sweepAngle: Float,
useCenter: Boolean,
topLeft: Offset = Offset.Zero,
size: Size = this.size.offsetSize(topLeft),
/*@FloatRange(from = 0.0, to = 1.0)*/
alpha: Float = 1.0f,
style: DrawStyle = Fill,
colorFilter: ColorFilter? = null,
blendMode: BlendMode = DefaultBlendMode
)

我们需要用到的就是颜色color,这个扇形起始角度startAngle,扇形终止的角度sweepAngle,是否扇形两端跟中心点连接起来的布尔值useCenter,扇形的左上位置topLeft以及扇形的大小size也就是设置半径,知道这些以后我们开始逐个代入参数吧


Canvas(
modifier = Modifier
.size(screenWidth().dp, screenHeight().dp)
.background(color = Color.White)
) {
drawArc(
color = drawColor,
0f,
120f,
style = Stroke(width = 10f),
useCenter = false,
size = Size(120f, 120f),
topLeft = Offset(nosePathX - 120f, nosePathY)
)

drawArc(
color = drawColor,
180f,
-120f,
style = Stroke(width = 10f),
useCenter = false,
size = Size(120f, 120f),
topLeft = Offset(nosePathX + 10f, nosePathY)
)
}

画兔唇的时候其实就是在鼻子的两端各画一个坐标轴,左边的兔唇起始角度就是从x轴开始也就是0度,顺时针旋转120度,左上位置的x坐标刚好离开鼻子一个半径的位置,右边的兔唇刚好相反,逆时针旋转120度,起始角度是180度,左上位置的x坐标刚好在鼻子的位置那里,稍微加个10f让兔唇可以对称一些,我们看下效果图


image.png


胡须


脸上好像空了点,兔子的胡须还没有呢,胡须其实就是两边各画三条线,用drawLine这个api,起始位置的x坐标跟眼睛中心点的x坐标一样,中间胡须起始位置的y坐标跟鼻子的y坐标一样,上下胡须的y坐标各减去一定的数值


Canvas(
modifier = Modifier
.size(screenWidth().dp, screenHeight().dp)
.background(color = Color.White)
) {
drawLine(
color = drawColor,
start = Offset(leftEyePathX, nosePathY - 60f),
end = Offset(leftEyePathX - 250f, nosePathY - 90f),
strokeWidth = 5f,
cap = StrokeCap.Round
)
drawLine(
color = drawColor,
start = Offset(leftEyePathX, nosePathY),
end = Offset(leftEyePathX - 250f, nosePathY),
strokeWidth = 5f,
cap = StrokeCap.Round
)
drawLine(
color = drawColor,
start = Offset(leftEyePathX, nosePathY + 60f),
end = Offset(leftEyePathX - 250f, nosePathY + 90f),
strokeWidth = 5f,
cap = StrokeCap.Round
)

drawLine(
color = drawColor,
start = Offset(rightEyePathX, nosePathY - 60f),
end = Offset(rightEyePathX + 250f, nosePathY - 90f),
strokeWidth = 5f,
cap = StrokeCap.Round
)
drawLine(
color = drawColor,
start = Offset(rightEyePathX, nosePathY),
end = Offset(rightEyePathX + 250f, nosePathY),
strokeWidth = 5f,
cap = StrokeCap.Round
)
drawLine(
color = drawColor,
start = Offset(rightEyePathX, nosePathY + 60f),
end = Offset(rightEyePathX + 250f, nosePathY + 90f),
strokeWidth = 5f,
cap = StrokeCap.Round
)
}

很简单的画了六条线,线的粗细也稍微设置的小一点,毕竟胡须还是比较细的,我们看下效果图


image.png


就这样兔子脑袋部分所有元素都画完了,我们接着给兔子画身体


身体


身体其实也是个椭圆,位置刚好在画布下方三分之一的位置,左上x坐标比脑袋左上x坐标大一点,y坐标就是画布三分之二的位置处,右下x坐标比脑袋右下x坐标稍微小一点,y坐标就是画布的底端,知道以后我们就仿照着脑袋画身体


val bodyPath = Path()
val bodyPathX = screenWidth() / 2
val bodyPathY = screenHeight() * 5 / 6
bodyPath.moveTo(bodyPathX, bodyPathY)
bodyPath.addOval(
Rect(
startX + 50f,
screenHeight() * 2 / 3,
screenWidth() - startX - 50f,
screenHeight()
)
)
bodyPath.close()
Canvas(
modifier = Modifier
.size(screenWidth().dp, screenHeight().dp)
.background(color = Color.White)
) {
drawPath(path = bodyPath, color = drawColor, style = Stroke(width = 10f))
}

效果图如下


image.png


双爪


画完身体我们再画兔子的双爪,双爪其实也是画两个椭圆,椭圆中心点的x坐标同两只眼睛的x坐标一样,y坐标在画布六分之五的位置


val leftHandPath = Path()
val leftHandPathX = screenWidth() * 3 / 8
val leftHandPathY = screenHeight() * 5 / 6
leftHandPath.moveTo(leftHandPathX, leftHandPathY)
leftHandPath.addOval(
Rect(
leftHandPathX - 35f,
leftHandPathY - 90f,
leftHandPathX + 35f,
leftHandPathY + 90f
)
)
leftHandPath.close()

val rightHandPath = Path()
val rightHandPathX = screenWidth() * 5 / 8
val rightHandPathY = screenHeight() * 5 / 6
rightHandPath.moveTo(rightHandPathX, rightHandPathY)
rightHandPath.addOval(
Rect(
rightHandPathX - 35f,
rightHandPathY - 90f,
rightHandPathX + 35f,
rightHandPathY + 90f
)
)
rightHandPath.close()
Canvas(
modifier = Modifier
.size(screenWidth().dp, screenHeight().dp)
.background(color = Color.White)
) {
drawPath(path = leftHandPath, color = drawColor, style = Stroke(width = 10f))
drawPath(path = rightHandPath, color = drawColor, style = Stroke(width = 10f))
}

我们看下效果图


image.png


尾巴


还差最后一步,我们给兔子画上尾巴,尾巴的中心点x坐标就是画布宽度减去脑袋右边x轴坐标,尾巴中心点的y坐标就是画布高度减去一定的数值,我们看下代码


val tailPath = Path()
val tailPathX = screenWidth() - startX
val tailPathY = screenHeight() - 200f
tailPath.moveTo(tailPathX, tailPathY)
tailPath.addOval(Rect(tailPathX - 60f, tailPathY - 90f, tailPathX + 60f, tailPathY + 90f))
tailPath.close()
Canvas(
modifier = Modifier
.size(screenWidth().dp, screenHeight().dp)
.background(color = Color.White)
) {
drawPath(path = tailPath, color = drawColor, style = Stroke(width = 10f))
}

就这样一只兔子画完了,我们看下最终效果图


image.png


看起来像那么回事了,我们再稍微点缀下,背景我们发现还有点单调,毕竟是过年了嘛,虽然多地不让放烟花,但我们看看还是可以的,网上找张烟花图片给兔子当背景吧,刚好也有drawImage这样的api可以将图片绘制到画布上,代码如下


val bgBitmap = ImageBitmap.imageResource(id = R.drawable.firework_night)
Canvas(
modifier = Modifier
.size(screenWidth().dp, screenHeight().dp)
.background(color = Color.White)
) {
drawImage(image = bgBitmap,
srcOffset = IntOffset(0,0),
srcSize = IntSize(bgBitmap.width,bgBitmap.height),
dstSize = IntSize(screenWidth().toInt()*5/4,screenHeight().toInt()*5/4),
dstOffset = IntOffset(0,0)
)
}

我们来看下效果怎么样


image.png


嗯~~大功告成~~好像也不是很好看哈哈哈,不过重点咱也不是为了美观,而是一个过年了图个寓意,另一个就是用下Compose里面Canvas这些api,毕竟随着kotlin逐步成熟,个人感觉Compose很有可能成为Android以后主流的UI开发模式


最后给大家拜个早年了,祝大家兔年大吉,“兔”飞猛进~~


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

在国企做程序员怎么样?

有读者咨询我,在国企做开发怎么样?当然是有利有弊,国企相对稳定,加班总体没有互联网多,不过相对而言,工资收入没有互联网高,而且国企追求稳定,往往技术栈比较保守,很难接触新的技术,导致技术水平进步缓慢。下面分享一位国企程序员的经历,希望能给大家一些参考价值。下文...
继续阅读 »

有读者咨询我,在国企做开发怎么样?

当然是有利有弊,国企相对稳定,加班总体没有互联网多,不过相对而言,工资收入没有互联网高,而且国企追求稳定,往往技术栈比较保守,很难接触新的技术,导致技术水平进步缓慢。

下面分享一位国企程序员的经历,希望能给大家一些参考价值。

下文中的“我”代表故事主人公

我校招加入了某垄断央企,在里面从事研发工程师的工作。下面我将分享一些入职后的一些心得体会。

在国企中,开发是最底层最苦B的存在,在互联网可能程序员还能够和产品经理argue,但是在国企中,基本都是领导拍脑袋的决定,即便这个需求不合理,或者会造成很多问题等等,你所需要的就是去执行,然后完成领导的任务。下面我会分享一些国企开发日常。

1、大量内部项目

在入职前几个月,我们都要基于一种国产编辑器培训,说白了集团的领导看市场上有eclipse,idea这样编译器,然后就说咱们内部也要搞一个国产的编译器,所有的项目都要强制基于这样一个编译器。

在国企里搞开发,通常会在项目中塞入一大堆其他项目插件,本来一个可能基于eclipse轻松搞定的事情,在国企需要经过2、3个项目跳转。但国企的项目本来就是领导导向,只需给领导演示即可,并不具备实用性。所以在一个项目集成多个项目后,可以被称为X山。你集成的其他项目会突然出一些非常奇怪的错误,从而导致自己项目报错。但是这也没有办法,在国企中搞开发,有些项目或者插件是被要求必须使用的。

2、外包

说到开发,在国企必然是离不开外包的。在我这个公司,可以分为直聘+劳务派遣两种用工形式,劳务派遣就是我们通常所说的外包,直聘就是通过校招进来的校招生。

直聘的优势在于会有公司的统一编制,可以在系统内部调动。当然这个调动是只存在于规定中,99.9%的普通员工是不会调动。劳务派遣通常是社招进来的或者外包。在我们公司中,项目干活的主力都是外包。我可能因为自身本来就比较喜欢技术,并且觉得总要干几年技术才能对项目会有比较深入的理解,所以主动要求干活,也就是和外包一起干活。一开始我认为外包可能学历都比较低或者都不行,但是在实际干活中,某些外包的技术执行力是很强的,大多数项目的实际控制权在外包上,我们负责管理给钱,也许对项目的了解的深度和颗粒度上不如外包。

上次我空闲时间与一个快40岁的外包聊天,才发现他之前在腾讯、京东等互联网公司都有工作过,架构设计方面都特别有经验。然后我问他为什么离开互联网公司,他就说身体受不了。所以身体如果不是特别好的话,国企也是一个不错的选择。

3、技术栈

在日常开发中,国企的技术一般不会特别新。我目前接触的技术,前端是JSP,后端是Springboot那一套。开发的过程一般不会涉及到多线程,高并发等技术。基本上都是些表的设计和增删改查。如果个人对技术没啥追求,可能一天的活2,3小时就干完了。如果你对技术有追求,可以在剩余时间去折腾新技术,自由度比较高。

所以在国企,作为普通基层员工,一般会有许多属于自己的时间,你可以用这些时间去刷手机,当然也可以去用这些时间去复盘,去学习新技术。在社会中,总有一种声音说在国企呆久了就待废了,很多时候并不是在国企待废了,而是自己让自己待废了。

4、升职空间

每个研发类央企都有自己的职级序列,一般分为技术和管理两种序列。

首先,管理序列你就不用想了,那是留给有关系+有能力的人的。其实,个人觉得在国企有关系也是一种有能力的表现,你的关系能够给公司解决问题那也行。

其次,技术序列大多数情况也是根据你的工龄长短和PPT能力。毕竟,国企研发大多数干的活不是研发与这个系统的接口,就是给某个成熟互联网产品套个壳。技术深度基本上就是一个大专生去培训机构培训3个月的结果。你想要往上走,那就要学会去PPT,学会锻炼自己的表达能力,学会如何讲到领导想听到的那个点。既然来了国企,就不要再想钻研技术了,除非你想跳槽互联网。

最后,在国企底层随着工龄增长工资增长(不当领导)还是比较容易的。但是,如果你想当领导,那还是天时地利人和缺一不可。

5、钱

在前面说到,我们公司属于成本单位,到工资这一块就体现为钱是总部发的。工资构成分由工资+年终奖+福利组成。

1.工资构成中没有绩效,没有绩效,没有绩效,重要的事情说三遍。工资是按照你的级别+职称来决定的,公司会有严格的等级晋升制度。但是基本可以概括为混年限。年限到了,你的级别就上去了,年限没到,你天天加班,与工资没有一毛钱关系。

2.年终奖,是总部给公司一个大的总包,然后大领导根据实际情况对不同部门分配,部门领导再根据每个人的工作情况将奖金分配到个人。所以,你干不干活,活干得好不好只和你的年终奖相关。据我了解一个部门内部员工的年终奖并不会相差太多。

3.最后就是福利了,以我们公司为例,大致可以分为通信补助+房补+饭补+一些七七八八的东西,大多数国企都是这样模式。

总结

1、老生常谈了。在国企,工资待遇可以保证你在一线城市吃吃喝喝和基本的生活需要没问题,当然房子是不用想的了。

2、国企搞开发,技术不会特别新,很多时候是项目管理的角色。工作内容基本体现为领导的决定。

3、国企研究技术没有意义,想当领导,就多学习做PPT和领导搞好关系。或者当一个平庸的人,混吃等死,把时间留给家人,也不乏是一种好选择。

作者:程序员大彬
来源:juejin.cn/post/7182355327076007996

收起阅读 »

移动端页面加载耗时监控方案

iOS
本文阐述了个人对移动端页面加载耗时监控的一些理解,主要从:节点划分及对应的实现方案,线上监控注意点,后续还能做的事 三个方面来和大家分享。前言移动端的页面加载速度,作为最为影响用户体验的因素之一,是我们做移动端性能优化的重点方向之一。而优化的效果体现,需要置信...
继续阅读 »

本文阐述了个人对移动端页面加载耗时监控的一些理解,主要从:节点划分及对应的实现方案,线上监控注意点,后续还能做的事 三个方面来和大家分享。

前言

移动端的页面加载速度,作为最为影响用户体验的因素之一,是我们做移动端性能优化的重点方向之一。

而优化的效果体现,需要置信的指标进行衡量(常见方法论:寻找方向->确定指标->实践->量化收益),而本文想要分享的就是:如何真实、完整、方便的获得页面加载时间,并会向线上监控环节,有一定延伸。

本文的示例代码都是OC(因为Java和kotlin我也不会😅),但相关思路和方案也适用于Android(Android端已实现并上线)。

页面加载耗时

常见方案

页面加载时长是一直以来大家都在攻坚的方向,所以市面上也有非常非常多的度量方案,从节点划分角度看:

较为基础的:ViewController 的 init -> viewDidLoad -> viewDidAppear

更进一步的:ViewController 的 init -> viewDidLoad -> viewDidAppear -> user Interactable

主流方案:ViewController 的 init -> viewDidLoad -> viewDidAppear -> view render completed -> user Interactable

还有什么地方可以改进的吗?

对于这些成熟方案,我还有什么可以更进一步的吗?主要总结为以下几个方面吧:

  • 完整反映用户体感

我们做性能优化,归根结底,更是用户体验优化,在满足功能需要的同时,不影响用户的使用体验。 所以,我个人认为,大多数的性能指标,都要考虑到用户体验这个方向;页面启动速度这一块,更是如此;而传统的方案,能够完整的反应用户体感吗? 我觉得还是有一部分的缺失的:用户主动发起交互到ViewController这个阶段。这一部分有什么呢,不就是直接tap触发的action里vc就初始化了吗? 实际在一些较为复杂、大型的项目中,并不然,中间可能会有很多其他处理,例如:方法hook、路由调度、参数解析、containerVC的初始化、动态库加载等等。这一部分的耗时,实际上也是用户体感的一部分,而这一部分的耗时,如果不加监控的话,也会对整体耗时产生劣化。(这里可能会有小伙伴问了,这些东西,不应该由各自负责的同学,例如负责路由的同学,自行监控吗?这里我想阐述的一个观点时,时长类的监控,如果由几个时间段拼接,相比于endTime - startTime,难免会产生gap,即,加入endTime = 10,startTime = 0,那么中间分成两段,很有可能endTime2 = 10,startTime2 = 6;endTime1 = 4,startTime1 = 0,造成总时长不准。总而言之,还是希望得到一个能够完整反映用户体感的时长。)

  • 数据采集与业务解耦

这一点其实市面上的很多方案已经做得很好了。解耦,一方面是为了,提效:避免后续有新的页面需要监控时,需要进行新的开发;另一方面,也是避免业务迭代对于监控数据的影响:如果是手动侵入性埋点,很难保证后续新增的耗时任务对监控数据不产生影响。 而本文方案,不需要在业务代码中插入任何代码,大都是通过方法hook来实现数据采集的;而对范围、以及匹配关系等的控制,也都是通过配置来完成的。

具体实现

节点确定&数据采集方式


根据一个页面(ViewController)的加载过程中,开发主要进行的处理,以及可能对用户体感产生影响的因素,将页面加载过程划分为如上图所示的11个节点,具体解释及实现方案如下:

1. 用户行为触发页面跳转

由于页面的跳转一般是通过用户点击、滑动等行为触发的,因此这里监听用户触摸屏幕的时间点;但有效节点仅为VC在初始化前的最后一次点击/交互。

具体实现: hook UIWidow 的 sendEvent:方法,在swizzle方法内记录信息;为了性能考虑,目前仅记录一个uint64_t的时间戳,且仅内存写; 注意这里需要记录手指抬起的时间,即 touch.phase == UITouchPhaseEnded,因为一般action被调用的时机就是此时; 同时,为了适配各种行为触发的新页面出现,还增加了一个手动添加该节点的方法,使一些较复杂且不通用,业务特性较强的初始化场景,也能够有该节点数据,且不依赖hook;但注意该手动方法为侵入式数据采集方式。

2. ViewController的初始化

具体实现:hook UIViewController或你的VC基类 的 - (instancetype)init 的方法;

3. 本地UI初始化

不依赖于网络数据的UI开始初始化。

这个节点,我实际上并没有在本次实现,这里的一个理想态是:将这部分行为(即UI初始化的代码),通过协议的方式,约束到指定方法中;例如,架构层面约束一个setupSubviews的接口,回调给各业务VC,供其进行基础UI绘制(目前这种方式再一些更复杂的业务场景下实现并运行较好);有这个基础约束的前提下,才能准确的采集我理想中该节点的耗时。而我目前所负责的模块,并没有这种强约束,而又不能简单的去认为所有基础UI都是在viewDidLoad中去完成的。因此需要 对原有架构的一定修改 或 能够保证所有基础UI行为都在viewDidLoad中实现,才能够实现该节点数据的准确采集。 因此2 ~ 3和3 ~ 4间的耗时,被融合为了一段2 ~ 4的耗时。

4. 本地UI初始化完成

不依赖于网络数据的UI初始化完成。

具体实现:监听主线程的闲时状态,VC初始化 节点后的首个闲时状态表示 本地UI初始化完成;(闲时状态即runloop进入kCFRunLoopBeforeWaiting

5. 发起网络请求

调用网络SDK的时间点。

这里描述的就是上面的节点划分图的第二条线,因为两条线的节点间没有强制的线性关系,虽然图中当前节点是放在了VC初始化平行的位置,但实际上,有些实现会在VC初始化之前就发起网络请求,进行预加载,这种情况在实现的时候也是需要兼容的。

具体实现:hook 业务调用网络SDK发起请求方法的api;这里的网络库各家实现方案就可能有较大差异了,根据自身情况实现即可。

6. 网络SDK回调

网络SDK的回调触发的时间点。

具体实现:hook 网络SDK向业务层回调的api;差异性同5。

7. send request
8. receive response

真正 发出网络请求 和 收到response 的时间点,用于计算真正的网络层耗时。 这俩和5、6是不是重复了啊?并不然,因为,网络库在接收到发起网络请求的请求后,实际上在端阶段,还会进行很多处理,例如公参的处理、签名、验签、json2Model等,都会产生耗时;而真正离开了端,在网上逛荡那一段,更是几乎“完全不可控”的状态。所以,分开来统计:端部分 和 网络阶段,才能够为后续的优化提供数据基础,这也是数据监控的意义所在

具体实现: 实际上系统网络api中就有对网络层详细性能数据的收集

- (void)URLSession:(NSURLSession *)session 
             task:(NSURLSessionTask *)task
didFinishCollectingMetrics:(NSURLSessionTaskMetrics *)metrics;

根据官方文档中的描述


可以发现,我们实际上需要的时长就是从 fetchStartDateresponseEndDate 间的时间。 因此可以该delegate,获取这两个时间点。

9. 详细UI初始化

详细UI指,依赖于网络接口数据的UI,这部分UI渲染完成才是页面达到对用户可见的状态。

具体实现:这里我们认为从网络SDK触发回调时,即开始进行详细UI的渲染,因此该节点和节点6是同一个节点。

10. 详细UI渲染完成

页面对用户来说,真正达到可见状态的节点。

具体实现: 对于一个常规的App页面来说,如何定义一个页面是否真正渲染完成了呢?

被有效的视图铺满

什么是有效视图呢?视频,图片,文字,按钮,cell,能向用户传递信息,或者产生交互的view; 铺满,并不是指完全铺满,而是这些有效视图填充到一定比例即可,因为按照正常的视觉设计和交互体验,都不会让整个屏幕的每一个像素点都充满信息或具备交互能力;而这个比例,则是根据业务的不同而不同的。 下面则是上述逻辑的实现思路:

确定有效视图的具体类
UITextView 
UITextField
UIButton
UILabel
UIImageView
UITableViewCell
UICollectionViewCell

主流方案中比较常见的,是前几种类,并不包括最后的两个cell;而这里为什么将cell也作为有效视图类呢? 首先,出于业务特征考虑,目前应用该套监控方案的页面,主要是以卡片列表样式呈现的;而且个人认为,市面上很多App的页面也都是列表形式来呈现内容的;当然,如果业务特征并不相符,例如全屏的视频播放页,就可以不这样处理。 其次,将cell作为有效视图,确实能够极大的降低每次计算覆盖率的耗时的。性能监控本身产生的性能消耗,是性能方向一直以来需要着重关注的点,毕竟你一个为了性能优化服务的工具,反而带来了不小的劣化,怎样也说不太过去啊😂~ 我也测试了是否包含cell对计算耗时的影响: 下表中为,在一个层级较为复杂的业务页面,页面完全渲染完成之后,完成一次覆盖率达到阈值的扫描所需的时长。

有效视图包含 cell不包含 cell
检测一次覆盖率耗时(ms)1~515~18
耗时减少15ms/次(83%)

而且,有效视图的类,建议支持在线配置,也可以是一些自定义类。

将cell作为有效视图,大家可能会产生一个新的顾虑:占位cell的情况,再具体点,就是常见的骨架图怎么办?骨架图是什么,就是在网络请求未返回的时候,用缓存的data或者模拟样式,渲染出一个包含大致结构,但不包含具体内容的页面状态,例如这种:

这种情况下,cell已经铺满了屏幕,但实际上并未完成渲染。这里就要依赖于节点的前后顺序了,详细UI是依赖于网络数据的,而骨架图是在网络返回之前绘制完成的,所以真正的覆盖率计算,是从网络数据返回开始的,因此骨架图的填充完成节点,并不会被错误统计未详细UI渲染完成的节点。

覆盖率的计算方式


如上图所示,开辟两个数组a、b,数组空间分别为屏幕长宽的像素数,并以0填充,分别代表横纵坐标; 从ViewController的view开始递归遍历他的subView,遇见有效视图时,将其frame的width和height,对应在数组a、b中的range的内存空间,都填充为1,每次遍历结束后,计算数组a、b中内容为1的比例,当达到阈值比例时,则视为可见状态。 示例代码如下:

- (void)checkPageRenderStatus:(UIView *)rootView {
  if (kPhoneDeviceScreenSize.width <= 0 || kPhoneDeviceScreenSize.height <= 0) {
      return;
  }

  memset(_screenWidthBitMap, 0, kPhoneDeviceScreenSize.width);
  memset(_screenHeightBitMap, 0, kPhoneDeviceScreenSize.height);

  [self recursiveCheckUIView:rootView];
}

- (void)recursiveCheckUIView:(UIView *)view {
  if (_isCurrentPageLoaded) {
      return;
  }

  if (view.hidden) {
      return;
  }

  // 检查view是否是白名单中的实例,直接用于填充bitmap
  for (Class viewClass in _whiteListViewClass) {
      if ([view isKindOfClass:viewClass]) {
          [self fillAndCheckScreenBitMap:view isValidView:YES];
          return;
      }
  }

  // 最后递归检查subviews
  if ([[view subviews] count] > 0) {
      for (UIView *subview in [view subviews]) {
          [self recursiveCheckUIView:subview];
      }
  }
}

- (BOOL)fillAndCheckScreenBitMap:(UIView *)view isValidView:(BOOL)isValidView {

  CGRect rectInWindow = [view convertRect:view.bounds toView:nil];

  NSInteger widthOffsetStart = rectInWindow.origin.x;
  NSInteger widthOffsetEnd = rectInWindow.origin.x + rectInWindow.size.width;
  if (widthOffsetEnd <= 0 || widthOffsetStart >= _screenWidth) {
      return NO;
  }
  if (widthOffsetStart < 0) {
      widthOffsetStart = 0;
  }
  if (widthOffsetEnd > _screenWidth) {
      widthOffsetEnd = _screenWidth;
  }
  if (widthOffsetEnd > widthOffsetStart) {
      memset(_screenWidthBitMap + widthOffsetStart, isValidView ? 1 : 0, widthOffsetEnd - widthOffsetStart);
  }

  NSInteger heightOffsetStart = rectInWindow.origin.y;
  NSInteger heightOffsetEnd = rectInWindow.origin.y + rectInWindow.size.height;
  if (heightOffsetEnd <= 0 || heightOffsetStart >= _screenHeight) {
      return NO;
  }
  if (heightOffsetStart < 0) {
      heightOffsetStart = 0;
  }
  if (heightOffsetEnd > _screenHeight) {
      heightOffsetEnd = _screenHeight;
  }
  if (heightOffsetEnd > heightOffsetStart) {
      memset(_screenHeightBitMap + heightOffsetStart, isValidView ? 1 : 0, heightOffsetEnd - heightOffsetStart);
  }

  NSUInteger widthP = 0;
  NSUInteger heightP = 0;
  for (int i=0; i< _screenWidth; i++) {
      widthP += _screenWidthBitMap[i];
  }
  for (int i=0; i< _screenHeight; i++) {
      heightP += _screenHeightBitMap[i];
  }

  if (widthP > _screenWidth * kPageLoadWidthRatio && heightP > _screenHeight * kPageLoadHeightRatio) {
      _isCurrentPageLoaded = YES;
      return YES;
  }

  return NO;
}

但是也会有极端情况(类似下图)


无法正确反应有效视图的覆盖情况。但是出于性能考虑,并不会采用二维数组,因为w*h的量太大,遍历和计算的耗时,会有指数级的激增;而且,正常业务形态,应该不太会有类似的极端形态。

即使真的会较高频的出现类似情况,也有一套备选方案:计算有效视图的面积 占 总面积 的比例;该种方式会涉及到UI坐标系的频繁转换,耗时也会略差于当前的方式。

在某些业务场景下,例如 无/少结果情况,关于页面等,完全渲染后,也无法达到铺满阈值。 这种情况,会以用户发生交互(同 1、用户行为触发页面跳转 的获取方式)和 主线程闲时状态超过5s (可配)来做兜底,看是否属于这种状态,如果是,则相关性能数据不上报,因为此种页面对性能的消耗较正常铺满的情况要低,并不能真实的反应性能消耗、瓶颈,因此,仅正常铺满的业务场景进行监控并优化,即可。

扫描的触发时机

以帧刷新为准,因为只有每次帧刷新后,UI才会真正产生变化;出于性能考虑,不会每帧都进行扫描,每间隔x帧(x可配,默认为1),扫描一次;同时,考虑高刷屏 和 大量UI绘制时会丢帧 的情况,设置 扫描时间间隔 的上下限,即:满足 隔x帧 的前提下,如果和上次扫描的时间差小于 下限,仍不扫描;如果 某次扫描时,和上次扫描的时间间隔 大于 上限,则无论中间隔几帧,都开启一次扫描。

11. 用户可交互

用户可见之后的下一个对用户来说至关重要的节点。如果只是可见,然后就疯狂占用主线程或其他资源,造成用户的点击等交互行为,还是会被卡主,用户只能看,不能动,这个体感也是很差的;

具体实现:详细UI渲染完成 后的 首次主线程闲时状态。

监控方案

这里由于各家的基建并不相同,因此只是总结一些小的建议,可能会比较零散,大家见谅。

  1. 建议采样收集

首先,数据的采集或者其他的新增行为/方法,一定是会产生耗时的,虽然可能不多,但还是秉着尽善尽美的原则,还是能少点就少点的,所以数据的采集,包括前面的hook等等一切行为,都只是随机的面向一部分用户开放,降低影响范围; 而且,如果数据量极大,全量的数据上报,其实对数据链路本身也会产生压力、增加成本。 当前,采样的前提是基本数据量足够,不然的话,采样样本量过小,容易对统计结果产生较大波动,造成不置信的结果。

  1. 可配置

除了基本的是否开启的开关之外,还有其他的很多的点 需要/可以/建议 使用线上配置控制。个人认为,线上配置,除了实现对逻辑的控制,更重要的一个作用,就是出现问题时及时止损。 举一些我目前使用的配置中的例子: - 有效视图类 - 渲染完成状态,横纵坐标的填充百分比阈值 - 终态的兜底阈值 - VC的类名、对应的网络请求 等等。

  1. 本地异常数据过滤

由于我们的样本数据量会非常大,所以对于异常数据我们不需要“手软”,我们需要有一套本地异常数据过滤的机制,来保证上报的数据都是符合要求的;不然我们后续统计处理的时候,也会因此出现新的问题需要解决。

后续还能做的事

这一部分,是对后续可实现方案的一个美好畅想~

1)页面可见态的终点,不只是覆盖率

其实,实际业务场景中,很多cell,即使绘制完,并渲染到屏幕上,此时,用户可见的也没有达到我们真正希望用户可见的状态,很多内容,都还是一个placeholder的状态。例如,通过url加载的image,我们一般都是先把他的size算好,把他的位置留好,cell渲染完就直接展示了;再进一步,如果是一个视频的播放卡片,即使网络图片加载好了,还要等待视频帧的返回,才能真正达到这张卡片的业务终态\color{red}{业务终态}业务终态(求教这里标红后如何能够让字体大小一致)。

这个非常后置,而且我们端上可能也影响不了什么的节点,采集起来有意义吗?

我觉得这是一个非常有价值的节点。一直都在说“技术反哺业务”,那么业务想要用户真正看到的那个终态,就是很重要的一环;因此,用户能在什么时间点看到,从业务角度说,能够影响其后续的方案设计(表现形式),完善用户体感对业务指标的影响;从技术角度说,可以感知真实的全链路的表现(不只是端),从而有针对性的进行优化。

如何获取到所有的业务终态呢?

这里一定是和业务有所耦合的,因为每个业务的终态,只有业务自身才知道;但是我们还是要尽量降低耦合度。 这里可以用协议的方式,为各个业务增加一个达到终态的标识,那么在某个业务达到终态之后,设置该标识即可,这里就是唯一对业务的侵入了;然后和计算覆盖率类似,这里的遍历,是业务维度(这里想象为卡片更好理解一点),只有全部业务的标识都ready之后,才是真正达到业务上的终态。

2)性能指标 关联 业务行为

其实,现在性能监控,各类平台,各个团队,或多或少的都在做,我相信,性能数据采集的代码,在工程中,也不仅仅只有一份;这个现状,在很多成一定规模的互联网公司中都可能存在。

而如果您和我一样,作为一个业务团队,如何在不重复造轮子的情况下,夹缝中求生存呢?

我个人目前的理解:将 性能表现 与 业务场景 相关联。

帧率、启动耗时、CPU、内存等等,这些性能指标数据的获取,在业界都有非常成熟的方案,而且我们的工程里,一定也有相关的代码;而我们能做的,仅仅是,调一下人家的api,然后把数据再自己上传一份(甚至有的连上传都包含了),就完事了吗?

这样我觉得并不能体现出我们自建监控的价值。个人理解,监控的意义在于:暴露问题 + 辅助定位问题 + 验证问题的解决效果

所以我们作为业务团队,将 性能数据 和 我们的业务做了什么 bind 到一起了,是不是就能一定程度上完成了上面的目的呢?


我们可以明确,我们什么样的业务行为,会影响我们的性能数据,也就是影响我们的用户基础体验。这样,不仅会帮助我们定位问题的原因,甚至会影响产品侧的一些产品能力设计方案。

完成这些建设之后,可能我们的监控就可以变成这样,甚至更好的状态:


3)完善全链路对性能表现的关注

性能数据的关注、监控,不应该仅仅在线上阶段,开发期 → 测试期 → 线上,全链路各个环节都应该具有。

  • 目前各家都比较关注线上监控,相信都已经较为完善;

  • 测试期的业务流程性能脚本;对于测试的性能测试方案,开放应该参与共建或者有一定程度的参与,这样才能从一定程度上保证数据的准确性,以及双方性能数据的相互认可;

  • 开发期,目前能够提供展示实时CPU、FPS、内存数据的基础能力的工具很常见,也比较容易实现;但实际上,在日常开发的过程中,很难让RD同时关注需求情况与性能数据表现。因此,还是需要一些工具来辅助:例如,我们可以对某些性能指标,设置一些阈值,当日常开发中,超过阈值时,则弹窗提醒RD确认是否原因、是否需要优化,例如,详细UI绘制阶段的耗时阈值是800ms,如果某位同学在进行变更后,实际绘制耗时多次超越该值,则弹窗提醒。

作者:XTShow
来源:juejin.cn/post/7184033051289059384

收起阅读 »

30个Python操作小技巧

1、列表推导列表的元素可以在一行中进行方便的循环。numbers = [1, 2, 3, 4, 5, 6, 7, 8]even_numbers = [number for number in numbers if number % 2 == 0]print(e...
继续阅读 »

1、列表推导

列表的元素可以在一行中进行方便的循环。

numbers = [1, 2, 3, 4, 5, 6, 7, 8]
even_numbers = [number for number in numbers if number % 2 == 0]
print(even_numbers)

输出:

 [1,3,5,7]

同时,也可以用在字典上。

dictionary = {'first_num': 1, 'second_num': 2,
             'third_num': 3, 'fourth_num': 4}
oddvalues = {key: value for (key, value) in dictionary.items() if value % 2 != 0}
print(oddvalues)Output: {'first_num': 1, 'third_num': 3}

2、枚举函数

枚举是一个有用的函数,用于迭代对象,如列表、字典或文件。该函数生成一个元组,其中包括通过对象迭代获得的值以及循环计数器(从0的起始位置)。当您希望根据索引编写代码时,循环计数器很方便。

sentence = 'Just do It'
length = len(sentence)
for index, element in enumerate(sentence):
   print('{}: {}'.format(index, element))
    if index == 0:
       print('The first element!')
   elif index == length - 1:
       print('The last element!')

3、通过函数返回多个值

在设计函数时,我们经常希望返回多个值。这里我们将介绍两种典型的方法:

方法一

最简单的方式就是返回一个tuple。

get_student 函数,它根据员工的ID号以元组形式返回员工的名字和姓氏。

# returning a tuple.
def get_student(id_num):
   if id_num == 0:
       return 'Taha', 'Nate'
   elif id_num == 1:
       return 'Jakub', 'Abdal'
   else:
       raise Exception('No Student with this id: {}'.format(id_num))

Student = get_student(0)
print('first_name: {}, last_name: {}'.format(Student[0], Student[1]))

方法二、

返回一个字典类型。因为字典是键、值对,我们可以命名返回的值,这比元组更直观。

# returning a dictionary
def get_data(id_num):
   if id_num == 0:
       return {'first_name': 'Muhammad', 'last_name': 'Taha', 'title': 'Data Scientist', 'department': 'A', 'date_joined': '20200807'}
   elif id_num == 1:
       return {'first_name': 'Ryan', 'last_name': 'Gosling', 'title': 'Data Engineer', 'department': 'B', 'date_joined': '20200809'}
   else:
       raise Exception('No employee with this id: {}'.format(id_num))
employee = get_data(0)
print('first_name: {},nlast_name: {},ntitle: {},ndepartment: {},ndate_joined: {}'.format(
   employee['first_name'], employee['last_name'], employee['title'], employee['department'], employee['date_joined']))

4、像数学一样比较多个数字

如果你有一个值,并希望将其与其他两个值进行比较,则可以使用以下基本数学表达式:1<x<30。

你也许经常使用的是这种

1<x and x<30

在python中,你可以这么使用

x = 5
print(1<x<30)

5、将字符串转换为字符串列表:

当你输入 "[[1, 2, 3],[4, 5, 6]]" 时,你想转换为列表,你可以这么做。

import ast
def string_to_list(string):
return ast.literal_eval(string)
string = "[[1, 2, 3],[4, 5, 6]]"
my_list = string_to_list(string)
print(my_list)

6、对于Else方法

Python 中 esle 特殊的用法。

number_List = [1, 3, 8, 9,1]

for number in number_List:
if number % 2 == 0:
print(number)
break
else:
print("No even numbers!!")

7、在列表中查找n个最大或n个最小的元素

使用 heapq 模块在列表中查找n个最大或n个最小的元素。

import heapq
numbers = [80, 25, 68, 77, 95, 88, 30, 55, 40, 50]
print(heapq.nlargest(5, numbers))
print(heapq.nsmallest(5, numbers))

8、在不循环的情况下重复整个字符串

value = "Taha"
print(value * 5)
print("-" * 21)

9、从列表中查找元素的索引

cities= ['Vienna', 'Amsterdam', 'Paris', 'Berlin']
print(cities.index('Berlin'))

10、在同一行中打印多个元素?

print("Analytics", end="")
print("Vidhya")
print("Analytics", end=" ")
print("Vidhya")
print('Data', 'science', 'blogathon', '12', sep=', ')

输出

AnalyticsVidhya
Analytics Vidhya
Data, science, blogathon, 12

11、把大数字分开以便于阅读

有时,当你试图打印一个大数字时,传递整数真的很混乱,而且很难阅读。然后可以使用下划线,使其易于阅读。

print(5_000_000_000_000)

print(7_543_291_635)

输出:

5000000000000
7543291635

12、反转列表的切片

切片列表时,需要传递最小、最大和步长。要以相反的顺序进行切片,只需传递负步长。让我们来看一个例子:

sentence = "Data science blogathon"
print(sentence[21:0:-1])

输出

nohtagolb ecneics ata

13、 “is” 和 “==” 的区别。

如果要检查两个变量是否指向同一个对象,则需要使用“is”

但是,如果要检查两个变量是否相同,则需要使用“==”。

list1 = [7, 9, 4]
list2 = [7, 9, 4]
print(list1 == list2)
print(list1 is list2)
list3 = list1
print(list3 is list1)

输出

True
False
True

14、在一行代码中合并两个词典。

first_dct = {"London": 1, "Paris": 2}
second_dct = {"Tokyo": 3, "Seol": 4}
merged = {**first_dct, **second_dct}
print(merged)

输出

{‘London’: 1, ‘Paris’: 2, ‘Tokyo’: 3, ‘Seol’: 4}

15、识别字符串是否以特定字母开头

sentence = "Analytics Vidhya"
print(sentence.startswith("b"))
print(sentence.startswith("A"))

16、获得字符的Unicode

print(ord("T"))
print(ord("A"))
print(ord("h"))
print(ord("a"))

17、获取字典的键值对

cities = {'London': 1, 'Paris': 2, 'Tokyo': 3, 'Seol': 4}
for key, value in cities.items():
print(f"Key: {key} and Value: {value}")

18、在列表的特定位置添加值

cities = ["London", "Vienna", "Rome"]
cities.append("Seoul")
print("After append:", cities)
cities.insert(0, "Berlin")
print("After insert:", cities)

输出:

[‘London’, ‘Vienna’, ‘Rome’, ‘Seoul’] After insert: [‘Berlin’, ‘London’, ‘Vienna’, ‘Rome’, ‘Seoul’]

19、Filter() 函数

它通过在其中传递的特定函数过滤特定迭代器,并且返回一个迭代器。

mixed_number = [8, 15, 25, 30,34,67,90,5,12]
filtered_value = filter(lambda x: x > 20, mixed_number)
print(f"Before filter: {mixed_number}")
print(f"After filter: {list(filtered_value)}")

输出:

Before filter: [8, 15, 25, 30, 34, 67, 90, 5, 12]
After filter: [25, 30, 34, 67, 90]

20、创建一个没有参数个数限制的函数

def multiplication(*arguments):
mul = 1
for i in arguments:
mul = mul * i
return mul
print(multiplication(3, 4, 5))
print(multiplication(5, 8, 10, 3))
print(multiplication(8, 6, 15, 20, 5))

输出:

60
1200
72000

21、一次迭代两个或多个列表

capital = ['Vienna', 'Paris', 'Seoul',"Rome"]
countries = ['Austria', 'France', 'South Korea',"Italy"]
for cap, country in zip(capital, countries):
print(f"{cap} is the capital of {country}")

22、检查对象使用的内存大小

import sys
mul = 5*6
print(sys.getsizeof(mul))

23、 Map() 函数

map() 函数用于将特定函数应用于给定迭代器。

values_list = [8, 10, 6, 50]
quotient = map(lambda x: x/2, values_list)
print(f"Before division: {values_list}")
print(f"After division: {list(quotient)}")

24、计算 item 在列表中出现的次数

可以在 list 上调用 count 函数。

cities= ["Amsterdam", "Berlin", "New York", "Seoul", "Tokyo", "Paris", "Paris","Vienna","Paris"]
print("Paris appears", cities.count("Paris"), "times in the list")

25、在元组或列表中查找元素的索引

cities_tuple = ("Berlin", "Paris", 5, "Vienna", 10)
print(cities_tuple.index("Paris"))
cities_list = ['Vienna', 'Paris', 'Seoul',"Amsterdam"]
print(cities_list.index("Amsterdam"))

26、2个 set 进行 join 操作

set1 = {'Vienna', 'Paris', 'Seoul'}
set2 = {"Tokyo", "Rome",'Amsterdam'}
print(set1.union(set2))

27、根据频率对列表的值进行排序

from collections import Counter
count = Counter([7, 6, 5, 6, 8, 6, 6, 6])
print(count)
print("Sort values according their frequency:", count.most_common())

输出:

Counter({6: 5, 7: 1, 5: 1, 8: 1})
Sort values according their frequency: [(6, 5), (7, 1), (5, 1), (8, 1)]

28、从列表中删除重复值

cities_list = ['Vienna', 'Paris', 'Seoul',"Amsterdam","Paris","Amsterdam","Paris"]
cities_list = set(cities_list)
print("After removing the duplicate values from the list:",list(cities_list))

29、找出两个列表之间的差异

cities_list1 = ['Vienna', 'Paris', 'Seoul',"Amsterdam", "Berlin", "London"]
cities_list2 = ['Vienna', 'Paris', 'Seoul',"Amsterdam"]
cities_set1 = set(cities_list1)
cities_set2 = set(cities_list2)
difference = list(cities_set1.symmetric_difference(cities_set2))
print(difference)

30、将两个不同的列表转换为一个字典

number = [1, 2, 3]
cities = ['Vienna', 'Paris', 'Seoul']
result = dict(zip(number, cities))
print(result)

作者:程序员学长
来源:juejin.cn/post/7126728825274105886

收起阅读 »

环信 flutter sdk集成IM离线推送及点击推送获取推送信息(iOS版)

前提条件1.macOS系统,安装了xcode和flutter集成环境2.有苹果开发者账号3.有环信开发者账号(注册地址:https://console.easemob.com/user/register)4.参考这篇文章https://www.imgeek.o...
继续阅读 »

前提条件

1.macOS系统,安装了xcode和flutter集成环境

2.有苹果开发者账号

3.有环信开发者账号

(注册地址:https://console.easemob.com/user/register)


4.参考这篇文章https://www.imgeek.org/article/825360043,完成推送证书的创建和上传

集成IM离线推送


1.创建一个新的项目

2.导入flutterSDK

3.初始化环信sdk

void initSDK() async {

  var options = EMOptions(

    appKey: “你的appkey”,

  );

  options.enableAPNs("EaseIM_APNS_Developer");

  await EMClient.getInstance.init(options);

  debugPrint("has init");

}

EaseIM_APNS_Developer是你在环信后台创建的证书名,需要注意,iOS需要上传开发证书和生产证书

4.可以在 _incrementCounter 这个按钮点击事件中调用一下登录操作,到此flutter层的工作已经完成

5.打开原生项目,修改包名,添加推送功能

6.打开AppDelegate 文件 导入im_flutter_sdk,并且在didRegisterForRemoteNotificationsWithDeviceToken方面里面调用环信的registerForRemoteNotifications方法,进行token的绑定

注:IM离线推送机制:

1.环信这边需要针对设备deviceToken和环信的username进行绑定,

2.IMserver 收到消息,会检测接收方是否在线,如果在线直接投递消息,如果不在线,则根据username 取设备的deviceToken

3.根据设备的deviceToken 和 上传的证书给设备推送消息

4.当app第一次运行的时候,就会走didRegisterForRemoteNotificationsWithDeviceToken方法,这个时候绑定token信息会报错,这个时候是正常的,因为你并没有登录,此时SDK内部会保存deviceToken,当你调用登录接口成功之后,SDK内部会进行一次绑定token的操作,

到此,推送功能已经集成完毕,注意测试时建议先把项目杀死,保证该用户已经离线


点击推送获取推送信息

第一种方法 自己做桥接,实现原生层与flutter层做交互

第二种方法 可以利用先有api 实现原生层给flutter层传递消息

今天主要介绍第二种方法

1.打开原生层 在didFinishLaunchingWithOptions和didReceiveRemoteNotification 方法里调用EMClientWrapper.shared().sendData(toFlutter: userInfo) 方法,把需要传递的数据传到flutter层

didFinishLaunchingWithOptions 是在app没有打开的情况下点击推送,从launchOptions里面拿到推送信息

didReceiveRemoteNotification是在 app已经打开的情况下点击推送,从userInfo里面拿到推送信息

注意:EMClientWrapper.shared().sendData 这个方法填的参数必须是一个字典

如下图所示

2.打开flutter层 调用EMClient.getInstance.customEventHandler方法 需要赋值一个函数,这个函数就是接受来自原生层传递过来的消息

3.此时 点击推送消息 在flutter层就能获取到信息,如图我测试的结果

完毕




收起阅读 »

Flutter 弹性布局的基石: Flex 和 Flexible

Flutter 弹性布局的基石 是 Flex 和 Flexible。理解了这两个 widget,后面的 Row,Column 就都轻而易举了。本文用示例的方式详细介绍 Flex 的布局算法。 Flex 布局算法 小写字母开头的 flex 是指 Flexible...
继续阅读 »

Flutter 弹性布局的基石 是 Flex 和 Flexible。理解了这两个 widget,后面的 Row,Column 就都轻而易举了。本文用示例的方式详细介绍 Flex 的布局算法。


Flex 布局算法


小写字母开头的 flex 是指 Flexible 的 属性 flex。




  1. 先布局 flex 为 0 或 null 的 child。在 main 轴上 child 受到的约束是 unbounded。如果 crossAxisAlignment 是 CrossAxisAlignment.stretch, 在 cross 轴上的约束是 tight,值是 cross 轴上约束的最大值。否则,在 cross 轴上的约束是 loose。




  2. 为 flex 不为 0 的 child 申请空间,flex 值越大,按比例得到的可以占用的空间越大。




  3. 为 flex 不为 0 的 child 分配空间。main 轴方向的最大值是第二步申请到的空间的值。如果 child 的 fit 参数为 FlexFit.tight,child 在主轴方向 受到 tight 约束,值为第二步申请到的空间的值。如果 child 的 fit 参数为 FlexFit.loose,child 在主轴方向 受到 loose 约束。child 在主轴方向可以任意小,但不能超第二步申请到的空间的值。




  4. Flex cross 轴的高度是能包住所有 child,并不超过最大约束。




  5. Flex main 轴的宽度与 mainAxisSize 有关。如果 mainAxisSize 是 MainAxisSize.max,main 轴的宽度是最大约束值,否则是能包住所有 child ,但不超过最大约束。




  6. Flex 自己的尺寸和 child 的尺寸确认后,根据 mainAxisAlignment 和 crossAxisAlignment 摆放 child。




看了算法并不直观,下面通过实例讲解。


非弹性组件在 main 轴受到的约束是 unbounded



Flex(
direction: Axis.horizontal,
children: [
Container(
width: 1000,
height: 100,
color: Colors.red[200],
),
],
)

我们看到,Flex 在主轴的约束是 unbounded,所以 container 可以取值 1000,超出屏幕,显示警告。


flex 值越高,可以分到的空间越大,但能否占满空间取决于 fit 参数



Flex(
direction: Axis.horizontal,
children: [
Flexible(flex:2 ,child: Container(width: 50,height: 80,color: Colors.green,),),
Flexible(flex:1, child: Container(width: 100,height: 50,color: Colors.blue[300],),),
Container(width: 50,height: 100,color: Colors.red[200],
),
],
)

假设宽一共 200,布局过程:



  1. 先分配非弹性 child 红色块 50。

  2. 绿色和蓝色块是弹性块,它们会瓜分剩下的 150,按 flex 值,绿色块应该分 100,蓝色块分 50。

  3. 绿色块的 fit 值是 loose,flex 不强制它把空间占满,所以它只点了 50。蓝色块的 fit 值 是 loose,它的 width 比 50 大 flex 会强制它的宽度为 50。效果就是右边还剩下 50,那本来是分给绿色块的。


如果绿色块的 fit 值修改为 FlexFit.tight,剩下的空间就会被占满了,这个时候 width 会被忽略。


Flexible 的作用就是为了修改 child 的 parentData,给 child 增加 fit, flex 布局信息。让 Flex 根据这些信息为 child 布局。


Expanded


class Expanded extends Flexible {
const Expanded({
super.key,
super.flex,
required super.child,
}) : super(fit: FlexFit.tight);
}

Expanded 其实就是 fit 固定为 FlexFit.tight 的 Flexible。其实可以直接用 Flexible 的,但因为 Expanded 太常用了,所以单独加了一个类。同时 Expanded 也更加有语义。Expanded 和 Flexible 的关系就像 Center 和 Align的一样。


Spacer


class Spacer extends StatelessWidget {

const Spacer({super.key, this.flex = 1})
: assert(flex != null),
assert(flex > 0);

final int flex;

@override
Widget build(BuildContext context) {
return Expanded(
flex: flex,
child: const SizedBox.shrink(),
);
}
}

Spacer 的 child 是 SizedBox.shrink(),用来占位,没有实际的意义。Spacer 是 Expanded 的包装,就是为了占空位用的。


至于摆放 child 的规则大同小异,如果有不明白的同学可以看 这篇 Flutter Wrap 图例


Flex 和 Flexible 如果都掌握了,Row 和 Colmn 自然就会了。因为 Row 只是 direction 为 Axis.horizontal 的 Flex,Column 只是 direction 为 Axis.vertical 的 Flex。


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

Flutter 蒙层控件 ModalBarrier

ModalBarrier 是一个蒙层控件,可以对他后面的 UI 进行遮挡,阻止用户和后面的 UI 发生交互。 ModalBarrier 介绍 在实现上,核心代码是 是一个 ConstrainedBox 包了一个 ColoredBox 。ConstrainedB...
继续阅读 »

ModalBarrier 是一个蒙层控件,可以对他后面的 UI 进行遮挡,阻止用户和后面的 UI 发生交互。


ModalBarrier 介绍


在实现上,核心代码是 是一个 ConstrainedBox 包了一个 ColoredBox 。ConstrainedBox 的作用是让 ModalBarrier 拥有允许范围内的最大尺寸。ColoredBox 就是画一个背景色。


ConstrainedBox(
constraints: const BoxConstraints.expand(),
child: color == null ? null : ColoredBox(
color: color!,
),
),

参数主要有 3 个 color 设置背景色,dismissible 决定点击的时候是否隐藏,onDismiss 是一个回调,当隐藏的时候调用。


使用 ModalBarrier


使用 ModalBarrier 需要 用到 Stack,下面是一个例子。开始的时候 ModalBarrier 不显示,点击按钮的时候,显示 ModalBarrier,过几秒后自动消失。显示 ModalBarrier 的时候,尝试点击按钮,是点不到的,因为已经被 ModalBarrier 遮挡了。



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

@override
State<MyWidget> createState() => _MyWidgetState();
}

class _MyWidgetState extends State<MyWidget> {
var showBarrier = false;

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

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

@override
Widget build(BuildContext context) {
return SizedBox(
width: 300,
height: 300,
child: Stack(
alignment: Alignment.center,
children: [
ElevatedButton(
onPressed: (() async {
setState(() {
showBarrier = true;
});
await Future.delayed(Duration(seconds: 5));
setState(() {
showBarrier = false;
});
}),
child: Text('显示 barrier')),
if (showBarrier)
ModalBarrier(
color: Colors.black38,
),
],
));
}
}

在这个例子中,你点遮罩,遮罩 是不消失的。因为让遮罩消失执行的代码是 Navigator.maybePop(context)。我们并没有 push ,所以 pop 也就没有反应了。


一般来说,如果想要全屏遮罩,直接用 Dialog 为好。想部分遮罩,才需要直接用 ModalBarrier,这个时候自己控制显隐。


 if (onDismiss != null) {
onDismiss!();
} else {
Navigator.maybePop(context);
}
}

ModalBarrier 源码的逻辑是这样的,所以我们可以添加 onDismiss 回调,在回调函数里隐藏遮罩,这样就不会再走 Navigator.maybePop(context); 了。


ModalBarrier(
dismissible: true,
onDismiss: () {
setState(() {
showBarrier = false;
});
},
color: Colors.black38,
);

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

比 Flutter ListView 更灵活的布局方式

大家好,我是 17。 在 Flutter 中,涉及到滚动布局的时候,很多同学会大量使用 ListView。 ListView 的局限 没错,在实现效果的方面 ListView 确实能做到大多数,但是有些情况下会很别扭,性能也不好。你可能遇到过下面的设计: b...
继续阅读 »

大家好,我是 17。


在 Flutter 中,涉及到滚动布局的时候,很多同学会大量使用 ListView


ListView 的局限


没错,在实现效果的方面 ListView 确实能做到大多数,但是有些情况下会很别扭,性能也不好。你可能遇到过下面的设计:



banner 和下面的列表是一起滚动的。如果用 ListView ,你一定可以马上写出代码:


ListView.builder(
itemBuilder: (context, index) {
if (index == 0) {
return Container(
height: 100,
color: Colors.blue,
child: Text('banner'),
);
} else {
return ListTile(title: Text('${index - 1}'));
}
},
itemCount: 100
)

上面的代码会有一个问题,banner 的高度和下面列表的高度不一样,导致无法使用定高列表,造成性能下降。需要 if else,如果有多个 banner,if else 也要多个,那就相当复杂了。


还有一个问题,在你没有设置任何边距的情况下,ListView 和上面的 Widget 可能会一段空白。



你需要这样去除空白。


ListView.builder(
padding: EdgeInsets.zero,

为什么会有空白呢?这是因为 ListView 继承自 BoxScrollView,它的主要贡献就是加了这个空白!


这个空白的值是多少呢?就是取的 mediaQuery 的 padding。因为浏海屏的出现,ios 中,上面和下面会有一部分不适合显示主要内容,所以就有了这个安全 padding。BoxScrollView 在设计的时候也考虑到了这一点,于是就默认加了这个 padding。但实际上,如果 listView 不是在最顶部,反而是帮了倒忙。


ListView 最理想的使用场景是展示的 item 都一样高,但多数情况下,item 是不一样高的。ListView 出现的目的是为了方便使用,但却是牺牲了灵活性。它只能有一个 SliverChild,这会导致 itemBuilder 函数逻辑的复杂和性能的下降。


更灵活的布局方式


其实我们可以直接从 ScrollView 继承,根据实际情况定制需要的组件。说到定制你可能会觉得一定很复杂,实际上是非常简单的,而且因为我们是根据业务量身定做的组件,所以用起来会特别顺手。


要用 ScrollView 实现上面的设计,只需要下面的代码:


class MyListView extends ScrollView {
const MyListView(
{Key? key,
this.banner,
required this.itemBuilder,
required this.itemExtent,
required this.itemCount})
: super(key: key);
final Widget? banner;
final IndexedWidgetBuilder itemBuilder;
final double itemExtent;
final int itemCount;

@override
List<Widget> buildSlivers(BuildContext context) {
List<Widget> list = [];
if (banner != null) {
list.add(SliverToBoxAdapter(child: banner!));
}
list.add(SliverFixedExtentList(
delegate: SliverChildBuilderDelegate(itemBuilder, childCount: itemCount),
itemExtent: itemExtent,
));
return list;
}
}

很简单吧。实际上,我们只是 override buildSlivers 方法,生成一个 list。SliverToBoxAdapter 可以看作是一个转换器,把普通的 Widget 转换为 Sliver Widget。虽然 buildSlivers 的返回值是 List<Widget> ,但实际上,Widget 应该是 Sliver Widget,否则无法滚动。


MyListView 使用起来也很方便,代码更简洁,没有了讨厌的 if else 了。


MyListView(
banner: Container(color: Colors.green, height: 100),
itemExtent: 20,
itemCount: 100,
itemBuilder: (context, index) => Text('$index'),
)

现在 banner 和 item 的逻辑是分开的,代码更加清晰,也更好维护。把 banner 这个高度不一样的 widget 分开后,剩下的 item 高度都是一样的,本例中,我们设置固定高度 itemExtent: 20,每个 item 的高度都是 20,在 buildSlivers 中用 itemExtent 做为参数,用 SliverFixedExtentList 生成定高列表,性能得到大大提高。


老板说,把第 10 条数据显示在第一的位置


这个需求还是很常见的,在某个时刻,需要把某条数据显示在第一的位置。如果用 ListView 实现起来不容易,你可能想要调整数据的位置,但需求是数据的位置不变,只是想让 ViewPort 滚动到 第 10 条数据的位置。你可能还想到了用 ListView 的 controller 来控制滚动位置,尝试一下可以知道并不方便实现,或者实现了也不方便维护。


直接用 ScrollView 就很简单了。 ScrollView 有一个参数可以直接实现在这样的功能,这个参数就是 center。你可能很奇怪,ListView 是从 BoxScrollView 继承,BoxScrollView 是从 ScrollView 继承,但是在 ListView 中没有发现这个参数啊?为了方便使用,BoxScrollView 只有一个 Sliver Child,center 参数没有了用武之地,在 ListView 中找不到这个参数也就不奇怪了。


实现功能


先看下效果,不使用 center 参数,banner 在第一个位置显示。



使用 center 参数后,第 10 条数据,自动显示在第一个位置。



下面是完整代码,贴到 main.dart 就能运行


import 'package:flutter/material.dart';

void main() => runApp(const MyApp());

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

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

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

@override
State<MyWidget> createState() => _MyWidgetState();
}

class _MyWidgetState extends State<MyWidget> {
@override
Widget build(BuildContext context) {
return SafeArea(
child: MyListView(
banner: Container(
color: Colors.blue[100],
alignment: Alignment.center,
height: 100,
child: const Text(
'IAM17 Flutter 天天更新',
),
),
itemBuilder: (context, index) {
return ListTile(
title: Text('$index'),
);
},
center: const ValueKey(9),
itemExtent: 20,
itemCount: 100));
}
}

class MyListView extends ScrollView {
const MyListView(
{Key? key,
this.banner,
required this.itemBuilder,
required this.itemExtent,
required this.itemCount,
Key? center})
: super(key: key, center: center);
final Widget? banner;
final IndexedWidgetBuilder itemBuilder;
final double itemExtent;
final int itemCount;

@override
List<Widget> buildSlivers(BuildContext context) {
List<Widget> list = [];
if (banner != null) {
list.add(SliverToBoxAdapter(child: banner!));
}
if (center == null) {
list.add(SliverFixedExtentList(
delegate:
SliverChildBuilderDelegate(itemBuilder, childCount: itemCount),
itemExtent: itemExtent,
));
} else {
for (var i = 0; i < itemCount; i++) {
list.add(SliverToBoxAdapter(
key: ValueKey(i),
child: itemBuilder(context, i),
));
}
}
return list;
}
}

当 center 不为 null 的时候,放弃使用 SliverFixedExtentList,只能把 child 一个一个加到 list 中。这样会损失一些性能,但能快速实现需求,还是值得的。


center 参数是如何影响位置的?


在 ViewPort 的构造函数中有一个 assert,如果 center 不为空,那么在 slivers 中必须要找到 key 为 center 的 child。


Viewport({
...
this.center,
...
}) :
assert(center == null || slivers.where((Widget child) => child.key == center).length == 1),

最终是给 ViewPort 对应的 renderObject 的 center 赋值。


代码位置 : flutter/lib/src/widgets/viewport.dart


void _updateCenter() {
// TODO(ianh): cache the keys to make this faster
final Viewport viewport = widget as Viewport;
if (viewport.center != null) {
int elementIndex = 0;
for (final Element e in children) {
if (e.widget.key == viewport.center) {
renderObject.center = e.renderObject as RenderSliver?;
break;
}
elementIndex++;
}
assert(elementIndex < children.length);
_centerSlotIndex = elementIndex;
} else if (children.isNotEmpty) {
renderObject.center = children.first.renderObject as RenderSliver?;
_centerSlotIndex = 0;
} else {
renderObject.center = null;
_centerSlotIndex = null;
}
}

总之,就是通过 key 找到对应的 Sliver Widget,对应到 renderObject,实现 center 的功能。


通过这个简单的案例说明,我们应该自己动手定制适合自己项目的 ”ListView“!通过简单的封装,就能让我们的代码更简洁,更容易维护,性能也会更好。


更多关于滚动的参数介绍可以看这篇 flutter 滚动的基石 Scrollable


回答下 @法的空间 提的问题:CustomScrollView 的意义何在?


BoxScrollView 和 CustomScrollView 都是 ScrollView 的 子类。BoxScrollView 只能创建一块滑动内容,CustomScrollView 可以支持滑动列表,这就是 CustomScrollView 的意义。


之所以没有直接用 CustomScrollView ,而是直接从 ScrollView 继承是为了可以把一些属性和滑动列表一起封装起来,方便使用。


如果代码不需要复用,直接用 CustomScrollView 也是可以的,而且也是最简单的方式。


CustomScrollView 的代码就一句:


 @override
List<Widget> buildSlivers(BuildContext context) => slivers;

ScrollView 是抽象类,不能直接用,CustomScrollView 的意义在于:我们不需要每次都要 extends 一个类出来,用 CustomScrollView 就可以支持滑动列表。


希望已经解答了你的问题,谢谢提问!


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

半夜,我差点揍了十年前的自己

张大胖走出公司的时候,已经将近半夜。此时天上的月亮仍旧散发着清冷的幽光,无情地审视着大地。最近公司业务繁忙,大批技术人员都被迫加班到很晚,张大胖正是其中之一。在公司门口趴活的出租车排成了长龙,连出租车司机都知道,这个时候是园区的下班高峰,最容易拉活儿。张大胖上...
继续阅读 »

张大胖走出公司的时候,已经将近半夜。

此时天上的月亮仍旧散发着清冷的幽光,无情地审视着大地。

最近公司业务繁忙,大批技术人员都被迫加班到很晚,张大胖正是其中之一。

在公司门口趴活的出租车排成了长龙,连出租车司机都知道,这个时候是园区的下班高峰,最容易拉活儿。

张大胖上了车,熟练地从背包中掏出了笔记本电脑。

“明天晚上有个面试,得抓紧时间再补补技术基础。” 张大胖心想。

张大胖没有注意到,前方的司机瞟了一眼后视镜,微微叹了一口气。

司机也是个胖子, 年龄看起来要比张大胖大个10岁左右,若隐若现的发际线都无意间展露出中年人的危机。

张大胖今天复习的是CPU缓存, 他一边看着电脑上的图,一边回忆之前的知识,喃喃自语:

“CPU 缓存「Cache」指的访问速度比一般内存快得多的高速存储器,主要是为了解决 CPU 运算速率与内存读写速率不匹配的问题。..... ”

张大胖发现由于连续加班熬夜,自己的脑子有点儿不太好使了。

“小伙子,你是搞计算机的吧, 太晚了,别再用功了,身体要紧!” 前座传来一声沧桑的话语。

“多谢师傅,明天晚上是四十大盗公司的面试,我要奋斗一下,努力进大厂!”

“进了大厂又如何?你看人家把面试安排在晚上,说明了什么?”

“唉!说明都在加班啊!” , 张大胖略微有点儿诧异,这位司机似乎懂点儿IT行业。

“不过,只要钱多,累点儿也值,像我这种没有背景的人,想在大城市立足,不吃苦是不可能啊。” 张大胖补充道。

司机沉默了, 稳稳地握着方向盘,眼光眺向远方。

张大胖的双眼也被笔记本电脑中的图给吸引了。

沉默了一会儿,司机突然开口:“对了,我有个亲戚,在一个创业公司写代码,叫什么舞动来着,发展势头很好,正在招人,你愿不愿意去啊?”

他扭过头来,两眼放光补充道:“如果公司上市,你就财务自由了!”

“师傅,小心前面的车,” 张大胖看他居然不看路了,马上提醒道。

“舞什么动啊?创业公司九死一生,风险太大了。” 张大胖一心求稳,想进大公司。

“年轻人,不冒险太可惜了!”前座的师傅无奈地摇了摇头。

张大胖心想,我凭什么相信你呢,你说财务自由就能自由?还有可能浪费掉我的青春呢!

“对了,我听说现在有个什么比特币,你们程序员应该懂,可以买一点儿啊!”

张大胖咧嘴笑了,没想到这位师傅信息挺灵通,连比特币都知道。

“师傅你可能不懂,比特币我研究过,什么Hash,什么去中心化,都是数字游戏而已,没有任何价值!”

“哎呀,现在好像几毛钱一个,你可以花个百十块,买几千个比特币玩玩不就行了!囤几年肯定涨价!” 听到张大胖不屑一顾,师傅似乎有点着急。

“哈哈,那还不如我和同学吃一顿自助!”

张大胖说完,就低头又去看CPU缓存去了。

车行驶了十分钟, 师傅又幽幽地说到:“小伙子,你炒股不?我可以给你推荐几个潜力股,比如腾讯,阿里,茅台,格力,微软,苹果...... ”

“师傅你好厉害啊,炒股都炒到美国去了,不过我不炒股,每天心惊肉跳的,实在受不了!”

“这些都是潜力股,你可以长期持有,收益绝对在几十倍以上,以后就不用这么辛苦了!”

张大胖笑了笑,心说这个北京的司机师傅可真会吹牛,股市中七亏二平一赚,自己可当不了那个幸运儿。

他又把思路拉回到CPU缓存中,开始复习地址映像的三种方法,直接映像,全相联映像和组相联映像。

“那你买房子吗?现在北京的房子正好处于的最低点,北京作为超级大都市,将来的房价会像香港那样,10几万一平。”

“师傅您说笑了,现在几万一平我们年轻人都买不起了,还十几万,到时候卖个谁去?”

“吱---” 突然一个急刹车,把张大胖吓了一跳。

司机师傅打开车门,一把就把张大胖拖了出来,揪住张大胖的衣领吼道:“我真TMD想揍你一顿,我给你指了好几条光明大道你不走,为什么非要去挤那独木桥?!”

张大胖愣了一下:“师傅,我们俩似乎没啥关系吧...... 小心我那16寸的顶配MacBook Pro,很贵的......”

出租车师傅的脸色慢慢缓和,深深地叹了一口气:“唉,我可真傻!”

他松开了张大胖,回到车上继续开车。

现已经能够看到小区耸立的高楼。一栋栋楼盘,亮着灯的已经不多。

惊魂未定张大胖开始收拾东西,准备下车。

“小伙子,坚定地学习技术确实难能可贵,坚持下来必定有所收获,但是我想给你几个建议:

  1. 不能只盯着技术,还要搞定业务,让技术为业务服务,一定要产生业务价值。

  1. 你要想方设法地增加技术影响力。

  1. 除了技术之外,要再发展一个领域,形成交叉优势

  1. .....”

张大胖心想,这师傅真是啰嗦,他不客气地把师傅打断:“谢谢师傅,这些道理我在码农翻身公众号看了很多了,我会遵照执行的。”

“还有啊,要拥抱不确定性,多去尝试一些投入低,可能有巨大回报的事情,比如.....”

“多谢师傅关心!” 张大胖确实有点不耐烦了。

司机师傅送给张大胖一张名片:“以后可以联系我啊!”

张大胖看都没看,随手扔到了包里,付款下车。

身后传来了师傅的喊声:“CPU缓存这一块儿最常考的是LRU算法,面试的时候要手写......”

张大胖头也不回,快步回家,逃离了这个啰嗦的“唐僧”。

第二天晚上,四十大盗公司的面试,面试官果然如同出租车司机预料的那样,要手写LRU算法。

张大胖准备充分,顺利通过。

张大胖回到家,赶紧翻出那张名片,只见上面写着三个大字:张大胖。

还有一行小字:我是十年以后的你

张大胖大为震惊,他拼命地回想昨晚和出租车司机谈话的内容,却如同做了一场梦,什么都想不起来了......

来源:mp.weixin.qq.com/s/Y4xHuLfd7U4s4wpn1H3vWw

收起阅读 »

Vue PC前端扫码登录

需求描述目前大多数PC端应用都有配套的移动端APP,如微信,淘宝等,通过使用手机APP上的扫一扫功能去扫页面二维码图片进行登录,使得用户登录操作更方便,安全,快捷。思路解析PC 扫码原理?扫码登录功能涉及到网页端、服务器和手机端,三端之间交互大致步骤如下:网页...
继续阅读 »

需求描述

目前大多数PC端应用都有配套的移动端APP,如微信,淘宝等,通过使用手机APP上的扫一扫功能去扫页面二维码图片进行登录,使得用户登录操作更方便,安全,快捷。

思路解析

PC 扫码原理?

扫码登录功能涉及到网页端、服务器和手机端,三端之间交互大致步骤如下:

  1. 网页端展示二维码,同时不断的向服务端发送请求询问该二维码的状态;

  2. 手机端扫描二维码,读取二维码成功后,跳转至确认登录页,若用户确认登录,则服务器修改二维码状态,并返回用户登录信息;

  3. 网页端收到服务器端二维码状态改变,则跳转登录后页面;

  4. 若超过一定时间用户未操作,网页端二维码失效,需要重新刷新生成新的二维码。

前端功能实现

如何生成二维码图片?

  • 二维码内容是一段字符串,可以使用uuid 作为二维码的唯一标识;

  • 使用qrcode插件 import QRCode from 'qrcode'; 把uuid变为二维码展示给用户

import {v4 as uuidv4} from "uuid"
import QRCode from "qrcodejs2"
let timeStamp = new Date().getTime() // 生成时间戳,用于后台校验有效期
let uuid = uuidv4()
let content = `uid=${uid}&timeStamp=${timeStamp}`
this.$nextTick(()=> {
    const qrcode = new QRCode(this.$refs.qrcode, {
      text: content,
      width: 180,
      height: 180,
      colorDark: "#333333",
      colorlight: "#ffffff",
      correctLevel: QRCode.correctLevel.H,
      render: "canvas"
    })
    qrcode._el.title = ''

如何控制二维码的时效性?

使用前端计时器setInterval, 初始化有效时间effectiveTime, 倒计时失效后重新刷新二维码

export default {
 name: "qrCode",
 data() {
   return {
     codeStatus: 1, // 1- 未扫码 2-扫码通过 3-过期
     effectiveTime: 30, // 有效时间
     qrCodeTimer: null // 有效时长计时器
     uid: '',
     time: ''
  };
},

 methods: {
   // 轮询获取二维码状态
   getQcodeStatus() {
     if(!this.qsCodeTimer) {
       this.qrCodeTimer = setInterval(()=> {
         // 二维码过期
         if(this.effectiveTime <=0) {
           this.codeStatus = 3
           clearInterval(this.qsCodeTimer)
           this.qsCodeTimer = null
           return
        }
         this.effectiveTime--
      }, 1000)
    }

  },
 
   // 刷新二维码
   refreshCode() {
     this.codeStatus = 1
     this.effectiveTime = 30
     this.qsCodeTimer = null
     this.generateORCode()
  }
},

前端如何获取服务器二维码的状态?

前端向服务端发送二维码状态查询请求,通常使用轮询的方式

  • 定时轮询:间隔1s 或特定时段发送请求,通过调用setInterval(), clearInterval()来停止;

  • 长轮询:前端判断接收到的返回结果,若二维码仍未被扫描,则会继续发送查询请求,直至状态发生变化(失效或扫码成功)

  • Websocket:前端在生成二维码后,会与后端建立连接,一旦后端发现二维码状态变化,可直接通过建立的连接主动推送信息给前端。

使用长轮询实现:

 // 获取后台状态
   async checkQRcodeStatus() {
      const res = await checkQRcode({
        uid: this.uid,
        time: this.time
      })
      if(res && res.code == 200) {
        let codeStatus - res.codeStatus
        this.codeStatus =  codeStatus
        let loginData = res.loginData
        switch(codeStatus) {
          case 3:
             console.log("二维码过期")
             clearInterval(this.qsCodeTimer)
             this.qsCodeTimer = null
             this.effectiveTime = 0
           break;
           case 2:
             console.log("扫码通过")
             clearInterval(this.qsCodeTimer)
             this.qsCodeTimer = null
             this.$emit("login", loginData)
           break;
           case 1:
             console.log("未扫码")
             this.effectiveTime > 0  && this.checkQRcodeStatus()
           break;
           default:
           break;
        }
      }
  },

参考资料:

作者:前端碎碎念
来源:juejin.cn/post/7179821690686275621

收起阅读 »

Android FCM接入

消息推送在现在的App中已经十分常见,我们经常会收到不同App的各种消息。消息推送的实现,国内与海外发行的App需要考虑不同的方案。国内发行的App,常见的有可以聚合各手机厂商推送功能的极光、个推等,海外发行的App肯定是直接使用Firebase Cloud ...
继续阅读 »

消息推送在现在的App中已经十分常见,我们经常会收到不同App的各种消息。消息推送的实现,国内与海外发行的App需要考虑不同的方案。国内发行的App,常见的有可以聚合各手机厂商推送功能的极光、个推等,海外发行的App肯定是直接使用Firebase Cloud Message(FCM)。

下面介绍下如何接入FCM与发送通知。

发送通知

FCM的SDK不包含创建和发送通知的功能,这部分需要我们自己实现。

在 Android 13+ 上请求运行时通知权限

Android 13 引入了用于显示通知的新运行时权限。这会影响在 Android 13 或更高版本上运行的所有使用 FCM 通知的应用。需要动态申请POST_NOTIFICATIONS权限后才能推送通知,代码如下:

class ExampleActivity : AppCompatActivity() {

   private val requestPermissionCode = this.hashCode()

   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
           if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
               ActivityCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
               // 申请通知权限
               ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.POST_NOTIFICATIONS), requestPermissionCode)
          }
  }

   override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
       super.onRequestPermissionsResult(requestCode, permissions, grantResults)
           if (requestCode == requestPermissionCode) {
               // 处理回调结果
          }
  }
}

创建通知渠道

从 Android 8.0(API 级别 26)开始,必须为所有通知分配渠道,否则通知将不会显示。通过将通知归类到不同的渠道中,用户可以停用您应用的特定通知渠道(而非停用您的所有通知),还可以控制每个渠道的视觉和听觉选项。

创建通知渠道代码如下:

class ExampleActivity : AppCompatActivity() {

   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       val notificationManager = NotificationManagerCompat.from(this)
       if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
           val applicationInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
               packageManager.getApplicationInfo(packageName, PackageManager.ApplicationInfoFlags.of(0))
          } else {
               packageManager.getApplicationInfo(packageName, 0)
          }
           val appLabel = getText(applicationInfo.labelRes)
           val exampleNotificationChannel = NotificationChannel("example_notification_channel", "$appLabel Notification Channel", NotificationManager.IMPORTANCE_DEFAULT).apply {
               description = "The description of this notification channel"
          }
           notificationManager.createNotificationChannel(minigameChannel)
      }
  }
}

创建并发送通知

创建与发送通知,代码如下:

class ExampleActivity : AppCompatActivity() {

   private var notificationId = 0

   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate()
       val notificationManager = NotificationManagerCompat.from(this)
      ...
       if (notificationManager.areNotificationsEnabled()) {
           val notification = NotificationCompat.Builder(this, "example_notification_channel")
               //设置小图标
              .setSmallIcon(R.drawable.notification)
               // 设置通知标题
              .setContentTitle("title")
               // 设置通知内容
              .setContentText("content")
               // 设置是否自动取消
              .setAutoCancel(true)
               // 设置通知声音
              .setSound(RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION))
               // 设置点击的事件
              .setContentIntent(PendingIntent.getActivity(this, requestCode, packageManager.getLaunchIntentForPackage(packageName)?.apply { putExtra("routes", "From notification") }, PendingIntent.FLAG_IMMUTABLE))
              .build()
           // notificationId可以记录下来
           // 可以通过notificationId对通知进行相应的操作
           notificationManager.notify(notificationId, notification)
      }
  }
}

注意,smallIcon必须设置,否则会导致崩溃。***

FCM

Firebase Cloud Message (FCM) 是一种跨平台消息传递解决方案,可让您免费可靠地发送消息。

官方接入文档

集成FCM

在项目下的build.gradle中添加如下代码:

buildscript {

   repositories {
       google()
       mavenCentral()
  }

   dependencies {
      ...
       classpath("com.google.gms:google-services:4.3.14")
  }
}

在app module下的build.gradle中添加代码,如下:

dependencies {
   // 使用Firebase Andorid bom(官方推荐)
   implementation platform('com.google.firebase:firebase-bom:31.1.0')
   implementation 'com.google.firebase:firebase-messaging'
   
   // 不使用bom
   implementation 'com.google.firebase:firebase-messaging:23.1.1'
}

在Firebase后台获取项目的google-services.json文件,放到app目录下


要接收FCM的消息推送,需要自定义一个Service继承FirebaseMessagingService,如下:

class ExampleFCMService : FirebaseMessagingService() {

   override fun onNewToken(token: String) {
       super.onNewToken(token)
       // FCM生成的令牌,可以用于标识用户的身份
  }

   override fun onMessageReceived(message: RemoteMessage) {
       super.onMessageReceived(message)
       // 接收到推送消息时回调此方法
  }

在AndroidManifest中注册Service,如下:

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
   <application>
       <service
           android:name="com.minigame.fcmnotificationsdk.MinigameFCMService"
           android:exported="false">
           <intent-filter>
               <action android:name="com.google.firebase.MESSAGING_EVENT" />
           </intent-filter>
       </service>
   </application>
</manifest>

通知图标的样式

当App处于不活跃状态时,如果收到通知,FCM会使用默认的图标与颜色来展示通知,如果需要更改的话,可以在AndroidManifest中通过meta-data进行配置,代码如下:

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
   <application>
       <!--修改默认图标-->
       <meta-data
           android:name="com.google.firebase.messaging.default_notification_icon"
           android:resource="@drawable/notification" />

       <!--修改默认颜色-->
       <meta-data
           android:name="com.google.firebase.messaging.default_notification_color"
           android:resource="@color/color_blue_0083ff" />
   </application>
</manifest>

修改前:


修改后:


避免自动初始化

如果有特殊的需求,不希望FCM自动初始化,可以通过在AndroidManifest中配置meta-data来实现,代码如下:

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
   <application>
       <meta-data
           android:name="firebase_messaging_auto_init_enabled"
           android:value="false" />
       
       <!--如果同时引入了谷歌分析,需要配置此参数-->
       <meta-data
           android:name="firebase_analytics_collection_enabled"
           android:value="false" />
   </application>
</manifest>

需要重新启动FCM自动初始化时,更改FirebaseMessagingisAutoInitEnabled的属性,代码如下:

FirebaseMessaging.getInstance().isAutoInitEnabled = true
// 如果同时禁止了Google Analytics,需要配置如下代码
FirebaseAnalytics.getInstance(context).setAnalyticsCollectionEnabled(true)

调用此代码后,下次App启动时FCM会自动初始化。

测试消息推送

在Firebase后台中,选择Messageing,并点击制作首个宣传活动,如图:


选择Firebase 通知消息,如图:


输入标题和内容后,点击发送测试消息,如图:


输入在FirebaseMessagingService的onNewToken方法中获取到的token,并点击测试,如图:


示例

已整合到demo中。

ExampleDemo github

ExampleDemo gitee

效果如图:


作者:ChenYhong
来源:juejin.cn/post/7180616999695810597

收起阅读 »

环信web、uniapp、微信小程序sdk报错详解---注册篇(一)

项目场景:记录对接环信sdk时遇到的一系列问题,总结一下避免大家再次踩坑。这里主要针对于web、uniapp、微信小程序在对接环信sdk时遇到的问题。注册篇(一)在初始化完成之后,就卡在了第一步注册用户,注册用户居然报错401,上截图原因分析:从console...
继续阅读 »

项目场景:

记录对接环信sdk时遇到的一系列问题,总结一下避免大家再次踩坑。这里主要针对于web、uniapp、微信小程序在对接环信sdk时遇到的问题。

注册篇(一)


在初始化完成之后,就卡在了第一步注册用户,注册用户居然报错401,上截图




原因分析:

从console控制台输出及network请求返回入手分析

可以看到报错描述Open registration doesn't allow, so register user need token,也就是注册用户需要token,知道问题所在就比较好解决了






解决方案:

解决思路,文档描述

文档描述:若支持SDK注册,需登录环信即时通讯云控制台 (https://console.easemob.com/app/im-service/detail),选择即时通讯 > 服务概览,将 设置下的用户注册模式设置为开放注册。可见文档地址:http://docs-im-beta.easemob.com/document/web/overview.html#sdk-%E6%B3%A8%E5%86%8C




拓展:

上文提到的用户注册模式是什么

据了解,环信的用户注册模式分为两种,一种是授权注册,一种是开放注册,这两种注册模式在即时通讯>服务概览>设置>用户注册模式可以看到,但是这两种注册模式有什么区别呢?


以下是环信文档对于开放注册和授权注册的解释,文档地址:http://docs-im-beta.easemob.com/document/server-side/account_system.html#%E5%BC%80%E6%94%BE%E6%B3%A8%E5%86%8C%E5%8D%95%E4%B8%AA%E7%94%A8%E6%88%B7




通俗解释就是授权注册比开放注册增加了token认证,授权注册更安全,但是如果在端上启用授权注册会比较麻烦,还需要自己封装请求,我这边建议大家注册还是交给后端同事来搞吧~~~~



收起阅读 »

Flow 转 LiveData,数据丢了,肿么回事?

前言 最近我在负责一段代码库,需要在使用 Flow 的 Data 层和仍然依赖 LiveData 暴露 State 数据的 UI 层之间实现桥接。好在 androidx.lifecycle 框架已经提供了一个叫做 asLiveData() 的方法,可以让你毫不...
继续阅读 »

前言


最近我在负责一段代码库,需要在使用 Flow 的 Data 层和仍然依赖 LiveData 暴露 State 数据的 UI 层之间实现桥接。好在 androidx.lifecycle 框架已经提供了一个叫做 asLiveData() 的方法,可以让你毫不费力地将 Flow 转为 LiveData


然而使用这种方式得到的 LiveData 需要牢记一点:在拥有一个及以上活跃的观察者的条件下,它才会发射数据。假使上游的 flow 产生了更新,但对应的 LiveData 并非活跃的状态,那么它将无法获得最新的数值。


让我通过如下的实例,向你展示我们可能会遇到的这种潜在问题。


示例


我们有一个简单的 Activity,它持有 AAC ViewModel 的实例:


 class MainActivity : AppCompatActivity() {  
     private val viewModel: MainViewModel by viewModels()  
   
     override fun onCreate(savedInstanceState: Bundle?) {  
         super.onCreate(savedInstanceState)  
         setContentView(R.layout.activity_main)    
    }  
 }

ViewModel 的实现是这样的:


 class MainViewModel : ViewModel() {  
     private val repository = Repository()  
   
     val state: LiveData<Int> = repository.state.asLiveData()  
 }

它持有一个 Repository 实例,充当琐碎的数据层。


同时 ViewModel 还通过前面提到的 asLiveData() 方法,将 Repository 持有的 StateFlow 转为了 LiveData 并对外暴露了其 State 数据。


Repository 的实现如下:


 class Repository {  
     private val _state = MutableStateFlow(-1)  
     val state: StateFlow<Int> = _state  
   
     suspend fun update() {  
         _state.emit(Random.nextInt(until = 1000))  
    }  
 }

它拥有一个包裹着 Integer 数据(初始值为 -1)的 StateFlow 示例,同时对外提供了一个方法允许外界更新它的 State:从 0 到 1000 之间取得一个新的随机数。


试想一下,假使希望 Activity 创建的时候就能执行这个数据更新。我们可以这么实现:



  1. MainViewModel 内创建一个 init() 来做这个操作

  2. Activity 的onCreate() 里调用该方法


 // MainViewModel
 fun init() {
     // update() is suspending, so we launch a new coroutine here
     viewModelScope.launch {  
         repository.update()
    }  
 }
 
 // MainActivity
 override fun onCreate(savedInstanceState: Bundle?) {  
     super.onCreate(savedInstanceState)  
     setContentView(R.layout.activity_main)  
   
     viewModel.init()
 }

这样的话,Activity 创建的时候一个新的协程将被启动,最终会调用 Repository 的 update() ,生成一个随机数并发射到它的 State。


此外,我们可能还需要在 ViewModel 中去发送包含了新生成数值的事件出去。可以在 ViewModel 中添加一个sendAnalyticalEvent() ,这样可以在执行完 Repository 的 update() 之后立即调用它。


 // MainViewModel
 fun init() {  
     viewModelScope.launch {  
         repository.update()  
         sendAnalyticalEvent() // <-- NEW
    }  
 }  
   
 private fun sendAnalyticalEvent() {  
     // Typically, we would schedule a network request here  
   
     val liveDataValue = state.value  
     val flowValue = repository.state.value  
     Log.d("Current number in LiveData", "$liveDataValue")  
     Log.d("Current number in StateFlow", "$flowValue")  
 }

该方法内,我们可以做些典型的操作,比如向后端服务器发送网络请求。这里,让我们仅仅在 Logcat 里打印来自 LiveData and Flow 的数值即可。


1.png


上面的运行结果相当出乎意料。你可能会争辩道:LiveData 没有获取到最新的数值,是因为没有足够的时间从上游的 flow 中收集数据,不然的话肯定能够拿到正确的数值。


但这个 case 里,不仅仅是 LiveData 获得到的是错误的数值,它获得到的是 null。而且请别忘了,它的存放在 Repository 里的初值是 -1。这只能代表一个意思:这里的 LiveData 压根没有从 StateFlow 里收集任何数据。


原因是我们还没有开始观察这个 LiveData,它自然会被当作是非活跃的。而且根据 asLiveData() 方法的文档可以知道,在这种情况下 LiveData 不会从上游的 flow 收集任何数据。



asLiveData:Creates a LiveData that has values collected from the origin Flow.




上游 flow 数据的收集发生在 LiveData 变成活跃的时候,即 LiveData.onActive。如果 flow 尚未完成,而 LiveData 变成了非激活状态,即 LiveData.onActive,那么 flow 的数据收集将在timeoutInMs 参数指定的时间后被取消。除非在超时之前,LiveData 变成活跃状态。



一旦我们开始在 Activity 里观察 LiveData 的数据(因此将促使 LiveData 变成活跃状态),它就能够拥有正确的、最新的数值了。


 // MainActivity
 override fun onCreate(savedInstanceState: Bundle?) {  
     super.onCreate(savedInstanceState)  
     setContentView(R.layout.activity_main)  
   
     viewModel.init()  
     viewModel.state.observe(this) { // <-- NEW  
         Log.d("Current number in MainActivity", "$it")  
    }  
 }

如下是 Logcat 里新的输出。


2.png


上面的示例里,我们采用的是 StateFlow,但规则同样适用于 SharedFlow


而且,情况将更加糟糕,因为当 LiveData 处于非激活状态的时候,任何发送给 SharedFlow 的事件都将永久丢失(默认情况下 SharedFlow 不会将任何数值重新发送给新的订阅者)。


总结


请时刻记住采用 asLiveData() 方法转换 Flow 得到的 LiveData 将会和预期的稍稍不同:它只会在注册了活跃观察者的情况下发射数据


就我个人而言,这种行为无可厚非:因为我们都还没有观察它、自然不会在意 LiveData 的数值是啥、能不能获取得到。但话说回来,确实存在一些场景,需要在你尚未开始观察的时候,去访问 ViewModelLiveData 的当前数值。


通过阅读这篇文章,我希望你在遇到这种获取不到正确数值的情况时,不要惊讶、心中有数。


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

Compose跨平台第一弹:体验Compose for Desktop

前言 Compose是Android官方提供的声明式UI开发框架,而Compose Multiplatform是由JetBrains 维护的,对于Android开发来说,个人认为学习Jetpack Compose是必须的,因为它会成为Android主流的开发模...
继续阅读 »

前言


Compose是Android官方提供的声明式UI开发框架,而Compose Multiplatform是由JetBrains 维护的,对于Android开发来说,个人认为学习Jetpack Compose是必须的,因为它会成为Android主流的开发模式,而compose-jb作为一个扩展能力,我们可以有选择的去尝试。今天我们先来了解一下使用compose-jb开发一个桌面端应用的流程。


接下来还会有第二弹,第三弹...


环境要求


开发Compose for Desktop环境要求主要有两点:




  • JDK 11或更高版本




  • IntelliJ IDEA 2020.3 或更高版本(也可以使用AS,这里为了使用IDEA提供的项目模板)




接着我们来一步步体验Compose for Desktop的开发流程。


开发流程


创建项目


下载好IDEA后,我们直接新建项目,选择Compose Multipalteform类型,输入项目名称,这里只选择Single platform且平台为Desktop即可。



创建好项目后,来看项目目录结构,目录结构如下图所示。



在配置文件中指定了程序入口为MainKt以及包名、版本号等。MainKt文件代码如下所示。


@Composable
@Preview
fun App() {
var text by remember { mutableStateOf("Hello, World!") }

MaterialTheme {
Button(onClick = {
text = "Hello, Desktop!"
}) {
Text(text)
}
}
}

fun main() = application {
Window(onCloseRequest = ::exitApplication) {
App()
}
}

在MainKt文件中,入口处调用了App()方法,App方法中绘制了一个按钮,运行程序,结果如下图所示。



我们可以看到一个Hello World的桌面端程序就显示出来了。接下来我们来添加一些页面元素。


添加输入框


为了让桌面端程序更“像样子”,我们首先修改桌面程序的标题为“学生管理系统”,这毕竟是我们学生时代最喜欢的名字。代码如下所示:


fun main() = application {
Window(onCloseRequest = ::exitApplication, title = "学生管理系统") {
App()
}
}

在App方法中,添加两个输入框分别为学号、密码,添加一个登陆按钮,写法与Android中的Compose一致,代码如下所示。


MaterialTheme {
var name by remember {
mutableStateOf("")
}
var password by remember {
mutableStateOf("")
}
Column {
TextField(name, onValueChange = {
name = it
}, placeholder = {
Text("请输入学号")
})
TextField(password, onValueChange = {
password = it
}, placeholder = {
Text("请输入密码")
})
Button(onClick = {

}) {
Text("登陆")
}
}

}

再次运行程序,页面如下所示。



添加头像


接着我们再来添加头像显示,我们将下载好的图片资源放在resources目录下



然后使用Image组件将头像显示出来即可,代码如下所示。


Image(
painter = painterResource("photo.png"),
contentDescription = null,
modifier = Modifier.size(width = 100.dp, height = 100.dp)
.clip(CircleShape)
)

再次运行程序,结果如下所示。



当然我们还可以将布局稍微修饰一下,使得布局看起来更好看一些。但这并不是这里的重点。


添加退出弹窗


当我们点击左上角(macOS)的X号时,应用程序就直接退出了,这是因为在Window函数中指定了退出事件,再来看一下这部分代码,如下所示。


fun main() = application {
Window(onCloseRequest = ::exitApplication, title = "学生管理系统") {
App()
}
}

接下来我们增加一个确认退出的弹窗提醒。代码如下所示。


fun main() = application {

var windowsOpen by remember {
mutableStateOf(true)
}
var isClose by remember {
mutableStateOf(false)
}
if (windowsOpen) {
Window(onCloseRequest = { isClose = true }, title = "学生管理系统") {
App()
if (isClose) {
Dialog(onCloseRequest = { isClose = false }, title = "确定退出应用程序吗?") {
Row {
Button(onClick = {
windowsOpen = false
}) {
Text("确定")
}
}
}
}
}
}

}

这里我们新增了两个变量windowsOpen、isClose分别用来控制应用程序的Window是否显示与确认弹窗的显示。这部分代码相信使用过Jetpack Compose的都可以看得懂。


运行程序,点击X号,弹出退出确认弹窗,点击确定,应用程序将退出。效果如下图所示。



实现一个网络请求功能


KMM入门 中我们借用「wanandroid」中「每日一问」接口实现了一个网络请求,现在我们将这部分功能移植到Desktop程序中,网络请求框架仍然使用Ktor,当然其实你也可以使用Retrofit,这一点并不重要。


首先添加Ktor的依赖,代码如下所示。


val jvmMain by getting {
dependencies {
implementation(compose.desktop.currentOs)
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4")
val ktorVersion = "2.1.2"
implementation("io.ktor:ktor-client-core:$ktorVersion")
implementation("io.ktor:ktor-client-content-negotiation:$ktorVersion")
implementation("io.ktor:ktor-serialization-kotlinx-json:$ktorVersion")
implementation("io.ktor:ktor-client-android:$ktorVersion")
}
}

添加一个Api接口


object Api {
val dataApi = "https://wanandroid.com/wenda/list/1/json"
}

创建HttpUtil类,用于创建HttpClient对象和获取数据的方法,代码如下所示。


import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.json.Json

class HttpUtil {
private val httpClient = HttpClient {
install(ContentNegotiation) {
json(Json {
prettyPrint = true
isLenient = true
ignoreUnknownKeys = true
})
}
}

/**
* 获取数据
*/
suspend fun getData(): String {
val rockets: DemoReqData =
httpClient.get(Api.dataApi).body()
return "${rockets.data} "
}
}

DemoReqData是接口返回数据对应映射的实体类,这里就不再给出了。


然后我们编写UI,点击按钮开始网络请求,代码如下所示。


Column() {
val scope = rememberCoroutineScope()
var demoReqData by remember { mutableStateOf(DemoReqData()) }
Button(onClick = {
scope.launch {
try {
demoReqData = HttpUtil().getData()
} catch (e: Exception) {
}
}
}) {
Text(text = "请求数据")
}

LazyColumn {
repeat(demoReqData.data?.datas?.size ?: 0) {
item {
Message(demoReqData.data?.datas?.get(it))
}
}
}
}

获取数据后,通过


Message方法


将数据展示出来,这里只将作者与标题内容显示出来,代码如下所示。


@Composable
fun Message(data: DemoReqData.DataBean.DatasBean?) {
Card(
modifier = Modifier
.background(Color.White)
.padding(10.dp)
.fillMaxWidth(), elevation = 10.dp
) {
Column(modifier = Modifier.padding(10.dp)) {
Text(
text = "作者:${data?.author}"
)
Text(text = "${data?.title}")
}
}
}

运行程序,点击“请求数据”,结果如下图所示。



这样我们就实现了一个简单的桌面端数据请求与显示功能。


写在最后


当然,在Compose For Desktop中还有许多的组件,比如Tooltips、Context Menu等等,这里无法一一介绍,需要我们在使用的时候去实践,我们将在后面的N弹中持续探索...


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

实测分析Const在Flutter中的性能表现

在实际的Flutter开发中,可以发现编辑器AS会提示在组件之前加上const关键字, 这是因为Flutter2之后,多了一个linter规则,prefer_const_constructors,官方建议首选使用const来实例化常量构造函数。 那cons...
继续阅读 »

在实际的Flutter开发中,可以发现编辑器AS会提示在组件之前加上const关键字,



这是因为Flutter2之后,多了一个linter规则,prefer_const_constructors,官方建议首选使用const来实例化常量构造函数。



那const作用是什么?并且在性能方面对整个app有多大的提升?


一、Const的作用


const 是 constant 的缩写,本意是不变的,不易改变的意思,包括C++、go中都有此关键字,同样的,在Flutter中也是表示不变的意思。具体来看看下面的代码。


Row(
children: [
Image(image: NetworkImage('https://flutter.github.io/assets-for-api-docs/assets/widgets/owl.jpg')),
Text("$_counter")
],
);

这是一个水平布局,内部排列了一个Image和Text,注意这个Text的是有一个动态的值_counter。


为了能够更新_counter,必然要调用setState() 方法。我们都知道,如果调用setState() ,那么整个Row包括Image和Text都会自动递归重建。每调用一次,父widget和子widget都会重建一次,那么在复杂的UI和业务场景下,就加深了app的不稳定性。


这就是为什么在开发中,要尽量在小的范围去使用setState,避免不必要的重建任务。为了优化这个问题,官方就更新出了const关键字,被const修饰的widget,就代表永远不会被重建。


比如在上述代码中Image是不可变的,Text是可变的,那么在Image之间加上const修饰,当调用setState() 时,只会更新Text,Image不会被重新构建。


Row(
children: [
const Image(image: NetworkImage('https://flutter.github.io/assets-for-api-docs/assets/widgets/owl.jpg')),
Text("$_counter")
],
);

二、性能分析


2.1 widget rebuild状态


DevTools提供了一个查询widget rebuild状态的工具,在 Widget rebuild stats 中勾选 Track widget rebuilds 来查看 widget 的重建信息。重建信息包括 Widget 名字、源码位置、上一帧中重建次数、当前页面中重建次数。



在每个widget之前都有一个小图标,



  • 黄色旋转圆圈 - 重建次数过多

  • 灰色圆圈 - 未重建

  • 灰色旋转圆圈 - 重建


为了进行const对比,我们以上面代码为例,


Row(
children: [
const Image(image: NetworkImage('https://flutter.github.io/assets-for-api-docs/assets/widgets/owl.jpg')),
Text("$_counter")
],
);

在Image前加上const,Text则不加,当调用setState时,观察两个widget的情况。



清楚的发现,没加const的Image widget前面的圆圈在旋转,则表示Image在重建,且重建次数+1。


2.2 内存占用


关于内存,DevTool同样提供了内存分析工具Memory,接下来结合案例进行分析。


在项目中新建两个类,内部不做额外的动作,


void _buildConstObject(){
const ConstObject();
}

void _buildConstObjectNot(){
ConstObjectNot();
}

其中ConstObject 加上const修饰,ConstObjectNot则不进行修饰,在触发build时,两个对象同时进行1000次的创建,


void _doBuild(){
for(var i = 0; i< 1000;i++){
_buildConstObject();
_buildConstObjectNot();
}
}

打开内存分析工具,可以发现未加Const修饰的ConstObjectNot创建了1000个对象,所占用内存约16k,而加了const的ConstObject则可以忽略不计。


注意这里ConstObjectNot和ConstObject内部是没有做任何widget创建的,如果在实际复杂的项目中,未使用const,内存将成倍增加。



2.3 流畅性


在DevTool中打开performance overlay, 在app顶部就会出现性能图层,这两张图表显示的是应用的耗时信息。如果 UI 产生了卡顿(跳帧),这些图表可以帮助分析应用中卡顿,每一张图表都代表当前线程的最近 300 帧表现。



如上图,第一张图属于raster 线程的性能情况即GPU性能,第二张图显示的UI线程性能表现。

当中垂直的绿色条条代表的是当前帧。每一帧都应该在 1/60 秒(大约 16 ms)内创建并显示。如果有一帧超时(任意图像)而无法显示,就导致了卡顿,图表之一就会展示出来一个红色竖条。如果是在 UI 图表出现了红色竖条,则表明 Dart 代码消耗了大量资源。而如果红色竖条是在 GPU 图表出现的,意味着场景太复杂导致无法快速渲染。


为了验证流畅性,我们开启了一个动画,动画在规定时间内进行重复性的放大缩小动作,且分为两个场景,一个场景是在所有widget以及对象前加上const修饰,另外一个场景则什么都不做,对比查看每帧的耗时。


class AnLogo extends AnimatedWidget {
static final _opacityTween = Tween<double>(begin: 0.1, end: 1.0);
static final _sizeTween = Tween<double>(begin: 0.0, end: 300.0);

const AnLogo({Key? key, required Animation<double> animation})
: super(key: key, listenable: animation);

@override
Widget build(BuildContext context) {
Animation<double> animation1 = listenable as Animation<double>;
return Scaffold(
appBar: AppBar(
title: const Text("动画"),
),
body: Center(
child: Opacity(
opacity: _opacityTween.evaluate(animation1),
child: Container(
margin: const EdgeInsets.symmetric(vertical: 10.0),
height: _sizeTween.evaluate(animation1),
width: _sizeTween.evaluate(animation1),
child: Image.asset("images/ic_1.jpeg"),
),
),
),
);
}
}














no constconst
constnot.gifconst.gif














no constconst

GPU帧率:



















GPU
no const平均最大耗时/帧9.9ms/frame
const平均最大耗时/帧7.6ms/frame

UI线程帧率:



















UI线程
no const平均最大耗时/帧7.8ms/frame
const平均最大耗时/帧7.1ms/frame

从实验结果上看,没有加const的GPU帧率平均最大达到9.9ms/帧,而加了const的GPU帧率比之降低了约2.3ms;UI帧率(CPU)加const与不加const相差不大,约0.7ms。


三、总结


从上面的测试看,不管是内存占用还是流畅性,添加const修饰的性能都是优于未添加const修饰的性能,const减少了组件的重建以及对象的创建,进行flutter开发时,在合适的时机去使用const以减少不必要的开销。


推荐阅读:


Flutter实战项目开源


Flutter: ' const '构造函数的性能分析


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

这年会奖品还能不要脸点吗?

又到年底年会的时候了,有网友晒出自己的年会奖品:网友表示:一溜看下来,迟到券比较实用。想不明白为什么没有涨薪优惠卷!迟到券还要提前申请?我提前知道我要迟到不会早点出门?迟到卷还不如早退卷实用好歹有啊这个经济现状,能按时发工资的就是好公司了一百万越南盾也行啊,至...
继续阅读 »

又到年底年会的时候了,有网友晒出自己的年会奖品:







网友表示:

  • 一溜看下来,迟到券比较实用。

  • 想不明白为什么没有涨薪优惠卷!

  • 迟到券还要提前申请?我提前知道我要迟到不会早点出门?

  • 迟到卷还不如早退卷实用

  • 好歹有啊

  • 这个经济现状,能按时发工资的就是好公司了

  • 一百万越南盾也行啊,至少看着多


素材来源于网络
收起阅读 »

Android开发中那些与代码无关的技巧

1.如何找到代码作为客户端的开发,工作中经常遇到,后端的同事来帮忙找接口详情。产品经理来询问之前的某些功能的业务逻辑,而这些代码或者逻辑都是前人遗留下来的……没有人知道在哪。那如何快速的找到你想找到的代码位置呢?(1)无敌搜索大法双击shift键,页面上有什么...
继续阅读 »
1.如何找到代码

作为客户端的开发,工作中经常遇到,后端的同事来帮忙找接口详情。产品经理来询问之前的某些功能的业务逻辑,而这些代码或者逻辑都是前人遗留下来的……没有人知道在哪。那如何快速的找到你想找到的代码位置呢?

(1)无敌搜索大法

双击shift键,页面上有什么就在代码中全局搜索什么,比如标题,按钮名字~找到资源文件布局文件,再进一步搜索用到这些文件的代码位置。

(2)log输出大法

在不方便debug的时候,可以输出一些log,通过查看log的输出,可以明确的看出程序运行时的运行逻辑和变量值。

(3)profiler查看大法

我们要善于利用AndroidStudio提供的工具,比如profiler。在profiler中可以看到手机中正在运行的Activity的名字,甚至能看到网络请求的详情等等,功能很强大!

(4)万能法找到页面

在你的Application中注册一个Activity的生命周期监听,

ActivityLifeCycle lifecycleCallbacks = new Application.ActivityLifecycleCallbacks();
registerActivityLifecycleCallbacks(lifecycleCallbacks);

在进入到页面的时候,直接输出页面路径~

@Override
public void onActivityCreated(Activity activity, Bundle bundle) {
   Log.e(TAG, "onActivityCreated :" + getActivityName(activity));
}
2.如何解决bug

这里讨论的是那些第一时间没有思路不知道如何解决的bug。这些bug有的是因为开发过程中粗心写错变量名,变量值,使用了错误的方法,少执行了方法,之前修改bug时某些地方被遗漏了,或者不小心把不应该改动的地方做了改动。也可能是因为使用的第三方库存在缺陷,也可能是数据问题,接口返回的数据不正确,用户做了意料之外的操作没有被程序正确处理等等。

解决棘手的bug之前,首先要稳定自己的心态。记住,心态很重要。无论这个bug已经造成了线上多么大的影响,你的boss多么着急的催着你解决bug,要有一个平稳的心态才能解决问题,否者,慌慌忙忙紧紧张张的状态下去解决bug,很可能会造成更多的bug!

(1)先看再想最后动手

解决bug的第一步,当然是稳定的复现bug。根据我的经验,如果一个bug可以被稳定的复现,至少它就被解决了70%。

通过观察bug的现象,就可以对bug做个大致的归类或者定位了。是因为数据问题?还是第三方库的问题?还或者是代码的问题?

接着就是debug,看日志等常规操作了~

如果经过上面的操作,你还是一筹莫展,那么请往下看。

(2)改变现状

如果你真的是一点思路也没有,很可能某些可能造成bug的代码也看不太懂。我建议你做一些改变现状的操作,比如:注掉某些代码,尝试其他的输入数据或者操作。总而言之,就是让bug的现象出现改变! 那么你做的这些操作肯定是对这个bug是有影响的!!!然后再逐步恢复之前注掉的代码,直到恢复某些注掉代码之后,bug的现象恢复了。很有可能这里就是造成bug的位置。bug定位了之后,再去思考解决办法。

(3)是技术问题还是业务问题

在实际的开发过程中,很多问题是通过技术手段解决不了的。可能是业务逻辑就出现了矛盾,也有可能是是因为一些奇奇怪怪的王八的屁股。这类问题要早点发现,早点提出,才能早点解决。有些可能踩红线的问题,作为开发,不要试图通过技术去解决!!!否则可能要去踩缝纫机了~~~

(4)张张嘴远胜于动动手

我一直坚信,世界上有更多能力比我强的人。我现在面对的bug也肯定不是只有我面对了。张张嘴问问周围的同事,问问网站上的大神,现在网络这么发达,只要别人解决过的问题,就不是问题。

很多时候的bug可能只是因为你对某些领域不熟悉,去请教那些对这个领域熟悉的人,你的问题对他们来说可能不是问题。

(5)bug解决不了,那就解决提出bug的人

有的时候的bug可能不是bug。提出bug的人可能只是对某些操作或者现象不理解,或者没有达到他们的预期。他们就会提出来,他们觉得现在的程序是有问题的……这个时候可以去尝试解决这个提出bug的人!让他们觉得这不是一个bug。当然你没有这种“解决人”的能力的话,就还是老老实实去解决bug吧~

(6)解决了bug之后

人的成长在于,遇到了问题,敢于直面问题,解决问题,并让自己今后避免再出现类似的问题!

解决了bug,无论这个bug是自己造成的还是别人造成的。要善于总结,避免日后自己再写出类似的问题。

3.如何实现不会的功能
(1)不要急着拒绝

遇到如何实现不会的功能,内心首先不要着急抗拒。

人总要成长,开发的技能如何成长?总不是像流水线工人那样做些一些“熟练”操作吧?总要走出自己的舒适圈,尝试解决一些问题,突破自己的上限吧~

你要知道,在Android开发这个领域,其实没有什么逾越不了技术壁垒!只要别人家有的,你就可能有!别人家做出来的东西,你就能做出来。这种信心,至少要有的~

(2)大事化小小事化了

一个复杂的功能,通常可以分解成一些简单功能,简单的功能就可以攻克!

那么当你在面对要实现一个复杂功能或者没有接触过的功能开发的时候,你所要做的其实就是分解这个功能,然后处理分解后的小功能,最后再把这些小功能组合回去!

心态要稳,天塌了有个高的顶着

遇到问题,尝试解决,实在不行,就要及时向上级反馈。作为你的上级,他们有责任也有能力帮你解决问题,或者至少给你提供解决问题的一种思路。心态要稳,天塌了有个高的顶着。

工作不是生活的全部,工作只是为了更好的生活!不要让那些无聊的代码影响你的心情影响你的生活!

作者:我是绿色大米呀
来源:juejin.cn/post/7182379138752675898

收起阅读 »

大白话DDD(DDD黑话终结者)

一、吐槽的话相信听过DDD的人有很大一部分都不知道这玩意具体是干嘛的,甚至觉得它有那么一些虚无缥缈。原因之一是但凡讲DDD的,都是一堆特别高大上的概念,然后冠之以一堆让人看不懂的解释,。作者曾经在极客时间上买了本DDD实战的电子书,被那些概念一路从头灌到尾,灌...
继续阅读 »

一、吐槽的话

相信听过DDD的人有很大一部分都不知道这玩意具体是干嘛的,甚至觉得它有那么一些虚无缥缈。原因之一是但凡讲DDD的,都是一堆特别高大上的概念,然后冠之以一堆让人看不懂的解释,。作者曾经在极客时间上买了本DDD实战的电子书,被那些概念一路从头灌到尾,灌得作者头昏脑涨,一本电子书那么多文章愣是没有一点点像样的案例,看到最后也 没明白那本电子书的作者究竟想写啥。原因之二是DDD经常出现在互联网黑话中,如果不能稍微了解一下DDD中的名词,我们一般的程序员甚至都不配和那些说这些黑话的人一起共事。

为了帮助大家更好的理解这种虚无缥缈的概念,也为了更好的减少大家在新词频出的IT行业工作的痛苦,作者尝试用人话来解释下DDD,并且最后会举DDD在不同层面上使用的例子,来帮助大家彻底理解这个所谓的“高大上”的概念。

二、核心概念

核心的概念还是必须列的,否则你都不知道DDD的名词有多么恶心,但我会用让你能听懂的话来解释。

1、领域/子域/核心域/支撑域/通用域

领域

DDD中最重要的一个概念,也是黑话中说的最多的,领域指的是特定的业务问题领域,是专门用来确定业务的边界。

子域

有时候一个业务领域可能比较复杂,因此会被分为多个子域,子域分为了如下几种:

  • 核心子域:业务成功的核心竞争力。用人话来说,就是领域中最重要的子域,如果没有它其他的都不成立,比如用户服务这个领域中的用户子域

  • 通用子域:不是核心,但被整个业务系统所使用。在领域这个层面中,这里指的是通用能力,比如通用工具,通用的数据字典、枚举这类(感叹DDD简直恨不得无孔不入)。在整个业务系统这个更高层面上,也会有通用域的存在,指的通用的服务(用户服务、权限服务这类公共服务可以作为通用域)。

  • 支撑子域:不是核心,不被整个系统使用,完成业务的必要能力。

2、通用语言/限界上下文

通用语言

指的是一个领域内,同一个名词必须是同一个意思,即统一交流的术语。比如我们在搞用户中心的时候,用户统一指的就是系统用户,而不能用其他名词来表达,目的是提高沟通的效率以及增加设计的可读性

限界上下文

限界上下文指的是领域的边界,通常来说,在比较高的业务层面上,一个限界上下文之内即一个领域。这里用一张不太好看的图来解释:


3、事件风暴/头脑风暴/领域事件

事件风暴

指的是领域内的业务事件,比如用户中心中,新增用户,授权,用户修改密码等业务事件。

头脑风暴

用最俗的人话解释,就是一堆人坐在一个小会议室中开会,去梳理业务系统都有哪些业务事件。

领域事件

领域内,子域和子域之间交互的事件,如用户服务中用户和角色交互是为用户分配角色,或者是为角色批量绑定用户,这里的领域事件有两个,一个是“为用户分配角色”,另一个是“为角色批量绑定用户”。

4、实体/值对象

实体

这里可以理解为有着唯一标识符的东西,比如用户实体。

值对象

实体的具体化,比如用户实体中的张三和李四。

实体和值对象可以简单的理解成java中类和对象,只不过这里通常需要对应数据实体。

5、聚合/聚合根

聚合

实体和实体之间需要共同协作来让业务运转,比如我们的授权就是给用户分配一个角色,这里涉及到了用户和角色两个实体,这个聚合即是用户和角色的关系。

聚合根

聚合根是聚合的管理者,即一个聚合中必定是有个聚合根的,通常它也是对外的接口。比如说,在给用户分配角色这个事件中涉及两个实体分别是用户和角色,这时候用户就是聚合根。而当这个业务变成给角色批量绑定用户的时候,聚合根就变成了角色。即使没有这样一个名词,我们也会有这样一个标准,让业务按照既定规则来运行,举个上文中的例子,给用户A绑定角色1,用户为聚合根,这样往后去查看用户拥有的角色,也是以用户的唯一标识来查,即访问聚合必须通过聚合根来访问,这个也就是聚合根的作用。

三、用途及案例

目前DDD的应用主要是在战略阶段和战术阶段,这两个名词也是非常的不讲人话,所谓的战略阶段,其实就是前期去规划业务如何拆分服务,服务之间如何交互。战术阶段,就是工程上的应用,用工程化做的比较好的java语言举例子,就是把传统的三层架构变成了四层架构甚至是N层架构而已。

1、微服务的服务领域划分

这是对于DDD在战略阶段做的事情:假如目前我司有个客服系统,内部的客服人员使用这个系统对外上亿的用户提供了形形色色的服务,同时内部人员觉得我们的客服系统也非常好用,老板觉得我们的系统做的非常好,可以拿出去对外售卖以提高公司的利润,那么这时候问题就来了,客服系统需要怎样去改造,才能够支持对外售卖呢?经过激烈的讨论,大致需求如下:

  • 对外售卖的形式有两种,分别是SaaS模式和私有化部署的模式。

  • SaaS模式需要新开发较为复杂的基础设施来支持,比如租户管理,用户管理,基于用户购买的权限系统,能够根据购买情况来给予不同租户不同的权限。而私有化的时候,由于客户是打包购买,这时候权限系统就不需要再根据用户购买来判断。

  • 数据同步能力,很多公司原本已经有一套员工管理系统,通常是HR系统或者是ERP,这时候客服系统也有一套员工管理,需要把公司人员一个一个录入进去,非常麻烦,因此需要和公司原有的数据来进行同步。

  • 老板的野心还比较大,希望造出来的这套基础设施可以为公司其他业务系统赋能,能支持其他业务系统对外售卖

在经过比较细致的梳理(DDD管这个叫事件风暴/头脑风暴)之后,我们整理出了主要的业务事件,大致如下:

1、用户可以自行注册租户,也可以由运营在后台为用户开通租户,每个租户内默认有一个超级管理员,租户开通之后默认有系统一个月的试用期,试用期超级管理员即可在管理端进行用户管理,添加子用户,分配一些基本权限,同时子用户可以使用系统的一些基本功能。

2、高级的功能,比如客服中的机器人功能是属于要花钱买的,试用期不具备此权限,用户必须出钱购买。每次购买之后会生成购买订单,订单对应的商品即为高级功能包。

3、权限系统需要能够根据租户购买的功能以及用户拥有的角色来鉴权,如果是私有化,由于客户此时购买的是完整系统,所以此时权限系统仅仅根据用户角色来鉴权即可。

4、基础设施还需要对其他业务系统赋能。

根据上面的业务流程,我们梳理出了下图中的实体


最后再根据实体和实体之间的交互,划分出了用户中心服务以及计费服务,这两个服务是两个通用能力服务,然后又划分出了基于通用服务的业务层,分别是租户管理端和运营后台以及提供给业务接入的应用中心,架构图如下:


基础设施层即为我们要做的东西,为业务应用层提供通用的用户权限能力、以及售卖的能力,同时构建开发者中心、租户控制台以及运营后台三个基础设施应用。

2、工程层面

这个是对于DDD在战术设计阶段的运用,以java项目来举例子,现在的搞微服务的,都是把工程分为了主要的三层,即控制层->逻辑层->数据层,但是到了DDD这里,则是多了一层,变成了控制层->逻辑层->领域能力层->数据层。这里一层一层来解释下:

分层描述
控制层对外暴漏的接口层,举个例子,java工程的controller
逻辑层主要的业务逻辑层
领域能力层模型层,系统的核心,负责表达业务概念,业务状态信息以及业务规则。即包含了该领域(问题域)所有复杂的业务知识抽象和规则定义。
数据层操作数据,java中主要是dao层

四、总结

在解释完了各种概念以及举例子之后,我们对DDD是什么有了个大概的认知,相信也是有非常多的争议。作者搞微服务已经搞了多年,也曾经在梳理业务的时候被DDD的各种黑话毒打过,也使用过DDD搞过工程。经历了这么多这方面的实践之后觉得DDD最大的价值其实还是在梳理业务的时候划分清楚业务领域的边界,其核心思想其实还是高内聚低耦合而已。至于工程方面,现在微服务的粒度已经足够细,完全没必要再多这么一层。这多出来的这一层,多少有种没事找事的感觉。更可笑的是,这个概念本身在对外普及自己的东西的时候,玩足了文字游戏,让大家学的一头雾水。真正好的东西,是能够解决问题,并且能够很容易的让人学明白,而不是一昧的造新词去迷惑人,也希望以后互联网行业多一些实干,少说一些黑话。

作者:李少博
来源:juejin.cn/post/7184800180984610873

收起阅读 »

RxJava加Retrofit文件分段上传

前言   本文基于 RxJava 和 Retrofit 库,设计并实现了一种用于大文件分块上传的工具,并对其进行了全面的拆解分析。抛砖引玉,对同样有处理文件分块上传诉求的读者,可能会起到一定的启发作用。 文章主体由四部分构成: 首先分析问题,问题拆解为:多线...
继续阅读 »

前言


  本文基于 RxJava 和 Retrofit 库,设计并实现了一种用于大文件分块上传的工具,并对其进行了全面的拆解分析。抛砖引玉,对同样有处理文件分块上传诉求的读者,可能会起到一定的启发作用。


文章主体由四部分构成:



  1. 首先分析问题,问题拆解为:多线程分段读取文件、构建和发出文件片段上传请求

  2. 基于 JDK 随机读取文件的类库,设计本地多线程分段读取文件的单元

  3. 基于 Retrofit 设计由文件片段构建上传的网络请求

  4. 从上述设计演变而来的完整代码实现


  另外,在文章提供的完整代码中,还附了一段由 PHP 编写,用来接收多线程分段数据的服务端接口实现,其中处理了因客户端都线程上传片段,导致服务端接收的文件片段无序,故需在适当时机合并分块构成目标文件。



受限于笔者的开发经验与理论理解,文章的思路和代码难免可能有偏颇,对于有改进和优化的部分,欢迎大家讨论区提出。



问题拆解


  要完成文件分段上传到服务端,第一步是分段读取本地文件。通常分段是为了多线程同时执行上传,提高设备计算和网络资源利用率,减少上传时间优化体验,这样即需要一个支持多线程的文件分段读取工具。由于文件可能超过设备内存大小,在读取这类超大文件时需要控制最大读取量防止内存溢出。此时文件已从磁盘数据转换为内存中的字节数据,只需要将这些内存数据传给服务端即可。这样问题被分成 3 个子问题:



  1. 分段读取文件到内存中

  2. 控制多线程数量

  3. 将文件片段传给服务端


  问题 1 很好解决,利用 Java 的 RandomAccessFile 可对文件的随机读取的特性,即可按需读取文件片段到内存中。问题 2 相对复杂一点,但如果有阅读过 JDK 中线程池源码的读者,就会发现这个问题的和控制线程池中线程数量其实是类似的。问题 3 就不复杂了,Retrofit 基于 OKhttp ,OkHttp是很容易基于字节数组构建 multipart/form-data 请求的。


分块并发读取文件


  根据上述对问题 1、2 的拆解,可将读取抽象为一个文件读取器,构建时传入文件对象和分段大小以及最大并发数,以及分段数据的回调。当外部启动读取时将根据文件大小和配置的分段大小构建若干个 Task 用于读取对应片段的数据。


public BlockReader(@NotNull File file, @NotNull BlockCallback callback, int poolSize, int blockSize) {
mFile = file;
mCallback = callback;
mPoolSize = poolSize;
mBlockSize = blockSize;
}

public void start(@Nullable BlockFilter filter) {
Observable.empty().observeOn(Schedulers.computation()).doOnComplete(() -> {
long length = mFile.length();
for (long offset = 0; offset < length; offset += mBlockSize) {
if (null != filter && filter.ignore(offset)) {
continue;
}
mQueue.offer(new ReadTask(offset));
}
for (int i = 0; i < Math.min(mPoolSize, mQueue.size()); i++) {
Observable.empty().observeOn(Schedulers.io()).doOnComplete(this::schedule).subscribe();
}
}).subscribe();
}

  多线程调度部分,可通过加锁和记录状态变量统计当前正运行的线程数,则可控制字节数组数,这样就相当于控制住了最大内存占用。


private void schedule() {
if (mRunning.get() >= mPoolSize) {
return;
}
ReadTask task;
synchronized (mQueue) {
if (mRunning.get() >= mPoolSize) {
return;
}
task = mQueue.poll();
if (null != task) {
mRunning.incrementAndGet();
}
}
if (null != task) {
task.run();
}
}

  最后是文件随机读取,直接调用 RandomAccessFile 的 API 即可:


private class ReadTask implements Action {

@Override
public void run() {
try (RandomAccessFile raf = new RandomAccessFile(mFile, RAF_MODE);
ByteArrayOutputStream out = new ByteArrayOutputStream(mBlockSize)) {
raf.seek(mOffset);
byte[] buf = new byte[DEF_BLOCK_SIZE];
long cnt = 0;
for (int bytes = raf.read(buf); bytes != -1 && cnt < mBlockSize; bytes = raf.read(buf)) {
out.write(buf, 0, bytes);
cnt += bytes;
}
out.flush();
mCallback.onFinished(mOffset, out.toByteArray());
} catch (IOException e) {
mCallback.onFinished(mOffset, null);
} finally {
mRunning.decrementAndGet();
schedule();
}
}
}

文件片段上传


  上传部分则使用 Retrofit 提供的注解和 OKHttp 的类库构建请求。但值得一提的是需要在磁盘IO线程同步完成网络IO,这样可以避免网络IO速度落后磁盘IO太多而导致任务堆积造成内存溢出。


public interface BlockUploader {
@POST("test/upload.php")
@Multipart
Single<Response<ResponseBody>> upload(@Header("filename") String filename,
@Header("total") long total,
@Header("offset") long offset,
@Part List<MultipartBody.Part> body);
}

private static void syncUpload(String fileName, long fileLength, long offset, byte[] bytes) {
RequestBody data = RequestBody.create(MediaType.parse("application/octet-stream"), bytes);
MultipartBody body = new MultipartBody.Builder()
.addFormDataPart("file", fileName, data)
.setType(MultipartBody.FORM)
.build();
retrofit.create(BlockUploader.class).upload(fileName, fileLength, offset, body.parts()).subscribe(resp -> {
if (resp.isSuccessful()) {
System.out.println("✓ offset: " + offset + " upload succeed " + resp.code());
} else {
System.out.println("✗ offset: " + offset + " upload failed " + resp.code());
}
}, throwable -> {
System.out.println("! offset: " + offset + " upload failed");
});
}

完整代码


  为控制篇幅,完整代码请移步 Github,服务端部分处理形如:


Snip20230102_10.png


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

运动APP视频轨迹回放分享实现

喜欢户外运动的朋友一般都应该使用过运动APP(keep, 咕咚,悦跑圈,国外的Strava等)的一项功能,就是运动轨迹视频分享,分享到朋友圈或是运动群的圈子里。笔者本身平常也是喜欢户外跑、骑行、爬山等户外运动,也跑过半马、全马,疫情原因之前报的杭州的全马也延期...
继续阅读 »

喜欢户外运动的朋友一般都应该使用过运动APP(keep, 咕咚,悦跑圈,国外的Strava等)的一项功能,就是运动轨迹视频分享,分享到朋友圈或是运动群的圈子里。笔者本身平常也是喜欢户外跑、骑行、爬山等户外运动,也跑过半马、全马,疫情原因之前报的杭州的全马也延期了好几次了。回归正题,本文笔者基于自己的思想实现运动轨迹回放的一套算法策略,实现本身是基于Mapbox地图的,但是其实可以套用在任何地图都可以实现,基本可以脱离地图SDK的API。Mapbox 10 版本之后的官方给出的Demo里已经有类似轨迹回放的Case了,但是深度地依赖地图SDK本身的API,倘若在高德上实现很难可以迁移的。


这里先看下gif动图的效果,这是我在奥森跑的10KM的一个轨迹:


轨迹视频回放_AdobeExpress .gif


整个的实现包含了轨迹的回放,视频的录制,然后视频的录制这块不再笔者这篇文章的介绍的范畴内。所以这里主要介绍轨迹的回放,这个回放过程其实也是包含了大概10多种动画在里面的,辅助信息距离的文字跳转动画;距离下面配速、运动时间等的flap in 及 out的动画;播放button,底部button的渐变Visibility; 地图的缩放以及视觉角度的变化等;以上的这些也不做讨论。主要介绍轨迹回放、整公里点的显示(起始、结束), 回放过程中窗口控制等,作为主要的讲解范畴。


首先介绍笔者最开始的一种实现,假如以上轨迹List 有一百个点,每相邻的两个点做Animation之后,在AnimationEnd的Listener里开起距离下一个点的Animation,直到所有点结束,这里有个问题每次的运动轨迹的点的数量不一样,所以开起Animation的次数也不一样,整个轨迹回放的时间等于所有的Animation执行的时间和,每次动画启动需要损耗20~30ms。倘若要分享到微信朋友圈,视频的时间是限制的,但之前的那种方式时间上显然不可控,每次动画启动的损耗累加导致视频播放不完。


紧接着换成AnimationSet, 将各个线段Animation的动画放入Set里,然后playSequentially执行,同样存在上面的问题。假如只执行一次动画,那么这次动画start的损耗在整个视频播放上时长上的占比就可以忽略不计了,那如何才能将整个List的回放在一个Animation下执行完呢?假如轨迹只是一个普通的 Path,那么我们就可以基于Path的 length一个属性动画了,当转化到地图运动轨迹上去时,又如何去实现呢?


基于Path Length的属性动画



  1. 计算List对应的Path

  2. 通过PathMeasure获取 Path 的 Length

  3. 对Path做 Length的属性动画


这里有两套Point体系,一个是View的Path对应的Points, 然后就是Map上的List对应的Points,运动轨迹原始数据是Map上的List 点,上面的第一步就是将Map上的Points 转成屏幕Pixel对应的点并生成Path; 第二部通过PathMeasure 计算Path的Length; 最后在Path Length上做属性动画,然而这里并非将属性动画中每次渐变的值(这里对应的是View的Point点)绘制成View对应的Path,而是将渐变中的点又通过Map的SDK转成地图Location点,绘制地图轨迹。这里一共做了两道转换,中间只是借助View的Path做了一个依仗Length属性做的一个动画。因为基本上每种地图SDK都有Pixel 跟Location Point点互相transform的API,所以这个可以直接迁移到其它地图上,例如高德地图等。


下面具体看下代码,先将Location 转成View的Point体系,这里保存了总的一个Path,以及List 中两两相邻点对应的分段Path的一个list.



  • 生成Path:


1.1 生成Path2.png


其中用到 Mapbox地图API Location 点转View的PointF 接口API toScreenLocation(LatLng latlng), 这里生成List, 然后计算得到Path.




  • 基于Length做属性动画:


1.3 Path length 属性动画.png


首先创建属性动画的 Instance:


ValueAnimator.ofObject(new DstPathEvaluator(), 0, mPathMeasure.getLength());

将每次渐变的值经过 calculateAnimPathData(value) 计算后存入到 以下的四个变量中,这里除了Length的渐变值,还附带有角度的一个二元组值。


dstPathEndPoint[0] = 0;//x坐标
dstPathEndPoint[1] = 0;//y坐标
dstPathTan[0] = 0;//角度值
dstPathTan[1] = 0;//角度值

然后将dstPathEndPoint 的值转成Mapbox的 Location的 Latlng 经纬度点,


PointF lastPoint = new PointF(dstPathEndPoint[0], dstPathEndPoint[1]);
LatLng lastLatLng = mapboxMap.getProjection().fromScreenLocation(lastPoint);
Point point = Point.fromLngLat(lastLatLng.getLongitude(), lastLatLng.getLatitude());

过滤掉一些动画过程中可能产生的异常点,最后加入到Mapbox的轨迹绘制的Layer中形成轨迹的一个渐变:


Location curLocation = mLocationList.get(animIndex);
float degrees = MapBoxPathUtil.getRotate(curLocation, point);
if (animIndex < 5 || Math.abs(degrees - curRotate) < 5) {//排除异常点
setMarkerRecord(point);
}

setMarkerRecord(point) 方法调用加入到 Map 轨迹的绘制Layer中


1.4 加入到Map轨迹绘制.png


动画过程中,当加入到Path中的点超过一定占比时,做了一个窗口显示的动画,窗口List跟整个List的一个计算:


//这里可以取后半段的数据,滑动窗口,保持 moveCamera 的窗口值不变。
int moveSize = passedPointList.size();
List<LatLng> windowPassList = passedPointList.subList(moveSize - windowLength, moveSize);

接下来看整公里点的绘制,看之前先看下上面的calculateAnimPathData()方法的逻辑


1.5 Path渐变的计算.png


如上,length为当前Path走过的距离,假设轨迹一共100点,当前走到 49 ~ 50 点之间,那么calculateLength就是0到50这个点的Path的长度,它是大于length的,offsetLength = calculateLength - length; 记录的是 当前点到50号点的一个长度offsetLength,animIndex值当前值对应50,recordPathList为一开始提到的跟计算总Path时一个分段Path的List, 获取到49 ~ 50 这个Path对应的一个model.


RecordPathBean recordPathBean = recordPathList.get(animIndex);

获得Path(49 ~ 50) 的长度减去 当前点到 50的Path(cur ~ 50)的到 Path(49 ~ cur) 的长度


float stopD = (float) (pathMeasure.getLength() - offsetLengthCur);

然后最终通过PathMeasure的 getPosTan 获得dstPathEndPoint以及dstPathTan数据。


pathMeasure.getSegment(0, stopD, dstPath, false);
mDstPathMeasure = new PathMeasure(dstPath, false);
//这里有个参数 tan
mDstPathMeasure.getPosTan(mDstPathMeasure.getLength(), dstPathEndPoint, dstPathTan);


  • 整公里点的绘制


原始数据中的List的Location中存储了一个字段kilometer, 当某个Location是整公里点时该字段就有对应的值,每次Path属性渐变时,上面的逻辑里记录了lastAnimIndex, animIndex。当 animIndex > lastAnimIndex时, 上面的calculateAnimPathData() 方法里分析animIndex有可能还没走到,所以在animIndex > lastAnimIndex时lastAnimIndex肯定走到了。


1.6 整公里点动画.png


当lastAnimIndex对应的点是 整公里时,做一个响应的属性动画。


至此,运动轨迹回放的一个动画执行逻辑分析完了,如文章开始所说,整个过程中其实还包含了好多种其它的动画,处理它们播放的一个时序问题,如何编排实现等等也是一个难点。另外还就是轨迹播放时的一个Camera的一个视觉跟踪的效果没有实现,这个用地图本身的Camera 的API是一种实现,但是如何跟上面的这些结合到一块;然后就是自行通过计算角度偏移,累计到一定的旋转角度时,转移地图的指南针;以上是笔者想到的方案,以上有计算角度的,但需要找准那个累计的角度值,然后大量实际数据适配。


最后,有需要了解轨迹回放功能其它实现的,可留言或私信笔者进行一起探讨。


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

RxJava2 事件分发&消费绑定逻辑 简析

前言 重温RxJava2源码,做个简单的记录,本文仅分析事件的发射与消费简单逻辑,从源码角度分析被观察者(上游事件)是如何与观察者(下游事件)进行关联的。 事件发射 Observable.just(1,2,3) .subscribe(); Ob...
继续阅读 »

前言


重温RxJava2源码,做个简单的记录,本文仅分析事件的发射与消费简单逻辑,从源码角度分析被观察者(上游事件)是如何与观察者(下游事件)进行关联的。


事件发射


Observable.just(1,2,3)
.subscribe();

Observable.create(new ObservableOnSubscribe<Integer>() {
@Override
public void subscribe(@NonNull ObservableEmitter<Integer> emitter) throws Exception {
emitter.onNext(1);
emitter.onNext(2);
emitter.onNext(3);
}
}).subscribe();

上述两种方式都是由被观察者发出3个事件,交给观察者(下游事件)去处理。这里分析一下Observable.justObservable.create 方法的区别


Observable被观察者(上游事件)


just方式


public static <T> Observable<T> just(T item1, T item2, T item3) {
return fromArray(item1, item2, item3);
}

这里将传入的item…继续传入fromArray方法


public static <T> Observable<T> fromArray(T... items) {
return RxJavaPlugins.onAssembly(new ObservableFromArray<T>(items));
}

最终将参数传入实例化的ObservableFromArray对象中,并将该对象返回,此处可先不关注RxJavaPlugins类,继续探索ObservableFromArray类都做了什么;


public final class ObservableFromArray<T> extends Observable<T> {
final T[] array;
public ObservableFromArray(T[] array) {
this.array = array;
}

@Override
public void subscribeActual(Observer<? super T> observer) {
FromArrayDisposable<T> d = new FromArrayDisposable<T>(observer, array);

observer.onSubscribe(d);

if (d.fusionMode) {
return;
}

d.run();
}
}

作为Observable的子类,每个被观察者都要实现自己的subscribeActual方法,这里才是真正与观察者进行绑定的具体实现,其中实例化了FromArrayDisposable对象,并将observer(观察者)与array传入,方法结尾调用了其run方法。


void run() {
T[] a = array;
int n = a.length;

for (int i = 0; i < n && !isDisposed(); i++) {
T value = a[i];
if (value == null) {
downstream.onError(new NullPointerException("The element at index " + i + " is null"));
return;
}
downstream.onNext(value);
}
if (!isDisposed()) {
downstream.onComplete();
}
}

可以看到其中对于最初传入的1、2、3,以此进行了onNext方法的调用,分发结束后调用了onComplete,事件结束。


create方式


首先从上面的实例代码可以看到,create方法中还需要传入ObservableOnSubscribe的实例对象,暂且不管,我们来挖掘一下create方法


public static <T> Observable<T> create(ObservableOnSubscribe<T> source) {
return RxJavaPlugins.onAssembly(new ObservableCreate<T>(source));
}

最终将上述我们创建的ObservableOnSubscribe对象传入新实例化的ObservableCreate对象中,并将该对象返回;


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

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

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

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

看到在subscribeActual方法中,创建了CreateEmitter对象,接着分别调用observer#onSubscribe方法和source#subscribe方法,这里要搞清楚其中的3个变量分别是什么



  • source:被观察者(上游事件),最初我们create方法中传入的接口对象,我们就是在source中进行事件分发的

  • observer:观察者(下游事件),我们的事件最终交给observer去处理,这里将observer传入了CreateEmitter,就是要在Emitter中进行中转分发事件给observer

  • parent:理解为一个上下游的中转站,上游事件发射后在这里交给下游去处理


最后我们看一下CreateEmitter类中的实现


static final class CreateEmitter<T>
extends AtomicReference<Disposable>
implements ObservableEmitter<T>, Disposable {

private static final long serialVersionUID = -3434801548987643227L;

final Observer<? super T> observer;

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

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

这里只贴出了onNext方法,可以看到当onNext方法被调用后,其中就会去调用observeronNext方法,而onNext最初的触发就是在实例代码中我们实例化的ObservableOnSubscribe其中的subscribe方法中


事件消费


...
.subscribe(new Consumer<Integer>() {
@Override
public void accept(Integer integer) throws Exception {

}
});

...
.subscribe(new Observer<Integer>() {
@Override
public void onSubscribe(@NonNull Disposable d) {

}

@Override
public void onNext(@NonNull Integer integer) {

}

@Override
public void onError(@NonNull Throwable e) {

}

@Override
public void onComplete() {

}
});

上述两种方式都是接收被观察者(上游事件)发出的事件,进行处理消费。这里分析一下ConsumerObserver的区别


Observer观察者(下游事件)


Consumer


public interface Consumer<T> {
/**
* Consume the given value.
* @param t the value
* @throws Exception on error
*/
void accept(T t) throws Exception;
}

Consumer仅为一个接口类,其中accept方法接收事件并消费,我们需要去到上游事件订阅下游事件时的subscribe方法,根据下游事件的参数类型与数量,会进入不同的subscribe重载方法中;


subscribe(Consumer<? super T> onNext) : Diposable


public final Disposable subscribe(Consumer<? super T> onNext) {
return subscribe(onNext, Functions.ON_ERROR_MISSING, Functions.EMPTY_ACTION, Functions.emptyConsumer());
}

public final Disposable subscribe(Consumer<? super T> onNext, Consumer<? super Throwable> onError,
Action onComplete, Consumer<? super Disposable> onSubscribe) {

LambdaObserver<T> ls = new LambdaObserver<T>(onNext, onError, onComplete, onSubscribe);
subscribe(ls);
return ls;
}

该方法中包装了一个LambdaObserver,将我们传入的onNext方法再传入其中


public final class LambdaObserver<T> extends AtomicReference<Disposable>
implements Observer<T>, Disposable, LambdaConsumerIntrospection {

private static final long serialVersionUID = -7251123623727029452L;
final Consumer<? super T> onNext;
final Consumer<? super Throwable> onError;
final Action onComplete;
final Consumer<? super Disposable> onSubscribe;

public LambdaObserver(Consumer<? super T> onNext, Consumer<? super Throwable> onError,
Action onComplete,
Consumer<? super Disposable> onSubscribe) {
super();
this.onNext = onNext;
this.onError = onError;
this.onComplete = onComplete;
this.onSubscribe = onSubscribe;
}

@Override
public void onNext(T t) {
if (!isDisposed()) {
try {
onNext.accept(t);
} catch (Throwable e) {
Exceptions.throwIfFatal(e);
get().dispose();
onError(e);
}
}
}

可以看到LambdaObserver实际上就是Observer的实现类,其中实现了onSubscribe onNext onError onComplete 方法,上述代码中我们看到我们最初的Consumer对象实际上就是其中的onNext变量,在LambdaObserver收到onNext事件消费时,再将事件交给Consumer去处理。Consumer相当于一种简易模式的观察者,根据被观察者的subscribe订阅方法消费特定的事件(onNextonError等)。


Observer


public interface Observer<T> {

void onSubscribe(@NonNull Disposable d);

void onNext(@NonNull T t);

void onError(@NonNull Throwable e);

void onComplete();
}

Observer是最原始的观察者,是所有Observer的顶层接口,其中方法为观察者可以消费的四个事件


subscribe(Observer<? super T> observer)


该方法也是其他所有订阅观察者方法最终会进入的方法


public final void subscribe(Observer<? super T> observer) {
ObjectHelper.requireNonNull(observer, "observer is null");
try {
observer = RxJavaPlugins.onSubscribe(this, observer);

subscribeActual(observer);
} catch (NullPointerException e) { // NOPMD
...
} catch (Throwable e) {
...
}
}

最终在subscribeActual方法中进行被观察者与观察者(上游与下游事件)的绑定。


写在结尾


抛开所有的操作符、线程切换来说,RxJava的上下游事件绑定逻辑还是十分清晰易读的,可以通过源码了解每个事件是如何从上游传递至下游的。至于其他逻辑,另起篇幅分析。


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

Kotlin 协程探索

Kotlin 协程是什么? 本文只是自己经过研究后,对 Kotlin 协程的理解概括,如有偏差,还请斧正。 简要概括: 协程是 Kotlin 提供的一套线程 API 框架,可以很方便的做线程切换。 而且在不用关心线程调度的情况下,能轻松的做并发编程。也可以说...
继续阅读 »

Kotlin 协程是什么?


本文只是自己经过研究后,对 Kotlin 协程的理解概括,如有偏差,还请斧正。


简要概括:



协程是 Kotlin 提供的一套线程 API 框架,可以很方便的做线程切换。 而且在不用关心线程调度的情况下,能轻松的做并发编程。也可以说协程就是一种并发设计模式。



下面是使用传统线程和协程执行任务:


       Thread{
//执行耗时任务
}.start()

val executors = Executors.newCachedThreadPool()
executors.execute {
//执行耗时任务
}

GlobalScope.launch(Dispatchers.IO) {
//执行耗时任务
}

在实际应用开发中,通常是在主线中去启动子线程执行耗时任务,等耗时任务执行完成,再将结果给主线程,然后刷新UI:


       Thread{
//执行耗时任务
runOnMainThread {
//获取耗时任务结果,刷新UI
}
}.start()

val executors = Executors.newCachedThreadPool()
executors.execute {
//执行耗时任务
runOnMainThread {
//获取耗时任务结果,刷新UI
}
}

Observable.unsafeCreate<Unit> {
//执行耗时任务
}.subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread()).subscribe {
//获取耗时任务结果,刷新UI
}

GlobalScope.launch(Dispatchers.Main) {
val result = withContext(Dispatchers.IO){
//执行耗时任务
}
//直接拿到耗时任务结果,刷新UI
refreshUI(result)
}

从上面可以看到,使用Java 的 ThreadExecutors 都需要手动去处理线程切换,这样的代码不仅不优雅,而且有一个重要问题,那就是要去处理与生命周期相关的上下文判断,这导致逻辑变复杂,而且容易出错。


RxJava 是一套优雅的异步处理框架,代码逻辑简化,可读性和可维护性都很高,很好的帮我们处理线程切换操作。这在 Java 语言环境开发下,是如虎添翼,但是在 Kotlin 语言环境中开发,如今的协程就比 RxJava 更方便,或者说更有优势。


下面看一个 Kotlin 中使用协程的例子:


        GlobalScope.launch(Dispatchers.Main) {
Log.d("TestCoroutine", "launch start: ${Thread.currentThread()}")
val numbersTo50Sum = withContext(Dispatchers.IO) {
//在子线程中执行 1-50 的自然数和
Log.d("TestCoroutine", "launch:numbersTo50Sum: ${Thread.currentThread()}")
delay(1000)
val naturalNumbers = generateSequence(0) { it + 1 }
val numbersTo50 = naturalNumbers.takeWhile { it <= 50 }
numbersTo50.sum()
}

val numbers50To100Sum = withContext(Dispatchers.IO) {
//在子线程中执行 51-100 的自然数和
Log.d("TestCoroutine", "launch:numbers50To100Sum: ${Thread.currentThread()}")
delay(1000)
val naturalNumbers = generateSequence(51) { it + 1 }
val numbers50To100 = naturalNumbers.takeWhile { it in 51..100 }
numbers50To100.sum()
}

val result = numbersTo50Sum + numbers50To100Sum
Log.d("TestCoroutine", "launch end:result=$result ${Thread.currentThread()}")
}
Log.d("TestCoroutine", "Hello World!,${Thread.currentThread()}")
控制台输出结果:
2023-01-02 16:05:45.846 10153-10153/com.wangjiang.example D/TestCoroutine: Hello World!,Thread[main,5,main]
2023-01-02 16:05:48.058 10153-10153/com.wangjiang.example D/TestCoroutine: launch start: Thread[main,5,main]
2023-01-02 16:05:48.059 10153-10322/com.wangjiang.example D/TestCoroutine: launch:numbersTo50Sum: Thread[DefaultDispatcher-worker-1,5,main]
2023-01-02 16:05:49.114 10153-10322/com.wangjiang.example D/TestCoroutine: launch:numbers50To100Sum: Thread[DefaultDispatcher-worker-1,5,main]
2023-01-02 16:05:50.376 10153-10153/com.wangjiang.example D/TestCoroutine: launch end:result=5050 Thread[main,5,main]

在上面的代码中:



  • launch 是一个函数,用于创建协程并将其函数主体的执行分派给相应的调度程序。

  • Dispatchers.MAIN 指示此协程应在为 UI 操作预留的主线程上执行。

  • Dispatchers.IO 指示此协程应在为 I/O 操作预留的线程上执行。

  • withContext(Dispatchers.IO) 将协程的执行操作移至一个 I/O 线程。


从控制台输出结果中,可以看出在计算 1-50 和 51-100 的自然数和的时候,线程是从主线程(Thread[main,5,main])切换到了协程的线程(DefaultDispatcher-worker-1,5,main),这里计算 1-50 和 51-100 都是同一个子线程。


在这里有一个重要的现象,代码从逻辑上看起来是同步的,并且启动协程执行任务的时候,没有阻塞主线程继续执行相关操作,而且在协程中的异步任务执行完成之后,又自动切回了主线程。这就是 Kotlin 协程给开发做并发编程带来的好处。这也是有个概念的来源: Kotlin 协程同步非阻塞


同步非阻塞”是真的“同步非阻塞” 吗?下面探究一下其中的猫腻,通过 Android Studio ,查看 .class 文件中的上面一段代码:


      BuildersKt.launch$default((CoroutineScope)GlobalScope.INSTANCE, (CoroutineContext)Dispatchers.getMain(), (CoroutineStart)null, (Function2)(new Function2((Continuation)null) {
int I$0;
int label;

@Nullable
public final Object invokeSuspend(@NotNull Object $result) {
Object var10000;
int numbersTo50Sum;
label17: {
Object var5 = IntrinsicsKt.getCOROUTINE_SUSPENDED();
Function2 var10001;
CoroutineContext var6;
switch(this.label) {
case 0:
ResultKt.throwOnFailure($result);
Log.d("TestCoroutine", "launch start: " + Thread.currentThread());
var6 = (CoroutineContext)Dispatchers.getIO();
var10001 = (Function2)(new Function2((Continuation)null) {
int label;

@Nullable
public final Object invokeSuspend(@NotNull Object $result) {
Object var4 = IntrinsicsKt.getCOROUTINE_SUSPENDED();
switch(this.label) {
case 0:
ResultKt.throwOnFailure($result);
Log.d("TestCoroutine", "launch:numbersTo50Sum: " + Thread.currentThread());
this.label = 1;
if (DelayKt.delay(1000L, this) == var4) {
return var4;
}
break;
case 1:
ResultKt.throwOnFailure($result);
break;
default:
throw new IllegalStateException("call to 'resume' before 'invoke' with coroutine");
}

Sequence naturalNumbers = SequencesKt.generateSequence(Boxing.boxInt(0), (Function1)null.INSTANCE);
Sequence numbersTo50 = SequencesKt.takeWhile(naturalNumbers, (Function1)null.INSTANCE);
return Boxing.boxInt(SequencesKt.sumOfInt(numbersTo50));
}

@NotNull
public final Continuation create(@Nullable Object value, @NotNull Continuation completion) {
Intrinsics.checkNotNullParameter(completion, "completion");
Function2 var3 = new <anonymous constructor>(completion);
return var3;
}

public final Object invoke(Object var1, Object var2) {
return ((<undefinedtype>)this.create(var1, (Continuation)var2)).invokeSuspend(Unit.INSTANCE);
}
});
this.label = 1;
var10000 = BuildersKt.withContext(var6, var10001, this);
if (var10000 == var5) {
return var5;
}
break;
case 1:
ResultKt.throwOnFailure($result);
var10000 = $result;
break;
case 2:
numbersTo50Sum = this.I$0;
ResultKt.throwOnFailure($result);
var10000 = $result;
break label17;
default:
throw new IllegalStateException("call to 'resume' before 'invoke' with coroutine");
}

numbersTo50Sum = ((Number)var10000).intValue();
var6 = (CoroutineContext)Dispatchers.getIO();
var10001 = (Function2)(new Function2((Continuation)null) {
int label;

@Nullable
public final Object invokeSuspend(@NotNull Object $result) {
Object var4 = IntrinsicsKt.getCOROUTINE_SUSPENDED();
switch(this.label) {
case 0:
ResultKt.throwOnFailure($result);
Log.d("TestCoroutine", "launch:numbers50To100Sum: " + Thread.currentThread());
this.label = 1;
if (DelayKt.delay(1000L, this) == var4) {
return var4;
}
break;
case 1:
ResultKt.throwOnFailure($result);
break;
default:
throw new IllegalStateException("call to 'resume' before 'invoke' with coroutine");
}

Sequence naturalNumbers = SequencesKt.generateSequence(Boxing.boxInt(51), (Function1)null.INSTANCE);
Sequence numbers50To100 = SequencesKt.takeWhile(naturalNumbers, (Function1)null.INSTANCE);
return Boxing.boxInt(SequencesKt.sumOfInt(numbers50To100));
}

@NotNull
public final Continuation create(@Nullable Object value, @NotNull Continuation completion) {
Intrinsics.checkNotNullParameter(completion, "completion");
Function2 var3 = new <anonymous constructor>(completion);
return var3;
}

public final Object invoke(Object var1, Object var2) {
return ((<undefinedtype>)this.create(var1, (Continuation)var2)).invokeSuspend(Unit.INSTANCE);
}
});
this.I$0 = numbersTo50Sum;
this.label = 2;
var10000 = BuildersKt.withContext(var6, var10001, this);
if (var10000 == var5) {
return var5;
}
}

int numbers50To100Sum = ((Number)var10000).intValue();
int result = numbersTo50Sum + numbers50To100Sum;
Log.d("TestCoroutine", "launch end:result=" + result + ' ' + Thread.currentThread());
return Unit.INSTANCE;
}

@NotNull
public final Continuation create(@Nullable Object value, @NotNull Continuation completion) {
Intrinsics.checkNotNullParameter(completion, "completion");
Function2 var3 = new <anonymous constructor>(completion);
return var3;
}

public final Object invoke(Object var1, Object var2) {
return ((<undefinedtype>)this.create(var1, (Continuation)var2)).invokeSuspend(Unit.INSTANCE);
}
}), 2, (Object)null);
Log.d("TestCoroutine", "Hello World!," + Thread.currentThread());

虽然上面 .class 文件中的代码比较复杂,但是从大体逻辑可以看出,Kotlin 协程也是通过回调接口来实现异步操作的,这也解释了 Kotlin 协程只是让代码逻辑是同步非阻塞,但是实际上并没有,只是 Kotlin 编译器为代码做了很多事情,这也是说 Kotlin 协程其实就是一套线程 API 框架的原因。


再看一个上面例子的变种:


        GlobalScope.launch(Dispatchers.Main) {
Log.d("TestCoroutine", "launch start: ${Thread.currentThread()}")
val numbersTo50Sum = async {
withContext(Dispatchers.IO) {
Log.d("TestCoroutine", "launch:numbersTo50Sum: ${Thread.currentThread()}")
delay(2000)
val naturalNumbers = generateSequence(0) { it + 1 }
val numbersTo50 = naturalNumbers.takeWhile { it <= 50 }
numbersTo50.sum()
}
}

val numbers50To100Sum = async {
withContext(Dispatchers.IO) {
Log.d("TestCoroutine", "launch:numbers50To100Sum: ${Thread.currentThread()}")
delay(500)
val naturalNumbers = generateSequence(51) { it + 1 }
val numbers50To100 = naturalNumbers.takeWhile { it in 51..100 }
numbers50To100.sum()
}
}
// 计算 1-50 和 51-100 的自然数和是两个并发操作
val result = numbersTo50Sum.await() + numbers50To100Sum.await()
Log.d("TestCoroutine", "launch end:result=$result ${Thread.currentThread()}")
}
Log.d("TestCoroutine", "Hello World!,${Thread.currentThread()}")

控制台输出结果:
2023-01-02 16:32:12.637 13303-13303/com.wangjiang.example D/TestCoroutine: Hello World!,Thread[main,5,main]
2023-01-02 16:32:13.120 13303-13303/com.wangjiang.example D/TestCoroutine: launch start: Thread[main,5,main]
2023-01-02 16:32:14.852 13303-13444/com.wangjiang.example D/TestCoroutine: launch:numbersTo50Sum: Thread[DefaultDispatcher-worker-2,5,main]
2023-01-02 16:32:14.853 13303-13443/com.wangjiang.example D/TestCoroutine: launch:numbers50To100Sum: Thread[DefaultDispatcher-worker-1,5,main]
2023-01-02 16:32:17.462 13303-13303/com.wangjiang.example D/TestCoroutine: launch end:result=5050 Thread[main,5,main]

async 创建了一个协程,它让计算 1-50 和 51-100 的自然数和是两个并发操作。上面控制台输出结果可以看到计算 1-50 的自然数和是在线程 Thread[DefaultDispatcher-worker-2,5,main] 中,而计算 51-100 的自然数和是在另一个线程Thread[DefaultDispatcher-worker-1,5,main]中。


从上面的例子,协程在异步操作,也就是线程切换上:主线程启动子线程执行耗时操作,耗时操作执行完成将结果更新到主线程的过程中,代码逻辑简化,可读性高。


suspend 是什么?


suspend 直译就是:挂起


suspend 是 Kotlin 语言中一个 关键字,用于修饰方法,当修饰方法时,表示这个方法只能被 suspend 修饰的方法调用或者在协程中被调用。


下面看一下将上面代码案例拆分成几个 suspend 方法:


    fun getNumbersTo100Sum() {
GlobalScope.launch(Dispatchers.Main) {
Log.d("TestCoroutine", "launch start: ${Thread.currentThread()}")
val result = calcNumbers1To100Sum()
Log.d("TestCoroutine", "launch end:result=$result ${Thread.currentThread()}")
}
Log.d("TestCoroutine", "Hello World!,${Thread.currentThread()}")
}

private suspend fun calcNumbers1To100Sum(): Int {
return calcNumbersTo50Sum() + calcNumbers50To100Sum()
}

private suspend fun calcNumbersTo50Sum(): Int {
return withContext(Dispatchers.IO) {
Log.d("TestCoroutine", "launch:numbersTo50Sum: ${Thread.currentThread()}")
delay(1000)
val naturalNumbers = generateSequence(0) { it + 1 }
val numbersTo50 = naturalNumbers.takeWhile { it <= 50 }
numbersTo50.sum()
}
}

private suspend fun calcNumbers50To100Sum(): Int {
return withContext(Dispatchers.IO) {
Log.d("TestCoroutine", "launch:numbers50To100Sum: ${Thread.currentThread()}")
delay(1000)
val naturalNumbers = generateSequence(51) { it + 1 }
val numbers50To100 = naturalNumbers.takeWhile { it in 51..100 }
numbers50To100.sum()
}
}
控制台输出结果:
2023-01-03 14:47:57.047 11349-11349/com.wangjiang.example D/TestCoroutine: Hello World!,Thread[main,5,main]
2023-01-03 14:47:59.311 11349-11349/com.wangjiang.example D/TestCoroutine: launch start: Thread[main,5,main]
2023-01-03 14:47:59.312 11349-11537/com.wangjiang.example D/TestCoroutine: launch:numbersTo50Sum: Thread[DefaultDispatcher-worker-3,5,main]
2023-01-03 14:48:00.336 11349-11535/com.wangjiang.example D/TestCoroutine: launch:numbers50To100Sum: Thread[DefaultDispatcher-worker-1,5,main]
2023-01-03 14:48:01.339 11349-11349/com.wangjiang.example D/TestCoroutine: launch end:result=5050 Thread[main,5,main]

suspend 关键字标记方法时,其实是告诉 Kotlin 从协程内调用方法。所以这个“挂起”,并不是说方法或函数被挂起,也不是说线程被挂起


假设一个非 suspend 修饰的方法调用 suspend 修饰的方法会怎么样呢?


  private fun calcNumbersTo100Sum(): Int {
return calcNumbersTo50Sum() + calcNumbers50To100Sum()
}

此时,编译器会提示:


Suspend function 'calcNumbersTo50Sum' should be called only from a coroutine or another suspend function
Suspend function 'calcNumbers50To100' should be called only from a coroutine or another suspend function

下面查看 .class 文件中的上面方法 calcNumbers50To100Sum 代码:


   private final Object calcNumbers50To100Sum(Continuation $completion) {
return BuildersKt.withContext((CoroutineContext)Dispatchers.getIO(), (Function2)(new Function2((Continuation)null) {
int label;

@Nullable
public final Object invokeSuspend(@NotNull Object $result) {
Object var4 = IntrinsicsKt.getCOROUTINE_SUSPENDED();
switch(this.label) {
case 0:
ResultKt.throwOnFailure($result);
Log.d("TestCoroutine", "launch:numbers50To100Sum: " + Thread.currentThread());
this.label = 1;
if (DelayKt.delay(1000L, this) == var4) {
return var4;
}
break;
case 1:
ResultKt.throwOnFailure($result);
break;
default:
throw new IllegalStateException("call to 'resume' before 'invoke' with coroutine");
}

Sequence naturalNumbers = SequencesKt.generateSequence(Boxing.boxInt(51), (Function1)null.INSTANCE);
Sequence numbers50To100 = SequencesKt.takeWhile(naturalNumbers, (Function1)null.INSTANCE);
return Boxing.boxInt(SequencesKt.sumOfInt(numbers50To100));
}

@NotNull
public final Continuation create(@Nullable Object value, @NotNull Continuation completion) {
Intrinsics.checkNotNullParameter(completion, "completion");
Function2 var3 = new <anonymous constructor>(completion);
return var3;
}

public final Object invoke(Object var1, Object var2) {
return ((<undefinedtype>)this.create(var1, (Continuation)var2)).invokeSuspend(Unit.INSTANCE);
}
}), $completion);
}

可以看到 private suspend fun calcNumbers50To100Sum() 经过 Kotlin 编译器编译后变成了private final Object calcNumbers50To100Sum(Continuation $completion)suspend 消失了,方法多了一个参数 Continuation $completion,所以 suspend修饰 Kotlin 的方法或函数,编译器会对此方法做特殊处理。


另外,suspend 修饰的方法,也预示着这个方法是耗时方法,告诉方法调用者要使用协程。当执行 suspend 方法,也预示着要切换线程,此时主线程依然可以继续执行,而协程里面的代码可能被挂起了。


下面再稍为修改 calcNumbers50To100Sum 方法:


   private suspend fun calcNumbers50To100Sum(): Int {
Log.d("TestCoroutine", "launch:numbers50To100Sum:start: ${Thread.currentThread()}")
val sum= withContext(Dispatchers.Main) {
Log.d("TestCoroutine", "launch:numbers50To100Sum: ${Thread.currentThread()}")
delay(1000)
val naturalNumbers = generateSequence(51) { it + 1 }
val numbers50To100 = naturalNumbers.takeWhile { it in 51..100 }
numbers50To100.sum()
}
Log.d("TestCoroutine", "launch:numbers50To100Sum:end: ${Thread.currentThread()}")
return sum
}
控制台输出结果:
2023-01-03 15:28:04.349 15131-15131/com.bilibili.studio D/TestCoroutine: Hello World!,Thread[main,5,main]
2023-01-03 15:28:04.803 15131-15131/com.bilibili.studio D/TestCoroutine: launch start: Thread[main,5,main]
2023-01-03 15:28:04.804 15131-15266/com.bilibili.studio D/TestCoroutine: launch:numbersTo50Sum: Thread[DefaultDispatcher-worker-3,5,main]
2023-01-03 15:28:06.695 15131-15131/com.bilibili.studio D/TestCoroutine: launch:numbers50To100Sum:start: Thread[main,5,main]
2023-01-03 15:28:06.696 15131-15131/com.bilibili.studio D/TestCoroutine: launch:numbers50To100Sum: Thread[main,5,main]
2023-01-03 15:28:07.700 15131-15131/com.bilibili.studio D/TestCoroutine: launch:numbers50To100Sum:end: Thread[main,5,main]
2023-01-03 15:28:07.700 15131-15131/com.bilibili.studio D/TestCoroutine: launch end:result=5050 Thread[main,5,main]

主线程不受协程线程的影响。


总结


Kotlin 协程是一套线程 API 框架,在 Kotlin 语言环境下使用它做并发编程比传统 Thread, Executors 和 RxJava 更有优势,代码逻辑上“同步非阻塞“,而且简洁,易阅读和维护。


suspend 是 Kotlin 语言中一个关键字,用于修饰方法,当修饰方法时,该方法只能被 suspend 修饰的方法和协程调用。此时,也预示着该方法是一个耗时方法,告诉调用者需要在协程中使用。


参考文档:



  1. Android 上的 Kotlin 协程

  2. Coroutines guide


下一篇,将研究 Kotlin Flow。


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

Android实现RecyclerView嵌套流式布局

前言 Android开发中,列表页面是常见需求,流式布局的标签效果也是常见需求,那么两者结合的效果啥样呢?这篇文章简单实现一下。 实现过程 添加流式布局依赖,在app/build.gradle文件中添加如下代码 implementation 'com.go...
继续阅读 »

前言


Android开发中,列表页面是常见需求,流式布局的标签效果也是常见需求,那么两者结合的效果啥样呢?这篇文章简单实现一下。


实现过程



  1. 添加流式布局依赖,在app/build.gradle文件中添加如下代码


implementation 'com.google.android.flexbox:flexbox:3.0.0'


  1. 新建Activity文件RecyclerViewActivity.class


package com.example.androidstudy;

import androidx.appcompat.app.AppCompatActivity;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;

import android.os.Bundle;
import android.widget.Toast;

import com.example.androidstudy.adapter.MyRecyclerAdapter;
import com.example.androidstudy.bean.TestData;

import java.util.ArrayList;
import java.util.List;

public class RecyclerViewActivity extends AppCompatActivity {

private RecyclerView recyclerView;
private MyRecyclerAdapter adapter;

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_recycler_view);
initViews();
initListener();
}

private void initListener() {
adapter.setItemCellClicker(tag -> Toast.makeText(RecyclerViewActivity.this, tag, Toast.LENGTH_SHORT).show());
}

private void initViews() {
recyclerView = findViewById(R.id.recyclerview);
// 设置布局管理器
recyclerView.setLayoutManager(new LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false));
List<String> sss = new ArrayList<>();
sss.add("重型卡车1");
sss.add("重车11");
sss.add("重型卡车3445");
sss.add("重型卡车6677");
List<String> sss1 = new ArrayList<>();
sss1.add("轻型卡车1");
sss1.add("轻车11");
sss1.add("轻型卡车3445");
sss1.add("轻型卡车6677");

List<String> sss2 = new ArrayList<>();
sss2.add("其他1");
sss2.add("其他2");
List<TestData> list = new ArrayList<>();
list.add(new TestData("重型",sss));
list.add(new TestData("轻型", sss1));
list.add(new TestData("其他", sss2));
// 实例化Adapter对象
adapter = new MyRecyclerAdapter(this, list);
// 设置Adapter
recyclerView.setAdapter(adapter);
adapter.notifyDataSetChanged();
}
}

Activity页面布局activity_recycler_view.xml


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

<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerview"
android:layout_width="match_parent"
android:layout_height="match_parent"/>

</androidx.constraintlayout.widget.ConstraintLayout>


  1. 创建Adapter文件MyRecyclerAdapter.class


package com.example.androidstudy.adapter;

import android.content.Context;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;

import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;

import com.example.androidstudy.R;
import com.example.androidstudy.bean.TestData;
import com.google.android.flexbox.FlexboxLayout;

import java.util.List;

public class MyRecyclerAdapter extends RecyclerView.Adapter<MyRecyclerAdapter.MyViewHolder>{

private List<TestData> data;
private Context myContext;

public MyRecyclerAdapter(Context context, List<TestData> data) {
this.myContext = context;
this.data = data;
}

@NonNull
@Override
public MyViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View inflate = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_cell, parent, false);
return new MyViewHolder(inflate);
}

public interface ItemCellClicker{
void onItemClick(String tag);
}

// 流式布局标签点击事件
public ItemCellClicker itemCellClicker;
// 设置点击事件回调
public void setItemCellClicker(ItemCellClicker itemCellClicker){
this.itemCellClicker = itemCellClicker;
}

@Override
public void onBindViewHolder(@NonNull MyViewHolder holder, int position) {
TextView title = holder.itemView.findViewById(R.id.tv_title);
FlexboxLayout flexboxLayout = holder.itemView.findViewById(R.id.flexbox_layout);

TestData data = this.data.get(position);
List<String> tags = data.getTag();
flexboxLayout.removeAllViews();
// flexbox布局动态添加标签
for (int i = 0; i < tags.size(); i++) {
String temp = tags.get(i);
View tagView = LayoutInflater.from(myContext).inflate(R.layout.item_tag_cell, null, false);
TextView tag = tagView.findViewById(R.id.tv_tag);
tag.setText(temp);
// 设置标签点击事件
tag.setOnClickListener(view -> itemCellClicker.onItemClick(temp));
flexboxLayout.addView(tagView);
}
title.setText(data.getTitle());
}

@Override
public int getItemCount() {
return data.size();
}

public static class MyViewHolder extends RecyclerView.ViewHolder{

public MyViewHolder(@NonNull View itemView) {
super(itemView);
}
}
}

列表项布局item_cell.xml


<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="10dp"
tools:context=".MyActivity">

<TextView
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
android:id="@+id/tv_title"
android:text="Hello android"
android:textSize="20sp"
android:textColor="@color/black"
android:layout_width="wrap_content"
android:layout_height="wrap_content"/>
<!--流式布局-->
<com.google.android.flexbox.FlexboxLayout
android:id="@+id/flexbox_layout"
android:orientation="horizontal"
app:flexWrap="wrap"
android:layout_width="match_parent"
android:layout_height="wrap_content"/>

</LinearLayout>

列表中标签布局item_tag_cell.xml


<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="10dp"
tools:context=".MyActivity">

<TextView
android:id="@+id/tv_tag"
android:paddingHorizontal="12dp"
android:background="@drawable/item_tag_bg"
android:gravity="center"
android:text="Hello android"
android:textSize="20sp"
android:textColor="@color/black"
android:layout_width="wrap_content"
android:layout_height="32dp"/>

</LinearLayout>

效果



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

如何搞一个在线的Shape生成

Shape是Android中一个必不可少的资源,很多的背景,比如圆角,分割线、渐变等等效果,几乎都有它的影子存在,毕竟写起来简单便捷,使用起来也是简单便捷,又占用内存小,谁能不爱?无论是初级,还是中高级,创建一个shape文件,相信大家都是信手拈来。 虽然在项...
继续阅读 »

Shape是Android中一个必不可少的资源,很多的背景,比如圆角,分割线、渐变等等效果,几乎都有它的影子存在,毕竟写起来简单便捷,使用起来也是简单便捷,又占用内存小,谁能不爱?无论是初级,还是中高级,创建一个shape文件,相信大家都是信手拈来。


虽然在项目里,我们可以直接复制一个Shape文件,改一改,就能很简单的实现,但是为了更方便的创建,直接拿来可以用,于是搞了一个在线的Shape生成,目前包含了,实心、空心、渐变的模式,希望可以帮助到大家,虽然是属于造轮子了,但猜测一下,估计有需要的人,哈哈~


今天的内容大致如下:


1、在线生成Shape效果


2、如何实现这样一个在线生成平台


3、具体的主要代码实现


4、总结及问题须知


一、在线生成Shape效果


效果不是很好,毕竟咱也不是搞UI的,不过功能均可用,问题不大,目前就是左侧功能选择区域,右侧是效果及代码展示区域,包含文件的下载操作。


在线地址:abnerming888.github.io/vip/shape/s…


实际效果如下:


image.png


二、如何实现这样一个在线生成平台


其实大家可以发现,虽然是辅助生成的Android功能,但本身就是网页,所以啊,懂得Web这是最基本的,不要求多么精通,但基本的页面得需要掌握,其次就是,清楚自己要实现什么功能,得有思路,比如这个Shape,那么你就要罗列常用的几种Shape类型,其主要的代码是如何呈现的,这是最重要的,搞定下面两步问题不大。


1、Shape代码模板


Shape的生成,其实是根据模板来的,只不过根据动态配置,改其中的参数而已,所以啊,是非常简单的,罗列基本的模板后,就可以选择性的更改。


实心模板


<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<corners android:radius="10dp"></corners>
<solid android:color="#ff0000" />
</shape>

空心模板


<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
>
<stroke
android:width="1dp"
android:color="#ff0000" />
<corners android:radius="10dp" />
<solid android:color="#171616"/>
</shape>

渐变模板


<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">

<gradient
android:angle="90"
android:centerColor="#000000"
android:endColor="#ff0000"
android:startColor="#ff0000"
android:type="linear" />
<corners android:radius="10dp"></corners>
</shape>

在上边的模板中,其实需要更改的元素并不是很多,无非就是,颜色值,角度大小,边框等信息,这些信息,需要用户自己选择,所以需要抛给用户触发。


2、Web页面编写及上传平台


有了相关模板,那么就需要绘制UI进行实现了,其实在Android studio里的插件最合适不过了,插件也已经实现了,这个我们后面说,目前的在线,就需要大家进行Web绘制了,也就是Html、Css、JavaScript相关的技术了,相对于Android而言,还是比较简单的,编码思想都是一样的,具体的编写,大家可以自行发挥。


其实大家最关心的是,我们的页面,如何让别人进行使用,一般的情况下,服务器是必须的,如果我们没有服务器,其实也有很多的三方免费的托管,比如Github上,Github搭建静态网站,大家可以去搜,网上很多资料,按照步骤来就可以轻松实现了。


三、具体的主要代码实现


1、颜色选择实现


颜色用到了coloris插件,它可以在触摸输入框的时候,弹出颜色选择框,效果如下图:


image.png


使用起来也是很简答,在标签后面增加data-coloris属性即可。


<input type="text" style="width: 75%" class="input_color" value="#ff0000" data-coloris/>

2、下载代码实现


下载代码是用到了一个三方插件,FileSaver.js,下载的时候,也是非常的简单:


 let blob = new Blob([code], {type: "text/plain;charset=utf-8"});
saveAs(blob, fileName + ".xml");

3、常规代码实现


常规代码,确实没啥好说的,无非就是Html、Css、JavaScript,大家可以直接右键看源代码即可。


四、总结及问题须知


其实大家可以发现,目前的生成,颜色也好,角度边框也好,都是固定写死的,其实,在实际的项目开发中,这些都是在资源里进行配置好的,直接选择资源里的即可,其实应该加个,可配置的参数,只配置一次,就可以动态的选择项目中的资源。


在线的毕竟还不是很方便,其实自己一直在搞一个自动化脚手架,可以直接生成到项目中,目前是针对公司里架构,不太方便开源出来,但2023年,改为自己的框架后,会给大家开源出来,很多代码,真的可以自动生成,真是方便了很多。


image.png


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

你不买我不买,显卡出货量破二十年新低!红绿蓝三家混战,国产GPU引起海外关注

显卡市场的寒气,藏不住了。刚刚过去的2022年,全球独显出货量创下二十年新低,比2021年同期下跌将近50%。Jon Peddie Research(JPR)最新数据显示,今年第三季度独显出货量仅690万块。如果追溯到2005年Q3,这一数据为2000万+。而...
继续阅读 »

显卡市场的寒气,藏不住了。

刚刚过去的2022年,全球独显出货量创下二十年新低,比2021年同期下跌将近50%


Jon Peddie Research(JPR)最新数据显示,今年第三季度独显出货量仅690万块。

如果追溯到2005年Q3,这一数据为2000万+


而英伟达作为全球显卡市场头号玩家,遭受的重创早就开始显现:今年Q2、Q3业绩连续下滑,如今股价已跌至去年最高点一半左右。

内忧之下,还有外患。

前有CPU巨头英特尔高调官宣分拆图形芯片部门,为更好和英伟达、AMD打擂台;后有中国GPU厂商异军突起,多家公司在今年宣布流片或量产,已引起国外关注。

看来老黄的2022,或许并不好过。

今年显卡市场扑朔迷离

如果以“短缺”概括2021年显卡市场,那么今年的江湖,则如过山车般跌宕。

年初还在到处缺货,市场价高过发售价太过正常,一些装机玩家索性改买品牌高性价笔记本。1月时,Meta还被曝一次性从英伟达买下1.6万个GPU,还引来不少艳羡目光。

3月,情况就发生了变化。

显卡市场价已有跳水现象,再到7月,国内外消费者已基本都能以建议零售价从官方渠道及主流平台购入英伟达及AMD显卡。

“空气卡”一词逐渐隐退,不再是引发大家共鸣的表达。

缺芯潮基本结束。


短短数月的变化,主要源于两点。

其一,全球消费热潮冷却;

其二,大规模挖矿行动的终结。

当然,此前显卡缺货引发的供应链加码生产,一消一涨,数月内就将显卡从“空气”变成“实体”。

但很快,产品过剩去库存,就成为了后半年主旋律。对各大厂商,冷热交替过快过烈,着实一番冰火两重天体验。


以占大半壁江山的英伟达为例。

7月初大批产品跌至零售价,到中旬,高端款RTX 3090 Ti跌到了比零售价还便宜38%。

一个月后,英伟达颤颤巍巍预披露了Q2财报,不出所料,与消费级显卡直接挂钩的游戏业务塌方,营收环比跌掉44%,黄仁勋表示,随季度推进,该板块销售预测还将下调,去库存成为主要目标。

随后,就是官方打折,甚至搞出买30系显卡及配备的电脑,送59.99美元游戏的促销路数。


在这种动荡之下,英伟达生意越来越不好做,从财报上就能看到。

2022年5-7月,公司营收环比下跌了66%(non-GAAP),净利润环比下跌62%(non-GAAP)。后面一季的数据略有回涨,营收环比涨幅为16%(non-GAAP),但同比去年同期,跌幅还是很大,达到了55%(non-GAAP)。

这当中,英伟达还和最大合作伙伴EVGA闹掰了。

9月,EVGA单方面宣布,不会同英伟达下一代产品合作。


要知道,两者合作20多年,而且EVGA收入中80%来自英伟达合作的显卡。

根据EVGA的说法,英伟达的合作态度是两者关系恶化的关键。具体来说,英伟达一方沟通越来越少,新产品信息不同步,重要活动也不cue合作方,连价格调整也不事先同步。

比如RTX 3090 Ti显卡,英伟达给零售商报价比EVGA对外低了300美元,却不事先沟通,这下,合作方相当“被动”。

由于双方交恶时间点又赶在40系列显卡前一周,当时引发不小震动。

而几天后40系高调发布,售价最高12999人民币,很多消费者反馈却是“不值”二字,更别说4090电源接口熔化,又是一波不满。


图源:theverge

而更大的变动或许还没到来——英伟达的新对手也越来越多。

各路对手杀到老黄城下

最明显的一个动向就是,英特尔开抢GPU市场份额了

本月初,英特尔宣布将把图形芯片部门(AXG)一分为二,通过重组业务,更好地和英伟达、AMD竞争。

过去英特尔一直在主导CPU市场,GPU方面一直不是其发展核心。但在AI热浪下,英特尔也不得不重视起加速计算市场了。

其在官方声明表示:

图形芯片和加速计算是英特尔的关键增长引擎。我们正在改进我们的结构,以加速和扩大它们的影响,并通过向客户发出统一的声音来推动上市战略。

据JPR统计,今年第三季度独显市场中,英特尔占比4%。对比来看,AMD也仅有8%。

而更引人注目的变化,发生在国内

今年,摩尔线程一年内交出两块全功能GPU;芯动科技发布了“风华2号”、“风华1号”开始量产;面向数据中心的壁仞则发布了首款通用GPU芯片BR100,单芯片峰值算力达到PFLOPS级别;象帝先也发布了拥有100%自主知识产权的通用GPU……

脚步之快,已引发海外关注。

权威机构Jon Peddie Research在其对2022全球GPU市场的年度报告中写道:

在AI和高性能计算的驱动下,中国厂商正在向GPU市场发起进军。

由此也带动全球GPU厂商数量激增,独显厂商中,中国面孔就占据了一半席位。


当然这不是一夜之间发生的事。

在AI浪潮的驱动下,中国在数字化升级和人工智能行业融入的脚步上都十分迅速,国内对于GPU的需求空前高涨。

另一边,中国人工智能行业过度依赖英伟达显卡的情况也确实存在。这不光会造成资金上的压力,还容易出现“卡脖子”的情况。

在多种趋势和因素的影响下,早在20年下半年开始,资本市场上讲出了包括图形渲染在内的全功能GPU的新故事。壁仞科技、摩尔线程先后成立并大笔融资,芯动科技、兆芯等老牌芯片公司的独立显卡项目也在这附近官宣。

如今2年时间过去,已有多家厂商完成了流片或量产。

不可否认,当下或许还只是国内厂商迈出的第一步。从IP供应商处购买授权的方式,好处是能够减少投入加速回报,还能迅速积累经验、逐步建立起人才队伍。但在自研上后面还有很长的路要走。

而且如苹果、三星等攀登IP自研之路时,也并非一帆风顺。苹果分手3年后又回头重新与Imagination合作,据市场传闻有专利方面的原因。

因此,对于国内GPU自研,还需要更多耐心。

但无论如何,在全球显卡市场遭遇动荡的背景下,风险和机遇都随之而来。眼下,或许只是市场变革的开始了。

另外,最新消息显示,英伟达、AMD以及英特尔都已削减在台积电的订单。

参考链接:
[1]https://www.tomshardware.com/news/sales-of-desktop-graphics-cards-hit-20-year-low
[2]https://www.tomshardware.com/news/ai-and-tech-sovereignity-drive-number-of-gpu-developers-in-china

詹士 明敏 发自 凹非寺

来自|量子位

收起阅读 »

react的useState源码分析

前言简单说下为什么React选择函数式组件,主要是class组件比较冗余、生命周期函数写法不友好,骚写法多,functional组件更符合React编程思想等等等。更具体的可以拜读dan大神的blog。其中Function components capture...
继续阅读 »

前言

简单说下为什么React选择函数式组件,主要是class组件比较冗余、生命周期函数写法不友好,骚写法多,functional组件更符合React编程思想等等等。更具体的可以拜读dan大神的blog。其中Function components capture the rendered values这句十分精辟的道出函数式组件的优势。

但是在16.8之前react的函数式组件十分羸弱,基本只能作用于纯展示组件,主要因为缺少state和生命周期。本人曾经在hooks出来前负责过纯函数式的react项目,所有状态处理都必须在reducer中进行,所有副作用都在saga中执行,可以说是十分艰辛的经历了。在hooks出来后我在公司的一个小中台项目中使用,落地效果不错,代码量显著减少的同时提升了代码的可读性。因为通过custom hooks可以更好地剥离代码结构,不会像以前类组件那样在cDU等生命周期堆了一大堆逻辑,在命令式代码和声明式代码中有一个良性的边界。

useState在React中是怎么实现的

Hooks take some getting used to — and especially at the boundary of imperative and declarative code.

如果对hooks不太了解的可以先看看这篇文章:前情提要,十分简明的介绍了hooks的核心原理,但是我对useEffect,useRef等钩子的实现比较好奇,所以开始啃起了源码,下面我会结合源码介绍useState的原理。useState具体逻辑分成三部分:mountState,dispatch, updateState

hook的结构

首先的是hooks的结构,hooks是挂载在组件Fiber结点上memoizedState的

//hook的结构
export type Hook = {
 memoizedState: any, //上一次的state
 baseState: any,  //当前state
 baseUpdate: Update<any, any> | null,  // update func
 queue: UpdateQueue<any, any> | null,  //用于缓存多次action
 next: Hook | null, //链表
};

renderWithHooks

在reconciler中处理函数式组件的函数是renderWithHooks,其类型是:

renderWithHooks(
 current: Fiber | null, //当前的fiber结点
 workInProgress: Fiber,
 Component: any, //jsx中用<>调用的函数
 props: any,
 refOrContext: any,
 nextRenderExpirationTime: ExpirationTime, //需要在什么时候结束
): any

在renderWithHooks,核心流程如下:

//从memoizedState中取出hooks
nextCurrentHook = current !== null ? current.memoizedState : null;
//判断通过有没有hooks判断是mount还是update,两者的函数不同
ReactCurrentDispatcher.current =
     nextCurrentHook === null
       ? HooksDispatcherOnMount
      : HooksDispatcherOnUpdate;
//执行传入的type函数
let children = Component(props, refOrContext);
//执行完函数后的dispatcher变成只能调用context的
ReactCurrentDispatcher.current = ContextOnlyDispatcher;

return children;

useState构建时流程

mountState

在HooksDispatcherOnMount中,useState调用的是下面的mountState,作用是创建一个新的hook并使用默认值初始化并绑定其触发器,因为useState底层是useReducer,所以数组第二个值返回的是dispatch。

type BasicStateAction<S> = (S => S) | S;

function mountState<S>(
 initialState: (() => S) | S,
){
 const hook = mountWorkInProgressHook();
//如果入参是func则会调用,但是不提供参数,带参数的需要包一层
 if (typeof initialState === 'function') {
   initialState = initialState();
}
//上一个state和基本(当前)state都初始化
 hook.memoizedState = hook.baseState = initialState;
 const queue = (hook.queue = {
   last: null,
   dispatch: null,
   eagerReducer: basicStateReducer, // useState使用基础reducer
   eagerState: (initialState: any),
});
//返回触发器
 const dispatch: Dispatch<
   //useState底层是useReducer,所以type是BasicStateAction
(queue.dispatch = (dispatchAction.bind(
   null,
   //绑定当前fiber结点和queue
  ((currentlyRenderingFiber: any): Fiber),
   queue,
): any));
 return [hook.memoizedState, dispatch];
}

mountWorkInProgressHook

这个函数是mountState时调用的构建hook的方法,在初始化完毕后会连接到当前hook.next(如果有的话)

function mountWorkInProgressHook(): Hook {
 const hook: Hook = {
   memoizedState: null,
   baseState: null,
   queue: null,
   baseUpdate: null,
   next: null,
};
 if (workInProgressHook === null) {
   // 列表中的第一个hook
   firstWorkInProgressHook = workInProgressHook = hook;
} else {
   // 添加到列表的末尾
   workInProgressHook = workInProgressHook.next = hook;
}
 return workInProgressHook;
}

dispatch分发函数

在上面我们提到,useState底层是useReducer,所以返回的第二个参数是dispatch函数,其中的设计十分巧妙。

假设我们有以下代码:

相关参考视频讲解:进入学习

const [data, setData] = React.useState(0)
setData('first')
setData('second')
setData('third')


在第一次setData后, hooks的结构如上图


在第二次setData后, hooks的结构如上图


在第三次setData后, hooks的结构如上图


在正常情况下,是不会在dispatcher中触发reducer而是将action存入update中在updateState中再执行,但是如果在react没有重渲染需求的前提下是会提前计算state即eagerState。作为性能优化的一环。

function dispatchAction<S, A>(
 fiber: Fiber,
 queue: UpdateQueue<S, A>,
 action: A,
) {
 const alternate = fiber.alternate;
  {
   flushPassiveEffects();
   //获取当前时间并计算可用时间
   const currentTime = requestCurrentTime();
   const expirationTime = computeExpirationForFiber(currentTime, fiber);

   const update: Update<S, A> = {
     expirationTime,
     action,
     eagerReducer: null,
     eagerState: null,
     next: null,
  };
   //下面的代码就是为了构建queue.last是最新的更新,然后last.next开始是每一次的action
   // 取出last
   const last = queue.last;
   if (last === null) {
     // 自圆
     update.next = update;
  } else {
     const first = last.next;
     if (first !== null) {

       update.next = first;
    }
     last.next = update;
  }
   queue.last = update;

   if (
     fiber.expirationTime === NoWork &&
    (alternate === null || alternate.expirationTime === NoWork)
  ) {
     // 当前队列为空,我们可以在进入render阶段前提前计算出下一个状态。如果新的状态和当前状态相同,则可以退出重渲染
     const lastRenderedReducer = queue.lastRenderedReducer; // 上次更新完后的reducer
     if (lastRenderedReducer !== null) {
       let prevDispatcher;
       if (__DEV__) {
         prevDispatcher = ReactCurrentDispatcher.current;  // 暂存dispatcher
         ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnUpdateInDEV;
      }
       try {
         const currentState: S = (queue.lastRenderedState: any);
         // 计算下次state
         const eagerState = lastRenderedReducer(currentState, action);
         // 在update对象中存储预计算的完整状态和reducer,如果在进入render阶段前reducer没有变化那么可以服用eagerState而不用重新再次调用reducer
         update.eagerReducer = lastRenderedReducer;
         update.eagerState = eagerState;
         if (is(eagerState, currentState)) {
           // 在后续的时间中,如果这个组件因别的原因被重渲染且在那时reducer更变后,仍有可能重建这次更新
           return;
        }
      } catch (error) {
         // Suppress the error. It will throw again in the render phase.
      } finally {
         if (__DEV__) {
           ReactCurrentDispatcher.current = prevDispatcher;
        }
      }
    }
  }
   scheduleWork(fiber, expirationTime);
}
}

useState更新时流程

updateReducer

因为useState底层是useReducer,所以在更新时的流程(即重渲染组件后)是调用updateReducer的。

function updateState<S>(
 initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
 return updateReducer(basicStateReducer, (initialState: any));
}

所以其reducer十分简单

function basicStateReducer<S>(state: S, action: BasicStateAction<S>): S {
 return typeof action === 'function' ? action(state) : action;
}

我们先把复杂情况抛开,跑通updateReducer流程

function updateReducer(
reducer: (S, A) => S,
initialArg: I,
init?: I => S,
){
// 获取当前hook,queue
const hook = updateWorkInProgressHook();
const queue = hook.queue;

queue.lastRenderedReducer = reducer;

// action队列的最后一个更新
const last = queue.last;
// 最后一个更新是基本状态
const baseUpdate = hook.baseUpdate;
const baseState = hook.baseState;

// 找到第一个没处理的更新
let first;
if (baseUpdate !== null) {
  if (last !== null) {
    // 第一次更新时,队列是一个自圆queue.last.next = queue.first。当第一次update提交后,baseUpdate不再为空即可跳出队列
    last.next = null;
  }
  first = baseUpdate.next;
} else {
  first = last !== null ? last.next : null;
}
if (first !== null) {
  let newState = baseState;
  let newBaseState = null;
  let newBaseUpdate = null;
  let prevUpdate = baseUpdate;
  let update = first;
  let didSkip = false;
  do {
    const updateExpirationTime = update.expirationTime;
    if (updateExpirationTime < renderExpirationTime) {
      // 优先级不足,跳过这次更新,如果这是第一次跳过更新,上一个update/state是newBaseupdate/state
      if (!didSkip) {
        didSkip = true;
        newBaseUpdate = prevUpdate;
        newBaseState = newState;
      }
      // 更新优先级
      if (updateExpirationTime > remainingExpirationTime) {
        remainingExpirationTime = updateExpirationTime;
      }
    } else {
      // 处理更新
      if (update.eagerReducer === reducer) {
        // 如果更新被提前处理了且reducer跟当前reducer匹配,可以复用eagerState
        newState = ((update.eagerState: any): S);
      } else {
        // 循环调用reducer
        const action = update.action;
        newState = reducer(newState, action);
      }
    }
    prevUpdate = update;
    update = update.next;
  } while (update !== null && update !== first);

  if (!didSkip) {
    newBaseUpdate = prevUpdate;
    newBaseState = newState;
  }

  // 只有在前后state变了才会标记
  if (!is(newState, hook.memoizedState)) {
    markWorkInProgressReceivedUpdate();
  }
  hook.memoizedState = newState;
  hook.baseUpdate = newBaseUpdate;
  hook.baseState = newBaseState;
    queue.lastRenderedState = newState;
}

const dispatch: Dispatch<A> = (queue.dispatch: any);
return [hook.memoizedState, dispatch];
}
export function markWorkInProgressReceivedUpdate() {
didReceiveUpdate = true;
}

后记

作为系列的第一篇文章,我选择了最常用的hooks开始,抛开提前计算及与react-reconciler的互动,整个流程是十分清晰易懂的。mount的时候构建钩子,触发dispatch时按序插入update。updateState的时候再按序触发reducer。可以说就是一个简单的redux。

作者:flyzz177
来源:juejin.cn/post/7184636589564231735

收起阅读 »

徒手撸一个注解框架

运行时注解主要是通过反射来实现的,而编译时注解则是在编译期间帮助我们生成代码,所以编译时注解效率高,但是实现起来复杂一点,运行时注解效率较低,但是实现起来简单。 首先来看下运行时注解怎么实现的吧。1.运行时注解1.1定义注解首先定义两个运行时注解,其中Rete...
继续阅读 »

运行时注解主要是通过反射来实现的,而编译时注解则是在编译期间帮助我们生成代码,所以编译时注解效率高,但是实现起来复杂一点,运行时注解效率较低,但是实现起来简单。 首先来看下运行时注解怎么实现的吧。

1.运行时注解

1.1定义注解

首先定义两个运行时注解,其中Retention标明此注解在运行时生效,Target标明此注解的程序元范围,下面两个示例RuntimeBindView用于描述成员变量和类,成员变量绑定view,类绑定layout;RuntimeBindClick用于描述方法,让指定的view绑定click事件。

@Retention(RetentionPolicy.RUNTIME)//运行时生效
@Target({ElementType.FIELD,ElementType.TYPE})//描述变量和类
public @interface RuntimeBindView {
   int value() default View.NO_ID;
}

@Retention(RetentionPolicy.RUNTIME)//运行时生效
@Target(ElementType.METHOD)//描述方法
public @interface RuntimeBindClick {
   int[] value();
}

1.2反射实现

以下代码是用反射实现的注解功能,其中ClassInfo是一个能解析处类的各种成员和方法的工具类, 源码见github.com/huangbei199… 其实逻辑很简单,就是从Activity里面取出指定的注解,然后再调用相应的方法,如取出RuntimeBindView描述类的注解,然后得到这个注解的返回值,接着调用activity的setContentView将layout的id设置进去就可以了。

public static void bindId(Activity obj){
   ClassInfo clsInfo = new ClassInfo(obj.getClass());
   //处理类
   if(obj.getClass().isAnnotationPresent(RuntimeBindView.class)) {
       RuntimeBindView bindView = (RuntimeBindView)clsInfo.getClassAnnotation(RuntimeBindView.class);
       int id = bindView.value();
       clsInfo.executeMethod(clsInfo.getMethod("setContentView",int.class),obj,id);
  }

   //处理类成员
   for(Field field : clsInfo.getFields()){
       if(field.isAnnotationPresent(RuntimeBindView.class)){
           RuntimeBindView bindView = field.getAnnotation(RuntimeBindView.class);
           int id = bindView.value();
           Object view = clsInfo.executeMethod(clsInfo.getMethod("findViewById",int.class),obj,id);
           clsInfo.setField(field,obj,view);
      }
  }

   //处理点击事件
   for (Method method : clsInfo.getMethods()) {
       if (method.isAnnotationPresent(RuntimeBindClick.class)) {
           int[] values = method.getAnnotation(RuntimeBindClick.class).value();
           for (int id : values) {
               View view = (View) clsInfo.executeMethod(clsInfo.getMethod("findViewById", int.class), obj, id);
               view.setOnClickListener(v -> {
                   try {
                       method.invoke(obj, v);
                  } catch (Exception e) {
                       e.printStackTrace();
                  }
              });
          }
      }
  }
}

1.3使用

如下所示,将我们定义好的注解写到相应的位置,然后调用BindApi的bind函数,就可以了。很简单吧

@RuntimeBindView(R.layout.first)//类
public class MainActivity extends AppCompatActivity {

   @RuntimeBindView(R.id.jump)//成员
   public Button jump;

   @Override
   protected void onCreate(Bundle savedInstanceState) {
       super.onCreate(savedInstanceState);
       BindApi.bindId(this);//调用反射
  }

   @RuntimeBindClick({R.id.jump,R.id.jump2})//方法
   public void onClick(View view){
       Intent intent = new Intent(this,SecondActivity.class);
       startActivity(intent);
  }
}

2.编译时注解

编译时注解就是在编译期间帮你自动生成代码,其实原理也不难。

2.1定义注解

我们可以看到,编译时注解定义的时候Retention的值和运行时注解不同。

@Retention(RetentionPolicy.CLASS)//编译时生效
@Target({ElementType.FIELD,ElementType.TYPE})//描述变量和类
public @interface CompilerBindView {
   int value() default -1;
}

@Retention(RetentionPolicy.CLASS)//编译时生效
@Target(ElementType.METHOD)//描述方法
public @interface CompilerBindClick {
   int[] value();
}

2.2根据注解生成代码

1)准备工作

首先我们要新建一个java的lib库,因为接下需要继承AbstractProcessor类,这个类Android里面没有。


然后我们需要引入两个包,javapoet是帮助我们生成代码的包,auto-service是帮助我们自动生成META-INF等信息,这样我们编译的时候就可以执行我们自定义的processor了。

apply plugin: 'java-library'

dependencies {
   implementation fileTree(dir: 'libs', include: ['*.jar'])
   api 'com.squareup:javapoet:1.9.0'
   api 'com.google.auto.service:auto-service:1.0-rc2'
}


sourceCompatibility = "1.8"
targetCompatibility = "1.8"

2)继承AbstractProcessor

如下所示,我们需要自定义一个类继承子AbstractProcessor并复写他的方法,并加上AutoService的注解。 ClassElementsInfo是用来存储类信息的类,这一步先暂时不用管,下一步会详细说明。 其实从函数的名称就可以看出是什么意思,init初始化,getSupportedSourceVersion限定所支持的jdk版本,getSupportedAnnotationTypes需要处理的注解,process我们可以在这个函数里面拿到拥有我们需要处理注解的类,并生成相应的代码。


3)搜集注解

首先我们看下ClassElementsInfo这个类,也就是我们需要搜集的信息。 TypeElement为类元素,VariableElement为成员元素,ExecutableElement为方法元素,从中我们可以获取到各种注解信息。 classSuffix为前缀,例如原始类为MainActivity,注解生成的类名就为MainActivity+classSuffix

public class ClassElementsInfo {

   //类
   public TypeElement mTypeElement;
   public int value;
   public String packageName;

   //成员,key为id
   public Map<Integer,VariableElement> mVariableElements = new HashMap<>();

   //方法,key为id
   public Map<Integer,ExecutableElement> mExecutableElements = new HashMap<>();

   //后缀
   public static final String classSuffix = "proxy";

   public String getProxyClassFullName() {
       return mTypeElement.getQualifiedName().toString() + classSuffix;
  }
   public String getClassName() {
       return mTypeElement.getSimpleName().toString() + classSuffix;
  }
  ......
}

然后我们就可以开始搜集注解信息了, 如下所示,按照注解类型一个一个的搜集,可以通过roundEnvironment.getElementsAnnotatedWith函数拿到注解元素,拿到之后再根据注解元素的类型分别填充到ClassElementsInfo当中。 其中ClassElementsInfo是存储在Map当中,key是String是classPath。

private void collection(RoundEnvironment roundEnvironment){
   //1.搜集compileBindView注解
   Set<? extends Element> set = roundEnvironment.getElementsAnnotatedWith(CompilerBindView.class);
   for(Element element : set){
       //1.1搜集类的注解
       if(element.getKind() == ElementKind.CLASS){
           TypeElement typeElement = (TypeElement)element;
           String classPath = typeElement.getQualifiedName().toString();
           String className = typeElement.getSimpleName().toString();
           String packageName = mElementUtils.getPackageOf(typeElement).getQualifiedName().toString();
           CompilerBindView bindView = element.getAnnotation(CompilerBindView.class);
           if(bindView != null){
               ClassElementsInfo info = classElementsInfoMap.get(classPath);
               if(info == null){
                   info = new ClassElementsInfo();
                   classElementsInfoMap.put(classPath,info);
              }
               info.packageName = packageName;
               info.value = bindView.value();
               info.mTypeElement = typeElement;
          }
      }
       //1.2搜集成员的注解
       else if(element.getKind() == ElementKind.FIELD){
           VariableElement variableElement = (VariableElement) element;
           String classPath = ((TypeElement)element.getEnclosingElement()).getQualifiedName().toString();
           CompilerBindView bindView = variableElement.getAnnotation(CompilerBindView.class);
           if(bindView != null){
               ClassElementsInfo info = classElementsInfoMap.get(classPath);
               if(info == null){
                   info = new ClassElementsInfo();
                   classElementsInfoMap.put(classPath,info);
              }
               info.mVariableElements.put(bindView.value(),variableElement);
          }
      }
  }

   //2.搜集compileBindClick注解
   Set<? extends Element> set1 = roundEnvironment.getElementsAnnotatedWith(CompilerBindClick.class);
   for(Element element : set1){
       if(element.getKind() == ElementKind.METHOD){
           ExecutableElement executableElement = (ExecutableElement) element;
           String classPath = ((TypeElement)element.getEnclosingElement()).getQualifiedName().toString();
           CompilerBindClick bindClick = executableElement.getAnnotation(CompilerBindClick.class);
           if(bindClick != null){
               ClassElementsInfo info = classElementsInfoMap.get(classPath);
               if(info == null){
                   info = new ClassElementsInfo();
                   classElementsInfoMap.put(classPath,info);
              }
               int[] values = bindClick.value();
               for(int value : values) {
                   info.mExecutableElements.put(value,executableElement);
              }
          }
      }
  }
}

4)生成代码

如下所示使用javapoet生成代码,使用起来并不复杂。

public class ClassElementsInfo {
  ......
   public String generateJavaCode() {
       ClassName viewClass = ClassName.get("android.view","View");
       ClassName clickClass = ClassName.get("android.view","View.OnClickListener");
       ClassName keepClass = ClassName.get("android.support.annotation","Keep");
       ClassName typeClass = ClassName.get(mTypeElement.getQualifiedName().toString().replace("."+mTypeElement.getSimpleName().toString(),""),mTypeElement.getSimpleName().toString());

       //构造方法
       MethodSpec.Builder builder = MethodSpec.constructorBuilder()
              .addModifiers(Modifier.PUBLIC)
              .addParameter(typeClass,"host",Modifier.FINAL);
       if(value > 0){
           builder.addStatement("host.setContentView($L)",value);
      }

       //成员
       Iterator<Map.Entry<Integer,VariableElement>> iterator = mVariableElements.entrySet().iterator();
       while(iterator.hasNext()){
           Map.Entry<Integer,VariableElement> entry = iterator.next();
           Integer key = entry.getKey();
           VariableElement value = entry.getValue();
           String name = value.getSimpleName().toString();
           String type = value.asType().toString();
           builder.addStatement("host.$L=($L)host.findViewById($L)",name,type,key);
      }

       //方法
       Iterator<Map.Entry<Integer,ExecutableElement>> iterator1 = mExecutableElements.entrySet().iterator();
       while(iterator1.hasNext()){
           Map.Entry<Integer,ExecutableElement> entry = iterator1.next();
           Integer key = entry.getKey();
           ExecutableElement value = entry.getValue();
           String name = value.getSimpleName().toString();
           MethodSpec onClick = MethodSpec.methodBuilder("onClick")
                  .addAnnotation(Override.class)
                  .addModifiers(Modifier.PUBLIC)
                  .addParameter(viewClass,"view")
                  .addStatement("host.$L(host.findViewById($L))",value.getSimpleName().toString(),key)
                  .returns(void.class)
                  .build();
           //构造匿名内部类
           TypeSpec clickListener = TypeSpec.anonymousClassBuilder("")
                  .addSuperinterface(clickClass)
                  .addMethod(onClick)
                  .build();
           builder.addStatement("host.findViewById($L).setOnClickListener($L)",key,clickListener);
      }

       TypeSpec typeSpec = TypeSpec.classBuilder(getClassName())
              .addModifiers(Modifier.PUBLIC)
              .addAnnotation(keepClass)
              .addMethod(builder.build())
              .build();
       JavaFile javaFile = JavaFile.builder(packageName,typeSpec).build();
       return javaFile.toString();
  }
}

最终使用了注解之后生成的代码如下

package com.android.hdemo;

import android.support.annotation.Keep;
import android.view.View;
import android.view.View.OnClickListener;
import java.lang.Override;

@Keep
public class MainActivityproxy {
 public MainActivityproxy(final MainActivity host) {
   host.setContentView(2131296284);
   host.jump=(android.widget.Button)host.findViewById(2131165257);
   host.findViewById(2131165258).setOnClickListener(new View.OnClickListener() {
     @Override
     public void onClick(View view) {
       host.onClick(host.findViewById(2131165258));
    }
  });
   host.findViewById(2131165257).setOnClickListener(new View.OnClickListener() {
     @Override
     public void onClick(View view) {
       host.onClick(host.findViewById(2131165257));
    }
  });
}
}

5)让注解生效

我们生成了代码之后,还需要让原始的类去调用我们生成的代码

public class BindHelper {

   static final Map<Class<?>,Constructor<?>> Bindings = new HashMap<>();

   public static void inject(Activity activity){
       String classFullName = activity.getClass().getName() + ClassElementsInfo.classSuffix;
       try{
           Constructor constructor = Bindings.get(activity.getClass());
           if(constructor == null){
               Class proxy = Class.forName(classFullName);
               constructor = proxy.getDeclaredConstructor(activity.getClass());
               Bindings.put(activity.getClass(),constructor);
          }
           constructor.setAccessible(true);
           constructor.newInstance(activity);
      }catch (Exception e){
           e.printStackTrace();
      }
  }
}

2.3调试

首先在gradle.properties里面加入如下的代码

android.enableSeparateAnnotationProcessing = true
org.gradle.daemon=true
org.gradle.jvmargs=-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=8888

然后点击Edit Configurations


新建一个remote


然后填写相关的参数,127.0.0.1表示本机,port与刚才gradle.properties里面填写的保持一致,然后点击ok


然后将Select Run/Debug Configuration选项调整到刚才新建的Configuration上,然后点击Build--Rebuild Project,就可以开始调试了。


2.4使用

如下所示为原始的类

@CompilerBindView(R.layout.first)
public class MainActivity extends AppCompatActivity {

   @CompilerBindView(R.id.jump)
   public Button jump;

   @Override
   protected void onCreate(Bundle savedInstanceState) {
       super.onCreate(savedInstanceState);
       BindHelper.inject(this);
  }

   @CompilerBindClick({R.id.jump,R.id.jump2})
   public void onClick(View view){
       Intent intent = new Intent(this,SecondActivity.class);
       startActivity(intent);
  }
}

以下为生成的类

package com.android.hdemo;

import android.support.annotation.Keep;
import android.view.View;
import android.view.View.OnClickListener;
import java.lang.Override;

@Keep
public class MainActivityproxy {
 public MainActivityproxy(final MainActivity host) {
   host.setContentView(2131296284);
   host.jump=(android.widget.Button)host.findViewById(2131165257);
   host.findViewById(2131165258).setOnClickListener(new View.OnClickListener() {
     @Override
     public void onClick(View view) {
       host.onClick(host.findViewById(2131165258));
    }
  });
   host.findViewById(2131165257).setOnClickListener(new View.OnClickListener() {
     @Override
     public void onClick(View view) {
       host.onClick(host.findViewById(2131165257));
    }
  });
}
}

3.总结

注解框架看起来很高大上,其实弄懂之后也不难,都是一个套路。

作者:我是黄大仙
来源:juejin.cn/post/7180166142093656120

收起阅读 »

这可能是中国最“恨”地铁的高校,甚至写了篇论文反对地铁经过

常坐北京地铁4号线的人可能听过这样一句调侃 “坐4号线的学生谁先下车谁就输了,坚持到最后的都是学霸中的学霸。”因为这一路会经过十多所高校,全都是名校。 虽然是个玩笑话,但很多地方的地铁都喜爱用高校名做站名。比如2022年11月28日,深圳地铁6号线支线正式通车...
继续阅读 »


常坐北京地铁4号线的人可能听过这样一句调侃 “坐4号线的学生谁先下车谁就输了,坚持到最后的都是学霸中的学霸。”

因为这一路会经过十多所高校,全都是名校。


虽然是个玩笑话,但很多地方的地铁都喜爱用高校名做站名。

比如2022年11月28日,深圳地铁6号线支线正式通车,其中,“深理工站”就以正在筹建的深圳理工大学来作为站名。

另一方面,大多数的高校也会有意的去争夺地铁站,一方面是方便学生出行,另一方面,地铁站命名也是一次对学校的宣传。


甚至在2021年,西安还曾发生过两高校掐架“争夺”地铁站命名的事,当时,西安地铁官网发布了14号线相关站点初步命名信息。

其中在西安北郊大学城的一站,暂被命名为“西安工业大学”。此站距离西安工业大学正门,陕西科技大学南门都非常近,仅200米左右。


这立刻引起了陕西科技大学的强烈不满。为了争取命名权,陕西科技大学先后两次和西安工业大学的校领导进行了沟通,并提出一些条件。

因为两所高校谈崩了,陕西科技大学要求旗下幼儿园方3月24日起不再接受西安工业大学子女入托。

最后,在被媒体和舆论痛批后,两所高校握手言和,解决了幼儿园不让孩子入园事件,同时,西安地铁14号线也更改了地铁站名,修改为 “西安工大·武德路站”


虽然只是一件小事,但高校间争夺地铁站命名确实不是第一次,有时候,地铁方面也会一碗水端平,把大家的校名都列上去。

比如 西工程大●西科大(临潼校区)站、


南医大●江苏经贸学院站 等。


但凡事都有例外,也有那么一些学校为了让地铁“远离”自己,还有学者专门写了论文来论证理由。

这可能真是中国最“恨”地铁的一所高校。

1 地铁和北大那些事

2018年,北京地铁4号线列车在13.5米深的地下呼啸而过,100米外北京大学信息科学技术学院大楼中,一台电子显微镜内“仿佛刮起了一阵飓风”。

用肉眼看,这台1米多高的白色金属镜筒安稳立在桌上。将它调至最高精度却会发现,显示屏上的黑白图像长了“毛刺”,原本纤毫毕现的原子图案因为振动变得模糊不清。

在北大校园内,因地铁运行受到影响的精密仪器,远不止这台价值数百万元的电镜。4号线开通时,北大有价值11亿元的精密仪器,其中4亿元的仪器受到影响。


地图上与地铁线路相邻的北京大学校园

原因很简单——交通微振动。**虽然这种振动几乎不易察觉,但对高校内的精密仪器来说,地铁几乎意味着“灾难性打击”。**

北大环境振动监测与评估实验室主任雷军,曾和学生拎着地震仪,测量过北京多条地铁线路,他们发现,在精密仪器更敏感的低频范围内,离地铁100米内地表振动强度比没有列车通过时高了30~100倍。

许多仪器的使用者并不知晓地铁振动会影响仪器。曾有同事找到雷军,抱怨实验室一台测量岩石年龄的精密仪器突然不正常了。这位老师叫来厂家,左调右调,愣是修不好,厂家也摸不着头脑。

事实上,并非仪器坏了,而是地铁4号线开通后,振动干扰了仪器。

实际上,当年在地铁4号线线路规划出来后,北大就曾和地铁公司为两个方案反复争论。

● 北大拒绝4号线地铁经过,想让地铁改线。

● 地铁公司表示,北大也可以整个搬走。

直至最后一次研讨会,双方仍僵持不下。那次会议由北京市一位副市长主持,邀请了一位院士和多位北大校外专家。

最后大家采取了一个折中方案,4号线经过北大的789米轨道段,将采用世界上最先进的轨道减振技术,也就是在钢轨下铺设钢弹簧浮置板。这种浮置板由一家德国公司发明,上面是约50厘米厚的钢筋混凝土板,下面是支撑着的钢弹簧,能将列车的振动与道床隔离。

最后北大做了妥协,这才有了后来的【北京大学东门站】


图片来源:北京大学新闻中心

不过,4号线真的开通后,北大学者发现虽然轨道减振有用,但也不算完全有用,很多精密仪器还是会受到干扰。

最后,北大自己一合计,决定在受地铁振动影响最小的西南边的校医院旧址那盖综合科研楼,将部分受影响的仪器搬过来。在此之前,很多科研人为了能正常做实验,只能选择在地铁停运的深夜开始运行精密仪器。

谁知道一波未平一波又起,北大综合科研楼地基刚打好,正在施工时,北京地铁16号线的规划出来了,好家伙,地铁16号线将绕经北大西门,离综合科研楼仅200米。


这一次可把北大气坏了,由于校内精密仪器已无处可挪,北大开始了强烈抗议。

后面才知道,因为地铁4号线的成功,地铁方面以为减振成功了,北大也没有把自己准备盖科研楼挪仪器的事告诉地铁方,这才有了擦着北大西边而过的地铁16号线规划。

这一次,北大再次重拳出击,首先论文论证是不能少的。


北京市为此还拨出上千万元专项资金,让大家拿出一个合理的解决方案,包括地铁轨道减振、重新设计综合科研楼,考虑在低层装减振平台等等。

最后,双方谁也不愿意退让的时候,项目戛然而止。据说北大领导和一位市领导在某个会议碰面,双方握手言好。地铁16号退后一步,往西绕开300多米,甩掉两座车站,北大也不再提要求。


就这样,这场北大和地铁的交锋,双方鸣鼓收兵。

2 高校与地铁的对抗

不过,高校和地铁的对抗,北大也绝不是个例。

与北大相似的还有清华,但是在拒绝这件事上,清华更强硬了一点。

早在1955年,清华大学就曾让铁路改过线。那时候,京张铁路位于清华校园同侧,振动曾严重干扰科研,在清华的争取下,铁路线向东迁了800米。

后面,地铁15号线原计划下穿清华大学,遭清华极力反对。最终,15号线只进入清华校内120米,没与4号线相连,形成换乘站。


受地铁影响的高校还有复旦大学、南京大学、中国科学院、首都医科大学、郑州大学医学院等。

不过并不是所有的高校都拥有强大的谈判能力。要知道,一个地铁线路方案如果已落成,再挪动位置几乎是不可能的。

因此,有的985高校没太多考虑,直接在同意文件上盖了章。有的高校遭遇了损失,却不愿意公开化。

中国电子工程设计院有限公司曾表示,给复旦大学、南京大学等多个受地铁影响的高校做过减振方案。

没想到一个小小的振动,也能引起如此大的漩涡,这可能就是“地铁蝴蝶效应”吧~

本文选自募格学术。参考资料:人民资讯、中科院深圳理工大学、潇湘晨报、人民日报等。

收起阅读 »

Android 字节码插桩全流程解析

1 准备工作 但凡涉及到gradle开发,我一般都是会在buildSrc文件夹下进行,还有没有伙伴不太了解buildSrc的,其实buildSrc是Android中默认的插件工程,在gradle编译的时候,会编译这个项目并配置到classpath下。这样的话在...
继续阅读 »

1 准备工作


但凡涉及到gradle开发,我一般都是会在buildSrc文件夹下进行,还有没有伙伴不太了解buildSrc的,其实buildSrc是Android中默认的插件工程,在gradle编译的时候,会编译这个项目并配置到classpath下。这样的话在buildSrc中创建的插件,每个项目都可以引入。


image.png
在buildSrc中可以创建groovy目录(如果对groovy或者kotlin了解),也可以创建java目录,对于插件开发个人更便向使用groovy,因为更贴近gradle。


1.1 创建插件


创建插件,需要实现Plugin接口,在引入这个插件后,项目编译的时候,就会执行apply方法。


class ASMPlugin implements Plugin<Project>{

@Override
void apply(Project project) {
def ext = project.extensions.getByType(AppExtension)
if (ext != null){
ext.registerTransform(new ASMTransform())
}
}
}

在apply方法中,可以执行自定义的Task,也可以执行自定义的Transform(其实也可以看做是一种特殊的Task),这里我们自定义了插桩相关的Transform。


1.2 创建Transform


什么是Transform呢?就是在class文件打包生成dex文件的过程中,对class字节码做处理,最终生成新的dex文件,那么有什么方式能够对字节码操作呢?ASM是一种方式,使用Javassist也可以织入字节码。


class ASMTransform extends Transform {

@Override
String getName() {
return "ASMTransform"
}

@Override
Set<QualifiedContent.ContentType> getInputTypes() {
return TransformManager.CONTENT_CLASS
}

@Override
Set<QualifiedContent.Scope> getScopes() {
return TransformManager.SCOPE_FULL_PROJECT
}

@Override
boolean isIncremental() {
return false
}

@Override
void transform(Context context, Collection<TransformInput> inputs, Collection<TransformInput> referencedInputs, TransformOutputProvider outputProvider, boolean isIncremental) throws IOException, TransformException, InterruptedException {
inputs.each { input ->
input.directoryInputs.each { dic ->
/**这里会拿到两个路径,分别是java代码编译后的javac/debug/classes,以及kotlin代码编译后的 tmp/kotlin-classes/debug */
println("dic path == >${dic.file.path}")
/**所有的class文件的根路径,我们已经拿到了,接下来就是分析这些文件夹下的class文件*/
findAllClass(dic.file)
/**这里一定不能忘记写*/
def dest = outputProvider.getContentLocation(dic.name, dic.contentTypes, dic.scopes, Format.DIRECTORY)
FileUtils.copyDirectory(dic.file, dest)
}
input.jarInputs.each { jar ->
/**这里也一定不能忘记写*/
def dest = outputProvider.getContentLocation(jar.name,jar.contentTypes,jar.scopes,Format.JAR)
FileUtils.copyFile(jar.file,dest)
}
}
}

/**
* 查找class文件
* @param file 可能是文件也可能是文件夹
*/
private void findAllClass(File file) {
if (file.isDirectory()) {
file.listFiles().each {
findAllClass(it)
}
} else {
modifyClass(file)
}
}

/**
* 进行字节码插桩
* @param file 需要插桩的字节码文件
*/
private void modifyClass(File file) {
println("最终的class文件 ==> ${file.absolutePath}")
/**如果不是.class文件,抛弃*/
if (!file.absolutePath.endsWith(".class")) {
return
}

/**BuildConfig.class文件以及R文件都抛弃*/
if (file.absolutePath.contains("BuildConfig.class") || file.absolutePath.contains("R")) {
return
}

doASM(file)
}

/**
* 进行ASM字节码插桩
* @param file 需要插桩的class文件
*/
private void doASM(File file) {
def fis = new FileInputStream(file)
def cr = new ClassReader(fis)
def cw = new ClassWriter(ClassWriter.COMPUTE_MAXS)
cr.accept(new ASMClassVisitor(Opcodes.ASM9, cw), ClassReader.SKIP_FRAMES | ClassReader.SKIP_DEBUG)
/**重新覆盖*/
def bytes = cw.toByteArray()
def fos = new java.io.FileOutputStream(file.absolutePath)
fos.write(bytes)
fos.flush()
fos.close()
}
}

如果想要使用Transform,那么需要引入transform-api,其实在transform 1.5之后gradle就支持Transform了。


implementation 'com.android.tools.build:transform-api:1.5.0'

当执行Transform任务的时候,最终会执行到transform方法,在这个方法中可以获取TransformInput的输入,主要包括两种:文件夹和Jar包;对于Jar包,我们不需要处理,只需要拷贝到目标文件夹下即可。


对于文件夹我们是需要处理的,因为这里包含了我们要处理的.class文件,对于Java编译后的class文件是存在javac/debug/classes根文件夹下,对于kotlin编译后的class文件是存在temp/classes根文件下。


所以在整个编译的过程中,只要是.class文件都会执行doASM这个方法,在这个方法中就是我们在上节提到的对于字节码的插桩。


1.3 ASM字节码插桩


class ASMClassVisitor extends ClassVisitor {

ASMClassVisitor(int api) {
super(api)
}

@Override
MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) {
println("visitMethod==>$name")
/**所有的方法都会在ASMMethodVisitor中插入字节码*/
def method = super.visitMethod(access, name, descriptor, signature, exceptions)
return new ASMMethodVisitor(api, method, access, name, descriptor)
}

ASMClassVisitor(int api, ClassVisitor classVisitor) {
super(api, classVisitor)
}

@Override
FieldVisitor visitField(int access, String name, String descriptor, String signature, Object value) {
return super.visitField(access, name, descriptor, signature, value)
}

@Override
AnnotationVisitor visitAnnotation(String descriptor, boolean visible) {
return super.visitAnnotation(descriptor, visible)
}
}

class ASMMethodVisitor extends AdviceAdapter {

private def methodName
/**
* Constructs a new {@link AdviceAdapter}.
*
* @param api the ASM API version implemented by this visitor. Must be one of {@link
* Opcodes#ASM4}, {@link Opcodes#ASM5}, {@link Opcodes#ASM6} or {@link Opcodes#ASM7}.
* @param methodVisitor the method visitor to which this adapter delegates calls.
* @param access the method's access flags (see {@link Opcodes}).
* @param name the method's name.
* @param descriptor the method's descriptor (see {@link Type Type}).
*/
protected ASMMethodVisitor(int api, MethodVisitor methodVisitor, int access, String name, String descriptor) {
super(api, methodVisitor, access, name, descriptor)
this.methodName = name
}

@Override
protected void onMethodEnter() {
super.onMethodEnter()
visitFieldInsn(GETSTATIC,
"com/lay/learn/base_net/LoggUtils",
"INSTANCE",
"Lcom/lay/learn/base_net/LoggUtils;")
visitMethodInsn(INVOKEVIRTUAL, "com/lay/learn/base_net/LoggUtils", "start", "()V", false)
}

@Override
protected void onMethodExit(int opcode) {
super.onMethodExit(opcode)
visitFieldInsn(GETSTATIC,
"com/lay/learn/base_net/LoggUtils",
"INSTANCE",
"Lcom/lay/learn/base_net/LoggUtils;")
visitLdcInsn(methodName)
visitMethodInsn(INVOKEVIRTUAL, "com/lay/learn/base_net/LoggUtils", "end", "(Ljava/lang/String;)V",false)
}
}

这里就不再细说了,贴上源码大家可以借鉴一下哈。


最终在编译的过程中,对所有的方法插入了我们自己的耗时计算逻辑,当运行之后


class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
}
}

虽然我们没有显示地在MainActivity的onCreate中插入耗时检测代码,但是在控制台中我们可以看到,onCreate方法耗时180ms


2022-12-28 19:50:19.243 13665-13665/com.lay.learn.asm E/LoggUtils: <init> 耗时==>0
2022-12-28 19:50:19.458 13665-13665/com.lay.learn.asm E/LoggUtils: onCreate 耗时==>180

1.4 插件配置


当我们完成一个插件之后,需要在META-INF文件夹下创建一个gradle-plugins文件夹,并在properties文件中声明插件全类名。


image.png


implementation-class=com.lay.asm.ASMPlugin

要注意插件id就是properties文件的名字。


这样只要某个工程中需要字节码插桩,只需要引入asm_plugin这个插件即可在编译的时候扫描整个工程。


plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
id 'asm_plugin'
}

附上buildSrc中的gradle配置文件


plugins{
id 'groovy'
}

repositories {
google()
mavenCentral()
}

dependencies {
implementation gradleApi()
implementation localGroovy()
implementation 'org.apache.commons:commons-io:1.3.2'
implementation "com.android.tools.build:gradle:7.0.3"
implementation 'com.android.tools.build:transform-api:1.5.0'
implementation 'org.ow2.asm:asm:9.1'
implementation 'org.ow2.asm:asm-util:9.1'
implementation 'org.ow2.asm:asm-commons:9.1'
}

java {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}

最后需要说一点就是,在Transform任务执行时,一定要将文件夹或者jar包传递到下一级的Transform中,否则会导致apk打包时缺少文件导致apk无法运行


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

Kotlin 惰性集合操作-序列 Sequence

集合操作函数 和 序列 在了解 Kotlin 惰性集合之前,先看一下 Koltin 标准库中的一些集合操作函数。 定义一个数据模型 Person 和 Book 类: data class Person(val name: String, val age: In...
继续阅读 »

集合操作函数 和 序列


在了解 Kotlin 惰性集合之前,先看一下 Koltin 标准库中的一些集合操作函数。


定义一个数据模型 Person 和 Book 类:


data class Person(val name: String, val age: Int) 

data class Book(val title: String, val authors: List<String>)

filter 和 map 操作:


    val people = listOf<Person>(
Person("xiaowang", 30),
Person("xiaozhang", 32),
Person("xiaoli", 28)
)
//大于 30 岁的人的名字集合列表
people.filter { it.age >= 30 }.map(Person::name)

count 操作:


   val people = listOf<Person>(
Person("xiaowang", 30),
Person("xiaozhang", 32),
Person("xiaoli", 28)
)
//小于 30 岁人的个数
people.count { it.age < 30 }

flatmap 操作:


      val books = listOf<Book>(
Book("Java 语言程序设计", arrayListOf("xiaowang", "xiaozhang")),
Book("Kotlin 语言程序设计", arrayListOf("xiaoli", "xiaomao")),
)
// 所有书的名字集合列表
books.flatMap { it.authors }.toList()

在上面这些函数,每做一步操作,都会创建中间集合,也就是每一步的中间结果都被临时存储在一个临时集合中


filter 函数源码:


public inline fun <T> Iterable<T>.filter(predicate: (T) -> Boolean): List<T> {
//创建一个新的集合列表
return filterTo(ArrayList<T>(), predicate)
}

public inline fun <T, C : MutableCollection<in T>> Iterable<T>.filterTo(destination: C, predicate: (T) -> Boolean): C {
for (element in this) if (predicate(element)) destination.add(element)
return destination
}

map 函数源码:


public inline fun <T, R> Iterable<T>.map(transform: (T) -> R): List<R> {
//创建一个新的集合列表
return mapTo(ArrayList<R>(collectionSizeOrDefault(10)), transform)
}

public inline fun <T, R, C : MutableCollection<in R>> Iterable<T>.mapTo(destination: C, transform: (T) -> R): C {
for (item in this)
destination.add(transform(item))
return destination
}

如果被操作的元素过多,假设 people 或 books 超过 50个、100个,那么 函数链式调用 如:fliter{}.map{} 就会变得低效,且浪费内存。


Kotlin 为解决上面这种问题,提供了惰性集合操作 Sequence 接口。这个接口表示一个可以逐个列举的元素列表。Sequence 只提供了一个 方法, iterator,用来从序列中获取值。


public interface Sequence<out T> {
/**
* Returns an [Iterator] that returns the values from the sequence.
*
* Throws an exception if the sequence is constrained to be iterated once and `iterator` is invoked the second time.
*/
public operator fun iterator(): Iterator<T>
}

public inline fun <T> Sequence(crossinline iterator: () -> Iterator<T>): Sequence<T> = object : Sequence<T> {
override fun iterator(): Iterator<T> = iterator()
}

/**
* Creates a sequence that returns all elements from this iterator. The sequence is constrained to be iterated only once.
*
* @sample samples.collections.Sequences.Building.sequenceFromIterator
*/
public fun <T> Iterator<T>.asSequence(): Sequence<T> = Sequence { this }.constrainOnce()

序列中的元素求值是惰性的。因此,可以使用序列更高效地对集合元素执行链式操作,而不需要创建额外的集合来保存过程中产生的中间结果。关于这个惰性是怎么来的,后面再详细解释。


可以调用扩展函数 asSequence 把任意集合转换成序列,调用 toList 来做反向的转换。


 val people = listOf<Person>(
Person("xiaowang", 30),
Person("xiaozhang", 32),
Person("xiaoli", 28)
)
people.asSequence().filter { it.age >= 30 }.map(Person::name).toList()

 val books = listOf<Book>(
Book("Java 语言程序设计", arrayListOf("xiaowang", "xiaozhang")),
Book("Kotlin 语言程序设计", arrayListOf("xiaoli", "xiaomao")),
)
books.asSequence().flatMap { it.authors }.toList()

序列中间和末端操作


序列操作分为两类:中间的和末端的。一次中间操作返回的是另一个序列,这个新序列知道如何变换原始序列中的元素。而一次末端返回的是一个结果,这个结果可能是集合、元素、数字,或者其他从初始集合的变换序列中获取的任意对象。



中间操作始终是惰性的。


下面从例子来理解这个惰性


listOf(1, 2, 3, 4).asSequence().map {
println("map${it}")
it * it
}.filter {
println("filter${it}")
it % 2 == 0
}

上面这段代码在控制台不会输出任何内容(因为没有末端操作)。


listOf(1, 2, 3, 4).asSequence().map {
println("map${it}")
it * it
}.filter {
println("filter${it}")
it % 2 == 0
}.toList()

控制台输出:
2023-01-01 20:23:05.071 17000-17000/com.wangjiang.example D/TestSequence: map1
2023-01-01 20:23:05.071 17000-17000/com.wangjiang.example D/TestSequence: filter1
2023-01-01 20:23:05.071 17000-17000/com.wangjiang.example D/TestSequence: map2
2023-01-01 20:23:05.071 17000-17000/com.wangjiang.example D/TestSequence: filter4
2023-01-01 20:23:05.071 17000-17000/com.wangjiang.example D/TestSequence: map3
2023-01-01 20:23:05.071 17000-17000/com.wangjiang.example D/TestSequence: filter9
2023-01-01 20:23:05.071 17000-17000/com.wangjiang.example D/TestSequence: map4
2023-01-01 20:23:05.071 17000-17000/com.wangjiang.example D/TestSequence: filter16

在末端操作 .toList()的时候,mapfilter 变换才被执行,而且元素是被逐个执行的。并不是所有元素经在 map 操作执行完成后,再执行 filter 操作。


为什么元素是逐个被执行,首先看下 toList() 方法:


public fun <T> Sequence<T>.toList(): List<T> {
return this.toMutableList().optimizeReadOnlyList()
}

public fun <T> Sequence<T>.toMutableList(): MutableList<T> {
return toCollection(ArrayList<T>())
}

public fun <T, C : MutableCollection<in T>> Sequence<T>.toCollection(destination: C): C {
for (item in this) {
destination.add(item)
}
return destination
}

最后的 toCollection 方法中的 for (item in this),其实就是调用 Sequence 中的迭代器 Iterator 进行元素迭代。其中这个 this 来自于 filter,也就是使用 filterIterator 进行元素迭代。来看下 filter


public fun <T> Sequence<T>.filter(predicate: (T) -> Boolean): Sequence<T> {
return FilteringSequence(this, true, predicate)
}

internal class FilteringSequence<T>(
private val sequence: Sequence<T>,
private val sendWhen: Boolean = true,
private val predicate: (T) -> Boolean
) : Sequence<T> {

override fun iterator(): Iterator<T> = object : Iterator<T> {
val iterator = sequence.iterator()
var nextState: Int = -1 // -1 for unknown, 0 for done, 1 for continue
var nextItem: T? = null

private fun calcNext() {
while (iterator.hasNext()) {
val item = iterator.next()
if (predicate(item) == sendWhen) {
nextItem = item
nextState = 1
return
}
}
nextState = 0
}

override fun next(): T {
if (nextState == -1)
calcNext()
if (nextState == 0)
throw NoSuchElementException()
val result = nextItem
nextItem = null
nextState = -1
@Suppress("UNCHECKED_CAST")
return result as T
}

override fun hasNext(): Boolean {
if (nextState == -1)
calcNext()
return nextState == 1
}
}
}

filter 中又会使用上一个 Sequencesequence.iterator() 进行元素迭代。再看下 map


public fun <T, R> Sequence<T>.map(transform: (T) -> R): Sequence<R> {
return TransformingSequence(this, transform)
}

internal class TransformingSequence<T, R>
constructor(private val sequence: Sequence<T>, private val transformer: (T) -> R) : Sequence<R> {
override fun iterator(): Iterator<R> = object : Iterator<R> {
val iterator = sequence.iterator()
override fun next(): R {
return transformer(iterator.next())
}

override fun hasNext(): Boolean {
return iterator.hasNext()
}
}

internal fun <E> flatten(iterator: (R) -> Iterator<E>): Sequence<E> {
return FlatteningSequence<T, R, E>(sequence, transformer, iterator)
}
}

也是使用上一个 Sequencesequence.iterator() 进行元素迭代。所以以此类推,最终会使用转换为 asSequence() 的源 iterator()


下面自定义一个 Sequence 来验证上面的猜想:


listOf(1, 2, 3, 4).asSequence().mapToString {
Log.d("TestSequence","mapToString${it}")
it.toString()
}.toList()

fun <T> Sequence<T>.mapToString(transform: (T) -> String): Sequence<String> {
return TransformingStringSequence(this, transform)
}

class TransformingStringSequence<T>
constructor(private val sequence: Sequence<T>, private val transformer: (T) -> String) : Sequence<String> {
override fun iterator(): Iterator<String> = object : Iterator<String> {
val iterator = sequence.iterator()
override fun next(): String {
val next = iterator.next()
Log.d("TestSequence","next:${next}")
return transformer(next)
}

override fun hasNext(): Boolean {
return iterator.hasNext()
}
}
}

控制台输出:
2023-01-01 20:43:43.899 21797-21797/com.wangjiang.example D/TestSequence: next:1
2023-01-01 20:43:43.899 21797-21797/com.wangjiang.example D/TestSequence: mapToString1
2023-01-01 20:43:43.899 21797-21797/com.wangjiang.example D/TestSequence: next:2
2023-01-01 20:43:43.899 21797-21797/com.wangjiang.example D/TestSequence: mapToString2
2023-01-01 20:43:43.899 21797-21797/com.wangjiang.example D/TestSequence: next:3
2023-01-01 20:43:43.899 21797-21797/com.wangjiang.example D/TestSequence: mapToString3
2023-01-01 20:43:43.899 21797-21797/com.wangjiang.example D/TestSequence: next:4
2023-01-01 20:43:43.899 21797-21797/com.wangjiang.example D/TestSequence: mapToString4

所以这就是 Sequence 为什么在获取结果的时候才会被应用,也就是末端操作被调用的时候,才会依次处理每个元素,这也是 被称为惰性集合操作的原因


经过一系列的 序列操作,每个元素逐个被处理,那么优先处理 filter 序列,其实可以减少变换的总次数。因为每个序列都是使用上一个序列的 sequence.iterator() 进行元素迭代。


创建序列


在集合操作上,可以使用集合直接调用 asSequence() 转换为序列。那么不是集合,有类似集合一样的变换,该怎么操作呢。


下面以求 1到100 的所有自然数之和为例子:


val naturalNumbers = generateSequence(0) { it + 1 }
val numbersTo100 = naturalNumbers.takeWhile { it <= 100 }
val sum = numbersTo100.sum()
println(sum)
控制台输出:
5050

先看下 generateSequence 源码:


public fun <T : Any> generateSequence(seed: T?, nextFunction: (T) -> T?): Sequence<T> =
if (seed == null)
EmptySequence
else
GeneratorSequence({ seed }, nextFunction)

private class GeneratorSequence<T : Any>(private val getInitialValue: () -> T?, private val getNextValue: (T) -> T?) : Sequence<T> {
override fun iterator(): Iterator<T> = object : Iterator<T> {
var nextItem: T? = null
var nextState: Int = -2 // -2 for initial unknown, -1 for next unknown, 0 for done, 1 for continue

private fun calcNext() {
//getInitialValue 获取的到就是 generateSequence 的第一个参数 0
//getNextValue 获取到的就是 generateSequence 的第二个参数 it+1,这个it 就是 nextItem!!
nextItem = if (nextState == -2) getInitialValue() else getNextValue(nextItem!!)
nextState = if (nextItem == null) 0 else 1
}

override fun next(): T {
if (nextState < 0)
calcNext()

if (nextState == 0)
throw NoSuchElementException()
val result = nextItem as T
// Do not clean nextItem (to avoid keeping reference on yielded instance) -- need to keep state for getNextValue
nextState = -1
return result
}

override fun hasNext(): Boolean {
if (nextState < 0)
calcNext()
return nextState == 1
}
}
}

上面代码其实就是创建一个 Sequence 接口实现类,并实现它的 iterator 接口方法,返回一个 Iterator 迭代器。


public fun <T> Sequence<T>.takeWhile(predicate: (T) -> Boolean): Sequence<T> {
return TakeWhileSequence(this, predicate)
}

internal class TakeWhileSequence<T>
constructor(
private val sequence: Sequence<T>,
private val predicate: (T) -> Boolean
) : Sequence<T> {
override fun iterator(): Iterator<T> = object : Iterator<T> {
val iterator = sequence.iterator()
var nextState: Int = -1 // -1 for unknown, 0 for done, 1 for continue
var nextItem: T? = null

private fun calcNext() {
if (iterator.hasNext()) {
//iterator.next() 调用的就是上一个 GeneratorSequence 的 next 方法,而返回值就是它的 it+1
val item = iterator.next()
//判断条件,也就是 it <= 100 -> item <= 100
if (predicate(item)) {
nextState = 1
nextItem = item
return
}
}
nextState = 0
}

override fun next(): T {
if (nextState == -1)
calcNext() // will change nextState
if (nextState == 0)
throw NoSuchElementException()
@Suppress("UNCHECKED_CAST")
val result = nextItem as T

// Clean next to avoid keeping reference on yielded instance
nextItem = null
nextState = -1
return result
}

override fun hasNext(): Boolean {
if (nextState == -1)
calcNext() // will change nextState
return nextState == 1
}
}
}

TakeWhileSequencenext 方法中,会优先调用内部方法 calcNext,而这个方法内部又是调用 GeneratorSequencenext方法,这样就 拿到了当前值 it+1(上一个是0+1,下一个就是1+1),拿到值后再判断 it <= 100 -> item <= 100


public fun Sequence<Int>.sum(): Int {
var sum: Int = 0
for (element in this) {
sum += element
}
return sum
}

sum 方法是序列的末端操作,也就是获取结果。for (element in this) ,调用上一个 Sequence 中的迭代器 Iterator 进行元素迭代,以此类推,直到调用 源 Sequence 中的迭代器 Iterator 进行元素迭代。


总结


Kotlin 标准库提供的集合操作函数:filter,map, flatmap 等,在操作的时候会创建存储中间结果的临时列表,当集合元素较多时,这种链式操作就会变得低效。为了解决这种问题,Kotlin 提供了惰性集合操作 Sequence 接口,只有在 末端操作被调用的时候,也就是获取结果的时候,序列中的元素才会被逐个执行,处理完第一个元素后,才会处理第二个元素,这样中间操作是被延期执行的。而且因为是顺序地去执行每一个元素,所以可以先做 filter 变换,再做 map 变换,这样有助于减少变换的总次数。


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

深入flutter布局约束原理

刚开始接触flutter的时候,Container组件是用得最多的。它就像HTML中的div一样普遍,专门用来布局页面的。 但是使用Container嵌套布局的时候,经常出现一些令人无法理解的问题。就如下面代码,在一个固定的容器中,子组件却铺满了全屏。 ///...
继续阅读 »

刚开始接触flutter的时候,Container组件是用得最多的。它就像HTML中的div一样普遍,专门用来布局页面的。


但是使用Container嵌套布局的时候,经常出现一些令人无法理解的问题。就如下面代码,在一个固定的容器中,子组件却铺满了全屏。


/// 例一
@override
Widget build(BuildContext context) {
return Container(
width: 300,
height: 300,
color: Colors.amber,
child: Container(width: 50, height: 50, color: Colors.red,),
);
}

image.png

然后要加上alignment属性,子组件正常显示了,但容器还是铺满全屏。


/// 例二
@override
Widget build(BuildContext context) {
return Container(
width: 300,
height: 300,
color: Colors.amber,
alignment: Alignment.center,
child: Container(width: 50, height: 50, color: Colors.red,),
);
}

image.png

而在容器外层添加一个Scaffold组件,它就正常显示了。


/// 例三
@override
Widget build(BuildContext context) {
return Scaffold(
body: Container(
width: 300,
height: 300,
color: Colors.amber,
alignment: Alignment.center,
child: Container(width: 50, height: 50, color: Colors.red,),
),
);
}

image.png

这一切的怪异行为困扰了我很久,直到我深入了flutter布局的学习,才渐渐解开这些疑惑。


1、flutter的widget类型


flutter的widget可以分为三类,组合类ComponentWidget代理类ProxyWidget绘制类RenderObjectWidget


image.png
组合类:如ContainerScaffoldMaterialApp还有一系列通过继承StatelessWidgetStatefulWidget的类。组合类是我们开发过程中用得最多的组件。


代理类InheritedWidget,功能型组件,它可以高效快捷的实现共享数据的跨组件传递。如常见的ThemeMediaQuery就是InheritedWidget的应用。


绘制类:屏幕上看到的UI几乎都会通过RenderObjectWidget实现。通过继承它,可以进行界面的布局和绘制。如AlignPaddingConstrainedBox等都是通过继承RenderObjectWidget,并通过重写createRenderObject方法来创建RenderObject对象,实现最终的布局(layout)和绘制(paint)。


2、Container是个组合类


显而易见Container继承StatelessWidget,它是一个组合类,同时也是一个由DecoratedBoxConstrainedBoxTransformPaddingAlign等组件组合的多功能容器。可以通过查看Container类,看出它实际就是通过不同的参数判断,再进行组件的层层嵌套来实现的。


@override
Widget build(BuildContext context) {
Widget? current = child;

if (child == null && (constraints == null || !constraints!.isTight)) {
current = LimitedBox(
maxWidth: 0.0,
maxHeight: 0.0,
child: ConstrainedBox(constraints: const BoxConstraints.expand()),
);
} else if (alignment != null) {
current = Align(alignment: alignment!, child: current);
}

final EdgeInsetsGeometry? effectivePadding = _paddingIncludingDecoration;
if (effectivePadding != null) {
current = Padding(padding: effectivePadding, child: current);
}

if (color != null) {
current = ColoredBox(color: color!, child: current);
}

if (clipBehavior != Clip.none) {
assert(decoration != null);
current = ClipPath(
clipper: _DecorationClipper(
textDirection: Directionality.maybeOf(context),
decoration: decoration!,
),
clipBehavior: clipBehavior,
child: current,
);
}

if (decoration != null) {
current = DecoratedBox(decoration: decoration!, child: current);
}

if (foregroundDecoration != null) {
current = DecoratedBox(
decoration: foregroundDecoration!,
position: DecorationPosition.foreground,
child: current,
);
}

if (constraints != null) {
current = ConstrainedBox(constraints: constraints!, child: current);
}

if (margin != null) {
current = Padding(padding: margin!, child: current);
}

if (transform != null) {
current = Transform(transform: transform!, alignment: transformAlignment, child: current);
}

return current!;
}


组合类基本不参与ui的绘制,都是通过绘制类的组合来实现功能。



3、flutter布局约束


flutter中有两种布局约束BoxConstraints盒约束和SliverConstraints线性约束,如Align、Padding、ConstrainedBox使用的是盒约束。


BoxConstraints盒约束是指flutter框架在运行时遍历整个组件树,在这过程中 「向下传递约束,向上传递尺寸」,以此来确定每个组件的尺寸和大小。


image.png

BoxConstraints类由4个属性组成,最小宽度minWidth、最大宽度maxWidth、最小高度minHeight、最大高度maxHeight


BoxConstraints({
this.minWidth,
this.maxWidth,
this.minHeight,
this.maxHeight,
});

根据这4个属性的变化,可以分为“紧约束(tight)”、“松约束(loose)”、“无界约束”、“有界约束”。


紧约束:最小宽(高)度和最大宽(高)度值相等,此时它是一个固定宽高的约束。


BoxConstraints.tight(Size size)
: minWidth = size.width,
maxWidth = size.width,
minHeight = size.height,
maxHeight = size.height;

松约束:最小宽(高)值为0,最大宽(高)大于0,此时它是一个约束范围。


BoxConstraints.loose(Size size)
: minWidth = 0.0,
maxWidth = size.width,
minHeight = 0.0,
maxHeight = size.height;

无界约束:最小宽(高)和最大宽(高)值存在double.infinity(无限)。


BoxConstraints.expand({double? width, double? height}) 
: minWidth = width ?? double.infinity,
maxWidth = width ?? double.infinity,
minHeight = height ?? double.infinity,
maxHeight = height ?? double.infinity;

有界约束:最小宽(高)和最大宽(高)值均为固定值。


BoxConstraints(100, 300, 100, 300)

4、Container布局行为解惑


了解了BoxConstraints布局约束,回到本文最开始的问题。


/// 例一
@override
Widget build(BuildContext context) {
return Container(
width: 300,
height: 300,
color: Colors.amber,
child: Container(width: 50, height: 50, color: Colors.red,),
);
}

例一中,两个固定宽高的Container,为什么子容器铺满了全屏?


根据BoxConstraints布局约束,遍历整个组件树,最开始的root是树的起点,它向下传递的是一个紧约束。因为是移动设备,root即是屏幕的大小,假设屏幕宽414、高896。于是整个布局约束如下:


image.png

这里有个问题,就是Container分明已经设置了固定宽高,为什么无效?


因为父级向下传递的约束,子组件必须严格遵守。这里Container容器设置的宽高超出了父级的约束范围,就会自动被忽略,采用符合约束的值。


例一两上Container都被铺满屏幕,而最底下的红色Container叠到了最上层,所以最终显示红色。


/// 例二
@override
Widget build(BuildContext context) {
return Container(
width: 300,
height: 300,
color: Colors.amber,
alignment: Alignment.center,
child: Container(width: 50, height: 50, color: Colors.red,),
);
}

例二也同样可以根据布局约束求证,如下图:


image.png


这里Container为什么是ConstrainedBoxAlign组件?前面说过Container是一个组合组件,它是由多个原子组件组成的。根据例二,它是由ConstrainedBox和Align嵌套而成。


Align提供给子组件的是一个松约束,所以容器自身设置50宽高值是在合理范围的,因此生效,屏幕上显示的就是50像素的红色方块。ConstrainedBox受到的是紧约束,所以自身的300宽高被忽略,显示的是铺满屏幕的黄色块。


/// 例三
@override
Widget build(BuildContext context) {
return Scaffold(
body: Container(
width: 300,
height: 300,
color: Colors.amber,
alignment: Alignment.center,
child: Container(width: 50, height: 50, color: Colors.red,),
),
);
}

例三中Scaffold向下传递的是一个松约束,所以黄色Container的宽高根据自身设置的300,在合理的范围内,有效。Container再向下传递的也是松约束,最终红色Container宽高为50。
image.png


这里还有个问题,怎么确定组件向下传递的是紧约束还是松约束?


这就涉及到组件的内部实现了,这里通过Align举个例。


Align是一个绘制组件,它能够进行界面的布局和绘制,这是因为Align的继承链为:



Align -> SingleChildRenderObjectWidget -> RenderObjectWidget



Align需要重写createRenderObject方法,返回RenderObject的实现,这里Align返回的是RenderPositionedBox,所以核心内容就在这个类中


class Align extends SingleChildRenderObjectWidget {
/// ...
@override
RenderPositionedBox createRenderObject(BuildContext context) {
return RenderPositionedBox(
alignment: alignment,
widthFactor: widthFactor,
heightFactor: heightFactor,
textDirection: Directionality.maybeOf(context),
);
}
/// ...
}

而RenderPositionedBox类中,重写performLayout方法,该方法用于根据自身约束条件,计算出子组件的布局,再根据子组件的尺寸设置自身的尺寸,形成一个至下而上,由上到下的闭环,最终实现界面的整个绘制。



RenderPositionedBox -> RenderAligningShiftedBox -> RenderShiftedBox -> RenderBox



class RenderPositionedBox extends RenderAligningShiftedBox {
/// ...
@override
void performLayout() {
final BoxConstraints constraints = this.constraints; // 自身的约束大小
final bool shrinkWrapWidth = _widthFactor != null || constraints.maxWidth == double.infinity;
final bool shrinkWrapHeight = _heightFactor != null || constraints.maxHeight == double.infinity;
/// 存在子组件
if (child != null) {
/// 开始布局子组件
child!.layout(constraints.loosen(), parentUsesSize: true);
/// 根据子组件的尺寸设置自身尺寸
size = constraints.constrain(Size(
shrinkWrapWidth ? child!.size.width * (_widthFactor ?? 1.0) : double.infinity,
shrinkWrapHeight ? child!.size.height * (_heightFactor ?? 1.0) : double.infinity,
));
/// 计算子组件的位置
alignChild();
} else {
/// 不存在子组件
size = constraints.constrain(Size(
shrinkWrapWidth ? 0.0 : double.infinity,
shrinkWrapHeight ? 0.0 : double.infinity,
));
}
}
/// ...
}

根据Align中performLayout方法的实现,可以确定该组件最终会给子组件传递一个怎么样的约束。


/// constraints.loosen提供的是一个松约束
child!.layout(constraints.loosen(), parentUsesSize: true);

/// loosen方法
BoxConstraints loosen() {
assert(debugAssertIsValid());
/// BoxConstraints({double minWidth = 0.0, double maxWidth = double.infinity, double minHeight = 0.0, double maxHeight = double.infinity})
return BoxConstraints(
maxWidth: maxWidth,
maxHeight: maxHeight,
);
}

其它绘制类的组件基本跟Align大同小异,只要重点看performLayout方法的实现,即可判断出组件提供的约束条件。


总结


1、flutter的widget分为,组合类、代理类和绘制类。


2、Container是一个组合类,由DecoratedBox、ConstrainedBox、Transform、Padding、Align等绘制组件组合而成。


3、flutter中有两种布局约束BoxConstraints盒约束和SliverConstraints线性约束。


4、BoxConstraints的约束原理是: 「向下传递约束,向上传递尺寸」


5、BoxConstraints的约束类型为:紧约束、松约束、无界约束、有界约束。


6、判断一个绘制组件的约束行为可以通过查看performLayout方法中layout传入的约束值。


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

IM会话列表刷新优化思考

背景脱离业务场景讲技术方案都是耍流氓最近接手了IM的业务,一上来就来了几个大需求,搞得有点手忙脚乱。在做需求的过程中发现,我们的会话列表(RecyclerView)居然每次更新都是notifyDataSetChanged(),因为IM的刷新频率是非常高的大家可...
继续阅读 »

背景

脱离业务场景讲技术方案都是耍流氓

最近接手了IM的业务,一上来就来了几个大需求,搞得有点手忙脚乱。在做需求的过程中发现,我们的会话列表(RecyclerView)居然每次更新都是notifyDataSetChanged(),因为IM的刷新频率是非常高的

大家可以想象一下微信消息列表,每来1条消息,就全局调用notifyDataSetChanged。

这里瞎猜一下,可能由于历史原因,之前设计的同学也是不得已而为之。既然发现了这个问题,那么我们如何来进行优化呢?

IM列表跟普通列表的区别

  • 有序性:列表中的Item按时间排序,或者其他规则(置顶也是修改时间实现)

  • 唯一性:每个会话都是唯一的,不存在重复

  • 单item更新频率高:可以参考微信的会话列表

DiffUtil

首先想到的是DiffUtil,它用来比较两个数据集,寻找出旧数据集->新数据集的最小变化量
实现思路:

  • 获取原始会话数据,进行排序,去重操作

  • 采用DiffUtil自动计算新老数据集差异,自动完成定向刷新

这里只摘取DiffUtil关键使用部分,至于高级用法和更高级的用法不再赘述

class DiffMsgCallBack: DiffUtil.Callback() {
   private val oldData: MutableList<MsgItem> = mutableListOf()
   private val newData: MutableList<MsgItem> = mutableListOf()
   //老数据集size
   override fun getOldListSize(): Int {
       return oldData.size
  }
   //新数据集size
   override fun getNewListSize(): Int {
       return newData.size
  }

   /**
    * 比较的是position,被DiffUtil调用,用来判断两个对象是否是相同的Item
    * 例如,如果你的Item有唯一的id字段,这个方法就 判断id是否相等
    */
   override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
       return oldData[oldItemPosition].id == newData[newItemPosition].id
  }
 
   /**
    * 用来检查 两个item是否含有相同的数据,当前item的内容是否发生了变化,这个方法仅仅在areItemsTheSame()返回true时,才调用
    */
   override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
       if (oldData[oldItemPosition].id != newData[newItemPosition].id){
           return false
      }
       if (oldData[oldItemPosition].content != newData[newItemPosition].content){
           return false
      }
       if (oldData[oldItemPosition].time != newData[newItemPosition].time){
           return false
      }
       return true
  }

   /**
    * 高级用法:实现部分(partial)绑定的方法,需要配合onBindViewHolder的3个参数的方法
    * 更高级用法:AsyncListDiffer+ListAdapter
    *
    */
   override fun getChangePayload(oldItemPosition: Int, newItemPosition: Int): Any? {
       return super.getChangePayload(oldItemPosition, newItemPosition)
  }

SortedList

当我以为DiffUtil已经可以满足需求的时候,无意间又发现了一个SortedList

SortedList是一个有序列表(数据集)的实现,可以保持ItemData都是有序的,并(自动)通知列表(RecyclerView)(数据集)中的更改。

搭配RecyclerView使用,去重,有序,自动定向刷新

这里也只摘取关键使用部分,具体用法不再详解

class SortListCallBack(adapter: RecyclerView.Adapter<*>?) : SortedListAdapterCallback<MsgItem>(adapter) {
   /**
    * 排序条件,实现排序的逻辑
    */
   override fun compare(o1: MsgItem?, o2: MsgItem?): Int {
       o1 ?: return -1
       o2 ?: return -1
       return o1.time - o2.time
  }

   /**
    * 和DiffUtil方法一致,用来判断 两个对象是否是相同的Item。
    */
   override fun areItemsTheSame(item1: MsgItem?, item2: MsgItem?): Boolean {
       return item1?.id == item2?.id

  }
   /**
    * 和DiffUtil方法一致,返回false,代表Item内容改变。会回调mCallback.onChanged()方法;
    * 相同:areContentsTheSame+areItemsTheSame
    */
   override fun areContentsTheSame(oldItem: MsgItem?, newItem: MsgItem?): Boolean {
       if (oldItem?.id != newItem?.id){
           return false
      }
       if (oldItem?.content != newItem?.content){
           return false
      }
       if (oldItem?.time != newItem?.time){
           return false
      }
       return true
  }
   
   /**
    * 高级用法:实现部分绑定的方法,需要配合onBindViewHolder的3个参数的方法
    */
   override fun getChangePayload(item1: MsgItem?, item2: MsgItem?): Any? {
       return super.getChangePayload(item1, item2)
  }

}

对比

DiffUtil和SortedList是非常相似的,修改过数据后,内部持有的回调接口都是同一个:androidx.recyclerview.widget.ListUpdateCallback

/**
* An interface that can receive Update operations that are applied to a list.
* <p>
* This class can be used together with DiffUtil to detect changes between two lists.
*/
public interface ListUpdateCallback {
   
  void onInserted(int position, int count);

  void onRemoved(int position, int count);

  void onMoved(int fromPosition, int toPosition);

  void onChanged(int position, int count, @Nullable Object payload);

DiffUtil计算出Diff或者SortedList察觉出数据集有改变后,会回调ListUpdateCallback接口的这四个方法,DiffUtil和SortedList提供的默认Callback实现中,都会通知Adapter完成定向刷新。 这就是自动定向刷新的原理

总结

  • DiffUtil比较两个数据源(一般是List)的差异(Diff),Callback中比对时传递的参数是 position

  • SortedList能完成数据集的排序和去重,Callback中比对时,传递的参数是ItemData

  • 都能完成自动定向刷新 + 部分绑定,一种自动定向刷新的手段

  • DiffUtil: 检测不出重复的,会被认为是新增的

  • DiffUtil高级用法支持子线程中处理数据,而SortList不支持

理想与现实

2种方案都有了,是不是可以进行IM会话列表的优化了呢,答案是不能

  • 业务需求迭代,牵一发而动全身

  • 祖传代码,无人敢动,更别说优化了

有时候我们写代码会想着后面再优化一下,然而很多时候都不会给你优化的机会,除非重大需求变动,所以一开始设计框架的时候就要结合业务场景尽量设计的更加合理

参考文章:blog.csdn.net/zxt0601/art…

作者:掀乱书页的风
来源:juejin.cn/post/7183517773790707769

收起阅读 »

前端白屏的检测方案,让你知道自己的页面白了

web
前言页面白屏,绝对是让前端开发者最为胆寒的事情,特别是随着 SPA 项目的盛行,前端白屏的情况变得更为复杂且棘手起来( 这里的白屏是指页面一直处于白屏状态 )要是能检测到页面白屏就太棒了,开发者谁都不想成为最后一个知道自己页面白的人😥web-see 前端监控方...
继续阅读 »

前言

页面白屏,绝对是让前端开发者最为胆寒的事情,特别是随着 SPA 项目的盛行,前端白屏的情况变得更为复杂且棘手起来( 这里的白屏是指页面一直处于白屏状态 )

要是能检测到页面白屏就太棒了,开发者谁都不想成为最后一个知道自己页面白的人😥

web-see 前端监控方案,提供了 采样对比+白屏修正机制 的检测方案,兼容有骨架屏、无骨架屏这两种情况,来解决开发者的白屏之忧

知道页面白了,然后呢?

web-see 前端监控,会给每次页面访问生成一个唯一的uuid,当上报页面白屏后,开发者可以根据白屏的uuid,去监控后台查询该id下对应的代码报错、资源报错等信息,定位到具体的源码,帮助开发者快速解决白屏问题

白屏检测方案的实现流程

采样对比+白屏修正机制的主要流程:

1、页面中间取17个采样点(如下图),利用 elementsFromPoint api 获取该坐标点下的 HTML 元素

2、定义属于容器元素的集合,如 ['html', 'body', '#app', '#root']

3、判断17这个采样点是否在该容器集合中。说白了,就是判断采样点有没有内容;如果没有内容,该点的 dom 元素还是容器元素,若17个采样点都没有内容则算作白屏

4、若初次判断是白屏,开启轮询检测,来确保白屏检测结果的正确性,直到页面的正常渲染

采样点分布图(蓝色为采样点):


如何使用

import webSee from 'web-see';

Vue.use(webSee, {
 dsn: 'http://localhost:8083/reportData', // 上报的地址
 apikey: 'project1', // 项目唯一的id
 userId: '89757', // 用户id
 silentWhiteScreen: true, // 开启白屏检测
 skeletonProject: true, // 项目是否有骨架屏
 whiteBoxElements: ['html', 'body', '#app', '#root'] // 白屏检测的容器列表
});

下面聊一聊具体的分析与实现

白屏检测的难点

1) 白屏原因的不确定

从问题推导现象虽然能成功,但从现象去推导问题却走不通。白屏发生时,无法和具体某个报错联系起来,也可能根本没有报错,比如关键资源还没有加载完成

导致白屏的原因,大致分两种:资源加载错误、代码执行错误

2) 前端渲染方式的多样性

前端页面渲染方式有多种,比如 客户端渲染 CSR 、服务端渲染 SSR 、静态页面生成 SSG 等,每种模式各不相同,白屏发生的情况也不尽相同

很难用一种统一的标准去判断页面是否白了

技术方案调研

如何设计出一种,在准确性、通用型、易用性等方面均表现良好的检测方案呢?

本文主要讨论 SPA 项目的白屏检测方案,包括有无骨架屏的两种情况

方案一:检测根节点是否渲染

原理很简单,在当前主流 SPA 框架下,DOM 一般挂载在一个根节点之下(比如 <div id="app"></div> ),发生白屏后通常是根节点下所有 DOM 被卸载,该方法通过检测根节点下是否挂载 DOM,若无则证明白屏

这是简单明了且有效的方案,但缺点也很明显:其一切建立在 白屏 === 根节点下 DOM 被卸载 成立的前提下,缺点是通用性较差,对于有骨架屏的情况束手无策

方案二:Mutation Observer 监听 DOM 变化

通过此 API 监听页面 DOM 变化,并告诉我们每次变化的 DOM 是被增加还是删除

但这个方案有几个缺陷

1)白屏不一定是 DOM 被卸载,也有可能是压根没渲染,且正常情况也有可能大量 DOM 被卸载

2)遇到有骨架屏的项目,若页面从始至终就没变化,一直显示骨架屏,这种情况 Mutation Observer 也束手无策

方案三:页面截图检测

这种方式是基于原生图片对比算法处理白屏检测的 web 实现

整体流程:对页面进行截图,将截图与一张纯白的图片做对比,判断两者是否足够相似

但这个方案有几个缺陷:

1、方案较为复杂,性能不高;一方面需要借助 canvas 实现前端截屏,同时需要借助复杂的算法对图片进行对比

2、通用性较差,对于有骨架屏的项目,对比的样张要由纯白的图片替换成骨架屏的截图

方案四:采样对比

该方法是对页面取关键点,进行采样对比,在准确性、易用性等方面均表现良好,也是最终采用的方案

对于有骨架屏的项目,通过对比前后获取的 dom 元素是否一致,来判断页面是否变化(这块后面专门讲解)

采样对比代码:

// 监听页面白屏
function whiteScreen() {
 // 页面加载完毕
 function onload(callback) {
   if (document.readyState === 'complete') {
     callback();
  } else {
     window.addEventListener('load', callback);
  }
}
 // 定义外层容器元素的集合
 let containerElements = ['html', 'body', '#app', '#root'];
 // 容器元素个数
 let emptyPoints = 0;
 // 选中dom的名称
 function getSelector(element) {
   if (element.id) {
     return "#" + element.id;
  } else if (element.className) {// div home => div.home
     return "." + element.className.split(' ').filter(item => !!item).join('.');
  } else {
     return element.nodeName.toLowerCase();
  }
}
 // 是否为容器节点
 function isContainer(element) {
   let selector = getSelector(element);
   if (containerElements.indexOf(selector) != -1) {
     emptyPoints++;
  }
}
 onload(() => {
   // 页面加载完毕初始化
   for (let i = 1; i <= 9; i++) {
     let xElements = document.elementsFromPoint(window.innerWidth * i / 10, window.innerHeight / 2);
     let yElements = document.elementsFromPoint(window.innerWidth / 2, window.innerHeight * i / 10);
     isContainer(xElements[0]);
     // 中心点只计算一次
     if (i != 5) {
       isContainer(yElements[0]);
    }
  }
   // 17个点都是容器节点算作白屏
   if (emptyPoints == 17) {
     // 获取白屏信息
     console.log({
       status: 'error'
    });
  }
}
}

白屏修正机制

若首次检测页面为白屏后,任务还没有完成,特别是手机端的项目,有可能是用户网络环境不好,关键的JS资源或接口请求还没有返回,导致的页面白屏

需要使用轮询检测,来确保白屏检测结果的正确性,直到页面的正常渲染,这就是白屏修正机制

白屏修正机制图例:


轮询代码:

// 采样对比
function sampling() {
 let emptyPoints = 0;
 ……
 // 页面正常渲染,停止轮询
 if (emptyPoints != 17) {
   if (window.whiteLoopTimer) {
     clearTimeout(window.whiteLoopTimer)
     window.whiteLoopTimer = null
  }
} else {
   // 开启轮询
   if (!window.whiteLoopTimer) {
     whiteLoop()
  }
}
 // 通过轮询不断修改之前的检测结果,直到页面正常渲染
 console.log({
   status: emptyPoints == 17 ? 'error' : 'ok'
});
}
// 白屏轮询
function whiteLoop() {
 window.whiteLoopTimer = setInterval(() => {
   sampling()
}, 1000)
}

骨架屏

对于有骨架屏的页面,用户打开页面后,先看到骨架屏,然后再显示正常的页面,来提升用户体验;但如果页面从始至终都显示骨架屏,也算是白屏的一种

骨架屏示例:


骨架屏的原理

无论 vue 还是 react,页面内容都是挂载到根节点上。常见的骨架屏插件,就是基于这种原理,在项目打包时将骨架屏的内容直接放到 html 文件的根节点中

有骨架屏的html文件:


骨架屏的白屏检测

上面的白屏检测方案对有骨架屏的项目失灵了,虽然页面一直显示骨架屏,但判断结果页面不是白屏,不符合我们的预期

需要通过外部传参明确的告诉 SDK,该页面是不是有骨架屏,如果有骨架屏,通过对比前后获取的 dom 元素是否一致,来实现骨架屏的白屏检测

完整代码:

/**
* 检测页面是否白屏
* @param {function} callback - 回到函数获取检测结果
* @param {boolean} skeletonProject - 页面是否有骨架屏
* @param {array} whiteBoxElements - 容器列表,默认值为['html', 'body', '#app', '#root']
*/
export function openWhiteScreen(callback, { skeletonProject, whiteBoxElements }) {
 let _whiteLoopNum = 0;
 let _skeletonInitList = []; // 存储初次采样点
 let _skeletonNowList = []; // 存储当前采样点

 // 项目有骨架屏
 if (skeletonProject) {
   if (document.readyState != 'complete') {
     sampling();
  }
} else {
   // 页面加载完毕
   if (document.readyState === 'complete') {
     sampling();
  } else {
     window.addEventListener('load', sampling);
  }
}
 // 选中dom点的名称
 function getSelector(element) {
   if (element.id) {
     return '#' + element.id;
  } else if (element.className) {
     // div home => div.home
     return ('.' + element.className.split(' ').filter(item => !!item).join('.'));
  } else {
     return element.nodeName.toLowerCase();
  }
}
 // 判断采样点是否为容器节点
 function isContainer(element) {
   let selector = getSelector(element);
   if (skeletonProject) {
     _whiteLoopNum ? _skeletonNowList.push(selector) : _skeletonInitList.push(selector);
  }
   return whiteBoxElements.indexOf(selector) != -1;
}
 // 采样对比
 function sampling() {
   let emptyPoints = 0;
   for (let i = 1; i <= 9; i++) {
     let xElements = document.elementsFromPoint(
      (window.innerWidth * i) / 10,
       window.innerHeight / 2
    );
     let yElements = document.elementsFromPoint(
       window.innerWidth / 2,
      (window.innerHeight * i) / 10
    );
     if (isContainer(xElements[0])) emptyPoints++;
     // 中心点只计算一次
     if (i != 5) {
       if (isContainer(yElements[0])) emptyPoints++;
    }
  }
   // 页面正常渲染,停止轮训
   if (emptyPoints != 17) {
     if (skeletonProject) {
       // 第一次不比较
       if (!_whiteLoopNum) return openWhiteLoop();
       // 比较前后dom是否一致
       if (_skeletonNowList.join() == _skeletonInitList.join())
         return callback({
           status: 'error'
        });
    }
     if (window._loopTimer) {
       clearTimeout(window._loopTimer);
       window._loopTimer = null;
    }
  } else {
     // 开启轮训
     if (!window._loopTimer) {
       openWhiteLoop();
    }
  }
   // 17个点都是容器节点算作白屏
   callback({
     status: emptyPoints == 17 ? 'error' : 'ok',
  });
}
 // 开启白屏轮训
 function openWhiteLoop() {
   if (window._loopTimer) return;
   window._loopTimer = setInterval(() => {
     if (skeletonProject) {
       _whiteLoopNum++;
       _skeletonNowList = [];
    }
     sampling();
  }, 1000);
}
}

如果不通过外部传参,SDK 能否自己判断是否有骨架屏呢? 比如在页面初始的时候,根据根节点上有没有子节点来判断

因为这套检测方案需要兼容 SSR 服务端渲染的项目,对于 SSR 项目来说,浏览器获取 html 文件的根节点上已经有了 dom 元素,所以最终采用外部传参的方式来区分

总结

这套白屏检测方案是从现象推导本质,可以覆盖绝大多数 SPA 项目的应用场景

小伙们若有其他检测方案,欢迎多多讨论与交流 💕

作者:海阔_天空
来源:juejin.cn/post/7176206226903007292

收起阅读 »

Android App封装 —— 实现自己的EventBus

背景 在项目中我们经常会遇到跨页面通信的需求,但传统的EventBus都有各自的缺点,如EventBus和RxBus需要自己管理生命周期,比较繁琐,基于LiveData的Bus切线程比较困难等。于是我参考了一些使用Flow实现EventBus的文章,结合自身需...
继续阅读 »

背景


在项目中我们经常会遇到跨页面通信的需求,但传统的EventBus都有各自的缺点,如EventBus和RxBus需要自己管理生命周期,比较繁琐,基于LiveData的Bus切线程比较困难等。于是我参考了一些使用Flow实现EventBus的文章,结合自身需求,实现了极简的EventBus。


EventBus


image.png


EventBus是用于 Android 和 Java 的发布/订阅事件总线。Publisher可以将事件Event post给每一个订阅者Subscriber中接收,从而达到跨页面通信的需求。


可以看出EventBus本身就是一个生产者消费者模型,而在我们第一篇搭建MVI框架的时候,用到的Flow天然就支持生产者和消费者模型,所以我们可以自己用Flow搭建一个自己的EventBus


基于Flow搭建EventBus


根据EventBus的架构图,我们来用Flow搭建,需要定义一下几点



  1. 定义事件Event

  2. 发送者 Publisher 如何发送事件

  3. 如何存储Event并且分发

  4. 如何订阅事件


1. 定义事件


sealed class Event {
data class ShowInit(val msg: String) : Event()
}

这个和之前搭建MVI框架类似,用一个sleaed classdata class或者object来定义事件,用来传递信息


2. 发送事件


fun post(event: Event, delay: Long = 0) {
...
}

发送事件定义一个这样的函数就可以了,传入事件和延迟时间


3. 存储Event并且分发


对于同一种Event,我们可以用一个SharedFlow来存储,依次发送给订阅方。而在整个App中,我们会用到各种不同种类的Event,所以这时候我们就需要用到HashMap去存储这些Event了。数据结构如下:


private val flowEvents = ConcurrentHashMap<String, MutableSharedFlow<Event>>()

4. 订阅事件


inline fun <reified T : Event> observe(
lifecycleOwner: LifecycleOwner,
minState: Lifecycle.State = Lifecycle.State.CREATED,
dispatcher: CoroutineDispatcher = Dispatchers.Main,
crossinline onReceived: (T) -> Unit
)


  • lifecycleOwner,用来定义订阅者的生命周期,这样我们就不需要额外管理注册与反注册了

  • minState,定义执行订阅的生命周期State

  • dispatcher,定义执行所在的线程

  • onReceived,收到Event后执行的Lamda


使用


    //任何地方
FlowEventBus.post(Event.ShowInit("article init"))

// Activity或者Fragment中
FlowEventBus.observe<Event.ShowInit>(this, Lifecycle.State.STARTED) {
binding.button.text = it.msg
}

完整代码


object FlowEventBus {

//用HashMap存储SharedFlow
private val flowEvents = ConcurrentHashMap<String, MutableSharedFlow<Event>>()

//获取Flow,当相应Flow不存在时创建
fun getFlow(key: String): MutableSharedFlow<Event> {
return flowEvents[key] ?: MutableSharedFlow<Event>().also { flowEvents[key] = it }
}

// 发送事件
fun post(event: Event, delay: Long = 0) {
MainScope().launch {
delay(delay)
getFlow(event.javaClass.simpleName).emit(event)
}
}

// 订阅事件
inline fun <reified T : Event> observe(
lifecycleOwner: LifecycleOwner,
minState: Lifecycle.State = Lifecycle.State.CREATED,
dispatcher: CoroutineDispatcher = Dispatchers.Main,
crossinline onReceived: (T) -> Unit
) = lifecycleOwner.lifecycleScope.launch(dispatcher) {
getFlow(T::class.java.simpleName).collect {
lifecycleOwner.lifecycle.whenStateAtLeast(minState) {
if (it is T) onReceived(it)
}
}
}

}

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

Android App封装 —— DI框架 Hilt?Koin?

背景 前面的项目Github wanandroid例子我们可以看到,我们创建Repository和ViewModel的时候,都是直接创建的 class MainViewModel : BaseViewModel<MainState, MainIntent...
继续阅读 »

背景


前面的项目Github wanandroid例子我们可以看到,我们创建Repository和ViewModel的时候,都是直接创建的


class MainViewModel : BaseViewModel<MainState, MainIntent>() {
private val mWanRepo = HomeRepository()
...
}


class MainActivity : BaseActivity<ActivityMainBinding>() {

override fun onCreate(savedInstanceState: Bundle?) {
mViewModel = ViewModelProvider(this).get(MainViewModel::class.java)
}
}

但是一般一个repository会被多个viewModel使用,我们不想创建多个同样类型的repository实例,这时候我们需要将WanRepository设置为单例。但是当代码越来越多,对象的共享依赖关系以及生命周期越来越复杂的时候,我们全部自己手写显然是比较复杂的。


所以Goolge强推我们使用DI(Dependency Injection)依赖注入来管理对象的创建,之前推出了强大的Dagger,但是由于难学难用,很少有人用到这个框架。后面又推出了Hilt,基于Dagger实现,针对于Android平台简化了使用方式,原理和Dagger是一致的。


本来准备将Hilt引用到项目中,后来发现了一个轻量级的DI框架koin,两者学习对比了一下之后还是决定使用Koin这个轻量级的框架,koin和Hilt的详细对比就不在此展开了,网上有很多文章。


那么就开始动工,准备在项目中集成koin吧。


koin


koin官网,官网永远是学习一个东西的最佳途径


1. 依赖


网上看到很多koin的使用案例,我看依赖的都是2.X的包


implementation "org.koin:koin-android:$koin_version"
implementation "org.koin:koin-android-viewmodel:$koin_version"

后面我去官网看了下文档,发现koin已经升级到3.x了,合并所有 Scope/Fragment/ViewModel API,只需要引用一个包就可以了


    implementation "io.insert-koin:koin-android:$koin_version" //3.3.1

2. 启动


添加好依赖后,可以在Application中启动koin,初始化koin的配置,代码如下


class App : Application() {
override fun onCreate() {
super.onCreate()

startKoin {
//开始启动koin
androidLogger()
androidContext(this@App)//这边传Application对象,这样你注入的类中,需要app对象的时候,可以直接使用
modules(appModule)//这里面传各种被注入的模块对象,支持多模块注入
}
}
}

3. 模块Module


上文中的modules(appModule),是用来配置koin使用的Module有哪些,那么Module是什么呢?


Koin是以Module的形式组织依赖项,我们可以将可能用到的依赖项定义在Module中,也就是对象的提供者


val repoModule = module {
single { HomeRepository() }
}

val viewModelModule = module {
viewModel { MainViewModel(get()) }
}

val appModule = listOf(viewModelModule, repoModule)

上面这段代码就是定义了两个Module,一个我专门用来定义repository,一个专门用来定义viewModel。


然后通过get()inject(),表示在需要注入依赖项,也就是对象的使用者,这时就会在Module里面检索对应的类型,然后自动注入。


所以之前Repository的创建变为


val mWanRepo: HomeRepository by inject(HomeRepository::class.java)

并且依据single定义为了单例


进一步简化可以将repository写到ViewModel的构造方法中


class MainViewModel(private val homeRepo: HomeRepository) : BaseViewModel<MainState, MainIntent>() {
...
}

根据viewModel { MainViewModel(get()) }的定义,在构造MainViewModel的时候会自动因为get()填充HomeRepository对象


4. Activity中使用ViewModel


class MainActivity : BaseActivity<ActivityMainBinding>() {

private val mViewModel by viewModel<MainViewModel>()

}

总结


koin和Hilt,大家可以看自己的习惯使用,Hilt的特点主要是利用注解生成代码,使用方便,效率也挺高的。koin我主要是看中它比较轻量级,可以快速入门使用。


项目地址:Github wanandroid


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

Android App封装 —— ViewBinding

一、背景 在前面的Github wanandroid项目中可以看到,我获取控件对象还是用的findviewbyId button = findViewById(R.id.button) viewPager = findViewById(R.id.view_pa...
继续阅读 »

一、背景


在前面的Github wanandroid项目中可以看到,我获取控件对象还是用的findviewbyId


button = findViewById(R.id.button)
viewPager = findViewById(R.id.view_pager)
recyclerView = findViewById(R.id.recycler_view)

现在肯定是需要对这个最常用的获取View的findViewById代码进行优化,主要是有两个原因




  1. 过于冗余


    findViewById对应所有的View都要书写findViewById(R.id.xxx)的方法,代码过于繁琐




  2. 不安全


    强制转换不安全,findViewById获取到的是一个View对象,是需要强转的,一旦类型给的不对则会出现异常,比如将TextView错转成ImageView




所以我们需要一个框架解决这个问题,大致是有三个方案


二、方案


方案一 butterkniife


这个应该很多人都用过,由大大佬JakeWharton开发,通过注解生成findViewById的代码来获取对应的View。


@BindView(R.id.button) 
EditText mButton;

但是2020年3月份,大佬已在GitHub上说明不再维护,推荐使用 ViewBinding了。


方案二 kotlin-android-extensions(KAE)


kotlin-android-extensions只需要直接引入布局可以直接使用资源Id访问View,节省findviewbyid()。


import kotlinx.android.synthetic.main.<布局>.*

button.setOnClickListener{...}

但是这个插件也已经被Google废弃了,会影响效率并且安全性和兼容性都不太友好,Google推荐ViewBinding替代


方案三 ViewBinding


既然都推荐ViewBinding,那现在来看看ViewBinding是啥。官网是这么说的



通过ViewBinding功能,您可以更轻松地编写可与视图交互的代码。在模块中启用视图绑定之后,系统会为该模块中的每个 XML 布局文件生成一个绑定类。绑定类的实例包含对在相应布局中具有 ID 的所有视图的直接引用。在大多数情况下,视图绑定会替代 findViewById。



简而言之就是就是替代findViewById来获取View的。那我们来看看ViewBinding如何使用呢?


三、ViewBinding使用


1. 条件


确保你的Android Studio是3.6或更高的版本



ViewBinding在 Android Studio 3.6 Canary 11 及更高版本中可用



2. 启用ViewBinding


在模块build.gradle文件android节点下添加如下代码


android {
viewBinding{
enabled = true
}
}

Android Studio 4.0 中,viewBinding 变成属性被整合到了 buildFeatures 选项中,所以配置要改成:


// Android Studio 4.0
android {
buildFeatures {
viewBinding = true
}
}

配置好后就已经启用好了ViewBinding,重新编译后系统会为每个布局生成对应的Binding类,类中包含布局ID对应的View引用,并采取驼峰式命名。


3. 使用


以activity举例,我们的MainActivity的布局是activity_main,之前我们布局代码是:


class MainActivity : BaseActivity() {

private lateinit var button: Button
private lateinit var viewPager: ViewPager2
private lateinit var recyclerView: RecyclerView

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

button = findViewById(R.id.button)
button.setOnClickListener { ... }
}
}

现在就要改为



  1. 对应的Binding类如ActivityMainBinding类去用inflate加载布局

  2. 然后通过getRoot获取到View

  3. 将View传入到setContentView(view:View)中


Activity就能显示activity_main.xml这个布局的内容了,并可以通过Binding对象直接访问对应View对象。


class MainActivity : BaseActivity() {

private lateinit var mBinding: ActivityMainBinding

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

mBinding = ActivityMainBinding.inflate(layoutInflater)
setContentView(mBinding.root)
mBinding.button.setOnClickListener { ... }
}
}

而在其他UI elements中,如fragment、dialog、adapter中,使用方式大同小异,都是通过inflate去加载出View,然后后面加以使用。


四、原理


生成的类可以在/build/generated/data_binding_base_class_source_out下找到


public final class ActivityMainBinding implements ViewBinding {
@NonNull
private final ConstraintLayout rootView;

@NonNull
public final Button button;

@NonNull
public final RecyclerView recyclerView;

@NonNull
public final ViewPager2 viewPager;

private ActivityMainBinding(@NonNull ConstraintLayout rootView, @NonNull Button button,
@NonNull RecyclerView recyclerView, @NonNull ViewPager2 viewPager) {
this.rootView = rootView;
this.button = button;
this.recyclerView = recyclerView;
this.viewPager = viewPager;
}

@Override
@NonNull
public ConstraintLayout getRoot() {
return rootView;
}

@NonNull
public static ActivityMainBinding inflate(@NonNull LayoutInflater inflater) {
return inflate(inflater, null, false);
}

@NonNull
public static ActivityMainBinding inflate(@NonNull LayoutInflater inflater,
@Nullable ViewGroup parent, boolean attachToParent) {
View root = inflater.inflate(R.layout.activity_main, parent, false);
if (attachToParent) {
parent.addView(root);
}
return bind(root);
}

@NonNull
public static ActivityMainBinding bind(@NonNull View rootView) {
// The body of this method is generated in a way you would not otherwise write.
// This is done to optimize the compiled bytecode for size and performance.
int id;
missingId: {
id = R.id.button;
Button button = ViewBindings.findChildViewById(rootView, id);
if (button == null) {
break missingId;
}

id = R.id.recycler_view;
RecyclerView recyclerView = ViewBindings.findChildViewById(rootView, id);
if (recyclerView == null) {
break missingId;
}

id = R.id.view_pager;
ViewPager2 viewPager = ViewBindings.findChildViewById(rootView, id);
if (viewPager == null) {
break missingId;
}

return new ActivityMainBinding((ConstraintLayout) rootView, button, recyclerView, viewPager);
}
String missingId = rootView.getResources().getResourceName(id);
throw new NullPointerException("Missing required view with ID: ".concat(missingId));
}
}

可以看到关键的方法就是这个bind方法,里面通过ViewBindings.findChildViewById获取View对象,而继续查看这个方法


public class ViewBindings {

private ViewBindings() {
}

/**
* Like `findViewById` but skips the view itself.
*
* @hide
*/
@Nullable
public static <T extends View> T findChildViewById(View rootView, @IdRes int id) {
if (!(rootView instanceof ViewGroup)) {
return null;
}
final ViewGroup rootViewGroup = (ViewGroup) rootView;
final int childCount = rootViewGroup.getChildCount();
for (int i = 0; i < childCount; i++) {
final T view = rootViewGroup.getChildAt(i).findViewById(id);
if (view != null) {
return view;
}
}
return null;
}
}

可见还是使用的findViewById,ViewBinding这个框架只是帮我们在编译阶段自动生成了这些findViewById代码,省去我们去写了。


五、优缺点


优点



  1. 对比kotlin-extension,可以控制访问作用域,kotlin-extension可以访问不是该布局下的view;

  2. 对比butterknife,减少注解以及id的一对一匹配

  3. 兼容Kotlin、Java;

  4. 官方推荐。


缺点



  1. 增加编译时间,因为ViwBinding是在编译时生成的,会产生而外的类,增加包的体积;

  2. include的布局文件无法直接引用,需要给include给id值,然后间接引用;


整体来说ViewBinding的优点还是远远大于缺点的,所以可以放心使用。


六、 封装


既然选择了方案ViewBinding,那我们要在项目中使用,肯定还需要对他加一些封装,我们可以用泛型封装setContentView的代码


abstract class BaseActivity<T : ViewBinding> : AppCompatActivity() {

private lateinit var _binding: T
protected val binding get() = _binding;

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
_binding = getViewBinding()
setContentView(_binding.root)

initViews()
initEvents()
}

protected abstract fun getViewBinding(): T
open fun initViews() {}
open fun initEvents() {}
}

class MainActivity : BaseActivity<ActivityMainBinding>() {

override fun getViewBinding() = ActivityMainBinding.inflate(layoutInflater)

override fun initViews() {
binding.button.setOnClickListener {
...
}
}

}

这样在Activity中使用起来就很方便,fragment也可以做类似的封装


abstract class BaseFragment<T : ViewBinding> : Fragment() {
private var _binding: T? = null
protected val binding get() = _binding!!

override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
): View? {
_binding = getViewBinding(inflater, container)
return binding.root
}

protected abstract fun getViewBinding(inflater: LayoutInflater, container: ViewGroup?): T

override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}

注意:


这里会发现Fragment和Activity的封装方式不一样,没有用lateinit

因为binding变量只有在onCreateView与onDestroyView才是可用的,而fragment的生命周期和activity的不同,fragment可以超出其视图的生命周期,比如fragment hide的时候,如果不将这里置为空,有可能引起内存泄漏

所以我们要在onCreateView中创建,onDestroyView置空。


七、总结


ViewBinding相比优点还是很多的,解决了安全性问题和兼容性问题,所以我们可以放心大胆的使用。


项目源码地址: Github wanandroid


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

Android App封装 ——架构(MVI + kotlin + Flow)

一、背景 最近看了好多MVI的文章,原理大多都是参照google发布的 应用架构指南,但是实现方式有很多种,就想自己封装一套自己喜欢用的MVI架构,以供以后开发App使用。 说干就干,准备对标“玩Android”,利用提供的数据接口,搭建一个自己习惯使用的一套...
继续阅读 »

一、背景


最近看了好多MVI的文章,原理大多都是参照google发布的 应用架构指南,但是实现方式有很多种,就想自己封装一套自己喜欢用的MVI架构,以供以后开发App使用。


说干就干,准备对标“玩Android”,利用提供的数据接口,搭建一个自己习惯使用的一套App项目,项目地址:Github wanandroid


二、MVI


先简单说一下MVI,从MVC到MVP到MVVM再到现在的MVI,google是为了一直解决痛点所以不断推出新的框架,具体的发展流程就不多做赘诉了,网上有好多,我们可以选择性适合自己的。


应用架构指南中主要的就是两个架构图:


2.1 总体架构


image.png


Google推荐的是每个应用至少有两层:



  • UI Layer 界面层: 在屏幕上显示应用数据

  • Data Layer 数据层: 提供所需要的应用数据(通过网络、文件等)

  • Domain Layer(optional)领域层/网域层 (可选):主要用于封装数据层的逻辑,方便与界面层的交互,可以根据User Case


图中主要的点在于各层之间的依赖关系是单向的,所以方便了各层之间的单元测试


2.2 UI层架构


UI简单来说就是拿到数据并展示,而数据是以state表示UI不同的状态传送给界面的,所以UI架构分为



  • UI elements层:UI元素,由activity、fragment以及包含的控件组成

  • State holders层: state状态的持有者,这里一般是由viewModel承担


image.png


2.3 MVI UI层的特点


MVI在UI层相比与MVVM的核心区别是它的两大特性:



  1. 唯一可信数据源

  2. 数据单向流动


image.png


从图中可以看到,



  1. 数据从Data Layer -> ViewModel -> UI,数据是单向流动的。ViewModel将数据封装成UI State传输到UI elements中,而UI elements是不会传输数据到ViewModel的。

  2. UI elements上的一些点击或者用户事件,都会封装成events事件,发送给ViewModel


2.4 搭建MVI要注意的点


了解了MVI的原理和特点后,我们就要开始着手搭建了,其中需要解决的有以下几点



  1. 定义UI Stateevents

  2. 构建UI State单向数据流UDF

  3. 构建事件流events

  4. UI State的订阅和发送


三、搭建项目


3.1 定义UI Stateevents


我们可以用interface先定义一个抽象的UI Stateeventseventintent是一个意思,都可以用来表示一次事件。


@Keep
interface IUiState

@Keep
interface IUiIntent

然后根据具体逻辑定义页面的UIState和UiIntent。


data class MainState(val bannerUiState: BannerUiState, val detailUiState: DetailUiState) : IUiState

sealed class BannerUiState {
object INIT : BannerUiState()
data class SUCCESS(val models: List<BannerModel>) : BannerUiState()
}

sealed class DetailUiState {
object INIT : DetailUiState()
data class SUCCESS(val articles: ArticleModel) : DetailUiState()
}

通过MainState将页面的不同状态封装起来,从而实现唯一可信数据源


3.2 构建单向数据流UDF


在ViewModel中使用StateFlow构建UI State流。



  • _uiStateFlow用来更新数据

  • uiStateFlow用来暴露给UI elements订阅


abstract class BaseViewModel<UiState : IUiState, UiIntent : IUiIntent> : ViewModel() {

private val _uiStateFlow = MutableStateFlow(initUiState())
val uiStateFlow: StateFlow<UiState> = _uiStateFlow

protected abstract fun initUiState(): UiState

protected fun sendUiState(copy: UiState.() -> UiState) {
_uiStateFlow.update { copy(_uiStateFlow.value) }
}
}

class MainViewModel : BaseViewModel<MainState, MainIntent>() {

override fun initUiState(): MainState {
return MainState(BannerUiState.INIT, DetailUiState.INIT)
}
}

3.3 构建事件流


在ViewModel中使用 Channel构建事件流



  1. _uiIntentFlow用来传输Intent

  2. 在viewModelScope中开启协程监听uiIntentFlow,在子ViewModel中只用重写handlerIntent方法就可以处理Intent事件了

  3. 通过sendUiIntent就可以发送Intent事件了


abstract class BaseViewModel<UiState : IUiState, UiIntent : IUiIntent> : ViewModel() {

private val _uiIntentFlow: Channel<UiIntent> = Channel()
val uiIntentFlow: Flow<UiIntent> = _uiIntentFlow.receiveAsFlow()

fun sendUiIntent(uiIntent: UiIntent) {
viewModelScope.launch {
_uiIntentFlow.send(uiIntent)
}
}

init {
viewModelScope.launch {
uiIntentFlow.collect {
handleIntent(it)
}
}
}

protected abstract fun handleIntent(intent: IUiIntent)

class MainViewModel : BaseViewModel<MainState, MainIntent>() {

override fun handleIntent(intent: IUiIntent) {
when (intent) {
MainIntent.GetBanner -> {
requestDataWithFlow()
}
is MainIntent.GetDetail -> {
requestDataWithFlow()
}
}
}
}

3.4 UI State的订阅和发送


3.4.1 订阅UI State


在Activity中订阅UI state的变化



  1. lifecycleScope中开启协程,collect uiStateFlow

  2. 使用map 来做局部变量的更新

  3. 使用distinctUntilChanged来做数据防抖


class MainActivity : BaseMVIActivity() {

private fun registerEvent() {
lifecycleScope.launchWhenStarted {
mViewModel.uiStateFlow.map { it.bannerUiState }.distinctUntilChanged().collect { bannerUiState ->
when (bannerUiState) {
is BannerUiState.INIT -> {}
is BannerUiState.SUCCESS -> {
bannerAdapter.setList(bannerUiState.models)
}
}
}
}
lifecycleScope.launchWhenStarted {
mViewModel.uiStateFlow.map { it.detailUiState }.distinctUntilChanged().collect { detailUiState ->
when (detailUiState) {
is DetailUiState.INIT -> {}
is DetailUiState.SUCCESS -> {
articleAdapter.setList(detailUiState.articles.datas)
}
}

}
}
}
}

3.4.2 发送Intent


直接调用sendUiIntent就可以发送Intent事件


button.setOnClickListener {
mViewModel.sendUiIntent(MainIntent.GetBanner)
mViewModel.sendUiIntent(MainIntent.GetDetail(0))
}

3.4.3 更新Ui State


调用sendUiState发送Ui State更新


需要注意的是: 在UiState改变时,使用的是copy复制一份原来的UiState,然后修改变动的值。这是为了做到 “可信数据源”,在定义MainState的时候,设置的就是val,是为了避免多线程并发读写,导致线程安全的问题。


class MainViewModel : BaseViewModel<MainState, MainIntent>() {
private val mWanRepo = WanRepository()

override fun initUiState(): MainState {
return MainState(BannerUiState.INIT, DetailUiState.INIT)
}

override fun handleIntent(intent: IUiIntent) {
when (intent) {
MainIntent.GetBanner -> {
requestDataWithFlow(showLoading = true,
request = { mWanRepo.requestWanData() },
successCallback = { data -> sendUiState { copy(bannerUiState = BannerUiState.SUCCESS(data)) } },
failCallback = {})
}
is MainIntent.GetDetail -> {
requestDataWithFlow(showLoading = false,
request = { mWanRepo.requestRankData(intent.page) },
successCallback = { data -> sendUiState { copy(detailUiState = DetailUiState.SUCCESS(data)) } })
}
}
}
}

其中 requestDataWithFlow 是封装的一个网络请求的方法


protected fun <T : Any> requestDataWithFlow(
showLoading: Boolean = true,
request: suspend () -> BaseData<T>,
successCallback: (T) -> Unit,
failCallback: suspend (String) -> Unit = { errMsg ->
//默认异常处理
},
) {
viewModelScope.launch {
val baseData: BaseData<T>
try {
baseData = request()
when (baseData.state) {
ReqState.Success -> {
sendLoadUiState(LoadUiState.ShowMainView)
baseData.data?.let { successCallback(it) }
}
ReqState.Error -> baseData.msg?.let { error(it) }
}
} catch (e: Exception) {
e.message?.let { failCallback(it) }
}
}
}

至此一个MVI的框架基本就搭建完毕了


3.5运行效果


www.alltoall.net_device-2022-12-15-161207_I_ahtLP5Kj.gif

四、 总结


不管是MVC、MVP、MVVM还是MVI,主要就是View和Model之间的交互关系不同



  • MVI的核心是 数据的单向流动

  • MVI使用kotlin flow可以很方便的实现 响应式编程

  • MV整个View只依赖一个State刷新,这个State就是 唯一可信数据源


目前搭建了基础框架,后续还会在此项目的基础上继续封装jetpack等更加完善这个项目。


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

产品经理不靠谱怎么办

一、产品和开发之争 开发和产品宿命的争斗由来已久,倏然就是一对天敌。在刚毕业那会,还不知道产品具体是干啥的时候,就听到了不少产品和开发打架的事情。印象最深的,就是平安产品开发掐架事件了。起因是产品经理提了一个需求,要求APP开发人员可以做到根据用户的手机壳来改...
继续阅读 »


一、产品和开发之争


开发和产品宿命的争斗由来已久,倏然就是一对天敌。

平安产品掐架事件

在刚毕业那会,还不知道产品具体是干啥的时候,就听到了不少产品和开发打架的事情。印象最深的,就是平安产品开发掐架事件了。起因是产品经理提了一个需求,要求APP开发人员可以做到根据用户的手机壳来改变手机软件主题,面对这样的需求,开发自然是要起义的。

真假分辨不是重点,从争论的热点而言可知,就这件事情而言,争论的原因是需求不符合常理。开发做的事情只是对世界建模而不是无中生有。而我们作为开发,平时和产品决斗最多的情况,是对于时间资源之争。产品混乱的开发节奏,不符合逻辑的需求,不合理的时间安排,不重点的优先级安排。

而且很多时候,产品的职位是要比开发的高的,话语权更加的高,会让他们更加的肆无忌惮,可恨!

拿我之前公司的真实的例子来说,产品所谓的需求文档都是短短几句话;一个迭代周期内从来没有按照原订计划上线计划的功能,各种小需求,拍脑袋的需求随意插入。前者,总监对这种行为的解释是需要开发和产品共同去参与设计,相互残缺不漏,不说开发得不到第一手信息,但是你的时间可还是有限的,工资也不是不涨的。后者直接导致了开发的加班。

为什么会有这些不靠谱的产品经理呢?

根本还是我的问题,我没有能力轻易的选择自己工作环境🙃

其次才是他们专业程度不够,被培训机构忽悠的,人人都是产品经理,门槛低工资还高,上可以直接对话老板,下可以指挥程序员,所以导致了什么阿猫阿狗都涌入。

但是产品的门槛其实很高的。他们需要很强逻辑能力, 整理出来的需求需要逻辑自洽, 需要思考用户的操作体验,需要思考人力资源的分配。面对老板、市场、业务方抛来的‘建议’,能够甄别出什么是功能,什么是需求,然后制定出合理的优先级。在敏捷项目中,还要制定迭代的计划,顶得住上面的压力,压得服下面的开发。

其中涉及到的专业技能有社会心理学、管理学、软件工程管理、用户画像学、以及一定的开发基础、一定的设计基础、一定的运营基础。

这些东西的难度不是程序员用计算机能够模拟的,不然为什么会有智障的小爱同学、小冰同学、siri。

根本的目的是为了解决问题

当然,本篇文章依旧《10x程序员》目的并不是为了抨击产品多么多么的不靠谱。就像郑晔老师所说,如果从不靠谱的数量来说,程序员是比产品多得多得。第一是因为程序员基数就比产品的多,第二也是因为万物皆可转码导致的。培训班培养几个月就出来工作了,他能有多强的编程能力?

只是从整个市场来看,当然还是有很多转行的,培训出来的很强的人。

这篇文章的目的,是为了解决点那个我们碰到这些不靠谱的产品经理的时候,我们应该如何怎么办?

首先要知道产品和开发的战争是因何而战的。

二、争论的真相是什么


争论的原因

产品和开发相互攻击是解决不了任何问题的。为了解决争斗,我们首先需要知道到底是争什么?为何而争。方能对症下药。

我们常常会出现下面这样的一个场景:

产品:我们需要一个单点登录的界面。输入账号密码就可以进行我们的界面。

开发:好的

一天时间,界面和交互逻辑,接口哗哗做完。

开发:东西做完了,你来看看

产品:??? 验证码呢?

开发:你又没说

产品:这个不是常识么?

开发:。。。。

又是半天时间,验证码搞定

产品:这个项目是放在门户下面,登录的功能不是应该在门户上面做么?现在跳转到别的项目还需要重新登陆,你怎么想的?

开发:顶你个肺,一开始怎么不说是这个场景

产品:你又没问。。。

🔪 🙎‍♂️

这是由于双方信息不同步的导致的。如果一开始开发就问:

  • 这个需求的用户是谁?

  • 这个需求的使用场景在哪里?

我想问题就会拖到后面了。开发必须要有自己的独立思考,多问几个为什么,才能够减少掉进坑中的次数。

双方的知识储备不一样,双方掌握的信息不一样,得到的结论自然也不一样。

所以这就需要我们在一个信息平台上,才能够沟通得有效率。

而这就需要我们双方都能有一个很好的沟通能力。也需要我们开发多张十个心眼,默认产品都是不靠谱的。多问几个为什么,不要害怕问题幼稚。如果产品都能够一一回应,而且逻辑自洽的话,那么恭喜你,你碰到了一个不错的产品。

有一句话说得好,当你和一个人谈话谈得很开心的时候,很可能是因为对方的段位比你高,他在向下兼容。

当然,出现上面的那些问题,也由于现在解决的问题不再是明确的,常常范围模糊,别说产品自己,业务用户也不知道自己想要什么?这个无形中提高了产品的门槛,还提高了需要软件设计师的架构能力,需要提前布局。

软件开发的主流由面向确定性问题,逐渐变成了面向不确定性问题。为了应付这个问题,敏捷开发这个最佳实践就应运而生。到了中国就变成了“田园敏捷”🐶,需求不明确,所有需求都是P0级。 为了解决这个问题,我们产品和开发能够在有效的资源中做些什么呢?这就不得不提到敏捷开发中两个很重要的阶段,需求澄清和需求反澄清,如果是开发负责人还需要参加需求准入。

沟通的真正目的是什么

先简单的介绍一些敏捷开发流程:

两周一迭代,在进入开发之前,产品内部需要先过一遍需求,随后根据列的需求和开发负责人讨论需求准入,开发负责人会根据人力资源来和产品共同商量,这个迭代可以上的内容。

需求澄清,这个是全体人员都参加,产品一一说需求的逻辑,开发可以提问。

之后就到了需求反澄清,这个阶段是开发在说自己对于需求的开发,以及开发的思路。随后进入开发阶段。开发完成,向产品show case, 测试通过之后前后端封版

封完版提发布工单,然后才进行反版。在这个阶段还包括了每日的站会过需求,还有发版之后的回顾会。

如时间表下图:


从图片可以看到对于开发两个重要的节点,一个是需求澄清,另外一个是需求反澄清。前者是产品在说,开发问。后者是开发在说,产品再问。这两个就是一个很好的拉平双方认知的机会。 这两个沟通的机会至关重要,是有效减少之后扯皮的关键节点。这就需要我们知道如何有效的进行沟通了。

唯心主义不是贬义,而是一个客观的事实。具体表现就在于,这个客观世界和我们所想象的总是不一样的。同样的,由于每个人认知的世界是不一样的,所以信息的传递是会衰减的,你不可能把你理解的信息 100% 传递给另外一个人,而这中间,如何传递,也就是如何描述将直接决定衰减的比例。

可以根据书中信息论模型来进行解释:


幻化为人的沟通的话。人的脑子就是信源,携带着信息到发送器,发送器通过自己的表达通过声带发送给对方,对方接受到信息还需要转译一遍进行自己的大脑。在传送的中间过程,还有噪声源,这个噪声源可以是物理环境认为的嘈杂,也可以认为是双方因为地位的不同,导致的思维方式的不同的噪声。

根据这个例子,可以用下面这张图来表示上面争论的原因:


扮演不同角色的时候,我们的思考模式是不同的。上图是产品作为信源,而开发作为信宿,反之亦然。

作为信源的话,我们将自己脑中的信息通过嘴巴表达出去的过程,是受限于知识储备和表达能力的。也就是说如果我们的知识储备足够的多,表达能力足够的强的话,在发送信息到对方的闹钟的时候,偏差自然也会更加的小。

作为信宿的话,我们开发作为接受的一方,需要提高自己的知识边界,主要是了解业务的前因后果,尽可能的提升解码的能力。

综上所述,我们沟通的目的是为了同步信息,减少对于需求的理解的偏差。而沟通出来的结果,就是共同确立一个验收的标准

只有验收的标准确定下来之后,才可以最到限度的减少后期扯皮的可能性。

那么我们作为开发需要怎么做呢?

开发需要做什么


开发在需求澄清的时候,其他问题都可以不问,但是这两个问题一定要搞清楚。

  1. 需求的背景是什么

  2. 需求能够给用户带来什么业务的价值

前者是为了理解业务的前因后果,当自己当成产品经理,让需求的逻辑能够自洽。后者是换位自己作为一个用户,以用户的视角来看问题。这也和我们公司以用户导向的价值观相符。

在需求反澄清的时候,作为一个前端工程师,我们最低限度的需要出两个东西,一个是API的设计文档,另外一个就是数据走向图。这个数据走向图我的前一篇文章《vue的业务开发如何进行组件化》中进行过阐述,具体可以去那篇文章看看。

敏捷开发不代表文档的缺失。

我曾经把产品问懵逼之后,把需求都砍了一大半。也间接实现了最好维护的代码。

我的目的不是为了砍需求,而是为了写出全世界最好维护的代码,即不用的代码。


三、抛弃固有印象


在程序员眼里:

  • 产品一般都没逻辑、缺乏交流基础(没常识)、没能力没主见;

在产品经理眼里:

  • 程序员通常属于严重沟通障碍、缺乏用户和产品意识、只考虑技术、没有大局观。

抛弃这些固有的刻板印象,沟通和理解更为重要。作为开发不能因为一时的占了上风,就沾沾自喜,大快人心,觉得压了产品一头。爽归爽了,你的工资可还是没动的。班还是要加的。所以解决问题才是主要的目的,不管工作中,还是生活中。 而这就要求我们:

  • 加强专业知识的学习,

  • 增加对彼此工作领域的认知,

  • 用逻辑而非借口来说服对方。

开发可以去考考PMP证书,虽然都说没有含金量,但是你得过了才有资格来说这句话。作为前端还可以去学学基础的美学设计。总的来说就是要扩展自己的知识边界。

而且,大家都是打工人,成年人了,我们要知道矛盾的根源是什么?真的是产品的不靠谱和开发的沟通障碍么?或许不见得。

四、矛盾的根源

之前刷知乎看到过程墨大佬的一段话,记了下来:

在我国,产品经理和研发工程师的核心冲突,是“有限的开发资源”与“无限制的目标”之间的矛盾。 “有限的开发资源”在研发工程师这一边,人力是有限的,人的工作时间是有限的,人的耐心是有限的,人能够做的事情是有限的。

“无限制的目标”在产品经理这一边,无数量限制的需求变更,无规则限制的产品设计流程,无时间限制的工期规划……

怎么解决?

要么提供更多的开发资源,也就是招更多更合格的工程师;要么就让产品经理对自己的行为做更多限制,让产品设计和规划按照客观规律办事。

当然,说到底两者之间的矛盾的根源是我国特色资本主义的内部矛盾,一方面想让团队跑得快,一方面又没有本事进行合理管理,最后产品经理和程序员打架,世人在骂产品经理无能程序员暴躁,其实归根结底是上面人无能而已。

五、一个问题

我之前面试,被问我这么一个问题:

一个需求你评估完成的时间需要两周,但是产品最多只能给你一周的时间,你怎么办?

那场面试虽然过了,但是我没有收到对于我说的答案的评价。所以很好奇大家的答案是什么😂

作者:我是小橘子哦
来源:juejin.cn/post/7175444771173826615

收起阅读 »

微信开放小程序运行SDK,我们的App可以跑小程序了

前言这几天看到微信团队推出了一个名为 Donut 的小程序原生语法开发移动应用框架,通俗的讲就是将微信小程序的能力开放给其他的企业,第三方的 App 也能像微信一样运行小程序了。其实不止微信,面对潜力越来越大的 B 端市场,阿里早期就开放了这样产品——mPaa...
继续阅读 »

前言

这几天看到微信团队推出了一个名为 Donut 的小程序原生语法开发移动应用框架,通俗的讲就是将微信小程序的能力开放给其他的企业,第三方的 App 也能像微信一样运行小程序了。



其实不止微信,面对潜力越来越大的 B 端市场,阿里早期就开放了这样产品——mPaas,只不过阿里没有做太多的宣传推广,再加上并没有兼容市面中占比和使用范围最大的微信小程序,所以一直处于不温不火的状态。

今天就主要对比分析下目前市面上这类产品的技术特点及优劣。

有这些产品

目前这类产品有一个统一的技术名称:小程序容器技术

小程序容器顾名思义,是一个承载小程序的运行环境,可主动干预并进行功能扩展,达到丰富能力、优化性能、提升体验的目的。

目前我已知的技术产品包括:mPaas、FinClip、uniSDK 以及上周微信团队才推出的 Donut。下面我们就一一初略讲下各自的特点。

他们的特点

1、mPaas

mPaaS是源于支付宝 App 的移动开发平台,为移动开发、测试、运营及运维提供云到端的一站式解决方案,能有效降低技术门槛、减少研发成本、提升开发效率,协助企业快速搭建稳定高质量的移动 App。

mPaaS 提供了包括 App 开发、H5 开发、小程序开发的能力,只要按照其文档可以开发 App,而且可以在其开发的 App 上跑 H5、也可跑基于支付宝小程序标准开发的的小程序。


由于行业巨头之间互不对眼,目前 mPaas 仅支持阿里生态的小程序,不能直接兼容例如微信、百度、字节等其他生态平台的小程序。

2、FinClip

FinClip是一款小程序容器,不论是移动 App,还是电脑、电视、车载主机等设备,在集成 FinClip SDK 之后,都能快速获得运行小程序的能力。

提供小程序 SDK 和小程序管理后台,开发者可以将已有的小程序迁移部署在自有 App 中,从而获得足够灵活的小程序开发与管理体验。

FinClip 兼容微信小程序语法,提供全套的的小程序开发管理套件,开发者不需要学习新的语法和框架,使用 FinClip IDE、小程序管理后台、小程序开发文档、FinClip App就能低成本高质量地完成从开发测试,到预览部署的全部工作。


3、Donut

Donut多端框架是支持使用小程序原生语法开发移动应用的框架,开发者可以一次编码,分别编译为小程序和 Android 以及 iOS 应用,实现多端开发。

基于该框架,开发者可以将小程序构建成可独立运行的移动应用,也可以将小程序构建成运行于原生应用中的业务模块。该框架还支持条件编译,开发者可灵活按需构建多端应用模块,可更好地满足企业在不同业务场景下搭建移动应用的需求。


4、uniSDK

Uni-app小程序 SDK,是为原生 App 打造的可运行基于 uni-app 开发的小程序前端项目的框架,从而帮助原生 App 快速获取小程序的能力。uni 小程序 SDK 是原生SDK,提供 Android 版本 和 iOS 版本,需要在原生工程中集成,然后即可运行用uni-app框架开发的小程序前端项目。

Unisdk是 uni-app 小程序生态中的一部分,开发者 App 集成了该 SDK 之后,就可以在自有 App 上面跑起来利用 uni-app 开发的小程序。

优劣势对比

1、各自的优势

mPaas

  • 大而全,App开发、H5开发、小程序开发一应俱全;

  • 技术产品来源于支付宝,背靠蚂蚁金服有大厂背书;

  • 兼容阿里系的小程序,例如支付宝、钉钉、高德、淘宝等;

  • 拥有小程序管理端、云端服务。

FinClip

  • 小而巧,只专注小程序集成,集成SDK后体积增加3M左右,提供小程序全生命周期的管理 ;

  • 提供小程序转 App 服务,能够一定程度解决 App 开发难的问题;

  • 几个产品中唯一支持企业私有化部署的,可进行定制化开发,满足定制化需求;

  • 兼容微信小程序,之前开发者已拥有的微信小程序,可无缝迁移至 FinClip;

  • 多端支持:iOS、Android、Windows、macOS、Linux,国产信创、车载操作系统。

Donut

  • 微信的亲儿子,对微信小程序兼容度有其他厂商无可比拟的优势(但也不是100%兼容微信小程序);

  • 提供小程序转 App 服务,能够一定程度解决 App 开发难的问题;

  • 体验分析支持自动接入功能,无需修改代码即可对应用中的所有元素进行埋点;

  • 提供丰富的登录方法:微信登录、苹果登录、验证码登录等。

uniSDK

  • 开源社区,众人拾柴火焰高;

  • uniapp 开发小程序可迁移至微信、支付宝、百度等平台之上,如果采用 uni 小程序 SDK,之后采用 uni-app 开发小程序,那么就可以实现一次开发,多端上架;

  • 免费不要钱。

2、各自的不足

mPaas

  • 小程序管理略简单,没有小程序全生命周期的管理;

  • App 集成其 SDK 之后,体积会扩大 30M 左右;

  • 不兼容微信小程序,之前微信开发的小程序,需要用支付宝小程序的标准进行重写才可迁移到 mPaaS 上;

  • 目前只支持 iOS 与 Android 集成,不支持其他端。

FinClip

  • 没有对应的移动应用开发平台,只专注于做小程序;

  • 生态能力相较于其他三者相对偏弱,但兼容微信语法可一定程度补齐;

  • 暂不支持 Serveless 服务;

  • 产品快速迭代,既有惊喜,也有未知。

Donut

  • 对小程序的数量、并发数、宽带上限等有比较严格的规定;

  • 目前仅处于 beta 阶段,使用过程有一定 bug 感;

  • 集成后体积增加明显,核心 SDK 500 MB,地图 300 MB;

  • 没有小程序全生命周期的管理;

  • 目前仅支持 iOS 与 Android 集成,不支持其他端。

uniSDK

  • 开源社区,质量由开源者背书,在集成、开发过程当中出现问题,bug解决周期长;

  • uni 小程序 SDK 仅支持使用 uni-app 开发的小程序,不支持纯 wxml 微信小程序运行;

  • 目前 uni 小程序 SDK 仅支持在原生 App 中集成使用,暂不支持 HBuilderX 打包生成的 App 中集成;

  • 目前只支持 iOS 与 Android 集成,不支持其他端。

以上就是关于几个小程序容器的测评分析结果,可以看出并没有完美的选择,每个产品都有自己的一些优势和不足,选择适合自己的就是最好的。希望能给需要的同学一定的参考,如果你有更好的选择欢迎交流讨论。

作者:Finbird
来源:juejin.cn/post/7181301359554068541

收起阅读 »

Java系列 | 远程热部署在美团的落地实践

1 前言1.1 什么是热部署所谓热部署,就是在应用正在运行时升级软件,却不需要重新启动应用。对于Java应用程序来说,热部署就是在运行时更新Java类文件,同时触发Spring以及其他常用第三方框架的一系列重新加载的过程。在这个过程中不需要重新启动,并且修改的...
继续阅读 »

1 前言

1.1 什么是热部署

所谓热部署,就是在应用正在运行时升级软件,却不需要重新启动应用。对于Java应用程序来说,热部署就是在运行时更新Java类文件,同时触发Spring以及其他常用第三方框架的一系列重新加载的过程。在这个过程中不需要重新启动,并且修改的代码实时生效,好比是战斗机在空中完成加油,不需要战斗机熄火降落,一系列操作都在“运行”状态来完成。

1.2 为什么我们需要热部署

据了解,美团内部很多工程师每天本地重启服务高达5~12次,单次大概3~8分钟,每天向Cargo(美团内部测试环境管理工具)部署3~5次,单次时长20~45分钟,部署频繁频次高、耗时长,严重影响了系统上线的效率。而插件提供的本地和远程热部署功能,可让将代码变更“秒级”生效。一般而言,开发者日常工作主要分为开发自测和联调两个场景,下面将分别介绍热部署在每个场景中发挥的作用。


1.2.1 开发自测场景

一般来讲,在用插件之前,开发者修改完代码还需等待3~8分钟启动时间,然后手动构造请求或协调上游发请求,耗时且费力。在使用完热部署插件后,修改完代码可以一键增量部署,让变更“秒级”生效,能够做到快速自测。而对于那些无法本地启动项目,也可以通过远程热部署功能使代码变更“秒级”生效。


1.2.2 联调场景

通常情况下,在使用插件之前,开发者修改代码经过20~35分钟的漫长部署,需要联系上游联调开发者发起请求,一直要等到远程服务器查看日志,才能确认代码生效。在使用热部署插件之后,开发者修改代码远程热部署能够秒级(2~10s)生效,开发者直接发起服务调用,可以节省大量的碎片化时间(热部署插件还具备流量回放、远程调用、远程反编译等功能,可配合进行使用)。


所以,热部署插件希望解决的痛点是:在可控的条件内,帮助开发者减少频繁编译部署的次数,节省碎片化的时间。最终为开发者每天节约出一定量的编码时间

1.3 热部署难在哪

为什么业界目前没有好用的开源工具?因为热部署不等同于热重启,像Tomcat或者Spring Boot DevTools此类热重启模式需要重新加载项目,性能较差。增量热部署难度较大,需要兼容常用的中间件版本,需要深入启动销毁加载流程。以美团为例,我们需要对JPDA(Java Platform Debugger Architecture)、Java Agent、ASM字节码增强、Classloader、Spring框架、Spring Boot框架、MyBatis框架、Mtthrift(美团RPC框架)、Zebra(美团持久层框架)、Pigeon(美团RPC框架),MDP(美团快速开发框架)、XFrame(美团快速开发脚手架)、Crane(美团分布式任务调度框架)等众多框架和技术原理深入了解才能做到全面的兼容和支持。另外,还需要IDEA插件开发能力,形成整体的产品解决方案闭环,美团的热部署插件Sonic正是在这种背景下应运而生。


1.4 Sonic可以做什么

Sonic是美团内部研发设计的一款IDEA插件,旨在通过低代码开发辅助远程/本地热部署,解决Coding、单测编写执行、自测联调等阶段的效率问题,提高开发者的编码产出效率。数据统计表明,开发者日常大概有35%时间用于编码的产出。如果想提高研发效率,要么扩大编码产出的时间占比,要么提高编码阶段的产出效率,而Sonic则聚焦提高编码阶段的产出效率。

目前,使用Sonic热部署可以解决大部分代码重复构建的问题。Sonic可以使用户在本地编写代码一键部署到远程环境,修改代码、部署、联调请求、查看日志,循环反复。如果不考虑代码修改时间,通常一个循环需要20~35分钟,而使用Sonic可以把整个时长缩短至5~10秒,而且能够给开发者带来高效沉浸式的开发体验。在实际编码工作中,多文件修改是家常便饭,Sonic对多文件的热部署能力尤为突出,它可以通过依赖分析等手段来对多文件批量进行远程热部署,并且支持Spring Bean Class、普通Class、Spring XML、MyBatis XML等多类型文件混合热部署。

那么跟业界现有的产品相比,Sonic有哪些优劣势呢?下面我们尝试给出几种产品的对比,仅供大家参考:

特性JRebelSpring Boot DevToolsIDEA热加载Tomcat热加载Spring LoaderSonic
远程Debug基于Debug协议修改
修改方法体内容✅效率低✅效率低
新增方法体✅效率低✅效率低
Jar包变更✅效率低✅效率低
Spring MVC✅效率低✅效率低
多文件热部署✅效率低✅效率低
新增泛型方法✅效率低✅效率低
新增非静态字段✅效率低✅效率低
新增静态字段✅效率低✅效率低
新增修改继承类✅效率低✅效率低
新增修改接口方法✅效率低✅效率低
新增修改匿名内部类✅效率低✅效率低
增加修改静态块✅效率低✅效率低
FastJson✅效率低✅效率低
Cglib✅效率低✅效率低
MyBatis Annotation✅效率低✅效率低
MyBatis XML✅效率低✅效率低
Gson✅效率低✅效率低
Jackson✅效率低✅效率低
Jdk代理✅效率低✅效率低
Log4j✅效率低✅效率低
Slf4J✅效率低✅效率低
Logback✅效率低✅效率低
Spring Tx✅效率低✅效率低
Spring 新增Xml✅效率低✅效率低
Spring Bean✅效率低✅效率低
Spring Boot✅效率低✅效率低
Spring Validator✅效率低✅效率低
远程热部署配置繁琐
IDEA插件集成

上表未把Sofa-Ark、Osgi、Arthas列举,此类属于插件化、模块化应用框架,以及Java在线诊断工具,核心能力非热部署。值得注意的是,Spring Boot DevTools只能应用在Spring Boot项目中,并且它不是增量热部署,而是通过Classloader迭代的方式重启项目,对大项目而言,性能上是无法接受的。虽然,JRebel支持三方插件较多,生态庞大,但是对于国产的插件不支持,例如FastJson等,同时它还存在远程热部署配置局限,对于公司内部的中间件需要个性化开发,并且是商业软件,整体的使用成本较高。

1.5 Sonic远程热部署落地推广的实践经验

相信大家都知道,对于技术产品的推广,尤其是开发、测试阶段使用的产品,由于远离线上环境,推动力、执行力、产品功能闭环能否做好,是决定着该产品是否能在企业内部落地并得到大多数人认可的重要的一环。此外,因为很多开发者在开发、测试阶段已逐渐形成了“固化动作”,如何改变这些用户的行为,让他们拥抱新产品,也是Sonic面临的艰巨挑战之一。我们从主动沟通、零成本(或极低成本)快速接入、自动化脚本,以及产品自动诊断、收集反馈等方向出发,践行出了四条原则。


2 整体设计方案

2.1 Sonic结构

Sonic插件由4大部分组成,包括脚本端、插件端、Agent端,以及Sonic服务端。脚本端负责自动化构建Sonic启动参数、服务启动等集成工作;IDEA插件端集成环境为开发者提供更便捷的热部署服务;Agent端随项目启动负责热部署的功能实现;服务端则负责收集热部署信息、失败上报等统计工作。如下图所示:


2.2 走进Agent

2.2.1 Instrumentation类常用API

public interface Instrumentation {

   //增加一个Class 文件的转换器,转换器用于改变 Class 二进制流的数据,参数 canRetransform 设置是否允许重新转换。
   void addTransformer(ClassFileTransformer transformer, boolean canRetransform);

   //在类加载之前,重新定义 Class 文件,ClassDefinition 表示对一个类新的定义,
   //如果在类加载之后,需要使用 retransformClasses 方法重新定义。addTransformer方法配置之后,后续的类加载都会被Transformer拦截。
   //对于已经加载过的类,可以执行retransformClasses来重新触发这个Transformer的拦截。类加载的字节码被修改后,除非再次被retransform,否则不会恢复。
   void addTransformer(ClassFileTransformer transformer);

   //删除一个类转换器
   boolean removeTransformer(ClassFileTransformer transformer);
   
   //是否允许对class retransform
   boolean isRetransformClassesSupported();

   //在类加载之后,重新定义 Class。这个很重要,该方法是1.6 之后加入的,事实上,该方法是 update 了一个类。
   void retransformClasses(Class<?>... classes) throws UnmodifiableClassException;
 
   //是否允许对class重新定义
   boolean isRedefineClassesSupported();

   //此方法用于替换类的定义,而不引用现有的类文件字节,就像从源代码重新编译以进行修复和继续调试时所做的那样。
   //在要转换现有类文件字节的地方(例如在字节码插装中),应该使用retransformClasses。
   //该方法可以修改方法体、常量池和属性值,但不能新增、删除、重命名属性或方法,也不能修改方法的签名
   void redefineClasses(ClassDefinition... definitions) throws  ClassNotFoundException, UnmodifiableClassException;

   //获取已经被JVM加载的class,有className可能重复(可能存在多个classloader)
   @SuppressWarnings("rawtypes")
   Class[] getAllLoadedClasses();
}

2.2.2 Instrument简介

Instrument的底层实现依赖于JVMTI(JVM Tool Interface),它是JVM暴露出来的一些供用户扩展的接口集合,JVMTI是基于事件驱动的,JVM每执行到一定的逻辑就会调用一些事件的回调接口(如果存在),这些接口可以供开发者去扩展自己的逻辑。

JVMTIAgent是一个利用JVMTI暴露出来的接口提供了代理启动时加载(Agent On Load)、代理通过Attach形式加载(Agent On Attach)和代理卸载(Agent On Unload)功能的动态库。而Instrument Agent可以理解为一类JVMTIAgent动态库,别名是JPLISAgent(Java Programming Language Instrumentation Services Agent),也就是专门为Java语言编写的插桩服务提供支持的代理。

2.2.3 启动时和运行时加载Instrument Agent过程


2.3 那些年JVM和HotSwap之间的“相爱相杀”

围绕着Method Body的HotSwap JVM一直在进行改进。从1.4版本开始,JPDA引入HotSwap机制(JPDA Enhancements),实现Debug时的Method Body的动态性。大家可参考文档:enhancements1.4
1.5版本开始通过JVMTI实现的java.lang.instrument(Java Platform SE 8)的Premain方式,实现Agent方式的动态性(JVM启动时指定Agent)。大家可参考文档:package-summary

1.6版本又增加Agentmain方式,实现运行时动态性(通过The Attach API 绑定到具体VM)。大家可参考文档:package-summary 。基本实现是通过JVMTI的retransformClass/redefineClass进行method、body级的字节码更新,ASM、CGLib基本都是围绕这些在做动态性。但是针对Class的HotSwap一直没有动作(比如Class添加method、添加field、修改继承关系等等),为什么会这样呢?因为复杂度过高,且没有很高的回报。

2.4 Sonic如何解决Instrumentation的局限性

由于JVM限制,JDK 7和JDK 8都不允许改类结构,比如新增字段,新增方法和修改类的父类等,这对于Spring项目来说是致命的。比如开发同学想修改一个Spring Bean,新增一个@Autowired字段,此类场景在实际应用时很多,所以Sonic对此类场景的支持必不可少。

那么,具体是如何做到的呢?这里要提一下“大名鼎鼎”的Dcevm。Dcevm(DynamicCode Evolution Virtual Machine)是Java Hostspot的补丁(严格上来说是修改),允许(并非无限制)在运行环境下修改加载的类文件。当前虚拟机只允许修改方法体(Method,Body),而Decvm可以增加、删除类属性、方法,甚至改变一个类的父类,Dcevm是一个开源项目,遵从GPL 2.0协议。更多关于Dcevm的介绍,大家可以参考:Wuerthinger10a以及GitHub Decvm

值得一提的是,在美团内部,针对Dcevm的安装,Sonic已经打通HULK,集成发布镜像即可完成(本地热部署可结合插件功能实现一键安装热部署环境)。

3 Sonic热部署技术解析

3.1 Sonic整体架构模型

上一章节我们主要介绍了Sonic的组成。下图详细介绍了Sonic在运行期间各个组成部分的工作职责,由它们形成一整套完备的技术产品落地闭环方案:


3.2 Sonic功能流转

Sonic通过NIO监听本地文件变更,触发文件变更事件,例如Class新增、Class修改、Spring Bean重载等事件流程。下图展示了一次热部署单个文件的生命周期:


3.3 文件监听

Sonic首先会在本地和远程预定义两个目录,/var/tmp/sonic/extraClasspath/var/tmp/sonic/classes。extraClasspath为Sonic自定义的拓展Classpath URL,classes为Sonic监听的目录,当有文件变更时,通过IDEA插件来部署到远程/本地,触发Agent的监听目录,来继续下面的热加载逻辑:


为什么Sonic不直接替换用户ClassPath下面的资源文件呢?因为考虑到业务方WAR包的API项目、Spring Boot、Tomcat项目、Jetty项目等,都是以JAR包来启动的,这样是无法直接修改用户的Class文件的。即使是用户项目可以修改,直接操作用户的Class,也会带来一系列的安全问题。

所以,Sonic采用拓展ClassPath URL路径来实现文件的修改和新增。并且存在这么一种场景,多个业务侧的项目引入相同的JAR包,在JAR里面配置MyBatis的XML和注解。在此类情况下,Sonic没有办法直接来修改JAR包中源文件,通过拓展路径的方式可以不需要关注JAR包,来修改JAR包中某一文件和XML。同理,采用此类方法可以进行整个JAR包的热替换。下面我们简单介绍一下Sonic的核心监听器,如下图所示:


3.4 JVM Class Reload

JVM的字节码批量重载逻辑,通过新的字节码二进制流和旧的Class对象生成ClassDefinition定义,instrumentation.redefineClasses(definitions),来触发JVM重载,重载过后将触发初始化时Spring插件注册的Transfrom。接下来,我们简单讲解一下Spring是怎么重载的。

新增class Sonic如何保证可以加载到Classloader上下文中?由于项目在远程执行,所以运行环境复杂,有可能是JAR包方式启动(Spring Boot),也有可能是普通项目,也有可能是War Web项目,针对此类情况Sonic做了一层Classloader URL拓展。


User ClassLoader是框架自定义的ClassLoader统称,例如Jetty项目是WebAppclassLoader。其中Urlclasspath为当前项目的lib文件件下,例如Spring Boot项目也是从当前项目BOOT-INF/lib/路径中加载CLass等等,不同框架的自定义位置稍有不同。所以针对此类情况,Agent必须拿到用户的自定义Classloader,如果是常规方式启动的,比如普通Spring XML项目,借助Plus(美团内部服务发布平台)发布,此类没有自定义Classloader,是默认AppClassLoader,所以Agent在用户项目启动过程中,借助字节码增强的方式来获取到真正的用户Classloader。


找到用户使用的子Classloader之后,通过反射的方式来获取Classloader中的元素Classpath,其中ClassPath中的URL就是当前项目加载Class时需要的所有运行时Class环境,并且包括三方的JAR包依赖等。

Sonic获取到URL数组,把Sonic自定义的拓展Classpath目录加入到URL数组首位,这样当有新增Class时,Sonic只需要将Class文件复制到拓展Classpath对应的包目录下面即可,当有其他Bean依赖新增的Class时,会从当前目录下面查找类文件。

为什么不直接对Appclassloader进行加强?而是对框架的自定义Classloader进行加强?


考虑这样一个场景,框架自定义类加载器中有ClassA,此时用户新增ClassB需要热加载,B Class里面有A的引用关系,如果增强AppClassLoader,初始化B实例时ClassLoader。loadclass首先从UserClassLoader开始加载ClassB的字节码,依靠双亲委派原则,B被Appclassloader加载,因为B依赖类A,所以当前AppClassLoader加载B一定是加载不到的,此时会抛出ClassNotFoundException异常。所以对类加载器拓展,一定要拓展最上层的类加载器,这样才会达到使用者想要的效果。

3.5 Spring Bean重载

Spring Bean Reload过程中,Bean的销毁和重启流程,主要内容如下图展示:


首先当修改Java Class D时,通过Spring ClasspathScan扫描校验当前修改的Bean是否Sprin Bean(注解校验),然后触发销毁流程(BeanDefinitionRegistry.removeBeanDefinition),此方法会将当前Spring上下文中的Bean D和依赖Spring Bean D的Bean C一并销毁,但是作用范围仅仅在当前Spring上下文。如果C被子上下文中的Bean B依赖,就无法更新子上下文中的依赖关系,当有系统请求时,Bean B中关联的Bean C还是热部署之前的对象,所以热部署失败。

因此,在Spring初始化过程中,需要维护父子上下文的对应关系,当子上下文变时若变更范围涉及到Bean B时,需要重新更新子上下文中的依赖关系,当有多上下文关联时需要维护多上下文环境,且当前上下文环境入口需要Reload。这里的入口是指:Spring MVC Controller、Mthrift和Pigeon,对不同的流量入口,采用不同的Reload策略。RPC框架入口主要操作为解绑注册中心、重新注册、重新加载启动流程等等,对Spring MVC Controller,主要是解绑和注册URL Mappping来实现流量入口类的变化切换。

3.6 Spring XML重载

当用户修改/新增Spring XML时,需要对XML中所有Bean进行重载。


重新Reload之后,将Spring销毁后重启。需要注意的是:XML修改方式改动较大,可能涉及到全局的AOP的配置以及前置和后置处理器相关的内容,影响范围为全局,所以目前只放开普通的XML Bean标签的新增/修改,其他能力酌情逐步放开。

3.7 MyBatis 热部署

Spring MyBatis热部署的主要处理流程是在启动期间获取所有Configuration路径,并维护它和Spring Context的对应关系,在热部署Class、XML时去匹配Configuration,从而重新加载Configuration以达到热部署的目的。


4 总结

4.1 热部署功能一览

上一章节主要讲述了Spring Bean、Spring MVC、MyBatis的重载流程,Sonic还支持其它常用的开发框架,丰富的框架支持和兼容能力是Sonic的基石,下面列举一些Sonic支持的常用的第三方框架:


截止目前,Sonic已经支持绝大部分常用第三方框架的热加载,常规业务开发几乎无需重启服务。并且在美团内部的成功率已经高达99.9%以上,真正地让热部署来代替常规部署构建成为一种可能。

4.2 IDE插件集成

Sonic也提供了功能强大的IDEA插件,让用户进行沉浸式开发,远程热部署也变得更加便利。


4.3 推广使用情况

截止到发稿时,Sonic在美团使用人数3000+,应用项目数量2000+。该项目还获得了美团内部2020年下半年到家研发平台“最佳效率团队”奖。

5 作者简介

凯哥、占峰、李晗、龚炎、程骁、玉龙等,均来自美团/到家研发平台。

来源:tech.meituan.com/2022/03/17/java-hotswap-sonic.html

收起阅读 »

程序猿健康防猝指南:体重和减肥的秘密

00、 引言作为一名IT码农,入行十载有余,写的代码(Bug)越来越多,习惯了加班熬夜、久坐不动,身体各项指标也不出意外的屡创新高。近年来各行业高压工作导致的猝死的时有发生,长此以往,充满惊喜的人生不知道404和和503哪个先来!本着科学、严谨的代码精神,大量...
继续阅读 »

00、 引言

作为一名IT码农,入行十载有余,写的代码(Bug)越来越多,习惯了加班熬夜、久坐不动,身体各项指标也不出意外的屡创新高。近年来各行业高压工作导致的猝死的时有发生,长此以往,充满惊喜的人生不知道404和和503哪个先来!


本着科学、严谨的代码精神,大量查阅、学习了健康、运动的相关知识,顺便整理成文。生命在于运动,运动需要科学!


申明:信息都来自书籍、网络,难以保证完全准确,只能尽量追求科学、可信。有些知识本身就存在争议,或科学研究有限只是说明其相关性,并无明确结论。



01、 标准体重与体质指数(BMI<24)

身体质量指数 BMI(Body Mass Index),又称体质指数、体重指数。是目前国际上常用的衡量人体胖瘦程度以及是否健康的一个标准,BMI指数用来判断你的体重正常、超重还是肥胖。

体重的公斤数(单位:千克)除以自己的身高(单位:米)的平方所得到的一个数字,公式:


网上也有很多计算器:薄荷健康 免费在线 BMI 计算器 BMI计算网

中国BMI标准如下图,适用范围:18至65岁的成年人。儿童、发育中的青少年、孕妇、乳母、老人及身型健硕的运动员除外。世界卫生组织认为BMI指数保持在22左右是比较理想的。


您目前BMI指数为:23.12,22.1,身体状况属于 【正常】,您的健康体重范围为 56~73 kg

标准体重有多种计算方法,常用的几个方法:


方法公式示例
世界卫生组织(WHO)的体重计算方法♂️ 男性:标准体重(kg)=(身高cm-80)X70% ♀️ 女性:标准体重(kg)=(身高cm-70)X60%(174-80)X70% = 65.8kg
我国常用的标准体重的计算公式♂️ 男性:标准体重(kg)=身高cm-105 ♀️ 女性:标准体重(kg)=身高cm-105-2.5174-105=69kg
我国征兵标准体重计算: 标准体重kg=身高cm - 110♂️ 男性:不超过30% ,不低于15%,合格 ♀️ 女性:不超过20% ,不低于15% ,合格174-110=64kg
  • 标准体重正负10﹪为正常体重

  • 标准体重正负10﹪~ 20﹪为体重过重或过轻

  • 标准体重正负20﹪以上为肥胖或体重不足

⚠️注意:标准体重和体质体质指数(BMI)是一种基于群体平均值的计算方法,针对单独个体其实并不严谨,个体都是有各种差异的,如年龄、肌肉、骨骼、脂肪含量都不同,BMI超重的人不一定就是肥胖,因此这个数据作为参考即可,体脂率(见后续章节)指标判定胖瘦更为科学。


02、 人体的主要物质=水、脂肪、蛋白质

人体内的水分含量最高,构成人体三大基础物质是糖、蛋白质、脂肪,也是人体的主要的营养物质。


人体必需的七种营养元素(蛋白质、脂肪、碳水化合物、矿物质、维生素、水、膳食纤维)。


2.1、水(多喝开水🐶1500~1700ml

成年人体内水分约占体重的55%~65%,年龄越小体内所含水分的百分比越高。水是细胞生存的基础,人体的各种生理化活动都是在水的参与下完成和实现的。一个成年人每日的摄水量总和约为2500毫升,注意是来自饮水、食物、物质代谢的总和,每天应该饮水1500~1700毫升(不要用饮料代替)。天热、排汗多的人要适当多补充水分。

水的输出:肾脏(尿液 一天1500ml);呼吸(350ml);皮肤(500ml);大便(150ml)。

当人体中缺水量达到人体体重的2%时,会感到口渴;到10%时,会烦躁无力,体温升高,血压下降;达到20%就会有生命危险。

渴了才喝水是不对的,可以观察尿液的颜色和排尿量判断喝水量,正常情况下尿液是淡黄色的,一天的排尿量是1500毫升左右,一般3~4小时排尿一次。如果半天不想上厕所,或者排出的尿液是深黄色的,那就说明饮水量不足了。


2.2、糖(碳水化合物)

糖又称为碳水化合物,由碳、氢、氧三种元素组成的有机化合物,是生物界三大基础物质之一,是人体活动的主要能量来源,谷类食物当中的碳水化合物是主要来源之一。


碳水化合物摄入不足,人就容易出现低血糖症状,皮下脂肪及肌肉也会分解来供能,长期下去就会明显消瘦;反之,如果一个人很胖,特别是腹部肥胖,或者血浆中甘油三酯明显增高,可能碳水化合物摄入过多。

摄入过多碳水,且运动不足,摄入能量多于消耗能量,造成能量的蓄积,会以化学能的形式储存起来,表现为多余的脂肪,从而造成肥胖。白米、白面中的淀粉含量较高,同样100克,米面的淀粉含量是薯类(土豆、山药、芋头等)的四倍,是豆类(赤小豆、芸豆等)的近两倍。因此多摄入粗粮、蔬菜水果,部分代替精致碳水(米面),更有利于控制体重。


2.2、脂肪

脂肪 不仅是人体重要的功能物质,人体每天所需能量有20%-30%来自脂肪。还有构成身体组织和生物活性物质,调节生理机能,保护内脏器官等多种作用。

现代社会中人们普遍面临的是脂肪过剩的问题,所以减肥大多主要是减脂。脂肪堆积在胸部、腹部、大腿及臀部,还有身体内部,如内脏、血管,内部脂肪过多会严重影响我们的身体健康。

2.3、蛋白质

蛋白质是一切生命的物质基础,蛋白质是肌肉的主要组成物质,也是构成大脑、内脏、血液、毛发、骨骼、皮肤、神经、抗体、酶等的基本物质。动物类的食物、豆类、坚果的蛋白质含量较高,而蔬菜水果中几乎没有多少蛋白。谷物的蛋白质含量属于中等,例如米饭90%的淀粉,剩下的就是10%的蛋白质。

人体蛋白质含量16%~20%正常,超标会增大肾脏的负担,对身体反而不好,通过体脂称也可以测量。

2.4、膳食纤维(多吃蔬菜水果!)

它与淀粉的构成差别不大,但却无法被人体消化吸收,对人体有益。膳食纤维最为人所熟知的作用就是促进排便。

  • 有利于通便,不可溶性膳食纤维可以加速肠道的排泄,改善便秘,维护肠道健康。

  • 有利于减肥,由于膳食纤维多的食物能量密度低,并且有饱腹感,从而控制能量摄入量。

膳食纤维主要存在于蔬菜、水果中,精米、精面中很少,肉、鱼、奶中没有。我们每个人一天最好吃1斤蔬菜,其中叶菜最好占一半。水果最好是连皮吃,这样膳食纤维可以多吃一些。

关键事实

  • 蔬菜水果提供丰富的微量营养素、膳食纤维和植物化学物。

  • 增加蔬菜和水果、全谷物摄入可降低心血管疾病的发病和死亡风险。增加全谷物摄入可降低体重增长。

  • 增加蔬菜摄入总量及十字花科蔬菜和绿色叶菜摄入量,可降低肺癌的发病风险。

  • 多摄入蔬菜水果、全谷物,可降低结直肠癌的发病风险。


03、 你的身体是否肥胖?—体脂率

长胖的原因是你摄入的能量超过了消耗的能量,从而导致身体囤积脂肪。我们的脂肪包括“皮下脂肪”和“内脏脂肪”,如果皮下脂肪高,那么通常内脏脂肪也不会低。脂肪含量是衡量身体胖瘦的关键,减肥也大多是减脂(也称燃脂)。

研究表明,与BMI一直保持肥胖的人群相比,将BMI从成年早期的肥胖减至中年时的超重,可显著降低全因死亡率风险,而如果BMI超重或肥胖人群将体重减到正常BMI,则可避免12.4%的早期死亡。 —— 减肥(控制体重)更长寿

3.1、体脂率

体脂率是指人体内脂肪重量在人体总体重中所占的比例,又称体脂百分数,它反映人体内脂肪含量的多少。正常成年人的体脂率分别是男性15%~18%和女性20%~28%。男性体脂肪若超过25%,女性若超过30%则可判定为肥胖。


  体脂率应保持在正常范围,若体脂率过高,超过正常值的20%以上就可视为肥胖。肥胖则表明运动不足、营养过剩或有某种内分泌系统的疾病,而且常会并发高血压、高血脂症、动脉硬化、冠心病、糖尿病、胆囊炎等病症。若体脂率过低,低于体脂含量的安全下限,即男性5%,女性13%~15%,则可能引起功能失调。

3.2、怎么测量体脂率呢?—体脂称

目前体脂称比较通用的测量方法是:BIA测量法。主要原理是将身体简单分为导电的体液、肌肉等,以及不导电的脂肪组织,测量时由电极片发出极微小电流经过身体,若脂肪比率高,则所测得的生物电阻较大,反之亦然。

  • 含水量高的部分,例如肌肉,导电性好,电阻率低。

  • 含水量低的部分,例如脂肪,导电性差,电阻率高。


当我们站在体脂秤上之后, 体脂秤会通过一只脚下的电极片发出人体感知不到的微弱电流,电流穿过你的全身,到达另一只脚下的电极片,形成一个回路。最后结合通过人体的电流大小,即可对脂肪率、肌肉率、内脏脂肪等级等数据进行分析。

3.3、内脏脂肪等级


内脏脂肪等级也叫内脏脂肪指数,正常范围是在1-9。内脏脂肪是我们身体当中一种必需的脂肪组织,与皮下脂肪不一样,皮下脂肪就是看得见、摸得着的所谓的的肥肉。内脏脂肪围绕着人体的脏器,主要在腹腔里面,所以大多表现为腰围粗、啤酒肚。


内脏脂肪等级也可以通过体脂称进行测量,如果超标是必须要重视的,可以通过“运动+合理饮食”减脂减肥。


04、 减肥/减脂的秘密?

4.1、热量差

体重变化的核心公式就是:每天变化的体重 = 每天吃进去的 - 每天消耗的,吃的更多就会体重增加,消耗的更多就会减重。


所以体重的变化取决于热量差,公式:

热量差 = 所有消耗(运动消耗+基础代谢消耗+食物热效应)- 所有摄入(食物热量*肠道吸收率)


这里的 食物热效应 指的是进食导致的额外的能量消耗,这些额外的能量主要用于食物的消化、吸收和代谢储存,又叫食物的特殊热力作用。《中国居民膳食指南》建议运动代谢能量至少占比15%,大约240-260卡路里,除去日常家务、基础活动之外,还需要大概6000步快走的运动量。

4.2、食物热量单位:卡路里

卡路里(Calorie,缩写为cal),简称卡,其定义为将1克水在1大气压下提升1摄氏度所需要的热量。

卡路里 (也叫热量),卡路里是能量单位,我们身体的运行需要能量,各种食物是提供给我们能量的原料,衡量这些能量的单位就是——卡路里。


正常活动量的成年人,《中国居民膳食指南》建议每天摄入的热量:男性2250大卡,女性1800大卡。


1卡路里=1千卡=1大卡=1000卡 = 4.184 千焦耳 一般包装食品的营养成分表中能量单位就用的“千焦”。

1kg脂肪=7700kcal(卡路里) 理论上来说,1kg脂肪=7700卡路里,就是说减肥1Kg,需要消耗7700卡路里,等于14个超级汉堡,慢跑运动15天(每天1个小时)。

4.3、基础代谢消耗(BMR)

基础代谢率是维持人体最基本的生理活动所需要消耗的能量,在安静状态下(通常为静卧状态)消耗的最低热量,主要是身体保持体温、维持心跳、维持呼吸等基本生理活动。基础代谢和年龄、性别、体重、肌肉含量有关。通过体脂称也可测量,在线的计算器:1分钟彻底了解自己

您的年龄身高对应标准体重为 63 KG(1KG=2斤)

您的基础代谢率为 1539 大卡

4.4、减肥的秘密—迈开腿+管住嘴

人体需要的能量是糖分、脂肪以及蛋白质为主,糖分约占比70%,余下是脂肪、蛋白质在人体当中的主要功能不是提供能量,是给器官供给生长和消耗的补充。

那这三种能量是怎么给我们的身体供能的呢?是否有先后顺序呢?是否像网上流行所说等糖分消耗完了才会消耗脂肪吗?

答案是一起消耗!实际上,不管做什么运动,甚至是休息的时候,它们都是同时供能的,只是比例不同。如下图,脂肪(Fat)在有氧运动20分钟后对身体的供能比例提升,碳水(糖分CHO)占比下降。


运动是减脂的最有效手段,但减肥是一个系统工程,必须结合“管住嘴”控制热量输入+“迈开腿”增加热量消耗,双管齐下才有效果。


管住嘴: 在吃的里面,糖(碳水化合物)、脂肪是最容易长胖的了,必须要控制每天的热量摄入,相比运动燃脂,吃就容易太多了!

脂肪的消耗有氧运动为主 + 力量训练为辅!

  • 脂肪的燃烧需要氧气,有氧运动燃脂更高效。

  • 运动要达到中低强度的运动心率,低于或高于这个范围,都不算中低强度运动心率,燃烧的脂肪的比例就不高了。

  • 这种中低强度运动心率的运动要持续20分钟以上。

  • 这种运动必须是大肌肉群的运动,如慢跑、游泳、健身操等。


05、 减肥/减脂的错误认知

❓只节食可以减肥吗?

理论可以,但效果不理想,方法也不对,不利于身体健康。

减脂就是在玩“热量差”的游戏,通过控制热量摄入在短时间内是可以很快有减肥效果,但很容易反弹。我们的身体是非常精明的,当你吃的太少时,你的身体接到的信号是你正在面临食物短缺的危机,为了防止你饿死,它会自动开启节能模式,降低你的基础代谢。也就是说,虽然你摄入的热量变少了,但是你的基础代谢消耗也变少了,并没有产生多大的能量缺口。

很多人通过节食减肥,开始掉秤很快,没几天就反弹回来,这是因为你的身体会先消耗糖原,而每消耗 1g 糖,会同时消耗点 3g 水,所以节食减肥时,你身体里的水分波动非常大,但是脂肪并没有太大变化。虽然你减重了,但没有减脂,可能你再正常吃个两三顿,体重马上又恢复回来了。而且因为之前出现过热量供应短缺的信号,当你再次正常吃的时候,身体反而会存储更多的脂肪来应对下一次危机,这也是为什么很多人越减肥反而越胖的原因。

❓只运动会不会瘦?

一个巨无霸汉堡大约是 500kcal,需要慢跑1个小时才能消耗,可以看到,吃是很容易的,消耗起来却是很难的,必须运动和控制饮食两者结合。

❓流汗是不是就是在减脂?

不是,流汗和脂肪消耗没有直接关系,流汗是身体平衡体温的一种方式。而脂肪大多被分解后(分解为甘油酸酯)通过呼吸排出,小部分在汗液、排便中排出。

❓快跑(高强度)和慢跑(中强度)哪个更燃脂?

慢跑!慢跑15分钟脂肪供能(分解)增加,25分钟明显增多。快跑(高强度)需要的能量更多,脂肪分解(先分解为糖)需要更多时间,不足以支撑高强度运动需求,会直接消耗糖类(糖类供能最快)。因此慢跑减脂效率更高,保持心率60%-75%范围。

❓运动30(*)分钟才会燃脂吗?

就像有人说“运动达不到有效燃脂心率=白练”一样,不是! 只要你还活着,任何时候糖原、脂肪都会消耗,只是比例不同,有氧运动20+分钟燃脂的效率(比例/或效果)更高。

❓运动强度越大燃脂越多吗?

不是,如下图,运动强度越高,身体所需的能量也随之增多,脂肪供能的速度比较慢,供能比例减小。



作者:安木夕
来源:juejin.cn/post/7182374196108853306

收起阅读 »

90%的Java开发人员都会犯的5个错误

前言 作为一名java开发程序员,不知道大家有没有遇到过一些匪夷所思的bug。这些错误通常需要您几个小时才能解决。当你找到它们的时候,你可能会默默地骂自己是个傻瓜。是的,这些可笑的bug基本上都是你忽略了一些基础知识造成的。其实都是很低级的错误。今天,我总结一...
继续阅读 »

前言


作为一名java开发程序员,不知道大家有没有遇到过一些匪夷所思的bug。这些错误通常需要您几个小时才能解决。当你找到它们的时候,你可能会默默地骂自己是个傻瓜。是的,这些可笑的bug基本上都是你忽略了一些基础知识造成的。其实都是很低级的错误。今天,我总结一些常见的编码错误,然后给出解决方案。希望大家在日常编码中能够避免这样的问题。


1. 使用Objects.equals比较对象


这种方法相信大家并不陌生,甚至很多人都经常使用。是JDK7提供的一种方法,可以快速实现对象的比较,有效避免烦人的空指针检查。但是这种方法很容易用错,例如:


Long longValue = 123L;
System.out.println(longValue==123); //true
System.out.println(Objects.equals(longValue,123)); //false

为什么替换==Objects.equals()会导致不同的结果?这是因为使用==编译器会得到封装类型对应的基本数据类型longValue,然后与这个基本数据类型进行比较,相当于编译器会自动将常量转换为比较基本数据类型, 而不是包装类型。


使用该Objects.equals()方法后,编译器默认常量的基本数据类型为int。下面是源码Objects.equals(),其中a.equals(b)使用的是Long.equals()会判断对象类型,因为编译器已经认为常量是int类型,所以比较结果一定是false


public static boolean equals(Object a, Object b) {
return (a == b) || (a != null && a.equals(b));
}

public boolean equals(Object obj) {
if (obj instanceof Long) {
return value == ((Long)obj).longValue();
}
return false;
}

知道了原因,解决方法就很简单了。直接声明常量的数据类型,如Objects.equals(longValue,123L)。其实如果逻辑严密,就不会出现上面的问题。我们需要做的是保持良好的编码习惯。


2. 日期格式错误


在我们日常的开发中,经常需要对日期进行格式化,但是很多人使用的格式不对,导致出现意想不到的情况。请看下面的例子。


Instant instant = Instant.parse("2021-12-31T00:00:00.00Z");
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("YYYY-MM-dd HH:mm:ss")
.withZone(ZoneId.systemDefault());
System.out.println(formatter.format(instant));//2022-12-31 08:00:00

以上用于YYYY-MM-dd格式化, 年从2021 变成了 2022。为什么?这是因为 javaDateTimeFormatter 模式YYYYyyyy之间存在细微的差异。它们都代表一年,但是yyyy代表日历年,而YYYY代表星期。这是一个细微的差异,仅会导致一年左右的变更问题,因此您的代码本可以一直正常运行,而仅在新的一年中引发问题。12月31日按周计算的年份是2022年,正确的方式应该是使用yyyy-MM-dd格式化日期。


这个bug特别隐蔽。这在平时不会有问题。它只会在新的一年到来时触发。我公司就因为这个bug造成了生产事故。


3. 在 ThreadPool 中使用 ThreadLocal


如果创建一个ThreadLocal 变量,访问该变量的线程将创建一个线程局部变量。合理使用ThreadLocal可以避免线程安全问题。


但是,如果在线程池中使用ThreadLocal ,就要小心了。您的代码可能会产生意想不到的结果。举个很简单的例子,假设我们有一个电商平台,用户购买商品后需要发邮件确认。


private ThreadLocal<User> currentUser = ThreadLocal.withInitial(() -> null);

private ExecutorService executorService = Executors.newFixedThreadPool(4);

public void executor() {
executorService.submit(()->{
User user = currentUser.get();
Integer userId = user.getId();
sendEmail(userId);
});
}

如果我们使用ThreadLocal来保存用户信息,这里就会有一个隐藏的bug。因为使用了线程池,线程是可以复用的,所以在使用ThreadLocal获取用户信息的时候,很可能会误获取到别人的信息。您可以使用会话来解决这个问题。


4. 使用HashSet去除重复数据


在编码的时候,我们经常会有去重的需求。一想到去重,很多人首先想到的就是用HashSet去重。但是,不小心使用 HashSet 可能会导致去重失败。


User user1 = new User();
user1.setUsername("test");

User user2 = new User();
user2.setUsername("test");

List<User> users = Arrays.asList(user1, user2);
HashSet<User> sets = new HashSet<>(users);
System.out.println(sets.size());// the size is 2

细心的读者应该已经猜到失败的原因了。HashSet使用hashcode对哈希表进行寻址,使用equals方法判断对象是否相等。如果自定义对象没有重写hashcode方法和equals方法,则默认使用父对象的hashcode方法和equals方法。所以HashSet会认为这是两个不同的对象,所以导致去重失败。


5. 线程池中的异常被吃掉


ExecutorService executorService = Executors.newFixedThreadPool(1);
executorService.submit(()->{
//do something
double result = 10/0;
});

上面的代码模拟了一个线程池抛出异常的场景。我们真正的业务代码要处理各种可能出现的情况,所以很有可能因为某些特定的原因而触发RuntimeException


但是如果没有特殊处理,这个异常就会被线程池吃掉。这样就会导出出现问题你都不知道,这是很严重的后果。因此,最好在线程池中try catch捕获异常。


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

快速上手Compose约束布局

前言 今天对Compose中约束布局的使用方法进行一下记录,我发现在学习Compose的过程中,像Column,Row等布局可以很快上手,可以理解怎样使用,但是对于ConstraintLayout 还是得额外学习一下,所以总结一下进行记录。其实Compose-...
继续阅读 »

前言


今天对Compose中约束布局的使用方法进行一下记录,我发现在学习Compose的过程中,像Column,Row等布局可以很快上手,可以理解怎样使用,但是对于ConstraintLayout 还是得额外学习一下,所以总结一下进行记录。其实Compose-ConstraintLayout 完全是我对传统布局使用习惯的迁移,已经习惯了约束的思维方式。


接下来我们就看ComposeConstraintLayout 是怎样使用的。


使用


首先我们先引入依赖


Groovy


implementation "androidx.constraintlayout:constraintlayout-compose:1.0.1"

Kotlin


implementation("androidx.constraintlayout:constraintlayout-compose:1.0.1")

在传统布局中,我们对约束布局的使用都是通过id进行相互约束的,那在Compose中我们同样需要先创建一个类似id功能一样的引用。


val (text) = createRefs()


在Compose中有两种创建引用的方式:createRefs() 和createRef()。createRef()只能创建一个,createRefs()每次能创建多个(最多16个)。



然后对我们的组件设置约束,这里我用了一个Text()做示例。


ConstraintLayout(modifier = Modifier.fillMaxSize()) {
val (text) = createRefs()
Text("Hello Word", modifier = Modifier.constrainAs(text) {
start.linkTo(parent.start)
top.linkTo(parent.top)
})
}

这样就实现了 Text() 组件在我们布局的左上角。


image-20220821091542253


当我们同时也对end 做出约束,就会达到一个Text()组件在布局中横向居中的效果。


Text("Hello Word", modifier = Modifier.constrainAs(text) {
start.linkTo(parent.start)
end.linkTo(parent.end)
top.linkTo(parent.top)
})

image-20220821091743026


当我们想有一个Button按钮 在文字的下方居中显示,我们可以这样做:


    ConstraintLayout(modifier = Modifier.fillMaxSize()) {
val (text, button) = createRefs()
Text("Hello Word", modifier = Modifier.constrainAs(text) {
start.linkTo(parent.start)
end.linkTo(parent.end)
top.linkTo(parent.top)
})
Button(onClick = {}, modifier = Modifier.constrainAs(button) {
start.linkTo(text.start)
end.linkTo(text.end)
top.linkTo(text.bottom)
}) {
Text("按钮")
}
}

Button组件相对于文字组件做出前,后,顶部约束。


image-20220821092737861


实践


接下来我们尝试使用约束布局来做一个个人信息显示的效果。我们先看下我们要实现的效果:


image-20220821094329930


我们先分解一下这个效果,一个Image图片,一个Text 名称,一个Text 微信号, 还有一个 二维码。


接下来我们就一步步来实现一下。


先是头像部分,我们对Image头像,先进行上,下,前约束,再设置一下左边距,能够留出空间来。


    Image(painter = painterResource(R.drawable.logo8), "head",
contentScale = ContentScale.Crop,
modifier = Modifier.constrainAs(head) {
start.linkTo(parent.start)
top.linkTo(parent.top)
bottom.linkTo(parent.bottom)
}.padding(start = 20.dp).size(60.dp).clip(CircleShape)
)

image-20220821095547624


然后我们开始添加名称和id


Text()名称组件是顶部和头像顶部对齐,start 和 头像的end 进行对齐;Id 是对于名称 start对齐,顶部与名称底部对齐。


       Text("Android开发那点事儿",
style = TextStyle(fontSize = 16.sp,
color = Color.Black, fontWeight = FontWeight(600)),
modifier = Modifier.constrainAs(name) {
top.linkTo(head.top)
start.linkTo(head.end)
}.padding(start = 10.dp)
)

Text("微信号:android-blog",
style = TextStyle(fontSize = 12.sp,
color = Color.DarkGray, fontWeight = FontWeight(400)),
modifier = Modifier.constrainAs(id) {
top.linkTo(name.bottom)
start.linkTo(name.start)
}.padding(start = 10.dp, top = 5.dp)
)

效果:


image-20220821162150814


最后我们来加载二维码,二维码图标和右箭头图标都是从“阿里icon”中找的图标。


将图标相对于头像上下居中,紧靠右边,然后留出间距,然后是箭头上下都跟二维码图标对齐,左侧紧贴二维码的右侧。


    ConstraintLayout(modifier = Modifier.width(300.dp)
.height(80.dp).background(Color.LightGray)) {
........
Image(
painter = painterResource(R.drawable.qr),"",
modifier = Modifier.size(20.dp).constrainAs(qr) {
top.linkTo(head.top)
bottom.linkTo(head.bottom)
end.linkTo(parent.end, 30.dp)
})
Image(
painter = painterResource(R.drawable.left), "",
modifier = Modifier.size(20.dp).constrainAs(left) {
top.linkTo(qr.top)
bottom.linkTo(qr.bottom)
start.linkTo(qr.end)
})

}

我们来看下最后完成的效果。


image-20220821163858144


至此,我们就通过ConstraintLayout完成了一个简单的效果,如果有传统布局的使用基础,Compose的使用起来还是可以很快上手的。


最后


ConstraintLayout 最基础的用法我们就写到这里,另外还有一些进阶用法会在后续的文章中给大家详细介绍。


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

分析了1011个程序员的裁员情况后得出的启示

大家应该能明显感觉到最近几个月求职环境不太好,但究竟有多不好,具体的情况是什么样的?为了分析程序员职场现状,我进行了裁员情况调查,一共有1011位程序员朋友参与。本文会根据调查结果,为大家带来一些启示(如果不想看分析过程,可以直接跳到文末看结论)。裁员真的多么...
继续阅读 »

大家应该能明显感觉到最近几个月求职环境不太好,但究竟有多不好,具体的情况是什么样的?

为了分析程序员职场现状,我进行了裁员情况调查,一共有1011位程序员朋友参与。

本文会根据调查结果,为大家带来一些启示(如果不想看分析过程,可以直接跳到文末看结论)。

裁员真的多么?

工作职级来看,受访者中初级工程师的裁员比例最少(可能是因为工资相对最低,裁员收益不大),而专家及以上最多,但整体差别不大。

平均来看,受访者中有19%经历了裁员。


公司中技术团队人数来定义公司规模技术团队只有几人的小公司裁员最严重,其他更大些的企业差距则不大。


可能是因为太小的企业还没有跑通业务变现的逻辑,老板抗风险能力也更差。

对我们的启示是 —— 为了工作稳定,不一定要去大厂(毕竟裁员比例也不低),而应该尽量选择有稳定业务的企业

你觉得这个裁员比例高吗?

大家都从事什么工作?

很多做业务的程序员会觉得做架构比较高大上。从工作职级来看看,随着职级与能力的提升,确实有越来越多的程序员从事架构工作:


技术团队规模来看,一线大厂(技术团队千人以上)从事架构工作的程序员比例最高,但整体差别不大。

平均来看,约有17%的程序员从事架构工作。


给我们的启示是 —— 在求职架构岗位时,可以打听下公司从事架构岗位的程序员比例,如果高于17%,可能没有多少让你施展拳脚的地方

同时,从上述两个分析看,架构工作既有难度(职级越高,从事架构工作的比例越高),又有稀缺性(公司平均只有17%的程序员从事架构工作)。

那程序员推崇架构工作就不难理解了 —— 因为更难,也更少。

如果业务不赚钱,那么业务线被砍,做业务的程序员被裁,这个逻辑是很好理解的。而做架构一般有通用性。

那么,面对裁员的浪潮,做架构真的比做业务有更高的抗风险能力么?

做架构还是做业务?

工作职级来看从事架构工作的裁员比例,会发现 —— 随着职级上升,架构工作的裁员比例显著提升。


对于立志在架构方面长期发展的程序员,肯定不想随着自己职级提升,被裁的风险越来越高吧。

相对应的,随着职级提升,做业务的程序员被裁的比例会逐渐降低。

虽然不同职级做架构的裁员比例都低于做业务,但诚如上文提到,公司平均只有17%的程序员从事架构工作。显然做业务的工作机会远远多于做架构

这对我们的启示是 —— 经济下行时期,程序员规划职业发展时,尽量向离钱近(做业务)的领域发展

大厂是救命稻草?

尽量往大厂卷是不是可以减少被裁的风险?

公司规模来看架构、业务工作的裁员比例,在技术团队只有几人的公司被裁的风险确实是最大的。但是一线大厂(技术团队千人以上)裁员比例也很高。

风险相对较小的,是技术团队几十人的公司。这样的公司可能自身有稳定的业务,也不盲目扩张,所以裁员规模相对较小。


从表中还发现个有趣的情况 —— 随着公司规模变大,架构岗被裁的比例显著增大。

大家都想去大厂做架构,但大厂架构是被裁的最多的。这是不是侧面印证了,很多大厂搞的高大上的轮子,并没有什么价值?

大家心里也这么想?

上面的很多分析结果,都对架构的同学不友好(尤其是大厂)。那么,大家听到的情况也是这样么?

我统计了你听说你司被裁程序员都是做什么的,其中从事架构岗位的比例如下:


可见,不仅参与调查的当事人的数据汇总后显示 —— 不要去大厂做架构

大家听说的公司的情况汇总后也在印证这一观点。

那么大家意识到在大厂做架构可能并不是个好选择了么?下面是没有被裁员,且认为自己发展前景好的程序员中从事业务、架构的比例


先不管这样的认知是否正确(觉得自己前景好)。单从比例看,不管是小厂大厂,做业务的同学们的认知比例趋于一致。

而大厂做架构的同学显然对自己的前景有极高的预期(不知道他们知不知道,他们也是被裁的比例最高的?)

为什么对于在大厂做架构的同学来说,预期会与实际有这么大差距呢?都是什么职级的同学会觉得公司架构岗被裁的比例更多呢?

下面是按工作职级划分的,谁听说的公司中架构岗被裁的比较多


没有初级工程师觉得公司架构岗被裁的更多,而有56%的专家及以上认为架构岗裁员更多。

年轻人还是太年轻,不愿相信事实。专家们早已看穿了现实。

总结

本次调查为我们带来了几条启示:

  1. 大厂裁员比例也不低。为了工作稳定,应该尽量选择有稳定业务的企业

  2. 在求职架构岗位时,可以打听下公司从事架构岗位的程序员比例,最好低于17%

  3. 不要迷信技术。在经济下行时期,应该尽量选择离钱近的业务

  4. 不要去大厂做架构。实际情况与大部分程序员预期完全不符

不管是做架构还是做业务,我们都要明白 —— 技术是为了创造价值。那么什么是价值

对于好的年景,能够为业务赋能的架构是有价值的。而在不好的年景,价值直接与能赚多少钱划等号,离钱越近的业务,价值就越大。

而这一切,都与技术本身的难度无关。

所以,为了稳定的职业发展,更应该着眼于业务本身,而不是深究技术。

作者:魔术师卡颂
来源:juejin.cn/post/7142674429649109000

收起阅读 »

前端常见登录方案梳理

web
前端登录有很多种方式,我们来挑一些常见的方案先梳理一下,后续再补充更多的。账号密码登录在系统数据库中已经有了账号密码,或者通过注册渠道生成了账号和密码,此时可以直接通过账号密码登录,只要账号密码正确就认为身份合法,可以换到系统访问的 token,用于后续业务鉴...
继续阅读 »

前端登录有很多种方式,我们来挑一些常见的方案先梳理一下,后续再补充更多的。

账号密码登录

在系统数据库中已经有了账号密码,或者通过注册渠道生成了账号和密码,此时可以直接通过账号密码登录,只要账号密码正确就认为身份合法,可以换到系统访问的 token,用于后续业务鉴权。

验证码登录

比如手机验证码,邮箱验证码等等。用户首先提供手机号/邮箱,后端根据会话信息生成一个特定的码下发到用户的手机或者邮箱(通过运营商提供的能力)。

用户得到这个码后填入登录表单,随手机号/邮箱一并发给后端,后端拿到手机号/邮箱、码后,与会话信息做校验,确认身份信息是否合法。

如果一致就检查数据库中是否有这个手机号/邮箱,有的话就不用创建用户了,直接通过登录;没有的话就说明是新用户,可以先创建用户,绑定好手机号/邮箱,然后通过登录。

第三方授权

比如微信授权,github授权之类的,可以通过OAuth授权得到访问对方开放API的能力。

OAuth 协议读起来很复杂,其实本质上就是:

  • 我是开发者,有个自己的业务系统。

  • 用户想图方便,希望通过一些常用的平台(比如微信,支付宝等)登录到我的业务系统。

  • 但是这也不是你想用就能用的,我首先要去三方平台登记一下我的应用,比如注册一个微信公众号,公众号再绑定我的业务域名(验证所有权),可能还要交个费做微信认证之类的。

  • 交了保护费后(经过上面的操作),我的业务系统就是某三方平台的合法应用了,就可以使用某三方平台的开放接口了。

  • 此时用户来到我的业务系统客户端,点击微信一键登录。

  • 然后我的业务系统就会按照微信的规矩生成一些鉴权需要的信息,拉起微信的中间页(如果是手机客户端,那可能就是通过 SDK 拉起手机微信)让用户授权。

  • 用户同意授权,微信的中间页鉴权成功后,就会给我的客户端返回一个 code 之类的回调信息,客户端需要把这个 code 传给后端。

  • 后端拿到这个 code 可以去微信服务器换取 access_token,基于这个 access_token,可以获取微信用户基本开放信息和帮助用户实现基础开放功能等。

  • 后端也可以基于此封装自定义的登录态返给客户端,如有必要,也可以生成用户表中的记录。

  • 此时我就认为这个用户是通过微信合法登录到我的系统中了。

有些字段或者信息之类的可能会描述得不够精确,但是整个鉴权的思路大概就是这样。

微信小程序登录

wx.login + code2Session 无感登录

如果你的业务系统需要鉴权大部分接口,但是又不想让用户一打开小程序就去输入啥或者点啥按钮登录,那么无感登录是比较适合的。

关键是找到能唯一标识用户身份的东西,openid 或者 unionid 就不错。那么怎么无感得到这些?wx.login + code2Session 值得拥有。

小程序前端 wx.login 得到用户登录凭证 code(目前说的有效期是五分钟),然后把 code 传给服务端,服务端调用微信服务的 auth.code2Session,使用 code 换取 openid、unionid、session_key 等信息,session_key 相当于是当前用户在微信的会话标识,我们可以基于此自定义登录态再返回给前端,前端拿着登录态再访问后端的业务接口。

getPhonenumber授权手机号登录

当指定 button 组件的 open-type 为 getPhoneNumber 时,可以拉起手机号授权,手机号某种程度上可以标识用户身份,自然也可以用来做登录。

旧版方案中,getPhonenumber得到的 e 对象中有 encryptedData, iv 字段,传给后端,根据解密算法能得到手机号和区号等信息。手机号也相当于是一种可以唯一标识用户的信息(虽然一个人可以有多个手机号,不过宽松点来说也可以用来标识用户),自然可以用来生成用户表记录,后续再与其他信息做关联即可。

但是旧版方案已经不建议使用了,目前 getPhonenumber得到的 e 对象中有 code 字段,这个 code 和 wx.login 得到的 code 不是同一回事。我们把这个 code 传给后端,后端再调用 phonenumber.getPhoneNumber得到手机号信息。

接着再封装登录态返回给前端即可。

微信公众号登录

首先分析一下渠道,在微信环境中,用户可能会直接通过链接访问 H5,也可能通过公众号菜单进入 H5。

微信公众号网页提供了授权方案,具体可以参考这个网页授权文档。

授权有两种形式,snsapi_base 和 snsapi_userinfo。

这个授权是支持无感的,具体见这个解释。

关于特殊场景下的静默授权

上面已经提到,对于以snsapi_base为 scope 的网页授权,就静默授权的,用户无感知;

对于已关注公众号的用户,如果用户从公众号的会话或者自定义菜单进入本公众号的网页授权页,即使是 scope 为snsapi_userinfo,也是静默授权,用户无感知。

这基本上就是说,如果是 snsapi_base 方式,目的主要是取 token 和 openid,用来做后续业务鉴权,那就是无感的。

如果是 snsapi_userinfo 方式,除了拿鉴权信息,还要要拿头像昵称等信息,可能需要用户授权,不过只要关注了该公众号,也可以不出现授权中间页,也是无感的。

下面说下具体的交互形式。

snsapi_base 场景下,需要绑定一个回调地址,交互形式是:

  1. 根据标准格式提供链接:

https://open.weixin.qq.com/connect/oauth2/authorize?appid=APPID&redirect_uri=REDIRECT_URI&response_type=code&scope=snsapi_base&state=STATE#wechat_redirect
  1. 你可以在公众号菜单跳转这个标准链接,或者通过其他网页跳转这个链接。这个链接是个微信鉴权的中间页,如果鉴权没问题就会回调到 REDIRECT_URI 对应的业务系统页面,也就是用户真正前往的网页,用户能感知到的就是网页的进度条加载了两次,然后就到目标页面了,基本上是无感的。

  2. 页面在回调时会在 querystring 上携带 code 参数。前端在这个页面拿到 code 后,可以传给后端,后端就可以调下面这个接口得到 token 信息,然后封装出登录态返给前端。

https://api.weixin.qq.com/sns/oauth2/access_token?appid=APPID&secret=SECRET&code=CODE&grant_type=authorization_code

具体实现时,不一定要在页面层级上完成 code 换 token 的操作,也可以在应用层级上实现。

  1. 后续可以根据需要进行 refreshToken。

snsapi_userinfo 场景下,也是跳一个标准链接。与 snsapi_base 场景相比,除了 scope 参数不一样,其他都一样。跳转这个标准链接时会根据有没有关注公众号决定是否要拉起授权中间页面。

https://open.weixin.qq.com/connect/oauth2/authorize?appid=APPID&redirect_uri=REDIRECT_URI&response_type=code&scope=snsapi_userinfo&state=STATE#wechat_redirect

接着也可以根据 code 换 token,进行必要的 refreshToken。

最重要的是,在 scope=snsapi_userinfo 场景下,还可以发起获取用户信息的请求,这才是它与 snsapi_base 的本质区别。如果 scope 不符合要求,则无法通过调用下面的接口得到用户信息。

https://api.weixin.qq.com/sns/userinfo?access_token=ACCESS_TOKEN&openid=OPENID&lang=zh_CN

还有一些公告调整内容要注意一下:

结语

好了,前端常见的一些登录方式先整理到这里,实际上还有很多种方案还没提到,比如生物认证登录,运营商验证登录等等,后面再补充,只要是双方互相认可的方案,并且能标识用户身份,不管是严格的还是宽松的,都可以拿来做认证使用,具体还要根据你的业务特性决定。

作者:Tusi
来源:juejin.cn/post/7172026468535369735

收起阅读 »

B站:你阳了和我裁员有什么关系

千万不要为了情怀去一家公司,尤其是持续亏损的公司,当他们裁员自救的时候,情怀这东西,啥也不是。下半年来,B站断断续续的裁员,最近疫情感染迅速,很多打工人一边发烧头痛,一边坚持工作。更惨的是,还有一些人被折磨的死去活来,还得撑着病体,接受着被裁的通知。犀牛在名古...
继续阅读 »

千万不要为了情怀去一家公司,尤其是持续亏损的公司,当他们裁员自救的时候,情怀这东西,啥也不是。

下半年来,B站断断续续的裁员,最近疫情感染迅速,很多打工人一边发烧头痛,一边坚持工作。更惨的是,还有一些人被折磨的死去活来,还得撑着病体,接受着被裁的通知。

犀牛在名古屋长跑:牛的,上周我被裁员,这周我对象也接到 hr 通知约谈。但他刚好阳性在家,hr 在明知道他阳性发烧的情况下一直在打电话,要求线上沟通,想赶紧完成他的 kpi。对象在床上一边发烧,一边偷偷抹眼泪。我在旁边看着有气发不出,只是心疼他。他应届毕业放弃其他 offer,拿了 b 站 sp 进来,现在却突然被裁。打工人已经很惨了,选 b 站打工,惨上加惨。

翻了下聊天记录,从发帖人和网友的对话中,了解到这对小情侣都是毕业时应届加入的 b 站,一方面,公司给他们开出了 sp 级别的 offer;另一方面,他们本身也是 b 站的资深用户,骨子里对这家公司还是有美好的向往和热爱的。

如今,没想到还没度过试用期,就收到了裁员的消息,而且是双双被裁,年关将至,人阳了、工作没了,对他们来说,梦想在这一刻,破碎的稀里哗啦的,这属实操蛋的生活。

在进行职业抉择的时候,持续亏损的企业、部门,尽量避免去,那里面暴雷的概率太大了。

创业从来都是九死一生的,无论是企业内部创业还是外部创业,都是如此,在老板眼里,大部分员工是资源、是耗材,业务红火的时候,疯狂投钱招人,遇到瓶颈时,就会冷静下来仔细盘算,开始降本增效。

打工要有打工的觉悟,不要觉得老板们冷酷无情,我们自己当了老板,也不一定会干的好,不一定更有人情味。现在站在打工人的视角,就要做好自身的基本面,避开那些风险高的公司和部门。

去稳定一些的公司,即使拿的工资少点,也是能够接受的,眼下稳定是最为重要的。我工作了几年了,越来越明白一个道理,穷的地方,裁起人来是很狠的。这和人品素质无关,公司、部门自己都撑不下去了,只能断臂求生。

b 站是 18 年 3 月份上市的,到现在小五年的时间了,还是持续亏损,股价曾经有过一段辉煌期,美股最高点157,现在 20 左右徘徊,今年三季度亏损 17 亿,同比收窄了,但距离盈利,还是有很长一段路要走。

年底失业,短时间内想找到工作,是较为困难的,建议他们等身体康复之后,开始整理这半年的工作经验,同时回顾下面试过程中的八股文,等到年后,一些公司盘点新年计划之后,新放出来hc,市场的情况会稍稍回暖一些,这时候面试成功的概率会大一些。

只不过,这个年就不那么好过了,大概率是不敢对两鬓斑斑的老父母说的,成年人了,很多事情,都是自己默默承担。

来源:公子龙

收起阅读 »

订单30分钟未支付自动取消怎么实现?

目录 了解需求 方案 1:数据库轮询 方案 2:JDK 的延迟队列 方案 3:时间轮算法 方案 4:redis 缓存 方案 5:使用消息队列 了解需求 在开发中,往往会遇到一些关于延时任务的需求。 例如 生成订单 30 分钟未支付,则自动取消 生成订单 ...
继续阅读 »

目录



  • 了解需求

  • 方案 1:数据库轮询

  • 方案 2:JDK 的延迟队列

  • 方案 3:时间轮算法

  • 方案 4:redis 缓存

  • 方案 5:使用消息队列


了解需求


在开发中,往往会遇到一些关于延时任务的需求。


例如



  • 生成订单 30 分钟未支付,则自动取消

  • 生成订单 60 秒后,给用户发短信


对上述的任务,我们给一个专业的名字来形容,那就是延时任务。那么这里就会产生一个问题,这个延时任务和定时任务的区别究竟在哪里呢?一共有如下几点区别


定时任务有明确的触发时间,延时任务没有


定时任务有执行周期,而延时任务在某事件触发后一段时间内执行,没有执行周期


定时任务一般执行的是批处理操作是多个任务,而延时任务一般是单个任务


下面,我们以判断订单是否超时为例,进行方案分析


方案 1:数据库轮询


思路


该方案通常是在小型项目中使用,即通过一个线程定时的去扫描数据库,通过订单时间来判断是否有超时的订单,然后进行 update 或 delete 等操作


实现


可以用 quartz 来实现的,简单介绍一下


maven 项目引入一个依赖如下所示


<dependency>
  <groupId>org.quartz-scheduler</groupId>
  <artifactId>quartz</artifactId>
  <version>2.2.2</version>
</dependency>

调用 Demo 类 MyJob 如下所示


package com.rjzheng.delay1;

import org.quartz.*;
import org.quartz.impl.StdSchedulerFactory;

public class MyJob implements Job {

  public void execute(JobExecutionContext context) throws JobExecutionException {
      System.out.println("要去数据库扫描啦。。。");
  }

  public static void main(String[] args) throws Exception {
      // 创建任务
      JobDetail jobDetail = JobBuilder.newJob(MyJob.class)
              .withIdentity("job1", "group1").build();
      // 创建触发器 每3秒钟执行一次
      Trigger trigger = TriggerBuilder
              .newTrigger()
              .withIdentity("trigger1", "group3")
              .withSchedule(
                      SimpleScheduleBuilder
                              .simpleSchedule()
                              .withIntervalInSeconds(3).
                              repeatForever())
              .build();
      Scheduler scheduler = new StdSchedulerFactory().getScheduler();
      // 将任务及其触发器放入调度器
      scheduler.scheduleJob(jobDetail, trigger);
      // 调度器开始调度任务
      scheduler.start();
  }

}

运行代码,可发现每隔 3 秒,输出如下


要去数据库扫描啦。。。

优点


简单易行,支持集群操作


缺点



  • 对服务器内存消耗大

  • 存在延迟,比如你每隔 3 分钟扫描一次,那最坏的延迟时间就是 3 分钟

  • 假设你的订单有几千万条,每隔几分钟这样扫描一次,数据库损耗极大


方案 2:JDK 的延迟队列


思路


该方案是利用 JDK 自带的 DelayQueue 来实现,这是一个无界阻塞队列,该队列只有在延迟期满的时候才能从中获取元素,放入 DelayQueue 中的对象,是必须实现 Delayed 接口的。


DelayedQueue 实现工作流程如下图所示



其中 Poll():获取并移除队列的超时元素,没有则返回空


take():获取并移除队列的超时元素,如果没有则 wait 当前线程,直到有元素满足超时条件,返回结果。


实现


定义一个类 OrderDelay 实现 Delayed,代码如下


package com.rjzheng.delay2;

import java.util.concurrent.Delayed;
import java.util.concurrent.TimeUnit;

public class OrderDelay implements Delayed {

  private String orderId;

  private long timeout;

  OrderDelay(String orderId, long timeout) {
      this.orderId = orderId;
      this.timeout = timeout + System.nanoTime();
  }

  public int compareTo(Delayed other) {
      if (other == this) {
          return 0;
      }
      OrderDelay t = (OrderDelay) other;
      long d = (getDelay(TimeUnit.NANOSECONDS) - t.getDelay(TimeUnit.NANOSECONDS));
      return (d == 0) ? 0 : ((d < 0) ? -1 : 1);
  }

  // 返回距离你自定义的超时时间还有多少
  public long getDelay(TimeUnit unit) {
      return unit.convert(timeout - System.nanoTime(), TimeUnit.NANOSECONDS);
  }

  void print() {
      System.out.println(orderId + "编号的订单要删除啦。。。。");
  }

}

运行的测试 Demo 为,我们设定延迟时间为 3 秒


package com.rjzheng.delay2;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.DelayQueue;
import java.util.concurrent.TimeUnit;

public class DelayQueueDemo {

  public static void main(String[] args) {
      List<String> list = new ArrayList<String>();
      list.add("00000001");
      list.add("00000002");
      list.add("00000003");
      list.add("00000004");
      list.add("00000005");

      DelayQueue<OrderDelay> queue = newDelayQueue < OrderDelay > ();
      long start = System.currentTimeMillis();
      for (int i = 0; i < 5; i++) {
          //延迟三秒取出
          queue.put(new OrderDelay(list.get(i), TimeUnit.NANOSECONDS.convert(3, TimeUnit.SECONDS)));
          try {
              queue.take().print();
              System.out.println("After " + (System.currentTimeMillis() - start) + " MilliSeconds");
          } catch (InterruptedException e) {
              e.printStackTrace();
          }
      }
  }

}

输出如下


00000001编号的订单要删除啦。。。。
After 3003 MilliSeconds
00000002编号的订单要删除啦。。。。
After 6006 MilliSeconds
00000003编号的订单要删除啦。。。。
After 9006 MilliSeconds
00000004编号的订单要删除啦。。。。
After 12008 MilliSeconds
00000005编号的订单要删除啦。。。。
After 15009 MilliSeconds

可以看到都是延迟 3 秒,订单被删除


优点


效率高,任务触发时间延迟低。


缺点



  • 服务器重启后,数据全部消失,怕宕机

  • 集群扩展相当麻烦

  • 因为内存条件限制的原因,比如下单未付款的订单数太多,那么很容易就出现 OOM 异常

  • 代码复杂度较高


方案 3:时间轮算法


思路


先上一张时间轮的图(这图到处都是啦)



时间轮算法可以类比于时钟,如上图箭头(指针)按某一个方向按固定频率轮动,每一次跳动称为一个 tick。这样可以看出定时轮由个 3 个重要的属性参数,ticksPerWheel(一轮的 tick 数),tickDuration(一个 tick 的持续时间)以及 timeUnit(时间单位),例如当 ticksPerWheel=60,tickDuration=1,timeUnit=秒,这就和现实中的始终的秒针走动完全类似了。


如果当前指针指在 1 上面,我有一个任务需要 4 秒以后执行,那么这个执行的线程回调或者消息将会被放在 5 上。那如果需要在 20 秒之后执行怎么办,由于这个环形结构槽数只到 8,如果要 20 秒,指针需要多转 2 圈。位置是在 2 圈之后的 5 上面(20 % 8 + 1)


实现


我们用 Netty 的 HashedWheelTimer 来实现


给 Pom 加上下面的依赖


<dependency>
  <groupId>io.netty</groupId>
  <artifactId>netty-all</artifactId>
  <version>4.1.24.Final</version>
</dependency>

测试代码 HashedWheelTimerTest 如下所示


package com.rjzheng.delay3;

import io.netty.util.HashedWheelTimer;
import io.netty.util.Timeout;
import io.netty.util.Timer;
import io.netty.util.TimerTask;

import java.util.concurrent.TimeUnit;

public class HashedWheelTimerTest {

  static class MyTimerTask implements TimerTask {

      boolean flag;

      public MyTimerTask(boolean flag) {
          this.flag = flag;
      }

      public void run(Timeout timeout) throws Exception {
          System.out.println("要去数据库删除订单了。。。。");
          this.flag = false;
      }
  }

  public static void main(String[] argv) {
      MyTimerTask timerTask = new MyTimerTask(true);
      Timer timer = new HashedWheelTimer();
      timer.newTimeout(timerTask, 5, TimeUnit.SECONDS);
      int i = 1;
      while (timerTask.flag) {
          try {
              Thread.sleep(1000);
          } catch (InterruptedException e) {
              e.printStackTrace();
          }
          System.out.println(i + "秒过去了");
          i++;
      }
  }

}

输出如下


1秒过去了
2秒过去了
3秒过去了
4秒过去了
5秒过去了
要去数据库删除订单了。。。。
6秒过去了

优点


效率高,任务触发时间延迟时间比 delayQueue 低,代码复杂度比 delayQueue 低。


缺点



  • 服务器重启后,数据全部消失,怕宕机

  • 集群扩展相当麻烦

  • 因为内存条件限制的原因,比如下单未付款的订单数太多,那么很容易就出现 OOM 异常


方案 4:redis 缓存


思路一


利用 redis 的 zset,zset 是一个有序集合,每一个元素(member)都关联了一个 score,通过 score 排序来取集合中的值


添加元素:ZADD key score member [score member …]


按顺序查询元素:ZRANGE key start stop [WITHSCORES]


查询元素 score:ZSCORE key member


移除元素:ZREM key member [member …]


测试如下


添加单个元素
redis> ZADD page_rank 10 google.com
(integer) 1

添加多个元素
redis> ZADD page_rank 9 baidu.com 8 bing.com
(integer) 2

redis> ZRANGE page_rank 0 -1 WITHSCORES
1) "bing.com"
2) "8"
3) "baidu.com"
4) "9"
5) "google.com"
6) "10"

查询元素的score值
redis> ZSCORE page_rank bing.com
"8"

移除单个元素
redis> ZREM page_rank google.com
(integer) 1

redis> ZRANGE page_rank 0 -1 WITHSCORES
1) "bing.com"
2) "8"
3) "baidu.com"
4) "9"

那么如何实现呢?我们将订单超时时间戳与订单号分别设置为 score 和 member,系统扫描第一个元素判断是否超时,具体如下图所示



实现一


package com.rjzheng.delay4;

import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.Tuple;

import java.util.Calendar;
import java.util.Set;

public class AppTest {

  private static final String ADDR = "127.0.0.1";

  private static final int PORT = 6379;

  private static JedisPool jedisPool = new JedisPool(ADDR, PORT);

  public static Jedis getJedis() {
      return jedisPool.getResource();
  }

  //生产者,生成5个订单放进去
  public void productionDelayMessage() {
      for (int i = 0; i < 5; i++) {
          //延迟3秒
          Calendar cal1 = Calendar.getInstance();
          cal1.add(Calendar.SECOND, 3);
          int second3later = (int) (cal1.getTimeInMillis() / 1000);
          AppTest.getJedis().zadd("OrderId", second3later, "OID0000001" + i);
          System.out.println(System.currentTimeMillis() + "ms:redis生成了一个订单任务:订单ID为" + "OID0000001" + i);
      }
  }

  //消费者,取订单

  public void consumerDelayMessage() {
      Jedis jedis = AppTest.getJedis();
      while (true) {
          Set<Tuple> items = jedis.zrangeWithScores("OrderId", 0, 1);
          if (items == null || items.isEmpty()) {
              System.out.println("当前没有等待的任务");
              try {
                  Thread.sleep(500);
              } catch (InterruptedException e) {
                  e.printStackTrace();
              }
              continue;
          }
          int score = (int) ((Tuple) items.toArray()[0]).getScore();
          Calendar cal = Calendar.getInstance();
          int nowSecond = (int) (cal.getTimeInMillis() / 1000);
          if (nowSecond >= score) {
              String orderId = ((Tuple) items.toArray()[0]).getElement();
              jedis.zrem("OrderId", orderId);
              System.out.println(System.currentTimeMillis() + "ms:redis消费了一个任务:消费的订单OrderId为" + orderId);
          }
      }
  }

  public static void main(String[] args) {
      AppTest appTest = new AppTest();
      appTest.productionDelayMessage();
      appTest.consumerDelayMessage();
  }

}

此时对应输出如下



可以看到,几乎都是 3 秒之后,消费订单。


然而,这一版存在一个致命的硬伤,在高并发条件下,多消费者会取到同一个订单号,我们上测试代码 ThreadTest


package com.rjzheng.delay4;

import java.util.concurrent.CountDownLatch;

public class ThreadTest {

  private static final int threadNum = 10;
  private static CountDownLatch cdl = newCountDownLatch(threadNum);

  static class DelayMessage implements Runnable {
      public void run() {
          try {
              cdl.await();
          } catch (InterruptedException e) {
              e.printStackTrace();
          }
          AppTest appTest = new AppTest();
          appTest.consumerDelayMessage();
      }
  }

  public static void main(String[] args) {
      AppTest appTest = new AppTest();
      appTest.productionDelayMessage();
      for (int i = 0; i < threadNum; i++) {
          new Thread(new DelayMessage()).start();
          cdl.countDown();
      }
  }

}

输出如下所示



显然,出现了多个线程消费同一个资源的情况。


解决方案


(1)用分布式锁,但是用分布式锁,性能下降了,该方案不细说。


(2)对 ZREM 的返回值进行判断,只有大于 0 的时候,才消费数据,于是将 consumerDelayMessage()方法里的


if(nowSecond >= score){
  String orderId = ((Tuple)items.toArray()[0]).getElement();
  jedis.zrem("OrderId", orderId);
  System.out.println(System.currentTimeMillis()+"ms:redis消费了一个任务:消费的订单OrderId为"+orderId);
}

修改为


if (nowSecond >= score) {
  String orderId = ((Tuple) items.toArray()[0]).getElement();
  Long num = jedis.zrem("OrderId", orderId);
  if (num != null && num > 0) {
      System.out.println(System.currentTimeMillis() + "ms:redis消费了一个任务:消费的订单OrderId为" + orderId);
  }
}

在这种修改后,重新运行 ThreadTest 类,发现输出正常了


思路二


该方案使用 redis 的 Keyspace Notifications,中文翻译就是键空间机制,就是利用该机制可以在 key 失效之后,提供一个回调,实际上是 redis 会给客户端发送一个消息。是需要 redis 版本 2.8 以上。


实现二


在 redis.conf 中,加入一条配置


notify-keyspace-events Ex


运行代码如下


package com.rjzheng.delay5;

import redis.clients.jedis.JedisPool;
import redis.clients.jedis.JedisPubSub;

public class RedisTest {

  private static final String ADDR = "127.0.0.1";
  private static final int PORT = 6379;
  private static JedisPool jedis = new JedisPool(ADDR, PORT);
  private static RedisSub sub = new RedisSub();

  public static void init() {
      new Thread(new Runnable() {
          public void run() {
              jedis.getResource().subscribe(sub, "__keyevent@0__:expired");
          }
      }).start();
  }

  public static void main(String[] args) throws InterruptedException {
      init();
      for (int i = 0; i < 10; i++) {
          String orderId = "OID000000" + i;
          jedis.getResource().setex(orderId, 3, orderId);
          System.out.println(System.currentTimeMillis() + "ms:" + orderId + "订单生成");
      }
  }

  static class RedisSub extends JedisPubSub {
      @Override
      public void onMessage(String channel, String message) {
          System.out.println(System.currentTimeMillis() + "ms:" + message + "订单取消");

      }
  }
}

输出如下



可以明显看到 3 秒过后,订单取消了


ps:redis 的 pub/sub 机制存在一个硬伤,官网内容如下


原:Because Redis Pub/Sub is fire and forget currently there is no way to use this feature if your application demands reliable notification of events, that is, if your Pub/Sub client disconnects, and reconnects later, all the events delivered during the time the client was disconnected are lost.


翻: Redis 的发布/订阅目前是即发即弃(fire and forget)模式的,因此无法实现事件的可靠通知。也就是说,如果发布/订阅的客户端断链之后又重连,则在客户端断链期间的所有事件都丢失了。因此,方案二不是太推荐。当然,如果你对可靠性要求不高,可以使用。


优点


(1) 由于使用 Redis 作为消息通道,消息都存储在 Redis 中。如果发送程序或者任务处理程序挂了,重启之后,还有重新处理数据的可能性。


(2) 做集群扩展相当方便


(3) 时间准确度高


缺点


需要额外进行 redis 维护


方案 5:使用消息队列


思路


我们可以采用 rabbitMQ 的延时队列。RabbitMQ 具有以下两个特性,可以实现延迟队列


RabbitMQ 可以针对 Queue 和 Message 设置 x-message-tt,来控制消息的生存时间,如果超时,则消息变为 dead letter


lRabbitMQ 的 Queue 可以配置 x-dead-letter-exchange 和 x-dead-letter-routing-key(可选)两个参数,用来控制队列内出现了 deadletter,则按照这两个参数重新路由。结合以上两个特性,就可以模拟出延迟消息的功能,具体的,我改天再写一篇文章,这里再讲下去,篇幅太长。


优点


高效,可以利用 rabbitmq 的分布式特性轻易的进行横向扩展,消息支持持久化增加了可靠性。


缺点


本身的易用度要依赖于 rabbitMq 的运维.因为要引用 rabbitMq,所以复杂度和成本变高。


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

为什么计算机中的负数要用补码表示?

思维导图: 1. 为什么计算机要使用二进制数制? 所谓数制其实就是一种 “计数的进位方式”。 常见的数制有十进制、二进制、八进制和十六进制: 十进制是我们日常生活中最熟悉的进位方式,它一共有 0、1、2、3、4、5、6、7、8 和 9 十个符号。在计数...
继续阅读 »

思维导图:





1. 为什么计算机要使用二进制数制?


所谓数制其实就是一种 “计数的进位方式”。


常见的数制有十进制、二进制、八进制和十六进制:




  • 十进制是我们日常生活中最熟悉的进位方式,它一共有 0、1、2、3、4、5、6、7、8 和 9 十个符号。在计数的过程中,当某一位满 10 时,就需要向它临近的高位进一,即逢十进一;




  • 二进制是程序员更熟悉的进位方式,也是随着计算机的诞生而发展起来的,它只有 0 和 1 两个符号。在计数的过程中,当某一位满 2 时,就需要向它临近的高位进一,即逢二进一;




  • 八进制和十六进制同理。




那么,为什么计算机要使用二进制数制,而不是人类更熟悉的十进制呢?其原因在于二进制只有两种状态,制造只有 2 个稳定状态的电子元器件可以使用高低电位或有无脉冲区分,而相比于具备多个状态的电子元器件会更加稳定可靠。




2.有符号数与无符号数


在计算机中会区分有符号数和无符号数,无符号数不需要考虑符号,可以将数字编码中的每一位都用来存放数值。有符号数需要考虑正负性,然而计算机是无法识别符号的 “正+” 或 “负-” 标志的,那怎么办呢?


好在我们发现 “正 / 负” 是两种截然不同的状态,正好可以映射到计算机能够理解的 “0 / 1” 上。因此,我们可以直接 “将符号数字化”,将 “正+” 数字化为 “0”,将 “负-” 数字化为 “1”,并将数字化后的符号和数值共同组成数字编码。


另外,为了计算方便,我们额外再规定将 “符号位” 放在数字编码的 “最高位”。例如,+1110-1110 用 8 位二进制表示就是:



  • 0000, 1110(符号作为编码的一部分,最高位 0 表示正数)

  • 1000, 1110(符号作为编码的一部分,最高位 1 表示负数)


从中我们也可以看出无符号数和有符号数的区别:




  • 1、最高位功能不同: 无符号数的编码中的每一位都可以用来存放数值信息,而有符号数需要在编码的最高位留出一位符号位;




  • 2、数值范围不同: 相同位数下有符号数和无符号数表示的数值范围不同。以 16 位数为例,无符号数可以表示 065536,而有符号数可以表示 -3276832768。





提示: 无符号数和有符号数表示的数值范围大小是一样大的,n 位二进制最多只能表示 2n2^n 个信息量,这是无法被突破的。





3. 机器数的运算效率问题


在计算机中,我们会把带 “正 / 负” 符号的数称为真值(True Value),而把符号化后的数称为机器数(Computer Number)。


机器数才是数字在计算机中的二进制表示。 例如在前面的数字中, +1110 是真值,而 0000, 1110 是机器数。新的问题来了:将符号数字化后的机器数,在运算的过程中符号位是否与数值参与运算,又应该如何运算呢?


我们先举几个加法运算的例子:



  • 两个正数相加:


0000, 1110 + 0000, 0001 = 0000, 1111 // 14 + 1 = 15 正确
^ ^ ^
符号位 符号位 符号位


  • 两个负数相加:


1000, 1110 + 1000, 0001 = 0000, 1111 // (-14) + (-1) = 15 错误
^ ^ ^
符号位 符号位 符号位(最高位的 1 溢出)


  • 正负数相加:


0000, 1110 + 1000, 0001 = 1001, 1111 // 14 + (-1) = -15 错误
^ ^ ^
符号位 符号位 符号位

可以看到,在对机器数进行 “按位加法” 运算时,只有两个正数的加法运算的结果是正确的,而包含负数的加法运算的结果却是错误的,会出现 -14 - 1 = 1514 - 1 = -15 这种错误结果。


所以,带负数的加法运算就不能使用常规的按位加法运算了,需要做特殊处理:




  • 两个正数相加:



    • 直接做按位加法。




  • 两个负数相加:



    • 1、用较大的绝对值 + 较小的绝对值(加法运算);

    • 2、最终结果的符号为负。




  • 正负数相加:



    • 1、判断两个数的绝对值大小(数值部分);

    • 2、用较大的绝对值 - 较小的绝对值(减法运算);

    • 3、最终结果的符号取绝对值较大数的符号。




哇🤩?好好的加法运算给整成减法运算? 运算器的电路设计不仅要多设置一个减法器,而且运算步骤还特别复杂。那么,有没有不需要设置减法器,而且步骤简单的方案呢?




4. 原码、反码、补码


为了解决有符号机器数运算效率问题,计算机科学家们提出多种机器数的表示法:



























机器数正数负数
原码符号位表示符号
数值位表示真值的绝对值
符号位表示数字的符号
数值位表示真值的绝对值
反码无(或者认为是原码本身)符号位为 1
数值位是对原码数值位的 “按位取反”
补码无(或者认为是原码本身)在负数反码的基础上 + 1



  • 1、原码: 原码是最简单的机器数,例如前文提到从 +1110-1110 转换得到的 0000, 11101000, 1110 就是原码表示法,所以原码在进行数字运算时会存在前文提到的效率问题;




  • 2、反码: 反码一般认为是原码和补码转换的中间过渡;




  • 3、补码: 补码才是解决机器数的运算效率的关键, 在计算机中所有 “整型类型” 的负数都会使用补码表示法;



    • 正数的补码是原码本身;

    • 零的补码是零;

    • 负数的补码是在反码的基础上再加 1。




很多教材和网上的资料会认为正数的原码、反码和补码是相同的,这么说倒也不影响什么。 但结合补码的设计原理,小彭的观点是正数是没有反码和补码的,负数使用补码是为了找到一个 “等价” 的正补数代替负数参与计算,将加减法运算统一为两个正数加法运算,而正数自然是不需要替换的,所以也就没有补码的形式。



提示: 为了便于你理解,小彭后文会继续用 “正数的补码是原码本身” 这个观点阐述。





5. 使用补码消除减法运算


理解补码表示法后,似乎还是不清楚补码有什么用❓


我们重新计算上一节的加法运算试试:
























































举例真值原码反码补码
+14+11100000, 11100000, 11100000, 1110
+13+11010000, 11010000, 11010000, 1101
-14+11101000, 11101111, 00011111, 0010
-15-11101000, 11111111, 00001111, 0001
+1+00010000, 00010000, 00010000, 0001
-1-00011000, 00011111, 11101111, 1111


  • 两个正数相加:


// 补码表示法
0000, 1110 + 0000, 0001 = 0000, 1111 // 14 + 1 = 15 正确
^ ^ ^
符号位 符号位 符号位


  • 两个负数相加:


// 补码表示法
1111, 0010 + 1111, 1111 = 1111, 0001 // (-14) + (-1) = -15 正确
^ ^ ^
符号位 符号位 符号位(最高位的 1 溢出)


  • 正负数相加:


// 补码表示法
0000, 1110 + 1111, 1111 = 0000, 1101 // 14 + (-1) = 13 正确
^ ^ ^
符号位 符号位 符号位(最高位的 1 溢出)

可以看到,使用补码表示法后,有符号机器数加法运算就只是纯粹的加法运算,不会因为符号的正负性而采用不同的计算方法,也不需要减法运算。因此电路设计中只需要设置加法器和补数器,就可以完成有符号数的加法和减法运算,能够简化电路设计。


除了消除减法运算外,补码表示法还实现了 “0” 的机器数的唯一性:


在原码表示法中,“+0” 和 “-0” 都是合法的,而在补码表示法中 “0” 只有唯一的机器数表示,即 0000, 0000 。换言之补码能够比原码多表示一个最小的负数 1000, 0000


最后提供按照不同表示法解释二进制机器数后得到的真值对比:






































































二进制数无符号真值原码真值反码真值补码真值
0000, 00000+0+0+0
0000, 00011+1+1+1
1000, 0000128-0(负零,无意义)-127-128(多表示一个数)
1000, 0001129-1-126-127
1111, 1110254-126-1-2
1111, 1111255-127-0(负零)-1



6. 补码我懂了,但是为什么?


理解原码和补码的定义不难,理解补码作用也不难,难的是理解补码是怎么设计出来的,总不可能是被树上的苹果砸到后想到的吧?


这就要提到数学中的 “补数” 概念:



  • 1、当一个正数和一个负数互为补数时,它们的绝对值之和就是模;

  • 2、一个负数可以用它的正补数代替。


6.1 时钟里的补数


听起来很抽象对吧❓其实生活中,就有一个更加形象的例子 —— 时钟,时钟里就蕴含着补数的概念!


比如说,现在时钟的时针刻度指向 6 点,我们想让它指向 3 点,应该怎么做:



  • 方法 1 : 逆时针地拨动 3 个点数,让时针指向 3 点,这相当于做减法运算 -3;

  • 方法 2: 顺时针地拨动 9 个点数,让时针指向 3 点,这相当于做加法运算 +9。


可以看到,对于时钟来说 -3 和 +9 竟然是等价的! 这是因为时钟只能 12 个小时,当时间点数超过 12 时就会自动丢失,所以 15 点和 3 点在时钟看来是都是 3 点。如果我们要在时钟上进行 6 - 3 减法运算,我们可以将 -3 等价替换为它的正补数 +9 后参与计算,从而将减法运算替换为 6 + 9 加法运算,结果都是 3。



6.2 十进制的例子


理解了补数的概念后,我们再多看一个十进制的例子:我们要计算十进制 354365 - 95937 = 的结果,怎么做呢?



  • 方法 1 - 借位做减法: 常规的做法是利用连续向前借位做减法的方式计算,这没有问题;

  • 方法 2 - 减模加补: 使用补数的概念后,我们就可以将减法运算消除为加法运算。


具体来说,如果我们限制十进制数的位长最多只有 6 位,那么模就是 1000000,-95937 对应的正补数就是 1000000 - 95937 = 904063 。此时,我们可以直接用正补数代替负数参与计算,则有:


354365 - 95937 // = 258428

= 354365 - (1000000 - 904063)

= 354365 - 1000000 + 904063 【减整加补】

= 258428

可以看到,把 -95937 等价替换为 +904063 后,就把减法运算替换为加法运算。细心的你可能要举手提问了,还是需要减去 1000000 呀?🙋🏻‍♀️ 


其实并不用,因为 1000000 是超过位数限制的,所以减去 1000000 这一步就像时针逆时针拨动一整圈一样是无效的。所以实际上需要计算的是:


// 实际需要计算的是:
354365 + 904063
= 1258428 = 258428
^
最高位 1 超出位数限制,直接丢弃

6.3 为什么要使用补码?


继续使用前文提到的 14 + (-1) 正负数相加的例子:


// 原码表示法
0000, 1110 + 1000, 0001 = 1001, 1111 // 14 + (-1) = -15 错误
^ ^ ^
符号位 符号位 符号位

// 补码表示法
0000, 1110 + 1111, 1111 = 1, 0000, 1101 // 14 + (-1) = 13 正确
^ ^ ^
符号位 符号位 最高位 1 超出位数限制,直接丢弃

如果我们限制二进制数字的位长最多只有 8 位,那么模就是 1, 0000, 0000 ,此时,-1 的二进制数 1000, 0001 的正补数就是 1111, 1111


我们使用正补数 1111, 1111 代替负数 1000, 0001 参与运算,加法运算后的结果是 1, 0000, 1101。其中最高位 1 超出位数限制,直接丢弃,所以最终结果是 0000, 1101,也就是 13,计算正确。


补码示意图



到这里,相信补码的设计原理已经很清楚了。


补码的关键在于:找到一个与负数等价的正补数,使用该正补数代替负数,从而将减法运算替换为两个正数加法运算。 补码的出现与运算器的电路设计有关,从设计者的角度看,希望尽可能简化电路设计和计算复杂度。而使用正补数代替负数就可以消除减法器,实现简化电路的目的。


所以,小彭认为只有负数才存在补码,正数本身就是正数,根本就没必要使用补数,更不需要转为补码。而且正数使用补码的话,还不能把负数转补码的算法用在正数上,还得强行加一条 “正数的补码是原码本身” 的规则,就离谱好吧。




7. 总结




  • 1、无符号数的编码中的每一位都可以用来存放数值信息,而有符号数需要在最高位留出一位符号位;




  • 2、在有符号数的机器数运算中,需要对正数和负数采用不同的计算方法,而且需要引入减法器;




  • 3、为了解决有符号机器数运算效率问题,计算机科学家们提出多种机器数的表示法:原码、反码、补码和移码;




  • 4、使用补码表示法后,运算器可以消除减法运算,而且实现了 “0” 的机器数的唯一性;




  • 5、补码的关键是找到一个与负数等价的正补数,使用该正补数代替负数参与计算,从而将减法运算替换为加法运算。




在前文讲补码的地方,我们提到计算机所有 “整型类型” 的负数都会使用补码表示法,刻意强调 “整数类型” 是什么原因呢,难道浮点数和整数在计算机中的表示方法不同吗?这个问题我们在 下一篇文章 里讨论,请关注。




参考资料




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

掌握这17张图,没人比你更懂RecyclerView的预加载

实际上,预拉取(prefetch)机制作为RecyclerView的重要特性之一,常常与缓存复用机制一起配合使用、共同协作,极大地提升了RecyclerView整体滑动的流畅度。 并且,这种特性在ViewPager2中同样得以保留,对ViewPager2滑动效...
继续阅读 »

实际上,预拉取(prefetch)机制作为RecyclerView的重要特性之一,常常与缓存复用机制一起配合使用、共同协作,极大地提升了RecyclerView整体滑动的流畅度。


并且,这种特性在ViewPager2中同样得以保留,对ViewPager2滑动效果的呈现也起着关键性的作用。因此,我们ViewPager2系列的第二篇,就是要来着重介绍RecyclerView的预拉取机制。




预拉取是指什么?


在计算机术语中,预拉取指的是在已知需要某部分数据的前提下,利用系统资源闲置的空档,预先拉取这部分数据到本地,从而提高执行时的效率。


具体到RecyclerView预拉取的情境则是:



  1. 利用UI线程正好处于空闲状态的时机




  1. 预先拉取待进入屏幕区域内的一部分列表项视图并缓存起来




  1. 从而减少因视图创建或数据绑定等耗时操作所引起的卡顿。



预拉取是怎么实现的?


正如把缓存复用的实际工作委托给了其内部的Recycler类一样,RecyclerView也把预拉取的实际工作委托给了一个名为GapWorker的类,其内部的工作流程,可以用以下这张思维导图来概括:



接下来我们就循着这张思维导图,来一一拆解预拉取的工作流程。


1.发起预拉取工作


通过查找对GapWorker对象的引用,我们可以梳理出3个发起预拉取工作的时机,分别是:



  • RecyclerView被拖动(Drag)时



    @Override
public boolean onTouchEvent(MotionEvent e) {
...
switch (action) {
...
case MotionEvent.ACTION_MOVE: {
...
if (mScrollState == SCROLL_STATE_DRAGGING) {
...
// 处于拖动状态并且存在有效的拖动距离时
if (mGapWorker != null && (dx != 0 || dy != 0)) {
mGapWorker.postFromTraversal(this, dx, dy);
}
}
}
break;
...
}
...
return true;
}


  • RecyclerView惯性滑动(Fling)时



    class ViewFlinger implements Runnable {
...
@Override
public void run() {
...
if (!smoothScrollerPending && doneScrolling) {
...
} else {
...
if (mGapWorker != null) {
mGapWorker.postFromTraversal(RecyclerView.this, consumedX, consumedY);
}
}
}
...
}


  • RecyclerView嵌套滚动时


   private void nestedScrollByInternal(int x, int y, @Nullable MotionEvent motionEvent, int type) {
...
if (mGapWorker != null && (x != 0 || y != 0)) {
mGapWorker.postFromTraversal(this, x, y);
}
...
}

2.执行预拉取工作


GapWorker是Runnable接口的一个实现类,意味着其执行工作的入口必然是在run方法。


final class GapWorker implements Runnable {
@Override
public void run() {
...
prefetch(nextFrameNs);
...
}
}

在run方法内部我们可以看到其调用了一个prefetch方法,在进入该方法之前,我们先来分析传入该方法的参数。


        // 查询最近一个垂直同步信号发出的时间,以便我们可以预测下一个
final int size = mRecyclerViews.size();
long latestFrameVsyncMs = 0;
for (int i = 0; i < size; i++) {
RecyclerView view = mRecyclerViews.get(i);
if (view.getWindowVisibility() == View.VISIBLE) {
latestFrameVsyncMs = Math.max(view.getDrawingTime(), latestFrameVsyncMs);
}
}
...
// 预测下一个垂直同步信号发出的时间
long nextFrameNs = TimeUnit.MILLISECONDS.toNanos(latestFrameVsyncMs) + mFrameIntervalNs;

prefetch(nextFrameNs);

由该方法的实参命名nextFrameNs可知,传入的是下一帧开始绘制的时间


了解过Android屏幕刷新机制的人都知道,当GPU渲染完图形数据并放入图像缓冲区(buffer)之后,显示屏(Display)会等待垂直同步信号(Vsync)发出,随即交换缓冲区并取出缓冲数据,从而开始对新的一帧的绘制。


所以,这个实参同时也表示下一个垂直同步信号(Vsync)发出的时间,这是个预测值,单位为纳秒。由最近一个垂直同步信号发出的时间(latestFrameVsyncMs),加上每一帧刷新的间隔时间(mFrameIntervalNs)计算而成。


其中,每一帧刷新的间隔时间是这样子计算得到的:


    // 如果取自显示屏的刷新率数据有效,则不采用默认的60fps
// 注意:此查询我们只静态地执行一次,因为它非常昂贵(>1ms)
Display display = ViewCompat.getDisplay(this);
float refreshRate = 60.0f; // 默认的刷新率为60fps
if (!isInEditMode() && display != null) {
float displayRefreshRate = display.getRefreshRate();
if (displayRefreshRate >= 30.0f) {
refreshRate = displayRefreshRate;
}
}
mGapWorker.mFrameIntervalNs = (long) (1000000000 / refreshRate); // 1000000000纳秒=1秒

也即假定在默认60fps的刷新率下,每一帧刷新的间隔时间应为16.67ms。


再由该方法的形参命名deadlineNs可知,传入的参数表示的是预抓取工作完成的最后期限


    void prefetch(long deadlineNs) {
...
}

综合一下就是,预抓取的工作必须在下一个垂直同步信号发出之前,也即下一帧开始绘制之前完成


什么意思呢?


这是由于从Android 5.0(API等级21)开始,出于提高UI渲染效率的考虑,Android系统引入了RenderThread机制,即渲染线程。这个机制负责接管原先主线程中繁重的UI渲染工作,使得主线程可以更加专注于与用户的交互,从而大幅提高页面的流畅度。



但这里有一个问题。


当UI线程提前完成工作,并将一个帧传递给RenderThread渲染之后,就会进入所谓的休眠状态,出现了大量的空闲时间,直至下一帧开始绘制之前。如图所示:



一方面,这些UI线程上的空闲时间并没有被利用起来,相当于珍贵的线程资源被白白浪费掉;


另一方面,新的列表项进入屏幕时,又需要在UI线程的输入阶段(Input)就完成视图创建与数据绑定的工作,这会推迟UI线程及RenderThread上的其他工作,如果这些被推迟的工作无法在下一帧开始绘制之前完成,就有可能造成界面上的丢帧卡顿。


GapWorker正是选择在此时间窗口内安排预拉取的工作,也即把创建和绑定的耗时操作,移到UI线程的空闲时间内完成,与原先的RenderThread并行执行



但这个预拉取的工作同样必须在下一帧开始绘制之前完成,否则预拉取的列表项视图还是会无法被及时地绘制出来,进而导致丢帧卡顿,于是才有了前面表示最后期限的传入参数。


了解完这个参数的含义后,让我们继续往下阅读源码。


2.1 构建预拉取任务列表


    void prefetch(long deadlineNs) {
buildTaskList();
...
}

进入prefetch方法后可以看到,预拉取的第一个动作就是先构建预拉取的任务列表,其内部又可分为以下3个事项:


2.1.1 收集预拉取的列表项数据


    private void buildTaskList() {
// 1.收集预拉取的列表项数据
final int viewCount = mRecyclerViews.size();
int totalTaskCount = 0;
for (int i = 0; i < viewCount; i++) {
RecyclerView view = mRecyclerViews.get(i);
// 仅对当前可见的RecyclerView收集数据
if (view.getWindowVisibility() == View.VISIBLE) {
view.mPrefetchRegistry.collectPrefetchPositionsFromView(view, false);
totalTaskCount += view.mPrefetchRegistry.mCount;
}
}
...
}

    static class LayoutPrefetchRegistryImpl
implements RecyclerView.LayoutManager.LayoutPrefetchRegistry
{
...
void collectPrefetchPositionsFromView(RecyclerView view, boolean nested) {
...
// 启用了预拉取机制
if (view.mAdapter != null
&& layout != null
&& layout.isItemPrefetchEnabled()) {
if (nested) {
...
} else {
// 基于移动量进行预拉取
if (!view.hasPendingAdapterUpdates()) {
layout.collectAdjacentPrefetchPositions(mPrefetchDx, mPrefetchDy,
view.mState, this);
}
}
...
}
}
}

public class LinearLayoutManager extends RecyclerView.LayoutManager implements
ItemTouchHelper.ViewDropHandler, RecyclerView.SmoothScroller.ScrollVectorProvider
{

public void collectAdjacentPrefetchPositions(int dx, int dy, RecyclerView.State state,
LayoutPrefetchRegistry layoutPrefetchRegistry)
{
// 根据布局方向取水平方向的移动量dx或垂直方向的移动量dy
int delta = (mOrientation == HORIZONTAL) ? dx : dy;
...
ensureLayoutState();
// 根据移动量正负值判断移动方向
final int layoutDirection = delta > 0 ? LayoutState.LAYOUT_END : LayoutState.LAYOUT_START;
final int absDelta = Math.abs(delta);
// 收集与预拉取相关的重要数据,并存储到LayoutState
updateLayoutState(layoutDirection, absDelta, true, state);
collectPrefetchPositionsForLayoutState(state, mLayoutState, layoutPrefetchRegistry);
}

}

这一事项主要是依据RecyclerView滚动的方向,收集即将进入屏幕的、待预拉取的列表项数据,其中,最关键的2项数据是:



  • 待预拉取项的position值——用于预加载项位置的确定

  • 待预拉取项与RecyclerView可见区域的距离——用于预拉取任务的优先级排序


我们以最简单的LinearLayoutManager为例,看一下这2项数据是怎样收集的,其最关键的实现就在于前面的updateLayoutState方法。


假定此时我们的手势是向上滑动的,则其进入的是layoutToEnd == true的判断:


    private void updateLayoutState(int layoutDirection, int requiredSpace,
boolean canUseExistingSpace, RecyclerView.State state)
{
...
if (layoutToEnd) {
...
// 步骤1,获取滚动方向上的第一个项
final View child = getChildClosestToEnd();
// 步骤2,确定待预拉取项的方向
mLayoutState.mItemDirection = mShouldReverseLayout ? LayoutState.ITEM_DIRECTION_HEAD
: LayoutState.ITEM_DIRECTION_TAIL;
// 步骤3,确认待预拉取项的position
mLayoutState.mCurrentPosition = getPosition(child) + mLayoutState.mItemDirection;
mLayoutState.mOffset = mOrientationHelper.getDecoratedEnd(child);
// 步骤4,确认待预拉取项与RecyclerView可见区域的距离
scrollingOffset = mOrientationHelper.getDecoratedEnd(child)
- mOrientationHelper.getEndAfterPadding();

} else {
...
}
...
mLayoutState.mScrollingOffset = scrollingOffset;
}


步骤1,获取RecyclerView滚动方向上的第一项,如图中①所示:


步骤2,确定待预拉取项的方向。不用反转布局的情况下是ITEM_DIRECTION_TAIL,该值等于1,如图中②所示:


步骤3,确认待预拉取项的position值。由滚动方向上的第一项的position值加上步骤2确定的方向值相加得到,对应的是RecyclerView待进入屏幕区域的下一个项,如图中③所示:


步骤4,确认待预拉取项与RecyclerView可见区域的距离,该值由以下2个值相减得到:



  • getEndAfterPadding:指的是RecyclerView去除了Padding后的底部位置,并不完全等于RecyclerView的高度。

  • getDecoratedEnd:指的是由列表项的底部位置,加上列表项设立的外边距,再加上列表项间隔的高度计算得到的值。


我们用一张图来说明一下:



首先,图中的①表示一个完整的屏幕可见区域,其中:



  • 深灰色区域对应的是RecyclerView设立的上下内边距,即Padding值。

  • 中灰色区域对应的是RecyclerView的列表项分隔线,即Decoration。

  • 浅灰色区域对应的是每一个列表项设立的外边距,即Margin值。


RecyclerView的实际可见区域,是由虚线a和虚线b所包围的区域,即去除了上下内边距之后的区域。getEndAfterPadding方法返回的值,即是虚线b所在的位置。


图中的②是对RecyclerView底部不可见区域的透视图,假定现在position=2的列表项的底部正好贴合到RecyclerView可见区域的底部,则getDecoratedEnd方法返回的值,即是虚线c所在的位置。


接下来,如果按前面的步骤4进行计算,即用虚线c所在的位置减去的虚线b所在的位置,得到的就是图中的③,即刚好是列表项的外边距加上分隔线的高度。


这个结果就是待预拉取列表项与RecyclerView可见区域的距离。随着向上滑动的手势这个距离值逐渐变小,直到正好进入RecyclerView的可见区域时变为0,随后开始预加载下一项。


这2项数据收集到之后,就会调用GapWorker的addPosition方法,以交错的形式存放到一个int数组类型的mPrefetchArray结构中去:


        @Override
public void addPosition(int layoutPosition, int pixelDistance) {
...
// 根据实际需要分配新的数组,或以2的倍数扩展数组大小
final int storagePosition = mCount * 2;
if (mPrefetchArray == null) {
mPrefetchArray = new int[4];
Arrays.fill(mPrefetchArray, -1);
} else if (storagePosition >= mPrefetchArray.length) {
final int[] oldArray = mPrefetchArray;
mPrefetchArray = new int[storagePosition * 2];
System.arraycopy(oldArray, 0, mPrefetchArray, 0, oldArray.length);
}

// 交错存放position值与距离
mPrefetchArray[storagePosition] = layoutPosition;
mPrefetchArray[storagePosition + 1] = pixelDistance;

mCount++;
}


需要注意的是,RecyclerView每次的预拉取并不限于单个列表项,实际上,它可以一次获取多个列表项,比如使用了GridLayoutManager的情况


2.1.2 根据预拉取的数据填充任务列表


    private void buildTaskList() {
...
// 2.根据预拉取的数据填充任务列表
int totalTaskIndex = 0;
for (int i = 0; i < viewCount; i++) {
RecyclerView view = mRecyclerViews.get(i);
...
LayoutPrefetchRegistryImpl prefetchRegistry = view.mPrefetchRegistry;
final int viewVelocity = Math.abs(prefetchRegistry.mPrefetchDx)
+ Math.abs(prefetchRegistry.mPrefetchDy);
// 以2为偏移量进行遍历,从mPrefetchArray中分别取出前面存储的position值与距离
for (int j = 0; j < prefetchRegistry.mCount * 2; j += 2) {
final Task task;
if (totalTaskIndex >= mTasks.size()) {
task = new Task();
mTasks.add(task);
} else {
task = mTasks.get(totalTaskIndex);
}
final int distanceToItem = prefetchRegistry.mPrefetchArray[j + 1];

// 与RecyclerView可见区域的距离小于滑动的速度,该列表项必定可见,任务需要立即执行
task.immediate = distanceToItem <= viewVelocity;
task.viewVelocity = viewVelocity;
task.distanceToItem = distanceToItem;
task.view = view;
task.position = prefetchRegistry.mPrefetchArray[j];

totalTaskIndex++;
}
}
...
}

Task是负责存储预拉取任务数据的实体类,其所包含属性的含义分别是:



  • position:待预加载项的Position值

  • distanceToItem:待预加载项与RecyclerView可见区域的距离

  • viewVelocity:RecyclerView的滑动速度,其实就是滑动距离

  • immediate:是否立即执行,判断依据是与RecyclerView可见区域的距离小于滑动的速度

  • view:RecyclerView本身


从第2个for循环可以看到,其是以2为偏移量进行遍历,从mPrefetchArray中分别取出前面存储的position值与距离的


2.1.3 对任务列表进行优先级排序


填充任务列表完毕后,还要依据实际情况对任务进行优先级排序,其遵循的基本原则就是:越可能快进入RecyclerView可见区域的列表项,其预加载的优先级越高


    private void buildTaskList() {
...
// 3.对任务列表进行优先级排序
Collections.sort(mTasks, sTaskComparator);
}

   static Comparator sTaskComparator = new Comparator() {
@Override
public int compare(Task lhs, Task rhs) {
// 首先,优先处理未清除的任务
if ((lhs.view == null) != (rhs.view == null)) {
return lhs.view == null ? 1 : -1;
}

// 然后考虑需要立即执行的任务
if (lhs.immediate != rhs.immediate) {
return lhs.immediate ? -1 : 1;
}

// 然后考虑滑动速度更快的
int deltaViewVelocity = rhs.viewVelocity - lhs.viewVelocity;
if (deltaViewVelocity != 0) return deltaViewVelocity;

// 最后考虑与RecyclerView可见区域距离最短的
int deltaDistanceToItem = lhs.distanceToItem - rhs.distanceToItem;
if (deltaDistanceToItem != 0) return deltaDistanceToItem;

return 0;
}
};


2.2 调度预拉取任务


    void prefetch(long deadlineNs) {
...
flushTasksWithDeadline(deadlineNs);
}

预拉取的第二个动作,则是将前面填充并排序好的任务列表依次调度执行:


    private void flushTasksWithDeadline(long deadlineNs) {
for (int i = 0; i < mTasks.size(); i++) {
final Task task = mTasks.get(i);
if (task.view == null) {
break; // 任务已完成
}
flushTaskWithDeadline(task, deadlineNs);
task.clear();
}
}

    private void flushTaskWithDeadline(Task task, long deadlineNs) {
long taskDeadlineNs = task.immediate ? RecyclerView.FOREVER_NS : deadlineNs;
RecyclerView.ViewHolder holder = prefetchPositionWithDeadline(task.view,
task.position, taskDeadlineNs);
...
}

2.2.1 尝试根据position获取ViewHolder对象


进入prefetchPositionWithDeadline方法后,我们终于再次见到了上一篇的老朋友——Recycler,以及熟悉的成员方法tryGetViewHolderForPositionByDeadline


    private RecyclerView.ViewHolder prefetchPositionWithDeadline(RecyclerView view,
int position, long deadlineNs)
{
...
RecyclerView.Recycler recycler = view.mRecycler;
RecyclerView.ViewHolder holder;
try {
...
holder = recycler.tryGetViewHolderForPositionByDeadline(
position, false, deadlineNs);
...
}

这个方法我们在上一篇文章有介绍过,作用是尝试根据position获取指定的ViewHolder对象,如果从缓存中查找不到,就会重新创建并绑定。


2.2.2 根据绑定成功与否添加到mCacheViews或RecyclerViewPool


    private RecyclerView.ViewHolder prefetchPositionWithDeadline(RecyclerView view,
int position, long deadlineNs)
{
...
if (holder != null) {
if (holder.isBound() && !holder.isInvalid()) {
// 如果绑定成功,则将该视图进入缓存
recycler.recycleView(holder.itemView);
} else {
//没有绑定,所以我们不能缓存视图,但它会保留在池中直到下一次预取/遍历。
recycler.addViewHolderToRecycledViewPool(holder, false);
}
}
...
return holder;
}

接下来,如果顺利地获取到了ViewHolder对象,且该ViewHolder对象已经完成数据的绑定,则下一步就该立即回收该ViewHolder对象,缓存到mCacheViews结构中以供重用。


而如果该ViewHolder对象还未完成数据的绑定,意味着我们没能在设定的最后期限之前完成预拉取的操作,列表项数据不完整,因而我们不能将其缓存到mCacheViews结构中,但它会保留在mRecyclerViewPool结构中,以供下一次预拉取或重用。


预拉取机制与缓存复用机制的怎么协作的?


既然是与缓存复用机制共用相同的缓存结构,那么势必会对缓存复用机制的流程产生一定的影响,同样,让我们用几张流程示意图来演示一下:




  1. 假定现在position=5的列表项的底部正好贴合到RecyclerView可见区域的底部,即还要滑动超过该列表项的外边距+分隔线高度的距离,下一个列表项才可见。




  2. 随着向上拖动的手势,GapWorker开始发起预加载的工作,根据前面梳理的流程,它会提前创建并绑定position=6的列表项的ViewHolder对象,并将其缓存到mCacheViews结构中去。






  1. 继续保持向上拖动,当position=6的列表项即将进入屏幕时,它会按照上一篇缓存复用机制的流程,从mCacheViews结构取出可复用的ViewHolder对象,无需再次经历创建和绑定的过程,因此滑动的流畅度有了提升。




  1. 同时,随着position=6的列表项进入屏幕,GapWorker也开始了对position=7的列表项的预加载




  1. 之后,随着拖动距离的增大,position=0的列表项也将被移出屏幕,添加到mCachedViews结构中去。



上一篇文章我们讲过,mCachedViews结构的默认大小限制为2,从这里就可以看出,其这样设计是想刚好能缓存一个被移出屏幕的可复用ViewHolder对象+一个待进入屏幕的预拉取ViewHolder对象的。


不知道你们注意到没有,在步骤5的示意图中,可复用ViewHolder对象是添加到预拉取ViewHolder对象前面的,之所以这样子画是遵循了源码中的实现:


    // 添加之前,先移除最老的一个ViewHolder对象
int cachedViewSize = mCachedViews.size();
if (cachedViewSize >= mViewCacheMax && cachedViewSize > 0) { // 当前已经放满
recycleCachedViewAt(0); // 移除mCachedView结构中的第1个
cachedViewSize--; // 总数减1
}

// 默认从尾部添加
int targetCacheIndex = cachedViewSize;
// 处理预拉取的情况
if (ALLOW_THREAD_GAP_WORK
&& cachedViewSize > 0
&& !mPrefetchRegistry.lastPrefetchIncludedPosition(holder.mPosition)) {
// 从最后一个开始,跳过所有最近预拉取的对象排在其前面
int cacheIndex = cachedViewSize - 1;
while (cacheIndex >= 0) {
int cachedPos = mCachedViews.get(cacheIndex).mPosition;
// 添加到最近一个非预拉取的对象后面
if (!mPrefetchRegistry.lastPrefetchIncludedPosition(cachedPos)) {
break;
}
cacheIndex--;
}
targetCacheIndex = cacheIndex + 1;
}
mCachedViews.add(targetCacheIndex, holder);

也就是说,虽然缓存复用的对象和预拉取的对象共用同一个mCachedViews结构,但二者是分组存放的,且缓存复用的对象是排在预拉取的对象前面的。这么说或许还是很难理解,我们用几张示意图来演示一下就懂了:


1.假定现在mCachedViews中同时有2种类型的ViewHolder对象,黑色的代表缓存复用的对象,白色的代表预拉取的对象;


2.现在,有另外一个缓存复用的对象想要放到mCachedViews中,按源码的做法,默认会从尾部添加,即targetCacheIndex = 3:



3.随后,需要进一步确认放入的位置,它会从尾部开始逐个遍历,判断是否是预拉取的ViewHolder对象,判断的依据是该ViewHolder对象的position值是否存在mPrefetchArray结构中:


    boolean lastPrefetchIncludedPosition(int position) {
if (mPrefetchArray != null) {
final int count = mCount * 2;
for (int i = 0; i < count; i += 2) {
if (mPrefetchArray[i] == position) return true;
}
}
return false;
}


4.如果是,则跳过这一项继续遍历,直到找到最近一个非预拉取的对象,将该对象的索引+1,即targetCacheIndex = cacheIndex + 1,得到确认放入的位置。



5.虽然二者是分组存放的,但二者内部仍是有序的,即按照加入的顺序正序排列。


开启预拉取机制后的实际效果如何?


最后,我们还剩下一个问题,即预拉取机制启用之后,对于RecyclerView的滑动展示究竟能有多大的性能提升?


关于这个问题,已经有人做过相关的测试验证,这里就不再大量贴图了,只概括一下其方案的整体思路:



  • 测量工具:开发者模式-GPU渲染模式


    • 该工具以滚动显示的直方图形式,直观地呈现渲染出界面窗口帧所需花费的时间

    • 水平轴上的每个竖条即代表一个帧,其高度则表示渲染该帧所花的时间。

    • 绿线表示的是16.67毫秒的基准线。若想维持每秒60帧的正常绘制,则需保证代表每个帧的竖条维持在此线以下。



  • 耗时模拟:在onBindViewHolder方法中,使用Thread.sleep(time)来模拟页面渲染的复杂度。复杂度的大小,通过time时间的长短来体现。时间越长,复杂度越高。

  • 测试结果:对比同一复杂度下的RecyclerView滑动,未启用预拉取机制的一侧流畅度明显更低,并且随着复杂度的增加,在16ms内无法完成渲染的帧数进一步增多,延时更长,滑动卡顿更明显。


最后总结一下:







































预加载机制
概念利用UI线程正好处于空闲状态的时机,预先拉取一部分列表项视图并缓存起来,从而减少因视图创建或数据绑定等耗时操作所引起的卡顿。
重要类GapWorker:综合滑动方向、滑动速度、与可见区域的距离等要素,构建并调度预拉取任务列表。
Recycler:获取ViewHolder对象,如果缓存中找不到,则重新创建并绑定
结构mCachedViews:顺利获取到了ViewHolder对象,且已完成数据的绑定时放入
mRecyclerPool:顺利获取到了ViewHolder对象,但还未完成数据的绑定时放入
发起时机被拖动(Drag)、惯性滑动(Fling)、嵌套滚动时
完成期限下一个垂直同步信号发出之前

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