注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

iOS SwiftUI 应用设计与布局 1.0

应用设计与布局深入了解使用SwiftUI创建的复杂的用户界面的结构和布局包含章节组合复杂用户界面组合复杂用户界面Landmarks应用的首页是一个纵向滚动的地标类别列表,每一个类别内部是一个横向滑动列表。随后将构建应用的页面导航,这个过程中可以学习到如果组合各...
继续阅读 »

应用设计与布局

深入了解使用SwiftUI创建的复杂的用户界面的结构和布局

app design and layout

包含章节

组合复杂用户界面

Landmarks应用的首页是一个纵向滚动的地标类别列表,每一个类别内部是一个横向滑动列表。随后将构建应用的页面导航,这个过程中可以学习到如果组合各种视图,并让它们适配不同的设备尺寸和设备方向。


第一节 添加一个首页视图

已经创建了所有在Landmarks应用中需要的视图,现在给应用创建一个首页视图,把之前创建的视图整合起来。首页不仅仅包含之前创建的视图,它还提供页面间导航的方式,同时也可以展示各种地标信息。

section 1

步骤1 创建一个名为CategoryHome.swift的自定义视图文件

section 1 step 1

步骤2 把应用的场景代理(scene delegate)的根视图从之前的地标列表视图更改为新创建的首页视图。现在应用启动后的每一个页面就是首页了,所以还需要添加从首页导航跳转到其它页面的方法。

section 1 step 2

步骤3 添加NavigationView,这个NavigationView将会容纳Landmarks应用中其它不同的视图。配合使用NavigationViewNavigationLink及相关的修改器,就可以构建出应用的页面间导航结构

section 1 step 3

步骤4 设置导航栏标题为Featured

section 1 step 4

第二节 创建地标类别列表

Landmarks应用为了便于用户浏览各种类别的地标,将地标按类别竖向排列形成列表视图,对于每一个类别内的具体地标,又把它们按照水平方向排列,形成横向列表。组合使用垂直栈(vertical statck)和水平栈(horizontal stack)并给列表添加滚动

section 2

步骤1 使用Dictionary结构体的初始化方法init(grouping:by:),把地标数据的类别属性category传入作为分组依据,可以把地标数据按类别分组。工程文件中已经为每一个地标样本数据预定义了类别。

section 2 step 1

步骤2 使用List显示地标数据的类别。Landmark.Category是枚举类型,它的值标识列表中每一种类别,可以保证类别不会有重复定义

section 2 step 1

第三节 添加针对单个类别的地标行列表

Landmarks应用对每个类别下的地标采用横向滑动的行进行展示。添加一个新的视图类型用来表示这样一个地标行,然后使用这个新创建的行类型具体展示某一具体类型上的所有地标。

section 3

步骤1 定义一个新的视图类型,用来展示地标类别行的内容。新建行视图需要存放地标具体类别的展示数据

section 3 step 1

步骤2 更新CategoryHome.swift的代码,把地标类别信息传给新建的行视图类型

section 3 step 2

步骤3 在CategoryRow.swift中使用一个HStack展示类别下的地标内容

section 3 step 3

步骤4 为行内容指定一个高度,并把行内容嵌入到ScrollView中,以支持横向滑动。预览视图时,可以多增加几个地标数据,用来查看列表的滑动是否正常。

section 3 step 4


收起阅读 »

复习Activity各种场景的生命周期

Activity是Android组件中最基本也是最为常见用的四大组件之一,也是我们在开发过程之中接触最多的组件,所以了解Activity的生命周期,并正确的理解与应用,是必不可少的。之前看到很多错误文章,今天特意自己亲自测试一遍,下面就来介绍一下Activit...
继续阅读 »

Activity是Android组件中最基本也是最为常见用的四大组件之一,也是我们在开发过程之中接触最多的组件,所以了解Activity的生命周期,并正确的理解与应用,是必不可少的。之前看到很多错误文章,今天特意自己亲自测试一遍,下面就来介绍一下Activity生命周期。

一.官网生命周期

在这里插入图片描述 上面图概括了android生命周期的各个环节,描述了activity从生成到销毁的过程。

  • onCreate:该方法时整个Activity生命周期的第一个方法,它表示Activity正在被创建,日常开发过程中,相信大家接触最多的就是这个方法,在这个方法中我们常常做一些初始化的工作(如加载布局资源、初始化数据等操作),此时Activity不可见。

  • onStart:顾名思义,该方法代表Activity正在被启动,此时Activity已经可见,但是无法响应用户的交互动作。

  • onResume:该方法表示Activity已经经过前面步骤创建完成,此时Activity已经可见并且已经来前台,用户能够看到界面并且能够进行交互操作并获得响应。

  • onPause:onPause方法表示Activity正在暂停,正常情况下,onStop紧接着就会被调用。在特殊情况下,如果这个时候用户快速地再回到当前的Activity,那么onResume会被调用(希望你手速够快,很难出现)。一般来说,在这个生命周期状态下,可以做一些存储数据、停止动画的工作,但是该方法不能执行耗时操作,这是由于启动新的Activity而唤醒的该状态,那会影响到新Activity的启动,原因是新的Activity的onResume方法是在老Activity的onPause执行完后才执行的(具体原因可以看下系统启动Activity的机制)。

  • onStop:表示Activity即将停止,可以做一些稍微重量级的资源回收工作等,同样也不能太耗时。

  • onDestroy:表示Activity即将被销毁,这是Activity生命周期的最后一个回调,我们可以做一些回收工作和最终的资源释放(如Service、BroadReceiver、Map等)。

  • onRestart:表示Activity正在重新启动,一般情况下,在当前Activity从不可见重新变为可见的状态时onRestart就会被调用。这种情形一般是由于用户的行为所导致的,比如用户按下Home键切换到桌面或者打开了一个新的Activity(这时当前Activity会暂停,也就是onPause和onStop被执行),接着用户有回到了这个Activity,就会出现这种情况。

二.常见一些情景

1.直接启动一个MainActivity

MainActivity: onCreate
MainActivity: onStart
MainActivity: onResume

2.在MainActivity中启动TwoActivity

此时MainActivity中先执行onPause方法,然后 TwoActivity执行onCreate → onStart → onResume,TwoActivity完全显示后才会执行MainActivity的 onStop方法

MainActivity: onPause
TwoActivity: onCreate
TwoActivity: onStart
TwoActivity: onResume
MainActivity: onStop

3.TwoActivity中点击back返回

此时TwoActivity 先执行 onPause方法,MainActivity执行onRestart→ onStart → onResume,最后TwoActivity 执行onStop→ onDestroy

TwoActivity: onPause
MainActivity: onRestart
MainActivity: onStart
MainActivity: onResume
TwoActivity: onStop
TwoActivity: onDestroy

3.MainActivity中点击home或者锁屏按键

MainActivity: onPause
MainActivity: onStop

4.重新进入MainActivity

MainActivity: onRestart
MainActivity: onStart
MainActivity: onResume

5.MainActivity 点击弹窗dialog

  • 注意:之前被很多人误导有生命周期变化,真实测试后发现没变化
**** 无生命周期变化 ****  
**** 无生命周期变化 ****
**** 无生命周期变化 ****

6.MainActivity 点击跳转透明Activity

设置透明activity的方法可以通过style

  <style name="MyTransparent" parent="Theme.AppCompat.Light.NoActionBar">
<item name="android:windowIsTranslucent">true</item>
<item name="android:windowAnimationStyle">@android:style/Animation.Translucent</item>
<item name="android:windowBackground">@android:color/transparent</item>
<item name="android:colorBackgroundCacheHint">@null</item>
<item name="android:windowNoTitle">true</item>
<item name="android:windowContentOverlay">@null</item>
</style>

发现跳转到透明TransActivity后MainActivity并没有执行onstop方法

MainActivity: onPause
TransActivity: onCreate
TransActivity: onStart
TransActivity: onResume

7.透明Activity点击返回

MainActivity直接就执行了onResume方法

TransActivity: onPause
MainActivity: onResume
TransActivity: onStop
TransActivity: onDestroy

8.透明Activity中点击home或者锁屏按键

发现TransActivity和MainActivity都调用了onStop方法

TransActivity: onPause
TransActivity: onStop
MainActivity: onStop

9.重新进入透明Activity

发现MainActivity也执行了onRestart → onStart

MainActivity: onRestart
MainActivity: onStart
TransActivity: onRestart
TransActivity: onStart
TransActivity: onResume

三.Activity中onSaveInstanceState()和onRestoreInstanceState()

1.onSaveInstanceState(Bundle outState):

onSaveInstanceState函数在Activity生命周期中执行。 outState 参数作用 : 数据保存 : Activity 声明周期结束的时候, 需要保存 Activity 状态的时候, 会将要保存的数据使用键值对的形式 保存在 Bundle 对象中;

调用时机 : Activity 被销毁的时候调用, 也可能没有销毁就调用了; 按下Home键 : Activity 进入了后台, 此时会调用该方法; 按下电源键 : 屏幕关闭, Activity 进入后台; 启动其它 Activity : Activity 被压入了任务栈的栈底; 横竖屏切换 : 会销毁当前 Activity 并重新创建;

onSaveInstanceState方法调用注意事项 : 用户主动销毁不会调用 : 当用户点击回退键 或者 调用了 finish() 方法, 不会调用该方法; 调用时机不固定 : 该方法一定是在 onStop() 方法之前调用, 但是不确定是在 onPause() 方法之前 还是 之后调用; 布局中组件状态存储 : 每个组件都 实现了 onSaveInstance() 方法, 在调用函数的时候, 会自动保存组件的状态, 注意, 只有有 id 的组件才会保存; 关于默认的 super.onSaveInstanceState(outState) : 该默认的方法是实现 组件状态保存的;

MainActivity按电源键进入后台的生命周期如下:

MainActivity: onPause
MainActivity: onStop
MainActivity: onSaveInstanceState

2.onRestoreInstanceState(Bundle outState):

方法回调时机 : 在 Activity 被系统销毁之后 恢复 Activity 时被调用, 只有销毁了之后重建的时候才调用, 如果内存充足, 系统没有销毁这个 Activity, 就不需要调用; – Bundle 对象传递 : 该方法保存的 Bundle 对象在 Activity 恢复的时候也会通过参数传递到 onCreate() 方法中;

四.activity的进程优先级。

前台进程>可见进程>service进程>后台进程>空进程

前台进程:

 1.当前进程activity正在与用户进行交互。
2.当前进程service正在与activity进行交互或者当前service调用了startForground()属于前台进程或者当前service正在执行生命周期(onCreate(),onStart(),onDestory())
3.进程持有一个BroadcostReceiver,这个BroadcostReceiver正在执行onReceive()方法

可见进程:

 1. 进程持有一个activity,这个activity不再前台,处于onPause()状态下,当前覆盖的activity是以dialog形式存在的。
2. 进程有一个service,这个service和一个可见的Activity进行绑定。

service进程:

 1.当前开启startSerice()启动一个service服务就可以认为进程是一个服务进程。

后台进程:

  activity的onStop()被调用,但是onDestroy()没有调用的状态。该进程属于后台进程。

空进程

 改进程没有任何运行的数据了,且保留在内存空间,并没有被系统killed,属于空进程。该进程很容易被杀死。

收起阅读 »

这次,我想把内存泄漏讲明白

检测内存是否泄漏非常简单,只要在任意位置调用 Debug.dumpHprofData(file) 即可,通过拿到 hprof 文件进行分析就可以知道哪里产生了泄漏,但 dump 的过程会 suspend 所有的 java 线程,导致用户界面无响应,所以又不能随...
继续阅读 »


检测内存是否泄漏非常简单,只要在任意位置调用 Debug.dumpHprofData(file) 即可,通过拿到 hprof 文件进行分析就可以知道哪里产生了泄漏,但 dump 的过程会 suspend 所有的 java 线程,导致用户界面无响应,所以又不能随意 dump。为了能找到合理的 dump 时机,leakCanary 就采用预判的方式,在 onDestroy 中先检测一下当前 Activity 是否存在泄漏的风险,如果有这种情况,就开始 dump。需要注意的是,在 onDestroy 做检测仅仅只是预判,一种时机,并不能断定真的发生了泄漏,真正的泄漏需要通过分析 hprof 文件才能知晓。

hprof 是由 JVM TI Agent HPROF 生成的一种二进制文件,文件格式可以查看 Binary Dump Format

一、如何预判内存泄漏

  • 主动检测法
  • 阈值检测法

1、主动检测法

  • Activity 的检测预判
  • Service 的检测预判
  • Bitmap 大图的检测预判

1、Activity 的检测预判 LeakCanary 中对 Activity 的预判是在 onDestroy 生命周期中通过弱引用队列来持有当前 Activity 引用,如果在主动触发 gc 之后,泄漏对象集合中仍然能找到该引用实例,则说明发生了内存泄漏,就开始 dump

2、Service 的检测预判 LeakCanary 对 Service 的内存泄漏检测时机,是 hook 监听 ActivityThread 的 stopService,然后记录这个 binder 到弱引用集合中,然后代理 AMS 的 serviceDoneExecuting 方法,通过 binder 在弱引用集合中去移除,移除成功的话,说明发生了内存泄漏,就开始 dump

3、Bitmap 大图检测预判 Bitmap 不像 Activity、Service 这种,能够通过生命周期主动监测当前是否有内存泄漏的可能,他一般是在 Activity、Service 发生泄漏 dump 的时候,顺便检测一下 Bitmap 。在 Koom 中,Bitmap 大图检测是分析 hprof 中是否有超过 Bitmap 设置的阈值 size (width * height)

2、阈值检测法

阈值检测法的代表框架是 Koom,他抛弃了 LeakCanary 的实时检测性,采用定时轮训检测当前内存是否在不断累加,增长达到一定次数(可自己配置)时会进行 dump hprof,这种方式会牺牲一定的时效性,但对于应用到线上的 Koom 的框架,他完全不需要这么高的时效性

二、如何分析内存泄漏

分析工具代表:

  • MAT
  • Android Studio
  • HaHa
    • Matrix
    • LeakCanary 1.x
  • shark
    • Liko
    • Koom
    • LeakCanary 2.x

1、MAT

MAT 工具下载可点击链接 ,Android 生成的 dump 需要做一下转换才能被 MAT 识别,转换指令:

hprof-conv <hprof 文件> <新生成的文件>

eg:

hprof-conv android.hprof mat.hprof

hprof-conv 跟 adb 在同一个文件夹下,配置了 adb 命令的可以直接用这个命令执行。

MAT 查内存泄漏会有点费劲,毕竟是个 java 通用工具,并不会指明告诉你是哪个 Activity 发生了泄漏,但可以分析个大概。

一般泄漏的都是比较大的实例:

image.png

点击类名进入查看:

image.png

ActivityLeakMaker 占用了近 190944 byte 的内存空间,并且引用链里面有 Activity 相关的内容,切回代码来看问题,原来是静态变量持有了 Activity 实例导致:

image.png

2、Android Studio

Android Studio 的 Profiler 工具支持 hprof 的解析,并且很智能的提示当前 leak 了哪些对象,打开方式很简单,将 hprof 文件拖拽至 as,然后双击 hprof 文件即可:

image.png

我们可以很直观的看到,当前 LeakedActivity 和 ReportFragment 发生了泄漏。

如果我们的需求仅仅只是在开发阶段进行内存泄漏检测的话,并且又不想接入 LeakCanary(因为有时候想调试下自己模块的代码,其他模块经常报内存泄漏,冻结当前线程,很影响调试),那么我们可以在应用里面埋个彩蛋,比如单击 5 次版本号,然后调用 Debug.dumpHprofData ,然后将 hprof 文件导出到 as 进行分析,这就将原本可能会进行数次 dump 的过程,改成了自己需要去检测的时候再去 dump。

3、HaHa

在 LeakCanary 的第一版的时候,是采用的 Haha 库来分析泄漏引用链,但由于后面新出的 Shark,比 HaHa 快 8 倍,并且内存占用还要少 10 倍,但查找泄漏路径的大致步骤与 Shark 无异,故此文就不分析 HaHa 了。

4、Shark

Shark 是 square 团队开发的一款全新的分析 hprof 文件的工具,其官方宣布比 Android Studio 用于 memory profiler 的核心库 perflib 要快 8 倍并且内存占用少 10 倍,更加适合手机端的分析工具。其目的就是提供快速解析hprof文件和分析快照的能力,并找出真正的泄漏对象以及对象到GcRoot 的最短引用路径链,以便帮助开发者更加直观的找出泄漏的真正原因。 -- 引用自《LeakCanary2.0解析

看了下 Koom 分析引用链的过程,大致可以分为以下几个步骤:

  • 分析 hprof 文件,获取镜像所有的 instance 实例
  • 遍历所有的实例,判断这个实例与各个 Detectors 是否有存在泄漏,如果有,则记录 objectId 到集合
  • 根据 objectId 集合获取各个泄漏实例引用链,分析出 gcRoot,并遍历 gcRoot 下的引用路径

这个地方重点在于如何找到泄漏的 objectId,因为找到 objectId,即可找到泄漏引用链。在分析 hprof 的时候我们可以拿到 dump 时的内存实例,那么,我们可以根据这个实例来判断是否泄漏,例如:

  • Activity : 判断实例是否是 android.app.Activity 的子类,并且 mFinished 或 mDestroyed 是否为 true (Activity 关闭时该值会为 true),因为 Activity 不泄露的话肯定是会被释放,所以,不可能存在于 dump 的实例中,有就是发生了泄漏
  • Bitmap : 获取实例的类名称是否为 android.graphics.Bitmap,如果是的话,则获取实例的 mWidth 和 mHeight 实例变量,计算两者的乘积是否超过阈值,是的话,也判定为泄漏
  • .... (更多判断可以看 analysis 目录的各个 Detector)

Shark 根据 objectId 分析出的引用链路径:

   ┬───
  │ GC Root: Local variable in native code
  │
  ├─ android.os.HandlerThread instance
  │    Leaking: UNKNOWN
  │    ↓ HandlerThread.contextClassLoader
  │                    ~~~~~~~~~~~~~~~~~~
  ├─ dalvik.system.PathClassLoader instance
  │    Leaking: UNKNOWN
  │    ↓ PathClassLoader.runtimeInternalObjects
  │                      ~~~~~~~~~~~~~~~~~~~~~~
  ├─ java.lang.Object[] array
  │    Leaking: UNKNOWN
  │    ↓ Object[].[197]
  │               ~~~~~
  ├─ com.kwai.koom.demo.leaked.ActivityLeakMaker$LeakedActivity class
  │    Leaking: UNKNOWN
  │    ↓ static ActivityLeakMaker$LeakedActivity.uselessObjectList
  │                                              ~~~~~~~~~~~~~~~~~
  ├─ java.util.ArrayList instance
  │    Leaking: UNKNOWN
  │    ↓ ArrayList.elementData
  │                ~~~~~~~~~~~
  ├─ java.lang.Object[] array
  │    Leaking: UNKNOWN
  │    ↓ Object[].[0]
  │               ~~~
  ╰→ com.kwai.koom.demo.leaked.ActivityLeakMaker$LeakedActivity instance
 •     Leaking: YES (This is the leaking object), Signature: 39f4102649e5d3a5be12db591c2e5f68a1c0d2e9

三、如何应用于线上

1、解决 dump 冻结问题

由于 dump hprof 会暂停所有 java 线程问题,致使 LeakCanary 只能应用于线下检测。但 Koom 和 Liko 另辟蹊径,采用 linux 的 copy-on-write 机制,从当前的主线程 fork 出一个子进程,然后在子进程进行 dump 分析,对于用户所在的进程不会有任何感知。

这个地方会有个坑,就是在 fork 子进程的时候 dump hprof。由于 dump 前会先 suspend 所有的 java 线程,等所有线程都挂起来了,才会进行真正的 dump。由于 copy-on-write 机制,子进程也会将父进程中的 threadList 也拷贝过来,但由于 threadList 中的 java 线程活动在父进程,子进程是无法挂起父进程中的线程的,然后就会一直处于等待中。

为了解决这个问题,Koom 和 Liko 采用欺骗的方式,在 fork 子进程之前,先将父进程中的 threadList 全部设置为 suspend 状态,然后 fork 子进程,子进程在 dump 的时候发现 threadList 都为挂起状态了,就立马开始 dump hprof,然后父进程在 fork 操作之后,立马 resume 恢复回 threadList 的状态

2、解决混淆问题

Shark 支持混淆反解析,思路也很简单,解析 mapping.txt 文件,每次读取一行,只解析类和字段:

  • 类特征 :行尾为 : 冒号结尾,然后根据 -> 作为 index 分割,左边的为原类名,右边的为混淆类名
  • 字段特征:行尾不为 : 冒号结尾,并且不包含 (括号(带括号的为方法),即为字段特征,根据 -> 作为 index 分割,左边为原字段名,右边的为混淆字段名

将混淆类名、字段名作为 key,原类名、原字段名作为 value 存入 map 集合,在分析出内存泄漏的引用路径类时,将类名和字段名都通过这个 map 集合去拿到原始类名和字段名即可,即完成混淆后的反解析

leakCanary 内部是写死的 mapping 文件为 leakCanaryObfuscationMapping.txt,如果打开该文件失败,则不做引用链反解析:

image.png

也即意味着,如果想 LeakCanary 支持混淆反解析,只需要将自己的 mapping 文件重命名为 leakCanaryObfuscationMapping.txt,然后放入 asset 目录即可

对于 Koom 的混淆反解析,Koom 并没有做,但我们可以自己去加这块代码:

private boolean buildIndex() {
  ...
   try {
     // 新增 ---------- start
     InputStream is =  KGlobalConfig.getApplication().getResources().getAssets().open("mapping.txt");
     ProguardMapping mapping = new ProguardMappingReader(is).readProguardMapping();
    // 新增 ---------- end
       
     heapGraph = HprofHeapGraph.Companion.indexHprof(hprof, mapping,
             kotlin.collections.SetsKt.setOf(gcRoots));
  } catch (Exception e) {
     e.printStackTrace();
  }
   return true;
}        

将 mapping.txt 文件放到 asset 目录即可,如下是混淆与混淆反解析的引用链的对比:

image.png

3、泄漏兜底

在预判内存泄漏发生时,我们可以将 Activity 中引用到的 Bitmap、DrawingCache 等进行主动释放,以此来降低泄漏的影响面。做法是,在 Activity onDestory 时候从 view 的 rootview 开始,递归释放所有子 view 涉及的图片、背景、DrawingCache、监听器等等资源,让 Activity 成为一个不占资源的空壳,泄露了也不会导致图片资源被持有,eg:

...
   Drawable d = iv.getDrawable();
if (d != null) {
   d.setCallback(null);
}        
iv.setImageDrawable(null);
...
...

但这一点对于阈值检测法的 Koom 来说,没办法做到,因为他拿不到 onDestroy 时的 Activity 实例,但也不要紧,我们可以将兜底操作做成通用操作,不管他泄漏与不泄露,都做 view 相关引用的卸载。

四、总结:

整体下来,分析个内存泄漏其实并不难,难就难在我们平时并没有养成好的习惯,对于引用的传递考虑的不周全,但我们可以加强自身的编码习惯,尽量减少项目中的泄漏问题

注:本文分析的 Koom 源码为 1.0 版本,目前 Koom 已经出 2.0 版本


收起阅读 »

JAVA创建线程的三种方式

JAVA创建线程的三种方式一、JAVA创建线程的方式JAVA中为了有异步计算,所以需要开启线程帮助后来计算,后台运行,在java中开启线程的方式有三种:继承Thread类实现Runnable接口使用Callable和Future二、线程创建方式的异同继承Thr...
继续阅读 »

JAVA创建线程的三种方式

一、JAVA创建线程的方式

JAVA中为了有异步计算,所以需要开启线程帮助后来计算,后台运行,在java中开启线程的方式有三种:

  1. 继承Thread类
  2. 实现Runnable接口
  3. 使用Callable和Future

二、线程创建方式的异同

  1. 继承Thread类: (1)通过Thread的构造方法创建线程 (2)通过start启动线程线程,且一个线程只能执行一次 (3)调用this.即可获得当前线程 (4)若要两个线程之间共享变量时,需要在声明为static变量,不推荐使用static变量,因为异步问题不容易控制。
  2. 实现Runnable接口 (1)线程只是实现了Runnable接口,还可以继承其他类和实现其他接口; (2)可以多个线程之间共享同一个目标对象(Runnable),非常适合多个线程处理同一份资源的情况;这一点比较难理解,看下面的代码示例会好理解。 (3)使用Thread.currentThread()可获得当前线程
  3. FutureTask:使用Callable和Future (1)Callable接口是Runnable接口的增强版 (2)Callable接口中的call()方法可以有返回值,也可以声明抛出异常

实现Runnable接口和继承Thread的方式区别: 继承Thread创建的线程是创建的Thread子类即可代表线程对象;而实现Runnable接口的创建的Runnable对象只能作为线程对象的target。 这也就是Runnable可以共享数据的原因。

FutureTask、Callable、Future之间的关系: 它实现了了RunnableFuture接口,而RunnableFuture接口又继承自Runnable和Future接口,所以FutureTask既可以作为Runnable被线程执行,又可以作为Future得到Callable的返回值。

public class FutureTask<V> implements RunnableFuture<V> {

}

三、实践

3.1 继承Thread类

class Test {

public static int number = 0;

public static void main(String[] args) {
TestThread testThread = new TestThread();
testThread.start();

TestThread2 testThread2 = new TestThread2();
testThread2.start();
}

public static class TestThread extends Thread {
@Override
public void run() {
super.run();
for (int i = 0; i < 10; i++) {
System.out.println(this.getName() + ":" + ++number);
}
}
}

public static class TestThread2 extends Thread {
@Override
public void run() {
super.run();
for (int i = 0; i < 10; i++) {
System.out.println(this.getName() + ":" + ++number);
}
}
}
}

3.2 实现Runnable接口

该代码示例中是两个线程共用一个Runnable,其中Runnable的数据是两个线程之间共享的。

class Test {
public static void main(String[] args) {
TestRunnable testRunnable = new TestRunnable();
// 共用一个Runnable,数据会按顺序输出
new Thread(testRunnable).start();
new Thread(testRunnable).start();

}

public static class TestRunnable implements Runnable {
int number = 0;

@Override
public void run() {
++number;
System.out.println(Thread.currentThread().getName() + " " + number);
}
}
}

3.3 FutureTask:使用Callable和Future

class Test {
public static void main(String[] args) {
FutureTask<Integer> task = new FutureTask<Integer>((Callable<Integer>) () -> {
int i = 0;
while (i < 10) {
++i;
}
//call()方法的返回值
return i;
});

new Thread(task, "有返回值的线程").start();

try {
System.out.println("task.get():" + task.get());
} catch (Exception e) {
e.printStackTrace();
}
}
}
收起阅读 »

冒泡排序的进化过程

基础版本 所有情况下时间复杂度都为O(n2n^2n2) public static void bob(int[] array) { // 总共比较n-1轮 for (int i = 0; i < array.length - 1; i++...
继续阅读 »

基础版本


所有情况下时间复杂度都为O(n2n^2)


public static void bob(int[] array) {
// 总共比较n-1轮
for (int i = 0; i < array.length - 1; i++) {
// 因为每次都能确定一个最大元素,所以每次都能少比较一次
for (int j = 0; j < array.length - i - 1; j++) {
if (array[j] > array[j + 1]) {
int temp = array[j];
array[j] = array[j + 1];
array[j + 1] = temp;
}
}
}
}

上述算法简单粗暴,对任意数组上来就是两层for套着猛干。


如果现在有个有序数组,比如{1,2,3,4,5,6,7,8},也会白费力气去浪费cpu,这是不必要的。怎么避免呢?


我们看到,如果元素整体有序,那么上述代码中的


if (arr[j] > arr[j + 1])

就永远不会满足,那么就不会发生元素的交换,所以我们可以添加个布尔值,来判断是否发生了元素交换,如果没发生,则认为已经整体有序了,直接跳出即可。如下:


1 进阶版本


这里添加了一个boolean来判断本次是否有元素交换,没有则提前结束。


private static void bob2(int[] arr) {
int length = arr.length;
for (int i = 0; i < length; i++) {
boolean swap = false;
for (int j = 0; j < length - 1; j++) {
if (arr[j] > arr[j + 1]) {
int temp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = temp;

swap = true;
}
}
if (!swap) break;
}
}

上述代码可以避免对整体有序的数组瞎排序。但是,如果一个数组不是整体有序,而是局部有序呢,比如{4,3,2,1,5,6,7,8},我们观察到后半部分是不需要参加排序的,也就是说,只需要将前半部分排序即可。


所以,我们就要确定从哪开始的元素是有序的,也就是确定有序区开始的下标


那么,怎么确定这个下标呢?


我们可以想一下,对于冒泡排序,如果后面的元素比前面的大,才交换,否则就不交换,也就是说,最后一次发生交换的位置,其后面一定是有序的。比如在位置i发生了交换,i后面没有发生过交换,那么i后面一定是有序的,否则i后面就还会发生交换。


所以,每次元素最后一次交换的位置,就是有序区下标的起点,也是无序区下标的终点。


定义一个下标,每次有元素交换就更新下标,下标后面的元素就是有序的,每次比较只比较下标前面的元素即可


代码如下:


2 高阶版本


private static void bob3(int[] arr) {
int length = arr.length;
int lastSwapIndex = 0;
// 定义有序区起始点,也就是无序区终点
int sortedBorder = length - 1;
for (int i = 0; i < length; i++) {
boolean swap = false;
// 只比较无序区
for (int j = 0; j < sortedBorder; j++) {
if (arr[j] > arr[j + 1]) {
int temp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = temp;
swap = true;
// 发生了交换,就更新下标
lastSwapIndex = j;
}
}
// 更新下标
sortedBorder = lastSwapIndex;
if (!swap) break;
}
}

上述代码就可以解决局部有序但整体无序的情况。


但是我们发现,上面的代码,都是从左向右比较的,如果数组是{2,3,4,5,6,7,8,1}这样呢,也是局部有序,但是,如果从左往右比,则很费时间,而从右往左比,则一轮就能结束。


但是!如果从右往左比的话,遇见{8,1,2,3,4,5,6,7}这样的又跪了,怎么办呢?我们可以采用双向比较法,也就是一次从左向右比,一次从右向左比,这就叫鸡尾酒排序


现在我们使用鸡尾酒排序(双向排序),每次排序后交换方向,代码如下:


3 最终版本(鸡尾酒排序)


private static void bob4(int[] arr) {
int length = arr.length;
for (int i = 0; i < (length >>> 1); i++) {
boolean swap = false;
// 从左到右
for (int j = 0; j < length - 1; j++) {
if (arr[j] > arr[j + 1]) {
swap(arr, j, j + 1);
swap = true;
}
}

if (!swap) break;

// 从右到左
swap = false;
for (int j = length - 1; j > 0; j--) {
if (arr[j] < arr[j - 1]) {
swap(arr, j, j - 1);
swap = true;
}
}

if (!swap) break;
}
}

// 交换元素
private static void swap(int[] arr, int i, int j) {
arr[i] ^= arr[j];
arr[j] ^= arr[i];
arr[i] ^= arr[j];
}

总结


我们虽然针对冒泡排序进行了多次优化,但是它的时间复杂度还是O(n2),这是无法避免的,因为冒泡排序每次只是交换相邻元素,也就是只消除了一个逆序对,凡是通过交换相邻元素进行的排序,其时间复杂度都是O(n2)


为什么呢?因为交换相邻元素每次只消除了一个逆序对。我们来证明下。


学过<线性代数>的应该知道逆序这个定义。



在一个排列中,如果一对数的前后位置与大小顺序相反,即前面的数大于后面的数,那么它们就称为一个逆序。一个排列中逆序的总数就称为这个排列的逆序数。



证明: 凡是通过交换相邻元素进行的排序,其时间复杂度都是O(n2n^2)


假设现有任意序列L,其共有n个元素,则其共能组成Cn2C_n^2个数对(从n个元素中,挑出2个元素组成的数对),也就是(n(n1)2)(\frac{n(n-1)}{2})个, 其中逆序数为a;然后取其反序列Lr,其逆序数为b,而且b=(n(n1)2)(\frac{n(n-1)}{2})-a,因为原来L中的顺序对,在Lr中全变成了逆序对,而且对于任意的数对,要么是顺序,要么是逆序(相同的可以认为是顺序),所以a+b=(n(n1)2)(\frac{n(n-1)}{2})


所以,L和Lr的总逆序对就是(n(n1)2)(\frac{n(n-1)}{2}),那么单个L的逆序对就是(n(n1)4)(\frac{n(n-1)}{4}),当n趋近于+∞时,就是n2n^2,而通过交换相邻元素每次只能消除一个逆序对,所以总共需要交换n2n^2次,所以相应算法的时间复杂度就是O(n2n^2)。


为什么交换相邻元素只能消除一个逆序对呢,因为只改变了相邻俩元素的位置,它俩前后的该比它大还是比它大,该比它小还是比它小。比如{5,4,3,2,1},我们交换了3和2,变为{5,4,2,3,1},我们发现,只是消除了{1,2}这个逆序对,前面的5和4,还是比它俩大,后面的1,还是比它俩小,所以只消除了一个逆序对,这里不再废话。


证明完毕。


其实,我们可以扩展一下,凡是每次只能消除一个逆序对的算法,其时间复杂度都是O(n2n^2)。也不再废话。


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

FastKV:一个真的很快的KV存储组件

一、前言 KV存储无论对于客户端还是服务端都是重要的构件。 对于Android客户端而言,最常见的莫过于SDK提供的SharePreferences(以下简称SP),但其低效率和ANR问题饱受诟病。 后来官方又推出了基于Kotlin的DataStore, 其中...
继续阅读 »

一、前言


KV存储无论对于客户端还是服务端都是重要的构件。

对于Android客户端而言,最常见的莫过于SDK提供的SharePreferences(以下简称SP),但其低效率和ANR问题饱受诟病。

后来官方又推出了基于Kotlin的DataStore, 其中的Preferences DataStore,换汤不换药,底层的存储策略还是一样的,目测该有的问题还是有。

18年年末微信开源了MMKV, 有较高热度。

我之前写过一个叫LightKV的Android客户端的KV存储组件,开源时间比MMKV要早一点,但关注量不多……不过话说回来,由于当时认知不足,LightKV的设计也不够成熟。


1.1 SP的不足


关于SP的缺点网上有不少讨论,这里主要提两个点:



  • 保存速度较慢


SP用内存层用HashMap保存,磁盘层则是用的XML文件保存。

每次更改,都需要将整个HashMap序列化为XML格式的报文然后整个写入文件。

归结其较慢的原因:

1、不能增量写入;

2、序列化比较耗时。



  • 可以能会导致ANR


public void apply() {
// ...省略无关代码...
QueuedWork.addFinisher(awaitCommit);
Runnable postWriteRunnable = new Runnable() {
@Override
public void run() {
awaitCommit.run();
QueuedWork.removeFinisher(awaitCommit);
}
};
SharedPreferencesImpl.this.enqueueDiskWrite(mcr, postWriteRunnable);
}

public void handleStopActivity(IBinder token, boolean show, int configChanges,
PendingTransactionActions pendingActions, boolean finalStateRequest, String reason) {
// ...省略无关代码...
// Make sure any pending writes are now committed.
if (!r.isPreHoneycomb()) {
QueuedWork.waitToFinish();
}
}

Activity stop时会等待SP的写入任务,如果SP的写入任务多且执行慢的话,可能会阻塞主线程较长时间,轻则卡顿,重则ANR。


1.2 MMKV的不足



  • 没有类型信息,不支持getAll

    MMKV的存储用类似于Protobuf的编码方式,只存储key和value本身,没有存类型信息(Protobuf用tag标记字段,信息更少)。

    由于没有记录类型信息,MMKV无法自动反序列化,也就无法实现getAll接口。

  • 读取相对较慢

    SP在加载的时候已经将value反序列化存在HashMap中了,读取的时候索引到之后就能直接引用了。

    而MMKV每次读取时都需要重新解码,除了时间上的消耗之外,还需要每次都创建新的对象。

    不过这不是大问题,相对SP没有差很多。

  • 需要引入so, 增加包体积

    引入MMKV需要增加的体积还是不少的,且不说jar包和aidl文件,光是一个arm64-v8a的so就有四百多K。



虽然说现在APP体积都不小,但毕竟增加体积对打包、分发和安装时间都多少有些影响。



  • 文件只增不减

    MMKV的扩容策略还是比较激进的,而且扩容之后不会主动trim size。

    比方说,假如有一个大value,让其扩容至1M,后面删除该value,哪怕有效内容只剩几K,文件大小还是保持在1M。

  • 可能会丢失数据

    前面的问题总的来说都不是什么“要紧”的问题,但是这个丢失数据确实是硬伤。

    MMKV官方有这么一段表述:

    通过 mmap 内存映射文件,提供一段可供随时写入的内存块,App 只管往里面写数据,由操作系统负责将内存回写到文件,不必担心 crash 导致数据丢失。





这个表述对一半不对一半。

如果数据完成写入到内存块,如果系统不崩溃,即使进程崩溃,系统也会将buffer刷入磁盘;

但是如果在刷入磁盘之前发生系统崩溃或者断电等,数据就丢失了,不过这种情况发生的概率不大;

另一种情况是数据写一半的时候进程崩溃或者被杀死,然后系统会将已写入的部分刷入磁盘,再次打开时文件可能就不完整了。

例如,MMKV在剩余空间不足时会回收无效的空间,如果这期间进程中断,数据可能会不完整。
MMKV官方的说明可以佐证:



CRC校验失败之后,MMKV有两种应对策略:直接丢弃所有数据,或者尝试读取数据(用户可以在初始化时设定)。

尝试读取数据不一定能恢复数据,甚至可能会读到一些错误的数据,得看运气。


这个过程是比较容易复现的,下面是其中一种复现路径:



  1. 新增和删除若干key-value
    得到数据如下:





  1. 插入一个大字符串,触发扩容,扩容前会触发垃圾回收




  2. 断点打在执行memmove的循环中,执行一部分memmove, 然后在手机上杀死进程






  1. 再次打开APP,数据丢失



相比之下,SP虽然低效,但至少不会丢失数据。


二、FastKV


在总结了之前的经验和感悟之后,笔者实现了一个高效且可靠的版本,且将其命名为: FastKV


2.1 特性


FastKV有以下特性:



  1. 读写速度快

    • FastKV采用二进制编码,编码后的体积相对XML等文本编码要小很多。

    • 增量编码:FastKV记录了各个key-value相对文件的偏移量,更新数据时,可以直接在对应的位置写入数据。

    • 默认用mmap的方式记录数据,更新数据时直接写入到内存即可,没有IO阻塞。



  2. 支持多种写入模式

    • 除了mmap这种非阻塞的写入方式,FastKV也支持常规的阻塞式写入方式,
      并且支持同步阻塞和异步阻塞(分别类似于SharePreferences的commit和apply)。



  3. 支持多种类型

    • 支持常用的boolean/int/float/long/double/String等基础类型。

    • 支持ByteArray (byte[])。

    • 支持存储自定义对象。

    • 内置StringSet编码器 (为了兼容SharePreferences)。



  4. 方便易用

    • FastKV提供了了丰富的API接口,开箱即用。

    • 提供的接口其中包括getAll()和putAll()方法,
      所以迁移SharePreferences等框架的数据到FastKV很方便,当然,迁移FastKV的数据到其他框架也很方便。



  5. 稳定可靠

    • 通过double-write等方法确保数据的完整性。

    • 在API抛IO异常时提供降级处理。



  6. 代码精简

    • FastKV由纯Java实现,编译成jar包后体积仅30多K。




2.2 实现原理


2.2.1 编码


文件的布局:



[data_len | checksum | key-value | key-value|....]




  • data_len: 占4字节, 记录所有key-value所占字节数。

  • checksum: 占8字节,记录key-value部分的checksum。


key-value的数据布局:


+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| delete_flag | external_flag | type | key_len | key_content | value |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| 1bit | 1bit | 6bits | 1 byte | | |


  • delete_flag :标记当前key-value是否删除。

  • external_flag: 标记value部分是否写到额外的文件。

    注:对于数据量比较大的value,放在主文件会影响其他key-value的访问性能,因此,单独用一个文件来保存该value, 并在主文件中记录其文件名。

  • type: value类型,目前支持boolean/int/float/long/double/String/ByteArray以及自定义对象。

  • key_len: 记录key的长度,key_len本身占1字节,所以支持key的最大长度为255。

  • key_content: key的内容本身,utf8编码。

  • value: 基础类型的value, 直接编码(little-end);

    其他类型,先记录长度(用varint编码),再记录内容。

    String采用UTF-8编码,ByteArray无需编码,自定义对象实现Encoder接口,分别在Encoder的encode/decode方法中序列化和反序列化。


2.2.2 存储




  • mmap

    为了提高写入性能,FastKV默认采用mmap的方式写入。




  • 降级

    当mmap API发生IO异常时,降级到常规的blocking I/O,同时为了不影响当前线程,会将写入放到异步线程中执行。




  • 数据完整性

    如果在写入一部分的过程中发生中断(进程或系统),则文件可能会不完整。

    故此,需要用一些方法确保数据的完整性。

    当用mmap的方式打开时,FastKV采用double-write的方式:数据依次写入A/B两个文件,确保任何时刻总有一个文件完整的; 加载数据时,通过checksum、标记、数据合法性检验等方法验证数据的正确性。

    double-write可以防止进程崩溃后数据不完整,但mmap是系统定时刷盘,若在刷盘系统崩溃或者断电,仍会丢失更新(之前的数据还在,仅丢失更新)。 可以通过调用force()强制刷盘,但这就不能发挥mmap的优点了。

    基于此,FastKV也支持用blocking I/O的方式写文件(比mmap慢,但是能确保数据真正落盘)。

    当用blocking I/O的写入时,先写临时文件,完整写入后再删除主文件,然后重命名临时文件为主文件。

    FastKV支持同步的和异步的blocking I/O,写入方式类似于SP的commit和apply,但是序列化key-value的部分是增量的,比SP的序列化整个HashMap的方式要快许多。




  • 更新策略(增/删/改)

    新增:写入到数据的尾部。

    删除:delete_flag设置为1。

    修改:如果value部分的长度和原来一样,则直接写入原来的位置;
    否则,先写入key-value到数据尾部,再标记原来位置的delete_flag为1(删除),最后再更新文件的data_len和checksum。




  • gc/truncate

    删除key-value时会收集信息(统计删除的个数,以及所在位置,占用空间等)。

    GC的触发点有两个:

    1、新增key-value时剩余空间不足,且已删除的空间达到阈值,且腾出删除空间后足够写入当前key-value, 则触发GC;

    2、删除key-value时,如果删除空间达到阈值,或者删除的key-value个数达到阈值,则触发GC。

    GC后如果不用的空间达到设定阈值,则触发truncate(缩小文件大小)。




2.3 使用方法


2.3.1 导入


dependencies {
implementation 'io.github.billywei01:fastkv:1.0.2'
}

2.3.2 初始化


    FastKVConfig.setLogger(FastKVLogger)
FastKVConfig.setExecutor(ChannelExecutorService(4))

初始化可以按需设置日志回调和Executor。

建议传入自己的线程池,以复用线程。


日志接口提供三个级别的回调,按需实现即可。


    public interface Logger {
void i(String name, String message);

void w(String name, Exception e);

void e(String name, Exception e);
}

2.3.3 数据读写



  • 基本用法


    FastKV kv = new FastKV.Builder(path, name).build();
if(!kv.getBoolean("flag")){
kv.putBoolean("flag" , true);
}


  • 保存自定义对象


    FastKV.Encoder<?>[] encoders = new FastKV.Encoder[]{LongListEncoder.INSTANCE};
FastKV kv = new FastKV.Builder(path, name).encoder(encoders).build();

String objectKey = "long_list";
List<Long> list = new ArrayList<>();
list.add(100L);
list.add(200L);
list.add(300L);
kv.putObject(objectKey, list, LongListEncoder.INSTANCE);

List<Long> list2 = kv.getObject("long_list");

FastKV支持保存自定义对象,为了加载文件时能自动反序列化,需在构建FastKV实例时传入对象的编码器。

编码器为实现FastKV.Encoder的对象。

比如上面的LongListEncoder的实现如下:


public class LongListEncoder implements FastKV.Encoder<List<Long>> {
public static final LongListEncoder INSTANCE = new LongListEncoder();

@Override
public String tag() {
return "LongList";
}

@Override
public byte[] encode(List<Long> obj) {
return new PackEncoder().putLongList(0, obj).getBytes();
}

@Override
public List<Long> decode(byte[] bytes, int offset, int length) {
PackDecoder decoder = PackDecoder.newInstance(bytes, offset, length);
List<Long> list = decoder.getLongList(0);
decoder.recycle();
return (list != null) ? list : new ArrayList<>();
}
}

编码对象涉及序列化/反序列化。

这里推荐笔者的另外一个框架:github.com/BillyWei01/…


2.3.4 For Android


Android平台上的用法和常规用法一致,不过Android平台多了SharePreferences API,以及支持Kotlin。

FastKV的API兼容SharePreferences, 可以很轻松地迁移SharePreferences的数据到FastKV。

相关用法可参考:github.com/BillyWei01/…


三、 性能测试



  • 测试数据:搜集APP中的SharePreferenses汇总的部份key-value数据(经过随机混淆)得到总共四百多个key-value。由于日常使用过程中部分key-value访问多,部分访问少,所以构造了一个正态分布的访问序列。

  • 比较对象: SharePreferences 和 MMKV

  • 测试机型:荣耀20S


测试结果:



























写入(ms)读取(ms)
SharePreferences14906
MMKV349
FastKV141


  • SharePreferences提交用的是apply, 耗时依然不少。

  • MMKV的读取比SharePreferences要慢一些,写入则比之快许多。

  • FastKV无论读取还是写入都比另外两种方式要快。


四、结语


本文探讨了当下Android平台的各类KV存储方式,提出并实现了一种新的存储组件,着重解决了KV存储的效率和数据可靠性问题。

目前代码已上传Github: github.com/BillyWei01/…


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

移动端网络监控实践

1. 背景介绍 在移动端应用开发场景下,不可避免的要与网络打交道。有时在网络请求失败时,我们想知道网络的质量;有时需要明确的告知用户当前网络质量(比如游戏场景实时显示延迟)。网络监控离不开最经典的TCP/IP模型,基于模型分层统计网络耗时有助于我们更清晰的了解...
继续阅读 »

1. 背景介绍


在移动端应用开发场景下,不可避免的要与网络打交道。有时在网络请求失败时,我们想知道网络的质量;有时需要明确的告知用户当前网络质量(比如游戏场景实时显示延迟)。网络监控离不开最经典的TCP/IP模型,基于模型分层统计网络耗时有助于我们更清晰的了解当前网络质量。


image-20210929181044521-2910245.png


TCP/IP参考模型中物理层难以在网络层面统计,网络层有Ping工具,传输层有系统提供的Socket接口,应用层最常用的有HTTP、RTMP协议。本文我们介绍ping工具、DNS解析耗时、TCP连接耗时、HTTP建立耗时。


2. ping


ping是基于网络层ICMP协议的,发送的是ICMP回显请求报文。下面我们先了解下ICMP。


2.1 ICMP(Internet控制报文协议)简介


ICMP是IP层的一个组成部分,主要传递差错报文以及其他需要注意的信息。ICMP报文通常被IP层或更高层协议(TCP或UDP)使用。ICMP的正式规范参见RFC 792[Posterl 1981b],ICMP封装在IP数据包内部,格式是20字节的IP首部+ICMP报文。ICMP报文格式如下:


image-20211005181747393-3429068.png


所有报文的前4个字节都是一样的,但是剩下的其他字节则互不相同。类型字段可以有15个不同的值,以描述特定类型的ICMP报文,某些CIMP报文还是用代码字段的值来进一步描述不同的条件。校验和字段覆盖整个ICMP报文。


不同类型由报文中的类型字段和代码字段来共同决定。报文可以分为查询报文和差错报文,ICMP差错报文有时需要做特殊处理(如在对ICMP差错报文进行响应时,永远不会生成另一份ICMP差错报文)。


对于我们今天用到的ping程序,使用了:



  • 类型为0,代码为0的回显应答的查询报文

  • 类型为8,代码为0的请求回显的查询报文


2.2 Ping程序协议简介


ping的工作原理很简单,一台网络设备发送请求等待另一网络设备的回复,并记录下发送时间。接收到回复之后,就可以计算报文传输时间了。只要接收到回复就表示连接是正常的。耗费的时间喻示了路径长度。重复请求响应的一致性也表明了连接质量的可靠性。因此,ping回答了两个基本的问题:是否有连接?连接的质量如何?


我们称发送回显请求的ping程序为客户,称被ping的主机为服务器。大多数的TCP/IP实现都在内核中直接支持ping服务器,这种服务器不是一个用户进程。ICMP回显请求和回显应答报文如下:


image-20211005183328906-3430011.png


Unix系统在实现ping程序时是把ICMP报文中的标识符字段置成发送进程的ID号。这样即使在同一台主机上同时运行了多个ping程序实例,ping程序也可以识别出返回的信息。序列号从0开始,每发送一次新的回显请求就加1。


2.3 ping程序命令介绍


ping程序的主要选项:



  1. -c:选项允许用户指定发送报文的数量,例如,ping –c10会发送10个报文然后停止;

  2. -f:选项表明报文发送速率与接收主机能够处理速率相同,这一参数可用于链路压力测试或接口性能比较;

  3. -l:选项用于计数,尽可能快的发送该数量报文,然后恢复正常,该命令用于测试处理泛洪的能力,需要root权限执行;

  4. -i:选项用于用户在两个连续报文之间指定等待秒数。该命令对于将报文间隔开或用在脚本中非常有用。正常情况下,偶然的ping包对数据流的影响是很小的。但重复报文或报文泛洪影响就很大了。因此,使用该选项时需谨慎;

  5. -n:选项将输出限制为数字形式,这在碰见DNS问题时很有用;

  6. -v:显示更详尽输出,较少输出为-q和-Q;

  7. -s:选项指定发送数据的大小。但如果设置的太小,小于8,则报文中就没有空间留给时间戳了。设置报文大小能诊断有路径MTU(Maximum Transmission Unit)设置或分段而导致的问题。如果不使用该选项,ping默认是64字节。


2.4 Android端执行ping程序


Android系统提供了ping命令行程序,在程序中可以通过popen执行系统自带ping程序,下面是执行ping程序的代码:


int RunPingQuery(int _querycount, int interval/*S*/, int timeout/*S*/, const char* dest, unsigned int packetSize) {
char cmd[256] = {0};


int index = snprintf(cmd, 256, "ping -c %d -i %d -w %d", _querycount, interval, timeout);

if (index < 0 || index >= 256) {
//sprintf return error
return -1;
}

int tempLen = 0;

if (packetSize > 0) {
tempLen = snprintf((char*)&cmd[index], 256 - index, " -s %u %s", packetSize, dest);
} else {
tempLen = snprintf((char*)&cmd[index], 256 - index, " %s", dest);
}

if (tempLen < 0 || tempLen >= 256 - index) {
//sprintf return error
return -1;
}
FILE* pp = popen(cmd, "r");

if (!pp) {
//popen error
return -1;
}

std::string pingresult_;
while (fgets(line, sizeof(line), pp) != NULL) {
pingresult_.append(line, strlen(line));
}

pclose(pp);

if (pingresult_.empty()) {
//m_strPingResult is empty
return -1;
}

struct PingStatus pingStatusTemp; //= {0};notice: cannot initial with = {0},crash
GetPingStatus(pingStatusTemp);
if (0 == pingStatusTemp.avgrtt && 0 == pingStatusTemp.maxrtt) {
//remote host is not available
return -1;
}
return 0;
}

int GetPingStatus(struct PingStatus& _ping_status, std::string pingresult_) {
if (pingresult_.empty()) return -1;

_ping_status.res = pingresult_;
std::vector<std::string> vecPingRes;
str_split('\n', pingresult_, vecPingRes);

std::vector<std::string>::iterator iter = vecPingRes.begin();

for (; iter != vecPingRes.end(); ++iter) {
if (vecPingRes.begin() == iter) { // extract ip from the result string and assign to _ping_status.ip
int index1 = iter->find_first_of("(", 0);

if (index1 > 0) {
int index2 = iter->find_first_of(")", 0);

if (index2 > index1) {
int size = index2 - index1 - 1;
std::string ipTemp(iter->substr(index1 + 1, size));
strncpy(_ping_status.ip, ipTemp.c_str(), (size < 16 ? size : 15));
}
}
} // end if(vecPingRes.begin()==iter)

int num = iter->find("packet loss", 0);

if (num >= 0) {
int loss_rate = 0;
int i = 3;

while (iter->at(num - i) != ' ') {
loss_rate += ((iter->at(num - i) - '0') * (int)pow(10.0, (double)(i - 3)));
i++;
}
_ping_status.loss_rate = (double)loss_rate / 100;
}

int num2 = iter->find("rtt min/avg/max", 0);

if (num2 >= 0) {
int find_begpos = 23;
int findpos = iter->find_first_of('/', find_begpos);
std::string sminRTT(*iter, find_begpos, findpos - find_begpos);
find_begpos = findpos + 1;
findpos = iter->find_first_of('/', find_begpos);
std::string savgRTT(*iter, find_begpos, findpos - find_begpos);
find_begpos = findpos + 1;
findpos = iter->find_first_of('/', find_begpos);
std::string smaxRTT(*iter, find_begpos, findpos - find_begpos);
_ping_status.minrtt = atof(sminRTT.c_str());
_ping_status.avgrtt = atof(savgRTT.c_str());
_ping_status.maxrtt = atof(smaxRTT.c_str());
}
}
return 0;
}

2.5 iOS端发送ping指令


iOS端主要通过创建socket发送ICMP执行,主要思路如下:



  1. 如果设置的是域名,需要将DNS转换为IP;

  2. 创建socketn = socket(family, type, protocol),family为AF_INET, type为SOCK_DGRAM, protocol为IPPROTO_ICMP;

  3. 构造ICMP包:


struct icmp {
u_char icmp_type; /* type of message, see below */
u_char icmp_code; /* type sub code */
u_short icmp_cksum; /* ones complement cksum of struct */
union {
u_char ih_pptr; /* ICMP_PARAMPROB */
struct in_addr ih_gwaddr; /* ICMP_REDIRECT */
struct ih_idseq {
n_short icd_id;
n_short icd_seq;
} ih_idseq;
int ih_void;

/* ICMP_UNREACH_NEEDFRAG -- Path MTU Discovery (RFC1191) */
struct ih_pmtu {
n_short ipm_void;
n_short ipm_nextmtu;
} ih_pmtu;

struct ih_rtradv {
u_char irt_num_addrs;
u_char irt_wpa;
u_int16_t irt_lifetime;
} ih_rtradv;
} icmp_hun;
#define icmp_pptr icmp_hun.ih_pptr
#define icmp_gwaddr icmp_hun.ih_gwaddr
#define icmp_id icmp_hun.ih_idseq.icd_id
#define icmp_seq icmp_hun.ih_idseq.icd_seq
#define icmp_void icmp_hun.ih_void
#define icmp_pmvoid icmp_hun.ih_pmtu.ipm_void
#define icmp_nextmtu icmp_hun.ih_pmtu.ipm_nextmtu
#define icmp_num_addrs icmp_hun.ih_rtradv.irt_num_addrs
#define icmp_wpa icmp_hun.ih_rtradv.irt_wpa
#define icmp_lifetime icmp_hun.ih_rtradv.irt_lifetime
union {
struct id_ts {
n_time its_otime;
n_time its_rtime;
n_time its_ttime;
} id_ts;
struct id_ip {
struct ip idi_ip;
/* options and then 64 bits of data */
} id_ip;
struct icmp_ra_addr id_radv;
u_int32_t id_mask;
char id_data[1];
} icmp_dun;
#define icmp_otime icmp_dun.id_ts.its_otime
#define icmp_rtime icmp_dun.id_ts.its_rtime
#define icmp_ttime icmp_dun.id_ts.its_ttime
#define icmp_ip icmp_dun.id_ip.idi_ip
#define icmp_radv icmp_dun.id_radv
#define icmp_mask icmp_dun.id_mask
#define icmp_data icmp_dun.id_data
};

void __preparePacket(char* _sendbuffer, int& _len) {
char sendbuf[MAXBUFSIZE];
memset(sendbuf, 0, MAXBUFSIZE);
struct icmp* icmp;
icmp = (struct icmp*) sendbuf;
icmp->icmp_type = ICMP_ECHO;
icmp->icmp_code = 0;
icmp->icmp_id = getpid() & 0xffff;/* ICMP ID field is 16 bits */
icmp->icmp_seq = htons(nsent_++);
memset(&sendbuf[ICMP_MINLEN], 0xa5, DATALEN); /* fill with pattern */

struct timeval now;
(void)gettimeofday(&now, NULL);
now.tv_usec = htonl(now.tv_usec);
now.tv_sec = htonl(now.tv_sec);
bcopy((void*)&now, (void*)&sendbuf[ICMP_MINLEN], sizeof(now));
_len = ICMP_MINLEN + DATALEN; /* checksum ICMP header and data */
icmp->icmp_cksum = 0;
icmp->icmp_cksum = in_cksum((u_short*) icmp, _len);
memcpy(_sendbuffer, sendbuf, _len);
}


  1. 接收ICMP包:


int PingQuery::__recv() {
char recvbuf[MAXBUFSIZE];
char controlbuf[MAXBUFSIZE];
memset(recvbuf, 0, MAXBUFSIZE);
memset(controlbuf, 0, MAXBUFSIZE);

struct msghdr msg = {0};
struct iovec iov = {0};
iov.iov_base = recvbuf;
iov.iov_len = sizeof(recvbuf);
msg.msg_name = &recvaddr_;
msg.msg_iov = &iov;
msg.msg_iovlen = 1;
msg.msg_control = controlbuf;

msg.msg_namelen = sizeof(recvaddr_);
msg.msg_controllen = sizeof(controlbuf);

int n = (int)recvmsg(sockfd_, &msg, 0);

if (n < 0) {
return -1;
}
//解析消息结构
return n;
}

2.6 ping网络延迟的相关参考


ping外网小于50ms,网络的延迟就算良好,是正常的。


一般来说,网络的延迟PING值越低,速度会越快;但是网络的速度与网络延迟这二者之间没有必然的联系,以下是ping网络延迟的相关参考数据:



  • ping网络:1到30ms:速度极快,几乎察觉不出有延迟,玩任何游戏速度都特别顺畅;~

  • ping网络:31到50ms:速度良好,可以正常游戏浏览网页,没有明显的延迟情况;~

  • ping网络:51到100ms:速度普通,对抗类游戏在一定水平以上能感觉出延迟,偶尔感觉到停顿;

  • ping网络:100ms到200ms:速度较差,无法正常游玩对抗类游戏,有明显的卡顿现象,偶尔出现丢包和掉线现象。


3. dns解析耗时


在我们创建socket前,会有一个域名转IP的解析过程。域名系统是一种用于TCP/IP应用程序的分布式数据库,提供了域名和IP地址之间的转换及有关电子邮件的选路信息。在Unix主机中,通过两个库函数gethostbyname和gethostbyaddr来访问的。前者接收主机名字返回IP地址,后者接收IP地址来寻找主机名字。


我们知道域名解析要访问域名服务,连接域名服务是基于UDP还是TCP呢?DNS名字服务器使用的熟知端口号是53,通过tcpdump观察到所有例子都是采用UDP,为什么采用的是UDP呢?


当名字解析器发出一个查询请求,并且返回响应中的TC(删减标志)比特被设置为1时,它就意味着响应的长度超过了512个字节,而仅返回前512个字节。在遇到这种情况时,名字解析器通过使用TCP重发原来的查询请求,它将允许返回的响应超过512个字节。TCP能将用户的数据流分为一些报文段,它就能用多个报文段来传送任意长度的用户数据。


我们要统计DNS解析延时就需要自己创建socket,发送DNS报文并获取响应计算耗时。创建socket需要知道DNS服务地址,怎么获取DNS地址呢?


一种常见的方法通过获取手机配置文件获取:


char buf1[PROP_VALUE_MAX];
char buf2[PROP_VALUE_MAX];
__system_property_get("net.dns1", buf1);
__system_property_get("net.dns2", buf2);

这种方式高版本获取不到DNS服务地址,部分高版本手机可通过下面方法获取:


    char buf3[1024];
__system_property_get("ro.config.dnscure_ipcfg", buf3);
std::string dnsCureIPCfgStr(buf3);
if (!dnsCureIPCfgStr.empty()) {
const std::vector<std::string> &kVector = splitstr(dnsCureIPCfgStr, '|');
if (kVector.size() > 2) {
const std::vector<std::string> &kVector2 = splitstr(dnsCureIPCfgStr, ';');
if (kVector2.size() > 2) {
_dns_servers.push_back(kVector2[0]); // 主DNS
_dns_servers.push_back(kVector2[1]); // 备DNS
return;
}
}
}

该方法获取到DNS列表,以逗号分隔地址列表,内网外网通过|区分。


通过ConnectivityManager获取:


private static String[] getDnsFromConnectionManager(Context context) {
LinkedList<String> dnsServers = new LinkedList<>();
if (Build.VERSION.SDK_INT >= 21 && context != null) {
ConnectivityManager connectivityManager = (ConnectivityManager) context.getSystemService(context.CONNECTIVITY_SERVICE);
if (connectivityManager != null) {
NetworkInfo activeNetworkInfo = connectivityManager.getActiveNetworkInfo();
if (activeNetworkInfo != null) {
for (Network network : connectivityManager.getAllNetworks()) {
NetworkInfo networkInfo = connectivityManager.getNetworkInfo(network);
if (networkInfo != null && networkInfo.getType() == activeNetworkInfo.getType()) {
LinkProperties lp = connectivityManager.getLinkProperties(network);
for (InetAddress addr : lp.getDnsServers()) {
dnsServers.add(addr.getHostAddress());
}
}
}
}
}
}
return dnsServers.isEmpty() ? new String[0] : dnsServers.toArray(new String[dnsServers.size()]);
}

获取到的是内网DNS地址。在Android端获取DNS服务地址需要考虑到Android品牌及系统的兼容性。


4. tcp连接耗时统计


TCP耗时从socket创建到连接、收发消息耗时:



  1. 创建socketsocket(AF_INET, SOCK_STREAM, IPPROTO_TCP);

  2. 建立连接:int connectRet = connect(fsocket, (sockaddr*)&_addr, sizeof(_addr));

  3. 发送测试指令:send

  4. 接收消息:recv


5. 总结


本文讨论了统计移动端网络耗时网络质量的主要方法:ping耗时、DNS耗时、TCP连接耗时等。在移动端要考虑到获取DNS服务地址的兼容性、tcp socket读写次数等策略,以及简要介绍了网络质量评估方法。



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

MVVM + RxAndroid + RxView + DataBinding + LiveData + LiveEventBus + Retrofit

前言 本来想记录一下最近相机相关的知识点的,但发现需要时间整理一下,那这里就介绍一下最近写的直播app中使用的整体架构吧。 由于之前项目大多是用MVC,MVP的整体架构,所以这次一个人写直播项目时就干脆用MVVM进行开发(sunflower的架构让我很馋) 简...
继续阅读 »

前言


本来想记录一下最近相机相关的知识点的,但发现需要时间整理一下,那这里就介绍一下最近写的直播app中使用的整体架构吧。


由于之前项目大多是用MVC,MVP的整体架构,所以这次一个人写直播项目时就干脆用MVVM进行开发(sunflower的架构让我很馋)


简介

最后现阶段是 基于 MVVM



  • UI: AndroidX + DataBinding + RxView + Bravh

  • 数据传递: LiveData + LiveEventBus

  • 网络请求: Retrofit + RxAndroid + OkHttp3


 // 分包工具
implementation deps.support.multidex
// androidX
implementation deps.androidX.appcompat
implementation deps.androidX.recyclerview
implementation deps.androidX.constraintLayout
implementation deps.androidX.lifecycle
implementation deps.androidX.palette
// material
implementation deps.material.runtime
// implementation deps.support.design
// implementation deps.support.recyclerview
// 腾讯直播SDK
implementation deps.liteavSdk.liteavsdk_smart
// 自定义采集控件
implementation deps.liveKit.runtime
// OkHttp3 + OkHttp3拦截器 腾讯云需要
implementation deps.okHttp3.runtime
implementation deps.okHttp3.interceptor
// gson
implementation deps.gson.runtime
// 腾讯IM
implementation deps.imsdk.runtime
// Glide
implementation deps.glide.runtime
// 腾讯存储服务
implementation deps.cosxml.runtime
// B站弹幕
implementation deps.DanmakuFlameMaster.runtime
// rxAndroid + rxJava
implementation deps.rxAndroid.runtime
implementation deps.rxAndroid.rxjava
// rxBinding
implementation deps.rxBinding.runtime
// autoDispose
implementation deps.autoDispose.android
implementation deps.autoDispose.lifecycle
// retrofit
implementation deps.retrofit.runtime
implementation deps.retrofit.adapter
implementation deps.retrofit.converter
// xxpermissions
implementation deps.xxpermissions.runtime
// liveEventBus
implementation deps.liveEventBus.runtime
// banner
implementation deps.banner.runtime
// bravh
implementation deps.bravh.runtime
// hilt
// implementation deps.hilt.runtime
// implementation deps.hilt.lifecycle
// kapt deps.hilt.kapt
// kapt deps.hilt.compiler
// leakCanary
debugImplementation deps.leakCanary.runtime

以上就是大致引入的包,然后接下来就是针对业务场景的一整套流程演示了:


登录场景

View

/**
* 登录页面
*/
public class LoginActivity extends MVVMActivity {
private static final String TAG = "LoginActivity";

private LoadingDialog.Builder mLoading; // 加载页面
private ActivityLoginBinding mDataBinding;// DataBinding
private LoginViewModel mViewModel;

@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
}

@Override
public void initViewModel() {
mDataBinding = DataBindingUtil.setContentView(this, R.layout.activity_login);
ViewModelProvider.Factory factory = new LoginViewModelFactory(getApplication(), this);
mViewModel = ViewModelProviders.of(this, factory).get(LoginViewModel.class);
}

@Override
public void init(){
mLoading = new LoadingDialog.Builder(LoginActivity.this);
mLoading.setMessage(getString(R.string.login_loading_text));
mLoading.create();
}

@Override
public void bindUi(){
// 登录请求
RxView.clicks(mDataBinding.loginBtn)
.subscribeOn(AndroidSchedulers.mainThread())
.to(AutoDispose.autoDisposable(AndroidLifecycleScopeProvider.from(this)))
.subscribe(unit ->
PermissionTools.requestPermission(this, () -> // 校验读写权限
mViewModel.Login(mDataBinding.userNameEdt.getText().toString().trim() // 登录请求
, mDataBinding.passwordEdt.getText().toString().trim())
, Permission.READ_PHONE_STATE));
// 注册按钮
RxView.clicks(mDataBinding.registerImg)
.to(AutoDispose.autoDisposable(AndroidLifecycleScopeProvider.from(this)))
.subscribe(unit -> startActivity(new Intent(LoginActivity.this, RegisterActivity.class))); // 跳转注册页面
}

/**
* 不带粘性消息
*/
@Override
public void subscribeUi() {
// 页面状态变化通知 带粘性消息
mViewModel.getLoginState().observe(this, state -> {
switch (state) {
case ERROR_CUSTOMER_SUCCESS_PASS: // 通过校验
mLoading.getObj().show();
break;
case ERROR_CUSTOMER_PASSWORD_ERROR: // 账号错误
case ERROR_CUSTOMER_USERNAME_ERROR: // 密码错误
mDataBinding.passwordEdt.setText(""); // 清空密码输入框
ToastUtil.showToast(this, TCErrorConstants.getErrorInfo(state));
break;
}
});

// 登录信息返回通知
LiveEventBus.get(RequestTags.LOGIN_REQ, BaseResponBean.class)
.observe(this, bean -> {
Optional.ofNullable(mLoading).ifPresent(builder -> mLoading.getObj().dismiss()); // 取消 Loading
if (bean.getCode() == 200) { // 登录成功
ToastUtil.showToast(LoginActivity.this, "登录成功!");
startActivity(new Intent(LoginActivity.this, MainActivity.class));
finish();
} else { // 登录失败
ToastUtil.showToast(LoginActivity.this, "登录失败:" + TCErrorConstants.getErrorInfo(bean.getCode()));
mDataBinding.passwordEdt.setText(""); // 清空密码输入框
}
});
}

@Override
public void initRequest() {

}

@Override
protected void onDestroy() {
super.onDestroy();
Optional.ofNullable(mLoading).ifPresent(builder -> mLoading.getObj().dismiss()); // 取消 Loading
}

}

以上的登录View中包含几个模块



  1. initViewModel() 是为了保证MVVM的完整性,进行的VIewModel初始化

  2. init() 用于处理一些View中控件的初始化

  3. bindUi() 是通过RxView,将页面的事件转换成Observable,然后在于ViewModel中具体的功能进行绑定

  4. subscribeUi() 是例如ViewModel中LiveData的变化,或是通过LiveEventBus返回的通知引起的View变化

  5. initRequest() 用于处理刚进入View时就要请求的方法


public abstract class MVVMActivity extends AppCompatActivity {

public abstract void initViewModel();

public abstract void init();

public abstract void bindUi();

public abstract void subscribeUi();

/**
* 请求网络数据
*/
public abstract void initRequest();

@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
initViewModel();
init();
subscribeUi();
initRequest();
}

@Override
protected void onResume() {
super.onResume();
bindUi();
}
}

以上就是每个方法的调用顺序


ViewModel

public class LoginViewModel extends ViewModel {

private final LoginRepository repository;
private final LifecycleOwner lifecycleOwner;
private final MutableLiveData<Integer> loginState = new MutableLiveData<>(); // 登录失败

public LoginViewModel(LoginRepository repository,LifecycleOwner lifecycleOwner) {
this.repository = repository;
this.lifecycleOwner = lifecycleOwner;
}

/**
* 登录行为
*
* @param userName 账号
* @param passWord 密码
*/
public void Login(String userName, String passWord) {
if (checkInfo(userName, passWord)) {
loginState.postValue(ERROR_CUSTOMER_SUCCESS_PASS);
repository.loginReq(lifecycleOwner, userName, passWord);
}
}

/**
* 检测用户输入的账号密码是否合法
*
* @param userName 账号
* @param passWord 密码
* @return true:通过检测 false:未通过
*/
private boolean checkInfo(String userName, String passWord) {
if (!TCUtils.isUsernameVaild(userName)) {
loginState.postValue(ERROR_CUSTOMER_USERNAME_ERROR);
return false;
}
if (!TCUtils.isPasswordValid(passWord)) {
loginState.postValue(ERROR_CUSTOMER_PASSWORD_ERROR);
return false;
}
return true;
}

public LiveData<Integer> getLoginState() {
return loginState;
}
}

ViewModel作为连通View以及Model之间的通道,负责管理LiveData,以及一些业务上的逻辑,而View尽量通过LiveData的双向绑定实现UI的更新。


Model

这里时Model的代表 Repository


public class LoginRepository extends BaseRepository {

private final static String TAG = "LoginRepository";

private final static String PREFERENCE_USERID = "userid";
private final static String PREFERENCE_USERPWD = "userpwd";

/**
* 单例模式
*/
@SuppressLint("StaticFieldLeak")
private static volatile LoginRepository singleton = null;

/********************************** 本地数据缓存 **************************************/
private LoginResponBean mUserInfo = new LoginResponBean(); // 登录返回后 用户信息存在这
private final LoginSaveBean loginSaveBean = new LoginSaveBean(); // 用于保存用户登录信息
private TCUserMgr.CosInfo mCosInfo = new TCUserMgr.CosInfo(); // COS 存储的 sdkappid

private Context mContext; // 初始化一些组件需要使用

/**
* 初始化缓存数据
*/
private void initData() {
loadUserInfo(); // 是否有缓存账号数据
}

private void loadUserInfo() {
if (mContext == null) return;
TXLog.d(TAG, "xzb_process: load local user info");
SharedPreferences settings = mContext.getSharedPreferences("TCUserInfo", Context.MODE_PRIVATE);
loginSaveBean.setmUserId(settings.getString(PREFERENCE_USERID, ""));
loginSaveBean.setmUserPwd(settings.getString(PREFERENCE_USERPWD, ""));
}

private void saveUserInfo() {
if (mContext == null) return;
TXLog.d(TAG, "xzb_process: save local user info");
SharedPreferences settings = mContext.getSharedPreferences("TCUserInfo", Context.MODE_PRIVATE);
SharedPreferences.Editor editor = settings.edit();
editor.putString(PREFERENCE_USERID, loginSaveBean.getmUserId());
editor.putString(PREFERENCE_USERPWD, loginSaveBean.getmUserPwd());
editor.apply();
}

/**
* 登录请求
*
* @param userName 账号
* @param passWord 密码
*/
public void loginReq(LifecycleOwner lifecycleOwner, String userName, String passWord) {
LoginRequestBuilder.loginFlowable(userName, passWord)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.flatMap((Function<BaseResponBean<LoginResponBean>, Flowable<BaseResponBean<AccountInfoBean>>>) loginBean -> {
if (loginBean != null) { // 登录成功
Optional.ofNullable(loginBean.getData()).ifPresent(userInfo -> mUserInfo = userInfo); // 保存返回的数据
if (loginBean.getMessage() != null) {
LiveEventBus.get(RequestTags.LOGIN_REQ, BaseResponBean.class)
.post(new BaseResponBean<>(loginBean.getCode(), loginBean.getMessage())); // 页面要处理的逻辑(注册返回)
}
if (loginBean.getCode() == 200
&& loginBean.getData() != null
&& loginBean.getData().getToken() != null
&& loginBean.getData().getRoomservice_sign() != null
&& loginBean.getData().getRoomservice_sign().getUserID() != null) {
setToken(loginBean.getData().getToken()); // Token 保存到本地 用于后期请求鉴权
setUserId(loginBean.getData().getRoomservice_sign().getUserID());// UserId 保存到本地 当前登录的账号
initMLVB();// 初始化直播SDK
return LoginRequestBuilder.accountFlowable(getUserId(), getToken()); // 请求账户信息
} else {
return Flowable.error(new ApiException(loginBean.getCode(), loginBean.getMessage())); // 抛出登录异常 不会继续链式调用
}
}
return Flowable.error(new ApiException(-1, "网络异常")); // 抛出登录异常 不会继续链式调用
})
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.to(AutoDispose.autoDisposable(AndroidLifecycleScopeProvider.from(lifecycleOwner)))
.subscribe(new DisposableSubscriber<BaseResponBean<AccountInfoBean>>() {
@Override
public void onNext(BaseResponBean<AccountInfoBean> accountBean) {
if (accountBean != null && accountBean.getCode() == 200) { // 查询账户信息返回
if (accountBean.getData() != null) {
if (accountBean.getData().getAvatar() != null)
loginSaveBean.setmUserAvatar(accountBean.getData().getAvatar()); // 保存用户头像信息
if (accountBean.getData().getNickname() != null)
loginSaveBean.setmUserName(accountBean.getData().getNickname()); // 用户称呼
if (accountBean.getData().getFrontcover() != null)
loginSaveBean.setmCoverPic(accountBean.getData().getFrontcover());// 直播封面?
if (accountBean.getData().getSex() >= 0) {
loginSaveBean.setmSex(accountBean.getData().getSex());// 用户性别
}
}
}
}

@Override
public void onError(Throwable t) {
if (t instanceof ApiException) {
Log.e("TAG", "request error" + ((ApiException) t).getStatusDesc());
} else {
Log.e("TAG", "request error" + t.getMessage());
}
}

@Override
public void onComplete() {

}
});
}



/**
* 注册账号请求
*
* @param username 账户名
* @param password 密码
*/
public void registerReq(LifecycleOwner lifecycleOwner,String username, String password) {
LoginRequestBuilder.registerFlowable(username, password)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.to(AutoDispose.autoDisposable(AndroidLifecycleScopeProvider.from(lifecycleOwner)))
.subscribe(new DisposableSubscriber<BaseResponBean>() {
@Override
public void onNext(BaseResponBean registerBean) {
if (registerBean != null) {
LiveEventBus.get(RequestTags.REGISTER_REQ, BaseResponBean.class)
.post(new BaseResponBean<>(registerBean.getCode(), registerBean.getMessage())); // 页面要处理的逻辑(登录返回)
}
}

@Override
public void onError(Throwable t) {

}

@Override
public void onComplete() {

}
});
}


/**
* 初始化直播SDK
*/
public void initMLVB() {
// 校验数据完整性
if (mUserInfo == null || mContext == null
|| mUserInfo.getRoomservice_sign() == null
|| mUserInfo.getRoomservice_sign().getSdkAppID() == 0
|| mUserInfo.getRoomservice_sign().getUserID() == null
|| mUserInfo.getRoomservice_sign().getUserSig() == null) return;

LoginInfo loginInfo = new LoginInfo();
loginInfo.sdkAppID = mUserInfo.getRoomservice_sign().getSdkAppID();
loginInfo.userID = getUserId();
loginInfo.userSig = mUserInfo.getRoomservice_sign().getUserSig();

String userName = loginSaveBean.getmUserName();
loginInfo.userName = !TextUtils.isEmpty(userName) ? userName : getUserId();
loginInfo.userAvatar = loginSaveBean.getmUserAvatar();
MLVBLiveRoom liveRoom = MLVBLiveRoom.sharedInstance(mContext);
liveRoom.login(loginInfo, new IMLVBLiveRoomListener.LoginCallback() {
@Override
public void onError(int errCode, String errInfo) {
Log.i(TAG, "MLVB init onError: errorCode = " + errInfo + " info = " + errInfo);
}

@Override
public void onSuccess() {
Log.i(TAG, "MLVB init onSuccess: ");
}
});
}

/**
* 自动登录
*/
public void autoLogin() {

}

public void setmContext(Context context) {
this.mContext = context;
initData();
}

public LoginSaveBean getLoginInfo(){
return loginSaveBean;
}

public static LoginRepository getInstance() {
if (singleton == null) {
synchronized (LoginRepository.class) {
if (singleton == null) {
singleton = new LoginRepository();
}
}
}
return singleton;
}
}

除去里面复杂的业务逻辑,可以看到Repository的主要作用是数据仓库,如用单例形式保存一些业务上的数据(用户账户信息),负责处理请求中的业务逻辑,通过RxAndroid和Retrofit的组合,来完成一系列的请求,并通过LiveEventBus或是LiveData来通知页面


HttpRequest

网络请求模块


// LoginRequestBuilder.java
public static Flowable<BaseResponBean<LoginResponBean>> loginFlowable(String userName, String passWord) {
HashMap<String, String> requestParam = new HashMap<>();
requestParam.put("userid", userName);
requestParam.put("password", TCUtils.md5(TCUtils.md5(passWord) + userName));
return RetrofitTools.getInstance(LoginService.class) // 这里是很标准的Retrofit写法
.login(RequestBodyMaker.getRequestBodyForParams(requestParam));
}

// LoginService.java
@POST("/login")
Flowable<BaseResponBean<LoginResponBean>> login(@Body RequestBody requestBody);

// RetrofitTools.java
public static <T> T getInstance(final Class<T> service) {
if (okHttpClient == null) {
synchronized (RetrofitTools.class) {
HttpLoggingInterceptor interceptor = new HttpLoggingInterceptor(new HttpInteraptorLog());
interceptor.setLevel(HttpLoggingInterceptor.Level.BODY);
okHttpClient = new OkHttpClient.Builder()
.addInterceptor(interceptor)
.connectTimeout(5, TimeUnit.SECONDS)
.readTimeout(5, TimeUnit.SECONDS)
.writeTimeout(5, TimeUnit.SECONDS)
.build();
}
}

if (retrofit == null) {
synchronized (RetrofitTools.class) {
if(retrofit == null) {
retrofit = new Retrofit.Builder()
.baseUrl(TCGlobalConfig.APP_SVR_URL) //BaseUrl
.client(okHttpClient) //请求的网络框架
.addConverterFactory(GsonConverterFactory.create()) //解析数据格式
.addCallAdapterFactory(RxJava3CallAdapterFactory.create()) // 使用RxJava作为回调适配器
.build();
}
}
}
return retrofit.create(service);
}

网络请求返回的Flowable(背压)可以直接通过组合,链式的方式,组合成符合业务逻辑的结构


以上看上去十分简单的一个例子就是糅合了MVVM + RxAndroid + RxView + DataBinding + LiveData + LiveEventBus + Retrofit


一些复杂的列表页面,则加入了Bravh,来优Adapter代码量


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

Android Runtime (ART) 和 Dalvik 小知识,大挑战!

1. Dalvik Dalvik是Google公司自己设计用于Android平台的虚拟机。它可以支持已转换为 .dex(即Dalvik Executable)格式的Java应用程序的运行,.dex 格式是专为Dalvik设计的一种压缩格式,适合内存和处理器速度...
继续阅读 »

1. Dalvik


Dalvik是Google公司自己设计用于Android平台的虚拟机。它可以支持已转换为 .dex(即Dalvik Executable)格式的Java应用程序的运行,.dex 格式是专为Dalvik设计的一种压缩格式,适合内存和处理器速度有限的系统。Dalvik 经过优化,允许在有限的内存中同时运行多个虚拟机的实例,并且每一个Dalvik 应用作为一个独立的Linux 进程执行。独立的进程可以防止在虚拟机崩溃的时候所有程序都被关闭。


在 Android L (Android 5.0) 之前叫作 DVM,5.0 之后直接删除DVM,代替它的是传闻已久的ART(Android Runtime)


在整个 Android 操作系统体系中,ART 位于下图黄色小方块位置:



不是说被删除就无用了,咱们毕竟做这一行的还是要简单的了解一下。


1.1 Dalvik 和 JVM 区别




  • 1、Dalvik 基于寄存器,而 JVM 基于栈。




  • 2、基于寄存器的虚拟机对于更大的程序来讲,在它们编译的时候,花费的时间更短。




  • 3、JVM字节码中,局部变量会被放入局部变量表中,继而被压入堆栈供操做码进行运算,固然JVM也能够只使用堆栈而不显式地将局部变量存入变量表中。




  • 4、Dalvik字节码中,局部变量会被赋给65536个可用的寄存器中的任何一个,Dalvik指令直接操做这些寄存器,而不是访问堆栈中的元素。




1.2 Dalvik 如何运行 java




  • VM字节码由.class文件组成,每一个文件一个class。




  • JVM在运行的时候为每个类装载字节码。相反的,Dalvik程序只包含一个.dex文件,这个文件包含了程序中全部的类。




  • Java编译器建立了JVM字节码以后,Dalvik的dx(d8)编译器删除.class文件,从新把它们编译成Dalvik字节码,而后把它们写进一个.dex文件中。这个过程包括翻译、重构、解释程序的基本元素(常量池、类定义、数据段)。




  • 常量池描述了全部的常量,包括引用、方法名、数值常量等。类定义包括了访问标志、类名等基本信息。数据段中包含各类被VM执行的函数代码以及类和函数的相关信息(例如DVM所须要的寄存器数量、局部变量表、操做数堆栈大小),还有实例变量。




1.3 dex文件


class 文件是由一个 java 源码文件生成的 class 文件,而 Android 是把所有 class 文件进行合并优化,然后生成一个最终的 class.dex 文件。dex 文件去除了 class 文件中的冗余信息(比如重复字符常量),并且结构更加紧凑,因此在 dex 解析阶段,可以减少 I/O 操作,提高了类的查找速度。



实际上,dex 文件在 App 安装过程中还会被进一步优化为 odex(optimized dex),此过程还会在后续介绍安装过程时再次提到。




注意:这一优化过程也会伴随着一些副作用,最经典的就是 Android 65535 问题。



65535


65535 代表 dex 文件中的方法个数、属性个数、以及类的个数。也就是说理论上不止方法数,我们在 java 文件中声明的变量,或者创建的类个数如果也超过 65535 个,同样会编译失败,Android 提供了 MultiDex 来解决这个问题。很多网上的文章说 65535 问题是因为解析 dex 文件到数据结构 DexFile 时,使用了 short 来存储方法的个数,其实这种说法是错误的!


Android 65535问题解决


2. Android Runtime (ART)


Android Runtime (ART) 是运行 Android 5.0(API 级别 21)及更高版本的设备的默认运行时。此运行时提供大量功能,可提升 Android 平台和应用的性能和流畅度。


ART 是 Android 上的应用和部分系统服务使用的托管式运行时。ART 及其前身 Dalvik 最初是专为 Android 项目打造的。作为运行时的 ART 可执行 Dalvik 可执行文件并遵循 Dex 字节码规范。


ART 和 Dalvik 是运行 Dex 字节码的兼容运行时,因此针对 Dalvik 开发的应用也能在 ART 环境中运作。不过,Dalvik 采用的一些技术并不适用于 ART。


2.1 ART 功能


2.1.1 预先 (AOT) 编译


ART 引入了预先编译机制,可提高应用的性能。ART 还具有比 Dalvik 更严格的安装时验证。


在安装时,ART 使用设备自带的 dex2oat 工具来编译应用。此实用工具接受 DEX 文件作为输入,并为目标设备生成经过编译的应用可执行文件。该工具应能够顺利编译所有有效的 DEX 文件。但是,一些后处理工具会生成无效文件,Dalvik 可以接受这些文件,但 ART 无法编译这些文件。


2.1.2 垃圾回收方面的优化


垃圾回收 (GC) 会耗费大量资源,这可能有损于应用性能,导致显示不稳定、界面响应速度缓慢以及其他问题。ART 通过以下几种方式对垃圾回收做了优化:



  • 大多采用并发设计,具有一次 GC 暂停;

  • 并发复制,可减少后台内存使用和碎片;

  • GC 暂停的时间不受堆大小影响;

  • 在清理最近分配的短时对象这种特殊情况中,回收器的总 GC 时间更短;

  • 优化了垃圾回收的工效,能够更加及时地进行并行垃圾回收,这使得 GC_FOR_ALLOC 事件在典型用例中极为罕见。


2.1.3 开发和调试方面的优化


ART 提供了大量功能来优化应用开发和调试。


2.1.3.1 支持采样分析器


一直以来,开发者都使用 Traceview 工具(用于跟踪应用执行情况)作为分析器。虽然 Traceview 可提供有用的信息,但每次方法调用产生的开销会导致 Dalvik 分析结果出现偏差,而且使用该工具明显会影响运行时性能。


ART 添加了对没有这些限制的专用采样分析器的支持,因而可更准确地了解应用执行情况,而不会明显减慢速度。KitKat 版本为 Dalvik 的 Traceview 添加了采样支持。



Traceview:Traceview 已弃用。如果您使用的是 Android Studio 3.2 或更高版本,应改为使用 CPU 性能剖析器 来执行以下操作:检查通过使用 Debug 类检测应用而捕获的 .trace 文件,记录新方法跟踪记录,保存 .trace 文件,以及检查应用进程的实时 CPU 使用情况。



2.1.3.2 支持更多调试功能


ART 支持许多新的调试选项,特别是与监控和垃圾回收相关的功能。例如,您可以:



  • 查看堆栈跟踪中保留了哪些锁,然后跳转到持有锁的线程。

  • 询问指定类的当前活动的实例数、请求查看实例,以及查看使对象保持有效状态的参考。

  • 过滤特定实例的事件(如断点)。

  • 查看方法退出(使用“method-exit”事件)时返回的值。

  • 设置字段观察点,以在访问和/或修改特定字段时暂停程序执行。


2.1.3.3 优化了异常和崩溃报告中的诊断详细信息


当发生运行时异常时,ART 会为您提供尽可能多的上下文和详细信息。ART 会提供 java.lang.ClassCastExceptionjava.lang.ClassNotFoundExceptionjava.lang.NullPointerException 的更多异常详细信息。(较高版本的 Dalvik 会提供 java.lang.ArrayIndexOutOfBoundsExceptionjava.lang.ArrayStoreException 的更多异常详细信息,这些信息现在包括数组大小和越界偏移量;ART 也提供这类信息。)


ART 还通过纳入 Java 和原生堆栈信息,在应用原生代码崩溃报告中提供更实用的上下文信息。


2.2 Android 8.0 中的 ART 功能改进


在 Android 8.0 版本中,Android Runtime (ART) 有了极大改进。下面的列表总结了设备制造商可以在 ART 中获得的增强功能。


2.2.1 并发压缩式垃圾回收器


正如 Google 在 Google I/O 大会上所宣布的那样,ART 在 Android 8.0 中提供了新的并发压缩式垃圾回收器 (GC)。该回收器会在每次执行 GC 时以及应用正在运行时对堆进行压缩,且仅在处理线程根时短暂停顿一次。该回收器具有以下优势:



  • GC 始终会对堆进行压缩:堆的大小平均比 Android 7.0 中的小 32%。

  • 得益于压缩,系统现可实现线程局部碰撞指针对象分配:分配速度比 Android 7.0 中的快 70%。

  • H2 基准的停顿次数比 Android 7.0 GC 的少 85%。

  • 停顿次数不再随堆的大小而变化,应用在使用较大的堆时也无需担心造成卡顿。

  • GC 实现细节 - 读取屏障:

    • 读取屏障是在读取每个对象字段时所做的少量工作。

    • 它们在编译器中经过了优化,但可能会减慢某些用例的速度。




2.2.2 循环优化


在 Android 8.0 版本中,ART 采取了多种循环优化措施,具体如下:



  • 消除边界检查

    • 静态:在编译时证明范围位于边界内

    • 动态:运行时测试确保循环始终位于边界内(否则不进行优化)



  • 消除归纳变量

    • 移除无用归纳

    • 用封闭式表达式替换仅在循环后使用的归纳



  • 消除循环主体内的无用代码,移除整个死循环

  • 强度降低

  • 循环转换:逆转、交换、拆分、展开、单模等

  • SIMDization(也称为矢量化)


循环优化器位于 ART 编译器中一个独立的优化环节中。大多数循环优化与其他方面的优化和简化类似。采用比平时更复杂的方式进行一些重写 CFG 的优化时会面临挑战,因为大多数 CFG 实用工具(请参阅 nodes.h)都侧重于构建而不是重写 CFG。


2.2.3 类层次结构分析


在 Android 8.0 中,ART 会使用类层次结构分析 (CHA),这是一种编译器优化,可根据对类层次结构的分析结果,将虚拟调用去虚拟化为直接调用。虚拟调用代价高昂,因为它们围绕 vtable 查找来实现,且会占用几个依赖负载。另外,虚拟调用也不能内嵌。


以下是对相关增强功能的总结:



  • 动态单一实现方法状态更新 - 在类关联时间结束时,如果 vtable 已被填充,ART 会按条目对超类的 vtable 进行比较。

  • 编译器优化 - 编译器会利用某种方法的单一实现信息。如果方法 A.foo 设置了单一实现标记,则编译器会将虚拟调用去虚拟化为直接调用,并借此进一步尝试内嵌直接调用。

  • 已编译代码无效 - 另外,在类关联时间结束时,如果单一实现信息已更新,且方法 A.foo 之前拥有单一实现,但该状态现已变为无效,则依赖方法 A.foo 拥有单一实现这一假设的所有已编译代码都需要变为无效代码。

  • 去优化 - 对于堆栈上已编译的有效代码,系统会启动去优化功能,以强制使已编译无效代码进入解释器模式,从而确保正确性。系统会采用结合了同步和异步去优化的全新去优化机制。


2.2.4 .oat 文件中的内嵌缓存


ART 现在采用内嵌缓存,并对有足够数据可用的调用站点进行优化。内嵌缓存功能会将额外的运行时信息记录到配置文件中,并利用这类信息将动态优化添加到预先编译中。


2.2.5 Dexlayout


Dexlayout 是在 Android 8.0 中引入的一个库,用于分析 dex 文件,并根据配置文件对其进行重新排序。Dexlayout 旨在使用运行时配置信息,在设备的空闲维护编译期间对 dex 文件的各个部分进行重新排序。通过将经常一起访问的部分 dex 文件集中在一起,程序可以因改进文件位置而拥有更好的内存访问模式,从而节省 RAM 并缩短启动时间。


由于配置文件信息目前仅在运行应用后可用,因此系统会在空闲维护期间将 dexlayout 集成到 dex2oat 的设备编译中。


2.2.6 Dex 缓存移除


在 Android 7.0 及更低版本中,DexCache 对象拥有四个大型数组,与 DexFile 中特定元素的数量成正比,即:



  • 字符串(每个 DexFile::StringId 一个引用),

  • 类型(每个 DexFile::TypeId 一个引用),

  • 方法(每个 DexFile::MethodId 一个原生指针),

  • 字段(每个 DexFile::FieldId 一个原生指针)。


这些数组用于快速检索我们以前解析的对象。在 Android 8.0 中,除方法数组外,所有数组都已移除。


2.2.7 解释器性能


在 Android 7.0 版本中,通过引入 mterp(一种解释器,具有以汇编语言编写的核心提取/解码/解释机制),解释器性能得以显著提升。Mterp 模仿了快速 Dalvik 解释器,并支持 arm、arm64、x86、x86_64、mips 和 mips64。对于计算代码而言,ART 的 Mterp 大致相当于 Dalvik 的快速解释器。不过,有时候,它的速度可能会显著变慢,甚至急剧变慢:



  • 调用性能。

  • 字符串操作和 Dalvik 中其他被视为内嵌函数的高频用户方法。

  • 堆栈内存使用量较高。


Android 8.0 解决了这些问题。


2.2.8 详细了解内嵌


从 Android 6.0 开始,ART 可以内嵌同一个 dex 文件中的任何调用,但只能内嵌来自其他 dex 文件的叶方法。此项限制具有以下两个原因:



  • 从其他 dex 文件进行内嵌要求使用该 dex 文件的 dex 缓存,这与同一个 dex 文件内嵌(只需重复使用调用方的 dex 缓存)有所不同。已编译代码中需要具有 dex 缓存,以便执行一系列指令,例如静态调用、字符串加载或类加载。

  • 堆栈映射只对当前 dex 文件中的方法索引进行编码。


为了应对这些限制,Android 8.0 做出了以下改进:



  • 从已编译代码中移除 dex 缓存访问(另请参阅“Dex 缓存移除”部分)

  • 扩展堆栈映射编码。


2.2.9 同步方面的改进


ART 团队调整了 MonitorEnter/MonitorExit 代码路径,并减少了我们对 ARMv8 上传统内存屏障的依赖,尽可能将其替换为较新的(获取/释放)指令。


2.2.10 更快速的原生方法


使用 @FastNative@CriticalNative 注解可以更快速地对 Java 原生接口 (JNI) 进行原生调用。这些内置的 ART 运行时优化可以加快 JNI 转换速度,并取代了现已弃用的 !bang JNI 标记。这些注解对非原生方法没有任何影响,并且仅适用于 bootclasspath 上的平台 Java 语言代码(无 Play 商店更新)。


@FastNative 注解支持非静态方法。如果某种方法将 jobject 作为参数或返回值进行访问,请使用此注解。


利用 @CriticalNative 注解,可更快速地运行原生方法,但存在以下限制:



  • 方法必须是静态方法 - 没有参数、返回值或隐式 this 的对象。

  • 仅将基元类型传递给原生方法。

  • 原生方法在其函数定义中不使用 JNIEnv 和 jclass 参数。

  • 方法必须使用 RegisterNatives 进行注册,而不是依靠动态 JNI 链接。



@FastNative 和 @CriticalNative 注解在执行原生方法时会停用垃圾回收。不要与长时间运行的方法一起使用,包括通常很快但一般不受限制的方法。




停顿垃圾回收可能会导致死锁。如果锁尚未得到本地释放(即尚未返回受管理代码),请勿在原生快速调用期间获取锁。此要求不适用于常规的 JNI 调用,因为 ART 将正执行的原生代码视为已暂停的状态。




@FastNative 可以使原生方法的性能提升高达 3 倍,而 @CriticalNative 可以使原生方法的性能提升高达 5 倍。



更多详情:官方:Android Runtime (ART) 和 Dalvik


3. 内存管理


Android Runtime (ART) 和 Dalvik 虚拟机使用分页和内存映射来管理内存。这意味着应用修改的任何内存,无论修改的方式是分配新对象还是轻触内存映射的页面,都会一直驻留在 RAM 中,并且无法换出。要从应用中释放内存,只能释放应用保留的对象引用,使内存可供垃圾回收器回收。这种情况有一个例外:对于任何未经修改的内存映射文件(如代码),如果系统想要在其他位置使用其内存,可将其从 RAM 中换出。


3.1 ART GC 概览


ART 有多个不同的 GC 方案,涉及运行不同的垃圾回收器。从 Android 8 (Oreo) 开始,默认方案是并发复制 (CC)。另一个 GC 方案是并发标记清除 (CMS)


并发复制 GC 的一些主要特性包括:



  • CC 支持使用名为“RegionTLAB”的触碰指针分配器。此分配器可以向每个应用线程分配一个线程本地分配缓冲区 (TLAB),这样,应用线程只需触碰“栈顶”指针,而无需任何同步操作,即可从其 TLAB 中将对象分配出去。

  • CC 通过在不暂停应用线程的情况下并发复制对象来执行堆碎片整理。这是在读取屏障的帮助下实现的,读取屏障会拦截来自堆的引用读取,无需应用开发者进行任何干预。

  • GC 只有一次很短的暂停,对于堆大小而言,该次暂停在时间上是一个常量。

  • 在 Android 10 及更高版本中,CC 会扩展为分代 GC。它支持轻松回收存留期较短的对象,这类对象通常很快便会无法访问。这有助于提高 GC 吞吐量,并显著延迟执行全堆 GC 的需要。


ART 仍然支持的另一个 GC 方案是 CMS。此 GC 方案还支持压缩,但不是以并发方式。在应用进入后台之前,它会避免执行压缩,应用进入后台后,它会暂停应用线程以执行压缩。如果对象分配因碎片而失败,也必须执行压缩操作。在这种情况下,应用可能会在一段时间内没有响应。


由于 CMS 很少进行压缩,因此空闲对象可能会不连续。CMS 使用一个名为 RosAlloc 的基于空闲列表的分配器。与 RegionTLAB 相比,该分配器的分配成本较高。最后,由于内部碎片,Java 堆的 CMS 内存用量可能会高于 CC 内存用量。


CMS具体内容可参考:Java 垃圾回收(GC)


3.2 垃圾回收


ART 或 Dalvik 虚拟机之类的受管内存环境会跟踪每次内存分配。一旦确定程序不再使用某块内存,它就会将该内存重新释放到堆中,无需程序员进行任何干预。这种回收受管内存环境中的未使用内存的机制称为"垃圾回收"。垃圾回收有两个目标:



  • 在程序中查找将来无法访问的数对象

  • 回收这些对象使用的资源。


Android 的内存堆是分代的,这意味着它会根据分配对象的预期寿命和大小跟踪不同的分配存储分区。


可参考:Java 垃圾回收(GC)


3.3 共享内存


为了在 RAM 中容纳所需的一切,Android 会尝试跨进程共享 RAM 页面。它可以通过以下方式实现这一点:



  • 每个应用进程都从一个名为 Zygote 的现有进程 fork。可参考:源码解读-应用是如何启动的

  • 大多数静态数据会内存映射到一个进程中。这种方法使得数据不仅可以在进程之间共享,还可以在需要时换出。静态数据示例包括:Dalvik 代码、应用资源和 lib 中的文件。

  • Android 使用明确分配的共享内存区域(通过 ashmem 或 gralloc)在进程间共享同一动态 RAM。例如,窗口 surface 使用在应用和屏幕合成器之间共享的内存,而光标缓冲区则使用在内容提供器和客户端之间共享的内存。


由于共享内存的广泛使用,在确定应用使用的内存量时需要小心谨慎。


3.4 分配与回收应用内存


Dalvik 堆局限于每个应用进程的单个虚拟内存范围。这定义了逻辑堆大小,该大小可以根据需要增长,但不能超过系统为每个应用定义的上限


堆的逻辑大小与堆使用的物理内存量不同。在检查应用堆时,Android 会计算按比例分摊的内存大小 (PSS) 值,该值同时考虑与其他进程共享的脏页和干净页,但其数量与共享该 RAM 的应用数量成正比。此 (PSS) 总量是系统认为的物理内存占用量。


Dalvik 堆不压缩堆的逻辑大小,这意味着 Android 不会对堆进行碎片整理来缩减空间。只有当堆末尾存在未使用的空间时,Android 才能缩减逻辑堆大小。但是,系统仍然可以减少堆使用的物理内存。垃圾回收之后,Dalvik 遍历堆并查找未使用的页面,然后使用 madvise 将这些页面返回给内核。因此,大数据块的配对分配和解除分配应该使所有(或几乎所有)使用的物理内存被回收。但是,从较小分配量中回收内存的效率要低得多,因为用于较小分配量的页面可能仍在与其他尚未释放的数据块共享。


3.5 限制应用内存


为了维持多任务环境的正常运行,Android 会为每个应用的堆大小设置硬性上限。不同设备的确切堆大小上限取决于设备的总体可用 RAM 大小。如果您的应用在达到堆容量上限后尝试分配更多内存,则可能会收到 OutOfMemoryError


在某些情况下,你可以通过调用 getMemoryClass() 向系统查询此确切可用的堆空间大小。


3.6 切换应用


当用户在应用之间切换时,Android 会将非前台应用保留在缓存中。非前台应用就是指用户看不到或未运行前台服务(如音乐播放)的应用。


例如,当用户首次启动某个应用时,系统会为其创建一个进程;但是当用户离开此应用时,该进程不会退出。系统会将该进程保留在缓存中。如果用户稍后返回该应用,系统就会重复使用该进程,从而加快应用切换速度。


如果你的应用具有缓存的进程且保留了目前不需要的资源,那么即使用户未使用您的应用,它也会影响系统的整体性能。当系统资源(如内存)不足时,它将会终止缓存中的进程。系统还会考虑终止占用最多内存的进程以释放 RAM。


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

优雅的使用注释

代码千万行,注释第一行。 代码不规范,同事泪两行。 前言 注释相信小伙伴们都不陌生,但是就是这个小小的注释就像项目文档一样让许多小伙伴又爱又恨。不喜欢写注释,又讨厌别人不写注释。在此我们将讨论 JavaScript 和 CSS 的注释,希望通过这篇文章,让你...
继续阅读 »

代码千万行,注释第一行。

代码不规范,同事泪两行。



前言


注释相信小伙伴们都不陌生,但是就是这个小小的注释就像项目文档一样让许多小伙伴又爱又恨。不喜欢写注释,又讨厌别人不写注释。在此我们将讨论 JavaScriptCSS 的注释,希望通过这篇文章,让你重拾对注释的喜爱,让编码的乐趣如星辰大海。


一、语法


1.1 CSS 注释


/* css 注释 */

1.2 JavaScript 注释


// 单行注释

/**
* 多行注释,注意第一行最好用两个 *
* ...
*/

/*
当然,除了两端的 * 必须加以外,其他的 * 不加也行
...
*/


二、基本使用


2.1 单行注释


一般情况下,单行注释会出现在代码的正上方,起到提示的作用:


/* 用注释备注 CSS 类名的功能 */

/* 顶部组件 */
.hd {
position: fixed;
width: 100vw;
}

/* 版心 */
.container {
margin: 16px auto;
width: 1200px;
}

// 用单行注释备注简单的信息

const userName = ""; // 用户名
const userAvatar = ""; // 用户头像

// xxx函数
const myFunction = () => {};

2.2 多行注释


多行注释一般用于需要备注的信息过多的情况,常常出没于 JavaScript 函数的附近。首先提出一个问题:为什么要用到多行注释,用单行注释不香吗?下面就来看看下面的代码:


// xxx函数
const myFunction = ({ id, name, avatar, list, type }) => {
// 此处省略 30 行代码
};

小伙伴们可能看到了,一个传入五个参数,内部数行代码的函数竟然只有短短的一行注释,也许你开发的时候能记住这个函数的用途以及参数的类型以及是否必传等,但是如果你隔了一段时间再回头看之前的代码,那么简短的注释就可能变成你的困扰。 更不用说没有注释,不写注释一时爽,回看代码火葬场。 写注释的目的在于提高代码的可读性。相比之下,下面的注释就清晰的多:


/**
* 调整滚动距离
* 用于显示给定 id 元素
* @param id string 必传 元素 id
* @param distance number 非必传 距离视口最顶部距离(避免被顶部固定定位元素遮挡)
* @returns null
*/
export const scrollToShowElement = (id = "", distance = 0) => {
return () => {
if (!id) {
return;
};

const element = document.getElementById(id);
if (!element) {
return;
};

const top = element?.offsetTop || 0;
window.scroll(0, top - distance);
};
};

对于复杂的函数,函数声明上面要加上统一格式的多行注释,同时内部的复杂逻辑和重要变量也需要加上单行注释,两者相互配合,相辅相成。函数声明的多行注释格式一般为:


/**
* 函数名称
* 函数简介
* @param 参数1 参数1数据类型 是否必传 参数1描述
* @param 参数2 参数2数据类型 是否必传 参数2描述
* @param ...
* @returns 返回值
*/

多行注释的优点是清晰明了,缺点是较为繁琐(可以借助编辑器生成 JavaScript 函数注释模板)。建议逻辑简单的函数使用单行注释,逻辑复杂的函数和公共/工具函数使用多行注释。


当然,一个好的变量/函数名也能降低阅读者的思考成本,可以移步到我的文章:《优雅的命名 🧊🧊》


三、进阶使用


无论是 css 还是 JavaScript 中,当代码越来越多的时候,也使得寻找要改动的代码时变得越来越麻烦。所以我们有必要对代码按模块进行整理,并在每个模块的顶部用注释,结束时使用空行进行分割。


 /* 以下代码仅为示例 */

/* 模块1 */
/* 类名1 */
.class-a {}

/* 类名2 */
.class-b {}

/* 类名3 */
.class-c {}

/* 模块2 */
/* 类名4 */
.class-d {}

/* 类名5 */
.class-e {}

/* ... */

// 以下代码仅为示例

// 模块1
// 变量1
const value1 = "";
// 变量2
const value2 = "";
// 变量3
const value3 = "";
// 函数1
const myFunction1 = () => {};

// 模块2
// 变量4
const value4 = "";
// 变量5
const value5 = "";
// 变量6
const value6 = "";
// 函数2
const myFunction2 = () => {};

// ...

效果有了,但是似乎不太明显,因此我们在注释中增加 - 或者 = 来进行分割试试:


 /* ------------------------ 模块1 ------------------------ */
/* 类名1 */
.class-a {}

/* 类名2 */
.class-b {}

/* 类名3 */
.class-c {}

/* ------------------------ 模块2 ------------------------ */
/* 类名4 */
.class-d {}

/* 类名5 */
.class-e {}

/* ... */

// 以下代码仅为示例

/* ======================== 模块1 ======================== */
// 变量1
const value1 = "";
// 变量2
const value2 = "";
// 变量3
const value3 = "";
// 函数1
const myFunction1 = () => {};

/* ======================== 模块2 ======================== */
// 变量4
const value4 = "";
// 变量5
const value5 = "";
// 变量6
const value6 = "";
// 函数2
const myFunction2 = () => {};

// ...

能直观的看出,加长版的注释分割效果更好,区分度更高。高质量的代码往往需要最朴实无华的注释进行分割。其中 JavaScript 的注释“分割线”建议使用多行注释。




“华丽的”分割线:


 /* ------------------------ 华丽的分割线 ------------------------ */

/* ======================== 华丽的分割线 ======================== */

四、扩展


工欲善其事,必先利其器。下面我要推荐几款 VSCode 编辑器关于注释的插件。


4.1 Better Comments


Better Comments.png


插件介绍:可以改变注释的颜色,有四种高亮的颜色(默认为红色、橙色、绿色、蓝色)和一种带删除线的黑色。颜色可以在插件配置里面修改。下图为实例颜色和本人在项目中的用法,一个注释对应一种情况。


注释的默认颜色.png


喜欢花里胡哨的coder们必备插件,有效提高注释的辨析度和美感,从此爱上注释。其改变注释颜色只需要加上一个或多个字符即可,开箱即用。


// ! 红色的高亮注释,双斜线后加英文叹号     !     配置
// todo 橙色的高亮注释,双斜线后加 todo 函数
// * 绿色的高亮注释,双斜线后加 * 变量
// ? 蓝色的高亮注释,双斜线后加英文问号 ? 组件
// // 黑色带删除线的注释,双斜线后加双斜线 // 说明

4.2 koroFileHeader


koroFileHeader.png


插件介绍:文件头部添加注释,在光标处添加函数注释,一键添加佛祖保佑永无BUG、神兽护体等注释图案。


koroFileHeader 说明.png


4.3 JavaScript Comment Snippet


JavaScript Comment Snippet.png


插件介绍:可以快速生成 JavaScript 注释,冷门但是好用。


JavaScript Comment Snippet 使用.gif
JavaScript Comment Snippet 使用.png
JavaScript Comment Snippet 使用.png


结语


不得不说注释在编码过程中真的相当重要,为了写出更优雅,更易于维护的代码,我们也应当把最重要的信息写到注释里。一个项目的 README.markdown 和项目中的注释就喜像是项目的 说明书 一样,能让非项目开发者更快的读懂代码的含义以及编码的思想。让代码成就我们,让代码改变世界,让注释,伴我同行!



收起阅读 »

技术总结 | 前端萌新现在上车Docker,还来得及么?

序言 作为一名爱学习的前端攻城狮,在当下疯狂内卷的大环境🐱, 不卷一卷Docker是不是有点说不过去,再加上现在我司前端部署项目大部分都是Docker,所以现在赶紧上车, 跟着Up主来look look,欢迎有big old指正 Q:你能说一下你怎么看待Do...
继续阅读 »

序言


作为一名爱学习的前端攻城狮,在当下疯狂内卷的大环境🐱, 不卷一卷Docker是不是有点说不过去,再加上现在我司前端部署项目大部分都是Docker,所以现在赶紧上车, 跟着Up主来look look,欢迎有big old指正



  • Q:你能说一下你怎么看待DockerDocker能干什么么

  • A:Docker是一个便携的应用容器, 用来自动化测试和持续集成、发布


大家在面试的时候是不是这么回答的😂,恭喜你答对了,但是不够完整,现在来结合文档和Demo具体看看,Docker到底能干啥


概念


什么是Docker


Docker就好比是一个集装箱,里面装着各式各类的货物。在一艘大船上,可以把货物规整的摆放起来。并且各种各样的货物被集装箱标准化了,集装箱和集装箱之间不会互相影响。


有人觉得Docker是一台虚拟机,但是这种想法是错误的,直接上图


8931c4f83956f72f924f9c30aee3c40.png



上图差异,左图虚拟机的Guest OS层和Hypervisor层在Docker中被Docker Engine层所替代。虚拟机的Guest OS即为虚拟机安装的操作系统,它是一个完整操作系统内核;虚拟机的Hypervisor层可以简单理解为一个硬件虚拟化平台,它在Host OS是以内核态的驱动存在的。



三大核心概念


镜像(image)


镜像是创建docker容器的基础,docker镜像类似于虚拟机镜像,可以将它理解为一个面向docker引擎的只读模块,包含文件系统


创建镜像的方式



  1. 使用Dockerfile Build镜像

  2. 拉取Docker官方镜像


容器(container)


容器是从镜像创建的应用运行实例,容器之间是相互隔离、互不可见的。可以把容器看做一个简易版的linux系统环境(包括root权限、进程空间、用户空间和网络空间等),以及运行在这个环境上的应用打包而成的应用盒子。


可以利用docker create命令创建一个容器,创建后的的容器处于停止状态,可以使用docker start命令来启动它。也可以运行docker run命令来直接从镜像启动运行一个容器。docker run = docker creat + docker start


当利用docker run创建并启动一个容器时,docker在后台的标准操作包括:


(1)检查本地是否存在指定的镜像,不存在就从公有仓库下载。

(2)利用镜像创建并启动一个容器。

(3)分配一个文件系统,并在只读的镜像层外面挂载一层可读写层。

(4)从宿主机配置的网桥接口中桥接一个虚拟的接口到容器中。

(5)从地址池中配置一个IP地址给容器。

(6)执行用户指定的应用程序。

(7)执行完毕后容器终止。


仓库(Repository)


安装Docker后,可用通过官方提供的registry镜像来搭建一套本地私有仓库环境。


下载registry镜像:


6246e8b8ff24b58a0bdf74ab6de1e30.png


基础操作


安装Docker


linux安装Docker


8d75417f1599a5e7716278780346a8e.png


windows安装docker


推荐安装Docker Desktop 飞机票


image.png


拉取镜像


# 拉取镜像
>>> docker pull ubuntu
Using default tag: latest
latest: Pulling from library/ubuntu
f3ef4ff62e0d: Pull complete
Digest: sha256:a0d9e826ab87bd665cfc640598a871b748b4b70a01a4f3d174d4fb02adad07a9
Status: Downloaded newer image for ubuntu:latest
docker.io/library/ubuntu:latest

# 查看本地所有镜像
>>> docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
ubuntu latest 597ce1600cf4 13 days ago 72.8MB
hello latest 8b9d88b05a48 2 weeks ago 231MB
centos latest 5d0da3dc9764 4 weeks ago 231MB
docker/getting-started latest 083d7564d904 4 months ago 28MB

# 删除镜像
>>> docker rmi ubuntu
Untagged: ubuntu:latest
Untagged: ubuntu@sha256:a0d9e826ab87bd665cfc640598a871b748b4b70a01a4f3d174d4fb02adad07a9
Deleted: sha256:597ce1600cf4ac5f449b66e75e840657bb53864434d6bd82f00b172544c32ee2
Deleted: sha256:da55b45d310bb8096103c29ff01038a6d6af74e14e3b67d1cd488c3ab03f5f0d


创建容器


#创建容器
>>> docker create --name my-ubuntu ubuntu
2da5d12e9cbaed77d90d23f5f5436215ec511e20607833a5a674109c13b58f48

#启动容器
>>> docker start 2da5d

#查看所有容器
>>> docker ps -a
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
2da5d12e9cba ubuntu "bash" About a minute ago Exited (0) 31 seconds ago my-ubuntu

#删除容器
>>> docker rm 2da5d

#创建并进入容器
>>> docker run --name my-ubuntu2 -it ubuntu
root@552c7c73dcf6:/#
#进入容器后就可以在容器内部执行脚本了

# 进入正在运行的容器
>>> docker exec -it 2703b1 sh
/ #


编排Dockerfile


Dockerfile是一个创建镜像所有命令的文本文件, 包含了一条条指令和说明, 每条指令构建一层, 通过docker build命令,根据Dockerfile的内容构建镜像,因此每一条指令的内容, 就是描述该层如何构建.有了Dockefile, 就可以制定自己的docker镜像规则,只需要在Dockerfile上添加或者修改指令, 就可生成docker镜像.


FROM ubuntu          #构造的新镜像是基于哪个镜像
MAINTAINER Up_zhu #维护者信息
RUN yum install nodejs #构建镜像时运行的shell命令
WORKDIR /app/my-app #设置工作路径
EXPOSE 8080 #指定于外界交互的端口,即容器在运行时监听的端口
ENV MYSQL_ROOT_PASSWORD 123456 #设置容器内环境变量
ADD ./config /app/config #拷贝文件或者目录到镜像,如果是URL或者压缩包会自动下载或者自动解压
COPY ./dist /app/my-app
VOLUME /etc/mysql #定义匿名卷

实战



基于vite项目打镜像,发布



新建Dockerfile


FROM nginx
COPY ./dist/ /usr/share/nginx/html/
COPY ./nginx/default.conf /etc/nginx/conf.d/default.conf

新建nginx配置文件


# nginx/default.conf
server {
listen 80;
server_name localhost;

#charset koi8-r;
access_log /var/log/nginx/host.access.log main;
error_log /var/log/nginx/error.log error;

location / {
root /usr/share/nginx/html;
index index.html index.htm;
}

#error_page 404 /404.html;

# redirect server error pages to the static page /50x.html
#
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}


打镜像


image.png


查看本地镜像


>>> docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
my-vite latest cc015756264b About a minute ago 133MB

启动容器


image.png


现在可以访问地址来验证是否成功


image.png


查看本地正在运行的容器


image.png


文末


是不是很Easy呢?我们从上面可以看出,Docker 的功能是十分强大的,除此之外,我们还可以拉取一些 UbuntuApache 等镜像, 也可以自己定制一下镜像,发布到Docker Hub.


image.png


当然!本文介绍的只是Docker的基础功能,小编能力到此,还需继续学习~



收起阅读 »

实现无感刷新token,我是这样做的

前言 最近在做需求的时候,涉及到登录token,产品提出一个问题:能不能让token过期时间长一点,我频繁的要去登录。 前端:后端,你能不能把token 过期时间设置的长一点。 后端:可以,但是那样做不安全,你可以用更好的方法。 前端:什么方法? 后端:给你...
继续阅读 »

前言



最近在做需求的时候,涉及到登录token,产品提出一个问题:能不能让token过期时间长一点,我频繁的要去登录。


前端:后端,你能不能把token 过期时间设置的长一点。


后端:可以,但是那样做不安全,你可以用更好的方法。


前端:什么方法?


后端:给你刷新token的接口,定时去刷新token


前端:好,让我思考一下



需求



当token过期的时候,刷新token,前端需要做到无感刷新token,即刷token时要做到用户无感知,避免频繁登录。实现思路


方法一


后端返回过期时间,前端判断token过期时间,去调用刷新token接口


缺点:需要后端额外提供一个token过期时间的字段;使用了本地时间判断,若本地时间被篡改,特别是本地时间比服务器时间慢时,拦截会失败。


方法二


写个定时器,定时刷新token接口


缺点:浪费资源,消耗性能,不建议采用。


方法三


在响应拦截器中拦截,判断token 返回过期后,调用刷新token接口



实现



axios的基本骨架,利用service.interceptors.response进行拦截


import axios from 'axios'

service.interceptors.response.use(
  response => {
    if (response.data.code === 409) {
        return refreshToken({ refreshToken: localStorage.getItem('refreshToken'), token: getToken() }).then(res => {
          const { token } = res.data
          setToken(token)
          response.headers.Authorization = `${token}`
        }).catch(err => {
          removeToken()
          router.push('/login')
          return Promise.reject(err)
        })
    }
    return response && response.data
  },
  (error) => {
    Message.error(error.response.data.msg)
    return Promise.reject(error)
  })



问题解决



问题一:如何防止多次刷新token


我们通过一个变量isRefreshing 去控制是否在刷新token的状态。


import axios from 'axios'

service.interceptors.response.use(
  response => {
    if (response.data.code === 409) {
      if (!isRefreshing) {
        isRefreshing = true
        return refreshToken({ refreshToken: localStorage.getItem('refreshToken'), token: getToken() }).then(res => {
          const { token } = res.data
          setToken(token)
          response.headers.Authorization = `${token}`
        }).catch(err => {
          removeToken()
          router.push('/login')
          return Promise.reject(err)
        }).finally(() => {
          isRefreshing = false
        })
      }
    }
    return response && response.data
  },
  (error) => {
    Message.error(error.response.data.msg)
    return Promise.reject(error)
  })

问题二:同时发起两个或者两个以上的请求时,其他接口怎么解决


当第二个过期的请求进来,token正在刷新,我们先将这个请求存到一个数组队列中,想办法让这个请求处于等待中,一直等到刷新token后再逐个重试清空请求队列。那么如何做到让这个请求处于等待中呢?为了解决这个问题,我们得借助Promise。将请求存进队列中后,同时返回一个Promise,让这个Promise一直处于Pending状态(即不调用resolve),此时这个请求就会一直等啊等,只要我们不执行resolve,这个请求就会一直在等待。当刷新请求的接口返回来后,我们再调用resolve,逐个重试。最终代码:


import axios from 'axios'

// 是否正在刷新的标记
let isRefreshing = false
//重试队列
let requests = []
service.interceptors.response.use(
response => {
//约定code 409 token 过期
if (response.data.code === 409) {
if (!isRefreshing) {
isRefreshing = true
//调用刷新token的接口
return refreshToken({ refreshToken: localStorage.getItem('refreshToken'), token: getToken() }).then(res => {
const { token } = res.data
// 替换token
setToken(token)
response.headers.Authorization = `${token}`
// token 刷新后将数组的方法重新执行
requests.forEach((cb) => cb(token))
requests = [] // 重新请求完清空
return service(response.config)
}).catch(err => {
//跳到登录页
removeToken()
router.push('/login')
return Promise.reject(err)
}).finally(() => {
isRefreshing = false
})
} else {
// 返回未执行 resolve 的 Promise
return new Promise(resolve => {
// 用函数形式将 resolve 存入,等待刷新后再执行
requests.push(token => {
response.headers.Authorization = `${token}`
resolve(service(response.config))
})
})
}
}
return response && response.data
},
(error) => {
Message.error(error.response.data.msg)
return Promise.reject(error)
}
)

作者:远航_
链接:https://juejin.cn/post/7018439775476514823

收起阅读 »

Android 热修复核心原理,ClassLoader类加载

Android 热修复核心原理,ClassLoader类加载[TOC]Android前沿技术探讨:ClassLoader在热修复中的应用又在写bug?这句话虽然是句玩笑话,但是也正因为我们是人不是神,但也不能面面俱到,什么都考虑完美,出现bug是不可避免的。那...
继续阅读 »

Android 热修复核心原理,ClassLoader类加载

[TOC]Android前沿技术探讨:ClassLoader在热修复中的应用

又在写bug?这句话虽然是句玩笑话,但是也正因为我们是人不是神,但也不能面面俱到,什么都考虑完美,出现bug是不可避免的。那么对于android我们出现了Bug怎么办?

早期遇到Bug我们一般会紧急发布了一个版本。然而这个Bug可能就是简简单单的一行代码,为了这一行代码,进行全量或者增量更新迭代一个版本,未免有点大材小用了。而且新版本的普及需要时间,而且如果这次的新版本又有个小问题,怎么办?

那么为了解决这一个问题,热修复出现了。

热修复,现在大家应该都不陌生。从16年开始开始,热修复技术在 Android 技术社区热了一阵子,这种不用发布新版本就可以修复线上 bug 的技术看起来非常黑科技。

本章节的目的并不在于热修复本身,主要是通过热修复这个案例熟悉其核心:类加载机制。(

ART 和 Dalvik

DVM也是实现了JVM规范的一个虚拟器,默认使用CMS垃圾回收器,但是与JVM运行 Class 字节码不同,DVM 执行 Dex(Dalvik Executable Format) ——专为 Dalvik 设计的一种压缩格式。Dex 文件是很多 .class 文件处理压缩后的产物,最终可以在 Android 运行时环境执行。

ART(Android Runtime) 是在 Android 4.4 中引入的一个开发者选项,也是 Android 5.0 及更高版本的默认 Android 运行时。ART 和 Dalvik 都是运行 Dex 字节码的兼容运行时,因此针对 Dalvik 开发的应用也能在 ART 环境中运作。

source.android.google.cn/devices/tec…

dexopt与dexaot

  • dexopt

    Dalvik中虚拟机在加载一个dex文件时,对 dex 文件 进行 验证 和 优化的操作,其对 dex 文件的优化结果变成了 odex(Optimized dex) 文件,这个文件和 dex 文件很像,只是使用了一些优化操作码。

  • dex2oat

    ART 预先编译机制,在安装时对 dex 文件执行dexopt优化之后再将odex进行 AOT 提前编译操作,编译为OAT(实际上是ELF文件)可执行文件(机器码)。(相比做过ODEX优化,未做过优化的DEX转换成OAT要花费更长的时间)

image.png

ClassLoader介绍

任何一个 Java 程序都是由一个或多个 class 文件组成,在程序运行时,需要将 class 文件加载到 JVM 中才可以使用,负责加载这些 class 文件的就是 Java 的类加载机制。ClassLoader 的作用简单来说就是加载 class 文件,提供给程序运行时使用。每个 Class 对象的内部都有一个 classLoader 字段来标识自己是由哪个 ClassLoader 加载的。

class Class<T> {
...
 private transient ClassLoader classLoader;
...
}

ClassLoader是一个抽象类,而它的具体实现类主要有:

  • BootClassLoader

    用于加载Android Framework层class文件。

  • PathClassLoader

    用于Android应用程序类加载器。可以加载指定的dex,以及jar、zip、apk中的classes.dex

  • DexClassLoader

    用于加载指定的dex,以及jar、zip、apk中的classes.dex

    很多博客里说PathClassLoader只能加载已安装的apk的dex,其实这说的应该是在dalvik虚拟机上。

    但现在一般不用关心dalvik了。

    Log.e(TAG, "Activity.class 由:" + Activity.class.getClassLoader() +" 加载");
    Log.e(TAG, "MainActivity.class 由:" + getClassLoader() +" 加载");


    //输出:
    Activity.class 由:java.lang.BootClassLoader@d3052a9 加载

    MainActivity.class 由:dalvik.system.PathClassLoader[DexPathList[[zip file "/data/app/com.enjoy.enjoyfix-1/base.apk"],nativeLibraryDirectories=[/data/app/com.enjoy.enjoyfix-1/lib/x86, /system/lib, /vendor/lib]]] 加载

    它们之间的关系如下:

image.png

PathClassLoaderDexClassLoader的共同父类是BaseDexClassLoader

public class DexClassLoader extends BaseDexClassLoader {

   public DexClassLoader(String dexPath, String optimizedDirectory,
String librarySearchPath, ClassLoader parent) {
super(dexPath, new File(optimizedDirectory), librarySearchPath, parent);
}
}

public class PathClassLoader extends BaseDexClassLoader {

   public PathClassLoader(String dexPath, ClassLoader parent) {
       super(dexPath, null, null, parent);
  }

public PathClassLoader(String dexPath, String librarySearchPath, ClassLoader parent){
super(dexPath, null, librarySearchPath, parent);
}
}

可以看到两者唯一的区别在于:创建DexClassLoader需要传递一个optimizedDirectory参数,并且会将其创建为File对象传给super,而PathClassLoader则直接给到null。因此两者都可以加载指定的dex,以及jar、zip、apk中的classes.dex

PathClassLoader pathClassLoader = new PathClassLoader("/sdcard/xx.dex", getClassLoader());

File dexOutputDir = context.getCodeCacheDir();
DexClassLoader dexClassLoader = new DexClassLoader("/sdcard/xx.dex",dexOutputDir.getAbsolutePath(), null,getClassLoader());

其实,optimizedDirectory参数就是dexopt的产出目录(odex)。那PathClassLoader创建时,这个目录为null,就意味着不进行dexopt?并不是,optimizedDirectory为null时的默认路径为: /data/dalvik-cache

在API 26源码中,将DexClassLoader的optimizedDirectory标记为了 deprecated 弃用,实现也变为了:

public DexClassLoader(String dexPath, String optimizedDirectory,
String librarySearchPath, ClassLoader parent) {
super(dexPath, null, librarySearchPath, parent);
}

......和PathClassLoader一摸一样了!

双亲委托机制

可以看到创建ClassLoader需要接收一个ClassLoader parent参数。这个parent的目的就在于实现类加载的双亲委托。即:

某个类加载器在接到加载类的请求时,首先将加载任务委托给父类加载器,依次递归,如果父类加载器可以完成类加载任务,就成功返回;只有父类加载器无法完成此加载任务时,才自己去加载。

protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException{

   // 检查class是否有被加载  
Class c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
if (parent != null) {
               //如果parent不为null,则调用parent的loadClass进行加载  
c = parent.loadClass(name, false);
          } else {
               //parent为null,则调用BootClassLoader进行加载  
               c = findBootstrapClassOrNull(name);
          }
      } catch (ClassNotFoundException e) {

      }

       if (c == null) {
           // 如果都找不到就自己查找
long t1 = System.nanoTime();
           c = findClass(name);
      }
}
return c;
}

因此我们自己创建的ClassLoader: new PathClassLoader("/sdcard/xx.dex", getClassLoader());并不仅仅只能加载 xx.dex中的class。

值得注意的是:c = findBootstrapClassOrNull(name);

按照方法名理解,应该是当parent为null时候,也能够加载BootClassLoader加载的类。

new PathClassLoader("/sdcard/xx.dex", null),能否加载Activity.class?

但是实际上,Android当中的实现为:(Java不同)

private Class findBootstrapClassOrNull(String name)
{
 return null;
}

findClass

可以看到在所有父ClassLoader无法加载Class时,则会调用自己的findClass方法。findClass在ClassLoader中的定义为:

protected Class<?> findClass(String name) throws ClassNotFoundException {
throw new ClassNotFoundException(name);
}

其实任何ClassLoader子类,都可以重写loadClassfindClass。一般如果你不想使用双亲委托,则重写loadClass修改其实现。而重写findClass则表示在双亲委托下,父ClassLoader都找不到Class的情况下,定义自己如何去查找一个Class。而我们的PathClassLoader会自己负责加载MainActivity这样的程序中自己编写的类,利用双亲委托父ClassLoader加载Framework中的Activity。说明PathClassLoader并没有重写loadClass,因此我们可以来看看PathClassLoader中的 findClass 是如何实现的。

public BaseDexClassLoader(String dexPath, File optimizedDirectory,String    
librarySearchPath, ClassLoader parent) {
super(parent);
this.pathList = new DexPathList(this, dexPath, librarySearchPath,
                                   optimizedDirectory);
}

@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
List<Throwable> suppressedExceptions = new ArrayList<Throwable>();
   //查找指定的class
   Class c = pathList.findClass(name, suppressedExceptions);
   if (c == null) {
ClassNotFoundException cnfe = new ClassNotFoundException("Didn't find class "" + name + "" on path: " + pathList);
       for (Throwable t : suppressedExceptions) {
cnfe.addSuppressed(t);
      }
           throw cnfe;
}
return c;
}

实现非常简单,从pathList中查找class。继续查看DexPathList

public DexPathList(ClassLoader definingContext, String dexPath,
           String librarySearchPath, File optimizedDirectory) {
//.........
   // splitDexPath 实现为返回 List<File>.add(dexPath)
   // makeDexElements 会去 List<File>.add(dexPath) 中使用DexFile加载dex文件返回 Element数组
   this.dexElements = makeDexElements(splitDexPath(dexPath), optimizedDirectory,
                                          suppressedExceptions, definingContext);
//.........
   
}

public Class findClass(String name, List<Throwable> suppressed) {
    //从element中获得代表Dex的 DexFile
for (Element element : dexElements) {
DexFile dex = element.dexFile;
if (dex != null) {
           //查找class
      Class clazz = dex.loadClassBinaryName(name, definingContext, suppressed);
           if (clazz != null) {
          return clazz;
      }
  }
  }
   if (dexElementsSuppressedExceptions != null) {
  suppressed.addAll(Arrays.asList(dexElementsSuppressedExceptions));
  }
return null;
}

热修复

PathClassLoader中存在一个Element数组,Element类中存在一个dexFile成员表示dex文件,即:APK中有X个dex,则Element数组就有X个元素。

image.png

PathClassLoader中的Element数组为:[patch.dex , classes.dex , classes2.dex]。如果存在Key.class位于patch.dex与classes2.dex中都存在一份,当进行类查找时,循环获得dexElements中的DexFile,查找到了Key.class则立即返回,不会再管后续的element中的DexFile是否能加载到Key.class了。

因此实际上,一种热修复实现可以将出现Bug的class单独的制作一份fix.dex文件(补丁包),然后在程序启动时,从服务器下载fix.dex保存到某个路径,再通过fix.dex的文件路径,用其创建Element对象,然后将这个Element对象插入到我们程序的类加载器PathClassLoaderpathList中的dexElements数组头部。这样在加载出现Bug的class时会优先加载fix.dex中的修复类,从而解决Bug。

热修复的方式不止这一种,并且如果要完整实现此种热修复可能还需要注意一些其他的问题(如:反射兼容)。


收起阅读 »

分析应用程序启动

一旦我们建立了触发应用程序缓慢启动的指标和场景,下一步就是提高性能。要了解是什么导致应用程序启动缓慢,我们需要对其进行分析。 Android Studio 提供了几种类型的分析器录制配置:Trace System Calls(又名 systrace、perfe...
继续阅读 »

一旦我们建立了触发应用程序缓慢启动的指标和场景,下一步就是提高性能。

要了解是什么导致应用程序启动缓慢,我们需要对其进行分析。 Android Studio 提供了几种类型的分析器录制配置:

Trace System Calls(又名 systrace、perfetto):对运行时的影响很小,非常有助于了解应用程序如何与系统和 CPU 交互,但不了解应用程序 VM 内部发生的 Java 方法调用。

Sample C/C++ Functions(又名 Simpleperf):我不感兴趣,我处理的应用程序运行的字节码比本机代码多得多。 在 Q+ 上,这现在也应该以低开销的方式对 Java 堆栈进行采样,但我还没有设法让它工作。

Trace Java Methods:这会捕获所有 VM 方法调用,这些调用引入了如此多的开销,结果没有多大意义。

Sample Java Methods:开销比跟踪少,但显示了 VM 内部发生的 Java 方法调用。 这是我在分析应用程序启动时的首选选项。

在应用程序启动时开始录制

Android Studio profiler 有通过连接到已经运行的进程来启动跟踪的 UI,但没有明显的方式在应用程序启动时开始记录。

该选项存在但隐藏在应用程序的 run configuration:在 profiling 选项卡中选中启动时启动此记录。

然后通过 Run > Profile app 部署应用程序。

分析 release builds

Android 开发人员通常在日常工作中使用调试构建类型,调试构建通常包括 LeakCanary 等额外库等。

开发人员应该分析发布版本而不是调试版本,以确保他们正在解决客户面临的实际问题。

不幸的是,发布版本是不可调试的,因此 Android 分析器无法记录发布版本的跟踪。

以下是解决该问题的几个选项。

1. 创建可调试的发布版本

我们可以暂时使我们的发布构建可调试,或者创建一个新的发布构建类型来进行分析。

android {
buildTypes {
release {
debuggable true
// ...
}
}
}

如果 APK 是可调试的,库和 Android 框架代码通常会有不同的行为。 ART 禁用了许多优化以启用连接调试器,这会显着且不可预测地影响性能。因此该解决方案并不理想。

2. 在有 root 设备上调试设备

Root 设备允许 Android Studio 分析器记录不可调试构建的跟踪。

通常不建议在模拟器上进行分析 - 每个系统组件的性能都会不同(cpu 速度、缓存大小、磁盘性能),因此“优化”实际上可以通过将工作转移到手机上较慢的东西来使事情变慢 . 如果您没有可用的 root 物理设备,您可以创建一个没有 Play 服务的模拟器,然后运行 adb root。

3. 在 Android Q 上使用 simpleperf

有一个名为 simpleperf 的工具,如果它们有一个特殊的清单标志,据说可以在非根 Q+ 设备上启用分析版本构建。 该文档将其称为 profileableFromShell,XML 示例有一个带有 android:shell 属性的 profileable 标记,官方清单文档没有显示任何内容。

<manifest ...>
<application ...>
<profileable android:shell="true" />
</application>
</manifest>

我查看了 cs.android.com 上的清单解析代码:

if (tagName.equals("profileable")) {
sa = res.obtainAttributes(
parser,
R.styleable.AndroidManifestProfileable
);
if (sa.getBoolean(
R.styleable.AndroidManifestProfileable_shell,
false
)) {
ai.privateFlags |= ApplicationInfo.PRIVATE_FLAG_PROFILEABLE_BY_SHELL;
}
}

如果清单具有 <profileable android:shell="true" /> (我没有尝试过),您似乎可以从命令行触发分析。 据我了解,Android Studio 团队仍在努力与此新功能集成。

分析下载的 APK

在 Square,我们的版本是用 CI 构建的。 正如我们之前看到的,从 Android Studio 分析应用程序启动需要检查运行配置中的一个选项。 我们如何使用下载的 APK 来做到这一点?

事实证明,这是可能的,但隐藏在 File > Profile or Debug APK 下。 这将打开一个包含解压缩 APK 的新窗口,您可以从中设置运行配置并开始分析。

Android Studio 分析器会减慢一切

不幸的是,当我在一个生产应用程序上测试这些方法时,即使在最近的 Android 版本上,Android Studio 的分析也会大大减慢应用程序的启动速度(大约慢 10 倍)。 我不知道为什么,也许是“高级分析”,它似乎不能被禁用。 我们需要另辟蹊径!

从代码分析

我们可以直接从代码开始跟踪,而不是从 Android Studio 进行分析:

val tracesDirPath = TODO("path for trace directory")
val fileNameFormat = SimpleDateFormat(
"yyyy-MM-dd_HH-mm-ss_SSS'.trace'",
Locale.US
)
val fileName = fileNameFormat.format(Date())
val traceFilePath = tracesDirPath + fileName
// Save up to 50Mb data.
val maxBufferSize = 50 * 1000 * 1000
// Sample every 1000 microsecond (1ms)
val samplingIntervalUs = 1000
Debug.startMethodTracingSampling(
traceFilePath,
maxBufferSize,
samplingIntervalUs
)

// ...

Debug.stopMethodTracing()

然后我们可以从设备中提取跟踪文件并将其加载到 Android Studio 中。

什么时候开始取样

我们应该在应用程序生命周期中尽早开始记录跟踪。 最早可以在 Android P 之前在应用程序启动时运行的代码是 ContentProvider,而在 Android P+ 上它是 AppComponentFactory。

Android P / API < 28

class AppStartListener : ContentProvider() {
override fun onCreate(): Boolean {
Debug.startMethodTracingSampling(...)
return false
}
// ...
}
<?xml version="1.0" encoding="utf-8"?>
<manifest
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">

<application>
<provider
android:name=".AppStartListener"
android:authorities="com.example.appstartlistener"
android:exported="false" />
</application>

</manifest>

在定义提供者时,我们可以设置一个 initOrder 标记,并且最高的数字首先被初始化。

Android P+ / API 28+

@RequiresApi(28)
class MyAppComponentFactory() :
androidx.core.app.AppComponentFactory() {

@RequiresApi(29)
override fun instantiateClassLoader(
cl: ClassLoader,
aInfo: ApplicationInfo
): ClassLoader {
if (Build.VERSION.SDK_INT >= 29) {
Debug.startMethodTracingSampling(...)
}
return super.instantiateClassLoader(cl, aInfo)
}

override fun instantiateApplicationCompat(
cl: ClassLoader,
className: String
): Application {
if (Build.VERSION.SDK_INT < 29) {
Debug.startMethodTracingSampling(...)
}
return super.instantiateApplicationCompat(cl, className)
}
}
<?xml version="1.0" encoding="utf-8"?>
<manifest
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">

<application
android:appComponentFactory=".MyAppComponentFactory"
tools:replace="android:appComponentFactory"
tools:targetApi="p">
</application>

</manifest>

在哪里存储采样

val tracesDirPath = TODO("path for trace directory")
  • API < 28:广播接收器可以访问上下文,我们可以在该上下文上调用 Context.getDataDir() 将跟踪存储在应用程序目录中。

  • API 28: AppComponentFactory.instantiateApplication() 负责创建一个新的应用程序实例,所以目前还没有可用的上下文。 我们可以直接硬编码到 /sdcard/ 的路径,但这需要 WRITE_EXTERNAL_STORAGE 权限。

  • API 29+:当面向 API 29 时,硬编码 /sdcard/ 停止工作。 我们可以添加 requestLegacyExternalStorage 标志,但无论如何 API 30 都不支持它。 建议在 API 30+ 上尝试 MANAGE_EXTERNAL_STORAGE。 无论哪种方式,AppComponentFactory.instantiateClassLoader() 都会传递一个 ApplicationInfo,因此我们可以使用 ApplicationInfo.dataDir 将跟踪存储在应用程序目录中。

何时停止采样

当应用程序的第一帧完全加载时,冷启动结束。 我们可以根据这个条件停止方法跟踪:

class MyApp : Application() {

override fun onCreate() {
super.onCreate()

var firstDraw = false
val handler = Handler()

registerActivityLifecycleCallbacks(
object : ActivityLifecycleCallbacks {
override fun onActivityCreated(
activity: Activity,
savedInstanceState: Bundle?
) {
if (firstDraw) return
val window = activity.window
window.onDecorViewReady {
window.decorView.onNextDraw {
if (firstDraw) return
firstDraw = true
handler.postAtFrontOfQueue {
Debug.stopMethodTracing()
}
}
}
}
})
}
}

我们还可以记录比应用程序启动时间更长的固定时间,例如 5秒:

Handler(Looper.getMainLooper()).postDelayed({
Debug.stopMethodTracing()
}, 5000)

使用 Nanoscope 进行分析

另一个用于分析应用程序启动的选项是 uber/nanoscope。 这是一个带有内置低开销跟踪的 Android 图像。 它很棒,但有一些限制:

它只跟踪主线程。

大型应用程序将溢出内存跟踪缓冲区。

应用启动步骤

一旦我们有了启动跟踪,我们就可以开始调查什么操作耗费时间。 应该期待 3 个主要部分:

ActivityThread.handlingBindApplication() 包含 Activity 创建之前的启动工作。 如果这很慢,那么我们可能需要优化 Application.onCreate()。

TransactionExecutor.execute() 负责创建和恢复 Activity ,包括填充视图层次结构。

ViewRootImpl.performTraversals() 是框架执行第一次测量、布局和绘制的地方。 如果这很慢,则可能是视图层次结构过于复杂,或者具有需要优化的自定义绘图的视图。

如果注意到服务是在第一次视图遍历之前启动的,那么延迟该服务的启动可能是值得的,以便它在视图遍历之后发生。

结论

一些要点:

分析发布版本专注于实际问题。

Android 上的分析应用程序启动状态远非理想。 基本上没有好的开箱即用解决方案,但 Jetpack Benchmark 团队正在努力解决这个问题。

从代码开始采样,以防止 Android Studio 拖慢一切。

收起阅读 »

adb 如何衡量应用启动

可以利用 ActivityTaskManager 的输出来获取应用程序启动持续时间。每当 Activity 启动时,都会在 logcat 输出中看到类似的内容:ActivityTaskManager: Displayed com.android.samples...
继续阅读 »

可以利用 ActivityTaskManager 的输出来获取应用程序启动持续时间。

每当 Activity 启动时,都会在 logcat 输出中看到类似的内容:

ActivityTaskManager: Displayed
com.android.samples.mytest/.MainActivity: +1s380ms

此持续时间(在此示例中为 1,380 毫秒)表示从启动应用程序到系统认为它“已启动”的时间,其中包括绘制第一帧(因此为“已显示”)。

这篇文章深入探讨了这个问题:

ActivityTaskManager 究竟测量什么?

ActivityTaskManager 测量从 system_process 接收到启动活动的意图到该活动的窗口完成绘制的时间(API < 30 上的正常运行时间,API 30+ 上的实时时间)。

关键要点:

此度量包括应用程序代码和资源加载之前的几百毫秒,即应用程序开发人员无法影响的时间。

可以在应用程序内进行测量而无需额外的时间,我将在最后分享如何进行。

ActivityTaskManager log

ActivityTaskManager: Displayed
com.android.samples.mytest/.MainActivity: +1s380ms

我们知道日志的样子,所以我们可以在 cs.android.com 上搜索它:"Displayed"

这导致我们到 ActivityTaskManager.logAppDisplayed():

private void logAppDisplayed(TransitionInfoSnapshot info) {
StringBuilder sb = mStringBuilder;
sb.setLength(0);
sb.append("Displayed ");
sb.append(info.launchedActivityShortComponentName);
sb.append(": ");
TimeUtils.formatDuration(info.windowsDrawnDelayMs, sb);
Log.i(TAG, sb.toString());
}

启动持续时间为 TransitionInfoSnapshot.windowsDrawnDelayMs。 它在 TransitionInfoSnapshot.notifyWindowsDrawn() 中计算:

TransitionInfoSnapshot notifyWindowsDrawn(
ActivityRecord r,
long timestampNs
) {
TransitionInfo info = getActiveTransitionInfo(r);
info.mWindowsDrawnDelayMs = info.calculateDelay(timestampNs);
return new TransitionInfoSnapshot(info);
}

private static final class TransitionInfo {
int calculateDelay(long timestampNs) {
long delayNanos = timestampNs - mTransitionStartTimeNs;
return (int) TimeUnit.NANOSECONDS.toMillis(delayNanos);
}
}

让我们找出 timestampNs 和 mTransitionStartTimeNs 在哪里被捕获。

ActivityMetricsLogger.notifyActivityLaunching() 捕获活动转换的开始:

private LaunchingState notifyActivityLaunching(
Intent intent,
ActivityRecord caller,
int callingUid
) {
long transitionStartNs = SystemClock.elapsedRealtimeNanos();
LaunchingState launchingState = new LaunchingState();
launchingState.mCurrentTransitionStartTimeNs = transitionStartNs;
return launchingState;
}

TransitionInfoSnapshot.notifyWindowsDrawn() 由 ActivityRecord.onWindowsDrawn() 调用,后者由 ActivityRecord.updateReportedVisibilityLocked() 调用:

void updateReportedVisibilityLocked() {
// ...
if (nowDrawn != reportedDrawn) {
onWindowsDrawn(nowDrawn, SystemClock.elapsedRealtimeNanos());
reportedDrawn = nowDrawn;
}
// ...
}

我们现在知道在哪里捕获开始和结束时间戳,但不幸的是 ActivityMetricsLogger.notifyActivityLaunching() 和 ActivityRecord.updateReportedVisibilityLocked() 有很多调用点,因此很难在 AOSP 源中进一步挖掘。

调试system_process

我告诉一个朋友,我在查看 Android 资源时遇到了死胡同,他问我:

为什么不设置断点?

我从未尝试过调试 system_process,但我们没有理由不能。 谢谢文森特的主意! 幸运的是,Android Studio 设置为查找应用编译所针对的 Android 版本的源代码。

使用 Root 设备,我可以将调试器连接到 system_process。

当我启动我的应用程序时,我遇到了 ActivityMetricsLogger.notifyActivityLaunching() 的断点。

和 TransitionInfoSnapshot.notifyWindowsDrawn() 的另一个断点。

读取堆栈跟踪

第一个堆栈跟踪显示当 system_process 收到启动活动的 Intent 时捕获开始时间戳。

第二个堆栈跟踪显示当该活动的窗口完成绘制时捕获结束时间戳。 相应的帧应在 16 毫秒内在显示屏上可见。

应用启动时间

启动 Activity 的用户体验在用户触摸屏幕时开始,但是应用程序开发人员对 ActivityThread.handleBindApplication() 之前花费的时间几乎没有影响,因此应用程序冷启动监控应该从这里开始。

ActivityThread.handleBindApplication() 加载 APK 和应用程序组件(AppComponentFactory、ContentProvider、Application)。 不幸的是,ActivityTaskManager 使用 ActivityTaskManagerService.startActivity() 作为开始时间,它发生在 ActivityThread.handleBindApplication() 之前的一段时间。

ActivityTaskManager 增加了多少时间?

我展示了我们可以使用 Process.getStartUptimeMillis() 来获取调用 ActivityThread.handleBindApplication() 的时间戳。 我还分享了一个代码片段来读取进程 fork 时间(参见 Processes.readProcessForkRealtimeMillis())。 我们可以将这 2 个值记录到 logcat:

val forkRealtime = Processes.readProcessForkRealtimeMillis()
val nowRealtimeMs = SystemClock.elapsedRealtime()
val nowUptimeMs = SystemClock.uptimeMillis()
val elapsedRealtimeMs = nowRealtimeMs - forkRealtime
val forkUptimeMs = nowUptimeMs - elapsedRealtimeMs
Log.d("AppStart", "$forkUptimeMs fork timestamp")

val processStart = Process.getStartUptimeMillis()
Log.d("AppStart", "$processStart bindApplication() timestamp")

我们还需要记录 ActivityMetricsLogger.mCurrentTransitionStartTime。 我们可以让我们之前的 system_process 断点非挂起并让它记录值。 Evaluate 和 log 的输出进入调试器控制台。 我们希望所有日志都在 logcat 中,因此我们从那里调用 Log.d()。

结果

D/AppStart: 27464211 Intent received
D/AppStart: 27464340 fork timestamp
D/AppStart: 27464533 bindApplication() timestamp
...
I/ActivityTaskManager: Displayed
com.example.logstartup/.MainActivity: +1s185ms

从接收到分叉 zygote 进程的意图需要 129 毫秒,从分叉到 ActivityThread.handleBindApplication() 需要 193 毫秒,即应用程序开始加载其代码和资源之前的 322 毫秒。 在此示例中,这是 ActivityTaskManager 报告的应用启动时间的约 30%。

实际数字低于此值,因为 system_process 正在运行并连接调试器。

从应用程序内部测量应用程序启动时间

我将该时间戳与传递给 TransitionInfoSnapshot.notifyWindowsDrawn() 的时间戳进行了比较,这两个值仅相隔几毫秒。

我们可以把我们学到的东西放在一起来衡量应用程序内的应用程序启动持续时间:

class MyApp : Application() {

override fun onCreate() {
super.onCreate()

var firstDraw = false
val handler = Handler()

registerActivityLifecycleCallbacks(
object : ActivityLifecycleCallbacks {
override fun onActivityCreated(
activity: Activity,
savedInstanceState: Bundle?
) {
if (firstDraw) return
val name = activity::class.java.simpleName
val window = activity.window
window.onDecorViewReady {
window.decorView.onNextDraw {
if (firstDraw) return@onNextDraw
firstDraw = true
handler.postAtFrontOfQueue {
val start = Process.getStartUptimeMillis()
val now = SystemClock.uptimeMillis()
val startDurationMs = now - start
Log.d(
"AppStart",
"Displayed $name in $startDurationMs ms"
)
}
}
}
}
})
}
}
D/AppStart: Displayed MainActivity in 863 ms

结论

ActivityTaskManager 的输出很方便,如果您尝试比较应用程序不同版本的启动时间,则完全值得使用。 请注意,应用程序开发人员无法影响那段时间的重要部分。

可以从应用程序内测量应用程序启动时间。


收起阅读 »

Android入门教程 | Fragment 基础概念

什么是Fragment?Fragment,直译为“碎片”,“片段”。 Fragment 表示 FragmentActivity 中的行为或界面的一部分。可以在一个 Activity 中组合多个片段,从而构建多窗格界面,并在多个 Activity 中重复使用某个...
继续阅读 »

什么是Fragment?

Fragment,直译为“碎片”,“片段”。 Fragment 表示 FragmentActivity 中的行为或界面的一部分。可以在一个 Activity 中组合多个片段,从而构建多窗格界面,并在多个 Activity 中重复使用某个片段。可以将片段视为 Activity 的模块化组成部分,它具有自己的生命周期,能接收自己的输入事件,并且可以在 Activity 运行时添加或移除片段(这有点像可以在不同 Activity 中重复使用的“子 Activity”)。

片段必须始终托管在 Activity 中,其生命周期直接受宿主 Activity 生命周期的影响。例如,当 Activity 暂停时,Activity 的所有片段也会暂停;当 Activity 被销毁时,所有片段也会被销毁。

不过,当 Activity 正在运行(处于已恢复生命周期状态)时,可以独立操纵每个片段,如添加或移除片段。当执行此类片段事务时,也可将其添加到由 Activity 管理的返回栈 — Activity 中的每个返回栈条目都是一条已发生片段事务的记录。借助返回栈,用户可以通过按返回按钮撤消片段事务(后退)。

Fragment的优点

  • Fragment加载灵活,替换方便。定制你的UI,在不同尺寸的屏幕上创建合适的UI,提高用户体验。
  • 可复用,页面布局可以使用多个Fragment,不同的控件和内容可以分布在不同的Fragment上。
  • 使用Fragment,可以少用一些Activity。一个Activity可以管辖多个Fragment。

Fragment生命周期

image.png

Fragment 类的代码与 Activity 非常相似。它包含与 Activity 类似的回调方法,如 onCreate()、onStart()、onPause() 和 onStop()。实际上,如果要将现有 Android 应用转换为使用片段,可能只需将代码从 Activity 的回调方法移入片段相应的回调方法中。

通常,至少应实现以下生命周期方法

  • onCreate() 系统会在创建片段时调用此方法。当片段经历暂停或停止状态继而恢复后,如果希望保留此片段的基本组件,则应在实现中将其初始化。
  • onCreateView() 系统会在片段首次绘制其界面时调用此方法。如要为片段绘制界面,从此方法中返回的 View 必须是片段布局的根视图。如果片段未提供界面,可以返回 null。
  • onPause() 系统会将此方法作为用户离开片段的第一个信号(但并不总是意味着此片段会被销毁)进行调用。通常,应在此方法内确认在当前用户会话结束后仍然有效的任何更改(因为用户可能不会返回)。

可能还想扩展几个子类,而非 Fragment 基类

  • DialogFragment 显示浮动对话框。使用此类创建对话框可有效代替使用 Activity 类中的对话框辅助方法,因为您可以将片段对话框纳入由 Activity 管理的片段返回栈,从而使用户能够返回清除的片段。
  • ListFragment 显示由适配器(如 SimpleCursorAdapter)管理的一系列项目,类似于 ListActivity。该类提供几种管理列表视图的方法,如用于处理点击事件的 onListItemClick() 回调。(请注意,显示列表的首选方法是使用 RecyclerView,而非 ListView。在此情况下,需在列表布局中创建包含 RecyclerView 的片段。如需了解具体操作方法,请参阅使用 RecyclerView 创建列表)
  • PreferenceFragmentCompat 以列表形式显示 Preference 对象的层次结构。此类用于为应用创建设置屏幕。
创建Fragment,使用自定义界面

片段通常用作 Activity 界面的一部分,并且会将其自己的布局融入 Activity。

如要为片段提供布局,必须实现 onCreateView() 回调方法,Android 系统会在片段需要绘制其布局时调用该方法。此方法的实现所返回的 View 必须是片段布局的根视图。

如要从 onCreateView() 返回布局,可以通过 XML 中定义的布局资源来扩展布局。为帮助您执行此操作,onCreateView() 提供了一个 LayoutInflater 对象。

例如,以下这个 Fragment 子类从 example_fragment.xml 文件加载布局:

public static class ExampleFragment extends Fragment {
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container,
Bundle savedInstanceState) {
// Inflate the layout for this fragment
return inflater.inflate(R.layout.example_fragment, container, false);
}
}

传递至 onCreateView() 的 container 参数是片段布局将插入到的父级 ViewGroup(来自 Activity 的布局)。savedInstanceState 参数是在恢复片段时,提供上一片段实例相关数据的 Bundle(处理片段生命周期部分对恢复状态做了详细阐述)。

inflate() 方法带有三个参数

  • 想要扩展的布局的资源 ID。
  • 将作为扩展布局父项的 ViewGroup。传递 container 对系统向扩展布局的根视图(由其所属的父视图指定)应用布局参数具有重要意义。
  • 指示是否应在扩展期间将扩展布局附加至 ViewGroup(第二个参数)的布尔值。(在本例中,此值为 false,因为系统已将扩展布局插入 container,而传递 true 值会在最终布局中创建一个多余的视图组。)

接下来,需将该片段添加到您的 Activity 中。

向Activity添加Fragment

通常,片段会向宿主 Activity 贡献一部分界面,作为 Activity 整体视图层次结构的一部分嵌入到 Activity 中。可以通过两种方式向 Activity 布局添加片段(以下为代码片段,并非完整代码)。

静态方式

在 Activity 的布局文件内声明片段。 在本例中,您可以将片段当作视图来为其指定布局属性。例如,以下是拥有两个片段的 Activity 的布局文件:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="horizontal"
android:layout_width="match_parent"
android:layout_height="match_parent">
<fragment android:name="com.example.news.ArticleListFragment"
android:id="@+id/list"
android:layout_weight="1"
android:layout_width="0dp"
android:layout_height="match_parent" />
<fragment android:name="com.example.news.ArticleReaderFragment"
android:id="@+id/viewer"
android:layout_weight="2"
android:layout_width="0dp"
android:layout_height="match_parent" />
</LinearLayout>

<fragment> 中的 android:name 属性指定要在布局中进行实例化的 Fragment 类。

创建此 Activity 布局时,系统会将布局中指定的每个片段实例化,并为每个片段调用 onCreateView() 方法,以检索每个片段的布局。系统会直接插入片段返回的 View,从而代替 <fragment> 元素。

注意:每个片段都需要唯一标识符,重启 Activity 时,系统可使用该标识符来恢复片段(也可以使用该标识符来捕获片段,从而执行某些事务,如将其移除)。可以通过两种方式为片段提供 ID: 为 android:id 属性提供唯一 ID。 为 android:tag 属性提供唯一字符串。

Java代码加载Fragment

或者,通过编程方式将片段添加到某个现有 ViewGroup。 在 Activity 运行期间,您可以随时将片段添加到 Activity 布局中。您只需指定要将片段放入哪个 ViewGroup。

如要在 Activity 中执行片段事务(如添加、移除或替换片段),则必须使用 FragmentTransaction 中的 API。如下所示,可以从 FragmentActivity 获取一个 FragmentTransaction 实例:

FragmentManager fragmentManager = getSupportFragmentManager();
FragmentTransaction fragmentTransaction = fragmentManager.beginTransaction();

然后,可以使用 add() 方法添加一个片段,指定要添加的片段以及将其插入哪个视图。例如:

ExampleFragment fragment = new ExampleFragment();
fragmentTransaction.add(R.id.fragment_container, fragment);
fragmentTransaction.commit();

传递到 add() 的第一个参数是 ViewGroup,即应放置片段的位置,由资源 ID 指定,第二个参数是要添加的片段。 一旦通过 FragmentTransaction 做出了更改,就必须调用 commit() 以使更改生效。

管理Fragment

如要管理 Activity 中的片段,需使用 FragmentManager。如要获取它,请从 Activity 调用 getSupportFragmentManager()

可使用 FragmentManager 执行的操作包括

  • 通过 findFragmentById()(针对在 Activity 布局中提供界面的片段)或 findFragmentByTag()(针对提供或不提供界面的片段)获取 Activity 中存在的片段。
  • 通过 popBackStack()(模拟用户发出的返回命令)使片段从返回栈中弹出。
  • 通过 addOnBackStackChangedListener() 注册侦听返回栈变化的侦听器。

也可使用 FragmentManager 打开一个 FragmentTransaction,通过它来执行某些事务,如添加和移除片段。

执行Fragment事务

在 Activity 中使用片段的一大优点是,可以通过片段执行添加、移除、替换以及其他操作,从而响应用户交互。提交给 Activity 的每组更改均称为事务,并且可使用 FragmentTransaction 中的 API 来执行一项事务。也可将每个事务保存到由 Activity 管理的返回栈内,从而让用户能够回退片段更改(类似于回退 Activity)。

如下所示,可以从 FragmentManager 获取一个 FragmentTransaction 实例:

FragmentManager fragmentManager = getSupportFragmentManager();
FragmentTransaction fragmentTransaction = fragmentManager.beginTransaction();

每个事务都是想要同时执行的一组更改。可以使用 add()、remove() 和 replace() 等方法,为给定事务设置您想要执行的所有更改。然后,如要将事务应用到 Activity,必须调用 commit()。

不过,在调用 commit() 之前,可能希望调用 addToBackStack(),以将事务添加到片段事务返回栈。该返回栈由 Activity 管理,允许用户通过按返回按钮返回上一片段状态。

例如,以下示例说明如何将一个片段替换为另一个片段,以及如何在返回栈中保留先前的状态:

// Create new fragment and transaction
Fragment newFragment = new ExampleFragment();
FragmentTransaction transaction = getSupportFragmentManager().beginTransaction();

// Replace whatever is in the fragment_container view with this fragment,
// and add the transaction to the back stack
transaction.replace(R.id.fragment_container, newFragment);
transaction.addToBackStack(null);

// Commit the transaction
transaction.commit();

在本例中,newFragment 会替换目前在 R.id.fragment_container ID 所标识的布局容器中的任何片段(如有)。通过调用 addToBackStack(),可以将替换事务保存到返回栈,以便用户能够通过按返回按钮撤消事务并回退到上一片段。

然后,FragmentActivity 会自动通过 onBackPressed() 从返回栈检索片段。

如果向事务添加多个更改(如又一个 add() 或 remove()),并调用 addToBackStack(),则调用 commit() 前应用的所有更改都将作为单一事务添加到返回栈,并且返回按钮会将它们一并撤消。

向 FragmentTransaction 添加更改的顺序无关紧要,不过:

必须最后调用 commit()。 如果要向同一容器添加多个片段,则添加片段的顺序将决定它们在视图层次结构中出现的顺序。 如果没有在执行删除片段的事务时调用 addToBackStack(),则事务提交时该片段会被销毁,用户将无法回退到该片段。不过,如果在删除片段时调用 addToBackStack(),则系统会停止该片段,并随后在用户回退时将其恢复。

调用 commit() 不会立即执行事务,而是在 Activity 的界面线程(“主”线程)可执行该操作时,再安排该事务在线程上运行。不过,如有必要,也可以从界面线程调用 executePendingTransactions(),以立即执行 commit() 提交的事务。通常不必这样做,除非其他线程中的作业依赖该事务。

注意:只能在 Activity 保存其状态(当用户离开 Activity)之前使用 commit() 提交事务。如果试图在该时间点后提交,则会引发异常。这是因为如需恢复 Activity,则提交后的状态可能会丢失。对于丢失提交无关紧要的情况,请使用 commitAllowingStateLoss()

生命周期变化

Fragment被创建的时候

它会经历以下状态

onAttach()
onCreate()
onCreateView()
onActivityCreated()

Fragment 对用户可见的时候

它会经历以下状态

onStart()
onResume()

Fragment进入“后台模式”的时候

它会经历以下状态

onPause()
onStop()

Fragment被销毁了(或者持有它的activity被销毁了)

它会经历以下状态

onPause()
onStop()
onDestroyView()
onDestroy()
onDetach()

Fragment与Activity不同的生命周期

Fragment 的大部分状态都和 Activity 很相似,但 fragment 有一些新的状态。

Fragment不同于Activity的生命周期 - onAttached() —— 当fragment被加入到activity时调用(在这个方法中可以获得所在的activity)。 - onCreateView() —— 当activity要得到fragment的layout时,调用此方法,fragment在其中创建自己的layout(界面)。 - onActivityCreated() —— 当activity的onCreated()方法返回后调用此方法 - onDestroyView() —— 当fragment中的视图被移除的时候,调用这个方法。 - onDetach() —— 当fragment和activity分离的时候,调用这个方法。

一旦activity进入resumed状态(也就是running状态),你就可以自由地添加和删除fragment了。因此,只有当activity在resumed状态时,fragment的生命周期才能独立的运转,其它时候是依赖于activity的生命周期变化的。

处理Fragment生命周期

管理片段生命周期与管理 Activity 生命周期很相似。和 Activity 一样,片段也以三种状态存在:

  • 已恢复:片段在运行中的 Activity 中可见。
  • 已暂停:另一个 Activity 位于前台并具有焦点,但此片段所在的 Activity 仍然可见(前台 Activity 部分透明,或未覆盖整个屏幕)。
  • 已停止:片段不可见。宿主 Activity 已停止,或片段已从 Activity 中移除,但已添加到返回栈。已停止的片段仍处于活动状态(系统会保留所有状态和成员信息)。不过,它对用户不再可见,并随 Activity 的终止而终止。 与 Activity 一样,您也可使用 onSaveInstanceState(Bundle)、ViewModel 和持久化本地存储的组合,在配置变更和进程终止后保留片段的界面状态。如要了解保留界面状态的更多信息,请参阅保存界面状态。

对于 Activity 生命周期与片段生命周期而言,二者最显著的差异是在其各自返回栈中的存储方式。默认情况下,Activity 停止时会被放入由系统管理的 Activity 返回栈中。不过,只有在移除片段的事务执行期间通过调用 addToBackStack() 显式请求保存实例时,系统才会将片段放入由宿主 Activity 管理的返回栈。

在其他方面,管理片段生命周期与管理 Activity 生命周期非常相似;对此,可采取相同的做法。

image.png

注意:如果 Fragment 中需要 Context 对象,则可以调用 getContext()。但请注意,只有在该片段附加到 Activity 时才需调用 getContext()。如果尚未附加该片段,或者其在生命周期结束期间已分离,则 getContext() 返回 null。

Fragment相关面试题:

1. 如何切换 fragement(不重新实例化)

翻看了 Android 官方 Doc,和一些组件的源代码,发现 replace()这个方法只是在上一个 Fragment不再需要时采用的简便方法.

正确的切换方式是 add(),切换时 hide(),add()另一个 Fragment;再次切换时,只需 hide()当前,show()另一个。这样就能做到多个 Fragment 切换不重新实例化:

2. Fragment 的的优点

  • Fragment 可以使你能够将 activity 分离成多个可重用的组件,每个都有它自己的生命周期和UI。
  • Fragment 可以轻松得创建动态灵活的 UI 设计,可以适应于不同的屏幕尺寸。从手机到平板电脑。
  • Fragment 是一个独立的模块,紧紧地与 activity 绑定在一起。可以运行中动态地移除、加入、交换等。
  • Fragment 提供一个新的方式让你在不同的安卓设备上统一你的 UI。
  • Fragment 解决 Activity 间的切换不流畅,轻量切换。
  • Fragment 替代 TabActivity 做导航,性能更好。
  • Fragment 在 4.2.版本中新增嵌套 fragment 使用方法,能够生成更好的界面效果。

3. Fragment 如何实现类似 Activity 栈的压栈和出栈效果

Fragment 的事物管理器内部维持了一个双向链表结构,该结构可以记录我们每次 add 的Fragment 和 replace 的 Fragment,然后当我们点击 back 按钮的时候会自动帮我们实现退栈操作。

4. Fragment 的 replace 和 add 方法的区别

Fragment 本身并没有 replace 和 add 方法,这里的理解应该为使用 FragmentManager 的 replace和 add 两种方法切换 Fragment 时有什么不同。

我们经常使用的一个架构就是通过 RadioGroup 切换 Fragment,每个 Fragment 就是一个功能模块。

Fragment 的容器一个 FrameLayout,add 的时候是把所有的 Fragment 一层一层的叠加到了FrameLayout 上了,而 replace 的话首先将该容器中的其他 Fragment 去除掉然后将当前 Fragment添加到容器中。

一个 Fragment 容器中只能添加一个 Fragment 种类,如果多次添加则会报异常,导致程序终止,而 replace 则无所谓,随便切换。

因为通过 add 的方法添加的 Fragment,每个 Fragment 只能添加一次,因此如果要想达到切换效果需要通过 Fragment 的的 hide 和 show 方法结合者使用。将要显示的 show 出来,将其他 hide起来。这个过程 Fragment 的生命周期没有变化。通过 replace 切换 Fragment,每次都会执行上一个 Fragment 的 onDestroyView,新 Fragment的 onCreateView、onStart、onResume 方法。

基于以上不同的特点我们在使用的使用一定要结合着生命周期操作我们的视图和数据。

5. Fragment与Activity之间是如何传值的

  • Activity向Fragment传值:

将要传的值,放到bundle对象里; 在Activity中创建该Fragment的对象fragment,

通过调用 fragment.setArguments()传递到fragment中; 在该Fragment中通过调用getArguments()得到bundle对象,就能得到里面的值。

  • Fragment向Activity传值:

在Activity中调用getFragmentManager()得到fragmentManager,,调用findFragmentByTag(tag)或者通过findFragmentById(id) FragmentManager fragmentManager = getFragmentManager(); Fragment fragment = fragmentManager.findFragmentByTag(tag);

通过回调的方式,定义一个接口(可以在 Fragment 类中定义),接口中有一个空的方法,在 fragment 中需要的时候调用接口的方法,值可以作为参数放在这个方法中,然后让 Activity 实现这个接口,必然会重写这个方法,这样值就传到了 Activity 中。

6. Fragment生命周期

  • onAttach(Contextcontext):在 Fragment 和 Activity 关联上的时候调用,且仅调用一次。在该回调中我们可以将 context 转化为 Activity 保存下来,从而避免后期频繁调用getAtivity() 获取 Activity 的局面,避免了在某些情况下 getAtivity() 为空的异常(Activity和 Fragment 分离的情况下)。同时也可以在该回调中将传入的Arguments提取并解析,在这里强烈推荐通过setArguments给Fragment传参数,因为在应用被系统回收时Fragment不会保存相关属性。

  • onCreate:在最初创建Fragment的时候会调用,和Activity的onCreate类似。

  • View onCreateView(LayoutInflater inflater, ViewGroup container,Bundle savedInstanceState):在准备绘制Fragment界面时调用,返回值为Fragment要绘制布局的根视图,当然也可以返回null。注意使用inflater构建View时一定要将attachToRoot指明false,因为Fragment会自动将视图添加到container中,attachToRoot为true会重复添加报错。onCreateView并不是一定会被调用,当添加的是没有界面的Fragment就不会调用,比如调用FragmentTransaction的add(Fragment fragment, String tag)方法。

  • onActivityCreated :在 Activity 的 onCreated 执行完时会调用。

  • onStart() :Fragment对用户可见的时候调用,前提是 Activity 已经 started。

  • onResume():Fragment和用户之前可交互时会调用,前提是Activity已经resumed。

  • onPause():Fragment和用户之前不可交互时会调用。

  • onStop():Fragment不可见时会调用。

  • onDestroyView():在移除Fragment相关视图层级时调用。

  • onDestroy():最终清楚Fragment状态时会调用。

  • onDetach():Fragment和Activity解除关联时调用。

7. ViewPager对Fragment生命周期的影响

ViewPager+Fragment 是比较常见的组合了,一般搭配ViewPager的FragmentPagerAdapter 或 FragmentStatePagerAdapter 使用。不过 ViewPager 为了防止滑动出现卡顿,有一个缓存机制,默认情况下 ViewPager 会创建并缓存当前页面左右两边的页面(如Fragment)。此时左右两个 Fragment 都会执行从 onAttach->….->onResume 的生命周期,明明 Fragment 没有显示却已经到onResume 了,在某些情况下会出现问题。比如数据的加载时机、判断 Fragment 是否可见等。


收起阅读 »

Android 点击响应时间

Android 用户希望应用能够在短时间内响应他们的操作。UX 研究告诉我们,响应时间短于 100 毫秒会让人感觉立竿见影,而超过 1 秒的响应时间会让用户失去注意力。 当响应时间接近 10 秒时,用户只需放弃他们的任务。测量用户操作响应时间对于确保良好的用户...
继续阅读 »

Android 用户希望应用能够在短时间内响应他们的操作。

UX 研究告诉我们,响应时间短于 100 毫秒会让人感觉立竿见影,而超过 1 秒的响应时间会让用户失去注意力。 当响应时间接近 10 秒时,用户只需放弃他们的任务。

测量用户操作响应时间对于确保良好的用户体验至关重要。 点击是应用程序必须响应的最常见的操作。 我们可以测量 Tap 响应时间吗?

Tap Response Time 是从用户按下按钮到应用程序对点击做出明显反应的时间。

更准确地说,它是从手指离开触摸屏到显示器呈现出对该点击具有可见反应的帧(例如导航动画的开始)的时间。 Tap Response Time 不包括任何动画时间。

Naive Tap 响应时间

我打开了 Navigation Advanced Sample 项目并添加了一个对 measureTimeMillis() 的调用来测量点击 about 按钮时的 Tap Response Time。

aboutButton.setOnClickListener {
val tapResponseTimeMs = measureTimeMillis {
findNavController().navigate(R.id.action_title_to_about)
}
PerfAnalytics.logTapResponseTime(tapResponseTimeMs)
}

这种方法存在几个缺点:

它可以返回负时间。

它不会随着代码库的大小而扩展。

没有考虑从手指离开触摸屏到点击监听器被调用的时间。

它没有考虑从我们完成调用 NavController.navigate() 到显示渲染一个新屏幕可见的帧的时间。

负时间

measureTimeMillis() 调用 System.currentTimeMillis() 可以由用户或电话网络设置,因此时间可能会不可预测地向后或向前跳跃。 经过的时间测量不应使用 System.currentTimeMillis()

大型代码库

为每一个有意义的点击监听器添加测量代码是一项艰巨的任务。 我们需要一个可随代码库大小扩展的解决方案,这意味着我们需要中央钩子来检测何时触发了有意义的操作。

触摸流水线

当手指离开触摸屏时,会发生以下情况:

  • system_server 进程接收来自触摸屏的信息并确定哪个窗口应该接收 MotionEvent.UP 触摸事件。(每个窗口都与一个输入事件套接字对相关联:第一个套接字由 system_server 拥有以发送输入事件。 第一个套接字与创建窗口的应用程序拥有的第二个套接字配对,以接收输入事件。)

  • system_server 进程将触摸事件发送到目标窗口的输入事件套接字。

  • 该应用程序在其侦听套接字上接收触摸事件,将其存储在一个队列 (ViewRootImpl.QueuedInputEvent) 中,并安排一个 Choreographer 框架来使用输入事件。(system_server 进程检测输入事件何时在队列中停留超过 5 秒,此时它知道它应该显示应用程序无响应 (ANR) 对话框。)

  • 当 Choreographer 框架触发时,触摸事件被分派到窗口的根视图,然后通过其视图层次结构分派它。

  • 被点击的视图接收 MotionEvent.UP 触摸事件并发布一个单击侦听器回调。 这允许在单击操作开始之前更新视图的其他视觉状态。

  • 最后,当主线程运行发布回调时,将调用视图单击侦听器。

从手指离开触摸屏到调用单击侦听器时发生了很多事情。 每个运动事件都包括事件发生的时间 (MotionEvent.getEventTime())。 如果我们可以访问导致点击的 MotionEvent.UP 事件,我们就可以测量 Tap Response Time 的真正开始时间。

遍历和渲染

findNavController().navigate(R.id.action_title_to_about)
  • 在大多数应用程序中,上述代码启动片段事务。 该事务可能是立即的(commitNow())或发布的(commit())。

  • 当事务执行时,视图层次结构会更新并安排布局遍历。

  • 当布局遍历执行时,一个新的帧被绘制到一个表面上。

  • 然后它与来自其他窗口的帧合成并发送到显示器。

理想情况下,我们希望确切知道视图层次结构的更改何时在显示器上真正可见。 不幸的是,据我所知,没有 Java API,所以我们必须要有创意。

从点击到渲染

Main thread tracing

为了弄清楚这一点,我们在单击按钮时启用 Java 方法跟踪。

  1. MotionEvent.ACTION_UP 事件被调度,一个点击被发送到主线程。

  2. 发布的点击运行,点击侦听器调用 NavController.navigate() 并将片段事务发布到主线程。

  3. 片段事务运行,视图层次结构更新,并在主线程上为下一帧安排视图遍历。

  4. 视图遍历运行,视图层次结构被测量、布局和绘制。

Systrace

在步骤 4 中,视图遍历绘制通道生成绘制命令列表(称为显示列表)并将该绘制命令列表发送到渲染线程。

第 5 步:渲染线程优化显示列表,添加波纹等效果,然后利用 GPU 运行绘图命令并绘制到缓冲区(OpenGL 表面)。 完成后,渲染线程告诉表面抛掷器(位于单独的进程中)交换缓冲区并将其放在显示器上。

第6步(在systrace截图中不可见):所有可见窗口的表面由surface flinger和hardware composer合成,并将结果发送到显示器。

点击响应时间

我们之前将 Tap Response Time 定义为从用户按下按钮到应用对点击做出明显反应的时间。 换句话说,我们需要测量经过步骤 1 到 6 的总持续时间。

第 1 步:向上调度

我们定义了 TapTracker,一个触摸事件拦截器。 TapTracker 存储上次 MotionEvent.ACTION_UP 触摸事件的时间。 当发布的点击监听器触发时,我们通过调用 TapTracker.currentTap 来检索触发它的 up 事件的时间:

object TapTracker : TouchEventInterceptor {

var currentTap: TapResponseTime.Builder? = null
private set

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

override fun intercept(
motionEvent: MotionEvent,
dispatch: (MotionEvent) -> DispatchState
): DispatchState {
val isActionUp = motionEvent.action == MotionEvent.ACTION_UP
if (isActionUp) {
val tapUptimeMillis = motionEvent.eventTime
// Set currentTap right before the click listener fires
handler.post {
TapTracker.currentTap = TapResponseTime.Builder(
tapUptimeMillis = tapUptimeMillis
)
}
}
// Dispatching posts the click listener.
val dispatchState = dispatch(motionEvent)

if (isActionUp) {
// Clear currentTap right after the click listener fires
handler.post {
currentTap = null
}
}
return dispatchState
}
}

然后我们将 TapTracker 拦截器添加到每个新窗口:

class ExampleApplication : Application() {

override fun onCreate() {
super.onCreate()

Curtains.onRootViewsChangedListeners +=
OnRootViewAddedListener { view ->
view.phoneWindow?.let { window ->
if (view.windowAttachCount == 0) {
window.touchEventInterceptors += TapTracker
}
}
}
}
}

第 2 步:单击侦听器和导航

让我们定义一个 ActionTracker,当发布的点击监听器触发时,它会被调用:

object ActionTracker {
fun reportTapAction(actionName: String) {
val currentTap = TapTracker.currentTap
if (currentTap != null) {
// to be continued...
}
}
}

以下是我们如何利用它:

aboutButton.setOnClickListener {
findNavController().navigate(R.id.action_title_to_about)
ActionTracker.reportTapAction("About")
}

但是,我们不想将该代码添加到每个点击侦听器中。 相反,我们可以向 NavController 添加目标侦听器:

navController.addOnDestinationChangedListener { _, dest, _ ->
ActionTracker.reportTapAction(dest.label.toString())
}

我们可以为每个选项卡添加一个目标侦听器。 或者我们可以利用生命周期回调向每个新的 NavHostFragment 实例添加目标侦听器:

class GlobalNavHostDestinationChangedListener
: ActivityLifecycleCallbacks {

override fun onActivityCreated(
activity: Activity,
savedInstanceState: Bundle?
) {
if (activity is FragmentActivity) {
registerFragmentCreation(activity)
}
}

private fun registerFragmentCreation(activity: FragmentActivity) {
val fm = activity.supportFragmentManager
fm.registerFragmentLifecycleCallbacks(
object : FragmentLifecycleCallbacks() {
override fun onFragmentCreated(
fm: FragmentManager,
fragment: Fragment,
savedInstanceState: Bundle?
) {
if (fragment is NavHostFragment) {
registerDestinationChange(fragment)
}
}
}, true
)
}

private fun registerDestinationChange(fragment: NavHostFragment) {
val navController = fragment.navController
navController.addOnDestinationChangedListener { _, dest, _ ->
val actionName = dest.label.toString()
ActionTracker.reportTapAction(actionName)
}
}

第三步:片段执行

调用 NavController.navigate() 不会立即更新视图层次结构。 相反,一个片段事务被发布到主线程。 当片段事务执行时,将创建并附加目标片段的视图。 由于所有挂起的片段事务都是一次性执行的,因此我们添加了自己的自定义事务以利用 runOnCommit() 回调。 让我们首先构建一个实用程序 OnTxCommitFragmentViewUpdateRunner.runOnViewsUpdated():

class OnTxCommitFragmentViewUpdateRunner(
private val fragment: Fragment
) {
fun runOnViewsUpdated(block: (View) -> Unit) {
val fm = fragment.parentFragmentManager
val transaction = fm.beginTransaction()
transaction.runOnCommit {
block(fragment.view!!)
}.commit()
}
}

然后我们将一个实例传递给 ActionTracker.reportTapAction():

class GlobalNavHostDestinationChangedListener
...
val navController = fragment.navController
navController.addOnDestinationChangedListener { _, dest, _ ->
val actionName = dest.label.toString()
- ActionTracker.reportTapAction(actionName)
+ ActionTracker.reportTapAction(
+ actionName,
+ OnTxCommitFragmentViewUpdateRunner(fragment)
+ )
}
}
}
 object ActionTracker {
- fun reportTapAction(actionName: String) {
+ fun reportTapAction(
+ actionName: String,
+ viewUpdateRunner: OnTxCommitFragmentViewUpdateRunner
+ ) {
val currentTap = TapTracker.currentTap
if (currentTap != null) {
- // to be continued...
+ viewUpdateRunner.runOnViewsUpdated { view ->
+ // to be continued...
+ }
}
}
}

第 4 步:帧和视图层次遍历

当片段事务执行时,会为下一帧安排一次视图遍历,我们使用 Choreographer.postFrameCallback() 将其挂钩:

object ActionTracker {
+
+ // Debounce multiple calls until the next frame
+ private var actionInFlight: Boolean = false
+
fun reportTapAction(
actionName: String,
viewUpdateRunner: OnTxCommitFragmentViewUpdateRunner
) {
val currentTap = TapTracker.currentTap
- if (currentTap != null) {
+ if (!actionInFlight & currentTap != null) {
+ actionInFlight = true
viewUpdateRunner.runOnViewsUpdated { view ->
- // to be continued...
+ val choreographer = Choreographer.getInstance()
+ choreographer.postFrameCallback { frameTimeNanos ->
+ actionInFlight = false
+ // to be continued...
+ }
}
}
}
}

第 5 步:渲染线程

视图遍历完成后,主线程将显示列表发送到渲染线程。 渲染线程执行额外的工作,然后告诉表面flinger交换缓冲区并将其放在显示器上。 我们注册一个 OnFrameMetricsAvailableListener 来获取总帧持续时间(包括在渲染线程上花费的时间):

 object ActionTracker {
...
val choreographer = Choreographer.getInstance()
choreographer.postFrameCallback { frameTimeNanos ->
actionInFlight = false
- // to be continued...
+ val callback: (FrameMetrics) -> Unit = { frameMetrics ->
+ logTapResponseTime(currentTap, frameMetrics)
+ }
+ view.phoneWindow!!.addOnFrameMetricsAvailableListener(
+ CurrentFrameMetricsListener(frameTimeNanos, callback),
+ frameMetricsHandler
+ )
}
}
}
}
+
+ private fun logTapResponseTime(
+ currentTap: TapResponseTime.Builder,
+ fM: FrameMetrics
+ ) {
+ // to be continued...
+ }

一旦我们有了帧指标,我们就可以确定帧缓冲区何时被交换,因此是 Tap 响应时间,即从 MotionEvent.ACTION_UP 到缓冲区交换的时间:

object ActionTracker {
...
currentTap: TapResponseTime.Builder,
fM: FrameMetrics
) {
- // to be continued...
+ val tap = currentTap.tapUptimeMillis
+ val intendedVsync = fM.getMetric(INTENDED_VSYNC_TIMESTAMP)
+ // TOTAL_DURATION is the duration from the intended vsync
+ // time, not the actual vsync time.
+ val frameDuration = fM.getMetric(TOTAL_DURATION)
+ val bufferSwap = (intendedVsync + frameDuration) / 1_000_000
+ Log.d("TapResponseTime", "${bufferSwap-tap} ms")
}
}

SurfaceFlinger

没有 Java API 来确定合成帧何时最终由 SurfaceFlinger 发送到显示器,因此我没有包含该部分。


收起阅读 »

iOS swiftUI 视图动画和转场 1.1

第二节 把视图的状态改态转化成动画效果已经学会了给单个视图添加动画的方法,现在可以学习怎么在视图的状态发生改变时添加动画效果。当用户点击按钮时会切换showDetail状态的值,在视图变化过程中添加动画效果。步骤1 把showDetail.toggl...
继续阅读 »

第二节 把视图的状态改态转化成动画效果

已经学会了给单个视图添加动画的方法,现在可以学习怎么在视图的状态发生改变时添加动画效果。当用户点击按钮时会切换showDetail状态的值,在视图变化过程中添加动画效果。

state change

步骤1 把showDetail.toggle()包裹在withAnimation函数调用块中。showDetail的改变影响了视图HikeDetail和详情切换按钮,在显示/隐藏详情的过程中都有了过滤动画效果。

with_animation block

放慢动画速度,可以观察SwiftUI动画在被中断下是怎么运作的

步骤2 给withAnimation传入一个时长4秒的基本动画参数.easeInOut(duration:4),可以指定动画过程时长,给withAnimation传入的动画参数与.animation(_:)修改器可用参数一致。

with animation duration block

步骤3 在动画过程进行中点击按钮切换视图状态,查看对应的动画被中断时的效果

with animation interrupt

步骤4 读下一节之前,把动画时长参数(.easeInOut(duration: 4))去掉,让动画不再缓慢进行。

第三节 定制视图转场动画

默值情况下,视图离屏和入屏时的动画效果是渐隐/渐现, 这个默认的转场效果可以使用transition(_:)修改器进行定制。

transitions

步骤1 给HikeView视图添加transition(_:)修改器,并定制转场参数为.slide,转场动画为滑入/滑出

transition slide

步骤2 可以把滑入/滑出这种转场动画封装起来,方便其它视图复用同样的转场效果

custom transition effect

步骤3 在moveAndFade转场效果的定义中使用move(edge:),让滑入/滑出从屏幕的同一边进行

move and fade custom

步骤4 使用asymmetric(insertion:removal:)修改器来定制视图显示/消失时的转场动画效果

custom move and fade slide scale

第四节 组合复杂的动画效果

点击图表下面的三个按钮,会在三个不同的数据集间进行切换并展示。本节中会使用组合动画,让图表在不同数据集间切换时的转换动画流畅自然。

combine animation

步骤1 把showDetail的默认值改为true,并把HikeView的预览模式视图固定在画布上。这样可以在编辑其它文件时,依然看到动画效果的变化。

pin canvas

步骤2 在HikeGraph.swift中定义了一个新的波动动画,并把它与滑入/滑出动画一起应用到图表视图上。

hike graph ripple

步骤3 把动画切换为弹簧动画(spring),并设置弹簧阻尼系数为0.5,动画过程中产生了逐渐回弹效果

spring animation

步骤4 加速弹簧动画的执行速度,缩短切换图表的时间

spring animation speed

步骤5 以当条形在图表中的位置为参数,添加延迟效果,图表中的每个条形会顺序动起来

spring animation index based delay

步骤6 观察一下自定义波动(rippling)效果是怎么作用在视图转场中的

检查是否理解

问题1 怎样从一串动画效果调用中,去掉其中的一种动画效果。以下面的代码为例,怎样去掉旋转动画

problem 1

  • a1
  • a2
  • a3

问题2 当你开发动画的过程上,为什么要把预览视图固定在画布上?

  •  为了固定动画过程中的当前帧
  •  为了在多个设备配置开发中预览动画效果
  •  为了在切换到其它不同文件时,固定显示当前视图的预览

问题3 在视图状态改变时,如何快速测试一个动画在被中断时的表现

  •  在包含animation(_:)修改器的代码行上打一个断点,然后单步按动画帧进行测试
  •  调整动画的持续时长,让动画在足够长的时间内完成,这样就可以调整动画的细节
  •  重复的调用sleep(100)来减慢动画的执行
收起阅读 »

iOS SwiftUI 视图动画和转场

视图动画和转场使用SwiftUI可以把视图状态的改变转成动画过程,SwiftUI会处理所有复杂的动画细节在这篇中,会给跟踪用户徒步的图表视图添加动画。使用animation(_:)修改器给一个视图添加动画效果非常容易下载起步项目并跟着本篇教程一步步实践,或者查...
继续阅读 »

视图动画和转场

使用SwiftUI可以把视图状态的改变转成动画过程,SwiftUI会处理所有复杂的动画细节

在这篇中,会给跟踪用户徒步的图表视图添加动画。使用animation(_:)修改器给一个视图添加动画效果非常容易

下载起步项目并跟着本篇教程一步步实践,或者查看本篇完成状态时的工程代码去学习


第一节 给每个视图单独添加动画

在视图上使用animation(_:)修改器时,SwiftUI会在视图的任何可进行动画的属性发生改变时产生对应的动画效果。视图的颜色、不透明度、旋转角度、大小及一些其它属性都是可进行动画的

animate button

步骤1 在HikeView.swift中,打开实时预览,体验一下图表的打开和隐藏,此时的状态改变时是没有添加动画效果的。在本篇的实践中,保持实时预览一直打开,每一步修改的效果就可以实时的看到

live preview animation

步骤2 给显示/隐藏切换的箭头按钮添加旋转动画,会发现现在按钮点击时的旋转有一个动画过渡的效果了

rotate button animation

rotate button animation video

步骤3 当视图从隐藏到展示时,让切换按钮变大1.5倍

rotate button scale

rotate button scale video

步骤4 把动画的类型从easeInOut改为spring()。SwiftUI包含一些预设或可自定义的动画类型,像弹簧(spring)动画和类型液体(fluid)动画类型。可以调整动画开始前的等待时长、动画的速度也可以指定让动画循环重复的进行

rotate button spring

步骤5 如果只想让按钮具有缩放动画而不进行旋转动画,可以在scaleEffect添加animation(nil)来实现。可以在这里做一些实验,如果把其它的一些动画效果结合在一起,会怎么样

rotate button no rotate

步骤6 学下一节之前,把本节中添加的animation(_:)修改器都去掉

rotate button resume

收起阅读 »

iOS SwiftUI 创建和组合视图 4.2

第三节 绘制徽章符号地标徽章中心有一个以地标App图标中的山峰图形改造形成的标志。山峰这个符号由两个形状组成,一个是表示山顶被雪覆盖的部分,另一个是山体。这里会使用有一定间距的两个局部三角形形状绘制这个徽章符号步骤1 把之前的徽章视图形状抽出来单独形...
继续阅读 »

第三节 绘制徽章符号

地标徽章中心有一个以地标App图标中的山峰图形改造形成的标志。山峰这个符号由两个形状组成,一个是表示山顶被雪覆盖的部分,另一个是山体。这里会使用有一定间距的两个局部三角形形状绘制这个徽章符号

badge symbol

步骤1 把之前的徽章视图形状抽出来单独形成一个BadgeBackground视图,并生成一个新的视图文件BadgeBackground.swift

badge background

步骤2 把BadgeBackground放在Badgebody属性中。

refactor badge

步骤3 创建名为BadgeSymbol的自定义视图,这个视图是一个山峰的形状,把这个形状复制多次并按一定角度旋转多次拼成一个徽章的图案

badge symbol

步骤4 使用pathAPI来绘制徽章符号的上半部分,试着调节spacingtopWidthtopHeight的系数,观察这些系数是怎么影响图形绘制的结果的

badge symbol top

步骤5 绘制徽章图案的下半部分,使用move(to:)把绘图光标移到另一个图形绘制的起点,绘制新的形状

badge symbol bottom

步骤6 用紫色填充徽章符号

badge symbol fill

第四节 组合徽章的前景符号和背景形状

徽章设计思路是在背景形状上面再绘制多个有固定旋转角度的山峰符号。定义一个新的类型用于展示旋转一定角度的徽章符号,使用ForEach生成不同旋转角度的山峰符号,绘制在徽章背景上,从而形成最终的徽章。

badge combine

步骤1 创建RotatedBadgeSymbol视图封装旋转徽章符号,调整旋转的角度,并在预览视图中查看效果

badge symbol rotate 4

步骤2 在Badge.swift中,使用ZStack把徽章图标放在徽章背景层上面。此时会发现,徽章符号的尺寸相比徽章背景大了许多,这不符合最初设计的预期

badge symbols

步骤3 缩放符号尺寸到合适的大小

badge geometry scale

步骤4 使用ForEach复制多个徽章图标,按360度周解均分,每一个徽章符号都比前一个多旋转45度,这种就会形成一个类似太阳和徽章图标

badge symbol completed

检查是否理解

问题1 GeometryReader的作用是什么?

  •  GeometryReader可以把父视图分割成网格,便于在屏幕上布局视图
  •  GeometryReader可以动态的绘制、定位、缩放视图,不需要写死它们的尺寸。这样可以在不同尺寸的屏幕上复用已经写好的视图
  •  使用GeometryReader可以自动识别应用视图层级上形状的类型和位置,例如: (圆)Circle

问题2 下面代码段布局后是哪一个图?

problem 2

  • answer 1
  • answer 2
  • answer 3

问题3 下面代码绘制出哪个图?

problem 3

  • answer 1
  • answer 2
  • answer 3
收起阅读 »

iOS SwiftUI 创建和组合视图 4.1

绘制和动画学习绘制形状和路径,并创建徽章和添加动画包含章节绘制路径和形状视图动画和转场绘制路径和形状用户在浏览完一个地标后会得到一个徽章。但用户要得到徽章首先要先要创建一个徽章。本篇教程就是使用路径和形状创建徽章的过程,创建的徽章可以和其它图形组合形成位置标志...
继续阅读 »

绘制和动画

学习绘制形状和路径,并创建徽章和添加动画

drawing and animation

包含章节

  • 绘制路径和形状
  • 视图动画和转场
  • 绘制路径和形状

    用户在浏览完一个地标后会得到一个徽章。但用户要得到徽章首先要先要创建一个徽章。本篇教程就是使用路径和形状创建徽章的过程,创建的徽章可以和其它图形组合形成位置标志。

    如果想要针对不同种类的地标创建不同的徽章,可以尝试改变徽章基本组成符号的重复次数、角度或大小。

    跟着教程一步步走,可以下载工程文件进行实践。


    第一节 创建徽章视图

    创建徽章前需要使用SwiftUI的矢量绘画API创建一个徽章视图

    badge

    步骤1 选择文件->新建->文件,然后从iOS文件模板列表中选择SwiftUI View。点击下一步(Next),输入文件名Badge后点击创建(Create)

    create file

    name file

    步骤2 调整Badge视图,暂时先让它显示"Badge"文本,一会儿再绘制徽章的形状

    badge text

    第二节 绘制徽章背景

    使用SwiftUI的图形API绘制一个徽章形状

    badge background

    步骤1 查看在文件HexagonParameters.swift中的代码。HexagonParameters结构体定义了绘制徽章六边形形状的控制点参数。不需要修改这些绘制相关的数据,仅仅使用这些数据指定绘制徽章形状时,线段和曲线的控制点位置。

    hexagonal data

    步骤2 在Badge.swift文件中,绘制徽章的形状并使用fill修改器给六边形填充颜色,形成一个视图。使用路径可以把多条直线、曲线或其它绘制形状的基本笔划连成一个复杂的图形,就像形成徽章六边形背景这样.

    Path

    步骤3 给路径添加起点,move(to:)方法可以把绘图光标移动到绘图中的一点,准备绘制的起点

    path start point

    步骤4 使用六边形的绘制参数数据HexagonParameters,依次绘制六边形的边,形成大致轮廓.addLine(to:)方法会使用当前绘图光标所在点为起点,方法参数中指定的点为终点绘制直线。目前六边形看起来有点问题,不过不要担心,这是意料中的事,下面的步骤做完,六边形的形状就会和开头显示的徽章的六边形形状一致了

    path fill

    步骤5 使用addQuadCurve(to:control:)方法绘制贝塞尔曲线,让六边形的角变的更圆润些。

    badge hexagonal

    步骤6 把徽章路径包裹在一个Geometry Reader中,这样徽章可以使用容器的大小,定义自己绘制的尺寸,这样就不需要硬编码绘制尺寸了(100)。当绘制区域不是正方形时,使用绘制区域的最小边长(长宽中哪个最小使用哪个)作为绘制徽章背景的边长,并保持徽章背景的长宽比为1:1

    geometry reader

    步骤7 使用xScalexOffset参数调整变量,把徽章几何绘图区域居中绘制出来

    badge square

    步骤8 把黑色实心填充色改为渐变色,使徽章看上去和开始设计的样式一致

    badge gradient

    步骤9 渐变色上再使用aspectRatio(_:contentMode:)修改器,让渐变色按内容宽高比进行成比例渐变填充。保持1:1的长宽比,徽章背景可以保持居中在徽章视图中,不管徽章视图本身是不是正方形

    badge center

收起阅读 »

学不好Lambda,能学好Kotlin吗

嗯,当然 不能 进入正题,Kotlin中,高阶函数的身影无处不在,听上去高端大气上档次的高阶函数,简化一点讲,其实就是Lambda + 函数。 如果,Lambda学不好,就会导致高阶函数学不好,就会导致协程等等一系列的Kotlin核心学不好,Kotlin自然就...
继续阅读 »

嗯,当然


不能


进入正题,Kotlin中,高阶函数的身影无处不在,听上去高端大气上档次的高阶函数,简化一点讲,其实就是Lambda + 函数。


如果,Lambda学不好,就会导致高阶函数学不好,就会导致协程等等一系列的Kotlin核心学不好,Kotlin自然就一知半解了。所以,下面,一起来学习吧。


开始一个稍微复杂一点的实现


需求如下:传入一个参数,打印该参数,并且返回该参数


分析


乍看需求,这还不简单,一个print加一个return不就完事了,但是如果用Lambda,该怎么写呢?


val myPrint = { str: String ->
print("str is $str")
str
}


  • 这里划第一个重点,Lambda的最后一行作为返回值输出。此时,如果直接打印myPrint,是可以直接输出的


fun main() {
println(myPrint("this is kotlin"))
}

image.png


结果和预想一致。如果对这种函数的写法结构有什么疑惑的,可以查看juejin.cn/post/701173…


String.()


一脸懵逼,这是啥玩意?(此处应有表情 尼克杨问号脸)


先写个例子看看


val testStr : String.() -> Unit = {
print(this)
}


  • 官方一点解释,在.和()之间没有函数名,所以这是给String增加了一个匿名的扩展函数,这个函数的功能是打印String。在括号内,也就是Lambda体中,会持有String本身,也就是this。怎么调用呢?如下:


fun main() {
"hello kotlin".testStr()
}
// 执行结果:hello kotlin


  • 此外这里还有一个重点:扩展函数是可以全局调用的

  • 扩展函数有啥用?举个例子,如果对Glide提供的方法不满意,可以直接扩展一个Glide.xxx函数供自己调用,在xxx函数内部,可以取到this,也就是Glide本身。

  • 有兴趣可以看一下Compose的源码,原来扩展函数还可以这么用


终极形态


先看代码


val addInt : Int.(Int) -> String = {
"两数相加的结果是${this + it}"
}

用已有的知识分析一下:



  • Int.():匿名的扩展函数

  • this:当前的Int,也就是调用这个扩展函数的对象

  • "两数相加的结果是${this + it}" : Lambda的最后一行,也就返回值


如何调用


一般有如下两种调用方式:


fun main() {
println(addInt(1,2))
println(1.addInt(2))
}


  • 第二种更加符合规范,之所以可以有第一种写法,是因为this会默认作为第一个参数

  • 此处可以记住一个知识点,扩展了某一个函数,扩展函数内部的this就是被扩展的函数本身


Kotlin函数返回值那些事


在Kotlin函数中,如果不指定函数的返回值类型,则默认为Unit


fun output() {println("helle kotlin")}


  • 上述函数的返回值为Unit类型


当函数体中出现return的时候,则需要手动为函数指定类型


fun output2() : Int {
return 0
}


  • 返回Int类型的0,需要手动指定函数的返回值类型,否则报错


如果是以下的函数,那么返回值为?


fun output3() = {}


  • 此处的返回值为() -> Unit,可省略,写全了,就是如下的样子:


fun output3() : () -> Unit = {}


  • 此处函数返回函数,已经是高阶函数的范畴了


如果函数接着套一个函数呢,比如


fun output4() = run { println("hello kotlin") }


  • 虽说run是一个函数,但是此处的返回值就不是() -> Unit

  • 此处的返回就是run的返回值,但是run是什么?


@kotlin.internal.InlineOnly
public inline fun <R> run(block: () -> R): R {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
return block()
}


  • run的作用就是执行内部的函数,在这里就是println方法。

  • run的返回自是R,也就是泛型,具体一点就是println的返回值,这里println的返回值是Unit,所以可以得出上面的output4的返回值就是Unit。

  • 这里如果不是很懂的话,可以看一个简单一点的例子


fun output5() = run {true}


  • 此处,函数的返回值就是true的类型,Boolen


函数中套一个函数怎么传参呢


刚刚的例子中,知道了怎么写一个函数中套函数,那么其中嵌套得函数怎么传参呢


fun output6() = {a: Int ->  println("this is $a")}


  • a为参数,函数是println,所以output6的返回值类型为(Int) -> Unit

  • 如果需要调用的话,需要这么写:


output6()(1)

最后一个重点:在写Lambda的时候,记住换行


几种函数写法的区别


fun a()


常见的函数


val a = {}


a是一个变量,只不过是一个接受了匿名函数的变量,可以执行这个函数,和第一种现象一致。


这里的a还可以赋值给另一个变量 val a2 = a,但是函数本身不能直接赋给一个变量,可以使用::,让函数本身变成函数的引用


--end---


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

Flutter开发·Stream的理解与简单使用

介绍 Stream和Future都是在Flutter中常用来处理异步事件的对象,与Future只能处理单次异步操作不同的是,Stream具有多次响应异步事件监听的功能,是一系列异步事件的序列。 分类 Stream从订阅模式上分可以分为两类,一个是单订阅模式,另...
继续阅读 »

介绍


Stream和Future都是在Flutter中常用来处理异步事件的对象,与Future只能处理单次异步操作不同的是,Stream具有多次响应异步事件监听的功能,是一系列异步事件的序列。


分类


Stream从订阅模式上分可以分为两类,一个是单订阅模式,另一个是多订阅模式,也称广播模式,单订阅模式表示只能有1个监听器对事件进行监听,即使前面的监听器取消了监听,也无法继续对这个stream进行二次监听,而多订阅模式则可以有多个监听器。


组成


Stream中主要包含了四个对象:



  • Stream:事件源,一般用于事件监听或事件转换等。

  • StreamController: 方便进行Stream管理的控制器。

  • StreamSink: 事件的输入口,包含add等方法进行事件发送。

  • StreamSubscription: Stream进行listen监听后得到的对象,用来管理事件订阅,包含取消监听等方法。


写法


单订阅模式


直接使用StreamController<int>()进行初始化,然后获取到stream对象进行listen监听。当点击按钮时通过获取sink对象,调用add方法进行事件发送,这样listen方法中就可以监听到事件响应。


StreamController<int> singleStreamController = StreamController<int>();

int num = 0;

@override
void initState() {
// TODO: implement initState
super.initState();
singleStreamController.stream.listen((event) {
setState(() {
num = event;
});
});
}

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(),
body: Column(
children: [
Text("the num is $num"),
FlatButton(onPressed: (){
singleStreamController.sink.add(1);
}, child: Text("点击"))
],
),
);
}

在上面的代码中,如果我在点击按钮时再开启一个监听的话,则会报如下错误,这就是单订阅模式的限制。


image.png


多订阅模式(广播模式)


正如广播这个名字一样,Stream也提供了broadcast方法生成可以注册多个监听器的stream。只需要将上面的初始化代码替换成这句话即可。


StreamController<int> singleStreamController = StreamController<int>.broadcast();

当我多次进行stream事件监听时,程序没有出现出任何的错误,这就是多订阅模式。


image.png


注意,在不再使用stream事件监听时要及时调用close和cancel方法进行取消订阅和事件流关闭。


StreamBuilder


上面的例子是点击界面中的按钮改变数值,通过sink发送事件,然后stream监听到数值变化再进行setState进行状态改变。其实流程还是有点绕的,Stream中也提供了可以直接在控件中获取Stream监听的写法就是StreamBuilder。如上面的代码可以写成:


StreamController<int> singleStreamController = StreamController<int>.broadcast();

@override
void initState() {
// TODO: implement initState
super.initState();
}

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(),
body: Column(
children: [
StreamBuilder<Object>(
stream: singleStreamController.stream,
builder: (context, snapshot) {
return Text("the num is ${snapshot.data}");
}
),
FlatButton(onPressed: (){
singleStreamController.sink.add(1);
}, child: Text("点击"))
],
),
);
}

其中,stream参数就是需要进行监听的数据对应的事件流对象,initialData参数是指定当stream还没有存储过数据时的默认值。builder方法中返回需要构建的控件,其中的snapshot参数为数据快照,它的data就是所存储的数据。
这样就可以在控件中监听到数据的变化,无需再调用setState方法。通过指定StreamBuilder中的stream对象,从而实现数据与界面的绑定。


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

Flutter输入框获取剪切板-合规问题踩坑

前言:公司法务部检测出Flutter开发的App存在未同意隐私协议先获取系统剪切板数据的问题,要求整改。经过一系列调试后,定位到原来是Flutter输入框的坑,只要使用到输入框,就会先获取下剪切板数据。还没有属性可以关闭,着实踩坑,以下记录分享给大家,希望能稳...
继续阅读 »

前言:公司法务部检测出Flutter开发的App存在未同意隐私协议先获取系统剪切板数据的问题,要求整改。经过一系列调试后,定位到原来是Flutter输入框的坑,只要使用到输入框,就会先获取下剪切板数据。还没有属性可以关闭,着实踩坑,以下记录分享给大家,希望能稳稳避坑......



合规问题-获取剪切板数据


这个问题首次出现其实是在去年iOS14上线直接把app应用获取剪贴板内容的行为直接暴露出来。2020年6月29日,抖音海外版TikTok因为频繁读取用户剪贴板内容引争议,甚至被作为后面将其驱逐出海外市场的导火索。
国内监管部门虽然并没有明确的对访问剪贴板内容的直接要求,但是随着近年来社会上对隐私保护的重视和媒体关注,接下来会有发酵可能。


获取剪切板内容的应用场景


目前国内剪切板内容主要应用场景是类似淘口令之类的方式,通过读取剪切板的内容,弹出对应的内容;更有甚者,采集用户剪切板数据进行大数据分析,因为用户复制的内容,具备极高的用户兴趣导向,作为大数据训练素材准确性很高。
而Flutter输入框为何也获取剪切板内容,有留意过长按输入框的交互吗? 长按会有toolbar提供粘贴、复制等功能,而粘贴就必须先获取剪切板的内容。
然后基本上App的登录页都有输入框,只要你在用户同意隐私协议之前,显示了Flutter中的TextField,就必然会触发这个潜在的合规问题。 🐶


Flutter输入框是如何获取剪切板数据的


这个问题需要我们一步步来跟踪源码。



  1. 首先看TextField的源码,有一个属性enableInteractiveSelection,可以理解为启用交互式选择。从业务逻辑出发,把这个属性设为false,应该就不会出现toolbar了,那应该不需要获取剪切板数据以提供粘贴功能。


/// text_field.dart
/// TextField的常量构造函数
const TextField({
Key? key,
this.controller,
this.focusNode,
this.decoration = const InputDecoration(),
TextInputType? keyboardType,
this.textInputAction,
this.textCapitalization = TextCapitalization.none,
this.style,
this.strutStyle,
this.textAlign = TextAlign.start,
this.textAlignVertical,
this.textDirection,
this.readOnly = false,
ToolbarOptions? toolbarOptions,
this.showCursor,
this.autofocus = false,
this.obscuringCharacter = '•',
this.obscureText = false,
this.autocorrect = true,
SmartDashesType? smartDashesType,
SmartQuotesType? smartQuotesType,
this.enableSuggestions = true,
this.maxLines = 1,
this.minLines,
this.expands = false,
this.maxLength,
@Deprecated(
'Use maxLengthEnforcement parameter which provides more specific '
'behavior related to the maxLength limit. '
'This feature was deprecated after v1.25.0-5.0.pre.',
)
this.maxLengthEnforced = true,
this.maxLengthEnforcement,
this.onChanged,
this.onEditingComplete,
this.onSubmitted,
this.onAppPrivateCommand,
this.inputFormatters,
this.enabled,
this.cursorWidth = 2.0,
this.cursorHeight,
this.cursorRadius,
this.cursorColor,
this.selectionHeightStyle = ui.BoxHeightStyle.tight,
this.selectionWidthStyle = ui.BoxWidthStyle.tight,
this.keyboardAppearance,
this.scrollPadding = const EdgeInsets.all(20.0),
this.dragStartBehavior = DragStartBehavior.start,
this.enableInteractiveSelection = true // 这个属性
})

/// 确实也是通过这个变量控制交互toolbar的显示与否
class _TextFieldSelectionGestureDetectorBuilder extends TextSelectionGestureDetectorBuilder {
_TextFieldSelectionGestureDetectorBuilder({
required _TextFieldState state,
}) : _state = state,
super(delegate: state);

final _TextFieldState _state;

@override
void onForcePressStart(ForcePressDetails details) {
super.onForcePressStart(details);
if (delegate.selectionEnabled && shouldShowSelectionToolbar) {
editableText.showToolbar();
}
}

@override
void onForcePressEnd(ForcePressDetails details) {
// Not required.
}
// 省略源码 *****
}

通过源码可以知道,TextField的真实渲染对象是editableText,editableText中会判断传入的enableInteractiveSelection,为false不去获取剪切板内容


/// editable_text.dart

bool get selectionEnabled => enableInteractiveSelection;

@override
void didUpdateWidget(EditableText oldWidget) {
super.didUpdateWidget(oldWidget);
// 省略代码*****
if (widget.style != oldWidget.style) {
final TextStyle style = widget.style;
// The _textInputConnection will pick up the new style when it attaches in
// _openInputConnection.
if (_hasInputConnection) {
_textInputConnection!.setStyle(
fontFamily: style.fontFamily,
fontSize: style.fontSize,
fontWeight: style.fontWeight,
textDirection: _textDirection,
textAlign: widget.textAlign,
);
}
}
// selectionEnabled即enableInteractiveSelection,
// 为false不调用update()。update方法后面会讲到,其实就是这个方法在获取剪切板内容
if (widget.selectionEnabled && pasteEnabled && widget.selectionControls?.canPaste(this) == true) {
_clipboardStatus?.update();
}
}

到这里,一切都很顺利,因为业务不需要启用交互,那么Flutter就没理由随意获取剪切板数据。然而坑就出在这里,即便enableInteractiveSelection设置为false,Flutter还是在另一个地方获取了剪切板内容,而且没有属性可配置!!!🔥
我们来到EditableTextState类,里面有_clipboardStatus私有变量,监听系统剪切板变化的变量,通过ValueNotifier进行通知。


/// editable_text.dart
void _onChangedClipboardStatus() {
setState(() {
// Inform the widget that the value of clipboardStatus has changed.
});
}

// State lifecycle:

@override
void initState() {
super.initState();
_clipboardStatus?.addListener(_onChangedClipboardStatus);
widget.controller.addListener(_didChangeTextEditingValue);
_focusAttachment = widget.focusNode.attach(context);
widget.focusNode.addListener(_handleFocusChanged);
_scrollController = widget.scrollController ?? ScrollController();
_scrollController!.addListener(() { _selectionOverlay?.updateForScroll(); });
_cursorBlinkOpacityController = AnimationController(vsync: this, duration: _fadeDuration);
_cursorBlinkOpacityController.addListener(_onCursorColorTick);
_floatingCursorResetController = AnimationController(vsync: this);
_floatingCursorResetController.addListener(_onFloatingCursorResetTick);
_cursorVisibilityNotifier.value = widget.showCursor;
}

initState是必定要走addListener方法的,而addListener里面就自动调用了前面的_clipboardStatus.update()方法,读取了剪切板内容


/// text_selection.dart
@override
void addListener(VoidCallback listener) {
if (!hasListeners) {
WidgetsBinding.instance!.addObserver(this);
}
if (value == ClipboardStatus.unknown) {
update();
}
super.addListener(listener);
}

/// Check the [Clipboard] and update [value] if needed.
Future<void> update() async {
// iOS 14 added a notification that appears when an app accesses the
// clipboard. To avoid the notification, don't access the clipboard on iOS,
// and instead always show the paste button, even when the clipboard is
// empty.
// TODO(justinmc): Use the new iOS 14 clipboard API method hasStrings that
// won't trigger the notification.
// https://github.com/flutter/flutter/issues/60145
switch (defaultTargetPlatform) {
case TargetPlatform.iOS:
value = ClipboardStatus.pasteable;
return;
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.macOS:
case TargetPlatform.windows:
break;
}

ClipboardData? data;
try {
// 这里获取了剪切板数据
data = await Clipboard.getData(Clipboard.kTextPlain);
} catch (stacktrace) {
// In the case of an error from the Clipboard API, set the value to
// unknown so that it will try to update again later.
if (_disposed || value == ClipboardStatus.unknown) {
return;
}
value = ClipboardStatus.unknown;
return;
}

final ClipboardStatus clipboardStatus = data != null && data.text != null && data.text!.isNotEmpty
? ClipboardStatus.pasteable
: ClipboardStatus.notPasteable;
if (_disposed || clipboardStatus == value) {
return;
}
value = clipboardStatus;
}

解析完毕,坑的原因找出来了,但是填坑却没那么简单!


如何避坑


既然源码实现如此,要改只能改源码,但我并不建议这么改,改源码对于协同开发很不友好。



  1. 当用户禁用了交互,且合规问题暴露出来,我们认为官方势必要解决这个问题,于是我先给官方提了issue

  2. 合规规定同意用户协议后,才能获取剪切板行为,那么我们完全可以从流程去避开这个问题:


用户未同意协议前,不要进入到带有输入框的页面;现在很多app也是这样做的,未同意协议就停留在闪屏页吧,能省好多事;
② 流程实在难改,就把输入框先换成普通的Container,同意后再换成textField就可以啦。


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

【辨析】Compose 完全脱离 View 系统了吗?

前言 Compose正式发布1.0已经相当一段时间了,但相信很多同学对Compose还是有很多迷惑的地方 Compose跟原生的View到底是什么关系?是跟Flutter一样完全基于Skia引擎渲染,还是说还是View的那老一套? 相信很多同学都会有下面的疑问...
继续阅读 »

前言


Compose正式发布1.0已经相当一段时间了,但相信很多同学对Compose还是有很多迷惑的地方

Compose跟原生的View到底是什么关系?是跟Flutter一样完全基于Skia引擎渲染,还是说还是View的那老一套?

相信很多同学都会有下面的疑问




下面我们就一起来看下下面这个问题


现象分析


我们先看这样一个简单布局


class TestActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
setContent {
ComposeBody()
}
}
}

@Composable
fun ComposeBody() {
Column {
Text(text = "这是一行测试数据", color = Color.Black, style = MaterialTheme.typography.h6)
Row() {
Text(text = "测试数据1!", color = Color.Black, style = MaterialTheme.typography.h6)
Text(text = "测试数据2!", color = Color.Black, style = MaterialTheme.typography.h6)
}
}
}

如上所示,就是一个简单的布局,包含Column,RowText

然后我们打开开发者选项中的显示布局边界,效果如下图所示:




我们可以看到Compose的组件显示了布局边界,我们知道,FlutterWebView H5内的组件都是不会显示布局边界的,难道Compose的布局渲染其实还是View的那一套?


我们下面再在onResume时尝试遍历一下View的层级,看一下Compose到底会不会转化成View


    override fun onResume() {
super.onResume()
window.decorView.postDelayed({
(window.decorView as? ViewGroup)?.let { transverse(it, 1) }
}, 2000)
}

private fun transverse(view: View, index: Int) {
Log.e("debug", "第${index}层:" + view)
if (view is ViewGroup) {
view.children.forEach { transverse(it, index + 1) }
}
}

通过以上方式打印页面的层级,输出结果如下:


E/debug: 第1层:DecorView@c2f703f[RallyActivity]
E/debug: 第2层:android.widget.LinearLayout{4202d0c V.E...... ........ 0,0-1080,2340}
E/debug: 第3层:android.view.ViewStub{2b50655 G.E...... ......I. 0,0-0,0 #10201b1 android:id/action_mode_bar_stub}
E/debug: 第3层:android.widget.FrameLayout{9bfc86a V.E...... ........ 0,90-1080,2340 #1020002 android:id/content}
E/debug: 第4层:androidx.compose.ui.platform.ComposeView{1b4d15b V.E...... ........ 0,0-1080,2250}
E/debug: 第5层:androidx.compose.ui.platform.AndroidComposeView{a8ec543 VFED..... ........ 0,0-1080,2250}

如上所示,我们写的Column,Row,Text并没有出现在布局层级中,跟Compose相关的只有ComposeViewAndroidComposeView两个View

ComposeViewAndroidComposeView都是在setContent时添加进去的Compose的容器,我们后面再分析,这里先给出结论



Compose在渲染时并不会转化成View,而是只有一个入口View,即AndroidComposeView

我们声明的Compose布局在渲染时会转化成NodeTree,AndroidComposeView中会触发NodeTree的布局与绘制

总得来说,Compose会有一个View的入口,但它的布局与渲染还是在LayoutNode上完成的,基本脱离了View



总得来说,纯Compose页面的页面层级如下图所示:



原理分析


前置知识


我们知道,在View系统中会有一棵ViewTree,通过一个树的数据结构来描述整个UI界面

Compose中,我们写的代码在渲染时也会构建成一个NodeTree,每一个组件就是一个ComposeNode,作为NodeTree上的一个节点


ComposeNodeTree 管理涉及 ApplierCompositionComposeNode

Composition 作为起点,发起首次的 composition,通过 Compose 的执行,填充 Slot Table,并基于 Table 创建 NodeTree。渲染引擎基于 Compose Nodes 渲染 UI, 每当 recomposition 发生时,都会通过 ApplierNodeTree 进行更新。 因此



Compose 的执行过程就是创建 Node 并构建 NodeTree 的过程。





为了了解NodeTree的构建过程,我们来介绍下面几个概念


Applier:增删 NodeTree 的节点


简单来说,Applier的作用就是增删NodeTree的节点,每个NodeTree的运算都需要配套一个Applier

同时,Applier 会提供回调,基于回调我们可以对 NodeTree 进行自定义修改:


interface Applier<N> {

val current: N // 当前处理的节点

fun onBeginChanges() {}

fun onEndChanges() {}

fun down(node: N)

fun up()

fun insertTopDown(index: Int, instance: N) // 添加节点(自顶向下)

fun insertBottomUp(index: Int, instance: N)// 添加节点(自底向上)

fun remove(index: Int, count: Int) //删除节点

fun move(from: Int, to: Int, count: Int) // 移动节点

fun clear()
}

如上所示,节点增删时会回调到Applier中,我们可以在回调的方法中自定义节点添加或删除时的逻辑,后面我们可以一起看下在Android平台Compose是怎样处理的


Composition: Compose执行的起点


CompositionCompose执行的起点,我们来看下如何创建一个Composition


val composition = Composition(
applier = NodeApplier(node = Node()),
parent = Recomposer(Dispatchers.Main)
)

composition.setContent {
// Composable function calls
}

如上所示



  1. Composition中需要传入两个参数,ApplierRecomposer

  2. Applier上面已经介绍过了,Recomposer非常重要,他负责Compose的重组,当重组后,Recomposer 通过调用 Applier 完成 NodeTree 的变更

  3. Composition#setContent 为后续 Compose 的调用提供了容器


通过上面的介绍,我们了解了NodeTree构建的基本流程,下面我们一起来分析下setContent的源码


setContent过程分析


setContent入口


setContent的源码其实比较简单,我们一起来看下:


public fun ComponentActivity.setContent(
parent: CompositionContext? = null,
content: @Composable () -> Unit
) {
//判断ComposeView是否存在,如果存在则不创建
if (existingComposeView != null) with(existingComposeView) {
setContent(content)
} else ComposeView(this).apply {
//将Compose content添加到ComposeView上
setContent(content)
// 将ComposeView添加到DecorView上
setContentView(this, DefaultActivityContentLayoutParams)
}
}

上面就是setContent的入口,主要作用就是创建了一个ComposeView并添加到DecorView


Composition的创建


下面我们来看下AndroidComposeViewComposition是怎样创建的

通过ComposeView#setContent->AbstractComposeView#createComposition->AbstractComposeView#ensureCompositionCreated->ViewGroup#setContent

最后会调用到doSetContent方法,这里就是Compose的入口:Composition创建的地方


private fun doSetContent(
owner: AndroidComposeView, //AndroidComposeView是owner
parent: CompositionContext,
content: @Composable () -> Unit
): Composition {
//..
//创建Composition,并传入Applier与Recomposer
val original = Composition(UiApplier(owner.root), parent)
val wrapped = owner.view.getTag(R.id.wrapped_composition_tag)
as? WrappedComposition
?: WrappedComposition(owner, original).also {
owner.view.setTag(R.id.wrapped_composition_tag, it)
}
//将Compose内容添加到Composition中
wrapped.setContent(content)
return wrapped
}

如上所示,主要就是创建一个Composition并传入UIApplierRecomposer,并将Compose content传入Composition


UiApplier的实现


上面已经创建了Composition并传入了UIApplier,后续添加了Node都会回调到UIApplier


internal class UiApplier(
root: LayoutNode
) : AbstractApplier<LayoutNode>(root) {
//...

override fun insertBottomUp(index: Int, instance: LayoutNode) {
current.insertAt(index, instance)
}

//...
}

如上所示,在插入节点时,会调用current.insertAt方法,那么这个current到底是什么呢?


private fun doSetContent(
owner: AndroidComposeView, //AndroidComposeView是owner
): Composition {
//UiApplier传入的参数即为AndroidComposeView.root
val original = Composition(UiApplier(owner.root), parent)
}

abstract class AbstractApplier<T>(val root: T) : Applier<T> {
private val stack = mutableListOf<T>()
override var current: T = root
}
}

可以看出,UiApplier中传入的参数其实就是AndroidComposeViewroot,即current就是AndroidComposeViewroot


    # AndroidComposeView
override val root = LayoutNode().also {
it.measurePolicy = RootMeasurePolicy
//...
}

如上所示,root其实就是一个LayoutNode,通过上面我们知道,所有的节点都会通过Applier插入到root


布局与绘制入口


上面我们已经在AndroidComposeView中拿到NodeTree的根结点了,那Compose的布局与测量到底是怎么触发的呢?


    # AndroidComposeView
override fun dispatchDraw(canvas: android.graphics.Canvas) {
//Compose测量与布局入口
measureAndLayout()

//Compose绘制入口
canvasHolder.drawInto(canvas) { root.draw(this) }
//...
}

override fun measureAndLayout() {
val rootNodeResized = measureAndLayoutDelegate.measureAndLayout()
measureAndLayoutDelegate.dispatchOnPositionedCallbacks()
}


如上所示,AndroidComposeView会通过root,向下遍历它的子节点进行测量布局与绘制,这里就是LayoutNode绘制的入口


小结



  1. Compose在构建NodeTree的过程中主要通过Composition,Applier,Recomposer构建,Applier会将所有节点添加到AndroidComposeView中的root节点下

  2. setContent的过程中,会创建ComposeViewAndroidComposeView,其中AndroidComposeViewCompose的入口

  3. AndroidComposeViewdispatchDraw中会通过root向下遍历子节点进行测量布局与绘制,这里是LayoutNode绘制的入口

  4. Android平台上,Compose的布局与绘制已基本脱离View体系,但仍然依赖于Canvas


Compose与跨平台


上面说到,Compose的绘制仍然依赖于Canvas,但既然这样,Compose是怎么做到跨平台的呢?

这主要是通过良好的分层设计


Compose 在代码上自下而上依次分为6层:



其中compose.runtimecompose.compiler最为核心,它们是支撑声明式UI的基础。


而我们上面分析的AndroidComposeView这一部分,属于compose.ui部分,它主要负责Android设备相关的基础UI能力,例如 layoutmeasuredrawinginput

但这一部分是可以被替换的,compose.runtime 提供了 NodeTree 管理等基础能力,此部分与平台无关,在此基础上各平台只需实现UI的渲染就是一套完整的声明式UI框架


基于compose.runtime可以实现任意一套声明式UI框架,关于compose.runtime的详细介绍可参考fundroid大佬写的:Jetpack Compose Runtime : 声明式 UI 的基础


Button的特殊情况


上面我们介绍了在纯Compose项目下,AndroidComposeView不会有子View,而是遍历LayoutnNode来布局测量绘制

但如果我们在代码中加入一个Button,结果可能就不太一样了


@Composable
fun ComposeBody() {
Column {
Text(text = "这是一行测试数据", color = Color.Black, style = MaterialTheme.typography.h6)
Row() {
Text(text = "测试数据1!", color = Color.Black, style = MaterialTheme.typography.h6)
Text(text = "测试数据2!", color = Color.Black, style = MaterialTheme.typography.h6)
}

Button(onClick = {}) {
Text(text = "这是一个Button",color = Color.White)
}
}
}

然后我们再看看页面的层级结构


E/debug: 第1层:DecorView@182e858[RallyActivity]
E/debug: 第2层:android.widget.LinearLayout{397edb1 V.E...... ........ 0,0-1080,2340}
E/debug: 第3层:android.widget.FrameLayout{e2b0e17 V.E...... ........ 0,90-1080,2340 #1020002 android:id/content}
E/debug: 第4层:androidx.compose.ui.platform.ComposeView{36a3204 V.E...... ........ 0,0-1080,2250}
E/debug: 第5层:androidx.compose.ui.platform.AndroidComposeView{a8ec543 VFED..... ........ 0,0-1080,2250}
E/debug: 第6层:androidx.compose.material.ripple.RippleContainer{28cb3ed V.E...... ......I. 0,0-0,0}
E/debug: 第7层:androidx.compose.material.ripple.RippleHostView{b090222 V.ED..... ......I. 0,0-0,0}

可以看到,很明显,AndroidComposeView下多了两层子View,这是为什么呢?


我们一起来看下RippleHostView的注释



Empty View that hosts a RippleDrawable as its background. This is needed as RippleDrawables cannot currently be drawn directly to a android.graphics.RenderNode (b/184760109),
so instead we rely on View's internal implementation to draw to the background android.graphics.RenderNode.
A RippleContainer is used to manage and assign RippleHostViews when needed - see RippleContainer.getRippleHostView.



意思也很简单,Compose目前还不能直接绘制水波纹效果,因此需要将水波纹效果设置为View的背景,这里利用View做了一个中转

然后RippleHostViewRippleContainer自然会添加到AndroidComposeView中,如果我们在Compose中使用了AndroidView,效果也是一样的

但是这种情况并没有违背我们上面说的,纯Compose项目下,AndroidComposeView下没有子View,因为Button并不是纯Compose


总结


本文主要分析回答了Compose到底有没有完全脱离View系统这个问题,总结如下:



  1. Compose在渲染时并不会转化成View,而是只有一个入口View,即AndroidComposeView,纯Compose项目下,AndroidComposeView没有子View

  2. 我们声明的Compose布局在渲染时会转化成NodeTree,AndroidComposeView中会触发NodeTree的布局与绘制,AndroidComposeView#dispatchDraw是绘制的入口

  3. Android平台上,Compose的布局与绘制已基本脱离View体系,但仍然依赖于Canvas

  4. 由于良好的分层体系,Compose可通过 compose.runtimecompose.compiler实现跨平台

  5. 在使用Button时,AndroidComposeView会有两层子View,这是因为Button中使用了View来实现水波纹效果

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

Android 程序崩溃之快速锁定!

前言 从刚开始接触Android开发,第一次发版,遇到程序崩溃,那就一个慌张。好几年过去了,现在的听到程序崩溃?嗯,稍等我看看什么问题,然后该锁定该锁定该解决解决。 发版前减少bug、崩溃等,发版后遇到bug、崩溃也不要慌张,毕竟 bug不 会因为你的慌张而自...
继续阅读 »

前言


从刚开始接触Android开发,第一次发版,遇到程序崩溃,那就一个慌张。好几年过去了,现在的听到程序崩溃?嗯,稍等我看看什么问题,然后该锁定该锁定该解决解决。


发版前减少bug、崩溃等,发版后遇到bug、崩溃也不要慌张,毕竟 bug不 会因为你的慌张而自动修复对吧?要以最快的速度解决(解决问题同样是能力的体现),并说明问题轻重,看看是直接发版还是坐等下次。同时,吸取教训避免同样问题发生。


今天咱们就聊聊Android程序闪退。一个应用的崩溃率高低,决定了这个应用的质量。


为了解决崩溃问题,Android 系统会输出各种相应的 log 日志,当然还各式各样的三方库,大程度上降低了工程师锁定崩溃问题的难度。


如果要给 crash 日志进行分类,可以分成 2 大类



  • JVM 异常(Exception)堆栈信息,如下:




  • native 代码崩溃日志,如下:



JVM 异常堆栈信息


Java 中异常(Exception)分两种:



  • 检查异常 checked Exception

  • 非检查异常 unchecked Exception


检查异常:就是在代码编译时期,Android Studio 就会提示代码有错误,无法通过编译,比如 InterruptedException。如果我们没有在代码中将这些异常 catch,而是直接抛出,最终也有可能导致程序崩溃。


        try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}

非检查异常:包括 error 和运行时异常(RuntimeException),Android Studio 并不会在编译时期提示这些异常信息,而是在程序运行时期因为代码错误而直接导致程序崩溃,比如 OOM 或者空指针异常(NullPointerException)。


2021-09-13 11:50:27.327 19984-19984/com.scc.demo E/AndroidRuntime: FATAL EXCEPTION: main
Process: com.scc.demo, PID: 19984
java.lang.RuntimeException: Unable to start activity ComponentInfo{com.scc.demo/com.scc.demo.actvitiy.HandlerActivity}: java.lang.NullPointerException: Attempt to invoke virtual method 'void android.widget.Button.setOnClickListener(android.view.View$OnClickListener)' on a null object reference
...
Caused by: java.lang.NullPointerException: Attempt to invoke virtual method 'void android.widget.Button.setOnClickListener(android.view.View$OnClickListener)' on a null object reference
at com.scc.demo.actvitiy.HandlerActivity.onCreate(HandlerActivity.java:41)
at android.app.Activity.performCreate(Activity.java:8000)
...

Java 异常


对于上述两种异常我们都可以使用 UncaughtExceptionHandler 来进行捕获操作,它是 Thread 的一个内部接口,定义如下:


    public interface UncaughtExceptionHandler {
/**
* 当给定Thread由于给定的Throwable而终止时调用的方法。
* 此方法抛出的任何异常都将被 Java 虚拟机忽略。
* @param t Thread
* @param e Throwable
*/
void uncaughtException(Thread t, Throwable e);
}

从官方对其介绍能够看出,对于传入的 Thread,如果因为"未捕获"异常而导致被终止,uncaughtException 则会被调用。我们可以借助它来间接捕获程序异常,并进行异常信息的记录工作,或者给出更友好的异常提示信息。


自定义异常处理类


自定义异常处理类


自定义类实现 UncaughtExceptionHandler 接口,并实现 uncaughtException 方法:


public class SccExceptionHandler implements Thread.UncaughtExceptionHandler {
private Thread.UncaughtExceptionHandler mDefaultHandler;
private static SccExceptionHandler sccExceptionHandler;
private Context mContext;

public static SccExceptionHandler getInstence() {
if (sccExceptionHandler == null) {
synchronized (SccExceptionHandler.class) {
sccExceptionHandler = new SccExceptionHandler();
}
}
return sccExceptionHandler;
}

public void init(Context context) {
mContext = context;
//系统默认未捕获异常handler
//the default uncaught exception handler for all threads
mDefaultHandler = Thread.getDefaultUncaughtExceptionHandler();
//将当前Handler设为系统默认
Thread.setDefaultUncaughtExceptionHandler(this);

}

@Override
public void uncaughtException(@NonNull @NotNull Thread t, @NonNull @NotNull Throwable e) {
if (!handlerUncaughtException(e) && mDefaultHandler != null) {
//注释1:系统处理
mDefaultHandler.uncaughtException(t, e);
} else {
//注释2:自己处理
Intent intent = new Intent(mContext, ImageViewActivity.class);
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK|Intent.FLAG_ACTIVITY_CLEAR_TASK);
mContext.startActivity(intent);
//终止进程
android.os.Process.killProcess(android.os.Process.myPid());
//终止当前运行的 Java 虚拟机。
//参数用作状态代码; 按照惯例,非零状态代码表示异常终止。
System.exit(0);
}
}

//处理程序未捕获异常
private boolean handlerUncaughtException(Throwable e) {
//1.收集 crash 现场的相关信息,如当前 App 的版本信息,设备的相关信息以及异常信息。
//2.日志的记录工作(如保存在本地),等开发人员排查问题或等下次启动APP上传至服务器。
return true;
//不想处理 return false;
}
}

注释1:在自定义异常处理类中需要持有线程默认异常处理类。这样做的目的是在自定义异常处理类无法处理或者处理异常失败时,还可以将异常交给系统做默认处理。


注释2:如果自定义异常处理类成功处理异常,需要进行页面跳转,或者将程序进程"杀死"。否则程序会一直卡死在崩溃界面,并弹出无响应对话框。



android.os.Process.myPid():返回此进程的标识符,可与 killProcess 和 sendSignal 一起使用。




android.os.Process.killProcess(android.os.Process.myPid()):使用给定的 PID 终止进程。 请注意,尽管此 API 允许我们根据其 PID 请求终止任何进程,但内核仍会对您实际能够终止的 PID 施加标准限制。 通常这意味着只有运行调用者的包/应用程序的进程和由该应用程序创建的任何其他进程; 共享一个通用 UID 的包也将能够杀死彼此的进程。



使用自定义异常处理类


SccExceptionHandler 定义好之后,就可以将其初始化,并将主线程注册到 SccExceptionHandler 中。如下:


public class SccApp extends Application {
@Override
public void onCreate() {
super.onCreate();
SccExceptionHandler.getInstence().init(this);
}
}

native 异常


当程序中的 native 代码发生崩溃时,系统会在 /data/tombstones/ 目录下保存一份详细的崩溃日志信息。由于对 native 还不是很熟悉就不误导大家,感兴趣的自己玩玩。


对于程序崩溃信号机制的介绍,可以参考腾讯的这篇文章:Android 平台 Native 代码的崩溃捕获机制及实现


线上崩溃日志获取


上面介绍的 Java 和 Native 崩溃的捕获都是基于自己能够复现 bug 的前提下。但是对于线上的用户,这种操作方式是不太现实的。


对于大多数公司来说,针对线上版本,没有必要自己实现一个抓取 log 的平台系统。最快速的实现方式就是集成第三方 SDK。目前比较成熟,采用也比较多的就是腾讯的 Bugly。


Bugly


Bugly 基本能够满足线上版本捕获 crash 的所有需求,包括 Java 层和 Native 层的 crash 都可以获取相应的日志。并且每天 Bugly 都会邮件通知上一天的崩溃日志,方便测试和开发统计 bug 的分布以及崩溃率。


接入文档



异常概括



崩溃分析



程序崩溃分析这块我没做调整,这个是bugly自动抓取的。


错误分析



具体内容




这里我用来存放去服务端请求接口时的参数和返回的数据。,下面看看具体效果。


使用起来相当方便,而且错误还提供解决方案,美滋滋。


xCrash


xCrash 能为安卓 app 提供捕获 java 崩溃,native 崩溃和 ANR 的能力。不需要 root 权限或任何系统权限。


xCrash 能在 app 进程崩溃或 ANR 时,在你指定的目录中生成一个 tombstone 文件(格式与安卓系统的 tombstone 文件类似)。


xCrash 已经在 爱奇艺 的不同平台(手机,平板,电视)的很多安卓 app(包括爱奇艺视频)中被使用了很多年。


xCrash传送门


Sentry


Sentry 是一项可帮助您实时监控和修复崩溃的服务。 服务器使用 Python,但它包含一个完整的 API,用于在任何应用程序中从任何语言发送事件。


Sentry传送门


XCrash 和 Sentry,这两者比 Bugly 好的地方就是除了自动拦截界面崩溃事件,还可以主动上报错误信息。


可以看出 XCrash 的使用更加灵活,工程师的掌控性更高。可以通过设置不同的过滤方式,针对性地上报相应的 crash 日志。并且在捕获到 crash 之后,可以加入自定义的操作,比如本地保存日志或者直接进行网络上传等。


另外:Sentry 还有一个好处就是可以通过设置过滤,来判断是否上报 crash 日志。这对于 SDK 的开发人员是很有用的。比如一些 SDK 的开发商只是想收集自身 SDK 引入的 crash,对于用户的其他操作导致的 crash 进行过滤,这种情况就可以考虑集成 Sentry。


Bugly 简单使用


感觉教程乱的可以自己去上文找Buyle文档自己集成,很简单的。


库文件导入


自动集成(推荐)


plugins {
id 'com.android.application'
}
android {
compileSdkVersion 30//项目的编译版本
defaultConfig {
applicationId "com.scc.demo"//包名
minSdkVersion 23//最低的兼容的Android系统版本
targetSdkVersion 30//目标版本,表示你在该Android系统版本已经做过充分的测试
versionCode 1//版本号
versionName "1.0.0"//版本名称
ndk {
abiFilters 'armeabi-v7a', 'arm64-v8a','x86'
//运行环境,要上传Google Play必须兼容64位,这里仅兼容ARM架构
//对于ARM架构,32 位库位于armeabi-v7a 中。64 位等效项是arm64-v8a。
//对于x86体系结构,查找x86(用于 32 位)和 x86_64(用于 64 位)。
}
}
}

dependencies {
implementation 'com.tencent.bugly:crashreport:3.4.4'
//集成Bugly NDK时,需要同时集成Bugly SDK。
implementation 'com.tencent.bugly:nativecrashreport:3.9.2'

}
复制代码

注意:自动集成时会自动包含Bugly SO库,建议在Module的build.gradle文件中使用NDK的"abiFilter"配置,设置支持的SO库架构。


如果在添加"abiFilter"之后Android Studio出现以下提示:


NDK integration is deprecated in the current plugin. Consider trying the new experimental plugin.


则在项目根目录的gradle.properties文件中添加:


android.useDeprecatedNdk=true


初始化


public class SccApp extends Application {
@Override
public void onCreate() {
super.onCreate();
//70594a1ff8 Bugly新建产品的 App ID
CrashReport.initCrashReport(getApplicationContext(), "70594a1ff8", false);
}
}

错误分析


设置


    private void setCrashReport(String url, String name, Map<String, String> params, String message) {
try {
if (params != null && !MStringUtils.isNullOrEmpty(url) && !MStringUtils.isNullOrEmpty(name) && !MStringUtils.isNullOrEmpty(params.toString()) && !MStringUtils.isNullOrEmpty(message)) {
CrashReport.putUserData(AppGlobalUtils.getApplication(), "SccParams", params.toString());
CrashReport.putUserData(AppGlobalUtils.getApplication(), "Data", "LoginName-Scc001:" + message);
CrashReport.postCatchedException(new RuntimeException(name + ":" + url + ":" + message));
}
} catch (Exception e) {
}
}

调用


        HashMap<String,String> hashMap = new HashMap<>();
hashMap.put("name","scc001");
hashMap.put("pass","111111");
String returnData = "哈哈哈哈哈";
setCrashReport("loin/register","Main",hashMap,returnData);

效果


错误列表



错误详情



出错堆栈



跟踪数据



崩溃分析


这个不用咱自己设置,Bugly自动抓取,下面提供跟错误分析类似功能这里就不多描述了。



本文内容到这里就算结束了。希望能帮你快速锁定 bug 并解决,让应用更完美,让你的老板更放心,票票来的更多一些。


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

JavaScript之彻底理解原型与原型链

前言 原型与原型链知识历来都是面试中考察的重点,说难不算太难,但要完全理解还是得下一定的功夫。先来看一道面试题开开胃口吧: function User() {} User.prototype.sayHello = function() {} var u1 = ...
继续阅读 »

前言


原型与原型链知识历来都是面试中考察的重点,说难不算太难,但要完全理解还是得下一定的功夫。先来看一道面试题开开胃口吧:


function User() {}
User.prototype.sayHello = function() {}
var u1 = new User();
var u2 = new User();
console.log(u1.sayHello === u2.sayHello);
console.log(User.prototype.constructor);
console.log(User.prototype === Function.prototype);
console.log(User.__proto__ === Function.prototype);
console.log(User.__proto__ === Function.__proto__);
console.log(u1.__proto__ === u2.__proto__);
console.log(u1.__proto__ === User.__proto__);
console.log(Function.__proto__ === Object.__proto__);
console.log(Function.prototype.__proto__ === Object.prototype.__proto__);
console.log(Function.prototype.__proto__ === Object.prototype);

基础铺垫




  1. JavaScript所有的对象本质上都是通过new 函数创建的,包括对象字面量的形式定义对象(相当于new Object()的语法糖)。


    对象定义.jfif




  2. 所有的函数本质上都是通过new Function创建的,包括ObjectArray
    函数定义.jfif




  3. 所有的函数都是对象。




prototype


每个函数都有一个属性prototype,它就是原型,默认情况下它是一个普通Object对象,这个对象是调用该构造函数所创建的实例的原型。
函数原型.jfif


contructor属性


JavaScript同样存在由原型指向构造函数的属性:constructor,即Func.prototype.constructor --> Func
constructor.jfif


__proto__


JavaScript中所有对象(除了null)都具有一个__proto__属性,该属性指向该对象的原型。


function User() {}
var u1 = new User();
// u1.__proto__ -> User.prototype
console.log(u1.__proto__ === User.prototype) // true

显而易见,实例的__proto__属性指向了构造函数的原型,那么多个实例的__proto__会指向同一个原型吗?


var u2 = new User();
console.log(u1.__proto__ === u2.__proto__) // true

如果多个实例的__proto__都指向构造函数的原型,那么实例如果能通过一种方式,访问原型上的方法,属性等,就可以在原型进行编程,实现继承的效果。


我们继续更新一下原型与原型链的关系图:
隐式原型.jfif


原型链


实例对象在查找属性时,如果查找不到,就会沿着__proto__去与对象关联的原型上查找,如果还查找不到,就去找原型的原型,直至查到最顶层,这也就是原型链的概念。


就借助面试题,举几个原型链的例子:


举例



  1. u1.sayHello()
    u1上是没有sayHello方法的,因此访问u1.__proto__(User.prototype),成功访问到sayHello方法

  2. u2.toString()
    u2,User.prototype都没有toString方法,User.prototype也是一个普通对象,因此继续寻找User.prototype.__proto__(Object.prototype),成功调用到toString方法


提高


学完上面那些,大多数面试题都可以做出来了,例如下面这种


function A() {}
function B(a) {
this.a = a;
}
function C(a) {
if (a) {
this.a = a;
}
}
A.prototype.a = 1;
B.prototype.a = 1;
C.prototype.a = 1;

console.log(new A().a); //1
console.log(new B().a); //undefined
console.log(new C(2).a); //2

但距离解决文章的最初的面试题还欠缺些什么,比如Function.__proto__ === Object.__proto__、Function.prototype.__proto__ === Object.prototype.__proto__等,接着我们来一一攻克它。


Objcet.__proto__Object.prototypeObject.prototype.__proto__




  • Object是构造函数,在第二部分我们讲过所有的函数都是通过new Function创建了,因此Object相当于Function的实例,即Object.__proto__ --> Function.prototype




  • Object.prototypeObject构造函数的原型,处于原型链的顶端,Object.prototype.__proto__已经没有可以指向的上层原型,因此其值为null




// 总结:
Object.__proto__ --> Function.prototype
Object.prototype.__proto__ --> null

Function.__proto__Function.prototypeFunction.prototype.__proto__



  • Function.prototypeFunction的原型,是所有函数实例的原型,例如上面讲的Object.__proto__

  • Function.prototype是一个普通对象,因此Function.prototype.__proto__ --> Object.prototype

  • Function.__proto__: __proto__指向创造它的构造函数的原型,那谁创造了Function那?


    • 猜想:函数对象也是对象,那Function.__proto__会指向Object.prototype吗?上文提到,Object.__proto__ --> Function.prototype。如果Function.__proto__ -> Object.prototype,感觉总是怪怪的,到底谁创造了谁,于是我去做了一下测试:


      Function与Object.png




    实践证明只存在Object.__proto__ --> Function.prototype


    • 苦思冥想没得出结果,难道Function函数是猴子不成,从石头缝里面蹦出来的?于是我进行了一同乱七八糟的测试,没想到找出了端倪。


      Function proto.png




    通过上面我们可以得出:Function.__proto__ --> Function.prototype

    Function函数不通过任何东西创建,JS引擎启动时,添加到内存中





总结


最后将原型与原型链方面的知识凝结成一张图:
原型链.jfif



  1. 所有函数(包括Function)的__proto__指向Function.prototype

  2. 自定义对象实例的__proto__指向构造函数的原型

  3. 函数的prototype__proto__指向Object.prototype

  4. Object.prototype.__proto__ --> null


后语


知识的海洋往往比想象中还要辽阔,原型与原型链这边也反复的学过多次,我认为应该学的比较全面,比较完善了。但遇到这个面试题后,我才发现我所学的只不过是一根枝干,JS里面真的有很多深层次的宝藏等待挖掘。学海无涯,与君共勉。


最后再附赠个简单的面试题,提高一下自信:


var F = function () {}
Object.prototype.a = function () {}
Function.prototype.b = function () {}

var f = new F();

console.log(f.a, f.b, F.a, F.b);

// 原型链
// f.__proto__ --> F.prototype --> Object.prototype
// F.__proto__ --> Function.prototype --> Object.prototype


收起阅读 »

18 个杀手级 JavaScript 单行代码

1、复制到剪贴板 使用 navigator.clipboard.writeText 轻松将任何文本复制到剪贴板。 const copyToClipboard = (text) => navigator.clipboard.writeText(text);...
继续阅读 »

1、复制到剪贴板


使用 navigator.clipboard.writeText 轻松将任何文本复制到剪贴板。


const copyToClipboard = (text) => navigator.clipboard.writeText(text);
copyToClipboard("Hello World");

2、检查日期是否有效


使用以下代码段检查给定日期是否有效。


const isDateValid = (...val) => !Number.isNaN(new Date(...val).valueOf());
isDateValid("December 17, 1995 03:24:00");
// Result: true

3、找出一年中的哪一天


查找给定日期的哪一天。


const dayOfYear = (date) =>  Math.floor((date - new Date(date.getFullYear(), 0, 0)) / 1000 / 60 / 60 / 24);
dayOfYear(new Date());
// Result: 272

4、将首字符串大写


Javascript 没有内置的大写函数,因此我们可以使用以下代码。


const capitalize = str => str.charAt(0).toUpperCase() + str.slice(1)capitalize("follow for more")// Result: Follow for more

5、找出两日期之间的天数


使用以下代码段查找给定 2 个日期之间的天数。


const dayDif = (date1, date2) => Math.ceil(Math.abs(date1.getTime() - date2.getTime()) / 86400000)dayDif(new Date("2020-10-21"), new Date("2021-10-22"))// Result: 366

6、清除所有 Cookie


你可以通过使用 document.cookie 访问 cookie 并清除它来轻松清除存储在网页中的所有 cookie。


const clearCookies = document.cookie.split(';').forEach(cookie => document.cookie = cookie.replace(/^ +/, '')
.replace(/=.*/, `=;expires=${new Date(0).toUTCString()};
path=/`));

7、生成随机十六进制


你可以使用 Math.random 和 padEnd 属性生成随机十六进制颜色。


const randomHex = () => `#${Math.floor(Math.random() * 0xffffff).toString(16).padEnd(6, "0")}`
console.log(randomHex());
//Result: #92b008

8、从数组中删除重复项


你可以使用 JavaScript 中的 Set 轻松删除重复项。


const removeDuplicates = (arr) => [...new Set(arr)];
console.log(removeDuplicates([1, 2, 3, 3, 4, 4, 5, 5, 6]));
// Result: [ 1, 2, 3, 4, 5, 6 ]

9、从 URL 获取查询参数


你可以通过传递 window.location 或原始 URL goole.com?search=easy&page=3 从 url 轻松检索查询参数


const getParameters = (URL) => {
URL = JSON.parse('{"' + decodeURI(URL.split("?")[1]).replace(/"/g, '\"').replace(/&/g, '","').replace(
/=/g, '":"') + '"}');
return JSON.stringify(URL);
};
getParameters(window.location) // Result: { search : "easy", page : 3 }

10、从日期记录时间


我们可以从给定日期以小时::分钟::秒的格式记录时间。


const timeFromDate = date => date.toTimeString().slice(0, 8);
console.log(timeFromDate(new Date(2021, 0, 10, 17, 30, 0)));
// Result: "17:30:00"

11、检查数字是偶数还是奇数


const isEven = num => num % 2 === 0;console.log(isEven(2));
// Result: True

12、求数字的平均值


使用 reduce 方法找到多个数字之间的平均值。


const average = (...args) => args.reduce((a, b) => a + b) / args.length;
average(1, 2, 3, 4);
// Result: 2.5

13、反转字符串


你可以使用 split、reverse 和 join 方法轻松反转字符串。


const reverse = str => str.split('').reverse().join('');reverse('hello world'); 
// Result: 'dlrow olleh'

14、检查数组是否为空


检查数组是否为空的简单单行程序将返回 true 或 false。


const isNotEmpty = arr => Array.isArray(arr) && arr.length > 0;
isNotEmpty([1, 2, 3]);
// Result: true

15、获取选定的文本


使用内置的 getSelectionproperty 获取用户选择的文本。


const getSelectedText = () => window.getSelection().toString();
getSelectedText();

16、打乱数组


使用 sort 和 random 方法打乱数组非常容易。


const shuffleArray = (arr) => arr.sort(() => 0.5 - Math.random());console.log(shuffleArray([1, 2, 3, 4]));// Result: [ 1, 4, 3, 2 ]

17、检测暗模式


使用以下代码检查用户的设备是否处于暗模式。


const isDarkMode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matchesconsole.log(isDarkMode) // Result: True or False

18、将 RGB 转换为十六进制


const rgbToHex = (r, g, b) =>   "#" + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1);rgbToHex(0, 51, 255); // Result: #0033ff

作者:耀在掘金73091
链接:https://juejin.cn/post/7018106908129099807

收起阅读 »

抛弃Vue转入React的六个月,我收获了什么?

对不起,我抛弃了Vue,转入React阵营。不因为其它,就因为在我这边使用React的工资比使用Vue的工资高。 在六月前,我硬背了几百道的React面试题,入职一家使用React的公司,薪资增幅120%; 入职就马上进入开发阶段,完全是从零开始,随着时间的推...
继续阅读 »

对不起,我抛弃了Vue,转入React阵营。不因为其它,就因为在我这边使用React的工资比使用Vue的工资高。


在六月前,我硬背了几百道的React面试题,入职一家使用React的公司,薪资增幅120%;


入职就马上进入开发阶段,完全是从零开始,随着时间的推移,发现React入门也不是网传的那么难。难道是自己天生就适合吃这碗饭…………


到今天已经六个月了,在这里想把这段时间的收获跟掘友们分享一下,请掘友们多多指教,一起进步哈。


另外我是用React16.8来入门,故在开发过程中大多使用函数式组件React Hooks来开发。


重磅提醒,文末有抽奖噢。


一、关于函数式组件的收获


函数式组件可以理解为一个能返回React元素的函数,其接收一个代表组件属性的参数props


在React16.8之前,也就是没有React Hooks之前,函数式组件只作为UI组件,其输出完全由参数props控制,没有自身的状态没有业务逻辑代码,是一个纯函数。函数式组件没有实例,没有生命周期,称为无状态组件。


在React Hooks出现后,可以用Hook赋予函数式组件状态和生命周期,于是函数式组件也可以作为业务组件。


开发过程中,类组件和函数式组件都有使用,经过六个月的开发,感觉还是函数式组件比类组件好用一些,感受最深的是以下两点:



  • 不用去学习class,不用去管烦人的this指向问题;

  • 复用性高,很容易就把共同的抽取出来,写出自定义Hook,来替代高阶组件。


函数式组件和类组件之间有一个非常重要的区别:函数式组件捕获了渲染所使用的值


我是遇到一个BUG才知道有这个区别,在解决这个BUG的过程理解了这个区别的含义。


那个BUG的场景是这样的,一个输入框,输入完内容,点击按钮搜索,搜索时先请求一个接口,获取一个类型,再用类型和输入框值去请求搜索接口。用代码简单描述一下。


import React, { Component } from "react";
import * as API from 'api/list';
class SearchComponent extends Component {
constructor() {
super();
this.state = {
inpValue: ""
};
}

getType () {
const param = {
val:this.state.inpValue
}
return API.getType(param);
}

getList(type){
const param = {
val:this.state.inpValue,
type,
}
return API.getList(param);
}

async handleSearch() {
const res = await this.getType();
const type = res?.data?.type;
const res1 = await this.getList(type);
console.log(res1);
}

render() {
return (
<div>
<input
type="text"
value={this.state.inpValue}
onChange={(e) => {
this.setState({ inpValue: e.target.value });
}}
/>
<button
onClick={() => {
this.handleSearch();
}}
>
搜索
</button>
</div>
);
}
}
export default SearchComponent;

以上代码逻辑看上去都没什么毛病,但是QA给我挑了一个BUG,在输入框输入要搜索的内容后,点击搜索按钮开始搜索,然后很快在输入框中又输入内容,结果搜索接口getList报错。查一下原因,发现是获取类型接口getType和搜索接口getList接受的参数val不一致。


在排查过程中,我非常纳闷,明明两次请求中val都是读取this.state.inpValue的值。当时同事指导我改成函数式组件就可解决这个BUG。


import React, { useState } from "react";
import * as API from 'api/list';
export const SearchComponent = () =>{
const [inpValue,setInpValue] = useState('');

const getType = () =>{
const param = {
val:inpValue
}
return API.getType(param);
}

const getList = (type) =>{
const param = {
val:inpValue,
type:type,
}
return API.getList(param);
}

const handleSearch = async ()=>{
const res = await getType();
const type = res?.data?.type;
const res1 = await getList(type);
console.log(res1);
}

return (
<div>
<input
type="text"
value={inpValue}
onChange={(e) => {
setInpValue(e.target.value);
}}
/>
<button
onClick={() => {
handleSearch();
}}
>
搜索
</button>
</div>
);
}
export default SearchComponent;

改成函数式组件后,再试一下,不报错了,BUG修复了。后面我查阅资料后才知道在函数式组件中的事件的state和props所获取的值是事件触发那一刻页面渲染所用的state和props的值。当点击搜索按钮后,val的值就是那一刻输入框中的值,无论输入框后面的值在怎么改变,不会捕获最新的值。


那为啥类组件中,能获取到最新的state值呢?关键在于类组件中是通过this去获取state的,而this永远是最新的组件实例。


其实在类组件中改一下,也可以解决这个BUG,改动的地方如下所示:


getType (val) {
const param = {
val,
}
return API.getType(param);
}
getList(val,type){
const param = {
val,
type,
}
return API.getList(param);
}
async handleSearch() {
const inpValue = this.state.inpValue;
const res = await this.getType(inpValue);
const type = res?.data?.type;
const res1 = await this.getList(inpValue,type);
console.log(res1);
}

在搜索事件handleSearch触发时,就把输入框的值this.state.inpValue存在inpValue变量中,后续执行的事件用到输入框的值都去inpValue变量取,后续再往输入框输入内容也不会影响到inpValue变量的值,除非再次触发搜索事件handleSearch。这样修改也可以解决这个BUG。


二、关于受控和非受控组件的收获


在React中正确理解受控和非受控组件的概念和作用至关重要,因为太多地方用到了



  • 受控组件


在Vue中有v-model指令可以很轻松把组件和数据关联起来,而在React中没有这种指令,那怎么让组件和数据联系起来,这时候就要用到受控组件的概念。


受控组件,我理解为组件的状态由数据来控制,改变这个数据的方法却不是组件的,这里所说的组件不仅仅是组件,也可以表示一个原生DOM。比如一个input输入框。


input输入框的状态(输入框的值)由数据value控制,改变数据value的方法setValue不是input输入框自身的。


import React, { useState } from "react";
const Input = () =>{
const [value,setValue] = useState('');
return (
<div>
<input
type="text"
value={value}
onChange={(e) => {
setValue(e.target.value);
}}
/>
</div>
);
}
export default Input;

再比如在Ant Design UI中的Form组件自定义表单控件时,要求自定义表单控件接受属性valueonChange,其中value来作为自定义表单控件的值,onChange事件来改变属性value


import React from 'react';
import { Form, Input } from 'antd';

const MyInput = (props) => {
const { value, onChange } = props;

const onNameChange = (e) => {
onChange?.(e.target.value)
}

return (
<Input
type="text"
value={value || ''}
onChange={onNameChange}
/>
)
}
const MyForm = () => {
const onValuesChange = (values) => {
console.log(values);
};

return (
<Form
name="demoFrom"
layout="inline"
onValuesChange={onValuesChange}
>
<Form.Item name="name">
<MyInput />
</Form.Item>
</Form>
)
}

export default MyForm;

我认为受控组件最大的作用是在第三方组件的状态经常莫名奇妙的改变时,用一个父组件将其包裹起来,传入一个要控制的状态和改变状态方法的props,把组件的状态提到父组件来控制,这样当组件的状态改变时就很清晰的知道哪个地方改变了组件的状态。



  • 非受控组件


非受控组件就是组件自身状态完全由自身来控制,是相对某个组件而言,比如input输入框受Input组件控制,而Input组件不受Demo组件控制,那么Input相对Demo组件是非受控组件。


import React, { useState } from "react";
const Input = () =>{
const [value,setValue] = useState('');
return (
<div>
<input
type="text"
value={value}
onChange={(e) => {
setValue(e.target.value);
}}
/>
</div>
);
}
const Demo = () =>{
return (
<Input/>
)
}
export default Demo;

也可以把非受控组件理解为组件的值只能由用户设置,而不能通过代码控制。此外要记住一个非常特殊的DOM元素<input type="file" />,其无法通过代码设置所上传的文件。


三、关于useState的收获


useState可谓是使用频率最高的一个Hook,下面分享一下使用的3个心得。




  • useState可以接收一个函数作为参数,把这个函数称为state初始化函数


    一般场景下useState只要传入一个值就可以作为这个state的初始值,但是如果这个值要通过一定计算才能得出的呢?那么此时就可以传入一个函数,在函数中计算完成后,返回一个初始值。


    import React, { useState } from 'react';
    export default (props) => {
    const { a } = props;
    const [b] = useState(() => {
    return a + 1;
    })
    return (
    <div>{b}</div>
    )
    };



  • state更新如何使用旧的state


    刚开始时,我这么使用的


    const [a , setA] = useState(1);
    const changeA = () =>{
    setA(a + 1);
    }

    后来遇到一个错误,代码如下


    const [a , setA] = useState(1);
    const changeA = () =>{
    setA(a + 1);
    setA(a + 1);
    }

    在函数中连续调用两次setA会发现a还是等于2,并不是等于3。正确要这么调用


    const [a , setA] = useState(1);
    const changeA = () =>{
    setA( a => a+1 );
    setA( a => a+1 );
    }



  • 如何拆分state


    在函数式组件中,只要一个state改变都会引起组件的重新渲染。而在React中只要父组件重新渲染,其子组件都会被重新渲染。


    那么问题就来了,如果把state拆分成多个,当依次改变这些state,则会多次触发组件重新渲染。


    若不拆分state,改变state时,就只会触发一次组件重新渲染。但是要注意,函数式组件不像类组件那样,改变其中state中的一个数据,会自动更新state中对应的数据。要这么处理


    const [data,setData] = useState({a:1,b:2,c:3});
    const changeData = () =>{
    setData(data =>{
    ...data,
    a:2,
    })
    }



当然也不能不拆分state,这样代码复用性会大大降低。我的经验是:




  • 将完全不相关的state拆分成一个个




  • 如果有些state是相互关联的,或是需要一起改变,那么将其合并为一个state




四、关于useMemo和usecallback的收获



  • 对其定义的理解


useCallback(fn,[a, b]);
useMemo(fn, [a, b]);

如上所示,useMemousecallback参数中fn是一个函数,参数中[a,b]ab可以是state或props。


useMemousecallback首次执行时,执行fn后创建一个缓存并返回这个缓存,监听[a,b]中的表示state或props的ab,若值未发生变化直接返回缓存,若值发生变化则重新执行fn再创建一个缓存并返回这个缓存。


useMemousecallback都会返回一个缓存,但是这个缓存各不相同,useMemo返回一个值,这个值可以认为是执行fn返回的。usecallback返回一个函数,这个函数可以认为是fn那么注意了传给useMemofn必须返回一个值



  • 结合React.memo来使用


React.memo包裹一个组件,当组件的props发生改变时才会重新渲染。


若包裹的组件是个函数式组件,在其中拥有useStateuseReduceruseContext的 Hook,当state 或context发生变化时,它仍会重新渲染,不过这些影响不大,使用React.memo的主要目的是控制父组件更新迫使子组件也更新的问题。


props值的类型可以为String、Boolean、Null、undefined、Number、Symbol、Object、Array、Function,简单的来说就是基础类型和引用类型。


两个值相等的基础类型的数据用==比较返回true,那两个值相等的引用类型的数据用==比较返回false,不信试一试以下代码,看是不是都为false


console.log({a:1} == {a:1});
console.log([1] == [1]);
const fn1 = () =>{console.log(1)};
const fn2 = () =>{console.log(1)};
console.log(fn1 == fn2);

正因为如此,当props的值为引用类型时,且这个值是通过函数计算出来的,用useMemousecallback来处理一下,避免计算出来的值相等,但是比较却不相等,导致组件更新。我认为usecallback是专门为处理props值为函数而创建的Hook。


在下面的例子,List组件是一个渲染开销很大的组件,它有两个属性,其中data属性是渲染列表的数据源,是一个数组,onClick属性是一个函数。在Demo组件中引入List组件,用useMome处理data属性值,用useCallback处理onClick属性值,使得List组件是否重新渲染只受Demo组件的data这个state控制。


List组件:


import React from 'react';
const List = (props) => {
const { onClick, data } = props;
//...
return (
<>
{data.map(item => {
return (
<div onClick={() =>{onClick(item)}} key={item.id}>{item.content}</div>
)
})}
</>
)
}
export default React.memo(List);

Demo组件:


import
React,
{ useState, useCallback, useMemo }
from 'react';
import List from './List';

const Demo = () => {
//...
const [data, setData] = useState([]);
const listData = useMemo(() => {
return data.filter(item => {
//...
})
}, [data])

const onClick = useCallback((item) => {
console.log(item)
}, []);

return (
<div>
<List onClick={onClick} data={listData} />
</div>
)
}

export default Demo;

可见useMemousecallback作为一个性能优化手段,可以在一点程度上解决React父组件更新,其子组件也被迫更新的性能问题。



  • 单独使用


假设List组件不用React.memo包裹,还能用useMemousecallback来优化吗?先来看一段代码,也可以这么使用组件。


import React, { useState } from 'react';
import List from './List';

export default function Demo() {
//...
const [data, setData] = useState([]);
const list = () => {
const listData = data.filter(item => {
//...
})
const onClick = (item) => {
console.log(item)
}
return (
<List onClick={onClick} data={listData} />
)
}
return (
<div>
{list()}
</div>
)
}

其中list返回一个React元素,是一个值,那么是不是可以用useMemo来处理一下。


import React, { useState, useMemo } from 'react';
import List from './List';

export default function Demo() {
//...
const [data, setData] = useState([]);
const listMemo = useMemo(() => {
const listData = data.filter(item => {
//...
})
const onClick = (item) => {
console.log(item)
}
return (
<List onClick={onClick} data={listData} />
)
}, [data])
return (
<div>
{listMemo}
</div>
)
}

listMemo这个值(React元素)是否重新生成只受Demo组件的data这个state控制。这样不是相当List组件是否重新渲染只受Demo组件的data这个state控制。



  • 不能滥用



不能认为“不管什么情况,只要用useMemouseCallback处理一下,就能远离性能的问题”



要认识到useMemouseCallback也有一定的计算开销,例如useMemo会缓存一些值,在后续重新渲染,将依赖数组中的值取出来和上一次记录的值进行比较,如果不相等才会重新执行回调函数,否则直接返回缓存的值。这个过程有一定的计算开销。


所以在使用useMemouseCallback前一定要考虑清楚使用场景,不能滥用。


下面总结一下要使用useMemouseCallback的场景:




  • 一个渲染开销很大的组件的属性是一个函数时,用useCallback处理一下这个属性值。




  • 得到一个值,有很高得计算开销,用useMemo处理一下。




  • 一个通过计算得到的引用类型的值,且这个值被赋值给一个渲染开销很大的组件的属性,用useMemo处理一下。




  • 自定义Hook中暴露出的引用类型的值,用useMemo处理一下。




总之一句话使用useMemouseCallback是为了保持引用相等和避免重复成本很高的计算。


五、关于useEffect和useLayoutEffect的收获


useEffect(fn);
useLayoutEffect(fn);

useEffect可谓是使用频率第二高的一个Hook,useLayoutEffect使用频率比较低。下面介绍四个使用心得:



  • 执行的时机


在使用useEffectuseLayoutEffect之前,要搞明白传入这个两个Hook的函数会在什么时候执行。


传入useEffect的函数是在React完成对DOM的更改,浏览器把更改后的DOM渲染出来后执行的


传入useLayoutEffect的函数是在React完成对DOM的更改后,浏览器把更改后的DOM渲染出来之前执行的


所以useEffect不会阻碍页面渲染,useLayoutEffect会阻碍页面渲染,但是如果要在渲染前获取DOM元素的属性做一些修改,useLayoutEffect是一个很好的选择。



  • 只想执行一次


组件初始化和每次更新时都会重新执行传入useEffectuseLayoutEffect的函数。那只想在组件初始化时执行一次呢?相当Vue中的mounted,这样实现:


useEffect(fn,[]);
useLayoutEffect(fn,[]);


  • 用来进行事件监听的坑


上面介绍在useEffect在第二参数传入一个空数组[]相当Vue中的mounted,那么在Vue的mounted中经常会用addEventListener监听事件,然后在beforeDestory中用removeEventListener移除事件监听。那用useEffect实现一下。


useEffect(() => {
window.addEventListener('keypress', handleKeypress, false);
return () => {
window.removeEventListener('keypress', handleKeypress, false);
};
},[])

上面用来监听键盘回事事件,但是你会发现一个很奇怪的现象,有些页面回车后会执行handleKeypress方法,有些页面回车后执行几次handleKeypress方法后,就不再执行了。


这是因为一个useEffect执行前会执行上一个useEffect的传入函数的返回函数,这个返回函数可以用来解除绑定,取消请求,防止引起内存泄露


此外组件卸载时,也会执行useEffect的传入函数的返回函数


示意如下代码所示:


window.addEventListener('keypress', handleKeypress, false); // 运行第一个effect
window.removeEventListener('keypress', handleKeypress, false);// 清除上一个effect
window.addEventListener('keypress', handleKeypress, false); // 运行下一个 effect
window.removeEventListener('keypress', handleKeypress, false); // 清除上一个effect
window.addEventListener('keypress', handleKeypress, false); // 运行下一个effect
window.removeEventListener('keypress', handleKeypress, false); // 清除最后一个effect

所以要解决上面的BUG,只要把useEffect的第二参数去掉即可。这点跟Vue中的mounted不一样。



  • 用来监听某个state或prop


useEffectuseLayoutEffect的第二参数是个数组,称为依赖,当依赖改变时候会执行传入的函数。


比如监听 a 这个state 和 b 这个prop,这样实现:


useEffect(fn,[a,b]);
useLayoutEffect(fn,[a,b]);

但是要注意不要一次性监听过多的state或prop,也就是useEffect的依赖过多,如果过多要去优化它,否则会导致这个useEffect难以维护。


我是这样来优化的:



  • 考虑该依赖是否必要,不必要去掉它。

  • useEffect拆分为更小的单元,每个useEffect依赖于各自的依赖数组。

  • 通过合并依赖中的相关state,将多个state聚合为一个state。


六、结语


以上五点就是我这六个月中印象最深的收获,有踩过的坑,有写被leader吐槽的代码。不过最大的收获还是薪资涨幅120%,哈哈哈,各位掘友要勇于跳出自己的舒适区,才能有更丰厚的收获。


虽然以上的收获在某些掘友们眼里会觉得比较简单,但是对于刚转入React的掘友们这些知识的使用频率非常高,要多多琢磨。如果有错误欢迎在评论中指出,或者掘友有更好的收获也在评论中分享一下。


作者:红尘炼心
链接:https://juejin.cn/post/7018328359742636039

收起阅读 »

转动的CSS“loading”,全都是技巧!

loader-1 这应该是最简单的CSS加载了。在圆圈上有一个红色的圆弧,仔细观察会发现,这个圆弧正好是1/4. 实现逻辑: 一个宽高相等容器,设定border为白色。然后给底边bottom设置红色, 当设定border-radius是50%,那他正好可以...
继续阅读 »

loader-1



这应该是最简单的CSS加载了。在圆圈上有一个红色的圆弧,仔细观察会发现,这个圆弧正好是1/4.


实现逻辑:


一个宽高相等容器,设定border为白色。然后给底边bottom设置红色,

当设定border-radius是50%,那他正好可以变成一个圆。



给这个圆加上旋转的动画。CSS中旋转角度的动画是rotate()我们只要给他设定从0旋转到360即可。(这个动画会在下面多次使用,下文不再赘述)


 @-webkit-keyframes rotation {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}

完整代码


.loader-1 {
width: 48px;
height: 48px;
border: 5px solid #FFF;
border-bottom-color: #FF3D00;
border-radius: 50%;
display: inline-block;
-webkit-animation: rotation 1s linear infinite;
animation: rotation 1s linear infinite;
}

loader-2



观察:外围是一个圈,内部有一个红色的元素在转动。


实现逻辑


一个宽高相等的容器,加上白色的边,50%的圆角。这样就是外围的圈。



里面的红色是如何实现?这里有两个思路。1;新增一个小div,让他在里面,并且跟loader-1一样,设置一个红色的底边。2:使用::after,思路跟方法1 一致。



加上旋转的动画。


完整代码


.loader-2 {
width: 48px;
height: 48px;
border: 3px solid #FFF;
border-radius: 50%;
display: inline-block;
position: relative;
-webkit-animation: rotation 1s linear infinite;
animation: rotation 1s linear infinite;
}

.loader-2:after {
content: "";
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 40px;
height: 40px;
border-radius: 50%;
border: 3px solid transparent;
border-bottom-color: #FF3D00;
}

loader-3



观察:内部是一个圆,外围是一个红色的圆弧。


实现逻辑


这个加载效果跟loader-2是一致的,区别就是红色圆弧在内外。


完整代码


.loader-3 {
width: 48px;
height: 48px;
border: 3px solid #FFF;
border-radius: 50%;
display: inline-block;
position: relative;
-webkit-animation: rotation 1s linear infinite;
animation: rotation 1s linear infinite;
}

.loader-3:after {
content: "";
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 56px;
height: 56px;
border-radius: 50%;
border: 3px solid transparent;
border-bottom-color: #FF3D00;
}

loader-4



观察:外围是一个圆,内部有两个圆,这两个圆正好是对称的。


实现逻辑


一个宽高相等的容器,加上白色的边,50%的圆角。这样就是外围的圈。



里面的红色是如何实现?这里有两个思路。1;新增两个小div,背景颜色设置为红色,然后设置50%圆角,这样看上去就像是两个小点。2:使用::after和::before,思路跟方法1 一致。



加上旋转的动画。


完整代码


.loader-4 {
width: 48px;
height: 48px;
border: 2px solid #FFF;
border-radius: 50%;
display: inline-block;
position: relative;
-webkit-animation: rotation 1s linear infinite;
animation: rotation 1s linear infinite;
}

.loader-4:before {
left: auto;
top: auto;
right: 0;
bottom: 0;
content: "";
position: absolute;
background: #FF3D00;
width: 6px;
height: 6px;
transform: translate(150%, 150%);
border-radius: 50%;
}

.loader-4:after {
content: "";
position: absolute;
left: 0;
top: 0;
background: #FF3D00;
width: 6px;
height: 6px;
transform: translate(150%, 150%);
border-radius: 50%;
}

loader-5



观察:一共是三层,最外层白圈,中间红圈,里边白圈。每一圈都有一个一半圆弧的缺口,外圈和最内圈的旋转方向一致。


实现逻辑


一个宽高相等的容器,加上白色的边,50%的圆角。这样就是外围的圈。



这里的问题是,圈的缺口如何实现,其实很简单,在css中有一个属性值:transparent,利用这个值给边框设置透明,即可实现缺口。



对于内部的红色和白色圆弧,继续使用::after和::before即可。



加上动画,这里有一个反方向旋转的动画(rotationBack)。
这里设置旋转是往负角度,旋转即可反方向旋转。


  @keyframes rotationBack {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(-360deg);
}
}

完整代码


.loader-5 {
width: 48px;
height: 48px;
border-radius: 50%;
display: inline-block;
position: relative;
border: 3px solid;
border-color: #FFF #FFF transparent transparent;
-webkit-animation: rotation 1s linear infinite;
animation: rotation 1s linear infinite;
}

.loader-5:before {
width: 32px;
height: 32px;
border-color: #FFF #FFF transparent transparent;
-webkit-animation: rotation 1.5s linear infinite;
animation: rotation 1.5s linear infinite;
}

.loader-5:after, .loader-5:before {
content: "";
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
margin: auto;
border: 3px solid;
border-color: transparent transparent #FF3D00 #FF3D00;
width: 40px;
height: 40px;
border-radius: 50%;
-webkit-animation: rotationBack 0.5s linear infinite;
animation: rotationBack 0.5s linear infinite;
transform-origin: center center; *
}

loader-6



观察:看上去像是一个时钟,一个圆里面有一根指针。


实现逻辑


一个宽高相等的容器,加上白色的边,50%的圆角。这样就是外围的圈。



指针是如何实现的:从这里开始不再讨论新增div的情况
其实红色的指针就是一个单纯的宽高不一致的容器。



完整代码


.loader-6 {
width: 48px;
height: 48px;
border: 2px solid #FFF;
border-radius: 50%;
display: inline-block;
position: relative;
-webkit-animation: rotation 1s linear infinite;
animation: rotation 1s linear infinite;
}


.loader-6:after {
content: "";
position: absolute;
left: 50%;
top: 0;
background: #FF3D00;
width: 3px;
height: 24px;
transform: translateX(-50%);
}

loader-7



观察:首先确定几个圈,一共两个。当第一个圈还没消失,第二个圈已经出现。最后出现了类似水波的效果。同时要注意的是,这两个两个圈是一样大的,这是因为他们最终消失的地方是一致的。


实现逻辑


首先确定,这两个圈是否在容器上。上面一直时在容器上添加边框,当然这个例子也可以,但是为了实现的简单,我们把这两个圈放在::after和::before中。



加上动画,这里的圈是逐渐放大的,在CSS中scale用来放大缩小元素。同时为了实现波纹逐渐清晰的效果,我们加上透明度。


  @keyframes animloader7 {
0% {
transform: scale(0);
opacity: 1;
}
100% {
transform: scale(1);
opacity: 0;
}
}

完整代码


这里因为两个圈是先后出现的,所以需要一个圈加上delay


.loader-7 {
width: 48px;
height: 48px;
display: inline-block;
position: relative;
}

.loader-7::after, .loader--7::before {
content: "";
width: 48px;
height: 48px;
border-radius: 50%;
border: 2px solid #FFF;
position: absolute;
left: 0;
top: 0;
-webkit-animation: animloader7 2s linear infinite;
animation: animloader7 2s linear infinite;
}

.loader-7::after {
-webkit-animation-delay: 1s;
animation-delay: 1s;
}
.loader-7::after, .loader-7::before {
content: "";
width: 48px;
height: 48px;
border-radius: 50%;
border: 2px solid #FFF;
position: absolute;
left: 0;
top: 0;
-webkit-animation: animloader7 2s linear infinite;
animation: animloader7 2s linear infinite;
}

loader-8



观察:一段圆弧加上一个三角形。


实现逻辑


一个宽高相等的容器,加上白色的边,50%的圆角。这样就是外围的圈。



transparent,利用这个值给边框设置透明,即可实现缺口。



在:after上创建箭头。CSS中我们有多种方法实现三角形,其中最简单是使用border,不需要给元素设置宽高,只需要设置border的大小,并且只有一边设置颜色。


border: 10px solid transparent;
border-right-color: #FFF

加上旋转动画。


完整代码


.loader-8 {
width: 48px;
height: 48px;
border: 3px solid #FFF;
border-bottom-color: transparent;
border-radius: 50%;
display: inline-block;
position: relative;
-webkit-animation: rotation 1s linear infinite;
animation: rotation 1s linear infinite;
}

.loader-8:after {
content: "";
position: absolute;
left: 20px;
top: 31px;
border: 10px solid transparent;
border-right-color: #FFF;
transform: rotate(-40deg);
}



收起阅读 »

Java 内存模型

运行时的数据区(Runtime Data Area)本文主要看 JVM 内存模型主要指运行时的数据区,包括 5 个部分,如下图所示。虚拟机栈:也叫方法栈,是线程私有的,线程在执行每个方法时,JVM 都会在虚拟机栈中创建一个栈帧,用来存储局部变量表、操作栈、动态...
继续阅读 »

运行时的数据区(Runtime Data Area)

本文主要看 JVM 内存模型主要指运行时的数据区,包括 5 个部分,如下图所示。

虚拟机栈:也叫方法栈,是线程私有的,线程在执行每个方法时,JVM 都会在虚拟机栈中创建一个栈帧,用来存储局部变量表、操作栈、动态链接、方法出口等信息。调用方法时执行入栈,方法返回时执行出栈。

本地方法栈:与栈类似,也是用来保存线程执行方法时的信息,不同的是,执行 Java 方法使用栈,而执行 native 方法使用本地方法栈。

程序计数器:保存着当前线程所执行的字节码位置,每个线程工作时都有一个独立的计数器。程序计数器为执行 Java 方法服务,执行 native 方法时,程序计数器为空(Undefined)。

栈、本地方法栈、程序计数器这三个部分都是线程独占的。

:是 JVM 管理的内存中最大的一块,堆被所有线程共享,目的是为了存放对象实例,几乎所有的对象实例都在这里分配。当堆内存没有可用的空间时,会抛出 OOM 异常。根据对象存活的周期不同,JVM 把堆内存进行分代管理,由垃圾回收器来进行对象的回收管理。

方法区:也是各个线程共享的内存区域,又叫非堆区。用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据,JDK 1.7 中的永久代和 JDK 1.8 中的 Metaspace 都是方法区的一种实现。

注意:面试回答这个问题时,要答出两个要点:一个是各部分的功能,另一个是哪些线程共享,哪些独占。

下面咱们详细来了解这五个部分。

1. 虚拟机栈(Stack)

虚拟机栈也是线程私有的,与线程的生命周期同步。在 Java 虚拟机规范中,对这个区域规定了两种异常状况:

  1. StackOverflowError:当线程请求栈深度超出虚拟机栈所允许的深度时抛出。
  2. OutOfMemoryError:当 Java 虚拟机动态扩展到无法申请足够内存时抛出。

在我们学习 Java 虚拟机的的过程当中,经常会看到一句话:

JVM 基于栈,而 Dalvik(DVM) 基于寄存器。

这里的"基于栈"指的就是虚拟机栈。虚拟机栈的初衷是用来描述 Java 方法执行的内存模型,线程在执行每个方法时,JVM 都会在虚拟机栈中创建一个栈帧,下面咱们来看看这个栈帧?

栈帧

栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构,每一个线程在执行某个方法时,都会为这个方法创建一个栈帧。

我们可以这样理解:一个线程包含多个栈帧,而每个栈帧内部包含局部变量表、操作数栈、动态连接、返回地址等。

局部变量表

局部变量表是变量值的存储空间,我们调用方法时传递的参数,以及在方法内部创建的局部变量都保存在局部变量表中。在 Java 编译成 class 文件的时候,就会在方法的 Code 属性表中的 max_locals 数据项中,确定该方法需要分配的最大局部变量表的容量。

注意:系统不会为局部变量赋予初始值(实例变量和类变量都会被赋予初始值),也就是说不存在类变量那样的准备阶段。

操作数栈

操作数栈(Operand Stack)也常称为操作栈,它是一个后入先出栈(LIFO)。

同局部变量表一样,操作数栈的最大深度也在编译的时候写入方法的Code属性表中的max_stacks数据项中。栈中的元素可以是任意Java数据类型,包括long和double。

当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的。在方法执行的过程中,会有各种字节码指令被压入和弹出操作数栈(比如:iadd指令就是将操作数栈中栈顶的两个元素弹出,执行加法运算,并将结果重新压回到操作数栈中)。

动态链接

动态链接的主要目的是为了支持方法调用过程中的动态连接(Dynamic Linking)。

在一个 class 文件中,一个方法要调用其他方法,需要将这些方法的符号引用转化为其所在内存地址中的直接引用,而符号引用存在于方法区中。

Java 虚拟机栈中,每个栈帧都包含一个指向运行时常量池中该栈所属方法的符号引用,持有这个引用的目的就是为了支持方法调用过程中的动态连接(Dynamic Linking)。

返回地址

当一个方法开始执行后,只有两种方式可以退出这个方法:

  • 正常退出:指方法中的代码正常完成,或者遇到任意一个方法返回的字节码指令(如return)并退出,没有抛出任何异常。

  • 异常退出:指方法执行过程中遇到异常,并且这个异常在方法体内部没有得到处理,导致方法退出。

无论当前方法采用何种方式退出,在方法退出后都需要返回到方法被调用的位置,程序才能继续执行。而虚拟机栈中的“返回地址”就是用来帮助当前方法恢复它的上层方法执行状态。

一般来说,方法正常退出时,调用者的 PC 计数值可以作为返回地址,栈帧中可能保存此计数值。而方法异常退出时,返回地址是通过异常处理器表确定的,栈帧中一般不会保存此部分信息。

2. 本地方法栈(Native Method Stack)

本地方法栈和上面介绍的虚拟栈基本相同,只不过是针对本地(native)方法。在开发中如果涉及 JNI 可能接触本地方法栈多一些,在有些虚拟机的实现中已经将两个合二为一了(比如HotSpot)。

3. 程序计数器(Program Counter Register)

Java 程序是多线程的,CPU 可以在多个线程中分配执行时间片段。当某一个线程被 CPU 挂起时,需要记录代码已经执行到的位置,方便 CPU 重新执行此线程时,知道从哪行指令开始执行。这就是程序计数器的作用。

"程序计数器"是虚拟机中一块较小的内存空间,主要用于记录当前线程执行的位置。

实际上一些我们熟悉的恢复线程操作、分支操作、循环操作、跳转、异常处理等都需要依赖这个计数器来完成。

关于程序计数器还有几点需要格外注意:

  1. 在 Java 虚拟机规范中,对程序计数器这一区域没有规定任何 OutOfMemoryError 情况(或许是感觉没有必要吧)。

  2. 线程私有的,每条线程内部都有一个私有程序计数器。它的生命周期随着线程的创建而创建,随着线程的结束而死亡。

  3. 当一个线程正在执行一个 Java 方法的时候,这个计数器记录的是正在执行的虚拟机字节码指令的地址。如果正在执行的是 Native 方法,这个计数器值则为空(Undefined)。

4.堆(Heap)

Java 堆(Heap)是 JVM 所管理的内存中最大的一块,该区域唯一目的就是存放对象实例,几乎所有对象的实例都在堆里面分配,因此它也是 Java 垃圾收集器(GC)管理的主要区域,有时候也叫作"GC 堆"。

同时它也是所有线程共享的内存区域,因此被分配在此区域的对象如果被多个线程访问的话,需要考虑线程安全问题。

按照对象存储时间的不同,堆中的内存可以划分为新生代(Young)和老年代(Old),其中新生代又被划分为 Eden 和 Survivor 区。具体如下图所示:

  • 堆空间=新生代(1/3)+老年代(2/3)

  • 新生代= Eden(8/10)+from(1/10)+to(1/10)

图中不同的区域存放具有不同生命周期的对象。这样可以根据不同的区域使用不同的垃圾回收算法,从而更具有针对性,进而提高垃圾回收效率。

Ecen区

大多数情况下,对象会在新生代 Eden区中进行分配,当Eden区没有足够空间进行分配时,虚拟机会发起一次Minor GC,Minor GC 相比 Major GC更频繁,回收速度也更快。

通过Minor GC之后,Eden 会被清空,Eden区中绝大部分对象会被回收,而那些无需回收的存活对象,将会进到Survivor 的 From区(若 From区不够,则直接进入Old 区)。

Survivor区

Survivor区相当于是 Eden区和 Old区的一个缓冲,类似于我们交通灯中的黄灯。

Survivor 又分为2个区,一个是 From 区,一个是To 区。每次执行 Minor GC,会将Eden区和 From 存活的对象放到Survivor 的 To 区(如果To区不够,则直接进入Old 区)。

Survivor 的存在意义就是减少被送到老年代的对象,进而减少Major GC的发生。Survivor的预筛选保 证,只有经历16次 Minor GC还能在新生代中存活的对象,才会被送到老年代。

Old区

老年代占据着2/3的堆内存空间,只有在Major GC 的时候才会进行清理,每次GC都会触发"Stop-The-World"。

内存越大,STW的时间也越长,所以内存也不仅仅是越大就越好。由于复制算法在对象存活率较高的老年代会进行很多次的复制操作,效率很低,所以老年代这里采用的是标记-整理算法

5. 方法区

方法区(Method Area)也是 JVM 规范里规定的一块运行时数据区。方法区主要是存储已经被 JVM 加载的类信息(版本、字段、方法、接口)、常量、静态变量、即时编译器编译后的代码和数据。该区域同堆一样,也是被各个线程共享的内存区域。

注意:关于方法区,很多开发者会将其跟“永久区”混淆。

所以我在这里对这两个概念进行一下对比:

  • 方法区是 JVM 规范中规定的一块区域,但是并不是实际实现,切忌将规范跟实现混为一谈,不同的 JVM 厂商可以有不同版本的“方法区”的实现。

  • HotSpot 在 JDK 1.7 以前使用“永久区”(或者叫 Perm 区)来实现方法区,在 JDK 1.8 之后“永久区”就已经被移除了,取而代之的是一个叫作“元空间(metaspace)”的实现方式。

总结一下就是:

  • 方法区是规范层面的东西,规定了这一个区域要存放哪些数据。

  • 永久区或者是 metaspace 是对方法区的不同实现,是实现层面的东西。

内存溢出与内存泄漏

  • 内存泄漏:分配出去的内存回收不了

  • 内存溢出:指系统内存不够用了

堆溢出

可以分为:内存泄漏和内存溢出,这两种情况都会抛出OutOfMemoryError异常。

内存泄露

内存泄漏是指对象实例在新建和使用完毕后,仍然被引用,没能被垃圾回收释放,一直积累,直到没有剩余内存可用。如果内存泄露,我们要找出泄露的对象是怎么被GC ROOT引用起来,然后通过引用链来具体分析泄露的原因。

内存溢出

内存溢出是指当我们新建一个实力对象时,实例对象所需占用的内存空间大于堆的可用空间。如果出现了内存溢出问题,这往往是程序本生需要的内存大于了我们给虚拟机配置的内存,这种情况下,我们可以采用调大-Xmx来解决这种问题。

栈溢出

栈(JVM Stack)存放主要是栈帧( 局部变量表,操作数栈, 动态链接,方法出口信息)的地方。注意区分栈和栈帧:栈里包含栈帧。

与线程栈相关的内存异常有两个:

  • StackOverflowError:当线程请求栈深度超出虚拟机栈所允许的深度时抛出(方法调用层次太深,内存不够新建栈帧) 。
  • OutOfMemoryError:当 Java 虚拟机动态扩展到无法申请足够内存时抛出(线程太多,内存不够新建线程) 。

StackOverflowError

递归调用是造成StackOverflowError的一个常见场景。因此当需要使用递归时,需要格外谨慎。

OutOfMemoryError

理论上,虚拟机栈、堆、方法区都有发生OutOfMemoryError的可能。但是实际项目中,大多发生于堆当中。

小结

千万不要将内存模型理解为虚拟机的"具体实现",虚拟机的具体实现有很多,比如 Sun 公司的 HotSpot、JRocket、IBM J9、以及我们非常熟悉的 Android Dalvik 和 ART 等。这些具体实现在符合上面 5 种运行时数据区的前提下,又各自有不同的实现方式。

JVM 的运行时内存结构中一共有两个"栈"和一个"堆",分别是:Java 虚拟机栈和本地方法栈,以及"GC堆"和方法区。除此之外还有一个程序计数器(几乎不会用到这一部分,忽略)。

JVM 内存中只有堆和方法区线程共享的数据区域,其它区域都是线程私有的。并且程序计数器是唯一一个在 Java 虚拟机规范中没有规定任何 OutOfMemoryError 情况的区域。

收起阅读 »

Java 类加载器

类加载器 ClassLoader。在Java 内存模型我们介绍了 Java 字节码文件(.class)的格式。一个完整的 Java 程序是由多个 .class 文件组成的,在程序运行过程中,需要将这些 .class 文件加载到 JVM 中才可以使用。而负责加载...
继续阅读 »

类加载器 ClassLoader。

Java 内存模型我们介绍了 Java 字节码文件(.class)的格式。一个完整的 Java 程序是由多个 .class 文件组成的,在程序运行过程中,需要将这些 .class 文件加载到 JVM 中才可以使用。而负责加载这些 .class 文件的就是类加载器(ClassLoader)。

Java 中的类何时被加载器加载

在 Java 程序启动的时候,并不会一次性加载程序中所有的 .class 文件,而是在程序的运行过程中,动态地加载相应的类到内存中。

通常情况下,Java 程序中的 .class 文件会在以下 2 种情况下被 ClassLoader 主动加载到内存中:

  • 调用类构造器

  • 调用类中的静态(static)变量或者静态方法

Java 中 ClassLoader

JVM 中自带 3 个类加载器:

  • 启动类加载器 BootstrapClassLoader

  • 扩展类加载器 ExtClassLoader (JDK 1.9 之后,改名为 PlatformClassLoader)

  • 系统加载器 APPClassLoader

以上 3 者在 JVM 中有各自分工,但是又互相有依赖。

类加载机制

类的加载指将编译好的 Class 类文件中的字节码读入内存中,将其放在方法区内并创建对应的 Class 对象。类的加载分为加载、链接、初始化,其中链接又包括验证、准备、解析三步。如下图所示。

  • 加载是文件到内存的过程。通过类的完全限定名查找此类字节码文件,并利用字节码文件创建一个 Class 对象。

  • 验证是对类文件内容验证。目的在于确保 Class 文件符合当前虚拟机要求,不会危害虚拟机自身安全。主要包括四种:文件格式验证,元数据验证,字节码验证,符号引用验证。

  • 准备阶段是进行内存分配。为类变量也就是类中由 static 修饰的变量分配内存,并且设置初始值。这里要注意,初始值是 0 或者 null,而不是代码中设置的具体值,代码中设置的值是在初始化阶段完成的。另外这里也不包含用 final 修饰的静态变量,因为 final 在编译的时候就会分配。

  • 解析主要是解析字段、接口、方法。主要是将常量池中的符号引用替换为直接引用的过程。直接引用就是直接指向目标的指针、相对偏移量等。

  • 初始化,主要完成静态块执行与静态变量的赋值。这是类加载最后阶段,若被加载类的父类没有初始化,则先对父类进行初始化。

只有对类主动使用时,才会进行初始化,初始化的触发条件包括在创建类的实例时、访问类的静态方法或者静态变量时、Class.forName() 反射类时、或者某个子类被初始化时。

如上图所示,浅绿的两个部分表示类的生命周期,就是从类的加载到类实例的创建与使用,再到类对象不再被使用时可以被 GC 卸载回收。这里要注意一点,由 Java 虚拟机自带的三种类加载器加载的类在虚拟机的整个生命周期中是不会被卸载的,只有用户自定义的类加载器所加载的类才可以被卸载。

类加载器

如上图所示,Java 自带的三种类加载器分别是:BootStrap 启动类加载器、扩展类加载器和应用加载器(也叫系统加载器)。图右边的桔黄色文字表示各类加载器对应的加载目录。启动类加载器加载 java home 中 lib 目录下的类,扩展加载器负责加载 ext 目录下的类,应用加载器加载 classpath 指定目录下的类。除此之外,可以自定义类加载器。

BootstrapClassLoader 启动类加载器

它并不是使用 Java 代码实现的,而是由 C/C++ 语言编写的,它本身属于虚拟机的一部分。因此我们无法在 Java 代码中直接获取它的引用。

Bootstrap类加载器负责加载rt.jar 中的JDK类文件,它是所有类加载器的父加载器。Bootstrap类加载器没有任何父类加载器,如果尝试在 Java 层获取 BootstrapClassLoader(String.class.getClassLoader()) 的引用,系统会返回 null,任何基于此的代码会抛出NullPointerException 异常。Bootstrap加载器被称为初始类加载器。

ExtClassLoader 扩展类加载器

而Extension将加载类的请求先委托给它的父加载器,也就是Bootstrap,如果没有成功加载的话,再从 jre/lib/ext目录下或者java.ext.dirs系统属性定义的目录下加载类。Extension加载器由sun.misc.Launcher$ExtClassLoader 实现

APPClassLoader 系统类加载器

默认的加载器就是System类加载器(又叫作Application类加载器)了。它负责从classpath环境变量中加载某些应用相关的类,classpath环境变量通常由-classpath或-cp命令行选项来定义,或者是JAR中的Manifest的classpath 属性。Application类加载器是Extension类加载器的子加载器

双亲委派模式(Parents Delegation Model)

既然 JVM 中已经有了这 3 种 ClassLoader,那么 JVM 又是如何知道该使用哪一个类加载器去加载相应的类呢?答案就是:双亲委派模式

所谓双亲委派模式就是,当类加载器收到加载类或资源的请求时,通常都是先委托给父类加载器加载,也就是说,只有当父类加载器找不到指定类或资源时,自身才会执行实际的类加载过程。

注意:"双亲委派"机制只是 Java 推荐的机制,并不是强制的机制。我们可以继承 java.lang.ClassLoader 类,实现自己的类加载器。如果想保持双亲委派模型,就应该重写 findClass(name) 方法;如果想破坏双亲委派模型,可以重写 loadClass(name) 方法。

Custom ClassLoader

JVM 中预置的 3 种 ClassLoader 只能加载特定目录下的 .class 文件,如果我们想加载其他特殊位置下的 jar 包或类时(比如,我要加载网络或者磁盘上的一个 .class 文件),默认的 ClassLoader 就不能满足我们的需求了,所以需要定义自己的 Classloader 来加载特定目录下的 .class 文件。

自定义 ClassLoader 步骤:

  1. 自定义一个类继承抽象类 ClassLoader。

  2. 重写 findClass 方法。

  3. 在 findClass 中,调用 defineClass 方法将字节码转换成 Class 对象,并返回。

public class DiskClassLoader extends ClassLoader{
String filePath;
public DiskClassLoader(String filePath){
this.filePath = filePath;
}

@RequiresApi(api = Build.VERSION_CODES.O)
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
String path = filePath+name+".class";
byte [] classBytes = null;
try {
classBytes = Files.readAllBytes(Paths.get(new URI(path)));
} catch (IOException e) {
e.printStackTrace();
} catch (URISyntaxException e) {
e.printStackTrace();
}
return defineClass(name,classBytes,0,classBytes.length);
}
}

复制代码

注意:如没有特殊的要求,一般不建议重写loadClass搜索类的算法。

加载器小结

Java 的类加载使用双亲委派模式,即一个类加载器在加载类时,先把这个请求委托给自己的父类加载器去执行,如果父类加载器还存在父类加载器,就继续向上委托,直到顶层的启动类加载器,如上图中蓝色向上的箭头。如果父类加载器能够完成类加载,就成功返回,如果父类加载器无法完成加载,那么子加载器才会尝试自己去加载。如图中的桔黄色向下的箭头。

这种双亲委派模式的好处,可以避免类的重复加载,另外也避免了 Java 的核心 API 被篡改。

Android 中的 ClassLoader

本质上,Android 和传统的 JVM 是一样的,也需要通过 ClassLoader 将目标类加载到内存,类加载器之间也符合双亲委派模型。但是在 Android 中, ClassLoader 的加载细节有略微的差别。

在 Android 虚拟机里是无法直接运行 .class 文件的,Android 会将所有的 .class 文件转换成一个 .dex 文件,并且 Android 将加载 .dex 文件的实现封装在 BaseDexClassLoader 中,而我们一般只使用它的两个子类:PathClassLoader 和 DexClassLoader。

DexClassLoader

先来看官方对 DexClassLoader 的描述:

A class loader that loads classes from {@code .jar} and {@code .apk} files containing a {@code classes.dex} entry. This can be used to execute code not installed as part of an application.

DexClassLoader 可以从 SD 卡上加载包含 class.dex 的 .jar 和 .apk 文件,这也是插件化和热修复的基础,在不需要安装应用的情况下,完成需要使用的 dex 的加载。

DexClassLoader 的源码里面只有一个构造方法,代码如下:

public class DexClassLoader extends BaseDexClassLoader {
public DexClassLoader(String dexPath, String optimizedDirectory, String librarySearchPath, ClassLoader parent) {
super((String)null, (File)null, (String)null, (ClassLoader)null);
throw new RuntimeException("Stub!");
}
}
复制代码

参数说明:

  • dexPath:包含 class.dex 的 apk、jar 文件路径 ,多个路径用文件分隔符(默认是":")分隔。

  • optimizedDirectory:此参数已弃用,自 API 级别 26 起无效。

  • librarySearchPath:C/C++ native 库的路径,多个路径用文件分隔符分隔; 可能是null。

  • parent:父类加载器

PathClassLoader

PathClassLoader 用来加载系统 apk 和被安装到手机中的 apk 内的 dex 文件。它的 2 个构造函数如下:

public class PathClassLoader extends BaseDexClassLoader {
public PathClassLoader(String dexPath, ClassLoader parent) {
super((String)null, (File)null, (String)null, (ClassLoader)null);
throw new RuntimeException("Stub!");
}

public PathClassLoader(String dexPath, String librarySearchPath, ClassLoader parent) {
super((String)null, (File)null, (String)null, (ClassLoader)null);
throw new RuntimeException("Stub!");
}

@SystemApi(client = MODULE_LIBRARIES)
public PathClassLoader(
@NonNull String dexPath, @Nullable String librarySearchPath, @Nullable ClassLoader parent,
@Nullable ClassLoader[] sharedLibraryLoaders) {
super(dexPath, librarySearchPath, parent, sharedLibraryLoaders);
}
}

复制代码

参数说明:

  • dexPath:dex 文件路径,或者包含 dex 文件的 jar 包路径;

  • librarySearchPath:C/C++ native 库的路径,多个路径用文件分隔符分隔; 可能是null。

  • parent:父类加载器

PathClassLoader 里面除了上面这些以外就没有其他的代码了,具体的实现都是在 BaseDexClassLoader 里面,其 dexPath 比较受限制,一般是已经安装应用的 apk 文件路径。

当一个 App 被安装到手机后,apk 里面的 class.dex 中的 class 均是通过 PathClassLoader 来加载的,可以通过如下代码验证:

public class MainActivity extends ActivityBase {
@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
MLog.e(this.getClass().getName(), "onCreate");
setContentView(R.layout.activity_main);
ClassLoader classLoader = MainActivity.class.getClassLoader();
MLog.e(classLoader.toString());
}
}
复制代码

打印结果如下:

2021-09-26 17:55:56.530 /com.scc.demo E/-SCC-com.scc.demo.actvitiy.MainActivity: onCreate
2021-09-26 17:55:56.770 /com.scc.demo E/-SCC-:
dalvik.system.PathClassLoader[DexPathList[[zip file "/data/app/com.scc.demo-0huSvtxqzKDw3GXvCu3P8g==/base.apk"],
nativeLibraryDirectories=[
/data/app/com.scc.demo-0huSvtxqzKDw3GXvCu3P8g==/lib/arm64,
/data/app/com.scc.demo-0huSvtxqzKDw3GXvCu3P8g==/base.apk!/lib/arm64-v8a,
/system/lib64,
/system/product/lib64]]]

复制代码

小结

  • ClassLoader 就是用来加载 class 文件的,不管是 jar 中还是 dex 中的 class。

  • Java 中的 ClassLoader 通过双亲委托来加载各自指定路径下的 class 文件。

  • 可以自定义 ClassLoader,一般覆盖 findClass() 方法,不建议重写 loadClass 方法。

  • Android 中常用的两种 ClassLoader 分别为:PathClassLoader 和 DexClassLoader。


收起阅读 »

Java 垃圾回收(GC)

前言垃圾回收(Garbage Collection,简写为 GC)可能是虚拟机众多知识点中最为大众所熟知的一个了,也是Java开发者最关注的一块知识点。Java 语言开发者比 C 语言开发者幸福的地方就在于,我们不需要手动释放对象的内存,JVM 中的垃圾回收器...
继续阅读 »

前言

垃圾回收(Garbage Collection,简写为 GC)可能是虚拟机众多知识点中最为大众所熟知的一个了,也是Java开发者最关注的一块知识点。Java 语言开发者比 C 语言开发者幸福的地方就在于,我们不需要手动释放对象的内存,JVM 中的垃圾回收器(Garbage Collector)会为我们自动回收。但是这种幸福是有代价的:一旦这种自动化机制出错,我们又不得不去深入理解 GC 回收机制,甚至需要对这些"自动化"的技术实施必要的监控和调节。

程序计数器、虚拟机栈、本地方法栈 3 个区域随线程而生,随线程而灭;栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作,这几个区域内不需要过多考虑回收的问题。

而堆和方法区则不一样,一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,我们只有在程序处于运行期间时才能知道会创建哪些对象,这部分内存的分配和回收都是动态的,垃圾收集器所关注的就是这部分内存。

什么是垃圾

所谓垃圾就是内存中已经没有用的对象。 既然是"垃圾回收",那就必须知道哪些对象是垃圾。Java 虚拟机中使用一种叫作可达性分析的算法来决定对象是否可以被回收。

可达性分析

可达性分析算法是从离散数学中的图论引入的,JVM 把内存中所有的对象之间的引用关系看作一张图,通过一组名为”GC Root"的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,最后通过判断对象的引用链是否可达来决定对象是否可以被回收。如下图所示:

比如上图中,对象ObjA/ObjB/ObjC 与 GC Root 之间都存在一条直接或者间接的引用链,这也代表它们与 GC Root 之间是可达的,因此它们是不能被 GC 回收掉的。

而对象E和被对d 引用到,但是并不存在一条引用链连接它们与 GC Root,所以当 GC 进行垃圾回收时,只要遍历到 D/E/F 这 3 个对象,就会将它们回收。

注意:上图中圆形图标虽然标记的是对象,但实际上代表的是此对象在内存中的引用。包括 GC Root 也是一组引用而并非对象。

GC Root 对象

在 Java 中,有以下几种对象可以作为 GC Root:

  1. Java 虚拟机栈(局部变量表)中的引用的对象。
  2. 方法区中静态引用指向的对象。
  3. 仍处于存活状态中的线程对象。
  4. Native 方法中 JNI 引用的对象。

什么时候回收

不同的虚拟机实现有着不同的 GC 实现机制,但是一般情况下每一种 GC 实现都会在以下两种情况下触发垃圾回收。

  • Allocation Failure:在堆内存中分配时,如果因为可用剩余空间不足导致对象内存分配失败,这时系统会触发一次 GC。

  • System.gc():在应用层,Java 开发工程师可以主动调用此 API 来请求一次 GC。

如何回收垃圾

由于垃圾收集算法的实现涉及大量的程序细节,各家虚拟机厂商对其实现细节各不相同,因此本课时并不会过多的讨论算法的实现,只是介绍几种算法的思想以及优缺点。

标记清除算法(Mark and Sweep GC)

从"GC Roots"集合开始,将内存整个遍历一次,保留所有可以被 GC Roots 直接或间接引用到的对象,而剩下的对象都当作垃圾对待并回收,过程分两步。

  1. Mark 标记阶段:找到内存中的所有 GC Root 对象,只要是和 GC Root 对象直接或者间接相连则标记为灰色(也就是存活对象),否则标记为黑色(也就是垃圾对象)。
  2. Sweep 清除阶段:当遍历完所有的 GC Root 之后,则将标记为垃圾的对象直接清除。

如下图所示:

  • 优点:实现简单,不需要将对象进行移动。
  • 缺点:这个算法需要中断进程内其他组件的执行(stop the world),并且可能产生内存碎片,提高了垃圾回收的频率。

复制算法(Copying)

将现有的内存空间分为两快,每次只使用其中一块,在垃圾回收时将正在使用的内存中的存活对象复制到未被使用的内存块中。之后,清除正在使用的内存块中的所有对象,交换两个内存的角色,完成垃圾回收。

  • 1.复制算法之前,内存分为 A/B 两块,并且当前只使用内存 A,内存的状况如下图。

  • 2.标记完之后,所有可达对象都被按次序复制到内存 B 中,并设置 B 为当前使用中的内存。内存状况如下图。

  • 优点:按顺序分配内存即可,实现简单、运行高效,不用考虑内存碎片。
  • 缺点:可用的内存大小缩小为原来的一半,对象存活率高时会频繁进行复制。

标记-压缩算法 (Mark-Compact)

需要先从根节点开始对所有可达对象做一次标记,之后,它并不简单地清理未标记的对象,而是将所有的存活对象压缩到内存的一端。最后,清理边界外所有的空间。因此标记压缩也分两步完成:

  1. Mark 标记阶段:找到内存中的所有 GC Root 对象,只要是和 GC Root 对象直接或者间接相连则标记为灰色(也就是存活对象),否则标记为黑色(也就是垃圾对象)。
  2. Compact 压缩阶段:将剩余存活对象按顺序压缩到内存的某一端。

  • 优点:这种方法既避免了碎片的产生,又不需要两块相同的内存空间,因此,其性价比比较高。
  • 缺点:所谓压缩操作,仍需要进行局部对象移动,所以一定程度上还是降低了效率。

JVM分代回收策略

Java 虚拟机根据对象存活的周期不同,把堆内存划分为几块,一般分为新生代、老年代,这就是 JVM 的内存分代策略。

注意: 在 HotSpot 中除了新生代和老年代,还有永久代

分代回收的中心思想就是:对于新创建的对象会在新生代中分配内存,此区域的对象生命周期一般较短。如果经过多次回收仍然存活下来,则将它们转移到老年代中。

年轻代(Young Generation)

新生成的对象优先存放在新生代中,新生代对象朝生夕死,存活率很低,在新生代中,常规应用进行一次垃圾收集一般可以回收 70%~95% 的空间,回收效率很高。新生代中因为要进行一些复制操作,所以一般采用的 GC 回收算法是复制算法。

Ecen区

大多数情况下,对象会在新生代 Eden区中进行分配,当Eden区没有足够空间进行分配时,虚拟机会发起一次Minor GC,Minor GC 相比 Major GC更频繁,回收速度也更快。

通过Minor GC之后,Eden 会被清空,Eden区中绝大部分对象会被回收,而那些无需回收的存活对象,将会进到Survivor 的 From区(若 From区不够,则直接进入Old 区)。

Survivor区

Survivor区相当于是 Eden区和 Old区的一个缓冲,类似于我们交通灯中的黄灯。

Survivor 又分为2个区,一个是 From 区,一个是To 区。每次执行 Minor GC,会将Eden区和 From 存活的对象放到Survivor 的 To 区(如果To区不够,则直接进入Old 区)。

Survivor 的存在意义就是减少被送到老年代的对象,进而减少Major GC的发生。Survivor的预筛选保 证,只有经历16次 Minor GC还能在新生代中存活的对象,才会被送到老年代。

Old区

老年代占据着2/3的堆内存空间,只有在Major GC 的时候才会进行清理,每次GC都会触发"Stop-The-World"。

内存越大,STW的时间也越长,所以内存也不仅仅是越大就越好。由于复制算法在对象存活率较高的老年代会进行很多次的复制操作,效率很低,所以老年代这里采用的是标记-整理算法

注意:对于老年代可能存在这么一种情况,老年代中的对象有时候会引用到新生代对象。这时如果要执行新生代 GC,则可能需要查询整个老年代上可能存在引用新生代的情况,这显然是低效的。所以,老年代中维护了一个 512 byte 的 card table,所有老年代对象引用新生代对象的信息都记录在这里。每当新生代发生 GC 时,只需要检查这个 card table 即可,大大提高了性能。

GC Log 分析

为了让上层应用开发人员更加方便的调试 Java 程序,JVM 提供了相应的 GC 日志。在 GC 执行垃圾回收事件的过程中,会有各种相应的 log 被打印出来。其中新生代和老年代所打印的日志是有区别的。

  • 新生代 GC:这一区域的 GC 叫作 Minor GC。因为 Java 对象大多都具备朝生夕灭的特性,所以 Minor GC 非常频繁,一般回收速度也比较快。
  • 老年代 GC:发生在这一区域的 GC 也叫作 Major GC 或者 Full GC。当出现了 Major GC,经常会伴随至少一次的 Minor GC。

注意:在有些虚拟机实现中,Major GC 和 Full GC 还是有一些区别的。Major GC 只是代表回收老年代的内存,而 Full GC 则代表回收整个堆中的内存,也就是新生代 + 老年代。

垃圾收集器

详解 CMS

基于分代回收理论,详细介绍几个典型的垃圾回收算法,先来看 CMS 回收算法。CMS 在 JDK1.7 之前可以说是最主流的垃圾回收算法。CMS 使用标记清除算法,优点是并发收集,停顿小。

从名字(包含"Mark Sweep")上就可以看出 CMS收集器是基于“标记-清除"算法实现的,它的运作过程相对于其他收集器来说要更复杂一些,整个过程分为4个步骤,包括:

  • 初始标记(CMS initial mark)
  • 并发标记(CMs concurrent mark)
  • 重新标记(CMS remark)
  • 并发清除(CMS concurrent sweep)

CMS 算法如下图所示。

 

  • 第一个阶段是初始标记,这个阶段会 stop the world,标记的对象只是从 root 集最直接可达的对象;

  • 第二个阶段是并发标记,这时 GC 线程和应用线程并发执行。主要是标记可达的对象;

  • 第三个阶段是重新标记阶段,这个阶段是第二个 stop the world 的阶段,停顿时间比并发标记要小很多,但比初始标记稍长,主要对对象进行重新扫描并标记;

  • 第四个阶段是并发清理阶段,进行并发的垃圾清理;

  • 最后一个阶段是并发重置阶段,为下一次 GC 重置相关数据结构。

G1 收集器

G1 在 1.9 版本后成为 JVM 的默认垃圾回收算法,G1 的特点是保持高回收率的同时,减少停顿。

G1 算法取消了堆中年轻代与老年代的物理划分,但它仍然属于分代收集器。G1 算法将堆划分为若干个区域,称作 Region,如下图中的小方格所示。一部分区域用作年轻代,一部分用作老年代,另外还有一种专门用来存储巨型对象的分区。

G1 也和 CMS 一样会遍历全部的对象,然后标记对象引用情况,在清除对象后会对区域进行复制移动整合碎片空间。

G1 回收过程如下。

  • G1 的年轻代回收,采用复制算法,并行进行收集,收集过程会 STW。

  • G1 的老年代回收时也同时会对年轻代进行回收。主要分为四个阶段:

    • 依然是初始标记阶段完成对根对象的标记,这个过程是STW的;

    • 并发标记阶段,这个阶段是和用户线程并行执行的;

    • 最终标记阶段,完成三色标记周期;

    • 复制/清除阶段,这个阶段会优先对可回收空间较大的 Region 进行回收,即 garbage first,这也是 G1 名称的由来。

G1 采用每次只清理一部分而不是全部的 Region 的增量式清理,由此来保证每次 GC 停顿时间不会过长。

总结如下,G1 是逻辑分代不是物理划分,需要知道回收的过程和停顿的阶段。此外还需要知道,G1 算法允许通过 JVM 参数设置 Region 的大小,范围是 1~32MB,可以设置期望的最大 GC 停顿时间等。有兴趣读者也可以对 CMS 和 G1 使用的三色标记算法做简单了解。

详解 ZGC

ZGC 特点

ZGC 是最新的 JDK1.11 版本中提供的高效垃圾回收算法,ZGC 针对大堆内存设计可以支持 TB 级别的堆,ZGC 非常高效,能够做到 10ms 以下的回收停顿时间。

这么快的响应,ZGC 是如何做到的呢?这是由于 ZGC 具有以下特点。

  • ZGC 使用了着色指针技术,我们知道 64 位平台上,一个指针的可用位是 64 位,ZGC 限制最大支持 4TB 的堆,这样寻址只需要使用 42 位,那么剩下 22 位就可以用来保存额外的信息,着色指针技术就是利用指针的额外信息位,在指针上对对象做着色标记。

  • 第二个特点是使用读屏障,ZGC 使用读屏障来解决 GC 线程和应用线程可能并发修改对象状态的问题,而不是简单粗暴的通过 STW 来进行全局的锁定。使用读屏障只会在单个对象的处理上有概率被减速。

  • 由于读屏障的作用,进行垃圾回收的大部分时候都是不需要 STW 的,因此 ZGC 的大部分时间都是并发处理,也就是 ZGC 的第三个特点。

  • 第四个特点是基于 Region,这与 G1 算法一样,不过虽然也分了 Region,但是并没有进行分代。ZGC 的 Region 不像 G1 那样是固定大小,而是动态地决定 Region 的大小,Region 可以动态创建和销毁。这样可以更好的对大对象进行分配管理。

  • 第五个特点是压缩整理。CMS 算法清理对象时原地回收,会存在内存碎片问题。ZGC 和 G1 一样,也会在回收后对 Region 中的对象进行移动合并,解决了碎片问题。

虽然 ZGC 的大部分时间是并发进行的,但是还会有短暂的停顿。来看一下 ZGC 的回收过程。

ZGC 回收过程

如下图所示,使用 ZGC 算法进行回收,从上往下看。初始状态时,整个堆空间被划分为大小不等的许多 Region,即图中绿色的方块。

开始进行回收时,ZGC 首先会进行一个短暂的 STW,来进行 roots 标记。这个步骤非常短,因为 roots 的总数通常比较小。

然后就开始进行并发标记,如上图所示,通过对对象指针进行着色来进行标记,结合读屏障解决单个对象的并发问题。其实,这个阶段在最后还是会有一个非常短的 STW 停顿,用来处理一些边缘情况,这个阶段绝大部分时间是并发进行的,所以没有明显标出这个停顿。

下一个是清理阶段,这个阶段会把标记为不在使用的对象进行回收,如上图所示,把橘色的不在使用的对象进行了回收。

最后一个阶段是重定位,重定位就是对 GC 后存活的对象进行移动,来释放大块的内存空间,解决碎片问题。

重定位最开始会有一个短暂的 STW,用来重定位集合中的 root 对象。暂停时间取决于 root 的数量、重定位集与对象的总活动集的比率。

最后是并发重定位,这个过程也是通过读屏障,与应用线程并发进行的。

总结

本课时着重讲解了 JVM 中有关垃圾回收的相关知识点,其中重点介绍了使用可达性分析来判断对象是否可以被回收,以及 3 种垃圾回收算法。最后通过分析 GC Log 验证了 Java 虚拟机中内存分配及分代策略的一些细节。

虚拟机垃圾回收机制很多时候都是影响系统性能、并发能力的主要因素之一。尤其是对于从事 Android 开发的工程师来说,有时候垃圾回收会很大程度上影响 UI 线程,并造成界面卡顿现象。因此理解垃圾回收机制并学会分析 GC Log 也是一项必不可少的技能。

收起阅读 »

Java多线程5 Callable、Future 和FutureTask

前言创建线程的2种方式,一种是直接继承Thread,另外一种就是实现Runnable接口。这2种方式都有一个缺陷就是:在执行完任务之后无法获取执行结果。如果需要获取执行结果,就必须通过共享变量或者使用线程通信的方式来达到效果,这样使用起来就比较麻烦。而自从Ja...
继续阅读 »

前言

创建线程的2种方式,一种是直接继承Thread,另外一种就是实现Runnable接口。
这2种方式都有一个缺陷就是:在执行完任务之后无法获取执行结果。
如果需要获取执行结果,就必须通过共享变量或者使用线程通信的方式来达到效果,这样使用起来就比较麻烦。
而自从Java 1.5开始,就提供了Callable和Future,通过它们可以在任务执行完毕之后得到任务执行结果

1 Callable介绍

Callable接口代表一段可以调用并返回结果的代码;Future接口表示异步任务,是还没有完成的任务给出的未来结果。所以说Callable用于产生结果,Future用于获取结果。

Callable接口使用泛型去定义它的返回类型。Executors类提供了一些有用的方法在线程池中执行Callable内的任务。由于Callable任务是并行的(并行就是整体看上去是并行的,其实在某个时间点只有一个线程在执行),我们必须等待它返回的结果。
java.util.concurrent.Future对象为我们解决了这个问题。在线程池提交Callable任务后返回了一个Future对象,使用它可以知道Callable任务的状态和得到Callable返回的执行结果。Future提供了get()方法让我们可以等待Callable结束并获取它的执行结果。

2 Future介绍

2.1 在Future接口中声明了5个方法,下面依次解释每个方法的作用:
方法作用
cance(boolean mayInterruptIfRunning)试图取消执行的任务,参数为true时直接中断正在执行的任务,否则直到当前任务执行完成,成功取消后返回true,否则返回false
isCancelled()方法表示任务是否被取消成功,如果在任务正常完成前被取消成功,则返回 true。
isDone()方法表示任务是否已经完成,若任务完成,则返回true;
get()方法用来获取执行结果,这个方法会产生阻塞,会一直等到任务执行完毕才返回;
get(long timeout, TimeUnit unit)设定计算结果的返回时间,如果在规定时间内没有返回计算结果则抛出TimeOutException。
2.2 Future提供了三种功能:

1)判断任务是否完成;
2)能够中断任务;
3)能够获取任务执行结果。
因为Future只是一个接口,所以是无法直接用来创建对象使用的,因此就有了下面的FutureTask。

3 FutureTask

我们先来看一下FutureTask的实现:


public class FutureTask<V> implements RunnableFuture<V>
复制代码

FutureTask类实现了RunnableFuture接口,我们看一下RunnableFuture接口的实现:


public interface RunnableFuture<V> extends Runnable, Future<V> {
void run();
}
复制代码

可以看出RunnableFuture继承了Runnable接口和Future接口,而FutureTask实现了RunnableFuture接口。所以它既可以作为Runnable被线程执行,又可以作为Future得到Callable的返回值。

FutureTask提供了2个构造器:


public FutureTask(Callable<V> callable) {
}
public FutureTask(Runnable runnable, V result) {
}
复制代码

事实上,FutureTask是Future接口的一个唯一实现类。

4 Future和FutureTask的使用

4.1 使用Callable+Future获取执行结果

public class CallableFutureTest {


public static void main(String[] args) {
//创建线程池
ExecutorService es = Executors.newSingleThreadExecutor();
//创建Callable对象任务
CallableDemo calTask = new CallableDemo();
//提交任务并获取执行结果
Future<Integer> future = es.submit(calTask);
//关闭线程池
es.shutdown();
try {
Thread.sleep(2000);
System.out.println("主线程在执行其他任务");

if (future.get() != null) {
//输出获取到的结果
System.out.println("future.get()-->" + future.get());
} else {
//输出获取到的结果
System.out.println("future.get()未获取到结果");
}

} catch (Exception e) {
e.printStackTrace();
}
System.out.println("主线程在执行完成");
}


}


class CallableDemo implements Callable<Integer> {

private int sum;

@Override
public Integer call() throws Exception {
System.out.println("Callable子线程开始计算啦!");
Thread.sleep(2000);

for (int i = 0; i < 100; i++) {
sum = sum + i;
}
System.out.println("Callable子线程计算结束!");
return sum;
}
}
复制代码

Callable子线程开始计算啦!
Callable子线程计算结束!
主线程在执行其他任务
future.get()-->4950
主线程在执行完成
复制代码
4.2 使用Callable+Future获取执行结果

public class CallableFutureTest {


public static void main(String[] args) {
// //创建线程池
// ExecutorService es = Executors.newSingleThreadExecutor();
// //创建Callable对象任务
// CallableDemo calTask=new CallableDemo();
// //提交任务并获取执行结果
// Future<Integer> future =es.submit(calTask);
// //关闭线程池
// es.shutdown();

//创建线程池
ExecutorService es = Executors.newSingleThreadExecutor();
//创建Callable对象任务
CallableDemo calTask = new CallableDemo();
//创建FutureTask
FutureTask<Integer> futureTask = new FutureTask<>(calTask);
//执行任务
es.submit(futureTask);
//关闭线程池
es.shutdown();
try {
Thread.sleep(2000);
System.out.println("主线程在执行其他任务");

if (futureTask.get() != null) {
//输出获取到的结果
System.out.println("futureTask.get()-->" + futureTask.get());
} else {
//输出获取到的结果
System.out.println("futureTask.get()未获取到结果");
}

} catch (Exception e) {
e.printStackTrace();
}
System.out.println("主线程在执行完成");


}
}

class CallableDemo implements Callable<Integer> {

private int sum;

@Override
public Integer call() throws Exception {
System.out.println("Callable子线程开始计算啦!");
Thread.sleep(2000);

for (int i = 0; i < 100; i++) {
sum = sum + i;
}
System.out.println("Callable子线程计算结束!");
return sum;
}
}
复制代码

Callable子线程开始计算啦!
Callable子线程计算结束!
主线程在执行其他任务
futureTask.get()-->4950
主线程在执行完成
复制代码

但其实这两种方法最终是一样的:
第一种方式Callable+Future最终也是以Callable+FutureTask的形式实现的。
在第一种方式中调用了: Future future = executor.submit(task);
那就让我们看看executor.submit(task)的源码吧:


//java.util.concurrent.AbstractExecutorService类中
/**
* @throws RejectedExecutionException {@inheritDoc}
* @throws NullPointerException {@inheritDoc}
*/

public <T> Future<T> submit(Callable<T> task) {
if (task == null) throw new NullPointerException();
RunnableFuture<T> ftask = newTaskFor(task);//可以看到源码中其实是在submit(Callable<T> task)内部创建了一个RunnableFuture<T>接口实现类
execute(ftask);
return ftask;
}
复制代码

而FutureTask又是RunnableFuture的实现类,那就再看看newTaskFor(Callable callable)里面干了什么:


 protected <T> RunnableFuture<T> newTaskFor(Callable<T> callable) {
return new FutureTask<T>(callable);
}
复制代码

收起阅读 »

Java虚拟机系列五:android虚拟机

一.android虚拟机与hotspot虚拟机的区别android虚拟机非标准jvm实现存储和执行dex文件采用基于寄存器的指令集指令长度为2,4,6个字节,执行指令效率高,移植性差,依赖于平台hotspot虚拟机标准jvm实现存储和执行class文件采用基于...
继续阅读 »

一.android虚拟机与hotspot虚拟机的区别

android虚拟机

  • 非标准jvm实现
  • 存储和执行dex文件
  • 采用基于寄存器的指令集
  • 指令长度为2,4,6个字节,执行指令效率高,移植性差,依赖于平台

hotspot虚拟机

  • 标准jvm实现
  • 存储和执行class文件
  • 采用基于栈的指令集
  • 指令长度为1个字节,因此具备有很好的移植性。

二. dalvik和art

1.Dalvik虚拟机

是Google等厂商合作开发的Android移动设备平台的核心组成部分之一。它可以支持已转换为.dex(即DalvikExecutable)格式的Java应用程序的运行。

2.ART虚拟机(Android Runtime)

由Google公司研发,在Android5.0及后续Android版本中作为正式的运行时库取代了以往的Dalvik虚拟机。ART能够把应用程序的字节码转换为机器码,是Android所使用的一种新的虚拟机。

3.主要不同点

Dalvik采用的是JIT技术,而ART采用Ahead-of-time(AOT)技术。ART同时也改善了性能、垃圾回收、应用程序出错以及性能分析。

android 5.0之前默认采用dalvik虚拟机,android5.0之后采用art虚拟机。

4.jit 与aot

JIT通过进行连续的性能分析(记录函数的调用频率和时长,用于确定程序哪部分需要优化)来优化程序代码的执行,在程序运行的过程中,Dalvik虚拟机在不断的进行将字节码编译成机器码的工作。

AOT在应用程序安装的过程中,就已经将所有的字节码重新编译成了机器码。应用程序运行过程中无需进行实时的编译工作,只需要进行直接调用。

image

jit通过工具dexopt将dex文件转化为odex文件,odex相对于dex的优化内容包含指令修改为quick指令以及将fied_idx改为用offset来定位

aot通过工具dex2oat将dex转化为elf(ExecutableAndLinkableFormat文件,它是一种类Unix操作系统上的二进制文件格式标准。

三.android虚拟机栈

1.hotspot 虚拟机栈

jvm_stack_frame

hotspot虚拟机栈中以栈结构存储了代表运行时的方法的栈帧列表,对于虚拟机栈的详细介绍参考Java虚拟机系列三: 运行时数据区解析,这里我们重点关注与android虚拟机的区别点:它通过局部变量表和操作数栈来实现局部变量的存储与计算,并返回结果。

2.dalvik 寄存器

android虚拟机栈同样存储的是一系列的栈帧,不同的是栈帧中去除了操作比较繁琐的局部变量表和操作数栈,改为采用寄存器来存储指令,局部变量以及结果信息。

dalvik_register

基于寄存器的指令更长,同时能处理的内容也更多,能有效的减少io次数。

四.android虚拟机堆

1.hotspot虚拟机堆

在Java虚拟机系列四:堆区管理中,我们分析了标准jvm的堆区结构由新生代,老年代和元数据区组成,其中新生代又区分为eden区和suvivor区,经过gc之后在不同的区中流转。

jvm_heap

2.Art虚拟机堆

Art虚拟机充分考虑移动端的数据特性,如启动需要的公共的class文件,图片等。

具体来看,art虚拟机堆由四部分组成:

image space - zygote space - allocation space - largeObject space

image

1.ImageSpace

存放boot.oat文件,它不会执行堆的gc

2.zygoteSpace

连续地址空间,匿名共享内存,进行垃圾回收,管理Zygote进程在启动过程中预加载的类和创建的各种对象资源。

3.allocation space

与传统的hotspot虚拟机堆相似,app运行过程中的对象的分配都在这里。

4.largetObject space

离散地址空间,进行垃圾回收,用来分配一些大于12K的大对象。

五.art虚拟机的gc策略

1.三种gc策略

  • Sticky GC :只回收上一次GC到本次GC之间申请的内存。
  • Partial GC:局部垃圾回收,除了ImageSpace和ZygoteSpace空间以外的其他内存垃圾。
  • Full GC: 全局垃圾回收,除了ImageSpace之外的Space的内存垃圾

2.gc的三个阶段:

阶段一:首先会进行一次轻量级的GC,完成后尝试分配。如果分配失败,则选取下一个GC策略,再进行一次轻量级GC。每次GC完成后都尝试分配,直到三种GC策略都被轮询了一遍还是不能完成分配,则进入下一阶段。 阶段二:允许堆进行增长的情况下进行对象的分配。 阶段三:进行一次允许回收软引用的GC的情况下进行对象的分配。

3.内存泄漏分析工具

  • as memory profiler : 实时内存dump (hprof文件)
  • leak canary : 自动泄漏分析工具
  • mat : 内存分析工具
  • hprof-conv : hprof转换工具,将memoryprofiler的hprof文件转化为mat能识别的文件,工具位于android/sdk/platform-tools/
  • visual vm : 可视化的vm内存实时情况
  • visual gc : visual vm的gc插件

4.内存泄漏分析方法

1. 记录需要分析的两个状态的hprof文件

这两个状态一般是怀疑泄漏的页面,或者通过leakCanary提示的泄漏页面,假设为页面A有泄漏。

打开Android studio 的 memory profiler页面 进入需要分析的页面A之前的页面Main(假定这个状态为纯净状态s1),点击capture-heap-dump存储纯净状态的hprof文件为memory-s1.hprof,进出几次A页面,并点击gc按钮,最终退出A页面回到纯净页面main,点击capture-heap-dump存储当前状态的hprof文件为memory-s2.hprof

2.将hprof转化为mat能识别的文件

执行命令得到mat-s1.hprof , mat-s2.hprof

hprof-conv memory-s1.hprof mat-s1.hprof
hprof-conv momory-s2.hprof mat-s2.hprof

3.对比 hprof文件

用mat工具导入mat-s1.hprof ,mat-s2.hprof 在mat-s1.hprof页面点击右上角的diff按钮,选择mat-s2.hprof,得到多次进入页面A之后增加的对象列表。

mat_diff

如图可以清晰看到SecondActivity增加了7个,为泄漏的对象。

重新点开mat-s2.hprof文件,这里一定要重新点开,否则会找不到持有链接的入口,找到SecondActivity,右键点击merge-shortest-Paths-to-Gc-Roots就可以得到持有SecondeActivity的实际对象。 mat-link


收起阅读 »

iOS SwiftUI 创建和组合视图 3.1

第四节 使用可观察对象来存储数据要实现用户标记哪个地标为自己喜爱的地标这个功能,需要使用可观察对象(observalble object)存放地标数据可观察对象是一种可以绑定到具体SwifUI视图环境中的数据对象。SwiftUI可以察觉它影响视图展示的任何变化...
继续阅读 »

第四节 使用可观察对象来存储数据

要实现用户标记哪个地标为自己喜爱的地标这个功能,需要使用可观察对象(observalble object)存放地标数据

可观察对象是一种可以绑定到具体SwifUI视图环境中的数据对象。SwiftUI可以察觉它影响视图展示的任何变化,并在这种变化发生后及时更新对应视图的展示内容

observable

步骤1 创建一个名为UserData.swift的文件

步骤2 声明一个遵循ObservableObject协议的新数据模型,ObservableObject协议来自响应式框架Combine。SwiftUI可以订阅可观察对象,并在数据发生改变时更新视图的显示内容

步骤3 添加存储属性showFavoritesOnlylandmarks,并赋予初始值。可观察对象需要对外公布内部数据的任何改动,因此订阅此可观察对象的订阅者就可以获得对应的数据改动信息

步骤4 给新建的数据模型的每一个属性添加@Published属性修饰词

combine

第五节 视图中适配数据模型对象

已经创建了UserData可观察对象,现在要改造视图,让它使用这个新的数据模型来存储视图内容数据

model

步骤1 在LandmarkList.swift文件中,使用@EnvironmentObject修饰的userData属性来替换原来的showFavoritesOnly状态属性,并对预览视图调用environmentObject(_:)修改器。只要environmentObject(_:)修改器应用在视图的父视图上,userData就能够自动获取它的值。

步骤2 替换原来使用showFavoritesOnly状态属性的地方,改为使用userData中的对应属性。与@State修饰的属性一样,也可以使用$前缀访问userData对象的成员绑定引用

步骤3 创建ForEach实例时使用userData.landmarks做为数据源

envrionment object

步骤4 在SceneDelegate.swift中,对LandmarkList视图调用environmentObject修改器,这样可以把UserData的数据对象绑定到LandmarkList视图的环境变量中,子视图可以获得父视图环境中的变量。此时如果在模拟器或者真机上运行应用,也可以正常展示视图内容

scene delegate

步骤5 更新LandmarkDetail视图,让它从父视图的环境变量中取要展示的数据。之后在更新地标的用户喜爱状态时,会用到landmarkIndex这个变量

landmark detail environment

步骤6 切换到LandmarkList.swift文件,并打开实时预览视图去验证所添加的功能是否正常工作

landmark list environment

第六节 为每一个地标创建一个喜爱按钮

Landmark这个应用可以在喜欢和不喜欢的地标列表间进行切换了,但喜欢的地标列表还是硬编码形成的,为了让用户可以自己标记哪个地标是自己喜欢的,需要在地标详情页添加一个标记喜欢的按钮

favorite button

步骤1 在LandmarkDetail.swiftHStack中添加地标名称的Text

步骤2 在地标名称的Text控件旁边添加一个新的按钮控件。使用if-else条件语句设置不同的图片显示状态表示这个地标是否被用户标记为喜欢。在Button的动作闭包中,使用了landmarkIndex去修改userData中对应地标的数据。

favorite star button

步骤3 切换到landmarkList.swift,并开启实时预览模式。当从列表页导航进入详情页后,点击喜欢按钮,喜欢的状态会在返回列表页后与列表中对应的地标喜欢状态保持一致,因为列表页和详情页的地标数据使用的是同一份,所以可以在不同页面间保持状态同步。

star button completed

检查是否理解

问题1 下列选项哪个可以把数据按视图层级关系传递下去?

  •  @EnvironmentObject属性
  •  environmentObject(_:)修改器

问题2 绑定(binding)的作用是什么?

  •  绑定是值和改变值的方法
  •  是一个视图连接在一起的方法,确保连续起来的视图接收同一份数据
  •  是一个临时固化值的方式,目的是在其它视图状态变化时,保持值不改变
收起阅读 »

iOS SwiftUI 创建和组合视图 3.0

处理用户输入在Landmark应用中,标记喜爱的地方,过滤地标列表,只显示喜欢的地标。要增加这些特性,首先要在列表上添加一个开关,用来过滤用户喜欢的地标。在地标上添加一个星标按钮,用户可以点击它来标记这个地标为自己喜欢的。下载工程文件并且跟着下面的教程实践&n...
继续阅读 »

处理用户输入

Landmark应用中,标记喜爱的地方,过滤地标列表,只显示喜欢的地标。要增加这些特性,首先要在列表上添加一个开关,用来过滤用户喜欢的地标。在地标上添加一个星标按钮,用户可以点击它来标记这个地标为自己喜欢的。

下载工程文件并且跟着下面的教程实践


第一节 标记用户最喜欢的地标

mark-favorite

给地标列表的每一行添加一个星标用来表示用户是否标记该地标为自己喜欢的

步骤1 打开工程项目,在项目导航下选择LandmarkRow.swift文件

步骤2 在空白占位后面添加一个if表达式,if表达式判断是否当前地标是用户喜欢的,如果用户标记当前地标为喜欢就显示星标。可以在SwitUI的代码块中使用if语句来条件包含视图

步骤3 由于系统图片是矢量类型的,可以使用foregroundColor(_:)来改变它的颜色。当地标landmark的isFavorite属性为真时,星标显示,稍后会讲怎么修改属性值。

star

第二节 过滤列表

可以定制地标列表,让它只显示用户喜欢的地标,或者显示所有的地标。要实现这个功能,需要给LandmarkList视图类型添加一些状态变量。

状态(State)是一个值或者一个值的集合,会随着时间而改变,同时会影响视图的内容、行为或布局。在属性前面加上@State修饰词就是给视图添加了一个状态值

state

步骤1 选择LandmarkList.swift文件,并给LandmarkList添加一个名为showFavoritesOnly的状态,初始值设置为false

步骤2 点击Resume按钮或快捷键Command+Option+P刷新画布。当对视图进行添加或修改属性等结构性改变时,需要手动刷新画布

步骤3 代码中通过检查showFavoritesOnly属性和每一个地标的isFavorite属性值来过滤地标列表所展示的内容

state favorite

第三节 添加控件来切换状态

为了让用户控制地标列表的过滤器,需要添加一个可以修改showFavoritesOnly值的控件,传递一个绑定关系给toggle控件可以实现

一个绑定关系(binding)是对可变状态的引用。当用户点击toggle控件,从开到关或从关到开,toggle控件会通过绑定关系对应的更新视图的状态

toggle state

步骤1 创建一个嵌套的ForEach组来把地标数据转换成地标行视图。在一个列表中组合静态和动态视图,或者组合两个甚至多个不同的动态视图组,使用ForEach类型动态生成而不是给列表传入数据集合生成列表视图

步骤2 添加一个Toggle视图作为列表的每一个子视图,传入一个showFavoritesOnly的绑定关系。使用$前缀来获得一个状态变量或属性的绑定关系

步骤3 实时预览模式下,点击Toggle控件来验证过滤器的功能

toggle binding

live preview


收起阅读 »

iOS SwiftUI 创建和组合视图 2.2

第七节 子视图传入数据LandmarkDetail视图目前还是使用写死的数据进行展示,与LandmarkRow视图一样,LandmarkDetail视图及它内部的子视图也需要传入landmark数据,并使用它来进行实际的展示从LandmarkDetail的子视...
继续阅读 »

第七节 子视图传入数据

LandmarkDetail视图目前还是使用写死的数据进行展示,与LandmarkRow视图一样,LandmarkDetail视图及它内部的子视图也需要传入landmark数据,并使用它来进行实际的展示

LandmarkDetail的子视图(CircleImageMapView)开始,需要把它们都改造成为使用传入的数据进行展示,而不是在布局代码中写死数据展示

pass data

步骤1 在CircleImage.swift文件中,添加一个存储属性,命名为image。这是一种在构建SwiftUI视图中很常用的模式,常常会包裹或封装一些属性修改器。

circle image data

步骤2 更新CirleImage的预览结构体,并传入Turtle Rock这个图片进行预览

circle image preview

步骤3 在MapView.swift中添加一个coordinate属性,并使用这个属性来替换写死的经纬度坐标

map view data

步骤4 更新MapView的预览结构体,并传入每一个地标的经纬度数据

map view preview

步骤5 在LandmarkDetail.swift中添加landmark属性。

步骤6 更新LandmarkDetail预览结构体,并传入第一个地标的数据

步骤7 把对应子视图的数据传入

landmark detail

步骤8 最后调用navigationBarTitle(_:displayMode:)修改器为地标详情页展示时在导航条上设置一个标题

landmark detail preview

步骤9 在SceneDelegate.swift中把应用的根视图替换为LandmarkList。应用在模拟器中独立启动时使用SceneDelegate的根视图做为第一个展示的视图

scene delegate root view

步骤10 在LandmarkList.swift中,传入当前行的地标数据到地标详情页LandmarkDetail

landmark list data

步骤11 切换到实时预览模式下去查看从地标列表页对应的行跳转到对应地标详情页是否正常

landmark list preview

第八节 动态生成预览视图

dynamic preivew

接下来要在不同尺寸设备上展示不同的预览视图,默认情况下,预览视图会选择当前Scheme选中的设备尺寸进行渲染,可以使用previewDevice(_:)修改器来改变预览视图的设备

步骤1 改变当前预览列表,让它渲染在iPhone SE设备上。可以使用Xcode Scheme菜单上的设备名称来指定渲染设备。

iPhone SE Preview

步骤2 在列表的预览视图中,还可以把LandmarkList嵌套进入ForEach实例中,使用设备数组名作为数据。ForEach运算作用在集合类型的数据上,就和列表使用集合类型数据一样,可以在子视图使用的任何场景下使用ForEach,例如:stacklistgroup等。当元素数据是简单值类型时(例如字符串类型),可以使用\.self作为keypath去标识

preiview multiple device

步骤3 使用previewDisplayName(_:)修改器可以给预览视图添加设备标签

步骤4 可以在画布上多设置几个设备进行预览,比较不同设备下视图的展示情况

preivew multiple devices

检查是否理解

问题1 除了List外,下面哪种类型可以从集合数据中展示动态列表视图

  •  Group
  •  ForEach
  •  UITableView

问题2 可以从遵循了Identifiable协议的集合数据创建列表视图。但如果集合数据不遵循Identifiable协议,还有什么办法可以创建列表视图?

  •  在集合数据上调用map(_:)方法
  •  在集合数据上调用sorted(by:)方法
  •  给List(_:id:)类型传入集合数据的同时,使用keypath指定一个唯一标识符字段

问题3 使用什么类型才能让列表的行实现点击跳转到其它视图页面?

  •  NavigationLink
  •  UITableViewDelegate
  •  NavigationView

问题4 下面哪种方式不是用来设置预览设备的?

  •  改变活动scheme中选中的模拟器
  •  在画面设置中设置一个不同的预览设备
  •  使用previewDevice(_:)指定一个或多个预览设备
  •  连接开发机并点击设备预览按钮
收起阅读 »

iOS SwiftUI 创建和组合视图 2.1

第四节 创建地标列表使用SwiftUI列表类型可以展示平台相关的列表视图。列表的元素可以是静态的,类似于栈内部的子视图,也可以是动态生成的视图,也可以混合动态和静态的视图。步骤1 创建SwiftUI视图,命名为LandmarkList.swift步骤...
继续阅读 »

第四节 创建地标列表

使用SwiftUI列表类型可以展示平台相关的列表视图。列表的元素可以是静态的,类似于栈内部的子视图,也可以是动态生成的视图,也可以混合动态和静态的视图。

landmark list

步骤1 创建SwiftUI视图,命名为LandmarkList.swift

步骤2 用List替换默认创建的Text,并将前两个LandmarkRow实例做为列表的子元素,预览视图中会以列表的形式展示出两个地标

landmark list file create

landmark list landmark list tow rows

第五节 创建动态列表

除了单独列出列表中的每个元素外,列表还可以从一个集合中动态的生成。

landmark list dynamic

创建列表时可以传入一个集合数据和一个闭包,闭包会针对每一个数据元素返回一个视图,这个视图就是列表的行视图。

步骤1 从列表中移除两个静态指定的行视图,给列表初始化器传入landmarkData数据,列表要配合可辨别的数据类型使用。想让数据变成可辨别的数据类型有两种方法:

  1. 传入一个keypath指定数据中哪一个字段用来唯一标识这个数据元素。

  2. 让数据遵循Identifiable协议

步骤2 在闭包中返回一个LandmarkRow视图,List初始化器中指定数据集合landmarkData和唯一标识符**keypath:**\.id,这样列表就会动态生成,如下图所示

keypath identifier list data

步骤3 切换到文件Landmark.swfit,声明Landmark类型遵循Identifiable协议,因为Landmark类型已经定义了id属性,正好满足Identifiable协议,所以不需要添加其它代码

identifiable data

步骤4 现在切换回文件LandmarkList.swift,移除keypath\.id,因为landmarkData数据集合的元素已经遵循了Identifiable协议,所以在列表初始化器中可以直接使用,不需要手动标明数据的唯一标识符了



第六节 设置从列表页到详情页的页面导航

地标列表可以正常渲染展示,但是列表的元素点击后没有反应,跳转不到地标详情页。现在就要给列表添加导航能力,把列表视图嵌套到NavigationView视图中,然后把列表的每一个行视图嵌套进NavigationLink视图中,就可以建立起从地标列表视图到地标详情页的跳转。

landmark list to detail

步骤1 把动态生成的列表视图嵌套进一个NavigationView视图中

embed in navigation view

步骤2 调用navigationBarTitle(_:)修改器设置地标列表显示时的导航条标题

landmark list navigation view

步骤3 在列表的闭包中,将每一个行元素包裹在NavigationLink中返回,并指定LandmarkDetail视图为目标视图

navigation link

步骤4 切换到实时预览模式下可以直接点击地标列表的任意一行,现在就可以跳转到地标详情页了。

list navigation

identifiable list

收起阅读 »

有了for循环 为什么还要forEach?

js中那么多循环,for for...in for...of forEach,有些循环感觉上是大同小异今天我们讨论下for循环和forEach的差异。 我们从几个维度展开讨论:for循环和forEach的本质区别。for循环和forEach的语法区别。for循...
继续阅读 »

js中那么多循环,for for...in for...of forEach,有些循环感觉上是大同小异今天我们讨论下for循环和forEach的差异。

我们从几个维度展开讨论:

for循环和forEach的本质区别。

for循环和forEach的语法区别。

for循环和forEach的性能区别。



本质区别



for循环是js提出时就有的循环方法。forEach是ES5提出的,挂载在可迭代对象原型上的方法,例如Array Set Map

forEach是一个迭代器,负责遍历可迭代对象。那么遍历迭代可迭代对象分别是什么呢。

遍历:指的对数据结构的每一个成员进行有规律的且为一次访问的行为。

迭代:迭代是递归的一种特殊形式,是迭代器提供的一种方法,默认情况下是按照一定顺序逐个访问数据结构成员。迭代也是一种遍历行为。

可迭代对象:ES6中引入了 iterable 类型,Array Set Map String arguments NodeList 都属于 iterable,他们特点就是都拥有 [Symbol.iterator] 方法,包含他的对象被认为是可迭代的 iterable


未标题-2.png


在了解这些后就知道 forEach 其实是一个迭代器,他与 for 循环本质上的区别是 forEach 是负责遍历(Array Set Map)可迭代对象的,而 for 循环是一种循环机制,只是能通过它遍历出数组。

再来聊聊究竟什么是迭代器,还记得之前提到的 Generator 生成器,当它被调用时就会生成一个迭代器对象(Iterator Object),它有一个 .next()方法,每次调用返回一个对象{value:value,done:Boolean}value返回的是 yield 后的返回值,当 yield 结束,done 变为 true,通过不断调用并依次的迭代访问内部的值。

迭代器是一种特殊对象。ES6规范中它的标志是返回对象的 next() 方法,迭代行为判断在 done 之中。在不暴露内部表示的情况下,迭代器实现了遍历。看代码


let arr = [1, 2, 3, 4]  // 可迭代对象
let iterator = arr[Symbol.iterator]() // 调用 Symbol.iterator 后生成了迭代器对象
console.log(iterator.next()); // {value: 1, done: false} 访问迭代器对象的next方法
console.log(iterator.next()); // {value: 2, done: false}
console.log(iterator.next()); // {value: 3, done: false}
console.log(iterator.next()); // {value: 4, done: false}
console.log(iterator.next()); // {value: undefined, done: true}

我们看到了。只要是可迭代对象,调用内部的 Symbol.iterator 都会提供一个迭代器,并根据迭代器返回的next 方法来访问内部,这也是 for...of 的实现原理。


let arr = [1, 2, 3, 4]
for (const item of arr) {
console.log(item); // 1 2 3 4
}

把调用 next 方法返回对象的 value 值并保存在 item 中,直到 valueundefined 跳出循环,所有可迭代对象可供for...of消费。 再来看看其他可迭代对象:


function num(params) {
console.log(arguments); // Arguments(6) [1, 2, 3, 4, callee: ƒ, Symbol(Symbol.iterator): ƒ]
let iterator = arguments[Symbol.iterator]()
console.log(iterator.next()); // {value: 1, done: false}
console.log(iterator.next()); // {value: 2, done: false}
console.log(iterator.next()); // {value: 3, done: false}
console.log(iterator.next()); // {value: 4, done: false}
console.log(iterator.next()); // {value: undefined, done: true}
}
num(1, 2, 3, 4)

let set = new Set('1234')
set.forEach(item => {
console.log(item); // 1 2 3 4
})
let iterator = set[Symbol.iterator]()
console.log(iterator.next()); // {value: 1, done: false}
console.log(iterator.next()); // {value: 2, done: false}
console.log(iterator.next()); // {value: 3, done: false}
console.log(iterator.next()); // {value: 4, done: false}
console.log(iterator.next()); // {value: undefined, done: true}

所以我们也能很直观的看到可迭代对象中的 Symbol.iterator 属性被调用时都能生成迭代器,而 forEach 也是生成一个迭代器,在内部的回调函数中传递出每个元素的值。

(感兴趣的同学可以搜下 forEach 源码, Array Set Map 实例上都挂载着 forEach ,但网上的答案大多数是通过 length 判断长度, 利用for循环机制实现的。但在 Set Map 上使用会报错,所以我认为是调用的迭代器,不断调用 next,传参到回调函数。由于网上没查到答案也不妄下断言了,有答案的人可以评论区给我留言)



for循环和forEach的语法区别



了解了本质区别,在应用过程中,他们到底有什么语法区别呢?



  1. forEach 的参数。

  2. forEach 的中断。

  3. forEach 删除自身元素,index不可被重置。

  4. for 循环可以控制循环起点。


forEach 的参数


我们真正了解 forEach 的完整传参内容吗?它大概是这样:


arr.forEach((self,index,arr) =>{},this)

self: 数组当前遍历的元素,默认从左往右依次获取数组元素。

index: 数组当前元素的索引,第一个元素索引为0,依次类推。

arr: 当前遍历的数组。

this: 回调函数中this指向。


let arr = [1, 2, 3, 4];
let person = {
name: '技术直男星辰'
};
arr.forEach(function (self, index, arr) {
console.log(`当前元素为${self}索引为${index},属于数组${arr}`);
console.log(this.name+='真帅');
}, person)

我们可以利用 arr 实现数组去重:


let arr1 = [1, 2, 1, 3, 1];
let arr2 = [];
arr1.forEach(function (self, index, arr) {
arr.indexOf(self) === index ? arr2.push(self) : null;
});
console.log(arr2); // [1,2,3]

image.png


forEach 的中断


在js中有break return continue 对函数进行中断或跳出循环的操作,我们在 for循环中会用到一些中断行为,对于优化数组遍历查找是很好的,但由于forEach属于迭代器,只能按序依次遍历完成,所以不支持上述的中断行为。


let arr = [1, 2, 3, 4],
i = 0,
length = arr.length;
for (; i < length; i++) {
console.log(arr[i]); //1,2
if (arr[i] === 2) {
break;
};
};

arr.forEach((self,index) => {
console.log(self);
if (self === 2) {
break; //报错
};
});

arr.forEach((self,index) => {
console.log(self);
if (self === 2) {
continue; //报错
};
});

如果我一定要在 forEach 中跳出循环呢?其实是有办法的,借助try/catch


try {
var arr = [1, 2, 3, 4];
arr.forEach(function (item, index) {
//跳出条件
if (item === 3) {
throw new Error("LoopTerminates");
}
//do something
console.log(item);
});
} catch (e) {
if (e.message !== "LoopTerminates") throw e;
};

若遇到 return 并不会报错,但是不会生效


let arr = [1, 2, 3, 4];

function find(array, num) {
array.forEach((self, index) => {
if (self === num) {
return index;
};
});
};
let index = find(arr, 2);// undefined

forEach 删除自身元素,index不可被重置


forEach 中我们无法控制 index 的值,它只会无脑的自增直至大于数组的 length 跳出循环。所以也无法删除自身进行index重置,先看一个简单例子:


let arr = [1,2,3,4]
arr.forEach((item, index) => {
console.log(item); // 1 2 3 4
index++;
});

index不会随着函数体内部对它的增减而发生变化。在实际开发中,遍历数组同时删除某项的操作十分常见,在使用forEach删除时要注意。


for 循环可以控制循环起点


如上文提到的 forEach 的循环起点只能为0不能进行人为干预,而for循环不同:


let arr = [1, 2, 3, 4],
i = 1,
length = arr.length;

for (; i < length; i++) {
console.log(arr[i]) // 2 3 4
};

那之前的数组遍历并删除滋生的操作就可以写成


let arr = [1, 2, 1],
i = 0,
length = arr.length;

for (; i < length; i++) {
// 删除数组中所有的1
if (arr[i] === 1) {
arr.splice(i, 1);
//重置i,否则i会跳一位
i--;
};
};
console.log(arr); // [2]
//等价于
var arr1 = arr.filter(index => index !== 1);
console.log(arr1) // [2]


for循环和forEach的性能区别



在性能对比方面我们加入一个 map 迭代器,它与 filter 一样都是生成新数组。我们对比 for forEach map 的性能在浏览器环境中都是什么样的:

性能比较:for > forEach > map

在chrome 62 和 Node.js v9.1.0环境下:for 循环比 forEach 快1倍,forEachmap 快20%左右。
原因分析

for:for循环没有额外的函数调用栈和上下文,所以它的实现最为简单。

forEach:对于forEach来说,它的函数签名中包含了参数和上下文,所以性能会低于 for 循环。

mapmap 最慢的原因是因为 map 会返回一个新的数组,数组的创建和赋值会导致分配内存空间,因此会带来较大的性能开销。如果将map嵌套在一个循环中,便会带来更多不必要的内存消耗。

当大家使用迭代器遍历一个数组时,如果不需要返回一个新数组却使用 map 是违背设计初衷的。在我前端合作开发时见过很多人只是为了遍历数组而用 map 的:


let data = [];
let data2 = [1,2,3];
data2.map(item=>data.push(item));

写在最后:这是我面试遇到的一个问题,当时只知道语法区别。并没有从可迭代对象迭代器生成器性能方面,多角度进一步区分两者的异同,我也希望我能把一个简单的问题从多角度展开细讲,让大家正在搞懂搞透彻。

收起阅读 »

如何在你的项目中使用新的ES规范

JavaScript 和 ECMAScript 的关系 JavaScript 是一种高级的、编译型的编程语言。而 ECMAScript 是一种规范。 JavaScript 是基于 ECMAScript 规范的脚本语言。ECMAScript(以下简称 ES)在 ...
继续阅读 »

JavaScript 和 ECMAScript 的关系


JavaScript 是一种高级的、编译型的编程语言。而 ECMAScript 是一种规范。


JavaScript 是基于 ECMAScript 规范的脚本语言ECMAScript(以下简称 ES)在 2015 年发布了 ES6(ECMAScript 2015),而且 TC39 委员会决定每年发布一个 ECMAScript 的版本,也就是我们看到的 ES6/7/8/9/11/12


ES11 中两个非常有用的特性


空值合并操作符(??)


Nullish coalescing Operator(空值处理)只有 null 和 undefined 的时候才认为真的是空。
以下为使用方式:


let user = {
u1: 0,
u2: false,
u3: null,
u4: undefined
u5: '',
}
let u1 = user.u1 || '用户1' // 用户1
let u2 = user.u2 || '用户2' // 用户2
let u3 = user.u3 || '用户3' // 用户3
let u4 = user.u4 || '用户4' // 用户4
let u5 = user.u5 || '用户5' // 用户5
// es11语法【只有 null 和 undefined 的时候才认为真的是空】
let u1 = user.u1 ?? '用户1' // 0
let u2 = user.u2 ?? '用户2' // false
let u3 = user.u3 ?? '用户3' // 用户3
let u4 = user.u4 ?? '用户4' // 用户4
let u5 = user.u5 ?? '用户5' // ''

应用的场景:后端返回的数据中 null 和 0 表示的意义可能是不一样的,null 表示为空,展示成 /。0 还是有数值,展示为 0。


let a = 0;
console.log(a ?? '/'); // 0
a = null;
console.log(a ?? '/'); // '/'

Optional chaining(可选链)



可选链操作符( ?. )允许读取位于连接对象链深处的属性的值,而不必明确验证链中的每个引用是否有效。?. 操作符的功能类似于 . 链式操作符,不同之处在于,在引用为空(nullish ) (null 或者 undefined) 的情况下不会引起错误,该表达式短路返回值是 undefined。与函数调用一起使用时,如果给定的函数不存在,则返回 undefined。



这个有点类似于 lodash 工具库中的 get 方法


let user = {
age: 18
}
let u1 = user.childer.name // TypeError: Cannot read property 'name' of undefined
// es11 语法
let u1 = user.childer?.name // undefined

浏览器兼容性问题


虽然 ES 新的特性十分好用,但需要注意的是它们的兼容性问题。
比如,可选链目前的兼容性如下:



解决方法就是讲 ES 新特性的语法转换成 ES5 的语法。


使用 Babel 进行转换


Babel 是一个 JavaScript 编译器。它的输入是下一代 JavaScript 语法书写的代码,输出浏览器兼容的 JavaScript 代码。


我们可以通过 Babel 中的转换插件来进行语法转换。比如我们上面两个语法可以通过以下两个插件进行转换。


空值合并操作符。@babel/plugin-proposal-nullish-coalescing-operator


使用:


npm install --save-dev @babel/plugin-proposal-nullish-coalescing-operator

在配置文件中:


{
"plugins": ["@babel/plugin-proposal-nullish-coalescing-operator"]
}

就可以做到以下的转换,输入:


var foo = object.foo ?? "default";

输出:


var _object$foo;

var foo =
(_object$foo = object.foo) !== null && _object$foo !== void 0
? _object$foo
: "default";

同理,可选链操作符可以看 @babel/plugin-proposal-optional-chaining,还有其他的都可以在 @babel/preset-env 目录中找到。


测试


在 Firefox 中,下载比较老的版本。



const foo = null ?? 'default string';
console.log(foo);
// expected output: "default string"

const baz = 0 ?? 42;
console.log(baz);

运行上面的代码,报错:



项目中使用,成功。说明 polyfil 成功了。



总结


JavaScript 是基于 ECMAScript 规范的脚本语言。ECMAScript 规范的发展给前端开发带来了很多的便利,但我们在使用的时候应该使用 Babel 这种 JavaScript 编译器将其转换成浏览器兼容的代码。


作者:Gopal
链接:https://juejin.cn/post/7018174628090609701

收起阅读 »

Vue 3 凉凉了吗 - 10 个灵魂拷问

vue
很多人问我,现在可以用 Vue 3 了吗,Vue 2升级成本高吗,我想借着早早聊的场子把大家经常问的问题,跟大家谈一谈我的看法,我会尽量公平公正,客观正向,但尽然是看法,难免会有一些有争议的地方,或者不认可的地方,你可以留言。我总结了 10 个问题,期望能帮助...
继续阅读 »

很多人问我,现在可以用 Vue 3 了吗,Vue 2升级成本高吗,我想借着早早聊的场子把大家经常问的问题,跟大家谈一谈我的看法,我会尽量公平公正,客观正向,但尽然是看法,难免会有一些有争议的地方,或者不认可的地方,你可以留言。我总结了 10 个问题,期望能帮助你在技术选型中起到一定的帮助:


0、升级 Vue 3 成本大吗


可大可小,如果你使用的Vue推荐的 template 语法,成本是非常小的,改个版本号就已经可以 run 起来了,但前提是它的周边库你也已经升级了,不信你试试,其实周边库的升级成本不能算做 Vue 3 的升级成本,因为就算没有 Vue 3,周边也会不断升级,只是刚好撞到了一起,然后这个锅就让 Vue 一起背了。


关于升级这方面我想也可以借鉴 Ant Design Vue 的渐进式升级姿势,Ant Design Vue 2.x 版本是改了少量的代码,让其在 Vue 3 下运行,然后慢慢的使用Composition API 迭代重构,对于简单的没有破坏性更新的继续在 2.x 下迭代小版本,然后将有大量重构代码,复杂度高的,有破坏性更新的在 3.x 版本上迭代升级。


业务代码的更新升级相较于组件库成本会更加可控,因为组件库一般都会用到一些黑科技或非文档API等等,会让升级成本变高,这也是为什么一些组件库迟迟不兼容 Vue3 的部分原因。


1、Vue 3 稳定了吗?


目前 Vue 3 已经相当稳定,除非你会用到各种黑科技,业务项目不应该有黑科技,如果用到了,千万不要写到简历里,相信我,“黑科技”不但不会加分,还会减分,因为所谓的黑科技,大概率是你写法就不对。不服来辩


2、Vue 3 生态不丰富?


你所谓的生态是指哪些?状态管理?路由管理?国际化?组件库?SSR?常用生态库都已经提供了 Vue 3 版本,而且 Vue 2 版本都在逐渐减少维护时间。


退一步讲,如果还不够,那可是造轮子,刷 KPI 的好机会,不是嘛。


3、Vue 3 的写法不习惯?


Composition API 只是可选项,你依然可以用 Option API,没有什么变化。但我们应该跳出舒适圈,拥抱未来,拥抱更好的东西。


4、Vue 3 好找工作吗?


前端技术风口已经不多了,Vue 3 算一个,遥想当年懂个生命周期、虚拟DOM就可以进大厂的时代,甚是想念。


5、Vue 3 不兼容 IE11?


是的,不兼容,如果公司业务需要兼容IE11,我给的方案是:先统计下你们有多少 IE11 用户,是否还值得投入精力兼容,推动去IE化是需要套路的,数据、成本、收益 PPT形式报告给老板,没有想想的那么难。再透漏下,react 版本的 Antd,也会在下一个大版本中不再兼容 IE 11。


6、Vue、React 如何选择?


还在纠结?工具人用哪个,它都只是工具,哪来的优越感?我用 angular,我骄傲了吗?


摸鱼小能手:哪个熟悉用哪个,哪个干活快用哪个


职场新人:公司用哪个就用哪个


KPI 高手:轮着换,使用 Vue(React) 重构 React(Vue) 项目,加载时间减少 30%,秒开率提升,转化率提升10%,带来收益 2千万/年,这TM得跳着升,没毛病吧


学生:都得学,前端框架还没复杂到二选一的地步


7、升级 Vue 3 带来的收益


性能提升,可维护性提升(主要还是看人),刷 KPI,升职加薪。


尤其是性能提升方面,我会在早早聊 Vue 专场给大家分享 Ant Design Vue 使用 Vue 3 重构,总结的一些经验。


8、何时使用 Vue 3?


别问,问就是现在


9、大厂都在用 React ?


其实并没有,我了解的百度、腾讯、京东、字节、快手、美团等等大厂都是 Vue 重度用户,阿里相对特殊些,只有少数部门在使用 Vue、Angular,之所以使用 React,不是说 Vue 不够好,只是最开始选择了React那些部门做的比较好,后来在 React 基建方面也已经做了很多工作,两套共存,有点浪费资源,仅此而已。至于那些个别团队,自带优越感式的招聘,大家可以忽略了,技术和氛围应该都不咋地。


10、硬广,Ant Design Vue 什么时候兼容 Vue 3?


Ant Design Vue 自 2.0 版本开始,已经全面兼容 Vue 3,目前文档站点默认还是 1.x 版本,是因为就像 Vue 3 一样,2.x 版本目前是 next tag,我们会在 Ant Design Vue 3.0 rc 后切回主站,没错 Ant Design Vue 已经 3.0 alpha 了。


所以 Vue 3 凉了吗,说真的,我也不知道,怎么算凉?从 Github Star、npm 下载量来看,都是呈上升趋势,我个人甚至押宝 Vue 3,已经在 6月份辞职,目前全职开源,押宝 Vue 3 了。


但是个别人有这种感觉,也是可以理解的,可以说 Vue 的成功,yyx 个人运营能力起到了至关重要的作用,react 反而低调了很多,因为 React 主要是为公司服务的,其次才是社区,他们没有运营的压力,也没有太大的动力去做运营。在 Vue 3 前期运营的过程中,或许过度强调了 Composition API,导致有部分人产生了不兼容误解,或许这部分人并不在少数,或许 Vue 应该将 Composition API 放在 3.2、3.3 的小版本上去迭代添加。


当然这都是猜测,10月23日,yyx 亲自为大家解读 Vue 3 及生态现状,这应该可以帮助你进一步做出决策。我也会为大家同步 Ant Design Vue 现状及未来规划,如果顺利,我们也会有新产品发布,但大概率要跳票了,哈哈哈,敬请期待吧。

收起阅读 »

Android Jetpack系列之ViewModel

ViewModel介绍ViewModel的定义:ViewModel旨在以注重生命周期的方式存储和管理界面相关的数据。ViewModel本质上是视图(View)与数据(Model)之间的桥梁,想想以前的MVC模式,视图和数据都会写在Activity/Fragme...
继续阅读 »

ViewModel介绍

ViewModel的定义:ViewModel旨在以注重生命周期的方式存储和管理界面相关的数据。ViewModel本质上是视图(View)与数据(Model)之间的桥梁,想想以前的MVC模式,视图和数据都会写在Activity/Fragment中,导致Activity/Fragment过重,后续难以维护,而ViewModel将视图和数据进行了分离解耦,为视图层提供数据。

ViewModel的特点:

ViewModel生命周期比Activity长

数据可在屏幕发生旋转等配置更改后继续留存。下面是ViewModel生命周期图:

viewmodel.png

可以看到即使是发生屏幕旋转,旋转之后拿到的ViewModel跟之前的是同一个实例,即发生屏幕旋转时,ViewModel并不会消失重建;而如果Activity是正常finish(),ViewModel则会调用onClear()销毁。

ViewModel中不能持有Activity引用

不要将Activity传入ViewModel中,因为ViewModel的生命周期比Activity长,所以如果ViewModel持有了Activity引用,很容易造成内存泄漏。如果想在ViewModel中使用Application,可以使用ViewModel的子类AndroidViewModel,其在构造方法中需要传入了Application实例:

public class AndroidViewModel extends ViewModel {
private Application mApplication;

public AndroidViewModel(@NonNull Application application) {
mApplication = application;
}

public <T extends Application> T getApplication() {
return (T) mApplication;
}
}

ViewModel使用举例

引入ViewModel,在介绍Jetpack系列文章Lifecycle时已经提过一次,这里再写一下:

implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0"

先看运行效果图:

viewmodel.gif

页面中有两个Fragment,左侧为列表,右侧为详情,当点击左侧某一个Item时,右侧会展示相应的数据,即两个Fragment可以通过ViewModel进行通信;同时可以看到,当屏幕发生旋转的时候,右侧详情页的数据并没有丢失,而是直接进行了展示。

//ViewModelActivity.kt
class ViewModelActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Log.e(JConsts.VIEW_MODEL, "onCreate")
setContentView(R.layout.activity_view_model)
}
}

其中的XML文件:

//activity_view_model.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".viewmodel.ViewModelActivity">

<fragment
android:id="@+id/fragment_item"
android:name="com.example.jetpackstudy.viewmodel.ItemFragment"
android:layout_width="150dp"
android:layout_height="match_parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toLeftOf="@+id/fragment_detail"
app:layout_constraintTop_toTopOf="parent" />

<fragment
android:id="@+id/fragment_detail"
android:name="com.example.jetpackstudy.viewmodel.DetailFragment"
android:layout_width="0dp"
android:layout_height="match_parent"
app:layout_constraintLeft_toRightOf="@+id/fragment_item"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

直接将Fragment以布局方式写入我们的Activity中,继续看两个Fragment:

//左侧列表Fragment
class ItemFragment : Fragment() {
lateinit var mRvView: RecyclerView

//Fragment之间通过传入同一个Activity来共享ViewModel
private val mShareModel by lazy {
ViewModelProvider(activity!!).get(ShareViewModel::class.java)
}

override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val view = LayoutInflater.from(activity)
.inflate(R.layout.layout_fragment_item, container, false)
mRvView = view.findViewById(R.id.rv_view)
mRvView.layoutManager = LinearLayoutManager(activity)
mRvView.adapter = MyAdapter(mShareModel)

return view
}

//构造RecyclerView的Adapter
class MyAdapter(private val sViewModel: ShareViewModel) :
RecyclerView.Adapter<MyAdapter.MyViewHolder>() {

class MyViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
val mTvText: TextView = itemView.findViewById(R.id.tv_text)
}

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MyViewHolder {
val itemView = LayoutInflater.from(parent.context)
.inflate(R.layout.item_fragment_left, parent, false)
return MyViewHolder(itemView)
}

override fun onBindViewHolder(holder: MyViewHolder, position: Int) {
val itemStr = "item pos:$position"
holder.mTvText.text = itemStr
//点击发送数据
holder.itemView.setOnClickListener {
sViewModel.clickItem(itemStr)
}
}

override fun getItemCount(): Int {
return 50
}
}
}
//右侧详情页Fragment
class DetailFragment : Fragment() {
lateinit var mTvDetail: TextView

//Fragment之间通过传入同一个Activity来共享ViewModel
private val mShareModel by lazy {
ViewModelProvider(activity!!).get(ShareViewModel::class.java)
}

override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return LayoutInflater.from(context)
.inflate(R.layout.layout_fragment_detail, container, false)
}

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
mTvDetail = view.findViewById(R.id.tv_detail)
//注册Observer并监听数据变化
mShareModel.itemLiveData.observe(activity!!, { itemStr ->
mTvDetail.text = itemStr
})
}
}

最后贴一下我们的ViewModel:

//ShareViewModel.kt
class ShareViewModel : ViewModel() {
val itemLiveData: MutableLiveData<String> by lazy { MutableLiveData<String>() }

//点击左侧Fragment中的Item发送数据
fun clickItem(infoStr: String) {
itemLiveData.value = infoStr
}
}

这里再强调一下,两个Fragment中ViewModelProvider(activity).get()传入的是同一个Activity,从而得到的ViewModel是同一个实例,进而可以进行互相通信。

上面使用ViewModel的写法有两个好处:

屏幕切换时保存数据

  • 屏幕发生变化时,不需要重新请求数据,直接从ViewModel中再次拿数据即可。

Fragment之间共享数据

  • Activity 不需要执行任何操作,也不需要对此通信有任何了解。
  • 除了 SharedViewModel 约定之外,Fragment 不需要相互了解。如果其中一个 Fragment 消失,另一个 Fragment 将继续照常工作。
  • 每个 Fragment 都有自己的生命周期,而不受另一个 Fragment 的生命周期的影响。如果一个 Fragment 替换另一个 Fragment,界面将继续工作而没有任何问题。

ViewModel与onSaveInstance(Bundle)对比

  • ViewModel是将数据存到内存中,而onSaveInstance()是通过Bundle将序列化数据存在磁盘中
  • ViewModel可以存储任何形式的数据,且大小不限制(不超过App分配的内存即可),onSaveInstance()中只能存储可序列化的数据,且大小一般不超过1M(IPC通信数据限制)

源码解析

ViewModel的存取

我们在获取ViewModel实例时,并不是直接new出来的,而是通过ViewModelProvider.get()获取的,顾名思义,ViewModelProvider意为ViewModel提供者,那么先来看它的构造方法:

//ViewModelProvider.java
public ViewModelProvider(@NonNull ViewModelStoreOwner owner) {
this(owner.getViewModelStore(), owner instanceof HasDefaultViewModelProviderFactory
? ((HasDefaultViewModelProviderFactory) owner).getDefaultViewModelProviderFactory()
: NewInstanceFactory.getInstance());
}

public ViewModelProvider(@NonNull ViewModelStoreOwner owner, @NonNull Factory factory) {
this(owner.getViewModelStore(), factory);
}

public ViewModelProvider(@NonNull ViewModelStore store, @NonNull Factory factory) {
mFactory = factory;
mViewModelStore = store;
}

ViewModelProvider构造函数中的几个参数:

  • ViewModelStoreOwner:ViewModel存储器拥有者,用来提供ViewModelStore
  • ViewModelStore:ViewModel存储器,用来存储ViewModel
  • Factory:创建ViewModel的工厂
private final ViewModelStore mViewModelStore;

private static final String DEFAULT_KEY =
"androidx.lifecycle.ViewModelProvider.DefaultKey";

@MainThread
public <T extends ViewModel> T get(@NonNull Class<T> modelClass) {
String canonicalName = modelClass.getCanonicalName();
if (canonicalName == null) {
throw new IllegalArgumentException("Local and anonymous classes can not be ViewModels");
}
//首先构造了一个key,直接调用下面的get(key,modelClass)
return get(DEFAULT_KEY + ":" + canonicalName, modelClass);
}

@MainThread
public <T extends ViewModel> T get(@NonNull String key, @NonNull Class<T> modelClass) {
//尝试从ViewModelStore中获取ViewModel
ViewModel viewModel = mViewModelStore.get(key);

if (modelClass.isInstance(viewModel)) {
if (mFactory instanceof OnRequeryFactory) {
((OnRequeryFactory) mFactory).onRequery(viewModel);
}
//viewModel不为空直接返回该实例
return (T) viewModel;
} else {
//noinspection StatementWithEmptyBody
if (viewModel != null) {
// TODO: log a warning.
}
}
if (mFactory instanceof KeyedFactory) {
viewModel = ((KeyedFactory) mFactory).create(key, modelClass);
} else {
//viewModel为空,通过Factory创建
viewModel = mFactory.create(modelClass);
}
//将ViewModel保存到ViewModelStore中
mViewModelStore.put(key, viewModel);
return (T) viewModel;
}

逻辑很简单,首先尝试通过ViewModelStore.get(key)获取ViewModel,如果不为空直接返回该实例;如果为空,通过Factory.create创建ViewModel并保存到ViewModelStore中。先来看Factory是如何创建ViewModel的,ViewModelProvider构造函数中,如果没有传入Factory,那么会使用NewInstanceFactory:

//接口Factory
public interface Factory {
<T extends ViewModel> T create(@NonNull Class<T> modelClass);
}

//默认Factory的实现类NewInstanceFactory
public static class NewInstanceFactory implements Factory {
private static NewInstanceFactory sInstance;

//获取单例
static NewInstanceFactory getInstance() {
if (sInstance == null) {
sInstance = new NewInstanceFactory();
}
return sInstance;
}

@Override
public <T extends ViewModel> T create(@NonNull Class<T> modelClass) {
try {
//反射创建
return modelClass.newInstance();
} catch (InstantiationException e) {
throw new RuntimeException("Cannot create an instance of " + modelClass, e);
} catch (IllegalAccessException e) {
throw new RuntimeException("Cannot create an instance of " + modelClass, e);
}
}
}

可以看到NewInstanceFactory的实现很简单,直接通过传入的class反射创建实例,泛型中限制必须是ViewModel的子类,所以最终创建的是ViewModel实例。

看完Factory,接着来看ViewModelStore:

public class ViewModelStore {
private final HashMap<String, ViewModel> mMap = new HashMap<>();

final void put(String key, ViewModel viewModel) {
ViewModel oldViewModel = mMap.put(key, viewModel);
//如果oldViewModel不为空,调用oldViewModel的onCleared释放资源
if (oldViewModel != null) {
oldViewModel.onCleared();
}
}

final ViewModel get(String key) {
return mMap.get(key);
}

Set<String> keys() {
return new HashSet<>(mMap.keySet());
}

//遍历存储的ViewModel并调用其clear()方法,然后清除所有ViewModel
public final void clear() {
for (ViewModel vm : mMap.values()) {
vm.clear();
}
mMap.clear();
}
}

ViewModelStore类也很简单,内部就是通过Map进行存储ViewModel的。到这里,我们基本看完了ViewModel的存取,流程如下:

viewmodelcreate.png

ViewModelStore的存取

上面聊了ViewModel的存取,有一个重要的点没有说到,既然ViewModel的生命周期比Activity长,而ViewModel又是通过ViewModelStore存取的,那么ViewModelStore又是如何存取的呢?在上面的流程图中我们知道ViewModelStore是通过ViewModelStoreOwner提供的:

//接口ViewModelStoreOwner.java
public interface ViewModelStoreOwner {
ViewModelStore getViewModelStore();
}

ViewModelStoreOwner中接口方法getViewModelStore()返回的既是ViewModelStore。我们上面例子获取ViewModel时是ViewModelProvider(activity).get(ShareViewModel::class.java),其中的activity其实就是ViewModelStoreOwner,也就是Activity中实现了这个接口:

//ComponentActivity.java
public class ComponentActivity extends androidx.core.app.ComponentActivity implements
LifecycleOwner,
ViewModelStoreOwner,.... {

@Override
public ViewModelStore getViewModelStore() {
//Activity还未关联到Application时,会抛异常,此时不能使用ViewModel
if (getApplication() == null) {
throw new IllegalStateException("Your activity is not yet attached to the "
+ "Application instance. You can't request ViewModel before onCreate call.");
}
ensureViewModelStore();
return mViewModelStore;
}

void ensureViewModelStore() {
if (mViewModelStore == null) {
NonConfigurationInstances nc =
(NonConfigurationInstances) getLastNonConfigurationInstance();
if (nc != null) {
//从NonConfigurationInstances中恢复ViewModelStore
mViewModelStore = nc.viewModelStore;
}
if (mViewModelStore == null) {
mViewModelStore = new ViewModelStore();
}
}
}

@Override
//覆写了Activity的onRetainNonConfigurationInstance方法,在Activity#retainNonConfigurationInstances()方法中被调用,即配置发生变化时调用。
public final Object onRetainNonConfigurationInstance() {
Object custom = onRetainCustomNonConfigurationInstance();

ViewModelStore viewModelStore = mViewModelStore;
if (viewModelStore == null) {
//尝试从之前的存储中获取NonConfigurationInstance
NonConfigurationInstances nc =
(NonConfigurationInstances) getLastNonConfigurationInstance();
if (nc != null) {
//从NonConfigurationInstances中恢复ViewModelStore
viewModelStore = nc.viewModelStore;
}
}

if (viewModelStore == null && custom == null) {
return null;
}
//如果viewModelStore不为空,当配置变化时将ViewModelStore保存到NonConfigurationInstances中
NonConfigurationInstances nci = new NonConfigurationInstances();
nci.custom = custom;
nci.viewModelStore = viewModelStore;
return nci;
}

//内部类NonConfigurationInstances
static final class NonConfigurationInstances {
Object custom;
ViewModelStore viewModelStore;
}

}

NonConfigurationInstances是ComponentActivity的静态内部类,里面包含了ViewModelStore。getViewModelStore()内部首先尝试通过getLastNonConfigurationInstance()来获取NonConfigurationInstances,不为空直接能拿到对应的ViewModelStore;否则直接new一个新的ViewModelStore

跟进去getLastNonConfigurationInstance()这个方法:

//Activity.java
public class Activity extends ContextThemeWrapper {

NonConfigurationInstances mLastNonConfigurationInstances;

public Object getLastNonConfigurationInstance() {
return mLastNonConfigurationInstances != null
? mLastNonConfigurationInstances.activity : null;
}

NonConfigurationInstances retainNonConfigurationInstances() {
//onRetainNonConfigurationInstance实现在子类ComponentActivity中实现
Object activity = onRetainNonConfigurationInstance();
.......

if (activity == null && children == null && fragments == null && loaders == null
&& mVoiceInteractor == null) {
return null;
}
NonConfigurationInstances nci = new NonConfigurationInstances();
nci.activity = activity;
......
return nci;
}

final void attach(Context context,
.......,
NonConfigurationInstances lastNonConfigurationInstances) {
mLastNonConfigurationInstances = lastNonConfigurationInstances;
}

static final class NonConfigurationInstances {
Object activity;
......
}
}

可以看到getLastNonConfigurationInstance()中是通过mLastNonConfigurationInstances是否为空来判断的,搜索一下该变量赋值的地方,就找到了Activity#attach()方法。我们知道Activity#attach()是在创建Activity的时候调用的,顺藤摸瓜就可以找到了ActivityThread:

//ActivityThread.java
final ArrayMap<IBinder, ActivityClientRecord> mActivities = new ArrayMap<>();

Activity.NonConfigurationInstances lastNonConfigurationInstances;

//1、将NonConfigurationInstances存储到ActivityClientRecord
ActivityClientRecord performDestroyActivity(IBinder token, boolean finishing,
int configChanges, boolean getNonConfigInstance, String reason) {
ActivityClientRecord r = mActivities.get(token);
Class<? extends Activity> activityClass = null;
if (r != null) {
......
if (getNonConfigInstance) {
try {
//retainNonConfigurationInstances
r.lastNonConfigurationInstances
= r.activity.retainNonConfigurationInstances();
} catch (Exception e) {
......
}
}
}
synchronized (mResourcesManager) {
mActivities.remove(token);
}
return r;
}

//2、从ActivityClientRecord中获取NonConfigurationInstances
private Activity performLaunchActivity(ActivityClientRecord r, Intent customIntent) {
......
activity.attach(...., r.lastNonConfigurationInstances,....);
}
  • 在执行performDestroyActivity()的时候,会调用Activity#retainNonConfigurationInstances()方法将生成的NonConfigurationInstances赋值给lastNonConfigurationInstances。
  • 在performLaunchActivity()中又会通过Activity#attach()将lastNonConfigurationInstances赋值给Activity.mLastNonConfigurationInstances,进而取到ViewModelStore。

ViewModelStore的存取都是间接在ActivityThread中进行并保存在ActivityClientRecord中。在Activity配置变化时,ViewModelStore可以在Activity销毁时得以保存并在重建时重新从lastNonConfigurationInstances中获取,又因为ViewModelStore提供了ViewModel,所以ViewModel也可以在Activity配置变化时得以保存,这也是为什么ViewModel的生命周期比Activity生命周期长的原因了。


收起阅读 »

Android内存泄露检测之LeakCanary的使用(转)

LeakCanary github地址:square.github.io/leakcanary/开始使用目前为止最新的版本是2.3版本,相比于2.0之前的版本,2.0之后的版本在使用上简洁了很多,只需要在dependencies中加入LeakCanary的依赖...
继续阅读 »


LeakCanary github地址:square.github.io/leakcanary/

开始使用

目前为止最新的版本是2.3版本,相比于2.0之前的版本,2.0之后的版本在使用上简洁了很多,只需要在dependencies中加入LeakCanary的依赖即可。而且debugImplementation只在debug模式下有效,所以不用担心用户在正式环境下也会出现LeakCanary收集。

**

dependencies {
// debugImplementation because LeakCanary should only run in debug builds.
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.3'
}

在项目中加入LeakCanary之后就可以开始检测项目的内存泄露了,把项目运行起来之后, 开始随便点自己的项目,下面以一个Demo项目为例,来聊一下LeakCanary记录内存泄露的过程以及我如何解决内存泄露的。
项目运行起来之后,在控制台可以看到LeakCanary的打印信息:

**

D/LeakCanary: Check for retained object found no objects remaining
D/LeakCanary: Scheduling check for retained objects in 5000ms because app became invisible
D/LeakCanary: Check for retained object found no objects remaining
D/LeakCanary: Rescheduling check for retained objects in 2000ms because found only 3 retained objects (< 5 while app visible)

这说明LeakCanary正在不断的检测项目中是否有剩余对象。那么LeakCanary是如何工作的呢?LeakCanary的基础是一个叫做ObjectWatcher Android的library。它hook了Android的生命周期,当activity和fragment 被销毁并且应该被垃圾回收时候自动检测。这些被销毁的对象被传递给ObjectWatcher, ObjectWatcher持有这些被销毁对象的弱引用(weak references)。如果弱引用在等待5秒钟并运行垃圾收集器后仍未被清除,那么被观察的对象就被认为是保留的(retained,在生命周期结束后仍然保留),并存在潜在的泄漏。LeakCanary会在Logcat中输出这些日志。

**

D LeakCanary: Watching instance of com.example.leakcanary.MainActivity 
// 5 seconds later...
D LeakCanary: Found 1 retained object

在我一顿瞎点之后, 在手机通知栏上面出现了这样的提示:

image.png

上面的意思是已经发现了4个保留的对象,点击通知可以触发堆转储(dump heap)。在app可见的时候,会一直等到5个保留的对象才会触发堆转储。这里要补充的一点是:当应用可见的时候默认的阈值是5,应用不可见的时候阈值是1。如果你看到了保留的对象的通知然后将应用切换到后台(例如点击home键),那么阈值就会从5变到1,LeakCanary会立即进行堆转储。那么堆转储是什么一回事呢?

堆转储

在我点了上面的通知之后, 控制台打印出了下面的语句:

**

D/LeakCanary: Check for retained objects found 3 objects, dumping the heap
D/LeakCanary: WRITE_EXTERNAL_STORAGE permission not granted, ignoring
I/testapplicatio: hprof: heap dump "/data/user/0/com.example.leakcaneraytestapplication/files/leakcanary/2020-05-28_16-35-28_155.hprof" starting...
I/testapplicatio: hprof: heap dump completed (22MB) in 2.963s objects 374548 objects with stack traces 0

这里开始进行堆转储,同时生成.hprof文件,LeakCanary将java heap的信息存到该文件中。同时在应用程序中也会出现一个提示。

image.png

LeakCanary是使用shark来转换.hprof文件并定位Java堆中保留的对象。如果找不到保留的对象,那么它们很可能在堆转储的过程中回收了。

image.png

对于每个被保留的对象,LeakCanary会找出阻止该保留对象被回收的引用链:泄漏路径。泄露路径就是从GC ROOTS到保留对象的最短的强引用路径的别名。确定泄漏路径以后,LeakCanary使用它对Android框架的了解来找出在泄漏路径上是谁泄漏了。

解决内存泄露

打开生成的Leaks应用,界面就类似下面这样婶儿滴。LeakCanary会计算一个泄漏路径并在UI上展示出来。这就是LeakCanary很友好的地方,通过UI展示,可以很直接的看到内存泄漏的过程。相对于mat和android studio 自带的profiler分析工具,这个简直太直观清晰了!

image.png

同时泄漏路径也在logcat中展示了出来:

**

HEAP ANALYSIS RESULT
====================================
1 APPLICATION LEAKS

References underlined with "~~~" are likely causes.
Learn more at https://squ.re/leaks.

111729 bytes retained by leaking objects
Signature: e030ebe81011d69c7a43074e799951b65ea73a
┬───
│ GC Root: Local variable in native code

├─ android.os.HandlerThread instance
│ Leaking: NO (PathClassLoader↓ is not leaking)
│ Thread name: 'LeakCanary-Heap-Dump'
│ ↓ HandlerThread.contextClassLoader
├─ dalvik.system.PathClassLoader instance
│ Leaking: NO (ToastUtil↓ is not leaking and A ClassLoader is never leaking)
│ ↓ PathClassLoader.runtimeInternalObjects
├─ java.lang.Object[] array
│ Leaking: NO (ToastUtil↓ is not leaking)
│ ↓ Object[].[871]
├─ com.example.leakcaneraytestapplication.ToastUtil class
│ Leaking: NO (a class is never leaking)
│ ↓ static ToastUtil.mToast
│ ~~~~~~
├─ android.widget.Toast instance
│ Leaking: YES (This toast is done showing (Toast.mTN.mWM != null && Toast.mTN.mView == null))
│ ↓ Toast.mContext
╰→ com.example.leakcaneraytestapplication.LeakActivity instance
Leaking: YES (ObjectWatcher was watching this because com.example.leakcaneraytestapplication.LeakActivity received Activity#onDestroy() callback and Activity#mDestroyed is true)
key = c1de58ad-30d8-444c-8a40-16a3813f3593
watchDurationMillis = 40541
retainedDurationMillis = 35535
====================================
0 LIBRARY LEAKS

路径中的每一个节点都对应着一个java对象。熟悉java内存回收机制的同学都应该知道”可达性分析算法“,LeakCanary就是用可达性分析算法,从GC ROOTS向下搜索,一直去找引用链,如果某一个对象跟GC Roots没有任何引用链相连时,就证明对象是”不可达“的,可以被回收。

我们从上往下看:

**

GC Root: Local variable in native code

在泄漏路径的顶部是GC Root。GC Root是一些总是可达的特殊对象。
接着是:

**

├─ android.os.HandlerThread instance
│ Leaking: NO (PathClassLoader↓ is not leaking)
│ Thread name: 'LeakCanary-Heap-Dump'
│ ↓ HandlerThread.contextClassLoader

这里先看一下Leaking的状态(YES、NO、UNKNOWN),NO表示没泄露。那我们还得接着向下看。

**

 ├─ dalvik.system.PathClassLoader instance
│ Leaking: NO (ToastUtil↓ is not leaking and A ClassLoader is never leaking)
│ ↓ PathClassLoader.runtimeInternalObjects

上面的节点告诉我们Leaking的状态还是NO,那再往下看。

**

   ├─ android.widget.Toast instance
│ Leaking: YES (This toast is done showing (Toast.mTN.mWM != null && Toast.mTN.mView == null))
│ ↓ Toast.mContext

中间Leaking是NO状态的我就不再贴出来,我们看看Leaking是YES的这一条,这里说明发生了内存泄露。
”This toast is done showing (Toast.mTN.mWM != null && Toast.mTN.mView == null)“,这里说明Toast发生了泄露,android.widget.Toast 这是系统的Toast控件,这说明我们在使用Toast的过程中极有可能创建了Toast对象,但是该回收它的时候无法回收它,导致出现了内存泄露,这里我们再往下看:

**

╰→ com.example.leakcaneraytestapplication.LeakActivity instance
Leaking: YES (ObjectWatcher was watching this because com.example.leakcaneraytestapplication.LeakActivity received Activity#onDestroy() callback and Activity#mDestroyed is true)

这里就很明显的指出了内存泄露是发生在了那个activity里面,我们根据上面的提示,找到对应的activity,然后发现了一段跟Toast有关的代码:

image.png

这里再进入ToastUtil这个自定义Toast类里面,看看下面的代码,有没有发现什么问题?这里定义了一个static的Toast对象类型,然后在showToast的时候创建了对象,之后就没有然后了。我们要知道static的生命周期是存在于整个应用期间的,而一般Toast对象只需要显示那么几秒钟就可以了,因为这里创建一个静态的Toast,用完之后又没有销毁掉,所以这里提示有内存泄露了。因此我们这里要么不用static修饰,要么在用完之后把Toast置为null。

**

public class ToastUtil {

private static Toast mToast;

public static void showToast(Context context, int resId) {
String text = context.getString(resId);
showToast(context, text);
}

public static void showToast(Context context, String text){
showToast(context, text, Gravity.BOTTOM);
}

public static void showToastCenter(Context context, String text){
showToast(context, text, Gravity.CENTER);
}

public static void showToast(Context context, String text, int gravity){
cancelToast();
if (context != null){
LayoutInflater inflater = (LayoutInflater) context
.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
View layout = inflater.inflate(R.layout.toast_layout, null);
((TextView) layout.findViewById(R.id.tv_toast_text)).setText(text);
mToast = new Toast(context);
mToast.setView(layout);
mToast.setGravity(gravity, 0, 20);
mToast.setDuration(Toast.LENGTH_LONG);
mToast.show();
}
}

public static void cancelToast() {
if (mToast != null){
mToast.cancel();
}
}
}

讲了这么多,其实内存泄露的本质是长周期对象持有了短周期对象的引用,导致短周期对象该被回收的时候无法被回收,从而导致内存泄露。我们只要顺着LeakCaneray的给出的引用链一个个的往下找,找到发生内存泄露的地方,切断引用链就可以释放内存了。

这里再补充一点上面的这个例子里面Leaking没有UNKNOWN的状态,一般情况下除了YES、NO还会出现UNKNOWN的状态,UNKNOWN表示这里可能出现了内存泄露,这些引用你需要花时间来调查一下,看看是哪里出了问题。一般推断内存泄露是从最后一个没有泄漏的节点(Leaking: NO )到第一个泄漏的节点(Leaking: YES)之间的引用。


收起阅读 »

❤️ Android 源码解读-startActivity(含启动新应用)❤️

开局一张图源码版本:Android 11(SDK 30)涉及到的类Instrumentation:负责 Application 和 Activity 的建立和生命周期控制。ActivityTaskManager:此类提供...
继续阅读 »

开局一张图

源码版本:Android 11(SDK 30)

涉及到的类

  • Instrumentation:负责 Application 和 Activity 的建立和生命周期控制。

  • ActivityTaskManager:此类提供有关Activity及其容器(如任务、堆栈和显示)的信息并与之交互。

  • ActivityTaskManagerService:用于管理Activity及其容器(任务、显示等)的系统服务。之前这个活是AMS的,现在从AMS中剥离出来了。

  • ActivityStartController:用于委托Activity启动的控制器。

  • ActivityStarter:专门负责一个 Activity 的启动操做。它的主要做用包括解析 Intent、建立 ActivityRecord、若是有可能还要建立 TaskRecordflex

  • ActivityStack:Activity在ATMS的栈管理,用来记录已经启动的Activity的先后关系,状态信息等。通过ActivityStack决定是否需要启动新的进程。

  • RootWindowContainer:设备的Root WindowContainer。

  • TaskRecord:ATMS抽象出来的一个"任务"的概念,是记录ActivityRecord的栈,一个"Task"包含若干个ActivityRecord。ATMS用TaskRecord确保Activity启动和退出的顺序。

  • ActivityStackSupervisor:负责所有Activity栈的管理。内部管理了mHomeStack、mFocusedStack和mLastFocusedStack三个Activity栈。其中,mHomeStack管理的是Launcher相关的Activity栈;mFocusedStack管理的是当前显示在前台Activity的Activity栈;mLastFocusedStack管理的是上一次显示在前台Activity的Activity栈。

  • ActivitySupervisor:管理 activity 任务栈。

  • ActivityThread:ActivityThread 运行在UI线程(主线程),App的真正入口。

  • ApplicationThread:用来实现ATMS和ActivityThread之间的交互。

  • ApplicationThreadProxy:ApplicationThread 在服务端的代理。ATMS就是通过该代理与ActivityThread进行通信的。

  • ClientLifecycleManager:能够组合多个客户端生命周期 transaction 请求和/或回调,并将它们作为单个 transaction 执行。

  • TransactionExecutor:已正确的顺序管理 transaction 执行的类。

1、Activity.java

1.1 startActivity()

@Override
public void startActivity(Intent intent) {
//接着往里看
this.startActivity(intent, null);
}

@Override
public void startActivity(Intent intent, @Nullable Bundle options) {
...
if (options != null) {
startActivityForResult(intent, -1, options);
} else {
startActivityForResult(intent, -1);
}
}

最终调用了 startActivityForResult 方法,传入的 -1 表示不需要获取 startActivity 的结果。

1.2 startActivityForResult()

public void startActivityForResult(@RequiresPermission Intent intent, int requestCode,
@Nullable Bundle options) {
//mParent表示当前Activitiy的父类,
if (mParent == null) {
options = transferSpringboardActivityOptions(options);
//ActivityThread mMainThread;
Instrumentation.ActivityResult ar =
mInstrumentation.execStartActivity(
this, mMainThread.getApplicationThread(), mToken, this,
intent, requestCode, options);
...
} else {
...
}
}

startActivityForResult 调用 Instrumentation.execStartActivity 方法。剩下的交给 Instrumentation 类去处理。

mMainThread 是 ActivityThread 类型,ActivityThread 可以理解为一个进程,这就是 Activity 所在的进程。

通过 mMainThread 获取一个 ApplicationThread 的引用,这个引用就是用来实现进程间通信的,具体来说就是 AMS 所在系统进程通知应用程序进程进行的一系列操作。

上面有Instrumentation、ActivityThread、ApplicationThread 等类的介绍。

2、Instrumentation.java

frameworks/base/core/java/android/app/Instrumentation.java

/**
* Base class for implementing application instrumentation code.
*/

public class Instrumentation {
...
}

2.1 execStartActivity()

 public ActivityResult execStartActivity(
Context who, IBinder contextThread, IBinder token, Activity target,
Intent intent, int requestCode, Bundle options) {
...
try {
intent.migrateExtraStreamToClipData();
intent.prepareToLeaveProcess(who);
int result = ActivityTaskManager.getService()
.startActivity(whoThread, who.getBasePackageName(), intent,
intent.resolveTypeIfNeeded(who.getContentResolver()),
token, target != null ? target.mEmbeddedID : null,
requestCode, 0, null, options);
//检查启动Activity的结果
checkStartActivityResult(result, intent);
} catch (RemoteException e) {
throw new RuntimeException("Failure from system", e);
}
return null;
}

在 Instrumentation 中,调用了 ActivityTaskManager.getService().startActivity() 。咱们近距离观望一下。

3、ActivityTaskManage.java

frameworks/base/core/java/android/app/ActivityTaskManager.java

@TestApi
@SystemService(Context.ACTIVITY_TASK_SERVICE)
public class ActivityTaskManager {
...
}

3.1 getService()

    /** @hide */
public static IActivityTaskManager getService() {
return IActivityTaskManagerSingleton.get();
}

3.2 IActivityTaskManagerSingleton

    @UnsupportedAppUsage(trackingBug = 129726065)
private static final Singleton<IActivityTaskManager> IActivityTaskManagerSingleton =
new Singleton<IActivityTaskManager>() {
@Override
protected IActivityTaskManager create() {
//代理对象(动态代理)
final IBinder b = ServiceManager.getService(Context.ACTIVITY_TASK_SERVICE);
return IActivityTaskManager.Stub.asInterface(b);
}
};

这里实际上返回的是一个 ActivityTaskManagerService 这出现了跨进程 (从应用进程(app) > system_service进程) ,然后调用其 startActivity 方法。

实际上这里就是通过 AIDL 来调用 ATMS 的 startActivity 方法,返回 IActivityTaskManager.startActivity 的结果:Activity 已正常成功启动。

至此,startActivity 的工作重心成功地从 应用进程(app) 转移到了系统进程(system_service) 的 ATMS 中。

4、ActivityTaskManagerService.java

frameworks/base/services/core/java/com/android/server/wm/ActivityTaskManagerService.java

/**
* 用于管理 activities and their containers (task, stacks, displays,... )的 system_service(系统服务)
*/

public class ActivityTaskManagerService extends IActivityTaskManager.Stub {
...
}

4.1 startActivity()

@Override
public final int startActivity(IApplicationThread caller, String callingPackage,
String callingFeatureId, Intent intent, String resolvedType, IBinder resultTo,
String resultWho, int requestCode, int startFlags, ProfilerInfo profilerInfo,
Bundle bOptions) {
return startActivityAsUser(caller, callingPackage, callingFeatureId, intent, resolvedType,
resultTo, resultWho, requestCode, startFlags, profilerInfo, bOptions,
UserHandle.getCallingUserId());
}

4.2 startActivityAsUser()

@Override
public int startActivityAsUser(IApplicationThread caller, String callingPackage,
String callingFeatureId, Intent intent, String resolvedType, IBinder resultTo,
String resultWho, int requestCode, int startFlags, ProfilerInfo profilerInfo,
Bundle bOptions, int userId) {
return startActivityAsUser(caller, callingPackage, callingFeatureId, intent, resolvedType,
resultTo, resultWho, requestCode, startFlags, profilerInfo, bOptions, userId,
true /*validateIncomingUser*/);
}

4.3 startActivityAsUser() 比上面多个参数

//重点来了
private int startActivityAsUser(IApplicationThread caller, String callingPackage,
@Nullable String callingFeatureId, Intent intent, String resolvedType,
IBinder resultTo, String resultWho, int requestCode, int startFlags,
ProfilerInfo profilerInfo, Bundle bOptions, int userId, boolean validateIncomingUser) {
assertPackageMatchesCallingUid(callingPackage);
enforceNotIsolatedCaller("startActivityAsUser");

userId = getActivityStartController().checkTargetUser(userId, validateIncomingUser,
Binder.getCallingPid(), Binder.getCallingUid(), "startActivityAsUser");

// 在此处切换到用户app堆栈。
return getActivityStartController().obtainStarter(intent, "startActivityAsUser")
.setCaller(caller)
.setCallingPackage(callingPackage)
.setCallingFeatureId(callingFeatureId)
.setResolvedType(resolvedType)
.setResultTo(resultTo)
.setResultWho(resultWho)
.setRequestCode(requestCode)
.setStartFlags(startFlags)
.setProfilerInfo(profilerInfo)
.setActivityOptions(bOptions)
.setUserId(userId)
.execute();

}

4.4 getActivityStartController()

    ActivityStartController getActivityStartController() {
return mActivityStartController;
}

5、ActivityStartController.java

/**
* Controller for delegating activity launches.
*/

public class ActivityStartController {
...
}

5.1 obtainStarter();

    /**
* @return A starter to configure and execute starting an activity. It is valid until after
* {@link ActivityStarter#execute} is invoked. At that point, the starter should be
* considered invalid and no longer modified or used.
*/

ActivityStarter obtainStarter(Intent intent, String reason) {
return mFactory.obtain().setIntent(intent).setReason(reason);
}

6、ActivityStarter.java

frameworks/base/services/core/java/com/android/server/wm/ActivityStarter.java

/**
* Controller for interpreting how and then launching an activity.
*
* This class collects all the logic for determining how an intent and flags should be turned into
* an activity and associated task and stack.
*/

class ActivityStarter {
...
}

6.1 execute()

    /**
* 根据前面提供的请求参数解析必要的信息,执行开始活动的请求。
* @return The starter result.
*/

int execute() {
...
res = executeRequest(mRequest);
...
}

6.2 executeRequest()

    /**
* Activity启动之前需要做那些准备?
* 通常的Activity启动流程将通过 startActivityUnchecked 到 startActivityInner。
*/

private int executeRequest(Request request) {
...
//Activity的记录
ActivityRecord sourceRecord = null;
ActivityRecord resultRecord = null;
...
//Activity stack(栈)管理
mLastStartActivityResult = startActivityUnchecked(r, sourceRecord, voiceSession,
request.voiceInteractor, startFlags, true /* doResume */, checkedOptions, inTask,
restrictedBgActivity, intentGrants);

...
return mLastStartActivityResult;
}

从上面代码可以看出,经过多个方法的调用,最终通过 obtainStarter 方法获取了 ActivityStarter 类型的对象,然后调用其 execute() 方法。

在 execute() 方法中,会再次调用其内部的 executeRequest() 方法。

咱们接着看看 startActivityUnchecked() ;

6.3 startActivityUnchecked()

    private int startActivityUnchecked(final ActivityRecord r, ActivityRecord sourceRecord,
IVoiceInteractionSession voiceSession, IVoiceInteractor voiceInteractor,
int startFlags, boolean doResume, ActivityOptions options, Task inTask,
boolean restrictedBgActivity, NeededUriGrants intentGrants) {
....
try {
...
result = startActivityInner(r, sourceRecord, voiceSession, voiceInteractor,
startFlags, doResume, options, inTask, restrictedBgActivity, intentGrants);
} finally {
...
}

postStartActivityProcessing(r, result, startedActivityRootTask);

return result;
}

6.4 startActivityInner()

    int startActivityInner(final ActivityRecord r, ActivityRecord sourceRecord,
IVoiceInteractionSession voiceSession, IVoiceInteractor voiceInteractor,
int startFlags, boolean doResume, ActivityOptions options, Task inTask,
boolean restrictedBgActivity, NeededUriGrants intentGrants) {
setInitialState(r, options, inTask, doResume, startFlags, sourceRecord, voiceSession,
voiceInteractor, restrictedBgActivity);

//计算启动模式
computeLaunchingTaskFlags();
computeSourceRootTask();
//设置启动模式
mIntent.setFlags(mLaunchFlags);

...

// 关键点来了
mRootWindowContainer.resumeFocusedTasksTopActivities(
mTargetRootTask, mStartActivity, mOptions, mTransientLaunch);
...

return START_SUCCESS;
}

这个是 mRootWindowContainer 是 RootWindowContainer

7、RootWindowContainer.java

frameworks/base/services/core/java/com/android/server/wm/RootWindowContainer.java

/** Root {@link WindowContainer} for the device. */
class RootWindowContainer extends WindowContainer<DisplayContent>
implements DisplayManager.DisplayListener {
...
}

7.1 resumeFocusedTasksTopActivities()

    boolean resumeFocusedTasksTopActivities(
Task targetRootTask, ActivityRecord target, ActivityOptions targetOptions,
boolean deferPause) {
...
boolean result = false;
if (targetRootTask != null && (targetRootTask.isTopRootTaskInDisplayArea()
|| getTopDisplayFocusedRootTask() == targetRootTask)) {
result = targetRootTask.resumeTopActivityUncheckedLocked(target, targetOptions,
deferPause);
}

...
return result;
}

当然这里看的是 targetRootTask 是 Task 对象的实例。咱们就去追踪这个方法。

8、

收起阅读 »

【奇淫技巧】解锁X5内核WebView同层渲染能力

前言WebView同层渲染,并不是一个新技术,国内一线互联网产品广泛应用,比如小程序的原生组件,电商H5嵌原生播放器等场景;如果您了解其原理,会发现这玩意在Android端,需要修改浏览器内核才能搞定,所以上手难度高;以至于读完文章,心血澎湃直呼牛逼,但是回过...
继续阅读 »

前言

WebView同层渲染,并不是一个新技术,国内一线互联网产品广泛应用,比如小程序的原生组件,电商H5嵌原生播放器等场景;

如果您了解其原理,会发现这玩意在Android端,需要修改浏览器内核才能搞定,所以上手难度高;以至于读完文章,心血澎湃直呼牛逼,但是回过头翻看原生WebView代码,并没有找到合适的API;

同层渲染简介

在Android平台,H5内容依托WebView视图渲染,它和原生的View是独立平等的关系, 从绘制层次上来看,WebView和原生必然存在相互覆盖遮挡的,更没法做到同步滚动;

在网页DOM树中,间杂一部分原生的组件,且保留原本的层次和样式,这就是同层渲染;

图片来源微信技术文章

同层渲染能解决什么问题?

使用web前端技术实现困难,或者稳定性、性能受限等情况

例如:视频播放器、地图、游戏引擎、直播推拉流、摄像头预览等场景;

领略X5内核浏览器同层渲染

准备工作:

准备一个占位标签

X5同层渲染的原理是用原生接管在H5页面里的特定标签,所以得准备一个H5页面,然后插入一个自定义的标签, 标签名可以随意定义,比如mytag,样式就按照标准的css来设置


占位的标签

强制开启X5WebView同层渲染

X5同层渲染能力默认是关闭的,通过云端开关控制,通过分析发现,可以强制修改本地SP属性,强制打开

if (mWebView.getX5WebViewExtension()!=null){
//强制设置EMBEDDED云控开关enable
SharedPreferences tbs_public_settings = getSharedPreferences("tbs_public_settings", Context.MODE_PRIVATE);
SharedPreferences.Editor edit = tbs_public_settings.edit();
edit.putInt("MTT_CORE_EMBEDDED_WIDGET_ENABLE",1);
edit.apply();
}else {
Log.d(TAG, "init: 非x5内核");
}

向浏览器注册目标占位标签的原生控件

使用registerEmbeddedWidget方法,可以想浏览器内核注册,需要原生来接管的占位的标签,第一个参数是需要接管的标签名,第二个参数是工厂创建对应原生标签对象的工厂接口

//注册dom树中占位标签,创建对应的原生组件
boolean result = mWebView.getX5WebViewExtension().registerEmbeddedWidget(new String[]{"mytag"}, new IEmbeddedWidgetClientFactory() {
@Override
public IEmbeddedWidgetClient createWidgetClient(String s, Map map, IEmbeddedWidget iEmbeddedWidget) {
Log.d(TAG, "init: createWidgetClient s"+s);
Log.d(TAG, "init: createWidgetClient map"+map.toString());
return new VideoEmbeddedWidgetClient(BrowserActivity.this);
}
});

其中createWidgetClient方法参数意义

  • s 标签名,大写
  • map 该标签的属性,在html中指定的
  • iEmbeddedWidget 提供的原生的该标签的代理接口

下面是map打印的内容

init: createWidgetClient map{src=https://vfx.mtime.cn/Video/2019/02/04/mp4/190204084208765161.mp4, style=position:absolute;width:350px; height:500px;, id=mytag}

处理原生占位控件的实现IEmbeddedWidgetClient

原生如何接管替换占位标签,主要的实现类是IEmbeddedWidgetClient

public interface IEmbeddedWidgetClient {

void onSurfaceCreated(Surface var1);

void onSurfaceDestroyed(Surface var1);

boolean onTouchEvent(MotionEvent var1);

void onRectChanged(Rect var1);

void onVisibilityChanged(boolean var1);

void onDestroy();

void onActive();

void onDeactive();

void onRequestRedraw();
}

首先,IEmbeddedWidgetClient并不是一个原生的View,而是给原生提供的该标签区域的绘制的入口,从onSurfaceCreatedonSurfaceDestroyed可以理解;

既然不是原生的View绘制,X5还是提供了类比View的属性,从API命名可以简单看出来其作用;

  • onSurfaceCreated 该标签可以绘制,请求原生API处理
  • onSurfaceDestroyed 该标签视图销毁,请求原生销毁
  • onTouchEvent 触摸事件分发
  • onRectChanged 该标签在WebView中坐标变化回调(例如滚动,改变宽高)
  • onVisibilityChanged 该标签显示隐藏
  • onDestroy 该标签被移除,或者display = none

演示个Demo

熟悉了X5的API,写个简单的Demo玩一下

Demo的设计思路是在一个垂直滚动的网页中,嵌入一个原生的相机,原生的相机要正常的采集显示,且前端代码可以控制相机标签的显示隐藏,同步滚动等基本操作;

网页




<span class="javascript">测试网页</span>






测试网页哈哈哈



1

2



相机占位标签

点击跳转哈哈哈

4

5

6






Java实现

public class CameraEmbeddedWidgetClient implements IEmbeddedWidgetClient {

private String TAG = "VideoEmbeddedWidgetClient";

private Rect rect;

private CameraHelper cameraHelper;

public CameraEmbeddedWidgetClient(Context c) {
cameraHelper = new CameraHelper(c);
}

@Override
public void onSurfaceCreated(Surface surface) {
Log.d(TAG, "onSurfaceCreated: ");
// Canvas canvas = surface.lockCanvas(rect);
// canvas.drawColor(Color.parseColor("#7f000000"));
// surface.unlockCanvasAndPost(canvas);
cameraHelper.preview(surface);
}

@Override
public void onSurfaceDestroyed(Surface surface) {
Log.d(TAG, "onSurfaceDestroyed: ");
cameraHelper.release();
}
}

snap.jpeg

前端样式改变和Native事件触发

  • 指定样式display:none 原生回调onVisibilityChanged(false)onDestroy
  • 指定样式display:block 重新创建新的Client
  • 指定样式visibility:visible 原生回调onVisibilityChanged(true)
  • 指定样式visibility:hidden 原生回调onVisibilityChanged(false)
  • 移除当前dom 等效于display:none

触摸事件的验证

必须得在js中设置改标签的事件监听

camera.addEventListener("touchStart",handlerTouch,false);
camera.addEventListener("touchend",handlerTouch,false);
camera.addEventListener("touchcancel",handlerTouch,false);
camera.addEventListener("touchleave",handlerTouch,false);
camera.addEventListener("touchmove",handlerTouch,false);

原生接受事件处理

IEmbeddedWidgetClient实现类

@Override
public boolean onTouchEvent(MotionEvent motionEvent)
{
Log.d(TAG, "onTouchEvent: "+motionEvent.toString());
float x = motionEvent.getX();
float y = motionEvent.getY();
int action = motionEvent.getAction();
switch (action){
case MotionEvent.ACTION_DOWN:
initX = x;
initY = y;
intercepted = false;
return false;
case MotionEvent.ACTION_MOVE:
float dx = x - initX;
float dy = y - initY;
if (!intercepted && Math.abs(dy)>Math.abs(dx) && Math.abs(dy)>16){
intercepted = true;
}
break;

case MotionEvent.ACTION_UP:

break;
}
return intercepted;
}
原文地址:https://juejin.cn/post/7018037732412768269
收起阅读 »

Android Activity/Window/View 的background

前言Activity/Window/View 的background,平时接触最多的就是View的background,Activity的background次之,最后用的较少的是Window的background,这三者有什么关联、区别呢?通过本篇文章,你将...
继续阅读 »

前言

Activity/Window/View 的background,平时接触最多的就是View的background,Activity的background次之,最后用的较少的是Window的background,这三者有什么关联、区别呢?
通过本篇文章,你将了解到:

1、View background 原理与使用
2、Window background 原理与使用
3、Activity background 原理与使用
4、常用背景设置

1、View background 原理与使用

先看个简单的图示:

image.png


一般来说,View展示区域分为两个部分:
1、背景
2、内容

本篇重点分析背景绘制,内容绘制请移步:Android 自定义View之Draw过程(上)

在平时的运用中,你是否思考过两个问题:

1、为什么内容区域能遮住背景区域
2、为什么View.scrollTo(xx)只能移动内容

先看第一个问题
来看看如何绘制View的背景:
熟知的View.draw(xx)方法如下:

#View.java
public void draw(Canvas canvas) {
...
//绘制背景-------------(1)
drawBackground(canvas);

...
if (!verticalEdges && !horizontalEdges) {
//--------------(2)

//绘制自身内容
onDraw(canvas);

//绘制子布局
dispatchDraw(canvas);
...
//前景、高亮等
return;
}
...
}

从上面(1)、(2)点可以看出,先绘制背景,再绘制内容,因此内容区域会遮住部分背景区域。

再看第二个问题
主要是drawBackground(canvas)方法:

#View.java
private void drawBackground(Canvas canvas) {
//背景Drawable
final Drawable background = mBackground;
if (background == null) {
//没有背景,无需绘制
return;
}

//设置背景Drawable,并设置其尺寸
setBackgroundBounds();

//支持硬件加速
if (canvas.isHardwareAccelerated() && mAttachInfo != null
&& mAttachInfo.mThreadedRenderer != null) {
//绘制背景,并返回Drawable ------------------(1)
mBackgroundRenderNode = getDrawableRenderNode(background, mBackgroundRenderNode);

final RenderNode renderNode = mBackgroundRenderNode;
if (renderNode != null && renderNode.hasDisplayList()) {
//绘制完成
setBackgroundRenderNodeProperties(renderNode);
//和Canvas关联起来,也就是将绘制好的背景挂到Canvas上
((RecordingCanvas) canvas).drawRenderNode(renderNode);
return;
}
}

//软件绘制
final int scrollX = mScrollX;
final int scrollY = mScrollY;
if ((scrollX | scrollY) == 0) {
//没有偏移,直接绘制
background.draw(canvas);
} else {
//现将canvas平移回来 ---------------(2)
canvas.translate(scrollX, scrollY);
//绘制
background.draw(canvas);
//再平移回去
canvas.translate(-scrollX, -scrollY);
}
}

上面标注了两个重点:
(1)
真正绘制背景的地方:

#View.java
private RenderNode getDrawableRenderNode(Drawable drawable, RenderNode renderNode) {
if (renderNode == null) {
//创建renderNode
renderNode = RenderNode.create(drawable.getClass().getName(),
new ViewAnimationHostBridge(this));
renderNode.setUsageHint(RenderNode.USAGE_BACKGROUND);
}

//获取尺寸,之前已经设置过
final Rect bounds = drawable.getBounds();
final int width = bounds.width();
final int height = bounds.height();
//获取专门绘制背景的Canvas
final RecordingCanvas canvas = renderNode.beginRecording(width, height);

//平移
canvas.translate(-bounds.left, -bounds.top);

try {
//绘制背景
drawable.draw(canvas);
} finally {
//结束绘制,将displayList记录到renderNode
renderNode.endRecording();
}

...
return renderNode;
}

可以看出,生成新的Canvas,用该Canvas绘制背景,并将绘制记录到背景的renderNode里。
(2)
你可能已经发现了,此处为什么对Canvas进行平移?
对于软件绘制来说,从RootView传递下来的Canvas是同一个,也就是说整个ViewTree都共用一个Canvas。对于View的绘制,其方法调用顺序如下:

draw(x)->dispatchDraw(x)->child.draw(x1,x2,x3)->child.draw(x)

在child.draw(x1,x2,x3)方法里,判断是否需要进行内容移动(mScrollX = 0 || mScrollY != 0),如果需要则移动Canvas,如下:

canvas.translate(-mScrollX, -mScrollY)

注意此处是取反了。
此时canvas已经被移动了,当调用到child.draw(xx)时候,就是上面分析的draw(xx)方法:
1、先绘制背景
2、绘制内容

绘制背景的时候将Canvas平移回来,再绘制背景,最后平移回去。再绘制内容的时候Canvas没变,依然了平移了(-mScrollX, -mScrollY),因此内容绘制的时候就会平移,而绘制背景的时候不变,这就回答了第二个问题。

上边仅仅针对软件绘制回答了第二个问题,那么硬件加速绘制的时候为啥不需要平移Canvas呢?此处简单说下结论:

硬件加速绘制的时候,每个View都有自己的Canvas,RenderNode,而相应的背景也有自己的Canvas,RenderNode,因此即使View的Canvas发生平移,也不会影响背景的Canvas,因此背景的Canvas无需针对mScrollX、mScrollY平移。

View绘制细节部分请移步:Android 自定义View之Draw过程(上)

用图表示背景绘制过程:

image.png

以上是针对View background分析,通常来说我们设置背景只要给其指定Drawable就ok了。
不论是通过动态设置:

#View.java
public void setBackground(Drawable background){...}
public void setBackgroundColor(@ColorInt int color){...}
public void setBackgroundResource(@DrawableRes int resid){...}
...

还是通过xml静态配置:

android:background="@color/colorGreen"
android:background="@drawable/test"
...

最终都是将生成的Drawable对象赋值给View的成员变量mBackground,最终在绘制的背景[drawBackground()]的时候使用该Drawable绘制。

2、Window background 原理与使用

若要设置Window背景,那么需要获取Window对象,我们常用的用到Window对象的地方有两个:Activity和Dialog。
Window是个抽象类,它的实现类是PhoneWindow,因此Activity和Dialog里Window指向实际上就是PhoneWindow对象。
获取Window引用方式如下:

Activity.getWindow()
Dialog.getWindow()

来看看Window是如何设置背景的:

    #Window.java
public abstract void setBackgroundDrawable(Drawable drawable);

#PhoneWindow.java
@Override
public final void setBackgroundDrawable(Drawable drawable) {
//mBackgroundDrawable 为记录当前Window的背景
if (drawable != mBackgroundDrawable) {
//背景改变则需要重新设置
mBackgroundDrawable = drawable;
if (mDecor != null) {
//mDecor 即为熟知的DecorView
//此处调用DecorView方法
mDecor.setWindowBackground(drawable);
...
}
}
}

PhoneWindow重写了Window里的setBackgroundDrawable(xx)方法。该方法里调用了DecorView的setWindowBackground(xx)。

#DecorView.java
public void setWindowBackground(Drawable drawable) {
if (mOriginalBackgroundDrawable != drawable) {
mOriginalBackgroundDrawable = drawable;
//赋值给View的成员变量mBackground,也就是给View设置背景
updateBackgroundDrawable();

//该处主要是判断如果Window不是半透明(windowTranslucent=true),但是drawable有透明度,强制设置透明度=255
if (drawable != null) {
mResizingBackgroundDrawable = enforceNonTranslucentBackground(drawable,
mWindow.isTranslucent() || mWindow.isShowingWallpaper());
} else {
mResizingBackgroundDrawable = getResizingBackgroundDrawable(
mWindow.mBackgroundDrawable, mWindow.mBackgroundFallbackDrawable,
mWindow.isTranslucent() || mWindow.isShowingWallpaper());
}
if (mResizingBackgroundDrawable != null) {
mResizingBackgroundDrawable.getPadding(mBackgroundPadding);
} else {
mBackgroundPadding.setEmpty();
}
drawableChanged();
}
}

可以看出给Window 设置背景最终反馈到DecorView上了。
以Activity为例,设置Activity Window的背景为绿色:

        ColorDrawable colorDrawable = new ColorDrawable();
colorDrawable.setColor(Color.GREEN);
getWindow().setBackgroundDrawable(colorDrawable);

效果如下:

image.png

方法调用流程:

image.png

3、Activity background 原理与使用

一般来说,我们会在Activity Theme里设置其背景:

    <style name="activitytheme" parent="Theme.AppCompat.Light.DarkActionBar">
<item name="android:windowBackground">#0033ff</item>
</style>

而在Activity onCreate(xx)里会调用setContentView(xx),而后调用PhoneWindow的generateLayout(xx)方法:

#PhoneWindow.java
protected ViewGroup generateLayout(DecorView decor) {
...
if (getContainer() == null) {
if (mBackgroundDrawable == null) {
...
//获取theme里设置的背景
if (a.hasValue(R.styleable.Window_windowBackground)) {
mBackgroundDrawable = a.getDrawable(R.styleable.Window_windowBackground);
}
}
...
}
...

if (getContainer() == null) {
//设置DecorView背景
mDecor.setWindowBackground(mBackgroundDrawable);
...
}
...
}

在Theme里设置的背景在此处被取出来,然后设置给DecorView背景。
严格上来说,Activity没有所谓背景的说法,它的"背景"指的是Window的背景,只是为了方便没有特意区分。 Activity有默认的背景,不同的主题取值不一样,我这主题默认背景色:

@color/material_grey_50
#fffafafa

那当要设置Activity为透明,怎么做呢?直接设置其背景透明,你会发现并没有达到效果,而是一片黑色。此时需要配合另一个属性使用:

    <style name="activitytheme" parent="Theme.AppCompat.Light.DarkActionBar">
<item name="android:windowIsTranslucent">true</item>
<item name="android:windowBackground">#00000000</item>
</style>

4、常用背景设置

从上可知,无论是Activity还是Window,设置它们背景的时候,最终都是设置了DecorView的背景。
我们知道,想要在屏幕上显示View,实际上是需要将这个View添加到Window里。调用如下方法:

WindowManager.addView(View view, ViewGroup.LayoutParams params)

该view作为Window的RootView。
来看看一些常用的RootView:

  • Activity/Dialog 使用DecorView作为RootView
  • PopupWindow使用PopupDecorView(没设置背景的时候)/PopupBackgroundView(有背景的时候)
  • 普通悬浮窗选择任意的View作为RootView

设置背景的过程实际上就是设置RootView的背景

上面举例说明了Activity背景设置,接下来阐述常用的弹框背景设置及其注意事项。

Dialog 背景设置

先看一个简单的Demo

    private void showDialog(Context context) {
Dialog dialog = new Dialog(context);
FrameLayout frameLayout = new FrameLayout(context);
TextView textView1 = new TextView(context);
textView1.setText("hello");
frameLayout.addView(textView1, new FrameLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT));
// frameLayout.setBackgroundColor(Color.RED);---------->(1)
dialog.setContentView(frameLayout);
dialog.getWindow().setLayout(800, 800);
ColorDrawable colorDrawable = new ColorDrawable();
colorDrawable.setColor(Color.TRANSPARENT);
// dialog.getWindow().setBackgroundDrawable(colorDrawable);--------->(2)
dialog.show();
}

将TextView添加到FrameLayout,并将FrameLayout作为Dialog ContentView添加进去,效果如下:

image.png

可以看出,Dialog默认设置了一个背景,该背景是有圆角的矩形。
现在将注释(1)打开:设置FrameLayout背景
效果如下:

image.png

发现圆角没了,先搞清楚这个圆角背景到底是哪个的背景呢?
将(1)注释掉,注释(2)打开,并设置

colorDrawable.setColor(Color.RED);

效果如下:

image.png

平白无故外层多了黑色的区域。
修改背景颜色为透明:

colorDrawable.setColor(Color.TRANSPARENT);

再看效果时,发现整个Dialog都没背景了。

image.png

在此基础上,将注释(1)打开,效果如下:

image.png

上面操作可能比较绕,实际上想表达的就是两个:

1、Dialog默认背景是DecorView的背景(DecorView默认背景会设置4个方向的Padding,当去除默认背景后会发现ContentView区域变大了)
2、一般来说,不要将Dialog背景改为ColorDrawable类型,会有黑色背景。要么将背景变为透明,然后设置contentView的背景;要么将背景改指向Shape。

设置Dialog背景两种方式:

//动态
dialog.getWindow().setBackgroundDrawable(colorDrawable);

//静态
//设置style
<style name="myDialog">
<item name="android:windowBackground">@android:color/transparent</item>
</style>

PopupWindow 背景设置

PopupWindow没有使用Window,也没有使用DecorView作为RootView。

    private void showPopupWindow(Context context, View anchor) {
TextView textView1 = new TextView(context);
textView1.setText("heloo jj");
PopupWindow popupWindow = new PopupWindow(textView1, 300, 300, true);
ColorDrawable colorDrawable = new ColorDrawable();
// colorDrawable.setColor(Color.GREEN);-------------->(1)
popupWindow.setBackgroundDrawable(colorDrawable);
popupWindow.showAsDropDown(anchor);
}

运行上面的Demo:

image.png

可以看出,PopupWindow 没有背景。
将注释(1)打开,效果如下:

image.png

背景已添加上。
想表达的意思:

PopupWindow 如果没有设置背景的话,那么背景会是透明的。

当设置PopupWindow背景时,会生成一个PopupBackgroundView 作为PopupWindow的RootView,而设置PopupWindow背景就是设置PopupBackgroundView的背景

设置PopupWindow背景两种方式:

//动态
popupWindow.setBackgroundDrawable(colorDrawable);

//静态
//设置style
<style name="myPopupWindow">
<item name="android:popupBackground">@color/green</item>
</style>

普通悬浮窗口 背景设置

如果是直接通过WindowManager.addView(View view, ViewGroup.LayoutParams params)添加弹窗。
需要设置RootView的背景,也就是上面方法的view的背景,否则背景将是黑色的。
关于悬浮窗已经分析过很多次了,更详细的内容请移步:
Window/WindowManager 不可不知之事

本文基于Android 10.0


收起阅读 »

iOS SwiftUI 创建和组合视图 2.0

创建列表和导航地标详情页视图已经创建完成,我们需要提供一种方式让用户可以查看完整的地标列表,并且可以查看每一个地标的详情下面会创建一个可以展示任何地标信息的视图,并动态生成一个可滚动列表,用户可以点击列表项去查看地标的详细信息。优化视图显示时,可以使用Xcod...
继续阅读 »

创建列表和导航

地标详情页视图已经创建完成,我们需要提供一种方式让用户可以查看完整的地标列表,并且可以查看每一个地标的详情

下面会创建一个可以展示任何地标信息的视图,并动态生成一个可滚动列表,用户可以点击列表项去查看地标的详细信息。优化视图显示时,可以使用Xcode画布来渲染多个不同设备大小下的预览视图。

下载下面的工程文件,并跟着教程一步步学习构建列表和视图间导航


第一节 了解样本数据

前面的教程中,自定义视图所展示的信息都直接被写死在代码中,这篇教程中会学习给自定义视图传入样本数据进行展示

swiftui-building-list

步骤1 打开项目导航器,选择Models->Landmark.swift文件,这个文件中声明了需要在应用中展示一个地标所需要信息的结构化名称,并通过导入landmarkData.json文件中的数据,生成一个地标信息数组。

building list model

步骤2 在项目导航器中选择Resources->landmarkData.json,在后面的教程中我们都会使用这个样本数据文件

building list sample data

步骤3 注意,之前的ContentView视图,已经被改名为LandmarkDetail了,在本教程和后面的教程中,还会创建一些其它的视图

landmark detail

第二节 创建行视图

本教程中创建的第一个视图就是用来显示每个地标的行视图,行视图把地标的相关信息存储在一个属性中,一行就可以代表一个地标,稍后就会把这些行组合成为一个列表。

swiftui-building-list-landmark-row

步骤1 创建一个名为LandmarkRow.swift的SwiftUI视图

landmark row create

步骤2 如果预览视图没有出现,可以选择菜单编辑器->画布,打开画布,并点击Resume进行预览,或者使用Command+Option+Enter快捷键调出画面,再使用Command+Option+P快捷键开始预览模式

步骤3 添加landmark属性做为LandmarkRow视图的一个存储属性。当添加landmark属性后,预览视图可能会停止工作,因为LandmarkRow视图初始化时需要有一个landmark实例。要想修复预览视图,需要修改Preview Provider

步骤4 在LandmarkRow_Previews的静态属性previews中给LandmarkRow初始化器中传入landmark参数,这个参数使用landmarkData数组的第一个元素。预览视图当前显示Hello, World

landmark row layout

步骤5 在一个HStack中嵌入一个Text

步骤6 修改这个Text,让它使用landmark属性的name字段

步骤7 在Text视图前面添加一个图片视图,在Text视图后面添加Spacer视图

landmark layout 1

第三节 自定义行预览

Xcode的画布会自动识别当前代码编辑器中遵循PreviewProvider协议的类型,并将它们渲染并展示在画面上。一个视图预览提供者(preview provider)返回一个或多个视图,这些视图可以配置不同的大小和设备型号。

可以定制从preview provider中返回的视图被渲染在何种场景下。

row preivew

步骤1 在LandmarkRow_Previews中,把landmark参数更新为landmarkData数组的第二个元素,预览视图会立即刷新反映第二个元素的渲染情况

preivew row 2

步骤2 使用previewLayout(_:)修改器设置一个行视图在列表中显示的尺寸大小。可以使用Group的方式,返回多个不同场景下的预览视图

preview layout size

步骤3 把预览的行视图包裹在Group中,把之前的第一个行视图也加进去。Group是一个容器,它可以把视图内容组织起来,Xcode会把Group内的每个子视图当作画布内一个单独的预览视图处理

preview group size

步骤4 为了简化代码,可以把previewLayout(_:)这个修改器应用到外层的Group上,Group的每一个子视图会继承自己所处环境的配置。对preivew provider的修改只会影响预览画布的表现,对实际的应用不会产生影响。

preview group coniguration


收起阅读 »

iOS SwiftUI 创建和组合视图 1.3

第六节 组合地标详情页前面我们创建了个地标详情页所需要的各种子视图元素:名称、地点、圆形图片以及位置地图,现在可以把这些视图元素组合在一起形成地标详情页的整个视图在项目工程浏览器中选择ContentView.swift文件body属性中嵌入一个VStack视图...
继续阅读 »

第六节 组合地标详情页

前面我们创建了个地标详情页所需要的各种子视图元素:名称、地点、圆形图片以及位置地图,现在可以把这些视图元素组合在一起形成地标详情页的整个视图

swiftui combine view begin

  1. 在项目工程浏览器中选择ContentView.swift文件

  2. body属性中嵌入一个VStack视图,它内部包含另一个VStack视图,内部的VStack视图又包含三个Text视图

  3. 在外层VStack的顶部添加自定义的地图视图MapView,并使用frame(width:height:)设置视图大小。当只指定高度时,宽度会自动计算为父视图的宽度,在这里就是屏幕宽度

  4. 点击Live Preview按钮进入实时预览模式,查看地图渲染情况。在实时预览模式下可以编辑视图,最新的改动也可以实时的刷新出来。

  5. MapView后面再添加一个CircleImage视图

  6. 为了让图片视图叠放在地图视图的上面,可以设置图片视图的垂直偏移量为-130,图片视图的底部内边距也为-130,这个效果就是把图片垂直上移了130,同时和下面的文字区域留出了130的空白分隔区

  7. 在外层VStack内部的最下面加上Spacer,可以让上面的视图内容顶到屏幕的上边

  8. 为了让地图的视图内容显示在状态栏的下方,可以给MapView添加edgesIgnoringSafeArea(.top)修改器,这可以让它在布局时忽略顶部的安全区域边距

swiftui combine view completed

检查是否理解

问题1 在声明自定义SwiftUI视图时,视图布局要声明的在哪里?

  •  在视图初始化器中
  •  body属性中
  •  layoutSubviews方法中

View协议中要求实现body属性,每一个SwiftUI视图都遵循View协议

问题2 代码布局的视图是以下哪个?

swiftui combine view problem2

  • swiftui combine view problem2-1
  • swiftui combine view problem2-2
  • swiftui combine view problem2-3

问题3 下面哪种方法是从body属性中返回三个视图的正确方法?

  • swiftui combine view problem3-1
  • swiftui combine view problem3-2
  • swiftui combine view problem3-3

问题4 配置视图时,下面哪种是正确使用修改器的方式?

  • swiftui combine view problem4-1
  • swiftui combine view problem4-2
  • swiftui combine view problem4-3

修改器每次都是返回一个新的对象,所以多个修改器可以通过链式调用

收起阅读 »

iOS SwiftUI 创建和组合视图 1.2

第四节 创建自定义图像视图(Image)有了地标名称、地标位置及状态视图,下一步再添加一个地标图片视图。这个图片视图将自定义遮罩(mask)、边框(border)和阴影(shadow)从控件加中拖一个Image到画布,或直接写代码到代码编辑器中步骤1 ...
继续阅读 »

第四节 创建自定义图像视图(Image)

有了地标名称、地标位置及状态视图,下一步再添加一个地标图片视图。这个图片视图将自定义遮罩(mask)、边框(border)和阴影(shadow)

从控件加中拖一个Image到画布,或直接写代码到代码编辑器中

步骤1 在项目资源文件中找到turtlerock.png图片,把它拖入资源编辑器(asset catalog editor)中,Xcode会创建一个新的图片集来存放这个图片,然后创建一个SwiftUI视图

swiftui assets catalog editor

步骤2 选择文件->新建->文件,打开模板选择器。在用户界面(User Interface)板块下,选择SwiftUI View并点击下一步,命名为CircleImage.swift,并点击创建(Create)。现在你已经准备好插入图片并修改布局来满足设计目标

swiftui create swiftui file

swiftui create circle image

步骤3 用Image替换Text,并使用turtlerock图片初始化Image视图

步骤4 添加clipShape(Circle())修改器到Image,给图片添加圆形剪切效果。Circle是一个形状,它可以被用作遮罩、也可以是圆圈,还可以是圆形填充视图。

步骤5 创建另一个灰色的圆圈并把它作为一个浮层添加到图片上,相当于给图片加了一个灰色边框

步骤6 给视图添加半径为10的阴影

swiftui turtlerock overlay

步骤7 把圆形边框的颜色改成白色,就完成了自定义图片视图的创建。

swiftui circle image completed

第五节 UIKit视图与SwiftUI视图混合使用

现在要创建一个地图视图,可以使用MapKit中的MKMapView视图类来渲染地图。要在SwiftUI中使用UIView及其子类,需要把这些UIView包裹在一个遵循UIViewRepresentable协议的SwiftUI视图中,SwiftUI中也包含适配WatchKitAppKit的类似的协议。

swiftui uikit swiftui combine

首先需要创建一个自定义视图用来容纳和显示MKMapView

步骤1 选择文件->新建->文件,选择iOS平台,选择SwiftUI View模板,并点击下一步(Next),命名文件为MapView.swift,并点击创建(Create)

步骤2 代码中导入MapKit引用,声明MapView遵循UIViewRepresentable协议。UIViewRepresentable协议要求实现两个方法UIView(context:)updateUIView(_:context:),第一个方法用来创建MKMapView,第二个方法用来配置视图响应状态变化

步骤3 替换body,用makeUIView(context:)方法来代替,创建并返回一个空的MKMapView

步骤4 创建方法updateUIView(_:context:),在方法内部设置地图视图的坐标为Turle Rock的中心。在静态模式下预览时,只会渲染swiftUI视图的部分,因为MKMapViewUIView的子类,所以需要切换到实时预览模式下才能看到地图被完全渲染出来

swiftui mapview mkmapview wrapper

步骤5 点击Live Preview(实时预览)按钮,可能需要点击Try AgainResume按钮来激活预览模式的切换。切换到实时预览模式下不久就可以看到指定地标所在的地图位置了

swiftui mkmapview live preview


收起阅读 »