2022 年 App 上架审核问题集锦,全面踩坑上线不迷路
相信这几年负责过上架应用市场的 App 开发,或多或少都躺过上线审核的坑,经历过的各种问题也是千奇百怪,今天就给大家做个汇总,希望可以帮助大家少走弯路,争取做一个“优雅”的客户端开发。
首先,近年来为了 “净化” App 环境、保护用户隐私和优化用户体验,各部委大致出台过如下所示的相关法规:
内容 | 时间 |
---|---|
《教育移动互联网应用程序备案管理办法》 | 2019 年 11 月 13 日 |
《App违法违规收集使用个人信息行为认定方法》 | 2019 年 12 月 30 日 |
《常见类型移动互联网应用程序必要个人信息范围规定》 | 2021 年 03 月 22 日 |
《个人信息保护法》 | 2021 年 11 月 1 日 |
《移动互联网应用程序(App)个人信息保护治理白皮书》 | 2021 年 11 月 22 日 |
《互联网用户账号信息管理规定》 | 2022 年 1 月 1 日 |
《数据出境安全评估办法》 | 2022 年 9 月 1 日 |
《互联网弹窗信息推送服务管理规定》 | 2022 年 9 月 30 日 |
可能还有一些我不知道的遗漏,那不知道这些法规你是否都听说过,这里举一些常见例子:
- 《互联网用户账号信息管理规定》 的第十二条就是在 App 展示用户 IP 的要求相关条款。
- 《常见类型移动互联网应用程序必要个人信息范围规定》就规定了 App 类目所能获取的权限范围和个人信息索取范围,例如新闻资讯类、浏览器类、安全管理类、应用商店类等无须个人信息,即可使用基本功能服务。
针对上面这个无需权限和个人信息也要提供基本功能服务,如下动图所示,今日头条、知乎和懂车帝就是很好的参考例子,在不同意个人隐私协议的情况下,会有仅浏览的模式,在这个情况下依然可以阅读内容而不是退出 App 。
所以严格意义上讲,现在 App 按照类目的规定,如果你的 App 在某些类目就只能获取对应权限,多了就是违规,而且一些类目必须用户在没有提供权限和同意协议的情况下,也必须提供服务。
- 《互联网弹窗信息推送服务管理规定》里就有: 弹窗推送广告显著标明“广告”,一键关闭,提供取消渠道等。
如下图所示,从意见稿开始之后,基本大部分 App 的启动广告就限制了有效点击范围,产品经理也不能拍着脑袋让你加各种奇奇怪怪的跳转。
- 《个人信息保护法》 这个大家肯定就不会陌生了,我在之前《个人信息保护法》更新究竟是什么 也聊过,其中最主要就是提供个人信息的导出机制和广告推送相关内容。
首先用户必须同意了你才能收集,不同意是不能收集,所以 App 里各式各样的弹出框就来了,这也是目前最常见的“合规方式”。
而导出个人信息的功能普遍是通过邮箱发送实现,事实上目前还有不少 App 没提供类似支持,还有 App 必须提供用户注销功能,这也是现在 App 开发的必选项,另外 App 还需要提供个性化推荐的开关能力,不然也有审核风险,当时有时候只是需要你放个按键。
另外,在《个保法》的提案里也提及了不能以用户不提供个人信息为由不提供服务,当时实际执行往往还是要看应用类目。
而在用户个人信息认定里,设备id (Android ID) 绝对是重灾区 ,因为几乎是个 App 就会使用到设备 ID,特别是接入的各类第三方 SDK 服务里普遍都会获取。
而处理方法也是普通粗旷,用户不同意隐私协议,就不初始化各类 SDK ,当然,有时候你可能还是会遇到某些奇葩的审核,明明你已经做了处理,平台还认定你违规,这时候可能你就需要学会申诉,不要傻傻自己一直摸索哪里还不对。
总的来说上架问题一般是和个人信息隐私相关的问题最多,而常见的问题有:
- 未经用户允许手机个人信息
- 所需信息和服务无关,过度收集
- 未提供导出和删除个人信息的功能服务
- 存在个人信息泄漏风险
- 未明确公布个人信息收集的目的和使用范围
最后这一条也是经常出现问题的点,例如现在会要求你提供哪些 SDK 使用了哪些权限和信息,收集规则是什么用于做什么 ,这也就需要 App 里提供更详细和丰富的隐私政策内容,当然 SDK 提供方也要。
而一般情况下最常见也是最容易触发整改的,就是设备ID,MAC 地址等相关内容,或者说你的 App 其实根本不需要这些也能提供服务,就如前面 《常见类型移动互联网应用程序必要个人信息范围规定》里的要求一样。
这里还有个关键,那就是用户在同意隐私条款时,你不能默认勾选,也就是有需要用户同意☑️的 UI 时,默认时不能选中,需要用户手动勾选同意。
当然,随着审核颗粒度的细化,越来越多奇奇怪怪的问题出现了,例如 Apk 里的资源文件存在安全泄漏问题 ,而解决该问题的有效方法就是:混淆和加固。
加固和混淆也适用于以下相关问题的解决,当然,加固的话建议选用第三方付费服务,免费加固的坑实在太多了。
- 《数据出境安全评估办法》 里针对数据出境也做了要求,其中最直观的例子就是:高德 SDK 无法在以外地区范围服务。
当然,不只是相关法规,平台有时候也有自己的规定和理解,比如有几位群友,先后在小米因为 App 里提供 UI 和商店截图一致被打回,理由是应用截图与应用实际功能不符 ,相信遇到这类问题的兄弟是相当郁闷,因为不一致这个认定其实很主观。
另外小米等平台还有以没通过Monkey 自动化测试为理由拒绝上架 ,一般这种情况推荐自己上传 testit.miui.com ,通过小米自动化测试后在上传审核时把你通过截图作为附加,这样可以解决审核时的扯皮问题。
有时候一些平台也会有安全扫描,例如华为就会扫描同名的包名,然后附上 git 链接告诉你风险 。
另外,华为审核时可能会对你的产品逻辑提出他们的想法,比如空白页面,添加引导,没有客服返回渠道等等。
还有另外一个高风险点就是自启动,相信我,如果你要上架平台,2022 年了就不要再想做什么保活相关的逻辑了。
除此之外,如果平台说你存在问题,尽量想办法要到检测报告,因为有时候一些平台委托的第三方可能会不是很“靠谱“,然后需要你自己出钱区做”二次付费检测“。
除了上面的问题之后,如果你还遇到如下图类似问题,都可以通过一些官方平台的检测如 open.oppomobile.com/opdp/privac… 帮助查找问题,这样也许就可以帮老板省下一笔开销,当然有一些第三方开源平台如 Hegui3.0 和 PrivacySentry 等项目,也可以帮助你解决一些实际问题。
最后,如果关于什么上架审核或者安全合规等问题,欢迎留言评论,也许以后本篇可以作为一个更新集合,继续帮助到更多需要的可怜 App 开发。
作者:恋猫de小郭
链接:https://juejin.cn/post/7142363251911688222
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »
毕业5年了还不知道热修复?
前言
热修复
到现在2022年已经不是一个新名词,但是作为Android开发核心技术栈的一部分,我这里还得来一次冷饭热炒。
随着移动端业务复杂程度的增加,传统的版本更新流程显然无法满足业务和开发者的需求,
热修复技术的推出在很大程度上改善了这一局面。国内大部分成熟的主流 App都拥有自己的热更新技术,像手淘、支付宝、微信、QQ、饿了么、美团等。
可以说,一个好的热修复技术,将为你的 App助力百倍。对于每一个想在 Android 开发领域有所造诣的开发者,掌握热修复技术更是必备的素质。
热修复
是 Android 大厂面试中高频面试知识点,也是我们必须要掌握的知识点。热修复技术,可以看作 Android平台发展成熟至一定阶段的必然产物。
Android热修复了解吗?修复哪些东西?
常见热修复框架对比以及各原理分析?
1.什么是热修复
热修复说白了就是不再使用传统的应用商店更新或者自更新方式,使用补丁包推送的方式在用户无感知的情况下,修复应用bug或者推送新的需求
传统更新
和热更新
过程对比如下:
热修复优缺点
:
- 优点:
- 1.只需要打补丁包,不需要重新发版本。
- 2.用户无感知,不需要重新下载最新应用
- 3.修复成功率高
- 缺点:
- 补丁包滥用,容易导致应用版本不可控,需要开发一套完整的补丁包更新机制,会增加一定的成本
2.热修复方案
首先我们得知道热修复修复哪些东西?
- 1.代码修复
- 2.资源修复
- 3.动态库修复
2.1:代码修复方案
从技术角度来说,我们的目的是非常明确的:把错误的代码替换成正确的代码。
注意这里的替换,并不是直接擦写dx文件,而是提供一份新的正确代码,让应用运行时绕过错误代码,执行新的正确代码。
想法简单直接,但实现起来并不容易。目前主要有三类技术方案:
2.1.1.类加载方案
之前分析类加载机制有说过:
加载流程先是遵循双亲委派原则,如果委派原则没有找到此前加载过此类,
则会调用CLassLoader的findClass方法,再去BaseDexClassLoader下面的dexElements数组中查找,如果没有找到,最终调用defineClassNative方法加载
代码修复就是基于这点:
将新的做了修复的dex文件,通过反射注入到BaseDexClassLoader的dexElements数组的第一个位置上dexElements[0],下次重新启动应用加载类的时候,会优先加载做了修复的dex文件,这样就达到了修复代码的目的。原理很简单
代码如下:
public class Hotfix {
public static void patch(Context context, String patchDexFile, String patchClassName)
throws ClassNotFoundException, NoSuchFieldException, IllegalAccessException, InstantiationException, NoSuchMethodException, InvocationTargetException {
//获取系统PathClassLoader的"dexElements"属性值
PathClassLoader pathClassLoader = (PathClassLoader) context.getClassLoader();
Object origDexElements = getDexElements(pathClassLoader);
//新建DexClassLoader并获取“dexElements”属性值
String otpDir = context.getDir("dex", 0).getAbsolutePath();
Log.i("hotfix", "otpdir=" + otpDir);
DexClassLoader nDexClassLoader = new DexClassLoader(patchDexFile, otpDir, patchDexFile, context.getClassLoader());
Object patchDexElements = getDexElements(nDexClassLoader);
//将patchDexElements插入原origDexElements前面
Object allDexElements = combineArray(origDexElements, patchDexElements);
//将新的allDexElements重新设置回pathClassLoader
setDexElements(pathClassLoader, allDexElements);
//重新加载类
pathClassLoader.loadClass(patchClassName);
}
private static Object getDexElements(ClassLoader classLoader) throws ClassNotFoundException, NoSuchFieldException, IllegalAccessException {
//首先获取ClassLoader的“pathList”实例
Field pathListField = Class.forName("dalvik.system.BaseDexClassLoader").getDeclaredField("pathList");
pathListField.setAccessible(true);//设置为可访问
Object pathList = pathListField.get(classLoader);
//然后获取“pathList”实例的“dexElements”属性
Field dexElementField = pathList.getClass().getDeclaredField("dexElements");
dexElementField.setAccessible(true);
//读取"dexElements"的值
Object elements = dexElementField.get(pathList);
return elements;
}
//合拼dexElements
private static Object combineArray(Object obj, Object obj2) {
Class componentType = obj2.getClass().getComponentType();
//读取obj长度
int length = Array.getLength(obj);
//读取obj2长度
int length2 = Array.getLength(obj2);
Log.i("hotfix", "length=" + length + ",length2=" + length2);
//创建一个新Array实例,长度为ojb和obj2之和
Object newInstance = Array.newInstance(componentType, length + length2);
for (int i = 0; i < length + length2; i++) {
//把obj2元素插入前面
if (i < length2) {
Array.set(newInstance, i, Array.get(obj2, i));
} else {
//把obj元素依次放在后面
Array.set(newInstance, i, Array.get(obj, i - length2));
}
}
//返回新的Array实例
return newInstance;
}
private static void setDexElements(ClassLoader classLoader, Object dexElements) throws ClassNotFoundException, NoSuchFieldException, IllegalAccessException {
//首先获取ClassLoader的“pathList”实例
Field pathListField = Class.forName("dalvik.system.BaseDexClassLoader").getDeclaredField("pathList");
pathListField.setAccessible(true);//设置为可访问
Object pathList = pathListField.get(classLoader);
//然后获取“pathList”实例的“dexElements”属性
Field declaredField = pathList.getClass().getDeclaredField("dexElements");
declaredField.setAccessible(true);
//设置"dexElements"的值
declaredField.set(pathList, dexElements);
}
}
类加载过程如下:
微信Tinker
,QQ 空间的超级补丁
、手 QQ 的QFix
、饿了 么的 Amigo
和 Nuwa
等都是使用这个方式
缺点:因为类加载后无法卸载,所以类加载方案必须重启App,让bug类重新加载后才能生效。
2.1.2:底层替换方案
底层替换方案不会再次加载新类,而是直接在 Native 层 修改原有类,
这里我们需要提到Art虚拟机中ArtMethod
:
每一个Java方法在Art虚拟机中都对应着一个 ArtMethod
,ArtMethod记录了这个Java方法的所有信息,包括所属类、访问权限、代码执行地址等。
结构如下:
// art/runtime/art_method.h
class ArtMethod FINAL {
...
protected:
GcRoot<mirror::Class> declaring_class_;
GcRoot<mirror::PointerArray> dex_cache_resolved_methods_;
GcRoot<mirror::ObjectArray<mirror::Class>> dex_cache_resolved_types_;
uint32_t access_flags_;
uint32_t dex_code_item_offset_;
uint32_t dex_method_index_;
uint32_t method_index_;
struct PACKED(4) PtrSizedFields {
void* entry_point_from_interpreter_; // 1
void* entry_point_from_jni_;
void* entry_point_from_quick_compiled_code_; //2
} ptr_sized_fields_;
...
}
在 ArtMethod结构体中,最重要的就是 注释1和注释2标注的内容,从名字可以看出来,他们就是方法的执行入口。
我们知道,Java代码在Android中会被编译为 Dex Code。
Art虚拟机中可以采用解释模式或者 AOT机器码模式执行 Dex Code
- 解释模式:
就是去除Dex Code,逐条解释执行。
如果方法的调用者是以解释模式运行的,在调用这个方法时,就会获取这个方法的 entry_point_from_interpreter_,然后跳转执行。 - AOT模式:
就会预先编译好 Dex Code对应的机器码,然后在运行期直接执行机器码,不需要逐条解释执行Dex Code。
如果方法的调用者是以AOT机器码方式执行的,在调用这个方法时,就是跳转到 entry_point_from_quick_compiled_code_中执行。
那是不是只需要替换这个几个 entry_point_* 入口地址就能够实现方法替换了呢?
并没有那么简单,因为不论是解释模式还是AOT模式,在运行期间还会需要调用ArtMethod中的其他成员字段
AndFix采用的是改变指针指向:
// AndFix/jni/art/art_method_replace_6_0.cpp
void replace_6_0(JNIEnv* env, jobject src, jobject dest) {
art::mirror::ArtMethod* smeth =
(art::mirror::ArtMethod*) env->FromReflectedMethod(src); // 1
art::mirror::ArtMethod* dmeth =
(art::mirror::ArtMethod*) env->FromReflectedMethod(dest); // 2
...
// 3
smeth->declaring_class_ = dmeth->declaring_class_;
smeth->dex_cache_resolved_methods_ = dmeth->dex_cache_resolved_methods_;
smeth->dex_cache_resolved_types_ = dmeth->dex_cache_resolved_types_;
smeth->access_flags_ = dmeth->access_flags_ | 0x0001;
smeth->dex_code_item_offset_ = dmeth->dex_code_item_offset_;
smeth->dex_method_index_ = dmeth->dex_method_index_;
smeth->method_index_ = dmeth->method_index_;
smeth->ptr_sized_fields_.entry_point_from_interpreter_ =
dmeth->ptr_sized_fields_.entry_point_from_interpreter_;
smeth->ptr_sized_fields_.entry_point_from_jni_ =
dmeth->ptr_sized_fields_.entry_point_from_jni_;
smeth->ptr_sized_fields_.entry_point_from_quick_compiled_code_ =
dmeth->ptr_sized_fields_.entry_point_from_quick_compiled_code_;
LOGD("replace_6_0: %d , %d",
smeth->ptr_sized_fields_.entry_point_from_quick_compiled_code_,
dmeth->ptr_sized_fields_.entry_point_from_quick_compiled_code_);
}
缺点:存在一些兼容性问题,由于ArtMethod结构体是Android开源的一部分,所以每个手机厂商都可能会去更改这部分的内容,这就可能导致ArtMethod替换方案在某些机型上面出现未知错误。
Sophix为了规避上面的AndFix的风险,采用直接替换整个结构体
。这样不管手机厂商如何更改系统,我们都可以正确定位到方法地址
2.4.3:install run
方案
Instant Run 方案的核心思想是——插桩,在编译时通过插桩在每一个方法中插入代码,修改代码逻辑,在需要时绕过错误方法,调用patch类的正确方法。
首先,在编译时Instant Run为每个类插入IncrementalChange变量
IncrementalChange $change;
为每一个方法添加类似如下代码:
public void onCreate(Bundle savedInstanceState) {
IncrementalChange var2 = $change;
//$change不为null,表示该类有修改,需要重定向
if(var2 != null) {
//通过access$dispatch方法跳转到patch类的正确方法
var2.access$dispatch("onCreate.(Landroid/os/Bundle;)V", new Object[]{this, savedInstanceState});
} else {
super.onCreate(savedInstanceState);
this.setContentView(2130968601);
this.tv = (TextView)this.findViewById(2131492944);
}
}
如上代码,当一个类被修改后,Instant Run会为这个类新建一个类,命名为xxx&override,且实现IncrementalChange接口,并且赋值给原类的$change变量。
public class MainActivity$override implements IncrementalChange {
}
此时,在运行时原类中每个方法的var2 != null,通过accessdispatch(参数是方法名和原参数)定位到patch类MainActivitydispatch(参数是方法名和原参数)定位到patch类MainActivityoverride中修改后的方法。
Instant Run是google在AS2.0时用来实现“热部署”的,同时也为“热修复”提供了一个绝佳的思路。美团的Robust就是基于此。
2.2:资源修复方案
这里我们来看看install run的原理即可,市面上的常见修复方案大部分都是基于此方法。
public static void monkeyPatchExistingResources(Context context,
String externalResourceFile, Collection<Activity> activities) {
if (externalResourceFile == null) {
return;
}
try {
// 创建一个新的AssetManager
AssetManager newAssetManager = (AssetManager) AssetManager.class
.getConstructor(new Class[0]).newInstance(new Object[0]); // ... 1
Method mAddAssetPath = AssetManager.class.getDeclaredMethod(
"addAssetPath", new Class[] { String.class }); // ... 2
mAddAssetPath.setAccessible(true);
// 通过反射调用addAssetPath方法加载外部的资源(SD卡资源)
if (((Integer) mAddAssetPath.invoke(newAssetManager,
new Object[] { externalResourceFile })).intValue() == 0) { // ... 3
throw new IllegalStateException(
"Could not create new AssetManager");
}
Method mEnsureStringBlocks = AssetManager.class.getDeclaredMethod(
"ensureStringBlocks", new Class[0]);
mEnsureStringBlocks.setAccessible(true);
mEnsureStringBlocks.invoke(newAssetManager, new Object[0]);
if (activities != null) {
for (Activity activity : activities) {
Resources resources = activity.getResources(); // ... 4
try {
// 反射得到Resources的AssetManager类型的mAssets字段
Field mAssets = Resources.class
.getDeclaredField("mAssets"); // ... 5
mAssets.setAccessible(true);
// 将mAssets字段的引用替换为新创建的newAssetManager
mAssets.set(resources, newAssetManager); // ... 6
} catch (Throwable ignore) {
...
}
// 得到Activity的Resources.Theme
Resources.Theme theme = activity.getTheme();
try {
try {
// 反射得到Resources.Theme的mAssets字段
Field ma = Resources.Theme.class
.getDeclaredField("mAssets");
ma.setAccessible(true);
// 将Resources.Theme的mAssets字段的引用替换为新创建的newAssetManager
ma.set(theme, newAssetManager); // ... 7
} catch (NoSuchFieldException ignore) {
...
}
...
} catch (Throwable e) {
Log.e("InstantRun",
"Failed to update existing theme for activity "
+ activity, e);
}
pruneResourceCaches(resources);
}
}
/**
* 根据SDK版本的不同,用不同的方式得到Resources 的弱引用集合
*/
Collection<WeakReference<Resources>> references;
if (Build.VERSION.SDK_INT >= 19) {
Class<?> resourcesManagerClass = Class
.forName("android.app.ResourcesManager");
Method mGetInstance = resourcesManagerClass.getDeclaredMethod(
"getInstance", new Class[0]);
mGetInstance.setAccessible(true);
Object resourcesManager = mGetInstance.invoke(null,
new Object[0]);
try {
Field fMActiveResources = resourcesManagerClass
.getDeclaredField("mActiveResources");
fMActiveResources.setAccessible(true);
ArrayMap<?, WeakReference<Resources>> arrayMap = (ArrayMap) fMActiveResources
.get(resourcesManager);
references = arrayMap.values();
} catch (NoSuchFieldException ignore) {
Field mResourceReferences = resourcesManagerClass
.getDeclaredField("mResourceReferences");
mResourceReferences.setAccessible(true);
references = (Collection) mResourceReferences
.get(resourcesManager);
}
} else {
Class<?> activityThread = Class
.forName("android.app.ActivityThread");
Field fMActiveResources = activityThread
.getDeclaredField("mActiveResources");
fMActiveResources.setAccessible(true);
Object thread = getActivityThread(context, activityThread);
HashMap<?, WeakReference<Resources>> map = (HashMap) fMActiveResources
.get(thread);
references = map.values();
}
//遍历并得到弱引用集合中的 Resources ,将 Resources mAssets 字段引用替换成新的 AssetManager
for (WeakReference<Resources> wr : references) {
Resources resources = (Resources) wr.get();
if (resources != null) {
try {
Field mAssets = Resources.class
.getDeclaredField("mAssets");
mAssets.setAccessible(true);
mAssets.set(resources, newAssetManager);
} catch (Throwable ignore) {
...
}
resources.updateConfiguration(resources.getConfiguration(),
resources.getDisplayMetrics());
}
}
} catch (Throwable e) {
throw new IllegalStateException(e);
}
}
- 在注释1处创建一个新的 AssetManager ,
- 在注释2 和注释3 处通过反射调用 addAssetPath 方法加载外部( SD 卡)的资源。
- 在注释4 处遍历 Activity 列表,得到每个 Activity 的 Resources ,
- 在注释5 处通过反射得到 Resources 的 AssetManager 类型的 rnAssets 字段 ,
- 注释6处改写 mAssets 字段的引用为新的 AssetManager 。
采用同样的方式,
- 在注释7处将 Resources. Theme 的 m Assets 字段 的引用替换为新创建的 AssetManager 。
- 紧接着 根据 SDK 版本的不同,用不同的方式得到 Resources 的弱引用集合,
- 再遍历这个弱引用集合, 将弱引用集合中的 Resources 的 mAssets 字段引用都替换成新创建的 AssetManager 。
资源修复原理
:
- 1.创建新的AssetManager,通过反射调用addAssetPath方法,加载外部资源,这样新创建的AssetManager就含有了外部资源
- 2.将AssetManager类型的mAsset字段全部用新创建的AssetManager对象替换。这样下次加载资源文件的时候就可以找到包含外部资源文件的AssetManager。
2.3:动态链接库so的修复
1.接口调用替换方案:
sdk提供接口替换System默认加载so库接口
SOPatchManager.loadLibrary(String libName) -> System.loadLibrary(String libName)
SOPatchManager.loadLibrary接口加载 so库的时候优先尝试去加载sdk 指定目录下的补丁so,
加载策略
如下:
如果存在则加载补丁 so库而不会去加载安装apk安装目录下的so库
如果不存在补丁so,那么调用System.loadLibrary去加载安装apk目录下的 so库。
我们可以很清楚的看到这个方案的优缺点:
优点:不需要对不同 sdk 版本进行兼容,因为所有的 sdk 版本都有 System.loadLibrary 这个接口。
缺点:调用方需要替换掉 System 默认加载 so 库接口为 sdk提供的接口, 如果是已经编译混淆好的三方库的so 库需要 patch,那么是很难做到接口的替换
虽然这种方案实现简单,同时不需要对不同 sdk版本区分处理,但是有一定的局限性没法修复三方包的so库同时需要强制侵入接入方接口调用,接着我们来看下反射注入方案。
2、反射注入方案
前面介绍过 System. loadLibrary ( "native-lib"); 加载 so库的原理,其实native-lib 这个 so 库最终传给 native 方法执行的参数是 so库在磁盘中的完整路径,比如:/data/app-lib/com.taobao.jni-2/libnative-lib.so, so库会在 DexPathList.nativeLibraryDirectories/nativeLibraryPathElements 变量所表示的目录下去遍历搜索
sdk<23 DexPathList.findLibrary 实现如下
可以发现会遍历 nativeLibraryDirectories数组,如果找到了 loUtils.canOpenReadOnly (path)返回为 true, 那么就直接返回该 path, loUtils.canOpenReadOnly (path)返回为 true 的前提肯定是需要 path 表示的 so文件存 在的。那么我们可以采取类似类修复反射注入方式,只要把我们的补丁so库的路径插入到nativeLibraryDirectories数组的最前面就能够达到加载so库的时候是补丁 库而不是原来so库的目录,从而达到修复的目的。
sdk>=23 DexPathList.findLibrary 实现如下
sdk23 以上 findLibrary 实现已经发生了变化,如上所示,那么我们只需要把补丁so库的完整路径作为参数构建一个Element对象,然后再插入到nativeLibraryPathElements 数组的最前面就好了。
- 优点:可以修复三方库的so库。同时接入方不需要像方案1 —样强制侵入用 户接口调用
- 缺点:需要不断的对 sdk 进行适配,如上 sdk23 为分界线,findLibrary接口实现已经发生了变化。
对于 so库的修复方案目前更多采取的是接口调用替换方式,需要强制侵入用户 接口调用。
目前我们的so文件修复方案采取的是反射注入的方案,重启生效。具有更好的普遍性。
如果有so文件修复实时生效的需求,也是可以做到的,只是有些限制情况。
常见热修复框架?
特性 | Dexposed | AndFix | Tinker/Amigo | QQ Zone | Robust/Aceso | Sophix |
---|---|---|---|---|---|---|
技术原理 | native底层替换 | native底层替换 | 类加载 | 类加载 | Instant Run | 混合 |
所属 | 阿里 | 阿里 | 微信/饿了么 | QQ空间 | 美团/蘑菇街 | 阿里 |
即时生效 | YES | YES | NO | NO | YES | 混合 |
方法替换 | YES | YES | YES | YES | YES | YES |
类替换 | NO | NO | YES | YES | YES | YES |
类结构修改 | NO | NO | YES | NO | NO | YES |
资源替换 | NO | NO | YES | YES | NO | YES |
so替换 | NO | NO | YES | NO | NO | YES |
支持gradle | NO | NO | YES | YES | YES | YES |
支持ART | NO | YES | YES | YES | YES | YES |
可以看出,阿里系多采用native底层方案,腾讯系多采用类加载机制。其中,Sophix是商业化方案;Tinker/Amigo支持特性较多,同时也更复杂,如果需要修复资源和so,可以选择;如果仅需要方法替换,且需要即时生效,Robust是不错的选择。
总结:
尽管热修复(或热更新)相对于迭代更新有诸多优势,市面上也有很多开源方案可供选择,但目前热修复依然无法替代迭代更新模式。有如下原因:
热修复框架多多少少会增加性能开销,或增加APK大小
热修复技术本身存在局限,比如有些方案无法替换so或资源文件
热修复方案的兼容性,有些方案无法同时兼顾Dalvik和ART,有些深度定制系统也无法正常工作
监管风险,比如苹果系统严格限制热修复
所以,对于功能迭代和常规bug修复,版本迭代更新依然是主流。一般的代码修复,使用Robust可以解决,如果还需要修复资源或so库,可以考虑Tinker。
作者:高级攻城狮
链接:https://juejin.cn/post/7142481619604111390
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
LinkedList源码解析
LinkedList源码解析
目标
- 理解LinkedList底层数据结构
- 深入源码掌握LinkedList查询慢,新增快的原因
1.简介
List 接口的链接列表实现。实现所有可选的列表操作,并且允许所有元素(包括 null )。除了实现 List 接口外, LinkedList 类还为在列表的开头及结尾 get 、 remove 和 insert 元素提供了统一 的命名方法。这些操作允许将链接列表用作堆栈、队列或双端队列。
特点 :
- 有序性 : 存入和取出的顺序是一致的
- 元素可以重复
- 含有带索引的方法
- 独有特点 : 数据结构是链表,可以作为栈、队列或者双端队列!
2.LinkedList原理分析
双向链表
底层数据结构源码
public class LinkedList<E> {
transient int size = 0;
//双向链表的头结点
transient Node<E> first;
//双向链表的最后一个节点
transient Node<E> last;
//节点类【内部类】
private static class Node<E> {
E item;//数据元素
Node<E> next;//下一个节点
Node<E> prev;//上一个节点
//节点的构造方法
Node(Node<E> prev, E element, Node<E> next) {
this.item = element;
this.next = next;
this.prev = prev;
}
} /
/...
}
2.1 LinkedList的数据结构
LinkedList是双向链表,在代码中是一个Node类。内部并没有数组的结构。双向链表肯定存在一个头节 点和一个尾部节点。node节点类,是以内部类的形式存在于LinkedList中的。Node类都有两个成员变 量:
- prev : 当前节点上一个节点,头节点的上一个节点是null
- next : 当前节点下一个节点,尾结点的下一个节点是null
链表数据结构的特点 : 查询慢,增删快!
- 链表数据结构基本构成,是一个node类
- 每个node类中,有上一个节点【prev】和下一个节点【next】
- 链表一定存在至少两个节点,first和last节点
- 如果LinkedList没有数据,first和last都是为null
2.2 LinkedList默认容量&最大容量
没有默认容量,也没有最大容量
2.3 LinkedList扩容机制
无需扩容机制,只要你的内存足够大,可以无限制扩容下去。前提是不考虑查询的效率。
2.4 为什么LinkedList查询慢,增删快?
LinkedList的数据结构的特点,链表的数据结构就是这样的特点!
- 链表是一种查询慢的结构【相对于数组来说】
- 链表是一种增删快的结构【相对于数组来说】
2.5 LinkedList源码剖析-为什么增删快?
新增add
//想LinkedList添加一个元素
public boolean add(E e){
//连接到链表的末尾
linkLast(e);
return true;
}/
/连接到最后一个节点上去
void linkLast(E e){
//将全局末尾节点赋值给l
final Node<E> l=last;
//创建一个新节点 : (上一个节点, 当前插入元素, null)
final Node<E> newNode=new Node<>(l,e,null);
//将当前节点作为末尾节点
last=newNode;
//判断l节点是否为null
if(l==null)
//既是尾结点也是头节点
first=newNode;
else
//之前的末尾节点,下一个节点时末尾节点!
l.next=newNode;
size++;//当前集合的元素数量+1
modCount++;//操作集合数+1。modCount属性是修改技术器
}/
/------------------------------------------------------------------
//向链表中部添加
//参数1,添加的索引位置,添加元素
public void add(int index,E element){
//检查索引位是否符合要求
checkPositionIndex(index);
//判断当前所有是否是存储元素个数
if(index==size)//true,最后一个元素
linkLast(element);
else
//连接到指定节点的后面【链表中部插入】
linkBefore(element,node(index));
}/
/根据索引查询链表中节点!
Node<E> node(int index){
// 判断索引是否小于 已经存储元素个数的1/2
if(index< (size>>1)){//二分法查找 : 提高查找节点效率
Node<E> x=first;
for(int i=0;i<index; i++)
x=x.next;
return x;
}else{
Node<E> x=last;
for(int i=size-1;i>index;i--)
x=x.prev;
return x;
}
}/
/将当前元素添加到指定节点之前
void linkBefore(E e,Node<E> succ){
// 取出当前节点的前一个节点
final Node<E> pred=succ.prev;
//创建当前元素的节点 : 上一个节点,当前元素,下一个节点
final Node<E> newNode=new Node<>(pred,e,succ);
//为指定节点上一个节点重新值
succ.prev=newNode;
//判断当前节点的上一个节点是否为null
if(pred==null)
first=newNode;//当前节点作为头部节点
else
pred.next=newNode;//将新插入节点作为上一个节点的下个节点
size++;//新增元素+1
modCount++;//操作次数+1
}
remove删除指定索引元素
//删除指定索引位置元素
public E remove(int index){
//检查元素索引
checkElementIndex(index);
//删除元素节点,
//node(index) 根据索引查到要删除的节点
//unlink()删除节点
return unlink(node(index));
}//根据索引查询链表中节点!
Node<E> node(int index){
// 判断索引是否小于 已经存储元素个数的1/2
if(index< (size>>1)){//二分法查找 : 提高查找节点效率
Node<E> x=first;
for(int i=0;i<index; i++)
x=x.next;
return x;
}else{
Node<E> x=last;
for(int i=size-1;i>index;i--)
x=x.prev;
return x;
}
}/
/删除一个指定节点
E unlink(Node<E> x){
//获取当前节点中的元素
final E element=x.item;
//获取当前节点的上一个节点
final Node<E> next=x.next;
//获取当前节点的下一个节点
final Node<E> prev=x.prev;
//判断上一个节点是否为null
if(prev==null){
//如果为null,说明当前节点为头部节点
first=next;
}else{
//上一个节点,的下一个节点改为下下节点
prev.next=next;
//将当前节点的上一个节点置空
x.prev=null;
}/
/判断下一个节点是否为null
if(next==null){
//如果为null,说明当前节点为尾部节点
last=prev;
}else{
//下一个节点的上节点,改为上上节点
next.prev=prev;
//当前节点的上节点置空
x.next=null;
}/
/删除当前节点内的元素
x.item=null;
size--;//集合中的元素个数-1
modCount++;//当前集合操作数+1。modCount计数器,记录当前集合操作次数
return element;//返回删除的元素
}
2.6 LinkedList源码剖析-为什么查询慢?
查询快和慢是一个相对概念!相对于数组来说
//根据索引查询一个元素
public E get(int index){
//检查索引是否存在
checkElementIndex(index);
// node(index)获取索引对应节点,获取节点中的数据item
return node(index).item;
}/
/根据索引获取对应节点对象
Node<E> node(int index){
//二分法查找索引对应的元素
if(index< (size>>1)){
Node<E> x=first;
//前半部分查找【遍历节点】
for(int i=0;i<index; i++)
x=x.next;
return x;
}else{
Node<E> x=last;
//后半部分查找【遍历】
for(int i=size-1;i>index;i--)
x=x.prev;
return x;
}
}/
/查看ArrayList里的数组获取元素的方式
public E get(int index){
rangeCheck(index);//检查范围
return elementData(index);//获取元素
}E
elementData(int index){
return(E)elementData[index];//一次性操作
}
作者:会飞的汤姆猫
链接:https://juejin.cn/post/7139026562154201125
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
最安全的加密算法 Bcrypt,再也不用担心数据泄密了~
这是《Spring Security 进阶》专栏的第三篇文章,给大家介绍一下Spring Security 中内置的加密算法BCrypt
,号称最安全的加密算法,究竟有着什么魔力能让黑客闻风丧胆
哈希(Hash)与加密(Encrypt)
哈希(Hash)是将目标文本转换成具有相同长度的、不可逆的杂凑字符串(或叫做消息摘要),而加密(Encrypt)是将目标文本转换成具有不同长度的、可逆的密文。
- 哈希算法往往被设计成生成具有相同长度的文本,而加密算法生成的文本长度与明文本身的长度有关。
- 哈希算法是不可逆的,而加密算法是可逆的。
HASH 算法是一种消息摘要算法,不是一种加密算法,但由于其单向运算,具有一定的不可逆性,成为加密算法中的一个构成部分。
JDK的String的Hash算法。代码如下:
public int hashCode() {
int h = hash;
if (h == 0 && value.length > 0) {
char val[] = value;
for (int i = 0; i < value.length; i++) {
h = 31 * h + val[i];
}
hash = h;
}
return h;
}
从JDK的API可以看出,它的算法等式就是s[0]*31^(n-1) + s[1]*31^(n-2) + ... + s[n-1]
,其中s[i]
就是索引为i的字符,n为字符串的长度。
HashMap的hash计算时先计算hashCode()
,然后进行二次hash。代码如下:
// 计算二次Hash
int hash = hash(key.hashCode());
static int hash(int h) {
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
可以发现,虽然算法不同,但经过这些移位操作后,对于同一个值使用同一个算法,计算出来的hash值一定是相同的。
那么,hash为什么是不可逆的呢?
假如有两个密码3和4,我的加密算法很简单就是3+4
,结果是7,但是通过7我不可能确定那两个密码是3和4,有很多种组合,这就是最简单的不可逆,所以只能通过暴力破解一个一个的试。
在计算过程中原文的部分信息是丢失了。一个MD5
理论上是可以对应多个原文的,因为MD5是有限多个而原文是无限多个的。
不可逆的MD5为什么是不安全的?
因为hash算法是固定的,所以同一个字符串计算出来的hash串是固定的,所以,可以采用如下的方式进行破解。
- 暴力枚举法:简单粗暴地枚举出所有原文,并计算出它们的哈希值,看看哪个哈希值和给定的信息摘要一致。
- 字典法:黑客利用一个巨大的字典,存储尽可能多的原文和对应的哈希值。每次用给定的信息摘要查找字典,即可快速找到碰撞的结果。
- 彩虹表(rainbow)法:在字典法的基础上改进,以时间换空间。是现在破解哈希常用的办法。
对于单机来说,暴力枚举法的时间成本很高(以14位字母和数字的组合密码为例,共有1.24×10^25种可能,即使电脑每秒钟能进行10亿次运算,也需要4亿年才能破解),字典法的空间成本很高(仍以14位字母和数字的组合密码为例,生成的密码32位哈希串的对照表将占用5.7×10^14 TB的存储空间)。但是利用分布式计算和分布式存储,仍然可以有效破解MD5算法。因此这两种方法同样被黑客们广泛使用。
如何防御彩虹表的破解?
虽然彩虹表有着如此惊人的破解效率,但网站的安全人员仍然有办法防御彩虹表。最有效的方法就是“加盐”,即在密码的特定位置插入特定的字符串,这个特定字符串就是“盐(Salt)”,加盐后的密码经过哈希加密得到的哈希串与加盐前的哈希串完全不同,黑客用彩虹表得到的密码根本就不是真正的密码。即使黑客知道了“盐”的内容、加盐的位置,还需要对H函数和R函数进行修改,彩虹表也需要重新生成,因此加盐能大大增加利用彩虹表攻击的难度。
一个网站,如果加密算法和盐都泄露了,那针对性攻击依然是非常不安全的。因为同一个加密算法同一个盐加密后的字符串仍然还是一毛一样滴!
一个更难破解的加密算法Bcrypt
BCrypt
是由Niels Provos和David Mazières设计的密码哈希函数,他是基于Blowfish密码而来的,并于1999年在USENIX上提出。
除了加盐来抵御rainbow table 攻击之外,bcrypt的一个非常重要的特征就是自适应性,可以保证加密的速度在一个特定的范围内,即使计算机的运算能力非常高,可以通过增加迭代次数的方式,使得加密速度变慢,从而可以抵御暴力搜索攻击。
Bcrypt
可以简单理解为它内部自己实现了随机加盐处理。使用Bcrypt,每次加密后的密文是不一样的。
对一个密码,Bcrypt每次生成的hash都不一样,那么它是如何进行校验的?
- 虽然对同一个密码,每次生成的hash不一样,但是hash中包含了salt(hash产生过程:先随机生成salt,salt跟password进行hash);
- 在下次校验时,从hash中取出salt,salt跟password进行hash;得到的结果跟保存在DB中的hash进行比对。
在Spring Security 中 内置了Bcrypt加密算法,构建也很简单,代码如下:
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
生成的加密字符串格式如下:
$2b$[cost]$[22 character salt][31 character hash]
比如:
$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy
\__/\/ \____________________/\_____________________________/
Alg Cost Salt Hash
上面例子中,$2a$
表示的hash算法的唯一标志。这里表示的是Bcrypt算法。
10
表示的是代价因子,这里是2的10次方,也就是1024
轮。
N9qo8uLOickgx2ZMRZoMye
是16个字节(128bits)的salt
经过base64编码得到的22长度的字符。
最后的IjZAgcfl7p92ldGxad68LJZdL17lhWy
是24个字节(192bits)的hash
,经过bash64的编码得到的31长度的字符。
PasswordEncoder 接口
这个接口是Spring Security 内置的,如下:
public interface PasswordEncoder {
String encode(CharSequence rawPassword);
boolean matches(CharSequence rawPassword, String encodedPassword);
default boolean upgradeEncoding(String encodedPassword) {
return false;
}
}
这个接口有三个方法:
encode
方法接受的参数是原始密码字符串,返回值是经过加密之后的hash值,hash值是不能被逆向解密的。这个方法通常在为系统添加用户,或者用户注册的时候使用。matches
方法是用来校验用户输入密码rawPassword,和加密后的hash值encodedPassword是否匹配。如果能够匹配返回true,表示用户输入的密码rawPassword是正确的,反之返回fasle。也就是说虽然这个hash值不能被逆向解密,但是可以判断是否和原始密码匹配。这个方法通常在用户登录的时候进行用户输入密码的正确性校验。upgradeEncoding
设计的用意是,判断当前的密码是否需要升级。也就是是否需要重新加密?需要的话返回true,不需要的话返回fasle。默认实现是返回false。
例如,我们可以通过如下示例代码在进行用户注册的时候加密存储用户密码
//将User保存到数据库表,该表包含password列
user.setPassword(passwordEncoder.encode(user.getPassword()));
BCryptPasswordEncoder
是Spring Security推荐使用的PasswordEncoder接口实现类
public class PasswordEncoderTest {
@Test
void bCryptPasswordTest(){
PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
String rawPassword = "123456"; //原始密码
String encodedPassword = passwordEncoder.encode(rawPassword); //加密后的密码
System.out.println("原始密码" + rawPassword);
System.out.println("加密之后的hash密码:" + encodedPassword);
System.out.println(rawPassword + "是否匹配" + encodedPassword + ":" //密码校验:true
+ passwordEncoder.matches(rawPassword, encodedPassword));
System.out.println("654321是否匹配" + encodedPassword + ":" //定义一个错误的密码进行校验:false
+ passwordEncoder.matches("654321", encodedPassword));
}
}
上面的测试用例执行的结果是下面这样的。(注意:对于同一个原始密码,每次加密之后的hash密码都是不一样的,这正是BCryptPasswordEncoder的强大之处,它不仅不能被破解,想通过常用密码对照表进行大海捞针你都无从下手),输出如下:
原始密码123456
加密之后的hash密码:$2a$10$zt6dUMTjNSyzINTGyiAgluna3mPm7qdgl26vj4tFpsFO6WlK5lXNm
123456是否匹配$2a$10$zt6dUMTjNSyzINTGyiAgluna3mPm7qdgl26vj4tFpsFO6WlK5lXNm:true
654321是否匹配$2a$10$zt6dUMTjNSyzINTGyiAgluna3mPm7qdgl26vj4tFpsFO6WlK5lXNm:false
BCrypt
产生随机盐(盐的作用就是每次做出来的菜味道都不一样)。这一点很重要,因为这意味着每次encode将产生不同的结果。
作者:码猿技术专栏
链接:https://juejin.cn/post/7143054506614489101
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
Android DIY你的菜单栏
前言
个人打算开发个视频编辑的APP,然后把一些用上的技术总结一下,这次主要是APP的底部菜单栏用到了一个自定义View去绘制实现的,所以这次主要想讲讲自定义View的一些用到的点和自己如何去DIY一个不一样的自定义布局。
实现的效果和思路
可以先看看实现的效果
两个页面的内容还没做,当前就是一个Demo,可以看到底部的菜单栏是一个绘制出来的不规则的一个布局,那要如何实现呢。可以先来看看它的一个绘制区域:
就是一个底部的布局和3个子view,底部的区域当然也是个规则的区域,只不过我们是在这块区域上去进行绘制。
可以把整个过程分为几个步骤:
1. 绘制底部布局
(1) 绘制矩形区域
(2) 绘制外圆形区域
(3) 绘制内圆形区域
2. 添加子view进行布局
3. 处理事件分发的区域 (底部菜单上边的白色区域不触发菜单的事件)
4. 写个动画意思意思
1. 绘制底部布局
这里做的话就没必要手动去添加view这些了,直接全部手动绘制就行。
companion object{
const val DIMENS_64 = 64.0
const val DIMENS_96 = 96.0
const val DIMENS_50 = 50.0
const val DIMENS_48 = 48.0
interface OnChildClickListener{
fun onClick(index : Int)
}
}
private var paint : Paint ?= null // 绘制蓝色区域的画笔
private var paint2 : Paint ?= null // 绘制白色内圆的画笔
private var allHeight : Int = 0 // 总高度,就是绘制的范围
private var bgHeight : Int = 0 // 背景的高度,就是蓝色矩阵的范围
private var mRadius : Int = 0 // 外圆的高度
private var mChildSize : Int = 0
private var mChildCenterSize : Int = 0
private var mWidthZone1 : Int = 0
private var mWidthZone2 : Int = 0
private var mChildCentre : Int = 0
private var childViews : MutableList<View> = mutableListOf()
private var objectAnimation : ObjectAnimator ?= null
var onChildClickListener : OnChildClickListener ?= null
init {
initView()
}
private fun initView(){
val lp = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,
DimensionUtils.dp2px(context, DIMENS_64).toInt())
layoutParams = lp
allHeight = DimensionUtils.dp2px(context, DIMENS_96).toInt()
bgHeight = DimensionUtils.dp2px(context, DIMENS_64).toInt()
mRadius = DimensionUtils.dp2px(context, DIMENS_50).toInt()
mChildSize = DimensionUtils.dp2px(context, DIMENS_48).toInt()
mChildCenterSize = DimensionUtils.dp2px(context, DIMENS_64).toInt()
setWillNotDraw(false)
initPaint()
}
private fun initPaint(){
paint = Paint()
paint?.isAntiAlias = true
paint?.color = context.resources.getColor(R.color.kylin_main_color)
paint2 = Paint()
paint2?.isAntiAlias = true
paint2?.color = context.resources.getColor(R.color.kylin_third_color)
}
上边是先把一些尺寸给定义好(我这边是没有设计图,自己去直接调整的,所以可能有些视觉效果不太好,如果有设计师帮忙的话效果肯定会好些),绘制流程就是绘制3个形状,然后代码里也加了些注释哪个变量有什么用,这步应该不难,没什么可以多解释的。
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
val wSize = MeasureSpec.getSize(widthMeasureSpec)
// 拿到子view做操作的,和这步无关,可以先不看
if (childViews.size <= 0) {
for (i in 0 until childCount) {
val cView = getChildAt(i)
initChildView(cView, i)
childViews.add(cView)
if (i == childCount/2){
val ms: Int = MeasureSpec.makeMeasureSpec(mRadius, MeasureSpec.AT_MOST)
measureChild(cView, ms, ms)
}else {
val ms: Int = MeasureSpec.makeMeasureSpec(mChildSize, MeasureSpec.AT_MOST)
measureChild(cView, ms, ms)
}
}
}
setMeasuredDimension(wSize, allHeight)
}
这步其实也很简单,就是说给当前自定义view设置高度为allHeight
override fun onDraw(canvas: Canvas?) {
super.onDraw(canvas)
// 绘制长方形区域
canvas?.drawRect(left.toFloat(), ((allHeight - bgHeight).toFloat()),
right.toFloat(), bottom.toFloat(), paint!!)
// 绘制圆形区域
paint?.let {
canvas?.drawCircle(
(width/2).toFloat(), mRadius.toFloat(),
mRadius.toFloat(),
it
)
}
// 绘制内圆区域
paint2?.let {
canvas?.drawCircle(
(width/2).toFloat(), mRadius.toFloat(),
(mRadius - 28).toFloat(),
it
)
}
}
最后进行绘制, 就是上面说的绘制3个图形,代码里的注释也说得很清楚。
2. 添加子view
我这里是外面布局去加子view的,想弄得灵活点(但感觉也不太好,后面还是想改成里面定义一套规范来弄会好些,如果自由度太高的话去做自定义就很麻烦,而且实际开发中这种需求也没必要把扩展性做到这种地步,基本就是整个APP只有一个地方使用)
但是这边也只是一个Demo先做个演示。
<com.kylin.libkcommons.widget.BottomMenuBar
android:id="@+id/bv_content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_alignParentBottom="true"
>
<ImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@drawable/home"
/>
<ImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@drawable/video"
/>
<ImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@drawable/more"
/>
</com.kylin.libkcommons.widget.BottomMenuBar>
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
val wSize = MeasureSpec.getSize(widthMeasureSpec)
if (childViews.size <= 0) {
for (i in 0 until childCount) {
val cView = getChildAt(i)
initChildView(cView, i)
childViews.add(cView)
if (i == childCount/2){
val ms: Int = MeasureSpec.makeMeasureSpec(mRadius, MeasureSpec.AT_MOST)
measureChild(cView, ms, ms)
}else {
val ms: Int = MeasureSpec.makeMeasureSpec(mChildSize, MeasureSpec.AT_MOST)
measureChild(cView, ms, ms)
}
}
}
setMeasuredDimension(wSize, allHeight)
}
拿到子view进行一个管理,做一些初始化的操作,主要是设点击事件这些,这里不是很重要。
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
if (mChildCentre == 0){
mChildCentre = width / 6
}
// 辅助事件分发区域
if (mWidthZone1 == 0 || mWidthZone2 == 0) {
mWidthZone1 = width / 2 - mRadius / 2
mWidthZone2 = width / 2 + mRadius / 2
}
// 设置每个子view的显示区域
for (i in 0 until childViews.size) {
if (i == childCount/2){
childViews[i].layout(mChildCentre*(2*i+1) - mChildCenterSize/2 ,
allHeight/2 - mChildCenterSize/2,
mChildCentre*(2*i+1) + mChildCenterSize/2 ,
allHeight/2 + mChildCenterSize/2)
}else {
childViews[i].layout(mChildCentre*(2*i+1) - mChildSize/2 ,
allHeight - bgHeight/2 - mChildSize/2,
mChildCentre*(2*i+1) + mChildSize/2 ,
allHeight - bgHeight/2 + mChildSize/2)
}
}
}
进行布局,这里比较重要,因为能看出,中间的图标会更大一些,所以要做一些适配。其实这里就是把宽度分为6块,然后3个view分别在1,3,5这三个左边点,y的话就是除中间那个,其它两个都是bgHeight绘制高度的的一半,中间那个是allHeight总高度的一半,这样3个view的x和y坐标都能拿到了,再根据宽高就能算出l,t,r,b四个点,然后布局。
3. 处理事件分发
可以看出我们的区域是一个不规则的区域,按照我们用抽象的角度去思考,我们希望这个菜单栏的区域只是显示蓝色的那个区域,所以蓝色区域上面的白色区域就算是我们自定义view的范围,他触发的事件也应该是后面的view的事件(Demo中后面的View是一个ViewPager),而不是菜单栏。
// 辅助事件分发区域
if (mWidthZone1 == 0 || mWidthZone2 == 0) {
mWidthZone1 = width / 2 - mRadius / 2
mWidthZone2 = width / 2 + mRadius / 2
}
这两块是圆外的x的区域。
/**
* 判断点击事件是否在点击区域中
*/
private fun isShowZone(x : Float, y : Float) : Boolean{
if (y >= allHeight - bgHeight){
return true
}
if (x >= mWidthZone1 && x <= mWidthZone2){
// 在圆内
val relativeX = abs(x - width/2)
val squareYZone = mRadius.toDouble().pow(2.0) - relativeX.toDouble().pow(2.0)
return y >= mRadius - sqrt(squareYZone)
}
return false
}
先判断y如果在背景的矩阵中(上面说了自定义view分成矩阵,外圆,内圆),那肯定是菜单的区域。如果不在,那就要判断y在不在圆内,这里就必须用勾股定理去判断。
override fun onTouchEvent(event: MotionEvent?): Boolean {
// 点击区域进行拦截
if (event?.action == MotionEvent.ACTION_DOWN && isShowZone(event.x, event.y)){
return true
}
return super.onTouchEvent(event)
}
最后做一个事件分发的拦截。除了计算区域那可能需要去想想,其它地方我觉得都挺好理解的吧。
4. 做个动画
给子view设点击事件让外部处理,然后给中间的按钮做个动画效果。
private fun initChildView(cView : View?, index : Int) {
cView?.setOnClickListener {
if (index == childViews.size/2) {
startAnim(cView)
}else {
onChildClickListener?.onClick(index)
}
}
}
private fun startAnim(view : View){
if (objectAnimation == null) {
objectAnimation = ObjectAnimator.ofFloat(view,
"rotation", 0f, -15f, 180f, 0f)
objectAnimation?.addListener(object : Animator.AnimatorListener {
override fun onAnimationStart(p0: Animator) {
}
override fun onAnimationEnd(p0: Animator) {
onChildClickListener?.onClick(childViews.size / 2)
}
override fun onAnimationCancel(p0: Animator) {
onChildClickListener?.onClick(childViews.size / 2)
}
override fun onAnimationRepeat(p0: Animator) {
}
})
objectAnimation?.duration = 1000
objectAnimation?.interpolator = AccelerateDecelerateInterpolator()
}
objectAnimation?.start()
}
注意做释放操作。
fun onDestroy(){
try {
objectAnimation?.cancel()
objectAnimation?.removeAllListeners()
}catch (e : Exception){
e.printStackTrace()
}finally {
objectAnimation = null
}
}
5. 小结
其实代码都挺简单的,关键是你要去想出一个方法来实现这个场景,然后感觉这个自定义viewgroup也是比较经典的,涉及到measure、layout、draw,涉及到动画,涉及到点击冲突。
这个Demo表示你要实现怎样的效果都可以,只要是draw能画出来的,你都能实现,我这个是中间凸出来,你可以实现凹进去,你可以实现波浪的样子,可以实现复杂的曲线,都行,你用各种基础图形去做拼接,或者画贝塞尔等等,其实都不难,主要是要有个计算和调试的过程。但是你的形状要和点击区域关联起来,你设计的图案越复杂,你要适配的点击区域计算量就越大。
甚至我还能做得效果更屌的是,那3个子view的图标,我都能画出来,就不用ImagerView,直接手动画出来,这样做的好处是什么呢?我对子view的图标能做各种炫酷的属性动画,我在切换viewpager时对图标做属性动画,那不得逼格再上一层。 为什么我没做呢,因为没有设计,我自己做的话要花大量的时间去调,要是有设计的话他告诉我尺寸啊位置啊这些信息,做起来就很快。我的APP主要是打算实现视频的编辑为主,所以这些支线就没打算花太多时间去处理。
作者:流浪汉kylin
链接:https://juejin.cn/post/7142350663907803144
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
Kotlin中 Flow、SharedFlow与StateFlow区别
一、简介
了解过协程Flow 的同学知道是典型的冷数据流,而SharedFlow
与StateFlow
则是热数据流。
- 冷流:只有当订阅者发起订阅时,事件的发送者才会开始发送事件。
- 热流:不管订阅者是否存在,只要发送了事件就会被消费,意思是不管接受方是否能够接收到,在这一点上有点像我们Android的
LiveData
。
解释:LiveData新的订阅者不会接收到之前发送的事件,只会收到之前发送的最后一条数据,
这个特性和SharedFlow的参数replay设置为1相似
二、使用分析
最好的分析是从使用时入手冷流flow
,热流SharedFlow和StateFlow
热流的具体的实现类分别是MutableSharedFlow和MutableStateFlow
用一个简单的例子来说明什么是冷流,什么是热流。
冷流flow:
private fun testFlow() {
val flow = flow<Int> {
(1..5).forEach {
delay(1000)
emit(it)
}
}
mBind.btCollect.setOnClickListener {
lifecycleScope.launch {
flow.collect {
Log.d(TAG, "testFlow 第一个收集器: 我是冷流:$it")
}
}
lifecycleScope.launch {
delay(5000)
flow.collect {
Log.d(TAG, "testFlow:第二个收集器 我是冷流:$it")
}
}
}
}
我点击收集按钮响应事件后,打印结果如下图:
这就是冷流
,需要去触发收集,才能接收到结果。
从上图时间可知flow每次重新订阅收集都会将所有事件重新发送一次
热流MutableSharedFlow和
private fun testSharedFlow() {
val sharedFlow = MutableSharedFlow<Int>(
replay = 0,//相当于粘性数据
extraBufferCapacity = 0,//接受的慢时候,发送的入栈
onBufferOverflow = BufferOverflow.SUSPEND
)
lifecycleScope.launch {
launch {
sharedFlow.collect {
println("collect1 received ago shared flow $it")
}
}
launch {
(1..5).forEach {
println("emit1 send ago flow $it")
sharedFlow.emit(it)
println("emit1 send after flow $it")
}
}
// wait a 100
delay(100)
launch {
sharedFlow.collect {
println("collect2 received shared flow $it")
}
}
}
}
第二个流收集被延迟,晚了100毫秒后就收不到了,想当于不管是否订阅,流都会发送,只管发,而collect1能够收集到是因为他在发送之前进行了订阅收集。
三、分析MutableSharedFlow中参数的具体含义
以上面testSharedFlow()方法中对象为例,上面的配置就是,当前对象的默认配置
源码如下图:
val sharedFlow = MutableSharedFlow<Int>(
replay = 0,//相当于粘性数据
extraBufferCapacity = 0,//接受的慢时候,发送的入栈
onBufferOverflow = BufferOverflow.SUSPEND //产生背压现象后的,执行策略
)
3.1、 reply:事件粘滞数
reply:事件粘滞数以testSharedFlow
方法为例如果设置了数目的话,那么其他订阅者不管什么时候订阅都能够收到replay数目的最新的事件,reply=1的话
有点类似Android中使用的livedata。
eg:和testSharedFlow
方法区别在于 replay = 2
private fun testSharedFlowReplay() {
val sharedFlow = MutableSharedFlow<Int>(
replay = 2,//相当于粘性数据
extraBufferCapacity = 0,//接受的慢时候,发送的入栈
onBufferOverflow = BufferOverflow.SUSPEND
)
lifecycleScope.launch {
launch {
sharedFlow.collect {
println("collect1 received ago shared flow $it")
}
}
launch {
(1..5).forEach {
println("emit1 send ago flow $it")
sharedFlow.emit(it)
println("emit1 send after flow $it")
}
}
// wait a minute
delay(100)
launch {
sharedFlow.collect {
println("collect2 received shared flow $it")
}
}
}
}
按照上面的解释collect2会收集到最新的4,5两个事件如下图:
3.2 extraBufferCapacity:缓存容量
extraBufferCapacity
:缓存容量,就是先发送几个事件,不管已经订阅的消费者是否接收,这种只管发不管消费者消费能力的情况就会出现背压,参数onBufferOverflow
就是用于处理背压问题
eg:和testSharedFlow
方法区别在于 extraBufferCapacity = 2
private fun testSharedFlowCapacity() {
val sharedFlow = MutableSharedFlow<Int>(
replay = 0,//相当于粘性数据
extraBufferCapacity = 2,//接受的慢时候,发送的入栈
onBufferOverflow = BufferOverflow.SUSPEND
)
lifecycleScope.launch {
launch {
sharedFlow.collect {
println("collect1 received ago shared flow $it")
}
}
launch {
(1..5).forEach {
println("emit1 send ago flow $it")
sharedFlow.emit(it)
println("emit1 send after flow $it")
}
}
// wait a minute
delay(100)
launch {
sharedFlow.collect {
println("collect2 received shared flow $it")
}
}
}
}
结果如下图:
优先发送将其缓存起来,testSharedFlow
测试中发送与接收在没有干扰(延时之类的干扰)的情况下 是一条顺序链,而设置了extraBufferCapacity
优先发送两条,不管消费情况,不设置的话(extraBufferCapacity = 0)
这时如果在collect1
里面设置延时delay(100)
,send会被阻塞(因为默认是 onBufferOverflow = BufferOverflow.SUSPEND的策略)
3.3、onBufferOverflow
onBufferOverflow:由背压就有处理策略,sharedflow默认为BufferOverflow.SUSPEND
,也即是如果当事件数量超过缓存,发送就会被挂起,上面提到了一句,DROP_OLDEST销毁最旧的值,DROP_LATEST销毁最新的值
三种参数含义
public enum class BufferOverflow {
/**
* 在缓冲区溢出时挂起。
*/
SUSPEND,
/**
* 在缓冲区溢出时删除** *旧的**值,添加新的值到缓冲区,不挂起。
*/
DROP_OLDEST,
/**
* 在缓冲区溢出时,删除当前添加到缓冲区的最新的**值\
*(使缓冲区内容保持不变),不要挂起。
*/
DROP_LATEST
}
eg:和testSharedFlowCapacity
方法区别在于 多了个delay(100)
- SUSPEND模式
private fun testSharedFlow2() {
val sharedFlow = MutableSharedFlow<Int>(
replay = 0,//相当于粘性数据
extraBufferCapacity = 2,//接受的慢时候,发送的入栈
onBufferOverflow = BufferOverflow.SUSPEND
)
lifecycleScope.launch {
launch {
sharedFlow.collect {
println("collect1 received ago shared flow $it")
delay(100)
}
}
launch {
(1..5).forEach {
println("emit1 send ago flow $it")
sharedFlow.emit(it)
println("emit1 send after flow $it")
}
}
// wait a minute
delay(100)
launch {
sharedFlow.collect {
println("collect2 received shared flow $it")
}
}
}
}
SUSPEND
情况下从第一张图知道collect1
都收集了,第二张图发现collect2
也打印了两次
,为什么只有两次呢?
因为 extraBufferCapacity = 2,
等于2,错过了两次的事件发送的接收,不信的话可以试一下extraBufferCapacity = 0
,这时候肯定打印了4次
,可能有人问为什么是4次
呢,因为collect2
的订阅者延时了100毫秒
才开始订阅,
- DROP_LATEST模式
private fun testSharedFlow2() {
val sharedFlow = MutableSharedFlow<Int>(
replay = 0,//相当于粘性数据
extraBufferCapacity = 2,//接受的慢时候,发送的入栈
onBufferOverflow = BufferOverflow.DROP_LATEST
)
lifecycleScope.launch {
launch {
sharedFlow.collect {
println("collect1 received ago shared flow $it")
delay(100)
}
}
launch {
(1..5).forEach {
println("emit1 send ago flow $it")
sharedFlow.emit(it)
println("emit1 send after flow $it")
}
}
// wait a minute
delay(100)
launch {
sharedFlow.collect {
println("collect2 received shared flow $it")
}
}
}
}
发送过快的话,销毁最新的,只保留最老的两条事件,我们可以知道1,2,肯定保留其他丢失
要想不丢
是怎么办呢,很简单不要产生背压现象
就行,在emit中延时delay(200)
,比收集耗时长就行。
- DROP_OLDEST模式
该模式同理DROP_LATEST模式,保留最新的extraBufferCapacity = 2(多少)的数据就行
。
四、StateFlow
初始化
val stateFlow = MutableStateFlow<Int>(value = -1)
由上图的继承关系可知stateFlow其实就是一种特殊的SharedFlow
,它多了个初始值value
由上图可知:每次更新数据都会和旧数据做一次比较,只有不同时候才会更新数值。
SharedFlow和StateFlow的侧重点
- StateFlow就是一个replaySize=1的sharedFlow,同时它必须有一个初始值,此外,每次更新数据都会和旧数据做一次比较,只有不同时候才会更新数值。
- StateFlow重点在状态,ui永远有状态,所以StateFlow必须有初始值,同时对ui而言,过期的状态毫无意义,所以stateFLow永远更新最新的数据(和liveData相似),所以必须有粘滞度=1的粘滞事件,让ui状态保持到最新。
另外在一个时间内发送多个事件,不会管中间事件有没有消费完成都会执行最新的一条.(中间值会丢失)
- SharedFlow侧重在事件,当某个事件触发,发送到队列之中,按照挂起或者非挂起、缓存策略等将事件发送到接受方,在具体使用时,SharedFlow更适合通知ui界面的一些事件,比如toast等,也适合作为viewModel和repository之间的桥梁用作数据的传输。
eg测试如下中间值丢失:
private fun testSharedFlow2() {
val stateFlow = MutableStateFlow<Int>(value = -1)
lifecycleScope.launch {
launch {
stateFlow.collect {
println("collect1 received ago shared flow $it")
}
}
launch {
(1..5).forEach {
println("emit1 send ago flow $it")
stateFlow.emit(it)
println("emit1 send after flow $it")
}
}
// wait a minute
delay(100)
launch {
stateFlow.collect {
println("collect2 received shared flow $it")
}
}
}
}
由下图可知,中间值丢失,collect2结果
可知永远有状态
好了到这里文章就结束了,源码分析后续再写。
作者:五问
链接:https://juejin.cn/post/7142038525997744141
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
Kotlin 协程 Select:看我如何多路复用
前言
协程通信三剑客:Channel、Select、Flow,上篇已经分析了Channel的深水区,本篇将会重点分析Select的使用及原理。
通过本篇文章,你将了解到:
- Select 的引入
- Select 的使用
- Invoke函数 的妙用
- Select 的原理
- Select 注意事项
1. Select 的引入
多路数据的选择
串行执行
如今的二维码识别应用场景越来越广了,早期应用比较广泛的识别SDK如zxing、zbar,它们各有各的特点,也存在识别不出来的情况,为了将两者优势结合起来,我们想到的方法是同一份二维码图片分别给两者进行识别。
如下:
//从zxing 获取二维码信息
suspend fun getQrcodeInfoFromZxing(bitmap: Bitmap?): String {
//模拟耗时
delay(2000)
return "I'm fish"
}
//从zbar 获取二维码信息
suspend fun getQrcodeInfoFromZbar(bitmap: Bitmap?): String {
delay(1000)
return "I'm fish"
}
fun testSelect() {
runBlocking {
var bitmap = null
var starTime = System.currentTimeMillis()
var qrcoe1 = getQrcodeInfoFromZxing(bitmap)
var qrcode2 = getQrcodeInfoFromZbar(bitmap)
println("qrcode1=$qrcoe1 qrcode2=$qrcode2 useTime:${System.currentTimeMillis() - starTime} ms")
}
}
查看打印,最后花费的时间:
qrcode1=I'm fish qrcode2=I'm fish useTime:3013 ms
当然这是串行的方式效率比较低,我们想到了用协程来优化它。
协程并行执行
如下:
fun testSelect1() {
var bitmap = null;
var starTime = System.currentTimeMillis()
var deferredZxing = GlobalScope.async {
getQrcodeInfoFromZxing(bitmap)
}
var deferredZbar = GlobalScope.async {
getQrcodeInfoFromZbar(bitmap)
}
runBlocking {
//挂起等待识别结果
var qrcoe1 = deferredZxing.await()
//挂起等待识别结果
var qrcode2 = deferredZbar.await()
println("qrcode1=$qrcoe1 qrcode2=$qrcode2 useTime:${System.currentTimeMillis() - starTime} ms")
}
}
查看打印,最后花费的时间:
qrcode1=I'm fish qrcode2=I'm fish useTime:2084 ms
可以看出,花费时间明显变少了。
与上个Demo 相比,虽然识别过程是放在协程里并行执行的,但是在等待识别结果却是串行的。我们引入两个识别库的初衷是哪个识别快就用哪个的结果,为了达成这个目的,传统的方式是:
同时监听并记录识别结果的返回。
同时监听多路结果
如下:
fun testSelect2() {
var bitmap = null;
var starTime = System.currentTimeMillis()
var deferredZxing = GlobalScope.async {
getQrcodeInfoFromZxing(bitmap)
}
var deferredZbar = GlobalScope.async {
getQrcodeInfoFromZbar(bitmap)
}
var isEnd = false
var result: String? = null
GlobalScope.launch {
if (!isEnd) {
//没有结束,则继续识别
var resultTmp = deferredZxing.await()
if (!isEnd) {
//识别没有结束,说明自己是第一个返回结果的
result = resultTmp
println("zxing recognize ok useTime:${System.currentTimeMillis() - starTime} ms")
//标记识别结束
isEnd = true
}
}
}
GlobalScope.launch {
if (!isEnd) {
var resultTmp = deferredZbar.await()
if (!isEnd) {
//识别没有结束,说明自己是第一个返回结果的
result = resultTmp
println("zbar recognize ok useTime:${System.currentTimeMillis() - starTime} ms")
isEnd = true
}
}
}
//检测是否有结果返回
runBlocking {
while (!isEnd) {
delay(1)
}
println("recognize result:$result")
}
}
通过检测isEnd 标记来判断是否有某个模块返回结果。
结果如下:
- zbar recognize ok useTime:1070 ms
- recognize result:I'm fish
由于模拟设定的zbar 解析速度快,因此每次都是采纳的是zbar的结果,所花费的时间大幅减少了,该结果符合预期。
Select 闪亮登场
虽说上个Demo结果符合预期,但是多了很多额外的代码、多引入了其它协程,并且需要子模块对标记进行赋值(对"isEnd"进行赋值),没有达到解耦的目的。我们希望子模块的任务是单一且闭环的,如果能在一个函数里统一检测结果的返回就好了。
Select 就是为了解决多路数据的选择而生的。
来看看它是怎么解决该问题的:
fun testSelect3() {
var bitmap = null;
var starTime = System.currentTimeMillis()
var deferredZxing = GlobalScope.async {
getQrcodeInfoFromZxing(bitmap)
}
var deferredZbar = GlobalScope.async {
getQrcodeInfoFromZbar(bitmap)
}
runBlocking {
//通过select 监听zxing、zbar 结果返回
var result = select<String> {
//监听zxing
deferredZxing.onAwait {value->
//value 为deferredZxing 识别的结果
"zxing result $value"
}
//监听zbar
deferredZbar.onAwait { value->
"zbar result $value"
}
}
//运行到此,说明已经有结果返回
println("result from $result useTime:${System.currentTimeMillis() - starTime}")
}
}
结果如下:
result from zbar result I'm fish useTime:1079
符合预期,同时可以看出:相比上个Demo,这样写简洁了许多。
2. Select 的使用
除了可以监听async的结果,Select 还可以监听Channel的发送方/接收方 数据,我们以监听接收方数据为例:
fun testSelect4() {
runBlocking {
var bitmap = null;
var starTime = System.currentTimeMillis()
var receiveChannelZxing = produce {
//生产数据
var result = getQrcodeInfoFromZxing(bitmap)
//发送数据
send(result)
}
var receiveChannelZbar = produce {
var result = getQrcodeInfoFromZbar(bitmap)
send(result)
}
var result = select<String> {
//监听是否有数据发送过来
receiveChannelZxing.onReceive {
value->"zxing result $value"
}
receiveChannelZbar.onReceive {
value->"zbar result $value"
}
}
println("result from $result useTime:${System.currentTimeMillis() - starTime}")
}
}
结果如下:
result from zbar result I'm fish useTime:1028
不论是async还是Channel,Select 都可以监听它们的数据,从而形成多路复用的效果。
在监听协程里调用select 表达式,表达式{}内声明需要监听的协程的数据,对于select 来说有两种场景:
- 没有数据,则select 挂起协程并等待直到其它协程数据准备完成后再次恢复select 所在的协程。
- 有数据,则select 正常执行并返回获取的数据。
3. Invoke函数 的妙用
在分析Select 原理之前,需要弄明白invoke函数的原理。
对于Kotlin 类来说,都可以重写其invoke函数。
operator fun invoke():String {
return "I'm fish"
}
如上,重写了SelectDemo里的invoke函数,和普通成员函数一样,我们可以通过对象调用它。
fun main(args: Array<String>) {
var selectDemo = SelectDemo()
var result = selectDemo.invoke()
println("result:$result")
}
当然,可以进一步简化:
fun main(args: Array<String>) {
var selectDemo = SelectDemo()
var result = selectDemo()
println("result:$result")
}
这里涉及到了kotlin的语法糖:对象居然可以像函数一样调用。
作为函数,invoke 当然也可以接收高阶函数作为参数:
operator fun invoke(block: (Int) -> String): String {
return block(3)
}
fun main(args: Array<String>) {
var selectDemo = SelectDemo()
var result = selectDemo { age ->
when (age) {
3 -> "I'm fish3"
4 -> "I'm fish4"
else -> "error"
}
}
println("result:$result")
}
因此,当看到对象作为函数调用时,实际上调用的是invoke函数,具体的逻辑需要查看其invoke函数的实现。
4. Select 的原理
上篇分析过Channel,因此本篇趁热打铁,通过Select 监听Channel数据的变化来分析其原理,为方便讲解,我们先以监听一个Channel的为例。
先从select 表达式本身入手。
fun testSelect5() {
runBlocking {
var starTime = System.currentTimeMillis()
var receiveChannelZxing = produce {
//发送数据
send("I'm fish")
}
//确保channel 数据已经send
delay(1000)
var result = select<String> {
//监听是否有数据发送过来
receiveChannelZxing.onReceive { value ->
"zxing result $value"
}
}
println("result from $result useTime:${System.currentTimeMillis() - starTime}")
}
}
select 是挂起函数,因此协程运行到此有可能被挂起。
#Select.kt
public suspend inline fun <R> select(crossinline builder: SelectBuilder<R>.() -> Unit): R {
//...
return suspendCoroutineUninterceptedOrReturn { uCont ->
//传入父协程体
val scope = SelectBuilderImpl(uCont)
try {
//执行builder
builder(scope)
} catch (e: Throwable) {
scope.handleBuilderException(e)
}
//通过返回值判断是否需要挂起协程
scope.getResult()
}
}
重点看builder(scope),builder 是高阶函数,实际上就是执行了select花括号里的内容,而它里面就是监听数据是否返回。
receiveChannelZxing.onReceive
刚开始看的时候势必以为onReceive是个函数,然而它是ReceiveChannel 里的成员变量:
#Channel.kt
public val onReceive: SelectClause1<E>
通过上一节的分析可知,关键是要找到SelectClause1 的invoke的实现。
#Select.kt
public interface SelectBuilder<in R> {
//block 有个入参
//声明了SelectClause1的扩展函数invoke
public operator fun <Q> SelectClause1<Q>.invoke(block: suspend (Q) -> R)
}
override fun <Q> SelectClause1<Q>.invoke(block: suspend (Q) -> R) {
//SelectBuilderImpl 实现了 SelectClause1 的invoke函数
registerSelectClause1(this@SelectBuilderImpl, block)
}
再看onReceive 的赋值:
#AbstractChannel.kt
final override val onReceive: SelectClause1<E>
get() = object : SelectClause1<E> {
@Suppress("UNCHECKED_CAST")
override fun <R> registerSelectClause1(select: SelectInstance<R>, block: suspend (E) -> R) {
registerSelectReceiveMode(select, RECEIVE_THROWS_ON_CLOSE, block as suspend (Any?) -> R)
}
}
因此,简单总结调用栈如下:
当调用receiveChannelZxing.onReceive{},实际上调用了SelectClause1.invoke(),而它里面又调用了SelectClause1.registerSelectClause1(),最终调用了AbstractChannel.registerSelectReceiveMode。
AbstractChannel. registerSelectReceiveMode
#AbstractChannel.kt
private fun <R> registerSelectReceiveMode(select: SelectInstance<R>, receiveMode: Int, block: suspend (Any?) -> R) {
while (true) {
//如果已经有结果了,则直接返回------->①
if (select.isSelected) return
if (isEmptyImpl) {
//没有发送者在等待,则入队等待,并返回 ------->②
if (enqueueReceiveSelect(select, block, receiveMode)) return
} else {
//直接取出值------->③
val pollResult = pollSelectInternal(select)
when {
pollResult === ALREADY_SELECTED -> return
pollResult === POLL_FAILED -> {} // retry
pollResult === RETRY_ATOMIC -> {} // retry
//调用block------->④
else -> block.tryStartBlockUnintercepted(select, receiveMode, pollResult)
}
}
}
}
分为4个点,接着来一一分析。
①
select 同时监听多个值,若是有1个符合要求的数据返回了,那么该isSelected 标记为true,当检测到该标记为true时直接退出。
结合之前的Demo,zbar 已经识别出结果了,当select 检测zxing的结果时直接返回。
②
#AbstractChannel.kt
private fun <R> enqueueReceiveSelect(
select: SelectInstance<R>,
block: suspend (Any?) -> R,
receiveMode: Int
): Boolean {
//构造为Node元素
val node = AbstractChannel.ReceiveSelect(this, select, block, receiveMode)
//添加到Channel队列里
val result = enqueueReceive(node)
if (result) select.disposeOnSelect(node)
return result
当select 时,发现Channel里没有数据,说明Channel还没有开始send,因此构造了Node(ReceiveSelect)加入到Channel queue里。当send数据时,会查找queue里是否有接收者等待,若有则调用Node(ReceiveSelect.completeResumeReceive):
#AbstractChannel.kt
override fun completeResumeReceive(value: E) {
block.startCoroutineCancellable(
if (receiveMode == RECEIVE_RESULT) ChannelResult.success(value) else value,
select.completion,
resumeOnCancellationFun(value)
)
}
block 被调度执行,最后会恢复select 协程的执行。
③
取出数据,并尝试恢复send协程。
④
在③的基础上,拿到数据后,直接执行block(此时并没有切换线程进行调度)。
小结一下select 原理:
可以看出:
select 本身执行并不耗时,若最终没有数据返回则挂起等待,若是有数据返回则不会挂起协程。
我们从头再捋一下select 配合Channel 的原理:
虽然以Channel为例讲解了select 原理,实际上async等结合select 原理大致差不多,重点都是利用了协程的挂起/恢复做文章。
5. Select 注意事项
如果select有多个数据同时到达,select 默认会选择第一个数据,若想要随机选择数据,可做如下处理:
var result = selectUnbiased<String> {
//监听是否有数据发送过来
receiveChannelZxing.onReceive { value ->
"zxing result $value"
}
}
想要知道select 还可以监听哪些数据,可查看该数据是否实现了SelectClauseX(X 表示0、1、2)。
以上即为Select 的原理及其使用,下篇将会进入协程的精华部分:Flow的运用,该部分内容较多,可能会分几篇分析,敬请期待。
本文基于Kotlin 1.5.3,文中完整Demo请点击
作者:小鱼人爱编程
链接:https://juejin.cn/post/7142083646822809607
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
Kotlin协程:flowOn与线程切换
本文分析示例代码如下:
launch(Dispatchers.Main) {
flow {
emit(1)
emit(2)
}.flowOn(Dispatchers.IO).collect {
delay(1000)
withContext(Dispatchers.IO) {
Log.d("liduo", "$it")
}
Log.d("liduo", "$it")
}
}
一.flowOn方法
flowOn方法用于将上游的流切换到指定协程上下文的调度器中执行,同时不会把协程上下文暴露给下游的流,即flowOn方法中协程上下文的调度器不会对下游的流生效。如下面这段代码所示:
launch(Dispatchers.Main) {
flow {
emit(2) // 执行在IO线程池
}.flowOn(Dispatchers.IO).map {
it + 1 // 执行在Default线程池
}.flowOn(Dispatchers.Default).collect {
Log.d("liduo", "$it") //执行在主线程
}
}
接下来,分析一下flowOn方法,代码如下:
public fun <T> Flow<T>.flowOn(context: CoroutineContext): Flow<T> {
// 检查当前协程没有执行结束
checkFlowContext(context)
return when {
// 为空,则返回自身
context == EmptyCoroutineContext -> this
// 如果是可融合的Flow,则尝试融合操作,获取新的流
this is FusibleFlow -> fuse(context = context)
// 其他情况,包装成可融合的Flow
else -> ChannelFlowOperatorImpl(this, context = context)
}
}
// 确保Job不为空
private fun checkFlowContext(context: CoroutineContext) {
require(context[Job] == null) {
"Flow context cannot contain job in it. Had $context"
}
}
在flowOn方法中,首先会检查方法所在的协程是否执行结束。如果没有结束,则会执行判断语句,这里flowOn方法传入的上下文不是空上下文,且通过flow方法构建出的Flow对象也不是FusibleFlow类型的对象,因此这里会走到else分支,将上游flow方法创建的Flow对象和上下文包装成ChannelFlowOperatorImpl类型的对象。
1.ChannelFlowOperatorImpl类
ChannelFlowOperatorImpl类继承自ChannelFlowOperator类,用于将上游的流包装成一个ChannelFlow对象,它的继承关系如下图所示:
通过上图可以知道,ChannelFlowOperatorImpl类最终继承了ChannelFlow类,代码如下:
internal class ChannelFlowOperatorImpl<T>(
flow: Flow<T>,
context: CoroutineContext = EmptyCoroutineContext,
capacity: Int = Channel.OPTIONAL_CHANNEL,
onBufferOverflow: BufferOverflow = BufferOverflow.SUSPEND
) : ChannelFlowOperator<T, T>(flow, context, capacity, onBufferOverflow) {
// 用于流融合时创建新的流
override fun create(context: CoroutineContext, capacity: Int, onBufferOverflow: BufferOverflow): ChannelFlow<T> =
ChannelFlowOperatorImpl(flow, context, capacity, onBufferOverflow)
// 若当前的流不需要通过Channel即可实现正常工作时,会调用此方法
override fun dropChannelOperators(): Flow<T>? = flow
// 触发对下一级流进行收集
override suspend fun flowCollect(collector: FlowCollector<T>) =
flow.collect(collector)
}
二.collect方法
在Kotlin协程:Flow基础原理中讲到,当执行collect方法时,内部会调用最后产生的Flow对象的collect方法,代码如下:
public suspend inline fun <T> Flow<T>.collect(crossinline action: suspend (value: T) -> Unit): Unit =
collect(object : FlowCollector<T> {
override suspend fun emit(value: T) = action(value)
})
这个最后产生的Flow对象就是ChannelFlowOperatorImpl类对象。
1.ChannelFlowOperator类的collect方法
ChannelFlowOperatorImpl类没有重写collect方法,因此调用的是它的父类ChannelFlowOperator类的collect方法,代码如下:
override suspend fun collect(collector: FlowCollector<T>) {
// OPTIONAL_CHANNEL为默认值,这里满足条件,之后会详细讲解
if (capacity == Channel.OPTIONAL_CHANNEL) {
// 获取当前协程的上下文
val collectContext = coroutineContext
// 计算新的上下文
val newContext = collectContext + context
// 如果前后上下文没有发生变化
if (newContext == collectContext)
// 直接触发对下一级流的收集
return flowCollect(collector)
// 如果上下文发生变化,但不需要切换线程
if (newContext[ContinuationInterceptor] == collectContext[ContinuationInterceptor])
// 切换协程上下文,调用flowCollect方法触发下一级流的收集
return collectWithContextUndispatched(collector, newContext)
}
// 调用父类的collect方法
super.collect(collector)
}
// 获取当前协程的上下文,该方法会被编译器处理
@SinceKotlin("1.3")
@Suppress("WRONG_MODIFIER_TARGET")
@InlineOnly
public suspend inline val coroutineContext: CoroutineContext
get() {
throw NotImplementedError("Implemented as intrinsic")
}
ChannelFlowOperator类的collect方法在设计上与协程的withContext方法设计思路是一致的:在方法内根据上下文的不同情况进行判断,在必要时才会切换线程去执行任务。
通过flowOn方法创建的ChannelFlowOperatorImpl类对象,参数capacity为默认值OPTIONAL_CHANNEL。因此代码在执行时会进入到判断中,但因为我们指定了上下文为Dispatchers.IO,因此上下文发生了变化,同时拦截器也发生了变化,所以最后会调用ChannelFlowOperator类的父类的collect方法,也就是ChannelFlow类的collect方法。
2.ChannelFlow类的collect方法
ChannelFlow类的代码如下:
override suspend fun collect(collector: FlowCollector<T>): Unit =
coroutineScope {
collector.emitAll(produceImpl(this))
}
在ChannelFlow类的collect方法中,首先通过coroutineScope方法创建了一个作用域协程,接着调用了produceImpl方法,代码如下:
public open fun produceImpl(scope: CoroutineScope): ReceiveChannel<T> =
scope.produce(context, produceCapacity, onBufferOverflow, start = CoroutineStart.ATOMIC, block = collectToFun)
produceImpl方法内部调用了produce方法,并且传入了待执行的任务collectToFun。
produce方法在Kotlin协程:协程的基础与使用中曾提到过,它是官方提供的启动协程的四个方法之一,另外三个方法为launch方法、async方法、actor方法。代码如下:
internal fun <E> CoroutineScope.produce(
context: CoroutineContext = EmptyCoroutineContext,
capacity: Int = 0,
onBufferOverflow: BufferOverflow = BufferOverflow.SUSPEND,
start: CoroutineStart = CoroutineStart.DEFAULT,
onCompletion: CompletionHandler? = null,
@BuilderInference block: suspend ProducerScope<E>.() -> Unit
): ReceiveChannel<E> {
// 根据容量与溢出策略创建Channel对象
val channel = Channel<E>(capacity, onBufferOverflow)
// 计算新的上下文
val newContext = newCoroutineContext(context)
// 创建协程
val coroutine = ProducerCoroutine(newContext, channel)
// 监听完成事件
if (onCompletion != null) coroutine.invokeOnCompletion(handler = onCompletion)
// 启动协程
coroutine.start(start, coroutine, block)
return coroutine
}
在produce方法内部,首先创建了一个Channel类型的对象,接着创建了类型为ProducerCoroutine的协程,并且传入Channel对象作为参数。最后,produce方法返回了一个ReceiveChannel接口指向的对象,当协程执行完毕后,会通过Channel对象将结果通过send方法发送出来。
至此,可以知道flowOn方法的实现实际上是利用了协程拦截器的拦截功能。
在这里之后,代码逻辑分成了两部分,一部分是block在ProducerCoroutine协程中的执行,另一部分是通过ReceiveChannel对象获取执行的结果。
3.flow方法中代码的执行
在produceImpl方法中,调用了produce方法,并且传入了collectToFun对象,这个对象将会在produce方法创建的协程中执行,代码如下:
internal val collectToFun: suspend (ProducerScope<T>) -> Unit
get() = { collectTo(it) }
当调用collectToFun对象的invoke方法时,会触发collectTo方法的执行,该方法在ChannelFlowOperator类中被重写,代码如下:
protected override suspend fun collectTo(scope: ProducerScope<T>) =
flowCollect(SendingCollector(scope))
在collectTo方法中,首先将参数scope封装成SendingCollector类型的对象,接着调用了flowCollect方法,该方法在ChannelFlowOperatorImpl类中被重写,代码如下:
override suspend fun flowCollect(collector: FlowCollector<T>) =
flow.collect(collector)
ChannelFlowOperatorImpl类的flowCollect方法内部调用了flow对象的collect方法,这个flow对象就是最初通过flow方法构建的对象。根据Kotlin协程:Flow基础原理的分析,这个flow对象类型为SafeFlow,最后会通过collectSafely方法,触发flow方法中的block执行。代码如下:
private class SafeFlow<T>(private val block: suspend FlowCollector<T>.() -> Unit) : AbstractFlow<T>() {
override suspend fun collectSafely(collector: FlowCollector<T>) {
// 触发执行
collector.block()
}
}
当flow方法在执行过程中需要向下游发出值时,会调用emit方法。根据上面flowCollect方法和collectTo方法可以知道,collectSafely方法的collector对象就是collectTo方法中创建的SendingCollector类型的对象,代码如下:
@InternalCoroutinesApi
public class SendingCollector<T>(
private val channel: SendChannel<T>
) : FlowCollector<T> {
// 通过Channel类对象发送值
override suspend fun emit(value: T): Unit = channel.send(value)
}
当调用SendingCollector类型的对象的emit方法时,会通过调用类型为Channel的对象的send方法,将值发送出去。
接下来,将分析下游如何接收上游发出的值。
4.接收flow方法发出的值
回到ChannelFlow类的collect方法,之前提到collect方法中调用produceImpl方法,开启了一个新的协程去执行任务,并且返回了一个ReceiveChannel接口指向的对象。代码如下:
override suspend fun collect(collector: FlowCollector<T>): Unit =
coroutineScope {
collector.emitAll(produceImpl(this))
}
在调用完produceImpl方法后,接着调用了emitAll方法,将ReceiveChannel接口指向的对象作为emitAll方法的参数,代码如下:
public suspend fun <T> FlowCollector<T>.emitAll(channel: ReceiveChannel<T>): Unit =
emitAllImpl(channel, consume = true)
emitAll方法是FlowCollector接口的扩展方法,内部调用了emitAllImpl方法对参数channel进行封装,代码如下:
private suspend fun <T> FlowCollector<T>.emitAllImpl(channel: ReceiveChannel<T>, consume: Boolean) {
// 用于保存异常
var cause: Throwable? = null
try {
// 死循环
while (true) {
// 挂起,等待接收Channel结果或Channel关闭
val result = run { channel.receiveOrClosed() }
// 如果Channel关闭了
if (result.isClosed) {
// 如果有异常,则抛出
result.closeCause?.let { throw it }
// 没有异常,则跳出循环
break
}
// 获取并发送值
emit(result.value)
}
} catch (e: Throwable) {
// 捕获到异常时抛出
cause = e
throw e
} finally {
// 执行结束关闭Channel
if (consume) channel.cancelConsumed(cause)
}
}
emitAllImpl方法是FlowCollector接口的扩展方法,而这里的FlowCollector接口指向的对象,就是collect方法中创建的匿名对象,代码如下:
public suspend inline fun <T> Flow<T>.collect(crossinline action: suspend (value: T) -> Unit): Unit =
collect(object : FlowCollector<T> {
override suspend fun emit(value: T) = action(value)
})
在emitAllImpl方法中,当通过receiveOrClosed方法获取到上游发出的值时,会调用emit方法通知下游,这时就会触发collect方法中block的执行,最终实现值从流的上游传递到了下游。
三.flowOn方法与流的融合
假设对一个流连续调用两次flowOn方法,那么流最终会在哪个flowOn方法指定的调度器中执行呢?代码如下:
launch(Dispatchers.Main) {
flow {
emit(2)
// emit方法是在IO线程执行还是在主线程执行呢?
}.flowOn(Dispatchers.IO).flowOn(Dispatchers.Main).collect {
Log.d("liduo", "$it")
}
}
答案是在IO线程执行,为什么呢?
根据本篇上面的分析,当第一次调用flowOn方法时,上游的流会被包裹成ChannelFlowOperatorImpl对象,代码如下:
public fun <T> Flow<T>.flowOn(context: CoroutineContext): Flow<T> {
// 检查当前协程没有执行结束
checkFlowContext(context)
return when {
// 为空,则返回自身
context == EmptyCoroutineContext -> this
// 如果是可融合的Flow,则尝试融合操作,获取新的流
this is FusibleFlow -> fuse(context = context)
// 其他情况,包装成可融合的Flow
else -> ChannelFlowOperatorImpl(this, context = context)
}
}
而当第二次调用flowOn方法时,由于此时上游的流——ChannelFlowOperatorImpl类型的对象,实现了FusibleFlow接口,因此,这里会触发流的融合,直接调用上游的流的fuse方法,并传入新的上下文。这里容量和溢出策略均为默认值。
根据Kotlin协程:Flow的融合、Channel容量、溢出策略的分析,这里会调用ChannelFlow类的fuse方法。相关代码如下:
public override fun fuse(context: CoroutineContext, capacity: Int, onBufferOverflow: BufferOverflow): Flow<T> {
...
// 计算融合后流的上下文
// context为下游的上下文,this.context为上游的上下文
val newContext = context + this.context
...
}
再根据之前在Kotlin协程:协程上下文与上下文元素中的分析,当两个上下文进行相加时,后一个上下文中的拦截器会覆盖前一个上下文中的拦截器。在上面的代码中,后一个上下文为上游的流的上下文,因此会优先使用上游的拦截器。代码如下:
public operator fun plus(other: CoroutineDispatcher): CoroutineDispatcher = other
四.总结
粉线为使用时代码编写顺序,绿线为下游触发上游的调用顺序,红线为上游向下游发送值的调用顺序,蓝线为线程切换的位置。
作者:李萧蝶
链接:https://juejin.cn/post/7139135208267186213
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
Koltin协程:Flow的触发与消费
本文分析示例代码如下:
launch(Dispatchers.Main) {
val task = flow {
emit(2)
emit(3)
}.onEach {
Log.d("liduo", "$it")
}
task.collect()
}
一.Flow的触发与消费
在Kotlin协程:Flow基础原理的分析中,流的触发与消费都是同时进行的。每当调用collect方法时,会触发流的执行,并同时在collect方法中对流发出的值进行消费。
而在协程中,其实还提供了分离流的触发与消费的操作——onEach方法。通过使用onEach方法,可以将原本在collect方法中的消费过程的移动到onEach方法中。这样在构建好一个Flow对象后,不会立刻去执行onEach方法,只有当调用collect方法时,才会真正的去触发流的执行。这样就实现了流的触发与消费的分离。
接下来,将对onEach方法进行分析。
1.onEach方法
onEach方法用于预先构建流的消费过程,只有在触发流的执行后,才会对流进行消费,代码如下:
public fun <T> Flow<T>.onEach(action: suspend (T) -> Unit): Flow<T> = transform { value ->
action(value)
return@transform emit(value)
}
onEach方法是一个Flow接口的扩展方法,返回一个类型为Flow的对象。Flow方法内部通过transform方法实现。
2.transform方法
transform方法是onEach方法的核心实现,代码如下:
public inline fun <T, R> Flow<T>.transform(
@BuilderInference crossinline transform: suspend FlowCollector<R>.(value: T) -> Unit
): Flow<R> = flow { // 创建Flow对象
collect { value -> // 触发collect
return@collect transform(value)
}
}
transform方法也是Flow接口的扩展方法,同样会返回一个类型为Flow的对象。并且在transform方法内部,首先构建了一个类型为Flow的对象,并且在这个Flow对象的执行体内,调用上游的流的collect来触发消费过程,并通过调用参数transform来实现消费。这个collect方法是一个扩展方法,在Kotlin协程:Flow基础原理分析过,因此不再赘述。
这就是onEach方法实现触发与消费分离的核心,它将对上游的流的消费过程包裹在了一个新的流内,只有当这个新的流或其下游的流被触发时,才会触发这个新的流自身的执行,从而实现对上游的流的消费。
接下来分析一下流的消费过程。
3.collect方法
collect方法用于触发流的消费,我们这里调用的collect方法,是一个无参数的方法,代码如下:
public suspend fun Flow<*>.collect(): Unit = collect(NopCollector)
这里的无参数collect方法是Flow接口的扩展方法。在无参数collect方法中,调用了另一个有参数的collect方法,这个有参数的collect方法在Kotlin协程:Flow基础原理中提到过,就是Flow接口中定义的方法,并且传入了NopCollecor对象,代码如下:
internal object NopCollector : FlowCollector<Any?> {
override suspend fun emit(value: Any?) {
// 什么都不做
}
}
NopCollecor是一个单例类,它实现了FlowCollector接口,但是emit方法为空实现。
因此,这里会调用onEach方法返回的Flow对象的collect方法,这部分在Kotlin协程:Flow基础原理进行过分析,最后会触发flow方法中的block参数的执行。而这个Flow对象就是transform方法返回的Flow对象。代码如下:
public inline fun <T, R> Flow<T>.transform(
@BuilderInference crossinline transform: suspend FlowCollector<R>.(value: T) -> Unit
): Flow<R> = flow { // 创建Flow对象
collect { value -> // 触发collect
return@collect transform(value)
}
}
通过上面的transform方法可以知道,在触发flow方法中的block参数执行后,会调用collect方法。上面提到transform方法是Flow接口的扩展方法,因此这里有会继续调用上游Flow对象的collect方法。这个过程与刚才分析的类似,这里调用的上游的Flow对象,就是我们在示例代码中通过flow方法构建的Flow对象。
此时,会触发上游flow方法中block参数的执行,并在执行过程中,通过emit方法将值发送到下游。
接下来,在transform方法中,collect方法的block参数会被会被回调执行,处理上游发送的值。这里又会继续调用transform方法中参数的执行,这部分逻辑在onEach方法中,代码如下:
public fun <T> Flow<T>.onEach(action: suspend (T) -> Unit): Flow<T> = transform { value ->
action(value)
return@transform emit(value)
}
这里会调用参数action的执行,流在这里最终被消费。同时,onEach方法会继续调用emit方法,将上游返回的值再原封不动的传递到下游,交由下游的流处理。
二.多消费过程的执行
首先看下面这段代码:
launch(Dispatchers.Main) {
val task = flow {
emit(2)
emit(3)
}.onEach {
Log.d("liduo1", "$it")
}.onEach {
Log.d("liduo2", "$it")
}
task.collect()
}
根据上面的分析,两个onEach方法会按顺序依次执行,打印出liduo1:2、liduo2:2、liduo1:3、liduo2:3。就是因为onEach方法会将上游的值继续向下游发送。
同样的,还有下面这段代码:
launch(Dispatchers.Main) {
val task = flow {
emit(2)
emit(3)
}.onEach {
Log.d("liduo1", "$it")
}
task.collect {
Log.d("liduo2", "$it")
}
}
这段代码也会打印出liduo1:2、liduo2:2、liduo1:3、liduo2:3。虽然使用了onEach方法,但也可以调用有参数的collect方法来对上游发送的数据进行最终的处理。
三.总结
粉线为代码编写顺序,绿线为下游触发上游的调用顺序,红线为上游向下游发送值的调用顺序,蓝线为onEach方法实现的核心。
作者:李萧蝶
链接:https://juejin.cn/post/7139427332602724365
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
Kotlin Sealed Class 太香了,Java 8 也想用怎么办?
为避免数据在分发过程中被恶意篡改,Kotlin 将 SealedClass 参数设置为 val 即可,
Java 17 以下未引入 SealedClass,且若实现 Kotlin val 同等效果,样板代码瞬间飙出许多,等于解决了数据一致性的同时,滋生了更多 “不一致” 问题,例如日后修改某字段,而忘配置构造方法等等。
痛定思痛,SealedClass4Java 应运而生,通过注解自动生成 SealedClass,像 Kotlin 一样使用 SealedClass。
献给喜欢 Kotlin 但又不得不维护 Java 老项目的朋友。
Github:SealedClass4Java
使用说明
1.创建一个接口,添加 SealedClass 注解,且接口名开头 _ 下划线,
@SealedClass
public interface _TestEvent {
void resultTest1(String a, int b);
void resultTest2(String a, int b, int c);
}
2.编译即可生成目标类,例如 TestEvent,然后像 Kotlin 一样使用该类:
TestEvent event = TestEvent.ResultTest1("textx");
switch (event.id) {
case TestEvent.ResultTest1.ID:
TestEvent.ResultTest1 event1 = (TestEvent.ResultTest1) event;
event1.copy(1);
event1.paramA;
event1.resultB;
break;
case TestEvent.ResultTest2.ID:
break;
}
进阶使用
本框架是 MVI-Dispatcher 项目优化过程中,为消除 “消息分流场景 final 样板代码” 而萌生的产物,所以我们不妨以 MVI-Dispatcher 使用场景为例:
注:“消息(message)、事件(event)、意图(intent)”,不同场景,叫法不同,但本质上是指同一东西,即 “可被消费的一次性数据”。
A.纯粹消息分发场景
1.定义一个接口,例如 _Messages,在方法列表中定义不携带参数的纯粹消息,定义完 build 生成对应 Messages 类。
@SealedClass
public interface _Messages {
void refreshNoteList();
void finishActivity();
}
2.在 MVI-View 中发送一个 Messages.RefreshNoteList( ) 纯粹消息
public class TestFragment {
public void onInput() {
MVI-Model.input(Messages.RefreshNoteList());
}
}
3.在 MVI-Model 中转发消息
public class PageMessenger extends MVI-Disptacher {
protected void onHandle(Messages intent){
sendResult(intent);
}
}
4.在 MVI-View 中响应消息
public class TestFragment {
public void onOutput() {
MVI-Model.output(this, intent-> {
switch(intent.id) {
case Messages.RefreshNoteList.ID: ... break;
case Messages.FinishActivity.ID: ... break;
}
});
}
}
B.带参数的意图分发场景
该场景十分普遍,例如页面向后台请求一数据,通过意图来传递参数,后台处理好结果,将结果注入到意图中,回传给页面。
所以该场景下,意图会携带 “参数” 和 “结果”,且发送场景下只需注入参数,回推场景下只需注入结果,
因而使用方法即,
1.定义接口,为参数添加 @Param 注解,
@SealedClass
public interface _NoteIntent {
void addNote(@Param Note note, boolean isSuccess);
void removeNote(@Param Note note, boolean isSuccess);
}
build 生成的静态方法,比如 AddNote 方法中,只提供 “参数” 列表,不提供结果列表,结果字段皆赋予默认值,以符合意图发送场景的使用。
public static NoteIntent AddNote(Note note) {
return new AddNote(note, false);
}
2.在 MVI-View 中发送一个 NoteIntent.AddNote(note) 意图,
public class TestFragment {
public void onInput() {
MVI-Model.input(NoteIntent.AddNote(note));
}
}
3.在 MVI-Model 中处理业务逻辑,注入结果和回推意图。
由于意图为确保 “数据一致性” 而不可修改,因此在注入结果的场景下,可通过 copy 方法拷贝一份新的意图,而 copy 方法的入参即 “结果” 列表,以符合意图回推场景的使用。
public class NoteRequester extends MVI-Disptacher {
protected void onHandle(NoteIntent intent){
switch(intent.id) {
case NoteIntent.AddNote.ID:
DataRepository.instance().addNote(result -> {
NoteIntent.AddNote addNote = (NoteIntent.AddNote) intent;
sendResult(addNote.copy(result.isSuccess));
});
break;
case NoteIntent.RemoveNote.ID:
...
break;
}
}
}
4.在 MVI-View 中响应意图
public class TestFragment {
public void onOutput() {
MVI-Model.output(this, intent-> {
switch(intent.id) {
case NoteIntent.AddNote.ID:
updateUI();
break;
case NoteIntent.RemoveNote.ID:
...
break;
}
});
}
}
C.不带参的事件分发场景
也即没有初值传参,只用于结果分发的情况。
这种场景和 “带参数意图分发场景” 通常重叠和互补,所以使用上其实大同小异。
1.定义接口,方法不带 @Param 注解。那么该场景下 NoteIntent.GetNotes 静态方法提供无参和有参两种,我们通常是使用无参,也即事件在创建时结果是被给到默认值。
@SealedClass
public interface _NoteIntent {
void getNotes(List<Note> notes);
}
2.在 MVI-View 中发送一个 NoteIntent.GetNotes() 事件,
public class TestFragment {
public void onInput() {
MVI-Model.input(NoteIntent.GetNotes());
}
}
3.在 MVI-Model 中处理业务逻辑,注入结果和回推意图。
由于意图为确保 “数据一致性” 而不可修改,因此在注入结果的场景下,可通过 copy 方法拷贝一份新的意图,而 copy 方法的入参即 “结果” 列表,以符合意图回推场景的使用。
public class NoteRequester extends MVI-Disptacher {
protected void onHandle(NoteIntent intent){
switch(intent.id) {
case NoteIntent.GetNotes.ID:
DataRepository.instance().getNotes(result -> {
NoteIntent.GetNotes getNotes = (NoteIntent.GetNotes) intent;
sendResult(getNotes.copy(result.notes));
});
break;
case NoteIntent.RemoveNote.ID:
...
break;
}
}
}
4.在 MVI-View 中响应事件
public class TestFragment {
public void onOutput() {
MVI-Model.output(this, intent-> {
switch(intent.id) {
case NoteIntent.GetNotes.ID:
updateUI();
break;
case NoteIntent.RemoveNote.ID:
...
break;
}
});
}
}
作者:KunMinX
链接:https://juejin.cn/post/7137571636781252622
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
Compose制作“抖音”、“快手”视频进度条Loading动画效果
现在互联网产品,感觉谁家的App不整点视频功能,严格意义上都不能说是一个现代互联网App了😂,我们知道最火的是抖音、快手这类短视频App,在刷视频的同时,他们的App交互上面的一些特色能让我们一直沉浸在刷视频中;
比如,我们今天要聊的,短视频翻页流列表,视频加载缓冲的时候,Loading的设计:
它设计:在视频底部,进度条上面,当视频缓冲加载等待的时候,它有一个波纹的扩散效果,
即不干扰用户刷视频的操作,也没有很明显的突兀效果
(比如:突兀的屏幕中间大圆圈Loading,就很突兀)
一定要记得:『点赞❤️+关注❤️+收藏❤️』起来,划走了可就再也找不到了😅😅🙈🙈
我们先来看一下“抖音、快手App”的视频进度条Loading效果(GIF图稍微失真了点
)
快手短视频首页的视频Loading
从视频里面可以看出来在视频底部,出现缓冲加载视频的时候,会有一个:“从中间往2边扩散”的效果。
GIF图放慢了一点,方便大家观看,实际研究过程,我一般是通过录制完视频,通过相册的视频编辑,去一帧一帧看,做了哪些动作,如下:
看完,我们发现:
1、一开始是在屏幕中间的位置,大概是20dp左右的宽度开始显示;
2、从中间扩散到屏幕边缘之后,会执行渐隐;
3、渐隐到透明,又开始从中间往2边扩散;
有了上面的前奏,我们就可以开始我们的编码了,那么在开始编码前,肯定需要知道宽度是多少,这里我们拿BoxWithConstraints来包我们的child composable,
我们可以看到BoxWithConstraints的代码如下:
// 代码来自:androidx.compose.foundation.layout
@Composable
@UiComposable
fun BoxWithConstraints(
modifier: Modifier = Modifier,
contentAlignment: Alignment = Alignment.TopStart,
propagateMinConstraints: Boolean = false,
content:
@Composable @UiComposable BoxWithConstraintsScope.() -> Unit
) {
val measurePolicy = rememberBoxMeasurePolicy(contentAlignment, propagateMinConstraints)
SubcomposeLayout(modifier) { constraints ->
val scope = BoxWithConstraintsScopeImpl(this, constraints)
val measurables = subcompose(Unit) { scope.content() }
with(measurePolicy) { measure(measurables, constraints) }
}
}
里面用到了SubcomposeLayout,来推迟内容组合,我们可以在BoxWithConstraintsScope里面获取到最大宽度maxWidth (单位dp)。
Loading线条,我们可以用DrawScope.drawLine来画,扩散效果肯定需要有动画来更新。
我们使用 rememberInfiniteTransition() 执行无限动画,使用animateFloat来获取动画更新的值:
// 代码来自:androidx.compose.animation.core
@Composable
fun InfiniteTransition.animateFloat(
initialValue: Float,
targetValue: Float,
animationSpec: InfiniteRepeatableSpec<Float>
): State<Float>
初始值(initialValue)可以定义成50F(读者可自行修改)
,目标值(targetValue)定义多少合适呢?
通过慢镜头查看“抖音、快手”的效果,发现它扩散完,会“渐隐到透明”,然后再从intialValue处开始重新扩散。
targetValue定义成maxWidth不行,那么我们拉大这个数值,可以定义成大概1.8倍的maxWidth;
由于maxWidth获取到的是dp单位的,我们需要转换成px,下面我们统一叫:width
val width = with(LocalDensity.current) { maxWidth.toPx() }
然后,我们的线条动画值就变成下面这样:
val lineProgressAnimValue by infiniteTransition.animateFloat(
initialValue = 100F,
targetValue = width * 1.8F,
animationSpec = infiniteRepeatable(
animation = tween(
durationMillis = TIME_PERIOD,
easing = FastOutLinearInEasing
)
)
)
private const val TIME_PERIOD = 1100
线条扩散到屏幕边缘的时候,需要执行渐隐,得出下面的alpha
val lineAlphaValue = if(lineProgressAnimValue <= width) {
// 读者可以根据自己体验
lineProgressAnimValue * 1.0F/ width * 1.0F
// 读者可以根据自己体验
//Math.min((lineProgressAnimValue.value) * 1.0F / width * 1.0F, 0.7F)
// 抖音、快手看效果都是1F,根据自己体验来设置吧
// 1F
} else {
// 扩散到屏幕边缘的时候,开始触发:渐隐
(width * 1.8F - lineProgressAnimValue) / width * 0.8F
}
// 线条宽度
val lineWidth = if(lineProgressAnimValue <= width) {
lineProgressAnimValue / 2
} else {
width / 2
}
最后,我们通过Canvas来绘制这个线条
Canvas(modifier = modifier) {
drawLine(
color = Color.White.copy(alpha = lineAlphaValue),
start = Offset(x = size.width / 2 - lineWidth, y = 0F),
end = Offset(x = size.width / 2 + lineWidth, y = 0F),
strokeWidth = 2.5F
)
}
来看看我们的最终效果吧:
作者:Halifax
链接:https://juejin.cn/post/7133793654912581639
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
Kotlin学习快速入门—— 属性委托
委托其实是一种设计模式,但Kotlin把此特性编写进了语法中,可以方便开发者快速使用,本篇也来具体讲解下关于Kotlin中属性委托的使用
委托对应的关键字是by
属性委托
先讲下属性委托吧,首先,复习下kotlin中设置set和get方法
默认的set和get我们可以隐藏,实际上一个简单的类代码如下:
class Person {
var personName = ""
// 这是默认的 get/set(默认是隐藏的)
get() = field
set(value) {
field = value
}
}
这里具体知识点可以查看之前所说Kotlin学习快速入门(3)——类 继承 接口 - Stars-One的杂货小窝
当然,如果是数据bean类,我们会将get和set方法隐藏(或者使用data
关键字来声明一个数据类)
若我们需要在get或set方法的时候做一下逻辑处理,比如说上面的personName
字段,我们只允许接收长度小于等于10的字符串,超过10长度的字符串就不接收(即不设置新数值),则是应该这样写:
class Person{
var personName = ""
// 这是重写的 get/set
get() = "PersonName $field"
set(value) {
field = if (value.length <= 10) value else field
}
}
然后,我们再延伸出来,如果此规则不止应用于personName字段,还可用到其他类的字段中,这个时候就是使用到属性委托。
简单描述: 我们将此规则抽取出来,需要应用到此规则的字段的get/set方法委托给规则去做,这就叫属性委托
延迟加载(懒加载)
在开始讲属性委托之前,先说明下延迟加载
Kotlin中提供了lazy方法,使用by
+lazy{}
联用,我们就实现延迟加载(也可称作懒加载)
fun main() {
val demo = Demo()
val textContent = demo.textContent
val result = demo.textContent.substring(1)
println(result)
println("打印:$textContent")
}
class Demo{
val textContent by lazy { loadFile() }
}
fun loadFile(): String {
println("读取文件...")
//模拟读取文件返回数据
return "读取的数据"
}
这里的关键词by出现在属性名后面,表示属性委托,即将属性的读和写委托给另一个对象,被委托的对象必须满足一定的条件:
- 对于
val
修饰的只读变量进行属性委托时,被委托的对象必须实现getValue()
接口,即定义如何获取变量值。 - 对于
var
修饰的读写变量进行属性委托时,被委托对象必须实现getValue()
和setValue()
接口,即定义如何读写变量值。
lazy()
方法,接收一个lambda函数,返回值是一个Lazy对象,所以就可以简写成上面的样子,其只实现了getValue()
接口,所以,当你尝试将textContent
改为var类型,IDE会提示报错!!
也是因为这点,属于延迟加载的字段,是不可被再次修改了,所以采用lazy懒加载的方式,其实就是单例模式
lazy
函数默认是线程安全的,而且是通过加锁实现的。如果你的变量不会涉及到多线程,那么请务必使用LazyThreadSafetyMode.NONE
参数,避免不必要的性能开销,如下示例代码
val name:String by lazy(LazyThreadSafetyMode.NONE) { "Karl" }
Delegates.vetoable
还记得上述我们要实现的规则吗,其实Kotlin中已经有了几个默认的委托规则供我们快速使用(上述的lazy其实也是一个)
Delegates.vetoable()的规则就是上述规则的通用封装,解释为:
但会在属性被赋新值生效之前会传递给Delegates.vetoable()
进行处理,依据Delegates.vetoable()
的返回的布尔值判断要不要赋新值。
如下面例子:
class Person {
var personName by Delegates.vetoable("") { property, oldValue, newValue ->
//当设置的新值满足条件,则会设置为新值
newValue.length <= 10
}
}
Delegates.notNull
设置字段不能为null,不过想不到具体的应用情景
class Person {
var personName by Delegates.notNull<String>()
}
Delegates.observable
使用Delegates.observable
可以帮我们快速实现观察者模式,只要字段数值发生改变,就会触发
class Person{
var age by Delegates.observable(0){ property, oldValue, newValue ->
//这里可以写相关的逻辑
if (newValue >= 18) {
tip = "已成年"
}else{
tip = "未成年"
}
}
var tip =""
}
上面的例子就比较简单,设置age同时更新提示,用来判断是否成年
val person = Person()
person.age = 17
println(person.tip)
补充-自定义委托
上述都是官方定义好的一些情形,但如果不满足我们的需求,这就需要自定义委托了
官方提供了两个基础类供我们自定义委托使用:
ReadWriteProperty
包含get和set方法,对应var
关键字
ReadOnlyProperty
只有get方法,对应val
关键字
PS:实际上,我们自己随意创建个委托类也是可以的,不过这样写不太规范,所以我们一般直接实现官方给的上述两个类即可
ReadWriteProperty和ReadOnlyProperty都需要传两个泛型,分别为R,T
R
持有属性的类型T
字段类型
可能上面描述不太明白,下面给个简单例子,Person类中有个name字段(String),首字母需要大写:
class Person {
var name by NameToUpperCase("")
}
class NameToUpperCase(var value:String) :ReadWriteProperty<Person,String>{
//NameToUpperCase类中默认有个属性字段,用来存数据
override fun getValue(thisRef: Person, property: KProperty<*>): String {
return this.value
}
override fun setValue(thisRef: Person, property: KProperty<*>, value: String) {
//在设置数值的时候,将第一个字母转为大写,一般推荐在setValue里编写逻辑
this.value = value.substring(0,1).toUpperCase()+value.substring(1)
}
}
个人看法,一般在setValue的时候进行设置数值比较好,因为getValue作操作的话,会触发多次,处理的逻辑复杂的话可能会浪费性能...
当然,再提醒下,上面的逻辑也可以直接去字段里的setValue()
里面改,使用委托的目的就是方便抽取出去供其他类使用同样的规则,减少模板代码
PS: 如果你的委托不是针对特定的类,R泛型可以改为Any
类委托
这个一般与多态一起使用,不过个人想不到有什么具体的应用情景,暂时做下简单的记录
interface IDataStorage{
fun add()
fun del()
fun query()
}
class SqliteDataStorage :IDataStorage{
override fun add() {
println("SqliteDataStorage add")
}
override fun del() {
println("SqliteDataStorage del")
}
override fun query() {
println("SqliteDataStorage query")
}
}
假如现在我们有个MyDb类,查询的方法与SqliteDataStorage这个里的方法有所区别,但其他方法都是没有区别,这个时候就会用到类委托了
有以下几种委托的使用方式
1.委托类作为构造器形参传入(常用)
class MyDb(private val storage:IDataStorage) : IDataStorage by storage{
override fun add() {
println("mydb add")
}
}
val db = MyDb(SqliteDataStorage())
db.add()
db.query()
输出结果:
mydb add
SqliteDataStorage query
如果是全部都是委托给SqliteDataStorage的话,可以简写为这样:
class MyDb(private val storage:IDataStorage) : IDataStorage by storage
2.新建委托类对象
class MyDb : IDataStorage by SpDataStorage(){
override fun add() {
println("mydb add")
}
}
这里测试的效果与上文一样,不在重复赘述
参考
作者:Stars-One
链接:https://juejin.cn/post/7134886417934581768
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
Flutter开发·async await原理解析
async await 与 Future
在异步调用中有三个关键词,async、await、Future,其中async和await需要一起使用。在Dart中可以通过async和await进行异步操作,async表示开启一个异步操作,也可以返回一个Future结果。如果没有返回值,则默认返回一个返回值为null的Future。
await的操作,不会影响方法外后续代码的执行;只会阻塞async方法的后续代码
例子1
_testAsyncKeyword() {
print("test函数开始了:${DateTime.now()}");
_testString().then((value) => print(value));
print("test函数结束了:${DateTime.now()}");
}
Future<String> _testString() async {
Future f = Future.delayed(Duration(milliseconds: 300), () {
return "我是测试字符串===1";
});
String result = await f;
print("我是测试字符串===2");
return result;
}
// flutter: test函数开始了:
// flutter: test函数结束了:
// flutter: 我是测试字符串===2
// flutter: 我是测试字符串===1
在代码示例中,执行到_testString()方法,会同步进入方法内部进行执行,当执行到await时就会停止async内部的执行,从而继续执行外面的代码。所以await的操作,不会影响后面代码的执行("test函数结束了"会先于_testString()内部打印)。
当await有返回后,会继续从await的位置继续执行(所以先打印出了 "我是测试字符串===2" ,然后返回Future的结果,并通过print打印出 "我是测试字符串===1")。
例子2
_testAsyncKeyword() async {
print("test函数开始了:${DateTime.now()}");
print(await _testString());
print("test函数结束了:${DateTime.now()}");
}
Future<String> _testString() async {
Future f = Future.delayed(Duration(milliseconds: 300), () {
return "我是测试字符串===1";
});
String result = await f;
print("我是测试字符串===2");
return result;
}
// flutter: test函数开始了:
// flutter: 我是测试字符串===2
// flutter: 我是测试字符串===1
// flutter: test函数结束了:
在代码示例中, _testAsyncKeyword() 本身内部就有一个await操作,当执行到await时就会停止_testAsyncKeyword() async内部的执行.等待_testString()有结果返回之后,继续执行.
_testString()内部也是有一个await操作,当执行到await时就会停止_testString() async内部的执行,等待300毫秒,Future有结果后,打印字符串2
_testAsyncKeyword() 继续执行 打印 字符串1 及 结束
例子3
_testAsyncKeyword() {
print("test函数开始了:${DateTime.now()}");
firstString().then((value) => print(value));
secondString().then((value) => print(value));
thirdString().then((value) => print(value));
print("test函数结束了:${DateTime.now()}");
}
_testKeyword2() async{
print("test函数开始了:${DateTime.now()}");
print(await firstString());
print(await secondString());
print(await thirdString());
print("test函数结束了:${DateTime.now()}");
}
Future<String> firstString() {
return Future.delayed(Duration(milliseconds: 300), () {
return "我是一个字符串";
});
}
Future<String> secondString() {
return Future.delayed(Duration(milliseconds: 200), () {
return "我是二个字符串";
});
}
Future<String> thirdString() {
return Future.delayed(Duration(milliseconds: 100), () {
return "我是三个字符串";
});
}
//_testAsyncKeyword() 的打印:
//flutter: test函数开始了:
//flutter: test函数结束了:
//flutter: 我是三个字符串
//flutter: 我是二个字符串
//flutter: 我是一个字符串
//_testKeyword2() 的打印:
//flutter: test函数开始了:
//flutter: 我是一个字符串
//flutter: 我是二个字符串
//flutter: 我是三个字符串
//flutter: test函数结束了:
通过上面三个例子 , 可以看出 await async 和 then之间的区别和联系了.
async、await的原理
async、await的操作属于**"假异步"**,这是为什么呢?
如果想要得到这个问题的答案,首先我们需要了解async、await的原理,了解协程的概念,因为async、await本质上就是协程的一种语法糖。协程,也叫作coroutine,是一种比线程更小的单元。如果从单元大小来说,基本可以理解为 进程->线程->协程。
任务调度
在弄懂协程之前,首先要明白并发和并行的概念
- 并发: 指的是由系统来管理多个IO的切换,并交由CPU去处理。
- 并行: 指的是多核CPU在同一时间里执行多个任务。
并发的实现由非阻塞操作+事件通知来完成,事件通知也叫做“中断”。操作过程分为两种,一种是CPU对IO进行操作,在操作完成后发起中断告诉IO操作完成。另一种是IO发起中断,告诉CPU可以进行操作。
线程: 本质上也是依赖于中断来进行调度的,线程还有一种叫做“阻塞式中断”,就是在执行IO操作时将线程阻塞,等待执行完成后再继续执行,通过单线程并发可以进行大量并发操作。但线程的消耗是很大的,并不适合大量并发操作的处理,且单个线程只能使用到单个CPU。当多核CPU出现后,单个线程就无法很好的利用多核CPU的优势了,所以又引入了线程池的概念,通过线程池来管理大量线程。当需要同时执行多项任务的时候,我们就会采用多线程并发执行.
Dart单线程运行模型: 输入单吸纳成运行机制,主要是通过消息循环机制来实现任务调度和处理的.
协程coroutine
多线程并发 操作系统在线程等待IO的时候,会阻塞当前线程,切换到其它线程,这样在当前线程等待IO的过程中,其它线程可以继续执行。当系统线程较少的时候没有什么问题,但是当线程数量非常多的时候,却产生了问题。一是系统线程会占用非常多的内存空间,二是过多的线程切换会占用大量的系统时间。
协程 运行在线程之上,当一个协程执行完成后,可以选择主动让出,让另一个协程运行在当前线程之上。协程并没有增加线程数量,只是在线程的基础之上通过分时复用的方式运行多个协程,而且协程的切换在用户态完成,切换的代价比线程从用户态到内核态的代价小很多。
协程分为无线协程和有线协程.
- 无线协程在离开当前调用位置时,会将当前变量放在 堆区,当再次回到当前位置时,还会继续从堆区中获取到变量。所以,一般在执行当前函数时就会将变量直接分配到堆区,而async、await就属于无线协程的一种。
- 有线协程则会将变量继续保存在 栈区,在回到指针指向的离开位置时,会继续从栈中取出调用。
async、await原理
之所以说async/await是假异步,是因为他在执行过程中并没有开启新的线程更没有并发执行,而是通过单线程上的任务调度(协程,没有并发执行功能)实现的:
当代码执行到async则表示进入一个协程,会同步执行async的代码块。async的代码块本质上也相当于一个函数,并且有自己的上下文环境。当执行到await时,则表示有任务需要等待,CPU则去调度执行其他IO,也就是后面的代码或其他协程代码。过一段时间CPU就会轮询一次,看某个协程是否任务已经处理完成,有返回结果可以被继续执行,如果可以被继续执行的话,则会沿着上次离开时指针指向的位置继续执行,也就是await标志的位置。
由于并没有开启新的线程,只是进行IO中断改变CPU调度,所以网络请求这样的异步操作可以使用async、await,但如果是执行大量耗时同步操作的话,应该使用isolate开辟新的线程去执行。
作者:单总不会亏待你
链接:https://juejin.cn/post/7025200193729462302
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
android 自定义View: 视差动画
废话不多说,先来看今天要完成的效果:
在上一篇:android setContentView()解析中我们介绍了,如何通过Factory2来自己解析View,
那么我们就通过这个机制,来完成今天的效果《视差动画》,
回顾
先来回顾一下如何在Fragment中自己解析View
class MyFragment : Fragment(), LayoutInflater.Factory2 {
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View {
val newInflater = inflater.cloneInContext(activity)
LayoutInflaterCompat.setFactory2(newInflater, this)
return newInflater.inflate(R.layout.my_fragment, container, false)
}
// 重写Factory2的方法
override fun onCreateView(
parent: View?,
name: String,
context: Context,
attrs: AttributeSet,
): View? {
val view = createView(parent, name, context, attrs)
// 此时的view就是自己创建的view!
// ...................
return view
}
// 重写Factory2的方法
override fun onCreateView(name: String, context: Context, attrs: AttributeSet): View? {
return onCreateView(null, name, context, attrs)
}
// SystemAppCompatViewInflater() 复制的系统源码
private var mAppCompatViewInflater = SystemAppCompatViewInflater()
private fun createView(
parent: View?, name: String?, mContext: Context,
attrs: AttributeSet,
): View? {
val is21 = Build.VERSION.SDK_INT < 21
// 自己去解析View
return mAppCompatViewInflater.createView(parent, name, mContext, attrs, false,
is21, /* Only read android:theme pre-L (L+ handles this anyway) */
true, /* Read read app:theme as a fallback at all times for legacy reasons */
false /* Only tint wrap the context if enabled */
)
}
}
如果对这段代码有兴趣的,可以去看 上一篇:android setContentView()解析,
思路分析
viewpager + fragment
自定义属性:
- 旋转: parallaxRotate
- 缩放 : parallaxZoom
- 出场移动:parallaxTransformOutX,parallaxTransformOutY
- 入场移动:parallaxTransformInX,parallaxTransformInY
给需要改变变换的view设置属性
在fragment的时候自己创建view,并且通过AttributeSet解析所有属性
将需要变换的view保存起来,
在viewpager滑动过程中,通过addOnPageChangeListener{} 来监听viewpager变化,当viewpager变化过程中,设置对应view对应变换即可!
viewPager+Fragment
首先先实现最简单的viewpager+Fragment
代码块1.1
class ParallaxBlogViewPager(context: Context, attrs: AttributeSet?) : ViewPager(context, attrs) {
fun setLayout(fragmentManager: FragmentManager, @LayoutRes list: ArrayList<Int>) {
val listFragment = arrayListOf<C3BlogFragment>()
// 加载fragment
list.map {
C3BlogFragment.instance(it)
}.forEach {
listFragment.add(it)
}
adapter = ParallaxBlockAdapter(listFragment, fragmentManager)
}
private inner class ParallaxBlockAdapter(
private val list: List<Fragment>,
fm: FragmentManager
) : FragmentPagerAdapter(fm) {
override fun getCount(): Int = list.size
override fun getItem(position: Int) = list[position]
}
}
C3BlogFragment:
代码块1.2
class C3BlogFragment private constructor() : Fragment(), LayoutInflater.Factory2 {
companion object {
@NotNull
private const val LAYOUT_ID = "layout_id"
fun instance(@LayoutRes layoutId: Int) = let {
C3BlogFragment().apply {
arguments = bundleOf(LAYOUT_ID to layoutId)
}
}
}
private val layoutId by lazy {
arguments?.getInt(LAYOUT_ID) ?: -1
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View {
val newInflater = inflater.cloneInContext(activity)
LayoutInflaterCompat.setFactory2(newInflater, this)
return newInflater.inflate(layoutId, container, false)
}
override fun onCreateView(
parent: View?,
name: String,
context: Context,
attrs: AttributeSet,
): View? {
val view = createView(parent, name, context, attrs)
/// 。。。 在这里做事情。。。
return view
}
private var mAppCompatViewInflater = SystemAppCompatViewInflater()
override fun onCreateView(name: String, context: Context, attrs: AttributeSet): View? {
return onCreateView(null, name, context, attrs)
}
private fun createView(
parent: View?, name: String?, mContext: Context,
attrs: AttributeSet,
): View? {
val is21 = Build.VERSION.SDK_INT < 21
return mAppCompatViewInflater.createView(parent, name, mContext, attrs, false,
is21,
true,
false
)
}
}
这个fragment目前的作用就是接收传过来的布局,展示,
并且自己解析view即可!
xml与调用:
R.layout.c3_1.item,这些布局很简单,就是
- 一张静态图片
- 一张动态图片
其他的布局都是一样的,这里就不看了.
来看看当前的效果
自定义属性
通常我们给一个view自定义属性,我们会选择在attrs.xml 中来进行,例如这样:
但是很明显,这么做并不适合我们的场景,因为我们想给任何view都可以设置属性,
那么我们就可以参考ConstraintLayout中的自定义属性:
我们自己定义属性:
并且给需要变换的view设置值
- app:parallaxRotate="10" 表示在移动过程中旋转10圈
- app:parallaxTransformInY="0.5" 表示入场的时候,向Y轴方向偏移 height * 0.5
- app:parallaxZoom="1.5" 表示移动过程中慢慢放大1.5倍
Fragment中解析自定义属性
我们都知道,所有的属性都会存放到AttributeSet中,先打印看一看:
(0 until attrs.attributeCount).forEach {
Log.i("szj属性",
"key:${attrs.getAttributeName(it)}\t" +
"value:${attrs.getAttributeValue(it)}")
}
这样一来就可以打印出所有的属性,并且找到需要用的属性!
那么接下来只需要将这些属性保存起来,在当viewpager滑动过程中取出用即可!
这里我们的属性是保存到view的tag中,
需要注意的是,如果你的某一个view需要变换,那么你的view就一定得设置一个id,因为这里是通过id来存储tag!
监听ViewPager滑动事件
# ParallaxBlogViewPager.kt
// 监听变化
addOnPageChangeListener(object : OnPageChangeListener {
// TODO 滑动过程中一直回调
override fun onPageScrolled(
position: Int,
positionOffset: Float,
positionOffsetPixels: Int,
) {
Log.e("szjParallaxViewPager",
"onPageScrolled:position:$position\tpositionOffset:${positionOffset}\tpositionOffsetPixels:${positionOffsetPixels}")
}
//TODO 当页面切换完成时候调用 返回当前页面位置
override fun onPageSelected(position: Int) {
Log.e("szjParallaxViewPager", "onPageSelected:$position")
}
//
override fun onPageScrollStateChanged(state: Int) {
when (state) {
SCROLL_STATE_IDLE -> {
Log.e("szjParallaxViewPager", "onPageScrollStateChanged:页面空闲中..")
}
SCROLL_STATE_DRAGGING -> {
Log.e("szjParallaxViewPager", "onPageScrollStateChanged:拖动中..")
}
SCROLL_STATE_SETTLING -> {
Log.e("szjParallaxViewPager", "onPageScrollStateChanged:拖动停止了..")
}
}
}
})
这三个方法介绍一下:
onPageScrolled(position:Int , positionOffset:Float, positionOffsetPixels)
@param position
: 当前页面下标@param positionOffset
:当前页面滑动百分比@param positionOffsetPixels
: 当前页面滑动的距离
在这个方法中需要注意的是,当假设当前是第0个页面,从左到右滑动,
- position = 0
- positionOffset = [0-1]
- positionOffsetPixels = [0 - 屏幕宽度]
当第1个页面的时候,从左到右滑动,和第0个页面的状态都是一样的
但是从第1个页面从右到左滑动的时候就不一样了,此时
- position = 0
- positionOffset = [1-0]
- positionOffsetPixels = [屏幕宽度 - 0]
onPageSelected(position:Int)
@param position
: 但页面切换完成的时候调用
onPageScrollStateChanged(state:Int)
@param state:
但页面发生变化时候调用,一共有3种状体
- SCROLL_STATE_IDLE 空闲状态
- SCROLL_STATE_DRAGGING 拖动状态
- SCROLL_STATE_SETTLING 拖动停止状态
了解了viewpager滑动机制后,那么我们就只需要在滑动过程中,
获取到刚才在tag种保存的属性,然后改变他的状态即可!
# ParallaxBlogViewPager.kt
// 监听变化
addOnPageChangeListener(object : OnPageChangeListener {
// TODO 滑动过程中一直回调
override fun onPageScrolled(
position: Int,
positionOffset: Float,
positionOffsetPixels: Int,
) {
// TODO 当前fragment
val currentFragment = listFragment[position]
currentFragment.list.forEach { view ->
// 获取到tag中的值
val tag = view.getTag(view.id)
(tag as? C3Bean)?.also {
// 入场
view.translationX = -it.parallaxTransformInX * positionOffsetPixels
view.translationY = -it.parallaxTransformInY * positionOffsetPixels
view.rotation = -it.parallaxRotate * 360 * positionOffset
view.scaleX =
1 + it.parallaxZoom - (it.parallaxZoom * positionOffset)
view.scaleY =
1 + it.parallaxZoom - (it.parallaxZoom * positionOffset)
}
}
// TODO 下一个fragment
// 防止下标越界
if (position + 1 < listFragment.size) {
val nextFragment = listFragment[position + 1]
nextFragment.list.forEach { view ->
val tag = view.getTag(view.id)
(tag as? C3Bean)?.also {
view.translationX =
it.parallaxTransformInX * (width - positionOffsetPixels)
view.translationY =
it.parallaxTransformInY * (height - positionOffsetPixels)
view.rotation = it.parallaxRotate * 360 * positionOffset
view.scaleX = (1 + it.parallaxZoom * positionOffset)
view.scaleY = (1 + it.parallaxZoom * positionOffset)
}
}
}
}
//TODO 当页面切换完成时候调用 返回当前页面位置
override fun onPageSelected(position: Int) {...}
override fun onPageScrollStateChanged(state: Int) { ... }
})
来看看现在的效果:
此时效果就基本完成了
但是一般情况下,引导页面都会在最后一个页面有一个跳转到主页的按钮
为了方便起见,我们只需要将当前滑动到的fragment页面返回即可!
这么一来,我们就可以在layout布局中为所欲为,因为我们可以自定义属性,并且自己解析,可以做任何自己想做的事情!
原创不易,您的点赞与关注就是对我最大的支持!
作者:史大拿
链接:https://juejin.cn/post/7137925163336597517
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
Flutter EventBus事件总线的应用
前言
flutter项目中,有许多可以实现跨组件通讯的方案,其中包括InheritedWidget,Notification,EventBus等。本文主要探讨的是EventBus事件总线实现跨组件通讯的方法。
EventBus的简介
EventBus
的核心是基于Streams
。它允许侦听器订阅事件并允许发布者触发事件,使得不同组件的数据不需要一层层传递,可以直接通过EventBus
实现跨组件通讯。
EventBus
最主要是通过触发事件和监听事件两项操作来实现不同页面的跨层访问。触发事件是通过fire(event)方法进行,监听事件则是通过on<T>()方法进行的,其中泛型可以传入指定类型,事件总线将进行针对性监听,如果泛型传值为空,则默认监听所有类型的事件:
void fire(event) {
streamController.add(event);
}
Stream<T> on<T>() {
if (T == dynamic) {
return streamController.stream as Stream<T>;
} else {
return streamController.stream.where((event) => event is T).cast<T>();
}
}
EventBus的实际应用
1、在
pubspec.yaml
文件中引用eventBus事件总线依赖;
2、创建一个全局的
EventBus
实例;
3、使用
fire(event)
方法在事件总线上触发一个新事件(触发事件);
4、为事件总线注册一个监听器(监听事件);
5、取消EventBus事件订阅,防止内存泄漏。
// 1、在pubspec.yaml文件中引用eventBus事件总线依赖;
dependencies:
event_bus: ^2.0.0
// 2、创建一个全局的EventBus实例;
EventBus myEventBus = EventBus();
// 3、使用fire(event)方法在事件总线上触发一个新事件(触发事件);
Center(
child: ElevatedButton(
onPressed: () {
myEventBus.fire('通过EventBus触发事件');
},
child: Text('触发事件'),
),
)
var getData;
@override
void initState() {
// TODO: implement initState
super.initState();
// 4、为事件总线注册一个监听器(监听事件);
getData = myEventBus.on().listen((event) {
print(event);
});
}
@override
void dispose() {
// TODO: implement dispose
super.dispose();
// 5、取消EventBus事件订阅,防止内存泄漏。
getData.cancel();
}
总结
EventBus
遵循的是发布/订阅模式,能够通过事件的触发和监听操作,有效实现跨组件通讯的功能。
作者:Zheng
链接:https://juejin.cn/post/7137327139061727262
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
Flutter 状态管理 | 业务逻辑与构建逻辑分离
1. 业务逻辑和构建逻辑
对界面呈现来说,最重要的逻辑有两个部分:业务数据的维护逻辑
和 界面布局的构建逻辑
。其中应用运行中相关数据的获取、修改、删除、存储等操作,就是业务逻辑。比如下面是秒表的三个界面,核心 数据
是秒表的时刻。在秒表应用执行功能时,数据的变化体现在秒数的变化、记录、重置等。
默认情况 | 暂停 | 记录 |
---|---|---|
界面的构建逻辑主要体现在界面如何布局,维持界面的出现效果。另外,在界面构建过程中,除了业务数据,还有一些数据会影响界面呈现。比如打开秒表时,只有一个启动按钮;在运行中,显示暂停按钮和记录按钮;在暂停时,记录按钮不可用,重置按钮可用。这样在不同的交互场景中,有不同的界面表现,也是构建逻辑处理的一部分。
2. 数据的维护
所以的逻辑本身都是对 数据
的维护,界面能够显示出什么内容,都依赖于数据进行表现。理解需要哪些数据、数据存储在哪里,从哪里来,要传到哪里去,是编程过程中非常重要的一个环节。由于数据需要在构建界面时使用,所以很自然的:在布局写哪里,数据就在哪里维护。
比如默认的计数器项目,其中只有一个核心数据 _counter
,用于表示当前点击的次数。
代码实现时, _counter
数据定义在 _MyHomePageState
中,改数据的维护也在状态类中:
对于一些简单的场景,这样的处理无可厚非。但在复杂的交互场景中,业务逻辑和构建逻辑杂糅在 State
派生类中,会导致代码复杂,逻辑混乱,不便于阅读和维护。
3.秒表状态数据对布局的影响
现在先通过代码来实现如下交互,首先通过 StopWatchType
枚举来标识秒表运行状态。在初始状态 none
时,只有一个开始按钮;点击开始,秒表在运行中,此时显示三个按钮,重置按钮是灰色,不可点击,点击旗子按钮,可以记录当前秒表值;暂停时,旗子按钮不可点击,点击重置按钮时,回到初始态。
enum StopWatchType{
none, // 初始态
stopped, // 已停止
running, // 运行中
}
如下所示,通过 _buildBtnByState
方法根据 StopWatchState
状态值构建底部按钮。根据不同的 state
情况处理不同的显示效果,这就是构建逻辑的体检。而此时的关键数据就是 StopWatchState
对象。
Widget _buildBtnByState(StopWatchType state) {
bool running = state == StopWatchType.running;
bool stopped = state == StopWatchType.stopped;
Color activeColor = Theme.of(context).primaryColor;
return Wrap(
spacing: 20,
children: [
if(state!=StopWatchType.none)
FloatingActionButton(
child: const Icon(Icons.refresh),
backgroundColor: stopped?activeColor:Colors.grey,
onPressed: stopped?reset:null,
),
FloatingActionButton(
child: running?const Icon(Icons.stop):const Icon(Icons.play_arrow_outlined),
onPressed: onTapIcon,
),
if(state!=StopWatchType.none)
FloatingActionButton(
backgroundColor: running?activeColor:Colors.grey,
child: const Icon(Icons.flag),
onPressed: running?onTapFlag:null,
),
],
);
}
这样按照常理,应该在 _HomePageState
中定义 StopWatchType
对象,并在相关逻辑中维护 state
数据的值,如下 tag1,2,3
处:
StopWatchType state = StopWatchState.none;
void reset(){
duration.value = Duration.zero;
setState(() {
state = StopWatchState.none; // tag1
});
}
void onTapIcon() {
if (_ticker.isTicking) {
_ticker.stop();
lastDuration = Duration.zero;
setState(() {
state = StopWatchType.stopped; // tag2
});
} else {
_ticker.start();
setState(() {
state = StopWatchType.running; // tag3
});
}
}
4.秒表记录值的维护
如下所示,在秒表运行时点击旗子,可以记录当前的时刻并显示在右侧:
由于布局界面在 _HomePageState
中,事件的触发也在该类中定义。按照常理,又需要在其中维护 durationRecord
列表数据,进行界面的展现。
List<Duration> durationRecord = [];
final TextStyle recordTextStyle = const TextStyle(color: Colors.grey);
Widget buildRecordeList(){
return ListView.builder(
itemCount: durationRecord.length,
itemBuilder: (_,index)=>Center(child:
Padding(
padding: const EdgeInsets.all(4.0),
child: Text(
durationRecord[index].toString(),style: recordTextStyle,
),
)
));
}
void onTapFlag() {
setState(() {
durationRecord.add(duration.value);
});
}
void reset(){
duration.value = Duration.zero;
durationRecord.clear();
setState(() {
state = StopWatchState.none;
});
}
其实到这里可以发现,随着功能的增加,需要维护的数据会越来越多。虽然全部塞在 _HomePageState
类型访问和修改比较方便,但随着代码的增加,状态类会越来越臃肿。所以分离逻辑在复杂的场景中是非常必要的。
5. 基于 flutter_bloc 的状态管理
状态类的核心逻辑应该在于界面的 构建逻辑
,而业务数据的维护,我们可以提取出来。这里通过 flutter_bloc
来将秒表中数据的维护逻辑进行分离,由 bloc
承担。
我们的目的是为 _HomePageState
状态类 "瘦身"
,如下,其中对于数据的处理逻辑都交由 StopWatchBloc
通过 add
相关事件来触发。_HomePageState
自身就无须书写维护业务数据的逻辑,可以在很大程度上减少 _HomePageState
的代码量,从而让状态类专注于界面构建逻辑。
class _HomePageState extends State<HomePage> {
StopWatchBloc get stopWatchBloc => BlocProvider.of<StopWatchBloc>(context);
void onTapIcon() {
stopWatchBloc.add(const ToggleStopWatch());
}
void onTapFlag() {
stopWatchBloc.add(const RecordeStopWatch());
}
void reset() {
stopWatchBloc.add(const ResetStopWatch());
}
首先创建状态类 StopWatchState
来维护这三个数据:
part of 'bloc.dart';
enum StopWatchType {
none, // 初始态
stopped, // 已停止
running, // 运行中
}
class StopWatchState {
final StopWatchType type;
final List<Duration> durationRecord;
final Duration duration;
const StopWatchState({
this.type = StopWatchType.none,
this.durationRecord = const [],
this.duration = Duration.zero,
});
StopWatchState copyWith({
StopWatchType? type,
List<Duration>? durationRecord,
Duration? duration,
}) {
return StopWatchState(
type: type ?? this.type,
durationRecord: durationRecord??this.durationRecord,
duration: duration??this.duration,
);
}
}
然后定义先关的行为事件,比如 ToggleStopWatch
用于开启或暂停秒表;ResetStopWatch
用于重置秒表;RecordeStopWatch
用于记录值。这就是最核心的三个功能:
abstract class StopWatchEvent {
const StopWatchEvent();
}
class ResetStopWatch extends StopWatchEvent{
const ResetStopWatch();
}
class ToggleStopWatch extends StopWatchEvent {
const ToggleStopWatch();
}
class _UpdateDuration extends StopWatchEvent {
final Duration duration;
_UpdateDuration(this.duration);
}
class RecordeStopWatch extends StopWatchEvent {
const RecordeStopWatch();
}
最后在 StopWatchBloc
中监听相关的事件,进行逻辑处理,产出正确的 StopWatchState
状态量。这样就将数据的维护逻辑封装到了 StopWatchBloc
中。
part 'event.dart';
part 'state.dart';
class StopWatchBloc extends Bloc<StopWatchEvent,StopWatchState>{
Ticker? _ticker;
StopWatchBloc():super(const StopWatchState()){
on<ToggleStopWatch>(_onToggleStopWatch);
on<ResetStopWatch>(_onResetStopWatch);
on<RecordeStopWatch>(_onRecordeStopWatch);
on<_UpdateDuration>(_onUpdateDuration);
}
void _initTickerWhenNull() {
if(_ticker!=null) return;
_ticker = Ticker(_onTick);
}
Duration _dt = Duration.zero;
Duration _lastDuration = Duration.zero;
void _onTick(Duration elapsed) {
_dt = elapsed - _lastDuration;
add(_UpdateDuration(state.duration+_dt));
_lastDuration = elapsed;
}
@override
Future<void> close() async{
_ticker?.dispose();
_ticker = null;
return super.close();
}
void _onToggleStopWatch(ToggleStopWatch event, Emitter<StopWatchState> emit) {
_initTickerWhenNull();
if (_ticker!.isTicking) {
_ticker!.stop();
_lastDuration = Duration.zero;
emit(state.copyWith(type:StopWatchType.stopped));
} else {
_ticker!.start();
emit(state.copyWith(type:StopWatchType.running));
}
}
void _onUpdateDuration(_UpdateDuration event, Emitter<StopWatchState> emit) {
emit(state.copyWith(
duration: event.duration
));
}
void _onResetStopWatch(ResetStopWatch event, Emitter<StopWatchState> emit) {
_lastDuration = Duration.zero;
emit(const StopWatchState());
}
void _onRecordeStopWatch(RecordeStopWatch event, Emitter<StopWatchState> emit) {
List<Duration> currentList = state.durationRecord.map((e) => e).toList();
currentList.add(state.duration);
emit(state.copyWith(durationRecord: currentList));
}
}
6. 组件状态类对状态的访问
这样 StopWatchBloc
封装了状态的变化逻辑,那如何在构建时让 组件状态类
访问到 StopWatchState
呢?实现需要在 HomePage
的上层包裹 BlocProvider
来为子节点能访问 StopWatchBloc
对象。
BlocProvider(
create: (_) => StopWatchBloc(),
child: const HomePage(),
),
比如构建表盘是通过 BlocBuilder
替代 ValueListenableBuilder
,这样当状态量 StopWatchState
发生变化是,且满足 buildWhen
条件时,就会 局部构建
来更新 StopWatchWidget 组件
。其他两个部分同理。这样在保证功能的实现下,就对逻辑进行了分离:
Widget buildStopWatch() {
return BlocBuilder<StopWatchBloc, StopWatchState>(
buildWhen: (p, n) => p.duration != n.duration,
builder: (_, state) => StopWatchWidget(
duration: state.duration,
radius: 120,
),
);
}
另外,由于数据已经分离,记录数据已经和 _HomePageState
解除了耦合。这就意味着记录面板可以毫无顾虑地单独分离出来,独立维护。这又进一步简化了 _HomePageState
中的构建逻辑,简化代码,便于阅读,这就是一个良性的反馈链。
到这里,关于通过状态管理如何分离 业务逻辑
和构建逻辑
就介绍的差不多了,大家可以细细品味。其实所有的状态管理库都大同小异,它们的目的不是在于 优化性能
,而是在于 优化结构层次
。这里用的是 flutter_bloc
,你完全也可以使用其他的状态管理来实现类似的分离。工具千变万化,但思想万变不离其宗。谢谢观看 ~
作者:张风捷特烈
链接:https://juejin.cn/post/7137851060231602184
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
Flutter 3.3 正式发布,快来看看有什么新功能吧
Flutter 3.3 正式发布啦,本次更新带来了 Flutter Web、桌面、文本性能处理等相关更新,另外,本次还为 go_router
、DevTools 和 VS Code 扩展引入了更多更新。
Framework
Global Selection
Flutter Web 在之前的版本中,经常会有选择文本时与预期的行为不匹配的情况,因为与 Flutter App 一样,原生 Web 是由 elements 树组成。
在传统的 Web 应用中,开发者可以通过一个拖动手势选择多个 Web 元素,但这在 Flutter Web 上无法轻松完成。
但是从 3.3 开始,随着SelectableArea
的引入, SelectableArea
Widget 的任何 Child 都可以自由启用改能力。
要利用这个强大的新特性,只需使用 SelectionArea
嵌套你的页面,比如路由下的 Scaffold
,然后让 Flutter 就会完成剩下的工作。
要更全面地深入了解这个新功能,请访问
SelectableArea
API
触控板输入
Flutter 3.3 改进了对触控板输入的支持,这不仅提供了更丰富和更流畅的控制逻辑,还减少了某些情况下的错误识别。
举个例子,在 Flutter cookbook 中拖动 UI 元素页面,滚动到页面底部,然后执行以下步骤:
- 缩小窗口大小,使上部呈现滚动条
- 悬停在上部
- 使用触控板滚动
- 在 Flutter 3.3 之前,在触控板上滚动会拖动项目,因为 Flutter 正在调度模拟的一般事件
- Flutter 3.3 后,在触控板上滚动会正确滚动列表,因为 Flutter 提供的是“滚动”手势,卡片无法识别,但滚动可以被识别。
有关更多信息,请参阅 Flutter 触控板手势 设计文档,以及 GitHub 上的以下 PR:
- PR 89944:在框架中支持触控板手势
- PR 31591:iPad 触控板手势
- PR 34060:“ChromeOS/Android 触控板手势”
- PR 31594:Win32 触控板手势
- PR 31592:Linux 触控板手势
- PR 31593:Mac 触控板手势macOS
Scribble
感谢社区成员fbcouch的贡献,Flutter 现在支持在 iPadOS 上使用 Apple Pencil 进行 Scribble 手写输入。
默认情况下,此功能在 CupertinoTextField
、TextField
和 EditableText
上启用,启用此功能,只需升级到 Flutter 3.3。
Text input
为了改进对富文本编辑的支持,该版本引入了平台的 TextInputPlugin
,以前,TextInputClient
只交付新的编辑状态,没有新旧之间的差异信息,而 TextEditingDeltas
填补了 DeltaTextInputClient
这个信息空白。
通过访问这些增量,开发者可以构建一个带有样式范围的输入字段,该范围在用户键入时会扩展和收缩。
要了解更多信息,请查看富文本编辑器演示。
Material Design 3
Flutter 团队继续将更多 Material Design 3 组件迁移到 Flutter。此版本包括对IconButton
、Chips
以及AppBar
.
要监控 Material Design 3 迁移的进度,请查看GitHub 上的将 Material 3 带到 Flutter。
图标按钮
Chip
Medium and large AppBar
Desktop
Windows
以前,Windows 的版本由特定于 Windows 应用的文件设置,但这个行为与其他平台设置其版本的方式不一致。
但现在开发者可以在项目 pubspec.yaml
文件和构建参数中设置 Windows 桌面应用程序版本。
有关设置应用程序版本的更多信息,请遵循 docs.flutter.dev上的文档和 迁移指南
Packages
go_router
为了扩展 Flutter 的原生导航 API,团队发布了一个新版本的 go_router
包,它的设计使得移动端、桌面端和 Web 端的路由逻辑变得更加简单。
go router
包由 Flutter 团队维护,通过提供声明性的、基于 url 的 API 来简化路由,从而更容易导航和处理深层链接。
最新版本 (5.0) 下应用能够使用异步代码进行重定向,并包括迁移指南中描述的其他重大更改.有关更多信息,请查看 docs.flutter.dev 上的导航和路由页面。
VS Code 扩展增强
Flutter 的 Visual Studio Code 扩展有几个更新,包括添加依赖项的改进,开发者现在可以使用Dart: Add Dependency一步添加多个以逗号分隔的依赖项。
Flutter 开发者工具更新
自上一个稳定的 Flutter 版本以来,DevTools 进行了许多更新,包括对数据显示表的 UX 和性能改进,以便更快、更少地滚动大型事件列表 ( #4175 )。
有关 Flutter 3.0 以来更新的完整列表,请在此处查看各个公告:
Performance
光栅缓存改进
此版本通过消除拷贝和减少 Dart 垃圾收集 (GC) 压力来提高从资产加载图像的性能。
以前在加载资产图像时,ImageProvider
API 需要多次复制压缩数据,当打开 assets 并将其作为类型化数据数组公开给 Dart 时,它会被复制到 native 堆中,然后当该类型化数据数组会被它被第二次复制到内部 ui.ImmutableBuffer
。
通过 #32999,压缩的图像字节可以直接加载到ui.ImmutableBuffer.fromAsset
用于解码的结构中,这种方法 需要 更改ImageProviders
,这个过程也更快,因为它绕过了先前方法基于通道的加载器所需的一些额外的调度开销,特别是在我们的微基准测试中,图像加载时间提高了近 2 倍。
有关更多信息和迁移指南,请参阅在 docs.flutter.dev 上ImageProvider.loadBuffer 。
Stability
iOS 指针压缩已禁用
在 2.10 稳定版本中,我们在 iOS 上启用了 Dart 的指针压缩优化,然而 GitHub 上的Yeatse提醒我们 优化的结果并不好。
Dart 的指针压缩通过为 Dart 的堆保留一个大的虚拟内存区域来工作,由于 iOS 上允许的总虚拟内存分配少于其他平台,因此这一大预留量减少了可供其他保留自己内存的组件使用的内存量,例如 Flutter 插件。
虽然禁用指针压缩会增加 Dart 对象消耗的内存,但它也增加了 Flutter 应用程序的非 Dart 部分的可用内存,这总体上更可取的方向。
Apple 提供了一项可以增加应用程序允许的最大虚拟内存分配的权利,但是此权利仅在较新的 iOS 版本上受支持,目前这并且不适用于运行 Flutter 仍支持的 iOS 版本的设备。
API 改进
PlatformDispatcher.onError
在以前的版本中,开发者必须手动配置自定义 Zone
项才能捕获应用程序的所有异常和错误,但是自定义 Zone
对 Dart 核心库中的一些优化是有害的,这会减慢应用程序的启动时间。
在此版本中,开发者可以通过设置回调来捕获所有错误和异常,而不是使用自定义。
有关更多信息,请查看docs.flutter.dev 上 Flutter 页面中更新的 PlatformDispatcher.onError
FragmentProgram changes
用 GLSL 编写并在 shaders:
应用文件的 Flutter 清单中列出的片段着色器,pubspec.yaml
现在将自动编译为引擎可以理解的正确格式,并作为 assets 与应用捆绑在一起。
通过此次更改,开发者将不再需要使用第三方工具手动编译着色器,未来应该是将 Engine 的FragmentProgram
API 视为仅接受 Flutter 构建工具的输出,当然目前还没有这种情况,但计划在未来的版本中进行此更改,如 FragmentProgram API 支持改进设计文档中所述。
有关此更改的示例,请参阅此Flutter 着色器示例。
Fractional translation
以前,Flutter Engine 总是将 composited layers 与精确的像素边界对齐,因为它提高了旧款(32 位)iPhone 的渲染性能。
自从添加桌面支持以来,我们注意到这导致了可观察到的捕捉行为,因为屏幕设备像素比通常要低得多,例如,在低 DPR 屏幕上,可以看到工具提示在淡入时明显捕捉。
在确定这种像素捕捉对于新 iPhone 型号的性能不再必要后,#103909 从 Flutter 引擎中删除了这种像素捕捉以提高桌面保真度。
此外,我们还发现,去除这种像素捕捉可以稳定我们的一些黄金图像测试,这些测试会经常随着细微的细线渲染差异而改变。
对支持平台的更改
32 位 iOS 弃用
正如我们之前在3.0 版本里宣布的一样 ,由于使用量减少,该版本是最后一个支持 32 位 iOS 设备和 iOS 版本 9 和 10的版本。
此更改影响 iPhone 4S、iPhone 5、iPhone 5C 以及第 2、3d 和第 4 代 iPad 设备。
Flutter 3.3 稳定版本和所有后续稳定版本不再支持 32 位 iOS 设备以及 iOS 9 和 10 版本,这意味着基于 Flutter 3.3 及更高版本构建的应用程序将无法在这些设备上运行。
停用 macOS 10.11 和 10.12
在 2022 年第四季度稳定版本中,我们预计将放弃对 macOS 版本 10.11 和 10.12 的支持。
这意味着在那之后针对稳定的 Flutter SDK 构建的应用程序将不再在这些版本上运行,并且 Flutter 支持的最低 macOS 版本将增加到 10.13 High Sierra。
Bitcode deprecation
在即将发布的 Xcode 14 版本中,iOS 应用程序提交将不再接受 Bitcode ,并且启用了 bitcode 的项目将在此版本的 Xcode 中发出构建警告。鉴于此,Flutter 将在未来的稳定版本中放弃对位码的支持。
默认情况下,Flutter 应用程序没有启用 Bitcode,我们预计这不会影响许多开发人员。
但是,如果你在 Xcode 项目中手动启用了 bitcode,请在升级到 Xcode 14 后立即禁用它,可以通过打开 ios/Runner.xcworkspace
构建设置Enable Bitcode并将其设置为No来做到这一点,Add-to-app 开发者应该在宿主 Xcode 项目中禁用它。
作者:恋猫de小郭
链接:https://juejin.cn/post/7137845252139778084
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
【开源 UI 组件】Flutter 图表范围选择器
前言
最近有一个小需求:图表支持局部显示,如下底部的区域选择器支持
- 左右拖动调节中间区域
- 拖拽中间区域,可以进行移动
- 图表数据根据中间区域的占比进行显示部分数据
这样当图表的数据量过大,不宜全部展示时,可选择的局部展示就是个不错的解决方案。由于一般的图表库没有提供该功能,这里自己通过绘制来实现以下,操作效果如下所示:
1. 使用 chart_range_selector
目前这个范围选择器已经发布到 pub
上了,名字是 chart_range_selector。大家可以通过依赖进行添加
dependencies:
chart_range_selector: ^1.0.0
这个库本身是作为独立 UI
组件存在的,在拖拽过程中改变区域范围时,会触发回调。使用者可以通过监听来获取当前区域的范围。这里的区域起止是以分率的形式给出的,也就是最左侧是 0
最右侧是 1
。如下的区域范围是 0.26 ~ 0.72
。
ChartRangeSelector(
height: 30,
initStart: 0.4,
initEnd: 0.6,
onChartRangeChange: _onChartRangeChange,
),
void _onChartRangeChange(double start, double end) {
print("start:$start, end:$end");
}
封装的组件名为: ChartRangeSelector
,提供了如下的一些配置参数:
配置项 | 类型 | 简述 |
---|---|---|
initStart | double | 范围启始值 0~1 |
initEnd | double | 范围终止值 0~1 |
height | double | 高度值 |
onChartRangeChange | OnChartRangeChange | 范围变化回调 |
bgStorkColor | Color | 背景线条颜色 |
bgFillColor | Color | 背景填充颜色 |
rangeColor | Color | 区域颜色 |
rangeActiveColor | Color | 区域激活颜色 |
dragBoxColor | Color | 左右拖拽块颜色 |
dragBoxActiveColor | Color | 左右拖拽块激活颜色 |
2. ChartRangeSelector 实现思路分析
这个组件整体上是通过 ChartRangeSelectorPainter
绘制出来的,其实这些图形都是挺规整的,绘制来说并不是什么难事。重点在于事件的处理,拖拽不同的部位需要处理不同的逻辑,还涉及对拖拽部位的校验、高亮示意,对这块的整合还是需要一定的功力的。
代码中通过 RangeData
可监听对象为绘制提供必要的数据,其中 minGap
用于控制范围的最小值,保证范围不会过小。另外定义了 OperationType
枚举表示操作,其中有四个元素,none
表示没有拖拽的普通状态;dragHead
表示拖动起始块,dragTail
表示拖动终止块,dragZone
表示拖动范围区域。
enum OperationType{
none,
dragHead,
dragTail,
dragZone
}
class RangeData extends ChangeNotifier {
double start;
double end;
double minGap;
OperationType operationType=OperationType.none;
RangeData({this.start = 0, this.end = 1,this.minGap=0.1});
//暂略相关方法...
}
在组件构建中,通过 LayoutBuilder
获取组件的约束信息,从而获得约束区域宽度最大值,也就是说组件区域的宽度值由使用者自行约束,该组件并不强制指定。使用 SizedBox
限定画板的高度,通过 CustomPaint
组件使用 ChartRangeSelectorPainter
进行绘制。使用 GestureDetector
组件进行手势交互监听,这就是该组件整体上实现的思路。
3.核心代码实现分析
可以看出,这个组件的核心就是 绘制
+ 手势交互
。其中绘制比较简单,就是根据 RangeData
数据和颜色配置画些方块而已,稍微困难一点的是对左右控制柄位置的计算。另外,三个可拖拽物的激活状态是通过 RangeData#operationType
进行判断的。
也就是说所有问题的焦点都集中在 手势交互
中对 RangeData
数据的更新。如下是处理按下的逻辑,当触电横坐标左右 10
逻辑像素之内,表示激活头部。如下 tag1
处通过 dragHead
方法更新 operationType
并触发通知,这样画板绘制时就会激活头部块,右侧和中间的激活同理。
---->[RangeData#dragHead]----
void dragHead(){
operationType=OperationType.dragHead;
notifyListeners();
}
void _onPanDown(DragDownDetails details, double width) {
double start = width * rangeData.start;
double x = details.localPosition.dx;
double end = width * rangeData.end;
if (x >= start - 10 && x <= end + 10) {
if ((start - details.localPosition.dx).abs() < 10) {
rangeData.dragHead(); // tag1
return;
}
if ((end - details.localPosition.dx).abs() < 10) {
rangeData.dragTail();
return;
}
rangeData.dragZone();
}
}
对于拖手势的处理,是比较复杂的。如下根据 operationType
进行不同的逻辑处理,比如当 dragHead
时,触发 RangeData#moveHead
方法移动 start
值。这里将具体地逻辑封装在 RangeData
类中。可以使代码更加简洁明了,每个操作都有 bool
返回值用于校验区域也没有发生变化,比如拖拽到 0
时,继续拖拽是会触发事件的,此时返回 false
,避免无意义的 onChartRangeChange
回调触发。
void _onUpdate(DragUpdateDetails details, double width) {
bool changed = false;
if (rangeData.operationType == OperationType.dragHead) {
changed = rangeData.moveHead(details.delta.dx / width);
}
if (rangeData.operationType == OperationType.dragTail) {
changed = rangeData.moveTail(details.delta.dx / width);
}
if (rangeData.operationType == OperationType.dragZone) {
changed = rangeData.move(details.delta.dx / width);
}
if (changed) widget.onChartRangeChange.call(rangeData.start, rangeData.end);
}
如下是 RangeData#moveHead
的处理逻辑,_recordStart
用于记录起始值,如果移动后未改变,返回 false
。表示不执行通知和触发回调。
---->[RangeData#moveHead]----
bool moveHead(double ds) {
start += ds;
start = start.clamp(0, end - minGap);
if (start == _recordStart) return false;
_recordStart = start;
notifyListeners();
return true;
}
4. 结合图表使用
下面是结合 charts_flutter
图标库实现的范围显示案例。其中核心点是 domainAxis
可以通过 NumericAxisSpec
来显示某个范围的数据,而 ChartRangeSelector
提供拽的交互操作来更新这个范围,可谓相辅相成。
class RangeChartDemo extends StatefulWidget {
const RangeChartDemo({Key? key}) : super(key: key);
@override
State<RangeChartDemo> createState() => _RangeChartDemoState();
}
class _RangeChartDemoState extends State<RangeChartDemo> {
List<ChartData> data = [];
int start = 0;
int end = 0;
@override
void initState() {
super.initState();
data = randomDayData(count: 96);
start = 0;
end = (0.8 * data.length).toInt();
}
Random random = Random();
List<ChartData> randomDayData({int count = 1440}) {
return List.generate(count, (index) {
int value = 50 + random.nextInt(200);
return ChartData(index, value);
});
}
@override
Widget build(BuildContext context) {
List<charts.Series<ChartData, int>> seriesList = [
charts.Series<ChartData, int>(
id: 'something',
colorFn: (_, __) => charts.MaterialPalette.blue.shadeDefault,
domainFn: (ChartData sales, _) => sales.index,
measureFn: (ChartData sales, _) => sales.value,
data: data,
)
];
return Column(
children: [
Expanded(
child: charts.LineChart(seriesList,
animate: false,
primaryMeasureAxis: const charts.NumericAxisSpec(
tickProviderSpec: charts.BasicNumericTickProviderSpec(desiredTickCount: 5),),
domainAxis: charts.NumericAxisSpec(
viewport: charts.NumericExtents(start, end),
)),
),
const SizedBox(
height: 10,
),
SizedBox(
width: 400,
child: ChartRangeSelector(
height: 30,
initEnd: 0.5,
initStart: 0.3,
onChartRangeChange: (start, end) {
this.start = (start * data.length).toInt();
this.end = (end * data.length).toInt();
setState(() {});
}),
),
],
);
}
}
class ChartData {
final int index;
final int value;
ChartData(this.index, this.value);
}
本文就介绍到这里,更多的实现细节感兴趣的可以研究一下源码。谢谢观看 ~
作者:张风捷特烈
链接:https://juejin.cn/post/7134885139980484615
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
这些flow常见API的使用,你一定需要掌握!
collect
通知flow执行
public suspend inline fun <T> Flow<T>.collect(crossinline action: suspend (value: T) -> Unit): Unit =
collect(object : FlowCollector<T> {
override suspend fun emit(value: T) = action(value)
})
flow
是冷流,只有调用collect{}
方法时才能触发flow代码块的执行
。还有一点要注意,collect{}
方法是个suspend
声明的方法,需要在协程作用域的范围能调用。
除此之外,collect{}
方法的参数是一个被crossinline
修饰的函数类型,旨在加强内联,禁止在该函数类型中直接使用return
关键字(return@标签除外
)。
fun main() {
GlobalScope.launch {
flow {
emit("haha")
}.collect {
}
}
}
launchIn()
指定协程作用域通知flow执行
public fun <T> Flow<T>.launchIn(scope: CoroutineScope): Job = scope.launch {
collect() // tail-call
}
这个方法允许我们直接传入一个协程作用域的参数,不需要直接在外部开启一个协程执行
。本质上就是使用我们传入的协程作用域手动开启一个协程代码块调用collect{}
通知协程执行。
这里看官方的源码有个tail-call
的注释,也就是尾调用的意思,猜测这里可能官方会在这里进行了优化,减少了栈中方法调用的层级,降低栈溢出的风险。
fun main() {
flow {
emit("haha")
}.launchIn(GlobalScope)
}
catch{}
捕捉异常
public fun <T> Flow<T>.catch(action: suspend FlowCollector<T>.(cause: Throwable) -> Unit): Flow<T> =
flow {
val exception = catchImpl(this)
if (exception != null) action(exception)
}
这个就是用来捕捉异常的,不过注意,只能捕捉catch()
之前的异常,下面来个图阐述下:
即,只能捕捉第一个红框中的异常,而不能捕捉第二个红框中的异常。
merge()
合流
public fun <T> merge(vararg flows: Flow<T>): Flow<T> = flows.asIterable().merge()
最终的实现类如下:
请注意,这个合流的每个流可以理解为是并行执行
的,而不是后一个流等待前一个流中的flow代码块中的逻辑执行完毕再执行,这样做的目的可以提供合流的每个流的执行效果。
测试代码如下:
fun main() {
GlobalScope.launch {
merge(flow {
delay(1000)
emit(4)
}, flow {
println("flow2")
delay(2000)
emit(20)
}).collect {
println("collect value: $it")
}
}
}
输出日志如下:
、
map{}
变换发送的数据类型
public inline fun <T, R> Flow<T>.map(crossinline transform: suspend (value: T) -> R): Flow<R> = transform { value ->
return@transform emit(transform(value))
}
这个api没什么可将的,很多的地方比如集合、livedata中都有它的影子,它的作用就是将当前数据类型变换成另一种数据类型(可以相同)。
fun main() {
GlobalScope.launch {
flow {
emit(5)
}.map {
"ha".repeat(it)
}.collect {
println("collect value: $it")
}
}
}
总结
本篇文章介绍了flow常见的api,接下来还会有一些列文章用来介绍flow的其他api,感谢阅读。
作者:长安皈故里
链接:https://juejin.cn/post/7134478501612093471
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
Android通知 Notification的简单使用
在Android应用的开发中,必然会遇上通知的开发需求,本文主要讲一下Android中的通知 Notification的简单基本使用,主要包含创建通知渠道、初始化通知、显示通知、显示图片通知、通知点击、以及配合WorkManager发送延迟通知。
创建通知渠道
首先,创建几个常量和变量,其中渠道名是会显示在手机设置-通知里app对应展示的通知渠道名称,一般基于通知作用取名。
companion object {
//渠道Id
private const val CHANNEL_ID = "渠道Id"
//渠道名
private const val CHANNEL_NAME = "渠道名-简单通知"
//渠道重要级
private const val CHANNEL_IMPORTANCE = NotificationManager.IMPORTANCE_DEFAULT
}
private lateinit var context: Context
//Notification的ID
private var notifyId = 100
private lateinit var manager: NotificationManager
private lateinit var builder: NotificationCompat.Builder
然后获取系统通知服务,创建通知渠道,其中因为通知渠道是Android8.0才有的,所以增加一个版本判断:
//获取系统通知服务
manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
//创建通知渠道,Android8.0及以上需要
createChannel()
private fun createChannel() {
//创建通知渠道,Android8.0及以上需要
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return
}
val notificationChannel = NotificationChannel(
CHANNEL_ID,
CHANNEL_NAME,
CHANNEL_IMPORTANCE
)
manager.createNotificationChannel(notificationChannel)
}
初始化通知
先生成NotificationCompat.Builder,然后初始化通知Builder的通用配置:
builder = NotificationCompat.Builder(context.applicationContext, CHANNEL_ID)
initNotificationBuilder()
/**
* 初始化通知Builder的通用配置
*/
private fun initNotificationBuilder() {
builder
.setAutoCancel(true) //设置这个标志当用户单击面板就可以让通知自动取消
.setSmallIcon(R.drawable.ic_reminder) //通知的图标
.setWhen(System.currentTimeMillis()) //通知产生的时间,会在通知信息里显示
.setDefaults(Notification.DEFAULT_ALL)
}
此外builder还有setVibrate、setSound、setStyle等方法,按需配置即可。
显示通知
给builder设置需要通知需要显示的title和content,然后通过builder.build()生成生成通知Notification,manager.notify()方法将通知发送出去。
fun configNotificationAndSend(title: String, content: String){
builder.setContentTitle(title)
.setContentText(content)
val notification = builder.build()
//发送通知
manager.notify(notifyId, notification)
//id自增
notifyId++
}
最简单的通知显示至此上面三步就完成了。
效果如下图:
显示图片通知
当通知内容过多一行展示不下时,可以通过设置
builder.setStyle(NotificationCompat.BigTextStyle().bigText(content)) //设置可以显示多行文本
这样通知就能收缩和展开,显示多行文本。 另外setStyle还可以设置图片形式的通知:
setStyle(NotificationCompat.BigPictureStyle().bigPicture(BitmapFactory.decodeResource(resources,R.drawable.logo)))//设置图片样式
效果如下图:
通知点击
目前为止的通知还只是显示,因为设置了builder.setAutoCancel(true),点击通知之后通知会自动消失,除此之外还没有其他操作。 给builder设置setContentIntent(PendingIntent)就能有通知点击之后的其他操作了。PendingIntent可以看作是对Intent的一个封装,但它不是立刻执行某个行为,而是满足某些条件或触发某些事件后才执行指定的行为。PendingIntent获取有三种方式:Activity、Service和BroadcastReceiver获取。通过对应方法PendingIntent.getActivity、PendingIntent.getBroadcast、PendingIntent.getService就能获取。 这里就示例一下PendingIntent.getBroadcast和PendingIntent.getActivity
PendingIntent.getBroadcast
首先创建一个BroadcastReceiver:
class NotificationHandleReceiver : BroadcastReceiver() {
companion object {
const val NOTIFICATION_HANDLE_ACTION = "notification_handle_action"
const val NOTIFICATION_LINK = "notificationLink"
const val TAG = "NotificationReceiver"
}
override fun onReceive(context: Context, intent: Intent?) {
if (intent?.action == NOTIFICATION_HANDLE_ACTION) {
val link = intent.getStringExtra(NOTIFICATION_LINK)
}
}
}
别忘了在清单文件中还需要静态注册BroadcastReceiver:
<receiver
android:name=".NotificationHandleReceiver"
android:exported="false">
<intent-filter>
<action android:name="notification_handle_action" />
</intent-filter>
</receiver>
然后创建一个上面BroadcastReceiver的Intent,在intent.putExtra传入相应的点击通知之后需要识别的操作:
fun generateDefaultBroadcastPendingIntent(linkParams: (() -> String)?): PendingIntent {
val intent = Intent(NotificationHandleReceiver.NOTIFICATION_HANDLE_ACTION)
intent.setPackage(context.packageName)
linkParams?.let {
val params = it.invoke()
intent.putExtra(NotificationHandleReceiver.NOTIFICATION_LINK, params)
}
return PendingIntent.getBroadcast(
context,
notifyId,
intent,
PendingIntent.FLAG_IMMUTABLE
)
}
这样生成的PendingIntent再builder.setContentIntent(pendingIntent),在我们点击通知之后,NotificationHandleReceiver的onReceive里就会收到信息了,根据信息处理后续操作即可。
PendingIntent. getActivity
Activity的PendingIntent用于跳转到指定activity,创建一个跳转activity的Intent(同普通的页面跳转的Intent),也是同上面在intent.putExtra传入相应的点击通知之后需要识别的操作:
val intent = Intent(this, XXXX::class.java).apply {
putExtra("title", title).putExtra("content", content)
}
return PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_IMMUTABLE)
也是这样生成的PendingIntent再builder.setContentIntent(pendingIntent),在我们点击通知之后,就会跳转到对应的activity页面,然后intent里就会收到信息了,根据信息处理后续操作即可。
Android12之PendingIntent特性
查看上面关于Android12的特性
在Android12平台上有关于PendingIntent的两点特性:
- 一是待处理 intent 可变性,必须为应用创建的每个
PendingIntent
对象指定可变性,这也是上面创建PendingIntent时需要设置flag为PendingIntent.FLAG_IMMUTABLE。 - 二是通知 trampoline 限制,以 Android 12 或更高版本为目标平台的应用无法从用作通知 trampoline 的服务或广播接收器中启动 activity。换言之,当用户点按通知或通知中的操作按钮时,您的应用无法在服务或广播接收器内调用
startActivity()
。所以当需要点击通知实现activity跳转时,需要使用PendingIntent. getActivity,而不是使用PendingIntent.getBroadcast,然后在BroadcastReceiver里实现activity跳转,后者方式在Android 12 或更高版本为目标平台的应用中将被限制。
配合WorkManager发送延迟通知
配合上WorkManager,就能实现发送延迟通知,主要是通过OneTimeWorkRequest的延迟特性。
创建一个延迟的OneTimeWorkRequest,加入WorkManager队列中:
fun sendWorkRequest(
context: Context,
reminderId: Int,
title: String,
content: String,
link: String,
triggerTime: Long
): OneTimeWorkRequest {
val duration = triggerTime - System.currentTimeMillis()
val data =
Data.Builder().putInt(REMINDER_WORKER_DATA_ID, reminderId).putString(REMINDER_WORKER_DATA_TITLE, title)
.putString(REMINDER_WORKER_DATA_CONTENT, content).putString(REMINDER_WORKER_DATA_LINK, link)
.build()
val uniqueWorkName =
"reminderData_${reminderId}"
val request = OneTimeWorkRequest.Builder(ReminderWorker::class.java)
.setInitialDelay(duration, TimeUnit.MILLISECONDS)
.setInputData(data)
.build()
WorkManager.getInstance(context)
.enqueueUniqueWork(uniqueWorkName, ExistingWorkPolicy.REPLACE, request)
return request
}
然后在doWork方法中拿到数据进行我们上面的通知发送显示即可。具体关于OneTimeWorkRequest的使用在本文中就不详细说明了。当需要发送延迟通知时,知道可以通过配合WorkManager实现。
Android13 通知权限
在目前最新的Android 13(API 级别 33)上对于通知增加了权限限制,具体可看官方描述:
作者:愿天深海
链接:https://juejin.cn/post/7134229758179016717
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
浅谈Kotlin编程-Kotlin空值处理
前言
许多编程语⾔(包括 Java)中最常⻅的错误之⼀,就是访问空成员会导致空异常(NullPointerException 或简称 NPE)。
开发中,经常会遇到空指针异常,如果对这个问题处理不当,还会引起程序的崩溃(crash),在Kotlin中,为了避免出现空指针异常,引入了 Null机制,本篇就来了解一下Kotlin中的 Null机制。
本文总览
1. 可空类型变量(?)
Kotlin中把变量分成了两种类型
- 可空类型变量
- 非空类型变量
通常,一个变量默认是非空类型。若要变量的值可以为空,必须在声明处的数据类型后添加 ?
来标识该变量可为空。如下示例:
var phone: String //声明非空变量
var price: Int? //声明可空变量
上述代码中,phone 为非空变量,price 为可空变量。若给变量name赋值为null,编译器会提示“Null can not be a value of a non-null type String”
错误信息。引起这个错误的原因是Kotlin官方约定变量默认为非空类型时,该变量不能赋值为null, 而price 赋值为null,编译可以通过。
声明可空变量时,若不知道初始值,则需将其赋值为null,否则会报“variable price must be initialized”
异常信息。
通过一段示例代码来学习如何判断变量是否为空,以及如何使用可空变量:
fun main() {
var name: String = "Any" // 非空变量
var phone: String? = null // 可空变量
if (phone != null) {
print(phone.length)
} else {
phone = "12345678901"
print("phone = " + phone)
}
}
运行结果:
phone = 12345678901
上述代码,定义一个非空变量 name,一个可空变量 phone。这段示例代码对可空变量进行判断,如果 phone 不为空则输出 phone的长度,否则将phone赋值为12345678901并打印输出。
2. 安全调用符(?.)
上一点的示例中,可空变量在使用时需要先通过if…else
判断,然后再进行相应的操作,这样使用还是比较繁琐。Kotlin提供了一个安全调用符?.
,用于调用可空类型变量中的成员方法或属性,语法格式为“变量?.成员”。其作用是先判断变量是否为null,如果不为null才调用变量的成员方法或者属性。
fun main() {
var name: String = "Any"
var phone: String? = null
var result = phone?.length
println(result)
}
运行结果:
null
结果可以看出,在使用?.
调用可空变量的属性时,若当前变量为空,则程序编译正常运行,且返回一个null值。
3. Elvis操作符(?:)
安全调用符调用可空变量中的成员方法或属性时,如果当前变量为空,则返回一个null值,但有时不想返回一个null值而是指定一个默认值,该如何处理呢?Kotlin中提供了一个Elvis操作符(?:)
,通过Elvis操作符(?:)
可以指定可空变量为null时,调用该变量中的成员方法或属性的返回值,其语法格式为 表达式 ?: 表达式 。若左边表达式非空,则返回左边表达式的值,否则返回右边表达式的值。
fun main() {
var name: String = "Any"
var phone: String? = null
var result = phone?.length ?: "12345678901"
println(result)
}
运行结果:
12345678901
从结果可以看出,当变量phone为空时,使用?:
操作符会返回指定的默认值“12345678901”,而非null值。
4. 非空断言(!!.)
除了使用安全调用符(?.)
来使用可空类型的变量之外,还可以通过非空断言(!!.)
来调用可空类型变量的成员方法或属性。使用非空断言时,调用变量成员方法或属性的语法结构为 “变量!!.成员” 。非空断言(!!.)
会将任何变量(可空类型变量或者非空类型变量)转换为非空类型的变量,若该变量为空则抛出异常。接下来我们通过一个例子来演示非空断言(!!.)
的使用,具体代码如下所示。
fun main() {
var phone: String? = null // 声明可空类型变量
var result = phone!!.length // 使用非空断言
println(result)
}
运行结果:
Exception in thread"main"kotlin.KotlinNullPointerException
at NoEmptyAssertionKt.main
(NoEmptyAssertion.kt:4)
运行结果抛出了空指针异常,若变量phone赋值不为空,则程序可以正常运行。
安全调用符与非空断言运算符都可以调用可空变量的方法,但是在使用时有一定的差别,如表所示。
操作符 | 安全 | 是否推荐 |
---|---|---|
安全调用符(?.) | 当变量值为null时,不会抛出异常,更安全 | 推荐使用 |
非空断言(!!) | 当变量值为null时,会抛出异常,不安全 | 可空类型变量经过非空断言后,这个变量变为非空变量,非空变量为null时,会报异常,不推荐 |
总结
上面四种情况的介绍,可以说的很全面地囊括 kotlin 中的空处理情况,开发中应根据实际场景使用合适的操作符。
作者:南巷羽
链接:https://juejin.cn/post/7134636916514750495
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
Compose制作一个“IOS”效果的SwitchButton
本文一个定制样式的SwitchButton,使用Compose来写是非常容易的,下面先来看看我们对外提供如下方法:
@Composeable
fun IosSwitchButton(
modifier: Modifier,
checked: Boolean,
width: Dp = 50.dp,
height: Dp = 30.dp,
// Thumb和Track的边缘间距
gapBetweenThumbAndTrackEdge: Dp = 2.dp,
checkedTrackColor: Color = Color(0xFF4D7DEE),
uncheckedTrackColor: Color = Color(0xFFC7C7C7),
onCheckedChange: ((Boolean) -> Unit)
)
我们先来实现点击切换,后面再来实现滑动切换,checked状态是需要外面(ViewModel)传过来,同样onCheckedChange回调的状态,需要同步更新到ViewModel中。
我们来简单的看看,只实现,点击切换按钮状态的效果代码:
// 定义按钮点击的状态记录
val switchONState = remember { mutableStateOf(checked) }
// Thumb的半径大小
val thumbRadius = height / 2 - gapBetweenThumbAndTrackEdge
// Thumb水平的位移
val thumbOffsetAnimX by animateFloatAsState(
targetValue = if (checked)
with(LocalDensity.current) { (width - thumbRadius - gapBetweenThumbAndTrackEdge).toPx() }
else
with(LocalDensity.current) { (thumbRadius + gapBetweenThumbAndTrackEdge).toPx() }
)
// Track颜色动画
val trackAnimColor by animateColorAsState(
targetValue = if (checked) checkedTrackColor else uncheckedTrackColor
)
上面的准备工作做完,我们就需要用到Canvas 来绘制Thumb和Track,按钮的点击我们需要用Modifier的pointerInput修饰符提供点按手势检测器:
Modifier.pointerInput(Unit) {
detectTapGestures(
onPress = { /* Called when the gesture starts */ },
onDoubleTap = { /* Called on Double Tap */ },
onLongPress = { /* Called on Long Press */ },
onTap = { /* Called on Tap */ }
)
}
看看我们的Canvas
Canvas(
modifier = modifier
.size(width = width, height = height)
.pointerInput(Unit) {
detectTapGestures(
onTap = {
// 更新切换状态
switchONState.value = !switchONState.value
onCheckedChange.invoke(switchONState.value)
}
)
}
) {
// 这里绘制Track和Thumb
}
绘制Track,我们需要更新drawRoundRect的color值,我们使用上面根据checked状态变更后的trackAnimColor颜色值:
drawRoundRect(
color = animateTrackColor,
// 圆角
cornerRadius = CornerRadius(x = height.toPx(), y = height.toPx())
)
绘制Thumb,我们需要更新drawCircle里面的中心坐标X轴数值,我们使用状态变更后的动画值thumbOffsetAnimX:
drawCircle(
color = Color.White,
// Thumb的半径
radius = thumbRadius.toPx(),
center = Offset(
x = thumbOffsetAnimX,
y = size.height / 2
)
)
上面实现只有点击功能,效果如下:
只能点击
GIF录制的效果不太明显,实际上根据大家个人的需求,如果只是为了点击能切换,上面的十几行代码就足够了;
当然我们对效果还是有追求的,请继续往下看,我们来看,如何实现滑动切换,我们还是看一下最后实现可滑动,可点击的效果吧,方便我们下面讲解:
可滑动,可点击,动画连贯
一定要记得:『点赞❤️+关注❤️+收藏❤️』起来,划走了可就再也找不到了😅😅🙈🙈
既然要用到滑动,那么我们就需要使用到Modifier的swipeable修饰符:
允许我们通过锚点设置,实现组件呈现吸附效果的动画,常用于开关等动画,需要注意的是:swipeable不会为被修饰的组件提供任何默认动画,只能为组件提供手势偏移量等信息。可以根据偏移量结合其他修饰符定制动画。
我们这里需要同时实现“点击”和“滑动”,这里需要把这2个修饰符组合到一个扩展文件里面,我们创建一个IOSSwitchModifierExtensions.kt文件:
// IOSSwitchModifierExtensions.kt
@ExperimentalMaterialApi
internal fun Modifier.swipeTrack(
anchors: Map<Float, Int>,
swipeableState: SwipeableState<Int>,
onClick: () -> Unit
) = composed {
this.then(Modifier
.pointerInput(Unit) {
detectTapGestures(
onTap = {
// 点击回调
onClick.invoke()
}
)
}
.swipeable(
state = swipeableState,
anchors = anchors,
thresholds = { _, _ ->
// 锚点间吸附效果的临界阈值
FractionalThreshold(0.3F)
},
// 水平方向
orientation = Orientation.Horizontal
)
)
}
我们下面会用到这个扩展方法,我们可以看到swipeable修饰符,需要SwipeableState和anchors
初始化swipeableState
val swipeableState = rememberSwipeableState(initialValue = 0, animationSpec = tween())
我们还需要初始化anchors设置在不同状态时对应的偏移量信息:
// Thumb的半径
val thumbRadius = (height / 2) - gapBetweenThumbAndTrackEdge
// 开始的锚点位置
val startAnchor = with(LocalDensity.current) {
(thumbRadius + gapBetweenThumbAndTrackEdge).toPx()
}
// 结束的锚点位置
val endAnchor = with(LocalDensity.current) {
(width - thumbRadius - gapBetweenThumbAndTrackEdge).toPx()
}
// 根据上面的注释,很明显了
val anchors = mapOf(startAnchor to 0, endAnchor to 1)
到这里,我们需要如何继续呢,我仍然是通过录制视频,然后通过一帧一帧的去分析IOS样式的switch动画效果来做的。
我们先看最终效果图,然后继续往下拆解:
可以看到,拖动Thumb的时候,灰色的矩形区域是缩小的,然后蓝色部分是一个颜色渐变动画,同样的,点击也是需要做对应的工作。
大家先思考一下,点击和滑动怎么做到一样的?
我们发现Swipeable有个animateTo方法,那这不就好使了吗?对不对
// 代码来自androidx.compose.material.Swipeable.kt
// 通过动画将状态设置为targetValue
suspend fun animateTo(targetValue: T, anim: AnimationSpec<Float> = animationSpec)
来了一个点,第二个点,第三个点,都来了:
// 因为animateTo是挂起函数,我们需要在coroutineScope.launch里面执行
val scope = rememberCoroutineScope()
从上面看到这里的小伙伴,应该知道,我们上面定义了一个IOSSwitchModifierExtensions.kt文件,我们在swipeTrack的onClick方法里面执行animateTo,其实这个animateTo只是更新我们当前的checked状态而已。
Canvas(
modifier = modifier
.size(width = width, height = height)
.swipeTrack(
anchors = anchors,
swipeableState = swipeableState,
onClick = {
scope.launch {
swipeableState.animateTo(if (!switchONState.value) 1 else 0)
}
}
)
) {
// 选中状态下的Track背景
// 未选中状态下的Track背景
// Thumb
}
接下来,应该怎么继续呢,我觉得大家可以先思考一下,再继续往下看。
刚刚上面,提到了有2个Track背景,一个背景是颜色渐变动画,一个是缩放动画。
Compose的Canvas怎么写scale呢? 别急,我们可以在源码和文档中找到Canvas#scale
不仅仅可以scale
,还可以rotate、insert、translate
等等。
还有一个问题,背景颜色渐变动画,我们要用animate*AsState
来做吗?
animate*AsState
函数是 Compose 中最简单的动画 API,用于为单个值添加动画效果。您只需提供结束值(或目标值),该 API 就会从当前值开始向指定值播放动画。
我们发现animate*AsState
并不是我们想要的,我们想要的是:
滑动的时候根据“当前滑动移动的距离”来更新Track背景色渐变
没有用Compose的时候,我们可以用初始化一个ArgbEvaluator,然后调用:
argbEvaluator.evaluate(fraction, startColor, stopColor)
在Compose中,我们应该怎么做呢?
我们发现Color.kt中的一个方法lerp:
androidx.compose.ui.graphics.ColorKt#lerp
上面的疑惑全部解开,下面就看看我们剩下的实现吧:
// 未选中状态下的Track的scale大小(0F - 1F)
val unCheckedTrackScale = rememberSaveable { mutableStateOf(1F) }
// 选中状态下Track的背景渐变色
val checkedTrackLerpColor by remember {
derivedStateOf {
lerp(
// 开始的颜色
uncheckedTrackColor,
// 结束的颜色
checkedTrackColor,
// 选中的Track颜色值,根据缩放值计算颜色【转换的渐变进度】
min((1F - unCheckedTrackScale.value) * 2, 1F)
)
}
}
LaunchedEffect(swipeableState.offset.value) {
val swipeOffset = swipeableState.offset.value
// 未选中的Track缩放大小
var trackScale: Float
((swipeOffset - startAnchor) / endAnchor).also {
trackScale = if (it < 0F) 0F else it
}
// 未选中的Track缩放大小更新,上面👆👆的:选中的Track颜色值,是根据这个来算的
unCheckedTrackScale.value = 1F - trackScale
// 更新开关状态
switchONState.value = swipeOffset >= endAnchor
// 回调状态
onCheckedChange.invoke(switchONState.value)
}
所以,我们的Canvas里面Track和Thumb最终颜色和缩放,是根据上面计算出来的值来更新的:
Canvas(
modifier = modifier.size(...).swipeTrack(...)
) {
// 选中状态下的背景
drawRoundRect(
//这种的不再使用:Color(ArgbEvaluator().evaluate(t, AndroidColor.RED, AndroidColor.BLUE) as Int)
color = checkedTrackLerpColor,
cornerRadius = CornerRadius(x = height.toPx(), y = height.toPx()),
)
// 未选中状态下的背景,随着滑动或者点击切换了状态,进行缩放
scale(
scaleX = unCheckedTrackScale.value,
scaleY = unCheckedTrackScale.value,
pivot = Offset(size.width * 1.0F / 2F + startAnchor, size.height * 1.0F / 2F)
) {
drawRoundRect(
color = uncheckedTrackColor,
cornerRadius = CornerRadius(x = height.toPx(), y = height.toPx()),
)
}
// Thumb
drawCircle(
color = Color.White,
radius = thumbRadius.toPx(),
center = Offset(swipeableState.offset.value, size.height / 2)
)
}
经过上面的漫长分析和实现,最终效果如下:
源码地址: ComposeIOSSwitchButton
作者:Halifax
链接:https://juejin.cn/post/7134702107742961701
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
Flutter 的 build 系统(一)
前言
对于Flutter开发者来说,build_runner 可以说并不是一个陌生的东西,很多package中就要求调用build_runner 来自动生成处理代码,比如说json_serializable;
但正如其描述中所述的那样,其是通过 Dart Build System来实现的,build_runner 和其又是一个什么关系,接下来就来学习一下dart的build系统
dart 的 build 系统
组成
dart 的 build系统,由 build_config、 build_modules、build_resolvers、 build_runner、 build_test、 build_web_compilers 共同组合、完成了dart 的 build 系统;
- build_config 就是解析那个build.yaml文件,用来配置build_runner,没什么好说的,具体的功能后面再细说;
- build_modules 好像是解析module级别信息的一个库
- build_resolvers 从自述文件中分析,好像是一个给build_runner 每步提供所需信息的解析器?
- build_runner 整个build系统的核心部分,其他部分都是为了拓展和实现此功能而存在的;
- build_test 字面意思,一个测试库;
- build_web_compilers 用于web端的build系统;
作用
Flutter的build系统其实就是生成代码,对标的应该是JAVA的APT这块的东西;
另外,对于 dart 的 build 系统,官方是有这么一段介绍:
Although the Dart build system is a good alternative to reflection (which has performance issues) and macros (which Dart’s compilers don’t support), it can do more than just read and write Dart code. For example, the sass_builder package implements a builder that generates
.css
files from.scss
and.sass
files.
也就是说dart build理论上是可以来做很多人心心念念的反射的;
基本使用
如果仅仅是使用方面来说,build_runner 的使用非常简单;比如说我们最常用的一条命令就是:
flutter pub run build_runner build
也可以配置build.yaml来修改配置信息,生成符合需要的代码;
不过在输入上面那句build_runner build之后发生了什么,像build_config之类的在这个过程中各自起了什么作用,这就需要追踪一下;
build_runner 都干了什么
根据日志信息,build_runner 的流程基本遵循这样一个套路:
- 生成和预编译build脚本
- 处理输入环境和资源
- 根据前面的脚本和输入信息,开始正式执行builder生成代码;
- 缓存信息,用于下一回生成代码的时候增量判断使用;
接下来就看下这些编译脚本、输入环境、资源等不知所云的东西,到底是什么;
生成和预编译build脚本
生成部分:
首先来到build_runner的main函数部分,前面一大片对参数检测的拦截判断,真正执行命令的地方放在了最后:
在这个方法中最先做的事就是生成build脚本
其内容也很简单,说白了就是输出一个文件而已:
至于这个文件内容是什么,有什么用,先放到后面再说;现在先关注于整体流程;
那么现在可以得知,这步会在scriptLocaton这个路径上生成一个build脚本;而这个路径也不难得到:
其实就是 .dart_tool/build/entrypoint/build.dart 这个文件;
预编译部分:
在上面贴的generateAndRun方法中,生成文件之后就会执行一个 _createKernelIfNeeded
方法,其作用也正如其名,检测是否需要就创建内核文件;
而这个内核文件,也就是后缀为build.dart.dill 文件
同时,在这里也提到了一个新的概念:assetGraph
,不过这些也是后面再细看的东西;
处理输入环境和资源
在编译完build脚本生成内核后,下面就是执行这个内核文件;在这里新开了一个isolate去执行这个文件:
接下来就该看下这个内核文件到底是什么……但是呢,内核文件这东西,本来就不是给人看的………………所以呢,可以从另一方面考虑下,比如说,既然内核文件看不了,那我就看内核文件的从哪编译来的,反正逻辑上也是大差不差,完全可以参考;
正好内核文件的来源,也就是那个build脚本,其位置在上面也提到过了;在我测试代码中,它最后是这样的:
其中的这个_i10,正是build_runner……看来兜兜转转又回来了?
应该说回来了,但没完全回来,上面提到的build_runner是bin目录下的;这次的build_runner是lib目录下的,入口还是不一样的;
在这里,build_runner build中的build这个参数才真正识别并开始执行;前面都是前戏;而执行这个build命令的是一个名为BuildCommandRunner
的类,其内部内置了包括build在内的诸多函数命令:
由于测试的指令参数为build,所以命中的commend为 BuildCommand
;而 BuildCommand 所做的事也基本集中在 src/generate/build.dart 这个文件中的build方法中了;自此开始真正去执行build_runner对应Builder中要求做的事;
其build方法所做的事还是比较容易看懂的:
- 配置环境(包括输入输出配置)
- 配置通用选项(build时候的配置项目)
- 调用BuildRunner.create创建Builder和生成所需数据,最后调用run执行;
而这部分所说的处理输入环境和资源就在 BuildRunner.create
这部分中;其会调用 BuildDefinition.prepareWorkspace
方法;
而在这里就出现了上面提到的assetGraph
,这里就是其创建和使用的地方:
所以,最终总结一下,处理输入环境和资源 这个环节所做的事就是根据配置生成输入输出、build过程中所需的各种参数,提供assetGraph这个东西;
具体这些配置入口在哪,从何而来,assetGraph又是什么东西,有什么作用,后面再看;
正式执行builder生成代码
这部分就是刚才提到的调用run方法的地方;
它的run方法咋看好像也不难懂的样子,主要是各种新名词有点多:
不过现在只跟随build流程来说的话,核心应该事其中的_safeBuild
方法:
其所做的事,除了各种心跳log之外,应该就是更新assetGraph;执行_runPhases
;另外毕竟事safeBuild嘛,所以新开了一个zone来处理;
_runPhases
所做的事就是真正去执行build所做的事,生成代码之类的;比如说json_serializable中的build,就会走_runBuilder
部分并最终调用runBuilder
中的builder.build
,也就是自定义Builder中需要自己实现的部分;
对了,关于像json_serializable的自定义Builder从何而来的问题,答案是一开始就已经集成进来了,在builder.dart中已经出现了其身影:
不过为什么build.dart 能得知具体有哪些builder?比如说json_serializable中的builder,是怎么加入到build.dart中的,那也是后面要看的东西;
缓存信息
再次回到 _safeBuild
这块,缓存信息的部分紧贴着run部分:
好像就写了一下文件,没了?
结语
这篇大体粗略的过了一下build这个命令都干了什么;不过像生成的文件内部结构、作用;配置信息来源,如何解析之类的问题还未解决;在后面会依次看看;
最后尝试实现一份自己的自定义Builder;
作者:lwlizhe
链接:https://juejin.cn/post/7133488621180420126
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
【Flutter】实现自定义TabBar主题色配置
需求背景
首页开发需求要求实现每个频道具备不同主题色风格,因此需要实现TabBar
每个Tab
具备自己主题色。Flutter
官方提供TabBar
组件只支持设置选中和非选中条件标签颜色并不支持配置不同更多不同配置色,TabBar
组件配置项为labelColor
和unselectedLabelColor
两者。因此若需要自定义实现支持配置主题色TabBar
组件。
改造实现详解
TabBar切换字体抖动问题解决
这在此之前文章中有提到过解决方案,主要实现逻辑是将原先切换动画替换为缩放实现,规避了动画实现出现的抖动问题。
TabBar切换字体主题色实现
TabBar
入参提供每个Tab的颜色配置: final List labelColors;- 找到
TabBar
切换逻辑代码【_TabBarState】:【_buildStyledTab】
_buildStyledTab中TabStyle
方法负责构建每个Tab
样式,调整该方法增加构建当前TabStyle
的Position
和currentPosition
,分别为对应Tab
的样式和当前选中Tab
的样式
Widget _buildStyledTab(Widget child,int position,int currentPosition, bool selected, Animation<double> animation,TabController controller) {
Color labelColor;
Color unselectedLabelColor;
labelColor = widget.labelColors[position];
unselectedLabelColor = widget.labelColors[currentPosition];
return _TabStyle(
animation: animation,
selected: selected,
labelColors: widget.labelColors,
labelColor: labelColor,
unselectedLabelColor: unselectedLabelColor,
labelStyle: widget.labelStyle,
unselectedLabelStyle: widget.unselectedLabelStyle,
tabController:controller,
child: child,
);
}
- 调整_TabStyle方法内部逻辑
增加以下代码逻辑通过TabController
获取当前选中Tab
定位并且增加渐变透明度调整
// 判断是否是临近的下一个Tab
bool isNext = false;
// 透明度不好计算呀
double opacity = 0.5;
// 当前选中的Tab
int selectedValue = tabController.index;
selectedColor = labelColors[selectedValue];
// 当前偏移方向
if (tabController.offset > 0) {
unselectedColor = labelColors[selectedValue + 1];
isNext = false;
} else if (tabController.offset < 0) {
isNext = true;
unselectedColor = labelColors[selectedValue - 1];
} else {
unselectedColor = selectedColor;
}
if (unselectedColor != Color(0xFF333333)) {
opacity = 0.9;
}
final Color color = selected
? Color.lerp(selectedColor, unselectedColor.withOpacity(opacity),
colorAnimation.value)
: unBuild
? Color.lerp(selectedColor.withOpacity(opacity),
unselectedColor.withOpacity(opacity), colorAnimation.value)
: Color.lerp(
selectedColor.withOpacity(opacity),
unselectedColor.withOpacity(isNext ? 1 : opacity),
colorAnimation.value);
- 在
CustomPaint
组件同样也需要增加选中色值设置
Color labelColor;
Color unselectedLabelColor;
labelColor = widget.labelColors[_currentIndex];
unselectedLabelColor = widget.labelColors[_currentIndex];
final Animation<double> animation = _ChangeAnimation(_controller);
Widget magicTabBar = CustomPaint(
painter: _indicatorPainter,
child: _TabStyle(
animation: animation,
selected: false,
unBuild: true,
labelColor: labelColor,
unselectedLabelColor: unselectedLabelColor,
labelColors: widget.labelColors,
labelStyle: widget.labelStyle,
unselectedLabelStyle: widget.unselectedLabelStyle,
tabController: widget.controller,
child: _TabLabelBar(
onPerformLayout: _saveTabOffsets,
children: wrappedTabs,
),
),
);
TabBar指示器自定义
官方提供TabBar
的选中指示器长度是跟随Tab
宽度不能做到固定宽度,且当改造TabBar
主题色之后也期望指示器支持跟随主题色变化。
- 自定义指示器继承
Decoration
增加三个入参TabController
、List<Color>
、width
。 - _UnderlinePainter增加当前选中
Tab
逻辑来确定主题色选择。
double page = 0;
int realPage = 0;
page = pageController.index + pageController.offset ?? 0;
realPage = pageController.index + pageController.offset?.floor() ?? 0;
double opacity = 1 - (page - realPage).abs();
Color thisColor = labelColors[realPage];
thisColor = thisColor;
Color nextColor = labelColors[
realPage + 1 < labelColors.length ? realPage + 1 : realPage];
nextColor = nextColor;
- _indicatorRectFor方法修改指示器宽度方法,计算出Tab的中心位置再根据设置宽度绘制最终偏移量位置信息。
final Rect indicator = insets.resolve(textDirection).deflateRect(rect);
double midValue = (indicator.right - indicator.left) / 2 + indicator.left;
return Rect.fromLTWH(
midValue - width / 2,
indicator.bottom - borderSide.width,
width,
borderSide.width,
);
最终效果
作者:JulyYu
链接:https://juejin.cn/post/7133880100914724871
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
Flutter StatefulBuilder实现局部刷新
前言
flutter项目中,在页面数据较多的情况下使用全量刷新对性能消耗较大且容易出现短暂白屏的现象,出于性能和用户体验方面的考虑我们经常会使用局部刷新代替全量刷新进行页面更新操作。
GlobalKey
、ValueNotifier
和StreamBuilder
等技术方案都可以实现Flutter页面的局部刷新,本文主要记录的是通过StatefulBuilder
组件来实现局部刷新的方法。
页面的全量刷新
在StatefulWidget
内直接调用setState
方法更新数据时,会导致页面重新执行build
方法,使得页面被全量刷新。
我们可以通过以下案例了解页面的刷新情况:
int a = 0;
int b = 0;
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// 点击按钮,数据‘a’加1,并刷新页面
ElevatedButton(
onPressed: () {
a++;
setState(() {});
},
child: Text('a : $a'),
),
// 点击按钮,数据‘b’加1,并刷新页面
ElevatedButton(
onPressed: () {
b++;
setState(() {});
},
child: Text('b : $b'),
),
],
),
),
);
}
代码运行效果如图:
当我们点击第一个ElevatedButton
组件时,会执行a++
和setState(() {})
语句。通过系统的Flutter Performance工具我们可以捕获到组件刷新的情况,当执行到setState(() {})
时,页面不只是刷新a
数据所在的ElevatedButton
组件,而是重新构建了页面,这会造成额外的性能消耗。
出于性能的考虑,我们更希望当点击第一个ElevatedButton
组件时,系统只对a
数据进行更新,b
作为局外人不参与此次活动。我们可以通过StatefulBuilder
组件来实现这个功能。
StatefulBuilder简介
StatefulBuilder
组件包含了两个参数,其中builder
参数为必传,不能为空:
const StatefulBuilder({
Key? key,
required this.builder,
}) : assert(builder != null),
super(key: key);
builder
包含了两个参数,一个页面的context,另一个是用于状态改变时触发重建的方法:
typedef StatefulWidgetBuilder = Widget Function(BuildContext context, StateSetter setState);
final StatefulWidgetBuilder builder;
StatefulBuilder的实际应用
StatefulBuilder
组件在实际应用中主要分成以下操作:
1、定义一个
StateSetter
类型的方法;
2、将需要局部刷新数据的组件嵌套在
StatefulBuilder
组件内;
3、调用第1步定义的
StateSetter
类型方法对StatefulBuilder
内部进行刷新;
int a = 0;
int b = 0;
// 1、定义一个叫做“aState”的StateSetter类型方法;
StateSetter? aState;
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
// 2、将第一个“ElevatedButton”组件嵌套在“StatefulBuilder”组件内;
StatefulBuilder(
builder: (BuildContext context, StateSetter setState) {
aState = setState;
return ElevatedButton(
onPressed: () {
a++;
// 3、调用“aState”方法对“StatefulBuilder”内部进行刷新;
aState(() {});
},
child: Text('a : $a'),
);
},
),
ElevatedButton(
onPressed: () {
b++;
setState(() {});
},
child: Text('b : $b'),
),
],
),
),
);
}
重新运行后点击第一个按钮对a
进行累加时,通过Flutter Performance工具我们可以了解到,只有StatefulBuilder
组件及其包含的组件被重新构建,实现了局部刷新的功能,有效的提高了页面的性能;
总结
StatefulWidget
内更新一个属性会导致整个树重新构建,为防止这种不必要的性能消耗,可以通过StatefulBuilder
组件进行局部刷新,有效的提高性能。
作者:zheng1998
链接:https://juejin.cn/post/7134335466614030344
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
翻车了,字节一道 Fragment面试题
一道面试题
前段时间面试,面试官先问了一下fragment的生命周期,我一看这简单呀,直接按照下图回答
面试官点点头,然后问,如果Activity里面有一个fragment,那么启动他们时,他们的生命周期加载顺序是什么?
所以今天,我们好好了解了解这个用得非常多,但是对底层不是很理解的fragment吧
首先回答面试官的问题,Fragment 的 start与activity 的start 的调用时机
调用顺序:
D/MainActivity: MainActivity:
D/MainActivity: onCreate: start
D/MainFragment: onAttach:
D/MainFragment: onCreate:
D/MainActivity: onCreate: end
D/MainFragment: onCreateView:
D/MainFragment: onViewCreated:
D/MainFragment: onActivityCreated:
D/MainFragment: onViewStateRestored:
D/MainFragment: onCreateAnimation:
D/MainFragment: onCreateAnimator:
D/MainFragment: onStart:
D/MainActivity: onStart:
D/MainActivity: onResume:
D/MainFragment: onResume:
可以看到Activity 在oncreate开始时,Fragment紧接着attach,create,然后activity执行完毕onCreate方法
此后都是Fragment在执行,直到onStart方法结束
然后轮到Activity,执行onStart onResume
也就是,Activity 创建的时候,Fragment一同创建,同时Fragment优先在后台先展示好,最后Activity带着Fragment一起展示到前台。
是什么?
Fragment中文翻译为”碎片“,在手机中,每一个Activity作为一个页面,有时候太大了,尤其是在平板的横屏下,我们希望左半边是一根独立模块,右半边是一个独立模块,比如一个新闻app,左边是标题栏,右边是显示内容
此时就非常适合Fragment
Fragment是内嵌入Activity中的,可以在onCreateView中加载自定义的布局,使用LayoutInflater,然后Activity持有FragmentManager对Fragment进行控制,
下图是他的代码框架
我们的Activity一般是用AppCompatActivity,而AppCompatActivity继承了FragmentActivity
public class AppCompatActivity extends FragmentActivity implements AppCompatCallback,
TaskStackBuilder.SupportParentable, ActionBarDrawerToggle.DelegateProvider {
也就是说Activity之所支持fragment,是因为有FragmentActivity,他内部有一个FragmentController,这个controller持有一个FragmentManager,真正做事的就是这个FragmentManager的实现类FragmentManagerImpl
整体架构
回到我们刚才的面试题,关于生命周期绝对是重中之重,但是实际上,生命周期本质只是被其他地方的方法被动调用而已,关键是Fragment自己的状态变化了,才会回调生命周期方法,所以我们来看看fragment的状态转移
fragment有七个状态
static final int INVALID_STATE = -1; // 为空时无效
static final int INITIALIZING = 0; // 未创建
static final int CREATED = 1; // 已创建,位于后台
static final int ACTIVITY_CREATED = 2; // Activity已经创建,Fragment位于后台
static final int STOPPED = 3; // 创建完成,没有开始
static final int STARTED = 4; // 开始运行,但是位于后台
static final int RESUMED = 5; // 显示到前台
在这里有一个有意思的地方,STOPPED,我本来以为是停止阶段,但是在源码中写为”
Fully created, not started.“,所以,其实Fragment的状态是对称的。RESUME状态反而是最后一个状态
调用过程如下
Fragment的状态转移过程主要受到宿主,事务的影响,宿主一般就是Activity,在我们刚刚的题目中,看到了Activity与Fragment的生命周期交替执行,本质上就是,Activity执行完后通知了Fragment进行状态转移,而Fragment执行了状态转移后对应的回调了生命周期方法
下图可以更加清晰
宿主改变Fragment状态
那么我们不禁要问,Activity如何改变Fragment的状态?
我们知道Activity继承于FragmentActivity,最终是通过持有的FragmentManager来控制Fragment,我们去看看
FragmentActivity
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
mFragments.attachHost(null /*parent*/);
super.onCreate(savedInstanceState);
...
mFragments.dispatchCreate();
}
可以看到,onCreate方法中执行了mFragments.dispatchCreate();
,看起来像是通知Fragment的onCreate执行,这也印证了我们开始时的周期回调顺序
D/MainActivity: MainActivity:
D/MainActivity: onCreate: start // 进入onCreate
D/MainFragment: onAttach: // 执行mFragments.dispatchCreate();
D/MainFragment: onCreate:
D/MainActivity: onCreate: end // 退出onCreate
类似的FragmentActivity在每一个生命周期方法中都做了相同的事情
@Override
protected void onDestroy() {
super.onDestroy();
if (mViewModelStore != null && !isChangingConfigurations()) {
mViewModelStore.clear();
}
mFragments.dispatchDestroy();
}
我们进入dispatchCreate看看,
Runnable mExecCommit = new Runnable() {
@Override
public void run() {
execPendingActions();
}
//内部修改了两个状态
public void dispatchCreate() {
mStateSaved = false;
mStopped = false;
dispatchStateChange(Fragment.CREATED);
private void dispatchStateChange(int nextState) {
try {
mExecutingActions = true;
moveToState(nextState, false);// 转移到nextState
} finally {
mExecutingActions = false;
}
execPendingActions();
}
//一路下来会执行到
void moveToState(Fragment f, int newState, int transit, int transitionStyle,
boolean keepActive) {
// Fragments that are not currently added will sit in the onCreate() state.
if ((!f.mAdded || f.mDetached) && newState > Fragment.CREATED) {
newState = Fragment.CREATED;
}
if (f.mRemoving && newState > f.mState) {
if (f.mState == Fragment.INITIALIZING && f.isInBackStack()) {
// Allow the fragment to be created so that it can be saved later.
newState = Fragment.CREATED;
} else {
// While removing a fragment, we can't change it to a higher state.
newState = f.mState;
}
}
...
}
可以看到上面的代码,最终执行到 moveToState,通过判断Fragment当前的状态,同时newState > f.mState,避免状态回退,然后进行状态转移
状态转移完成后就会触发对应的生命周期回调方法
事务管理
如果Fragment只能随着Activity的生命周期变化而变化,那就太不灵活了,所以Android给我们提供了一个独立的操作方案,事务
同样由FragManager管理,具体由FragmentTransaction执行,主要是添加删除替换Fragment等,执行操作后,需要提交来保证生效
FragmentManager fragmentManager = ...
FragmentTransaction transaction = fragmentManager.beginTransaction();
transaction.setReorderingAllowed(true);
transaction.replace(R.id.fragment_container, ExampleFragment.class, null); // 替换Fragment
transaction.commit();// 这里的commit是提交的一种方法
Android给我们的几种提交方式
FragmentTransaction是个挂名抽象类,真正的实现在BackStackState回退栈中,我们看下commit
@Override
public int commit() {
return commitInternal(false);
}
int commitInternal(boolean allowStateLoss) {
if (mCommitted) throw new IllegalStateException("commit already called");
...
mCommitted = true;
if (mAddToBackStack) {
mIndex = mManager.allocBackStackIndex(this);//1
} else {
mIndex = -1;
}
// 入队操作
mManager.enqueueAction(this, allowStateLoss);//2
return mIndex;
}
可以看到,commit的本质就是将事务提交到队列中,这里出现了两个数组,注释1处
ArrayList<BackStackRecord> mBackStackIndices;
ArrayList<Integer> mAvailBackStackIndices;
public int allocBackStackIndex(BackStackRecord bse) {
synchronized (this) {
if (mAvailBackStackIndices == null || mAvailBackStackIndices.size() <= 0) {
if (mBackStackIndices == null) {
mBackStackIndices = new ArrayList<BackStackRecord>();
}
int index = mBackStackIndices.size();
mBackStackIndices.add(bse);
return ind
} else {
int index = mAvailBackStackIndices.remove(mAvailBackStackIndices.size()-1);
mBackStackIndices.set(index, bse);
return index;
}
}
}
mBackStackIndices数组,每个元素是一个回退栈,用来记录索引。比如说,当有五个BackStackState时,移除掉1,3两个,就是在mBackStackIndices将对应元素置为null,然后mAvailBackStackIndices会添加这两个回退栈,记录被移除的回退栈
当下次commit时,就判定mAvailBackStackIndices中的索引,对应的BackStackState一定是null,直接写到这个索引即可
而一组操作都commit到同一个队列里面,所以要么全部完成,要么全部不做,可以保证原子性
注释二处是一个入队操作
public void enqueueAction(OpGenerator action, boolean allowStateLoss
synchronized (this) {
...
mPendingActions.add(action);
scheduleCommit(); // 真正的提交
}
}
public void scheduleCommit() {
synchronized (this) {
boolean postponeReady =
mPostponedTransactions != null && !mPostponedTransactions.isEmpty();
boolean pendingReady = mPendingActions != null && mPendingActions.size() == 1;
if (postponeReady || pendingReady) {
mHost.getHandler().removeCallbacks(mExecCommit);
mHost.getHandler().post(mExecCommit); // 发送请求
}
}
这里最后 mHost.getHandler()是拿到了宿主Activity的handler,使得可以在主线程执行,mExecCommit本身是一个线程
我们继续看下这个mExecCommit
Runnable mExecCommit = new Runnable() {
@Override
public void run() {
execPendingActions();
}
};
public boolean execPendingActions() {
ensureExecReady(true);
...
doPendingDeferredStart();
burpActive();
return didSomething;
}
void doPendingDeferredStart() {
if (mHavePendingDeferredStart) {
mHavePendingDeferredStart = false;
startPendingDeferredFragments();
}
}
void startPendingDeferredFragments() {
if (mActive == null) return;
for (int i=0; i<mActive.size(); i++) {
Fragment f = mActive.valueAt(i);
if (f != null) {
performPendingDeferredStart(f);
}
}
}
public void performPendingDeferredStart(Fragment f) {
if (f.mDeferStart) {
f.mDeferStart = false;
moveToState(f, mCurState, 0, 0, false); // 最终到了MoveToState
}
}
还记得我们在宿主改变Fragment状态,里面的最终路径吗?是的,就是这个moveToState,无论是宿主改变Fragment状态,还是事务来改变,最终都会执行到moveToState,然后call对应的生命周期方法来执行,这也是为什么我们要将状态转移作为学习主线,而不是生命周期。
除了commit,可以看到FragmentTransaction有众多对Fragment进行增删改查的方法
都是由BackStackState来执行,最后都会执行到moveToState中
具体是如何改变的,有很多细节,这里不再赘述。
小结
本节我们讲了Fragment在android系统中的状态,那就是通过自身状态转移来回调对应生命周期方法,这块是自动实现的,我们开发时不太需要关注状态转移,只要知道什么时候执行某个生命周期方法,然后再在对应方法中写业务逻辑即可
有两个方法可以让Fragment状态转移,
- 宿主Activity生命周期内自动修改Fragment状态,回调Fragment的生命周期方法
- 通过手动提交事务,修改Fragment状态,回调Fragment的生命周期方法
作者:小松漫步
链接:https://juejin.cn/post/7021398731056480269
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
Android登录拦截的场景-面向切面基于AOP实现
前言
场景如下:用户第一次下载App,点击进入首页列表,点击个人页面,需要校验登录,然后跳转到登录页面,注册/登录完成跳转到个人页面。
非常常见的场景,正常我们开发就只能判断是否已经登录,如果未登录就跳转到登录,然后登录完成之后怎么继续执行?如何封装?有哪些方式?其实很多人并不清楚。
这里做一个系列总结一下,看看公共有多少种方式实现,你们使用的是哪一种方案,或者说你们觉得哪一种方案最好用。
这一次分享的是全网最多的方案 ,面向切面 AOP 的方式。你去某度一搜,Android拦截登录
最多的结果就是AOP实现登录拦截的功能,既然大家都推荐,我们就来看看它到底如何?
一、了解面向切面AOP
我们学习Java的开始,我们一直就知道 OOP 面向对象,其实 AOP 面向切面,是对OOP的一个补充,AOP采取横向收取机制,取代了传统纵向继承体系重复性代码,把某一类问题集中在一个地方进行处理,比如处理程序中的点击事件、打印日志等。
AOP是编程思想就是把业务逻辑和横切问题进行分离,从而达到解耦的目的,提高代码的重用性和开发效率。OOP的精髓是把功能或问题模块化,每个模块处理自己的家务事。但在现实世界中,并不是所有功能都能完美得划分到模块中。AOP的目标是把这些功能集中起来,放到一个统一的地方来控制和管理。
我记得我最开始接触 AOP 还是在JavaEE的框架SSH的学习中,AspectJ框架,开始流行于后端,现在在Android开发的应用中也越来越广泛了,Android中使用AspectJ框架的应用也有很多,比如点击事件防抖,埋点,权限申请等等不一而足,这里不展开说明,毕竟我们这一期不是专门讲AspectJ的应用的。
简单的说一下AOP的重点概念(摘抄):
前置通知(Before):在目标方法被调用之前调用通知功能。
后置通知(After):在目标方法完成之后调用通知,此时不会关心方法的输出是什么。
返回通知(After-returning):在目标方法成功执行之后调用通知。
异常通知(After-throwing):在目标方法抛出异常后调用通知。
环绕通知(Around):通知包裹了被通知的方法,在被通知的方法调用之前和调用之后执行自定义的行为。
连接点:是在应用执行过程中能够插入切面的一个点。
切点: 切点定义了切面在何处要织入的一个或者多个连接点。
切面:是通知和切点的结合。通知和切点共同定义了切面的全部内容。
引入:引入允许我们向现有类添加新方法或属性。
织入:是把切面应用到目标对象,并创建新的代理对象的过程。切面在指定的连接点被织入到目标对象中。在目标对象的生命周期中有多个点可以进行织入:
编译期: 在目标类编译时,切面被织入。这种方式需要特殊的编译器。AspectJ的织入编译器就是以这种方式织入切面的。
类加载期:切面在目标加载到JVM时被织入。这种方式需要特殊的类加载器(class loader)它可以在目标类被引入应用之前增强该目标类的字节码。
运行期: 切面在应用运行到某个时刻时被织入。一般情况下,在织入切面时,AOP容器会为目标对象动态地创建一个代理对象。SpringAOP就是以这种方式织入切面的。
简单理解就是把一个方法拿出来,在这个方法执行前,执行后,做一些特别的操作。关于AOP的基本使用推荐大家看看大佬的教程:
不多BB,我们直接看看Android中如何使用AspectJ实现AOP逻辑,实现拦截登录的功能。
二、集成AOP框架
Java项目集成
buildscript {
repositories {
mavenCentral()
}
dependencies {
classpath 'org.aspectj:aspectjtools:1.8.9'
classpath 'org.aspectj:aspectjweaver:1.8.9'
}
}
组件build.gradle
dependencies {
implementation 'org.aspectj:aspectjrt:1.9.6'
}
import org.aspectj.bridge.IMessage
import org.aspectj.bridge.MessageHandler
import org.aspectj.tools.ajc.Main
// 获取log打印工具和构建配置
final def log = project.logger
final def variants = project.android.applicationVariants
variants.all { variant ->
if (!variant.buildType.isDebuggable()) {
// 判断是否debug,如果打release把return去掉就可以
log.debug("Skipping non-debuggable build type '${variant.buildType.name}'.")
// return;
}
// 使aspectj配置生效
JavaCompile javaCompile = variant.javaCompile
javaCompile.doLast {
String[] args = ["-showWeaveInfo",
"-1.8",
"-inpath", javaCompile.destinationDir.toString(),
"-aspectpath", javaCompile.classpath.asPath,
"-d", javaCompile.destinationDir.toString(),
"-classpath", javaCompile.classpath.asPath,
"-bootclasspath", project.android.bootClasspath.join(File.pathSeparator)]
log.debug "ajc args: " + Arrays.toString(args)
MessageHandler handler = new MessageHandler(true);
new Main().run(args, handler);
//在编译时打印信息如警告、error等等
for (IMessage message : handler.getMessages(null, true)) {
switch (message.getKind()) {
case IMessage.ABORT:
case IMessage.ERROR:
case IMessage.FAIL:
log.error message.message, message.thrown
break;
case IMessage.WARNING:
log.warn message.message, message.thrown
break;
case IMessage.INFO:
log.info message.message, message.thrown
break;
case IMessage.DEBUG:
log.debug message.message, message.thrown
break;
}
}
}
}
Kotlin项目集成
dependencies {
classpath 'com.android.tools.build:gradle:3.6.1'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath 'com.hujiang.aspectjx:gradle-android-plugin-aspectjx:2.0.10'
项目build.gradle
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt'
apply plugin: 'android-aspectjx'
android {
...
// AOP 配置
aspectjx {
// 排除一些第三方库的包名(Gson、 LeakCanary 和 AOP 有冲突)
exclude 'androidx', 'com.google', 'com.squareup', 'com.alipay', 'com.taobao',
'org.apache',
'org.jetbrains.kotlin',
"module-info", 'versions.9'
}
}
ependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation 'org.aspectj:aspectjrt:1.9.5'
}
集成AOP踩坑:
zip file is empty
和第三方包有冲突,比如Gson,OkHttp等,需要配置排除一下第三方包,
gradle版本兼容问题
AGP版本4.0以上不支持 推荐使用3.6.1
kotlin兼容问题 :
基本都是推荐使用 com.hujiang.aspectjx
编译版本兼容问题:
4.0以上使用KT编译版本为Java11需要改为Java8
组件化兼容问题:
如果在library的moudle中自定义的注解, 想要通过AspectJ来拦截织入, 那么这个@Aspect类必须和自定义的注解在同一moudle中, 否则是没有效果的
等等...
难点就在集成,如何在指定版本的Gradle,Kotlin项目中集成成功。只要集成成功了,使用到是简单了。
三、定义注解实现功能
定义标记的注解
//不需要回调的处理
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Login {
}
定义处理类
@Aspect
public class LoginAspect {
@Pointcut("@annotation(com.guadou.kt_demo.demo.demo3_bottomtabbar_fragment.aop.Login)")
public void Login() {
}
//不带回调的注解处理
@Around("Login()")
public void loginJoinPoint(ProceedingJoinPoint joinPoint) throws Throwable {
YYLogUtils.w("走进AOP方法-Login()");
Signature signature = joinPoint.getSignature();
if (!(signature instanceof MethodSignature)){
throw new RuntimeException("该注解只能用于方法上");
}
Login login = ((MethodSignature) signature).getMethod().getAnnotation(Login.class);
if (login == null) return;
//判断当前是否已经登录
if (LoginManager.isLogin()) {
joinPoint.proceed();
} else {
//如果未登录,去登录页面
LoginManager.gotoLoginPage();
}
}
object LoginManager {
@JvmStatic
fun isLogin(): Boolean {
val token = SP().getString(Constants.KEY_TOKEN, "")
YYLogUtils.w("LoginManager-token:$token")
val checkEmpty = token.checkEmpty()
return !checkEmpty
}
@JvmStatic
fun gotoLoginPage() {
commContext().gotoActivity<LoginDemoActivity>()
}
}
其实逻辑很简单,就是判断是否登录,看是放行还是跳转到登录页面
使用的逻辑也是很简单,把需要处理的逻辑使用方法抽取,并标记注解即可
override fun init() {
mBtnCleanToken.click {
SP().remove(Constants.KEY_TOKEN)
toast("清除成功")
}
mBtnProfile.click {
//不带回调的登录方式
gotoProfilePage2()
}
}
@Login
private fun gotoProfilePage2() {
gotoActivity<ProfileDemoActivity>()
}
效果:
这..这和我使用Token自己手动判断有什么区别,完成登录之后还得我再点一次按钮,当然了这只是登录拦截,我想要的是登录成功之后继续之前的操作,怎么办?
其实使用AOP的方式的话,我们可以使用消息通知的方式,比如LiveBus FlowBus之类的间接实现这个效果。
我们先单独的定义一个注解
//需要回调的处理用来触发用户登录成功后的后续操作
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface LoginCallback {
}
修改定义的切面类
@Aspect
public class LoginAspect {
@Pointcut("@annotation(com.guadou.kt_demo.demo.demo3_bottomtabbar_fragment.aop.Login)")
public void Login() {
}
@Pointcut("@annotation(com.guadou.kt_demo.demo.demo3_bottomtabbar_fragment.aop.LoginCallback)")
public void LoginCallback() {
}
//带回调的注解处理
@Around("LoginCallback()")
public void loginCallbackJoinPoint(ProceedingJoinPoint joinPoint) throws Throwable {
YYLogUtils.w("走进AOP方法-LoginCallback()");
Signature signature = joinPoint.getSignature();
if (!(signature instanceof MethodSignature)){
throw new RuntimeException("该注解只能用于方法上");
}
LoginCallback loginCallback = ((MethodSignature) signature).getMethod().getAnnotation(LoginCallback.class);
if (loginCallback == null) return;
//判断当前是否已经登录
if (LoginManager.isLogin()) {
joinPoint.proceed();
} else {
LifecycleOwner lifecycleOwner = (LifecycleOwner) joinPoint.getTarget();
LiveEventBus.get("login").observe(lifecycleOwner, new Observer<Object>() {
@Override
public void onChanged(Object integer) {
try {
joinPoint.proceed();
LiveEventBus.get("login").removeObserver(this);
} catch (Throwable throwable) {
throwable.printStackTrace();
LiveEventBus.get("login").removeObserver(this);
}
}
});
LoginManager.gotoLoginPage();
}
}
//不带回调的注解处理
@Around("Login()")
public void loginJoinPoint(ProceedingJoinPoint joinPoint) throws Throwable {
YYLogUtils.w("走进AOP方法-Login()");
Signature signature = joinPoint.getSignature();
if (!(signature instanceof MethodSignature)){
throw new RuntimeException("该注解只能用于方法上");
}
Login login = ((MethodSignature) signature).getMethod().getAnnotation(Login.class);
if (login == null) return;
//判断当前是否已经登录
if (LoginManager.isLogin()) {
joinPoint.proceed();
} else {
//如果未登录,去登录页面
LoginManager.gotoLoginPage();
}
}
}
在去登录页面之前注册一个LiveEventBus事件,当登录完成之后发出通知,这里就直接放行调用注解的方法。即可完成继续执行的操作。
使用:
override fun init() {
mBtnCleanToken.click {
SP().remove(Constants.KEY_TOKEN)
toast("清除成功")
}
mBtnProfile.click {
//不带回调的登录方式
gotoProfilePage()
}
}
@LoginCallback
private fun gotoProfilePage() {
gotoActivity<ProfileDemoActivity>()
}
效果:
总结
从上面的代码我们就基于AOP思想实现了登录拦截功能,以后我们对于需要用户登录之后才能使用的功能只需要在对应的方法上添加指定的注解即可完成逻辑,彻底摆脱传统耗时耗力的开发方式。
需要注意的是AOP框架虽然使用起来很方便,能帮我们轻松完成函数插桩功能,但是它也有自己的缺点。AspectJ 在实现时会包装自己的一些类,不仅会影响切点方法的性能
,还会导致安装包体积的增大
。最关键的是对Kotlin不友好,对高版本AGP不友好,所以大家在使用时需要仔细权衡是否适合自己的项目。如有需求可以运行源码查看效果。源码在此!
由于篇幅原因,后期会出单独出一些其他方式实现的登录拦截的实现,如果觉得这种方式不喜欢,大家可以对比一下哪一种方式比较你胃口。大家可以点下关注看看最新更新。
题外话:
我发现大家看我的文章都喜欢看一些总结性的,实战性的,直接告诉你怎么用的那种。所以我尽量不涉及到集成原理与基本使用,直接开箱即用。当然关于原理和基本的使用,我也不是什么大佬,我想大家也看不上我讲的。不过我也会给出推荐的文章。
好了,我本人如有讲解不到位或错漏的地方,希望同学们可以指出交流。
如果感觉本文对你有一点点点的启发,还望你能点赞
支持一下,你的支持是我最大的动力。
Ok,这一期就此完结。
作者:newki
链接:https://juejin.cn/post/7132643283083198501
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
Flutter 语法进阶 | 深入理解混入类 mixin
混入类引言
混入类是 Dart
中独有的概念,它是 继承
、实现
之外的另一种 is-a
关系的维护方式。它和接口非常像,一个类支持混入多个类,但在本质上和接口还是有很大区别的。在感觉上来说,从耦合性来看,混入类像是 抽象类
和 接口
的中间地带。下面就来认识一下混入类的 使用与特性
。
1. 混入类的定义与使用
混入类通过 mixin
关键字进行声明,如下的 MoveAble
类,其中可以持有 成员变量
,也可以声明和实现成员方法。对混入类通过 with
关键字进行使用,如下的 Shape
混入了 MoveAble
类。在下面 main
方法测试中可以看出,混入一个类,就可以访问其中的成员属性与方法,这点和 继承
非常像。
void main(){
Shape shape = Shape();
shape.speed = 20;
shape.move();//=====Shape move====
print(shape is MoveAble);// true
}
mixin MoveAble{
double speed = 10;
void move(){
print("=====$runtimeType move====");
}
}
class Shape with MoveAble{
}
一个类可以混入若干个类,通过 ,
号隔开。如下 Shape
混入了 MoveAble
和 PaintAble
,就表示 Shape
对象可以同时拥有这两个类的能力。这个功能和接口有点相似,不过混入类可以进行对方法进行实现,这点要比接口更灵活。有点 随插随用
的感觉,甚至 Shape
类中可以什么都不做,就坐拥 “王权富贵”
。
mixin PaintAble{
void paint(){
print("=====$runtimeType paint====");
}
}
class Shape with MoveAble,PaintAble{
}
值得注意一点的是:混入类支持 抽象方法
,而且同样要求派生类必须实现 抽象方法
。如下 PaintAble
的 tag1
处定义了 init
抽象方法,在 Shape
中必须实现,这一点又和 抽象类
有些相像。所以我说混入类像是 抽象类
和 接口
的中间地带,它不像继承那样单一,也不像接口那么死板。
mixin PaintAble{
late Paint painter;
void paint(){
print("=====$runtimeType paint====");
}
void init();// tag1
}
class Shape with MoveAble,PaintAble{
@override
void init() {
painter = Paint();
}
}
2. 混入类对二义性的解决方式
通过前面可以看出,混入类
可谓 上得厅堂下得厨房
,能打能抗的六边形战士。但,没有任何事物是完美无缺的。仔细想一下,既然语法上支持 多混入
,那解决二义性就是一座不可避免大山。接口
牺牲了 普通成员
和 方法实现
,可谓断尾求生,才解决二义性问题,支持 多实现
。而 混入类
又能写成员变量,又能写成员方法,那它牺牲了什么呢?答案是:
混入类不能拥有【构造方法】
这一点就从本质上限制了 混入类
无法直接创建对象,这也是它和 普通类
最大的差异。从这里可以看出,抽象类
、接口
、混入类
都具有不能直接实例化的特点。本质上是因为这三者都是更高层的抽象,可能存在未实现的功能。那为什么混入类无法构造,就能解决二义性问题呢?下面来分析一下,两个混入类中的同名成员、同名方法,在多混入场景中是如何工作的。如下测试代码所示,A
、B
两个混入类拥有同名的 成员属性
和 成员方法
:
mixin A {
String name = "A";
void log() {
print(name);
}
}
mixin B {
String name = "B";
void log() {
print(name);
}
}
此时,C
依次混入 A
、B
类,然后实例化 C
对象,执行 log
方法,可以看出,打印的是 B
。
class C with A, B {}
void main() {
C c = C();
c.log(); // B
}
如果 C
依次混入 B
、A
类,打印结果是 A
。也就是说对于多混入来说,混入类的顺序是至关重要的,当存在二义性问题时,会 “后来居上”
,访问最后混入类的方法或变量。这点往往会被很多人忽略,或压根不知道。
class C with B, A {}
void main() {
C c = C();
c.log(); // A
}
另外,补充一个小细节,如果 C
类覆写了 log
方法,那么执行时毋庸置疑是走 C#log
。由于混入类支持方法实现,所以派生类中可以通过 super
关键字触发 “基类”
的方法。同样对于二义性的处理也是 “后来居上”
,下面的 super.log()
执行的是 B
类方法。这种特性常用于对有生命周期的类进行拓展的场景,比如 AutomaticKeepAliveClientMixin
。
class C with A, B {
@override
void log() {
super.log();// B
print("C");
}
}
3.混入类间的继承细节
另外,两个混入类间可以通过 on
关键字产生类似于 继承
的关系:如下 MoveAble on Position
之后,MoveAble
类中可以访问 Position
中定义的 vec2
成员变量。
但有一点要特别注意,由于 MoveAble on Position
,当 Shape with MoveAble
时,必须在 MoveAble
之前混入 Position
。这点可能很多人也都不知道。
class Shape with Position,MoveAble,PaintAble{
}
另外,混入类并非仅由mixin
声明,一切满足 没有构造方法
的类都可以作为混入类。比如下面 A
是 普通类
,B
是 接口(抽象)类
,都可以在 with
后作为 混入类被对待
。也就是说,一个类的可以用多重身份,并非是互斥的,它具体是什么身份,要看使用的场景。而使用场景最醒目的标志是 关键字
:
关键字 | 类关系 | 耦合性 |
---|---|---|
extend | 继承 | 高 |
implements | 实现 | 低 |
with | 混入 | 中 |
class A {
String name = "A";
void log() {
print(name);
}
}
abstract class B{
void log();
}
class C with A, B {
@override
void log() {
super.log();// B
print("C");
}
}
4.根据源码理解混入类
混入类在 Flutter
框架层的使用是非常多的,在 《Flutter 渲染机制 - 聚沙成塔》的 十二章 结合源码介绍了混入类的价值。下面来举个混入类的使用场景,会有些难,新手适当理解。比如 AutomaticKeepAliveClientMixin
继承 State
:
mixin AutomaticKeepAliveClientMixin<T extends StatefulWidget> on State<T> {}
所以它可以在 State
的生命周期相关回调方法中做额外的处理,来实现某些特定的功能能。
这样,当在 State
派生类中混入 AutomaticKeepAliveClientMixin
,根据混入类二义性的特点,对于已经覆写的方法,可以通过 super.XXX
访问混入类的功能。对于未覆写的方法,会默认走混入类方法,这样就可以形成一种 "可插拔"
的功能件。
举个更易懂的例子,如下定义一个 LogStateMixin
,对 initState
和 dispose
方法进行覆写并输出日志。这样在一个 State
派生类中混入 LogStateMixin
就可以不动声色地实现生命周期打印功能,不想要就不混入。对于一些逻辑相对独立,或可以进行复用的拓展功能,使用 mixin
是非常方便的。
mixin LogStateMixin<T extends StatefulWidget> on State<T> {
@override
void initState() {
super.initState();
print("====initState====");
}
// 略其他回调...
@override
void dispose() {
super.dispose();
print("====dispose====");
}
}
源码中有大量的混入类应用场景,大家可以自己去发现一下。本文从更深层次,分析了混入类的来龙去脉,它和 继承
、接口
的差异。作为 Dart
中相对独立的概念,对混入类的理解是非常重要的,它相当于在原有的 类间六大关系
中又添加了一种。本文想说的就这么多,谢谢观看~
作者:张风捷特烈
链接:https://juejin.cn/post/7132651702980706312
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
Android 官方项目是怎么做模块化的?快来学习下
概述
模块化是将单一模块代码结构拆分为高内聚内耦合的多模块的一种编码实践。
模块化的好处
模块化有以下好处:
- 可扩展性:在高耦合的单一代码库中,牵一发而动全身。模块化项目当采用关注点分离原则。这会赋予了贡献者更多的自主权,同时也强制执行架构模式。
- 支持并行工作:模块化有助于减少代码冲突,为大型团队中的开发人员提供更高效的并行工作。
- 所有权:一个模块可以有一个专门的 owner,负责维护代码和测试、修复错误和审查更改。
- 封装:独立的代码更容易阅读、理解、测试和维护。
- 减少构建时间:利用 Gradle 的并行和增量构建可以减少构建时间。
- 动态交付:模块化是 Play 功能交付 的一项要求,它允许有条件地交付应用程序的某些功能或按需下载。
- 可重用性:模块化为代码共享和构建多个应用程序、跨不同平台、从同一基础提供了机会。
模块化的误区
模块化也可能会被滥用,需要注意以下问题:
- 太多模块:每个模块都有其成本,如 Gradle 配置的复杂性增加。这可能会导致 Gradle 同步及编译时间的增加,并产生持续的维护成本。此外,与单模块相比,添加更多模块会增加项目 Gradle 设置的复杂性。这可以通过使用约定插件来缓解,将可重用和可组合的构建配置提取到类型安全的 Kotlin 代码中。在 Now in Android 应用程序中,可以在 build-logic文件夹 中找到这些约定插件。
- 没有足够的模块:相反,如果你的模块很少、很大并且紧密耦合,最终会产生另外的大模块。这将失去模块化的一些好处。如果您的模块臃肿且没有单一的、明确定义的职责,您应该考虑将其进一步拆分。
- 太复杂了:模块化并没有灵丹妙药 -- 一种方案解决所有项目的模块化问题。事实上,模块化你的项目并不总是有意义的。这主要取决于代码库的大小和相对复杂性。如果您的项目预计不会超过某个阈值,则可扩展性和构建时间收益将不适用。
模块化策略
需要注意的是没有单一的模块化方案,可以确保其对所有项目都适用。但是,可以遵循一般准则,可以尽可能的享受其好处并规避其缺点。
这里提到的模块,是指 Android 项目中的 module,通常会包含 Gradle 构建脚本、源代码、资源等,模块可以独立构建和测试。如下:
一般来说,模块内的代码应该争取做到低耦合、高内聚。
- 低耦合:模块应尽可能相互独立,以便对一个模块的更改对其他模块的影响为零或最小。他们不应该了解其他模块的内部工作原理。
- 高内聚:一个模块应该包含一组充当系统的代码。它应该有明确的职责并保持在某些领域知识的范围内。例如,Now in Android 项目中的core-network模块负责发出网络请求、处理来自远程数据源的响应以及向其他模块提供数据。
Now in Android 项目中的模块类型
注:模块依赖图(如下)可以在模块化初期用于可视化各个模块之间的依赖关系。
Now in Android 项目中有以下几种类型的模块:
- app 模块: 包含绑定其余代码库的应用程序级和脚手架类,
app
例如和应用程序级受控导航。一个很好的例子是通过导航设置和底部导航栏设置。该模块依赖于所有模块和必需的模块。 feature-
模块: 功能特定的模块,其范围可以处理应用程序中的单一职责。这些模块可以在需要时被任何应用程序重用,包括测试或其他风格的应用程序,同时仍然保持分离和隔离。如果一个类只有一个feature
模块需要,它应该保留在该模块中。如果不是,则应将其提取到适当的core
模块中。一个feature
模块不应依赖于其他功能模块。他们只依赖于core
他们需要的模块。core-
模块:包含辅助代码和特定依赖项的公共库模块,需要在应用程序中的其他模块之间共享。这些模块可以依赖于其他核心模块,但它们不应依赖于功能模块或应用程序模块。- 其他模块 - 例如和模块
sync
、benchmark
、test
以及app-nia-catalog
用于快速显示我们的设计系统的目录应用程序。
项目中的主要模块
基于以上模块化方案,Now in Android 应用程序包含以下模块:
模块名 | 职责 | 关键类及核心示例 |
---|---|---|
app | 将应用程序正常运行所需的所有内容整合在一起。这包括 UI 脚手架和导航。 | NiaApp, MainActivity 应用级控制导航通过 NiaNavHost, NiaTopLevelNavigation |
feature-1, feature-2 ... | 与特定功能或用户相关的功能。通常包含从其他模块读取数据的 UI 组件和 ViewModel。如:feature-author 在 AuthorScreen 上显示有关作者的信息。feature-foryou 它在“For You” tab 页显示用户的新闻提要和首次运行期间的入职。 | AuthorScreen AuthorViewModel |
core-data | 保存多个特性模块中的数据。 | TopicsRepository AuthorsRepository |
core-ui | 不同功能使用的 UI 组件、可组合项和资源,例如图标。 | NiaIcons NewsResourceCardExpanded |
core-common | 模块之间共享的公共类。 | NiaDispatchers Result |
core-network | 发出网络请求并处理对应的结果。 | RetrofitNiANetworkApi |
core-testing | 测试依赖项、存储库和实用程序类。 | NiaTestRunner TestDispatcherRule |
core-datastore | 使用 DataStore 存储持久数据。 | NiaPreferences UserPreferencesSerializer |
core-database | 使用 Room 的本地数据库存储。 | NiADatabase DatabaseMigrations Dao classes |
core-model | 整个应用程序中使用的模型类。 | Author Episode NewsResource |
core-navigation | 导航依赖项和共享导航类。 | NiaNavigationDestination |
Now in Android 的模块化
Now in Android 项目中的模块化方案是在综合考虑项目的 Roadmap、即将开展的工作和新功能的情况下定义的。Now in Android 项目的目标是提供一个接近生产环境的大型 App 的模块化方案,并且要让方案看起来并没有过度模块化,希望是在两者之间找到一种平衡。
这种方法与 Android 社区进行了讨论,并根据他们的反馈进行了改进。这里并没有一个绝对的正确答案。归根结底,模块化 App 有很多方法和方法,没有唯一的灵丹妙药。这就需要在模块化之前考虑清楚目标、要解决的问题已经对后续工作的影响,这些特定的情况会决定模块化的具体方案。可以绘制出模块依赖关系图,以便帮助更好地分析和规划。
这个项目就是一个示例,并不是一个需要固守不可改变固定结构,相反而是可以根据需求就行变化的。根据 Now in Android 这是我们发现最适合我们项目的一般准则,并提供了一个示例,可以在此基础上进一步修改、扩展和构建。如果您的数据层很小,则可以将其保存在单个模块中。但是一旦存储库和数据源的数量开始增长,可能值得考虑将它们拆分为单独的模块。
最后,官方对其他方式的模块化方案也是持开发态度,有更好的方案及建议也可以反馈出来。
总结
以上内容是根据 Modularization learning journey 翻译整理而得。整体上是提供了一个示例,对一些初学者有一个可以参考学习的工程,对社区中模块化开发起到的积极的作用。说实话,这部分技术在国内并不是什么新技术了。
下面讲一个我个人对这个模块化方案的理解,以下是个人观点,请批判性看待。
首先是好的点提供了通用的 Gradle 配置,简化了各个模块的配置步骤,各种方式预计会在之后的一些项目中流行开来。
不足的点就是没有明确模块化的整体策略,是应采取按照功能还是按照特性分,类似讨论还有我们平时的类文件是按照功能来分还是特性来分,如下是按照特性区分:
# DO,建议方式
- Project
- feature1
- ui
- domain
- data
- feature2
- ui
- domain
- data
- feature3
按照功能区分的方式大致如下:
# DO NOT,不建议方式
- Project
- ui
- feature1
- feature2
- feature3
- domain
- feature1
- feature2
- feature3
- data
我个人是倾向去按照特性的方式区分,而示例中看上去是偏后者,或者是一个混合体,比如有的模块是添加 feature
前缀的,但是 core-model
模块又是在统一的一个模块中集中管理。个人建议的方式应该是将各个模块中各自使用的模型放到自己的模块中,否则项目在后续进行组件化时将会遇到频繁发版的问题。当然,这种方式在模块化的阶段并没有什么大问题。
模块化之后就是组件化,组件化之后就是壳工程,每个技术阶段对应到团队发展的阶段,有机会的话后面可以展开聊聊。
作者:madroid
链接:https://juejin.cn/post/7128069998978793509
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
Kotlin 协程如何与 Java 进行混编?
问题
在 Java 与 Kotlin 混编项目中大概率是会遇到 Kotlin 线程的使用问题。协程的混编相对于其他特性的使用上会相对麻烦而且比较容易踩坑。我们以获取 token 来举例,比如有一个获取 token 的 suspend 函数:
// 常规的 suspend 函数,可以供 Kotlin 使用,Java 无法直接使用
suspend fun getTokenSuspend(): String {
// do something too long
return "Token"
}
想要在 Java 中直接调用则会产出如下错误:
了解 Kotlin 协程机制的同学应该知道 suspend 修饰符在 Kotlin 编译器期会被处理成对应的 Continuation 类,这里不展开讨论。
这个问题也可以使用简单的方式进行解决,那就是使用 runBlocking 进行简单包装一下即可。
使用 runBlocking 解决
一般情况下我们可能会使用以下代码解决上述问题。定义的 Kotlin 协程代码如下:
// 提供给 Java 使用的封装函数,Java 代码可以直接使用
fun getTokenBlocking(): String =runBlocking{// invoke suspend fun
getTokenSuspend()
}
在 Java 层代码的使用方式大致如下:
public void funInJava() {
String token = TokenKt.getTokenBlocking();
}
看上去方案比较简单,但是直接使用 runBlocking 也会存在一些隐患。 runBlocking 会阻塞当前调用者的线程,如果是在主线程进行调用的话,会导致 App 卡顿,严重的会导致 ANR 问题。那有没有比 runBlocking 更合理的解决方案呐?
回答这个问题之前,先梳理下 Java 与 Kotlin 两种语言在处理耗时函数的一般做法。
Java & Kotlin 耗时函数的一般定义
Java
- 靠语义约束。比如定义的函数名中 sync 修饰,表明他可能是一个耗时的函数,更好的还会添加
@WorkerThread
注解,让 lint 帮助使用者去做一些检查,确保不会在主线程中去调用一些耗时函数导致页面卡顿。 - 靠语法约束,定义 Callback。将耗时的函数执行放到一个单独的线程中执行,然后将回调的结果通过 Callback 的形式返回。这种方式无论调用者是什么水平,代码质量都不会有问题;
Kotlin
- 靠语义约束,同 Java。
- 添加 suspend 修饰,靠语法约束。内部耗时函数切到子线程中执行。外部调用者使用同步的方式调用耗时函数却不会阻塞主线程(这也是 Kotlin 协程主要宣传的点)。
在 Java 与 Kotlin 混编的项目中,上述情况的复杂度将会上升。
使用 CompletableFuture 解决
在审视一下 runBlocking 的使用问题,这种做法是将 Kotlin 中的语法约束退化到语义约束层面了,有的可能连语义层面的约束都没有,这种情况只能祈求调用者的使用是正确的 -- 在子线程调用,而不是在主线程调用。那应该如何怎么处理,就是采用回调的方式,让语法能够规避的问题就不要采用语义来处理。
suspend fun getToken(): String {
// do something too long
return "Token"
}
fun getTokenFuture(): CompletableFuture<String> {
returnCoroutineScope(Dispatchers.IO).future{getToken()}
}
注意:future 是 org.jetbrains.kotlinx:kotlinx-coroutines-jdk8
包中提供的工具类,基于 CoroutineScope
定义的扩展函数,使用时需要导入依赖包。
Java 中的使用方式如下:
public void funInJava() {
try {
// 通过 Future get() 显示调用 getTokenFuture 函数
TestKt.getTokenFuture().get();
} catch (ExecutionException e) {
e.printStackTrace();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
可能会问这里看上去和直接在函数内部使用 runBlocking 没有太大的区别,反而使用上会更麻烦些。的确是这样的,这样的目的是把选择权交给调用者,或者说让调用者显示的知道这不是一个简单的函数,从而提高其在使用 API 时的警惕度,也就是之前提到的从语法层面对 API 进行约束。
退一步说,上述的内容是针对的仅仅是“不得不”这么做的场景,但是对于大部分场景都是可以通过合理的设计来避免出现上述情况:
- 底层定义的 suspend 函数可以在上层的 ViewModel 中的
viewModelScope
中调用解决; - 统一对外暴露的 API 是 Java 类的话,新增的 API 提供可以使用 suspend 类型的扩展函数,使用 suspend 类型对外暴露;
- 如果明确知道调用者是 Java 代码,那么请提供 Callback 的 API 定义;
总结
尽量使用合理的设计来尽量规避 Kotlin 协程与 Java 混用的情况,在 API 的定义上语法约束优先与语义约束,语义约束优于没有任何约束。当然在特殊的情况下也可以使用 CompletableFuture
API 来封装协程相关 API。
下面对几种常见场景推荐的一些写法:
- 在单元测试中可以直接使用
runBlocking
; - 耗时函数可以直接定义为 suspend 函数或者使用 Callback 形式返回;
- 对于 Java 类中调用协程函数的场景应使用显示的声明告知调用者,严格一点的可以判断线程,对于在主线程调用的可以抛出异常或者记录下来统一处理;
作者:madroid
链接:https://juejin.cn/post/7130270050572828703
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
最近很火的反调试,你知道它是什么吗?
前言
我们日常开发中,永远离不开debug调试,断点技术一直是我们排查bug的有力手段之一!随着网络安全意识的逐步提高,对app安全的要求就越来越高,反调试的技术也渐渐深入我们开发者的眼帘,那么我们来具体看看,android中,同时也是linux内核中,是怎么处理调试程序的!
执行跟踪
无论是断点还是其他debug手段,其实都可以总结为一个技术手段,就是执行跟踪,含义就是一个程序监视另一个程序的技术,被跟踪的程序通过一步步执行,知道收到一个信号或者系统调用停止!
在linux内核中,就是通过ptrace系统调用进行的执行跟踪
#include <sys/ptrace.h>
long ptrace(enum __ptrace_request request, pid_t pid, void *addr, void *data);
随着我们对linux的了解,那么就离不开对权限的讨论!一个程序跟踪另一个程序这种东西,按照linux风格,肯定是具有某种权限才可以执行!这个权限就是设置了CAP_SYS_PTRACE 权限的进程,就可以跟踪系统中除了init进程(linux第一个进程)外的任何进程!当然!就算一个进程没有CAP_SYS_PTRACE权限,也可以跟踪一个与被监视进程有相同属组的进程,比如父进程可以通过ptrace跟踪子进程!执行跟踪还有一个非常重要的特点,就是两个进程不能同时跟踪一个进程!
我们再回到ptrace函数调用,可以看到第一个参数是一个枚举值,其实就是发出ptrace的当前行为,它有以下可选命令(仅部分举例):
其他的参数含义如下:
pid参数标识目标进程,addr参数表明执行peek(读操作)和poke(写操作)操作的地址,data参数则对于poke操作,指明存放数据的地址,对于peek操作,指明获取数据的地址。
ptrace设计探讨
我们了解了linux提供的系统api,那么我们还是从设计者角度出发,我们想要跟踪一个进程的话,我们需要干点什么?来来来,我们来想一下,可能就会有以下几个问题吧
- 被跟踪进程与跟踪进程怎么建立联系
- 如果使程序停止在我们想要停止的点(比如断点)
- 跟踪进程与被跟踪进程怎么进行数据交换,又或者我们怎么样看到被跟踪进程中当前的数据
下面我们逐步去探讨一下这几个问题吧!(以PTRACE_ATTACH 作为例子)首先对于问题1,我们怎么建立起被跟踪进程与跟踪进程之间的联系呢?linux中进程存在父子关系,兄弟关系对吧!这些进程就可以通过相对便捷的方式进行通信,同时linux也有定义了特殊的信号提供给父子进程的通信。看到这里,相信大家能够猜到ptrace究竟干了啥!就是通过调用ptrace系统调用,把被跟踪进程(第二个参数pid)的进程描述符号中的p_pptr字段指向了跟踪进程!毕竟linux判断进程的描述,就靠着进程描述符,想要建立父子关系,修改进程描述符即可,就这么简单!这里补充一下部分描述符号:
那么好!我们建立进程之间的联系了,那么当执行跟踪终止的时候,我们就可以调用ptrace 第一个参数为PTRACE_DETACH 命令,把p_pptr恢复到原来的数据即可!(那么有人会问,原来的父进程描述符保存在哪里了,嘿嘿,在p_opptr中,也就是他的祖先中,这里我们不深入讨论)
接下来我们来讨论一下问题2和问题3,怎么使程序停止呢?(这里我们讨论常用的做法,以linux内核2.4版本为准,请注意细微的区别)其实就是被监控进程在读取指令前,就会执行被嵌入的监控代码,如果我想要停止在代码的某一行,这个时候cpu会执行一条“陷阱指令”也称为Debug指令,一般来说,这条指令作用只是为了使程序停止,然后发出一个SIGCHLD信号给父进程(不了解信号的知识可以看看这篇),嘿嘿,那么这个父进程是谁呢?没错,就是我们刚刚改写的监控进程,这样一来,我们的监控进程就能够收到被监控进程的消息,此时就可以继续调用其他的ptrace调用(第一个参数指定为其他需要的枚举值),查看当前寄存器或者其他的数据
这么说下来可能会有人还是不太懂,我们举个例子,我们的单步调试是怎么样做的:
还是上面的步骤,子进程发送一个SIGCHLD给父进程,此时身为父进程的监控线程就可以再调用ptrace(PTRACE_SINGLESTEP, *, *, * )方法给子进程的下一条指令设置陷阱指令,进行单步调试,此时控制权又会给到子进程,子进程执行完一个指令,就会又发出SIGCHLD给父进程,如此循环下去!
反调试
最近隐私合规与app安全性能被各大app所重视,对于app安全性能来说,反调试肯定是最重要的一环!看到上面的这些介绍,我们应该也明白了ptrace的作用,下面我们介绍一下几种常见的反调试方案:
- ptrace占位:利用ptrace的机制,我们知道一个进程只能被一个监控进程所监控,所以我们可以提前初始化一个进程,用这个进程对我们自身app的进程调用一次ptrace即可
- 轮询进程状态:可以通过轮训的手段,查看进程当前的进程信息:proc/pid/status
Name: test\
Umask: 0022\
State: D (disk sleep)-----------------------表示此时线程处于sleeping,并且是uninterruptible状态的wait。
Tgid: 157-----------------------------------线程组的主pid\
Ngid: 0\
Pid: 159------------------------------------线程自身的pid\
PPid: 1-------------------------------------线程组是由init进程创建的。\
TracerPid: 0\ **这里是关键**
Uid: 0 0 0 0\
Gid: 0 0 0 0\
FDSize: 256---------------------------------表示到目前为止进程使用过的描述符总数。\
Groups: 0 10 \
VmPeak: 1393220 kB--------------------------虚拟内存峰值大小。\
VmSize: 1390372 kB--------------------------当前使用中的虚拟内存,小于VmPeak。\
VmLck: 0 kB\
VmPin: 0 kB\
VmHWM: 47940 kB-----------------------------RSS峰值。\
VmRSS: 47940 kB-----------------------------RSS实际使用量=RSSAnon+RssFile+RssShmem。\
RssAnon: 38700 kB\
RssFile: 9240 kB\
RssShmem: 0 kB\
VmData: 366648 kB--------------------------进程数据段共366648KB。\
VmStk: 132 kB------------------------------进程栈一共132KB。\
VmExe: 84 kB-------------------------------进程text段大小84KB。\
VmLib: 11488 kB----------------------------进程lib占用11488KB内存。\
VmPTE: 1220 kB\
VmPMD: 0 kB\
VmSwap: 0 kB\
Threads: 40-------------------------------进程中一个40个线程。\
SigQ: 0/3142------------------------------进程信号队列最大3142,当前没有pending
如果TracerPid不为0,那么就存在被监控的进程,此时如果该进程不是我们所信任的进程,就调用我们指定好的程序重启即可!读取这个proc/pid/status文件涉及到的相关处理可以自行google,这里就不再重复列举啦!
总结
看到这里,我们也能够明白debug在linux内核中的处理流程啦!最后,点个赞再走呗!
作者:Pika
链接:https://juejin.cn/post/7132438417970823176
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
Android: Shape 的使用
Android Shape 的使用
在Android开发中,我们可以使用shape定义各种各样的形状,也可以定义一些图片资源。相对于传统
图片来说,使用shape可以减少资源占用,减少安装包大小,还能够很好地适配不同尺寸的手机。
1. shape属性
- shape 属性基本语法示例:
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<!-- 圆角属性-->
<corners
android:bottomLeftRadius="10dp"
android:bottomRightRadius="10dp"
android:radius="5dp"
android:topLeftRadius="15dp"
android:topRightRadius="15dp" />
<!-- 渐变属性-->
<gradient
android:angle="-45"
android:centerColor="#ff0099"
android:centerX="20"
android:centerY="30"
android:endColor="#80FF00"
android:gradientRadius="45dp"
android:startColor="#FF0089BD"
android:type="linear"
android:useLevel="false" />
<!-- 边距属性-->
<padding
android:bottom="12dp"
android:left="10dp"
android:right="15dp"
android:top="10dp" />
<!--大小属性-->
<size
android:width="200dp"
android:height="200dp" />
<!-- 填充属性-->
<!-- <solid android:color="#ffff9d"/>-->
<!-- 描边属性-->
<stroke
android:width="2dp"
android:color="#dcdcdc" />
</shape>
2. 基本属性
Shape可以定义控件的一些展示效果,例如圆角,渐变,填充,描边,大小,边距; shape 子标签就可以实现这些效果, shape 子标签有下面几个属性:corners,gradient,padding,size,solid,stroke:
- corners(圆角)是用来字义圆角
<corners //定义圆角
android:radius="10dp" //全部的圆角半径;
android:topLeftRadius="5dp" //左上角的圆角半径;
android:topRightRadius="5dp" //右上角的圆角半径;
android:bottomLeftRadius="5dp" //左下角的圆角半径;
android:bottomRightRadius="5dp" /> //右下角的圆角半径。
- solid(填充色)是用以指定内部填充色;
<solid android:color="#ffff00"/> //内部填充色
- gradient(渐变)用以定义渐变色,可以定义两色渐变和三色渐变,及渐变样式;
<gradient
android:type=["linear" | "radial" | "sweep"] //共有3中渐变类型,线性渐变
(默认)/放射渐变/扫描式渐变;
android:angle="90" //渐变角度,必须为45的倍数,0为从左到右,90为从上到下;
android:centerX="0.5" //渐变中心X的相当位置,范围为0~1;
android:centerY="0.5" //渐变中心Y的相当位置,范围为0~1;
android:startColor="#24e9f2" //渐变开始点的颜色;
android:centerColor="#2564ef" //渐变中间点的颜色,在开始与结束点之间;
android:endColor="#25f1ef" //渐变结束点的颜色;
android:gradientRadius="5dp" //渐变的半径,只有当渐变类型为radial时才能使用;
android:useLevel="false" /> //使用LevelListDrawable时就要设置为true。设为
false时才有渐变效果。
- stroke(描边)是描边属性,可以定义描边的宽度,颜色,虚实线等;
<stroke
android:width="1dp" //描边的宽度
android:color="#ff0000" //描边的颜色
// 以下两个属性设置虚线
android:dashWidth="1dp" //虚线的宽度,值为0时是实线
android:dashGap="1dp" />//虚线的间隔
- padding(内边距)是用来定义内部边距
<padding
android:left="10dp" //左内边距;
android:top="10dp" //上内边距;
android:right="10dp" //右内边距;
android:bottom="10dp" /> //下内边距。
- size(大小)标签是用来定义图形的大小的
<size
android:width="50dp" //宽度
android:height="50dp" />// 高度
3. 特殊属性
Shape可以定义当前Shape的形状的,比如矩形,椭圆形,线形和环形;这些都是通过 shape 标签属性来定义的, shape 标签有下面几个属性:rectangle,oval,line,ring:
<shape xmlns:android="http://schemas.android.com/apk/res/android"
//shape的形状,默认为矩形,可以设置为矩形(rectangle)、椭圆形(oval)、线性形状(line)环形(ring)
android:shape=["rectangle" | "oval" | "line" | "ring"]
//下面的属性只有在android:shape="ring"时可用:
android:innerRadius="10dp" // 内环的半径;
android:innerRadiusRatio="2" // 浮点型,以环的宽度比率来表示内环的半径;
android:thickness="3dp" // 环的厚度;
android:thicknessRatio="2" // 浮点型,以环的宽度比率来表示环的厚度;
android:useLevel="false"> // boolean值,如果当做是LevelListDrawable使用时值为
true,否则为false。
</shape>
- rectangle(矩形)
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/colorPrimary"/>
</shape>
- oval(椭圆)
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="@color/colorPrimary"/>
<size android:height="100dp"
android:width="100dp"/>
</shape>
- line(线)
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="line">
<stroke
android:width="1dp"
android:color="@color/colorAccent"
android:dashGap="3dp"//虚线间距
android:dashWidth="4dp"/>//虚线宽度
<size android:height="3dp"/>
</shape>
- ring(圆环)
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="ring"
android:useLevel="false"
android:innerRadius="20dp" // 内环的半径
android:thickness="10dp"> // 圆环宽度
<!--useLevel需要设置为false-->
<solid android:color="@color/colorAccent"/>
</shape>
4.shape用法
- 在res/drawable下新建 shape_text.xml 文件;
//参考 1.shape属性
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<!-- 圆角属性-->
<corners ... />
<!-- 渐变属性-->
<gradient ... />
<!-- 边距属性-->
<padding ... />
<!--大小属性-->
<size ... />
<!-- 描边属性-->
<stroke ... />
</shape>
- 在布局中引用 shape_text.xml 文件;
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/shape_text"
android:text="Shape测试"
android:textColor="@android:color/black"
android:textSize="15sp" />
</LinearLayout>
作者:提笔写bug
链接:https://juejin.cn/post/7130442723433119774
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
Android技术分享|【Android踩坑】怀疑人生,主线程修改UI也会崩溃?
前言
某天早晨,吃完早餐,坐回工位,打开电脑,开启chrome,进入友盟页面,发现了一个崩溃信息:
java.lang.RuntimeException: Unable to resume activity {com.youdao.youdaomath/com.youdao.youdaomath.view.PayCourseVideoActivity}: android.view.ViewRootImpl$CalledFromWrongThreadException: Only the original thread that created a view hierarchy can touch its views.
at android.app.ActivityThread.performResumeActivity(ActivityThread.java:3824)
at android.app.ActivityThread.handleResumeActivity(ActivityThread.java:3856)
at android.app.servertransaction.ResumeActivityItem.execute(ResumeActivityItem.java:51)
at android.app.servertransaction.TransactionExecutor.executeLifecycleState(TransactionExecutor.java:145)
at android.app.servertransaction.TransactionExecutor.execute(TransactionExecutor.java:70)
at android.app.ActivityThread$H.handleMessage(ActivityThread.java:1831)
at android.os.Handler.dispatchMessage(Handler.java:106)
at android.os.Looper.loop(Looper.java:201)
at android.app.ActivityThread.main(ActivityThread.java:6806)
at java.lang.reflect.Method.invoke(Native Method)
at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:547)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:873)
Caused by: android.view.ViewRootImpl$CalledFromWrongThreadException: Only the original thread that created a view hierarchy can touch its views.
at android.view.ViewRootImpl.checkThread(ViewRootImpl.java:8000)
at android.view.ViewRootImpl.requestLayout(ViewRootImpl.java:1292)
at android.view.View.requestLayout(View.java:23147)
at android.view.View.requestLayout(View.java:23147)
at android.widget.TextView.checkForRelayout(TextView.java:8914)
at android.widget.TextView.setText(TextView.java:5736)
at android.widget.TextView.setText(TextView.java:5577)
at android.widget.TextView.setText(TextView.java:5534)
at android.widget.Toast.setText(Toast.java:332)
at com.youdao.youdaomath.view.common.CommonToast.showShortToast(CommonToast.java:40)
at com.youdao.youdaomath.view.PayCourseVideoActivity.checkNetWork(PayCourseVideoActivity.java:137)
at com.youdao.youdaomath.view.PayCourseVideoActivity.onResume(PayCourseVideoActivity.java:218)
at android.app.Instrumentation.callActivityOnResume(Instrumentation.java:1413)
at android.app.Activity.performResume(Activity.java:7400)
at android.app.ActivityThread.performResumeActivity(ActivityThread.java:3816)
一眼看上去似乎是比较常见的子线程修改UI的问题。并且是在Toast上面报出的,常识告诉我Toast在子线程弹出是会报错,但是应该是提示Looper没有生成的错,而不应该是上面所报出的错误。那么会不会是生成Looper以后报的错的?
一、Demo 验证
所以我先做了一个demo,如下:
@Override
protected void onResume() {
super.onResume();
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
Toast.makeText(MainActivity.this,"子线程弹出Toast",Toast.LENGTH_SHORT).show();
}
});
thread.start();
}
运行一下,果不其然崩溃掉,错误信息就是提示我必须准备好looper才能弹出toast:
java.lang.RuntimeException: Can't toast on a thread that has not called Looper.prepare()
at android.widget.Toast$TN.<init>(Toast.java:393)
at android.widget.Toast.<init>(Toast.java:117)
at android.widget.Toast.makeText(Toast.java:280)
at android.widget.Toast.makeText(Toast.java:270)
at com.netease.photodemo.MainActivity$1.run(MainActivity.java:22)
at java.lang.Thread.run(Thread.java:764)
接下来就在toast里面准备好looper,再试试吧:
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
Looper.prepare();
Toast.makeText(MainActivity.this,"子线程弹出Toast",Toast.LENGTH_SHORT).show();
Looper.loop();
}
});
thread.start();
运行发现是能够正确的弹出Toast的:
那么问题就来了,为什么会在友盟中出现这个崩溃呢?
二、再探堆栈
然后仔细看了下报错信息有两行重要信息被我之前略过了:
at com.youdao.youdaomath.view
.PayCourseVideoActivity.onResume(PayCourseVideoActivity.java:218)
android.widget.Toast.setText(Toast.java:332)
发现是在主线程报了Toast设置Text的时候的错误。这就让我很纳闷了,子线程修改UI会报错,主线程也会报错?
感觉这么多年Android白做了。这不是最基本的知识么?
于是我只能硬着头皮往源码深处看了:
先来看看Toast是怎么setText的:
public static Toast makeText(@NonNull Context context, @Nullable Looper looper,
@NonNull CharSequence text, @Duration int duration) {
Toast result = new Toast(context, looper);
LayoutInflater inflate = (LayoutInflater)
context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
View v = inflate.inflate(com.android.internal.R.layout.transient_notification, null);
TextView tv = (TextView)v.findViewById(com.android.internal.R.id.message);
tv.setText(text);
result.mNextView = v;
result.mDuration = duration;
return result;
}
很常规的一个做法,先是inflate出来一个View对象,再从View对象找出对应的TextView,然后TextView将文本设置进去。
至于setText在之前有详细说过,是在ViewRootImpl里面进行checkThread是否在主线程上面。所以感觉似乎一点问题都没有。那么既然出现了这个错误,总得有原因吧,或许是自己源码看漏了?
那就重新再看一遍ViewRootImpl#checkThread方法吧:
void checkThread() {
if (mThread != Thread.currentThread()) {
throw new CalledFromWrongThreadException(
"Only the original thread that created a view hierarchy can touch its views.");
}
}
这一看,还真的似乎给我了一点头绪,系统在checkThread的时候并不是将Thread.currentThread和MainThread作比较,而是跟mThread作比较,那么有没有一种可能mThread是子线程?
一想到这里,我就兴奋了,全类查看mThread到底是怎么初始化的:
public ViewRootImpl(Context context, Display display) {
...代码省略...
mThread = Thread.currentThread();
...代码省略...
}
可以发现全类只有这一处对mThread进行了赋值。那么会不会是子线程初始化了ViewRootimpl呢?似乎我之前好像也没有研究过Toast为什么会弹出来,所以顺便就先去了解下Toast是怎么show出来的好了:
/**
* Show the view for the specified duration.
*/
public void show() {
if (mNextView == null) {
throw new RuntimeException("setView must have been called");
}
INotificationManager service = getService();
String pkg = mContext.getOpPackageName();
TN tn = mTN;
tn.mNextView = mNextView;
try {
service.enqueueToast(pkg, tn, mDuration);
} catch (RemoteException e) {
// Empty
}
}
调用Toast的show方法时,会通过Binder获取Service即NotificationManagerService,然后执行enqueueToast方法(NotificationManagerService的源码就不做分析),然后会执行Toast里面如下方法:
@Override
public void show(IBinder windowToken) {
if (localLOGV) Log.v(TAG, "SHOW: " + this);
mHandler.obtainMessage(SHOW, windowToken).sendToTarget();
}
发送一个Message,通知进行show的操作:
@Override
public void show(IBinder windowToken) {
if (localLOGV) Log.v(TAG, "SHOW: " + this);
mHandler.obtainMessage(SHOW, windowToken).sendToTarget();
}
在Handler的handleMessage方法中找到了SHOW的case,接下来就要进行真正show的操作了:
public void handleShow(IBinder windowToken) {
if (localLOGV) Log.v(TAG, "HANDLE SHOW: " + this + " mView=" + mView
+ " mNextView=" + mNextView);
// If a cancel/hide is pending - no need to show - at this point
// the window token is already invalid and no need to do any work.
if (mHandler.hasMessages(CANCEL) || mHandler.hasMessages(HIDE)) {
return;
}
if (mView != mNextView) {
// remove the old view if necessary
handleHide();
mView = mNextView;
Context context = mView.getContext().getApplicationContext();
String packageName = mView.getContext().getOpPackageName();
if (context == null) {
context = mView.getContext();
}
mWM = (WindowManager)context.getSystemService(Context.WINDOW_SERVICE);
// We can resolve the Gravity here by using the Locale for getting
// the layout direction
final Configuration config = mView.getContext().getResources().getConfiguration();
final int gravity = Gravity.getAbsoluteGravity(mGravity, config.getLayoutDirection());
mParams.gravity = gravity;
if ((gravity & Gravity.HORIZONTAL_GRAVITY_MASK) == Gravity.FILL_HORIZONTAL) {
mParams.horizontalWeight = 1.0f;
}
if ((gravity & Gravity.VERTICAL_GRAVITY_MASK) == Gravity.FILL_VERTICAL) {
mParams.verticalWeight = 1.0f;
}
mParams.x = mX;
mParams.y = mY;
mParams.verticalMargin = mVerticalMargin;
mParams.horizontalMargin = mHorizontalMargin;
mParams.packageName = packageName;
mParams.hideTimeoutMilliseconds = mDuration ==
Toast.LENGTH_LONG ? LONG_DURATION_TIMEOUT : SHORT_DURATION_TIMEOUT;
mParams.token = windowToken;
if (mView.getParent() != null) {
if (localLOGV) Log.v(TAG, "REMOVE! " + mView + " in " + this);
mWM.removeView(mView);
}
if (localLOGV) Log.v(TAG, "ADD! " + mView + " in " + this);
// Since the notification manager service cancels the token right
// after it notifies us to cancel the toast there is an inherent
// race and we may attempt to add a window after the token has been
// invalidated. Let us hedge against that.
try {
mWM.addView(mView, mParams);
trySendAccessibilityEvent();
} catch (WindowManager.BadTokenException e) {
/* ignore */
}
}
}
代码有点长,我们最需要关心的就是mWm.addView方法。
相信看过ActivityThread的同学应该知道mWm.addView方法是在ActivityThread的handleResumeActivity里面也有调用过,意思就是进行ViewRootImpl的初始化,然后通过ViewRootImp进行View的测量,布局,以及绘制。
看到这里,我想到了一个可能的原因:
那就是我的Toast是一个全局静态的Toast对象,然后第一次是在子线程的时候show出来,这个时候ViewRootImpl在初始化的时候就会将子线程的对象作为mThread,然后下一次在主线程弹出来就出错了吧?想想应该是这样的。
三、再探Demo
所以继续做我的demo来印证我的想法:
@Override
protected void onResume() {
super.onResume();
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
Looper.prepare();
sToast = Toast.makeText(MainActivity.this,"子线程弹出Toast",Toast.LENGTH_SHORT);
sToast.show();
Looper.loop();
}
});
thread.start();
}
public void click(View view) {
sToast.setText("主线程弹出Toast");
sToast.show();
}
做了个静态的toast,然后点击按钮的时候弹出toast,运行一下:
发现竟然没问题,这时候又开始怀疑人生了,这到底怎么回事。ViewRootImpl此时的mThread应该是子线程啊,没道理还能正常运行,怎么办呢?debug一步一步调试吧,一步一步调试下来,发现在View的requestLayout里面parent竟然为空了:
然后在仔细看了下当前View是一个LinearLayout,然后这个View的子View是TextView,文本内容是"主线程弹出toast",所以应该就是Toast在new的时候inflate的布局
View v = inflate.inflate(com.android.internal.R.layout.transient_notification, null);
找到了对应的toast布局文件,打开一看,果然如此:
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:background="?android:attr/toastFrameBackground">
<TextView
android:id="@android:id/message"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginHorizontal="24dp"
android:layout_marginVertical="15dp"
android:layout_gravity="center_horizontal"
android:textAppearance="@style/TextAppearance.Toast"
android:textColor="@color/primary_text_default_material_light"
/>
</LinearLayout>
也就是说此时的View已经是顶级View了,它的parent应该就是ViewRootImpl,那么为什么ViewRootImpl是null呢,明明之前已经show过了。看来只能往Toast的hide方法找原因了
四、深入源码
所以重新回到Toast的类中,查看下Toast的hide方法(此处直接看Handler的hide处理,之前的操作与show类似):
public void handleHide() {
if (localLOGV) Log.v(TAG, "HANDLE HIDE: " + this + " mView=" + mView);
if (mView != null) {
// note: checking parent() just to make sure the view has
// been added... i have seen cases where we get here when
// the view isn't yet added, so let's try not to crash.
if (mView.getParent() != null) {
if (localLOGV) Log.v(TAG, "REMOVE! " + mView + " in " + this);
mWM.removeViewImmediate(mView);
}
// Now that we've removed the view it's safe for the server to release
// the resources.
try {
getService().finishToken(mPackageName, this);
} catch (RemoteException e) {
}
mView = null;
}
}
此处调用了mWm的removeViewImmediate,即WindowManagerImpl里面的removeViewImmediate方法:
@Override
public void removeViewImmediate(View view) {
mGlobal.removeView(view, true);
}
会调用WindowManagerGlobal的removeView方法:
public void removeView(View view, boolean immediate) {
if (view == null) {
throw new IllegalArgumentException("view must not be null");
}
synchronized (mLock) {
int index = findViewLocked(view, true);
View curView = mRoots.get(index).getView();
removeViewLocked(index, immediate);
if (curView == view) {
return;
}
throw new IllegalStateException("Calling with view " + view
+ " but the ViewAncestor is attached to " + curView);
}
}
然后调用removeViewLocked方法:
private void removeViewLocked(int index, boolean immediate) {
ViewRootImpl root = mRoots.get(index);
View view = root.getView();
if (view != null) {
InputMethodManager imm = InputMethodManager.getInstance();
if (imm != null) {
imm.windowDismissed(mViews.get(index).getWindowToken());
}
}
boolean deferred = root.die(immediate);
if (view != null) {
//此处调用View的assignParent方法将viewParent置空
view.assignParent(null);
if (deferred) {
mDyingViews.add(view);
}
}
}
所以也就是说在Toast时间到了以后,会调用hide方法,此时会将parent置成空,所以我刚才试的时候才没有问题。那么按道理说只要在Toast没有关闭的时候点击再次弹出toast应该就会报错。
所以还是原来的代码,再来一次,这次不等Toast关闭,再次点击:
果然如预期所料,此时在主线程弹出Toast就会崩溃。
五、发现原因
那么问题原因找到了:
是在项目子线程中有弹出过Toast,然后Toast并没有关闭,又在主线程弹出了同一个对象的toast,会造成崩溃。
此时内心有个困惑:
如果是子线程弹出Toast,那我就需要写Looper.prepare方法和Looper.loop方法,为什么我自己一点印象都没有。
于是我全局搜索了Looper.prepare,发现并没有找到对应的代码。所以我就全局搜索了Toast调用的地方,发现在JavaBridge的回调当中找到了:
class JSInterface {
@JavascriptInterface
public void handleMessage(String msg) throws JSONException {
LogHelper.e(TAG, "msg::" + msg);
JSONObject jsonObject = new JSONObject(msg);
String callType = jsonObject.optString(JS_CALL_TYPE);
switch (callType) {
...代码省略..
case JSCallType.SHOW_TOAST:
showToast(jsonObject);
break;
default:
break;
}
}
}
/**
* 弹出吐司
* @param jsonObject
* @throws JSONException
*/
public void showToast(JSONObject jsonObject) throws JSONException {
JSONObject payDataObj = jsonObject.getJSONObject("data");
String message = payDataObj.optString("data");
CommonToast.showShortToast(message);
}
但是看到这段代码,又有疑问了,我并没有在Javabridge的回调中看到有任何准备Looper的地方,那么为什么Toast没有崩溃掉?
所以在此处加了一段代码:
class JSInterface {
@JavascriptInterface
public void handleMessage(String msg) throws JSONException {
LogHelper.e(TAG, "msg::" + msg);
JSONObject jsonObject = new JSONObject(msg);
String callType = jsonObject.optString(JS_CALL_TYPE);
Thread currentThread = Thread.currentThread();
Looper looper = Looper.myLooper();
switch (callType) {
...代码省略..
case JSCallType.SHOW_TOAST:
showToast(jsonObject);
break;
default:
break;
}
}
}
并且加了一个断点,来查看下此时的情况:
确实当前线程是JavaBridge线程,另外JavaBridge线程中已经提前给开发者准备好了Looper。所以也难怪一方面奇怪自己怎么没有写Looper的印象,一方面又很好奇为什么这个线程在开发者没有准备Looper的情况下也能正常弹出Toast。
总结
至此,真相终于找出来了。
相比较发生这个bug 的原因,解决方案就显得非常简单了。
只需要在CommonToast的showShortToast方法内部判断是否为主线程调用,如果不是的话,new一个主线程的Handler,将Toast扔到主线程弹出来。
这样就会避免了子线程弹出。
PS:本人还得吐槽一下Android,Android官方一方面明明宣称不能在主线程以外的线程进行UI的更新,**另一方面在初始化ViewRootImpl的时候又不把主线程作为成员变量保存起来,而是直接获取当前所处的线程作为mThread保存起来,这样做就有可能会出现子线程更新UI的操作。**从而引起类似我今天的这个bug。
作者:anyRTC
链接:https://juejin.cn/post/7130824794060587016
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
Android性能优化 -- 大图治理
在实际的Android项目开发中,图片是必不可少的元素,几乎所有的界面都是由图片构成的;像列表页、查看大图页等,都是需要展示图片,而且这两者是有共同点的,列表展示的Item数量多,如果全部加载进来势必会造成OOM,因此列表页通常采用分页加载,加上RecyclerView的复用机制,一般很少会发生OOM。
但是对于大图查看,通常在外界展示的是一张缩略图,点开之后放大就是原图,如果图片很大,OOM发生也是正常的,因此在加载大图的时候,可以看下面这张图
一张图片如果很大,在手机屏幕中并不能完全展示,那么其实就没有必要讲图片完全加载进来,而是可以采用分块加载的方式,只展示显示的那一部分,当图片向上滑动的时候,之前展示的区域内存能够复用,不需要开辟新的内存空间来承接新的模块,从而达到了大图的治理的目的。
1 自定义大图View
像在微信中点击查看大图,查看大图的组件就是一个自定义View,能够支持滑动、拖拽、放大等功能,因此我们也可以自定义一个类似于微信的大图查看器,从中了解图片加载优化的魅力
1.1 准备工作
class BigView : View{
constructor(context: Context):super(context){
initBigView(context)
}
constructor(context: Context,attributeSet: AttributeSet):super(context,attributeSet){
initBigView(context)
}
private fun initBigView(context: Context) {
}
}
本节使用的语言为kotlin,需要java代码的伙伴们可以找我私聊哦。
这个是我从网站上找的一张长图,大概700K左右,需要的可以自行下载,其实想要了解其中的原理和实现,不一定要找一张特别大的图片,所有的问题都是举一反三的。
class BigView : View, GestureDetector.OnGestureListener, View.OnTouchListener {
//分块加载
private lateinit var mRect: Rect
//内存复用
private lateinit var mOptions: BitmapFactory.Options
//手势
private lateinit var mGestureDetector: GestureDetector
//滑动
private lateinit var mScroller: Scroller
constructor(context: Context) : super(context) {
initBigView(context)
}
constructor(context: Context, attributeSet: AttributeSet) : super(context, attributeSet) {
initBigView(context)
}
private fun initBigView(context: Context) {
mRect = Rect()
mOptions = BitmapFactory.Options()
mGestureDetector = GestureDetector(context, this)
mScroller = Scroller(context)
setOnTouchListener(this)
}
override fun onDown(e: MotionEvent?): Boolean {
return false
}
override fun onShowPress(e: MotionEvent?) {
}
override fun onSingleTapUp(e: MotionEvent?): Boolean {
return false
}
override fun onScroll(
e1: MotionEvent?,
e2: MotionEvent?,
distanceX: Float,
distanceY: Float
): Boolean {
return false
}
override fun onLongPress(e: MotionEvent?) {
}
override fun onFling(
e1: MotionEvent?,
e2: MotionEvent?,
velocityX: Float,
velocityY: Float
): Boolean {
return false
}
override fun onTouch(v: View?, event: MotionEvent?): Boolean {
return false
}
}
前面我们提到的分块加载、内存复用、手势等操作,直接在view初始化时完成,这样我们前期的准备工作就完成了。
1.2 图片宽高适配
当我们加载一张图片的时候,要让这张图片完全展示在手机屏幕上不被裁剪,就需要做宽高的适配;如果这张图片大小是80M,那么为了获取宽高而将图片加载到内存中肯定会OOM,那么在图片加载到内存之前就像获取图片的宽高该怎么办呢?BitmapFactory.Options就提供了这个手段
fun setImageUrl(inputStream: InputStream) {
//获取图片宽高
mOptions.inJustDecodeBounds = true
BitmapFactory.decodeStream(inputStream,null,mOptions)
imageWidth = mOptions.outWidth
imageHeight = mOptions.outHeight
mOptions.inJustDecodeBounds = false
//开启复用
mOptions.inMutable = true
mOptions.inPreferredConfig = Bitmap.Config.RGB_565
//创建区域解码器
try {
BitmapRegionDecoder.newInstance(inputStream,false)
}catch (e:Exception){
}
requestLayout()
}
当设置inJustDecodeBounds为true(记住要成对出现,使用完成之后需要设置为false),意味着我调用decodeStream方法的时候,不会将图片的内存加载而是仅仅为了获取宽高。
然后拿到了图片的宽高之后呢,调用requestLayout方法,会回调onMeasure方法,这个方法大家就非常熟悉了,能够拿到view的宽高,从而完成图片的适配
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
//适配
viewWidth = measuredWidth
viewHeight = measuredHeight
originScale = viewWidth / imageWidth.toFloat()
mScale = originScale
//分块加载首次进入展示的rect
mRect.left = 0
mRect.top = 0
mRect.right = imageWidth
mRect.bottom = (viewHeight / mScale).toInt()
}
这里设置Rect的right就是图片的宽度,因为原始图片的宽度可能比控件的宽度要宽,因此是将控件的宽度与图片的宽度对比获取了缩放比,那么Rect的bottom就需要等比缩放
这里的mRect可以看做是这张图片上的一个滑动窗口,无论是放大还是缩小,只要在屏幕上看到的区域,都可以看做是mRect在这张图片上来回移动截取的目标区域
1.3 BitmapRegionDecoder
在onMeasure中,我们定义了需要加载的图片的Rect,这是一块区域,那么我们通过什么样的方式能够将这块区域的图片加载出来,就是通过BitmapRegionDecoder区域解码器。
区域解码器,顾名思义,能够在某个区域进行图片解码展示
//创建区域解码器
try {
BitmapRegionDecoder.newInstance(inputStream,false)
}catch (e:Exception){
}
在传入图片流的时候,我们就已经创建了BitmapRegionDecoder,同时将图片流作为参数构建了解码器,那么这个解码器其实已经拿到了整张图片的资源,因此任意一块区域,通过BitmapRegionDecoder都能够解码展示出来
override fun onDraw(canvas: Canvas?) {
super.onDraw(canvas)
mRegionDecoder ?: return
//复用bitmap
mOptions.inBitmap = mutableBitmap
mutableBitmap = mRegionDecoder?.decodeRegion(mRect, mOptions)
//画出bitmap
val mMatrix = Matrix()
mMatrix.setScale(mScale, mScale)
mutableBitmap?.let {
canvas?.drawBitmap(it, mMatrix, null)
}
}
首先我们想要进行内存复用,需要调用BitmapFactory.Options的inBitmap,这个参数的含义就是,当我们在某块区域加载图片之后,如果图片上滑那么就需要重新加载,那么这个时候就不会重新开辟一块内存空间,而是复用之前的这块区域,所以调用BitmapRegionDecoder的decodeRegion方法,传入需要展示图片的区域,就能够给mutableBitmap赋值,这样就达成了一块内存空间,多次复用的效果。
这样通过压缩之后,在屏幕中展示了这个长图的最上边部分,那么剩下就需要做的是手势事件的处理。
2 大图View的手势事件处理
通过前期的准备工作,我们已经实现了图片的区域展示,那么接下来关键在于,我们通过手势来查看完整的图片,对于手势事件的响应,在onTouch方法中处理。
override fun onTouch(v: View?, event: MotionEvent?): Boolean {
return mGestureDetector.onTouchEvent(event)
}
2.1 GestureDetector
通常来说,手势事件的处理都是通过GestureDetector来完成,因此当onTouch方法监听到手势事件之后,直接传给GestureDetector,让GestureDetector来处理这个事件。
override fun onDown(e: MotionEvent?): Boolean {
return false
}
override fun onShowPress(e: MotionEvent?) {
}
override fun onSingleTapUp(e: MotionEvent?): Boolean {
return false
}
override fun onScroll(
e1: MotionEvent?,
e2: MotionEvent?,
distanceX: Float,
distanceY: Float
): Boolean {
return false
}
override fun onLongPress(e: MotionEvent?) {
}
override fun onFling(
e1: MotionEvent?,
e2: MotionEvent?,
velocityX: Float,
velocityY: Float
): Boolean {
return false
}
首先,我们先看下之前注册的GestureDetector.OnGestureListener监听器中实现的方法:
(1)onDown
override fun onDown(e: MotionEvent?): Boolean {
if(!mScroller.isFinished){
mScroller.forceFinished(true)
}
return true
}
当手指按下时,因为滑动的惯性,所以down事件的处理就是如果图片还在滑动时,按下就停止滑动;
(2)onScroll
那么当你的手指按下之后,可能还会继续滑动,那么就是会回调到onScroll方法,在这个方法中,主要做滑动的处理
override fun onScroll(
e1: MotionEvent?,
e2: MotionEvent?,
distanceX: Float,
distanceY: Float
): Boolean {
mRect.offset(0, distanceY.toInt())
//边界case处理
if (mRect.bottom > imageHeight) {
mRect.bottom = imageHeight
mRect.top = imageHeight - (viewHeight / mScale).toInt()
}
if (mRect.top < 0) {
mRect.top = 0
mRect.bottom = (viewHeight / mScale).toInt()
}
postInvalidate()
return false
}
在onScroll方法中,其实已经对滑动的距离做了计算(这个真的太nice了,不需要我们自己手动计算),因此只需要对mRect展示区域进行变换即可;
但是这里会有两个边界case,例如滑动到底部时就不能再滑了,这个时候,mRect的底部很可能都已经超过了图片的高度,因此需要做边界的处理,那么滑动到顶部的时候同样也是需要做判断。
(3)onFling
惯性滑动。我们在使用列表的时候,我们在滑动的时候,虽然手指的滑动距离很小,但是列表划出去的距离却很大,就是因为惯性,所以GestureDetector中对惯性也做了处理。
override fun onFling(
e1: MotionEvent?,
e2: MotionEvent?,
velocityX: Float,
velocityY: Float
): Boolean {
mScroller.fling(0, mRect.top, 0, -velocityY.toInt(), 0, 0, 0, imageHeight - viewHeight)
return false
}
//计算惯性
override fun computeScroll() {
super.computeScroll()
if (mScroller.isFinished) {
return
}
if (mScroller.computeScrollOffset()) {
//正在滑动
mRect.top = mScroller.currY
mRect.bottom = mScroller.currY + (viewHeight / mScale).toInt()
postInvalidate()
}
}
这个还是比较好理解的,就是设置最大的一个惯性滑动距离,无论怎么滑动,边界值就是从顶部一划到底,这个最大的距离就是 imageHeight - viewHeight
设置了惯性滑动的距离,那么在惯性滑动时,也需要实时改变mRect的解码范围,需要重写computeScroll方法,判断如果是正在滑动(通过 mScroller.computeScrollOffset() 判断),那么需要改变mRect的位置。
2.2 双击放大效果处理
我们在使用app时,双击某张图片或者双指拉动某张图片的时候,都会讲图片放大,这也是业内主流的两种图片放大的方式。
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
//适配
viewWidth = measuredWidth
viewHeight = measuredHeight
//缩放比
val radio = viewWidth / imageWidth.toFloat()
//分块加载首次进入展示的rect
mRect.left = 0
mRect.top = 0
mRect.right = imageWidth
mRect.bottom = viewHeight
}
我们先看一下不能缩放时,mRect的赋值;那么当我们双击放大时,left和top的位置不会变,因为图片放大了,但是控件的大小不会变,因此left的最大值就是控件的宽度,bottom的最大值就是控件的高度。
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
//适配
viewWidth = measuredWidth
viewHeight = measuredHeight
originScale = viewWidth / imageWidth.toFloat()
mScale = originScale
//分块加载首次进入展示的rect
mRect.left = 0
mRect.top = 0
mRect.right = Math.min(imageWidth, viewWidth)
mRect.bottom = Math.min((viewHeight / mScale).toInt(), viewHeight)
}
这里就将onMeasure进行改造;那么对于双击事件的处理,可以使用GestureDetector.OnDoubleTapListener来处理,在onDoubleTap事件中回调。
override fun onDoubleTap(e: MotionEvent?): Boolean {
if (mScale < originScale * 2) {
mScale = originScale * 2
} else {
mScale = originScale
}
postInvalidate()
return false
}
这里做了缩放就是判断mScale的值,因为一开始进来不是缩放的场景,因此 mScale = originScale,当双击之后,需要将mScale扩大2倍,当重新绘制的时候,Bitmap就放大了2倍。
那么当图片放大之后,之前横向不能滑动现在也可以滑动查看图片,所以需要处理,同时也需要考虑边界case
override fun onDoubleTap(e: MotionEvent?): Boolean {
if (mScale < originScale * 2) {
mScale = originScale * 2
} else {
mScale = originScale
}
//
mRect.right = mRect.left + (viewWidth / mScale).toInt()
mRect.bottom = mRect.top + (viewHeight / mScale).toInt()
if (mRect.bottom > imageHeight) {
mRect.bottom = imageHeight
mRect.top = imageHeight - (viewHeight / mScale).toInt()
}
if (mRect.top < 0) {
mRect.top = 0
mRect.bottom = (viewHeight / mScale).toInt()
}
if(mRect.right > imageWidth){
mRect.right = imageWidth
mRect.left = imageWidth - (viewWidth / mScale).toInt()
}
if(mRect.left < 0){
mRect.left = 0
mRect.right = (viewWidth / mScale).toInt()
}
postInvalidate()
return false
}
当双击图片之后,mRect解码的区域也随之改变,因此需要对right和bottom做相应的改变,图片放大或者缩小,都是在控件宽高的基础之上
override fun onScroll(
e1: MotionEvent?,
e2: MotionEvent?,
distanceX: Float,
distanceY: Float
): Boolean {
mRect.offset(distanceX.toInt(), distanceY.toInt())
//边界case处理
if (mRect.bottom > imageHeight) {
mRect.bottom = imageHeight
mRect.top = imageHeight - (viewHeight / mScale).toInt()
}
if (mRect.top < 0) {
mRect.top = 0
mRect.bottom = (viewHeight / mScale).toInt()
}
if(mRect.left < 0){
mRect.left = 0
mRect.right = (viewWidth / mScale).toInt()
}
if(mRect.right > imageWidth){
mRect.right = imageWidth
mRect.left = imageWidth - (viewWidth / mScale).toInt()
}
postInvalidate()
return false
}
因为需要左右滑动,那么onScroll方法也需要做相应的改动,mRect的offset需要加上x轴的偏移量。
2.3 手指放大效果处理
上一小节介绍了双击事件的效果处理,那么这一节就介绍另一个主流的放大效果实现 - 手指缩放,是依赖
ScaleGestureDetector,其实跟GestureDetector的使用方式一致,这里就不做过多的赘述。
mScaleGestureDetector = ScaleGestureDetector(context, ScaleGesture())
复制代码
在初始化ScaleGestureDetector的时候,需要传入一个ScaleGesture内部类,集成ScaleGestureDetector.SimpleOnScaleGestureListener,在onScale方法中获取缩放因子来绘制
inner class ScaleGesture : ScaleGestureDetector.SimpleOnScaleGestureListener() {
override fun onScale(detector: ScaleGestureDetector?): Boolean {
var scale = detector?.scaleFactor ?: mScale//可以代替mScale
if (scale < originScale) {
scale = originScale
} else if (scale > originScale * 2) {
scale = originScale * 2
}
//在原先基础上缩放
mRect.right = mRect.left + (viewWidth / scale).toInt()
mRect.bottom = mRect.top + (viewHeight / scale).toInt()
mScale = scale
postInvalidate()
return super.onScale(detector)
}
}
这里别忘记了别事件传递出来,对于边界case可自行处理
override fun onTouch(v: View?, event: MotionEvent?): Boolean {
mGestureDetector.onTouchEvent(event)
mScaleGestureDetector.onTouchEvent(event)
return true
}
下面附上大图治理的流程图
黄颜色模块: BitmapFactory.Options配置,避免整张大图直接加载在内存当中,通过开启内存复用(inMutable),使用区域解码器,绘制一块可见区域‘
浅黄色模块: View的绘制流程
作者:A帝
链接:https://juejin.cn/post/7131385674694918180
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
分享Kotlin协程在Android中的使用
前言
之前我们学了几个关于协程的基础知识,本文将继续分享Kotlin协程的知识点~挂起,同时介绍协程在Android开发中的使用。
正文
挂起
suspend关键字
说到挂起,那么就会离不开suspend关键字,它是Kotlin中的一个关键字,它的中文意思是暂停或者挂起。
以下是通过suspend修饰的方法:
suspend fun suspendFun(){
withContext(Dispatchers.IO){
//do db operate
}
}
通过suspend关键字来修饰方法,限制这个方法只能在协程里边调用,否则编译不通过。
suspend方法其实本身没有挂起的作用,只是在方法体力便执行真正挂起的逻辑,也相当于提个醒。如果使用suspend修饰的方法里边没有挂起的操作,那么就失去了它的意义,也就是无需使用。
虽然我们无法正常去调用它,但是可以通过反射去调用:
suspend fun hello() = suspendCoroutine<Int> { coroutine ->
Log.i(myTag,"hello")
coroutine.resumeWith(kotlin.Result.success(0))
}
//通过反射来调用:
fun helloTest(){
val helloRef = ::hello
helloRef.call()
}
//抛异常:java.lang.IllegalArgumentException: Callable expects 1 arguments, but 0 were provided.
fun helloTest(){
val helloRef = ::hello
helloRef.call(object : Continuation<Int>{
override val context: CoroutineContext
get() = EmptyCoroutineContext
override fun resumeWith(result: kotlin.Result<Int>) {
Log.i(myTag,"result : ${result.isSuccess} value:${result.getOrNull()}")
}
})
}
//输出:hello
挂起与恢复
看一个方法:
public suspend inline fun <T> suspendCancellableCoroutine(
crossinline block: (CancellableContinuation<T>) -> Unit
): T =
suspendCoroutineUninterceptedOrReturn { uCont ->
val cancellable = CancellableContinuationImpl(uCont.intercepted(), resumeMode = MODE_CANCELLABLE)
block(cancellable)
cancellable.getResult()
}
这是Kotlin协程提供的api,里边虽然只有短短的三句代码,但是实现甚是复杂而且关键。
继续跟进看看getResult()方法:
internal fun getResult(): Any? {
installParentCancellationHandler()
if (trySuspend()) return COROUTINE_SUSPENDED//返回此对象表示挂起
val state = this.state
if (state is CompletedExceptionally) throw recoverStackTrace(state.cause, this)
if (resumeMode == MODE_CANCELLABLE) {//检查
val job = context[Job]
if (job != null && !job.isActive) {
val cause = job.getCancellationException()
cancelResult(state, cause)
throw recoverStackTrace(cause, this)
}
}
return getSuccessfulResult(state)//返回结果
}
最后写一段代码,然后转为Java看个究竟:
fun demo2(){
GlobalScope.launch {
val user = requestUser()
println(user)
val state = requestState()
println(state)
}
}
编译后生成的代码大致流程如下:
public final Object invokeSuspend(Object result) {
...
Object cs = IntrinsicsKt.getCOROUTINE_SUSPENDED();//上面所说的那个Any:CoroutineSingletons.COROUTINE_SUSPENDED
switch (this.label) {
case 0:
this.label = 1;
user = requestUser(this);
if(user == cs){
return user
}
break;
case 1:
this.label = 2;
user = result;
println(user);
state = requestState(this);
if(state == cs){
return state
}
break;
case 2:
state = result;
println(state)
break;
}
}
当协程挂起后,然后恢复时,最终会调用invokeSuspend方法,而协程的block代码会封装在invokeSuspend方法中,使用状态来控制逻辑的实现,并且保证顺序执行。
通过以上我们也可以看出:
- 本质上也是一个回调,Continuation
- 根据状态进行流转,每遇到一个挂起点,就会进行一次状态的转移。
协程在Android中的使用
举个例子,使用两个UseCase来模拟挂起的操作,一个是网络操作,另一个是数据库的操作,然后操作ui,我们分别看一下代码上面的区别。
没有使用协程:
//伪代码
mNetworkUseCase.run(object: Callback {
onSuccess(user: User) {
mDbUseCase.insertUser(user, object: Callback{
onSuccess() {
MainExcutor.excute({
tvUserName.text = user.name
})
}
})
}
})
我们可以看到,用回调的情况下,只要对数据操作稍微复杂点,回调到主线程进行ui操作时,很容易就嵌套了很多层,导致了代码难以看清楚。那么如果使用协程的话,这种代码就可以得到很大的改善。
使用协程:
private fun requestDataUseGlobalScope(){
GlobalScope.launch(Dispatchers.Main){
//模拟从网络获取用户信息
val user = mNetWorkUseCase.requireUser()
//模拟将用户插入到数据库
mDbUseCase.insertUser(user)
//显示用户名
mTvUserName.text = user.name
}
}
对以上函数作说明:
- 通过GlobalScope开启一个顶层协程,并制定调度器为Main,也就是该协程域是在主线程中运行的。
- 从网络获取用户信息,这是一个挂起操作
- 将用户信息插入到数据库,这也是一个挂起操作
- 将用户名字显示,这个操作是在主线程中。
由此在这个协程体中就可以一步一步往下执行,最终达到我们想要的结果。
如果我们需要启动的线程越来越多,可以通过以下方式:
private fun requestDataUseGlobalScope1(){
GlobalScope.launch(Dispatchers.Main){
//do something
}
}
private fun requestDataUseGlobalScope2(){
GlobalScope.launch(Dispatchers.IO){
//do something
}
}
private fun requestDataUseGlobalScope3(){
GlobalScope.launch(Dispatchers.Main){
//do something
}
}
但是平时使用,我们需要注意的就是要在适当的时机cancel掉,所以这时我们需要对每个协程进行引用:
private var mJob1: Job? = null
private var mJob2: Job? = null
private var mJob3: Job? = null
private fun requestDataUseGlobalScope1(){
mJob1 = GlobalScope.launch(Dispatchers.Main){
//do something
}
}
private fun requestDataUseGlobalScope2(){
mJob2 = GlobalScope.launch(Dispatchers.IO){
//do something
}
}
private fun requestDataUseGlobalScope3(){
mJob3 = GlobalScope.launch(Dispatchers.Main){
//do something
}
}
如果是在Activity中,那么可以在onDestroy中cancel掉
override fun onDestroy() {
super.onDestroy()
mJob1?.cancel()
mJob2?.cancel()
mJob3?.cancel()
}
可能你发现了一个问题:如果启动的协程不止是三个,而是更多呢?
没错,如果我们只使用GlobalScope,虽然能够达到我们的要求,但是每次我们都需要去引用他,不仅麻烦,还有一点是它开启的顶层协程,如果有遗漏了,则可能出现内存泄漏。所以我们可以使用kotlin协程提供的一个方法MainScope()来代替它:
private val mMainScope = MainScope()
private fun requestDataUseMainScope1(){
mMainScope.launch(Dispatchers.IO){
//do something
}
}
private fun requestDataUseMainScope2(){
mMainScope.launch {
//do something
}
}
private fun requestDataUseMainScope3(){
mMainScope.launch {
//do something
}
}
可以看到用法基本一样,但有一点很方便当我们需要cancel掉所有的协程时,只需在onDestroy方法cancel掉mMainScope就可以了:
override fun onDestroy() {
super.onDestroy()
mMainScope.cancel()
}
MainScope()方法:
@Suppress("FunctionName")
public fun MainScope(): CoroutineScope = ContextScope(SupervisorJob() + Dispatchers.Main)
使用的是一个SupervisorJob(制定的协程域是单向传递的,父传给子)和Main调度器的组合。
在平常开发中,可以的话使用类似于MainScope来启动协程。
结语
本文的分享到这里就结束了,希望能够给你带来帮助。关于Kotlin协程的挂起知识远不止这些,而且也不容易理清,还是会继续去学习它到底用哪些技术点,深入探究原理究竟是如何。
作者:敖森迪
链接:https://juejin.cn/post/7130427677432872968
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
Flutter中的ValueNotifier和ValueListenableBuilder
在这篇文章中,我们将深入探讨ValueNotifier及其相应的主题。
ValueNotifier简介
ValueNotifier
是继承自ChangeNotifier
的一个类。该类可以保存单个值,并每当其持有值更改时会通知正在监听它的Widget
。ValueNotifier
还是非常有用得,性能高效,因为它只重建使用ValueListenableBuilder
监听它的Widget
。
ValueNotifier使用
将ValueNotifier视为保留值的数据流。我们为它提供一个值,每个监听器都会收到值变化的通知。
我们可以创建任何类型的int、bool、list或任何自定义数据类型的ValueNotifier。您可以像这样创建一个ValueNotifier对象:
ValueNotifier<int> counter = ValueNotifier<int>(0);
我们可以像这样更新值:
counter.value = counter.value++;
//或者
counter.value++;
此外,我们可以像这样监听ValueNotifier
:
counter.addListener((){
print(counter.value);
});
删除值通知监听器
如果我们手动监听ValueNotifier
,当前页面上不使用时,我们可以使用removeListener
函数从ValueNotifier
中手动删除侦听器。
ValueNotifier<int> valueNotifier = ValueNotifier(0);
void remove() {
valueNotifier.removeListener(doTaskWhenNotified);
}
void add(){
valueNotifier.addListener(doTaskWhenNotified);
}
void doTaskWhenNotified() {
print(valueNotifier.value);
}
释放ValueNotifier
当不再使用时调用dispose
方法是一个良好做法,否则可能会导致内存泄漏。ValueNotifier
上的dispose
方法将释放任何订阅的监听器。
@override
void dispose() {
counter.dispose();
super.dispose();
}
什么是ValueListenableBuilder?
Flutter中有许多类型的构建器,如StreamBuilder
、AnimatedBuilder
、FutureBuilder
等,他们的名字表明他们是消费的对象类型。ValueListenableBuilder
使用ValueNotifier
对象,如果我们想在Widget
中的监听某一个值,我们可以使用ValueListenableBuilder
,每次我们收到值更新时,都会执行构建器方法。当我们路由到另一个页面时,ValueListenableBuilder
会自动在内部删除监听。
const ValueListenableBuilder({
required this.valueListenable,
required this.builder,
this.child,
})
这是ValueListenableBuilder
的构造函数。在这里,valueListenable
是要收听的ValueNotifier
。构建器函数接收3个参数(BuildContext context, dynamic value, Widget child)
,该value
是从提供的valueNotifier
收到的数据。可以使用子参数。如果子构建成本高,并且不依赖于通知符的值,我们将使用它进行优化。
使用Value Notifier的计数器应用程序
使用ValueNotifer
和ValueListenableBuilder
的计数器应用程序,这里没有使用setState
,当值发生改变的时候,我们只重建文本部分。
import 'package:flutter/material.dart';
void main() {
runApp(const App());
}
class App extends StatelessWidget {
const App({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return const MaterialApp(
home: HomePage(),
);
}
}
class HomePage extends StatefulWidget {
const HomePage({Key? key}) : super(key: key);
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
final _counterNotifier = ValueNotifier<int>(0);
@override
Widget build(BuildContext context) {
print('HOMEPAGE BUILT');
return Scaffold(
appBar: AppBar(title: const Text('Counter App')),
body: Center(
child: ValueListenableBuilder(
valueListenable: _counterNotifier,
builder: (context, value, _) {
return Text('Count: $value');
},
),
),
floatingActionButton: FloatingActionButton(
onPressed: () {
_counterNotifier.value++;
},
child: const Icon(Icons.add),
),
);
}
@override
void dispose() {
_counterNotifier.dispose();
super.dispose();
}
}
作者:Eau_z
链接:https://juejin.cn/post/7132000226331590692
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
Java四大引用详解:强引用、软引用、弱引用、虚引用
面试官考察Java引用会问到强引用、弱引用、软引用、虚引用,具体有什么区别?本篇单独来详解 @mikechen
Java引用
从JDK 1.2版本开始,对象的引用被划分为4种级别,从而使程序能更加灵活地控制对象的生命周期,这4种级别由高到低依次为:强引用、软引用、弱引用和虚引用。
强引用
强引用是最普遍的引用,一般把一个对象赋给一个引用变量,这个引用变量就是强引用。
比如:
// 强引用
MikeChen mikechen=new MikeChen();
在一个方法的内部有一个强引用,这个引用保存在Java栈中,而真正的引用内容(MikeChen)保存在Java堆中。
如果一个对象具有强引用,垃圾回收器不会回收该对象,当内存空间不足时,JVM 宁愿抛出 OutOfMemoryError异常。
如果强引用对象不使用时,需要弱化从而使GC能够回收,如下:
//帮助垃圾收集器回收此对象
mikechen=null;
显式地设置mikechen对象为null,或让其超出对象的生命周期范围,则GC认为该对象不存在引用,这时就可以回收这个对象,具体什么时候收集这要取决于GC算法。
举例:
package com.mikechen.java.refenence;
/**
* 强引用举例
*
* @author mikechen
*/
public class StrongRefenenceDemo {
public static void main(String[] args) {
Object o1 = new Object();
Object o2 = o1;
o1 = null;
System.gc();
System.out.println(o1); //null
System.out.println(o2); //java.lang.Object@2503dbd3
}
}
StrongRefenenceDemo 中尽管 o1已经被回收,但是 o2 强引用 o1,一直存在,所以不会被GC回收。
软引用
软引用是一种相对强引用弱化了一些的引用,需要用java.lang.ref.SoftReference 类来实现。
比如:
String str=new String("abc"); // 强引用
SoftReference<String> softRef=new SoftReference<String>(str); // 软引用
如果一个对象只具有软引用,则内存空间足够,垃圾回收器就不会回收它,如果内存空间不足了,就会回收这些对象的内存。
先通过一个例子来了解一下软引用:
/**
* 弱引用举例
*
* @author mikechen
*/
Object obj = new Object();
SoftReference softRef = new SoftReference<Object>(obj);//删除强引用
obj = null;//调用gc
// 对象依然存在
System.gc();System.out.println("gc之后的值:" + softRef.get());
软引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用对象被垃圾回收,Java虚拟机就会把这个软引用加入到与之关联的引用队列中。
ReferenceQueue<Object> queue = new ReferenceQueue<>();
Object obj = new Object();
SoftReference softRef = new SoftReference<Object>(obj,queue);//删除强引用
obj = null;//调用gc
System.gc();
System.out.println("gc之后的值: " + softRef.get()); // 对象依然存在
//申请较大内存使内存空间使用率达到阈值,强迫gc
byte[] bytes = new byte[100 * 1024 * 1024];//如果obj被回收,则软引用会进入引用队列
Reference<?> reference = queue.remove();if (reference != null){
System.out.println("对象已被回收: "+ reference.get()); // 对象为null
}
软引用通常用在对内存敏感的程序中,比如高速缓存就有用到软引用,内存够用的时候就保留,不够用就回收。
我们看下 Mybatis 缓存类 SoftCache 用到的软引用:
public Object getObject(Object key) {
Object result = null;
SoftReference<Object> softReference = (SoftReference)this.delegate.getObject(key);
if (softReference != null) {
result = softReference.get();
if (result == null) {
this.delegate.removeObject(key);
} else {
synchronized(this.hardLinksToAvoidGarbageCollection) {
this.hardLinksToAvoidGarbageCollection.addFirst(result);
if (this.hardLinksToAvoidGarbageCollection.size() > this.numberOfHardLinks) {
this.hardLinksToAvoidGarbageCollection.removeLast();
}
}
}
}
return result;}
注意:软引用对象是在jvm内存不够的时候才会被回收,我们调用System.gc()方法只是起通知作用,JVM什么时候扫描回收对象是JVM自己的状态决定的,就算扫描到软引用对象也不一定会回收它,只有内存不够的时候才会回收。
弱引用
弱引用的使用和软引用类似,只是关键字变成了 WeakReference:
MikeChen mikechen = new MikeChen();
WeakReference<MikeChen> wr = new WeakReference<MikeChen>(mikechen );
弱引用的特点是不管内存是否足够,只要发生 GC,都会被回收。
举例说明:
package com.mikechen.java.refenence;
import java.lang.ref.WeakReference;
/**
* 弱引用
*
* @author mikechen
*/
public class WeakReferenceDemo {
public static void main(String[] args) {
Object o1 = new Object();
WeakReference<Object> w1 = new WeakReference<Object>(o1);
System.out.println(o1);
System.out.println(w1.get());
o1 = null;
System.gc();
System.out.println(o1);
System.out.println(w1.get());
}
}
弱引用的应用
WeakHashMap
public class WeakHashMapDemo {
public static void main(String[] args) throws InterruptedException {
myHashMap();
myWeakHashMap();
}
public static void myHashMap() {
HashMap<String, String> map = new HashMap<String, String>();
String key = new String("k1");
String value = "v1";
map.put(key, value);
System.out.println(map);
key = null;
System.gc();
System.out.println(map);
}
public static void myWeakHashMap() throws InterruptedException {
WeakHashMap<String, String> map = new WeakHashMap<String, String>();
//String key = "weak";
// 刚开始写成了上边的代码
//思考一下,写成上边那样会怎么样? 那可不是引用了
String key = new String("weak");
String value = "map";
map.put(key, value);
System.out.println(map);
//去掉强引用
key = null;
System.gc();
Thread.sleep(1000);
System.out.println(map);
}}
当key只有弱引用时,GC发现后会自动清理键和值,作为简单的缓存表解决方案。
ThreadLocal
static class ThreadLocalMap {
static class Entry extends WeakReference<ThreadLocal<?>> {
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
//......}
ThreadLocal.ThreadLocalMap.Entry 继承了弱引用,key为当前线程实例,和WeakHashMap基本相同。
虚引用
虚引用”顾名思义,就是形同虚设,与其他几种引用都不同,虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收器回收。
虚引用也称为“幽灵引用”或者“幻影引用”,它是最弱的一种引用关系。
虚引用需要java.lang.ref.PhantomReference 来实现:
A a = new A();
ReferenceQueue<A> rq = new ReferenceQueue<A>();
PhantomReference<A> prA = new PhantomReference<A>(a, rq);
复制代码
虚引用主要用来跟踪对象被垃圾回收器回收的活动。
虚引用与软引用和弱引用的一个区别在于:虚引用必须和引用队列 (ReferenceQueue)联合使用,当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之 关联的引用队列中。
Java引用总结
java4种引用的级别由高到低依次为:强引用 > 软引用 > 弱引用 > 虚引用。
以上
作者:mikechen的互联网架构
链接:https://juejin.cn/post/7131175540874018830
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
神奇的共享内存
前言
共享内存(shared memory)是最常见的ipc进程之间通讯的方式之一了,很多linux书籍上,都将共享内存评价为“最有用的ipc机制”,就连Binder机制盛行的android体系,同样也离不开共享内存的应用!在所以ipc方式中,共享内存以“快”赢得了很多开发者的掌声,我们下面深入看看!
共享内存相关函数
首先讲到共享内存,那么肯定离不开要介绍几个函数
shmget
int shmget(key_t key, size_t size, int shmflg);
shmget函数用来获取一个内存区的ipc标识,这个标识在内核中,属于一个身份标识符号(ipc标识符,正常情况下是不会重复的,但是标识符也有限制的,比如linux2.4最大为32768,用完了就会重新计算),通过shmget调用,会返回给我们当前的ipc标识,如果这个共享内存区本来就不存在,就直接创建,否则就把当前标识直接返回给我们!说了一大堆,其实很简单,就相当于给我们返回了一个代表该共享内存的标识罢了!
shmat
void *shmat(int shmid, const void *shmaddr, int shmflg);
shmat把一个共享内存区域添加到进程上,我们之前在mmap这一章节有提到过线性区的概念,就是进程可用的一组地址(可以用,但是用的时候才真正分配),而shmat就把共享内存的这块地址,通过(shmid shmget可以获取到的)放到了进程中的可用地址范围内,用范围内的合适地址(shmaddr这里指进程想要发生映射的可用地址)指向了共享内存实际的地址,可以见上图!
shmdt
int shmdt(const void *shmaddr);
用于从当前进程把指定的共享内存shmaddr地址分离出去,这里只是分离,只是从当前进程中不可见了,但是对于其他进程来说,还是依旧存在的,再拿上面的图举例子,如果进程1中调用了shmadt,那么当前状态就如下图所示
同时这里有个非常需要注意的点,就是就算共享内存没有被其他任何进程使用,它所占有的页也是不能直接被删除的,只能用“页的换出”操作代替不用的页(留个疑问,后文解析)
当然,为了避免用户态过程中共享内存的过分创建,一般的限制大小为4096个
共享内存本质
看到这里的朋友,包括我,一定会想问,共享内存最本质是个什么东西呀?为什么linux会创建处理这么一个神奇的东西?在这里我可以告诉大家,共享内存其实就是一个“文件”!不光如此,我们所熟知的ipc方式,比如管道,消息队列,共享内存,其实就是对文件的操作!我的天,我们嗤之以鼻的“文件”,最不起眼不被用的ipc方式,只是换了个名称,就让大家高攀不起了!是的,共享内存的本质,其实就是shm特殊文件系统的一个文件罢了!因为shm文件系统在linux系统中没有安装点,即没有可视化的文件路径,普通用户无法“看到”或者“摸到”,就给我们产生了一个错觉,以为是一个很高深的东西,其实并没有啦!一个共享内存,其实就是一个文件,只不过这个文件我们看不到罢了,但是linux内核能看到,就这么简单!(以后面试官问到ipc有哪些,回答“文件”即可哈哈哈,手动狗头)
那么接下来又有一个问题了,为什么一个文件能有这么大的奇效,我们常说的共享内存只需要一次拷贝(假如进程a写入到进程b可见算一次)呀,面试官还经常问我们呢!一个小小文件怎么做到的?没错,没错!就是mmap搞得鬼呀!属于共享内存的这个文件,在进程中其实就是使用了mmap操作,把进程的地址映射到了这个文件,所以写入一次就对其他同样进行mmap的进程可见罢了!这个mmap,是通过shm_mmap函数实现的(细节可看官网,这里就不贴出来了)最后我们再看一下共享内存的核心数据结构,shmid_kernel
struct shmid_kernel /* private to the kernel */
{
struct kern_ipc_perm shm_perm; //描述进程间通信许可的结构
struct file * shm_file; //指向共享内存文件的指针
unsigned long shm_nattch; //挂接到本段共享内存的进程数
unsigned long shm_segsz; //段大小
time_t shm_atim; //最后挂接时间
time_t shm_dtim; //最后解除挂接时间
time_t shm_ctim; //最后变化时间
pid_t shm_cprid; //创建进程的PID
pid_t shm_lprid;//最后使用进程的PID
....
};
共享内存页回收问题
我们刚刚留下了一个疑问点,就是共享内存的页就算没有进程引用,也不能被直接删除,而是采用换出的方式!为什么不能被删除呢?因为在正常情况下,linux内核中对于页删除有比较严格的判断,页被删除的前提需要页被标记被脏,触发磁盘写回的操作,然后才会从删除这个页!但是共享内存的页其实在磁盘上是没有存在映射的索引节点的,因此写回磁盘这个操作前提就不成立,所以正常的处理是这个页会被保留,但是页的内容会被其他有需要的页的“伙伴”被复用,做到只是数据的删除页不删除!这是需要注意的点!当然,在紧急内存不足的情况下,系统也会调用try_to_swap_out方法,回收一般页,但是共享内存的页会有定制的shmem_write_page,会进行页的copy操作,防止了属于共享内存的页被“直接删除”。
Android中的共享内存
Android中也有很多地方用到了共享内存,比如ContentProvider中数据的交换,比如CursorWindow的数据交换,里面其实就是利用了共享内存。还有就是传递给SurfaceFlinger的渲染数据,也就是通过共享内存完成的。之所以使用共享内存,还是得益于共享内存的设计,效率较高且没有像管道这种多拷贝的情况,不使用Binder是也是因为Binder依赖的Parcel数据传输,在大数据上并没有很大的优势!当然,相比于Binder,共享内存算是作为最底层api,并没有提供同步机制!当然,Binder同时也用了mmap(binder_mmap),在这基础上通过mutex_lock进行了同步机制,算是比共享内存有了更加契合Android的设计
总结
看完这里,应该都会用共享内存进行我们所需的开发了,无论是Binder还是共享内存,只有在合适自己的场合使用,才能获得最大收益!最后!
作者:Pika
链接:https://juejin.cn/post/7131333107696795678
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
Transform 被废弃,TransformAction 了解一下~
前言
Transform API
是 AGP1.5
就引入的特性,主要用于在 Android
构建过程中,在 Class
转Dex
的过程中修改 Class
字节码。利用 Transform API
,我们可以拿到所有参与构建的 Class
文件,然后可以借助ASM
等字节码编辑工具进行修改,插入自定义逻辑。
国内很多团队都或多或少的用 AGP
的 Transform API
来搞点儿黑科技,比如无痕埋点,耗时统计,方法替换等。但是在AGP7.0
中Transform
已经被标记为废弃了,并且将在AGP8.0
中移除。
而Transrom
被废弃之后,它的代替品则是Transform Action
,它是由Gradle
提供的产物变换API
。
Transform API
是由AGP
提供的,而Transform Action
则是由Gradle提供。不光是 AGP
需要 Transform
,Java
也需要,所以由 Gradle
来提供统一的 Transform API
也合情合理。
当然如果你只是想利用ASM
对字节码插桩,AGP
提供了对基于TransformAction
的ASM
插桩的封装,只需要使用AsmClassVisitorFactory
即可,关于具体的使用可见:Transform 被废弃,ASM 如何适配?
而本文主要包括以下内容:
TransformAction
是什么?- 如何自定义
TransformAction
TransformAction
在AGP
中的应用
TransformAction
是什么?
简单来说,TransformAction
就是Gradle
提供的产物转换API
,可以注册两个属性间的转换Action
,将依赖从一个状态切换到另一个状态
我们在项目中的依赖,可能会有多个变体,例如,一个依赖可能有以下两种变体:classes
( org.gradle.usage=java-api
, org.gradle.libraryelements=classes
)或JAR
( org.gradle.usage=java-api, org.gradle.libraryelements=jar
)
它们的主要区别就在于,一个的产物是jar
,一个则是classes
(类目录)
当Gradle
解析配置时,解析的配置上的属性将确定请求的属性,并选中匹配属性的变体。例如,当配置请求org.gradle.usage=java-api, org.gradle.libraryelements=classes
时,就会选择classes
目录作为输入。
但是如果依赖项没有所请求属性的变体,那么解析配置就会失败。有时我们可以将依赖项的产物转换为请求的变体。
例如,解压缩Jar
的TranformAction
会将 java-api
,jars
转换为java-api,classes
变体。
这种转换可以对依赖的产物进行转换,所以称为“产物转换” 。Gradle
允许注册产物转换,并且当依赖项没有所请求的变体时,Gradle
将尝试查找一系列产物转换以创建变体。
TransformAction
选择和执行逻辑
如上所述,当Gradle
解析配置并且配置中的依赖关系不具有带有所请求属性的变体时,Gradle
会尝试查找一系列TransformAction
以创建变体。
每个注册的转换都是从一组属性转换为一组属性。例如,解压缩转换可以从org.gradle.usage=java-api, org.gradle.libraryelements=jars
转换至org.gradle.usage=java-api, org.gradle.libraryelements=classes
。
为了找到一条这样的链,Gradle
从请求的属性开始,然后将所有修改某些请求的属性的TransformAction
视为通向那里的可能路径。
例如,考虑一个minified
属性,它有两个值: true
和false
。minified
属性表示是否删除了不必要的类文件。
如果我们的依赖只有minified=false
的变体,并且我们的配置中请求了minified=true
的属性,如果我们注册了minify
的转换,那么它就会被选中
在找到的所有变换链中,Gradle
尝试选择最佳的变换链:
- 如果只有一个转换链,则选择它。
- 如果有两个变换链,并且一个是另一个的后缀,则将其选中。
- 如果存在最短的变换链,则将其选中。
- 在所有其他情况下,选择将失败并报告错误。
同时还有两个特殊情况:
- 当已经存在与请求属性匹配的依赖项变体时,
Gradle
不会尝试选择产物转换。 artifactType
属性是特殊的,因为它仅存在于解析的产物上,而不存在于依赖项上。因此任何只变换artifactType
的TransformAction
,只有在使用ArtifactView时才会考虑使用
自定义TransformAction
下面我们就以自定义一个MinifyTransform
为例,来看看如何自定义TransformAction
,主要用于过滤产物中不必要的文件
定义TransformAction
abstract class Minify : TransformAction<Minify.Parameters> { // (1)
interface Parameters : TransformParameters { // (2)
@get:Input
var keepClassesByArtifact: Map<String, Set<String>>
}
@get:PathSensitive(PathSensitivity.NAME_ONLY)
@get:InputArtifact
abstract val inputArtifact: Provider<FileSystemLocation>
override
fun transform(outputs: TransformOutputs) {
val fileName = inputArtifact.get().asFile.name
for (entry in parameters.keepClassesByArtifact) { // (3)
if (fileName.startsWith(entry.key)) {
val nameWithoutExtension = fileName.substring(0, fileName.length - 4)
minify(inputArtifact.get().asFile, entry.value, outputs.file("${nameWithoutExtension}-min.jar"))
return
}
}
println("Nothing to minify - using ${fileName} unchanged")
outputs.file(inputArtifact) // (4)
}
private fun minify(artifact: File, keepClasses: Set<String>, jarFile: File) {
println("Minifying ${artifact.name}")
// Implementation ...
}
}
代码很简单,主要分为以下几步:
- 实现
TransformAction
接口并声明参数类型 - 实现参数接口,实现自定义参数
- 获取输入并实现
transform
逻辑 - 输出变换结果,当不需要变换时直接将输入作为变换结果
其实一个TransformAction
主要就是输入,输出,变换逻辑三个部分
注册TransformAction
接下来就是注册了,您需要注册TransformAction
,并在必要时提供参数,以便在解析依赖项时可以选择它们
val artifactType = Attribute.of("artifactType", String::class.java)
val minified = Attribute.of("minified", Boolean::class.javaObjectType)
val keepPatterns = mapOf(
"guava" to setOf(
"com.google.common.base.Optional",
"com.google.common.base.AbstractIterator"
)
)
dependencies {
attributesSchema {
attribute(minified) // <1>
}
artifactTypes.getByName("jar") {
attributes.attribute(minified, false) // <2>
}
}
configurations.all {
afterEvaluate {
if (isCanBeResolved) {
attributes.attribute(minified, true) // <3>
}
}
}
dependencies { // (4)
implementation("com.google.guava:guava:27.1-jre")
implementation(project(":producer"))
}
dependencies {
registerTransform(Minify::class) { // <5>
from.attribute(minified, false).attribute(artifactType, "jar")
to.attribute(minified, true).attribute(artifactType, "jar")
parameters {
keepClassesByArtifact = keepPatterns
// Make sure the transform executes each time
timestamp = System.nanoTime()
}
}
}
tasks.register<Copy>("resolveRuntimeClasspath") {
from(configurations.runtimeClasspath)
into(layout.buildDirectory.dir("runtimeClasspath"))
}
注册TransformAction
也分为以下几步:
- 添加
minified
属性 - 将所有JAR文件的
minified
属性设置为false
- 在所有可解析的配置上设置请求的属性为
minified=true
- 添加将要转换的依赖项
- 注册
Transformaction
,设置from
与to
的属性,并且传递自定义参数
运行TransformAction
在定义与注册了TransformAction
之后,下一步就是运行了
上面我们自定义了resolveRuntimeClasspath
的Task
,Minify
转换会在我们请求minified=true
的变体时调用
当我们运行gradle resolveRuntimeClasspath
时就可以得到如下输出
> Task :resolveRuntimeClasspath
Nothing to minify - using jsr305-3.0.2.jar unchanged
Minifying guava-27.1-jre.jar
Nothing to minify - using failureaccess-1.0.1.jar unchanged
Nothing to minify - using listenablefuture-9999.0-empty-to-avoid-conflict-with-guava.jar unchanged
Nothing to minify - using j2objc-annotations-1.1.jar unchanged
Nothing to minify - using checker-qual-2.5.2.jar unchanged
Nothing to minify - using error_prone_annotations-2.2.0.jar unchanged
Nothing to minify - using animal-sniffer-annotations-1.17.jar unchanged
可以看出,当我们执行task
的时候,gradle
自动调用了TransformAction
,对guava.jar
进行了变换,并将结果存储在layout.buildDirectory.dir("runtimeClasspath")
中
变换ArtifactType
的TransformAction
上文提到,artifactType
属性是特殊的,因为它仅存在于解析的产物上,而不存在于依赖项上。因此任何只变换artifactType
的TransformAction
,只有在使用ArtifactView时才会考虑使用
其实在AGP
中,相当一部分自定义TransformAction
都是属于只变换ArtifactType
的,下面我们来看下如何自定义一个这样的TransformAction
class TransformActionPlugin : Plugin<Project> {
override fun apply(project: Project) {
project.run {
val artifactType = Attribute.of("artifactType", String::class.java)
dependencies.registerTransform(MyTransform::class.java) { // 1
it.from.attribute(artifactType, "jar")
it.to.attribute(artifactType, "my-custom-type")
}
val myTaskProvider = tasks.register("myTask", MyTask::class.java) {
it.inputCount.set(10)
it.outputFile.set(File("build/myTask/output/file.jar"))
}
val includedConfiguration = configurations.create("includedConfiguration") // 2
dependencies.add(includedConfiguration.name, "org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.10")
val combinedInputs = project.files(includedConfiguration, myTaskProvider.map { it.outputFile })
val myConfiguration = configurations.create("myConfiguration")
dependencies.add(myConfiguration.name, project.files(project.provider { combinedInputs }))
tasks.register("consumerTask", ConsumerTask::class.java) { // 3
it.artifactCollection = myConfiguration.incoming.artifactView {viewConfiguration ->
viewConfiguration.attributes.attribute(artifactType, "my-custom-type")
}.artifacts
it.outputFile.set(File("build/consumerTask/output/output.txt"))
}
}
}
}
主要分为以下几步:
- 声明与注册自定义
Transform
,指定输入与输出的artifactType
- 创建自定义的
configuration
,指定输入的依赖是什么(当然也可以直接用AGP
已有的configuration
) - 在使用时,通过自定义
configuration
的artifactView
,获取对应的产物 ConsumerTask
中消费自定义TransformAction
的输出产物
然后我们运行./gradlew consumerTask
就可以得到以下输出
> Task :app:consumerTask
Processing ~/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-stdlib-common/1.7.10/bac80c520d0a9e3f3673bc2658c6ed02ef45a76a/kotlin-stdlib-common-1.7.10.jar. File exists = true
Processing ~/AndroidProject/2022/argust/GradleTutorials/app/build/myTask/output/file.jar. File exists = true
Processing ~/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-stdlib-jdk8/1.7.10/d70d7d2c56371f7aa18f32e984e3e2e998fe9081/kotlin-stdlib-jdk8-1.7.10.jar. File exists = true
Processing ~/.gradle/caches/modules-2/files-2.1/org.jetbrains/annotations/13.0/919f0dfe192fb4e063e7dacadee7f8bb9a2672a9/annotations-13.0.jar. File exists = true
Processing ~/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-stdlib/1.7.10/d2abf9e77736acc4450dc4a3f707fa2c10f5099d/kotlin-stdlib-1.7.10.jar. File exists = true
Processing ~/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-stdlib-jdk7/1.7.10/1ef73fee66f45d52c67e2aca12fd945dbe0659bf/kotlin-stdlib-jdk7-1.7.10.jar. File exists = true
可以看出,当运行consumerTask
时,执行了 MyTransform
,并将jar
类型的产物转化成了my-custom-type
TransformAction
在AGP
中的应用
现在AGP
中的Transform
已经基本上都改成TransformAction
了,我们一起来看几个例子
AarTransform
Android ARchive
,也就是.aar
后缀的资源包,gradle
是如何使用它的呢?
如果有同学尝试过就知道,如果是默认使用java-libray
的工程,肯定无法依赖并使用aar
的,引入时会报Could not resolve ${dependencyNotation}
,说明在Android Gradle Plugin
当中,插件对aar
包的依赖进行了处理,只有通过了插件处理,才能正确使用aar
内的资源。那就来看看AGP
是如何在TransformAction
的帮助下做到这点的
Aar
转换的实现就是AarTransform
,我们一起来看下源码:
// DependencyConfigurator.kt
for (transformTarget in AarTransform.getTransformTargets()) {
registerTransform(
AarTransform::class.java,
AndroidArtifacts.ArtifactType.EXPLODED_AAR,
transformTarget
) { params ->
params.targetType.setDisallowChanges(transformTarget)
params.sharedLibSupport.setDisallowChanges(sharedLibSupport)
}
}
public abstract class AarTransform implements TransformAction<AarTransform.Parameters> {
@NonNull
public static ArtifactType[] getTransformTargets() {
return new ArtifactType[] {
ArtifactType.SHARED_CLASSES,
ArtifactType.JAVA_RES,
ArtifactType.SHARED_JAVA_RES,
ArtifactType.PROCESSED_JAR,
ArtifactType.MANIFEST,
ArtifactType.ANDROID_RES,
ArtifactType.ASSETS,
ArtifactType.SHARED_ASSETS,
ArtifactType.JNI,
ArtifactType.SHARED_JNI,
// ...
};
}
@Override
public void transform(@NonNull TransformOutputs transformOutputs) {
// 具体实现
}
代码也比较简单,主要做了下面几件事:
- 在
DependencyConfigurator
中注册Aar
转换成各种类型资源的TransformAction
- 在
AarTransform
中根据类型将aar
包中的文件解压到输出到各个目录
JetifyTransform
Jetifier
也是在迁移到AndroidX
之后的常用功能,它可以将引用依赖内的android.support.*
引用都替换为对androidx
的引用,从而实现对support
包的兼容
下面我们来看一下JetifyTransform
的代码
// com.android.build.gradle.internal.DependencyConfigurator
if (projectOptions.get(BooleanOption.ENABLE_JETIFIER)) {
registerTransform(
JetifyTransform::class.java,
AndroidArtifacts.ArtifactType.AAR,
jetifiedAarOutputType
) { params ->
params.ignoreListOption.setDisallowChanges(jetifierIgnoreList)
}
registerTransform(
JetifyTransform::class.java,
AndroidArtifacts.ArtifactType.JAR,
AndroidArtifacts.ArtifactType.PROCESSED_JAR
) { params ->
params.ignoreListOption.setDisallowChanges(jetifierIgnoreList)
}
}
// com.android.build.gradle.internal.dependency.JetifyTransform
override fun transform(transformOutputs: TransformOutputs) {
val inputFile = inputArtifact.get().asFile
val outputFile = transformOutputs.file("jetified-${inputFile.name}")
jetifierProcessor.transform2(
input = setOf(FileMapping(inputFile, outputFile)),
copyUnmodifiedLibsAlso = true,
skipLibsWithAndroidXReferences = true
)
}
- 读取并判断
ENABLE_JETIFIER
属性,这就是我们在gradle.properties
中配置的jetifier
开关 - 为
aar
和jar
类型的依赖都注册JetifyTransform
转换 - 在
transform
中对support
包的依赖进行替换,完成后会将处理过的资源重新压缩,并且会带上jetified
的前缀
总结
本文主要讲解了TransformAction
是什么,TransformAction
自定义,以及TransformAction
在AGP
中的应用,可以看出,目前AGP
中的产物转换已经基本上都用TransformAction
来实现了
事实上,AGP
对TransformAction
进行了一定的封装,如果你只是想利用ASM
实现字节码插桩,那么直接使用AsmClassVisitorFactory
就好了。但如果想要阅读AGP
的源码,了解AGP
构建的过程,还是需要了解一下TransformAction
的基本使用与原理的
示例代码
本文所有代码可见:github.com/RicardoJian…
作者:程序员江同学
链接:https://juejin.cn/post/7131889789787176974
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
使用 Kotlin 对 XML 文件解析、修改及创建
一 XML 基本概念
XML 全称 ExtensibleMarkupLanguage,中文称可扩展标记语言。它是一种通用的数据交换格式,具有平台无关性、语言无关性、系统无关性的优点,给数据集成与交互带来了极大的方便。XML 在不同的语言环境中解析方式都是一样的,只不过实现的语法不同而已。
XML 可用来描述数据、存储数据、传输数据/交换数据。
XML 文档形成了一种树结构,它从"根部"开始,然后扩展到"枝叶"。DOM 又是基于树形结构的 XML 解析方式,能很好地呈现这棵树的样貌。XML 文档节点的类型主要有:
各节点定义:
Node | 描述 | 子节点 |
---|---|---|
Document | XML document 的根节点 | Element, ProcessingInstruction, DocumentType, Comment |
DocumentType | 文档属性 | No children |
Element | 元素 | Element, Text, Comment, ProcessingInstruction, CDATASection, EntityReference |
Attr | 属性 | Text, EntityReference |
ProcessingInstruction | 处理指令 | No children |
Comment | 注释 | No children |
Text | 文本 | No children |
Entity | 实体类型项目 | Element, Text, Comment, ProcessingInstruction, CDATASection, EntityReference |
二 XML 解析方式
一个 XML 文档的生命周期应该包括两部分:
- 解析文档
- 操作文档数据
那么接下来介绍如何来解析 XML 以及解析之后如何使用。
根据底层原理的不同,解析 XML 文件一般分为两种形式,一种是基于树形结构来解析的称为 DOM;另一种是基于事件流的形式称为 SAX。
2.1 DOM(Document Object Model)
DOM 是用与平台和语言无关的方式表示 XML 文档的官方 W3C 标准。是基于树形结构的 XML 解析方式,它会将整个 XML 文档读入内存并构建一个 DOM 树,基于这棵树形结构对各个节点(Node)进行操作。
优点:
- 允许随机读取访问数据,因为整个 Dom 树都加载到内存中
- 允许随机的对文档结构进行增删
缺点:
- 耗时,整个 XML 文档必须一次性解析完
- 占内存,整个 Dom 树都要加载到内存中
适用于:文档较小,且需要修改文档内容
2.1.1 DOM 解析 XML
第一步:建立一个 Stuff.xml 文件
<?xml version="1.0"?>
<company>
<staff id="1001">
<firstname>Jack</firstname>
<lastname>Ma</lastname>
<nickname>Hui Chuang A Li</nickname>
<salary currency="USD">100000</salary>
</staff>
<staff id="2001">
<firstname>Pony</firstname>
<lastname>Ma</lastname>
<nickname>Pu Tong Jia Ting</nickname>
<salary currency="RMB">200000</salary>
</staff>
</company>
第二步:DOM 解析
package com.elijah.kotlinlearning
import org.w3c.dom.Element
import org.w3c.dom.Node
import org.w3c.dom.NodeList
import java.io.File
import javax.xml.parsers.DocumentBuilderFactory
fun main(args: Array<String>) {
// Instantiate the Factory
val dbf = DocumentBuilderFactory.newInstance()
try {
// parse XML file
val xlmFile = File("${projectPath}/src/res/Staff.xml")
val xmlDoc= dbf.newDocumentBuilder().parse(xlmFile)
xmlDoc.documentElement.normalize()
println("Root Element :" + xmlDoc.documentElement.nodeName)
println("--------")
// get <staff>
val staffList: NodeList = xmlDoc.getElementsByTagName("staff")
for (i in 0 until staffList.length) {
var staffNode = staffList.item(i)
if (staffNode.nodeType === Node.ELEMENT_NODE) {
val element = staffNode as Element
// get staff's attribute
val id = element.getAttribute("id")
// get text
val firstname = element.getElementsByTagName("firstname").item(0).textContent
val lastname = element.getElementsByTagName("lastname").item(0).textContent
val nickname = element.getElementsByTagName("nickname").item(0).textContent
val salaryNodeList = element.getElementsByTagName("salary")
val salary = salaryNodeList.item(0).textContent
// get salary's attribute
val currency = salaryNodeList.item(0).attributes.getNamedItem("currency").textContent
println("Current Element : ${staffNode.nodeName}")
println("Staff Id : $id")
println("First Name: $firstname")
println("Last Name: $lastname")
println("Nick Name: $nickname")
println("Salary [Currency] : ${salary.toLong()} [$currency]")
}
}
} catch (e: Throwable) {
e.printStackTrace()
}
}
第三步:解析结果输出
Root Element :company
--------
Current Element : staff
Staff Id : 1001
First Name: Jack
Last Name: Ma
Nick Name: Hui Chuang A Li
Salary [Currency] : 100000 [USD]
Current Element : staff
Staff Id : 2001
First Name: Pony
Last Name: Ma
Nick Name: Pu Tong Jia Ting
Salary [Currency] : 200000 [RMB]
2.1.2 DOM 创建、生成 XML
第一步:创建新的 XML 并填充内容
package com.elijah.kotlinlearning
import org.w3c.dom.Document
import org.w3c.dom.Element
import java.io.File
import javax.xml.parsers.DocumentBuilderFactory
import javax.xml.transform.OutputKeys
import javax.xml.transform.TransformerFactory
import javax.xml.transform.dom.DOMSource
import javax.xml.transform.stream.StreamResult
fun main(args: Array<String>) {
// Instantiate the Factory
val docFactory = DocumentBuilderFactory.newInstance()
try {
// root elements
val docBuilder = docFactory.newDocumentBuilder()
val doc = docBuilder.newDocument()
val rootElement: Element = doc.createElement("company")
doc.appendChild(rootElement)
// add xml elements: staff 1001
val staff = doc.createElement("staff")
staff.setAttribute("id", "1001")
// set staff 1001's attribute
val firstname = doc.createElement("firstname")
firstname.textContent = "Jack"
staff.appendChild(firstname)
val lastname = doc.createElement("lastname")
lastname.textContent = "Ma"
staff.appendChild(lastname)
val nickname = doc.createElement("nickname")
nickname.textContent = "Hui Chuang A Li"
staff.appendChild(nickname)
val salary: Element = doc.createElement("salary")
salary.setAttribute("currency", "USD")
salary.textContent = "100000"
staff.appendChild(salary)
rootElement.appendChild(staff)
// add xml elements: staff 1002
val staff2: Element = doc.createElement("staff")
rootElement.appendChild(staff2)
staff2.setAttribute("id", "1002")
// set staff 1002's attribute
val firstname2 = doc.createElement("firstname")
firstname2.textContent = "Pony"
staff2.appendChild(firstname2)
val lastname2 = doc.createElement("lastname")
lastname2.textContent = "Ma"
staff2.appendChild(lastname2)
val nickname2 = doc.createElement("nickname")
nickname2.textContent = "Pu Tong Jia Ting"
staff2.appendChild(nickname2)
val salary2= doc.createElement("salary")
salary2.setAttribute("currency", "RMB")
salary2.textContent = "200000"
staff2.appendChild(salary2)
rootElement.appendChild(staff2)
val newXmlFile = File("${projectPath}/src/res/", "generatedXml.xml")
// write doc to new xml file
generateXml(doc, newXmlFile)
} catch (e: Throwable) {
e.printStackTrace()
}
}
// write doc to new xml file
private fun generateXml(doc: Document, file: File) {
// Instantiate the Transformer
val transformerFactory = TransformerFactory.newInstance()
val transformer = transformerFactory.newTransformer()
// pretty print
transformer.setOutputProperty(OutputKeys.INDENT, "yes")
val source = DOMSource(doc)
val result = StreamResult(file)
transformer.transform(source, result)
}
第二步:生成 XML 文件 generatedXml.xml
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<company>
<staff id="1001">
<firstname>Jack</firstname>
<lastname>Ma</lastname>
<nickname>Hui Chuang A Li</nickname>
<salary currency="USD">100000</salary>
</staff>
<staff id="1002">
<firstname>Pony</firstname>
<lastname>Ma</lastname>
<nickname>Pu Tong Jia Ting</nickname>
<salary currency="RMB">200000</salary>
</staff>
</company>
2.2 SAX(Simple API for XML)
SAX 处理的特点是基于事件流的。分析能够立即开始,而不是等待所有的数据被处理。而且,由于应用程序只是在读取数据时检查数据,因此不需要将数据存储在内存中。这对于大型文档来说是个巨大的优点。
优点:
- 访问能够立即进行,不需要等待所有数据被加载
- 只在读取数据时检查数据,不需要保存在内存中
- 占用内存少,不需要将整个数据都加载到内存中
- 允许注册多个 Handler,可以用来解析文档内容,DTD 约束等等
缺点:
- 需要应用程序自己负责 TAG 的处理逻辑(例如维护父/子关系等),文档越复杂程序就越复杂
- 单向导航,无法定位文档层次,很难同时访问同一文档的不同部分数据,不支持 XPath
- 不能随机访问 xml 文档,不支持原地修改 xml
适用于: 文档较大,只需要读取文档数据。
2.2.1 SAX 解析 XML
第一步:新建 ContentHandler 解析类
package com.elijah.kotlinlearning
import org.xml.sax.Attributes
import org.xml.sax.helpers.DefaultHandler
class ContentHandler: DefaultHandler(){
private var nodeName :String? = null // 当前节点名
private lateinit var firstname: StringBuilder // 属性:firstname
private lateinit var lastname: StringBuilder // 属性:lastname
private lateinit var nickname: StringBuilder // 属性:nickname
private lateinit var salary: StringBuilder // 属性:salary
// 开始解析文档
override fun startDocument() {
firstname = StringBuilder()
lastname = StringBuilder()
nickname = StringBuilder()
salary = StringBuilder()
}
// 开始解析节点
override fun startElement(
uri: String?,
localName: String?,
qName: String?,
attributes: Attributes?
) {
nodeName = localName
}
// 开始解析字符串
override fun characters(ch: CharArray?, start: Int, length: Int) {
// 判断节点名称
when (nodeName) {
"firstname" -> {
firstname.append(ch, start, length)
}
"lastname" -> {
lastname.append(ch, start, length)
}
"nickname" -> {
nickname.append(ch, start, length)
}
"salary" -> {
salary.append(ch, start, length)
}
}
}
// 结束解析节点
override fun endElement(uri: String?, localName: String?, qName: String?) {
// 打印出来解析结果
if (localName == "staff") {
println("Staff is : $nodeName")
println("First Name: ${firstname.toString()}")
println("Last Name: ${lastname.toString()}")
println("Nick Name: ${nickname.toString()}")
println("Salary [Currency] : ${salary.toString()}")
// 清空, 不妨碍下一个 staff 节点的解析
firstname.clear()
lastname.clear()
nickname.clear()
salary.clear()
}
}
// 结束解析文档
override fun endDocument() {
super.endDocument()
}
}
第二步:新建解析器对指定 XML 进行解析
package com.elijah.kotlinlearning
import org.xml.sax.InputSource
import java.io.File
import javax.xml.parsers.SAXParserFactory
fun main(args: Array<String>) {
try{
// 新建解析器工厂
val saxParserFactory = SAXParserFactory.newInstance()
// 通过解析器工厂获得解析器对象
val saxParser = saxParserFactory.newSAXParser()
// 获得 xmlReader
val xmlReader = saxParser.xmlReader
// 设置解析器中的解析类
xmlReader.contentHandler = ContentHandler()
// 设置解析内容
val inputStream = File("${projectPath}/src/res/Staff.xml").inputStream()
xmlReader.parse(InputSource(inputStream))
} catch(e: Throwable){
e.printStackTrace()
}
}
作者:话唠扇贝
链接:https://juejin.cn/post/7131018900002570276
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
Flutter 语法进阶 | 抽象类和接口本质的区别
1. 接口存在的意义?
在 Dart
中 接口
定义并没有对应的关键字。可能有些人觉得 Dart
中弱化了 接口
的概念,其实不然。我们一般对接口的理解是:接口是更高级别的抽象,接口中的方法都是 抽象方法
,没有方法体。通过接口的定义,我们可以通过定义接口来声明功能,通过实现接口来确保某类拥有这些功能。
不过你有没有仔细想过,为什么接口会存在,引入接口的概念是为了解决什么问题?可能有人会说,通过接口,可以规范一类事物的功能,可以面向接口进行操作,从而可以更加灵活地进行拓展。其实这只是接口的作用,而且这些功能 抽象类
也可以支持。所以接口一定存在什么特殊的功能,是抽象类无法做到的。
都是抽象方法的抽象类,和接口有什么本质的区别呢?在我的初入编程时,这个问题就伴随着我,但渐渐地,这个问题好像对编程没有什么影响,也就被遗忘了。网上很多文章介绍 抽象类
和 接口
的区别,只是在说些无关痛痒的形式区别,并不能让我觉得接口存在有什么必要性。
思考一件事物存在的本质意义,可以从没有这个事物会产生什么后果来分析。现在想一下,如果没有接口,一切的抽象行为仅靠 抽象类
完成会有什么局限性
或说 弊端
。没有接口,就没有 实现 (implements)
的概念,其实这就等价于在问 implements
消失了,对编程有什么影响。没有实现,类之间就只能通过 继承 (extends)
来维护 is-a
的关系。所以就等价于在问 extends
有什么局限性
或说 弊端
。答案呼之欲出:多继承的二义性
。
那问题来了,为什么类不能支持 多继承
,而接口可以支持 多实现
,继承
和 实现
有什么本质的区别呢?为什么 实现
不会带来 二义性
的问题,这是理解接口存在关键。
2. 继承 VS 实现
下面我们来探讨一下 继承
和 实现
的本质区别。如下 A
和 B
类,有一个相同的成员变量和成员方法:
class A{
String name;
A(this.name);
void run(){ print("B"); }
}
class B{
String name;
B(this.name);
void run(){ print("B"); }
}
对于继承而言 派生类
会拥有基类的成员变量与成员方法,如果支持多继承,就会出现两个问题:
- 问题一 : 基类中有同名
成员变量
,无法确定成员的归属类 - 问题二: 基类中有同名
成员方法
,且子类未覆写。在调用时,无法确定执行哪个。
class C extends A , B {
C(String name) : super(name); // 如果多继承,该为哪个基类的 name 成员赋值 ??
}
void main(){
C c = C("hello")
c.run(); // 如果多继承,该执行哪个基类的 run 方法 ??
}
其实仔细思考一下,一般意义上的接口之所以能够 多实现
,就是通过限制,对这两个问题进行解决。比如 Java
中:
- 不允许在接口中定义普通的
成员变量
,解决问题一。 - 在接口中只定义抽象成员方法,不进行实现。而是强制派生类进行实现,解决问题二。
abstract class A{
void run();
}
abstract class B{
void run();
}
class C implements A,B{
@override
void run() {
print("C");
}
}
到这里,我们就认识到了为什么接口不存在 多实现
的二义性问题。这就是 继承
和 实现
最本质的区别,也是 抽象类
和 接口
最重要的差异。从这里可以看出,接口就是为了解决多继承
二义性的问题,而引入的概念,这就是它存在的意义。
3. Dart 中接口与实现的特殊性
Dart
中并不像 Java
那样,有明确的关键字作为 接口类
的标识。因为 Dart
中的接口概念不再是 传统意义
上的狭义接口。而是 Dart
中的任何类都可以作为接口,包括普通的类,这也是为什么 Dart
不提供关键字来表示接口的原因。
既然普通类可以作为接口,那多实现中的 二义性问题
是必须要解决的,Dart
中是如何处理的呢? 如下是 A
、B
两个普通类,其中有两个同名 run
方法:
class A{
void run(){
print("run in a");
}
}
class B{
void run(){
print("run in a");
}
void log(){
print("log in a");
}
}
当 C
类实现 A
、B
接口,必须强制覆写 所有
成员方法 ,这点解决了二义性的 问题二
:
那 问题一
中的 成员变量
的歧义如何解决呢?如下,在 A
、B
中添加同名的成员变量:
class A{
final String name;
A(this.name);
// 略同...
}
class B{
final String name;
B(this.name);
// 略同...
}
当 C
类实现 A
、B
接口,必须强制覆为 所有
成员变量提供 get
方法 ,这点解决了二义性的 问题一
:
这样,C
就可以实现两个普通类,而避免了二义性问题:
class C implements A, B {
@override
String get name => "C";
@override
void log() {}
@override
void run() {}
}
其实,这是 Dart
对 implements
关键字的功能加强,迫使派生类必须提供 所有
成员变量的 get
方法,必须覆写 所有
成员方法。这样就可以让 类
和 接口
成为两个独立的概念,一个 class
既可以是类,也可以是接口,具有双重身份。其区别在于,在 extend
关键字后,表示继承
,是作为类来对待;在 implements
关键字之后,表示实现
,是作为接口来对待。
4.Dart 中抽象类作为接口的小细节
我们知道,抽象类中允许定义 普通成员变量/方法
。下面举个小例子说明一下 继承 extend
和 实现 implements
的区别。对于继承来说,派生类只需要实现抽象方法即可,抽象基类
中的普通成员方法可以不覆写:
而前面说过,implements
关键字要求派生类必须覆写 接口
中的 所有
方法 。也就表示下面的 C implements A
时,也必须覆写 log
方法。从这个例子中,可以很清楚地看出 继承
和 实现
的差异性。
抽象类
和 接口
的区别,就是 继承
和 实现
的区别,在代码上的体现是 extend
和 implements
关键字功能的区别。只有理解 继承
的局限性,才能认清 接口
存在的必要性。那本文就到这了,谢谢观看 ~
作者:张风捷特烈
链接:https://juejin.cn/post/7131880904154644516
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
浅谈Kotlin编程-Kotlin基础语法和编码规范
前言
上一篇我们认识了Kotlin编程语言,也搭建好开发环境。本篇就进入Kotlin的基础语法介绍,与其他编程语言一样,Kotlin也有自己的一套编码规范。
文章总览
1.Kotlin基本语法
1.1 函数声明
使用关键字 fun
声明:
fun sum(a: Int, b: Int): Int { return a + b }
以上函数有俩个 int
参数:a , b;返回值为 Int
类型值。
在Kotlin中,返回值类型可以自行推断,函数体可以是表达式:这与Java是有区别的,直接用 =
相连
fun sum(a: Int, b: Int) = a + b
无返回值的函数,使用 Unit
为写法更简便可以将 Unit 省略。
fun printSum(a: Int, b: Int): Unit {
println("sum of $a and $b is ${a + b}")
}
// Unit 返回类型可以省略
1.2 程序主入口
Kotlin 程序的入口是 main
函数,与 Java 是一样的。
fun main() {
println("Hello world!") // 打印字符串
}
程序在执行时,会先进入 main 函数开始执行。
1.3 变量
- 只读局部变量(常量) 使用
val
定义
val a: Int = 1 // ⽴即赋值
val b = 2 // ⾃动推断出 `Int` 类型
val c: Int // 如果没有初始值类型不能省略
c = 3 // 明确赋值
- 可重新赋值变量 使用
var
定义
var x = 5 // ⾃动推断出 `Int` 类型
x += 1 // x重新赋值
这与 Java 有很大区别,不用指定变量的类型,有编译器自动推断出来。
1.4 条件表达式
与 Java 中的 if 语句一样
if (a > b) {
return a
} else {
return b
}
在 Kotlin中 if
也可以⽤作表达式,更加简便
fun max(a: Int, b: Int) = if (a > b) a else b
1.5 when表达式
when 将它的参数与所有的分⽀条件顺序⽐较,直到某个分⽀满⾜条件
when (obj) {
1 -> "One"
"Hello" -> "Greeting"
is Long -> "Long"
!is String -> "Not a string"
else -> "Unknown"
}
可以类比 Java 中的 switch 语句。
1.6 空值与空检测
一个表达式或者一个变量可以为Null, 在Kotlin中可以使用 ?
来结尾表示
fun parseInt(str: String): Int? { // …… }
// 函数返回值可为空,当返回值 不是 Int 类型,返回值就是Null
这一特性解决了 Java 中一老大难的问题:NullpointException 空指针报错问题,在日常开发中帮开发者提高了不少开发效率和减少了不少bug。
1.7 区间使用
使⽤ in
操作符来检测某个数字是否在指定区间内
val x = 10
val y = 9
if (x in 1..y+1) {
println("in range")
}
这个特性可以运用到 区间和数列中。
2.Kotlin编码规范
目录结构:可以类比 Java 项目,包名的规则:小写字母,公司/组织域名反写
代码源文件:以 .kt 为扩展名,命名规则首字母大写的驼峰风格,例如
HelloWorld.kt
命名规则:
- 类与对象的名称以大写字母开头并使用驼峰风格
- 包的名称总是小写且不使用下划线
文档注释:
- 多行注释
- 单行注释
代码缩进风格要统一
注解:将注解放在单独的⾏上,在它们所依附的声明之前,并使⽤相同的缩进
链式调用:对链式调⽤换⾏时,将
.
字符或者?.
操作符放在下⼀⾏,带有缩进
不在
.
或者?.
左右留空格:foo.bar().filter { it > 2 }.joinToString() , foo?.bar()
在
//
之后留⼀个空格: // 这是⼀条注释
不要在⽤于指定类型参数的尖括号前后留空格:
class Map { …… }
不要在
::
前后留空格:Foo::class 、String::length
不要在⽤于标记可空类型的
?
前留空格:String?
总结
本文主要讲解 Kotlin 常用的基本语法,后续会针对特定的知识点展开学习,同时学习了Kotlin 编码规范,对日常规范编写代码是非常有帮助。
作者:南巷羽
链接:https://juejin.cn/post/7130094040141266957
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
Android—以面试角度剖析HashMap源码
前言
HashMap 这个词想必大家都挺熟悉的!但往往大多数都知其所用,而不知其原理,导致面试的处处碰壁!因此,这一篇的作用就是以面试的角度剖析HashMap!话不多说,直接开始!
温馨提示:此文有点长,建议先插眼,等有空闲时间观看
1、为什么要学HashMap?
刚刚说了本篇是以面试角度剖析HashMap,那么面试常见的问题有哪些呢?
- HashMap的原理?内部数据结构?
- HashMap中put方法的过程是怎样实现的?
- HashMap中hash函数是怎样实现的?
- HashMap是怎样扩容的呢?
- HashMap中某个Entry链太长,查找时间复杂度可能达到O(n),怎么优化?
2、剖析HashMap
2.1 HashMap初始化
虽然这一步大家很熟悉,但过程还是少补了!
HashMap hashMap = new HashMap<>(6, 1);
HashMap hashMap2 = new HashMap<>();
源码解析
这个就很简单了,初始化HashMap有两个构造器,一个无参,一个有参。(泛型就不说了吧)
那就从简先看无参的!
/**
* The default initial capacity - MUST be a power of two.
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
/**
* The load factor used when none specified in constructor.
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
源码解析
这里我们看到:
初始化给
this.loadFactor
赋值为0.75f
;
而这个0.75f就是该HashMap对应的扩展因子。(扩展因子:当长度大于等于 容量长度*扩展因子时,需要对该map进行扩容)
而 map默认长度就是
DEFAULT_INITIAL_CAPACITY=1 << 4
也就是默认16
结合扩展因子一起看,也就是说,当map长度大于等于 16*0.75f的时候,对应map需要扩容!(至于怎么扩容,下面会讲解)
这里看完了无参的,趁热打铁看看有参数的!
public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);
}
源码解析
这里我们看到代码瞬间多了起来,不过前面那几个if判断都是对入参进行一系列校验,核心代码在最后两句:
this.loadFactor = loadFactor
这个在上面讲过,就是给扩展因子赋值,只不过由默认变成了手动this.threshold = tableSizeFor(initialCapacity);
这里我们看到调用了tableSizeFor
方法,并将入参一带入该方法中!
那么这个神奇的tableSizeFor
方法到底做了甚么呢???
2.1.1 tableSizeFor 方法剖析
/**
* The maximum capacity, used if a higher value is implicitly specified
* by either of the constructors with arguments.
* MUST be a power of two <= 1<<30.
*/
static final int MAXIMUM_CAPACITY = 1 << 30;
/**
* Returns a power of two size for the given target capacity.
*/
static final int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
在进行源码解析前,先对这个方法里的两个操作符进行讲解:
>>>
表示无符号右移,也叫逻辑右移,即若该数为正,则高位补0,而若该数为负数,则右移后高位同样补0。
按二进制形式把所有的数字向右移动对应的位数,低位移出(舍弃),高位的空位补零。对于正数来说和带符号右移相同,对于负数来说不同。其他结构和>>相似。
|
表示的是或运算,即两个二进制数同位中,只要有一个为1则结果为1,若两个都为1其结果也为1,换句话说就是取并集。
源码解析
就以刚刚入参为6为例(cap=6):
int n = cap - 1
这个时候n=5
n |= n >>> 1
这个时候需要将这句代码拆成两部解析
- 继续往下走当执行
n |= n >>> 2
时
- 此时不管是
n >>> 4
还是n >>> 16
因为是取并集结果都为0000 0111
转为十进制为n=7
- 那么看看最后一句
(n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
最终结果为n=8
那我们把入参稍微调大一点,17为例(cap=17)
如图所示
我们可以得出一个结论,通过这个方法tableSizeFor
计算出的值,将大于等于cap的最近的一个2的n次方的一个值,而这对应的值就是该map的初始化容量长度
OK!到这HashMap的初始化已经剖析完成了。接下来该剖析HashMap的put操作!
2.2 HashMap对应put操作
敲黑板!!核心内容来了!!!
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
源码解析
这里我们可以看到,该方法调用了两个方法:hash(xx)
以及putVal(xx,xx,xx,xx,xx)
。
因为hash(xx)
作为putVal
方法的入参,因此,我们先看hash方法
是怎么工作的
2.2.1 HashMap对应Hash算法
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
源码解析
这里我们可以看到 使用了^
运算符,它的意思总结起来就是一句话:同为假,异为真。
比如说:
0000 0000 0001 0001
0000 0000 0001 0111
——————————
0000 0000 0000 0110
这里我们看到:相同的,运算后都为0;不同的,运算后为1
了解这个算法后,我们来看看这个方法运行:
如图所示
现在我们看到,经过一系列计算发现最终结果居然还是为 key.hashCode()
,那为啥还要与 (h>>>16)
进行 ^
异或运算呢?能不能直接return key.hashCode()
呢?
答案是:当然肯定不能!!!那它为什么要这样写呢???为什么非要用^
异或运算呢?
回答这个问题之前,我们先来熟悉一下:或、与、异或 这三者运算规则;
如图所示
- 或运算:(只要有1,那么就是1) 0|0=0 ,1|0=1 ,0|1=1 ,1|1=1 我们看到有三者都为1
- 与运算:(都是1时,结果才为1) 0&0=0 ,0&1=0 ,1&0=0 ,1&1=1 我们看到有三者都为0
- 异或预算:(只要一样结果就是0)0^0=0 ,0^1=1 ,1^0=1 ,1^1=0 我们看到有两者为0,两者为1
总结
从这三者运算结果看,只有异或运算 真假各占50% ,也就是说,当使用异或运算时,对应的Key更具有散列性。为什么要有散列性,下文会体现出来!
如图所示
当key比较复杂时,返回结果已经和key.hashCode
有所不同了,因此对应的(h = key.hashCode()) ^ (h >>> 16)
还是很有必要的
到这Hash算法差不多结束了。接下来继续下一步操作!
按理说,下一步应该剖析putVal(xx,xx,xx,xx,xx)
方法源码。但仔细想了哈,还是先吧结果说出来,最后将结果带进去阅读源码应该会更好一点。
2.2.2 HashMap内部构造结构
如图所示
- HashMap内部构造为数组+链表的形式,而数组的默认长度要么是标准的16,要么就是
tableSizeFor
方法返回的结果 - 当链表长度大于等于8时,将会转为红黑树结构
刚刚我们说的是,将结果带入源码解析。那我们再来分析一下这张图
试想一下,这种结构该如何保存值呢??
- 因为它是数组结构,所以第一时间得要找到能存储该值的下标,只有找到对应下标了才能更好的保存值
- 找到对应下标了,再看该下标是否存在链表结构,如果不存在则创建新的链表结构,并将对应key-value存储起来
- 如果存在对应链表结构,则判断该链表是否转化为红黑树,如果真,则按红黑树原理存储或者替换对应值
- 如果非红黑树结构,则判断对应key是否在该链表中,如果在链表中,则直接替换原有值
- 如果对应key不存在原有链表中,则先判断该链表长度是否大于等于7,如果真,则创建新的单元格按红黑树的原理存储对应元素,最终长度自增1位;(因为长度满足8位就是红黑树结构,因此要在自增前判断是否满足要求)
- 如果链表长度小于7,那么创建新的单元格直接存入该链表中,并与上一个单元格next相互关联
到这!大部分的概念理论叙述完了,接下来到了剖析源码验证环节了!!!
2.2.3 putVal方法剖析
/**
* The next size value at which to resize (capacity * load factor).
*
* @serial
*/
int threshold;
static final int TREEIFY_THRESHOLD = 8;
/**
* Implements Map.put and related methods
*
* @param hash hash for key
* @param key the key
* @param value the value to put
* @param onlyIfAbsent if true, don't change existing value
* @param evict if false, the table is in creation mode.
* @return previous value, or null if none
*/
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node[] tab; Node p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length; //分析点1
if ((p = tab[i = (n - 1) & hash]) == null) //分析点2
tab[i] = newNode(hash, key, value, null);
else {
Node e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p; //分析点3
else if (p instanceof TreeNode) //分析点 4
e = ((TreeNode)p).putTreeVal(this, tab, hash, key, value);
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null); // 分析点5
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash); // 分析点5-1
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break; //分析点6
p = e; //分析点7
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null) //分析点8
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold)
resize(); //分析点9
afterNodeInsertion(evict);
return null;
}
源码分析
分析点1:当该map第一次进行put操作时,对应的tab数组并未初始化。因此这里需要调用
resize()
方法,并给变量n赋值(分析点9会单独讲解该方法)
分析点2:这里使用了
(p = tab[i = (n - 1) & hash]) == null
这句代码,从该方法前两句可以看出
- n表示该HashMap对应数组长度
- tab表示该HashMap对应数组
- hash表示该方法的第一个入参,是由上一个方法根据hash算法推算出具有散列性的值
i = (n - 1) & hash
这句代码就是通过 hash算法推算的值与数组长度-1进行运算,取出对应的下标,因为hash具有散列性(平均),因此能够均匀的分配对应数组单元格
- 如果通过下标找到元素为空,那么就创建新的链表结构,并将当前key-value存入对应链表结构中
分析点3:这里使用了
p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))
这句代码,结合分析点2一起看可以得出:
- p 表示通过下标找到的对应链表结构,并且非空
- k 表示该方法第二个入参,表示对应key值
- 因此该判断条件意思:如果输入的key,与链表第一个元素的key相同,那么将该单元格赋值给创建的
e
节点 (分析点8还会继续讲解该变量)
分析点4:这里使用了
e = ((TreeNode
结合判断条件可以看出)p).putTreeVal(this, tab, hash, key, value)
- p 表示通过下标找到的对应链表结构,并且非空
p instanceof TreeNode
这句判断条件表示,该链表结构是否TreeNode
类型(红黑树结构)- 这里红黑树就不详解了,结构组成总结起来就一句话:你比我大,那去这一边,比我小,那就去另外一边,一直往下每个节点都这样判断
- 因此这里具体意思就是:如果为红黑树结构,那就按照红黑树结构存储替换值,并且将对应节点返回赋值给上面创建的
e
节点(分析点8还会继续讲解该变量)
分析点5:
- 逻辑执行此处,这说明已经不满足上述分析点,也就是说,处理的单位只会存在标注的红框里
(e = p.next) == null
这句代码表示,如果往下找已经没有节点了,那么执行p.next = newNode(hash, key, value, null)
创建新的单元格并将对应key-value存储起来并与p.next
相互关联
分析点5-1:
- 结合分析点5一起看,上一步将创建的单元格与
p.next
相关关联后 TREEIFY_THRESHOLD
该变量=8binCount >= TREEIFY_THRESHOLD - 1
这句代码意思是,判断当前链表是否大于等于7 ,因为自增在下文,因此这里需要减一。treeifyBin(tab, hash);
这句代码意思是,满足上面判断条件,将当前链表转为红黑树结构
- 结合分析点5一起看,上一步将创建的单元格与
分析点6:
- e 在分析5 执行了
e = p.next
并且不为null (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))
这句意思表示在红框标注里是否通过key找到了对应的单元格,如果真则跳出循环;如果假则执行分析7
- e 在分析5 执行了
分析点7:
- 结合分析6一起看,如果当前key与当前单元格对应key不等,那么就执行
p = e;
指向下一个单元格
- 结合分析6一起看,如果当前key与当前单元格对应key不等,那么就执行
分析点8:
onlyIfAbsent
该变量为方法的第4个入参,value=false
能进入该逻辑,因此对应
e
不为空!上述条件中,满足条件有:分析3、分析4、分析6
这三者条件都是满足对应key相同则赋值,那么这里就是替换对应相同key对应的value值
分析点9:
- 能进分析9,则说明满足
++size > threshold
条件 - size表示该map中所有key-value 的总长度
- threshold 表示 达到扩容条件的目标值
resize()
方法,那就是扩容了。那么这个方法到底做了甚么呢?
- 能进分析9,则说明满足
2.2.3.1 扩容 resize()
方法
讲解扩容之前先整理下,在哪些情况会调用该方法?
- 分析点1 在table=null 或者table.length=0的时候会调用该方法
- 分析点9 在满足
++size > threshold
条件时,会调用该方法
因此我们得结合这两种情况来阅读该方法!
当执行分析点1逻辑时
我们可以删掉该源方法的一部分逻辑,因此
static final float DEFAULT_LOAD_FACTOR = 0.75f;
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
final Node[] resize() {
Node[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) {
.....
}
else if (oldThr > 0){
//初始化HashMap时,调用了有参构造器,就会进入该逻辑
newCap = oldThr;
}
else {
//初始化HashMap时,调用无参构造器,就会进入该逻辑
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) {
//初始化HashMap时,调用了有参构造器,就会进入该逻辑
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
Node[] newTab = (Node[])new Node[newCap];
table = newTab;
.....
return newTab;
}
源码解析
这里我们可以看到,通过分析点1进入该方法时:
已知条件为:table =null , oldCap=0
默认情况下将会直接进入else 相关逻辑 ,如果用户初始化HashMap调用的有参构造器,那么就会执行代码注释标注的部分(下面所有都按初始化时,调用无参构造器讲解)
当执行
newCap = DEFAULT_INITIAL_CAPACITY
对应newCap=16
当执行
(int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY)
对应 newThr=16*0.75f
当执行
threshold = newThr
对应threshold=12
当执行
Node
以及[] newTab = (Node [])new Node[newCap] table = newTab
对应table长度为newCap
也就是默认16
这个就是通过分析点1进入该方法的所有逻辑!
那通过分析点9进入该方法呢?
当执行分析点9逻辑时
对应代码逻辑:
static final float DEFAULT_LOAD_FACTOR = 0.75f;
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
static final int MAXIMUM_CAPACITY = 1 << 30;
final Node[] resize() {
Node[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) { //分析点10
threshold = Integer.MAX_VALUE;
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY) //分析点11
newThr = oldThr << 1; // double threshold
}
//删除上面已经讲解过的代码.....
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
Node[] newTab = (Node[])new Node[newCap]; //分析点12
table = newTab;
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
Node e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
((TreeNode)e).split(this, newTab, j, oldCap);
else { // preserve order
Node loHead = null, loTail = null;
Node hiHead = null, hiTail = null;
Node next;
do {
next = e.next;
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
源码解析
分析点10:这里是为了做最大限制扩容,如果扩容前的长度已经达到了
1<<30
,那么此次扩容长度将会是最大值Integer.MAX_VALUE
分析点11:我们来拆分一下这段条件判断代码:
(newCap=oldCap<<1
=DEFAULT_INITIAL_CAPACITY
执行
newCap=oldCap<<1
时,对应 newCap=扩容前的长度<<1 ,也就是 16<<1 ,最终结果为 32
在判断逻辑里,当执行
newThr = oldThr << 1
时,也就是 12<<1,最终结果为 24
分析点12:将分析11的结果创建了一个全新的数组,并在下面的循环中,将原有数组里的内容赋值给这个全新数组
到这里,整个扩容机制已经讲解完了!趁热打铁,继续下一个!
2.3 HashMap对应get操作
public V get(Object key) {
Node e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
源码解析
这里我们可以看到 依然调用了两个方法
hash(xx)
、getNode(xx,xx)
hash(xx)
这个在put操作里讲解过,这里不再赘述
因此现在只需要讲解这个方法
getNode(xx,xx)
即可
2.3.1 getNode 方法剖析
final Node getNode(int hash, Object key) {
Node[] tab; Node first, e; int n; K k;
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) { //分析点1
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first; //分析点2
if ((e = first.next) != null) {
//分析点3
if (first instanceof TreeNode)
return ((TreeNode)first).getTreeNode(hash, key);
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null; //分析点4
}
源码解析
分析点1:这里仅仅是对map里面的数组进行判断,看是否为有效有值的数组,并将有效值给first赋值
分析点2:这里表示如果是在第一层节点通过key找到对应节点时,那就直接返回对应节点
分析点3:这里就和分析2相反,第一层找不到,那就只有遍历下面对应节点的下一层。如果是链表,那就按链表形式查找;如果是红黑树,那就按照红黑树形式查找;如果找到了,就将对应的节点向上一层返回
分析点4:到这里这说明上面所有方式都没有找到对应key相关的节点,因此返回null
好了!到这里HashMap相关源码已全部剖析完毕!现在来结合上文面试题总结一下!
3、总结
HashMap的原理?内部数据结构?
- HashMap底层它是有哈希表组成,当链条过长时,将会转化为红黑树结构
HashMap中put方法的过程是怎样实现的?
- 对key求hash值,然后再计算下标
- 如果没有碰撞,直接放入数组中
- 如果碰撞了,就根据key判断是否存在于链表中,存在则直接覆盖值,不存在则以链表的方式链接到后面
- 如果链表长度过长(>=8),此时链表将转为红黑树
- 如果桶满了(容量*加载因子),那么就需要调用resize方法进行扩容
HashMap中hash函数是怎样实现的?
- 高16bit不变,低16bit和高16bit做了一个异或
- 通过(n-1)&hash 得到对应的下标
HashMap是怎样扩容的呢?
- 在resize方法里,首先通过(容量*加载因子)计算出下一次扩容所需要达到的条件
- 当在putVal,如果对应长度达到了扩容的条件那么就会再次调用resize方法,通过 原长度<<1 移位操作 进行扩容
- 而对应的扩容条件也会跟随这 原扩容因子<<1 移位操作
HashMap中某个Entry链太长,查找时间复杂度可能达到O(n),怎么优化?
- 其实上面已经答了,就是将链表转化为红黑树操作!
到这里,本篇内容已经进入尾声了!相信能坚持看到这里的小伙伴,已经对hashMap有了充分的认知!
下一篇准备来个手写HashMap,来巩固HashMap知识点!!!
作者:hqk
链接:https://juejin.cn/post/7130524758059253774
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
Android drawFunctor 原理及应用
一. 背景
蚂蚁 NativeCanvas 项目 Android 平台中使用了基于 TextureView 环境实现 GL 渲染的技术方案,而 TextureView 需使用与 Activity Window 独立的 GraphicBuffer,RenderThread 在上屏 TextureView 内容时需要将 GraphicBuffer 封装为 EGLImage 上传为纹理再渲染,内存占用较高。为降低内存占用,经仔细调研 Android 源码,发现其中存在一种称为 drawFunctor 的技术,用来将 WebView 合成后的内容同步到 Activity Window 内上屏。经过一番探索成功实现了基于 drawFunctor 实现 GL 注入 RenderThread 的功能,本文将介绍这是如何实现的。
二. drawFunctor 原理介绍
drawFunctor 是 Android 提供的一种在 RenderThread 渲染流程中插入执行代码机制,Android 框架是通过以下三步来实现这个机制的:
- 在 UI 线程 View 绘制流程 onDraw 方法中,通过 RecordingCanvas.invoke 接口,将 functor 插入 DisplayList 中
- 在 RenderThread 渲染 frame 时执行 DisplayList,判断如果是 functor 类型的 op,则保存当前部分 gl 状态
- 在 RenderThread 中真正执行 functor 逻辑,执行完成后恢复 gl 状态并继续
目前只能通过 View.OnDraw 来注入 functor,因此对于非 attached 的 view 是无法实现注入的。Functor 对具体要执行的代码并未限制,理论上可以插入任何代码的,比如插入一些统计、性能检测之类代码。系统为了 functor 不影响当前 gl context,执行 functor 前后进行了基本的状态保存和恢复工作。
另外,如果 View 设置了使用 HardwareLayer, 则 RenderThread 会单独渲染此 View,具体做法是为 Layer 生成一块 FBO,View 的内容渲染到此 FBO 上,然后再将 FBO 以 View 在 hierachy 上的变换绘制 Activity Window Buffer 上。 对 drawFunctor 影响的是, 会切换到 View 对应的 FBO 下执行 functor, 即 functor 执行的结果是写入到 FBO 而不是 Window Buffer。
三. 利用 drawFunctor 注入 GL 渲染
根据上文介绍,通过 drawFunctor 可以在 RenderThread 中注入任何代码,那么也一定可以注入 OpenGL API 来进行渲染。我们知道 OpenGL API 需要执行 EGL Context 上,所以就有两种策略:一种是利用 RenderThread 默认的 EGL Context 环境,一种是创建与 RenderThread EGL Context share 的 EGL Context。本文重点介绍第一种,第二种方法大同小异。
Android Functor 定义
首先找到 Android 源码中 Functor 的头文件定义并引入项目:
namespace android {
class Functor {
public:
Functor() {}
virtual ~Functor() {}
virtual int operator()(int /*what*/, void * /*data*/) { return 0; }
};
}
RenderThread 执行 Functor 时将调用 operator()方法,what 表示 functor 的操作类型,常见的有同步和绘制, 而 data 是 RenderThread 执行 functor 时传入的参数,根据源码发现是 data 是 android::uirenderer::DrawGlInfo 类型指针,包含当前裁剪区域、变换矩阵、dirty 区域等等。
DrawGlInfo 头文件定义如下:
namespace android {
namespace uirenderer {
/**
* Structure used by OpenGLRenderer::callDrawGLFunction() to pass and
* receive data from OpenGL functors.
*/
struct DrawGlInfo {
// Input: current clip rect
int clipLeft;
int clipTop;
int clipRight;
int clipBottom;
// Input: current width/height of destination surface
int width;
int height;
// Input: is the render target an FBO
bool isLayer;
// Input: current transform matrix, in OpenGL format
float transform[16];
// Input: Color space.
// const SkColorSpace* color_space_ptr;
const void* color_space_ptr;
// Output: dirty region to redraw
float dirtyLeft;
float dirtyTop;
float dirtyRight;
float dirtyBottom;
/**
* Values used as the "what" parameter of the functor.
*/
enum Mode {
// Indicates that the functor is called to perform a draw
kModeDraw,
// Indicates the the functor is called only to perform
// processing and that no draw should be attempted
kModeProcess,
// Same as kModeProcess, however there is no GL context because it was
// lost or destroyed
kModeProcessNoContext,
// Invoked every time the UI thread pushes over a frame to the render thread
// *and the owning view has a dirty display list*. This is a signal to sync
// any data that needs to be shared between the UI thread and the render thread.
// During this time the UI thread is blocked.
kModeSync
};
/**
* Values used by OpenGL functors to tell the framework
* what to do next.
*/
enum Status {
// The functor is done
kStatusDone = 0x0,
// DisplayList actually issued GL drawing commands.
// This is used to signal the HardwareRenderer that the
// buffers should be flipped - otherwise, there were no
// changes to the buffer, so no need to flip. Some hardware
// has issues with stale buffer contents when no GL
// commands are issued.
kStatusDrew = 0x4
};
}; // struct DrawGlInfo
} // namespace uirenderer
} // namespace android
Functor 设计
operator()调用时传入的 what 参数为 Mode 枚举, 对于注入 GL 的场景只需处理 kModeDraw 即可,c++ 侧类设计如下:
// MyFunctor定义
namespace android {
class MyFunctor : Functor {
public:
MyFunctor();
virtual ~MyFunctor() {}
virtual void onExec(int what,
android::uirenderer::DrawGlInfo* info);
virtual std::string getFunctorName() = 0;
int operator()(int /*what*/, void * /*data*/) override;
private:
};
}
// MyFunctor实现
int MyFunctor::operator() (int what, void *data) {
if (what == android::uirenderer::DrawGlInfo::Mode::kModeDraw) {
auto info = (android::uirenderer::DrawGlInfo*)data;
onExec(what, info);
}
return android::uirenderer::DrawGlInfo::Status::kStatusDone;
}
void MyFunctor::onExec(int what, android::uirenderer::DrawGlInfo* info) {
// 渲染实现
}
因为 functor 是 Java 层调度的,而真正实现是在 c++ 的,因此需要设计 java 侧类并做 JNI 桥接:
// java MyFunctor定义
class MyFunctor {
private long nativeHandle;
public MyFunctor() {
nativeHandle = createNativeHandle();
}
public long getNativeHandle() {
return nativeHanlde;
}
private native long createNativeHandle();
}
// jni 方法:
extern "C" JNIEXPORT jlong JNICALL
Java_com_test_MyFunctor_createNativeHandle(JNIEnv *env, jobject thiz) {
auto p = new MyFunctor();
return (jlong)p;
}
在 View.onDraw () 中调度 functor
框架在 java Canvas 类上提供了 API,可以在 onDraw () 时将 functor 记录到 Canvas 的 DisplayList 中。不过由于版本迭代的原因 API 在各版本上稍有不同,经总结可采用如下代码调用,兼容各版本区别:
public class FunctorView extends View {
...
private static Method sDrawGLFunction;
private MyFunctor myFunctor = new MyFunctor();
@Override
public void onDraw(Canvas cvs) {
super.onDraw(cvs);
getDrawFunctorMethodIfNot();
invokeFunctor(cvs, myFunctor);
}
private void invokeFunctor(Canvas canvas, MyFunctor functor) {
if (functor.getNativeHandle() != 0 && sDrawGLFunction != null) {
try {
sDrawGLFunction.invoke(canvas, functor.getNativeHandle());
} catch (Throwable t) {
// log
}
}
}
public synchronized static Method getDrawFunctorMethodIfNot() {
if (sDrawGLFunction != null) {
return sDrawGLFunction;
}
hasReflect = true;
String className;
String methodName;
Class<?> paramClass = long.class;
try {
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P) {
className = "android.graphics.RecordingCanvas";
methodName = "callDrawGLFunction2";
} else if (Build.VERSION.SDK_INT > Build.VERSION_CODES.LOLLIPOP_MR1) {
className = "android.view.DisplayListCanvas";
methodName = "callDrawGLFunction2";
} else if (Build.VERSION.SDK_INT == Build.VERSION_CODES.LOLLIPOP) {
className = "android.view.HardwareCanvas";
methodName = "callDrawGLFunction";
} else if (Build.VERSION.SDK_INT == Build.VERSION_CODES.LOLLIPOP_MR1) {
className = "android.view.HardwareCanvas";
methodName = "callDrawGLFunction2";
} else {
className = "android.view.HardwareCanvas";
methodName = "callDrawGLFunction";
paramClass = int.class;
}
Class<?> canvasClazz = Class.forName(className);
sDrawGLFunction = SystemApiReflector.getInstance().
getDeclaredMethod(SystemApiReflector.KEY_GL_FUNCTOR, canvasClazz,
methodName, paramClass);
} catch (Throwable t) {
// 异常
}
if (sDrawGLFunction != null) {
sDrawGLFunction.setAccessible(true);
} else {
// (异常)
}
return sDrawGLFunction;
}
}
注意上述代码反射系统内部 API,Android 10 之后做了 Hidden API 保护,直接反射会失败,此部分可网上搜索解决方案,此处不展开。
四. 实践中遇到的问题
GL 状态保存&恢复
Android RenderThread 在执行 drawFunctor 前会保存部分 GL 状态,如下源码:
// Android 9.0 code
// 保存状态
void RenderState::interruptForFunctorInvoke() {
mCaches->setProgram(nullptr);
mCaches->textureState().resetActiveTexture();
meshState().unbindMeshBuffer();
meshState().unbindIndicesBuffer();
meshState().resetVertexPointers();
meshState().disableTexCoordsVertexArray();
debugOverdraw(false, false);
// TODO: We need a way to know whether the functor is sRGB aware (b/32072673)
if (mCaches->extensions().hasLinearBlending() &&
mCaches->extensions().hasSRGBWriteControl()) {
glDisable(GL_FRAMEBUFFER_SRGB_EXT);
}
}
// 恢复状态
void RenderState::resumeFromFunctorInvoke() {
if (mCaches->extensions().hasLinearBlending() &&
mCaches->extensions().hasSRGBWriteControl()) {
glEnable(GL_FRAMEBUFFER_SRGB_EXT);
}
glViewport(0, 0, mViewportWidth, mViewportHeight);
glBindFramebuffer(GL_FRAMEBUFFER, mFramebuffer);
debugOverdraw(false, false);
glClearColor(0.0f, 0.0f, 0.0f, 0.0f);
scissor().invalidate();
blend().invalidate();
mCaches->textureState().activateTexture(0);
mCaches->textureState().resetBoundTextures();
}
可以看出并没有保存所有 GL 状态,可以增加保存和恢复所有其他 GL 状态的逻辑,也可以针对实际 functor 中改变的状态进行保存和恢复;特别注意 functor 执行时的 GL 状态是非初始状态,例如 stencil、blend 等都可能被系统 RenderThread 修改,因此很多状态需要重置到默认。
View变换处理
当承载 functor 的 View 外部套 ScrollView、ViewPager,或者 View 执行动画时,渲染结果异常或者不正确。例如水平滚动条中 View 使用 functor 渲染,内容不会随着滚动条移动调整位置。进一步研究源码 Android 发现,此类问题原因都是 Android 在渲染 View 时加入了变换,变换采用标准 4x4 变换列矩阵描述,其值可以从 DrawGlInfo::transform 字段中获取, 因此渲染时需要处理 transform,例如将 transform 作为模型变换矩阵传入 shader。
ContextLost
Android framework 在 trimMemory 时在 RenderThread 中会销毁当前 GL Context 并创建一个新 Context, 这样会导致 functor 的 program、shader、纹理等 GL 资源都不可用,再去渲染的话可能会导致闪退、渲染异常等问题,因此这种情况必须处理。
首先,需要响应 lowMemory 事件,可以通过监听 Application 的 trimMemory 回调实现:
activity.getApplicationContext().registerComponentCallbacks(
new ComponentCallbacks2() {
@Override
public void onTrimMemory(int level) {
if (level == 15) {
// 触发functor重建
}
}
@Override
public void onConfigurationChanged(Configuration newConfig) {
}
@Override
public void onLowMemory() {
}
});
然后,保存 & 恢复 functor 的 GL 资源和执行状态,例如 shader、program、fbo 等需要重新初始化,纹理、buffer、uniform 数据需要重新上传。注意由于无法事前知道 onTrimMemory 发生,上一帧内容是无法恢复的,当然知道完整的状态是可以重新渲染出来的。
鉴于存在无法提前感知的 ContextLost 情况,建议采用基于 commandbuffer 的模式来实现 functor 渲染逻辑。
五. 效果
我们用一个 OpenGL 渲染的简单 case (分辨率1080x1920),对使用 TextureView 渲染和使用 drawFunctor 渲染的方式进行了比较,结果如下:
Simple Case | 内存 | CPU 占用 |
---|---|---|
基于 TextureView | 100 M ( Graphics 38 M ) | 6% |
基于 GLFunctor | 84 M ( Graphics 26 M ) | 4% |
从上述结果可得出结论,使用 drawFunctor 方式在内存、CPU 占用上具有优势, 可应用于局部页面的互动渲染、视频渲染等场景。
作者:支付宝体验科技
链接:https://juejin.cn/post/7130501902545977352
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
Android Gradle 三方依赖管理
发展历史
Gradle 的依赖管理是一个从开始接触 Android 开发就一直伴随着我们的问题(作者是Android开发,仅以此为例),从最初的 没有统一管理
到 通过.gradle或gradle.properties管理
,再到 Kotlin 出现之后使用 buildSrc 管理
以及在这基础上优化的 Composing Builds
,Gradle 依赖管理一直在不断的发展、更新,而到了 Gradle 7.0,Gradle 本身又专门提供了全新的 Version Catalogs
用于依赖管理,今天我们就来说说这些方式的优劣及使用方式吧。
最原始的依赖
当我们通过 Android Studio 创建一个新项目,这个项目里面默认的依赖就是最原始的,没有经过统一管理的;如果你的项目中只有一个 module
,那么这种默认的管理方式也是可以接受的,是否对它进行优化,这取决于你是否愿意投入成本去修改,谈不上什么优劣。
使用 .gradle
配置
当你的项目中 module
的数量超过一个甚至越来越多的时候,对 Gradle 依赖进行统一管理就变得重要起来,因为你不会想在升级一个三方依赖的版本后发现冲突,然后一个个打开各个 module
的 build.gradle
文件,找到你升级的那个依赖引用,重复的进行版本修改;
因此我们有了初步的优化方案:
- 在项目根目录下创建
config.gradle
文件,在其中按照以下格式添加相关配置;
ext {
android = [
compileSdkVersion: 30
]
dependencies = [
"androidx-core-ktx" : "androidx.core:core-ktx:1.3.2",
"androidx-appcompat": "androidx.appcompat:appcompat:1.2.0",
"google-material" : "com.google.android.material:material:1.3.0"
]
}
- 在项目根目录下的
build.gradle
文件顶部添加apply from: "config.gradle"
; - 在各个
module
的build.gradle
中就可以通过rootProject
来引用对应的依赖及参数了;
...
android {
compileSdkVersion rootProject.ext.android.compileSdkVersion
}
...
dependencies {
implementation rootProject.ext.dependencies["androidx-core-ktx"]
implementation rootProject.ext.dependencies["androidx-appcompat"]
implementation rootProject.ext.dependencies["google-material"]
}
...
使用这种方式,我们就能够将项目中的版本配置、三方依赖统一管理起来了,但是这种方式还是有缺陷的,我们无法像正常代码中一样便捷的跳转到依赖定义的地方,也不能简单的找到定义的依赖在哪些地方被使用。
使用 gradle.properties
配置
这个方式和上面的方式类似,把依赖相关数据定义到 gradle.properties
文件中:
...
androidx-core-ktx = androidx.core:core-ktx:1.3.2
androidx-appcompat = androidx.appcompat:appcompat:1.2.0
androidx-material = com.google.android.material:material:1.3.0
在各个 module
的 build.gradle
中使用;
...
dependencies {
implementation "${androidx-core-ktx}"
implementation "${androidx-appcompat}"
implementation "${google-material}"
}
这种方式相对于 .gradle
方式不需要单独创建 config.gradle
文件,但是同样的也无法快速定位到定义的地方及快速跳转到依赖使用。
使用 buildSrc
配置
在 Kotlin 的支持下,我们又有了新的方案,这个方案依赖于 IDEA 会将 buildSrc
路径作为插件编译到项目以及 Kotlin dsl 的支持,并且解决上面两个方案依赖无法快速跳转问题;
使用方式如下:
- 在项目根目录新建文件夹
buildSrc
,并在该路径下新建build.gradle.kts
文件,该文件使用 Kotlin 语言配置
repositories {
google()
mavenCentral()
}
plugins {
// 使用 kotlin-dsl 插件
`kotlin-dsl`
}
- 在
buildSrc
中添加源码路径src/main/kotlin
,并在源码路径下添加依赖配置Dependencies.kt
object Dependencies {
const val ANDROIDX_CORE_KTX = "androidx.core:core-ktx:1.3.2"
const val ANDROIDX_APPCOMPAT = "androidx.appcompat:appcompat:1.2.0"
const val GOOGLE_MATERIAL = "com.google.android.material:material:1.3.0"
}
- 在各个
module
中的build.gradle.kts
文件中使用依赖
...
dependencies {
implementation(Dependencies.ANDROIDX_CORE_KTX)
implementation(Dependencies.ANDROIDX_APPCOMPAT)
implementation(Dependencies.GOOGLE_MATERIAL)
}
这个方案的优点正如上面所说的,能够快速方便的定位到依赖的定义及使用,其确定就在于因为需要 Kotlin 支持,所以需要向项目中引入 Kotlin 的依赖,并且各个 module
的 build.gradle
配置文件需要转换为 build.gradle.kts
格式。
使用 Composing Builds
配置
Composing Builds
方案的本质和 buildSrc
方案是一样的,都是将对应 module
中的代码编译作为插件,在 build.gradle.kts
中可以直接引用,那为什么还要有 Composing Builds
这种方案呢?这是因为 buildSrc
方案中,如果 buildSrc
中的配置有修改,会导致整个项目都会进行重新构建,如果项目较小可能影响不大,但如果项目过大,那这个缺点显然是无法接受的,Composing Builds
方案应运而生。
使用方式:
- 在项目根目录创建
module
文件夹,名称随意,这里使用plugin-version
,并在文件夹中创建build.gradle.kts
配置文件,内容如下:
plugins {
id("java-gradle-plugin")
id("org.jetbrains.kotlin.jvm") version "1.7.10"
}
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
java {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
dependencies {
// 添加Gradle相关的API,否则无法自定义Plugin和Task
implementation(gradleApi())
implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:1.7.10")
}
gradlePlugin {
plugins {
create("version") {
// 添加插件,下面是包名
id = "xx.xx.xx"
// 在源码路径创建类继承 Plugin<Project>
implementationClass = "xx.xx.xx.VersionPlugin"
}
}
}
- 创建源码目录及包路径
src/main/kotlin/xx.xx.xx
,在包中新建类VersionPlugin
继承org.gradle.api.Plugin
class VersionPlugin : Plugin<Project> {
override fun apply(target: Project) {
}
}
- 在项目根目录下的
settings.gradle.kts
文件中添加includeBuild("plugin-version")
- 最后和
buildSrc
方案一样,在源码路径下新增相关依赖配置,在各个module
中引用即可。
Version Catalogs
配置
从 Gradle 7.0
开始,Gradle
新增了 Version Catalogs
功能,用于在项目之间共享依赖项版本, Gradle
文档中列出的一下优点:
- 对于每个
Catelog
,Gradle
都会生成类型安全的访问器,可以轻松的在IDE
中使用,完成添加依赖; - 每个
Catelog
对生成的所有项目都可见,可以确保依赖版本同步到所有子项目; Catelog
可以声明依赖关系包,这些捆绑包是通常在一起使用的依赖关系组;Catelog
可以将依赖项的组、名称和实际版本分开,改用版本引用,从而可以在多个依赖项中共享版本声明。
接下来我们来学习这种方案的具体使用。
开始使用
使用 Version Catalogs
首先当然是需要项目 Gradle
版本高于 7.0
,之后在项目根路径下的 settings.gradle.kts
中添加配置(因为作者项目用的是 .kts
,groovy
按对应语法添加即可)
dependencyResolutionManagement {
// 版本目录配置
versionCatalogs {
// 创建一个名称为 libs 的版本目录
create("libs") {
// 声明 groovy 依赖
library("groovy-core", "org.codehaus.groovy:groovy:3.0.5")
}
}
}
在上面的配置之后,你就可以在项目中使用对应依赖了。例:build.gradle.kts
dependencies {
implementation(libs.groovy.core)
}
这里有细心的小伙伴就会发现,我们声明的是 groovy-core
,使用的时候却是 libs.groovy.core
,这是因为 Version Catalogs
在根据别名生成依赖时对安全访问器的映射要求,别名必须由 ascii
字符组成,后跟数字,中间分隔只支持 短划线-
、下划线_
、点.
,因此声明别名时可以使用groovy-core
、groovy_core
、groovy.core
,最终生成的都是 libs.groovy.core
。
使用 settings.gradle.kts
配置
就如上面的示例中,我们就是在 settings.gradle.kts
中声明了 groovy-core
的依赖,并且需要的地方使用,接下来我们详细说明对依赖项声明的语法:
dependencyResolutionManagement {
// 版本目录配置
versionCatalogs {
// 创建一个名称为 libs 的版本目录
create("libs") {
// 声明 kotlin 版本
version("kotlin", "1.7.10")
// 声明 groovy 版本
version("groovy", "3.0.5")
// 声明 groovy 依赖
library("groovy-core", "org.codehaus.groovy:groovy:3.0.5")
// 声明 groovy 依赖
library("groovy-nio", "org.codehaus.groovy", "groovy-nio").version("3.05")
// 声明 groovy 依赖使用版本引用
library("groovy-json", "org.codehaus.groovy", "groovy-json").versionRef("groovy")
// 声明 groovy 依赖组
bundle("groovy", listOf("groovy-core", "groovy-json", "groovy-nio"))
// 声明 kotlin 序列化插件
plugin("kotlin-serialization", "org.jetbrains.kotlin.plugin.serialization").versionRef("kotlin")
}
}
这种方式相对统一了依赖版本,却无法做到多项目统一。
使用 libs.versions.toml
配置
还是先看示例代码:
dependencyResolutionManagement {
// 版本目录配置
versionCatalogs {
// 创建一个名称为 libs 的版本目录
create("libs") {
// 不能如此配置,会抛出异常
from(files("./gradle/libs.versions.toml"))
// 可以添加此配置
from(files("./gradle/my-libs.versions.toml"))
}
// 创建一个名称为 configLibs 的版本目录
create("configLibs") {
// 添加配置文件
from(files("./gradle/configLibs.versions.toml"))
}
}
}
在配置版本目录后,出了直接在 .kts
里面添加依赖定义,还可以通过 from
方法从 .toml
文件中加载,.toml
文件一般放在项目根路径下的 gradle
文件夹中。
这里需要注意的是,gradle
有一个默认配置名称为 libs
,如果你创建的版本目录名称是 libs
,那么你就无需通过 from
方法加载 libs.versions.toml
文件,因为 gradle
会默认此配置,你只需在 ./gradle
路径下创建 libs.versions.toml
文件即可,重复添加会导致编译失败;如果你已经有了一个 libs.versions.toml
你也可以在添加以下配置来修改默认配置名称:
dependencyResolutionManagement {
defaultLibrariesExtensionName.set("projectLibs")
}
如果你创建的版本目录名称不是默认配置名称,那么就需要你手动添加 from
方法加载配置;所有版本目录名称建议以 Libs
结尾,否则会有 warning
,提示后续将不支持此命名。
接下来我们来看 .toml
文件的配置规则:
# 声明版本号
[versions]
kotlin = "1.7.10"
groovy = "3.0.5"
# 声明依赖
[libraries]
# groovy
groovy-core = "org.codehaus.groovy:groovy:3.0.5"
groovy-json = { module = "org.codehaus.groovy:groovy-json", version = "3.0.5" }
groovy-nio = { group = "org.codehaus.groovy", name = "groovy-nio", version.ref = "groovy" }
# 声明依赖组
[bundles]
groovy = ["groovy-core", "groovy-json", "groovy-nio"]
# 声明插件
[plugins]
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
这种方式在统一单一项目依赖版本的同时,可以通过分享 .toml
文件来达成多项目依赖版本的统一,但是同样的,同样的文件在不同项目中不可避免是会被修改的,用着用着就不一致了。
使用插件配置
虽然从本地文件导入很方便,但是并不能解决多项目共享版本目录的问题,gradle
提供了新的解决方案,我们可以在一个独立的项目中配置好各个三方依赖,然后将其发布到 maven
等三方仓库中,各个项目再从 maven
仓库中统一获取依赖
插件配置
为了实现此功能,gradle
提供了 version-catalog
插件,再配合 maven-publish
插件,就能很方便的生产插件并发布到 maven
仓库。
新建 gradle
插件项目,修改 build.gradle.kts
plugins {
`maven-publish`
`version-catalog`
}
// 版本目录配置
catalog {
versionCatalog {
// 在这里配置各个三方依赖
from(files("./gradle/libs.versions.toml"))
version("groovy", "3.0.5")
library("groovy-json", "org.codehaus.groovy", "groovy-json").versionRef("groovy")
}
}
// 配置 publishing
publishing {
publications {
create<MavenPublication>("maven") {
from(components["versionCatalog"])
}
}
}
这里需要注意的是,插件项目的 gradle
版本必须要高于 7.0
并且低于使用该插件的项目的版本,否则将无法使用。
插件使用
配置从 maven
仓库加载版本目录
dependencyResolutionManagement {
// 版本目录配置
versionCatalogs {
// 创建一个名称为 libs 的版本目录
create("libs") {
// 从 maven 仓库获取依赖
from("io.github.wangjie0822:catalog:1.1.3")
}
}
}
重写版本
从 maven
仓库中获取版本目录一般来讲就不应该修改了,但是仅一份依赖清单怎么满足我们的开发需求呢,不说各个依赖库都在不断的持续更新,如果我们需要使用的依赖没有在版本目录里面声明呢?我们不可能为了修改一个依赖的版本或者添加一个依赖就频繁的发布Catalog
插件版本,这样成本太高,这就需要我们进行个性化配置了
dependencyResolutionManagement {
// 版本目录配置
versionCatalogs {
// 创建一个名称为 libs 的版本目录
create("libs") {
// 从 maven 仓库获取依赖
from("io.github.wangjie0822:catalog:1.1.3")
// 添加仓库里面没有的依赖
library("tencent-mmkv", "com.tencent", "mmkv").version("1.2.14")
// 修改groovy版本
version("groovy", "3.0.6")
}
}
}
请注意,我们只能重写版本目录里面定义的版本号,所以在定义版本目录时尽量将所有版本号都是用版本引用控制。
使用方式
上面说了那么多的配置定义方式,下面来看看Version Catalogs
的使用方式:
plugins {
// 可以直接使用定义的 version 版本号
kotlin("plugin.serialization") version libs.versions.kotlin
// 也可以直接使用定义的插件
alias(libs.plugin.kotlin.serialization)
}
android {
defaultConfig {
// 其它非依赖的字段可以在版本目录的版本中定义 通过 versions 获取
minSdk = configLibs.versions.minSdk.get().toInt()
targetSdk = configLibs.versions.targetSdk.get().toInt()
versionCode = configLibs.versions.versionCode.get().toInt()
versionName = configLibs.versions.versionName.get()
}
}
dependencies {
// 使用 groovy 依赖
implementation(libs.groovy.core)
// 使用包含 groovy-core groovy-json groovy-no 三个依赖的依赖组
implementation(libs.bundles.groovy)
// 使用 configLibs 中定义的依赖
implementation(configLibs.groovy.core)
}
上面我们已经说过这种方案的优点,可以让我们在所有项目中保持依赖版本的统一,甚至可以分享出去让其他开发者使用;同时也有着和 buildSrc
、Composing Builds
一样的可跳转、可追溯的优点;
但是相比于这两个方案,Version Catalogs
生成的代码只有默认的注释,并且无法直接看到使用的依赖的版本号,而在 buildSrc
、Composing Builds
中我们能够对依赖的功能进行详细的注释,甚至添加上对应的使用文档地址、Github 地址等,如果支持自定义注释,那这个功能就更完美了。
总结
Android 发展至今,各种新技术层出不穷,版本管理也出现了很多方案,这些方案并没有绝对的优劣,还是需要结合实际项目需求来选择的,但是新的方案还是需要学习了解的。
关于 Version Catalogs
插件项目,可以参照 WangJie0822/Catalog (github.com)
关于 Version Catalogs
的方案使用,可以参照 WangJie0822/Cashbook: 记账本 (github.com) 最新代码
如果想要了解 buildSrc
方案,可以参照 WangJie0822/Cashbook: 记账本 (github.com)
作者:王杰0822
链接:https://juejin.cn/post/7130530401763737607
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
线程池及使用场景说明
持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第22天,点击查看活动详情
newFixedThreadPool(固定大小的线程池):
public static ExecutorService newFixedThreadPool(int nThreads) {
return new ThreadPoolExecutor(nThreads, nThreads,
0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>());
}
该线程池特点:
1.核心线程数和最大线程数大小一样
2.keepAliveTime为0
3.阻塞队列使用的是LinkedBlockingQuene(无界队列)
该线程池工作机制:
1.线程数少于核心线程数,新建线程执行任务
2.线程数等于核心线程数时,将任务加到阻塞队列(最大值为Integer.MAX_VALUE),可以一直加加加(可能会出现OOM)
newSingleThreadExecutor(单线程线程池)
public static ExecutorService newSingleThreadExecutor() {
return new FinalizableDelegatedExecutorService (new ThreadPoolExecutor(1, 1, 0L,
TimeUnit.MILLISECONDS, ew LinkedBlockingQueue<Runnable>()));
}
该线程池特点:
1.核心线程数和最大线程数大小一样且都是1
2.keepAliveTime为0
3.阻塞队列是LinkedBlockingQuene
该线程池工作机制:
1.线程中没有线程时,新建线程执行任务
2有一个线程以后,将任务加到阻塞队列(最大值为Integer.MAX_VALUE),可以一直加加加
#### 该线程池特点:
1.核心线程数为0,且最大线程数为Integer.MAX_VALUE
2.阻塞队列是SynchronousQuene(同步队列)
SynchronousQuene:一个不存储元素的阻塞队列。每个插入操作必须等到另一个线程调用移除操作,否则插入操作一直处于阻塞状态,吞吐量要高于LInkedBlockQuene。
锁当提交任务的速度大于处理任务的速度时,每次提交一个任务,就必然会创建一个线程。极端的情况下会创建过多的线程,耗尽CPU和内存资源。由于空闲60秒的线程会被终止,长时间保持空闲的CachedThreadPool不会占用任何资源。
#### 该线程池工作机制:
1.没有核心线程时,直接向SynchronousQuene中提交任务
2.执行完任务的线程有60秒处理时间
newScheduledThreadPoo
该线程池特点:
1.最大线程数为Integer.MAX_VALUE
2.阻塞队列是DelayedWorkQuene(延迟队列)
DelayedWorkQuene中封装了一个优先级队列,这个队列会对队列中的ScheduleFutureTask进行排序,两个任务的执行Time不同时,time小的先执行; 否则比较添加队列中的ScheduledFutureTask的顺序号sequenceNumber,先提交的先执行。
API
ScheduledThreadPoolExecutor添加任务提供了另外两个方法:
1.scheduleAtFixedRate():按某种速率周期执行
2.scheduleWithFixedDelay():在某个延迟后执行
两种方法的内部实现都是创建了一个ScheduledFutureTask对象封装了任务的延迟执行时间及执行周期,并调用decorateTask()方法转成RunnableScheduledFuture对象,然后添加到队列中。
该线程池工作机制:
1.调用上面两个方法添加一个任务
2.线程池中的线程从DelayQuene中取任务
3.然后执行任务
作者:北洋
链接:https://juejin.cn/post/7112061831132708878
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
为什么要使用Kotlin 对比 Java,Kotlin简介
什么是Kotlin
打开Kotlin编程语言的官网,里面大大的写着,
A modern programming languagethat makes developers happier.
是一门让程序员写代码时更有幸福感的现代语言
- Kotlin语法糖非常多,可以写出更为简洁的代码,便于阅读。
- Kotlin提供了空安全的支持,可以的让程序更为稳定。
- Kotlin提供了协程支持,让异步任务处理起来更为方便。
- Google:Kotlin-first,优先支持kotlin,使用kotlin可以使用更多轮子
接下来对比Java举一些例子。
简洁
当定义一个网络请求的数据类时
Java
public class JPerson {
private String name;
private int age;
//getter
//setter
//hashcode
//copy
//equals
//toString
}
Kotlin
data class KPerson(val name: String,val age: Int)
这里用的是Kotlin 的data class
在class 前面加上data
修饰后,kotlin会自动为我们生成上述Java类注释掉的部分
当我们想从List中筛掉某些我们不想要的元素时
Java
List<Integer> list = new ArrayList<>();
List<Integer> result = new ArrayList<>();
for (Integer integer : list) {
if (integer > 0) { //只要值>0的
result.add(integer);
}
}
System.out.println(result);
Kotlin
val list: List<Int> = ArrayList()
println(list.filter { it > 0 })
如上代码,都能达到筛选List中 值>0 的元素的效果。
这里的filter
是Kotlin提供的一个拓展函数,拓展函数顾名思义就是拓展原来类中没有的函数,当然我们也可以自定义自己的拓展函数。
当我们想写一个单例类时
Java
public class PersonInJava {
public static String name = "Jayce";
public static int age = 10;
private PersonInJava() {
}
private static PersonInJava instance;
static {
instance = new PersonInJava();
}
public static PersonInJava getInstance() {
return instance;
}
}
Kotlin
object PersonInKotlin {
val name: String = "Jayce"
val age: Int = 10
}
是的,只需要把class
换成object
就可以了,两者的效果一样。
还有很多很多,就不一一举例了,接下来看看空安全。
安全
空安全
var name: String = "Jayce" //name的定义是一个非空的String
name = null //将name赋值为null,IDE会报错,编译不能通过,因为name是非空的String
var name: String? = "Jayce" //String后面接"?"说明是一个可空的String
name.length //直接使用会报错,需要提前判空
//(当然,Kotlin为我们提供了很多语法糖,我们可以很方便的进行判空)
类型转换安全
fun gotoSleep(obj: Any) {
if (obj is PersonInKotlin) {//判断obj是不是PersonInKotlin
obj.sleep() // 在if的obj已经被认为是PersonInKotlin类型,所以可以直接调用他的函数,调用前不需要类型转换
}
}
携程
这里只是简单的举个例子
Kotlin的协程不是传统意义上那个可以提高并发性能的协程序
官方的对其定义是这样的
- 协程是一种并发设计模式,您可以在Android平台上使用它来简化异步执行的代码
- 程序的逻辑可以在协程中顺序地表达,而底层库会为我们解决其异步性。
当我们用Java请求网络数据时,一般是这么写的。
getPerson(new Callback<Person>() {//这里有一个回调
@Override
public void success(Person person) {
runOnUiThread(new Runnable() { //切换线程
@Override
public void run() {
updateUi(person)
}
})
}
@Override
public void failure(Exception e) {
...
}
});
有Kotlin协程后我们只需要这么写
CoroutineScope(Dispatchers.Main).launch { //启动一个协程
val person = withContext(Dispatchers.IO) {//切换IO线程
getPerson() //请求网络
}
updateUi(person)//主线程更新UI
}
他们两个都干的同一件事,最明显的区别就是,代码更为简洁了,如果在回调里面套回调的话回更加明显,用Java的传统写法就会造成人们所说的CallBack Hell。
除此之外协程还有如下优点
- 轻量
- 更少的内存泄漏
- 内置取消操作
- 集成了Jatpack
这里就不继续深入了,有兴趣的同学可以参考其他文章。
Kotlin-first
在Google I/O 2019的时候,谷歌已经宣布Kotlin-first
,建议Android开发将Kotlin作为第一开发语言。
为什么呢,总结就是因为Kotlin简洁、安全、兼容Java、还有协程。
至于有没有其他原因,我也不知道。(手动狗头)
Google将为更多的投入到Kotlin中来,比如
为Kotlin提供特定的APIs (KTX, 携程, 等)
提供Kotlin的线上练习
示例代码优先支持Kotlin
Jetpack Compose,这个是用Kotlin开发的,没得选。。。。。
跨平台开发,用Kotlin实现跨平台开发。
好的Kotlin就先介绍到这里,感兴趣的同学就快学起来吧~
接下来在其他文章会对Kotlin和携程进行详细的介绍。
作者:JayceonDu
链接:https://juejin.cn/post/7130198991106637832
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
从权限系统的菜单管理看算法和数据结构
菜单管理,感觉上是个小模块,但实际做下来的感触是,要做的好用,不容易。
算法和数据结构,长期活跃在面试题中,实际业务中好像接触的不多,但如果能用好,可以解决大问题。
如上图,是我在开源世界找到的一个菜单管理的设计页面,其上可以看到,菜单管理主要管理一颗菜单树,可以增删改查、排序等功能。接下来,我们就一步一步的实现菜单管理的各个功能,这里我主要介绍后端的设计方案,不涉及前端页面的处理和展示。
type IMenu interface {
MenuList(ctx context.Context, platformId int, menuName string, status int) ([]*model.MenuListResp, response.Error)
GetMenuInfo(ctx context.Context, menuId int) (*model.MenuInfo, response.Error)
EditMenu(ctx context.Context, params *model.MenuEditParams) response.Error
DeleteMenu(ctx context.Context, platformId, menuId int) response.Error
AddMenu(ctx context.Context, params *model.MenuAddParams) response.Error
MenuExport(ctx context.Context, platformId int) ([]byte, response.Error)
MenuImport(ctx context.Context,platformId int,jsonData []byte) response.Error
MenuSort(ctx context.Context, platformId int, tree []*model.MenuTree) response.Error
}
可以看到,菜单管理模块主要分三块,一是对菜单的增删改查、二是菜单的导入导出、三是菜单的排序。 由于菜单模块天然的就是一个树状结构,所以我就想到能不能在代码中用树作为处理菜单时的数据结构,把这个模块做完后,我发现这种思路是对的,可以解决菜单树的重排问题,无需在手动填写排序字段(就是人工为菜单的顺序赋值)。
首先我们设计数据库,由于我们确认了以树处理菜单的思路,所以在表设计时,我把菜单信息和菜单树剥离,首先避免了字段过多,其次在用menu_id去获取单个菜单信息时,就完全不用遍历树了,直接从菜单信息表中拿数据,而且表分开后,代码逻辑也更清晰了。
介绍一下表中的关键字段,其它字段可忽略:
表名 | 字段名 | 说明 |
---|---|---|
menu_tree | platform_id | 这颗菜单树属于哪个业务系统 |
menu_tree | parent_menu_id | 节点的父节点id,没有父则为0 |
menu_tree | menu_id | 节点自己的id,就是 menu_info 表的主键 |
menu_tree | index | 排序字段 |
menu_info | alias | 菜单唯一标识 |
一、 添加菜单
添加菜单接口所需的信息如下:
type MenuAddParams struct {
PlatformId int `json:"platform_id" v:"required"` // 平台ID
Type int64 `json:"type" v:"required|in:1,2,3,4,5"` // 菜单类型 1菜单2子菜单3按钮4页签5数据标签
ParentMenuId int `json:"parent_menu_id"` // 上级菜单,没有则传0
// 菜单信息
Name string `json:"name" v:"required|length:3,20"` // 菜单名
...
}
添加菜单很简单,由于前端参数中已经存在父菜单id,我们只需要把这个菜单节点加到这个父菜单的子节点列表的末尾即可。
这里唯一需要注意的就是,我们需要 select MAX(index)
获取父节点的子节点列表中index最大的值,然后+1作为新节点的index,这样就可以把新节点添加到列表末尾。但要注意并发问题,比如两个人同时添加菜单,有可能会导致index一样。
解决方案就是把newIndex放到redis里,然后在添加时判断redis里的index是否>=数据库的maxIndex,理论上一定成立,不成立则说明newIndex还未写进db,流程图如下:
使用分布式锁,或者修改
select MAX(index)
的事务隔离级别也可以实现。
二、菜单列表
菜单列表与其说是列表,不如说是菜单树,树状返回整个菜单结构,不分页,两个原因:一、我们要在菜单列表上做拖曳排序,需要整个菜单树结构;二、菜单数据有它本身的特点,再多也就千级别顶天了,而且我们在数据存储时就区分了菜单树表(menu_tree)和(menu_info),这时获取菜单列表只需要访问 menu_tree,菜单信息可以懒加载,需要时再通过 menu_id 主键id获取,速度很快。
接口返回结构设计如下:
{
"data": {
"children": [
{
"children": [
{
"children": [],
"menu_id": 2,
"name": "子菜单1",
"type": 1
}
],
"menu_id": 1,
"name": "菜单1",
"type": 1
}
],
"menu_id": 0,
"name": "",
"type": 0
},
"errmsg": "",
"errno": 0
}
我们用一个 menu_id 为0 的节点作为根节点,因为树需要一个唯一的根。
这里有人可能会说,不对啊,你 menu_tree 表里没有name和type,你不还是得去menu_info里获取吗?确实,实践中我确实有访问menu_info,但我感觉这两个字段其实可以冗余存储到 menu_tree中,这样优化后,就真的不用访问 menu_info了,只不过我模块已经写完了,就懒得改了,哈哈。
这个数据返给前端,他直接渲染成树即可,点击树中某个节点,前端拿到这个节点的menu_id,去GetMenuInfo获取菜单信息,前面说过,这个获取很简单,就是主键id查找。
要建立这样一个返回结构,用如下关键代码实现即可:
type MenuTree struct {
MenuId int `json:"menu_id"` // 菜单ID
Name string `json:"name"` // 菜单名
Type int64 `json:"type"` // 菜单类型 1菜单2子菜单3按钮4页签5数据标签
Children []*MenuTree `json:"children"` // 子菜单
}
func (s *sMenu) MenuTree(ctx context.Context, platformId int) (*model.MenuTree, error) {
// 构造一个根节点
root := &model.MenuTree{}
err := s.menuTreeChildren(ctx, root)
if err != nil{
return nil, err
}
return root, nil
}
func (s *sMenu) menuTreeChildren(ctx context.Context, node *model.MenuTree) (error) {
if node.MenuId != 0{
menuInfo, err := dao.MenuInfo.GetMenuInfo(
ctx,
node.MenuId,
dao.MenuInfo.Columns().Id,
dao.MenuInfo.Columns().Name,
dao.MenuInfo.Columns().Type,
)
if err != nil {
return gerror.Wrap(err, "")
}
node.Name = menuInfo.Name
node.Type = menuInfo.Type
}
// 获取子节点列表
// select * from menu_tree where platform_id = ? and parent_menu_id = ?
childs, err := dao.MenuTree.GetMenuTreeChild(ctx, platformId, tree.MenuId)
if err != nil {
return gerror.Wrap(err, "")
}
treeChilds := make([]*model.MenuTree, len(childs))
for i, e := range childs {
tree := &model.MenuTree{
MenuId: e.Id,
}
err := s.menuTreeChildren(ctx, platformId, tree)
if err != nil{
return err
}
treeChilds[i] = tree
}
node.Children = treeChilds
return nil
}
这个构造过程有没有一点点熟悉?这不就是树的dfs前序遍历算法的递归版本吗?
通过这种方式,我们把数据库中的树加载到内存中,后续的很多操作,都依赖对树的遍历实现。
三、编辑菜单、获取菜单信息
这两个操作都是只针对 menu_info 表的,用menu_id主键操作就行,没什么技术含量。
四、删除菜单
删除怎么实现呢?前面说过,后续的很多操作都是在对树进行遍历,那么删除也就很简单了,我们通过menuId确定要删除的节点,并遍历它的所有子节点,放到 treeIds 这个收集器中,当子节点遍历完毕时,把treeIds中收集的id取出来,去删除这条记录即可。
// menuIds 负责在遍历过程中收集所有的 menus_ids,treeIds 负责在遍历过程中收集 menu_tree表的主键
func (s *sMenu) deleteMenuTree(ctx context.Context, platformId, menuId int, menuIds []int, treeIds []int) ([]int, []int, error) {
// 把menuId从菜单树中删除,并递归删除它的所有子菜单
child, err := dao.MenuTree.GetMenuTreeChild(ctx, platformId, menuId)
if err != nil {
return menuIds, treeIds, err
}
menuIds = append(menuIds, menuId)
menuTree, err := dao.MenuTree.GetMenuTreeByMenuId(ctx, platformId, menuId, dao.MenuTree.Columns().Id)
if err != nil {
return menuIds, treeIds, err
}
treeIds = append(treeIds, int(menuTree.Id))
for _, e := range child {
menuIds, treeIds, err = s.deleteMenuTree(ctx, platformId, int(e.MenuId), menuIds, treeIds)
if err != nil {
return menuIds, treeIds, err
}
}
return menuIds, treeIds, nil
}
后续的删除代码就不再展示了,主键都收集完毕了,DELETE FROM ... WHERE id IN ()
一条语句搞定即可。
五、菜单排序
来到本文的重点,菜单重排序,很多系统都是直接让用户填排序字段,非常容易出错,用过的人都知道,不太好用。我们直接来实现拖曳排序,而且可以任意拖曳,把节点拖到其它父节点,把节点拖到顶级菜单等,都可以实现。
首先前端同学需要实现拖曳组件,然后直接把拖曳后的整个菜单树回给我们即可,我们负责检查树的节点发生了什么变动。
前端同学也可以直接检测被拖曳的菜单id,和拖曳后的位置,把这些信息发给后端即可。不过为了让前端同学早点下班,这些活还是我们交给我们来干把。
我们现在拿到了前端发回的树结构,字段和数据跟我们通过菜单列表返回的json数据一致,只是其中有一个被拖曳的节点,不再原来的位置,它可能跟兄弟节点交换了顺序,可能变更了父节点,也可能变为顶级菜单。如何找到被拖曳的是哪个节点,并找到它拖曳后的位置和顺序呢?
在算法知识中,有一个很重要的思想,就是分治,当我们碰到比较复杂的问题时候,就一定要把它拆解为几个子问题解决,针对这个场景,我们可以拆分为以下几个问题:
- 如果被拖曳的节点变更了父节点,我们如何找到它的位置和顺序?
我们先序dfs遍历这个新的菜单树,每个节点都去数据库查询它的parent_menu_id,如果发现数据库的父节点id跟新菜单树的父节点id对不上,则可以断定这个节点是被拖曳过来的,同时也就知道了它的新位置和顺序。 - 如果被拖曳的节点未变更父节点,只是变更了顺序,我们如何找到它?
我们后序dfs遍历这个新菜单树,收集当前节点的子节点列表list1,并且从数据库中拉出当前节点的子节点列表list2,- 如果 list1.len < list2.len
说明这个节点有子节点被拖曳走了,我们不需要管这个节点,因为我们不关注被拖曳节点原先在哪儿。这个情况直接忽略即可 - 如果 list1.len > list2.len 说明有节点被拖曳进来,那我们遍历list1和list2找不同即可。
- 如果 list1.len == list2.len 也是遍历list1和list2,看看它们两是不是完全一样。
- 如果 list1.len < list2.len
- 找到被拖曳的节点以及它的新位置和顺序后,如何更新到数据库?
更新这个节点的父节点id,并要判断它的顺序- 它的新顺序就在父节点的子节点列表末尾
子节点列表(有序的)最后一个节点的index + 1即可 - 它在子节点列表的开头或者中间
它获得原节点的index,后续节点的index依次+1 - 原先就没有子节点,它是第一个
那它的index设为1即可
- 它的新顺序就在父节点的子节点列表末尾
具体的代码实现如下:
func (s *sMenu) MenuSort(ctx context.Context, platformId int, tree []*model.MenuTree) response.Error {
s.lockSortMenu(ctx, platformId)
defer s.unlockSortMenu(ctx, platformId)
err = s.dfsTreeSort(ctx, platformId, &model.MenuTree{
Children: tree,
}, 0)
if err != nil && err != StopDFS {
return nil, response.NewErrorAutoMsg(
http.StatusServiceUnavailable,
response.ServerError,
).WithErr(err)
}
return
}
var StopDFS = gerror.New("STOP")
// 这个dfsTreeSort遍历时有个隐含条件,由于我们知道被拖曳的节点只有一个,所以我们找到这个节点后,马上就可以终止遍历
func (s *sMenu) dfsTreeSort(ctx context.Context, platformId int, node *model.MenuTree, parentTreeId int) error {
if node == nil {
return nil
}
childsMenus := make([]*model.MenuTree, 0)
for i := 0; i < len(node.Children); i++ {
n := node.Children[i]
mt, err := dao.MenuTree.GetMenuTreeByMenuId(ctx, platformId, n.MenuId, dao.MenuTree.Columns().ParentTreeId)
if err != nil {
return err
}
if int(mt.PlatformId) != parentTreeId {
// 发现被移动节点
err = s.swapTreeNode(ctx, platformId, n, parentTreeId, i)
if err != nil {
return err
}
return StopDFS // 终止遍历
}
// 收集子菜单
childsMenus = append(childsMenus, n)
err = s.dfsTreeSort(ctx, platformId, n, n.MenuId)
if err != nil {
return err
}
}
// 判断子节点列表的顺序
if node.MenuId != 0 && len(childsMenus) > 0 {
oldChilds, err := dao.MenuTree.GetMenuTreeChild(ctx, platformId, node.MenuId)
if err != nil {
return err
}
if len(childsMenus) < len(oldChilds) {
// 这个情况不处理
return nil
}
for i := 0; i < len(childsMenus); i++ {
if i < len(oldChilds) && childsMenus[i].MenuId != int(oldChilds[i].MenuId) {
// 发现被移动节点
err = s.swapTreeNode(ctx, platformId, childsMenus[i], parentTreeId, i)
if err != nil {
return err
}
return StopDFS
}
}
// 前面顺序如果都一样,那必然是最后一个节点新增的
if len(childsMenus) > len(oldChilds){
L := len(childsMenus) - 1
err = s.swapTreeNode(ctx, platformId, childsMenus[L], parentTreeId, L)
if err != nil {
return err
}
return StopDFS
}
}
return nil
}
func (s *sMenu) swapTreeNode(ctx context.Context, platformId int, node *model.MenuTree, newParentMenuId int, index int) error {
tx, err := g.DB().Begin(ctx)
if err != nil {
return gerror.Wrap(err, "")
}
ctx = context.WithValue(ctx, "tx", tx)
childs, err := dao.MenuTree.GetMenuTreeChild(ctx, platformId, newParentMenuId)
if err != nil {
return err
}
var newIndex int64 = 1
if len(childs) == 0 {
// 原先没有子节点
newIndex = 1
}
if len(childs) != 0 && index > len(childs)-1 {
// 在末尾
newIndex = childs[len(childs)-1].Index + 1
}
if len(childs) != 0 {
// 在中间 或者在开头
for i := index; i < len(childs); i++ {
newIndex = childs[i].Index
for j, e := range childs[i:] {
fileds := make([]interface{}, 0)
fileds = append(fileds, dao.MenuTree.Columns().Index)
if int(e.MenuId) == node.MenuId{
// 不遍历到自己
continue
}
err = dao.MenuTree.EditMenuTree(ctx, &entity.MenuTree{
Id: e.Id,
Index: newIndex + int64(j) + 1,
}, fileds...)
if err != nil {
tx.Rollback()
return err
}
}
}
}
fileds := make([]interface{}, 0)
fileds = append(fileds, dao.MenuTree.Columns().ParentTreeId)
fileds = append(fileds, dao.MenuTree.Columns().Index)
err = dao.MenuTree.EditMenuTreeByMenuId(ctx, &entity.MenuTree{
MenuId: int64(node.MenuId),
PlatformId: int64(platformId),
ParentTreeId: int64(newParentMenuId),
Index: newIndex,
}, fileds...)
if err != nil {
tx.Rollback()
return err
}
tx.Commit()
return nil
}
在菜单排序操作时,最好也加上分布式锁,菜单排序时禁止添加菜单、导入菜单等操作。
六、菜单导入、导出
菜单导入、导出在实践中也是非常有用的,常用的场景是:测试环境添加、更新、删除菜单后,想同步到正式环境,难道要再操作一遍吗?简单的办法就是导出测试环境的菜单,再导入到正式环境即可。
1. 菜单导出
既然已经获取到了菜单列表,那么把它导出成json文件也不是什么难事,这里的矛盾是,菜单列表里返回的菜单树信息是不全的,我们需要补充信息,导出一颗完整的菜单树:
type MenuExportTree struct {
MenuInfo
Children []*MenuExportTree `json:"children"` // 子菜单
}
type MenuTree struct {
MenuId int `json:"id"` // 菜单ID
ParentMenuId int `json:"parent_id"` // 父菜单ID
Name string `json:"name"` // 菜单名
Type int64 `json:"type"` // 菜单类型 1菜单2子菜单3按钮4页签5数据标签
Children []*MenuTree `json:"lists"` // 子菜单
}
菜单列表返回的字段是不全的,我们需要拿到 menu_info 表的所有字段,这里有两种思路,一种是仿照菜单列表的写法再写一遍,但是这次建立树节点时要获取下 menu_info 表的信息;另一种做法则是调用菜单列表获取到菜单树,然后做一个树克隆的算法,在克隆的过程中,把 menu_info 的信息写进去。
这里介绍第二种做法,原树的节点(MenuTree)克隆一个新树(MenuExportTree),这里我们为了炫技复习基础,换用BFS来遍历树和克隆树。
// BFS 树克隆,原树 node ,新树 newNode
// BFS 遍历原树,在遍历过程中,建立新树节点
func (s *sMenu) bfsTreeCopy(node *model.MenuTree, newNode *model.MenuExportTree) {
p := node
if p == nil {
return
}
q := newNode
// isVisit是防止树中有回环指向,在菜单树中其实不存在回环,其实可以不要。
isVisit := make(map[*model.MenuTree]int)
queueP := list.New() // P 原树队列
queueQ := list.New() // Q 新树队列
queueP.PushBack(p)
queueQ.PushBack(q)
for queueP.Len() != 0 {
size := queueP.Len()
for i := 0; i < size; i++ {
e := queueP.Front()
eq := queueQ.Front()
p = e.Value.(*model.MenuTree)
q = eq.Value.(*model.MenuExportTree)
if _, ok := isVisit[p]; !ok {
q.MenuId = p.MenuId
q.Children = make([]*model.MenuExportTree, 0)
if q.MenuId != 0 {
// 获取 menu_info 表数据
menuInfo, _ := dao.MenuInfo.GetMenuInfo(
context.Background(),
q.MenuId,
dao.MenuInfo.Columns().Status,
dao.MenuInfo.Columns().Icon,
dao.MenuInfo.Columns().CreateTime,
)
q.MenuInfo = menuInfo
}
isVisit[p] = 1
}
for _, child := range p.Children {
queueP.PushBack(child)
t := &model.MenuExportTree{}
q.Children = append(q.Children, t)
queueQ.PushBack(t) // 推一个空的新节点到queueQ,下次循环会为其赋值
}
queueP.Remove(e)
queueQ.Remove(eq)
}
}
}
BFS 比较擅长处理需要针对每层节点进行操作的情况, DFS则可以在遍历时方便的获取到父节点的id,大部分时候我们选择一种遍历算法使用即可。
2. 菜单导入
导入则比较简单了,这里的导入是指我们用导入数据覆盖原数据,比较简单。如果要支持导入部分树节点,则可能比较麻烦,不能用 menu_id 数据库主键作为菜单唯一标识了,因为不同环境的主键不同。需要为菜单生成唯一标识,比如 menu_key 之类的字段,然后用它作为导入时定位菜单的依据。
所以不如简单点,导入就是用导入的数据覆盖原来数据,步骤就是,删除原来的菜单树,然后建立一颗新的菜单树。
// 根据 MenuExportTree 建立一个新的菜单树写入数据库中
func (s *sMenu) dfsTreeImport(ctx context.Context, tx *gdb.TX, root *model.MenuExportTree, parentMenuId int, index int) error {
if root == nil {
return nil
}
menuId := 0
// 前序遍历,写入 menu_info 表
if len(root.Name) != 0 {
menuInfo := &entity.MenuInfo{
PlatformId: int64(root.PlatformId),
Name: root.Name,
Type: root.Type,
Icon: root.Icon,
IsOutlink: root.IsOutLink,
RouteUrl: root.RouteData,
Status: root.Status,
ShowTime: root.Showtime,
BackendApi: root.BackendApi,
DataLabels: gjson.New(root.DataLabels.Data),
}
err := dao.MenuInfo.AddMenuInfoReturnId(ctx, menuInfo)
if err != nil {
_ = tx.Rollback()
return gerror.Wrap(err, "")
}
menuId = int(menuInfo.Id)
}
for i := 0; i < len(root.Children); i++ {
n := root.Children[i]
err := s.dfsTreeImport(ctx, tx, n, menuId, i+1)
if err != nil {
_ = tx.Rollback()
return err
}
}
// 当遍历完这个节点的子节点后,把这个节点写入 menu_tree
if len(root.Name) != 0 {
tree := &entity.MenuTree{
PlatformId: int64(root.PlatformId),
ParentTreeId: int64(parentMenuId),
MenuId: int64(menuId),
Index: int64(index),
}
err := dao.MenuTree.AddMenuTree(ctx, tree)
if err != nil {
_ = tx.Rollback()
return err
}
}
return nil
}
七、参考文献
- LeetCode
- gitee.com/fe.zookeepe… (图源)
- 代码风格(GoFrameV2)
作者:FengY_HYY
链接:https://juejin.cn/post/7129759332299702285
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
Android代码检查之自定义Lint
概述
Lint 是 Android studio 提供的一款静态代码检查工具,它可以帮助我们检查 Android 项目源文件是否有潜在的 bug,以及在正确性、安全性、性能、易用性、无障碍性和国际化方面是否需要优化改进。Lint 的好处不言而喻,它能够在编码阶段就帮我们提前发现代码中的“坏味道”,显著降低线上问题出现的概率;同时也能有效促进团队的开发规范的统一。
关于执行 lint 检查的几种方式不多做赘述,接下来着重来看下如何实现自定义 Lint 规则并应用到实际项目中。
自定义 Lint 接入方案
自定义 Lint 规则最终都会打成 JAR 包,只需将该输出 JAR 提供给其他组件使用即可。目前有两种方式可供选择:
全局方案
把此 jar 拷贝到 ~/.android/lint/
目录中即可。缺点显而易见:针对所有工程生效,会影响同一台机器其他工程的 Lint 检查。即便触发工程时拷贝过去,执行完删除,但其他进程或线程使用 ./gradlew lint
仍可能会受到影响。
AAR 壳方案
另一种实现方式是将 jar 置于一个 aar 中,如果某个工程想要接入执行自定义的 lint 规则,只需依赖这个发布后的 aar 即可,如此一来,新增的 lint 规则就可将影响范围控制在单个项目内了。另外,该方案也是 Google 目前推荐的方式,aar 内容也支持 lint.jar
条目:
AAR 文件的文件扩展名为 .aar,Maven 工件类型应该也是 aar。此文件本身是一个 zip 文件。唯一的必需条目是 /AndroidManifest.xml。AAR 文件可能包含以下一个或多个可选条目:
xx.aar
|-/classes.jar
|-/res/
|-/R.txt
|-/public.txt
|-/assets/
|-/libs/name.jar
|-/jni/abi_name/name.so(其中 abi_name 是 Android 支持的 ABI 之一)
|-/proguard.txt
|-/lint.jar
|-/api.jar
|-/prefab/(用于导出原生库)
具体可参考 Android 官方对于 aar 的介绍:developer.android.com/studio/proj…
编写自定义 Lint 规则
接下来主要从以下几个方面来介绍自定义 Lint 的开发流程。
1. 创建 java-library & 配置 lint 依赖
自定义的 lint 规则最终输出格式为 jar 包,所以我们只需要创建一个 java-library 即可,build.gradle
配置如下:
lint-rules/build.gradle
plugins {
id 'java-library'
id 'org.jetbrains.kotlin.jvm'
}
dependencies {
// 官方提供的Lint相关API,并不稳定,每次AGP升级都可能会更改,且并不是向下兼容的
compileOnly "com.android.tools.lint:lint-api:${rootProject.ext.lintVersion}"
// 目前Android中内置的lint检测规则
compileOnly "com.android.tools.lint:lint-checks:${rootProject.ext.lintVersion}"
compileOnly "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlinVersion"
testImplementation "junit:junit:4.13.2"
testImplementation "com.android.tools.lint:lint:$lintVersion"
testImplementation "com.android.tools.lint:lint-tests:$lintVersion"
}
java {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
jar {
manifest {
// Only use the "-v2" key here if your checks have been updated to the
// new 3.0 APIs (including UAST)
attributes('Lint-Registry-V2': 'com.dorck.lint.rules.old.MyCustomIssueRegistry')
}
}
configurations {
lintJarOutput
}
dependencies {
lintJarOutput files(jar)
}
defaultTasks 'assemble'
配置期间如果发现如下问题:
需要将 java 闭包中的 sourceCompatibility
和 targetCompatibility
改为 1.8。
此外,如果你创建 module 时选择的是 kotlin 语言,还可能会遇到以下这个坑:
只需要将 kotlin 标准库依赖方式改为 compileOnly 即可:
compileOnly "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlinVersion"
2. 编写 lint-rules
平时经常使用 kotlin 开发项目的同学应该都遇到过这种情况:一旦我们希望类 A 实现一个接口 B,那么通过 AS 快捷键 option+ enter
选择 implement members
后就会为我们的类 A 自动实现 B 中接口,并加了一堆 TODO 方法:
目前编码环境并不会提示任何错误,然而,如果我们粗心忘记去掉上面接口实现中的 TODO
方法,一旦我们其他类调用到这个类 SomethingNew
,程序就立马抛出一个 NotImplementedError
异常。显然,如果前置静态代码检查阶段没有拦住这个问题进而跑到了线上,那么就只能祈祷别人不会去调用了,否则故障在所难免了。好了,既然需求过来了,我就来尝试通过自定义 Lint 帮助团队其他成员在编码阶段就发现问题并强制处理。
首先,在上一步中,我们在 lint-rules/build.gradle
中指定了自定义的 MyCustomIssueRegistry
,现在里面空空如也,我们需要先创建一个 Detector 用于检测 Standard.kt
中的 TODO()
方法:
@Suppress("UnstableApiUsage")
class KotlinTodoDetector : Detector(), Detector.UastScanner {
override fun getApplicableMethodNames(): List<String> {
return listOf("TODO")
}
override fun visitMethodCall(context: JavaContext, node: UCallExpression, method: PsiMethod) {
println("KotlinTodoDetector >>> matched TODO in [${method.parent.containingFile.toString()}]")
if (context.evaluator.isMemberInClass(method, "kotlin.StandardKt__StandardKt")) {
val deleteFix = fix().name("Delete this TODO method")
.replace().all().with("").build()
context.report(
ISSUE,
context.getLocation(node),
"You must fix `TODO()` first.", deleteFix)
}
}
companion object {
private const val ISSUE_ID = "KotlinTodo"
val ISSUE = Issue.create(
ISSUE_ID,
"Detecting `TODO()` method from kotlin/Standard.kt.",
"""
You have unimplemented method or undo work marked by `TODO()`,
please implement it or remove dangerous TODO.
""",
category = Category.CORRECTNESS,
priority = 9,
severity = Severity.ERROR,
implementation = Implementation(KotlinTodoDetector::class.java, Scope.JAVA_FILE_SCOPE),
)
}
}
此处我们需要检测的对象是 Java 源文件,这里只需要继承自 Detector
并实现 Detector.UastScanner
接口即可。当然,我们也可以选择按组合方式实现更多其他 Scanner,这取决于我们希望扫描的文件范围。目前支持的扫描范围有:
- UastScanner:扫描 Java 或者 kotlin 源文件
- ClassScanner:扫描字节码或编译的类文件
- BinaryResourceScanner:扫描二进制资源文件(res/raw/bitmap等)
- ResourceFolderScanner:扫描资源文件夹
- XmlScanner:扫描 xml 格式文件
- GradleScanner:扫描 Gradle 格式文件
- OtherFileScanner:其他类型文件
检测 Java 源文件,可以通过 getApplicableMethodNames
指定扫描的方法名,其他还有类名、文件名、属性名等等,并通过 visitMethodCall
接受检测到的方法。这里我们只需要检测 Kotlin 标准库中的 Standard.kt
中的 TODO
方法,匹配到后通过 context.report
来报告具体问题,这里需要指定一个 Issue 对象来描述问题具体信息,相关字段如下:
- id : 唯一值,应该能简短描述当前问题。利用 Java 注解或者 XML 属性进行屏蔽时,使用的就是这个 id。
- summary : 简短的总结,通常5-6个字符,描述问题而不是修复措施。
- explanation : 完整的问题解释和修复建议。
- category : 问题类别。常见的有:CORRECTNESS、SECURITY、COMPLIANCE、USABILITY、LINT等等。
- priority : 优先级。1-10 的数字,10 为最重要/最严重。
- severity : 严重级别:Fatal, Error, Warning, Informational, Ignore。
- Implementation : 为 Issue 和 Detector 提供映射关系,Detector 就是当前 Detector。声明扫描检测的范围Scope,Scope 用来描述 Detector 需要分析时需要考虑的文件集,包括:Resource文件或目录、Java文件、Class文件等。
此外,我们还可以设置出现该 issue 上报时的默认解决方案 fix,这里我们创建了一个 deleteFix
实现开发者快速移除报错位置的 TODO
代码。
最后,只需要自定义一个 Registry 声明自己需要检测的 Issues 即可:
@Suppress("UnstableApiUsage")
class MyCustomIssueRegistry : IssueRegistry() {
init {
println("MyCustomIssueRegistry, run...")
}
override val issues: List<Issue>
get() = listOf(
JcenterDetector.ISSUE,
KotlinTodoDetector.ISSUE,
)
override val minApi: Int
get() = 8 // works with Studio 4.1 or later; see com.android.tools.lint.detector.api.Api / ApiKt
override val api: Int
get() = CURRENT_API
override val vendor: Vendor
get() = Vendor(
vendorName = "Dorck",
contact = "xxx@gmail.com"
)
}
更多关于 AST 相关类及语法介绍可参考官方指导文档或者 Lint 源码,此处不多做介绍,这里很难一言以蔽之。
3. Lint 发布&接入
文章开头部分已经介绍了 Lint 的相关接入方案,出于灵活性和可用性角度考虑自然选择 aar 壳的方式。经过这几年 lint 的发展,实现起来也很简单:只需要创建一个 Android-Library module,然后稍微配置下 gradle 即可:
lint-aar:
plugins {
id 'com.android.library'
id 'org.jetbrains.kotlin.android'
}
dependencies {
lintPublish project(':checks')
// other dependencies
}
就是这么简单,此处的 lintPublish
配置允许我们引用另一个 module,它会获取该组件输出的 jar 并将其打包为 lint.jar
然后放到自身的 AAR 中。
最后,我们在 app 模块中依赖一下 lint-aar
这个组件,并编写以下测试代码:
interface SimpleInterface {
fun initialize()
fun doSomething()
}
class SomethingNew : SimpleInterface {
override fun initialize() {
TODO("Not yet implemented")
}
override fun doSomething() {
TODO("Not yet implemented")
}
}
接下来执行一下 ./gradlew :app:lint
即可看到控制台输出以下内容:
我们也可以点击 Lint 输出的测试报告链接去查看详细信息:
Note:AGP 7.0 开始,执行
./gradlew :app:lint
只会作用于默认变体 lint 任务上,而不是诸如此前的执行所有变体 lint 任务。例如:我们此前执行
./gradlew :app:lint
可能会导致debugLint
、releaseLint
、releaseChinaLint
等诸多变体 lint 任务的执行,严重拖慢了编译速度,所以一般要指定特定变体的 lint 任务来执行:./gradlew :app:lintDebug
。而7.0开始将无需如此麻烦,尽管放心使用./gradlew :app:lint
即可。
最后,在 Android studio 中我们也可以看到编译器给我们的代码警告了:
并且我们上面设置的 deleteFix 也生效了,即点击 Delete this TODO method
就可以轻松移除 TODO()
方法,快速解决问题。
4. 编写测试代码
TTD(Test-Driven Development)是一个不错的习惯,很多时候作为开发人员大多时候无需关心最新编写的 lint 组件发布状态,因为不断发布和集成到示例代码中测试是一个比较糟糕的体验,严重消耗我们的精力。如此一来,我们就不得不了解下 lint 规则编码时的单测流程了,我相信能够显著提升你的开发效率。
首先,我们需要依赖 Lint 的单测组件:
testImplementation "com.android.tools.lint:lint-tests:$lintVersion"
接着,在 lint-rules
模块中创建单测文件用于验证我们之前的 KotlinTodo
规则:
最后来看下 KotlinTodoDetectorTest
如何实现的:
package com.dorck.lint.examples
import com.android.tools.lint.checks.infrastructure.TestFiles.kotlin
import com.android.tools.lint.checks.infrastructure.TestLintTask.lint
import com.dorck.lint.rules.old.issues.KotlinTodoDetector
import org.junit.Test
@Suppress("UnstableApiUsage")
class KotlinTodoDetectorTest {
@Test
fun sampleTest() {
lint().files(
kotlin(
"""
package test.pkg
class SimpleInterfaceImpl : SimpleInterface {
override fun doSomething(){
TODO("Not yet implemented")
}
}
interface SimpleInterface {
fun doSomething()
}
""".trimIndent()
))
.issues(KotlinTodoDetector.ISSUE)
.run()
.expect(
"""
src/test/pkg/SimpleInterfaceImpl.kt:5: Error: You must fix TODO() first. [KotlinTodo]
TODO("Not yet implemented")
~~~~~~~~~~~~~~~~~~~~~~~~~~~
1 errors, 0 warnings
""".trimIndent()
)
.expectFixDiffs(
"""
Fix for src/test/pkg/SimpleInterfaceImpl.kt line 5: Delete this TODO method:
@@ -5 +5
- TODO("Not yet implemented")
""".trimIndent()
)
}
}
其实也很简单,只需要模拟创建一个 Java/kotlin/Gradle/xml 等格式的源文件,然后在 java()
或 koltin()
方法参数里面写上测试代码,并指定要验证的 Issue 以及期待的反馈内容。当然,expect()
中期待的输出检查结果我们是无法知晓的,我们只需要先设置为空字符串,然后先跑一下测试用例,预期肯定会失败,如此,我们只需要将终端输出的实际错误信息 copy 到 expect()
中即可:
最后,重新 run 一下单测,就会发现能够正常通过测试了。更多关于 Lint 单元测试的用法可以参考:Lint unit testing
5. 忽略某些规则检查
某些情况下,我们希望忽略某些 Lint 规则的检查或者更改 Lint 规则的严重级别,那么,我们可以选择增加一个 Lint 配置文件,用于解决上述问题。我们可以手动在项目 app/根目录下创建一个名为 lint.xml
的文件:
<?xml version="1.0" encoding="UTF-8"?>
<lint>
<!-- list of issues to configure -->
<issue id="DefaultLocale" severity="ignore"/>
<issue id="DeprecatedProvider" severity="ignore"/>
<issue id="ObsoleteLayoutParam">
<!-- The <ignore> tag has two possible attributes: path and regexp (see below) -->
<ignore path="res/layout-xlarge/activation.xml" />
<!-- You can use globbing patterns in the path strings -->
<ignore path="**/layout-x*/onclick.xml" />
<ignore path="res/**/activation.xml" />
</issue>
<issue id="MissingTranslation" severity="ignore"/>
<issue id="KotlinTodo" severity="ignore"/>
</lint>
在 lint.xml 中我们可以选择更改某条规则的严重级别,使原本不受重视的规则更加引人注意或者放宽其他规则的级别。当然,我们也可以指定某条规则在特定匹配路径下被忽略,这将取决于我们自己设定的 regex 匹配规则。
Note:如果我们创建了 lint.xml (文件名强约定),并且
build.gradle
的lintOptions
中没有自定义设定 lint 配置文件的名称和路径,则 AGP自动在临近目录中寻找名为lint.xml
的配置文件。
此外,我们也可以通过在 build.gradle
>> lintOptions
DSL 中设置开启或者关闭某些特定规则,当然也可以配置报告的输出格式以及路径:
lintOptions {
textReport false
lintConfig file('default-lint.xml') // At `app/default-lint.xml`
disable 'KotlinTodo', 'MissingTranslation'
xmlOutput file("lint-report.xml")
}
更多关于 LintOptions
DSl 的配置可查看官方文档:LintOptions-dsl
Note:如果你项目中使用了 lint plugin,那么可以参考 lint DSL的相关释义:AGP-lint-dsl
其他的设置 lint 配置的方式还有手动在 Android studio 的工具栏 Analyze > Inspect Code > Specify Inspection Scope 中或者通过 Lint 命令行工具来配置,这两种方式就不具体介绍了,感兴趣的朋友可以去看下官方文档的介绍。
版本迭代过程
AGP 4.0开始,Android studio 支持了独立的 com.android.lint
插件,进一步降低了自定义 lint 的成本。借助此插件,在上述 lint-rules/build.gradle
中通过在 manifest 中注册自定义 Registry
改为通过服务表单注册(当然,以前的方式目前还是可以用的)。以下是基于官方最新推荐的方式来配置和注册自定义规则的:
plugins {
id 'java-library'
id 'org.jetbrains.kotlin.jvm'
id 'com.android.lint'
}
dependencies {
// 官方提供的Lint相关API,并不稳定,每次AGP升级都可能会更改,且并不是向下兼容的
compileOnly "com.android.tools.lint:lint-api:${rootProject.ext.lintVersion}"
// 目前Android中内置的lint检测规则
compileOnly "com.android.tools.lint:lint-checks:${rootProject.ext.lintVersion}"
compileOnly "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlinVersion"
testImplementation "junit:junit:4.13.2"
testImplementation "com.android.tools.lint:lint:$lintVersion"
testImplementation "com.android.tools.lint:lint-tests:$lintVersion"
}
java {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
可以看到,以此插件方式,我们需要关注的额外配置更少了,很大程度上降低了接入成本。
下面再来谈谈 AGP-7.0 开始的改动。其一变动是上面谈及过的执行 ./gradlew lint
只会作用于默认变体的 Lint 任务上,而不是以前的所有变体任务。
另外一项是在 7.0 中,lint 最终将能够跨模块增量运行,这意味着如果我们只更改一个模块中的代码,lint 只需在该模块下游的模块上重新运行分析检测。对于具有许多模块的大型项目,这应该是一项重大的改进。
开发技巧
1. 借助 Psi 工具查看 AST 语法树
Lint 检查的实质是对代码的 AST(Abstract Syntax Tree,即抽象语法树)数据进行检查分析,故而会用到大量 AST 与 lombok.ast 开源库相关知识。阅读源码是一种不错的分析语法树方式,不过我们可以借助 AS 的一些插件帮我们快速便捷解析类的节点树并加以解读。
利用 PsiViewer
就可以查看类的 AST 构造,如此一来我就可以另辟蹊径找到特定的属性来匹配特定代码了。值得注意的是,上面的 AST viewer 插件对 kotlin 代码支持不是很好,如果有需要,建议先将 kotlin 反编译为 java 再分析。
2. 参考 Android 内置 Lint 规则
我发现官方近期对于 Lint 的技术推进很上心,各路文档和 FAQ 陆续补齐了。关于内置规则,Android官方团队也对每条做了详细说明和用法指导,详细参考:googlesamples.github.io/android-cus…
作者:道可诉
链接:https://juejin.cn/post/7130410607534145544
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
分库分表后路由策略设计
概述
分库分表后设计到的第一个问题就是,如何选择路由key,应该如何对key进行路由。路由key应该在每个表中都存在而且唯一。路由策略应尽量保证数据能均匀进行分布。
如果是对大数据量进行归档类的业务可以选择时间作为路由key。比如按数据的创建时间作为路由key,每个月或者每个季度创建一个表。按时间作为分库分表后的路由策略可以做到数据归档,历史数据访问流量较小,流量都会打到最新的数据库表中。
也可以设计其与业务相关的路由key。这样可以保证每个数据库的资源都能很好的承担流量。
支持场景
外卖订单平台分库分表后需要支持的场景,用户的角度,需要实时查看所点外卖订单的状态,跟踪订单信息。商家需要查询订单信息,通过订单分析菜品的质量,进行商业决策。
用户Consumer = C端 商家Business = B端
用户下单后订单可能会落到不同的表中,查询的时候可能需要查询多张表。
路由策略
如果创建订单时随机插入到某一张表中,或者不知道插入到那张表中,查询订单的时候都需要查询所有的表才能确保查询的准确信。
如果在插入订单的时候有一定的规则,根据这个规则插入到数据库中,查询的时候也执行相应的规则到对应的表中进行查询。这样就能减少数据操作的复杂性。可以通过设计路由策略来实现,用户和商家查询数据的时候都遵循相同的路由策略。
用户端路由key
根据上一小节的路由策略分析,现在需要选定一个路由key。用户端让同一个用户id的数据保存到某固定的表中,所以可以选用用户id最为路由key。
在单库的情况下,用户下单,生成一个订单,把用户id作为路由key,对user_id取hash值然后对表的数量进行取模,得到对应需要路由的表,然后写入数据。
多库多表的情况下需要先找到对应的库然后再找到对应的表。多库多表的路由策略:用户下达->生成订单->路由策略:根据用户id的hash值对数据库的数量进行取模找到对应的数据库->根据用户id的hash值除以对表的数量,然后在对表的数量进行取模即可找到对应的表。
路由策略设计的要点是根据具体的业务业务场景设计,跟用户信息关联度比较大的作为路由key进行hash值取模
商家路由key
单独为商家B端设计了一套表(C端和B端是独立的)。
用户的角度以user_id作为路由key,商户的角度以商家id作为路由key。商家是如何通过路由key路由数据的呢。游湖在下单的时候把队友的订单号发送到MQ里,商家可以去消费这个MQ,然后根据订单号获取订单信息,然后再把订单信息插入到商户的数据库表当中。商户的路由策略和用户的路由策略是一样的。
用户端和商户端的完整数据流程图:
作者:人间凑个数
链接:https://juejin.cn/post/7128726681954549768
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。