注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

面试官:双亲委派模型你了解吗?

面试官:要不你今天来详细讲讲双亲委派机制? 候选者:嗯,好的。 候选者:上次提到了:class文件是通过「类加载器」装载至JVM中的 候选者:为了防止内存中存在多份同样的字节码,使用了双亲委派机制(它不会自己去尝试加载类,而是把请求委托给父加载器去完成,依次向...
继续阅读 »

面试官要不你今天来详细讲讲双亲委派机制?


候选者:嗯,好的。


候选者:上次提到了:class文件是通过「类加载器」装载至JVM中的


候选者:为了防止内存中存在多份同样的字节码,使用了双亲委派机制(它不会自己去尝试加载类,而是把请求委托给父加载器去完成,依次向上)


候选者:JDK 中的本地方法类一般由根加载器(Bootstrp loader)装载,JDK 中内部实现的扩展类一般由扩展加载器(ExtClassLoader )实现装载,而程序中的类文件则由系统加载器(AppClassLoader )实现装载。



候选者:这应该很好理解吧?


面试官:雀食(确实)!


面试官顺着话题,我想问问,打破双亲委派机制是什么意思?


候选者:很好理解啊,意思就是:只要我加载类的时候,不是从APPClassLoader->Ext ClassLoader->BootStrap ClassLoader 这个顺序找,那就算是打破了啊


候选者:因为加载class核心的方法在LoaderClass类的loadClass方法上(双亲委派机制的核心实现)


候选者:那只要我自定义个ClassLoader,重写loadClass方法(不依照往上开始寻找类加载器),那就算是打破双亲委派机制了。


面试官:这么简单?


候选者:嗯,就是这么简单


面试官那你知道有哪个场景破坏了双亲委派机制吗?


候选者:最明显的就Tomcat啊


面试官:详细说说?


候选者:在初学时部署项目,我们是把war包放到tomcat的webapp下,这意味着一个tomcat可以运行多个Web应用程序(:


候选者:是吧?


面试官:嗯..


候选者:那假设我现在有两个Web应用程序,它们都有一个类,叫做User,并且它们的类全限定名都一样,比如都是com.yyy.User。但是他们的具体实现是不一样的


候选者:那么Tomcat是如何保证它们是不会冲突的呢?


候选者:答案就是,Tomcat给每个 Web 应用创建一个类加载器实例(WebAppClassLoader),该加载器重写了loadClass方法,优先加载当前应用目录下的类,如果当前找不到了,才一层一层往上找(:


候选者:那这样就做到了Web应用层级的隔离



面试官嗯,那你还知道Tomcat还有别的类加载器吗?


候选者:嗯,知道的


候选者:并不是Web应用程序下的所有依赖都需要隔离的,比如Redis就可以Web应用程序之间共享(如果有需要的话),因为如果版本相同,没必要每个Web应用程序都独自加载一份啊。


候选者:做法也很简单,Tomcat就在WebAppClassLoader上加了个父类加载器(SharedClassLoader),如果WebAppClassLoader自身没有加载到某个类,那就委托SharedClassLoader去加载。


候选者:(无非就是把需要应用程序之间需要共享的类放到一个共享目录下嘛)


面试官:嗯..


候选者:为了隔绝Web应用程序与Tomcat本身的类,又有类加载器(CatalinaClassLoader)来装载Tomcat本身的依赖


候选者:如果Tomcat本身的依赖和Web应用还需要共享,那么还有类加载器(CommonClassLoader)来装载进而达到共享


候选者:各个类加载器的加载目录可以到tomcat的catalina.properties配置文件上查看


候选者:我稍微画下Tomcat的类加载结构图吧,不然有点抽象



面试官:嗯,还可以,我听懂了,有点意思。


面试官顺便,我想问下,JDBC你不是知道吗,听说它也是破坏了双亲委派模型的,你怎么理解的。


候选者:Eumm,这个有没有破坏,见仁见智吧。


候选者:JDBC定义了接口,具体实现类由各个厂商进行实现嘛(比如MySQL)


候选者:类加载有个规则:如果一个类由类加载器A加载,那么这个类的依赖类也是由「相同的类加载器」加载。


候选者:我们用JDBC的时候,是使用DriverManager进而获取Connection,DriverManager在java.sql包下,显然是由BootStrap类加载器进行装载


候选者:当我们使用DriverManager.getConnection()时,得到的一定是厂商实现的类。


候选者:但BootStrap ClassLoader会能加载到各个厂商实现的类吗?


候选者:显然不可以啊,这些实现类又没在java包中,怎么可能加载得到呢


面试官:嗯..


候选者:DriverManager的解决方案就是,在DriverManager初始化的时候,得到「线程上下文加载器」


候选者:去获取Connection的时候,是使用「线程上下文加载器」去加载Connection的,而这里的线程上下文加载器实际上还是App ClassLoader


候选者:所以在获取Connection的时候,还是先找Ext ClassLoader和BootStrap ClassLoader,只不过这俩加载器肯定是加载不到的,最终会由App ClassLoader进行加载



面试官:嗯..


候选者:那这种情况,有的人觉得破坏了双亲委派机制,因为本来明明应该是由BootStrap ClassLoader进行加载的,结果你来了一手「线程上下文加载器」,改掉了「类加载器」


候选者:有的人觉得没破坏双亲委派机制,只是改成由「线程上下文加载器」进行类加载,但还是遵守着:「依次往上找父类加载器进行加载,都找不到时才由自身加载」。认为”原则”上是没变的。


面试官:那我了解了


本文总结




  • 前置知识: JDK中默认类加载器有三个:AppClassLoader、Ext ClassLoader、BootStrap ClassLoader。AppClassLoader的父加载器为Ext ClassLoader、Ext ClassLoader的父加载器为BootStrap ClassLoader。这里的父子关系并不是通过继承实现的,而是组合。




  • 什么是双亲委派机制: 加载器在加载过程中,先把类交由父类加载器进行加载,父类加载器没找到才由自身加载。




  • 双亲委派机制目的: 为了防止内存中存在多份同样的字节码(安全)




  • 类加载规则: 如果一个类由类加载器A加载,那么这个类的依赖类也是由「相同的类加载器」加载。




  • 如何打破双亲委派机制: 自定义ClassLoader,重写loadClass方法(只要不依次往上交给父加载器进行加载,就算是打破双亲委派机制)




  • 打破双亲委派机制案例: Tomcat



    • 为了Web应用程序类之间隔离,为每个应用程序创建WebAppClassLoader类加载器

    • 为了Web应用程序类之间共享,把ShareClassLoader作为WebAppClassLoader的父类加载器,如果WebAppClassLoader加载器找不到,则尝试用ShareClassLoader进行加载

    • 为了Tomcat本身与Web应用程序类隔离,用CatalinaClassLoader类加载器进行隔离,CatalinaClassLoader加载Tomcat本身的类

    • 为了Tomcat与Web应用程序类共享,用CommonClassLoader作为CatalinaClassLoader和ShareClassLoader的父类加载器

    • ShareClassLoader、CatalinaClassLoader、CommonClassLoader的目录可以在Tomcat的catalina.properties进行配置




  • 线程上下文加载器: 由于类加载的规则,很可能导致父加载器加载时依赖子加载器的类,导致无法加载成功(BootStrap ClassLoader无法加载第三方库的类),所以存在「线程上下文加载器」来进行加载。


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

Flutter 快速开发框架

项目简介 此框架旨在将常规的Flutter项目中使用到的通用(与业务无关)的功能从剥离出来,构成Flutter开发项目的框架,在开发新的Flutter项目时,可以直接引用本项目 import 'package:framework/framework.dart'...
继续阅读 »

项目简介


此框架旨在将常规的Flutter项目中使用到的通用(与业务无关)的功能从剥离出来,构成Flutter开发项目的框架,在开发新的Flutter项目时,可以直接引用本项目 import 'package:framework/framework.dart'来使用框架中相关的功能,提升开发效率。


Github项目地址:可以看我主页找我拿


此框架目前包含以下功能模块:接口请求API模块、消息提示模块、路由模块、统一错误处理、日志模块、屏幕适配测试、自定义UI组件库、本地存储模块构成


框架使用说明


引用


import 'package:framework/framework.dart';

使用


参考 Example 中使用的例子

框架首页



网络HTTP模块



消息提示模块



路由模块



统一错误处理介绍



日志模块



屏幕适配测试



自定义UI组件库



本地存储模块



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

都 2021 年了,还有人在研究 Handler?

我们经常使用和提及 Android 中特有的线程间通信方式即 Handler 机制,缘于该机制特别好用、极为重要! 初尝 Handler 机制的时候,原以为 Handler 类发挥了很大的作用。当你深入了解它的原理之后,会发现 Handler 只是该机制的调用...
继续阅读 »

我们经常使用和提及 Android 中特有的线程间通信方式即 Handler 机制,缘于该机制特别好用、极为重要!


初尝 Handler 机制的时候,原以为 Handler 类发挥了很大的作用。当你深入了解它的原理之后,会发现 Handler 只是该机制的调用入口和回调而已,最重要的东西是 LooperMessagQueue,以及不断流转的 Message


本次针对 Handler 机制常被提及和容易困扰的 20 个问题进行整理和回答,供大家解惑和回顾~


问题前瞻:



  1. 简述下 Handler 机制的总体原理?

  2. Looper 存在哪?如何可以保证线程独有?

  3. 如何理解 ThreadLocal 的作用?

  4. 主线程 Main Looper 和一般 Looper 的异同?

  5. Handler 或者说 Looper 如何切换线程?

  6. Looper 的 loop() 死循环为什么不卡死?

  7. Looper 的等待是如何能够准确唤醒的?

  8. Message 如何获取?为什么这么设计?

  9. MessageQueue 如何管理 Message?

  10. 理解 Message 和 MessageQueue 的异同?

  11. Message 的执行时刻如何管理?

  12. Handler、Mesage 和 Runnable 的关系如何理解?

  13. IdleHandler 空闲 Message 了解过吗?有什么用?

  14. 异步 Message 或同步屏障了解过吗?怎么用?什么原理?

  15. Looper 和 MessageQueue、Message 及 Handler 的关系?

  16. Native 侧的 NativeMessageQueue 和 Looper 的作用是?

  17. Native 侧如何使用 Looper?

  18. Handler 为什么可能导致内存泄露?如何避免?

  19. Handler 在系统当中的应用

  20. Android 为什么不允许并发访问 UI?


1. 简述下 Handler 机制的总体原理?




  1. Looper 准备和开启轮循:



    • Looper#prepare() 初始化线程独有的 Looper 以及 MessageQueue

    • Looper#loop() 开启死循环读取 MessageQueue 中下一个满足执行时间的 Message

      • 尚无 Message 的话,调用 Native 侧的 pollOnce() 进入无限等待

      • 存在 Message,但执行时间 when 尚未满足的话,调用 pollOnce() 时传入剩余时长参数进入有限等待






  2. Message 发送、入队和出队:



    • Native 侧如果处于无限等待的话:任意线程向 Handler 发送 MessageRunnable 后,Message 将按照 when 条件的先后,被插入 Handler 持有的 Looper 实例所对应的 MessageQueue 中适当的位置。 MessageQueue 发现有合适的 Message 插入后将调用 Native 侧的 wake() 唤醒无限等待的线程。这将促使 MessageQueue 的读取继续进入下一次循环,此刻 Queue 中已有满足条件的 Message 则出队返回给 Looper

    • Native 侧如果处于有限等待的话:在等待指定时长后 epoll_wait 将返回。线程继续读取 MessageQueue,此刻因为时长条件将满足将其出队




  3. Looper 处理 Message 的实现:


    Looper 得到 Message 后回调 Message 的 callback 属性即 Runnable,或依据 target 属性即 Handler,去执行 Handler 的回调。



    • 存在 mCallback 属性的话回调 Handler$Callback

    • 反之,回调 handleMessage()




2. Looper 存在哪?如何可以保证线程独有?



  • Looper 实例被管理在静态属性 sThreadLocal

  • ThreadLocal 内部通过 ThreadLocalMap 持有 Looper,key 为 ThreadLocal 实例本身,value 即为 Looper 实例

  • 每个 Thread 都有一个自己的 ThreadLocalMap,这样可以保证每个线程对应一个独立的 Looper 实例,进而保证 myLooper() 可以获得线程独有的 Looper


彩蛋:一个 App 拥有几个 Looper 实例?几个 ThreadLocal 实例?几个 MessageQueue 实例?几个 Message 实例?几个 Handler 实例



  • 一个线程只有一个 Looper 实例

  • 一个 Looper 实例只对应着一个 MessageQueue 实例

  • 一个 MessageQueue 实例可对应多个 Message 实例,其从 Message 静态池里获取,存在 50 的上限

  • 一个线程可以拥有多个 Handler 实例,其Handler 只是发送和执行任务逻辑的入口和出口

  • ThreadLocal 实例是静态的,整个进程共用一个实例。每个 Looper 存放的 ThreadLocalMap 均弱引用它作为 key


3. 如何理解 ThreadLocal 的作用?



  • 首先要明确并非不是用来切换线程的,只是为了让每个线程方便程获取自己的 Looper 实例,见 Looper#myLooper()

    • 后续可供 Handler 初始化时指定其所属的 Looper 线程

    • 也可用来线程判断自己是否是主线程




4. 主线程 Main Looper 和一般 Looper 的异同?




  • 区别:



    1. Main Looper 不可 quit


    主线程需要不断读取系统消息和用书输入,是进程的入口,只可被系统直接终止。进而其 Looper 在创建的时候设置了不可 quit 的标志,而其他线程的 Looper 则可以也必须手动 quit



    1. Main Looper 实例还被静态缓存


    为了便于每个线程获得主线程 Looper 实例,见 Looper#getMainLooper(),Main Looper 实例还作为 sMainLooper 属性缓存到了 Looper 类中。




  • 相同点:



    1. 都是通过 Looper#prepare() 间接调用 Looper 构造函数创建的实例

    2. 都被静态实例 ThreadLocal 管理,方便每个线程获取自己的 Looper 实例




彩蛋:主线程为什么不用初始化 Looper?


App 的入口并非 MainActivity,也不是 Application,而是 ActivityThread。


其为了 Application、ContentProvider、Activity 等组件的运行,必须事先启动不停接受输入的 Looper 机制,所以在 main() 执行的最后将调用 prepareMainLooper() 创建 Looper 并调用 loop() 轮循。


不需要我们调用,也不可能有我们调用。


可以说如果主线程没有创建 Looper 的话,我们的组件也不可能运行得到!


5. Handler 或者说 Looper 如何切换线程?




  1. Handler 创建的时候指定了其所属线程的 Looper,进而持有了 Looper 独有的 MessageQueue




  2. Looper#loop() 会持续读取 MessageQueue 中合适的 Message,没有 Message 的时候进入等待




  3. 当向 Handler 发送 Message 或 Runnable 后,会向持有的 MessageQueue 中插入 Message




  4. Message 抵达并满足条件后会唤醒 MessageQueue 所属的线程,并将 Message 返回给 Looper




  5. Looper 接着回调 Message 所指向的 Handler Callback 或 Runnable,达到线程切换的目的




简言之,向 Handler 发送 Message 其实是向 Handler 所属线程的独有 MessageQueue 插入 Message。而线程独有的 Looper 又会持续读取该 MessageQueue。所以向其他线程的 Handler 发送完 Message,该线程的 Looper 将自动响应。


6. Looper 的 loop() 死循环为什么不卡死?


为了让主线程持续处理用户的输入,loop() 是死循环,持续调用 MessageQueue#next() 读取合适的 Message。


但当没有 Message 的时候,会调用 pollOnce() 并通过 Linux 的 epoll 机制进入等待并释放资源。同时 eventFd 会监听 Message 抵达的写入事件并进行唤醒。


这样可以空闲时释放资源、不卡死线程,同时能持续接收输入的目的


彩蛋1:loop() 后的处理为什么不可执行


因为 loop() 是死循环,直到 quit 前后面的处理无法得到执行,所以避免将处理放在 loop() 的后面。


**彩蛋2:Looper 等待的时候线程到底是什么状态? **


调用 Linux 的 epoll 机制进入等待,事实上 Java 侧打印该线程的状态,你会发现线程处于 Runnable 状态,只不过 CPU 资源被暂时释放。


7. Looper 的等待是如何能够准确唤醒的?


读取合适 Message 的 MessageQueue#next() 会因为 Message 尚无或执行条件尚未满足进行两种等的等待:




  • 无限等待


    尚无 Message(队列中没有 Message 或建立了同步屏障但尚无异步 Message)的时候,调用 Natvie 侧的 pollOnce() 会传入参数 -1


    Linux 执行 epoll_wait() 将进入无限等待,其等待合适的 Message 插入后调用 Native 侧的 wake() 向唤醒 fd 写入事件触发唤醒 MessageQueue 读取的下一次循环




  • 有限等待


    有限等待的场合将下一个 Message 剩余时长作为参数交给 epoll_wait(),epoll 将等待一段时间之后自动返回,接着回到 MessageQueue 读取的下一次循环




8. Message 如何获取?为什么这么设计?




  • 享元设计模式:通过 Message 的静态方法 obatin() 获取,因为该方法不是无脑地 new,而是从单链表池子里获取实例,并在 recycle() 后将其放回池子




  • 好处在于复用 Message 实例,满足频繁使用 Message 的场景,更加高效




  • 当然,缓存池存在上限 50,因为没必要无限制地缓存,这本身也是一种浪费




  • 需要留意缓存池是静态的,也就是整个进程共用一个缓存池




9. MessageQueue 如何管理 Message?



  • MessageQueue 通过单链表管理 Message,不同于进程共用的 Message Pool,其是线程独有的

  • 通过 Message 的执行时刻 when 对 Message 进行排队和出队

  • MessageQueue 除了管理 Message,还要管理空闲 Handler 和 同步屏障


10. 理解 Message 和 MessageQueue 的异同?




  • 相同点:都是通过单链表来管理 Message 实例;




    • Message 通过 obtain() 和 recycle() 向单链表获取插入节点




    • MessageQueue 通过 enqueueMessage() 和 next() 向单链表获取和插入节点






  • 区别:



    • Message 单链表是静态的,供进程使用的缓存池




  • MessageQueue 单链表非静态,只供 Looper 线程使用




11. Message 的执行时刻如何管理?



  • 发送的 Message 都是按照执行时刻 when 属性的先后管理在 MessageQueue 里

    • 延时 Message 的 when 等于调用的当前时刻delay 之和

    • 非延时 Message 的 when 等于当前时刻(delay 为 0

    • 插队 Message 的 when 固定为 0,便于插入队列的 head



  • 之后 MessageQueue 会根据读取的时刻和 when 进行比较

    • 将 when 已抵达的出队,

    • 尚未抵达的计算出当前时刻和目标 when 的插值,交由 Native 等待对应的时长,时间到了自动唤醒继续进行 Message 的读取




事实上,无论上述哪种 Message 都不能保证在其对应的 when 时刻执行,往往都会延迟一些!因为必须等当前执行的 Message 处理完了才有机会读取队列的下一个 Message。


比如发送了非延时 Message,when 即为发送的时刻,可它们不会立即执行。都要等主线程现有的任务(Message)走完才能有机会出队,而当这些任务执行完 when 的时刻已经过了。假使队列的前面还有其他 Message 的话,延迟会更加明显!


彩蛋:. onCreate() 里向 Handler 发送大量 Message 会导致主线程卡顿吗?


不会,发送的大量 Message 并非立即执行,只是先放到队列当中而已。


onCreate() 以及之后同步调用的 onStart() 和 onResume() 处理,本质上也是 Message。等这个 Message 执行完之后,才会进行读取 Message 的下一次循环,这时候才能回调 onCreate 里发送的 Message。


需要说明的是,如果发送的是 FrontOfQueue 将 Message 插入队首也不会立即先执行,因为 onStart 和 onResume 是 onCreate 之后同步调用的,本质上是同一个 Message 的作业周期


12. Handler、Mesage 和 Runnable 的关系如何理解?



  • 作为使用 Handler 机制的入口,Handler 是发送 Message 或 Runnable 的起点

  • 发送的 Runnable 本质上也是 Message,只不过作为 callback 属性被持有

  • Handler 作为 target 属性被持有在 Mesage 中,在 Message 执行条件满足的时候供 Looper 回调


事实上,Handler 只是供 App 使用 Handler 机制的 API,实质来说,Message 是更为重要的载体。


13. IdleHandler 空闲 Message 了解过吗?有什么用?




  • 适用于期望空闲时候执行,但不影响主线程操作的任务




  • 系统应用:



    1. Activity destroy 回调就放在了 IdleHandler

    2. ActivityThreadGCHandler 使用了 IdleHandler,在空闲的时候执行 GC 操作




  • App 应用:



    1. 发送一个返回 true 的 IdleHandler,在里面让某个 View 不停闪烁,这样当用户发呆时就可以诱导用户点击这个 View

    2. 将某部分初始化放在 IdleHandler 里不影响 Activity 的启动




  • 彩蛋问题:



    1. add/remove IdleHandler 的方法,是否需要成对使用?


    不需要,回调返回 false 也可以移除




    1. mIdleHanders 一直不为空时,为什么不会进入死循环?




    执行过 IdleHandler 之后会将计数重置为 0,确保下一次循环不重复执行



    1. 是否可以将一些不重要的启动服务,搬移到 IdleHandler 中去处理?


    最好不要,回调时机不太可控,需要搭配 remove 谨慎使用



    1. IdleHandle 的 queueIdle() 运行在那个线程?


    取决于 IdleHandler add 到的 MessageQueue 所处的线程




14. 异步 Message 或同步屏障了解过吗?怎么用?什么原理?




  • 异步 Message:设置了 isAsync 属性的 Message 实例



    • 可以用异步 Handler 发送

    • 也可以调用 Message#setAsynchronous() 直接设置为异步 Message




  • 同步屏障:在 MessageQueue 的某个位置放一个 target 属性为 null 的 Message,确保此后的非异步 Message 无法执行,只能执行异步 Message




  • 原理:当 MessageQueue 轮循 Message 时候发现建立了同步屏障的时候,会去跳过其他 Message,读取下个 async 的 Message 并执行,屏障移除之前同步 Message 都会被阻塞




  • 应用:比如屏幕刷新 Choreographer 就使用到了同步屏障,确保屏幕刷新事件不会因为队列负荷影响屏幕及时刷新。




  • 注意:同步屏障的添加或移除 API 并未对外公开,App 需要使用的话需要依赖反射机制




15. Looper 和 MessageQueue、Message 及 Handler 的关系?



  • Message 是承载任务的载体,在 Handler 机制中贯穿始终

  • Handler 则是对外公开的 API,负责发送 Message 和处理任务的回调,是 Message 的生产者

  • MessagQueue 负责管理待处理 Message 的入队和出队,是 Message 的容器

  • Looper 负责轮循 MessageQueue,保持线程持续运行任务,是 Message 的消费者


彩蛋:如何保证 MessageQueue 并发访问安全?


任何线程都可以通过 Handler 生产 Message 并放入 MessageQueue 中,可 Queue 所属的 Looper 在持续地读取并尝试消费 Message。如何保证两者不产生死锁?


Looper 在消费 Message 之前要先拿到 MessageQueue 的锁,**只不过没有 Message 或 Message 尚未满足条件的进行等待前会事先释放锁,**具体在于 nativePollOnce() 的调用在 synchronized 方法块的外侧。


Message 入队前也需先拿到 MessageQueue 的锁,而这时 Looper 线程正在等待且不持有锁,可以确保 Message 的成功入队。入队后执行唤醒后释放锁,Native 收到 event 写入后恢复 MessagQueue 的读取并可以拿到锁,成功出队。


这样一种在没有 Message 可以消费时执行等待同时不占着锁的机制,避免了生产和消费的死锁。


16. Native 侧的 NativeMessageQueue 和 Looper 的作用是?




  • NativeMessageQueue 负责连接 Java 侧的 MessageQueue,进行后续的 waitwake,后续将创建 wake 的FD,并通过 epoll 机制等待或唤醒。但并不参与管理 Java 的 Message




  • Native 侧也需要 Looper 机制,等待和唤醒的需求是同样的,所以将这部分实现都封装到了 JNI 的NativeMessageQueue 和 Native 的 Looper 中,供 Java 和 Native 一起使用




17. Native 侧如何使用 Looper?




  • Looper Native 部分承担了 Java 侧 Looper 的等待和唤醒,除此之外其还提供了 Message、MessageHandlerWeakMessageHandlerLooperCallbackSimpleLooperCallback 等 API




  • 这些部分可供 Looper 被 Native 侧直接调用,比如 InputFlinger 广泛使用了 Looper




  • 主要方法是调用 Looper 构造函数或 prepare 创建 Looper,然后通过 poll 开始轮询,接着 sendMessageaddEventFd,等待 Looper 的唤醒。使用过程和 Java 的调用思路类似




18. Handler 为什么可能导致内存泄露?如何避免?



  • 持有 Activity 实例的内名内部类或内部类的生命周期应当和 Activity 保持一致,否则产生内存泄露的风险。

  • 如果 Handler 使用不当,将造成不一致,表现为:匿名内部类或内部类写法的 Handler、Handler$Callback、Runnable,或者Activity 结束时仍有活跃的 Thread 线程或 Looper 子线程

  • 具体在于:异步任务仍然活跃或通过发送的 Message 尚未处理完毕,将使得内部类实例的生命周期被错误地延长。造成本该回收的 Activity 实例被别的 ThreadMain Looper 占据而无法及时回收(活跃的 Thread 或 静态属性 sMainLooper 是 GC Root 对象)

  • 建议的做法:

    • 无论是 Handler、Handler$Callback 还是 Runnable,尽量采用静态内部类 + 弱引用的写法,确保尽管发生不当引用的时候也可以因为弱引用能清楚持有关系

    • 另外在 Activity 销毁的时候及时地终止 Thread、停止子线程的 Looper 或清空 Message,确保彻底切断 Activity 经由 Message 抵达 GC Root 的引用源头(Message 清空后会其与 Handler 的引用关系,Thread 的终止将结束其 GC Root 的源头)




注意:静态的 sThreadLocal 实例不持有存放 Looper 实例的 ThreadLocalMap,而是由 Thread 持有。从这个角度上来讲,Looper 会被活跃的 GC Root Thread 持有,进而也可能导致内存泄露。


彩蛋:网传的 Handler$Callback 方案能否解决内存泄露?


不能。


Callback 采用内部类或匿名内部类写法的话,默认持有 Activity 的引用,而 Callback 被 Handler 持有。这最终将导致 Message -> Handler -> Callback -> Activity 的链条仍然存在。


19. Handler 在系统当中的应用


特别广泛,比如:



  • Activity 生命周期的管理

  • 屏幕刷新

  • HandlerThread、IntentService

  • AsyncTask 等。


主要利用 Handler 的切换线程、主线程异步 Message 的重要特性。注意:Binder 线程非主线程,但很多操作比如生命周期的管理都要回到主线程,所以很多 Binder 调用过来后都要通过 Handler 切换回主线程执行后续任务,比如 ActviityThread$H 就是 extends Handler。


20. Android 为什么不允许并发访问 UI?


Android 中 UI 非线程安全,并发访问的话会造成数据和显示错乱。


但此限制的检查始于ViewRootImpl#checkThread(),其会在刷新等多个访问 UI 的时机被调用,去检查当前线程,非主线程的话抛出异常。


而 ViewRootImpl 的创建在 onResume() 之后,也就是说如果在 onResume() 执行前启动线程访问 UI 的话是不会报错的,这点需要留意!


彩蛋:onCreate() 里子线程更新 UI 有问题吗?为什么?


不会。


因为异常的检测处理在 ViewRootImpl 中,该实例的创建和检测在 onResume() 之后进行。


结语


能力和精力有限,如果出现遗漏、错误或细节不明的地方,欢迎不吝赐教。


让我们共同维护这些个问题,彻底吃透 Handler 机制!


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

【知识点】OkHttp 原理 8 连问

前言 OkHttp可以说是Android开发中最常见的网络请求框架,OkHttp使用方便,扩展性强,功能强大,OKHttp源码与原理也是面试中的常客 但是OKHttp的源码内容比较多,想要学习它的源码往往千头万绪,一时抓不住重点. 本文从几个问题出发梳理OKH...
继续阅读 »

前言


OkHttp可以说是Android开发中最常见的网络请求框架,OkHttp使用方便,扩展性强,功能强大,OKHttp源码与原理也是面试中的常客

但是OKHttp的源码内容比较多,想要学习它的源码往往千头万绪,一时抓不住重点.

本文从几个问题出发梳理OKHttp相关知识点,以便快速构建OKHttp知识体系,如果对你有用,欢迎点赞~


本文主要包括以下内容



  1. OKHttp请求的整体流程是怎样的?

  2. OKHttp分发器是怎样工作的?

  3. OKHttp拦截器是如何工作的?

  4. 应用拦截器和网络拦截器有什么区别?

  5. OKHttp如何复用TCP连接?

  6. OKHttp空闲连接如何清除?

  7. OKHttp有哪些优点?

  8. OKHttp框架中用到了哪些设计模式?


1. OKHttp请求整体流程介绍


首先来看一个最简单的Http请求是如何发送的。


   val okHttpClient = OkHttpClient()
val request: Request = Request.Builder()
.url("https://www.google.com/")
.build()

okHttpClient.newCall(request).enqueue(object :Callback{
override fun onFailure(call: Call, e: IOException) {
}

override fun onResponse(call: Call, response: Response) {
}
})

这段代码看起来比较简单,OkHttp请求过程中最少只需要接触OkHttpClientRequestCallResponse,但是框架内部会进行大量的逻辑处理。

所有网络请求的逻辑大部分集中在拦截器中,但是在进入拦截器之前还需要依靠分发器来调配请求任务。

关于分发器与拦截器,我们在这里先简单介绍下,后续会有更加详细的讲解



  • 分发器:内部维护队列与线程池,完成请求调配;

  • 拦截器:五大默认拦截器完成整个请求过程。




整个网络请求过程大致如上所示



  1. 通过建造者模式构建OKHttpClientRequest

  2. OKHttpClient通过newCall发起一个新的请求

  3. 通过分发器维护请求队列与线程池,完成请求调配

  4. 通过五大默认拦截器完成请求重试,缓存处理,建立连接等一系列操作

  5. 得到网络请求结果


2. OKHttp分发器是怎样工作的?


分发器的主要作用是维护请求队列与线程池,比如我们有100个异步请求,肯定不能把它们同时请求,而是应该把它们排队分个类,分为正在请求中的列表和正在等待的列表,
等请求完成后,即可从等待中的列表中取出等待的请求,从而完成所有的请求


而这里同步请求各异步请求又略有不同


同步请求


synchronized void executed(RealCall call) {
runningSyncCalls.add(call);
}

因为同步请求不需要线程池,也不存在任何限制。所以分发器仅做一下记录。后续按照加入队列的顺序同步请求即可


异步请求


synchronized void enqueue(AsyncCall call) {
//请求数最大不超过64,同一Host请求不能超过5个
if (runningAsyncCalls.size() < maxRequests && runningCallsForHost(call) < maxRequestsPerHost) {
runningAsyncCalls.add(call);
executorService().execute(call);
} else {
readyAsyncCalls.add(call);
}
}

当正在执行的任务未超过最大限制64,同时同一Host的请求不超过5个,则会添加到正在执行队列,同时提交给线程池。否则先加入等待队列。

每个任务完成后,都会调用分发器的finished方法,这里面会取出等待队列中的任务继续执行


3. OKHttp拦截器是怎样工作的?


经过上面分发器的任务分发,下面就要利用拦截器开始一系列配置了


# RealCall
override fun execute(): Response {
try {
client.dispatcher.executed(this)
return getResponseWithInterceptorChain()
} finally {
client.dispatcher.finished(this)
}
}

我们再来看下RealCallexecute方法,可以看出,最后返回了getResponseWithInterceptorChain,责任链的构建与处理其实就是在这个方法里面


internal fun getResponseWithInterceptorChain(): Response {
// Build a full stack of interceptors.
val interceptors = mutableListOf<Interceptor>()
interceptors += client.interceptors
interceptors += RetryAndFollowUpInterceptor(client)
interceptors += BridgeInterceptor(client.cookieJar)
interceptors += CacheInterceptor(client.cache)
interceptors += ConnectInterceptor
if (!forWebSocket) {
interceptors += client.networkInterceptors
}
interceptors += CallServerInterceptor(forWebSocket)

val chain = RealInterceptorChain(
call = this,interceptors = interceptors,index = 0
)
val response = chain.proceed(originalRequest)
}

如上所示,构建了一个OkHttp拦截器的责任链

责任链,顾名思义,就是用来处理相关事务责任的一条执行链,执行链上有多个节点,每个节点都有机会(条件匹配)处理请求事务,如果某个节点处理完了就可以根据实际业务需求传递给下一个节点继续处理或者返回处理完毕。

如上所示责任链添加的顺序及作用如下表所示:







































拦截器作用
应用拦截器拿到的是原始请求,可以添加一些自定义header、通用参数、参数加密、网关接入等等。
RetryAndFollowUpInterceptor处理错误重试和重定向
BridgeInterceptor应用层和网络层的桥接拦截器,主要工作是为请求添加cookie、添加固定的header,比如Host、Content-Length、Content-Type、User-Agent等等,然后保存响应结果的cookie,如果响应使用gzip压缩过,则还需要进行解压。
CacheInterceptor缓存拦截器,如果命中缓存则不会发起网络请求。
ConnectInterceptor连接拦截器,内部会维护一个连接池,负责连接复用、创建连接(三次握手等等)、释放连接以及创建连接上的socket流。
networkInterceptors(网络拦截器)用户自定义拦截器,通常用于监控网络层的数据传输。
CallServerInterceptor请求拦截器,在前置准备工作完成后,真正发起了网络请求。

我们的网络请求就是这样经过责任链一级一级的递推下去,最终会执行到CallServerInterceptorintercept方法,此方法会将网络响应的结果封装成一个Response对象并return。之后沿着责任链一级一级的回溯,最终就回到getResponseWithInterceptorChain方法的返回,如下图所示:


4. 应用拦截器和网络拦截器有什么区别?


从整个责任链路来看,应用拦截器是最先执行的拦截器,也就是用户自己设置request属性后的原始请求,而网络拦截器位于ConnectInterceptorCallServerInterceptor之间,此时网络链路已经准备好,只等待发送请求数据。它们主要有以下区别



  1. 首先,应用拦截器在RetryAndFollowUpInterceptorCacheInterceptor之前,所以一旦发生错误重试或者网络重定向,网络拦截器可能执行多次,因为相当于进行了二次请求,但是应用拦截器永远只会触发一次。另外如果在CacheInterceptor中命中了缓存就不需要走网络请求了,因此会存在短路网络拦截器的情况。

  2. 其次,除了CallServerInterceptor之外,每个拦截器都应该至少调用一次realChain.proceed方法。实际上在应用拦截器这层可以多次调用proceed方法(本地异常重试)或者不调用proceed方法(中断),但是网络拦截器这层连接已经准备好,可且仅可调用一次proceed方法。

  3. 最后,从使用场景看,应用拦截器因为只会调用一次,通常用于统计客户端的网络请求发起情况;而网络拦截器一次调用代表了一定会发起一次网络通信,因此通常可用于统计网络链路上传输的数据。


5. OKHttp如何复用TCP连接?


ConnectInterceptor的主要工作就是负责建立TCP连接,建立TCP连接需要经历三次握手四次挥手等操作,如果每个HTTP请求都要新建一个TCP消耗资源比较多

Http1.1已经支持keep-alive,即多个Http请求复用一个TCP连接,OKHttp也做了相应的优化,下面我们来看下OKHttp是怎么复用TCP连接的


ConnectInterceptor中查找连接的代码会最终会调用到ExchangeFinder.findConnection方法,具体如下:


# ExchangeFinder
//为承载新的数据流 寻找 连接。寻找顺序是 已分配的连接、连接池、新建连接
private RealConnection findConnection(int connectTimeout, int readTimeout, int writeTimeout,
int pingIntervalMillis, boolean connectionRetryEnabled) throws IOException {
synchronized (connectionPool) {
// 1.尝试使用 已给数据流分配的连接.(例如重定向请求时,可以复用上次请求的连接)
releasedConnection = transmitter.connection;
result = transmitter.connection;

if (result == null) {
// 2. 没有已分配的可用连接,就尝试从连接池获取。(连接池稍后详细讲解)
if (connectionPool.transmitterAcquirePooledConnection(address, transmitter, null, false)) {
result = transmitter.connection;
}
}
}

synchronized (connectionPool) {
if (newRouteSelection) {
//3. 现在有了IP地址,再次尝试从连接池获取。可能会因为连接合并而匹配。(这里传入了routes,上面的传的null)
routes = routeSelection.getAll();
if (connectionPool.transmitterAcquirePooledConnection(address, transmitter, routes, false)) {
foundPooledConnection = true;
result = transmitter.connection;
}
}

// 4.第二次没成功,就把新建的连接,进行TCP + TLS 握手,与服务端建立连接. 是阻塞操作
result.connect(connectTimeout, readTimeout, writeTimeout, pingIntervalMillis,
connectionRetryEnabled, call, eventListener);

synchronized (connectionPool) {
// 5. 最后一次尝试从连接池获取,注意最后一个参数为true,即要求 多路复用(http2.0)
//意思是,如果本次是http2.0,那么为了保证 多路复用性,(因为上面的握手操作不是线程安全)会再次确认连接池中此时是否已有同样连接
if (connectionPool.transmitterAcquirePooledConnection(address, transmitter, routes, true)) {
// 如果获取到,就关闭我们创建里的连接,返回获取的连接
result = transmitter.connection;
} else {
//最后一次尝试也没有的话,就把刚刚新建的连接存入连接池
connectionPool.put(result);
}
}

return result;
}

上面精简了部分代码,可以看出,连接拦截器使用了5种方法查找连接



  1. 首先会尝试使用 已给请求分配的连接。(已分配连接的情况例如重定向时的再次请求,说明上次已经有了连接)

  2. 若没有 已分配的可用连接,就尝试从连接池中 匹配获取。因为此时没有路由信息,所以匹配条件:address一致——hostport、代理等一致,且匹配的连接可以接受新的请求。

  3. 若从连接池没有获取到,则传入routes再次尝试获取,这主要是针对Http2.0的一个操作,Http2.0可以复用square.comsquare.ca的连接

  4. 若第二次也没有获取到,就创建RealConnection实例,进行TCP + TLS握手,与服务端建立连接。

  5. 此时为了确保Http2.0连接的多路复用性,会第三次从连接池匹配。因为新建立的连接的握手过程是非线程安全的,所以此时可能连接池新存入了相同的连接。

  6. 第三次若匹配到,就使用已有连接,释放刚刚新建的连接;若未匹配到,则把新连接存入连接池并返回。


以上就是连接拦截器尝试复用连接的操作,流程图如下:


6. OKHttp空闲连接如何清除?


上面说到我们会建立一个TCP连接池,但如果没有任务了,空闲的连接也应该及时清除,OKHttp是如何做到的呢?


  # RealConnectionPool
private val cleanupQueue: TaskQueue = taskRunner.newQueue()
private val cleanupTask = object : Task("$okHttpName ConnectionPool") {
override fun runOnce(): Long = cleanup(System.nanoTime())
}

long cleanup(long now) {
int inUseConnectionCount = 0;//正在使用的连接数
int idleConnectionCount = 0;//空闲连接数
RealConnection longestIdleConnection = null;//空闲时间最长的连接
long longestIdleDurationNs = Long.MIN_VALUE;//最长的空闲时间

//遍历连接:找到待清理的连接, 找到下一次要清理的时间(还未到最大空闲时间)
synchronized (this) {
for (Iterator<RealConnection> i = connections.iterator(); i.hasNext(); ) {
RealConnection connection = i.next();

//若连接正在使用,continue,正在使用连接数+1
if (pruneAndGetAllocationCount(connection, now) > 0) {
inUseConnectionCount++;
continue;
}
//空闲连接数+1
idleConnectionCount++;

// 赋值最长的空闲时间和对应连接
long idleDurationNs = now - connection.idleAtNanos;
if (idleDurationNs > longestIdleDurationNs) {
longestIdleDurationNs = idleDurationNs;
longestIdleConnection = connection;
}
}
//若最长的空闲时间大于5分钟 或 空闲数 大于5,就移除并关闭这个连接
if (longestIdleDurationNs >= this.keepAliveDurationNs
|| idleConnectionCount > this.maxIdleConnections) {
connections.remove(longestIdleConnection);
} else if (idleConnectionCount > 0) {
// else,就返回 还剩多久到达5分钟,然后wait这个时间再来清理
return keepAliveDurationNs - longestIdleDurationNs;
} else if (inUseConnectionCount > 0) {
//连接没有空闲的,就5分钟后再尝试清理.
return keepAliveDurationNs;
} else {
// 没有连接,不清理
cleanupRunning = false;
return -1;
}
}
//关闭移除的连接
closeQuietly(longestIdleConnection.socket());

//关闭移除后 立刻 进行下一次的 尝试清理
return 0;
}

思路还是很清晰的:



  1. 在将连接加入连接池时就会启动定时任务

  2. 有空闲连接的话,如果最长的空闲时间大于5分钟 或 空闲数 大于5,就移除关闭这个最长空闲连接;如果 空闲数 不大于5 且 最长的空闲时间不大于5分钟,就返回到5分钟的剩余时间,然后等待这个时间再来清理。

  3. 没有空闲连接就等5分钟后再尝试清理。

  4. 没有连接不清理。


流程如下图所示:


7. OKHttp有哪些优点?



  1. 使用简单,在设计时使用了外观模式,将整个系统的复杂性给隐藏起来,将子系统接口通过一个客户端OkHttpClient统一暴露出来。

  2. 扩展性强,可以通过自定义应用拦截器与网络拦截器,完成用户各种自定义的需求

  3. 功能强大,支持SpdyHttp1.XHttp2、以及WebSocket等多种协议

  4. 通过连接池复用底层TCP(Socket),减少请求延时

  5. 无缝的支持GZIP减少数据流量

  6. 支持数据缓存,减少重复的网络请求

  7. 支持请求失败自动重试主机的其他ip,自动重定向


8. OKHttp框架中用到了哪些设计模式?



  1. 构建者模式:OkHttpClientRequest的构建都用到了构建者模式

  2. 外观模式: OkHttp使用了外观模式,将整个系统的复杂性给隐藏起来,将子系统接口通过一个客户端OkHttpClient统一暴露出来。

  3. 责任链模式: OKHttp的核心就是责任链模式,通过5个默认拦截器构成的责任链完成请求的配置

  4. 享元模式: 享元模式的核心即池中复用,OKHttp复用TCP连接时用到了连接池,同时在异步请求中也用到了线程池


总结


本文主要梳理了OKHttp原理相关知识点,并回答了以下问题:



  1. OKHttp请求的整体流程是怎样的?

  2. OKHttp分发器是怎样工作的?

  3. OKHttp拦截器是如何工作的?

  4. 应用拦截器和网络拦截器有什么区别?

  5. OKHttp如何复用TCP连接?

  6. OKHttp空闲连接如何清除?

  7. OKHttp有哪些优点?

  8. OKHttp框架中用到了哪些设计模式?

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

Android中Window 和 WindowManager

Window 是一个抽象类,具体实现是 PhoneWindow,通过 WindowManager 创建。WindowManager是外界访问Window的入口,Window 的具体实现位于 WindowManagerService 中WindowManager...
继续阅读 »

Window 是一个抽象类,具体实现是 PhoneWindow,通过 WindowManager 创建。

WindowManager是外界访问Window的入口,Window 的具体实现位于 WindowManagerService 中

WindowManager 和 WindowManagerService 的交互是一个 IPC 的过程

Andorid 中所有的视图都是通过 Window 来呈现的

不管是 Activity 、Dialog 还是 Taost

因此, Window 实际是 View 的直接管理者 !!!

1. Window 和 WindowManager

WindowManager 添加 Window 的过程

将一个 Button 添加到屏幕坐标为(100,,300)的位置上。其中,Flags 和 type 这两个参数比较重要。

mFloatingButton = new Button(this);
mFloatingButton.setText("button");
mLayoutParams = new WindowManager.LayoutParams(
LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT, 0 ,0 , PixelFormat.TRANSPARENT);
mLayoutParams.flags = LayoutParams.FLAG_NOT_TOUCH_MODAL
   | LayoutParams.FLAG_NOT_FOCUSABLE
   | LayoutParams.FLAG_SHOW_WHEN_LOCKED
   
mLayoutParams.gravity = Gravity.LEFT | Gravity.TOP;
mLayoutParams.x = 100;
mLayoutParams.y =300;
mWindowManager.addView (mFloatignButton, mLayoutParams);

Flags 参数表示 Window 的属性

  • FLAG_NOT_FOCUSABLE

    表示Window 不需要获取焦点,也不需要接收各种输入事件,此标记会同时启用 FLAG_NOT_TOUCH_MODAL ,最终事件会直接传递给下层的具体焦点的 Window

  • FLAG_NOT_TOUCH_MODAL

    在此模式下,系统会将当前 Window 区域以外的单击事件传递给底层的 Window ,当前 Window 区域以内的单击事件则自己处理。这个标记很重要,一般来说都需要开启此标记,否则其他 Window 将无法收到单击事件。

  • FLAG_SHOW_WHEN_LOCKED

    开启此模式可以让 Window 显示在锁屏的界面上。

Type 参数表示 Window 的类型, 应用 Window 、子 Window 和 系统 Window

  • 应用 Window 对应着一个 Activity , 层级 1-99
  • 子Window 不能单独存在,需要依附在特定的父Window之中 , 层级 1000-1999
  • 系统Window 是需要声明权限在能创建的 Window, 比如 Toast 和 系统状态栏这些都是系统 Window , 2000-2999

    一般选用 TYPE_SYSTEM_OVERLAY 或者 TYPE_SYSTEM_ERROR

注: Window 是分层的,每个Window 都有对应的 z-ordered , 层级大的会覆盖在层级小的 Window 的上面。

WindowManager 所提供的功能很简单,常用方只有三个方法:

  • 添加 View
  • 更新 View
  • 删除View

这三个方法定义在 ViewManager 中,WindowManager 继承了 ViewManager

public interface ViewManager
{
public void addView(View view , ViewGroup.LayoutParams params);
public void updateViewLayout(View view, ViewGroup.LayoutParams params);
public void removeView(View view);
}

拖动 Window 的效果

根据手指的位置来设定 LayoutParams 中 的 x 和 y 的值即可改变 Window 的位置。

首先给 View 设置 onTouchListener : mFloatingButton.setOnTouchListener(this)

然后在onTouch 方法中不断更新View 的位置即可。

public boolean onTouch(View v, MotionEvent envet){
int rawX = (int) event.getRawX();
int rawY = (int) event.getRwaY();
switch(event.getAction()){
case MotionEvent.ACTION_MOVE:{
mLayoutParams.x = rawX;
mLayoutParams.y = rawY;
mWindowManager.updateViewLayout(mFloatingButton, mLayoutParams);
break;
}
default:
break;
}
return false;
}

2. Window 的内部机制

Window 是一个抽象的概念,每一个Window 都对应着一个 View 和一个 ViewRootImpl 。

Window 和 View 通过 ViewRootImpl 来建立联系,因此Window 并不是实际存在的,它是以View 的形式存在。

View 才是 Window 存在的实体。

1. Window 的添加过程

Window 的添加过程需要通过 WindowManager 的 addView 来实现, WindowManager 是一个接口,它的真正实现是 WindowManagerImpl 类。


@Override
public void addView(View view, ViewGroup.LayoutParams params){
   mGlobal.addView(view, params , mDisplay, mParentWindow);
}

@Override
public void updateViewLayout(View view , ViewGroup.LayoutParams params){
   mGlobal.updateViewLayout(view ,params);
}

@Override
public void removeView(View view){
   mGlobal.removeView(view, false);
}

交给 WindowManagerGlobal 来处理

WindowManagerGlobal 以工厂的形式向外提供自己的实例。

WindowManagerGlobal 的 addView 方法主要分为如下步

  1. 检查参数是否合法,如果是子 Window 那么还需要调整一些布局参数

    if (view == null){
    throw new IllegalArgumentException("view must not be null");
    }
    if (display == null){
       throw new IllegalArgumentException("display must be null");
    }
    if(!(params instanceof WindowManager.LayoutParams)){
       throw new IllegalArgumentException("Params must be WindowManager.LayoutParams");
    }

    final WindowManager.LayoutParams wparams = (WindowManager.LayoutParams)params;
    if (parentWindow != null){
       parentWindow.adjustLayoutParamsForSubWindow(wparams);
    }
  2. 创建 ViewRootImpl 并将 View 添加到列表中

在 WindowManagerGlobal 内部有如下几个列表比较重要:

private final ArrayList<Viwe>mViews = new ArrayList<View>();
private final ArrayList<ViewRootImpl> mRoots = new ArrayList<ViewRootImpl>();
private final ArrayList<WindowManager.LayoutParams> mParams = new ArrayList<WindowManager.LayoutParams>();
private final ArraySet<View> mDyingVies = new ArraySet<View>();
  • mViews 存储的是所有 Window 所对应的 View
  • mRoots 存储的是所有 Window 所对应的 ViewRootImpl
  • mParams 存储的是所有 Window 所对应的布局参数
  • mDyingViews 则存储了那些正在被删除的 View 对象。或者是那些已经调用 removeView 方法但是删除操作还未完成的 Window 对象。

在 addView 中通过如下方式将 Window 的一系列对象添加到列表中

root = new ViewRootImpl(view.getContext(), display);
view.setLayoutParams(wparams);

mViews.add(view);
mRoot.add(root);
mParams.add(wparams);
  1. 通过 ViewRootImpl 来更新界面并完成 Window 的添加过程

    由ViewRootImpl 的 setView 方法来完成。View 的绘制过程是由 ViewRootImpl 来完成的。

    setView 内部通过 requestLayout 来完成异步刷新请求。

public void requestLayout(){
if (!mHandlingLayoutInLayoutRequest){
checkThread();
mLayoutRequested = true;
// 实际是VIEW 绘制的入口
scheduleTraversals();
}
}

接着会通过 WindowSession 最终来完成 Window 的添加过程。

在下面的代码中 mWindowSession 的类型是 IWindowSession , 它是一个 Binder 对象,真正实现类是 Session, 也就是 Window 的添加过程是一次IPC 调用

try{
   mOrigWindowType = mWindowAttributes.type;
   mAttachInfo.mRecomputeGlobalAttributes = true;
   collectViewAttributes();
   res =  mWindsSeesion.addToDisplay(mWindow, mSeq, mWindowAttributes, getHostVisibility(), mDisplay.getDisplayId(),mAttachInfo.mContentInsets, mInputChannel);
}catch (RemoteException e){
   mAdded = false;
   mView = null;
   mAttachInfo.mRootView = null;
   mInputChannel = null;
   mFallbackEventHandler.setView(null);
   unscheduleTraversals();
   setAccessibilityFocus(null, null);
   throw new RuntimeException("Adding window failed", e);
}

在 Session 内部会通过 WindowManagerService 来实现 Window 的添加

public int addToDisplay(IWindow window, int seq, WindowManager.LayoutParams attrs,
                      int viewVisibility, int displayId, Rect outCotentInsets,
                      InputChannel outInputChannel){
   return mService.addWindow(this, window, seq, attrs, viewVisibility, disalayId, outContentInsets, outInputChannel);
}

如此一来,Window 的添加请求就给 WindowManagerService 去处理了。在WindowManagerService 内部会为每一个应用保留一个单独的 Session.

2. Window 的删除过程

先通过 WindowManagerImpl后,再进一步通过 WindowManagerGlobal 来实现的。

public void removeView(View view , boolean immediate){
if (view == null){
throw new IllegalArgumentException("view must not be null");
}

synchronized(mLock){
       // 先通过 findViewLocked 来查找待删除的 View 的索引,建立数组遍历
int index = findViewLocked(view, true);
View curView = mRoots.get(index).getView();
       // 调用removeViewLocked 来做进一步的删除
removeViewLocked(index, immediate);
if (curView == view){
return;
}
throw new IllegalStateExceotion("Calling with view " + view + "but the
ViewAncestor is attached to " + curView);
}
}
private void removeViewLocked(int index, boolean immediate){
   ViewRootImpl root = mRoots.get(index);
   View view = root.getView();
   
   if (view != null){
       InputMethodManager imm = InputMethodManager.getInstance();
       if ( imm != null){
           imm.windowDismissed(mViews.get(index).getWindowToken());
      }
  }
   boolean deferred = root.die(immediate);
   if(view != null){
       view.assignParent(null);
       if (deferred){
           mDyingViews.add(view);
      }
  }
}

removeViewLocked 是通过 ViewRootImpl 来完成删除操作。

WindowManager 中提供了两种删除接口 removeView 和 removeImmdiate

分别代表 异步删除和 同步删除

3. Window 的更新过程

由WindowManagerGlobal 的 updateViewLayout 方法实现。

4. Window 的创建过程

Activity 的 Window 是通过 PolicyManager 的一个工厂方法来创建的。

Activity 的视图是如何附属在 Window 上,由于Activity 的视图由setContentView 方法提供。

public void setContentView(int layoutResID){
getWindow().setContentView(layoutResID);
initWindowDecorActionBar();
}

这里可以看出,Activity 将具体实现交给了 Window 处理,而Window 的具体实现是 PhoneWindow .

这里主要看 PhoneWindow

  1. 如果没有 DecorCView ,那么就创建它

    DecorView 是一个 FrameLayout , 是Activity 中的顶级 View , 一般来说它的内部包含标题栏和内部栏。

    不管怎样,内容栏是一定要存在的,并且内容来具体固定的 id ,那就是 “ content" .完整的 id 是 android.R.content.

    DecorView 的创建过程由 installDecor 方法来完成,在方法内部会通过 generateDecor 来直接创建 DecorView ,这时其还是一个空白的FrameLayout:

    protected DecorView generateDecor(){
       return new DecorView(getContext(), -1);
    }

    为了初始化 DecorView 的结构,PhoneWindow 还需要通过 generateLayout 方法来加载具体的布局文件到 DecorView 中。

    View in = mLayoutInflater.inflate(layoutResource, null);
    decor.addView(in , new ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT));
    mContentRoot = (ViewGroup) in;
    ViewGroup contentParent = (ViewGroup)findViewById(ID_ANDROID_CONTENT);
// 该id 对应的ViewGroup 就是 mContentParent
public static final inT ID_ANDROID_CONTENT = com.android.internal.R.id.content
  1. 将View 添加到 DecorView 的 mContentParent 中
// 直接将 Activity 的视图添加到 Decorview 的 mContentParent 中
mLayoutInflater.inflate(layoutResID, mContentParent)
   
// Activity 的布局文件被添加到 DecorView 的 mContentParent中, setContentView
  1. 回调Activity 的 onContentChanged 方法通知 Activity 视图已经改变
final Callback cb =  getCallback();
if (cb != null && !isDestroyed()){
cb.onContentChanged();
}

在 makeVisible 方法中,DecorView 真正地完成了添加和显示这两个过程,此时 Activity 的视图才能被用户看到。

void makeVisible(){
if (!mWindowAdded){
ViewManager wm = getWindiowManager();
wm.addView(mDecor, getWindow().getAttributes());
mWindowAdded = true;
}
mDecor.setVisihility(View.VISIBLE);
}


在 makeVisible 方法中,DecorView 真正地完成了添加和显示这两个过程,此时 Activity 的视图才能被用户看到。

void makeVisible(){
if (!mWindowAdded){
ViewManager wm = getWindiowManager();
wm.addView(mDecor, getWindow().getAttributes());
mWindowAdded = true;
}
mDecor.setVisihility(View.VISIBLE);
}
收起阅读 »

一篇文章带你走近Android自定义view

前言从专科到本科,目前本科大四,已经是学习Android的第四个年头了,本打算积累一下冲23考研,但是最近被大佬洗脑后准备冲一冲22的考研,所以后续出文章的几率会很小,但是在前不久答应粉丝整理一个较为详细的Android自定义view教程,恰巧最近报名被华为选...
继续阅读 »

前言

从专科到本科,目前本科大四,已经是学习Android的第四个年头了,本打算积累一下冲23考研,但是最近被大佬洗脑后准备冲一冲22的考研,所以后续出文章的几率会很小,但是在前不久答应粉丝整理一个较为详细的Android自定义view教程,恰巧最近报名被华为选入2021年鸿蒙公开课的学生代表之一,在学校为请假条奔波的路上,所以抽出一下午写一篇文章。(有点小感冒,如发现错误请见谅,感谢指正!!!)。


下文为正文内容,所有链接案例注解都比较详细

一、为什么要自定义view

随着各大产品经理的内卷,Android系统内置的View早已无法满足我们的需求,我们需要针对自己的业务来定制我们需要的view,以达到更好的用户体验感,从而增加用户的黏性。

二、先看看一个超级简单的自定义view(三个构造函数)

需求:一个界面两个跑马灯(在xml中实现) 出现的问题:Textview在xml文件中实现跑马灯,如果有两个跑马灯,则会出现抢焦点的现象,只会跑一个。 解决方式:自定义一个Textview,设置其自动获得焦点: isFocused();

public class MyTextView extends TextView {
//在用代码创建的时候调用
public MyTextView(Context context) {
this(context, null);
}

//在识别XML的时候会调用此方法创建Textview,底层会用反射去AttribestSet去取属性值
public MyTextView(Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}

//给第一个构造函数和第二个使用
public MyTextView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}

//解决一个问题,需要Textview天生获取焦点
@Override
public boolean isFocused() {
return true;
}
}

从以上代码中,本人已将函数的作用写入到备注中。

三、了解手机的坐标系

4bdd208ec73144d1be51e84291c3651f_tplv-k3u1fbpfcp-watermark.webp

具体案例文章:Android用Canvas画一个真正能跑的跑马灯

四、使用Canvas画一个折线图(重写onDraw()方法)

此文章案例主要为canvas.drawLine(),drawText()的简单使用。

具体案例文章:Android用Canvas画一个折线图,并加以简单封装

五、如何自定义属性,且在view中获取到属性的值(小提,在六中会有案例)

以颜色为例。

//attrs文件
<attr name="leftcolor" format="reference|color"/>
<attr name="rightcolor" format="reference|color"/>
//java文件 ---TaiJiView为自定义view名称
//获取自定义属性。
TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.TaiJiView);
//获取颜色
int leftcolor = ta.getColor(R.styleable.TaiJiView_leftcolor, Color.BLACK);
int rightcolor=ta.getColor(R.styleable.TaiJiView_rightcolor, Color.WHITE);
//回收
ta.recycle();
//布局中
app:leftcolor="@color/colorPrimary"
app:rightcolor="#ff0000"

六、绘制图案以及加入设置简单的动画(案例讲解很详细)

canvas.drawCircle ,旋转动画

具体案例文章:Android自定义view之太极图

七、自定义view的实现分类以及自定义组合控件的案例

  • 自定义组合控件:将多个控件组合成为一个新的控件。(本案例)
  • 继承系统控件:如标题二的案例
  • 继承View:如标题六的案例
  • 继承ViewGroup:继承自LinearLayout等系统控件,在系统控件的基础功能上进行扩展。

具体案例文章:Android自定义view之模仿登录界面文本输入框(华为云APP)

八、简单测量以及自定义接口实例来控制动画的更新计算表达式(onMeasure,TypeEvaluator)

项目源码贴在链接文章末尾

具体案例文章:Android自定义view之围棋动画

九 、通过改变变量的值达到动画效果

Android自定义view之利用drawArc方法实现动态效果

Android自定义view之围棋动画(化繁为简)

Android自定义view之利用PathEffect实现动态效果

Android自定义view之线条等待动画(灵感来源:金铲铲之战)

小提:把绘制点移动到中间。代码看起来会简洁点

十、当界面更新频繁(SurfaceView)

讲讲Android为自定义view提供的SurfaceView

十一、GLSurfaceView(继承自SurfaceView,3D效果)

Android自定义view之3D正方体

如需继续深入还请了解openGL相关内容。

十二 、关于SVG

Android利用SVG实现动画效果 Android SVG动画详细例子

十三 、上一个简单github案例

Android线条等待动画JMWorkProgress(可添加依赖直接使用)

十四 、还没来得及具体写的(关键词)

贝塞尔曲线,事件分发机制。枚举(可在框架中用于确定动画状态)

十五 、两道面试相关八股(根据本人面试大厂整理)

1.View绘制流程

View的绘制是从 ViewRootImpl的 performTraversals()方法开始,从最顶层的 View(ViewGroup)开始逐层对每个 View进行绘制操作 。

View 绘制中主要流程分为measure,layout, draw 三个阶段。

measure :根据父 view 传递的 MeasureSpec 进行计算大小, 自定义View的过程中都会在onMeasure中进行宽高的测量,这个方法会从父布局中接收两个参数 widthMeasureSpac和 heightMeasureSpac,所以子布局的宽高大小需要受限于父布局。

layout :根据 measure 子 View 所得到的布局大小和布局参数,将子View放在合适的位置上, 结合源码可知 layout()会将四个位置参数传递给 setOpticalFrame()或者 setFrame(),而 setOpticalFrame()内部会调用 setFrame(),所以最终通过 setFrame()确定 View在 ViewGroup中的位置。位置确定完毕会调用 onLayout(l,t,r,b)对子View进行摆放。

draw :把 View 对象绘制到屏幕上。

  • Canvas:画布,不管是文字,图形,图片都要通过画布绘制而成
  • Paint:画笔,可设置颜色,粗细,大小,阴影等等等等,一般配合画布使用
  • Path:路径,用于形成一些不规则图形。
  • Matrix:矩阵,可实现对画布的几何变换。

2.View 的事件分发机制

触摸事件的类型

触摸事件对应的是 MotionEvent 类,事件的类型主要有如下三种:

  • ACTION_DOWN
  • ACTION_MOVE(移动的距离超过一定的阈值会被判定为 ACTION_MOVE 操作)
  • ACTION_UP

View 事件分发本质就是对 MotionEvent 事件分发的过程。即当一个 MotionEvent 发生后,系统将这个点击事件传递到一个具体的 View 上。

事件分发流程

事件分发过程由三个方法共同完成:

dispatchTouchEvent: 方法返回值为 true 表示事件被当前视图消费掉;返回为 super.dispatchTouchEvent 表示继续分发该事件,返回为 false 表示交给父类的 onTouchEvent 处理。

onInterceptTouchEvent: 方法返回值为 true 表示拦截这个事件并交由自身的 onTouchEvent 方法进行消费;返回 false 表示不拦截,需要继续传递给子视图。 如果 return super.onInterceptTouchEvent(ev), 事件拦截分两种情况:

  • 1.如果该View存在子View且点击到了该子View, 则不拦截, 继续分发 给 子 View 处理, 此时相当于 return false。

  • 2.如果该 View 没有子 View 或者有子 View 但是没有点击中子 View(此时 ViewGroup 相当于普通 View), 则交由该 View 的 onTouchEvent 响应,此时相当于 return true。

注意:一般的 LinearLayout、 RelativeLayout、FrameLayout 等 ViewGroup 默认不拦截, 而 ScrollView,ListView 等 ViewGroup 则可能拦截,得看具体情况。

onTouchEvent: 方法返回值为 true 表示当前视图可以处理对应的事件;返回值 为 false 表示当前视图不处理这个事件,它会被传递给父视图的 onTouchEvent 方法进行处理。如果 return super.onTouchEvent(ev),事件处理分为两种情况:

  • 1.如果该 View 是 clickable 或者 longclickable 的,则会返回 true, 表示消费 了该事件, 与返回 true 一样;

  • 2.如果该 View 不是 clickable 或者 longclickable 的,则会返回 false, 表示不 消费该事件,将会向上传递,与返回 false 一样。

注意:在 Android 系统中,拥有事件传递处理能力的类有以下三种:

  • Activity:拥有分发和消费两个方法。

  • ViewGroup:拥有分发、拦截和消费三个方法。

  • View:拥有分发、消费两个方法。


收起阅读 »

Retrofit流程极简解析

Retrofit流程极简解析以SandwichDemo为例子来解析。github地址创建Retrofitprivate val retrofit: Retrofit = Retrofit.Builder() .client(okHttpClient) .bas...
继续阅读 »

Retrofit流程极简解析

以SandwichDemo为例子来解析。github地址

创建Retrofit

  • private val retrofit: Retrofit = Retrofit.Builder()
    .client(okHttpClient)
    .baseUrl(
    "https://gist.githubusercontent.com/skydoves/aa3bbbf495b0fa91db8a9e89f34e4873/raw/a1a13d37027e8920412da5f00f6a89c5a3dbfb9a/"
    )
    .addConverterFactory(GsonConverterFactory.create())

    /* asynchronous supports */
    // .addCallAdapterFactory(DataSourceCallAdapterFactory.create())

    /* coroutines supports */
    .addCallAdapterFactory(CoroutinesResponseCallAdapterFactory.create())
    //.addCallAdapterFactory(CoroutinesDataSourceCallAdapterFactory.create())
    .build()

    创建接口类

    val disneyService: DisneyCoroutinesService = retrofit.create(DisneyCoroutinesService::class.java)

    获取接口返回的数据

    val apiResponse:ApiResponse<List<Poster>> = disneyService.fetchDisneyPosterList()

    就是这么简单,数据获取完成


细分流程解析

    1. 创建Retrofit。这里使用了创建者模式,通过Retrofit.Builder来创建Retrfofit实例,一般项目里都会做成单例
    2. Builder().client(OkHttpClient client)设置网络请求的最终调用者,这里和OkHttp是绝配
    3. baseUrl(Url baseUrl)设置baseUrl链接
    4. addConverterFactory(Converter.Factory factory)添加网络参数和返回类的转换器,例如Gson,Moshi
    5. addCallAdapterFactory(CallAdapter.Factory factory)添加接口请求结果的转换器
    6. build()方法中,会通过platform.defaultCallAdapterFactories(callbackExecutor)来添加默认的CallAdapter.Factory转换器和我们自定义的转换器。而ConvertorFactory转换器,默认加入new BuiltInConverters()和平台默认转换器platform.defaultConverterFactories()以及我们自定义的转换器
  • 通过Retrofit创建接口类 1.调用create(Class<T> service)方法来创建对应的接口类

    return (T)
    Proxy.newProxyInstance(
    service.getClassLoader(),
    new Class<?>[] {service},
    new InvocationHandler() {
    private final Platform platform = Platform.get();
    private final Object[] emptyArgs = new Object[0];

    @Override
    public @Nullable Object invoke(Object proxy, Method method, @Nullable Object[] args)
    throws Throwable {
    // If the method is a method from Object then defer to normal invocation.
    if (method.getDeclaringClass() == Object.class) {
    return method.invoke(this, args);
    }
    args = args != null ? args : emptyArgs;
    return platform.isDefaultMethod(method)
    ? platform.invokeDefaultMethod(method, service, proxy, args)
    : loadServiceMethod(method).invoke(args);
    }
    });

    这里就是通过动态代理来。动态代理的理论网上很多,可以自己搜索;简单说下,就是比如代理的接口类,调用它的方法时候,会进入到动态代理类里InvocationHandlerinvoke()中,这里参数有method提供Method的各种方法,args参数提供方法的各个参数。这里就是完全代理了接口方法,来自己实现,这里思想多大,舞台就有多大。

  • invoke方法解析

    1. 解析loadServiceMethod(method).invoke(args)loadServiceMethod()方法返回ServiceMethod抽象类,实际是HttpServiceMethod类。
    2. 核心方法HttpServiceMethod.parseAnnotations方法调用并返回HttpServiceMethod类,这里是核心解析方法;上面的invoke(args)方法最终是调用了HttpServiceMethod类的invoke方法,最终是调用如下:
     @Override
    final @Nullable ReturnT invoke(Object[] args) {
    Call<ResponseT> call = new OkHttpCall<>(requestFactory, args, callFactory, responseConverter);
    return adapt(call, args);
    }

    这里记住这个类OkHttpCall 3. 解析HttpServiceMethod<ResponseT, ReturnT>.parseAnnotations()方法: 这里会通过RequestFactory来解析参数和返回值,其中 java if (Utils.getRawType(parameterType) == Continuation.class) { isKotlinSuspendFunction = true; return null; } 这个解析判断是否是suspend函数。 这里会根据是否挂起函数来确定不同的返回值。 继续:根据是否是挂起函数,来获取对应的adapterType,即类似Call<UserData>里的UserData类型,或者suspendUserData返回值类型。

    CallAdapter<ResponseT, ReturnT> callAdapter =
    createCallAdapter(retrofit, method, adapterType, annotations);
    Type responseType = callAdapter.responseType();

    这里通过返回类型,来匹配我们加入的CallAdapter来进行返回的Response的包装或者逻辑处理

    Converter<ResponseBody, ResponseT> responseConverter =
    createResponseConverter(retrofit, method, responseType);

    这里通过responseType来获取我们添加的返回结果转换器,比如GsonFactory,MothiFactory来 4.

        if (!isKotlinSuspendFunction) {
    return new CallAdapted<>(requestFactory, callFactory, responseConverter, callAdapter);
    } else if (continuationWantsResponse) {
    //noinspection unchecked Kotlin compiler guarantees ReturnT to be Object.
    return (HttpServiceMethod<ResponseT, ReturnT>)
    new SuspendForResponse<>(
    requestFactory,
    callFactory,
    responseConverter,
    (CallAdapter<ResponseT, Call<ResponseT>>) callAdapter);
    } else {
    //noinspection unchecked Kotlin compiler guarantees ReturnT to be Object.
    return (HttpServiceMethod<ResponseT, ReturnT>)
    new SuspendForBody<>(
    requestFactory,
    callFactory,
    responseConverter,
    (CallAdapter<ResponseT, Call<ResponseT>>) callAdapter,
    continuationBodyNullable);
    }

    这里会返回最终的各式各样的HttpMethod的实现类 如果非suspend函数,则直接返回CallAdapter这里,Java代码非协程一般都是这种情况; 如果是suspend函数且返回值为Response类型的,则返回SuspendForResponse 其余的suspend函数情况,则返回SuspendForBodykotlin+协程里一般是这种情况 5. 分析CallAdapterSuspendForBody的区别,最大区别,就是Suspend会再adapt里自动调用OkHttp的请求接口方法并返回对应的Response,而CallAdapter则不会,而是需要使用者自己去调用。

    至此,简略版的Retrofit流程已经梳理完毕

    我们自己可以自定义的部分:ConverterFactoryCallFactory这里官方都给了默认的和常用的,例如Converter转换类就有gson,guava,jackson,moshi,jaxb....;而默认的CallFactory,除了库里自带的默认的DefualtCallFactory,还有官方写的库:guava,java8,rxjava,rxjava2,rxjava3,scala,这里常用的是rxjava2,rxjava3,还有例如我现在用的Sandwich库里封装的CoroutinesResponseCallAdapterFactorykotlin协程配合起来非常好用

收起阅读 »

FLutter即时通讯

1. 即时通讯简述 即时通讯是端开发工作中常见的需求,本篇文章以作者工作中使用FLutter开发社交软件即时通讯需求为背景,描述一下即时通讯功能设计的要点。 2. 重要概念 即时通讯需要前后端配合,约定消息格式与消息内容。本次IM客户端需求开发使用了公司已有的...
继续阅读 »

1. 即时通讯简述


即时通讯是端开发工作中常见的需求,本篇文章以作者工作中使用FLutter开发社交软件即时通讯需求为背景,描述一下即时通讯功能设计的要点。


2. 重要概念


即时通讯需要前后端配合,约定消息格式与消息内容。本次IM客户端需求开发使用了公司已有的基于Socket.io搭建的后台,下文描述涉及到的一些概念。


2.1 WebSocket协议


WebSocket是一种在单个TCP连接上进行全双工通信的协议。WebSocket协议与传统的HTTP协议的主要区别为,WebSocket协议允许服务端主动向客户端推送数据,而传统的HTTP协议服务器只有在客户端主动请求之后才能向客户端发送数据。在没有WebSocket之前,即时通讯大部分采用长轮询方式。


2.2 Socket.io和WebSocket的区别


Socket.io不是WebSocket,它只是将WebSocket和轮询 (Polling)机制以及其它的实时通信方式封装成了通用的接口,并且在服务端实现了这些实时机制的相应代码。也就是说,WebSocket仅仅是Socket.io实现即时通信的一个子集。因此WebSocket客户端连接不上Socket.io服务端,当然Socket.io客户端也连接不上WebSocket服务端。


2.3 服务端socket消息


理解了服务端socket消息也就理解了服务器端的即时通讯逻辑,服务器发出的socket消息可以分为两种:




  1. 服务器主动发出的消息:


    例如,社交软件中的A用户给B用户发出了消息,服务器在收到A用户的消息后,通过socket链接,将A用户的消息转发给B用户,B用户客户端接收到的消息就属于服务器主动发出的。其他比较常见的场景例如直播软件中,全平台用户都会收到的礼物消息广播。




  2. 服务器在接收到客户端消息后的返回消息:


    例如,长链接心跳机制,客户端向服务器发送ping消息,服务器在成功接受客户端的ping消息后返回的pong消息就属于服务器的返回消息。其他常见的场景如社交软件中A用户给B用户发出了消息,服务器在收到A用户的消息后,给A客户端返回一条消息,供A客户端了解消息的发送状态,判断发送是否成功。大部分场景,服务器在接收到客户端主动发出的消息之后都需要返回一条消息。




3. 客户端实现流程


几个设计客户端即时通讯的重点。


3.1 心跳机制


所谓心跳就是客户端发出ping消息,服务器成功收到后返回pong消息。当客户端一段时间内不在发送ping消息,视为客户端断开,服务器就会主动关闭socket链接。当客户端发送ping消息,服务器一段时间内没有返回pong消息,视为服务器断开,客户端就会启动重连机制。


启动流程


3.2 重连机制


重连机制为客户端重新发起连接,常见的重连条件如下:



  • 客户端发送ping消息,服务器一段时间内没有返回pong。

  • 客户端网络断开。

  • 服务器主动断开连接。

  • 客户端主动连接失败。


当出现极端情况(客户端断网)时,频繁的重连可能会导致资源的浪费,可以设置一段时间内的最大重连次数,当重连超过一定次数时,休眠一段时间。


3.3 消息发送流程



  1. 将消息存储到本地数据库,发送状态设为等待。

  2. 发送socket消息。

  3. 接收到服务器返回的socket消息后,将本地数据库等待状态的消息改为成功。


注意事项:


将消息存储到本地数据库时需要生成一个id存入数据库,同时传给服务器,当收到消息时根据id判断更新本地数据库的哪一条消息。


3.4 消息接收流程



3.5 其他相关



  • 聊天页消息的排序:在查询本地数据库时使用order by按时间排序。

  • 消息列表:也推荐做本地存储,当收到消息的时候需要先判断本地消息列表是否有当前消息用户的对话框,如果没有就先插入,有就更新。消息列表的维护就不展开说了,感兴趣可以看代码。

  • 图片语音消息:将图片和语言先上传到专门的服务器上(各种专门的云存储服务器),sokcet消息和本地存储传递的是云服务器上的URL。

  • 多人聊天(群聊):与单人聊天逻辑基本一致,区别位本地数据库需要添加一个会话ID字段,打开一个群就查询对应会话ID的数据。聊天消息不再是谁发给谁,而是在哪个群聊下。


4. 客户端Flutter代码


把部分代码贴上来,完整项目在作者的github上。


4.1 心跳机制


  heart() {
pingTimer = Timer.periodic(Duration(seconds: 30), (data) {
if (pingWaitTime >= 60) {
socket.connect();
pingWaitTime = 0;
pingWaitTimer!.cancel();
ping();
}
if (!pingWaitFlag) ping();
});
}

ping() {
debugPrint("ping");
String pingData =
'{"type":"ping","payload":{"front":true},"msg_id":${DateTime.now().millisecondsSinceEpoch}}';
socket.emit("message", pingData);
pingWaitFlag = true;
pingWaitTime = 0;
pingWaitTimer = Timer.periodic(Duration(seconds: 1), (data) {
pingWaitTime++;
print(data.hashCode);
if (pingWaitTime % 10 == 0) debugPrint(pingWaitTime.toString());
});
}
//pong
if (socketMessage.type == PONG && socketMessage.code == 1000) {
pingWaitFlag = false;
pingWaitTimer!.cancel();
pingWaitTime = 0;
}

4.2 本地数据库设计


数据库表的设计是比较重要的,理解了数据库设计,读代码也就无压力了。


      //消息表
CREATE TABLE chatDetail (
chat_id TEXT PRIMARY KEY,//主键
from_id TEXT,//发送人
to_id TEXT,//接收人
created_at TEXT,
content TEXT,//消息内容
image TEXT,//UI展示用,用户头像
name TEXT,//UI展示用,用户名
sex TEXT,//UI展示用,用户性别
status TEXT,//消息状态
type INTEGER,//消息类型,图片/文字/语音等
chat_object_id TEXT//聊天对象ID,对当前用户而言的聊天对象,是一系列本地操作的核心
)
//消息列表表
CREATE TABLE chatList (
cov_id TEXT,
unread_count INTEGER,
last_msg_text TEXT,
last_msg_at TEXT,
image TEXT,
name TEXT,
sex TEXT,
chat_object_id TEXT PRIMARY KEY)

5. 总结


无论是Flutter技术,或是IOS/Android/Web。只要掌握了即时通讯的核心开发流程,不同的技术只是API有些变化。API往往看文档就能解决,大前端或是特定平台的工程师还是要掌握核心开发流程,会几种做同样事情的API意义不大。


demo写的比较简单,有问题可以评论。


项目github地址


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

看动画学算法之:平衡二叉搜索树AVL Tree

简介 平衡二叉搜索树是一种特殊的二叉搜索树。为什么会有平衡二叉搜索树呢? 考虑一下二叉搜索树的特殊情况,如果一个二叉搜索树所有的节点都是右节点,那么这个二叉搜索树将会退化成为链表。从而导致搜索的时间复杂度变为O(n),其中n是二叉搜索树的节点个数。 而平衡二叉...
继续阅读 »

简介


平衡二叉搜索树是一种特殊的二叉搜索树。为什么会有平衡二叉搜索树呢?


考虑一下二叉搜索树的特殊情况,如果一个二叉搜索树所有的节点都是右节点,那么这个二叉搜索树将会退化成为链表。从而导致搜索的时间复杂度变为O(n),其中n是二叉搜索树的节点个数。


而平衡二叉搜索树正是为了解决这个问题而产生的,它通过限制树的高度,从而将时间复杂度降低为O(logn)。


AVL的特性


在讨论AVL的特性之前,我们先介绍一个概念叫做平衡因子,平衡因子表示的是左子树和右子树的高度差。


如果平衡因子=0,表示这是一个完全平衡二叉树。


如果平衡因子=1,那么这棵树就是平衡二叉树AVL。


也就是是说AVL的平衡因子不能够大于1。


先看一个AVL的例子:



总结一下,AVL首先是一个二叉搜索树,然后又是一个二叉平衡树。


AVL的构建


有了AVL的特性之后,我们看下AVL是怎么构建的。


public class AVLTree {

//根节点
Node root;

class Node {
int data; //节点的数据
int height; //节点的高度
Node left;
Node right;

public Node(int data) {
this.data = data;
left = right = null;
}
}

同样的,AVL也是由各个节点构成的,每个节点拥有data,left和right几个属性。


因为是二叉平衡树,节点是否平衡还跟节点的高度有关,所以我们还需要定义一个height作为节点的高度。


在来两个辅助的方法,一个是获取给定的节点高度:


//获取给定节点的高度
int height(Node node) {
if (node == null)
return 0;
return node.height;
}

和获取平衡因子:


//获取平衡因子
int getBalance(Node node) {
if (node == null)
return 0;
return height(node.left) - height(node.right);
}

AVL的搜索


AVL的搜索和二叉搜索树的搜索方式是一致的。


先看一个直观的例子,怎么在AVL中搜索到7这个节点:



搜索的基本步骤是:



  1. 从根节点15出发,比较根节点和搜索值的大小

  2. 如果搜索值小于节点值,那么递归搜索左侧树

  3. 如果搜索值大于节点值,那么递归搜索右侧树

  4. 如果节点匹配,则直接返回即可。


相应的java代码如下:


//搜索方法,默认从根节点搜索
public Node search(int data){
return search(root,data);
}

//递归搜索节点
private Node search(Node node, int data)
{
// 如果节点匹配,则返回节点
if (node==null || node.data==data)
return node;

// 节点数据大于要搜索的数据,则继续搜索左边节点
if (node.data > data)
return search(node.left, data);

// 如果节点数据小于要搜素的数据,则继续搜索右边节点
return search(node.right, data);
}

AVL的插入


AVL的插入和BST的插入是一样的,不过插入之后有可能会导致树不再平衡,所以我们需要做一个再平衡的步骤。


看一个直观的动画:



插入的逻辑是这样的:



  1. 从根节点出发,比较节点数据和要插入的数据

  2. 如果要插入的数据小于节点数据,则递归左子树插入

  3. 如果要插入的数据大于节点数据,则递归右子树插入

  4. 如果根节点为空,则插入当前数据作为根节点


插入数据之后,我们需要做再平衡。


再平衡的逻辑是这样的:



  1. 从插入的节点向上找出第一个未平衡的节点,这个节点我们记为z

  2. 对z为根节点的子树进行旋转,得到一个平衡树。


根据以z为根节点的树的不同,我们有四种旋转方式:



  • left-left:



如果是left left的树,那么进行一次右旋就够了。


右旋的步骤是怎么样的呢?



  1. 找到z节点的左节点y

  2. 将y作为旋转后的根节点

  3. z作为y的右节点

  4. y的右节点作为z的左节点

  5. 更新z的高度


相应的代码如下:


Node rightRotate(Node node) {
Node x = node.left;
Node y = x.right;

// 右旋
x.right = node;
node.left = y;

// 更新node和x的高度
node.height = max(height(node.left), height(node.right)) + 1;
x.height = max(height(x.left), height(x.right)) + 1;

// 返回新的x节点
return x;
}


  • right-right:


如果是right-right形式的树,需要经过一次左旋:



左旋的步骤正好和右旋的步骤相反:



  1. 找到z节点的右节点y

  2. 将y作为旋转后的根节点

  3. z作为y的左节点

  4. y的左节点作为z的右节点

  5. 更新z的高度


相应的代码如下:


//左旋
Node leftRotate(Node node) {
Node x = node.right;
Node y = x.left;

//左旋操作
x.left = node;
node.right = y;

// 更新node和x的高度
node.height = max(height(node.left), height(node.right)) + 1;
x.height = max(height(x.left), height(x.right)) + 1;

// 返回新的x节点
return x;
}


  • left-right:



如果是left right的情况,需要先进行一次左旋将树转变成left left格式,然后再进行一次右旋,得到最终结果。



  • right-left:



如果是right left格式,需要先进行一次右旋,转换成为right right格式,然后再进行一次左旋即可。


现在问题来了,怎么判断一个树到底是哪种格式呢?我们可以通过获取平衡因子和新插入的数据比较来判断:




  1. 如果balance>1,那么我们在Left Left或者left Right的情况,这时候我们需要比较新插入的data和node.left.data的大小


    如果data < node.left.data,表示是left left的情况,只需要一次右旋即可


    如果data > node.left.data,表示是left right的情况,则需要将node.left进行一次左旋,然后将node进行一次右旋




  2. 如果balance<-1,那么我们在Right Right或者Right Left的情况,这时候我们需要比较新插入的data和node.right.data的大小
    如果data > node.right.data,表示是Right Right的情况,只需要一次左旋即可


    如果data < node.left.data,表示是Right left的情况,则需要将node.right进行一次右旋,然后将node进行一次左旋




插入节点的最终代码如下:


//插入新节点,从root开始
public void insert(int data){
root=insert(root, data);
}

//遍历插入新节点
Node insert(Node node, int data) {

//先按照普通的BST方法插入节点
if (node == null)
return (new Node(data));

if (data < node.data)
node.left = insert(node.left, data);
else if (data > node.data)
node.right = insert(node.right, data);
else
return node;

//更新节点的高度
node.height = max(height(node.left), height(node.right)) + 1;

//判断节点是否平衡
int balance = getBalance(node);

//节点不平衡有四种情况
//1.如果balance>1,那么我们在Left Left或者left Right的情况,这时候我们需要比较新插入的data和node.left.data的大小
//如果data < node.left.data,表示是left left的情况,只需要一次右旋即可
//如果data > node.left.data,表示是left right的情况,则需要将node.left进行一次左旋,然后将node进行一次右旋
//2.如果balance<-1,那么我们在Right Right或者Right Left的情况,这时候我们需要比较新插入的data和node.right.data的大小
//如果data > node.right.data,表示是Right Right的情况,只需要一次左旋即可
//如果data < node.left.data,表示是Right left的情况,则需要将node.right进行一次右旋,然后将node进行一次左旋

//left left
if (balance > 1 && data < node.left.data)
return rightRotate(node);

// Right Right
if (balance < -1 && data > node.right.data)
return leftRotate(node);

// Left Right
if (balance > 1 && data > node.left.data) {
node.left = leftRotate(node.left);
return rightRotate(node);
}

// Right Left
if (balance < -1 && data < node.right.data) {
node.right = rightRotate(node.right);
return leftRotate(node);
}

//返回插入后的节点
return node;
}

AVL的删除


AVL的删除和插入类似。


首先按照普通的BST删除,然后也需要做再平衡。


看一个直观的动画:



删除之后,节点再平衡也有4种情况:




  1. 如果balance>1,那么我们在Left Left或者left Right的情况,这时候我们需要比较左节点的平衡因子


    如果左节点的平衡因子>=0,表示是left left的情况,只需要一次右旋即可


    如果左节点的平衡因<0,表示是left right的情况,则需要将node.left进行一次左旋,然后将node进行一次右旋




  2. 如果balance<-1,那么我们在Right Right或者Right Left的情况,这时候我们需要比较右节点的平衡因子


    如果右节点的平衡因子<=0,表示是Right Right的情况,只需要一次左旋即可


    如果右节点的平衡因子>0,表示是Right left的情况,则需要将node.right进行一次右旋,然后将node进行一次左旋




相应的代码如下:


Node delete(Node node, int data)
{
//Step 1. 普通BST节点删除
// 如果节点为空,直接返回
if (node == null)
return node;

// 如果值小于当前节点,那么继续左节点删除
if (data < node.data)
node.left = delete(node.left, data);

//如果值大于当前节点,那么继续右节点删除
else if (data > node.data)
node.right = delete(node.right, data);

//如果值相同,那么就是要删除的节点
else
{
// 如果是单边节点的情况
if ((node.left == null) || (node.right == null))
{
Node temp = null;
if (temp == node.left)
temp = node.right;
else
temp = node.left;

//没有子节点的情况
if (temp == null)
{
node = null;
}
else // 单边节点的情况
node = temp;
}
else
{ //非单边节点的情况
//拿到右侧节点的最小值
Node temp = minValueNode(node.right);
//将最小值作为当前的节点值
node.data = temp.data;
// 将该值从右侧节点删除
node.right = delete(node.right, temp.data);
}
}

// 如果节点为空,直接返回
if (node == null)
return node;

// step 2: 更新当前节点的高度
node.height = max(height(node.left), height(node.right)) + 1;

// step 3: 获取当前节点的平衡因子
int balance = getBalance(node);

// 如果节点不再平衡,那么有4种情况
//1.如果balance>1,那么我们在Left Left或者left Right的情况,这时候我们需要比较左节点的平衡因子
//如果左节点的平衡因子>=0,表示是left left的情况,只需要一次右旋即可
//如果左节点的平衡因<0,表示是left right的情况,则需要将node.left进行一次左旋,然后将node进行一次右旋
//2.如果balance<-1,那么我们在Right Right或者Right Left的情况,这时候我们需要比较右节点的平衡因子
//如果右节点的平衡因子<=0,表示是Right Right的情况,只需要一次左旋即可
//如果右节点的平衡因子>0,表示是Right left的情况,则需要将node.right进行一次右旋,然后将node进行一次左旋
// Left Left Case
if (balance > 1 && getBalance(node.left) >= 0)
return rightRotate(node);

// Left Right Case
if (balance > 1 && getBalance(node.left) < 0)
{
node.left = leftRotate(node.left);
return rightRotate(node);
}

// Right Right Case
if (balance < -1 && getBalance(node.right) <= 0)
return leftRotate(node);

// Right Left Case
if (balance < -1 && getBalance(node.right) > 0)
{
node.right = rightRotate(node.right);
return leftRotate(node);
}
return node;
}

本文的代码地址:


learn-algorithm


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

密码学系列之:加密货币中的scrypt算法

简介 为了抵御密码破解,科学家们想出了很多种方法,比如对密码进行混淆加盐操作,对密码进行模式变换和组合。但是这些算法逐渐被一些特制的ASIC处理器打败,这些ASIC处理器不做别的,就是专门来破解你的密码或者进行hash运算。 最有名的当然是比特币了,它使用的是...
继续阅读 »

简介


为了抵御密码破解,科学家们想出了很多种方法,比如对密码进行混淆加盐操作,对密码进行模式变换和组合。但是这些算法逐渐被一些特制的ASIC处理器打败,这些ASIC处理器不做别的,就是专门来破解你的密码或者进行hash运算。


最有名的当然是比特币了,它使用的是为人诟病的POW算法,谁的算力高,谁就可以挖矿,这样就导致了大量无意义的矿机的产生,这些矿机什么都不能干,就算是用来算hash值。结果浪费了大量的电力。


普通人更是别想加入这个只有巨头才能拥有的赛道,如果你想用一个普通的PC机来挖矿,那么我估计你挖到矿的几率可能跟被陨石砸中差不多。


为了抵御这种CPU为主的密码加密方式,科学家们发明了很多其他的算法,比如需要占用大量内存的算法,因为内存不像CPU可以疯狂提速,所以限制了很多暴力破解的场景,今天要将的scrypt算法就是其中一种,该算法被应用到很多新的加密货币挖矿体系中,用以表示他们挖矿程序的公平性。


scrypt算法


scrypt是一种密码衍生算法,它是由Colin Percival创建的。使用scrypt算法来生成衍生key,需要用到大量的内存。scrypt算法在2016年作为RFC 7914标准发布。


密码衍生算法主要作用就是根据初始化的主密码来生成系列的衍生密码。这种算法主要是为了抵御暴力破解的攻击。通过增加密码生成的复杂度,同时也增加了暴力破解的难度。


但是和上面提到的原因一样,之前的password-based KDF,比如PBKDF2虽然提高了密码生成的遍历次数,但是它使用了很少的内存空间。所以很容易被简单的ASIC机器破解。scrypt算法就是为了解决这样的问题出现的。


scrypt算法详解


scrypt算法会生成非常大的伪随机数序列,这个随机数序列会被用在后续的key生成过程中,所以一般来说需要一个RAM来进行存储。这就是scrypt算法需要大内存的原因。


接下我们详细分析一下scrypt算法,标准的Scrypt算法需要输入8个参数,如下所示:



  • Passphrase: 要被hash的输入密码

  • Salt: 对密码保护的盐,防止彩虹表攻击

  • CostFactor (N): CPU/memory cost 参数,必须是2的指数(比如: 1024)

  • BlockSizeFactor (r): blocksize 参数

  • ParallelizationFactor (p): 并行参数

  • DesiredKeyLen (dkLen): 输出的衍生的key的长度

  • hLen: hash函数的输出长度

  • MFlen: Mix函数的输出长度


这个函数的输出就是DerivedKey。


首先我们需要生成一个expensiveSalt。首先得到blockSize:


blockSize = 128*BlockSizeFactor 

然后使用PBKDF2生成p个blockSize,将这p个block组合成一个数组:


[B0...Bp−1] = PBKDF2HMAC-SHA256(Passphrase, Salt, 1, blockSize*ParallelizationFactor)

使用ROMix对得到的block进行混合:


   for i ← 0 to p-1 do
Bi ← ROMix(Bi, CostFactor)

将B组合成新的expensiveSalt:


expensiveSalt ← B0∥B1∥B2∥ ... ∥Bp-1

接下来使用PBKDF2和新的salt生成最终的衍生key:


return PBKDF2HMAC-SHA256(Passphrase, expensiveSalt, 1, DesiredKeyLen);

下面是ROMix函数的伪代码:


Function ROMix(Block, Iterations)

Create Iterations copies of X
X ← Block
for i ← 0 to Iterations−1 do
Vi ← X
X ← BlockMix(X)

for i ← 0 to Iterations−1 do
j ← Integerify(X) mod Iterations
X ← BlockMix(X xor Vj)

return X

其中BlockMix的伪代码如下:


Function BlockMix(B):

The block B is r 128-byte chunks (which is equivalent of 2r 64-byte chunks)
r ← Length(B) / 128;

Treat B as an array of 2r 64-byte chunks
[B0...B2r-1] ← B

X ← B2r−1
for i ← 0 to 2r−1 do
X ← Salsa20/8(X xor Bi) // Salsa20/8 hashes from 64-bytes to 64-bytes
Yi ← X

return ← Y0∥Y2∥...∥Y2r−2 ∥ Y1∥Y3∥...∥Y2r−1

scrypt的使用


Scrypt被用在很多新的POW的虚拟货币中,比如Tenebrix、 Litecoin 和 Dogecoin。感兴趣的朋友可以关注一下。


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

java流太太太..............好用了

情景:一个集合对象list,现在想获取这个集合中每个对象的id,并将这些id值存放在另一个集合中,方便我去查询数据。如果是你来实现这个需求,你会用什么方法去实现呢。 我猜会有许多人会选择循环变量这个集合对象,取出id存放在集合里面,代码是这样的: List&...
继续阅读 »
  • 情景:一个集合对象list,现在想获取这个集合中每个对象的id,并将这些id值存放在另一个集合中,方便我去查询数据。如果是你来实现这个需求,你会用什么方法去实现呢。


我猜会有许多人会选择循环变量这个集合对象,取出id存放在集合里面,代码是这样的:


List<Clazz> list = clazzes;
List<Long> ids = new ArrayList();
for (Clazz clazz : list) {
ids.add(clazz.getId());
}

但是!实际上,这个需求可以只用一行代码就可以解决,那是用的什么呢?“流”请看代码:


List<Clazz> list = clazzes;
List<Long> collect = list.stream().map(Clazz::getId).collect(Collectors.toList());

使用流一行代码就可以解决关键看着清晰明了。
上面list.stream().map(Clazz::getId).collect(Collectors.toList())这一行代码用了JAVA8 的两个新特性



  • 双冒号 双冒号就是把方法当作参数传递给需要的方法,或者是传递到stream()中去。在这里就是将其传到stream中去其语法格式 类名::方法名

  • stream 流 通过Collectors 类将流转换成集合元素 流的操作还有许多,可以参考搜索网络


再分享一下 最近根据echart图来查询数据,我在写查询语句筛选条件使用了大量的stream流,发现使用stream流是真的舒服。


我先描述我最近的一个接口:这个接口需要展示四个饼图。而四个饼图是:1.男女教师占比;2.各年龄段占比 3.学历占比,4.职称统计
我想在一个接口中完成这个四个的查询 我的思路有几个:


1.是写多个查询语句 需要一个查询一个(但是各种筛选条件下来 很麻烦)


2.利用视图 可以用来多次调用(但是在查询中会存在in操作 觉得麻烦)


3.利用stream流 根据筛选条件查出符合的教师信息 对每一个操作进行筛选


 通过各种筛选条件查出的结果: teacherList (集合类型)
Long count1 = teacherList.stream().filter(e -> e.getGender().equals(0)).count(); //男生数量
Long count2 = teacherList.stream().filter(e -> e.getGender().equals(1)).count(); //女生数量

通过这样可以直接算出数量 而不用去便利算数据


而更多详细的stream流的信息可以去网上搜索学习


我对stream流的学习还在表面 还有许多灵活的用法我还需要继续学习 欢迎大佬指导!


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

为什么需要Java内存模型?

面试官:今天想跟你聊聊Java内存模型,这块你了解过吗? 候选者:嗯,我简单说下我的理解吧。那我就从为什么要有Java内存模型开始讲起吧 面试官:开始你的表演吧。 候选者:那我先说下背景吧 候选者:1. 现有计算机往往是多核的,每个核心下会有高速缓存。高速缓存...
继续阅读 »

面试官今天想跟你聊聊Java内存模型,这块你了解过吗?


候选者:嗯,我简单说下我的理解吧。那我就从为什么要有Java内存模型开始讲起吧


面试官:开始你的表演吧。


候选者:那我先说下背景吧


候选者:1. 现有计算机往往是多核的,每个核心下会有高速缓存。高速缓存的诞生是由于「CPU与内存(主存)的速度存在差异」,L1和L2缓存一般是「每个核心独占」一份的。


候选者:2. 为了让CPU提高运算效率,处理器可能会对输入的代码进行「乱序执行」,也就是所谓的「指令重排序」


候选者:3. 一次对数值的修改操作往往是非原子性的(比如i++实际上在计算机执行时就会分成多个指令)


候选者:在永远单线程下,上面所讲的均不会存在什么问题,因为单线程意味着无并发。并且在单线程下,编译器/runtime/处理器都必须遵守as-if-serial语义,遵守as-if-serial意味着它们不会对「数据依赖关系的操作」做重排序。



候选者:CPU为了效率,有了高速缓存、有了指令重排序等等,整块架构都变得复杂了。我们写的程序肯定也想要「充分」利用CPU的资源啊!于是乎,我们使用起了多线程


候选者:多线程在意味着并发,并发就意味着我们需要考虑线程安全问题


候选者:1. 缓存数据不一致:多个线程同时修改「共享变量」,CPU核心下的高速缓存是「不共享」的,那多个cache与内存之间的数据同步该怎么做?


候选者:2. CPU指令重排序在多线程下会导致代码在非预期下执行,最终会导致结果存在错误的情况。



候选者:针对于「缓存不一致」问题,CPU也有其解决办法,常被大家所认识的有两种:


候选者:1.使用「总线锁」:某个核心在修改数据的过程中,其他核心均无法修改内存中的数据。(类似于独占内存的概念,只要有CPU在修改,那别的CPU就得等待当前CPU释放)


候选者:2.缓存一致性协议(MESI协议,其实协议有很多,只是举个大家都可能见过的)。MESI拆开英文是(Modified (修改状态)、Exclusive (独占状态)、Share(共享状态)、Invalid(无效状态))


候选者:缓存一致性协议我认为可以理解为「缓存锁」,它针对的是「缓存行」(Cache line) 进行”加锁”,所谓「缓存行」其实就是 高速缓存 存储的最小单位。



面试官:嗯…


候选者:MESI协议的原理大概就是:当每个CPU读取共享变量之前,会先识别数据的「对象状态」(是修改、还是共享、还是独占、还是无效)。


候选者:如果是独占,说明当前CPU将要得到的变量数据是最新的,没有被其他CPU所同时读取


候选者:如果是共享,说明当前CPU将要得到的变量数据还是最新的,有其他的CPU在同时读取,但还没被修改


候选者:如果是修改,说明当前CPU正在修改该变量的值,同时会向其他CPU发送该数据状态为invalid(无效)的通知,得到其他CPU响应后(其他CPU将数据状态从共享(share)变成invalid(无效)),会当前CPU将高速缓存的数据写到主存,并把自己的状态从modify(修改)变成exclusive(独占)


候选者:如果是无效,说明当前数据是被改过了,需要从主存重新读取最新的数据。



候选者:其实MESI协议做的就是判断「对象状态」,根据「对象状态」做不同的策略。关键就在于某个CPU在对数据进行修改时,需要「同步」通知其他CPU,表示这个数据被我修改了,你们不能用了。


候选者:比较于「总线锁」,MESI协议的”锁粒度”更小了,性能那肯定会更高咯


面试官但据我了解,CPU还有优化,你还知道吗?


候选者:嗯,还是了解那么一点点的。


候选者:从前面讲到的,可以发现的是:当CPU修改数据时,需要「同步」告诉其他的CPU,等待其他CPU响应接收到invalid(无效)后,它才能将高速缓存数据写到主存。


候选者:同步,意味着等待,等待意味着什么都干不了。CPU肯定不乐意啊,所以又优化了一把。


候选者:优化思路就是从「同步」变成「异步」。


候选者:在修改时会「同步」告诉其他CPU,而现在则把最新修改的值写到「store buffer」中,并通知其他CPU记得要改状态,随后CPU就直接返回干其他事了。等到收到其它CPU发过来的响应消息,再将数据更新到高速缓存中。


候选者:其他CPU接收到invalid(无效)通知时,也会把接收到的消息放入「invalid queue」中,只要写到「invalid queue」就会直接返回告诉修改数据的CPU已经将状态置为「invalid」



候选者:而异步又会带来新问题:那我现在CPU修改完A值,写到「store buffer」了,CPU就可以干其他事了。那如果该CPU又接收指令需要修改A值,但上一次修改的值还在「store buffer」中呢,没修改至高速缓存呢。


候选者:所以CPU在读取的时候,需要去「store buffer」看看存不存在,存在则直接取,不存在才读主存的数据。【Store Forwarding】


候选者:好了,解决掉第一个异步带来的问题了。(相同的核心对数据进行读写,由于异步,很可能会导致第二次读取的还是旧值,所以首先读「store buffer」。


面试官还有其他?


候选者:那当然啊,那「异步化」会导致相同核心读写共享变量有问题,那当然也会导致「不同」核心读写共享变量有问题啊


候选者:CPU1修改了A值,已把修改后值写到「store buffer」并通知CPU2对该值进行invalid(无效)操作,而CPU2可能还没收到invalid(无效)通知,就去做了其他的操作,导致CPU2读到的还是旧值。


候选者:即便CPU2收到了invalid(无效)通知,但CPU1的值还没写到主存,那CPU2再次向主存读取的时候,还是旧值…


候选者:变量之间很多时候是具有「相关性」(a=1;b=0;b=a),这对于CPU又是无感知的…


候选者:总体而言,由于CPU对「缓存一致性协议」进行的异步优化「store buffer」「invalid queue」,很可能导致后面的指令很可能查不到前面指令的执行结果(各个指令的执行顺序非代码执行顺序),这种现象很多时候被称作「CPU乱序执行」


候选者:为了解决乱序问题(也可以理解为可见性问题,修改完没有及时同步到其他的CPU),又引出了「内存屏障」的概念。



面试官:嗯…


候选者:「内存屏障」其实就是为了解决「异步优化」导致「CPU乱序执行」/「缓存不及时可见」的问题,那怎么解决的呢?嗯,就是把「异步优化」给”禁用“掉(:


候选者:内存屏障可以分为三种类型:写屏障,读屏障以及全能屏障(包含了读写屏障),屏障可以简单理解为:在操作数据的时候,往数据插入一条”特殊的指令”。只要遇到这条指令,那前面的操作都得「完成」。


候选者:那写屏障就可以这样理解:CPU当发现写屏障的指令时,会把该指令「之前」存在于「store Buffer」所有写指令刷入高速缓存。


候选者:通过这种方式就可以让CPU修改的数据可以马上暴露给其他CPU,达到「写操作」可见性的效果。


候选者:那读屏障也是类似的:CPU当发现读屏障的指令时,会把该指令「之前」存在于「invalid queue」所有的指令都处理掉


候选者:通过这种方式就可以确保当前CPU的缓存状态是准确的,达到「读操作」一定是读取最新的效果。



候选者:由于不同CPU架构的缓存体系不一样、缓存一致性协议不一样、重排序的策略不一样、所提供的内存屏障指令也有差异,为了简化Java开发人员的工作。Java封装了一套规范,这套规范就是「Java内存模型」


候选者:再详细地说,「Java内存模型」希望 屏蔽各种硬件和操作系统的访问差异,保证了Java程序在各种平台下对内存的访问都能得到一致效果。目的是解决多线程存在的原子性、可见性(缓存一致性)以及有序性问题。



面试官那要不简单聊聊Java内存模型的规范和内容吧?


候选者:不了,怕一聊就是一个下午,下次吧?


本文总结




  • 并发问题产生的三大根源是「可见性」「有序性」「原子性」




  • 可见性:CPU架构下存在高速缓存,每个核心下的L1/L2高速缓存不共享(不可见)




  • 有序性:主要有三方面可能导致打破



    • 编译器优化导致重排序(编译器可以在不改变单线程程序语义的情况下,可以对代码语句顺序进行调整重新排序)

    • 指令集并行重排序(CPU原生就有可能将指令进行重排)

    • 内存系统重排序(CPU架构下很可能有store buffer /invalid queue 缓冲区,这种「异步」很可能会导致指令重排)




  • 原子性:Java的一条语句往往需要多条 CPU 指令完成(i++),由于操作系统的线程切换很可能导致 i++ 操作未完成,其他线程“中途”操作了共享变量 i ,导致最终结果并非我们所期待的。




  • 在CPU层级下,为了解决「缓存一致性」问题,有相关的“锁”来保证,比如“总线锁”和“缓存锁”。



    • 总线锁是锁总线,对共享变量的修改在相同的时刻只允许一个CPU操作。

    • 缓存锁是锁缓存行(cache line),其中比较出名的是MESI协议,对缓存行标记状态,通过“同步通知”的方式,来实现(缓存行)数据的可见性和有序性

    • 但“同步通知”会影响性能,所以会有内存缓冲区(store buffer/invalid queue)来实现「异步」进而提高CPU的工作效率

    • 引入了内存缓冲区后,又会存在「可见性」和「有序性」的问题,平日大多数情况下是可以享受「异步」带来的好处的,但少数情况下,需要强「可见性」和「有序性」,只能”禁用”缓存的优化。

    • “禁用”缓存优化在CPU层面下有「内存屏障」,读屏障/写屏障/全能屏障,本质上是插入一条”屏障指令”,使得缓冲区(store buffer/invalid queue)在屏障指令之前的操作均已被处理,进而达到 读写 在CPU层面上是可见和有序的。




  • 不同的CPU实现的架构和优化均不一样,Java为了屏蔽硬件和操作系统访问内存的各种差异,提出了「Java内存模型」的规范,保证了Java程序在各种平台下对内存的访问都能得到一致效果


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

「一探究竟」迷之序列化

事件起因 今天,我需要上线一个非常小但是又非常重要的系统改动,即给核心接口的RPC接口出参增加序列化接口(由下图可见,原实体类未实现序列化)。 编码、测试、代码审核一气呵成,然后收到驳回通知,架构师说实现序列化接口时注意不要忘记配置serialversio...
继续阅读 »

事件起因


今天,我需要上线一个非常小但是又非常重要的系统改动,即给核心接口的RPC接口出参增加序列化接口(由下图可见,原实体类未实现序列化)。


image-20210907025636984.png




编码、测试、代码审核一气呵成,然后收到驳回通知,架构师说实现序列化接口时注意不要忘记配置serialversionUID,还非常贴心的跟我说,IDEA 有一个插件可以自动生成UID,推荐我下载使用(IDEA serialversionUID 插件地址),按照要求调整之后,提测、编译、发布一气呵成,进入今天的午觉模式 (😎)




梦中惊魂


我突然梦见企业微信以每毫秒弹出一个窗口的速度不停的闪烁,周围的人熙熙攘攘,面露忧色,不知道在说些什么...


线上出问题了?和我有什么关系呢(🤪)肯定不是我的问题,不过为了保险起见,还是回忆一下今天都做了什么事吧。


**做了什么?**中台系统上线。**改了什么?**对部分类增加了序列化接口,并增加了serialversionUID... 会导致什么? 接口调用失败...COE...


蹭的一下,我立即从梦中醒来,开始看企业微信,看监控,看接口可用率,看了一切数据正常无误后才逐渐心安。




纳尼?我们不用Java序列化?


回顾自己所了解的关于序列化的知识,打开了各种关于序列化的文章,都给我指向了一个答案:我这种改动铁定会影响序列化,就像下面这样程序会报错。


Exception in thread "main" java.io.InvalidClassException: ser.demo.StuDemo; local class incompatible: stream classdesc serialVersionUID = 6395135316924936201, local class serialVersionUID = 1
at java.io.ObjectStreamClass.initNonProxy(ObjectStreamClass.java:616)
at java.io.ObjectInputStream.readNonProxyDesc(ObjectInputStream.java:1843)
at java.io.ObjectInputStream.readClassDesc(ObjectInputStream.java:1713)
at java.io.ObjectInputStream.readOrdinaryObject(ObjectInputStream.java:2000)
at java.io.ObjectInputStream.readObject0(ObjectInputStream.java:1535)
at java.io.ObjectInputStream.readObject(ObjectInputStream.java:422)
at ser.demo.App.main(App.java:27)

现在线上没报错,只有一种可能,即:我们的RPC框架并没有使用原生的序列化方式。遇事不决架构师,咨询完毕之后果然和我猜测的一样,还从架构师的口中知晓了另外几种序列化方式,比如:MessagePack、Hessian等等。




常见序列化方式


Java序列化


Java序列化就是将一个对象转化为一个二进制表示的字节数组,通过保存或则转移这些二进制数组达到持久化的目的。


要实现序列化,需要实现java.io.Serializable接口,反序列化是和序列化相反的过程,就是把二进制数组转化为对象的过程,在反序列化的时候,必须有原始类的模板才能将对象还原,其核心方法在于以下两个方法,其中Serializable接口起到的作用是标识是否实现序列化、以及前后对象是否一致等作用。


序列化:java.io.ObjectOutputStream#writeObject0


反序列化:java.io.ObjectInputStream#readObject0


以测试类(StuDemo)为例,序列化后的结果如下:


// 序列化
FileOutputStream fos = new FileOutputStream("C:\\Users\\Kerwin\\Desktop\\log\\object.out");
ObjectOutputStream oos = new ObjectOutputStream(fos);
StuDemo demo = new StuDemo("Kerwin");
oos.writeObject(demo);
oos.flush();
oos.close();

// 结果如下
//  sr ser.demo.StuDemoX??莅 L namet Ljava/lang/String;xpt Kerwin

一堆乱码,但还是能看出来文件内容大致是指向某一个类,有什么字段、对应的值等信息。




MessagePack 序列化


MessagePack(简写Msgpack)是一个高效的二进制序列化格式,它让你像JSON一样可以在各种语言之间交换数据,但是它比JSON更快、更小。


更快更小就代表着性能更高,它是如何实现的?


Msgpack序列化的时候,字段不会标明Key,仅会按照字段的先后顺序存储,类似数组一样,它的编码方式是类型 + 长度 + 内容,如下所示:


image-20210907041011662.png


这种高效的编码方式就带来一些限制,例如:



  • 服务端不可随意在任意位置增加字段,因为客户端不升级的话会导致反序列化失败

  • 不能使用第三方包提供的集合类工具包作为返回值


使用方式如下:


// 其中 StuDemo 类需要增加 @Message 注解标识需要被MessagePack序列化
// MessagePack 序列化方式不需要依赖 Serializable
public static void main(String[] args) throws IOException {
StuDemo demo = new StuDemo("Kerwin");
MessagePack pack = new MessagePack();

// 序列化
byte[] bytes = pack.write(demo);

// 反序列化
StuDemo res = pack.read(bytes, StuDemo.class);
System.out.println(res.getName());



PS:我司的RPC框架目前就使用的MessagePack序列化方式,也是因为此,所以上述调整 serialVersionUID 时没有发生任何问题
同理,受制于底层序列化的限制,我们的新人文档中也明确提到了上述的限制,比如必须在最末尾增加字段等等。





Hessian2 序列化


Hessian是动态类型、二进制、紧凑的,并且可跨语言移植的一种序列化框架,在Hessian的基础之上,Hessian2的性能和压缩率大大提升。


Hessian会把复杂的对象所有属性存储在一个类似Map的结构中进行序列化,所以在父类、子类中存在同名成员变量的情况下,它先序列化子类,然后序列化父类,因此会导致子类同名成员变量的值被父类覆盖等情况。


它有八大核心设计目标,官网



  • 必须自我描述序列化类型,即不需要外部模式或接口定义

  • 必须与语言无关,包括支持脚本语言

  • 必须在一次传递中可读或可写

  • 必须尽可能紧凑(压缩)

  • 必须简单

  • 必须尽可能快

  • 必须支持Unicode字符串

  • 必须支持8位二进制数据

  • 必须支持加密


使用方式如下:


public class StuHessianDemo implements Serializable {

private static final long serialVersionUID = -640696903073930546L;

private String name;

public StuHessianDemo(String name) {
this.name = name;
}

public String getName() {
return name;
}

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

public static void main(String[] args) throws IOException {
StuHessianDemo hessianDemo = new StuHessianDemo("Kerwin");

ByteArrayOutputStream stream = new ByteArrayOutputStream();
HessianOutput hessianOutput = new HessianOutput(stream);
hessianOutput.writeObject(hessianDemo);

ByteArrayInputStream inputStream = new ByteArrayInputStream(stream.toByteArray());

// Hessian的反序列化读取对象
HessianInput hessianInput = new HessianInput(inputStream);
System.out.println(((StuHessianDemo) hessianInput.readObject()).getName());
}

// 结果:Kerwin



选择的依据


由上文我们得知了几种常用的序列化方式,及其优劣,比如MessagePack就是极致的压缩和快,Hessian2则依赖Serializable接口,在保证安全性、自身描述性的基础上,尽可能的追求空间利用率,效率等,而Java序列化方式则一直被诟病,难等大雅之堂,因此在RPC框架选择底层序列化方式时,需要根据自身所需,有所侧重的选择某一项序列化方式。


选择的依据如下,优先级从高到低:


image-20210907051517893.png




一点思考


JSON序列化的地位


其实JSON序列化才是我们最熟知的序列化方式,它本身也不需要实现Serializable接口,为什么大多数RPC框架没有选择用它作为默认的序列化方式呢?


在了解完上文的内容后,我们知道关键还是在性能,效率、空间开销上,因为JSON是一种文本类型序列化框架,采用KEY-VALUE的方式存储数据,它在进行序列化的额外空间开销相对就更大,在反序列化时更不必说,需要依赖反射,因此性能进一步缩水。


然而JSON本身又具备极强的可读性、因此被作为Web中HTTP协议的事实标准。




为什么还要自定义 serialVersionUID


在《Effect Java》中有一句提到:


不管你选择了哪种序列化方式,都要为自己编写的每个可序列化的类声明一个显式的序列版本UID。


为什么架构师会提醒我实现它?为什么书中也会这么说?


serialVersionUID分解下来全称为:serial Version UID,序列版本UID,每一个可序列化的类都有一个long域中显式地指定该编号,如果编码者未定义的话,系统就会对这个类的结构运用一个加密的散列函数(SHA-1),从而在运行时自动产生该标识号,该编号会受类名称、接口名称、公有及受保护的成员变量所影响,一旦有相关改动例如增加一个不重要的公有方法即会影响UID,导致异常发生。


因此这是一个习惯问题,也是为了避免潜在风险。




总结


截止到这里,我们了解了原来之前学习到的Java序列化是那么的不实用(甚至到了被吐槽的地步),也知晓了一些框架使用注意事项底层的秘密(比如MsgPack增加字段),下面是关于序列化的一些小建议:



  1. 无论是否依赖Serializable,接口出参都建议实现序列化接口。

  2. 如果实现了序列化接口,务必自行实现serialVersionUID。

  3. 接口出参对象不宜使用特殊的数据类型(如MsgPack第三方集合等)、过于复杂的结构(继承等),不然会导致很多莫名其妙的问题发生。

  4. 当发生服务端/客户端数据不一致时,第一时间想到是序列化问题,并针对当前序列化方式的特点,仔细排查。

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

kafka!还好我留了一手

本文面试情节虚假,但知识真实,请在家人或者朋友的陪同下仔细观看,防止在观看的过程发呆、走神导致没学到知识。性能篇一位身穿格子衬衣,头发好似一拳超人的中年人走了过来,没错他就是面试官,他手握简历,若有所思,我当时害怕极了,然后他开口:小伙子啊,我们这边是基础架构...
继续阅读 »

本文面试情节虚假,但知识真实,请在家人或者朋友的陪同下仔细观看,防止在观看的过程发呆、走神导致没学到知识。

性能篇

一位身穿格子衬衣,头发好似一拳超人的中年人走了过来,没错他就是面试官,他手握简历,若有所思,我当时害怕极了,然后他开口:小伙子啊,我们这边是基础架构的中间件组,既然你的简历没提到kafka,那我接下来问问你kafka的知识吧。

:好的,kafka平时看的不多,但也还了解一点,不是特别精通所以没写了。(嘿嘿,我是故意没写的,早就知道你要来这一套,kafka其实是俺最精通的东西了)
面试官捋了捋他那稀疏的胡须:那我们开始吧,先说说kafka的Log文件存在什么地方?
:kafka的topic可以分区,所以Log对应了一个命名形式为topic-partition的文件夹,比如对于一个有两个分区的topic来说,它的log分别存在xxx/topic-1和xxx/topic-2中。
面试官:那按照这样的说法,所以log文件的位置应该就是xxx/topic-1/data.log或者xxx/topic-2/data.log?
:不是的,kafka的log会分段,每个分区文件夹下,其实有很多的log段,它们共同组成了log,每个日志段大小是1G,如果一个日志段写完,会自动写入一个新的段。
面试官:为什么要分段?不分段行不行?
:分段可以很好的维护数据,首先不分段,当查找一条数据的时候会很麻烦,就像在一本没有目录的新华字典里找数据一样,如果分了段我们只要知道数据在哪个段中,然后在对应的段中查找即可。同时由于log是持久化磁盘的,磁盘的空间不可能无穷无尽的,当需要清除一些老数据,通过分段机制,只需要删除较老的数据段即可。
面试官:hold on,hold on~,你说分了段后我们只要知道数据在哪个段中即可,那么我们怎么知道数据在哪个段中的?
:easy,easy~,kafka内部维护一个跳跃表,跳跃表的节点就是每个段的段号。这样当查询数据的时候,先根据跳跃表就可以快速定位到目标数据段。
面试官:跳跃表是可以加速访问,但是每个段的段号是咋确定的?
:kakfa的段号其实就是根据偏移量来的,它代表当前段内偏移量最小的那条数据的offset,比如:

 segment1的段号是200,segment2的段号是500,那么segment1就存储了偏移量200-499的消息。
面试官:嗯嗯,那定位到段后,如何定位到具体的消息,直接遍历吗?
:不是直接遍历,直接遍历效率太低,kafka采用稀疏索引的方式来搜索具体的消息,其实每个log分段后,除了log文件外,还有两个索引文件,分别是.index和.timeindex,

 其中.index就是我说的偏移量索引文件,它不会为每条消息创建索引,它会每隔一个范围区间创建索引,所以称之为稀疏索引。

 比如我们要查找消息6的时候,首先加载稀疏文件索引.index到内存中,然后通过二分法定位到消息5,最后通过消息5指向的物理地址接着向下顺序查找,直至找到消息6。
面试官:那稀疏索引的好处是什么?
:稀疏索引是一个折中的方案,既不占用太多空间,也提供了一定的快速检索能力。
面试官:上面你说到了.timeindex文件,它是干嘛的?
:这和kafka清理数据有着密切的关系,kafka默认保留7天内的数据,对于超过7天的数据,会被清理掉,这里的清理逻辑主要根据timeindex时间索引文件里最大的时间来判断的,如果最大时间与当前时间差值超过7天,那么对应的数据段就会被清理掉。
面试官:说到数据清理,除了你说的根据时间来判断的,还有哪些?
:还有根据日志文件大小和日志起始偏移量的方式,对于日志文件大小,如果log文件(所有的数据段总和)大于我们设定的阈值,那么就会从第一个数据段开始清理,直至满足条件。对于日志起始偏移量,如果日志段的起始偏移量小于等于我们设定的阈值,那么对应的数据段就会被清理掉。
面试官:你知道消息合并吗?如果知道说说消息合并带来的好处。
:了解一点,消息合并就是把多条消息合并在一起,然后一次rpc调用发给broker,这样的好处无疑会减少很多网络IO资源,其次消息会有个crc校验,如果不合并每条消息都要crc,合并之后,多条消息可以一起crc一次。
面试官:那合并之后的消息,什么时候会给broker?
:合并的消息会在缓冲区内,如果缓冲区快满了或者一段时间内没有生产消息了,那么就会把消息发给broker。
面试官:那你知道消息压缩吗?
:知道一点,压缩是利用cpu时间来节省带宽成本,压缩可以使数据包的体积变得更小,生产者负责将数据消息压缩,消费者拿到消息后自行解压。
面试官:所有只有生产者可以压缩?
:不是的,broker也可以压缩,当生产者指定的压缩算法和broker指定压缩算法的不一样的时候,broker会先按照生产者的压缩算法解压缩一下,然后再按照自己的压缩算法压缩一下,这是需要注意的,如果出现这种情况会影响整体的吞吐。还有就是新老版本的问题,如果新老版本的压缩算法不兼容,比如broker版本比较老,不支持新的压缩算法,那么也会发生一样的事情。
面试官:我们知道kafka的消息是要写入磁盘的,磁盘IO会不会很慢?
:是这样的,kafka的消息是磁盘顺序读写的,有关测试结果表明,一个由6块7200r/min的RAID-5阵列组成的磁盘簇的线性(顺序)写入速度可以达到 600MB/s,而随机写入速度只有 100KB/s,两者性能相差6000倍。操作系统可以针对线性读写做深层次的优化,比如预读(read-ahead,提前将一个比较大的磁盘块读入内存)和后写(write-behind,将很多小的逻辑写操作合并起来组成一个大的物理写操作)技术。顺序写盘的速度不仅比随机写盘的速度快,而且也比随机写内存的速度快。
面试官:顺序读写是为了解决了缓慢的磁盘问题,那在网络方面还有其他的优化吗?
:有,零拷贝,在没有零拷贝的时候,消息是这样交互的:

  1. 切到内核态:内核把磁盘数据copy到内核缓冲区
  2. 切到用户态:把内核的数据copy到用户程序
  3. 切到内核态:用户数据copy到内核socket缓冲区
  4. socket把数据copy给网卡

可以发现一份数据经过多次copy,最终兜兜转转又回到了内核态,实属浪费。

当有了零拷贝之后:

  1. 磁盘数据copy到内核缓冲
  2. 内核缓冲把描述符和长度发给socket,同时直接把数据发给网卡

可以发现通过零拷贝,减少了两次copy过程,大大降低了开销。

可靠篇

面试官:(关于性能方面的问的差不多了,接下来换换口味吧),kafka的多消费者模型是怎么做到的?
:如果要支持多个消费者同时消费一个topic,最简单的方式就是把topic复制一份,但这无疑会浪费很多空间,尤其在消费者很多的情况下,

于是kafka设计出一套offset机制,即一份数据,不同的消费者根据位置来获取不同的消息即可。

面试官:那你知道消费者的offset存在哪吗?
:很久以前,是存在zookeeper中的,但是offset需要频繁更新,zookeeper又不适合频繁更新,所以后来就把消费者位移存在了一个叫_consumer_offset的topic中,这个topic会在第一个消费者启动的时候自动创建,默认50个分区,3个副本。
面试官:那你说说这个_consumer_offset里面具体存了什么?
:这里其实主要分为key和value,value可以简单的认为就是我们的消费者位移,关于key,这里要细说下,由于每个消费者都属于一个消费者组,并且每个消费者其实消费的是某个topic的分区,所以通过group-topic-partition就可以关联上对应的消费者了,这也就是key的组成。
面试官:那你能介绍下消费者提交位移的方式吗?
:这里分为自动提交和手动提交。自动提交的话,就不需要我们干预,我们消费完消息后,kafka会自动帮我们提交,手动提交的话,就需要我们在消费到消息后自己主动commit。
面试官:自动提交会有什么问题?
:自动提交的策略是consumer默认每隔5秒提交一次位移,如果consumer在接下来的很长时间内都没有数据消费,那么自动提交策略就会一直提交重复的位移,导致_consumer_offset有很多重复的消息。
面试官:那这有什么解决方案吗?
:有,这种情况的核心问题就是可能会有大量的、重复的位移消息占用存储空间,只要把重复的去掉即可,kafka提供一种类似redis的aofrewrite的功能,叫compact策略,compact是由一个logCleaner线程来完成的,它会把重复的、并且较老的消息清除掉。

面试官:那如果consumer自动重启了,位移没来的及提交咋办?
:这个会造成重复消费,一般业务上需要配合做幂等。
面试官:那手动提交能解决这个问题吗?
:不能,如果我们在业务处理完之后手动提交,但是在还没得及提交的情况下,也发生了重启或者其他原因导致提交不上去,在消费者恢复后也会发生重复消费。
面试官:那如果我是先提交,后处理业务逻辑呢?
:这种情况也不能保证100%没问题,如果提交成功,但是处理业务时出错,正常来说,这时希望重新消费这条数据是不行的,因为已经提交了,除非你重置offset。总之无论哪种方案都不能保证100%的完美,我们需要自己根据业务情况做幂等或者根据log来找到丢失的数据。
面试官:消费者提交消费位移时提交的是是当前消费到的最新消息的offset还是offset+1?
:offset+1。
面试官:从生产者的角度谈谈消息不丢失的看法。
:关于消息丢失问题,kafka的生产者提供了3种策略来供使用者选择,每种策略各有利弊,需要结合业务的实际状况来选择。

  1. 第一种就是生产者不关心消息的情况,只负责发,这种模式无疑速度是最快的,吞吐是最好的,但是可能造成大量的数据丢失,比如在borker出现问题的时候,生产者还不停的发,那么到broker恢复期间的数据都将丢失。
  2. 第二种就是生产者需要所有副本都写入成功,不管是Leader副本还是Follower副本,那么当Follower副本越多,吞吐理论就越差,但是这种模式下,消息是最安全的。
  3. 第三种就是生产者只需要收到Leader副本的ack即可,不用关心Follower副本的写入情况,它是个折中的做法,保证了一定的安全性的同时也不会太影响吞吐。

如果你不在意自己的数据丢失问题,追求吞吐,比如像log这种,可以采用第一种,如果你非常在意自己的数据安全性,那么就选第二种。如果你希望吞吐稍微好点,同时数据又能安全些,建议第三种,但是第三种在Follower副本出现的问题的时候对生产者来说是无法感知的。

面试官:那你说说一个Follower副本如何被选举成Leader的?
:在kafka中有这样几个概念:

  • AR:所有副本集合
  • ISR:所有符合选举条件的副本集合
  • OSR:落后太多或者挂掉的副本集合

AR = ISR + OSR,在正常情况下,AR应该是和ISR一样的,但是当某个Follower副本落后太多或者某个Follower副本节点挂掉了,那么它会被移出ISR放入OSR中,kafka的选举也比较简单,就是把ISR中的第一个副本选举成新的Leader节点。比如现在AR=[1,2,3],1挂掉了,那么ISR=[2,3],这时会选举2为新的Leader。

面试官捋了捋自己左边的刘海:你还有什么要问我的吗?
:老师,请问你会组合拳吗?

 面试官:组合拳我不会,但是等会会有很多人组合过来面你。

未完待续...


作者:假装懂编程
链接:https://juejin.cn/post/7018702635544870948
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

收起阅读 »

【Java字符串】字符串虽简单,但这些你不一定知道

前言: 字符串是程序开发当中,使用最频繁的类型之一,有着与基础类型相同的地位(字符串不属于基本类型),甚至在 JVM(Java 虚拟机)编译的时候会对字符串做特殊的处理,比如拼加操作可能会被 JVM 直接合成为一个最终的字符串,从而到达高效运行的目的。 1 :...
继续阅读 »

前言:


字符串是程序开发当中,使用最频繁的类型之一,有着与基础类型相同的地位(字符串不属于基本类型),甚至在 JVM(Java 虚拟机)编译的时候会对字符串做特殊的处理,比如拼加操作可能会被 JVM 直接合成为一个最终的字符串,从而到达高效运行的目的。


1 :构造方法:


将字节数组或者字符数组转成字符串。


String s1 = new String();//创建了一个空内容的字符串。

String s2 = null;//s2没有任何对象指向,是一个null常量值。

String s3 = "";//s3指向一个具体的字符串对象,只不过这个字符串中没有内容。

//一般在定义字符串时,不用new。

String s4 = new String("abc");

String s5 = "abc"; 一般用此写法

new String(char[]);//将字符数组转成字符串。

new String(char[],offset,count);//将字符数组中的一部分转成字符串。


2 :一般方法:


    按照面向对象的思想:


2.1 获取:


    2.1.1:获取字符串的长度。length() ;


    2.1.2:指定位置的字符。char charAt(int index);


    2.1.3:获取指定字符的位置。如果不存在返回-1,所以可以通过返回值-1来判断某一个字符不存在的情况。           


 int indexOf(int ch);//返回第一次找到的字符角标

 int indexOf(int ch,int fromIndex); //返回从指定位置开始第一次找到的角标

 int indexOf(String str); //返回第一次找到的字符串角标

int indexOf(String str,int fromIndex);

 int lastIndexOf(int ch);

 int lastIndexOf(int ch,int fromIndex);

 int lastIndexOf(String str);
int lastIndexOf(String str,int fromIndex);


    2.1.4:获取子串。


String substring(int start);//从start位开始,到length()-1为止.
String substring(int start,int end);//从start开始到end为止。//包含start位,不包含end位。
substring(0,str.length());//获取整串


2.2 判断:


    2.2.1:字符串中包含指定的字符串吗?


            boolean contains(String substring);


    2.2.2:字符串是否以指定字符串开头啊?


            boolean startsWith(string);


    2.2.3:字符串是否以指定字符串结尾啊?


            boolean endsWith( string);


    2.2.4:判断字符串是否相同


            boolean equals(string);//覆盖了Object中的方法,判断字符串内容是否相同。


    2.2.5:判断字符串内容是否相同,忽略大小写。


            boolean equalsIgnoreCase(string) ;


2.3 转换:


    2.3.1:通过构造函数可以将字符数组或者字节数组转成字符串。


    2.3.2:可以通过字符串中的静态方法,将字符数组转成字符串。


            static String copyValueOf(char[] );

            static String copyValueOf(char[],int offset,int count);

            static String valueOf(char[]);

            static String valueOf(char[],int offset,int count);


    2.3.3:将基本数据类型或者对象转成字符串。


            static String valueOf(char);

            static String valueOf(boolean);

            static String valueOf(double);

            static String valueOf(float);

            static String valueOf(int);

            static String valueOf(long);

            static String valueOf(Object);


    2.3.4:将字符串转成大小写。


            String toLowerCase();


            String toUpperCase();


    2.3.5:将字符串转成数组。


            char[] toCharArray();//转成字符数组。


            byte[] getBytes();//可以加入编码表。转成字节数组。


    2.3.6:将字符串转成字符串数组。切割方法。


            String[] split(分割的规则-字符串);


    2.3.7:将字符串进行内容替换。注意:修改后变成新字符串,并不是将原字符串直接修改。


            String replace(oldChar,newChar);


            String replace(oldstring,newstring);


    2.3.8: String concat(string); //对字符串进行追加。


            String trim();//去除字符串两端的空格


    int compareTo();//如果参数字符串等于此字符串,则返回值 0;如果此字符串按字典顺序小于字符串参数,则返回一个小于 0 的值;如果此字符串按字典顺序大于字符串参数,则返回一个大于 0 的值。


3.StringBuffer 字符串缓冲区:


构造一个其中不带字符的字符串缓冲区,初始容量为 16 个字符。


特点:


1 :可以对字符串内容进行修改。


2 :是一个容器。


3 :是可变长度的。


4 :缓冲区中可以存储任意类型的数据。


5 :最终需要变成字符串。


容器通常具备一些固定的方法:


1 ,添加。


    StringBuffer append(data):在缓冲区中追加数据。追加到尾部。


    StringBuffer insert(index,data):在指定位置插入数据。


2 ,删除。


    StringBuffer delete(start,end);删除从start至end-1范围的元素


    StringBuffer deleteCharAt(index);删除指定位置的元素


//sb.delete(0,sb.length());//清空缓冲区。


3 ,修改。


     StringBuffer replace(start,end,string);将start至end-1替换成string


    void setCharAt(index,char);替换指定位置的字符


    void setLength(len);将原字符串置为指定长度的字符串


4 ,查找。 (查不到返回-1)


    int indexOf(string); 返回指定子字符串在此字符串中第一次出现处的索引。

    int indexOf(string,int fromIndex);从指定位置开始查找字符串

    int lastIndexOf(string); 返回指定子字符串在此字符串中最右边出现处的索引。

    int lastIndexOf(string,int fromIndex); 从指定的索引开始反向搜索


5,获取子串。


    string substring(start); 返回start到结尾的子串


    string substring(start,end); 返回start至end-1的子串


6 ,反转。


    StringBuffer reverse();字符串反转


4. StringBuilder 字符串缓冲区:


JDK1.5 出现StringBuiler; 构造一个其中不带字符的字符串生成器,初始容量为 16 个字符。该类被设计用作 StringBuffer 的一个简易替换,用在字符串缓冲区被单个线程使用的时候(这种情况很普遍)。


方法和StringBuffer一样;


5.StringBuffer 和 StringBuilder 的区别:


StringBuffer 线程安全。


StringBuilder 线程不安全。


单线程操作,使用StringBuilder 效率高。


多线程操作,使用StringBuffer 安全。


        StringBuilder sb = new StringBuilder("abcdefg");

        sb.append("ak");  //abcdefgak

        sb.insert(1,"et");//aetbcdefg

        sb.deleteCharAt(2);//abdefg

        sb.delete(2,4);//abefg

        sb.setLength(4);//abcd

        sb.setCharAt(0,'k');//kbcdefg

        sb.replace(0,2,"hhhh");//hhhhcdefg
//想要使用缓冲区,先要建立对象。

        StringBuffer sb = new StringBuffer();     

        sb.append(12).append("haha");//方法调用链。

        String s = "abc"+4+'q';

        s = new StringBuffer().append("abc").append(4).append('q').toString();


class  Test{

    public static void main(String[] args) {

        String s1 = "java";

        String s2 = "hello";

        method_1(s1,s2);

        System.out.println(s1+"...."+s2); //java....hello

       

        StringBuilder s11 = new StringBuilder("java");

        StringBuilder s22 = new StringBuilder("hello");

        method_2(s11,s22);

        System.out.println(s11+"-----"+s22); //javahello-----hello

    }

    public static void method_1(String s1,String s2){

        s1.replace('a','k');

        s1 = s2;

    }

    public static void method_2(StringBuilder s1,StringBuilder s2){

        s1.append(s2);

        s1 = s2;

    }

}

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

完蛋,公司被一条 update 语句干趴了!

sql
大家好,我是小林。 昨晚在群划水的时候,看到有位读者说了这么一件事。 大概就是,在线上执行一条 update 语句修改数据库数据的时候,where 条件没有带上索引,导致业务直接崩了,被老板教训了一波 这次我们就来看看: 为什么会发生这种的事故? 又该如何...
继续阅读 »

大家好,我是小林。


昨晚在群划水的时候,看到有位读者说了这么一件事。


在这里插入图片描述


大概就是,在线上执行一条 update 语句修改数据库数据的时候,where 条件没有带上索引,导致业务直接崩了,被老板教训了一波


这次我们就来看看:



  • 为什么会发生这种的事故?

  • 又该如何避免这种事故的发生?


说个前提,接下来说的案例都是基于 InnoDB 存储引擎,且事务的隔离级别是可重复读。


为什么会发生这种的事故?


InnoDB 存储引擎的默认事务隔离级别是「可重复读」,但是在这个隔离级别下,在多个事务并发的时候,会出现幻读的问题,所谓的幻读是指在同一事务下,连续执行两次同样的查询语句,第二次的查询语句可能会返回之前不存在的行。


因此 InnoDB 存储引擎自己实现了行锁,通过 next-key 锁(记录锁和间隙锁的组合)来锁住记录本身和记录之间的“间隙”,防止其他事务在这个记录之间插入新的记录,从而避免了幻读现象。


当我们执行 update 语句时,实际上是会对记录加独占锁(X 锁)的,如果其他事务对持有独占锁的记录进行修改时是会被阻塞的。另外,这个锁并不是执行完 update 语句就会释放的,而是会等事务结束时才会释放。


在 InnoDB 事务中,对记录加锁带基本单位是 next-key 锁,但是会因为一些条件会退化成间隙锁,或者记录锁。加锁的位置准确的说,锁是加在索引上的而非行上。


比如,在 update 语句的 where 条件使用了唯一索引,那么 next-key 锁会退化成记录锁,也就是只会给一行记录加锁。


这里举个例子,这里有一张数据库表,其中 id 为主键索引。



假设有两个事务的执行顺序如下:


在这里插入图片描述


可以看到,事务 A 的 update 语句中 where 是等值查询,并且 id 是唯一索引,所以只会对 id = 1 这条记录加锁,因此,事务 B 的更新操作并不会阻塞。


但是,在 update 语句的 where 条件没有使用索引,就会全表扫描,于是就会对所有记录加上 next-key 锁(记录锁 + 间隙锁),相当于把整个表锁住了


假设有两个事务的执行顺序如下:



可以看到,这次事务 B 的 update 语句被阻塞了。


这是因为事务 A的 update 语句中 where 条件没有索引列,所有记录都会被加锁,也就是这条 update 语句产生了 4 个记录锁和 5 个间隙锁,相当于锁住了全表。



因此,当在数据量非常大的数据库表执行 update 语句时,如果没有使用索引,就会给全表的加上 next-key 锁, 那么锁就会持续很长一段时间,直到事务结束,而这期间除了 select ... from 语句,其他语句都会被锁住不能执行,业务会因此停滞,接下来等着你的,就是老板的挨骂。


那 update 语句的 where 带上索引就能避免全表记录加锁了吗?


并不是。


关键还得看这条语句在执行过程种,优化器最终选择的是索引扫描,还是全表扫描,如果走了全表扫描,就会对全表的记录加锁了


又该如何避免这种事故的发生?


我们可以将 MySQL 里的 sql_safe_updates 参数设置为 1,开启安全更新模式。



官方的解释:



If set to 1, MySQL aborts UPDATE or DELETE statements that do not use a key in the WHERE clause or a LIMIT clause. (Specifically, UPDATE statements must have a WHERE clause that uses a key or a LIMIT clause, or both. DELETE statements must have both.) This makes it possible to catch UPDATE or DELETE statements where keys are not used properly and that would probably change or delete a large number of rows. The default value is 0.


大致的意思是,当 sql_safe_updates 设置为 1 时。


update 语句必须满足如下条件之一才能执行成功:



  • 使用 where,并且 where 条件中必须有索引列;

  • 使用 limit;

  • 同时使用 where 和 limit,此时 where 条件中可以没有索引列;


delete 语句必须满足如下条件之一才能执行成功:



  • 使用 where,并且 where 条件中必须有索引列;

  • 同时使用 where 和 limit,此时 where 条件中可以没有索引列;


如果 where 条件带上了索引列,但是优化器最终扫描选择的是全表,而不是索引的话,我们可以使用 force index([index_name]) 可以告诉优化器使用哪个索引,以此避免有几率锁全表带来的隐患。


总结


不要小看一条 update 语句,在生产机上使用不当可能会导致业务停滞,甚至崩溃。


当我们要执行 update 语句的时候,确保 where 条件中带上了索引列,并且在测试机确认该语句是否走的是索引扫描,防止因为扫描全表,而对表中的所有记录加上锁。


我们可以打开 MySQL sql_safe_updates 参数,这样可以预防 update 操作时 where 条件没有带上索引列。


如果发现即使在 where 条件中带上了列索引列,优化器走的还是全标扫描,这时我们就要使用 force index([index_name]) 可以告诉优化器使用哪个索引。


这次就说到这啦,下次要小心点,别再被老板挨骂啦。


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

庆祝神舟十三号发射成功,来一个火箭发射动画

前言 北京时间10月16日0时23分,神舟十三号飞船成功发射,目前三名航天员已经顺利进驻空间站,开始为期6个月的“太空差旅”生活。 国家的航天技术的突飞猛进也让岛上码农很自豪,今天看 Flutter 的动画知识,看到了 AnimatedPositioned ...
继续阅读 »

前言


北京时间10月16日0时23分,神舟十三号飞船成功发射,目前三名航天员已经顺利进驻空间站,开始为期6个月的“太空差旅”生活。
image.png
国家的航天技术的突飞猛进也让岛上码农很自豪,今天看 Flutter 的动画知识,看到了 AnimatedPositioned 这个组件,可以用于控制组件的相对位置移动。结合这个神舟十三号的发射,灵机一动,正好可以使用AnimatedPositioned 这个组件实现火箭发射动画。话不多说,先上效果!
火箭发射动画.gif


效果说明


这里其实是两张图片叠加,一张是背景地球星空的背景图,一张是火箭。火箭在发射过程中有两个变化:



  • 高度越来越高,其实就是相对图片背景图底部的位置越来越大就可以实现;

  • 尺寸越来越小,这个可以控制整个组件的尺寸实现。


然后是动画取消的选择,火箭的速度是越来越快,试了几个 Flutter 自带的曲线,发现 easeInCubic 这个效果挺不错的,开始慢,后面越来越快,和火箭发射的过程是类似的。


AnimatedPositioned介绍


AnimatedPositioned组件的使用方式其实和 AnimatedContainer 类似。只是AnimatedPositionedPositioned 组件的替代。构造方法定义如下:


const AnimatedPositioned({
Key? key,
required this.child,
this.left,
this.top,
this.right,
this.bottom,
this.width,
this.height,
Curve curve = Curves.linear,
required Duration duration,
VoidCallback? onEnd,
})

前面的参数和 Positioned 一样,后面是动画控制参数,这些参数的定义和 AnimatedContainer 的是一样的:



  • curve:动画效果曲线;

  • duration:动画时长;

  • onEnd:动画结束后回调。


我们可以改变 lefttopwidth等参数来实现动画过渡的效果。比如我们的火箭发射,就是修改 bottom (飞行高度控制)和 width (尺寸大小控制)来实现的。


火箭发射动画实现


有了上面的两个分析,火箭发射动画就简单了!完整代码如下:


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

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

class _RocketLaunchState extends State<RocketLaunch> {
var rocketBottom = -80.0;
var rocketWidth = 160.0;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('火箭发射'),
brightness: Brightness.dark,
backgroundColor: Colors.black,
),
backgroundColor: Colors.black,
body: Center(
child: Stack(
alignment: Alignment.bottomCenter,
children: [
Image.asset(
'images/earth.jpeg',
height: double.infinity,
fit: BoxFit.fill,
),
AnimatedPositioned(
child: Image.asset(
'images/rocket.png',
fit: BoxFit.fitWidth,
),
bottom: rocketBottom,
width: rocketWidth,
duration: Duration(seconds: 5),
curve: Curves.easeInCubic,
),
],
),
),
floatingActionButton: FloatingActionButton(
child: Text(
'发射',
style: TextStyle(
color: Colors.white,
),
textAlign: TextAlign.center,
),
onPressed: () {
setState(() {
rocketBottom = MediaQuery.of(context).size.height;
rocketWidth = 40.0;
});
},
),
);
}
}

其中一开始设置 bottom 为负值,是为了隐藏火箭的焰火,这样会更有感觉一些。然后就是在点击发射按钮的时候,通过 setState 更改底部距离和火箭尺寸就可以搞定了。


总结


通过神舟十三飞船发射,来一个火箭动画是不是挺有趣?其实这篇主要的知识点还是介绍 AnimatedPositioned 的应用。通过 AnimatedPositioned可以实现很多层叠组件的相对移动变化效果,比如进度条的滑块,滑动开关等。各位 Flutter 玩家也可以利用 AnimatedPositioned 这个组件自己来玩一下好玩的动画效果哦!


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

复习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
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

移动端网络监控实践

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
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

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 发送到显示器,因此我没有包含该部分。


收起阅读 »

学不好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
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

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


收起阅读 »

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


收起阅读 »

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

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

介绍


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


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


安装包监控


Android Studio 的 APK Analyser


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





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



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



Matrix中 的 ApkChecker(传送)


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


安装包优化实践


Lint查找无用文件


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



选中整个项目,如下所示



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



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



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


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


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


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


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

未启用



启用后



文件优化


图片优化


降低图片bit



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


使用 VectorDrawable 图片


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


Android Studio 中点击 File > New > Vector Asset



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


使用 tint 着色器


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



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


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



使用 webp 格式图片


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


选中图片>右键>选择




图片由.png转为.webp


三方库优化


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


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


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

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

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


关于 App Bundle


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


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


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



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


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


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

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

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

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


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


什么是 Handler 使用不当?


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


一般具备这么几个特征:



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


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

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

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


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

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

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




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

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

为什么会内存泄露?


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




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


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





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


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


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


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


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

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





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


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


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


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

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


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


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

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

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


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


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


如何正确使用 Handler?


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


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



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


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

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


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

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



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




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


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



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


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





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


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


比如这样:


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

仍然可能造成内存泄漏。


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



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


结语


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



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

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

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

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

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

Kotlin开发中的一些Tips

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

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


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


1.字符串拼接


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


一个简单的例子:


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

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

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


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

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


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


2.lazy


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


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

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


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

3.companion object


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


class CompanionTest {

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

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

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

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


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


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


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


4.inline


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


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


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

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


UtilsKt.add(1, 4);

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


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

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


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

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


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


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


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

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

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


然后看一下字节码:


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

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


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



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



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


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


5. Gson解析


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


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





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


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


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


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


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

flutter 风车加载指示组件

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

前言

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

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

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

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

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

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

风车加载组件

接口定义

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

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

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

实现思路

风车绘制

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

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

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

旋转效果

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

代码实现

WindmillIndicator定义

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

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

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

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

旋转速度设定

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

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

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

controller.repeat();
}

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

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

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

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

风车叶片绘制

风车叶片代码定义如下:

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

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

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

共接收三个参数:

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

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

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

return path;
}

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

风车组件

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

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

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

运行效果

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

速度0.5

速度1

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

总结

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

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


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

收起阅读 »

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

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

一 前言


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


二 callback代码同步化



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



2.1 callback代码同步化


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


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

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

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

onNext(id + value)

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


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

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

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

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


三 suspend解密



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



3.1 suspend函数解体


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


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



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


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



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


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

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



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



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



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

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

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


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


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


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


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

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


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

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

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

val result = continuation.result
val suspended = COROUTINE_SUSPENDED

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

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

3.2 执行流程


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



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

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

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


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


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


image.png



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


image.png



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


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


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

java中的IO、NIO、Okio

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

java IO

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

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

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

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

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

带缓存的读

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

带缓存的写

注意需要

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

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

带缓存的读写

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

IO在网络中实现通信

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

NIO

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

对比IO:

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

流和缓存

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

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

阻塞非阻塞

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

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

Selector

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

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

读例子

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

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

NIO在网络中实现通信

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

Okio

Okio的优势

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

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

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

Segment

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

Segment的数据结构如下:

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

Okio的依赖链接

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

Android+Okio实例

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

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

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

小结

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

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

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


收起阅读 »