注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

iOS SwiftUI 创建和组合视图 1.1

第三节 使用栈来组合视图上一节创建了标题视图,接下来要添加一些文本视图来描述地标所在州及所在公园的名称等其它详细信息创建SwiftUI视图就是在body属性中描述视图的内容、布局及行为,但body属性只返回单个视图,这时组合多个视图时可以把它们放入一个栈中,通...
继续阅读 »

第三节 使用栈来组合视图

上一节创建了标题视图,接下来要添加一些文本视图来描述地标所在州及所在公园的名称等其它详细信息

swiftui layout stack

创建SwiftUI视图就是在body属性中描述视图的内容、布局及行为,但body属性只返回单个视图,这时组合多个视图时可以把它们放入一个栈中,通过水平、垂直、前后嵌套多个视图完成视图组合,做为一个整体在body属性中返回

这一节中,使用一个垂直栈,把标题放在包含公园详情的水平栈的上方,在水平栈中,布局公园详情相关的内容

可以使用Xcode提供的结构化布局来把视图嵌套在容器视图中

步骤1 按下Command键的同时,点击Text视图的初始化代码打开结构化编辑弹窗,然后选择把控件嵌套在垂直栈中(Embed in VStack),在栈中添加Text View控件可以从组件中直接拖进栈中完成

swiftui view embed in vertical stack

步骤2 点击Xcode右上角的+号,托动一个Text控件到指定位置,代码立即就会在编辑器中补全

步骤3 把Text视图的占位文本修改为Joshua Tree Nation Park,视图会自动调整位置布局

步骤4 设置位置控件的字体为子标题样式

swiftui inspector add text view

步骤5 设置VStack初始化参数为左对齐内部的子视图。默认情况下,栈会把内部视图在自己的主轴上居中对齐,并自动计算各子视图的间距。下一步要添加一个Text控制用来描述公园的状态,它水平排列在位置信息的右边。

swiftui vstack leadng alignment

步骤6 在画布内,command按下的同时点击位置视图,在弹出的菜单中选择嵌入到水平栈中(Embed in HStack)

步骤7 在位置控件的后面加一个公园状态的Text视图,并把占位文字改为California,字体设置为子标题样式

步骤8 为了水平布局使用整个屏幕宽度,在位置控件和公园状态控件中间添加一个Spacer控件,用来填充两个控件中间的空白部分,并把两个控件分别顶向屏幕的两侧。Spacer是一个可以伸缩的空白控件,他负责占用其它控件布局完成后剩下的所有空间。

步骤9 使用padding()修改器给地标信息内容视图整体加内边距

swiftui embed in hstack

收起阅读 »

❤️Android 安装包体积优化❤️

介绍 实际开发应用时,包体积优化是必不可少的。毕竟手机内存有限,如果包体积过大很多用户会直接放弃(以前手机内存很小的时候,这个真的很重要),现在由于手机内存大了(512G已经挡不住了),现在的用户更关注流畅度和美观作为参考,但是该有的优化还是要优化的,毕竟要尽...
继续阅读 »

介绍


实际开发应用时,包体积优化是必不可少的。毕竟手机内存有限,如果包体积过大很多用户会直接放弃(以前手机内存很小的时候,这个真的很重要),现在由于手机内存大了(512G已经挡不住了),现在的用户更关注流畅度和美观作为参考,但是该有的优化还是要优化的,毕竟要尽善尽美嘛。


本文主要分两部分:安装包监控、安装包大小优化。


安装包监控


Android Studio 的 APK Analyser


这是 Android Studio 提供的一个 APK 检测工具,通过它可以查看一个 apk 文件内部各项内容所占的大小,并且按照大小排序显示。因此我们很容易观察到 APK 中哪一部分内容占用了最大空间。APK Analyzer 的使用非常简单,只要将需要分析的 APK 文件拖入 Android Studio 中即可(直接点击项目中的apk也可以),显示内容类似下图所示:





从上图中可以看出classes.dex都代码文件,不是很好东,看图片也占用了比较大的资源空间,因此可以针对性地对其做压缩优化等操作。(我这个项目是个demo所以显得代码占比较多。实际项目中肯定是图片资源占比相对较大)



从上图看出,实际上 APK Analyzer 的作用不光是查看 APK 大小,从它的名字也能看出它是用来分析 APK 的,因此可以使用它来分析一些优秀 APK 的目录结构、代码规范,甚至是使用了哪些动态库技术等。



Matrix中 的 ApkChecker(传送)


Matrix是微信终端自研和正在使用的一套APM(Application Performance Management)系统。 Matrix-ApkChecker 作为Matrix系统的一部分,是针对android安装包的分析检测工具,根据一系列设定好的规则检测apk是否存在特定的问题,并输出较为详细的检测结果报告,用于分析排查问题以及版本追踪。


安装包优化实践


Lint查找无用文件


使用 Lint 查看未引用资源。Lint 是一个静态扫描工具,它可以识别出项目中没有被任何代码所引用到的资源文件。具体使用也很简单,只要在 Android Studio 中点击 Analyze -> Inspect Code,如下所示:



选中整个项目,如下所示



如果项目中有未被使用资源,则 Lint 会在窗口 Inspection Result 中显示,类似结果如下:



低效布局权重:提供优化方案。



上面就是未使用的资源:会使应用程序变大,并降低构建速度。


还有很多就不多介绍了,感兴趣的可以去玩玩。


启用压缩、混淆和优化功能


当你使用 Android Studio 3.4 或 Android Gradle 插件 3.4.0 及更高版本时,R8 是默认编译器,用于将项目的 Java 字节码转换为在 Android 平台上运行的 DEX 格式。不过,当您使用 Android Studio 创建新项目时,缩减、混淆处理和代码优化功能默认处于停用状态


        debug{
// 启用代码收缩、模糊处理和优化
minifyEnabled true
// 资源缩减
shrinkResources true
//包括与打包在一起的默认ProGuard规则文件
//R8配置文件。
proguardFiles getDefaultProguardFile(
'proguard-android-optimize.txt'),
'proguard-rules.pro'
}

未启用



启用后



文件优化


图片优化


降低图片bit



不需要太精致的图片可以将图中32 bit降至16 bit或者8 bit,


使用 VectorDrawable 图片


UI小姐姐能提供最好不能提供,咱们自己自己造。


Android Studio 中点击 File > New > Vector Asset



Clip Art 项为固有的矢量图,我们直接用即可,而Local file(SVG,PSD)选项,则是我们需要转换的了,剩下的提示进行就可以啦。


使用 tint 着色器


纯色图片,仅修改颜色就要再导入一张图片,比较占资源,这时我们使用tint就避免浪费资源和时间。



<ImageView
android:layout_width="200dp"
android:src="@drawable/ic_vector"
android:layout_marginTop="@dimen/dimen_20"
android:layout_gravity="center_horizontal"
android:layout_height="200dp"/>
<ImageView
...其他属性跟上面一致
android:tint="@color/color_188FFF"
tools:ignore="UseAppTint" />


tools:igore的作用就是忽略一些指定错误,或者 抑制警告。



使用 webp 格式图片


使用webp格式的图片可以在保持清晰度的情况下减小图片的磁盘大小,是一种比较优秀的,google推荐的图片格式。


选中图片>右键>选择




图片由.png转为.webp


三方库优化


在 App 中会引入各种三方的"轮子",但是在引入之前最好权衡一下是否需要将其代码全部引入,造成不必要的代码或者资源也被打包到 APK 中。


例如Facebook全家桶,你不可能全部用到仅导入部分即可,如登入和分享


dependencies {
// Facebook Core only (Analytics)
implementation 'com.facebook.android:facebook-core:11.1.0'
// Facebook Login only
implementation 'com.facebook.android:facebook-login:11.1.0'
// Facebook Share only
implementation 'com.facebook.android:facebook-share:11.1.0'
// Facebook Messenger only
implementation 'com.facebook.android:facebook-messenger:11.1.0'
// Facebook App Links only
implementation 'com.facebook.android:facebook-applinks:11.1.0'
// Facebook Android SDK (everything)
implementation 'com.facebook.android:facebook-android-sdk:11.1.0'
}

仅需导入
dependencies {
implementation 'com.facebook.android:facebook-login:11.1.0'
implementation 'com.facebook.android:facebook-share:11.1.0'
}

例如XRecyclerView一个 RecyclerView 实现了 pullrefresh 、loadingmore 和 header featrues。你可能仅用到 loadingmore,那你就可以将关于loadingmore部分截取出来。而不用导入整个包。


关于 App Bundle


这个功能就跟ios一样了,他们就是将所有资源全部打到项目中,然后App Store,根据安装设备的属性,来选取相应资源打包进行下载。


谷歌的 Dynamic Delivery 功能就天然地解决了这个问题,通过 Google Play Store 安装 APK 时,也会根据安装设备的属性,只选取相应的资源打包到 APK 文件中。


如下图,你上传的700MB大小的aab,但是你下载的话会有两套资源打在apk中,但是用户下载仅一套资源700MB。



但是 App Bundle 目前只适合在 Google Play Store 上发布的项目,国内目前还是通过各家的插件化方案来实现动态部署,一定程度上也可以算作减少安装包大小的方案。


还有一个骚操作,就是前期资源打包,后续资源用户边玩边下载,缺点可能造成卡顿和浪费流量,仅供参考。


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

重新理解为什么 Handler 可能导致内存泄露?

总说 Handler 使用不当会导致内存泄露,真正的原因到底是什么? 网上千篇一律的答案貌似没有说到点子上,本文带你重新理解个中细节! 什么是 Handler 使用不当? 先搞清楚什么叫 Handler 使用不当? 一般具备这么几个特征: Handler 采...
继续阅读 »

总说 Handler 使用不当会导致内存泄露,真正的原因到底是什么?


网上千篇一律的答案貌似没有说到点子上,本文带你重新理解个中细节!


什么是 Handler 使用不当?


先搞清楚什么叫 Handler 使用不当?


一般具备这么几个特征:



  1. Handler 采用匿名内部类内部类扩展,默认持有外部类 Activity 的引用:


// 匿名内部类
override fun onCreate(savedInstanceState: Bundle?) {
...
val innerHandler: Handler = object : Handler(Looper.getMainLooper()) {
override fun handleMessage(msg: Message) {
Log.d(
"MainActivity",
"Anonymous inner handler message occurred & what:${msg.what}"
)
}
}
}

// 内部类
override fun onCreate(savedInstanceState: Bundle?) {
...
val innerHandler: Handler = MyHandler(Looper.getMainLooper())
}

inner class MyHandler(looper: Looper): Handler(looper) {
override fun handleMessage(msg: Message) {
Log.d(
"MainActivity",
"Inner handler message occurred & what:\${msg.what}"
)
}
}


  1. Activity 退出的时候 Handler 仍可达,有两种情况:

    • 退出的时候仍有 Thread 在处理中,其引用着 Handler

    • 退出的时候虽然 Thread 结束了,但 Message 尚在队列中排队处理正在处理中,间接持有 Handler




override fun onCreate(savedInstanceState: Bundle?) {
...
val elseThread: Thread = object : Thread() {
override fun run() {
Log.d(
"MainActivity",
"Thread run"
)

sleep(2000L)
innerHandler.sendEmptyMessage(1)
}
}.apply { start() }
}

为什么会内存泄露?


上述的 Thread 在执行的过程中,如果 Activity 进入了后台,后续因为内存不足触发了 destroy。虚拟机在标记 GC 对象的时候,会发生如下两种情形:




  • Thread 尚未结束,处于活跃状态


    活跃的 Thread 作为 GC Root 对象,其持有 Handler 实例,Handler 又默认持有外部类 Activity 的实例,这层引用链仍可达:





  • Thread 虽然已结束,但发送的 Message 还未处理完毕


    Thread 发送的 Message 可能还在队列中等待,又或者正好处于 handleMessage() 的回调当中。此刻 Looper 通过 MessagQueue 持有该 Message,Handler 又作为 target 属性被 Message 持有,Handler 又持有 Activity,最终导致 Looper 间接持有 Activity。


    大家可能没有注意到主线程的 Main Looper 是不同于其他线程的 Looper 的。


    为了能够让任意线程方便取得主线程的 Looper 实例,Looper 将其定义为了静态属性 sMainLooper


    public final class Looper {
    private static Looper sMainLooper; // guarded by Looper.class
    ...
    public static void prepareMainLooper() {
    prepare(false);
    synchronized (Looper.class) {
    sMainLooper = myLooper();
    }
    }
    }

    静态属性也是 GC Root 对象,其通过上述的应用链导致 Activity 仍然可达。





这两种情形都将导致 Activity 实例将无法被正确地标记,直到 Thread 结束 且 Message 被处理完毕。在此之前 Activity 实例将得不到回收。


其他线程的 Looper 会导致内存泄露吗?


为了便于每个线程方便拿到自己的 Looper 实例,Looper 采用静态的 sThreadLocal 属性缓存了各 Looper 实例。


public final class Looper {
static final ThreadLocal sThreadLocal = new ThreadLocal();
...
public static @Nullable Looper myLooper() {
return sThreadLocal.get();
}
}

那 sThreadLocal 作为静态属性也是 GC Root 对象,从这个角度讲会不会也间接导致 Message 无法回收呢?


答案是不会,因为 ThreadLocal 内部的 Map 采用弱引用持有 Looper 对象,不会导致 Looper 及引用链实例无法被回收。


public class ThreadLocal {
...
static class ThreadLocalMap {
static class Entry extends WeakReference> {
/** The value associated with this ThreadLocal. */
Object value;

Entry(ThreadLocal k, Object v) {
super(k);
value = v;
}
}
}
}

内部类 Thread 也会导致 Activity 无法回收吧?


为了侧重阐述 Handler 导致的内存泄漏,并没有针对 Thread 直接产生的引用链作说明。


上面的代码示例中 Thread 也采用了匿名内部类形式,其当然也持有 Activity 实例。从这点上来说,尚未结束的 Thread 会直接占据 Acitvity 实例,这也是导致 Activity 内存泄露的一条引用链,需要留意!


如何正确使用 Handler?


GC 标记的时候 Thread 已结束并且 Message 已被处理的条件一旦没有满足,Activity 的生命周期就将被错误地延长,继而引发内存泄露!


那如何避免这种情况的发生呢?针对上面的特征,其实应该已经有了答案。



  1. 将 Handler 定义为静态内部类


private class MainHandler(looper: Looper?, referencedObject: MainActivity?) :
WeakReferenceHandler(looper, referencedObject) {
override fun handleMessage(msg: Message) {
val activity: MainActivity? = referencedObject
if (activity != null) {
// ...
}
}
}

另外还需要弱引用外部类的实例:


open class WeakReferenceHandler(looper: Looper?, referencedObject: T) : Handler(looper!!) {
private val mReference: WeakReference = WeakReference(referencedObject)

protected val referencedObject: T?
protected get() = mReference.get()
}



  1. onDestroy 的时候纠正生命周期




    • Activity 销毁的时候,如果异步任务尚未结束停止 Thread:


      override fun onDestroy() {
      super.onDestroy()
      thread.interrupt()
      }



    • 同时还要将 Handler 未处理的 Message 及时移除,Message 执行 recycle() 时将重置其与和 Handler 的关系:


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





非内部类的 Handler 会内存泄露吗?


上面说过匿名内部类或内部类是 Handler 造成内存泄漏的一个特征,那如果 Handler 不采用内部类的写法,会造成泄露吗?


比如这样:


override fun onCreate(...) {
Handler(Looper.getMainLooper()).apply {
object : Thread() {
override fun run() {
sleep(2000L)
post {
// Update ui
}
}
}.apply { start() }
}
}

仍然可能造成内存泄漏。


虽然 Handler 不是内部类,但 post 的 Runnable 也是内部类,其同样会持有 Activity 的实例。另外,post 到 Handler 的 Runnable 最终会作为 callback 属性被 Message 持有。



基于这两个表现,即便 Handler 不是内部类了,但因为 Runnable 是内部类,同样会发生 Activity 被 Thread 或 Main Looper 不当持有的风险。


结语


回顾一下本文的几个要点:



  • 持有 Activity 实例的内名内部类或内部类的生命周期应当和 Activity 保持一致

  • 如果 Activity 本该销毁了,但异步任务仍然活跃或通过 Handler 发送的 Message 尚未处理完毕,将使得内部类实例的生命周期被错误地延长

  • 造成本该回收的 Activity 实例被别的 ThreadMain Looper 占据而无法及时回收

  • 记得持有 Activity 尽量采用静态内部类 + 弱引用的写法,另外在 Activity 销毁的时候及时地终止 Thread 或清空 Message

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

Kotlin开发中的一些Tips

如果你开始使用Kotlin,我个人的建议是多关注编译后字节码或是反编译后的java代码,这样你会发现更多的细节。单纯只学习语法会让你忽略一些细节,而这可能会是性能问题或bug的来源。 下面我举一些我在使用中碰到的问题,看能否给你启发,想到哪就写到哪了。本篇内容...
继续阅读 »

如果你开始使用Kotlin,我个人的建议是多关注编译后字节码或是反编译后的java代码,这样你会发现更多的细节。单纯只学习语法会让你忽略一些细节,而这可能会是性能问题或bug的来源。


下面我举一些我在使用中碰到的问题,看能否给你启发,想到哪就写到哪了。本篇内容基于Java 8 + Kotlin 1.5.21


1.字符串拼接


java 中我们通常使用StringBuilderconcat或者+等方式来拼接字符串。Kotlin中还可以使用字符串模板和plus


一个简单的例子:


val a = "Hello"
val b = "World"

val c = "$a $b"
val d = "$a $b!"
val e = a.plus(" ").plus(b)
val f = a.plus(" ").plus(b).plus("!")

然后点击Tools - > Kotlin - > Show Kotlin Bytecode -> Decompile就可以对kotlin编译后的字节码进行反编译,从而看到java版本的代码。
在这里插入图片描述
示例代码区别只是末尾多拼接了一个!,结果转换代码略有不同,但+号拼接方式在java中最终也是用 StringBuilder 来进行字符串拼接,所以其实是等价的。这里只是展示不同。


但问题是使用plus的代码,可以看到图中倒数第二行,append中直接是a + " "。感觉有点不对劲,那我们直接看一下字节码:

可以看到创建了两个StringBuilder,也就是说每plus一次,创建一个。也就是说plus内部实现是将左右两个参数传入方法中,然后用StringBuilder拼接。等价关系为:plus(plus(a, " "), b)。所以反编译为什么会那样展示,貌似也能说的通了。


"$a $b"方式字节码如下图:
在这里插入图片描述
所以在kotlin中怎么拼接字符,不用我再多说了吧。和java一样,循环中拼接字符,还是推荐用StringBuilder,如果使用字符串模板不也是每次循环时创建一个StringBuilder吗?


2.lazy


lazy 作用是属性被第一次使用的时候再进行初始化,达到懒加载的目的。


private val name: String by lazy { "weilu" }

lazy有三种初始化模式:
在这里插入图片描述
而默认的模式是 LazyThreadSafetyMode.SYNCHRONIZED ,它确保只有一个线程可以初始化实例。我们看一下具体实现代码:
在这里插入图片描述
源码中使用了@Volatilesynchronized实现了双重检查锁,这样保证了线程安全。但是这也是不小的性能开销。如果我们只是单线程中使用lazy,可以指定LazyThreadSafetyMode.NONE来避免此类问题。
在这里插入图片描述
优化后使用方法:


private val name: String by lazy(LazyThreadSafetyMode.NONE) { "weilu" }

3.companion object


Kotlin类中如果需要写静态属性或方法,需要使用伴生对象( companion object )来创建。下面我列出了几种写法:


class CompanionTest {

companion object {
val TEST_1 = "TEST_1"
const val TEST_2 = "TEST_2"

private val TEST_3 = "TEST_3"
private const val TEST_4 = "TEST_4"

fun test() {
println(TEST_1)
println(TEST_2)
println(TEST_3)
println(TEST_4)
}
}
val test5 = "TEST_5"
private val test6 = "TEST_6"
}

我们看一下生成的代码:
在这里插入图片描述


可以看到,在不加const修饰的情况下,生成了getTEST_1方法。那么调用TEST_1时,其实是调用CompanionTest.Companion.getTEST_1(),这样的代码说实话有点繁琐。


如何可以像Java那样直接读取静态属性,那就像TEST_2一样,加const修饰,这样这个变量就可以内联式的编译,也就不会生成多余的方法。


同时也需要注意一个test5、test6这两个写法的区别。


4.inline


inline 是方法的一个修饰符,用来让方法以内联的方式进行编译。什么是内联,简单说就像是复制了一份方法实现代码进来。


比如我们有一个计算方法add:


fun add(a: Int, b: Int): Int {
return a + b;
}

如果直接使用,反编译后代码如下:


UtilsKt.add(1, 4);

如果添加一个inline 修饰符,反编译后代码如下:


byte a$iv = 1;
int b$iv = 4;
int var10000 = a$iv + b$iv;

其实一般的方法我们不需要添加inline ,否则一调用方法就“复制”一遍,这生成的代码就太多了,体积也会不断变大。所以AS也会给我们警告:


Expected performance impact from inlining is insignificant. Inlining works best for functions with parameters of functional types

翻译过来就是:内联对性能的影响是很小的,内联最适合带有函数类型参数的函数。


这里说的很明白,内联适合将函数作为参数传递的方法。如果你看过一些kotlin源码,会发现一些高阶函数let、map、run都是如此。


具体是为什么,我们可以看一个例子:


	private fun testFunction(i: Int, call: (Int) -> String) {
call.invoke(i)
}

fun test() {
testFunction(9) {
it.toString()
}
}

反编译:
在这里插入图片描述
Function1是Kotlin 提供的泛型接口,1 就是说明有一个参数。所以使用lambda表达式实际上每次都是在创建一个FunctionX对象。


然后看一下字节码:


INVOKESTATIC java/lang/Integer.valueOf (I)Ljava/lang/Integer;
INVOKEINTERFACE kotlin/jvm/functions/Function1.invoke (Ljava/lang/Object;)Ljava/lang/Object; (itf)

里面有使用Integer.valueOf进行自动装箱。然后对于Function1.invoke(Object) : Object,入参会装箱。如果有返回值又会拆箱。


以上两点都是直接使用lambda表达式所带来的性能损耗。避免这种情况有两个方法:



1.可以将lambda表达式赋值给一个变量,然后每次引用该变量,这样既可以避免重复创建函数对象,也可以避免重复装箱拆箱开销。



2.inline内联函数可以避免高阶函数创建函数对象及装箱拆箱开销,但是要注意inline函数体不宜过大。


下图是给testFunction方法加inline后的反编译代码:
在这里插入图片描述


5. Gson解析


主要问题是gson与data class使用时产生的问题。简单说就是即使你的变量声明为不可空(不包含基础类型),且有默认值,如果json中的这个字段是null,那么解析后这个不可空变量也会被赋值为null。这时你在使用这个字段时程序自然会崩溃。


具体的问题原因以及解决方法可以看下面的几篇博客,写的都很清晰详细,这里就不多说了。





这些内容也是我在使用kotlin开发过程中学到和遇到过的,也因此返工了不少之前的代码。。。分享出来也是希望帮你少踩一些坑。


其实还有许多类似文中提到的性能开销,我这里也没法一一列举出来。所以就需要我们在学习和使用中多关注编译后的代码。


记得很早看过一部动漫叫《钢之炼金术师》,里面的核心就是说:获得某种东西,需要以同等的代价来交换。比如我们使用的许多开源框架都非常简单灵活,但是代价就是作者的封装优化。Kotlin的简洁不是没有代价的,里面包含了许多默认的行为,而代价就是一定的性能损耗。如果我们掌握这些细节,扬长避短,或许可以实现“双赢”。


扯多了,哈哈。如果本篇对你有帮助的话,多多点赞支持一下!


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

flutter 风车加载指示组件

前言Flutter 官方提供了诸如 CircularProgressIndicator和 LinearProgressIndicator两种常见的加载指示组件,但是说实话,实在太普通,比如下面这个CircularProgressIndica...
继续阅读 »

前言

Flutter 官方提供了诸如 CircularProgressIndicator和 LinearProgressIndicator两种常见的加载指示组件,但是说实话,实在太普通,比如下面这个CircularProgressIndicator

屏幕录制2021-10-10 下午9.53.09.gif

正好我们介绍到了动画环节,那我们自己来一个有趣的加载指示组件吧。创意送哪来呢,冥思苦想中脑海里突然就响起了一首歌:

大风车吱呀吱哟哟地转,这里的风景呀真好看! 天好看,地好看

没错,这就是当时风靡全中国的放学档,儿童必看节目《大风车》的主题曲。

大风车节目 嗯,我们就自己来个风车动画加载组件吧,最终完成效果如下,支持尺寸和旋转速度的设定。

风车加载组件

接口定义

遵循接口先行的习惯,我们先设计对外的接口。对于一个动画加载组件,我们需要支持两个属性:

  • 尺寸:可以由调用者决定尺寸的大小,以便应用在不同的场合。由于风车加载是个正方形,我们定义参数名为 size,类型为 double
  • 速度:风车是旋转的,需要支持旋转速度调节,以便满足应用用户群体的偏好。我们定义参数名为 speed,单位是转/秒,即一秒旋转多少圈,类型也是 double
  • 旋转方向:可以控制顺时针还是逆时针方向旋转。参数名为 direction,为枚举。枚举名称为 RotationDirection,有两个枚举值,分别是 clockwise 和 antiClockwise(感谢评论区脖子不太长的建议)。

其实还可以支持颜色设置,不过看了一下,大部分风车是4个叶片,颜色为蓝黄红绿组合,这里我们就直接在组件内部固定颜色了,这样也可以简化调用者的使用。 然后是定义组件名称,我们依据英文意思,将组件命名为 WindmillIndicator

实现思路

风车绘制

关键是绘制风车,根据给定的尺寸绘制完风车后,再让它按设定的速度旋转起来就好了。绘制风车的关键点又在于绘制叶片,绘制一个叶片后,其他三个叶片依次旋转90度就可以了。我们来看一下叶片的绘制。叶片示意图如下: 叶片绘制示意 叶片整体在一个给定尺寸的正方形框内,由三条线组成:

  • 红色线:弧线,我们设定起点在底边X 轴方向1/3宽度处,终点是左侧边 Y 轴方向1/3高度处,圆弧半径为边长的一半。
  • 绿色线:弧线,起点为红色线的终点,终点为右上角顶点,圆弧半径为边长。
  • 蓝色线,连接绿色线的终点和红色线的起点,以达到闭合。

有了叶片,其他的就是依次旋转90度了,绘制完后的示意图如下所示: 风车绘制示意

旋转效果

我们把每一个叶片作为独立的组件,按照设定的速度,更改旋转角度即可,只要4个叶片的旋转增量角度同时保持一致,风车的形状就能够一致保持,这样就有风车旋转的效果了。

代码实现

WindmillIndicator定义

WindmillIndicator 需要使用 Animation 和 AnimationController 来控制动画,因此是一个 StatefulWidget。根据我们上面的接口定义,得到WindmillIndicator的定义如下:

class WindmillIndicator extends StatefulWidget {
final size;
// 旋转速度,默认:1转/秒
final double speed;
final direction;
WindmillIndicator({Key? key,
this.size = 50.0,
this.speed = 1.0,
this.direction = RotationDirection.clockwise,
})
: assert(speed > 0),
assert(size > 0),
super(key: key);

@override
_WindmillIndicatorState createState() => _WindmillIndicatorState();
}

这里使用了 assert 来防止参数错误,比如 speed 不能是负数和0(因为后面计算旋转速度需要将 speed 当除数来计算动画周期),同时 size 不可以小于0

旋转速度设定

我们使用 Tween<double>设定Animation 的值的范围,begin和 end 为0和1.0,然后每个叶片在构建的时候旋转角度都加上2π 弧度乘以 Animation 对象的值,这样一个周期下来就是旋转了一圈。然后是 AnimationController 来控制具体的选择速度,实际的时间使用毫秒数,用1000 / speed 得到的就是旋转一圈需要的毫秒数。这样即能够设定旋转速度为 speed。代码如下所示:

class _WindmillIndicatorState extends State<WindmillIndicator>
with SingleTickerProviderStateMixin {
late Animation<double> animation;
late AnimationController controller;

@override
void initState() {
super.initState();
int milliseconds = 1000 ~/ widget.speed;
controller = AnimationController(
duration: Duration(milliseconds: milliseconds), vsync: this);
animation = Tween<double>(begin: 0, end: 1.0).animate(controller)
..addListener(() {
setState(() {});
});

controller.repeat();
}

@override
Widget build(BuildContext context) {
return AnimatedWindmill(
animation: animation,
size: widget.size,
direction: widget.direction,
);
}

@override
void dispose() {
if (controller.status != AnimationStatus.completed &&
controller.status != AnimationStatus.dismissed) {
controller.stop();
}

controller.dispose();
super.dispose();
}

这里在initState 里设置好参数之后就调用了controller.repeat(),以使得动画重复进行。在 build 方法里,我们构建了一个AnimatedWindmill对象,将 Animation 对象和 size 传给了它。AnimatedWindmill是风车的绘制和动画组件承载类。

风车叶片绘制

风车叶片代码定义如下:

class WindmillWing extends StatelessWidget {
final double size;
final Color color;
final double angle;

const WindmillWing(
{Key? key, required this.size, required this.color, required this.angle});

@override
Widget build(BuildContext context) {
return Container(
transformAlignment: Alignment.bottomCenter,
transform: Matrix4.translationValues(0, -size / 2, 0)..rotateZ(angle),
child: ClipPath(
child: Container(
width: size,
height: size,
alignment: Alignment.center,
color: color,
),
clipper: WindwillClipPath(),
),
);
}
}

共接收三个参数:

  • size:即矩形框的边长;
  • color:叶片填充颜色;
  • angle:叶片旋转角度。

实际叶片旋转时参照底部中心位置(bottomCenter)旋转(不同位置的效果不一样,感兴趣的可以拉取代码修改试试)。这里有两个额外的注意点:

  • transform参数我们首先往 Y 轴做了 size / 2的平移,这是因为旋转后风车整体位置会偏下size / 2,因此上移补偿,保证风车的位置在中心。
  • 实际叶片的形状是对 Container 进行裁剪得来的,这里使用了 ClipPath 类。ClipPath 支持使用自定义的CustomClipper<Path>裁剪类最子元素的边界进行裁剪。我们定义了WindwillClipPath类来实现我们说的风车叶片外观裁剪,也就是把正方形裁剪为风车叶片形状。WindwillClipPath的代码如下,在重载的 getClip方法中将我们所说的叶片绘制路径返回即可。
class WindwillClipPath extends CustomClipper<Path> {
@override
Path getClip(Size size) {
var path = Path()
..moveTo(size.width / 3, size.height)
..arcToPoint(
Offset(0, size.height * 2 / 3),
radius: Radius.circular(size.width / 2),
)
..arcToPoint(
Offset(size.width, 0),
radius: Radius.circular(size.width),
)
..lineTo(size.width / 3, size.height);

return path;
}

@override
bool shouldReclip(covariant CustomClipper<Path> oldClipper) {
return false;
}
}

风车组件

有了风车叶片组件,风车组件构建就简单多了(这也是拆分子组件的好处之一)。我们将风车组件继承 AnimatedWidget,然后使用 Stack 组件将4个叶片组合起来,每个叶片给定不同的颜色和旋转角度即可。而旋转角度是由叶片的初始角度加上Animation对象控制的旋转角度共同确定的。然后控制顺时针还是逆时针根据枚举值控制角度是增加还是减少就可以了,风车组件的代码如下:

class AnimatedWindmill extends AnimatedWidget {
final size;
final direction;
AnimatedWindmill(
{Key? key,
required Animation<double> animation,
required this.direction,
this.size = 50.0,
}) : super(key: key, listenable: animation);

@override
Widget build(BuildContext context) {
final animation = listenable as Animation<double>;
final rotationAngle = direction == RotationDirection.clockwise
? 2 * pi * animation.value
: -2 * pi * animation.value;
return Stack(
alignment: Alignment.topCenter,
children: [
WindmillWing(
size: size,
color: Colors.blue,
angle: 0 + rotationAngle,
),
WindmillWing(
size: size,
color: Colors.yellow,
angle: pi / 2 + rotationAngle,
),
WindmillWing(
size: size,
color: Colors.green,
angle: pi + rotationAngle,
),
WindmillWing(
size: size,
color: Colors.red,
angle: -pi / 2 + rotationAngle,
),
],
);
}

运行效果

我们分别看运行速度为0.5和1的效果,实测感觉速度太快或太慢体验都一般,比较舒适的速度在0.3-0.8之间,当然你想晃晕用户的可以更快些😂😂😂。

速度0.5

速度1

源码已提交至:动画相关源码,想用在项目的可以直接把WindmillIndicator的实现源文件windmill_indicator.dart拷贝到自己的项目里使用。

总结

本篇实现了风车旋转的加载指示动画效果,通过这样的效果可以提升用户体验,尤其是儿童类的应用,绝对是体验加分的动效。从 Flutter学习方面来说,重点是三个知识:

  • AnimationAnimationController 和 AnimatedWidget的应用;
  • Matrix4控制Container 的平移和旋转的使用;
  • 使用 ClipPath 和自定义CustomClipper<Path> 对组件形状进行裁剪,这个在很多场景会用到,比如那些特殊形状的组件。


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

收起阅读 »

kt协程 | suspend非阻塞挂起魔术解密

一 前言 kotin协程,一种轻量级用户态线程,能通过suspend函数避免回调地狱以及快速实现线程的切换等,已经普及到大量实际项目中。这里将解析协程核心功能suspend的「非阻塞式挂起」实现原理,从而避免管中窥豹,使得在后续使用kotlin协程开发能更加得...
继续阅读 »

一 前言


kotin协程,一种轻量级用户态线程,能通过suspend函数避免回调地狱以及快速实现线程的切换等,已经普及到大量实际项目中。这里将解析协程核心功能suspend的「非阻塞式挂起」实现原理,从而避免管中窥豹,使得在后续使用kotlin协程开发能更加得心应手。


二 callback代码同步化



kotlin官方宣传的非阻塞式挂起,即用同步代码写异步操作。其实可以理解,就是通过suspend将代码“用阻塞式的写法实现非阻塞式线程调度”,就是内部帮我们切线程。 先简述同步化写法,下面会逐步分析,kotlin协程是如何通过suspend来实现非阻塞式的代码同步化



2.1 callback代码同步化


假设一个简单的需求,先从网络获取最新的消息,再与本地数据库做增量操作,获取完整消息。 在原生中,回调代码如下:


fun fetch(){
fetchRemote { msg->
fetchLocal(msg) { result ->
//实际业务操作
println("result:$result")
}
}
}

fun fetchRemote(onNext:(Int)->Unit){
Thread.sleep(300)
val value = 1

onNext(value)
}
fun fetchLocal(id:Int,onNext:(Int)->Unit){
Thread.sleep(300)
val value = 2

onNext(id + value)

利用了kotlin协程,可以直接以同步方式:


suspend fun fetch():Int{//用正常同步写法,消除回调
val msg = fetchRemote()
val result = fetchLocal(msg)
println("result:$result")
return result
}

//简单起见,采用suspendCoroutine
suspend fun fetchRemote() = suspendCoroutine {
it.resume(1)
}

suspend fun fetchLocal(id:Int) = suspendCoroutine {
it.resume(id + 2)
}

ok,上面的 suspendFetch函数写法,就是传说中的 “同步代码实现异步操作” 了,简称「代码同步化」


三 suspend解密



备注:为方便理解,下面展示的是伪代码,与实际字节码翻译可能存在不同;



3.1 suspend函数解体


这里先讲解一个声明为suspend的函数,如suspend fun fetch():Int,会如何被kotlin编译器解体,再讲述执行过程。


先总结:kotlin编译器会使用状态机实现回调功能,每一个suspend函数都是状态机的一个状态,suspend就是声明一个状态而已,再往简单地说,编译器会注入代码,内部帮我们实现了回调!



  1. 编译器首先会在suspend函数,加入一个额外的参数 completion: Continuation,比如会将上述的suspend fun fetch():Int变成fun fetch(completion: Continuation):Any,这也额外解释了为何suspend只能被suspend函数或协程内调用。


注意,这里的返回值变成any,是因为除了我们定义的返回值以外,还可能返回挂起标识CoroutineSingletons.COROUTINE_SUSPENDED,也就是用于实现挂起的逻辑



  1. kotlin编译器用状态机机制判断当前会执行哪个代码块,每一个挂起函数都会被当成一个状态点,用label来表示,如fetchRemote是一个label,一个可能会存在挂起的状态,伪代码:


 
fun fetch(completion: Continuation):Any{
...
when(label){
0 -> {//label 为默认值0 ,即fetch函数被第一次调用运行,函数代码此时正常运行,还没接触到任何其他挂起函数
...
label = 1 //下面会运行一个挂起函数,所以状态label立即加1,也就是说label==1,表示代码运行到了第一个挂起函数,此处是fetchRemote()
val state = fetchRemote()
...
return COROUTINE_SUSPENDED
}
1 -> { //label 1 ,表示在遇到第一个挂起函数fetchRemote() 之后,调用resume等方式恢复了调度
...
label = 2 //下面会运行另一个挂起函数,所以状态label立即加1,也就是说label==2,表示代码运行到了第二个挂起函数,此处是fetchLocal()
val state = fetchLocal(id)
...
return COROUTINE_SUSPENDED
}
2 -> {//label 2 ,表示在遇到第二个挂起函数fetchLocal() 之后,调用resume等方式恢复了调度
...
println("result:$result")
return result
}
}
}

再次提下总结:每一个suspend函数都是状态机的一个状态,suspend就是声明一个状态,体现到代码层次就是一个label值来表示。



  1. 到这里,还需要在状态之间分发上一个状态机的执行结果「即,上一个suspend的返回值」。kotlin通过生成一个状态机管理类,存储label和结果值,解决这个问题:



这里的类命名只是为了方便理解



class FetchStateMachine(
completion: Continuation
) : ContinuationImpl(completion) {
var result: Result? = null
var label: Int = 0

override fun invokeSuspend(result: Any?) {
this.result = result
fetch(this)
}
}

先注意这里的invokeSuspend包裹了真实的要执行的协程体,并保存了传进来的执行结果result,负责存储每个suspend函数执行结果以共享。


4.一个小点,就是如何判断它是第一次执行这个suspend函数,也就是初始状态label==0。这里比较简单,直接通过判断completion是不是生成的状态机类就知道了,不是状态机类就代表第一次执行,包裹起来:


val continuation = completion as? FetchStateMachine ?: FetchStateMachine(completion)


  1. 再接上最开始提到的挂起逻辑。是否特别好奇过,究竟协程是如何知道该挂起,该怎么做了?答案很简单,当某个挂起函数,如fetchRemote(),没有调resume时,编译器会让它返回一个CoroutineSingletons.COROUTINE_SUSPENDED结果,这也是为什么返回值会变成Any,然后只要判断result == 挂起标志,代码直接return,就实现挂起了!!是不是很朴实??


val result = fetchRemote(continuation)
if (result == CoroutineSingletons.COROUTINE_SUSPENDED){
return result
}

到了这里,就可以看到编译器对fetch()解体的代码的模样了:


fun fetch(completion: Continuation): Any {
class FetchStateMachine(
completion: Continuation
) : ContinuationImpl(completion) {
var result: Result? = null //执行结果的共享
var label: Int = 0 //判断执行到哪个代码快,挂起函数

override fun invokeSuspend(result: Any?) {//触发状态机运行,调用resumeWith时会触发
this.result = result
suspendFetch(this)
}
}

//第一次执行,包裹成状态机类
val continuation = completion as? FetchStateMachine ?: FetchStateMachine(completion)

val result = continuation.result
val suspended = COROUTINE_SUSPENDED

when (continuation.label) {
0 -> {
//检查是否异常
result.throwOnFailure()
//立即修改label+1
continuation.label = 1
val var0 = fetchRemote(continuation)
if (var0 == suspended){ //表示suspendRemote挂起
return var0
}

//再次触发状态机跑下一个 label1,正常情况不会跑这里。只有当suspendRemote实现是普通函数 suspend fun suspendRemote() = 1,才会触发
fetch(continuation)
}
1 -> {
result.throwOnFailure()
continuation.label = 2
val var0 = fetchLocal(result.value,continuation)
if (var0 == suspended){//这里就相当于一次挂起了
return var0
}
fetch(continuation)
}
2 -> {
result.throwOnFailure()
return result.value
}
else -> throw IllegalStateException("call to 'resume' before 'invoke' with coroutine")
}
}

3.2 执行流程


ok,这里针对编译器解体的代码,讲一下状态机执行过程;



  1. launch协程后,会触发协程体执行,从而第一次调用到fetch()方法,开始执行状态机;

  2. 第一次进来,将completion包装成状态机类,此时label为0,执行到第一个挂起函数fetchRemote()

  3. fetchRemote() 是个普通函数,类似suspend fun a()=1这种只是简单声明suspend的函数,会直接返回函数结果值,递归调度fetch(continuation)


//Decompilerkotlin to java by cfr
public static final Object a(@NotNull Continuation $completion) {
return Boxing.boxInt((int)1);
}


  1. fetchRemote() 是实现了suspendCoroutine/suspendCoroutine的正经挂起函数时,函数会返回一个挂起标志CoroutineSingletons.COROUTINE_SUSPENDED,这也是会什么suspend函数返回值是Any类型,到这里会发生一次挂起;


image.png



  1. 对于fetchRemote,当调用resumeWith恢复调度时,会递归循环调用我们一开始生成的状态机包裹类的invokeSuspend方法,而invokeSuspend方法就是会再次触发自身函数,即fetch()


image.png



  1. 此时触发状态机接着跑此时的label为1,会跑到fetchLocal挂起方法。然后循环递归步骤3 4,直到结束。


这里的执行流程核心就是一个循环递归,从而帮我们内部实现回调。


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

React 中 setState 是一个宏任务还是微任务?

最近有个朋友面试,面试官问了个奇葩的问题,也就是我写在标题上的这个问题。 能问出这个问题,面试官应该对 React 不是很了解,也是可能是看到面试者简历里面有写过自己熟悉 React,面试官想通过这个问题来判断面试者是不是真的熟悉 React 🤣。 面试官的...
继续阅读 »

最近有个朋友面试,面试官问了个奇葩的问题,也就是我写在标题上的这个问题。



能问出这个问题,面试官应该对 React 不是很了解,也是可能是看到面试者简历里面有写过自己熟悉 React,面试官想通过这个问题来判断面试者是不是真的熟悉 React 🤣。


面试官的问法是否正确?


面试官的问题是,setState 是一个宏任务还是微任务,那么在他的认知里,setState 肯定是一个异步操作。为了判断 setState 到底是不是异步操作,可以先做一个实验,通过 CRA 新建一个 React 项目,在项目中,编辑如下代码:


import React from 'react';
import logo from './logo.svg';
import './App.css';

class App extends React.Component {
state = {
count: 1000
}
render() {
return (
<div className="App">
<img
src={logo} alt="logo"
className="App-logo"
onClick={this.handleClick}
/>
<p>我的关注人数:{this.state.count}</p>
</div>
);
}
}

export default App;

页面大概长这样:



上面的 React Logo 绑定了一个点击事件,现在需要实现这个点击事件,在点击 Logo 之后,进行一次 setState 操作,在 set 操作完成时打印一个 log,并且在 set 操作之前,分别添加一个宏任务和微任务。代码如下:


handleClick = () => {
const fans = Math.floor(Math.random() * 10)
setTimeout(() => {
console.log('宏任务触发')
})
Promise.resolve().then(() => {
console.log('微任务触发')
})
this.setState({
count: this.state.count + fans
}, () => {
console.log('新增粉丝数:', fans)
})
}


很明显,在点击 Logo 之后,先完成了 setState 操作,然后再是微任务的触发和宏任务的触发。所以,setState 的执行时机是早于微任务与宏任务的,即使这样也只能说它的执行时机早于 Promise.then,还不能证明它就是同步任务。


handleClick = () => {
const fans = Math.floor(Math.random() * 10)
console.log('开始运行')
this.setState({
count: this.state.count + fans
}, () => {
console.log('新增粉丝数:', fans)
})
console.log('结束运行')
}


这么看,似乎 setState 又是一个异步的操作。主要原因是,在 React 的生命周期以及绑定的事件流中,所有的 setState 操作会先缓存到一个队列中,在整个事件结束后或者 mount 流程结束后,才会取出之前缓存的 setState 队列进行一次计算,触发 state 更新。只要我们跳出 React 的事件流或者生命周期,就能打破 React 对 setState 的掌控。最简单的方法,就是把 setState 放到 setTimeout 的匿名函数中。


handleClick = () => {
setTimeout(() => {
const fans = Math.floor(Math.random() * 10)
console.log('开始运行')
this.setState({
count: this.state.count + fans
}, () => {
console.log('新增粉丝数:', fans)
})
console.log('结束运行')
})
}


由此可见,setState 本质上还是在一个事件循环中,并没有切换到另外宏任务或者微任务中,在运行上是基于同步代码实现,只是行为上看起来像异步。所以,根本不存在面试官的问题。


React 是如何控制 setState 的 ?


前面的案例中,setState 只有在 setTimeout 中才会变得像一个同步方法,这是怎么做到的?


handleClick = () => {
// 正常的操作
this.setState({
count: this.state.count + 1
})
}
handleClick = () => {
// 脱离 React 控制的操作
setTimeout(() => {
this.setState({
count: this.state.count + fans
})
})
}

先回顾之前的代码,在这两个操作中,我们分别在 Performance 中记录一次调用栈,看看两者的调用栈有何区别。


正常操作


脱离 React 控制的操作


在调用栈中,可以看到 Component.setState 方法最终会调用enqueueSetState 方法 ,而 enqueueSetState 方法内部会调用 scheduleUpdateOnFiber 方法,区别就在于正常调用的时候,scheduleUpdateOnFiber 方法内只会调用 ensureRootIsScheduled ,在事件方法结束后,才会调用 flushSyncCallbackQueue 方法。而脱离 React 事件流的时候,scheduleUpdateOnFiberensureRootIsScheduled 调用结束后,会直接调用 flushSyncCallbackQueue 方法,这个方法就是用来更新 state 并重新进行 render。




function scheduleUpdateOnFiber(fiber, lane, eventTime) {
if (lane === SyncLane) {
// 同步操作
ensureRootIsScheduled(root, eventTime);
// 判断当前是否还在 React 事件流中
// 如果不在,直接调用 flushSyncCallbackQueue 更新
if (executionContext === NoContext) {
flushSyncCallbackQueue();
}
} else {
// 异步操作
}
}

上述代码可以简单描述这个过程,主要是判断了 executionContext 是否等于 NoContext 来确定当前更新流程是否在 React 事件流中。


众所周知,React 在绑定事件时,会对事件进行合成,统一绑定到 document 上( react@17 有所改变,变成了绑定事件到 render 时指定的那个 DOM 元素),最后由 React 来派发。


所有的事件在触发的时候,都会先调用 batchedEventUpdates$1 这个方法,在这里就会修改 executionContext 的值,React 就知道此时的 setState 在自己的掌控中。


// executionContext 的默认状态
var executionContext = NoContext;
function batchedEventUpdates$1(fn, a) {
var prevExecutionContext = executionContext;
executionContext |= EventContext; // 修改状态
try {
return fn(a);
} finally {
executionContext = prevExecutionContext;
// 调用结束后,调用 flushSyncCallbackQueue
if (executionContext === NoContext) {
flushSyncCallbackQueue();
}
}
}


所以,不管是直接调用 flushSyncCallbackQueue ,还是推迟调用,这里本质上都是同步的,只是有个先后顺序的问题。


未来会有异步的 setState


如果你有认真看上面的代码,你会发现在 scheduleUpdateOnFiber 方法内,会判断 lane 是否为同步,那么是不是存在异步的情况?


function scheduleUpdateOnFiber(fiber, lane, eventTime) {
if (lane === SyncLane) {
// 同步操作
ensureRootIsScheduled(root, eventTime);
// 判断当前是否还在 React 事件流中
// 如果不在,直接调用 flushSyncCallbackQueue 更新
if (executionContext === NoContext) {
flushSyncCallbackQueue();
}
} else {
// 异步操作
}
}

React 在两年前,升级 fiber 架构的时候,就是为其异步化做准备的。在 React 18 将会正式发布 Concurrent 模式,关于 Concurrent 模式,官方的介绍如下。




什么是 Concurrent 模式?


Concurrent 模式是一组 React 的新功能,可帮助应用保持响应,并根据用户的设备性能和网速进行适当的调整。在 Concurrent 模式中,渲染不是阻塞的。它是可中断的。这改善了用户体验。它同时解锁了以前不可能的新功能。




作者:Shenfq
链接:https://juejin.cn/post/6992006476558499853

收起阅读 »

正确介绍自己的项目经验 再也不为面试发愁了

在面试时,经过简单寒暄后,面试官一般先从让候选人自我介绍开始,紧接着就是问候选人简历中所列的项目,让介绍下项目经验。常见的问法是,说下你最近的(或感觉不错的)一个项目。面试中很多人忽视对这一个环节的准备,不仅回答不了面试官的追问,甚至连自己的项目都讲不清楚,说...
继续阅读 »

在面试时,经过简单寒暄后,面试官一般先从让候选人自我介绍开始,紧接着就是问候选人简历中所列的项目,让介绍下项目经验。常见的问法是,说下你最近的(或感觉不错的)一个项目。面试中很多人忽视对这一个环节的准备,不仅回答不了面试官的追问,甚至连自己的项目都讲不清楚,说起来磕磕巴巴,甚至有的人说出的项目经验从时间段或技术等方面和简历上的不匹配,这样无疑会让面试官对面试者的能力产生怀疑。


面试时7份靠能力,3份靠技能,本文将从“前期准备”和“面试技巧”两大层面告诉大家如何准备面试时的项目介绍,当然,这只是一家之言,没有最好的方式,只有更适合的方法,仁者见仁智者见智。


前期分析




  1. 知己知彼百战不殆。如果想打动面试官,那么你就必须得了解他到底想要从你口中了解到什么,哪些信息是他所想要的。


    在面试前准备项目描述时,别害怕,因为面试官什么都不知道,最了解你项目的还是你自己。


    面试官是人,不是神,拿到你的简历的时候,只能根据你所描述的项目去推测你的工作经历,是没法核实你的项目细节的(一般公司会到录用后,通过背景调查的方式来核实)。


    更何况,你做的项目是以月或以年为单位算的,而面试官最多用30分钟来从你的简历上了解你的项目经验,所以你对项目的熟悉程度要远远超过面试官,所以你一点也不用紧张。而面试官想了解更多他想知道的你的工作方式及项目中所负责的内容、所用到的技术栈,就不得不从你的介绍中去深挖技术点,以期了解你对项目及技术的了解的深度。


    首先从气势上就要告诉面试官,这项目就是你参与的,有你所负责的功能模块,让面试官不容置疑。


    心态上准备好了,那么就要分析面试官想要考察什么呢?



    • 表达能力。考察表达及逻辑思维能力,看面试者能不能在几分钟就跟一个完全没有参与过项目的人讲清楚这个项目。

    • 实际工作经验。你在项目中中承担了什么角色,做了什么工作。这些工作中的技术含量及跟同事合作情况如何。另外可能会针对某个项目,不断深入问一些技术上的问题,或者是从侧面问技术类实现,这是为了深入核实你做项目的细节及对技术的理解运用。

    • 解决问题能力。一般都会问项目难点,其实就是想知道当你遇到技术或业务难点,是如何思考并解决问题的。

    • 项目复盘及经验总结能力。哪里是自己觉得做的成功的,哪里觉得没做好的,是否还有继续优化的空间。自己所做的项目对个人能力有哪些提升。




  2. 熟能生巧,对答自如


    首先是需要有个充足的准备,写项目经验一定要写自己熟悉的,因为面试官就会根据你写的项目经验提问。在面试前,就要在脑子里过一遍这个项目,准备好说辞,面试的时候自信点。讲清楚这个项目是满足什么需求的,开发过程中遇到哪些困难,自己怎么解决这些困难的。如果你经过充分准备,面试中也能答的很好,那面试官好感度就会增加,相反,如果面试中说的磕磕绊绊,那么可信度就会低了。




  3. 明确目标,控盘引导


    在面试前,你需要明确自己的面试目的,就是通过面试,争取拿到 Offer 。


    最保守的方式就是在自己介绍项目的时候要么就是以面试官为主导,回答很简单,就是面试官问你一句你答一句。这会让面试官失去想了解你的信心,其次也会让自己错失表现自己,凸显重点思想的机会。做好防守虽然也是一种取胜的方式,但并非上策,很容易丢分。


    讲自己简历中所列的项目一定要很清晰明了有逻辑,埋下后续可能会提问到的技术点,也给面试官留下一个好印象。如果项目经验介绍的好,有逻辑和引导力,那么就会给自己带来以下两点明显的好处:



    1. 给面试官带来的第一印象好,会让面试官感觉该候选人表述能力较强。

    2. 一般来说,面试官会根据候选人介绍的项目背景来提问题,假设面试时会问10个问题,那么至少有5个问题会根据候选人所介绍的项目背景来问,候选人如果说的好,那么就可以很好地引导面试官去问后继问题,这就相当于把提问权完全交由自己控制了。


    如果你的工作经验比面试官还丰富的话,甚至还可以控制整个面试流程,甚至遇到 Nice 的面试官的话会以讨论的方式进行沟通。


    既然面试官无法了解你的底细,那么他们怎么来验证你的项目经验和技术?下面总结了一些常用的提问方式。




面试技巧


内容上要对项目进行以下拆分,思考并进行总结,并试着口语化讲出来。



  1. 项目描述。用通俗易懂且简洁的方式简述项目,阐述整个项目及其背景、规模,不要有太多的技术词汇。

  2. 项目模块。2-3分钟的流程介绍,详细的列出项目实现的功能,各个模块,整个过程,大概思路。

  3. 项目技术栈。说出项目实现的技术栈和架构,能说出项目的不寻常之处,比如采用了某项新技术,采用了某个架框等,简要说明技术选型。

  4. 候选人的角色及责任。说出你在项目中的责任,所涉及的功能模块,运用的技术,碰到的难题、代码细节,技术点、应对措施。

  5. 项目总结,待优化点


方法上可以使用万能的STAR原则


Situation(背景):做这个项目的背景是什么,比如这是个人项目还是团队项目,为什么需要做这个
项目,你的角色是什么,等等。

Target(目标):该项目要达成的目标是什么?为了完成这个目标有哪些困难?

Action(行动):针对所要完成目标,你做了哪些工作?如何克服了其中一些困难?

Result(结果):项目最终结果如何?有哪些成就?有哪些不足之处可以改进?


除了项目所取得的成绩之外,候选人还可以说明自己做完项目的感受,包括项目中哪些环节做的不错,哪些环节有提高的空间,自己在做这个项目中有何收获等。


无论是介绍自己的IT产品开发经历,还是在其他公司的实习项目经历,候选人都可以运用STAR法则来具体说明,轻松表现出自己分析阐述问题的清晰性、条理性和逻辑性。


但面试前如下的一些情况还是需要多加注意的。




  1. 回答很简单。问什么答什么,往往就一句话回答。如果你日常回答别人的问题或者之前面试中出现过类似情况就要有所改善了。这里应该将你知道的说出来,重点突出跟问题相关的思想、框架或技术点等。




  2. 扯闲篇,大忌。说少了太过于简短没有互动不好,自来熟,回答问题没有重点,没有逻辑,乱说一通也是大忌。会让面试官感觉你思路混乱,抓不到重点,只是拿其他方面的东西东拼西凑。




  3. 说的太过流利,也未必就是好事。虽然面试有所准备在面试官看来是好事,但是机械的准备好答案去背诵,主观上给人一种你并没有理解这个问题,只是靠记忆知道答案,后续面试官的问题也会相应的加大难度。这方面改善建议是适当停顿,做思考状,边思考边说,过程中同面试官有个眼神上的互动。




  4. 有的放矢的介绍技术细节。不要一次性过多的介绍技术细节,技术面点到为止,等面试官来问。因为面试官通常都有自己的面试节奏。所以技术点等着问的时候再多聊,可以先事先埋下技术点引导着面试官继续追问。




  5. 主动介绍项目亮点。因为面试官没有义务挖掘你的亮点,所以这就需要自己主动提。遇到不会的问题,就如实说这个技术点不会。或者半懂也可以直接说。甚至可以谈谈自己的见解。把自己了解的说说。




项目准备


一般来说,在面试前,大家应当准备项目描述的说辞,自信些,因为这部分你说了算,流利些,因为你经过充分准备后,可以知道你要说些什么。而且这些是你实际的项目经验(不是学习经验,也不是培训经验),那么一旦让面试官感觉你都说不上来,那么可信度就很低了。


不少人是拘泥于“项目里做了什么业务,以及代码实现的细节”,这就相当于把后继提问权直接交给面试官。下表列出了一些不好的回答方式。


不露痕迹地说出面试官爱听的话


在项目介绍的时候(当然包括后继的面试),面试官其实很想要听一些关键点,只要你说出来,而且回答相关问题比较好,这绝对是加分项。我在面试别人的时候,一旦这些关键点得到确认,我是绝对会在评语上加上一笔的。


下面列些面试官爱听的关键点和对应的说辞。



一旦有低级错误,可能会直接出局


面试过程中有些方面你是绝对不能出错,所以你在准备过程中需要尤其注意如下的因素。下面列了些会导致你直接出局的错误回答。



面试场景题


举一个例子,比如考察候选人是否聪明,star 法则会这样询问:


1.在刚才的项目中,你提到了公司业务发展很快,人手不够,你是如何应对的呢?
2.在你的项目里面解决了什么样的难题
3.在你的项目里面如何做的登录
4.前端的项目如何进行优化,移动端呢?
5.图片加载失败要做啥
6.让你带领一个小团队完成一个项目,你会怎么做?
7.项目的同源处理,跨域相关
8.如果再做这个项目,你会在哪些方面进行改善?
面试中,如果面试官让你描述一个自己比较得意的项目的时候,一定记得要遵循 STAR 法则进行回答。比如


为了整合 xxx 业务(S),我承担 xxx 角色,具体负责 xxx (T)。做了 xxx 事情(A),最后产生了 xxx 结果


然后在描述项目亮点的时候也一样,比如


由于项目 xxx 原因(S),我需要进行 xxx 改进(T),然后进行了 xxx 处理(A),最后产出了 xxx 结果,数据对比为 xxx


整体这样下来,会显得你很有思考力,且具有行动力,可以给企业创造出价值,这也是面试官评定候选人最关键的指标之一。


面试官的套路

面试时所问的问题基本分为两种:具象的问题和开放性的问题。


具象的问题基本都会参考工作经验按照 STAR 法则来进行,主要是了解基本的素养,技术深度和潜力。


开放性的问题基本是考察思维发散能力,考察在某个领域的深度和广度,基本上会结合技术问题来问,或者是结合工作内容来问。


比如:实现某种技术的 n 种方法?某种技术的实现原理?和什么什么相比有哪些优缺点?你对这项技术的思考是什么?


面试者的应对

1.就实际情况做回答,提前准备的时候多发散,多思考,多总结。这一块是可以自己准备的加分项。


2.发散性问题主要是看自己平时积累。首先基础知识要牢固,同时也要了解最新技术动态。面对这类问题切记也不能答非所问而跑题了。


注意:


1.避免拿别人的项目直接用


很多初级阶段的同学们,可能并没有实际的商业项目,或者所做过的项目类型有限,就直接从网上找项目当做自己的项目,直接使用是断不可取的,但是如果你仿造别人的项目自己去尝试着将功能实现,有自己的新得体验,这样在做的过程中也可以对项目中的功能点和技术栈有进一步的了解,不至于在面试的时候,磕磕巴巴,甚至将项目时间都搞错。


2.避免低级错误


很多基础相关的低级错误一定要杜绝,如果被问到熟悉知识点就多答,不熟悉就直接说不熟悉。每个人都有自己擅长的点也有不擅长的。


另外就是可以引导一些话题,不要自说自话。很多人会一直很激进的表达自己,反而显得强势。有的面试者被问到数据库相关内容,他不仅回答数据库,还会把大数据处理技术全部都说出来。其实点到为止最好,面试官感兴趣会继续问,但是你一直主导话题,会减分。


这里要说的是,不要把不是自己做的项目说成是自己做的,自己不是核心负责人说成是负责人,即使你对项目很熟悉了解,像我们一线起来的面试官,问几个问题就很清楚你实际参与了多少了,只是大部分不会明说而已,反而起到反效果。


总结


首先我要劝大家,认真对待每一次面试。既然知道自己要参加面试,就在家自己模拟一下面试。自己提前准备一下自己的项目描述,不要到了面试的时候去打磕巴。但是如果你参加面试的时候实在紧张了,磕巴了不要慌。深呼吸尝试让自己放松,一般面试官也会给些提示帮助你回答的。


两句话,第一,面试前一定要准备,第二,本文给出是的方法,不是教条,大家可以按本文给出的方向结合自己的项目背景做准备,而不是死记硬背本文给出的一些说辞。



作者:Gaby
链接:https://juejin.cn/post/7017732278509453348

收起阅读 »

就因为JSON.stringify,我的年终奖差点打水漂了

产品同学在诉苦:线上用户不能提交表单了,带来了好多客诉,估计会是p0故障,希望尽快解决。 测试同学在纳闷:这个场景测试和预发环境明明验过的,怎么线上就不行了。 后端同学在讲原因:接口缺少了value字段,导致出错了。 就是木有人说问题怎么解决!!! 就是木有人...
继续阅读 »

产品同学在诉苦:线上用户不能提交表单了,带来了好多客诉,估计会是p0故障,希望尽快解决。


测试同学在纳闷:这个场景测试和预发环境明明验过的,怎么线上就不行了。


后端同学在讲原因:接口缺少了value字段,导致出错了。


就是木有人说问题怎么解决!!!


就是木有人说问题怎么解决!!!


就是木有人说问题怎么解决!!!


这样的场景不知道你是不是也似曾相识呢?o(╥﹏╥)o,不管咋说第一要务还是先把线上问题解决掉,减少持续影响,赶紧把交接的代码翻出来,开始了排查过程。


问题原因



如下图:有这样一个动态表单搜集页面,用户选择或者填写了信息之后(各字段非必填情况下也可以直接提交),接着前端把数据发送给后端,结束,看起来没有多复杂的逻辑。



image.png


直接错误原因



非必填情况下,signInfo字段中经过JSON.stringify后的字符串对象缺少value key,导致后端parse之后无法正确读取value值,进而报接口系统异常,用户无法进行下一步动作。



// 异常入参数据,数组字符串中没有value key
{
signInfo: '[{"fieldId":539},{"fieldId":540},{"fieldId":546,"value":"10:30"}]'
}

// 正常入参数据
{
signInfo: '[{"fieldId":539,"value":"银卡"},{"fieldId":540,"value":"2021-03-01"},{"fieldId":546,"value":"10:30"}]'
}



异常数据是如何产生的


// 默认情况下数据是这样的
let signInfo = [
{
fieldId: 539,
value: undefined
},
{
fieldId: 540,
value: undefined
},
{
fieldId: 546,
value: undefined
},
]
// 经过JSON.stringify之后的数据,少了value key,导致后端无法读取value值进行报错
// 具体原因是`undefined`、`任意的函数`以及`symbol值`,出现在`非数组对象`的属性值中时在序列化过程中会被忽略
console.log(JSON.stringify(signInfo))
// '[{"fieldId":539},{"fieldId":540},{"fieldId":546}]'


解决方案



问题的原因找到了,解决方式 (这里只讲前端的解决方案,当然也可以由后端解决) 也很简单,将value值为undefined的项转化为空字符串再提交即可。



方案一:新开一个对象处理


let signInfo = [
{
fieldId: 539,
value: undefined
},
{
fieldId: 540,
value: undefined
},
{
fieldId: 546,
value: undefined
},
]

let newSignInfo = signInfo.map((it) => {
const value = typeof it.value === 'undefined' ? '' : it.value
return {
...it,
value
}
})

console.log(JSON.stringify(newSignInfo))
// '[{"fieldId":539,"value":""},{"fieldId":540,"value":""},{"fieldId":546,"value":""}]'


方案二:利用JSON.stringify第二个参数,直接处理



方案一的缺陷是需要新开一个对象进行一顿操作才能解决,不够优雅



let signInfo = [
{
fieldId: 539,
value: undefined
},
{
fieldId: 540,
value: undefined
},
{
fieldId: 546,
value: undefined
},
]

// 判断到value为undefined,返回空字符串即可
JSON.stringify(signInfo, (key, value) => typeof value === 'undefined' ? '' : value)
// '[{"fieldId":539,"value":""},{"fieldId":540,"value":""},{"fieldId":546,"value":""}]'


故事后续



原本这是一个已经上线有一段时间的页面,为何会突然出现这个问题,之前却没有呢?仔细询问下,原来是中途产品同学提了一个小的优化点,离职的小伙伴感觉点比较小直接就改了代码上线了,未曾想出现了线上问题。



后面针对这件事从产品到测试、到后端、到前端单独做了一个完整的复盘,细节就不再展开说了。


因为从发现问题到解决问题速度较快、影响用户数较少,还未达到问责程度,俺的年终奖可算是保住了o(╥﹏╥)o。


重学JSON.stringify



经过这件事情,我觉得有必要重新审视一下JSON.stringify这个方法,彻底搞清楚转换规则,并尝试手写实现一个JSON.stringify



如果你曾遇到和我一样的问题,欢迎一起来重新学习一次,一定会有不一样的收获噢!


学透JSON.stringify



JSON.stringify()  方法将一个 JavaScript 对象转换为 JSON 字符串,如果指定了一个 replacer 函数,则可以选择性地替换值,或者指定的 replacer 是数组,则可选择性地仅包含数组指定的属性。



以下信息来自MDN


语法


JSON.stringify(value[, replacer [, space]])

参数




  • value


    将要序列化成 一个 JSON 字符串的值。




  • replacer 可选



    1. 如果该参数是一个函数,则在序列化过程中,被序列化的值的每个属性都会经过该函数的转换和处理;

    2. 如果该参数是一个数组,则只有包含在这个数组中的属性名才会被序列化到最终的 JSON 字符串中;

    3. 如果该参数为 null 或者未提供,则对象所有的属性都会被序列化。




  • space 可选



    1. 指定缩进用的空白字符串,用于美化输出(pretty-print);

    2. 如果参数是个数字,它代表有多少的空格;上限为10。

    3. 该值若小于1,则意味着没有空格;

    4. 如果该参数为字符串(当字符串长度超过10个字母,取其前10个字母),该字符串将被作为空格;

    5. 如果该参数没有提供(或者为 null),将没有空格。




返回值


一个表示给定值的JSON字符串。

异常



  • 当在循环引用时会抛出异常TypeError ("cyclic object value")(循环对象值)

  • 当尝试去转换 BigInt 类型的值会抛出TypeError ("BigInt value can't be serialized in JSON")(BigInt值不能JSON序列化).


基本使用


注意



  1. JSON.stringify可以转换对象或者值(平常用的更多的是转换对象)

  2. 可以指定replacer为函数选择性的地替换

  3. 也可以指定replacer为数组,可转换指定的属性


这里仅仅是NDN上关于JSON.stringify其中最基础的说明,咱们先打个码试试这几个特性


// 1. 转换对象
console.log(JSON.stringify({ name: '前端胖头鱼', sex: 'boy' })) // '{"name":"前端胖头鱼","sex":"boy"}'

// 2. 转换普通值
console.log(JSON.stringify('前端胖头鱼')) // "前端胖头鱼"
console.log(JSON.stringify(1)) // "1"
console.log(JSON.stringify(true)) // "true"
console.log(JSON.stringify(null)) // "null"

// 3. 指定replacer函数
console.log(JSON.stringify({ name: '前端胖头鱼', sex: 'boy', age: 100 }, (key, value) => {
return typeof value === 'number' ? undefined : value
}))
// '{"name":"前端胖头鱼","sex":"boy"}'

// 4. 指定数组
console.log(JSON.stringify({ name: '前端胖头鱼', sex: 'boy', age: 100 }, [ 'name' ]))
// '{"name":"前端胖头鱼"}'

// 5. 指定space(美化输出)
console.log(JSON.stringify({ name: '前端胖头鱼', sex: 'boy', age: 100 }))
// '{"name":"前端胖头鱼","sex":"boy","age":100}'
console.log(JSON.stringify({ name: '前端胖头鱼', sex: 'boy', age: 100 }, null , 2))
/*
{
"name": "前端胖头鱼",
"sex": "boy",
"age": 100
}
*/

9大特性要记住



以前仅仅是使用了这个方法,却没有详细了解他的转换规则,居然有9个之多。



特性一



  1. undefined任意的函数以及symbol值,出现在非数组对象的属性值中时在序列化过程中会被忽略

  2. undefined任意的函数以及symbol值出现在数组中时会被转换成 null

  3. undefined任意的函数以及symbol值单独转换时,会返回 undefined


// 1. 对象中存在这三种值会被忽略
console.log(JSON.stringify({
name: '前端胖头鱼',
sex: 'boy',
// 函数会被忽略
showName () {
console.log('前端胖头鱼')
},
// undefined会被忽略
age: undefined,
// Symbol会被忽略
symbolName: Symbol('前端胖头鱼')
}))
// '{"name":"前端胖头鱼","sex":"boy"}'

// 2. 数组中存在着三种值会被转化为null
console.log(JSON.stringify([
'前端胖头鱼',
'boy',
// 函数会被转化为null
function showName () {
console.log('前端胖头鱼')
},
//undefined会被转化为null
undefined,
//Symbol会被转化为null
Symbol('前端胖头鱼')
]))
// '["前端胖头鱼","boy",null,null,null]'

// 3.单独转换会返回undefined
console.log(JSON.stringify(
function showName () {
console.log('前端胖头鱼')
}
)) // undefined
console.log(JSON.stringify(undefined)) // undefined
console.log(JSON.stringify(Symbol('前端胖头鱼'))) // undefined

特性二



布尔值数字字符串的包装对象在序列化过程中会自动转换成对应的原始值。



console.log(JSON.stringify([new Number(1), new String("前端胖头鱼"), new Boolean(false)]))
// '[1,"前端胖头鱼",false]'

特性三



所有以symbol为属性键的属性都会被完全忽略掉,即便 replacer 参数中强制指定包含了它们。



console.log(JSON.stringify({
name: Symbol('前端胖头鱼'),
}))
// '{}'
console.log(JSON.stringify({
[ Symbol('前端胖头鱼') ]: '前端胖头鱼',
}, (key, value) => {
if (typeof key === 'symbol') {
return value
}
}))
// undefined

特性四



NaN 和 Infinity 格式的数值及 null 都会被当做 null。



console.log(JSON.stringify({
age: NaN,
age2: Infinity,
name: null
}))
// '{"age":null,"age2":null,"name":null}'

特性五



转换值如果有 toJSON() 方法,该方法定义什么值将被序列化。



const toJSONObj = {
name: '前端胖头鱼',
toJSON () {
return 'JSON.stringify'
}
}

console.log(JSON.stringify(toJSONObj))
// "JSON.stringify"

特性六



Date 日期调用了 toJSON() 将其转换为了 string 字符串(同Date.toISOString()),因此会被当做字符串处理。



const d = new Date()

console.log(d.toJSON()) // 2021-10-05T14:01:23.932Z
console.log(JSON.stringify(d)) // "2021-10-05T14:01:23.932Z"

特性七



对包含循环引用的对象(对象之间相互引用,形成无限循环)执行此方法,会抛出错误。



let cyclicObj = {
name: '前端胖头鱼',
}

cyclicObj.obj = cyclicObj

console.log(JSON.stringify(cyclicObj))
// Converting circular structure to JSON

特性八



其他类型的对象,包括 Map/Set/WeakMap/WeakSet,仅会序列化可枚举的属性



let enumerableObj = {}

Object.defineProperties(enumerableObj, {
name: {
value: '前端胖头鱼',
enumerable: true
},
sex: {
value: 'boy',
enumerable: false
},
})

console.log(JSON.stringify(enumerableObj))
// '{"name":"前端胖头鱼"}'

特性九



当尝试去转换 BigInt 类型的值会抛出错误



const alsoHuge = BigInt(9007199254740991)

console.log(JSON.stringify(alsoHuge))
// TypeError: Do not know how to serialize a BigInt

手写一个JSON.stringify



终于重新学完JSON.stringify的众多特性啦!咱们根据这些特性来手写一个简单版本的吧(无replacer函数和space



源码实现


const jsonstringify = (data) => {
// 确认一个对象是否存在循环引用
const isCyclic = (obj) => {
// 使用Set数据类型来存储已经检测过的对象
let stackSet = new Set()
let detected = false

const detect = (obj) => {
// 不是对象类型的话,可以直接跳过
if (obj && typeof obj != 'object') {
return
}
// 当要检查的对象已经存在于stackSet中时,表示存在循环引用
if (stackSet.has(obj)) {
return detected = true
}
// 将当前obj存如stackSet
stackSet.add(obj)

for (let key in obj) {
// 对obj下的属性进行挨个检测
if (obj.hasOwnProperty(key)) {
detect(obj[key])
}
}
// 平级检测完成之后,将当前对象删除,防止误判
/*
例如:对象的属性指向同一引用,如果不删除的话,会被认为是循环引用
let tempObj = {
name: '前端胖头鱼'
}
let obj4 = {
obj1: tempObj,
obj2: tempObj
}
*/
stackSet.delete(obj)
}

detect(obj)

return detected
}

// 特性七:
// 对包含循环引用的对象(对象之间相互引用,形成无限循环)执行此方法,会抛出错误。
if (isCyclic(data)) {
throw new TypeError('Converting circular structure to JSON')
}

// 特性九:
// 当尝试去转换 BigInt 类型的值会抛出错误
if (typeof data === 'bigint') {
throw new TypeError('Do not know how to serialize a BigInt')
}

const type = typeof data
const commonKeys1 = ['undefined', 'function', 'symbol']
const getType = (s) => {
return Object.prototype.toString.call(s).replace(/\[object (.*?)\]/, '$1').toLowerCase()
}

// 非对象
if (type !== 'object' || data === null) {
let result = data
// 特性四:
// NaN 和 Infinity 格式的数值及 null 都会被当做 null。
if ([NaN, Infinity, null].includes(data)) {
result = 'null'
// 特性一:
// `undefined`、`任意的函数`以及`symbol值`被`单独转换`时,会返回 undefined
} else if (commonKeys1.includes(type)) {
// 直接得到undefined,并不是一个字符串'undefined'
return undefined
} else if (type === 'string') {
result = '"' + data + '"'
}

return String(result)
} else if (type === 'object') {
// 特性五:
// 转换值如果有 toJSON() 方法,该方法定义什么值将被序列化
// 特性六:
// Date 日期调用了 toJSON() 将其转换为了 string 字符串(同Date.toISOString()),因此会被当做字符串处理。
if (typeof data.toJSON === 'function') {
return jsonstringify(data.toJSON())
} else if (Array.isArray(data)) {
let result = data.map((it) => {
// 特性一:
// `undefined`、`任意的函数`以及`symbol值`出现在`数组`中时会被转换成 `null`
return commonKeys1.includes(typeof it) ? 'null' : jsonstringify(it)
})

return `[${result}]`.replace(/'/g, '"')
} else {
// 特性二:
// 布尔值、数字、字符串的包装对象在序列化过程中会自动转换成对应的原始值。
if (['boolean', 'number'].includes(getType(data))) {
return String(data)
} else if (getType(data) === 'string') {
return '"' + data + '"'
} else {
let result = []
// 特性八
// 其他类型的对象,包括 Map/Set/WeakMap/WeakSet,仅会序列化可枚举的属性
Object.keys(data).forEach((key) => {
// 特性三:
// 所有以symbol为属性键的属性都会被完全忽略掉,即便 replacer 参数中强制指定包含了它们。
if (typeof key !== 'symbol') {
const value = data[key]
// 特性一
// `undefined`、`任意的函数`以及`symbol值`,出现在`非数组对象`的属性值中时在序列化过程中会被忽略
if (!commonKeys1.includes(typeof value)) {
result.push(`"${key}":${jsonstringify(value)}`)
}
}
})

return `{${result}}`.replace(/'/, '"')
}
}
}
}

测试一把


// 1. 测试一下基本输出
console.log(jsonstringify(undefined)) // undefined
console.log(jsonstringify(() => { })) // undefined
console.log(jsonstringify(Symbol('前端胖头鱼'))) // undefined
console.log(jsonstringify((NaN))) // null
console.log(jsonstringify((Infinity))) // null
console.log(jsonstringify((null))) // null
console.log(jsonstringify({
name: '前端胖头鱼',
toJSON() {
return {
name: '前端胖头鱼2',
sex: 'boy'
}
}
}))
// {"name":"前端胖头鱼2","sex":"boy"}

// 2. 和原生的JSON.stringify转换进行比较
console.log(jsonstringify(null) === JSON.stringify(null));
// true
console.log(jsonstringify(undefined) === JSON.stringify(undefined));
// true
console.log(jsonstringify(false) === JSON.stringify(false));
// true
console.log(jsonstringify(NaN) === JSON.stringify(NaN));
// true
console.log(jsonstringify(Infinity) === JSON.stringify(Infinity));
// true
let str = "前端胖头鱼";
console.log(jsonstringify(str) === JSON.stringify(str));
// true
let reg = new RegExp("\w");
console.log(jsonstringify(reg) === JSON.stringify(reg));
// true
let date = new Date();
console.log(jsonstringify(date) === JSON.stringify(date));
// true
let sym = Symbol('前端胖头鱼');
console.log(jsonstringify(sym) === JSON.stringify(sym));
// true
let array = [1, 2, 3];
console.log(jsonstringify(array) === JSON.stringify(array));
// true
let obj = {
name: '前端胖头鱼',
age: 18,
attr: ['coding', 123],
date: new Date(),
uni: Symbol(2),
sayHi: function () {
console.log("hello world")
},
info: {
age: 16,
intro: {
money: undefined,
job: null
}
},
pakingObj: {
boolean: new Boolean(false),
string: new String('前端胖头鱼'),
number: new Number(1),
}
}
console.log(jsonstringify(obj) === JSON.stringify(obj))
// true
console.log((jsonstringify(obj)))
// {"name":"前端胖头鱼","age":18,"attr":["coding",123],"date":"2021-10-06T14:59:58.306Z","info":{"age":16,"intro":{"job":null}},"pakingObj":{"boolean":false,"string":"前端胖头鱼","number":1}}
console.log(JSON.stringify(obj))
// {"name":"前端胖头鱼","age":18,"attr":["coding",123],"date":"2021-10-06T14:59:58.306Z","info":{"age":16,"intro":{"job":null}},"pakingObj":{"boolean":false,"string":"前端胖头鱼","number":1}}

// 3. 测试可遍历对象
let enumerableObj = {}

Object.defineProperties(enumerableObj, {
name: {
value: '前端胖头鱼',
enumerable: true
},
sex: {
value: 'boy',
enumerable: false
},
})

console.log(jsonstringify(enumerableObj))
// {"name":"前端胖头鱼"}

// 4. 测试循环引用和Bigint

let obj1 = { a: 'aa' }
let obj2 = { name: '前端胖头鱼', a: obj1, b: obj1 }
obj2.obj = obj2

console.log(jsonstringify(obj2))
// TypeError: Converting circular structure to JSON
console.log(jsonStringify(BigInt(1)))
// TypeError: Do not know how to serialize a BigInt

复制代码

通过上面测试可以看出,jsonstringify基本和JSON.stringify表现一致,(也有可能测试用例不够全面,欢迎提出一起学习)



作者:前端胖头鱼
链接:https://juejin.cn/post/7017588385615200270

收起阅读 »

java中的IO、NIO、Okio

java IO写这种写方法只能一个字节一个字节的写;注意把要关闭的流写在try括号中,省去了代码中finally关闭的过程,以下例子均是。private static void ioWrite() { try (OutputStream outputS...
继续阅读 »

java IO

这种写方法只能一个字节一个字节的写;

注意把要关闭的流写在try括号中,省去了代码中finally关闭的过程,以下例子均是。

private static void ioWrite() {
try (OutputStream outputStream = new FileOutputStream("./demo.txt")) {
outputStream.write('a');
outputStream.write('b');
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
}

这种读方法只能一个字节一个字节的写;

private static void ioRead() {
try (InputStream inputStream = new FileInputStream("./demo.txt")) {
System.out.println((char)inputStream.read());
System.out.println((char)inputStream.read());
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
}

带缓存的读

private static void ioBufferedRead() {
try (InputStream inputStream = new FileInputStream("./demo.txt");
Reader reader = new InputStreamReader(inputStream);
BufferedReader bufferedReader = new BufferedReader(reader)) {
System.out.println(bufferedReader.readLine());
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
}

带缓存的写

注意需要

bufferedOutputStream.flush()需要写流数据,但是该方法会在流关闭前被自动调用,因此在try中写了流对象后,可以省去这一步。 此外注意,该方法会覆盖原来文件的内容而不是追加。

private static void ioBufferedWrite() {
try (OutputStream outputStream = new FileOutputStream("./demo.txt");
BufferedOutputStream bufferedOutputStream = new BufferedOutputStream(outputStream)) {
bufferedOutputStream.write('a');
bufferedOutputStream.write('q');
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
}

带缓存的读写

 private static void ioWriteRead() {
try (
InputStream inputStream = new BufferedInputStream(new FileInputStream("./demo.txt"));
OutputStream outputStream = new BufferedOutputStream(new FileOutputStream("./demoNew.txt"))) {
byte[] data = new byte[1024];
int read;
while ((read = inputStream.read(data)) != -1) {
outputStream.write(data, 0, read);
}
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
}

IO在网络中实现通信

private static void ioNetDemo() {
try (Socket socket = new Socket("yanfriends.com", 80);
BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream()));
BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream()))) {
writer.write("GET / HTTP/1.1\n" +
"Host: http://www.yanfriends.com\n\n");
writer.flush();
String message;
while ((message = reader.readLine()) != null) {
System.out.println(message);
}
} catch (IOException e) {
e.printStackTrace();
}
}

NIO

NIO(New IO)库于JDK1.4引入,目的和IO一致但实现方式不同,NIO主要用到的是块,所以NIO的效率要比IO高很多。在Java API中提供了两套NIO,一套是针对标准输入输出NIO,另一套就是网络编程NIO。

对比IO:

IONIO
面向流面向缓冲
阻塞IO非阻塞IO
选择器

流和缓存

Java IO是面向流的,这意味着是一次性从流中读取一批数据,这些数据并不会缓存在任何地方,并且对于在流中的数据是不支持在数据中前后移动。如果需要在这些数据中移动(为什么要移动,可以多次读取),则还是需要将这部分数据先缓存在缓冲区中。

NIO采用的是面向缓冲区的方式,有些不同,数据会先读取到缓冲区中以供稍后处理。在buffer中是可以方便地前移和后移,这使得在处理数据时可以有更大的灵活性。但是需要检查buffer是否包含需要的所有数据以便能够将其完整地处理,并且需要确保在通过channel往buffer读数据的时候不能够覆盖还未处理的数据。

阻塞非阻塞

IO流是阻塞式的,当线程调用其read()或write()方法时线程会阻塞,直到完成了数据的读写,在读写的过程中线程是什么都做不了的。

NIO提供了一种非阻塞模式,使得线程向channel请求读数据时,只会获取已经就绪的数据,并不会阻塞以等待所有数据都准备好(IO就是这样做),这样在数据准备的阶段线程就能够去处理别的事情。对于非阻塞式写数据是一样的。线程往channel中写数据时,并不会阻塞以等待数据写完,而是可以处理别的事情,等到数据已经写好了,线程再处理这部分事情。当线程在进行IO调用并且不会进入阻塞的情况下,这部分的空余时间就可以花在和其他channel进行IO交互上。也就是说,这样单个线程就能够管理多个channel的输入和输出了。

Selector

Java NIO中的Selector允许单个线程监控多个channel,可以将多个channel注册到一个Selector中,然后可以"select"出已经准备好数据的channel,或者准备好写入的channel。这个selector机制使得单个线程同时管理多个channel变得更容易。

采用NIO的API调用方式和IO是不一样的,与直接从InputStream中读取字节数据不同,在NIO中,数据必须要先被读到buffer中,然后再从那里进行后续的处理。

读例子

  当线程在进行IO调用并且不会进入阻塞的情况下,这部分的空余时间就可以花在和其他channel进行IO交互上。也就是说,这样单个线程就能够管理多个channel的输入和输出了。

  private static void nioRead() {
try {
RandomAccessFile file = new RandomAccessFile("./demo.txt", "r");
FileChannel channel = file.getChannel();
ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
channel.read(byteBuffer);
byteBuffer.flip();
System.out.println(Charset.defaultCharset().decode(byteBuffer));
byteBuffer.clear();
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
}

NIO在网络中实现通信

private static void nioNetDemo() {
try {
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.bind(new InetSocketAddress(80));
serverSocketChannel.configureBlocking(false);
Selector selector = Selector.open();
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
while (true) {
selector.select();
for (SelectionKey key : selector.selectedKeys()) {
if (key.isAcceptable()) {
SocketChannel socketChannel = serverSocketChannel.accept();
ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
while (socketChannel.read(byteBuffer) != -1) {
byteBuffer.flip();
socketChannel.write(byteBuffer);
byteBuffer.clear();
}
}
}
}
} catch (IOException e) {
e.printStackTrace();
}
}

Okio

Okio的优势

Java IO的读写,缓冲区的存在必然涉及copy的过程,而如果涉及双流操作,比如从一个输入流读入,再写入到一个输出流,在缓冲存在的情况下,数据走向是:

  1. 从输入流读出到缓冲区
  2. 从输入流缓冲区copy到 b[]
  3. 将 b[] copy 到输出流缓冲区
  4. 输出流缓冲区读出数据到输出流

这种操作存在着冗余copy操作,Okio应运而生。除此之外,Okio还简化出了一套对开发者更加友好的API,弥补了IO/NIO使用不方便的缺点。

Segment

Okio使用Segment来作为数据存储手段。Segment 实际上也是对 byte[] 进行封装,再通过各种属性来记录各种状态。在交换时,如果可以,将Segment整体作为数据传授媒介,这样就没有具体数据的copy过程,而是交换了对应的Segment引用。Segment通过Buffer进行缓冲管理,在Buffer.write()里,通过移动引用而不是真实数据,是减少数据copy进而交换数据的关键。

Segment的数据结构如下:

final class Segment {
// 默认容量
static final int SIZE = 8192;
// 最小分享数据量
static final int SHARE_MINIMUM = 1024;
// 存储具体数据的数组
final byte[] data;
// 有效数据索引起始位置
int pos;
// 有效数据索引结束位置
int limit;
// 指示Segment是否为共享状态
boolean shared;
// 指示当前Segment是否为数据拥有者,与shared互斥
// 默认构造函数的Segment owner为true,当把数据分享
// 出去时,被分享的Segment的owner标记为false
boolean owner;
// 指向下一个Segment
Segment next;
// 指向前一个Segment
Segment prev;
}

Okio的依赖链接

private static void okioRead() {
try (BufferedSource source = Okio.buffer(Okio.source(new File("./demo.txt")))) {
System.out.println(source.readUtf8Line());
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) { // AIO Asynchronous I/O
e.printStackTrace();
}
}

Android+Okio实例

下面利用OkHttp和Okio实现一个下载网络图片的简单例子:

File file = new File(getCacheDir() + "/demoImg.jpg");
OkHttpClient client = new OkHttpClient();
final Request request = new Request.Builder()
.url("https://avatar.csdnimg.cn/7/E/5/1_lucasxu01.jpg")
.build();
client.newCall(request)
.enqueue(new Callback() {
@Override
public void onFailure(@NotNull Call call, @NotNull IOException e) {
v.post(new Runnable() {
@Override
public void run() {
Toast.makeText(MainActivity.this, "下载出错", Toast.LENGTH_SHORT).show();
}
});
}

@Override
public void onResponse(@NotNull Call call, @NotNull Response response) {
try (BufferedSink sink = Okio.buffer(Okio.sink(apk))) {
sink.write(response.body().bytes());
} catch (IOException e) {
e.printStackTrace();
}
v.post(new Runnable() {
@Override
public void run() {
Toast.makeText(MainActivity.this, "下载成功", Toast.LENGTH_SHORT).show();
}
});
}
});

小结

Okio核心竞争力为,增强了流于流之间的互动,使得当数据从一个缓冲区移动到另一个缓冲区时,可以不经过copy能达到:

  1. 以Segment作为存储结构,真实数据以类型为byte[]的成员变量data存在,并用其它变量标记数据状态,在需要时,如果可以,移动Segment引用,而非copy data数据
  2. Segment在Segment线程池中以单链表存在以便复用,在Buffer中以双向链表存在存储数据,head指向头部,是最老的数据
  3. Segment能通过slipt()进行分割,可实现数据共享,能通过compact()进行合并。由Buffer来进行数据调度,基本遵守 “大块数据移动引用,小块数据进行copy” 的思想
  4. Source 对应输入流,Sink 对应输出流
  5. TimeOut 以达到在期望时间内完成IO操作的目的,同步超时在每次IO操作中检查耗时,异步超时开启另一线程间隔时间检查耗时

Okio并没有打算优化底层IO方式以及替代原生IO方式,Okio优化了缓冲策略以减轻内存压力和性能消耗,并且对于部分IO场景,提供了更友好的API,而更多的IO场景,该记的还得记。


收起阅读 »

ARouter 拦截器之多 module 独立运行

本文说明上篇文章 已分享了路由配置、跳转、原理、完整的效果演示gif以及源码,而且是多 module 项目演示的,算是路由 ARouter 的入门,还没配置使用的可以先去看看。本文的内容主要涉及如下两个:路由拦截器使用module 独立运行前者在我们...
继续阅读 »

本文说明

上篇文章 已分享了路由配置、跳转、原理、完整的效果演示gif以及源码,而且是多 module 项目演示的,算是路由 ARouter 的入门,还没配置使用的可以先去看看。

本文的内容主要涉及如下两个:

  • 路由拦截器使用
  • module 独立运行

前者在我们开发中有这样一种应用场景,默认用户不登录可以浏览一部分页面,当点击部分页面的时候就需要先去登录,也就是跳转到登录页面,普通的做法是根据需求挨个去做点击事件,这就很麻烦,如果需要跳转登录的时候传递参数啥的,那就改动超级大了;而路由ARouter的拦截器功能就很好的解决了这个问题,还支持自定义拦截器,使用起来很灵活。

后者的使用场景适合项目大,多人开发的情景,这样可以各自负责一个模块,独立调试运行,利于项目管理以及代码的维护。这块在上一篇文章的前提下还需要额外配置,本文会讲。

module 独立运行

先来看看module独立运行,然后我们在各个模块做一个模拟的跳转页面需要验证登录的示例,这样比较清晰。

第一步:配置 gradle.properties

gradle.properties 文件中添加如下代码

#是否需要单独运行某个模块 true:表示某个模块不作为依赖库使用
isSingleCircleModule=true
#isSingleCircleModule=false
isSingleHomeModule=true
#isSingleHomeModule=false

第二步:配置app下的build.gradle

在app下的build.gradle文件配置

if (!isSingleCircleModule.toBoolean()) {
implementation project(path: ':circle')
}
if (!isSingleHomeModule.toBoolean()){
implementation project(path: ':home')
}

并注释掉原来的依赖

//    implementation project(path: ':circle')
// implementation project(path: ':home')

第三步:配置各独立模块下的build.gradle

circle模块下build.gradle文件最顶部改动如下:

//plugins {
// id 'com.android.library'
//}

if (isSingleCircleModule.toBoolean()) {
apply plugin: 'com.android.application'
} else {
apply plugin: 'com.android.library'
}

home模块下build.gradle文件最顶部改动如下:

//plugins {
// id 'com.android.library'
//}

if (isSingleHomeModule.toBoolean()) {
apply plugin: 'com.android.application'
} else {
apply plugin: 'com.android.library'
}

第四步:看效果

上面的配置完成后,点击Sync Project with Gradle Files 等待编译完成,可看到如下状态:

Select Run弹窗

这个时候我们选择其中一个module运行,会发现报错如下:

Could not identify launch activity: Default Activity not found
Error while Launching activity

很明显,我们都知道Android程序的主入口是从清单文件配置的,但我们的各module都还没有做这个工作。

circle模块下的清单文件中,配置如下:

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


<application
android:allowBackup="true"
android:icon="@mipmap/app_icon"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/AppTheme">

<activity android:name=".CircleActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

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

</manifest>

其中的iconlabel以及theme都可以定义在baselib中,这样我们任何 module 配置的时候就可以直接引用,而无需各自复制一份了;除此之外,values文件夹下的东西都可以移动到baselib下,方便其他模块引用,这也就是baselib模块的作用,如果你要细分,还可以j将公共资源放在一个独立的模块里,这个模块通常叫做:commonlib,具体情况而定。

配置完清单文件,运行后发现桌面会多出来一个 APP icon,打开只有一个页面,就是我们的circlemodule的主页面。home模块的清单配置就不展示了,下面看下效果:

module独立运行

这个时候再切回去运行app模块,如果发现有问题,先卸载再运行就ok了。但是会有一个问题,原来可以跳转其他模块的功能,现在跳转不了了,这其实很正常,因为在组件化开发模式下,每个 module 都是独立的app,所以肯定不能直接跳转过去。

那如何实现正常跳转呢?

需要两步,将gradle.properties中的代码修改为如下:

#isSingleCircleModule=true
isSingleCircleModule=false
#isSingleHomeModule=true
isSingleHomeModule=false

接着将circlehome模块的清单文件中的 application属性和默认启动配置项删掉,然后再运行就 ok 了。

如果想将其中一个作为依赖库使用,那么就指匠情挑设置为false即可。

关于组件之间 AndroidManifest 合并问题

其实这个可以在正式打包的时候,注释掉module中的相关代码即可,毕竟是在组件模式。那有没有办法解决每次都要注释的问题呢?答案是yes.

大致思路如下:

在可独立运行的module的res->main文件夹下新建一个文件夹(命名自定义),然后将对应的清单文件复制一份,名称不需要修改,内容的差别就是前面提到的,去掉application属性和默认启动配置项。

接着在对应 module 的 build.gradle 中指定表单的路径,代码如下:

sourceSets {
main {
if (isSingleCircleModule.toBoolean()) {
manifest.srcFile 'src/main/module/AndroidManifest.xml'
} else {
manifest.srcFile 'src/main/AndroidManifest.xml'
}
}
}

这样在不同的开发模式下就会读取到不同的 AndroidManifest.xml ,然后我们需要修改这两个表单的内容以为我们不同的开发模式服务。

单模块独立运行小结

优点:

  • 项目耦合度低,开发效率高,出现问题易排查
  • 利于项目进度管理,分工明确
  • 适合多人大项目

缺点:

  • 前期配置比较复杂,开发过程中需要修改部分配置
  • 稳定性不好把握,毕竟不是google官方出的框架,后期出问题不好处理

其实还有很多问题,实践过的应该明白,每个项目都有自己的独特之处,会有各种各样的奇怪问题,但一般网上我们都可以找到解决方案。

路由拦截器使用

首先还是需要添加几个配置,在工程下的build.gradle文件中添加下面这行代码:

classpath 'com.alibaba:arouter-register:1.0.2'

app模块的build.gradle文件下,配置改动如下:

plugins {
id 'com.android.application'
id 'com.alibaba.arouter' // 拦截器必须配置
}

配置完这两步,按照惯例,该是编译了。

为了演示,我这里在app下新建一个名为LoginActivity的页面,作业登录拦截后跳转的页面,页面内容只有一个提示文本,这里补贴代码。

然后分别在宿主模块app、功能模块circlehome中去做跳转登录页面,看看我们的拦截器是否起到了拦截作用,下面开始定义拦截器。

要独立运行某个模块,这里就不再赘述了,大家自行修改配置即可。

拦截器完整代码如下:

/**
* Description: 登录拦截器
* Date: 2021/10/9 10:42
* <p>
* 拦截器会在跳转之间执行,多个拦截器会按优先级顺序依次执行
* * <p>
* * priority 数值越小权限越高
*/

@Interceptor(priority = 2, name = "登录ARouter拦截器")
public class LoginInterceptor implements IInterceptor {

private Context mContext;

@Override
public void process(Postcard postcard, InterceptorCallback callback) {
boolean isLogin = mContext.getSharedPreferences("arouterdata", mContext.MODE_PRIVATE).getBoolean("isLogin", false);
if (isLogin) {
callback.onContinue(postcard);
} else {
switch (postcard.getPath()) {
// 需要登录的拦截下来
case ARouterPath.APP_MY_INFO:
ARouter.getInstance().build(ARouterPath.LOGIN_PAGE).with(postcard.getExtras()).navigation();
break;
default:
callback.onContinue(postcard);
break;
}
}
}

/**
* 拦截器的初始化,会在sdk初始化的时候调用该方法,仅会调用一次
*
* @param context
*/

@Override
public void init(Context context) {
mContext = context;
}

}

拦截器初始化需要重新安装才会生效,这点要注意。拦截器是不需要我们手动显示调用的,而是框架通过注解来使用的,所以我们只需要写好逻辑代码即可。

以上代码可以实现模块内和跨模块跳转拦截,本地的登录状态我这里没有处理逻辑,所以每次都会被拦截到。下面看效果:

拦截器效果

演示效果模拟进入MyInfoActivity页面时需要先登录,分别从三个模块做了跳转演示。

总结

组件化module独立运行与合并操作起来相对繁琐一点,但优点也很明显。路由框架ARouter的拦截器使用起来就很简单了,其实拦截器完全可以在学完上一篇之后,直接使用,如果组件化多模块独立运行实际项目使用不到,可以先跳过,简单了解流程即可。

Android的框架演变也很快,“三化技术”在两年前特别火,几乎大家都在讨论,但并没有持续多长时间就被新出的技术替代了,而作为一个开发者,自己需要掌握一个基本技能:从零开始搭建一个项目框架,并且这个框架尽可能的要跟上项目的持续发展

收起阅读 »

Android Jetpack系列之Lifecycle

Lifecycle介绍Lifecycle可以让某一个类变成Activity、Fragment的生命周期观察者类,监听其生命周期的变化并可以做出响应。Lifecycle使得代码更有条理性、精简、易于维护。Lifecycle中主要有两个角色:LifecycleOw...
继续阅读 »

Lifecycle介绍

Lifecycle可以让某一个类变成ActivityFragment的生命周期观察者类,监听其生命周期的变化并可以做出响应。Lifecycle使得代码更有条理性、精简、易于维护。

Lifecycle中主要有两个角色:

  • LifecycleOwner: 生命周期拥有者,如Activity/Fragment等类都实现了该接口并通过getLifecycle()获得Lifecycle,进而可通过addObserver()添加观察者。
  • LifecycleObserver: 生命周期观察者,实现该接口后就可以添加到Lifecycle中,从而在被观察者类生命周期发生改变时能马上收到通知。

实现LifecycleOwner的生命周期拥有者可与实现LifecycleObserver的观察者完美配合。

场景case

假设我们有一个在屏幕上显示设备位置的 Activity,我们可能会像下面这样实现:

internal class MyLocationListener(
private val context: Context,
private val callback: (Location) -> Unit) {

fun start() {
// connect to system location service
}

fun stop() {
// disconnect from system location service
}
}

class MyActivity : AppCompatActivity() {
private lateinit var myLocationListener: MyLocationListener

override fun onCreate(...) {
myLocationListener = MyLocationListener(this) { location ->
// update UI
}
}

public override fun onStart() {
super.onStart()
myLocationListener.start()
// manage other components that need to respond
// to the activity lifecycle
}

public override fun onStop() {
super.onStop()
myLocationListener.stop()
// manage other components that need to respond
// to the activity lifecycle
}
}

注:上面代码来自官方示例~

我们可以在Activity 或 Fragment 的生命周期方法(示例中的onStart/onStop)中直接对依赖组件进行操作。但是,这样会导致代码条理性很差且不易扩展。那么有了Lifecycle,可以将依赖组件的代码从Activity/Fragment生命周期方法中移入组件本身中。

Lifecycle使用

根目录下build.gradle:

allprojects {
repositories {
google()

// Gradle小于4.1时,使用下面的声明替换:
// maven {
// url 'https://maven.google.com'
// }
// An alternative URL is 'https://dl.google.com/dl/android/maven2/'
}
}

app下的build.gradle:

    dependencies {
def lifecycle_version = "2.3.1"
def arch_version = "2.1.0"

// ViewModel
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
// LiveData
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"
// Lifecycles only (without ViewModel or LiveData)
implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version"

// Saved state module for ViewModel
implementation "androidx.lifecycle:lifecycle-viewmodel-savedstate:$lifecycle_version"

// Annotation processor
kapt "androidx.lifecycle:lifecycle-compiler:$lifecycle_version"
// 可选 - 如果使用Java8,使用下面这个代替lifecycle-compiler
implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycle_version"

// 可选 - 在Service中使用Lifecycle
implementation "androidx.lifecycle:lifecycle-service:$lifecycle_version"

// 可选 - Application中使用Lifecycle
implementation "androidx.lifecycle:lifecycle-process:$lifecycle_version"

// 可选 - ReactiveStreams support for LiveData
implementation "androidx.lifecycle:lifecycle-reactivestreams-ktx:$lifecycle_version"

// 可选 - Test helpers for LiveData
testImplementation "androidx.arch.core:core-testing:$arch_version"
}

Activity/Fragment中使用Lifecycle

首先先来实现LifecycleObserver观察者:

open class MyLifeCycleObserver : LifecycleObserver {

@OnLifecycleEvent(value = Lifecycle.Event.ON_START)
fun connect(owner: LifecycleOwner) {
Log.e(JConsts.LIFE_TAG, "Lifecycle.Event.ON_CREATE:connect")
}

@OnLifecycleEvent(value = Lifecycle.Event.ON_STOP)
fun disConnect() {
Log.e(JConsts.LIFE_TAG, "Lifecycle.Event.ON_DESTROY:disConnect")
}
}

在方法上,我们使用了@OnLifecycleEvent注解,并传入了一种生命周期事件,其类型可以为ON_CREATEON_STARTON_RESUMEON_PAUSEON_STOPON_DESTROYON_ANY中的一种。其中前6个对应Activity中对应生命周期的回调,最后一个ON_ANY可以匹配任何生命周期回调。 所以,上述代码中的connect()、disConnect()方法分别应该在ActivityonStart()、onStop()中触发时执行。接着来实现我们的Activity:

class MainActivity : AppCompatActivity() {

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
Log.e(JConsts.LIFE_TAG, "$ACTIVITY:onCreate")

//添加LifecycleObserver观察者
lifecycle.addObserver(MyLifeCycleObserver())
}

override fun onStart() {
Log.e(JConsts.LIFE_TAG, "$ACTIVITY:onStart")
super.onStart()
}

override fun onResume() {
Log.e(JConsts.LIFE_TAG, "$ACTIVITY:onResume")
super.onResume()
}

override fun onPause() {
Log.e(JConsts.LIFE_TAG, "$ACTIVITY:onPause")
super.onPause()
}

override fun onStop() {
Log.e(JConsts.LIFE_TAG, "$ACTIVITY:onStop")
super.onStop()
}

override fun onDestroy() {
Log.e(JConsts.LIFE_TAG, "$ACTIVITY:onDestroy")
super.onDestroy()
}
}

可以看到在Activity中我们只是在onCreate()中添加了这么一行代码:

lifecycle.addObserver(MyLifeCycleObserver())

其中getLifecycle()LifecycleOwner中的方法,返回的是Lifecycle对象,并通过addObserver()的方式添加了我们的生命周期观察者。接下来看执行结果,启动Activity:

2021-06-30 20:57:58.038 11257-11257/ E/Lifecycle_Study: ACTIVITY:onCreate

//onStart() 传递到 MyLifeCycleObserver: connect()
2021-06-30 20:57:58.048 11257-11257/ E/Lifecycle_Study: ACTIVITY:onStart
2021-06-30 20:57:58.049 11257-11257/ E/Lifecycle_Study: Lifecycle.Event.ON_START:connect

2021-06-30 20:57:58.057 11257-11257/ E/Lifecycle_Study: ACTIVITY:onResume

关闭Activity:

2021-06-30 20:58:02.646 11257-11257/ E/Lifecycle_Study: ACTIVITY:onPause

//onStop() 传递到 MyLifeCycleObserver: disConnect()
2021-06-30 20:58:03.149 11257-11257/ E/Lifecycle_Study: ACTIVITY:onStop
2021-06-30 20:58:03.161 11257-11257/ E/Lifecycle_Study: Lifecycle.Event.ON_STOP:disConnect

2021-06-30 20:58:03.169 11257-11257/ E/Lifecycle_Study: ACTIVITY:onDestroy

可以看到我们的MyLifeCycleObserver中的connect()/disconnect()方法的确是分别在ActivityonStart()/onStop()回调时执行的。

自定义LifecycleOwner

AndroidX中的Activity、Fragmen实现了LifecycleOwner,通过getLifecycle()能获取到Lifecycle实例(Lifecycle是抽象类,实例化的是子类LifecycleRegistry)。

public interface LifecycleOwner {
@NonNull
Lifecycle getLifecycle();
}

public class LifecycleRegistry extends Lifecycle {

}

如果我们想让一个自定义类成为LifecycleOwner,可以直接实现LifecycleOwner

class CustomLifeCycleOwner : LifecycleOwner {
private lateinit var registry: LifecycleRegistry

fun init() {
registry = LifecycleRegistry(this)
//通过setCurrentState来完成生命周期的传递
registry.currentState = Lifecycle.State.CREATED
}

fun onStart() {
registry.currentState = Lifecycle.State.STARTED
}

fun onResume() {
registry.currentState = Lifecycle.State.RESUMED
}

fun onPause() {
registry.currentState = Lifecycle.State.STARTED
}

fun onStop() {
registry.currentState = Lifecycle.State.CREATED
}

fun onDestroy() {
registry.currentState = Lifecycle.State.DESTROYED
}

override fun getLifecycle(): Lifecycle {
//返回LifecycleRegistry实例
return registry
}
}

首先我们的自定义类实现了接口LifecycleOwner,并在getLifecycle()返回LifecycleRegistry实例,接下来就可以通过LifecycleRegistry#setCurrentState来传递生命周期状态了。到目前为止,已经完成了大部分工作,最后也是需要去添加LifecycleObserver:

可以看到getLifecycle()返回的是LifecycleRegistry实例,并且在onSaveInstanceState()中分发了Lifecycle.State.CREATED状态,但是其他生命周期回调中并没有写了呀,嗯哼?再细看一下,onCreate()中有个ReportFragment.injectIfNeededIn(this),直接进去看看:

ObserverWithState#dispatchEvent()中调用了mLifecycleObserver.onStateChanged(),这个mLifecycleObserverLifecycleEventObserver类型(父类是接口LifecycleObserver ),在构造方法中通过Lifecycling.lifecycleEventObserver()创建的,最终返回的是ReflectiveGenericLifecycleObserver

//ReflectiveGenericLifecycleObserver.java
class ReflectiveGenericLifecycleObserver implements LifecycleEventObserver {
private final Object mWrapped;
private final CallbackInfo mInfo;

ReflectiveGenericLifecycleObserver(Object wrapped) {
mWrapped = wrapped;
mInfo = ClassesInfoCache.sInstance.getInfo(mWrapped.getClass());
}

@Override
public void onStateChanged(@NonNull LifecycleOwner source, @NonNull Event event) {
mInfo.invokeCallbacks(source, event, mWrapped);
}
}

ClassesInfoCache内部存了所有观察者的回调信息,CallbackInfo是当前观察者的回调信息。getInfo()中如果从内存mCallbackMap中有对应回调信息,直接返回;否则通过createInfo()内部解析注解OnLifecycleEvent对应的方法并最终生成CallbackInfo返回。

//ClassesInfoCache.java
CallbackInfo getInfo(Class<?> klass) {

CallbackInfo existing = mCallbackMap.get(klass);
if (existing != null) {
return existing;
}
existing = createInfo(klass, null);
return existing;
}

private void verifyAndPutHandler(Map<MethodReference, Lifecycle.Event> handlers,
MethodReference newHandler, Lifecycle.Event newEvent, Class<?> klass) {
Lifecycle.Event event = handlers.get(newHandler);
if (event == null) {
handlers.put(newHandler, newEvent);
}
}

private CallbackInfo createInfo(Class<?> klass, @Nullable Method[] declaredMethods) {
Class<?> superclass = klass.getSuperclass();
Map<MethodReference, Lifecycle.Event> handlerToEvent = new HashMap<>();
if (superclass != null) {
CallbackInfo superInfo = getInfo(superclass);
if (superInfo != null) {
handlerToEvent.putAll(superInfo.mHandlerToEvent);
}
}

Class<?>[] interfaces = klass.getInterfaces();
for (Class<?> intrfc : interfaces) {
for (Map.Entry<MethodReference, Lifecycle.Event> entry : getInfo(
intrfc).mHandlerToEvent.entrySet()) {
verifyAndPutHandler(handlerToEvent, entry.getKey(), entry.getValue(), klass);
}
}

Method[] methods = declaredMethods != null ? declaredMethods : getDeclaredMethods(klass);
boolean hasLifecycleMethods = false;
//遍历寻找OnLifecycleEvent注解对应的方法
for (Method method : methods) {
OnLifecycleEvent annotation = method.getAnnotation(OnLifecycleEvent.class);
if (annotation == null) {
continue;
}
hasLifecycleMethods = true;
Class<?>[] params = method.getParameterTypes();
int callType = CALL_TYPE_NO_ARG;
if (params.length > 0) {
callType = CALL_TYPE_PROVIDER;
//第一个方法参数必须是LifecycleOwner
if (!params[0].isAssignableFrom(LifecycleOwner.class)) {
throw new IllegalArgumentException(
"invalid parameter type. Must be one and instanceof LifecycleOwner");
}
}
Lifecycle.Event event = annotation.value();

if (params.length > 1) {
callType = CALL_TYPE_PROVIDER_WITH_EVENT;
//第2个参数必须是Lifecycle.Event
if (!params[1].isAssignableFrom(Lifecycle.Event.class)) {
throw new IllegalArgumentException(
"invalid parameter type. second arg must be an event");
}
//当有2个参数时,注解必须是Lifecycle.Event.ON_ANY
if (event != Lifecycle.Event.ON_ANY) {
throw new IllegalArgumentException(
"Second arg is supported only for ON_ANY value");
}
}
if (params.length > 2) {
throw new IllegalArgumentException("cannot have more than 2 params");
}
MethodReference methodReference = new MethodReference(callType, method);
verifyAndPutHandler(handlerToEvent, methodReference, event, klass);
}
CallbackInfo info = new CallbackInfo(handlerToEvent);
mCallbackMap.put(klass, info);
mHasLifecycleMethods.put(klass, hasLifecycleMethods);
return info;
}

//CallbackInfo.java
static class CallbackInfo {
final Map<Lifecycle.Event, List<MethodReference>> mEventToHandlers;
final Map<MethodReference, Lifecycle.Event> mHandlerToEvent;

CallbackInfo(Map<MethodReference, Lifecycle.Event> handlerToEvent) {
mHandlerToEvent = handlerToEvent;
mEventToHandlers = new HashMap<>();
for (Map.Entry<MethodReference, Lifecycle.Event> entry : handlerToEvent.entrySet()) {
Lifecycle.Event event = entry.getValue();
List<MethodReference> methodReferences = mEventToHandlers.get(event);
if (methodReferences == null) {
methodReferences = new ArrayList<>();
mEventToHandlers.put(event, methodReferences);
}
methodReferences.add(entry.getKey());
}
}

void invokeCallbacks(LifecycleOwner source, Lifecycle.Event event, Object target) {
invokeMethodsForEvent(mEventToHandlers.get(event), source, event, target);
invokeMethodsForEvent(mEventToHandlers.get(Lifecycle.Event.ON_ANY), source, event,
target);
}

private static void invokeMethodsForEvent(List<MethodReference> handlers,
LifecycleOwner source, Lifecycle.Event event, Object mWrapped) {
if (handlers != null) {
for (int i = handlers.size() - 1; i >= 0; i--) {
handlers.get(i).invokeCallback(source, event, mWrapped);
}
}
}

最终调用到了MethodReference#invokeCallback()

//MethodReference.java
static class MethodReference {
final int mCallType;
final Method mMethod;

MethodReference(int callType, Method method) {
mCallType = callType;
mMethod = method;
mMethod.setAccessible(true);
}

void invokeCallback(LifecycleOwner source, Lifecycle.Event event, Object target) {
//noinspection TryWithIdenticalCatches
try {
//OnLifecycleEvent注解对应的方法入参
switch (mCallType) {
//没有参数
case CALL_TYPE_NO_ARG:
mMethod.invoke(target);
break;
//一个参数:LifecycleOwner
case CALL_TYPE_PROVIDER:
mMethod.invoke(target, source);
break;
//两个参数:LifecycleOwner,Event
case CALL_TYPE_PROVIDER_WITH_EVENT:
mMethod.invoke(target, source, event);
break;
}
} catch (InvocationTargetException e) {
throw new RuntimeException("Failed to call observer method", e.getCause());
} catch (IllegalAccessException e) {
throw new RuntimeException(e);
}
}

@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}

MethodReference that = (MethodReference) o;
return mCallType == that.mCallType && mMethod.getName().equals(that.mMethod.getName());
}

@Override
public int hashCode() {
return 31 * mCallType + mMethod.getName().hashCode();
}
}

根据不同入参个数通过反射来初始化并执行观察者相应方法,整个流程就从LifecycleOwner中的生命周期Event传到了LifecycleObserver中对应的方法。到这里整个流程就差不多结束了,最后是LifecycleOwner的子类LifecycleRegistry添加观察者的过程:

//LifecycleRegistry.java
@Override
public void addObserver(@NonNull LifecycleObserver observer) {
State initialState = mState == DESTROYED ? DESTROYED : INITIALIZED;
ObserverWithState statefulObserver = new ObserverWithState(observer, initialState);
//key是LifecycleObserver,value是ObserverWithState
ObserverWithState previous = mObserverMap.putIfAbsent(observer, statefulObserver);
//如果已经存在,直接返回
if (previous != null) {
return;
}
LifecycleOwner lifecycleOwner = mLifecycleOwner.get();
if (lifecycleOwner == null) {
// it is null we should be destroyed. Fallback quickly
return;
}

boolean isReentrance = mAddingObserverCounter != 0 || mHandlingEvent;
//目标State
State targetState = calculateTargetState(observer);
mAddingObserverCounter++;
//循环遍历,将目标State连续同步到Observer中
while ((statefulObserver.mState.compareTo(targetState) < 0
&& mObserverMap.contains(observer))) {
pushParentState(statefulObserver.mState);
statefulObserver.dispatchEvent(lifecycleOwner, upEvent(statefulObserver.mState));
popParentState();
// mState / subling may have been changed recalculate
targetState = calculateTargetState(observer);
}

if (!isReentrance) {
// we do sync only on the top level.
sync();
}
mAddingObserverCounter--;
}

private State calculateTargetState(LifecycleObserver observer) {
Entry<LifecycleObserver, ObserverWithState> previous = mObserverMap.ceil(observer);

State siblingState = previous != null ? previous.getValue().mState : null;
State parentState = !mParentStates.isEmpty() ? mParentStates.get(mParentStates.size() - 1)
: null;
return min(min(mState, siblingState), parentState);
}

添加观察者,并通过while循环,将最新的State状态连续同步到Observer中,虽然可能添加ObserverLifecyleOwner分发事件晚,但是依然能收到所有事件,类似于事件总线的粘性事件。最后画一下整体的类图关系: Lifecycle.png


收起阅读 »

做一个透明的Dialog Activity

做一个透明的Dialog Activity平时在很多软件中,肯定见到过从底部的弹窗,比如分享某个文件,从底部弹出的分享平台,大部分是通过PopupWindow 底部弹出实现,这次来讲一个不一样的。1. 什么是 Dialog Activity让Acti...
继续阅读 »

做一个透明的Dialog Activity

平时在很多软件中,肯定见到过从底部的弹窗,比如分享某个文件,从底部弹出的分享平台,大部分是通过PopupWindow 底部弹出实现,这次来讲一个不一样的。

1.png

1. 什么是 Dialog Activity

让Activity拥有和Dialog一样的效果,背景虚化,悬浮效果。

2. 为什么要使用 Dialog Activity

有时候我们需要在弹窗中去做复杂的逻辑,这就导致Dialog很臃肿,而拥有Dialog样式的Activity可以像我们写UI一样,使用架构去对复杂的逻辑进行层次划分,这样在逻辑上会清洗一些,在页面的声明周期上也更方便管理一些。

3. 怎么实现 Dialog Activity

3.1 写一个样式

<style name="Theme.ActivityDialogStyle" parent="Theme.AppCompat.Light.NoActionBar">
<item name="android:windowIsTranslucent">true</item>
<item name="android:windowBackground">@android:color/transparent</item>
<item name="android:backgroundDimEnabled">true</item>
<item name="android:windowContentOverlay">@null</item>
<item name="android:windowCloseOnTouchOutside">false</item>
<item name="android:windowIsFloating">true</item>
</style>

其中的含义
windowIsTranslucent: 是否半透明
windowBackground: 设置dialog的背景
backgroundDimEnabled:背景是否模糊显示
windowContentOverlay:设置窗口内容不覆盖
windowCloseOnTouchOutside:
windowIsFloating:是否浮现在activity之上,这个属性很重要,设置为true之后,Activty的状态栏才会消失。

3.2 引用样式

 <activity
android:name=".MainActivity2"
android:theme="@style/Theme.ActivityDialogStyle" />

注意:activity必须要继承 AppCompatActivity。

3.3 可配置选项

如果需要设置圆角背景

在onCreate添加

getWindow().setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT));

如果想让该页面填满屏幕,大家知道Dialog默认是不填满的。 在onCreate添加

 Window window = getWindow();
// 把 DecorView 的默认 padding 取消,同时 DecorView 的默认大小也会取消
window.getDecorView().setPadding(0, 0, 0, 0);
WindowManager.LayoutParams layoutParams = window.getAttributes();
// 设置宽度
layoutParams.width = WindowManager.LayoutParams.MATCH_PARENT;
window.setAttributes(layoutParams);
// 给 DecorView 设置背景颜色,很重要,不然导致 Dialog 内容显示不全,有一部分内容会充当 padding,上面例子有举出
// window.getDecorView().setBackgroundColor(Color.GREEN);

3.4 踩坑

实际上整个的过程十分简单,但是诡异的事情发生了。我写了一个布局,是线性布局,它并没有什么问题,却一直无法正常显示。去掉 windowIsFloating 属性就好了,但windowIsFloating会造成dialog Activity存在状态栏。 最终通过修改布局解决,先看看第一个布局和效果。

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:context=".MainActivity2">

<LinearLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />

<LinearLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="2"
android:background="@color/white" />

</LinearLayout>

可以看到这个Activity是显示了,因为背景有虚化,但layout_weight 为2的白色却没有显示。

2.png

修改后的布局(使用相对布局作为根布局):

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity2">

<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">

<LinearLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />

<LinearLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="2"
android:background="@color/white" />
</LinearLayout>
</RelativeLayout>

3.png

这样才正常显示,虽然解决了,但是原因真的好迷。

收起阅读 »

Java多线程2 多个线程之间共享数据

线程范围的共享变量多个业务模块针对同一个static变量的操作 要保证在不同线程中 各模块操作的是自身对应的变量对象public class ThreadScopeSharaData { private static int data = 0 ; ...
继续阅读 »

线程范围的共享变量

多个业务模块针对同一个static变量的操作 要保证在不同线程中 各模块操作的是自身对应的变量对象


public class ThreadScopeSharaData {

private static int data = 0 ;

public static void main(String[] args) {
for(int i = 0 ;i<2 ;i++){
new Thread(new Runnable(){

@Override
public void run() {
data = new Random().nextInt();
System.out.println(Thread.currentThread().getName()+ " put random data:"+data);
new A().get() ;
new B().get() ;
}

}).start() ;
}

}

static class A {
public int get(){
System.out.println("A from " + Thread.currentThread().getName()
+ " get data :" + data);
return data ;
}
}

static class B{
public int get(){
System.out.println("B from " + Thread.currentThread().getName()
+ " get data :" + data);
return data ;
}
}
}

模块A ,B都需要访问static的变量data 在线程0中会随机生成一个data值 假设为10 那么此时模块A和模块B在线程0中得到的data的值为10 ;在线程1中 假设会为data赋值为20 那么在当前线程下

模块A和模块B得到data的值应该为20

看程序执行的结果:


Thread-0 put random data:-2009009251
Thread-1 put random data:-2009009251
A from Thread-0 get data :-2009009251
A from Thread-1 get data :-2009009251
B from Thread-0 get data :-2009009251
B from Thread-1 get data :-2009009251

Thread-0 put random data:-2045829602
Thread-1 put random data:-1842611697
A from Thread-0 get data :-1842611697
A from Thread-1 get data :-1842611697
B from Thread-0 get data :-1842611697
B from Thread-1 get data :-1842611697

会出现两种情况

1.由于线程执行速度,新的随机值将就的随机值覆盖 data 值一样
2.data 值不一样,但 A、B线程都

1.使用Map实现线程范围内数据的共享

可是将data数据和当前允许的线程绑定在一块,在模块A和模块B去获取数据data的时候 是通过当前所属的线程去取得data的结果就行了。
声明一个Map集合 集合的Key为Thread 存储当前所属线程 Value 保存data的值,代码如下:


public class ThreadScopeSharaData {


private static Map<Thread, Integer> threadData = new HashMap<>();

public static void main(String[] args) {

for (int i = 0; i < 2; i++) {
new Thread(new Runnable() {
@Override
public void run() {
int data = new Random().nextInt();
System.out.println(Thread.currentThread().getName() + " put random data:" + data);
threadData.put(Thread.currentThread(), data);
new A().get();
new B().get();

}
}).start();

}

}

static class A {
public void get() {
int data = threadData.get(Thread.currentThread());

System.out.println("A from " + Thread.currentThread().getName() + " get data:" + data);

}
}

static class B {
public void get() {
int data = threadData.get(Thread.currentThread());
System.out.println("B from " + Thread.currentThread().getName() + " get data:" + data);

}
}
}

Thread-0 put random data:-123490895
Thread-1 put random data:-1060992440
A from Thread-0 get data:-123490895
A from Thread-1 get data:-1060992440
B from Thread-0 get data:-123490895
B from Thread-1 get data:-1060992440
2.ThreadLocal实现线程范围内数据的共享

(1)订单处理包含一系列操作:减少库存量、增加一条流水台账、修改总账,这几个操作要在同一个事务中完成,通常也即同一个线程中进行处理,如果累加公司应收款的操作失败了,则应该把前面的操作回滚,否则,提交所有操作,这要求这些操作使用相同的数据库连接对象,而这些操作的代码分别位于不同的模块类中。\

(2)银行转账包含一系列操作: 把转出帐户的余额减少,把转入帐户的余额增加,这两个操作要在同一个事务中完成,它们必须使用相同的数据库连接对象,转入和转出操作的代码分别是两个不同的帐户对象的方法。\

(3)例如Strut2的ActionContext,同一段代码被不同的线程调用运行时,该代码操作的数据是每个线程各自的状态和数据,对于不同的线程来说,getContext方法拿到的对象都不相同,对同一个线程来说,不管调用getContext方法多少次和在哪个模块中getContext方法,拿到的都是同一个。\

4.实验案例:定义一个全局共享的ThreadLocal变量,然后启动多个线程向该ThreadLocal变量中存储一个随机值,接着各个线程调用另外其他多个类的方法,这多个类的方法中读取这个ThreadLocal变量的值,就可以看到多个类在同一个线程中共享同一份数据。\

5.实现对ThreadLocal变量的封装,让外界不要直接操作ThreadLocal变量。
(1)对基本类型的数据的封装,这种应用相对很少见。
(2)对对象类型的数据的封装,比较常见,即让某个类针对不同线程分别创建一个独立的实例对象。


public class ThreadLocalTest {

private static ThreadLocal<Integer> threadLocal = new ThreadLocal<>();

public static void main(String[] args) {

for (int i = 0; i < 2; i++) {
new Thread(new Runnable() {
@Override
public void run() {
int data = new Random().nextInt();
System.out.println(Thread.currentThread().getName() + " put random data:" + data);
threadLocal.set(data);
new A().get();
new B().get();

}
}).start();

}

}

static class A {
public void get() {
int data = threadLocal.get();

System.out.println("A from " + Thread.currentThread().getName() + " get data:" + data);

}
}

static class B {
public void get() {
int data = threadLocal.get();
System.out.println("B from " + Thread.currentThread().getName() + " get data:" + data);

}
}
}

Thread-0 put random data:-2015900409
Thread-1 put random data:-645411160
A from Thread-0 get data:-2015900409
A from Thread-1 get data:-645411160
B from Thread-0 get data:-2015900409
B from Thread-1 get data:-645411160
优化

public class ThreadLocalTest {

private static ThreadLocal<Integer> threadLocal = new ThreadLocal<>();

//private static ThreadLocal<MyThreadScopeData> myThreadScopeDataThreadLocal = new ThreadLocal<>();


public static void main(String[] args) {

for (int i = 0; i < 2; i++) {
new Thread(new Runnable() {
@Override
public void run() {
int data = new Random().nextInt();
System.out.println(Thread.currentThread().getName() + " put random data:" + data);
threadLocal.set(data);

// MyThreadScopeData myThreadScopeData = new MyThreadScopeData();
// myThreadScopeData.setName("name" + data);
// myThreadScopeData.setAge(data);
// myThreadScopeDataThreadLocal.set(myThreadScopeData);

//获取与当前线程绑定的实例并设置值
MyThreadScopeData.getThreadInstance().setName("name" + data);
MyThreadScopeData.getThreadInstance().setAge(data);
new A().get();
new B().get();

}
}).start();

}

}

static class A {
public void get() {
int data = threadLocal.get();


// MyThreadScopeData myData = myThreadScopeDataThreadLocal.get();
//
//
// System.out.println("A from " + Thread.currentThread().getName()
// + " getMyData: " + myData.getName() + "," + myData.getAge());

MyThreadScopeData myData = MyThreadScopeData.getThreadInstance();
System.out.println("A from " + Thread.currentThread().getName()
+ " getMyData: " + myData.getName() + "," + myData.getAge());
}
}

static class B {
public void get() {
int data = threadLocal.get();
//System.out.println("B from " + Thread.currentThread().getName() + " get data:" + data);

MyThreadScopeData myData = MyThreadScopeData.getThreadInstance();
System.out.println("B from " + Thread.currentThread().getName()
+ " getMyData: " + myData.getName() + "," + myData.getAge());
}
}
}

//一个绑定当前线程的类
class MyThreadScopeData {

private static ThreadLocal<MyThreadScopeData> map = new ThreadLocal<>();
private String name;
private int age;

private MyThreadScopeData() {
}

//定义一个静态方法,返回各线程自己的实例
//这里不必用同步,因为每个线程都要创建自己的实例,所以没有线程安全问题。
public static MyThreadScopeData getThreadInstance() {
//获取当前线程绑定的实例
MyThreadScopeData instance = map.get();
if (instance == null) {
instance = new MyThreadScopeData();
map.set(instance);
}
return instance;
}

public String getName() {
return name;
}

public void setName(String name) {
this.name = name;
}

public int getAge() {
return age;
}

public void setAge(int age) {
this.age = age;
}


}

Thread-1 put random data:-1041517189
Thread-0 put random data:-98835751
A from Thread-1 getMyData: name-1041517189,-1041517189
A from Thread-0 getMyData: name-98835751,-98835751
B from Thread-1 getMyData: name-1041517189,-1041517189
B from Thread-0 getMyData: name-98835751,-98835751
实例:

设计4个线程,其中两个线程每次对j增加1,另外两个线程对j每次减少1,写出程序。

1、如果每个线程执行的代码相同,可以使用同一个Runnable对象,这个Runnable对象中有那个共享数据,例如,卖票系统就可以这么做。


public class SellTicket {
//卖票系统,多个窗口的处理逻辑是相同的
public static void main(String[] args) {
Ticket t = new Ticket();
new Thread(t).start();
new Thread(t).start();
}
}

/**
* 将属性和处理逻辑,封装在一个类中
*
* @author yang
*/

class Ticket implements Runnable {

private int ticket = 10;

public synchronized void run() {
while (ticket > 0) {
ticket--;
System.out.println("当前票数为:" + ticket);
}
}
}

2、如果每个线程执行的代码不同,这时候需要用不同的Runnable对象,例如,设计2个线程。一个线程对j增加1,另外一个线程对j减1,银行存取款系统。


public class MultiThreadShareData {
private int j;
public static void main(String[] args) {
MultiThreadShareData multiThreadShareData = new MultiThreadShareData();
for(int i=0;i<2;i++){
new Thread(multiThreadShareData.new ShareData1()).start();//增加
new Thread(multiThreadShareData.new ShareData2()).start();//减少
}
}
//自增
private synchronized void Inc(){
j++;
System.out.println(Thread.currentThread().getName()+" inc "+j);
}
//自减
private synchronized void Dec(){
j--;
System.out.println(Thread.currentThread().getName()+" dec "+j);
}

class ShareData1 implements Runnable {
public void run() {
for(int i=0;i<5;i++){
Inc();
}
}
}
class ShareData2 implements Runnable {
public void run() {
for(int i=0;i<5;i++){
Dec();
}
}
}
}

Thread-0 inc 1
Thread-0 inc 2
Thread-0 inc 3
Thread-0 inc 4
Thread-0 inc 5
Thread-1 dec 4
Thread-1 dec 3
Thread-2 inc 4
Thread-2 inc 5
Thread-2 inc 6
Thread-2 inc 7
Thread-2 inc 8
Thread-1 dec 7
Thread-1 dec 6
Thread-1 dec 5
Thread-3 dec 4
Thread-3 dec 3
Thread-3 dec 2
Thread-3 dec 1
Thread-3 dec 0
收起阅读 »

Kotlin是如何帮助你避免内存泄漏的?

本文的代码位置在github.com/marcosholga…中的kotlin-mem-leak分支上。 我是通过创建一个会导致内存泄漏的Activity,然后观察其使用Java和Kotlin编写时的表现来进行测试的。 其中Java代码如下: public c...
继续阅读 »

本文的代码位置在github.com/marcosholga…中的kotlin-mem-leak分支上。
我是通过创建一个会导致内存泄漏的Activity,然后观察其使用JavaKotlin编写时的表现来进行测试的。
其中Java代码如下:


public class LeakActivity extends Activity {

@Override protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_leak);
View button = findViewById(R.id.button);
button.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
startAsyncWork();
}
});
}

@SuppressLint("StaticFieldLeak")
void startAsyncWork() {
Runnable work = new Runnable() {
@Override public void run() {
SystemClock.sleep(20000);
}
};
new Thread(work).start();
}
}


如上述代码所示,我们的button点击之后,执行了一个耗时任务。这样如果我们在20s之内关闭LeakActivity的话就会产生内存泄漏,因为这个新开的线程持有对LeakActivity的引用。如果我们是在20s之后再关闭这个Activity的话,就不会导致内存泄漏。
然后我们把这段代码改成Kotlin版本:


class KLeakActivity : Activity() {

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_leak)
button.setOnClickListener { startAsyncWork() }
}

private fun startAsyncWork() {
val work = Runnable { SystemClock.sleep(20000) }
Thread(work).start()
}
}

咋一看,好像就只是在Runable中使用lambda表达式替换了原来的样板代码。然后我使用leakcanary和我自己的@LeakTest注释写了一个内存泄漏测试用例。


class LeakTest {
@get:Rule
var mainActivityActivityTestRule = ActivityTestRule(KLeakActivity::class.java)

@Test
@LeakTest
fun testLeaks() {
onView(withId(R.id.button)).perform(click())
}
}


我们使用这个用例分别对Java写的LeakActivityKotlin写的KLeakActivity进行测试。测试结果是Java写的出现内存泄漏,而Kotlin写的则没有出现内存泄漏。
这个问题困扰了我很长时间,一度接近自闭。。


image


然后某天,我突然灵光一现,感觉应该和编译后字节码有关系。


分析LeakActivity.java的字节码


Java类产生的字节码如下:


.method startAsyncWork()V
.registers 3
.annotation build Landroid/annotation/SuppressLint;
value = {
"StaticFieldLeak"
}
.end annotation

.line 29
new-instance v0, Lcom/marcosholgado/performancetest/LeakActivity$2;

invoke-direct {v0, p0}, Lcom/marcosholgado/performancetest/LeakActivity$2;-><init>
(Lcom/marcosholgado/performancetest/LeakActivity;)V

.line 34
.local v0, "work":Ljava/lang/Runnable;
new-instance v1, Ljava/lang/Thread;

invoke-direct {v1, v0}, Ljava/lang/Thread;-><init>(Ljava/lang/Runnable;)V

invoke-virtual {v1}, Ljava/lang/Thread;->start()V

.line 35
return-void
.end method


我们知道匿名内部类持有对外部类的引用,正是这个引用导致了内存泄漏的产生,接下来我们就在字节码中找出这个引用。


new-instance v0, Lcom/marcosholgado/performancetest/LeakActivity$2;

复制代码

上述字节码的含义是:
首先我们创建了一个LeakActivity$2的实例。。


奇怪的是我们没有创建这个类啊,那这个类应该是系统自动生成的,那它的作用是什么啊?
我们打开LeakActivity$2的字节码看下


.class Lcom/marcosholgado/performancetest/LeakActivity$2;
.super Ljava/lang/Object;
.source "LeakActivity.java"

# interfaces
.implements Ljava/lang/Runnable;

# instance fields
.field final synthetic this$0:Lcom/marcosholgado/performancetest/LeakActivity;

# direct methods
.method constructor <init>(Lcom/marcosholgado/performancetest/LeakActivity;)V
.registers 2
.param p1, "this$0" # Lcom/marcosholgado/performancetest/LeakActivity;

.line 29
iput-object p1, p0, Lcom/marcosholgado/performancetest/LeakActivity$2;
->this$0:Lcom/marcosholgado/performancetest/LeakActivity;

invoke-direct {p0}, Ljava/lang/Object;-><init>()V

return-void
.end method


第一个有意思的事是这个LeakActivity$2实现了Runnable接口。


这就说明LeakActivity$2就是那个持有LeakActivity对象引用的匿名内部类的对象。


# interfaces
.implements Ljava/lang/Runnable;


就像我们前面说的,这个LeakActivity$2应该持有LeakActivity的引用,那我们继续找。


# instance fields
.field final synthetic
this$0:Lcom/marcosholgado/performancetest/LeakActivity;


果然,我们发现了外部类LeakActivity的对象的引用。
那这个引用是什么时候传入的呢?只有可能是在构造器中传入的,那我们继续找它的构造器。


.method constructor 
<init>(Lcom/marcosholgado/performancetest/LeakActivity;)V

果然,在构造器中传入了LeakActivity对象的引用。
让我们回到LeakActivity的字节码中,看看这个LeakActivity$2被初始化的时候。


new-instance v0, Lcom/marcosholgado/performancetest/LeakActivity$2;
invoke-direct {v0, p0},
Lcom/marcosholgado/performancetest/LeakActivity$2;-><init>
(Lcom/marcosholgado/performancetest/LeakActivity;)V


可以看到,我们使用LeakActivity对象来初始化LeakActivity$2对象,这样就解释了为什么LeakActivity.java会出现内存泄漏的现象。


分析 KLeakActivity.kt的字节码


KLeakActivity.kt中我们关注startAsyncWork这个方法的字节码,因为其他部分和Java写法是一样的,只有这部分不一样。
该方法的字节码如下所示:


.method private final startAsyncWork()V
.registers 3

.line 20
sget-object v0,
Lcom/marcosholgado/performancetest/KLeakActivity$startAsyncWork$work$1;
->INSTANCE:Lcom/marcosholgado/performancetest/KLeakActivity$startAsyncWork$work$1;

check-cast v0, Ljava/lang/Runnable;

.line 24
.local v0, "work":Ljava/lang/Runnable;
new-instance v1, Ljava/lang/Thread;

invoke-direct {v1, v0}, Ljava/lang/Thread;-><init>(Ljava/lang/Runnable;)V

invoke-virtual {v1}, Ljava/lang/Thread;->start()V

.line 25
return-void
.end method

可以看出,与Java字节码中初始化一个包含Activity引用的实现Runnable接口对象不同的是,这个字节码使用了静态变量来执行静态方法。


sget-object v0,         
Lcom/marcosholgado/performancetest/KLeakActivity$startAsyncWork$work$1; ->
INSTANCE:Lcom/marcosholgado/performancetest/KLeakActivity$startAsyncWork$work$1;


我们深入KLeakActivity\$startAsyncWork\$work$1的字节码看下:


.class final Lcom/marcosholgado/performancetest/KLeakActivity$startAsyncWork$work$1;
.super Ljava/lang/Object;
.source "KLeakActivity.kt"

# interfaces
.implements Ljava/lang/Runnable;

.method static constructor <clinit>()V
.registers 1

new-instance v0,
Lcom/marcosholgado/performancetest/KLeakActivity$startAsyncWork$work$1;

invoke-direct {v0},
Lcom/marcosholgado/performancetest/KLeakActivity$startAsyncWork$work$1;-><init>()V

sput-object v0,
Lcom/marcosholgado/performancetest/KLeakActivity$startAsyncWork$work$1;
->INSTANCE:Lcom/marcosholgado/performancetest/KLeakActivity$startAsyncWork$work$1;

return-void
.end method

.method constructor <init>()V
.registers 1

invoke-direct {p0}, Ljava/lang/Object;-><init>()V

return-void
.end method

可以看出,KLeakActivity\$startAsyncWork\$work$1实现了Runnable接口,但是其拥有的是静态方法,因此不需要外部类对象的引用。
所以Kotlin不出现内存泄漏的原因出来了,在Kotlin中,我们使用lambda(实际上是一个 SAM)来代替Java中的匿名内部类。没有Activity对象的引用就不会发生内存泄漏。
当然并不是说只有Kotlin才有这个功能,如果你使用Java8中的lambda的话,一样不会发生内存泄漏。
如果你想对这部分做更深入的了解,可以参看这篇文章Translation of Lambda Expressions
如果有需要翻译的同学可以在评论里面说就行啦。


image


现在把其中比较重要的一部分说下:



上述段落中的Lamdba表达式可以被认为是静态方法。因为它们没有使用类中的实例属性,例如使用super、this或者该类中的成员变量。
我们把这种Lambda称为Non-instance-capturing lambdas(这里我感觉还是不翻译为好)。而那些需要实例属性的Lambda则称为instance-capturing lambdas




Non-instance-capturing lambdas可以被认为是private、static方法。instance-capturing lambdas可以被认为是普通的private、instance方法。



这段话放在我们这篇文章中是什么意思呢?


因为我们Kotlin中的lambda没有使用实例属性,所以其是一个non-instance-capturing lambda,可以被当成静态方法来看待,就不会产生内存泄漏。


如果我们在其中添加一个外部类对象属性的引用的话,这个lambda就转变成instance-capturing lambdas,就会产生内存泄漏。


class KLeakActivity : Activity() {

private var test: Int = 0

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_leak)
button.setOnClickListener { startAsyncWork() }
}

private fun startAsyncWork() {
val work = Runnable {
test = 1 // comment this line to pass the test
SystemClock.sleep(20000)
}
Thread(work).start()
}
}

如上述代码所示,我们使用了test这个实例属性,就会导致内存泄漏。
startAsyncWork方法的字节码如下所示:


.method private final startAsyncWork()V
.registers 3

.line 20
new-instance v0, Lcom/marcosholgado/performancetest/KLeakActivity$startAsyncWork$work$1;

invoke-direct {v0, p0},
Lcom/marcosholgado/performancetest/KLeakActivity$startAsyncWork$work$1;
-><init>(Lcom/marcosholgado/performancetest/KLeakActivity;)V

check-cast v0, Ljava/lang/Runnable;

.line 24
.local v0, "work":Ljava/lang/Runnable;
new-instance v1, Ljava/lang/Thread;

invoke-direct {v1, v0}, Ljava/lang/Thread;-><init>(Ljava/lang/Runnable;)V

invoke-virtual {v1}, Ljava/lang/Thread;->start()V

.line 25
return-void
.end method

很明显,我们传入了KLeakActivity的对象,因此就会导致内存泄漏。


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

建议收藏!!Flutter状态管理插件哪家强?请看岛上码农的排行榜!

前言一路下来,Flutter 状态管理相关的文章写了有几十篇了,这是本人也没想到的结果。Flutter 的状态管理插件实在太多,感觉要深挖,可以继续写上几十篇。只是,这样写,怕是自己不累,看客都累了!😂😂😂授人以鱼不如授人以渔,本篇就专门对 Flutter 比...
继续阅读 »

前言

一路下来,Flutter 状态管理相关的文章写了有几十篇了,这是本人也没想到的结果。Flutter 的状态管理插件实在太多,感觉要深挖,可以继续写上几十篇。只是,这样写,怕是自己不累,看客都累了!😂😂😂授人以鱼不如授人以渔,本篇就专门对 Flutter 比较流行的状态管理插件做一个合集并附上对比分析和排行榜。大家可以结合对比数据和官方文档来在实际开发中选择。大家可以在评论区晒出自己用的状态管理插件和选择的理由,互相参考一下!

横向对比参数

我们横向对比以数据说话,综合了 pub 的喜欢数(Likes)、流行度(Popularity)和得分(Pub Points),Gitbub的 Star 数、贡献者数量五个维度进行比对。各个参数说明如下:

  • 喜欢数(Likes):反映的是该插件受 Flutter 开发者的喜好程度,间接反映了插件的文档完整性、可读性和插件的易用性;
  • 流行度(Popularity):反映的是该插件受 Flutter 开发者的欢迎程度和插件应用的广泛性(白话解释:使用人的人越多,意味着有更多的人提前帮你踩坑💣)。
  • 得分(Pub Points):pub 的得分满分是130分,其实是对插件的一个比较基础的全面评测,分为如下6个部分:
    • 遵循插件规范(20分)
    • 文档完整性(20分)
    • 跨平台支持(20分)
    • 通过静态分析(30分)
    • 版本兼容性(20分)
    • 支持 null safety(20分)
  • GitHub Star 数:这个大家都懂,反映的是受开发者认可的程度,实力的象征!
  • 贡献者数量:这个其实就是插件的社区号召力和参与维护的人数,贡献者越多也意味着插件的可靠性越高,不至于说更新过慢或突然中止维护(中止维护属于天坑了💣💣💣)。

为了统一对比尺度,我们统一按与本篇列出的管理插件同维度最大值进行比对,根据比值得出星级,共设置5颗星,比值与星级对应关系如下:

  • 0.9-1.0:5星
  • 0.8-0.9:4星
  • 0.6-0.8:3星
  • 0.3-0.6:2星
  • 0.3以下:1星

状态管理插件对比分析

我们先看对比数据,再来做星级评比,状态管理插件清单的五项数据如下:

插件名称喜欢数流行度(%)得分Star 数贡献者数量
Provider52071001303.9k60
Redux2459711547514
MobX696981202k61
GetX6406991204.9k140
BLoC1215991307.8k135
Event Bus257981305973
GetIt15409913078619
FlutterCommand4372130283
Binder47571201632
StateRebuilder319951203906
Stacked8509711054362
Fish Redux52921007.2k34
flutter_meedu4685130152
Riverpod1039981302k61
flutter_hooks816981301.9k31

各项参数星级评定如下:

插件名称喜欢数流行度(%)得分Star 数贡献者数量
Provider★★★★★★★★★★★★★★★★★★★
Redux★★★★★★★★★
MobX★★★★★★★★★★★★
GetX★★★★★★★★★★★★★★★★★★★★★★★
BLoC★★★★★★★★★★★★★★★★★★★★
Event Bus★★★★★★★★★★
GetIt★★★★★★★★★★
FlutterCommand★★★★★★★★
Binder★★★★★★★
StateRebuilder★★★★★★★★★★
Stacked★★★★★★★★★★★
Fish Redux★★★★★★★★★★★★★
flutter_meedu★★★★★★★★★
Riverpod★★★★★★★★★★★★
flutter_hooks★★★★★★★★★★

排行榜

基于上面的星级评定和数据,我们把每项数据的比值求和,从大大小排序,得到的分值和排行榜如下。

插件名称排名综合评分星级
GetX14.54★★★★★
BLoC24.14★★★★
Provider33.74★★★★
Fish Redux42.86★★★
Riverpod52.83★★★
MobX62.81★★★
flutter_hooks72.57★★
GetIt82.47★★
Stacked92.46★★
Event Bus102.11★★
Redux112.05★★
StateRebuilder122.02★★
flutter_meedu131.87★★
FlutterCommand141.75★★
Binder151.53★★

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

收起阅读 »

更高级的 Android 启动任务调度库

在之前的文章中,我介绍了自研的 Android 启动任务调度工具 AndroidStartup。近期,因为在组件化项目中运用该项目的需要,我对这个库做了一番升级。在最新的 2.2 版本中,我新增了一些特性。相比于目前市面上其他的启动任务调度库,使其具备了更多的...
继续阅读 »

在之前的文章中,我介绍了自研的 Android 启动任务调度工具 AndroidStartup。近期,因为在组件化项目中运用该项目的需要,我对这个库做了一番升级。在最新的 2.2 版本中,我新增了一些特性。相比于目前市面上其他的启动任务调度库,使其具备了更多的优势。这里我只介绍下经过新的版本迭代之后该项目与其他项目的不同点。对于其基础的实现原理,可以参考我之前的文章 《异步、非阻塞式 Android 启动任务调度库》


1、支持多种线程模型


这是相对于 Jetpack 的启动任务库的优势,在指定任务的时候,你可以通过 ISchedulerJobthreadMode() 方法指定该任务执行的线程,当前支持主线程(ThreadMode.MAIN)和非主线程(ThreadMode.BACKGROUND)两种情况。前者在主线程当中执行,后者在线程池当中执行,同时,该库还允许你自定义自己的线程池。关于这块的实现原理可以参考之前的文章或者项目源码。


2、非阻塞的任务调度方式


在之前的文章中也提到了,如果说采用 CountDownLatch 等阻塞的方式来实现任务调度,虽然不会占用主线程的 CPU,但是子线程会被阻塞,一样会导致 CPU 空转,影响程序执行的性能,尤其启动的时候大量任务执行时的情况。所以,在这个库的设计中,我们使用了通知唤醒的方式进行任务调度。也就是,


首先,它会将所有的需要执行的任务收集起来;然后,它会根据任务的依赖关系指定分发和调度任务的子任务;最后,当当前任务执行完毕,该任务会通知所有的子任务按照顺序执行。大致实现逻辑如下,


override fun execute() {
val realJob = {
// 1. Run the task if match given process.
if (matcher.match(job.targetProcesses())) {
job.run(context)
}

// 2. Then sort children task.
children.sortBy { child -> -child.order() }

// 3. No matter the task invoked in current process or not,
// its children will be notified after that.
children.forEach { it.notifyJobFinished(this) }
}

try {
if (job.threadMode() == ThreadMode.MAIN) {
// Cases for main thread.
if (Thread.currentThread() == Looper.getMainLooper().thread) {
realJob.invoke()
} else {
mainThreadHandler.post { realJob.invoke() }
}
} else {
// Cases for background thread.
executor.execute { realJob.invoke() }
}
} catch (e: Throwable) {
throw SchedulerException(e)
}
}

3、非 Class 的依赖方式


之前在本项目中,以及其他的项目中可能采用了基于 Class 的形式进行任务依赖。这种使用方式存在一些问题,即在组件化开发的时候,Class 之间需要直接进行引用。这导致各个组件之间的强耦合。这显然不是我们希望的。


所以,为了更好地支持组件化,在该库的新版本中,我们允许通过 name() 方法执行任务的名称,以及通过 dependencies() 方法指定该任务依赖的其他任务的名称。name() 默认使用任务 Class 的全限定名。这样,当多个组件之间进行相互依赖的时候,只需要通过字符串指定名称而无需引用具体的类。


比如,一个任务在一个组件中定义如下,


@StartupJob class BlockingBackgroundJob : ISchedulerJob {

override fun name(): String = "blocking"

override fun threadMode(): ThreadMode = ThreadMode.BACKGROUND

override fun dependencies(): List<String> = emptyList()

override fun run(context: Context) {
Thread.sleep(5_000L) // 5 seconds
L.d("BlockingBackgroundJob done! ${Thread.currentThread()}")
toast("BlockingBackgroundJob done!")
}
}

在另一个组件中的另一个任务需要依赖上述任务的时候,定义如下,


@StartupJob class SubModuleTask : ISchedulerJob {

override fun dependencies(): List<String> = listOf("blocking")

override fun run(context: Context) {
Log.d("SubModuleTask", "runed ")
}
}

这样我们就实现组件化场景中的依赖关系了。


4、支持任务的优先级


在实际开发中,我们可能会遇到需要为所有的根任务或者一个任务的所有的子任务指定执行的先后顺序的场景。或者在组件化中,存在依赖关系,但是我们希望某个根任务优先执行,但是不想为每个子任务都执行依赖关系的时候,我们可以通过指定这个任务的优先级为最高来使其最先被执行。你可以通过 priority() 方法传递一个 0 到 100 的整数来指定任务的优先级。


@StartupJob class TopPriorityJob : ISchedulerJob{

override fun priority(): Int = 100

override fun run(context: Context) {
L.d("Top level job done!")
}
}

优先级局限于依赖关系相同的任务,所以是依赖关系的补充,不会造成歧义。


5、支持指定任务执行的进程,可自定义进程匹配策略


如果我们的项目支持多进程,而我们希望某些启动任务只在某个进程中执行而其他进程不需要执行,以此避免没必要的任务来提升任务执行的性能的时候,我们可以通过指定任务执行的进程来进行优化。你可以通过 targetProcesses() 传递一个进程的列表来指定该任务执行的所有进程。默认列表为空,表示运行在所有的进程。


对于进程的匹配,我们提供了 IProcessMatcher 这个接口,


interface IProcessMatcher {
fun match(target: List<String>): Boolean
}

你可以通过指定这个接口来自定义线程的匹配策略。


6、支持注解形式的组件化调用


在之前的版本中,通过 ContentProvider 的形式我们一样可以实现所有组件内任务的收集和调用。但是使用 ContentProvider 存在一些不便之处,比如 ContentProvider 的初始化实际在 Application 的 attachBaseContext(),如果我们的任务中一些操作需要放到 Application 的 onCreate() 中执行的时候,通过 ContentProvider 默认装载任务的调度方式就存在问题。而通过基于注解 + APT的形式,我们可以随意指定任务收集、整理和执行的时机,灵活性更好。


为了支持组件化,我们在之前的项目上做了一些拓展。之前的项目虽然也是基于注解发现机制,但是在组件化的应用中存在问题。在新的版本中,我们只是处理了组件化应用场景中的问题,但是使用方式上面完全兼容,只不过你需要为每个组件在 gradle.build 中增加一个行信息来指定组件的名称(就像 ARouter 一样),


javaCompileOptions {
annotationProcessorOptions {
arguments = [STARTUP_MODULE_NAME: project.getName()]
}
}

也就是说你还是通过 @StartupJob 注解将任务标记为启动任务,然后通过


launchStartup(this) {
scanAnnotations()
}

这行代码启动扫描并执行任务。


在新的版本中,所有生产的代码会被统一放到包 me.shouheng.startup.hunter 下面,然后通过 JobHunter$$组件名 的形式为每个组件生成自己的类,然后在扫描任务的时候通过加载这个包名之下的所有的代码来找到所有要执行的任务。如果你对组件化感兴趣可以直接阅读这块的源码实现。


总结


启动任务调度库的设计不算复杂,但是我却在之前的面试中两次被问到如何设计。这种类型的问题能很好地考察代码设计能力。相信阅读这个库的代码之后,此类的问题再也难不倒你。如果你对 APT+注解 的组件化实现方式等感兴趣一样可以阅读这个库的代码。


以上介绍了这个库的一些特性和优势,没用过多地介绍其源码实现,感兴趣的同学可以直接阅读项目的源码,相信你能够从代码中学到一些东西。对于示例项目,除了阅读这个项目的示例,还可以参考 Android-VMLib 这个项目。该项目地址:github.com/Shouheng88/…


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

Flutter 入门与实战(九十三):使用 Animation 构建爱心三连动画

前言 我们开始 Flutter 动画相关篇章之旅,在应用中通过动效能够给用户带来更愉悦的体验,比较典型的例子就是一些直播平台的动效了,比如送火箭能做出来那种火箭发射的动效——感觉倍有面子,当然这是土豪的享受,我等码农只在视频里看过😂😂😂。本篇我们来介绍基于 A...
继续阅读 »

前言


我们开始 Flutter 动画相关篇章之旅,在应用中通过动效能够给用户带来更愉悦的体验,比较典型的例子就是一些直播平台的动效了,比如送火箭能做出来那种火箭发射的动效——感觉倍有面子,当然这是土豪的享受,我等码农只在视频里看过😂😂😂。本篇我们来介绍基于 Animation 类实现的基本动画构建。


Animation 简介


Animation 是一个抽象类,它并不参与屏幕的绘制,而是在设定的事件范围内对一段区间值进行插值。插值的方式可以是线性、曲线、一个阶跃函数或其他能够想到的方式。这个类的对象能够知道当前的值和状态(完成或消失)。Animation 类提供了一个监听回调方法,当它的值改变的时候,就会调用该方法:


@override
void addListener(VoidCallback listener);

因此,在监听回调里,我们可以来刷新界面,通过Animation 对象最新的值控制 UI 组件的位置、尺寸、角度,从而实现动画的效果。Animation 类通常会和 AnimationController 一起使用。


AnimationController 简介


AnimationController 是一个特殊的 Animation 类,它继承自 Animation<double>。每当硬件准备好绘制下一帧时,AnimationController就会产生一个新的值。默认情况下 AnimationController 会在给定的时间范围内产生的值是从0到1.0的线性序列值(通常60个值/秒,以达到60 fps的效果)。例如,下面的代码构建一个时长为2秒的 AnimationController


var controller =
AnimationController(duration: const Duration(seconds: 2), vsync: this);

AnimationController 具有 forwardreverse等控制动画的方法,通常用于控制动画的开始和恢复。


连接 AnimationAnimationController 的是 Animatable类,该类也是一个抽象类, 常用的的实现类包括 Tween<T>(线性插值),CurveTween(曲线插值)。Animatable 类有一个 animate 方法,该方法接收 Animation<double>类参数(通常是 AnimationController),并返回一个 Animation 对象。


Animation<T> animate(Animation<double> parent) {
return _AnimatedEvaluation<T>(parent, this);
}

animate方法使用给定的 Animation<double>对象驱动完成动效,但使用的值的范围是自身的值,从而可以构建自定义值范围的动效。比如,要构建一个2秒内从0增长100的动效值,可以使用如下的代码。


var controller =
AnimationController(duration: const Duration(seconds: 2), vsync: this);
var animation = Tween<double>(begin: 0, end: 100).animate(controller);

应用 - 爱心三连


有了上面的基础,我们就可以开始牛刀小试了,我们先来一个爱心三连放大缩小的动效,如下所示,首次点击逐渐放大,再次点击逐渐缩小恢复到原样。


爱心三连.gif
界面代码很简单,三个爱心其实就是使用Stack 将三个不同颜色的爱心 Icon 组件叠加在一起,然后通过 Animtion对象的值改变 Icon 的大小。在 Animation 值变化的监听回调李使用 setState 刷新界面就好了。完整代码如下:


import 'package:flutter/material.dart';

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

@override
_AnimtionDemoState createState() => _AnimtionDemoState();
}

class _AnimtionDemoState extends State<AnimtionDemo>
with SingleTickerProviderStateMixin {
late Animation<double> animation;
late AnimationController controller;

@override
void initState() {
super.initState();
controller =
AnimationController(duration: const Duration(seconds: 1), vsync: this);
animation = Tween<double>(begin: 40, end: 100).animate(controller)
..addListener(() {
setState(() {});
});
controller.addStatusListener((status) {
print(status);
});
}

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Animation 动画'),
),
body: Center(
child: Stack(
alignment: Alignment.center,
children: [
Icon(
Icons.favorite,
color: Colors.red[100],
size: animation.value * 1.5,
),
Icon(
Icons.favorite,
color: Colors.red[400],
size: animation.value,
),
Icon(
Icons.favorite,
color: Colors.red[600],
size: animation.value / 2,
),
],
),
),
floatingActionButton: FloatingActionButton(
child: Icon(Icons.play_arrow, color: Colors.white),
onPressed: () {
if (controller.status == AnimationStatus.completed) {
controller.reverse();
} else {
controller.forward();
}
},
),
);
}

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

这里需要提的是在_AnimtionDemoState中混入了SingleTickerProviderStateMixin,这里其实是为 AnimationController 提供了一个 TickerProivder 对象。TickerProivder对象会在每一帧刷新前触发一个 onTick回调,从而实现AnimationController的值更新。


总结


本篇介绍了Flutter 动画构建类 AnimationAnimationController 的使用,通过这两个类可以实现很多基础动画效果,例如常见的进度条、缩放、旋转、移动等。接下来我们还将介绍基于 Animation 实现动画的其他方式和其他类型的动画效果。


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

RxHttp + Flow 三步搞定任意请求

1、前言 RxHttp 在之前的版本中,已提供了RxHttp + Await协程、RxHttp + RxJava两种请求方式,这一次,RxHttp 无缝适配了 Flow , RxHttp + Flow协程配合使用,使得请求更加简单,至此,RxHttp已集齐3架...
继续阅读 »

1、前言


RxHttp 在之前的版本中,已提供了RxHttp + Await协程RxHttp + RxJava两种请求方式,这一次,RxHttp 无缝适配了 Flow , RxHttp + Flow协程配合使用,使得请求更加简单,至此,RxHttp已集齐3架马车(Flow、Await、RxJava),且每架马车皆遵循请求三部曲,掌握请求三部曲,就掌握了RxHttp的精髓。


gradle依赖


1、必选


jitpack添加到项目的build.gradle文件中,如下:


allprojects {
repositories {
maven { url "https://jitpack.io" }
}
}

//使用kapt依赖rxhttp-compiler时必须
apply plugin: 'kotlin-kapt'

android {
//必须,java 8或更高
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
}

dependencies {
implementation 'com.squareup.okhttp3:okhttp:4.9.1'
implementation 'com.github.liujingxing.rxhttp:rxhttp:2.7.0'
kapt 'com.github.liujingxing.rxhttp:rxhttp-compiler:2.7.0' //生成RxHttp类,纯Java项目,请使用annotationProcessor代替kapt
}

2、可选


//非必须,根据自己需求选择 RxHttp默认内置了GsonConverter
implementation 'com.github.liujingxing.rxhttp:converter-fastjson:2.7.0'
implementation 'com.github.liujingxing.rxhttp:converter-jackson:2.7.0'
implementation 'com.github.liujingxing.rxhttp:converter-moshi:2.7.0'
implementation 'com.github.liujingxing.rxhttp:converter-protobuf:2.7.0'
implementation 'com.github.liujingxing.rxhttp:converter-simplexml:2.7.0'

2、RxHttp + Flow 使用


2.1、请求三部曲


用过RxHttp的同学知道,RxHttp发送任意请求皆遵循请求三部曲,如下:
rxhttp_flow_chart.jpg
代码表示


 RxHttp.get("/service/...")  //第一步,确定请求方式,可以选择postForm、postJson等方法
.add("key", "value")
.toFlow<Student>() //第二步,调用toFlow方法并输入泛型类型,拿到Flow对象
.catch {
//异常回调
val throwable = it
}.collect { //第三步,调用collect方法发起请求
//成功回调
val student = it
}

协程请求三部曲详解




  • 第一步,选择get、postForm、postJson、postBody等方法来确定请求方式,随后便可通过add、addFile、addHeader等方法来添加参数、文件、请求头等信息




  • 第二步,调用toFlow/toFlowXxx系列方法,并传入泛型类型,以获取到Flow对象,toFlow有一系列重载方法,可以实现上传/下载及进度的监听,本文后续会详细介绍,在这一步后,可以调用catchonStartonCompletion等方法去监听异常、开始及结束回调,跟平时使用Flow对象没有任何区别




  • 第三步,调用collect方法就会开始发送请求,如果一些正常的话,就会收到成功回调




以上就是RxHttp在协程中最常规的操作,掌握请求三部曲,就掌握了RxHttp的精髓


2.2、BaseUrl处理


RxHttp通过@DefaultDomain、@Domain注解来配置默认域名及非默认域名,如下:


public class Url {

@DefaultDomain //通过该注解设置默认域名
public static String BASE_URL = "https://www.wanandroid.com";

// name 参数在这会生成 setDomainToGoogleIfAbsent方法,可随意指定名称
// className 参数在这会生成RxGoogleHttp类,可随意指定名称
@Domain(name = "Google", className = "Google")
public static String GOOGLE = "https://www.google.com";
}

以上配置http://www.wanandroid.com为默认域名,http://www.google.com为非默认域名


多BaseUrl处理


//1、使用默认域名,传入相对路径即可
//此时 url 为 https://www.wanandroid.com/service/...
RxHttp.get("/service/...")
...

//2、使用google域名方式一:传入绝对路径
RxHttp.get("https://wwww.google.com/service/...")
...

//3、使用google域名方式二:调用setDomainToGoogleIfAbsent方法
//该方法是通过 @Domain 注解的 name 字段生成的,命名规则为 setDomainTo{name}IfAbsent
RxHttp.get("/service/...")
.setDomainToGoogleIfAbsent()
...

//4、使用google域名方式三:直接使用RxGoogleHttp类发送请求,
//该类是通过 @Domain 注解的 className 字段生成的,命名规则为 Rx{className}http
RxGoogleHttp.get("/service/...")
...

注:以上4种配置域名的方式,优先级别为:2 > 3 > 4 > 1


动态域名处理


//直接对url重新赋值即可,改完立即生效
Url.BASE_URL = "https://www.baidu.com";
RxHttp.get("/service/...")
...
//此时 url 为 https://www.baidu.com/service/...

2.3、业务code统一判断


我想大部分人的接口返回格式都是这样的


class BaseResponse<T> {
var code = 0
var msg : String? = null
var data : T
}

拿到该对象的第一步就是对code做判断,如果code != 200(假设200代表数据正确),就会拿到msg字段给用户一些错误提示,如果等于200,就拿到data字段去更新UI,常规的操作是这样的


RxHttp.get("/service/...")
.toFlow<BaseResponse<Student>>()
.collect {
if (response.code == 200) {
//拿到data字段(Student)刷新UI
} else {
val msg = it.msg //拿到msg字段给出错误提示
}
}

试想一下,一个项目少说也有30+个这样的接口,如果每个接口读取这么判断,就显得不够优雅,也可以说是灾难,相信也没有人会这么干。而且对于UI来说,只需要data字段即可,错误提示啥的我管不着。


那有没有什么办法,能直接拿到data字段,并且对code做出统一判断呢?有的,直接上代码


RxHttp.get("/service/...")
.toFlowResponse<Student>() //调用此方法,直接拿到data字段,也就是Student对象
.catch {
// code非200时,走异常回调,在这可拿到msg及code字段
val msg = it.msg
val code = it.code
}.collect {
//直接拿到data字段,在这就是Student对象
val student = it
}

可以看到,以上调用toFlowResponse()方法,成功回调就可直接拿到data字段,也就是Student对象。


此时,相信很多人会有疑问,




  • 业务code哪里判断的?




  • 异常回调里的it是什么对象,为啥可以拿到msg、code字段?




先来回答第一个问题,业务code哪里判断的?


其实toFlowResponse()方法并不是RxHttp内部提供的,而是通过自定义解析器,并用@Parser注解标注,最后由注解处理器rxhttp-compiler自动生成的,听不懂?没关系,直接看代码


@Parser(name = "Response")
open class ResponseParser<T> : TypeParser<T> {

//以下两个构造方法是必须的
protected constructor() : super()
constructor(type: Type) : super(type)

@Throws(IOException::class)
override fun onParse(response: okhttp3.Response): T {
val data: BaseResponse<T> = response.convertTo(BaseResponse::class, *types)
val t = data.data //获取data字段
if (data.code != 200 || t == null) { //code不等于200,说明数据不正确,抛出异常
throw ParseException(data.code.toString(), data.msg, response)
}
return t //最后返回data字段
}
}

上面代码只需要关注两点即可,


第一点,我们在类开头使用了@Parser注解,并为解析器取名为Response,此时rxhttp-compiler就会生成toFlowResponse<T>()方法,命名规则为toFlow{name}


第二点,我们在if语句里,code != 200data == null时,就抛出ParseException异常,并带上了msg、code字段,所以我们在异常回调通过强转,就可以拿到这两个字段


接着回答第二个问题,异常回调里的it是什么对象,为啥可以拿到msg、code字段?


其实it就是Throwable对象,而msg、codeThrowable的扩展字段,这需要我们自己为其扩展,代码如下:


val Throwable.code: Int
get() =
when (this) {
is HttpStatusCodeException -> this.statusCode //Http状态码异常
is ParseException -> this.errorCode.toIntOrNull() ?: -1 //业务code异常
else -> -1
}

val Throwable.msg: String
get() {
return if (this is UnknownHostException) { //网络异常
"当前无网络,请检查你的网络设置"
} else if (
this is SocketTimeoutException //okhttp全局设置超时
|| this is TimeoutException //rxjava中的timeout方法超时
|| this is TimeoutCancellationException //协程超时
) {
"连接超时,请稍后再试"
} else if (this is ConnectException) {
"网络不给力,请稍候重试!"
} else if (this is HttpStatusCodeException) { //请求失败异常
"Http状态码异常"
} else if (this is JsonSyntaxException) { //请求成功,但Json语法异常,导致解析失败
"数据解析失败,请检查数据是否正确"
} else if (this is ParseException) { // ParseException异常表明请求成功,但是数据不正确
this.message ?: errorCode //msg为空,显示code
} else {
"请求失败,请稍后再试"
}
}

到这,业务code统一判断就介绍完毕,上面的代码,大部分人只需要简单修改后,就可用到自己的项目上,如ResponseParser解析器,只需要改下if语句的判断条件即可


3、上传/下载


RxHttp对文件的优雅操作是与生俱来的,配合Flow,可以说是如虎添翼,不多说,直接上代码


3.1、文件上传


RxHttp.postForm("/service/...")  
.addFile("file", File("xxx/1.png")) //添加单个文件
.addFiles("fileList", ArrayList<File>()) //添加多个文件
.toFlow<String>()
.catch { //异常回调 }
.collect { //成功回调 }

只需要通过addFile系列方法添加File对象即可,就是这么简单粗暴,想监听上传进度,toFlow方法传入进度回调即可,如下:


RxHttp.postForm("/service/...")      
.addFile("file", File("xxx/1.png"))
.addFiles("fileList", ArrayList<File>())
.toFlow<String> { //这里还可以选择自定义解析器对应的toFlowXxx方法
val process = it.progress //已上传进度 0-100
val currentSize = it.currentSize //已上传size,单位:byte
val totalSize = it.totalSize //要上传的总size 单位:byte
}.catch { //异常回调 }
.collect { //成功回调 }

3.2、文件下载


接着再来看看下载,直接贴代码


val localPath = "sdcard//android/data/..../1.apk"  
RxHttp.get("/service/...")
.toFlow(localPath) {
//it为Progress对象
val process = it.progress //已下载进度 0-100
val currentSize = it.currentSize //已下载size,单位:byte
val totalSize = it.totalSize //要下载的总size 单位:byte
}
.catch { //异常回调 }
.collect { //成功回调,这里可以拿到本地存储路径,也就是localPath }

你没看错,下载也是调用 toFlow方法,传入本地路径及进度回调即可,当然,如果不需要监听进度,进度回调也可不传,来看看用来下载的toFlow方法签名


/**
* @param destPath 本地存储路径
* @param append 是否追加下载,即是否断点下载
* @param capacity 队列size,仅监听进度回调时生效
* @param progress 进度回调
*/
fun CallFactory.toFlow(
destPath: String,
append: Boolean = false,
capacity: Int = 1,
progress: (suspend (Progress) -> Unit)? = null
): Flow<String>

以上4个参数,只有destPath是必须的,其它3个参数,根据实际需要传递,想要断点下载,append传入true,想要监听进度就传入进度回调,


至于capacity参数,这个需要额外说明一下,它是指定队列的缓存大小,什么队列?进度回调的队列,目的就是丢弃来不及消费的事件,在现实场景中,可能会存在下游消费速度 小于 上游生产速度的情况,这就会导致事件的堆积,翻译过来就是下载很快,但你处理进度回调的地方很慢,就有可能出现你还在处理进度为10的事件,但实际下载进度可能到了50甚至更高,capacity设置为1的话,10-50之间的事件就会被丢弃,接下来下游收到的可能就是进度为50的事件,这就保证了下游收到的始终的最新的事件,也就是最及时的下载进度,当然,如果你想收到全部的进度回调事件,将capacity设置为100即可。


3.3、暂停/恢复下载


很多会有暂停/恢复下载的需求,但对于下载来说,并没有真正意义的暂停及恢复,所谓的暂停,不过就是停止下载,也就是中断请求,而恢复,就是再次发起请求从上次中断的位置继续下载,也就是断点下载,所有,只需要知道如何取消请求及断点下载即可


取消请求


Flow的取消,就是外部协程的关闭


val job = lifecycleScope.launch {
val localPath = "sdcard//android/data/..../1.apk"
RxHttp.get("/service/...")
.toFlow(localPath) {
//it为Progress对象
val process = it.progress //已下载进度 0-100
val currentSize = it.currentSize //已下载size,单位:byte
val totalSize = it.totalSize //要下载的总size 单位:byte
}
.catch { //异常回调 }
.collect { //成功回调,这里可以拿到本地存储路径,也就是localPath }
}
//在需要的时候,调用job.cancel()就是取消请求
job.cancel()

断点下载


上面介绍过,想要断点下载,只需要额外将toFlow方法的第二个参数append设置为true即可,如下:


val localPath = "sdcard//android/data/..../1.apk"  
RxHttp.get("/service/...")
.toFlow(localPath, true) {
//it为Progress对象
val process = it.progress //已下载进度 0-100
val currentSize = it.currentSize //已下载size,单位:byte
val totalSize = it.totalSize //要下载的总size 单位:byte
}
.catch { //异常回调 }
.collect { //成功回调,这里可以拿到本地存储路径,也就是localPath }

注:断点下载需要服务器接口支持


对于Android 10文件上传/下载,请点击RxHttp 完美适配Android 10/11 上传/下载/进度监听


4、转LiveData 


Flow依赖于协程环境,如果不想使用协程,又想要使用Flow,那LiveData就是一个很好的选择,在官方androidx.lifecycle:lifecycle-livedata-ktx:x.x.x库中提供了asLiveData方法,可方便的将FlowLiveData对象,有了LiveData对象,就不再需要协程环境


4.1、普通请求转LiveData


//当前在FragmentActivity环境中
RxHttp.get("/service/...")
.toFlow<Student>()
.catch { }
.asLiveData()
.observe(this) {
val student = it;
//更新UI
}

由于调用了asLiveData,所以,以上代码,不需要协程环境也可执行;


4.2、带进度上传转LiveData


RxHttp.postForm("/service/...")      
.addFile("file", File("xxx/1.png"))
.addFiles("fileList", ArrayList<File>())
.toFlow<Student> { //这里还可以选择自定义解析器对应的toFlowXxx方法
val process = it.progress //已上传进度 0-100
val currentSize = it.currentSize //已上传size,单位:byte
val totalSize = it.totalSize //要上传的总size 单位:byte
}
.catch { //异常回调 }
.asLiveData()
.observe(this) {
val student = it;
//更新UI
}

上面代码中,转LiveData后,下游observe只能收到上传完成的回调,如果你想收到包括进度回调在内的所有事件,则需要使用toFlowProgress替代toFlow方法(toFlow内部是通过toFlowProgress方法实现的,有兴趣的自己查看源码),如下:


RxHttp.postForm("/service/...")      
.addFile("file", File("xxx/1.png"))
.addFiles("fileList", ArrayList<File>())
.toFlowProgress<Student>() //该方法没有进度回调参数
.catch { //异常回调 }
.asLiveData()
.observe(this) {
//此时这里将收到所有事件,这里的it为ProgressT<Student>对象
val process = it.progress //已上传进度 0-100
val currentSize = it.currentSize //已上传size,单位:byte
val totalSize = it.totalSize //要上传的总size 单位:byte
val student = it.result //接口返回的对象
if (student != null) {
//不为null,代表上传完成,接口请求结束
}
}

4.3、带进度下载转LiveData


下载也一样,RxHttp提供了一个下载对应的toFlowProgress方法,如下:


fun CallFactory.toFlowProgress(
destPath: String,
append: Boolean = false,
capacity: Int = 1
): Flow<ProgressT<String>>

跟上面介绍下载时对应的toFlow方法相比,少了一个进度回调的参数,这里悄悄告诉你,下载的toFlow方法,内部就是通过toFlowProgress方法实现的,想了解的自己去查看源码,这里不做介绍


结合asLiveData方法,使用如下:


val localPath = "sdcard//android/data/..../1.apk"  
RxHttp.get("/service/...")
.toFlowProgress(localPath)
.catch { //异常回调 }
.asLiveData()
.observe(this) {
//此时这里将收到所有事件,这里的it为ProgressT<String>对象
val process = it.progress //已下载进度 0-100
val currentSize = it.currentSize //已下载size,单位:byte
val totalSize = it.totalSize //要下载的总size 单位:byte
val path = it.result //本地存储路径
if (path != null) {
//不为null,代表下载完成,接口请求结束
}
}

5、小结


看完本文,相信你已经领悟到了RxHttp的优雅,不管上传/下载,还是进度的监听,通通三步搞懂,掌握请求三部曲,就掌握了RxHttp的精髓。


其实,RxHttp远不止这些,本文只介绍了RxHttp + Flow的配合使用,更多功能,如:公共参数/请求头的添加、请求加解密、缓存等等,请查看


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

通过命令行玩转Git,需要记住那些命令?

Git 简介 什么是 Git ? Git 是目前世界上最先进的分布式版本控制系统!!!什么?啥意思?不懂,没关系,你只要记住,它很重要,非常重要,程序猿的必备技能即可。 Git 的命令非常非常多,这里强调一下,不要傻傻的去背这些命令,没啥卵用,有些命令可能你这...
继续阅读 »

Git 简介


什么是 Git ? Git 是目前世界上最先进的分布式版本控制系统!!!什么?啥意思?不懂,没关系,你只要记住,它很重要,非常重要,程序猿的必备技能即可。


Git 的命令非常非常多,这里强调一下,不要傻傻的去背这些命令,没啥卵用,有些命令可能你这辈子你都未必使得上。


本章的目的是教你如何通过命令行完成 Git 的日常基操,并会适当介绍命令的意义,加深你的理解。


在介绍这些命令之前,我们先来看一张灰常重要的图:


image.png


图中有四个空间,是 Git 工作流程的精髓所在,分别是:



  • Remote: 远程仓库,即你在 Github 或者 Gitee 等平台上创建的项目仓库;

  • Repository: 本地仓库,你可以认为就是我们拉取项目后生成的 .git 文件夹;

  • Index: 暂存区,事实上它只是一个文件,即 .git 文件夹里面的 index 文件,它保存即将提交到本地仓库的文件列表信息;

  • workspace:  工作区,即你在 VS code 或者 WebStorm 编译器正在编写的代码。


Git 基本命令手册


1.克隆/拉取项目


拉取项目是开始搬砖的第一步,一般创建好项目远程仓库,你就能获取到一个 .git 结尾的地址,或者这个地址可能由公司同事给到你,之后你随便找个目录,通过 Git Bash 输入下面命令即可拉取到项目到本地。


git clone 你的项目地址.git

2.查看远程分支


一般拉取完项目后,我们是处于 master/main 主分支,不要着急就去编写代码,先要查看一下项目的远程分支情况,根据要求看是否需要切换到某个分支进行特定开发。


git branch -r

3.查看本地分支


一般本地创建的分支情况应该是和远程分支一一对应,当然,在未进行发布或者和远程分支建立关联,本地分支并不会影响到远程分支,你可以随意创建、删除和修改。


git branch

4.创建本地分支


git branch dev(分支名称, 可以随便取)

通过上面的命令,你会创建好一个本地分支,当然,该分支是基于你当前所在分支创建的。你可以再次敲命令去查看远程分支和本地分支各自的情况(多敲,很快命令就记住了-^〇^-)。


5.删除本地分支


对远程仓库分支并不会有任何影响


git branch -d dev

6.切换分支


创建好本地分支,我们就可以切换到该分支上。


git checkout dev


创建和切换两个操作也可以一起做: git checkout -b xxx (创建并切换到该本地分支)



7.发布本地分支到远程分支


当我们创建好一个本地分支的时候,这个时候还是不要着急去开始编码,我们要把该分支发布到远程仓库去,让远程仓库也拥有该分支,且让它和你本地分支进行关联,方便我们后续直接进行 git pull 或者 git push 操作。


git push origin dev

发布完本地分支后,你可以同样通过 git branch -r 去查看你远程仓库的分支列表是否多了新的分支。


8.建立本地分支与远程分支的关联


本地分支与远程分支关联这步不是必须,但后续就能很方便的直接使用 git pull 或者 git push 获取或提交该分支的代码,而不用去指定分支。


git push --set-upstream origin dev

如果你不关联两者分支的关系,强行去使用,你可能会遇到图中的情况,大致意思就是让你指定目标分支或者去关联分支情况再进行操作。(Git 的提示信息还是很友好的)


image.png


9.添加文件进暂存区


完成上面的几步,我们就能开始搬砖了。在对代码更改后,要把提交代码到远程仓库,我们就要先把代码添加到暂存区,之后提交到本地仓库,最后才能提交到远程仓库。



  • 把工作区某个文件添加进暂存区,比如 src/main.js 文件,则 git add src/main.js


git add xxx(文件路径)
# 多个
git add xxx(文件路径) xxx(文件路径) xxx(文件路径) ...


  • 把工作区更改的所有文件都添加进暂存区


git add .


这里你可能会在想,为什么要先添加到暂存区,再本地仓库之后才提交到远程仓库呢? 如果你是从 SVN 转过来,可能会稍微有点不了解暂存区,这里涉及暂存区的意义,网上有很多解释,这里就不做过多的解释了。

但你可以简单的怎么理解,假如你在开发中开发了用户的添加功能和文章的添加功能,两个功能都同时开发完了,因为都互不影响,完全独立,你想分成两次提交分别写上对应的 commit 信息说明,这个时候就可以使用到暂存区,先将用户添加功能添加到暂存区,然后 commit 到本地仓库,再进行文章添加功能的提交,最后在一起 push 到远程仓库即可。



10.删除文件出暂存区


当你误把文件添加进暂存区,也不要慌,有添加,就肯定有删除。



  • 把工作区某个文件删除出暂存区


git rm --cached xxx(文件路径)


  • 清空暂存区,暂存区实质是 .git 目录下的 index 文件,将此文件删除,那么就认为暂存区被清空。


rm .git/index


当然,这只是把暂存区中跟踪的文件移除而已,不会改动原文件的内容,原先更改的内容还在。



11.查看工作区与暂存区状态


这个命令用于查看工作区和暂存区的状态,能看到哪些文件被修改了,它修改后是否被暂存了,又或者还没有暂存。这个暂存的过程,专业的叫法是 Git tracked,也就是是否被跟踪。


git status

通过下图,我们能查看到所有改动过的文件,绿色的文件是已经添加进暂存区的,红色的文件则是未添加到暂存区的,而且每个文件前都有对应的操作说明,如果是新文件则是 new file,修改的文件则是 modified,删除的是 deleted, 如果是未添加进暂存区的新文件,则没有。


image.png


12.提交暂存区文件到本地仓库


git commit -m "说明信息"

通过上面的命令,我们就将暂存区的文件提交到本地仓库了,我们可以通过 git status 再次查看暂存区的情况。


image.png


Git 的提示真的是非常友好的。


13.查看提交记录


这个命令可以显示提交过的记录。


git log

image.png


进入提交记录日志列表后,可以按 q 退出。



上面的命令会显示所有的 commit 记录,如果你想要显示最近几条记录,你可以通过 git log -n(n为数字, 可以随意指定) 命令完成。



14.提交本地仓库文件到远程仓库


git push

执行这条命令的前提是进行过第7步骤才行哦。


把本地仓库文件提交到远程仓库后,我们再次查看提交日志。


image.png


15.拉取新代码


git pull

这个命令同样也是建立在第7步骤之前的。


Git 进阶命令手册


记住上面15个命令,Git 的日常基操基本也满足了,当然现在各种编辑器功能强大,基本都集成了 Git 的可视化操作,不用命令行来操作,也完全没有问题。


但是程序猿使用命令行不是一件很酷的事情吗?不为别的,只为装13也是可以搞一搞的,哈哈哈。下面再来讲一些命令,虽然使用频率不高,但是也是很重要的。


1.查看全局的Git配置


git config --global -l
# or
git config --global --list

2.查看全局用户名及邮箱


git config --global user.name
git config --global user.email

3.设置全局用户名及邮箱


git config --global user.name "你自己的用户名"
git config --global user.email "你自己的邮箱"

4.查看局部项目用户名及邮箱


要在当前项目根目录下执行。


git config user.name
git config user.email

5.设置局部项目用户名及邮箱


要在当前项目根目录下执行。


git config user.name "你自己的用户名"
git config user.email "你自己的邮箱"

6.删除远程仓库分支


这个命令对于一些未设有保护的分支来说,是挺危险的操作,要慎重执行。


git push --delete origin dev(分支名称)

7.修改远程仓库分支名称


修改远程仓库分支的名称过程是:先修改本地分支名称 - 删除远程分支 - 重新发布本地分支到远程仓库


# 修改本地分支名称
git branch -m 旧分支名称 新分支名称
# 删除远程分支
git push --delete origin 分支名称
# 发布新分支
git push origin 新分支名称
# 重新建立关联
git push --set-upstream origin 新分支名称

8.合并分支


合并分支是一个比较常见的操作了,当你在一个分支开发完新功能后,很多时候这些分支最终都要合并到 master/main 这个主分支上,这个主分支拥有所有分支的代码,并且它是稳定且在生产环境上跑的,所以在你确定要将分支合并到主分支上之前,一定要确保这个分支代码是没有问题。


# 切换到稳定的目标分支
git checkout master
# 更新最新代码,防止本地仓库对应分支代码不够新而出现问题
git pull
# 合并分支到本地仓库
git merge dev
# 发布合并后的新代码到远程分支
git push

合并后,你可以通过 git log 去查看是否有相关的 commit 记录。


9.将A分支直接合并到B分支


git merge xxx(A分支名称) xxx(B分支名称)

10.合并单个commit


有时候我们只想合并某一个 commit 来满足一些特定需要,这也是可以做到的。


# 切换到稳定的目标分支
git checkout master
# 合并某个commit到本地仓库
git cherry-pick xxx(commitId)
# 发布合并后的新代码到远程分支
git push

commitId 是一个 commit 的唯一标识,你可以通过 git log 来找到它,它是一串很长不重复的字符。


image.png


当然,你也可以去到 Github 或者 Gitee 平台对应项目里面找。



合并单个 commit 你可能会遇到冲突的情况,如:在我执行合并某一个 commit 给我报了一个错,大致意思就是代码冲突了,需要去调整后,才能提交。


image.png


代码冲突是件非常蛋疼的事情,不仅仅合并的时候会发生冲突,比较频繁发生的场景是多人共同开发的时候,因为两人负责同个项目,就有极大的可能会改到相同的文件,相同的代码,这就会造成冲突。一般这个时候 Git 会阻止你的提交,让你先去整改,这时候就需要你非常谨慎细心的去处理,因为一旦粗心,就极有可能干掉同伴的代码或者自己的代码,这会造成很严重的后果。(不要问我怎么知道的,都是血的教训︶︿︶)


解决代码冲突一般我会借助编辑器等工具来完成,就不通过命令行操作了,这样比较方便,不容易出错。我使用的是 VS code 编辑器,在编辑器内冲突的文件一般都会标红,点开文件,会发现里面会有写 <<<< 的符号,被符号包围的内容就是冲突的地方。


image.png


编辑器一般会提供四个选项帮助你快速操作,当然你也可以手动删除修改。



  • Accept Current Change:接收当前更改,也就是冲突部分以旧的为准。

  • Accept Incoming Change:接受传入的更改,也就是冲突部分以新的为准。

  • Accept Both Changes:接受两个更改,也就是新旧都存在,可能会出现重复。

  • Compare Changes:比较变化,会分成两个文件,让你更直观的查看两者的冲突内容。


当你解决完冲突后,你可以保存文件,再执行以下操作,提交代码。


# 添加更改进入暂存区
git add .
# 提交暂存区到本地仓库
git commit -m ""
# 提交本地仓库commt到远程仓库
git push


11.撤销最近的一个commit


git reset --soft HEAD^

这个命令用于撤销本地仓库最近的一个 commit ,撤销后会回到暂存区中,通过 git log 可以查看 commit 记录会减少,但不影响远程仓库。


还有另一个相似的撤销 commit 命令,但它比较危险,撤销后的内容是直接就删除的,不会回到暂存区,要慎重使用!!!


git reset --hard HEAD^

12.查看简洁提交记录


这个命令可以让你更加直观的查看你需要的信息。


git log --pretty=oneline

image.png



其实这些信息都存放在 .git 文件夹中,在 .git\logs\refs\heads 下记录了所有分支的 commit 版本号。



13.备份工作区内容stash


这是一个神奇的命令,特别对项目分支比较多的情况,是非常有用的,他的使用场景大致是:当你正在A分支中开发功能,你临时接到一个紧急的需求,需要优先切换到B分支中去开发,这个时候A分支中已经改动的代码要怎么办呢?这时就能使用 stash 来备份代码了。


git stash

执行命令后,当前工作区会回到最近的一次 commit 状态,并将更改的代码保存在 Git 栈中,这样你就能先切换到B分支中去开发,等B分支功能开发完成,再切换回A分支,通过下面的命令取回原来的更改的代码,继续进行A分支的功能开发了。


git stash pop

是不是非常的 nice!!!


14.查看备份列表或清空备份列表



  • 显示Git栈内的所有备份


git stash list


  • 清空Git栈


git stash clear

15.查看远程仓库地址


git remote -v

image.png


16.更改远程仓库地址


git remote set-url origin xxx(新仓库地址)

更改远程仓库地址有另外的两种方式


# 删除项目的远程仓库地址
git remote rm origin
# 添加项目的远程仓库地址
git remote add origin xxx(新仓库地址)

或者去改配置文件


17.比较工作区和暂存区的所有差异


git diff

这个命令是对比工作区和暂存区的所有差异,但个人感觉不太直观,也可能只是比较少用吧,关于对比我更愿意借助编辑器工具来查看。


image.png


对比工作区单个文件和暂存区的区别:


git diff xxx(文件地址)

18.将工作区中的文件还原成与暂存区的一致


git checkout xxx(文件地址)
# 批量还原
git checkout xxx(文件地址) xxx(文件地址) xxx(文件地址) ...


收起阅读 »

优雅的命名

前言 优秀的代码往往是最通俗易懂的代码,在于它的易于维护。在开发过程中,变量/方法优秀的命名往往有助于理解该变量/方法的用途,起到命名即注释的作用。而糟糕的命名往往会让人摸不着头脑。为了提高代码的可维护性,我们需要更优雅的命名方式。 一、通用规则 1. 有意义...
继续阅读 »

前言


优秀的代码往往是最通俗易懂的代码,在于它的易于维护。在开发过程中,变量/方法优秀的命名往往有助于理解该变量/方法的用途,起到命名即注释的作用。而糟糕的命名往往会让人摸不着头脑。为了提高代码的可维护性,我们需要更优雅的命名方式。


一、通用规则


1. 有意义


起一个有意义的变量名这条相信绝大多数开发者都能做到,即变量名有实际的指代意义,在此不再赘述。


2. 指代具体


命名时需要使其更加具体详尽,可以具体到所在的模块,或者能表达出其逻辑/功能。


/* bad */
.title {}
/* good */
.head-title {}

// bad
const info;
// good
const userInfo;

3. 遵循传统


无论是CSS、JavaScript、还是文件的命名,都有一些约定俗成的惯例和规范,我们只需遵循即可。


4. 约定规范


命名的规则有很多种,没有高低之分,只有相对合适,没有绝对完美的规则。通常为了维持项目的风格统一,通常在一个项目中,相同种类的规则只选取一种。毕竟规范也只是一种工具,运用规范的根本目的是为了更好的开发和维护,太过复杂的规范反而会阻碍正常开发。因之,在项目启动前,在技术栈选取后就应当进行规范的约定,这个过程是团队意见的整合,毕竟规范是要靠团队成员的相互配合。


二、CSS 中的命名


1. 划分原则


CSS中的类名根据其职责可以分为公共类名和自定义类名。其中公共类名又可以分为常见类名(一般是约定俗成)和工具类名。


2. 常见类名


下面整理了一些常见的 css类名 供大家参考:








收起阅读 »

❤️谈谈grid布局(细读必有收获)

grid布局的理念是把网页划分成一个一个网格组合成不同样式的布局,再通过对网格进行内容填充,组成一个网页。通过一下这个案例了解grid的基本概念👇👇 经典九宫格布局: 🚨关键点🚨: 容器: 需通过display:grid设置为grid容器,容器中包含所有i...
继续阅读 »

grid布局的理念是把网页划分成一个一个网格组合成不同样式的布局,再通过对网格进行内容填充,组成一个网页。通过一下这个案例了解grid的基本概念👇👇


经典九宫格布局:
image.png



🚨关键点🚨:

容器: 需通过display:grid设置为grid容器,容器中包含所有item

行: 横向为行,对应颜色块123

行距: 上下两个item的间距为行距

列: 纵向为列,对应颜色块147

列距: 左右两个item的间距为列距

item(子元素): 也就是上图对应的123456789颜色块

边: 每个itme共有 上 下 左 右 四条边



1.1 display


display属性规定是否/如何显示元素。我们需要使用grid布局,就要把容器设置为grid或者inline-grid

grid 设置为块级元素的grid布局

inline-grid 设置为行内元素的grid布局

区别如下:


image.png
代码案例
在线代码入口:👉👉(点击传送)


.grid_container {
display:grid;
/* display:inline-grid; */
grid-template-columns: 100px 100px 100px;
grid-template-rows: 100px 100px 100px;
}

1.2 grid-template-columnsgrid-template-rows


grid-template-columns属性用来定义grid布局的每一列列宽

grid-template-rows属性用来定义grid布局的每一行行高

代码案例1:在线代码入口👉👉(点击传送)

定义一个三行三列,每列列宽100px,每行行高100px


.grid_container {
display:grid;
grid-template-columns: 100px 100px 100px;
grid-template-rows: 100px 100px 100px;
}

代码案例2:在线代码入口👉👉(点击传送)

当拥有很多行和列的时候,普通的写法根本不实在,所以现在引入一个函数repeat()

repeat()函数可设置重复的值,或者重复的一个模式,还是以三行三列100px为例:


.grid_container {
display:grid;
/* 重复一个值 */
grid-template-columns: repeat(3,100px);
grid-template-rows: repeat(3,100px);
/* 重复一个模式
grid-template-columns: repeat(3,100px 50px);
grid-template-rows: repeat(3,100px 50px);
*/

}

代码案例3:在线代码入口👉👉(点击传送)

这里以圣杯布局为例:左右固定,中间自适应。在这种情况下固定的列宽或行高已经不能满足实现圣杯布局了,所以这个例子引入两个关键字autofr

auto:自适应属性

fr:fraction 的缩写,意为"片段",可以看做百分比属性,通过以下例子可以帮助理解该关键字

auto为例:


.grid_container {
display:grid;
/* 左右列150px,中间列自适应*/
grid-template-columns: 150px auto 150px;
/* 一行行高 300px*/
grid-template-rows: repeat(1,300px);
}

image.png

fr为例:
左右列占比 2/10 = 20% ,中间列占比 6/10 = 60%, 注意10 = 2+6+2


#grid_container{
display: grid;
grid-template-columns: 2fr 6fr 2fr;
grid-template-rows: repeat(1,300px);
}

image.png

代码案例4:在线代码入口👉👉(点击传送)

当需求是要求每个item子元素的宽高只有100px,但是容器宽度自适应时,我们就无法得知应该设置几行几列的属性了,所以这里再引入一个关键字auto-fill

auto-fill:自动填充

⚠️注意:grid-template-rows需要使用关键字时,容器必须要有固定高度⚠️


#grid_container{
display: grid;
height:500px;
grid-template-columns: repeat(auto-fill,100px);
grid-template-rows: repeat(auto-fill,100px);
}

代码案例5:在线代码入口👉👉(点击传送)

如果grid布局的子元素设置为自适应宽度,但宽度缩小到一定程度时就会出现错误,所以避免出现这种错误,我们必须要有一个最小的宽度,所以这里引入一个函数minmax()

minmax():设置一个长度范围,参数1:最小值,参数2:最大值

例子:最小值500px,最大值6fr


.grid_container {
display:grid;
width:600px;
grid-template-columns: 2fr minmax(500px,6fr) 2fr;
/* 自行屏蔽查看区别 */
/* grid-template-columns: 2fr 6fr 2fr; */
grid-template-rows: repeat(1,300px);
}

1.3grid-template-areas


1.3 grid-template-areas


grid-template-areas:用于划分区域,通过以下案例可以帮助理解

代码案例1:在线代码入口👉👉(点击传送)

1、划分出ai九个区域

2、或者每一行划分一个区域,三行就是a b c三个区域

2、当然可以不划分部分区域,使用(.)点表示不需要划分的区域


.grid_container {
display:grid;
grid-template-columns: repeat(3,100px);
grid-template-rows: repeat(3,100px);
/* 划分九个区域 */
grid-template-areas:
'a b c'
'd e f'
'g h i';

/* 划分三个区域 */
/* grid-template-areas:
'a a a'
'b b b'
'c c c'; */
/* 不划分部分区域 */
/* grid-template-areas:
'a . c'
'a . c'
'a . c'; */
}


划分区域的用途会在后面结合其他的属性进行讲解!!



1.4 grid-row-gapgrid-column-gapgrid-gap


grid-row-gap:行间距

grid-column-gap:列间距

grid-gap: 行间距 和 列间距 的简写形式,如:grid-gap: <grid-row-gap> <grid-column-gap>;


代码案例1:在线代码入口👉👉(点击传送)

这里以最简单的九宫格为例


.grid_container {
display:grid;
grid-template-columns: repeat(3,100px);
grid-template-rows: repeat(3,100px);
grid-row-gap:10px;
grid-column-gap:20px;
/* 下面语句和上面设置的间距效果相同,自行解除注释对比 */
/* grid-gap:10px 20px; */
}

image.png


1.5 grid-auto-flow


grid-auto-flow:设置grid布局的放置顺序,正常情况下是,从左到右放置每一个item子元素,在特殊情况下我们可以重新改变它的放置顺序,比如从上到下。可选值:从左到右 row、从上到下column、稠密从左到右row dense、稠密从上到下column dense,接下来会一一举例说明;

正常设置grid-auto-flow属性为 rowcolumn会出现以下两种效果,左边为row,右边为column


image.png
image.png


这里还是以九宫格为例,我们将 数字1数字2数字3 方块设置为各占2个单元格时,在grid-auto-flow属性默认等于row就会出现以下一幕
image.png

当我们把代码设置成 稠密型的从左到右row dense时,布局就会被尽可能的填满,不会出现上图存在的空格


image.png

代码如下:在线代码入口👉👉(点击传送)


.grid_container {
display:grid;
grid-template-columns: repeat(3,100px);
grid-template-rows: repeat(3,100px);
/* 默认,从左到右 */
grid-auto-flow:row;
/* 稠密型 从左到右 请自行开启屏蔽 */
/* grid-auto-flow:row dense; */
}
.item-1 {
background-color: #B53471;
grid-column-start: 1;
grid-column-end: 3;
}
.item-2 {
background-color: #ffcccc;
grid-column-start: 1;
grid-column-end: 3;
}
.item-3 {
background-color: #ff4d4d;
grid-column-start: 1;
grid-column-end: 3;
}


通过上面的例子可以清楚稠密型其实就是,尽可能填满容器而已,所以column dense例子就不多做解析,在线代码入口👉👉(点击传送)



image.png
image.png


1.6 justify-itemsalign-itemsplace-items


属性说明

justify-items:设置item子元素内容水平位置

align-items:设置item子元素内容垂直位置

place-items:align-itemsjustify-items 两个属性的简写方式,若省略第二个值,则认为第二个值等于第一个值


place-items: <align-items> <justify-items>

属性可选值(三个属性均有以下可选值)


start案例:在线代码入口👉👉(点击传送)

对齐子元素容器的起始边框,justify-items对齐水平的起始边框,align-items对齐垂直的起始边框

image.png

end案例:在线代码入口👉👉(点击传送)

对齐子元素容器的结束边框,justify-items对齐水平的结束边框,align-items对齐垂直的结束边框

image.png

center案例:在线代码入口👉👉(点击传送)

子元素容器内部居中,justify-items水平居中,align-items垂直居中

image.png

stretch案例:在线代码入口👉👉(点击传送)

默认就是这个属性,只要不设置宽度和高度就会把宽高拉伸铺满

image.png


1.7 justify-contentalign-contentplace-content



注意这三个属性和1.6描述的区别在于, justify-itemsalign-itemsplace-items是针对子元素内容的,justify-contentalign-contentplace-content是针对grid容器内容的



属性说明

justify-content:设置grid布局容器内容水平位置

align-content:设置grid布局容器内容垂直位置

place-content:align-contentjustify-content 两个属性的简写方式,若省略第二个值,则认为第二个值等于第一个值


place-content: <align-content> <justify-content>

属性可选值(三个属性均有以下可选值)







































可选值可选值说明
start对齐grid容器的起始边框
end对齐grid容器的结束边框
centergrid容器内部居中
stretchgrid容器内容大小没有指定时,拉伸占据整个grid容器
space-around每个子元素两侧间隔相等,所以子元素之间间隔比容器边框的间隔大一倍
space-between子元素与子元素之间间隔相等,子元素与容器边框没有间隔
space-evenly子元素与子元素之间间隔相等,子元素与容器边框之间也是同样长度的间隔

start案例:在线代码入口👉👉(点击传送)

对齐容器的水平和垂直起始边框,justify-content对齐水平的起始边框,align-content对齐垂直的起始边框


  justify-content:start;
align-content:start;

image.png


end案例:在线代码入口👉👉(点击传送)

对齐容器的水平和垂直结束边框,justify-content对齐水平的结束边框,align-content对齐垂直的结束边框


  justify-content:end;
align-content:end;

image.png




center案例:在线代码入口👉👉(点击传送)

容器内容水平和垂直居中对齐,justify-content容器内容水平居中对齐,align-content容器内容垂直居中对齐


  justify-content:center;
align-content:center;

image.png




stretch案例:在线代码入口👉👉(点击传送)

自动拉伸铺满grid容器,justify-content水平铺满容器,align-content垂直铺满容器


  justify-content:stretch;
align-content:stretch;

image.png




space-around案例:在线代码入口👉👉(点击传送)

每个子元素两侧间隔相等,所以子元素之间间隔比容器边框的间隔大一倍


  justify-content:space-around;
align-content:space-around;

image.png




space-between案例:在线代码入口👉👉(点击传送)

子元素与子元素之间间隔相等,子元素与容器边框没有间隔


  justify-content:space-between;
align-content:space-between;

image.png


space-evenly案例:在线代码入口👉👉(点击传送)

子元素与子元素之间间隔相等,子元素与容器边框之间也是同样长度的间隔


  justify-content:space-evenly;
align-content:space-evenly;

image.png


1.8 grid-auto-columnsgrid-auto-rows


grid-auto-columns:设置多余列的列宽

grid-auto-rows:设置多余行的行高

在某种情况下,我们设置了9宫格布局可能会出现10个item子元素,那正常的前9个子元素都设置有合适的宽高,但是多余出现的第10个如果不进行设置,就会出现不正常的布局,通过以下案例可以帮助理解


image.png

当使用 grid-auto-flow:column;改变默认的放置顺序会出现以下情况
image.png

所以在出现以上情况时,使用grid-auto-columnsgrid-auto-rows解决问题
在线代码入口👉👉(点击传送),自行修改案例代码观察变化。


.grid_container {
grid-auto-columns:100px;
grid-auto-rows:100px;
}

image.png


1.9 grid-templategrid


grid-template属性是grid-template-columnsgrid-template-rowsgrid-template-areas这三个属性的合并简写形式。

grid属性是grid-template-rowsgrid-template-columnsgrid-template-areas、 grid-auto-rowsgrid-auto-columnsgrid-auto-flow这六个属性的合并简写形式。



这两个属性用法比较复杂,后期再考虑重新写一篇文章讲解,有需要的请在评论区留言,留言数多的话,会尽快出新文章



2.0(子元素)grid-column-startgrid-column-endgrid-row-startgrid-row-endgrid-columngrid-row


横纵向网格线始终比横纵向子元素多1,下面通过几个案例帮助理解
image.png


案例1:在线代码入口👉👉(点击传送)

🥇当方块一想占满横向两个方格时,将方块一的grid-column-startgrid-column-end分别设置成13,或者设置grid-column: 1/3

🥈当方块一想占满纵向两个方格时,将方块一的grid-row-startgrid-row-end分别设置成13,或者设置grid-row: 1/3


.item-1 {
background-color: #B53471;
/* 横向 */
/* grid-column-start: 1;
grid-column-end: 3; */
grid-column: 1/3; /*效果相同 */

/* 纵向 */
/* grid-row-start: 1;
grid-row-end: 3; */
grid-row: 1/3; /*效果相同 */
}

image.png

案例2:在线代码入口👉👉(点击传送)

🥇当遇到多个方格进行属性设置时,需要考虑网格线是否被别的元素包含,如下图所示:
image.png

所以在案例1的原有基础上,我们想把方块2的纵向占两个方块,位置放在原方块4原方块7的位置,那么我们就要考虑方块1已经包含过的网格线不能使用。所以设置上边框网格线的的时候就要避开纵向的第2条网格线,这样我们要设置上边框网格线为3,下边框网格线为5


.item-2 {
background-color: #ffcccc;
grid-column: 1/2;
grid-row: 3/5;
}

效果如下:

image.png


2.1 (子元素)justify-selfalign-selfplace-self



其实这一节没啥好讲的,属性justify-itemsalign-itemsplace-items 属性效果一样,只不过前者是统一设置grid容器中的子元素内容位置,后者则是在子元素上单独设置,并且会覆盖统一设置的效果。



justify-self:设置水平位置

align-self:设置垂直位置

place-selfalign-self属性和justify-self属性的合并简写形式。(忽略第二个值,则认为第二个值等于第一个值)

案例1:在线代码入口👉👉(点击传送)

所有子元素内容水平垂直居中,第一个子元素内容对齐垂直方向结束边框align-self: end;,对齐水平方向结束边框justify-self: end;


代码和效果如下:justify-selfalign-self 覆盖了justify-itemsalign-items 设置的居中显示


.grid_container {
justify-items: center;
align-items: center;
}
.item-1 {
justify-self:end;
align-self:end;
background-color: #B53471;
}

image.png


2.1 (子元素)grid-area















属性名属性说明
grid-area指定子元素防止在哪个区域

在上面 1.3 中已经说过如何划分区域了,接下来我们通过 grid-area 属性来了解如何使用区域

案例1:在线代码入口👉👉(点击传送)

将就九宫格中1 2 3 方块替换到 4 5 6方块


.grid_container {
display:grid;
grid-template-columns: repeat(3,100px);
grid-template-rows: repeat(3,100px);
/* 划分九个区域 */
grid-template-areas:
'a b c'
'd e f'
'g h i';
}
.item-1 {
background-color: #B53471;
grid-area: d;
}
.item-2 {
background-color: #ffcccc;
grid-area: e;
}
.item-3 {
background-color: #ff4d4d;
grid-area: f;
}

image.png


案例2:在线代码入口👉👉(点击传送)

将九宫格中的方块1 2 3 纵向占满两个单元格,方块4 水平占满3个单元格


.grid_container {
display:grid;
grid-template-columns: repeat(3,100px);
grid-template-rows: repeat(3,100px);
/* 划分三个区域 */
grid-template-areas:
'a b c'
'a b c'
'd d d';
}
.item-1 {
background-color: #B53471;
grid-area: a;
}
.item-2 {
background-color: #ffcccc;
grid-area: b;
}
.item-3 {
background-color: #ff4d4d;
grid-area: c;
}
.item-4 {
background-color: #ffaf40;
grid-area: d;
}

image.png


作者:是舍长
链接:https://juejin.cn/post/7017074528752762911

收起阅读 »

构建大型前端业务项目的一点经验

目前工作中接手的几个项目都是 B端 PC 项目,业务逻辑都比较复杂,并且代码历史较久,在日常的维护中经常会遇到想摊手的技术问题,发现问题、解决问题、避免再次出现同样的问题,既是项目可持续维护的因素之一,也是个人工作经验积累的一个过程 本文可当做 接手前端新项...
继续阅读 »

目前工作中接手的几个项目都是 B端 PC 项目,业务逻辑都比较复杂,并且代码历史较久,在日常的维护中经常会遇到想摊手的技术问题,发现问题、解决问题、避免再次出现同样的问题,既是项目可持续维护的因素之一,也是个人工作经验积累的一个过程



本文可当做 接手前端新项目?这里有些注意点你可能需要留意一下编写可维护的现代化前端项目 的补充



具体、连贯的变量名


在前后端分离的现代化 web开发流程下,相比于以往,前端承担了更多的业务逻辑,尽管存在着 TypeScript等约束工具,但相比于后端语言,js 仍具备相当大的灵活性,这就导致了代码一旦复杂,前端代码的排查会更加麻烦


单一变量的层层传递与到处使用是很常见的事情,变量的命名对于追踪变量有着相当大的影响


所以变量名称必须是具体且有实际意义的,不提倡为了追求变量名的精确性而使得变量名称冗长,但模糊而宽泛的变量名同样不可取,这就要求变量名称即准确又简短,在某些时候,可能很难做到这一点,个人倾向是,实在无法做好权衡的前提下,宁愿选择冗长的变量名也不要选择一个模糊宽泛的


例如,data可以当做是一个变量名,这个变量名用于临时的局部变量没啥问题,毕竟你一眼就能看到这个变量所有的使用范围,但如果变量所持有的数据的作用范围较大(例如跨组件)且具备实际业务意义,那么就不太妙了,data 可以用作任何数据的变量名,当需要追踪 data的时候,在编辑器里搜索 data,发现到处都是 data,并不是一件美好的事情


一旦确定好了变量名后,最好不要再对其进行重命名,例如想将 A组件里的 userData 传递到 B 组件中,B组件最好原模原样地接收这个变量名,而不是将其命名为其他的什么名称


有些时候可能就必须要在传递变量的时候进行重命名,例如第三方组件就接收 data,那么你无法传递 userData,这种情况下当然不得不重命名


避免对变量重命名的目的主要是,为了防止在追踪变量的时候因变量名称改变而产生思维上的重新整理,一个变量被重命名了好几次后,追踪到底再回过头来你可能就忘记了自己当初在追踪什么东西,同时对于搜索功能也不太友好,从一而终的连贯变量名可以让你一次搜索就能从追踪的起始位置跳过中间一大堆逻辑直接到终点


必要的选择器


现代化的前端项目基本都会使用 ReactVue等数据驱动的框架,UI组件一般也都是使用别人封装好的组件库,除非是要写样式,否则html元素选择器基本上都是可有可无的了,但并不是说就不需要了,最起码的一个好处是,让你想在代码里查找页面上的一个元素时,直接根据选择器名就能精准定位了


页面上有个弹窗展示得不太对,你在浏览器页面里看到这个弹窗元素名叫 ant-modal-wrap,是个第三方的组件所以你代码里根本搜不到这个选择器名;页面上有一句文案有点问题,你在浏览器页面里看到这个文案所在的元素是个没有选择器的 div标签,在目前普遍 全站div的浪潮下,光是定位这个文案到底是哪里的就够花费好一阵子了


所以,这里选择器是起到了一个索引的作用,既然是索引,那么同样应该遵守上面变量名相关的规则,即选择器名称应当 即准确又简短


优化应该从一开始就开始


不要提前优化


相信很多人都听过这句话,我曾经将这句话当做是至理名言,但经历的多了之后,目前已经开始有所质疑了


不要提前优化,那么要在什么时候优化?快要 hold 不住的时候才优化?迭代了七八十版的时候再优化?团队人员换了一茬又一茬的时候再优化?


当然是可以的,但是那个时候谁来优化,谁来做这种在业务看来毫无产出甚至是可能优化出 bug的吃力不讨好的事情?


一个函数里塞了数十层的 if...else,函数体的代码量超过千行,看着就应该要被优化的,但是这些代码在这里绵延了数年之久,经过了一批又一批不同程序员的修改,承载了不知多少明面上暗地里的业务逻辑,技术上或许好优化,但谁能保证某处优化不会对业务逻辑造成破坏?


没有提前优化,过程中也没有优化,那就完全是没有任何优化了,因而屎山就诞生了


我认为 不要提前优化 这句话是产生在一个朝九晚五不加班需求少有充足时间做技术优化的语境之下,这种语境下,这句话是没啥问题的,只是大部分情况下,现实情况根本不符合语境,所以这句话就有问题了


该拆的组件、该提取的公共方法、该规划的目录结构、该引入的代码规范……应该从一开始就形成,不要等着需要优化的时候才优化,那个时候已经来不及了


复用(组件、方法)


代码复用是为了提升工作效率,但如果只是为了复用代码而复用,就本末倒置了


通用方法、通用组件鼓励复用,但业务逻辑、业务组件,慎重复用


一个常见的例子是,移动端详情页页面和编辑页可能具有大部分重合的逻辑,但类似这种业务属性很强的组件,除非你确信这个组件将来不出现大的改动,否则不要为了贪图眼前的便利而想当然地进行复用


本来为了区分展示态和编辑态,就已经写了一些条件语句了,日后若是出现了已经复用的逻辑必须要按照业务需求进行拆分,甚至是逻辑完全南辕北辙,初期还好,或许还能抢救一下拆分出来,但到了中后期才发现这个问题很可能已经晚了,掺杂了那么多的业务逻辑,你还敢去做拆分吗?那么这个复用组件的代码量必然要被大量的 if...else 占领,修改任何一个功能点、排查任何问题都要兼顾两套逻辑,对于维护者来说,这会造成相当大的心智负担,对于项目本身来说,维护的代码将会变得更大


业务代码是千变万化的,原本多个场景下相似的逻辑,很可能随着业务的迭代变得毫无关系,在这种场景下,复用不仅不能提高工作效率,反而还会拖后腿


而对于通用方法和通用组件来说,为了更加彻底地解耦,其应当是函数式的,不应当对外部状态产生隐式地修改


通用方法最好是纯函数,相同的输入有相同的输出,入参、出参都应当是明确的,让人一眼就看出来需要哪些入参,又会有哪些出参,而不是直接传入一个大的对象,然后在方法体内去一个个查找所需的对象属性


通用组件不应当自作主张修改外部数据,而应该将产生的变化主动抛出去,让上一层组件来明确决定如何使用这个变化


依据社区而不是从心


为项目选择设计模式、UI组件库、状态管理库等基础功能的时候,应当选取社区内热度更高的而非根据个人的喜好


你所认为很牛x的设计模式、第三方库等,可能是其他人根本就没听过的,或者其他人根本就不认同的,这只会增加团队之间的协作难度


团队合作项目的代码是用来传承的而不是用来炫技的


抛弃惯性思维


待在舒适区,这是人之本能,因为熟悉,所以上一个项目使用的技术栈在下一个项目里也要继续用


但是,真的合适吗?


上一个项目用了 mobx,这个项目里也必须要用吗?上一个项目里将所有的状态数据都放到了状态管理库里,这个项目也要这样做吗?上一个项目没用 TypeScript,这一个也不用吗?


可能是不需要的,可能是需要更换的,当然,并不是说就要跟上一个项目反着来,到底怎样最起码要有一个思考的过程,而不是上一个项目就是这样的,所以这一个也要这样


考虑清楚了再写TODO


有意识做优化是个好习惯,但意识得能落到实处


以我的经验看,在多人协作的、业务敏捷迭代的项目中,大多数 todo是无法完成的


大部分 todo都是基于当时的情况做出的考量,当时这个方法可能只有几行,todo 要做的时候很简单,但是当时没有做,当过了一段时间再想起来这事的时候,发现那个方法已经变成了几百行了,你还敢动吗?


或者换句话说,你还有完成这个 todo的心思吗?人都是懒惰的,你愿意将原本可以用在打游戏刷视频的时间用在完成这个 todo上吗?看到了别人写的 todo,并且也看明白了,但是你愿意帮别人完成这个 todo吗?


该做的事情应当立即完成,或许因为某些原因无法立即完成,所以你想延后再来做,但是一般情况下,后续再来完成的成本必然大于当下,现在都完成不了,凭什么认为以后就能完成?


真的需要做的事情,哪怕会让进度延期,只要你理由充分,其他人不可能也没理由去阻止你


小结


很多时候,一些让你能够写出更好的代码建议,实际上对于业务产出是毫无帮助的,哪怕你不遵守这些建议甚至反着来,也不影响你的产出不影响你的绩效,毕竟产出和绩效跟代码写得好不好并没有直接关系,甚至这些所谓的建议有时候还会影响你快速产出,只要我能拿出一个好的产出拿到一个好的绩效,代码写得糙点烂点又有什么关系?以后的事情以后再说呗,搞不好以后维护的人根本不是我


这种心理或许才是常态,毕竟这更加符合现实的利益


但如果你是一位对技术有追求的人,你真的甘心就如此吗?我认为除了现实的考量之外,还应当为自己写下的代码负责


作者:清夜
链接:https://juejin.cn/post/7016948081321050148

收起阅读 »

npm install之后发生了什么

  下载项目后,执行的第一个命令行一般都是 npm install 。在这个过程中可能一帆风顺,也可能遇到大大小小的报错,有时候花点时间各种搜索能解决,可下次遇到还是一头雾水的上网找各种方案尝试解决报错。   那么,你清楚当你输入 npm instal ,按下...
继续阅读 »

  下载项目后,执行的第一个命令行一般都是 npm install 。在这个过程中可能一帆风顺,也可能遇到大大小小的报错,有时候花点时间各种搜索能解决,可下次遇到还是一头雾水的上网找各种方案尝试解决报错。


  那么,你清楚当你输入 npm instal ,按下 Enter 键之后,究竟发生了什么吗?


正文


一、npm install之后发生了什么


  npm install 大概会经过以下几个流程,下面我们就来简单看一下(原图地址)。


install.jpg



  1. npm install执行后,会检查并获取npm配置,优先级为



项目级别的.npmrc文件 > 用户级别的.npmrc文件 > 全局的.npmrc文件 > npm内置的.npmrc文件



.npmrc 文件就是npm的配置文件。查看npm的所有配置, 包括默认配置,可以通过下面的命令:


npm config ls -l


  1. 然后检查项目中是否有package-lock.json文件。


  从npm 5.x开始,执行npm install时会自动生成一个 package-lock.json 文件。


package-lock.json 文件精确描述了node_modules 目录下所有的包的树状依赖结构,每个包的版本号都是完全精确的。


  因此npm会先检查项目中是否有 package-lock.json 文件,分为两种情况:



  • 如果有,检查package-lock.jsonpackage.json中声明的依赖是否一致



  • 一致:直接使用 package-lock.json 中声明的依赖,从缓存或者网络中加载依赖

  • 不一致:各个版本的npm处理方式如上图



  • 如果没有,根据package.json递归构建依赖树,然后根据依赖树下载完整的依赖资源,在下载时会检查是否有相关的资源缓存



  • 存在:将缓存资源解压到 node_modules

  • 不存在:从远程仓库下载资源包,并校验完整性,并添加到缓存,同时解压到 node_modules



  1. 最终将下载资源包,存放在缓存目录中;解压资源包到当前项目的node_modules目录;并生成 package-lock.json 文件。


  构建依赖树时,不管是直接依赖还是子依赖,都会按照扁平化的原则,优先将其放置在 node_modules 根目录中(最新的npm规范), 在这个过程中,如果遇到相同的模块,会检查已放置在依赖树中的模块是否符合新模块的版本范围,如果符合,则跳过,不符合,则在当前模块的 node_modules 下放置新模块。


二、npm缓存


  在执行 npm installnpm update 命令下载依赖后,除了将依赖包安装在 node_modules 目录下外,还会在本地的缓存目录缓存一份。我们
可以通过以下命令获取缓存位置:


// 获取缓存位置
npm config get cache

// C:\Users\DB\AppData\Roaming\npm-cache
复制代码

  如我的缓存位置在C:\Users\DB\AppData\Roaming\npm-cache下面的_cacache 文件夹中。


  再次安装依赖的时候,会根据 package-lock.json 中存储的 integrity、version、name 信息生成一个唯一的 key,然后拿着key去目录中查找对应的缓存记录,如果有缓存资源,就会找到tar包的hash值,根据 hash 再去找缓存的 tar 包,并把对应的二进制文件解压到相应的项目 node_modules 下面,省去了网络下载资源的开销。


  因此,如果我们可能因为网络原因导致下载的包不完整,这就可能造成删除node_modules重新下载的依旧是问题包,假如删除 node_modules 重新下载问题依旧,此时就需借助命令行清除缓存。


// 清除缓存
npm cache clean --force

复制代码

  不过 _cacache 文件夹中不包含全局安装的包,所以想清除存在问题的包为全局安装包时,需用 npm uninstall -g 解决


三、关于yarn


yarn简介:


  yarn是由Facebook、Google、Exponent 和 Tilde 联合推出了一个新的 JS 包管理工具 ,正如官方文档中写的,Yarn 是为了弥补 npm 的一些缺陷而出现的。


yarn特点:



  • 速度快



  • yarn 缓存了每个下载过的包,所以再次使用时无需重复下载。 同时利用并行下载以最大化资源利用率,因此安装速度更快。



  • 安全

    • 在执行代码之前,yarn 会通过算法校验每个安装包的完整性。



  • 可靠

    • 使用详细、简洁的锁文件格式和明确的安装算法,yarn 能够保证在不同系统上无差异的工作。




四、yarn和npm部分命令对比



总结


  无论是使用npm 还是 yarn 来管理你的项目依赖,我们都应该知其然更知其所以然,这样才能在项目中跟海的定位及解决问题,不是吗?




链接:https://juejin.cn/post/7016994983186006024

收起阅读 »

进来聊聊!Vue 和 React 大杂烩!

相信应用层面的知识,大家都比较熟悉了,实际 React 用来实现业务对于熟悉 Vue 的开发人员来说也不是难事,今天我们简单的了解一下 React 和 Vue 。(瞎聊聊) 先来两张源码编译图对比一下: 由于每个步骤能涉及的东西太多,所以本篇就简单聊一下他...
继续阅读 »

相信应用层面的知识,大家都比较熟悉了,实际 React 用来实现业务对于熟悉 Vue 的开发人员来说也不是难事,今天我们简单的了解一下 React 和 Vue 。(瞎聊聊)


先来两张源码编译图对比一下:


image.png


image.png


由于每个步骤能涉及的东西太多,所以本篇就简单聊一下他们的区别以及他在我们项目中实际的应用场景中能够做什么(想到什么聊什么)。


Vue


new Vue


我们知道 Vue 和 React 都是通过替换调指定的 Dom 元素来渲染我们的组件,来看一下:


import Vue from 'vue'
import App from './App.vue'

new Vue({
render: h => h(App),
}).$mount('#app')

先说 Vue 的,new Vue 做了什么?相信读过源码的同学都会知道,他执行了一堆初始化操作 initLifecycle、initEvents、initRender、initInjections、initState、initProvide


具体包括以下操作:选项合并(用户选项、默认选项)、$children$refs$slots$createElement等实例属性和方法初始化、自定义事件处理、数据响应式处理、生命周期钩子调用、可能的挂载。


响应式原理


当一个 Vue 实例被创建时,它将 data 对象中的所有的 property 加入到 Vue 的响应式系统中。当这些 property 的值发生改变时,视图将会产生“响应”,即匹配更新为新的值。


var data = {
a: 1
}
var vm = new Vue({
data
})
vm.a = 1
data.a // 1

data.a = 2
vm.a // 2

Vue 通过劫持 get 和 set 来实现响应式原理,这也是与 React 最大区别所在,React 只能手动调用 this.setState来将state改变。


我在往期篇幅有具体谈过 Vue 的响应式原理:
深入浅出Vue响应式原理


模板编译 && 视图渲染


当 data 中的数据实现了响应式之后,就开始在模板上做功夫了。


这里有一个很重要的东西叫虚拟 Dom。


所谓虚拟 DOM 就是用 js 来描述一个 DOM 节点,在 Vue 中通过 Vnode 类来描述各种真实 DOM 节点。


在视图渲染之前,把 template 先编译成虚拟 Dom 缓存下来,等数据发生变化需要重新渲染时,通过 diff 算法找出差异对比新旧节点(patch),之后把最终结果替换到真实 Dom 上,最终完成一次视图更新。


了解更多关于 diff 移步至:diff算法


1632467217.jpg


关于编译原理要细聊就有点多了,大致总结一下:



  • 第一步是将 模板字符串 转换成 AST语法树(解析器)

  • 第二步是对 AST 进行静态节点标记,主要用来做虚拟 DOM 的渲染优化(优化器)

  • 第三步是 使用 element ASTs 生成 render 函数代码字符串(代码生成器)


有兴趣请移步至:
Vue 模板编译原理


生命周期


在这些过程中,Vue 会暴露一些钩子函数供我们在适当时机去执行某些操作,这就是生命周期钩子函数。关于 Vue 的生命周期大家应该都熟记于心了,简单过一下:




  • beforeCreate (创建实例前的钩子,此时 data 里的数据还不能用。)




  • created (实例创建完成后的钩子,此时 data 已完成初始化可使用,但 Dom 尚未挂载。)




  • beforeMount (将编译完成的 HTML 挂载到对应虚拟 Dom,此时页面并无内容。)




  • mounted (Dom 已完成挂载,此时可以操作 Dom,此阶段也可以调用接口等操作。)




  • beforeUpdate (更新之前的钩子,当data变化时,会触发beforeUpdate方法。基本上没有什么用处。)




  • updated (更新之后的钩子,当数据变化导致地虚拟DOM重新渲染时会被调用,被调用时,组件DOM已经更新。建议不要在这个钩子函数中操作数据,可能陷入死循环。)




  • beforeDestory (实例销毁前的钩子,此时还可以使用 this,通常在这一步会进行清除计时器等操作)




  • destoryed (实例销毁完成的钩子,调用完成后,Vue实例的所有内容都会解绑定,移出全部事件监听器,同时销毁所有的子实例。)




React


大家可能会比较关心 React 会扯什么(猜的),毕竟 Vue 已经是家喻户晓,加上国内业务使用也是居多,生态圈及各类解决方案也是层出不穷。


ReactDOM.render


ReactDOM.render 是 React 的最基本方法用于将模板转为 HTML 语言,并插入指定的 DOM 节点。


import App from './App.jsx'
import ReactDOM from 'react-dom'

ReactDOM.render(
<App></App>,
document.getElementById('root')
)

render 方法实际是调用了内部的 React.createElement 方法,进而执行 ReactMount._renderSubtreeIntoContainer


还有一个方法 ReactDOM.unmountComponentAtNode() 作用和 ReactDOM.render() 正好相反,他是清空一个渲染目标中的 React 部件或 HTML。


React state


state 是 React 中很重要的东西,说到 state 就不得不提到 setState 这个方法,很多人认为 setState 是异步操作,其实并不是。之所以会有一种异步的表现方式是因为 React 本身的性能机制导致的。因为每次调用 setState 都会触发更新,异步操作是为了提高性能,将多个状态合并一起更新,减少 render 调用。


image.png


如图,setState 接受一个新状态并不会立即执行,而是存入 pending 队列中进行判断。


如果有阅读过源码的同学就会知道他在其中通过判断 isBatchingUpdates (是否是批量更新模式)来进行区分。


如果是,那就会将状态保存到 dirtyComponents (脏组件)。


如果否,那就遍历所有的脏组件,并调用 updateComponent 更新 pending 队列的 state 或 props。执行完后,将
isBatchingUpdates 设置为 true。


假如有如下代码:


for ( let i = 0; i < 100; i++ ) {
this.setState( { count: this.state.count + 1 } );
}
复制代码

若 setState 是同步机制,那么这个组件会被 render 100次,这无疑对性能是毁灭性的。


当然 React 也想到了这个问题并做了处理:



React 会将 setState 的调用合并为一个执行,所以 setState 执行时我们并没有看到 state 马上更新,而是通过回调获取到更新后的数据(有点类似 Vue 中的 nextTick),也就是刚刚上图所叙。



React 渲染流程


对于首次渲染,React 的主要是通过 React.render 接收到的 VNode 转化为 Fiber 树,并根据树的层级关系构建出 Dom 树并渲染。


而二次渲染(更新),Fiber 树已经存在内存中了,所以 React 会计算 Fiber 树中的各个节点差异(diff),并将变化更新渲染。


实际上 Vue 和 React 的 diff 算法都是同层 diff,复杂度都为O(n),但是他们的不同在于 React 首位节点是固定不动的(除了删除),然后依次遍历对比。


Vue 的 diff 在 compile 阶段的 optimize 标记了 static 点,可以减少 diff 次数,而且是双向遍历方法,并且借鉴了开源库 snabbdom。(不论 Vue 还是 React 两者都是各有秋千)


再说回渲染, React 中也存在着和 Vue 一样的 VNode(虚拟 Dom)。


JSX 会被编译转换成 React.createElement 函数的调用,返回值就是 VNode,其作用和 Vue 中的 VNode 基本一致。


关于 Fiber 是一个比较抽象的概念比较难理解,可以理解为他是用来描述有关组件以及输入输出的信息的一个 JavaScript 对象。


了解更多 Fiber:Fiber传送门


小结一下:


React 渲染流程(浅看):


jsx --> createElement 函数 --> 这个函数帮助我们创建 ReactElement 对象(对象树) --> ReactDOM.render 函数 --> 映射到浏览器的真实DOM


生命周期


在渲染过程中暴露出来的钩子就是生命周期钩子函数了,看图:


image.png


我在 Vue 转 React 系列中有提到过 ->传送门


组件的生命周期可分成三个状态:



  • Mounting:已插入真实 DOM

  • Updating:正在被重新渲染

  • Unmounting:已移出真实 DOM


简单过一下生命周期:




  • componentWillMount 在渲染前调用,在客户端也在服务端。




  • componentDidMount : 在第一次渲染后调用,只在客户端。之后组件已经生成了对应的DOM结构,可以通过this.getDOMNode()来进行访问。 如果你想和其他JavaScript框架一起使用,可以在这个方法中调用setTimeout, setInterval或者发送AJAX请求等操作(防止异步操作阻塞UI)。




  • componentWillReceiveProps 在组件接收到一个新的 prop (更新后)时被调用。这个方法在初始化render时不会被调用。




  • shouldComponentUpdate 返回一个布尔值。在组件接收到新的props或者state时被调用。在初始化时或者使用forceUpdate时不被调用,可以在你确认不需要更新组件时使用。




  • componentWillUpdate在组件接收到新的props或者state但还没有render时被调用。在初始化时不会被调用。




  • componentDidUpdate 在组件完成更新后立即调用。在初始化时不会被调用。




  • componentWillUnmount在组件从 DOM 中移除之前立刻被调用。




小结


本文只是涉及内容众多,难免会有遗漏或不周,还请看官轻喷


作者:饼干_
链接:https://juejin.cn/post/7016530148073668621

收起阅读 »

前端必学的flip动画思想

前言 相信大家在用Vue的时候,一定用过他的transition-group组件。在该组件下方可以看到这么一句话 这个看起来很神奇,内部的实现,Vue 使用了一个叫 FLIP 简单的动画队列,使用 transforms 将元素从之前的位置...
继续阅读 »

前言


相信大家在用Vue的时候,一定用过他的transition-group组件。在该组件下方可以看到这么一句话



这个看起来很神奇,内部的实现,Vue 使用了一个叫 FLIP 简单的动画队列,使用 transforms 将元素从之前的位置平滑过渡新的位置。,我们将之前实现的例子和这个技术结合,使我们列表的一切变动都会有动画过渡。



和一个特别特别酷炫的动画效果


dfx1w-f5cnr.gif


下面,跟我一起走进Flip动画的奇妙世界


前置知识


getBoundingClientRect


通过dom.getBoundingClientRect(),可以得到某个元素在屏幕上的矩形区域


const rect = dom.getBoundingClientRect(); // 获取矩形区域
rect.left; // 获取矩形区域的left值
rect.top; // 获取矩形区域的top值

transform


transform是css3提供的属性,含义为变形或变换


css3提供了多种变换方式,包括平移、旋转、倾斜、缩放,还包括更加具有通用性的矩阵变换


所有变换,均不会影响真实布局位置,只是影响最终的视觉效果


animate api


Element 接口的animate()方法是一个创建新Animation的便捷方法,将它应用于元素,然后运行动画。它将返回一个新建的 Animation 对象实例


使用animate api实现动画非常简单,仅需要通过下面的代码即可实现


dom.animate(
[
{ /* 起始css属性 */ },
{ /* 结束css属性 */ },
],
{
duration: 800, // 完成动画的时间
}
);

其他API请看MDN文档


Flip思想


Flip是一种动画思路,专门针对上述场景


它由四个单词组成,分别是:



  • First

  • Last

  • Invert

  • Play


具体过程如下


image.png


在代码实现上,可以遵循以下结构实现动画效果


// ① Frist
record(container); // 记录容器中每个子元素的起始坐标
// 改变元素顺序
change();
// ② Last + ③ Invert + ④ Play
move(container); // 让元素真正实现移动

实现


<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<style>
* {
margin: 0;
padding: 0;
}

.btns {
text-align: center;
}

.btns button {
margin: 0 1em;
outline: none;
border: none;
background: #579ef8;
color: #fff;
padding: 7px 10px;
border-radius: 5px;
cursor: pointer;
}

.btns button:hover {
opacity: 0.8;
}

.container {
width: 500px;
overflow: hidden;
margin: 20px auto;
display: flex;
flex-wrap: wrap;
}

.item {
width: 50px;
height: 50px;
box-sizing: border-box;
text-align: center;
background: #eef5fe;
border: 1px solid #ddebfd;
line-height: 50px;
margin: 5px;
}
</style>

<body>
<div class="btns">
<button id="sort">随机排序</button>
</div>
<div class="container">
<div class="item">1</div>
<div class="item">2</div>
<div class="item">3</div>
<div class="item">4</div>
<div class="item">5</div>
<div class="item">6</div>
<div class="item">7</div>
<div class="item">8</div>
<div class="item">9</div>
<div class="item">10</div>
<div class="item">11</div>
<div class="item">12</div>
<div class="item">13</div>
<div class="item">14</div>
<div class="item">15</div>
<div class="item">16</div>
<div class="item">17</div>
<div class="item">18</div>
<div class="item">19</div>
<div class="item">20</div>
<div class="item">21</div>
<div class="item">22</div>
<div class="item">23</div>
<div class="item">24</div>
<div class="item">25</div>
<div class="item">26</div>
<div class="item">27</div>
<div class="item">28</div>
<div class="item">29</div>
<div class="item">30</div>
<div class="item">31</div>
<div class="item">32</div>
<div class="item">33</div>
<div class="item">34</div>
<div class="item">35</div>
<div class="item">36</div>
<div class="item">37</div>
<div class="item">38</div>
<div class="item">39</div>
<div class="item">40</div>
<div class="item">41</div>
<div class="item">42</div>
<div class="item">43</div>
<div class="item">44</div>
<div class="item">45</div>
<div class="item">46</div>
<div class="item">47</div>
<div class="item">48</div>
<div class="item">49</div>
<div class="item">50</div>
</div>

<script>
const container = document.querySelector('.container')
function change() {
const childrens = [...container.children]
for(let i = 0, l = childrens.length; i < l; i ++) {
const children = childrens[i]
const j = Math.floor(Math.random() * l)
if (i !== j) {
// 获取当前dom的下一个元素
const inextDom = children.nextElementSibling
// 把i插入j之前
container.insertBefore(children, childrens[j])
// 把下标j的元素插入到i元素之前
container.insertBefore(childrens[j], inextDom)
}
}
}
sort.onclick = () => {
record(container)
change()
move(container)
}

function record(container) {
for(let i = 0, len = container.children.length; i < len; i ++) {
const dom = container.children[i]
const rect = dom.getBoundingClientRect()
dom.startX = rect.left
dom.startY = rect.top
}
}

function move(container) {
for(let i = 0, len = container.children.length; i < len; i ++) {
const dom = container.children[i]
const rect = dom.getBoundingClientRect()
const curX = rect.left, curY = rect.top
dom.animate([
{ transform: `translate(${dom.startX - curX}px, ${dom.startY - curY}px)` },
{ transform: `translate(0px, 0px)` }
], { duration: 600 })
}
}
</script>
</body>

</html>

以上就是所有代码了,可以在控制台看到,不会出现style标签,非常的神奇。


结语


FLIP 不光可以做位置变化的动画,对于透明度,大小等等都可以很轻松的实现。


Flip非常有用,可以实现在任何需要动画的地方



链接:https://juejin.cn/post/7016912165789515783

收起阅读 »

什么是 Promise.allSettled() !新手老手都要会?

Promise.allSettled() 方法返回一个在所有给定的 promise 都已经 fulfilled 或 rejected 后的 promise,并带有一个对象数组,每个对象表示对应的 promise 结果。 接着,我们来看看 Promise.all...
继续阅读 »

Promise.allSettled() 方法返回一个在所有给定的 promise 都已经 fulfilledrejected 后的 promise,并带有一个对象数组,每个对象表示对应的 promise 结果。


接着,我们来看看 Promise.allSettled() 是如何工作的。


1. Promise.allSettled()


Promise.allSettled() 可用于并行执行独立的异步操作,并收集这些操作的结果。


该函数接受一个 promise 数组(通常是一个可迭代对象)作为参数:


const statusesPromise = Promise.allSettled(promises);

当所有的输入 promises 都被 fulfilledrejected 时,statusesPromise 会解析为一个具有它们状态的数组




  1. { status: 'fulfilled', value: value } — 如果对应的 promise 已经 fulfilled




  2. 或者 {status: 'rejected', reason: reason} 如果相应的 promise 已经被 rejected




2823245504-60af4dde07f62_fix732.png


在解析所有 promises 之后,可以使用 then 语法提取它们的状态:


statusesPromise.then(statuses => {
statuses; // [{ status: '...', value: '...' }, ...]
});

或者使用 async/await 语法:


const statuses = await statusesPromise;
statuses; // [{ status: '...', value: '...' }, ...]

2. 取水果和蔬菜


在深入研究 Promise.allSettle() 之前,我们先定义两个简单的 helper 函数。


首先,resolveTimeout(value, delay)返回一个 promise ,该 promise 在经过 delay 时间后用 value 来实现


function resolveTimeout(value, delay) {
return new Promise(
resolve => setTimeout(() => resolve(value), delay)
);
}

第二,rejectTimeout(reason, delay) - 返回一个 promise,在经过 delay 时间后拒绝reason


最后,我们使用这些辅助函数来试验 promise.allsettle()


2.1 All promises fulfilled


我们同时访问当地杂货店的蔬菜和水果。访问每个列表是一个异步操作:


const statusesPromise = Promise.allSettled([
resolveTimeout(['potatoes', 'tomatoes'], 1000),
resolveTimeout(['oranges', 'apples'], 1000)
]);
// wait...
const statuses = await statusesPromise;
// after 1 second
console.log(statuses);
// [
// { status: 'fulfilled', value: ['potatoes', 'tomatoes'] },
// { status: 'fulfilled', value: ['oranges', 'apples'] }
// ]

线上事例:codesandbox.io/s/all-resol…


Promise.allSettled([...])返回一个 promise statusesPromise,该 promise 在1秒内解决,就在蔬菜和水果被解决之后,并行地解决。


statusesPromise 解析为一个包含状态的数组。



  1. 数组的第一项包含有蔬菜的已完成状态:status: 'fulfilled', value: ['potatoes', 'tomatoes'] }

  2. 同样的方式,第二项是水果的完成状态: { status: 'fulfilled', value: ['oranges', 'apples'] }


2.2一个 promise 被拒绝


想象一下,在杂货店里已经没有水果了。在这种情况下,我们拒绝水果的 promise。


promise.allsettle() 在这种情况下如何工作?


const statusesPromise = Promise.allSettled([
resolveTimeout(['potatoes', 'tomatoes'], 1000),
rejectTimeout(new Error('Out of fruits!'), 1000)
]);
// wait...
const statuses = await statusesPromise;
// after 1 second
console.log(statuses);
// [
// { status: 'fulfilled', value: ['potatoes', 'tomatoes'] },
// { status: 'rejected', reason: Error('Out of fruits!') }
// ]

线上事例:codesandbox.io/s/one-rejec…


Promise.allSettled([...]) 返回的 promise 在 1 秒后解析为一个状态数组:




  1. 数组的第一项,蔬菜 promise 成功解析:{ status: 'fulfilled', value: ['potatoes', 'tomatoes'] }




  2. 第二项,因为水果 promise 被拒绝,所以是一个拒绝状态: { status: 'rejected', reason: Error('Out of fruits') }




即使输入数组中的第二个 promise 被拒绝,statusesPromise仍然会成功解析一个状态数组。


2.3 所有的 promises 都被 rejected


如果杂货店里的蔬菜和水果都卖光了怎么办?在这种情况下,两个 promise 都会被拒绝。


const statusesPromise = Promise.allSettled([
rejectTimeout(new Error('Out of vegetables!'), 1000),
rejectTimeout(new Error('Out of fruits!'), 1000)
]);
// wait...
const statuses = await statusesPromise;
// after 1 second
console.log(statuses);
// [
// { status: 'rejected', reason: Error('Out of vegetables!') },
// { status: 'rejected', reason: Error('Out of fruits!') }
// ]

线上事例:codesandbox.io/s/all-rejec…


在这种情况下,statusesPromise仍然成功地解析为一个状态数组。然而,该数组包含被拒绝的promise 的状态。


3.总结


Promise.allSettled(promises)可以并行地运行 promise,并将状态(fulfilled 或reject)收集到一个聚合数组中。


Promise.allSettled(...)在你需要执行平行和独立的异步操作并收集所有结果时非常有效,即使某些异步操作可能失败。



链接:https://juejin.cn/post/7016856020395753509

收起阅读 »

说一说Web端侧AI

前言 AI 正在不断拓展前端的技术边界, 算法的加持也为前端研发注入了全新的力量。本文为大家介绍什么是端智能,端智能的应用场景以及 Web 端侧实现 AI 的基本原理概念。 什么是端智能 首先,回顾一个AI应用的开发流程,具体步骤包括了数据的采集与预处理模型的...
继续阅读 »

前言


AI 正在不断拓展前端的技术边界, 算法的加持也为前端研发注入了全新的力量。本文为大家介绍什么是端智能,端智能的应用场景以及 Web 端侧实现 AI 的基本原理概念。


什么是端智能


首先,回顾一个AI应用的开发流程,具体步骤包括了

数据的采集与预处理

模型的选取与训练

模型的评估

模型服务部署


模型训练的中间产物为一个模型文件,通过加载模型文件,部署为可调用的服务,然后就可以调用进行推理预测了。


在传统流程中,模型服务会部署在高性能的服务器上,由客户端发起请求,由服务器端进行推理,将预测结果返回给客户端,而端智能则是在客户端上完成推理的过程。


端智能的应用场景


端智能现在已经有非常多的应用场景,涵盖视觉类的 AR 、互动游戏,推荐类的信息流推荐,触达类的智能Push等,语音类的直播、智能降噪等多个领域。算法逐渐从服务端覆盖到用户实时感知更强的移动终端。


典型应用包括了

AR 应用、游戏。由 AI 提供理解视觉信息的能力,由 AR 根据视觉信息来实现虚实结合的交互,带来更沉浸式的购物、互动体验。比如美颜相机、虚拟试妆,即是通过检测人脸面部的关键点,在特定区域使用 AR 增强、渲染妆容。

互动游戏。飞猪双十一的互动游戏"找一找", 即是一个跑在 h5 页面的图片分类应用,通过摄像头实时捕捉图片,调用分类模型进行分类,当出现游戏设定目标时得分。
端侧重排。通过实时的用户意识识别,对服务器推荐算法下发的feeds流进行重新排列,做出更精准的内容推荐。

智能Push。通过端侧感知用户状态,决策是否需要向用户实施干预,推送Push,选取合适的时机主动触达用户,而非服务器端定时的批量推送,带来更精准的营销,更好的用户体验。


端智能的优势


从普遍的应用场景,可以看到端智能的明显优势,包括了

低延时

实时的计算节省了网络请求的时间。对于高帧率要求的应用,比如美颜相机每秒都要请求服务器,高延迟绝对是用户所不能接受的。而对于高频交互场景,比如游戏,低延时变得更为重要。

低服务成本

本地的计算节省了服务器资源,现在的新手机发布都会强调手机芯片的 AI 计算能力,越来越强的终端性能让更多的端上 AI 应用成为了可能。

保护隐私

数据隐私的话题在今天变得越来越重要。通过在端侧进行模型的推理,用户数据不需要上传到服务器,保证了用户隐私的安全。


端智能的局限


同时,端智能也有一个最明显的局限,就是低算力,虽然端侧的性能越来越强,但是和服务器相比还是相差甚远。为了在有限的资源里做复杂的算法,就需要对硬件平台进行适配,做指令级的优化,让模型能够在终端设备中跑起来,同时,需要对模型进行压缩,在时间和空间上减少消耗。


现在已经有一些比较成熟的端侧推理引擎了,这些框架、引擎都对终端设备做了优化来充分发挥设备的算力。比如Tensorflow LitePytorch mobile、阿里的 MNN、百度飞桨 PaddlePaddle


Web端呢


Web 端同样拥有端侧 AI 的优势与局限,作为在 PC 上用户访问互联网内容和服务的主要手段,在移动端很多APP也会嵌入 Web 页面,但是浏览器内存和存储配额的有限,让 Web 上运行 AI 应用看上去更不可能。


然而在 2015 年的时候就已经出现了一个 ConvNetJS的库,可以在浏览器里用卷积神经网络做分类、回归任务,虽然现在已经不维护了,2018 年的时候涌现了非常多的JS的机器学习、深度学习框架。如 Tensorflow.jsSynapticBrain.jsMindKeras.jsWebDNN 等。


受限于浏览器算力,部分框架如 keras.jsWebDNN框架只支持加载模型进行推理,而不能在浏览器中训练。


此外,一些框架不适用于通用的深度学习任务,它们支持的网络类型有所不同。比如 TensorFlow.jsKeras.jsWebDNN 支持了 DNNCNNRNN。而 ConvNetJS 主要支持 CNN 任务,不支持 RNNBrain.jssynaptic 主要支持 RNN 任务,不支持 CNN 网络中使用的卷积和池化操作。Mind 仅支持基本的 DNN


在选择框架时需要看下是否支持具体需求。


Web端架构


Web端是如何利用有限的算力的呢?


一个典型的 JavaScript 机器学习框架如图所示,从底向上分别是驱动硬件,使用硬件的浏览器接口,各种机器学习框架、图形处理库,最后是我们的应用。


Untitled


CPU vs GPU


Web 浏览器中运行机器学习模型的一个先决条件是通过 GPU 加速获得足够的计算能力。


在机器学习中,尤其是深度网络模型,广泛使用的操作是将大矩阵与向量相乘,再与另一个向量做加法。这种类型的典型操作涉及数千或数百万个浮点操作,而是它们通常是可并行化的。


以一个简单的向量相加为例,将两个向量相加可分为许多较小的运算,即每个索引位置相加。这些较小的操作并不相互依赖。尽管 CPU 对每个单独的加法所需的时间通常更少,随着计算量规模的变大,并发会逐渐显示出优势。


Untitled


WebGPU/WebGL vs WebAssembly


有了硬件之后,需要对硬件进行充分的利用。




  • WebGL


    WebGL 是目前性能最高的 GPU 利用方案,WebGL 为在浏览器中加速 2D3D图形渲染而设计,但可以用于神经网络的并行计算来加速推理过程,实现速度数量级的提升。




  • WebGPU


    随着 Web 应用对可编程 3D 图形、图像处理和 GPU 访问需求的持续增强,为了在 WEB 中引入GPU 加速科学计算性能,W3C2017 年提出了 WebGPU ,作为下一代 WEB 图形的的 API 标准,具有更低的驱动开销,更好的支持多线程、使用 GPU 进行计算。




  • WebAssembly


    当终端设备没有 WebGL 支持或者性能较弱的时候,使用 CPU 的通用计算方案为 WebAssemblyWebAssembly是一种新的编码方式,可以在现代的网络浏览器中运行,它是一种低级的类汇编语言,具有紧凑的二进制格式,可以接近原生的性能运行,并为诸如C / C ++ 等语言提供一个编译目标,以便它们可以在 Web 上运行。




Tensorflow.js


Tensorflow.js 为例,为了在不同的环境下实现运行,tensorflow 支持了不同的后端,根据设备条件自动选择相应的后端 ,当然也支持手动更改。


Untitled


tf.setBackend('cpu');
console.log(tf.getBackend());

对一些通用模型进行测试,WebGL速度大于普通 CPU 后端计算的 100 倍,WebAssembly 则比普通的 JS CPU 后端快 10-30 倍。


同时, tensorflow 也提供了 tfjs-node 版本,通过 C++CUDA 代码编译的本机编译库驱动 CPUGPU 进行计算,训练速度与 Python 版本的 Keras 相当。不需要切换常用语言,可以直接在 nodejs 服务上添加 AI 模块,而不是再启动一个 python 的服务。


模型压缩


有了框架对于硬件设备的适配,还需要对模型进行压缩,复杂的模型固然有更好的预测精度,但是高额的存储空间、计算资源的消耗,过长的推理速度在大部分移动端场景中还是难以接受的。


模型的复杂度在于模型结构的复杂以及海量的参数。模型文件中通常存储了两部分信息:结构参数,如下图中简化的神经网络所示,每个方块对应一个神经元,每个神经元以及神经元中的连线上都是参数。


模型的推理即从左侧输入,通过与神经元进行计算,再通过连线加上权重传到下一层计算,到最终层得到预测输出。节点越多、连接越多,则计算量越大。


Untitled


模型剪枝


对训练好的模型进行裁剪,是模型压缩的一个常见方式,网络模型中存在着大量冗余的参数,大量神经元激活值趋近于 0,通过对无效节点或者是不那么重要的节点进行裁剪,可以减少模型的冗余。


最简单粗暴的剪枝即 DropOut ,在训练过程中随机对神经元进行丢弃。
大部分的剪枝方法会计算重要性因子,计算神经元节点对于最终的结果的重要性,剪去不那么重要的节点。


模型剪枝的过程是迭代反复进行的,并非剪枝后直接用来推理,通过剪枝后的训练恢复模型的精度,模型的压缩过程是在精度和压缩比例之间的不断权衡,在可接受的精度损失范围内选择最好的压缩效果。


模型量化


为了保证较高的精度,大部分的科学运算都是采用浮点型进行计算,常见的是 32 位浮点型和 64 位浮点型,即 float32double64。量化即是把高精度的数值转化为低精度。


如二值量化(1bit量化)会直接将 Float32/float64 的值映射到 1bit ,存储空间直接压缩 32 倍/ 64 倍,计算时加载所需内存同样也会变小,更小的模型尺寸,带来更低的功耗以及更快的计算速度。除此还有8bit量化、任意bit量化。


知识蒸馏


知识蒸馏则是将深度网络中所学到的知识转移到另一个相对简单的网络中,先训练一个 teacher 网络,然后使用这个 teacher 网络的输出和数据的真实标签去训练 student 网络。


工具


模型压缩的实现较为复杂,如果只是面向应用,大概了解其作用原理即可,可以直接用封装好的工具。


比如 Tensorflow Model Optimization Toolkit 提供了量化功能,其官方对于一些通用模型进行了压缩测试,如下表可以看到,对于 mobilenet 模型,模型大小由 10M+ 压缩到了 3、4M,而模型的精度损失很小。


Untitled


百度的飞桨提供的 PaddleSlim 则提供了上述三种压缩方法。


Untitled


总结


综上,开发一个 Web 端上的 AI 应用,流程即变成了

针对特定场景设计算法、训练模型

对模型进行压缩

转换为推理引擎所需要的格式

加载模型进行推理预测


对于算法而言,通用的深度学习框架已然提供了若干的通用预训练模型,可以直接用来做推理,也可以在其基础上训练自己的数据集。模型的压缩与推理也可以使用现有的工具。


作者:凹凸实验室
链接:https://juejin.cn/post/7013674501116264484

收起阅读 »

iOS SwiftUI 创建和组合视图 1.0

创建和组合视图这个教程指导你构建一个名为Landmarks(地标)的应用。这个应用的功能是可以发现并分享你喜欢的地标。首先从创建地标详情页开始。Landmarks使用栈来按层组合图片、文本等视图元素,从而布局页面。在视图中添加地图,需要引入MapKit组件,在...
继续阅读 »

创建和组合视图

这个教程指导你构建一个名为Landmarks(地标)的应用。这个应用的功能是可以发现并分享你喜欢的地标。首先从创建地标详情页开始。

Landmarks使用栈来按层组合图片、文本等视图元素,从而布局页面。在视图中添加地图,需要引入MapKit组件,在你布局页面的过程中, Xcode可以提供实时的反馈,让你所做的改动立即转化成对应的代码实现。


第一节 创建新项目并体验画布

创建SwiftUI项目工程,体验画布、预览模式和SwiftUI模板代码

要想在Xcode中预览画布中的视图或者与画布中的视图进行交互,要求你的Mac系统版本号不低于macOS Catalina 10.15

create new project

步骤1 打开Xcode,在启动页面点击创建新工程或者在菜单中选择文件->新建->项目

create new project xcode

create new project xcode menu

步骤2 在项目模板选择器中,选择iOS作为项目平台,选项单视图应用(Single View App)作为项目模板,并点击下一步(Next)

create new project app template

步骤3 输入Landmarks作为项目名称,选择SwiftUI作为用户界面的创建方式,并点击下一步(Next),在磁盘目录下选择一个位置用来存放新创建的工程项目

create new project info

步骤4 工程创建好并打开后,在文件导航器中,选择ContentView.swift文件,可以浏览一下SwiftUI视图的组成结构。默认情况下,SwiftUI的视图文件包含两个结构体(Struct) 第一个结构体遵循View协议,描述视图的内容和布局。第二个结构体声明为第一个视图的预览视图。

步骤5 在**画布(Canvas)上,点击恢复(Resume)**按钮可以显示预览视图,也可以使用快捷键Command+Option+P

如果工程中没有出现画布(Canvas),可以选择菜单:编辑器(Editor) -> 编辑器和画布(Editor and Canvas) 打开画布进行预览

create new project completed

步骤6 在body属性内部,修改文字Hello World为其它的不同的文字,当你在改变代码的同时,预览视图也会实时的更新对应的内容变化

creating and combining views

第二节 定制文本视图(Text View)

可能通过修改代码来改变一个视图的显示样式,也可以通过检查器获取视图可修改属性,然后再写对应的代码改变样式。在创建应用的过程中,可以同时使用源码编辑器、画布或者检查器,无论当前使用的是哪一个工具编辑视图,代码会保持和这些编辑器展示的样式一致

customize text view

下面我们使用检查器来定制视图的显示样式

步骤1 在预览视图中,按下Command键的同时点击控件,会弹出一个编辑弹层,然后选择检查器(Inspect), 编辑弹层显示所有可以定制的视图属性,选中的控件不同,可以定制的属性集合也不相同

swift preview inspectror

步骤2 使用检查器把文字更改为Turtle Rock,也就是在应用中显示的第一个地标的名称

swiftui preivew inspector change text

步骤3 改变字体修改器为Title,使用系统字体修饰文字,可以自动按照用户在设备中设置的字体偏好大小进行调整。定制SwiftUI视图所调用的方法被称为视图修改器(Modifiers),修改器在原视图的基础上修改部分显示样式和属性,返回一个新的视图,这样就可以让多个修改器串连进行,形成水平方向的链式调用,或者垂直方向的堆叠调用

swiftui preview inspector change font

步骤4 手动在代码中添加foregroundColor(.green) 属性修改器,就会把文字的颜色调整为绿色。代码是决定视图样式的根本,当我们使用检查器来改变或移除一个属性修改器时,Xcode也会在代码编辑器中同步改变或移除对应的修改器代码

swiftui code change foreground color

步骤5 在代码编辑器中,按下Command的同时点击Text单词也可以属性弹窗,从中选择检查器后,再点击Color弹出菜单,选择继承(Inherited),让文字的颜色恢复成原来的黑色

swiftui code inspector resume font

步骤6 当我们移除 foregroundColor(.green) 时,Xcode会自动更新你的代码来反映视图的实际显示状况

swiftui xcode resume


收起阅读 »

了解Parcelable存在的意义

Parcelable是Google团队专门为Android设计的序列化类,那在Java中已经有了Serializable序列化为什么还需要Parcelable呢?我们接下来就通过阅读Parcelable的实现类和源码来比较它们的区别,建议先对Serializa...
继续阅读 »

Parcelable是Google团队专门为Android设计的序列化类,那在Java中已经有了Serializable序列化为什么还需要Parcelable呢?我们接下来就通过阅读Parcelable的实现类和源码来比较它们的区别,建议先对Serializable序列化原理有一个了解

1.实现类

我们看一个实现了Parcelable的实体类。

public class Person implements Parcelable {

private String name;
private int sex;
private int age;
private String phone;


protected Person(Parcel in) {
name = in.readString();
sex = in.readInt();
age = in.readInt();
phone = in.readString();
}

public static final Creator<Person> CREATOR = new Creator<Person>() {
@Override
public Person createFromParcel(Parcel in) {
return new Person(in);
}

@Override
public Person[] newArray(int size) {
return new Person[size];
}
};

@Override
public int describeContents() {
return 0;
}

@Override
public void writeToParcel(Parcel dest, int flags) {
dest.writeString(name);
dest.writeInt(sex);
dest.writeInt(age);
dest.writeString(phone);
}
}

describeContents()方法是默认实现,特殊情况才需要返回1,newArray(int size)方法也是默认实现就行。重点看createFromParcel()和writeToParcel()方法,见名知意writeToParcel()就是序列化方法,createFromParcel()是反序列化方法。不管是启动Activity时的传递对象还是AIDL中的使用,都是通过调用这两个方法来实现数据对象的序列化和反序列化。

2.源码分析

先分析序列化writeToParcel()方法,对数据的序列化操作都是通过传入的Parcel对象。

@Override
public void writeToParcel(Parcel dest, int flags) {
dest.writeString(name);
dest.writeInt(sex);
dest.writeInt(age);
dest.writeString(phone);
}

我们追踪写入String数据的进去看一下。

public final void writeString(@Nullable String val) {
writeString16(val);
}

继续深入。

public final void writeString16(@Nullable String val) {
mReadWriteHelper.writeString16(this, val);
}

调用了一个帮助类的方法,传入了自己和序列化数据。

public void writeString16(Parcel p, String s) {
p.writeString16NoHelper(s);
}

最终还是调用的Parcel方法。

public void writeString16NoHelper(@Nullable String val) {
nativeWriteString16(mNativePtr, val);
}

nativeWriteString16()是一个本地方法。

@FastNative
private static native void nativeWriteString16(long nativePtr, String val);

序列化到了这里也就追踪不下去了,那我们再看反序列化createFromParcel()方法。

public Person createFromParcel(Parcel in) {
return new Person(in);
}

对数据的操作也是通过Parcel对象。直接调用了Person有参构造方法,并传入了Parcel对象。

protected Person(Parcel in) {
name = in.readString();
sex = in.readInt();
age = in.readInt();
phone = in.readString();
}

我们也看下是如何读取String数据的。

public final String readString() {
return readString16();
}

再深入。

public final @Nullable String readString16() {
return mReadWriteHelper.readString16(this);
}

同样,还是调用了帮助类的方法,传入了自己。

public String readString16(Parcel p) {
return p.readString16NoHelper();
}

还是调用了Parcel对象的方法。

public @Nullable String readString16NoHelper() {
return nativeReadString16(mNativePtr);
}

再调用了本地方法。

@FastNative
private static native String nativeReadString16(long nativePtr);

反序列化也只能追踪到这里,会发现所有的操作都是通过Parcel类实现,但是序列化和反序列化的源码流程很简单,暴露给我们的过程很少,核心的数据处理都是采用的本地方法,会疑惑数据究竟存到哪里去了呢?其实是在本地开辟了一块共享内存,通过指针指向了这块内存,把数据存入了这里面。

3.Parcelable VS Serializable

  1. Parcelable只是对内存操作,并没有序列化成正在的二进制;而Serializable会被流操作对象序列化成二进制字节数据;
  2. Serializable中使用了大量的反射和临时变量,在性能上低于Parcelable;
  3. Serializable在使用时是传入到流对象进行序列化和反序列化处理,而Parcelable都是在内部实现序列化和反序列化,Parcelable更加灵活;

4.总结

根据上述分析,得出下面结论:

  1. Parcelable只适合在Android中进行IPC通信时使用,也建议优先采用,可提高性能;但是需要注意,因为Parcelable是对内存的操作,所以大量对象数据时,可能会造成内存溢出。
  2. Serializable可以在IPC、本地存储、网络传输中都可以使用,但是因为使用了大量反射和临时变量,相对于Parcelable在性能上稍逊。
收起阅读 »

高级UI事件分发、事件冲突处理

一、MotionEvent介绍二、事件的接收流程。可根据之前的结成介绍找到入口。viewRootImpl会对事件进行处理,首先找到DecorView,然后再找到activity再在dispatchTouchEvent()里处理。setView@ViewRoot...
继续阅读 »

一、MotionEvent介绍

image.png

二、事件的接收流程。

可根据之前的结成介绍找到入口。

viewRootImpl会对事件进行处理,首先找到DecorView,然后再找到activity再在dispatchTouchEvent()里处理。

setView@ViewRootImp.java
--> mInputEventReceiver = new WindowInputEventReceiver(inputChannel,Looper.myLooper());//接收事件的
-->WindowInputEventReceiver是内部类,事件在onInputEvent(InputEvent event)方法里处理
-->enqueueInputEvent()
-->doProcessInputEvents()
-->deliverInputEvent(q)
-->stage.deliver(q)(InputStage stage;ViewPostImeStage)
-->onprocess()
-->processPointerEvent(q);
//mView是DecorView
-->mView.dispatchPointerEvent(event)//这个方法是View.java
-->dispatchTouchEvent()//这个方法在DecorView.java
-->dispatchTouchEvent@Activity.java
-->getwindow().superDispatchTouchEvent(ev);



superDispatchTouchEvent@PhoneWindow.java
-->mDecor.superDispatchTouchEvent(event)
-->最终调用的是DispatchTouchEvent@ViewGroup.java//我们处理的事件分发机制
-->onTouchEvent()

//事件处理的方法
View.dispatchTouchEvent();

DecoreView.java的dispatchTouchEvent方法。

cb == activity
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
final Window.Callback cb = mWindow.getCallback();
return cb != null && !mWindow.isDestroyed() && mFeatureId < 0
? cb.dispatchTouchEvent(ev) : super.dispatchTouchEvent(ev);
}

三、父布局里有一个子view,点击子view的流程打印结果。

1.viewGroup

  • dispatchtouchevent()
  • onTouchEvent()
  • OnInterceptTouchEvent():返回true,子view被拦截

2. view

  • onclick()
  • onTouch()
  • dispatchTouchEvent()
  • onTouchEvent()

3. 打印结果

结论:每个事件都会经历父容器到子view


dispatchTouchEvent: 父容器
onInterceptTouchEvent: 父容器
dispatchTouchEvent: 子View
onTouch: 0
onTouchEvent: MotionEvent.ACTION_DOWN = 0


dispatchTouchEvent: 父容器
onInterceptTouchEvent: 父容器
dispatchTouchEvent: 子View
onTouch: 1
MotionEvent.ACTION_UP = 1
onClick

四、事件处理。

1.事件消费(没重写)

当源码中result == true的时候代表这个事件被消费了。

view#disPatchTouchEvent
-->onTouch()//此处执行onTouch方法,则后面的方法不执行。
-->onTouchEvent()//此处是执行onclick()方法的。
-->MotionEvent.Action_up
-->performClick()
-->performClickInternal()
-->onclick() //点击事件执行。
-->MotionEvent.Action_move
-->!pointInView()//当发现移动的时候移出了view,则up的时候就不会触发点击和长按的响应应。
-->MotionEvent.Action_down
-->checkForLongClick()//延时回调,长按的逻辑处理。当在500ms内触发up则取消长按。

2.viewGroup的dispatchTouchEvent的流程

  • 只有第一根手指按下会响应action_down。后续的所有手指都是action_Point_Down.

  • 最后抬起的那根手指是action_up。之前抬起的都是action_point_up

  • 最多识别32跟手指。int有多少位多少根手指。

viewGroup#dispatchTouchEvent
-->1.actionMasked == MotionEvent.ACTION_DOWN//不管是单指还是多指,会进入一次。重置状态
-->2.检测是否拦截。//OnInterceptTouchEvent
-->3.通过条件判断决定是否要分发事件。
-->进入循环判断子view里是否要处理这个事件。
-->4.当子view不处理,自己判断是否处理这个事件。
-->viewgroup进行自己处理事件是调用的view的dispatchTouchEvent()


换个角度去分析

第一块:

  • 是否拦截子view

第二块:

  • 遍历子view是否处理事件。

第三块:

  • 子view不处理,询问自己是否处理。
  • 子view处理,分情况。

大概分析一下

在第一块代码,Action_down的时候如果没有拦截子view,则会在第二个块代码遍历找到需要执行事件的view并把target.child记录下来。当后续的action_move就不会走第二块代码,之前记录的target.child去执行move事件。

五、viewgroup嵌套viewGroup事件触发分析

viewpager:横着滑动(左右滑动)。

listview:竖着滑动(上下滑动)。

1.当viewpager的oninterceptTouchEvent返回值为true。

上下不可以滑动,左右可以滑动

2.当viewpager的oninterceptTouchEvent返回值为false。

上下可以滑动,左右不可以滑动。

3.当viewpager的oninterceptTouchEvent返回值为false,当ListView的dispatchTouchEvent返回值为false。

上下不可以滑动,左右能滑动

如何实现上下可以,左右也可以滑动?

两个view叠加在一起,冲突是必然的。

冲突处理:

1.内部拦截法(子view根据条件来让事件由谁触发)要让子view拿到事件。

用此方法,必须能让子view能拿到事件。

子viewgroup

@Override
public boolean dispatchTouchEvent(MotionEvent event) {
int x = (int) event.getX();
int y = (int) event.getY();


switch (event.getAction()) {
case MotionEvent.ACTION_DOWN: {
//让父控件不去拦截自己
getParent().requestDisallowInterceptTouchEvent(true);
break;
}
case MotionEvent.ACTION_MOVE: {
int deltaX = x - mLastX;
int deltaY = y - mLastY;
// 这个条件由业务逻辑决定,看什么时候 子View将事件让出去
//左右滑动,就让父容器拦截。
if (Math.abs(deltaX) > Math.abs(deltaY)) {
getParent().requestDisallowInterceptTouchEvent(false);
}
break;
}
case MotionEvent.ACTION_UP: {
break;

}
default:
break;
}

mLastX = x;
mLastY = y;
return super.dispatchTouchEvent(event);
}

父viewgroup


// 拦截自己的孩子
@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
// down事件的时候不能拦截,因为这个时候 requestDisallowInterceptTouchEvent 无效
if (event.getAction() == MotionEvent.ACTION_DOWN) {
super.onInterceptTouchEvent(event);
return false;
}
return true;
}

2.外部拦截法(由父容器来根据条件让事件由谁来触发)


// 外部拦截法:一般只需要在父容器处理,根据业务需求,返回true或者false
public boolean onInterceptTouchEvent(MotionEvent event) {
int x = (int) event.getX();
int y = (int) event.getY();

switch (event.getAction()) {
case MotionEvent.ACTION_DOWN: {
mLastX = (int) event.getX();
mLastY = (int) event.getY();
break;
}
case MotionEvent.ACTION_MOVE: {
int deltaX = x - mLastX;
int deltaY = y - mLastY;
if (Math.abs(deltaX) > Math.abs(deltaY)) {
return true;
}
break;
}
case MotionEvent.ACTION_UP: {
break;
}
default:
break;
}

return super.onInterceptTouchEvent(event);
}


收起阅读 »

Android WebView H5 秒开方案总结

为了满足跨平台和动态性的要求,如今很多 App 都采用了 Hybrid 这种比较成熟的方案来满足多变的业务需求。Hybrid 也叫混合开发,即半原生半 H5 的方式,通过 WebView 来实现需要高度灵活性的业务,在需要和 Native 做交互或者是调用特定...
继续阅读 »

为了满足跨平台和动态性的要求,如今很多 App 都采用了 Hybrid 这种比较成熟的方案来满足多变的业务需求。Hybrid 也叫混合开发,即半原生半 H5 的方式,通过 WebView 来实现需要高度灵活性的业务,在需要和 Native 做交互或者是调用特定平台能力时再通过 JsBridge 来实现两端交互

采取 Hybrid 方案的理由可以有很多个:实现跨平台和动态更新、保持各端之间业务和逻辑的统一、满足快速开发的需求;而放弃 Hybrid 方案的理由只需要一个:性能相对 Native 来说要差得多。WebView 比较让人诟病的一点就是性能相对 Native 来说比较差,经常需要 load 一段时间后才能加载完成,用户体验较差。开发者在实现了基本的业务需求后,也需要来进一步优化用户体验。目前也已经有很多通用的手段来优化 WebView 展示首屏页面的时间和性能成本,而这些优化手段也不单单局限于某个平台,对于 Android 和 IOS 来说大多都是通用的,当然这也离不开前端和服务端的支持。本文就来对这些优化方案做一个总结,希望对你有所帮助 🤣🤣

一、性能瓶颈

想要优化 WebView,就需要先知道限制了 WebView 的性能瓶颈到底有哪几方面

百度 APP 曾经统计了其某一天全网用户的落地页首屏展现速度 80 分位数据,从点击到首屏展现(首图加载完成),大致需要 2600 ms

百度的开发人员将这一整个过程划分为了四个阶段,并统计出了各个阶段的平均耗时

  • 初始化 Native App 组件,花费了 260 ms。主要工作是:初始化 WebView。首次创建 WebView 的耗时均值为 500 ms,第二次创建 WebView 时会快很多
  • 初始化 Hybrid,花费了 170 ms。主要工作是:根据调起协议中传入的相关参数,校验解压下发到本地的 Hybrid 模板,大致需要 100 ms 的时间;WebView.loadUrl 执行后,触发对 Hybrid 模板头部和 Body 的解析
  • 加载正文数据和渲染页面,花费了 1400 ms。主要工作是:加载解析页面所需的 JS 文件,并通过 JS 调用端能力发起对正文数据的请求,客户端从 Server 拿到数据后,用 JsCallback 的方式回传给前端,前端需要对客户端传来的 JSON 格式的正文数据进行解析,并构造 DOM 结构,进而触发内核的渲染流程;此过程中,涉及到对 JS 的请求,加载、解析、执行等一系列步骤,并且存在端能力调用、JSON 解析、构造 DOM 等操作,较为耗时
  • 加载图片,花费了 700 ms(图片貌似标错了,此处统计的应该是从渲染正文结束首图加载完成之间的时间)。主要工作是:在上一步中,前端获取到的正文数据包含落地页的图片地址集,在完成正文的渲染后,需要前端再次执行图片请求的端能力,客户端这边接收到图片地址集后按顺序请求服务器,完成下载后,客户端会调用一次 IO 将文件写入缓存,同时将对应图片的本地地址回传给前端,最终通过内核再发起一次 IO 操作获取到图片数据流,进行渲染

可以看到,最耗时的就是 加载正文数据和渲染页面 和 加载图片 两个阶段,需要进行多次网络请求、JS 调用、IO 读写;其次是 初始化 WebView 和 加载模板文件 两个阶段,这两个阶段耗时相近,虽然基本不用进行网络请求,但涉及到对浏览器内核和模板文件的初始化操作,存在一些无法避免的时间花费

从这就可以得出最基本的优化方向:

  • 初始化的时间是否可以更快一点?例如,WebView 和模板文件的初始化时间是否可以更少一点? 能不能提前完成这些任务?
  • 完成首屏页面的前置任务是否可以更少一点?例如,网络请求、JS 调用、IO 读写的次数是否可以更少一点? 是否可以合并或者提前完成这些任务?
  • 资源文件的加载时间是否可以更快一点?例如,图片、JS、CSS 文件的请求次数是否可以更少一点? 能不能直接使用本地缓存?网络请求速度是否可以更快一点?

二、WebView 预加载

创建 WebView 属于一个比较耗时的操作,特别是在第一次创建的时候由于需要初始化浏览器内核,会耗时几百毫秒,之后再次创建 WebView 就会快很多,但也还需要几十毫秒。为了避免每次使用时都需要同步等待 WebView 创建完成,我们可以选择在合适的时机 预加载 WebView 并存入 缓存池 中,等要用到时再直接从缓存池中取,从而缩短显示首屏页面的时间

想要进行预加载,那就要思考以下两个问题该如何解决:

  • 触发时机如何选?

    既然创建 WebView 属于一个比较耗时的操作,那我们在预加载时一样可能会拖慢当前主线程,这样相当于只是把耗时操作提前了而已,我们需要保证预加载操作不会影响到当前主线程任务

  • Context 如何选?

    WebView 需要和 Context 进行绑定,且每个 WebView 应该是对应于特定的 Activity Context 实例的,不能直接使用 Application 来创建 WebView,我们需要保证预加载的 WebView Context 和最终的 Context 之间的一致性

第一个问题可以通过 IdleHandler 来解决。通过 IdleHandler 提交的任务只有在当前线程关联的 MessageQueue 为空的情况下才会被执行,因此通过 IdleHandler 来执行预创建可以保证不会影响到当前主线程任务

第二个问题可以通过 MutableContextWrapper 来解决。顾名思义,MutableContextWrapper 是系统提供的 Context 包装类,其内部包含一个 baseContext,MutableContextWrapper 所有的内部方法都会交由 baseContext 来实现,且 MutableContextWrapper 允许外部替换它的 baseContext,因此我们可以在一开始的时候使用 Application 作为 baseContext,等到 WebView 和 Activity 进行实际绑定的时候再来替换

最终预加载 WebView 的大致逻辑就如下所示。我们可以在 PageFinished 或者退出 WebViewActivity 的时候就主动调用 prepareWebView() 方法来进行预加载,需要用到的时候就从缓存池中取出来动态添加到布局文件中

/**
* @Author: leavesC
* @Date: 2021/10/4 18:57
* @Desc:
* @公众号:字节数组
*/

object WebViewCacheHolder {

private val webViewCacheStack = Stack<RobustWebView>()

private const val CACHED_WEB_VIEW_MAX_NUM = 4

private lateinit var application: Application

fun init(application: Application) {
this.application = application
prepareWebView()
}

fun prepareWebView() {
if (webViewCacheStack.size < CACHED_WEB_VIEW_MAX_NUM) {
Looper.myQueue().addIdleHandler {
log("WebViewCacheStack Size: " + webViewCacheStack.size)
if (webViewCacheStack.size < CACHED_WEB_VIEW_MAX_NUM) {
webViewCacheStack.push(createWebView(MutableContextWrapper(application)))
}
false
}
}
}

fun acquireWebViewInternal(context: Context): RobustWebView {
if (webViewCacheStack.isEmpty()) {
return createWebView(context)
}
val webView = webViewCacheStack.pop()
val contextWrapper = webView.context as MutableContextWrapper
contextWrapper.baseContext = context
return webView
}

private fun createWebView(context: Context): RobustWebView {
return RobustWebView(context)
}

}

此方案虽然无法缩减创建 WebView 所需的时间,但可以缩短完成首屏页面的时间。需要注意,对 WebView 进行缓存采取的是用空间换时间的做法,需要考虑低端机型运存较小的情况

三、渲染优化

想要优化首屏的渲染速度,首先得从整个页面访问请求的链路上看,借用阿里巴巴淘系技术的一张图,下面是常规端上 H5 页面访问链路

这一整个过程需要完成多个网络请求和 IO 操作,WebView 在加载了基本的 HTML 和 CSS 文件后,再通过 JS 从服务端获取正文数据,拿到数据后还需要完成解析 JSON、构造 DOM、应用 CSS 样式等一系列耗时操作,最终才能由内核进行渲染上屏

移动端的系统版本、处理器速度、运存大小是完全不受我们控制的,且极容易受网络波动的影响,网络链接的耗时是非常长且不可控的。如果 WebView 每次渲染都重复经历以上整个步骤,那用户的使用体验就是完全不可控的,此时可以尝试通过以下方法来进行优化

预置离线包

  • 精简并抽取公共的 JS 和 CSS 文件作为通用的页面模板,可以按业务类型来生成多套模板文件,每次打包时均预置最新的模板文件到客户端中,每套模板文件均有特定的版本号,App 在后台定时去静默更新。通过这种方式来避免每次使用都要去联网请求,从而缩短总耗时
  • 一般情况下,WebView 会在加载完主 HTML 之后才去加载 HTML 中的 JS 和 CSS 文件,先后需要进行多次 IO 操作,我们可以将 JS 和 CSS 还有一些图片都内联到一个文件中,这样加载模板时就只需要一次 IO 操作,也大大减少了因为 IO 加载冲突导致模板加载失败的问题

并行请求

  • H5 在加载模板文件的同时,由 Native 端来请求正文数据,Native 端再通过 JS 将正文数据传给 H5,以此来实现并行请求从而缩短总耗时

预加载

  • 当模板和正文数据分离之后,由于 WebView 每次使用的都是同一个模板文件,因此我们并不需要在用户进入页面的时候才去加载模板,可以直接在预加载 WebView 的同时就让其预热加载模板,这样每次使用时仅需要将正文数据传给 H5,H5 收到数据后直接进行页面渲染即可
  • 对于 Feed 流,可以通过一定策略去预加载正文数据,当用户点击查看详情时,最理想情况下就可以直接使用缓存的数据,避免受到网络的影响

延迟加载

  • 呈现首屏页面所需要的依赖项越多,就意味着用户需要的等待时间就越长,因此要尽可能地减少在首屏完成前执行的操作,对于一些非首屏必需的网络请求、 JS 调用、埋点上报等,都可以后置到首屏显示后再执行

页面静态直出

  • 并行请求正文数据虽然能够缩短总耗时,但还是需要完成解析 JSON、构造 DOM、应用 CSS 样式等一系列耗时操作,最终才能交由内核进行渲染上屏,此时 组装 HTML 这个操作就显得比较耗时了。为了进一步缩短总耗时,可以改为由后端对正文数据和前端代码进行整合,直出首屏内容,直出后的 HTML 文件已经包含了首屏展现所需的内容和样式,无需进行二次加工,内核可以直接渲染。其它动态内容可以在渲染完首屏后再进行异步加载
  • 由于客户端可能向用户提供了控制 WebView 字体大小,夜间模式的选项,为了保证首屏渲染结果的准确性,服务端直出的 HTML 就需要预留一些占位符用于后续动态回填,客户端在 loadUrl 之前先利用正则匹配的方式查找这些占位字符,按照协议映射成端信息。经过客户端回填处理后的 HTML 内容就已经具备了展现首屏的所有条件

复用 WebView

  • 更进一步的做法就是可以尝试复用 WebView。由于 WebView 使用的模板文件已经是固定的了,因此我们可以在 WebView 预加载缓存池的基础上增加复用 WebView 的逻辑,当 WebView 使用完毕后可以将其正文数据全部清空并再次存入缓存池中,等下次需要时就可以直接注入新的正文数据进行复用了,从而减少了频繁创建 WebView 和预热模板文件带来的开销

视觉优化

实现以上的优化方案后,页面的展现速度已经很快了,但在实际开发中还是会发现存在 Activity 切换过程中无法渲染 H5 页面的问题,产生视觉上的白屏现象,这可以通过开发者模式放慢动画时间来验证

从下图可以看到在 Activity 切换过程中的确是有一段明显的白屏过程

通过研究系统源码可以知道,在系统版本大于等于 4.3,小于等于 6.0 之间,ViewRootImpl 在处理 View 绘制的时候,会通过一个布尔变量 mDrawDuringWindowsAnimating 来控制 Window 在执行动画的过程中是否允许进行绘制,该字段默认为 false,我们可以利用反射的方式去手动修改这个属性,避免这个白屏效果

这个方案基本也只适用于 Android 6.0 版本了,更低的系统版本也很少进行适配了

/**
* 让 activity transition 动画过程中可以正常渲染页面
*/

fun setDrawDuringWindowsAnimating(view: View) {
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.M
|| Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR1
) {
//小于 4.3 和大于 6.0 时不存在此问题,无须处理
return
}
try {
val rootParent: ViewParent = view.rootView.parent
val method: Method = rootParent.javaClass
.getDeclaredMethod("setDrawDuringWindowsAnimating", Boolean::class.javaPrimitiveType)
method.isAccessible = true
method.invoke(rootParent, true)
} catch (e: Throwable) {
e.printStackTrace()
}
}

优化后的效果

四、Http 缓存策略

在上一步的渲染优化中就涉及到了对网络请求的优化,包括 减少网络请求次数、并行执行网络请求、网络请求预执行 等。对于应用来说,网络请求是不可避免的,但我们可以通过设定缓存策略来避免重复执行网络请求,或者是可以用比较低的成本来完成非首次的网络请求,这就涉及到了和 Http 缓存相关的知识点

WebView 一共支持以下四种缓存策略,默认使用的是 LOAD_DEFAULT,该策略就属于 Http 缓存策略

  • LOAD_CACHE_ONLY:只使用本地缓存,不进行网络请求
  • LOAD_NO_CACHE:不使用本地缓存,只通过网络请求
  • LOAD_CACHE_ELSE_NETWORK:只要本地有缓存就进行使用,否则就通过网络请求
  • LOAD_DEFAULT:根据 Http 协议来决定是否进行网络请求

以请求网络上一个静态文件为例,查看其响应头,当中的 Cache-Control、Expires、Etag、Last-Modified 等信息就定义了具体的缓存策略

Cache-Control、Expires

Cache-Control 是 Http 1.1 中新增加的一个用来定义资源缓存策略的报文头,它由一些定义一个响应资源应该何时被缓存、如何被缓存以及缓存多长时间的指令组成,可选值有很多种:no-cache、no-store、only-if-cached、max-age 等,比如上图所示就使用到了 max-age 来设定资源的最大有效时间,时间单位为秒

Expires 是 Http 1.0 中规定的字段,含义和 Cache-Control 类似,但由于 Expires 可能会因为客户端和服务端的时间不一致造成缓存失效,因此现在主要使用的是 Cache-Control,在优先级上也是 Cache-Control 更高

Cache-Control 也是一个通用的 Http 报文头字段,它可以分别在请求头和响应头中使用,具有不同的含义,以 max-age 为例:

  • 请求头:客户端用于告知服务端,希望接收一个有效期不大于 max-age 的资源
  • 响应头:服务端用于告知客户端,该资源在请求发起后的 max-age 时间内均是有效的,上图所示的 2592000 秒也即 30 天,客户端在第一次发起请求后的 30 天内无需再向服务端进行请求,可以直接使用本地缓存

如果在 WebView 中使用了 LOAD_DEFAULT 的话,就会遵循此 Http 缓存策略,在有效期内 WebView 会直接使用本地缓存

ETag、Last-Modified

Cache-Control 避免了 WebView 在有效期内去重复请求资源,有效期过了后 WebView 就还是需要重新去请求网络,但此时服务端的资源也许并没有发生变化,WebView 依然可以使用本地缓存,此时客户端就需要依靠 ETag 和 Last-Modified 这两个报文头来向服务器确认该资源是否可以继续使用

在第一次请求资源的时候,响应头中就包含了 ETag 和 Last-Modified,这两个报文头就用来唯一标识该资源文件

  • ETag:用于作为资源的唯一标识信息
  • Last-Modified:用于记录资源的最后一次修改时间

等客户端判断到 max-age 已过期后,就会携带这两个报文头去执行网络请求,服务端就通过这两个标识符来判断客户端的缓存资源是否可以继续使用

如下图所示,在有效期过后,客户端会在 If-None-Match 请求头中携带上第一次网络请求时拿到的 ETag 值。实际上 ETag 和 Last-Modified 可以只使用一个,以下就只使用到了 ETag;如果要传递 Last-Modified 的话,对应的请求头就是 If-Modified-Since

如果服务端判断出资源已过期,就会返回新的资源文件,此时就相当于在第一次请求资源文件,后续操作就和一开始保持一致;如果服务端判断资源还未过期,则会返回一个 304 状态码,告知客户端可以继续使用本地缓存,客户端同时更新 max-age 值,重复一开始的的缓存失效规则,这样客户端就可以用极低的成本来完成本次网络请求,这在请求的资源文件比较大的时候特别有用

但 Http 缓存策略也存在一些问题需要注意,即如何保证用户在资源更新了时能马上感知到且重新下载最新资源。假设服务端在资源有效期内更新了资源内容,此时由于客户端还处于 max-age 阶段,无法马上感知到资源已更新,从而造成更新不及时。一种比较好的解决方案就是:要求服务端在每次更新资源文件时都为其生成一个新的名字,可以用 hash 值或者随机数命名,而资源文件依托的主文件在每次发版时都引用最新的资源文件路径,从而保证客户端能够马上就感知到资源已更新,从而保证及时更新。而且,通过这种方案,既可以为资源文件设定一个非常大的 max-age 值,尽量让客户端只使用本地缓存,又可以保证每次发版时客户端都能及时更新

所以说,通过合理地设定 Http 缓存策略,一方面能够很明显地减少服务器网络带宽消耗、降低服务器的压力和开销,另一方面也可以减少客户端网络延迟的情况、避免重复请求资源文件、加快页面的打开速度,毕竟加载本地缓存文件的开销怎样都要比从网络上加载低得多

五、拦截请求与共享缓存

如今的 WebView 页面往往是图文混排的,图片是资讯类应用的重要表现形式,WebView 获取图片资源的传统方案有以下两种:

  • H5 端自己通过网络请求去下载资源。优点:实现简单,各端之间可以只专注自己的业务。缺点:两端之间的无法共享缓存,造成资源重复请求,流量浪费
  • H5 端通过调用 Native 的图片下载和缓存能力来获取资源。优点:可以实现两端之间的缓存共享。缺点:需要由 H5 端来主动触发 Native 执行,时机较为延迟,且需要通过多次 JS 调用完成资源传递,存在效率问题

以上两种方案都存在着一些缺点,要么是无法共享缓存,要么是存在效率问题,这里就再介绍一种改进方案:

实际上,WebViewClient 提供了一个 shouldInterceptRequest 方法用于支持外部去拦截请求,WebView 每次在请求网络资源时都会回调该方法,方法入参就包含了 Url,Header 等请求参数,返回值 WebResourceResponse 即代表获取到的资源对象,默认是返回 null,即由浏览器内核自己去完成网络请求

我们可以通过该方法来主动拦截并完成图片的加载操作,这样我们既可以使得两端的资源文件得以共享,也避免了多次 JS 调用带来的效率问题

大致实现就如下所示,这里我通过 OkHttp 来代理实现网络请求

/**
* @Author: leavesC
* @Date: 2021/10/4 18:56
* @Desc:
* @公众号:字节数组
*/

object WebViewInterceptRequestProxy {

private lateinit var application: Application

private val webViewResourceCacheDir by lazy {
File(application.cacheDir, "RobustWebView")
}

private val okHttpClient by lazy {
OkHttpClient.Builder().cache(Cache(webViewResourceCacheDir, 100L * 1024 * 1024))
.followRedirects(false)
.followSslRedirects(false)
.addNetworkInterceptor(
ChuckerInterceptor.Builder(application)
.collector(ChuckerCollector(application))
.maxContentLength(250000L)
.alwaysReadResponseBody(true)
.build()
)
.build()
}

fun init(application: Application) {
this.application = application
}

fun shouldInterceptRequest(webResourceRequest: WebResourceRequest?): WebResourceResponse? {
if (webResourceRequest == null || webResourceRequest.isForMainFrame) {
return null
}
val url = webResourceRequest.url ?: return null
if (isHttpUrl(url)) {
return getHttpResource(url.toString(), webResourceRequest)
}
return null
}

private fun isHttpUrl(url: Uri): Boolean {
val scheme = url.scheme
log("url: $url")
log("scheme: $scheme")
if (scheme == "http" || scheme == "https") {
return true
}
return false
}

private fun getHttpResource(
url: String,
webResourceRequest: WebResourceRequest
): WebResourceResponse? {
val method = webResourceRequest.method
if (method.equals("GET", true)) {
try {
val requestBuilder =
Request.Builder().url(url).method(webResourceRequest.method, null)
val requestHeaders = webResourceRequest.requestHeaders
if (!requestHeaders.isNullOrEmpty()) {
var requestHeadersLog = ""
requestHeaders.forEach {
requestBuilder.addHeader(it.key, it.value)
requestHeadersLog = it.key + " : " + it.value + "\n" + requestHeadersLog
}
log("requestHeaders: $requestHeadersLog")
}
val response = okHttpClient.newCall(requestBuilder.build())
.execute()
val body = response.body
if (body != null) {
val mimeType = response.header(
"content-type", body.contentType()?.type
).apply {
log(this)
}
val encoding = response.header(
"content-encoding",
"utf-8"
).apply {
log(this)
}
val responseHeaders = mutableMapOf<String, String>()
var responseHeadersLog = ""
for (header in response.headers) {
responseHeaders[header.first] = header.second
responseHeadersLog =
header.first + " : " + header.second + "\n" + responseHeadersLog
}
log("responseHeadersLog: $responseHeadersLog")
var message = response.message
val code = response.code
if (code == 200 && message.isBlank()) {
message = "OK"
}
val resourceResponse =
WebResourceResponse(mimeType, encoding, body.byteStream())
resourceResponse.responseHeaders = responseHeaders
resourceResponse.setStatusCodeAndReasonPhrase(code, message)
return resourceResponse
}
} catch (e: Throwable) {
log("Throwable: $e")
}
}
return null
}

private fun getAssetsImage(url: String): WebResourceResponse? {
if (url.contains(".jpg")) {
try {
val inputStream = application.assets.open("ic_launcher.webp")
return WebResourceResponse(
"image/webp",
"utf-8", inputStream
)
} catch (e: Throwable) {
log("Throwable: $e")
}
}
return null
}

}

采用此方案的好处有:

  • 通过 OkHttp 本身的 Cache 功能来实现资源缓存,并不局限于特定的文件类型,可以用于图片、HTML、JS、CSS 等多种类型
  • OkHttp 是完全遵循 Http 协议的,我们可以在这基础上来自由扩展 Http 缓存策略
  • 解耦了客户端和前端代码,由客户端充当 Server 的角色,对于前端来说是完全无感知的,用比较低的成本就实现了两端缓存共享
  • WebView 自带的缓存机制允许的最大缓存空间是比较小的,此方案相当于突破了 WebView 的最大缓存容量限制
  • 如果移动端已经预置了离线包,那么就可以通过此方案判断离线包是否已经包含目标文件,存在的话直接使用,否则才联网请求,参照上述的 getAssetsImage 方法

需要注意,以上只是一份示例代码,并不能直接用于生产环境,读者需要根据具体业务去进行扩展。Github 上也有一个通过此方案实现了 WebView 缓存复用的开源库,读者可以去借鉴其思路:CacheWebView

六、DNS 优化

DNS 也即域名解析,指代的是将域名转换为具体的 IP 地址的过程。DNS 会在系统级别进行缓存,如果已经解析过某域名,那么在下次使用时就可以直接去访问已知的 IP 地址,而不用先发起 DNS 再访问 IP 地址

如果 WebView 访问的主域名和客户端的不一致,那么 WebView 在首次访问线上资源时,就需要先完成域名解析才能开始资源请求,这个过程就需要多耗费几十毫秒的时间。因此最好就是保持客户端整体 API 地址、资源文件地址、WebView 线上地址的主域名都是一致的

七、CDN 加速

CDN 的全称是 Content Delivery Network,即内容分发网络。CDN 是构建在现有网络基础之上的智能虚拟网络,依靠部署在各地的边缘服务器,通过中心平台的负载均衡、内容分发、调度等功能模块,使用户就近获取所需内容,降低网络拥塞,提高用户访问响应速度和命中率

通过将 JS、CSS、图片、视频等静态类型文件托管到 CDN,当用户加载网页时,就可以从地理位置上最接近它们的服务器接收这些文件,解决了远距离访问和不同网络带宽线路访问造成的网络延迟情况

八、白屏检测

在正常情况下,完成上述的优化措施后用户基本是可以秒开 H5 页面的了。但异常情况总是会有的,用户的网络环境和系统环境千差万别,甚至 WebView 也可能发生内部崩溃。当发生问题时,用户看到的可能就直接只是一个白屏页面了,所以进一步的优化手段就是需要去检测是否发生白屏以及相应的应对措施

检测白屏最直观的方案就是对 WebView 进行截图,遍历截图的像素点的颜色值,如果非白屏颜色的颜色点超过一定的阈值,就可以认为不是白屏。字节跳动技术团队的做法是:通过 View.getDrawingCache()方法去获取包含 WebView 视图的 Bitmap 对象,然后把截图缩小到原图的 1/6,遍历检测图片的像素点,当非白色的像素点大于 5% 的时候就可以认为是非白屏的情况,可以相对高效且准确地判断出是否发生了白屏

当检测到白屏后,如果发现怎么重试也无法成功,那就只能进行降级处理了,放弃上述的优化措施,直接加载线上的详情页,优先保证用户体验


收起阅读 »

Flutter怎么样做国际化

什么是国际化 国际化是指在设计软件时,将软件与特定语言及地区脱钩的过程。当软件被移植到不同的语言地区时,软件本身不用做内部工程上的改变或修正。 本地化则是指当移植软件时,加上与特定区域设置有关的资讯和翻译文件的过程。 国际化和本地化之间的区别虽然微妙,但却很重...
继续阅读 »

什么是国际化


国际化是指在设计软件时,将软件与特定语言及地区脱钩的过程。当软件被移植到不同的语言地区时,软件本身不用做内部工程上的改变或修正。


本地化则是指当移植软件时,加上与特定区域设置有关的资讯和翻译文件的过程。 国际化和本地化之间的区别虽然微妙,但却很重要。国际化意味着产品有适用于任何地方的潜力;本地化则是为了更适合于特定地方的使用,而另外增添的特色。用一项产品来说,国际化只需做一次,但本地化则要针对不同的区域各做一次。 这两者之间是互补的,并且两者结合起来才能让一个系统适用于各地。


国际化实现中的困难


开发软件时,国际化和本地化对开发者是一个有挑战性的任务,特别是当软件当初设计时没有考虑这个问题时。通常做法是将文本和其他环境相关的资源与程序代码相分离。这样在理想的情况下,应对变化的环境时无需修改代码,只要修改资源,从而显著简化了工作。


Flutter的国际化


Flutter中的国际化包括Flutter组件的国际化和其他文本的国际化两者;


Flutter组件的国际化


Flutter给我们提供的Widget默认情况下就是支持国际化,但是在没有进行特别的设置之前,它们无论在什么环境都是以英文的方式显示的。


如果想要添加其他语言,你的应用必须指定额外的 MaterialApp 属性并且添加一个单独的 package,叫做 flutter_localizations


截至 2020 年 11 月,该软件包支持 78 种语言。


pubspec添加依赖


想要使用 flutter_localizations 的话,我们需要在 pubspec.yaml 文件中添加它作为依赖:


dependencies:
flutter:
sdk: flutter
flutter_localizations:
sdk: flutter

设置MaterialApp




  • 在localizationsDelegates中指定哪些Widget需要进行国际化



    • 用于生产本地化值集合的工厂

    • 我们这里指定了Material、Widgets、Cupertino都使用国际化




  • supportedLocales指定要支持哪些国际化



    • 我们这里指定中文和英文(也可以指定国家编码)




MaterialApp(
localizationsDelegates: [
GlobalMaterialLocalizations.delegate, // 指定本地化的字符串
GlobalCupertinoLocalizations.delegate, // 对应的Cupertino风格
GlobalWidgetsLocalizations.delegate // 指定默认的文本排列方向, 由左到右或由右到左
],
supportedLocales: [
Locale("en"),
Locale("zh")
],
)

注意:如果要指定语言代码、文字代码和国家代码,可以进行如下指定方式:


// Full Chinese support for CN, TW, and HK
supportedLocales: [
const Locale.fromSubtags(languageCode: 'zh'), // generic Chinese 'zh'
const Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hans'), // generic simplified Chinese 'zh_Hans'
const Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant'), // generic traditional Chinese 'zh_Hant'
const Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hans', countryCode: 'CN'), // 'zh_Hans_CN'
const Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant', countryCode: 'TW'), // 'zh_Hant_TW'
const Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant', countryCode: 'HK'), // 'zh_Hant_HK'
],

Flutter中自定义文本的国际化


创建本地化类


该类用于定义我们需要进行本地化的字符串等信息:



  • 1.我们需要一个构造器,并且传入一个Locale对象

  • 2.定义一个Map,其中存放我们不同语言对应的文本

  • 3.定义它们对应的getter方法,根据语言环境返回不同的结果


import 'package:flutter/material.dart';

class QWLocalizations {
final Locale locale;

QWLocalizations(this.locale);

static Map<String, Map<String, String>> _localizedValues = {
"fr": {"title": "Titre", "hello": "Bonjour"},
"zh": {"title": "首页", "hello": "你好"}
};

String get title {
return _localizedValues[locale.languageCode]?["title"] ?? 'title';
}

String get hello {
return _localizedValues[locale.languageCode]?["hello"] ?? 'hello';
}

static QWLocalizations of(BuildContext context) {
return Localizations.of(context, QWLocalizations);
}
}

自定义Delegate


上面的类定义好后,我们在什么位置或者说如何对它进行初始化呢?
我们可以像Flutter Widget中的国际化方式一样对它们进行初始化,也就是我们可以定义一个对象的Delegate类,并且将其传入localizationsDelegates中;


Delegate的作用就是当Locale发生改变时,调用对应的load方法,重新加载新的Locale资源


HYLocalizationsDelegate需要继承自LocalizationsDelegate,并且有三个方法必须重写:
isSupported,shouldReload,load


import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';

import 'QWLocalizations.dart';

class QWLocalizationsDelegate extends LocalizationsDelegate<QWLocalizations> {
//是否在我们支持的语言范围
@override
bool isSupported(Locale locale) {
return ["fr", "zh"].contains(locale.languageCode);
}

/*
* 当Localizations Widget重新build时,是否调用load方法重新加载Locale资源
一般情况下,Locale资源只应该在Locale切换时加载一次,不需要每次Localizations重新build时都加载一遍;
所以一般情况下返回false即可;
* */
@override
bool shouldReload(LocalizationsDelegate<QWLocalizations> old) {
return false;
}

/*
* 当Locale发生改变时(语言环境),加载对应的HYLocalizations资源
这个方法返回的是一个Future,因为有可能是异步加载的;
但是我们这里是直接定义的一个Map,因此可以直接返回一个同步的Future(SynchronousFuture)
* */
@override
Future<QWLocalizations> load(Locale locale) {
return SynchronousFuture(QWLocalizations(locale));
}

static QWLocalizationsDelegate delegate = QWLocalizationsDelegate();
}

异步加载数据


假如我们的数据是异步加载的,比如来自Json文件或者服务器,应该如何处理呢?


QWLocalizations类中如下面代码:


  static Map<String, Map<String, String>> _localizedValues = {};

Future<bool> loadJson() async {
// 1.加载json文件
String jsonString = await rootBundle.loadString("assets/json/i18n.json");

// 2.转成map类型
final Map<String, dynamic> map = json.decode(jsonString);

// 3.注意:这里是将Map<String, dynamic>转成Map<String, Map<String, String>>类型
_localizedValues = map.map((key, value) {
return MapEntry(key, value.cast<String, String>());
});
return true;
}

在QWLocalizationsDelegate中使用异步进行加载:


  @override
Future<QWLocalizations> load(Locale locale) async {
final localization = QWLocalizations(locale);
await localization.loadJson();
return localization;
}

使用本地化类


接着我们可以在代码中使用HYLocalization类。



  • 我们可以通过QWLocalizations.of(context)获取到QWLocalizations对象


Text(
QWLocalizations.of(context).hello,
)

国际化的工具---Intl


认识arb文件


目前我们已经可以通过加载对应的json文件来进行本地化了。


但是还有另外一个问题,我们在进行国际化的过程中,下面的代码依然需要根据json文件手动编写


String get title {
return _localizedValues[locale.languageCode]?["title"] ?? 'title';
}

String get hello {
return _localizedValues[locale.languageCode]?["hello"] ?? 'hello';
}

有没有一种更好的方式,让我们可以快速在本地化文件即dart代码文件直接来转换呢?答案就是arb文件



  • arb文件全称Application Resource Bundle,表示应用资源包,目前已经得到Google的支持;

  • 其本质就是一个json文件,但是可以根据该文件转成对应的语言环境;

  • arb的说明文档:github.com/google/app-…


使用IDE插件来进行arb和dart文件之间的转换




  • 初始化intl




选择工具栏Tools - Flutter Intl - Initialize for the Project



完成上面的操作之后会自动生成如下文件目录:



  • generated是自动生成的dart代码

  • I10n是对应的arb文件目录



使用intl


在localizationsDelegates中配置生成的class,名字是S



  • 1.添加对应的delegate

  • 2.supportedLocales使用S.delegate.supportedLocales


localizationsDelegates: [
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
S.delegate
],
supportedLocales: S.delegate.supportedLocales,

因为我们目前还没有对应的本地化字符串,所以需要在intl_en.arb文件中编写:


{
"title": "home",
"hello": "hello"
}



  • 编写后ctrl(command) + s保存即可;




之后按照如下格式在代码中使用


S.of(context).title

添加中文


如果希望添加中文支持:add local




  • 在弹出框中输入zh即可


我们会发现,会生成对应的intl_zh.arb和messages_zh.dart文件



arb其它语法


如果我们希望在使用本地化的过程中传递一些参数:



  • 比如hello kobe或hello james

  • 比如你好啊,李银河或你好啊,王小波


修改对应的arb文件:



  • {name}:表示传递的参数


{
"title": "home",
"hello": "hello",
"sayHello": "hello {name}"
}

在使用时,传入对应的参数即可:


Text(S.of(context).sayHello("李银河")),

总结


文本的国际化实质就是根据系统提供的locale信息去获取对应的文本和对UI做相应操作(指从左到右还是从右到左展示),locale信息是指国家代码、地区代码等,通常我们本地需要做的就是把文案按照{国家代码:{通用文本:本地化文案}}的格式进行组织排列。这里国家代码比如中国是zh,美国是en。通用文本一般用英文。最后就是根据locale信息和通用文本去字典获取本地化文本值的过程。


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

Flutter 绘制番外篇 - 圆中取形

前言: 对一些有趣的绘制 技能和知识, 我会通过 [番外篇] 的形式加入《Flutter 绘制指南 - 妙笔生花》小册中,一方面保证小册的“与时俱进” 和 “活力”。另一方面,是为了让一些重要的知识有个 好的归宿。 一、正 N 边形的绘制 1. 正三角形绘制...
继续阅读 »
前言:

对一些有趣的绘制 技能知识, 我会通过 [番外篇] 的形式加入《Flutter 绘制指南 - 妙笔生花》小册中,一方面保证小册的“与时俱进”“活力”。另一方面,是为了让一些重要的知识有个 好的归宿




一、正 N 边形的绘制


1. 正三角形绘制

对于正 N 形而言,绘制的本质就是对点的收集。如下图,外接圆上,平均等分三份,对应弧度的圆上坐标即为待收集的点。将这些点依次相连,即可得到期望的图形。





容易看出,对于正三角形,三个点分别位于 120°240° 的圆上。通过 三角函数更新很容易求得三个点的坐标,并用 points 列表进行记录。


@override
void paint(Canvas canvas, Size size) {
canvas.translate(size.width / 2, size.height / 2);
int count = 3;
double radius = 140 / 2;
List<Offset> points = [];
for (int i = 0; i < count; i++) {
double perRad = 2 * pi / count * i;
points.add(Offset(radius * cos(perRad), radius * sin(perRad)));
}
_drawShape(canvas, points);
}



得到点集之后,就可以形成路径进行绘制。本例全部源码位于: 01_triangle



final Paint shapePaint = Paint()
..style = PaintingStyle.stroke;

void _drawShape(Canvas canvas, List<Offset> points) {
Path shapePath = Path();
shapePath.moveTo(points[0].dx, points[0].dy);
for (int i = 1; i < points.length; i++) {
shapePath.lineTo(points[i].dx, points[i].dy);
}
shapePath.close();
canvas.drawPath(shapePath, shapePaint);
}



2. 正 N 边形

正三角形 同理,改变上面的 count 值,就可以将圆等分成 count 份,再对圆上对应点进行收集即可。























正四边形正五边形
正六边形正七边形
image-20211007132438225

可能大家会觉得上面奇数情况下,不是很。因为上面以水平方向的 为起点,是上下对称。视觉上,我们更习惯于 左右对称。想实现如下的左右对称正 N 边形,其实也很简单,在计算点位时逆时针旋转 90°即可。



double rotate = - pi / 2; 
for (int i = 0; i < count; i++) {
double perRad = 2 * pi / count * i;
points.add(Offset(
radius * cos(perRad + rotate), // 在计算时加上旋转量
radius * sin(perRad + rotate),
));
}

另外,通过圆的半径大小可以控制 正 N 边形 的大小。本例全部源码位于: 02_n_side




二、 N 角星的绘制


1、五角星的绘制

先看下思路:前面我们已经知道如何收录 正五边形 的五个点,现在再搞个小的 正五边形 。如果将两个点集进行交错合并,实现首尾相连会是什么样子呢?也就是 红0--蓝0--红1--蓝1--红2--蓝2...



这里外圆的五个点集为 outPoints,内圆的五个点集为 innerPoints 。让两个列表交错合并也非常简单,就是指定索引插入元素而已。


for(int i =0; i< count; i++){
outPoints.insert(2*i+1, innerPoints[i]);
}

这样将合并的点集形成路径,就可以得到如下的图形:





上面图形已经有点 五角星 的外貌了,可以看出只要在收集内圆上点时,顺时针偏转一下角度就行了。比如下面偏转了 15° ,看起来就更像了:



double innerRadius = 70 / 2;
List<Offset> innerPoints = [];
double offset = 15 * pi / 180;
for (int i = 0; i < count; i++) {
double perRad = 2 * pi / count * i;
innerPoints.add(Offset(
innerRadius * cos(perRad + offset),
innerRadius * sin(perRad + offset),
));
}



那这个偏角到底是多少,才符合五角星呢?也就是求下面的 α 值是多少,由于小圆上五个点是 正五边形,所以 β180°*(5-2)/5=108° ,所以 α = 180°-108°/2-90°=36°



这样就得到了一个标准的五角星,只不过是上下对称的。



要改成左右对称 很简单,上面也说过,在计算点位时,逆时针旋转 90° 即可:本例全部源码位于: 03_five_star



List<Offset> innerPoints = [];
double offset = pi / count;
for (int i = 0; i < count; i++) {
double perRad = 2 * pi / count * i;
innerPoints.add(Offset(
innerRadius * cos(perRad + rotate + offset),
innerRadius * sin(perRad + rotate + offset),
));
}

通过 外圆半径/内圆半径 可以控制五角星的 胖瘦

















70/4070/2870/15



2. N 角星的绘制

五角星完成了,其它的也就水到渠成。最重要的一步是找到角度偏移量 αn 的对应关系,不难算出:


α = 180°- 180°*(n-2)/n/2-90°
= 180°/n

注: n 边形的内角和为 180°*(n-2)

上面为了方便理解,使用了两个点集分别收集内外圆上的点,最后进行整合。理解原理后,我们可以一次性收集两个圆上的点,避免而外的合并操作。代码如下:


int count = 6;
double outRadius = 140 / 2;
double innerRadius = 70 / 2;
double offset = pi / count;
List<Offset> outPoints = [];

double rotate = -pi / 2;
for (int i = 0; i < count; i++) {
double perRad = 2 * pi / count * i;
outPoints.add(Offset(
outRadius * cos(perRad + rotate),
outRadius * sin(perRad + rotate),
));
outPoints.add(Offset(
innerRadius * cos(perRad + rotate + offset),
innerRadius * sin(perRad + rotate + offset),
));
}



这样,对于不同的 count ,就可以得到对应角数的星星。如下是 2~9 角星:





三、形状路径的使用


1、路径工具的使用

上面把所有的计算逻辑都塞在了画板中,显得非常杂乱,完全可以把这些路径形成逻辑单独抽离出来。如下 ShapePath 类,使用者只需要进行 基本参数配置 来创建对象即可,通过对象来拿到相关路径。本例全部源码位于: 04_n_star


// ShapePath型 成员变量
late ShapePath shapePath = ShapePath.star(
n: n,
outRadius: 140 / 2,
innerRadius: 80 / 2,
);

// 获取 shapePath 中的路径
canvas.drawPath(shapePath.path, shapePaint);

只需要两行代码,就可以通过ShapePath.star 构造,获得 n 角星的路径:





也通过ShapePath.polygon 构造,获得正 n 边形的路径:





2、路径工具的封装

ShapePath 中有四个成员,其中 noutRadiusinnerRadius 是路径信息的配置,_path 是路径。在获取路径时做了个判断:如果路径为空,则先通过之前的逻辑构建路径,否则,直接返回已有路径。这样可以避免同一 ShapePath 对象构建多次相同的路径。


import 'dart:math';
import 'dart:ui';

class ShapePath {

ShapePath.star({
this.n = 5,
this.outRadius = 100,
this.innerRadius = 60,
});

ShapePath.polygon({
this.n = 5,
this.outRadius = 100,
}) : innerRadius = null;

final int n;
final double outRadius;
final double? innerRadius;
Path? _path;

Path get path {
if (_path == null) {
_buildPath();
}
return _path!;
}

void _buildPath() {
int count = n;
double offset = pi / count;
List<Offset> points = [];
double rotate = -pi / 2;
for (int i = 0; i < count; i++) {
double perRad = 2 * pi / count * i;
points.add(Offset(
outRadius * cos(perRad + rotate),
outRadius * sin(perRad + rotate),
));
if (innerRadius != null) {
points.add(Offset(
innerRadius! * cos(perRad + rotate + offset),
innerRadius! * sin(perRad + rotate + offset),
));
}
}

_path = Path();
_path!.moveTo(points[0].dx, points[0].dy);
for (int i = 1; i < points.length; i++) {
_path!.lineTo(points[i].dx, points[i].dy);
}
_path!.close();
}
}



3、路径的作用

路径是绘制操作的基石,它的作用可以说非常多,可以根据路径进行合并、裁剪、描边、填充、运动等。如下是自定义 ShapeBorder 形状进行裁剪:


ClipPath(
clipper: ShapeBorderClipper(shape: MyShapeBorder()),
child: Image.asset(
'assets/images/wy_300x200.webp',
height: 200,
))


class MyShapeBorder extends ShapeBorder{

@override
EdgeInsetsGeometry get dimensions => const EdgeInsets.all(0);

@override
Path getInnerPath(Rect rect, {TextDirection? textDirection}) {
return Path();
}

@override
Path getOuterPath(Rect rect, {TextDirection? textDirection}) {
ShapePath shapePath = ShapePath.polygon(
n: 6,
outRadius: rect.shortestSide/2,
);
return shapePath.path.shift(Offset(rect.longestSide/2,rect.shortestSide/2));
}

@override
void paint(Canvas canvas, Rect rect, {TextDirection? textDirection}) {
}

@override
ShapeBorder scale(double t) {
return this;
}
}

路径的使用方式在 《Flutter 绘制指南 - 妙笔生花》相关章节有具体介绍,本文主要目的是来探讨:根据圆来拾取几何图形、并形成路径的方法。到这里,本文要介绍的内容就结束了,谢谢观看~


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

Android 开发必知必会:Java 并发之三大性质、synchronized、volatile

原子性 原子(atomic) 本意是“不能被进一步分割的最小粒子”,而原子操作(atomic operation) 意为“不可被中断的一个或一系列操作”。原子性则可以表示为:一个操作是不可中断的,要么全部执行成功要么全部执行失败,有着“同生共死”的感觉。 有序...
继续阅读 »

原子性


原子(atomic) 本意是“不能被进一步分割的最小粒子”,而原子操作(atomic operation) 意为“不可被中断的一个或一系列操作”。原子性则可以表示为:一个操作是不可中断的,要么全部执行成功要么全部执行失败,有着“同生共死”的感觉。


有序性


指的是在代码顺序结构中,我们可以直观的指定代码的执行顺序, 即从上到下按序执行。但编译器和CPU处理器会根据自己的决策,对代码的执行顺序进行重新排序。优化指令的执行顺序,提升程序的性能和执行速度,使语句执行顺序发生改变,出现重排序,但最终结果看起来没什么变化(单核)。


有序性问题


指的是在多线程环境下(多核),由于执行语句重排序后,重排序的这一部分没有一起执行完,就切换到了其它线程,导致的结果与预期不符的问题。这就是编译器的编译优化给并发编程带来的程序有序性问题


指令重排序


为了使处理器内部的运算单元能尽量被充分利用,处理器可能会对输入的代码进行乱序执行优化,处理器会在计算之后将乱序执行的结果重组,并确保这一结果和顺序执行结果是一致的,但是这个过程并不保证各个语句计算的先后顺序和输入代码中的顺序一致。这就是指令重排序。


可见性


当一个线程修改一个线程共享变量时,另外的线程能够读到这个修改的值。也就是说,被修饰的共享变量被任何线程读取的时候都能拿到最新的值。


synchronized


定义:



在多线程的环境下,多个线程同时访问共享资源会出现一些问题,而 synchronized 关键字则是用来保证线程同步的。synchronizedJava 提供的一个并发控制的关键字。主要有两种用法,分别是同步方法和同步代码块。也就是说,synchronized 既可以修饰方法也可以修饰代码块。


Java 中的每一个对象都可以作为锁,这个对象也被称为 监视器(monitor) 。具体表现为以下3种形式:



  • 对于普通同步方法,锁是当前实例对象。

  • 对于静态同步方法,琐是当前类的 Class 对象。

  • 对于同步方法块,锁是 Syschonized 括号里配置的对象。



作用:


给修饰的方法和代码块加锁,保证同时只能有一个线程访问。


特点:


有序性原子性可见性


使用:


Java:


public class SynchronizedTest {

   private final User user = new User();

   /**
    * 同步方法,监视器为当前对象
    * 此处的锁和 synchronized(this) 是同样的
    */
   public synchronized void synchronizedMethod() {}

   /**
    * 同步静态方法,监视器为当前类的 Class 对象
    * 此处的锁和 synchronized(SynchronizedTest.class) 是同样的
    */
   public synchronized static void synchronizedStaticMethod() {}

   /**
    * 同步代码块,监视器为 synchronized(object) 传入的对象
    */
   public void synchronizedCodeBlock() {

       /* 监视器为 user 对象,同时只能有一个线程拿到 user 锁 */
       synchronized (user) {
           System.out.println(user.name);
      }

       /* 监视器为当前类的实例对象,同时只能有一个线程拿到该类的实例锁 */
       synchronized (this) {
           System.out.println("SynchronizedTest");
      }

       /* 监视器为当前类的 Class 对象,同时只能有一个线程拿到当前类的 Class 对象锁 */
       synchronized (SynchronizedTest.class) {
           System.out.println("SynchronizedTest.class");
      }
  }
}

Kotlin:


class SynchronizedTestKt {

private val user = User()

/**
* 同步方法,监视器为当前对象
* 此处的锁和 synchronized(this) 是同样的
*/
@Synchronized
fun synchronizedMethod() {}

/**
* 同步代码块,监视器为 synchronized(object) 传入的对象
*/
fun synchronizedCodeBlock() {

/* 监视器为 user 对象,同时只能有一个线程拿到 user 锁 */
synchronized(user) { println(user.name) }

/* 监视器为当前类的实例对象,同时只能有一个线程拿到该类的实例锁 */
synchronized(this) { println("SynchronizedTestKt") }

/* 监视器为当前类的 Class 对象,同时只能有一个线程拿到当前类的 Class 对象锁 */
synchronized(SynchronizedTestKt::class.java) { println("SynchronizedTestKt.class") }
}

/**
* 伴生对象
*/
companion object {

/**
* 同步伴生对象方法,监视器为当前类的 Class 对象
* 此处的锁和 synchronized(SynchronizedTestKt::class.java) 是同样的
*/
@Synchronized
fun synchronizedStaticMethod() {}
}
}

volatile


定义:



Java 语言规范第3版中对 volatile 的定义如下:Java 编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致地更新,线程应该确保通过排他锁单独获得这个变量。


Java 语言提供了 volatile,在某些情况下比锁要更加方便。如果一个字段被声明成 volatileJava 线程内存模型确保所有线程看到这个变量的值是一致的 。volatile 是一种轻量且在有限的条件下线程安全技术,它保证修饰的变量的可见性和有序性,但非原子性。相对于 synchronize 高效,而常常跟 synchronize 配合使用。



作用:



  1. 保证了不同线程对该变量操作的内存可见性

  2. 禁止指令重排序


特点:


有序性非原子性可见性


实现原理:


引《Java 并发编程的艺术》书中的例子:


X86 处理器下通过工具获取 JIT 编译器生成的汇编指令来查看对 volatile 进行写操作时,CPU 会做什么事。


Java 代码:


instance = new Singleton;    // instance 是被 volatile 修饰的变量

转为汇编:


0x01a3de1d: movb $0×0,0×1104800(%esi);0x01a3de24: lock addl $0×0,(%esp);

volatile 变量修饰的共享变量进行写操作的时候会多出第二行汇编代码,通过查 IA-32架构软件开发者手册可知,Lock 前缀的指令在多核处理器下会引发了两件事情:



  1. 将当前处理器缓存行的数据写回到系统内存。

  2. 这个写回内存的操作会使在其他CPU里缓存了该内存地址的数据无效。


为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存的数据读到内部缓存(L1,L2或其他)后再进行操作,但操作完不知道何时会写到内存。如果对声明了 volatile 的变量进行写操作,JVM 就会向处理器发送一条 Lock 前缀的指令,将这个变量所在缓存行的数据写回到系统内存。但是,就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题。所以,在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。


volatile 的两条实现原则:



  1. Lock 前缀指令会引起处理器缓存回写到内存。

  2. 一个处理器的缓存回写到内存会导致其他处理器的缓存无效。


使用:


Java:


public class Main {

   private volatile int variable = 0;
}

Kotlin:


class Main {

   @Volatile
   private var variable: Int = 0
}

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

【开源项目】Compose版SmartRefreshLayout,了解一下~

下拉刷新是我们开发中的常见的需求,官方提供了SwipeRefreshLayout来实现下拉刷新,但我们常常需要定制Header或者Header与内容一起向下滚动,因此SwipeRefreshLayout往往不能满足我们的需求 在使用XML开发时,Github上...
继续阅读 »

下拉刷新是我们开发中的常见的需求,官方提供了SwipeRefreshLayout来实现下拉刷新,但我们常常需要定制Header或者Header与内容一起向下滚动,因此SwipeRefreshLayout往往不能满足我们的需求

在使用XML开发时,Github上有不少开源库如 SmartRefreshLayout 实现了下拉刷新功能,可以方便地定制化Header与滚动方式

本文主要介绍如何开发一个简单易用的ComposeSmartRefreshLayout,快速实现下拉刷新功能,如果对您有所帮助可以点个Star: Compose版SmartRefreshLayout


效果图


我们首先看下最终的效果图















基本使用自定义Header














Lottie HeaderFixedBehind(固定在背后)














FixedFront(固定在前面)FixedContent(内容固定)

特性



  1. 接入方便,使用简单,快速实现下拉刷新功能

  2. 支持自定义Header,Header可观察下拉状态并更新UI

  3. 自定义Header支持Lottie,并支持观察下拉状态开始与暂停动画

  4. 支持自定义Translate,FixedBehind,FixedFront,FixedContent等滚动方式

  5. 支持与Paging结合实现上滑加载更多功能


使用


接入


第 1 步:在工程的build.gradle中添加:


allprojects {
repositories {
...
mavenCentral()
}
}

第2步:在应用的build.gradle中添加:


dependencies {
implementation 'io.github.shenzhen2017:compose-refreshlayout:1.0.0'
}

简单使用


SwipeRefreshLayout函数主要包括以下参数:



  1. isRefreshing: 是否正在刷新

  2. onRefresh: 触发刷新回调

  3. modifier: 样式修饰符

  4. swipeStyle: 下拉刷新方式

  5. swipeEnabled: 是否允许下拉刷新

  6. refreshTriggerRate: 刷新生效高度与indicator高度的比例

  7. maxDragRate: 最大刷新距离与indicator高度的比例

  8. indicator: 自定义的indicator,有默认值


在默认情况下,我们只需要传入isRefreshing(是否正在刷新)与onRefresh触发刷新回调两个参数即可


@Composable
fun BasicSample() {
var refreshing by remember { mutableStateOf(false) }
LaunchedEffect(refreshing) {
if (refreshing) {
delay(2000)
refreshing = false
}
}
SwipeRefreshLayout(isRefreshing = refreshing, onRefresh = { refreshing = true }) {
//...
}
}

如上所示:在触发刷新回调时将refreshing设置为true,并在刷新完成后设置为false即可实现简单的下拉刷新功能


自定义Header


SwipeRefreshLayout支持传入自定义的Header,如下所示:


@Composable
fun CustomHeaderSample() {
var refreshing by remember { mutableStateOf(false) }
LaunchedEffect(refreshing) {
if (refreshing) {
delay(2000)
refreshing = false
}
}

SwipeRefreshLayout(
isRefreshing = refreshing,
onRefresh = { refreshing = true },
indicator = {
BallRefreshHeader(state = it)
}) {
//...
}
}

如上所示:BallRefreshHeader即为自定义的Header,Header中会传入SwipeRefreshState,我们通过SwipeRefreshState可获得以下参数



  1. isRefreshing: 是否正在刷新

  2. isSwipeInProgress: 是否正在滚动

  3. maxDrag: 最大下拉距离

  4. refreshTrigger: 刷新触发距离

  5. headerState: 刷新状态,包括PullDownToRefresh,Refreshing,ReleaseToRefresh三个状态

  6. indicatorOffset: Header偏移量


这些参数都是MutableState我们可以观察这些参数的变化以实现Header UI的更新


自定义Lottile Header


Compose目前已支持Lottie,我们接入Lottie依赖后,就可以很方便地实现一个Lottie Header,并且在正在刷新时播放动画,其它时间暂停动画,示例如下:


@Composable
fun LottieHeaderOne(state: SwipeRefreshState) {
var isPlaying by remember {
mutableStateOf(false)
}
val speed by remember {
mutableStateOf(1f)
}
isPlaying = state.isRefreshing
val lottieComposition by rememberLottieComposition(
spec = LottieCompositionSpec.RawRes(R.raw.refresh_one),
)
val lottieAnimationState by animateLottieCompositionAsState(
composition = lottieComposition, // 动画资源句柄
iterations = LottieConstants.IterateForever, // 迭代次数
isPlaying = isPlaying, // 动画播放状态
speed = speed, // 动画速度状态
restartOnPlay = false // 暂停后重新播放是否从头开始
)
Box(
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight(), contentAlignment = Alignment.Center
) {
LottieAnimation(
lottieComposition,
lottieAnimationState,
modifier = Modifier.size(150.dp)
)

}
}

自定义下滑方式


SwipeRefreshLayout支持以下4种下滑方式


enum class SwipeRefreshStyle {
Translate, //平移,即内容与Header一起向下滑动,Translate为默认样式
FixedBehind, //固定在背后,即内容向下滑动,Header不动
FixedFront, //固定在前面, 即Header固定在前,Header与Content都不滑动
FixedContent //内容固定,Header向下滑动,即官方样式
}

如上所示,其中默认方式为Translate,即内容与Header一起向下滑动

各位可根据需求选择相应的下滑方式,比如要实现类似官方的下滑效果,即可使用FixedContent


上拉加载更多


Compose中,上拉加载更多直接使用Paging3看起来已经足够用了,因此本库没有实现上拉加载更多相关功能

因此如果想要实现上拉加载更多,可自行结合Paging3使用


主要原理


下拉刷新功能,其实主要是嵌套滚动的问题,我们将HeaderContent放到一个父布局中统一管理,然后需要做以下事



  1. 当我们的手指向下滚动时,首先交由Content处理,如果Content滚动到顶部了,再交由父布局处理,然后父布局根据手势进行一定的偏移,增加offset

  2. 当我们松手时,判断偏移的距离,如果大于刷新触发距离则触发刷新,否则回弹到顶部(offset置为0)

  3. 当我们手指向上滚动时,首先交由父布局处理,如果父布局的offset>0则由父布局处理,减少offset,否则则由Content消费手势


NestedScrollConnection介绍


为了实现上面说的需求,我们需要对滚动进行拦截,Compose提供了NestedScrollConnection来实现嵌套滚动


interface NestedScrollConnection {
fun onPreScroll(available: Offset, source: NestedScrollSource): Offset = Offset.Zero

fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset = Offset.Zero

suspend fun onPreFling(available: Velocity): Velocity = Velocity.Zero

suspend fun onPostFling(consumed: Velocity, available: Velocity) = return Velocity.Zero
}

如上所示,NestedScrollConnection主要提供了4个接口



  1. onPreScroll: 先拦截滑动事件,消费后再交给子布局

  2. onPostScroll: 子布局处理完滑动事件后再交给父布局,可获取当前还剩下多少可用的滑动事件偏移量

  3. onPreFling: Fling开始前回调

  4. onPostFling: Fling完成后回调



Fling含义:当我们手指在滑动列表时,如果是快速滑动并抬起,则列表会根据惯性继续飘一段距离后停下,这个行为就是 FlingonPreFling 在你手指刚抬起时便会回调,而 onPostFling 会在飘一段距离停下后回调。



具体实现


上面我们已经介绍了总体思路与NestedScrollConnection API,然后我们应该需要重写以下方法



  1. onPostScroll: 当Content滑动到顶部时,如果继续往上滑,我们就应该增加父布局的offset,因此在onPostScroll中判断available.y > 0,然后进行相应的偏移,对我们来说是个合适的时机

  2. onPreScroll: 当我们上滑时,如果offset>0,则说明父布局有偏移,因此我们应先减小父布局的offset直到0,然后将剩余的偏移量传递给Content,因此下滑时应该使用onPreScroll拦截判断

  3. onPreFling: 当我们松开手时,应判断当前的偏移量是否大于刷新触发距离,如果大于则触发刷新,否则父布局的offset置为0,这个判断在onPreFling时做比较合适


具体实现如下:


internal class SwipeRefreshNestedScrollConnection() : NestedScrollConnection {
override fun onPreScroll(
available: Offset,source: NestedScrollSource
)
: Offset = when {
// 如果用户正在上滑,需要在这里拦截处理
source == NestedScrollSource.Drag && available.y < 0 -> onScroll(available)
else -> Offset.Zero
}

override fun onPostScroll(
consumed: Offset,available: Offset,source: NestedScrollSource
)
: Offset = when {
// 如果用户正在下拉,在这里处理剩余的偏移量
source == NestedScrollSource.Drag && available.y > 0 -> onScroll(available)
else -> Offset.Zero
}

override suspend fun onPreFling(available: Velocity): Velocity {
//如果偏移量大于刷新触发距离,则触发刷新
if (!state.isRefreshing && state.indicatorOffset >= refreshTrigger) {
onRefresh()
}
//不消费速度,直接返回0
return Velocity.Zero
}
}

总结


本文主要介绍如何使用及实现一个Compose版的SmartRefreshLayout,它具有以下特性:



  1. 接入方便,使用简单,快速实现下拉刷新功能

  2. 支持自定义Header,Header可观察下拉状态并更新UI

  3. 自定义Header支持Lottie,并支持观察下拉状态开始与暂停动画

  4. 支持自定义Translate,FixedBehind,FixedFront,FixedContent等滚动方式

  5. 支持与Paging结合实现上滑加载更多功能


项目地址


Compose版SmartRefreshLayout

开源不易,如果项目对你有所帮助,欢迎点赞,Star,收藏~


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

可恶,又学到了一点 CSS

昨天在做笔记整理的时候,看到一个面试题,如何实现水平垂直居中,虽然心里有一点点数,但是看到好几种答案,还是决定亲自动手验证一番,这验证一开始就出现了小问题,接着就像捅了个马蜂窝一样,各种疑惑扑面而来,而我又想弄清楚,折腾大半天,终于把问题锁定到了 line-h...
继续阅读 »

昨天在做笔记整理的时候,看到一个面试题,如何实现水平垂直居中,虽然心里有一点点数,但是看到好几种答案,还是决定亲自动手验证一番,这验证一开始就出现了小问题,接着就像捅了个马蜂窝一样,各种疑惑扑面而来,而我又想弄清楚,折腾大半天,终于把问题锁定到了 line-heightvertical-align 身上。


大家现在应该都用 flex 布局,但是毕竟折腾好一会呢,好歹记录一下自己的收获哈哈


1.疑惑代码


    <div>
       <div>我要水平垂直居中</div>
   </div>

.container{
   border: 2px solid black;
   background-color: chartreuse;
   width: 200px;
   height: 200px;
   text-align: center;
   line-height: 200px;
}
.box{
   display: inline-block;
   line-height: normal;
   font-size: 1rem;
   vertical-align: middle;
   background-color: cornflowerblue;
}

你别说,还真居中了:


image-20211004131103654.png


2.一些知识点


2.1 水平垂直对齐


有一些疑惑真的只是自己无知哈哈哈



  • text-align 不仅可以作用在文本,还可以对行内元素和行内块元素有效果,设置水平对齐方式

  • vertical-align 只对行内元素和行内块元素起作用,设置元素的垂直对齐方式


2.2 line-height


说来也蛮搞笑的,我对 line-height 的印象就是,当元素 height 等于 line-height 的时候,元素内部的文本就会垂直居中。但是昨天查资料的时候发现,这里面牵扯到了很多复杂的问题。


简单的和我一起学习一下吧,MDN 上边说的,line-height 可用于多行文本的间距或者是单行文本的高度等


看到这里大家可以去看一下,底下的两篇参考文章,得出以下结论:



  • 没给元素设置高度时,元素高度采用的是 line-height 的高度,这个属性具有继承性。也自带默认值,所以当你给一个没有设置高度的元素设置 line-height:0;,即使里面有文本,它也是会塌陷的。

  • 可以分为好几种盒子,当你设置 line-height 的时候,行内框是不会变化的,改变的是行距,它只由 font-size 的决定。这其实就是上边元素 height 等于 line-height 的时候,元素内文本会垂直居中的原因。

  • 取值为 number 时,line-heightnumber 乘以当前元素的 font-size,取 normal 时一般就是 number 为 1.2


3. 疑惑产生


喜欢东拆拆西拆拆的我发现,上边代码注释掉 vertical-align: middle; 效果并没有变化,依旧垂直居中着,但是将它改成 vertical-align: top; 又起到作用了如下:


image-20211004152748629.png


4.解决


真的,其实写博客之前我都没有理解为什么会出现这种怪异情况,但是写着写着就来灵感了,原来是这样哈哈哈


其实咱们把文字内容加一点,使它成为多行文本,效果就出来了,没注释掉 vertical-align: middle 的效果如下:


image-20211004153753934.png


注释掉了,就是这个样子,没有垂直居中:


image-20211004153832263.png


所以前面的只是巧合,因为是单行文本的原因,平衡上下行距,应该也不叫行距,应该就是为了平衡才会出现垂直居中的效果哦!


链接:https://juejin.cn/post/7015117674422206494
收起阅读 »

Vue中 前端实现生成 PDF 并下载

思路: 通过 html2canvas 将 HTML 页面转换成图片,然后再通过 jspdf 将图片的 base64 生成为 pdf 文件。 1. 安装及引入 // 将页面 html 转换成图片 npm install html2canvas --save ...
继续阅读 »

思路: 通过 html2canvas 将 HTML 页面转换成图片,然后再通过 jspdf 将图片的 base64 生成为 pdf 文件。


1. 安装及引入


// 将页面 html 转换成图片
npm install html2canvas --save
// 将图片生成 pdf
npm install jspdf --save

在项目主文件 main.js 中引入定义好的实现方法并注册


import htmlToPdf from '@/utils/htmlToPdf';
// 使用 Vue.use() 方法就会调用工具方法中的install方法
Vue.use(htmlToPdf);

传送门:Vue中 Vue.use() 原理及使用


2. 封装导出 pdf 文件方法


配置详解


let pdf = new jsPDF('p', 'pt', [pdfX, pdfY]);
第一个参数: l:横向 p:纵向
第二个参数:测量单位("pt","mm", "cm", "m", "in" or "px");
第三个参数:可以是下面格式,默认为“a4”。如需自定义格式,只需将大小作为数字数组传递,如:[592.28, 841.89];
a0 - a10
b0 - b10
c0 - c10
dl
letter
government-letter
legal
junior-legal
ledger
tabloid
credit-card

pdf.addPage() 在PDF文档中添加新页面,默认a4。参数如下:


在这里插入图片描述


pdf.addImage() 将图像添加到PDF。参数如下:


在这里插入图片描述


删除某页 pdf


let targetPage = pdf.internal.getNumberOfPages(); //获取总页
pdf.deletePage(targetPage); // 删除目标页
复制代码

保存 pdf 文档


pdf.save(`测试.pdf`);
复制代码

在这里插入图片描述


封装导出 pdf 文件方法(utils/htmlToPdf.js)


// 导出页面为PDF格式
import html2Canvas from 'html2canvas'
import JsPDF from 'jspdf'
export default{
install (Vue, options) {
Vue.prototype.getPdf = function () {
// 当下载pdf时,若不在页面顶部会造成PDF样式不对,所以先回到页面顶部再下载
let top = document.getElementById('pdfDom');
if (top != null) {
top.scrollIntoView();
top = null;
}
let title = this.exportPDFtitle;
html2Canvas(document.querySelector('#pdfDom'), {
allowTaint: true
}).then(function (canvas) {
// 获取canvas画布的宽高
let contentWidth = canvas.width;
let contentHeight = canvas.height;
// 一页pdf显示html页面生成的canvas高度;
let pageHeight = contentWidth / 841.89 * 592.28;
// 未生成pdf的html页面高度
let leftHeight = contentHeight;
// 页面偏移
let position = 0;
// html页面生成的canvas在pdf中图片的宽高(本例为:横向a4纸[841.89,592.28],纵向需调换尺寸)
let imgWidth = 841.89;
let imgHeight = 841.89 / contentWidth * contentHeight;
let pageData = canvas.toDataURL('image/jpeg', 1.0);
let PDF = new JsPDF('l', 'pt', 'a4');
// 两个高度需要区分: 一个是html页面的实际高度,和生成pdf的页面高度
// 当内容未超过pdf一页显示的范围,无需分页
if (leftHeight < pageHeight) {
PDF.addImage(pageData, 'JPEG', 0, 0, imgWidth, imgHeight)
} else {
while (leftHeight > 0) {
PDF.addImage(pageData, 'JPEG', 0, position, imgWidth, imgHeight)
leftHeight -= pageHeight;
position -= 592.28;
// 避免添加空白页
if (leftHeight > 0) {
PDF.addPage();
}
}
}
PDF.save(title + '.pdf')
})
}
}
}

相关组件中应用


<template>
<div class="wrap" >
<div id="pdfDom" style="padding: 10px;">
<el-table
:data="tableData"
border>
<el-table-column prop="date" label="日期" width="250"></el-table-column>
<el-table-column prop="name" label="姓名" width="250"></el-table-column>
<el-table-column prop="address" label="地址"></el-table-column>
</el-table>
</div>
<button type="button" style="margin-top: 20px;" @click="btnClick">导出PDF</button>
</div>

</template>

<script>
export default {
data() {
return {
exportPDFtitle: "页面导出PDF文件名",
tableData: [
{
date: '2016-05-06',
name: '王小虎',
address: '重庆市九龙坡区火炬大道'
}, {
date: '2016-05-07',
name: '王小虎',
address: '重庆市九龙坡区火炬大道'
},{
date: '2016-05-03',
name: '王小虎',
address: '上海市普陀区金沙江路 1518 弄'
}, {
date: '2016-05-02',
name: '王小虎',
address: '上海市普陀区金沙江路 1518 弄'
}, {
date: '2016-05-04',
name: '王小虎',
address: '上海市普陀区金沙江路 1518 弄'
}, {
date: '2016-05-01',
name: '王小虎',
address: '上海市普陀区金沙江路 1518 弄'
}, {
date: '2016-05-08',
name: '王小虎',
address: '上海市普陀区金沙江路 1518 弄'
}, {
date: '2016-05-06',
name: '王小虎',
address: '上海市普陀区金沙江路 1518 弄'
}, {
date: '2016-05-06',
name: '王小虎',
address: '上海市普陀区金沙江路 1518 弄'
}, {
date: '2016-05-07',
name: '王小虎',
address: '上海市普陀区金沙江路 1518 弄'
}, {
date: '2016-05-01',
name: '王小虎',
address: '上海市普陀区金沙江路 1518 弄'
}, {
date: '2016-05-08',
name: '王小虎',
address: '上海市普陀区金沙江路 1518 弄'
}, {
date: '2016-05-06',
name: '王小虎',
address: '上海市普陀区金沙江路 1518 弄'
}, {
date: '2016-05-07',
name: '王小虎',
address: '上海市普陀区金沙江路 1518 弄'
}, {
date: '2016-05-06',
name: '王小虎',
address: '南京市江宁区将军大道'
}, {
date: '2016-05-07',
name: '王小虎',
address: '南京市江宁区将军大道'
},, {
date: '2016-05-04',
name: '王小虎',
address: '上海市普陀区金沙江路 1518 弄'
}, {
date: '2016-05-01',
name: '王小虎',
address: '上海市普陀区金沙江路 1518 弄'
}, {
date: '2016-05-08',
name: '王小虎',
address: '上海市普陀区金沙江路 1518 弄'
}, {
date: '2016-05-06',
name: '王小虎',
address: '上海市普陀区金沙江路 1518 弄'
}, {
date: '2016-05-07',
name: '王小虎',
address: '上海市普陀区金沙江路 1518 弄'
},{
date: '2016-05-01',
name: '王小虎',
address: '上海市普陀区金沙江路 1518 弄'
}, {
date: '2016-05-08',
name: '王小虎',
address: '上海市普陀区金沙江路 1518 弄'
}, {
date: '2016-05-06',
name: '王小虎',
address: '上海市普陀区金沙江路 1518 弄'
}, {
date: '2016-05-08',
name: '王小虎',
address: '武汉市洪山区文化大道'
}, {
date: '2016-05-06',
name: '王小虎',
address: '武汉市洪山区文化大道'
}, {
date: '2016-05-07',
name: '王小虎',
address: '武汉市洪山区文化大道'
}, {
date: '2016-05-06',
name: '王小虎',
address: '南京市江宁区将军大道'
}, {
date: '2016-05-07',
name: '王小虎',
address: '武汉市洪山区文化大道'
},
]
}
},
methods: {
btnClick(){
this.$nextTick(() => {this.getPdf();})
},
},
}
</script>

效果


在这里插入图片描述


待优化部分

分页时,页面内容被截断(欢迎留言讨论交流);

不同内容,另起一页开始;思路:计算超出内容,占最后一页的高度(设定间距 = 页面高度 - 超出部分高度)。


作者:明天也要努力
链接:https://juejin.cn/post/7016249316834557959

收起阅读 »

你还在为pc端适配而烦恼吗?相信我,看了之后就不烦恼了

作为一名前端开发者,你有没有遇到过这种头痛的事情。每次开发pc端的网页时,不管是官网还是管理后台,UI设计师都是按照1920*1080(16:9)的比例来给你提供设计稿的,导致你画页面的时候。会出现两种情况。第一种按照设计师提供的设计稿比例画页面的话,导致在不...
继续阅读 »

作为一名前端开发者,你有没有遇到过这种头痛的事情。每次开发pc端的网页时,不管是官网还是管理后台,UI设计师都是按照1920*1080(16:9)的比例来给你提供设计稿的,导致你画页面的时候。会出现两种情况。

第一种按照设计师提供的设计稿比例画页面的话,导致在不同比例的屏幕上,就会呈现不同的样式效果,有的过大有的过小,这时候用户就会问你,你是不是写的bug。。。很无语

第二种就是按照设计师提供的比例按自适应的方式来画页面,这样导致页面的尺寸与设计稿不同,并且在不同设备也会有差别,这时候设计师跟用户就会跑过来说,这里是不是少了1px的单位,这里是不是写错了。。。很头疼


今天我就介绍一种可以解决pc端网页适配的方法,大家觉得有用的话,就给我个小小点赞,也可以在评论区留言。


PC实现适配也是用了rem这个css3属性,rem相对于根元素(即html元素)font-size计算值的倍数。这里以PC常见的分辨率1920px和1366px(14寸笔记本)为例说明。为了更好的说明,假设设计师给的设计稿是1920px,我们既要做1920px屏幕,也要给1366px的屏幕做适配。


现在我们随便取1920px设计稿一块区域,假设宽度273px,高度随意。那么在1366px屏幕上宽度应该显示多少呢?


我们将屏幕宽度等比分成100份


//1920分辨率屏幕
avg = 1920 / 100 = 19.20 px

//1366分辨率屏幕
avg = 1366 / 100 = 13.66 px

在1366分辨率屏幕应该显示宽度 = (1366 * 273) / 1920 最后是194.228125px


//1920分辨率屏幕定义根
font-size = 19.20px //即 1rem = 19.20px

//1366分辨率屏幕
font-size = 13.66px //即 1rem = 13.66px

适配代码


html{
font-size:19.20px; /*默认以设计稿为基准*/
}

@media only screen and (max-width: 1366px) {
html{
font-size:13.66px;
}
}
#test{
width:14.21875rem;
}

id为test的盒子在1920屏幕宽度= 14.21875 * 19.20 最后是273


id为test的盒子在1366屏幕宽度= 14.21875 * 13.66 最后是194.228125


这样一来我们就适配了1920px和1366px屏幕。PC一般也就是这两个分辨率占多数,兼容了这两个分辨率屏幕基本就可以了。在说下国内基本没有在兼容IE8的浏览器了。基本都是IE9+,css3属性在IE9+上还是可以使用的。不过建议小伙伴们使用前还是确定下,浏览器兼容


最后在对上面补充点,有的小伙伴可能觉得每次设置宽高前都要手动的转换,实在是太麻烦,不要着急我为大家找了个sass方法。


// PX 转 rem 
@function px2Rem($px, $base-font-size: 19.2px) {
@if (unitless($px)) {
//有无单位
@return ($px / 19.2) * 1rem;
} @else if (unit($px) == em) {
@return $px;
}
@return ($px / $base-font-size) * 1rem;
}

测试下上面的方法


#test{
width:px2Rem(273px)
}
//输出
#test{
width:14.21875rem;
}

大家将屏幕分辨率调整为1920px1366px来查看灰色区域宽度。




内容



@function px2Rem($px, $base-font-size: 19.2px) {

@if (unitless($px)) { //有无单位

@return ($px / 19.2) * 1rem;

} @else if (unit($px) == em) {

@return $px;

}
@return ($px / $base-font-size) * 1rem;
}

*{
margin:0;

padding:0;
}

html{
font-size:19.20px;
}

html,body{
height:100%;
}

body{
font-size:px2Rem(16px);
}

#app{
position:relative;

width:100%;

height:100%;
}

.nav{
position:fixed;

left:0;

top:0;

width:px2Rem(273px);

height:100%;

background-color:#E4E4E4;

transition:all .3s;

z-index:11;
}

.content{

position:absolute;

left:px2Rem(273px);

top:0;

right:0;

bottom:0;

width:auto;

height:100%;

background-color:#CBE9CB;

overflow-y:auto;

z-index:10;
}

p{
font-size:px2Rem(20px);
}

@media only screen and (max-width: 1366px) {
html{
font-size:13.66px;
}
}

效果图


screenshot-waliblog.com-2021.10.05-01_12_14.png


小伙伴们如果有更好的PC适配方案也可给我讲讲,欢迎在下方留言


sass下使用变量


height: calc(100% - #{px2rem(200px)});

链接:https://juejin.cn/post/7015257656193449992

收起阅读 »

你会用ES6,那倒是用啊!

不是标题党,这是一位leader在一次代码评审会对小组成员发出的“怒吼”,原因是在代码评审中发现很多地方还是采用ES5的写法,也不是说用ES5写法不行,会有BUG,只是造成代码量增多,可读性变差而已。 恰好,这位leader有代码洁癖,面对3~5年经验的成员,...
继续阅读 »

不是标题党,这是一位leader在一次代码评审会对小组成员发出的“怒吼”,原因是在代码评审中发现很多地方还是采用ES5的写法,也不是说用ES5写法不行,会有BUG,只是造成代码量增多,可读性变差而已。


恰好,这位leader有代码洁癖,面对3~5年经验的成员,还写这种水平的代码,极为不满,不断对代码进行吐槽。不过对于他的吐槽,我感觉还是有很大收获的,故就把leader的吐槽记录下来,分享给掘友们,觉得有收获点个赞,有错误的或者更好的写法,非常欢迎在评论中留言。


ps:ES5之后的JS语法统称ES6!!!


一、关于取值的吐槽


取值在程序中非常常见,比如从对象obj中取值。


const obj = {
a:1,
b:2,
c:3,
d:4,
e:5,
}

吐槽


const a = obj.a;
const b = obj.b;
const c = obj.c;
const d = obj.d;
const e = obj.e;

或者


const f = obj.a + obj.d;
const g = obj.c + obj.e;

吐槽:“不会用ES6的解构赋值来取值吗?5行代码用1行代码搞定不香吗?直接用对象名加属性名去取值,要是对象名短还好,很长呢?搞得代码中到处都是这个对象名。”


改进


const {a,b,c,d,e} = obj;
const f = a + d;
const g = c + e;

反驳


不是不用ES6的解构赋值,而是服务端返回的数据对象中的属性名不是我想要的,这样取值,不是还得重新创建个遍历赋值。


吐槽


看来你对ES6的解构赋值掌握的还是不够彻底。如果想创建的变量名和对象的属性名不一致,可以这么写:


const {a:a1} = obj;
console.log(a1);// 1

补充


ES6的解构赋值虽然好用。但是要注意解构的对象不能为undefinednull。否则会报错,故要给被解构的对象一个默认值。


const {a,b,c,d,e} = obj || {};

二、关于合并数据的吐槽


比如合并两个数组,合并两个对象。


const a = [1,2,3];
const b = [1,5,6];
const c = a.concat(b);//[1,2,3,1,5,6]

const obj1 = {
a:1,
}
const obj1 = {
b:1,
}
const obj = Object.assgin({}, obj1, obj2);//{a:1,b:1}

吐槽


ES6的扩展运算符是不是忘记了,还有数组的合并不考虑去重吗?


改进


const a = [1,2,3];
const b = [1,5,6];
const c = [...new Set([...a,...b])];//[1,2,3,5,6]

const obj1 = {
a:1,
}
const obj2 = {
b:1,
}
const obj = {...obj1,...obj2};//{a:1,b:1}

三、关于拼接字符串的吐槽


const name = '小明';
const score = 59;
const result = '';
if(score > 60){
result = `${name}的考试成绩及格`;
}else{
result = `${name}的考试成绩不及格`;
}

吐槽


像你们这样用ES6字符串模板,还不如不用,你们根本不清楚在${}中可以做什么操作。在${}中可以放入任意的JavaScript表达式,可以进行运算,以及引用对象属性。


改进


const name = '小明';
const score = 59;
const result = `${name}${score > 60?'的考试成绩及格':'的考试成绩不及格'}`;

四、关于if中判断条件的吐槽


if(
type == 1 ||
type == 2 ||
type == 3 ||
type == 4 ||
){
//...
}

吐槽


ES6中数组实例方法includes会不会使用呢?


改进


const condition = [1,2,3,4];

if( condition.includes(type) ){
//...
}

五、关于列表搜索的吐槽


在项目中,一些没分页的列表的搜索功能由前端来实现,搜索一般分为精确搜索和模糊搜索。搜索也要叫过滤,一般用filter来实现。


const a = [1,2,3,4,5];
const result = a.filter(
item =>{
return item === 3
}
)

吐槽


如果是精确搜索不会用ES6中的find吗?性能优化懂么,find方法中找到符合条件的项,就不会继续遍历数组。


改进


const a = [1,2,3,4,5];
const result = a.find(
item =>{
return item === 3
}
)

六、关于扁平化数组的吐槽


一个部门JSON数据中,属性名是部门id,属性值是个部门成员id数组集合,现在要把有部门的成员id都提取到一个数组集合中。


const deps = {
'采购部':[1,2,3],
'人事部':[5,8,12],
'行政部':[5,14,79],
'运输部':[3,64,105],
}
let member = [];
for (let item in deps){
const value = deps[item];
if(Array.isArray(value)){
member = [...member,...value]
}
}
member = [...new Set(member)]

吐槽


获取对象的全部属性值还要遍历吗?Object.values忘记了吗?还有涉及到数组的扁平化处理,为啥不用ES6提供的flat方法呢,还好这次的数组的深度最多只到2维,还要是遇到4维、5维深度的数组,是不是得循环嵌套循环来扁平化?


改进


const deps = {
'采购部':[1,2,3],
'人事部':[5,8,12],
'行政部':[5,14,79],
'运输部':[3,64,105],
}
let member = Object.values(deps).flat(Infinity);

其中使用Infinity作为flat的参数,使得无需知道被扁平化的数组的维度。


补充


flat方法不支持IE浏览器。


七、关于获取对象属性值的吐槽


const name = obj && obj.name;

吐槽


ES6中的可选链操作符会使用么?


改进


const name = obj?.name;

八、关于添加对象属性的吐槽


当给对象添加属性时,如果属性名是动态变化的,该怎么处理。


let obj = {};
let index = 1;
let key = `topic${index}`;
obj[key] = '话题内容';

吐槽


为何要额外创建一个变量。不知道ES6中的对象属性名是可以用表达式吗?


改进


let obj = {};
let index = 1;
obj[`topic${index}`] = '话题内容';

九、关于输入框非空的判断


在处理输入框相关业务时,往往会判断输入框未输入值的场景。


if(value !== null && value !== undefined && value !== ''){
//...
}

吐槽


ES6中新出的空值合并运算符了解过吗,要写那么多条件吗?


if(value??'' !== ''){
//...
}

十、关于异步函数的吐槽


异步函数很常见,经常是用 Promise 来实现。


const fn1 = () =>{
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve(1);
}, 300);
});
}
const fn2 = () =>{
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve(2);
}, 600);
});
}
const fn = () =>{
fn1().then(res1 =>{
console.log(res1);// 1
fn2().then(res2 =>{
console.log(res2)
})
})
}

吐槽


如果这样调用异步函数,不怕形成地狱回调啊!


改进


const fn = async () =>{
const res1 = await fn1();
const res2 = await fn2();
console.log(res1);// 1
console.log(res2);// 2
}

补充


但是要做并发请求时,还是要用到Promise.all()


const fn = () =>{
Promise.all([fn1(),fn2()]).then(res =>{
console.log(res);// [1,2]
})
}

如果并发请求时,只要其中一个异步函数处理完成,就返回结果,要用到Promise.race()


十一、后续


欢迎来对以上十点leader的吐槽进行反驳,你的反驳如果有道理的,下次代码评审会上,我替你反驳。


此外以上的整理内容有误的地方,欢迎在评论中指正,万分感谢。


如果你还有其它想吐槽的,也非常欢迎在评论中留下你的吐槽。


作者:红尘炼心
链接:https://juejin.cn/post/7016520448204603423

收起阅读 »

国庆假期,整整七天,我使用Flutter终于做出了即时通信!!!?

前言:在这个假期,我完成了一个小Demo,Flutter 与 Springboot 进行websocket的通讯,为啥想要去做这个Demo呢,主要是在各大平台以及google搜索后发现,没有一个详细的例子来教大家进行一对一、一对多的通讯,大多数都是教你怎么连接...
继续阅读 »

前言:在这个假期,我完成了一个小Demo,Flutter 与 Springboot 进行websocket的通讯,为啥想要去做这个Demo呢,主要是在各大平台以及google搜索后发现,没有一个详细的例子来教大家进行一对一、一对多的通讯,大多数都是教你怎么连接,却没有教你怎么去进行下一步的功能实现,于是我利用了五天的假期,踩了无数的坑,终于是完成了它,所以,点个赞吧,不容易啊,兄弟们😭


源码在文章最后,直接运行就完事,服务端我都帮兄弟们架包打好了,运行一下就行,运行方法在文末简单叙述了😎


服务端分析:Springboot WebSocket 即时通讯


先上效果图(我自己搜索这样功能性的问题时,没有效果图基本上都是不想看的):


tt0.top-039531.gif


屏幕截图 2021-10-05 153152.jpg


即时通讯最重要的功能是完成了(发送文字信息)


阅读本文的注意点:


1.需要一点WebSocket的原理知识


2.Flutter使用WebSocket的方式,本文使用 'dart:io' ,大家也可以使用插件


正文:


1.WebSocket的简单原理


很多同学在第一次碰到这个协议时会发现它与HTTP相似,于是就会问,我们已经有了 HTTP 协议,为什么还需要WebSocket?它有什么特殊的地方呢?


其实是因为 HTTP 协议有一个缺陷:通信只能由客户端发起。


img


而WebSocket首先是一个持久化的协议,它的最大特点就是,服务器可以主动向客户端推送信息,客户端也可以主动向服务器发送信息,这个协议非常适合即时通讯或者消息的推送。


img


2.Flutter中怎么使用WebSocket


有两种方式:


1.Flutter自带的 'dart:io'



  • 连接WebSocket服务器


    Future<WebSocket> webSocketFuture =  WebSocket.connect('ws://192.168.13.32:9090'); //connect中放服务端地址



  • 存放WebSocket.connect返回的对象


    static WebSocket _webSocket;



  • 发送消息


    _webSocket.add('发送消息内容');



  • 监听接收消息,调用listen方法


    void onData(dynamic content) {
    print('收到消息:'+content);
    }
    _webSocket.listen(onData, onDone: () {
    print('onDone');
    }, onError: () {
    print('onError');
    }, cancelOnError: true);



  • 例子:


    webSocketFuture.then((WebSocket ws) {
    _webSocket = ws;
    void onData(dynamic content) {
    print('收到新的消息');
    }
    // 调用add方法发送消息
    _webSocket.add('message');
    // 监听接收消息,调用listen方法
    _webSocket.listen(onData, onDone: () {
    print('onDone');
    }, onError: () {
    print('onError');
    }, cancelOnError: true);
    });



  • 关闭WebSocket连接


    _webSocket.close();



2.第三方插件库实现 WebSocket

基本使用步骤也都是:连接 WebSocket 服务器、发送消息、接收消息、关闭 WebSocket 连接。



  • 在项目的 pubspec.yaml 里加入引用:


dependencies:
web_socket_channel: 官网最新版本


  • 导入包:


import 'package:web_socket_channel/io.dart';


  • 连接 WebSocket 服务器:


var channel = IOWebSocketChannel.connect("ws://192.168.13.32:9090");

通过IOWebSocketChannel我们便可以进行各种操作



  • 发送消息:


channel.sink.add("connected!");


  • 监听接收消息:


channel.stream.listen((message) {      print('收到消息:' + message);    });


  • 关闭 WebSocket 连接:


channel.sink.close();

以上就是 Flutter 通过第三方插件库实现 WebSocket 通信功能的基本步骤。


3.联系人界面以及对话界面的UI实现


我的小部件都进行了封装,源码请看文章最后,分析部分只放重要代码




  • 对话框ui处理


    顶部使用appbar,包含一个返回按钮,用户信息以及状态,还有一个设置按钮(没有什么难点就不放代码了)




1633651654(1).png



  • 双方信息处理


1633651683(1).png


这里有个ui处理难点,就是分析信息是谁发出的,是自己还是对方呢


这里我选择在每条信息Json格式的末尾加上一个判断符:messageType,”receiver“代表对方发的信息,”sender“代表是自己发的


ui处理:


ListView.builder(  itemCount: UserMessage.messages.length, //总发送信息的条数  shrinkWrap: true,  padding: const EdgeInsets.only(top: 10, bottom: 10),  physics: const NeverScrollableScrollPhysics(),  itemBuilder: (context, index) {    return Container(      padding: const EdgeInsets.only(          left: 14, right: 14, top: 10, bottom: 10),      child: Align(          alignment:              (UserMessage.messages[index].messageType == "receiver"                  ? Alignment.topLeft                  : Alignment.topRight),          child: Container(              decoration: BoxDecoration(                borderRadius: BorderRadius.circular(20),                color: (UserMessage.messages[index].messageType ==                        "receiver"                    ? Colors.grey.shade200                    : Colors.blue[200]),              ),              padding: const EdgeInsets.all(16),              child: Text(                UserMessage.messages[index].messageContent,                style: TextStyle(fontSize: 15),              ))),    );  },),



  • 单个联系人的ui




1633605454(1).png


一行为一个联系人模块,其内包含用户头像,用户姓名,即时通讯内容,以及上一次对话的时间,点击每行跳转到相对应的聊天框。


封装处理:


class ConversationList extends StatefulWidget {  String name; //用户姓名  String messageText; //即时内容  String imageUrl; //用户头像  String time;//上一次对话时间  bool isMessageRead; //用于处理字体大小  ConversationList(      {Key? key, required this.name,      required this.messageText,      required this.imageUrl,      required this.time,      required this.isMessageRead}) : super(key: key);  @override  _ConversationListState createState() => _ConversationListState();}

详细布局:


return GestureDetector(    onTap: () {      Navigator.push(context, MaterialPageRoute(builder: (context){        return ChatDetailPage(name:widget.name,userImageUrl:widget.imageUrl);      }));    },    child: Container(      padding: const EdgeInsets.only(left: 16, right: 16, top: 10, bottom: 10),      child: Row(        children: <Widget>[          Expanded(            child: Row(              children: <Widget>[                CircleAvatar(                  backgroundImage: AssetImage(widget.imageUrl),                  maxRadius: 30,                ),                const SizedBox(                  width: 16,                ),                Expanded(                  child: Container(                    color: Colors.transparent,                    child: Column(                      crossAxisAlignment: CrossAxisAlignment.start,                      children: <Widget>[                        Text(                          widget.name,                          style: const TextStyle(fontSize: 16),                        ),                        const SizedBox(                          height: 6,                        ),                        Text(                          widget.messageText,                          style: TextStyle(                              fontSize: 13,                              color: Colors.grey.shade600,                              fontWeight: widget.isMessageRead                                  ? FontWeight.bold                                  : FontWeight.normal),                        ),                      ],                    ),                  ),                ),              ],            ),          ),          Text(            widget.time,            style: TextStyle(                fontSize: 12,                fontWeight: widget.isMessageRead                    ? FontWeight.bold                    : FontWeight.normal),          ),        ],      ),    ),  );}



  • 列表实现:


    这里简单使用ListView.builder


    ListView.builder(  itemCount: chatUsers.length,  shrinkWrap: true,  padding: const EdgeInsets.only(top: 16),  physics: const NeverScrollableScrollPhysics(),  itemBuilder: (context, index){    return ConversationList(      name: chatUsers[index].name,      messageText: chatUsers[index].messageText,      imageUrl: chatUsers[index].imageURL,      time: chatUsers[index].time,      isMessageRead: (index == 0 || index == 3)?true:false,    );  },),



ui的难点部分在这里就分析完成了


4.Flutter WebSocket处理


因为是个Demo,封装的信息比较简单




  • 对发送的信息进行封装


    class ChatMessage {  String messageContent;  String messageType;  ChatMessage({required this.messageContent, required this.messageType});}



  • 对基本信息,以及用户信息进行封装,


    class UserMessage{  static int userId = 0;  static String socketUrl = "ws://192.168.10.104:9090/websocket/";  ///将对话暂存在这里  static List<ChatMessage> messages = [  ];}



  • 初始化时连接服务器并且对其进行监听


    当数据返回时,保存到内存中


    //连接wensocket并且监听void connect(String userName) {  Future<WebSocket> futureWebSocket =      WebSocket.connect(UserMessage.socketUrl + "/$userName"); //socket地址  futureWebSocket.then((WebSocket ws) {    _webSocket = ws;    _webSocket.readyState;    // 监听事件    void onData(dynamic content) {      print('收到消息:' + content);      setState(() {        if (content.toString().substring(0, 1) ==            UserMessage.userId.toString()) {          ///自己发送的消息(服务端没有完善)        } else {          UserMessage.messages.add(ChatMessage(              messageContent: content.toString().substring(                    2,                  ),              messageType: "receiver"));        }      });    }    _webSocket.listen(onData,        onError: (a) => print("error"), onDone: () => print("done"));  });}



  • 发送信息处理


    对数据需要将json对象转换为json字符串


    // 向服务器发送消息void sendMessage(dynamic message) {  print(convert.jsonEncode(message));  _webSocket.add(convert.jsonEncode(message);}

    onPressed: () {  var toUser = "0"; //服务端没有完善,这里固定用户id了  if (UserMessage.userId == 0) {    toUser = "1";  } else {    toUser = "0";  }  var message = {    "msg": _controller.text,    "toUser": toUser,    "type": 1  };  ///传递信息  sendMessage(message); //发送信息  setState(() { //更新ui    UserMessage.messages.add(      ChatMessage(          messageContent: _controller.text,          messageType: "sender"),    );    _controller.clear(); //清除输入框内文字  });},



  • 在退出页面时,关闭WebSocket连接


    @overridevoid dispose() {  // TODO: implement dispose  super.dispose();  closeSocket();}



5.该文章项目运行步骤




  • 下载代码压缩包,解压后的目录是这样的:




1633654613(1).png


其中images是图片,lib是源代码,jar包是服务端


第一步:创建一个新项目,源代码是使用了空安全的,也可以创建不是空安全的项目,改一下代码即可


第二步:将images与lib复制进去


第三步:在pubspec.yaml中配置静态图片


assets:  - images/

第四步:运行.jar包


切换到包存放的地址,服务端的端口为9090,如果与各位的端口冲突可以修改服务端代码,或者停止你在使用9090的这个端口


java -jar 包名.jar

查找端口:


netstat -aon|findstr 9090

查看指定 PID 的进程


tasklist|findstr 10021

结束进程


强制(/F参数)杀死 pid 为 10021的所有进程包括子进程(/T参数):


taskkill /T /F /PID 9088 

第五步:在cmd中查找自己的ip,然后在代码的这里修改


1633656694(1).png


第六步:运行后的操作


运行后会先进入登录界面,这里第一只手机输入0 ,第二只手机输入1,因为服务端默认从0开始(给用户分配id),因为没有数据库。


1633656155(1).png


这样进入就可以了,然后就可以像效果图一样开始交流


服务端有问题可以参考这篇文章:Springboot WebSocket 即时通讯



作者:阿Tya
链接:https://juejin.cn/post/7016606314025451557

收起阅读 »

优雅地处理运行时权限请求

前言从android 6.0(API 级别 23)开始,android引入了运行时权限,用户开始在应用运行时向其授予权限,而不是在应用安装时向其授予权限,如果应用的某项功能需要使用到受运行时权限保护的资源(例如相机、位置、麦克风等),但在运行该功能前没有动态地...
继续阅读 »

前言

从android 6.0(API 级别 23)开始,android引入了运行时权限,用户开始在应用运行时向其授予权限,而不是在应用安装时向其授予权限,如果应用的某项功能需要使用到受运行时权限保护的资源(例如相机、位置、麦克风等),但在运行该功能前没有动态地申请相应的权限,那么在调用该功能时就会抛出SecurityException异常, android 6.0已经推出了很多年了,相信大家对于运行时权限的申请过程已经非常的熟悉,但是android的运行时权限的申请过程一直都是非常的繁琐的,主要有两步:

1、在需要申请权限的地方检查该权限是否被同意,如果同意了就直接执行,如果不同意就动态申请权限;

2、重写Activity或Fragment的onRequestPermissionsResult方法,在里面根据grantResults数组判断权限是否被同意,如果同意就直接执行,如果不同意就要进行相应的提示,如果用户勾选了“don't ask again”,还要引导用户去“Settings”界面打开权限,这时还要重写onActivityResult判断权限是否被同意.

就是这简单的两步,却夹杂了大量的if else语句,不但不优雅,而且每次都要写重复的样板代码,可能android的开发者也意识到了这一点,在最新androidx中引入了activity result api,通过activity result api你可以不需要自己管理requestCode,只需要提供需要请求的权限和处理结果的回调就行,让权限请求简单了一点,但是如果在权限请求的过程中,用户点击拒绝或者拒绝并不再询问,那么我们还是需要自己处理这些情况,但是这些处理流程都是一样的,完全可以封装起来,所以我就把以前的一个使用无界面fragment代理权限申请的库重构了一下,让权限的请求流程更加简单,本文会先复习一下权限的分类,然后再介绍PermissionHelper申请权限时的设计,最后记录一下从android 6.0后随着系统的迭代跟权限申请相关的重要行为变更。

权限的分类

android中所有的预定义权限(不包括厂商自定义的)都可以在Manifest.permission这个静态类中找到定义,android把权限分为四类:普通权限、签名权限、危险权限和特殊权限,每一种类型的权限都分配一个对应的Protection Level,分别为:normal、signature、dangerous和appop,下面简单介绍一下这四种类型的权限

1、普通权限

普通权限也叫正常权限,Protection Level为normal,它不需要动态申请,你只需要在AndroidManifest.xml中静态地声明,然后系统在应用安装时就会自动的授予该应用相应的权限,当应用获得授权时,它就可以访问应用沙盒外受该普通权限保护地数据或操作,这些数据或操作不会泄漏或篡改用户的隐私,对用户或其他应用几乎没有风险。

2、签名权限

这类权限我们用得比较少,它只对拥有相同签名的应用开放,Protection Level为signature,它也不需要动态申请,例如应用A在AndroidManifest.xml中自定义了一个permission且在权限标签中加入android:protectionLevel=”signature”,表示应用A声明了一个签名权限,那么应用B想要访问应用A受该权限保护的数据时,必须要在AndroidManifest.xml中声明该权限,同时要用与应用A相同的签名打包,这样系统在应用B安装时才会自动地授予应用B该权限,应用B在获得授权后就可以访问该权限控制的数据,其他应用即使知道这个权限,也在AndroidManifest.xml中声明了该权限,但由于应用签名不同,安装时系统不会授予它该权限,这样其他应用就无法访问受该权限保护的数据。

还有一些签名权限不会供第三方应用程序使用,只会供系统预装应用使用,这种签名权限的Protection Level为signature和privileged。

3、危险权限

危险权限也叫运行时权限,Protection Level为dangerous,跟普通权限相反,一旦应用获取了该类权限,用户的隐私数据就会面临被泄露或篡改的风险,所以如果你想使用该权限保护的数据或操作,就必须在AndroidManifest.xml中静态地声明需要用到的危险权限,并在访问这些数据或操作前动态的申请权限,系统就会弹出一个权限请求弹窗征求用户的同意,除非用户同意该权限,否则你不能使用该权限保护的数据或操作。

所有的危险权限都有对应的权限组,android预定义了11个权限组(根据android 11总结),这11个权限组中包含了30个危险权限和几个普通权限,当我们动态的申请某个危险权限时,都是按权限组申请的,当用户一旦同意授权该危险权限,那么该权限所对应的权限组中的其他在AndroidManifest.xml中注册的权限也会同时被授权,android预定义的11个权限组包含的危险权限如下:

Permission GroupDangerous Permissions
CALENDAR (日历)READ_CALENDAR
WRITE_CALENDAR
CALL_LOG (通话记录,Added in android 29)READ_CALL_LOG
WRITE_CALL_LOG
PROCESS_OUTGOING_CALLS
CAMERA (相机)CAMERA
CONTACTS (通讯录)READ_CONTACTS
WRITE_CONTACTS
GET_ACCOUNTS
LOCATION (位置信息)ACCESS_COARSE_LOCATION
ACCESS_FINE_LOCATION
ACCESS_BACKGROUND_LOCATION (Added in android 10)
MICROPHONE (麦克风)RECORD_AUDIO
PHONE (电话)READ_PHONE_NUMBERS
READ_PHONE_STATE
CALL_PHONE
ANSWER_PHONE_CALLS
ADD_VOICEMAIL
USE_SIP
ACCEPT_HANDOVER (Added in android 9)
SENSORS (身体传感器)BODY_SENSORS
SMS (短信)READ_SMS
RECEIVE_WAP_PUSH
RECEIVE_SMS
RECEIVE_MMS
SEND_SMS
STORAGE (存储空间)READ_EXTERNAL_STORAGE
WRITE_EXTERNAL_STORAGE
ACCESS_MEDIA_LOCATION (Added in android 10)
ACTIVITY_RECOGNITION (身体活动,Added in android 10)ACTIVITY_RECOGNITION (Added in android 10)

4、特殊权限

特殊权限用于保护一些特定的应用程序操作,Protection Level为appop,使用前也需要在AndroidManifest.xml中静态地声明,也需要动态的申请,但是它不同于危险权限的申请,危险权限的申请会弹出一个对话框询问你是否同意,而特殊权限的申请需要跳转到指定的设置界面,让你手动点击toggle按钮确认是否同意,截止到android 11,我了解到的常用的5个特殊权限为:

  • SYSTEM_ALERT_WINDOW:允许应用在其他应用的顶部绘制悬浮窗,当你创建的悬浮窗是TYPE_APPLICATION_OVERLAY类型时需要申请这个权限;
  • WRITE_SETTINGS:允许应用修改系统设置,当你需要修改系统参数Settings.System时需要申请该权限,例如修改系统屏幕亮度等;
  • REQUEST_INSTALL_PACKAGES: 允许应用安装未知来源应用,android 8.0以后当你在应用中安装第三方应用时需要申请这个权限,否则不会跳转到安装界面;
  • PACKAGE_USAGE_STATS:允许应用收集其他应用的使用信息,当你使用UsageStatsManager相关Api获取其他应用的信息时需要申请这个权限;
  • MANAGE_EXTERNAL_STORAGE(Added in android 11):允许应用访问作用域存储(scoped storage)中的外部存储,android 11以后强制新安装的应用使用作用域存储,但是对于文件管理器这一类的应用它们需要管理整个SD卡上的文件,所以针对这些特殊应用可以申请这个权限来获得对整个SD卡的读写权限,当应用授予这个权限后,它就可以访问文件的真实路径,注意这个权限是很危险的,声明这个权限上架应用时可能需要进行审核.

除了特殊权限,LOCATION权限组中的位置权限也有点特殊,需要注意一下,位置信息的获取不仅依赖位置权限的动态申请还依赖系统定位开关,如果你没有打开定位开关就申请了位置权限,那么就算用户同意授权位置权限,应用通过Location相关Api也无法获取到位置信息,所以申请位置权限前,最好先通过LocationManager#isProviderEnabled方法判断是否打开定位开关后再进行位置权限的申请,如果没有打开定位开关需要先跳转到设置界面打开定位开关,伪代码如下:

val locationManager = this.getSystemService(Context.LOCATION_SERVICE) as LocationManager
if (locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) or locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)) {
//请求位置权限
} else {
//跳转到开启定位的地方
Toast.makeText(this, "检测到未开启定位服务,请开启", Toast.LENGTH_SHORT).show()
val intent = Intent().apply {
action = Settings.ACTION_LOCATION_SOURCE_SETTINGS
}
startActivityForResult(intent, REQUEST_CODE_LOCATION_PROVIDER)
}

当然,上面危险权限和特殊权限的判断与申请,PermissionHelper都已经替你做好了封装,你只需要像平常一样在AndroidManifest.xml中静态地声明权限,然后在代码中动态地申请就行,下面我把危险权限和特殊权限都统称为动态权限,因为它们都是需要动态申请的。

动态权限申请设计

动态权限的申请依据不同的android版本和应用targetSdkVersion有着不同的行为,主要有两种处理,如下:

  • android版本 <= 5.1 或者 应用的targetSdkVersion <= 22:当用户同意安装应用时,系统会要求用户授权应用声明的所有权限,包括动态权限,如果用户不同意授权,只能拒绝安装应用,如果用户同意全部授权,他们撤销权限的唯一方式就是卸载应用;
  • android版本 >= 6.0 且 应用的targetSdkVersion >= 23:当用户同意安装应用时,系统不再强制用户必须授权动态权限,系统只会授权应用除动态权限之外的普通权限,而动态权限需要应用使用到相关功能时才动态申请,当申请动态权限时,用户可以选择授权或拒绝每项权限,即使用户同意授权权限,用户也可以随时进入应用的“Settings”中调整应用的动态权限授权,所以你每次使用到该权限的功能时,都要动态申请,因为用户有可能在“Settings”界面中把它再次关闭掉.

在android版本 <= 5.1 或者 应用的targetSdkVersion <= 22时,系统使用的是AppOps来进行权限管理,这是android在4.4推出的一套应用程序操作权限管理,AppOps所管理的是所有可能涉及用户隐私和安全的操作,例如access notification、keep weak lock、display toast 等等,而运行时权限管理是android 6.0才出现,是基于AppOps的实现,进一步做了动态请求封装和明确的规范,同时当targetSdkVersion <= 22的应用运行在 >= 6.0的android系统上时,动态权限可以在“Settings”界面中关闭,应用运行过程中使用到相关功能时就会由于没有权限而出现崩溃,这时只能使用AppOps的 checkOp方法来检测对应的权限是否已经授权,没有权限就跳转到“Settings”界面,考虑到目前android 6.0已经推出了很久,应用商店也不允许targetSdkVersion < 23的应用上架,所以为了减少框架的复杂度,动态权限申请设计就没有考虑兼容AppOps的权限管理操作,所以当你使用PermissionHelper时应用的targetSdkVersion要 >= 23

PermissionHelper支持危险权限和特殊权限的申请,只需要一行代码就可以发起权限请求,具有生命周期感应能力,只在界面可见时才发起请求和回调结果,同时当系统配置更改例如屏幕旋转后能够恢复之前权限申请流程,不会中断权限申请流程,灵活性高,可以设置请求前、拒绝后回调,在回调发生时暂停权限申请流程,然后根据用户意愿再决定是否继续权限申请流程,整个申请过程如图:

PermissionHelper可以通过设置回调在权限申请开始前和权限被拒绝后把要请求的权限和被拒绝的权限回调出去,在回调中你可以通过弹窗向用户解释要申请的权限对应用的必要性,引导用户继续授权或再次授权,PermissionHelper不定制弹窗UI,弹窗的UI由开发者自定义,开发者只需要在用户同意或拒绝后调用回调中的Process实例的相应方法就能让被暂停的权限申请流程恢复,然后在最终的结果回调中处理结果就行,整个过程都是链式的,关于向用户解释权限申请原因的弹窗,弹窗内容建议包含下面的3点:

1、包含需要授权的权限列表的描述;

2、包含确认按钮,用户可以点击确认按钮再次授权或跳转到”Settings“;

3、包含取消按钮,用户可以点击取消按钮放弃授权.

如果用户不授权这个权限,就会导致应用无法继续运行下去,可以考虑取消第3步的取消按钮,即无法取消这个弹窗,一定要用户再次授权或跳转到”Settings“去授权。

PermissionHelper整个框架的设计参考了okhttp的拦截器模式,通过责任链模式的形式把危险权限申请、特殊权限申请、申请前处理和申请后处理划分为一个个节点,然后通过Chain串联起各个节点,每个节点只负责对应的内容,如下:

val originalRequest = Request()    
val interceptors = listOf(
StartRequestNode(),
RequestLocationNode(),
RequestNormalNode(),
RequestSpecialNode(),
PostRequestNode(),
FinishRequestNode()
)
DefaultChain(originalRequest, interceptors).process(originalRequest)

通过这样的形式PermissionHelper就可以很灵活的控制权限申请流程,对于生命周期感应能力的实现PermissionHelper使用了Lifecycle+LiveData组件,这两个都是官方支持的用于实现需要响应生命周期感应的操作,可以编写更轻量级和更易于维护的代码,避免界面销毁后的内存泄漏,对于系统配置更改后的数据恢复则使用到了ViewModel组件,这是官方支持的用于保存需要在配置更改后恢复的数据,例如一些UI相关的数据,通过这三件套 + 责任链模式实现了一个简单易用的权限申请框架,更多详细使用和实现细节可以查看代码仓库

权限申请相关变更

自android 6.0推出动态权限申请之后,有一些申请行为也随着系统的迭代发生变化,目的都是更好的保护用户的隐私权,使得权限申请对用户感知:

android 8.0以后并且应用的targetSdkVersion >= 28时,应用申请某个危险权限授权,用户同意后,系统不再错误地把该危险权限对应的权限组中的其他在AndroidManifest.xml中注册的权限一并授予给应用,系统只会授予应用明确请求的权限,然而,一旦用户应用同意授权某个危险权限,则后续对该危险权限的权限组中的其他权限请求都会被自动批准,而不会提示用户,例如某个应用在AndroidManifest.xml中注册READ_EXTERNAL_STORAGE和WRITE_EXTERNAL_STORAGE权限,应用申请READ_EXTERNAL_STORAGE权限并且用户同意,在android 8.0之前,系统在用户同意后还会一并授予WRITE_EXTERNAL_STORAGE权限,因为它和READ_EXTERNAL_STORAGE权限是同一个权限组并且也在AndroidManifest.xml中注册,但在android 8.0之后并且应用的targetSdkVersion >= 28,系统在用户同意后只会授予READ_EXTERNAL_STORAGE权限,但是如果后来应用又申请WRITE_EXTERNAL_STORAGE权限,系统会立即授予该权限,而不会提示用户,换句话说,如果只申请了外部存储空间读取权限,在低版本下(android < 8.0)对外部存储空间使用写入操作是没有问题的,但是在高版本(android >= 8.0 && targetSdkVersion >= 28)下是会出现问题的,解决方案是将两个读和写的权限一起申请。

android 9.0增加了CALL_LOG(通话记录)权限组,并把READ_CALL_LOG、WRITE_CALL_LOG]、PROCESS_OUTGOING_CALLS权限从PHONE(电话)权限组移动到了CALL_LOG权限组,CALL_LOG权限组使得用户能够更好地控制需要访问电话通话记录敏感信息的应用程序,例如读取通话记录和识别电话号码。

android 10引入了很多隐私变更,新增了ACTIVITY_RECOGNITION(身体活动)权限和权限组,允许应用检测用户的步数或分类用户的身体活动如步行、骑自行车等;同时android 10引入了作用域存储,当应用启用作用域存储时,WRITE_EXTERNAL_STORAGE权限会失效,应用对WRITE_EXTERNAL_STORAGE权限的申请不会对应用的存储访问权限产生任何影响,并且WRITE_EXTERNAL_STORAGE会在未来被废弃,因为作用域存储的目的就是不让应用随意的修改应用沙盒外的外部存储;同时新增了ACCESS_BACKGROUND_LOCATION权限,归属于LOCATION权限组,用于后台运行的应用访问用户定位时申请,与ACCESS_FINE_LOCATION和ACCESS_COARSE_LOCATION这些前台定位权限区分开,当你的应用targetSdkVersion >= 29并且运行在android 10以上时,应用在后台访问定位时需要动态的申请后台定位权限,当你把后台定位权限和前台定位权限一起申请时,弹窗授权框会有2个允许选项:始终允许仅在应用使用过程中允许,点击始终允许表示同时授权后台定位权限和前台定位权限,点击仅在应用使用过程中允许表示仅授权前台定位权限,然后下次再次申请时只会单独申请后台定位权限,并且也会有2个允许选项,并且要点击始终允许才会让后台定位权限申请通过,当你的应用targetSdkVersion < 29运行在android 10以上时,应用在申请前台定位权限时系统会把后台定位权限一并授予给应用;android 10还新增了ACCESS_MEDIA_LOCATION权限,归属于STORAGE (存储空间) 权限组,android 10以后,因为隐私问题,默认不再提供图片的地理位置信息,要获取该信息需要向用户申请ACCESS_MEDIA_LOCATION权限,并使用MediaStore.setRequireOriginal()接口更新文件Uri。

android 11也引入了很多隐私变更,android 11强制新安装的应用(targetSdkVersion >= 30)启用作用域存储,新增MANAGE_EXTERNAL_STORAGE用于代替WRITE_EXTERNAL_STORAGE权限,提供给手机管家、文件管理器这类需要管理整个SD卡上的文件的应用申请;android 11中当用户开启“安装未知来源应用”权限后返回应用,应用会被杀死重启,该行为与强制分区存储有关;从android 11后,如果应用对某个权限连续点击多次拒绝,那么下一次请求该权限时系统会直接拒绝连授权弹窗都不会弹出,该行为等同于android 11之前勾选了don‘t ask again;android 11后还新增了一次性权限(One-time permissions)和权限自动重置功能(Permissions auto-reset),这些变更只要你正确的进行运行时权限请求就不需要做额外适配;同时android 11后当targetSdkVersion < 30的应用把后台定位权限和前台定位权限一起申请时,弹窗授权框的允许选项中不再会显示始终允许选项,只有本次允许仅在应用使用过程中允许,也就说点击允许时只会授予你前台定位权限不再默认授予你后台定位权限,而android 11后targetSdkVersion >= 30的应用的ACCESS_BACKGROUND_LOCATION权限需要独立申请,不能与前台权限一起申请,如果与前台权限一起申请,系统会直接拒绝连授权弹窗都不会弹出,系统推荐增量请求权限,这样对用户更友好,同时用户必须先同意前台权限后才能进入后台定位权限的申请。

可以看到从android 10引入ACCESS_BACKGROUND_LOCATION权限以来,后台定位权限的申请一直都非常特殊,它在android 10可以和前台定位权限一起申请,而在android 11又不可以一起申请还有先后申请顺序,针对这种特殊情况,申请后台定位权限时要做到:

  • 1、先请求前台定位权限,再请求后台定位权限;
  • 2、单独请求后台定位权限,不要与其他权限一同请求.

上面这些PermissionHelper都已经做好了处理,申请时只需要把后台定位权限和前台定位权限一起传进去就行。

结语

本文主要让让大家对权限的申请流程有进一步的认识,然后可以通过对动态权限的封装,将检测动态权限,请求动态权限,权限设置跳转,监听权限设置结果等处理和业务功能隔离开来,业务以后可以非常快速的接入动态权限支持,提高开发效率。


收起阅读 »

Android混合开发快速上手入门

一 混合开发简介原生app :java/kotlin 纯原生写出的app;web app:web写出的app;hybird app:原生+web(通过webview)写出的app;当然,现在也有很多第三方混合开发框架以及简便的js桥,但是作为最基础的webvi...
继续阅读 »


一 混合开发简介

原生app :java/kotlin 纯原生写出的app;

web app:web写出的app;

hybird app:原生+web(通过webview)写出的app;

当然,现在也有很多第三方混合开发框架以及简便的js桥,但是作为最基础的webview,掌握js/android的互调等相关知识是非常必要的。

二 Android-Js互调

2.1 准备自己的html文件

安卓和html中js的互调,一是要有安卓代码,二肯定需要html网页。工程中,网页都是放在服务器,方便随时更改,用户无需再次更新自己的app,已达到hybrid开发的目的,实例方便起见,将html文件放在了本地。

首先,在自己安卓项目中的app目录下新建assets文件夹(若没有):

接着,在assets文件夹下新建自己的html文件,代码如下:

<html>
<head>
<meta http-equiv="Content-Type" charset="GB2312"/>

<script type="text/javascript">
function javacalljs(){
document.getElementById("showmsg").innerHTML = "JAVA调用了JS的无参函数";
}

function javacalljswith(arg){
document.getElementById("showmsg").innerHTML = (arg);
}

</script>

</head>

<body>
<h3 align="center">Web模块</h3>

<h3 id="showmsg" align="center">调用js显示结果</h3>

<div style="text-align:center; vertical-align:middle;">
<input type="button" value="Js调用Java代码" onclick="window.android.jsCallAndroid()"/>
</div>

<br>

<br>

<div style="text-align:center; vertical-align:middle;">
<input type="button" value="Js调用Java代码并传参数" onclick="window.android.jsCallAndroidArgs('Js传过来的参数')"/>
</div>

</body>
</html>

2.2 WebView控件的准备设置

在自己的activity活动中获得webview控件后,需要进行以下设置:

WebSettings webSettings = webview.getSettings();
//与js交互必须设置
webSettings.setJavaScriptEnabled(true);
webview.loadUrl("file:///android_asset/html.html");
webview.addJavascriptInterface(MainActivity.this,"android");
  • webSettings.setJavaScriptEnabled(true) 表示让WebView支持调用Js;
  • webview.loadUrl("file:///android_asset/html.html") 表示加载assets文件下的html.html文件(因为没有网络地址所以加载的本地文件)
  • webview.addJavascriptInterface(MainActivity.this,"android") 给webview添加Js调用接口,第一个参数为类对象,第二个参数为自定义别名,Js通过这个别名来调用Java的方法,我这里自定义为android。 html中用到:<input type="button" value="Js调用Java代码" οnclick="window.android.jsCallAndroid()"/>

2.3 Android调用Js代码

在android代码中(如按钮点击事件中),通过webview这个中介调用loadUrl来执行html代码中的Js代码:

 tvJs.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
webview.loadUrl("javascript:javacalljs()");
}
});

下为html中要被安卓调用的js函数代码,函数意图为:向id为showmsg的h3大小标题中写入字符串“JAVA调用了JS的无参函数”。

function javacalljs(){
document.getElementById("showmsg").innerHTML = "JAVA调用了JS的无参函数";
}

在上述基础上,若要在Android调用Js函数时传参数,只需要在loadUrl方法中进行字符串的拼接,将参数以字符串形式拼接进去即可。

webview.loadUrl("javascript:javacalljswith(" + "'Android传过来的参数'" + ")");

2.4 Js调用Android方法和传参数

点击html按钮,通过οnclick="window.android.jsCallAndroid()事件,通过android别名调用Java文件的jsCallAndroid()方法。曾经Js可直接调用Java代码窃取App信息,为安全起见,在Android4.4以上并且必须加入@JavascriptInterface才有响应。

@JavascriptInterface
public void jsCallAndroid(){
tvShowmsg.setText("Js调用Android方法");
}

@JavascriptInterface
public void jsCallAndroidArgs(String args){
tvShowmsg.setText(args);
}

所有的activity代码如下:

package com.lucas.autils.autils;

import android.app.Activity;
import android.os.Bundle;
import android.util.Log;
import android.view.View;
import android.webkit.JavascriptInterface;
import android.webkit.WebChromeClient;
import android.webkit.WebSettings;
import android.webkit.WebView;
import android.webkit.WebViewClient;
import android.widget.TextView;

/**
* 原生webview js与安卓互调
*/

public class JsJavaActivity extends Activity {

private WebView webview;
private TextView tvJs;
private TextView tvJsArgs;
private TextView tvShowmsg;


@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_jsjava);

setWebview();
initView();
}

private void initView() {
tvJs = (TextView) findViewById(R.id.tv_androidcalljs);
tvJsArgs = (TextView) findViewById(R.id.tv_androidcalljsargs);
tvShowmsg = (TextView) findViewById(R.id.tv_showmsg);

tvJs.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
webview.loadUrl("javascript:javacalljs()");
}
});

tvJsArgs.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
webview.loadUrl("javascript:javacalljswith(" + "'Android传过来的参数'" + ")");
}
});
}

private void setWebview() {
webview = (WebView) findViewById(R.id.webview);
WebSettings webSettings = webview.getSettings();
webSettings.setBuiltInZoomControls(true);
webSettings.setSupportZoom(true);
//与js交互必须设置
webSettings.setJavaScriptEnabled(true);
webview.loadUrl("file:///android_asset/html.html");
webview.addJavascriptInterface(JsJavaActivity.this,"android");
}

@JavascriptInterface
public void jsCallAndroid(){
tvShowmsg.setText("Js调用Android方法");
}

@JavascriptInterface
public void jsCallAndroidArgs(String args){
tvShowmsg.setText(args);
}

}

三 常用的几个方法和注意点

3.1 WebViewClient中的shouldOverrideUrlLoading拦截url

安卓webview中setWebViewClient方法中需要一个WebViewClient对象,而WebViewClient中有个方法为shouldOverrideUrlLoading,通过此方法可以进行我们需要跳转的url地址的拦截,并根据我们需要进行自定义化的一些操作,解析url做相应的事情。

3.2 WebViewClient中的onPageStarted

onPageStarted会在webview加载相应的url开始之前进行调用,常用来处理需要在加载相应url之前的一些操作。

3.3 WebViewClient中的onPageFinished

onPageStarted会在webview加载相应的url结束之后进行调用,常用来处理需要在加载相应url之后的一些操作,比如加载后更加网页标题填充原生页面最上方的活动标题。

3.4 webview的evaluateJavascript方法

该方法的执行不会使页面刷新,而方法(loadUrl )的执行则会使页面刷新。此Android 4.4 后才可使用。

//拦截url
webview.setWebViewClient(new WebViewClient(){
@Override
public boolean shouldOverrideUrlLoading(WebView view, String url) {
if (url.indexOf("jump")>-1){
Toast.makeText(JsJavaActivity.this,"拦截到了相应url",Toast.LENGTH_LONG).show();
return true;
}else if (url.startsWith("http")){
view.loadUrl(url);
return true;
}
return false;
}


@Override
public void onPageStarted(WebView view, String url, Bitmap favicon) {
super.onPageStarted(view, url, favicon);
// 开始加载页面时
}



@Override
public void onPageFinished(WebView view, String url) {
super.onPageFinished(view, url);
// 加载结束

//因为该方法的执行不会使页面刷新,而方法(loadUrl )的执行则会使页面刷新。
//Android 4.4 后才可使用
webview.evaluateJavascript("javascript:changename()", new ValueCallback<String>() {
@Override
public void onReceiveValue(String value) {
//此处为 js 返回的结果
Log.v("Native",value);
}
});


}

});
收起阅读 »

Glide源码解析

本次源码解析基于4.12.0,如有描述错误,请大佬们评论指出。一、Glide的用法 // RecyclerView中加载图片 @Override public void onBindViewHolder(PhotoViewHolder holder, int ...
继续阅读 »

本次源码解析基于4.12.0,如有描述错误,请大佬们评论指出。

一、Glide的用法

 // RecyclerView中加载图片
@Override
public void onBindViewHolder(PhotoViewHolder holder, int position) {
GlideApp.with(holder.itemView).load(list.get(position))
.transform(new RoundedCorners(40))
.diskCacheStrategy(DiskCacheStrategy.RESOURCE)
.placeholder(R.drawable.ic_launcher)
.error(R.drawable.ic_launcher)
.into(holder.imageView);
}

二、Glide一些面试常考点

  • 2.1、 Glide如何感知Application、Activity、Fragment的生命周期?

Q:先问下如果你想感知application的那几个内存不足的方法,你会怎么做。
ComponentCallbacks2是系统提供的类。 image.png image.png

Application类管理这些订阅者,方法回调时,遍历通知。 image.png

Glide中的trimMemory收到事件通知后的已做处理,不需要我们自己再去清理Glide的资源占用。 image.png

Q:页面如果有ImageView,加载图片时立马页面关闭/返回,此时应该停止加载,Glide如何感知Activity/Fragment的onDestroy呢?

当然是在对应的Act或者Fragment中插入空白SupportRequestManagerFragment实现。

GlideApp.with(activity).load("https://t7.baidu.com/it/u=3652245443,3894439772&fm=193&f=GIF").into(view);
GlideApp.with(fragment).load("https://t7.baidu.com/it/u=3652245443,3894439772&fm=193&f=GIF").into(view);

image.png

image.png 如果说我们的Activity有个ImageView,它里面有两个Fragment也加载ImageView,按照规范,with方法应该是基于ImageView所处的context来决定,该传Act就传Act,该传Fragment就传Framgent没错,这样一来,Glide就嵌入3个SupportRequestManagerFragment进入了我们的页面。有点厉害哦。

但是如果Fragment里面不小心写成了下面这样

//fragment中的ImageView
GlideApp.with(view).load("https://t7.baidu.com/it/u=3652245443,3894439772&fm=193&f=GIF").into(view);

用一个简单的demo模拟这种场景,Glide从View去找Fragment竟然找不到, \color{#ff0000}{既然找不到View所在的Fragment容器,那就只能用跟Activity保持一致了} 所以小伙子们写with方法的时候要注意啊。(记得之前我用过kotlin中Fragment拓展方法,可以通过View找到其所在的Fragment)

image.png image.png

  • 2.2、 Glide的MemoryCache(LruResourceCache)和LruBitmapPool以及DiskLruCache默认size多大呢?

    final int MEMORY_CACHE_TARGET_SCREENS = 2;
final int BITMAP_POOL_TARGET_SCREENS =Build.VERSION.SDK_INT < Build.VERSION_CODES.O ? 4 : 1;
int widthPixels =context.getResources().getDisplayMetrics().widthPixels;
int heightPixels =context.getResources().getDisplayMetrics().heightPixels;
int screenSize = widthPixels * heightPixels * BYTES_PER_ARGB_8888_PIXEL;
//8及其8以上4张图图片的size
int targetBitmapPoolSize = Math.round(screenSize * BITMAP_POOL_TARGET_SCREENS);
//2张屏幕大小的size
int targetMemoryCacheSize = Math.round(screenSize * MEMORY_CACHE_TARGET_SCREENS);

int DEFAULT_DISK_CACHE_SIZE = 250 * 1024 * 1024;
String DEFAULT_DISK_CACHE_DIR = "image_manager_disk_cache";
File cacheDirectory = context.getCacheDir();

LruResourceCache默认: 只有2张屏幕大小的图片size
LruBitmapPool默认: 只有1 or 4张的屏幕大小的Bitmap可以复用;
DiskLruCache默认: 在内置SD卡且占用空间250M

image.png

  • 2.3、 三级缓存的添加和移除发生在什么时机?

    弱引用缓存(ResourceWeakReference)   内存缓存(LruResourceCache)   磁盘缓存(DiskLruCache)

    同一个Bitmap一旦从LruResourceCache取出(remove)了,那它就只有弱引用缓存了,如果弱引用清除时,就是LruCache加入缓存时,看样子二者不能共存?

    目前测试的结果:一旦图片加载ok了,先加入弱引用缓存,如果是recyclerView列表,item复用,ImageView会被into很多次,该Bitmap对应的弱引用早就没了,此时Bitmap会加入LruResourceCache。如果不是列表是页面加载的ImageView,当页面关闭时,Bitmap的弱引用清除,此时会加入LruResourceCache。如果LruResourceCache内存不够,那就进行trim。

    DiskLruCache下文讲。

  • 2.4、 Glide如何区分一个Url的内容是png,还是jpg,还是gif的呢?

先让大家看一下内容,后面会贯通讲下。

image.png

  • 2.5、  设置BitmapFactory.Options.inBitmap作用

BitmapFactory.Options.inBitmap = lruBitmapPool.getDirty(width, height, expectedConfig)
inBitmap表示要复用Bitmap,该Bitmap就来自LruBitmapPool,由于这个池本身容纳的bitmap数量有限,能提供还好,不能提供它还是直接createBitmap(新创建)返回Bitmap。

Q:如果BitmapPool中的有Bitmap存在,是不是一定可以复用?有没有啥限制?

@Override
public void reschedule() {
runReason = RunReason.SWITCH_TO_SOURCE_SERVICE;
getActiveSourceExecutor().execute(job);
}

image.png

第二次调用SourceGenerator的startNext就准备缓存到磁盘,这个缓存的就是源数据。

private void cacheData(Object dataToCache) {
try {
Encoder<Object> encoder = helper.getSourceEncoder(dataToCache);
DataCacheWriter<Object> writer =
new DataCacheWriter<>(encoder, dataToCache, helper.getOptions());
originalKey = new DataCacheKey(loadData.sourceKey, helper.getSignature());
helper.getDiskCache().put(originalKey, writer);
} finally {
loadData.fetcher.cleanup();
}
sourceCacheGenerator =
new DataCacheGenerator(Collections.singletonList(loadData.sourceKey), helper, this);
}

存了之后,就用NIO的方式读取刚刚缓存在磁盘里面的文件,这一套操作,是不是有点慢了,先存到本地再读取file获取ByteBuffer。

image.png

  • 3.2.3、根据DirectByteBuffer解码出Resource(Bitmap)

我们这里load进去的url就是一张图片,对应三条解码路径:

  • DirectByteBuffer->GifDrawable->Drawable
  • DirectByteBuffer->Bitmap->Drawable
  • DirectByteBuffer->BitmapDrawable->Drawable

但是不确定是哪一条,那就都试试,发现每次都从gif类型(ByteBufferGifDecoder)开始,不知是不是特意为之,如果类型不匹配就换下一个。

private Resource<ResourceType> decodeResourceWithList(DataRewinder<DataType> rewinder....)
throws GlideException {
Resource<ResourceType> result = null;
for (int i = 0, size = decoders.size(); i < size; i++) {
ResourceDecoder<DataType, ResourceType> decoder = decoders.get(i);
try {
DataType data = rewinder.rewindAndGet();
if (decoder.handles(data, options)) { //gif类型需要通过获取文件类型判断,bitmap则直接true
data = rewinder.rewindAndGet(); //重置buffer读取的位置到起始位置
result = decoder.decode(data, width, height, options);
}
} catch (IOException | RuntimeException | OutOfMemoryError e) {
exceptions.add(e);
}
if (result != null) {
break;
}
}
if (result == null) {
throw new GlideException(failureMessage, new ArrayList<>(exceptions));
}
return result;
}

image.png

解析ByteBuffer的文件类型关键代码来了:

// DefaultImageHeaderParser
@NonNull
private ImageType getType(Reader reader) throws IOException {
try {
final int firstTwoBytes = reader.getUInt16();
// JPEG.类型读取两个字节就可以判断了
if (firstTwoBytes == EXIF_MAGIC_NUMBER) {
return JPEG;
}
//gif要读3字节
final int firstThreeBytes = (firstTwoBytes << 8) | reader.getUInt8();
if (firstThreeBytes == GIF_HEADER) {
return GIF;
}
//png要读4字节
final int firstFourBytes = (firstThreeBytes << 8) | reader.getUInt8();
if (firstFourBytes == PNG_HEADER) {
reader.skip(25 - 4);
try {
int alpha = reader.getUInt8();
return alpha >= 3 ? PNG_A : PNG;
} catch (Reader.EndOfFileException e) {
return PNG;
}
}
//更多其他类型不列举了
// WebP (reads up to 21 bytes).
......
return UNKNOWN;
}
}

每一次尝试,缓冲区都会读一些字节,下次尝试还是要从头开始,此时就需要重置位置为0,所以搞了个ByteBufferRewinder(rewind--倒带)来干这事。 image.png

很明显,我们这个不是Gif图,那就换下一个试试ByteBufferBitmapDecoder。 image.png

先将ByteBuffer转换InputStream,看到InputStream,是不是跟Bitmap很近了,它先获取流中Bitmap的宽高和是否有旋转角度,以及是否配置Target.SIZE_ORIGINAL来调整目标宽高,一般来说,图片无旋转,且图片没有显式配置是Target.SIZE_ORIGINAL,那么目标宽高就是我们之前获取的宽高(不记得了就看上面的)。

然后再次检测文件类型(不明白之前已经尝试gif类型判断时,已经得出了图片类型,但是它没保存,此时还要再获取一次,差评!),基于scaleType综合考虑采样率,代码太多了,就不贴了。在流保存Bitmap之前,设置Bitmap走复用。

private static void setInBitmap(
BitmapFactory.Options options, BitmapPool bitmapPool, int width, int height)
{
.....
options.inBitmap = bitmapPool.getDirty(width, height, expectedConfig);
.....
}
.......//一系列配置整完后 bitmap操作开始了
Bitmap downsampled = BitmapFactory.decodeStream(dataRewinder.rewindAndGet(), null, options);
callbacks.onDecodeComplete(bitmapPool, downsampled);
Bitmap rotated = null;
if (downsampled != null) {
downsampled.setDensity(displayMetrics.densityDpi);
//开始旋转Bitmap了,又是很好的可以抄袭的地方,以后有旋转bitmap的场景也这么干
rotated = TransformationUtils.rotateImageExif(bitmapPool, downsampled, orientation);
if (!downsampled.equals(rotated)) {
bitmapPool.put(downsampled);
}
}
return rotated;

image.png

  • 3.2.4、目标bitmap获取到,还要transform下,就是我们设置的什么圆角操作等啦。

public Resource<Transcode> decode(....){
//Resource<ResourceType> 就是 Resource<Bitmap>--->相当于拿到bitmap
Resource<ResourceType> decoded = decodeResource(rewinder, width, height, options);
//对bitmap做转换
Resource<ResourceType> transformed = callback.onResourceDecoded(decoded);
return transcoder.transcode(transformed, options);
}

callback.onResourceDecoded(decoded)很关键

<Z> Resource<Z> onResourceDecoded(DataSource dataSource, @NonNull Resource<Z> decoded) {
Class<Z> resourceSubClass = (Class<Z>) decoded.get().getClass();
Transformation<Z> appliedTransformation = null;
Resource<Z> transformed = decoded;
//磁盘缓存策略在这里发挥作用
if (dataSource != DataSource.RESOURCE_DISK_CACHE) {
//选取其中一个跟Bitmap匹配的Transformation操作
appliedTransformation = decodeHelper.getTransformation(resourceSubClass);
//应用操作
transformed = appliedTransformation.transform(glideContext, decoded, width, height);
}
//应用完之后,旧的bitmap直接让其回收
if (!decoded.equals(transformed)) {
decoded.recycle();
}
......
//DiskCacheStrategy.DATA的isResourceCacheable默认就是false了
//DiskCacheStrategy.AUTOMATIC经过了几重不明所以的判断,isFromAlternateCacheKey=false,导致也是false
//但是不影响,因为之前已经在本地缓存过一次源数据了
//所以这里专门为DiskCacheStrategy.RESOURCE和DiskCacheStrategy.ALL使用
if (diskCacheStrategy.isResourceCacheable(isFromAlternateCacheKey, dataSource, encodeStrategy)) {
.....
final Key key;
switch (encodeStrategy) {
case SOURCE: //源数据,,不太可能会走这个逻辑
key = new DataCacheKey(currentSourceKey, signature);
break;
case TRANSFORMED: //转换后的bitmap对应的key
key = new ResourceCacheKey(decodeHelper.getArrayPool(), currentSourceKey, signature,
width, height,appliedTransformation, resourceSubClass, options);
break;
.....
}
LockedResource<Z> lockedResult = LockedResource.obtain(transformed);
//拿到key,但是没有做缓存操作,因为defer是延迟处理的,后面会很快存转换后的数据到磁盘
deferredEncodeManager.init(key, encoder, lockedResult);
result = lockedResult;
}
return result;
}

image.png image.png 圆角的处理,以后有这种需求,也这么干。

  • 3.2.5、通知bitmap就绪了且按需保存转换的数据到磁盘。

 private void decodeFromRetrievedData() {
Resource<Bitmap> nresource = decodeFromData(currentFetcher, currentData, currentDataSource);
notifyEncodeAndRelease(resource, currentDataSource, isLoadingFromAlternateCacheKey);
}

//resource就是bitmap
private void notifyEncodeAndRelease(Resource<Bitmap> resource, DataSource dataSource, boolean isLoadedFromAlternateCacheKey) {
if (resource instanceof Initializable) {
// bitmap.prepareToDraw(); 预先将bitmap加载到gpu上
((Initializable) resource).get().prepareToDraw();
}
....
//通知engine以及回调给用户onResourceReady
....
//这里真正开始写入转换的后的数据
if (deferredEncodeManager.hasResourceToEncode()) {
deferredEncodeManager.encode(diskCacheProvider, options);
}
.....
}

//deferredEncodeManager //这里真正开始写入转换的后的数据
void encode(DiskCacheProvider diskCacheProvider, Options options) {
GlideTrace.beginSection("DecodeJob.encode");
try {
//bitmap缓存为file
diskCacheProvider.getDiskCache().put(key, new DataCacheWriter<>(encoder, toEncode, options));
} finally {
toEncode.unlock();
GlideTrace.endSection();
}
}
@Override
public void onResourceReady(@NonNull Bitmap resource, @Nullable Transition<? super Z> transition) {
if (transition == null || !transition.transition(resource, this)) {
//终于看到ImageView显示图片了
imageView.setImageBitmap(resource);
} else {
maybeUpdateAnimatable(resource);
}
}
//DiskCache进行put时,就会调用DataCacheWriter的wirte方法
//wirte方法就调用encoder的encode方法,将bitmap缓存到文件
public boolean encode( Resource<Bitmap> resource, File file, Options options) {
final Bitmap bitmap = resource.get();
Bitmap.CompressFormat format = getFormat(bitmap, options);
try {
int quality = options.get(COMPRESSION_QUALITY);
boolean success = false;
OutputStream os = null;
try {
os = new FileOutputStream(file);
if (arrayPool != null) {
os = new BufferedOutputStream(os, arrayPool);
}
bitmap.compress(format, quality, os);
os.close();
success = true;
} catch (IOException e) {
....
} finally {
if (os != null) {
try {
os.close();
} catch (IOException e) {
// Do nothing.
}
}
}
return success;
} finally {
GlideTrace.endSection();
}
}

大家仔细看下上面代码的注释。 当看到imageView.setImageBitmap(bitmap) 后,整个逻辑就走完了。

四、从以上加载流程来提出问题

  • 4.1、 DiskCacheStrategy.RESOURCE、DiskCacheStrategy.DATA、DiskCacheStrategy.AUTOMATIC有啥区别?

尽管DiskCacheStrategy.AUTOMATIC是默认,听说很智能,智能个鬼,从简单加载url显示bitmap来看,我暂时看不出它跟DiskCacheStrategy.DATA有啥子区别。
DiskCacheStrategy.RESOURCE:只缓存bitmap转换后的数据到磁盘,在SourceGenerator的startNextLoad去加载网络资源,下载回调返回的是流数据,直接拿着数据流,去解析,解析ok,最后转成Bitmap,bitmap根据用户设置的transform或者默认transform做一次转换,最后将转换后的bitmap缓存到磁盘。
DiskCacheStrategy.DATA:只缓存源数据到磁盘。在SourceGenerator的startNextLoad去加载网络资源,下载回调返回的是流数据,然后将流数据缓存以源数据缓存到磁盘,然后将本地的磁盘缓存的源数据file使用NIO读取为DirectByteBuffer,然后对这个byteBuffer进行一系列的解析处理:可以解析,就将bytebuffer转成inputStream,最后转成bitmap,后面流程差不多一样了。

如果让我选择磁盘缓存策略,我会优先选DiskCacheStrategy.RESOURCE,至少在我看来从默认的设置AUTOMATIC没看到优点。不知道有没有啥副作用啊。

  • 4.2、 实际场景中弱引用、MemoryCache添加移除时机?

首次从网络加载图片,当bitmap一切就绪,在ImageView上设置Bitmap时会发通知完成回调,此时资源bitmap的弱引用会被添加,,,此时LruCache中没有Bitmap哦,不要以为bitmap此时也加入到LruCache中了。

public synchronized void onEngineJobComplete(....) {
if (resource != null && resource.isMemoryCacheable()) {
//此时加入弱引用缓存中
activeResources.activate(key, resource);
}
.....
}

那LruCache的添加操作在何时呢?当资源释放的时候,比如我们的页面(含有Glide加载ImageVeiw)关闭,或者recyclerView的Item列表滑动复用item时,会触发弱引用的清除和LruCache对资源的添加。

@Override
public void onResourceReleased(Key cacheKey, EngineResource<?> resource) {
//资源释放的时候,就清空弱引用先将它放入队列里面的
activeResources.deactivate(cacheKey);
if (resource.isMemoryCacheable()) {
//资源释放的时候,弱引用清楚,此时Lru缓存加入进去
cache.put(cacheKey, resource);
}
.....
}
synchronized void deactivate(Key key) {
ResourceWeakReference removed = activeEngineResources.remove(key);
if (removed != null) {
removed.reset();
}
}

页面关闭/返回 image.png

recyclerView列表滑动 image.png

那MemoryCache缓存中何时取出,又是何时添加的
其实就是在发起请求前,Engine先从内存缓存中取,有就直接通知回调,没有就走后面一系列流程。

private EngineResource<?> loadFromMemory(EngineKey key...) {
if (!isMemoryCacheable) { //跳过缓存
return null;
}
//从弱引用ResourceWeakReference中查找
EngineResource<?> active = = activeResources.get(key);
if (active != null) {
active.acquire(); //资源被使用,引用++
return active;
}
//从MemoryCache中找,找出来就是从LruCache中移除,remove的返回值就是啊
EngineResource<?> cached = cache.remove(key);
final EngineResource<?> result;
if (cached == null) {
result = null;
} else if (cached instanceof EngineResource) {
result = (EngineResource<?>) cached;
} else {
result = new EngineResource<>( cached, true,true, key,this);
}
if (result != null) {
//资源被使用,引用++ 且 添加到弱引用中
result.acquire();
activeResources.activate(key, result);
return result;
}
return null;
}

看样子,资源弱引用存在,那LruResourceCache就不可能存在这个资源,二者属于不同阶段的一个相互补充,没得交集。

五、后续

本期只是针对load(url)做了一个简单的操作流转的记录,这个记录贯穿了一系列的知识点,对Glide的了解还是比较浅,后续对其ModelLoader、Gif、video加载这块,也做个补充吧。

收起阅读 »

Android 架构之OkHttp源码解读(上)

前言在我们编写Android程序时,OkHttp已经成为了我们必不可少的部分,但我们往往知道OkHttp怎么用,不知其原理。在本篇中,我将通过如下方式带你深入其原理。OkHttp 介绍OkHttp 调用流程socket 连接池复用机制高并发请求队列:任务分发责...
继续阅读 »

前言

在我们编写Android程序时,OkHttp已经成为了我们必不可少的部分,但我们往往知道OkHttp怎么用,不知其原理。在本篇中,我将通过如下方式带你深入其原理。

  • OkHttp 介绍

  • OkHttp 调用流程

  • socket 连接池复用机制

  • 高并发请求队列:任务分发

  • 责任链模式拦截器设计

1.0 OkHttp 介绍

由Square公司贡献的一个处理网络请求的开源项目,是目前Android使用最广泛的网络框架。从Android4.4开始HttpURLConnection的底层实现采用的是OkHttp。

谷歌官方在6.0以后再android sdk已经移除了httpclient,加入了okhttp。

很多知名网络框架,比如 Retrofit 底层也是基于OkHttp实现的。

1.1 OkHttp 调用流程

图片1.png

如图所示:

OkHttp请求过程中最少只需要接触OkHttpClient、Request、Call、Response,但是框架内部进行大量的逻辑处理。

所有的逻辑大部分集中在拦截器中,但是在进入拦截器之前还需要依靠分发器来调配请求任务。

  • . 分发器:内部维护队列与线程池,完成请求调配;
  • . 拦截器:五大默认拦截器完成整个请求过程。

1.2 socket 连接池复用机制

在了解socket 的复用连接池之前,我们首先要了解几个概念。

  1. TCP 三次握手
  2. TCP 四次挥手

1.2.1 TCP三次握手

2.png

如图所示

我们把客户端比喻成男生,服务器比喻成女生。男生在追求女生的时候,男生发送了求偶的信号,女生在接受到求偶信号后,表示愿意接受男生,于是向男生发送了我愿意,但你要给我彩礼钱的信号。男生收到女生愿意信号后,表示也愿意给彩礼钱,向女生递交了彩礼钱。

整个过程双方必须秉持着相互了解对方的意图并且相互同意的情况下,才能相互连接。连接成功后,将会保持一段时间的长连接,就好如男女朋友在一起的一段时间,当发现彼此不合时,就迎来了TCP四次挥手(分手)

1.2.2 TCP四次挥手

3.png

如图所示

我们依然将客户端比喻成男生,服务器比喻成女生。当男生发现女生太做作了,不合适时,就向女生提出了分手,女生第一时间给男生反应,你为什么要分手?过后女生也想明白了,就再次问男生是不是确定要分手?男生实在不想继续下去了,于是就向女生表明了确定要分手。

在整个TCP四次挥手过程中,只要有一方提出了断开连接,另一方在收了到断开连接信息后,先是表明已经收到了断开连接提示,然后再次提出方发送是否确认断开的提示,当收到确认断开信息时,双方才能断开整个TCP连接。

所以为什么会有连接复用?或者说连接复用为什么会提高性能?

通常我们在发起http请求的时候首先要完成tcp的三次握手,然后传输数据,最后再释放连接。三次握手的过程可以参考这里 TCP三次握手详解及释放连接过程。 一次Http响应的过程 在这里插入图片描述

如图所示:

在高并发的请求连接情况下或者同个客户端多次频繁的请求操作,无限制的创建会导致性能低下。 因此http有一种叫做keepalive connections的机制,它可以在传输数据后仍然保持连接,当客户端需要再次获取数据时,直接使用刚刚空闲下来的连接而不需要再次握手。

在这里插入图片描述

Okhttp支持5个并发KeepAlive,默认链路生命为5分钟(链路空闲后,保持存活的时间)。

1.2.3 连接池(ConnectionPool)分析

public final class ConnectionPool {
/**
* Background threads are used to cleanup expired connections. There will be at most a single
* thread running per connection pool. The thread pool executor permits the pool itself to be
* garbage collected.
*/

private static final Executor executor = new ThreadPoolExecutor(0 /* corePoolSize */,
Integer.MAX_VALUE /* maximumPoolSize */, 60L /* keepAliveTime */, TimeUnit.SECONDS,
new SynchronousQueue<Runnable>(), Util.threadFactory("OkHttp ConnectionPool", true));

/** The maximum number of idle connections for each address. */
//每个地址的最大空闲连接数。
private final int maxIdleConnections;
//每个地址的最长保持时间
private final long keepAliveDurationNs;
private final Runnable cleanupRunnable = new Runnable() {
@Override public void run() {
while (true) {
long waitNanos = cleanup(System.nanoTime());
if (waitNanos == -1) return;
if (waitNanos > 0) {
long waitMillis = waitNanos / 1000000L;
waitNanos -= (waitMillis * 1000000L);
synchronized (ConnectionPool.this) {
try {
ConnectionPool.this.wait(waitMillis, (int) waitNanos);
} catch (InterruptedException ignored) {
}
}
}
}
}
};
// 双向队列
private final Deque<RealConnection> connections = new ArrayDeque<>();
final RouteDatabase routeDatabase = new RouteDatabase();
boolean cleanupRunning;
....

源码解析

  • Executor executor:线程池,用来检测闲置socket并对其进行清理。
  • Deque connections:缓存池。Deque 是一个双端列表,支持在头尾插入元素,这里用作LIFO(后进先出)堆栈,多用于缓存数据。
  • RouteDatabase routeDatabase:用来记录连接失败的router。

1、缓存操作

ConnectionPool提供对Deque进行操作的方法分别对put、get、connectionBecameIdle、evictAll几个操作。分别对应放入连接、获取连接、移除连接、移除所有连接操作。这里举例put和get操作。

put操作

  void put(RealConnection connection) {
assert (Thread.holdsLock(this));
if (!cleanupRunning) {
cleanupRunning = true;
//下文重点讲解
executor.execute(cleanupRunnable);
}
connections.add(connection);
}

源码解析

可以看到在新的connection 放进列表之前执行清理闲置连接的线程。 既然是复用,那么看下他获取连接的方式。

get操作

  /**
* Returns a recycled connection to {@code address}, or null if no such connection exists. The
* route is null if the address has not yet been routed.
*/

@Nullable RealConnection get(Address address, StreamAllocation streamAllocation, Route route) {
assert (Thread.holdsLock(this));
for (RealConnection connection : connections) {
if (connection.isEligible(address, route)) {
streamAllocation.acquire(connection, true);
return connection;
}
}
return null;
}

源码解析

遍历connections缓存列表,当某个连接计数的次数小于限制的大小以及request的地址和缓存列表中此连接的地址完全匹配。则直接复用缓存列表中的connection作为request的连接。

2、连接池清理和回收

上文我们讲到 Executor 线程池,用来清理闲置socket连接的。我们在put新连接到队列的时候会先执行清理闲置线程连接的线程,调用的是: executor.execute(cleanupRunnable),接下来我们就来分析:cleanupRunnable。

  private final Runnable cleanupRunnable = new Runnable() {
@Override public void run() {
while (true) {
long waitNanos = cleanup(System.nanoTime());
if (waitNanos == -1) return;
if (waitNanos > 0) {
long waitMillis = waitNanos / 1000000L;
waitNanos -= (waitMillis * 1000000L);
synchronized (ConnectionPool.this) {
try {
ConnectionPool.this.wait(waitMillis, (int) waitNanos);
} catch (InterruptedException ignored) {
}
}
}
}
}
};

源码解析

线程中不停调用Cleanup 清理的动作并立即返回下次清理的间隔时间。继而进入wait 等待之后释放锁,继续执行下一次的清理。所以可能理解成他是个监测时间并释放连接的后台线程。 所以在这只要了解cleanup动作的过程,就清楚了这个线程池是如何回收的了

3、总结

到这,整个socket连接池复用机制讲完了。连接池复用的核心就是用Deque来存储连接,通过put、get、connectionBecameIdle、evictAll几个操作。另外通过判断连接中的计数对象StreamAllocation来进行自动回收连接。

1.3 高并发请求队列:任务分发

图片4.png

如图所示

当我们进行多网络接口请求时,将会通过对应任务分发器分派对应的任务。在解读源码之前,将会先手写一份直播任务分发的小demo,先理解何为分发器,方便后面更容易理解OkHttp是如何进行分发的。

1.3.1、手写直播分发demo

需求整理:

当用户进入直播界面的时候,用户首先能看到主播流所展示的页面,其次红包流、购物车流、以及其他流所展示的界面布局。而且这些附加流可动态控制,每个模块也必须单独做自己模块的事。

先定义 直播任务分发器

  • LivePart
public abstract class LivePart {
public abstract void dispatch(BaseEvent event);
}
  • BaseEvent
public abstract class BaseEvent {

}
  • LiveEvent
//用于通知开播事件类
public class LiveEvent extends BaseEvent{
}

定义对应直播流

  • 主播流 SmallVideoPart
//事件分发机制
public class SmallVideoPart extends LivePart {
@Override
public void dispatch(BaseEvent event) {
if(event instanceof LiveEvent){
System.out.println("主播流来了,其他小视频窗口流要渲染出来了");
//可在这执行直播流相关的逻辑
}

}
}
  • 红包流 RedPackPart
//红包部件干他自己的事情
public class RedPackPart extends LivePart {
@Override
public void dispatch(BaseEvent event) {
if(event instanceof LiveEvent) {
System.out.println("直播流来了,红包准备开始");
//可在这执行红包相关的逻辑
}
}
}

  • 购物车流 GouwuchePart

哈哈哈,看到这是不是游刃有余呢?不过这里与同步请求不同的是,这里有俩个队列,一个正在执行的队列,一个为等待队列。 从这段代码里可知,什么时候进正在执行队列,什么时候进等待队列。 那么问题来了,已经进入等待队列里面的请求,什么时候迁移到执行队列里面来呢? 答案就在于这个方法的请求参 AsyncCall ,其实它就是一个Runnable ,进去寻找答案。

final class AsyncCall extends NamedRunnable {
private final Callback responseCallback;

String host() {
return originalRequest.url().host();
}

Request request() {
return originalRequest;
}

RealCall get() {
return RealCall.this;
}

AsyncCall(Callback responseCallback) {
super("OkHttp %s", redactedUrl());
this.responseCallback = responseCallback;
}

@Override protected void execute() {
boolean signalledCallback = false;
try {
//后面会重点讲解这getResponseWithInterceptorChain 方法
Response response = getResponseWithInterceptorChain();
if (retryAndFollowUpInterceptor.isCanceled()) {
signalledCallback = true;
responseCallback.onFailure(RealCall.this, new IOException("Canceled"));
} else {
signalledCallback = true;
responseCallback.onResponse(RealCall.this, response);
}
} catch (IOException e) {
if (signalledCallback) {
// Do not signal the callback twice!
Platform.get().log(INFO, "Callback failure for " + toLoggableString(), e);
} else {
eventListener.callFailed(RealCall.this, e);
responseCallback.onFailure(RealCall.this, e);
}
} finally {
// 当请求执行完成调用了Dispatcher的finished方法
client.dispatcher().finished(this);
}
}
}

源码分析

这里就是网络请求的核心类,不过在这不用看那么多,只需要看最后 finally 调用了 finished 方法。也就是说每个网络请求结束时,都会调用该方法,这还没完全找到答案,继续追进dispatcher的 finished方法。

  /** Used by {@code AsyncCall#run} to signal completion. */
void finished(AsyncCall call) {
finished(runningAsyncCalls, call, true);
}

继续深入

  private <T> void finished(Deque<T> calls, T call, boolean promoteCalls) {
int runningCallsCount;
Runnable idleCallback;
synchronized (this) {
if (!calls.remove(call)) throw new AssertionError("Call wasn't in-flight!");
// promoteCalls这里是true, 执行promoteCalls()
if (promoteCalls) promoteCalls();
runningCallsCount = runningCallsCount();
idleCallback = this.idleCallback;
}

if (runningCallsCount == 0 && idleCallback != null) {
idleCallback.run();
}
}

这里第三个变量为true,也就是 promoteCalls 这个方法是必然执行的,那么进这个方法看看。

  private void promoteCalls() {
// 如果执行的队列请求数量超过64个,直接return
if (runningAsyncCalls.size() >= maxRequests) return; // Already running max capacity.
// 如果等待的队列请求数量为空,直接return
if (readyAsyncCalls.isEmpty()) return; // No ready calls to promote.
// 遍历等待队列
for (Iterator<AsyncCall> i = readyAsyncCalls.iterator(); i.hasNext(); ) {
AsyncCall call = i.next();
// 检查一下正在执行的同一host的请求数量是不是不满5个
if (runningCallsForHost(call) < maxRequestsPerHost) {
// 满足条件,当前等待任务移出等待队列
i.remove();
//当前被移除等待队列的任务加入正在执行队列
runningAsyncCalls.add(call);
//直接执行请求任务!
executorService().execute(call);
}
if (runningAsyncCalls.size() >= maxRequests) return; // Reached max capacity.
}
}

1.3.3、总结

哈哈哈哈,相信能看到这,上面提的问题直接迎刃而解。是不是很简单?再来一张图总结一下。

图片5.png

1.4 责任链模式拦截器设计

在上文讲解 Dispatcher 分发器的时候,里面讲解了异步请求,并且贴出了 AsyncCall 代码段,再次在这里贴一次。

final class AsyncCall extends NamedRunnable {
...略
@Override protected void execute() {
boolean signalledCallback = false;
try {
Response response = getResponseWithInterceptorChain();
...略
} catch (IOException e) {
...略
} finally {
client.dispatcher().finished(this);
}
}
}

同步调用

  @Override public Response execute() throws IOException {
...略
try {
client.dispatcher().executed(this);
Response result = getResponseWithInterceptorChain();
if (result == null) throw new IOException("Canceled");
return result;
} catch (IOException e) {
eventListener.callFailed(this, e);
throw e;
} finally {
client.dispatcher().finished(this);
}
}

源码分析

这里可以看出 同步、异步调用 代码段里面,都调用了 getResponseWithInterceptorChain 方法。既然都调用了这方法,那我们进入一探究竟。

  Response getResponseWithInterceptorChain() throws IOException {
// Build a full stack of interceptors.
List<Interceptor> interceptors = new ArrayList<>();
//开发者自定义拦截器
interceptors.addAll(client.interceptors());
// RetryAndFollowUpInterceptor (重定向拦截器)
interceptors.add(retryAndFollowUpInterceptor);
// BridgeInterceptor (桥接拦截器)
interceptors.add(new BridgeInterceptor(client.cookieJar()));
//CacheInterceptor (缓存拦截器)
interceptors.add(new CacheInterceptor(client.internalCache()));
// ConnectInterceptor (连接拦截器)
interceptors.add(new ConnectInterceptor(client));
if (!forWebSocket) {
//开发者自定义拦截器
interceptors.addAll(client.networkInterceptors());
}
//CallServerInterceptor(读写拦截器)
interceptors.add(new CallServerInterceptor(forWebSocket));

Interceptor.Chain chain = new RealInterceptorChain(interceptors, null, null, null, 0,
originalRequest, this, eventListener, client.connectTimeoutMillis(),
client.readTimeoutMillis(), client.writeTimeoutMillis());

return chain.proceed(originalRequest);
}

源码分析

这段代码,我们可以理解成添加了一系列责任链拦截器。那么问题又来了。 何为责任链?何为拦截器?它们有什么作用? 在本篇里,先让你理解这些,然后在下一篇里具体详解每一个拦截器。 如果理解什么是责任链拦截器的读者也可以选择跳过下面内容,直接看下一篇 Android 架构之OkHttp源码解读(中)

在本篇里,我准备了俩个小demo,相信看完后应该能有所收获。

1.4.1 模拟公司员工报销Demo

需求整理

现在要写一个报销系统,其中组长报销额度为1000;主管报销额度为5000;经理报销额度为10000;boos报销额度为10000+。

代码如下

1、Leader

public abstract class Leader {
//上级领导
public Leader nextHandler;
/**
* 处理报账请求
* @param money 能批复的报账额度
*/

public final void handleRequest(int money){
System.out.println(getLeader());
if(money <=limit()){
handle(money);
}else{
System.out.println("报账额度不足,提交领导");
if(null != nextHandler){
nextHandler.handleRequest(money);
}
}
}
/**
* 自身能批复的额度权限
* @return 额度
*/

public abstract int limit();
/**
* 处理报账行为
* @param money 具体金额
*/

public abstract void handle(int money);
/**
* 获取处理者
* @return 处理者
*/

public abstract String getLeader();
}

代码分析

该类可看作为 领导基类,具有报销功能的人。

2、组长

//组长(额度1000)
public class GroupLeader extends Leader {
@Override
public int limit() {
return 1000;
}
@Override
public void handle(int money) {
System.out.println("组长批复报销"+ money +"元");
}
@Override
public String getLeader() {
return "当前是组长";
}
}

3、主管

//主管(额度5000):
public class Director extends Leader {
@Override
public int limit() {
return 5000;
}
@Override
public void handle(int money) {
System.out.println("主管批复报销"+ money +"元");
}
@Override
public String getLeader() {
return "当前是主管";
}
}

4、经理

//经理(额度10000)
public class Manager extends Leader {
@Override
public int limit() {
return 10000;
}
@Override
public void handle(int money) {
System.out.println("经理批复报销"+ money +"元");
}
@Override
public String getLeader() {
return "当前是经理";
}
}

5、boos

//老板
public class Boss extends Leader {
@Override
public int limit() {
return Integer.MAX_VALUE;
}
@Override
public void handle(int money) {
System.out.println("老板批复报销"+ money +"元");
}
@Override
public String getLeader() {
return "当前是老板";
}
}

6、开始报销

    //员工要报销  员工-》组长-》主管-》经理-》老板
//员工报销8000块
private void bxMoney(){
GroupLeader groupLeader = new GroupLeader();
Director director = new Director();
Manager manager = new Manager();
Boss boss = new Boss();
//设置上级领导处理者对象,组长的上级为主管
groupLeader.nextHandler = director;
//设置主管上级为经理
director.nextHandler = manager;
//设置经理上级为boos
manager.nextHandler = boss;
//这种责任链不好,还需要指定下一个处理对象
//发起报账申请
groupLeader.handleRequest(8000);
}

7、运行效果

 I/System.out: 当前是组长
I/System.out: 报账额度不足,提交领导
I/System.out: 当前是主管
I/System.out: 报账额度不足,提交领导
I/System.out: 当前是经理
I/System.out: 经理批复报销8000

8、总结

到这,相信你对责任链有了一个初步的认知,上一级做不好的交给下一级,但是这种责任链并不好,因为要通过代码手动指定责任链下一级到底是谁,而我们看到的OkHttp框架里面并不是用的这种模式。所以就迎来了第二个demo。

1.4.2 模拟支付场景Demo

需求整理

小明去超市里面买东西,结账的时候发现微信和支付宝的余额都不足,但是支付宝和微信里面余额加起来能够付款,于是小明 选择了微信、支付宝混合支付;小白也去超市买东西,但他的支付宝、微信金额都远远大于结账金额,于是他可以任选其一支付。

代码如下

1、定义一个具有支付能力的基类

public abstract class AbstractPay {

/**
* 支付宝支付
*/

public static int ALI_PAY = 1;

/**
* 微信支付
*/

public static int WX_PAY = 2;
/**
* 两者支付方式
*/

public static int ALL_PAY = 3;

/**
* 条码支付
*
* @param payRequest
* @param abstractPay
*/

abstract protected void barCode(PayRequest payRequest, AbstractPay abstractPay);
}

2、支付宝支付

public class AliPay extends AbstractPay {
@Override
public void barCode(PayRequest payRequest, AbstractPay abstractPay) {
if (payRequest.getPayCode() == ALI_PAY) {
System.out.println("支付宝扫码支付");
} else if(payRequest.getPayCode() == ALL_PAY){
System.out.println("支付宝扫码支付完成,等待下一步");
abstractPay.barCode(payRequest, abstractPay);
}else {
abstractPay.barCode(payRequest, abstractPay);
}
}
}

3、微信支付

public class WxPay extends AbstractPay {
@Override
public void barCode(PayRequest payRequest, AbstractPay abstractPay) {
if (payRequest.getPayCode() == WX_PAY) {
System.out.println("微信扫码支付");
} else if(payRequest.getPayCode() == ALL_PAY){
System.out.println("微信扫码支付完成,等待下一步");
abstractPay.barCode(payRequest, abstractPay);
}else {
abstractPay.barCode(payRequest, abstractPay);
}
}
}

4、待支付的商品

/**
* 待支付商品
*/

public class PayRequest {
//待选择的支付方式
private int payCode=0;

public int getPayCode() {
return payCode;
}

public void setPayCode(int payCode) {
this.payCode = payCode;
}
}

5、支付操作类

public class PayChain extends AbstractPay {
/**
* 完整责任链列表
*/

private List<AbstractPay> list = new ArrayList<>();

/**
* 索引
*/

private int index = 0;

/**
* 添加责任对象
*
* @param abstractPay
* @return
*/

public PayChain add(AbstractPay abstractPay) {
list.add(abstractPay);
return this;
}

@Override
public void barCode(PayRequest payRequest, AbstractPay abstractPay) {
// 所有遍历完了,直接返回
if (index == list.size()) {
System.out.println("支付全部完成,请取商品");
return;
}
// 获取当前责任对象
AbstractPay current = list.get(index);
// 修改索引值,以便下次回调获取下个节点,达到遍历效果
index++;
// 调用当前责任对象处理方法
current.barCode(payRequest, this);
}
}

6、开始支付

    private void scanMoney() {
PayRequest payRequest = new PayRequest();
//1、支付宝支付;2、微信支付;3、两者支付方式
payRequest.setPayCode(3);
PayChain chain = new PayChain();
chain.add(new AliPay());
chain.add(new WxPay());
chain.barCode(payRequest, chain);
}

7、运行效果

 I/System.out: 支付宝扫码支付完成,等待下一步
I/System.out: 微信扫码支付完成,等待下一步
I/System.out: 支付全部完成,请取商品

看这段代码结构是否似曾相识?这不就是OkHttp添加拦截器的格式么? 那么是不是可以假设一下,OkHttp添加的拦截器,是否也按照demo的方式执行的? 在这里再次贴一下OkHttp添加拦截器的代码段。

  Response getResponseWithInterceptorChain() throws IOException {
// Build a full stack of interceptors.
List<Interceptor> interceptors = new ArrayList<>();
//开发者自定义拦截器
interceptors.addAll(client.interceptors());
// RetryAndFollowUpInterceptor (重定向拦截器)
interceptors.add(retryAndFollowUpInterceptor);
// BridgeInterceptor (桥接拦截器)
interceptors.add(new BridgeInterceptor(client.cookieJar()));
//CacheInterceptor (缓存拦截器)
interceptors.add(new CacheInterceptor(client.internalCache()));
// ConnectInterceptor (连接拦截器)
interceptors.add(new ConnectInterceptor(client));
if (!forWebSocket) {
//开发者自定义拦截器
interceptors.addAll(client.networkInterceptors());
}
//CallServerInterceptor(读写拦截器)
interceptors.add(new CallServerInterceptor(forWebSocket));

Interceptor.Chain chain = new RealInterceptorChain(interceptors, null, null, null, 0,
originalRequest, this, eventListener, client.connectTimeoutMillis(),
client.readTimeoutMillis(), client.writeTimeoutMillis());

return chain.proceed(originalRequest);
}

源码解读

这里看添加方式几乎和demo一样,那么使用呢?源码最后一句调用了RealInterceptorChain.proceed方法,我们进去看看。

 @Override public Response proceed(Request request) throws IOException {
return proceed(request, streamAllocation, httpCodec, connection);
}

public Response proceed(Request request, StreamAllocation streamAllocation, HttpCodec httpCodec,
RealConnection connection) throws IOException {
if (index >= interceptors.size())
throw new AssertionError();
...略
calls++;
...略
// Call the next interceptor in the chain.
RealInterceptorChain next = new RealInterceptorChain(interceptors, streamAllocation, httpCodec,
connection, index + 1, request, call, eventListener, connectTimeout, readTimeout,
writeTimeout);
Interceptor interceptor = interceptors.get(index);
Response response = interceptor.intercept(next);
...略
return response;
}

源码解读

看这,具体使用也和demo如出一辙,拦截器使用完了,demo选择的return结束,okHttp选择抛异常结束;每当一个拦截器使用完了,就会继续切换下一个拦截器。好了,本篇文章就到这差不多结束了,最后再来个总结。

8、总结

相信看到这里的小伙伴,你应该理解了OkHttp的重要性、调用流程、连接池复用、任务分发、以及这样添加责任链拦截器的原因。


收起阅读 »

Retrofit解析

本次源码解析基于2.9.0,如有描述错误,请大佬们评论指出。一、Retrofit的作用Retrofit基于okhttp,简化了okhttp请求接口的操作,而且适配Rxjava和kotlin的协程,但目前还没有适配kotlin的Flow,如果要适配,自己封装也是...
继续阅读 »

本次源码解析基于2.9.0,如有描述错误,请大佬们评论指出。

一、Retrofit的作用

Retrofit基于okhttp,简化了okhttp请求接口的操作,而且适配Rxjava和kotlin的协程,但目前还没有适配kotlin的Flow,如果要适配,自己封装也是可以的。

先看看早期直接使用okhttp请求 image.png 构造请求+解析响应+使用okhttp的线程池执行(当然okhttp也有同步调用),一堆操作很是麻烦,如果加上loading显示/隐藏、线程切换代码会更加复杂,retrofit+rxjava的经典搭配适应潮流就出现了。

retrofit适配的返回值 image.png 支持协程的话,小伙伴可能会懵逼了,注解啥的都好说,这个都好处理,它怎么拿到方法上的suspend,其实retrofit不需要拿suspend这个修饰符,因为java压根没有suspend,编译之后显真身,suspend在kotlin看来就只是一个挂起函数标志,在编译成java字节码后偷偷摸摸多了个用于回调的接口Continuation。 image.png 先来看看retrofit用法

//就是创建我们的retrofit客户端
public class HttpManager {
private Retrofit mRetrofit;
private Map<Class<?>, Object> mMap = new HashMap<>();
private static class SingletonInstance {
private static final HttpManager INSTANCE = new HttpManager();
}
public static HttpManager getInstance() {
return SingletonInstance.INSTANCE;
}
private HttpManager() {
mRetrofit = new Retrofit.Builder()
.client(自定义的okhttpClient) //不写的话,retrofit也会默认创建
.baseUrl("https://xxxx.xxxx.cn")
.addConverterFactory(ScalarsConverterFactory.create())//转换为String对象
.addConverterFactory(GsonConverterFactory.create())//转换为Gson对象
//接口返回值适配
.addCallAdapterFactory(RxJava2CallAdapterFactory.create())
.build();
}
public <T> T create(Class<T> cls) {
if (!mMap.containsKey(cls)) {
T t = mRetrofit.create(cls);
mMap.put(cls, t);
}
return (T) mMap.get(cls);
}
}

image.png image.png image.png

Q: 那个Call直接可以enqueue,那个observeable在subscribe后数据就可以接收,协程挂起恢复后就直接返回了,有点厉害,咋实现?
拿简单的observable来说,我们会使用create方法创建一个Observable,然后需要自己管理数据的发射,retrofit操作的Observable估计也要自行处理数据的发射???是这样么?后文解释。 image.png

Q: 方法上有注解,请求参数也有注解,返参还有泛型,这个怎么处理?
方法上有注解,请求参数也有注解,拿到method后解析注解,这个不难,拿到这些注解后,构建Request,如果是post的话,还要构造RequestBody,要注明MediaType,返回值是Call、Observable等决定是走默认的还是rxjava,或者协程,返回值上的泛型也很关键,在okhttp的rawResponse拿到后,要解析响应,需要预先选择合适的解析器解析数据。

二、从Retrofit+Observable请求post接口熟悉流程

  • 2.1、post请求编写

image.png 这里没有为协程专门搞个什么CallAdapterFactory哦,因为协程走默认的DefaultCallAdapterFactory。 这个默认的在创建Retrofit对象时添加进去的。 image.png image.png

  • 2.2、 retrofit的创建--->Retrofit的build方法

public Retrofit build() {
.....
okhttp3.Call.Factory callFactory = this.callFactory;
if (callFactory == null) { //没有就默认可以给你创建OkhttpClient
callFactory = new OkHttpClient();
}
Executor callbackExecutor = this.callbackExecutor;
if (callbackExecutor == null) { //回调的线程池,安卓默认就是主线程切换
callbackExecutor = new MainThreadExecutor();
}
//添加默认的 new DefaultCallAdapterFactory(callbackExecutor)
List<CallAdapter.Factory> callAdapterFactories = new ArrayList<>(this.callAdapterFactories);
callAdapterFactories.addAll(new DefaultCallAdapterFactory(callbackExecutor)
List<Converter.Factory> converterFactories = new ArrayList<>( 1 + this.converterFactories.size() + platform.defaultConverterFactoriesSize());
//添加一些默认的转换器
converterFactories.add(new BuiltInConverters());
converterFactories.addAll(this.converterFactories);
converterFactories.addAll(new OptionalConverterFactory());
return new Retrofit(....);
}
//android的回调的主线程池
static final class MainThreadExecutor implements Executor {
private final Handler handler = new Handler(Looper.getMainLooper());
@Override
public void execute(Runnable r) {
handler.post(r);
}
}

如果没看retrofit的build方法,在debug时,发现我们明明只添加了两个转换器和一个RxjavaCallAdapter,为啥会多出来一些不认识的转换器,那是因为retrofit在创建时,偷偷摸摸给你添加了一些默认的。多贴心呐。 记住:callFactory就是OkHttpClient

  • 2.3、 经典retrofit的动态代理---->create方法

image.png create方法的返回值是我们自定义的Api接口对象,所以可以直接调用Api的方法---废话。
InvocationHandler的invoke方法的返回值是Object,Api接口类里面方法的返回值可能是Call、Observable、Object,采用Object做返回值就都可以支持了。

来看看loadServiceMethod

private final Map<Method, ServiceMethod<?>> serviceMethodCache = new ConcurrentHashMap<>();
//用一个支持并发安全的Map缓存Method了
ServiceMethod<?> loadServiceMethod(Method method) {
ServiceMethod<?> result = serviceMethodCache.get(method);
if (result != null) return result;
synchronized (serviceMethodCache) {
result = serviceMethodCache.get(method);
if (result == null) { //首次请求,肯定都会先去解析注解
result = ServiceMethod.parseAnnotations(this, method);
serviceMethodCache.put(method, result);
}
}
return result;
}

返回值是个ServiceMethod,他的子类有好几个。 image.png 其中以Rxjava和Call方式请求都是返回的CallAdapted.
协程返回的是SuspendForBody或者SuspendForResponse.

来看看parseAnnotations

static <T> ServiceMethod<T> parseAnnotations(Retrofit retrofit, Method method) {
//先创建RequestFactory,然后创建HttpServiceMethod
RequestFactory requestFactory = RequestFactory.parseAnnotations(retrofit, method);
........
返回值是Void以及不能解析的返回值类型判断
.....
return HttpServiceMethod.parseAnnotations(retrofit, method, requestFactory);
}
  • 2.4、 RequestFactory创建---->解析方法注解以及方法参数

很关键的RequestFactory.parseAnnotations(...)返回RequestFactory,它里面含有太多信息

static RequestFactory parseAnnotations(Retrofit retrofit, Method method) {
return new Builder(retrofit, method).build();
}
//方法参数处理器数组
ParameterHandler<?>[] parameterHandlers
RequestFactory build() {
for (Annotation annotation : methodAnnotations) {
//解析方法上的注解
parseMethodAnnotation(annotation);
}
//hasBody isFormEncoded relativeUrl isFormEncoded isMultipart gotPart
//那我们的post这里肯定是有body的哈,不是表单提交,这里不是很重要的细节,不写
......
int parameterCount = parameterAnnotationsArray.length;
parameterHandlers = new ParameterHandler<?>[parameterCount];
for (int p = 0, lastParameter = parameterCount - 1; p < parameterCount; p++) {
parameterHandlers[p] =
//解析参数
parseParameter(p, parameterTypes[p], parameterAnnotationsArray[p], p == lastParameter);
}
.....
return new RequestFactory(this);
}

来看看解析方法上的注解parseMethodAnnotation(annotation)
这里就以例子@POST("app/courses/behaviour")参考,解析方法上的注解获取请求方式以及短路径

private void parseMethodAnnotation(Annotation annotation) {
//判断方法上注解的类型 DELETE GET HEAD PATCH PUT OPTIONS HTTP POST retrofit2.http.Headers
//Multipart FormUrlEncoded)
//这里只保留POST
if (annotation instanceof DELETE) {
....
} else if (annotation instanceof POST) {
//这里就是处理这个 @POST("app/courses/behaviour")
this.httpMethod ="POST";
this.hasBody = true;
String value=((POST) annotation).value();
if (value.isEmpty()) {
return;
}
.......
//保存短路径
this.relativeUrl = value;
this.relativeUrlParamNames = parsePathParameters(value);
}
......
}

来看解析方法参数--->parseParameter
先讲讲我们的Body类
fun getCourse(@Body info: CourseInfo): Observable 参数用@Body注解,设计了ParameterHandler这个类 image.png 这里拎出Body这个类,它要构造RequestBody,需选择xxxRequestBodyConverter才能构建成功。 image.png

private @Nullable ParameterHandler<?> parseParameter(....) {
ParameterHandler<?> result = null;
if (annotations != null) {
for (Annotation annotation : annotations) {
ParameterHandler<?> annotationAction =
//解析方法参数的注解
parseParameterAnnotation(p, parameterType, annotations, annotation);
.......
result = annotationAction;
}
}
if (result == null) {
if (allowContinuation) {
try { //判断是不是走协程,使用Continuation.class类判断
//这个判断有点粗糙
if (Utils.getRawType(parameterType) == Continuation.class) {
isKotlinSuspendFunction = true;
return null;
}
} catch (NoClassDefFoundError ignored) {
}
}
.....
}
return result;
}

上面👆就解释了,Retrofit怎么判断是走协程的,是通过判断参数里面有没有一个Continuation类型,是的话,就走协程。下面在参数里面添加了Continuation,但是我希望他走Rxjava,但不幸的事,它认为应该走协程,那就奔溃了。后文解释。 方来了,这个parseParameterAnnotation方法有400+行,一个方法400+行呐。但我们只看需要的 这里以注解是Field和Body为例。

@Nullable
private ParameterHandler<?> parseParameterAnnotation(
int p, Type type, Annotation[] annotations, Annotation annotation
) {
//annotation instanceof Url/Path/Query/QueryName/QueryMap/Header/HeaderMap等太多,删除
//以Field和Body类讲解
if (annotation instanceof Url) {
.....
} else if (annotation instanceof Field) {
.....
//省略部分逻辑
Converter<?, String> converter = retrofit.stringConverter(type, annotations);
return new ParameterHandler.Field<>(name, converter, encoded);
} else if (annotation instanceof Body) {
//body类型 肯定就不是表单类型了
......
Converter<?, RequestBody> converter;
converter = retrofit.requestBodyConverter(type, annotations, methodAnnotations);
//requestBody转换器存在于请求参数处理器中
return new ParameterHandler.Body<>(method, p, converter);
}
return null; // Not a Retrofit annotation.
}

Field注解: image.png 来看看retrofit.stringConverter(type, annotations)方法 image.png image.png image.png 请求url(baseUrl+relativeUrl拼接)、头字段的处理

Request.Builder get() {
HttpUrl url;
HttpUrl.Builder urlBuilder = this.urlBuilder;
if (urlBuilder != null) {
url = urlBuilder.build();
} else {
url = baseUrl.resolve(relativeUrl);
}
RequestBody body = this.body;
......
MediaType contentType = this.contentType;
if (contentType != null) {
if (body != null) {
body = new ContentTypeOverridingRequestBody(body, contentType);
} else {
headersBuilder.add("Content-Type", contentType.toString());
}
}
return requestBuilder.url(url).headers(headersBuilder.build()).method(method, body);
}

解析okhttp3的rawResponse为Retrofit的Response

 Response<T> parseResponse(okhttp3.Response rawResponse) throws IOException {
ResponseBody rawBody = rawResponse.body();
rawResponse = rawResponse.newBuilder()
.body(new NoContentResponseBody(rawBody.contentType(), rawBody.contentLength()))
.build();
int code = rawResponse.code();
if (code < 200 || code >= 300) {
try {
// 使用okio读取的
ResponseBody bufferedBody = Utils.buffer(rawBody);
return Response.error(bufferedBody, rawResponse);
} finally {
rawBody.close();
}
}
if (code == 204 || code == 205) {
rawBody.close();
return Response.success(null, rawResponse);
}
ExceptionCatchingResponseBody catchingBody = new ExceptionCatchingResponseBody(rawBody);
try {
//之前保存的响应体转换器去转换ResponseBody
T body = responseConverter.convert(catchingBody);
return Response.success(body, rawResponse);
} catch (RuntimeException e) {
catchingBody.throwIfCaught();
throw e;
}
}

image.png

Q: 协程请求的接口方法声明上没有Call,它是怎么选择DefaultCallAdapterFactory的呢?

static <ResponseT, ReturnT> HttpServiceMethod<ResponseT, ReturnT> parseAnnotations(
Retrofit retrofit, Method method)
{
//先创建RequestFactory
RequestFactory requestFactory = RequestFactory.parseAnnotations(retrofit, method);
boolean isKotlinSuspendFunction = requestFactory.isKotlinSuspendFunction; //true
......
Annotation[] annotations = method.getAnnotations();
Type adapterType;
if (isKotlinSuspendFunction) {
Type[] parameterTypes = method.getGenericParameterTypes();
//找那个Continuation<?super ExtendItem>参数,取的是ExtendItem的下界
Type responseType = Utils.getParameterLowerBound(0, (ParameterizedType) parameterTypes[parameterTypes.length - 1]);
.....
//获取返回值的类型,,这里偷偷摸摸加东西了,Call.class-->看来要走DefaultCallAdapterFactory
adapterType = new Utils.ParameterizedTypeImpl(null, Call.class, responseType);
//而且给方法注解加多一个SkipCallbackExecutor类型的注解
annotations = SkipCallbackExecutorImpl.ensurePresent(annotations);
} else {
adapterType = method.getGenericReturnType();
}
//创建CallAdapter 区分是走默认的还是Rxjava那一套
CallAdapter<ResponseT, ReturnT> callAdapter = createCallAdapter(retrofit, method, adapterType, annotations);
//校验响应类型是不是okhttp的Response,是的话直接throw Exception
//检验响应类型是不是retrofit的Response,是的话,没带泛型,直接throw Exception
//校验请求如果是head请求且返回值是Void类型,不满足的就直接throw Exception
.....
//创建转换器
Converter<ResponseBody, ResponseT> responseConverter = createResponseConverter(retrofit, method, responseType);
okhttp3.Call.Factory callFactory = retrofit.callFactory;
if (!isKotlinSuspendFunction) {
//非协程的部分
return new CallAdapted<>(requestFactory, callFactory, responseConverter, callAdapter);
} else if (continuationWantsResponse) {
//协程的返回值是Response<XXX>类型
return (HttpServiceMethod<ResponseT, ReturnT>)
new SuspendForResponse<>(requestFactory, callFactory, responseConverter, (CallAdapter<ResponseT, Call<ResponseT>>) callAdapter);
} else {
//协程的返回值是xxx类型-->我们这里就是ExtendItem类型,所以走这里
return (HttpServiceMethod<ResponseT, ReturnT>)
new SuspendForBody<>(requestFactory, callFactory, responseConverter, (CallAdapter<ResponseT, Call<ResponseT>>) callAdapter, continuationBodyNullable);
}
}

requestFactory.isKotlinSuspendFunction在创建RequestFactory时,走协程的话会设置为true。
上面依次创建requestFactory、callAdapter,响应转换器responseConverter,最后创建SuspendForBody(HttpServiceMethod的子类)。--->callFactory就是OkhttpClient。
这个地方细节有点多,suspend编译之后,参数加了个Continuation,但是CallAdapter只有2种,协程走的是默认的DefaultCallAdapterFactory,而且为了不跟retrofit的Call请求方式起冲突了,偷偷摸摸的在我们代码里面下毒了。

    Type adapterType = new Utils.ParameterizedTypeImpl(null, Call.class, responseType);
Annotation[] annotations = SkipCallbackExecutorImpl.ensurePresent(annotations);

Utils.ParameterizedTypeImpl就是ParameterizedType的子类,这里rawType就是Call,responseType就是ExtendItem

static final class ParameterizedTypeImpl implements ParameterizedType { 
.....
ParameterizedTypeImpl(@Nullable Type ownerType, Type rawType, Type... typeArguments) {
this.ownerType = ownerType;
this.rawType = rawType;
this.typeArguments = typeArguments.clone();
}
@Override
public Type getRawType() {
return rawType;
}
.......
}

从Contiuation参数上获取到响应类型是ExtendItem类型,然后再经过封装,将响应类型的getRawType返回Call,这点很关键,这下就决定能走DefaultCallAdapterFactory了。
再来看看ensurePrensent方法:偷偷摸摸给方法加上一个新的注解SkipCallbackExecutor返回,然后给CallAdapter创建使用。贴心呐。
SkipCallbackExecutor 表示跳过线程切换到主线程,协程才不用Retrofit的主线程切换MainThreadExecutor,是用户通过协程调度器实现。 image.png

最后,我们看看suspend实际处理的样子: image.png 创建callAdapter 之前也讲过的
image.png

看看DefaultCallAdapterFactory的get方法,有用到SkipCallbackExecutor注解。 image.png 看那个getRawType(returnType),只要是Call.class就能返回非null的CallAdapter,所以Retrofit封装响应类型的rawType为Call是有用的。
同时基于SkipCallbackExecutor注解的判断,导致CallAdapter的adapt方法直接将入参的call原样返回了。

响应体的转换器准备

private final List<Converter.Factory> converterFactories = new ArrayList<>();
private final List<CallAdapter.Factory> callAdapterFactories = new ArrayList<>();

前面讲过的,他会尝试converFactories,我们这里是ExtendItem类型,那么它的转换器,只可能是GsonResponseBodyConverter了 image.png

Retrofit的java代码跟协程代码融合的地方-->SuspendForBody的invoke方法

final @Nullable Object invoke(Object[] args) {
Call<ResponseT> call = new OkHttpCall<>(requestFactory, args, callFactory, responseConverter);
//之前SuspendForBody的CallAdapter啥事没干,入参是Retrofit中的Call,因没有处理线程切换的操作,返参还是Retrofit中的Call,看后面的图哈。
Call<ResponseT> call = callAdapter.adapt(call, args)
Continuation<ResponseT> continuation = (Continuation<ResponseT>) args[args.length - 1];
try {
return isNullable ? KotlinExtensions.awaitNullable(call, continuation)
: KotlinExtensions.await(call, continuation);
} catch (Exception e) {
return KotlinExtensions.suspendAndThrow(e, continuation);
}

}

image.png Retrofit的call创建好后,因我们的返回值是ExtendItem,不是可空的,所以就丢给KotlinExtensions.await方法,开始协程处理。

suspend fun <T : Any> Call<T>.await(): T {
//支持可取消的协程
return suspendCancellableCoroutine { continuation ->
continuation.invokeOnCancellation {
//取消接口请求
cancel()
}
//走okhttp的异步接口请求
enqueue(object : Callback<T> {
override fun onResponse(call: Call<T>, response: Response<T>) {
if (response.isSuccessful) {
val body = response.body()
if (body == null) {
val invocation = call.request().tag(Invocation::class.java)!!
val method = invocation.method()
val e = KotlinNullPointerException("Response from " +method.declaringClass.name + '.' +method.name +" was null but response body type was declared as non-null")
continuation.resumeWithException(e)
} else {
continuation.resume(body)
}
} else {
continuation.resumeWithException(HttpException(response))
}
}
override fun onFailure(call: Call<T>, t: Throwable) {
continuation.resumeWithException(t)
}
})
}
}

java调用转到kotlin调用,java的第一个参数是Call,这里是Call的拓展方法await,然后java第二个参数,给suspendCancellableCoroutine接收,这个await方法就是回调转成挂起函数的经典模板。这个模板代码一行都没精简过哈。

Q: 那协程请求完后切换到主线程在哪里执行的呢? image.png

看代码,关注下lifecycleScope的launch是否有主线程调度器,

val LifecycleOwner.lifecycleScope: LifecycleCoroutineScope
get() = lifecycle.coroutineScope
val Lifecycle.coroutineScope: LifecycleCoroutineScope
get() {
while (true) {
val existing = mInternalScopeRef.get() as LifecycleCoroutineScopeImpl?
if (existing != null) {
return existing
}
val newScope = LifecycleCoroutineScopeImpl( this,
//协程上下文在这里
SupervisorJob() + Dispatchers.Main.immediate)
if (mInternalScopeRef.compareAndSet(null, newScope)) {
newScope.register()
return newScope
}
}
}

协程上下文:SupervisorJob() + Dispatchers.Main.immediate,从这里看出他是个Supervisor,主从作用域,它取消了,不会影响父协程,非常的可以。
调度器是这个Dispatchers.Main.immediate,还能说啥,协程的调度通过协程拦截器拦截Continuation实现。

image.png image.png kotlin的代码debug不好整,大家大致看下。

image.png

五、后续

我们看到用retrofit+协程写个接口请求,还要显式的try catch,真的是,有点,哎,看后续Retrofit支持Kotlin的Flow不,不支持的话,可以考虑自己整个试试。

收起阅读 »

iOS 知识拓展

iOS
本期概要本期话题:什么是暗时间。Tips 带来了多个内容:Fastlane 用法总结、minimumLineSpacing 与 minimumInteritemSpacing 的区别以及一个定位 RN 发热问题的过程。面试解析:本期围绕 block 的变量捕获...
继续阅读 »

本期概要

  • 本期话题:什么是暗时间。
  • Tips 带来了多个内容:Fastlane 用法总结、minimumLineSpacing 与 minimumInteritemSpacing 的区别以及一个定位 RN 发热问题的过程。
  • 面试解析:本期围绕 block 的变量捕获机制展开说明。
  • 优秀博客带来了几篇编译优化的文章。
  • 学习资料带来了一个从 0 设计计算机的视频教程,还有 Git 和正则表达式的文字教程。
  • 开发工具介绍了两个代码片段整理的相关工具。

本期话题

@zhangferry:最近在看一本书:《暗时间》,初听书名可能有些不知所云,因为这个词是作者发明的,我们来看文中对“暗时间”的解释:

看书并记住书中的东西只是记忆,并没有涉及推理,只有靠推理才能深入理解一个事物,看到别人看不到的地方,这部分推理的过程就是你的思维时间,也是人一生中占据一个显著比例的“暗时间”。你走路、买菜、洗脸洗手、坐公交、逛街、出游、吃饭、睡觉,所有这些时间都可以成为暗时间,你可以充分利用这些时间进行思考,反刍和消化平时看和读的东西,这些时间看起来微不足道,但日积月累会产生巨大的效应。

这里对于暗时间的解释是思维时间,因为思维是人的”后台线程“,我们通常注意不到它,可它却实际存在且非常重要。但按思维时间来说其适用的范围就有点窄了,大多数情况我们并不会一直保持思考。我尝试把刘未鹏关于暗时间的概念进行扩展,除思维时间外,还包括那些零碎的,可以被利用但未被利用起来的时间。“明时间”,暗时间倘若都能利用起来,那定是极佳的。

目前我有两个关于暗时间应用的实践:

1、在上下班走路过程中是思考时间。我现在换了一条上下班路线,使得步行时间更长,一趟在 15 分钟左右。这段时间,我会尝试想下今天的工作内容,规划日常任务;或者回忆最近在看的某篇文章,脑海里进行推演然后尝试复述其过程;或者仅仅观察路过的行人,想象下如果我是他们,我在另一个视角观察到的自己是什么样子。总之,让大脑活跃起来。

2、等待的过程是运动时间。等人或者等红绿灯的时候,我会尝试让自己运动起来,比如小动作像垫垫脚,大一点的动作像跳一跳、跑一跑。运动是一项反人性的事情,所以它不能规划,一规划就要跟懒惰做斗争,所以干脆就随时有空就动两下。通常这种小型的运动体验,如果突然因为要开始干正事被打断了,还会有种意犹未尽的感觉。

当然还可以有别的尝试,重要的是我们要明白和感受到暗时间这个东西,然后再想办法怎么利用它。至少在我的一些尝试中会让一些本该枯燥的时间变得更有趣了些。

开发Tips

整理编辑:zhangferry

Fastlane 用法总结

图片来源:iOS-Tips

React Native 0.59.9 引发手机发烫问题解决思路

内容贡献:yyhinbeijing

问题出现的现象是:RN 页面放置久了,或者反复操作不同的 RN 页面,手机会变得很烫,并且不会自动降温,要杀掉进程才会降温,版本是 0.59.9,几乎不同手机不同手机系统版本均遇到了这个问题,可以确定是 RN 导致的,但具体哪里导致的呢,以下是通过代码注释定位问题的步骤,后面数值为 CPU 占用率:

1、原生:7.2%

2、无网络无 Flatlist:7.2%

3、网络 + FlatList :100%+

4、网络 + 无 FlatList:100%+

5、去掉 loading:2.6% — 30%,会降低

6、网络和 FlatList 全部放开,只关闭 loading 最低 7.2%,能降低,最高 63%

首先是发现网络导致 CPU 占用率很高,然后网络注释掉 RNLoading (我们自写的 loading 动画),发现内存占用不高了。就断定是 RNLoading 问题,查询发现:我们每次点击 tab 都会加载 loading,而 loading 又是动画,这样大量的动画引发内存问题。虽不是特例问题,但发现、定位、解决问题的过程仍然是有借鉴意义的,即确定范围,然后不断缩小范围。

面试解析

整理编辑:反向抽烟师大小海腾

面试解析会按照主题讲解一些高频面试题,本期面试题是 block 的变量捕获机制

block 的变量捕获机制

block 的变量捕获机制,是为了保证 block 内部能够正常访问外部的变量。

1、对于全局变量,不会捕获到 block 内部,访问方式为直接访问;作用域的原因,全局变量哪里都可以直接访问,所以不用捕获。

2、对于局部变量,外部不能直接访问,所以需要捕获。

  • auto 类型的局部变量(我们定义出来的变量,默认都是 auto 类型,只是省略了),block 内部会自动生成一个同类型成员变量,用来存储这个变量的值,访问方式为值传递auto 类型的局部变量可能会销毁,其内存会消失,block 将来执行代码的时候不可能再去访问那块内存,所以捕获其值。由于是值传递,我们修改 block 外部被捕获变量的值,不会影响到 block 内部捕获的变量值。
  • static 类型的局部变量,block 内部会自动生成一个同类型成员变量,用来存储这个变量的地址,访问方式为指针传递。static 变量会一直保存在内存中, 所以捕获其地址即可。相反,由于是指针传递,我们修改 block 外部被捕获变量的值,会影响到 block 内部捕获的变量值。
  • 对于对象类型的局部变量,block 会连同它的所有权修饰符一起捕获。
    • 如果 block 是在栈上,将不会对对象产生强引用
    • 如果 block 被拷贝到堆上,将会调用 block 内部的 copy(__funcName_block_copy_num)函数,copy 函数内部又会调用 assign(_Block_object_assign)函数,assign 函数将会根据变量的所有权修饰符做出相应的操作,形成强引用(retain)或者弱引用。
    • 如果 block 从堆上移除,也就是被释放的时候,会调用 block 内部的 dispose(_Block_object_dispose)函数,dispose 函数会自动释放引用的变量(release)。
  • 对于 __block(可用于解决 block 内部无法修改 auto 变量值的问题) 修饰的变量,编译器会将 __block 变量包装成一个 __Block_byref_varName_num 对象。它的内存管理几乎等同于访问对象类型的 auto 变量,但还是有差异。
    • 如果 block 是在栈上,将不会对 __block 变量产生强引用
    • 如果 block 被拷贝到堆上,将会调用 block 内部的 copy 函数,copy 函数内部又会调用 assign 函数,assign 函数将会直接对 __block 变量形成强引用(retain)。
    • 如果 block 从堆上移除,也就是被释放的时候,会调用 block 内部的 dispose 函数,dispose 函数会自动释放引用的 __block 变量(release)。
  • 被 __block修饰的对象类型的内存管理:
    • 如果 __block 变量是在栈上,将不会对指向的对象产生强引用
    • 如果 __block 变量被拷贝到堆上,将会调用 __block 变量内部的 copy(__Block_byref_id_object_copy)函数,copy 函数内部会调用 assign 函数,assign 函数又会根据变量的所有权修饰符做出相应的操作,形成强引用(retain)或者弱引用。(注意:这里仅限于 ARC 下会 retain,MRC 下不会 retain,所以在 MRC 下还可以通过 __block 解决循环引用的问题)
    • 如果 __block 变量从堆上移除,会调用 __block 变量内部的 dispose 函数,dispose 函数会自动释放指向的对象(release)。

掌握了 block 的变量捕获机制,我们就能更好的应对内存管理,避免因使用不当造成内存泄漏。

常见的 block 循环引用为:self(obj) -> block -> self(obj)。这里 block 强引用了 self 是因为对于对象类型的局部变量,block 会连同它的所有权修饰符一起捕获,而对象的默认所有权修饰符为 __strong。

self.block = ^{
NSLog(@"%@", self);
};
复制代码

为什么这里说 self 是局部变量?因为 self 是 OC 方法的一个隐式参数。

为了避免循环引用,我们可以使用 __weak 解决,这里 block 将不再持有 self。

__weak typeof(self) weakSelf = self;
self.block = ^{
NSLog(@"%@", weakSelf);
};
复制代码

为了避免在 block 调用过程中 self 提前释放,我们可以使用 __strong 在 block 执行过程中持有 self,这就是所谓的 Weak-Strong-Dance。

__weak typeof(self) weakSelf = self;
self.block = ^{
__strong typeof(self) strongSelf = weakSelf;
NSLog(@"%@", strongSelf);
};
复制代码

当然,我们平常用的比较多的还是 @weakify(self) 和 @strongify(self) 啦。

@weakify(self);
self.block = ^{
@strongify(self);
NSLog(@"%@", self);
};
复制代码

如果你使用的是 RAC 的 Weak-Strong-Dance,你还可以这样:

@weakify(self, obj1, obj2);
self.block = ^{
@strongify(self, obj1, obj2);
NSLog(@"%@", self);
};
复制代码

如果是嵌套的 block:

@weakify(self);
self.block = ^{
@strongify(self);
self.block2 = ^{
@strongify(self);
NSLog(@"%@", self);
}
};
复制代码

你是否会疑问,为什么内部不需要再写 @weakify(self) ?这个问题就留给你自己去思考和解决吧!

相比于简单的相互循环引用,block 造成的大环引用更需要你足够细心以及敏锐的洞察力,比如:

TYAlertView *alertView = [TYAlertView alertViewWithTitle:@"TYAlertView" message:@"This is a message, the alert view containt text and textfiled. "];
[alertView addAction:[TYAlertAction actionWithTitle:@"取消" style:TYAlertActionStyleCancle handler:^(TYAlertAction *action) {
NSLog(@"%@-%@", self, alertView);
}]];
self.alertController = [TYAlertController alertControllerWithAlertView:alertView preferredStyle:TYAlertControllerStyleAlert];
[self presentViewController:alertController animated:YES completion:nil];
复制代码

这里循环引用有两处:

  1. self -> alertController -> alertView -> handlerBlock -> self
  2. alertView -> handlerBlock -> alertView

避免循环引用:

TYAlertView *alertView = [TYAlertView alertViewWithTitle:@"TYAlertView" message:@"This is a message, the alert view containt text and textfiled. "];
@weakify(self, alertView);
[alertView addAction:[TYAlertAction actionWithTitle:@"取消" style:TYAlertActionStyleCancle handler:^(TYAlertAction *action) {
@strongify(self, alertView);
NSLog(@"%@-%@", self, alertView);
}]];
self.alertController = [TYAlertController alertControllerWithAlertView:alertView preferredStyle:TYAlertControllerStyleAlert];
[self presentViewController:alertController animated:YES completion:nil];
复制代码

另外再和你提一个小知识点,当我们在 block 内部直接使用 _variable 时,编译器会给我们警告:Block implicitly retains self; explicitly mention 'self' to indicate this is intended behavior

原因是 block 中直接使用 _variable 会导致 block 隐式的强引用 self。Xcode 认为这可能会隐式的导致循环引用,从而给开发者带来困扰,而且如果不仔细看的话真的不太好排查,笔者之前就因为这个循环引用找了半天,还拉上了我导师一起查找原因。所以警告我们要显式的在 block 中使用 self,以达到 block 显式 retain 住 self 的目的。改用 self->_variable 或者 self.variable

你可能会觉得这种困扰没什么,如果你使用 @weakify 和 @strongify 那确实不会造成循环引用,因为 @strongify 声明的变量名就是 self。那如果你使用 weak typeof(self) weak_self = self; 和 strong typeof(weak_self) strong_self = weak_self 呢?

优秀博客

整理编辑:皮拉夫大王在此我是熊大

本期主题:编译优化

1、iOS编译过程的原理和应用 -- 来自 CSDN:黄文臣

做编译优化前,先了解下编译原理吧!该作者通过 iOS 的视角,白话了编译原理,通俗易懂。

2、Xcode编译疾如风系列 - 分析编译耗时 -- 来自腾讯社区:小菜与老鸟

在进行编译速度优化前,一个合适的分析工具是必要的,它能告诉你哪部分编译时间较长,让你发现问题,从而解决问题,本文介绍了几种分析编译耗时的方式,助你分析构建时间。该作者还有其他相关姊妹篇,建议前往阅读。

3、iOS 微信编译速度优化分享 -- 来自云+社区:微信终端开发团队

文章对编译优化由浅入深做了介绍。作者首先介绍了常见的现有方案,利用现有方案以及精简代码、将模板基类改为虚基类、使用 PCH 等方案做了部分优化。文章精彩的部分在于作者并没有止步于此,而是从编译原理入手,结合量化手段,分析出编译耗时的瓶颈。在找到问题的瓶颈后,作者尝试人工进行优化,但是效率较低。最终在 IWYU 基础上,增加了 ObjC 语言的支持,高效地处理了一部分多余的头文件。

4、iOS编译速度如何稳定提高10倍以上之一 -- 来自掘金:Mr_Coder

美柚 iOS 的编译提效历程。作者对常见的优化做了分析,列举了各自的优缺点。有想做编译优化的可以参考这篇文章了解一下。对于业界的主流技术方案,别的技术文章往往只介绍优点,对方案的缺点谈的不够彻底。这篇文章从实践者的角度阐述了常见方案的优缺点,很有参考价值。文章介绍了双私有源二进制组件并与 ccache 做了对比,最后列出了方案支持的功能点。

5、iOS编译速度如何稳定提高10倍以上之二 -- 来自掘金:Mr_Coder

作为上文的姊妹篇,本文详细介绍了双私有源二进制组件的方案细节以及使用方法。对该方案感兴趣的可以关注下。

6、一款可以让大型iOS工程编译速度提升50%的工具 -- 来自美团技术团队:思琦 旭陶 霜叶

本文主要介绍了如何通过优化头文件搜索机制来实现编译提速,全源码编译效率提升 45%。文中涉及很多知识点,比如 hmap 文件的作用、Build Phases - Headers 中的 Public,Private,Project 各自是什么作用。文中详细分析了 podspec 创建头文件产物的逻辑以及 Use Header Map 失效的原因。干货比较多,可能得多读几遍。

学习资料

整理编辑:Mimosa

从 0 到 1 设计一台计算机

地址:https://www.bilibili.com/video/BV1wi4y157D3

来自 Ele实验室 的计算机组成原理课程,该系列视频主要目的是让大家对「计算机是如何工作的」有个较直观的认识,做为深入学习计算机科学的一个启蒙。观看该系列视频最好有一些数字电路和模拟电路的基础知识,Ele 实验室同时也有关于 数电 和 模电 的基础知识介绍供大家参考。

Git Cheat Sheet 中文版

地址:https://github.com/flyhigher139/Git-Cheat-Sheet

Git Cheat Sheet 让你不用再去记所有的 git 命令!对新手友好,可以用于查阅简单的 git 命令。

正则表达式 30 分钟入门教程

地址:https://deerchao.cn/tutorials/regex/regex.htm

30 分钟内让你明白正则表达式是什么,并对它有一些基本的了解。别被那些复杂的表达式吓倒,只要跟着我一步一步来,你会发现正则表达式其实并没有想象中的那么困难。除了作为入门教程之外,本文还试图成为可以在日常工作中使用的正则表达式语法参考手册。

工具推荐

整理编辑:zhangferry

SnippetsLab

地址:http://www.renfei.org/snippets-lab/

软件状态:$9.99

软件介绍

一款强大的代码片段管理工具,从此告别手动复制粘贴,SnippetsLab 的设计更符合 Apple 的交互习惯,支持导航栏快速操作。另外还可以同步 Github Gist 内容,使用 iCloud 备份。

CodeExpander

地址:https://codeexpander.com/

软件状态:普通版免费,高级版付费

软件介绍

专为开发者开发的一个集输入增强、代码片段管理工具,支持跨平台,支持云同步(Github/码云)。免费版包含 90% 左右功能,相对 SnippetsLab 来说其适用范围更广泛,甚至包括一些日常文本的片段处理。

关于我们

iOS 摸鱼周报,主要分享开发过程中遇到的经验教训、优质的博客、高质量的学习资料、实用的开发工具等。周报仓库在这里:https://github.com/zhangferry/iOSWeeklyLearning ,如果你有好的的内容推荐可以通过 issue 的方式进行提交。另外也可以申请成为我们的常驻编辑,一起维护这份周报。另可关注公众号:iOS成长之路,后台点击进群交流,联系我们,获取更多内容。


收起阅读 »

iOS RXSwift 9.1

iOS
学习资源书籍RxSwift - By Raywenderlich视频Learning Path: RxSwift from Start to Finish - By Realm 团队RxSwift in Practice - By...
继续阅读 »

学习资源

书籍

视频

博客

教程

开源项目

  • CleanArchitectureRxSwift - Example of Clean Architecture of iOS app using RxSwift

  • PinPlace - Routing app. Build with MVVM+RxSwift and ❤️.

  • RxTodo - iOS Todo Application using RxSwift and ReactorKit

  • Drrrible - Dribbble for iOS using ReactorKit

  • RxMarbles - RxMarbles iOS app


关于本文档

问题反馈

如果你发现文档存在问题,可以通过以下任意一种方式将问题反馈给作者:

  • (推荐)在存在问题页面,点击左上方的编辑页面按钮,对文档进行修正,最后提交 Pull Request
  • 前往 文档库 提 issues, 并注明文档哪些地方存在问题
  • 加入到 RxSwift QQ 交流群: 871293356,将问题反馈给整理人
  • 通过邮件将问题反馈给整理人:beeth0vendev@gmail.com

文档更新日志

文档变更将被记录在此文件内。


2.0.0

19年5月21日(RxSwift 5)


1.2.0

18年2月15日

  • 纠正错别字
  • 给 retry 操作符加入演示代码
  • 给 replay 操作符加入演示代码
  • 给 connect 操作符加入演示代码
  • 给 publish 操作符加入演示代码
  • 给 reduce 操作符加入演示代码
  • 给 skipUntil 操作符加入演示代码
  • 给 skipWhile 操作符加入演示代码
  • 给 skip 操作符加入演示代码

1.1.0

17年12月7日


1.0.0

17年10月18日(RxSwift 4)


0.2.0

17年10月9日

0.1.1

17年9月18日


0.1.0

17年9月4日


0.0.1

17年9月1日(RxSwift 3.6.1)

收起阅读 »

设计模式-工厂方法模式

工厂方法模式(Factory Method)又称为多态性工厂模式,其核心不再像简单工厂模式那样负责所有的子类的创建,而是将具体的创建工作交给子类去完成 在前文已经介绍简单工厂模式 时,写了如下代码: /** * type:角色类型 - 管理员、员工 * n...
继续阅读 »

工厂方法模式(Factory Method)又称为多态性工厂模式,其核心不再像简单工厂模式那样负责所有的子类的创建,而是将具体的创建工作交给子类去完成


在前文已经介绍简单工厂模式 时,写了如下代码:


/**
* type:角色类型 - 管理员、员工
* name:对应角色的名字
*/
const Factory = (type, name) => {
switch (type) {
case "admin": // 创建管理员
return new Admin(name, ["user", "salary", "vacation"]);
case "staff": // 创建员工
return new Staff(name, ["vacation"]);
default: // 健壮性处理
throw new Error("暂不支持该角色的创建");
}
};

虽然其可以让我们在创建实例的时候很爽,不用关心内部具体的实现,通过观察代码,可以发现其存在的问题:



  • 不符合设计原则-开放封闭原则


当每一次新增一个权限角色的时候,对需要对上面的代码进行修改,严重破坏了原有的代码和业务逻辑,与开放封闭原则背离



  • 容易变成面条代码


虽然角色越来越多,那么内部的case也会随之变得越来越多,简单工厂函数的内容也随着变得冗余


理想情况下,我们是希望在新增新的权限角色时,对于老的代码无任何的修改便可以完成新功能的增加


首先看一下工厂方法模式的UML:


image-20211006145801692.png


其相比简单工厂模式,会多了一个工厂方法,Admin类对应多了一个AdminFactory类,现在只需要通过AdminFactory类创建实例即可


接下来看看工厂方法模式如何创建:


class Person {
constructor(name, permission) {
this.name = name;
this.permission = permission;
}
}

/**
* 管理员
*/
class Admin extends Person {
constructor(name, permission) {
super(name, permission);
}
}

/**
* 管理员的工厂方法
*/
class AdminFactory {
static create(name) {
return new Admin(name, ["user", "salary", "vacation"]);
}
}

/**
* 员工
*/
class Staff extends Person {
constructor(name, permission) {
super(name, permission);
}
}

/**
* 员工的工厂方法
*/
class StaffFactory {
static create(name) {
return new Staff(name, ["vacation"]);
}
}

const admin = AdminFactory.create("管理员");
const zs = StaffFactory.create("张三");
const ls = StaffFactory.create("李四");

若是需要创建新的权限角色,只需要创建对应的工厂方法即可,完全符合开放封闭原则,也可以避免面条式代码,具体实例创建都是通过对应工厂方法创建


作者:Nordon
链接:https://juejin.cn/post/7016149646334492679

收起阅读 »

设计模式-适配器模式

适配器模式又称为包装器模式,将一个类的接口转化为用户需要的另外一个接口,主要是为了解决对象之间接口不兼容的问题,比如随着业务迭代升级出现了旧的接口与心的接口不兼容,这个时候不可能强制使用旧接口的用户去升级,而是在中间加一个适配器进行转换,让旧接口的使用者无感使...
继续阅读 »

适配器模式又称为包装器模式,将一个类的接口转化为用户需要的另外一个接口,主要是为了解决对象之间接口不兼容的问题,比如随着业务迭代升级出现了旧的接口与心的接口不兼容,这个时候不可能强制使用旧接口的用户去升级,而是在中间加一个适配器进行转换,让旧接口的使用者无感使用,保证了稳定性,在日常生活中适配器的案例随处可见,比如耳机插口不统一、充电接口不统一等,这个时候就需要一个适配器来解决问题


UML:


image-20211007135111668.png


接下看一下UML对应的代码实现:


class Target {
constructor(type) {
let result;

switch (type) {
case "adapter":
result = new Adapter();
break;
default:
result = null;
}
return result;
}

Request() {
console.log('Target Request');
}
}

class Adaptee {
constructor() {
console.log("Adaptee created");
}

SpecificRequest() {
console.log("Adaptee request");
}
}

class Adapter extends Adaptee {
constructor() {
super();
console.log("Adapter created");
}

Request() {
return this.SpecificRequest();
}
}

function init_Adapter() {
var f = new Target("adapter");
f.Request();
}

init_Adapter();

应用场景


开发中常用的axios,其支持node端和浏览器端,那么在不同端调用axios需要进行不同的处理,而这些对于使用者而言都是无感的,我们在使用的时候都是使用同一套API直接干就完事了,不会在意内部具体做了些什么,这个时候就需要使用适配器来抹平不同端的差异,让使用者用着开心


接下来可以模拟简单实现这个过程


使用axios请求一个接口:


axios({
url: "xxx",
method: "GET",
})
.then((res) => {
console.log("success:", res);
})
.catch((err) => {
console.log("fail:", err);
});

接下来需要手动实现axios函数:


function axios(config) {
let adaptor = getDefaultAdaptor();

// 无论是node端 还是 浏览器端,在使用的时候都只是转入一个config配置对象,返回一个 Promise 对象
return adaptor(config);
}

上文说到因为axios可以在浏览器端和node端使用,getDefaultAdaptor函数就是起到适配器的作用,根据不同的环境分别创建不同的adaptor


/**
* 适配器
*/
function getDefaultAdaptor() {
let adaptor;

if (typeof XMLHttpRequest !== "undefined") {
// 是浏览器环境
adaptor = xhr;
} else if (typeof process !== "undefined") {
// node 环境
adaptor = http;
}

return adaptor;
}

其中xhr和http为两个函数,用于创建具体的请求,至此适配器的使用已经完成,接下来就看看不同端是如何实现的接口请求


浏览器端:


/**
* 浏览器环境
*/
function xhr(config) {
return new Promise((resolve, reject) => {
const req = new XMLHttpRequest();
req.open(config.method, config.url, true);

req.onreadystatechange = function () {
if (req.readyState === 4) {
if (req.status >= 200 && req.status < 300) {
resolve(req.responseText);
} else {
reject("请求失败");
}
}
};

req.send();
});
}

node端:


/**
* node 环境
*/
function http(config) {
const url = require("url");
const http = require("http");
// 将需要的参数 解析出来
const { hostname, port, path } = url.parse(config.url);
return new Promise((resolve, reject) => {
const options = {
hostname,
port,
path,
method: config.method,
};

const req = http.request(options, function (response) {
let chunks = [];

response.on("data", (chunk) => {
chunks.push(chunk);
});

response.on("end", () => {
const res = Buffer.concat(chunks).toString();
resolve(res);
});
});

// 监听请求异常
req.on("error", (err) => {
reject(err);
});

// 请求发送完毕
ren.end();
});
}

作者:Nordon
链接:https://juejin.cn/post/7016215674322157581

收起阅读 »