注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

Android资源管理及资源的编译和打包过程分析

前言在工作和学习中,我们除了要写一些业务代码,还要对项目的编译和打包流程有一定的认识,才能在遇到相关问题的时候能有所头绪。在这个过程中,我们往往会忽略掉资源文件是如何被添加进去的,Android的资源管理框架是一个很庞大和复杂的框架,资源编译打包的过程也很复杂...
继续阅读 »

前言

在工作和学习中,我们除了要写一些业务代码,还要对项目的编译和打包流程有一定的认识,才能在遇到相关问题的时候能有所头绪。在这个过程中,我们往往会忽略掉资源文件是如何被添加进去的,Android的资源管理框架是一个很庞大和复杂的框架,资源编译打包的过程也很复杂和繁琐,本文就来浅谈一下Android的资源文件是如何编译和打包的吧,除了当做一个自我总结,也希望能对看到本文的你有所帮助和启发。当然了文章比较长,希望你能耐心的看完。

编译打包流程

Android一个包中,除了代码以外,还有很多的资源文件,这些资源文件在apk打包的过程中,通过AAPT工具,打包到apk中。我们首先看一下apk的打包流程图,

image.png

概述一下这张图,打包主要有一下几个步骤:

  • 打包资源文件:通过aapt工具将res目录下的文件打包生成R.java文件和resources.arsc资源文件,比如AndroidManifest.xml和xml布局文件等。
  • 处理aidl files:如果有aidl接口,通过aidl工具打包成java接口类
  • java Compiler:javac编译,将R.java,源码文件,aidl.java编译为class文件
  • dex:源码.class,第三方jar包等class文件通过dx工具生成dex文件
  • apkbuilder:apkbuilder将所有的资源编译过的和不需要编译的,dex文件,arsc资源文件打包成一个完整的apk文件
  • jarsigner:以上生成的是一个没有签名的apk文件,这里通过jarsigner工具对该apk进行签名,从而得到一个带签名的apk文件
  • zipalign:对齐,将apk包中所有的资源文件距离文件起始偏移为4的整数倍,这样运行时可以减少内存的开销

资源分类

asset目录

存放原始资源文件,系统在编译时不会编译该目录下的资源文件,所以不能通过id的方式访问,如果要访问这些文件,需要指定文件名来访问。可以通过AssetManager访问原始文件,它允许你以简单的字节流的形式打开和读取和应用程序绑定在一起的原始资源文件。以下是一个从assets中读取本地的json文件的实例:

        StringBuilder sb = new StringBuilder();
AssetManager assets = getAssets();
try {
InputStream open = assets.open(“xxx.json”);
//使用一个转换流转换为字符流进行读取
InputStreamReader inputStreamReader = new InputStreamReader(open);
//缓冲字符流
BufferedReader reader = new BufferedReader(inputStreamReader);
String readLine;
while((readLine = reader.readLine())!=null){
sb.append(readLine);
}
String s = sb.toString();
} catch (IOException e) {
e.printStackTrace();
}
复制代码

来看看一般项目中asset目录下会放些什么东东吧

image.png

res目录

存放可编译的资源文件(raw除外),编译时,系统会自动在R.java文件中生成资源文件的id,访问这种资源可以通过R.xxx.id即可。

目录资源类型
animator/用于定义属性动画的xml
anim/用于定义补间动画的xml(属性动画也可以在这里定义)
color/用于颜色状态列表的xml
drawable/位图文件(.9.png、.png、.jpg、.gif)
mipmap/适用于不同启动器图标密度的可绘制对象文件
layout/用于定义用户界面布局的 XML 文件
menu/用于定义应用菜单(如选项菜单、上下文菜单或子菜单)的 XML 文件
values/包含字符串、整型数和颜色等简单值的 XML 文件
XML/可在运行时通过调用 Resources.getXML() 读取的任意 XML 文件。各种 XML 配置文件(如可搜索配置)都必须保存在此处。
font/带有扩展名的字体文件(如 .ttf、.otf 或 .ttc),或包含 元素的 XML 文件
raw/需以原始形式保存的任意文件

编译资源文件的结果

好处

对资源进行编译有以下两点好处

  • 空间占用小:二进制xml文件占用的空间更小,因为所有的xml文件的标签、属性名称、属性值和内容所涉及到的字符串都会被统一收集到一个字符串池中。有了这个字符串池,原来使用字符串的地方就可以使用一个整数索引代替,从而可以减少文件的大小
  • 解析速度快:二进制的xml文件解析的速度更快,xml文件中不在包含字符串值,所以就省去了解析字符串的时间,从而提高了速度。

编译完成之后,除了assets资源之外,会给其他所有的资源生成一个id,根据这些id,打包工具会生成一个资源索引表resources.arsc以及R.java文件。资源索引表会记录所有资源的信息,根据资源id和设备信息,快速的匹配最合适的资源,R文件则记录各个资源的id常量。

生成资源索引表

首先来看一张图,这是resources.arsc的结构图 20160623160331859.png

整个resources.arsc是由一系列的chunk组成的,每一个chunk都有一个头,用来描述chunk的元数据。

  • header:每个chunk的头部用来描述该chunk的元信息,包括当前chunk的类型,头大小,块大小等
  • Global String Pool:全局字符串池,将所有字符串放到这个池子中,大家都复用这个池子中的数据,什么样的字符串会放到这个池子中呢?所有资源的文件的路径名,以及资源文件中所定义的资源的值,所以这个池子也可以叫做资源项的值字符串资源池,包含了所有在资源包里定义的资源项的值字符串,比如下面代码中ABC就存放在这里
  • package数据块:
    • package header:记录包的元数据,包名、大小、类型等
    • 资源类型字符串池:存储所有类型相关的字符串,如:attr、drawable、layout、anim等
    • 资源项名称字符串池:存储应用所有资源文件中资源项名称相关的字符串,比如下边的app_name就存放在这里。
    • Type Spec:类型规范数据块,用来描述资源项的配置差异性,通过这个差异性描述,我们就可以知道每一个资源项的配置状况。Android设备众多,为了使得应用程序支持不同的大小、密度、语言,Android将资源组织为18个维度,每一个资源类都对应一组配置列表,配置这个资源类的不同维度,最后再使用一套匹配算法来为应用程序在资源目录中选择最合适的资源。
    • config list:上边说到,每个type spec是一个类型的描述,每个类型会有多个维度,config list就是由多个ResTable_type结构来描述的,每一个ResTable_type描述的就是一个维度。
 <resources>    
    <string name="app_name">ABC</string>    
</resources>
复制代码

生成R文件和资源id

image.png

首先看一下R文件的结构图,每一种资源文件都对应一个静态内部类,对照前面所说的res文件目录结构,其中每个静态内部类中的一个静态常量分别定义一条资源标识符

image.png

或者这样:

    public static final class layout {
        public static final int main=0x7f030000;
    }
复制代码

public static final int main=0x7f030000;就表示layout目录下的main.xml文件。id中最高字节代表package的id,次高字节代表type的id,最后的字节代表当前类型中出现的序号。

  • package id:相当于一个命名空间,限定资源的来源,Android系统当前定义了两个资源命令空间,其中系统资源命令空间是0x01,另外一个应用程序资源命令空间为0x7f,所有位于 0x01到0x7f 之间的packageid都是合法的。
  • type id:指资源的类型id,如anim、color、layout、raw...等,每一种资源都对应一个type id
  • entry id:指每一个资源在其所属资源类型中出现的次序,不同资源类型的entry id是有可能相同的,但是由于他们的type id不同,所以一样可以进行区分。

资源文件只能以小写字母和下划线作为首字母,随后的名字中只能出现a-z或者0-9或者_.这些字符,否则会报错。

当我们在相应的res的资源目录中添加资源文件时,便会在相应的R文件中的静态内部类中自动生成一条静态的常量,对添加的文件进行索引。

在布局文件中当我们需要为组件添加id属性时,可以使用@+id/idname,+表示在R文件的名为id的内部类中添加一条记录。如果这个id不存在,则会首先生成它。

资源文件打包流程

说完了资源文件的一些基本信息以后,相信你对apk包内的资源文件有了一个更加明确的认识了吧,接下来我们就来讲一讲资源文件是如何打包到apk中的,这个过程非常复杂,需要好好的理解和记忆。

Android资源打包工具在编译应用程序资源之前,会创建资源表ResourceTable,当应用程序资源编译完之后,这个资源表就包含了资源的所有信息,然后就可以根据这个资源表来生成资源索引文件resources.arsc了。

解析AndroidManifest.xml

获取要编译资源的应用程序的包名、minSdkVersion等,有了包名就可以创建资源表了,也就是ResourceTable。

添加被引用的资源包

通常在编译一个apk包的时候,至少会涉及到两个资源包,一个是被引用的系统资源包,里面有很多系统级的资源,比如我们熟知的四大布局 LinearLayout、FrameLayout等以及一些属性layout_width、layout_height、layout_oritation等,另一个就是当前正在编译的应用程序的资源包。

收集资源文件

在编译应用程序资源之前,aapt会创建AaptAssets对象,用来收集当前需要编译的资源文件,这些资源文件被保存在AaptAssets类的成员变量mRes中。

将收集到的资源增加到资源表ResourceTable

之前将资源添加到了AaptAssets中,这一步将资源添加到ResourceTable中,我们最后要根据这个资源表来生成resources.arsc资源索引表,回头看看arsc文件的结构图,它也有一个resourceTable。

这一步收集到资源表的资源是不包括values的,因为values资源需要经过编译后,才能添加到资源表中

编译values资源

values资源描述的是一些比较简单的轻量级资源,如strings/colors/dimen等,这些资源是在编译的过程中进行收集的

给bag资源分配id

values资源下,除了string之外,还有其他的一些特殊资源,这些资源给自己定义一些专用的值,比如LinearLayout的orientation属性,它的取值范围为 vertical 和 horizontal,这就相当于定义了vertical和horizontal两个bag。

在编译其他非values资源之前,我们需要给之前收集到的bag资源分配资源id,因为它可能会被其它非values类资源所引用。

编译xml文件

之前的六步为编译xml文件做好了准备,收集到了xml所需要用到的所有资源,现在可以开始编译xml文件了,比如layout、anims、animators等。编译xml文件又可以分为四个步骤

解析xml文件

这一步会将xml文件转化为一系列树形结构的XMLNode,每一个XMLNode都表示一个xml元素,解析完成之后,就可以得到一个根节点XMLNode,然后就可以根据这个根节点来完成下边的操作

赋予属性名称id

这一步为每个xml元素的属性名称都赋予资源id,比如一个控件TextView,它有layout_width和layout_height两个属性,这里就要给这些属性名称赋予一个资源id。对系统资源包来说,这些属性名称都是它定义好的一些列bag资源,在编译的时候,就已经分配好了资源id了。

对于每一个xml文件都是从根节点开始给属性名称赋予资源id,然后再递归的给每一个子节点属性名称赋予资源id,一直到每一个节点的属性名称都有了资源id为止。

解析属性值

这一步是上一步的进一步深化,上一步为每个属性赋值id,这一步对属性对应的值进行解析,比如对于刚才的TextView,就会对其width和height的值进行解析,可能是match_parent也可能是warp_content.

压平xml文件

将xml文件进行扁平化处理,将其变为二进制格式,有如下几个步骤

  1. 收集有资源id的属性名称字符串,并将它们放在一个数组里。这些收集到的属性名称字符串保存在字符串资源池中,与收集到的资源id数组是一一对应的。
  2. 收集xml文件中其他所有的字符串,也就是没有资源id的字符串
  3. 写入xml文件头,最终编译出来的xml二进制文件是一系列的chunk组成的,每一个chunk都有一个头部,来描述元信息。
  4. 写入资源池字符串,将第一步和第二步收集到的内容写入Global String pool中,也就是之前所说的arsc文件结构里的全局字符串资源池中
  5. 写入资源id,将所有的资源id收集起来,生成package时要用到,对应arsc文件的结构的package。
  6. 压平xml文件,就是将各个xml元素中的字符串都替换掉,这些字符串或者被替换为到字符串资源池的一个索引,或者被替换为一个具有类型的其他值

给资源生成资源符号

这里生成资源符号为之后生成R文件做准备,之前的操作将所有收集到的资源文件都按照类型保存在资源表中,也就是ResourceTable对象。aapt在这里只需要遍历每一个package里面的type,然后取出每一个entry的名称,在根据其在相应的type中出现的次序,就可以计算出相应的资源id了,然后就能得到其资源符号。资源符号=名称+资源id

根据资源id生成资源索引表

在这里我们将生成resources.arsc,对其生成的步骤再次进行拆解

  1. 按照package收集类型字符串,如drawable、string、layout、id等,当前被编译的应用程序有几个package,就对应几组类型字符串,每一组类型字符串保存在其所属的package中。
  2. 收集资源型名称字符串,还是以package为单位,比如在string.xml中,<resources>    <string name="app_name">ABC</string>  </resources>就可以收集其中的属性app_name
  3. 收集资源项值字符串,还是上面的string.xml就可以收集到ABC
  4. 生成package数据块,就是按照之前说的resources.arsc文件格式中package的格式进行一步步的解析和收集
  5. 写入资源索引表头部,也就是ResTable_header
  6. 写入资源项的值字符串资源池,上面的第3步,将所有的值字符串收集起来了,这里直接写入就好了
  7. 写入package数据块,将第4步收集到的package数据块写入到资源索引表中。

经过以上几步,资源项索引表resources.arsc就生成好了。

编译AndroidManifest.xml文件

经过以上的几个步骤,应用程序的所有资源就编译完成了,这里就将应用程序的配置文件AndroidManifest.xml也编译为二进制文件。

生成R文件

到这里,我们已经知道了所有的资源以及其对应的id,然后就可以愉快的写入到R文件了,根据不同的type写到不同的静态内部类中,就像之前所描述的R文件的格式那样。

打包到APK

所有的资源文件都编译以及生成完之后,就可以将其打包到apk中了

  • assets目录
  • res目录,除了values之外,因为values目录下的资源文件经过编译以后,已经直接写入到资源索引表中去了
  • 资源索引表resources.arsc
  • 除了资源文件之外的其他文件(dex、AndroidManifest.xml、签名信息等)

结语

终于捋完了,整个资源文件的编译打包过程真的是很复杂又很繁琐的一个过程,在阅读的过程中要时刻对照着那几张机构图才能更好地对这些文件有更清晰的认识。资源文件在Android的学习和工作中是非常重要的,很多时候这些知识会被忽略掉,但是如果有时间好好捋一捋这些知识对于自身是一个很大的提升。

画个流程图

最后再用一张流程图来回顾一个整个流程

image.png


收起阅读 »

Android高手笔记 - 网络优化

一文带你了解android中对注入框架的检测。(以下的检测来源于对某APP进行逆向分析得出的情况)1.检测栈信息2.检测包名信息public static boolean xp1(Context context) {         boolean scan...
继续阅读 »

一文带你了解android中对注入框架的检测。

(以下的检测来源于对某APP进行逆向分析得出的情况)

1.检测栈信息

image.png

2.检测包名信息

public static boolean xp1(Context context) {

        boolean scanPackage = scanPackage(context, new String(Base64.decode("ZGUucm9idi5hbmRyb2lkLnhwb3NlZC5pbnN0YWxsZXI=", 2)));

        MLog.b("attack", "Installed xposed:" + scanPackage);

        return scanPackage;

}

解密
ZGUucm9idi5hbmRyb2lkLnhwb3NlZC5pbnN0YWxsZXI= = de.robv.android.xposed.installer
 

 

 public static boolean xp2(Context context) {

        StackTraceElement[] stackTrace;

        context.getFilesDir();

        try {

            throw new Exception("凸一_一凸");

        } catch (Exception e) {

            MLog.a("attack", e.getMessage());

            boolean z = false;

            for (StackTraceElement stackTraceElement : e.getStackTrace()) {

                if (stackTraceElement.getClassName().equals(new String(Base64.decode("ZGUucm9idi5hbmRyb2lkLnhwb3NlZC5YcG9zZWRCcmlkZ2U=", 2))) && stackTraceElement.getMethodName().equals(new String(Base64.decode("bWFpbg==", 2)))) {

                    z = true;

                }

                if (stackTraceElement.getClassName().equals(new String(Base64.decode("ZGUucm9idi5hbmRyb2lkLnhwb3NlZC5YcG9zZWRCcmlkZ2U=", 2))) && stackTraceElement.getMethodName().equals(new String(Base64.decode("aGFuZGxlSG9va2VkTWV0aG9k", 2)))) {

                    z = true;

                }

            }

            MLog.b("attack", "Exception hit:" + z);

            return z;

        }

    }

 

解密:

ZGUucm9idi5hbmRyb2lkLnhwb3NlZC5YcG9zZWRCcmlkZ2U=de.robv.android.xposed.XposedBridge


aGFuZGxlSG9va2VkTWV0aG9k = handleHookedMethod

bWFpbg==main
 

 ```

```C++


  public static String xp3(Context context) {

        String str;

        context.getFilesDir();

        try {

            Field declaredField = DexAOPEntry.java_lang_ClassLoader_loadClass_proxy(ClassLoader.getSystemClassLoader(), new String(Base64.decode("ZGUucm9idi5hbmRyb2lkLnhwb3NlZC5YcG9zZWRIZWxwZXJz", 2))).getDeclaredField(new String(Base64.decode("ZmllbGRDYWNoZQ==", 2)));

            declaredField.setAccessible(true);

            Map map = (Map) declaredField.get(null);

            ArrayList arrayList = new ArrayList();

            arrayList.addAll(map.keySet());

            str = new JSONArray(arrayList).toString();

        } catch (Exception e) {

            str = null;

        }

        MLog.b("attack", "FieldInHook msg:" + str);

        return str;

    }


解密:

ZGUucm9idi5hbmRyb2lkLnhwb3NlZC5YcG9zZWRIZWxwZXJz =de.robv.android.xposed.XposedHelpers

ZmllbGRDYWNoZQ== fieldCache


 public static String xp4(Context context) {

        String str;

        context.getFilesDir();

        PackHookPlugin packHookPlugin = new PackHookPlugin(1);

        try {

            Field declaredField = DexAOPEntry.java_lang_ClassLoader_loadClass_proxy(ClassLoader.getSystemClassLoader(), new String(Base64.decode("ZGUucm9idi5hbmRyb2lkLnhwb3NlZC5YcG9zZWRCcmlkZ2U=", 2))).getDeclaredField(new String(Base64.decode("c0hvb2tlZE1ldGhvZENhbGxiYWNrcw==", 2)));

            declaredField.setAccessible(true);

            Map map = (Map) declaredField.get(null);

            Class java_lang_ClassLoader_loadClass_proxy = DexAOPEntry.java_lang_ClassLoader_loadClass_proxy(ClassLoader.getSystemClassLoader(), new String(Base64.decode("ZGUucm9idi5hbmRyb2lkLnhwb3NlZC5YcG9zZWRCcmlkZ2UkQ29weU9uV3JpdGVTb3J0ZWRTZXQ=", 2)));

            Method declaredMethod = java_lang_ClassLoader_loadClass_proxy.getDeclaredMethod(new String(Base64.decode("Z2V0U25hcHNob3Q=", 2)), new Class[0]);

            for (Entry entry : map.entrySet()) {

                Member member = (Member) entry.getKey();

                Object value = entry.getValue();

                String a = ScanMethod.a(member.toString());

                if (!"".equals(a) && java_lang_ClassLoader_loadClass_proxy.isInstance(value)) {

                    for (Object obj : (Object[]) declaredMethod.invoke(value, new Object[0])) {

                        String[] split = obj.getClass().getClassLoader().toString().split("\"");

                        if (split.length > 1) {

                            packHookPlugin.a(StringTool.a(split, 1), a);

                        }

                    }

                }

            }

            JSONArray a2 = packHookPlugin.a();

            JSONArray methodToNative = methodToNative();

            if (a2 != null) {

                if (methodToNative != null) {

                    for (int i = 0; i < methodToNative.length(); i++) {

                        a2.put(methodToNative.getJSONObject(i));

                    }

                }

                str = a2.toString();

            } else {

                if (methodToNative != null) {

                    str = methodToNative.toString();

                }

                str = null;

            }

        } catch (Exception e) {

        }

        MLog.b("attack", "MethodInHook msg:" + str);

        return str;

}

解密:

ZGUucm9idi5hbmRyb2lkLnhwb3NlZC5YcG9zZWRCcmlkZ2U=de.robv.android.xposed.XposedBridge

 

c0hvb2tlZE1ldGhvZENhbGxiYWNrcw== sHookedMethodCallbacks

ZGUucm9idi5hbmRyb2lkLnhwb3NlZC5YcG9zZWRCcmlkZ2UkQ29weU9uV3JpdGVTb3J0ZWRTZXQ= de.robv.android.xposed.XposedBridge$CopyOnWriteSortedSet

Z2V0U25hcHNob3Q=getSnapshot

 ```

```C++


 public static boolean xp5(Context context) {

        try {

            Throwable th = new Throwable();

            th.setStackTrace(new StackTraceElement[]{new StackTraceElement(new String(Base64.decode("U2NhbkF0dGFjaw==", 2)), "", "", 0), new StackTraceElement(new String(Base64.decode("ZGUucm9idi5hbmRyb2lkLnhwb3NlZC5YcG9zZWRCcmlkZ2U=", 2)), "", "", 0)});

            StackTraceElement[] stackTrace = th.getStackTrace();

            if (stackTrace.length != 2 || !stackTrace[1].getClassName().equals(new String(Base64.decode("ZGUucm9idi5hbmRyb2lkLnhwb3NlZC5YcG9zZWRCcmlkZ2U=", 2)))) {

                return true;

            }

            return false;

        } catch (Exception e) {

            return false;

        }

    }

解密:

U2NhbkF0dGFjaw== ScanAttack

ZGUucm9idi5hbmRyb2lkLnhwb3NlZC5YcG9zZWRCcmlkZ2U= de.robv.android.xposed.XposedBridge


    public static boolean xp6(Context context) {

        try {

            StringWriter stringWriter = new StringWriter();

            new Throwable().printStackTrace(new PrintWriter(stringWriter));

            if (stringWriter.toString().contains(new String(Base64.decode("ZGUucm9idi5hbmRyb2lkLnhwb3NlZA==", 2)))) {

                return true;

            }

            return false;

        } catch (Exception e) {

            return false;

        }

    }

解密:ZGUucm9idi5hbmRyb2lkLnhwb3NlZA==de.robv.android.xposed

收起阅读 »

Android基础到进阶UI祖宗级 View介绍+实用

View的继承关系在Android系统中,任何可视化控件都需要从android.view.View类继承。而任何从android.view.View继承的类都可以称为视图(View)。Android的绝大部分UI组件都放在android.widget包及其子包...
继续阅读 »

View的继承关系

在Android系统中,任何可视化控件都需要从android.view.View类继承。而任何从android.view.View继承的类都可以称为视图(View)。Android的绝大部分UI组件都放在android.widget包及其子包,下图就是android.widget包中所有View及其子类的继承关系:

从上图看,有很多布局类等为什么没有在上图看到,在这里要说明一下这里仅是android.widget包的,还有其他视图的虽然也继承View但是他们不属于android.widget包例如下面两个组件:

RecyclerView继承ViewGroup,但是属于androidx.recyclerview.widget包的。

ConstraintLayout继承ViewGroup,但是属于androidx.constraintlayout.widget包的;

其他还有很多其他包、或自定义控件,这里就不做过多描述了。

Android中的视图类可分为3种:布局(Layout)类视图容器(View Container)类视图类(例TextView),这3种类都是android.view.View的子类。ViewGroup是一个容器类,该类也是View的重要子类,所有的布局类和视图容器类都是ViewGroup的子类,而视图类直接继承自View类。 下图描述了View、ViewGroup、视图容器类及视图类的继承关系。

从上图所示的继承关系可以看出:

  • Button、TextView、EditText都是视图类,TextView是Button和EditText的父类,TextView直接继承自View类。
  • GridView和ListView是ViewGroup的子类,但并不是直接子类,GridView、ListView继承自AbsListView继承自AdapterView继承自ViewGroup,从而形成了视图容器类的层次结构。
  • 布局视图虽然也属于容器视图,但由于布局视图具有排版功能,所以将这类视图置为布局类

对于一个Android应用的图形用户界面来说,ViewGroup作为容器来装其他组件,而ViewGroup里除了可以包含普通View组件之外,还可以再次包含ViewGroup组件。

创建View对象

使用XML布局定义View,再用代码控制View

XML布局文件是Android系统中定义视图的常用方法,所有的XML布局文件必须保存在res/layout目录中。XML布局文件的命名及定义需要注意如下几点:

  • XML布局文件的扩展名必须是xml。
  • 由于aapt会根据每一个XML布局文件名在R类的内嵌类中生成一个int类型的变量,这个变量名就是XML布局文件名,因此,XML布局文件名(不包含扩展名)必须符合Java变量名的命名规则,例如,XML布局文件名不能以数字开头。
  • 每一个XML布局文件的根节点可以是任意的视图标签,如< LinearLayout >,< TextView >。
  • XML布局文件的根节点必须包含android命名空间,而且命名空间的值必须是android="schemas.android.com/apk/res/and…
  • 为XML布局文件中的标签指定ID时需要使用这样的格式:@+id/tv_xml,其实@+id就是在R.java文件里新增一个id名称,在同一个xml文件中确保ID唯一。
  • 由于每一个视图ID都会在R.id类中生成与之相对应的变量,因此,视图ID的值也要符合Java变量的命名规则,这一点与XML布局文件名的命名规则相同。

举例

1.创建activity_view.xml文件

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/ll_view"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@color/color_666666"
    android:orientation="vertical">
    <TextView
        android:id="@+id/tv_xml"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        android:layout_marginTop="@dimen/dimen_20"
        android:background="@color/color_188FFF"
        android:padding="@dimen/dimen_10"
        android:text="XML设置TextView"
        android:textColor="@color/white"
        android:textSize="@dimen/text_size_18" />
    <Button
        android:id="@+id/btn_xml"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        android:layout_marginTop="@dimen/dimen_20"
        android:background="@color/color_188FFF"
        android:padding="@dimen/dimen_10"
        android:text="按钮"
        android:textColor="@color/white"
        android:textSize="@dimen/text_size_18" />
</LinearLayout>

2.加载布局文件、关联控件

如果要使用上面的XML布局文件(activity_view.xml),通常需要在onCreate方法中使用setContentView方法指定XML布局文件的资源lD,并获取在activity_view.xml文件中定义的某个View,代码如下:

public class ViewActivity extends AppCompatActivity{
    private Button btnXml;
    private TextView tvXml;
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        //加载布局文件
        setContentView(R.layout.activity_view);
        //如果想获得在activity_view.xml文件中定义的某个View
        //关联控件:R.id.tv_xml是tvXml的ID,确保这个ID在R.layout.activity_view中
        tvXml = findViewById(R.id.tv_xml);
        //关联控件:R.id.btn_xml是btnXml的ID,确保这个ID在R.layout.activity_view中
        btnXml = findViewById(R.id.btn_xml);
    }
}

3.在获得XML布局文件中的视图对象时需要注意下面几点:

  • 先使用setContentView方法装载XML布局文件,再使用findViewByld方法,否则findViewByld方法会由于未找到控件而产生空指针异常,导致应用崩溃。

  • 虽然所有的XML布局文件中的视图ID都在R.id类中生成了相应的变量,但使用findViewByld方法只能获得已经装载的XML布局文件中的视图对象。

    • 例,activity_view.xml中TextView的对应R.id.tv_xml;
    • 其他XML文件中有TextView的R.id.tv_shuaiciid,tv_shuaici不在activity_view.xml中如果使用 tvXml = findViewById(R.id.tv_shuaici);
    • 结果应用崩溃。原因:在activity_view.xml中找不到ID为tv_shuaici的视图对象。

4.用代码控制视图

虽然使用XML布局文件可以非常方便地对控件进行布局,但若想控制这些控件的行为,仍然需要编写Java代码。在上面介绍了使用findViewByld方法获得指定的视图对象,当获得视图对象后,就可以使用代码来控制这些视图对象了。例如,下面的代码获得了一个TextView对象,并修改了TextView的文本。

TextView tvXml = findViewById(R.id.tv_xml);
//直接使用字符串来修改TextView的文本
tvXml.setText("帅次");
//使用字符串资源(res/values/strings.xml)
//其中R.string.str_tv_shuaici是字符串资源ID,系统会使用这个ID对应的字符串设置TextView的文本。
tvXml.setText(R.string.str_tv_shuaici);

选择其中一样即可,如果同时设置,最后一次设置为最终结果。

使用代码的方式来创建View对象

在更高级的Android应用中,往往需要动态添加视图。要实现这个功能,最重要的是获得当前的视图容器对象,这个容器对象所对应的类需要继承ViewGroup类。 将其他的视图添加到当前的容器视图中需要如下几步:

  • 第1步,获得当前的容器视图对象;
  • 第2步,获得或创建待添加的视图对象;
  • 第3步,将相应的视图对象添加到容器视图中。

实例

1.获得当前的容器视图对象

//1、获取activity_view.xml中LinearLayout对象
 //2、也可以给LinearLayout添加@+id/,然后通过findViewById关联控件也能获取LinearLayout对象
LinearLayout linearLayout =
        (LinearLayout)getLayoutInflater().inflate(R.layout.activity_view,null);
//加载布局文件
setContentView(linearLayout);

2.获得或创建待添加的视图对象

EditText editText = new EditText(this);
editText.setHint("请输入内容");

3.将相应的视图对象添加到容器视图中

@Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        //1、获取activity_view.xml中LinearLayout对象
        //2、也可以给LinearLayout添加@+id/,然后通过findViewById关联控件也能获取LinearLayout对象
        LinearLayout linearLayout =
                (LinearLayout)getLayoutInflater().inflate(R.layout.activity_view,null);
        //加载布局文件
        setContentView(linearLayout);
        EditText editText = new EditText(this);
        editText.setHint("请输入内容");
        linearLayout.addView(editText);
    }

效果图如下:

总结

  • 实际上不管使用那种方式,他们创建Android用户界面行为的本质是完全一样的。大部分时候,设置UI组件的XML属性还有对应的方法。
  • 对于View类而言,它是所有UI组件的基类,因此它包含的XML属性和方法是所有组件都可以使用的。

自定义View

为什么要自定义View

Android系统提供了一系列的原生控件,但这些原生控件并不能够满足我们的需求时,我们就需要自定义View了。

自定义View的基本方法

自定义View的最基本的三个方法分别是: onMeasure()、onLayout()、onDraw(); View在Activity中显示出来,要经历测量、布局和绘制三个步骤,分别对应三个动作:measure、layout和draw。

  • 测量:onMeasure()决定View的大小;
  • 布局:onLayout()决定View在ViewGroup中的位置;
  • 绘制:onDraw()决定绘制这个View。

需要用到的两个对象

  • Canvas(画布),可在画布上面绘制东西,绘制的内容取决于所调用的方法。如drawCircle方法,用来绘制圆形,需要我们传入圆心的x和y坐标,以及圆的半径。
  • Paint(画笔),用来告诉画布,如何绘制那些要被绘制的对象。

这两个方法暂时了解就行,如果拓展开,这不够写,后面可能会针对这两个对象单独拉一个章节出来。

自绘控件View实例

1、直接继承View类

自绘View控件时,最主要工作就是绘制出丰富的内容,这一过程是在重写的onDraw方法中实现的。由于是View,它没有子控件了,所以重写onLayout没有意义。onMeasure的方法可以根据自己的需要来决定是否需要重写,很多情况下,不重写该方法并不影响正常的绘制。

/**
 * 创建人:scc
 * 功能描述:自定义View
 */

public class CustomView extends View {
    private Paint paint;
    //从代码创建视图时使用的简单构造函数。
    public CustomView(Context context) {
        super(context);
    }
    //从XML使用视图时调用的构造函数。
    public CustomView(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
    }
    //View的绘制工作
    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        //实例化画笔对象
        paint = new Paint();
        //给画笔设置颜色
        paint.setColor(Color.RED);
        //设置画笔属性
        //paint.setStyle(Paint.Style.FILL);//画笔属性是实心圆
        paint.setStyle(Paint.Style.STROKE);//画笔属性是空心圆
        paint.setStrokeWidth(10);//设置画笔粗细
        //cx:圆心的x坐标;cy:圆心的y坐标;参数三:圆的半径;参数四:定义好的画笔
        canvas.drawCircle(getWidth() / 4, getHeight() / 4150, paint);
    }
}

2、在布局 XML 文件中使用自定义View

<com.scc.demo.view.CustomView
        android:id="@+id/view_circle"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"/>

3、实现效果:

性能优化

在自定义View时需要注意,避免犯以下的性能错误:

  • 在非必要时,对View进行重绘。
  • 绘制一些不被用户所看到的的像素,也就是过度绘制。(被覆盖的地方)
  • 在绘制期间做了一些非必要的操作,导致内存资源的消耗。

可进一步了解和优化:

  • View.invalite()是最最广泛的使用操作,因为在任何时候都是刷新和更新视图最快的方式。

在自定义View时要小心避免调用非必要的方法,因为这样会导致重复强行绘制整个视图层级,消耗宝贵的帧绘制周期。检查清楚View.invalite()和View.requestLayout()方法调用时间位置,因为这会影响整个UI,导致GPU和它的帧速率变慢。

  • 避免过渡重绘。为了避免过渡重绘,我们可以利用Canvas方法,只绘制控件中所需要的部分。整个一般在重叠部分或控件时特别有用。相应的方法是Canvas.clipRect()(指定要被绘制的区域);
  • 在实现View.onDraw()方法中,不应该在方法内及调用的方法中进行任何的对象分配。在该方法中进行对象分配,对象会被创建和初始化。而当View.onDraw()方法执行完毕时。垃圾回收器会释放内存。如果View带动画,那么View在一秒内会被重绘60次。所以要避免在View.onDraw()方法中分配内存。

永远不要在View.onDraw()方法中及调用的方法中进行内存分配,避免带来负担。垃圾回收器多次释放内存,会导致卡顿。最好的方式就是在View被首次创建出来时,实例化这些对象。

到这里View基本差不多了,还有其他属性、方法、事件等,在后面的TexView、Button、Layout等中慢慢了解。

收起阅读 »

iOS开发常见面试题(底层篇)

1.iOS 类(class)和结构体(struct)有什么区别?Swift 中,类是引用类型,结构体是值类型。值类型在传递和赋值时将进行复制,而引用类型则只会使用引用对象的一个"指向"。所以他们两者之间的区别就是两个类型的区别。举个简单的例子,代码如下clas...
继续阅读 »

1.iOS 类(class)和结构体(struct)有什么区别?

Swift 中,类是引用类型,结构体是值类型。值类型在传递和赋值时将进行复制,而引用类型则只会使用引用对象的一个"指向"。所以他们两者之间的区别就是两个类型的区别。

举个简单的例子,代码如下

class Temperature {
var value: Float = 37.0
}

class Person {
var temp: Temperature?

func sick() {
temp?.value = 41.0
}
}

let A = Person()
let B = Person()
let temp = Temperature()

A.temp = temp
B.temp = temp

A.sick() 上面这段代码,由于 Temperature 是 class ,为引用类型,故 A 的 temp 和 B 的 temp指向同一个对象。A 的 temp修改了,B 的 temp 也随之修改。这样 A 和 B 的 temp 的值都被改成了41.0。如果将 Temperature 改为 struct,为值类型,则 A 的 temp 修改不影响 B 的 temp。

内存中,引用类型诸如类是在堆(heap)上,而值类型诸如结构体实在栈(stack)上进行存储和操作。相比于栈上的操作,堆上的操作更加复杂耗时,所以苹果官方推荐使用结构体,这样可以提高 App 运行的效率。

class有这几个功能struct没有的:

class可以继承,这样子类可以使用父类的特性和方法 类型转换可以在runtime的时候检查和解释一个实例的类型 可以用deinit来释放资源 一个类可以被多次引用 struct也有这样几个优势:

结构较小,适用于复制操作,相比于一个class的实例被多次引用更加安全。 无须担心内存memory leak或者多线程冲突问题


2.iOS自动释放池是什么,如何工作 ?

当您向一个对象发送一个autorelease消息时,Cocoa就会将该对象的一个引用放入到最新的自动释放池。它仍然是个正当的对象,因此自动释放池定义的作用域内的其它对象可以向它发送消息。当程序执行到作用域结束的位置时,自动释放池就会被释放,池中的所有对象也就被释放。

1.object-c 是通过一种"referring counting"(引用计数)的方式来管理内存的, 对象在开始分配内存(alloc)的时候引用计数为一,以后每当碰到有copy,retain的时候引用计数都会加一, 每当碰到release和autorelease的时候引用计数就会减一,如果此对象的计数变为了0, 就会被系统销毁.

2.NSAutoreleasePool 就是用来做引用计数的管理工作的,这个东西一般不用你管的.

3.autorelease和release没什么区别,只是引用计数减一的时机不同而已,autorelease会在对象的使用真正结束的时候才做引用计数减一.

3.iOS你在项目中用过 runtime 吗?举个例子

Objective-C 语言是一门动态语言,编译器不需要关心接受消息的对象是何种类型,接收消息的对象问题也要在运行时处理。

pragramming 层面的 runtime 主要体现在以下几个方面:

1.关联对象 Associated Objects
2.消息发送 Messaging
3.消息转发 Message Forwarding
4.方法调配 Method Swizzling
5.“类对象” NSProxy Foundation | Apple Developer Documentation
6.KVC、KVO About Key-Value Coding

4.KVC /KVO的底层原理和使用场景

1 KVC(KeyValueCoding)
1.1 KVC 常用的方法

(1)赋值类方法
- (void)setValue:(nullable id)value forKey:(NSString *)key;
- (void)setValue:(nullable id)value forKeyPath:(NSString *)keyPath;
- (void)setValue:(nullable id)value forUndefinedKey:(NSString *)key;
- (void)setValuesForKeysWithDictionary:(NSDictionary<NSString *, id> *)keyedValues;

(2)取值类方法
// 能取得私有成员变量的值
- (id)valueForKey:(NSString *)key;
- (id)valueForKeyPath:(NSString *)keyPath;
- (NSDictionary *)dictionaryWithValuesForKeys:(NSArray *)keys;

1.2 KVC 底层实现原理

当一个对象调用setValue:forKey: 方法时,方法内部会做以下操作:
1.判断有没有指定key的set方法,如果有set方法,就会调用set方法,给该属性赋值
2.如果没有set方法,判断有没有跟key值相同且带有下划线的成员属性(_key).如果有,直接给该成员属性进行赋值
3.如果没有成员属性_key,判断有没有跟key相同名称的属性.如果有,直接给该属性进行赋值
4.如果都没有,就会调用 valueforUndefinedKey 和setValue:forUndefinedKey:方法
1.3 KVC 的使用场景
1.3.1 赋值
(1) KVC 简单属性赋值

Person *p = [[Person alloc] init];
// p.name = @"jack";
// p.money = 22.2;
使用setValue: forKey:方法能够给属性赋值,等价于直接给属性赋值
[p setValue:@"rose" forKey:@"name"];
[p setValue:@"22.2" forKey:@"money"];

(2) KVC复杂属性赋值

//给Person添加 Dog属性
Person *p = [[Person alloc] init];
p.dog = [[Dog alloc] init];
// p.dog.name = @"阿黄";

1)setValue: forKeyPath: 方法的使用
//修改p.dog 的name 属性
[p.dog setValue:@"wangcai" forKeyPath:@"name"];
[p setValue:@"阿花" forKeyPath:@"dog.name"];

2)setValue: forKey: 错误用法
[p setValue:@"阿花" forKey:@"dog.name"];
NSLog(@"%@", p.dog.name);

3)直接修改私有成员变量
[p setValue:@"旺财" forKeyPath:@"_name"];

(3) 添加私有成员变量

Person 类中添加私有成员变量_age
[p setValue:@"22" forKeyPath:@"_age"];

1.3.2 字典转模型

(1)简单的字典转模型
+(instancetype)videoWithDict:(NSDictionary *)dict
{
JLVideo *videItem = [[JLVideo alloc] init];
//以前
// videItem.name = dict[@"name"];
// videItem.money = [dict[@"money"] doubleValue] ;

//KVC,使用setValuesForKeysWithDictionary:方法,该方法默认根据字典中每个键值对,调用setValue:forKey方法
// 缺点:字典中的键值对必须与模型中的键值对完全对应,否则程序会崩溃
[videItem setValuesForKeysWithDictionary:dict];
return videItem;
}

(2)复杂的字典转模型
注意:复杂字典转模型不能直接通过KVC 赋值,KVC只能在简单字典中使用,比如:
NSDictionary *dict = @{
@"name" : @"jack",
@"money": @"22.2",
@"dog" : @{
@"name" : @"wangcai",
@"money": @"11.1",

}

};
JLPerson *p = [[JLPerson alloc]init]; // p是一个模型对象
[p setValuesForKeysWithDictionary:dict];
内部转换原理:
// [p setValue:@"jack" forKey:@"name"];
// [p setValue:@"22.2" forKey:@"money"];
// [p setValue:@{
// @"name" : @"wangcai",
// @"money": @"11.1",
//
// } forKey:@"dog"]; //给 dog赋值一个字典肯定是不对的

(3)KVC解析复杂字典的正确步骤
NSDictionary *dict = @{
@"name" : @"jack",
@"money": @"22.2",
@"dog" : @{
@"name" : @"wangcai",
@"price": @"11.1",
},
//人有好多书
@"books" : @[
@{
@"name" : @"5分钟突破iOS开发",
@"price" : @"19.8"
},
@{
@"name" : @"3分钟突破iOS开发",
@"price" : @"24.8"
},
@{
@"name" : @"1分钟突破iOS开发",
@"price" : @"29.8"
}
]
};

XMGPerson *p = [[XMGPerson alloc] init];
p.dog = [[XMGDog alloc] init];
[p.dog setValuesForKeysWithDictionary:dict[@"dog"]];

//保存模型的可变数组
NSMutableArray *arrayM = [NSMutableArray array];

for (NSDictionary *dict in dict[@"books"]) {
//创建模型
Book *book = [[Book alloc] init];
//KVC
[book setValuesForKeysWithDictionary:dict];
//将模型保存
[arrayM addObject:book];
}

p.books = arrayM;

备注:
(1)当字典中的键值对很复杂,不适合用KVC;
(2)服务器返还的数据,你可能不会全用上,如果在模型一个一个写属性非常麻烦,所以不建议使用KVC字典转模型

1.3.3 取值
(1) 模型转字典

 Person *p = [[Person alloc]init];
p.name = @"jack";
p.money = 11.1;
//KVC取值
NSLog(@"%@ %@", [p valueForKey:@"name"], [p valueForKey:@"money"]);

//模型转字典, 根据数组中的键获取到值,然后放到字典中
NSDictionary *dict = [p dictionaryWithValuesForKeys:@[@"name", @"money"]];
NSLog(@"%@", dict);

(2) 访问数组中元素的属性值

Book *book1 = [[Book alloc] init];
book1.name = @"5分钟突破iOS开发";
book1.price = 10.7;

Book *book2 = [[Book alloc] init];
book2.name = @"4分钟突破iOS开发";
book2.price = 109.7;

Book *book3 = [[Book alloc] init];
book3.name = @"1分钟突破iOS开发";
book3.price = 1580.7;

// 如果valueForKeyPath:方法的调用者是数组,那么就是去访问数组元素的属性值
// 取得books数组中所有Book对象的name属性值,放在一个新的数组中返回
NSArray *books = @[book1, book2, book3];
NSArray *names = [books valueForKeyPath:@"name"];
NSLog(@"%@", names);

//访问属性数组中元素的属性值
Person *p = [[Person alloc]init];
p.books = @[book1, book2, book3];
NSArray *names = [p valueForKeyPath:@"books.name"];
NSLog(@"%@", names);

2 KVO (Key Value Observing)
2.1 KVO 的底层实现原理

(1)KVO 是基于 runtime 机制实现的
(2)当一个对象(假设是person对象,对应的类为 JLperson)的属性值age发生改变时,系统会自动生成一个继承自JLperson的类NSKVONotifying_JLPerson,在这个类的 setAge 方法里面调用
[super setAge:age];
[self willChangeValueForKey:@"age"];
[self didChangeValueForKey:@"age"];
三个方法,而后面两个方法内部会主动调用
-(void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSString *,id> *)change context:(void *)context方法,在该方法中可以拿到属性改变前后的值.

2.2 KVO的作用
作用:能够监听某个对象属性值的改变

// 利用KVO监听p对象name 属性值的改变
Person *p = [[XMGPerson alloc] init];
p.name = @"jack";

/* 对象p添加一个观察者(监听器)
Observer:观察者(监听器)
KeyPath:属性名(需要监听哪个属性)
*/

[p addObserver:self forKeyPath:@"name" options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld context:@"123"];

/**
* 利用KVO 监听到对象属性值改变后,就会调用这个方法
*
* @param keyPath 哪一个属性被改了
* @param object 哪一个对象的属性被改了
* @param change 改成什么样了
*/

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSString *,id> *)change context:(void *)context
{
// NSKeyValueChangeNewKey == @"new"
NSString *new = change[NSKeyValueChangeNewKey];
// NSKeyValueChangeOldKey == @"old"
NSString *old = change[NSKeyValueChangeOldKey];

NSLog(@"%@-%@",new,old);
}

5.iOS中持久化方式有哪些?

属性列表文件 -- NSUserDefaults 的存储,实际是本地生成一个 plist 文件,将所需属性存储在 plist 文件中

对象归档 -- 本地创建文件并写入数据,文件类型不限

SQLite 数据库 -- 本地创建数据库文件,进行数据处理

CoreData -- 同数据库处理思想相同,但实现方式不同

6.什么是KVC和KVO?

KVC(Key-Value-Coding)内部的实现:一个对象在调用setValue的时候
(1)首先根据方法名找到运行方法的时候所需要的环境参数。
(2)他会从自己isa指针结合环境参数,找到具体的方法实现的接口。
(3)再直接查找得来的具体的方法实现。KVO(Key-Value- Observing):当观察者为一个对象的属性进行了注册,被观察对象的isa指针被修改的时候,isa指针就会指向一个中间类,而不是真实的类。所以 isa指针其实不需要指向实例对象真实的类。所以我们的程序最好不要依赖于isa指针。在调用类的方法的时候,最好要明确对象实例的类名

7.iOS中属性修饰符的作用?

ios5之前是MRC,内存需要程序员进行管理,ios5之后是ARC,除非特殊情况,比如C框架或者循环引用,其他时候是不需要程序员手动管理内存的。 ios中当我们定义属性@property的时候就需要属性修饰符,下面我们就看一下不同属性修饰符的作用。有错误和不足的地方还请大家谅解并批评指正。

主要的属性修饰符有下面几种:

  • copy
  • assign
  • retain
  • strong
  • weak
  • readwrite/readonly (读写策略、访问权限)
  • nonatomic/atomic (安全策略)

如果以MRC和ARC进行区分修饰符使用情况,可以按照如下方式进行分组:

 1. MRC: assign/ retain/ copy/  readwritereadonly/ nonatomic、atomic  等。
2. ARC: assign/ strong/ weak/ copy/ readwritereadonly/ nonatomic、atomic 等。

属性修饰符对retainCount计数的影响。

  1. alloc为对象分配内存,retainCount 为1 。
  2. retain MRC下 retainCount + 1。
  3. copy 一个对象变成新的对象,retainCount为 1, 原有的对象计数不变。
  4. release 对象的引用计数 -1。
  5. autorelease 对象的引用计数 retainCount - 1,如果为0,等到最近一个pool结束时释放。

不管MRC还是ARC,其实都是看reference count是否为0,如果为0那么该对象就被释放,不同的地方是MRC需要程序员自己主动去添加retain 和 release,而ARC apple已经给大家做好,自动的在合适的地方插入retain 和 release类似的内存管理代码,具体原理如下,图片摘自官方文档。



MRC 和 ARC原理

下面就详述上所列的几种属性修饰符的使用场景,应用举例和注意事项。

8.iOS atomatic nonatomic区别和理解

第一种

atomic和nonatomic区别用来决定编译器生成的getter和setter是否为原子操作。atomic提供多线程安全,是描述该变量是否支持多线程的同步访问,如果选择了atomic 那么就是说,系统会自动的创建lock锁,锁定变量。nonatomic禁止多线程,变量保护,提高性能。

atomic:默认是有该属性的,这个属性是为了保证程序在多线程情况下,编译器会自动生成一些互斥加锁代码,避免该变量的读写不同步问题。

nonatomic:如果该对象无需考虑多线程的情况,请加入这个属性,这样会让编译器少生成一些互斥加锁代码,可以提高效率。

atomic的意思就是setter/getter这个函数,是一个原语操作。如果有多个线程同时调用setter的话,不会出现某一个线程执行完setter全部语句之前,另一个线程开始执行setter情况,相当于函数头尾加了锁一样,可以保证数据的完整性。nonatomic不保证setter/getter的原语行,所以你可能会取到不完整的东西。因此,在多线程的环境下原子操作是非常必要的,否则有可能会引起错误的结果。

比如setter函数里面改变两个成员变量,如果你用nonatomic的话,getter可能会取到只更改了其中一个变量时候的状态,这样取到的东西会有问题,就是不完整的。当然如果不需要多线程支持的话,用nonatomic就够了,因为不涉及到线程锁的操作,所以它执行率相对快些。

下面是载录的网上一段加了atomic的例子:




{lock}
if (property != newValue) {
[property release];
property = [newValue retain];
}
{unlock}

可以看出来,用atomic会在多线程的设值取值时加锁,中间的执行层是处于被保护的一种状态,atomic是oc使用的一种线程保护技术,基本上来讲,就是防止在写入未完成的时候被另外一个线程读取,造成数据错误。而这种机制是耗费系统资源的,所以在iPhone这种小型设备上,如果没有使用多线程间的通讯编程,那么nonatomic是一个非常好的选择。

第二种

atomic和nonatomic用来决定编译器生成的getter和setter是否为原子操作。

atomic
设置成员变量的@property属性时,默认为atomic,提供多线程安全。

在多线程环境下,原子操作是必要的,否则有可能引起错误的结果。加了atomic,setter函数会变成下面这样:


                    {lock}
if (property != newValue) {
[property release];
property = [newValue retain];
}
{unlock}

nonatomic
3禁止多线程,变量保护,提高性能。

3atomic是Objc使用的一种线程保护技术,基本上来讲,是防止在写未完成的时候被另外一个线程读取,造成数据错误。而这种机制是耗费系统资源的,所以在iPhone这种小型设备上,如果没有使用多线程间的通讯编程,那么nonatomic是一个非常好的选择。

3指出访问器不是原子操作,而默认地,访问器是原子操作。这也就是说,在多线程环境下,解析的访问器提供一个对属性的安全访问,从获取器得到的返回值或者通过设置器设置的值可以一次完成,即便是别的线程也正在对其进行访问。如果你不指定 nonatomic ,在自己管理内存的环境中,解析的访问器保留并自动释放返回的值,如果指定了 nonatomic ,那么访问器只是简单地返回这个值。

9.iOS UIViewController的完整生命周期

UIViewController的完整生命周期

-[ViewControllerinitWithNibName:bundle:];

-[ViewControllerinit];

-[ViewControllerloadView];

-[ViewControllerviewDidLoad];

-[ViewControllerviewWillDisappear:];

-[ViewControllerviewWillAppear:];

-[ViewControllerviewDidAppear:];

-[ViewControllerviewDidDisappear:];

1、 alloc 创建对象,分配空间

2、init(initWithNibName) 初始化对象,初始化数据

3、loadView 从nib载入视图 ,通常这一步不需要去干涉。除非你没有使用xib文件创建视图

4、viewDidLoad 载入完成,可以进行自定义数据以及动态创建其他控件

5、viewWillAppear 视图将出现在屏幕之前,马上这个视图就会被展现在屏幕上了

6、viewDidAppear 视图已在屏幕上渲染完成

当一个视图被移除屏幕并且销毁的时候的执行顺序,这个顺序差不多和上面的相反

1、viewWillDisappear 视图将被从屏幕上移除之前执行

2、viewDidDisappear 视图已经被从屏幕上移除,用户看不到这个视图了

3、dealloc 视图被销毁,此处需要对你在init和viewDidLoad中创建的对象进行释放

ViewController 的 loadView,、viewDidLoad,、viewDidUnload 分别是在什么时候调用的?

viewDidLoad在view从nib文件初始化时调用,loadView在controller的view为nil时调用。

此方法在编程实现view时调用,view控制器默认会注册memory warning notification,当view controller的任何view没有用的时候,viewDidUnload会被调用,在这里实现将retain的view release,如果是retain的IBOutlet view属性则不要在这里release,IBOutlet会负责release。

10.ios7 层协议,tcp四层协议及如何对应的?


11.iOS应用导航模式有哪些?

平铺模式,一般由scrollView和pageControl组合而成的展示方式。手机自带的天气比较典型。

标签模式,tabBar的展示方式,这个比较常见。

树状模式,tableView的多态展示方式,常见的9宫格、系统自带的邮箱等展现方式。

12.一个参数既可以是const还可以是volatile吗?解释为什么。

• 是的。一个例子是只读的状态寄存器。它是volatile因为它可能被意想不到地改变。它是const因为程序不应该试图去修改它。

13.iOS 响应者链的事件传递过程?

如果view的控制器存在,就传递给控制器;如果控制器不存在,则将其传递给它的父视图

在视图层次结构的最顶级视图,如果也不能处理收到的事件或消息,则其将事件或消息传递给window对象进行处理

如果window对象也不处理,则其将事件或消息传递给UIApplication对象

如果UIApplication也不能处理该事件或消息,则将其丢弃

14.iOS 请说明并比较以下关键词:weak,block

weak与weak基本相同。前者用于修饰变量(variable),后者用于修饰属性(property)。weak 主要用于防止block中的循环引用。 block也用于修饰变量。它是引用修饰,所以其修饰的值是动态变化的,即可以被重新赋值的。block用于修饰某些block内部将要修改的外部变量。 weak和block的使用场景几乎与block息息相关。而所谓block,就是Objective-C对于闭包的实现。闭包就是没有名字的函数,或者理解为指向函数的指针。

15.iOS UIView的Touch事件注意点

如果父控件不能接收触摸事件,那么子控件就不可能接收到触摸事件(掌握)
UIView不接收触摸事件的三种情况:
不接收用户交互 : userInteractionEnabled = NO
隐藏 : hidden = YES
透明 : alpha = 0.0 ~ 0.01
UIImageView的userInteractionEnabled默认就是NO,因此UIImageView以及它的子控件默认是不能接收触摸事件的

16.iOS 说明并比较关键词:strong, weak, assign, copy等等

strong表示指向并拥有该对象。其修饰的对象引用计数会增加1。该对象只要引用计数不为0则不会被销毁。当然强行将其设为nil可以销毁它。

weak表示指向但不拥有该对象。其修饰的对象引用计数不会增加。无需手动设置,该对象会自行在内存中销毁。

assign主要用于修饰基本数据类型,如NSInteger和CGFloat,这些数值主要存在于栈上。

weak 一般用来修饰对象,assign一般用来修饰基本数据类型。原因是assign修饰的对象被释放后,指针的地址依然存在,造成野指针,在堆上容易造成崩溃。而栈上的内存系统会自动处理,不会造成野指针。

copy与strong类似。不同之处是strong的复制是多个指针指向同一个地址,而copy的复制每次会在内存中拷贝一份对象,指针指向不同地址。copy一般用在修饰有可变对应类型的不可变对象上,如NSString, NSArray, NSDictionary。

Objective-C 中,基本数据类型的默认关键字是atomic, readwrite, assign;普通属性的默认关键字是atomic, readwrite, strong。

1、属性readwrite,readonly,assign,retain,copy,nonatomic 各自什么作用,他们在那种情况下用?

readwrite:默认的属性,可读可写,生成settergetter方法。

readonly:只读,只生成getter方法,也就是说不能修改变量。

assign:用于声明基本数据类型(intfloat)仅设置变量,是赋值属性。

retain:持有属性,setter方法将传入的参数先保留,再赋值,传入的参数 引用计数retaincount 会加1

在堆上开辟一块空间,用指针a指向,然后将指针a赋值(assign)给指针b,等于是a和b同时指向这块堆空间,当a不使用这块堆空间的时候,是否要释放这块堆空间?答案是肯定要的,但是这件堆空间被释放后,b就成了野指针。

如何避免这样的问题? 这就引出了引用计数器,当a指针这块堆空间的时候,引用计数器+1,当b也指向的时候,引用计数器变成了2,当a不再指向这块堆空间时,release-1,引用计数器为1,当b也不指向这块堆空间时,release-1,引用计数器为0,调用dealloc函数,空间被释放

总结:当数据类型为int,float原生类型时,可以使用assign。如果是上面那种情况(对象)就是用retain。

copy:是赋值特性,setter方法将传入对象赋值一份;需要完全一份新的变量时,直接从堆区拿。

当属性是 NSString、NSArray、NSDictionary时,既可以用strong 修饰,也可以用copy修饰。当用strong修饰的NSString 指向一个NSMutableString时,如果在不知情的情况下这个NSMutableString的别的引用修改了值,就会出现:一个不可变的字符串却被改变了的情况, 使用copy就不会出现这种情况。


nonatomic:非原子性,可以多线程访问,效率高。
atomic:原子性,属性安全级别的表示,同一时刻只有一个线程访问,具有资源的独占性,但是效率很低。
strong:强引用,引用计数+ 1,ARC下,一个对象如果没有强引用,系统就会释放这个对象。
weak:弱引用,不会使引用计数+1.当一个指向对象的强引用都被释放时,这块空间依旧会被释放掉。

使用场景:在ARC下,如果使用XIB 或者SB 来创建控件,就使用 weak。纯代码创建控件时,用strong修饰,如果想用weak 修饰,就需要先创建控件,然后赋值给用weak修饰的对象。

查找了一些资料,发现主要原因是,controller需要拥有它自己的view(这个view是所以子控件的父view),因此viewcontroller对view就必须是强引用(strong reference),得用strong修饰view。对于lable,它的父view是view,view需要拥有label,但是controller是不需要拥有label的。如果用strong修饰,在view销毁的情况下,label还仍然占有内存,因为controller还对它强引用;如果用wak修饰,在view销毁的时label的内存也同时被销毁,避免了僵尸指针出现。

用引用计数回答就是:因为Controller并不直接“拥有”控件,控件由它的父view“拥有”。使用weak关键字可以不增加控件引用计数,确保控件与父view有相同的生命周期。控件在被addSubview后,相当于控件引用计数+1;父view销毁后,所有的子view引用计数-1,则可以确保父view销毁时子view立即销毁。weak的控件在removeFromSuperview后也会立即销毁,而strong的控件不会,因为Controller还保有控件强引用。

总结归纳为:当控件的父view销毁时,如果你还想继续拥有这个控件,就用srtong;如果想保证控件和父view拥有相同的生命周期,就用weak。当然在大多数情况下用两个都是可以的。

使用weak的时候需要特别注意的是:先将控件添加到superview上之后再赋值给self,避免控件被过早释放。

17.iOS里什么是响应链,它是怎么工作的?

第一反应就是,响应链就是响应链啊,由一串UIResponder对象链接,收到响应事件时由上往下传递,直到能响应事件为止。

但其中却大有文章...

1.由一串UIResponder对象链接 ?

我们知道UIResponder类里有个属性:

@property(nonatomic, readonly, nullable) UIResponder *nextResponder;

如果我们对响应链原理不清楚的话,会很容易的认为,这条链是由 nextResponder 指针连接起来的,在寻找响应者的时候是顺着这个指针找下去直到找到响应者为止的,但这是错误的认为。 举个例子: 现在我们有这样一个场景:

AppDelegate上的Window上有一个UIViewController *ViewController, 然后在ViewController.view 上按顺序添加viewA和viewB,viewB稍微覆盖viewA一部分用来测试, 给viewA,viewB 分别添加点击手势tapA 和 tapB,然后把viewB.userInteractionEnabled = NO,让viewB不能响应点击。

然后我们点击重复的那块区域,会发现viewA响应了tap手势,执行了tapA的事件。 我们知道viewB设置了viewB.userInteractionEnabled = NO,不响应tap手势是正常的,但怎么会透过viewB,viewA响应了手势?

我们知道nextResponder指针指向的规则:

  • UIView
  • 如果 view 是一个 view controller 的 root view,nextResponder 是这个 view controller.
  • 如果 view 不是 view controller 的 root view,nextResponder 则是这个 view 的 superview
  • UIViewController
  • 如果 view controller 的 view 是 window 的 root view, view controller 的 nextResponder 是这个 window
  • 如果 view controller 是被其他 view controller presented调起来的,那么 view controller 的 nextResponder 就是发起调起的那个 view controller
  • UIWindow
  • window 的 nextResponder 是 UIApplication 对象.
  • UIApplication
  • UIApplication 对象的 nextResponder 是 app delegate, 但是 app delegate 必须是 UIResponder 对象,并且不能使 view ,view controller 或 UIApplication 对象他本身.

那么上述情况下,viewB所在的响应者链应该是: viewB -> ViewController.view -> ViewController -> Window -> Application 这种情况下怎么也轮不到viewA去响应啊。

所以,当有事件需要响应时,nextResponder 并不是链接响应链的那根绳子,响应链的工作方式另有别的方式

2. 那么响应链是如何工作,正确找到应该响应该事件的响应者的?

UIKit使用基于视图的hit-testing来确定touch事件发生的位置。具体解释就是,UIKit将touch的位置和视图层级中的view的边界进行了比较,UIView的方法 hitTest:withEvent: 在视图层级中进行,寻找包含指定touch的最深子视图。这个视图成为touch事件的第一个响应者。

说白了就是,当有touch事件来的时候,会从最下面的视图开始执行 hitTest:withEvent: ,如果符合成为响应者的条件,就会继续遍历它的 subviews 继续执行 hitTest:withEvent: ,直到找到最合适的view成为响应者。

这里要注意几个点:

  • 符合响应者的条件包括
  • touch事件的位置在响应者区域内
  • 响应者 hidden 属性不为 YES
  • 响应者 透明度 不是 0
  • 响应者 userInteractionEnabled 不为 NO
  • 遍历 subview 时,是从上往下顺序遍历的,即 view.subviews 的 lastObject 到 firstObject 的顺序,找到合适的响应者view,即停止遍历.

所以再回看上面的例子,当我们点击中间的重复区域时,流程其实是这样:

  • AppDelegate 的 window 收到事件,并开始执行 hitTest:withEvent: ,发现符合要求,开始遍历子view.
  • window 上只有 viewcontroller.view ,所以viewcontroller.view 开始执行 hitTest:withEvent: ,发现符合要求,开始遍历子view.
  • viewcontroller.view 有两个子view, viewA 和 viewB ,但是viewB 在 viewA 上边,所以先 viewB 执行 hitTest:withEvent: ,结果发现viewB 不符合要求,因为viewB 的 userInteractionEnabled 为 NO.
  • 接下来 viewA 执行 hitTest:withEvent: ,发现符合条件,并且viewA 也没有子view可去遍历,于是返回viewA.
  • viewA成了最终事件的响应者.

这样就完美解释了,最开始例子的响应状况.

那么如果 viewB 的 userInteractionEnabled 属性为YES的话,是怎么样的呢?

如果 viewB 的 userInteractionEnabled 属性为YES,上面流程的第三部就会发现viewB是符合要求的,而直接返回viewB作为最终响应者,中断子view的遍历,viewA都不会被遍历到了.

这就是响应链相关的点,如果有什么不对的请留言提示,然后有什么别的需要补充的我会及时补充~

18.什么是iOS的动态绑定 ?

—在运行时确定要调用的方法

动态绑定将调用方法的确定也推迟到运行时。在编译时,方法的调用并不和代码绑定在一起,只有在消实发送出来之后,才确定被调用的代码。通过动态类型和动态绑定技术,您的代码每次执行都可以得到不同的结果。运行时因子负责确定消息的接收者和被调用的方法。运行时的消息分发机制为动态绑定提供支持。当您向一个动态类型确定了的对象发送消息时,运行环境系统会通过接收者的isa指针定位对象的类,并以此为起点确定被调用的方法,方法和消息是动态绑定的。而且,您不必在Objective-C 代码中做任何工作,就可以自动获取动态绑定的好处。您在每次发送消息时,

特别是当消息的接收者是动态类型已经确定的对象时,动态绑定就会例行而透明地发生。

19.iOS单元测试框架有哪些?

OCUnit 是 OC 官方测试框架, 现在被 XCTest 所取代。 XCTest 是与 Foundation 框架平行的测试框架。 GHUnit 是第三方的测试框架。github地址OCMock都是第三方的测试框架。

20.iOS ARC全解?

考查点

我记得在刚接触iOS的时候对这个ARC和MRC就讨论颇深,认为ARC是对程序员的一种福利,让我们节省了大量的代码,那么ARC是什么呢?

ARC 是苹果在 WWDC 2011 提出来的技术,因此很多新入行的同学可能对此技术细节并不熟悉。但是,虽然 ARC 极大地简化了我们的内存管理工作,但是引用计数这种内存管理方案如果不被理解,那么就无法处理好那些棘手的循环引用问题。所以,这道面试题其实是考查同学对于 iOS 程序内存管理的理解深度。 答案

自动的引用计数(Automatic Reference Count 简称 ARC),是苹果在 WWDC 2011 年大会上提出的用于内存管理的技术。

引用计数(Reference Count)是一个简单而有效的管理对象生命周期的方式。当我们创建一个新对象的时候,它的引用计数为 1,当有一个新的指针指向这个对象时,我们将其引用计数加 1,当某个指针不再指向这个对象是,我们将其引用计数减 1,当对象的引用计数变为 0 时,说明这个对象不再被任何指针指向了,这个时候我们就可以将对象销毁,回收内存。由于引用计数简单有效,除了 Objective-C 语言外,微软的 COM(Component Object Model )、C++11(C++11 提供了基于引用计数的智能指针 share_prt) 等语言也提供了基于引用计数的内存管理方式。

引用计数这种内存管理方式虽然简单,但是手工写大量的操作引用计数的代码不但繁琐,而且容易被遗漏。于是苹果在 2011 年引入了 ARC。ARC 顾名思义,是自动帮我们填写引用计数代码的一项功能。

ARC 的想法来源于苹果在早期设计 Xcode 的 Analyzer 的时候,发现编译器在编译时可以帮助大家发现很多内存管理中的问题。后来苹果就想,能不能干脆编译器在编译的时候,把内存管理的代码都自动补上,带着这种想法,苹果修改了一些内存管理代码的书写方式(例如引入了 @autoreleasepool 关键字)后,在 Xcode 中实现了这个想法。

ARC 的工作原理大致是这样:当我们编译源码的时候,编译器会分析源码中每个对象的生命周期,然后基于这些对象的生命周期,来添加相应的引用计数操作代码。所以,ARC 是工作在编译期的一种技术方案,这样的好处是:

编译之后,ARC 与非 ARC 代码是没有什么差别的,所以二者可以在源码中共存。实际上,你可以通过编译参数 -fno-objc-arc 来关闭部分源代码的 ARC 特性。

相对于垃圾回收这类内存管理方案,ARC 不会带来运行时的额外开销,所以对于应用的运行效率不会有影响。相反,由于 ARC 能够深度分析每一个对象的生命周期,它能够做到比人工管理引用计数更加高效。例如在一个函数中,对一个对象刚开始有一个引用计数 +1 的操作,之后又紧接着有一个 -1 的操作,那么编译器就可以把这两个操作都优化掉。

但是也有人认为,ARC 也附带有运行期的一些机制来使 ARC 能够更好的工作,他们主要是指 weak 关键字。weak 变量能够在引用计数为 0 时被自动设置成 nil,显然是有运行时逻辑在工作的。我通常并没有把这个算在 ARC 的概念当中,当然,这更多是一个概念或定义上的分歧,因为除开 weak 逻辑之外,ARC 核心的代码都是在编译期填充的。

21.iOS内存的使用和优化的注意事项

重用问题:

如UITableViewCells、UICollectionViewCells、UITableViewHeaderFooterViews

设置正确的reuseIdentifier,充分重用;

尽量把views设置为不透明:

当opque为NO的时候,图层的半透明取决于图片和其本身合成的图层为结果,可提高性能;

不要使用太复杂的XIB/Storyboard:

载入时就会将XIB/storyboard需要的所有资源,

包括图片全部载入内存,即使未来很久才会使用。

那些相比纯代码写的延迟加载,性能及内存就差了很多;

选择正确的数据结构:

学会选择对业务场景最合适的数组结构是写出高效代码的基础。

比如,数组: 有序的一组值。

使用索引来查询很快,使用值查询很慢,插入/删除很慢。

字典: 存储键值对,用键来查找比较快。

集合: 无序的一组值,用值来查找很快,插入/删除很快。

gzip/zip压缩:

当从服务端下载相关附件时,可以通过gzip/zip压缩后再下载,使得内存更小,下载速度也更快。

延迟加载:

对于不应该使用的数据,使用延迟加载方式。

对于不需要马上显示的视图,使用延迟加载方式。

比如,网络请求失败时显示的提示界面,可能一直都不会使用到,因此应该使用延迟加载。

数据缓存:

对于cell的行高要缓存起来,使得reload数据时,效率也极高。

而对于那些网络数据,不需要每次都请求的,应该缓存起来,

可以写入数据库,也可以通过plist文件存储。

处理内存警告:

一般在基类统一处理内存警告,将相关不用资源立即释放掉

重用大开销对象:

一些objects的初始化很慢,

比如NSDateFormatter和NSCalendar,但又不可避免地需要使用它们。

通常是作为属性存储起来,防止反复创建。

避免反复处理数据:

许多应用需要从服务器加载功能所需的常为JSON或者XML格式的数据。

在服务器端和客户端使用相同的数据结构很重要;

使用Autorelease Pool:

在某些循环创建临时变量处理数据时,自动释放池以保证能及时释放内存;

22.什么是iOS的目标-动作机制 ?

目标是动作消息的接收者。一个控件,或者更为常见的是它的单元,以插座变量(参见"插座变量"部分)

的形式保有其动作消息的目标。

动作是控件发送给目标的消息,或者从目标的角度看,它是目标为了响应动作而实现的方法。

程序需要某些机制来进行事件和指令的翻译。这个机制就是目标-动作机制。

23.iOS 事件传递的完整过程?

先将事件对象由上往下传递(由父控件传递给子控件),找到最合适的控件来处理这个事件。

调用最合适控件的touches….方法

如果调用了[super touches….];就会将事件顺着响应者链条往上传递,传递给上一个响应者
接着就会调用上一个响应者的touches….方法

如何判断上一个响应者:
如果当前这个view是控制器的view,那么控制器就是上一个响应者
如果当前这个view不是控制器的view,那么父控件就是上一个响应者

24.什么是iOS的响应者链?

  • 响应者链条:是由多个响应者对象连接起来的链条
  • 作用:能很清楚的看见每个响应者之间的联系,并且可以让一个事件多个对象处理。
  • 响应者对象:能处理事件的对象



25.iOS UIView的Touch事件有哪几种触摸事件?

处理事件的方法

UIView是UIResponder的子类,可以覆盖下列4个方法处理不同的触摸事件

  //一根或者多根手指开始触摸view
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event
//一根或者多根手指在view上移动
- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event
//一根或者多根手指离开view
- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event
//触摸结束前,某个系统事件(例如电话呼入)会打断触摸过程
- (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event

26.iOS开发:Objective-C中通知与协议的区别?

what is difference between NSNotification and protocol? (通知和协议的不同之处?)

我想大家都知道这个东西怎么用,但是更深层次的思考可能就比较少了吧,众所周知就是代理是一对一的,但是通知是可以多对多的.但是为什么是这个样子,有没有更深的思考过这个问题?

今天看了下网上的几个视频教程,KVO、KVC、谓词、通知,算是开发中的高级点的东西了。通知和协议都是类似于回调一样,于是就在思考通知和协议到底有什么不同,或者说什么时候该用通知,什么时候该用协议。

下面是网上摘抄的一段解释:

协议有控制链(has-a)的关系,通知没有。首先我一开始也不太明白,什么叫控制链(专业术语了~)。但是简单分析下通知和代理的行为模式,我们大致可以有自己的理解简单来说,通知的话,它可以一对多,一条消息可以发送给多个消息接受者。代理按我们的理解,到不是直接说不能一对多,比如我们知道的明星经济代理人,很多时候一个经济人负责好几个明星的事务。只是对于不同明星间,代理的事物对象都是不一样的,一一对应,不可能说明天要处理A明星要一个发布会,代理人发出处理发布会的消息后,别称B的发布会了。但是通知就不一样,他只关心发出通知,而不关心多少接收到感兴趣要处理。因此控制链(has-a从英语单词大致可以看出,单一拥有和可控制的对应关系。

1.通知:

通知需要有一个通知中心:NSNotificationCenter,自定义通知的话需要给一个名字,然后监听。

优点:通知的发送者和接受者都不需要知道对方。可以指定接收通知的具体方法。通知名可以是任何字符串。

缺点:较键值观察(KVO)需要多点代码,在删掉前必须移除监听者。

2.协议

通过setDelegate来设置代理对象,最典型的例子是常用的TableView.

优点:支持它的类有详尽和具体信息。

缺点:该类必须支持委托。某一时间只能有一个委托连接到某一对象。

相信看到这些东西,认真思考一下,就可以知道在那种情况下使用通知,在那种情况下使用代理了吧.

27.写一个NSString类的实现

 + (id)initWithCString:(c*****t char *)nullTerminatedCString  encoding:(NSStringEncoding)encoding;** 

+ (id) stringWithCString: (c*****t char*)nullTerminatedCString

encoding: (NSStringEncoding)encoding

{

NSString *obj;

obj = [self allocWithZone: NSDefaultMallocZone()];

obj = [obj initWithCString: nullTerminatedCString encoding: encoding];

return AUTORELEASE(obj);

}

28.iOS 事件的产生和传递流程

发生触摸事件后,系统会将该事件加入到一个由UIApplication管理的事件队列中
UIApplication会从事件队列中取出最前面的事件,并将事件分发下去以便处理,通常,先发送事件给应用程序的主窗口(keyWindow)
主窗口会在视图层次结构中找到一个最合适的视图来处理触摸事件,这也是整个事件处理过程的第一步
找到合适的视图控件后,就会调用视图控件的touches方法来作具体的事件处理 touchesBegan… touchesMoved… touchedEnded…
这些touches方法的默认做法是将事件顺着响应者链条向上传递(不实现touches方法,系统会自动向上一个响应者传递),将事件交给上一个响应者进行处理
如果一个事件既想自己处理也想交给上一个响应者处理,那么自己实现touches方法,并且调用super的touches方法,[super touches、、、];

29.关键字volatile有什么含意?并给出三个不同的例子?

一个定义为volatile的变量是说这变量可能会被意想不到地改变,这样,编译器就不会去假设这个变量的值了。精确地说就是,优化器在用到

这个变量时必须每次都小心地重新读取这个变量的值,而不是使用保存在寄存器里的备份。下面是volatile变量的几个例子:

• 并行设备的硬件寄存器(如:状态寄存器)

• 一个中断服务子程序中会访问到的非自动变量(Non-automatic variables)

• 多线程应用中被几个任务共享的变量

30.iOS hitTest方法&pointInside方法

hitTest方法
当事件传递给控件的时候,就会调用控件的这个方法,去寻找最合适的view
point:当前的触摸点,point这个点的坐标系就是方法调用者

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event;
pointInside方法
作用:判断当前这个点在不在方法调用者(控件)上

- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event;

hitTest:withEvent:的实现原理

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{

// 1.判断当前控件能否接收事件
if (self.userInteractionEnabled == NO || self.hidden == YES || self.alpha <= 0.01) return nil;

// 2. 判断点在不在当前控件
if ([self pointInside:point withEvent:event] == NO) return nil;

// 3.从后往前遍历自己的子控件
NSInteger count = self.subviews.count;

for (NSInteger i = count - 1; i >= 0; i--) {
UIView *childView = self.subviews[i];

// 把当前控件上的坐标系转换成子控件上的坐标系
CGPoint childP = [self convertPoint:point toView:childView];

UIView *fitView = [childView hitTest:childP withEvent:event];


if (fitView) { // 寻找到最合适的view
return fitView;
}
}
// 循环结束,表示没有比自己更合适的view
return self;
}


作者:iOS鑫
链接:https://www.jianshu.com/p/2cc5d8b4e8d3








收起阅读 »

iOS面试题快来来来(内存方向)

1.形成tableView卡顿的缘由有哪些?1.最经常使用的就是cell的重用, 注册重用标识符若是不重用cell时,每当一个cell显示到屏幕上时,就会从新建立一个新的cellhtml若是有不少数据的时候,就会堆积不少cell。ios若是重用cell,为ce...
继续阅读 »

1.形成tableView卡顿的缘由有哪些?

  • 1.最经常使用的就是cell的重用, 注册重用标识符

    若是不重用cell时,每当一个cell显示到屏幕上时,就会从新建立一个新的cellhtml

    若是有不少数据的时候,就会堆积不少cell。ios

    若是重用cell,为cell建立一个ID,每当须要显示cell 的时候,都会先去缓冲池中寻找可循环利用的cell,若是没有再从新建立cellc++

  • 2.避免cell的从新布局

    cell的布局填充等操做 比较耗时,通常建立时就布局好面试

    如能够将cell单独放到一个自定义类,初始化时就布局好swift

  • 3.提早计算并缓存cell的属性及内容

    当咱们建立cell的数据源方法时,编译器并非先建立cell 再定cell的高度xcode

    而是先根据内容一次肯定每个cell的高度,高度肯定后,再建立要显示的cell,滚动时,每当cell进入凭虚都会计算高度,提早估算高度告诉编译器,编译器知道高度后,紧接着就会建立cell,这时再调用高度的具体计算方法,这样能够方式浪费时间去计算显示之外的cell缓存

  • 4.减小cell中控件的数量

    尽可能使cell得布局大体相同,不一样风格的cell可使用不用的重用标识符,初始化时添加控件,网络

    不适用的能够先隐藏数据结构

  • 5.不要使用ClearColor,无背景色,透明度也不要设置为0

    渲染耗时比较长多线程

  • 6.使用局部更新

    若是只是更新某组的话,使用reloadSection进行局部更

  • 7.加载网络数据,下载图片,使用异步加载,并缓存

  • 8.少使用addView 给cell动态添加view

  • 9.按需加载cell,cell滚动很快时,只加载范围内的cell

  • 10.不要实现无用的代理方法,tableView只遵照两个协议

  • 11.缓存行高:estimatedHeightForRow不能和HeightForRow里面的layoutIfNeed同时存在,这二者同时存在才会出现“窜动”的bug。因此个人建议是:只要是固定行高就写预估行高来减小行高调用次数提高性能。若是是动态行高就不要写预估方法了,用一个行高的缓存字典来减小代码的调用次数便可

  • 12.不要作多余的绘制工做。在实现drawRect:的时候,它的rect参数就是须要绘制的区域,这个区域以外的不须要进行绘制。例如上例中,就能够用CGRectIntersectsRect、CGRectIntersection或CGRectContainsRect判断是否须要绘制image和text,而后再调用绘制方法。

  • 13.预渲染图像。当新的图像出现时,仍然会有短暂的停顿现象。解决的办法就是在bitmap context里先将其画一遍,导出成UIImage对象,而后再绘制到屏幕;

  • 14.使用正确的数据结构来存储数据。

2.如何提高 tableview 的流畅度?

  • 本质上是下降 CPU、GPU 的工做,从这两个大的方面去提高性能。

    CPU:对象的建立和销毁、对象属性的调整、布局计算、文本的计算和排版、图片的格式转换和解码、图像的绘制

    GPU:纹理的渲染

  • 卡顿优化在 CPU 层面

    尽可能用轻量级的对象,好比用不到事件处理的地方,能够考虑使用 CALayer 取代 UIView

    不要频繁地调用 UIView 的相关属性,好比 frame、bounds、transform 等属性,尽可能减小没必要要的修改

    尽可能提早计算好布局,在有须要时一次性调整对应的属性,不要屡次修改属性

    Autolayout 会比直接设置 frame 消耗更多的 CPU 资源

    图片的 size 最好恰好跟 UIImageView 的 size 保持一致

    控制一下线程的最大并发数量

    尽可能把耗时的操做放到子线程

    文本处理(尺寸计算、绘制)

    图片处理(解码、绘制)

  • 卡顿优化在 GPU层面

    尽可能避免短期内大量图片的显示,尽量将多张图片合成一张进行显示

    GPU能处理的最大纹理尺寸是 4096x4096,一旦超过这个尺寸,就会占用 CPU 资源进行处理,因此纹理尽可能不要超过这个尺寸

    尽可能减小视图数量和层次

    减小透明的视图(alpha<1),不透明的就设置 opaque 为 YES

    尽可能避免出现离屏渲染

  • iOS 保持界面流畅的技巧

    1.预排版,提早计算

    在接收到服务端返回的数据后,尽可能将 CoreText 排版的结果、单个控件的高度、cell 总体的高度提早计算好,将其存储在模型的属性中。须要使用时,直接从模型中往外取,避免了计算的过程。

    尽可能少用 UILabel,可使用 CALayer 。避免使用 AutoLayout 的自动布局技术,采起纯代码的方式

    2.预渲染,提早绘制

    例如圆形的图标能够提早在,在接收到网络返回数据时,在后台线程进行处理,直接存储在模型数据里,回到主线程后直接调用就能够了

    避免使用 CALayer 的 Border、corner、shadow、mask 等技术,这些都会触发离屏渲染。

    3.异步绘制

    4.全局并发线程

    5.高效的图片异步加载

3.APP启动时间应从哪些方面优化?

App启动时间能够经过xcode提供的工具来度量,在Xcode的Product->Scheme-->Edit Scheme->Run->Auguments中,将环境变量DYLD_PRINT_STATISTICS设为YES,优化需如下方面入手

  • dylib loading time

    核心思想是减小dylibs的引用

    合并现有的dylibs(最好是6个之内)

    使用静态库

  • rebase/binding time

    核心思想是减小DATA块内的指针

    减小Object C元数据量,减小Objc类数量,减小实例变量和函数(与面向对象设计思想冲突)

    减小c++虚函数

    多使用Swift结构体(推荐使用swift)

  • ObjC setup time

    核心思想同上,这部份内容基本上在上一阶段优化事后就不会太过耗时

    initializer time

  • 使用initialize替代load方法

    减小使用c/c++的attribute((constructor));推荐使用dispatch_once() pthread_once() std:once()等方法

    推荐使用swift

    不要在初始化中调用dlopen()方法,由于加载过程是单线程,无锁,若是调用dlopen则会变成多线程,会开启锁的消耗,同时有可能死锁

    不要在初始化中建立线程

4.如何下降APP包的大小

下降包大小须要从两方面着手

  • 可执行文件

    编译器优化:Strip Linked Product、Make Strings Read-Only、Symbols Hidden by Default 设置为 YES,去掉异常支持,Enable C++ Exceptions、Enable Objective-C Exceptions 设置为 NO, Other C Flags 添加 -fno-exceptions 利用 AppCode 检测未使用的代码:菜单栏 -> Code -> Inspect Code

    编写LLVM插件检测出重复代码、未被调用的代码

  • 资源(图片、音频、视频 等)

    优化的方式能够对资源进行无损的压缩

    去除没有用到的资源

5.如何检测离屏渲染与优化

  • 检测,经过勾选Xcode的Debug->View Debugging-->Rendering->Run->Color Offscreen-Rendered Yellow项。
  • 优化,如阴影,在绘制时添加阴影的路径

6.怎么检测图层混合

一、模拟器debug中color blended layers红色区域表示图层发生了混合

二、Instrument-选中Core Animation-勾选Color Blended Layers

避免图层混合:

  • 确保控件的opaque属性设置为true,确保backgroundColor和父视图颜色一致且不透明
  • 如无特殊须要,不要设置低于1的alpha值
  • 确保UIImage没有alpha通道

UILabel图层混合解决方法:

iOS8之后设置背景色为非透明色而且设置label.layer.masksToBounds=YES让label只会渲染她的实际size区域,就能解决UILabel的图层混合问题

iOS8 以前只要设置背景色为非透明的就行

为何设置了背景色可是在iOS8上仍然出现了图层混合呢?

UILabel在iOS8先后的变化,在iOS8之前,UILabel使用的是CALayer做为底图层,而在iOS8开始,UILabel的底图层变成了_UILabelLayer,绘制文本也有所改变。在背景色的四周多了一圈透明的边,而这一圈透明的边明显超出了图层的矩形区域,设置图层的masksToBounds为YES时,图层将会沿着Bounds进行裁剪 图层混合问题解决了

7.平常如何检查内存泄露?

  • 目前我知道的方式有如下几种

    Memory Leaks

    Alloctions

    Analyse

    Debug Memory Graph

    MLeaksFinder

  • 泄露的内存主要有如下两种:

    Laek Memory 这种是忘记 Release 操做所泄露的内存。

    Abandon Memory 这种是循环引用,没法释放掉的内存。



作者:iOS鑫
链接:https://www.jianshu.com/p/f9da4407c04b

收起阅读 »

UIScrollView属性及其代理方法

一、UIScrollView是什么?1、UIScrollView是滚动的view,UIView本身不能滚动,子类UIScrollview拓展了滚动方面的功能。2、UIScrollView是所有滚动视图的基类。以后的UITableView,UITextView等...
继续阅读 »

一、UIScrollView是什么?

1、UIScrollView是滚动的view,UIView本身不能滚动,子类UIScrollview拓展了滚动方面的功能。
2、UIScrollView是所有滚动视图的基类。以后的UITableView,UITextView等视图都是继承于该类。
使用场景:显示不下(单张大图);内容太多(图文混排);滚动头条(图片);相册等

二、UIScrollView使用

1、UIScrollview主要专长于两个方面:

      a、滚动:contentSize大于frame.size的时候,能够滚动。
b、 缩放:自带缩放,可以指定缩放倍数。
2、UIScrollView滚动相关属性contentSize

 //定义内容区域大小,决定是否能够滑动
contentOffset //视图左上角距离坐标原点的偏移量
scrollsToTop //滑动到顶部(点状态条的时候)
pagingEnabled //是否整屏翻动
bounces //边界是否回弹
scrollEnabled //是否能够滚动
showsHorizontalScrollIndicator //控制是否显示水平方向的滚动条
showVerticalScrollIndicator //控制是否显示垂直方向的滚动条
alwaysBounceVertical //控制垂直方向遇到边框是否反弹
alwaysBounceHorizontal //控制水平方向遇到边框是否反弹

3、UIScrollView缩放相关属性

minimumZoomScale  //  缩小的最小比例
maximumZoomScale //放大的最大比例
zoomScale //设置变化比例
zooming //判断是否正在进行缩放反弹
bouncesZoom //控制缩放的时候是否会反弹
要实现缩放,还需要实现delegate,指定缩放的视图是谁。

4.UIScrollView滚动实例应用
- (void)scrollView{
// 创建滚动视图,但我们现实的屏幕超过一屏时,就需要滚动视图
UIScrollView *scrollView = [[UIScrollView alloc] initWithFrame:self.view.frame];
scrollView.backgroundColor = [UIColor yellowColor];
scrollView.tag = 1000;
// 设置滚动区域
scrollView.contentSize = CGSizeMake(4 * CGRectGetWidth(self.view.frame), self.view.frame.size.height);
[self.view addSubview:scrollView];
// 添加子视图
for (int i = 0; i < 4; i ++) {
UILabel *label = [[UILabel alloc] initWithFrame:CGRectMake(CGRectGetWidth(self.view.frame) * i, 0, CGRectGetWidth(self.view.frame), CGRectGetHeight(self.view.frame))];
label.text = [NSString stringWithFormat:@"这是%d个视图",i];
label.font = [UIFont systemFontOfSize:30];
[scrollView addSubview:label];
UIImageView *imageView = [[UIImageView alloc] initWithImage:[UIImage imageNamed:[NSString stringWithFormat:@"%d.jpg",i]]]; // (有四张片分别取名0.jpg,1.jpg,2.jpg.3.jpg)
[imageView setFrame:self.view.frame];
[label addSubview:imageView];

// label.backgroundColor = [UIColor colorWithRed:arc4random()%256/255.0 green:
// arc4random()%256/255.0 blue:arc4random()%256/255.0 alpha:1];

}
// 设置分页效果 (默认值为NO)
scrollView.pagingEnabled = YES;
// 设置滚动条是否显示(默认值是YES)
scrollView.showsHorizontalScrollIndicator = YES;
// 设置边界是否有反弹效果(默认值是YES)
scrollView.bounces = YES;
// 设置滚动条的样式
scrollView.indicatorStyle = UIScrollViewIndicatorStyleWhite;
/*
indicatorStyle(枚举值)
UIScrollViewIndicatorStyleDefault, //白色
UIScrollViewIndicatorStyleBlack, // 黑色
*/


// 设置scrollView的代理
scrollView.delegate = self; // (记得导入协议代理 <UIScrollViewAccessibilityDelegate>)
}

5、UIScrollView滚动代理方法
// 滚动就会触发
- (void)scrollViewDidScroll:(UIScrollView *)scrollView
{ NSLog(@"只有scrollview是跟滚动状态就会调用此方法");
}
//开始拖拽时触发
- (void)scrollViewWillBeginDragging:(UIScrollView *)scrollView{
NSLog(@"开始拖拽");

}
// 结束拖拽时触发
- (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate{
NSLog(@"结束拖拽");
}
// 开始减速时触发
- (void)scrollViewWillBeginDecelerating:(UIScrollView *)scrollView{
NSLog(@"开始减速");

}
// 结束减速时触发(停止)
- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView{
NSLog(@"结束减速(停止)");
}

6、UIScrollView缩放实例应用
- (void)viewDidLoad {
[super viewDidLoad];
self.view.backgroundColor = [UIColor orangeColor];
// 初始化一个scrollView
UIScrollView *scrollView = [[UIScrollView alloc] initWithFrame:self.view.frame];
scrollView.backgroundColor = [UIColor greenColor];
scrollView.delegate = self;


// 设置缩放比率
// 设置可缩小道德最小比例
scrollView.minimumZoomScale = 0.5;
// 设置可放大的最大比例
scrollView.maximumZoomScale = 2.0;
[self.view addSubview:scrollView];

// 使得要添加的图片宽高成比例
UIImage *myImage = [UIImage imageNamed:@"7.jpg"];
// 得到原始宽高
float imageWidth = myImage.size.width;
float imageHeight = myImage.size.height;
// 这里我们规定imageView的宽为200,根据此宽度得到等比例的高度
float imageViewWidth = 200;
float imageViewHeight = 200 *imageHeight/imageWidth;
// 初始化一个UIimageview
UIImageView *imageView = [[UIImageView alloc] initWithFrame:CGRectMake(0, 0, imageViewWidth, imageViewHeight)];
// 为imageView设置图片
imageView.image = myImage;
// 让imageView居中
imageView.center = self.view.center;
imageView.tag = 1000;
[scrollView addSubview:imageView];

}

7、UIScrollView缩放有关的代理

#pragma mark -- 滚动视图与缩放有关的代理方法
//指定scrollview的某一个子视图为可缩放视图,前提条件是次视图已经添加到scrollview上面
-(UIView*)viewForZoomingInScrollView:(UIScrollView *)scrollView{
UIView *imageView = (UIView*)[scrollView viewWithTag:1000];
return imageView;
}

// 开始缩放的代理方法 第二个参数view:这个参数使我们将要缩放的视图(这里就是imageView)
- (void)scrollViewWillBeginZooming:(UIScrollView *)scrollView withView:(UIView *)view{
NSLog(@"%@",view);

}

// 正在缩放的代理方法 只要在缩放就执行该方法,所以此方法会在缩放过程中多次调用
- (void)scrollViewDidZoom:(UIScrollView *)scrollView{
// 在缩放过程中为了使得该视图一直在屏幕中间,所以我们需要在他缩放的过程中一直调整他的center
// 得到scrollview的子视图
UIImageView *imageView = (UIImageView *)[scrollView viewWithTag:1000];
// 打印imageView的frame,分析为什么他的位置会改变
// NSLog(@"frame -- %@",NSStringFromCGRect(imageView.frame));

// 设置imageview的center,是他的位置一直在屏幕中央
imageView.center = scrollView.center;
// 打印contentSize 分析为什么缩放之后会滑动
NSLog(@"contentSize %@",NSStringFromCGSize(scrollView.contentSize));
}


// 缩放结束所执行的代理方法
/**
* @ view 当前正在缩放的视图
* @ scale 当前正在缩放视图的缩放比例
*/

- (void)scrollViewDidEndZooming:(UIScrollView *)scrollView withView:(UIView *)view atScale:(CGFloat)scale{
// 缩放完成之后恢复原大小,这里运用到2D仿射变换函数中与捏合有关的函数
view.transform =CGAffineTransformMakeScale(1, 1);


}



作者:小猪也浪漫
链接:https://www.jianshu.com/p/62918c39b95e

收起阅读 »

JAVA中线程间通信的小故事

从掘金的大佬中偷学到一个技能,为了提升知识提炼与字面表达能力,斟酌贴代码的篇幅,尽量用文字表达清楚技术知识的本质。(简单点就是“多说人话”) 正文开始! 前情提要 关于“线程间通信”的这个叫法,没查到比较官方的定义,也许它是一个通俗词吧。下面是基于笔...
继续阅读 »

从掘金的大佬中偷学到一个技能,为了提升知识提炼与字面表达能力,斟酌贴代码的篇幅,尽量用文字表达清楚技术知识的本质。(简单点就是“多说人话”)



正文开始!


前情提要


关于“线程间通信”的这个叫法,没查到比较官方的定义,也许它是一个通俗词吧。下面是基于笔者个人理解所总结出的定义,重在严谨。


不同线程之间通过资源状态同步相互影响彼此的执行逻辑。


线程间的基本通信可以划分为启动与结束,线程等待与唤醒线程。在JAVA中他们都对应了固定的API与固定用法,是还存在其他的通信方式,但本文不做展开。


一、线程停止的性格差异


张三与小明的故事


1. thread.stop() 愚蠢且粗鲁的张三



立即强制停止某个线程的执行,中断代码执行指令,并退出线程。被停止的线程无法安全的进行善后处理。



代入角色举个栗子,愚蠢的张三安排他儿子小明烧开水。小明很聪明,已经牢记了烧水的步骤,拿锅接水,开火,水沸关火。


小明很听话,便进入厨房开始了忙碌。


半分钟后愚蠢张三的电话突然响了 ,有关部门通知马上会停止燃气供应,张三意识到小明不能再烧水了,决定停止小明的工作。


此时小明还在接水,但愚蠢的张三假装没看见,他一把将小明拉出了厨房。小明内心非常懵逼,但是他有苦说不出。


他们离开后,厨房的水还在哗哗的流,最后淹了厨房...


总结:完全不需要考虑善后的线程才能用stop()


2. thread.interrupt() 温柔的张三



通知某个线程中断当前在执行的任务,被中断的线程可以先进内部善后处理,再退出线程,或者不退出。



代入角色,还是上面的栗子。这不过这次张三并没后直接抱走小明,而是大声告诉小明,该离开厨房了。


小明此时有两种选择,第一中是丢下手上的事情,马上走出厨房,让水继续哗哗的流。第二种是关闭水龙头再走出厨房。


如果你是小明,你准备怎么做?


总结:中断线程用thread.interrupt()就对了,最起码温柔


3.Thread.interrupted() 可怜的小明,正确答案只能获取一次



每次在被中断后的第一次调用时返回true,之后在没有被在此中断前都一直返回false



代入角色,还是上面的栗子。小明知道张三有可能会通知他出现了例外情况,所以小明在每一个关键步骤前检查是否需要停止,如果发现被叫停就马上进行善后工作,离开厨房。因为他知道他基本只有一次机会。


总结:用于简单任务的中断判断,如果无法衡量是否简单,那就没必要用,除非你对中断次数是非常敏感的。


4.isInterrupted() 快乐的小明,获取正确答案不限次数



只要被中断过一次,之后获取到的状态都是true



小明的快乐你懂了吗


总结:小明的快乐你懂了吗


5.Thread.sleep(x)小明在厨房睡着了



当前线程进入挂起状态,挂起的过程中可能会被中断,被中断时则会被catch (InterruptedException e)捕获,可以进行善后处理,选择是否退出。



代入角色,没错小明真睡着了!如果温柔的张三大声告诉小明离开厨房,小明被惊醒后要是不犯迷糊就会有序的停止当且阶段的工作,比如关闭水龙头,然后离开厨房。


要是小明犯迷糊呢?小明一般不会犯迷糊


因为他知道


犯迷糊的小明会被张三暴揍!


总结:Thread.sleep(x)后需要捕获的异常catch (InterruptedException e),理解为例外更好些,因为它并不代表程序错误


二、等待的细节与唤醒的差别


小明与小芳的故事


1.wait() 小明的素质



当需要访问的资源不满足条件时,选择进入等待区。直到被唤醒后重新竞争锁,获取锁后接着之前的逻辑继续执行



小明和小芳一起看电视,小明先抢到了遥控器,他想看足球比赛,切到了足球频道,球员A准备射门,但是小明点的啤酒还没到,小明看比赛必须得有啤酒。


如果小明没礼貌,那么他就暂停电视,把遥控器坐在屁股下面,一直盯着电视,直到啤酒来了,小明恢复电视,继续看。


如果小明有礼貌,那么他就先让出了遥控器,小芳拿到遥控器开心的放起了甄嬛传。 小明呢则开始发呆(细节1),直到(细节2)有人告诉他啤酒来了,他便重新(细节3)去抢遥控器,抢到后遥控器后起到足球频道,电视机画面直接从球员A准备射门处(细节3)开始播放。


如果小明发呆的时候出现了意外怎么办呢?不用担心这会立即叫醒小明,他可以自主选择下一步怎么办。


这就是为什么wait()时也需要catch (InterruptedException e)


总结:用wait()让出锁和资源,减少兄弟线程的等待时间


2.notify() 幸运女神



由当前作为锁的对象随机从与当前锁相关且进入wait()的线程中唤醒一个,被唤醒的线程重新进行锁的竞争



从上帝视角看,当资源只能满足一个线程使用时,使用notify(),能节约不必要的额外开销。


而被选中的那个线程就是唯一的幸运儿~


3.notifyAll() 阳光普照



由当前作为锁的对象唤醒所有与当前锁相关且进入wait()的线程,被唤醒的线程重新进行锁的竞争



如果没有特殊考虑,为了世界和平,通常你应当唤醒所有进入等待的线程。


三、join 快来绑一绑timing



将多个并行线程任务,连成一个串行的线程任务,带头线程不管成功还是失败,跟随线程都会立即执行



再举个栗子吧,张三安排小芳做饭,并让小明负责打酱油。


接下来的情况就会变得非常有趣。


小芳炒完菜要出锅的时候需要酱油,但是此时小明还没有买回酱油。小芳便使用join大法将自己绑定到了小明买回酱油这件任务的结束timing上。


结果呢?如果小明顺利买回了酱油,小芳使用酱油提鲜后装盘出锅。


如果小明路上摔跤了,导致提前退出了任务。小芳则使用空酱油后装盘出锅


这不怪小芳,她哪知道小明没有带回酱油呢。


总结: join()之后应该在此判断条件是否满足,避免拿到NPE


四、yield



稍微让出一点时间片给同级别线程,又立即恢复自己的执行。



像是快速wait()(不用别人叫的那种),再快速自动恢复


缺少科学分析验证,不敢多说~    




END

收起阅读 »

一文带你实现遍历android内存模块

1.Android内存模块遍历原理 在android系统上,要遍历app进程中的内存模块数据,必须用到proc文件系统。 proc它是由linux内核挂着到内存中,它提供内核配置、进程状态输出等功能。 用adb命令方式可以进行查看app进程中所有加载的模块...
继续阅读 »

1.Android内存模块遍历原理


在android系统上,要遍历app进程中的内存模块数据,必须用到proc文件系统。
proc它是由linux内核挂着到内存中,它提供内核配置、进程状态输出等功能。


用adb命令方式可以进行查看app进程中所有加载的模块信息。
cat /proc/%d/maps : cat是查看的意思, %d表示要查看的APP的进程pid


maps文件中显示出来的各个列信息解释:


第1列:模块内容在内存中的地址范围,以16进制显示。


第2列:模块内容在内存中的读取权限,r代表可读,w代表可写,x代表可执行,p代表私有,s代码共享。


第3列:模块内容对应模块文件中的偏移。


第4列:模块文件在文件系统中的主次设备号。


第5列:模块文件在文件系统中的节点号。


第6列:模块文件在文件系统中的路径。 image.png


2.android内存模块遍历实现



//存储模块信息的结构体
struct ProcMap {
void *startAddr;
void *endAddr;
size_t length;
std::string perms;
long offset;
std::string dev;
int inode;
std::string pathname;

bool isValid() { return (startAddr != NULL && endAddr != NULL && !pathname.empty()); }
};

//获取模块信息函数
bool getAPPMod(int pid)
{

ProcMap retMap;
char line[512] = {0};
char mapPath[128] = {0};

sprintf(mapPath, "/proc/%d/maps", pid);

FILE *fp = fopen(mapPath, "r");
if (fp != NULL)
{

while (fgets(line, sizeof(line), fp)) {

char tmpPerms[5] = {}, tmpDev[12] = {}, tmpPathname[455] = {};

sscanf(line, "%llx-%llx %s %ld %s %d %s",
(long long unsigned *) &retMap.startAddr,
(long long unsigned *) &retMap.endAddr,
tmpPerms, &retMap.offset, tmpDev, &retMap.inode, tmpPathname);

}
}

return true;

}

收起阅读 »

官方推荐 Flow 取代 LiveData,有必要吗?

前言打开Android架构组件页面,我们可以发现一些最新发布的jetpack组件,如Room,DataStore, Paging3,DataBinding 等都支持了FlowGoogle开发者账号最近也发布了几篇使用Flow的文章,比如:从...
继续阅读 »

前言

打开Android架构组件页面,我们可以发现一些最新发布的jetpack组件,如RoomDataStorePaging3,DataBinding 等都支持了Flow
Google开发者账号最近也发布了几篇使用Flow的文章,比如:从 LiveData 迁移到 Kotlin 数据流
看起来官方在大力推荐使用Flow取代LiveData,那么问题来了,有必要吗?
LiveData用得好好的,有必要再学Flow吗?本文主要回答这个问题,具体包括以下内容
1.LiveData有什么不足?
2.Flow介绍以及为什么会有Flow
3.SharedFlowStateFlow的介绍与它们之间的区别

本文具体目录如下所示:

1. LiveData有什么不足?

1.1 为什么引入LiveData?

要了解LiveData的不足,我们先了解下LiveData为什么被引入

LiveData 的历史要追溯到 2017 年。彼时,观察者模式有效简化了开发,但诸如 RxJava 一类的库对新手而言有些太过复杂。为此,架构组件团队打造了 LiveData: 一个专用于 Android 的具备自主生命周期感知能力的可观察的数据存储器类。LiveData 被有意简化设计,这使得开发者很容易上手;而对于较为复杂的交互数据流场景,建议您使用 RxJava,这样两者结合的优势就发挥出来了

可以看出,LiveData就是一个简单易用的,具备感知生命周期能力的观察者模式
它使用起来非常简单,这是它的优点,也是它的不足,因为它面对比较复杂的交互数据流场景时,处理起来比较麻烦

1.2 LiveData的不足

我们上文说过LiveData结构简单,但是不够强大,它有以下不足
1.LiveData只能在主线程更新数据
2.LiveData的操作符不够强大,在处理复杂数据流时有些捉襟见肘

关于LiveData只能在主线程更新数据,有的同学可能要问,不是有postValue吗?其实postValue也是需要切换到到主线程的,如下图所示:

这意味着当我们想要更新LiveData对象时,我们会经常更改线程(工作线程→主线程),如果在修改LiveData后又要切换回到工作线程那就更麻烦了,同时postValue可能会有丢数据的问题。

2. Flow介绍

Flow 就是 Kotlin 协程与响应式编程模型结合的产物,你会发现它与 RxJava 非常像,二者之间也有相互转换的 API,使用起来非常方便。

2.1 为什么引入Flow

为什么引入Flow,我们可以从Flow解决了什么问题的角度切入

  1. LiveData不支持线程切换,所有数据转换都将在主线程上完成,有时需要频繁更改线程,面对复杂数据流时处理起来比较麻烦
  2. RxJava又有些过于麻烦了,有许多让人傻傻分不清的操作符,入门门槛较高,同时需要自己处理生命周期,在生命周期结束时取消订阅

可以看出,Flow是介于LiveDataRxJava之间的一个解决方案,它有以下特点

  • Flow 支持线程切换、背压
  • Flow 入门的门槛很低,没有那么多傻傻分不清楚的操作符
  • 简单的数据转换与操作符,如 map 等等
  • 冷数据流,不消费则不生产数据,这一点与LiveData不同:LiveData的发送端并不依赖于接收端。
  • 属于kotlin协程的一部分,可以很好的与协程基础设施结合

关于Flow的使用,比较简单,有兴趣的同学可参阅文档:Flow文档

3. SharedFlow介绍

我们上面介绍过,Flow 是冷流,什么是冷流?

  • 冷流 :只有订阅者订阅时,才开始执行发射数据流的代码。并且冷流订阅者只能是一对一的关系,当有多个不同的订阅者时,消息是重新完整发送的。也就是说对冷流而言,有多个订阅者的时候,他们各自的事件是独立的。
  • 热流:无论有没有订阅者订阅,事件始终都会发生。当 热流有多个订阅者时,热流订阅者们的关系是一对多的关系,可以与多个订阅者共享信息。

3.1 为什么引入SharedFlow

上面其实已经说得很清楚了,冷流订阅者只能是一对一的关系,当我们要实现一个流,多个订阅者的需求时(这在开发中是很常见的),就需要热流
从命名上也很容易理解,SharedFlow即共享的Flow,可以实现一对多关系,SharedFlow是一种热流

3.2 SharedFlow的使用

我们来看看SharedFlow的构造函数

public fun <T> MutableSharedFlow(
replay: Int = 0,
extraBufferCapacity: Int = 0,
onBufferOverflow: BufferOverflow = BufferOverflow.SUSPEND
)
: MutableSharedFlow<T>

其主要有3个参数
1.replay表示当新的订阅者Collect时,发送几个已经发送过的数据给它,默认为0,即默认新订阅者不会获取以前的数据
2.extraBufferCapacity表示减去replayMutableSharedFlow还缓存多少数据,默认为0
3.onBufferOverflow表示缓存策略,即缓冲区满了之后Flow如何处理,默认为挂起

简单使用如下:

//ViewModel
val sharedFlow=MutableSharedFlow<String>()

viewModelScope.launch{
sharedFlow.emit("Hello")
sharedFlow.emit("SharedFlow")
}

//Activity
lifecycleScope.launch{
viewMode.sharedFlow.collect {
print(it)
}
}

3.3 将冷流转化为SharedFlow

普通flow可使用shareIn扩展方法,转化成SharedFlow

    val sharedFlow by lazy {
flow<Int> {
//...
}.shareIn(viewModelScope, WhileSubscribed(500), 0)
}

shareIn主要也有三个参数:

@param scope 共享开始时所在的协程作用域范围
@param started 控制共享的开始和结束的策略
@param replay 状态流的重播个数

started 接受以下的三个值:
1.Lazily: 当首个订阅者出现时开始,在scope指定的作用域被结束时终止。
2.Eagerly: 立即开始,而在scope指定的作用域被结束时终止。
3.WhileSubscribed: 这种情况有些复杂,后面会详细讲解

对于那些只执行一次的操作,您可以使用Lazily或者Eagerly。然而,如果您需要观察其他的流,就应该使用WhileSubscribed来实现细微但又重要的优化工作

3.4 Whilesubscribed策略

WhileSubscribed策略会在没有收集器的情况下取消上游数据流,通过shareIn运算符创建的SharedFlow会把数据暴露给视图 (View),同时也会观察来自其他层级或者是上游应用的数据流。
让这些流持续活跃可能会引起不必要的资源浪费,例如一直通过从数据库连接、硬件传感器中读取数据等等。当您的应用转而在后台运行时,您应当保持克制并中止这些协程。

public fun WhileSubscribed(
stopTimeoutMillis: Long = 0,
replayExpirationMillis: Long = Long.MAX_VALUE
)

如上所示,它支持两个参数:

  • 1.stopTimeoutMillis 控制一个以毫秒为单位的延迟值,指的是最后一个订阅者结束订阅与停止上游流的时间差。默认值是 0 (立即停止).这个值非常有用,因为您可能并不想因为视图有几秒钟不再监听就结束上游流。这种情况非常常见——比如当用户旋转设备时,原来的视图会先被销毁,然后数秒钟内重建。
  • 2.replayExpirationMillis表示数据重播的过时时间,如果用户离开应用太久,此时您不想让用户看到陈旧的数据,你可以用到这个参数

4. StateFlow介绍

4.1 为什么引入StateFlow

我们前面刚刚看了SharedFlow,为什么又冒出个StateFlow?
StateFlow 是 SharedFlow 的一个比较特殊的变种,StateFlow 与 LiveData 是最接近的,因为:

  • 1.它始终是有值的。
  • 2.它的值是唯一的。
  • 3.它允许被多个观察者共用 (因此是共享的数据流)。
  • 4.它永远只会把最新的值重现给订阅者,这与活跃观察者的数量是无关的。

可以看出,StateFlowLiveData是比较接近的,可以获取当前的值,可以想像之所以引入StateFlow就是为了替换LiveData
总结如下:
1.StateFlow继承于SharedFlow,是SharedFlow的一个特殊变种
2.StateFlowLiveData比较相近,相信之所以推出就是为了替换LiveData

4.2 StateFlow的简单使用

我们先来看看构造函数:

public fun <T> MutableStateFlow(value: T): MutableStateFlow<T> = StateFlowImpl(value ?: NULL)

1.StateFlow构造函数较为简单,只需要传入一个默认值
2.StateFlow本质上是一个replay为1,并且没有缓冲区的SharedFlow,因此第一次订阅时会先获得默认值
3.StateFlow仅在值已更新,并且值发生了变化时才会返回,即如果更新后的值没有变化,也没会回调Collect方法,这点与LiveData不同

StateFlow类似,我们也可以用stateIn将普通流转化成SharedFlow

val result: StateFlow<Result<UiState>> = someFlow
.stateIn(
scope = viewModelScope,
started = WhileSubscribed(5000),
initialValue = Result.Loading
)

shareIn类似,唯一不同的时需要传入一个默认值
同时之所以WhileSubscribed中传入了5000,是为了实现等待5秒后仍然没有订阅者存在就终止协程的功能,这个方法有以下功能

  • 用户将您的应用转至后台运行,5 秒钟后所有来自其他层的数据更新会停止,这样可以节省电量。
  • 最新的数据仍然会被缓存,所以当用户切换回应用时,视图立即就可以得到数据进行渲染。
  • 订阅将被重启,新数据会填充进来,当数据可用时更新视图。
  • 在屏幕旋转时,因为重新订阅的时间在5s内,因此上游流不会中止

4.3 在页面中观察StateFlow

LiveData类似,我们也需要经常在页面中观察StateFlow
观察StateFlow需要在协程中,因此我们需要协程构建器,一般我们会使用下面几种

  1. lifecycleScope.launch : 立即启动协程,并且在本 ActivityFragment 销毁时结束协程。
  2. LaunchWhenStarted 和 LaunchWhenResumed,它会在lifecycleOwner进入X状态之前一直等待,又在离开X状态时挂起协程


如上图所示:
1.使用launch是不安全的,在应用在后台时也会接收数据更新,可能会导致应用崩溃
2.使用launchWhenStartedlaunchWhenResumed会好一些,在后台时不会接收数据更新,但是,上游数据流会在应用后台运行期间保持活跃,因此可能浪费一定的资源

这么说来,我们使用WhileSubscribed进行的配置岂不是无效了吗?订阅者一直存在,只有页面关闭时才会取消订阅
官方推荐repeatOnLifecycle来构建协程
在某个特定的状态满足时启动协程,并且在生命周期所有者退出该状态时停止协程,如下图所示。

比如在某个Fragment的代码中:

onCreateView(...) {
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.lifecycle.repeatOnLifecycle(STARTED) {
myViewModel.myUiState.collect { ... }
}
}
}

当这个Fragment处于STARTED状态时会开始收集流,并且在RESUMED状态时保持收集,最终在Fragment进入STOPPED状态时结束收集过程。
结合使用repeatOnLifecycle APIWhileSubscribed,可以帮助您的应用妥善利用设备资源的同时,发挥最佳性能

4.4 页面中观察Flow的最佳方式

通过ViewModel暴露数据,并在页面中获取的最佳方式是:

  • ?? 使用带超时参数的 WhileSubscribed 策略暴露 Flow示例 1
  • ?? 使用 repeatOnLifecycle 来收集数据更新。示例 2


最佳实践如上图所示,如果采用其他方式,上游数据流会被一直保持活跃,导致资源浪费
当然,如果您并不需要使用到Kotlin Flow的强大功能,就用LiveData好了 :)

StateFlowSharedFlow有什么区别?

从上文其实可以看出,StateFlowSharedFlow其实是挺像的,让人有些傻傻分不清,有时候也挺难选择该用哪个的

我们总结一下,它们的区别如下:

  1. SharedFlow配置更为灵活,支持配置replay,缓冲区大小等,StateFlowSharedFlow的特化版本,replay固定为1,缓冲区大小默认为0
  2. StateFlowLiveData类似,支持通过myFlow.value获取当前状态,如果有这个需求,必须使用StateFlow
  3. SharedFlow支持发出和收集重复值,而StateFlowvalue重复时,不会回调collect
  4. 对于新的订阅者,StateFlow只会重播当前最新值,SharedFlow可配置重播元素个数(默认为0,即不重播)

可以看出,StateFlow为我们做了一些默认的配置,在SharedFlow上添加了一些默认约束,这些配置可能并不符合我们的要求

  1. 它忽略重复的值,并且是不可配置的。这会带来一些问题,比如当往List中添加元素并更新时,StateFlow会认为是重复的值并忽略
  2. 它需要一个初始值,并且在开始订阅时会回调初始值,这有可能不是我们想要的
  3. 它默认是粘性的,新用户订阅会获得当前的最新值,而且是不可配置的,而SharedFlow可以修改replay

StateFlow施加在SharedFlow上的约束可能不是最适合您,如果不需要访问myFlow.value,并且享受SharedFlow的灵活性,可以选择考虑使用SharedFlow

总结

简单往往意味着不够强大,而强大又常常意味着复杂,两者往往不能兼得,软件开发过程中常常面临这种取舍。
LiveData的简单并不是它的缺点,而是它的特点。StateFlowSharedFlow更加强大,但是学习成本也显著的更高.
我们应该根据自己的需求合理选择组件的使用

  1. 如果你的数据流比较简单,不需要进行线程切换与复杂的数据变换,LiveData对你来说相信已经足够了
  2. 如果你的数据流比较复杂,需要切换线程等操作,不需要发送重复值,需要获取myFlow.valueStateFlow对你来说是个好的选择
  3. 如果你的数据流比较复杂,同时不需要获取myFlow.value,需要配置新用户订阅重播无素的个数,或者需要发送重复的值,可以考虑使用SharedFlow


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

收起阅读 »

实战:5分钟搞懂OkHttp断点上传

1、前言 经常会有同学问:文件的断点上传如何实现? 断点上传/下载,这是在客户端经常遇到的场景,当我们需要上传或下载一个大文件时,都会考虑使用断点续传的方式。 断点上传相较于断点下载来说,最大的区别就在于断点位置的记录,上传记录在服务端,下载记录在客户端...
继续阅读 »

1、前言


经常会有同学问:文件的断点上传如何实现?


断点上传/下载,这是在客户端经常遇到的场景,当我们需要上传或下载一个大文件时,都会考虑使用断点续传的方式。


断点上传相较于断点下载来说,最大的区别就在于断点位置的记录,上传记录在服务端,下载记录在客户端,因此,客户端需要在上传前,通过接口去拿到文件的断点位置,然后在上传时,将文件输入流跳转到断点位置


2、准备工作


对于文件上传,其实就是打开文件的输入流,不停的读取数据到byte数组中,随后写出到服务端;那客户端要做的就是跳过已经上传的部分,也就是直接跳到断点位置,这样就可以从断点位置去读取数据,也就达到了断点上传的目的。


伪代码如下:


String filePath = "...";
long skipSize = 100; //假设断点位置是 100 byte
InputStream input = input = new FileInputStream(filePath);
input.skip(skipSize) //跳转到断点位置

然而,OkHttp并没有直接提供设置断点的方法,所以需要客户端自定义RequestBody,取名为FileRequestBody,如下:


//为简化阅读,已省略部分代码
public class FileRequestBody extends RequestBody {

private final File file;
private final long skipSize; //断点位置
private final MediaType mediaType;

public FileRequestBody(File file, long skipSize, @Nullable MediaType mediaType) {
this.file = file;
this.skipSize = skipSize;
this.mediaType = mediaType;
}

@Override
public long contentLength() throws IOException {
return file.length() - skipSize;
}

@Override
public void writeTo(@NotNull BufferedSink sink) throws IOException {
InputStream input = null;
Source source = null;
try {
input = new FileInputStream(file);
if (skipSize > 0) {
input.skip(skipSize); //跳到断点位置
}
source = Okio.source(input);
sink.writeAll(source);
} finally {
OkHttpCompat.closeQuietly(source, input);
}
}
}


为方便阅读,以上省略部分源码,FileRequestBody类完整源码



有了FileRequestBody类,我们只需要传入一个断点位置,剩下的工作就跟普通的文件上传一样。 接下来,直接进入代码实现。


3、代码实现


3.1 获取断点位置


首先,需要服务端提供一个接口,通过userId去查找该用户未上传完成的任务列表,代码如下:


RxHttp.get("/.../getToUploadTask")
.add("userId", "88888888")
.asList<ToUploadTask>()
.subscribe({
//成功回调,这里通过 it 拿到 List<ToUploadTask>
}, {
//异常回调
});

其中ToUploadTask类如下:


//待上传任务
data class ToUploadTask(
val md5: String, //文件的md5,用于验证文件的唯一性
val filePath: String, //文件在客户端的绝对路径
val skipSize: Long = 0 //断点位置
)

注:md5、filePath 这两个参数需要客户端在文件上传时传递给服务端,用于对文件的校验,防止文件错乱


3.2 断点上传


有了待上传任务,客户端就可以执行断点上传操作,OkHttp代码如下:


fun uploadFile(uploadTask: ToUploadTask) {
//1.校验文件是否存在
val file = File(uploadTask.filePath)
if (!file.exists() && !file.isFile) return
//2.校验文件的 md5 值
val fileMd5 = FileUtils.getFileMD5ToString(file)
if (!fileMd5.equals(uploadTask.md5)) return
//3.构建请求体
val fileRequestBody = FileRequestBody(file, uploadTask.skipSize, BuildUtil.getMediaType(file.name))
val multipartBody = MultipartBody.Builder()
.addFormDataPart("userId", "88888888")
.addFormDataPart("md5", fileMd5)
.addFormDataPart("filePath", file.absolutePath)
.addFormDataPart("file", file.name, fileRequestBody) //添加文件body
.build()
//4.构建请求
val request = Request.Builder()
.url("/.../uploadFile")
.post(multipartBody)
.build()
//5.执行请求
val okClient = OkHttpClient.Builder().build()
okClient.newCall(request).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
//异常回调
}
override fun onResponse(call: Call, response: Response) {
//成功回调
}
})
}


FIleUtils源码BuildUtil源码



当然,考虑到很少人会直接使用OkHttp,所以这里也贴出RxHttp的实现代码,很简单,仅需构建一个UpFile对象即可,就可很方便的监听上传进度,代码如下:


fun uploadFile(uploadTask: ToUploadTask) {                            
//1.校验文件是否存在
val file = File(uploadTask.filePath)
if (!file.exists() && !file.isFile) return
//2.校验文件的 md5 值
val fileMd5 = FileUtils.getFileMD5ToString(file)
if (!fileMd5.equals(uploadTask.md5)) return
val upFile = UpFile("file", file, file.name, uploadTask.skipSize)
//3.直接上传
RxHttp.postForm("/.../uploadFile")
.add("userId", "88888888")
.add("md5", fileMd5)
.add("filePath", file.absolutePath)
.addFile(upFile)
.upload(AndroidSchedulers.mainThread()) {
//上传进度回调
}
.asString()
.subscribe({
//成功回调
}, {
//异常回调
})
}

4、小结


断点上传相较普通的文件上传,客户端多了一个断点的设置,大部分工作量在服务端,服务端不仅需要处理文件的拼接逻辑,还需记录未上传完成的任务,并通过接口暴露给客户端。



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

iOS底层探索开发 必不可少的 clang插件

Clang插件LLVM下载由于国内的网络限制,我们需要借助镜像下载LLVM的源码https://mirror.tuna.tsinghua.edu.cn/help/llvm/下载llvm项目git clone https://mirrors.tuna....
继续阅读 »

Clang插件

LLVM下载

由于国内的网络限制,我们需要借助镜像下载LLVM的源码

https://mirror.tuna.tsinghua.edu.cn/help/llvm/

  • 下载llvm项目

git clone https://mirrors.tuna.tsinghua.edu.cn/git/llvm/llvm.git

  • 在LLVM的tools目录下下载Clang

cd llvm/tools
git clone https://mirrors.tuna.tsinghua.edu.cn/git/llvm/clang.git

  • 在 LLVM 的 projects 目录下下载 compiler-rt, libcxx, libcxxabi

cd ../projects
git clone https://mirrors.tuna.tsinghua.edu.cn/git/llvm/compiler-rt.g it
git clone https://mirrors.tuna.tsinghua.edu.cn/git/llvm/libcxx.git
git clone https://mirrors.tuna.tsinghua.edu.cn/git/llvm/libcxxabi.git

  • 在Clang的tools下安装extra工具

cd ../tools/clang/tools
git clone https://mirrors.tuna.tsinghua.edu.cn/git/LLvm/cLang-tooLs-e xtra.git

LLVM编译

由于最新的LLVM只支持c make来编译了,我们还需要安装c make。

安装cmake

  • 查看brew是否安装cmake如果有就跳过下面步骤

brew list

  • 通过brew安装cmake

brew install cmake

编译LLVM

通过xcode编译LLVM

  • cmake编译成Xcode项目

mkdir build_xcode
cd build_xcode
cmake -G Xcode ../llvm

  • 使用Xcode编译Clang。
    • 选择自动创建Schemes




  • 在HKPlugin目录下新建一个名为HKPlugin.cpp的文件和CMakeLists.txt的文件。在CMakeLists.txt中写上

add_llvm_library( HKPlugin MODULE BUILDTREE_ONLY
HKPlugin.cpp
)
接下来利用cmake重新生成一下Xcode项目,在build_xcode中cmake -g Xcode ../llvm

  • 最后可以在LLVM的Xcode项目中可以看到Loadable modules目录下有自己 的Plugin目录了。我们可以在里面编写插件代码。


添加下自己的插件,等下编译

#include <iostream>
#include "clang/AST/AST.h"
#include "clang/AST/DeclObjC.h"
#include "clang/AST/ASTConsumer.h"
#include "clang/ASTMatchers/ASTMatchers.h"
#include "clang/Frontend/CompilerInstance.h"
#include "clang/ASTMatchers/ASTMatchFinder.h"
#include "clang/Frontend/FrontendPluginRegistry.h"
using namespace clang;
using namespace std;
using namespace llvm;
using namespace clang::ast_matchers;

namespace HKPlugin {
class HKConsumer: public ASTConsumer {
public:
//解析一个顶级的声明就回调一次
bool HandleTopLevelDecl(DeclGroupRef D) {
cout<<"正在解析..."<<endl;
return true;
}

//整个文件都解析完成的回调
void HandleTranslationUnit(ASTContext &Ctx) {
cout << "文件解析完毕" << endl;
}
};

//继承PluginASTAction 实现我们自定义的Action
class HKASTAction:public PluginASTAction{
public:
//重写
bool ParseArgs(const CompilerInstance &CI, const std::vector<std::string> &arg){
return true;
}

std::unique_ptr<ASTConsumer> CreateASTConsumer(CompilerInstance &CI, StringRef InFile) {
return unique_ptr<HKConsumer>(new HKConsumer);
}
};
}
//注册插件

static FrontendPluginRegistry::Add<HKPlugin::HKASTAction> HK("HKPlugin","this is HKPlugin");

先简单写些测试代码,然后编译生成dylib



int sum(int a);
int a;
int sum(int a){
int b = 10;
return 10 + b;
}
int sum2(int a,int b){
int c = 10;
return a + b + c;
}


写些测试代码

自己编译的 clang 文件路径 -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator13.5.sdk/ -Xclang -load -Xclang 自己编译的 clang 文件路径 -Xclang -add-plugin -Xclang 自己编译的 clang 文件路径 -c 自己编译的 clang 文件路径

例: /Users/xxx/Desktop/LLVMENV/build_xcode/Debug/bin/clang -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator13.5.sdk/ -Xclang -load -Xclang /Users/xxx/Desktop/LLVMENV/build_xcode/Debug/lib/HKPlugin.dylib -Xclang -add-plugin -Xclang HKPlugin -c ./hello.m
注:iPhoneSimulator13.5.sdk换成自己目录下的sdk版本


正在解析...
正在解析...
正在解析...
正在解析...
文件解析完毕

现在在viewController中声明属性

#import "ViewController.h"

@interface ViewController ()
@property(nonatomic, strong) NSDictionary* dict;
@property(nonatomic, strong) NSArray* arr;
@property(nonatomic, strong) NSString* name;
@end

@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
// Do any additional setup after loading the view.
}
@end

然后通过语法分析,查看抽象语法树

clang -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator13.5.sdk -fmodules -fsyntax-only -Xclang -ast-dump ViewController.m




TranslationUnitDecl 0x7f9e57000008 <<invalid sloc>> <invalid sloc> <undeserialized declarations>
|-TypedefDecl 0x7f9e570008a0 <<invalid sloc>> <invalid sloc> implicit __int128_t '__int128'
| `-BuiltinType 0x7f9e570005a0 '__int128'
|-TypedefDecl 0x7f9e57000910 <<invalid sloc>> <invalid sloc> implicit __uint128_t 'unsigned __int128'
| `-BuiltinType 0x7f9e570005c0 'unsigned __int128'
|-TypedefDecl 0x7f9e570009b0 <<invalid sloc>> <invalid sloc> implicit SEL 'SEL *'
| `-PointerType 0x7f9e57000970 'SEL *' imported
| `-BuiltinType 0x7f9e57000800 'SEL'
|-TypedefDecl 0x7f9e57000a98 <<invalid sloc>> <invalid sloc> implicit id 'id'
| `-ObjCObjectPointerType 0x7f9e57000a40 'id' imported
| `-ObjCObjectType 0x7f9e57000a10 'id' imported
|-TypedefDecl 0x7f9e57000b78 <<invalid sloc>> <invalid sloc> implicit Class 'Class'
| `-ObjCObjectPointerType 0x7f9e57000b20 'Class' imported
| `-ObjCObjectType 0x7f9e57000af0 'Class' imported
|-ObjCInterfaceDecl 0x7f9e57000bd0 <<invalid sloc>> <invalid sloc> implicit Protocol
|-TypedefDecl 0x7f9e57000f48 <<invalid sloc>> <invalid sloc> implicit __NSConstantString 'struct __NSConstantString_tag'
| `-RecordType 0x7f9e57000d40 'struct __NSConstantString_tag'
| `-Record 0x7f9e57000ca0 '__NSConstantString_tag'
|-TypedefDecl 0x7f9e58008400 <<invalid sloc>> <invalid sloc> implicit __builtin_ms_va_list 'char *'
| `-PointerType 0x7f9e57000fa0 'char *'
| `-BuiltinType 0x7f9e570000a0 'char'
|-TypedefDecl 0x7f9e580086e8 <<invalid sloc>> <invalid sloc> implicit __builtin_va_list 'struct __va_list_tag [1]'
| `-ConstantArrayType 0x7f9e58008690 'struct __va_list_tag [1]' 1
| `-RecordType 0x7f9e580084f0 'struct __va_list_tag'
| `-Record 0x7f9e58008458 '__va_list_tag'
|-ImportDecl 0x7f9e5852bc18 <./ViewController.h:9:1> col:1 implicit UIKit
|-ObjCInterfaceDecl 0x7f9e58541e00 <line:11:1, line:14:2> line:11:12 ViewController
| |-super ObjCInterface 0x7f9e5852be78 'UIViewController'
| `-ObjCImplementation 0x7f9e5857f460 'ViewController'
|-ObjCCategoryDecl 0x7f9e58541f30 <ViewController.m:11:1, line:15:2> line:11:12
| |-ObjCInterface 0x7f9e58541e00 'ViewController'
| |-ObjCPropertyDecl 0x7f9e58548a00 <line:12:1, col:44> col:44 dict 'NSDictionary *' readwrite nonatomic strong
| |-ObjCMethodDecl 0x7f9e58548a80 <col:44> col:44 implicit - dict 'NSDictionary *'
| |-ObjCMethodDecl 0x7f9e58548c28 <col:44> col:44 implicit - setDict: 'void'
| | `-ParmVarDecl 0x7f9e58548cb0 <col:44> col:44 dict 'NSDictionary *'
| |-ObjCPropertyDecl 0x7f9e58551cd0 <line:13:1, col:39> col:39 arr 'NSArray *' readwrite nonatomic strong
| |-ObjCMethodDecl 0x7f9e58551d50 <col:39> col:39 implicit - arr 'NSArray *'
| |-ObjCMethodDecl 0x7f9e58551ea8 <col:39> col:39 implicit - setArr: 'void'
| | `-ParmVarDecl 0x7f9e58551f30 <col:39> col:39 arr 'NSArray *'
| |-ObjCPropertyDecl 0x7f9e585650d0 <line:14:1, col:40> col:40 name 'NSString *' readwrite nonatomic strong
| |-ObjCMethodDecl 0x7f9e58565150 <col:40> col:40 implicit - name 'NSString *'
| `-ObjCMethodDecl 0x7f9e585652a8 <col:40> col:40 implicit - setName: 'void'
| `-ParmVarDecl 0x7f9e58565330 <col:40> col:40 name 'NSString *'
`-ObjCImplementationDecl 0x7f9e5857f460 <line:17:1, line:25:1> line:17:17 ViewController
|-ObjCInterface 0x7f9e58541e00 'ViewController'
|-ObjCMethodDecl 0x7f9e5857f580 <line:19:1, line:22:1> line:19:1 - viewDidLoad 'void'
| |-ImplicitParamDecl 0x7f9e585c9c08 <<invalid sloc>> <invalid sloc> implicit self 'ViewController *'
| |-ImplicitParamDecl 0x7f9e585c9c70 <<invalid sloc>> <invalid sloc> implicit _cmd 'SEL':'SEL *'
| `-CompoundStmt 0x7f9e585cf2b8 <col:21, line:22:1>
| `-ObjCMessageExpr 0x7f9e585c9cd8 <line:20:5, col:23> 'void' selector=viewDidLoad super (instance)
|-ObjCIvarDecl 0x7f9e585c8168 <line:12:44> col:44 implicit _dict 'NSDictionary *' synthesize private
|-ObjCPropertyImplDecl 0x7f9e585c81c8 <<invalid sloc>, col:44> <invalid sloc> dict synthesize
| |-ObjCProperty 0x7f9e58548a00 'dict'
| `-ObjCIvar 0x7f9e585c8168 '_dict' 'NSDictionary *'
|-ObjCIvarDecl 0x7f9e585c84e0 <line:13:39> col:39 implicit _arr 'NSArray *' synthesize private
|-ObjCPropertyImplDecl 0x7f9e585c8540 <<invalid sloc>, col:39> <invalid sloc> arr synthesize
| |-ObjCProperty 0x7f9e58551cd0 'arr'
| `-ObjCIvar 0x7f9e585c84e0 '_arr' 'NSArray *'
|-ObjCIvarDecl 0x7f9e585c9890 <line:14:40> col:40 implicit _name 'NSString *' synthesize private
|-ObjCPropertyImplDecl 0x7f9e585c98f0 <<invalid sloc>, col:40> <invalid sloc> name synthesize
| |-ObjCProperty 0x7f9e585650d0 'name'
| `-ObjCIvar 0x7f9e585c9890 '_name' 'NSString *'
|-ObjCMethodDecl 0x7f9e585c82f8 <line:12:44> col:44 implicit - dict 'NSDictionary *'
|-ObjCMethodDecl 0x7f9e585c8450 <col:44> col:44 implicit - setDict: 'void'
| `-ParmVarDecl 0x7f9e58548cb0 <col:44> col:44 dict 'NSDictionary *'
|-ObjCMethodDecl 0x7f9e585c8670 <line:13:39> col:39 implicit - arr 'NSArray *'
|-ObjCMethodDecl 0x7f9e585c9800 <col:39> col:39 implicit - setArr: 'void'
| `-ParmVarDecl 0x7f9e58551f30 <col:39> col:39 arr 'NSArray *'
|-ObjCMethodDecl 0x7f9e585c9a20 <line:14:40> col:40 implicit - name 'NSString *'
`-ObjCMethodDecl 0x7f9e585c9b78 <col:40> col:40 implicit - setName: 'void'
`-ParmVarDecl 0x7f9e58565330 <col:40> col:40 name 'NSString *'

我们可以找到其中的属性节点和他的修饰符

| |-ObjCPropertyDecl 0x7f9e585650d0 <line:14:1, col:40> col:40 name 'NSString *' readwrite nonatomic strong
完整代码如下

#include <iostream>
#include "clang/AST/AST.h"
#include "clang/AST/DeclObjC.h"
#include "clang/AST/ASTConsumer.h"
#include "clang/ASTMatchers/ASTMatchers.h"
#include "clang/Frontend/CompilerInstance.h"
#include "clang/ASTMatchers/ASTMatchFinder.h"
#include "clang/Frontend/FrontendPluginRegistry.h"
using namespace clang;
using namespace std;
using namespace llvm;
using namespace clang::ast_matchers;

namespace HKPlugin {

class HKMatchCallback:public MatchFinder::MatchCallback{
private:
CompilerInstance &CI;

bool isUserSourceCode(const string fileName){
if (fileName.empty()) return false;
//非xcode中的源码都认为是用户的
if(fileName.find("/Applications/Xcode.app/") == 0)return false;
return true;
}

//判断是否应该用copy修饰
bool isShouldUseCopy(const string typeStr){
if (typeStr.find("NSString") != string::npos ||
typeStr.find("NSArray") != string::npos ||
typeStr.find("NSDictionary") != string::npos ) {
return true;
}
return false;
}
public:
HKMatchCallback(CompilerInstance &CI):CI(CI){}
void run(const MatchFinder::MatchResult &Result) {
//通过Result获得节点
//之前绑定的标识
const ObjCPropertyDecl * propertyDecl = Result.Nodes.getNodeAs<ObjCPropertyDecl>("objcPropertyDecl");


//获取文件名称
string fileName = CI.getSourceManager().getFilename(propertyDecl-> getSourceRange().getBegin()).str();

//判断节点有值并且是用户文件
if (propertyDecl && isUserSourceCode(fileName)) {
//节点类型转为字符串
string typeStr = propertyDecl->getType().getAsString();
//拿到及诶单的描述信息
ObjCPropertyDecl::PropertyAttributeKind attrKind = propertyDecl->getPropertyAttributes();
//判断应该使用copy但是没有使用copy
if (isShouldUseCopy(typeStr) && !(attrKind & clang::ObjCPropertyDecl::OBJC_PR_copy)) {
cout << typeStr << "应该用copy修饰!但你没有" << endl;
//诊断引擎
DiagnosticsEngine &diag = CI.getDiagnostics();
//Report 报告
diag.Report(propertyDecl->getBeginLoc(),diag.getCustomDiagID(DiagnosticsEngine::Warning, "--- %0 这个地方推荐使用copy"))<<typeStr
}
// cout<<"--拿到了:"<<typeStr<<"---属于文件:"<<fileName<<endl;

}
}
};

class HKConsumer: public ASTConsumer {
private:
//AST节点的查找过滤器
MatchFinder matcher;
HKMatchCallback callback;
public:
HKConsumer(CompilerInstance &CI):callback(CI){
//添加一个MatchFinder去匹配objcPropertyDecl节点
//回调在HKMatchCallback里面run方法

matcher.addMatcher(objcPropertyDecl().bind("objcPropertyDecl"), &callback);
}
//解析一个顶级的声明就回调一次
bool HandleTopLevelDecl(DeclGroupRef D) {
cout<<"正在解析..."<<endl;
return true;
}

//整个文件都解析完成的回调
void HandleTranslationUnit(ASTContext &Ctx) {
cout << "文件解析完毕" << endl;
matcher.matchAST(Ctx);//将语法树交给过滤器
}
};

//继承PluginASTAction 实现我们自定义的Action
class HKASTAction:public PluginASTAction{
public:
//重写
bool ParseArgs(const CompilerInstance &CI, const std::vector<std::string> &arg){
return true;
}

std::unique_ptr<ASTConsumer> CreateASTConsumer(CompilerInstance &CI, StringRef InFile) {
//ASTConsumer是一个抽象类,这里返回一个自定义的类来继承
return unique_ptr<HKConsumer>(new HKConsumer(CI));
}
};
}
//注册插件

static FrontendPluginRegistry::Add<HKPlugin::HKASTAction> HK("HKPlugin","this is HKPlugin");






作者:Mjs
链接:https://www.jianshu.com/p/d613d935662d



收起阅读 »

OC底层原理-动态方法决议

当lookupImpOrForward函数从cache和methodTable中找不到对应Method,继续向下执行就会来到resolveMethod_locked函数也就是我们常说的动态方法决议 if (slowpath(behavior & ...
继续阅读 »

当lookupImpOrForward函数从cache和methodTable中找不到对应Method,继续向下执行就会来到resolveMethod_locked函数也就是我们常说的动态方法决议


    if (slowpath(behavior & LOOKUP_RESOLVER)) {
behavior ^= LOOKUP_RESOLVER;
return resolveMethod_locked(inst, sel, cls, behavior);
}

resolveMethod_locked

    runtimeLock.assertLocked();
ASSERT(cls->isRealized());

runtimeLock.unlock();

if (! cls->isMetaClass()) {
// try [cls resolveInstanceMethod:sel]
resolveInstanceMethod(inst, sel, cls);
}
else {
// try [nonMetaClass resolveClassMethod:sel]
// and [cls resolveInstanceMethod:sel]
resolveClassMethod(inst, sel, cls);
if (!lookUpImpOrNilTryCache(inst, sel, cls)) {
resolveInstanceMethod(inst, sel, cls);
}
}

// chances are that calling the resolver have populated the cache
// so attempt using it
return lookUpImpOrForwardTryCache(inst, sel, cls, behavior);

只执行一次

behavior & LOOKUP_RESOLVER
behavior ^= LOOKUP_RESOLVER;
这俩步操作保证resolveMethod_locked只被执行一次

resolveInstanceMethod


static void resolveInstanceMethod(id inst, SEL sel, Class cls)
{
runtimeLock.assertUnlocked();
ASSERT(cls->isRealized());
SEL resolve_sel = @selector(resolveInstanceMethod:);

if (!lookUpImpOrNilTryCache(cls, resolve_sel, cls->ISA(/*authenticated*/true))) {
// Resolver not implemented.
//根类NSObject有默认实现兜底,不会走到这里
return;
}

BOOL (*msg)(Class, SEL, SEL) = (typeof(msg))objc_msgSend;
//向cls对象发送resolveInstanceMethod:消息,参数为当前的sel
bool resolved = msg(cls, resolve_sel, sel);

// Cache the result (good or bad) so the resolver doesn't fire next time.
// +resolveInstanceMethod adds to self a.k.a. cls
//从方法缓存中再快速查找一遍
IMP imp = lookUpImpOrNilTryCache(inst, sel, cls);

if (resolved && PrintResolving) {
if (imp) {
_objc_inform("RESOLVE: method %c[%s %s] "
"dynamically resolved to %p",
cls->isMetaClass() ? '+' : '-',
cls->nameForLogging(), sel_getName(sel), imp);
}
else {
// Method resolver didn't add anything?
_objc_inform("RESOLVE: +[%s resolveInstanceMethod:%s] returned YES"
", but no new implementation of %c[%s %s] was found",
cls->nameForLogging(), sel_getName(sel),
cls->isMetaClass() ? '+' : '-',
cls->nameForLogging(), sel_getName(sel));
}
}
}

resolveClassMethod


static void resolveClassMethod(id inst, SEL sel, Class cls)
{
runtimeLock.assertUnlocked();
ASSERT(cls->isRealized());
ASSERT(cls->isMetaClass());

if (!lookUpImpOrNilTryCache(inst, @selector(resolveClassMethod:), cls)) {
// Resolver not implemented.
// NSObject有兜底实现
return;
}

Class nonmeta;
{
mutex_locker_t lock(runtimeLock);
nonmeta = getMaybeUnrealizedNonMetaClass(cls, inst);
// +initialize path should have realized nonmeta already
if (!nonmeta->isRealized()) {
_objc_fatal("nonmeta class %s (%p) unexpectedly not realized",
nonmeta->nameForLogging(), nonmeta);
}
}
BOOL (*msg)(Class, SEL, SEL) = (typeof(msg))objc_msgSend;
bool resolved = msg(nonmeta, @selector(resolveClassMethod:), sel);

// Cache the result (good or bad) so the resolver doesn't fire next time.
// +resolveClassMethod adds to self->ISA() a.k.a. cls
IMP imp = lookUpImpOrNilTryCache(inst, sel, cls);

if (resolved && PrintResolving) {
if (imp) {
_objc_inform("RESOLVE: method %c[%s %s] "
"dynamically resolved to %p",
cls->isMetaClass() ? '+' : '-',
cls->nameForLogging(), sel_getName(sel), imp);
}
else {
// Method resolver didn't add anything?
_objc_inform("RESOLVE: +[%s resolveClassMethod:%s] returned YES"
", but no new implementation of %c[%s %s] was found",
cls->nameForLogging(), sel_getName(sel),
cls->isMetaClass() ? '+' : '-',
cls->nameForLogging(), sel_getName(sel));
}
}
}

resolveMethod_locked会发送resolveInstanceMethod:和resolveClassMethod:消息,为了减少程序的崩溃提用户体验,苹果在这里给开发者一次机会去补救,这个过程就叫做动态方法决议。这里也体现了aop编程思想,在objc_msg流程中给开发者提供了一个切面,切入自己想要处理,比如安全处理,日志收集等等。

resolveClassMethod


static void resolveClassMethod(id inst, SEL sel, Class cls)
{
runtimeLock.assertUnlocked();
ASSERT(cls->isRealized());
ASSERT(cls->isMetaClass());

if (!lookUpImpOrNilTryCache(inst, @selector(resolveClassMethod:), cls)) {
// Resolver not implemented.
return;
}

Class nonmeta;
{
mutex_locker_t lock(runtimeLock);
nonmeta = getMaybeUnrealizedNonMetaClass(cls, inst);
// +initialize path should have realized nonmeta already
if (!nonmeta->isRealized()) {
_objc_fatal("nonmeta class %s (%p) unexpectedly not realized",
nonmeta->nameForLogging(), nonmeta);
}
}
BOOL (*msg)(Class, SEL, SEL) = (typeof(msg))objc_msgSend;
bool resolved = msg(nonmeta, @selector(resolveClassMethod:), sel);

// Cache the result (good or bad) so the resolver doesn't fire next time.
// +resolveClassMethod adds to self->ISA() a.k.a. cls
IMP imp = lookUpImpOrNilTryCache(inst, sel, cls);

if (resolved && PrintResolving) {
if (imp) {
_objc_inform("RESOLVE: method %c[%s %s] "
"dynamically resolved to %p",
cls->isMetaClass() ? '+' : '-',
cls->nameForLogging(), sel_getName(sel), imp);
}
else {
// Method resolver didn't add anything?
_objc_inform("RESOLVE: +[%s resolveClassMethod:%s] returned YES"
", but no new implementation of %c[%s %s] was found",
cls->nameForLogging(), sel_getName(sel),
cls->isMetaClass() ? '+' : '-',
cls->nameForLogging(), sel_getName(sel));
}
}
}

看完源码思考俩个问题

1.为什么在resloveInstanceMethod函数中调用了一次lookUpImpOrNilTryCache,resolveMethod_locked函数最后又调用了一次lookUpImpOrNilTryCache?这俩次分别有什么作用?

  • 第一次TryCache流程分析

堆栈信息-->第一次tryCache会把我动态添加的方法存进cache

本次TryCache,会调用lookUpImpOrForWard函数查找MethodTable。入参behavior值为4,找不到imp的话不会再走动态决议和消息转发,直接return nil,分支如下:

    if (slowpath((behavior & LOOKUP_NIL) && imp == forward_imp)) {
return nil;
}
所以这次tryCache实际的作用就是在动态决议添加方法之后,找到方法,并调用log_and_fill_cache函数存进缓存(佐证了下面这段注释)

   // Cache the result (good or bad) so the resolver doesn't fire next time.
// +resolveInstanceMethod adds to self a.k.a. cls
  • 第二次TryCache流程分析

这次我们直接看注释吧

    // chances are that calling the resolver have populated the cache
// so attempt using it

调用动态决议可能填充了得缓存,并尝试使用它。嗯,第二次tryCache的作用已经简单明了。
本次调用入参behavior值为1,methodTable查找不到imp不会走动态决议流程,但会调用消息转发

  • 为什么分为俩次呢,一次不行吗?

为什么不最后查找方法,填充缓存再返回,反而要先填充缓存,再尝试从缓存中查找,这么做有什么好处呢?

有个关于多线程的猜想:

假如线程a发送消息s进入了动态决议流程,此时线程b也发送消息s,这时候如果缓存中有已添加的imp响应消息s,是不是就不会继续慢速查找,动态决议等后续流程。这么想,动态决议添加的方法是不是越先添加到缓存越好。

另外一点我们看到resolveClassMethod之后,也尝试从缓存中查找,而且找不到又调用了一遍resolveInstanceMethod。

可已看出苹果开发者在设计这段流程的思考🤔可能是:
既然你愿意通过动态方法决议去添加这个imp,费了这么大功夫,很显然你想使用该imp,而且使用的频率可能不低。既然如此在resolver方法调用完毕,我就帮你放进缓存吧。以后你想用直接从缓存中找。

2. 为什么类resolver之后会尝试调用instance的resolver?难道instance的resolver还能解决类方法缺失的问题?

关于这个问题,我们来看张经典的

如果我们查找一个类方法沿着继承链最终会找到NSObject(rootMetaClass的父类是NSObject),这会导致一个有意思的问题:我们的NSObject对象方法可以响应类方法的sel

看个实例

给NSObect添加个instaceMethod



是不是很惊喜,其实我们底层对classMethod和InstanceMethod根本没有区分,classMethod也是InstanceMethod

* class_getClassMethod.  Return the class method for the specified
* class and selector.
**********************************************************************/
Method class_getClassMethod(Class cls, SEL sel)
{
if (!cls || !sel) return nil;

return class_getInstanceMethod(cls->getMeta(), sel);
}
只不过,找classMethod是从MetaClass查找InstanceMethod,找InstanceMethod是从class找InstanceMethod。
透过现象看本质,这里就可以解释,为什么resolveClass完毕,缓存中找不到imp,会再次调用resolveInstance。显然,我们给NSObject添加InstanceMethod可以解决问题,而且可以在这里我们也可以添加classMethod。毕竟classMethod也是InstanceMethod。






作者:可可先生_3083
链接:https://www.jianshu.com/p/2d1372b4d2c9





收起阅读 »

iOS 攻防 - DYLD_INSERT_LIBRARIES

Tweak是通过DYLD_INSERT_LIBRARIES来插入动态库的,那么它是怎么做到的呢?这就需要去dyld源码中探究了。一、 DYLD_INSERT_LIBRARIES原理由于dyld源码中b不同版本有变动,需要分别看下新老版本的实现1.1 dyld-...
继续阅读 »

Tweak是通过DYLD_INSERT_LIBRARIES来插入动态库的,那么它是怎么做到的呢?这就需要去dyld源码中探究了。

一、 DYLD_INSERT_LIBRARIES原理

由于dyld源码中b不同版本有变动,需要分别看下新老版本的实现

1.1 dyld-519.2.2 源码

打开dyld源码工程,搜索DYLD_INSERT_LIBRARIES关键字,在dyld.cpp5906行有如下代码:

// load any inserted libraries
if ( sEnv.DYLD_INSERT_LIBRARIES != NULL ) {
for (const char* const* lib = sEnv.DYLD_INSERT_LIBRARIES; *lib != NULL; ++lib)
loadInsertedDylib(*lib);
}


这段代码是判断DYLD_INSERT_LIBRARIES不为空就循环加载插入动态库


if ( gLinkContext.processIsRestricted ) {
pruneEnvironmentVariables(envp, &apple);
// set again because envp and apple may have changed or moved
setContext(mainExecutableMH, argc, argv, envp, apple);
}


这里判断进程如果受限制(processIsRestricted不为空)执行pruneEnvironmentVariablespruneEnvironmentVariables会移除DYLD_INSERT_LIBRARIES中的数据,相当于被清空了。这样插入的动态库就不会被加载了。

既然越狱插件是通过DYLD_INSERT_LIBRARIES插入的,那么只要让自己的进程受限就能起到保护作用了。

搜索processIsRestricted = true是在4696行设置值的:

// any processes with setuid or setgid bit set or with __RESTRICT segment is restricted
if ( issetugid() || hasRestrictedSegment(mainExecutableMH) ) {
gLinkContext.processIsRestricted = true;
}

issetugid不能在上架的App中设置,那么就只能设置hasRestrictedSegment了,这里传入的参数是主程序:

static bool hasRestrictedSegment(const macho_header* mh)
{
//load command 数量
const uint32_t cmd_count = mh->ncmds;
const struct load_command* const cmds = (struct load_command*)(((char*)mh)+sizeof(macho_header));
const struct load_command* cmd = cmds;
for (uint32_t i = 0; i < cmd_count; ++i) {
switch (cmd->cmd) {
case LC_SEGMENT_COMMAND:
{
const struct macho_segment_command* seg = (struct macho_segment_command*)cmd;

//dyld::log("seg name: %s\n", seg->segname);
//读取__RESTRICT SEGMENT
if (strcmp(seg->segname, "__RESTRICT") == 0) {
const struct macho_section* const sectionsStart = (struct macho_section*)((char*)seg + sizeof(struct macho_segment_command));
const struct macho_section* const sectionsEnd = &sectionsStart[seg->nsects];
for (const struct macho_section* sect=sectionsStart; sect < sectionsEnd; ++sect) {
//读取__restrict SECTION
if (strcmp(sect->sectname, "__restrict") == 0)
return true;
}
}
}
break;
}
cmd = (const struct load_command*)(((char*)cmd)+cmd->cmdsize);
}

return false;
}

这段代码的意思是判断load commands中有没有__RESTRICT SECTIONSECTION中有没有__restrict SEGMENT

也就是说只要有这个SECTION就会开启进程受限了。

1.2 dyld-851.27源码

dyld2.cpp7120行中仍然有DYLD_INSERT_LIBRARIES的判断
// load any inserted libraries
if ( sEnv.DYLD_INSERT_LIBRARIES != NULL ) {
for (const char* const* lib = sEnv.DYLD_INSERT_LIBRARIES; *lib != NULL; ++lib)
loadInsertedDylib(*lib);
}

processIsRestricted变成了一个函数

bool processIsRestricted()
{
#if TARGET_OS_OSX
return !gLinkContext.allowEnvVarsPath;
#else
return false;
#endif
}

这里可以看到只在OSX下才有效。

6667行也只有OSX下才有可能清空环境变量:

#if TARGET_OS_OSX
if ( !gLinkContext.allowEnvVarsPrint && !gLinkContext.allowEnvVarsPath && !gLinkContext.allowEnvVarsSharedCache ) {
pruneEnvironmentVariables(envp, &apple);
// set again because envp and apple may have changed or moved
setContext(mainExecutableMH, argc, argv, envp, apple);
}
else
#endif
{
checkEnvironmentVariables(envp);
defaultUninitializedFallbackPaths(envp);
}

hasRestrictedSegment也变成了OSX下专属:

#if TARGET_OS_OSX
static bool hasRestrictedSegment(const macho_header* mh)
{
const uint32_t cmd_count = mh->ncmds;
const struct load_command* const cmds = (struct load_command*)(((char*)mh)+sizeof(macho_header));
const struct load_command* cmd = cmds;
for (uint32_t i = 0; i < cmd_count; ++i) {
switch (cmd->cmd) {
case LC_SEGMENT_COMMAND:
{
const struct macho_segment_command* seg = (struct macho_segment_command*)cmd;

//dyld::log("seg name: %s\n", seg->segname);
if (strcmp(seg->segname, "__RESTRICT") == 0) {
const struct macho_section* const sectionsStart = (struct macho_section*)((char*)seg + sizeof(struct macho_segment_command));
const struct macho_section* const sectionsEnd = &sectionsStart[seg->nsects];
for (const struct macho_section* sect=sectionsStart; sect < sectionsEnd; ++sect) {
if (strcmp(sect->sectname, "__restrict") == 0)
return true;
}
}
}
break;
}
cmd = (const struct load_command*)(((char*)cmd)+cmd->cmdsize);
}

return false;
}
#endif

结论:iOS 10以前dyld会判断主程序是否有__RESTRICT,__restrict来决定是否加载DYLD_INSERT_LIBRARIESiOS 10及以后并不会进行判断直接进行了加载。


二、 DYLD_INSERT_LIBRARIES 攻防

2.1 iOS10以前攻防

2.1.1 RESTRIC段防护


Other Linker Flags中输入-Wl,-sectcreate,__RESTRICT,__restrict,/dev/null


这样通过DYLD_INSERT_LIBRARIES注入的库就无效了。越狱手机上的插件就无效了。(仅在iOS 10以下有效)。


2.1.2 修改二进制破解

针对RESTRIC的防护可以用二进制修改器将段名称修改掉,就可以绕过检测了。
修改Data中的任意一位这个值就变了:



修改后重签就可以了。


2.1.3 防止RESTRICT被修改


针对RESTRICT被修改可以在代码中判断MachO中是否有对应的RESTRIC,如果没有就证明被修改了。参考dyld源码修改判断如下:
#import <mach-o/dyld.h>

#if __LP64__
#define macho_header mach_header_64
#define LC_SEGMENT_COMMAND LC_SEGMENT_64
#define LC_SEGMENT_COMMAND_WRONG LC_SEGMENT
#define LC_ENCRYPT_COMMAND LC_ENCRYPTION_INFO
#define macho_segment_command segment_command_64
#define macho_section section_64
#else
#define macho_header mach_header
#define LC_SEGMENT_COMMAND LC_SEGMENT
#define LC_SEGMENT_COMMAND_WRONG LC_SEGMENT_64
#define LC_ENCRYPT_COMMAND LC_ENCRYPTION_INFO_64
#define macho_segment_command segment_command
#define macho_section section
#endif

static bool hp_hasRestrictedSegment(const struct macho_header* mh) {
const uint32_t cmd_count = mh->ncmds;
const struct load_command* const cmds = (struct load_command*)(((char*)mh)+sizeof(struct macho_header));
const struct load_command* cmd = cmds;
for (uint32_t i = 0; i < cmd_count; ++i) {
switch (cmd->cmd) {
case LC_SEGMENT_COMMAND: {
const struct macho_segment_command* seg = (struct macho_segment_command*)cmd;
printf("seg->segname: %s\n",seg->segname);
//dyld::log("seg name: %s\n", seg->segname);
if (strcmp(seg->segname, "__RESTRICT") == 0) {
const struct macho_section* const sectionsStart = (struct macho_section*)((char*)seg + sizeof(struct macho_segment_command));
const struct macho_section* const sectionsEnd = &sectionsStart[seg->nsects];
for (const struct macho_section* sect=sectionsStart; sect < sectionsEnd; ++sect) {
printf("sect->sectname: %s\n",sect->sectname);
if (strcmp(sect->sectname, "__restrict") == 0)
return true;
}
}
}
break;
}
cmd = (const struct load_command*)(((char*)cmd)+cmd->cmdsize);
}
return false;
}
调用

+ (void)load {
//获取主程序 macho_header
const struct macho_header *header = _dyld_get_image_header(0);
if (hp_hasRestrictedSegment(header)) {
NSLog(@"没有修改");
} else {
NSLog(@"被修改了");
}
}
这样就能知道RESTRICT有没有被修改。要Hook检测逻辑就需要找到hp_hasRestrictedSegment函数的地址进行inline hook。或者找到调用hp_hasRestrictedSegment的地方,那么在检测过程中就不能有明显的特征。一般将结果告诉服务端。或者做一些破坏功能的逻辑,比如网络请求相关的内容。

2.2 iOS10及以后攻防

2.2.1 使用DYLD源码防护(黑白名单)

既然iOS10以上系统不进行判断检测了,那么我们可以自己扫描判断哪些应该被加载哪些不能被加载。


#import <mach-o/dyld.h>

const char *whiteListLibStrs =
"/usr/lib/substitute-inserter.dylib/System/Library/Frameworks/Foundation.framework/Foundation/usr/lib/libobjc.A.dylib/usr/lib/libSystem.B.dylib/System/Library/Frameworks/UIKit.framework/UIKit/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation/System/Library/PrivateFrameworks/CoreAutoLayout.framework/CoreAutoLayout/usr/lib/libcompression.dylib/System/Library/Frameworks/CFNetwork.framework/CFNetwork/usr/lib/libarchive.2.dylib/usr/lib/libicucore.A.dylib/usr/lib/libxml2.2.dylib/usr/lib/liblangid.dylib/System/Library/Frameworks/IOKit.framework/Versions/A/IOKit/usr/lib/libCRFSuite.dylib/System/Library/PrivateFrameworks/SoftLinking.framework/SoftLinking/usr/lib/libc++abi.dylib/usr/lib/libc++.1.dylib/usr/lib/system/libcache.dylib/usr/lib/system/libcommonCrypto.dylib/usr/lib/system/libcompiler_rt.dylib/usr/lib/system/libcopyfile.dylib/usr/lib/system/libcorecrypto.dylib";

const char *blackListLibStrs =
"/usr/lib/libsubstitute.dylib/usr/lib/substitute-loader.dylib/usr/lib/libsubstrate.dylib/Library/MobileSubstrate/DynamicLibraries/RHRevealLoader";

void imageListCheck() {
//进程依赖的库数量
int count = _dyld_image_count();
//第一个为自己。过滤掉,因为每次执行的沙盒路径不一样。
for (int i = 1; i < count; i++) {
const char *image_name = _dyld_get_image_name(i);
// printf("%s",image_name);
//黑名单检测
if (strstr(blackListLibStrs, image_name)) {//不在白名单
printf("image_name in black list: %s\n",image_name);
break;
}
//白名单检测
if (!strstr(whiteListLibStrs, image_name)) {
printf("image_name not in white list: %s\n",image_name);
}
}
}

调用

+ (void)load {
imageListCheck();
}
  • 白名单可以直接通过_dyld_get_image_name获取,这里和系统版本有关。需要跑支持的系统版本获取得到并集。维护起来比较麻烦。
  • 黑名单中可以将一些越狱库和检测到的异常库放入其中。
  • 一般检测到问题直接上报服务端。不要直接表现出异常。

黑白名单一般都通过服务端下发,黑名单直接检测出问题上报服务端处理,白名单维护用来检测上报未知的库供分析更新黑白名单。

这种防护方式可以通过fishhook Hook _dyld_image_count_dyld_get_image_name来做排查是哪块做的检测从而去绕过。

  • 对于检测代码最好混淆函数名称。
  • 返回值不要返回一个布尔值,函数被hook之后或者被修改成返回YES 之后很多判断代码都没用了。最好返回特定字符串加密这种。
  • 检测到被注入时不要exit(0)完事,太明显了,这种很容易被绕过。攻防的核心不在于防护技术,而在于会不会被对方发现。微信的做法就是上报服务端封号处理。
  • 在检测到时可以悄悄对业务逻辑做一些处理,比如网络请求正常返回但是页面显示异常或者功能不全等。

没有绝对安全的代码,只不过在与会不会被对方发现以及破解的代价。如果破解代价大于收益很少有人去破解的。



作者:HotPotCat
链接:https://www.jianshu.com/p/79a24b728b99。


收起阅读 »

Git-flow作者称其不适用于持续交付?

Git
前言 Git-flow是由Vincent Driessen在2010年提出的一个Git分支模型,在这10年中,Git-flow在许多软件团队中变得非常流行,以至于人们开始将其视为某种标准。 不过最近Vincent Driessen更新了他10年前那篇著名的A...
继续阅读 »

前言


Git-flow是由Vincent Driessen在2010年提出的一个Git分支模型,在这10年中,Git-flow在许多软件团队中变得非常流行,以至于人们开始将其视为某种标准。
不过最近Vincent Driessen更新了他10年前那篇著名的A successful Git branching model,大意是Git-flow已不适用于当今持续交付的软件工程方式,推荐更简单的Github flow等模型


Git-flow作者都承认Git-flow不适合持续交付了,那我们更有必要好好研究一下了,以免掉坑里。
本文主要包括以下内容:
1.Git-flow介绍
2.为什么Git-flow不适用于持续交付?
3.Github flow介绍
4.Gitlab flow介绍


1. Git-flow是什么?


Git-flow是由Vincent Driessen在2010年提出的一个Git分支模型,其结构图如下所示,相信大家都看过

Git-flow主要包括以下分支



  • master 是长期分支,一般用于管理对外发布版本,每个commit对一个tag,也就是一个发布版本

  • develop 是长期分支,一般用于作为日常开发汇总,即开发版的代码

  • feature 是短期分支,一般用于一个新功能的开发

  • hotfix 是短期分支 ,一般用于正式发布以后,出现bug,需要创建一个分支,进行bug修补。

  • release 是短期分支,一般用于发布正式版本之前(即合并到 master 分支之前),需要对预发布的版本进行测试。release 分支在经历测试之后,测试确认验收,将会被合并到 developmaster


1.1 Git-flow工作流程


一般工作流程如下:



  • 1.日常在develop开发

  • 2.如果有比较大的功能或者其他需求,那么新开分支:feature/xxx 来做,并在这个分支上进行打包和提测。

  • 3.在封版日,将该版本上线的需求合并到develop,然后将开个新的分支release/版本号(如release/1.0.1),将develop合并至该分支。

  • 4.灰度阶段,在releases/版本号 分支上修复BUG,打包并发布,发布完成后反合入masterdevelop分支

  • 5.如果在全量发布后,发现有线上问题,那么在对应的master分支上新开分支hotfix/{版本号}来修复,并升级版本号,修复完成后,然后将hotfix合并到master,同时将合并到develop


2. 为什么Git-flow不适用于持续交付?



在这 10 年中,Git 本身已经席卷全球,并且使用 Git 开发的最受欢迎的软件类型正在更多地转向 Web 应用程序——至少在我的过滤器气泡中。 Web 应用程序通常是持续交付的,而不是回滚的,而且您不必支持同时 运行的多个版本的软件。



Vincent Driessen所述。Git-flow描述了feature分支、release分支、masterdevelop分支以及hotfix分支是如何相互关联的。
这种方法非常适用于用户下载的打包软件,例如库和桌面应用程序。


然而,对于许多Web应用来说,Git-flow是矫枉过正的。有时,您的develop分支和release分支之间没有足够大的差异来区分值得。或者,您的hotfix分支和feature分支的工作流程可能相同。
在这种情况下,Vincent Driessen推荐Github flow分支模型


Git-flow的主要优点在于结构清晰,每个分支的任务划分的很清楚,而它的缺点自然就是有些复杂了
Git-flow需要同时维护两个长期分支。大多数工具都将master当作默认分支,可是开发是在develop分支进行的,这导致经常要切换分支,非常烦人。
更大问题在于,这个模式是基于"版本发布"的,目标是一段时间以后产出一个新版本。但是,很多网站项目是"持续发布",代码一有变动,就部署一次。这时,master分支和develop分支的差别不大,没必要维护两个长期分支


2.1 Git-fow何时值得额外的复杂性


当然,是否使用Git-flow取决于你的业务复杂性,有时使用Git-flow是必须的,主要是当你需要同时维护多版本的时候,适合的是需要『多个版本并存』的场景
所谓『多版本并存』,就是说开发团队要同时维护多个有客户使用的版本,对于传统软件,比如我开发一个新的操作系统叫做Doors,先卖v1,卖出去1000万份,然后看在v1的基础上开发v2,但是客户会持续给v1bug,这些bug既要在v1的后续补丁中fix,也要在v2fix,等v2再卖出去2000万份开始开发v3的时候,v1依然有客户,我就必须要维持v1v2v3三个多版本都要支持。


关于Git-flow同时支持多个版本,很多人可能会有疑问,因为develop只针对一个版本能持续交付
说实话我也感觉挺疑问的,后面查阅资料发现还有一个衍生的support分支,可以同时支持多个版本,在兴趣的同学可参考:mindsers.blog/post/severa…


3.Github flow介绍



Github flow它只有一个长期分支,就是master,因此用起来非常简单。



  • 第一步:根据需求,从master拉出新分支,不区分功能分支或补丁分支。

  • 第二步:新分支开发完成后,或者需要讨论的时候,就向master发起一个pull request(简称PR)。

  • 第三步:Pull Request既是一个通知,让别人注意到你的请求,又是一种对话机制,大家一起评审和讨论你的代码。对话过程中,你还可以不断提交代码

  • 第四步:布署流程:当项目负责人同意新功能可以发布,且代码也通过审核了。但是在代码合并之前还是要进行测试。所以要把feature分支的代码部署到测试环境进行测试

  • 第五步:你的Pull Request被接受,合并进master,重新部署到生产环境后,原来你拉出来的那个分支就被删除。

  • 第六步:修复正式环境bug流程:从master分支切一个HotFix分支,经过以上同样的流程发起PR合并即可


3.1 Github flow的优点


Github flow的最大优点就是简单,对于"持续发布"的产品,可以说是最合适的流程。


3.2 Github flow的缺点


它的问题也在于它的假设:master分支的更新与产品的发布是一致的。也就是说,master分支的最新代码,默认就是当前的线上代码。
可是,有些时候并非如此,代码合并进入master分支,并不代表它就能立刻发布。比如,苹果商店的APP提交审核以后,等一段时间才能上架。这时,如果还有新的代码提交,master分支就会与刚发布的版本不一致。另一个例子是,有些公司有发布窗口,只有指定时间才能发布,这也会导致线上版本落后于master分支。
上面这种情况,只有master一个主分支就不够用了。通常,你不得不在master分支以外,另外新建一个production分支跟踪线上版本。


同时对于Github flow我还有个疑问,合并到master分支后即会部署到生产环境,但是在merge后的代码难道不会产生冲突吗?合并冲突难道不需要重新测试吗?如果评论区有了解的小伙伴可以解惑下


Github flow用起来比较简单,但是在很多公司的业务开发过程中一般都有开发、测试、预发布、生产几个环境,没有强有力的工具来支撑,我认为很难用这种简单的模式来实现管理。
看起来这种模式特别适合小团队,人少,需求少,比较容易通过这种方式管理分支。


4.Gitlab flow介绍


Gitlab flowGit-flowGithub flow的综合。它吸取了两者的优点,既有适应不同开发环境的弹性,又有单一主分支的简单和便利。它是Gitlab.com推荐的做法。
Gitlab flow的最大原则叫做”上游优先”(upsteam first),即只存在一个主分支master,它是所有其他分支的”上游”。只有上游分支采纳的代码变化,才能应用到其他分支。
Gitlab flow分为持续发布与版本发布两种情况,以适应不同的发布类型


4.1 持续发布



对于”持续发布”的项目,它建议在master分支以外,再建立不同的环境分支。
比如,”开发环境”的分支是master,”预发环境”的分支是pre-production,”生产环境”的分支是production


开发分支是预发分支的"上游",预发分支又是生产分支的"上游"。代码的变化,必须由"上游"向"下游"发展。比如,生产环境出现了bug,这时就要新建一个功能分支,先把它合并到master,确认没有问题,再cherry-pickpre-production,这一步也没有问题,才进入production


只有紧急情况,才允许跳过上游,直接合并到下游分支。


4.2 版本发布



对于"版本发布"的项目,建议的做法是每一个稳定版本,都要从master分支拉出一个分支,比如2-3-stable2-4-stable等等。
以后,只有修补bug,才允许将代码合并到这些分支,并且此时要更新小版本号。


4.3 Gitlab flow开发流程


对于Android开发,我们一般使用版本发布,因此我们使用Gitlab flow开发的工作流为



  • 1.新的迭代开始,所有开发人员从主干master拉个人分支开发特性, 分支命名规范 feature-name

  • 2.开发完成后,在迭代结束前,合入master分支

  • 3.master分支合并后,自动cicddev环境

  • 4.开发自测通过后,从master拉取要发布的分支,release-$version,将这个分支部署到测试环境进行测试

  • 5.测出的bug,通过从release-$versio拉出分支进行修复,修复完成后,再合入release-$versio

  • 6.正式发布版本,如果上线后,又有bug,根据5的方式处理

  • 7.等发布版本稳定后,将release-$versio反合入主干master分支


值得注意的是,按照Github flow规范,第5步如果测出bug,应该在master上修改,然后cherry-pickreleases上来,但是这样做太麻烦了,直接在releases分支上修复bug然后再反合入master分支应该是一个简单而且可以接受的做法


总结


正如Vincent Driessen所说的,总而言之,请永远记住,灵丹妙药并不存在。考虑你自己的背景。不要讨厌。自己决定


Git-flow适用于大团队多版本并存迭代的开发流程
Github-flow适用于中小型团队持续集成的开发流程
Gitlab-flow适用范围则介于上面二者之间,支持持续发布与版本发布两种情况


总得来说,各种Git工作流自有其适合工作的场景,毕竟软件工程中没有银弹,读者可根据自己的项目情况对比选择使用,自己决定~


参考资料


如何看待 Git flow 发明人称其不适用于持续交付?
Git 开发工作流程:Git Flow 与 GitHub Flow
Git 工作流程
高效团队的gitlab flow最佳实践

收起阅读 »

Jetpack Compose初体验--(导航、生命周期等)

普通导航 在Jetpack Compose中导航可以使用Jetpack中的Navigation组件,引入相关的扩展依赖就可以了 Navigation官方文档 implementation "androidx.navigation:navigation-co...
继续阅读 »

普通导航


在Jetpack Compose中导航可以使用Jetpack中的Navigation组件,引入相关的扩展依赖就可以了 Navigation官方文档


implementation "androidx.navigation:navigation-compose:2.4.0-alpha01"

使用Navigation导航用到两个比较重要的对象NavHost和NavController。



  • NavHost用来承载页面,和管理导航图

  • NavController用来控制如何导航还有参数回退栈等


导航的路径使用字符串来表示,当使用NavController导航到某个页面的时候,NavHost内部会自动进行页面重组。


来个小栗子实践一下


@Composable
fun MainView(){
val navController = rememberNavController()
NavHost(navController = navController, startDestination = "first_screen"){
composable("first_screen"){
FirstScreen(navController = navController)
}
composable("second_screen"){
SecondScreen(navController = navController)
}
composable("third_screen"){
ThirdScreen(navController = navController)
}
}
}


  • 通过rememberNavController()方法创建navController对象

  • 创建NavHost对象,传入navController并指定首页

  • 通过composable()方法来往NavHost中添加页面,构造方法中的字符串就代表该页面的路径,后面的第二个参数就是具体的页面。


下面把这三个页面写出来,每个页面里面都有个按钮继续执行其他导航


@Composable
fun FirstScreen(navController: NavController){
Column(modifier = Modifier.fillMaxSize().background(Color.Blue),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Button(onClick = {
navController.navigate("second_screen")
}) {
Text(text = "I am First 点击我去Second")
}
}
}
@Composable
fun SecondScreen(navController: NavController){
Column(modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally) {
Button(onClick = {
navController.navigate("third_screen")
}) {
Text(text = "I am Second 点击我去Third")
}
}
}
@Composable
fun ThirdScreen(navController: NavController){
Column(modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally) {
Button(onClick = {
navController.navigate("first_screen")
}) {
Text(text = "I am Third 点击我去first")
}
}
}

这样一个简单的导航效果就完成了,感觉用了这个之后,要跟activity和fragment说拜拜了~~ ,全场只需一个activity加一堆可组合项(@Composable),新建一个页面简单了太多太多。


当然页面之间跳转传参是少不了的,Compose中如何传参呢?


参数传递肯定有发送端和接收端,navController是发送端,NavHost是接收端。先在NavHost中配置参数占位符,和接收取参数的方法。


@Composable
fun MainView(){
val navController = rememberNavController()
NavHost(navController = navController, startDestination = "first_screen"){
composable("first_screen"){
FirstScreen(navController = navController)
}
composable("second_screen/{userId}/{isShow}",
//默认情况下 所有参数都会被解析为字符串 如果不是字符串需要单独指定 type
arguments = listOf(navArgument("isShow"){type = NavType.BoolType})
){ backStackEntry ->
SecondScreen(navController = navController,
backStackEntry.arguments?.getString("userId"),
backStackEntry.arguments?.getBoolean("isShow")!!
)
}
composable("third_screen?selectable={selectable}",
arguments = listOf(navArgument("selectable"){defaultValue = "哈哈哈我是可选参数的默认值"})){
ThirdScreen(navController = navController,it.arguments?.getString("selectable"))
}
composable("four_screen"){
FourScreen(navController = navController)
}
}
}

如上代码,接收参数直接在在该页面地址后面添加参数占位符类似second_screen/{userId}/{isShow},然后通过arguments参数来接收arguments = listOf(navArgument("isShow"){type = NavType.BoolType})。还可以通过defaultValue来定义参数的默认值。


默认情况下 所有参数都会被解析为字符串 如果不是字符串需要单独指定 type。


参数发送端更简单,参数直接跟到页面路径后面就可以,类似navController.navigate("second_screen/12345/true") 下面给前面的页面添加上参数


@Composable
fun FirstScreen(navController: NavController){
Column(modifier = Modifier
.fillMaxSize()
.background(Color.Blue),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Button(onClick = {
navController.navigate("second_screen/12345/true"){
}
}) {
Text(text = "I am First 点击我去Second")
}
Spacer(modifier = Modifier.size(30.dp))
}
}
@Composable
fun SecondScreen(navController: NavController,userId:String?,isShow:Boolean){
Column(modifier = Modifier
.fillMaxSize()
.background(Color.Green),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally) {
Button(onClick = {
navController.navigate("third_screen?selectable=测试可选参数"){
popUpTo(navController.graph.startDestinationId){saveState = true}
}
}) {
Text(text = "I am Second 点击我去Third")
}
Spacer(modifier = Modifier.size(30.dp))
Text(text = "arguments ${userId}")
if(isShow){
Text(text = "测试boolean值")
}
}
}
@Composable
fun ThirdScreen(navController: NavController,selectable:String?){
Column(modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally) {
Button(onClick = {
navController.navigate("first_screen")
}) {
Text(text = "I am Third 点击我去first")
}
Spacer(modifier = Modifier.size(30.dp))
Button(onClick = {
navController.navigate("four_screen")
}) {
Text(text = "I am Third 点击我去four")
}
selectable?.let { Text(text = it) }
}
}

效果如下


copmose_21.gif


生命周期


既然新的界面不使用activity或者fragment了,但是activity和fragment中的生命周期是非常有用的比如创建和销毁某些对象。那么Jetpack Compose中的每个组合函数的生命周期是怎样的呢?


可组合项的生命周期比视图比activity 和 fragment 的生命周期更简单,一般是进入组合、执行0次或者多次重组、退出组合。生命周期相关的函数主要有下面的几个,使用@Composable修饰的可组合函数中没有自带的生命周期函数,想要监听其生命周期,需要使用Effect API



  • LaunchedEffect:第一次调用Compose函数的时候调用

  • DisposableEffect:内部有一个 onDispose()函数,当页面退出时调用

  • SideEffect:compose函数每次执行都会调用该方法


来个小例子体验一下


@Composable
fun LifecycleDemo() {
val count = remember { mutableStateOf(0) }

Column {
Button(onClick = {
count.value++
}) {
Text("Click me")
}

LaunchedEffect(Unit){
Log.d("Compose", "onactive with value: " + count.value)
}
DisposableEffect(Unit) {
onDispose {
Log.d("Compose", "onDispose because value=" + count.value)
}
}
SideEffect {
Log.d("Compose", "onChange with value: " + count.value)
}
Text(text = "You have clicked the button: " + count.value.toString())
}
}

效果如下:


copmose_26.gif


然后把前面的例子稍微改一下,我们把LaunchedEffect和DisposableEffect一起放到一个if语句里面


@Composable
fun LifecycleDemo() {
val count = remember { mutableStateOf(0) }

Column {
Button(onClick = {
count.value++
}) {
Text("Click me")
}

if (count.value < 3) {
LaunchedEffect(Unit){
Log.d("Compose", "onactive with value: " + count.value)
}
DisposableEffect(Unit) {
onDispose {
Log.d("Compose", "onDispose because value=" + count.value)
}
}
}

SideEffect {
Log.d("Compose", "onChange with value: " + count.value)
}
Text(text = "You have clicked the button: " + count.value.toString())
}
}

那么此时的生命周期就是:当首次进入if语句的时候执行LaunchedEffect函数,离开if语句的时候,就执行DisposableEffect方法。


底部导航


说到导航就不得不说底部导航和顶部导航,底部导航的实现非常简单,直接使用JetPack Compose提供的脚手架在结合navController和NavHost就能轻松实现


@Composable
fun BottomMainView(){
val bottomItems = listOf(Screen.First,Screen.Second,Screen.Third)
val navController = rememberNavController()
Scaffold(
bottomBar = {
BottomNavigation {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
bottomItems.forEach{screen ->
BottomNavigationItem(
icon = { Icon(Icons.Filled.Favorite,"") },
label = { Text(stringResource(screen.resourceId)) },
selected = currentRoute == screen.route,
onClick = {
navController.navigate(screen.route){
//当底部导航导航到在非首页的页面时,执行手机的返回键 回到首页
popUpTo(navController.graph.startDestinationId){saveState = true}
//从名字就能看出来 跟activity的启动模式中的SingleTop模式一样 避免在栈顶创建多个实例
launchSingleTop = true
//切换状态的时候保存页面状态
restoreState = true
}
})
}

}
}
){
NavHost(navController = navController, startDestination = Screen.First.route ){
composable(Screen.First.route){
First(navController)
}
composable(Screen.Second.route){
Second(navController)
}
composable(Screen.Third.route){
Third(navController)
}
}
}
}
@Composable
fun First(navController: NavController){
Column(modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,horizontalAlignment = Alignment.CenterHorizontally) {
Text(text = "First",fontSize = 30.sp)
}
}
@Composable
fun Second(navController: NavController){
Column(modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,horizontalAlignment = Alignment.CenterHorizontally) {
Text(text = "Second",fontSize = 30.sp)
}
}
@Composable
fun Third(navController: NavController){
Column(modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,horizontalAlignment = Alignment.CenterHorizontally) {
Text(text = "Third",fontSize = 30.sp)
}
}

效果如下


copmose_22.gif


顶部导航


顶部导航使用TabRow和ScrollableTabRow这两个组件,其内部都是由一个一个的Tab组件组成。TabRow是平分整个屏幕的宽度,ScrollableTabRow可以超出屏幕宽度并且可以滑动,用法都是一样。


@Composable
fun TopTabRow(){
var state by remember { mutableStateOf(0) }
var titles = listOf("Java","Kotlin","Android","Flutter")
Column {
TabRow(selectedTabIndex = state) {
titles.forEachIndexed{index,title ->
run {
Tab(
selected = state == index,
onClick = { state = index },
text = {
Text(text = title)
})
}
}
}
Column(Modifier.weight(1f)) {
when (state){
0 -> TopTabFirst()
1 -> TopTabSecond()
2 -> TopTabThird()
3 -> TopTabFour()
}
}
}
}
@Composable
fun TopTabFirst(){
Column(modifier = Modifier.fillMaxSize(), verticalArrangement=Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally) {
Text(text = "Java")
}
}
@Composable
fun TopTabSecond(){
Column(modifier = Modifier.fillMaxSize(), verticalArrangement=Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally) {
Text(text = "Kotlin")
}
}
@Composable
fun TopTabThird(){
Column(modifier = Modifier.fillMaxSize(), verticalArrangement=Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally) {
Text(text = "Android")
}
}
@Composable
fun TopTabFour(){
Column(modifier = Modifier.fillMaxSize(), verticalArrangement=Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally) {
Text(text = "Flutter")
}
}

copmose_23.gif


上面只能实现点击每个Tab 切换不同的页面,如果我们想要实现类似我们在xml布局中的ViewPage+TabLayout的效果呢


在Jetpack中怎么实现ViewPage的效果呢,Google的github上提供了一个半官方的库名字叫pager:github.com/google/acco…


implementation "com.google.accompanist:accompanist-pager:0.13.0"

该库目前还是实验性的,以后API都可能会修改,目前使用的时候需要使用@ExperimentalPagerApi注解标记。


@ExperimentalPagerApi
@Composable
fun TopScrollTabRow(){
var titles = listOf("Java","Kotlin","Android","Flutter","scala","python")
val scope = rememberCoroutineScope()
var pagerState = rememberPagerState(
pageCount = titles.size, //总页数
initialOffscreenLimit = 2, //预加载的个数
infiniteLoop = true, //无限循环
initialPage = 0, //初始页面
)
Column {
ScrollableTabRow(
selectedTabIndex = pagerState.currentPage,
modifier = Modifier.wrapContentSize(),
edgePadding = 16.dp
) {
titles.forEachIndexed{index,title ->
run {
Tab(
selected = pagerState.currentPage == index,
onClick = {
scope.launch {
pagerState.scrollToPage(index)
}
},
text = {
Text(text = title)
})
}
}
}
HorizontalPager(
state=pagerState,
modifier = Modifier.weight(1f)
) {index ->
Column(modifier = Modifier.fillMaxSize(),verticalArrangement = Arrangement.Center,horizontalAlignment = Alignment.CenterHorizontally) {
Text(text = titles[index])
}
}
}
}

pagerState.scrollToPage(index)方法可以控制pager滚动,不过它是一个suspend修饰的方法,需要运行在协程中,在jetpack compose中使用协程可以使用rememberCoroutineScope()方法来获取一个compose中的协程的作用域


效果如下:


copmose_24.gif


Banner


pager库都引入了那顺便吧Banner效果也练习一下,为了显示网络图片还得引入一个新的库,accompanist-coil。在JetPack Compose中官方提供了两个显示网络图片的库accompanist-coil和accompanist-glide,这里使用accompanist-coil。


implementation 'com.google.accompanist:accompanist-coil:0.11.1'

@ExperimentalPagerApi
@Composable
fun Third(navController: NavController){
var pics = listOf("https://wanandroid.com/blogimgs/8a0131ac-05b7-4b6c-a8d0-f438678834ba.png",
"https://www.wanandroid.com/blogimgs/62c1bd68-b5f3-4a3c-a649-7ca8c7dfabe6.png",
"https://www.wanandroid.com/blogimgs/50c115c2-cf6c-4802-aa7b-a4334de444cd.png",
"https://www.wanandroid.com/blogimgs/90c6cc12-742e-4c9f-b318-b912f163b8d0.png")
Column(modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally) {
Text(text = "Third",fontSize = 30.sp)
var pagerState = rememberPagerState(
pageCount = 4, //总页数
initialOffscreenLimit = 2, //预加载的个数
infiniteLoop = true, //无限循环
initialPage = 0, //初始页面
)
Box(modifier = Modifier
.fillMaxWidth()
.height(260.dp)
.background(color = Color.Yellow)) {
HorizontalPager(
state=pagerState,
modifier = Modifier.fillMaxSize()
) {index ->
Image(modifier = Modifier.fillMaxSize(),
painter = rememberCoilPainter(request = pics[index]),
contentScale=ContentScale.Crop,
contentDescription = "图片描述")
}
HorizontalPagerIndicator(
pagerState = pagerState,
modifier = Modifier
.padding(16.dp).align(Alignment.BottomStart),
)
}
}
}

使用Jetpack Compose写页面感觉比使用xml简单了很多,相信未来Android中的xml布局会像前端的jquary一样用的越来越少。



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

使用更为安全的方式收集 Android UI 数据流

在 Android 应用中,通常需要从 UI 层收集 Kotlin 数据流,以便在屏幕上显示数据更新。同时,您也会希望通过收集这些数据流,来避免产生不必要的操作和资源浪费 (包括 CPU 和内存),以及防止在 View 进入后台时泄露数据。 本文将会带您学习如...
继续阅读 »

在 Android 应用中,通常需要从 UI 层收集 Kotlin 数据流,以便在屏幕上显示数据更新。同时,您也会希望通过收集这些数据流,来避免产生不必要的操作和资源浪费 (包括 CPU 和内存),以及防止在 View 进入后台时泄露数据。


本文将会带您学习如何使用 LifecycleOwner.addRepeatingJobLifecycle.repeatOnLifecycle 以及 Flow.flowWithLifecycle API 来避免资源的浪费;同时也会介绍为什么这些 API 适合作为在 UI 层收集数据流时的默认选择。


资源浪费


无论数据流生产者的具体实现如何,我们都 推荐 从应用的较底层级暴露 Flow API。不过,您也应该保证数据流收集操作的安全性。


使用一些现存 API (如 CoroutineScope.launchFlow.launchInLifecycleCoroutineScope.launchWhenX) 收集基于 channel 或使用带有缓冲的操作符 (如 bufferconflateflowOnshareIn) 的冷流的数据是 不安全的,除非您在 Activity 进入后台时手动取消启动了协程的 Job。这些 API 会在内部生产者在后台发送项目到缓冲区时保持它们的活跃状态,而这样一来就浪费了资源。



注意: 冷流 是一种数据流类型,这种数据流会在新的订阅者收集数据时,按需执行生产者的代码块。



例如下面的例子中,使用 callbackFlow 发送位置更新的数据流:‍


// 基于 Channel 实现的冷流,可以发送位置的更新
fun FusedLocationProviderClient.locationFlow() = callbackFlow<Location> {
val callback = object : LocationCallback() {
override fun onLocationResult(result: LocationResult?) {
result ?: return
try { offer(result.lastLocation) } catch(e: Exception) {}
}
}
requestLocationUpdates(createLocationRequest(), callback, Looper.getMainLooper())
.addOnFailureListener { e ->
close(e) // 在出现异常时关闭 Flow
}
// 在 Flow 收集结束时进行清理操作
awaitClose {
removeLocationUpdates(callback)
}
}
复制代码


注意: callbackFlow 内部使用 channel 实现,其概念与阻塞 队列 十分类似,并且默认容量为 64。



使用任意前述 API 从 UI 层收集此数据流都会导致其持续发送位置信息,即使视图不再展示数据也不会停止!示例如下:


class LocationActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

// 最早在 View 处于 STARTED 状态时从数据流收集数据,并在
// 生命周期进入 STOPPED 状态时 SUSPENDS(挂起)收集操作。
// 在 View 转为 DESTROYED 状态时取消数据流的收集操作。
lifecycleScope.launchWhenStarted {
locationProvider.locationFlow().collect {
// 新的位置!更新地图
}
}
// 同样的问题也存在于:
// - lifecycleScope.launch { /* 在这里从 locationFlow() 收集数据 */ }
// - locationProvider.locationFlow().onEach { /* ... */ }.launchIn(lifecycleScope)
}
}
复制代码

lifecycleScope.launchWhenStarted 挂起了协程的执行。虽然新的位置信息没有被处理,但 callbackFlow 生产者仍然会持续发送位置信息。使用 lifecycleScope.launchlaunchIn API 会更加危险,因为视图会持续消费位置信息,即使处于后台也不会停止!这种情况可能会导致您的应用崩溃。


为了解决这些 API 所带来的问题,您需要在视图转入后台时手动取消收集操作,以取消 callbackFlow 并避免位置提供者持续发送项目并浪费资源。举例来说,您可以像下面的例子这样操作:


class LocationActivity : AppCompatActivity() {

// 位置的协程监听器
private var locationUpdatesJob: Job? = null

override fun onStart() {
super.onStart()
locationUpdatesJob = lifecycleScope.launch {
locationProvider.locationFlow().collect {
// 新的位置!更新地图。
}
}
}

override fun onStop() {
// 在视图进入后台时停止收集数据
locationUpdatesJob?.cancel()
super.onStop()
}
}
复制代码

这是一个不错的解决方案,美中不足的是有些冗长。如果这个世界有一个有关 Android 开发者的普遍事实,那一定是我们都不喜欢编写模版代码。不必编写模版代码的一个最大好处就是——写的代码越少,出错的概率越小!


LifecycleOwner.addRepeatingJob


现在我们境遇相同,并且也知道问题出在哪里,是时候找出一个解决方案了。我们的解决方案需要: 1. 简单;2. 友好或者说便于记忆与理解;更重要的是 3. 安全!无论数据流的实现细节如何,它都应能够应对所有用例。


事不宜迟——您应该使用的 API 是 lifecycle-runtime-ktx 库中所提供的 LifecycleOwner.addRepeatingJob。请参考下面的代码:


class LocationActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

// 最早在 View 处于 STARTED 状态时从数据流收集数据,并在
// 生命周期进入 STOPPED 状态时 STOPPED(停止)收集操作。
// 它会在生命周期再次进入 STARTED 状态时自动开始进行数据收集操作。
lifecycleOwner.addRepeatingJob(Lifecycle.State.STARTED) {
locationProvider.locationFlow().collect {
// 新的位置!更新地图
}
}
}
}
复制代码

addRepeatingJob 接收 Lifecycle.State 作为参数,并用它与传入的代码块一起,在生命周期到达该状态时,自动创建并启动新的协程;同时也会在生命周期低于该状态时取消正在运行的协程


由于 addRepeatingJob 会在协程不再被需要时自动将其取消,因而可以避免产生取消操作相关的模版代码。您也许已经猜到,为了避免意外行为,这一 API 需要在 Activity 的 onCreate 或 Fragment 的 onViewCreated 方法中调用。下面是配合 Fragment 使用的示例:


class LocationFragment: Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
// ...
viewLifecycleOwner.addRepeatingJob(Lifecycle.State.STARTED) {
locationProvider.locationFlow().collect {
// 新的位置!更新地图
}
}
}
}
复制代码


注意: 这些 API 在 lifecycle:lifecycle-runtime-ktx:2.4.0-alpha01 库或其更新的版本中可用。



使用 repeatOnLifecycle


出于提供更为灵活的 API 以及保存调用中的 CoroutineContext 的目的,我们也提供了 挂起函数 Lifecycle.repeatOnLifecycle 供您使用。repeatOnLifecycle 会挂起调用它的协程,并会在进出目标状态时重新执行代码块,最后在 Lifecycle 进入销毁状态时恢复调用它的协程。


如果您需要在重复工作前执行一次配置任务,同时希望任务可以在重复工作开始前保持挂起,该 API 可以帮您实现这样的操作。示例如下:


class LocationActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

lifecycleScope.launch {
// 单次配置任务
val expensiveObject = createExpensiveObject()

lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
// 在生命周期进入 STARTED 状态时开始重复任务,在 STOPED 状态时停止
// 对 expensiveObject 进行操作
}

// 当协程恢复时,`lifecycle` 处于 DESTROY 状态。repeatOnLifecycle 会在
// 进入 DESTROYED 状态前挂起协程的执行
}
}
}
复制代码

Flow.flowWithLifecycle


当您只需要收集一个数据流时,也可以使用 Flow.flowWithLifecycle 操作符。这一 API 的内部也使用 suspend Lifecycle.repeatOnLifecycle 函数实现,并会在生命周期进入和离开目标状态时发送项目和取消内部的生产者。


class LocationActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

locationProvider.locationFlow()
.flowWithLifecycle(this, Lifecycle.State.STARTED)
.onEach {
// 新的位置!更新地图
}
.launchIn(lifecycleScope)
}
}
复制代码


注意: Flow.flowWithLifecycle API 的命名以 Flow.flowOn(CoroutineContext) 为先例,因为它会在不影响下游数据流的同时修改收集上游数据流的 CoroutineContext。与 flowOn 相似的另一点是,Flow.flowWithLifecycle 也加入了缓冲区,以防止消费者无法跟上生产者。这一特点源于其实现中使用的 callbackFlow



配置内部生产者


即使您使用了这些 API,也要小心那些可能浪费资源的热流,就算它们没有被收集亦是如此!虽然针对这些热流有一些合适的用例,但是仍要多加注意并在必要时进行记录。另一方面,在一些情况下,即使可能造成资源的浪费,令处于后台的内部数据流生产者保持活跃状态也会利于某些用例,如: 您需要即时刷新可用数据,而不是去获取并暂时展示陈旧数据。您可以根据用例决定生产者是否需要始终处于活跃状态


您可以使用 MutableStateFlowMutableSharedFlow 两个 API 中暴露的 subscriptionCount 字段来控制它们,当该字段值为 0 时,内部的生产者就会停止。默认情况下,只要持有数据流实例的对象还在内存中,它们就会保持生产者的活跃状态。针对这些 API 也有一些合适的用例,比如使用 StateFlowUiState 从 ViewModel 中暴露给 UI。这么做很合适,因为它意味着 ViewModel 总是需要向 View 提供最新的 UI 状态。


相似的,也可以为此类操作使用 共享开始策略 配置 Flow.stateInFlow.shareIn 操作符。WhileSubscribed() 将会在没有活跃的订阅者时停止内部的生产者!相应的,无论数据流是 Eagerly (积极) 还是 Lazily (惰性) 的,只要它们使用的 CoroutineScope 还处于活跃状态,其内部的生产者就会保持活跃。



注意: 本文中所描述的 API 可以很好的作为默认从 UI 收集数据流的方式,并且无论数据流的实现方式如何,都应该使用它们。这些 API 做了它们要做的事: 在 UI 于屏幕中不可见时,停止收集其数据流。至于数据流是否应该始终处于活动状态,则取决于它的实现。



在 Jetpack Compose 中安全地收集数据流


Flow.collectAsState 函数可以在 Compose 中收集来自 composable 的数据流,并可以将值表示为 State,以便能够更新 Compose UI。即使 Compose 在宿主 Activity 或 Fragment 处于后台时不会重组 UI,数据流生产者仍会保持活跃并会造成资源的浪费。Compose 可能会遭遇与 View 系统相同的问题。


在 Compose 中收集数据流时,可以使用 Flow.flowWithLifecycle 操作符,示例如下:


@Composable
fun LocationScreen(locationFlow: Flow<Flow>) {

val lifecycleOwner = LocalLifecycleOwner.current
val locationFlowLifecycleAware = remember(locationFlow, lifecycleOwner) {
locationFlow.flowWithLifecycle(lifecycleOwner.lifecycle, Lifecycle.State.STARTED)
}

val location by locationFlowLifecycleAware.collectAsState()

// 当前位置,可以拿它做一些操作
}
复制代码

注意,您 需要记得 生命周期感知型数据流使用 locationFlowlifecycleOwner 作为键,以便始终使用同一个数据流,除非其中一个键发生改变。


Compose 的副作用 (Side-effect) 便是必须处在 受控环境中,因此,使用 LifecycleOwner.addRepeatingJob 不安全。作为替代,可以使用 LaunchedEffect 来创建跟随 composable 生命周期的协程。在它的代码块中,如果您需要在宿主生命周期处于某个 State 时重新执行一个代码块,可以调用挂起函数 Lifecycle.repeatOnLifecycle


对比 LiveData


您也许会觉得,这些 API 的表现与 LiveData 很相似——确实是这样!LiveData 可以感知 Lifecycle,而且它的重启行为使其十分适合观察来自 UI 的数据流。同理 LifecycleOwner.addRepeatingJobsuspend Lifecycle.repeatOnLifecycle 以及 Flow.flowWithLifecycle 等 API 亦是如此。


在纯 Kotlin 应用中,使用这些 API 可以十分自然地替代 LiveData 收集数据流。如果您使用这些 API 收集数据流,换成 LiveData (相对于使用协程和 Flow) 不会带来任何额外的好处。而且由于 Flow 可以从任何 Dispatcher 收集数据,同时也能通过它的 操作符 获得更多功能,所以 Flow 也更为灵活。相对而言,LiveData 的可用操作符有限,且它总是从 UI 线程观察数据。


数据绑定对 StateFlow 的支持


另一方面,您会想要使用 LiveData 的原因之一,可能是它受到数据绑定的支持。不过 StateFlow 也一样!更多有关数据绑定对 StateFlow 的支持信息,请参阅 官方文档


在 Android 开发中,请使用 LifecycleOwner.addRepeatingJobsuspend Lifecycle.repeatOnLifecycle Flow.flowWithLifecycle 从 UI 层安全地收集数据流。


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

Android直播间的送礼物动画-GiftSurfaceView

GiftSurfaceViewGiftSurfaceView 最初出自于2014年开发HalloStar项目时所写,主要用于HalloStar项目直播间的送礼物动画。现在想来,那夕阳下的奔跑,是我逝去的青春。因高仿全民TV项目时想起,所以抽空整理了下,以此记录...
继续阅读 »


GiftSurfaceView

GiftSurfaceView 最初出自于2014年开发HalloStar项目时所写,主要用于HalloStar项目直播间的送礼物动画。现在想来,那夕阳下的奔跑,是我逝去的青春。因高仿全民TV项目时想起,所以抽空整理了下,以此记录。

Gif展示


引入

Maven:

<dependency>
<groupId>com.king.view</groupId>
<artifactId>giftsurfaceview</artifactId>
<version>1.1.0</version>
<type>pom</type>
</dependency>

Gradle:

compile 'com.king.view:giftsurfaceview:1.1.0'

Lvy:

<dependency org='com.king.view' name='giftsurfaceview' rev='1.1.0'>
<artifact name='$AID' ext='pom'></artifact>
</dependency>
如果Gradle出现compile失败的情况,可以在Project的build.gradle里面添加如下:(也可以使用上面的GitPack来complie)
allprojects {
repositories {
maven { url 'https://dl.bintray.com/jenly/maven' }
}
}

示例

    public void updateGiftSurfaceView(int type){

frame.removeAllViews();

GiftSurfaceView giftSurfaceView = new GiftSurfaceView(context);
if(type == RANDOM){
giftSurfaceView.setImageResource(R.drawable.rose);
}else{
giftSurfaceView.setImageBitmap(bitmap,.5f);
}

giftSurfaceView.setPointScale(1,width/10,(int)(height/3.8f));
giftSurfaceView.setRunTime(10000);

try {

switch (type){
case RANDOM:
giftSurfaceView.setRandomPoint(9);
break;
case V:
giftSurfaceView.setListPoint(PointUtils.getListPointByResourceJson(context,ASSET_V),true);
break;
case HEART:
giftSurfaceView.setListPoint(PointUtils.getListPointByResourceJson(context,ASSET_HEART),true);
break;
case LOVE:
giftSurfaceView.setListPoint(PointUtils.getListPointByResourceJson(context,ASSET_LOVE));
break;
case SMILE:
giftSurfaceView.setListPoint(PointUtils.getListPointByResourceJson(context,ASSET_SMILE));
break;
case X:
giftSurfaceView.setListPoint(PointUtils.getListPointByResourceJson(context,ASSET_X));
break;
case V520:
giftSurfaceView.setListPoint(PointUtils.getListPointByResourceJson(context,ASSET_V520));
break;
case V1314:
giftSurfaceView.setRunTime(GiftSurfaceView.LONG_TIME);
giftSurfaceView.setListPoint(PointUtils.getListPointByResourceJson(context,ASSET_V1314));
break;

}
frame.addView(giftSurfaceView);
} catch (IOException e) {
e.printStackTrace();
}


}

以上为部分代码使用示例,更多详情请下载查看。

代码下载:GiftSurfaceView.zip

收起阅读 »

Java静态代理和动态代理

前言 再开始之前我们先不使用任何代理来实现一个网络请求的流程。 定义一个请求的接口: public interface Request { void request(); } 使用OkHttp来实现这个接口 public class ...
继续阅读 »

  • 前言



再开始之前我们先不使用任何代理来实现一个网络请求的流程。


定义一个请求的接口:


public interface Request {
void request();
}

使用OkHttp来实现这个接口


public class OkHttpImpl implements Request {
@Override
public void request() {
System.out.println("OkHttp请求成功");
}
}

现在我们的网络请求已经写好了,我们测试一下:


Request request = new OkHttpImpl();
request.request();

输出: OkHttp请求成功

看起来挺好用的,但是项目经理是个老程序员了,没有用过OkHttp,非要说Volley比OkHttp好用,让你把所有网络请求换成Volley框架


我们使用Volley来实现Request接口


public class VolleyImpl implements Request{
@Override
public void request() {
System.out.println("Volley请求成功");
}
}

重新测试测试一下:


Request request = new VolleyImpl();
request.request();

输出: Volley请求成功

现在项目经理又来了,说:“你网络请求怎么连个加载框都有没?”,这个时候又得去改代码了,但是公司网络框架已经封住好了,不让随便修改,这个时候没有办法了,只能这样写了:


showDialog(); //显示加载进度条

Request request = new VolleyImpl();
request.request();

hideDialog(); //隐藏加载进度条

看起来代码没问题,但是项目中有上百个网络请求,难道每次写网络请求都要手动加上进度条的代码吗?这个时候你去问项目经理,项目经理说:“你去看看Java静态代理和动态代理,或许能找到答案~”。



  • 静态代理



在这里插入图片描述 看起来用户不需要直接访问网络框架了,而是先访问一个代理类,由代理类去执行网络请求,那我们先新建一个代理类:


public class RequestProxy implements Request {

private final Request mRequest;

public RequestProxy(Request request) {
mRequest = request;
}

public void before(){
System.out.println("开始请求");
showDialog(); //显示加载进度条
}

public void after(){
System.out.println("请求完成");
hideDialog(); //隐藏加载进度条
}

@Override
public void request() {
before();
mRequest.request();
after();
}
}

现在我们来测试一下:


Request request = new VolleyImpl();
RequestProxy proxy = new RequestProxy(request);
proxy.request();

输出:
开始请求
Volley请求成功
请求完成

静态代理优点:



  1. 可以在代理类中对目标类进行扩展。

  2. 用户只需要使用代理类的方法,不需要关心真正实现方法。

  3. 用户可以通过代理类实现与真正逻辑的解耦。


静态代理的缺点:



  1. 如果增加一个接口,还需要重新写一个代理类。



  • 动态代理



动态代理不需要写代理类,能很好的弥补静态代理的缺点


我们需要使用Java内部给我们提供好的**Proxy.newProxyInstance()**方法


public static Object newProxyInstance(ClassLoader loader,
Class<?>[] interfaces,
InvocationHandler h)


newProxyInstance方法需要传入三个参数:



  1. loader: 类加载器

  2. interfaces: 要代理的接口

  3. InvocationHandler: 会回调动态代理的消息


我们先来实现一下动态代理:


Request request = new VolleyImpl();
Object o = Proxy.newProxyInstance(request.getClass().getClassLoader(), new Class[]{Request.class}, new InvocationHandler() {
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
System.out.println("请求前");
method.invoke(request, args);
System.out.println("请求后");
return null;
}
});

((Request) o).request();

输出:
请求前
Volley请求成功
请求后


  • 动态代理代码解析



我们先把要代理的接口传入到newProxyInstance方法中,并拿到代理对象“o”。


Object o = Proxy.newProxyInstance(request.getClass().getClassLoader(), new Class[]{Request.class}, new InvocationHandler() {})

我们可以把代理类强转成我们要代理的接口,然后直接调用方法


((Request) o).request();

这样代理类的invoke()方法就会被回调,我们看一下invoke()的三个参数:


@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {

return null;
}


  1. proxy: 代理类的对象

  2. method: 代理类调用的方法

  3. args: 代理类调用方法传的参数


既然回调方法中有method参数了,我们就可以利用反射直接掉用method.invoke(request, args)来调用方法了,同时我们也可以在调用方法前后加上要扩展的代码。


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

优化Android工程中的图片资源

场景 在一些上古工程中,由于年久失修,架构演进跟不上业务发展需要,会衍生出非常多比较明显的性能问题,其中就包括工程中图片资源的问题。 最明显的例子就是,工程中的图片资源未经任何压缩,直接使用来自设计稿中的原图,非常占用安装包体积;其次,显示效果不理想,在对...
继续阅读 »

场景


在一些上古工程中,由于年久失修,架构演进跟不上业务发展需要,会衍生出非常多比较明显的性能问题,其中就包括工程中图片资源的问题。


最明显的例子就是,工程中的图片资源未经任何压缩,直接使用来自设计稿中的原图,非常占用安装包体积;其次,显示效果不理想,在对应分辨率的图片资源文件夹中放入了错误尺寸的图片,导致应用运行时 UI 图片出现模糊、大颗粒等情况。


优化方案


压缩图片资源文件夹的大小


优化工作往往要从业务入手,在业务发展方向明确的前提下,并不是所有的 UI 效果都需要用图片文件的方式进行显示,对于一些简单的 UI,可以考虑使用代码进行绘制。使用代码绘制可以极其明显的减少图片对硬件资源的占用,一来可以减小包体积,二来通常可以减小运行时的内存。


对于一些必须需要通过图片文件来实现的 UI 效果,也需要对图片文件进行相应的压缩后再放入对应分辨率的文件夹,可以考虑无损压缩和有损压缩。


这里重点提下有损压缩,并不是所有的有损压缩都会直接影响 UI 呈现的,如果事先获知应用所运行的设备屏幕硬件本身色彩还原度很差,尺寸较小,分辨率也较低,那么有损压缩往往是更具性价比的选择。


注意这里的压缩不单单指图片质量的压缩,同时也包括图片尺寸的缩放。对于一些特定设备屏幕尺寸,我们可以限定一个最大的图片尺寸作为约束。


检查对应分辨率资源文件夹下的图片


种种原因下,代码工程中往往会存在对于分辨率资源文件夹下放错图片资源的情况。


比如,在 drawable-xxhdpi 下放入了本应该放在 drawable-mdpi 的图片资源,那么最终的 UI 呈现就可能会出现模糊、大颗粒、锯齿感等情况。


image.png


比如下图,在一个 xhdpi 的设备中,实际加载了 mdpi 的图片资源,导致出现 UI 模糊情况。


定义一个 48dp×48dp 的控件,实际控件大小为 96px×96px


<ImageView    
android:id="@+id/iv"   
android:src="@mipmap/ic_launcher"   
app:layout_constraintBottom_toBottomOf="parent"   
app:layout_constraintTop_toTopOf="parent"   
app:layout_constraintRight_toRightOf="parent"   
app:layout_constraintLeft_toLeftOf="parent"   
android:layout_width="48dp"   
android:layout_height="48dp"/>


如果放错了图片资源,则实际加载了 48px×48px 大小的图片。


image.png 将应用进行截图,放大后可以很明显看到模糊情况。


image.png


提供两种方案供参考。


第一种是运行时检查,结合 BitmapCanary 工具,判断应用运行时 UI 控件是否加载了对应尺寸的图片,如果加载的图片资源尺寸小于控件自身的尺寸,那么就需要特别关注,并返回代码工程中进行修改。


第二种是开发时检查,通过脚本工具遍历工程图片资源文件夹中的图片文件,逐一检查图片尺寸,结合我们之前定义过的图片最大尺寸约束,可以剔除并发现放错的图片资源,再针对筛选出的这些特定的图片资源作压缩和缩放。


优化工具


为了让优化工具更加通用,我编写了 ImageRes361Tool 工具,它的工作流程和架构图如下。


架构图


image.png



  • ImageRes361Tool 层:应用层,负责一键执行

  • ImageFinder 层:负责查找工程中不合规的图片资源

  • ImageSaver 层:保存图片

  • Config 层:配置压缩等级、策略以及目标文件夹

  • ImageCompressTool 层:包装图片压缩功能,简化压缩 API

  • PIL、OpenCV 层:负责压缩、处理图片

  • Logger 层:记录日志

  • Thread 层:多线程操作,提升执行效率


工作流程


image.png


使用流程


python 环境


python3 环境要求


输入工程地址


image.png


回车运行


image.png


最终效果


以工程中其中一个 module 为例,清理掉超出图片最大尺寸约束的图片后,图片资源大小可以由 4.4Mb 锐减至 88Kb ;检查并修改对应分辨率的图片资源后,应用运行时不再出现 UI 模糊的情况。


后记


优化类工作往往解决的不仅仅是技术问题,更是管理问题。


制定了开发标准能否顺利执行?架构演进能否跟上业务的不断发展?为了性能指标能否排除万难团结协作?......


管理类问题只能交由管理解决,绝不是某个技术工具就能解决得了的。


看着那些来自大厂的头部 APP,白屏、卡顿、高内存占用等都非常常见,再加上给用户定制的“私人专属”开屏广告,使得启动速度异常地慢。从用户体验的角度来说,不可谓优秀。是它们的技术力不够吗?


应该不是。


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

玩会儿Compose,原神主题列表

Jetpack Compose出来有一段时间了,一直都没有去尝试,这次有点想法去玩一玩这个声明性界面工具,就以“原神”为主题写个列表吧。 整体设计参考DisneyCompose 效果图: 数据源 因为数据比较简单,也就只包含图片、姓名、描述等。...
继续阅读 »

Jetpack Compose出来有一段时间了,一直都没有去尝试,这次有点想法去玩一玩这个声明性界面工具,就以“原神”为主题写个列表吧。


整体设计参考DisneyCompose


效果图:


image.png


image.png


数据源


因为数据比较简单,也就只包含图片、姓名、描述等。所以在后台数据存储上选择的是Bmob后端云,一个方便前端开发的后端服务平台。


主要数据也是从原神各大网站搜集下来的,新建表结构并且将数据填充,我们简单看一下Bmob的后台。


image.png


数据准备好了,那就开始我们的Compose之旅。


首页UI绘制


整体结构


从上面的项目效果图来看,首页总布局属于是一个网格列表,平分两格,列表中的每个Item上方带有头像,头像下面是角色名称以及角色其他信息。


image.png


网格布局


因为整体分成两列,所以选择的是网格布局,Compose提供了一个实现-LazyVerticalGrid


fun LazyVerticalGrid(
cells: GridCells,
modifier: Modifier = Modifier,
state: LazyListState = rememberLazyListState(),
contentPadding: PaddingValues = PaddingValues(0.dp),
content: LazyGridScope.() -> Unit
)

LazyVerticalGrid中有几个重要参数先说明一下:



  • GridCells :主要控制如何将单元格构建为列,如GridCells.Fixed(2),表示两列平分。

  • Modifier : 主要用来对列表进行额外的修饰。

  • PaddingValues :主要设置围绕整个内容的padding。

  • LazyListState :用来控制或观察列表状态的状态对象


首页布局是平分两列的网格布局,那相应的代码如下:


LazyVerticalGrid(cells = GridCells.Fixed(2)) {}

单个Item


看过了外部框架,那现在来看每个Item的布局。每个Item为卡片式,外边框为圆角,且带有阴影。内部上方是一张图片Image,图片下方是两行文字Text。那Item具体该怎样布局?


我们先来看看在Compose之前,在xml中是怎么写?例如使用ConstraintLayout布局,顶部放一个ImageView,再来一个TextView layout_constraintTop_toBottomOf ImageView,最后在来个TextViewTopToBottomOf第一个TextView


那使用Compose应该怎么写?


其实在Compose里也存在着ConstraintLayout布局并且具体Api的调用思路与在xml中使用也是一致的。我们就来看看具体操作。


ConstraintLayout() {
Image()
Text()
Text()
}

一共两个元素:ImageText,分别代表着xml里的ImageViewTextView



  • Image:


Image(
painter = rememberCoilPainter(request = item.url),
contentDescription = "",
contentScale = ContentScale.Crop,
modifier = Modifier
.clickable(onClick = {
val objectId = item.objectId
navController.navigate("detail/$objectId")
})
.padding(0.dp, 4.dp, 0.dp, 0.dp)
.width(180.dp)
.height(160.dp)
.constrainAs(image) {
centerHorizontallyTo(parent)
top.linkTo(parent.top)
})

Image加载的是网络图片,则使用painter加载图片链接,contentScale与xml中的scaleType相似,modifier主要设置图片的样式,点击事件、宽高等。里面有一个需要注意的点constrainAs(image)


constrainAs(image) {
centerHorizontallyTo(parent)
top.linkTo(parent.top)
}

这段代码主要表示Image在父布局中的位置,例如相对父布局,相对其他子控件等,有点xml中layout_constraintTop_toBottomOf内味。下面Text也是相同的道理。



  • Text


Text(text = item.name,
color = Color.Black,
style = MaterialTheme.typography.h6,
textAlign = TextAlign.Center,
modifier = Modifier
.padding(0.dp, 4.dp, 0.dp, 0.dp)
.constrainAs(title) {
centerHorizontallyTo(parent)
top.linkTo(image.bottom)
}
)

Text的设置主要包含Text内容、文字类型、大小、颜色等。在constrainAs(title)里有一句top.linkTo(image.bottom),这句代码指的就是xml中,TextView layout_constraintTop_toBottomOf ImageView


在Image和Text中发现了一个点,constrainAs(?)中传入了一个值,且设置相对位置时也是以此值为控件的代表。这是在进行相对位置的设定之前,利用createRefs创建多个引用,在ConstraintLayout中作为Modifier.constrainAs的一部分分配给布局。


val (image, title, content) = createRefs()

具体代码:


ConstraintLayout() {
val (image, title, content) = createRefs()
//头像
Image(
//图片地址
painter = rememberCoilPainter(request = item.url),
contentDescription = "",
//图片缩放规则
contentScale = ContentScale.Crop,
modifier = Modifier
.clickable(onClick = {//点击事件
val objectId = item.objectId
navController.navigate("detail/$objectId")
})
.padding(0.dp, 4.dp, 0.dp, 0.dp)
.width(180.dp)
.height(160.dp)
.constrainAs(image) {
centerHorizontallyTo(parent) //水平居中
top.linkTo(parent.top)//位于父布局的顶部
})
//文字
Text(text = item.name,
color = Color.Black,//颜色
style = MaterialTheme.typography.h6,//字体格式
textAlign = TextAlign.Center,
modifier = Modifier
.padding(0.dp, 4.dp, 0.dp, 0.dp)
.constrainAs(title) {
centerHorizontallyTo(parent)//水平居中
top.linkTo(image.bottom)//位于图片的下方
}
)
Text(text = item.from,
color = Color.Black,
style = MaterialTheme.typography.body1,
textAlign = TextAlign.Center,
modifier = Modifier
.padding(4.dp)
.constrainAs(content) {
centerHorizontallyTo(parent)
top.linkTo(title.bottom)

})
}

image.png


数据填充


UI已经画好了,接下来就是数据展示的事情。还是以ViewModel-LiveData-Repository为整体请求方式。 因为数据都存储到了Bmob后台,就直接使用Bmob的方式查询数据:


private val bmobQuery: BmobQuery<GcDataItem> = BmobQuery()

fun queryRoleData(successLiveData: MutableLiveData<List<GcDataItem>>) {
bmobQuery.findObjects(object : FindListener<GcDataItem>() {
override fun done(list: MutableList<GcDataItem>?, e: BmobException?) {
if (e == null) {
successLiveData.value = list
}
}

})
}

具体的请求方式可参考Bmob的完档,这里就不在赘述。 ViewModel中还是抛出一个LiveData,而UI层相对之前有一些变化。


@OptIn(ExperimentalFoundationApi::class)
@Composable
fun HomePoster(navController: NavController, model: HomeViewModel
= viewModel()) {
model.queryGcData()
val data: List<GcDataItem> by model.getDataLiveData().observeAsState(listOf())

LazyVerticalGrid(cells = GridCells.Fixed(2)) {
items(data) {
ItemPoster(navController, item = it)
}
}

}

Compose提供了一个viewModel()方法来获取ViewModel实例,至于怎么拿到数据,Compose提供了LiveData的一个扩展方法 observeAsState(listOf()) 。它的主要作用是用来观察这个LiveData,并通过State表示它的值,每次有新值提交到LiveData时,返回的状态将被更新,从而导致每个状态的重新组合。


拿到List数据后,网格LazyVerticalGrid就开始使用items(data){}添加列表,


 LazyVerticalGrid(cells = GridCells.Fixed(2)) {
items(data) {
ItemPoster(navController, item = it)
}
}

而ItemPoster就是我们设置Item布局的地方,将每个Item的数据传递给ItemPoster,利用Image、Text等控件设置imageUrl、text内容等。


@Composable
fun ItemPoster(navController: NavController, item: GcDataItem) {
Surface(
modifier = Modifier
.padding(4.dp),
color = Color.White,
elevation = 8.dp,
shape = RoundedCornerShape(8.dp)
)
{
ConstraintLayout() {
val (image, title, content) = createRefs()

Image(
//设置图片Url-item.url
painter = rememberCoilPainter(request = item.url),
...)

Text(text = item.name
...)

Text(text = item.from
...)
}

}

跳转


样例中还有一个从列表跳转到详情页的功能,Compose提供了一个跳转组件-navigation。这个navigation与之前管理Fragment的navigation思路也是一致的,利用NavHostController进行不同页面的管理。我们先使用 rememberNavController()方法创建一个NavHostController实例。


val navController = rememberNavController()

接着将navController与NavHost相关联,且设置导航图的起始目的地startDestination


 NavHost(navController = navController, startDestination = "Home") {}

我们将起始目的地暂时先标记为“Home”。 那如何对页面进行管理?这就需要在NavHost中使用composable添加页面,例如该项目有两个页面,一个首页列表页,一个详情页。我们就可以这样写:


 NavHost(
navController = navController, startDestination = "Home"
)
{
composable(
route = "Home",
)
{
HomePoster(navController)
}

composable("detail/{objectId}"){
val objectId = it.arguments?.getString("objectId")
DetailPoster(objectId){
navController.popBackStack()
}
}
}

第一个composable则代表的是列表页,并且将到达目的地的路线route设置为“Home”,其实类似于ARouter框架中在每个Activity上设置Path,做一个标识作用,后面做跳转时也是依据该route进行跳转。


第二个composable则代表的是详情页,同样设置route="detail"


那如何从列表页跳到详情页?只需要在点击事件里使用navController.navigate("detail"),传入想要跳转的route即可。


携带参数跳转


因为详情页需要根据所点击列表Item的Id进行数据查询,点击时要将id传到详情页,这就需要携带参数。 在Compose中,向route添加参数占位符,如"detail/{objectId}",从composable()函数提取 NavArguments。 如下修改详情页:


 composable("detail/{objectId}"){
val objectId = it.arguments?.getString("objectId")
DetailPoster(objectId){
navController.popBackStack()
}
}

跳转时将objectId传到route的占位符中即可。


clickable(onClick = {
val objectId = item.objectId
navController.navigate("detail/$objectId")})

当然,compose navigation还支持launchMode设置、深层链接等,具体可查看官方文档


一点感受


对于用习惯了xml编写UI的我来说,首次上手Compose其实还是蛮不习惯,Compose打破了原有的格局,给了我们一个全新的视角去看待Android,学完后有种“哦,原来UI还可以这么干!!”的感叹。对于Android开发者来说,其实需要这些新的路线去突破自己的固有化思维。


Compose的风格其实和Flutter有点像,估计是出于同一个爸爸的原因。但是Compose没有Flutter的无限套娃,对Android开发者来说还是比较友好的。如果想要学习Flutter,可以用Compose作为过渡。


以上便是本篇内容,感谢阅读,如果对你有帮助,欢迎点赞收藏关注三连走一波?   



项目地址:genshin-compose


收起阅读 »

Objective-C 消息转发深度理解(2)

4.1.3 forwarding_prep_0伪代码分析Hopper分析完毕后直接搜索forwarding_prep_0查看反汇编伪代码:int ___forwarding_prep_0___(int arg0, int arg1, int arg2, int...
继续阅读 »


4.1.3 forwarding_prep_0伪代码分析

Hopper分析完毕后直接搜索forwarding_prep_0查看反汇编伪代码:

int ___forwarding_prep_0___(int arg0, int arg1, int arg2, int arg3, int arg4, int arg5) {
//……
rax = ____forwarding___(&stack[0], 0x0);
if (rax != 0x0) {
rax = *rax;
}
else {
//arg0,arg1
rax = objc_msgSend(stack[0], stack[8]);
}
return rax;
}
  • 可以看到内部是对___forwarding___的调用。
  • ____forwarding___返回值不存在的时候调用的是objc_msgSend参数是arg0
    arg1

4.1.4 __forwarding__伪代码分析


点击进去查看___forwarding___的实现:


int ____forwarding___(int arg0, int arg1, int arg2, int arg3, int arg4, int arg5) {
r9 = arg5;
r8 = arg4;
rcx = arg3;
r13 = arg1;
r15 = arg0;
rax = COND_BYTE_SET(NE);
if (arg1 != 0x0) {
r12 = *_objc_msgSend_stret;
}
else {
r12 = *_objc_msgSend;
}
rbx = *(r15 + rax * 0x8);
rsi = *(r15 + rax * 0x8 + 0x8);
var_140 = rax * 0x8;
if (rbx >= 0x0) goto loc_115af7;

loc_115ac0:
//target pointer处理
rax = *_objc_debug_taggedpointer_obfuscator;
rax = *rax;
rcx = (rax ^ rbx) >> 0x3c & 0x7;
rax = ((rax ^ rbx) >> 0x34 & 0xff) + 0x8;
if (rcx != 0x7) {
rax = rcx;
}
if (rax == 0x0) goto loc_115ea6;

loc_115af7:
var_150 = r12;
var_138 = rsi;
var_148 = r15;
rax = object_getClass(rbx);
r15 = rax;
r12 = class_getName(rax);
//是否能响应 forwardingTargetForSelector,不能响应跳转 loc_115bab 否则继续执行 也就是forwardingTargetForSelector方法返回nil或者自身
if (class_respondsToSelector(r15, @selector(forwardingTargetForSelector:)) == 0x0) goto loc_115bab;

loc_115b38:
//rax返回值
rax = [rbx forwardingTargetForSelector:var_138];
//返回值是否存在,返回值是否等于自己 是则跳转 loc_115bab
if ((rax == 0x0) || (rax == rbx)) goto loc_115bab;

loc_115b55:
if (rax >= 0x0) goto loc_115b91;

loc_115b5a:
rcx = *_objc_debug_taggedpointer_obfuscator;
rcx = *rcx;
rdx = (rcx ^ rax) >> 0x3c & 0x7;
rcx = ((rcx ^ rax) >> 0x34 & 0xff) + 0x8;
if (rdx != 0x7) {
rcx = rdx;
}
if (rcx == 0x0) goto loc_115e95;

loc_115b91:
*(var_148 + var_140) = rax;
r15 = 0x0;
goto loc_115ef1;

loc_115ef1:
if (**___stack_chk_guard == **___stack_chk_guard) {
rax = r15;
}
else {
rax = __stack_chk_fail();
}
//返回 forwardingTargetForSelector 为消息的接收者
return rax;

loc_115e95:
rbx = rax;
r15 = var_148;
r12 = var_150;
goto loc_115ea6;

loc_115ea6:
if (dyld_program_sdk_at_least(0x7e30901ffffffff) != 0x0) goto loc_116040;

loc_115ebd:
r14 = _getAtomTarget(rbx);
*(r15 + var_140) = r14;
___invoking___(r12, r15, r15, 0x400, 0x0, r9, var_150, var_148, var_140, var_138, var_130, stack[-304], stack[-296], stack[-288], stack[-280], stack[-272], stack[-264], stack[-256], stack[-248], stack[-240]);
if (*r15 == r14) {
*r15 = rbx;
}
goto loc_115ef1;

loc_116040:
____forwarding___.cold.1();
rax = objc_opt_class(@class(NSInvocation));
*____forwarding___.invClass = rax;
rax = class_getInstanceSize(rax);
*____forwarding___.invClassSize = rax;
return rax;

loc_115bab:
var_140 = rbx;
//是否僵尸对象
if (strncmp(r12, "_NSZombie_", 0xa) == 0x0) goto loc_115f30;

loc_115bce:
r14 = var_140;
//是否能够响应 methodSignatureForSelector
if (class_respondsToSelector(r15, @selector(methodSignatureForSelector:)) == 0x0) goto loc_115f46;

loc_115bef:
rbx = var_138;
//调用
rax = [r14 methodSignatureForSelector:rbx];
if (rax == 0x0) goto loc_115fc1;

loc_115c0e:
r15 = rax;
rax = [rax _frameDescriptor];
r12 = rax;
if (((*(int16_t *)(*rax + 0x22) & 0xffff) >> 0x6 & 0x1) != r13) {
rax = sel_getName(rbx);
rcx = "";
if ((*(int16_t *)(*r12 + 0x22) & 0xffff & 0x40) == 0x0) {
rcx = " not";
}
r8 = "";
if (r13 == 0x0) {
r8 = " not";
}
_CFLog(0x4, @"*** NSForwarding: warning: method signature and compiler disagree on struct-return-edness of '%s'. Signature thinks it does%s return a struct, and compiler thinks it does%s.", rax, rcx, r8, r9, var_150);
}
//是否能够响应_forwardStackInvocation
if (class_respondsToSelector(object_getClass(r14), @selector(_forwardStackInvocation:)) == 0x0) goto loc_115d61;

loc_115c9a:
if (*____forwarding___.onceToken != 0xffffffffffffffff) {
dispatch_once(____forwarding___.onceToken, ^ {/* block implemented at ______forwarding____block_invoke */ } });
}
[NSInvocation requiredStackSizeForSignature:r15];
var_138 = r15;
rdx = *____forwarding___.invClassSize;
r13 = &var_150 - (rdx + 0xf & 0xfffffffffffffff0);
memset(r13, 0x0, rdx);
objc_constructInstance(*____forwarding___.invClass, r13);
var_150 = rax;
r15 = var_138;
[r13 _initWithMethodSignature:var_138 frame:var_148 buffer:&stack[-8] - (0xf + rax & 0xfffffffffffffff0) size:rax];
[var_140 _forwardStackInvocation:r13];
rbx = 0x1;
goto loc_115dce;

loc_115dce:
if (*(int8_t *)(r13 + 0x34) != 0x0) {
rax = *r12;
if (*(int8_t *)(rax + 0x22) < 0x0) {
rcx = *(int32_t *)(rax + 0x1c);
rdx = *(int8_t *)(rax + 0x20) & 0xff;
memmove(*(rdx + var_148 + rcx), *(rdx + rcx + *(r13 + 0x8)), *(int32_t *)(*rax + 0x10));
}
}
rax = [r15 methodReturnType];
r14 = rax;
rax = *(int8_t *)rax;
if ((rax != 0x76) && (((rax != 0x56) || (*(int8_t *)(r14 + 0x1) != 0x76)))) {
r15 = *(r13 + 0x10);
if (rbx != 0x0) {
r15 = [[NSData dataWithBytes:r15 length:var_150] bytes];
[r13 release];
rax = *(int8_t *)r14;
}
if (rax == 0x44) {
asm { fld tword [r15] };
}
}
else {
r15 = ____forwarding___.placeholder;
if (rbx != 0x0) {
r15 = ____forwarding___.placeholder;
[r13 release];
}
}
goto loc_115ef1;

loc_115d61:
var_138 = r12;
r12 = r14;
//forwardInvocation的判断,如果没有实现直接跳转loc_115f8e
if (class_respondsToSelector(object_getClass(r14), @selector(forwardInvocation:)) == 0x0) goto loc_115f8e;

loc_115d8d:
rax = [NSInvocation _invocationWithMethodSignature:r15 frame:var_148];
r13 = rax;
[r12 forwardInvocation:rax];
var_150 = 0x0;
rbx = 0x0;
r12 = var_138;
goto loc_115dce;

loc_115f8e:
//错误日志
r14 = @selector(forwardInvocation:);
____forwarding___.cold.4(&var_130, r12);
rcx = r14;
_CFLog(0x4, @"*** NSForwarding: warning: object %p of class '%s' does not implement methodSignatureForSelector: -- trouble ahead", var_140, rcx, r8, r9, var_150);
goto loc_115fba;

loc_115fba:
rbx = var_138;
goto loc_115fc1;

loc_115fc1:
rax = sel_getName(rbx);
r14 = rax;
rax = sel_getUid(rax);
if (rax != rbx) {
rcx = r14;
r8 = rax;
_CFLog(0x4, @"*** NSForwarding: warning: selector (%p) for message '%s' does not match selector known to Objective C runtime (%p)-- abort", var_138, rcx, r8, r9, var_150);
}
if (class_respondsToSelector(object_getClass(var_140), @selector(doesNotRecognizeSelector:)) == 0x0) goto loc_116034;

loc_11601b:
[var_140 doesNotRecognizeSelector:rdx];
asm { ud2 };
rax = loc_116034(rdi, rsi, rdx, rcx, r8, r9);
return rax;

loc_116034:
____forwarding___.cold.3(var_140);
goto loc_116040;

loc_115f46:
rbx = class_getSuperclass(r15);
r14 = object_getClassName(r14);
if (rbx == 0x0) {
rax = object_getClassName(var_140);
rcx = r14;
r8 = rax;
_CFLog(0x4, @"*** NSForwarding: warning: object %p of class '%s' does not implement methodSignatureForSelector: -- did you forget to declare the superclass of '%s'?", var_140, rcx, r8, r9, var_150);
}
else {
rcx = r14;
_CFLog(0x4, @"*** NSForwarding: warning: object %p of class '%s' does not implement methodSignatureForSelector: -- trouble ahead", var_140, rcx, r8, r9, var_150);
}
goto loc_115fba;

loc_115f30:
r14 = @selector(forwardingTargetForSelector:);
____forwarding___.cold.2(var_140, r12, var_138, rcx, r8);
goto loc_115f46;
}

可以看到汇编伪代码的调用流程与看到的API调用流程差不多。


4.1.5 __forwarding__伪代码还原


还原主要逻辑伪代码如下:


#include <stdio.h>

@interface NSInvocation(additions)

+ (unsigned long long)requiredStackSizeForSignature:(NSMethodSignature *)signature;

-(id)_initWithMethodSignature:(id)arg1 frame:(void*)arg2 buffer:(void*)arg3 size:(unsigned long long)arg4;

+(id)_invocationWithMethodSignature:(id)arg1 frame:(void*)arg2;

@end


@interface NSObject(additions)

- (void)_forwardStackInvocation:(NSInvocation *)invocation;

@end


void forwardingTargetForSelector(Class cls, SEL sel, const char * className, id obj);
void methodSignatureForSelector(Class cls, id obj, SEL sel);
void doesNotRecognizeSelector(id obj, SEL sel);
void _forwardStackInvocation(id obj,NSMethodSignature *signature);
void forwardInvocation(id obj,NSMethodSignature *signature);

int ____forwarding___(int arg0, int arg1, int arg2, int arg3, int arg4, int arg5) {
SEL sel = NULL;
id obj;
Class cls = object_getClass(obj);
const char * className = class_getName(cls);
forwardingTargetForSelector(cls,sel,className,obj);
return 0;
}

void forwardingTargetForSelector(Class cls, SEL sel, const char * className, id obj) {
//是否能响应 forwardingTargetForSelector,不能响应跳转 loc_115bab 否则继续执行 也就是forwardingTargetForSelector方法返回nil或者自身
if (class_respondsToSelector(cls, @selector(forwardingTargetForSelector:))) {
id obj = [cls forwardingTargetForSelector:sel];
if ((obj == nil) || (obj == cls)) {
methodSignatureForSelector(cls,obj,sel);
} else if (obj >= 0x0) {
//返回 forwardingTargetForSelector 备用消息接收者
// return obj;
} else {
//taggedpointer 处理
//返回NSInvocation size数据
}
} else {
//是否僵尸对象
if (strncmp(className, "_NSZombie_", 0xa)) {
methodSignatureForSelector(cls,obj,sel);
} else {
SEL currentSel = @selector(forwardingTargetForSelector:);
doesNotRecognizeSelector(obj,currentSel);
}
}
}


void methodSignatureForSelector(Class cls, id obj, SEL sel) {
if (class_respondsToSelector(cls, @selector(methodSignatureForSelector:))) {
NSMethodSignature *signature = [obj methodSignatureForSelector:sel];
if (signature) {
_forwardStackInvocation(obj,signature);
} else {
doesNotRecognizeSelector(obj,sel);
}
} else {
doesNotRecognizeSelector(obj,sel);
}
}

void _forwardStackInvocation(id obj,NSMethodSignature *signature) {
//是否能够响应_forwardStackInvocation
if (class_respondsToSelector(object_getClass(obj), @selector(_forwardStackInvocation:))) {
//执行dispatch_once相关逻辑
[NSInvocation requiredStackSizeForSignature:signature];
void *bytes;
// objc_constructInstance([NSInvocation class], bytes);
NSInvocation *invocation = [invocation _initWithMethodSignature:signature frame:NULL buffer:NULL size:bytes];
[obj _forwardStackInvocation:invocation];
const char * type = [signature methodReturnType];
//返回signature
} else {
forwardInvocation(obj,signature);
}
}

void forwardInvocation(id obj,NSMethodSignature *signature) {
//forwardInvocation的判断,如果没有实现直接跳转loc_115f8e
if (class_respondsToSelector(object_getClass(obj), @selector(forwardInvocation:))) {
NSInvocation *invocation = [NSInvocation _invocationWithMethodSignature:signature frame:NULL];
[obj forwardInvocation:invocation];
const char * type = [signature methodReturnType];
//返回signature
} else {
SEL sel = @selector(forwardInvocation:);
doesNotRecognizeSelector(obj,sel);
}
}

void doesNotRecognizeSelector(id obj, SEL sel) {
if (class_respondsToSelector(object_getClass(obj), @selector(doesNotRecognizeSelector:))) {
[obj doesNotRecognizeSelector:sel];
/*
____forwarding___.cold.1();
rax = objc_opt_class(@class(NSInvocation));
*____forwarding___.invClass = rax;
rax = class_getInstanceSize(rax);
*____forwarding___.invClassSize = rax;
return rax;
*/

} else {
/*
____forwarding___.cold.1();
rax = objc_opt_class(@class(NSInvocation));
*____forwarding___.invClass = rax;
rax = class_getInstanceSize(rax);
*____forwarding___.invClassSize = rax;
return rax;
*/

}
}
为了方便分析我这里class-dumpCoreFoundation头文件。手机端使用cycript进入SpringBoard应用,然后classdumpdyld导出CoreFoudation的头文件,最后拷贝到电脑端,具体操作如下:

cycript -p SpringBoard
@import net.limneos.classdumpdyld;
classdumpdyld.dumpBundle([NSBundle > bundleWithIdentifier:@"com.apple.CoreFoudation"]);
//输出导出头文件路径
@"Wrote all headers to /tmp/CoreFoundation"
//拷贝到电脑的相应目录
scp -r -P 12345 root@localhost:/tmp/CoreFoundation/ ./CoreFoundation_Headers/

伪代码流程图如下



反汇编流程与根据API分析的流程差不多。

  • forwardingTargetForSelector快速转发会对返回值会进行判断,如果是返回的自身或者nil直接进入下一流程(慢速转发)。
  • 如果返回taggedpointer有单独的处理。
  • methodSignatureForSelector慢速转发会先判断有没有实现_forwardStackInvocation(私有方法)。实现_forwardStackInvocation后不会再进入forwardInvocation流程,相当于_forwardStackInvocation是一个私有的前置条件。
  • methodSignatureForSelector如果没有返回签名信息不会继续进行下面的流程。
  • forwardInvocation没有实现就直接走到doesNotRecognizeSelector流程了。

4.2 流程分析


上篇文章分析resolveInstanceMethod在消息转发后还会调用一次resolveInstanceMethod(在日志文件中看到是在doesNotRecognizeSelector之前,methodSignatureForSelector之后)。那么实现对应的方法做下验证:

HPObject resolveInstanceMethod: HPObject-0x100008290-instanceMethod
-[HPObject forwardingTargetForSelector:] - instanceMethod
-[HPObject methodSignatureForSelector:] - instanceMethod
HPObject resolveInstanceMethod: HPObject-0x100008290-instanceMethod
-[HPObject doesNotRecognizeSelector:] - instanceMethod

证实是在methodSignatureForSelector之后,doesNotRecognizeSelector之前有一次进行了方法动态决议。那么为什么要这么处理呢?因为消息转发的过程中可能已经加入了对应的sel-imp,所以再给一次机会进行方法动态决议。这次决议后不会再进行消息转发。

但是在反汇编分析中并没有明确的再次进行动态方法决议的逻辑。


4.2.1 反汇编以及源码探究

那么在第二次调用resolveInstanceMethod前打断点查看下堆栈信息
macOS堆栈如下:

* thread #1, queue = 'com.apple.main-thread', stop reason = breakpoint 5.1
frame #0: 0x0000000100300f53 libobjc.A.dylib`resolveMethod_locked(inst=0x0000000000000000, sel="instanceMethod", cls=HPObject, behavior=0) at objc-runtime-new.mm:6339:13
frame #1: 0x00000001002ffbd5 libobjc.A.dylib`lookUpImpOrForward(inst=0x0000000000000000, sel="instanceMethod", cls=HPObject, behavior=0) at objc-runtime-new.mm:6601:16
frame #2: 0x00000001002d6df9 libobjc.A.dylib`class_getInstanceMethod(cls=HPObject, sel="instanceMethod") at objc-runtime-new.mm:6210:5
* frame #3: 0x00007fff2e33fc68 CoreFoundation`__methodDescriptionForSelector + 282
frame #4: 0x00007fff2e35b57c CoreFoundation`-[NSObject(NSObject) methodSignatureForSelector:] + 38
frame #5: 0x0000000100003a21 HPObjcTest`-[HPObject methodSignatureForSelector:](self=0x0000000100706a30, _cmd="methodSignatureForSelector:", aSelector="instanceMethod") at HPObject.m:29:12 [opt]
frame #6: 0x00007fff2e327fc0 CoreFoundation`___forwarding___ + 408
frame #7: 0x00007fff2e327d98 CoreFoundation`__forwarding_prep_0___ + 120
frame #8: 0x0000000100003c79 HPObjcTest`main + 153
frame #9: 0x00007fff683fecc9 libdyld.dylib`start + 1
frame #10: 0x00007fff683fecc9 libdyld.dylib`start + 1
可以看到methodSignatureForSelector调用后进入了__methodDescriptionForSelector随后调用了class_getInstanceMethod。查看汇编确实在__methodDescriptionForSelector中调用了class_getInstanceMethod


那么系统是如何从methodSignatureForSelector调用到__methodDescriptionForSelector的?
当前的methodSignatureForSelector的实现是:

- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector {
NSLog(@"%s - %@",__func__,NSStringFromSelector(aSelector));
return [super methodSignatureForSelector:aSelector];
}

如果改为返回nil呢?

HPObject resolveInstanceMethod: HPObject-0x100008288-instanceMethod
-[HPObject forwardingTargetForSelector:] - instanceMethod
-[HPObject methodSignatureForSelector:] - instanceMethod
-[HPObject doesNotRecognizeSelector:] - instanceMethod
这个时候发现没有第二次调用了,那也就是说核心逻辑在[super methodSignatureForSelector:aSelector]的实现中。
查看源码:

// Replaced by CF (returns an NSMethodSignature)
+ (NSMethodSignature *)methodSignatureForSelector:(SEL)sel {
_objc_fatal("+[NSObject methodSignatureForSelector:] "
"not available without CoreFoundation");
}

// Replaced by CF (returns an NSMethodSignature)
- (NSMethodSignature *)methodSignatureForSelector:(SEL)sel {
_objc_fatal("-[NSObject methodSignatureForSelector:] "
"not available without CoreFoundation");
}

注释说的已经很明显了实现在CoreFoundation中,直接搜索methodSignatureForSelector的反汇编实现:


/* @class NSObject */
-(void *)methodSignatureForSelector:(void *)arg2 {
rdx = arg2;
if ((rdx != 0x0) && (___methodDescriptionForSelector(objc_opt_class(), rdx) != 0x0)) {
rax = [NSMethodSignature signatureWithObjCTypes:rdx];
}
else {
rax = 0x0;
}
return rax;
}
  • sel不为nil的时候会调用___methodDescriptionForSelector。这样就串联起来了。

class_getInstanceMethod的实现如下:


Method class_getInstanceMethod(Class cls, SEL sel)
{
if (!cls || !sel) return nil;
lookUpImpOrForward(nil, sel, cls, LOOKUP_RESOLVER);
return _class_getMethod(cls, sel);
}

4.2.2 断点调试验证

既然上面已经清楚了resolveInstanceMethod第二次调用是methodSignatureForSelector之后调用的,那么不妨打个符号断点跟踪下methodSignatureForSelector:




显然只需要关心调用的函数以及跳转逻辑。

跟进去__methodDescriptionForSelector


这样通过断点也从methodSignatureForSelector定位到了resolveInstanceMethod

结论:

  • 实例方法 - methodSignatureForSelector-> ___methodDescriptionForSelector -> class_getInstanceMethod-> lookUpImpOrForward->resolveMethod_locked-> resolveInstanceMethod
  • 类方法 + methodSignatureForSelector -> ___methodDescriptionForSelector(传递的是元类) -> class_getInstanceMethod- lookUpImpOrForward->resolveMethod_locked-> resolveClassMethod

⚠️总结:

  1. 在methodSignatureForSelector内部调用了class_getInstanceMethod进行lookUpImpOrForward随后进入方法动态决议。这也就是class_getInstanceMethod调用第二次的来源入口。
  2. methodSignatureForSelector后第二次调用class_getInstanceMethod是为了再给一次进行消息查找和动态决议流程,因为消息转发流程过程中有可能实现了对应的sel-imp

动态方法决议以及消息转发整个流程如下:




五、消息发送查找总结

前面已经通过objc_msgSend分析整个消息缓存、查找、决议、转发整个流程。

  • 通过CacheLookup进行消息快速查找
    • 整个cache查找过程相当于是insert过程的逆过程,找到imp就解码跳转,否则进入慢速查找流程。
  • 通过lookUpImpOrForward进行消息慢速查找
    • 慢速查找涉及到递归查找,查找过程分为二分查找/循环查找。
    • 找到imp直接跳转,否则查找父类缓存。父类缓存依然找不到则在父类方法列表中查找,直到找到nil。查找到父类方法/缓存方法直接插入自己的缓存中。
  • imp找不到的时候进行方法动态决议
    • 当快速和慢速消息查找都没有找到imp的时候就进入了方法动态决议流程,在这个流程中主要是添加imp后再次进行快速慢速消息查找。
  • 之后进入本篇的消息转发流程,消息转发分为快速以及慢速。
    • 在动态方法决议没有返回imp的时候就进入到了消息转发阶段。
    • 快速消息转发提供一个备用消息接收者,返回值不能为nil与自身。这个过程不能修改参数和返回值。
    • 慢速消息转发需要提供消息签名,只要提供有效签名就可以解决消息发送错误问题。同时要实现forwardInvocation配合处理消息。
    • forwardInvocation配合处理消息,使target生效起作用。
    • 在慢速消息转发后系统会再进行一次慢速消息查找流程。这次不会再进行消息转发。
    • 消息转发仍然没有解决问题会进入doesNotRecognizeSelector,这个方法并不能处理错误,实现它仍然会报错。只是能拿到错误信息而已。

⚠️慢速消息转发后系统仍然给了一次机会进行 慢速消息查找!!!(并不仅仅是动态方法决议)。

整个流程如下:







作者:HotPotCat
链接:https://www.jianshu.com/p/f5bf0549b1f5







收起阅读 »

iOS Hook原理 - 反hook& MonkeyDev

一、 反 hook 初探我们Hook别人的代码一般使用OC的MethodSwizzle,如果我们用fishhook将MethodSwizzle hook了,别人是不是就hook不了我们的代码了?1.1 创建主工程 AntiHookDemo创建一个工程AntiH...
继续阅读 »

一、 反 hook 初探

我们Hook别人的代码一般使用OCMethodSwizzle,如果我们用
fishhookMethodSwizzle hook了,别人是不是就hook不了我们的代码了?

1.1 创建主工程 AntiHookDemo

创建一个工程AntiHookDemo,页面中有两个按钮btn1btn2:



对应两个事件:

- (IBAction)btn1Click:(id)sender {
NSLog(@"click btn1");
}

- (IBAction)btn2Click:(id)sender {
NSLog(@"click btn2");
}

1.2 创建防护 HookManager (FrameWork 动态库)

这个时候要使用fishhook防护,在FrameWork中写防护代码。基于两点:

  1. Framework在主工程+ load执行之前执行+ load
  2. 别人注入的Framework也在防护代码之后。

创建一个HookManager Framework,文件结构下:




AntiHookManager.h

#import <Foundation/Foundation.h>
#import <objc/message.h>

//暴露给外界使用
CF_EXPORT void (*exchange_p)(Method _Nonnull m1, Method _Nonnull m2);

@interface AntiHookManager : NSObject

@end

AntiHookManager.m:

#import "AntiHookManager.h"
#import "fishhook.h"

@implementation AntiHookManager

+ (void)load {
//基本防护
struct rebinding exchange;
exchange.name = "method_exchangeImplementations";
exchange.replacement = hp_exchange;
exchange.replaced = (void *)&exchange_p;
struct rebinding bds[] = {exchange};
rebind_symbols(bds, 1);
}


//指回原方法
void (*exchange_p)(Method _Nonnull m1, Method _Nonnull m2);

void hp_exchange(Method _Nonnull m1, Method _Nonnull m2) {
//可以在这里进行上报后端等操作
NSLog(@"find Hook");
}

@end

HookManager.h中导出头文件:

#import <HookManager/AntiHookManager.h>

然后将AntiHookManager.h放入public Headers

修改主工程的ViewController.m如下:


#import <HookManager/HookManager.h>

@interface ViewController ()

@end

@implementation ViewController

- (void)viewDidLoad {
[super viewDidLoad];
exchange_p(class_getInstanceMethod(self.class, @selector(btn2Click:)),class_getInstanceMethod(self.class, @selector(test)));
}

- (void)test {
NSLog(@"self Hook Success");
}

- (IBAction)btn1Click:(id)sender {
NSLog(@"click btn1");
}

- (IBAction)btn2Click:(id)sender {
NSLog(@"click btn2");
}

@end

在工程中Hook自己的方法,这个时候运行主工程:


AntiHookDemo[1432:149145] click btn1
AntiHookDemo[1432:149145] self Hook Success

btn2能够被自己正常Hook


1.3 创建注入工程 HookDemo

  1. 在根目录创建APP文件夹以及Payload文件夹,拷贝AntiHookDemo.appAPP/Payload目录,压缩zip -ry AntiHookDemo.ipa Payload/生成.ipa文件
  2. 拷贝appResign.sh重签名脚本以及yololib注入工具到根目录。
  3. 创建HPHook注入Framework

HPHook代码如下:


#import "HPInject.h"
#import <objc/message.h>

@implementation HPInject

+ (void)load {
method_exchangeImplementations(class_getInstanceMethod(objc_getClass("ViewController"), @selector(btn1Click:)), class_getInstanceMethod(self, @selector(my_click)));
}

- (void)my_click {
NSLog(@"inject Success");
}

@end

编译运行:

AntiHookDemo[1437:149999] find  Hook
AntiHookDemo[1437:149999] click btn1
AntiHookDemo[1437:149999] self Hook Success

首先是检测到了Hook,其次自己内部btn2 hook成功了,btn1 hook没有注入成功。到这里暴露给自己用和防止别人Hook都已经成功了。对于三方库中正常使用到的Hook可以在防护代码中做逻辑判断可以加白名单等调用回原来的方法。如果自己的库在image list最后一个那么三方库其实已经Hook完了。

当然只Hook method_exchangeImplementations不能完全防护,还需要Hook class_replaceMethod以及method_setImplementation

这种防护方式破解很容易,一般不这么处理:
1.在Hopper中可以找到method_exchangeImplementations,直接在MachO中修改这个字符串HookManager中就Hook不到了(这里会直接crash,因为viewDidLoad中调用了exchange_p,对于有保护逻辑的就可以绕过了,并且method_exchangeImplementations没法做混淆)


2.可以很容易定位到防护代码,直接在防护代码之前Hook,或者将fishhook中的一些系统函数Hook也能破解。本质上是不执行防护代码。


二、MonkeyDev

MonkeyDev是逆向开发中一个常用的工具 MonkeyDev。能够帮助我们进行重签名和代码注入。


2.1 安装 MonkeyDev

theos安装(Cydia Substrate就是 theos中的工具)

sudo git clone --recursive https://github.com/theos/theos.git /opt/theos

配置环境变量

#逆向相关配置
#export THEOS=/opt/theos

#写入环境变量
#export PATH=$THEOS/bin:$PATH

运行nic.pl查看theos信息。



[error] Cowardly refusing to make a project inside $THEOS (/opt/theos/)出现这个错误则是export配置有问题。

指定Xcode

sudo xcode-select -s /Applications/Xcode.app

安装命令

这里是安装Xcode插件。安装完成后重启XcodeXcode中会出现MonkeyDev对应的功能:



  • MonkeyApp:自动给第三方应用集成RevealCycript和注入dylib的模块,支持调试dylib和第三方应用,支持Pod给第三放应用集成SDK,只需要准备一个砸壳后的ipa或者app文件即可。
  • MonkeyPod:提供了创建Pod的项目。
  • CaptainHook Tweak:使用CaptainHook提供的头文件进行OC函数的Hook以及属性的获取。
  • Command-line Tool:可以直接创建运行于越狱设备的命令行工具。
  • Logos Tweak:使用theos提供的logify.pl工具将.xm文件转成.mm文件进行编译,集成了CydiaSubstrate,可以使用MSHookMessageExMSHookFunctionHook OC函数和指定地址。


错误处理
1.MonkeyDev 安装出现:Types.xcspec not found
添加一个软连接:
sudo ln -s /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/Library/Xcode/PrivatePlugIns/IDEOSXSupportCore.ideplugin/Contents/Resources /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/Library/Xcode/Specifications

2.2 重签名

创建一个MonkeyDemo工程:


工程目录如下:



在工程目录下有一个TargetApp目录,直接将微信8.0.2版本拖进去:


编译运行工程:

这个时候就重签名成功了。相比用脚本自己跑方便很多,也能避免很多异常。

2.3 MonkeyDev 代码注入



工程配置

MonkeyDemo注入一下AntiHookDemo,将AntiHookDemo编译生成的App加入MonkeyDemoTargetApp中:


代码注入

MonkeyDemo工程MonkeyDemoDylib->Logos目录,.xm文件可以写OCC++C




MonkeyDemoDylib.xmtype改为Objective-C++ Preprocessed Source

这里面的默认代码就是Logos语法:




.xm默认打开方式修改为Xcode后重启Xcode就能识别代码了,否则就还是默认文本文件。将默认的代码删除,写Hook btn1Click的代码:

#import <UIKit/UIKit.h>

//要hook的类
%hook ViewController

//要hook的方法
- (void)btn1Click:(id)sender {
NSLog(@"Monkey Hook Success");
//调用原来的方法
%orig;
}

%end

直接运行工程后点击btn1

AntiHookDemo[9306:5972601] find  Hook
AntiHookDemo[9306:5972601] find Hook
AntiHookDemo[9309:5973617] Monkey Hook Success
AntiHookDemo[9350:5987306] click btn1




这个时候就Hook成功了,并且检测到了Hook。这里没有防护住是因为Monkey中用的是getImpsetImp
AntiHookManager做下改进:
AntiHookManager .h:

#import <Foundation/Foundation.h>
#import <objc/message.h>

//暴露给外界使用
CF_EXPORT void (*exchange_p)(Method _Nonnull m1, Method _Nonnull m2);

CF_EXPORT IMP _Nonnull (*getImp_p)(Method _Nonnull m);

CF_EXPORT IMP _Nonnull(*setImp_p)(Method _Nonnull m, IMP _Nonnull imp);

@interface AntiHookManager : NSObject

@end

AntiHookManager .m:

#import "AntiHookManager.h"
#import "fishhook.h"

@implementation AntiHookManager

+ (void)load {
//基本防护
struct rebinding exchange;
exchange.name = "method_exchangeImplementations";
exchange.replacement = hp_exchange;
exchange.replaced = (void *)&exchange_p;

struct rebinding setIMP;
setIMP.name = "method_setImplementation";
setIMP.replacement = hp_setImp;
setIMP.replaced = (void *)&setImp_p;


struct rebinding getIMP;
getIMP.name = "method_getImplementation";
getIMP.replacement = hp_getImp;
getIMP.replaced = (void *)&getImp_p;

struct rebinding bds[] = {exchange,setIMP,getIMP};
rebind_symbols(bds, 3);
}


//指回原方法
void (*exchange_p)(Method _Nonnull m1, Method _Nonnull m2);

IMP _Nonnull (*getImp_p)(Method _Nonnull m);

IMP _Nonnull(*setImp_p)(Method _Nonnull m, IMP _Nonnull imp);

void hp_exchange(Method _Nonnull m1, Method _Nonnull m2) {
//可以在这里进行上报后端等操作
NSLog(@"find Hook");
}

void (hp_getImp)(Method _Nonnull m) {
NSLog(@"find Hook getImp");
}

void (hp_setImp)(Method _Nonnull m, IMP _Nonnull imp) {
NSLog(@"find Hook setImp");
}

@end

这个时候控制台输出:


AntiHookDemo[1488:207119] find  Hook getImp
AntiHookDemo[1488:207119] find Hook
AntiHookDemo[1488:207119] find Hook getImp
AntiHookDemo[1488:207119] find Hook
AntiHookDemo[1488:207119] click btn1

点击btn1也没有Hook到了。在这里运行时有可能CrashJSEvaluateScript的时候,直接删除App重新跑一次就可以了。
libsubstrate.dylib解析的,
其实这里.xm文件是被libsubstrate.dylib解析成MonkeyDemoDylib.mm中的内容(.xm代码是不参与编译的):



MSHookMessageEx底层用的是setImpgetImpOC进行Hook的。

错误问题
1.Signing for "MonkeyDemoDylib" requires a development team. Select a development team in the Signing & Capabilities editor.

直接在该targetbuild settings 中添加CODE_SIGNING_ALLOWED=NO





2.Failed to locate Logos Processor. Is Theos installed? If not, see https://github.com/theos/theos/wiki/Inst allation.
出现这个错误一般是theos没有安装好。或者路径配置的有问题。

3.library not found for -libstdc++
需要下载对应的库到XCode目录中。参考:https://github.com/longyoung/libstdc.6.0.9-if-help-you-give-a-star

4.The WatchKit app’s Info.plist must have a WKCompanionAppBundleIdentifier key set to the bundle identifier of the companion app.
删除DerivedData重新运行。

5.This application or a bundle it contains has the same bundle identifier as this application or another bundle that it contains. Bundle identifiers must be unique.
这种情况大概率是手机上之前安装过相同bundleIdApp安装不同版本导致,需要删除重新安装。还有问题的话删除DerivedDatabundleId

6.This app contains a WatchKit app with one or more Siri Intents app extensions that declare IntentsSupported that are not declared in any of the companion app's Siri Intents app extensions. WatchKit Siri Intents extensions' IntentsSupported values must be a subset of the companion app's Siri Intents extensions' IntentsSupported values.
需要删除com.apple.WatchPlaceholder(在/opt/MonkeyDev/Tools目录中修改pack.sh):


rm -rf "${TARGET_APP_PATH}/com.apple.WatchPlaceholder" || true

然后删除DerivedData重新运行。

  1. LLVM Profile Error: Failed to write file "default.profraw": Operation not permitted
    这个说明App内部做了反调试防护。直接在Monkey中开启sysctl
rebind_symbols((struct rebinding[1]){{"sysctl", my_sysctl, (void*)&orig_sysctl}},1);
8.Attempted to load Reveal Library twice. Are you trying to load dynamic library with Reveal Framework already linked?
直接删除dylibOther Linker Flags的设置即可(可能的原因是手机端已经导入了这个库):



⚠️遇见莫名其妙的错误建议删除DerivedData重启Xcode重新运行。


总结

  • Hook
    • 使用fishhook Hookmethod_exchangeImplementationsclass_replaceMethodmethod_setImplementation
    • 需要在动态库中添加防护代码。
    • 本地导出原函数IMP供自己项目使用,配合白名单。
    • 这种防护很容易破解,一般不推荐这么使用。
  • MonkeyDev:逆向开发中一个常用的工具。
    • 重签名:很容易,直接拖进去.ipa或者.app运行工程就可以了。
    • 代码注入:Logos主要是编写.xm文件。底层依然是getImpsetImp的调用。



作者:HotPotCat
链接:https://www.jianshu.com/p/a68890a8fdb2

收起阅读 »

iOS逆向 - fishhook

一、Hook概述HOOK中文译为挂钩或钩子。在iOS逆向中是指改变程序运行流程的一种技术。通过hook可以让别人的程序执行自己所写的代码。在逆向中经常使用这种技术。只有了解其原理才能够对恶意代码进行有效的防护。比如很久之前的微信自动抢红包插件:1.1Hook的...
继续阅读 »

一、Hook概述

HOOK中文译为挂钩钩子。在iOS逆向中是指改变程序运行流程的一种技术。通过hook可以让别人的程序执行自己所写的代码。在逆向中经常使用这种技术。只有了解其原理才能够对恶意代码进行有效的防护。

比如很久之前的微信自动抢红包插件:


1.1Hook的几种方式

iOSHOOK技术的大致上分为5种:Method SwizzlefishhookCydia Substratelibffiinlinehook

1.1.1 Method Swizzle (OC)

利用OCRuntime特性,动态改变SEL(方法编号)和IMP(方法实现)的对应关系,达到OC方法调用流程改变的目的。主要用于OC方法。

可以将SEL 和 IMP 之间的关系理解为一本书的目录SEL 就像标题IMP就像页码。他们是一一对应的关系。(书的目录不一定一一对应,可能页码相同,理解就行。)。

Runtime提供了交换两个SELIMP对应关系的函数:

OBJC_EXPORT void
method_exchangeImplementations(Method _Nonnull m1, Method _Nonnull m2)
OBJC_AVAILABLE(10.5, 2.0, 9.0, 1.0, 2.0);

通过这个函数交换两个SELIMP对应关系的技术,称之为Method Swizzle(方法欺骗)


runtime中有3种方式实现方法交换:

  • method_exchangeImplementations:在分类中直接交换就可以了,如果不在分类需要配合class_addMethod实现原方法的回调。
  • class_replaceMethod:直接替换原方法。
  • method_setImplementation:重新赋值原方法,通过getImpsetImp配合。

1.1.2 fishhook (外部函数)

Facebook提供的一个动态修改链接mach-O文件的工具。利用MachO文件加载原理,通过修改懒加载非懒加载两个表的指针达到C(系统C函数)函数HOOK的目的。fishhook

总结下来是:dyld 更新 Mach-O 二进制的 __DATA segment 的 __la_symbol_str 中的指针,使用 rebind_symbol方法更新两个符号位置来进行符号的重新绑定。


1.1.3 Cydia Substrate

Cydia Substrate 原名为 Mobile Substrate ,主要作用是针对OC方法C函数以及函数地址进行HOOK操作。并不仅仅针对iOS而设计,安卓一样可以用。Cydia Substrate官方

Cydia Substrate主要分为3部分:Mobile HookerMobileLoadersafe mode

Mobile Hooker

它定义了一系列的宏和函数,底层调用objcruntimefishhook来替换系统或者目标应用的函数。其中有两个函数:


void MSHookMessageEx(Class class, SEL selector, IMP replacement, IMP result) 
MSHookFunction :(inline hook)主要作用于CC++函数 MSHookFunction。 Logos语法的%hook就是对这个函数做了一层封装。

void MSHookFunction(voidfunction,void* replacement,void** p_original)

MobileLoader

MobileLoader用于加载第三方dylib在运行的应用程序。启动时MobileLoader会根据规则把指定目录的第三方的动态库加载进去,第三方的动态库也就是我们写的破解程序。

safe mode

破解程序本质是dylib寄生在别人进程里。 系统进程一旦出错,可能导致整个进程崩溃,崩溃后就会造成iOS瘫痪。所以CydiaSubstrate引入了安全模式,在安全模式下所有基于CydiaSubstratede的三方dylib都会被禁用,便于查错与修复。

1.1.4 libffi

基于libbfi动态调用C函数。使用libffi中的ffi_closure_alloc构造与原方法参数一致的"函数" (stingerIMP),以替换原方法函数指针;此外,生成了原方法和Block的调用的参数模板cifblockCif。方法调用时,最终会调用到void _st_ffi_function(ffi_cif *cif, void *ret, void **args, void *userdata), 在该函数内,可获取到方法调用的所有参数、返回值位置,主要通过ffi_call根据cif调用原方法实现和切面blockAOP库 StingerBlockHook就是使用libbfi做的。

1.1.5 inlinehook 内联钩子 (静态)

Inline Hook 就是在运行的流程中插入跳转指令来抢夺运行流程的一个方法。大体分为三步:

  • 将原函数的前 N 个字节搬运到 Hook 函数的前 N 个字节;
  • 然后将原函数的前 N 个字节填充跳转到 Hook 函数的跳转指令;
  • 在 Hook 函数末尾几个字节填充跳转回原函数 +N 的跳转指令;


MSHookFunction就是inline hook

基于 Dobby 的 Inline HookDobby 是通过插入 __zDATA 段和__zTEXT 段到 Mach-O 中。

  • __zDATA 用来记录 Hook 信息(Hook 数量、每个 Hook 方法的地址)、每个 Hook 方法的信息(函数地址、跳转指令地址、写 Hook 函数的接口地址)、每个 Hook 的接口(指针)。
  • __zText 用来记录每个 Hook 函数的跳转指令。

dobby
Dobby(原名:HOOKZz)是一个全平台的inlineHook框架,它用起来就和fishhook一样。
Dobby 通过 mmap 把整个 Mach-O 文件映射到用户的内存空间,写入完成保存本地。所以 Dobby 并不是在原 Mach-O上进行操作,而是重新生成并替换。



二 fishHook

2.1 fishhook的使用

fishhook源码.h文件中只提供了两个函数和一个结构体rebinding

rebind_symbols、rebind_symbols_image


FISHHOOK_VISIBILITY
int rebind_symbols(struct rebinding rebindings[], size_t rebindings_nel);

FISHHOOK_VISIBILITY
int rebind_symbols_image(void *header,
intptr_t slide,
struct rebinding rebindings[],
size_t rebindings_nel)
;

  • rebindings[]:存放rebinding结构体的数组(可以同时交换多个函数)。
  • rebindings_nelrebindings数组的长度。
  • slideASLR
  • headerimageHeader

只有两个函数重新绑定符号,两个函数的区别是一个指定image一个不指定。按照我们一般的理解放在前面的接口更常用,参数少的更简单。

rebinding

struct rebinding {
const char *name;//需要HOOK的函数名称,C字符串
void *replacement;//新函数的地址
void **replaced;//原始函数地址的指针!
};

  • name:要HOOK的函数名称,C字符串。
  • replacement:新函数的地址。(函数指针,也就是函数名称)。
  • replaced:原始函数地址的指针。(二级指针)。

2.1.1 Hook NSLog

现在有个需求,Hook系统的NSLog函数。
Hook代码:

- (void)hook_NSLog {
struct rebinding rebindNSLog;
rebindNSLog.name = "NSLog";
rebindNSLog.replacement = HP_NSLog;
rebindNSLog.replaced = (void *)&sys_NSLog;

struct rebinding rebinds[] = {rebindNSLog};

rebind_symbols(rebinds, 1);
}

//原函数,函数指针
static void (*sys_NSLog)(NSString *format, ...);

//新函数
void HP_NSLog(NSString *format, ...) {
format = [format stringByAppendingFormat:@"\n Hook"];
//调用系统NSLog
sys_NSLog(format);
}
调用:

    [self hook_NSLog];
NSLog(@"hook_NSLog");
输出:

hook_NSLog
Hook

这个时候就已经HookNSLog,走到了HP_NSLog中。
Hook代码调用完毕,sys_NSLog保存系统NSLog原地址,NSLog指向HP_NSLog

2.1.2 Hook 自定义 C 函数

Hook一下自己的C函数:

void func(const char * str) {
NSLog(@"%s",str);
}

- (void)hook_func {
struct rebinding rebindFunc;
rebindFunc.name = "func";
rebindFunc.replacement = HP_func;
rebindFunc.replaced = (void *)&original_func;

struct rebinding rebinds[] = {rebindFunc};

rebind_symbols(rebinds, 1);
}
//原函数,函数指针
static void (*original_func)(const char * str);

//新函数
void HP_func(const char * str) {
NSLog(@"Hook func");
original_func(str);
}
调用:

 [self hook_func];
func("HotPotCat");
输出:
HotPotCat

这个时候可以看到没有Hookfunc

结论:自定义的函数fishhook hook 不了,系统的可以hook

2.2 fishhook原理

fishHOOK可以HOOK C函数,但是我们知道函数是静态的,也就是说在编译的时候,编译器就知道了它的实现地址,这也是为什么C函数只写函数声明调用时会报错。那么为什么fishhook还能够改变C函数的调用呢?难道函数也有动态的特性存在?

是否意味着C Hook就必须修改调用地址?那意味着要修改二进制。(原理上使用汇编可以实现。fishhook不是这么处理的)

那么系统函数和本地函数区别到底在哪里?

2.2.1 符号 & 符号绑定 & 符号表 & 重绑定符号

NSLog函数的地址在编译的那一刻并不知道NSLog的真实地址。NSLogFoundation框架中。在运行时NSLog的地址在 共享缓存 中。在整个手机中只有dyld知道NSLog的真实地址。

LLVM编译器生成MachO文件时,如果让我们做就先空着系统函数的地址,等运行起来再替换。我们知道MachO中分为Text(只读)和Data(可读可写),那么显然这种方式行不通。

那么可行的方案是在Data段放一个 占位符(8字节)让代码编译的时候直接bl 占位符。在运行的时候dyld加载应用的时候将Data段的地址修改为NSLog真实地址,代码bl 占位符没有变 。这个技术就叫做 PIC(position independent code`)位置无关代码。(实际不是这么简单)

  • 占位符 就叫做 符号
  • dylddata段符号进行修改的这个过程叫做 符号绑定
  • 一个又一个的符号放在一起形成了一个列表,叫做 符号表

对于外部的C函数通过 符号 找 地址 也就给了我们机会动态的Hook外部C函数。OC是修改SELIMP对应的关系,符号 也是修改符号所对应的地址。这个动作叫做 重新绑定符号表。这也就是fishhook``hook的原理。

2.2.2验证

Hook NSLog前后分别调用NSLog:

    NSLog(@"before");
[self hook_NSLog];
NSLog(@"after");




MachO中我们能看到懒加载和非懒加载符号表,dyld绑定过程中绑定的是非懒加载符号和弱符号的。NSLog是懒加载符号,只有调用的时候才去绑定。

MachO中可以看到_NSLogData(值)是0000000100006960offset为:0x8010
在第一个NSLog处打个断点 运行查看:
主程序开始0x0000000100b24000ASLR0xb24000

0x0000000100b24000 + 0x8010中存储的内容为0x0100b2a960
0000000100006960 + 0xb24000 (ASLR) = 0x100B2A960
所以这里就对应上了。0x0100b2a960这个地址就是(符号表中的值其实是一个代码的地址,指向本地代码。)。



执行完第一个NSLog后(hook前):



符号表指向了HP_NSLog

这也就是fishhook能够Hook的真正原因(修改懒加载符号表)。

2.3 符号绑定过程(间接)

刚才在上面NSLog第一次执行之前我们拿到的地址0x0100b2a960实际上指向一段本地代码,加上ASLR后执行对应地址的代码然后就修改了懒加载符号表。

那么这个过程究竟是怎么做的呢?

先说明一些符号的情况:

  • 本地符号:只能本MachO用。
  • 全局符号:暴露给外面用。
  • 间接符号:当我们要调用外部函数/方法时,在编译时期地址是不知道的。比如系统的NSLog

间接符号专门有个符号表Indirect Symbols





比首地址大0x0000000100e0c000,所以这个地址在本MachO中。
0x100e12998 - 0x0000000100e0c000 = 0x6998

6998MachOSymbol Stubs中:





这个时候就对应上了:



这段代码的意思是执行桩中的代码找到符号表中代码跳转执行(0000000100006A28)。

6A28这段代码在__stub_helper中:



对应上了。实际上执行的是dyld_stub_binder。也就是说懒加载符号表里面的初始值都是执行符号绑定的函数

dyld_stub_binder是外部函数,那么怎么得到的dyld_stub_binder函数呢?



所以dyld_stub_binder是通过去非懒加载表中查找。
验证 :




验证确认,No-Lazy Symbol Pointers表中默认值是0

符号绑定过程:

  • 程序一运行,先绑定No-Lazy Symbol Pointers表中dyld_stub_binder的值。
  • 调用NSLog先找桩,执行桩中的代码。桩中的代码是找懒加载符号表中的代码去执行。
  • 懒加载符号表中的初始值是本地的源代码,这个代码去NoLazy表中找绑定函数地址。
  • 进入dyldbinder函数进行绑定。

binder函数执行完毕后就调用第一次的NSLog了。这个时候再看一下懒加载符号表中的符号:




符号已经变了。这个时候符号就已经绑定成功了。

接着执行第二次NSLog,这个时候依然是去找桩中的代码执行:



这个时候通过桩直接跳到了真实地址(还是虚拟的)。这个做的原因是符号表中保存地址执行代码,代码是保存在代码段的(桩)。





  • 外部函数调用时执行桩中的代码(__TEXT,__stubs)。
  • 桩中的代码去懒加载符号表中找地址执行(__DATA,__la_symbo_ptrl)。
    • 通过懒加载符号表中的地址去执行。要么直接调用函数地址(绑定过了),要么去__TEXT,__stubhelper中找绑定函数进行绑定。懒加载符号表中默认保存的是寻找binder的代码。
  • 懒加载中的代码去__TEXT,__stubhelper中执行绑定代码(binder函数)。
  • 绑定函数在非懒加载符号表中(__DATA._got),程序运行就绑定好了dyld

2.4 通过符号找字符串

上面使用fishhook的时候我们是通过rebindNSLog.name = "NSLog";告诉fishhookhook NSLog。那么fishhook通过NSLog怎么找到的符号的呢?

首先,我们清楚在绑定的时候是去Lazy Symbol中去找的NSLog对应的绑定代码:




找的是0x00008008这个地址,在Lazy SymbolNSLog排在第一个。

Indirect Symbols中可以看到顺序和Lazy Symbols中相同,也就是要找Lazy Symbols中的符号,只要找到Indirect Symbols中对应的第几个就可以了。




那么怎么确认Indirect Symbols中的第几个呢?
Indirect Symbolsdata对应值(十六进制)这里NSLog101,这个代表着NSLog在总的符号表(Symbols)中的角标:



在这里我们可以看到NSLogString Table中偏移为0x98(十六进制)。


通过偏移值计算得到0xCC38就确认到了_NSLog(长度+首地址)。

这里通过.隔开,函数名前面有_

这样我们就从Lazy Symbols -> Indirect Symbols -> Symbols - > String Table 通过符号找到了字符串。那么fishhook的过程就是这么处理的,通过遍历所有符号和要hook的数组中的字符串做对比。

fishhook中有一张图说明这个关系:




这里是通过符号查找close字符串。

  1. Lazy Symbol Pointer Tableclose index1061
  2. Indirect Symbol Table 1061 对应的角标为0X00003fd7(十进制16343)。
  3. Symbol Table找角标16343对应的字符串表中的偏移值70026
  4. String Table中找首地址+偏移值(70026)就找到了close
    字符串。

实际的原理还是通过传递的字符串找到符号进行替换:通过字符串找符号过程:

  1. String Table中找到字符串计算偏移值。
  2. 通过偏移值在Symbols中找到角标。
  3. 通过角标在Indirect Symbols中找到对应的符号。这个时候就能拿到这个符号的index了。
  4. 通过找到的indexLazy Symbols中找到对应index的符号。

2.5 去掉符号&恢复符号

符号本身在MachO文件中,占用包体积大小 ,在我们分析别人的App时符号是去掉的。

2.5.1 去除符号

符号基本分为:全局符号、间接符号(导出&导入)、本地符号。
对于App来说会去掉所有符号(间接符号除外)。对于动态库来说要保留全局符号(外部要调用)。

去掉符号在Build setting中设置:




  • Deployment Postprocessing:设置为YES则在编译阶段去符号,否则在打包阶段去符号。
  • Strip StyleAll Symbols去掉所有符号(间接除外),Non-Global Symbols去掉除全局符号外的符号。Debugging Symbols去掉调试符号。

设置Deployment PostprocessingYESStrip StyleAll Symbols。编译查看多了一个.bcsymbolmap文件,这个文件就是bitcode


这个时候的MachO文件中Symbols就只剩下间接符号表中的符号了:


其中
value为函数的实现地址(imp)。间接符号不会找到符号表中地址执行,是找Lazy Symbol Table中的地址。

代码中打断点就断不住了:




先计算出偏移值,下次直接ASLR+偏移值直接断点。这个也就是动态调试常用的方法。


2.5.2 恢复符号

前面动态调试下断点比较麻烦,如果能恢复符号的话就方便很多了。
在上面的例子中去掉所有符号后Symbol Table中只有间接符号了。虽然符号表中没有了,但是类列表和方法列表中依然存在。

这也就为我们提供了创建Symbol Table的机会。
可以通过restore-symbol工具恢复符号(只能恢复oc的,runtime机制导致):./restore-symbol 原始Macho文件 -o 恢复后文件

./restore-symbol FishHookDemo -o recoverDemo



这个时候就可以重签名后进行动态调试了。

2.6 fishhook源码解析

rebind_symbols
rebind_symbols的实现:

//第一次是拿dyld的回调,之后是手动拿到所有image去调用。这里因为没有指定image所以需要拿到所有的。
int rebind_symbols(struct rebinding rebindings[], size_t rebindings_nel) {
//prepend_rebindings的函数会将整个 rebindings 数组添加到 _rebindings_head 这个链表的头部
//Fishhook采用链表的方式来存储每一次调用rebind_symbols传入的参数,每次调用,就会在链表的头部插入一个节点,链表的头部是:_rebindings_head
int retval = prepend_rebindings(&_rebindings_head, rebindings, rebindings_nel);
//根据上面的prepend_rebinding来做判断,如果小于0的话,直接返回一个错误码回去
if (retval < 0) {
return retval;
}
//根据_rebindings_head->next是否为空判断是不是第一次调用。
if (!_rebindings_head->next) {
//第一次调用的话,调用_dyld_register_func_for_add_image注册监听方法.
//已经被dyld加载的image会立刻进入回调。之后的image会在dyld装载的时候触发回调。这里相当于注册了一个回调到 _rebind_symbols_for_image 函数。
_dyld_register_func_for_add_image(_rebind_symbols_for_image);
} else {
//不是第一次调用,遍历已经加载的image,进行的hook
uint32_t c = _dyld_image_count();//这个相当于 image list count
for (uint32_t i = 0; i < c; i++) {
//遍历重新绑定image header aslr
_rebind_symbols_for_image(_dyld_get_image_header(i), _dyld_get_image_vmaddr_slide(i));
}
}
return retval;
}
  • 首先通过prepend_rebindings函数生成链表,存放所有要Hook的函数。
  • 根据_rebindings_head->next是否为空判断是不是第一次调用,第一次调用走系统的回调,第二次则自己获取所有的image list进行遍历。
  • 最后都会走_rebind_symbols_for_image函数。

  • _rebind_symbols_for_image

    //两个参数 header  和 ASLR
    static void _rebind_symbols_for_image(const struct mach_header *header,
    intptr_t slide) {
    //_rebindings_head 参数是要交换的数据,head的头
    rebind_symbols_for_image(_rebindings_head, header, slide);
    }

    这里直接调用了rebind_symbols_for_image,传递了head链表地址。

    rebind_symbols_image

    int rebind_symbols_image(void *header,
    intptr_t slide,
    struct rebinding rebindings[],
    size_t rebindings_nel) {
    struct rebindings_entry *rebindings_head = NULL;
    int retval = prepend_rebindings(&rebindings_head, rebindings, rebindings_nel);
    //如果指定image就直接调用了 rebind_symbols_for_image,没有遍历了。
    rebind_symbols_for_image(rebindings_head, (const struct mach_header *) header, slide);
    if (rebindings_head) {
    free(rebindings_head->rebindings);
    }
    free(rebindings_head);
    return retval;
    }

    底层和rebind_symbols都调用到了rebind_symbols_for_image,由于给定了image所以不需要循环遍历。

    rebind_symbols_for_image

    //回调的最终就是这个函数! 三个参数:要交换的数组  、 image的头 、 ASLR的偏移
    static void rebind_symbols_for_image(struct rebindings_entry *rebindings,
    const struct mach_header *header,
    intptr_t slide) {

    /*dladdr() 可确定指定的address 是否位于构成进程的进址空间的其中一个加载模块(可执行库或共享库)内,如果某个地址位于在其上面映射加载模块的基址和为该加载模块映射的最高虚拟地址之间(包括两端),则认为该地址在加载模块的范围内。如果某个加载模块符合这个条件,则会搜索其动态符号表,以查找与指定的address 最接近的符号。最接近的符号是指其值等于,或最为接近但小于指定的address 的符号。
    */

    /*
    如果指定的address 不在其中一个加载模块的范围内,则返回0 ;且不修改Dl_info 结构的内容。否则,将返回一个非零值,同时设置Dl_info 结构的字段。
    如果在包含address 的加载模块内,找不到其值小于或等于address 的符号,则dli_sname 、dli_saddr 和dli_size字段将设置为0 ; dli_bind 字段设置为STB_LOCAL , dli_type 字段设置为STT_NOTYPE 。
    */


    // typedef struct dl_info {
    // const char *dli_fname; //image 镜像路径
    // void *dli_fbase; //镜像基地址
    // const char *dli_sname; //函数名字
    // void *dli_saddr; //函数地址
    // } Dl_info;

    Dl_info info;//拿到image的信息
    //dladdr函数就是在程序里面找header
    if (dladdr(header, &info) == 0) {
    return;
    }
    //准备从MachO里面去找!
    segment_command_t *cur_seg_cmd;//临时变量
    //这里与MachOView中看到的对应
    segment_command_t *linkedit_segment = NULL;//SEG_LINKEDIT
    struct symtab_command* symtab_cmd = NULL;//LC_SYMTAB 符号表地址
    struct dysymtab_command* dysymtab_cmd = NULL;//LC_DYSYMTAB 动态符号表地址
    //cur为了跳过header的大小,找loadCommands cur = 首地址 + mach_header大小
    uintptr_t cur = (uintptr_t)header + sizeof(mach_header_t);
    //循环load commands找对应的 SEG_LINKEDIT LC_SYMTAB LC_DYSYMTAB
    for (uint i = 0; i < header->ncmds; i++, cur += cur_seg_cmd->cmdsize) {
    cur_seg_cmd = (segment_command_t *)cur;
    //这里`SEG_LINKEDIT`获取和`LC_SYMTAB`与`LC_DYSYMTAB`不同是因为在`MachO`中分别对应`LC_SEGMENT_64(__LINKEDIT)`、`LC_SYMTAB`、`LC_DYSYMTAB`
    if (cur_seg_cmd->cmd == LC_SEGMENT_ARCH_DEPENDENT) {
    if (strcmp(cur_seg_cmd->segname, SEG_LINKEDIT) == 0) {
    linkedit_segment = cur_seg_cmd;
    }
    } else if (cur_seg_cmd->cmd == LC_SYMTAB) {
    symtab_cmd = (struct symtab_command*)cur_seg_cmd;
    } else if (cur_seg_cmd->cmd == LC_DYSYMTAB) {
    dysymtab_cmd = (struct dysymtab_command*)cur_seg_cmd;
    }
    }
    //有任何一项为空就直接返回,nindirectsyms表示间接符号表中符号数量,没有则直接返回
    if (!symtab_cmd || !dysymtab_cmd || !linkedit_segment ||
    !dysymtab_cmd->nindirectsyms) {
    return;
    }

    // Find base symbol/string table addresses
    //符号表和字符串表都属于data段中的linkedit,所以以linkedit基址+偏移量去获取地址(这里的偏移量不是整个macho的偏移量,是相对基址的偏移量)
    //链接时程序的基址 = __LINKEDIT.VM_Address -__LINKEDIT.File_Offset + silde的改变值
    uintptr_t linkedit_base = (uintptr_t)slide + linkedit_segment->vmaddr - linkedit_segment->fileoff;
    //printf("地址:%p\n",linkedit_base);
    //符号表的地址 = 基址 + 符号表偏移量
    nlist_t *symtab = (nlist_t *)(linkedit_base + symtab_cmd->symoff);
    //字符串表的地址 = 基址 + 字符串表偏移量
    char *strtab = (char *)(linkedit_base + symtab_cmd->stroff);

    // Get indirect symbol table (array of uint32_t indices into symbol table)
    //动态(间接)符号表地址 = 基址 + 动态符号表偏移量
    uint32_t *indirect_symtab = (uint32_t *)(linkedit_base + dysymtab_cmd->indirectsymoff);

    cur = (uintptr_t)header + sizeof(mach_header_t);
    for (uint i = 0; i < header->ncmds; i++, cur += cur_seg_cmd->cmdsize) {
    cur_seg_cmd = (segment_command_t *)cur;
    if (cur_seg_cmd->cmd == LC_SEGMENT_ARCH_DEPENDENT) {
    //寻找到load command 中的data【LC_SEGEMNT_64(__DATA)】,相当于拿到data段的首地址
    if (strcmp(cur_seg_cmd->segname, SEG_DATA) != 0 &&
    strcmp(cur_seg_cmd->segname, SEG_DATA_CONST) != 0) {
    continue;
    }

    for (uint j = 0; j < cur_seg_cmd->nsects; j++) {
    section_t *sect =
    (section_t *)(cur + sizeof(segment_command_t)) + j;
    //找懒加载表(lazy symbol table)
    if ((sect->flags & SECTION_TYPE) == S_LAZY_SYMBOL_POINTERS) {
    //找到直接调用函数 perform_rebinding_with_section,这里4张表就都已经找到了。传入要hook的数组、ASLR、以及4张表
    perform_rebinding_with_section(rebindings, sect, slide, symtab, strtab, indirect_symtab);
    }
    //非懒加载表(Non-Lazy symbol table)
    if ((sect->flags & SECTION_TYPE) == S_NON_LAZY_SYMBOL_POINTERS) {
    perform_rebinding_with_section(rebindings, sect, slide, symtab, strtab, indirect_symtab);
    }
    }
    }
    }
    }
    • 找到SEG_LINKEDITLC_SYMTABLC_DYSYMTABload commans

    SEG_LINKEDIT获取和LC_SYMTABLC_DYSYMTAB不同是因为在Load Commands中本来就不同,我们解析其它字段也要做类似操作
    • 根据linkedit和偏移值分别找到符号表的地址字符串表的地址以及间接符号表地址
    • 遍历load commandsdata段找到懒加载符号表非懒加载符号表
    • 找到表的同时就直接调用perform_rebinding_with_section进行hook替换函数符号。

    perform_rebinding_with_section

    //rebindings:要hook的函数链表,可以理解为数组
    //section:懒加载/非懒加载符号表地址
    //slide:ASLR
    //symtab:符号表地址
    //strtab:字符串标地址
    //indirect_symtab:动态(间接)符号表地址
    static void perform_rebinding_with_section(struct rebindings_entry *rebindings,
    section_t *section,
    intptr_t slide,
    nlist_t *symtab,
    char *strtab,
    uint32_t *indirect_symtab) {
    //nl_symbol_ptr和la_symbol_ptrsection中的reserved1字段指明对应的indirect symbol table起始的index。也就是第几个这里是和间接符号表中相对应的
    //这里就拿到了index
    uint32_t *indirect_symbol_indices = indirect_symtab + section->reserved1;
    //slide+section->addr 就是符号对应的存放函数实现的数组也就是我相应的__nl_symbol_ptr和__la_symbol_ptr相应的函数指针都在这里面了,所以可以去寻找到函数的地址。
    //indirect_symbol_bindings中是数组,数组中是函数指针。相当于lazy和non-lazy中的data
    void **indirect_symbol_bindings = (void **)((uintptr_t)slide + section->addr);
    //遍历section里面的每一个符号(懒加载/非懒加载)
    for (uint i = 0; i < section->size / sizeof(void *); i++) {
    //找到符号在Indrect Symbol Table表中的值
    //读取indirect table中的数据
    uint32_t symtab_index = indirect_symbol_indices[i];
    if (symtab_index == INDIRECT_SYMBOL_ABS || symtab_index == INDIRECT_SYMBOL_LOCAL ||
    symtab_index == (INDIRECT_SYMBOL_LOCAL | INDIRECT_SYMBOL_ABS)) {
    continue;
    }
    //以symtab_index作为下标,访问symbol table,拿到string table 的偏移值
    uint32_t strtab_offset = symtab[symtab_index].n_un.n_strx;
    //获取到symbol_name 首地址 + 偏移值。(char* 字符的地址)
    char *symbol_name = strtab + strtab_offset;
    //判断是否函数的名称是否有两个字符,因为函数前面有个_,所以方法的名称最少要1个
    bool symbol_name_longer_than_1 = symbol_name[0] && symbol_name[1];
    //遍历最初的链表,来判断名字进行hook
    struct rebindings_entry *cur = rebindings;
    while (cur) {
    for (uint j = 0; j < cur->rebindings_nel; j++) {
    //这里if的条件就是判断从symbol_name[1]两个函数的名字是否都是一致的,以及判断字符长度是否大于1
    if (symbol_name_longer_than_1 &&
    strcmp(&symbol_name[1], cur->rebindings[j].name) == 0) {
    //判断replaced的地址不为NULL 要替换的自己实现的方法和rebindings[j].replacement的方法不一致。
    if (cur->rebindings[j].replaced != NULL &&
    indirect_symbol_bindings[i] != cur->rebindings[j].replacement) {
    //让rebindings[j].replaced保存indirect_symbol_bindings[i]的函数地址,相当于将原函数地址给到你定义的指针的指针。
    *(cur->rebindings[j].replaced) = indirect_symbol_bindings[i];
    }
    //替换内容为自己自定义函数地址,这里就相当于替换了内存中的地址,下次桩直接找到lazy/non-lazy表的时候直接就走这个替换的地址了。
    indirect_symbol_bindings[i] = cur->rebindings[j].replacement;
    //替换完成跳转外层循环,到(懒加载/非懒加载)数组的下一个数据。
    goto symbol_loop;
    }
    }
    //没有找到就找自己要替换的函数数组的下一个函数。
    cur = cur->next;
    }
    symbol_loop:;
    }
    }
    • 首先通过懒加载/非懒加载符号表和间接符号表找到所有的index
    • 将懒加载/非懒加载符号表的data放入indirect_symbol_bindings数组中。
    indirect_symbol_bindings就是存放lazynon-lazy表中的data数组:
    • 遍历懒加载/非懒加载符号表。
      • 读取indirect_symbol_indices找到符号在Indrect Symbol Table表中的值放入symtab_index
      • symtab_index作为下标,访问symbol table,拿到string table的偏移值。
      • 根据 strtab_offset偏移值获取字符地址symbol_name,也就相当于字符名称。
      • 循环遍历rebindings也就是链表(自定义的Hook数据)
      • 判断&symbol_name[1]rebindings[j].name两个函数的名字是否都是一致的,以及判断字符长度是否大于1
      • 相同则先保存原地址到自定义函数指针(如果replaced传值的话,没有传则不保存)。并且用要Hook的目标函数replacement替换indirect_symbol_bindings,这里就完成了Hook
    • reserved1确认了懒加载和非懒加载符号在间接符号表中的index值。

    疑问点:懒加载和非懒加载怎么和间接符号表index对应的呢?
    直接Hook dyld_stub_binder以及NSLog看下index对应的值:




    在间接符号表中非懒加载符号从20开始供两个,懒加载从22开始,这也就对应上了。这也就验证了懒加载和非懒加载符号都在间接符号表中能对应上。

    总结


    作者:HotPotCat
    链接:https://www.jianshu.com/p/ca52a45b3a2f



    作者:HotPotCat
    链接:https://www.jianshu.com/p/ca52a45b3a2f

    收起阅读 »

    回顾 | Jetpack WindowManager 更新

    在今年年初,我们发布了 Jetpack WindowManager 库 alpha02 版本,这是一个较为重大的版本更新,并且包含部分已弃用的 API (目前已经发布到 1.0.0-alpha09 版),本文将为您回顾这次版本更新的内容。 Jetpack W...
    继续阅读 »

    在今年年初,我们发布了 Jetpack WindowManager 库 alpha02 版本,这是一个较为重大的版本更新,并且包含部分已弃用的 API (目前已经发布到 1.0.0-alpha09 版),本文将为您回顾这次版本更新的内容。


    Jetpack WindowManager 库可帮助您构建能够感知折叠和铰链等新设备功能的应用,使用以前不存在的新功能。在开发 Jetpack WindowManager 库时,我们结合了开发者的反馈意见,并且在 Alpha 版本中持续迭代 API,以提供一个更干净完整的 API 界面。我们一直在关注 WindowManager 空间中的不同领域以提供更多的功能,我们引入了 WindowMetrics,以便您可以在 Android 4.1 (API 级别 16) 及以上版本使用这些在 Android 11 加入的新 API


    首版发布后,我们用了大量时间来分析开发者反馈,并在 alpha02 版本中进行了大量的更新,接下来我们来看在 alpha02 版本中更新的具体内容!


    新建一个 WindowManager


    Alpha02 版本提供了一个简单的构造函数,这个构造函数只有一个参数,参数指向一个可见实体 (比如当前显示的 Activity) 的 Context:


    val windowManager = WindowManager(context: Context)

    原有的构造函数 仍可使用,但已被标记为废弃:


    @Deprecated
    val windowManager = WindowManager(context: Context, windowBackend: WindowBackend?)

    当您想在一个常见的设备或模拟器上使用一个自定义的 WindowBackend 模拟一个可折叠设备时,可使用原有的构造函数进行测试。这个 样例工程 中的实现可以供您参考。


    在 alpha02 版本,您仍可给参数 WindowBackend 传参为 null,我们计划在未来的版本中将 WindowBackend 设置为必填参数,移除 deprecation 标志,以推动此接口在测试时使用。


    添加 DisplayFeature 弃用 DeviceState


    另一个重大变化是弃用了 DeviceState 类,同时也弃用了使用它通知您应用的回调。之所以这样做,是因为我们希望提供更加通用的 API,这些通用的 API 允许系统向您的应用返回所有可用的 DisplayFeature 实例,而不是定义全局的设备状态。我们在 alpha06 的版本中已经将 DeviceState 从公共 API 中移除,请改用 FoldingFeature。


    alpha02 版本引入了带有更新了回调协议的新 DisplayFeature 类,以在 DisplayFeature 更改时通知您的应用。您可以注册、反注册回调来使用这些方法:


    registerLayoutChangeCallback(@NonNull Executor executor, @NonNull Consumer<WindowLayoutInfo> callback)

    unregisterLayoutChangeCallback(@NonNull Consumer<WindowLayoutInfo> callback)

    WindowLayoutInfo 包含了位于 window 内的 DisplayFeature 实例列表。


    FoldingFeature 类实现了 DisplayFeature 接口,其中包含了有关下列类型功能的信息:


    TYPE_FOLD(折叠类型)

    TYPE_HINGE(铰链类型)

    设备可能的折叠状态如下:


    △ DisplayFeature 可能的状态: 完全展开、半开、翻转


    △ DisplayFeature 可能的状态: 完全展开、半开、翻转


    需要注意的是这里没有与 DeviceState 中 POSTURE_UNKNOWN 和 POSTURE_CLOSED 姿态对应的状态。


    要获取最新的状态信息,您可以使用已注册回调返回的 FoldingFeature 信息:


    class LayoutStateChangeCallback : Consumer<WindowLayoutInfo> {
    override fun accept(newLayoutInfo: WindowLayoutInfo) {
    // 检查 newLayoutInfo. getDisplayFeatures() 的返回值,
    // 看它是否为 FoldingFeature 实例,并获取其中的信息。
    }
    }

    如何使用这些信息,请参阅: github.com/android/use…


    更好的回调注册


    上述示例代码的回调 API 也更加健壮了。在之前版本中,如果应用在 window 可用之前注册回调,将会抛出异常。


    在 aplha02 版本中我们修改了上述的行为。您可在对您应用设计有用的任何时候,注册这些回调,库会在 window 可用时发送初始 WindowLayoutInfo。


    R8 规则


    我们在库中添加了 R8 的 "keep" 规则,以保留那些因为内部模块的组织架构而可能被删除的方法或类。这些规则会自动合并到应用最终的 R8 规则中,这样可以防止应用出现如 alpha01 版本上的崩溃。


    WindowMetrics


    由于历史的命名习惯和各种可能的 Window Manager 状态,在 Android 上获取当前 window 的尺寸信息比较困难。Android 11 中一些被废弃的方法 (例如 Display#getSize 和 Display#getMetrics) 和在 window 尺寸新的 API 的使用,都凸显了可折叠设备从全屏到多窗口和自适应窗口这一上升的趋势。为了简化这一过渡过程,我们在 Android 11 中增加了 WindowMetrics API


    在第一次布局完成之前,WindowMetrics 可以让您轻松获取当前 window 状态信息,和系统当前状态下最大 Window 尺寸信息。例如像 Surface Duo 这样的设备,设备会有一个默认的配置决定应用从哪一个屏幕启动,但是也可以跨过设备的铰链扩展到两块屏幕上。在默认的状态,'getMaximumWindowMetrics' 方法返回应用当前所在屏幕的边界信息。当应用被移动到处于跨屏状态,'getMaximumWindowMetrics' 方法返回反映新状态的边界信息。这些信息最早在 onCreate 期间就会提供,您的 Activity 可以利用这些信息进行计算或者尽早做出决定,以便在第一时间选择正确的布局。


    API 返回的结果不包括系统 inset 信息,比如状态栏或导航栏,这是由于目前支持的所有 Android 版本中,在第一次布局完成之前,这些值对应的区域都不可用。关于使用 ViewCompat 去获取系统可用 inset 信息,Chris Banes 的文章 - 处理视觉冲突|手势导航 (二) 是非常好的资源。API 返回的边界信息也不会对布局填充时可能发生变化的布局参数作出响应。


    要访问这些 API,您需要像上文说明的那样先获取一个 WindowManager 对象:


    val windowManager = WindowManager(context: Context)

    现在您就可以访问 WindowMetrics API,并可轻松获取当前 window 的尺寸以及最大尺寸信息。


    windowManager.currentWindowMetrics

    windowManager.maximumWindowMetrics

    例如,如果您的应用在手机和平板电脑上的布局或导航模式截然不同,那么可以在视图填充之前依赖此信息对布局做出选择。如果您认为用户会对布局的明显变化感到疑惑,您可以忽略当前 window 尺寸信息的变化,选择部分信息作为常量。在选择填充哪些之前,您可以使用 window 最大尺寸信息。


    尽管 Android 11 平台已经包含了在 onCreate 期间获取 inset 信息的 API,但是我们还没有将这个 API 添加到 WindowManager 库中,这是因为我们想了解这些功能中哪些对开发者有用。您可以积极反馈,以便我们了解在您第一次布局之前,需要知道哪些能够使编写布局更为简便的值或抽象。


    我们希望这些可以用在 Android 低版本上的 API 能够帮助您构建响应 window 尺寸变化的应用,同时帮助您替换上文提到的已废弃 API。


    联系我们


    我们非常希望得到您对这些 API 的反馈,尤其是您认为缺少的那些,或者可让您开发变得更轻松的那些反馈。有一些使用场景我们可能没有考虑到,所以希望您在 public tracker 上向我们提交 bug 或功能需求。


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

    Android so文件的加载原理

    so
    先说说so的编译类型 Android只支持3种cpu架构分为:arm,mips,x86,目前用的最多的是arm体系cpu,x86和mips体系的很少用到了。 arm体系中,又分32位和64位: armeabi/armeabi-v7a:这个架构是arm类型的,主...
    继续阅读 »



    1. 先说说so的编译类型
      Android只支持3种cpu架构分为:arm,mips,x86,目前用的最多的是arm体系cpu,x86和mips体系的很少用到了。
      arm体系中,又分32位和64位:

      armeabi/armeabi-v7a:这个架构是arm类型的,主要用于Android 4.0之后的,cpu是32位的,其中armeabi是相当老旧的一个版本, 缺少对浮点数的硬件支持,基本已经淘汰,可以不用考虑了。

      arm64-v8a:这个架构是arm类型的,主要是用于Android 5.0之后,cpu是64位的。平时项目中引入第三方的so文件时,第三方会根据cpu的架构编译成不同类型的so文件,项目引入这些so文件时,会将这些文件分别放入jniLibs目录下的arm64-v8a,armeabi-v7a等这些目录下,其实对于arm体系的so文件,没这个必要,因为arm体系是向下兼容的,比如32位的so文件是可以在64位的系统上运行的。Android上每启动一个app都会创建一个虚拟机,Android 64位的系统加载32位的so文件时,会创建一个64位的虚拟机的同时,还会创建一个32位的虚拟机,这样就能兼容32位的app应用了。鉴于兼容的原理,在app中,可以只保留armeabi-v7a版本的so文件就足够了。64位的操作系统会在32位的虚拟机上加载这个它。这样就极大的精简了app打包后的体积。虽然这样可以精简apk的体积,但是,在64位平台上运行32位版本的ART和Android组件,将丢失专为64位优化过的性能(ART,webview,media等等)所以,更好的方法是,为相应的abi打对应的apk包,这样就可以为不同abi版本生成不同的apk包。具体在build.gradle中的配置如下:



    android {

    ...

    splits {
    abi {
    enable true
    reset()
    include 'x86', 'x86_64', 'armeabi-v7a', 'arm64-v8a' //select ABIs to build APKs for
    universalApk true //generate an additional APK that contains all the ABIs
    }
    }

    // map for the version code
    project.ext.versionCodes = ['armeabi': 1, 'armeabi-v7a': 2, 'arm64-v8a': 3, 'mips': 5, 'mips64': 6, 'x86': 8, 'x86_64': 9]

    android.applicationVariants.all { variant ->
    // assign different version code for each output
    variant.outputs.each { output ->
    output.versionCodeOverride =
    project.ext.versionCodes.get(output.getFilter(com.android.build.OutputFile.ABI), 0) * 1000000 + android.defaultConfig.versionCode
    }
    }
    }


    1. so的加载流程
      可以通过以下命令来查看手机的cpu型号(以OPPO R7手机为例),在AS中的Terminal窗口中,输入如下命令


      C:\Users\xg\Desktop\AndroidSkill>adb shell
    shell@hwmt7:/ $ getprop ro.product.cpu.abilist
    arm64-v8a,armeabi-v7a,armeabi

    手机支持的种类存在一个abiList 的集合中,有个前后顺序,比如我的手机,支持三种类型, abiList 的集合中就有三个元素,第一个元素是arm64-v8a ,第二个元素是armeabi-v7a,第三个元素是armeabi 。按照这个先后顺序,我们遍历jniLib 目录,如果这个目录下有arm64-v8a子目录并且里面有so文件,那么接下来将加载arm64-v8a下的所有so文件,就不再去看其他子目录(比如armeabi-v7a)了,以此类推。在我的手机上,如果arm64-v8a 下有a.so,armeabi-v7a下有a.so和b.so那么我的手机只会加载arm64-v8a下的a.so,而永远不会加载到b.so,这时候就会抛出找不到b.so的异常,这是由Android 中的so加载算法导致的。因此,为了节省apk的体积,我们只能保存一份so文件,那就是armeabi-v7a下的so文件。32位的arm手机,肯定能加载到armeabi-v7a下的so文件。64位的arm手机,想要加载32位的so文件,千万不要在arm64 -v8a目录下放置任何so文件。把so文件都放在armeabi-v7a目录下就可以加载到了。


    下面举个例子来说明上面so的加载过程:
    32位的arm手机,如果项目的jniLibs目录下存在如下的so文件,
    jniLibs/arm64-v8a/libmsc.so
    jniLibs/armeabi-v7a/libmsc.so
    当要加载msc这个so文件时,就会直接到areabi-v7a目录下找。找到就加载, 找不到就报 couldn’t find “libmsc.so”
    如果armeabi-v7a这个目录都不存在时,也报 couldn’t find “libmsc.so”


    64位的arm手机,如果项目的jniLibs目录下存在如下的so文件,
    jniLibs/arm64-v8a/libmsc.so
    jniLibs/armeabi-v7a/libmsc.so
    当要加载msc.so文件时,就先到arm64-v8a目录下找,找到后,就不会去其他目录下找了。
    如果arm64-v8a目录下未找到,则到armeabi-v7a目录下找,找到就使用,找不到就去其他目录找,依次类推,如果都找到不到就报 couldn’t find “libmsc.so”。
    这个查找过程可以看下图:
    在这里插入图片描述



    1. so的加载方式
      方式一:System.loadLibrary方法,加载jniLibs目录下的so文件。例如,jniLibs目录下的arm64-v8a目录下有一个libHello.so文件,那么加载这个so文件是:


         System.loadLibray("Hello");//注意,没有lib前缀

    方式二:使用System.load方法,加载任意路径下的so文件,需要传入一个参数,这个参数就是so文件所在的完整路径。这两种方式最终都是调用的底层的dlopen方法加载so文件。但是方式二,由于可以传入so的路径,这样就可以实现动态加载so文件。so的插件化,就是使用的这种方式。动态加载so文件时,有时会出现 dlopen failed:libXXX.so is 32-bit instead of 64 bit 的异常。出现这个异常的原因是,手机的操作系统是64位的,这样加载这个32位的so文件时,会默认使用64位的虚拟机去加载,这样就报了这个异常。解决这个问题的方式,可以先在jniLibs目录下armeabi-v7a目录下,放入一个很简单的32位的libStub.so文件,在动态加载插件的so文件时,先去加载这个jniLibs/armeabi-v7a目录下的libStub.so文件,这样就会创建一个32位的虚拟机,当加载插件的32位的so文件时,就会使用这个32位的虚拟机来加载插件的so文件,这样也就不会报错了。


    注意,每个abi目录下的so文件数量要相同,因为,如果,在arm64-v8a目录下,存在a.so文件,在armeabi-v7a目录下,存在a.so和b.so文件,如果是在64位的arm系统的手机上加载a.so和b.so文件,由于先找a.so文件会先到arm64-v8a目录下找,找到后,后续的其他so文件就会都在这个目录下找了,有arm64-v8a目录下没有b.so文件,这样就会报couldn’t find "b.so"文件异常。所以,要保持每个abi目录下的so文件个数一致。


    关于加载插件中的so文件,是通过先创建加载插件的DexClassLoader,将插件中的so文件的路径传递给DecClassLoader的构造函数的第三个参数,这样,后续使用这个DexClassLoader去加载插件中的类或方法,插件中这些类或者方法中去加载插件的so文件。


    ————————————————
    版权声明:本文为CSDN博主「hujin2017」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
    原文链接:https://blog.csdn.net/hujin2017/article/details/102804883

    收起阅读 »

    探索 Android 消息机制

    1. Android 消息机制概述 Android 消息机制是由 Handler、Looper 和 MessageQueue 三者合作完成的,消息机制可以分为消息机制初始化、消息轮询、消息发送和消息处理 4 个过程来理解,消息机制是基于 Linux 的事...
    继续阅读 »

    1. Android 消息机制概述


    Android 消息机制.png


    Android 消息机制是由 HandlerLooperMessageQueue 三者合作完成的,消息机制可以分为消息机制初始化消息轮询消息发送消息处理 4 个过程来理解,消息机制是基于 Linux 的事件轮询机制 epoll 和用来通知事件的文件描述符 eventfd 来实现的 。


    消息机制初始化过程是从消息轮询器 Looper 的 prepare() 方法开始的,当线程调用 Looper 的 prepare() 方法时,prepare() 方法会调用 Looper 的构造函数创建一个 Looper ,并放到线程私有变量 ThreadLocal 中。Looper 的构造函数中会创建一个消息队列 MessageQueue ,而消息队列的构造方法会调用 nativeInit() JNI 方法初始化 Native 层的消息队列,在 Native 层消息队列的构造方法中,会调用 Native 层 Looper 的构造函数初始化 Native 层的 Looper ,而在 Native 层 Looper 的构造函数中会调用 rebuildEpollLocked() 方法,在 rebuildEpollLocked() 方法中会调用 epoll_create1() 系统调用创建一个 epoll 实例,然后再调用 epoll_ctl() 系统调用给 epoll 实例添加一个唤醒事件文件描述符,到这里消息机制的初始化就完成了。


    epoll 、select 和 poll 都是 Linux 中的一种 I/O 多路复用机制, poll 和 select 在每次调用时,都必须遍历所有被监视的文件描述符,文件描述符列表越大,性能就越差。而 epoll 则把监听注册从监听中分离了出来,这样就不需要每次调用时都遍历文件描述符列表了。创建 epoll 实例时,Linux 会创建一个 evnetpoll 结构体,这个结构体中有 rbrrdlist 两个成员,rbr 是红黑树的根节点,epoll 会用红黑树存储所有需要监控的事件 ,rdlist 则是存放着要通过 epoll_wait() 返回给用户的事件。


    唤醒事件文件描述符是一个 eventfd 对象,是 Linux 中的一个用来通知事件的文件描述符,与 pipe 相比,pipe 只能在进程/线程间使用,而 eventfd 是广播式的通知,可以多对多。eventfd 的结构体 eventfd_ctx 中有 wqhcount 两个成员,wqh 是一个等待队列的头结点,类型为 __wait_queue_head ,是一个自带自旋锁双向链表的节点,而 count 则是一个计数器


    消息轮询过程是从 Looper 的 loop() 方法开始的,当线程调用 Looper 的 loop() 方法后,loop() 方法中会调用 MessageQueuenext() 方法获取下一条要处理的消息,next() 方法中会通过 nativePollOnce() JNI 方法调检查当前消息队列中是否有新的消息要处理,nativePollOnce() 方法会调用 NativeMessageQueuepollOnce() 方法,NativeMessageQueue 的 pollOnce() 方法会调用 Native 层 Looper 的 pollOnce() 方法, Native 层 Looper 的 pollOnce() 方法中会把 timeout 参数传到 epoll_wait() 系统调用中,epoll_wait() 调用后会等待事件的产生,当 MessageQueue 中没有更多消息时,传到 epoll_wait() 中的 timeout 的值就是 -1 ,这时线程会一直被阻塞,直到有新的消息进来,这就是为什么 Looper 的死循环不会导致 CPU 飙高,因为主线程处于阻塞状态。当调用完 nativePollOnce() 方法后,MessageQueue 就会看下当前消息是不是同步屏障,是的话就找出并返回异步消息给 Looper ,不是的话则找出下一条到了发送时间的返回非异步消息。


    消息发送过程一般是从 Handler 的 sendMessage() 方法开始的,当我们调用 Handler 的 sendMessage() 或 sendEmptyMessage() 等方法时,Handler 会调用 MessageQueue 的 enqueueMessage() 方法把消息加入到消息队列中。消息 Message 并不是真正的队列结构,而是链表结构。MessageQueue 的enqueueMessage() 方法首先会判断消息的延时时间是否晚于当前链表中最后一个结点的发送时间,是的话则把该消息作为链表的最后一个结点。然后 enqueueMessage() 方法会判断是否需要唤醒消息轮询线程,是的话则通过 nativeWake() JNI 方法调用 NativeMessageQueue 的 wake() 方法。NativeMessageQueue 的 wake() 方法又会调用 Native 层 Looper 的 wake() 方法,在 Native 层 Looper 的 wake() 方法中,会通过 write() 系统调用写入一个 W 字符到唤醒事件文件描述符中,这时监听这个唤醒事件文件描述符的消息轮询线程就会被唤醒


    消息处理过程也是从 Looper 的 loop() 方法开始的,当 Looper 的 loop() 方法从 MessageQueue 的 next() 中获取到消息时,就会调用 Message 的 targetdispatchMessage() 的方法,Message 的 target 就是发送消息时用的 Handler ,Handler 的 dispatchMessage() 方法首先会判断 Message 是否设置了 callback 回调 ,比如用 post() 方法发送消息时,传入 post() 方法中的 Runnable 就是 Message 的 callback 回调,如果 Message 没有设置 callback ,则 dispatchMessage() 方法会调用 Handler 的 handleMessage() 方法,到这里消息处理过程就结束了。


    另外在使用消息 Message 的时候,建议使用 Message 的 obtain() 方法复用全局消息池中的消息。


    2. 消息机制初始化流程


    消息机制初始化流程就是 Handler、Looper 和 MessageQueue 三者的初始化流程,Handler 的初始化流程比较简单,而 Looper 的初始化流程则是从 prepare() 方法开始的,当 Looper 的 prepare() 方法被调用后,Looper 会创建一个消息队列 MessageQueue ,在 MessageQueue 的构造方法中会调用 nativeInit() JNI 方法初始化 Native 层的消息队列,在 NativeMessageQueue 的构造方法中会创建 Native 层的 Looper 实例,而在 Native 层的 Looper 的构造函数中,则会把唤醒事件的文件描述符监控请求的文件描述符添加到 epoll 的兴趣列表中。


    消息机制初始化流程.png


    1.1 Handler 初始化流程


    Handler 的初始化过程比较简单,这个过程中比较特别的两个点分别是不能在没有调用 Looper.prepare() 的线程创建 Handler以及异步 Handler


    Handler 中有好几个构造函数,其中不传 Looper 的构造函数在高版本的 SDK 中已经被声明为弃用了,也就是我们要创建主线程消息处理器的话,就要把 Looper.getMainLooper() 传到 Handler 的构造函数中。


    Handler 的构造函数有一个比较特别的一个 async 参数,async 为 true 时表示该 Handler 是一个异步消息处理器,使用这个 Handler 发送的消息会是异步消息,但是这个构造函数没有开放给我们使用,是系统组件自己用的。


    HandlerCode.png


    1.2 Looper 初始化流程


    之所以我们能在 Activity 中直接用 Handler 给主线程发消息 ,是因为 ActivityThread 的主函数 main() 中初始化了一个主线程专用的 Looper ,也正是这个 Looper 一直在轮询主线程要处理的消息。


    ActivityThread.png


    Looper 的 prepareMainLooper() 方法会调用 prepare() 方法创建一个新的 Looper , prepare() 是一个公共静态方法,如果我们也要开一个新的线程执行一个任务,这个任务也需要放在死循环中执行并等待消息,而我们又不想浪费 CPU 资源的话,就可以通过 Looper.prepare() 来创建线程的 Looper ,也可以直接使用 Android SDK 中 的 HandlerThread ,HandlerThread 内部也维护了一个 Looper。prepare() 方法会把创建好的 Looper 会放在线程局部变量 ThreadLocal 中。


    prepare() 方法可以传入一个 quitAllowed 参数,这个参数默认为 true ,用于指定是否允许退出,假如 quitAllowed 为 false 的话,那在 MessageQueue 的 quit() 方法被调用时就会抛出一个非法状态异常。


    Looper.png


    Looper 的构造函数中创建了 MessageQueue ,下面来看下 MessageQueue 的初始化流程。


    1.3 MessageQueue 初始化流程


    在 MessageQueue 的构造函数中调用了一个 JNI 方法 nativeInit() ,并且把初始化后的 NativeMessageQueue 的指针保存在 mPtr 中,发送消息的时候要用这个指针来唤醒消息轮询线程。


    MessageQueue.png


    nativeInit() 方法中调用了 NativeMessageQueue 的构造函数,在 NativeMessageQueue 的构造函数中创建了一个新的 Native 层的 Looper ,这个 Looper 跟 Java 层的 Looper 没有任何关系,只是在 Native 层实现了一套类似功能的逻辑。


    NativeMessageQueue 的构造函数中创建完 Looper 后,会通过 setForThread() 方法把它设置给当前线程,这个操作类似于把 Looper 放到 ThreadLocal 中。


    NativeMessageQueue.png


    在 Native 层的 Looper 的构造函数中,创建了一个新的唤醒事件文件描述符(eventfd)并赋值给 mWakeEventFd 变量,这个变量是一个唤醒事件描述符,然后再调用 rebuildEpollLocked() 方法重建 epoll 实例,新的事件文件描述符的初始值为 0 ,标志为 EFD_NONBLOCKEFD_CLOEXEC ,关于什么是文件描述符和这两个标志的作用在后面会讲到。


    NativeLooper.png


    rebuildEpollLocked() 方法的实现如下,关于什么是 epoll 后面会讲到,在 rebuildEpollLocked() 方法的最后会遍历请求列表,这个请求列表中的请求有很多地方会添加,比如输入分发器 InputDispatcherregisterInputChannel() 方法中也会添加一个请求到 Native 层 Looper 的请求列表中。


    rebuildEpollLocked().png


    1.4 Unix/Linux 体系架构


    由于 eventfd 和文件描述符都是 Linux 中的概念,所以下面来看一些 Linux 相关的知识。


    Linux 体系架构.png


    Linux 操作系统的体系架构分为用户态内核态(用户空间和内核空间),内核本质上看是一种软件,控制着计算机的硬件资源,并提供上层应用程序运行的环境。


    而用户态就是上层应用程序的活动空间,应用程序的执行,比如依托于内核提供的资源,包括 CPU 资源、存储资源、I/O 资源等,为了让上层应用能够访问这些资源,内核必须为上层应用提供访问的接口,也就是系统调用


    系统调用是受控的内核入口,借助这一机制,进程可以请求内核以自己的名义去执行某些动作,以 API 的形式,内核提供有一系列服务供程序访问,包括创建进程、执行 I/O 以及为进程间通信创建管道等。


    1.5 文件描述符


    Linux 继承了 UNIX 一切皆文件 的思想,在 Linux 中,所有执行 I/O 操作的系统调用都以文件描述符指代已打开的文件,包括管道(pipe)、FIFO、Socket、终端、设备和普通文件,文件描述符往往是数值很小的非负整数,获取文件描述符一般是通过系统调用 open() ,在参数中指定 I/O 操作目标文件的路径名。


    通常由 shell 启动的进程会继承 3 个已打开的文件描述符:



    • 描述符 0 :标准输入,指代为进程提供输入的文件


    • 描述符 1 :标准输出,指代供进程写入输出的文件


    • 描述符 2 :标准错误,指代进程写入错误消息或异常通告的文件



    文件描述符(File Descriptor) 是 Linux 中的一个索引值,系统在运行时有大量的文件操作,内核为了高效管理已被打开的文件会创建索引,用于指向被打开的文件,这个索引就是文件描述符


    1.6 事件文件描述符 eventfd


    eventfd 可以用于线程或父子进程间通信,内核通过 eventfd 也可以向用户空间发送消息,其核心实现是在内核空间维护一个计数器,向用户空间暴露一个与之关联的匿名文件描述符,不同线程通过读写该文件描述符通知或等待对方,内核则通过该文件描述符通知用户程序。


    在 Linux 中,很多程序都是事件驱动的,也就是通过 select/poll/epoll 等系统调用在一组文件描述符上进行监听,当文件描述符的状态发生变化时,应用程序就调用对应的事件处理函数,有的时候需要的只是一个事件通知,没有对应具体的实体,这时就可以使用 eventfd


    与管道(pipe)相比,管道是半双工的传统 IPC 方式,两个线程就需要两个 pipe 文件,而 eventfd 只要打开一个文件,而文件描述符又是非常宝贵的资源,linux 的默认值也只有 1024 个。eventfd 非常节省内存,可以说就是一个计数器,是自旋锁 + 唤醒队列来实现的,而管道一来一回在用户空间有多达 4 次的复制,内核还要为每个 pipe 至少分配 4K 的虚拟内存页,就算传输的数据长度为 0 也一样。这就是为什么只需要通知机制的时候优先考虑使用 eventfd 。


    eventfd 提供了一种非标准的同步机制,eventfd() 系统调用会创建一个 eventfd 对象,该对象拥有一个相关的由内核维护的 8 字节无符号整数,它返回一个指向该对象的文件描述符,向这个文件描述符中写入一个整数会把该整数加到对象值上,当对象值为 0 时,对该文件描述符的 read() 操作将会被阻塞,如果对象的值不是 0 ,那么 read() 会返回该值,并将对象值重置为 0 。


    struct eventfd_ctx {
    struct kref kref;
    wait_queue_head_t wqh;
    __u64 count;
    unsigned int flags;
    int id;
    };

    eventfd_ctx 结构体是 eventfd 实现的核心,其中 wqhcountflags 的作用如下。


    wqh 是等待队列头,所有阻塞在 eventfd 上的读进程挂在该等待队列上。


    count 是 eventfd 计数器,当用户程序在一个 eventfd 上执行 write 系统调用时,内核会把该值加在计数器上,用户程序执行 read 系统调用后,内核会把该值清 0 ,当计数器为 0 时,内核会把 read 进程挂在等待队列头 wqh 指向的队列上。


    有两种方式可以唤醒等待在 eventfd 上的进程,一个是用户态 write ,另一个是内核态的 eventfd_signal ,也就是 eventfd 不仅可以用于用户进程相互通信,还可以用作内核通知用户进程的手段。


    在一个 eventfd 上执行 write 系统调用,会向 count 加上被写入的值,并唤醒等待队列中输入的元素,内核中的 eventfd_signal 函数也会增加 count 的值并唤醒等待队列中的元素。


    flags 是决定用户 read 后内核的处理方式的标志,取值有EFD_SEMAPHOREEFD_CLOEXECEFD_NONBLOCK三个。


    EFD_SEMAPHORE表示把 eventfd 作为一个信号量来使用。


    EFD_NONBLOCK 表示该文件描述符是非阻塞的,在调用文件描述符的 read() 方法时,有该标志的文件描述符会直接返回 -1 ,在调用文件描述符的 write() 方法时,如果写入的值的和大于 0xFFFFFFFFFFFFFFFE ,则直接返回 -1 ,否则就会一直阻塞直到执行 read() 操作。


    EFD_CLOEXEC 表示子进程执行 exec 时会清理掉父进程的文件描述符。


    3. 事件轮询 epoll


    selectpollepoll都是 I/O 多路复用模型,可以同时监控多个文件描述符,当某个文件描述符就绪,比如读就绪或写就绪时,则立刻通知对应程序进行读或写操作,select/poll/epoll 都是同步 I/O ,也就是读写是阻塞的。


    1. epoll 简介

    epoll 是 Linux 中的事件轮询(event poll)机制,是为了同时监听多个文件描述符的 I/O 读写事件而设计的,epoll API 的优点有能高效检查大量文件描述符支持水平和边缘触发避免复杂的信号处理流程灵活性高四个。


    当检查大量的文件描述符时,epoll 的性能延展性比 select() 和 poll() 高很多


    epoll API 支持水平触发边缘触发,而 select() 和 poll() 只支持水平触发,信号驱动 I/O 则只支持边缘触发。


    epoll 可以避免复杂的信号处理流程,比如信号队列溢出时的处理。


    epoll 灵活性高,可以指定我们想检查的事件类型,比如检查套接字文件描述符的读就绪、写就绪或两者同时指定。


    2. 水平触发与边缘触发

    Linux 中的文件描述符准备就绪的通知有水平触发边缘触发两种模式。


    水平触发通知就是文件描述符上可以非阻塞地执行 I/O 调用,这时就认为它已经就绪。


    边缘触发通知就是文件描述符自上次状态检查以来有了新的 I/O 活动,比如新的输入,这时就要触发通知。


    3. epoll 实例

    epoll API 的核心数据结构称为 epoll 实例,它与一个打开的文件描述符关联,这个文件描述符不是用来做 I/O 操作的,而是内核数据结构的句柄,这些内核数据结构实现了记录兴趣列表维护就绪列表两个目的。


    这些内核数据结构记录了进程中声明过的感兴趣的文件描述符列表,也就是兴趣列表(interest list)


    这些内核数据结构维护了处于 I/O 就绪状态的文件描述符列表,也就是就绪列表(ready list),ready list 中的成员是兴趣列表的子集。


    4 epoll API 的 4 个系统调用

    epoll API 由以下 4 个系统调用组成。


    epoll_create() 创建一个 epoll 实例,返回代表该实例的文件描述符,有一个 size 参数,该参数指定了我们想通过 epoll 实例检查的文件描述符个数。


    epoll_creaet1() 的作用与 epoll_create() 一样,但是去掉了无用的 size 参数,因为 size 参数在 Linux 2.6.8 后就被忽略了,而 epoll_create1() 把 size 参数换成了 flag 标志,该参数目前只支持 EPOLL_CLOEXEC 一个标志。


    epoll_ctl() 操作与 epoll 实例相关联的列表,通过 epoll_ctl() ,我们可以增加新的描述符到列表中,把已有的文件描述符从该列表中移除,以及修改代表文件描述符上事件类型的掩码。


    epoll_wait()用于获取 epoll 实例中处于就绪状态的文件描述符。


    5. epoll_ctl()

    epoll_ctl.png


    epoll_ctl() 用于操作与 epoll 实例相关联的列表,成功返回 0 ,失败返回 -1,的 fd 参数指明了要修改兴趣列表中的哪一个文件描述符的设定,该参数可以是代表管道、FIFO、套接字等,甚至可以是另一个 epoll 实例的文件描述符。


    op 参数用于指定要执行的操作,可以选择的值如下。


    EPOLL_CTL_ADD 表示把描述符添加到 epoll 实例 epfd 的兴趣列表中。


    EPOLL_CTL_MOD 表示修改描述符上设定的事件。


    EPOLL_CTL_DEL 表示把文件描述符从 epfd 的兴趣列表中移除。


    6. epoll_wait()

    epoll_wait.png


    epoll_wait() 方法用于获取 epoll 实例中处于就绪状态的文件描述符,其中参数 timeout 就是 MessageQueue 的 next() 方法中的 nextPollTimeoutMillis ,timeout 参数用于确定 epoll_wait() 的阻塞行为,阻塞行为有如下几种。



    • -1 :调用将一直阻塞,直到兴趣列表中的文件描述符有事件产生,或者直到捕捉到一个信号为止

    • 0 :执行一次非阻塞式检查,看兴趣列表中的文件描述符上产生了哪个事件

    • 大于 0 :调用将阻塞至 timeout 毫秒,直到文件描述符上有事件发生,或者捕捉到一个信号为止


    7. epoll 事件

    下面是几个调用 epoll_ctl() 时可以在 ev.events 中指定的位掩码,以及由 epoll_wait() 返回的 evlist[].events 中的值。



    • EPOLLIN:可读取非高优先级的数据

    • EPOLLPRI:可读取高优先级的数据

    • EPOLLRDHUP:套接字对端关闭

    • EPOLLOUT:普通数据可写

    • EPOLLET:采用边缘触发事件通知

    • EPOLLONESHOT:在完成事件通知后禁用检查

    • EPOLLERR:在错误时发生

    • EPOLLHUP:出现挂断


    4. 消息轮询过程


    1. 消息轮询过程概述

    消息循环过程主要是由 Looper 的 loop() 方法、MessageQueue 的 next() 方法、Native 层 Looper 的 pollOnce() 这三个方法组成。


    消息轮询过程是从 Looper 的 loop() 方法开始的,loop() 方法中有一个死循环,死循环中会调用 MessageQueue 的 next() 方法,获取到消息后,loop() 方法就会调用 Message 的 target 的 dispatchMessage() 方法分发消息,target 其实就是最初发送 Message 的 Handler 。loop() 方法最后会调用 recycleUnchecked() 方法回收处理完的消息。


    在 MessageQueue 的 next() 方法中,首先会调用 nativePollOnce() JNI 方法检查队列中是否有新的消息要处理,没有时线程就会被阻塞。有的话就会尝试找出需要优先执行的异步线程,没有异步消息的话,就会判断消息是否到了要执行的时间,是的话就返回给 Looper 处理,否则重新计算消息的执行时间。


    2. Looper.loop()

    前面讲到了在 ActivityThread 的 main() 函数中会调用 Looper 的 loop() 方法让 Looper 开始轮询消息,loop() 方法中有一个死循环,死循环中会调用 MessageQueue 的 next() 方法获取下一条消息,获取到消息后,loop() 方法就会调用 Message 的 target 的 dispatchMessage() 方法,target 其实就是发送 Message 的 Handler 。最后就会调用 Message 的 recycleUnchecked() 方法回收处理完的消息。


    loop().png


    3. MessageQueue.next()

    在 MessageQueue 的 next() 方法中,首先会调用 nativePollOnce() JNI方法检查队列中是否有新的消息要处理,如果没有,那么当前线程就会在执行到 Native 层的 epoll_wait() 时阻塞。如果有消息,而且消息是同步屏障,那就会找出或等待需要优先执行的异步消息。调用完 nativePollOnce() 后,如果没有异步消息,就会判断当前消息是否到了要执行的时间,是的话则返回消息给 Looper 处理,不是的话就重新计算消息的执行时间(when)。在把消息返回给 Looper 后,下一次执行 nativePollOnce() 的 timeout 参数的值是默认的 0 ,所以进入 next() 方法时,如果没有消息要处理,next() 方法中还可以执行 IdleHandler。在处理完消息后,next() 方法最后会遍历 IdleHandler 数组,逐个调用 IdleHandler 的 queueIdle() 方法。


    下图是 MessageQueue 中找出异步消息后的链表变化。


    MessageQueue 异步消息处理机制.png


    光看 next() 方法的代码的话会觉得有点绕。ViewRootImpl 的 scheduleTraversals() 方法在很多地方都会被调用,当 scheduleTraversals() 方法被调用时,ViewRootImpl 就会调用 MessageQueuepostSyncBarrier() 方法插入一个同步屏障到消息链表中,然后再调用 ChoreographerpostCallback() 方法执行一个 View 遍历任务 ,然后再调用 MessageQueue 的 removeSyncBarrier() 方法移除同步屏障。Choreographer 的 postCallback() 方法会调用 postCallbackDelayedInternal() 方法,postCallbackDelayedInternal() 方法会调用 scheduleFrameLocked() 方法,scheduleFrameLock() 方法会从消息池中获取一条消息,并调用 Message 的 setAsynchronous() 方法把这条消息的标志 flags 设为异步标志 FLAG_ASYNCHRONOUS,然后调用内部类 FrameHandlersendMessageAtFrontOfQueue() 方法把异步消息添加到队列中。


    scheduleFrameLocked().png


    下面是 MessageQueue 的 next() 方法的具体实现代码。


    MessageQueue.next().png


    IdleHandler 可以用来做一些在主线程空闲的时候才做的事情,通过 Looper.myQueue().addIdleHandler() 就能添加一个 IdleHandler 到 MessageQueue 中,比如下面这样。


    addIdleHandler().png


    当 IdleHandler 的 queueIdle() 方法返回 false 时,那 MessageQueue 就会在执行完 queueIdle() 方法后把这个 IdleHandler 从数组中删除,下次不再执行。


    4. Looper.pollOnce()(Native 层)

    继续往下看。在 NativeMessageQueuepollOnce() 方法中,会调用 Native 层的 Looper 的 pollOnce() 方法。


    NativeMessageQueuePollOnce.png


    在 Looper 的 pollOnce() 方法中,首先会遍历了响应列表,如果响应的标识符(identifier)ident 值大于等 0 ,则返回标识符,响应是在 pollInner() 方法中添加的。


    NativeLooperPollOnce.png


    6. Looper.pollInner() (Native 层)

    在 pollInner() 方法中,首先会调用 epoll_wait() 获取可用事件,获取不到就阻塞当前线程,否则遍历可用事件数组 eventItems ,如果遍历到的事件的文件描述符是唤醒事件文件描述符 mWakeEventFd ,则调用 awoken()方法 唤醒当前线程。然后还会遍历响应数组信封数组,这两个数组是在 Native 层消息机制里用的,和我们上层用的关系不大,这里就不展开讲了。


    LooperPollInner.png


    awoken() 方法的实现很简单,只是调用了 read() 方法把 mWakeEventFd 的数据读取出来,mWakeEventFd 是一个 eventfd ,eventfd 的特点就是在读的时候它的 counter 的值会重置为 0


    awoken().png


    4. 消息发送机制



    当我们用 Handler 的 sendMessage()sendEmptyMessage()post() 等方法发送消息时, 最终都会走到 Handler 的 enqueueMessage() 方法。Handler 的 enqueueMessage() 又会调用 MessageQueue 的 enqueueMessage() 方法。


    ![sendMessage()](/Users/oushaoze/Documents/Projects/giteePages/assets/images/Android 消息机制/sendMessage().png)


    MessageQueue 的 enqueueQueue() 方法的实现如下。enqueueMessage() 首先会判断,当没有更多消息消息不是延时消息消息的发送时间早于上一条消息这三个条件其中一个成立时,就会把当前消息作为链表的头节点,然后如果 IdleHandler 都执行完的话,就会调用 nativeWake() JNI 方法唤醒消息轮询线程。


    如果把当前消息作为链表的头结点的条件不成立,就会遍历消息链表,当遍历到最后一个节点,或者发现了一条早于当前消息的发送时间的消息,就会结束遍历,然后把遍历结束的最后一个节点插入到链表中。如果在遍历链表的过程中发现了一条异步消息,就不会再调用 nativeWake() JNI 方法唤醒消息轮询线程。


    ![enqueueMessage()](/Users/oushaoze/Documents/Projects/giteePages/assets/images/Android 消息机制/enqueueMessage().png)


    nativeWake() 的实现如下,只是简单调用了 Native 层 Looper 的 wake() 方法。


    nativeWake().png


    Native 层 Looper 的 wake() 方法的实现如下,TEMP_FAILURE_RETRY 是一个用于重试,能返回 EINTR 的函数 ,write() 方法会向唤醒事件文件描述符写入一个 W 字符,这个操作唤醒被阻塞的消息循环线程 。


    LooperWake.png


    5. 消息处理过程


    消息处理过程是从 Looper 的 loop() 方法开始的,当 Looper 从 MessageQueue 中获取下一条要处理的消息后,就会调用 Message 的 target 的 dispatchMessage() 方法,而 target 其实就是发送消息的 Handler 。


    LooperLoop().png


    设置 Message 的 target 的地方就是在 HandlerenqueueMessage() 方法中。


    HandlerEnqueueMessage.png


    在 Handler 的 dispatchMessage() 方法中,如果消息是通过 post() 方法发送,那么 post() 传入的 Runnable 就会作为 msg 的 callback 字段。如果 callback 字段不为空,dispatchMessage() 方法就会调用 callback 的 run() 方法 ,否则调用 Handler 的 callback 或 Handler 本身的 handleMessage() 方法,Handler 的 callback 指的是在创建 Handler 时传入构造函数的 Callback


    dispatchMessage.png


    6. 消息 Message


    下面我们来看下 Message 的实现。Message 中的 what消息的标识符。而 arg1arg2objdata 分别是可以放在消息中的整型数据Object 类型数据Bundle 类型数据when 则是消息的发送时间


    sPool全局消息池,最多能存放 50 条消息,一般建议用 Message 的 obtain() 方法复用消息池中的消息,而不是自己创建一个新消息。如果在创建完消息后,消息没有被使用,想回收消息占用的内存,可以调用 recycle() 方法回收消息占用的资源。如果消息在 Looper 的 loop() 方法中处理了的话,Looper 就会调用 recycleUnchecked() 方法回收 Message 。


    Message.png


    参考资料




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

    判断是否完全二叉树

    Hello: ? 今天又和小伙伴们见面啦,最近一直做二叉树相关的题目今天再和大家分享一道相关的题目《判断是不是完全二叉树》 判断是否是完全二叉树 查看全部源码:点击查看全部源码 介绍-什么是完全二叉树? 先看如下这一张图: ...
    继续阅读 »

    Hello:


    ? 今天又和小伙伴们见面啦,最近一直做二叉树相关的题目今天再和大家分享一道相关的题目《判断是不是完全二叉树》


    判断是否是完全二叉树


    查看全部源码:点击查看全部源码


    介绍-什么是完全二叉树?


    先看如下这一张图:










    这个一颗二叉树,如何区分该树是不是完全二叉树呢?



    • 当一个节点存在右子节点但是不存在左子节点这颗树视为非完全二叉树

    • 当一个节点的左子节点存在但是右子节点不存在视为完全二叉树

    • 如果没有子节点,那也是要在左侧开始到右侧依次没有子节点才视为完全二叉树,就像上图2中



    而上面第一张图这颗二叉树很明显是一颗非完全二叉树,因为在第三层也就是在节点2它并没有右子节点。在6和4节点中隔开了一个节点(2节点没有右子节点),所以不是完全二叉树


    再看第二张图,这颗树就是一个完全二叉树,虽然在这个颗节点3没有右子节点,但是6 4 5节点之间并没有空缺的子节点,这里就解释了上面说的第三条(如何没有子节点,那也是在左侧开始到右侧依次没有子节点才视为完全二叉树)



    流程


    这道题可以使用按层遍历的方式来解决:



    • 首先准备一个队列,按层遍历使用队列是最好的一种解决方法

    • 首先将头节点加入到队列里面(如果头节点为空,你可以认为它是一个非完全二叉树也可以认为它是完全二叉树)

    • 遍历该队列跳出遍历的条件是直到这个队列为空时

    • 这个时候需要准备一个Bool的变量,如果当一个节点的左子节点或者右子节点不存在时将其置成true

    • 当Bool变量为true并且剩余节点的左或右子节点不为空该树就是非完全二叉树

    • 当一树的左子节点不存在并且右子节点存在,该树也是非完全二叉树


    代码


    树节点


    type TreeNode struct {
    val string
    left *TreeNode
    right *TreeNode
    }

    测试代码


    func main() {
    root := &TreeNode{val: "1"}
    root.left = &TreeNode{val: "2"}
    root.left.left = &TreeNode{val: "4"}
    root.left.right = &TreeNode{val: "10"}
    root.left.left.left = &TreeNode{val: "7"}
    root.right = &TreeNode{val: "3"}
    root.right.left = &TreeNode{val: "5"}
    root.right.right = &TreeNode{val: "6"}
    if IsCompleteBt(root) {
    fmt.Println("是完全二叉树")
    } else {
    fmt.Println("不是完全二叉树")
    }
    }

    判断树是否为完全二叉树代码


    // IsCompleteBt 这里默认根节点为空属于完全二叉树,这个可以自已定义是否为完全二叉树/***/
    func IsCompleteBt(root *TreeNode) bool {
    if root == nil {
    return true
    }

    /**
    * 条件:
    * 1.当一个节点存在右子节点但是不存在左子节点这颗树视为非完全二叉树
    * 2.当一个节点的左子节点存在但是右子节点不存在视为完全二叉树
    */

    var tempNodeQueue []*TreeNode

    tempNodeQueue = append(tempNodeQueue, root)

    var tempNode *TreeNode
    isSingleNode := false
    for len(tempNodeQueue) != 0 {
    tempNode = tempNodeQueue[0]
    tempNodeQueue = tempNodeQueue[1:]

    if (isSingleNode && (tempNode.left != nil || tempNode.right != nil)) || (tempNode.left == nil && tempNode.right != nil){
    return false
    }

    if tempNode.left != nil{
    tempNodeQueue = append(tempNodeQueue,tempNode.left)
    }else{
    isSingleNode = true
    }

    if tempNode.right != nil {
    tempNodeQueue = append(tempNodeQueue, tempNode.right)
    }else{
    isSingleNode = true
    }
    }
    return true
    }

    代码解读


    这段代码里面没有多少好说的,就说下for里面第一个if判断叭


    这里看下上面流程中最后两个条件,当满足最后两个条件的时候才可以判断出来这颗树是否是完全二叉树.



    同样因为实现判断是否是完全二叉树是通过对树的按层遍历来处理的,因为对树的按层遍历通过队列是可以间单的实现的。所以这里使用到了队列



    至于这里为什么要单独创建一个isSingleNode变量:



    • 因为当有一个节点左侧节点或者是右侧的节点没有的时候,在这同一层后面如果还有不为空的节点时,那么这颗树便不是完全二叉树,看下图


    image-20210707163759637


    在这颗树的最后一层绿色涂鸭处是少一个节点的,所以我用了一个变量我标识当前节点(在上图表示节点2)的子节点是不是少一个,如果少了当前节点(在上图表示节点2)的下一个节点(在上图表示节点3)的子节点(在上图表示4和5)如果存在则不是完全二叉树,所以这就是创建了一个isSingleNode变量的作用


    运行结果


    image-20210707150308392


    作者:我与晚风同行
    链接:https://juejin.cn/post/6982109128395063304
    来源:掘金
    著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
    收起阅读 »

    Android 依赖注入 hilt 库的使用

    hilt官网 1-什么是控制反转和依赖注入? IOC(控制反转):全称是 Inverse of Control , 是一种思想.指的是让第3方去控制去创建对象. DI(依赖注入):全称是 Dependency Injection , 对象的创建是通过注入...
    继续阅读 »

    hilt官网


    1-什么是控制反转和依赖注入?


    IOC(控制反转):全称是 Inverse of Control , 是一种思想.指的是让第3方去控制去创建对象.


    DI(依赖注入):全称是 Dependency Injection , 对象的创建是通过注入的方式实现. 是IOC的一种具体实现.


    2- 为啥要用依赖注入?


    在java中我们创建对象都是通过new Object(), 或者是使用反射泛型进行创建, 需要指定泛型, 需要继承或者实现某接口, 不够灵活, 举个例子: 比如在使用MVVM模式进行网络请求时,我们通常在ViewModel定义Repository层,然后把Api传递给Repository层. 最后在ViewModel中发起接口请求


    // 定义网络接口
    interface MainApi {
    default void requestList() {}
    }

    // 仓库抽象类
    abstract class BaseRepo{}

    // 首页仓库
    class MainRepo extends BaseRepo {
    private MainApi api;
    public MainRepo(MainApi api) {
    this.api = api;
    }
    void requestList() {
    // 具体调用接口
    api.requestList();
    }
    }

    // 抽象ViewModel层
    abstract class BaseViewModel {}

    // ViewModel层
    class MainViewModel extends BaseViewModel {
    MainRepo repo = new MainRepo(new MainApi() {});

    void requestList(){
    // 通过repo请求接口
    repo.requestList();
    }
    }

    问题: 每次都要在Model层创建Repository对象和Api对象,这是重复且冗余的.


    解决方案: 通过在ViewModel层和Repo层指定泛型,然后反射创建


    // 定义网络接口
    interface MainApi {
    default void requestList() {
    }
    }

    // 仓库抽象类
    abstract class BaseRepo<Api> {
    private Api api;

    public Api getApi() {
    return api;
    }

    public void setApi(Api api) {
    this.api = api;
    }
    }

    // 首页仓库
    class MainRepo extends BaseRepo<MainApi> {
    void requestList() {
    // 具体调用接口
    getApi().requestList();
    }
    }

    // 抽象ViewModel层
    abstract class BaseViewModel<R extends BaseRepo> {
    private R repo;

    public BaseViewModel() {
    try {
    repo = crateRepoAndApi(this);
    } catch (Exception e) {
    e.printStackTrace();
    }
    }

    public R getRepo() {
    return repo;
    }
    // 反射创建Repo和Api
    public R crateRepoAndApi(BaseViewModel<R> model) throws Exception {
    Type repoType = ((ParameterizedType) model.getClass().getGenericSuperclass()).getActualTypeArguments()[0];
    R repo = (R) repoType.getClass().newInstance();
    Type apiType = ((ParameterizedType) repoType.getClass().getGenericSuperclass()).getActualTypeArguments()[0];
    String apiClassPath = apiType.getClass().toString().replace("class ", "").replace("interface ", "");
    repo.setApi(Class.forName(apiClassPath));
    return repo;
    }
    }

    // ViewModel层
    class MainViewModel extends BaseViewModel<MainRepo> {
    void requestList() {
    // 通过repo请求接口
    getRepo().requestList();
    }
    }

    通过反射可以避免在ViewModel里写new Repo()和new api()的代码. 除了反射还有没有更好的实现方式呢?


    image.png


    3-jectpack 中 hilt库的使用方法


    1-引入包


    1-在项目最外层build.gralde引入
    classpath 'com.google.dagger:hilt-android-gradle-plugin:2.37'

    2-在app模块顶部
    plugin "dagger.hilt.android.plugin"
    plugin "kotlin-kapt"

    3-在app模块内,最外层添加纠正错误类型
    kapt {
    correctErrorTypes true
    }

    4-添加依赖
    implementation 'com.google.dagger:hilt-android:2.37'
    kapt 'com.google.dagger:hilt-compiler:2.37'

    2-必须在Application子类上添加注解@HiltAndroidApp


    @HiltAndroidApp
    class MyApp : Application() {
    override fun onCreate() {
    super.onCreate()
    }
    }

    @HiltAndroidApp 创建一个容器.该容器遵循 Android 的生命周期类,目前支持的类型是: Activity, Fragment, View, Service, BroadcastReceiver @Inject


    使用 @Inject 来告诉 Hilt 如何提供该类的实例,常用于构造方法,非私有字段,方法中。


    Hilt 有关如何提供不同类型的实例信息也称之为绑定


    @Module


    module 是用来提供一些无法用 构造@Inject 的依赖,如第三方库,接口,build 模式的构造等。


    使用 @Module 注解的类,需要使用 @InstallIn 注解指定 module 的范围


    增加了 @Module 注解的类,其实代表的就是一个模块,并通过指定的组件来告诉在那个容器中可以使用绑定安装。


    @InstallIn


    使用 @Module 注入的类,需要使用 @InstallIn 注解指定 module 的范围。


    例如使用 @InstallIn(ActivityComponent::class) 注解的 module 会绑定到 activity 的生命周期上。


    @Provides


    常用于被 @Module 注解标记类的内部方法上。并提供依赖项对象。


    @EntryPoint Hilt 支持最常见的 Android 类 Application、Activity、Fragment、View、Service、BroadcastReceiver 等等,但是您可能需要在Hilt 不支持的类中执行依赖注入,在这种情况下可以使用 @EntryPoint 注解进行创建,Hilt 会提供相应的依赖。

    收起阅读 »

    用了postman,接口测试不用愁了

    Postman是一个功能强大的接口测试工具,不仅可以调用http接口也可以发送https请求,满足日常测试工作的需求。 一、下载 官网:https://www.postman.com 1.选择需要下载的版本号 2.双击下载的安装包,进入到用户登录和...
    继续阅读 »


    Postman是一个功能强大的接口测试工具,不仅可以调用http接口也可以发送https请求,满足日常测试工作的需求。


    一、下载


    官网:https://www.postman.com


    1.选择需要下载的版本号



    2.双击下载的安装包,进入到用户登录和注册的页面


    若个人使用,点击下方Skip and go to the app进入到postman的主页面。


    若企业或团队使用,可以先注册账号加入到团队工作区



    二、postman界面


    1.界面导航说明



    2.请求体选择


    form-data:是post请求当中常用的一种,将表单数据处理为一条消息,以标签为单元,用分隔符分开。既可以单独上传键值对,也可以直接上传文件(当上传字段是文件时,会有Content-Type来说明文件类型,但该文件不会作为历史保存,只能在每次需要发送请求的时候,重新添加文件)


    x-www-form-urlencoded:对应信息头
    application/x-www-form-urlencoded,将所表单中的数据转换成键值对的形式。


    raw:可以上传任意类型的文本,比如text、JavaScript、json、HTML、XML。一般输出为json格式,请求头为Content-Type:application/json 。使用时要用花括号{}将数据包裹起来,才能够正常转化成json格式。


    binary:对应请求头Content-Type:application/octet-stream,只能上传二进制文件且没有键值对,一次只能上传一个文件。



    三、请求方法


    GET:用于从API访问数据用于从API访问数据


    POST:创建新的数据


    PUT:更新数据,全部更新


    PATCH:更新数据,部分更新


    DELETE:删除现有数据



    四、发送一个http请求


    1.get请求


    在URL处填写请求的地址信息,有请求参数的填写在Params中,点击Send,就可以在下面的窗口中查看到响应的json数据。



    2.post请求


    在URL处填写请求的地址信息,选择请求体格式,输入json格式的数据,点击Send发送请求


    在这里插入图片描述


    3.接口响应数据解析


    其中Body和Status是做接口测试的重点,一般来说接口测试都会验证响应体中的数据和响应状态码是否正确。


    Test Results是在编写断言后,可以查看断言的执行结果。


    Time和Size在做性能测试时,可以根据这两个参数来对所测接口的性能做一个简单的判断。


    在这里插入图片描述


    Pretty:在postman中响应结果默认展示的是pretty,数据经过格式化后看起来更加直观,并且显示行号。


    Raw:返回的数据是文本格式,也就是未经处理过的原始数据。


    Preview:一般对返回HTML的页面效果比较明显,如请求百度后返回中可以直接看到页面。



    五、发送https请求设置


    主界面的右上面点击工具标志–选择“Setting”,进入到设置页面。



    在General选项中将SSL certificate verification设为ON,即打开https请求开关。



    在Certificate选项中将CA Certificate开关设置为ON,然后点击Add Certificate,进入到证书添加页面。



    填写请求的地址加端口号,上传CA证书秘钥,设置完成后回到主页面可以发起https请求了。



    六、接口管理(Collection)


    日常工作中接口测试涉及到一个或多个系统中的很多用例需要维护,那么就需要对用例进行分类管理。postman中的Collection可以实现这个功能。


    用例分类管理,方便后期维护


    可以批量执行用例,实现接口自动化测试


    1.创建集合目录


    在Collection选项中,点击“+”号,即可添加一个集合目录,右键可以对目录进行重命名、添加子目录或添加请求等。或者点击集合后面的“…”号,也可查看到更多操作。




    创建好的用例管理效果,如图显示:



    2.批量执行用例


    选中一个Collection,点击右上角的RUN,进入到Collection Runner界面,默认会把所有的用例选中,点击底部的Run Collection按钮执行用例。


    用了postman,接口测试不用愁了



    断言统计:左上角Passed和Failed都为0,表示当前Collection中断言执行的成功数和失败数,如果没有断言默认都为0。


    View Summary:运行结果总览,点击可以看到每个请求的具体断言详细信息。


    Run Again:将Collection中的用例重新运行一次


    New:返回到Runner界面,重新选择用例集合


    Export Results:导出运行结果,默认为json格式


    七、日志查看


    接口测试过程中报错时少不了去查看请求的日志信息,postman中提供了这个功能,可以方便定位问题。


    方法一:点击主菜单View–>Show Postman Console


    方法二:主界面左下角的“Console”按钮



    点击Show Postman Console,进入到日志界面,可以在搜索栏中输入搜索的URL,也可以过滤日志级别



    搜索框:通过输入URL或者请求的关键字进行查找。


    ALL Logs:这里有Log、Info、Warning、Error级别的日志。


    Show raw log:点开可以查看到原始请求的报文信息


    Show timestamps:勾选后展示请求的时间


    Hide network:把请求都隐藏掉,只查看输出日志


    八、断言


    断言是做自动化测试的核心,没有断言,那么只能叫做接口的功能测试,postman中提供的断言功能很强大,内置断言很多也很方便使用。


    点击主界面Tests,在右侧显示框中展示了所有内置断言。按接口响应的组成划分,有状态行、响应头、响应体。


    状态行断言:


    断言状态码:Status code: code is 200


    断言状态信息:Status code:code name has string


    响应头断言:


    断言响应头中包含:Response headers:Content-Type header check


    响应体断言:


    断言响应体中包含XXX字符串:Response body:Contains string


    断言响应体等于XXX字符串:Response body : is equal to a string


    断言响应体(json)中某个键名对应的值:Response body : JSON value check


    响应时间断言:


    断言响应时间:Response time is less than 200ms


    用了postman,接口测试不用愁了


    例如:


    点击右侧的状态码断言,显示在Tests下面的窗口中,点击send发送请求后,在返回的Test Results中可以查看到断言结果。




    以上是整理的postman中常用方法,掌握后对于接口测试来说非常方便,也有利于用例的维护。



    收起阅读 »

    Android集成开发google登录

    这是我参与新手入门的第2篇文章 背景 项目在要Google Play上架,并支持全球下载,加了google登录 一.准备 google登录集成地址 在google登录中创建并配置项目:console.developers.google...
    继续阅读 »

    这是我参与新手入门的第2篇文章


    背景



    项目在要Google Play上架,并支持全球下载,加了google登录



    一.准备


    google登录集成地址



    1. 在google登录中创建并配置项目:console.developers.google.com


    在控制面板选择Credentials → New Project,会提示创建项目名称和组织名称,如下图


    WX20210708-135551.png 2. 创建项目成功后开始创建OAuth client ID image.png 应用类型选择为Android


    image.png 根据系统提示,名称, packageName以及SHA-1值 获取SHA-1值的方式: keytool -keystore path-to-debug-or-production-keystore -list -v


    image.png


    创建成功后会生成一个Client ID 一定要保存好,集成的时候要用



    PS: 如果是通过集成文档创建成功的,会提示下载credentials.json文件,一定要下,不然可能会坑



    image.png


    二.集成开发



    PS: google登录需要运行在Android 4.1及以上且Google Play 服务 15.0.0及以上版本




    • 把刚才下载的credentials.json文件放入app路径的根目录

    • 检查项目顶级build.gradle中包含Maven库


    allprojects {
    repositories {
    google()

    // If you're using a version of Gradle lower than 4.1, you must instead use:
    // maven {
    // url 'https://maven.google.com'
    // }
    }
    }

    在app的build.gradle中引用google play服务


    dependencies {
    implementation 'com.google.android.gms:play-services-auth:19.0.0'
    }

    添加登录



    • 配置 Google Sign-in 和 GoogleSignInClient 对象


    var mGoogleSignInClient: GoogleSignInClient? = null
    private fun initGoogle() {
    val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)
    .requestIdToken(CommonConstants.GOOGLE_CLIENT_ID)
    .requestEmail()
    .build()
    mGoogleSignInClient = GoogleSignIn.getClient(requireActivity(), gso)
    }


    CommonConstants.GOOGLE_CLIENT_ID 为创建项目成功后的Client ID




    • 调起登录


    private fun signIn() {
    val signInIntent: Intent = mGoogleSignInClient?.signInIntent!!
    startActivityForResult(signInIntent, RC_SIGN_IN)
    }


    • onActivityResult中接收消息


        private val RC_SIGN_IN: Int = 3000
    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
    if (requestCode == RC_SIGN_IN) {
    // The Task returned from this call is always completed, no need to attach
    // a listener.
    val task: Task<GoogleSignInAccount> = GoogleSignIn.getSignedInAccountFromIntent(data)
    handleSignInResult(task)
    }
    super.onActivityResult(requestCode, resultCode, data)
    }

    private fun handleSignInResult(completedTask: Task<GoogleSignInAccount>) {
    try {
    val account = completedTask.getResult(ApiException::class.java)
    // Signed in successfully, show authenticated UI.
    Log.e("handleSignInResult", account.toString())
    Log.e("handleSignInResult_displayName", account?.displayName!!)
    Log.e("handleSignInResult_email", account?.email!!)
    Log.e("handleSignInResult_familyName", account?.familyName!!)
    Log.e("handleSignInResult_givenName", account?.givenName!!)
    Log.e("handleSignInResult_id", account?.id!!)
    Log.e("handleSignInResult_idToken", account?.idToken!!)
    Log.e("handleSignInResult_isExpired", account?.isExpired.toString())
    Log.e("handleSignInResult_photoUrl", account?.photoUrl.toString())
    } catch (e: ApiException) {
    // The ApiException status code indicates the detailed failure reason.
    // Please refer to the GoogleSignInStatusCodes class reference for more information.
    Log.e("handleSignInResult", "signInResult:failed code=" + e.statusCode)
    }
    }

    检查现有用户是否登录


     val account = GoogleSignIn.getLastSignedInAccount(activity)
    updateUI(account);

    退出登录


    val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)
    .requestIdToken(CommonConstants.GOOGLE_CLIENT_ID)
    .requestEmail()
    .build()
    val mGoogleSignInClient = GoogleSignIn.getClient(activity, gso)
    mGoogleSignInClient.signOut().addOnCompleteListener(activity) { }

    拿到token信息后发送至自己的服务进行校验,至此google登录完成


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

    政策工具类-谷歌AndroidAppBundle(aab)政策海外发行

    作者 大家好,我是怡寶; 本人于18年毕业于湖南农业大学,于2021年6月加入37手游安卓团队; 目前负责于海外游戏发行安卓开发。 背景 根据Google Play的政策要求,自 2021 年 8 月起,Google Play 将开始要求新应用使用 ...
    继续阅读 »

    作者


    大家好,我是怡寶;


    本人于18年毕业于湖南农业大学,于2021年6月加入37手游安卓团队;


    目前负责于海外游戏发行安卓开发。


    背景


    根据Google Play的政策要求,自 2021 年 8 月起,Google Play 将开始要求新应用使用 Android App Bundle(以下简称aab) 进行发布。该格式将取代 APK 作为标准发布格式。


    想了解更多关于aab的介绍可以直接阅读android官方文档,有详细的说明developer.android.com/guide/app-b…


    juejin1


    正常情况:直接Android Studio上面点击打包或者用Gradle命令直接生成一个aab,交给运营提包到Google Play商店上面去,任务完成,下班~ 。


    存在问题:我没有工程,也没有源码,到我手上的就只有一个apk,走google提供的方案就不行了。


    思 考:我们常做的事情是把apk拿过来,反编译一下,修改修改代码,换换参数,然后重新打成新apk。 apk和aab都是同一个项目生成的,代码资源都一样,那么可不可以相互转化?


    查资料ing.....


    本文向大家介绍如何从apk一步步转化成aab,文末提供本文所使用到的工具&python脚本源码


    需要工具



    apk生成aab


    Android Studio打包可选Android App Bundle(aab),并提供详细教程,本文不再说明。


    解压apk


    通过apktool去解压apk包


    java -jar apktool_2.5.0.jar d test.apk -s -o decode_apk_dir

    解压apk后 decode_apk_dir 目录结构:


    ./decode_apk_dir
    ├── AndroidManifest.xml
    ├── apktool.yml
    ├── assets
    ├── classes2.dex
    ├── classes.dex
    ├── lib
    ├── original
    ├── res
    └── unknown

    编译资源


    编译资源使用aapt2编译生成 *.flat文件集合


    aapt2 compile --dir decode_apk_dir\res -o compiled_resources.zip

    生成compiled_resources.zip文件


    为什么要加.zip的后缀,不和谷歌官方文档一样直接生成compiled_resources文件,或者compiled_resources文件夹。此处为了windows能正常的编译打包,linux和mac随意~


    关联资源


    aapt2 link --proto-format -o base.apk -I android_30.jar \
    --min-sdk-version 19 --target-sdk-version 29 \
    --version-code 1 --version-name 1.0 \
    --manifest decode_apk_dir\AndroidManifest.xml \
    -R compiled_resources.zip --auto-add-overlay

    生成base.apk


    解压base.apk


    通过unzip解压到base文件夹,目录结构:


    ./base
    ├── AndroidManifest.xml
    ├── res
    └── resources.pb

    拷贝资源


    以base文件夹为根目录


    创建 base/manifest 将 base/AndroidManifest.xml 剪切过来


    拷贝assets , 将 ./temp/decode_apk_dir/assets 拷贝到 ./temp/base/assets


    拷贝lib, 将 ./temp/decode_apk_dir/lib 拷贝到 ./temp/base/lib


    拷贝unknown, 将 ./temp/decode_apk_dir/unknown 拷贝到 ./temp/base/root


    拷贝kotlin, 将 ./temp/decode_apk_dir/kotlin拷贝到 ./temp/base/root/kotlin


    拷贝META-INF,将./temp/decode_apk_dir/original/META-INF 拷贝到 ./temp/base/root/META-INF (删除签名信息***.RSA**、.SF.MF)


    创建./base/dex 文件夹,将 ./decode_apk_dir/*.dex(多个dex都要一起拷贝过来)


    base/manifest                        ============> base/AndroidManifest.xml
    decode_apk_dir/assets ============> base/assets
    decode_apk_dir/lib ============> base/lib
    decode_apk_dir/unknown ============> base/root
    decode_apk_dir/kotlin ============> base/root/kotlin
    decode_apk_dir/original/META-INF ============> base/root/META-INF
    decode_apk_dir/*.dex ============> base/dex/*.dex

    最终的目录结构


    base/
    ├── assets
    ├── dex
    ├── lib
    ├── manifest
    ├── res
    ├── resources.pb
    └── root

    压缩资源


    将base文件夹,压缩成base.zip 一定要zip格式


    编译aab


    打包app bundle需要使用bundletool


    java -jar bundletool-all-1.6.1.jar build-bundle \
    --modules=base.zip --output=base.aab

    aab签名


    jarsigner -digestalg SHA1 -sigalg SHA1withRSA \
    -keystore luojian37.jks \
    -storepass ****** \
    -keypass ****** \
    base.aab \
    ******

    注意:您不能使用 apksigner 为 aab 签名。签名aab的时候不需要使用v2签名,使用JDK的普通签名就行。


    测试


    此时我们已经拿到了一个aab的包,符合Google Play的上架要求,那么我们要确保这个aab的包是否正常呢?作为一个严谨的程序员还是得自己测一下。


    上传Google Play


    上传Google Play的内部测试,通过添加测试用户从Google Play去下载到手机测试。更加能模拟真实的用户环境。


    bundletool安装aab(推荐)


    每次都上传到Google Play上面去测试,成本太高了,程序员一般没上传权限,运营也不在就没法测试了。此时我们可以使用bundletool模拟aab的安装。


    连接好手机,调好adb,执行bundletool命令进行安装


    1.从 aab 生成一组 APK


    java -jar bundletool-all-1.6.1.jar build-apks \
    --bundle=base.aab \
    --output=base.apks \
    --ks=luojian37.jks \
    --ks-pass=pass:****** \
    --ks-key-alias=****** \
    --key-pass=pass:******

    2.将 APK 部署到连接的设备


    java -jar bundletool-all-1.6.1.jar install-apks --apks=base.apks

    还原成apk


    竟然apk可以转化成aab,同样aab也可以生成apk,而且更加简单


    java -jar bundletool-all-1.6.1.jar build-apks \
    --mode=universal \
    --bundle=base.aab \
    --output=test.apks \
    --ks=luojian37.jks \
    --ks-pass=pass:****** \
    --ks-key-alias=****** \
    --key-pass=pass:******

    此时就可以或得一个test.apks的压缩包,解压这个压缩包就有一个universal.apk,和开始转化的apk几乎一样。


    获取工具&源码


    github.com/37sy/build_…


    作者:37手游安卓团队
    链接:https://juejin.cn/post/6982111395621896229
    来源:掘金
    著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
    收起阅读 »

    iOS面试你需要了解的问题-应用签名

    一、代码签名代码签名是对可执行文件或脚本进行数字签名。用来确认软件在签名后未被修改或损坏的措施。和数字签名原理一样,只不过签名的数据是代码而已。1.1简单代码签名在iOS出来之前,以前的主流操作系统(Mac/Windows)软件随便从哪里下载都能运行,系统安全...
    继续阅读 »

    一、代码签名

    代码签名是对可执行文件或脚本进行数字签名。用来确认软件在签名后未被修改或损坏的措施。和数字签名原理一样,只不过签名的数据是代码而已。

    1.1简单代码签名

    iOS出来之前,以前的主流操作系统(Mac/Windows)软件随便从哪里下载都能运行,系统安全存在隐患,盗版软件、病毒入侵、静默安装等等。苹果希望解决这样的问题,要保证每一个安装到 iOS 上的 APP 都是经过苹果官方允许的,怎样保证呢?就是通过代码签名。

    如果要实现验证。最简单的方式就是通过苹果官方生成非对称加密的一对公私钥。在iOS的系统中内置一个公钥,私钥由苹果后台保存。我们传APPAppStore时,苹果后台用私钥对APP数据进行签名,iOS系统下载这个APP后,用公钥验证这个签名。若签名正确,这个APP肯定是由苹果后台认证的并且没有被修改过,也就达到了苹果的需求:保证安装的每一个APP都是经过苹果官方允许的。

    如果我们iOS设备安装APP只从App Store这一个入口这件事就简单解决了,没有任何复杂的东西,一个数字签名搞定。

    但是实际上iOS安装APP还有其他渠道。比如对于我们开发者iOSer而言,在开发APP时需要直接真机调试。而且苹果还开放了企业内部分发的渠道,企业证书签名的APP也是需要顺利安装的。苹果需要开放这些方式安装APP,这些需求就无法通过简单的代码签名来办到了。

    1.2苹果的需求

    • 安装包不需要上传到App Store,可以直接安装到手机上。
    • 苹果为了保证系统的安全性,必须对安装的APP有绝对的控制权:
      • 经过苹果允许才可以安装
      • 不能被滥用导致非开发APP也能被安装

    为了实现这些需求,iOS签名的复杂度也就开始增加了。苹果给出的方案是双层签名

    二、双层签名

    为了实现苹果验证应用的需求,苹果给出的方案是双层签名
    有两个角色:
    1.iOS系统
    2.Mac系统

    因为iOSAPP开发环境在Mac系统下。所以这个依赖关系成为了苹果双层签名的基础。


    2.1双层签名流程





    1. 在Mac系统中生成非对称加密算法的一对公钥\私钥(Xcode帮你代办了,钥匙串)。这里称为公钥M 私钥M ( M = Mac)。

    2. 苹果自己有固定的一对公私钥,和之前App Store原理一样,私钥在苹果后台,公钥在每个iOS系统中。这里称为公钥A ,私钥A。 (A=Apple)

    3. 公钥M 以及一些开发者的信息,传到苹果后台(这个就是CSR文件),用苹果后台里的私钥 A 去签名公钥M。得到一份数据包含了公钥M 以及其签名,把这份数据称为证书。这里苹果服务器就相当于认证服务器。

    4. 在开发时,编译完一个 APP 后,用本地的私钥 M(导出的P12) 对这个 APP 进行签名(证书p12是绑定在一起的),同时把第三步得到的证书一起打包进 APP 里,安装到手机上。

    5. 在安装时,iOS 系统取得证书,通过系统内置的公钥 A去验证证书的数字签名是否正确。

    6. 验证证书后确保了公钥 M是苹果认证过的,再用公钥 M 去验证 APP的签名(p12签名也就是私钥 M),这里就间接验证了这个 APP 安装行为是否经过苹果官方允许。(这里只验证安装行为,不验证APP 是否被改动,因为开发阶段 APP 内容总是不断变化的,苹果不需要管。)

    这里双层签名流程不是最终的iOS签名原理,在这个基础上还要加东西。

    有了双层签名过程,已经可以保证开发者的认证,和程序的安全性了。 但是,你要知道iOS的程序,主要渠道是要通过APP Store才能分发到用户设备的,如果只有上述的过程,那岂不是只要申请了一个证书,就可以安装到所有iOS设备了?

    三、描述文件的产生





    描述文件(Provisioning profile)一般包括三样东西:证书App ID设备。当我们在真机运行或者打包一个项目的时候,证书用来证明我们程序的安全性和合法性。

    苹果为了解决应用滥用的问题,所以苹果又加了两个限制。

    • 1.限制在苹果后台注册过的设备才可以安装。
    • 2.限制签名只能针对某一个具体的APP

    并且苹果还想控制App里面的iCloud/PUSH/后台运行/调试器附加这些权限,所以苹果把这些权限开关统一称为Entitlements(授权文件)。并将这个文件放在了一个叫做Provisioning Profile(描述文件)文件中。

    描述文件是在AppleDevelop网站创建的(在Xcode中填上AppleID它会代办创建),Xcode运行时会打包进入APP内。�所以我们使用CSR申请证书时,还要申请一个东西—就是描述文件!

    在开发时,编译完一个 APP 后,用本地的私钥M对这个APP进行签名,同时把从苹果服务器得到的 Provisioning Profile 文件打包进APP里,文件名为embedded.mobileprovision,把 APP 安装到手机上。最后系统进行验证。

    可以通过:

    security cms -D -i embedded.mobileprovision

    查看描述文件内容。

    资源文件签名:


    machoView签名:



    总结

    • 苹果签名原理
      • Mac电脑生成一对公钥 M私钥 M
        • 利用本地公钥 M创建CSR文件,请求证书
        • 钥匙串将证书本地私钥 Mp12证书)做关联
      • 苹果服务器利用本地私钥 A生成证书以及描述文件
        • 证书包含Mac电脑的公钥 M以及签名
        • 描述文件:设备列表AppID列表权限
      • iOS系统利用系统中的公钥 A(与苹果服务器私钥是一对)对App进行验证。
        • 验证描述文件是否与证书匹配
        • 验证App的安装行为(通过验证证书,拿出证书中的公钥MApp签名(p12 私钥M)进行验证)


    作者:HotPotCat
    链接:https://www.jianshu.com/p/0cd614e060ff
    来源:简书
    著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。




    收起阅读 »

    Android沙雕操作之hook Toast

    一,背景 这是个沙雕操作,原因是:在小米手机的部分机型上,弹Toast时会在吐司内容前面带上app名称,如下: 此时产品经理发话了:为了统一风格,在小米手机上去掉Toast前的应用名。 网上有以下解决方案,比如:先给toast的message设置为空...
    继续阅读 »

    一,背景


    这是个沙雕操作,原因是:在小米手机的部分机型上,弹Toast时会在吐司内容前面带上app名称,如下:


    1.gif


    此时产品经理发话了:为了统一风格,在小米手机上去掉Toast前的应用名。


    网上有以下解决方案,比如:先给toastmessage设置为空,然后再设置需要提示的message,如下:


    Toast toast = Toast.makeText(context, “”, Toast.LENGTH_LONG);
    toast.setText(message);
    toast.show();

    但这些都不能从根本上解决问题,于是Hook Toast的方案诞生了。


    二,分析


    首先分析一下Toast的创建过程.


    Toast的简单使用如下:


    Toast.makeText(this,"abc",Toast.LENGTH_LONG).show();

    1,构造toast


    通过makeText()构造一个Toast,具体代码如下:


    public static Toast makeText(@NonNull Context context, @Nullable Looper looper,
    @NonNull CharSequence text, @Duration int duration)
    {
    if (Compatibility.isChangeEnabled(CHANGE_TEXT_TOASTS_IN_THE_SYSTEM)) {
    Toast result = new Toast(context, looper);
    result.mText = text;
    result.mDuration = duration;
    return result;
    } else {
    Toast result = new Toast(context, looper);
    View v = ToastPresenter.getTextToastView(context, text);
    result.mNextView = v;
    result.mDuration = duration;

    return result;
    }
    }

    makeText()中也就是设置了时长以及要显示的文本或自定义布局,对Hook什么帮助。


    2,展示toast


    接着看下Toast的show():


    public void show() {
    ...

    INotificationManager service = getService();
    String pkg = mContext.getOpPackageName();
    TN tn = mTN;
    tn.mNextView = mNextView;
    final int displayId = mContext.getDisplayId();

    try {
    if (Compatibility.isChangeEnabled(CHANGE_TEXT_TOASTS_IN_THE_SYSTEM)) {
    if (mNextView != null) {
    // It's a custom toast
    service.enqueueToast(pkg, mToken, tn, mDuration, displayId);
    } else {
    // It's a text toast
    ITransientNotificationCallback callback =
    new CallbackBinder(mCallbacks, mHandler);
    service.enqueueTextToast(pkg, mToken, mText, mDuration, displayId, callback);
    }
    } else {
    // 展示toast
    service.enqueueToast(pkg, mToken, tn, mDuration, displayId);
    }
    } catch (RemoteException e) {
    // Empty
    }
    }

    代码很简单,主要是通过serviceenqueueToast()enqueueTextToast()两种方式显示toast。


    service是一个INotificationManager类型的对象,INotificationManager是一个接口,这就为动态代理提供了可能。


    service是在每次show()时通过getService()获取,那就来看看getService():


    @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.P)
    private static INotificationManager sService;

    @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.P)
    static private INotificationManager getService() {
    if (sService != null) {
    return sService;
    }
    sService = INotificationManager.Stub.asInterface(
    ServiceManager.getService(Context.NOTIFICATION_SERVICE));
    return sService;
    }

    getService()最终返回的是sService,是一个懒汉式单例,因此可以通过反射获取到其实例。


    3,小结


    sService是一个单例,尅反射获取到其实例。


    sService实现了INotificationManager接口,因此可以动态代理。


    因此可以通过Hook来干预Toast的展示。


    三,撸码


    理清了上面的过程,实现就很简单了,直接撸码:


    1,获取sService的Field


    Class<Toast> toastClass = Toast.class;

    Field sServiceField = toastClass.getDeclaredField("sService");
    sServiceField.setAccessible(true);

    2,动态代理替换


    Object proxy = Proxy.newProxyInstance(Thread.class.getClassLoader(), new Class[]{INotificationManager.class}, new InvocationHandler() {
    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {

    return null;
    }
    });
    // 用代理对象给sService赋值
    sServiceField.set(null, proxy);

    3,获取sService原始对象


    因为动态代理不能影响被代理对象的原有流程,因此需要在第二步的InvocationHandler()invoke()中需要执行原有的逻辑,这就需要获取sService的原始对象。


    前面已经获取到了sService的Field,它是静态的,那直接通过sServiceField.get(null)获取不就可以了?然而并不能获取到,这是因为整个Hook操作是在应用初始化时,整个应用还没有执行过Toast.show()的操作,因此sService还没有初始化(因为它是一个懒汉单例)。


    既然不能直接获取,那就通过反射调用一下:


    Method getServiceMethod = toastClass.getDeclaredMethod("getService", null);
    getServiceMethod.setAccessible(true);
    Object service = getServiceMethod.invoke(null);

    接着完善一下第二步代码:


    Object proxy = Proxy.newProxyInstance(Thread.class.getClassLoader(), new Class[]{INotificationManager.class}, new InvocationHandler() {
    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {

    return method.invoke(service, args);
    }
    });

    到此,已经实现了对Toast的代理,Toast可以按照原始逻辑正常执行,但还没有加入额外逻辑。


    4,添加Hook逻辑


    InvocationHandlerinvoke()方法中添加额外逻辑:


    Object proxy = Proxy.newProxyInstance(Thread.class.getClassLoader(), new Class[]{INotificationManager.class}, new InvocationHandler() {
    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
    // 判断enqueueToast()方法时执行操作
    if (method.getName().equals("enqueueToast")) {
    Log.e("hook", method.getName());
    getContent(args[1]);
    }
    return method.invoke(service, args);
    }
    });

    args数组的第二个是TN类型的对象,其中有一个LinearLayout类型的mNextView对象,mNextView中有一个TextView类型的childView,这个childView就是展示toast文本的那个TextView,可以直接获取其文本内容,也可以对其赋值,因此代码如下:


    private static void getContent(Object arg) throws ClassNotFoundException, NoSuchFieldException, IllegalAccessException {
    // 获取TN的class
    Class<?> tnClass = Class.forName(Toast.class.getName() + "$TN");
    // 获取mNextView的Field
    Field mNextViewField = tnClass.getDeclaredField("mNextView");
    mNextViewField.setAccessible(true);
    // 获取mNextView实例
    LinearLayout mNextView = (LinearLayout) mNextViewField.get(arg);
    // 获取textview
    TextView childView = (TextView) mNextView.getChildAt(0);
    // 获取文本内容
    CharSequence text = childView.getText();
    // 替换文本并赋值
    childView.setText(text.toString().replace("HookToast:", ""));
    Log.e("hook", "content: " + childView.getText());
    }

    最后看一下效果:


    2.gif


    四,总结


    这个一个沙雕操作,实际应用中这种需求也比较少见。通过Hook的方式可以统一控制,而且没有侵入性。大佬勿喷!!!



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

    未勾选用户协议、隐私政策实现抖动效果

    这是我参与新手入门的第2篇文章 产品看到别家的app,未勾选协议的时候,会给用户一个抖动效果的提示,感觉不错,然后看了看自家的app,不行,没有抖动,不能很明显表示,于是需求出来了,用户未勾选的时候,给个抖动效果。( 呵,都不能有点创新,当然不能说出来...
    继续阅读 »

    这是我参与新手入门的第2篇文章



    产品看到别家的app,未勾选协议的时候,会给用户一个抖动效果的提示,感觉不错,然后看了看自家的app,不行,没有抖动,不能很明显表示,于是需求出来了,用户未勾选的时候,给个抖动效果。( 呵,都不能有点创新,当然不能说出来了,只能内心暗说,哈哈,给自己加了点戏,)正事来了,开始。。。干,就完了。




    如果需要实现用户协议、隐私政策的代码,请看这篇文章:juejin.cn/post/698126…



    实现功能大概需要三个步骤:



    一、 用什么实现;二、实现的步骤;三、运行效果



    一、用什么实现



    其实实现起来很简单,用补间动画就行了。



    二、实现的步骤


    这里说下实现补间动画的步骤:总共需要以下几个步骤


    1.如果res目录下没有anim文件,就新建一个文件夹; image.png 2.在anim文件夹下创建一个名字叫translate_checkbox_shake.xml的文件,抖动动画


    <?xml version="1.0" encoding="utf-8"?>
    <translate xmlns:android="http://schemas.android.com/apk/res/android"
    android:duration="300"
    android:fromXDelta="0"
    android:interpolator="@anim/cyc"
    android:toXDelta="30">
    </translate>

    再在anim下创建一个插值器,名字叫cyc,这样会有抖动效果


    <?xml version="1.0" encoding="utf-8"?>
    <cycleInterpolator xmlns:android="http://schemas.android.com/apk/res/android"
    android:cycles="2">
    </cycleInterpolator>

    3.在translate_checkbox_shake.xml里写上需要的动画属性;


    android:duration="300"与android:cycles="2"联合表示在300毫秒内将动画执行2次,根据需求来设置就行了;


    属性toXDelta和fromXDelta是横向效果,toYDela和fromYDelta是竖向,感兴趣的可以尝试下。、


    4.在代码中使用 AnimationUtils.loadAnimation加载新创建的动画文件; image.png


     val animation = AnimationUtils.loadAnimation(this, R.anim.translate_checkbox_shake)

    5.在代码中使用View的startAnimation启动动画,完事


     binding.llShake.startAnimation(animation)

    三、效果如下:


    20210704160630743.gif


    作者:JasonYin

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

    电子厂里撂了挑子,我默默自学起了Android|2021年中总结

    大四那年我被骗到了电子厂,无法忍受流水线的工作,愤而撩了挑子。前途一片渺茫的时候,我连夜爬起自学起了Android,开启了我的Android开发之路。至今已毕业多年,一直在这条热爱的道路上坚持着,快乐、知足、感恩。 分享我的故事之前,先简单回顾一下我这半年都...
    继续阅读 »

    大四那年我被骗到了电子厂,无法忍受流水线的工作,愤而撩了挑子。前途一片渺茫的时候,我连夜爬起自学起了Android,开启了我的Android开发之路。至今已毕业多年,一直在这条热爱的道路上坚持着,快乐、知足、感恩。


    分享我的故事之前,先简单回顾一下我这半年都干了啥。


    这半年


    年初看到了一篇文章《我的 2020 年终总结》,深受感染。作者杰哥在2020一整年,始终坚持日更输出,拿到了多个平台的证书和奖杯。同时还学做了多道菜品,期间还坚持健身和旅游放松。一年同样是365天,别人竟过得如此充实、如此精彩!


    钦佩之余我不禁陷入了思考,联想到了自己。忽然意识到自高考以后,总是间歇性踌躇满志无疾而终,太久没有为一个目标而坚持了。 我想好好做成一件事情,我要给自己定个目标。我擅长Android开发,那就坚持写作,保证一两个礼拜输出一篇高质量文章。


    一则将自己用心打磨的东西分享出来,帮助别的开发者;二来利用持续的输出倒逼自己不断地摄入新知识,迫使我持续学习,养成终生学习、定期总结的好习惯。但分享给大家看的东西不比私人笔记,需要注意很多细节,诸如深入的理解、通俗的讲解、友好的排版等等。


    为此我做了很多准备,潜心学习了很多优质文章的行文风格、目录次序、MarkDown语言以及一堆作图工具。接着删除了手机、平板里的游戏和视频软件等一切时间杀手。另外收集了大量Android相关的优质话题。并买了个专业的待办事项App,用来随时记录新的灵感,高效地安排每篇文章的写作计划。万事俱备,一月底的时候就开始了半学习、半摸索的写作之路。


    写了一些文章


    半年不到的时间内我输出了十四篇技术文章和三篇随笔。技术文章主要聚焦在Android领域比较流行的话题,比如持续火爆的Jetpack框架,重大UI变革的Compose 工具包,即将发布的Android 12系统以及国人热捧的鸿蒙系统。



    被多个官方转载


    自High的文章没有价值,好在我不是自我感动,写的文章被多个官方平台转载。【深度解读Jetpack框架的基石-AppCompat】是第一篇被Google转载的文章,我很激动、也很意外。因为那是今年输出的第一篇文章,排版和措辞都略显粗糙。很感谢他们提供的平台,这些认同让我坚定了写作方向。


    2篇文章被Android官方公众号转载:



    3篇文章被CSDN官方公众号转载:



    1篇文章被搜狐技术公众号分享:



    1篇文章被掘金官方公众号转载:



    额外赞扬一波掘金平台,上面的高质量文章很多,技术氛围很好。我在这里读到了很多优质文章,也结识了很多优秀作者。而且相较其他平台,掘金对于新人更加友好,只要你的文章认真、质量过关,掘金不会吝啬曝光量。我入驻掘金的时间不长,但前两个月都闯进了移动端前二十的作者榜单,比心。



    特别感谢鸿神


    从事Android工作以来,拜读过鸿神的很多文章,但并不认识。写文章这段时间与鸿神有了多次交流,在钦佩他技术厉害的同时,更感受到他为人的Nice。很感激他的个人公众号转载过我多篇文章,给予的帮助。



    接受认可以及批评


    当然,输出文章的初衷还是希望对大家有所帮助。欣慰的是文章受到了很多积极的评价:有留下“全网最佳”评价的朋友,也有专门加我好友跟我道谢的朋友。你们的认可是我持续输出的最大动力。



    有赞扬自然也有批评,有些朋友说我某个知识点没提到、评价Demo难以理解、吐槽技术点过时。。。真的,我诚恳接受每个批评,将努力发掘和改正这些不足。


    我沉迷于将一个技术点一次性讲清楚,又常常选取一个大的话题,最终导致文章的篇幅都很大。这又需要准备很长时间,而这些时间都来源于工作、生活之余的零碎片段。思路非常很容易被打断,一不小心就错过某个细节,或者代码写得仓促,请大家多多包涵。


    那年高考


    回到文章的标题上来,回顾下我与Android结缘的心路历程,这还得从那年高考讲起。


    高考已过十年有余,那会儿的江苏高考已经很卷,一年一度的新政策搞得我们无所适从。还好我高二那年一鼓作气,势如破竹拿下小四门全A。可惜高考的时候还是大意了,即便我侥幸冲破了葛军神卷的围堵,还是栽在了语文作文上。不会出问题的化学还是出了问题,痛失了6A。在双重失利的情况下,艰难地挺过了一本线。


    与理想的211大学失之交臂后,只能在一众双非大学里碰碰运气了。路过江苏大学招生座位的时候,他们的老师对我兴趣十足,想跟我签订个志愿协议:保证能上他们学校的四个好专业之一,最终录取则要按照我定的顺序来。他提供了车辆工程机械工程电气工程电子信息工程这几个专业,事实上这个顺序已经按照分数线进行了由高到低的排名。


    爸爸和我在前一分钟还不知道江苏有个不在南京的江苏大学(散装江苏还真不是说笑的)。我们对于这个大学和这些专业完全不了解,彻底犯了难。不知道怎么选,更不知道怎么排序。在这重要的抉择时刻,爸爸把选择权交给了我,让我按照自己的想法来(内心OS:呐,你自己选哦,选错了别怨我)。


    面对这一众陌生又熟悉的名词,稚嫩的高三学生开始了他的内心戏:



    • 车辆工程?机械工程?是要学修车吗,还是做拖拉机,摩托车啥的,还是不要了吧

    • 电气工程?是学做电工吗,上电线杆修变压器的那种?但跟我喜欢的物理貌似有点关系,还不错

    • 电子信息工程?电子?电路?芯片?手机?手机能打电话、发短信、玩游戏,高端、有意思,就它了


    所以我在协议上郑重写下了:电子信息工程 > 电气工程 > 机械工程> 车辆工程。是的,我把顺序完美调了个头,哈哈。爸爸看到这个完全颠倒的顺序后,一脸疑惑,隐约不安。但确认了我坚定无比的眼神后,欲言又止,不想耽误我的远大前程。



    12-widget

    结果可想而知,毫无悬念地被江苏大学电子信息工程专业成功录取。进入学校后我才了解到这几个专业的真实情况后,心里直呼草率了,捂脸。


    我的大学



    电子信息工程专业确如我猜想的那样,跟芯片有关系。除此之外,还跟通信、操作系统密不可分。要学的知识点超级多:有令人头皮发麻的数电模电、单片机,需要记忆一堆公式的通信原理,C语言、Java语言和数据库。一句话,很多很散,复杂且枯燥。完全不是我想象中手机的有趣样子,自然是提不起一点兴趣。


    加上高中老师“认真学,到大学就解放了” 的反复洗脑深深地影响了我,便开始混日子。翘课是常有的事,连高等数学挂科了,都没激起我内心的一点涟漪。现在想来也不赖高中老师,这就是给自己的懒惰找的借口,哈哈。


    玩命地打工


    考研是不可能考研的,进大学的时候我就笃定了毕业后直接参加工作,去挣钱。工作需要什么?当时的我浅薄地以为,表达能力、处事能力这些社交素质才是最重要的。可这些本事,学校里不教啊。那就到社会中去,去打工,玩命地打工,还能挣到零花钱。


    在这样的“指导思想”下,大学的寒暑假,几乎都在打工中度过。前前后后在台湾仁宝代工厂做过工人,在日本妮飘面纸厂做过保安,在苏宁电器卖过步步高手机(那一整个暑假,耳朵都被宋慧乔的广告插曲统治着)。。。



    多份打工的体验,让我待人接物变得更加自信、接触新的环境也更加的从容,好像确实提升了所谓的社交素质。但让我感受最深的是,很多工作真的不容易,大学里不愁吃穿、只要顾好学习一件事情的生活真的太珍贵了,可那时候就是没有毅力去珍惜。


    肆意的青春


    大学里特别迷恋某位明星,就跟着一起痴迷Hipop文化。喜欢的歌以说唱为主,看的书都是日韩、港台潮流杂志,外在就更“嘻哈”了:染一头金色头发、打个“钻石”耳钉、戴个夸张的耳环、穿一套炸街的嘻哈服装。从里到外都很Real,简直就是学院里最靓的仔。那个时候Hipop没现在火,知道和接受的人很少,我在他们眼中特别另类,但我不Care。打工得来的大部分钱也都花在了置办这些行头上,在淘宝还不流行的年代买成了淘宝的五星买家。



    12-widget

    看似充实的大学生活,难掩空洞和无聊。除了帝国时代文明的陪伴,就通过画画、练字来排遣这无病呻吟的时光。


    大四了还去电子厂装电路板?


    浑浑噩噩地熬到了大四,终于到检验我社交才能的时候了。信心满满地参加了多个宣讲会,最后竟没有一家企业欣赏我“名企”的兼职经历,连笔试机会都不给啊。接连遭受企业的无情毒打,我才认识到专业成绩和基础仍然是企业最看重的东西。 可这就被动了,书本这一块早就被我放弃了。当年可是村里的高考状元啊,要是连工作都没找到就太丢人了!这种焦虑的状况持续了一个多月。



    12-widget

    工作还得继续找啊!痛定思痛,开始仔细地分析。恶补成绩和基础已经不可能了,那就去整点硬核的实习经验,在专业经验这块弯道超车。 恰好一个电子公司到学校招实习生,说是画PCB电路板子,还发正规的实习证书。这简直是雪中送炭,不拿工资我也得去啊。


    到了之后就傻眼了,压根不是想像中的电子公司,而是一家装配电瓶车充电器的电子厂。算嘞,既来之则安之,给我画电路图就行。可他们让我们一帮学生到流水线上组装电路板,就是左手拿电阻右手拿二极管,在快速转动的传送带上放元件!过分!


    才练习了半小时就得全部上流水线,我手忙脚乱地忙到几乎崩溃。联想到之前在代工厂的打工经历,心里直犯嘟哝:这哪是实习,分明就是打零工嘛,干上一年我还是找不到工作啊,简直就是在浪费时间! 我越想越气,越气装得越乱,越乱越被骂。情绪被逼到了极点,我甩开了电路板子,气呼呼地跟领班说:我,不干了!


    管不了工人们鄙视的眼神,我像逃兵一样跑了出来,钻上了回学校的公交。一路上都在跟自己较劲:你就这么跑了对吗?这点苦都受不了以后能干好什么?跟爸妈吹嘘的实习证书又该怎么办?


    复杂的情绪笼罩了一整天,直到晚上睡觉,还在为这事犯愁。


    Android给了我曙光


    躺在床上,思绪不禁回到了三年前。那时的我对手机兴趣满满,选择了这个专业。如今专业四年即将划上终点,而当初的梦想却未曾踏出半步。 惆怅之余看了眼身旁的HTC G14手机,突然想起店员曾说过它搭载了时下最火的Android智能系统。又回想起学校里曾经有过Android开发的培训广告,我不禁两眼放光:手机我有了,正好是这个最火的Android系统,那干嘛不开发个软件试试呢?如果能开发个完整的App,简历里、面试时不就有东西可说了嘛!


    想罢,立马从床上爬起来搜索关于Android开发的一切。那个年代Android Studio还没发布,开发资料更少得可怜。庆幸我学习能力还不错,顺利地装好了驱动、打开了开发者模式、搭好了EclipseSDK环境,这时候已经到了深夜。当G14成功运行了Hello world的时候,我情不自禁地炸了一句“Yeah”,气得舍友直骂娘。那一刻我兴奋不已,因为我感觉找对了方向。


    网上的资料少且零碎,第二天一早就去图书馆找相关书籍。谢天谢地,还真有一本Android相关的书。我抱着手里的“圣经”,虔诚地学习了各种控件的使用,小心翼翼地倒腾了两天,终于搞出了一个播放本地mp3的播放界面。看到这有模有样的成果,成就感爆棚。于是乘胜追击,加了很多小功能:音乐封面、上下首、播放模式、文件列表、主题切换、启动画面等等。


    大概又搞了一个礼拜,一个完整的音乐App成型了。我把杰作安装到G14上,随身携带。面试的时候时不时拿出来演示一番,顺带着复述着那些似懂非懂的API。 那个年代懂Android的人很少,我如愿以偿地找到了Android开发工作。我清晰地记得拿到Offer后,爸爸在电话那头的兴奋。在他们不看好的方向上获得成功、受到认可的感觉真得很棒!


    打那以后,我对Android的兴趣一发不可收拾。在学校的最后一点时光里,总忍不住开发个小Demo把玩把玩,时不时地刷个新Rom体验体验。G14很快就被折腾不行了,对我而言这是一部意义非凡的手机,多次搬家都不忍丢弃。 如今那个启蒙App早已找不着了,很想找来跑一跑,康康当时写的代码有多烂、界面有多丑,哈哈。


    社会人


    说不清是音乐App助我找到了工作,还是自学Android的热情打动了公司,给了我机会。


    我有幸一直从事品牌手机的ROM开发工作,从开发第三方App、到修改系统App、再到定制Framework;从面向Bug编程、到面向对象编程、再到面向产品编程,一晃已过了七年!


    临笔前特地到官网瞅了一眼这些年开发过的Android设备,有20多部。当这么多部造型各异的手机和平板,平铺在电脑面前时,回忆历历在目、感慨不已。


    成长为安卓老兵的同时,外在也不可抗拒地发生变化。发际线渐渐失守,眼镜戴上就摘不下来了,身形也渐渐走样。好像也不全是坏事,它们提醒着我在工作、生活、学习的同时,时刻关注身体健康。



    半山腰回望


    如果大四那年没有在电子厂里撂挑子,我大概率不会自学Android。可能最终也能找着工作,但极有可能不会从事我如今热爱的Android行业。


    我很荣幸参与和见证了这个行业的发展,这些年它变化太快,像是一场狂欢。从颠覆移动领域的变革时代,到移动互联的红利时代,再到如今内卷严重的存量时代,各方都在努力地维持或改变:



    • 巨头们在不断调整战略:Google通过GMS想方设法地控制Android系统,厂商们在同质化严重的Android设备里寻求亮点和突破,在传统设备以外持续探索和开发新的赛道。。。

    • 开发者们亦疲于奔命:应对各种快速迭代的新技术,应付各种碎片化ROM的适配,苦于前端、跨平台技术的蚕食。。。


    移动互联的落寞必然引发Android市场的紧缩,企业对于Android群体的要求将持续拉高,Android开发的内卷加剧则是不争的事实。 如果热爱Android、对Android仍有信心,时刻保持技术人的好奇心和探索欲吧,对新技术以及新领域:



    • AABJetpackKotlinComposeFlutter。。。

    • 革新的智能座舱、划时代的自动驾驶、万物互联的鸿蒙、一统Android和Chrome OS的Fuchsia。。。



    最后一点碎碎念



    I always knew what the right path was. Without exception, l knew, but l never took it. You know why ? lt was too damn hard.



    这是我最喜欢的电影《闻香识女人》里迈克中校的感人自白:“无一例外,我永远知道哪条路是对的。但我从来不走,因为太XX难了”。知易行难,这无疑是古今中外、亘古不变的难题。它关乎的东西太多:改变自律坚持成长,哪一个都不好对付。


    如今的我早已被生活磨平了棱角,渐渐丢掉了当年的那份冲劲和激情。但每每想起当年那个敢于说不、熬夜自学的我,感慨之余多了一份坚持。


    也许你也曾踌躇满志、无疾而终,记得想想最初的自己,你会找到那个答案。


    正值毕业季,祝福即将踏入社会的新朋友,以及社会中浮沉的老朋友,都有个淋漓尽致的人生!



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


    收起阅读 »

    想搞懂Jetpack架构可以不搞懂生命周期知识吗?

    1. 前言 Activity生命周期真是一个非常古老的话题,无论是10年前,还是当下。不管是面试还是工作,经常会遇到与Activity生命周期相关的问题。比如“按下返回键和Home键,生命周期方法调用顺序”、“A启动B,它们的生命周期方法调用顺序”。工作中,...
    继续阅读 »

    1. 前言


    Activity生命周期真是一个非常古老的话题,无论是10年前,还是当下。不管是面试还是工作,经常会遇到与Activity生命周期相关的问题。比如“按下返回键和Home键,生命周期方法调用顺序”、“A启动B,它们的生命周期方法调用顺序”。工作中,Jetpack Lifecycle、LiveData、ViewModel等组件都是建立在生命周期之上。


    在我研究Jetpack Lifecycle、LiveData、ViewModel源码时,我发现它们与组件的生命周期有很大的关系。它们能够自动感知组件的生命周期变化。LiveData能够在onDestroy方法调用时自动将监听注销掉,ViewModel能够在Configuration发生改变时(比如旋转屏幕)自动保存数据,并且在Activity重建时恢复到Configuration发生改变之前。


    本文我将从几个场景详细介绍Activity的生命周期变化。


    2. 单Activity按返回按钮


    触发步骤:



    • 按返回按钮

    • 或者调用finish方法

    • 重新进入Activity


    该场景演示了用户启动,销毁,重新进入Activity的生命周期变化。调用顺序如图:


    状态管理:



    • onSaveInstanceState没有被调用,因为Activity被销毁,没有必要保存状态

    • 当Activity被重新进入时,onCreate方法bundle参数为null


    3. 单Activity按Home键


    触发步骤:



    • 用户按Home键

    • 或者切换至其它APP

    • 重新进入Activity


    该场景Activity会调用onStop方法,但是不会立即调用onDestroy方法。调用顺序如图:


    状态管理:


    当Activity进入Stopped状态,系统使用onSaveInstanceState保存app状态,以防系统将app进程杀死,重启后恢复状态。


    4. 单Activity旋转屏幕


    触发步骤:



    • Configuration发生改变, 比如旋转屏幕

    • 用户在多窗口模式下调整窗口大小


    当用户旋转屏幕,系统会保留旋转之前的状态,能很好的恢复到之前的状态。调用顺序如图:


    状态管理:



    • Activity被完全销毁掉,但是状态会被保存,而且会在新的Activity中恢复该状态

    • onCreate和onRestoreInstanceState方法中的bundle是一样的


    5. 单Activity弹出Dialog


    触发步骤:



    • 在API 24+上开启多窗口模式失去焦点时

    • 其它应用部分遮盖当前APP,比如弹出权限授权dialog

    • 弹出intent选择器时,比如弹出系统的分享dialog



    该场景不适用于以下情况:



    • 相同APP中弹dialog,比如弹出AlertDialog或者DialogFragment不会导致Activity onPause发生调用

    • 系统通知。当用户下拉系统通知栏时,不会导致下面的Activity onPause发生调用。


    6. 多个Activity跳转


    触发步骤:



    • activity1 跳转到activity2

    • 按返回按钮



    注意:activity1 跳转到activity2 正确的调用顺序是



    ->activity1.onPause


    ->activity2.onCreate


    ->activity2.onStart


    ->activity2.onResume


    ->activity1.onStop


    ->activity1.onSaveInstanceState



    在该场景下,当新的activity启动时,activity1处于STOPPED状态下(但是没有被销毁),这与用户按Home键有点类似。当用户按返回按钮时,activity2被销毁掉。


    状态管理:



    • onSaveInstanceState会被调用,但是onRestoreInstanceState不会。当activity2展示在前台时,如果发生了旋转屏幕,当activity1再次获得焦点时,它将会被销毁并且重建,这就是为什么activity1在失去焦点时为什么需要保存状态。

    • 如果系统杀死了app进程,该场景后面会介绍到


    7. 多个Activity跳转,并且旋转屏幕



    • activity1 跳转到activity2

    • 在activity2上旋转屏幕

    • 按返回按钮



    注意: 当返回activity1时,必须保证屏幕是保持旋转后的状态,否则并不会调用onDestroy方法。而且是在activity1回到前台时才会主动掉onDestroy


    状态管理:


    保存状态对所有的activity都非常重要,不仅仅是对前台activity。所有在后台栈中的activity在configuration发生改变时重建UI时都需要将保存的状态恢复回来。


    8. 多个Activity跳转,被系统kill掉app



    • activity1 跳转到activity2

    • 在activity2上按Home键

    • 系统资源不足kill app



    9. 总结


    本文主要是从Google大佬Jose Alcérreca的文章翻译过来。他假设的这7个关于activity的生命周期场景,对了解Lifecycle有非常大的帮助。甚至对于面试都是有非常大的帮助。


    后续我会写一系列关于Jetpack的文章。文风将会延续我的一贯风格,深入浅出,坚持走高质量创作路线。本文是我讲解Lifecycle的开篇之作。生命周期是Lifecycle、LiveDa、ViewModel等组件的基础。在对生命周期知识掌握不牢靠的情况,去研究那些组件,无异于空中楼阁。



    作者:字节小站
    链接:https://juejin.cn/post/6981965690014007327
    来源:掘金
    著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。


    收起阅读 »

    从 LiveData 迁移到 Kotlin 数据流

    LiveData 的历史要追溯到 2017 年。彼时,观察者模式有效简化了开发,但诸如 RxJava 一类的库对新手而言有些太过复杂。为此,架构组件团队打造了 LiveData: 一个专用于 Android 的具备自主生命周期感知能力的可观察的数据存储器类。L...
    继续阅读 »

    LiveData 的历史要追溯到 2017 年。彼时,观察者模式有效简化了开发,但诸如 RxJava 一类的库对新手而言有些太过复杂。为此,架构组件团队打造了 LiveData: 一个专用于 Android 的具备自主生命周期感知能力的可观察的数据存储器类。LiveData 被有意简化设计,这使得开发者很容易上手;而对于较为复杂的交互数据流场景,建议您使用 RxJava,这样两者结合的优势就发挥出来了。


    DeadData?


    LiveData 对于 Java 开发者、初学者或是一些简单场景而言仍是可行的解决方案。而对于一些其他的场景,更好的选择是使用 Kotlin 数据流 (Kotlin Flow)。虽说数据流 (相较 LiveData) 有更陡峭的学习曲线,但由于它是 JetBrains 力挺的 Kotlin 语言的一部分,且 Jetpack Compose 正式版即将发布,故两者配合更能发挥出 Kotlin 数据流中响应式模型的潜力。


    此前一段时间,我们探讨了 如何使用 Kotlin 数据流 来连接您的应用当中除了视图和 View Model 以外的其他部分。而现在我们有了 一种更安全的方式来从 Android 的界面中获得数据流,已经可以创作一份完整的迁移指南了。


    在这篇文章中,您将学到如何把数据流暴露给视图、如何收集数据流,以及如何通过调优来适应不同的需求。


    数据流: 把简单复杂化,又把复杂变简单


    LiveData 就做了一件事并且做得不错: 它在 缓存最新的数据 和感知 Android 中的生命周期的同时将数据暴露了出来。稍后我们会了解到 LiveData 还可以 启动协程创建复杂的数据转换,这可能会需要花点时间。


    接下来我们一起比较 LiveData 和 Kotlin 数据流中相对应的写法吧:


    #1: 使用可变数据存储器暴露一次性操作的结果


    这是一个经典的操作模式,其中您会使用协程的结果来改变状态容器:


    △ 将一次性操作的结果暴露给可变的数据容器 (LiveData)


    △ 将一次性操作的结果暴露给可变的数据容器 (LiveData)


    <!-- Copyright 2020 Google LLC.  
    SPDX-License-Identifier: Apache-2.0 -->

    class MyViewModel {
    private val _myUiState = MutableLiveData<Result<UiState>>(Result.Loading)
    val myUiState: LiveData<Result<UiState>> = _myUiState

    // 从挂起函数和可变状态中加载数据
    init {
    viewModelScope.launch {
    val result = ...
    _myUiState.value = result
    }
    }
    }

    如果要在 Kotlin 数据流中执行相同的操作,我们需要使用 (可变的) StateFlow (状态容器式可观察数据流):


    △ 使用可变数据存储器 (StateFlow) 暴露一次性操作的结果


    △ 使用可变数据存储器 (StateFlow) 暴露一次性操作的结果


    class MyViewModel {
    private val _myUiState = MutableStateFlow<Result<UiState>>(Result.Loading)
    val myUiState: StateFlow<Result<UiState>> = _myUiState

    // 从挂起函数和可变状态中加载数据
    init {
    viewModelScope.launch {
    val result = ...
    _myUiState.value = result
    }
    }
    }

    StateFlowSharedFlow 的一个比较特殊的变种,而 SharedFlow 又是 Kotlin 数据流当中比较特殊的一种类型。StateFlow 与 LiveData 是最接近的,因为:



    • 它始终是有值的。

    • 它的值是唯一的。

    • 它允许被多个观察者共用 (因此是共享的数据流)。

    • 它永远只会把最新的值重现给订阅者,这与活跃观察者的数量是无关的。



    当暴露 UI 的状态给视图时,应该使用 StateFlow。这是一种安全和高效的观察者,专门用于容纳 UI 状态。



    #2: 把一次性操作的结果暴露出来


    这个例子与上面代码片段的效果一致,只是这里暴露协程调用的结果而无需使用可变属性。


    如果使用 LiveData,我们需要使用 LiveData 协程构建器:


    △ 把一次性操作的结果暴露出来 (LiveData)


    △ 把一次性操作的结果暴露出来 (LiveData)


    class MyViewModel(...) : ViewModel() {
    val result: LiveData<Result<UiState>> = liveData {
    emit(Result.Loading)
    emit(repository.fetchItem())
    }
    }

    由于状态容器总是有值的,那么我们就可以通过某种 Result 类来把 UI 状态封装起来,比如加载中、成功、错误等状态。


    与之对应的数据流方式则需要您多做一点配置:


    △ 把一次性操作的结果暴露出来 (StateFlow)


    △ 把一次性操作的结果暴露出来 (StateFlow)


    class MyViewModel(...) : ViewModel() {
    val result: StateFlow<Result<UiState>> = flow {
    emit(repository.fetchItem())
    }.stateIn(
    scope = viewModelScope,
    started = WhileSubscribed(5000), //由于是一次性操作,也可以使用 Lazily
    initialValue = Result.Loading
    )
    }

    stateIn 是专门将数据流转换为 StateFlow 的运算符。由于需要通过更复杂的示例才能更好地解释它,所以这里暂且把这些参数放在一边。


    #3: 带参数的一次性数据加载


    比方说您想要加载一些依赖用户 ID 的数据,而信息来自一个提供数据流的 AuthManager:


    △ 带参数的一次性数据加载 (LiveData)


    △ 带参数的一次性数据加载 (LiveData)


    使用 LiveData 时,您可以用类似这样的代码:


    class MyViewModel(authManager..., repository...) : ViewModel() {
    private val userId: LiveData<String?> =
    authManager.observeUser().map { user -> user.id }.asLiveData()

    val result: LiveData<Result<Item>> = userId.switchMap { newUserId ->
    liveData { emit(repository.fetchItem(newUserId)) }
    }
    }

    switchMap 是数据变换中的一种,它订阅了 userId 的变化,并且其代码体会在感知到 userId 变化时执行。


    如非必须要将 userId 作为 LiveData 使用,那么更好的方案是将流式数据和 Flow 结合,并将最终的结果 (result) 转化为 LiveData。


    class MyViewModel(authManager..., repository...) : ViewModel() {
    private val userId: Flow<UserId> = authManager.observeUser().map { user -> user.id }

    val result: LiveData<Result<Item>> = userId.mapLatest { newUserId ->
    repository.fetchItem(newUserId)
    }.asLiveData()
    }

    如果改用 Kotlin Flow 来编写,代码其实似曾相识:


    △ 带参数的一次性数据加载 (StateFlow)


    △ 带参数的一次性数据加载 (StateFlow)


    class MyViewModel(authManager..., repository...) : ViewModel() {
    private val userId: Flow<UserId> = authManager.observeUser().map { user -> user.id }

    val result: StateFlow<Result<Item>> = userId.mapLatest { newUserId ->
    repository.fetchItem(newUserId)
    }.stateIn(
    scope = viewModelScope,
    started = WhileSubscribed(5000),
    initialValue = Result.Loading
    )
    }

    假如说您想要更高的灵活性,可以考虑显式调用 transformLatest 和 emit 方法:


    val result = userId.transformLatest { newUserId ->
    emit(Result.LoadingData)
    emit(repository.fetchItem(newUserId))
    }.stateIn(
    scope = viewModelScope,
    started = WhileSubscribed(5000),
    initialValue = Result.LoadingUser //注意此处不同的加载状态
    )

    #4: 观察带参数的数据流


    接下来我们让刚才的案例变得更具交互性。数据不再被读取,而是被观察,因此我们对数据源的改动会直接被传递到 UI 界面中。


    继续刚才的例子: 我们不再对源数据调用 fetchItem 方法,而是通过假定的 observeItem 方法获取一个 Kotlin 数据流。


    若使用 LiveData,可以将数据流转换为 LiveData 实例,然后通过 emitSource 传递数据的变化。


    △ 观察带参数的数据流 (LiveData)


    △ 观察带参数的数据流 (LiveData)


    class MyViewModel(authManager..., repository...) : ViewModel() {
    private val userId: LiveData<String?> =
    authManager.observeUser().map { user -> user.id }.asLiveData()

    val result = userId.switchMap { newUserId ->
    repository.observeItem(newUserId).asLiveData()
    }
    }

    或者采用更推荐的方式,把两个流通过 flatMapLatest 结合起来,并且仅将最后的输出转换为 LiveData:


    class MyViewModel(authManager..., repository...) : ViewModel() {
    private val userId: Flow<String?> =
    authManager.observeUser().map { user -> user?.id }

    val result: LiveData<Result<Item>> = userId.flatMapLatest { newUserId ->
    repository.observeItem(newUserId)
    }.asLiveData()
    }

    使用 Kotlin 数据流的实现方式非常相似,但是省下了 LiveData 的转换过程:


    △ 观察带参数的数据流 (StateFlow)


    △ 观察带参数的数据流 (StateFlow)


    class MyViewModel(authManager..., repository...) : ViewModel() {
    private val userId: Flow<String?> =
    authManager.observeUser().map { user -> user?.id }

    val result: StateFlow<Result<Item>> = userId.flatMapLatest { newUserId ->
    repository.observeItem(newUserId)
    }.stateIn(
    scope = viewModelScope,
    started = WhileSubscribed(5000),
    initialValue = Result.LoadingUser
    )
    }

    每当用户实例变化,或者是存储区 (repository) 中用户的数据发生变化时,上面代码中暴露出来的 StateFlow 都会收到相应的更新信息。


    #5: 结合多种源: MediatorLiveData -> Flow.combine


    MediatorLiveData 允许您观察一个或多个数据源的变化情况,并根据得到的新数据进行相应的操作。通常可以按照下面的方式更新 MediatorLiveData 的值:


    val liveData1: LiveData<Int> = ...
    val liveData2: LiveData<Int> = ...

    val result = MediatorLiveData<Int>()

    result.addSource(liveData1) { value ->
    result.setValue(liveData1.value ?: 0 + (liveData2.value ?: 0))
    }
    result.addSource(liveData2) { value ->
    result.setValue(liveData1.value ?: 0 + (liveData2.value ?: 0))
    }

    同样的功能使用 Kotlin 数据流来操作会更加直接:


    val flow1: Flow<Int> = ...
    val flow2: Flow<Int> = ...

    val result = combine(flow1, flow2) { a, b -> a + b }

    此处也可以使用 combineTransform 或者 zip 函数。


    通过 stateIn 配置对外暴露的 StateFlow


    早前我们使用 stateIn 中间运算符来把普通的流转换成 StateFlow,但转换之后还需要一些配置工作。如果现在不想了解太多细节,只是想知道怎么用,那么可以使用下面的推荐配置:


    val result: StateFlow<Result<UiState>> = someFlow
    .stateIn(
    scope = viewModelScope,
    started = WhileSubscribed(5000),
    initialValue = Result.Loading
    )

    不过,如果您想知道为什么会使用这个看似随机的 5 秒的 started 参数,请继续往下读。


    根据文档,stateIn 有三个参数:?


    @param scope 共享开始时所在的协程作用域范围

    @param started 控制共享的开始和结束的策略

    @param initialValue 状态流的初始值

    当使用 [SharingStarted.WhileSubscribed] 并带有 `replayExpirationMillis` 参数重置状态流时,也会用到 initialValue。

    started 接受以下的三个值:



    • Lazily: 当首个订阅者出现时开始,在 scope 指定的作用域被结束时终止。

    • Eagerly: 立即开始,而在 scope 指定的作用域被结束时终止。

    • WhileSubscribed: 这种情况有些复杂 (后文详聊)。


    对于那些只执行一次的操作,您可以使用 Lazily 或者 Eagerly。然而,如果您需要观察其他的流,就应该使用 WhileSubscribed 来实现细微但又重要的优化工作,参见后文的解答。


    WhileSubscribed 策略


    WhileSubscribed 策略会在没有收集器的情况下取消上游数据流。通过 stateIn 运算符创建的 StateFlow 会把数据暴露给视图 (View),同时也会观察来自其他层级或者是上游应用的数据流。让这些流持续活跃可能会引起不必要的资源浪费,例如一直通过从数据库连接、硬件传感器中读取数据等等。当您的应用转而在后台运行时,您应当保持克制并中止这些协程


    WhileSubscribed 接受两个参数:


    public fun WhileSubscribed(
    stopTimeoutMillis: Long = 0,
    replayExpirationMillis: Long = Long.MAX_VALUE
    )


    超时停止


    根据其文档:



    stopTimeoutMillis 控制一个以毫秒为单位的延迟值,指的是最后一个订阅者结束订阅与停止上游流的时间差。默认值是 0 (立即停止)。



    这个值非常有用,因为您可能并不想因为视图有几秒钟不再监听就结束上游流。这种情况非常常见——比如当用户旋转设备时,原来的视图会先被销毁,然后数秒钟内重建。


    liveData 协程构建器所使用的方法是 添加一个 5 秒钟的延迟,即如果等待 5 秒后仍然没有订阅者存在就终止协程。前文代码中的 WhileSubscribed (5000) 正是实现这样的功能:


    class MyViewModel(...) : ViewModel() {
    val result = userId.mapLatest { newUserId ->
    repository.observeItem(newUserId)
    }.stateIn(
    scope = viewModelScope,
    started = WhileSubscribed(5000),
    initialValue = Result.Loading
    )
    }

    这种方法会在以下场景得到体现:



    • 用户将您的应用转至后台运行,5 秒钟后所有来自其他层的数据更新会停止,这样可以节省电量。

    • 最新的数据仍然会被缓存,所以当用户切换回应用时,视图立即就可以得到数据进行渲染。

    • 订阅将被重启,新数据会填充进来,当数据可用时更新视图。


    数据重现的过期时间


    如果用户离开应用太久,此时您不想让用户看到陈旧的数据,并且希望显示数据正在加载中,那么就应该在 WhileSubscribed 策略中使用 replayExpirationMillis 参数。在这种情况下此参数非常适合,由于缓存的数据都恢复成了 stateIn 中定义的初始值,因此可以有效节省内存。虽然用户切回应用时可能没那么快显示有效数据,但至少不会把过期的信息显示出来。



    replayExpirationMillis 配置了以毫秒为单位的延迟时间,定义了从停止共享协程到重置缓存 (恢复到 stateIn 运算符中定义的初始值 initialValue) 所需要等待的时间。它的默认值是长整型的最大值 Long.MAX_VALUE (表示永远不将其重置)。如果设置为 0,可以在符合条件时立即重置缓存的数据。



    从视图中观察 StateFlow


    我们此前已经谈到,ViewModel 中的 StateFlow 需要知道它们已经不再需要监听。然而,当所有的这些内容都与生命周期 (lifecycle) 结合起来,事情就没那么简单了。


    要收集一个数据流,就需要用到协程。Activity 和 Fragment 提供了若干协程构建器:



    • Activity.lifecycleScope.launch : 立即启动协程,并且在本 Activity 销毁时结束协程。

    • Fragment.lifecycleScope.launch : 立即启动协程,并且在本 Fragment 销毁时结束协程。

    • Fragment.viewLifecycleOwner.lifecycleScope.launch : 立即启动协程,并且在本 Fragment 中的视图生命周期结束时取消协程。


    LaunchWhenStarted 和 LaunchWhenResumed


    对于一个状态 X,有专门的 launch 方法称为 launchWhenX。它会在 lifecycleOwner 进入 X 状态之前一直等待,又在离开 X 状态时挂起协程。对此,需要注意对应的协程只有在它们的生命周期所有者被销毁时才会被取消


    △ 使用 launch/launchWhenX 来收集数据流是不安全的


    △ 使用 launch/launchWhenX 来收集数据流是不安全的


    当应用在后台运行时接收数据更新可能会引起应用崩溃,但这种情况可以通过将视图的数据流收集操作挂起来解决。然而,上游数据流会在应用后台运行期间保持活跃,因此可能浪费一定的资源。


    这么说来,目前我们对 StateFlow 所进行的配置都是无用功;不过,现在有了一个新的 API。


    lifecycle.repeatOnLifecycle 前来救场


    这个新的协程构建器 (自 lifecycle-runtime-ktx 2.4.0-alpha01 后可用) 恰好能满足我们的需要: 在某个特定的状态满足时启动协程,并且在生命周期所有者退出该状态时停止协程。


    △ 不同数据流收集方法的比较


    △ 不同数据流收集方法的比较


    比如在某个 Fragment 的代码中:


    onCreateView(...) {
    viewLifecycleOwner.lifecycleScope.launch {
    viewLifecycleOwner.lifecycle.repeatOnLifecycle(STARTED) {
    myViewModel.myUiState.collect { ... }
    }
    }
    }

    当这个 Fragment 处于 STARTED 状态时会开始收集流,并且在 RESUMED 状态时保持收集,最终在 Fragment 进入 STOPPED 状态时结束收集过程。如需获取更多信息,请参阅: 使用更为安全的方式收集 Android UI 数据流


    结合使用 repeatOnLifecycle API 和上面的 StateFlow 示例可以帮助您的应用妥善利用设备资源的同时,发挥最佳性能。


    △ 该 StateFlow 通过 WhileSubscribed(5000) 暴露并通过 repeatOnLifecycle(STARTED) 收集


    △ 该 StateFlow 通过 WhileSubscribed(5000) 暴露并通过 repeatOnLifecycle(STARTED) 收集



    注意: 近期在 Data Binding 中加入的 StateFlow 支持 使用了 launchWhenCreated 来描述收集数据更新,并且它会在进入稳定版后转而使用 repeatOnLifecyle


    对于数据绑定,您应该在各处都使用 Kotlin 数据流并简单地加上 asLiveData() 来把数据暴露给视图。数据绑定会在 lifecycle-runtime-ktx 2.4.0 进入稳定版后更新。



    总结


    通过 ViewModel 暴露数据,并在视图中获取的最佳方式是:



    • ?? 使用带超时参数的 WhileSubscribed 策略暴露 StateFlow。[示例 1]

    • ?? 使用 repeatOnLifecycle 来收集数据更新。[示例 2]


    如果采用其他方式,上游数据流会被一直保持活跃,导致资源浪费:



    • ? 通过 WhileSubscribed 暴露 StateFlow,然后在 lifecycleScope.launch/launchWhenX 中收集数据更新。

    • ? 通过 Lazily/Eagerly 策略暴露 StateFlow,并在 repeatOnLifecycle 中收集数据更新。


    当然,如果您并不需要使用到 Kotlin 数据流的强大功能,就用 LiveData 好了 :)


    ManuelWojtekYigit、Alex Cook、FlorinaChris 致谢!




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

    okhttp文件上传失败,居然是Android Studio背锅?太难了~

    1、前言 本案例是我本人遇到的真实案例,因查找原因的过程一度让我崩溃,我相信不少人也遇到过相同的问题,故将其记录下来,希望对大家有帮助,本案例使用RxHttp 2.6.4 + OkHttp 4.9.1版本,当然,如果你使用Retrofit等其它基于OkHtt...
    继续阅读 »

    1、前言


    本案例是我本人遇到的真实案例,因查找原因的过程一度让我崩溃,我相信不少人也遇到过相同的问题,故将其记录下来,希望对大家有帮助,本案例使用RxHttp 2.6.4 + OkHttp 4.9.1版本,当然,如果你使用Retrofit等其它基于OkHttp封装的框架,且用到监听上传进度功能,那么很大概率你也会遇到这个问题,请耐心看完,如果你想直接看到结果,划到文章末尾即可。


    2、问题描述


    事情是这样的,有一段文件上传的代码,如下:


    fun uploadFiles(fileList: List<File>) {
    RxHttp.postForm("/server/...")
    .add("key", "value")
    .addFiles("files", fileList)
    .upload {
    //上传进度回调
    }
    .asString()
    .subscribe({
    //成功回调
    }, {
    //失败回调
    })
    }

    这段代码在写完后很长一段时间内都是ok的,突然有一天,执行这段代码居然报错了,日志如下:


    image.png 这个异常是100%出现的,很熟悉的异常,具体原因就是,数据流被关闭了,但依然往里面写数据,来看看最后抛异常的地方,如下:


    image.png 可以看到,方法里面第一行代码就判断数据流是否已关闭,是的话,抛出异常。


    注:如果你是RxHttp使用者,正在尝试这段代码,发现没问题,也不要惊讶,因为这需要在Android Studio特定场景下执行才会出现,而且是相对高频使用的场景,请待我一步步揭晓答案


    3、一探究竟


    本着出现问题,先定位到自己代码的原则,打开ProgressRequestBody类76行看看,如下:


    public class ProgressRequestBody extends RequestBody {

    //省略相关代码
    private BufferedSink bufferedSink;
    @Override
    public void writeTo(BufferedSink sink) throws IOException {
    if (bufferedSink == null) {
    bufferedSink = Okio.buffer(sink(sink));
    }
    requestBody.writeTo(bufferedSink); //这里是76行
    bufferedSink.flush();
    }
    }

    ProgressRequestBody继承了okhttp3.RequestBody类,作用是监听上传进度;显然最后执行到这里时,数据流已经被关闭了,从日志里可以看到,最后一次调用ProgressRequestBody#writeTo(BufferedSink)方法的地方在CallServerInterceptor拦截器的59行,打开看看


    class CallServerInterceptor(private val forWebSocket: Boolean) : Interceptor {

    //省略相关代码
    @Throws(IOException::class)
    override fun intercept(chain: Interceptor.Chain): Response {
    //省略相关代码
    if (responseBuilder == null) {
    if (requestBody.isDuplex()) {
    exchange.flushRequest()
    val bufferedRequestBody = exchange.createRequestBody(request, true).buffer()
    requestBody.writeTo(bufferedRequestBody)
    } else {
    val bufferedRequestBody = exchange.createRequestBody(request, false).buffer()
    requestBody.writeTo(bufferedRequestBody) //这里是59行
    bufferedRequestBody.close() //数据写完,将数据流关闭
    }
    }
    }
    }

    熟悉OkHttp原理的同学应该知道,CallServerInterceptor拦截器是okhttp拦截器链的最后一个拦截器,将客户端数据写出到服务端,就是在这里实现的,也就是59行,那问题就来了,数据都还没写出去,数据流怎么就关闭了呢?这令我百思不得其解,毫无头绪。


    于是乎,我做了很多无用功,如:重新检查代码,看看是否有手动关闭数据流的地方,显然没有找到;接着,实在没有办法,代码回滚,回滚到最初写这段代码的版本,我满怀期待的以为,这下应该没问题了,可尝试过后,依旧报java.lang.IllegalStateException: closed,成年人的崩溃就在这一瞬间,我陷入了绝境,已经消耗5个小时在这个问题上,此时已晚上23:30,看来又是一个不眠夜。


    question1.jpeg


    习惯告诉我,一个问题很久没查出来,可以先放弃,好吧,拔手机关电脑,洗澡睡觉。


    半小时后,我躺在床上,很难受,于是我拿出手机,打开app,再试了试上传功能,惊奇的发现,可以了,上传成功了,这。。。。一脸懵逼,我找谁说理去,虽然没问题了,但问题没找到,作为一名初级程序员,这我无法接受。


    精神的力量把我从床上扶了起来,再次打开电脑,连上手机,这次,果然有了新的收获,也一下子刷新了我的世界观;当我再次打开app,尝试上传文件时,一样的错误出现在我眼前,What??? 刚才还好好的,连上电脑就不行了?


    question2.jpeg


    ok,我彻底没脾气了,拔掉手机,重启app,再试,没问题了,再次连上电脑,再试,问题又出来了。。


    此时,我的心态有了些许的好转,毕竟有了新的调查方向,我再次查看错误日志,发现了一个很奇怪的地方,如下: image.png


    com.android.tools.profiler.agent.okhttp.OkHttp3Interceptor是从哪冒出来的?在我的认知里,OkHttp3是没有这个拦截器的,为了验证我的认知,再次查看okhttp3源码,如下:


    image.png


    确定是没有添加这个拦截器的,仔细看日志发现,OkHttp3InterceptorCallServerInterceptor、ConnectInterceptor之间执行的,那就只有一个解释,OkHttp3Interceptor是通过addNetworkInterceptor方法添加,现在就好办了,全局搜索addNetworkInterceptor就知道是谁添加的,哪里添加的,很可惜,未找到调用此方法的源码,似乎又陷入了绝境。


    question.jpeg


    那就只能开启调试,看看OkHttp3Interceptor是否在OkHttpClient对象的networkInterceptors网络拦截器列表里,一调试,果然有发现,如下:


    image.png 调试点击下一步,神奇的事情就发生了,如下:


    image.png


    这怎么解释?networkInterceptors.size始终是0,interceptors.size是如何加1变为5的?再来看看,加的1是什么,如下:


    image.png


    很熟悉,就是我们之前提到的OkHttp3Interceptor,这是如何做到的?只有一个解释,OkHttpClient#networkInterceptors()方法被字节码插桩技术插入了新的代码,为了验证我的想法,我做了以下实验:


    image.png


    image.png


    可以看到,我直接new了一个OkHttpClient对象,啥也没配置,调用networkInterceptors()方法,就获取了OkHttp3Interceptor拦截器,但OkHttpClient对象里的networkInterceptors列表中是没有这个拦截器的,这就证实了我的想法。


    那现在的问题就是,OkHttp3Interceptor是谁注入的?跟文件上传失败是否有直接的关系?


    OkHttp3Interceptor是谁注入的?


    先来探索第一个问题,通过OkHttp3Interceptor类的包名class com.android.tools.profiler.agent.okhttp,我有以下3点猜测



    • 包名有com.android.tools,应该跟 Android 官方有关系


    • 包名有agent,又是拦截器,应该跟网络代理,也就是网络监控有关


    • 最后一点,也是最重要的,包名有profiler,这让我联想到了Android Studio(以下简称AS)里Profiler网络分析器



    果然,在Google的源码中,真找到了OkHttp3Interceptor类,看看相关代码:


    public final class OkHttp3Interceptor implements Interceptor {

    //省略相关代码
    @Override
    public Response intercept(Interceptor.Chain chain) throws IOException {
    Request request = chain.request();
    HttpConnectionTracker tracker = null;
    try {
    tracker = trackRequest(request); //1、追踪请求体
    } catch (Exception ex) {
    StudioLog.e("Could not track an OkHttp3 request", ex);
    }
    Response response;
    try {
    response = chain.proceed(request);
    } catch (IOException ex) {

    }
    try {
    if (tracker != null) {
    response = trackResponse(tracker, response); //2、追踪响应体
    }
    } catch (Exception ex) {
    StudioLog.e("Could not track an OkHttp3 response", ex);
    }
    return response;
    }

    可以确定它就是一个网络监控器,但它是不是AS的网络监听器,我却还持怀疑态度,因为我这个项目没开启Profiler分析器,但我最近在开发room数据库相关功能,开启了数据分析器Database Inspector,难道跟这个有关?我尝试关掉Database Inspector,并且重启app,再次尝试文件上传,居然成功了,是真的成功了,你能信?我也不信,于是,再次开启Database Inspector,再次尝试文件上传,失败了,异常跟之前的一模一样;接着,我关闭Database Inspector,并且打开Profiler分析器,再次尝试文件上传,一样失败了。


    我想到这里,基本可以认定OkHttp3Interceptor就是Profiler里面的网络监控器,但也好像缺乏直接证据,于是,我尝试改了下ProgressRequestBody类,如下:


    public class ProgressRequestBody extends RequestBody {

    //省略相关代码
    private BufferedSink bufferedSink;

    @Override
    public void writeTo(BufferedSink sink) throws IOException {
    //如果调用方是OkHttp3Interceptor,不写请求体,直接返回
    if (sink.toString().contains(
    "com.android.tools.profiler.support.network.HttpTracker$OutputStreamTracker"))
    return;
    if (bufferedSink == null) {
    bufferedSink = Okio.buffer(sink(sink));
    }
    requestBody.writeTo(bufferedSink);
    bufferedSink.flush();
    }
    }

    以上代码,仅仅加了一句if语句,这条语句可以判断当前调用方是不是OkHttp3Interceptor,是的话,不写请求体,直接返回;如果OkHttp3Interceptor就是Profiler里的网络监控器,那么此时Profiler里应该是看不到请求体的,也就是看不到请求参数,如下:


    image.png


    可以看到,Profiler里的网络监控器,没有监控到请求参数。


    这就证实了OkHttp3Interceptor的确是Profiler里的网络监控器,也就是AS动态注入的。


    OkHttp3Interceptor 与文件上传是否有直接的关系?


    通过上面的案例分析,显然是有直接关系的,当你未打开Database InspectorProfiler时,文件上传一切正常。


    OkHttp3Interceptor是如何影响文件上传的?


    回到正题,OkHttp3Interceptor是如何影响文件上传的?这个就需要继续分析OkHttp3Interceptor的源码,来看看追踪请求体的代码:


    public final class OkHttp3Interceptor implements Interceptor {

    private HttpConnectionTracker trackRequest(Request request) throws IOException {
    StackTraceElement[] callstack =
    OkHttpUtils.getCallstack(request.getClass().getPackage().getName());
    HttpConnectionTracker tracker =
    HttpTracker.trackConnection(request.url().toString(), callstack);
    tracker.trackRequest(request.method(), toMultimap(request.headers()));
    if (request.body() != null) {
    OutputStream outputStream =
    tracker.trackRequestBody(OkHttpUtils.createNullOutputStream());
    BufferedSink bufferedSink = Okio.buffer(Okio.sink(outputStream));
    request.body().writeTo(bufferedSink); // 1、将请求体写入到BufferedSink中
    bufferedSink.close(); // 2、关闭BufferedSink
    }
    return tracker;
    }

    }

    想到这里问题就很清楚了,上面备注的第一代码中request.body(),拿到的就是ProgressRequestBody对象,随后调用其writeTo(BufferedSink)方法,传入BufferedSink对象,方法执行完,就将BufferedSink对象关闭了,然而,ProgressRequestBody里却将BufferedSink声明为成员变量,并且为空时才会赋值,这就导致后续CallServerInterceptor调用其writeTo(BufferedSink)方法时,使用的还是上一个已关闭的BufferedSink对象,此时再往里面写数据,自然就java.lang.IllegalStateException: closed异常了。


    4、如何解决


    知道了具体的原因,就好解决,将ProgressRequestBody里面的BufferedSink对象改为局部变量即可,如下:


    public class ProgressRequestBody extends RequestBody {

    //省略相关代码
    @Override
    public void writeTo(BufferedSink sink) throws IOException {
    BufferedSink bufferedSink = Okio.buffer(sink(sink));
    requestBody.writeTo(bufferedSink);
    bufferedSink.colse();
    }
    }

    改完后,开启Profiler里的网络监控器,再次尝试文件上传,ok成功了,但又有一个新的问题,ProgressRequestBody是用于监听上传进度的,OkHttp3InterceptorCallServerInterceptor先后调用了其writeTo(BufferedSink)方法,这就会导致请求体写两次,也就是进度监听会收到两遍,而我们真正需要的是CallServerInterceptor调用的那次,咋整?好办,我们前面就判断过调用方是否OkHttp3Interceptor


    于是,做出如下更改:


    public class ProgressRequestBody extends RequestBody {

    //省略相关代码
    @Override
    public void writeTo(BufferedSink sink) throws IOException {
    //如果调用方是OkHttp3Interceptor,直接写请求体,不再通过包装类来处理请求进度
    if (sink.toString().contains(
    "com.android.tools.profiler.support.network.HttpTracker$OutputStreamTracker")) {
    requestBody.writeTo(bufferedSink);
    } else {
    BufferedSink bufferedSink = Okio.buffer(sink(sink));
    requestBody.writeTo(bufferedSink);
    bufferedSink.colse();
    }
    }
    }

    你以为这样就完了?相信很多人都会用到com.squareup.okhttp3:logging-interceptor日志拦截器,当你添加该日志拦截器后,再次上传文件,会发现,进度回调又执行了两遍,为啥?因为该日志拦截器,也会调用ProgressRequestBody#writeTo(BufferedSink)方法,看看代码:


    //省略部分代码
    class HttpLoggingInterceptor @JvmOverloads constructor(
    private val logger: Logger = Logger.DEFAULT
    ) : Interceptor {

    @Throws(IOException::class)
    override fun intercept(chain: Interceptor.Chain): Response {
    val request = chain.request()
    val requestBody = request.body

    if (logHeaders) {
    if (!logBody || requestBody == null) {
    logger.log("--> END ${request.method}")
    } else if (bodyHasUnknownEncoding(request.headers)) {
    logger.log("--> END ${request.method} (encoded body omitted)")
    } else if (requestBody.isDuplex()) {
    logger.log("--> END ${request.method} (duplex request body omitted)")
    } else if (requestBody.isOneShot()) {
    logger.log("--> END ${request.method} (one-shot body omitted)")
    } else {
    val buffer = Buffer()
    //1、这里调用了RequestBody的writeTo方法,并传入了Buffer对象
    requestBody.writeTo(buffer)
    }
    }

    val response: Response
    try {
    response = chain.proceed(request)
    } catch (e: Exception) {
    throw e
    }
    return response
    }

    }

    可以看到,HttpLoggingInterceptor内部也会调用RequestBody#writeTo方法,并传入Buffer对象,到这,我们就好办了,在ProgressRequestBody类增加一个Buffer的判断逻辑即可,如下:


    public class ProgressRequestBody extends RequestBody {

    //省略相关代码
    @Override
    public void writeTo(BufferedSink sink) throws IOException {
    //如果调用方是OkHttp3Interceptor,或者传入的是Buffer对象,直接写请求体,不再通过包装类来处理请求进度
    if (sink instanceof Buffer
    || sink.toString().contains(
    "com.android.tools.profiler.support.network.HttpTracker$OutputStreamTracker")) {
    requestBody.writeTo(bufferedSink);
    } else {
    BufferedSink bufferedSink = Okio.buffer(sink(sink));
    requestBody.writeTo(bufferedSink);
    bufferedSink.colse();
    }
    }
    }

    这样就完了?也不见得,如果后续又遇到什么拦截器调用其writeTo方法,还是会出现进度回调执行两遍的情况,只能在遇到这种情况时,加入对应的判断逻辑


    到这,也许有人会问,为啥不直接判断调用方是不是CallServerInterceptor,是的话监听进度回调,否则,直接写入请求体。想法很好,也是可行的,如下:


    public class ProgressRequestBody extends RequestBody {

    //省略相关代码
    @Override
    public void writeTo(BufferedSink sink) throws IOException {
    //如果调用方是CallServerInterceptor,监听上传进度
    if (sink.toString().contains("RequestBodySink(okhttp3.internal")) {
    BufferedSink bufferedSink = Okio.buffer(sink(sink));
    requestBody.writeTo(bufferedSink);
    bufferedSink.colse();
    } else {
    requestBody.writeTo(bufferedSink);
    }
    }
    }

    但是该方案有个致命的缺陷,如果okhttp未来版本更改了目录结构,ProgressRequestBody类就完全失效。


    两个方案就由大家自己去选择,这里给出ProgressRequestBody完整源码,需要自取


    5、小结


    本案例上传失败的直接原因就是在AS开启了Database Inspector数据库分析器或Profiler网络监控器时,AS就会通过字节码插桩技术,对OkHttpClient#networkInterceptors()方法注入新的字节码,使其多返回一个com.android.tools.profiler.agent.okhttp.OkHttp3Interceptor拦截器(用于监听网络),该拦截器会调用ProgressRequestBody#writeTo(BufferedSink)方法,并传入BufferedSink对象,writeTo方法执行完毕后,立即将BufferedSink对象关闭,在随后的CallServerInterceptor拦截又调用ProgressRequestBody#writeTo(BufferedSink)方法往已关闭的BufferedSink对象写数据,最终导致java.lang.IllegalStateException: closed异常。


    但有个有疑惑,我却未找到答案,那就是为啥开启Database Inspector也会导致AS去监听网络?有知道的小伙伴可以评论区留言。




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

    面试必备:Kotlin线程同步的N种方法

    面试的时候经常会被问及多线程同步的问题,例如: “ 现有 Task1、Task2 等多个并行任务,如何等待全部执行完成后,执行 Task3。” 在 Kotlin 中我们有多种实现方式,本文将所有这些方式做了整理,建议收藏。 1. Thread.join ...
    继续阅读 »

    面试的时候经常会被问及多线程同步的问题,例如:


    “ 现有 Task1、Task2 等多个并行任务,如何等待全部执行完成后,执行 Task3。”


    在 Kotlin 中我们有多种实现方式,本文将所有这些方式做了整理,建议收藏。


    1. Thread.join
    2. Synchronized
    3. ReentrantLock
    4. BlockingQueue
    5. CountDownLatch
    6. CyclicBarrier
    7. CAS
    8. Future
    9. CompletableFuture
    10. Rxjava
    11. Coroutine
    12. Flow


    我们先定义三个Task,模拟上述场景, Task3 基于 Task1、Task2 返回的结果拼接字符串,每个 Task 通过 sleep 模拟耗时: image.png


    val task1: () -> String = {
    sleep(2000)
    "Hello".also { println("task1 finished: $it") }
    }

    val task2: () -> String = {
    sleep(2000)
    "World".also { println("task2 finished: $it") }
    }

    val task3: (String, String) -> String = { p1, p2 ->
    sleep(2000)
    "$p1 $p2".also { println("task3 finished: $it") }
    }



    1. Thread.join()


    Kotlin 兼容 Java,Java 的所有线程工具默认都可以使用。其中最简单的线程同步方式就是使用 Threadjoin()


    @Test
    fun test_join() {
    lateinit var s1: String
    lateinit var s2: String

    val t1 = Thread { s1 = task1() }
    val t2 = Thread { s2 = task2() }
    t1.start()
    t2.start()

    t1.join()
    t2.join()

    task3(s1, s2)

    }



    2. Synchronized


    使用 synchronized 锁进行同步


    	@Test
    fun test_synchrnoized() {
    lateinit var s1: String
    lateinit var s2: String

    Thread {
    synchronized(Unit) {
    s1 = task1()
    }
    }.start()
    s2 = task2()

    synchronized(Unit) {
    task3(s1, s2)
    }

    }

    但是如果超过三个任务,使用 synchrnoized 这种写法就比较别扭了,为了同步多个并行任务的结果需要声明n个锁,并嵌套n个 synchronized




    3. ReentrantLock


    ReentrantLock 是 JUC 提供的线程锁,可以替换 synchronized 的使用


    	@Test
    fun test_ReentrantLock() {

    lateinit var s1: String
    lateinit var s2: String

    val lock = ReentrantLock()
    Thread {
    lock.lock()
    s1 = task1()
    lock.unlock()
    }.start()
    s2 = task2()

    lock.lock()
    task3(s1, s2)
    lock.unlock()

    }

    ReentrantLock 的好处是,当有多个并行任务时是不会出现嵌套 synchrnoized 的问题,但仍然需要创建多个 lock 管理不同的任务,


    4. BlockingQueue


    阻塞队列内部也是通过 Lock 实现的,所以也可以达到同步锁的效果


    	@Test
    fun test_blockingQueue() {

    lateinit var s1: String
    lateinit var s2: String

    val queue = SynchronousQueue<Unit>()

    Thread {
    s1 = task1()
    queue.put(Unit)
    }.start()

    s2 = task2()

    queue.take()
    task3(s1, s2)
    }

    当然,阻塞队列更多是使用在生产/消费场景中的同步。




    5. CountDownLatch


    JUC 中的锁大都基于 AQS 实现的,可以分为独享锁和共享锁。ReentrantLock 就是一种独享锁。相比之下,共享锁更适合本场景。 例如 CountDownLatch,它可以让一个线程一直处于阻塞状态,直到其他线程的执行全部完成:


    	@Test
    fun test_countdownlatch() {

    lateinit var s1: String
    lateinit var s2: String
    val cd = CountDownLatch(2)
    Thread() {
    s1 = task1()
    cd.countDown()
    }.start()

    Thread() {
    s2 = task2()
    cd.countDown()
    }.start()

    cd.await()
    task3(s1, s2)
    }

    共享锁的好处是不必为了每个任务都创建单独的锁,即使再多并行任务写起来也很轻松




    6. CyclicBarrier


    CyclicBarrier 是 JUC 提供的另一种共享锁机制,它可以让一组线程到达一个同步点后再一起继续运行,其中任意一个线程未达到同步点,其他已到达的线程均会被阻塞。


    CountDownLatch 的区别在于 CountDownLatch 是一次性的,而 CyclicBarrier 可以被重置后重复使用,这也正是 Cyclic 的命名由来,可以循环使用


    	@Test
    fun test_CyclicBarrier() {

    lateinit var s1: String
    lateinit var s2: String
    val cb = CyclicBarrier(3)

    Thread {
    s1 = task1()
    cb.await()
    }.start()

    Thread() {
    s2 = task1()
    cb.await()
    }.start()

    cb.await()
    task3(s1, s2)

    }



    7. CAS


    AQS 内部通过自旋锁实现同步,自旋锁的本质是利用 CompareAndSwap 避免线程阻塞的开销。 因此,我们可以使用基于 CAS 的原子类计数,达到实现无锁操作的目的。


     	@Test
    fun test_cas() {

    lateinit var s1: String
    lateinit var s2: String

    val cas = AtomicInteger(2)

    Thread {
    s1 = task1()
    cas.getAndDecrement()
    }.start()

    Thread {
    s2 = task2()
    cas.getAndDecrement()
    }.start()

    while (cas.get() != 0) {}

    task3(s1, s2)

    }

    while 循环空转看起来有些浪费资源,但是自旋锁的本质就是这样,所以 CAS 仅仅适用于一些cpu密集型的短任务同步。




    volatile


    看到 CAS 的无锁实现,也许很多人会想到 volatile, 是否也能实现无锁的线程安全?


     	@Test
    fun test_Volatile() {
    lateinit var s1: String
    lateinit var s2: String

    Thread {
    s1 = task1()
    cnt--
    }.start()

    Thread {
    s2 = task2()
    cnt--
    }.start()

    while (cnt != 0) {
    }

    task3(s1, s2)

    }

    注意,这种写法是错误的 volatile 能保证可见性,但是不能保证原子性,cnt-- 并非线程安全,需要加锁操作




    8. Future


    上面无论有锁操作还是无锁操作,都需要定义两个变量s1s2记录结果非常不方便。 Java 1.5 开始,提供了 CallableFuture ,可以在任务执行结束时返回结果。


    @Test
    fun test_future() {

    val future1 = FutureTask(Callable(task1))
    val future2 = FutureTask(Callable(task2))

    Executors.newCachedThreadPool().execute(future1)
    Executors.newCachedThreadPool().execute(future2)

    task3(future1.get(), future2.get())

    }

    通过 future.get(),可以同步等待结果返回,写起来非常方便




    9. CompletableFuture


    future.get() 虽然方便,但是会阻塞线程。 Java 8 中引入了 CompletableFuture ,他实现了 Future 接口的同时实现了 CompletionStage 接口。 CompletableFuture 可以针对多个 CompletionStage 进行逻辑组合、实现复杂的异步编程。 这些逻辑组合的方法以回调的形式避免了线程阻塞:


    @Test
    fun test_CompletableFuture() {
    CompletableFuture.supplyAsync(task1)
    .thenCombine(CompletableFuture.supplyAsync(task2)) { p1, p2 ->
    task3(p1, p2)
    }.join()
    }



    10. RxJava


    RxJava 提供的各种操作符以及线程切换能力同样可以帮助我们实现需求: zip 操作符可以组合两个 Observable 的结果;subscribeOn 用来启动异步任务


    @Test
    fun test_Rxjava() {

    Observable.zip(
    Observable.fromCallable(Callable(task1))
    .subscribeOn(Schedulers.newThread()),
    Observable.fromCallable(Callable(task2))
    .subscribeOn(Schedulers.newThread()),
    BiFunction(task3)
    ).test().awaitTerminalEvent()

    }



    11. Coroutine


    前面讲了那么多,其实都是 Java 的工具。 Coroutine 终于算得上是 Kotlin 特有的工具了:


    @Test
    fun test_coroutine() {

    runBlocking {
    val c1 = async(Dispatchers.IO) {
    task1()
    }

    val c2 = async(Dispatchers.IO) {
    task2()
    }

    task3(c1.await(), c2.await())
    }
    }

    写起来特别舒服,可以说是集前面各类工具的优点于一身。




    12. Flow


    Flow 就是 Coroutine 版的 RxJava,具备很多 RxJava 的操作符,例如 zip:



    @Test
    fun test_flow() {

    val flow1 = flow<String> { emit(task1()) }
    val flow2 = flow<String> { emit(task2()) }

    runBlocking {
    flow1.zip(flow2) { t1, t2 ->
    task3(t1, t2)
    }.flowOn(Dispatchers.IO)
    .collect()

    }

    }

    flowOn 使得 Task 在异步计算并发射结果。




    总结


    上面这么多方式,就像茴香豆的“茴”字的四种写法,没必要都掌握。作为结论,在 Kotlin 上最好用的线程同步方案首推协程!



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

    iOS-汇编-指针、OC

    局部变量&全局变量int global = 10; int main(int argc, char * argv[]) { int a = 20; int b = global + 1; return UIApplicatio...
    继续阅读 »

    编译器优化

    局部变量&全局变量

    int global = 10;

    int main(int argc, char * argv[]) {
    int a = 20;
    int b = global + 1;
    return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));
    }

    在不进行优化的情况下:


    改成Fastest、Smallest模式,ab都被优化掉了。




    局部变量和全局变量会被优化掉。

    函数

    int func(int a,int b) {
    return a + b;
    }

    int main(int argc, char * argv[]) {
    int value = func(10, 20);
    return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));
    }


    func函数也会被优化掉,因为对程序的执行结果没有影响。
    修改下:


    int func(int a,int b) {
    return a + b;
    }

    int main(int argc, char * argv[]) {
    int value = func(10, 20);
    NSLog(@"%d",value);
    return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));
    }





    可以看到直接将0x1e结果入栈。一样也会被优化。

    编译配置(Optimization Level)


    编译器(从c汇编的编译过程)的优化配置(指定被编译代码的执行速度和二进制文件大小的优化程度)。优化后的代码效率比较高,但是可读性比较差,且编译时间更长。
    优化等级配置在Build Settings -> Apple Clang - Code Generation -> Optimization Level 中:




  • None [-O0]:不优化。
    编译器的目标是降低编译消耗,保证调试时输出期望的结果。程序的语句之间是独立的:如果在程序的停在某一行的断点出,我们可以给任何变量赋新值抑或是将程序计数器指向方法中的任何一个语句,并且能得到一个和源码完全一致的运行结果。
  • Fast [-O, O1]: 大函数所需的编译时间和内存消耗都会稍微增加。
    在这种设置下,编译器会尝试减小代码文件的大小,减少执行时间,但并不执行需要大量编译时间的优化。在苹果的编译器中,在优化过程中,严格别名,块重排和块间的调度都会被默认禁止掉。此优化级别提供了良好的调试体验,堆栈使用率也提高,并且代码质量优于None[-O0]。
  • Faster [-O2]:编译器执行所有不涉及时间空间交换的所有的支持的优化选项。
    更高的性能优化Fast[-O1]。在这种设置下,编译器不会进行循环展开、函数内联或寄存器重命名。和‘Fast[-O1]’项相比,此设置会增加编译时间和生成代码的性能。
  • Fastest [-O3]:在开启Fast[-O1]项支持的所有优化项的同时,开启函数内联和寄存器重命名选项
    是更高的性能优化Faster[-O2],指示编译器优化所生成代码的性能,而忽略所生成代码的大小,有可能会导致二进制文件变大。还会降低调试体验。
  • Fastest, Smallest [-Os]:在不显着增加代码大小的情况下尽量提供高性能
    这个设置开启了Fast[-O1]项中的所有不增加代码大小的优化选项,并会进一步的执行可以减小代码大小的优化。增加的代码大小小于Fastest[-O3]。与Fast[-O1]相比,它还会降低调试体验。
  • Fastest, Aggressive Optimizations [-Ofast]:与Fastest, Smallest[-Os]相比该级别还执行其他更激进的优化
    这个设置开启了Fastest[-O3]中的所有优化选项,同时也开启了可能会打破严格编译标准的积极优化,但并不会影响运行良好的代码。该级别会降低调试体验,并可能导致代码大小增加。
  • Smallest, Aggressive Size Optimizations [-Oz]:不使用LTO的情况下减小代码大小
    与-Os相似,指示编译器仅针对代码大小进行优化,而忽略性能优化,这可能会导致代码变慢。



  • XcodeDebug模式默认为None[-O0]Release默认为Fastest, Smallest[-Os]

    指针

    指针在汇编中只是地址, 在底层来说就是数据。

    指针基本常识

    指针的宽度为8字节。

    void func() {
    //指针的宽度8字节
    int *a;
    printf("%lu",sizeof(a));
    }

    int main(int argc, char * argv[]) {
    func();
    return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));
    }


    • sizeof:是个符号,操作符。这里验证了是常量8

    指针的运算

    指针++

    int型指针++:

    int *a;
    a = (int *)100;
    a++;

    运算结果:104int4字节。

    char型指针++:

    char *a;
    a = (char *)100;
    a++;

    运算结果:101char1字节。

    指向指针的指针++:

    int **a;
    a = (int **)100;
    a++;

    运算结果:108,指针占8字节

    指针+

    int **a;
    a = (int **)100;
    a = a + 1;

    运算结果:108。与a++++、--与编译器有关)等价。

    指针-

    int *a;
    a = (int *)100;
    int *b;
    b = (int *)200;
    int x = a - b; // a/4 - b/4 = -25


    运算结果:-25。(a/4 - b/4 = -25

    • 指针的运算与指向的数据类型宽度(步长)有关。
    • 指针的运算单位是执行的数据类型的宽度。
    • 结构体和基本类型不能强制转换,普通类型可以通过&

    指针的反汇编

    void func() {
    int* a;
    int b = 10;
    a = &b;
    }

    对应的汇编:

    TestDemo`func:
    0x10098a1c4 <+0>: sub sp, sp, #0x10 ; =0x10
    //sp+0x4 x8
    0x10098a1c8 <+4>: add x8, sp, #0x4 ; =0x4
    //1O给w9
    0x10098a1cc <+8>: mov w9, #0xa
    //w9 入栈
    -> 0x10098a1d0 <+12>: str w9, [sp, #0x4]
    //x8 指向 sp+0x8。相当于x8指向sp,也就是指向10的地址
    0x10098a1d4 <+16>: str x8, [sp, #0x8]

    0x10098a1d8 <+20>: add sp, sp, #0x10 ; =0x10
    0x10098a1dc <+24>: ret

    [sp, #0x8]是个指针变量。从0x8~0x10保存的就是指针。

    数组和指针

    void func() {
    int arr[5] = {1,2,3,4,5};
    //int *a == &arr[0] == arr
    int *a = arr;
    for (int i = 0; i < 5; i++) {
    printf("%d\n",arr[i]);
    printf("%d\n",*(arr + i));
    // printf("%d\n",*(arr++));
    printf("%d\n",*(a++));
    }
    }

    *(arr++) 会报错。int *a = arr; 之后 a++就没问题了、

    • 数组名和指针变量是一样的,唯一的区别是一个是常量,一个是变量。
      int *a == &arr[0] == arr

    指针的基本用法

    void func() {
    char *p1;
    char c = *p1;
    printf("%c",c);
    }



    p1由于是个指针,没有初始化编译不会报错,运行会报错。在iOS中默认是0,运行会直接野指针。

    指向char的指针+0

    void func() {
    char *p1;
    char c = *p1;
    char d = *(p1 + 0);
    }


    对应汇编

    TestDemo`func:
    0x104b661bc <+0>: sub sp, sp, #0x10 ; =0x10
    //p1 -> 0x0 x8指向p1
    0x104b661c0 <+4>: ldr x8, [sp, #0x8]
    //c = [x8] 给到 w9
    -> 0x104b661c4 <+8>: ldrb w9, [x8]
    0x104b661c8 <+12>: strb w9, [sp, #0x7]
    0x104b661cc <+16>: ldr x8, [sp, #0x8]
    //d = [x8] 给到 w9
    0x104b661d0 <+20>: ldrb w9, [x8]
    0x104b661d4 <+24>: strb w9, [sp, #0x6]

    0x104b661d8 <+28>: add sp, sp, #0x10 ; =0x10
    0x104b661dc <+32>: ret

    指向char的指针+1

    void func() {
    char *p1;//指针 -> x8 0x0
    char c = *p1;// [x8]
    char d = *(p1 + 1);//[x8, #0x1]
    }

    对应汇编:

    TestDemo`func:
    0x1041f21bc <+0>: sub sp, sp, #0x10 ; =0x10
    //p1
    0x1041f21c0 <+4>: ldr x8, [sp, #0x8]
    //c
    -> 0x1041f21c4 <+8>: ldrb w9, [x8]
    0x1041f21c8 <+12>: strb w9, [sp, #0x7]
    0x1041f21cc <+16>: ldr x8, [sp, #0x8]
    //d
    0x1041f21d0 <+20>: ldrb w9, [x8, #0x1]
    0x1041f21d4 <+24>: strb w9, [sp, #0x6]
    0x1041f21d8 <+28>: add sp, sp, #0x10 ; =0x10
    0x1041f21dc <+32>: ret

    指向int的指针+1

    void func() {
    int *p1;//指针 -> x8 0x0
    int c = *p1;// [x8]
    int d = *(p1 + 1);//[x8, #0x4]
    }
    TestDemo`func:
    0x1040e61bc <+0>: sub sp, sp, #0x10 ; =0x10
    //p1 [x8]
    0x1040e61c0 <+4>: ldr x8, [sp, #0x8]
    //c
    -> 0x1040e61c4 <+8>: ldr w9, [x8]
    0x1040e61c8 <+12>: str w9, [sp, #0x4]
    0x1040e61cc <+16>: ldr x8, [sp, #0x8]
    //d
    0x1040e61d0 <+20>: ldr w9, [x8, #0x4]
    0x1040e61d4 <+24>: str w9, [sp]
    0x1040e61d8 <+28>: add sp, sp, #0x10 ; =0x10
    0x1040e61dc <+32>: ret

    指向int的指针的指针+1

    void func() {
    int **p1;//指针 -> x8 0x0
    int *c = *p1;// [x8]
    int *d = *(p1 + 1);//[x8, #0x8]
    }
    TestDemo`func:
    0x1041821b8 <+0>: sub sp, sp, #0x20 ; =0x20
    //p1 [x8]
    0x1041821bc <+4>: ldr x8, [sp, #0x18]
    //c
    -> 0x1041821c0 <+8>: ldr x8, [x8]
    0x1041821c4 <+12>: str x8, [sp, #0x10]
    0x1041821c8 <+16>: ldr x8, [sp, #0x18]
    //d
    0x1041821cc <+20>: ldr x8, [x8, #0x8]
    0x1041821d0 <+24>: str x8, [sp, #0x8]
    0x1041821d4 <+28>: add sp, sp, #0x20 ; =0x20
    0x1041821d8 <+32>: ret

    这里拉伸了#0x2016字节对齐。

    指向指针的指针

    void func() {
    char **p1;
    char c = **p1;
    }

    取地址的地址在汇编中:

    TestDemo`func:
    0x102cf61c4 <+0>: sub sp, sp, #0x10 ; =0x10
    //初始值
    0x102cf61c8 <+4>: ldr x8, [sp, #0x8]
    //两次ldr,二级指针在寻址
    -> 0x102cf61cc <+8>: ldr x8, [x8]
    0x102cf61d0 <+12>: ldrb w9, [x8]

    0x102cf61d4 <+16>: strb w9, [sp, #0x7]
    0x102cf61d8 <+20>: add sp, sp, #0x10 ; =0x10
    0x102cf61dc <+24>: ret

    两次ldr,二级指针在寻址。


    指针的指针&指针混合偏移

    void func() {
    char **p1;
    char c = *(*(p1 + 2) + 2); // [0x10 + 0x2]
    }

    p1偏移 (2 * 指针) +(2 * char)


    void func() {
    char **p1;
    char c = *(*(p1 + 2) + 2); // [0x10 + 0x2]
    char c2 = p1[1][2]; // [0x8 + 0x2]
    }

    p1[1][2]等价于*(*(p1 + 1) + 2)


    OC反汇编

    创建一个简单的Hotpot类:

    //Hotpot.h
    @interface Hotpot : NSObject

    @property (nonatomic, copy) NSString *name;
    @property (nonatomic, assign) int age;

    + (instancetype)hotpot;

    @end

    //Hotpot.m
    #import "Hotpot.h"

    @implementation Hotpot

    + (instancetype)hotpot {
    return [[self alloc] init];
    }

    @end

    main.m中调用:

    #import "Hotpot.h"

    int main(int argc, char * argv[]) {
    Hotpot *hp = [Hotpot hotpot];
    return 0;
    }

    对应的汇编代码:




    我们都知道OC方法objc_msgSend默认有两个参数self cmd,分别是idSEL类型。
    验证下:

    (lldb) x 0x1027c95b0
    0x1027c95b0: f8 95 7c 02 01 00 00 00 20 96 7c 02 01 00 00 00 ..|..... .|.....
    0x1027c95c0: 08 00 00 00 10 00 00 00 08 00 00 00 00 00 00 00 ................
    (lldb) po 0x01027c95f8
    Hotpot

    (lldb) x 0x1027c95a0
    0x1027c95a0: bd 65 7c 02 01 00 00 00 a8 af b3 df 01 00 00 00 .e|.............
    0x1027c95b0: f8 95 7c 02 01 00 00 00 20 96 7c 02 01 00 00 00 ..|..... .|.....
    (lldb) po (SEL)0x01027c65bd
    "hotpot"

    (lldb) register read x0
    x0 = 0x00000001027c95f8 (void *)0x00000001027c95d0: Hotpot
    (lldb) register read x1
    x1 = 0x00000001027c65bd "hotpot"
    (lldb)

    接着进入hotpot方法中,对应汇编如下:



    发现没有走objc_msgSend方法,直接走了objc_alloc_init方法。

    ⚠️:这块和支持的最低版本有关。
    iOS9中为objc_msgSend 和 objc_msgSend对应allocinit
    iOS11中为objc_alloc 和 objc_msgSend,这里优化了alloc直接调用了objc_alloc,没有调用objc_msgSend
    iOS13中为objc_alloc_init,这里同时优化了allocinit

    hotpot方法执行完毕后会返回实例对象:




    在下面调用了一个objc_storeStrong函数(OC中用strong修饰的函数都会调用这个函数,例子中hp局部变量默认就是__strong)。objc_storeStrong调用后如果被外部引用引用计数+1,否则就销毁。
    objc4-818.2源码中objc_storeStrong源码(在NSObject.mm中):

    void
    objc_storeStrong(id *location, id obj)
    {
    id prev = *location;
    if (obj == prev) {
    return;
    }
    objc_retain(obj);
    *location = obj;
    objc_release(prev);
    }

    这个函数有两个参数 id* 和 id,函数的目的为对strong修饰的对象retain + 1,对旧对象release

        //x8指向 sp + 0x8 地址
    0x1022421a0 <+60>: add x8, sp, #0x8 ; =0x8
    //x8 就是指向x0的地址
    0x1022421a4 <+64>: str x0, [sp, #0x8]
    0x1022421a8 <+68>: stur wzr, [x29, #-0x4]
    0x1022421ac <+72>: mov x0, x8
    0x1022421b0 <+76>: mov x8, #0x0
    0x1022421b4 <+80>: mov x1, x8
    //objc_storeStrong 第一个参数 &hp,第二个参数 0x0
    -> 0x1022421b8 <+84>: bl 0x102242520 ; symbol stub for: objc_storeStrong
    0x1022421bc <+88>: ldur w0, [x29, #-0x4]
    0x1022421c0 <+92>: ldp x29, x30, [sp, #0x20]
    0x1022421c4 <+96>: add sp, sp, #0x30 ; =0x30
    0x1022421c8 <+100>: ret

    调用objc_storeStrong的过程就相当于:

    //分别传入 &hp  和 0x0
    void
    objc_storeStrong(id *location, id obj)
    {
    id prev = *location;//id prev = *hp
    if (obj == prev) {
    return;
    }
    objc_retain(obj);// nil
    *location = obj;// hp 指向第二个对象 hp = nil
    objc_release(prev);//释放老对象 release hp 释放堆空间
    }
    所以这里objc_storeStrong调用为了释放对象。
    objc_storeStrong断点前后验证:

    (lldb) p hp
    (Hotpot *) $3 = 0x000000028014bf60
    (lldb) ni
    (lldb) p hp
    (Hotpot *) $4 = nil
    (lldb)

    单步执行后hp变成了nil

    工具反汇编

    由于大部分情况下OC代码都比较复杂,自己分析起来比较麻烦。我们一般都借助工具来协助反汇编,一般会用到MachoViewHopper,IDA
    将刚才的代码稍作修改:

    #import "Hotpot.h"

    int main(int argc, char * argv[]) {
    Hotpot *hp = [Hotpot hotpot];
    hp.name = @"cat";
    hp.age = 1;
    return 0;
    }

    通过hopper打开macho文件


    可以看到已经自动解析出了方法名和参数,那么编译器是怎么做到呢?

    双击objc_cls_ref_Hotpot会跳转到对应的地址:




    可以看到所有方法都在这块。
    所以在分析汇编代码的时候编译器就能根据工具找到这些字符串。这也就是能还原的原因。

    Block反汇编

    在平时开发中经常会用到block,那么block汇编是什么样子呢?

    int main(int argc, char * argv[]) {
    void(^block)(void) = ^() {
    NSLog(@"block test");
    };
    block();
    return 0;
    }

    一般在反汇编的时候我们希望定位到block的实现(invoke
    对应汇编如下:


    invoke0x102c4e160

    block源码定义如下(Block_private.h):

    struct Block_layout {
    void *isa; //8字节
    volatile int32_t flags; // contains ref count //4字节
    int32_t reserved;//4字节
    BlockInvokeFunction invoke;
    struct Block_descriptor_1 *descriptor;
    // imported variables
    };

    也就是isa往下16字节就是invoke


    hopper中:



    StackBlock

    int main(int argc, char * argv[]) {
    int a = 10;
    void(^block)(void) = ^() {
    NSLog(@"block test:%d",a);
    };
    block();
    return 0;
    }




    验证isainvoke

    (lldb) po 0x100a8c000
    <__NSStackBlock__: 0x100a8c000>
    signature: "<unknown signature>"

    (lldb) x 0x100a8c000
    0x100a8c000: 30 88 ae df 01 00 00 00 94 3f c5 89 01 00 00 00 0........?......
    0x100a8c010: 00 00 00 00 00 00 00 00 24 00 00 00 00 00 00 00 ........$.......
    (lldb) po 0x01dfae8830
    __NSStackBlock__

    (lldb) dis -s 0x100a8a140
    TestOC&BlockASM`__main_block_invoke:
    0x100a8a140 <+0>: sub sp, sp, #0x30 ; =0x30
    0x100a8a144 <+4>: stp x29, x30, [sp, #0x20]
    0x100a8a148 <+8>: add x29, sp, #0x20 ; =0x20
    0x100a8a14c <+12>: stur x0, [x29, #-0x8]
    0x100a8a150 <+16>: str x0, [sp, #0x10]
    0x100a8a154 <+20>: ldr w8, [x0, #0x20]
    0x100a8a158 <+24>: mov x0, x8
    0x100a8a15c <+28>: adrp x9, 2

    invokeimp实现通过dis -s查看汇编实现。

    hopper中:




    global blockblockdescriptor是在一起的,stack block并不在一起。




    作者:HotPotCat
    链接:https://www.jianshu.com/p/e3351311efa8


    收起阅读 »

    iOS 自定义命令行工具

    我们再越狱手机上能用很多工具,尤其是在终端上的一些操作。那么怎么实现一个在iOS终端的命令行工具呢?比如我们将常用的命令封装成自己的一个命令行工具方便自己调用。在这里我以ps -A和debugserver的开启为例。一、工程创建首先用Xcode创建一个iOS ...
    继续阅读 »

    我们再越狱手机上能用很多工具,尤其是在终端上的一些操作。那么怎么实现一个在iOS终端的命令行工具呢?

    比如我们将常用的命令封装成自己的一个命令行工具方便自己调用。在这里我以ps -Adebugserver的开启为例。


    一、工程创建

    首先用Xcode创建一个iOS App,这么做是因为要生成iOS终端可执行的命令行,默认main函数如下:

    #import <UIKit/UIKit.h>
    #import "AppDelegate.h"

    int main(int argc, char * argv[]) {
    NSString * appDelegateClassName;
    @autoreleasepool {
    // Setup code that might create autoreleased objects goes here.
    appDelegateClassName = NSStringFromClass([AppDelegate class]);
    }
    return UIApplicationMain(argc, argv, nil, appDelegateClassName);
    }
    这样工程就创建好了,接下来就是功能的实现了。当然可以根据自己的需要配置自己支持的架构等相关内容。

    二、main函数

    2.1 main函数精简

    由于是制作命令行工具,所以界面相关的内容都删除,只保留main函数。精简后如下:


    #import <Foundation/Foundation.h>
    /**
    @param argc 入参个数
    @param argv 入参数组 argv[0] 为可执行文件
    */

    int main(int argc, char * argv[]) {
    @autoreleasepool {
    //根据自己的需要做逻辑处理
    }
    return 0;
    }

    2.2 main框架

    首先实现基本的框架,我们需要的功能一个是列出所有进程,一个是启动手机端debugserver。后续可能还会扩展更多功能并且为了方便使用需要加入一个help和容错处理。那么就有了:

    • help函数提供说明帮助。
    • runPS实现ps -A列出所有进程。
    • runDebugServer实现开启手机端runDebugServer功能。
    实现代码如下:

    /**
    @param argc 入参个数
    @param argv 入参数组 argv[0] 为可执行文件
    */

    int main(int argc, char * argv[]) {
    @autoreleasepool {
    if (argc == 1 || strcmp(argv[1], "-h") == 0 || strcmp(argv[1], "--help") == 0) {
    //help();
    } else {
    if (strcmp(argv[1], "-p") == 0 || strcmp(argv[1], "--process") == 0) {
    runPS();
    } else if ((strcmp(argv[1], "-d") == 0 || strcmp(argv[1], "--debugserver") == 0) && argc > 2 && argv[2] != NULL) {
    runDebugServer(argv[2]);
    } else {
    printf("illegal option:%s\n",argv[1]);
    printf("Try 'HPCMD --help' for more information. \n");
    }
    }
    }
    return 0;
    }

    main函数有两个参数:

    • argc:参数个数。
    • argv:入参数组,这个入参数组第一个参数argv[0]就是可执行文件本身。

    三、如何代码调用shell命令。


    查询资料得知有3种方式:

    • 1.system函数,目前已经被废弃。不过应该可以找到函数地址去尝试直接调用。
      1. NSTask,不过这个只能用在macOS中,如果写macOS终端命令行工具可以用这个。
      1. posix_spawn目前也只有这个能用了。在#include <spawn.h>中。

    posix_spawn函数定义如下:


    int     posix_spawn(pid_t * __restrict, const char * __restrict,
    const posix_spawn_file_actions_t *,
    const posix_spawnattr_t * __restrict,
    char *const __argv[__restrict],
    char *const __envp[__restrict]) __API_AVAILABLE(macos(10.5), ios(2.0)) __API_UNAVAILABLE(watchos, tvos);

    posix_spawn函数一共6个参数

    • pid_t:子进程pidpid 参数指向一个缓冲区,该缓冲区用于返回新的子进程的进程ID
    • const char * :可执行文件的路径path(其实就是可以调用某些系统命令,只不过要指定其完整路径)
    • posix_spawn_file_actions_tfile_actions 参数指向生成文件操作对象,该对象指定要在子对象之间执行的与文件相关的操作
    • posix_spawnattr_tattrp 指向一个属性对象,该对象指定创建的子进程的各种属性。
    • argv:指定在子进程中执行的程序的参数列表
    • envp:指定在子进程中执行的程序的环境

    这里简单封装runCMD函数如下:


    /*
    posix_spawn 函数一共6个参数
    pid_t:子进程 pid(pid 参数指向一个缓冲区,该缓冲区用于返回新的子进程的进程ID)
    const char * :可执行文件的路径 path(其实就是可以调用某些系统命令,只不过要指定其完整路径)
    posix_spawn_file_actions_t:file_actions 参数指向生成文件操作对象,该对象指定要在子对象之间执行的与文件相关的操作
    posix_spawnattr_t:attrp 指向一个属性对象,该对象指定创建的子进程的各种属性。
    argv:指定在子进程中执行的程序的参数列表
    envp:指定在子进程中执行的程序的环境
    */

    #include <spawn.h>

    int runCMD(char *cmd, char *argv[]) {
    pid_t pid;
    //这里注意 cmd 也要包含在 argv[0]中传入。
    posix_spawn(&pid, cmd, NULL, NULL, argv, NULL);
    int stat;
    waitpid(pid,&stat,0);
    printf("run cmd:%s stat:%d\n",cmd,stat);
    return stat;
    }

    四、功能实现

    4.1 help实现


    //打印help信息
    void help() {
    printf("-p:--process 显示进程 (等效ps -A) \n");
    printf("-d:<--debugserver 应用名称/进程id>开启debugserver (等效 debugserver localhost:12346 -a 进程名/进程id) \n");
    printf("-h:--help \n");
    }

    4.2 runPS实现

    void runPS() {
    char *CMD_argv[] = {
    "/usr/bin/ps",
    "-A",
    NULL
    };
    //ps -A
    runCMD(CMD_argv[0],CMD_argv);
    }

    4.3 runDebugServer 实现

    //debugserver localhost:12346 -a 进程名
    void runDebugServer(char *process) {
    printf("process:%s\n",process);
    char *CMD_argv[5] = {
    "/usr/bin/debugserver",
    "localhost:12346",
    "-a",
    NULL,
    NULL
    };
    CMD_argv[3] = process;
    runCMD(CMD_argv[0],CMD_argv);
    }

    这里需要注意的是最后一个参数要为NULL

    这样整个功能就全部完成。


    五、运行


    1.由于创建的是App工程,编译生成App后将其中的MachO文件拷贝出来。
    2.将可执行文件拷贝到手机根目录

    scp -P 12345 ./HPCMD root@localhost:~/
    3.手机端执行HPCMD
    -h:

    zaizai:~ root# ./HPCMD -h
    -p:--process 显示进程 (等效ps -A)
    -d:<--debugserver 应用名称/进程id>开启debugserver (等效 debugserver localhost:12346 -a 进程名/进程id)
    -h:--help
    -p:
    zaizai:~ root# ./HPCMD -p
    PID TTY TIME CMD
    1 ?? 17:09.03 /sbin/launchd
    295 ?? 5:41.90 /usr/libexec/substituted
    296 ?? 0:00.00 (amfid)
    1585 ?? 0:00.00 /usr/libexec/amfid
    1600 ?? 412:41.57 /usr/sbin/mediaserverd

    -d:
    zaizai:~ root# ./HPCMD -d WeChat
    process:WeChat
    debugserver-@(#)PROGRAM:LLDB PROJECT:lldb-1200.2.12
    for arm64.
    Attaching to process WeChat...
    Listening to port 12346 for a connection from localhost...
    -s:

    zaizai:~ root# ./HPCMD -s
    illegal option:-s
    Try 'HPCMD --help' for more information.

    这样就验证完整个cmd的功能了。

    可以根据自己的需求实现自己的自定义命令行工具,当然对于一些其它操作需要更多权限可以直接导出系统的SpringBoard可执行文件从而导出它的权限文件用ldid重签自己的命令行工具



    作者:HotPotCat
    链接:https://www.jianshu.com/p/d7f0eca98198

    收起阅读 »

    什么是spring,它能够做什么?

    1.什么是Spring Spring是一个开源框架,它由Rod Johnson创建。它是为了解决企业应用开发的复杂性而创建的。    Spring使用基本的JavaBean来完成以前只可能由EJB完成的事情。   然而,Spr...
    继续阅读 »

    1.什么是Spring


    Spring是一个开源框架,它由Rod Johnson创建。它是为了解决企业应用开发的复杂性而创建的。


       Spring使用基本的JavaBean来完成以前只可能由EJB完成的事情。
      然而,Spring的用途不仅限于服务器端的开发。从简单性、可测试性和松耦合的角度而言,任何Java应用都可以从Spring中受益。
       目的:解决企业应用开发的复杂性
       功能:使用基本的JavaBean代替EJB,并提供了更多的企业应用功能
       范围:任何Java应用


       它是一个容器框架,用来装javabean(java对象),中间层框架(万能胶)可以起一个连接作用,比如说把Struts和hibernate粘合在一起运用。简单来说,Spring是一个轻量级的控制反转(IoC)和面向切面(AOP)的容器框架。


    2. 什么是控制反转(或依赖注入) 


       控制反转(IoC=Inversion of Control)IoC,用白话来讲,就是由容器控制程序之间的(依赖)关系,而非传统实现中,由程序代码直接操控。这也就是所谓“控制反转”的概念所在:(依赖)控制权由应用代码中转到了外部容器,控制权的转移,是所谓反转。
       IoC还有一个另外的名字:“依赖注入 (DI=Dependency Injection)”  ,即由容器动态的将某种依赖关系注入到组件之中 ,案例:实现Spring的IoC

    第一步:需要添加springIDE插件,配置相关依赖(插件如何安装点击打开链接


    pom.xml  (1.spring-context   2.spring-orm  3.spring-web  4.spring-aspects)


    <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <groupId>zking</groupId>
    <artifactId>s1</artifactId>
    <packaging>war</packaging>
    <version>0.0.1-SNAPSHOT</version>
    <name>s1 Maven Webapp</name>
    <url>http://maven.apache.org</url>
    <dependencies>
    <dependency>
    <groupId>junit</groupId>
    <artifactId>junit</artifactId>
    <version>3.8.1</version>
    <scope>test</scope>
    </dependency>

    <dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-context</artifactId>
    <version>5.0.1.RELEASE</version>
    </dependency>

    <dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-web</artifactId>
    <version>5.0.1.RELEASE</version>
    </dependency>
    <dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-orm</artifactId>
    <version>5.0.1.RELEASE</version>
    </dependency>

    <dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-aspects</artifactId>
    <version>5.0.1.RELEASE</version>
    </dependency>
    </dependencies>
    <build>
    <finalName>s1</finalName>
    </build>
    </project>

    第二步:插件Spring的xml文件(右键-->new-->other-->spring-->Spring Bean Configuration File)


    注:创建spring的XML文件时,需要添加beans/aop/tx/context标签支持(勾上即可)


    ApplicationContext.xml


     


    <?xml version="1.0" encoding="UTF-8"?>
    <beans xmlns="http://www.springframework.org/schema/beans"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xmlns:aop="http://www.springframework.org/schema/aop"
    xmlns:context="http://www.springframework.org/schema/context"
    xmlns:tx="http://www.springframework.org/schema/tx"
    xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd
    http://www.springframework.org/schema/aop http://www.springframework.org/schema/aop/spring-aop-4.3.xsd
    http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context-4.3.xsd
    http://www.springframework.org/schema/tx http://www.springframework.org/schema/tx/spring-tx-4.3.xsd">


    </beans>

    第三步:创建一个helloworld类


    package p1;

    import org.springframework.context.ApplicationContext;
    import org.springframework.context.support.ClassPathXmlApplicationContext;

    public class HelloWorld {
    private String name;

    public HelloWorld() {
    super();
    System.out.println("new HelloWorld()");
    }

    public HelloWorld(String name) {
    super();
    this.name = name;
    }

    public void init() {
    System.out.println("init.......");
    }
    public String getName() {
    return name;
    }

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

    3. 如何在spring当中定义和配置一个JavaBean


    使用无参构造方法+set方法创建一个JavaBean


       1 id:在容器中查找Bean(对象)的id(唯一、且不能以/开头)
       2 class:bean(对象)的完整类名
       3 name:在容器中查找Bean(对象)的名字(唯一、允许以/开头、允许多个值,多个值之间用逗号或空格隔开)
       4 scope:(singleton|prototype)默认是singleton
         4.1 singleton(单例模式):在每个Spring IoC容器中一个bean定义对应一个对象实例
         4.2 prototype(原型模式/多例模式):一个bean(对象)定义对应多个对象实例
       4 abstract:将一个bean定义成抽象bean(抽象bean是不能实例化的),抽象类一定要定义成抽象bean,非抽象类也可以定义成抽象bean
       5 parent:指定一个父bean(必须要有继承关系才行)
       6 init-method:指定bean对象()的初始化方法


       7 使用有参数构造方法创建javaBean(java对象):constructor-arg


    第四步:在xml中创建bean(看不懂属性的,在第三点中有介绍)


    <bean id="helloworld" class="p1.HelloWorld" scope="prototype" name="a b c" init-method="init">
    <property name="name">
    <value>zs</value>
    </property>
    </bean>

    <bean id="helloworld2" class="p1.HelloWorld">
    <constructor-arg index="0">
    <value>zzz</value>
    </constructor-arg>
    </bean>

    第五步:写一个测试的类即可


    public static void main(String[] args) {
    //以前的写法
    HelloWorld helloWorld=new HelloWorld();
    helloWorld.setName("张三");
    System.out.println("hello"+helloWorld.getName());
    //-------------------------------------------------------------
    //Spring
    ApplicationContext applicationContext=new ClassPathXmlApplicationContext("ApplicationContext.xml");
    HelloWorld a = (HelloWorld)applicationContext.getBean("a");
    System.out.println("你好: "+a.getName());

    HelloWorld b = (HelloWorld)applicationContext.getBean("b");
    System.out.println("你好: "+b.getName());

    HelloWorld c = (HelloWorld)applicationContext.getBean("c");
    System.out.println("你好: "+c.getName());

    HelloWorld d = (HelloWorld)applicationContext.getBean("helloworld2");
    System.out.println("--------------------------------");
    System.out.println("你好: "+d.getName());
    }

    4. 简单属性的配置:


       8+1+3:8大基本数据类型+String+3个sql
                           java.util.Date      java.sql.Date    java.sql.Time    java.sql.Timestamp
       通过<value>标签赋值即可


    5. 复杂属性的配置


      5.1 JavaBean    ref bean=""
      5.2 List或数组
      5.3 Map
      5.4 Properties


    创建一个学生类(Student),定义这几个属性


    private HelloWold helloworld;

    private String []arr;
    private List list;
    private Map map;
    private Properties properties;

    在xml配置进行配置


    <?xml version="1.0" encoding="UTF-8"?>
    <beans xmlns="http://www.springframework.org/schema/beans"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xmlns:aop="http://www.springframework.org/schema/aop"
    xmlns:context="http://www.springframework.org/schema/context"
    xmlns:tx="http://www.springframework.org/schema/tx"
    xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd
    http://www.springframework.org/schema/aop http://www.springframework.org/schema/aop/spring-aop-4.3.xsd
    http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context-4.3.xsd
    http://www.springframework.org/schema/tx http://www.springframework.org/schema/tx/spring-tx-4.3.xsd">
    <bean id="helloworlds" class="p1.HelloWold">
    <property name="name">
    <value>张三</value>
    </property>

    </bean>
    <bean id="ss" class="p1.Student">
    <property name="helloworld">
    <ref bean="helloworlds"><!-- ref引用另一个对象 -->
    </property>

    <property name="arr">
    <list>
    <value>aa</value>
    <value>bb</value>
    <value>cc</value>
    <value>dd</value>
    </list>
    </property>
    <property name="list">
    <list>
    <value>11</value>
    <value>22</value>
    <value>33</value>
    </list>
    </property>
    <property name="map">
    <map>
    <entry>
    <key>
    <value>zs</value>
    </key>
    <value>张三</value>
    </entry>
    <entry>
    <key>
    <value>ls</value>
    </key>
    <value>李四</value>
    </entry>
    <entry>
    <key>
    <value>ww</value>
    </key>
    <value>王五</value>
    </entry>
    </map>
    </property>
    <property name="properties">
    <props>
    <prop key="a2">222</prop>
    </props>
    </property>

    </bean>

    6. 针对项目,配置文件路径的2种写法


    ApplicationContext 


    String path = "applicationContext.xml";(独自开发)


    String path = "classpath:applicationContext-*.xml";//src(分模块开发  多人开发)


     



    ————————————————
    版权声明:本文为CSDN博主「湮顾千古」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
    原文链接:https://blog.csdn.net/sujin_/article/details/78700158

    收起阅读 »

    TCP和UDP详解(非常详细)

    TCP和UDP详解 计算机网络知识扫盲:https://blog.csdn.net/hansionz/article/details/85224786 网络编程套接字:https://blog.csdn.net/hansionz/article/detail...
    继续阅读 »


    TCP和UDP详解


    计算机网络知识扫盲:https://blog.csdn.net/hansionz/article/details/85224786
    网络编程套接字:https://blog.csdn.net/hansionz/article/details/85226345
    HTTP协议详解:https://blog.csdn.net/hansionz/article/details/86137260


    前言:本篇博客介绍TCP协议和UDP协议的各个知识点,这两个协议都是位于传输层的协议,我们首先从传输层谈起。


    传输层: 传输层是TCP/IP协议五层模型中的第四层。它提供了应用程序间的通信,它负责数据能够从发送端传输到接收端。其功能包括:一、格式化信息流;二、提供可靠传输。为实现后者,传输层协议规定接收端必须发回确认,并且假如分组丢失,必须重新发送。


    再谈端口号: 在网络知识扫盲博客中谈到端口号标识了一个主机上进行通信的不同应用程序。在TCP/IP协议中, 用"源IP", "源端口号", "目的IP", "目的端口号", "协议号" 这样一个五元组来标识一个通信(可以通过 netstat -n查看,协议号指的是那个使用协议)。
    一个进程可以绑定多个端口号,但是一个端口号不能被多个进程绑定。


    端口号范围划分:



    • 0 - 1023: 知名端口号,HTTP、FTP、 SSH等这些广为使用的应用层协议他们的端口号都是固定的,自己写的程序中,不能随意绑定知名端口号。

    • 1024 - 65535:操作系统动态分配的端口号。 客户端程序的端口号,就是由操作系统从这个范围分配的。


    常见的知名端口号:



    • ssh服务器:22端口

    • ftp服务器:21端口

    • http服务器:80端口

    • telnet服务器:23端口

    • https服务器:443端口

    • MYSQL服务器:3306端口


    在Linux操作系统中使用命令cat /etc/services可以看到所有的知名端口。


    netstat工具: 用来查看网络状态。



    • n 拒绝显示别名,能显示数字的全部转化成数字

    • l 仅列出有在Listen (监听)的服务状态

    • p 显示正在使用Socket的程序识别码和程序名称

    • t (tcp)仅显示tcp相关选项

    • u u (udp)仅显示udp相关选项

    • a (all)显示所有选项,默认不显示LISTEN相关


    pidof [进程名]: 可以根据进程名直接查看服务器的进程id。例如:pidof sshd


    UDP协议


    UDP协议报文格式:
    在这里插入图片描述



    • 16位UDP长度表示整个数据报(UDP首部+UDP数据)的长度

    • 如果校验和出错,就会直接丢弃(UDP校验首部和数据部分)


    UDP协议的特点:



    • 无连接:只知道对端的IP和端口号就可以发送,不需要实现建立连接。

    • 不可靠:没有确认机制, 没有重传机制。如果因为网络故障该段无法发到对方, UDP协议层也不会给应用层返回任何错误信息。

    • 面向数据报: 应用层交给UDP多长的报文, UDP原样发送既不会拆分,也不会合并。如果发送端调用一次sendto, 发送100个字节, 那么接收端也必须调用对应的一次recvfrom, 接收100个 字节,而不能循环调用10次recvfrom, 每次接收10个字节。所以UDP不能够灵活的控制读写数据的次数和数量。


    UDP的缓冲区:UDP存在接收缓冲区,但不存在发送缓冲区。



    • UDP没有发送缓冲区,在调用sendto时会直接将数据交给内核,由内核将数据传给网络层协议进行后续的传输动作。为什么UDP不需要发送缓冲区? 因为UDP不保证可靠性,它没有重传机制,当报文丢失时,UDP不需要重新发送,而TCP不同,他必须具备发送缓冲区,当报文丢失时,TCP必须保证重新发送,用户不会管,所以必须要具备发送缓冲区。


    • UDP具有接收缓冲区,但是这个接收缓冲区不能保证收到的UDP报文的顺序和发送UDP报的顺序一致,如果缓冲区满了再到达的UDP数据报就会被丢弃。



    UDP接收缓冲区和丢包问题:https://blog.csdn.net/ljh0302/article/details/49738191


    UDP是一种全双工通信协议。 UDP协议首部中有一个16位的大长度. 也就是说一个UDP能传输的报文长度是64K(包含UDP首部)。如果我们需要传输的数据超过64K, 就需要在应用层手动的分包, 多次发送, 并在接收端手动拼装。


    常见的基于UDP的应用层协议:



    • NFS:网络文件系统

    • TFTP:简单文件传输协议

    • DHCP:动态主机配置协议

    • BOOTP:启动协议(用于无盘设备启动)

    • DNS:域名解析协议

    • 程序员在写UDP程序时自己定义的协议


    TCP协议


    TCP全称传输控制协议,必须对数据的传输进行控制。


    TCP协议报文格式:
    在这里插入图片描述



    • 源端口号/目的端口号:表示数据从哪个进程来,要到那个进程去


    • 32位序号:序号是可靠传输的关键因素。TCP将要传输的每个字节都进行了编号,序号是本报文段发送的数据组的第一个字节的编号,序号可以保证传输信息的有效性。比如:一个报文段的序号为300,此报文段数据部分共有100字节,则下一个报文段的序号为401。


    • 32位确认序号:每一个ACK对应这一个确认号,它指明下一个期待收到的字节序号,表明该序号之前的所有数据已经正确无误的收到。确认号只有当ACK标志为1时才有效。比如建立连接时,SYN报文的ACK标志位为0。


    • 4位首部长度(数据偏移): 表示该TCP头部有多少个32位bit(有多少个4字节),所以TCP头部大长度是15 * 4 = 60。根据该部分可以将TCP报头和有效载荷分离。TCP报文默认大小为20个字节。


    • 6位标志位:

      URG:它为了标志紧急指针是否有效。
      ACK:标识确认号是否有效。
      PSH:提示接收端应用程序立即将接收缓冲区的数据拿走。
      RST:它是为了处理异常连接的, 告诉连接不一致的一方,我们的连接还没有建立好, 要求对方重新建立连接。我们把携带RST标识的称为复位报文段。
      SYN: 请求建立连接; 我们把携带SYN标识的称为同步报文段。
      FIN:通知对方, 本端要关闭连接了, 我们称携带FIN标识的为结束报文段。






    • 16位的紧急指针:按序到达是TCP协议保证可靠性的一种机制,但是也存在一些报文想优先被处理,这时就可以设置紧急指针,指向该报文即可,同时将紧急指针有效位置位1

    • 16位窗口大小:如果发送方发送大量数据,接收方接收不过来,会导致大量数据丢失。然后接收方可以发送给发送发消息让发送方发慢一点,这是流量控制。接收方将自己接收缓冲器剩余空间的大小告诉发送方叫做16位窗口大小。发送发可以根据窗口大小来适配发送的速度和大小,窗口大小最大是2的16次方,及64KB,但也可以根据选项中的某些位置扩展,最大扩展1G。

    • 16位校验和:发送端填充,CRC校验。如果接收端校验不通过, 则认为数据有问题(此处的检验和不光包含TCP首部也包含TCP数据部分)。


    确认应答机制:
    在这里插入图片描述


    接收端收到一条报文后,向发送端发送一条确认ACK,此ACK的作用就是告诉发送端:接收端已经成功的收到了消息,并且希望收到下一条报文的序列号是什么。这个确认号就是期望的下一个报文的序号。


    每一个ACK都带有对应的确认序列号,意思是告诉发送者,我们已经收到了哪些数据,下一个发送数据应该从哪里开始。 如上图,主机A给主机B发送了1-1000的数据,ACK应答,携带了1001序列号。告诉主机A,我已经接受到了1-1000数据,下一次你从1001开始发送数据。


    超时重传:
    在这里插入图片描述


    TCP在传输数据过程中,还加入了超时重传机制。假设主机A发送数据给主机B,主机B没有收到数据包,主机B自然就不会应答,如果主机A在一个特定时间间隔内没有收到主机B发来的确认应答,就会进行重发,这就是超时重传机制
    当然还存在另一种可能就是主机A未收到B发来的确认应答,也可能是因为ACK丢失了。
    在这里插入图片描述


    因此主机B会收到很多重复数据,那么TCP协议需要能够识别出那些包是重复的包, 并且把重复的包丢弃掉,这时候我们可以利用前面提到的16位序列号, 就可以很容易做到去重的效果。


    超时重发的时间应该如何确定?
    在理想的情况下,可以找到一个小的时间来保证 "确认应答"一定能在这个时间内返回。但是这个时间的长短,随着网络环境的不同是有差异的。如果超时时间设的太长,会影响整体的重传效率。如果超时时间设的太短,有可能会频繁发送重复的包。TCP为了保证无论在任何环境下都能比较高性能的通信,因此会动态计算这个最大超时时间。


    Linux中超时时间以500ms为一个单位进行控制,每次判定超时重发的超时时间都是500ms的整数倍。如果重发一次之后,仍然得不到应答,等待2*500ms后再进行重传。如果仍然得不到应答,等待4*500ms进行重传。依次类推,以指数形式递增,当累计到一定的重传次数,TCP认为网络或者对端主机出现异常,强制关闭连接。


    连接管理机制


    在正常情况下, TCP要经过三次握手建立连接,四次挥手断开连接。


    三次握手及四次挥手:https://mp.csdn.net/mdeditor/86495932


    TIME_WAIT状态: 当我们实现一个TCP服务器时,我们把这个服务器运行起来然后将服务器关闭掉,再次重新启动服务器会发现一个问题:就是不能马上再次绑定这个端口号和ip,需要等一会才可以重新绑定,其实等的这一会就是TIME_WAIT状态。



    • TCP协议规定主动关闭连接的一方要处于TIME_ WAIT状态,等待两个MSL的时间后才能回到CLOSED状态。

    • 当我们使用Ctrl-C终止了server,server是主动关闭连接的一方在TIME_WAIT期间仍然不能再次监听同样的server端口。

    • MSLRFC1122中规定为两分钟(120s),但是各操作系统的实现不同,在Centos7上默认配置的值是60s可以通过cat /proc/sys/net/ipv4/tcp_fin_timeout查看MSL的值。


    为什么TIME_WAIT时间一定是2MSL:


    首先,TIME_WAIT是为了防止最后一个ACK丢失,如果没有TIME_WAIT,那么主动断开连接的一方就已经关闭连接,但是另一方还没有断开连接,它收不到确认ACK会认为自己上次发送的FIN报文丢失会重发该报文,但是另一方已经断开连接了,这就会造成连接不一致的问题,所以TIME_WAIT是必须的。


    MSLTCP报文在发送缓冲区的最大生存时间,如果TIME_WAIT持续存在2MSL的话就能保证在两个传输方向上的尚未被接收或迟到的报文段都已经消失。(否则服务器立刻重启,可能会收到来自上一个进程的迟到的数据,但是这种数据很可能是错误的)。同时也是在理论上保证最后一个报文可靠到达。(假设最后一个ACK丢失, 那么服务器会再重发一个FIN,这时虽然客户端的进程不在了,但是TCP连接还在,仍然可以重发LAST_ACK,这就会导致问题)


    解决TIME_WAIT状态引起的bind失败的方法:


    serverTCP连接没有完全断开之前不允许重新绑定,也就是TIME_WAIT时间没有过,但是这样不允许立即绑定在某些情况下是不合理的:



    • 服务器需要处理非常大量的客户端的连接 (每个连接的生存时间可能很短,但是每秒都有很大数量的客户 端来请求)

    • 这个时候如果由服务器端主动关闭连接(比如某些客户端不活跃,就需要被服务器端主动清理掉),这样服务器端就会产生大量TIME_WAIT状态

    • 如果客户端的请求量很大,就可能导致TIME_WAIT的连接数很多,每个连接都会占用一个通信五元组(源ip, 源端口, 目的ip, 目的端口, 协议)。其中服务器的ip和端口和协议是固定的,如果新来的客户端连接的ip和端口号TIME_WAIT占用的连接重复就造成等待。


    解决方法:使用setsockopt()设置socket描述符的选项SO_REUSEADDR为1,表示允许创建端口号相同但IP地址不同的多个socket描述符。
    关于setsockopthttps://www.cnblogs.com/clschao/articles/9588313.html


    服务器端CLOSE_WAIT状态: 如果客户端是主动断开连接的一方,在服务器端假设没有关闭新连接,这时服务器端就会产生一个CLOSE_WAIT状态,因为服务器没有去关闭连接,所以这个CLOSE_WAIT状态很容易测试出来,这时四次挥手没有结束,只完成了两次。


    #include "tcp_socket.hpp"

    typedef void (*Handler)(string& req, string* res);

    class TcpServer
    {
    public:
    TcpServer(string ip, uint16_t port)
    :_ip(ip)
    ,_port(port)
    {}

    void Start(Handler handler)
    {
    //1.创建socket
    listen_sock.Socket();
    //2.绑定ip和端口号
    listen_sock.Bind(_ip, _port);
    //3.监听
    listen_sock.Listen(5);

    while(1)
    {
    TcpSocket new_sock;
    string ip;
    uint16_t port;
    //4.接收连接
    listen_sock.Accept(&new_sock, &ip, &port);
    cout <<"client:" << ip.c_str() << " connect" << endl;
    while(1)
    {
    //5.连接成功读取客户端请求
    string req;
    bool ret = new_sock.Recv(&req);
    cout << ret << endl;
    if(!ret)
    {
    //此处服务器端不关闭新连接,导致CLOSE_WAIT状态
    //new_sock.Close();
    break;
    }
    //6.处理请求
    string res;
    handler(req, &res);

    //写回处理结果
    new_sock.Send(res);
    cout << "客户:" << ip.c_str() << " REQ:" << req << ". RES:" << res << endl;
    }
    }
    }
    private:
    TcpSocket listen_sock;
    string _ip;
    uint16_t _port;
    };

    运行结果:
    在这里插入图片描述


    如果服务器上出现大量的CLOSE_WAIT状态,原因就是服务器没有正确的关闭 socket,导致四次挥手没有正确完成。这是可能是一个BUG,只需要加上对应的 close即可解决问题。


    滑动窗口:


    确认应答策略对每一个发送的数据段都要给一个ACK确认应答,接收方收到ACK后再发送下一个数据段,但是这样做有一个比较大的缺点,就是性能较差,尤其是数据往返的时间较长的时候。


    既然一发一收的方式性能较低,那么我们考虑一次发送多条数据,就可以大大的提高性能,它是将多个段的等待时间重叠在一起。
    在这里插入图片描述
    窗口大小指的是无需等待确认应答而可以继续发送数据的最大值。上图的窗口大小就是4000个字节(四个段)。发送前四个段的时候,不需要等待任何ACK直接发送即可。当收到第一个ACK后滑动窗口向后移动,继续发送第五个段的数据,然后依次类推。操作系统内核为了维护这个滑动窗口,需要开辟发送缓冲区来记录当前还有哪些数据没有应答。只有确认应答过的数据,才能从缓冲区删掉,窗口越大,则网络的吞吐率就越高。滑动窗口左边代表已经发送过并且确认,可以从发送缓冲区中删除了,滑动窗口里边代表发送出去但是没有确认,滑动窗口右边代表还没有发送的数据。
    在这里插入图片描述


    如果在这种情况中出现了丢包现象,应该如何重发呢?



    • 数据到达接收方,但是应答报文丢失:可以更具后边的ACK确认。假设发送方发送1-1000的数据,接收方收到返回确认ACK,但是返回的ACK丢失了,另一边发送1001-2000收到的确认ACK 2001,就可以认为1-1000数据接收成功


    • 数据包之间丢失: 当某一段报文段丢失之后,发送端会一直收到 1001 这样的ACK,就像是在提醒发送端 "我想要的是 1001" 一样,如果发送端主机连续三次收到了同样一个"1001" 这样的应答,就会将对应的数据 1001 - 2000 重新发送,这个时候接收端收到了 1001 之后, 再次返回的ACK就是7001了。因为2001 - 7000接收端其实之前就已经收到了,被放到了接收端操作系统内核的接收缓冲区中。这种机制被称为 “高速重发控制”(也叫 "快重传")。



    在这里插入图片描述


    快重传要求接收方在收到一个失序的报文段后就立即发出重复确认(为的是使发送方及早知道有报文段没有到达对方)而不要等到自己发送数据时捎带确认。快重传算法规定,发送方只要一连收到三个重复确认就应当立即重传对方尚未收到的报文段,而不必继续等待设置的重传计时器时间到期。由于不需要等待设置的重传计时器到期,能尽早重传未被确认的报文段,能提高整个网络的吞吐量
    流量控制


    接收端处理数据的速度是有限的,如果发送端发的太快,导致接收端的缓冲区被装满,这个时候如果发送端继续发送,就会造成丢包,然后引起丢包重传等等一系列连锁反应。因此TCP支持根据接收端的处理能力,来决定发送端的发送速度,这个机制就叫做流量控制(Flow Control)



    • 接收端将自己可以接收的缓冲区大小放入TCP首部中的"窗口大小"字段,通过ACK确认报文通知发送端

    • 窗口大小字段越大,说明网络的吞吐量越高,接收端一旦发现自己的缓冲区快满了,就会将窗口大小设置成一个更小的值通知给发送端

    • 发送端接受到这个窗口之后,就会减慢自己的发送速度,如果接收端缓冲区满了, 就会将窗口置为0。这时发送方不再发送数据,但是需要定期发送一个窗口探测数据段,使接收端把窗口大小告诉发送端。


    接收端如何把窗口大小告诉发送端呢? 在的TCP首部中,有一个16位窗口字段,就是存放了窗口大小信息,16位数字大表示65535,那么TCP窗口大就是65535字节吗? 实际上TCP首部40字节选项中还包含了一个窗口扩大因子M,实际窗口大小是 窗口字段的值左移M位。接收端窗口如果更新,会向发送端发送一个更新通知,如果这个更新通知在中途丢失了,会导致无法继续通信,所以发送端要定时发送窗口探测包。


    拥塞控制:


    虽然TCP有了滑动窗口这个大杀器能够高效可靠的发送大量的数据,但是如果在刚开始阶段就发送大量的数据,仍然可能引发问题,因为网络上有很多的计算机,可能当前的网络状态就已经比较拥堵,在不清楚当前网络状态下,贸然发送大量的数据是很有可能引起雪上加霜的,造成网络更加堵塞


    TCP引入慢启动机制,先发少量的数据探探路,摸清当前的网络拥堵状态,再决定按照多大的速度传输数据。
    在这里插入图片描述


    图中的cwnd为拥塞窗口,在发送开始的时候定义拥塞窗口大小为1,每次收到一个ACK应答拥塞窗口加1。每次发送数据包的时候,将拥塞窗口和接收端主机反馈的窗口大小做比较,取较小的值作为实际发送的窗口。


    像上面这样的拥塞窗口增长速度,是指数级别的。"慢启动"只是指初使时慢,但是增长速度非常快。为了不增长的那么快,因此不能使拥塞窗口单纯的加倍,此处引入一个叫做慢启动的阈值当拥塞窗口超过这个阈值的时候,不再按照指数方式增长, 而是按照线性方式增长。


    在这里插入图片描述



    • TCP开始启动的时候,慢启动阈值等于窗口最大值

    • 在每次超时重发的时候,慢启动阈值会变成原来的一半同时拥塞窗口置回1


    少量的丢包,我们仅仅是触发超时重传。大量的丢包,我们就认为网络拥塞。当TCP通信开始后,网络吞吐量会逐渐上升。随着网络发生拥堵,吞吐量会立刻下降。拥塞控制归根结底是TCP协议想尽可能快的把数据传输给对方,但是又要避免给网络造成太大压力的折中方案。


    在这里插入图片描述
    拥塞控制与流量控制的区别:


    拥塞控制是防止过多的数据注入到网络中,可以使网络中的路由器或链路不致过载,是一个全局性的过程。 流量控制是点对点通信量的控制,是一个端到端的问题,主要就是权衡发送端发送数据的速率,以便接收端来得及接收。


    拥塞控制的标志:



    • 重传计时器超时

    • 接收到三个重复确认


    拥塞避免:(按照线性规律增长)



    • 拥塞避免并非完全能够避免拥塞,在拥塞避免阶段将拥塞窗口控制为按线性规律增长,使网络比较不容易出现拥塞。

    • 拥塞避免的思路是让拥塞窗口cwnd缓慢地增大,即每经过一个往返时间RTT就把发送方的拥塞控制窗口加一。


    无论是在慢开始阶段还是在拥塞避免阶段,只要发送方判断网络出现拥塞(其根据就是没有收到确认,虽然没有收到确认可能是其他原因的分组丢失,但是因为无法判定,所以都当做拥塞来处理),这时就把慢开始门限设置为出现拥塞时的门限的一半。然后把拥塞窗口设置为1,执行慢开始算法。
    在这里插入图片描述



    • 加法增大:执行拥塞避免算法后,拥塞窗口线性缓慢增大,防止网络过早出现拥塞

    • 乘法减小:无论是慢开始阶段还是拥塞避免,只要出现了网络拥塞(超时),那就把慢开始门限值ssthresh减半


    快恢复(与快重传配合使用)



    • 采用快恢复算法时,慢开始只在TCP连接建立时和网络出现超时时才使用。

    • 当发送方连续收到三个重复确认时,就执行“乘法减小”算法,把ssthresh门限减半。但是接下去并不执行慢开始算法。

    • 考虑到如果网络出现拥塞的话就不会收到好几个重复的确认,所以发送方现在认为网络可能没有出现拥塞。所以此时不执行慢开始算法,而是将cwnd设置为ssthresh的大小,然后执行拥塞避免算法。


    延迟应答


    如果接收数据的主机立刻返回ACK应答,这时候返回的窗口可能比较小。假设接收端缓冲区为1M 一次收到了500K的数据。如果立刻应答,返回的窗口就是500K。 但实际上可能处理端处理的速度很快,10ms之内就把500K数据从缓冲区消费掉了,在这种情况下,接收端处理还远没有达到自己的极限,即使窗口再放大一些也能处理过来。如果接收端稍微等一会再应答,比如等待200ms再应答,那么这个时候返回的窗口大小就是1M


    窗口越大,网络吞吐量就越大,传输效率就越高。我们的目标是在保证网络不拥塞的情况下尽量提高传输效率。



    • 数量限制: 每隔N个包就应答一次

    • 时间限制: 超过大延迟时间就应答一次


    注:具体的数量和超时时间, 依操作系统不同也有差异; 一般N取2, 超时时间取200ms


    捎带应答:


    延迟应答的基础上,存在很多情况下,客户端服务器在应用层也是"一发一收" 的。 意味着客户端给服务器说了"How are you", 服务器也会给客户端回一个"Fine, thank you"。那么这个时候ACK就可以搭顺风车,和服务器回应的 "Fine, thank you" 一起回给客户端


    面向字节流:


    当我们创建一个TCPsocket,同时在内核中创建一个发送缓冲区和一个接收缓冲区



    • 调用write时,内核将数据会先写入发送缓冲区中,如果发送的字节数太长,会被拆分成多个TCP的数据包发出,如果发送的字节数太短,就会先在缓冲区里等待, 等到缓冲区长度达到设置长度,然后等到其他合适的时机发送出去。

    • 调用read接收数据的时候, 数据也是从网卡驱动程序到达内核的接收缓冲区。然后应用程序可以调用read从接收缓冲区拿数据。TCP的一个连接,既有发送缓冲区, 也有接收缓冲区,那么对于这一个连接,既可以读数据,也可以写数据。所以是全双工的。


    由于缓冲区的存在,TCP程序的读和写不需要一一匹配。例如: 写100个字节数据时, 可以调用一次write写100个字节, 也可以调用100次write, 每次写一个字节; 读100个字节数据时, 也完全不需要考虑写的时候是怎么写的, 既可以一次read 100个字节, 也可以一次 read一个字节, 重复100次


    粘包问题:


    粘包问题中的 "包"是指的应用层的数据包。在TCP的协议头中,没有如同UDP一样的 "报文长度"这样的字段,但是有一个序号这样的字段。站在传输层的角度, TCP是一个一个报文过来的,按照序号排好序放在缓冲区中,但是站在应用层的角度,它看到的只是一串连续的字节数据。应用程序看到了这么一连串的字节数据, 就不知道从哪个部分开始到哪个部分结束是一个完整的应用层数据包,这就是粘包问题


    如何避免粘包问题呢?明确两个包之间的边界



    • 对于定长的包,保证每次都按固定大小读取即可。例如一个Request结构, 是固定大小的, 那么就从缓冲区从头开始按sizeof(Request)依次读取即可

    • 对于变长的包,可以在包头的位置,约定一个包总长度的字段,从而就知道了包的结束位置

    • 对于变长的包,还可以在包和包之间使用明确的分隔符(应用层协议是程序员自己来定义的, 只要保证分隔符不和正文冲突即可)


    对于UDP协议,如果还没有上层交付数据UDP的报文长度仍然在。 同时UDP一个一个把数据交付给应用层,这样就有存在明确的数据边界,站在应用层的角度, 使用UDP的时候要么收到完整的UDP报文要么不收,不会出现"半个"的情况。


    TCP连接异常情况:



    • 进程终止:进程终止会释放文件描述符,仍然可以发送FIN,和正常关闭没有什么区别。机器重启和进程终止一样。

    • 机器掉电/网线断开:接收端认为连接还在,一旦接收端有写入操作,接收端发现连接已经不在了,就会进行reset。即使没有写入操作,TCP自己也内置了一个保活定时器,会定期询问对方是否还在。如果对方不在,也会把连接释放。应用层的某些协议, 也有一些这样的检测机制.例如HTTP长连接中, 也会定期检测对方的状态.Q在QQ 断线之后, 也会定期尝试重新连接



    ————————————————
    版权声明:本文为CSDN博主「Hansionz」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
    原文链接:https://blog.csdn.net/hansionz/article/details/86435127

    收起阅读 »

    Android即时通讯系列文章(4)MapStruct:分层式架构下不同数据模型之间相互转换的利器

    文章开篇,让我们先来解答一下上篇文章中留下的疑问,即:为什么要设计多个Entity?以「分离关注点」为原则的分层式架构,是我们在进行应用架构设计时经常采用的方案,例如为人熟知的MVC/MVP/MVVM等架构设计模式下,划分出的表示层、业务逻辑层、数据访问层、持...
    继续阅读 »

    文章开篇,让我们先来解答一下上篇文章中留下的疑问,即:

    为什么要设计多个Entity?

    以「分离关注点」为原则的分层式架构,是我们在进行应用架构设计时经常采用的方案,例如为人熟知的MVC/MVP/MVVM等架构设计模式下,划分出的表示层、业务逻辑层、数据访问层、持久层等。为了保持应用架构分层之后的独立性,通常需要在各个层次之间定义不同的数据模型,于是不可避免地要面临数据模型之间的相互转换问题。

    常见的不同层次的数据模型包括:

    VO(View Object):视图对象,用于展示层,关联某一指定页面的展示数据。

    DTO(Data Transfer Object):数据传输对象,用于传输层,泛指与服务端进行传输交互的数据。

    DO(Domain Object):领域对象,用于业务层,执行具体业务逻辑所需的数据。

    PO(Persistent Object):持久化对象,用于持久层,持久化到本地存储的数据。

    还是以即时通讯中消息收发为例:

    聊天时序图.png

    • 客户端在会话页面编辑消息并发送后,消息相关的数据在展示层被构造为MessageVO,展示在会话页面的聊天记录中;
    • 展示层将MessageVO转换为持久层对应的MessagePO后,调用持久层的持久化方法,将消息保存到本地数据库或其他地方
    • 展示层将MessageVO转换为传输层所要求的为MessageDTO后,传输层将数据传输到服务端
    • 至于对应的逆向操作,相信你也可以对于推理出来,这里就不再赘述了。

    在上篇文章中,我们以get/set操作的方式手动编写了映射代码,这种方式不但繁琐且容易出错,考虑到后期扩展其他消息类型时又要重复做同样的事情,出于提高开发效率的考虑,经过一番调研之后,我们决定采用MapStruct库以自动化的形式帮我们完成这件事情。

    MapStruct是什么?

    MapStruct是一个代码生成器,用于生成类型安全、高性能、无依赖的映射代码。

    我们所要做的,就是定义一个Mapper(映射器)接口,并声明需要实现的映射方法,即可在编译期利用MapStruct注解处理器,生成该接口的实现类,该实现类以自动化的方式帮我们完成get/set操作,以实现源对象与目标对象之间的映射关系。

    MapStruct的使用

    以Gradle的形式添加MapStruct依赖项:

    在模块级别的build.gradle文件中添加:

    dependencies {
    ...
    implementation "org.mapstruct:mapstruct:1.4.2.Final"
    annotationProcessor "org.mapstruct:mapstruct-processor:1.4.2.Final"
    }

    如果项目中使用的是Kotlin语言则需要:

    dependencies {
    ...
    implementation "org.mapstruct:mapstruct:1.4.2.Final"
    kapt("org.mapstruct:mapstruct-processor:1.4.2.Final")
    }

    接下来,我们会以上次定义好的MessageVO与MessageDTO为操作对象,实践如何使用MapStruct自动化完成两者之间的字段映射:

    创建映射器接口

    1. 创建一个Java接口(也可以以抽象类的形式),并添加@Mapper注解表明是个映射器:
    2. 声明一个映射方法,指定入参类型和出参类型:
    @Mapper
    public interface MessageEntityMapper {

    MessageDTO.Message.Builder vo2Dto(MessageVO messageVo);

    MessageVO dto2Vo(MessageDTO.Message messageDto);

    }

    这里使用MessageDTO.Message.Builder而非MessageDTO.Message的原因是,ProtoBuf生成的Message使用了Builder模式,并为了防止外部直接实例化而把构造参数设为private,这将导致MapStruct在编译的时候报错,至于原因,等你看完后面的内容就明白了。

    默认场景下的隐式映射

    当入参类型的字段名与出参类型字段名一致时,MapStruct会帮我们隐式映射,即不需要我们主动处理。

    目前支持以下类型的自动转换:

    • 基本数据类型及其包装类型
    • 数值类型之间,但从较大的数据类型转换为较小的数据类型(例如从long到int)可能会导致精度损失
    • 基本数据类型与字符串之间
    • 枚举类型和字符串之间
    • ...

    这其实是一种约定优于配置的思想:

    约定优于配置(convention over configuration),也称作按约定编程,是一种软件设计范式,旨在减少软件开发人员需做决定的数量,获得简单的好处,而又不失灵活性。

    本质是说,开发人员仅需规定应用中不符约定的部分。如果您所用工具的约定与你的期待相符,便可省去配置;反之,你可以配置来达到你所期待的方式。

    体现在MapStruct库之中即是,我们仅需针对那些MapStruct库没法帮我们完成隐式映射的字段,配置好对应的处理方式即可。

    比如我们例子中的MessageVO与MessageDTO,两者的messageId, senderId, targetId, timestamp几个字段的名称和数据类型都是一致的,因而不需要我们额外处理。

    特殊场景下的字段映射处理

    字段名称不一致:

    这种情况下,只需在映射方法之上添加@Mapping注解,标注源字段的名称以及目标字段的名称即可。

    比如我们例子中在message_dto.proto文件中定义的messageType是一个枚举类型,ProtoBuf为我们生成MessageDTO.Message时,额外为我们生成了一个messageTypeValue来表示该枚举类型的值,我们用上述方法即可完成从messageType到messageTypeValue的映射:

        @Mapping(source = "messageType", target = "messageTypeValue")
    MessageDTO.Message.Builder vo2Dto(MessageVO messageVo);
    字段类型不一致:

    这种情况下,只需为两种不同的数据类型额外声明一个映射方法,即以源字段的类型为入参类型,以目标字段的类型为出参类型的映射方法。

    MapStruct会检查是否存在该映射方法,如果有,则会在映射器接口的实现类中调用该方法完成映射。

    比如我们例子中,content字段被定义为bytes类型,对于生成的MessageDTO.Message类中则是用ByteString类型表示,而MessageVO中的content字段则是String类型,因此需要在映射器接口中额外声明一个byte2String映射方法与一个string2Byte映射方法:

        default String byte2String(ByteString byteString) {
    return new String(byteString.toByteArray());
    }

    default ByteString string2Byte(String string) {
    return ByteString.copyFrom(string.getBytes());
    }

    又比如,我们不想处理上面messageType到messageTypeValue的映射,而是想直接完成messageType到枚举类型的映射,那我们就可以声明以下两个映射方法:

        default int enum2Int(MessageDTO.Message.MessageType type) {
    return type.getNumber();
    }

    default String byte2String(ByteString byteString) {
    return new String(byteString.toByteArray());
    }
    忽略某些字段:

    出于特殊的需要,某些层次的数据模型可能会新增部分字段,用于处理特定的业务,这些字段对于其他层次是没有任何意义的,所以没必要在其他层次保留这些字段,同时为了避免MapStruct隐式映射时找不到相应字段导致出错,我们可以在注解中添加ignore = true忽略这些字段:

    比如我们例子中,ProtoBuf生成的MessageDTO.Message类中还额外为我们新增了三个字段mergeFrom、senderIdBytes、targetIdBytes,这三个字段对于MessageVO是没有必要的,因此需要让MapStruct帮我们忽略掉:

        @Mapping(target = "mergeFrom", ignore = true)
    @Mapping(target = "senderIdBytes", ignore = true)
    @Mapping(target = "targetIdBytes", ignore = true)
    MessageDTO.Message.Builder vo2Dto(MessageVO messageVo);

    其他场景的额外处理

    前面我们说过,由于MessageDTO.Message的构造函数被设为private导致编译时报错,实际上MessageDTO.Message.Builder的构造函数也是private的,该Builder的实例化是通过MessageDTO.Message.newBuilder()方法进行的。

    而MapStruct默认情况下是需要调用目标类的默认构造函数来完成映射任务的,那我们就没有办法了么?

    实际上,MapStruct允许你自定义对象工厂,这些工厂将提供了工厂方法,用以调用来获取目标类型的实例。

    我们要做的,只是声明该工厂方法的返回类型为我们的目标类型,然后在工厂方法中以想要的方式返回该目标类型的实例,随后在映射器接口的@Mapper注解中添加use参数,传入我们的工厂类。MapStruct就会优先自动找到该工厂方法,完成目标类型的实例化。

    public class MessageDTOFactory {

    public MessageDTO.Message.Builder createMessageDto() {
    return MessageDTO.Message.newBuilder();
    }
    }

    @Mapper(uses = MessageDTOFactory.class)
    public interface MessageEntityMapper {

    最后,我们定义一个名为INSTANCE 的成员,该成员通过调用Mappers.getMapper()方法,并传入该映射器接口类型,实现返回该映射器接口类型的单例。

    public interface MessageEntityMapper {

    MessageEntityMapper INSTANCE = Mappers.getMapper(MessageEntityMapper.class);

    完整的映射器接口代码如下:

    @Mapper(uses = MessageDTOFactory.class)
    public interface MessageEntityMapper {

    MessageEntityMapper INSTANCE = Mappers.getMapper(MessageEntityMapper.class);

    @Mapping(source = "messageType", target = "messageTypeValue")
    @Mapping(target = "mergeFrom", ignore = true)
    @Mapping(target = "senderIdBytes", ignore = true)
    @Mapping(target = "targetIdBytes", ignore = true)
    MessageDTO.Message.Builder vo2Dto(MessageVO messageVo);

    MessageVO dto2Vo(MessageDTO.Message messageDto);

    @Mapping(source = "messageTypeValue", target = "messageType")
    default MessageDTO.Message.MessageType int2Enum(int value) {
    return MessageDTO.Message.MessageType.forNumber(value);
    }

    default int enum2Int(MessageDTO.Message.MessageType type) {
    return type.getNumber();
    }

    default String byte2String(ByteString byteString) {
    return new String(byteString.toByteArray());
    }

    default ByteString string2Byte(String string) {
    return ByteString.copyFrom(string.getBytes());
    }
    }

    自动生成映射器接口的实现类

    映射器接口定义好之后,当我们重新构建项目时MapStruct就会帮我们生成该接口的实现类,我们可以在{module}/build/generated/source/kapt/debug/{包名}路径找到该类,来对其细节一探究竟:

    public class MessageEntityMapperImpl implements MessageEntityMapper {

    private final MessageDTOFactory messageDTOFactory = new MessageDTOFactory();

    @Override
    public Builder vo2Dto(MessageVO messageVo) {
    if ( messageVo == null ) {
    return null;
    }

    Builder builder = messageDTOFactory.createMessageDto();

    if ( messageVo.getMessageType() != null ) {
    builder.setMessageTypeValue( messageVo.getMessageType() );
    }
    if ( messageVo.getMessageId() != null ) {
    builder.setMessageId( messageVo.getMessageId() );
    }
    if ( messageVo.getMessageType() != null ) {
    builder.setMessageType( int2Enum( messageVo.getMessageType().intValue() ) );
    }
    builder.setSenderId( messageVo.getSenderId() );
    builder.setTargetId( messageVo.getTargetId() );
    if ( messageVo.getTimestamp() != null ) {
    builder.setTimestamp( messageVo.getTimestamp() );
    }
    builder.setContent( string2Byte( messageVo.getContent() ) );

    return builder;
    }

    @Override
    public MessageVO dto2Vo(Message messageDto) {
    if ( messageDto == null ) {
    return null;
    }

    MessageVO messageVO = new MessageVO();

    messageVO.setMessageId( messageDto.getMessageId() );
    messageVO.setMessageType( enum2Int( messageDto.getMessageType() ) );
    messageVO.setSenderId( messageDto.getSenderId() );
    messageVO.setTargetId( messageDto.getTargetId() );
    messageVO.setTimestamp( messageDto.getTimestamp() );
    messageVO.setContent( byte2String( messageDto.getContent() ) );

    return messageVO;
    }
    }

    可以看到,如上文所讲,由于该实现类实际仍以普通的get/set方法调用来完成字段映射,整个过程并没有用到反射,且由于是在编译期生成该类,减少了运行期的性能损耗,故符合其“高性能”的定义。

    另一方面,当属性映射出错时,能在编译期及时获知,避免了运行时的报错崩溃,且对于某些特定类型增加了非空判断等措施,故符合其“类型安全”的定义。

    接下来,我们即可用该映射器实例的映射方法替换之前手动编写的映射代码:

    class EnvelopeHelper {
    companion object {
    /**
    * 填充操作(VO->DTO)
    * @param envelope 信封类,包含消息视图对象
    */
    fun stuff(envelope: Envelope): MessageDTO.Message? {
    return envelope.messageVO?.run {
    MessageEntityMapper.INSTANCE.vo2Dto(this).build()
    } ?: null
    }

    /**
    * 提取操作(DTO->VO)
    * @param messageDTO 消息数据传输对象
    */
    fun extract(messageDTO: MessageDTO.Message): Envelope? {
    with(Envelope()) {
    messageVO = MessageEntityMapper.INSTANCE.dto2Vo(messageDTO)
    return this
    }
    }
    }
    }

    总结

    如你所见,最终结果就是我们减少了大量的样板代码,使代码整体结构的更易于理解,后期扩展其他类型的对象也只需要增加对应的映射方法即可,即同时提高了代码的可读性/可维护性/可扩展性。

    MapStruct遵循约定优于配置的原则,以尽可能自动化的方式,帮我们解决了应用分层式架构下、不同数据模型之间、繁琐且易出错的相互转换工作,实在是极大提高开发人员开发效率的利器!

    收起阅读 »

    Android即时通讯系列文章(3)数据传输格式选型:资源受限的移动设备上数据传输的困境

    前言跟PC时代的传统互联网相比,移动互联网得益于移动设备的便携性,仅短短数年便快速地渗透到了人们生活、工作的各个方面。虽然通信技术和硬件设备在不断地更新升级换代,但就目前而言,电量、流量等对于移动设备来讲仍属于稀缺资源。参与过Android系统版本升级适配工作...
    继续阅读 »

    前言

    跟PC时代的传统互联网相比,移动互联网得益于移动设备的便携性,仅短短数年便快速地渗透到了人们生活、工作的各个方面。虽然通信技术和硬件设备在不断地更新升级换代,但就目前而言,电量、流量等对于移动设备来讲仍属于稀缺资源。

    参与过Android系统版本升级适配工作的开发人员,也许可以很明显地感受到,近年来Android系统每一个更新的版本都是往更省电、更省流量、更省内存的方向靠拢的,比如:

    • Android 6.0 引入了 低电耗模式 和 应用待机模式
    • Android 7.0 引入了 随时随地低电耗模式
    • Android 8.0 引入了 后台执行限制
    • Android 9.0 引入了 应用待机存储分区

    ...

    移动应用向网络发出的请求时主要的耗电来源之一,除了发送和接收数据包本身需要消耗电量外,开启无线装置并保持唤醒也会消耗额外的电量。特别是对于即时通讯这种网络交互频繁的应用场景来讲,数据传输大小是必须要考虑优化的一个方面,要尽量做到减少冗余数据,提高传输效率,从而减少对电量、流量的损耗。

    二进制数据相对于可读性更好的文本数据而言,数据冗余量小,数据排列更为紧凑,因而体积更小,传输速度更快。但是要使用自定义二进制协议的话,就意味着需要自己定义数据结构,自己做序列化反序列化工作,版本兼容也是个问题。基于时间成本与技术成本的考虑,我们决定采用Protobuf帮我们完成这部分工作。

    什么是Protobuf?

    Protobuf,全称Protocol Buffer(协议缓冲区),是Google开源的跨语言、跨平台、可扩展的结构化数据序列化机制。与XML、JSON及其他数据传输格式相比,Protocol更为轻巧、快速、简单。我们只需在.proto文件中定义好数据结构,即可利用Protobuf编译器编译生成针对各种平台、语言的数据访问类代码,轻松地在各种数据流中写入和读取结构化数据,尤其适用于数据存储及网络通信等场景。

    总结起来即是:

    优点:

    1. 数据大小:以独特的Varint、Zigzag编码方式及T-L-V数据存储方式实现数据压缩
    2. 解析效率:以高效的二进制格式实现数据的自动编码和解析
    3. 通用性:跨语言、跨平台
    4. 易用性:可用Protobuf编译器自动生成数据访问类
    5. 可扩展性:可随着版本迭代扩展格式
    6. 兼容性:可向后兼容旧格式编码的数据
    7. 可维护性:多个平台只需共同维护一个.proto文件

    缺点:

    可读性差:缺少.proto文件情况下难以去理解数据结构

    既然是数据传输格式选型,那么免不了与其他数据传输格式进行比较,我们常见的与服务端交互的数据传输格式莫过于XML与JSON。

    • XML

      可扩展标记语言(Extensible Markup Language),是一种文本类型的数据格式,以“<”开头,“>”结束的标签作为主要的语法规则。XML的设计侧重于作为文档描述,但也被广泛用于表示任意的数据结构。

    优点:

    1. 可读性好
    2. 可扩展性好

    缺点:

    1. 解析代价高,对它进行编码/解码会给应用程序带来巨大的性能损失
    2. 空间占用大,有效数据传输率低(大量的标签)

    从事Android开发的你肯定对Android的轻量级持久化方案SharedPreference不陌生,SharedPreference即是以xml为主要实现,不过目前Android官方已建议使用DataStore作为SharedPreference的替代方案,DataStore则是以ProtoBuf为主要实现。

    • JSON

    JavaScript对象表示法(JavaScript Object Notation),是一种开放标准文件格式以及数据交换格式,以文本形式来存储和传输由属性值对及数组组成的数据对象,常见于与服务器的通信。

    优点:

    除了拥有与XML相同的优点外,由于不需要像XML那样严格的闭合标签,因此有效数据量传输率更高,可节约所占用的带宽。

    ProtoBuf实现

    以Gradle形式添加ProtoBuf依赖项

    1. 项目级别的build.gradle文件:
    dependencies {
    ...
    // Protobuf
    classpath 'com.google.protobuf:protobuf-gradle-plugin:0.8.8'
    }
    1. 模块级别的build.gradle文件:
    apply plugin: 'com.google.protobuf'

    android {
    sourceSets {
    main {
    // 定义proto文件目录
    proto {
    srcDir 'src/main/proto'
    }
    }
    }
    }

    dependencies {
    def PROTOBUF_VERSION = "3.0.0"

    api "com.google.protobuf:protobuf-java:${PROTOBUF_VERSION}"
    api "com.google.protobuf:protoc:${PROTOBUF_VERSION}"
    }

    protobuf {
    protoc { artifact = 'com.google.protobuf:protoc:3.2.0' }
    plugins {
    javalite {
    artifact = 'com.google.protobuf:protoc-gen-javalite:3.0.0'
    }
    }
    generateProtoTasks {
    all().each {
    task -> task.plugins { javalite {} }
    }
    }
    }

    在proto文件中定义要存储的消息的数据结构

    首先,我们需要在{module}/src/main/proto目录下新建message_dto.proto文件,以定义我们要存储的对象的数据结构,如下:

    1.png

    在定义数据结构之前,我们先来思考一下,一条最基础的即时通讯消息应该要包含哪些字段?这里以生活中常见的收发信件为例子:

    信件内容自然我们最关心的——content

    谁给我寄的信,是给我还是给其他人的呢?——sender_id、target_id

    为了快速检索信件,我们还需要一个唯一值——message_id

    是什么类型的信件呢?是信用卡账单还是情书呢?——type

    如果有多封信件,为了阅读的通顺我们还需要理清信件的时间线——timestamp

    以下就是最终定义出的message_dto.proto文件,接下来让我们逐步去解读这个文件:

    syntax = "proto3";

    option java_package = "com.madchan.imsdk.lib.objects.bean.dto";
    option java_outer_classname = "MessageDTO";

    message Message {
    enum MessageType {
    MESSAGE_TYPE_UNSPECIFIED = 0; // 未指定
    MESSAGE_TYPE_TEXT = 1; // 文本消息
    }
    //消息唯一值
    uint64 message_id = 1;
    //消息类型
    MessageType message_type = 2;
    //消息发送用户
    string sender_id = 3;
    //消息目标用户
    string target_id = 4;
    //消息时间戳
    uint64 timestamp = 5;
    //消息内容
    bytes content = 6;
    }

    声明使用语法
    syntax = "proto3";

    文件首行表明我们使用的是proto3语法,默认不声明的话,ProtoBuf编译器会认为我们使用的是proto2,该声明必须位于首行,且非空、非注释。

    指定文件选项
    option java_package = "com.madchan.imsdk.lib.objects.bean.dto";

    java_package用于指定我们要生成的Java类的包目录路径。

    option java_outer_classname = "MessageDTO";

    java_outer_classname指定我们要生成的Java包装类的类名。默认不声明的话,会将.proto 文件名转换为驼峰式来命名。

    此外还有一个java_multiple_files选项,当为true时,会将.proto文件中声明的多个数据结构转成多个单独的.java文件。默认为false时,则会以内部类的形式只生成一个.java文件。

    指定字段类型
        //消息唯一值
    uint64 message_id = 1;

    也许你注意到了,针对消息唯一值message_id和消息时间戳timestamp我们采用的是uint64,这其实是unsigned int的缩写,意味无符号64位整数,即Long类型的正数,关于无符号整数的解释如下:

    计算机里的数是用二进制表示的,最左边的这一位一般用来表示这个数是正数还是负数,这样的话这个数就是有符号整数。如果最左边这一位不用来表示正负,而是和后面的连在一起表示整数,那么就不能区分这个数是正还是负,就只能是正数,这就是无符号整数。

        enum MessageType {
    MESSAGE_TYPE_UNSPECIFIED = 0; // 未指定
    MESSAGE_TYPE_TEXT = 1; // 文本消息
    }
    //消息类型
    MessageType message_type = 2;

    而描述消息类型时,由于消息类型的值通常只在一个预定义的范围之内,符合枚举特性,因此我们采用枚举来实现。这里我们先简单定义了一个未知类型和文本消息类型。

    需要注意的是,每个枚举定义都必须包含一个映射到零的常量作为其第一个元素,以作为默认值。

    其他的数据类型请参考此表,该表显示了.proto 文件中所支持的数据类型,以及自动生成的对应语言的类中的相应数据类型。

    developers.google.com/protocol-bu…

    分配字段编号

    你可能会觉得奇怪,每个字段后带的那个数字是什么意思。这些其实是每个字段的唯一编号,用于在消息二进制格式中唯一标识我们的字段,一旦该编号被使用,就不应该再更改。

    如果我们在版本迭代中想要删除某个字段,需要确保不会重复使用该字段编号,否则可能会产生诸如数据损坏等严重问题。为了确保不会发生这种状况,我们需要使用reserved标识保留已删除字段的字段编号或名称,如果后续尝试使用这些字段,ProtoBuf编译器将会报错,如下:

    message Message {
    reserved 3, 4 to 6;
    reserved "sender_id ", "target_id ";
    }

    另外一件我们需要了解的事情是,ProtoBuf中1到15范围内的字段编号只占用一个字节进行编码(包括字段编号和字段类型),而16到2047范围内的字段编号则占用两个字节。基于这个特性,我们需要为频繁出现(也即必要字段)的字段保留1到15范围内的字段进行编号,而对于可选字段而采用16到2047范围内的字段进行编号。

    添加注释

    我们还可以向proto文件添加注释,支持// 和 /* ... */ 语法,注释会同样保留到自动生成的对应语言的类中。

    使用ProtoBuf编译器自动生成一个Java类

    一切准备就绪后,我们就可以直接重新构建项目,ProtoBuf编译器会自动根据.proto文件中定义的message,在{module}/build/generated/source/proto/debug/javalite目录下生成对应包名路径的Java类文件,之后只需将该类文件拷贝到src/main/java目录下即可,我们完全可以用Gradle Task帮我们完成这项工作:

    // 是否允许Proto生成DTO类
    def enableGenerateProto = true
    // def enableGenerateProto = false

    project.tasks.whenTaskAdded { Task task ->
    if (task.name == 'generateDebugProto') {
    task.enabled = enableGenerateProto
    if(task.enabled) {
    task.doLast {
    // 复制Build目录下的DTO类到Src目录
    copy {
    from 'build/generated/source/proto/debug/javalite'
    into 'src/main/java'
    }
    // 删除Build目录下的DTO类
    FileTree tree = fileTree("build/generated/source/proto/debug/javalite")
    tree.each{
    file -> delete file
    }
    }
    }
    }
    }

    通过阅读自动生成的MessageDTO.java文件可以看到,Protobuf编译器为每个定义好的数据结构生成了一个Java类,并为访问类中的每个字段提供了sette()r和getter()方法,且提供了Builder类用于创建类的实例。

    用基于Java语言的ProtoBuf API写入和读取消息

    到这里我们先把前面定义好的消息数据结构同步到MessageVO.kt,保持两个实体类的字段一致,至于为什么这样做,而不直接共用一个MessageDTO.java,下一篇文章会解释。

    data class MessageVo(
    var messageId: Long,
    var messageType: Int,
    var sendId: String,
    var targetId: String,
    var timestamp: Long,
    var content: String
    ) : Parcelable {
    constructor(parcel: Parcel) : this(
    parcel.readLong(),
    parcel.readInt(),
    parcel.readString() ?: "",
    parcel.readString() ?: "",
    parcel.readLong(),
    parcel.readString() ?: ""
    ) {
    }

    override fun writeToParcel(parcel: Parcel, flags: Int) {
    parcel.writeLong(messageId)
    parcel.writeInt(messageType)
    parcel.writeString(sendId)
    parcel.writeString(targetId)
    parcel.writeLong(timestamp)
    parcel.writeString(content)
    }

    override fun describeContents(): Int {
    return 0
    }

    companion object CREATOR : Parcelable.Creator<MessageVo> {
    override fun createFromParcel(parcel: Parcel): MessageVo {
    return MessageVo(parcel)
    }

    override fun newArray(size: Int): Array<MessageVo?> {
    return arrayOfNulls(size)
    }
    }

    现在,我们要做的就是以下两件事:

    1. 将来自视图层的MessageVO对象转换为数据传输层MessageDTO对象,并序列化为二进制数据格式进行消息发送。
    2. 接收二进制数据格式的消息,反序列化为MessageDTO对象,并将来自数据传输层的MessageDTO对象转换为视图层的MessageVO对象。

    我们把这部分工作封装到EnvelopHelper类:

    class EnvelopeHelper {
    companion object {
    /**
    * 填充操作(VO->DTO)
    * @param envelope 信封类,包含消息视图对象
    */
    fun stuff(envelope: Envelope): MessageDTO.Message? {
    envelope?.messageVo?.apply {
    return MessageDTO.Message.newBuilder()
    .setMessageId(messageId)
    .setMessageType(MessageDTO.Message.MessageType.forNumber(messageType))
    .setSenderId(sendId)
    .setTargetId(targetId)
    .setTimestamp(timestamp)
    .setContent(ByteString.copyFromUtf8(content))
    .build()
    }
    return null
    }

    /**
    * 提取操作(DTO->VO)
    * @param messageDTO 消息数据传输对象
    */
    fun extract(messageDTO: MessageDTO.Message): Envelope? {
    messageDTO?.apply {
    val envelope = Envelope()
    val messageVo = MessageVo(
    messageId = messageId,
    messageType = messageType.number,
    sendId = senderId,
    targetId = targetId,
    timestamp = timestamp,
    content = String(content.toByteArray())
    )
    envelope.messageVo = messageVo
    return envelope
    }
    return null
    }
    }
    }

    分别在以下两处消息收发的关键节点调用,便可完成对消息传输的序列化反序列化工作:

    MessageAccessService.kt:

    /** 根据MessageCarrier.aidl文件自动生成的Binder对象,需要返回给客户端 */
    private val messageCarrier: IBinder = object : MessageCarrier.Stub() {
    override fun sendMessage(envelope: Envelope) {
    Log.d(TAG, "Send a message: " + envelope.messageVo?.content)
    val messageDTO = EnvelopeHelper.stuff(envelope)
    messageDTO?.let { WebSocketConnection.send(ByteString.of(*it.toByteArray())) }
    ...
    }
    ...
    }

    WebSocketConnection.kt:

    /**
    * 在收到二进制格式消息时调用
    * @param webSocket
    * @param bytes
    */
    override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
    super.onMessage(webSocket, bytes)
    ...
    val messageDTO = MessageDTO.Message.parseFrom(bytes.toByteArray())
    val envelope = EnvelopeHelper.extract(messageDTO)
    Log.d(MessageAccessService.TAG, "Received a message : " + envelope?.messageVo?.content)
    ...
    }

    下一章节预告

    在上面的文章中我们留下了一个疑问,即为何要拆分成MessageVO与MessageDTO两个实体对象?这其实涉及到了DDD(Domain-Driven Design,领域驱动设计)的问题,是为了实现结构分层之后的解耦而设计的,需要在不同的层次使用不同的数据模型。

    不过,像文章中那种使用get/set方式逐一进行字段映射的操作毕竟太过繁琐,且容易出错,因此,下篇文章我们将介绍MapStruct库,以自动化的方式帮我们简化这部分工作,敬请期待。

    收起阅读 »

    Android即时通讯系列文章(2)网络通信协议选型:应以什么样的标准去选择适合你应用的网络通信协议?

    前言在前一篇文章《多进程:为什么要把消息服务拆分到一个独立的进程?》中我们出于保证连接的稳定性的目的,将应用拆分成了「主进程」和「通讯进程」,并为二者定义了相互通信的接口。即便如此,我们也只是实现了客户端一侧的进程间通信,而要实现与完整聊天系统中另一端的角色—...
    继续阅读 »

    前言

    在前一篇文章《多进程:为什么要把消息服务拆分到一个独立的进程?》中我们出于保证连接的稳定性的目的,将应用拆分成了「主进程」和「通讯进程」,并为二者定义了相互通信的接口。即便如此,我们也只是实现了客户端一侧的进程间通信,而要实现与完整聊天系统中另一端的角色——服务端的通信,则需依靠「网络通信协议」来协助完成,在此我们选用的是WebSocket协议。

    什么是WebSocket?

    WebSocket一词,从词面上可以拆解为 Web & Socket 两个单词,Socket我们并不陌生,其是对处于网络中不同主机上的应用进程之间进行双向通信的端点的抽象,是应用程序通过网络协议进行通信的接口,一个Socket对应着通信的一端,由IP地址和端口组合而成。需要注意的是,Socket并不是具体的一种协议,而是一个逻辑上的概念。

    那么WebSocket和Socket之间存在着什么联系呢,是否可以理解为是Socket概念在Web环境的移植呢?为了解答这个疑惑,我们先来回顾一下,在Java平台上进行Socket编程的流程:

    1. 服务端创建ServerSocket实例并绑定本地端口进行监听
    2. 客户端创建Socket实例并指定要连接的服务端的IP地址和端口
    3. 客户端发起连接请求,服务端成功接受之后,双方就建立了一个端对端的TCP连接,在该连接上可以双向通信。而后服务端继续处于监听状态,接受其他客户端的连接请求。

    上述流程还可以简化为:

    1. 服务端监听
    2. 客户端请求
    3. 连接确认

    与之类似,WebSocket服务端与客户端之间的通信过程可以描述为:

    • 服务端创建包含有效主机与端口的WebSocket实例,随后启动并等待客户端连接
    • 客户端创建WebSocket实例,并为该实例提供一个URL,该URL代表希望连接的服务器端点
    • 客户端通过HTTP请求握手建立连接之后,后面就使用刚才发起HTTP请求的TCP连接进行双向通信。

    1.png

    WebSocket协议最初是HTML5规范的一部分,但后来移至单独的标准文档中以使规范集中化,其借鉴了Socket的思想,通过单个TCP连接,为Web浏览器端与服务端之间提供了一种全双工通信机制。WebSocket协议旨在与现有的Web基础体系结构良好配合,基于此设计原则,协议规范定义了WebSocket协议握手流程需借助HTTP协议进行,并被设计工作在与HTTP(80)和HTTPS(443)相同的端口,也支持HTTP代理和中间件,以保证能完全向后兼容。

    由于WebSocket本身只是一个应用层协议,原则上只要遵循这个协议的客户端均可使用,因此我们才得以将其运用到我们的Android客户端。

    什么是全双工通信?

    简单来讲,就是通信双方(客户端和服务端)可同时向对方发送消息。为什么这一点很重要呢?因为传统的基于HTTP协议的通信是单向的,只能由客户端发起,服务端无法主动向客户端推送信息。一旦面临即时通讯这种对数据实时性要求很高的场景,当服务端有数据更新而客户端要获知,就只能通过客户端轮询的方式,具体又可分为以下两种轮询策略:

    • 短轮询

    即客户端定时向服务端发送请求,服务端收到请求后马上返回响应并关闭连接。 优点:实现简单 缺点: 1.并发请求对服务端造成较大压力 2.数据可能没有更新,造成无效请求 3.频繁的网络请求导致客户端设备电量、流量快速消耗 4.定时操作存在时间差,可能造成数据同步不及时 5.每次请求都需要携带完整的请求头

    2.png

    • 长轮询

    即服务端在收到请求之后,如果数据无更新,会阻塞请求,直至数据更新或连接超时才返回。 优点:相较于短轮询减少了HTTP请求的次数,节省了部分资源。 缺点: 1.连接挂起同样会消耗资源 2.冗余请求头问题依旧存在 3.png

    与上述两个方案相比,WebSocket的优势在于,当连接建立之后,后续的数据都是以帧的形式发送。除非某一端主动断开连接,否则无需重新建立连接。因此可以做到:

    1.减轻服务器的负担 2.极大地减少不必要的流量、电量消耗 3.提高实时性,保证客户端和服务端数据的同步 4.减少冗余请求头造成的开销

    4.png

    5.png

    除了WebSocket,实现移动端即时通讯的还有哪些技术?

    • XMPP

    全称(Extensible Messaging and Presence Protocol,可扩展通讯和表示协议),是一种基于XML的协议,它继承了在XML环境中灵活的发展性。 XMPP中定义了三个角色,客户端,服务器,网关。通信能够在这三者的任意两个之间双向发生。服务器同时承担了客户端信息记录,连接管理和信息的路由功能。网关承担着与异构即时通信系统的互联互通,异构系统可以包括SMS(短信),MSN,ICQ等。基本的网络形式是单客户端通过TCP/IP连接到单服务器,然后在之上传输XML。 优点 1.超强的可扩展性。经过扩展以后的XMPP可以通过发送扩展的信息来处理用户的需求。 2.易于解析和阅读。方便了开发和查错。 3.开源。在客户端、服务器、组件、源码库等方面,都已经各自有多种实现。 缺点 1.数据负载太重。过多的冗余标签、低效的解析效率使得XMPP在移动设备上表现不佳。

    应用场景举例:点对点单聊约球

    我刚毕业时入职的公司曾接手开发一个线上足球约战的社交平台APP项目,当时为了提高约球时的沟通效率,考虑为应用引入聊天模块,并优先实现点对点单聊功能。那时市面上的即时通讯SDK方案还尚未成熟,综合当时团队成员的技术栈,决定采用XMPP+Openfire+Smack作为自研技术搭建聊天框架。 Openfire基于XMPP协议,采用Java开发,可用于构建高效的即时通信服务器端,单台服务器可支持上万并发用户。Openfire安装和使用都非常简单,并利用Web进行管理。由于是采用开放的XMPP协议,因此可以使用各种支持XMPP协议的IM客户端软件登录服务。 Smack是一个开源的、易于使用的XMPP客户端Java类库,提供了一套可扩展的API。

    • MQTT

    全称(Message Queuing Telemetry Transport,消息队列遥测传输协议),是一种基于发布/订阅模式的“轻量级”通讯协议,其构建于TCP/IP协议之上。MQTT最大优点在于,可以以极少的代码和有限的带宽,为连接远程设备提供实时可靠的消息服务。作为一种低开销、低带宽占用的即时通讯协议,使其在物联网、小型设备、移动应用等方面有较广泛的应用。 特点 1.基于发布/订阅模型。提供一对多的消息发布,解除应用程序耦合。 2.低开销。MQTT客户端很轻巧,只需要最少的资源,同时MQTT消息头也很小,可以优化网络带宽。 3.可靠的消息传递。MQTT定义了3种消息发布服务质量,以支持消息可靠性:至多一次,至少一次,只有一次。 4.对不可靠网络的支持。专为受限设备和低带宽、高延迟或不可靠的网络而设计。

    应用场景举例:赔率更新、赛事直播聊天室

    我第二家入职的公司的主打产品是一款提供模拟竞猜、赛事直播的体育类APP,其中核心的功能模块就是提供各种赛事的最新比分赔率数据,最初采用的即是上文所说的低效的HTTP轮询方案,效果可想而知。后面技术重构后改用了MQTT,极大地减少了对网络环境的依赖,提高了数据的实时性和可靠性。再往后搭建直播模块时,考虑到聊天室这种一对多的消息发布场景同样适合用MQTT解决,于是沿用了原先的技术方案扩展了新的聊天室模块。

    • WebSocket

    而相较之下,WebSocket的特点包括: 1.**较少的控制开销。**在连接创建后,服务器和客户端之间交换数据时,用于协议控制的数据包头部相对较小。 2.**更好的二进制支持。**Websocket定义了二进制帧,相对HTTP,可以更轻松地处理二进制内容。 3.**可以支持扩展。**Websocket定义了扩展,用户可以扩展协议、实现部分自定义的子协议,如以上所说的XMPP协议、MQTT协议等。

    WebSocket协议在Android客户端的实现

    实现WebSocket协议很简单,广为Android开发者使用的网络请求框架——OkHttp对WebSocket通信流程进行了封装,提供了简明的接口用于WebSocket的连接建立、数据收发、连接保活、连接关闭等,使我们可以专注于业务实现而无须关注通信细节,简单到我们只需要实现以下两步:

    • 创建WebSocket实例并提供一个URL以指定要连接的服务器地址
    • 提供一个WebSocket连接事件监听器,用于监听事件回调以处理连接生命周期的每个阶段

    WebSocket URL的构成与Http URL很相似,都是由协议、主机、端口、路径等构成,区别就是WebSocket URL的协议名采用的是ws://和wss://,wss://表明是安全的WebSocket连接。

    6.png

    首先我们在项目中引入OkHttp库的依赖:

    implementation("com.squareup.okhttp3:okhttp:4.9.0")

    其次,我们须指定要连接的服务器地址,此处可以使用WebSocket的官方服务器地址:

    /** WebSocket服务器地址 */
    private var serverUrl: String = "ws://echo.websocket.org"

    @Synchronized
    fun connect() {
    val request = Request.Builder().url(serverUrl).build()
    val okHttpClient = OkHttpClient.Builder().callTimeout(20, TimeUnit.SECONDS).build()
    ...
    }

    接着,我们调用OkHttpClient实例的newWebSocket(request: Request, listener: WebSocketListener)方法,该方法需传入两个参数,第一个是上文构建的Request对象,第二个是WebSocket连接事件的监听器,WebSocket协议包含四个主要的事件:

    • Open:客户端和服务器之间建立了连接后触发
    • Message:服务端向客户端发送数据时触发。发送的数据可以是纯文本或二进制数据
    • Close:服务端与客户端之间的通信结束时触发。
    • Error:通信过程中发生错误时触发。

    每个事件都通过分别实现对应的回调来进行处理。OkHttp提供的监听器包含以下回调:

    abstract class WebSocketListener {
    open fun onOpen(webSocket: WebSocket, response: Response) {}
    open fun onMessage(webSocket: WebSocket, text: String) {}
    open fun onMessage(webSocket: WebSocket, bytes: ByteString) {}
    open fun onClosing(webSocket: WebSocket, code: Int, reason: String) {}
    open fun onClosed(webSocket: WebSocket, code: Int, reason: String) {}
    open fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {}
    }
    object WebSocketConnection : WebSocketListener()
    @Synchronized
    fun connect() {
    ...
    webSocketClient = okHttpClient.newWebSocket(request, this)
    }
    ...
    }

    以上的事件通常在连接状态发生变化时被动触发,另一方面,如果用户想主动执行某些操作,WebSocket也提供了相应的接口以给用户显式调用。WebSocket协议包含两个主要的操作:

    • send( ) :向服务端发送消息,包括文本或二进制数据
    • close( ):主动请求关闭连接。

    可以看到,OkHttp提供的WebSocket接口也提供了这两个方法:

    interface WebSocket {
    ...
    fun send(text: String): Boolean
    fun send(bytes: ByteString): Boolean
    fun close(code: Int, reason: String?): Boolean
    ...
    }

    当onOpen方法回调时,即是连接建立成功,可以传输数据了。此时我们便可以调用WebSocket实例的send()方法发送文本消息或二进制消息,WebSocket官方服务器会将数据通过onMessage(webSocket: WebSocket, bytes: ByteString)或onMessage(webSocket: WebSocket, text: String)回调原样返回给我们。

    WebSocket是如何建立连接的?

    我们可以通过阅读OkHttp源码获知,newWebSocket(request: Request, listener: WebSocketListener)方法内部是创建了一个RealWebSocket实例,该类是WebSocket接口的实现类,创建实例成功后便调用connect(client: OkHttpClient)方法开始异步建立连接。

    override fun newWebSocket(request: Request, listener: WebSocketListener): WebSocket {
    val webSocket = RealWebSocket(
    taskRunner = TaskRunner.INSTANCE,
    originalRequest = request,
    listener = listener,
    random = Random(),
    pingIntervalMillis = pingIntervalMillis.toLong(),
    extensions = null, // Always null for clients.
    minimumDeflateSize = minWebSocketMessageToCompress
    )
    webSocket.connect(this)
    return webSocket
    }

    连接建立的过程主要是向服务器发送了一个HTTP请求,该请求包含了额外的一些请求头信息:

    val request = originalRequest.newBuilder()
    .header("Upgrade", "websocket")
    .header("Connection", "Upgrade")
    .header("Sec-WebSocket-Key", key)
    .header("Sec-WebSocket-Version", "13")
    .header("Sec-WebSocket-Extensions", "permessage-deflate")
    .build()

    这些请求头的意义如下:

    Connection: Upgrade:表示要升级协议

    Upgrade: websocket:表示要升级到websocket协议。

    Sec-WebSocket-Version:13:表示websocket的版本。如果服务端不支持该版本,需要返回一个Sec-WebSocket-Versionheader,里面包含服务端支持的版本号。

    Sec-WebSocket-Key:与后面服务端响应首部的Sec-WebSocket-Accept是配套的,提供基本的防护,比如恶意的连接,或者无意的连接。

    当返回的状态码为101时,表示服务端同意客户端协议转换请求,并将其转换为Websocket协议,该过程称之为Websocket协议握手(websocket Protocol handshake),协议升级完成后,后续的数据交换则遵照WebSocket的协议。

    前面我们一直说「握手」,握手究竟指的是什么呢?在计算机领域的语境中,握手通常是指确保服务器与其客户端同步的过程。握手是WebSocket协议的基本概念。

    为了直观展示,以上实例中传输的消息均以文本为例,WebSocket还支持二进制数据的传输,而这就要依靠「数据传输协议」来完成了,这是下一篇文章的内容,敬请期待。

    总结

    为了完成与服务端的双向通信,我们选取了WebSocket协议作为网络通信协议,并通过对比传统HTTP协议和其他相关的即时通讯技术,总结出,在为移动设备下应用选择的合适的网络通信协议时,可以有以下的参考标准:

    • 支持全双工通信
    • 支持二进制数据传输
    • 支持扩展
    • 跨语言、跨平台实现

    同时,也对WebSocket协议在Android端的实现提供了示例,并对WebSocket协议握手流程进行了初步窥探,当然,这只是第一步,往后的心跳保活、断线重连、消息队列等每一个都可以单独作为一个课题,会在后面陆续推出的。

    收起阅读 »

    Android即时通讯系列文章番外篇(1)使用Netty框架快速搭设WebSocket服务器

    前言随着本系列所讨论技术点的逐步深入,仅靠之前提到的官方测试服务器已经不能满足我们演示的需要了,于是我们有必要尝试在本地搭建自己的WebSocket服务器,今天这篇文章就是介绍这方面的内容。由于不属于原先的写作计划之内,同时也为了保持系列文章的连贯性,因此特意...
    继续阅读 »

    前言

    随着本系列所讨论技术点的逐步深入,仅靠之前提到的官方测试服务器已经不能满足我们演示的需要了,于是我们有必要尝试在本地搭建自己的WebSocket服务器,今天这篇文章就是介绍这方面的内容。

    由于不属于原先的写作计划之内,同时也为了保持系列文章的连贯性,因此特意将本篇文章命名为「番外篇」。

    Netty简单介绍

    还记得前面的文章「 Android即时通讯系列文章(2)网络通信协议选型:应以什么样的标准去选择适合你应用的网络通信协议?」里我们所提到的吗?WebSocket本身只是一个应用层协议,原则上只要遵循这个协议的客户端/服务端均可使用。对于客户端,前面我们已明确采用OkHttp框架来实现了,而对于服务端,我们则计划采用Netty框架来实现。

    Netty是什么?Netty是一款异步的、基于事件驱动的网络应用程序框架,支持快速开发可维护的、高性能的、面向协议的服务端和客户端。

    Netty封装了Java NIO API的能力,把原本在高负载下繁琐且容易出错的I/O操作,隐藏在一个简单易用的API之下。这无疑对于缺少服务端编程经验的客户端开发人员是非常友好的,只要把Netty的几个核心组件弄明白了,快速搭设一个满足本项目演示需要的WebSocket服务器基本上没什么问题。

    Netty核心组件

    Channel

    Channel是Netty传输API的核心,被用于所有的I/O操作,Channel 接口所提供的API大大降低了Java中直接使用Socket类的复杂性。

    回调

    Netty在内部使用了回调来处理事件,当一个回调被触发时,相关的事件可以交由一个ChannelHandler的实现处理。

    Future

    Future提供了一种在操作完成时通知应用程序的方式,可以看作是一个异步操作的结果的占位符,它将在未来的某个时刻完成,并提供对其结果的访问。

    Netty提供了自己的实现——ChannelFuture,由ChannelFutureListener提供的通知机制消除了手动检查对应操作是否完成的步骤。

    事件和ChannelHandler

    Netty使用不同的事件来通知我们状态的改变,这使得我们能够基于已经发生的事件来触发适当的动作。

    每个事件都可以被分发给ChannelHandler类,ChannelHandler类中提供了自定义的业务逻辑,架构上有助于保持业务逻辑与网络处理代码的分离。

    用IntelliJ IDEA运行Netty的WebSocket演示代码

    众所周知,Android Studio是基于IntelliJ IDEA开发的,因此对于习惯了用Android Studio进行开发的Android开发人员,用起IntelliJ IDEA来也几乎没有任何障碍。本篇的目的是快速搭设WebSocket服务器,因此选择直接将Netty的WebSocket演示代码拉取下来运行。在确保项目能成功运行起来的基础上,再逐步去分析演示代码。

    该演示代码展示的交互效果很简单,跟前面的官方测试服务器一样,当客户端向服务端发送一个消息,服务器都会将消息原原本本地回传给客户端(没错,又是Echo Test。。。)。虽然看起来好像用处不大,但它充分地体现了客户端/服务器系统中典型的请求-响应交互模式。

    接下来我们分别进行两端的工作:

    服务端的工作:

    • IntelliJ IDEA左上角New-Project-Maven创建新工程
    • 拉取Netty的WebSocket演示代码到src目录下
    • 按Alt+Enter快捷键自动导入Netty依赖
    • 运行WebSocketServer类的main()函数

    当控制台输出输出语句,即表示WebSocket服务器成功运行在本机上了:

    Open your web browser and navigate to http://127.0.0.1:8080/

    客户端的工作:

    • 保证手机网络与服务端在同一局域网下
    • 将要连接的WebSocket服务器地址更改为:ws://{服务端IP地址}:8080/websocket
    • 正常发送消息

    从控制台可以看到,客户端成功地与WebSocket服务器建立了连接,并在发送消息后成功收到了服务器的回传消息:

    11.png

    WebSocket演示代码分析

    总的来说,Netty的WebSocket演示代码中包含了两部分核心工作,其分别的意义以及对应的类如下表所示:

    核心工作意义对应的类
    提供ChannelHandler接口实现服务器对从客户端接收的数据的业务逻辑处理WebSocketServerHandler
    ServerBootstrap实例创建配置服务器的启动,将服务器绑定到它要监听连接请求的端口上WebSocketServer

    我们先来看看WebSocketServerHandler类核心工作的主要代码:

    public class WebSocketServerHandler extends SimpleChannelInboundHandler {

    private WebSocketServerHandshaker handshaker;

    // ...省去其他代码

    /**
    * 当有新的消息传入时都会回调
    *
    * @param ctx
    * @param msg
    */
    @Override
    public void channelRead0(ChannelHandlerContext ctx, Object msg) {
    if (msg instanceof FullHttpRequest) {
    handleHttpRequest(ctx, (FullHttpRequest) msg);
    } else if (msg instanceof WebSocketFrame) {
    handleWebSocketFrame(ctx, (WebSocketFrame) msg);
    }
    }

    private void handleHttpRequest(ChannelHandlerContext ctx, FullHttpRequest req) {
    // ...省去其他代码

    // 握手
    WebSocketServerHandshakerFactory wsFactory = new WebSocketServerHandshakerFactory(
    getWebSocketLocation(req), null, true, 5 * 1024 * 1024);
    handshaker = wsFactory.newHandshaker(req);
    if (handshaker == null) {
    WebSocketServerHandshakerFactory.sendUnsupportedVersionResponse(ctx.channel());
    } else {
    handshaker.handshake(ctx.channel(), req);
    }
    }

    private void handleWebSocketFrame(ChannelHandlerContext ctx, WebSocketFrame frame) {
    // ...省去其他代码

    // 对于文本帧和二进制数据帧,将数据简单地回送给了远程节点。
    if (frame instanceof TextWebSocketFrame) {
    // Echo the frame
    ctx.write(frame.retain());
    return;
    }
    if (frame instanceof BinaryWebSocketFrame) {
    // Echo the frame
    ctx.write(frame.retain());
    }
    }

    // ...省去其他代码

    }

    如你所见,为了处理所有接收到的数据,我们重写了WebSocketServerHandler类的channelRead()方法,重写的方法中主要处理了Http请求和WebSocket帧两种类型的数据。

    Http请求类型的数据主要是为了处理客户端的握手建立连接过程,详情可参考前面的文章「 Android即时通讯系列文章(2)网络通信协议选型:应以什么样的标准去选择适合你应用的网络通信协议?」,这里就不再展开讲了。

    而WebSocket帧类型的数据主要是为了处理来自客户端主动发送的消息,我们知道,当WebSocket连接建立之后,后续的数据都是以帧的形式发送。主要包含以下几种类型的帧:

    • 文本帧
    • 二进制帧
    • Ping帧
    • Pong帧
    • 关闭帧

    其中,文本帧与二进制帧同属于消息帧,Ping帧和Ping帧主要用于连接保活,关闭帧则用于关闭连接,我们这里主要关心对消息帧的处理,可以看到,我们只是将数据简单回传回了远端节点,从而实现Echo Test。

    然后,我们再回过头来看WebSocketServer类的核心工作的主要代码:

    ublic final class WebSocketServer {

    // ...省去其他代码
    static final int PORT = Integer.parseInt(System.getProperty("port", SSL? "8443" : "8080"));

    public static void main(String[] args) throws Exception {
    // ...省去其他代码

    EventLoopGroup bossGroup = new NioEventLoopGroup(1);
    EventLoopGroup workerGroup = new NioEventLoopGroup();
    try {
    ServerBootstrap b = new ServerBootstrap();
    b.group(bossGroup, workerGroup)
    .channel(NioServerSocketChannel.class) // 指定所使用的NIO传输Channel
    .childHandler(new WebSocketServerInitializer(sslCtx));

    // 使用指定的端口,异步地绑定服务器;调用sync()方法阻塞等待直到绑定完成
    Channel ch = b.bind(PORT).sync().channel();

    System.out.println("Open your web browser and navigate to " +
    (SSL? "https" : "http") + "://127.0.0.1:" + PORT + '/');

    // 获取Channel的CloseFuture,并且阻塞当前线程直到它完成
    ch.closeFuture().sync();
    } finally {
    // 关闭EventLoopGroup,释放所有的资源
    bossGroup.shutdownGracefully();
    workerGroup.shutdownGracefully();
    }
    }
    }

    我们使用ServerBootstrap引导类来完成Websocket服务器的网络层配置,随后调用bind(int inetPort)方法将进程绑定到某个指定的端口,此过程称之为引导服务器。

    我们是如何将前面定义的WebSocketServerHandler与ServerBootstrap关联起来的呢?关键就在于childHandler(ChannelHandler childHandler)方法。

    每个Channel都拥有一个与之相关联的ChannelPipeline,其持有一个ChannelHandler的实例链。我们需要提供一个ChannelInitializer的实现,并在其initChannel()回调方法中,将包括WebSocketServerHandler在内的一组自定义的ChannelHandler安装到ChannelPipeline中:

    public class WebSocketServerInitializer extends ChannelInitializer {

    // ...省去其他代码

    public WebSocketServerInitializer(SslContext sslCtx) {
    // ...省去其他代码
    }

    @Override
    public void initChannel(SocketChannel ch) throws Exception {
    ChannelPipeline pipeline = ch.pipeline();
    if (sslCtx != null) {
    pipeline.addLast(sslCtx.newHandler(ch.alloc()));
    }
    pipeline.addLast(new HttpServerCodec());
    pipeline.addLast(new HttpObjectAggregator(65536));
    pipeline.addLast(new WebSocketServerHandler());
    }
    }

    将Echo形式改为Broadcast形式

    我们之前讲过,现今主流的IM应用几乎都是采用服务器中转的方式来进行消息传输的,为了更好地实践这种设计,我们进一步来对WebSocket服务器进行改造,把Echo形式改为Broadcast形式,即:

    当接收到某一客户端的一条消息之后,将该消息转发给服务端维护的、除发送方之外的其他客户端连接。

    要实现这一功能我们需要用到ChannelGroup类,ChannelGroup负责跟踪所有活跃中的WebSocket连接,当有新的客户端通过握手成功建立连接后,我们就要把这个新的Channel添加到ChannelGroup中去。

    当接收到了WebSocket消息帧数据后,就调用ChannelGroup的writeAndFlush()方法将消息传输给所有已经连接的WebSocket Channel。

    ChannelGroup还允许传递过滤参数,我们可以以此过滤掉发送方的Channel。

    public class WebSocketServerHandler extends SimpleChannelInboundHandler {

    // ...省去其他代码
    private final ChannelGroup group;

    public WebSocketServerHandler(ChannelGroup group) {
    this.group = group;
    }

    private void handleWebSocketFrame(ChannelHandlerContext ctx, WebSocketFrame frame) {
    // ...省去其他代码
    if (frame instanceof TextWebSocketFrame) {
    // ctx.write(frame.retain());
    group.writeAndFlush(frame.retain(), ChannelMatchers.isNot(ctx.channel()));
    return;
    }
    if (frame instanceof BinaryWebSocketFrame) {
    // ctx.write(frame.retain());
    group.writeAndFlush(frame.retain(), ChannelMatchers.isNot(ctx.channel()));
    }
    }

    @Override
    public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
    if (evt == WebSocketServerProtocolHandler.ServerHandshakeStateEvent.HANDSHAKE_COMPLETE) {
    // 将新的WebSocket Channel添加到ChannelGroup 中,以便它可以接收到所有的消息
    group.add(ctx.channel());
    } else {
    super.userEventTriggered(ctx, evt);
    }
    }
    }


    运行起来之后,让多个客户端连接到此服务器,当客户端中的一个发送了一条消息后,其他连接的客户端会收到由服务器广播的这一条消息:

    12.png 13.png

    相关源码已上传到Github

    总结

    为了满足更多场景的演示需要,我们使用了Netty框架来快速搭建本机的WebSocket服务器。

    我们基于Netty的WebSocket演示代码进行改造,核心工作包括以下两部分:

    • 配置服务器的启动,将服务器绑定到它要监听连接请求的端口上
    • 服务器对从客户端接收的数据的业务逻辑处理

    我们先是以简单的Echo形式实现了客户端/服务器系统中典型的请求/响应交互模式,并进一步改用广播形式,实现了多个用户之间的相互通信。

    收起阅读 »

    为什么说在 Android 中请求权限从来都不是一件简单的事情?

    周末时间参加了东莞和深圳的两场 GDG,因为都是线上参与,所以时间上并不赶,我只需要坐在家里等活动开始就行了。等待的时间一时兴起,突然想写一篇原创,聊一聊我自己在写 Android 权限请求代码时的一些技术心得。正如这篇文章标题所描述的一样,在 Android...
    继续阅读 »

    周末时间参加了东莞和深圳的两场 GDG,因为都是线上参与,所以时间上并不赶,我只需要坐在家里等活动开始就行了。

    等待的时间一时兴起,突然想写一篇原创,聊一聊我自己在写 Android 权限请求代码时的一些技术心得。

    正如这篇文章标题所描述的一样,在 Android 中请求权限从来都不是一件简单的事情。为什么?我认为 Google 在设计运行时权限这块功能时,充分考虑了用户的使用体验,但是却没能充分考虑开发者的编码体验。

    之前在公众号的留言区和大家讨论时,有朋友说:我觉得 Android 提供的运行时权限 API 很好用呀,并没有觉得哪里使用起来麻烦。

    真的是这样吗?我们来看一个具体的例子。

    假设我正在开发一个拍照功能,拍照功能通常都需要用到相机权限和定位权限,也就是说,这两个权限是我实现拍照功能的先决条件,一定要用户同意了这两个权限我才能继续进行拍照。

    那么怎样去申请这两个权限呢?Android 提供的运行时权限 API 相信每个人都很熟悉了,我们自然而然可以写出如下代码:

    class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)
    ActivityCompat.requestPermissions(this,
    arrayOf(Manifest.permission.CAMERA, Manifest.permission.ACCESS_FINE_LOCATION), 1)
    }

    override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) {
    super.onRequestPermissionsResult(requestCode, permissions, grantResults)
    when (requestCode) {
    1 -> {
    var allGranted = true
    for (result in grantResults) {
    if (result != PackageManager.PERMISSION_GRANTED) {
    allGranted = false
    }
    }
    if (allGranted) {
    takePicture()
    } else {
    Toast.makeText(this, "您拒绝了某项权限,无法进行拍照", Toast.LENGTH_SHORT).show()
    }
    }
    }
    }

    fun takePicture() {
    Toast.makeText(this, "开始拍照", Toast.LENGTH_SHORT).show()
    }

    }

    可以看到,这里先是通过调用 requestPermissions() 方法请求相机权限和定位权限,然后在 onRequestPermissionsResult() 方法里监听授权的结果。如果用户同意了这两个权限,那么我们就可以去进行拍照了,如果用户拒绝了任意一个权限,那么弹出一个 Toast 提示,告诉用户某项权限被拒绝了,从而无法进行拍照。

    这种写法麻烦吗?这个就仁者见仁智者见智了,有些朋友可能觉得这也没多少行代码呀,有什么麻烦的。但我个人认为还是比较麻烦的,每次需要请求运行时权限时,我都会觉得很心累,不想写这么啰嗦的代码。

    不过我们暂时不从简易性的角度考虑,从正确性的角度上来讲,这种写法对吗?我认为是有问题的,因为我们在权限被拒绝时只是弹了一个 Toast 来提醒用户,并没有提供后续的操作方案,用户如果真的拒绝了某个权限,应用程序就无法继续使用了。

    因此,我们还需要提供一种机制,当权限被用户拒绝时,可以再次重新请求权限。

    现在我对代码进行如下修改:

    class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)
    requestPermissions()
    }

    override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) {
    super.onRequestPermissionsResult(requestCode, permissions, grantResults)
    when (requestCode) {
    1 -> {
    var allGranted = true
    for (result in grantResults) {
    if (result != PackageManager.PERMISSION_GRANTED) {
    allGranted = false
    }
    }
    if (allGranted) {
    takePicture()
    } else {
    AlertDialog.Builder(this).apply {
    setMessage("拍照功能需要您同意相机和定位权限")
    setCancelable(false)
    setPositiveButton("确定") { _, _ ->
    requestPermissions()
    }
    }.show()
    }
    }
    }
    }

    fun requestPermissions() {
    ActivityCompat.requestPermissions(this,
    arrayOf(Manifest.permission.CAMERA, Manifest.permission.ACCESS_FINE_LOCATION), 1)
    }

    fun takePicture() {
    Toast.makeText(this, "开始拍照", Toast.LENGTH_SHORT).show()
    }

    }

    这里我将请求权限的代码提取到了一个 requestPermissions() 方法当中,然后在 onRequestPermissionsResult() 里判断,如果用户拒绝了某项权限,那么就弹出一个对话框,告诉用户相机和定位权限是必须的,然后在 setPositiveButton 的点击事件中调用 requestPermissions() 方法重新请求权限。

    我们来看一下现在的运行效果:

    可以看到,现在我们对权限被拒绝的场景进行了更加充分的考虑。

    那么现在这种写法,是不是就将请求运行时权限的各种场景都考虑周全了呢?其实还没有,因为 Android 权限系统还提供了一种非常 “恶心” 的机制,叫拒绝并不再询问。

    当某个权限被用户拒绝了一次,下次我们如果再申请这个权限的话,界面上会多出一个拒绝并不再询问的选项。只要用户选择了这一项,那么完了,我们之后都不能再去请求这个权限了,因为系统会直接返回我们权限被拒绝。

    这种机制对于用户来说非常友好,因为它可以防止一些恶意软件流氓式地无限重复申请权限,从而严重骚扰用户。但是对于开发者来说,却让我们苦不堪言,如果我的某项功能就是必须依赖于这个权限才能运行,现在用户把它拒绝并不再询问了,我该怎么办?

    当然,绝大多数的用户都不是傻 X,当然知道拍照功能需要用到相机权限了,相信 99% 的用户都会点击同意授权。但是我们可以不考虑那剩下 1% 的用户吗?不可以,因为你们公司的测试就是那 1% 的用户,他们会进行这种傻 X 式的操作。

    也就是说,即使只为了那 1% 的用户,为了这种不太可能会出现的操作方式,我们在程序中还是得要将这种场景充分考虑进去。

    那么,权限被拒绝且不再询问了,我们该如何处理呢?比较通用的处理方式就是提醒用户手动去设置当中打开权限,如果想做得再好一点,可以提供一个自动跳转到当前应用程序设置界面的功能。

    下面我们就来针对这种场景进行完善,如下所示:

    class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)
    requestPermissions()
    }

    override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) {
    super.onRequestPermissionsResult(requestCode, permissions, grantResults)
    when (requestCode) {
    1 -> {
    val denied = ArrayList()
    val deniedAndNeverAskAgain = ArrayList()
    grantResults.forEachIndexed { index, result ->
    if (result != PackageManager.PERMISSION_GRANTED) {
    if (ActivityCompat.shouldShowRequestPermissionRationale(this, permissions[index])) {
    denied.add(permissions[index])
    } else {
    deniedAndNeverAskAgain.add(permissions[index])
    }
    }
    }
    if (denied.isEmpty() && deniedAndNeverAskAgain.isEmpty()) {
    takePicture()
    } else {
    if (denied.isNotEmpty()) {
    AlertDialog.Builder(this).apply {
    setMessage("拍照功能需要您同意相册和定位权限")
    setCancelable(false)
    setPositiveButton("确定") { _, _ ->
    requestPermissions()
    }
    }.show()
    } else {
    AlertDialog.Builder(this).apply {
    setMessage("您需要去设置当中同意相册和定位权限")
    setCancelable(false)
    setPositiveButton("确定") { _, _ ->
    val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
    val uri = Uri.fromParts("package", packageName, null)
    intent.data = uri
    startActivityForResult(intent, 1)
    }
    }.show()
    }
    }
    }
    }
    }

    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
    super.onActivityResult(requestCode, resultCode, data)
    when (requestCode) {
    1 -> {
    requestPermissions()
    }
    }
    }

    fun requestPermissions() {
    ActivityCompat.requestPermissions(this,
    arrayOf(Manifest.permission.CAMERA, Manifest.permission.ACCESS_FINE_LOCATION), 1)
    }

    fun takePicture() {
    Toast.makeText(this, "开始拍照", Toast.LENGTH_SHORT).show()
    }

    }

    现在代码已经变得比较长了,我还是带着大家来梳理一下。

    这里我在 onRequestPermissionsResult() 方法中增加了 denied 和 deniedAndNeverAskAgain 两个集合,分别用于记录拒绝和拒绝并不再询问的权限。如果这两个集合都为空,那么说明所有权限都被授权了,这时就可以直接进行拍照了。

    而如果 denied 集合不为空,则说明有权限被用户拒绝了,这时候我们还是弹出一个对话框来提醒用户,并重新申请权限。而如果 deniedAndNeverAskAgain 不为空,说明有权限被用户拒绝且不再询问,这时就只能提示用户去设置当中手动打开权限,我们编写了一个 Intent 来执行跳转逻辑,并在 onActivityResult() 方法,也就是用户从设置回来的时候重新申请权限。

    那么现在运行一下程序,效果如下图所示:

    可以看到,当我们第一次拒绝权限的时候,会提醒用户,相机和定位权限是必须的。而如果用户继续置之不理,选择拒绝并不再询问,那么我们将提醒用户,他必须手动开户这些权限才能继续运行程序。

    到现在为止,我们才算是把一个 “简单” 的权限请求流程用比较完善的方式处理完毕。然而代码写到这里真的还算是简单吗?每次申请运行时权限,都要写这么长长的一段代码,你真的受得了吗?

    这也就是我编写 PermissionX 这个开源库的原因,在 Android 中请求权限从来都不是一件简单的事情,但它不应该如此复杂

    PermissionX 将请求运行时权限时那些应该考虑的复杂逻辑都封装到了内部,只暴露最简单的接口给开发者,从而让大家不需要考虑上面我所讨论的那么多场景。

    而我们使用 PermissionX 来实现和上述一模一样的功能,只需要这样写就可以了:

    class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)
    PermissionX.init(this)
    .permissions(Manifest.permission.CAMERA, Manifest.permission.ACCESS_FINE_LOCATION)
    .onExplainRequestReason { scope, deniedList ->
    val message = "拍照功能需要您同意相册和定位权限"
    val ok = "确定"
    scope.showRequestReasonDialog(deniedList, message, ok)
    }
    .onForwardToSettings { scope, deniedList ->
    val message = "您需要去设置当中同意相册和定位权限"
    val ok = "确定"
    scope.showForwardToSettingsDialog(deniedList, message, ok)
    }
    .request { _, _, _ ->
    takePicture()
    }
    }

    fun takePicture() {
    Toast.makeText(this, "开始拍照", Toast.LENGTH_SHORT).show()
    }

    }

    可以看到,请求权限的代码一下子变得极其精简。

    我们只需要在 permissions() 方法中传入要请求的权限名,在 onExplainRequestReason() 和 onForwardToSettings() 回调中填写对话框上的提示信息,然后在 request() 回调中即可保证已经得到了所有请求权限的授权,调用 takePicture() 方法开始拍照即可。

    通过这样的直观对比大家应该能感受到 PermissionX 所带来的便利了吧?上面那段长长的请求权限的代码我真的是为了给大家演示才写的,而我再也不想写第二遍了。

    收起阅读 »

    Compose Text简单使用

    Text控件的相关API说明 Compose中的Text就等价于Android原生中的TextView,API也比较简单: fun Text( text: String, // 文字内容,可以直接传递字符串,也可以使用stringResource(...
    继续阅读 »

    Text控件的相关API说明


    Compose中的Text就等价于Android原生中的TextView,API也比较简单:


    fun Text(
    text: String, // 文字内容,可以直接传递字符串,也可以使用stringResource(id = R.string.hello)来指定
    modifier: Modifier = Modifier, // 修饰符,可以指定宽高,背景,点击事件等。
    color: Color = Color.Unspecified, // 文字颜色
    fontSize: TextUnit = TextUnit.Unspecified, // 文字大小
    fontStyle: FontStyle? = null, // 文字样式,比如斜体
    fontWeight: FontWeight? = null, // 字体宽度,比如粗体
    fontFamily: FontFamily? = null, // 字体样式,比如SansSerif,Serif等
    letterSpacing: TextUnit = TextUnit.Unspecified, // 字符间距
    textDecoration: TextDecoration? = null, // 装饰物,比如添加下划线
    textAlign: TextAlign? = null, // 文字对齐方式,比如居中对齐
    lineHeight: TextUnit = TextUnit.Unspecified, // 行高
    overflow: TextOverflow = TextOverflow.Clip, // 文字溢出的展示方式,比如裁剪,或末尾显示...等
    softWrap: Boolean = true, // 文字过长是否换行
    maxLines: Int = Int.MAX_VALUE, // 最大行数
    onTextLayout: (TextLayoutResult) -> Unit = {}, // 布局变化的回调
    style: TextStyle = LocalTextStyle.current // 设置Style,类似TextView的style
    )

    TextStyle的API,内容跟Text里面的大部分相同,具体可以查看相关API


    基础示例


    我们来个小Demo


    @Composable
    fun TextDemo() {
    val text = "this is compose text demo, which likes TextView in android native xml layout"
    Text(
    text = text, // 文字
    color = Color.Green, // 字体颜色
    fontSize = 16.sp, // 字体大小
    fontStyle = FontStyle.Italic, // 斜体
    fontWeight = FontWeight.Bold, // 粗体
    textAlign = TextAlign.Center, // 对齐方式: 居中对齐
    modifier = Modifier.width(300.dp), // 指定宽度为300dp
    maxLines = 2, // 最大行数
    overflow = TextOverflow.Ellipsis, // 文字溢出后就裁剪
    softWrap = true, // 文字过长时是否换行
    textDecoration = TextDecoration.Underline, // 文字装饰,这里添加下划线
    )
    }

    效果如下:


    示例


    然后我们加上字体样式:


    fontFamily = FontFamily.Cursive, // 字体样式

    效果如下:


    示例


    我们再加上行高和字符间距:


    lineHeight = 40.sp, // 行高40sp
    letterSpacing = 5.sp // 字符间距5sp

    效果如下:


    示例


    富文本


    使用原生的TextView如果想要实现富文本,需要使用Spanable,而且需要计算文字的下标,非常麻烦,Compose的就相当好用了。


    1 使用SpanStyle来实现富文本

    API如下:


    class SpanStyle(
    val color: Color = Color.Unspecified, // 文字颜色
    val fontSize: TextUnit = TextUnit.Unspecified, // 文字大小
    val fontWeight: FontWeight? = null, // 字体粗细,比如粗体
    val fontStyle: FontStyle? = null, // 文字样式,比如斜体
    val fontSynthesis: FontSynthesis? = null, // 指定的字体找不到时,所采用的策略
    val fontFamily: FontFamily? = null, // 字体样式,比如Serif
    val fontFeatureSettings: String? = null, // 字体的排印设置,可以取CSS中font-feature-settings的值
    val letterSpacing: TextUnit = TextUnit.Unspecified, // 字符间距
    val baselineShift: BaselineShift? = null, // 文字举例baseline的像上偏移量
    val textGeometricTransform: TextGeometricTransform? = null, // 用于几何变换,比如缩放、倾斜等
    val localeList: LocaleList? = null, // 国际化相关符号列表
    val background: Color = Color.Unspecified, // 背景色
    val textDecoration: TextDecoration? = null, // 装饰,比如下划线
    val shadow: Shadow? = null // 阴影
    )

    直接看Demo:


    @Composable
    fun TextDemo2() {
    Text(buildAnnotatedString {
    // 使用白色背景,红色字体,18sp,Monospace字体来绘制"Hello " (注意后面有个空格)
    withStyle(style = SpanStyle(color = Color.Red, background = Color.White, fontSize = 18.sp, fontFamily = FontFamily.Monospace)) {
    append("Hello ")
    }
    // 正常绘制"World"
    append("World ")
    // 使用黄色背景,绿色字体,18sp,Serif字体,W900粗体来绘制"Click"
    withStyle(style = SpanStyle(color = Color.Green, background = Color.Yellow, fontSize = 30.sp, fontFamily = FontFamily.Serif, fontWeight = FontWeight.W900)) {
    append("Click")
    }
    // 正常绘制" Me" (注意前面有个空格)
    append(" Me")

    // 添加阴影及几何处理
    withStyle(
    style = SpanStyle(
    color = Color.Yellow,
    background = Color.White,
    baselineShift = BaselineShift(1.0f), // 向BaseLine上偏移10
    textGeometricTransform = TextGeometricTransform(scaleX = 2.0F, skewX = 0.5F), // 水平缩放2.0,并且倾斜0.5
    shadow = Shadow(color = Color.Blue, offset = Offset(x = 1.0f, y = 1.0f), blurRadius = 10.0f) // 添加音阴影和模糊处理
    )
    ) {
    append(" Effect")
    }
    })
    }

    其中buildAnnotatedString()可以理解为构建了一个作用域,在该作用域内可以使用withStyle(style)来指定文字格式,效果如下:


    示例


    2 使用ParagraphStyle来实现段落

    API如下:


    class ParagraphStyle constructor(
    val textAlign: TextAlign? = null, // 对齐方式
    val textDirection: TextDirection? = null, // 文字方向
    val lineHeight: TextUnit = TextUnit.Unspecified, //行高
    val textIndent: TextIndent? = null // 缩进方式
    )

    直接看Demo:


    @Composable
    fun TextDemo3() {
    Text(buildAnnotatedString {
    // 指定对齐方式为Start,通过textIndent指定第一行每段第一行缩进32sp,其余行缩进8sp
    withStyle(style = ParagraphStyle(textAlign = TextAlign.Start, textIndent = TextIndent(firstLine = 32.sp, restLine = 8.sp))) {

    // 第一段,因为只有一行,所以直接缩进32sp
    withStyle(style = SpanStyle(color = Color.Red)) {
    append("Hello, this is first paragraph\n")
    }
    // 第二段(第一行会缩进32sp,后续每行会缩进8sp)
    withStyle(style = SpanStyle(color = Color.Green, fontWeight = FontWeight.Bold)) {
    append("Hello, this is second paragraph,very long very long very long very long very long very long very long very long very long very long\n")
    }
    // 第三段,因为只有一行,所以直接缩进32sp
    append("Hello, this is third paragraph\n")
    }
    })
    }

    效果如下:


    示例


    交互


    传统的Android的TextView可以实现选中/不可选中,但是却很难实现部分可选中,部分不可选中;传统的TextView可以设置点击事件,但是很难实现获取点击文字的位置,这些在Compose中都不是事。


    1 可选中和不可选中

    我们可以直接使用SelectionContainer来包括可以选中的文本,使用DisableSelection来包括不可选中的文本,eg:


    @Composable
    fun TextDemo4() {
    // 设置可选区域
    SelectionContainer {
    // Column等价于竖直的LinearLayout
    Column {
    Text(text = "可以选中我,可以选中我,可以选中我")

    // 设置不可选区域
    DisableSelection {
    Text(text = "选不中我,选不中我,选不中")
    }

    // 位于可选区域内,可选
    Text(text = "可以选中我,可以选中我,可以选中我")
    }
    }
    }

    效果如下:


    示例


    2 单个文字响应点击事件

    我们可以直接使用ClickableText来实现点个文字的点击效果,API如下:


    fun ClickableText(
    text: AnnotatedString, // 传入的文字,这里必须传入AnnotatedString
    modifier: Modifier = Modifier, // 修饰符
    style: TextStyle = TextStyle.Default, // 文本Style
    softWrap: Boolean = true, // 文本长度过长时,是否换行
    overflow: TextOverflow = TextOverflow.Clip, // 文字超出显示范围的处理方式,默认Clip,就是不显示
    maxLines: Int = Int.MAX_VALUE, // 最大行数
    onTextLayout: (TextLayoutResult) -> Unit = {}, // 布局发生变化的回调
    onClick: (Int) -> Unit // 点击事件,参数为点击文字的下标
    )

    Demo如下:


    @Composable
    fun TextDemo5(context: Context) {
    ClickableText(text = AnnotatedString("请点击我"), onClick = { index ->
    Toast.makeText(context, "点击位置:$index", Toast.LENGTH_SHORT).show()
    })
    }

    效果如下:


    示例


    如果要给整个Text()设置点击事件,直接使用Modifier.clickable{}即可。


    3 给指定文字添加注解(超链接)

    我们可以使用pushStringAnnotation()和pop()函数对来给指定文字添加注解,如下:


    @Composable
    fun TextDemo6(context: Context) {

    // 构建注解文本
    val url_tag = "article_url";
    val articleText = buildAnnotatedString {
    append("点击")

    // pushStringAnnotation()表示开始添加注解,可以理解为构造了一个<tag,annotation>的映射
    pushStringAnnotation(tag = url_tag, annotation = "https://devloper.android.com")
    // 要添加注解的文本为"打开本文"
    withStyle(style = SpanStyle(color = Color.Blue, fontWeight = FontWeight.Bold)) {
    append("展示Android官网")
    }
    // pop()表示注解结束
    pop()
    }

    // 构造可点击文本
    ClickableText(text = articleText, onClick = { index ->
    // 根据tag取出annotation并打印
    articleText.getStringAnnotations(tag = url_tag, start = index, end = index).firstOrNull()?.let { annotation ->
    Toast.makeText(context, "点击了:${annotation.item}", Toast.LENGTH_SHORT).show()
    }
    })
    }

    效果如下:


    示例


    Demo可在这里下载: gitee.com/lloydfinch/…


    当然,Text的用法远不止此,更多的用法可以查看官方API即可。



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

    WindowInspector(窗口检查器)出来两年了,还不了解?!!

    前言这个知识点,出来两年了,现在在网上搜索,没有看到相关分享。一个非常好用的Api,Android 10 才增加的,解决悬浮窗口的一个痛点,下面把我的经验跟大家分享一下,希望大能够受用。悬浮窗口的痛点(View is attach)为什么说 “View is ...
    继续阅读 »

    前言

    这个知识点,出来两年了,现在在网上搜索,没有看到相关分享。一个非常好用的Api,Android 10 才增加的,解决悬浮窗口的一个痛点,下面把我的经验跟大家分享一下,希望大能够受用。

    悬浮窗口的痛点(View is attach)

    为什么说 “View is attach的判断是悬浮窗口的痛点”?

    在Android 10 之前

    WinodwManager 提供了 addView、removeView 操作,没有查询接口,如何判断view是否被add,所能想到的方式就是view is attach。加上 “ View not attached to window manager” 的 源码log,使开发者确信,通过View.isAttachedToWindow判断,就可以判断 view是否在window上,没有调查使用中可能出现的风险。

    可悲的是,没有其他好的Api之前,使用View.isAttachedToWindow 也几乎变成了一个唯一的选择。

    Android 10 推了新的API,让开发者多了一个更好的选择。

    通过一个案例,来说明这个API,并解决这个痛点。

    案例回顾

    悬浮窗口的使用

    windowManager.addView(view, layoutParams);//新增窗口
    windowManager.removeView(view);移除窗口

    但新增或者移除容易导致Crash,log如下

    如:同一个view 被add 两次

    05-21 03:19:13.285 3463 3463 W System.err: java.lang.IllegalStateException: View XXX{7afdd92 V.E...... ......I. 0,0-56,290} has already been added to the window manager.

    05-21 03:19:13.285 3463 3463 W System.err: at android.view.WindowManagerGlobal.addView(WindowManagerGlobal.java:359)

    05-21 03:19:13.285 3463 3463 W System.err: at android.view.WindowManagerImpl.addView(WindowManagerImpl.java:96)

    移除一个,没有Attach 的窗口

    W System.err: java.lang.IllegalArgumentException: View=XXXX{25626d6 V.E...... ........ 0,0-56,229} not attached to window manager

    08-19 07:08:08.832 25836 25836 W System.err: at android.view.WindowManagerGlobal.findViewLocked(WindowManagerGlobal.java:517)

    08-19 07:08:08.832 25836 25836 W System.err: at android.view.WindowManagerGlobal.removeView(WindowManagerGlobal.java:426)

    08-19 07:08:08.832 25836 25836 W System.err: at android.view.WindowManagerImpl.removeView(WindowManagerImpl.java:123)

    通常的解决方式:log提示 not attached -> 刚好可以使用View.isAttachedToWindow 去判断代码如下:

    if(view.isAttachedToWindow()){
    try {
    windowManager.removeView(textView);
    } catch (Exception e) {
    e.printStackTrace();
    }
    }
    if(!view.isAttachedToWindow()){
    try {
    windowManager.addView(view,layoutParams);
    } catch (Exception e) {
    e.printStackTrace();
    }
    }

    遇到的问题

    使用以上代码,但在项目中还是有极低概率还是会出现crash。

    也就是 isAttachedToWindow ≠ view is add window

    解决问题

    • 方案一:

      应用中给View标志位,表示view 是否被add。但是总觉得本地标识,没有系统api准确,该方案不算太好。偶现的问题,必然有必现的过程,后面进行了深入的分析。

      项目中遇到的问题是:同一个view,windowManager.addView 被连续执行了两次,也不是异步造成的。

      最后发现,两次调用时间间隔极短,isAttachedToWindow 还没有来得及改变。

      经详细调查有了方案二。

    • 方案二:

      使用 WindowInspector,此类在Android 10 framework 才增加,且只有getGlobalWindowViews 这一个静态方法

      view.isAttachedToWindow() 改为 WindowInspector.getGlobalWindowViews().contains(view)

    源码分析

    getGlobalWindowViews

    看下主要类的调用关系,以及主要的方法

    RmzUoV.png

    /**
    * addView 与 removeView 都会调用此方法
    * addView required:false 如果 index !=0 同一个View重复add,抛异常
    * removeView required:true index < 0 ,没有找到View,抛异常
    */
    private int findViewLocked(View view, boolean required) {
    final int index = mViews.indexOf(view);
    if (required && index < 0) {
    throw new IllegalArgumentException("View=" + view + " not attached to window manager");
    }
    return index;
    }

    通过以上分析:WindowManagerGlobal 中 mView 是问题的关键,管理着所属应用的所有View。

    Android 10,提供了 WindowInspector.getGlobalWindowViews()。可以获取mViews。修改代码如下

    if(WindowInspector.getGlobalWindowViews().contains(view)){
    windowManager.removeView(view);
    }
    if(!WindowInspector.getGlobalWindowViews().contains(view)){
    windowManager.addView(view,layoutParams);
    }

    分析到这里,问题就解决了。那isAttachedToWindow 怎么就不行呢?

    isAttachedToWindow

    以下时序图看出:

    当刷新线程走完后,才认为是attach。

    在这里插入图片描述

    两者区别是:attach 当view 被add ,且被刷新了。

    总结

    经过上文分析,从view add -> attach , 区别是view 是否被刷新。

    分析了下:为什么要重新用WindowInspector,而不是用WIndowManager WindowManager:是对Window 的管理,查询觉得不合适吧,所以用了WindowInspector类

    下面说明下可能出现的一些误区

    1、getGlobalWindowViews 获取的View列表 ,其实是WindowManagerGlobal.mViews 的浅拷贝,所以增删改,都不会应该WindowManagerGlobal 中mViews的结构。

    2、强调下,不要被Global迷惑,Global的范围是应用级别的,获取不到其他应用的窗口。

    收起阅读 »