注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

Flutter之事件节流、防抖封装

在应用开发过程中经常会遇到因用户短时间内连续多次重复触发某个事件,导致对应事件的业务逻辑重复执行而出现业务异常,此时就需要对事件进行节流或者防抖处理避免出现业务异常。本文将介绍在 Flutter 开发中如何实现节流和防抖的统一封装。 前言 首先我们来了解一下节...
继续阅读 »

在应用开发过程中经常会遇到因用户短时间内连续多次重复触发某个事件,导致对应事件的业务逻辑重复执行而出现业务异常,此时就需要对事件进行节流或者防抖处理避免出现业务异常。本文将介绍在 Flutter 开发中如何实现节流和防抖的统一封装。


前言


首先我们来了解一下节流和防抖的定义,以及在什么场景下需要用到节流和防抖。


节流


节流是在事件触发时,立即执行事件的目标操作逻辑,在当前事件未执行完成时,该事件再次触发时会被忽略,直到当前事件执行完成后下一次事件触发才会被执行。


throttle.png


按指定时间节流


按指定时间节流是在事件触发时,立即执行事件的目标操作逻辑,但在指定时间内再次触发事件会被忽略,直到指定时间后再次触发事件才会被执行。


throttle-timeout.png


防抖


防抖是在事件触发时,不立即执行事件的目标操作逻辑,而是延迟指定时间再执行,如果该时间内事件再次触发,则取消上一次事件的执行并重新计算延迟时间,直到指定时间内事件没有再次触发时才执行事件的目标操作。


debounce.png


使用场景


节流多用于按钮点击事件的限制,如数据提交等,可有效防止数据的重复提交。防抖则多用于事件频繁触发的场景,如滚动监听、输入框输入监听等,可实现滚动停止间隔多久后触发事件的操作或输入框输入变化停止多久后触发事件的操作。


效果


先看一下最终封装完成后的使用示例及效果,实现计数器功能,对点击分别进行节流、指定时间节流、防抖限制。


/// 事件目标操作
void increase() {
count += 1;
}

/// 节流
() async{
await Future.delayed(Duration(seconds: 1));
increase();
}.throttle()

/// 指定时间节流
increase.throttleWithTimeout(2000)

///防抖
increase.debounce(timeout: 1000)


increase 是事件目标操作,即这里的数字加一,分别进行节流、指定时间节流、防抖限制,调用封装的 throttlethrottleWithTimeoutdebounce 扩展方法实现。其中节流为了模拟事件耗时操作增加了一秒延迟。


实现效果:


flutter-throttle-demo.gif


实现


接下来将通过从单事件的节流/防抖限制到封装抽取一步一步实现对节流和防抖的通用封装。


简单节流实现


首先来看一下节流的简单实现,前面讲了节流的原理,就是在事件未执行完成时忽略事件的再次触发,根据这个原理添加一个变量标识事件是否可执行,默认为 true 可执行,当事件执行时设置为 false,执行完成后重新设置为 true,当标识为 false 时忽略事件,这样就实现了对事件的节流,代码实现如下:


Future increase() async{
count += 1;
await Future.delayed(const Duration(seconds: 1));
}

/// 事件是否可执行
bool enable = true;

void throttleIncrease() async{
if(enable){
enable = false;
await increase();
enable = true;
}
}

添加一个 enable 变量标识事件是否可执行。这里为了模拟事件的耗时操作在 increase 方法里添加了一秒的延时。这样就简单实现了事件的节流,运行看一下效果:


flutter-throttle-simple.gif


节流封装


通过上面的简单代码实现了对事件的节流,但是只对某一个确定的事件有效,如果还有其他事件也需要实现节流效果那就得重新写一遍上面的代码,这样很明显是不科学的。那么我们就需要对上面的代码进行封装,使其能应用到多个事件上。


上面的代码事件调用是直接写在节流的实现里的,那么将事件进行抽象,把事件的具体执行方法抽取为一个参数,这样就能满足多个事件的节流控制了,实现如下:


bool enable = true;
void throttle(Function func) async{
if(enable){
enable = false;
await func();
enable = true;
}
}

/// 使用
throttle(increase);

throttle(decrease);

进一步封装


经过前面的封装后,确实可以对多个事件进行节流限制,但在实际开发过程中发现有两个问题:


**问题一:**所有事件的节流控制使用的是一个 enable 变量控制,这样就会导致在事件 1 执行过程中事件 2 会被忽略,这显然不是我们想要的效果。


举一个典型的场景,在 Flutter 中跳转新页面并获取页面的返回值,此时实现如下:


Future toNewPage() async{
var result = await Navigator.pushNamed(context, "/newPage");
/// do something
}

此时如果对 toNewPage 进行节流控制,并且跳转的页面里的按钮事件也做了同样的节流控制,就会导致新界面的按钮事件无法执行,因为我们节流用的是同一个变量进行控制,而 toNewPage 需要接收页面返回值,事件未执行完一直在等待页面返回值导致 enable 变量一直为 false 所以新界面的点击事件就会被忽略。


**问题二:**当事件的执行报错,会导致后续所有使用该方式节流的事件都不会被触发。原理跟上面的一样,当事件执行报错时不会继续向下执行,此时 enable 无法赋值为 true,一直为 false 从而导致后续事件都不会被执行。


怎么解决上面两个问题呢?首先解决简单的问题二,问题二很好解决,加一个 try-catch-finally 即可:


void throttle(Function func) async{
if(enable){
enable = false;
try {
await func();
} catch (e) {
rethrow;
} finally {
enable = true;
}
}
}

在方法调用上增加 try-catch-finally ,在 finally 中将 enable 设置为 true,在 catch 中不对异常做任何处理,使用 rethrow 将异常重新抛出去即可,这样就解决了问题二。


再来看问题一,既然使用同一个 enable 会有问题,那就使用多个变量来控制,每个事件用一个 enable 变量来控制,实现如下:


Map _funcThrottle = {};

void throttle(Function func) async{
String key = func.hashCode.toString();
bool enable = _funcThrottle[key] ?? true;
if(enable){
_funcThrottle[key] = false;
try {
await func();
} catch (e) {
rethrow;
} finally {
_funcThrottle.remove(key);
}
}
}

使用一个 Map 来存放事件的 enable 变量,使用事件方法的 hashCode 作为事件的 key,这样就解决了问题一。


但实际开发过程中发现还是有问题,封装后的 throttle 方法在使用时有下面两种方式:


/// 1
Future increase() async{
count += 1;
await Future.delayed(const Duration(seconds: 1));
}

throttle(increase);

/// 2
throttle(() async{
count += 1;
await Future.delayed(const Duration(seconds: 1));
});

使用第一种方式时是没有问题,但是第二种发现就有问题,节流不起作用了,为什么呢?是因为第二种使用的是匿名函数或者叫 lambda 函数,这种方式每次触发事件相当于都会重新创建一个函数参数传入 throttle 就会导致 func.hashCode.toString() 获取的值每次都不一样,所以导致节流无效。


那这种情况又该怎么解决呢?首先想到的是给 throttle 增加一个参数 key ,不同的事件传入不同的 key 值。这样确实能解决问题,但是增加了使用成本,每个事件都得传入一个 key,对于已有代码改造也相对来说不方便。于是想到了另外一种解决办法,也是本方案最终实现的方法,用一个对象来代理执行事件,具体实现如下:


class FunctionProxy {
static final Map _funcThrottle = {};
final Function? target;

FunctionProxy(this.target);

void throttle() async {
String key = hashCode.toString();
bool enable = _funcThrottle[key] ?? true;
if (enable) {
_funcThrottle[key] = false;
try {
await target?.call();
} catch (e) {
rethrow;
} finally {
_funcThrottle.remove(key);
}
}
}
}

创建一个方法的代理类,在该类里实现 throttle ,此时使用的 key 为该代理类的 hashCode , 使用如下:


onPressed: FunctionProxy(increase).throttle
/// or
onPressed: FunctionProxy(() async{
count += 1;
await Future.delayed(const Duration(seconds: 1));
}).throttle

这样最终返回给 onPressed 的是 FunctionProxythrottle 函数,而 throttle 是一个确定的函数,这就最终解决了上述问题。


但是使用时每次都创建 FunctionProxy 类,看着不太友好,给 Function 增加一个 throttle 方法,让使用更加简单:


extension FunctionExt on Function{
VoidCallback throttle(){
return FunctionProxy(this).throttle;
}
}

/// 使用
Future increase() async{
count += 1;
await Future.delayed(const Duration(seconds: 1));
}

onPressed: increase.throttle()

onPressed: () async{
count += 1;
await Future.delayed(const Duration(seconds: 1));
}.throttle()

指定时间节流


节流是事件执行完后才允许下次事件执行,指定时间节流是事件开始执行指定时间后允许下次事件执行,使用延迟指定时间后将 enable 设置为 true 来实现,代码如下:


class FunctionProxy {
static final Map _funcThrottle = {};
final Function? target;

final int timeout;

FunctionProxy(this.target, {int? timeout}) : timeout = timeout ?? 500;

void throttleWithTimeout() {
String key = hashCode.toString();
bool enable = _funcThrottle[key] ?? true;
if (enable) {
_funcThrottle[key] = false;
Timer(Duration(milliseconds: timeout), () {
_funcThrottle.remove(key);
});
target?.call();
}
}
}

增加了 timeout 参数,即指定的节流时间,使用 Timer 延迟指定时间后将 key 从 _funcThrottle 中移除,这里没有加 try-catch ,因为事件异常并不会影响 Timer 的执行,同样的为 Function 增加一个 throttleWithTimeout 扩展:


extension FunctionExt on Function{
VoidCallback throttleWithTimeout({int? timeout}){
return FunctionProxy(this, timeout: timeout).throttleWithTimeout;
}
}

使用:


Future increase() async{
count += 1;
await Future.delayed(const Duration(seconds: 1));
}
onPressed: increase.throttleWithTimeout(timeout: 1000)

onPressed: () async{
count += 1;
await Future.delayed(const Duration(seconds: 1));
}.throttleWithTimeout(timeout: 1000)

防抖


防抖是在事件触发指定时间内该事件未再次触发时再执行,同样可以使用 Timer 来实现:


class FunctionProxy {
static final Map _funcDebounce = {};
final Function? target;

final int timeout;

FunctionProxy(this.target, {int? timeout}) : timeout = timeout ?? 500;

void debounce() {
String key = hashCode.toString();
Timer? timer = _funcDebounce[key];
timer?.cancel();
timer = Timer(Duration(milliseconds: timeout), () {
Timer? t = _funcDebounce.remove(key);
t?.cancel();
target?.call();
});
_funcDebounce[key] = timer;
}
}

同样增加 timeout 参数用于指定防抖的时间间隔,与节流不同的是防抖的 Map 的 value 不是 bool 类型而是 Timer 类型,当事件触发时创建一个 Timer 设置延迟 timeout 后执行,并将 Timer 添加到 Map 中,如果在 timeout 时间内事件再次触发则将 Map 中的 Timer 取消再重新创建新的 Timer,从而实现防抖效果。


同样为 Function 添加 debounce 防抖扩展方法:


extension FunctionExt on Function{
VoidCallback debounce({int? timeout}){
return FunctionProxy(this, timeout: timeout).debounce;
}
}

使用:


Future increase() async{
count += 1;
await Future.delayed(const Duration(seconds: 1));
}
onPressed: increase.debounce(timeout: 1000)

onPressed: () async{
count += 1;
await Future.delayed(const Duration(seconds: 1));
}.debounce(timeout: 1000)

完整代码


至此节流、防抖的封装就完成了,完整代码如下:


extension FunctionExt on Function{
VoidCallback throttle(){
return FunctionProxy(this).throttle;
}

VoidCallback throttleWithTimeout({int? timeout}){
return FunctionProxy(this, timeout: timeout).throttleWithTimeout;
}

VoidCallback debounce({int? timeout}){
return FunctionProxy(this, timeout: timeout).debounce;
}
}

class FunctionProxy {
static final Map _funcThrottle = {};
static final Map _funcDebounce = {};
final Function? target;

final int timeout;

FunctionProxy(this.target, {int? timeout}) : timeout = timeout ?? 500;

void throttle() async {
String key = hashCode.toString();
bool enable = _funcThrottle[key] ?? true;
if (enable) {
_funcThrottle[key] = false;
try {
await target?.call();
} catch (e) {
rethrow;
} finally {
_funcThrottle.remove(key);
}
}
}

void throttleWithTimeout() {
String key = hashCode.toString();
bool enable = _funcThrottle[key] ?? true;
if (enable) {
_funcThrottle[key] = false;
Timer(Duration(milliseconds: timeout), () {
_funcThrottle.remove(key);
});
target?.call();
}
}

void debounce() {
String key = hashCode.toString();
Timer? timer = _funcDebounce[key];
timer?.cancel();
timer = Timer(Duration(milliseconds: timeout), () {
Timer? t = _funcDebounce.remove(key);
t?.cancel();
target?.call();
});
_funcDebounce[key] = timer;
}
}

点击组件封装


完成对节流、防抖的封装后,我们还可以对点击组件进行封装,这样不管是对现有 Flutter 代码还是新代码增加节流、防抖功能都会更加的简单。比如对 GestureDetector 组件可做如下封装:


enum ClickType {
none,
throttle,
throttleWithTimeout,
debounce
}

class ClickWidget extends StatelessWidget {
final Widget child;
final Function? onTap;
final ClickType type;
final int? timeout;

const ClickWidget(
{Key? key,
required this.child,
this.onTap,
this.type = ClickType.throttle,
this.timeout})
: super(key: key);

@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
child: child,
onTap: _getOnTap(),
);
}

VoidCallback? _getOnTap() {
if (type == ClickType.throttle) {
return onTap?.throttle();
} else if (type == ClickType.throttleWithTimeout) {
return onTap?.throttleWithTimeout(timeout: timeout);
}else if (type == ClickType.debounce) {
return onTap?.debounce(timeout: timeout);
}
return () => onTap?.call();
}
}

增加 type,用于指定节流、指定时间节流、防抖或者不限制,分别调用对应的方法。默认为节流,可根据项目实际需求设置默认方式或对项目中使用到的其他点击组件进行封装,经过封装后,修改已有代码增加默认限制功能就可以直接替换组件名字而无需改动其他代码实现事件限制的功能。


使用:


/// before
GestureDetector(
child: Text("xxx"),
onTap: increase,
)
/// after
ClickWidget(
child: Text("xxx"),
onTap: increase,
)

ClickWidget(
child: Text("指定时间节流"),
type: ClickType.throttleWithTimeout,
timeout: 1000,
onTap: increase,
)

ClickWidget(
child: Text("防抖"),
type: ClickType.debounce,
timeout: 1000,
onTap: increase,
)

总结


开发过程中,大部分的事件处理都需要进行节流或者防抖限制,防止事件的重复处理导致业务的异常,经过封装后不管是对老项目的优化改造还是新项目的开发,节流和防抖的处理都将变得更简单快捷。


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

常见的Android编译优化问题

编译常见问题 在开发过程中,有碰到过一些由于编译优化导致的代码修改并不符合我们预期的情况。这也就是之前为什么我经常说编译产物其实是不太可以被信任的。 方法签名变更,底层仓库的方法变更但是上层模块并没有跟随一起重新编译导致的这个问题。 常量优化,将一些常量的调...
继续阅读 »

编译常见问题


在开发过程中,有碰到过一些由于编译优化导致的代码修改并不符合我们预期的情况。这也就是之前为什么我经常说编译产物其实是不太可以被信任的。



  1. 方法签名变更,底层仓库的方法变更但是上层模块并没有跟随一起重新编译导致的这个问题。

  2. 常量优化,将一些常量的调用点直接替换成常量的值。

  3. 删除空导包, 没有用的一些导包就会做一次剔除。


最近倒霉了


我们最近碰到一个pipeline相关而且很妖怪的问题。我们一个pipeline会检查apk产物中是否存在异常的方法调用,就是之前介绍的在R8的基础上开发出来的A8。但是最近有一个类被删除了之后呢,但是代码中还有一处调用点。但是这个检测竟然被通过了,然后这部分代码就被合入了master。


image.png


这个引用的文件就如上图所示,是一个debug buildType中的,所以并不是所有的apk中都会存在这部分代码。


然后呢,这个MergeRequest就被合入了master分支,因为当天是我们出下一个版本包的时间,然后交付给测试的就是全量编译的debugrelease包。别的开发同学rebase完master之后就发现piepline都跑不过了,就导致了他们当天的代码无法被合入。


这个就是事情大概的起因和经过,但是各位有没有想过为什么会发生这个问题吗。这个是不是我们的pipeline出现了bug,导致了这种问题无法被识别出来了呢。


以前有说过,如果简单的说我们的快编系统就是把模块替换成对应的aar,从而达到编译提速。所以因为我们使用的是这个模块对应的aar产物,所以大概率就是因为这个模块的编译产物和源代码有差异导致了这个问题。


其实这个问题一出现我就已经知道大概率是由空导包优化导致的这个问题,因为在pipeline检查的时候,检测的apk产物中确实不存在这个导包。因为我们使用的是一个历史版本的aar,其中无效导包的部分已经被编译器做了删除空导包的优化了。接下来我们看下我写的一个demo中的无效导包。


image.png


image.png


图一呢是源代码java文件,图二呢则是jar包中的代码。可以简单的看出来行号呢是可以对应的上的,但是这个AppCompatActivity的无效导包在产物中已经被优化掉了。这里也就回答了在编译过程中会保留行号,但是也会优化掉一部分不需要的代码,让我们编译出来的产物更小。


所以也就导致了我们的产物和我们的源代码之间的差异,另外一个角度就是说从apk中我们确实是不存在这个类的导包。但是呢在我们把这部分代码重新编译成aar的时候,就会出现source缺失,导致的语法树无法生成,之后导致的编译失败问题。


这也就是所以我一直和大家说编译产物是不可以被信任的呢。


以前倒霉过


这个是之前的一个故事了,我们之前呢在模块中定义了一些静态常量吧,然后用来标识当前SDK的版本,然后这个值在别的模块中被引用到了。


有一次因为需求变更,我们更改了这个静态变量的值,然后呢我就把这个需求提测了。之后测试反馈给我为什么这边的这个值没有变化啊。


image.png


我的天,当时我就是这样,发生了什么情况。然后呢我全量打了个包好了,我当时也就以为只是编译时的一个bug而已。然后后来呢,我查了下资料发现这个就是一个java编译时的常量优化问题。过了一阵子吧,我面试了下字节跳动,然后我和面试官也聊了下这个话题,然后呢在这个方法签名变更的问题上,当时我略输一筹,哈哈哈哈。接下来我们就看下一个demo。


image.png


image.png


图1呢也是java代码,图2呢则是aar中的编译产物。其中我们可以看到,这个静态常量在编译成产物之后就会被编译成这样。


所以这个就解释了我一开始碰到的这个问题,他就是由于我们的编译器已经把aar中的这部分静态常量编译成了直接的值,然后呢我们的源变化之后如果没有重新编译对应的模块,就会导致这个值一直无法被更新到最新的值。


结论


如果大家对安卓编译相关有兴趣的话,这些问题很可能都会在面试的时候被问到。希望这不仅仅只是一篇我对于这些问题的思考,也能对各位有所帮助吧。


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

KeyValueX:消除样板代码,让 Android 项目不再 KV 爆炸

背景源于深夜一段独白:Key Value 定义几十上百个是常见事,目前有更简便方式么,此为项目中为数不多不受控制之地,指数膨胀,且易埋下一致性隐患,每新增一 value,需兼顾 key、get、put、init,5 处 …public class Config...
继续阅读 »

背景

源于深夜一段独白:

Key Value 定义几十上百个是常见事,目前有更简便方式么,

此为项目中为数不多不受控制之地,指数膨胀,且易埋下一致性隐患,

每新增一 value,需兼顾 key、get、put、init,5 处 …

public class Configs {
 
...
   
 private static int TEST_ID;
 
 public final static String KEY_TEST_ID = "KEY_TEST_ID";
 
 public static void setTestId(int id) {
   TEST_ID = id;
   SPUtils.getInstance().put(KEY_TEST_ID, id);
}
 
 public static int getTestId() {
   return TEST_ID;
}
 
 public static void initConfigs() {
   TEST_ID = SPUtils.getInstance().getInt(KEY_TEST_ID, 0);
}
}

随后陆续收到改善建议,有小伙伴提到 “属性代理”,并推荐了群友 DylanCai 开源库 MMKV-KTX

与此同时,受 “属性代理” 启发,本人萌生 Java 下 key、value、get、put、init 缩减为一设计。

Github:KeyValueX

V1.0

V1.0 使用 3 步曲

1.如读写 POJO,需实现 Serializable 接口

public class User implements Serializable {
 public String title;
 public String content;
}

2.像往常一样,创建项目配置管理类,如 Configs

//Configs 中不再定义一堆 KEY、VALUE 常量和 get、put、init 静态方法,
//只需一条条 KeyValue 静态变量:

public class Configs {
 public final static KeyValueString accountId = new KeyValueString("accountId");
 public final static KeyValueSerializable<User> user = new KeyValueSerializable<>("user");
}

3.在页面等处通过 get( ) set( ) 方法读写 KeyValue

public class MainActivity extends AppCompatActivity {
...
         
 //测试持久化写入
 Configs.user.set(u);

 //测试读取
 Log.d("---title", Configs.user.get().title);
 Log.d("---content", Configs.user.get().content);
}

V1.0 复盘

KeyValueX v1.0 一出,很快在群里引发热议,群友 DylanCai 提到多模块或存在 KeyName 重复问题,群友彭旭锐提议通过 “注解” 消除重复等问题。

与此同时我也发现,KeyValueX 虽然已消除 key、value、get、put、init 样板代码,但还是有两处一致性问题:

final 修饰符和 KeyName 一致性,

—— final 在 Java 下必写,以免开发者误给 KeyValue 直接赋值:

public class MainActivity extends AppCompatActivity {
...
     
 //正常使用
 Configs.user.set(u);
 
 //误用
 Configs.user = u;
}

那么有开发者可能说,我每次新增 KeyValue,通过 ctrl + D 复制行不就行了

public class Configs {
 public final static KeyValueString accountId = new KeyValueString("accountId");
 public final static KeyValueBoolean isAdult = new KeyValueBoolean("accountId");
}

确实这样可解决 final 一致性,但同时也会滋生 KeyName 一致性问题,也即记得改 KeyValue 变量名,忘改 KeyName,且这种疏忽编译器无法发现,唯有线上出事故专门排查时才可发现。

故综合多方面因素考虑,v2.0 采取注解设计:

V2.0

V2.0 使用 3 步曲

1.创建 KeyValueGroup 接口

@KeyValueGroup
public interface KeyValues {
 @KeyValue KeyValueInteger days();
 @KeyValue KeyValueString accountId();
 @KeyValue KeyValueSerializable<User> user();
}

2.像往常一样,创建项目配置管理类,如 Configs

//Configs 中不再定义一堆 KEY、VALUE 常量和 get、put、init 静态方法,
//只需一条 KeyValueGroup 静态变量:

public class Configs {
 public final static KeyValues keyValues = KeyValueCreator.create(KeyValues.class);
}

3.在页面等处通过 get( ) set( ) 方法读写 KeyValue

public class MainActivity extends AppCompatActivity {
...
         
 //测试持久化写入
 Configs.keyValues.user().set(u);

 //测试读取
 Log.d("---title", Configs.keyValues.user().get().title);
 Log.d("---content", Configs.keyValues.user().get().content);
}

V2.0 复盘

V2.0 通过接口 + 注解设计,一举消除 final 和 KeyName 一致性问题,

且通过无参反射实现 KeyValues 的实例化,使写代码过程中无需特意 build 生成 Impl 类,对 build 一次便需数分钟的巨型项目较友好。

根据上图可见,无参反射加载类,耗时仅次于 new,故 V2.0 设计本人还算满意,已在 Java 项目中全面使用,欢迎测试反馈。

Github:KeyValueX

KeyValue-Dispatcher

期间群友 DylanCai 提出可改用动态代理实现,也即效仿 Retrofit,根据接口定义运行时动态生成方法,

如此无需声明注解,使接口定义更简洁,看起来就像:

public interface KeyValues {
 KeyValueInteger days();
 KeyValueString accountId();
 KeyValueSerializable<User> user();
}

与此同时,可根据适配器模式实现个转换器,例如转换为 UnPeek-LiveData,如此即可顺带完成高频操作 —— 更新配置后通知部分页面刷新 UI。

public interface KeyValues {
Result<Integer> days();
Result<String> accountId();
Result<User> user();
}

Configs.keyValues.days().observer(this, result -> {
...
});

不过动态代理有个硬伤,即类名方法名不可混淆,不然运行时难调到对应方法,

故动态代理方式最终未考虑,不过转换器设计我甚是喜欢,加之 Java 后端 Properties 启发,故萌生 Dispatcher 设计 ——

基于 MVI-Dispatcher 实现 KeyValue-Dispatcher。具体思路即通过 HashMap 内聚 KeyValue,如此只需声明 Key,而无需考虑 value、getter、setter、init:

KV-D 使用 3 步曲

1.定义 Key 列表

public class Key {
 public final static String TEST_STRING = "test_string";
 public final static String TEST_BOOLEAN = "test_boolean";
}

2.读写

//读
boolean b = GlobalConfigs.getBoolean(Key.TEST_BOOLEAN);
//写
GlobalConfigs.put(Key.TEST_STRING, value);

3.顺带可通知 UI 刷新

GlobalConfigs.output(this, keyValueEvent -> {
 switch (keyValueEvent.currentKey) {
   case Key.TEST_STRING: ... break;
   case Key.TEST_BOOLEAN: ... break;
}
});

依托 MVI-Dispatcher 消息聚合设计,任何由配置变更引发的 UI 刷新,皆从这唯一出口响应。

目前已更新至 MVI-Dispatcher 项目,感兴趣可自行查阅。

KV-D 复盘

KV-D 旨在消除学习成本,让开发者像 SPUtils 一样使用,与此同时自动达成内存快读、消除样板代码、规避不可预期错误。

不过 KV-D 只适合 Java 项目用。如欲于 Kotlin 实现属性代理,还需基于 KeyValueX 那类设计。

于是 KeyValueX 再做升级:

1.简化注解:只需接口处声明此为 KeyValueX 接口,

2.自动分组:以 KeyValueX 接口为单位生成路径 MD5,KeyName 根据 MD5 自动分组,

3.全局内存快读:如 ViewModelProvider 使用,并提供全局内存快读。

V3.0

V3.0 使用 2 步曲

1.创建 KeyValueGroup 接口,例如

@KeyValueX
public interface Configs {
 KeyValueInteger days();
 KeyValueString accountId();
 KeyValueSerializable<User> user();
}

2.在页面等处通过 get( ) set( ) 方法读写 KeyValue

public class MainActivity extends AppCompatActivity {
 private final Configs configs = KeyValueProvider.get(Configs.class);
 
...

 //写
 configs.user().set(user);

 //读
 configs.user().get().title;
 configs.user().get().content;
}

已更新至 KeyValueX 项目,感兴趣可自行查阅。


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

Flutter Clip 用来实现文本标签的效果

Clip 是Material Design的一个 Widget,用来实现标签效果,Clip中通过属性可配置一个文本、完整的 Widget、一个动作(比如按钮点击)。 1 基本使用效果如下 class ClipHomeState extends State {...
继续阅读 »

Clip 是Material Design的一个 Widget,用来实现标签效果,Clip中通过属性可配置一个文本、完整的 Widget、一个动作(比如按钮点击)。


1 基本使用效果如下


在这里插入图片描述


class ClipHomeState extends State {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text("Clip")),
body: const Center(
///-----------------------------------------------------
///核心代码
child: Chip(
//左侧的小组件
avatar: CircleAvatar(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
child: Text('早'),
),
//右侧的文本
label: Text('Flutter 基础 '),
),

///-----------------------------------------------------
),
);
}
}

Chip 的 avatar 属性配置的是一个Widget,所这里可以组合足够复杂的 Widget合集,在本实例中只是使用了 CircleAvatar,CircleAvatar常用来展示圆形的小头像。


2 结合 Wrap 组件实现多标签效果


class ClipWrapHomeState extends State {
final List<String> _tagList = [
"早起", "晚睡", "测试", "努力",
"不想睡觉", "清晨的太阳", "这是什么", "哈哈"
];

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text("Clip")),
body: Padding(
///-----------------------------------------------------
///核心代码
padding: EdgeInsets.all(12),
child: Wrap(
spacing: 10,
children: _tagList.map((e) => Chip(label: Text(e))).toList(),
),
///-----------------------------------------------------
),
);
}
}

在这里插入图片描述


3 Clip 的属性概述


3.1 label 文本相关配置

label 是用来配置文本的,labelStyle是用来设置这个文本的样式,如颜色、大小 、粗细等等,labelPadding是用来设置文本四周的边距的。


  Chip buildChip() {
return const Chip(
//左侧的小组件
avatar: CircleAvatar(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
child: Text('早'),
),

///右侧的文本
label: Text('Flutter 基础 '),
labelStyle: TextStyle(color: Colors.red),
labelPadding: EdgeInsets.only(left: 12, right: 12),
);
}
}

在这里插入图片描述


3.2 右侧的删除按钮配置 deleteIcon

avatar 是用来配置左侧的显示的小 Widget,deleteIcon是用来配置右侧的删除按钮的。


  Chip buildChip() {
return Chip(
//左侧的小组件
avatar: const CircleAvatar(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
child: Text('早'),
),

///右侧的文本
label: Text('Flutter 基础 '),
labelStyle: TextStyle(color: Colors.red),
labelPadding: EdgeInsets.only(left: 12, right: 12),

///
deleteIcon: Icon(Icons.close),
//删除按钮颜色
deleteIconColor: Colors.red,
//长按的提示
deleteButtonTooltipMessage: "删除",
onDeleted: () {
print("--点击了删除");
},
);
}

在这里插入图片描述


3.3 阴影设置


属性 elevation 用来设置阴影高度,shadowColor属性用来设置阴影颜色


Chip buildChip() {
return Chip(
//左侧的小组件
avatar: const CircleAvatar(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
child: Text('早'),
),

///右侧的文本
label: Text('Flutter 基础 '),
labelStyle: TextStyle(color: Colors.red),
labelPadding: EdgeInsets.only(left: 12, right: 12),

///
deleteIcon: Icon(Icons.close),
//删除按钮颜色
deleteIconColor: Colors.red,
//长按的提示
deleteButtonTooltipMessage: "删除",
onDeleted: () {
print("--点击了删除");
},

///
elevation: 10,//阴影 高度
shadowColor: Colors.blue,//阴影颜色
backgroundColor: Colors.grey,//背景色

);
}



完毕


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

Flutter状态管理-Bloc的使用

前言 目前Flutter三大主流状态管理框架分别是provider、flutter_bloc、getx,三大状态管理框架各有优劣,本篇文章将介绍其中的flutter_bloc框架的使用,他是bloc设计思想模式在flutter上的实现,bloc全程全称 bus...
继续阅读 »

前言


目前Flutter三大主流状态管理框架分别是providerflutter_blocgetx,三大状态管理框架各有优劣,本篇文章将介绍其中的flutter_bloc框架的使用,他是bloc设计思想模式在flutter上的实现,bloc全程全称 business logic ,业务逻辑的意思,核心思想就是最大程度的将页面ui与数据逻辑的解耦,使我们的项目可读性、可维护性、健壮性增强。


两种使用模式


首先第一步引入插件:


flutter_bloc: ^8.1.1

引入之后,flutter_bloc使用支持以下两种模式管理。


Bloc模式,分别有ui层(view)、数据层(state)、事件层(event)、逻辑处理层(bloc),适合大型复杂页面使用。这四层结构bloc在源码处就进行了封装处理,所以我们使用的时候是必须要分离出来的,比如eventstate是要强制分开去写的。这也导致了简单页面使用此模式复杂化的问题,所以这种模式对于简单页面是非常没有必要的,但是如果是复杂页面的话,非常建议使用此模式,相信你在处理页面数据逻辑的时候会非常的清晰。

下面我们以计数器为例写个demo,记住bloc模式有四层,且必须分开,我们建四个文件分别代表这四层,

image.png

数据层: 用来存放数据,这个很简单。


/// 数据层
class DemoState {
// 自增数字
late int num;

DemoState init() {
// 初始化为0
return DemoState()..num = 0;
}

DemoState clone() {
// 获取最新对象
return DemoState()..num = num;
}
}

事件层: 用来存放页面所有事件的地方,比如计数器页面只有初始化事件和自增事件。


/// 页面中所有的交互事件
abstract class DemoEvent {}
/// 初始化事件
class InitEvent extends DemoEvent {}
/// 自增事件
class IncrementEvent extends DemoEvent {}

Bloc逻辑处理层: 处理上方数据和事件逻辑的地方,通过源码就能发现作者的意图,泛型里必须分开传入事件和数据,也足以说明这个模式的特点,就是为复杂页面准备的。所以如果写计数器的话,你就会感觉非常没有必要,因为计数器页面很简单,但是当你的state层里的数据非常多且复杂的时候,你就能体会出分开的好处了。
image.png
代码:


/// 逻辑处理层 继承Bloc
class DemoBloc extends Bloc<DemoEvent, DemoState> {
///构造方法
DemoBloc() : super(DemoState().init()) {
/// on 注册所有事件 on固定写法
on<InitEvent>(_init);
on<IncrementEvent>(_add);
}

/// 私有化逻辑方法 暴露Event事件即可
void _init(InitEvent event, Emitter<DemoState> emit) {
// emit方法,通知更新状态 类似于 ChangeNotifier的notifyListeners方法。
emit(state.clone());
}

_add(IncrementEvent event, Emitter<DemoState> emit) {
state.num++;
// 调用emit方法更新状态
emit(state.clone());
}
}

UI层: UI层只负责页面的编写,而无需关心数据的生成,根节点返回BlocProvider,并实现create方法,返回我们的bloc实体类。child实现我们的 UI页面。


/// UI层
class BlocNumPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
// 以下固定写法
return BlocProvider(
create: (BuildContext context) => DemoBloc()..add(InitEvent()),
child: Builder(builder: (context) => _buildPage(context)),
);
}

Widget _buildPage(BuildContext context) {
// 获取bloc实例
final bloc = BlocProvider.of<DemoBloc>(context);
return Stack(
children: [
Center(
// 对于需要更新状态的组件 外层包裹一个BlocBuilder,传入bloc、state
child: BlocBuilder<DemoBloc, DemoState>(
builder: (context, state) {
// 返回具体ui组件
return Text("点击了${bloc.state.num}次");
},
),
),
Positioned(
bottom: 20,
right: 20,
child: FloatingActionButton(
onPressed: () {
// 调用add方法触发自增事件,
bloc.add(IncrementEvent());
},
child: Icon(Icons.add),
),
)
],
);
}
}

效果:

Oct-30-2022 18-33-01.gif


Cubit模式


Cubit模式,分别有ui层(view)、数据层(state)、逻辑处理层(cubit),相较于bloc模式去掉了event层,适合比较简单的页面。跟bloc模式只是逻辑处理哪里发生了改变,数据层、页面ui层代码一模一样。


可以看到Cubit模式的逻辑就少了很多代码,而且是直接处理数据即可。通过源码,作者意图也很明显,只是传递了数据层。
image.png


/// 写逻辑
class CubitCubit extends Cubit<CubitState> {
CubitCubit() : super(CubitState().init());

///自增
void increment() => emit(state.clone()..num = state.num + 1);
}

其他写法跟Bloc是一样的,就不粘贴了,那看到这有的小伙伴可能就要问了,一个页面要创建3、4个文件,这也太麻烦了吧,高端的程序员往往不会去写一些重复性较高的代码,其实上面的四个文件都是可以通过插件自动生成的,这里下面两个插件,一个是官方的,官方的不会自动生成ui层的文件,一个是小呆呆写的,可以自动生成ui层重复性的代码文件,两者区别不大,推荐小呆呆的,因为可以多生成一个文件。
image.png


最后


Bloc本质上是一种数据逻辑和UI解耦思想,上面的演示只是非常非常简单的用法,就可以看出作者在源码层给我们强制性的设定了非常明确的各个模型,每个模型只专心负责一个事情,这样看起来一个页面会非常的清晰,可以说Flutter_Bloc是一个非常适合大型项目使用的状态管理框架。


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

数据结构:7种哈希散列算法,你知道几个?

一、前言 哈希表的历史 哈希散列的想法在不同的地方独立出现。1953 年 1 月,汉斯·彼得·卢恩 ( Hans Peter Luhn ) 编写了一份IBM内部备忘录,其中使用了散列和链接。开放寻址后来由 AD Linh 在 Luhn 的论文上提出。大约在同一...
继续阅读 »

一、前言


哈希表的历史


哈希散列的想法在不同的地方独立出现。1953 年 1 月,汉斯·彼得·卢恩 ( Hans Peter Luhn ) 编写了一份IBM内部备忘录,其中使用了散列和链接。开放寻址后来由 AD Linh 在 Luhn 的论文上提出。大约在同一时间,IBM Research的Gene Amdahl、Elaine M. McGraw、Nathaniel Rochester和Arthur Samuel为IBM 701汇编器实现了散列。 线性探测的开放寻址归功于 Amdahl,尽管Ershov独立地有相同的想法。“开放寻址”一词是由W. Wesley Peterson在他的文章中创造的,该文章讨论了大文件中的搜索问题。


二、哈希数据结构


哈希表的存在是为了解决能通过O(1)时间复杂度直接索引到指定元素。


这是什么意思呢?通过我们使用数组存放元素,都是按照顺序存放的,当需要获取某个元素的时候,则需要对数组进行遍历,获取到指定的值。如图所示;





而这样通过循环遍历比对获取指定元素的操作,时间复杂度是O(n),也就是说如果你的业务逻辑实现中存在这样的代码是非常拉胯的。那怎么办呢?这就引入了哈希散列表的设计。




在计算机科学中,一个哈希表(hash table、hash map)是一种实现关联数组的抽象数据结构,该结构将键通过哈希计算映射到值。


也就是说我们通过对一个 Key 值计算它的哈希并与长度为2的n次幂的数组减一做与运算,计算出槽位对应的索引,将数据存放到索引下。那么这样就解决了当获取指定数据时,只需要根据存放时计算索引ID的方式再计算一次,就可以把槽位上对应的数据获取处理,以此达到时间复杂度为O(1)的情况。如图所示;





哈希散列虽然解决了获取元素的时间复杂度问题,但大多数时候这只是理想情况。因为随着元素的增多,很可能发生哈希冲突,或者哈希值波动不大导致索引计算相同,也就是一个索引位置出现多个元素情况。如图所示;





李二狗拎瓢冲都有槽位的下标索引03的 叮裆猫 发生冲突时,情况就变得糟糕了,因为这样就不能满足O(1)时间复杂度获取元素的诉求了。


那么此时就出现了一系列解决方案,包括;HashMap 中的拉链寻址 + 红黑树、扰动函数、负载因子ThreadLocal 的开放寻址、合并散列、杜鹃散列、跳房子哈希、罗宾汉哈希等各类数据结构设计。让元素在发生哈希冲突时,也可以存放到新的槽位,并尽可能保证索引的时间复杂度小于O(n)


三、实现哈希散列


哈希散列是一个非常常见的数据结构,无论是我们使用的 HashMap、ThreaLocal 还是你在刷题中位了提升索引效率,都会用到哈希散列。


只要哈希桶的长度由负载因子控制的合理,每次查找元素的平均时间复杂度与桶中存储的元素数量无关。另外许多哈希表设计还允许对键值对的任意插入和删除,每次操作的摊销固定平均成本。


好,那么介绍了这么多,小傅哥带着大家做几个关于哈希散列的数据结构,通过实践来了解会更加容易搞懂。



1. 哈希碰撞


说明:通过模拟简单 HashMap 实现,去掉拉链寻址等设计,验证元素哈新索引位置碰撞。


public class HashMap01<K, V> implements Map<K, V> {

private final Object[] tab = new Object[8];

@Override
public void put(K key, V value) {
int idx = key.hashCode() & (tab.length - 1);
tab[idx] = value;
}

@Override
public V get(K key) {
int idx = key.hashCode() & (tab.length - 1);
return (V) tab[idx];
}

}





  • HashMap01 的实现只是通过哈希计算出的下标,散列存放到固定的数组内。那么这样当发生元素下标碰撞时,原有的元素就会被新的元素替换掉。


测试


@Test
public void test_hashMap01() {
Map<String, String> map = new HashMap01<>();
map.put("01", "花花");
map.put("02", "豆豆");
logger.info("碰撞前 key:{} value:{}", "01", map.get("01"));

// 下标碰撞
map.put("09", "蛋蛋");
map.put("12", "苗苗");
logger.info("碰撞前 key:{} value:{}", "01", map.get("01"));
}




06:58:41.691 [main] INFO cn.bugstack.algorithms.test.AlgorithmsTest - 碰撞前 key:01 value:花花
06:58:41.696 [main] INFO cn.bugstack.algorithms.test.AlgorithmsTest - 碰撞前 key:01 value:苗苗

Process finished with exit code 0


  • 通过测试结果可以看到,碰撞前 map.get("01") 的值是花花,两次下标索引碰撞后存放的值则是苗苗

  • 这也就是使用哈希散列必须解决的一个问题,无论是在已知元素数量的情况下,通过扩容数组长度解决,还是把碰撞的元素通过链表存放,都是可以的。


2. 拉链寻址


说明:既然我们没法控制元素不碰撞,但我们可以对碰撞后的元素进行管理。比如像 HashMap 中拉链法一样,把碰撞的元素存放到链表上。这里我们就来简化实现一下。


public class HashMap02BySeparateChaining<K, V> implements Map<K, V> {

private final LinkedList<Node<K, V>>[] tab = new LinkedList[8];

@Override
public void put(K key, V value) {
int idx = key.hashCode() & (tab.length - 1);
if (tab[idx] == null) {
tab[idx] = new LinkedList<>();
tab[idx].add(new Node<>(key, value));
} else {
tab[idx].add(new Node<>(key, value));
}
}

@Override
public V get(K key) {
int idx = key.hashCode() & (tab.length - 1);
for (Node<K, V> kvNode : tab[idx]) {
if (key.equals(kvNode.getKey())) {
return kvNode.value;
}
}
return null;
}

static class Node<K, V> {
final K key;
V value;

public Node(K key, V value) {
this.key = key;
this.value = value;
}

public K getKey() {
return key;
}

public V getValue() {
return value;
}

}

}



  • 因为元素在存放到哈希桶上时,可能发生下标索引膨胀,所以这里我们把每一个元素都设定成一个 Node 节点,这些节点通过 LinkedList 链表关联,当然你也可以通过 Node 节点构建出链表 next 元素即可。

  • 那么这时候在发生元素碰撞,相同位置的元素就都被存放到链表上了,获取的时候需要对存放多个元素的链表进行遍历获取。


测试


@Test
public void test_hashMap02() {
Map<String, String> map = new HashMap02BySeparateChaining<>();
map.put("01", "花花");
map.put("05", "豆豆");
logger.info("碰撞前 key:{} value:{}", "01", map.get("01"));

// 下标碰撞
map.put("09", "蛋蛋");
map.put("12", "苗苗");
logger.info("碰撞前 key:{} value:{}", "01", map.get("01"));
}


07:21:16.654 [main] INFO cn.bugstack.algorithms.test.AlgorithmsTest - 碰撞前 key:01 value:花花
07:22:44.651 [main] INFO cn.bugstack.algorithms.test.AlgorithmsTest - 碰撞前 key:01 value:花花

Process finished with exit code 0


  • 此时第一次和第二次获取01位置的元素就都是花花了,元素没有被替代。因为此时的元素是被存放到链表上了。


3. 开放寻址


说明:除了对哈希桶上碰撞的索引元素进行拉链存放,还有不引入新的额外的数据结构,只是在哈希桶上存放碰撞元素的方式。它叫开放寻址,也就是 ThreaLocal 中运用斐波那契散列+开放寻址的处理方式。


public class HashMap03ByOpenAddressing<K, V> implements Map<K, V> {

private final Node<K, V>[] tab = new Node[8];

@Override
public void put(K key, V value) {
int idx = key.hashCode() & (tab.length - 1);
if (tab[idx] == null) {
tab[idx] = new Node<>(key, value);
} else {
for (int i = idx; i < tab.length; i++) {
if (tab[i] == null) {
tab[i] = new Node<>(key, value);
break;
}
}
}
}

@Override
public V get(K key) {
int idx = key.hashCode() & (tab.length - 1);
for (int i = idx; i < tab.length; i ++){
if (tab[idx] != null && tab[idx].key == key) {
return tab[idx].value;
}
}
return null;
}

static class Node<K, V> {
final K key;
V value;

public Node(K key, V value) {
this.key = key;
this.value = value;
}

}

}



  • 开放寻址的设计会对碰撞的元素,寻找哈希桶上新的位置,这个位置从当前碰撞位置开始向后寻找,直到找到空的位置存放。

  • 在 ThreadLocal 的实现中会使用斐波那契散列、索引计算累加、启发式清理、探测式清理等操作,以保证尽可能少的碰撞。


测试


@Test
public void test_hashMap03() {
Map<String, String> map = new HashMap03ByOpenAddressing<>();
map.put("01", "花花");
map.put("05", "豆豆");
logger.info("碰撞前 key:{} value:{}", "01", map.get("01"));
// 下标碰撞
map.put("09", "蛋蛋");
map.put("12", "苗苗");
logger.info("碰撞前 key:{} value:{}", "01", map.get("01"));
}


07:20:22.382 [main] INFO cn.bugstack.algorithms.test.AlgorithmsTest - 碰撞前 key:01 value:花花
07:20:22.387 [main] INFO cn.bugstack.algorithms.test.AlgorithmsTest - 碰撞前 key:01 value:花花
07:20:22.387 [main] INFO cn.bugstack.algorithms.test.AlgorithmsTest - 数据结构:HashMap{tab=[null,{"key":"01","value":"花花"},{"key":"09","value":"蛋蛋"},{"key":"12","value":"苗苗"},null,{"key":"05","value":"豆豆"},null,null]}

Process finished with exit code 0


  • 通过测试结果可以看到,开放寻址对碰撞元素的寻址存放,也是可用解决哈希索引冲突问题的。


4. 合并散列


说明:合并散列是开放寻址和单独链接的混合,碰撞的节点在哈希表中链接。此算法适合固定分配内存的哈希桶,通过存放元素时识别哈希桶上的最大空槽位来解决合并哈希中的冲突。


public class HashMap04ByCoalescedHashing<K, V> implements Map<K, V> {

private final Node<K, V>[] tab = new Node[8];

@Override
public void put(K key, V value) {
int idx = key.hashCode() & (tab.length - 1);
if (tab[idx] == null) {
tab[idx] = new Node<>(key, value);
return;
}

int cursor = tab.length - 1;
while (tab[cursor] != null && tab[cursor].key != key) {
--cursor;
}
tab[cursor] = new Node<>(key, value);

// 将碰撞节点指向这个新节点
while (tab[idx].idxOfNext != 0){
idx = tab[idx].idxOfNext;
}

tab[idx].idxOfNext = cursor;
}

@Override
public V get(K key) {
int idx = key.hashCode() & (tab.length - 1);
while (tab[idx].key != key) {
idx = tab[idx].idxOfNext;
}
return tab[idx].value;
}

static class Node<K, V> {
final K key;
V value;
int idxOfNext;

public Node(K key, V value) {
this.key = key;
this.value = value;
}

}

}



  • 合并散列的最大目的在于将碰撞元素链接起来,避免因为需要寻找碰撞元素所发生的循环遍历。也就是A、B元素存放时发生碰撞,那么在找到A元素的时候可以很快的索引到B元素所在的位置。


测试


07:18:43.613 [main] INFO cn.bugstack.algorithms.test.AlgorithmsTest - 碰撞前 key:01 value:花花
07:18:43.618 [main] INFO cn.bugstack.algorithms.test.AlgorithmsTest - 碰撞前 key:01 value:苗苗
07:18:43.619 [main] INFO cn.bugstack.algorithms.test.AlgorithmsTest - 数据结构:HashMap{tab=[null,{"idxOfNext":7,"key":"01","value":"花花"},null,null,null,{"idxOfNext":0,"key":"05","value":"豆豆"},{"idxOfNext":0,"key":"12","value":"苗苗"},{"idxOfNext":6,"key":"09","value":"蛋蛋"}]}

Process finished with exit code 0


  • 相对于直接使用开放寻址,这样的挂在链路指向的方式,可以提升索引的性能。因为在实际的数据存储上,元素的下一个位置不一定空元素,可能已经被其他元素占据,这样就增加了索引的次数。所以使用直接指向地址的方式,会更好的提高索引性能。


5. 杜鹃散列


说明:这个名字起的比较有意思,也代表着它的数据结构。杜鹃鸟在孵化🐣的时候,雏鸟会将其他蛋或幼崽推出巢穴;类似的这个数据结构会使用2组key哈希表,将冲突元素推到另外一个key哈希表中。


private V put(K key, V value, boolean isRehash) {
Object k = maskNull(key);
if (containsKey(k)) {
return null;
}
if (insertEntry(new Entry<K, V>((K) k, value))) {
if (!isRehash) {
size++;
}
return null;
}
rehash(2 * table.length);
return put((K) k, value);
}

private boolean insertEntry(Entry<K, V> e) {
int count = 0;
Entry<K, V> current = e;
int index = hash(hash1, current.key);
while (current != e || count < table.length) {
Entry<K, V> temp = table[index];
if (temp == null) {
table[index] = current;
return true;
}
table[index] = current;
current = temp;
if (index == hash(hash1, current.key)) {
index = hash(hash2, current.key);
} else {
index = hash(hash1, current.key);
}
++count;
}
return false;
}



  • 当多个键映射到同一个单元格时会发生这种情况。杜鹃散列的基本思想是通过使用两个散列函数而不是仅一个散列函数来解决冲突。

  • 这为每个键在哈希表中提供了两个可能的位置。在该算法的一种常用变体中,哈希表被分成两个大小相等的较小的表,每个哈希函数都为这两个表之一提供索引。两个散列函数也可以为单个表提供索引。

  • 在实践中,杜鹃哈希比线性探测慢约 20-30%,线性探测是常用方法中最快的。然而,由于它对搜索时间的最坏情况保证,当需要实时响应率时,杜鹃散列仍然很有价值。杜鹃散列的一个优点是它的无链接列表属性,非常适合 GPU 处理。


测试



07:52:04.010 [main] INFO cn.bugstack.algorithms.test.AlgorithmsTest - 碰撞前 key:01 value:花花
07:52:04.016 [main] INFO cn.bugstack.algorithms.test.AlgorithmsTest - 碰撞前 key:01 value:苗苗
07:52:04.016 [main] INFO cn.bugstack.algorithms.test.AlgorithmsTest - 数据结构:{01=花花, 12=苗苗, 05=豆豆, 09=蛋蛋}

Process finished with exit code 0


  • 从测试结果可以看到,杜鹃散列可以通过两个散列函数解决索引冲突问题。不过这个探测的过程比较耗时。


6. 跳房子散列


说明:跳房子散列是一种基于开放寻址的算法,它结合了杜鹃散列、线性探测和链接的元素,通过桶邻域的概念——任何给定占用桶周围的后续桶,也称为“虚拟”桶。 该算法旨在在哈希表的负载因子增长超过 90% 时提供更好的性能;它还在并发设置中提供了高吞吐量,因此非常适合实现可调整大小的并发哈希表。


public boolean insert(AnyType x) {
if (!isEmpty()) {
return false;
}
int currentPos = findPos(x);
if (currentPos == -1) {
return false;
}
if (array[currentPos] != null) {
x = array[currentPos].element;
array[currentPos].isActive = true;
}
String hope;
if (array[currentPos] != null) {
hope = array[currentPos].hope;
x = array[currentPos].element;
} else {
hope = "10000000";
}
array[currentPos] = new HashEntry<>(x, hope, true);
theSize++;
return true;
}



  • 该算法使用一个包含n 个桶的数组。对于每个桶,它的邻域是H个连续桶的小集合(即索引接近原始散列桶的那些)。邻域的期望属性是在邻域的桶中找到一个项目的成本接近于在桶本身中找到它的成本(例如,通过使邻域中的桶落在同一缓存行中)。在最坏的情况下,邻域的大小必须足以容纳对数个项目(即它必须容纳 log( n ) 个项目),但平均只能是一个常数。如果某个桶的邻域被填满,则调整表的大小。


测试


@Test
public void test_hashMap06() {
HashMap06ByHopscotchHashing<Integer> map = new HashMap06ByHopscotchHashing<>();
map.insert(1);
map.insert(5);
map.insert(9);
map.insert(12);
logger.info("数据结构:{}", map);
}


17:10:10.363 [main] INFO cn.bugstack.algorithms.test.AlgorithmsTest - 数据结构:HashMap{tab=[null,{"element":1,"hope":"11000000","isActive":true},{"element":9,"hope":"00000000","isActive":true},null,{"element":12,"hope":"10000000","isActive":true},{"element":5,"hope":"10000000","isActive":true},null,null]}

Process finished with exit code 0


  • 通过测试可以看到,跳房子散列会在其原始散列数组条目中找到,或者在接下来的H-1个相邻条目之一找到对应的冲突元素。


7. 罗宾汉哈希


说明:罗宾汉哈希是一种基于开放寻址的冲突解决算法;冲突是通过偏向从其“原始位置”(即项目被散列到的存储桶)最远或最长探测序列长度(PSL)的元素的位移来解决的。


public void put(K key, V value) {
Entry entry = new Entry(key, value);
int idx = hash(key);
// 元素碰撞检测
while (table[idx] != null) {
if (entry.offset > table[idx].offset) {
// 当前偏移量不止一个,则查看条目交换位置,entry 是正在查看的条目,增加现在搜索的事物的偏移量和 idx
Entry garbage = table[idx];
table[idx] = entry;
entry = garbage;
idx = increment(idx);
entry.offset++;
} else if (entry.offset == table[idx].offset) {
// 当前偏移量与正在查看的检查键是否相同,如果是则它们交换值,如果不是,则增加 idx 和偏移量并继续
if (table[idx].key.equals(key)) {
// 发现相同值
V oldVal = table[idx].value;
table[idx].value = value;
} else {
idx = increment(idx);
entry.offset++;
}
} else {
// 当前偏移量小于我们正在查看的我们增加 idx 和偏移量并继续
idx = increment(idx);
entry.offset++;
}
}
// 已经到达了 null 所在的 idx,将新/移动的放在这里
table[idx] = entry;
size++;
// 超过负载因子扩容
if (size >= loadFactor * table.length) {
rehash(table.length * 2);
}
}



  • 09、12 和 01 发生哈希索引碰撞,进行偏移量计算调整。通过最长位置探测碰撞元素位移来处理。


测试


public void test_hashMap07() {
Map<String, String> map = new HashMap07ByRobinHoodHashing<>();
map.put("01", "花花");
map.put("05", "豆豆");
logger.info("碰撞前 key:{} value:{}", "01", map.get("01"));
// 下标碰撞
map.put("09", "蛋蛋");
map.put("12", "苗苗");
logger.info("碰撞前 key:{} value:{}", "01", map.get("12"));
logger.info("数据结构:{}", map);
}


07:34:32.593 [main] INFO cn.bugstack.algorithms.test.AlgorithmsTest - 碰撞前 key:01 value:花花
09 1
12 1
01 1
09 9
12 1
05 5
07:35:07.419 [main] INFO cn.bugstack.algorithms.test.AlgorithmsTest - 碰撞前 key:01 value:苗苗
07:35:07.420 [main] INFO cn.bugstack.algorithms.test.AlgorithmsTest - 数据结构:HashMap{tab=[null,{"key":"01","offset":0,"value":"花花"},{"key":"12","offset":1,"value":"苗苗"},null,null,{"key":"05","offset":0,"value":"豆豆"},null,null,null,{"key":"09","offset":0,"value":"蛋蛋"},null,null,null,null,null,null]}

Process finished with exit code 0


  • 通过测试结果和调试的时候可以看到,哈希索引冲突是通过偏向从其“原始位置”(即项目被散列到的存储桶)最远或最长探测序列长度(PSL)的元素的位移来解决。这块可以添加断点调试验证。


四、常见面试问题



  • 介绍一下散列表

  • 为什么使用散列表

  • 拉链寻址和开放寻址的区别

  • 还有其他什么方式可以解决散列哈希索引冲突

  • 对应的Java源码中,对于哈希索引冲突提供了什么样的解决方案

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

高并发技巧-redis和本地缓存使用技巧

在这篇文章中,我主要介绍的是分布式缓存和本地缓存的使用技巧,包括缓存种类介绍,各种的使用场景,以及如何使用,最后再给出实战案例。 众所周知,缓存最主要的目的就是加速访问,缓解数据库压力。最常用的缓存就是分布式缓存,比如redis,在面对大部分并发场景或者一些...
继续阅读 »

在这篇文章中,我主要介绍的是分布式缓存和本地缓存的使用技巧,包括缓存种类介绍,各种的使用场景,以及如何使用,最后再给出实战案例。



众所周知,缓存最主要的目的就是加速访问,缓解数据库压力。最常用的缓存就是分布式缓存,比如redis,在面对大部分并发场景或者一些中小型公司流量没有那么高的情况,使用redis基本都能解决了。但是在流量较高的情况下可能得使用到本地缓存了,比如guava的LoadingCache和快手开源的ReloadableCache。


三种缓存的使用场景


这部分会介绍redis,比如guava的LoadingCache和快手开源的ReloadableCache的使用场景和局限,通过这一部分的介绍就能知道在怎样的业务场景下应该使用哪种缓存,以及为什么。


Redis的使用场景和局限性


如果宽泛的说redis何时使用,那么自然就是用户访问量过高的地方使用,从而加速访问,并且缓解数据库压力。如果细分的话,还得分为单节点问题和非单节点问题。


如果一个页面用户访问量比较高,但是访问的不是同一个资源。比如用户详情页,访问量比较高,但是每个用户的数据都是不一样的,这种情况显然只能用分布式缓存了,如果使用redis,key为用户唯一键,value则是用户信息。


redis导致的缓存击穿


但是需要注意一点,一定要设置过期时间,而且不能设置到同一时间点过期。举个例子,比如用户又个活动页,活动页能看到用户活动期间获奖数据,粗心的人可能会设置用户数据的过期时间点为活动结束,这样会


单(热)点问题


单节点问题说的是redis的单个节点的并发问题,因为对于相同的key会落到redis集群的同一个节点上,那么如果对这个key的访问量过高,那么这个redis节点就存在并发隐患,这个key就称为热key。


如果所有用户访问的都是同一个资源,比如小爱同学app首页对所有用户展示的内容都一样(初期),服务端给h5返回的是同一个大json,显然得使用到缓存。首先我们考虑下用redis是否可行,由于redis存在单点问题,如果流量过大的话,那么所有用户的请求到达redis的同一个节点,需要评估该节点能否抗住这么大流量。我们的规则是,如果单节点qps达到了千级别就要解决单点问题了(即使redis号称能抗住十万级别的qps),最常见的做法就是使用本地缓存。显然小爱app首页流量不过百,使用redis是没问题的。


LoadingCache的使用场景和局限性


对于这上面说的热key问题,我们最直接的做法就是使用本地缓存,比如你最熟悉的guava的LoadingCache,但是使用本地缓存要求能够接受一定的脏数据,因为如果你更新了首页,本地缓存是不会更新的,它只会根据一定的过期策略来重新加载缓存,不过在我们这个场景是完全没问题的,因为一旦在后台推送了首页后就不会再去改变了。即使改变了也没问题,可以设置写过期为半小时,超过半小时重新加载缓存,这种短时间内的脏数据我们是可以接受的。


LoadingCache导致的缓存击穿


虽然说本地缓存和机器上强相关的,虽然代码层面写的是半小时过期,但由于每台机器的启动时间不同,导致缓存的加载时间不同,过期时间也就不同,也就不会所有机器上的请求在同一时间缓存失效后都去请求数据库。但是对于单一一台机器也是会导致缓存穿透的,假如有10台机器,每台1000的qps,只要有一台缓存过期就可能导致这1000个请求同时打到了数据库。这种问题其实比较好解决,但是容易被忽略,也就是在设置LoadingCache的时候使用LoadingCache的load-miss方法,而不是直接判断cache.getIfPresent()== null然后去请求db;前者会加虚拟机层面的锁,保证只有一个请求打到数据库去,从而完美的解决了这个问题。


但是,如果对于实时性要求较高的情况,比如有段时间要经常做活动,我要保证活动页面能近实时更新,也就是运营在后台配置好了活动信息后,需要在C端近实时展示这次配置的活动信息,此时使用LoadingCache肯定就不能满足了。


ReloadableCache的使用场景和局限性


对于上面说的LoadingCache不能解决的实时问题,可以考虑使用ReloadableCache,这是快手开源的一个本地缓存框架,最大的特点是支持多机器同时更新缓存,假设我们修改了首页信息,然后请求打到的是A机器,这个时候重新加载ReloadableCache,然后它会发出通知,监听了同一zk节点的其他机器收到通知后重新更新缓存。使用这个缓存一般的要求是将全量数据加载到本地缓存,所以如果数据量过大肯定会对gc造成压力,这种情况就不能使用了。由于小爱同学首页这个首页是带有状态的,一般online状态的就那么两个,所以完全可以使用ReloadableCache来只装载online状态的首页。


小结


到这里三种缓存基本都介绍完了,做个小结:



  1. 对于非热点的数据访问,比如用户维度的数据,直接使用redis即可;

  2. 对于热点数据的访问,如果流量不是很高,无脑使用redis即可;

  3. 对于热点数据,如果允许一定时间内的脏数据,使用LoadingCache即可;

  4. 对于热点数据,如果一致性要求较高,同时数据量不大的情况,使用ReloadableCache即可;


小技巧


不管哪种本地缓存虽然都带有虚拟机层面的加锁来解决击穿问题,但是意外总有可能以你意想不到的方式发生,保险起见你可以使用两级缓存的方式即本地缓存+redis+db。


缓存使用的简单介绍


这里redis的使用就不再多说了,相信很多人对api的使用比我还熟悉


LoadingCache的使用


这个是guava提供的网上一抓一大把,但是给两点注意事项



  1. 要使用load-miss的话, 要么使用V get(K key, Callable<? extends V> loader);要么使用build的时候使用的是build(CacheLoader<? super K1, V1> loader)这个时候可以直接使用get()了。此外建议使用load-miss,而不是getIfPresent==null的时候再去查数据库,这可能导致缓存击穿;

  2. 使用load-miss是因为这是线程安全的,如果缓存失效的话,多个线程调用get的时候只会有一个线程去db查询,其他线程需要等待,也就是说这是线程安全的。


LoadingCache<String, String> cache = CacheBuilder.newBuilder()
.maximumSize(1000L)
.expireAfterAccess(Duration.ofHours(1L)) // 多久不访问就过期
.expireAfterWrite(Duration.ofHours(1L)) // 多久这个key没修改就过期
.build(new CacheLoader<String, String>() {
@Override
public String load(String key) throws Exception {
// 数据装载方式,一般就是loadDB
return key + " world";
}
});
String value = cache.get("hello"); // 返回hello world

reloadableCache的使用


导入三方依赖


<dependency>
<groupId>com.github.phantomthief</groupId>
<artifactId>zknotify-cache</artifactId>
<version>0.1.22</version>
</dependency>

需要看文档,不然无法使用,有兴趣自己写一个也行的。


public interface ReloadableCache<T> extends Supplier<T> {

/**
* 获取缓存数据
*/
@Override
T get();

/**
* 通知全局缓存更新
* 注意:如果本地缓存没有初始化,本方法并不会初始化本地缓存并重新加载
*
* 如果需要初始化本地缓存,请先调用 {@link ReloadableCache#get()}
*/
void reload();

/**
* 更新本地缓存的本地副本
* 注意:如果本地缓存没有初始化,本方法并不会初始化并刷新本地的缓存
*
* 如果需要初始化本地缓存,请先调用 {@link ReloadableCache#get()}
*/
void reloadLocal();
}

老生常谈的缓存击穿/穿透/雪崩问题


这三个真的是亘古不变的问题,如果流量大确实需要考虑。


缓存击穿


简单说就是缓存失效,导致大量请求同一时间打到了数据库。对于缓存击穿问题上面已经给出了很多解决方案了。



  1. 比如使用本地缓存

  2. 本地缓存使用load-miss方法

  3. 使用第三方服务来加载缓存


1.2和都说过,主要来看3。假如业务愿意只能使用redis而无法使用本地缓存,比如数据量过大,实时性要求比较高。那么当缓存失效的时候就得想办法保证只有少量的请求打到数据库。很自然的就想到了使用分布式锁,理论上说是可行的,但实际上存在隐患。我们的分布式锁相信很多人都是使用redis+lua的方式实现的,并且在while中进行了轮训,这样请求量大,数据多的话会导致无形中让redis成了隐患,并且占了太多业务线程,其实仅仅是引入了分布式锁就加大了复杂度,我们的原则就是能不用就不用。


那么我们是不是可以设计一个类似分布式锁,但是更可靠的rpc服务呢?当调用get方法的时候这个rpc服务保证相同的key打到同一个节点,并且使用synchronized来进行加锁,之后完成数据的加载。在快手提供了一个叫cacheSetter的框架。下面提供一个简易版,自己写也很容易实现。


import com.google.common.collect.Lists;
import org.apache.commons.collections4.CollectionUtils;

import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.CountDownLatch;

/**
* @Description 分布式加载缓存的rpc服务,如果部署了多台机器那么调用端最好使用id做一致性hash保证相同id的请求打到同一台机器。
**/
public abstract class AbstractCacheSetterService implements CacheSetterService {

private final ConcurrentMap<String, CountDownLatch> loadCache = new ConcurrentHashMap<>();

private final Object lock = new Object();

@Override
public void load(Collection<String> needLoadIds) {
if (CollectionUtils.isEmpty(needLoadIds)) {
return;
}
CountDownLatch latch;
Collection<CountDownLatch> loadingLatchList;
synchronized (lock) {
loadingLatchList = excludeLoadingIds(needLoadIds);

needLoadIds = Collections.unmodifiableCollection(needLoadIds);

latch = saveLatch(needLoadIds);
}
System.out.println("needLoadIds:" + needLoadIds);
try {
if (CollectionUtils.isNotEmpty(needLoadIds)) {
loadCache(needLoadIds);
}
} finally {
release(needLoadIds, latch);
block(loadingLatchList);
}

}

/**
* 加锁
* @param loadingLatchList 需要加锁的id对应的CountDownLatch
*/
protected void block(Collection<CountDownLatch> loadingLatchList) {
if (CollectionUtils.isEmpty(loadingLatchList)) {
return;
}
System.out.println("block:" + loadingLatchList);
loadingLatchList.forEach(l -> {
try {
l.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
});
}

/**
* 释放锁
* @param needLoadIds 需要释放锁的id集合
* @param latch 通过该CountDownLatch来释放锁
*/
private void release(Collection<String> needLoadIds, CountDownLatch latch) {
if (CollectionUtils.isEmpty(needLoadIds)) {
return;
}
synchronized (lock) {
needLoadIds.forEach(id -> loadCache.remove(id));
}
if (latch != null) {
latch.countDown();
}
}

/**
* 加载缓存,比如根据id从db查询数据,然后设置到redis中
* @param needLoadIds 加载缓存的id集合
*/
protected abstract void loadCache(Collection<String> needLoadIds);

/**
* 对需要加载缓存的id绑定CountDownLatch,后续相同的id请求来了从map中找到CountDownLatch,并且await,直到该线程加载完了缓存
* @param needLoadIds 能够正在去加载缓存的id集合
* @return 公用的CountDownLatch
*/
protected CountDownLatch saveLatch(Collection<String> needLoadIds) {
if (CollectionUtils.isEmpty(needLoadIds)) {
return null;
}
CountDownLatch latch = new CountDownLatch(1);
needLoadIds.forEach(loadId -> loadCache.put(loadId, latch));
System.out.println("loadCache:" + loadCache);
return latch;
}

/**
* 哪些id正在加载数据,此时持有相同id的线程需要等待
* @param ids 需要加载缓存的id集合
* @return 正在加载的id所对应的CountDownLatch集合
*/
private Collection<CountDownLatch> excludeLoadingIds(Collection<String> ids) {
List<CountDownLatch> loadingLatchList = Lists.newArrayList();
Iterator<String> iterator = ids.iterator();
while (iterator.hasNext()) {
String id = iterator.next();
CountDownLatch latch = loadCache.get(id);
if (latch != null) {
loadingLatchList.add(latch);
iterator.remove();
}
}
System.out.println("loadingLatchList:" + loadingLatchList);
return loadingLatchList;
}
}

业务实现


import java.util.Collection;
public class BizCacheSetterRpcService extends AbstractCacheSetterService {
@Override
protected void loadCache(Collection<String> needLoadIds) {
// 读取db进行处理
// 设置缓存
}
}

缓存穿透


简单来说就是请求的数据在数据库不存在,导致无效请求打穿数据库。


解法也很简单,从db获取数据的方法(getByKey(K key))一定要给个默认值。


比如我有个奖池,金额上限是1W,用户完成任务的时候给他发笔钱,并且使用redis记录下来,并且落表,用户在任务页面能实时看到奖池剩余金额,在任务开始的时候显然奖池金额是不变的,redis和db里面都没有发放金额的记录,这就导致每次必然都去查db,对于这种情况,从db没查出来数据应该缓存个值0到缓存。


缓存雪崩


就是大量缓存集中失效打到了db,当然肯定都是一类的业务缓存,归根到底是代码写的有问题。可以将缓存失效的过期时间打散,别让其集中失效就可以了。


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

摆脱USB线,使用无线连接去开发安卓

前言 工作了大半年,之前一直都需要USB线连接手机才能用Android Studio去调试和安装安卓APP,然后上个礼拜,我突然发现前辈没连无线就可以调试,这让我好奇心一下上来,但又不好意思问,于是搜索了一下关于无线调试的内容,就看到谷歌早就给安卓用上了无线调...
继续阅读 »

前言


工作了大半年,之前一直都需要USB线连接手机才能用Android Studio去调试和安装安卓APP,然后上个礼拜,我突然发现前辈没连无线就可以调试,这让我好奇心一下上来,但又不好意思问,于是搜索了一下关于无线调试的内容,就看到谷歌早就给安卓用上了无线调试,只不过我一直不知道。


image.png
经过我探究了一番,踩了许多坑,最终于今天总算是知道如何稳定的进行无线连接了。


正篇


先感叹一下,不得不说,无线调试真的好用,总算不需要担心线绕来绕去。


一波三折


第一波风雨


为什么我对无线调试这么喜悦,原因就在于我手上的这个手机实在太坑,我几乎半年都被它所折磨,其实我一报型号你们就会明白了,它叫XiaoMi11,将它与电脑连接,结果说是在充电但电量一直在减少,而且那条线也在我一直连接高强度使用后露出了内部结构,所以又换了一根。


第二波浪潮


无线调试功能知晓后,我也是仔细阅读官方文档后,先把adb找到,配置到环境


image.png
我的路径在C:\Users\86152\AppData\Local\Android\Sdk\platform-tools,其实目的就是将这个包含adb.exe的platform-tools路径添加到系统环境变量Path:


image.png
这步完成后,直接win+r,再输入cmd回车之后输入adb devices即可看到自己连接的设备,这时也代表adb已经配好了环境。(本人是在Windows环境下的操作)

接着看看自己手机无线网是不是和电脑在一个局域下,直接ping自己手机ip,如果正常收发包即代表可以无线调试,作者是电脑连着经过路由器的线,而无线网也是该路由器发出的,不一定要电脑也连无线网:


image.png


adb devices

ping 你的手机IP 可以在手机无线调试的地方找到,文中下面的图可以看到

再查看自己手机已经是Android12,是大于官方给出的Android11,于是选择第一种连接方式:


image.png
就是如下图,直接在Android Studio中选择这个功能进行无线连接
image.png
如下图,它提供了两种方式,扫二维码,或者在手机开发者选项的无线调试使用验证码:


image.png
image.png

我又按照文档操作,打开了手机的无线调试:


image.png
结果无论是扫描二维码还是使用验证码都如石沉大海,了无音讯,一直就没成功过,就搁这一直转圈。


60b1098f1ac18e3273cb7dce45ee990.jpg
bb2dbd11f03c0b899bd38a4dc4a0f61.jpg

第三波呼啸


迫不得已,我又看了一些博客的老方法与官网对Android11以下的处理终于连上了,不过我每次都得打开手机无线调试看到IP地址和端口全部输进去才能连,而我就这么傻乎乎的连了一个星期:


6c7581671af66ac12318d52be2c7ed8.jpg

解决方法


所以作者在此不介绍这个不大好用的方法了,直接聊聊最好用的吧:
配置完adb环境后可以重启Android Studio,然后我们打开下图的AS的命令编辑器,同样可以输入adb命令了,当然同样可以ping命令去看看是否可以接通。


image.png
首先我们先输入命令:


image.png
会发现第一次给出failed,再输入一次报already connect即可连接成功


adb connect 手机ip地址(即无线调试中IP地址和端口中":xxxx"前面部分)

下次只要你手机仍连接此WIFI,只需要AS每次打开时输入过上面命令即可,不过在第一次连接时要同意手机上的弹窗哦,不然后面可能无线调试在连WIFI时不会自动开启,需要手动去点。


总结


快快掌握这个方法,让我们开启无线调试的安卓开发生活吧!


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

以感知生命周期的方式观察 Flow 数据

问题 Android 是有生命周期的,在 UI 展示的时候可以接受一些数据更新 UI,在 App 进入后台的时候应该停止接受数据以便释放资源,并且避免一些意想不到的异常; 协程和 Flow 是和 Android 平台无关的 API,正常情况下无法感知 ...
继续阅读 »

问题



  • Android 是有生命周期的,在 UI 展示的时候可以接受一些数据更新 UI,在 App 进入后台的时候应该停止接受数据以便释放资源,并且避免一些意想不到的异常;




  • 协程和 Flow 是和 Android 平台无关的 API,正常情况下无法感知 Android 生命周期方法;


解决方案


我们现在当然是有一些方案来解决上述问题,可能会有以下几种方式。


Flow Lifecycle-aware.gif


方式一:手动取消 Job


Activity 方式:



Fragment 方式:



以上方式显然是比较麻烦的,有一些模板代码。


方式二:使用 repeatOnLifecycle 解决模板代码


Activity 方式:



Fragment 方式:




注:需要添加依赖 androidx.lifecycle:lifecycle-runtime-ktx



这种方式虽然解决了一些模板代码的问题,但是仍然有多层嵌套的问题。


方式三:使用 flowWithLifecycle 解决多层嵌套


Activity 方式:




注:此 API 是在 2.6.0-alpha01 版本中添加。



Fragment 方式类似,使用 flowWithLifecycle 即可。flowWithLifecycle 是新版中新增的一个扩展函数,实现方式如下:



看上去是解决了嵌套的问题,但是并彻底,观察数据的操作还是需要在协程体中进行。


方式四:使用 collectWithLifecycle 彻底解决多层嵌套



自定义扩展函数 collectWithLifecycle 实现大致如下:



这个是我自己的一个扩展,官方库中并没有添加,不过我已经反馈给官方了。这种方式仅仅是解决了嵌套的问题,但是却隐藏了 Flow ,从而导致一系列的操作符将无法使用,不过这个在 UI Element/Compose 中并不是什么大问题。。


扩展阅读


处理 Android UI 的方式之外,Compose 本身也有同样的问题,官方也通过自定义扩展函数的形式添加了支持,大致如下:



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

Flutter3.3对Material3设计风格的支持

在Flutter3.3版本以上,支持Material3,使用Material3样式首先是要配置启用Material3。 Material3 主要体现在 圆角风格、颜色、文本样式等方面。 1 配置启用 Material3 查看当前 Flutter的版本 在程序...
继续阅读 »

在Flutter3.3版本以上,支持Material3,使用Material3样式首先是要配置启用Material3。


Material3 主要体现在 圆角风格、颜色、文本样式等方面。


1 配置启用 Material3


查看当前 Flutter的版本


image.png
在程序的入口 MaterialApp中设置主题ThemeData中的useMaterial3属性为true.


///flutter应用程序中的入口函数
void main() => runApp(TextApp());

///应用的根布局
class TextApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
///构建Materia Desin 风格的应用程序
return MaterialApp(
title: "Material3",
///在主题中启用
theme: ThemeData(
brightness: Brightness.light,
colorSchemeSeed: Colors.blue,
//启用
useMaterial3: true,
),
///默认的首页面
home: Material3Home(),
);
}
}

2 按钮样式的改变


按钮默认的圆角大小改变


2.1 ElevatedButton

ElevatedButton(
onPressed: (){},
child: const Text("Elevated"),
),


image.png


2.2 ElevatedButton 自定义样式

  ElevatedButton(
style: ElevatedButton.styleFrom(
// 前景色
// ignore: deprecated_member_use
onPrimary: Theme.of(context).colorScheme.onPrimary,
// 背景色
// ignore: deprecated_member_use
primary: Theme.of(context).colorScheme.primary,
).copyWith(elevation: ButtonStyleButton.allOrNull(0.0)),
onPressed: (){},
child: const Text('Filled'),
),

image.png


2.3 OutlinedButton

OutlinedButton(
onPressed: (){},
child: const Text("Outlined"),
),

image.png


2.4 FloatingActionButton.small

 FloatingActionButton.small(
onPressed: () {},
child: const Icon(Icons.add),
),

image.png


2.5 FloatingActionButton

FloatingActionButton(
onPressed: () {},
child: const Icon(Icons.add),
),

image.png


2.6 FloatingActionButton.extended

   FloatingActionButton.extended(
onPressed: () {},
icon: const Icon(Icons.add),
label: const Text("Create"),
),

image.png


2.7 FloatingActionButton.large

  FloatingActionButton.large(
onPressed: () {},
child: const Icon(Icons.add),
),

image.png


3 AlertDialog 的边框圆角改变


  void openDialog(BuildContext context) {
showDialog<void>(
context: context,
builder: (context) => AlertDialog(
title: const Text("Basic Dialog Title"),
content: const Text(
"A dialog is a type of modal window that appears in front of app content to provide critical information, or prompt for a decision to be made."),
actions: <Widget>[
TextButton(
child: const Text('Dismiss'),
onPressed: () => Navigator.of(context).pop(),
),
TextButton(
child: const Text('Action'),
onPressed: () => Navigator.of(context).pop(),
),
],
),
);
}

image.png


4 主题文本样式的默认大小改变


final textTheme = Theme.of(context)
.textTheme
.apply(displayColor: Theme.of(context).colorScheme.onSurface);


TextStyle large =textTheme.displayLarge;
TextStyle displayMedium =textTheme.displayMedium;
TextStyle displaySmall =textTheme.displaySmall;


image.png


5 ColorScheme 的变更


Widget buildMaterial() {
///当前颜色主题
ColorScheme colorScheme = Theme.of(context).colorScheme;
//背景色
final Color color = colorScheme.surface;
//阴影色
Color shadowColor = colorScheme.shadow;
Color surfaceTint = colorScheme.primary;
return Material(
borderRadius: BorderRadius.all(Radius.circular(4.0)),
elevation: 4,//阴影
color: color,//背景色
shadowColor: shadowColor,//阴影色
surfaceTintColor: surfaceTint,//
type: MaterialType.card,
child: Padding(
padding: const EdgeInsets.all(38.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'测试1',
style: Theme.of(context).textTheme.labelMedium,
),
Text(
'测试2',
style: Theme.of(context).textTheme.labelMedium,
),
],
),
),
);
}

image.png


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

超好用的官方core-ktx库,了解一下(终)~

ktx
Handler.postDelayed()简化lambda传入 不知道大家在使用Handler下的postDelayed()方法是不是感觉很不简洁,我们看下这个函数源码: public final boolean postDelayed(@NonNull Ru...
继续阅读 »

Handler.postDelayed()简化lambda传入


不知道大家在使用Handler下的postDelayed()方法是不是感觉很不简洁,我们看下这个函数源码:


public final boolean postDelayed(@NonNull Runnable r, long delayMillis) {
return sendMessageDelayed(getPostMessage(r), delayMillis);
}

可以看到Runnable类型的参数r放在第一位,在Kotlin中我们就无法利用其提供的简洁的语法糖,只能这样使用:


private fun test11(handler: Handler) {
handler.postDelayed({
//编写代码逻辑
}, 100)
}

有没有感觉很别扭,估计官方也发现了这个问题,就提供了这样一个扩展方法:


public inline fun Handler.postDelayed(
delayInMillis: Long,
token: Any? = null,
crossinline action: () -> Unit
): Runnable {
val runnable = Runnable { action() }
if (token == null) {
postDelayed(runnable, delayInMillis)
} else {
HandlerCompat.postDelayed(this, runnable, token, delayInMillis)
}
return runnable
}

可以看到将函数类型(相当于上面的Runnable中的代码执行逻辑)放到了方法参数的最后一位,这样利用kotlin的语法糖就可以这样使用:


private fun test11(handler: Handler) {
handler.postDelayed(200) {

}
}

可以看到这个函数类型使用了crossinline修饰,这个是用来加强内联的,因为其另一个Runnable的函数类型中进行了调用,这样我们就无法在这个函数类型action中使用return关键字了(return@标签除外),避免使用return关键字带来返回上的歧义不稳定性


除此之外,官方core-ktx还提供了类似的扩展方法postAtTime()方法,使用和上面一样!!


Context.getSystemService()泛型实化获取系统服务


看下以往我们怎么获取ClipboardManager:


private fun test11() {
val cm: ClipboardManager = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
}

看下官方提供的方法:


public inline fun <reified T : Any> Context.getSystemService(): T? =
ContextCompat.getSystemService(this, T::class.java)

借助于内联泛型实化简化了获取系统服务的代码逻辑:


private fun test11() {
val cm: ClipboardManager? = getSystemService()
}

泛型实化的用处有很多应用场景,大家感兴趣可以参考我另一篇文章:Gson序列化的TypeToken写起来太麻烦?优化它


Context.withStyledAttributes简化操作自定义属性


这个扩展一般只有在自定义View中较常使用,比如读取xml中设置的属性值,先看下我们平常是如何使用的:


private fun test11(
@NonNull context: Context,
@Nullable attrs: AttributeSet,
defStyleAttr: Int
) {
val ta = context.obtainStyledAttributes(
attrs, androidx.cardview.R.styleable.CardView, defStyleAttr,
androidx.cardview.R.style.CardView
)
//获取属性执行对应的操作逻辑
val tmp = ta.getColorStateList(androidx.cardview.R.styleable.CardView_cardBackgroundColor)

ta.recycle()
}

在获取完属性值后,还需要调用recycle()方法回收TypeArray,这个一旦忘记写就不好了,能让程序保证的写法那就尽量避免人为处理,所以官方提供了下面的扩展方法:


public inline fun Context.withStyledAttributes(
@StyleRes resourceId: Int,
attrs: IntArray,
block: TypedArray.() -> Unit
) {
obtainStyledAttributes(resourceId, attrs).apply(block).recycle()
}

使用如下:


private fun test11(
@NonNull context: Context,
@Nullable attrs: AttributeSet,
defStyleAttr: Int
) {
context.withStyledAttributes(
attrs, androidx.cardview.R.styleable.CardView, defStyleAttr,
androidx.cardview.R.style.CardView
) {
val tmp = getColorStateList(androidx.cardview.R.styleable.CardView_cardBackgroundColor)
}
}

上面的写法就保证了recycle()不会漏写,并且带接收者的函数类型block: TypedArray.() -> Unit也能让我们省略this直接调用TypeArray中的公共方法。


SQLiteDatabase.transaction()自动开启事务读写数据库


平常对SQLite进行写操作时为了效率及安全保证需要开启事务,一般我们都会手动进行开启和关闭,还是那句老话,能程序自动保证的事情就尽量避免手动实现,所以一般我们都会封装一个事务开启和关闭的方法,如下:


private fun writeSQL(sql: String) {
SQLiteDatabase.beginTransaction()
//执行sql写入语句
SQLiteDatabase.endTransaction()
}

官方core-ktx也提供了相似的扩展方法:


public inline fun <T> SQLiteDatabase.transaction(
exclusive: Boolean = true,
body: SQLiteDatabase.() -> T
): T {
if (exclusive) {
beginTransaction()
} else {
beginTransactionNonExclusive()
}
try {
val result = body()
setTransactionSuccessful()
return result
} finally {
endTransaction()
}
}

大家可以自行选择使用!


<K : Any, V : Any> lruCache()简化创建LruCache


LruCache一般用作数据缓存,里面使用了LRU算法来优先淘汰那些近期最少使用的数据。在Android开发中,我们可以使用其设计一个Bitmap缓存池,感兴趣的可以参考Glide内存缓存这块的源码,就利用了LruCache实现。


相比较于原有创建LruCache的方式,官方库提供了下面的扩展方法简化其创建流程:


inline fun <K : Any, V : Any> lruCache(
maxSize: Int,
crossinline sizeOf: (key: K, value: V) -> Int = { _, _ -> 1 },
@Suppress("USELESS_CAST")
crossinline create: (key: K) -> V? = { null as V? },
crossinline onEntryRemoved: (evicted: Boolean, key: K, oldValue: V, newValue: V?) -> Unit =
{ _, _, _, _ -> }
): LruCache<K, V> {
return object : LruCache<K, V>(maxSize) {
override fun sizeOf(key: K, value: V) = sizeOf(key, value)
override fun create(key: K) = create(key)
override fun entryRemoved(evicted: Boolean, key: K, oldValue: V, newValue: V?) {
onEntryRemoved(evicted, key, oldValue, newValue)
}
}
}

看下使用:


private fun createLRU() {
lruCache<String, Bitmap>(3072, sizeOf = { _, value ->
value.byteCount
}, onEntryRemoved = { evicted: Boolean, key: String, oldValue: Bitmap, newValue: Bitmap? ->
//缓存对象被移除的回调方法
})
}

可以看到,比之手动创建LruCache要稍微简单些,能稍微节省下使用成本。


bundleOf()快捷写入并创建Bundle对象


image.png


bundleOf()方法的参数被vararg声明,代表一个可变的参数类型,参数具体的类型为Pair,这个对象我们之前的文章有讲过,可以借助中缀表达式函数to完成Pair的创建:


private fun test12() {
val bundle = bundleOf("a" to "a", "b" to 10)
}

这种通过传入可变参数实现的Bundle如果大家不太喜欢,还可以考虑自行封装通用扩展函数,在函数类型即lambda中实现更加灵活的Bundle创建及写入:


1.自定义运算符重载方法set实现Bundle写入:


operator fun Bundle.set(key: String, value: Any?) {
when (value) {
null -> putString(key, null)

is Boolean -> putBoolean(key, value)
is Byte -> putByte(key, value)
is Char -> putChar(key, value)
is Double -> putDouble(key, value)
is Float -> putFloat(key, value)
is Int -> putInt(key, value)
is Long -> putLong(key, value)
is Short -> putShort(key, value)

is Serializable -> putSerializable(key, value)
//其实数据类型自定参考bundleOf源码实现
}
}

2.自定义BundleBuild支持向Bundle写入多个值


class BundleBuild(private val bundle: Bundle) {

infix fun String.to(that: Any?) {
bundle[this] = that
}
}

其中to()方法使用了中缀表达式的写法


3.暴漏扩展方法实现在lambda中完成Bundle的写入和创建


private fun bundleOf(block: BundleBuild.() -> Unit): Bundle {
return Bundle().apply {
BundleBuild(this).apply(block)
}
}

然后就可以这样使用:


private fun test12() {
val bundle = bundleOf {
"a" to "haha"
//经过一些逻辑操作获取结果后在写入Bundle
val t1 = 10 * 5
val t2 = ""
t2 to t1
}
}

相比较于官方库提供的bundleOf()提供的创建方式,通过函数类型也就是lambda创建并写入Bundle的方式更加灵活,并内部支持执行操作逻辑获取结果后再进行写入。


总结


关于官方core-ktx的研究基本上已经七七八八了,总共输出了五篇相关文章,对该库了解能够节省我们编写模板代码的时间,提高开发效率,大家如果感觉写的不错,可以点个赞支持下哈,感谢!!


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

【Android爬坑周记】用SplashScreen做一个会动的开屏!

Android 12以上加入了SplashScreen,并且支持开屏动画了!因此我在【小鹅事务所】项目中加入了一个开屏动画,如下(为方便动图展示,我故意延长了几秒钟): SplashScreen 简单介绍一下SplashScreen,仅在冷启动或者温启动的时...
继续阅读 »

Android 12以上加入了SplashScreen,并且支持开屏动画了!因此我在【小鹅事务所】项目中加入了一个开屏动画,如下(为方便动图展示,我故意延长了几秒钟):


开屏.gif


SplashScreen


简单介绍一下SplashScreen,仅在冷启动或者温启动的时候会展示SplashScreen,支持VAD动画、帧动画。我就先使用帧动画实现这个开屏动画,后面会考虑换成VAD动画。关于SplashScreen具体就不细讲啦,我讲这些讲不明白,没有官方文档讲得好,直接进入实战!!


注意裁切


image_bbWXUd5R2b.png


ICON在设计的时候只能够占用三分之二大小的圆,超出这部分的会被裁切掉,所以这点需要注意!


设计


首先打开UI设计软件,我此处用Figma,新建一个方形的框框,方形的框框里面整一个三分二大小的圆圈,像这样。


image_zN3wn3Qj0_.png


然后呢,就把设计好的Icon放进去


image_nPXCuEjgOv.png


这个时候一张静态图就做好啦,但是帧动画需要让图片动起来的话,就需要多张静态图。怎么设计它动起来呢?我的思路是让它扭头!像这样。


image_bKPGvPkiso.png


然后再把框框的颜色隐藏掉,我们只需要透明背景的Icon


image_9vcZawLYyF.png


注意,为了展示外边需要留空间,我给它们的框框加上描边,实际不需要!这个时候就可以导出图片啦,我这边选择导出矢量图,也就是SVG格式。


image_tg-XgUBqeW.png


导入动画


打开Android Studio,右键点击res → new → Vector Asset,再导入图片,将静态图都导进去就可以做动画啦。


image_7DgCf5ExTe.png


新建anim_little_goose.xml,根标签是animation-list,并在里面放4个item。


<?xml version="1.0" encoding="utf-8"?>
<animation-list xmlns:android="http://schemas.android.com/apk/res/android"
android:oneshot="false">
<item
android:drawable="@drawable/ic_little_goose"
android:duration="50" />
<item
android:drawable="@drawable/ic_little_goose_back"
android:duration="150" />
<item
android:drawable="@drawable/ic_little_goose"
android:duration="50" />
<item
android:drawable="@drawable/ic_little_goose_fore"
android:duration="150" />
</animation-list>

根据命名可以看出




  • 第一帧为正常的小鹅,展示50毫秒




  • 第二帧为向后扭头的小鹅,展示150毫秒




  • 第三帧为正常的小鹅,展示50毫秒




  • 第四帧为向前扭头的小鹅,展示150毫秒




一次循环就是400毫秒,点开Split界面,就能在右边预览动画了,这个时候,动画就简简单单做好了。


As.gif


SplashScreen


引入依赖


由于SplashScreen是Android12以上才有的,而Android12以下需要适配,但是!Jetpack提供了同名适配库,去gradle引用就好了。


//SplashScreen
implementation 'androidx.core:core-splashscreen:1.0.0'

设置开屏主题


然后在res/values/themes中新建一个style标签,并将其父标签设为Theme.SplashScreen,需要注意的是,如果适配了黑夜模式的话,也可以在values-night/themes文件下单独配置。


<style name="Theme.AppSplashScreen" parent="Theme.SplashScreen">
<item name="windowSplashScreenBackground">@color/primary_color</item>
<item name="windowSplashScreenAnimatedIcon">@drawable/anim_little_goose</item>
<item name="windowSplashScreenAnimationDuration">3000</item>
<item name="postSplashScreenTheme">@style/Theme.Account</item>
</style>

我这边配置一个无ICON背景的动画,因此不用windowSplashScreenIconBackgroundColor标签设置ICON背景。


简单介绍一下我设置的4个标签




  • windowSplashScreenBackground设置整个开屏动画的背景颜色。




  • windowSplashScreenAnimatedIcon设置的是开屏动画播放的动画文件,也就是上面写的动画文件。




  • windowSplashScreenAnimationDuration设置的是动画的播放时长,也就是说小鹅抖三秒钟头就会停止播放。




  • postSplashScreenTheme这个设置的是开屏动画播放完需要回到的主题,此处设置了我的主题。


    <style name="Theme.Account" parent="Theme.MaterialComponents.DayNight.NoActionBar">
    ...
    </style>



在Manifest注册


    <application
android:label="@string/app_name"
...
android:theme="@style/Theme.Account">

...
<activity
android:name=".ui.MainActivity"
android:theme="@style/Theme.AppSplashScreen"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>

</application>

可以看到在打开应用打开的第一个Activity,即MainActivity中设置了开屏主题,而在Application中设置了自己的主题。在Application设置主题的话,这个Application中的除了特殊设置Theme的Activity,其它都默认使用Application主题。


去MainActivity吧!


class MainActivity : BaseActivity() {

private val binding by viewBinding(ActivityMainBinding::inflate)

override fun onCreate(savedInstanceState: Bundle?) {
val splashScreen = installSplashScreen()
splashScreen.setKeepOnScreenCondition { !isAppInit }
super.onCreate(savedInstanceState)
initView()
}
...

}

重写onCreate函数,并在调用super.onCreate之前加载SplashScreen,即调用installSplashScreen,获得一个splashScreen实例,理论上来说调用installSplashScreen函数已经可以实现开屏动画了,可是我想等到一部分数据加载完再进入APP怎么办?


可以看到我调用了setKeepOnScreenCondition 函数,传入一个接口,这个接口返回一个Boolean值,如果返回true则继续展示开屏,如果返回false则进入APP。而此函数在每次绘制之前都会调用,是主线程调用的,因此不能在这里处理太多东西阻塞主线程!


我这边就设置了一个顶层变量,每次都去看看这个顶层变量的值,不会阻塞主线程。


class AccountApplication : Application() {

val supervisorScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)

//初始化数据 防止第一次打开还要加载
private fun initData() {
supervisorScope.launch {
val initIconDataDeferred = async { TransactionIconHelper.initIconData() }
val initTransactionDeferred = async { TransactionHelper.initTransaction() }
val initScheduleDeferred = async { ScheduleHelper.initSchedule() }
val initNoteDeferred = async { NoteHelper.initNote() }
val initMemorialsDeferred = async { MemorialHelper.initMemorials() }
val initTopMemorialDeferred = async { MemorialHelper.initTopMemorial() }
val initDataStoreDeferred = async { DataStoreHelper.INSTANCE.initDataStore() }
initIconDataDeferred.await()
initTransactionDeferred.await()
initScheduleDeferred.await()
initNoteDeferred.await()
initMemorialsDeferred.await()
initTopMemorialDeferred.await()
initDataStoreDeferred.await()
isAppInit = true
}
}
}

var isAppInit = false

我在Application中对所有需要初始化的东西先初始化一遍,初始化完之后再将isAppInit设置为true,此时在闪屏那边获取的为false,也就是说就会进入APP了。


到这里就结束了,去运行一下吧!


开屏.gif


总结


说实话,在我看来,SplashScreen其实用处不大,因为我们的闪屏一般是用来放advertisement的,而不是放有趣的动画的!


参考


SplashScreen: developer.android.google.cn/develop/ui/…


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

Android APT实战学习技巧

apt
简介 APT(Annotation Processing Tool)即注解处理器,在编译的时候可以处理注解然后搞一些事情,也可以在编译时生成一些文件之类的。ButterKnife和EventBus都使用了APT技术,如果不会APT技术就很难看懂这两个框架的源码...
继续阅读 »

简介


APT(Annotation Processing Tool)即注解处理器,在编译的时候可以处理注解然后搞一些事情,也可以在编译时生成一些文件之类的。ButterKnife和EventBus都使用了APT技术,如果不会APT技术就很难看懂这两个框架的源码。


作用


使用APT可以在编译时来处理编译时注解,生成额外的Java文件,有如下效果:



  • 可以达到减少重复代码手工编写的效果。



如ButterKnife,我们可以直接使用注解来减少findviewbyid这些代码,只需要通过注解表示是哪个id就够了。




  • 功能封装。将主要的功能逻辑封装起来,只保留注解调用。

  • 相对于使用Java反射来处理运行时注解,使用APT有着更加良好的性能。


Android基本编译流程


Android中的代码编译时需要经过:Java——>class ——> dex 流程,代码最终生成dex文件打入到APK包里面。


编译流程如图所示:




  • APT是在编译开始时就介入的,用来处理编译时注解。

  • AOP(Aspect Oridnted Programming)是在编译完成后生成dex文件之前,通过直接修改.class文件的方式,来对代码进行修改或添加逻辑。常用在在代码监控,代码修改,代码分析这些场景。


基本使用


基本使用流程主要包括如下几个步骤:



  1. 创建自定义注解

  2. 创建注解处理器,处理Java文件生成逻辑

  3. 封装一个供外部调用的API

  4. 项目中调用


整理思路



  1. 首先我们需要创建两个JavaLibrary

  2. 一个用来定义注解,一个用来扫描注解

  3. 获取到添加注解的成员变量名

  4. 动态生成类和方法用IO生成文件


实战


创建一个空项目



创建两个JavaLibrary



  • 注解的Lib: apt-annotation

  • 扫描注解的Lib: apt-processor




创建完之后



app模块依赖两个Library


implementation project(path: ':apt-annotation')
annotationProcessor project(path: ':apt-processor')


注解Lib中创建一个注解类


如果还不会自定义注解的同学,可以先去看我之前写的一篇Java自定义注解入门到实战


@Retention(RetentionPolicy.CLASS)
@Target(ElementType.FIELD)
public @interface Print {

}


扫描注解的Lib添加依赖


dependencies {
//自动注册,动态生成 META-INF/...文件
implementation 'com.google.auto.service:auto-service:1.0-rc6'
annotationProcessor 'com.google.auto.service:auto-service:1.0-rc6'
//依赖apt-annotation
implementation project(path: ':apt-annotation')
}


创建扫描注解的类



重写init方法,输出Hello,APT


注意: 这里是JavaLib,所以不能使用Log打印,这里可以使用Java的println()或注解处理器给我们提供的方法,建议使用注解处理器给我们提供的



现在我们已经完成了APT的基本配置,现在我们可以build一下项目了,成败在此一举



如果你已经成功输出了文本,说明APT已经配置好,可以继续下一步了


继续完成功能


现在我们可以继续完成上面要实现的功能了,我们需要先来实现几个方法


/**
* 要扫描扫描的注解,可以添加多个
*/
@Override
public Set<String> getSupportedAnnotationTypes() {
HashSet<String> hashSet = new HashSet<>();
hashSet.add(Print.class.getCanonicalName());
return hashSet;
}

/**
* 编译版本,固定写法就可以
*/
@Override
public SourceVersion getSupportedSourceVersion() {
return processingEnv.getSourceVersion();
}


定义注解


我们先在MianActivity中添加两个成员变量并使用我们定义的注解



定义注解


真正解析注解的地方是在process方法,我们先试试能不能拿到被注解的变量名


/**
* 扫描注解回调
*/
@Override
public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
//拿到所有添加Print注解的成员变量
Set<? extends Element> elements = roundEnv.getElementsAnnotatedWith(Print.class);
for (Element element : elements) {
//拿到成员变量名
Name simpleName = element.getSimpleName();
//输出成员变量名
processingEnv.getMessager().printMessage(Diagnostic.Kind.NOTE,simpleName);
}
return false;
}


编译试一下



生成类


既然能拿到被注解的变量名,后面就简单了,我们只需要用字符串拼出来一个工具类,然后用IO流写到本地就ok了



查看效果


现在点击一下编译,然后我们可以看到app模块下的build文件已经有我们生成的类了



调用方法


现在我们回到MainActivity,就可以直接调用这个动态生成的类了



总结


优点:


它可以做任何你不想做的繁杂的工作,它可以帮你写任何你不想重复代码,将重复代码抽取出来,用AOP思想去编写。 它可以生成任何java代码供你在任何地方使用。


难点:


在于设计模式和解耦思想的灵活应用。在于代理类代码生成的繁琐:你可以手动进行字符串拼接,也可以用squareup公司的javapoet库来构建出任何你想要的java代码。


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

安卓之如何优雅的处理Activity回收突发事件

情景与原因 前面的文章说过,我的一个业务要从页面A进入页面B,也就意味着我的应用出现了在ActivityA的基础上启动了ActivityB的情景,这个时候ActivityA就进入了停止状态,但这个时候如果出现系统内存不足的情况,就会把ActivityA回收掉,...
继续阅读 »

情景与原因


前面的文章说过,我的一个业务要从页面A进入页面B,也就意味着我的应用出现了在ActivityA的基础上启动了ActivityB的情景,这个时候ActivityA就进入了停止状态,但这个时候如果出现系统内存不足的情况,就会把ActivityA回收掉,此时用户按下Back键返回A,仍然会正常显示A,但此时的A是执行onCreate()方法加载的,而不是执行onRestart()方法,也就是说我们的ActivityA页面是被重新创建出来的。


那有什么区别呢,这就是我们平时网上填表之类的最讨厌的情景会浮现了,比如你好不容易把信息填好,点击下一步,然后发现想修改上个页面信息,哎,这时候你会发现由于系统内存不足回收掉了A,于是你辛辛苦苦填好的信息都没了,这太痛了。这一情景的出现就说明回收了A再重新创建会导致我们在A当中存的一些临时数据与状态都可能被丢失。


这就是我们今天要解决的问题。


解决方法


虽然出现这个问题是是否影响用户体验的,但解决方法是非常简单的,因为我们的Activity中还提供了一个onSaveInstanceState()回调方法,它就可以保证在Activity被回收之前一定被调用,而我们就可以利用这一特性去解决上述问题。


方法介绍


onSaveInstanceState()方法中会携带一个Bundle类型的参数,而Bundle提供了一系列方法用于保存我们想要的数据,其中如putString()就是用于保存字符串的,而putInt()方法显而易见也是保存整型数据的,而每个保存方法都需要传入两个参数,其中第一个参数我们称之为键,是用于在Bundle中取值的特定标记,第二个参数是我们要保存的内容,属于键值对类型,学过数据库的一般都知道。


写法


如下,我们可以这么去写:


override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
val tempData = "your temp data"
outState.putString("data_key", tempData)
}

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
if (savedInstanceState != null) {
val tempData = savedInstanceState.getString("data_key")
}
...
}

在onSaveInstanceState()中保存你想要的数据,在onCreate()中给取出来,这样就不用害怕再创建的时候数据丢失了,不过如果是横竖屏切换造成数据丢失还是依照前文ViewModel的写法更好。


结语


其实基础往往是最容易忽视的,我们之前光在那说高楼大厦如何建成,但地基并不牢靠,所以还是需要落实到基础上最好。


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

聊聊ART虚拟机_对象的分配问题

ART
前置知识 有Android开发基础 了解 Java 语法和 JVM 前言 ART 虚拟机(下图 Runtime 层),相信各位搞 Android 开发的同学都有知道,总体的印象呢就是:ART 与 JVM 不同,其不符合 JVM 规范不属于 JVM ,且为 ...
继续阅读 »

前置知识



  • 有Android开发基础

  • 了解 Java 语法和 JVM


前言


ART 虚拟机(下图 Runtime 层),相信各位搞 Android 开发的同学都有知道,总体的印象呢就是:ART 与 JVM 不同,其不符合 JVM 规范不属于 JVM ,且为 Dalvik 的进阶版。


但是,我们有必要对 ART 进行更加深入的了解,其有助于我们对 Android 的更深层次的理解。所以,本文将和聊一聊 ART 虚拟机,以及 ART 中一个对象是如何分配的。



何为ART虚拟机


在开始阶段,我们还是需要来聊一下什么是 ART 虚拟机,其不同在何处。



探析Android中的四类性能优化一文中,我们有提到 ART 虚拟机是 Google 在 Android4.4 的时候引入的,其用于替代 Dalvik 虚拟机。而在替代 Dalvik 虚拟机的同时,他也是兼容之前的 dex 格式的。ART 与 Dalvik 的不同点如下所示。



ART特性

1. 预编译

Dalvik 中的应用每次运行时,字节码都需要通过即时编译器 JIT 转换为机器码,这会使得应用的运行效率降低。在 ART 中,系统在安装应用时会进行一次预编译(AOT,Ahead-Of-Time),将字节码预先编译成机器码并存储在本地,这样应用就不用在每次运行时执行编译了,运行效率也大大提高。


2. 垃圾回收算法

在 Dalvik 采用的垃圾回收算法是标记-清除算法,启动垃圾回收机制会造成两次暂停(一次在遍历阶段,另一次在标记阶段)。而在 ART 下,GC 速度比 Dalvik 要快,这是因为应用本身做了垃圾回收的一些工作,启动 GC 后,不再是两次暂停,而是一次暂停,而且 ART 使用了一种新技术(packard pre-cleaning),在暂停前做了许多事情,减轻了暂停时的工作量。


3. 64 位

Dalvik 是为 32 位 CPU 设计的,而 ART 支持 64 位并兼容 32 位 CPU,这也是 Dalvik 被淘汰的主要原因。



由此可知,ART 让 Android 的性能有了很大的提升,从 2015 直到现在,我们使用的都还是 ART 虚拟机。


下图为 ART 的整体架构,我们可以看出,上层是执行层,负责直接对书写的代码进行处理,而下层则为运行时刻对 Java 语法的支持。


ART架构


对象的分配


对于对象的分配问题,实际上是 ART 对于类的管理问题。而类中则是描述了一个对象的内存布局(类成员的大小、类型和排布)和其函数信息。


例如 Object 类,包含以下的信息:


一个保存的是类型定义,一个保存的是锁的信息。


Object 类


类加载


一个类分配的对象的大小,是由继承链所决定的。当 Java 中的类首次使用的时候,就会进行类加载。例如首次使用到一个子类的时候,会自动将继承链上面的所有父类都进行加载,而整个继承链上面的类的总和就是该子类的大小。


例如下文中的子类的大小就是 AWork + BaseWork 两者合起来的大小。


puvlic class AWork extends BaseWork{

public AWork(WorkBean workBean){
super(work);
}

@Override
public process(Processbean processbean){
workBean.getA().actionA(processbean.getProcessA);
}
}

内存布局


如下图所示,当有 A->B->Object 这个继承关系的时候,其内存布局是父类在上,子类在下的方式进行排布的。而在每一个类里面,则是将引用类型置于最上方,而其他的类型则按字母顺序进行排序。


内存布局


双亲继承(双亲委派)


何为双亲继承呢?


双亲委派的意思是如果一个类加载器需要加载类,那么首先它会把这个类请求委派给父类加载器去完成,每一层都是如此。一直递归到顶层,当父加载器无法完成这个请求时,子类才会尝试去加载。


这么做的好处有一点,那就是不会出现假的委派父类,我们在委派的时候按照既定的逻辑寻找、只有在继承链上面的才是正确的,使得不会有虚假的父类出现。


这类底层的逻辑,反映出合理的继承链是有利于设计和执行的。其实由此我们也可以看到,其实很多设计原则的道理和这些底层逻辑设计也是相同的,例如迪米特原则和接口隔离原则,都是反映出继承链要合理,不要贪多的思维。


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

安卓关于Bitmap.isRecycled()空指针报错的解决方案

前言 起因是我在开发功能需要使用Bitmap的方法: BitmapFactory.decodeResource(my.main.getResources(),R.drawable.vector_my_need); 结果就倒大霉,运行时直接报错: java.la...
继续阅读 »

前言


起因是我在开发功能需要使用Bitmap的方法:


BitmapFactory.decodeResource(my.main.getResources(),R.drawable.vector_my_need);

结果就倒大霉,运行时直接报错:


java.lang.NullPointerException: Attempt to invoke virtual method 'boolean android.graphics.Bitmap.isRecycled()' on a null object reference

从日志分析,我们知道是出现了空指针,当时我先是想自己找原因,结果定位到view的源代码里,是在draw方法中,但没找到,我还没放弃,于是又定位了updateDisplayListIfDirty() 方法以及其他报错对应点,于是终于发现了是我bitmap的使用出了问题:


image.png
找到问题固然是好事,可是如何解决呢?这就要靠搜索了,接下来让我们看看解决方法。


正篇


正确的搜索方法


其实我在搜索上吃了许多亏,一开始在国内搜索上一直给我推C站的结果虽然有点相似但其实都相差甚远,最后我在StackOverflow上找到了答案,这也是曾经让别人困惑的一个问题:


image.png
可以看到有31K人浏览过此问题,所以该问题早有认可的答案:


image.png
意思说,我们用的vector矢量可绘制对象需要创建位图,而不是对其进行解码,且方法在下一个帖子中。


完美的解决方案


其实说到这,我已经明白,是我用SVG图资源放到安卓项目中转成Vector的xml文件,这种文件解码无法获得正确的bitmap,于是我恍然大悟的点开了下个帖子:


image.png
我为这个标准答案标记了中文解释,给出的是将我们的vector资源实例成Drawable对象,然后通过Bitmap的创建方法去创建成一个新的bitmap,代码如下:


Drawable d = getResources().getDrawable(R.drawable.your_drawable, your_app_theme);

这一步就是变成Drawable对象,接下来:


public static Bitmap drawableToBitmap (Drawable drawable) {

if (drawable instanceof BitmapDrawable) {
return ((BitmapDrawable)drawable).getBitmap();
}

Bitmap bitmap = Bitmap.createBitmap(drawable.getIntrinsicWidth(), drawable.getIntrinsicHeight(), Config.ARGB_8888);
Canvas canvas = new Canvas(bitmap);
drawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight());
drawable.draw(canvas);

return bitmap;
}

这段代码是将Drawable对象转换成Bitmap封装成了工具方法,便于直接应用于主代码。

而方法内部,也分成两层:

首先第一层,我们用instanceof测试它左边的对象是否是它右边的类的实例,如果是真命题则直接返回强制转成BitmapDrawable,并直接调用它的getBitmap()方法即可。


image.png
而如果第一层没有成功,则由第二层处理,我们先实例化Bitmap对象,利用Bitmap的createBitmap()方法输入drawable对象的固有宽高和BItmap通道配置获取bitmap


image.png


image.png
然后调用canvas绘制bitmap,最后先用drawable的setBounds()方法为Drawable对象指定一个边界矩形,这是为了调用 draw() 方法前可以确定绘制对象将绘制的位置,接着用draw()方法完成绘制,返回最终的bitmap即完成。


image.png


结语


这还是我第一次这么容易就获取到了明确的正确解决方案,所以特地记录下来,当然,如果你出现了这个空指针就不需要去看英文结果了,让我们更方便的解决这个问题吧。


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

Android动态更换应用图标

一、背景 近日,微博官方发布了一项新功能,即可以在App设置中动态更换微博的显示图标样式。根据微博官方的说法,除了最原始的图标外,微博还推出了另外10种不同的样式,既有3D微博、炫彩微博等保留了眼睛造型的新样式,也有奶酪甜馨、巧克力等以食物命名的“新口味”,还...
继续阅读 »

一、背景


近日,微博官方发布了一项新功能,即可以在App设置中动态更换微博的显示图标样式。根据微博官方的说法,除了最原始的图标外,微博还推出了另外10种不同的样式,既有3D微博、炫彩微博等保留了眼睛造型的新样式,也有奶酪甜馨、巧克力等以食物命名的“新口味”,还有梦幻紫、幻想星空等抽象派新造型,给了微博用户多种选择的自由。


不过需要注意的是,这一功能并不是面对所有人开放的,只有微博年费会员才能享受。此外,iOS 10.3及以上和Android 10及以上系统版本支持该功能,但是iPad与一加8Pro手机无法使用该功能。因部分手机存在系统差异,会导致该功能不可用,微博方面后续还会对该功能进行进一步优化。


image.png


二、技术实现


其实,说到底,上述功能用到的是动态更换桌面图标的技术。如果说多年以前,实现图标的切换还是一种时髦的技术,那么,我们可以直接使用PackageManager就可以实现动态更换桌面图标。


实现的细节是,在Manifest文件中使用标签准备多个Activity入口,没个activity都指向入口Activity,并且为每个拥有标签的activity设置单独的icon和应用名,最后调用SystemService 服务kill掉launcher,并执行launcher的重启操作。


首先,我们在AndroidManifest.xml文件中添加如下代码:


<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="com.xzh.demo">

<!-- 权限-->
<uses-permission android:name="android.permission.KILL_BACKGROUND_PROCESSES"/>

<application
android:allowBackup="true"
android:icon="@mipmap/wb_default_logo"
android:label="@string/app_name"
android:roundIcon="@mipmap/wb_default_logo"
android:supportsRtl="true"
android:theme="@style/Theme.AndroidDemo">

...//省略其他代码

<!-- 默认微博-->
<activity-alias
android:name="com.xzh.demo.default"
android:targetActivity=".MainActivity"
android:label="@string/app_name"
android:enabled="false"
android:icon="@mipmap/wb_default_logo"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity-alias>

<!-- 3D微博-->
<activity-alias
android:name=".threedweibo"
android:targetActivity=".MainActivity"
android:label="@string/wb_3d"
android:enabled="false"
android:icon="@mipmap/wb_3dweibo"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity-alias>

... //省略其他

</application>
</manifest>

上面配置中涉及到的属性如下:



  • android:name:注册的组件名字,启动组件的名称。

  • android:enabled:是否启用这个组件,也就是是否显示这个入口。

  • android:icon:图标

  • android:label:名称

  • android:targetActivity:默认的activity没有这个属性,指定目标activity,与默认的activity中的name属性是一样的,需要有相应的java类文件。


接着,我们在MainActivity触发Logo图标更换逻辑,代码如下:


class MainActivity : AppCompatActivity() {
var list: List<LogoBean> = ArrayList()
var recyclerView: RecyclerView? = null
var adapter: LogoAdapter? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
initView()
initData()
initRecycle()
}
private fun initView() {
recyclerView = findViewById(R.id.recycle_view)
}
private fun initData() {
list = Arrays.asList(
LogoBean(R.mipmap.wb_default_logo, "默认图标", true),
LogoBean(R.mipmap.wb_3dweibo, "3D微博", false),
LogoBean(R.mipmap.wb_cheese_sweetheart, "奶酪甜心", false),
LogoBean(R.mipmap.wb_chocolate_sweetheart, "巧克力", false),
LogoBean(R.mipmap.wb_clear_colorful, "清透七彩", false),
LogoBean(R.mipmap.wb_colorful_sunset, "多彩日落", false),
LogoBean(R.mipmap.wb_colorful_weibo, "炫彩微博", false),
LogoBean(R.mipmap.wb_cool_pool, "清凉泳池", false),
LogoBean(R.mipmap.wb_fantasy_purple, "梦幻紫", false),
LogoBean(R.mipmap.wb_fantasy_starry_sky, "幻想星空", false),
LogoBean(R.mipmap.wb_hot_weibo, "热感微博", false),
)
}
private fun initRecycle() {
adapter =LogoAdapter(this,list);
val layoutManager = GridLayoutManager(this, 3)
recyclerView?.layoutManager = layoutManager
recyclerView?.adapter = adapter
adapter?.setOnItemClickListener(object : OnItemClickListener {
override fun onItemClick(view: View?, position: Int) {
if(position==1){
changeLogo("com.xzh.demo.threedweibo")
}else if (position==2){
changeLogo("com.xzh.demo.cheese")
}else if (position==3){
changeLogo("com.xzh.demo.chocolate")
}else {
changeLogo("com.xzh.demo.default")
}
}
})
}

fun changeLogo(name: String) {
val pm = packageManager
pm.setComponentEnabledSetting(
componentName,
PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP
)
pm.setComponentEnabledSetting(
ComponentName(this, name),
PackageManager.COMPONENT_ENABLED_STATE_ENABLED, PackageManager.DONT_KILL_APP
)
reStartApp(pm)
}
fun reStartApp(pm: PackageManager) {
val am = getSystemService(ACTIVITY_SERVICE) as ActivityManager
val intent = Intent(Intent.ACTION_MAIN)
intent.addCategory(Intent.CATEGORY_HOME)
intent.addCategory(Intent.CATEGORY_DEFAULT)
val resolveInfos = pm.queryIntentActivities(intent, 0)
for (resolveInfo in resolveInfos) {
if (resolveInfo.activityInfo != null) {
am.killBackgroundProcesses(resolveInfo.activityInfo.packageName)
}
}
}
}

注意上面的changeLogo()方法中的字符串需要和AndroidManifest.xml文件中的<activity-alias>的name相对应。运行上面的代码,然后点击应用中的某个图标,就可以更换应用的桌面图标,如下图所示。


image.png


不过,测试的时候也遇到一些适配问题:



  • 小米9:版本升级时,新版本在AndroidManifest中删除A3,老版本切换图标到A3,为卸载直接覆盖安装新版本,手机桌面图标消失。

  • magic 4:版本升级时,新版本在AndroidManifest中删除A3,老版本切换图标到A3,为卸载直接覆盖安装新版本,手机桌面图标切换到默认图标,但点击之后未能打开APP。

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

Android gradle迁移至kts

背景 在kotlin语言已经渗透至各领域的环境下,比如服务端,android,跨平台Kmm,native for kotlin,几乎所有的领域都可以用kotlin去编写了,当然还有不成熟的地方,但是JB的目标是很一致的!我们最常用的gradle构建工具,也支持...
继续阅读 »

背景


在kotlin语言已经渗透至各领域的环境下,比如服务端,android,跨平台Kmm,native for kotlin,几乎所有的领域都可以用kotlin去编写了,当然还有不成熟的地方,但是JB的目标是很一致的!我们最常用的gradle构建工具,也支持kotlin好久了,但是由于编译速度或者转换成本的原因,真正实现kts转换的项目很少。在笔者的mac m1 中使用最新版的AS去编译build.gradle.kts,速度已经是和用groovy写的gradle脚本不相上下了,所以就准备写了这篇文章,希望做一个记录与分享。

groovykotlin
好处:构建速度较快,运用广泛,动态灵活好处:编译时完成所有,语法简洁,android项目中可用一套语言开发构建脚本与app编写
坏处:语法糖的作用下,很难理解gradle运行的全貌,作用单一,维护成本较高坏处:编译略慢于groovy,学习资料较少

虽然主流的gradle脚本编写依旧是groovy,但是android开发者官网也在推荐迁移到kotlin


编译前准备


这里推荐看看这篇文章,里面也涵盖了很多干货,


全局替换‘’为“”


在kotlin中,表示一个字符串用“”,不同于groovy的‘ ’,所以我们需要全局替换。可以通过快捷方式command/control+shift+R 全局替换,选中匹配正则表达式并设定file mask 为 *.gradle:


正则表达式
'(.*?[^\\])'
作用范围为
"$1"

image.png


全局替换方法调用


在groovy中,方法是可以隐藏(),举个例子


apply plugin: "com.android.application"

这里实际上是调用apply方法,然后命名参数是plugin,内容围为"com.android.application",然而在kotlin语法中,我们需要以()或者invoke的方式才能调用一个方法,所以我们要给所有的groovy函数调用添加()


正则表达式
(\w+) (([^=\{\s]+)(.*))
作用范围为
$1($2)

image.png
很遗憾的是,这个对于多行来说还是存在不足的,所以我们全局替换后还需要手动去修正部分内容即可,这里我们只要记得一个原则即可,想要调用一个kotlin函数,把参数包裹在()内即可,比如调用一个task函数,那么参数即为


task(sourcesJar(type: Jar) {
from(android.sourceSets.main.java.srcDirs)
classifier = "sources"
})

gradle kt化


接下来我们只需要把build.gradle 更改为文件名称为build.gradle.kts 即可,由于我们修改文件为了build.gradle.kts,所以当前就以kts脚本进行编译,所以很多的参数都是处于找不到状态的,即使sync也会报错,所以我们需要把报错的地方先注释掉,然后再进行sync操作,如果成功的话,AS就会帮我们进行一次编译,此时就可以有代码提示了。


开始前准备


以kotlin的方式编译,此时函数就处于可点击查看状态,区别于groovy,因为groovy是动态类型语言,所以很多做了很多语法糖,但是也给我们在debug阶段带来了很多困难,比如没有提示等等,因为groovy只需要保证在运行时找到函数即可,而kotlin却不一样,所以很多动态生成的函数就无法在编译时直接使用了,比如


image.png
对于这种动态函数,kotlin for gradle 其实也给我们内置了很多参数来对应着groovy的动态函数,下面我们来从以下方面去实践吧,tip:以下是gradle脚本编写常用


ext


我们在groovy脚本中,可以定义额外的变量在ext{}中,那么这个在kotlin中可以使用吗?嘿嘿,能用我就不会提到对吧!对的,不可以,因为ext也是一个动态函数,我们kotlin可没法用呀!那怎么办!别怕,kts中给我们定义了一个类似的变量,即extra,我们可以通过by extra去定义,然后就可以自由用我们的myNewProperty变量啦!


val myNewProperty by extra("initial value")

但是,如果我们在其他的gradle.kts脚本中用myNewProperty这个变量,那么也会找不到,因为myNewProperty这个的作用域其实只在当前文件中,确切来说是我们的build.gradle 最后会被编译生成一个Build_Init的类,这个类里面的东西能用的前提是,被先编译过!如果当前编译中的module引用了未被编译的module的变量,这当然不可行啦!当然,还是有对策的,我们可以在BuildScr这个module中定义自定义的函数,因为BuildScr这个module被定义在第一个先执行的module,所以我们后面的module就可以引用到这个“第一个module”的变量的方式去引用自定义的变量!


task



  • 新建task


groovy版本

task clean(type: Delete) {
delete rootProject.buildDir
}

比如clean就是一个我们自定义的task,转换为kotlin后其实也很简单,task是一个函数名,Delete是task的类型,clean是自定义名称


task("clean",{
delete(rootProject.buildDir)
})

当然,我们的task类型可能在编写的由于泛型推断,隐藏了具体的类型,这个时候我们可以通过


 ./gradlew help --task task名

去查看相应的类型



  • 已有task修改
    对于有些是已经在gradle编译时存在的函数任务,比如


groovy版本

wrapper{
gradleVersion = "7.1.1"
distributionType = Wrapper.DistributionType.BIN
}

这个我们kotlin版本的build.gradle能不能识别呢?其实是不可以的,因为编译器也不知道从哪里去找wrapper的定义,因为这个函数在groovy中隐藏了作用域,其实它存在于TaskContainerScope这个作用域中,所以对于所有的的task,其实都是执行在这里面的,我们可以通过tasks去找到


tasks {
named("wrapper") {
gradleVersion = "7.1.1"
distributionType = Wrapper.DistributionType.BIN

}
}

这种方式,去找到一个我们想要的task,并配置其内容



  • 生命周期函数
    我们可以通过函数调用的方式去配置相应的生命周期函数,比如doLast


tasks.create("greeting") {
doLast { println("Hello, World!") }
}

再比如dependOn


task("javadocJar", {
dependsOn(tasks.findByName("javadoc"))
})

动态函数


sourceSets就是一个典型的动态函数,为什么这么说,因为很多plugin都有自己的设置,比如Groovy的sourceSets,再比如Android的SourceSets,它其实是一个接口,正在实现其实是在plugin中。如果我们需要自定义配置一些东西,比如配置jniLibs的libs目录,直接迁移到kts就会出现main找不到的情况,这里是因为main不是一个内置的函数,但是存在相应的成员,这个时候我们可以通过by getting方式去获取,只要我们的变量在作用域内是存在的(编译阶段会添加),就可以获取到。如果我们想要生成其他成员,也可以通过by creating{}方式去生成一个没有的成员


sourceSets{
val main by getting{
jniLibs.srcDirs("src/main/libs")
jni.srcDirs()
}

}

也可以通过getByName方式去获取


sourceSets.getByName("main")

plugins


在比较旧的版本中,我们AS默认创建引入一个plugin的方式是


apply plugin: 'com.android.application'

其实这也是依赖了groovy的动态编译机制,这里针对的是,比如android{}作用域,如果我们转换成了build.gradle.kts,我们会惊讶的发现,android{}这个作用域居然爆红找不到了!这个时候我们需要改写成


plugins {
id("com.android.application")
}

就能够找到了,那么这背后的原理是什么呢?我们有必要去探究一下gradle的内部实现。


说了这么多的应用层写法,了解我的小伙伴肯定知道,原理解析肯定是放在最后啦!但是gradle是一个庞大的工程,单单靠着干唠是写不完的,所以我选出了最重要的一个例子,即plugins的解析,希望能够抛砖引玉,一起学习下去吧!


Plugins解析


我们可以通过在gradle文件中设置断点,然后debug运行gradle调试来学习gradle,最终在编译时,我们会走到DefaultScriptPluginFactory中进行相应的任务生成,我们来看看


DefaultScriptPluginFactory


            final ScriptTarget initialPassScriptTarget = initialPassTarget(target);

ScriptCompiler compiler = scriptCompilerFactory.createCompiler(scriptSource);

// 第一个阶段Pass 1, extract plugin requests and plugin repositories and execute buildscript {}, ignoring (i.e. not even compiling) anything else
CompileOperation initialOperation = compileOperationFactory.getPluginsBlockCompileOperation(initialPassScriptTarget);
Class scriptType = initialPassScriptTarget.getScriptClass();
ScriptRunner initialRunner = compiler.compile(scriptType, initialOperation, baseScope, Actions.doNothing());
initialRunner.run(target, services);

PluginRequests initialPluginRequests = getInitialPluginRequests(initialRunner);
PluginRequests mergedPluginRequests = autoAppliedPluginHandler.mergeWithAutoAppliedPlugins(initialPluginRequests, target);

PluginManagerInternal pluginManager = topLevelScript ? initialPassScriptTarget.getPluginManager() : null;
pluginRequestApplicator.applyPlugins(mergedPluginRequests, scriptHandler, pluginManager, targetScope);

// 第二个阶段Pass 2, compile everything except buildscript {}, pluginManagement{}, and plugin requests, then run
final ScriptTarget scriptTarget = secondPassTarget(target);
scriptType = scriptTarget.getScriptClass();

CompileOperation operation = compileOperationFactory.getScriptCompileOperation(scriptSource, scriptTarget);

final ScriptRunner runner = compiler.compile(scriptType, operation, targetScope, ClosureCreationInterceptingVerifier.INSTANCE);
if (scriptTarget.getSupportsMethodInheritance() && runner.getHasMethods()) {
scriptTarget.attachScript(runner.getScript());
}
if (!runner.getRunDoesSomething()) {
return;
}

Runnable buildScriptRunner = () -> runner.run(target, services);

boolean hasImperativeStatements = runner.getData().getHasImperativeStatements();
scriptTarget.addConfiguration(buildScriptRunner, !hasImperativeStatements);
}



可以看到,源码中特别注释了,编译时的两个阶段,我们可以看到,所有的script(指函数调用),都是分别经过了阶段1和阶段2之后才真正生效的。


image.png


那么为什么android作用域在apply plugin的方式不行,plugins方式却可以呢?其实就是两个运行阶段不一致的问题。groovy可以在运行时动态找到android 这个函数,即使两者都在阶段2运行,因为groovy语法本身的特性,即使android这个函数没有定义我们也可以引用,也是在运行时阶段报错。而kotlin不一样,kotlin需要在编译的时候需要找到我们要引用的函数,即android,所以同一个阶段即plugin都没有生效(需要执行完阶段才生效),我们当然也找不到android函数,那为什么plugins又可以呢?其实很容易想到,因为plugins是在第一阶段中执行并生效的,而android引用在第二个阶段,我们接着看源码


重点关注一下compileOperationFactory.getPluginsBlockCompileOperation方法,这个方法的实现类是DefaultCompileOperationFactory,在这里我们可以看到里面定义了两个阶段


public class DefaultCompileOperationFactory implements CompileOperationFactory {
private static final StringInterner INTERNER = new StringInterner();
private static final String CLASSPATH_COMPILE_STAGE = "CLASSPATH";
private static final String BODY_COMPILE_STAGE = "BODY";

private final BuildScriptDataSerializer buildScriptDataSerializer = new BuildScriptDataSerializer();
private final DocumentationRegistry documentationRegistry;

public DefaultCompileOperationFactory(DocumentationRegistry documentationRegistry) {
this.documentationRegistry = documentationRegistry;
}

public CompileOperation getPluginsBlockCompileOperation(ScriptTarget initialPassScriptTarget) {
InitialPassStatementTransformer initialPassStatementTransformer = new InitialPassStatementTransformer(initialPassScriptTarget, documentationRegistry);
SubsetScriptTransformer initialTransformer = new SubsetScriptTransformer(initialPassStatementTransformer);
String id = INTERNER.intern("cp_" + initialPassScriptTarget.getId());
return new NoDataCompileOperation(id, CLASSPATH_COMPILE_STAGE, initialTransformer);
}

public CompileOperation getScriptCompileOperation(ScriptSource scriptSource, ScriptTarget scriptTarget) {
BuildScriptTransformer buildScriptTransformer = new BuildScriptTransformer(scriptSource, scriptTarget);
String operationId = scriptTarget.getId();
return new FactoryBackedCompileOperation<>(operationId, BODY_COMPILE_STAGE, buildScriptTransformer, buildScriptTransformer, buildScriptDataSerializer);
}
}

getPluginsBlockCompileOperation中创建了一个InitialPassStatementTransformer类对象,我们关注transform方法的内容,即如果找到了plugins,我们就进行接下来的transform操作transformPluginsBlock,这就验证了,plugins的确在第一个阶段即classpath阶段运行



@Override
public Statement transform(SourceUnit sourceUnit, Statement statement) {
...

if (scriptBlock.getName().equals(PLUGINS)) {
return transformPluginsBlock(scriptBlock, sourceUnit, statement);
}
...


总结


文章列出来了几个关键的迁移了,相信大部分的问题都可以解决了,的确在迁移到kotlin之后,还是存在一定的迁移成本的,大部分就只能生啃官网介绍,希望看完都有收获吧!


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

使用 Flutter 轻松搞定短视频上滑翻页效果

前言 我们在短视频应用中经常会看到不停上滑浏览下一条视频的沉浸式交互效果,这种交互能够让用户不停地翻页,直到找到喜欢的视频内容,从而营造一种不断“搜寻目标”的感觉,让用户欲罢不能。这种交互形式在 Flutter 中可以轻松使用 PageView 组件实现。 ...
继续阅读 »

前言


我们在短视频应用中经常会看到不停上滑浏览下一条视频的沉浸式交互效果,这种交互能够让用户不停地翻页,直到找到喜欢的视频内容,从而营造一种不断“搜寻目标”的感觉,让用户欲罢不能。这种交互形式在 Flutter 中可以轻松使用 PageView 组件实现。
上滑交互.gif


PageView 组件介绍


PageView 组件专门设计用来实现翻页效果,类定义如下:


PageView({
Key? key,
this.scrollDirection = Axis.horizontal,
this.reverse = false,
PageController? controller,
this.physics,
this.pageSnapping = true,
this.onPageChanged,
List<Widget> children = const <Widget>[],
this.dragStartBehavior = DragStartBehavior.start,
this.allowImplicitScrolling = false,
this.restorationId,
this.clipBehavior = Clip.hardEdge,
this.scrollBehavior,
this.padEnds = true,
})

其中常用的属性说明如下:



  • scrollDirection:滑动方向,可以支持纵向翻页或横向翻页,默认是横向翻页。

  • controller:翻页控制器,可以通过控制器来制定初始页,以及跳转到具体的页面。

  • onPageChanged:翻页后的回调函数,会告知翻页后的页码。

  • reverse:是否反向翻页,默认是 false。如果横向滑动翻页的话,如果开启反向翻页,则是从右到左翻页。如果是纵向翻页的话,就是从顶部到底部翻页。

  • children:在翻页中的组件列表,每一页都以自定义组件内容,因此这个组件也可以用于做引导页,或是类似滑动查看详情的效果。


使用示例


PageView 使用起来非常简单,我们先定义一个PageView 翻页的内容组件,简单地将接收的图片文件满屏显示。代码如下,实际应用的时候可以根据需要换成其他自定义组件。


 class ImagePageView extends StatelessWidget {
final String imageName;
const ImagePageView({Key? key, required this.imageName}) : super(key: key);

@override
Widget build(BuildContext context) {
return Scaffold(
body: Image.asset(
imageName,
fit: BoxFit.fitHeight,
width: MediaQuery.of(context).size.width,
height: MediaQuery.of(context).size.height,
),
);
}
}

之后是定义一个 PageViewDemo 来应用 PageView 翻页应用示例,代码如下:


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

@override
State<PageViewDemo> createState() => _PageViewDemoState();
}

class _PageViewDemoState extends State<PageViewDemo> {
late PageController _pageController;
int _pageIndex = 1;

@override
void initState() {
_pageController = PageController(
initialPage: _pageIndex,
viewportFraction: 1.0,
);
super.initState();
}

@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: PageView(
scrollDirection: Axis.vertical,
onPageChanged: (index) {
_pageIndex = index;
},
controller: _pageController,
allowImplicitScrolling: false,
padEnds: true,
reverse: false,
children: const [
ImagePageView(imageName: 'images/earth.jpeg'),
ImagePageView(imageName: 'images/island-coder.png'),
ImagePageView(imageName: 'images/mb.jpeg'),
],
),
);
}
}

这个示例里,我们的 pageController 只是演示了设置初始页码。我们看到的 viewportFraction 可以理解为一页内容占据屏幕的比例,比如我们可以设置该数值为1/3,支持一个屏幕分段显示3个页面内容。


分段显示.gif


PageController 应用


PageController 可以控制滑动到指定位置,比如我们可以调用 animateToPage方法实现一个快速滑动到顶部的悬浮按钮。


floatingActionButton: FloatingActionButton(
onPressed: () {
_pageController.animateToPage(
0,
duration: const Duration(
milliseconds: 1000,
),
curve: Curves.easeOut,
);
},
backgroundColor: Colors.black.withAlpha(180),
child: const Icon(
Icons.arrow_upward,
color: Colors.white,
),
),

实现效果如下。


滑动到顶部.gif
PageController 还有如下控制翻页的方法:



  • jumpToPage:跳转到指定页面,但是没有动画。注意这里不会校验页码是否会超出范围。

  • nextPage:滑动到下一页,实际上调用的是 animateToPage 方法。

  • previousPage:滑动到上一页,实际上调用的是 animateToPage 方法。


总结


本篇介绍了 Flutter 的翻页组件 PageView 的使用,通过 PageView 可以轻松实现类似短视频的纵向上滑翻页的效果,也可以实现横向翻页效果(如阅读类软件)。在接下来的系列文章中,本专栏将会介绍更多 Flutter 实用的组件。


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

❤️Android 快别用Toast了,来试试Snackbar❤️

🔥 应用场景 Toast提示默认显示在界面底部,使用Toast.setGravity()将提示显示在中间,如下: Toast toast = Toast.makeText(this, str, Toast.LENGTH_SHORT); ...
继续阅读 »

🔥 应用场景


Toast提示默认显示在界面底部,使用Toast.setGravity()将提示显示在中间,如下:


        Toast toast = Toast.makeText(this, str, Toast.LENGTH_SHORT);
toast.setGravity(Gravity.CENTER, 0, 0);
toast.show();

运行在在Android 12上无法显示,查看Logcat提示如下:


Toast: setGravity() shouldn't be called on text toasts, the values won't be used

意思就是:你不能使用toast调用setGravity,调用无效。哎呀,看给牛气的,咱看看源码找找原因


🔥 源码


💥 Toast.setGravity()


    /**
* 设置Toast出现在屏幕上的位置。
*
* 警告:从 Android R 开始,对于面向 API 级别 R 或更高级别的应用程序,此方法在文本 toast 上调用时无效。
*/
public void setGravity(int gravity, int xOffset, int yOffset) {
if (isSystemRenderedTextToast()) {
Log.e(TAG, "setGravity() shouldn't be called on text toasts, the values won't be used");
}
mTN.mGravity = gravity;
mTN.mX = xOffset;
mTN.mY = yOffset;
}

妥了,人家就告诉你了 版本>=Android R(30),调用该方法无效。无效就无效呗,还不给显示了,过分。


Logcat的提示居然是在这里提示的,来都来了,咱们看看isSystemRenderedTextToast()方法。


💥 Toast.isSystemRenderedTextToast()


    /**
*Text Toast 将由 SystemUI 呈现,而不是在应用程序内呈现,因此应用程序无法绕过后台自定义 Toast 限制。
*/
@ChangeId
@EnabledAfter(targetSdkVersion = Build.VERSION_CODES.Q)
private static final long CHANGE_TEXT_TOASTS_IN_THE_SYSTEM = 147798919L;

private boolean isSystemRenderedTextToast() {
return Compatibility.isChangeEnabled(CHANGE_TEXT_TOASTS_IN_THE_SYSTEM) && mNextView == null;
}

重点了。Text Toast 将由 SystemUI 呈现,而不是在应用程序内呈现。


清晰明了,可以这样玩,但是你级别不够,不给你玩。


事情整明白了,再想想解决解决方案。他说了Text Toast 将由 SystemUI 呈现,那我不用 Text 不就行了。


🔥 Toast 提供的方法


先看看Tast提供的方法:



有这几个方法。咱们实践一下。保险起见看看源码


💥 Toast.setView() 源码


    /**
* 设置显示的View
* @deprecated 自定义 Toast 视图已弃用。 应用程序可以使用 makeText 方法创建标准文本 toast,
* 或使用 Snackbar
*/
@Deprecated
public void setView(View view) {
mNextView = view;
}

这个更狠,直接弃用。




  • 要么老老实实的用默认的Toast。




  • 要么使用 Snackbar。




🔥 Snackbar


Snackbar 就是一个类似Toast的快速弹出消息提示的控件(我是刚知道,哈哈)。


与Toast相比:




  • 一次只能显示一个




  • 与用户交互



    • 在右侧设置按钮来添加事件,根据 Material Design 的设计原则,只显示 1 个按钮 (添加多个,以最后的为准)




  • 提供Snackbar显示和关闭的监听事件



    • BaseTransientBottomBar.addCallback(BaseCallback)




💥 代码实现


    showMessage(findViewById(android.R.id.content), str, Snackbar.LENGTH_INDEFINITE);

public static void showMessage(View view, String str, int length) {
Snackbar snackbar = Snackbar.make(view, str, length);

View snackbarView = snackbar.getView();
//设置布局居中
FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(snackbarView.getLayoutParams().width, snackbarView.getLayoutParams().height);
params.gravity = Gravity.CENTER;
snackbarView.setLayoutParams(params);
//文字居中
TextView message = (TextView) snackbarView.findViewById(R.id.snackbar_text);
//View.setTextAlignment需要SDK>=17
message.setTextAlignment(View.TEXT_ALIGNMENT_GRAVITY);
message.setGravity(Gravity.CENTER);
message.setMaxLines(1);
snackbar.addCallback(new BaseTransientBottomBar.BaseCallback<Snackbar>() {
@Override
public void onDismissed(Snackbar transientBottomBar, int event) {
super.onDismissed(transientBottomBar, event);
//Snackbar关闭
}

@Override
public void onShown(Snackbar transientBottomBar) {
super.onShown(transientBottomBar);
//Snackbar显示
}
});
snackbar.setAction("取消", new View.OnClickListener() {
@Override
public void onClick(View v) {
//显示一个默认的Snackbar。
Snackbar.make(view, "我先走", BaseTransientBottomBar.LENGTH_LONG).show();
}
});
snackbar.show();
}

Snackbar.make的三个参数:



  • View:从View中找出当前窗口最外层视图,然后在其底部显示。

  • 第二个参数(text)

    • CharSequence

    • StringRes



  • duration(显示时长)

    • Snackbar.LENGTH_INDEFINITE 从 show()开始显示,直到它被关闭或显示另一个 Snackbar

    • Snackbar.LENGTH_SHORT 短时间

    • Snackbar.LENGTH_LONG 长时间

    • 自定义持续时间 以毫秒为单位




💥 效果


Android 12



Android 5.1



💥 工具类


如果觉得设置麻烦可以看看下面这边文章,然后整合一套适合自己的。


一行代码搞定Snackbar


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

Flutter 小技巧之优化你的代码性能

又到了小技巧系列更新时间,今天我们分享一个比较轻松的内容:Flutter 里的代码优化,优化的目的主要是为了提高性能和可维护性,放心,本篇我们不讲深入的源码分析,就是分享最最最基础的布局代码优化。我们先从一个简单的例子开始,相信大家对于 Flutter 的 U...
继续阅读 »

又到了小技巧系列更新时间,今天我们分享一个比较轻松的内容:Flutter 里的代码优化,优化的目的主要是为了提高性能和可维护性,放心,本篇我们不讲深入的源码分析,就是分享最最最基础的布局代码优化。

我们先从一个简单的例子开始,相信大家对于 Flutter 的 UI 构建不会陌生,那么如下代码所示,日常开发过程中 A 和 B 这两种代码组织方式,你更常用的是哪一种?

A (函数方式)B (Component Class 方式)

如果是从代码运行之后的 UI 效果来看,这两个方式运行之后的布局效果并不会有什么差异,而通常因为可以写更少代码和参数调用更方便等原因,我们可能在编写页面的内部控件时,会更经常使用 A (函数方式) 这种写法,也有称之为 Helper Method 的叫法。

那使用函数方式构建 UI 有没有问题?答案肯定是没问题,但是某些场景下,对比使用 B (Component Class 方式) ,可能性能表现上相对没那么优秀

举个例子,如下代码所示,在 renderA 函数里我们通过点击按键修改 count,在修改之后触发 UI 渲染时就需要用到 setState ,也就是我们每点一下,当前整个页面就是触发一次 rebuild ,但是我们只是想要改变当前 renderA 里的 count 文本而已。

这就是使用函数构建内部控件最常见的问题之一,因为子控件更新时是通过父容器的 setState ,所以每次子控件比如 renderA 发生变化时,就会触发整个 Widget 都出现 rebuild ,这其实并不是特别符合我们的预期。

科普一个众所周知的知识点, setState 其实就是调用 StatefulWidget 对应的 StatefulElement 里的 markNeedsBuild 方法,也就是对 Element (BuildContext) 里的 _dirty 标识为设置为 true ,仅此而已, 然后等待下次渲染更新

当然,你说像 renderA 这种写法会引起很严重的性能问题吗?事实上并不会,因为众所周知 Flutter 里的 UI 构建是通过多个不同的树来完成的,而 Widget 并不是真实的控件,所以一般情况下 renderA 这种写法导致的 rebuild 是不会产生严重的性能缺陷。

但是,如果同级下你的 renderB 是如下所示这样的情况呢?虽然这段代码毫无意义,但是我们在 renderA 点击改变 count 的时候,其实并没有改变 renderB 的用到的 status 参数,但是因为 renderA 里调用了 setState ,导致 renderB 每次都会进行重复进行浮点计算。

当然你可以说我写个变量进行缓存提前判断也可以解决,但这并不是这个例子的关键,那如果把上面这个例子变成 Component Class 的方式会有什么好处:

  • A 在点击更新 count 时不会影响其他控件
  • B 控件通过 didUpdateWidget 可以用更优雅的方式决定更新条件

这样看起来是不是更合理一些?另外 Component Class 的实现方式,也能在一定层度解决代码层级嵌套的问题,有时候实现一些 Component Class 的模版也可以成为 Flutter 里提高效率的工具,这个后面我们会聊到。

当然使用 Component Class 在无形之中会需要你写更多的代码,同时控件之间的状态联动成本也会有所提高,例如你需要在 B 控件关联 A 的 count 变化去改变高度,这时候可能就需要加入 InheritedWidget 或者 ValueNotifier 等方式来实现。

例如 Flutter 里 DefaultTabController 配合 TabBar 和 TabBarView 的实现就是一个很好的参考。

 Widget build(BuildContext context) {
return DefaultTabController(
length: myTabs.length,
child: Scaffold(
appBar: AppBar(
bottom: TabBar(
tabs: myTabs,
),
),
body: TabBarView(
children: myTabs.map((Tab tab) {
final String label = tab.text.toLowerCase();
return Center(
child: Text(
'This is the $label tab',
style: const TextStyle(fontSize: 36),
),
);
}).toList(),
),
),
);
}

所以到这里我们理解一个小技巧:在不偷懒的情况下,使用 Component Class 的方式实现子控件会比使用函数方式可能得到更好的性能和代码结构

当然,使用 Component Class 实现的方式,在调试时也会比函数方式更方便,如下图所示,当使用函数方式布局时,你在 Flutter Inspector 里看到的 Widget Tree 和 Details Tree 是完全铺平的情况,也没办法定制调试参数。

但是当你 Component Class 组织布局的时候,你就可以通过 override debugFillProperties 方法来可视化一些参数状态,例如 ItemA 里可以把 count 添加到 debugFillProperties 里,这样在 Details Tree 里也可以直观看到目前的 count 状态信息。

所以这里又有一个小技巧:通过 override debugFillProperties ,可以定制一些 Debug 时的可视化参数来帮助我们更好调试布局

既然讲到利用 Component Class 组织布局,那就不得不聊一个典型的控件:AnimatedBuilder 。

AnimatedBuilder 可以是最常说到的一个性能优化的例子, 一般情况下在页面的子控件里使用动画,特别是循环动画的话,我们都会建议使用前面介绍的 Component Class 方式,不然动画导致当前页面不停 rebuild 肯定会导致性能影响。

但是有时候我就不想用 Component Class 该怎么办?我就是想写在当前 Page 里,那就可以使用 AnimatedBuilder ,你只要把需要执行动画的部分放到 builder 方法里就好了。

因为 AnimatedBuilder 的内部会有一个 _AnimatedState 用于独立触发 setState,从而执行外部 builder 方法执行动画效果

类似 AnimatedBuilder 的模版实现,可以在一定程度上解决使用 Component Class 的痛点,当然,在使用 AnimatedBuilder 还是有一些需要注意, 比如 child 如果不需要跟随动画进行其他变化,一般是要放到 AnimatedBuilder 的 child 配置里,因为如果直接放在 builder 方法里,那就会出现 child 也跟随动画重新 rebuild 的情况,但是如果是放到 child 配置项里,那就是调用了 child 的对象缓存。

不正确使用正确使用
image-20221020175113790

如果对于这个缓存概念不理解,可以参考 《MediaQuery 和 build 优化你不知道的秘密》 里的“缓存区域不随帧变化,以便得到最小化的构建”。

当然类似 AnimatedBuilder 的构建方式还要注意 context 问题,不要拿错 context ,这也是很多时候会犯的潜在错误,特别是在调用 of(context) 的时候。

那有的人可能到这里会觉得,那你之前一直说 Widget 很轻,Widget 不是真正的控件,那 rebuild 多几次有什么问题?

一般情况下确实不会有太大问题,但是当你的控件有 Opacity 、ColorFilter 、 ShaderMash 或者 ClipRectClip.antiAliasWithSaveLayer)时,就可能会有较大的性能影响,因为他们都是可能会触发 saveLayer 的操作。

为什么 saveLayer 对性能影响很大?因为需要在 GPU 绘制是需要增加额外的缓冲区域,粗俗点说就是需要做图层的保存和合成,这就会对 GPU 渲染时产生较大影响的耗时。

而这里面最常遇到的应该就是 Opacity 带来的性能问题,因为它看起来是那么的轻便,但是从官方的介绍里,除非真的有必要,不然可以使用效果类似的实现去做场景替代,例如:

你需要对图片做透明度相关的动画是,那么使用 AnimatedOpacity 或 FadeInImage 代替 Opacity 会对性能更有帮助

AnimatedOpacity 和 Opacity 不一样吗?某种程度上还真不大一样, Opacity 的内部是 pushOpacity 的操作,而 AnimatedOpacity 里虽然有 OpacityLayer ,但是变动时是 updateCompositedLayer ;而 FadeInImage 会使用 GPU 的 fragment shader 去处理透明度的问题,所以性能也会更好一些。

或者在类似有颜色透明度的场景时,可以通过 Color.fromRGBO 来替代 Opacity ,除非你需要将不透明度应用到一大组较为复杂的 child 里,你才会需要使用 Opacity 。

/// no
Opacity(opacity: 0.5, child: Container(color: Colors.red))

/// yes
Container(color: Color.fromRGBO(255, 0, 0, 0.5))

另外还有 IntrinsicHeight / IntrinsicWidth 的场景,因为它们是可以通过 child 的内部宽高来调整 child 的大小,但是这个推算布局的过程会比较费时,可能会到 O(N²),虽然 Flutter 里针对这部分计算结果做了缓存,但是不妨碍它的耗时。

这么说可能有点抽象,举一个官方介绍过的例子,如下代码所示,当你在 ListView 里对 Row 的 children 进行 Align 排列时,你可能会发现它没有效果,因为此时通过 Border 可以看到,绿色和蓝色方框的父容器大小一致。

但是在加上 IntrinsicHeight 之后, 因为通过 IntrinsicHeight 的测算之后再返回 size,Row 里的三个 Item 现在高度一致,,这时候 Align 就可以生效了,但是正如前面所说,这个操作性对性能来说相对昂贵,虽然系统有缓存参数,但是如果出现动画 rebuild ,也会对性能造成影响。

对这部分感兴趣的可以看 : 《带你了解不一样的 Flutter》

到这里我们就理解了 (函数方式) 和 (Component Class 方式)组织布局的不同之处,同时也知道了 Component Class 方式可以帮助我们更好地调试布局代码,也举例了一些 UI 布局里常见的耗时场景

那本篇的小技巧到这里就结束了,如果你还有什么感兴趣或者有疑惑的,欢迎留言评论~


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

聊一聊Kotlin之data class

Kotlin是由JetBrains开发的针对JVM、Android和浏览器的静态编程语言,是Android的官方语言。Kotlin拥有较多高级而又简洁的语法特性,提升了我们的开发效率,减少了代码量。在使用 java 的时候,我们在用class定义一个entit...
继续阅读 »

Kotlin是由JetBrains开发的针对JVM、Android和浏览器的静态编程语言,是Android的官方语言。Kotlin拥有较多高级而又简洁的语法特性,提升了我们的开发效率,减少了代码量。

在使用 java 的时候,我们在用class定义一个entity时除了写get、set方法(使用Kotlin后省了这部分工作),经常还会重写类的 equalshashCodetoString方法,这些方法往往都是模板化的。在 kotlin 中提供了data class搞定这些模版化代码。

data class与class的区别:

实现方式

  • class类
    class ClassUser(val name: String, var age: Int)
  • data class类
    data class DataClassUser(val name: String, var age: Int)

自动重写toString方法

  • data类的toString方法会打印出属性的值
  • 非data类的toString方法则打印出内存地址
    val classUser = ClassUser("classuser", 18)
    val dataClassUser = DataClassUser("dataclassuser", 20)
    println("ClassUser -> ${classUser.toString()}")
    // ClassUser -> com.imock.vicjava.keyuse.ClassUser@11026067

    println("DataClassUser -> ${dataClassUser.toString()}")
    // DataClassUser -> DataClassUser(name=dataclassuser, age=20)

新增componentN方法

  • data类新增属性的componentN方法,component1代表第一个属性,component2代表第二个属性。(常用于解构声明)
    val dataClassUser = DataClassUser("dataclassuser", 20)
    println("DataClassUser component1() -> ${dataClassUser.component1()}")
    // DataClassUser component1() -> dataclassuser

    println("DataClassUser component2() -> ${dataClassUser.component2()}")
    // DataClassUser component2() -> 20

新增copy方法

  • data类新增copy方法,可以用来修改部分属性,但是保持其他不变。
    val dataClassUser = DataClassUser("dataclassuser", 20)
    println("ClassUser toString() -> ${classUser.toString()}")
    // DataClassUser -> DataClassUser(name=dataclassuser, age=20)

    val newDataClassUser = dataClassUser.copy(age = 22)
    println("DataClassUser copy -> ${newDataClassUser.toString()}")
    // DataClassUser copy -> DataClassUser(name=dataclassuser, age=22)

重写hashCode和 equals方法

  • data类重写hashCode方法,equals方法可以稍后看下源码,先判断两个是否是同一个对象,如果不是则进行类型判断,是相同类型则逐个比较属性的值。 
    val classUserLisa1 = ClassUser("lisa", 20)
    val classUserLisa2 = ClassUser("lisa", 20)
    println("ClassUser equals -> ${classUserLisa1.equals(classUserLisa2)}")
    // ClassUser equals -> false
    println("classUserLisa1 hashCode -> ${classUserLisa1.hashCode()}")
    // classUserLisa1 hashCode -> 2081652693
    println("classUserLisa2 hashCode -> ${classUserLisa2.hashCode()}")
    // classUserLisa2 hashCode -> 406765571

    val dataClassUserLisa1 = DataClassUser("lisa", 20)
    val dataClassUserLisa2 = DataClassUser("lisa", 20)
    println("DataClassUser equals -> ${dataClassUserLisa1.equals(dataClassUserLisa2)}")
    // DataClassUser equals -> true
    println("dataClassUserLisa1 hashCode -> ${dataClassUserLisa1.hashCode()}")
    // dataClassUserLisa1 hashCode -> 102981865
    println("dataClassUserLisa2 hashCode -> ${dataClassUserLisa2.hashCode()}")
    // dataClassUserLisa2 hashCode -> 102981865

data class为何如此神奇

data class DataClassUser(val name: String, var age: Int)

class ClassUser(var name: String, var age: Int)

单独看实现上两者没有太大的区别,一个使用data class,一个使用class,为何data class却多出那么多能力?得益于Kotlin高级的语法特性。我们都知道kotlin最终还是要编译成 java class 在 JVM 上运行的,为了更好的理解Kotlin高级而又简洁的语法特性,有时我们需要看看用kotlin写完的代码编译后是什么样子。Talk is cheap, show me the code.

class类编译后的java代码

Kotlin写法如下:

class ClassUser(var name: String, var age: Int)

查看编译后的java代码如下,可以看到帮我们自动生成了get、set和构造方法:

public final class ClassUser {
@NotNull
private final String name;
private int age;
@NotNull
public final String getName() {
return this.name;
}
public final int getAge() {
return this.age;
}
public final void setAge(int var1) {
this.age = var1;
}
public ClassUser(@NotNull String name, int age) {
Intrinsics.checkNotNullParameter(name, "name");
super();
this.name = name;
this.age = age;
}
}

data class类编译后的java代码

Kotlin写法如下:

data class DataClassUser(val name: String, var age: Int)

查看其编译后的java代码如下,会发现比class类编译后的代码多了部分方法,新增了componentscopy方法,重写了equalshashCodetoString方法。

public final class DataClassUser {
@NotNull
private final String name;
private int age;
@NotNull
public final String getName() {
return this.name;
}
public final int getAge() {
return this.age;
}
public final void setAge(int var1) {
this.age = var1;
}
public DataClassUser(@NotNull String name, int age) {
Intrinsics.checkNotNullParameter(name, "name");
super();
this.name = name;
this.age = age;
}
// 新增方法
@NotNull
public final String component1() {
return this.name;
}
// 新增方法
public final int component2() {
return this.age;
}
// 新增方法
@NotNull
public final DataClassUser copy(@NotNull String name, int age) {
Intrinsics.checkNotNullParameter(name, "name");
return new DataClassUser(name, age);
}
// 新增方法
// $FF: synthetic method
public static DataClassUser copy$default(DataClassUser var0, String var1, int var2, int var3, Object var4) {
if ((var3 & 1) != 0) {
var1 = var0.name;
}
if ((var3 & 2) != 0) {
var2 = var0.age;
}
return var0.copy(var1, var2);
}
// 重写该方法
@NotNull
public String toString() {
return "DataClassUser(name=" + this.name + ", age=" + this.age + ")";
}
// 重写该方法
public int hashCode() {
String var10000 = this.name;
return (var10000 != null ? var10000.hashCode() : 0) * 31 + Integer.hashCode(this.age);
}
// 重写该方法
public boolean equals(@Nullable Object var1) {
if (this != var1) {
if (var1 instanceof DataClassUser) {
DataClassUser var2 = (DataClassUser)var1;
if (Intrinsics.areEqual(this.name, var2.name) && this.age == var2.age) {
return true;
}
}
return false;
} else {
return true;
}
}
}

总结

知道data class干了啥

  • 重写toString方法。
  • 新增componentN方法(component1()、component2()、...、componentN()),其对应属性的声明顺序(常用于解构声明)。
  • 新增copy方法,可以用来修改部分属性,但是保持其他不变。
    特别提下copy方法,可能有些同学疑问很少见到这个方法使用场景,慢慢地等你用上了MVI框架就知道State必须使用 Kotlin data class,copy方法的应用自然少不了。
  • 重写equalshasCode方法,equals()方法不再单一比较对象引用,而是先判断两个是否是同一个对象,如果不是则进行类型判断,是相同类型则逐个比较属性的值。  

使用data class需要注意啥

  • 主构造函数必须要至少有一个参数。
  • 主构造函数中的所有参数必须被标记为val或者var。
  • 数据类不能有以下修饰符:abstract、inner、open、sealed。  


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

一看就懂!图解 Kotlin SharedFlow 缓存系统

前言Kotlin 为我们提供了两种创建“热流”的工具:StateFlow 和 SharedFlow。StateFlow 经常被用来替代 LiveData 充当架构组件使用,所以大家相对熟悉。其实 StateFlow 只是 SharedFlo...
继续阅读 »

前言

Kotlin 为我们提供了两种创建“热流”的工具:StateFlow 和 SharedFlow。StateFlow 经常被用来替代 LiveData 充当架构组件使用,所以大家相对熟悉。其实 StateFlow 只是 SharedFlow 的一种特化形式,SharedFlow 的功能更强大、使用场景更多,这得益于其自带的缓存系统,本文用图解的方式,带大家更形象地理解 SharedFlow 的缓存系统。

创建 SharedFlow 需要使用到 MutableSharedFlow() 方法,我们通过方法的三个参数配置缓存:

fun <T> MutableSharedFlow(
replay: Int = 0,
extraBufferCapacity: Int = 0,
onBufferOverflow: BufferOverflow = BufferOverflow.SUSPEND
): MutableSharedFlow<T>

接下来,我们通过时序图的形式介绍这三个关键参数对缓存的影响。正文之前让我们先统一一下用语:

  • Emitter:Flow 数据的生产者,从上游发射数据
  • Subcriber:Flow 数据的消费者,在下游接收数据

replay

当 Subscriber 订阅 SharedFlow 时,有机会接收到之前已发送过的数据,replay 指定了可以收到 subscribe 之前数据的数量。replay 不能为负数,默认值为 0 表示 Subscriber 只能接收到 subscribe 之后 emit 的数据:

上图展示的是 replay = 0 的情况,Subscriber 无法收到 subscribe 之前 emit 的 ❶,只能接收到 ❷ 和 ❸。

当 replay = n ( n > 0)时,SharedFlow 会启用缓存,此时 BufferSize 为 n,意味着可以缓存发射过的最近 n 个数据,并发送给新增的 Subscriber。

上图以 n = 1 为例 :

  1. Emitter 发送 ❶ ,并被 Buffer 缓存
  2. Subscriber 订阅 SharedFlow 后,接收到缓存的 ❶
  3. Emitter 相继发送 ❷ ❸ ,Buffer 缓存的数据相继依次被更新

在生产者消费者模型中,有时消费的速度赶不及生产,此时要加以控制,要么停止生产,要么丢弃数据。SharedFlow 也同样如此。有时 Subscriber 的处理速度较慢,Buffer 缓存的数据得不到及时处理,当 Buffer 为空时,emit 默认将会被挂起 ( onBufferOverflow = SUSPEND)

上面的图展示了 replay = 1 时 emit 发生 suspend 场景:

  1. Emitter 发送 ❶ 并被缓存
  2. Subscriber 订阅 SharedFlow ,接收 replay 的 ❶ 开始处理
  3. Emitter 发送 ❷ ,缓存数据更新为 ❷ ,由于 Subscriber 对 ❶ 的处理尚未结束,❷ 在缓存中没有及时被消费
  4. Emitter 发送 ❸,由于缓存的 ❷ 尚未被 Subscriber 消费,emit 发生挂起
  5. Subscriber 开始消费 ❷ ,Buffer 缓存 ❸ , Emitter 可以继续 emit 新数据

注意 SharedFlow 作为一个多播可以有多个 Subscriber,所以上面例子中,❷ 被消费的时间点,取决于最后一个开始处理的 Subscriber。

extraBufferCapacity

extraBufferCapacity 中的 extra 表示 replay-cache 之外为 Buffer 还可以额外追加的缓存。

若 replay = n, extraBufferCapacity = m,则 BufferSize = m + n

extraBufferCapacity 默认为 0,设置 extraBufferCapacity 有助于提升 Emitter 的吞吐量

在上图的基础之上,我们再设置 extraBufferCapacity = 1,效果如下图:

上图中 BufferSize = 1 + 1 = 2 :

  1. Emitter 发送 ❶ 并得到 Subscriber1 的处理 ,❶ 作为 replay 的一个数据被缓存,
  2. Emitter 发送 ❷,Buffer 中 replay-cache 的数据更新为 ❷
  3. Emitter 发送 ❸,Buffer 在存储了 replay 数据 ❷ 之上,作为 extra 又存储了 ❸
  4. Emitter 发送 ❹,此时 Buffer 已没有空余位置,emit 挂起
  5. Subscriber2 订阅 SharedFlow。虽然此时 Buffer 中存有 ❷ ❸ 两个数据,但是由于 replay = 1,所以 Subscriber2 只能收到最近的一个数据 ❸
  6. Subscriber1 处理完 ❶ 后,依次处理 Buffer 中的下一个数据,开始消费 ❷
  7. 对于 SharedFlow 来说,已经不存在没有消费 ❷ 的 Subscriber,❷ 移除缓存,❹ 的 emit 继续,并进入缓存,此时 Buffer 又有两个数据 ❸ ❹ ,
  8. Subscriber1 处理完 ❷ ,开始消费 ❸
  9. 不存在没有消费 ❸ 的 Subscriber, ❸ 移除缓存。

onBufferOverflow

前面的例子中,当 Buffer 被填满时,emit 会被挂起,这都是建立在 onBufferOverflow 为 SUSPEND 的前提下的。onBufferOverflow 用来指定缓存移除时的策略,除了默认的 SUSPEND,还有两个数据丢弃策略:

  • DROP_LATEST:丢弃最新的数据
  • DROP_OLDEST:丢弃最老的数据

需要特别注意的是,当 BufferSize = 0 时,extraBufferCapacity 只支持 SUSPEND,其他丢弃策略是无效的。这很好理解,因为 Buffer 中没有数据,所以丢弃无从下手,所以启动丢弃策略的前提是 Buffer 至少有一个缓冲区,且数据被填满

上图展示 DROP_LATEST 的效果。假设 replay = 2,extra = 0

  1. Emitter 发送 ❸ 时,由于 ❶ 已经被消费,所以 Buffer 数据从 ❶❷ 变为 ❷❸
  2. Emitter 发送 ❹ 时,由于 ❷ 还未被消费,Buffer 处于填满状态, ❹ 直接被丢弃
  3. Emitter 发送 ❺ 时,由于 ❷ 已经被费,可以移除缓存,Buffer 数据变为 ❸❺

上图展示了 DROP_OLDEST 的效果,与 DROP_LATEST 比较后非常明显,缓存中永远会储存最新的两个数据,但是较老的数据不管有没有被消费,都可能会从 Buffer 移除,所以 Subscriber 可以消费当前最新的数据,但是有可能漏掉中间的数据,比如图中漏掉了 ❷

注意:当 extraBufferCapacity 设为 SUSPEND 可以保证 Subscriber 一个不漏的消费掉所有数据,但是会影响 Emitter 的速度;当设置为 DROP_XXX 时,可以保证 emit 调用后立即返回,但是 Subscriber 可能会漏掉部分数据。

如果我们不想让 emit 发生挂起,除了设置 DROP_XXX 之外,还有一个方法就是调用 tryEmit,这是一个非 suspend 版本的 emit

abstract suspend override fun emit(value: T)

abstract fun tryEmit(value: T): Boolean

tryEmit 返回一个 boolean 值,你可以这样判断返回值,当使用 emit 会挂起时,使用 tryEmit 会返回 false,其余情况都是 true。这意味着 tryEmit 返回 false 的前提是 extraBufferCapacity 必须设为 SUSPEND,且 Buffer 中空余位置为 0 。此时使用 tryEmit 的效果等同于 DROP_LATEST。

SharedFlow Buffer

前面介绍的 MutableSharedFlow 的三个参数,其本质都是围绕 SharedFlow 的 Buffer 进行工作的。那么这个 Buffer 具体结构是怎样的呢?

上面这个图是 SharedFlow 源码中关于 Buffer 的注释,这个图形象地告诉了我们 Buffer 是一个线性数据结构(就是一个普通的数组 Array<Any?>),但是这个图不能直观反应 Buffer 运行机制。下面通过一个例子,看一下 Buffer 在运行时的具体更新过程:

val sharedFlow = MutableSharedFlow<Int>(
replay = 2,
extraBufferCapacity = 2,
onBufferOverflow = BufferOverflow.SUSPEND
)
var emitValue = 1

fun main() {
runBlocking {
launch {
sharedFlow.onEach {
delay(200) // simulate the consume of data
}.collect()
}

repeat(12) {
sharedFlow.emit(emitValue)
emitValue++
delay(50)
}
}
}

上面的代码很简单,SharedFlow 的 BufferSize = 2+2 = 4,Emitter 生产的速度大于 Subscriber 消费的速度,所以过程中会出现 Buffer 的填充和更新,下面依旧用图的方式展示 Buffer 的变化

先看一下代码对应的时序图:

有前面的介绍,相信这个时序图很容易理解,这里就不再赘述了,下面重点图解一下 Buffer 的内存变化。SharedFlow 的 Buffer 本质上是一个基于 Array 实现的 queue,通过指针移动从往队列增删元素,避免了元素在实际数组中的移动。这里关键的指针有三个:

  • head:队列的 head 指向 Buffer 的第一个有效数据,这是时间上最早进入缓存的数据,在数据被所有的 Subscriber 消费之前不会移除缓存。因此 head 也代表了最慢的 Subscriber 的处理进度
  • replay:Buffer 为 replay-cache 预留空间的其实位置,当有新的 Subscriber 订阅发生时,从此位置开始处理数据。
  • end:新数据进入缓存时的位置,end 这也代表了最快的 Subscriber 的处理进度。

如果 bufferSize 表示当前 Buffer 中存储数据的个数,则我们可知三指针 index 符合如下关系:

  • replay <= head + bufferSize
  • end = head + bufferSize

了解了三指针的含义后,我们再来看上图中的 Buffer 是如何工作的:

最后,总结一下 Buffer 的特点:

  • 基于数组实现,当数组空间不够时进行 2n 的扩容
  • 元素进入数组后的位置保持不变,通过移动指针,决定数据的消费起点
  • 指针移动到数组尾部后,会重新指向头部,数组空间可循环使用


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

Android完美处理输入框被挡问题

前言 前段时间出现了webview的输入框被软键盘挡住的问题,处理之后顺便对一些列的输入框被挡住的情况进行一个总结。 正常情况下的输入框被挡 正常情况下,输入框被输入法挡住,一般给window设softInputMode就能解决。 window.getAttr...
继续阅读 »

前言


前段时间出现了webview的输入框被软键盘挡住的问题,处理之后顺便对一些列的输入框被挡住的情况进行一个总结。


正常情况下的输入框被挡


正常情况下,输入框被输入法挡住,一般给window设softInputMode就能解决。

window.getAttributes().softInputMode = WindowManager.LayoutParams.XXX


有3种情况:

(1)SOFT_INPUT_ADJUST_RESIZE: 布局会被软键盘顶上去
(2)SOFT_INPUT_ADJUST_PAN:只会把输入框给顶上去(就是只顶一部分距离)
(3)SOFT_INPUT_ADJUST_NOTHING:不做任何操作(就是不顶)


SOFT_INPUT_ADJUST_PAN和SOFT_INPUT_ADJUST_RESIZE的不同在于SOFT_INPUT_ADJUST_PAN只是把输入框,而SOFT_INPUT_ADJUST_RESIZE会把整个布局顶上去,这就会有种布局高度在输入框展示和隐藏时高度动态变化的视觉效果。


如果你是出现了输入框被挡的情况,一般设置SOFT_INPUT_ADJUST_PAN就能解决。如果你是输入框没被挡,但是软键盘弹出的时候会把布局往上顶,如果你不希望往上顶,可以设置SOFT_INPUT_ADJUST_NOTHING。


softInputMode是window的属性,你给在Mainifest给Activity设置,也是设给window,你如果是Dialog或者popupwindow这种,就直接getWindow()来设置就行。正常情况下设置这个属性就能解决问题。


Webview的输入框被挡


但是Webview的输入框被挡的情况下,设这个属性有可能会失效。


Webview的情况下,SOFT_INPUT_ADJUST_PAN会没效果,然后,如果是Webview并且你还开沉浸模式的情况的话,SOFT_INPUT_ADJUST_RESIZE和SOFT_INPUT_ADJUST_PAN都会不起作用。

我去查看资料,发现这就是经典的issue 5497, 网上很多的解决方案就是通过AndroidBug5497Workaround,这个方案很容易能查到,我就不贴出来了,原理就是监听View树的变化,然后再计算高度,再去动态设置。这个方案的确能解决问题,但是我觉得这个操作不可控的因素比较多,说白了就是会不会某种机型或者情况下使用会出现其它的BUG,导致你需要写一些判断逻辑来处理特殊的情况。


解法就是不用沉浸模式然后使用SOFT_INPUT_ADJUST_RESIZE就能解决。但是有时候这个window显示的时候就需要沉浸模式,特别是一些适配刘海屏、水滴屏这些场景。


setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN)

那我的第一反应就是改变布局


window. setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);

这样是能正常把弹框顶上去,但是控件内部用的也是WRAP_CONTENT导致SOFT_INPUT_ADJUST_RESIZE改变布局之后就恢复不了原样,也就是会变形。而不用WRAP_CONTENT用固定高度的话,SOFT_INPUT_ADJUST_RESIZE也是失效的。


没事,还要办法,在MATCH_PARENT的情况下我们去设置fitSystemWindows为true,但是这个属性会让出一个顶部的安全距离,效果就是向下偏移了一个状态栏的高度。


这种情况下你可以去设置margin来解决这个顶部偏移的问题。


params.topMargin = statusHeight == 0 ? -120 : -statusHeight;
view.setLayoutParams(params);

这样的操作是能解除顶部偏移的问题,但是布局有可能被纵向压缩,这个我没完全测试过,我觉得如果你布局高度是固定的,可能不会受到影响,但我的webview是自适应的,webview里面的内容也是自适应的,所以我这出现了布局纵向压缩的情况。举个例子,你的view的高度是800,状态栏高度是100,那设fitSystemWindows之后的效果就是view显示700,paddingTop 100,这样的效果,设置params.topMargin =-100,之后,view显示700,paddingTop 100。大概是这个意思:能从视觉上消除顶部偏移,但是布局纵向被压缩的问题没得到处理


所以最终的解决方法是改WindowInsets的Rect (这个我等下会再解释是什么意思)


具体的操作就是在你的自定义view中加入下面两个方法


@Override
public void setFitsSystemWindows(boolean fitSystemWindows) {
fitSystemWindows = true;
super.setFitsSystemWindows(fitSystemWindows);
}


@Override
protected boolean fitSystemWindows(Rect insets) {
Log.v("mmp", "测试顶部偏移量: "+insets.top);
insets.top = 0;
return super.fitSystemWindows(insets);
}

小结


解决WebView+沉浸模式下输入框被软键盘挡住的步骤:



  1. window.getAttributes().softInputMode设置成SOFT_INPUT_ADJUST_RESIZE

  2. 设置view的fitSystemWindows为true,我这里是webview里面的输入框被挡住,设的就是webview而不是父View

  3. 重写fitSystemWindows方法,把insets的top设为0


WindowInsets


根据上面的3步操作,你就能处理webview输入框被挡的问题,但是如果你想知道为什么,这是什么原理。你就需要去了解WindowInsets。我们的沉浸模式的操作setSystemUiVisibility和设置fitSystemWindows属性,还有重写fitSystemWindows方法,都和WindowInsets有关。


WindowInsets是应用于窗口的系统视图的插入。例如状态栏STATUS_BAR和导航栏NAVIGATION_BAR。它会被view引用,所以我们要做具体的操作,是对view进行操作。


还有一个比较重要的问题,WindowInsets的不同版本都是有一定的差别,Android28、Android29、Android30都有一定的差别,例如29中有个android.graphics.Insets类,这是28里面没有的,我们可以在29中拿到它然后查看top、left等4个属性,但是只能查看,它是final的,不能直接拿出来修改。


但是WindowInsets这块其实能讲的内容比较多,以后可以拿出来单独做一篇文章,这里就简单介绍下,你只需要指定我们解决上面那些问题的原理,就是这个东西。


源码解析


大概对WindowInsets有个了解之后,我再带大家简单过一遍setFitsSystemWindows的源码,相信大家会印象更深。


public void setFitsSystemWindows(boolean fitSystemWindows) {
setFlags(fitSystemWindows ? FITS_SYSTEM_WINDOWS : 0, FITS_SYSTEM_WINDOWS);
}

它这里只是设置一个flag而已,如果你看它的注释(我这里就不帖出来了),他会把你引导到protected boolean fitSystemWindows(Rect insets)这个方法(我之后会说为什么会到这个方法)


@Deprecated
protected boolean fitSystemWindows(Rect insets) {
if ((mPrivateFlags3 & PFLAG3_APPLYING_INSETS) == 0) {
if (insets == null) {
// Null insets by definition have already been consumed.
// This call cannot apply insets since there are none to apply,
// so return false.
return false;
}
// If we're not in the process of dispatching the newer apply insets call,
// that means we're not in the compatibility path. Dispatch into the newer
// apply insets path and take things from there.
try {
mPrivateFlags3 |= PFLAG3_FITTING_SYSTEM_WINDOWS;
return dispatchApplyWindowInsets(new WindowInsets(insets)).isConsumed();
} finally {
mPrivateFlags3 &= ~PFLAG3_FITTING_SYSTEM_WINDOWS;
}
} else {
// We're being called from the newer apply insets path.
// Perform the standard fallback behavior.
return fitSystemWindowsInt(insets);
}
}

(mPrivateFlags3 & PFLAG3_APPLYING_INSETS) == 0 这个判断后面会简单讲,你只需要知道正常情况是执行fitSystemWindowsInt(insets)


而fitSystemWindows又是哪里调用的?往前跳,能看到是onApplyWindowInsets调用的,而onApplyWindowInsets又是由dispatchApplyWindowInsets调用的。其实到这里已经没必要往前找了,能看出这个就是个分发机制,没错,这里就是WindowInsets的分发机制,和View的事件分发机制类似,再往前找就是viewgroup调用的。前面说了WindowInsets在这里不会详细说,所以WindowInsets分发机制这里也不会去展开,你只需要先知道有那么一回事就行。


public WindowInsets dispatchApplyWindowInsets(WindowInsets insets) {
try {
mPrivateFlags3 |= PFLAG3_APPLYING_INSETS;
if (mListenerInfo != null && mListenerInfo.mOnApplyWindowInsetsListener != null) {
return mListenerInfo.mOnApplyWindowInsetsListener.onApplyWindowInsets(this, insets);
} else {
return onApplyWindowInsets(insets);
}
} finally {
mPrivateFlags3 &= ~PFLAG3_APPLYING_INSETS;
}
}

假设mPrivateFlags3是0,PFLAG3_APPLYING_INSETS是20,0和20做或运算,就是20。然后判断是否有mOnApplyWindowInsetsListener,这个Listener就是我们有没有在外面做


setOnApplyWindowInsetsListener(new OnApplyWindowInsetsListener() {
@Override
public WindowInsets onApplyWindowInsets(View v, WindowInsets insets) {
......
return insets;
}
});

假设没有,调用onApplyWindowInsets


public WindowInsets onApplyWindowInsets(WindowInsets insets) {
if ((mPrivateFlags3 & PFLAG3_FITTING_SYSTEM_WINDOWS) == 0) {
// We weren't called from within a direct call to fitSystemWindows,
// call into it as a fallback in case we're in a class that overrides it
// and has logic to perform.
if (fitSystemWindows(insets.getSystemWindowInsetsAsRect())) {
return insets.consumeSystemWindowInsets();
}
} else {
// We were called from within a direct call to fitSystemWindows.
if (fitSystemWindowsInt(insets.getSystemWindowInsetsAsRect())) {
return insets.consumeSystemWindowInsets();
}
}
return insets;
}

mPrivateFlags3 & PFLAG3_FITTING_SYSTEM_WINDOWS就是20和40做与运算,那就是0,所以调用fitSystemWindows。


而fitSystemWindows的(mPrivateFlags3 & PFLAG3_APPLYING_INSETS) == 0)就是20和20做与运算,不为0,所以调用fitSystemWindowsInt。


分析到这里,就需要结合我们上面解决BUG的思路了,我们其实是要拿到Rect insets这个参数,并且修改它的top。


setOnApplyWindowInsetsListener(new OnApplyWindowInsetsListener() {
@Override
public WindowInsets onApplyWindowInsets(View v, WindowInsets insets) {
......
return insets;
}
});

setOnApplyWindowInsetsListener回调中的insets可以拿到android.graphics.Insets这个类,但是你只能看到top是多少,没办法修改。当然你可以看到top是多少,然后按我上面的做法Margin设置一下


params.topMargin = -top;


如果你的布局不发生纵向变形,那倒没有多大关系,如果有变形,那就不能用这个做法。从源码看,这个过程主要涉及3个方法。我们能看出最好下手的地方就是fitSystemWindows。因为onApplyWindowInsets和dispatchApplyWindowInsets是分发机制的方法,你要在这里下手的话可能会出现流程混乱等问题。


所以我们这样做来解决fitSystemWindows = true出现的顶部偏移。


@Override
public void setFitsSystemWindows(boolean fitSystemWindows) {
fitSystemWindows = true;
super.setFitsSystemWindows(fitSystemWindows);
}


@Override
protected boolean fitSystemWindows(Rect insets) {
Log.v("mmp", "测试顶部偏移量: "+insets.top);
insets.top = 0;
return super.fitSystemWindows(insets);
}

扩展


上面已经解决问题了,这里是为了扩展一下思路。

fitSystemWindows方法是protected,导致你能重写它,但是如果这个过程我们没办法用继承来实现呢?


其实这就是一个解决问题的思路,我们要知道为什么会出现这种情况,原理是什么,比如这里我们知道这个fitSystemWindows导致的顶部偏移是insets的top导致的。你得先知道这一点,不然你不知道怎么去解决这个问题,你只能去网上找别人的方法一个一个试。那我怎么知道是insets的top导致的呢?这就需要有一定的源码阅读能力,还要知道这个东西设计的思想是怎样的。当你知道有这么一个东西之后,再想办法去拿到它然后改变数据。


这里我我们是利用继承protected方法这个特性去获取到insets,那如果这个过程没办法通过继承实现怎么办?比如这里是因为fitSystemWindows是view的方法,而我们自定义view正好继承view。如果它是内部自己写的一个类去实现这个操作呢?


这种情况下一般两种操作比较万金油:



  1. 你写一个类去继承它那个类,然后在你写的类里面去改insets,然后通过反射的方式把它注入给View

  2. 动态代理


我其实一开始改这个的想法就是用动态代理,所以马上把代码撸出来。


public class WebViewProxy implements InvocationHandler {

private Object relObj;

public Object newProxyInstance(Object object){
this.relObj = object;
return Proxy.newProxyInstance(relObj.getClass().getClassLoader(), relObj.getClass().getInterfaces(), this);
}

@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
try {
if ("fitSystemWindows".equals(method.getName()) && args != null && args.length == 1){
Log.v("mmp", "测试代理效果 "+args);
}
}catch (Exception e){
e.printStackTrace();
}
return proxy;
}

}

WebViewProxy proxy = new WebViewProxy();
View viewproxy = (View) proxy.newProxyInstance(mWebView);

然后才发现fitSystemWindows不是接口方法,白忙活一场,但是如果fitSystemWindows是接口方法的话,我这里就可以用通过动态代理加反射的操作去修改这个insets,虽然用不上,但也是个思路。最后发现可以直接重写这个方法就行,我反倒还把问题想复杂了。


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

Android 搜索框架使用

App中搜索功能是必不可少的,搜索功能可以帮助用户快速获取想要的信息。对此,Android提供了一个搜索框架,本文介绍如何通过搜索框架实现搜索功能。 搜索框架简介 Android 搜索框架提供了搜索弹窗和搜索控件两种使用方式。 搜索弹窗:系统控制的弹窗,激...
继续阅读 »

App中搜索功能是必不可少的,搜索功能可以帮助用户快速获取想要的信息。对此,Android提供了一个搜索框架,本文介绍如何通过搜索框架实现搜索功能。


搜索框架简介


Android 搜索框架提供了搜索弹窗和搜索控件两种使用方式。




  • 搜索弹窗:系统控制的弹窗,激活后显示在页面顶部,输入的内容提交后会通过Intent传递到指定的搜索Activity中处理,可以添加搜索建议。




  • 搜索控件(SearchView):系统实现的搜索控件,可以放在任意位置(可以与Toolbar结合使用),默认情况下与EditText类似,需要自己添加监听处理用户输入的数据,通过配置可以达到与搜索弹窗一致的行为。




使用搜索框架实现搜索功能


可搜索配置


在res/xml目录下创建searchable.xml(必须用此命名),如下:


<?xml version="1.0" encoding="utf-8"?>
<searchable xmlns:android="http://schemas.android.com/apk/res/android"
android:hint="@string/search_hint"
android:label="@string/app_name" />

android:label是此配置文件必须配置的属性,通常配置为App的名字,android:hint配置用户未输入内容时的提示文案,官方建议格式为“搜索${content or product}”


更多可搜索配置包含的语法和用法可以看官方文档


搜索页面


配置一个单独的Activity用于显示搜索内容,用户可能会在搜索完一个内容后立刻搜索下一个内容,所以建议把搜索页面设置为SingleTop,避免重复创建搜索页面。


AndroidManifest中配置搜索页面,如下:


<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<application
...
>

<activity
android:name=".search.SearchActivity"
android:exported="false"
android:launchMode="singleTop">

<meta-data
android:name="android.app.searchable"
android:resource="@xml/searchable" />

<intent-filter>
<action android:name="android.intent.action.SEARCH" />
</intent-filter>
</activity>
</application>
</manifest>

Activity中处理搜索数据,代码如下:


class SearchActivity : AppCompatActivity() {

private lateinit var binding: LayoutSearchActivityBinding

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = DataBindingUtil.setContentView(this, R.layout.layout_search_activity)
// 当搜索页面第一次打开时,获取搜索内容
getQueryKey(intent)
}

override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent)
// 更新Intent数据
setIntent(intent)
// 当搜索页面多次打开,并仍在栈顶时,获取搜索内容
getQueryKey(intent)
}

private fun getQueryKey(intent: Intent?) {
intent?.run {
if (Intent.ACTION_SEARCH == action) {
// 用户输入的内容
val queryKey = getStringExtra(SearchManager.QUERY) ?: ""
if (queryKey.isNotEmpty()) {
doSearch(queryKey)
}
}
}
}

private fun doSearch(queryKey: String) {
// 根据用户输入内容执行搜索操作
}
}

使用SearchView


SearchView可以放在页面的任意位置,本文与Toolbar结合使用,如何在Toolbar中创建菜单项在上一篇文章中介绍过,此处省略。要使SearchView与搜索弹窗保持一致的行为需要在代码中进行配置,如下:


class SearchActivity : AppCompatActivity() {

private lateinit var binding: LayoutSearchActivityBinding

override fun onCreateOptionsMenu(menu: Menu?): Boolean {
menuInflater.inflate(R.menu.example_seach_menu, menu)
menu?.run {
val searchManager = getSystemService(Context.SEARCH_SERVICE) as SearchManager
val searchView = findItem(R.id.action_search).actionView as SearchView
//设置搜索配置
searchView.setSearchableInfo(searchManager.getSearchableInfo(componentName))
}
return true
}

...
}

使用搜索弹窗


Activity中使用搜索弹窗,如果Activity已经配置为搜索页面则无需额外配置,否则需要在AndroidManifest中添加配置,如下:


<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<application
...
>

<activity
android:name=".search.SearchExampleActivity">

<!--为当前页面指定搜索页面-->
<!--如果所有页面都使用搜索弹窗,则将此meta-data移到applicaion标签下-->
<meta-data
android:name="android.app.default_searchable"
android:value=".search.SearchActivity" />
</activity>
</application>
</manifest>

Activity中通过onSearchRequested方法来调用搜索弹窗,如下:


class SearchExampleActivity : AppCompatActivity() {

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val binding: LayoutSearchExampleActivityBinding = DataBindingUtil.setContentView(this, R.layout.layout_search_example_activity)
binding.btnSearchDialog.setOnClickListener { onSearchRequested() }
}
}

搜索弹窗对Activity生命周期的影响


搜索弹窗的显示隐藏,不会像其他弹窗一样触发ActivityonPauseonResume方法。如果在搜索弹窗显示隐藏的同时需要对其他功能进行处理,可以通过onSearchRequestedOnDismissListener来实现,代码如下:


class SearchExampleActivity : AppCompatActivity() {

override fun onSearchRequested(): Boolean {
// 搜索弹窗显示,可以在此处理其他功能
return super.onSearchRequested()
}

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val binding: LayoutSearchExampleActivityBinding = DataBindingUtil.setContentView(this, R.layout.layout_search_example_activity)
binding.btnSearchDialog.setOnClickListener { onSearchRequested() }
val searchManager = getSystemService(Context.SEARCH_SERVICE) as SearchManager
searchManager.setOnDismissListener {
// 搜索弹窗隐藏,可以在此处理其他功能
}
}
}

附加额外的参数


使用搜索弹窗时,如果需要附加额外的参数用于优化搜索查询的过程,例如用户的性别、年龄等,可以通过如下代码实现:


// 配置额外参数
class SearchExampleActivity : AppCompatActivity() {

override fun onSearchRequested(): Boolean {
val appData = Bundle()
appData.putString("gender", "male")
appData.putInt("age", 24)
startSearch(null, false, appData, false)
// 返回true表示已经发起了查询
return true
}

...
}

// 在搜素页面中获取额外参数
class SearchActivity : AppCompatActivity() {

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
intent?.run {
if (Intent.ACTION_SEARCH == action) {
// 用户输入的内容
val queryKey = getStringExtra(SearchManager.QUERY) ?: ""
// 额外参数
val appData = getBundleExtra(SearchManager.APP_DATA)
appData?.run {
val gender = getString("gender") ?: ""
val age = getInt("age")
}
}
}
}
}

语音搜索


语音搜索让用户无需输入内容就可进行搜索,要开启语音搜索,需要在searchable.xml增加配置,如下:


<?xml version="1.0" encoding="utf-8"?>
<searchable xmlns:android="http://schemas.android.com/apk/res/android"
android:hint="@string/search_hint"
android:label="@string/app_name"
android:voiceSearchMode="showVoiceSearchButton|launchRecognizer" />

语音搜索必须配置showVoiceSearchButton用于显示语音搜索按钮,配置launchRecognizer指定语音搜索按钮启动一个语音识别程序用于识别语音转录为文本并发送至搜索页面。


更多语音搜索配置包含的语法和用法可以看官方文档


注意,语音识别后的文本会直接发送至搜索页面,无法更改,需要进行完备的测试确保语音搜索功能适合你的App。


搜索记录


用户执行过搜索后,可以将搜索的内容保存下来,下次要搜索相同的内容时,输入部分文字后就会显示匹配的搜索记录。


要实现此功能,需要完成下列步骤:


创建SearchRecentSuggestionsProvider


自定义RecentSearchProvider继承SearchRecentSuggestionsProvider,代码如下:


class RecentSearchProvider : SearchRecentSuggestionsProvider() {

companion object {
// 授权方的名称(建议设置为文件提供者的完整名称)
const val AUTHORITY = "com.chenyihong.exampledemo.search.RecentSearchProvider"
// 数据库模式
// 必须配置 DATABASE_MODE_QUERIES
// 可选配置 DATABASE_MODE_2LINES,为搜索记录提供第二行文本,可用于作为详情补充
const val MODE: Int = DATABASE_MODE_QUERIES or DATABASE_MODE_2LINES
}

init {
// 设置搜索授权方的名称与数据库模式
setupSuggestions(AUTHORITY, MODE)
}
}

AndroidManifest中配置Provider,如下:


<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<application
...
>

<!--android:authorities的值与RecentSearchProvider中的AUTHORITY一致-->
<provider
android:name=".search.RecentSearchProvider"
android:authorities="com.chenyihong.exampledemo.search.RecentSearchProvider"
android:exported="false" />
</application>
</manifest>

修改可搜索配置


searchable.xml增加配置,如下:


<?xml version="1.0" encoding="utf-8"?>
<searchable xmlns:android="http://schemas.android.com/apk/res/android"
android:hint="@string/search_hint"
android:label="@string/app_name"
android:voiceSearchMode="showVoiceSearchButton|launchRecognizer"
android:searchSuggestAuthority="com.chenyihong.exampledemo.search.RecentSearchProvider"
android:searchSuggestSelection=" ?"/>

android:searchSuggestAuthority 的值与RecentSearchProvider中的AUTHORITY保持一致。android:searchSuggestSelection的值必须为" ?",该值为数据库选择参数的占位符,自动由用户输入的内容替换。


在搜索页面中保存查询


获取到用户输入的数据时保存,代码如下:


class SearchActivity : BaseGestureDetectorActivity() {

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
intent?.run {
if (Intent.ACTION_SEARCH == action) {
val queryKey = getStringExtra(SearchManager.QUERY) ?: ""
if (queryKey.isNotEmpty()) {
// 第一个参数为用户输入的内容
// 第二个参数为第二行文本,可为null,仅当RecentSearchProvider.MODE为DATABASE_MODE_QUERIES or DATABASE_MODE_2LINES时有效。
SearchRecentSuggestions(this@SearchActivity, RecentSearchProvider.AUTHORITY, RecentSearchProvider.MODE)
.saveRecentQuery(queryKey, "history $queryKey")
}
}
}
}
}

清除搜索历史


为了保护用户的隐私,官方的建议是App必须提供清除搜索记录的功能。请求搜索记录可以通过如下代码实现:


class SearchActivity : BaseGestureDetectorActivity() {

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
SearchRecentSuggestions(this, RecentSearchProvider.AUTHORITY, RecentSearchProvider.MODE)
.clearHistory()
}
}

示例


整合之后做了个示例Demo,代码如下:


// 可搜索配置
<?xml version="1.0" encoding="utf-8"?>
<searchable xmlns:android="http://schemas.android.com/apk/res/android"
android:hint="@string/search_hint"
android:label="@string/app_name"
android:searchSuggestAuthority="com.chenyihong.exampledemo.search.RecentSearchProvider"
android:searchSuggestSelection=" ?"
android:voiceSearchMode="showVoiceSearchButton|launchRecognizer" />

// 清单文件
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<application
...
>

<activity
android:name=".search.SearchExampleActivity"
android:screenOrientation="portrait">

<!--为当前页面指定搜索页面-->
<meta-data
android:name="android.app.default_searchable"
android:value=".search.SearchActivity" />
</activity>

<activity
android:name=".search.SearchActivity"
android:exported="false"
android:launchMode="singleTop"
android:parentActivityName="com.chenyihong.exampledemo.search.SearchExampleActivity"
android:screenOrientation="portrait">

<meta-data
android:name="android.support.PARENT_ACTIVITY"
android:value="com.chenyihong.exampledemo.search.SearchExampleActivity" />

<meta-data
android:name="android.app.searchable"
android:resource="@xml/searchable" />

<intent-filter>
<action android:name="android.intent.action.SEARCH" />
</intent-filter>
</activity>

<provider
android:name=".search.RecentSearchProvider"
android:authorities="com.chenyihong.exampledemo.search.RecentSearchProvider"
android:exported="false" />
</application>
</manifest>

// 示例Activity
class SearchExampleActivity : BaseGestureDetectorActivity() {

override fun onSearchRequested(): Boolean {
val appData = Bundle()
appData.putString("gender", "male")
appData.putInt("age", 24)
startSearch(null, false, appData, false)
return true
}

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val binding: LayoutSearchExampleActivityBinding = DataBindingUtil.setContentView(this, R.layout.layout_search_example_activity)
binding.btnSearchView.setOnClickListener { startActivity(Intent(this, SearchActivity::class.java)) }
binding.btnSearchDialog.setOnClickListener { onSearchRequested() }
val searchManager = getSystemService(Context.SEARCH_SERVICE) as SearchManager
searchManager.setOnDismissListener {
runOnUiThread { Toast.makeText(this, "Search Dialog dismiss", Toast.LENGTH_SHORT).show() }
}
}
}

class SearchActivity : BaseGestureDetectorActivity() {

private lateinit var binding: LayoutSearchActivityBinding

private val textDataAdapter = TextDataAdapter()

private val originData = ArrayList<String>()

private var lastQueryValue = ""

override fun onCreateOptionsMenu(menu: Menu?): Boolean {
menuInflater.inflate(R.menu.example_seach_menu, menu)
menu?.run {
val searchManager = getSystemService(Context.SEARCH_SERVICE) as SearchManager
val searchView = findItem(R.id.action_search).actionView as SearchView
searchView.setOnCloseListener {
textDataAdapter.setNewData(originData)
false
}
searchView.setSearchableInfo(searchManager.getSearchableInfo(componentName))
if (lastQueryValue.isNotEmpty()) {
searchView.setQuery(lastQueryValue, false)
}
}
return true
}

override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.itemId == R.id.action_clear_search_histor) {
SearchRecentSuggestions(this, RecentSearchProvider.AUTHORITY, RecentSearchProvider.MODE)
.clearHistory()
}
return super.onOptionsItemSelected(item)
}

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = DataBindingUtil.setContentView(this, R.layout.layout_search_activity)
setSupportActionBar(binding.toolbar)
supportActionBar?.run {
title = "SearchExample"
setHomeAsUpIndicator(R.drawable.icon_back)
setDisplayHomeAsUpEnabled(true)
}
binding.rvContent.adapter = textDataAdapter
originData.add("test data qwertyuiop")
originData.add("test data asdfghjkl")
originData.add("test data zxcvbnm")
originData.add("test data 123456789")
originData.add("test data /.,?-+")
textDataAdapter.setNewData(originData)
// 获取搜索内容
getQueryKey(intent, false)
}

override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent)
// 更新Intent数据
setIntent(intent)
// 获取搜索内容
getQueryKey(intent, true)
}

private fun getQueryKey(intent: Intent?, newIntent: Boolean) {
intent?.run {
if (Intent.ACTION_SEARCH == action) {
val queryKey = getStringExtra(SearchManager.QUERY) ?: ""
if (queryKey.isNotEmpty()) {
SearchRecentSuggestions(this@SearchActivity, RecentSearchProvider.AUTHORITY, RecentSearchProvider.MODE)
.saveRecentQuery(queryKey, "history $queryKey")
if (!newIntent) {
lastQueryValue = queryKey
}
val appData = getBundleExtra(SearchManager.APP_DATA)
doSearch(queryKey, appData)
}
}
}
}

private fun doSearch(queryKey: String, appData: Bundle?) {
appData?.run {
val gender = getString("gender") ?: ""
val age = getInt("age")
}
val filterData = originData.filter { it.contains(queryKey) } as ArrayList<String>
textDataAdapter.setNewData(filterData)
}
}

ExampleDemo github


ExampleDemo gitee


效果如图:


device-2022-10-07-18 -original-original.gif

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

Android进阶宝典 -- GC与ART调优

1 GC相关算法 在进行GC的时候,垃圾回收器需要知道什么对象需要被回收,回收后内存如何整理,这其中就涉及到了很多核心的算法,这里详细介绍一下。 1.1 垃圾确认算法 垃圾确认算法,目的在于标记可以被回收的对象,其中主要有2种:引用计数算法和GcRoot可达性...
继续阅读 »

1 GC相关算法


在进行GC的时候,垃圾回收器需要知道什么对象需要被回收,回收后内存如何整理,这其中就涉及到了很多核心的算法,这里详细介绍一下。


1.1 垃圾确认算法


垃圾确认算法,目的在于标记可以被回收的对象,其中主要有2种:引用计数算法和GcRoot可达性分析算法


1.1.1 引用计数算法


引用计数算法是比较原始的一个算法,核心逻辑采用计数器的方式,当一个对象被引用时,引用计数+1,而引用失效之后,引用计数-1,当这个对象引用计数为0时,代表该对象是可以被回收的。


那么这个引用计数算法被废弃的主要原因有2个:


(1)需要使用引用计数器存储计数,需要额外开辟内存;

(2)最大问题就是,无法解决循环引用的问题,这样会导致引用计数始终无法变为0,但两个引用对象已经没有其他对象使用了。


所以可达性分析算法的出现,就能够解决这个问题。


1.1.2 可达性分析算法


可达性分析算法,是以根节点集合为起点,其实就是GcRoots集合,然后遍历每个GcRoot引用的对象,其中与GcRoot直接或者间接连接的对象都是存活的对象,其他对象会被标记为垃圾。


image.png


那么什么样的对象会被选中为GcRoot呢?


(1)虚拟机栈局部变量表中的对象


这个其实比较好解释,就是一个方法的执行肯定需要这个对象的,如果随便就被回收了,这个方法也执行不下去了。


(2)方法区中的静态变量

(3)方法区中的常量


这种都是生命周期比较长的对象,也可以作为GcRoot


(4)本地方法栈中JNI本地方法的引用对象。


我们能够看到,GcRoot对象的共同点都是不易于被垃圾回收器回收


1.2 垃圾清除算法


前面我们通过标记算法标记了可以被回收的对象,接下来通过垃圾清除算法就可以将垃圾回收


1.2.1 标记清除算法


image.png


其中打上标记的,就是需要被清除的垃圾对象,那么垃圾回收之后


image.png


这种算法存在的的问题:


(1)效率差; 需要遍历全部对象查找被标记的对象

(2)在GC的时候需要STW,影响用户体验

(3)核心问题会产生内存碎片,这种算法不能重新整理内存,例如需要申请4内存空间,会发现没有连续的4块内存,只能再次发起GC


1.2.2 复制算法


这部分跟新生代的survivor区域有些类似,复制算法是将内存区域1分为2,每次只使用1块区域,当发起GC的时候,先把活的对象全部复制到另一块区域,然后把当前区域的对象全部删除。


image.png


在分配内存时,只是使用左半边区域,发起GC后:


image.png


我们发现,复制算法会整理内存,这里就不会再有内存碎片了。


这种方式存在的弊端:因为涉及到内存整理,因此需要维护对象的引用关系,时间开销大。


1.3.3 标记整理算法


其实看名字,就应该知道这个算法是集多家之所长,在清除的同时还能去整理内存,避免内存碎片。


image.png


首先跟标记清除算法一样,先将死的对象全部清楚,然后通过算法内部逻辑移动内存碎片,使其成为一块连续的内存


image.png


其实3种算法比较来看,复制算法效率最快,但是内存开销大;相对来说,标记整理更加平滑一些,但是也不是最优解,而且凡是移动内存的操作,全部都会STW,影响用户体验。


1.3.4 分代收集算法


这个方式在上一篇文章开题就已经介绍过了,将堆区分为新生代和老年代,因为大部分对象一开始都会存储在Eden区,因此新生代会是垃圾回收最活跃的,因此在新生代就使用了复制算法,将新生代按照8(Eden):2(survivor)的比例分成,速度最快,减少因为STW带来的体验问题


那么在老年代显然是GC不活跃的区域,而且在这个区域中不能有内存碎片,防止大对象无法分配内存,因此采用的是标记整理算法,始终是连续的内存区域。


2 垃圾回收器


2.1 垃圾回收的并行与串行


image.png
从上图中,我们可以看出,只有一个GC线程在执行垃圾回收操作,这个时候垃圾回收就是串行执行的


image.png


在上图中,我们可以看到有多个GC线程在同时工作,这个时候垃圾回收就是并行的


其实在多线程中有两个概念:并行和并发。


其中,并行就是上述GC线程,在同一时间段执行,但是线程之间并无竞争关系而是独立运行的,这就是并行执行;而并发同样也是多个线程在同一时间点执行,只不过他们之间存在竞争关系,例如抢占锁,就涉及到了并发安全的问题。


2.2 垃圾回收器分类


关于垃圾回收器的分类,我们从新生代和老年代两个大方向来看:


image.png


我们可以看到,在新生代的垃圾回收器,都是采用的复制算法,目的就是为了提效;而在老年代而是采用标记整理算法居多,前面的像Serial、ParNew这些垃圾回收器采用的复制算法我们都明白是什么流程,接下来介绍下CMS垃圾回收器的并发标记清除算法思想。


2.2.1 CMS垃圾回收器


CMS垃圾回收器,是JDK1.5之后发布的第一款真正意义上的并发垃圾回收器。它采用的思想是并发标记 - 清除 - 整理,真正去优化因为STW带来的性能问题


这里先看下CMS的具体工作原理

(1)标记GCROOT对象;这个过程时间短,会STW;

(2)标记整个GCROOT引用链;这个过程耗时久,采用并发标记的方式,与用户线程混用,不会STW,因为耗时比较久,在此期间可能会产生新的对象;

(3)重新标记;因为第二步可能产生新的对象,因此需要重新标记数据变动的地方,这个过程时间短,会STW;

(4)并发清理;将标记死亡的对象全部清除,这个过程不会STW;


看到上面的主要过程后,可能会问,整理内存并没有做,那么是什么时候完成的内存整理呢?其实CMS内存整理并不是伴随着每次GC完成的,而是开启定时,在空闲的时间完成内存整理,因为内存整理会导致STW,这样就不会影响到用户体验。


3 ART虚拟机调优


前面我们介绍的都是JVM,而Android开发使用的又不是JVM,那么为什么要学习JVM呢,其实不然,因为不管是ART还是Dalvik,都是依赖JVM的规范做的衍生产物,所以两者是相通的。


3.1 Dalvik和ART与Hotspot的区别


首先Android中使用的ART虚拟机,在Android 5.0以前是Dalvik虚拟机,这两种虚拟机与Hotspot基本是一样的,差别在于两者执行的指令集是不一样的,Android中指令集是基于寄存器的,而Hotspot是基于堆栈的;还有就是Android虚拟机不能执行class文件,而是执行dex文件。


接下来我们通过对比DVM和JVM运行时数据区的差异


3.1.1 栈区别


我们知道,在JVM中执行方法时,每个方法对应一个栈帧,每个栈帧中的数据结构如下:


image.png


而ART/Dalvik中同样存在栈帧,但是跟Hotspot的差别比较大,因为Android中指令集是基于寄存器的,所以将局部变量表和操作数栈移除了,取而代之的是寄存器的形式。


image.png


因为在字节码指令中指明了操作数的地址,因此CPU可以直接获取到操作数,例如累加操作,通过CPU的ALU计算单元直接计算,然后赋值给另一块内存地址,相较于JVM不断入栈出栈,这种响应速度更快,尤其对于Android来说,速度大于一切。


所以DVM的栈内存相较于JVM,少了操作数栈的概念,而是采用了寄存器的多地址模式,速度更快。


3.1.2 堆内存


image.png


ART的堆内存跟JVM的堆内存几乎是完全不一样的,主要是分为4块:


(1)Image Space:这块区域用于存储预加载的类,在类加载之前自动加载


这部分首先要从Dalvik虚拟机开始说起,在Android 2.2之后,Dalvik引入了JIT(即时编译技术),它会对于执行过的代码做dex优化,不需要每次都编译dex文件,提高了执行的速度,但是这个是在运行时做的处理,dex转为机器码需要时间。


因此在Android 5.0之后,Dalvik被废弃,取而代之的是ART虚拟机,从而引进了全新的编译方式AOT,就是在安装app的过程中,将dex文件全部编译为本地机器码,运行时就直接拿机器码执行,提高了执行速度,但是也存在很多问题,安装app的时候特别慢,造成资源浪费。


因此在Android N(Android 7.0)之后,引入了混编技术(JIT + 解释 + AOT)。在安装应用的时候不再全量转换,那么安装速度变快了;而是在运行时将经常执行的方法进行JIT,并将这些信息保存在Profile文件中,那么在手机空闲或者充电的时候,后台有一个BackgroundDexOptService会从Profile文件中拿到这些方法,看哪些没有编译成机器码进行AOT,然后存储在base.art文件中


那么base.art文件就是存储在Image Space中的,这个区域不会发生GC。


(2)Zygote Space:用于存储Zygote进程启动之后,预加载的类和创建的对象;\
(3)Allocation Space:用于存储用户数据,我们自己写的代码创建的对象,类似于JVM中堆的新生代

(4)LargeObject Space:用于存储超过12K(3页)的大对象,类似于JVM堆中的老年代


3.1.3 对象分配


在ART中存在3种GC策略,内部采用的垃圾回收器是CMS


(1)浮游GC:这次GC只会回收上次GC到本次GC中间申请的内存空间;

(2)局部GC:除了Image Space和Zygote Space之外的内存区域做一次内存回收;

(3)全量GC:除了Image Space之外,全部的内存做一次内存回收。


所以在ART分配对象的时候,会从第一个策略开始依次判断是否有足够空间分配内存,如果不够就继续往下走;如果全量GC都无法分配内存,那么就判断是否能够扩容堆内存。


3.2 线上内存问题定位


回到
# Android进阶宝典 -- JVM运行时数据区开头说的场景



(1)App莫名其妙地产生卡顿;

(2)线下测试好好的,到了线上就出现OOM;

(3)自己写的代码质量不高;



其实我们在线下开发的过程中,如果不注意内存问题其实很难会发现,因为我们每次修改都会run一次应用,相当于应用做了一次重置,类似于OOM或者内存溢出很难察觉,但是一到线上,用户使用时间久了就会出问题,下面就用一个线上案例配合JVM内存分配查找问题原因。


当时的场景,我们需要自定义一个View,这个View在旋转的时候需要做颜色的渐变,我们先看下出问题的代码。


class MyFadeView : View {

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

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

private fun initView() {
initTimer()
}

private val colors = mutableListOf("#CF1B1B", "#009988", "#000000")
private var currentColor = colors[0]

@SuppressLint("DrawAllocation")
override fun onDraw(canvas: Canvas?) {
super.onDraw(canvas)
Log.e("TAG", "onDraw")
val borderPaint = Paint()
borderPaint.color = Color.parseColor(currentColor)
borderPaint.isAntiAlias = true
borderPaint.strokeWidth =
context.resources.getDimension(androidx.constraintlayout.widget.R.dimen.abc_action_bar_content_inset_material)

val path = Path()
path.moveTo(0f, 0f)
path.lineTo(0f, 100f)
path.lineTo(100f, 100f)
path.lineTo(100f, 0f)
path.lineTo(0f, 0f)


canvas?.let {
it.drawPath(path, borderPaint)
}
}

private var FadeRunnable: Runnable = Runnable {
currentColor = colors[(0..2).random()]
postInvalidate()
}

private fun initTimer() {

val timer = object : CountDownTimer(1000, 2000) {
override fun onTick(millisUntilFinished: Long) {
Handler().post(FadeRunnable)
initTimer()
}

override fun onFinish() {
}

}
timer.start()
}
}

这里我们先做一个简单的自定义View,然后我们可以看下内存Profiler


image.png


内存曲线还是比较平滑的,看下对象分配


image.png


其中Paint还有Path创建的对象比较多,为什么呢?伙伴们应该都知道,每次调用postInvalidate方法,都会走onDraw方法,频繁地调用onDraw方法,导致Paint和Path被创建了多次。


在之前JVM的学习中,我们知道当一个方法结束之后,栈内的对象也会被回收,因此这样就会造成频繁地创建和销毁对象,如果当前内存紧张便会频繁地GC,导致内存抖动,因此创建对象不能在频繁调用的方法中执行,需要在initView中做初始化。


image.png
还有就是,伙伴们有用过直接使用Color.parseColor去加载一种颜色,这种方法也不能在频繁调用的方法中执行,看下源码,在这个方法中调用了substring方法,每次都会创建一个String对象。


那么有个问题,内存抖动是造成App卡顿的真凶吗?其实不然,即便是产生了内存抖动,在方法执行结束之后,对象也都被回收掉了不会存在于内存中,JVM还是很强大的,在内存充足的时候还是没有太大的影响的。


如果是产生了卡顿,那么一定伴随着内存泄漏,因为内存泄漏导致内存不断减少,从而导致了GC的提前到来,又加上频繁地创建和销毁对象,导致频繁地GC,从而产生了卡顿。


# Android性能优化 -- 内存优化这篇文章中,有关于内存优化工具的具体使用,有兴趣的伙伴可以看一下。


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

Flutter paint shader渐变使用的问题

背景 flutter版本要实现一个渐变的圆弧指示器,如图 颜色需要有个渐变,而且根据百分比的不同,中间的菱形指向还不一样 1.自定义CustomPainter class PlatePainter extends CustomPainter { @ove...
继续阅读 »

背景


flutter版本要实现一个渐变的圆弧指示器,如图

GIF 2022-10-18 14-33-55.gif

颜色需要有个渐变,而且根据百分比的不同,中间的菱形指向还不一样


1.自定义CustomPainter


class PlatePainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
// 画图逻辑
}

@override
bool shouldRepaint(covariant CustomPainter oldDelegate) {
// 是否需要重绘的判断 ,可以先返回false
return false;
}
}

然后加入一点点画图的细节:


import 'dart:math';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';

class PlatePainter3 extends CustomPainter {
final Paint _paintProgress = Paint()
..strokeWidth = 15
..style = PaintingStyle.stroke;
final Paint _paintBg = Paint()
..strokeWidth = 15
..color = const Color(0xFFC8CAFF).withAlpha(22)
..style = PaintingStyle.stroke;
final Paint _paintLine = Paint()
..strokeWidth = 2
..color = const Color(0Xff7A80FF)
..style = PaintingStyle.fill;
final Path _path = Path();
final Paint _paintCenter = Paint()
..strokeWidth = 2
..color = const Color(0xFF767DFF).withAlpha(14)
..style = PaintingStyle.fill;

@override
void paint(Canvas canvas, Size size) {
final width = size.width;
final height = size.height;
final center = Offset(width / 2, height * 3 / 4);
final rect = Rect.fromCircle(
center: center,
radius: 60,
);
canvas.drawArc(rect, pi * 0.8, pi * 2 * (0.1 + 0.1 + 0.5), false, _paintBg);
_paintProgress.shader = ui.Gradient.sweep(
center,
[
const Color(0XffCACCFF),
const Color(0Xff7A80FF),
],
);
canvas.drawArc(rect, pi * 0.8, (pi * 2 * 0.7) , false, _paintProgress);

TextPainter textPainter = TextPainter(
text: const TextSpan(text: '0', style: TextStyle(color: Colors.black, fontSize: 10)),
textDirection: TextDirection.ltr,
);
textPainter.layout(maxWidth: width);
textPainter.paint(canvas, Offset(width / 2 - 60 + 15, height - 5));
textPainter.text = const TextSpan(text: '100', style: TextStyle(color: Colors.black, fontSize: 10));
textPainter.layout(maxWidth: width);
textPainter.paint(canvas, Offset(width / 2 + 60 - 15 - 20, height - 5));
Offset c = Offset(width / 2, height * 3 / 4);
var angle = pi * 0.8 + pi * 2 * (0.1 + 0.1 + 0.5) ;
canvas.drawLine(c + _calXYByRadius(angle, 50), c + _calXYByRadius(angle, 70), _paintLine);

final o1 = c+_calXYByRadius(angle, 15);
final o2 = c+_calXYByRadius(angle + pi, 15);
final o3 = c+_calXYByRadius(angle + 0.5 * pi, 5);
final o4 = c+_calXYByRadius(angle + pi + 0.5 * pi, 5);
_path.reset();
_path.moveTo(o1.dx, o1.dy);
_path.lineTo(o3.dx, o3.dy);
_path.lineTo(o2.dx, o2.dy);
_path.lineTo(o4.dx, o4.dy);
_path.close();
_paintCenter.color = const Color(0xFF767DFF);
canvas.drawPath(_path, _paintCenter);

_paintCenter.color = const Color(0xFF767DFF).withAlpha(14);
canvas.drawCircle(c, 20, _paintCenter);
_paintCenter.color = Colors.white;
canvas.drawCircle(c, 2, _paintCenter);
}

@override
bool shouldRepaint(covariant CustomPainter oldDelegate) {
return false;
}

Offset _calXYByRadius(double angle, double radius) {
final y = sin(angle) * radius;
final x = cos(angle) * radius;
return Offset(x, y);
}
}

中间颜色的渐变用到了Paint的方法shader,设置的属性为 dart:ui包下的Gradient,不要导错包了,应该import的时候加入 as ui,才可以如代码中设置的样式.


import 'dart:ui' as ui;

满心欢喜的运行一下,Duang


image.png

渐变颜色没有按照想象中的开始和结束.


2.关于 paint的shader属性


/// The shader to use when stroking or filling a shape.
///
/// When this is null, the [color] is used instead.
///
/// See also:
///
/// * [Gradient], a shader that paints a color gradient.
/// * [ImageShader], a shader that tiles an [Image].
/// * [colorFilter], which overrides [shader].
/// * [color], which is used if [shader] and [colorFilter] are null.
Shader? get shader {
return _objects?[_kShaderIndex] as Shader?;
}
set shader(Shader? value) {
_ensureObjectsInitialized()[_kShaderIndex] = value;
}

直接查看Gradient类的sweep方法,参数如下


Gradient.sweep(
Offset center,
List<Color> colors, [
List<double>? colorStops,
TileMode tileMode = TileMode.clamp,
double startAngle = 0.0,
double endAngle = math.pi * 2,
Float64List? matrix4,
])

翻译如下



创建一个以 center 为中心、从 startAngle 开始到 endAngle 结束的扫描渐变。 startAngleendAngle 应该以弧度提供,零弧度是 center 右侧的水平线,正角度围绕 center 顺时针方向。如果提供了 colorStopscolorStops[i] 是一个从 0.0 到 1.0 的数字,它指定了 color[i] 在渐变中的开始位置。如果 colorStops 没有提供,那么只有两个停止点,在 0.0 和 1.0,是隐含的(因此 color 必须只有两个条目)。 startAngle 之前和 endAngle 之后的行为由 tileMode 参数描述。有关详细信息,请参阅 [TileMode] 枚举。



哦哦,应该修改startAngle和endAngle方法,然后按照开始和结束的颜色结束.修改


_paintProgress.shader = ui.Gradient.sweep(
center,
[
const Color(0XffCACCFF),
const Color(0Xff7A80FF),
],
[0, 1],
TileMode.clamp,
0.8 * pi,
2.2 * pi,
);

然后运行


image.png

好像开始的颜色正常了,但是结束颜色还是一样的问题.


3.两种解决方法


3.1 设置shader属性(推荐)


_paintProgress.shader = ui.Gradient.sweep(
center,
[
const Color(0Xff7A80FF),
const Color(0XffCACCFF),
const Color(0Xff7A80FF),
],
[0.0, 0.5, 0.9],
TileMode.clamp,
);

运行如图:


image.png


3.2 旋转控件,开始绘制从0开始


painter修改代码


import 'dart:math';
import 'dart:ui' as ui;

import 'package:flutter/material.dart';

class PlatePainter4 extends CustomPainter {

final Paint _paintProgress = Paint()
..strokeWidth = 15
..style = PaintingStyle.stroke;
final Paint _paintBg = Paint()
..strokeWidth = 15
..color = const Color(0xFFC8CAFF).withAlpha(22)
..style = PaintingStyle.stroke;
final Paint _paintLine = Paint()
..strokeWidth = 2
..color = const Color(0Xff7A80FF)
..style = PaintingStyle.fill;
final Path _path = Path();
final Paint _paintCenter = Paint()
..strokeWidth = 2
..color = const Color(0xFF767DFF).withAlpha(14)
..style = PaintingStyle.fill;

@override
void paint(Canvas canvas, Size size) {
final width = size.width;
final height = size.height;
final center = Offset(width / 2, height /2);
final rect = Rect.fromCircle(
center: center,
radius: 60,
);
canvas.drawArc(rect, 0, (pi * 1.4), false, _paintBg);
_paintProgress.shader = ui.Gradient.sweep(
center,
[
const Color(0XffCACCFF),
const Color(0Xff7A80FF),
// const Color(0Xff7A80FF),
// Colors.white,
// Colors.black,
],
);
canvas.drawArc(rect, 0, (pi * 1.4) , false, _paintProgress);

// TextPainter textPainter = TextPainter(
// text: const TextSpan(text: '0', style: TextStyle(color: Colors.black, fontSize: 10)),
// textDirection: TextDirection.ltr,
// );
// textPainter.layout(maxWidth: width);
// textPainter.paint(canvas, Offset(width / 2 - 60 + 15, height - 5));
// textPainter.text = const TextSpan(text: '100', style: TextStyle(color: Colors.black, fontSize: 10));
// textPainter.layout(maxWidth: width);
// textPainter.paint(canvas, Offset(width / 2 + 60 - 15 - 20, height - 5));
Offset c = Offset(width / 2, height / 2);
var angle = pi * 1.4 ;
canvas.drawLine(c + _calXYByRadius(angle, 50), c + _calXYByRadius(angle, 70), _paintLine);

final o1 = c+_calXYByRadius(angle, 15);
final o2 = c+_calXYByRadius(angle + pi, 15);
final o3 = c+_calXYByRadius(angle + 0.5 * pi, 5);
final o4 = c+_calXYByRadius(angle + pi + 0.5 * pi, 5);
_path.reset();
_path.moveTo(o1.dx, o1.dy);
_path.lineTo(o3.dx, o3.dy);
_path.lineTo(o2.dx, o2.dy);
_path.lineTo(o4.dx, o4.dy);
_path.close();
_paintCenter.color = const Color(0xFF767DFF);
canvas.drawPath(_path, _paintCenter);

_paintCenter.color = const Color(0xFF767DFF).withAlpha(14);
canvas.drawCircle(c, 20, _paintCenter);
_paintCenter.color = Colors.white;
canvas.drawCircle(c, 2, _paintCenter);
}

@override
bool shouldRepaint(covariant CustomPainter oldDelegate) {
return false;
}

Offset _calXYByRadius(double angle, double radius) {
final y = sin(angle) * radius;
final x = cos(angle) * radius;
return Offset(x, y);
}
}

页面代码加入旋转代码:


Transform.rotate(
angle: 0.8 * pi,
child: CustomPaint(
painter: PlatePainter4(),
size: const Size(180, 180),
),
),

运行如下图第二个:

image.png

缺点:画文字的坐标还需要重新计算和旋转


4.加上动画,动起来


效果图:


GIF 2022-10-18 15-30-24.gif

最终代码:
Page:


import 'dart:math';

import 'package:demo4/widgets/plate_painter.dart';
import 'package:demo4/widgets/plate_painter3.dart';
import 'package:flutter/material.dart';

import '../widgets/plate_painter2.dart';
import '../widgets/plate_painter4.dart';

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

@override
State<Page6> createState() => _Page6State();
}

class _Page6State extends State<Page6> with TickerProviderStateMixin{
late AnimationController _animationController;
static final Animatable<double> _iconTurnTween =
Tween<double>(begin: 0.0, end: 1.0).chain(CurveTween(curve: Curves.fastOutSlowIn));

@override
void initState() {
_animationController = AnimationController(vsync: this, duration: const Duration(seconds: 6));
_animationController.drive(_iconTurnTween);
_animationController.forward();
super.initState();
}

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

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('自定义圆盘'),
),
body: Column(
children: [
AnimatedBuilder(
animation: _animationController.view,
builder: (_, __) {
final progress = _animationController.value;
return CustomPaint(
painter: PlatePainter(progress),
size: const Size(180, 180),
);
},
),
AnimatedBuilder(
animation: _animationController.view,
builder: (_, __) {
final progress = _animationController.value;
return Transform.rotate(
angle: 0.8 * pi,
child: CustomPaint(
painter: PlatePainter2(progress),
size: const Size(180, 180),
),
);
},
),

],
),
);
}
}

方法一:


import 'dart:math';
import 'dart:ui' as ui;

import 'package:flutter/material.dart';

class PlatePainter extends CustomPainter {
PlatePainter(
this.progress,
);

final num progress;
final Paint _paintProgress = Paint()
..strokeWidth = 15
..style = PaintingStyle.stroke;
final Paint _paintBg = Paint()
..strokeWidth = 15
..color = const Color(0xFFC8CAFF).withAlpha(22)
..style = PaintingStyle.stroke;
final Paint _paintLine = Paint()
..strokeWidth = 2
..color = const Color(0Xff7A80FF)
..style = PaintingStyle.fill;
final Path _path = Path();
final Paint _paintCenter = Paint()
..strokeWidth = 2
..color = const Color(0xFF767DFF).withAlpha(14)
..style = PaintingStyle.fill;

@override
void paint(Canvas canvas, Size size) {
final width = size.width;
final height = size.height;
final center = Offset(width / 2, height * 3 / 4);
final rect = Rect.fromCircle(
center: center,
radius: 60,
);
canvas.drawArc(rect, pi * 0.8, pi * 2 * (0.1 + 0.1 + 0.5), false, _paintBg);
_paintProgress.shader = ui.Gradient.sweep(
center,
[
const Color(0Xff7A80FF),
const Color(0XffCACCFF),
const Color(0Xff7A80FF),
],
[0.0, 0.5, 0.9],
TileMode.clamp,
);
canvas.drawArc(rect, pi * 0.8, (pi * 2 * 0.7) * progress, false, _paintProgress);

TextPainter textPainter = TextPainter(
text: const TextSpan(text: '0', style: TextStyle(color: Colors.black, fontSize: 10)),
textDirection: TextDirection.ltr,
);
textPainter.layout(maxWidth: width);
textPainter.paint(canvas, Offset(width / 2 - 60 + 15, height - 5));
textPainter.text = const TextSpan(text: '100', style: TextStyle(color: Colors.black, fontSize: 10));
textPainter.layout(maxWidth: width);
textPainter.paint(canvas, Offset(width / 2 + 60 - 15 - 20, height - 5));
Offset c = Offset(width / 2, height * 3 / 4);
var angle = pi * 0.8 + pi * 2 * (0.1 + 0.1 + 0.5) * progress;
canvas.drawLine(c + _calXYByRadius(angle, 50), c + _calXYByRadius(angle, 70), _paintLine);

final o1 = c+_calXYByRadius(angle, 15);
final o2 = c+_calXYByRadius(angle + pi, 15);
final o3 = c+_calXYByRadius(angle + 0.5 * pi, 5);
final o4 = c+_calXYByRadius(angle + pi + 0.5 * pi, 5);
_path.reset();
_path.moveTo(o1.dx, o1.dy);
_path.lineTo(o3.dx, o3.dy);
_path.lineTo(o2.dx, o2.dy);
_path.lineTo(o4.dx, o4.dy);
_path.close();
_paintCenter.color = const Color(0xFF767DFF);
canvas.drawPath(_path, _paintCenter);

_paintCenter.color = const Color(0xFF767DFF).withAlpha(14);
canvas.drawCircle(c, 20, _paintCenter);
_paintCenter.color = Colors.white;
canvas.drawCircle(c, 2, _paintCenter);
}

@override
bool shouldRepaint(covariant CustomPainter oldDelegate) {
return (oldDelegate as PlatePainter).progress != progress;
}

Offset _calXYByRadius(double angle, double radius) {
final y = sin(angle) * radius;
final x = cos(angle) * radius;
return Offset(x, y);
}
}

方法二:


import 'dart:math';
import 'dart:ui' as ui;

import 'package:flutter/material.dart';

class PlatePainter2 extends CustomPainter {
PlatePainter2(
this.progress,
);

final num progress;
final Paint _paintProgress = Paint()
..strokeWidth = 15
..style = PaintingStyle.stroke;
final Paint _paintBg = Paint()
..strokeWidth = 15
..color = const Color(0xFFC8CAFF).withAlpha(22)
..style = PaintingStyle.stroke;
final Paint _paintLine = Paint()
..strokeWidth = 2
..color = const Color(0Xff7A80FF)
..style = PaintingStyle.fill;
final Path _path = Path();
final Paint _paintCenter = Paint()
..strokeWidth = 2
..color = const Color(0xFF767DFF).withAlpha(14)
..style = PaintingStyle.fill;

@override
void paint(Canvas canvas, Size size) {
final width = size.width;
final height = size.height;
final center = Offset(width / 2, height /2);
final rect = Rect.fromCircle(
center: center,
radius: 60,
);
canvas.drawArc(rect, 0, (pi * 1.4), false, _paintBg);
_paintProgress.shader = ui.Gradient.sweep(
center,
[
const Color(0XffCACCFF),
const Color(0Xff7A80FF),
// const Color(0Xff7A80FF),
// Colors.white,
// Colors.black,
],
);
canvas.drawArc(rect, 0, (pi * 1.4) * progress, false, _paintProgress);

// TextPainter textPainter = TextPainter(
// text: const TextSpan(text: '0', style: TextStyle(color: Colors.black, fontSize: 10)),
// textDirection: TextDirection.ltr,
// );
// textPainter.layout(maxWidth: width);
// textPainter.paint(canvas, Offset(width / 2 - 60 + 15, height - 5));
// textPainter.text = const TextSpan(text: '100', style: TextStyle(color: Colors.black, fontSize: 10));
// textPainter.layout(maxWidth: width);
// textPainter.paint(canvas, Offset(width / 2 + 60 - 15 - 20, height - 5));
Offset c = Offset(width / 2, height /2);
var angle = pi * 1.4 * progress;
canvas.drawLine(c + _calXYByRadius(angle, 50), c + _calXYByRadius(angle, 70), _paintLine);

final o1 = c+_calXYByRadius(angle, 15);
final o2 = c+_calXYByRadius(angle + pi, 15);
final o3 = c+_calXYByRadius(angle + 0.5 * pi, 5);
final o4 = c+_calXYByRadius(angle + pi + 0.5 * pi, 5);
_path.reset();
_path.moveTo(o1.dx, o1.dy);
_path.lineTo(o3.dx, o3.dy);
_path.lineTo(o2.dx, o2.dy);
_path.lineTo(o4.dx, o4.dy);
_path.close();
_paintCenter.color = const Color(0xFF767DFF);
canvas.drawPath(_path, _paintCenter);

_paintCenter.color = const Color(0xFF767DFF).withAlpha(14);
canvas.drawCircle(c, 20, _paintCenter);
_paintCenter.color = Colors.white;
canvas.drawCircle(c, 2, _paintCenter);
}

@override
bool shouldRepaint(covariant CustomPainter oldDelegate) {
return (oldDelegate as PlatePainter2).progress != progress;
}

Offset _calXYByRadius(double angle, double radius) {
final y = sin(angle) * radius;
final x = cos(angle) * radius;
return Offset(x, y);
}
}

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

viewpager2中viewModelScope 取消的问题

场景 有这么一个场景,一个菜谱订制的app里,用户是根据每周作为一个周期制定自己的菜谱计划,每天从已知菜谱库存中选一两道菜,规划自己下周做什么吃,下下周做什么吃。 viewpager(或viewpager2)中加载若干个fragment,fragment里被传...
继续阅读 »

场景


有这么一个场景,一个菜谱订制的app里,用户是根据每周作为一个周期制定自己的菜谱计划,每天从已知菜谱库存中选一两道菜,规划自己下周做什么吃,下下周做什么吃。


viewpager(或viewpager2)中加载若干个fragment,fragment里被传入一个时间戳的参数,用这个时间戳当做初始时间,计算出本周的起止时间(周日~周六),然后获取这一周的菜谱计划。类似于这样(demo式UI)


image.png


image.png


上图中标注a点击后切到上一周(viewpager2中前一个fragment),b点击切到下一周,下方列表为viewpager2(若干fragments)当然列表显示什么不重要


viewpagerAdapter 类似这样,用list维护一个Fragment


class MealPagerAdapter(fm: FragmentManager?, life : Lifecycle) : FragmentStateAdapter(fm!! , life) {

val fragmentList = mutableListOf<MealFragment>()

val startDateTimeList = mutableListOf<Long>()

fun addItem(startDate : Long) {
startDateTimeList.add(startDate)
fragmentList.add(MealFragment.newInstance(startDate))
notifyItemInserted(fragmentList.size - 1)
}

override fun getItemCount(): Int = fragmentList.size

override fun createFragment(position: Int): MealFragment = fragmentList[position]

}

默认初始调用一次addItem方法,参数传入当前周(周日开始)的周日0点的时间戳,就可以获得这一周的起止日期了,这是一个可以无限添加fragment的viewpager2, 所以


offscreenPageLimit = 5

设置多少不那么重要了,暂定5吧


MealFragment里就是 viewmodel中定义一个initData()方法,伪代码示意:


class MealViewModel : BaseViewModel() {

fun init() {
viewModelScope.launch {
// 请求数据
...
}
}
}

class MealFragment : Fragment() {

val viewModel : MealViewModel by viewModels()

onverried fun onViewCreated() {
viewmodel.init()
}
}

现在是不是已经看出问题来了。


现象是多次点击【下一周】按钮 添加几个fragment,只要超出了设置的离屏缓存数量,往回滑,之前显示过的fragment会重新加载,因为viewpager移除了fragment,不过重新经过onViewcreated 周期的时候,viewmodel里的 viewmodelScope 不再执行,导致页面空白


viewModelScope.launch {
// 不再执行了
...
}

显然,这个协程scope被cancel(close)了,不过没有被从ViewModel的map中移除,
所以也就谈不上重建。


扫一眼ViewModel 源码,内部有个


Map<String, Object> mBagOfTags = new HashMap<>();

用来存储scope


出现这种问题大概权当使用不当吧,虽然我这么用viewpager已经很久了


一些方案


方案1


viewmodel里的viewModelScope 被取消但不被移除,那就暂且不用这个了,替换为GlobalScope 总能用吧


stackoverflow上有一个同样和我一知半解的老外提了同样的问题


未测试


不过这种方案应该没人会采用,globalScope 估计只用于demo测试中


方案2


不自己缓存fragmentlist了,只缓存数据,每次去从viewpager缓存中获取


class MealViewPagerAdapter(fm: FragmentManager?, life : Lifecycle) : FragmentStateAdapter(fm!! , life) {

val startDateTimeList = mutableListOf<Long>()

fun addItem(startDate : Long) {
startDateTimeList.add(startDate)
notifyItemInserted(itemCount - 1)
}

override fun getItemCount(): Int = startDateTimeList.size

override fun createFragment(position: Int): MealItemFragment = MealItemFragment.newInstance(startDateTimeList[position])
}

测试ok


方案3


不使用viewmodelScope, 用fragment的Scope代替


fun launchLifecycle(lifecycleOwner : LifecycleOwner , block: suspend () -> Unit) {
lifecycleOwner.lifecycleScope.launch {
block()
}
}

测试ok


方案4


谷歌官方的示例项目iosched中是这样写的


viewpager2中同样使用list 缓存fragment,但不是缓存实例,只缓存生成方法,createFragment(position: Int) 方法调用的时候调用闭包来获取fragment(invoke)。


/**
* Adapter that builds a page for each info screen.
*/
inner class InfoAdapter(fragment: Fragment) : FragmentStateAdapter(fragment) {
override fun createFragment(position: Int) = INFO_PAGES[position].invoke()

override fun getItemCount() = INFO_PAGES.size
}

companion object {

private val INFO_TITLES = arrayOf(
R.string.event_title,
R.string.travel_title,
R.string.faq_title
)
private val INFO_PAGES = arrayOf(
{ EventFragment() },
{ TravelFragment() },
{ FaqFragment() }
// TODO: Track the InfoPage performance b/130335745
)
}

尽管他只有三个fragment,不过测了下自己的场景,同样能解决问题


测试ok


代码位于项目中的InfoFragment类里


方案5


上边的stackOverflow方法中还提到用共享viewmodel的方式,大致应该是这样


private val viewModel: SearchViewModel by viewModels(
ownerProducer = { requireParentFragment() }
)

让viewmodel去伴随父fragment的周期,不过感觉设计上不太合适,没有去测


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

Jetpack架构演变(一):初步使用flow,附加经典案例

对于初学者来说使用lieveData的好处是足够简单和相对安全 引入flow主要因为以下几点: 具有更友好的API,学习成本较低 跟Kotlin协程、LiveData结合更紧密,Flow能够转换成LiveData,在ViewModel中直接使用 结合协程的作...
继续阅读 »

对于初学者来说使用lieveData的好处是足够简单和相对安全


引入flow主要因为以下几点:



  • 具有更友好的API,学习成本较低

  • 跟Kotlin协程、LiveData结合更紧密,Flow能够转换成LiveData,在ViewModel中直接使用

  • 结合协程的作用域,当协程被取消时,Flow也会被取消,避免内存泄漏

  • flow库隶属于kotlin, livedata属于Android, 拜托Android平台的限制对于未来跨平台发展有利


【flow是个冷数据流】


所谓冷流,即下游无消费行为时,上游不会产生数据,只有下游开始消费,上游才开始产生数据。


而所谓热流,即无论下游是否有消费行为,上游都会自己产生数据。


下边通过一个经典场景详细描述下flow(单纯的flow,而stateFlow会在后续章节中讲解)的使用


案例:一个菜谱应用app中,我想在一个页面展示一个列表(recyclerview) ,此列表的每个item是个子列表,子列表依次为


计划菜谱列表;


收藏菜谱列表;


根据食材筛选的菜谱列表;


根据食材获取用户偏好的菜谱列表;


如图



四个子列表需要四个接口来获取,组装好后来刷新最后的列表


其中每个列表都有可能是空,是emptylist的话这行就不显示了,因为四个接口数据量大小不同,所以不会同一时间返回,同时又要保障这四个子列表按要求的顺序来展示。


思路:


设计数据结构,最外层的data:


data class ContainerData(val title : String , val list: List<Recipe>)

其中Recipe实体是每个菜谱


data class Recipe(val id: String,
val name: String,
val cover: String,
val type: Int,
val ingredients: List<String>? = mutableListOf(),
val minutes: Int,
val pantryItemCount : Int )

模拟四个请求为:


val plannlist = Request.getPlannlist()


val favouritelist= Request.getFavouritelist()


... 以此类推


如果按照要求四个请求返回次序不同,同时要求在列表中按顺序显示,如果实现?


方案一:可以等待四个请求都返回后然后组装数据,刷新列表


可以利用协程的await方法:


val dataList = MutableLiveData<List<Constainer>>()

viewModelScope.launch {
// planner
val plannerDefer = async { Request.getPlannlist() }

// favourite
val favouriteDefer = async { Request.getFavouritelist() }

val plannerData = plannerDefer.await()
val favouriteData = favouriteDefer.await()


....省略后两个接口


val list = listof(
Container("planner" , plannerData),
Container("favourite" , favouriteData),
...
)

dataList.postValue(list)
}

await() 方法是挂起协程,四个接口异步请求(非顺序),等最后一个数据请求返回后才会执行下边的步骤


然后组装数据利用liveData发送,在view中渲染


viewModel.dataList.observe(viewLifecycleOwner) {
mAdapter.submitList(it)
}

此种方式简单,并且有效解决了按顺序排列四个列表的需求,缺点是体验差,假如有一个接口极慢,其他几个就会等待它,用户看着loading一直发呆么。


方案二:接口间不再互相等待,哪个接口先回来就渲染哪个,问题就是如何保障顺序?


有的同学会有方案:先定制一个空数据list


val list = listOf(
Container("planner", emptylist()),
Container("favourite", emptylist()),
...
)

然后先用adapter去渲染list,哪个接口回来就去之前的列表查找替换,然后adapter刷新对应的数据,当然可以,不过会产生一部分逻辑胶水代码,查找遍历的操作。


此时我们可以借助flow来实现了


1 构造一个planner数据流


val plannerFlow = flow { 
val plannList = Request.getPlanlist()
emit(ContainerData("Planner", plannList))
}.onStart {
emit(ContainerData("", emptylist()))
}

注意是个val 变量, 不要写成 fun plannerFlow() 方法,不然每次调用开辟新栈的时候新建个flow,并且会一直保存在内存中,直到协程取消


其中onStart 会在发送正式数据之前发送,作为预加载。


然后我们就可以构造正式请求了


viewModelScope.launch {

combine(plannerFlow , favouriteFlow , xxxFlow ,xxxFlow) { planner , favourites , xxx , xxx ->
mutableListOf(planner , favourites , xxx,xxx)
}.collect {
datalist.postValue(it)
}
}

combine 的官方注释为


Returns a Flow whose values are generated with transform function by combining the most recently emitted values by each flow.

combine操作符可以连接两个不同的Flow , 一旦产生数据就会触发组合后的flow的流动,同时它是有序的。


后续章节继续讲述flow其他特性,并彻底弃用liveData。


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

面试突击90:过滤器和拦截器有什么区别?

实现过滤器和拦截器 首先,我们先来看一下二者在 Spring Boot 项目中的具体实现,这对后续理解二者的区别有很大的帮助。 a) 实现过滤器 过滤器可以使用 Servlet 3.0 提供的 @WebFilter 注解,配置过滤的 URL 规则,然后再实现 ...
继续阅读 »

实现过滤器和拦截器


首先,我们先来看一下二者在 Spring Boot 项目中的具体实现,这对后续理解二者的区别有很大的帮助。


a) 实现过滤器


过滤器可以使用 Servlet 3.0 提供的 @WebFilter 注解,配置过滤的 URL 规则,然后再实现 Filter 接口,重写接口中的 doFilter 方法,具体实现代码如下:


import org.springframework.stereotype.Component;
import javax.servlet.*;
import javax.servlet.annotation.WebFilter;
import java.io.IOException;
@Component
@WebFilter(urlPatterns = "/*")
public class TestFilter implements Filter {
@Override
public void init(FilterConfig filterConfig) throws ServletException {
System.out.println("过滤器:执行 init 方法。");
}
@Override
public void doFilter(ServletRequest servletRequest,
ServletResponse servletResponse,
FilterChain filterChain) throws IOException, ServletException {
System.out.println("过滤器:开始执行 doFilter 方法。");
// 请求放行
filterChain.doFilter(servletRequest, servletResponse);
System.out.println("过滤器:结束执行 doFilter 方法。");
}
@Override
public void destroy() {
System.out.println("过滤器:执行 destroy 方法。");
}
}

其中:




  • void init(FilterConfig filterConfig):容器启动(初始化 Filter)时会被调用,整个程序运行期只会被调用一次。用于实现 Filter 对象的初始化。




  • void doFilter(ServletRequest request, ServletResponse response,FilterChain chain):具体的过滤功能实现代码,通过此方法对请求进行过滤处理,其中 FilterChain 参数是用来调用下一个过滤器或执行下一个流程




  • void destroy():用于 Filter 销毁前完成相关资源的回收工作。


    b) 实现拦截器


    拦截器的实现分为两步,第一步,创建一个普通的拦截器,实现 HandlerInterceptor 接口,并重写接口中的相关方法;第二步,将上一步创建的拦截器加入到 Spring Boot 的配置文件中。
    接下来,先创建一个普通拦截器,实现 HandlerInterceptor 接口并重写 preHandle/postHandle/afterCompletion 方法,具体实现代码如下:




import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
@Component
public class TestInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
System.out.println("拦截器:执行 preHandle 方法。");
return true;
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
System.out.println("拦截器:执行 postHandle 方法。");
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
System.out.println("拦截器:执行 afterCompletion 方法。");
}
}

其中:



  • boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handle):在请求方法执行前被调用,也就是调用目标方法之前被调用。比如我们在操作数据之前先要验证用户的登录信息,就可以在此方法中实现,如果验证成功则返回 true,继续执行数据操作业务;否则就返回 false,后续操作数据的业务就不会被执行了。

  • void postHandle(HttpServletRequest request, HttpServletResponse response, Object handle, ModelAndView modelAndView):调用请求方法之后执行,但它会在 DispatcherServlet 进行渲染视图之前被执行。

  • void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handle, Exception ex):会在整个请求结束之后再执行,也就是在 DispatcherServlet 渲染了对应的视图之后再执行。


最后,我们再将上面的拦截器注入到项目配置文件中,并设置相应拦截规则,具体实现代码如下:


import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class AppConfig implements WebMvcConfigurer {

// 注入拦截器
@Autowired
private TestInterceptor testInterceptor;

@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(testInterceptor) // 添加拦截器
.addPathPatterns("/*"); // 拦截所有地址
}
}

了解了二者的使用之后,接下来我们来看二者的区别。


过滤器 VS 拦截器


过滤器和拦截器的区别主要体现在以下 5 点:



  1. 出身不同;

  2. 触发时机不同;

  3. 实现不同;

  4. 支持的项目类型不同;

  5. 使用的场景不同。


接下来,我们一一来看。


1.出身不同


过滤器来自于 Servlet,而拦截器来自于 Spring 框架,从上面代码中我们也可以看出,过滤器在实现时导入的是 Servlet 相关的包,如下图所示:
image.png
而拦截器在实现时,导入的是 Spring 相关的包,如下图所示:
image.png


2.触发时机不同


请求的执行顺序是:请求进入容器 > 进入过滤器 > 进入 Servlet > 进入拦截器 > 执行控制器(Controller),如下图所示:
image.png
所以过滤器和拦截器的执行时机也是不同的,过滤器会先执行,然后才会执行拦截器,最后才会进入真正的要调用的方法


3.实现不同


过滤器是基于方法回调实现的,我们在上面实现过滤器的时候就会发现,当我们要执行下一个过滤器或下一个流程时,需要调用 FilterChain 对象的 doFilter 方法进行回调执行,如下图所示:
image.png
由此可以看出,过滤器的实现是基于方法回调的。
拦截器是基于动态代理(底层是反射)实现的,它的实现如下图所示:
image.png
代理调用的效果如下图所示:
image.png


4.支持的项目类型不同


过滤器是 Servlet 规范中定义的,所以过滤器要依赖 Servlet 容器,它只能用在 Web 项目中;而拦截器是 Spring 中的一个组件,因此拦截器既可以用在 Web 项目中,同时还可以用在 Application 或 Swing 程序中


5.使用的场景不同


因为拦截器更接近业务系统,所以拦截器主要用来实现项目中的业务判断的,比如:登录判断、权限判断、日志记录等业务。
过滤器通常是用来实现通用功能过滤的,比如:敏感词过滤、字符集编码设置、响应数据压缩等功能。


本文项目源码下载


gitee.com/mydb/spring…


总结


过滤器和拦截器都是基于 AOP 思想实现的,用来处理某个统一的功能的,但二者又有 5 点不同:出身不同、触发时机不同、实现不同、支持的项目类型不同以及使用的场景不同。过滤器通常是用来进行全局过滤的,而拦截器是用来实现某项业务拦截的。


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

Flutter 组件集录 | 新一代 Button 按钮参上

0. 按钮一族现状 随着 Flutter 3.3 的发布,RaisedButton 组件从 Flutter 框架中移除,曾为界面开疆拓土的 按钮三兄弟 彻底成为历史。 另外 MaterialButton、RawMaterialButton 也将在未来计划被废...
继续阅读 »
0. 按钮一族现状

随着 Flutter 3.3 的发布,RaisedButton 组件从 Flutter 框架中移除,曾为界面开疆拓土的 按钮三兄弟 彻底成为历史。



另外 MaterialButtonRawMaterialButton 也将在未来计划被废弃,所以不建议大家再使用了:


image.png




目前,取而代之的是 TextButtonElevatedButtonOutlinedButton 三个按钮组件,本文将重点介绍这三者的使用方式。


1664584766319.png


另外,一些简单的按钮封装组件仍可使用:


CupertinoButton : iOS 风格按钮
CupertinoNavigationBarBackButton : iOS 导航栏返回按钮
BackButton : 返回按钮
IconButton : 图标按钮
CloseButton : 关闭按钮
FloatingActionButton : 浮动按钮

还有一些 多按钮 集成的组件,将在后续文章中详细介绍:


CupertinoSegmentedControl
CupertinoSlidingSegmentedControl
ButtonBar
DropdownButton
ToggleButtons



1. 三个按钮组件的默认表现

如下,是 ElevatedButton 的默认表现:有圆角和阴影,在点击时有水波纹。构造时必须传入点击回调函数onPressed 和子组件 child :


20.gif


ElevatedButton(
onPressed: () {},
child: Text('ElevatedButton'),
),



如下,是 OutlinedButton 的默认表现:有圆角和外边线,内部无填充,在点击时有水波纹。构造时必须传入点击回调函数onPressed 和子组件 child :



OutlinedButton(
onPressed: () {},
child: Text('OutlinedButton'),
);



如下,是 TextButton 的默认表现:无边线,无填充,在点击时有水波纹。构造时必须传入点击回调函数onPressed 和子组件 child :


22.gif


TextButton(
onPressed: () {},
child: Text('TextButton'),
);



2. 按钮样式的更改

如果稍微翻一下源码就可以看到,这三个按钮本质上是一样的,都是 ButtonStyleButton 的衍生类。只不过他们的默认样式 ButtonStyle 不同而已:


image.png


如下所示,在 ButtonStyleButton 类中队列两个抽象方法,需要子类去实现,返回默认按钮样式:


image.png




拿下面的 ElevatedButton 组件来说,它需要实现 defaultStyleOf 方法来返回默认主题。在未使用 Material3 时,通过 styleFrom 静态方法根据主题进行相关属性设置:比如各种颜色、阴影、文字样式、边距、形状等。


image.png




所以,需要修改按钮样式,只要提供 style 属性设置即可:该属性类型为 ButtonStyle,三个按钮组件都提供了 styleFrom 静态方法创建 ButtonStyle 对象,使用如下:


image.png


ButtonStyle style = ElevatedButton.styleFrom(
backgroundColor: Colors.orange,
foregroundColor: Colors.white,
elevation: 0,
padding: const EdgeInsets.symmetric(horizontal: 40),
shape: const StadiumBorder(),
side: const BorderSide(color: Colors.black,),
);

ElevatedButton(
onPressed: () {},
child: Text('Login'),
style: style
);



通过指定 shape 可以形状,如下所示,通过 CircleBorder 实现圆形组件:


image.png


ButtonStyle style = ElevatedButton.styleFrom(
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
elevation: 2,
shape: const CircleBorder(),
);

ElevatedButton(
onPressed: () {},
style: style,
child: const Icon(Icons.add)
);



TextButtonElevatedButtonOutlinedButton 这三个按钮,只是默认主题不同。如果提供相同的配置,OutlinedButton 因为可以实现下面的显示效果。


image.png


ButtonStyle style = OutlinedButton.styleFrom(
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
elevation: 0,
shape: const CircleBorder(),
side:BorderSide.none
);

OutlinedButton(
onPressed: () {},
style: style,
child: const Icon(Icons.add)
);




常见样式属性:































































属性名类型用途
foregroundColorColor?前景色
backgroundColorColor?背景色
disabledForegroundColorColor?禁用时前景色
disabledBackgroundColorColor?禁用时背景色
shadowColorColor?阴影色
elevationdouble?阴影深度
textStyleTextStyle?文字样式
paddingEdgeInsetsGeometry?边距
sideBorderSide?边线
shapeOutlinedBorder?形状



另外,还有一些不常用的属性,了解一下即可:

























































属性名类型用途
alignmentAlignmentGeometry?子组件区域中对齐方式
enableFeedbackbool?是否启用反馈,如长按震动
enabledMouseCursorMouseCursor?桌面端鼠标样式
disabledMouseCursorMouseCursor?禁用时桌面端鼠标样式
animationDurationDuration?动画时长
minimumSizeSize?最小尺寸
maximumSizeSize?最大尺寸
fixedSizeSize?固定尺寸
paddingEdgeInsetsGeometry?边距



3. 按钮的事件

这三个按钮在构造时都需要传入 onPressed 参数作为点击回调。另外,还有三个回调 onLongPress 用于监听长按事件;onHover 用于监听鼠标悬浮事件;onFocusChange 用于监听焦点变化的事件。



ElevatedButton(
onPressed: () {
print('========Login==========');
},
onHover: (bool value) {
print('=====onHover===$value==========');
},
onLongPress: () {
print('========onLongPress==========');
},
onFocusChange: (bool focus) {
print('=====onFocusChange===$focus==========');
},
child: const Text('Login'),
);



当按钮的 onPressedonLongPress 都为 null 时,按钮会处于 禁用状态 。此时按钮不会响应点击,也没有水波纹效果;另外,按钮的背景色,前景色分别取用 disabledBackgroundColordisabledForegroundColor 属性:


image.png


image.png


ElevatedButton(
onPressed: null,
style: style,
child: const Text('Login'),
);



4. 按钮的尺寸

在按钮默认样式中,规定了最小尺寸是 Size(64, 36) , 最大尺寸无限。


image.png


也就是说,在父级区域约束的允许范围,按钮的尺寸由 子组件边距 确定的。如下所示,子组件中文字非常大,按钮尺寸会适用文字的大小。



ButtonStyle style = ElevatedButton.styleFrom(
// 略...
padding: const EdgeInsets.symmetric(horizontal: 40,vertical: 10),
);

ElevatedButton(
onPressed: null,
style: style,
child: const Text('Login',style: TextStyle(fontSize: 50),),
);



父级约束 是绝对不能违逆的,在紧约束下,按钮的尺寸会被锁死。如下,通过 SizedBox 为按钮施加一个 200*40 的紧约束:


image.png


SizedBox(
width: 200,
height: 40,
child: ElevatedButton(
onPressed: (){},
style: style,
child: const Text('Login'),
),
);

如下,将紧约束宽度设为 10 ,可以看出按钮也只能遵循。即使它本身最小尺寸是 Size(64, 36),也不能违背父级的约束:


image.png


所以,想要修改按钮的尺寸,有两种方式:





    1. 子组件尺寸 边距 入手,调整按钮尺寸。





    1. 为按钮施加 紧约束 ,锁死按钮尺寸。






5. 简看 ButtonStyleButton 组件的源码实现

首先,ButtonStyleButton 是一个抽象类,其继承自 StatefulWidget, 说明其需要依赖状态类实现内部的变化。


image.png


createState 方法中返回 _ButtonStyleState 状态对象,说明按钮构建的逻辑在该状态类中:


@override
State<ButtonStyleButton> createState() => _ButtonStyleState();



直接来看 _ButtonStyleState 中的构造方法,一开始会触发组件的 themeStyleOfdefaultStyleOf 抽象方法获取 ButtonStyle 对象。这也就是TextButtonElevatedButtonOutlinedButton 三者作为实现类需要完成的逻辑。


image.png




构建的组件也就是按钮的最终表现,其中使用了 ConstrainedBox 组件处理约束;Material 组件处理基本表现内容;InkWell 处理水波纹和相关事件;Padding 用于处理内边距;Align 处理对齐方式。


image.png


使用,总的来看:ButtonStyleButton 组件就是一些常用组件的组合体而已,通过 ButtonStyle 类进行样式配置,来简化构建逻辑。通过封装,简化使用。另外,我们可以通过主题来统一样式,无需一个个进行配置,这个在后面进行介绍。那本文就到这里,谢谢观看 ~


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

ProtoBuf 基本语法总结,看这一篇就够了

前言最近项目是采用微服务架构开发的,各服务之间通过gPRC调用,基于ProtoBuf序列化协议进行数据通信,因此接触学习了Protobuf,本文会对Protobuf的语法做下总结,感兴趣的小伙伴们可以参考借鉴,希望对大家能有所帮助。gRPC的调用模型如下:基本...
继续阅读 »

前言

最近项目是采用微服务架构开发的,各服务之间通过gPRC调用,基于ProtoBuf序列化协议进行数据通信,因此接触学习了Protobuf,本文会对Protobuf的语法做下总结,感兴趣的小伙伴们可以参考借鉴,希望对大家能有所帮助。

gRPC的调用模型如下:

基本规范

  • 文件以.proto做为文件后缀,除结构定义外的语句以分号结尾。
  • rpc方法定义结尾的分号可有可无。
  • Message命名采用驼峰命名方式,字段命名采用小写字母加下划线分隔方式。

基本语法

首先看一个简单的示例:

/*
头部相关声明
*/
syntax = "proto3"; // 语法版本为protobuf3.0
package user; // 定义包名,可以为.proto文件指定包名,防止消息名冲突。
import "common.proto"; // 导入common.proto
option go_package = ".;proto";

//服务
service User {
rpc SayHello (SayHelloRequest) returns (SayHelloResponse) {}
}

//定义请求消息体
message SayHelloRequest {
string name = 1;
int64 role = 2;
}

//定义响应消息体
message SayHelloResponse {
string message = 1;
}
  • .proto文件的第一个非注释行用于指定语法版本,默认为“proto2”;

package定义包

可以为.proto文件指定包名,防止消息名冲突。

import 导入包

可以通过import导入其它.proto中定义的消息;常用于导入一些公共的信息。

正常情况下只能使用直接导入的proto文件的定义;如果需要使用多级import导入的文件,import 可以使用 public 属性。示例如下:

a.proto

import public "common.proto"; // 注意此处使用的是import public
import "c.proto";

b.proto

import "a.proto";

在b.proto中可以用common.proto中定义的内容,但是不能用c中的定义的内容。

定义Message

定义message使用“message”关键字,消息的字段声明由4部分构成:字段修饰符 字段类型 字段名称 = 标志号。

格式如下:

message 消息名称 {

[字段修饰符] 字段类型 字段名称 = 标志号;

}

字段修饰符

  • singular:默认值,该字段可以出现0次或者1次(不能超过1次);
  • repeated:该字段可以重复任意多次(包括0次);

我们可以使用repeated关键字来表示动态数组,示例如下:

message User {
repeated int64 id = 1;
}

在请求的时候我们可以传[]int64{1, 2, 3, 4}

字段类型

关于字段类型,这里列举几个常用的,其它的如果有需要可以直接网上搜。

类型备注
string字符串
double64位浮点型
float32位浮点型
int32、int64整型
bool布尔型
uint32、uint64无符号整型
sint32、sint64有符号的整形

字段编号

每个字段都有一个编号,这些编号是 唯一的。该编号会用来识别二进制数据中的字段。编号在1-15范围内可以用一个字节编码表示,在16-2047范围用两个字节表示,所以将15以内得编号留给频繁出现的字段可以节省空间。

枚举类型

在定义消息类型时,我们有可能会为某个字段预定义值的一个列表,我们可以通过enum来添加一个枚举,为每个可能的值添加一个常量。示例如下:

message UserRequest {
string name = 1;
// 定义性别枚举
enum Gender {
UNKNOWN = 0;
MAN = 1;
WOMAN = 2;
}
// 定义一个枚举字段
Gender gender = 2;
}

注意:所有枚举定义都需要包含一个常量映射到0并且作为定义的首行。

嵌套类型

嵌套类型,也就是字面意思,在 message 消息体中,又嵌套了其它的 message 消息体,一共有两种模式,如下:

syntax = "proto3";
message UserResponse {
message User {
int64 id = 1;
string name = 2;
}
repeated User users = 1;
}

如果在外部消息之外使用内部消息,则需要使用“outermsg.innermsg”的方式,如,需要在UserResponse外使用User, 则应该使用:

UserResponse.User

Map类型

在返回列表的时候,map类型经常用到,可以使用map关键字可以创建一个映射,语法如:

map<key_type, value_type> map_field = N;
  • key_type 只能是整数或字符串,enum不能作为key_type;
  • value_type 是除了映射(map)意外的任意类型;

示例:

message User {
int64 id = 1;
string name = 2;
}

map[int64, User] users = 1;

定义Service

如果想在RPC中使用已经定义好的消息类型,可以在.proto文件中定一个消息服务接口,使用service关键字进行服务定义,如:

service User {
rpc SayHello (SayHelloRequest) returns (SayHelloResponse) {}
}


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

Compose 动画艺术探索之灵动岛

说起灵动岛,大家肯定都不陌生,因为这段时间这个东西实在是太火了,这是苹果14中算是最大的更新了😂,不拿缺点当缺点,并且还能在缺点上玩出花,这个产品思路确实厉害👍,不得不服!灵动岛看着效果挺炫,其实实现起来并不是特别复杂,今天带大家一起来使用 Compose 实...
继续阅读 »

说起灵动岛,大家肯定都不陌生,因为这段时间这个东西实在是太火了,这是苹果14中算是最大的更新了😂,不拿缺点当缺点,并且还能在缺点上玩出花,这个产品思路确实厉害👍,不得不服!灵动岛看着效果挺炫,其实实现起来并不是特别复杂,今天带大家一起来使用 Compose 实现下属于安卓的“灵动岛”!废话不多说,先来看下本篇文章实现的效果。


实现的灵动岛.gif


看着还可以吧,哈哈哈,接着往下说!


苹果的灵动岛


在网上找了写灵动岛的视频,大家想看的可以点击链接去看下,肯定比Gif图清晰。


灵动岛视频


苹果灵动岛.gif


嗯,这样看着确实挺好看,如果不是见过真机显示效果我真的就信了😂,不过还是上面说的,思路奇特,大方承认缺点值得肯定!


Compose 简单实现


之前几篇文章大概说了下 Compose 中的动画,思考下这个动画该如何写?我刚看到这个动画的时候也觉得实现起来不容易,但其实转念一想并不难,其实这些动画总结下来就是根据事件不同 Size 的大小也发生了改变,如果在之前原生安卓实现的话会复杂一些,但在 Compose 中就很简单了,还记得之前几篇文章中提到的 animateSizeAsState 么?这是 Compose 中开箱即用的 API,这里其实就可以使用这个来实现,来一起看下代码!


@Composable
fun DynamicScreen() {
   var isCharge by remember { mutableStateOf(true) }

   val animateSizeAsState by animateSizeAsState(
       targetValue = Size(if (isCharge) 170f else 100f, 30f)
  )

   Column {
       Box(modifier = Modifier
              .width(animateSizeAsState.width.dp)
              .height(animateSizeAsState.height.dp)
              .shadow(elevation = 3.dp, shape = RoundedCornerShape(15.dp))
              .background(color = Color.Black),
      )

       Button(
           modifier = Modifier.padding(top = 30.dp, bottom = 5.dp),
           onClick = { isCharge = false }) {
           Text(text = "默认状态")
      }

       Button(
           modifier = Modifier.padding(vertical = 5.dp),
           onClick = { isCharge = true }) {
           Text(text = "充电状态")
      }
  }
}

其实核心代码只有一行,就是上面所说的 animateSizeAsState ,其他的代码基本都在画布局,这里使用 Box 来画了下灵动岛的黑色圆角,并且将 box 的背景设置为了黑色,然后画了两个按钮,一个表示充电状态,另一个表示默认状态,点击按钮就可以进行切换,来看下效果!


简单充电灵动.gif


大概样式有了,但是不是感觉少了点什么?没错!苹果的动画有回弹效果,但咱们这个没有,那该怎么办呢?还好上一篇文章中咱们讲过动画规格,这里就使用 Spring 就可以满足咱们的需求了,如果想详细了解 Compose 动画规格的话可以移步上一篇文章:Compose 动画艺术探索之动画规格


来稍微改下代码:


val animateSizeAsState by animateSizeAsState(
   targetValue = Size(if (isCharge) 170f else 100f, 30f),
   animationSpec = spring(
       dampingRatio = Spring.DampingRatioLowBouncy,
       stiffness = Spring.StiffnessMediumLow
  )
)

别的代码都没动,只是修改了下动画规格,再来看下效果!


简单回弹灵动岛.gif


嗯,是不是有点意思了!


实现多种切换


上面咱们简单实现了充电的一种状态,但是咱们可以看到苹果里面可不止这一种,上面咱们使用的是 Boolean 值来进行切换的,但如果多种状态的话 Boolean 就有点力不从心了,这个时候就得考虑新的方案了!


private sealed class BoxState(val height: Dp, val width: Dp) {
   // 默认状态
   object NormalState : BoxState(30.dp, 100.dp)

   // 充电状态
   object ChargeState : BoxState(30.dp, 170.dp)

   // 支付状态
   object PayState : BoxState(100.dp, 100.dp)

   // 音乐状态
   object MusicState : BoxState(170.dp, 340.dp)

   // 多个状态
   object MoreState : BoxState(30.dp, 100.dp)
}

可以看到上面代码中写了一个密封类,参数就是灵动岛的宽和高,然后根据苹果灵动岛的样式大概可以分为了几种状态:默认状态就是一小条;充电状态高度较默认状态不变,宽度增加;支付状态高度增加,宽度较默认状态不变;音乐状态高度和宽度都较默认状态增加;多个应用状态宽度不变,但会多出一个小黑圆点。


下面还需要修改下状态:


var boxState: BoxState by remember { mutableStateOf(BoxState.NormalState) }

将状态值由 Boolean 改为了刚刚编写的 BoxState ,然后修改下 animateSizeAsState 的使用:


val animateSizeAsState by animateSizeAsState(
   targetValue = Size(boxState.width.value, boxState.height.value),
animationSpec = spring(
       dampingRatio = Spring.DampingRatioLowBouncy,
       stiffness = Spring.StiffnessMediumLow
  )
)

接下来再修改下按钮的点击事件:


Button(
   modifier = Modifier.padding(top = 30.dp, bottom = 5.dp),
   onClick = { boxState = BoxState.NormalState }) {
   Text(text = "默认状态")
}

Button(
   modifier = Modifier.padding(vertical = 5.dp),
   onClick = { boxState = BoxState.ChargeState }) {
   Text(text = "充电状态")
}

可以看到代码较上面基本没什么改动,只是在点击的时候切换了对应的 BoxState 值。下面再添加几个按钮来对应上面编写的几种状态:


Button(
   modifier = Modifier.padding(vertical = 5.dp),
   onClick = { boxState = BoxState.PayState }) {
   Text(text = "支付状态")
}

Button(
   modifier = Modifier.padding(vertical = 5.dp),
   onClick = { boxState = BoxState.MusicState }) {
   Text(text = "音乐状态")
}

嗯,代码很简单,就不过多描述,直接运行看效果吧!


基本都有灵动岛.gif


嗯,效果是不是已经出来了,哈哈哈,是不是很简单,代码实现个简单样式固然不难,但是如果想把系统应用甚至三方应用都适配灵动岛可不是一个简单的事。不过这里咱们值考虑如何实现灵动岛的动画,并不深究系统实现的问题及瓶颈。


多应用状态


上面基本已经实现了灵动岛的大部分动画,但状态中还有一个多应用,就是多个应用在灵动岛上的显示效果还没弄。多应用状态和别的不太一样,别的状态都是灵动岛宽高的变化,但多应用状态会多分出一个小黑圆点,这个需要单独写下。


val animateDpAsState by animateDpAsState(
   targetValue = if (boxState is BoxState.MoreState) 105.dp else 70.dp,
   animationSpec = spring(
       dampingRatio = Spring.DampingRatioLowBouncy,
       stiffness = Spring.StiffnessMediumLow
  )
)

Box {
   Box(
       modifier = Modifier
          .width(animateSizeAsState.width.dp)
          .height(animateSizeAsState.height.dp)
          .shadow(elevation = 3.dp, shape = RoundedCornerShape(15.dp))
          .background(color = Color.Black),
  )
   Box(
       modifier = Modifier
          .padding(start = animateDpAsState)
          .size(30.dp)
          .shadow(elevation = 3.dp, shape = RoundedCornerShape(15.dp))
          .background(color = Color.Black)
  )
}

可以看到这块又加了一个动画 animateDpAsState 来处理多应用状态小黑圆点的展示,如果当前状态为多应用状态的话即 padding 值增加,这样小黑圆点就会单独显示出来,反之不是多应用状态的话,小黑圆点就会在灵动岛下面进行隐藏,不进行展示。实现效果就是开头的效果了。此处也就不再进行展示。


其他方案实现


上面的动画实现主要使用的是 animateSizeAsState ,这个实现当然是没有问题的,但如果不止需要 Size 的话就不太够用了,比如还需要透明度的变化,亦或者还需要旋转缩放等操作的时候就不够用了,这个时候应该怎么办呢?别担心,官方为我们提供了 updateTransition 来处理这种情况,Transition 可管理一个或多个动画作为其子项,并在多个状态之间同时运行这些动画。


其实 updateTransition 咱们并不陌生,在 Compose 动画艺术探索之可见性动画 这篇文章中也提到过,AnimatedVisibility 源码中就使用到了。


下面来试着将 animateSizeAsState 修改为 updateTransition


val transition = updateTransition(targetState = boxState, label = "transition")

val boxHeight by transition.animateDp(label = "height", transitionSpec = boxSizeSpec()) {
   boxState.height
}
val boxWidth by transition.animateDp(label = "width", transitionSpec = boxSizeSpec()) {
   boxState.width
}

Box(
   modifier = Modifier
      .width(boxWidth)
      .height(boxHeight)
      .shadow(elevation = 3.dp, shape = RoundedCornerShape(15.dp))
      .background(color = Color.Black),
)

使用方法并不难,可以看到这里使用了 animateDp 方法来处理灵动岛的宽高动画,然后设置了下动画规格,为了方便这里将动画规格抽取了下,其实和上面使用的一致,都是 springtransition 还为我们提供了一些常用的动画方法,来看下有哪些吧!


image.png


上图中的动画方法都可以进行使用,大家可以根据需求来选择使用。


下面来运行看下 updateTransition 实现的效果吧:


tran灵动岛.gif


可以看到效果基本一致,如果不需要别的参数直接使用 animateSizeAsState 就足够了,但如果需要别的一些操作的话就可以考虑使用 updateTransition 来实现了。


多个应用切换优化


多应用状态苹果实现的样式中有类似水滴的动效,这块需要使用二阶贝塞尔曲线,其实并不复杂,来看下代码:


Canvas(modifier = Modifier.padding(start = 70.dp)) {
   val path = Path()
   val width = (animateFloatAsState + 30) * density
   val x = animateFloatAsState * density
   val p2x = density * 15f
   val p2y = density * 25f
   val p1x = density * 15f
   val p1y = density * 5f
   val p4x = width - 15f * density
   val p4y = density * 30f
   val p3x = width - 15f * density
   val p3y = 0f
   val c2x = (abs(p4x - p2x)) / 2
   val c2y = density * 20f
   val c1x = (abs(p3x - p1x)) / 2
   val c1y = density * 10f
   path.moveTo(p2x, p2y)
   path.lineTo(p1x, p1y)
   // 用二阶贝塞尔曲线画右边的曲线,参数的第一个点是上面的一个控制点
   path.quadraticBezierTo(c1x, c1y, p3x, p3y)
   path.lineTo(p4x, p4y)
   // 用二阶贝塞尔曲线画左边边的曲线,参数的第一个点是下面的一个控制点
   path.quadraticBezierTo(c2x, c2y, p2x, p2y)

   if (animateFloatAsState == 35f) {
       path.reset()
  } else {
       drawPath(
           path = path, color = Color.Black,
           style = Fill
      )
  }

   path.addOval(Rect(x + 0f, 0f, x + density * 30f, density * 30f))
   path.close()
   drawPath(
       path = path, color = Color.Black,
       style = Fill
  )
}

嗯,看着其实还挺多,其实并不难,确定好四个个点,然后连接上色就行,然后根据小黑圆点的位置动态绘制连接部分即可,关于贝塞尔曲线在这里就不细说了,大伙应该比我懂。最后来看下效果吧!


苹果水滴状.gif


这回是不是就有点像了,哈哈哈!


打完收工


本文带大家一起写了下当下很火的苹果灵动岛,只是最简单的模仿实现,效果肯定不如苹果调教一年的效果,仅供大家参考。


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

kotlin-android-extensions 插件到底是怎么实现的?

前言 kotlin-android-extensions 插件是 Kotlin 官方提供的一个编译器插件,用于替换 findViewById 模板代码,降低开发成本 虽然 kotlin-android-extensions 现在已经过时了,但比起其他替换 fi...
继续阅读 »

前言


kotlin-android-extensions 插件是 Kotlin 官方提供的一个编译器插件,用于替换 findViewById 模板代码,降低开发成本


虽然 kotlin-android-extensions 现在已经过时了,但比起其他替换 findViewById 的方案,比如第三方的 ButterKnife 与官方现在推荐的 ViewBinding


kotlin-android-extensions 还是有着一个明显的优点的:极其简洁的 APIKAE 方案比起其他方案写起来更加简便,这是怎么实现的呢?我们一起来看下


原理浅析


当我们接入KAE后就可以通过以下方式直接获取 View


import kotlinx.android.synthetic.main.activity_main.*

class MainActivity : AppCompatActivity() {

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
viewToShowText.text = "Hello"
}

}

而它的原理也很简单,KAE插件将上面这段代码转换成了如下代码


public final class MainActivity extends AppCompatActivity {
private HashMap _$_findViewCache;

protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
this.setContentView(1300023);
TextView var10000 = (TextView)this._$_findCachedViewById(id.textView);
var10000.setText((CharSequence)"Hello");
}

public View _$_findCachedViewById(int var1) {
if (this._$_findViewCache == null) {
this._$_findViewCache = new HashMap();
}
View var2 = (View)this._$_findViewCache.get(var1);
if (var2 == null) {
var2 = this.findViewById(var1);
this._$_findViewCache.put(var1, var2);
}
return var2;
}

public void _$_clearFindViewByIdCache() {
if (this._$_findViewCache != null) {
this._$_findViewCache.clear();
}

}
}

可以看到,实际上 KAE 插件会帮我们生成一个 _$_findCachedViewById()函数,在这个函数中首先会尝试从一个 HashMap 中获取传入的资源 id 参数所对应的控件实例缓存,如果还没有缓存的话,就调用findViewById()函数来查找控件实例,并写入 HashMap 缓存当中。这样当下次再获取相同控件实例的话,就可以直接从 HashMap 缓存中获取了。


当然KAE也帮我们生成了_$_clearFindViewByIdCache()函数,不过在 Activity 中没有调用,在 Fragment 的 onDestroyView 方法中会被调用到


总体结构


在了解了KAE插件的简单原理后,我们一步一步来看一下它是怎么实现的,首先来看一下总体结构


KAE插件可以分为 Gradle 插件,编译器插件,IDE 插件三部分,如下图所示



我们今天只分析 Gradle 插件与编译器插件的源码,它们的具体结构如下:




  1. AndroidExtensionsSubpluginIndicatorKAE插件的入口

  2. AndroidSubplugin用于配置传递给编译器插件的参数

  3. AndroidCommandLineProcessor用于接收编译器插件的参数

  4. AndroidComponentRegistrar用于注册如图的各种Extension


源码分析


插件入口


当我们查看 kotlin-gradle-plugin 的源码,可以看到 kotlin-android-extensions.properties 文件,这就是插件的入口


implementation-class=org.jetbrains.kotlin.gradle.internal.AndroidExtensionsSubpluginIndicator

接下来我们看一下入口类做了什么工作


class AndroidExtensionsSubpluginIndicator @Inject internal constructor(private val registry: ToolingModelBuilderRegistry) :
Plugin<Project> {
override fun apply(project: Project) {
project.extensions.create("androidExtensions", AndroidExtensionsExtension::class.java)
addAndroidExtensionsRuntime(project)
project.plugins.apply(AndroidSubplugin::class.java)
}

private fun addAndroidExtensionsRuntime(project: Project) {
project.configurations.all { configuration ->
val name = configuration.name
if (name != "implementation") return@all
configuration.dependencies.add(
project.dependencies.create(
"org.jetbrains.kotlin:kotlin-android-extensions-runtime:$kotlinPluginVersion"
)
)
}
}
}

open class AndroidExtensionsExtension {
open var isExperimental: Boolean = false

open var features: Set<String> = AndroidExtensionsFeature.values().mapTo(mutableSetOf()) { it.featureName }

open var defaultCacheImplementation: CacheImplementation = CacheImplementation.HASH_MAP
}

AndroidExtensionsSubpluginIndicator中主要做了这么几件事



  1. 创建androidExtensions配置,可以看出其中可以配置是否开启实验特性,启用的feature(因为插件中包含viewsparcelize两个功能),viewId缓存的具体实现(是hashMap还是sparseArray)

  2. 自动添加kotlin-android-extensions-runtime依赖,这样就不必在接入了插件之后,再手动添加依赖了,这种写法可以学习一下

  3. 配置AndroidSubplugin插件,开始配置给编译器插件的传参


配置编译器插件传参


class AndroidSubplugin : KotlinCompilerPluginSupportPlugin {
// 1. 是否开启编译器插件
override fun isApplicable(kotlinCompilation: KotlinCompilation<*>): Boolean {
if (kotlinCompilation !is KotlinJvmAndroidCompilation)
return false
// ...

return true
}

// 2. 传递给编译器插件的参数
override fun applyToCompilation(
kotlinCompilation: KotlinCompilation<*>
): Provider<List<SubpluginOption>> {
//...

val pluginOptions = arrayListOf<SubpluginOption>()
pluginOptions += SubpluginOption("features",
AndroidExtensionsFeature.parseFeatures(androidExtensionsExtension.features).joinToString(",") { it.featureName })

fun addVariant(sourceSet: AndroidSourceSet) {
val optionValue = lazy {
sourceSet.name + ';' + sourceSet.res.srcDirs.joinToString(";") { it.absolutePath }
}
pluginOptions += CompositeSubpluginOption(
"variant", optionValue, listOf(
SubpluginOption("sourceSetName", sourceSet.name),
//use the INTERNAL option kind since the resources are tracked as sources (see below)
FilesSubpluginOption("resDirs", project.files(Callable { sourceSet.res.srcDirs }))
)
)
kotlinCompilation.compileKotlinTaskProvider.configure {
it.androidLayoutResourceFiles.from(
sourceSet.res.sourceDirectoryTrees.layoutDirectories
)
}
}

addVariant(mainSourceSet)

androidExtension.productFlavors.configureEach { flavor ->
androidExtension.sourceSets.findByName(flavor.name)?.let {
addVariant(it)
}
}

return project.provider { wrapPluginOptions(pluginOptions, "configuration") }
}

// 3. 定义编译器插件的唯一 id,需要与后面编译器插件中定义的 pluginId 保持一致
override fun getCompilerPluginId() = "org.jetbrains.kotlin.android"

// 4. 定义编译器插件的 `Maven` 坐标信息,便于编译器下载它
override fun getPluginArtifact(): SubpluginArtifact =
JetBrainsSubpluginArtifact(artifactId = "kotlin-android-extensions")
}

主要也是重写以上4个函数,各自的功能在文中都有注释,其中主要需要注意applyToCompilation方法,我们传递了featuresvariant等参数给编译器插件


variant的主要作用是为不同 buildTypeproductFlavor目录的 layout 文件生成不同的包名


import kotlinx.android.synthetic.main.activity_main.*
import kotlinx.android.synthetic.debug.activity_debug.*
import kotlinx.android.synthetic.demo.activity_demo.*

比如如上代码,activity_debug文件放在debug目录下,而activiyt_demo文件则放在demo这个flavor目录下,这种情况下它们的包名是不同的


编译器插件接收参数


class AndroidCommandLineProcessor : CommandLineProcessor {
override val pluginId: String = ANDROID_COMPILER_PLUGIN_ID

override val pluginOptions: Collection<AbstractCliOption>
= listOf(VARIANT_OPTION, PACKAGE_OPTION, EXPERIMENTAL_OPTION, DEFAULT_CACHE_IMPL_OPTION, CONFIGURATION, FEATURES_OPTION)

override fun processOption(option: AbstractCliOption, value: String, configuration: CompilerConfiguration) {
when (option) {
VARIANT_OPTION -> configuration.appendList(AndroidConfigurationKeys.VARIANT, value)
PACKAGE_OPTION -> configuration.put(AndroidConfigurationKeys.PACKAGE, value)
EXPERIMENTAL_OPTION -> configuration.put(AndroidConfigurationKeys.EXPERIMENTAL, value)
DEFAULT_CACHE_IMPL_OPTION -> configuration.put(AndroidConfigurationKeys.DEFAULT_CACHE_IMPL, value)
else -> throw CliOptionProcessingException("Unknown option: ${option.optionName}")
}
}
}

这段代码很简单,主要是解析variant,包名,是否开启试验特性,缓存实现方式这几个参数


注册各种Extension


接下来到了编译器插件的核心部分,通过注册各种Extension的方式修改编译器的产物


class AndroidComponentRegistrar : ComponentRegistrar {
companion object {
fun registerViewExtensions(configuration: CompilerConfiguration, isExperimental: Boolean, project: MockProject) {

ExpressionCodegenExtension.registerExtension(project,
CliAndroidExtensionsExpressionCodegenExtension(isExperimental, globalCacheImpl))

IrGenerationExtension.registerExtension(project,
CliAndroidIrExtension(isExperimental, globalCacheImpl))

StorageComponentContainerContributor.registerExtension(project,
AndroidExtensionPropertiesComponentContainerContributor())

ClassBuilderInterceptorExtension.registerExtension(project,
CliAndroidOnDestroyClassBuilderInterceptorExtension(globalCacheImpl))

PackageFragmentProviderExtension.registerExtension(project,
CliAndroidPackageFragmentProviderExtension(isExperimental))
}
}

override fun registerProjectComponents(project: MockProject, configuration: CompilerConfiguration) {
if (AndroidExtensionsFeature.VIEWS in features) {
registerViewExtensions(configuration, isExperimental, project)
}
}
}

可以看出,主要就是在开启了AndroidExtensionsFeature.VIEWS特性时,注册了5个Extension,接下来我们来看下这5个Extension都做了什么


IrGenerationExtension


IrGenerationExtensionKAE插件的核心部分,在生成 IR 时回调,我们可以在这个时候修改与添加 IR,KAE插件生成的_findCachedViewById方法都是在这个时候生成的,具体实现如下:


private class AndroidIrTransformer(val extension: AndroidIrExtension, val pluginContext: IrPluginContext) :
IrElementTransformerVoidWithContext() {

override fun visitClassNew(declaration: IrClass): IrStatement {
if ((containerOptions.cache ?: extension.getGlobalCacheImpl(declaration)).hasCache) {
val cacheField = declaration.getCacheField()
declaration.declarations += cacheField // 添加_$_findViewCache属性
declaration.declarations += declaration.getClearCacheFun() // 添加_$_clearFindViewByIdCache方法
declaration.declarations += declaration.getCachedFindViewByIdFun() // 添加_$_findCachedViewById方法
}
return super.visitClassNew(declaration)
}

override fun visitCall(expression: IrCall): IrExpression {
val result = if (expression.type.classifierOrNull?.isFragment == true) {
// this.get[Support]FragmentManager().findFragmentById(R$id.<name>)
createMethod(fragmentManager.child("findFragmentById"), createClass(fragment).defaultType.makeNullable()) {
addValueParameter("id", pluginContext.irBuiltIns.intType)
}.callWithRanges(expression).apply {
// ...
}
} else if (containerHasCache) {
// this._$_findCachedViewById(R$id.<name>)
receiverClass.owner.getCachedFindViewByIdFun().callWithRanges(expression).apply {
dispatchReceiver = receiver
putValueArgument(0, resourceId)
}
} else {
// this.findViewById(R$id.<name>)
irBuilder(currentScope!!.scope.scopeOwnerSymbol, expression).irFindViewById(receiver, resourceId, containerType)
}
return with(expression) { IrTypeOperatorCallImpl(startOffset, endOffset, type, IrTypeOperator.CAST, type, result) }
}
}

如上所示,主要做了两件事:



  1. visitClassNew方法中给对应的类(比如 Activity 或者 Fragment )添加了_$_findViewCache属性,以及_$_clearFindViewByIdCache_$_findCachedViewById方法

  2. visitCall方法中,将viewId替换为相应的表达式,比如this._$_findCachedViewById(R$id.<name>)或者this.findViewById(R$id.<name>)


可以看出,其实KAE插件的大部分功能都是通过IrGenerationExtension实现的


ExpressionCodegenExtension


ExpressionCodegenExtension的作用其实与IrGenerationExtension基本一致,都是用来生成_$_clearFindViewByIdCache等代码的


主要区别在于,IrGenerationExtension在使用IR后端时回调,生成的是IR


ExpressionCodegenExtension在使用 JVM 非IR后端时回调,生成的是字节码


在 Kotlin 1.5 之后,JVM 后端已经默认开启 IR,可以认为这两个 Extension 就是新老版本的两种实现


StorageComponentContainerContributor


StorageComponentContainerContributor的主要作用是检查调用是否正确


class AndroidExtensionPropertiesCallChecker : CallChecker {
override fun check(resolvedCall: ResolvedCall<*>, reportOn: PsiElement, context: CallCheckerContext) {
// ...
with(context.trace) {
checkUnresolvedWidgetType(reportOn, androidSyntheticProperty)
checkDeprecated(reportOn, containingPackage)
checkPartiallyDefinedResource(resolvedCall, androidSyntheticProperty, context)
}
}
}

如上,主要做了是否有无法解析的返回类型等检查


ClassBuilderInterceptorExtension


ClassBuilderInterceptorExtension的主要作用是在onDestroyView方法中调用_$_clearFindViewByIdCache方法,清除KAE缓存


private class AndroidOnDestroyCollectorClassBuilder(
private val delegate: ClassBuilder,
private val hasCache: Boolean
) : DelegatingClassBuilder() {
override fun newMethod(
origin: JvmDeclarationOrigin,
access: Int,
name: String,
desc: String,
signature: String?,
exceptions: Array<out String>?
): MethodVisitor {
val mv = super.newMethod(origin, access, name, desc, signature, exceptions)
if (!hasCache || name != ON_DESTROY_METHOD_NAME || desc != "()V") return mv
hasOnDestroy = true
return object : MethodVisitor(Opcodes.API_VERSION, mv) {
override fun visitInsn(opcode: Int) {
if (opcode == Opcodes.RETURN) {
visitVarInsn(Opcodes.ALOAD, 0)
visitMethodInsn(Opcodes.INVOKEVIRTUAL, currentClassName, CLEAR_CACHE_METHOD_NAME, "()V", false)
}
super.visitInsn(opcode)
}
}
}
}

可以看出,只有在 Fragment 的onDestroyView方法中添加了 clear 方法,这是因为 Fragment 的生命周期与其根 View 生命周期可能并不一致,而 Activity 的 onDestroy 中是没有也没必要添加的


PackageFragmentProviderExtension


PackageFragmentProviderExtension的主要作用是注册各种包名,以及该包名下的各种提示


import kotlinx.android.synthetic.main.activity_main.*
import kotlinx.android.synthetic.debug.activity_debug.*
import kotlinx.android.synthetic.demo.activity_demo.*

比如我们在 IDE 中引入上面的代码,就可以引入 xml 文件中定义的各个 id 了,这就是通过这个Extension实现的


总结


本文主要从原理浅析,总体架构,源码分析等角度分析了 kotlin-android-extensions 插件到底是怎么实现的


相比其它方案,KAE使用起来可以说是非常简洁优雅了,可以看出 Kotlin 编译器插件真的可以打造出极简的 API,因此虽然KAE已经过时了,但还是有必要学习一下的


如果本文对你有所帮助,欢迎点赞收藏~


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

Android打造专有hook,让不规范的代码扼杀在萌芽之中

俗话说,无规矩不成方圆,同样的放在代码里也是十分的贴切,所谓在代码里的规矩,指的就是规范,在一定规范约束下的项目,无论是参与开发还是后期维护,都是非常的直观与便捷,不能说赏心悦目,也可以用健壮可维护来表示;毕竟协同开发的项目,每个人都有自己的一套开发标准,你没...
继续阅读 »

俗话说,无规矩不成方圆,同样的放在代码里也是十分的贴切,所谓在代码里的规矩,指的就是规范,在一定规范约束下的项目,无论是参与开发还是后期维护,都是非常的直观与便捷,不能说赏心悦目,也可以用健壮可维护来表示;毕竟协同开发的项目,每个人都有自己的一套开发标准,你没有一套规范,或者是规范没有落地执行,想想,长此以往,会发生什么?代码堆积如山?维护成本翻倍增加?新人接手困难?等等,所谓的问题会扑面而来。


正所谓规范是一个项目的基石,也是衡量一个项目,是否健壮,稳定,可维护的标准,可谓是相当重要的。我相信,大部分的公司都有自己的一套规范标准,我也相信,很多可能就是一个摆设,毕竟人员的众多,无法做到一一的约束,如果采取人工的检查,无形当中就会投入大量的时间和人力成本,基于此,所谓的规范,也很难执行下去。


介于人工和时间的投入,我在以往的研究与探索中,开发出了一个可视化的代码检查工具,之前进行过分享,《一个便捷操作的Android可视化规范检查》,想了解的老铁可以看一看,本篇文章不做过多介绍,当时只介绍了工具的使用,没有介绍相关功能的开发过程,后续,有时间了,我会整理开源出来,一直忙于开发,老铁们,多谅解。这个可视化的检查工具,虽然大大提高了检查效率,也节省了人力和时间,但有一个潜在的弊端,就是,只能检查提交之后的代码是否符合规范,对于提交之前没有进行检查,也就说,在提交之前,规范也好,不规范也罢,都能提交上来,用工具检查后,进行修改,更改不规范的地方后然后再提交,只能采取这样的一个模式检查。


这样的一个模式,比较符合,最后的代码检查,适用于项目负责人,开发Leader,对组员提交上来的代码进行规范的审阅,其实并不适用于开发人员,不适用不代表着不可用,只不过相对流程上稍微复杂了几步;应对这样的一个因素,如何适用于开发人员,方便在提交代码之前进行规范检查,便整体提上了研发日程,经过几日的研究与编写,一个简单便捷的Android端Git提交专有hook,便应运而生了。


说它简单,是因为不需要编写任何的代码逻辑,只需要寥寥几步命令,便安装完毕,通过配置文件,便可灵活定制属于自己的检查范围。


为了更好的阐述功能及讲述实现过程,便于大家定制自己的开发规范,再加上篇幅的约束,我总结了四篇文章来进行系统的梳理,还请大家,保持关注,今天这篇,主要讲述最终的开发成果,也就是规范工具如何使用,规范这个东西,其实大差不差,大家完全可以使用我自己已经开发好的这套。


这个工具的开发,利用的是git 钩子(hook),当然也是借助的是Node.js来实现的相关功能,下篇文章会详细介绍,我们先来安装程序,来目睹一下实际的效果,安装程序,只需要执行几步命令即可,无需代码介入,在实际的开发中需要开发人员,分别进行安装。


安装流程


1、安装 Node.js,如果已经安装,可直接第2步:


Node.js中允许使用 JavaScript 开发服务端以及命令行程序,我们可以去官网nodejs.org
下载最新版本的安装程序,然后一步一步进行安装就可以了,这个没什么好说的,都是开发人员。


2、安装android_standard


android_standard是最终的工具,里面包含着拦截代码判断的各种逻辑 在项目根目录下执行如下命令:


npm install android_standard --save-dev

执行完命令后,你会发现,你的项目下已经多了一个目录,还有两个json文件,如下图所示:


image.png


node_modules,用来存放下载安装的包文件夹,里面有我们要使用到的功能,其实和Android中lib目录很类似,都是一些提供功能的库。


package.json文件,是配置文件,比如应用的名字,作者,介绍,还有相关的依赖等,和Android中的build.gradle文件类似。


3、创建git配置文件,执行如下命令


node node_modules/android_standard/gitCommitConfig

命令执行成功会返回如下信息:


image.png


此命令执行完后,会在项目根目录下创建gitCommitConfig文件,这个文件很重要,是我们执行相关命令的配置文件,内容如下,大家可以根据自己实际项目需要进行更改。


项目下生成gitCommitConfig.android文件,.android是我自己定义的,至于什么格式,等你自己开发的时候,完全可以自定义,是个文件就行。


image.png


打开后,文件内容如下,此文件是比较重要的,后续所有的规范检查,都要根据这个文件里的参数来执行,大家在使用的时候,就可以通过这个文件来操作具体的规范检查。


image.png


4、更改执行文件,执行如下命令


执行文件,就是需要在上边生成的package.json文件,添加运行程序,使其在git提交时进行hook拦截。


node node_modules/android_standard/package

5、添加git过滤


因为执行完上述命令后,会产生几个文件,而这几个文件是不需要我们上传到远程仓库的,所以我们需要在.gitignore文件里添加忽略,直接复制即可。


/node_modules
package.json
package-lock.json
gitCommitConfig.android

6、后续如果有更新,可命令进行操作:


注:此命令在更新时执行


npm update android_standard --save-dev

7、删除操作


注:后续不想使用了,便可执行如下命令:


npm uninstall android_standard --save-dev

具体使用


通过上述的安装流程,短短几个命令,我们的规范检查便安装完毕,后续只需要通过gitCommitConfig.android文件,来动态的更改参数即可,是不是非常的方便,接下来,我们来实际的操作一番。


关于配置文件的相关参数,也都有注释,一看便知,这里简单针对最后的参数,做一个说明,也就是gitCommand这个参数,true为工具,false为命令方式;true也好,false也好,在主要的功能验证上,没有区别,唯一的区别就是,命令行的方式提交,会有颜色区分,后面有效果。


image.png


我们先来看下命令行下的执行效果,当配置文件开关gitCommitSwitch已开,并且gitCommand为false,其他的配置参数,大家可以根据需要进行改动,在代码提交的时候如下效果:


image.png


在Android studio中提交代码执行效果


image.png


TortoiseGit提交代码执行效果:


image.png


目前呢,针对Android端的规范检查,无论是java还是Kotlin,还是资源文件,都做了一定的适配,经过多方测试,一切正常,如果大家的公司也需要这样的一个hook工具,欢迎使用,也欢迎继续关注接下来的相关实现逻辑文章。


好了各位老铁,这篇文章先到这里,下篇文章会讲述,具体的实现过程,哦,忘记了,上篇结尾的遗留组件化还未更新,那就更新完组件化,接着更新这个,哈哈,敬请期待!


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

由浅入深、详解Android中Drawable的那些事

引言 对于 Drawable ,一直没有专门记录,日常开发中,也是属于忘记了再搜一下。主要是使用程度有限(仅仅只是shape或者 layer 等冰山一角),另一方面是 Android 对其的高度抽象,导致从没去关注过细节,从而对于 Drawable 没有真正的...
继续阅读 »

引言


对于 Drawable ,一直没有专门记录,日常开发中,也是属于忘记了再搜一下。主要是使用程度有限(仅仅只是shape或者 layer 等冰山一角),另一方面是 Android 对其的高度抽象,导致从没去关注过细节,从而对于 Drawable 没有真正的理解其设计与存在的意义。


反而是偶尔一次发现其他同学的运用,才明白了自己的狭隘,为此,怀着无比惭愧的心情,写下此篇,与君共勉。


鉴于此,本篇将完整的描述开发中常见各种 Drawable ,以及在工程化项目的背景下,如何更好的运用。总体难度较低,不涉及源码,适合轻松阅读。


来者何人


2022的今天,随便问一个Android开发,Drawable 是什么?



比如我。他(她)肯定会告诉你(鄙视的眼神),你si不si傻,Drawable 都不知道,Drawable,DrawbleDrawable不就是...😐


不就是经常用来设置的图像吗?🫣(不确定语气,似乎说的不完整)



上述说的有错吗,也没错。嗯,但总觉得差点什么,过于简单?细心的你肯定会觉得没这么简单。


那到底什么是Drawable?



Drawable 表示的是一种可以在Canvas上进行绘制的抽象概念。人话就是 就是指可在屏幕上绘制的图形。



就这?就这?就这?


这说了个啥,水水水,一天就知道水文章?🫵🏼


嗯🧐,在开发角度来看,Drawable 是一个抽象的类,用来表示可以绘制在屏幕上绘制的图形。我们常见有很多种 Drawable ,比如Bitmapxx,Colorxxx,Shapexxx,它们一般都用于表示图像,但严格上来说,又不全是图像


表情包:你是不是外面有狗了?


后半句用人话怎么理解呢?



对于普通的图形或图片,我们肯定没法更改,因为其已经固定了(资源文件)。


但是对于 Drawable,虽然某种程度上也是图形(矢量资源),但其具备处理或绘制具体显示逻辑的方式。也就是说,这是一个支持修改的图形,比如我们可以把一张图塞给了 BitmapDrawable ,但依然可以做二次调整,比如拉伸一下,改一下位置,给这张图上再添加点别的什么东西。或者也可以理解为这是一个简化版的View,只不过它更简易,目的纯粹。其无法像 View 一样接收事件或者和用户交互,其更像一个绘制板,指哪打哪,仅作为显示使用。



当然除了简单的绘图,Drawable 还提供了很多通用api,使得我们可以与正在绘制的内容进行交互,从而更加完善。


相应的,Drawable 内部其实也有自己的宽高、通过 intrinsicWidthintrinsicHeight 即可获取。需要注意的是:



  • Drawable 的宽高不等于其展示时的大小,我们可以认为 Drawable 不存在大小的概念,因为其用于View背景时,其会被拉伸至View的同等大小。

  • 也并不是所有的 Drawable 都有内部宽高,比如,由一个图片所形成的 Drawable ,其相应的宽高也就是图片的宽高,而由颜色所形成的Drawable ,相应的内部也不存在宽高。


Drawable的种类


如下所示,Drawable有如下类型:


image-20220919224205510



好家伙,这也太多了吧,而且后续还会越来越多。



当然这么多,我们一般其实根本不可能全部用上,常见的有如下几种类别:


无状态




  • BitmapDrawable


    <<bitmap


    用于将图片转为BitmapDrawable;




  • ShapeDrawable


    <<shape


    通过颜色来构造Drawable;




  • VectorDrawable


    <<vector


    矢量图,Android5.0及以上支持。便于在缩放过程中保证显示质量,以及一个矢量图支持多个屏幕,减少apk大小;




  • TransitionDrawable


    <<transition


    用于实现Drawable间的淡入淡出效果;




  • InsetDrawable


    <<inset


    用于将其他Drawable内嵌到自己当中,并可以在四周留出一定的间距。当一个View希望自己的背景比实际的区域小时,可以采用其来实现。




有状态




  • StateListDrawable


    <<selector


    用于有状态交互时的View设置,比如 按下时 的背景,松开时 的背景,有焦点时的背景灯;




  • LevelListDrawable


    <<level-list


    根据等级(level)来切换不同的 Drawble。在View中可以通过设置 setImageLevel 更改不同的Drawable ;




  • ScaleDrawable


    <<scale


    根据不同的等级(level)指定 Drawable 缩放到一定比例;




  • ClipDrwable


    <<clip


    根据当前等级(level)来裁剪 Drawable ;




常见的Drawable


BitmapDrawable


常见使用场景


用于表示一张图片,用于设置 bitmapBitmapDrawable 区域内的绘制方式时使用,如水平平铺或者竖直平铺以及扩展铺满。


xml中的标签:


常见的属性有如下:




  • android:src


    资源id




  • android:antialias


    开启图片抗锯齿,用于让图片变得平滑,同时抗锯齿也会一定程度上降低图片清晰度,不过幅度几乎无法感知;




  • android:dither


    开启抖动效果,为低像素机型做的自动降级显示,保证显示效果。比如当前图片彩色模式为ARGB8888,而设备屏幕色彩模式为RGB555,此时开启抖动就可以避免图片显示失真;




  • android:filter


    过滤效果。在图片尺寸被拉伸或者压缩时,有助于保持显示效果;




  • android:gravity


    当前图片小于容器尺寸时,此选项便于对图片进行定位,当titleMode开启时,此属性将失效;




  • android:mipMap


    纹理映射开关,主要是为了应对图片大小缩放处理时,Android可以通过纹理映射技术提前按缩小的层级生成图片预存储在内存中,以此来提高速度与图片质量。默认情况下,mipmap文件夹里的默认开启此开关,drawable默认关闭。但需要注意,此开关只能建议系统开启此功能,至于最终是否真正开启,取决于系统。




  • android:tileMode


    用于设置图片的平铺模式,有以下几个值:[disabledclamprepeatmirror]



    • disabled (默认值) 关闭平铺模式

    • clamp 图片四周的像素会扩展到周围区域

    • repeat 水平和竖直方向上的平铺效果

    • mirror 在水平和竖直方向的的镜面投影效果




image-20220922231438984


示例代码:


val bitmap = BitmapFactory.decodeResource(resources, R.drawable.ic_doge)
val drawable = BitmapDrawable(bitmap).apply {
setTileModeXY(Shader.TileMode.CLAMP, Shader.TileMode.CLAMP)
isFilterBitmap = true
gravity = Gravity.CENTER
setMipMap(true)
setDither(true)
}
ivDrawable.background = drawable

<?xml version="1.0" encoding="utf-8"?>
<bitmap xmlns:android="http://schemas.android.com/apk/res/android"
android:dither="true"
android:filter="true"
android:gravity="center"
android:mipMap="true"
android:src="@drawable/test"
android:tileMode="repeat" />



ShapeDrawable


常见使用场景


通过颜色来构造图形,作为背景描边或者背景色渐变时使用,可以说是最常见的一种 Drawable


xml中的标签:


常见的属性如下:




  • shape


    表示图形的形状,如下四个选项:rectangle(矩形)、oval(椭圆)、line(横线)、ring(圆环)




  • corners


    表示shape的四个角的角度,只适用于矩形shape。



    • android:radius 为四个角设置相同的角度

    • android:topLeftRadius 设置左上角角度

    • android:bottomLeftRadius 设置左下角角度

    • android:bottomRightRadius 设定右下角的角度




  • gradient


    用于表示渐变效果,与 标签互斥(其表示纯色填充)



    • android:angle 渐变的角度,默认为0,其值必须为45的倍数, 0表示从左向右,90表示从下到上

    • android:centerX 渐变中心点的横坐标

    • android:centerY 渐变中心点纵坐标

    • android:startColor 渐变的起始色

    • android:centerColor 渐变的中间点

    • android:endColor 渐变的结束色

    • android:gradientRadius 渐变半径,仅当android:type=“radial”时有效

    • android:useLevel 是否使用等级区分,在 StateListDrawable 时有效,默认 false

    • android:type 渐变类型,linear(线性渐变)、radial(径向渐变)、sweep




  • solid 表示纯色填充




  • stroke 用于设置描边



    • android:width 描边宽度

    • android:color 描边颜色

    • android:dashWidth 描边虚线时的宽度

    • android:dashGap 描边虚线时的间隔


    image-20220925193303529




  • padding


    用于表示空白,其代表了在View中使用时的空白。但其在shape中并没有什么作用,可以在 layer-list 中进行使用。




  • size


    用于表示 shape固有大小 ,但其不代表shape最终显示的大小。因为对于shape来说,其没有宽/高的概念,因为其最终被设置到View上作为背景时,其会被自动拉伸或缩放。但作为drawable,它拥有着固有宽/高,即通过 getIntrinsicWidthgetIntrinsicHeight 获取。对于某些Drawable而言,比如BitMapDrawable时,其宽高就是图片大小;而对于shape时,其就不存在大小,默认为-1。当然你也可以通过 size 设置大小,但其最终代表的是shape的固有宽高,作为背景时其并不是最终显示时的宽高。




示例如下:


image-20220925201141661




LayerDrawable


表示一种层次化的集合 drawable ,一般常见于需要多个 Drawable 叠加 摆放效果时使用。


一个 layer-list 中可以包含多个 item ,每个item表示一个 Drawable ,其常用的属性 android:top,left,right,bottom 。相当于相对View的 上下左右 偏移量,单位为像素。此外也可以通过 Drawable 引用一个已有的 Drwable 资源。


xml中的标签:


示例如下:


image-20220925201808649




StateListDrawable


用于为不同的 View状态 引用不同的 Drawable ,比如在View 按下 时,View 禁用 时等。


xml中的标签:


常用的属性如下:




  • constantSize


    表示其固有大小是否随着状态而改变。


    因为每次切换状态时,都会伴随着 Drawable 的改变,如果此时不是用于背景,则如果 Drawable 的固有大小不一致,则会导致StateListDrawable 的大小发生变化。如果此值为 true ,则表示当前 StateDrawable 的固有大小是当前其内部所有 Drawable 中最大值。反之,则根据状态决定;




  • android:dither


    是否开启抖动效果,用于在低质量屏幕上获得较好的显示效果;




  • variablePadding


    表示padding是否随着状态而改变,true表示跟随状态而决定,取决于当前显示的drawable,false则选取drawable集合中padding最大值。




示例如下:









image-20220925210028927statelaist



LevelListDrawable


用于根据不同的等级表示一个 Drawable 集合。


默认等级范围为0,最小为0,最大为10000,可以在View中使用 Drawable 从而设置相应的 level 切换不同的 Drawable。如果这个drawable被用于ImageView 的 前景Drawable,还可以通过 ImageView.setImageViewLevel 来切换。


xml中的标签:


示例代码如下:


image-20220925210620317


在代码中即可通过 setLevel切换。


 view.background.level = 10
view.background.level = 200



TransitaionDrawable


用于实现两个 Drawable 之间的淡入淡出效果。


xml中的标签:


示例如下:









image-20220925213450558translition



InsetDrawable


用于将其他 Drawable 内嵌到自己当中,并可以在四周留出一定的间距。比如当某个 View 希望自己的背景比自己的实际区域小时,可以采用这个 Drawable ,当然采用 LayerDrawable 也可以实现。


xml中的标签:


其属性分别如下:



  • android:inset 表示四边内凹大小

  • android:insetTop 表示顶部内凹大小

  • android:insetLeft 表示左边内凹大小

  • android:insetBottom 表示底部内凹大小

  • android:insetRight 表示右边内凹大小


image-20220925215737375




ScaleDrawable


用于根据等级(level)将指定的 Drawable 缩放到一定比例。


xml中的标签:


相应的属性如下所示:




  • android:scaleGravity


    类似于与android:gravity




  • android:scaleWidth


    指定宽度的缩放比例(相对于原drawable缩放了多少)




  • android:scaleHeight


    指定高度的缩放比例(相对于原drawable缩放了多少)




  • android:level(minSdk>=24)


    指定缩放等级,默认为0,即最小,最高10000,此值越大,最终显示的drawable越大





需要注意的是,当level为0时,其不会显示,所以我们使用ScaleDrawable时,需要在代码中,将 drawable.level 调为1。



示例如下:


<?xml version="1.0" encoding="utf-8"?>
<scale xmlns:android="http://schemas.android.com/apk/res/android"
android:drawable="@drawable/level2_drawable"
android:level="1"
android:scaleWidth="70%"
android:scaleHeight="70%"
android:scaleGravity="center" />



ClipDrawable


用于根据当前等级(level)来裁剪另一个 Drawable


xml中的标签:


具体属性如下:




  • android:drawable


    需要裁剪的drawable




  • android:clipOrientation


    裁剪方向,有水平(horizontal)、竖直(vertical) 两种




  • android:level(minSdk>=24)


    设置等级,为0时表示完全裁剪(即隐藏),值越大,裁剪的越小。最大值10000(即不裁剪,原图)。




  • android:gravity



































































    参数含义
    top内部drawable位于容器顶部,不改变大小。ps: 竖直裁剪时,则从底部开始裁剪。
    bottom内部drawable位于容器底部,不改变大小。ps: 竖直裁剪时,则从顶部开始裁剪。
    left(默认值)内部drawable位于容器底部,不改变大小。ps: 水平裁剪时,则从顶部开始裁剪。
    right内部drawable位于容器右边,不改变大小。ps: 水平裁剪时,从右边开始裁剪。
    start同left
    end同right
    center使内部drawable在容器中居中,不改变大小。 ps:竖直裁剪时,从上下同时开始裁剪;水平裁剪时,从左右同时开始。
    center_horizontal内部的drawable在容器中水平居中,不改变大小。 ps:水平裁剪时,从左右两边同时开始裁剪。
    center_vertical内部的drawable在容器中垂直居中,不改变大小。 ps:竖直裁剪时,从上下两边同时开始裁剪。
    fill使内部drawable填充满容器。 ps:仅当level为0(0表示ClipDrawable被完全裁剪,即不可见)时,才具有裁剪行为。
    fill_horizontal使内部drawable在水平方向填充容器。 ps:如果水平裁剪,仅当level为0时,才具有裁剪行为。
    fill_vertical使内部drawable在竖直方向填充容器。 ps:如果垂直裁剪,仅当level为0时,才具有裁剪行为。
    clip_horizontal竖直方向裁剪。
    clip_vertical竖直方向裁剪。



示例如下:


image-20220926214129677


image-20220926213734053




自定义Drawable


通常情况下,我们往往用不到自定义 Drawable ,主要源于Android已经提供了很多通常会用到的功能,不过了解自定义 Drawable 在某些场景下可以非常便于我们开发体验。


自定义 Drawable 也很简单,我们只需要继承 Drawable 即可,从而实现:




  • draw()


    实现自定义的绘制。


    如果要获取当前 Drawable 绘制的边界大小,可以通过 getBounds() 获取;


    如果需要获取当前 Drawable 的中心点,也可以通过 getBounds().exactCenterX() ,或者 getBounds().centerX() ,区别在于前者用于获取精确位置;




  • setAlpha()


    设置透明度;




  • setColorFilter()


    设置滤镜效果;




  • getOpacity()


    返回当前 Drawable 的透明度;




比如画一个类似的 ProgressBar ,因为其是一个 Drawable ,所以可以用作任意的 View


class CustomCircleProgressDrawable : Drawable(), Animatable {

private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private val rectF = RectF()
private var progress = 0F
private val valueAnimator by lazy(LazyThreadSafetyMode.NONE) {
ValueAnimator.ofFloat(0f, 1f).apply {
duration = 2000
repeatCount = Animation.INFINITE
interpolator = LinearInterpolator()
addUpdateListener {
progress = it.animatedValue as Float
invalidateSelf()
}
}
}

init {
paint.style = Paint.Style.STROKE
paint.strokeWidth = 10f
paint.strokeCap = Paint.Cap.ROUND
paint.color = Color.GRAY
start()
}

override fun draw(canvas: Canvas) {
var min = (min(bounds.bottom, bounds.right) / 2).toFloat()
paint.strokeWidth = min / 10
min -= paint.strokeWidth * 3
val centerX = bounds.exactCenterX()
val centerY = bounds.exactCenterY()
rectF.left = centerX - min
rectF.right = centerX + min
rectF.top = centerY - min
rectF.bottom = centerY + min
paint.color = Color.GRAY
canvas.drawArc(rectF, -90f, 360f, false, paint)
paint.color = Color.GREEN
canvas.rotate(360F * progress, centerX, centerY)
canvas.drawArc(rectF, -90F, 30F + 330F * progress, false, paint)
}

override fun setAlpha(alpha: Int) {
paint.alpha = alpha
invalidateSelf()
}

override fun setColorFilter(colorFilter: ColorFilter?) {
paint.colorFilter = colorFilter
invalidateSelf()
}

override fun getOpacity(): Int {
return PixelFormat.TRANSLUCENT
}

override fun start() {
if (valueAnimator.isRunning) return
valueAnimator.start()
}

override fun stop() {
if (valueAnimator.isRunning) valueAnimator.cancel()
}

override fun isRunning(): Boolean {
return valueAnimator.isRunning
}
}

原理也很简单,我们实现了 onDraw 方法,在其中利用 canvas 绘制了两个圆环,其中前者是作为背景,后者不断地利用属性动画进行变化,并且不断旋转 canvas ,从而实现类似进度条的效果。


效果如下:


Kapture 2022-09-28 at 23.07.28


实践推荐


比如我们现在有这样一个 View ,需要在左上角展示一个文字,背景是张图片,另外还有一个从顶部到下的半透明渐变阴影。


如下图所示:


image-20220927235824306


一般情况下,我们肯定会不假思索的写出如下代码。


image-20220928234411154


上述写法没有问题,但其并不是所有场景的最推荐方式。比如这种样式此时需要在 RecyclerView 中展示呢?


所以,此时就可以利用 Drawable 简化我们的 View 层级,改造思路如下:


image-20220929101205068


如上所示,相对于最开始,我们将布局层级由 3 层降低为了 1 层,对于性能的提升也将指数级上升。


现在有同学可能要问了,你的这个 View 很简单,自定义一个 Drawable 还好说,那 View 很复杂呢?难不成我真要纯纯自定义吗?


要回答这个问题,其实很简单,我们要明确 Drawable 的意义,其只是一个可绘制的图像 。过于复杂的View,我们可以将其拆分为多个层级,然后对于其中纯展示的View,使用 Drawable 降低其复杂度。


从某个角度来说,Drawable也可以是我们自定义View的好帮手。



总结


合理利用 Drawable 会很大程度提高我们的使用体验。相应的,对于布局优化,Drawable 也是一大利器。问题回到文章最开始,如果现在再问你。Drawable 到底是什么? 如何自定义一个 Drawable ? 如何利用其做一些骚操作?我想,这都不是问题。


参考


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

Flutter 动画剖析(一) 彻底掌握动画的使用

动画定义 早期的动画片是利用大量图片进行快速切换从而达到一种看似连续的动画效果,这就是最早期的帧动画,利用人的视觉延迟产生的一种连续的效果,其实现在的动画也是这个原理,在同一时间屏幕进行多次有规律的渲染次数,渲染次数越多,动画就越流畅,也就是我们平常说的屏幕刷...
继续阅读 »

动画定义


早期的动画片是利用大量图片进行快速切换从而达到一种看似连续的动画效果,这就是最早期的帧动画,利用人的视觉延迟产生的一种连续的效果,其实现在的动画也是这个原理,在同一时间屏幕进行多次有规律的渲染次数,渲染次数越多,动画就越流畅,也就是我们平常说的屏幕刷新率。


本篇文章主旨让大家在使用Flutter动画的过程中游刃有余,不对具体源码进行解析。



  • 动画关键属性:动画时长、动画轨迹。



动画其实就是对象在规定的时间内进行的特定规则运动的表现。所以我们需要关心的核心属性就是动画时长和动画轨迹。



理解了动画实现的原理,所谓万变不离其宗,Flutter动画亦是如此,下面首先看下Flutter中使用动画的几个关键类。


动画核心类:


AnimationController 动画控制器


用来设置动画时长、动画开始结束数值、控制开始结束动画等操作。


构造方法以及常用方法:


_controller = AnimationController(
vsync: this,//设置Ticker 动画帧的回调函数
duration: const Duration(milliseconds: 2000),// 正向动画时长 //2s
reverseDuration: const Duration(milliseconds: 2000),// 反向动画时长 //2s
lowerBound: 0,// 开始动画数值 double类型
upperBound: 1.0,// 结束动画数值 double类型
animationBehavior: AnimationBehavior.normal ,// 动画器行为 是否重复动画 两个枚举值
debugLabel: "缩放动画",// 调试标签 动画过多时方便调式,toString时显示
// _controller.toString;
//输出: AnimationController#9d121(▶ 0.000; for 缩放动画)➩Tween<double>(0.0 → 1.0)➩0.0
);
// 常用方法:
// 监听动画运动
_controller.addListener(() { });
// 监听动画开始、停止等状态
_controller.addStatusListener((status) {
// dismissed 动画在起始点停止
// forward 动画正在正向执行
// reverse 动画正在反向执行
// completed 动画在终点停止
if (status == AnimationStatus.completed) {
_controller.reverse(); //反向执行 100-0
} else if (status == AnimationStatus.dismissed) {
_controller.forward(); //正向执行 0-100
}
});
// 启动动画
// _controller.forward();//正向开始动画
// _controller.reverse();//反向开始动画
_controller.repeat(); // 无限循环开始动画

vsync参数需要类混入:

SingleTickerProviderStateMixinTickerProviderStateMixin,如果页面内只有一个动画控制器使用第一个,多个控制器使用第二个。


AnimatedBuilder 实现动画组件核心类


一般情况下,我们需要将需要有动画效果的组件上包裹一层AnimatedBuilder从而监听动画控制器更新数据,内部也是通过有状态部件监听不断刷新页面实现。

构造方法:


const AnimatedBuilder({
Key? key,
required Listenable animation,//动画控制器
required this.builder,// 返回动画
this.child,// 传递给build的child子组件
})

以上两个组件就可以实现简单的动画效果了,下面我们使用AnimationControllerAnimatedBuilder实现一个简单的缩放动画。使FlutterLogo组件大小不断变化。


// 开启动画
_controller.repeat(); // 无限循环开始动画

Center(
child: AnimatedBuilder(
child: FlutterLogo(),
animation: _controller,
builder: (context, child) {
return Container(
width: 100 * _controller.value,
height: 100 * _controller.value,
child: child,
);
}),
)

效果:

Oct-15-2022 14-27-43.gif

可以看到组件通过控制器0-1不断变化,logo大小也发生了变化,也就简单实现了缩放的效果。

注:AnimatedBuilder是实现的局部组件刷新,并不会触发本身的build方法。


Animation<T> 声明动画


以上我们通过控制器实现了简单的缩放动画效果,但是我们发现开始和结束的数据只能是double类型的数字,中间的变化状态是需要我们来进行计算的,如果遇到较为复杂的过渡变化,计算也会同样变得复杂,那么为了解决这个问题,Animation<T>应运而生,该类主要用来声明控制动画运动的数据类,数据为泛型类型,可自定义。


有了这个类,我们就可以实现自定义数据算法的过渡效果,例如颜色的渐变。

Animation一般和Tween配合使用。


Tween<T> 实现声明动画


为对象在开始和结束中间运动时变化的过程类,也称为补间动画,泛型和Animation一致,通过Animation给定泛型,生成Tween类设置动画开始和动画结束数据并调用animate方法设置动画控制器。

一般情况,动画开始和结束我们用0 ~ 1 表示,实际上,我们也可以使用其他数据设置开始和结束数据。例如颜色过渡ColorTween、大小过渡SizeTween、矩形过渡RectTween变化等,而无需关心运动过程中的数值是如何计算的,因为这些官方已经帮我们计算好了。
源码中可以看到,这些扩展的Tween类都实现了lerp方法。


例如颜色渐变实现:


/// 返回0 ~ 1 颜色渐变过程中的色值。
@override
Color? lerp(double t) => Color.lerp(begin, end, t);


static Color? lerp(Color? a, Color? b, double t) {
///...略
// 颜色渐变算法 具体算法可以翻代码自行查看 都是数学知识
return Color.fromARGB(
_clampInt(_lerpInt(a.alpha, b.alpha, t).toInt(), 0, 255),
_clampInt(_lerpInt(a.red, b.red, t).toInt(), 0, 255),
_clampInt(_lerpInt(a.green, b.green, t).toInt(), 0, 255),
_clampInt(_lerpInt(a.blue, b.blue, t).toInt(), 0, 255),
);
}

举例:


// 开始动画
_controller.repeat(reverse: true); // 无限循环开始动画 结束倒放为true
/// 颜色渐变动画
Animation<Color?> animation;// 声明动画,数据为Color颜色
animation = ColorTween(begin: Colors.red, end: Colors.yellow).animate(_controller);// 实现动画,设置动画由红向黄渐变
// Size大小变化
Animation<Size?> animation2;
animation2 = SizeTween(begin: Size(100,50), end:Size(50,100)).animate(cure);


效果:

Oct-15-2022 14-53-06.gif
Oct-15-2022 15-29-28.gif
当然我们也可以自定义补间动画的过程,实现lerp方法,这里就考验你数学知识的掌握了,就不展开了,掌握原理即可。


Curve & CurvedAnimation 动画运动曲线


上面的动画效果虽然实现了复杂过程的变化,但是还缺少我们动画的核心属性,就是运动轨迹,因为上面没有设置,默认的运动轨迹是线性变化的,所以给我们的效果都是非常平稳的。如果实现更为丰富的动画效果,那么Curve应运而生,Curve是一个数值转换器,可以理解为方程式,默认y=x;它可以让我们运动过程不再是匀速变化,而是让运动过程可以拥有加速、减速、越界等变化,并且Curves里内置了非常丰富的运动轨迹可以直接使用。 在之前的文章也有用到过。


CurvedAnimationCurve类的具体使用,将非线性曲线应用到动画中,使用也非常的简单。只需要将动画控制器赋值给CurvedAnimation,上方Tweenanimate方法里设置 CurvedAnimation即可。

代码:


//构造
CurvedAnimation({
required this.parent,// 动画控制器
required this.curve,// 正向动画曲线
this.reverseCurve,// 反向动画曲线
});

//自定义运动曲线
CurvedAnimation cure = CurvedAnimation(parent: _controller, curve: Curves.easeIn);

// 使用 将_controller替换为cure
Animation<Color> animation;
animation = Tween<Color>(begin: Colors.black, end: Colors.white).animate(cure);


可以看到下方有非常丰富的曲线效果。
image.png

源码注释里有mp4效果演示,想方便了解效果可以看这篇文章。
Flutter 动画曲线Curves 效果一览。


自定义Curves


随便点击去一个Cubic类,实现方法很简单,


从源码中可以看到 Curve里有可以实现两个方法,官方建议实现transformInternal方法,因为transform方法内部直接返回了transformInternal这个方法。
image.png
那么实现就很简单了,定义一个类,继承Curves,实现transformInternal方法即可。transformInternal就可以将我们给定的数值转换为我们想要的数值。


class MyCurve extends Curve {
@override
double transformInternal(double x) {
// 自定义变化曲线
// 默认 y= x; 线性运动
// y = x的立方。这里可以理解为定义方程 x可以理解为0-1的变化过程
// y即是返回0-1变化的的自定义算法,无需关心具体的动画运动轨迹是如何计算的。
return x * x * x;
}
}

注:Curve只负责0 ~ 1(也就是动画开始 ~ 动画结束)的变化曲线,无论任何数据驱动的动画我们都可以用0 ~ 1来表示运动曲线。具体的过渡算法我们无需关心,那是补间动画需要做的事情,内置的补间动画Flutter已经帮我们算了,使用也非常的方便。


其他 内置常用动画组件的使用


其实在Flutter中还内置了我们常用的动画效果组件,例如平移、渐变、缩放等组件,实现过程原理基本相当,区别是我们不需要自己计算了,直接设置动画器即可。


部分内置动画使用:


1、平移动画 SlideTransition


根据组件自身大小进行平移,接收Offset数据,分别代表自身组件大小的倍数。


// 构造
const SlideTransition({
super.key,
required Animation<Offset> position,
this.transformHitTests = true,
this.textDirection,//阅读习惯方向
this.child,
})

2、渐变动画 FadeTransition


渐变动画一般指组件透明度渐变效果,接收double类型,0 ~ 1为完全透明 ~ 完全不透明。


const FadeTransition({
super.key,
required Animation<double> opacity,
this.alwaysIncludeSemantics = false,
super.child,
})

3、缩放动画 ScaleTransition


缩放动画接收double数据,0 ~ 1 为最小到最大,可以指定缩放中心。


const ScaleTransition({
super.key,
required Animation<double> scale,
this.alignment = Alignment.center,//缩放中心
this.filterQuality,//过滤器质量
this.child,
})

4、旋转动画 RotationTransition


旋转动画一般指平面二维的旋转,接收double参数,0 ~1为旋转一圈,同样可指定旋转中心。


const RotationTransition({
super.key,
required Animation<double> turns,
this.alignment = Alignment.center,
this.filterQuality,
this.child,
})

5、3D旋转动画


3D动画系统没有现成的,需要我们使用矩阵变换自行计算,其实也很简单,通过Matrix4类设置矩阵变换即可,下方为绕y轴进行旋转,范围是0~2pi。


AnimatedBuilder(
child: child,
animation: animation,
builder: (context, child) {
return Transform(
alignment: Alignment.center, //相对于坐标系原点的对齐方式
transform: Matrix4.identity()
..rotateX(0)//x轴不变
..rotateY(animation.value),//绕y轴旋转,0-2pi
child: Container(width: 100, height: 100, child: child));
});

5、组合动画


将上面所有动画效果组合起来也很简单,将以上动画通过子组件进行嵌套即可,最终的子组件为我们动画所需的组件。

核心代码:


animation = Tween(begin: 0.0, end: 1.0).animate(cure);
animation2 = Tween<Offset>(begin: Offset(0.0, 0.0), end: Offset(1.0, 0.0)).animate(cure);
animation3 = Tween(begin: 0.0, end: 1.0).animate(cure);
animation4 = Tween(begin: 0.0, end: pi * 2).animate(cure);

// // 平移
SlideTransitionLogo(
animation: animation2,
// 渐变
child: FadeTransition(
opacity: animation3,
// 二维旋转
child: RotationTransitionLogo(
// 缩放
child: ScaleTransition(
// 3D旋转
child: AnimatedBuilder(
child: FlutterLogo(),
animation: animation4,
builder: (context, child) {
return Transform(
alignment: Alignment.center, //相对于坐标系原点的对齐方式
transform: Matrix4.identity()
..rotateX(0)
..rotateY(animation4.value),
child: Container(width: 100, height: 100, child: child));
}),
scale: animation,
),

animation: animation3,
),将
),
),


效果:

Oct-15-2022 16-34-21.gif


其实系统内置的还有些其他现成的动画效果,有兴趣的小伙伴可以自己研究下,原理基本相同。


自定义动画效果


自定义动画一般和自绘制结合使用,根据绘制的组件和动画的运动曲线来达到我们想要的效果。下一篇有时间剖析下动画与绘制结合使用的方法与细节。


总结


动画归根结底是让数据不断变化来驱使UI产生变化,重点的就是我们如何处理这个变化过程中的数据,以上是对于Flutter动画使用方面的一些总结,希望对你有所帮助~ 如有疑问,欢迎指正。


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

Android性能优化 - 从SharedPreferences跨越到DataStore

再谈SharedPreferences 对于android开发者们来说,SharedPreferences已经是一个有足够历史的话题了,之所以还在性能优化这个专栏中再次提到,是因为在实际项目中还是会有很多使用到的地方,同时它也有足够的“坑”,比如常见的主进程阻...
继续阅读 »

再谈SharedPreferences


对于android开发者们来说,SharedPreferences已经是一个有足够历史的话题了,之所以还在性能优化这个专栏中再次提到,是因为在实际项目中还是会有很多使用到的地方,同时它也有足够的“坑”,比如常见的主进程阻塞,虽然SharedPreferences 提供了异步操作api apply,但是apply方法依旧有可能造成ANR


public void apply() {
final long startTime = System.currentTimeMillis();

final MemoryCommitResult mcr = commitToMemory();
final Runnable awaitCommit = new Runnable() {
@Override
public void run() {
try {
mcr.writtenToDiskLatch.await();
} catch (InterruptedException ignored) {
}

if (DEBUG && mcr.wasWritten) {
Log.d(TAG, mFile.getName() + ":" + mcr.memoryStateGeneration
+ " applied after " + (System.currentTimeMillis() - startTime)
+ " ms");
}
}
};

QueuedWork.addFinisher(awaitCommit);

Runnable postWriteRunnable = new Runnable() {
@Override
public void run() {
awaitCommit.run();
QueuedWork.removeFinisher(awaitCommit);
}
};
// 写入队列
SharedPreferencesImpl.this.enqueueDiskWrite(mcr, postWriteRunnable);

// Okay to notify the listeners before it's hit disk
// because the listeners should always get the same
// SharedPreferences instance back, which has the
// changes reflected in memory.
notifyListeners(mcr);
}

我们可以看到我们的runnable被写入了队列,而这个队列会在handleStopService()handlePauseActivity()handleStopActivity()  的时候会一直等待 apply() 方法将数据保存成功,否则会一直等待,从而阻塞主线程造成 ANR。


@Override
public void handlePauseActivity(ActivityClientRecord r, boolean finished, boolean userLeaving,
int configChanges, PendingTransactionActions pendingActions, String reason) {
if (userLeaving) {
performUserLeavingActivity(r);
}

r.activity.mConfigChangeFlags |= configChanges;
performPauseActivity(r, finished, reason, pendingActions);

// Make sure any pending writes are now committed.
if (r.isPreHoneycomb()) {
// 这里就是元凶
QueuedWork.waitToFinish();
}
mSomeActivitiesChanged = true;
}

谷歌官方也有解释
image.png


虽然QueuedWork在android 8中有了新的优化,但是实际上依旧有ANR的出现,在低版本的机型上更加出现频繁,所以我们不可能把sp真的逃避掉。


目前业内有很多替代的方案,就是采用MMKV去解决,但是官方并没有采用像mmkv的方式去解决,而是另起炉灶,在jetpack中引入DataStore去替代旧时代的SharedPreferences。


DataStore



Jetpack DataStore 是一种数据存储解决方案,允许您使用协议缓冲区存储键值对或类型化对象。DataStore 使用 Kotlin 协程和 Flow 以异步、一致的事务方式存储数据。



DataStore 提供两种不同的实现:Preferences DataStore 和 Proto DataStore(基于protocol buffers)。我们这里主要以Preferences DataStore作为分析,同时在kotlin中,datastore采取了flow的良好架构,进行了内部的调度实现,同时也提供了java兼容版本(采用RxJava实现)


使用例子


val Context.dataStore : DataStore<Preferences> by preferencesDataStore(“文件名”)

因为datastore需要依靠协程的环境,所以我们可以有以下方式


读取
CoroutineScope(Dispatchers.Default).launch {
context.dataStore.data.collect {
value = it[booleanPreferencesKey(key)] ?: defValue
}
}
写入
CoroutineScope(Dispatchers.IO).launch {
context.dataStore.edit { settings ->
settings[booleanPreferencesKey(key) ] = value
}

}

其中booleanPreferencesKey代表着存入的value是boolean类型,同样的,假设我们需要存入的数据类型是String,相应的key就是通过stringPreferencesKey(key名) 创建。同时因为返回的是flow,我们是需要调用collect这种监听机制去获取数值的改变,如果想要像sp一样采用同步的方式直接获取,官方通过runBlocking进行获取,比如


val exampleData = runBlocking { context.dataStore.data.first() }

DataStore原理


DataStore提供给了我们非常简洁的api,所以我们也能够很快速的入门使用,但是其中的原理实现,我们是要了解的,因为其创建过程十分简单,我们就从数据更新(context.dataStore.edit)的角度出发,看看DataStore究竟做了什么。


首先我们看到edit方法


public suspend fun DataStore<Preferences>.edit(
transform: suspend (MutablePreferences) -> Unit
): Preferences {
return this.updateData {
// It's safe to return MutablePreferences since we freeze it in
// PreferencesDataStore.updateData()
it.toMutablePreferences().apply { transform(this) }
}
}

可以看到edit方法是一个suspend的函数,其主要的实现就是依靠updateData方法的调用



interface DataStore<T> 中:

public suspend fun updateData(transform: suspend (t: T) -> T): T

我们分析到DataStore是有两种实现,我们要看的就是Preferences DataStore的实现,其实现类是


internal class PreferenceDataStore(private val delegate: DataStore<Preferences>) :
DataStore<Preferences> by delegate {
override suspend fun updateData(transform: suspend (t: Preferences) -> Preferences):
Preferences {
return delegate.updateData {
val transformed = transform(it)
// Freeze the preferences since any future mutations will break DataStore. If a user
// tunnels the value out of DataStore and mutates it, this could be problematic.
// This is a safe cast, since MutablePreferences is the only implementation of
// Preferences.
(transformed as MutablePreferences).freeze()
transformed
}
}
}

可以看到PreferenceDataStore中updateData方法的具体实现其实在delegate中,而这个delegate的创建是在



PreferenceDataStoreFactory中

public fun create(
corruptionHandler: ReplaceFileCorruptionHandler<Preferences>? = null,
migrations: List<DataMigration<Preferences>> = listOf(),
scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
produceFile: () -> File
): DataStore<Preferences> {
val delegate = DataStoreFactory.create(
serializer = PreferencesSerializer,
corruptionHandler = corruptionHandler,
migrations = migrations,
scope = scope
) {
忽略
}
return PreferenceDataStore(delegate)
}

DataStoreFactory.create方法中:


  public fun <T> create(
serializer: Serializer<T>,
corruptionHandler: ReplaceFileCorruptionHandler<T>? = null,
migrations: List<DataMigration<T>> = listOf(),
scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
produceFile: () -> File
): DataStore<T> =
SingleProcessDataStore(
produceFile = produceFile,
serializer = serializer,
corruptionHandler = corruptionHandler ?: NoOpCorruptionHandler(),
initTasksList = listOf(DataMigrationInitializer.getInitializer(migrations)),
scope = scope
)
}

DataStoreFactory.create 创建的其实是一个SingleProcessDataStore的对象,SingleProcessDataStore同时也是继承于DataStore,它就是所有DataStore背后的真正的实现者。而它的updateData方法就是一切谜团解决的钥匙。


    
override suspend fun updateData(transform: suspend (t: T) -> T): T {
val ack = CompletableDeferred<T>()
val currentDownStreamFlowState = downstreamFlow.value

val updateMsg =
Message.Update(transform, ack, currentDownStreamFlowState, coroutineContext)

actor.offer(updateMsg)

return ack.await()
}

我们可以看到,update方法中,有一个叫 ack的 CompletableDeferred对象,而CompletableDeferred,是继承于Deferred。我们到这里就应该能够猜到了,这个Deferred对象不正是我们协程中常用的异步调用类嘛!它提供了await操作允许我们等待异步的结果。
最后封装好的Message被放入actor.offer(updateMsg) 中,actor是消息处理类对象,它的定义如下


internal class SimpleActor<T>(
/**
* The scope in which to consume messages.
*/
private val scope: CoroutineScope,
/**
* Function that will be called when scope is cancelled. Should *not* throw exceptions.
*/
onComplete: (Throwable?) -> Unit,
/**
* Function that will be called for each element when the scope is cancelled. Should *not*
* throw exceptions.
*/
onUndeliveredElement: (T, Throwable?) -> Unit,
/**
* Function that will be called once for each message.
*
* Must *not* throw an exception (other than CancellationException if scope is cancelled).
*/
private val consumeMessage: suspend (T) -> Unit
) {
private val messageQueue = Channel<T>(capacity = UNLIMITED)

我们看到,我们所有的消息会被放到一个叫messageQueue的Channel对象中,Channel其实就是一个适用于协程信息通信的线程安全的队列。


最后我们回到主题,offer函数干了什么


    省略前面

do {
// We don't want to try to consume a new message unless we are still active.
// If ensureActive throws, the scope is no longer active, so it doesn't
// matter that we have remaining messages.
scope.ensureActive()

consumeMessage(messageQueue.receive())
} while (remainingMessages.decrementAndGet() != 0)

其实就是通过consumeMessage消费了我们的消息。到这里我们再一次回到我们DataStore中的SimpleActor实现对象


private val actor = SimpleActor<Message<T>>(
scope = scope,
onComplete = {
it?.let {
downstreamFlow.value = Final(it)
}
// We expect it to always be non-null but we will leave the alternative as a no-op
// just in case.

synchronized(activeFilesLock) {
activeFiles.remove(file.absolutePath)
}
},
onUndeliveredElement = { msg, ex ->
if (msg is Message.Update) {
// TODO(rohitsat): should we instead use scope.ensureActive() to get the original
// cancellation cause? Should we instead have something like
// UndeliveredElementException?
msg.ack.completeExceptionally(
ex ?: CancellationException(
"DataStore scope was cancelled before updateData could complete"
)
)
}
}
) {
consumeMessage 实际
msg ->
when (msg) {
is Message.Read -> {
handleRead(msg)
}
is Message.Update -> {
handleUpdate(msg)
}
}
}

可以看到,consumeMessage其实就是以lambada形式展开了,实现的内容也很直观,如果是Message.Update就调用了handleUpdate方法


private suspend fun handleUpdate(update: Message.Update<T>) {

// 这里就是completeWith调用,也就是回到了外部Deferred的await方法
update.ack.completeWith(
runCatching {

when (val currentState = downstreamFlow.value) {
is Data -> {
// We are already initialized, we just need to perform the update
transformAndWrite(update.transform, update.callerContext)
}

...

最后通过了transformAndWrite调用writeData方法,写入数据(FileOutputStream)


internal suspend fun writeData(newData: T) {
file.createParentDirectories()

val scratchFile = File(file.absolutePath + SCRATCH_SUFFIX)
try {
FileOutputStream(scratchFile).use { stream ->
serializer.writeTo(newData, UncloseableOutputStream(stream))
stream.fd.sync()
// TODO(b/151635324): fsync the directory, otherwise a badly timed crash could
// result in reverting to a previous state.
}

if (!scratchFile.renameTo(file)) {
throw IOException(
"Unable to rename $scratchFile." +
"This likely means that there are multiple instances of DataStore " +
"for this file. Ensure that you are only creating a single instance of " +
"datastore for this file."
)
}

至此,我们整个过程就彻底分析完了,读取数据跟写入数据类似,只是最后调用的处理函数不一致罢了(consumeMessage 调用handleRead),同时我们也分析出来handleUpdate的update.ack.completeWith让我们也回到了协程调用完成后的世界。


SharedPreferences全局替换成DataStore


分析完DataStore,我们已经有了足够的了解了,那么是时候将我们的SharedPreferences迁移至DataStore了吧!


旧sp数据迁移


已存在的sp对象数据可以通过以下方法无缝迁移到datastore的世界


dataStore = context.createDataStore( name = preferenceName, migrations = listOf( SharedPreferencesMigration( context, "sp的名称" ) ) )   

无侵入替换sp为DataStore


当然,我们项目中可能会存在很多历史遗留的sp使用,此时用手动替换会容易出错,而且不方便,其次是三方库所用到sp我们也无法手动更改,那么有没有一种方案可以无需对原有项目改动,就可以迁移到DataStore呢?嗯!我们要敢想,才敢做!这个时候就是我们的性能优化系列的老朋友,ASM登场啦!


我们来分析一下,怎么把


val sp = this.getSharedPreferences("test",0)
val editor = sp.edit()
editor.putBoolean("testBoolean",true)
editor.apply()

替换成我们想要的DataStore,不及,我们先看一下这串代码的字节码


    LINENUMBER 24 L2
ALOAD 0
LDC "test"
ICONST_0
INVOKEVIRTUAL com/example/spider/MainActivity.getSharedPreferences (Ljava/lang/String;I)Landroid/content/SharedPreferences;
ASTORE 2

我们可以看到,我们的字节码中存在ALOAD ASTORE这种依赖于操作数栈环境的指令,就知道不能简单的实现指令替换,而是采用同类替换的方式去现实,即我们可以通过继承于SharedPreferences,在自定义SharedPreferences中实现DataStore的操作,严格来说,这个自定义SharedPreferences,其实就相当于一个壳子了。这种替换方式在Android性能优化-线程监控与线程统一也有使用到。


image.png


我们来看一下自定义的SharedPreferences操作,这里以putBoolean相关操作举例子



class DataPreference(val context: Context,name:String):SharedPreferences {
val Context.dataStore : DataStore<Preferences> by preferencesDataStore(name)

override fun getBoolean(key: String, defValue: Boolean): Boolean {
var value = defValue
runBlocking {

}
runBlocking {
context.dataStore.data.first {
value = it[booleanPreferencesKey(key)] ?: defValue
true
}
}
// CoroutineScope(Dispatchers.Default).launch {
// context.dataStore.data.collect {
//
// value = it[booleanPreferencesKey(key)] ?: defValue
// Log.e("hello","value os $value")
// }
// }

return value
}


override fun edit(): SharedPreferences.Editor {
return DataEditor(context)
}


inner class DataEditor(private val context: Context): SharedPreferences.Editor {


override fun putBoolean(key: String, value: Boolean): SharedPreferences.Editor {
CoroutineScope(Dispatchers.IO).launch {
context.dataStore.edit { settings ->
settings[booleanPreferencesKey(key) ] = value
}
}
return this
}



override fun commit(): Boolean {
// 空实现即可
}

override fun apply() {
// 空实现即可
}
}
}

因为putBoolean中其实就已经把数据存好了,所有我们的commit/apply都可以以空实现的方式替代。同时我们也声明一个扩展函数


StoreTest.kt
fun Context.getDataPreferences(name:String,mode:Int): SharedPreferences {
return DataPreference(this,name)
}

字节码部分操作也比较简单,我们只需要把原本的 INVOKEVIRTUAL com/example/spider/MainActivity.getSharedPreferences (Ljava/lang/String;I)Landroid/content/SharedPreferences; 指令替换成INVOKESTATIC的StoreTestKt扩展函数getDataPreferences调用即可,同时由于接受的是SharedPreferences类型而不是我们的DataPreference类型,所以需要采用CHECKCAST转换。


static void spToDataStore(
MethodInsnNode node,
ClassNode klass,
MethodNode method
) {
println("init ===> " + node.name+" --"+node.desc + " " + node.owner)


if (node.name.equals("getSharedPreferences")&&node.desc.equals("(Ljava/lang/String;I)Landroid/content/SharedPreferences;")) {

MethodInsnNode methodHookNode = new MethodInsnNode(Opcodes.INVOKESTATIC,
"com/example/spider/StoreTestKt",
"getDataPreferences",
"(Landroid/content/Context;Ljava/lang/String;I)Landroid/content/SharedPreferences;",
false)

TypeInsnNode typeInsnNode = new TypeInsnNode(Opcodes.CHECKCAST, "android/content/SharedPreferences")
InsnList insertNodes = new InsnList()
insertNodes.add(methodHookNode)
insertNodes.add(typeInsnNode)
method.instructions.insertBefore(node, insertNodes)
method.instructions.remove(node)

println("hook ===> " + node.name + " " + node.owner + " " + method.instructions.indexOf(node))
}

}

方案的“不足”


当然,我们这个方案并不是百分比完美的


editor.apply()
sp.getBoolean

原因是如果采用这种方式apply()后立马取数据,因为我们替换后putBoolean其实是一个异步操作,而我们getBoolean是同步操作,所以就有可能没有拿到最新的数据。但是这个使用姿势本身就是一个不好的使用姿势,同时业内的滴滴开源Booster的sp异步线程commit优化也同样有这个问题。因为put之后立马get不是一个规范写法,所以我们也不会对此多加干预。不过对于我们DataStore替换后来说,也有更加好的解决方式


CoroutineScope(Dispatchers.Default).launch {
context.dataStore.data.collect {

value = it[booleanPreferencesKey(key)] ?: defValue
Log.e("hello","value os $value")
}
}

通过flow的异步特性,我们完全可以对value进行collect,调用层通过collect进行数据的收集,就能够做到万无一失啦(虽然也带来了侵入性)


总结


到这里,我们又完成了性能优化的一篇,sp迁移至DataStore的后续适配,等笔者有空了会写一个工具库(挖坑),虽然sp是一个非常久远的话题了,但是依旧值得我们分析,同时也希望DataStore能够被真正利用起来,适当的选用DataStore与MMKV。


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

常用到的几个Kotlin开发技巧,减少对业务层代码的入侵

善用@get/@set: JvmName()注解并搭配setter/getter使用 假设当前存在下面三个类代码: #Opt1 public class Opt1 { private String mContent; public Stri...
继续阅读 »

善用@get/@set: JvmName()注解并搭配setter/getter使用


假设当前存在下面三个类代码:


#Opt1


public class Opt1 {

private String mContent;

public String getRealContent() {
return mContent;
}

public void setContent(String mContent) {
this.mContent = mContent;
}
}

#Opt2


public class Opt2 {

public void opt2(Opt1 opt1) {
System.out.println(opt1.getRealContent());
}
}

@Opt3


public class Opt3 {

public void opt3(Opt1 opt1) {
System.out.println(opt1.getRealContent());
}
}

这个时候我想将Opt1类重构成kotlin,我们先看下通过AS的命令Convert Java File to Kotlin File自动转换的结果:


image.png


可以看到为了兼容Opt2Opt3的调用,直接把我的属性名给改成了realContent,kotlin会自动生成getRealContent()setRealContent()方法,这样Opt2Opt3就不用进行任何调整了,kotlin这样就显得太过于智能了。


这样看起来没啥问题,但是java重构kotlin,直接把属性名给我改了,并隐式生成了属性的set和get方法,对于java而言不使用的方法会报灰提示或者只有当前类使用AS会警告可以声明成private,但是对于kotlin生成的set、get方法是隐式的,容易忽略。


所以大家在使用Convert Java File to Kotlin File命令将java重构kotlin的结果一定不能抱有百分之百的信任,即使它很智能,但还是一定要细细的看下转换后的代码逻辑,可能还有不少的优化空间。


这个地方就得需要我们手动进行修改了,比如不想对外暴露修改这个字段的set方法,调整如下:


class Opt1 {
var realContent: String? = null
private set
}

再比如保持原有的字段名mContent,不能被改为realContent,同时又要保证兼容Opt2Opt3类的调用不能报错,且尽量避免去修改里面的代码,我们就可以做如下调整:


class Opt1 {
@get: JvmName("getRealContent")
var mContent: String? = null
private set
}

善用默认参数+@JvmOverloads减少模板代码编写


假设当前Opt1有下面的方法:


public String getSqlCmd(String table) {
return "select * from " + table;
}

且被Opt2Opt3进行了调用,这个时候如果有另一个类Opt3想要调用这个函数并只想从数据库查询指定字段,如果用java实现有两种方式:



  1. 直接在getSqlCmd()方法中添加一个查询字段参数,如果传入的值为null,就查询所有的字段,否则就查询指定字段:


public String getSqlCmd(String table, String name) {
if (TextUtils.isEmpty(name)) {
return "select * from " + table;
}
return "select " + name + " from " + table;
}

这样一来,是不是原本Opt2Opt3getSqlCmd()方法调用是不是需要改动,多传一个参数给方法,而在日常的项目开发中,有可能这个getSqlCmd()被几十个地方调用,难道你一个个的改过去?不太现实且是一种非常糟糕的实现。



  1. 直接在Opt1中新增一个getSqlCmd()的重载方法,传入指定的字段去查询:


public String getSqlCmd(String table,String name) {
return "select " + name + " from " + table;
}

这样做的好处就是不用调整Opt2Opt3getSqlCmd(String table)方法调用逻辑,但是会编写很多模板代码,尤其是getSqlCmd()这个方法体可能七八十行的情况下。


如果Opt1类代码减少即200-400行且不负责的情况下,我们可以将其重构成kotlin,借助于默认参数来实现方法功能增加又不用编写模板代码的效果(如果你的Java类上千行又很复杂,请谨慎转换成kotlin使用下面这种方式)。


@JvmOverloads
fun getSqlCmd(table: String, name: String? = null): String {
return "select ${if (name.isNullOrEmpty()) "*" else name} from $table"
}

添加默认参数name时还要添加@JvmOverloads注解,这样是为了保证java只传一个table参数也能正常调用。


通过上面这种方式,我们就能保证实现了方法功能增加,又不用改动Opt2Opt3对于getSqlCmd()方法的调用逻辑,并且还不用编写额外的模板代码,一举多得。


总结


本篇文章主要介绍了在java重构成kotlin过程中比较常用到的两个技巧,最终实现效果是减少对业务逻辑代码的入侵,希望能对你有所帮助。


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

搜索中常见数据结构与算法探究

1 前言ES 现在已经被广泛的使用在日常的搜索中,Lucene 作为它的内核值得我们深入研究,比如 FST,下面就用两篇分享来介绍一些本文的主题:第一篇主要介绍数据结构和算法基础和分析方法,以及一些常用的典型的数据结构;第二篇主要介绍图论,以及自动机,KMP,...
继续阅读 »

1 前言

ES 现在已经被广泛的使用在日常的搜索中,Lucene 作为它的内核值得我们深入研究,比如 FST,下面就用两篇分享来介绍一些本文的主题:

  1. 第一篇主要介绍数据结构和算法基础和分析方法,以及一些常用的典型的数据结构;
  2. 第二篇主要介绍图论,以及自动机,KMP,FST 等算法;
    下面开始第一篇

2 引言

“算法是计算机科学领域最重要的基石之一 “
“编程语言虽然该学,但是学习计算机算法和理论更重要,因为计算机算法和理论更重要,因为计算机语言和开发平台日新月异,但万变不离其宗的是那些算法和理论,例如数据结构、算法、编译原理、计算机体系结构、关系型数据库原理等等。“——《算法的力量》

2.1 提出问题

2.1.1 案例一

设有一组 N 个数而要确定其中第 k 个最大者,我们称之为选择问题。常规的解法如下:

  1. 该问题的一种解法就是将这 N 个数读进一个数组中,在通过某种简单的算法,比如冒泡排序法,以递减顺序将数组排序,然后返回位置 k 上的元素。
  2. 稍微好一点的算法可以先把前 k 个元素读入数组并对其排序。接着,将剩下的元素再逐个读入。当新元素被读到时,如果它小于数组中的第 k 个元素则忽略之,否则就将其放到数组中正确的位置上,同时将数组中的一个元素挤出数组。当算法终止时,位于第 k 个位置上的元素作为答案返回。

这两种算法编码都很简单,但是我们自然要问:哪个算法更好?哪个算法更重要?还是两个算法都足够好?使用 N=30000000 和 k=15000000 进行模拟将发现,两个算法在合理的时间量内均不能结束;每一种算法都需要计算机处理若干时间才能完成。
其实还有很多可以解决这个问题,比如二叉堆,归并算法等等。

2.2.2 案例二

输入是由一些字母构成的一个二维数组以及一组单词组成。目标是要找出字谜中的单词,这些单词可能是水平、垂直、或沿对角线上任何方向放置。下图所示的字谜由单词 this、two、fat 和 that 组成。

现在至少也有两种直观的算法来求解这个问题:

  1. 对单词表中的每个单词,我们检查每一个有序三元组(行,列,方向)验证是否有单词存在。这需要大量嵌套的 for 循环,但它基本上是直观的算法。
  2. 对于每一个尚未越出迷板边缘的有序四元组(行,列,方向,字符数)我们可以测试是否所指的单词在单词表中。这也导致使用大量嵌套的 for 循环。

上述两种方法相对来说都不难编码,但如果增加行和列的数量,则上面提出的两种解法均需要相当长的时间。

以上两个案例中,我们可以看到要写一个工作程序并不够。如果这个程序在巨大的数据集上运行,那么运行时间就变成了重要问题。

那么,使用自动机理论可以快速的解决这个问题,下一篇中给大家详细的分析。

3 数据结构与算法基础

3.1 数据结构基础

3.1.1 什么是数据结构

在计算机领域中,数据是信息的载体,是能够输入到计算机中并且能被计算机识别、存储和处理的符号的总称。数据结构是指数据元素和数据元素之间的相互关系或数据的组织形式。数据元素是数据的的基本单位,数据元素有若干基本项组成。

3.1.2 数据之间的关系

数据之前的关系分为两类:

  1. 逻辑关系
    表示数据之间的抽象关系,按每个元素可能具有的前趋数和直接后继数将逻辑结构分为线性结构和非线性结构。逻辑关系或逻辑结构有如下特点:
  • 只是描述数据结构中数据元素之间的联系规律;
  • 是从具体问题中抽象出来的数学模型,是独立于计算机存储器的(与硬件无关)

逻辑结构的分类如下:

  • 线性结构
  • 树形结构
  • 图状结构
  • 其他结构
  1. 物理关系
    逻辑关系在计算中的具体实现方法,分为顺序存储方法、链式存储方法、索引存储方法、散列存储方法。物理关系或物理结构有如下特点:
  • 是数据的逻辑结构在计算机存储其中的映像;
  • 存储结构是通过计算机程序来实现,因而是依赖于具体的计算机语言的;

物理结构分类如下:

  • 顺序结构
  • 链式结构
  • 索引结构

3.2 算法基础

3.2.1 基础概念

算法是为求解一个问题需要遵循的、被清楚指定的简单指令的集合。对于一个问题,一旦某种算法给定并且被确定是正确的,那么重要的一步就是确定该算法将需要多少诸如时间或空间等资源量的问题。如果一个问题的求解算法竟然需要长达一年时间,那么这种算法就很难能有什么用处。同样,一个需要若干个 GB 的内存的算法在当前的大多数机器上也是无法使用的。

3.2.2 数学基础

一般来说,估算算法资源消耗所需的分析是一个理论问题,因此需要一套数学分析法,我们先从数学定义开始。

  • 定理 1:如果存在正常数 c 和 n0,使得当 N>= n0 时,T (N) <= cf (N),则记为 T (N) = O (f (N))。
  • 定理 2:如果存在正常数 c 和 n0,使得当 N>=n0 时,T (N) <= cg (N),则记为 T (N) = Ω(g (N))。
  • 定理 3:T (N) = θ(h (N)) 当且仅当 T (N) = O (h (N)) 和 T (N) = Ω(h (N))。
  • 定理 4:如果对每一个正常数 c 都存在常数 n0 使得当 N>n0 时,T (N) < cp (N),则 T (N) = o (p (N))。

这些定义的目的是要在函数间建立一种相对的级别。给定两个函数,通常存在一些点,在这些点上一个函数的值小于另一个函数的值,因此,一般宣称 f (N)<g (N),是没有什么意义的。于是,我们比较他们的相对增长率。当将相对增长率应用到算法分析时,会明白它是重要的度量。

如果用传统的不等式来计算增长率,那么第一个定义 T (N) = O (f (N)) 是说 T (N) 的增长率小于或者等于 f (N) 的增长率。第二个定义 T (N) = Ω(g (N)) 是说 T (N) 增长率大于或者等于 g (N) 的增长率。第三个定义 T (N) = θ(h (N)) 是说 T (N) 的增长率等于 h (N) 的增长率。最后一个定义 T (N) = o (p (N)) 说的则是 T (N) 的增长率小于 p (N) 的增长率。他不同于大 O,因为大 O 包含增长率相同的可能性。

要证明某个函数 T (N) = O (f (N)) ,通常不是形式的使用这些定义,而是使用一些已知的结果(比如说 T (N) = O (log (N)))。一般来说,这就意味着证明是非常简单的计算而不应涉及微积分,除非遇到特殊情况。如下是常见的已知函数结果

  • c(常数函数)
  • logN(对数函数)
  • logN^2(对数平方函数)
  • N(线性函数)
  • NlogN
  • N^2(二次函数)
  • N^3(三次函数)
  • 2^N(指数函数)

在使用已知函数结果时,有几点需要注意:

  1. 首先,将常数或低阶项放进大 O 是非常坏的习惯。不要写成 T (N) = O (2*N^2) 或 T (N) = O (N^2 + N)。这两种情形下,正确的形式是 T (N) = O (N^2)。也就是说低阶项一般可以被忽略,而常数也可以弃掉。
  2. 其次,我们总能够通过计算极限 limN→∞f (N)/g (N)(极限公式)来确定两个函数 f (N) 和 g (N) 的相对增长率。该极限可以有四种可能的值:
    极限是 0:这意味着 f (N) = o (g (N))。
    极限是 c != 0: 这意味着 f (N) = θ(g (N))。
    极限是∞ :这意味着 g (N) = o (f (N))。
    极限摆动:二者无关。

3.2.3 复杂度函数

正常情况下的复杂度函数包含如下两种:
时间复杂度
空间复杂度

时间和空间的度量并没有一个固定的标准,但是在正常情况下,时间复杂度的单位基本上是以一次内存访问或者一次 IO 来决定。空间复杂度是指在算法执行过程中需要占用的存储空间。对于一个算法来说,时间复杂度和空间复杂度往往是相互影响,当追求一个好的时间复杂度时,可能会使空间复杂度变差,即可能占用更多的存储空间;反之,当追求一个较好的空间复杂度时,可能会使时间复杂度变差,即可能占用较长的运算时间。

3.3 知识储备

3.3.1 质数分辨定理(HashTree 的理论基础)

简单的说就是,n 个不同的质数可以分辨的连续数的个数和他们的乘机相同。分辨是指这些连续的整数不可能有相同的余数序列。

3.3.2 Hash 算法

1)Hash
Hash 一般翻译成散列,也可以直接音译成哈希,就是把任意长度的输入,通过散列算法变换成固定长度的输出,该输入就是散列值。不同的输入可能散列成相同的值,确定的散列值不可能确定一个输入。

  1. 常见的 Hash 算法
  • MD4:消息摘要算法;
  • MD5:消息摘要算法,MD4 的升级版本;
  • SHA-1:SHA-1 的设计和 MD4 相同原理,并模仿该算法
    自定义 HASH 算法:程序设计者可以自定义 HASH 算法,比如 java 中重写的 hashCode () 方法
  1. Hash 碰撞
    解决 Hash 碰撞常见的方法有一下几种:
  • 分离链接法(链表法):做法是将散列到同一个值的所有元素保留在一个表中,例如 JDK 中的 HashMap;
  • 探测散列表:当发生 Hash 碰撞时,尝试寻找另外一个单元格,直到知道到空的单元为止。包括:线性探测法,平方探测法,双散列。

3.3.3 树结构的基本概念

  • 树的递归定义:一棵树是一些节点的集合。这个集合可以是空集;若不是空集,则树由根节点 root 以及 0 个或多个非空的子树组成,这些子树中每一棵的根都被来自根 root 的一条有向的边所连接;
  • 树叶节点:没有儿子节点称为树叶;
  • 深度:对于任意节点 ni,ni 的深度为从根到 ni 的唯一路径的长;
  • 高度:对于任意节点 ni,ni 的高度为从 ni 到一片树叶的最长路径的长。
  • 树的遍历:树的遍历分为两种,先序遍历和后续遍历;

3.3.4 二叉搜索树

二叉搜索树是一棵二叉树,其中每个节点都不能有多于两个子节点。
对于二叉查找树的每一个节点 X,它的左子树中所有项的值都小于 X 节点中的项,而它的右子树中所有项的值大于 X 中的项;

4 常见数据结构与算法分析

4.1 线性数据结构

4.1.1 HashMap

  1. 总述
    HashMap 是开发中最常用的数据结构之一,数据常驻于内存中,对于小的数据量来说,HashMap 的增删改查的效率都非常高,复杂度接近于 O (1)。

  2. 数据结构和算法

  • HashMap 由一个 hash 函数和一个数组组成;
  • 数据插入,当进入到 map 的时候,根据 hash (key) 找到对应点位置,如果位置为空,直接保存,如果位置不为空,则使用链表的方式处理;为了解决遍历链表所增加的时间,JDK 中的链表在大小增大到 8 时,将会演变成红黑树以降低时间复杂度。为什么开始使用链表,后面使用红黑树:
    • 数据量较小的时候,链表的查询效率相对来说也比较高,使用红黑树占用空间比链表要大;
    • 为什么选择 8,请参考泊松分布;
  • 查找和删除的过程,同插入的过程类似;
  • HashMap 可以支持自动扩容,扩容机制需要看具体的实现;
  1. 优缺点
  • 优点:动态可变长存储数据,快速的查询速度,查询复杂度接近 O (1);
  • 缺点:只支持小数据量的内存查询;
  1. 使用场景
  • 在内存中小数据量的数据保存和快速查找;

4.1.2 Bloom Filter(布隆过滤器)

  1. 总述
    布隆过滤器算法为大数据量的查找提供了快速的方法,时间复杂度为 O (k),布隆过滤器的语义为:
  • 布隆过滤器的输出为否定的结果一定为真;
  • 布隆过滤器的输出为肯定的结果不一定为真;
  1. 数据结构和算法
    布隆过滤器的具体结构和算法为:
  • 布隆过滤器包含 k 个 hash 函数,每个函数可以把 key 散列成一个整数(下标);
  • 布隆过滤器包含了一个长度为 n 的 bit 数组(向量数组),每个 bit 的初始值为 0;
  • 当某个 key 加入的时候,用 k 个 hash 函数计算出 k 个散列值,并把数组中对应的比特置为 1;
  • 判断某个 key 是否在集合时,用 k 个 hash 函数算出 k 个值,并查询数组中对应的比特位,如果所有的 bit 位都为 1,认为在集合中;
  • 布隆过滤器的大小需要提前评估,并且不能扩容;

布隆过滤器的插入过程如下:

判断某个 key 是否在集合时,用 k 个 hash 函数算出 k 个值,并查询数组中对应的比特位,如果所有的 bit 位都为 1,认为在集合中

  • 布隆过滤器无法删除数据;
  • 布隆过滤器查询的时间复杂度为 O (k);
  • 布隆过滤器空间的占用在初始化的时候已经固定不能扩容。
  1. 优缺点
  • 优点:布隆过滤器在时间和空间上都有巨大的优势。布隆过滤器存储空间和插入 / 查找时间都是常数。布隆过滤器不需要存储数据本身,节省空间。
  • 缺点:布隆过滤器的缺点是有误差。元素越多误差越高。可以通过提高 hash 函数的个数和扩大 bit 数组的长度来降低误差率;
  1. 场景
  • 使用场景:缓存击穿,判断有无。

4.1.3 SkipList(跳表)

  1. 总述
    跳表是一种特殊的链表,相比一般的链表有更高的查找效率,可比拟二差查找树,平均期望的插入,查找,删除的时间复杂度都是 O (logN);

  2. 数据结构和算法
    跳表可视为水平排列(Level)、垂直排列(Row)的位置(Position)的二维集合。每个 Level 是一个列表 Si,每个 Row 包含存储连续列表中相同 Entry 的位置,跳表的各个位置可以通过以下方式进行遍历。

  • After (P):返回和 P 在同一 Level 的后面的一个位置,若不存在则返回 NULL;
  • Before (P):返回和 P 在同一 Level 的前面的一个位置,若不存在则返回 NULL;
  • Below (P):返回和 P 在同一 Row 的下面的一个位置,若不存在则返回 NULL;
  • Above (P):返回和 P 在同一 Row 的上面的一个位置,若不存在则返回 NULL;

有顺序关系的多个 Entry (K,V) 集合 M 可以由跳表实现,跳表 S 由一系列列表 {S0,S1,S2,……,Sh} 组成,其中 h 代表的跳表的高度。每个列表 Si 按照 Key 顺序存储 M 项的子集,此外 S 中的列表满足如下要求:

  • 列表 S0 中包含了集合 M 的每个一个 Entry;
  • 对于 i = 1 ,…… ,h-1 列表 Si 包含列表 Si-1 中 Entry 的随机子集;

Si 中的 Entry 是从 Si-1 中的 Entry 集合中随机选择的,对于 Si-1 中的每一个 Entry,以 1/2 的概率来决定是否需要拷贝到 Si 中,我们期望 S1 有大约 n/2 个 Entry,S2 中有大约 n/4 个 Entry,Si 中有 n/2^i。跳表的高度 h 大约是 logn。从一个列表到下一个列表的 Entry 数减半并不是跳表的强制要求;
插入的过程描述,以上图为例,插入 Entry58:

  • 找到底层列表 S0 中 55 的位置,在其后插入 Entry58;
  • 假设随机函数取值为 1,紧着回到 20 的位置,在其后插入 58,并和底层列表 S0 的 - Entry58 链接起来形成 Entry58 的 Row;
  • 假设随机函数取值为 0,则插入过程终止;

下图为随机数为 1 的结果图:

删除过程:同查找过程。

时间复杂度

  • 查找包括两个循环,外层循环是从上层 Level 到底层 Level,内层循环是在同一个 Level,从左到右;
  • 跳表的高度大概率为 O (logn),所以外层循环的次数大概率为 O (logn);
  • 在上层查找比对过的 key,不会再下层再次查找比对,任意一个 key 被查找比对的概率为 1/2,因此内存循环比对的期望次数是 2 也就是 O (1);
  • 因此最终的时间复杂度函数 O (n) = O (1)*O (logn) 也就是 O (logn);

空间复杂度

  • Level i 期望的元素个数为 n/2^i;
  • 跳表中所有的 Entry(包含同一个 Entry 的 Row 中的元素) Σ n/2^i = nΣ1/2^i,其中有级数公式得到 Σ1/2^i < 2;
  • 期望的列表空间为 O (n);
  1. 优缺点
  • 优点:快速查找,算法实现简单;
  • 缺点:跳表在链表的基础上增加了多级索引以提升查询效率,使用空间来换取时间,必然会增加存储的负担。
  1. 使用场景
    许多开源的软件都在使用跳表:
  • Redis 中的有序集合 zset
  • LevelDB Hbase 中的 memtable
  • Lucene 中的 Posting List

4.2 简单非线性数据结构

4.2.1 AVL

  1. 总述
    AVL 树是带有平衡条件的二叉查找树,这个平衡条件必须要容易保持,而且它保证树的深度必须是 O (logN)。在 AVL 树中任何节点的两个子树的高度最大差别为 1。

  2. 数据结构和算法
    AVL 树本质上还是一棵二叉查找树,有以下特点:

  • AVL 首先是一棵二叉搜索树;
  • 带有平衡条件:每个节点的左右子树的高度之差的绝对值最多为 1;
  • 当插入节点或者删除节点时,树的结构发生变化导致破坏特点二时,就要进行旋转保证树的平衡;

针对旋转做详细分析如下:
我们把必须重新平衡的节点叫做 a,由于任意节点最多有两个儿子,因此出现高度不平衡就需要 a 点的两棵子树的高度差 2。可以看出,这种不平衡可能出现一下四种情况:

  • 对 a 的左儿子的左子树进行一次插入;
  • 对 a 的左儿子的右子树进行一次插入;
  • 对 a 的右儿子的左子树进行一次插入;
  • 对 a 的右儿子的柚子树进行一次插入;

情形 1 和 4 是关于 a 的对称,而 2 和 3 是关于 a 点的对称。因此理论上解决两种情况。
第一种情况是插入发生在外侧的情况,该情况通过对树的一次单旋转而完成调整。第二种情况是插入发生在内侧的情况,这种情况通过稍微复杂些的双旋转来处理。

单旋转的简单示意图如下:

双旋转的简单示意图如下:

  1. 优缺点
  • 优点:使用二叉查找算法时间复杂度为 O (logN),结构清晰简单;
  • 缺点:插入和删除都需要进行再平衡,浪费 CPU 资源;
  1. 使用场景
  • 少量数据的查找和保存;

.4.2.2 Red Black Tree

  1. 总述
    红黑树是一种自平衡的二叉查找树,是 2-3-4 树的一种等同,它可以在 O (logN) 内做查找,插入和删除。

  2. 数据结构和算法
    在 AVL 的基础之上,红黑树又增加了如下特点:

  • 每个节点或者是红色,或者是黑色;
  • 根节点是黑色;
  • 如果一个节点时红色的,那么它的子节点必须是黑色的;
  • 从一个节点到一个 null 引用的每一条路径必须包含相同数目的黑色节点;

红黑树的示意图如下(图片来源于网络):

那么将一个节点插入到红黑树中,需要执行哪些步骤呢?

  • 将红黑树当做一棵二叉搜索树,将节点插入;
  • 将插入的节点着色为红色;
  • 通过一系列的旋转和着色等操作,使之重新成为一棵红黑树;

在第二步中,被插入的节点被着为红色之后,他会违背哪些特性呢

  • 对于特性 1,显然是不会违背;
  • 对于特性 2,显然也是不会违背;
  • 对于特性 4,显然也是不会违背;
  • 对于特性 3,有可能会违背,我们将情况描述如下
    • 被插入的节点是根节点:直接把此节点涂为黑色;
    • 被插入的节点的父节点是黑色:什么也不需要做。节点被插入后,仍然是红黑树;
    • 被插入的节点的父节点是红色:此种情况下与特性 3 违背,所以将情况分析如下:
      • 当前节点的父节点是红色,且当前节点的祖父节点的另一个子节点也是红色。处理策略为:将父节点置为黑色、将叔叔节点置为黑色、将祖父节点置为红色;
      • 当前节点的父节点是红色,叔叔节点时黑色,且当前节点是其父节点的右子节点。将父节点作为新的当前节点、以新的当前节点作为支点进行左旋;
      • 当前节点的父节点是红色,叔叔节点时黑色,且当前节点时父节点的左子节点。将父节点置为黑色、将祖父节点置为红色、以祖父节点为支点进行右旋;

定理:一棵含有 n 个节点的红黑树的高度至多为 2log (N+1),证明过程请查看参考资料。
由此定理可推论红黑树的时间复杂度为 log (N);

  1. 优缺点
  • 优点:查询效率高,插入和删除的失衡的代销比 AVL 要小很多;
  • 缺点:红黑树不追求完全平衡;
  1. 使用场景
  • 红黑树的应用很广泛,主要用来存储有序的数据,时间复杂度为 log (N),效率非常高。例如 java 中的 TreeSet、TreeMap、HashMap 等

4.2.3 B+Tree

  1. 总述
    提起 B+Tree 都会想到大名鼎鼎的 MySql 的 InnoDB 引擎,该引擎使用的数据结构就是 B+Tree。B+Tree 是 B-Tree(平衡多路查找树)的一种改良,使得更适合实现存储索引结构,也是该篇分享中唯一一个与磁盘有关系的数据结构。首先我们先了解一下磁盘的相关东西。

系统从磁盘读取数据到内存时是以磁盘块(block)为基本单位,位于同一块磁盘块中的数据会被一次性读取出来。InnoDB 存储引擎中有页(Page)的概念,页是引擎管理磁盘的基本单位。

  1. 数据结构和算法
    首先,先了解一下一棵 m 阶 B-Tree 的特性:
  • 每个节点最多有 m 个子节点;
  • 除了根节点和叶子结点外,其他每个节点至少有 m/2 个子节点;
  • 若根节点不是叶子节点,则至少有两个子节点;
  • 所有的叶子结点都是同一深度;
  • 每个非叶子节点都包含 n 个关键字
  • 关键字的个数的关系为 m/2-1 < n < m -1

B-Tree 很适合作为搜索来使用,但是 B-Tree 有一个缺点就是针对范围查找支持的不太友好,所以才有了 B+Tree;
那么 B+Tree 的特性在 B-Tree 的基础上又增加了如下几点:

  • 非叶子节点只存储键值信息;
  • 所有的叶子节点之间都有一个链指针(方便范围查找);
  • 数据记录都存放在叶子节点中;

我们将上述特点描述整理成下图(假设一个页(Page)只能写四个数据):

这样的数据结构可以进行两种运算,一种是针对主键的范围查找和分页查找,另外一种是从根节点开始,进行随机查找;

  1. 优缺点
  • 优点:利用磁盘可以存储大量的数据,简单的表结构在深度为 3 的 B+Tree 上可以保存大概上亿条数据;B+Tree 的深度大概也就是 2~4,深度少就意味这 IO 会减少;B+Tree 的时间复杂度 log (m) N
  • 缺点:插入或者删除数据有可能会导致数据页分裂;即使主键是递增的也无法避免随机写,这点 LSM-Tree 很好的解决了;无法支持全文索引;
  1. 使用场景
  • 使用场景大多数数据库的引擎,例如 MySql,MongoDB 等

4.2.4 HashTree

  1. 总述
    HashTree 是一种特殊的树状结构,根据质数分辨定理,树每层的个数为 1、2、3、5、7、11、13、17、19、23、29…..

  2. 数据结构和算法
    从 2 起的连续质数,连续 10 个质数接可以分辨大约 6464693230 个数,而按照目前 CPU 的计算水平,100 次取余的整数除法操作几乎不算什么难事。

我们选择质数分辨算法来构建一颗哈希树。选择从 2 开始的连续质数来构建一个 10 层的哈希树。第一层节点为根节点,根节点先有 2 个节点,第二层的每个节点包含 3 个子节点;以此类推,即每层节点的数据都是连续的质数。对质数进行取余操作得到的数据决定了处理的路径。下面我们以随机插入 10 个数(442 9041 3460 3164 2997 3663 8250 908 8906 4005)为例,来图解 HashTree 的插入过程,如下:

HashTree 的节点查找过程和节点插入过程类似,就是对关键字用质数取余,根据余数确定下一节点的分叉路径,知道找到目标节点。如上图,在从对象中查找所匹配的对象,比较次数不超过 10 次,也就是说时间复杂度最多是 o (1).

删除的过程和查找类似。

  1. 优缺点:
  • 优点:结构简单,查找迅速,结构不变。
  • 缺点:非有序性。

4.2.5 其他数据结构


作者:欧子有话说
链接:https://juejin.cn/post/7153071026916720677
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

收起阅读 »

Android 带你玩转单元测试

前言 为什么要用到单元测试呢,一般开发谁会写单元测试,反正我认识的人都不会做,又耗时间,效果又一般,要是在单元测试的代码里面又出BUG的话又要改半天,麻烦。 但是有的时候真的是不得不用,比如说你有一步逻辑操作,你想去判断这逻辑操作是否正确。但是运行这步操作之前...
继续阅读 »

前言


为什么要用到单元测试呢,一般开发谁会写单元测试,反正我认识的人都不会做,又耗时间,效果又一般,要是在单元测试的代码里面又出BUG的话又要改半天,麻烦。

但是有的时候真的是不得不用,比如说你有一步逻辑操作,你想去判断这逻辑操作是否正确。但是运行这步操作之前有10步操作,然后这个逻辑操作的情况一共有10种(举个比较极端的栗子)。那如果你运行Debug检验每一种情况的时候,都需要每种情况先执行10步操作才能验证,那就很麻烦啊。


所以这时候你可能就会需要用到单元测试,直接对单步操作进行测试,也不用把整个项目都跑起来,直接对特定的方法进行测试。

但说句实在话,虽然开发流程中规定要进行单元测试。但这单元测试谁来做,还不是研发来做,我们代码平时都很赶,还有什么时间去写单元测试的逻辑和用例,所以我觉得仅仅对某部分base库或者重要的逻辑做测试就够了。


搭建环境


搭建环境很简单,在gradle中添加依赖


testImplementation 'org.mockito:mockito-core:2.25.1'

版本号肯定不是固定的,可以直接在File-Project Structure中查找这个库,这样肯定是最新版本,不过要记得把implementation变成testImplementation 。


然后我们创建相应的测试类,也很简单,以前我是手动创建的,之前get到别人的一招。

光标放到你想测的类的类名,然后alt + enter , 选择Create Test\


b1d256f6f117e0a9f59250806e3bc32.png


自动会帮你填好name,你想改也行,下面可以选before和after,就是你想在测试前和测试后做的操作的方法。再下面Member可惜选着对应的方法。

选择好之后点击OK,然后会让你选择androidTest下还是test下,默认创建android项目不是帮你创建3个文件夹嘛\


236f41c5878f94ba9390ad213265690.png


我们因为是只对某个方法做测试,所以选择test(两个文件夹的区别以后再说)。


单元测试


假如我想测一个功能,就测我以前写的那个Gson解析泛型的功能吧。


    public T getDataContent(String jsondata){
Gson gson = new Gson();

Type type = getClass().getGenericSuperclass();
Type[] types = ((ParameterizedType) type).getActualTypeArguments();
Type ty = new ParameterizedTypeImpl(BaseResponse.class, new Type[]{types[0]});
BaseResponse<T> data = gson.fromJson(jsondata, ty);

return data.content;
}

看看BaseResponse


public class BaseResponse<T> {
public String ret;
public String msg;
public T content;
}

因为这个是一个很重要的功能,每个地方的网络请求都会走这段代码,所以我要测试它,看看不同的情况是否能得到我想要的结果。


按照上面的做法生成一个测试的类和方法


public class HttpCallBackTest {

@Test
public void getDataContent(){
}

}

可以发现在androidstudio里面,getDataContent方法左边有个运行按钮,点击就可以单独对这个方法进行测试。


现在我们要测试这个功能,那么就需要写测试用例,假如我这边写4个测试用例看看能不能都成功解析,4个json字符串(在代码里面加了换行符所以可能有点难看)。


 String mockData = "{\n" +
"\t"ret":"1",\n" +
"\t"msg":"success",\n" +
"\t"content":{\n" +
"\t\t"id":"10000",\n" +
"\t\t"sex":"男",\n" +
"\t\t"age":18\n" +
"\t}\n" +
"}";

String mockData2 = "{\n" +
"\t"ret":"1",\n" +
"\t"msg":"success",\n" +
"\t"content":[\n" +
"\t\t{\n" +
"\t\t\t"id":"10000",\n" +
"\t\t\t"sex":"男",\n" +
"\t\t\t"age":"18"\n" +
"\t\t},\n" +
"\t\t{\n" +
"\t\t\t"id":"10001",\n" +
"\t\t\t"sex":"女",\n" +
"\t\t\t"age":"16"\n" +
"\t\t}\n" +
"\t]\n" +
"}";

String mockData3 = "{\n" +
"\t"ret":"1",\n" +
"\t"msg":"success",\n" +
"\t"content": "aaa"\n" +
"}";

String mockData4 = "{\n" +
"\t"ret":"1",\n" +
"\t"msg":"success",\n" +
"\t"content": []\n" +
"}";

写个对象来接收


public static class TestData{

public String id;

public String sex;

public int age;

}

现在来写测试的代码

(1)第一个测试用例


    @Test
public void getDataContent(){
httpCallBack = new HttpCallBack<TestData>();

TestData testData = (TestData) httpCallBack .getDataContent(mockData);

assertEquals("10000",testData.id);
assertEquals("男",testData.sex);
assertEquals(18,testData.age);
}

测试用到的assertEquals方法,这个之后会详细讲。


可以看到下边会有打印 Process finished with exit code 0 说明测试通过,如果不通过会显示详细的不通过的信息。

比如说我写的 assertEquals(12,testData.age); ,错误的情况会提示


e5d770976eaf8b24c4c515193cb9018.png


如果是代码错误的话也会报出详细的Exception信息。


(2)第二个测试用例


    @Test
public void getDataContent(){
httpCallBack = new HttpCallBack<Lits<TestData>>();
Lits<TestData> testDatas = (Lits<TestData>) httpCallBack .getDataContent(mockData2);
assertEquals("女",testDatas.get(1).sex);
}

(3)第三个测试用例


    @Test
public void getDataContent(){
httpCallBack = new HttpCallBack<String>();
String testData = (String ) httpCallBack .getDataContent(mockData3);
assertEquals("aaa",testData);
}

(4)第四个测试用例


    @Test
public void getDataContent(){
httpCallBack = new HttpCallBack<Lits<TestData>>();
Lits<TestData> testDatas = (Lits<TestData>) httpCallBack .getDataContent(mockData4);
assertEquals(0,testDatas.size());
}

4个用例如果都通过,说明我这个解析json泛型的方法基本不会有问题。

当然,可以把4种情况都写在一起,这样就只用跑一次,我这里是为了看清楚点所有分开写。

这样就是一个简单的单元测试的流程。


assert


从上面可以看出最主要判断测试正确和错误的方法是用assert(断言)。

而这些方法都是属于Assert类,大概的断言方法有这些


a6a1d74be49951e3d540620fc04ec78.png


其中 assertThat 是一个比较高级的用法,这个以后再说,不过我个人基本是没有用过assertThat ,单单其它的几个方法基本就够用了。


补充


可能有的朋友有些时候觉得测一个类难以下手,比如还是我说的解析代码,你是这样写的。


public void requestFinish(String jsonData){
......
......

Gson gson = new Gson();
Type type = getClass().getGenericSuperclass();
Type[] types = ((ParameterizedType) type).getActualTypeArguments();
Type ty = new ParameterizedTypeImpl(BaseResponse.class, new Type[]{types[0]});
BaseResponse<T> data = gson.fromJson(jsondata, ty);

// 假如用回调的方式
callback.finish(data.content);
......
}

比如这样,要怎么断言,我这个方法中又不仅仅只有解析的代码,还有其他的代码,而且我这个方法是一个void方法,不像上面一样有返回值的。


其实很简单,要不然就判断这个方法的外层那个方法,要不然就像我一样单独把那块功能代码抽出来。我是建议抽出来,也符合单一职权。


总结


这是我自己旧博客的文章,原地址 http://www.jianshu.com/p/472c4c35e… ,现在使用单元测试会比之前更方便,当你写了一个很复杂的方法,但你想测试不同的输入会输出不同的情况,如果你不用单元测试,你就需要每次改输入的变量然后run,这种情况下使用单元测试会帮助你剩下很多的时间,具体的还要视情况而定。


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

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

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

前言

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

一、优化检测工具

flutter编译模式

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

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

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

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

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

如何开启profile模式?

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

检测工具

1、Flutter Inspector (debug模式下)

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

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

image.png

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

1662522497922.jpg

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

Highlight Repaints

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

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

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

image.png

2、Performance Overlay(性能图层)

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

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

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

image.png

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

image.png

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

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

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

image.png

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

image.png

其中:

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

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

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

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

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

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

image.png

二、Flutter布局优化

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

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

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

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

image.png

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

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

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

1、常规优化

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

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

我们应该尽量避免在 build() 中执行耗时操作,因为 build() 会被频繁地调用,尤其是当 Widget 重建的时候。 此外,我们不要在代码中进行阻塞式操作,可以将一般耗时操作等通过 Future 来转换成异步方式来完成。 对于 CPU 计算频繁的操作,例如图片压缩,可以使用 isolate 来充分利用多核心 CPU。

2)、build() 方法中堆叠了大量的 Widget

这将会导致三个问题:

1、代码可读性差:画界面时需要一个 Widget 嵌套一个 Widget,但如果 Widget 嵌套太深,就会导致代码的可读性变差,也不利于后期的维护和扩展。

2、复用难:由于所有的代码都在一个 build(),会导致无法将公共的 UI 代码复用到其它的页面或模块。

3、影响性能:我们在 State 上调用 setState() 时,所有 build() 中的 Widget 都将被重建,因此 build() 中返回的 Widget 树越大,那么需要重建的 Widget 就越多,也就会对性能越不利。

所以,你需要 控制 build 方法耗时,将 Widget 拆小,避免直接返回一个巨大的 Widget,这样 Widget 会享有更细粒度的重建和复用。

3)、尽可能地使用 const 构造器

当构建你自己的 Widget 或者使用 Flutter 的 Widget 时,这将会帮助 Flutter 仅仅去 rebuild 那些应当被更新的 Widget。 因此,你应该尽量多用 const 组件,这样即使父组件更新了,子组件也不会重新进行 rebuild 操作。特别是针对一些长期不修改的组件,例如通用报错组件和通用 loading 组件等。

image.png

4)、列表优化

  • 尽量避免使用 ListView默认构造方法

    不管列表内容是否可见,会导致列表中所有的数据都会被一次性绘制出来

  • 建议使用 ListView 和 GridView 的 builder 方法

    它们只会绘制可见的列表内容,类似于 Android 的 RecyclerView。

image.png

其实,本质上,就是对列表采用了懒加载而不是直接一次性创建所有的子 Widget,这样视图的初始化时间就减少了。

2、深入光栅化优化

优化光栅线程

屏幕显示器一般以60Hz的固定频率刷新,每一帧图像绘制完成后,会继续绘制下一帧,这时显示器就会发出一个Vsync信号,按60Hz计算,屏幕每秒会发出60次这样的信号。CPU计算好显示内容提交给GPU,GPU渲染好传递给显示器显示。 Flutter遵循了这种模式,渲染流程如图:

image.png

flutter通过native获取屏幕刷新信号通过engine层传递给flutter framework image.png

所有的 Flutter 应用至少都会运行在两个并行的线程上:UI 线程和 Raster 线程。

  • UI 线程

    构建 Widgets 和运行应用逻辑的地方。

  • Raster 线程

    用来光栅化应用。它从 UI 线程获取指令将其转换成为GPU命令并发送到GPU。

我们通常可以使用Flutter DevTools-Performance 进行检测,步骤如下:

  • 在 Performance Overlay 中,查看光栅线程和 UI 线程哪个负载过重。

  • 在 Timeline Events 中,找到那些耗费时间最长的事件,例如常见的 SkCanvas::Flush,它负责解决所有待处理的 GPU 操作。

  • 找到对应的代码区域,通过删除 Widgets 或方法的方式来看对性能的影响。

image.png

三、Flutter内存优化

1、const 实例化

const 对象只会创建一个编译时的常量值。在代码被加载进 Dart Vm 时,在编译时会存储在一个特殊的查询表里,仅仅只分配一次内存给当前实例。

我们可以使用 flutter_lints 库对我们的代码进行检测提示

2、检测消耗多余内存的图片

Flutter Inspector:点击 “Highlight Oversizeded Images”,它会识别出那些解码大小超过展示大小的图片,并且系统会将其倒置,这些你就能更容易在 App 页面中找到它。

image.png

通过下面两张图可以清晰的看出使用“Highlight Oversizeded Images”的检测效果 image.png image.png

针对这些图片,你可以指定 cacheWidth 和 cacheHeight 为展示大小,这样可以让 flutter 引擎以指定大小解析图片,减少内存消耗。 image.png

3、针对 ListView item 中有 image 的情况来优化内存

ListView 不会销毁那些在屏幕可视范围之外的那些 item,如果 item 使用了高分辨率的图片,那么它将会消耗非常多的内存。

ListView 在默认情况下会在整个滑动/不滑动的过程中让子 Widget 保持活动状态,这一点是通过 AutomaticKeepAlive 来保证,在默认情况下,每个子 Widget 都会被这个 Widget 包裹,以使被包裹的子 Widget 保持活跃。 其次,如果用户向后滚动,则不会再次重新绘制子 Widget,这一点是通过 RepaintBoundaries 来保证,在默认情况下,每个子 Widget 都会被这个 Widget 包裹,它会让被包裹的子 Widget 仅仅绘制一次,以此获得更高的性能。 但,这样的问题在于,如果加载大量的图片,则会消耗大量的内存,最终可能使 App 崩溃。

image.png

通过将这两个选项置为 false 来禁用它们,这样不可见的子元素就会被自动处理和 GC。

4、多变图层与不变图层分离

在日常开发中,会经常遇到页面中大部分元素不变,某个元素实时变化。如Gif,动画。这时我们就需要RepaintBoundary,不过独立图层合成也是有消耗,这块需实测把握。

这会导致页面同一图层重新Paint。此时可以用RepaintBoundary包裹该多变的Gif组件,让其处在单独的图层,待最终再一块图层合成上屏。

image.png

5、降级CustomScrollView,ListView等预渲染区域为合理值

默认情况下,CustomScrollView除了渲染屏幕内的内容,还会渲染上下各250区域的组件内容,例如当前屏幕可显示4个组件,实际仍有上下共4个组件在显示状态,如果setState(),则会进行8个组件重绘。实际用户只看到4个,其实应该也只需渲染4个, 且上下滑动也会触发屏幕外的Widget创建销毁,造成滚动卡顿。高性能的手机可预渲染,在低端机降级该区域距离为0或较小值。

image.png

四、总结

Flutter为什么会卡顿、帧率低?总的来说均为以下2个原因:

  • UI线程慢了-->渲染指令出的慢

  • GPU线程慢了-->光栅化慢、图层合成慢、像素上屏慢

所以我们一般使用flutter布局尽量按照以下原则

Flutter优化基本原则:

  • 尽量不要为 Widget 设置半透明效果,而是考虑用图片的形式代替,这样被遮挡的 Widget 部分区域就不需要绘制了;

  • 控制 build 方法耗时,将 Widget 拆小,避免直接返回一个巨大的 Widget,这样 Widget 会享有更细粒度的重建和复用;

  • 对列表采用懒加载而不是直接一次性创建所有的子 Widget,这样视图的初始化时间就减少了。

五、其他

如果大家对flutter动态化感兴趣,我们也为大家准备了flutter动态化平台-Fair

欢迎大家使用 Fair,也欢迎大家为我们点亮star


作者:58技术
链接:https://juejin.cn/post/7145730792948252686
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

收起阅读 »

Android消息机制中Message常用的几种监控方式

本篇文章主要是讲解Android消息机制中Message执行的几种监控方式: Printer监听Message执行的起始时机 Observer监听Message执行的起始时机并将Message作为参数传入 dump方式打印消息队列中Message快...
继续阅读 »

本篇文章主要是讲解Android消息机制中Message执行的几种监控方式:




  1. Printer监听Message执行的起始时机




  2. Observer监听Message执行的起始时机并将Message作为参数传入




  3. dump方式打印消息队列中Message快照





上面几种方式各有其优缺点及适用场景,下面我们一一进行分析(其中,Android SDK32中Looper的源码发生了一些变化,不过不影响阅读)。


Printer方式


对应Looper源码中的:


image.png


image.png


我们直接深入到Looper的核心方法loopOnce()(基于SDK32的源码)进行分析:


private static boolean loopOnce(final Looper me, final long ident, final int thresholdOverride) {
Message msg = me.mQueue.next(); // might block
...
final Printer logging = me.mLogging;
if (logging != null) {
logging.println(">>>>> Dispatching to " + msg.target + " "
+ msg.callback + ": " + msg.what);
}
long origWorkSource = ThreadLocalWorkSource.setUid(msg.workSourceUid);
try {
msg.target.dispatchMessage(msg);
...
}
...
if (logging != null) {
logging.println("<<<<< Finished to " + msg.target + " " + msg.callback);
}
...
msg.recycleUnchecked();
return true;
}

其中msg.target.dispatchMessage()就是我们消息分发执行的地方,而在这个执行前后都会调用Printer.println()方法。


所以如果我们能够将这个Printer对象替换成我们自定义的,不就可以监听Message执行和结束的时机,所幸,Looper也确实提供了一个方法setMessageLogging()支持外部自定义Printer传入:


public void setMessageLogging(@Nullable Printer printer) {
mLogging = printer;
}

这个有什么用呢,比如可以用来监听耗时的Message,从而定位到业务代码中卡顿的代码位置进行优化,ANRWatchDog据我所知就使用了这样的原理。


Observer方式


这个定位到Looper源码中就是:


image.png


image.png


可以看到这个接口提供的方法参数更加丰富,我们看下它在源码中的调用位置(精简后的代码如下):


private static boolean loopOnce(final Looper me, final long ident, final int thresholdOverride) {
Message msg = me.mQueue.next(); // might block
final Observer observer = sObserver;
Object token = null;
if (observer != null) {
token = observer.messageDispatchStarting();
}
try {
msg.target.dispatchMessage(msg);
if (observer != null) {
observer.messageDispatched(token, msg);
}
} catch (Exception exception) {
if (observer != null) {
observer.dispatchingThrewException(token, msg, exception);
}
throw exception;
}
}

和上面的Printer调用有点相似,也是在消息执行前、消息执行后调用,其中执行后分为两种:




  1. 正常执行后调用messageDispatched()




  2. 异常执行后调用dispatchingThrewException()




下面我们简单的介绍Observer这三个接口方法:



messageDispatchStarting()Message执行之前进行调用,并且可以返回一个标识来标识这条Message消息,这样当消息正常执行结束后,调用messageDispatched()方法传入这个标识和当前分发的Message,我们就可以建立这个标识和Message之间的映射关系;出现异常的时候就会调用dispatchingThrewException()方法,除了传入标识和分发的Message外,还会传入捕捉到的异常。



不过很遗憾的是,Observer是个被@Hide标记的,不允许开发者进行调用,如果大家真要使用,可以参考这篇文章:监控Android Looper Message调度的另一种姿势


dump方式


这个可以打印当前消息队列中每条消息的快照信息,可以根据需要进行调用:



  1. Looper.dump():


public void dump(@NonNull Printer pw, @NonNull String prefix) {
pw.println(prefix + toString());
mQueue.dump(pw, prefix + " ", null);
}


  1. MessageQueue.dump()


void dump(Printer pw, String prefix, Handler h) {
synchronized (this) {
long now = SystemClock.uptimeMillis();
int n = 0;
for (Message msg = mMessages; msg != null; msg = msg.next) {
if (h == null || h == msg.target) {
pw.println(prefix + "Message " + n + ": " + msg.toString(now));
}
n++;
}
pw.println(prefix + "(Total messages: " + n + ", polling=" + isPollingLocked()
+ ", quitting=" + mQuitting + ")");
}
}

很直观的可以看到,当调用dump()方法时会传入一个Printer对象实例,就会遍历消息队列mMessages,通过传入的Printer打印每条消息的内容。


其中Message重写了toString()方法:


image.png


大家可以根据需要自行使用。


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

【Flutter 异步编程 - 叁】 | 初步认识 Stream 类的使用

一、分析 Stream 对象 要了解一个事物,最好去思考它存在的 价值 。当你可以意识到某个事物的作用,缺少它会有什么弊端,自然会有兴趣去了解它。而不是稀里糊涂的看别人怎么用,自己死记硬背 API 有哪些,分别表示什么意思。一味的堆砌知识点,这样无论学什么都是...
继续阅读 »

一、分析 Stream 对象


要了解一个事物,最好去思考它存在的 价值 。当你可以意识到某个事物的作用,缺少它会有什么弊端,自然会有兴趣去了解它。而不是稀里糊涂的看别人怎么用,自己死记硬背 API 有哪些,分别表示什么意思。一味的堆砌知识点,这样无论学什么都是流于表面,不得要领。




1. Stream 存在的必要性

可能很多朋友都没有在开发中使用过 Stream 对象,知道它挺重要,但又不知道他的具体的用途。有种只可远观,不可亵玩的距离感。Stream 可以弥补 Future 的短板,它对于异步来说是一块很重要的版块。


一个 Future 对象诞生的那一刻,无论成败,它最终注定只有一个结果。就像一个普通的网络接口,一次请求只会有一个响应结果。应用开发在绝大多数场景是一个 ,对应一个 ,所以和 Future 打交道比较多。


image.png


但有些场景,任务无法一次完成,对于 一次 请求,会有 若干次 响应。比如现实生活中,你追更一部小说,在你订阅后,作者每次新时,都会通知你。在这个场景下,小说完结代表任务结束,期间会触发多次响应通知,这是 Future 无法处理的。


另外,事件通知的时间不确定的,作者创作的过程也是非常耗时的,所以机体没有必要处于同步等待 的阻塞状态。像这种 异步事件序列 被形象的称之为 Stream 流



在人类科学中,一件重要事物的存在,必然有其发挥效用的场所,在这片领域之下,它是所向披靡的王。在接触新知识、新概念时,感知这片领域非常重要,一个工具只有在合适的场景下,才能发挥最大的效力。




2.从读取文件认识 Stream 的使用

File 对象可以通过 readAsString 异步方法读取文件内容,返回 Future<String> 类型对象。而 Future 异步任务只有一次响应机会,通过 then 回调,所以该方法会将文件中的 所有字符 读取出来。


---->[File#readAsString]---
Future<String> readAsString({Encoding encoding = utf8});

但有些场景中没有必要不能 全部读取。比如,想要在一个大文件中寻找一些字符,找到后就 停止读取 ;想要在读取文件时 显示 读取进度。这时,只能响应一次事件的 Future 就爱莫能助了,而这正是 Stream 大显身手的领域。在 File 类中有 openRead 方法返回 Stream 对象,我们先通过这个方法了解一下 Stream 的使用方式。


Stream<List<int>> openRead([int? start, int? end]);



现在的场景和上面 追更小说 是很相似的:



  • 小说作者 无需一次性向 读者 提供所有的章节;小说是 一章章 进行更新的,每次更新章节,都需要 通知读者 进行阅读。

  • 操作系统 不用一次性读取全部文件内容,返回给请求的 机体;文件是 一块块 进行读取的,每块文件读取完,需要 通知机体 进行处理。



在对 Stream 的理解中,需要认清两个角色: 发布者订阅者 。其中发布者是真正处理任务的机体,是结果的生产者,比如 作者操作系统服务器 等,它们有 发送通知 的义务。订阅者是发送请求的机体,对于异步任务,其本身并不参与到执行过程中,可以监听通知来获取需要的结果数据。


代码处理中 Stream 对象使用 listen 方法 监听通知 ,该方法的第一入参是回调函数,每次通知时都会被触发。回调函数的参数类型是 Stream 的泛型,表示此次通知时携带的结果数据。


StreamSubscription<T> listen(void onData(T event)?,
{Function? onError, void onDone()?, bool? cancelOnError});



如下是通过 Stream 事件读取文件,显示读取进度的处理逻辑。当 openRead 任务分发之后,操作系统会一块一块地对文件进行读取,每读一块会发送通知。Dart 代码中通过 _onData 函数进行监听,回调的 bytes 就是读取的字节数组结果。


image.png


_onData 函数中根据每次回调的字节数,就可以很轻松地计算出读取的进度。 onDone 指定的函数,会在任务完成时被触发,任务完成也就表示不会再有事件通知了。


void readFile() async {
File file = File(path.join(Directory.current.path, "assets", "Jane Eyre.txt"));
print("开始读取 Jane Eyre.txt ");
fileLength = await file.length();
Stream<List<int>> stream = file.openRead();
stream.listen(_onData,onDone: _onDone);
}

void _onData(List<int> bytes) {
counter += bytes.length;
double progress = counter * 100 / fileLength;
DateTime time = DateTime.now();
String timeStr = "[${time.hour}:${time.minute}:${time.second}:${time.millisecond}]";
print(timeStr + "=" * (progress ~/ 2) + '[${progress.toStringAsFixed(2)}%]');
}

void _onDone() {
print("读取 Jane Eyre.txt 结束");
}



3.初步认识 StreamSubscription

Stream#listen 方法监听后,会返回一个 StreamSubscription 对象,表示此次对流的订阅。


StreamSubscription<T> listen(void onData(T event)?,
{Function? onError, void onDone()?, bool? cancelOnError});

通过这个订阅对象,可以暂停 pause 或恢复 resume 对流的监听,以及通过 cancel 取消对流的监听。


---->[StreamSubscription]----
void pause([Future<void>? resumeSignal]);
void resume();
Future<void> cancel();



比如下面当进度大于 50 时,取消对流的订阅:通过打印日志可以看出 54.99% 时,订阅取消,流也随之停止,可以注意一个细节。此时 onDone 回调并未触发,表示当 Stream 任务被取消订阅时,不能算作完成。


image.png


late StreamSubscription<List<int>> subscription;

void readFile() async {
File file = File(path.join(Directory.current.path, "assets", "Jane Eyre.txt"));
print("开始读取 Jane Eyre.txt ");
fileLength = await file.length();
Stream<List<int>> stream = file.openRead();
// listen 方法返回 StreamSubscription 对象
subscription = stream.listen(_onData,onDone: _onDone);
}


void _onData(List<int> bytes) async{
counter += bytes.length;
double progress = counter * 100 / fileLength;
DateTime time = DateTime.now();
String timeStr = "[${time.hour}:${time.minute}:${time.second}:${time.millisecond}]";
print(timeStr + "=" * (progress ~/ 2) + '[${progress.toStringAsFixed(2)}%]');
if(progress > 50){
subscription.cancel(); // 取消订阅
}
}



二、结合应用理解 Stream 的使用


单看 Dart 代码在控制台打印,实在有些不过瘾。下面通过一个有趣的小例子,介绍 StreamFlutter 项目中的使用。这样可以更形象地认识 Stream 的用途,便于进一步理解。




1. 场景分析

现实生活中如果细心观察,会发现很多 Stream 概念的身影。比如在银行办理业务时,客户可以看作 Stream 中的一个元素,广播依次播报牌号,业务员需要对某个元素进行处理。在餐馆中,每桌的客人可以看作 Stream 中的一个元素,客人下单完成,厨师根据请求准备饭菜进行处理。这里,通过模拟 红绿灯 的状态变化,来说明 Stream 的使用。


16.gif


可以想象,在一个时间轴上,信号灯的变化是一个连续不断的事件。我们可以将每次的变化视为 Stream 中的一个元素,信号灯每秒的状态信息都会不同。也就是说,这个 Stream 每秒会产出一个状态,要在应用中模拟红绿灯,只需要监听每次的通知,更新界面显示即可。


befcfa2a7819d9739bcba435aff62d1.png


这里将信号灯的状态信息通过 SignalState 类来封装,成员变量有当前秒数 counter 和信号灯类型 type 。 其中信号灯类型通过 SignalType 枚举表示,有如下三种类型:


const int _kAllowMaxCount = 10;
const int _kWaitMaxCount = 3;
const int _kDenialMaxCount = 10;

class SignalState {
final int counter;
final SignalType type;

SignalState({
required this.counter,
required this.type,
});
}

enum SignalType {
allow, // 允许 - 绿灯
denial, // 拒绝 - 红灯
wait, // 等待 - 黄灯
}



2. 信号灯组件的构建

如下所示,信号灯由三个 Lamp 组件和数字构成。三个灯分别表示 红、黄、绿 ,某一时刻只会量一盏,不亮的使用灰色示意。三个灯水平排列,有一个黑色背景装饰,和文字呈上下结构。


image.png




先看灯 Lamp 组件的构建:逻辑非常简单,使用 Container 组件显示圆形,构造时可指定颜色值,为 null 时显示灰色。


class Lamp extends StatelessWidget {
final Color? color;

const Lamp({Key? key, required this.color}) : super(key: key);

@override
Widget build(BuildContext context) {
return Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: color ?? Colors.grey.withOpacity(0.8),
shape: BoxShape.circle,
),
);
}
}



如下是 SignalLamp 组件的展示效果,其依赖于 SignalState 对象进行显示。根据 SignalType 确定显示的颜色和需要点亮的灯,状态中的 counter 成员用于展示数字。


image.png


class SignalLamp extends StatelessWidget {
final SignalState state;

const SignalLamp({Key? key, required this.state}) : super(key: key);

Color get activeColor {
switch (state.type) {
case SignalType.allow:
return Colors.green;
case SignalType.denial:
return Colors.red;
case SignalType.wait:
return Colors.amber;
}
}

@override
Widget build(BuildContext context) {
return Column(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 10),
decoration: BoxDecoration(
color: Colors.black, borderRadius: BorderRadius.circular(30),),
child: Wrap(
alignment: WrapAlignment.center,
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 15,
children: [
Lamp(color: state.type == SignalType.denial ? activeColor : null),
Lamp(color: state.type == SignalType.wait ? activeColor : null),
Lamp(color: state.type == SignalType.allow ? activeColor : null),
],
),
),
Text(
state.counter.toString(),
style: TextStyle(
fontWeight: FontWeight.bold, fontSize: 50, color: activeColor,
),
)
],
);
}
}



4. Stream 事件的添加与监听

这样,指定不同的 SignalState 就会呈现相应的效果,如下是黄灯的 2 s


SignalLamp(
state: SignalState(counter: 2, type: SignalType.wait),
)

image.png


在使用 Stream 触发更新之前,先说一下思路。Stream 可以监听一系列事件的触发,每次监听会获取新的信号状态,根据新状态渲染界面即可。如下在 SignalState 中定义 next 方法,便于产出下一状态。逻辑很简单,如果数值大于一,类型不变,数值减一,比如 红灯 6 的下一状态是 红灯 5 ; 如果数值等于一,会进入下一类型的最大数值,比如 红灯 1 的下一状态是 黄灯 3


---->[SignalState]----
SignalState next() {
if (counter > 1) {
return SignalState(type: type, counter: counter - 1);
} else {
switch (type) {
case SignalType.allow:
return SignalState(
type: SignalType.denial, counter: _kDenialMaxCount);
case SignalType.denial:
return SignalState(type: SignalType.wait, counter: _kWaitMaxCount);
case SignalType.wait:
return SignalState(type: SignalType.allow, counter: _kAllowMaxCount);
}
}
}



把每个事件通知看做元素,Stream 应用处理事件序列,只不过序列中的元素在此刻是未知的,何时触发也是不定的。Stream 基于 发布-订阅 的思想通过监听来处理这些事件。 其中两个非常重要的角色: 发布者 是元素的生产者,订阅者 是元素的消费者。


在引擎中的 async 包中封装了 StreamController 类用于控制元素的添加操作,同时提供 Stream 对象用于监听。代码处理如下,tag1 处,监听 streamControllerstream 对象。事件到来时触发 emit 方法 ( 方法名任意 ),在 emit 中会回调出 SignalState 对象,根据这个新状态更新界面即可。然后延迟 1s 继续添加下一状态。


---->[_MyHomePageState]----
final StreamController<SignalState> streamController = StreamController();
SignalState _signalState = SignalState(counter: 10, type: SignalType.denial);

@override
void initState() {
super.initState();
streamController.stream.listen(emit); // tag1
streamController.add(_signalState);
}

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

void emit(SignalState state) async {
_signalState = state;
setState(() {});
await Future.delayed(const Duration(seconds: 1));
streamController.add(state.next());
}

这样 streamController 添加元素,作为 发布者;添加的元素可以通过 StreamControllerstream 成员进行监听。





5. Stream 的控制与异常监听

在前面介绍过 Stream#listen 方法会返回一个 StreamSubscription 的订阅对象,通过该对象可以暂停、恢复、取消对流的监听。如下所示,通过点击按钮执行 _toggle 方法,可以达到 暂停/恢复 切换的效果:



---->[_MyHomePageState]----
late StreamSubscription<SignalState> _subscription;

@override
void initState() {
super.initState();
_subscription = streamController.stream.listen(emit);
streamController.add(_signalState);
}

void _toggle() {
if(_subscription.isPaused){
_subscription.resume();
}else{
_subscription.pause();
}
setState(() {});
}



另外,StreamController 在构造时可以传入四个函数来监听流的状态:


image.png


final StreamController<SignalState> streamController = StreamController(
onListen: ()=> print("=====onListen====="),
onPause: ()=> print("=====onPause====="),
onResume: ()=> print("=====onResume====="),
onCancel: ()=> print("=====onCancel====="),
);

onListen 会在 stream 成员被监听时触发一次;onPauseonResumeonCancel 分别对应订阅者的 pauseresumecancel 方法。如下是点击暂停和恢复的日志信息:


image.png




Stream#listen 方法中还有另外两个可选参数用于异常的处理。 onError 是错误的回调函数,cancelOnError 标识用于控制触发异常时,是否取消 Stream


StreamSubscription<T> listen(void onData(T event)?,
{Function? onError, void onDone()?, bool? cancelOnError});

如下所示,在 emit 中故意在 红 7 时通过 addError 添加一个异常元素。这里界面简单显示错误信息,在 3 s 后异常被修复,继续添加新元素。



void emit(SignalState state) async {
_signalState = state;
setState(() {});
await Future.delayed(const Duration(seconds: 1));
SignalState nextState = state.next();
if (nextState.counter == 7 && nextState.type == SignalType.denial) {
streamController.addError(Exception('Error Signal State'));
} else {
streamController.add(nextState);
}
}



listen 方法中使用 onError 监听异常事件,进行处理:其中逻辑是渲染错误界面,三秒后修复异常,继续产出下一状态:


_subscription = streamController.stream.listen(
emit,
onError: (err) async {
print(err);
renderError();
await Future.delayed(const Duration(seconds: 3));
fixError();
emit(_signalState.next());
},
cancelOnError: false,
);

关于异常的处理,这里简单地提供 hasError 标识进行构建逻辑的区分:



bool hasError = false;

void renderError(){
hasError = true;
setState(() {});
}
void fixError(){
hasError = false;
}



最后说一下 listencancelOnError 的作用,它默认是 false 。如果 cancelOnError = true ,在监听到异常之后,就会取消监听 stream ,也就是说之后控制器添加的元素就会监听了。这样异常时 StreamController 会触发 onCancel 回调:


image.png




三、异步生成器函数与 Stream


前面介绍了通过 StreamController 获取 Stream 进行处理的方式,下面再来看另一种获取 Stream 的方式 - 异步生成器函数




1. 思考 Stream 与 Iterable

通过前面对 Stream 的认识,我们知道它是在 时间线 上可拥有若干个可监听的事件元素。而 Iterable 也可以拥有多个元素,两者之间是有很大差距的。Iterable时间空间 上都对元素保持持有关系;而 Stream 只是在时间上监听若干元素的到来,并不在任意时刻都持有元素,更不会在空间上保持持有关系。


对于一个 Type 类型的数据,在异步任务中,Stream<T>Future<T> 就是多值和单值的区别,它们的结果都不能在 当前时刻 得到,只能通过监听在 未来 得到值。 与之相对的就是 Iterable<Type>Type ,它们代表此时此刻,实实在在的对象,可以随时使用。






















单值多值
同步TypeIterable<Type>
异步Future<Type>Stream<Type>



2. 通过异步生成器函数获取 Stream 对象

Future 对象可以通过 async/awiat 关键字,简化书写,更方便的获取异步任务结果。 对于 Stream 也有类似的 async*/yield 关键字。 如下所示, async* 修饰的方法需要返回一个 Stream 对象。


在方法体中通过 yield 关键字 产出 泛型结果对象,如下是对 信号状态流 元素产生出的逻辑:遍历 count 次,每隔 1 s 产出一个状态。


class SignalStream{
SignalState _signalState = SignalState(counter: 10, type: SignalType.denial);

Stream<SignalState> createStream({int count = 100}) async*{
for(int i = 0 ; i < count; i++){
await Future.delayed(const Duration(seconds: 1));
_signalState = _signalState.next();
yield _signalState;
}
}
}



这样,在 _MyHomePageState 中通过 signalStream.createStream() 就可以创建一个有 100 个元素的流,进行监听。每次接收到新状态时,更新界面,也可以达到目的:



---->[_MyHomePageState]---
final SignalStream signalStream = SignalStream();

_subscription = signalStream.createStream().listen(
emit,
);

void emit(SignalState state) async {
_signalState = state;
setState(() {});
}



到这里,关于 Stream 的初步认识就结束了,当然 Stream 的知识还有很多,在后面会陆续介绍。通过本文,你只需要明白 Stream 是什么,通过它我们能干什么就行了。下一篇我们将分析一下 FutureBuilderStreamBuilder 组件的使用和源码实现。它们是 Flutter 对异步对象的封装组件,通过对它们的认识,也能加深我们对 FutureStream 的立即。 那本文就到这里,谢谢观看 ~


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

【入门级】Java解决动态规划背包问题

前言本文是最入门级别的动态规划背包问题的解题过程,帮助小白理解动态规划背包问题的解题思路和转化为代码的过程。动态规划背包问题是什么?一个背包只能装下5kg物品;现有:物品一:1kg价值6元,物品二:2kg价值10元,物品三:3kg价值15元,物品四:4kg价值...
继续阅读 »

前言

本文是最入门级别的动态规划背包问题的解题过程,帮助小白理解动态规划背包问题的解题思路和转化为代码的过程。

动态规划背包问题是什么?

一个背包只能装下5kg物品;

现有:

物品一:1kg价值6元,

物品二:2kg价值10元,

物品三:3kg价值15元,

物品四:4kg价值12元。

问:怎么装,价值最大化? (每样物品只有一件,且每个物品不可拆分)

动态规划解题转代码


动态规划的解题套路千千万,但都是离不开穷举+if装这个物品会怎样else不装会怎样,最终比较一下结果哪条路得到价值最大,就是哪条路。

我选个最好理解的。

总体思路是:背包总共5kg分成1kg1kg的作为最外层循环(穷举的根),每次都取最优。

第一步:拆包填表格

当背包只有1kg时当背包只有2kg时当背包只有3kg时当背包只有4kg时当背包只有5kg时
加入物品一(1kg,¥6)
加入物品二(2kg,¥10)
加入物品三(3kg,¥15)
加入物品四(4kg,¥12)

如何填写表格?把当前状态(背包为某kg)下,最多能装的价格填进去!

1)横着看第一行:当背包1kg时,加入物品1是多少¥,就填进去;当背包是2kg,加入物品一是多少¥,就填进去......以此类推

当背包只有1kg时当背包只有2kg时当背包只有3kg时当背包只有4kg时当背包只有5kg时
加入物品一(1kg,¥6)¥6¥6¥6¥6¥6
加入物品二(2kg,¥10)
加入物品三(3kg,¥15)
加入物品四(4kg,¥12)

2)横着看第二行:当背包1kg时,加不进去物品二,那当背包1k时候利益最大就是¥6;当背包2kg时候加物品二是¥10,比加物品一的¥6多,所以利益最大是放二物品 ;当背包是3kg时候,在原有物品一的基础上,还可以再加物品二,价值就变为¥6+¥10=¥16元,以此类推。

当背包只有1kg时当背包只有2kg时当背包只有3kg时当背包只有4kg时当背包只有5kg时
加入物品一(1kg,¥6)¥6¥6¥6¥6¥6
加入物品二(2kg,¥10)¥6¥10¥16¥16¥16
加入物品三(3kg,¥15)
加入物品四(4kg,¥12)

3)横着看第三行:1kg放不下;2kg也装不下,取之前最大利益10¥;3kg可以装下,但是3kg全装物品三价值为¥15,但是之前两物品可以得到¥16,那么还是之前的落下来¥16。4kg时候,装3kg物品三还剩1kg装下物品一后二者之和为¥21,所以最大值取物品三加物品一的。

当背包只有1kg时当背包只有2kg时当背包只有3kg时当背包只有4kg时当背包只有5kg时
加入物品一(1kg,¥6)¥6¥6¥6¥6¥6
加入物品二(2kg,¥10)¥6¥10¥16¥16¥16
加入物品三(3kg,¥15)¥6¥10¥16¥21¥25
加入物品四(4kg,¥12)

4)横着看第四行:同上道理

当背包只有1kg时当背包只有2kg时当背包只有3kg时当背包只有4kg时当背包只有5kg时
加入物品一(1kg,¥6)¥6¥6¥6¥6¥6
加入物品二(2kg,¥10)¥6¥10¥16¥16¥16
加入物品三(3kg,¥15)¥6¥10¥16¥21¥25
加入物品四(4kg,¥12)¥6¥10¥16¥21¥25

以上,物品加完,价值最大在哪里?在表格最右下角!


这个思路,和穷举四个物品432*1=24种结果区别在哪里?这种方式相当于穷举每次都有最优解!

这就是状态转移方程: 就是拿装和 不装 每次都和上面的比较,大了就装,小了就不装!

第二步:转为代码

以上这拆包填表过程转为伪代码是什么?

一、首先看空表格:即初始化代码

1、最基础的准备:

        // 物品价值
int value[] = { 6, 10, 15, 12 };
// 物品重量
int weight[] = { 1, 2, 3, 4 };
// 背包总容量
int bagWeight = 5;
// 物品总数量
int num = 4;

2、准备下面这个表格:二维数组

        // 表格内容:第一个[]表示行(坐标) 第二个[]表示列 (坐标)
// [][] 两个坐标定位出哪个表格,dp[][]取出的就是最大价值金额
// 防止越界可以加个1,横是待装物品个数,竖是被拆分的背包重量
int dp[][] = new int[num + 1][bagWeight + 1];
当背包只有1kg时当背包只有2kg时当背包只有3kg时当背包只有4kg时当背包只有5kg时
加入物品一(1kg,¥6)
加入物品二(2kg,¥10)
加入物品三(3kg,¥15)坐标是[2][3]dp[2][3] = ¥21
加入物品四(4kg,¥12)这里是最大

 二、看怎么循环填表格

1、按行循环

// 最外层循环即 表格横向有几行就循环几次
for (int i = 1; i <= num; i++) {

}

2、每行里按列循环

// 被拆分的背包 单行从左到右依次循环,有几列循环几次
for (int everyBagWeight = 1; everyBagWeight <= bagWeight; everyBagWeight++) {

}

3、表格里填入多少如何判断 

1)能装下这个物品:

// if 物品重量 小于 当前拆分后背包的重量 就是能装
// weight[i是最外层的循环(有几个物品i就等于几,i-1下标的值就是第几个物品的重量值)]
if (weight[i - 1] <= everyBagWeight) {

}

1-1)能装下这个物品,装还是不装

// 能装就计算装之后和装之前 哪个是最大价值
dp[i][everyBagWeight] = Math.max(
// 装之后
value[i - 1] + dp[i - 1][everyBagWeight - weight[i - 1]],
// 装之前
dp[i - 1][everyBagWeight]
);

 这里很晕举个例子说明,以红色格子为例子:

横坐标[1]横坐标[2]横坐标[3]横坐标[4]横坐标[5]
纵坐标[1]¥6¥6¥6¥6¥6
纵坐标[2]¥6¥10¥16¥16¥16
纵坐标[3]¥6¥10¥16¥21¥25
纵坐标[4]¥6¥10¥16¥21¥25
// 红色格子能装就计算装之后和装之前 哪个是最大价值
//给纵坐标4,横坐标5的格子赋值
dp[i=4 ][everyBagWeight = 5kg] =

Math.max(
// 装之后~~~~~~~~~~~~~~~~
//value[i-1=3]是第四个物品的价值 = 12¥
//dp[i-1=3]是纵坐标是[3],
//[5 - weight[3]]即(总重量)减掉(当前物品四的weight[3]=4kg )=1kg
//dp[3][1]是纵坐标是[3],横坐标为[1]即粉色格子值¥6
//所以装之后总价值为¥12+¥6=¥18

value[i-1] + dp[i - 1][everyBagWeight - weight[i - 1]],//=¥18

//-----------------------------------------------------
// 装之前~~~~~~~~~~~~~~~
//dp[i-1=3][everyBagWeight = 5]
//纵坐标是3 横坐标是5 即是绿色格子的值 ¥25
dp[i - 1][everyBagWeight]
);
//取最大:25>18所以是赋值25

2)装不下

	            // 装不下 就是绿色格子直接赋值上面的价值
} else {
dp[i][everyBagWeight] = dp[i - 1][everyBagWeight];
}

 三、输出结果(最大价值)

//表格右下角就是结果
System.out.print(dp[num][bagWeight]);

 第三步:完整代码

public class Bag {
public static void main(String[] args) {
// 物品价值
int value[] = { 6, 10, 15, 12 };
// 物品重量
int weight[] = { 1, 2, 3, 4 };
// 背包总容量
int bagWeight = 5;
// 物品总数量
int num = 4;
// 表格内容:第一个[]表示价值 第二个[]表示重量??
int dp[][] = new int[num + 1][bagWeight + 1];

// 每次加物品 最外层循环即表格横向有几行就循环几次
for (int i = 1; i <= num; i++) {

// 被拆分的背包 单行从左到右依次循环,有几列循环几次
for (int everyBagWeight = 1; everyBagWeight <= bagWeight; everyBagWeight++) {

// 尝试装物品
// if装 : 物品重量 小于 当前拆分后背包的重量
if (weight[i - 1] <= everyBagWeight) {
// 能装就计算装之后和装之前 哪个是最大价值
dp[i][everyBagWeight] = Math.max(
// 装之后
value[i - 1] + dp[i - 1][everyBagWeight - weight[i - 1]],
// 装之前
dp[i - 1][everyBagWeight]);

// 装不下 就是上面的价值
} else {
dp[i][everyBagWeight] = dp[i - 1][everyBagWeight];
}
}
}
//表格右下角就是结果
System.out.print(dp[num][bagWeight]);
}

}


 动态规划写出路径

以上问题,我们只是计算出了最大价值是多少,那如果需要输出拿了哪个物品呢?

我们只需要把最右列倒着遍历,即背包重量最大时的容量都装了哪些物品,即可

当背包只有1kg时当背包只有2kg时当背包只有3kg时当背包只有4kg时当背包只有5kg时
加入物品一(1kg,¥6)¥6 [1][5]
加入物品二(2kg,¥10)¥16 [2][5]
加入物品三(3kg,¥15)¥25 [3][5]
加入物品四(4kg,¥12)¥25 [4][5]

 拿这道题举例子,有如下这么几种情况:

1)如果加了物品四和没加物品四是一样的,代表物品四根本没有加入。即dp[4][5] ==dp[3][5]

2)如果加了物品三和没加物品三是不一样的,代表物品三是加入了的,需要输出!

3)因为我们表格横纵坐标都是从1开始的,所以遍历不到,最后补上就可以。

        // 具体的物品输出,只需要遍历最后一列即可(从右下角表格向上走)
for (int j = num; j > 1; j--) {
if (dp[j][bagWeight] == dp[j - 1][bagWeight]) {
// 该物品加入,与没加入没有差别,意味着该物品没有加入,即不用输出
} else {
// 该物品被加入了,输出即可
System.out.println("加入物品" + j + ":重量=" + weight[j - 1] + ";价值=" + value[j - 1]);
bagWeight = bagWeight - weight[j - 1];
}
}
// 如果背包不等于0,就要把最后一个商品加进来
if (bagWeight != 0) {
System.out.println("最后加入物品" + 1 + ":重量=" + weight[0] + ";价值=" + value[0]);
}

以上就是入门全部过程~


作者:Java程序员调优
链接:https://juejin.cn/post/7151416114949324813
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

收起阅读 »

Flutter 组件集录 | 日期范围组件 - DateRangePickerDialog

前言 今天随手翻翻源码,突然看到 showDateRangePicker,心中狂喜。原来 Flutter 早已将 日期范围选择器 内置了,可能有些小伙伴已经知道,但应该还有一部分朋友不知道。想当年,为了日期范围选择可吃了不少坑。做为 Flutter 内置组件收...
继续阅读 »
前言

今天随手翻翻源码,突然看到 showDateRangePicker,心中狂喜。原来 Flutter 早已将 日期范围选择器 内置了,可能有些小伙伴已经知道,但应该还有一部分朋友不知道。想当年,为了日期范围选择可吃了不少坑。做为 Flutter 内置组件收集狂魔的我,自然要发篇文章来安利一下这个组件。另外,该组件已经收录入 FutterUnit ,可更新查看。

















123
image.pngimage.pngimage.png



1. 日期范围选择器的使用

如下所示,是最简单的日期选择器操作示意:点击选择按钮时,触发下面代码中的 _show 方法:

















主界面选择器界面保存后界面
1771665361328_.pic.jpg1761665361327_.pic.jpg1751665361327_.pic.jpg

showDateRangePickerFlutter 内置的方法,用于弹出日期范围的对话框。其中必传的参数有三个:



























参数类型描述
contextBuildContext构建上下文
firstDateDateTime可选择的最早日期
lastDateDateTime可选择的最晚日期

image.png


该方法返回 DateTimeRange? 泛型的 Future 对象,如下代码所示:可以通过 async/await 来等待 showDateRangePicker 任务的完成,获取 DateTimeRange? 结果对象。


void _show() async {
DateTime firstDate = DateTime(2021, 1, 1);
DateTime lastDate = DateTime.now();
DateTimeRange? range = await showDateRangePicker(
context: context,
firstDate: firstDate,
lastDate: lastDate,
);
print(range);
}



2. 日期范围选择器的语言

默认情况下,你会发现选择器是 英文 的(左图),怎么能改成中文呢?















英文中文
1781665362161_.pic.jpg1791665362162_.pic.jpg

默认情况下,应用是不支持多语言,对于日历这种内置组件的多语言,可以通过加入 flutter_localizations 依赖实现:


dependencies:
flutter_localizations:
sdk: flutter

MaterialApp 中指定 localizationsDelegatessupportedLocales 。如果应用本身没有多语言的需求,可以指定只支持中文:


image.png


如果需要多语言,可以通过 locale 参数指定语言。如果未指定的话,会使用当前项目中的当前语言。


image.png


简单瞄一眼 showDateRangePicker 源码,可以看出 locale 非空时,会通过 Localizations.override 来让子树使用指定的 locale 语言:


image.png




3. 日期范围选择器的其他参数

除了默认的必需参数外,还有一些参数用于指定相关文字。下面三张图中标注了相关文本对应的位置,如果需要修改相关文字,设置对应参数即可:

















123
1821665363618_.pic.jpg1821665363618_.pic.jpg1821665363618_.pic.jpg



另外,showDateRangePicker 方法中可以传入 initialDateRange 设置弹出时的默认时间范围; currentDate 可以设置当前日期,如下右图的 8 日


DateTimeRange? range = await showDateRangePicker(
context: context,
firstDate: firstDate,
lastDate: lastDate,
initialDateRange: DateTimeRange(
start: DateTime(2022, 10, 1),
end: DateTime(2022, 10, 6),
),
currentDate: DateTime(2022, 10, 8)
);














未设置默认情况设置默认值
image.pngimage.png



4. 源码简看

showDateRangePicker 方法,本质上就是就是通过 showDialog 方法展示对话框:


image.png


其中的内容是 DateRangePickerDialog 组件,方法中的绝大多数参数都是为了创建 DateRangePickerDialog 对象而准备的。


image.png




DateRangePickerDialog 就是一个很普通的 StatefulWidget 的派生类:


image.png


依赖 _DateRangePickerDialogState 状态类进行组件构建。如果在开发中,DateRangePickerDialog 无法满足使用需求,可以将代码拷贝一份进行魔改。


@override
State<DateRangePickerDialog> createState() => _DateRangePickerDialogState();



如下所示,可以在月份条目下叠放月份信息,看起来更直观;或者修改选中时的激活端点的装饰:















月份背景修改端点装饰
image.pngimage.png

如下稍微翻翻源码,可以找到每个月份是通过 _MonthItem 组件构建的,所以需要对条目进行魔改,就在这里处理:


image.png


_MonthItemState 中,有 _buildDayItem 方法,如下是两端激活处的 BoxDecoration 装饰对象。 Decoration 的自定义能力非常强, BoxDecoration 如果无法满足需求,可以通过自定义 Decoration 进行绘制。


image.png


抓住这些核心的构建处理场合,我们可以更灵活地根据具体需求来魔改。而不是让应用千篇一律,毕竟 Flutter 框架中封装的组件只能满足大多数的基本使用场景,并不能尽善尽美。



需求是无限的,变化也是无限的,能应对变化的只有变化本身,能操纵变化的是我们编程者。



希望通过本文可以让更多的朋友知道 DateRangePickerDialog 的存在,让你的日期选择需求变得简单。那本文就到这里,谢谢观看 ~



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

Kotlin对象的懒加载方式?by lazy 与 lateinit 的异同

前言 属性或对象的延时加载是我们相当常用的,一般我们都是使用 lateinit 和 by lazy 来实现。 他们两者都是延时初始化,那么在使用时那么他们两者有什么区别呢? lateinit 见名知意,延时初始化的标记。lateinit var可以让我们声明一...
继续阅读 »

前言


属性或对象的延时加载是我们相当常用的,一般我们都是使用 lateinit 和 by lazy 来实现。


他们两者都是延时初始化,那么在使用时那么他们两者有什么区别呢?


lateinit


见名知意,延时初始化的标记。lateinit var可以让我们声明一个变量并且不用马上初始化,在我们需要的时候进行手动初始化即可。


如果我们不初始化会怎样?


    private lateinit var name: String

findViewById<Button>(R.id.btn_load).click {

YYLogUtils.w("name:$name age:$age")

}

会报错:


image.png


所以对应这一种情况我们会有一个是否初始化的判断


    private lateinit var name: String

findViewById<Button>(R.id.btn_load).click {

if (this::name.isInitialized) {
YYLogUtils.w("name:$name age:$age")
}

}

lateinit var的作用相对较简单,其实就是让编译期在检查时不要因为属性变量未被初始化而报错。(注意一定要记得初始化哦!)


by lazy


by lazy 委托延时处理,分为委托和延时


其实如果我们不想延时初始化,我们直接使用委托by也可以实现。


   private var age: Int by Delegates.observable(18) { property, oldValue, newValue ->
YYLogUtils.w("发生了回调 property:$property oldValue:$oldValue newValue:$newValue")
}


findViewById<Button>(R.id.btn_load).click {
age = 25

YYLogUtils.w("name:$name age:$age")

}

我们通过 by Delegates 的方式就可以指定委托对象,这里我用的 Delegates.obsevable 它的作用是修改 age 的值之后会有回调的处理。


运行的效果:


image.png


除了 Delegates.obsevable 它还有其他的用法。


public object Delegates {

public fun <T : Any> notNull(): ReadWriteProperty<Any?, T> = NotNullVar()

public inline fun <T> observable(initialValue: T, crossinline onChange: (property: KProperty<*>, oldValue: T, newValue: T) -> Unit):
ReadWriteProperty<Any?, T> =
object : ObservableProperty<T>(initialValue) {
override fun afterChange(property: KProperty<*>, oldValue: T, newValue: T) = onChange(property, oldValue, newValue)
}

public inline fun <T> vetoable(initialValue: T, crossinline onChange: (property: KProperty<*>, oldValue: T, newValue: T) -> Boolean):
ReadWriteProperty<Any?, T> =
object : ObservableProperty<T>(initialValue) {
override fun beforeChange(property: KProperty<*>, oldValue: T, newValue: T): Boolean = onChange(property, oldValue, newValue)
}
}

private class NotNullVar<T : Any>() : ReadWriteProperty<Any?, T> {
private var value: T? = null

public override fun getValue(thisRef: Any?, property: KProperty<*>): T {
return value ?: throw IllegalStateException("Property ${property.name} should be initialized before get.")
}

public override fun setValue(thisRef: Any?, property: KProperty<*>, value: T) {
this.value = value
}
}


  • notNull方法我们可以看到就是说这个对象不能为null,否则就会抛出异常。

  • observable方法主要用于监控属性值发生变更,类似于一个观察者。当属性值被修改后会往外部抛出一个变更的回调。

  • vetoable方法跟observable类似,都是用于监控属性值发生变更,当属性值被修改后会往外部抛出一个变更的回调。与observable不同的是这个回调会返回一个Boolean值,来决定此次属性值是否执行修改。


其实用不用委托没什么区别,就是看是否需要属性变化的回调监听,否则我们直接用变量即可


   private var age: Int  = 18


findViewById<Button>(R.id.btn_load).click {
age = 25

YYLogUtils.w("name:$name age:$age")

}

如果我们想实现延时初始化的关键就是 lazy 关键字,所以,lazy是如何工作的呢? 让我们一起在Kotlin标准库参考中总结lazy()方法,如下所示:


image.png



  • lazy() 返回的是一个存储在lambda初始化器中的Lazy类型实例。

  • getter的第一次调用执行传递给lazy()的lambda并存储其结果。

  • 后面再调用的话,getter调用只返回存储中的值。


简单地说,lazy创建一个实例,在第一次访问属性值时执行初始化,存储结果并返回存储的值。


   private val age: Int by lazy { 18 / 2 }


findViewById<Button>(R.id.btn_load).click {
age = 25

YYLogUtils.w("name:$name age:$age")

}

由于我们使用的是 by lazy ,归根到底还是一种委托,只是它是一种特殊的委托,它的过程是这样的:


我们的属性 age 需要 by lazy 时,它生成一个该属性的附加属性:age?delegate。
在构造器中,将使用 lazy(()->T) 创建的 Lazy 实例对象赋值给 age?delegate。
当该属性被调用,即其getter方法被调用时返回 age?delegate.getVaule(),而 age?delegate.getVaule()方法的返回结果是对象 age?delegate 内部的 _value 属性值,在getVaule()第一次被调用时会将_value进行初始化并储存起来,往后都是直接将_value的值返回,从而实现属性值的唯一一次的初始化,并无法再次修改。所以它是只读的。


当我们调用这个 age 这个属性的时候才会初始化,它属于一种懒加载,既然是懒加载,就必然涉及到线程安全的问题,我们看看lazy是怎么解决的。


public actual fun <T> lazy(initializer: () -> T): Lazy<T> = SynchronizedLazyImpl(initializer)

public actual fun <T> lazy(mode: LazyThreadSafetyMode, initializer: () -> T): Lazy<T> =
when (mode) {
LazyThreadSafetyMode.SYNCHRONIZED -> SynchronizedLazyImpl(initializer)
LazyThreadSafetyMode.PUBLICATION -> SafePublicationLazyImpl(initializer)
LazyThreadSafetyMode.NONE -> UnsafeLazyImpl(initializer)
}

public actual fun <T> lazy(lock: Any?, initializer: () -> T): Lazy<T> = SynchronizedLazyImpl(initializer, lock)

我们需要考虑的是线程安全和非线程安全




  • SYNCHRONIZED通过加锁来确保只有一个线程可以初始化Lazy实例,是线程安全的




  • PUBLICATION表示不加锁,可以并发访问多次调用,但是我之接收第一个返回的值作为Lazy的实例,其他后面返回的是啥玩意儿我不管。这也是线程安全的




  • NONE不加锁,是线程不安全的




总结


总的来说其实 lateinit 是延迟初始化, by lazy 是懒加载即初始化方式已确定,只是在使用的时候执行。


虽然两者都可以推迟属性初始化的时间,但是 lateinit var 只是让编译期忽略对属性未初始化的检查,后续在哪里以及何时初始化还需要开发者自己决定。而by lazy真正做到了声明的同时也指定了延迟初始化时的行为,在属性被第一次被使用的时候能自动初始化。


并且 lateinit 是可读写的,by lazy 是只读的。


那我们什么时候该使用 lateinit,什么时候使用 by lazy ?


其实大部分情况下都可以通用,只是 by lazy 一般用于非空只读属性,需要延迟加载情况,而 lateinit 一般用于非空可变属性,需要延迟加载情况。


惯例,如有错漏还请指出,如果有更好的方案也欢迎留言区交流。


如果感觉本文对你有一点点的启发,还望你能点赞支持一下,你的支持是我最大的动力。


Ok,这一期就此完结。



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

落地 Kotlin 代码规范,DeteKt 了解一下~

前言 各个团队多少都有一些自己的代码规范,但制定代码规范简单,困难的是如何落地。如果完全依赖人力Code Review难免有所遗漏。 这个时候就需要通过静态代码检查工具在每次提交代码时自动检查,本文主要介绍如何使用DeteKt落地Kotlin代码规范,主要包括...
继续阅读 »

前言


各个团队多少都有一些自己的代码规范,但制定代码规范简单,困难的是如何落地。如果完全依赖人力Code Review难免有所遗漏。


这个时候就需要通过静态代码检查工具在每次提交代码时自动检查,本文主要介绍如何使用DeteKt落地Kotlin代码规范,主要包括以下内容



  1. 为什么使用DeteKt?

  2. IDE接入DeteKt插件

  3. CLI命令行方式接入DeteKt

  4. Gradle方式接入DeteKt

  5. 自定义Detekt检测规则

  6. Github Action集成Detekt检测


为什么使用DeteKt?


说起静态代码检查,大家首先想起来的可能是lint,相比DeteKt只支持Kotlin代码,lint不仅支持KotlinJava代码,也支持资源文件规范检查,那么我们为什么不使用Lint呢?


在我看来,Lint在使用上主要有两个问题:



  1. IDE集成不够好,自定义lint规则的警告只有在运行./gradlew lint后才会在IDE上展示出来,在clean之后又会消失

  2. lint检查速度较慢,尤其是大型项目,只对增量代码进行检查的逻辑需要自定义


DeteKt提供了IDE插件,开启后可直接在IDE中查看警告,这样可以在第一时间发现问题,避免后续检查发现问题后再修改流程过长的问题


同时Detekt支持CLI命令行方式接入与Gradle方式接入,支持只检查新增代码,在检查速度上比起lint也有一定的优势


IDE接入DeteKt插件


如果能在IDE中提示代码中存在的问题,应该是最快发现问题的方式,DeteKt也贴心的为我们准备了插件,如下所示:



主要可以配置以下内容:



  1. DeteKt开关

  2. 格式化开关,DeteKt直接使用了ktlint的规则

  3. Configuration file:规则配置文件,可以在其中配置各种规则的开关与参数,默认配置可见:default-detekt-config.yml

  4. Baseline file:基线文件,跳过旧代码问题,有了这个基线文件,下次扫描时,就会绕过文件中列出的基线问题,而只提示新增问题。

  5. Plugin jar: 自定义规则jar包,在自定义规则后打出jar包,在扫描时就可以使用自定义规则了


DeteKt IDE插件可以实时提示问题(包括自定义规则),如下图所示,我们添加了自定义禁止使用kae的规则:



对于一些支持自动修复的格式问题,DeteKt插件支持自动格式化,同时也可以配置快捷键,一键自动格式化,如下所示:



CLI命令行方式接入DeteKt


DeteKt支持通过CLI命令行方式接入,支持只检测几个文件,比如本次commit提交的文件


我们可以通过如下方式,下载DeteKtjar然后使用


curl -sSLO https://github.com/detekt/detekt/releases/download/v1.22.0-RC1/detekt-cli-1.22.0-RC1.zip
unzip detekt-cli-1.22.0-RC1.zip
./detekt-cli-1.22.0-RC1/bin/detekt-cli --help

DeteKt CLI支持很多参数,下面列出一些常用的,其他可以参见:Run detekt using Command Line Interface


Usage: detekt [options]
Options:
--auto-correct, -ac
支持自动格式化的规则自动格式化,默认为false
Default: false
--baseline, -b
如果传入了baseline文件,只有不在baseline文件中的问题才会掘出来
--classpath, -cp
实验特性:传入依赖的class路径和jar的路径,用于类型解析
--config, -c
规则配置文件,可以配置规则开关及参数
--create-baseline, -cb
创建baseline,默认false,如果开启会创建出一个baseline文件,供后续使用
--input, -i
输入文件路径,多个路径之间用逗号连接
--jvm-target
EXPERIMENTAL: Target version of the generated JVM bytecode that was
generated during compilation and is now being used for type resolution
(1.6, 1.8, 9, 10, 11, 12, 13, 14, 15, 16 or 17)
Default: 1.8
--language-version
为支持类型解析,需要传入java版本
--plugins, -p
自定义规则jar路径,多个路径之间用,或者;连接

在命令行可以直接通过如下方式检查


java -jar /path/to/detekt-cli-1.21.0-all.jar # detekt-cli-1.21.0-all.jar所在路径
-c /path/to/detekt_1.21.0_format.yml # 规则配置文件所在路径
--plugins /path/to/detekt-formatting-1.21.0.jar # 格式化规则jar,主要基于ktlint封装
-ac # 开启自动格式化
-i $FilePath$ # 需要扫描的源文件,多个路径之间用,或者;连接

通过如上方式进行代码检查速度是非常快的,根据经验来说一般就是几秒之内可以完成,因此我们完成可以将DeteKtgit hook结合起来,在每次提交commit的时候进行检测,而如果是一些比较耗时的工具比如lint,应该是做不到这一点的


类型解析


上面我们提到了,DeteKt--classpth参数与--language-version参数,这些是用于类型解析的。


类型解析是DeteKt的一项功能,它允许 Detekt 对您的 Kotlin 源代码执行更高级的静态分析。


通常,Detekt 在编译期间无法访问编译器语义分析的结果,我们只能获取Kotlin源代码的抽象语法树,却无法知道语法树上符号的语义,这限制了我们的检查能力,比如我们无法判断符号的类型,两个符号究竟是不是同一个对象等


通过启用类型解析,Detekt 可以获取Kotlin编译器语义分析的结果,这让我们可以自定义一些更高级的检查。


而要获取类型与语义,当然要传入依赖的class,也就是classpath,比如android项目中常常需要传入android.jarkotlin-stdlib.jar


Gradle方式接入DeteKt


CLI方式检测虽然快,但是需要手动传入classpath,比较麻烦,尤其是有时候自定义规则需要解析我们自己的类而不是kotlin-stdlib.jar中的类时,那么就需要将项目中的代码的编译结果传入作为classpath了,这样就更麻烦了


DeteKt同样支持Gradle插件方式接入,这种方式不需要我们另外再配置classpath,我们可以将CLI命令行方式与Gradle方式结合起来,在本地通过CLI方式快速检测,在CI上通过Gradle插件进行完整的检测


接入步骤


// 1. 引入插件
plugins {
id("io.gitlab.arturbosch.detekt").version("[version]")
}

repositories {
mavenCentral()
}

// 2. 配置插件
detekt {
config = files("$projectDir/config/detekt.yml") // 规则配置
baseline = file("$projectDir/config/baseline.xml") // baseline配置
parallel = true
}

// 3. 自定义规则
dependencies {
detektPlugins "io.gitlab.arturbosch.detekt:detekt-formatting:1.21.0"
detektPlugins project(":customRules")
}

// 4. 配置 jvmTarget
tasks.withType(Detekt).configureEach {
jvmTarget = "1.8"
}
// DeteKt Task用于检测,DetektCreateBaselineTask用于创建Baseline
tasks.withType(DetektCreateBaselineTask).configureEach {
jvmTarget = "1.8"
}

// 5. 只分析指定文件
tasks.withType<io.gitlab.arturbosch.detekt.Detekt>().configureEach {
// include("**/special/package/**") // 只分析 src/main/kotlin 下面的指定目录文件
exclude("**/special/package/internal/**") // 过滤指定目录
}

如上所示,接入主要需要做这么几件事:



  1. 引入插件

  2. 配置插件,主要是配置configbaseline,即规则开关与老代码过滤

  3. 引入detekt-formatting与自定义规则的依赖

  4. 配置JvmTarget,用于类型解析,但不用再配置classpath了。

  5. 除了baseline之外,也可以通过includeexclude的方式指定只扫描指定文件的方式来实现增量检测


通过以上方式就接入成功了,运行./gradlew detektDebug就可以开始检测了,扫描结果可在终端直接查看,并可以直接定位到问题代码处,也可以在build/reprots/路径下查看输出的报告文件:



自定义Detekt检测规则


要落地自己制定的代码规范,不可避免的需要自定义规则,当然我们首先要看下DeteKt自带的规则,是否已经有我们需要的,只需把开关打开即可.


DeteKt自带规则


DeteKt自带的规则都可以通过开关配置,如果没有在 Detekt 闭包中指定 config 属性,detekt 会使用默认的规则。这些规则采用 yaml 文件描述,运行 ./gradlew detektGenerateConfig 会生成 config/detekt/detekt.yml 文件,我们可以在这个文件的基础上制定代码规范准则。


detekt.yml 中的每条规则形如:


complexity: # 大类
active: true
ComplexCondition: # 规则名
active: true # 是否启用
threshold: 4 # 有些规则,可以设定一个阈值
# ...

更多关于配置文件的修改方式,请参考官方文档-配置文件


Detekt 的规则集划分为 9 个大类,每个大类下有具体的规则:



















































规则大类说明
comments与注释、文档有关的规范检查
complexity检查代码复杂度,复杂度过高的代码不利于维护
coroutines与协程有关的规范检查
empty-blocks空代码块检查,空代码应该尽量避免
exceptions与异常抛出和捕获有关的规范检查
formatting格式化问题,detekt直接引用的 ktlint 的格式化规则集
naming类名、变量命名相关的规范检查
performance检查潜在的性能问题
potentail-bugs检查潜在的BUG
style统一团队的代码风格,也包括一些由 Detekt 定义的格式化问题

更细节的规则说明,请参考:官方文档-规则集说明


自定义规则


接下来我们自定义一个检测KAE使用的规则,如下所示:


//  入口
class CustomRuleSetProvider : RuleSetProvider {
override val ruleSetId: String = "detekt-custom-rules"
override fun instance(config: Config): RuleSet = RuleSet(
ruleSetId,
listOf(
NoSyntheticImportRule(),
)
)
}

// 自定义规则
class NoSyntheticImportRule : Rule() {
override val issue = Issue(
"NoSyntheticImport",
Severity.Maintainability,
"Don’t import Kotlin Synthetics as it is already deprecated.",
Debt.TWENTY_MINS
)

override fun visitImportDirective(importDirective: KtImportDirective) {
val import = importDirective.importPath?.pathStr
if (import?.contains("kotlinx.android.synthetic") == true) {
report(
CodeSmell(
issue,
Entity.from(importDirective),
"'$import' 不要使用kae,推荐使用viewbinding"
)
)
}
}
}

代码其实并不复杂,主要做了这么几件事:



  1. 添加CustomRuleSetProvider作为自定义规则的入口,并将NoSyntheticImportRule添加进去

  2. 实现NoSyntheticImportRule类,主要包括issue与各种visitXXX方法

  3. issue属性用于定义在控制台或任何其他输出格式上打印的ID、严重性和提示信息

  4. visitImportDirective即通过访问者模式访问语法树的回调,当访问到import时会回调,我们在这里检测有没有添加kotlinx.android.synthetic,发现存在则报告异常


支持类型解析的自定义规则


上面的规则没有用到类型解析,也就是说不传入classpath也能使用,我们现在来看一个需要使用类型解析的自定义规则


比如我们需要在项目中禁止直接使用android.widget.Toast.show,而是使用我们统一封装的工具类,那么我们可以自定义如下规则:


class AvoidToUseToastRule : Rule() {
override val issue = Issue(
"AvoidUseToastRule",
Severity.Maintainability,
"Don’t use android.widget.Toast.show",
Debt.TWENTY_MINS
)

override fun visitReferenceExpression(expression: KtReferenceExpression) {
super.visitReferenceExpression(expression)
if (expression.text == "makeText") {
// 通过bindingContext获取语义
val referenceDescriptor = bindingContext.get(BindingContext.REFERENCE_TARGET, expression)
val packageName = referenceDescriptor?.containingPackage()?.asString()
val className = referenceDescriptor?.containingDeclaration?.name?.asString()
if (packageName == "android.widget" && className == "Toast") {
report(
CodeSmell(
issue, Entity.from(expression), "禁止直接使用Toast,建议使用xxxUtils"
)
)
}
}
}
}

可以看出,我们在visitReferenceExpression回调中检测表达式,我们不仅需要判断是否存在Toast.makeTest表达式,因为可能存在同名类,更需要判断Toast类的具体类型,而这就需要获取语义信息


我们这里通过bindingContext来获取表达式的语义,这里的bindingContext其实就是Kotlin编译器存储语义信息的表,详细的可以参阅:K2 编译器是什么?世界第二高峰又是哪座?


当我们获取了语义信息之后,就可以获取Toast的具体类型,就可以判断出这个Toast是不是android.widget.Toast,也就可以完成检测了


Github Action集成Detekt检测


在完成了DeteKt接入与自定义规则之后,接下来就是每次提交代码时在CI上进行检测了


一些大的开源项目每次提交PR都会进行一系列的检测,我们也用Github Action来实现一个


我们在.github/workflows目录添加如下代码


name: Android CI

on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]

jobs:
detekt-code-check:

runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v3
- name: set up JDK 11
uses: actions/setup-java@v3
with:
java-version: '11'
distribution: 'temurin'
cache: gradle

- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: DeteKt Code Check
run: ./gradlew detektDebug

这样在每次提交PR的时候,就都会自动调用该workflow进行检测了,检测不通过则不允许合并,如下所示:



点进去也可以看到详细的报错,具体是哪一行代码检测不通过,如图所示:



总结


本文主要介绍了DeteKt的接入与如何自定义规则,通过IDE集成,CLI命令行方式与Gradle插件方式接入,以及CI自动检测,可以保证代码规范,IDE提示,CI检测三者的统一,方便提前暴露问题,提高代码质量。


如果本文对你有所帮助,欢迎点赞~


示例代码


本文所有代码可见:github.com/RicardoJian…


参考资料


detekt.dev/docs/intro

代码质量堪忧?用 detekt 呀,拿捏得死死的~


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

Android修改弹窗样式的几种方式

一、载入布局修改样式 这种方式大家都比较熟悉,直接在xml 上设计布局的内容,然后创建弹窗时加载这个布局,这个方式可以让我们更好的自定义样式,比较考验个人的审美和写UI 的能力,如果你很强的话,那么你可以设计各种花里胡哨的的弹窗,下面我简单的介绍一下这个方式的...
继续阅读 »

一、载入布局修改样式


这种方式大家都比较熟悉,直接在xml 上设计布局的内容,然后创建弹窗时加载这个布局,这个方式可以让我们更好的自定义样式,比较考验个人的审美和写UI 的能力,如果你很强的话,那么你可以设计各种花里胡哨的的弹窗,下面我简单的介绍一下这个方式的使用。


先定义一个edit_name.xml 的文件,在这个文件中写入下面的代码。


<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:orientation="vertical"
android:layout_height="wrap_content">

<TextView
android:layout_marginTop="10dp"
android:padding="10dp"
android:layout_width="match_parent"
android:text="@string/please_input_name"
android:textSize="20sp"
android:textAlignment="center"
android:layout_height="wrap_content"
android:gravity="center_horizontal">
</TextView>

<EditText
android:id="@+id/name_edit"
android:layout_width="match_parent"
android:layout_height="wrap_content">
</EditText>

<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_marginTop="10dp"
android:padding="15dp"
android:layout_height="wrap_content">

<TextView
android:id="@+id/info_n"
app:layout_constraintTop_toTopOf="parent"
android:text="@string/cancel"
android:textSize="18sp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintHorizontal_bias="0.3"
app:layout_constraintRight_toRightOf="parent">
</TextView>

<TextView
android:id="@+id/info_y"
app:layout_constraintTop_toTopOf="parent"
android:text="@string/sure"
android:textSize="18sp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintHorizontal_bias="0.7"
app:layout_constraintRight_toRightOf="parent">
</TextView>

</androidx.constraintlayout.widget.ConstraintLayout>

</LinearLayout>

上面的布局文件出来的效果是这样的 。



xml 文件写好了,那么我们看看代码是如何载入这个布局的。先创建一个 AlertDialog(dialog) 和 View ( dialogView) 对象 , 然后 dialogView 载入上面写好的布局文件, 通过 dialog.setView(dialogView) 设置 dialog 的布局。


private void showDialog1() {
// 创建一个 dialogView 弹窗
AlertDialog.Builder builder = new
AlertDialog.Builder(MainActivity.this);
final AlertDialog dialog = builder.create();
View dialogView = null;
//设置对话框布局
dialogView = View.inflate(MainActivity.this,
R.layout.edit_name, null);
dialog.setView(dialogView);
dialog.show();
// 获取布局控件
editName =(EditText) dialogView.findViewById(R.id.name_edit);
editN= (TextView) dialogView.findViewById(R.id.info_n);
editY = (TextView) dialogView.findViewById(R.id.info_y);
editN.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
dialog.dismiss();
}
});
editY.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
Toast.makeText(MainActivity.this,"姓名为:"+editName.getText().toString(),Toast.LENGTH_SHORT).show();
dialog.dismiss();
}
});
}

这种方式的载入布局,后面如果你有需求要改动,或者改变样式,那么你直接修改 xml 文件 , 或者在java 代码中重新设置一个新的布局。


二、载入style样式


载入style 样式呢,这个方法适用于所有Android 布局控件,所有控件都可以通过这个方式去修改样式,当然前提是你得会写 style 样式。当然,我也对这个东西了解不是很深,在这就先班门弄斧、关公面前舞大刀一下,浅浅的介绍一下这个东西。


首先在values目录下创建一个 styles.xml 文件



在文件中创建一个自定义的样式,如下所示,这个样式特别简单,就是一些基本的定义。这里的 name="myDialogStyle" 很重要,下面我们载入这个样式时,就是根据这个 name 找到这个样式的。


<!--重写系统弹出Dialog -->
<style name="myDialogStyle" parent="android:Theme.Dialog">
<item name="android:windowFrame">@null</item>
<item name="android:windowIsFloating">true</item>
<item name="android:windowIsTranslucent">false</item>
<item name="android:windowNoTitle">true</item><!--除去title-->
<item name="android:windowContentOverlay">@null</item>
<item name="android:backgroundDimEnabled">false</item>
<item name="android:windowBackground">@null</item><!--除去背景色-->
</style>

在 java 代码中创建弹窗时载入这个样式。


 private void showDialog3() {
AlertDialog mDialog = new AlertDialog.Builder(MainActivity.this, R.style.myDialogStyle)
.setTitle("标题")
.setMessage("这个是什么呢?")
.setPositiveButton(R.string.sure,null)
.setNegativeButton(R.string.cancel, null)
.create();
mDialog.show();
}

额,好吧,我承认有点丑,毕竟我不是做UI的,似乎这是个很好的借口。。。。。



人都是爱美的,看到这么丑总觉得怪怪的,重新扣了下面的这段样式


<style name="myDialogStyleAlert"  parent="@android:style/Theme.Holo.Light.Dialog">
<item name="android:windowBackground">@android:color/transparent</item>
<item name="android:windowContentOverlay">@null</item>
<item name="android:windowMinWidthMajor">@android:dimen/dialog_min_width_major</item>
<item name="android:windowMinWidthMinor">@android:dimen/dialog_min_width_minor</item>
</style>

在代码中引入这个样式后,效果如下所示。似乎好看点了。



当然这篇文章的主要目的并不是让你弄成一个好看的弹窗,这个我也不会,还是回归主题,我们如何修改弹窗的样式,用这种方法呢,也能争对性的修改弹窗的样式,只要你知道样式的内容代表什么,那么都能进行简单的修改。


三、通过反射机制修改弹窗样式


我们直接看代码,大家可能会好奇,哎,这个东西是怎么来的,为什么这么写呢?说起这个,那我们不得不先看看源码了。


private void showDialog2() {
AlertDialog mDialog = new AlertDialog.Builder(MainActivity.this)
.setTitle("标题")
.setMessage("这个是什么呢?")
.setPositiveButton(R.string.sure,null)
.setNegativeButton(R.string.cancel, null)
.show();
// 修改弹窗的背景颜色
mDialog.getWindow().setBackgroundDrawableResource(R.color.purple_200);
// 修改 确定取消 按钮的字体大小
mDialog.getButton(AlertDialog.BUTTON_POSITIVE).setTextSize(20);
mDialog.getButton(DialogInterface.BUTTON_NEGATIVE).setTextSize(20);
try {
//获取mAlert对象
Field mAlert = AlertDialog.class.getDeclaredField("mAlert");
mAlert.setAccessible(true);
Object mAlertController = mAlert.get(mDialog);

//获取mTitleView并设置大小颜色
Field mTitle = mAlertController.getClass().getDeclaredField("mTitleView");
mTitle.setAccessible(true);
TextView mTitleView = (TextView) mTitle.get(mAlertController);
mTitleView.setTextSize(40);
mTitleView.setTextColor(Color.WHITE);

//获取mMessageView并设置大小颜色
Field mMessage = mAlertController.getClass().getDeclaredField("mMessageView");
mMessage.setAccessible(true);
TextView mMessageView = (TextView) mMessage.get(mAlertController);
mMessageView.setTextColor(Color.RED);
mMessageView.setTextSize(30);
} catch (IllegalAccessException e) {
e.printStackTrace();
} catch (NoSuchFieldException e) {
e.printStackTrace();
}
}

在 android studio 中 使用 ctrl + shift + n 的快捷键, 然后搜索 AlertDialog 就可以看到源码的文件,我们打开这个文件



在 AlertDialog 这个文件中,在开头的位置,很容易就看到 mAlert 这个对象的声明



下面这段代码就是通过放射机制获取 mAlert 这个对象。


//获取mAlert对象
Field mAlert = AlertDialog.class.getDeclaredField("mAlert");
mAlert.setAccessible(true);
Object mAlertController = mAlert.get(mDialog);

通过同样的方法 查看 AlertController.java 这个文件的代码,查看这个代码可以发现这里声明了一些变量,这些变量就是弹窗的组成,通过变量名能够大概知道它代表着什么东西。



下面这两段就是设置弹窗标题和消息的样式的代码。


//获取mTitleView并设置大小颜色
Field mTitle = mAlertController.getClass().getDeclaredField("mTitleView");
mTitle.setAccessible(true);
TextView mTitleView = (TextView) mTitle.get(mAlertController);
mTitleView.setTextSize(40);
mTitleView.setTextColor(Color.WHITE);

//获取mMessageView并设置大小颜色
Field mMessage = mAlertController.getClass().getDeclaredField("mMessageView");
mMessage.setAccessible(true);
TextView mMessageView = (TextView) mMessage.get(mAlertController);
mMessageView.setTextColor(Color.RED);
mMessageView.setTextSize(30);

效果是这样的。细心的人可能会发现,上面设置的内容好像 跟下面显示的不一样吧,我读书少,你别骗我啊!


确实,上面通过反射的方式并没有让我的弹窗样式修改成功。



我查看了log ,发现有报错,大概就是因为无法通过反射机制找到对于的对象,所以并没有修改样式成功,那是不是说这个方法不可行呢,并不是,我简单查找了一下原因,怀疑是本地的环境有冲突,存在多个AlertDialog.java 这个文件的源码,无法精准的找到对应的变量,导致冲突报错了。



提示: 上面的方式提供一个思想,如果你在实际应用中没有找到别的方法解决,这个方式可以提供参考,当然,可能你得先解决这个报错的问题。


四、设置App style样式


上面讲了如何设置弹窗的 style样式,这里再讲讲从 App的层面来修改样式,也就是说设置App 的主题风格来设置弹窗的样式。


先在 styles.xml 文件中声明一个 App 样式,我设置的如下所示。


<style name="myAppTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
<!--<item name="android:windowFullscreen">true</item>-->
<item name="android:windowContentOverlay">@null</item>
<item name="android:windowBackground">@android:color/white</item>
<!-- item name="android:windowIsTranslucent">true</item -->
<item name="android:windowTranslucentNavigation">true</item>
<item name="android:selectableItemBackground">@null</item>
<item name="android:selectableItemBackgroundBorderless">@null</item>
<item name="android:windowEnableSplitTouch">false</item>
<item name="android:splitMotionEvents">false</item>
<item name="android:textColorPrimary">@color/teal_700</item>
<item name="android:colorControlNormal">@android:color/white</item>
<item name="android:textColorAlertDialogListItem">@android:color/white</item>
</style>

然后在 AndroidManifest.xml 使用这个theme 。



下面我们看看Java 代码


CharSequence[] stringList = new CharSequence[]{"苹果","香蕉","梨"};
private void showDialog4() {
int index = 1;
AlertDialog mDialog = new AlertDialog.Builder(MainActivity.this)
.setTitle("标题")
.setIcon(null)
.setNegativeButton(R.string.cancel, null)
.setPositiveButton(null, null)
.setSingleChoiceItems(stringList, index,null)
.create();
mDialog.show();
// 修改弹窗的背景颜色
mDialog.getWindow().setBackgroundDrawableResource(R.color.dialog_background_color);
}

效果如下所示。



不知道大家有没有发现,上面的弹窗跟前面几个不一样的,它是动态加载的,里面的内容可以根据需求动态增加,这种动态变化的,如果我要修改苹果、香蕉这些文字的颜色是白色时,前面的几种方式中,第一种是很难进行修改的,这个是动态变化的也不是直接在xml 上写死能解决的。第二种也是可以的,就是载入style样式后,弹窗并没有官方的那么美观,如果你能写成一模一样,那当我没说。通过放射机制修改,也是可以,就是我试了一下,没找到怎么改(好吧,是我太菜!)。


好了,说了这么多,主要的需求就是,怎么把上面的苹果、香蕉这些文字的颜色改成白色。其实我已经给了解决方案 , 上面的 style 样式中的最后一行代码, 没错就是下面这行代码。 为什么是这行代码呢,不能别的吗? 额 ,还真不能! 下面听我娓娓道来。


 <item name="android:textColorAlertDialogListItem">@android:color/white</item>

搜索源码 values.xml 文件,搜索 AlertDialog, 查找到下面的的位置。



其中下面红色的框框是我们要找的东西,这里进入这个布局文件



在这个布局文件中,我们可以发现下面设置 textColor , 这个就是设置选择框文字的颜色,我们再点进去查看这个设置的资源



点击上面的资源会跳转到下面的位置 ,这里可以看到一个name 为 textColorAlertDialogListItem 的资源,在这个文件中,查找这个name ,



就可以看到在这里设置颜色,所以这个 android:textColorAlertDialogListItem 就是我们要的东西。



在定义的布局文件中,重新定义这个 android:textColorAlertDialogListItem 的变量的颜色。也就是上面我写的这行代码。


 <item name="android:textColorAlertDialogListItem">@android:color/white</item>

上面已经讲了一下修改弹窗样式的方式的思维方式,我写的样式很丑并不重要,重要的是这个思维,这种思维方式并不仅仅适用于弹窗的样式,其他安卓控件也是适用。毕竟编程的思维是相通的。


下面我找到的一些常用的样式 仅供参考,具体效果还望实际操作后看效果。


<style name="AppThemeDemo" parent="Theme.AppCompat.Light.DarkActionBar">

<!-- 应用的主要色调,actionBar默认使用该颜色,Toolbar导航栏的底色 -->
<item name="colorPrimary">@color/white</item>
<!-- 应用的主要暗色调,statusBarColor 默认使用该颜色 -->
<item name="colorPrimaryDark">@color/white</item>
<!-- 一般控件的选中效果默认采用该颜色,如 CheckBox,RadioButton,SwitchCompat,ProcessBar等-->
<item name="colorAccent">@color/colorAccent</item>

<!-- 状态栏、顶部导航栏 相关-->
<!-- status bar color -->
<item name="android:statusBarColor">#00000000</item>
<!-- activity 是否能在status bar 底部绘制 -->
<item name="android:windowOverscan">true</item>
<!-- 让status bar透明,相当于statusBarColor=transparent + windowOverscan=true -->
<item name="android:windowTranslucentStatus">true</item>
<!-- 改变status bar 文字颜色, true黑色, false白色,API23可用-->
<item name="android:windowLightStatusBar">true</item>
<!-- 全屏显示,隐藏状态栏、导航栏、底部导航栏 -->
<item name="android:windowFullscreen">true</item>
<!-- hide title bar -->
<item name="windowNoTitle">true</item>
<!-- 底部虚拟导航栏颜色 -->
<item name="android:navigationBarColor">#E91E63</item>
<!-- 让底部导航栏变半透明灰色,覆盖在Activity之上(默认false,activity会居于底部导航栏顶部),如果设为true,navigationBarColor 失效 -->
<item name="android:windowTranslucentNavigation">true</item>

<!-- WindowBackground,可以设置@drawable,颜色引用(@color),不能设置颜色值(#fffffff),
Window区域说明:Window涵盖整个屏幕显示区域,包括StatusBar的区域。当windowOverscan=false时,window的区域比Activity多出StatusBar,当windowOverscan=true时,window区域与Activity相同-->
<item name="android:windowBackground">@drawable/ic_launcher_background</item>
<!--<item name="android:windowBackground">@color/light_purple</item>-->

<!-- 控件相关 -->
<!-- button 文字是否全部大写(系统默认开)-->
<item name="android:textAllCaps">false</item>

<!-- 默认 Button,TextView的文字颜色 -->
<item name="android:textColor">#B0C4DE</item>
<!-- 默认 EditView 输入框字体的颜色 -->
<item name="android:editTextColor">#E6E6FA</item>
<!-- RadioButton checkbox等控件的文字 -->
<item name="android:textColorPrimaryDisableOnly">#1C71A9</item>
<!-- 应用的主要文字颜色,actionBar的标题文字默认使用该颜色 -->
<item name="android:textColorPrimary">#FFFFFF</item>
<!-- 辅助的文字颜色,一般比textColorPrimary的颜色弱一点,用于一些弱化的表示 -->
<item name="android:textColorSecondary">#C1C1C1</item>
<!-- 控件选中时的颜色,默认使用colorAccent -->
<item name="android:colorControlActivated">#FF7F50</item>
<!-- 控件按压时的色调-->
<item name="android:colorControlHighlight">#FF00FF</item>
<!-- CheckBox,RadioButton,SwitchCompat等默认状态的颜色 -->
<item name="android:colorControlNormal">#FFD700</item>
<!-- 默认按钮的背景颜色 -->
<item name="android:colorButtonNormal">#1C71A9</item>

<!-- 【无效】 在theme中设置Activity的属性无效, 请到AndroidManifest中Activity标签下设置 -->
<item name="android:launchMode">singleTop</item>
<item name="android:screenOrientation">landscape</item>

</style>


代码已上传至 gitee :zpeien/AndroidProject - 码云 - 开源中国 (gitee.com)


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

MVP 架构最终审判 —— MVP 解决了哪些痛点,又引入了哪些坑?(三)

MVP
复杂度 Android 架构演进系列是围绕着复杂度向前推进的。 软件的首要技术使命是“管理复杂度” —— 《代码大全》 因为低复杂度才能降低理解成本和沟通难度,提升应对变更的灵活性,减少重复劳动,最终提高代码质量。 架构的目的在于“将复杂度分层” 复杂...
继续阅读 »

复杂度


Android 架构演进系列是围绕着复杂度向前推进的。



软件的首要技术使命是“管理复杂度” —— 《代码大全》



因为低复杂度才能降低理解成本和沟通难度,提升应对变更的灵活性,减少重复劳动,最终提高代码质量。



架构的目的在于“将复杂度分层”



复杂度为什么要被分层?


若不分层,复杂度会在同一层次展开,这样就太 ... 复杂了。


举一个复杂度不分层的例子:


小李:“你会做什么菜?”


小明:“我会做用土鸡生的土鸡蛋配上切片的番茄,放点油盐,开火翻炒的番茄炒蛋。”


听了小明的回答,你还会和他做朋友吗?


小明把不同层次的复杂度以不恰当的方式揉搓在一起,让人感觉是一种由“没有必要的具体”导致的“难以理解的复杂”。


小李其实并不关心土鸡蛋的来源、番茄的切法、添加的佐料、以及烹饪方式。


这样的回答除了难以理解之外,局限性也很大。因为它太具体了!只要把土鸡蛋换成洋鸡蛋、或是番茄片换成块、或是加点糖、或是换成电磁炉,其中任一因素发生变化,小明就不会做番茄炒蛋了。


再举个正面的例子,TCP/IP 协议分层模型自下到上定义了五层:



  1. 物理层

  2. 数据链路成

  3. 网络层

  4. 传输层

  5. 应用层


其中每一层的功能都独立且明确,这样设计的好处是缩小影响面,即单层的变动不会影响其他层。


这样设计的另一个好处是当专注于一层协议时,其余层的技术细节可以不予关注,同一时间只需要关注有限的复杂度,比如传输层不需要知道自己传输的是 HTTP 还是 FTP,传输层只需要专注于端到端的传输方式,是建立连接,还是无连接。


有限复杂度的另一面是“下层的可重用性”。当应用层的协议从 HTTP 换成 FTP 时,其下层的内容不需要做任何更改。


引子


该系列的前三篇结合“搜索”这个业务场景,讲述了不使用架构写业务代码会产生的痛点:



  1. 低内聚高耦合的绘制:控件的绘制逻辑散落在各处,散落在各种 Activity 的子程序中(子程序间相互耦合),分散在现在和将来的逻辑中。这样的设计增加了界面刷新的复杂度,导致代码难以理解、容易改出 Bug、难排查问题、无法复用。

  2. 耦合的非粘性通信:Activity 和 Fragment 通过获取对方引用并互调方法的方式完成通信。这种通信方式使得 Fragment 和 Activity 耦合,从而降低了界面的复用度。并且没有一种内建的机制来轻松的实现粘性通信。

  3. 上帝类:所有细节都在界面被铺开。比如数据存取,网络访问这些和界面无关的细节都在 Activity 被铺开。导致 Activity 代码不单纯、高耦合、代码量大、复杂度高、变化源不单一、改动影响范围大。

  4. 界面 & 业务:界面展示和业务逻辑耦合在一起。“界面该长什么样?”和“哪些事件会触发界面重绘?”这两个独立的变化源没有做到关注点分离。导致 Activity 代码不单纯、高耦合、代码量大、复杂度高、变化源不单一、改动影响范围大、易改出 Bug、界面和业务无法单独被复用。


详细分析过程可以点击下面的链接:




  1. 写业务不用架构会怎么样?(一)




  2. 写业务不用架构会怎么样?(二)




  3. 写业务不用架构会怎么样?(三)




这一篇试着引入 MVP 架构(Model-View-Presenter)进行重构,看能不能解决这些痛点。


在重构之前,先介绍下搜索的业务场景,该功能示意图如下:


1662106805162.gif


业务流程如下:在搜索条中输入关键词并同步展示联想词,点联想词跳转搜索结果页,若无匹配结果则展示推荐流,返回时搜索历史以标签形式横向铺开。点击历史可直接发起搜索跳转到结果页。


将搜索业务场景的界面做了如下设计:


微信截图_20220902171024.png


搜索页用Activity来承载,它被分成两个部分,头部是常驻在 Activity 的搜索条。下面的“搜索体”用Fragment承载,它可能出现三种状态 1.搜索历史页 2.搜索联想页 3.搜索结果页。


Fragment 之间的切换采用 Jetpack 的Navigation。关于 Navigation 详细的介绍可以点击关于 Navigation 更详细的介绍可以点击Navigation 组件使用入门  |  Android 开发者  |  Android Developers


生命周期不友好


Presenter 在调 View 层接口的时候是鲁莽的,它并不顾及界面的生命周期,这会发生 crash。


假设用户触发搜索后,正好网络不佳,等了好久搜索结果一直未展示,用户退出了搜索页。但退出没多久后,客户端接收到了网络响应,然后 Presenter 就会调用 View 层接口,通知界面跳转到搜索结果页,此时就会发生如下的 crash:


java.lang.IllegalArgumentException: Navigation action/destination cannot be found from the current destination NavGraph


即在当前的 NavGraph 中无法找到要跳转的目的地。(它的确是不存在了)


解决方案是得让 Presenter 具备生命周期感知能力,当界面的生命周期不可见时,就不再调用 View 层接口。


通常的做法的是为业务接口新增和生命周期相关的方法:


interface SearchPresenter {
fun onDestory() // 新增生命周期方法
}

// 将 View 层接口改为可空类型
class SearchPresenterImpl(private val searchView: SearchView?) : SearchPresenter {
override fun onDestroy() {
searchView = null // 生命周期结束时 View 层接口置空
}
}

class TemplateSearchActivity : AppCompatActivity(), SearchView {
override fun onDestroy() {
super.onDestroy()
// 将生命周期传递给 Presenter
searchPresenter.onDestroy()
}
}

在生命周期结束时将 View 层接口置空。执行业务逻辑时得对 searchView 先判空。


在没有 JetPack 的 Lifecycle 之前上述代码是让 Presenter 感知生命周期的惯用写法。有了 Lifecycle 后,代码可以得到简化:


class SearchPresenterImpl(private val searchView: SearchView) : SearchPresenter {
init {
// 将 View 层接口强转成 LifecycleOwner,并添加生命周期监听者
(searchView as? LifecycleOwner)?.lifecycle?.onStateChanged {
// 在生命周期为 ON_DESTROY 时,调用 onDestroy()
if (it == Lifecycle.Event.ON_DESTROY) onDestroy()
}
}
private fun onDestroy() {
searchView = null
}
}

虽然传进来的是 View 层接口,但它的实现者是 Activity,可以把它强转为 LifecycleOwner,并添加生命周期观察者。这样就可以在 Presenter 内部监听生命周期的变化。


其中的 onStateChanged() 是 Lifecycle 的扩展方法:


// 扩展方法简化了业务层使用的代码量
fun Lifecycle.onStateChanged(action: ((event: Lifecycle.Event) -> Unit)) {
addObserver(object : LifecycleEventObserver {
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
action(event)
if (event == Lifecycle.Event.ON_DESTROY) {
removeObserver(this)
}
}
})
}

生命周期安全还可以更进一步。当界面生命周期完结后,除了不把晚到的数据推送给界面之外,还可以取消异步任务,节约资源并避免内存泄漏。


还是拿刚才联想词的交互来举例,点击联想词记为一次搜索,得录入搜索历史,而搜索历史得做持久化,采用 MMKV,这个细节应该被封装在 SearchRepository 中:


class SearchRepository {
// 获取搜索历史
suspend fun getHistory(): List<String> = suspendCancellableCoroutine { continuation->
val historyBundle = MMKV.mmkvWithID("template-search")?.decodeParcelable("search-history", Bundle::class.java)
val historys = historyBundle?.let { (it.getStringArray("historys") ?: emptyArray()).toList() }.orEmpty()
continuation.resume(historys,null)
}
// 更新搜索历史
suspend fun putHistory(historys:List<String>) = suspendCancellableCoroutine<Unit> { continuation ->
val bundle = Bundle().apply { putStringArray("historys", historys.toTypedArray()) }
MMKV.mmkvWithID("template-search")?.encode("search-history", bundle)
continuation.resume(Unit,null)
}
}

虽然 MMKV 足够快,但 IO 还是充满了不确定性。顺手异步化一下没毛病,使用suspendCancellableCoroutine将同步方法转成 suspend 方法。


这样的话得为 suspend 提供一个协程运行环境:


class SearchPresenterImpl(private val searchView: SearchView) : SearchPresenter {
// 协程域
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val searchRepository: SearchRepository = SearchRepository()
private val historys = mutableListOf<String>()
// 初始化读历史
override fun init() {
searchView.initView()
// 初始化时,启动协程获取历史
scope.launch {
searchRepository.getHistory().also { historys.addAll(it) }
withContext(Dispatchers.Main) {
searchView.showHistory(historys)
}
}
}
// 搜索时写历史
override fun search(keyword: String, from: SearchFrom) {
searchView.gotoSearchPage(keyword, from)
searchView.stretchSearchBar(true)
searchView.showSearchButton(false)
// 新增历史
if (historys.contains(keyword)) {
historys.remove(keyword)
historys.add(0, keyword)
} else {
historys.add(0, keyword)
if (historys.size > 11) historys.removeLast()
}
searchView.showHistory(historys)
// 启动协程持久化历史
scope.launch { searchRepository.putHistory(historys) }
}
}

新建了一个 CoroutineScope 用于启动协程,CoroutineScope 的用意是控制协程的生命周期。但上述的写法和GlobalScope.launch()半径八两,因为没有在界面销毁时取消协程释放资源。所以 Presenter.onDestroy() 还得新增一行逻辑:


class SearchPresenterImpl(private val searchView: SearchView) : SearchPresenter {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private fun onDestroy() {
searchView = null
scope.cancel()
}
}

阶段性总结:




  • 生命周期安全包括两个方面:

    1. 以生命周期安全的方式刷新界面:当界面生命周期结束时,不再推送数据刷新之。

    2. 异步任务与界面生命周期绑定:当界面生命周期结束时,取消仍未完成的异步任务,以释放资源,避免内存泄漏



  • MVP 架构没有内建的机制来实现上述的生命周期安全,它是手动挡,得自己动手建立一套生命周期安全的机制。而 MVVM 和 MVI 是默认具备生命周期感知能力的。(在后续篇章展开)



困难重重的业务复用


业务接口复用


整个搜索业务中,触发搜索行为的有3个地方,分别是搜索页的搜索按钮(搜索 Activity)、点击搜索历史标签(历史 Fragment)、点击搜索联想词(联想 Fragment)。


这三个触发点分别位于三个不同的界面。而触发搜索的业务逻辑被封装在 SearchPresenter 的业务接口中:


class SearchPresenterImpl(private val searchView: SearchView) : SearchPresenter {
private val historys = mutableListOf<String>() // 历史列表
override fun search(keyword: String, from: SearchFrom) {
searchView.gotoSearchPage(keyword, from) // 跳转到搜索结果页
searchView.stretchSearchBar(true) // 拉升搜索条
searchView.showSearchButton(false) // 隐藏搜索按钮
// 更新历史
if (historys.contains(keyword)) {
historys.remove(keyword)
historys.add(0, keyword)
} else {
historys.add(0, keyword)
if (historys.size > 11) historys.removeLast()
}
// 刷新搜索历史
searchView.showHistory(historys)
// 搜索历史持久化
scope.launch { searchRepository.putHistory(historys) }
}
}

理论上,三个不同的界面应该都调用这个方法触发搜索,这使得搜索这个动作的业务实现内聚于一点。但在 MVP 中情况比想象的要复杂的多。


首先 SearchPresenter 的实例只有一个且被搜索 Activity 持有。其他两个 Fragment 如何获取该实例?


当然可以有一个非常粗暴的方式,即先将 Activity 持有的 Presenter 实例 public 化,然后就能在 Fragment 中先获取 Activity 实例,再获取 Presenter 实例。但这样写使得 Fragment 和 Activity 强耦合。


那从 Fragment 发一个广播到 Activity,Activity 在接收到广播后调用 Presenter.search() 可否?


不行!因为点击联想词有两个效果:1. 触发搜索 2. 更新历史


发广播可以实现第一个效果,但更新历史不能使用广播,因为历史列表historys: List<String>是保存在 Presenter 层,直接从联想页发广播到历史页拿不到当前的历史列表,就算能拿到,也不该这么做,因为这形成了一条新的更新历史的路径,增加复杂度和排查问题的难度。


所以 MVP 架构在单 Activity + 多 Fragment 场景下,无法优雅地轻松地实现多界面复用业务逻辑。


而在 MVVM 和 MVI 中这是一件轻而易举的事情。(后续篇章会展开)


View 层接口复用


当前 MVP 的现状如下:Activity 是 Presenter 的唯一持有者,也是 View 层接口的唯一实现者。


这样的设计就会产生一些奇怪的代码,比如下面这个场景。为了让搜索历史展示,得在 View 层接口中新增一个方法:


interface SearchView {
fun showHistory(historys: List<String>)// 新增刷新历史的 View 层接口
}

// 搜索 Activity
class TemplateSearchActivity : AppCompatActivity(), SearchView {
override fun showHistory(historys: List<String>) {
// 奇怪的实现:Activity 通知 Fragment 刷新界面
EventBus.getDefault().post(SearchHistorysEvent(historys))
}
}

// 搜索历史页
class SearchHistoryFragment : BaseSearchFragment() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
EventBus.getDefault().register(this)
}
override fun onDestroy() {
super.onDestroy()
EventBus.getDefault().unregister(this)
}
@Subscribe(threadMode = ThreadMode.MAIN)
fun onHints(event: SearchHistorysEvent) {
showHistory(event.history) // 接收到 Activity 的消息后,展示搜索历史
}
}

奇怪的事情发生了,为了在发起搜索行为后刷新搜索历史,引入了广播。(此处无法使用 Navigation 的带参跳转,因为搜索行为发生后要跳的界面是结果页而非历史页)


之所以会这样是因为“Activity 是 View 层接口的唯一实现者”,其实 showHistory() 这个 View 层接口应该在历史页 Fragment 实现,因为展示历史的不是 Activity 而是 Fragment。


那把 View 层接口在 Fragment 在实现一遍,然后注册给 Presenter?


这样语义就很变扭了,因为 Fragment 得实现一堆和自己无关的 View 层接口(除了 showHistory()),这些冗余接口得保持空实现。


而且 Presenter 当前只支持持有一个 View 层接口,得重构成支持多 View 层接口。当持有多个 View 层接口,且它们生命周期不完全同步时,如何正确的区别对待?这又是一件复杂的事情。


最后 Fragment 也无法优雅地轻松地获取 Presenter 实例。


用流程图来描述下单 Activity + 多 Fragment 界面框架下的 MVP 的窘境:


微信截图_20221003162510.png


即 Activity 同子 Fragment 发起同一个业务请求,该请求会同时触发 Activity 及子 Fragment 的界面刷新。


MVP 无法轻松地实现该效果,它不得不这样蹩脚地应对:


微信截图_20221003163620.png


即发起业务请求以及响应界面刷新都途径 Activity。这加重了 Activity 的负担。


成也 View 层接口,败也 View 层接口。下面这个例子又在 View 层接口的伤疤上补了一刀。


产品需求:当搜索为空匹配时,展示推荐流。


推荐流在另一个业务模块中已通过 MVP 方式实现。可否把它拿来一用?


另一个业务模块的 View 层接口中有 6 个方法。当前 Activity 得是现在这些和它无关的冗余方法们并保持空实现。


当前 Activity 还得持有一个新的 Presenter。在搜索匹配结果为空的时,再调新 Presenter 的一个业务接口拉取推荐流。然后在新 View 层接口中绘制推荐流。(搜索结果的展示没有做到内聚,分散在了两个 View 层接口中,增加了维护难度)


虽然不那么优雅,但还是实现了需求。上例中的搜索和推荐接口是串行关系,还比较好处理,若改成更复杂的并行,View 层界面就无力招架了,比如同时拉取两个接口,待它们全返回后才刷新界面。


这是一个如何等待多个异步回调的问题,在面试题 | 等待多个并发结果有哪几种方法?中有详细介绍。普通的异步回调还好弄,但现在异步回调的实现者是 Activity,就有点难办了。(因为无法手动创建 Activity 实例)


再看下面这个产品需求:当展示搜索结果时,上拉加载更多搜索结果。当展示推荐流时,上拉加载更多推荐结果。


界面应该只提供加载更多的时机,至于加载更多是拉取搜索接口还是推荐接口,这是业务逻辑,界面应该无感知,得交给 Presenter 处理。


搜索和推荐分处于两个 Presenter,它们只知道如何加载更多的自己,并不知道对方的存在。关于搜索和推荐业务如何组是一个新的业务逻辑,既不属于推荐 Presenter,也不属于搜索 Presenter。若采用 Activity 持有两个 Presenter 的写法,新业务逻辑势必得在 Activity 中展开,违背了界面和业务隔离的原则。


拦截转发是我能想到的一个解决方案:新建一个 Presenter,持有两个老 Presenter,在内部构建 View 层口的实例并注册给老 Presenter 实现拦截,然后在内部实现等待多个 View 层接口以及加载更多的业务逻辑。


这个方案听上去就很费劲。。。


之所以会这样,是因为 View 层接口是一个 “具体的接口”,而它又和一个 “具体的界面” 搭配在一起。这使得 Presenter 和“这种类型的界面”耦合在一起,较难在其他界面复用。


总结


经过三篇对搜索业务场景的重构,现总结 MVP 的优缺点如下:



  • 分层:MVP 最大的贡献在于将界面绘制与业务逻辑分层,前者是 MVP 中的 V(View),后者是 MVP 中的 P(Presenter)。分层实现了业务逻辑和界面绘制的解耦,让各自更加单纯,降低了代码复杂度。

  • 面向接口通信:MVP 将业务和界面分层之后,各层之间就需要通信。通信通过接口实现,接口把做什么和怎么做分离,使得关注点分离成为可能:接口的持有者只关心做什么,而怎么做留给接口的实现者关心。界面通过业务接口向 Presenter 发出请求以触发业务逻辑,这使得它不需要关心业务逻辑的实现细节。Presenter 通过 view 层接口返回响应以指导界面刷新,这使得它不需要关心界面绘制的细节。

  • 有限的解耦:因为 View 层接口的存在,迫使 Presenter 得了解该把哪个数据塞给哪个 View 层接口。这是一种耦合,Presenter 和这个具体的 View 层接口耦合,较难复用于其他业务。

  • 有限内聚的界面绘制:MVP 并未向界面提供唯一 Model,而是将描述一个完整界面的 Model 分散在若干 View 层接口回调中。这使得界面的绘制无法内聚到一点,增加了界面绘制逻辑维护的复杂度。

  • 困难重重的复用:理论上,界面和业务分层之后,各自都更加单纯,为复用提供了可能性。但不管是业务接口的复用,还是View层接口的复用都相当别扭。

  • Presenter 与界面共存亡:这个特性使得 MVP 无法应对横竖屏切换的场景。

  • 无内建跨界面(粘性)通信机制:MVP 无法优雅地实现跨界面通信,也未内建粘性通信机制,得借助第三方库实现。

  • 生命周期不友好:MVP 并未内建生命周期管理机制,易造成内存泄漏、crash、资源浪费。

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